@krodak/clickup-cli 1.44.0 → 1.45.1
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/.claude-plugin/plugin.json +1 -1
- package/README.md +1 -0
- package/dist/chunk-OPPKVJ6P.js +349 -0
- package/dist/index.js +1299 -76
- package/dist/writer-UZHLSQMW.js +19 -0
- package/package.json +1 -1
- package/skills/clickup-cli/SKILL.md +20 -2
package/dist/index.js
CHANGED
|
@@ -1,8 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
backfillAttachments,
|
|
4
|
+
formatDate,
|
|
5
|
+
formatDateISO,
|
|
6
|
+
formatDuration,
|
|
7
|
+
formatLongDuration,
|
|
8
|
+
formatTimestamp,
|
|
9
|
+
readBundleData,
|
|
10
|
+
renderBundleMarkdown,
|
|
11
|
+
writeBundleData
|
|
12
|
+
} from "./chunk-OPPKVJ6P.js";
|
|
2
13
|
|
|
3
14
|
// src/index.ts
|
|
4
15
|
import { realpathSync as realpathSync2 } from "fs";
|
|
5
|
-
import { basename, resolve } from "path";
|
|
16
|
+
import { basename, resolve as resolve2 } from "path";
|
|
6
17
|
import { Command } from "commander";
|
|
7
18
|
import { createRequire } from "module";
|
|
8
19
|
import { fileURLToPath } from "url";
|
|
@@ -81,10 +92,12 @@ function normalizeViewId(input) {
|
|
|
81
92
|
var ClickUpClient = class {
|
|
82
93
|
apiToken;
|
|
83
94
|
teamId;
|
|
95
|
+
rateLimiter;
|
|
84
96
|
meCache = null;
|
|
85
97
|
constructor(config) {
|
|
86
98
|
this.apiToken = config.apiToken;
|
|
87
99
|
this.teamId = config.teamId;
|
|
100
|
+
this.rateLimiter = config.rateLimiter;
|
|
88
101
|
}
|
|
89
102
|
taskPath(taskId, suffix = "") {
|
|
90
103
|
const normalized = normalizeTaskId(taskId);
|
|
@@ -102,7 +115,7 @@ var ClickUpClient = class {
|
|
|
102
115
|
return "";
|
|
103
116
|
}
|
|
104
117
|
sleep(ms) {
|
|
105
|
-
return new Promise((
|
|
118
|
+
return new Promise((resolve3) => setTimeout(resolve3, ms));
|
|
106
119
|
}
|
|
107
120
|
retryDelayMs(res, attempt) {
|
|
108
121
|
const retryAfter = res.headers.get("retry-after");
|
|
@@ -118,9 +131,11 @@ var ClickUpClient = class {
|
|
|
118
131
|
const maxRetries = 3;
|
|
119
132
|
let attempt = 0;
|
|
120
133
|
for (; ; ) {
|
|
134
|
+
await this.rateLimiter?.acquire();
|
|
121
135
|
const res = await fetch(url, { ...init, signal: AbortSignal.timeout(3e4) });
|
|
122
136
|
const retryable = res.status === 429 || res.status === 502 || res.status === 503 || res.status === 504;
|
|
123
137
|
if (!retryable || attempt >= maxRetries) return res;
|
|
138
|
+
if (res.status === 429) this.rateLimiter?.penalize();
|
|
124
139
|
attempt++;
|
|
125
140
|
const delayMs = this.retryDelayMs(res, attempt);
|
|
126
141
|
process.stderr.write(
|
|
@@ -236,6 +251,7 @@ var ClickUpClient = class {
|
|
|
236
251
|
subtasks: String(filters.subtasks ?? true)
|
|
237
252
|
});
|
|
238
253
|
if (filters.includeClosed) baseParams.set("include_closed", "true");
|
|
254
|
+
if (filters.archived) baseParams.set("archived", "true");
|
|
239
255
|
if (!filters.all) {
|
|
240
256
|
const me = await this.getMe();
|
|
241
257
|
baseParams.append("assignees[]", String(me.id));
|
|
@@ -280,10 +296,40 @@ var ClickUpClient = class {
|
|
|
280
296
|
const data = await this.request(this.taskPath(taskId, "/comment"));
|
|
281
297
|
return readCollectionField(data, "comments", "task comments");
|
|
282
298
|
}
|
|
299
|
+
/**
|
|
300
|
+
* Every comment on a task. ClickUp returns 25 per call and pages with
|
|
301
|
+
* `start` (the oldest returned comment's date) + `start_id`; the cursor
|
|
302
|
+
* comment is repeated on the next page, so results are deduped by id.
|
|
303
|
+
*/
|
|
304
|
+
async getAllTaskComments(taskId) {
|
|
305
|
+
const PAGE_SIZE = 25;
|
|
306
|
+
const seen = /* @__PURE__ */ new Set();
|
|
307
|
+
const all = [];
|
|
308
|
+
let cursor;
|
|
309
|
+
for (; ; ) {
|
|
310
|
+
const qs = cursor ? `?start=${encodeURIComponent(cursor.start)}&start_id=${encodeURIComponent(cursor.startId)}` : "";
|
|
311
|
+
const data = await this.request(
|
|
312
|
+
this.taskPath(taskId, `/comment${qs}`)
|
|
313
|
+
);
|
|
314
|
+
const page = readCollectionField(data, "comments", "task comments");
|
|
315
|
+
let added = 0;
|
|
316
|
+
for (const c of page) {
|
|
317
|
+
if (seen.has(c.id)) continue;
|
|
318
|
+
seen.add(c.id);
|
|
319
|
+
all.push(c);
|
|
320
|
+
added++;
|
|
321
|
+
}
|
|
322
|
+
if (page.length < PAGE_SIZE || added === 0) break;
|
|
323
|
+
const last = page[page.length - 1];
|
|
324
|
+
cursor = { start: last.date, startId: last.id };
|
|
325
|
+
}
|
|
326
|
+
return all;
|
|
327
|
+
}
|
|
283
328
|
async getTasksFromList(listId, params = {}, options = {}) {
|
|
284
329
|
return this.paginate((page) => {
|
|
285
330
|
const base = { subtasks: "true", page: String(page), ...params };
|
|
286
331
|
if (options.includeClosed) base["include_closed"] = "true";
|
|
332
|
+
if (options.archived) base["archived"] = "true";
|
|
287
333
|
const qs = new URLSearchParams(base).toString();
|
|
288
334
|
return `/list/${listId}/task?${qs}`;
|
|
289
335
|
});
|
|
@@ -291,6 +337,12 @@ var ClickUpClient = class {
|
|
|
291
337
|
async getTask(taskId) {
|
|
292
338
|
return this.request(this.taskPath(taskId, "?include_markdown_description=true"));
|
|
293
339
|
}
|
|
340
|
+
/** Full task for archival: markdown description plus the direct subtask list. */
|
|
341
|
+
async getTaskForExport(taskId) {
|
|
342
|
+
return this.request(
|
|
343
|
+
this.taskPath(taskId, "?include_markdown_description=true&include_subtasks=true")
|
|
344
|
+
);
|
|
345
|
+
}
|
|
294
346
|
/**
|
|
295
347
|
* Resolve any accepted task-id form to a native ClickUp task id.
|
|
296
348
|
* - Task URLs are reduced to their id segment.
|
|
@@ -764,6 +816,23 @@ var ClickUpClient = class {
|
|
|
764
816
|
const data = await this.requestV3(`/workspaces/${workspaceId}/docs`);
|
|
765
817
|
return readCollectionField(data, "docs", "docs");
|
|
766
818
|
}
|
|
819
|
+
/** Every doc in the workspace, following v3 `next_cursor` pagination. */
|
|
820
|
+
async getAllDocs(workspaceId, options = {}) {
|
|
821
|
+
const all = [];
|
|
822
|
+
let cursor;
|
|
823
|
+
for (; ; ) {
|
|
824
|
+
const params = new URLSearchParams({ limit: "50" });
|
|
825
|
+
if (options.archived) params.set("archived", "true");
|
|
826
|
+
if (cursor) params.set("next_cursor", cursor);
|
|
827
|
+
const data = await this.requestV3(
|
|
828
|
+
`/workspaces/${workspaceId}/docs?${params.toString()}`
|
|
829
|
+
);
|
|
830
|
+
all.push(...readCollectionField(data, "docs", "docs"));
|
|
831
|
+
if (!data.next_cursor) break;
|
|
832
|
+
cursor = data.next_cursor;
|
|
833
|
+
}
|
|
834
|
+
return all;
|
|
835
|
+
}
|
|
767
836
|
async getDocPage(workspaceId, docId, pageId) {
|
|
768
837
|
return this.requestV3(
|
|
769
838
|
`/workspaces/${workspaceId}/docs/${docId}/pages/${pageId}?content_format=text/md`
|
|
@@ -1562,50 +1631,6 @@ function writeConfig(config, profileName) {
|
|
|
1562
1631
|
saveMultiProfileConfig(multi);
|
|
1563
1632
|
}
|
|
1564
1633
|
|
|
1565
|
-
// src/date.ts
|
|
1566
|
-
function formatDate(ms) {
|
|
1567
|
-
return new Date(Number(ms)).toLocaleDateString("en-US", {
|
|
1568
|
-
month: "short",
|
|
1569
|
-
day: "numeric",
|
|
1570
|
-
year: "numeric"
|
|
1571
|
-
});
|
|
1572
|
-
}
|
|
1573
|
-
function formatTimestamp(ms) {
|
|
1574
|
-
return new Date(Number(ms)).toLocaleString("en-US", {
|
|
1575
|
-
month: "short",
|
|
1576
|
-
day: "numeric",
|
|
1577
|
-
hour: "numeric",
|
|
1578
|
-
minute: "2-digit"
|
|
1579
|
-
});
|
|
1580
|
-
}
|
|
1581
|
-
function formatDuration(ms) {
|
|
1582
|
-
const totalMinutes = Math.round(Math.abs(ms) / 6e4);
|
|
1583
|
-
const hours = Math.floor(totalMinutes / 60);
|
|
1584
|
-
const minutes = totalMinutes % 60;
|
|
1585
|
-
if (hours > 0 && minutes > 0) return `${hours}h ${minutes}m`;
|
|
1586
|
-
if (hours > 0) return `${hours}h`;
|
|
1587
|
-
return `${minutes}m`;
|
|
1588
|
-
}
|
|
1589
|
-
function formatLongDuration(ms) {
|
|
1590
|
-
const totalMinutes = Math.round(Math.abs(ms) / 6e4);
|
|
1591
|
-
if (totalMinutes === 0) return "< 1m";
|
|
1592
|
-
const days = Math.floor(totalMinutes / 1440);
|
|
1593
|
-
const hours = Math.floor(totalMinutes % 1440 / 60);
|
|
1594
|
-
const minutes = totalMinutes % 60;
|
|
1595
|
-
const parts = [];
|
|
1596
|
-
if (days > 0) parts.push(`${days}d`);
|
|
1597
|
-
if (hours > 0) parts.push(`${hours}h`);
|
|
1598
|
-
if (minutes > 0) parts.push(`${minutes}m`);
|
|
1599
|
-
return parts.join(" ");
|
|
1600
|
-
}
|
|
1601
|
-
function formatDateISO(ms) {
|
|
1602
|
-
const d = new Date(Number(ms));
|
|
1603
|
-
const year = d.getUTCFullYear();
|
|
1604
|
-
const month = String(d.getUTCMonth() + 1).padStart(2, "0");
|
|
1605
|
-
const day = String(d.getUTCDate()).padStart(2, "0");
|
|
1606
|
-
return `${year}-${month}-${day}`;
|
|
1607
|
-
}
|
|
1608
|
-
|
|
1609
1634
|
// src/output.ts
|
|
1610
1635
|
import chalk from "chalk";
|
|
1611
1636
|
function isTTY() {
|
|
@@ -4280,6 +4305,42 @@ var commandMetadata = [
|
|
|
4280
4305
|
}
|
|
4281
4306
|
]
|
|
4282
4307
|
},
|
|
4308
|
+
{
|
|
4309
|
+
name: "export",
|
|
4310
|
+
description: "Export tasks and docs to a local archive (lossless JSON + rendered markdown)",
|
|
4311
|
+
quickReference: [
|
|
4312
|
+
{
|
|
4313
|
+
section: "read",
|
|
4314
|
+
usage: "export user <userRef>",
|
|
4315
|
+
description: "Export all tasks assigned to a user (me, id, email; incl. closed, archived)"
|
|
4316
|
+
},
|
|
4317
|
+
{
|
|
4318
|
+
section: "read",
|
|
4319
|
+
usage: "export team <spaceRef>",
|
|
4320
|
+
description: "Export every list in a space (id or name), incl. archived"
|
|
4321
|
+
},
|
|
4322
|
+
{
|
|
4323
|
+
section: "read",
|
|
4324
|
+
usage: "export roadmap <listId>",
|
|
4325
|
+
description: "Export a list with initiatives grouped (--item-id) and subtask trees"
|
|
4326
|
+
},
|
|
4327
|
+
{
|
|
4328
|
+
section: "read",
|
|
4329
|
+
usage: "export initiatives <listId>",
|
|
4330
|
+
description: "Export only initiative-typed tasks (--item-id) plus their subtask trees"
|
|
4331
|
+
},
|
|
4332
|
+
{
|
|
4333
|
+
section: "read",
|
|
4334
|
+
usage: "export docs",
|
|
4335
|
+
description: "Export every workspace doc as markdown page trees"
|
|
4336
|
+
},
|
|
4337
|
+
{
|
|
4338
|
+
section: "read",
|
|
4339
|
+
usage: "export all",
|
|
4340
|
+
description: "Export the whole workspace (plans, confirms; --yes for non-interactive)"
|
|
4341
|
+
}
|
|
4342
|
+
]
|
|
4343
|
+
},
|
|
4283
4344
|
{
|
|
4284
4345
|
name: "attach-get",
|
|
4285
4346
|
description: "Download task attachment(s) by ID or title",
|
|
@@ -6371,8 +6432,8 @@ async function installSkillInteractive() {
|
|
|
6371
6432
|
const source = skillPath();
|
|
6372
6433
|
const installed = [];
|
|
6373
6434
|
if (isTTY()) {
|
|
6374
|
-
const { checkbox:
|
|
6375
|
-
const selected = await
|
|
6435
|
+
const { checkbox: checkbox3 } = await import("@inquirer/prompts");
|
|
6436
|
+
const selected = await checkbox3({
|
|
6376
6437
|
message: "Install skill for which agents?",
|
|
6377
6438
|
choices: targets.map((t) => ({
|
|
6378
6439
|
name: `${t.name}${t.detected ? chalk8.dim(" (detected)") : ""}`,
|
|
@@ -7163,7 +7224,7 @@ async function downloadAttachment(attachment, targetPath, force) {
|
|
|
7163
7224
|
return { title: attachment.title, path: targetPath, size: buffer.length };
|
|
7164
7225
|
}
|
|
7165
7226
|
async function attachGet(config, taskId, selector, opts) {
|
|
7166
|
-
const { resolve:
|
|
7227
|
+
const { resolve: resolve3 } = await import("path");
|
|
7167
7228
|
const client = new ClickUpClient(config);
|
|
7168
7229
|
const attachments = await client.getTaskAttachments(taskId);
|
|
7169
7230
|
const selected = selectAttachments(attachments, selector, opts.all ?? false);
|
|
@@ -7171,16 +7232,1086 @@ async function attachGet(config, taskId, selector, opts) {
|
|
|
7171
7232
|
for (const att of selected) {
|
|
7172
7233
|
let targetPath;
|
|
7173
7234
|
if (opts.output && !opts.all) {
|
|
7174
|
-
targetPath =
|
|
7235
|
+
targetPath = resolve3(opts.output);
|
|
7175
7236
|
} else {
|
|
7176
7237
|
const dir = opts.dir ?? ".";
|
|
7177
|
-
targetPath =
|
|
7238
|
+
targetPath = resolve3(dir, sanitizeFilename(att.title));
|
|
7178
7239
|
}
|
|
7179
7240
|
results.push(await downloadAttachment(att, targetPath, opts.force ?? false));
|
|
7180
7241
|
}
|
|
7181
7242
|
return results;
|
|
7182
7243
|
}
|
|
7183
7244
|
|
|
7245
|
+
// src/commands/export.ts
|
|
7246
|
+
import { mkdirSync as mkdirSync4, writeFileSync as writeFileSync4 } from "fs";
|
|
7247
|
+
import { join as join6, resolve } from "path";
|
|
7248
|
+
|
|
7249
|
+
// src/export/discover.ts
|
|
7250
|
+
function slug(name) {
|
|
7251
|
+
return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "unnamed";
|
|
7252
|
+
}
|
|
7253
|
+
async function resolveUserRef(client, teamId, ref) {
|
|
7254
|
+
if (ref === "me") {
|
|
7255
|
+
const me = await client.getMe();
|
|
7256
|
+
return { id: me.id, username: me.username };
|
|
7257
|
+
}
|
|
7258
|
+
const members = await client.getWorkspaceMembers(teamId);
|
|
7259
|
+
const needle = ref.trim().toLowerCase();
|
|
7260
|
+
const match = members.find(
|
|
7261
|
+
(m) => String(m.id) === needle || m.username?.toLowerCase() === needle || m.email?.toLowerCase() === needle
|
|
7262
|
+
);
|
|
7263
|
+
if (!match) {
|
|
7264
|
+
const available = members.map((m) => m.username ?? m.email ?? String(m.id)).join(", ");
|
|
7265
|
+
throw new Error(`User "${ref}" not found. Available: ${available}`);
|
|
7266
|
+
}
|
|
7267
|
+
return { id: match.id, username: match.username ?? match.email ?? String(match.id) };
|
|
7268
|
+
}
|
|
7269
|
+
async function workspaceInfo(client, teamId) {
|
|
7270
|
+
const teams = await client.getTeams();
|
|
7271
|
+
const team = teams.find((t) => t.id === teamId);
|
|
7272
|
+
return { id: teamId, name: team?.name ?? teamId };
|
|
7273
|
+
}
|
|
7274
|
+
function toDiscovered(task) {
|
|
7275
|
+
return {
|
|
7276
|
+
id: task.id,
|
|
7277
|
+
listId: task.list.id,
|
|
7278
|
+
initiative: (task.custom_item_id ?? 0) !== 0
|
|
7279
|
+
};
|
|
7280
|
+
}
|
|
7281
|
+
function dedupe(tasks) {
|
|
7282
|
+
const seen = /* @__PURE__ */ new Set();
|
|
7283
|
+
return tasks.filter((t) => seen.has(t.id) ? false : (seen.add(t.id), true));
|
|
7284
|
+
}
|
|
7285
|
+
async function discoverUserTasks(client, teamId, userRef) {
|
|
7286
|
+
const user = await resolveUserRef(client, teamId, userRef);
|
|
7287
|
+
const base = { all: true, assignees: [user.id], includeClosed: true, subtasks: true };
|
|
7288
|
+
const [active, archived] = await Promise.all([
|
|
7289
|
+
client.getMyTasks(teamId, base),
|
|
7290
|
+
client.getMyTasks(teamId, { ...base, archived: true })
|
|
7291
|
+
]);
|
|
7292
|
+
const all = dedupe([...active, ...archived]);
|
|
7293
|
+
return {
|
|
7294
|
+
slice: { name: `user-${slug(user.username)}`, kind: "user", scope: String(user.id) },
|
|
7295
|
+
tasks: all.map(toDiscovered),
|
|
7296
|
+
workspace: await workspaceInfo(client, teamId),
|
|
7297
|
+
tasksById: Object.fromEntries(all.map((t) => [t.id, t]))
|
|
7298
|
+
};
|
|
7299
|
+
}
|
|
7300
|
+
async function resolveSpaceRef(client, teamId, ref) {
|
|
7301
|
+
const spaces = await client.getSpaces(teamId);
|
|
7302
|
+
const needle = ref.trim().toLowerCase();
|
|
7303
|
+
const match = spaces.find((s) => s.id === ref.trim() || s.name.toLowerCase() === needle);
|
|
7304
|
+
if (!match) {
|
|
7305
|
+
throw new Error(`Space "${ref}" not found. Available: ${spaces.map((s) => s.name).join(", ")}`);
|
|
7306
|
+
}
|
|
7307
|
+
return { id: match.id, name: match.name };
|
|
7308
|
+
}
|
|
7309
|
+
async function listTasks(client, listId) {
|
|
7310
|
+
const [active, archived] = await Promise.all([
|
|
7311
|
+
client.getTasksFromList(listId, {}, { includeClosed: true }),
|
|
7312
|
+
client.getTasksFromList(listId, {}, { includeClosed: true, archived: true })
|
|
7313
|
+
]);
|
|
7314
|
+
return dedupe([...active, ...archived]);
|
|
7315
|
+
}
|
|
7316
|
+
async function union(active, archived) {
|
|
7317
|
+
const [a, b] = await Promise.all([active, archived]);
|
|
7318
|
+
const seen = /* @__PURE__ */ new Set();
|
|
7319
|
+
return [...a, ...b].filter((x) => seen.has(x.id) ? false : (seen.add(x.id), true));
|
|
7320
|
+
}
|
|
7321
|
+
async function walkSpace(client, spaceId, spaceName) {
|
|
7322
|
+
const [lists, folders] = await Promise.all([
|
|
7323
|
+
union(client.getLists(spaceId, false), client.getLists(spaceId, true)),
|
|
7324
|
+
union(client.getFolders(spaceId, false), client.getFolders(spaceId, true))
|
|
7325
|
+
]);
|
|
7326
|
+
const folderEntries = await Promise.all(
|
|
7327
|
+
folders.map(async (f) => ({
|
|
7328
|
+
id: f.id,
|
|
7329
|
+
name: f.name,
|
|
7330
|
+
lists: (await union(client.getFolderLists(f.id, false), client.getFolderLists(f.id, true))).map((l) => ({ id: l.id, name: l.name }))
|
|
7331
|
+
}))
|
|
7332
|
+
);
|
|
7333
|
+
const hierarchy = {
|
|
7334
|
+
space: { id: spaceId, name: spaceName },
|
|
7335
|
+
folders: folderEntries,
|
|
7336
|
+
lists: lists.map((l) => ({ id: l.id, name: l.name }))
|
|
7337
|
+
};
|
|
7338
|
+
return hierarchy;
|
|
7339
|
+
}
|
|
7340
|
+
async function discoverTeamTasks(client, teamId, spaceRef) {
|
|
7341
|
+
const space = await resolveSpaceRef(client, teamId, spaceRef);
|
|
7342
|
+
const hierarchy = await walkSpace(client, space.id, space.name);
|
|
7343
|
+
const listIds = [
|
|
7344
|
+
...hierarchy.lists.map((l) => l.id),
|
|
7345
|
+
...hierarchy.folders.flatMap((f) => f.lists.map((l) => l.id))
|
|
7346
|
+
];
|
|
7347
|
+
const perList = await Promise.all(listIds.map((id) => listTasks(client, id)));
|
|
7348
|
+
const all = dedupe(perList.flat());
|
|
7349
|
+
return {
|
|
7350
|
+
slice: { name: `team-${slug(space.name)}`, kind: "team", scope: space.id },
|
|
7351
|
+
tasks: all.map(toDiscovered),
|
|
7352
|
+
workspace: await workspaceInfo(client, teamId),
|
|
7353
|
+
tasksById: Object.fromEntries(all.map((t) => [t.id, t])),
|
|
7354
|
+
hierarchy
|
|
7355
|
+
};
|
|
7356
|
+
}
|
|
7357
|
+
async function discoverListTasks(client, teamId, listId, opts) {
|
|
7358
|
+
const list = await client.getListWithStatuses(listId);
|
|
7359
|
+
let all = await listTasks(client, listId);
|
|
7360
|
+
if (opts.kind === "initiatives") {
|
|
7361
|
+
const itemId = opts.initiativeItemId;
|
|
7362
|
+
if (itemId === void 0) throw new Error("initiatives export requires an initiative item id");
|
|
7363
|
+
const matching = all.filter((t) => t.custom_item_id === itemId);
|
|
7364
|
+
if (matching.length === 0) {
|
|
7365
|
+
const found = [...new Set(all.map((t) => t.custom_item_id ?? 0).filter((id) => id !== 0))].sort((a, b) => a - b).join(", ");
|
|
7366
|
+
throw new Error(
|
|
7367
|
+
`No tasks with custom item id ${itemId} in list "${list.name}" (custom item ids found: ${found || "none"})`
|
|
7368
|
+
);
|
|
7369
|
+
}
|
|
7370
|
+
all = matching;
|
|
7371
|
+
}
|
|
7372
|
+
return {
|
|
7373
|
+
slice: { name: `${opts.kind}-${slug(list.name)}`, kind: opts.kind, scope: listId },
|
|
7374
|
+
tasks: all.map(toDiscovered),
|
|
7375
|
+
workspace: await workspaceInfo(client, teamId),
|
|
7376
|
+
tasksById: Object.fromEntries(all.map((t) => [t.id, t])),
|
|
7377
|
+
list: { id: list.id, name: list.name }
|
|
7378
|
+
};
|
|
7379
|
+
}
|
|
7380
|
+
|
|
7381
|
+
// src/util/batch.ts
|
|
7382
|
+
async function runInBatches(items, concurrency, fn) {
|
|
7383
|
+
if (!Number.isInteger(concurrency) || concurrency < 1) {
|
|
7384
|
+
throw new Error(`concurrency must be a positive integer, got ${concurrency}`);
|
|
7385
|
+
}
|
|
7386
|
+
const results = [];
|
|
7387
|
+
for (let i = 0; i < items.length; i += concurrency) {
|
|
7388
|
+
const batch = items.slice(i, i + concurrency);
|
|
7389
|
+
const settled = await Promise.allSettled(batch.map(fn));
|
|
7390
|
+
settled.forEach((res, idx) => {
|
|
7391
|
+
const item = batch[idx];
|
|
7392
|
+
if (res.status === "fulfilled") {
|
|
7393
|
+
results.push({ item, ok: true, result: res.value });
|
|
7394
|
+
} else {
|
|
7395
|
+
const error = res.reason instanceof Error ? res.reason : new Error(String(res.reason), { cause: res.reason });
|
|
7396
|
+
results.push({ item, ok: false, error });
|
|
7397
|
+
}
|
|
7398
|
+
});
|
|
7399
|
+
}
|
|
7400
|
+
return results;
|
|
7401
|
+
}
|
|
7402
|
+
|
|
7403
|
+
// src/export/bundle.ts
|
|
7404
|
+
function replyCount(c) {
|
|
7405
|
+
const n = c.reply_count;
|
|
7406
|
+
return typeof n === "number" ? n : 0;
|
|
7407
|
+
}
|
|
7408
|
+
async function fetchTaskBundle(client, taskId) {
|
|
7409
|
+
const [task, rawComments] = await Promise.all([
|
|
7410
|
+
client.getTaskForExport(taskId),
|
|
7411
|
+
client.getAllTaskComments(taskId)
|
|
7412
|
+
]);
|
|
7413
|
+
const comments = await Promise.all(
|
|
7414
|
+
rawComments.map(async (c) => ({
|
|
7415
|
+
...c,
|
|
7416
|
+
replies: replyCount(c) > 0 ? await client.getThreadedComments(c.id) : []
|
|
7417
|
+
}))
|
|
7418
|
+
);
|
|
7419
|
+
return {
|
|
7420
|
+
task,
|
|
7421
|
+
comments,
|
|
7422
|
+
attachments: task.attachments ?? [],
|
|
7423
|
+
subtaskIds: (task.subtasks ?? []).map((s) => s.id),
|
|
7424
|
+
fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
7425
|
+
};
|
|
7426
|
+
}
|
|
7427
|
+
|
|
7428
|
+
// src/export/manifest.ts
|
|
7429
|
+
import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync3, renameSync, writeFileSync } from "fs";
|
|
7430
|
+
import { join as join3 } from "path";
|
|
7431
|
+
var MANIFEST_VERSION = 1;
|
|
7432
|
+
var FILE = "manifest.json";
|
|
7433
|
+
function emptyManifest() {
|
|
7434
|
+
return { version: MANIFEST_VERSION, workspace: void 0, tasks: {}, docs: {}, slices: {} };
|
|
7435
|
+
}
|
|
7436
|
+
function loadManifest(root) {
|
|
7437
|
+
const path = join3(root, FILE);
|
|
7438
|
+
if (!existsSync2(path)) return emptyManifest();
|
|
7439
|
+
let parsed;
|
|
7440
|
+
try {
|
|
7441
|
+
parsed = JSON.parse(readFileSync3(path, "utf8"));
|
|
7442
|
+
} catch (err) {
|
|
7443
|
+
throw new Error(`Cannot parse ${path}: ${err.message}`, { cause: err });
|
|
7444
|
+
}
|
|
7445
|
+
const m = parsed;
|
|
7446
|
+
if (m.version !== MANIFEST_VERSION) {
|
|
7447
|
+
throw new Error(
|
|
7448
|
+
`Unsupported manifest version ${String(m.version)} in ${path} (expected ${MANIFEST_VERSION})`
|
|
7449
|
+
);
|
|
7450
|
+
}
|
|
7451
|
+
return {
|
|
7452
|
+
version: MANIFEST_VERSION,
|
|
7453
|
+
workspace: m.workspace ?? void 0,
|
|
7454
|
+
tasks: m.tasks ?? {},
|
|
7455
|
+
docs: m.docs ?? {},
|
|
7456
|
+
slices: m.slices ?? {}
|
|
7457
|
+
};
|
|
7458
|
+
}
|
|
7459
|
+
function saveManifest(root, manifest) {
|
|
7460
|
+
mkdirSync2(root, { recursive: true });
|
|
7461
|
+
const path = join3(root, FILE);
|
|
7462
|
+
const tmp = `${path}.tmp`;
|
|
7463
|
+
writeFileSync(tmp, JSON.stringify(manifest, null, 2) + "\n");
|
|
7464
|
+
renameSync(tmp, path);
|
|
7465
|
+
}
|
|
7466
|
+
|
|
7467
|
+
// src/util/plural.ts
|
|
7468
|
+
function plural(n, singular, pluralForm = `${singular}s`) {
|
|
7469
|
+
return `${n} ${n === 1 ? singular : pluralForm}`;
|
|
7470
|
+
}
|
|
7471
|
+
|
|
7472
|
+
// src/export/engine.ts
|
|
7473
|
+
function addSlice(manifest, taskId, slice) {
|
|
7474
|
+
const entry = manifest.tasks[taskId];
|
|
7475
|
+
if (entry && !entry.slices.includes(slice)) entry.slices.push(slice);
|
|
7476
|
+
}
|
|
7477
|
+
async function runExport(client, plan, opts) {
|
|
7478
|
+
const manifest = loadManifest(opts.root);
|
|
7479
|
+
manifest.workspace = plan.workspace;
|
|
7480
|
+
const sliceName = plan.slice.name;
|
|
7481
|
+
const checkpointEvery = opts.checkpointEvery ?? 25;
|
|
7482
|
+
const queue = [];
|
|
7483
|
+
const queued = /* @__PURE__ */ new Set();
|
|
7484
|
+
const enqueue = (id) => {
|
|
7485
|
+
if (!queued.has(id)) {
|
|
7486
|
+
queued.add(id);
|
|
7487
|
+
queue.push(id);
|
|
7488
|
+
}
|
|
7489
|
+
};
|
|
7490
|
+
for (const t of plan.tasks) enqueue(t.id);
|
|
7491
|
+
const summary = {
|
|
7492
|
+
fetched: 0,
|
|
7493
|
+
skipped: 0,
|
|
7494
|
+
failed: [],
|
|
7495
|
+
attachmentsDownloaded: 0,
|
|
7496
|
+
attachmentsFailed: 0
|
|
7497
|
+
};
|
|
7498
|
+
const touched = /* @__PURE__ */ new Set();
|
|
7499
|
+
let sinceCheckpoint = 0;
|
|
7500
|
+
while (queue.length > 0) {
|
|
7501
|
+
const batch = queue.splice(0, opts.concurrency);
|
|
7502
|
+
const toFetch = [];
|
|
7503
|
+
for (const id of batch) {
|
|
7504
|
+
touched.add(id);
|
|
7505
|
+
if (!opts.refresh && manifest.tasks[id]) {
|
|
7506
|
+
summary.skipped++;
|
|
7507
|
+
addSlice(manifest, id, sliceName);
|
|
7508
|
+
try {
|
|
7509
|
+
const cached = await readBundleData(opts.root, id);
|
|
7510
|
+
for (const s of cached.subtaskIds) enqueue(s);
|
|
7511
|
+
if (opts.downloadAttachments && cached.attachments.length > 0) {
|
|
7512
|
+
const bf = await backfillAttachments(opts.root, id, opts.download);
|
|
7513
|
+
summary.attachmentsDownloaded += bf.downloaded;
|
|
7514
|
+
summary.attachmentsFailed += bf.failed.length;
|
|
7515
|
+
}
|
|
7516
|
+
} catch {
|
|
7517
|
+
delete manifest.tasks[id];
|
|
7518
|
+
toFetch.push(id);
|
|
7519
|
+
summary.skipped--;
|
|
7520
|
+
}
|
|
7521
|
+
continue;
|
|
7522
|
+
}
|
|
7523
|
+
toFetch.push(id);
|
|
7524
|
+
}
|
|
7525
|
+
const outcomes = await runInBatches(toFetch, opts.concurrency, async (id) => {
|
|
7526
|
+
const bundle = await fetchTaskBundle(client, id);
|
|
7527
|
+
const written = await writeBundleData(opts.root, bundle, {
|
|
7528
|
+
downloadAttachments: opts.downloadAttachments,
|
|
7529
|
+
download: opts.download
|
|
7530
|
+
});
|
|
7531
|
+
return { bundle, written };
|
|
7532
|
+
});
|
|
7533
|
+
for (const o of outcomes) {
|
|
7534
|
+
if (!o.ok) {
|
|
7535
|
+
summary.failed.push({ id: o.item, error: o.error.message });
|
|
7536
|
+
continue;
|
|
7537
|
+
}
|
|
7538
|
+
const { bundle, written } = o.result;
|
|
7539
|
+
summary.fetched++;
|
|
7540
|
+
summary.attachmentsDownloaded += written.attachmentsDownloaded;
|
|
7541
|
+
summary.attachmentsFailed += written.attachmentsFailed.length;
|
|
7542
|
+
const existing = manifest.tasks[bundle.task.id];
|
|
7543
|
+
manifest.tasks[bundle.task.id] = {
|
|
7544
|
+
fetchedAt: bundle.fetchedAt,
|
|
7545
|
+
slices: existing?.slices.includes(sliceName) ? existing.slices : [...existing?.slices ?? [], sliceName],
|
|
7546
|
+
contentHash: written.contentHash
|
|
7547
|
+
};
|
|
7548
|
+
for (const s of bundle.subtaskIds) enqueue(s);
|
|
7549
|
+
sinceCheckpoint++;
|
|
7550
|
+
}
|
|
7551
|
+
const total = touched.size + queue.length;
|
|
7552
|
+
const done = summary.fetched + summary.skipped + summary.failed.length;
|
|
7553
|
+
opts.log(
|
|
7554
|
+
`[${sliceName}] ${done}/${plural(total, "task")} (${summary.fetched} fetched, ${summary.skipped} cached, ${summary.failed.length} failed)`
|
|
7555
|
+
);
|
|
7556
|
+
if (sinceCheckpoint >= checkpointEvery) {
|
|
7557
|
+
saveManifest(opts.root, manifest);
|
|
7558
|
+
sinceCheckpoint = 0;
|
|
7559
|
+
}
|
|
7560
|
+
}
|
|
7561
|
+
const known = new Set(Object.keys(manifest.tasks));
|
|
7562
|
+
const hasTask = (id) => known.has(id);
|
|
7563
|
+
const spaceNames = opts.spaceNames;
|
|
7564
|
+
const spaceName = spaceNames ? (id) => spaceNames[id] : void 0;
|
|
7565
|
+
const renderOutcomes = await runInBatches([...known], opts.concurrency * 2, async (id) => {
|
|
7566
|
+
const bundle = await readBundleData(opts.root, id);
|
|
7567
|
+
await renderBundleMarkdown(opts.root, bundle, hasTask, spaceName);
|
|
7568
|
+
});
|
|
7569
|
+
for (const o of renderOutcomes) {
|
|
7570
|
+
if (!o.ok) opts.log(`warning: could not render ${o.item}: ${o.error.message}`);
|
|
7571
|
+
}
|
|
7572
|
+
manifest.slices[sliceName] = {
|
|
7573
|
+
kind: plan.slice.kind,
|
|
7574
|
+
scope: plan.slice.scope,
|
|
7575
|
+
exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
7576
|
+
taskCount: [...touched].filter((id) => manifest.tasks[id]).length
|
|
7577
|
+
};
|
|
7578
|
+
saveManifest(opts.root, manifest);
|
|
7579
|
+
return summary;
|
|
7580
|
+
}
|
|
7581
|
+
|
|
7582
|
+
// src/export/docs.ts
|
|
7583
|
+
import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync2 } from "fs";
|
|
7584
|
+
import { join as join4 } from "path";
|
|
7585
|
+
var PARENT_TYPES = {
|
|
7586
|
+
7: "workspace",
|
|
7587
|
+
4: "space",
|
|
7588
|
+
6: "folder",
|
|
7589
|
+
5: "list",
|
|
7590
|
+
1: "task"
|
|
7591
|
+
};
|
|
7592
|
+
function docDirName(doc) {
|
|
7593
|
+
const s = doc.name ? slug(doc.name) : "";
|
|
7594
|
+
return s && s !== "unnamed" ? `${s}-${doc.id}` : doc.id;
|
|
7595
|
+
}
|
|
7596
|
+
function pageFileName(page) {
|
|
7597
|
+
const s = page.name ? slug(page.name) : "";
|
|
7598
|
+
return s && s !== "unnamed" ? `${s}-${page.id}.md` : `${page.id}.md`;
|
|
7599
|
+
}
|
|
7600
|
+
function pageDirName(page) {
|
|
7601
|
+
return pageFileName(page).replace(/\.md$/, "");
|
|
7602
|
+
}
|
|
7603
|
+
function yamlString(v) {
|
|
7604
|
+
return JSON.stringify(v);
|
|
7605
|
+
}
|
|
7606
|
+
function renderPage(page, docId) {
|
|
7607
|
+
const header = [
|
|
7608
|
+
"---",
|
|
7609
|
+
`id: ${page.id}`,
|
|
7610
|
+
`doc: ${docId}`,
|
|
7611
|
+
`title: ${page.name ? yamlString(page.name).slice(1, -1) : ""}`,
|
|
7612
|
+
...page.parent_page_id ? [`parent: ${page.parent_page_id}`] : [],
|
|
7613
|
+
...page.archived ? ["archived: true"] : [],
|
|
7614
|
+
...page.date_created ? [`created: ${new Date(page.date_created).toISOString()}`] : [],
|
|
7615
|
+
...page.date_updated ? [`updated: ${new Date(page.date_updated).toISOString()}`] : [],
|
|
7616
|
+
"---"
|
|
7617
|
+
];
|
|
7618
|
+
return header.join("\n") + "\n\n" + (page.content ?? "") + "\n";
|
|
7619
|
+
}
|
|
7620
|
+
function writePages(dir, docId, pages, prefix = "", depth = 0) {
|
|
7621
|
+
const out = [];
|
|
7622
|
+
for (const page of pages) {
|
|
7623
|
+
const file = pageFileName(page);
|
|
7624
|
+
const relPath = prefix ? `${prefix}/${file}` : file;
|
|
7625
|
+
mkdirSync3(join4(dir, prefix), { recursive: true });
|
|
7626
|
+
writeFileSync2(join4(dir, relPath), renderPage(page, docId));
|
|
7627
|
+
out.push({ page, relPath, depth });
|
|
7628
|
+
if (page.pages?.length) {
|
|
7629
|
+
const childPrefix = prefix ? `${prefix}/${pageDirName(page)}` : pageDirName(page);
|
|
7630
|
+
out.push(...writePages(dir, docId, page.pages, childPrefix, depth + 1));
|
|
7631
|
+
}
|
|
7632
|
+
}
|
|
7633
|
+
return out;
|
|
7634
|
+
}
|
|
7635
|
+
function docTitle(doc) {
|
|
7636
|
+
return doc.name || `(unnamed ${doc.id})`;
|
|
7637
|
+
}
|
|
7638
|
+
function renderDocReadme(doc, written, location) {
|
|
7639
|
+
const lines = [
|
|
7640
|
+
`# ${docTitle(doc)}`,
|
|
7641
|
+
"",
|
|
7642
|
+
`Doc id: ${doc.id} \xB7 Location: ${location} \xB7 ${plural(written.length, "page")}`,
|
|
7643
|
+
...doc.date_updated ? [`Last updated: ${new Date(Number(doc.date_updated)).toISOString()}`] : [],
|
|
7644
|
+
"",
|
|
7645
|
+
"## Pages",
|
|
7646
|
+
""
|
|
7647
|
+
];
|
|
7648
|
+
for (const w of written) {
|
|
7649
|
+
const name = w.page.name || `(untitled ${w.page.id})`;
|
|
7650
|
+
const flag = w.page.archived ? " (archived)" : "";
|
|
7651
|
+
lines.push(`${" ".repeat(w.depth)}- [${name}](${w.relPath})${flag}`);
|
|
7652
|
+
}
|
|
7653
|
+
lines.push("");
|
|
7654
|
+
return lines.join("\n");
|
|
7655
|
+
}
|
|
7656
|
+
function renderDocsIndex(entries) {
|
|
7657
|
+
const lines = [
|
|
7658
|
+
"# Docs",
|
|
7659
|
+
"",
|
|
7660
|
+
plural(entries.length, "doc"),
|
|
7661
|
+
"",
|
|
7662
|
+
"| Doc | Location | Pages |",
|
|
7663
|
+
"| --- | --- | --- |"
|
|
7664
|
+
];
|
|
7665
|
+
for (const e of entries.sort((a, b) => docTitle(a.doc).localeCompare(docTitle(b.doc)))) {
|
|
7666
|
+
lines.push(`| [${docTitle(e.doc)}](${e.dir}/README.md) | ${e.location} | ${e.pages} |`);
|
|
7667
|
+
}
|
|
7668
|
+
lines.push("");
|
|
7669
|
+
return lines.join("\n");
|
|
7670
|
+
}
|
|
7671
|
+
function countPages(pages) {
|
|
7672
|
+
return pages.reduce((n, p) => n + 1 + countPages(p.pages ?? []), 0);
|
|
7673
|
+
}
|
|
7674
|
+
async function exportDocs(client, teamId, opts) {
|
|
7675
|
+
const manifest = loadManifest(opts.root);
|
|
7676
|
+
const docsRoot = join4(opts.root, "docs");
|
|
7677
|
+
mkdirSync3(docsRoot, { recursive: true });
|
|
7678
|
+
const [docs, spaces, teams] = await Promise.all([
|
|
7679
|
+
client.getAllDocs(teamId),
|
|
7680
|
+
client.getSpaces(teamId),
|
|
7681
|
+
client.getTeams()
|
|
7682
|
+
]);
|
|
7683
|
+
const spaceNames = new Map(spaces.map((s) => [s.id, s.name]));
|
|
7684
|
+
const teamName = teams.find((t) => t.id === teamId)?.name ?? teamId;
|
|
7685
|
+
const locationOf = (doc) => {
|
|
7686
|
+
const t = doc.parent ? PARENT_TYPES[doc.parent.type] ?? `type ${doc.parent.type}` : "unknown";
|
|
7687
|
+
const id = doc.parent?.id ?? "";
|
|
7688
|
+
if (t === "space") return `space ${spaceNames.get(id) ?? id}`;
|
|
7689
|
+
if (t === "workspace") return `workspace ${teamName}`;
|
|
7690
|
+
return `${t} ${id}`;
|
|
7691
|
+
};
|
|
7692
|
+
const summary = { docs: 0, pages: 0, skipped: 0, failed: [] };
|
|
7693
|
+
const entries = [];
|
|
7694
|
+
opts.log(`Plan [docs]: ${plural(docs.length, "doc")} in workspace "${teamName}"`);
|
|
7695
|
+
for (const doc of docs) {
|
|
7696
|
+
const dir = docDirName(doc);
|
|
7697
|
+
const location = locationOf(doc);
|
|
7698
|
+
const cached = manifest.docs[doc.id];
|
|
7699
|
+
if (cached && !opts.refresh) {
|
|
7700
|
+
summary.skipped++;
|
|
7701
|
+
entries.push({ doc, dir, pages: cached.pageCount, location });
|
|
7702
|
+
continue;
|
|
7703
|
+
}
|
|
7704
|
+
try {
|
|
7705
|
+
const pages = await client.getDocPages(teamId, doc.id);
|
|
7706
|
+
const dirPath = join4(docsRoot, dir);
|
|
7707
|
+
mkdirSync3(dirPath, { recursive: true });
|
|
7708
|
+
writeFileSync2(join4(dirPath, "doc.json"), JSON.stringify({ doc, pages }, null, 2) + "\n");
|
|
7709
|
+
const written = writePages(dirPath, doc.id, pages);
|
|
7710
|
+
writeFileSync2(join4(dirPath, "README.md"), renderDocReadme(doc, written, location));
|
|
7711
|
+
const pageCount = countPages(pages);
|
|
7712
|
+
manifest.docs[doc.id] = {
|
|
7713
|
+
fetchedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
7714
|
+
name: doc.name,
|
|
7715
|
+
pageCount
|
|
7716
|
+
};
|
|
7717
|
+
summary.docs++;
|
|
7718
|
+
summary.pages += pageCount;
|
|
7719
|
+
entries.push({ doc, dir, pages: pageCount, location });
|
|
7720
|
+
opts.log(
|
|
7721
|
+
`[docs] ${summary.docs + summary.skipped + summary.failed.length}/${docs.length} ${docTitle(doc)} (${plural(pageCount, "page")})`
|
|
7722
|
+
);
|
|
7723
|
+
} catch (err) {
|
|
7724
|
+
summary.failed.push({ id: doc.id, error: err.message });
|
|
7725
|
+
}
|
|
7726
|
+
}
|
|
7727
|
+
writeFileSync2(join4(docsRoot, "README.md"), renderDocsIndex(entries));
|
|
7728
|
+
manifest.slices["docs"] = {
|
|
7729
|
+
kind: "docs",
|
|
7730
|
+
scope: teamId,
|
|
7731
|
+
exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
7732
|
+
taskCount: 0
|
|
7733
|
+
};
|
|
7734
|
+
if (!manifest.workspace) manifest.workspace = { id: teamId, name: teamName };
|
|
7735
|
+
saveManifest(opts.root, manifest);
|
|
7736
|
+
return summary;
|
|
7737
|
+
}
|
|
7738
|
+
|
|
7739
|
+
// src/export/index-team.ts
|
|
7740
|
+
function link(task) {
|
|
7741
|
+
return `[${task.name.replace(/\|/g, "\\|")}](../../tasks/${task.id}/task.md)`;
|
|
7742
|
+
}
|
|
7743
|
+
function assignees(task) {
|
|
7744
|
+
return task.assignees.map((a) => a.username).join(", ");
|
|
7745
|
+
}
|
|
7746
|
+
function typeOf(task, opts) {
|
|
7747
|
+
const id = task.custom_item_id ?? 0;
|
|
7748
|
+
if (id === 0) return "task";
|
|
7749
|
+
if (opts.initiativeItemId !== void 0 && id === opts.initiativeItemId) return "initiative";
|
|
7750
|
+
return opts.typeNames?.[id] ?? `type ${id}`;
|
|
7751
|
+
}
|
|
7752
|
+
function taskTable(tasks, opts) {
|
|
7753
|
+
const lines = ["| Task | Status | Assignees | Type |", "| --- | --- | --- | --- |"];
|
|
7754
|
+
for (const t of tasks) {
|
|
7755
|
+
lines.push(`| ${link(t)} | ${t.status.status} | ${assignees(t)} | ${typeOf(t, opts)} |`);
|
|
7756
|
+
}
|
|
7757
|
+
return lines;
|
|
7758
|
+
}
|
|
7759
|
+
function renderTeamIndex(hierarchy, tasks, opts) {
|
|
7760
|
+
const topLevel = tasks.filter((t) => !t.parent);
|
|
7761
|
+
const byList = /* @__PURE__ */ new Map();
|
|
7762
|
+
for (const t of topLevel) byList.set(t.list.id, [...byList.get(t.list.id) ?? [], t]);
|
|
7763
|
+
const listCount = hierarchy.lists.length + hierarchy.folders.reduce((n, f) => n + f.lists.length, 0);
|
|
7764
|
+
const lines = [
|
|
7765
|
+
`# ${hierarchy.space.name} (space)`,
|
|
7766
|
+
"",
|
|
7767
|
+
`Exported ${opts.exportedAt.slice(0, 10)} \xB7 ${plural(listCount, "list")} \xB7 ${plural(hierarchy.folders.length, "folder")} \xB7 ${plural(tasks.length, "task")}`,
|
|
7768
|
+
""
|
|
7769
|
+
];
|
|
7770
|
+
const renderList = (list, level) => {
|
|
7771
|
+
const group = byList.get(list.id) ?? [];
|
|
7772
|
+
const h = "#".repeat(level);
|
|
7773
|
+
lines.push(`${h} ${list.name} \u2014 ${plural(group.length, "task")}`, "");
|
|
7774
|
+
const related = opts.relatedSlices?.filter((s) => s.listId === list.id) ?? [];
|
|
7775
|
+
for (const r of related) {
|
|
7776
|
+
lines.push(`\u2192 see also [${r.name}](../${r.name}/README.md)`, "");
|
|
7777
|
+
}
|
|
7778
|
+
if (group.length > 0) lines.push(...taskTable(group, opts), "");
|
|
7779
|
+
};
|
|
7780
|
+
for (const list of hierarchy.lists) renderList(list, 2);
|
|
7781
|
+
for (const folder of hierarchy.folders) {
|
|
7782
|
+
lines.push(`## Folder: ${folder.name}`, "");
|
|
7783
|
+
for (const list of folder.lists) renderList(list, 3);
|
|
7784
|
+
}
|
|
7785
|
+
return lines.join("\n");
|
|
7786
|
+
}
|
|
7787
|
+
function checkbox2(task) {
|
|
7788
|
+
return isDoneStatus(task.status.status) ? "[x]" : "[ ]";
|
|
7789
|
+
}
|
|
7790
|
+
function renderRoadmapIndex(list, tasks, opts) {
|
|
7791
|
+
const children = /* @__PURE__ */ new Map();
|
|
7792
|
+
for (const t of tasks) {
|
|
7793
|
+
if (t.parent) children.set(t.parent, [...children.get(t.parent) ?? [], t]);
|
|
7794
|
+
}
|
|
7795
|
+
const isInitiative = (t) => opts.initiativeItemId !== void 0 && t.custom_item_id === opts.initiativeItemId;
|
|
7796
|
+
const initiatives = tasks.filter((t) => !t.parent && isInitiative(t));
|
|
7797
|
+
const ungrouped = tasks.filter((t) => !t.parent && !isInitiative(t));
|
|
7798
|
+
const lines = [
|
|
7799
|
+
`# ${list.name}`,
|
|
7800
|
+
"",
|
|
7801
|
+
`Exported ${opts.exportedAt.slice(0, 10)} \xB7 ${plural(initiatives.length, "initiative")} \xB7 ${plural(tasks.length, "task")}`,
|
|
7802
|
+
""
|
|
7803
|
+
];
|
|
7804
|
+
const renderTree = (parentId, depth) => {
|
|
7805
|
+
for (const c of children.get(parentId) ?? []) {
|
|
7806
|
+
lines.push(`${" ".repeat(depth)}- ${checkbox2(c)} ${link(c)}`);
|
|
7807
|
+
renderTree(c.id, depth + 1);
|
|
7808
|
+
}
|
|
7809
|
+
};
|
|
7810
|
+
if (initiatives.length > 0) {
|
|
7811
|
+
lines.push("## Initiatives", "");
|
|
7812
|
+
for (const init of initiatives) {
|
|
7813
|
+
lines.push(`### ${link(init)} \u2014 ${init.status.status}`);
|
|
7814
|
+
const meta = [];
|
|
7815
|
+
if (init.assignees.length > 0) meta.push(`Owner: ${assignees(init)}`);
|
|
7816
|
+
if (init.tags?.length) meta.push(`Tags: ${init.tags.map((t) => t.name).join(", ")}`);
|
|
7817
|
+
if (init.start_date || init.due_date) {
|
|
7818
|
+
const from = init.start_date ? formatDateISO(init.start_date) : "?";
|
|
7819
|
+
const to = init.due_date ? formatDateISO(init.due_date) : "?";
|
|
7820
|
+
meta.push(`${from} \u2192 ${to}`);
|
|
7821
|
+
}
|
|
7822
|
+
if (meta.length > 0) lines.push(meta.join(" \xB7 "));
|
|
7823
|
+
lines.push("");
|
|
7824
|
+
if (children.has(init.id)) {
|
|
7825
|
+
renderTree(init.id, 0);
|
|
7826
|
+
lines.push("");
|
|
7827
|
+
}
|
|
7828
|
+
}
|
|
7829
|
+
}
|
|
7830
|
+
lines.push(`## Ungrouped tasks (${ungrouped.length})`, "");
|
|
7831
|
+
if (ungrouped.length > 0) {
|
|
7832
|
+
lines.push("| Task | Status | Assignees |", "| --- | --- | --- |");
|
|
7833
|
+
for (const t of ungrouped) lines.push(`| ${link(t)} | ${t.status.status} | ${assignees(t)} |`);
|
|
7834
|
+
lines.push("");
|
|
7835
|
+
}
|
|
7836
|
+
return lines.join("\n");
|
|
7837
|
+
}
|
|
7838
|
+
|
|
7839
|
+
// src/export/index-user.ts
|
|
7840
|
+
function link2(task) {
|
|
7841
|
+
return `[${task.name.replace(/\|/g, "\\|")}](../../tasks/${task.id}/task.md)`;
|
|
7842
|
+
}
|
|
7843
|
+
function cap(s) {
|
|
7844
|
+
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
7845
|
+
}
|
|
7846
|
+
function closedAt(task) {
|
|
7847
|
+
return task.date_closed ?? task.date_done ?? void 0;
|
|
7848
|
+
}
|
|
7849
|
+
function monthOf(ms) {
|
|
7850
|
+
return formatDateISO(ms).slice(0, 7);
|
|
7851
|
+
}
|
|
7852
|
+
function renderUserIndex(user, tasks, opts) {
|
|
7853
|
+
const lines = [
|
|
7854
|
+
`# ${user.username} \u2014 tasks`,
|
|
7855
|
+
"",
|
|
7856
|
+
`Exported ${opts.exportedAt.slice(0, 10)} \xB7 ${plural(tasks.length, "task")} assigned \xB7 user id ${user.id}`,
|
|
7857
|
+
""
|
|
7858
|
+
];
|
|
7859
|
+
const open = tasks.filter((t) => !isDoneStatus(t.status.status));
|
|
7860
|
+
const done = tasks.filter((t) => isDoneStatus(t.status.status));
|
|
7861
|
+
const byStatus = /* @__PURE__ */ new Map();
|
|
7862
|
+
for (const t of open) {
|
|
7863
|
+
const key = t.status.status.toLowerCase();
|
|
7864
|
+
byStatus.set(key, [...byStatus.get(key) ?? [], t]);
|
|
7865
|
+
}
|
|
7866
|
+
const order = (s) => /progress|review|active/.test(s) ? 0 : /to ?do|open|backlog/.test(s) ? 2 : 1;
|
|
7867
|
+
for (const [status, group] of [...byStatus.entries()].sort(
|
|
7868
|
+
(a, b) => order(a[0]) - order(b[0]) || a[0].localeCompare(b[0])
|
|
7869
|
+
)) {
|
|
7870
|
+
lines.push(
|
|
7871
|
+
`## ${cap(status)} (${group.length})`,
|
|
7872
|
+
"",
|
|
7873
|
+
"| Task | List | Due |",
|
|
7874
|
+
"| --- | --- | --- |"
|
|
7875
|
+
);
|
|
7876
|
+
for (const t of group) {
|
|
7877
|
+
lines.push(`| ${link2(t)} | ${t.list.name} | ${t.due_date ? formatDateISO(t.due_date) : ""} |`);
|
|
7878
|
+
}
|
|
7879
|
+
lines.push("");
|
|
7880
|
+
}
|
|
7881
|
+
if (done.length > 0) {
|
|
7882
|
+
lines.push(`## Done (${done.length})`, "");
|
|
7883
|
+
const byMonth = /* @__PURE__ */ new Map();
|
|
7884
|
+
for (const t of done) {
|
|
7885
|
+
const when = closedAt(t);
|
|
7886
|
+
const key = when ? monthOf(when) : "unknown";
|
|
7887
|
+
byMonth.set(key, [...byMonth.get(key) ?? [], t]);
|
|
7888
|
+
}
|
|
7889
|
+
for (const [month, group] of [...byMonth.entries()].sort((a, b) => b[0].localeCompare(a[0]))) {
|
|
7890
|
+
lines.push(`### ${month}`, "", "| Task | List | Closed |", "| --- | --- | --- |");
|
|
7891
|
+
for (const t of group) {
|
|
7892
|
+
const when = closedAt(t);
|
|
7893
|
+
lines.push(`| ${link2(t)} | ${t.list.name} | ${when ? formatDateISO(when) : ""} |`);
|
|
7894
|
+
}
|
|
7895
|
+
lines.push("");
|
|
7896
|
+
}
|
|
7897
|
+
}
|
|
7898
|
+
const where = /* @__PURE__ */ new Map();
|
|
7899
|
+
for (const t of tasks) {
|
|
7900
|
+
const space = t.space?.id ? opts.spaceNames[t.space.id] ?? t.space.id : "Unknown space";
|
|
7901
|
+
const key = `${space} / ${t.list.name}`;
|
|
7902
|
+
where.set(key, (where.get(key) ?? 0) + 1);
|
|
7903
|
+
}
|
|
7904
|
+
lines.push("## Where these live", "");
|
|
7905
|
+
for (const [key, n] of [...where.entries()].sort((a, b) => b[1] - a[1])) {
|
|
7906
|
+
lines.push(`- ${key}: ${n}`);
|
|
7907
|
+
}
|
|
7908
|
+
lines.push("");
|
|
7909
|
+
return lines.join("\n");
|
|
7910
|
+
}
|
|
7911
|
+
|
|
7912
|
+
// src/export/root-readme.ts
|
|
7913
|
+
import { writeFileSync as writeFileSync3 } from "fs";
|
|
7914
|
+
import { join as join5 } from "path";
|
|
7915
|
+
function renderRootReadme(manifest) {
|
|
7916
|
+
const ws = manifest.workspace;
|
|
7917
|
+
const taskCount = Object.keys(manifest.tasks).length;
|
|
7918
|
+
const docCount = Object.keys(manifest.docs).length;
|
|
7919
|
+
const lines = [
|
|
7920
|
+
`# ClickUp export${ws ? `: ${ws.name}` : ""}`,
|
|
7921
|
+
"",
|
|
7922
|
+
`${plural(taskCount, "task")} \xB7 ${plural(docCount, "doc")} \xB7 generated by \`cup export\``,
|
|
7923
|
+
"",
|
|
7924
|
+
"## Slices",
|
|
7925
|
+
"",
|
|
7926
|
+
"Each slice is an index into the shared task store. Start from whichever matches what you are looking for.",
|
|
7927
|
+
"",
|
|
7928
|
+
"| Slice | Kind | Scope | Tasks | Exported |",
|
|
7929
|
+
"| --- | --- | --- | --- | --- |"
|
|
7930
|
+
];
|
|
7931
|
+
const slices = Object.entries(manifest.slices).filter(([, s]) => s.kind !== "docs").sort((a, b) => a[0].localeCompare(b[0]));
|
|
7932
|
+
for (const [name, s] of slices) {
|
|
7933
|
+
lines.push(
|
|
7934
|
+
`| [${name}](slices/${name}/README.md) | ${s.kind} | ${s.scope} | ${s.taskCount} | ${s.exportedAt.slice(0, 10)} |`
|
|
7935
|
+
);
|
|
7936
|
+
}
|
|
7937
|
+
if (docCount > 0) lines.push("", "Docs: [docs/](docs/README.md)");
|
|
7938
|
+
lines.push(
|
|
7939
|
+
"",
|
|
7940
|
+
"## Layout",
|
|
7941
|
+
"",
|
|
7942
|
+
"- `tasks/<id>/` \u2014 one directory per task: `task.json` (lossless API payload), `task.md` (rendered), `comments.json` / `comments.md`, `attachments/`",
|
|
7943
|
+
"- `slices/<name>/README.md` \u2014 navigable index for one export scope",
|
|
7944
|
+
"- `manifest.json` \u2014 what has been exported and when; drives incremental re-runs",
|
|
7945
|
+
"",
|
|
7946
|
+
"Task ids match ClickUp URLs: `https://app.clickup.com/t/<id>` \u2192 `tasks/<id>/`.",
|
|
7947
|
+
""
|
|
7948
|
+
);
|
|
7949
|
+
return lines.join("\n");
|
|
7950
|
+
}
|
|
7951
|
+
function writeRootReadme(root, manifest) {
|
|
7952
|
+
writeFileSync3(join5(root, "README.md"), renderRootReadme(manifest));
|
|
7953
|
+
}
|
|
7954
|
+
|
|
7955
|
+
// src/util/rate-limit.ts
|
|
7956
|
+
function createRateLimiter(requestsPerMinute) {
|
|
7957
|
+
if (!(requestsPerMinute > 0)) {
|
|
7958
|
+
throw new Error(`requestsPerMinute must be a positive number, got ${requestsPerMinute}`);
|
|
7959
|
+
}
|
|
7960
|
+
const capacity = Math.max(5, Math.floor(requestsPerMinute / 10));
|
|
7961
|
+
const refillPerMs = requestsPerMinute / 6e4;
|
|
7962
|
+
let tokens = capacity;
|
|
7963
|
+
let lastRefill = Date.now();
|
|
7964
|
+
const queue = [];
|
|
7965
|
+
let timer;
|
|
7966
|
+
function refill() {
|
|
7967
|
+
const now = Date.now();
|
|
7968
|
+
tokens = Math.min(capacity, tokens + (now - lastRefill) * refillPerMs);
|
|
7969
|
+
lastRefill = now;
|
|
7970
|
+
}
|
|
7971
|
+
function drain() {
|
|
7972
|
+
timer = void 0;
|
|
7973
|
+
refill();
|
|
7974
|
+
while (queue.length > 0 && tokens >= 1) {
|
|
7975
|
+
tokens -= 1;
|
|
7976
|
+
queue.shift()();
|
|
7977
|
+
}
|
|
7978
|
+
if (queue.length > 0) {
|
|
7979
|
+
const waitMs = Math.ceil((1 - tokens) / refillPerMs);
|
|
7980
|
+
timer = setTimeout(drain, waitMs);
|
|
7981
|
+
}
|
|
7982
|
+
}
|
|
7983
|
+
return {
|
|
7984
|
+
acquire() {
|
|
7985
|
+
return new Promise((resolve3) => {
|
|
7986
|
+
queue.push(resolve3);
|
|
7987
|
+
if (!timer) drain();
|
|
7988
|
+
});
|
|
7989
|
+
},
|
|
7990
|
+
penalize() {
|
|
7991
|
+
refill();
|
|
7992
|
+
tokens = 0;
|
|
7993
|
+
}
|
|
7994
|
+
};
|
|
7995
|
+
}
|
|
7996
|
+
|
|
7997
|
+
// src/commands/export.ts
|
|
7998
|
+
var CONCURRENCY = 4;
|
|
7999
|
+
function requireTeam(config) {
|
|
8000
|
+
if (!config.teamId) {
|
|
8001
|
+
throw new Error("Export requires a teamId in your config. Run `cup init` to set one.");
|
|
8002
|
+
}
|
|
8003
|
+
return config.teamId;
|
|
8004
|
+
}
|
|
8005
|
+
function makeClient(config, rpm) {
|
|
8006
|
+
return new ClickUpClient({ ...config, rateLimiter: createRateLimiter(rpm) });
|
|
8007
|
+
}
|
|
8008
|
+
function emptySummary(plan, opts) {
|
|
8009
|
+
return {
|
|
8010
|
+
slice: plan.slice.name,
|
|
8011
|
+
planned: plan.tasks.length,
|
|
8012
|
+
out: resolve(opts.out),
|
|
8013
|
+
dryRun: opts.dryRun,
|
|
8014
|
+
fetched: 0,
|
|
8015
|
+
skipped: 0,
|
|
8016
|
+
failed: [],
|
|
8017
|
+
attachmentsDownloaded: 0,
|
|
8018
|
+
attachmentsFailed: 0
|
|
8019
|
+
};
|
|
8020
|
+
}
|
|
8021
|
+
async function fetchTypeNames(client, teamId) {
|
|
8022
|
+
try {
|
|
8023
|
+
const types = await client.getCustomTaskTypes(teamId);
|
|
8024
|
+
return Object.fromEntries(types.map((t) => [t.id, t.name]));
|
|
8025
|
+
} catch {
|
|
8026
|
+
return {};
|
|
8027
|
+
}
|
|
8028
|
+
}
|
|
8029
|
+
function describePlan(plan) {
|
|
8030
|
+
const initiatives = plan.tasks.filter((t) => t.initiative).length;
|
|
8031
|
+
return `Plan [${plan.slice.name}]: ${plural(plan.tasks.length, "task")} (${plural(initiatives, "initiative")}) in workspace "${plan.workspace.name}"`;
|
|
8032
|
+
}
|
|
8033
|
+
async function execute(client, teamId, plan, opts, writeIndex) {
|
|
8034
|
+
opts.log(describePlan(plan));
|
|
8035
|
+
const summary = emptySummary(plan, opts);
|
|
8036
|
+
if (opts.dryRun) return summary;
|
|
8037
|
+
const root = resolve(opts.out);
|
|
8038
|
+
const spaces = await client.getSpaces(teamId);
|
|
8039
|
+
const spaceNames = Object.fromEntries(spaces.map((s) => [s.id, s.name]));
|
|
8040
|
+
const run2 = await runExport(client, plan, {
|
|
8041
|
+
root,
|
|
8042
|
+
refresh: opts.refresh,
|
|
8043
|
+
downloadAttachments: opts.attachments,
|
|
8044
|
+
concurrency: CONCURRENCY,
|
|
8045
|
+
log: opts.log,
|
|
8046
|
+
spaceNames
|
|
8047
|
+
});
|
|
8048
|
+
await writeIndex(root, spaceNames);
|
|
8049
|
+
writeRootReadme(root, loadManifest(root));
|
|
8050
|
+
return { ...summary, ...run2 };
|
|
8051
|
+
}
|
|
8052
|
+
async function exportUser(config, userRef, opts) {
|
|
8053
|
+
const teamId = requireTeam(config);
|
|
8054
|
+
const client = makeClient(config, opts.rpm);
|
|
8055
|
+
const user = await resolveUserRef(client, teamId, userRef);
|
|
8056
|
+
const plan = await discoverUserTasks(client, teamId, userRef);
|
|
8057
|
+
return execute(client, teamId, plan, opts, async (root, spaceNames) => {
|
|
8058
|
+
const tasks = Object.values(plan.tasksById ?? {});
|
|
8059
|
+
const dir = join6(root, "slices", plan.slice.name);
|
|
8060
|
+
mkdirSync4(dir, { recursive: true });
|
|
8061
|
+
writeFileSync4(
|
|
8062
|
+
join6(dir, "README.md"),
|
|
8063
|
+
renderUserIndex(user, tasks, { exportedAt: (/* @__PURE__ */ new Date()).toISOString(), spaceNames })
|
|
8064
|
+
);
|
|
8065
|
+
writeFileSync4(
|
|
8066
|
+
join6(dir, "tasks.json"),
|
|
8067
|
+
JSON.stringify({ user, taskIds: plan.tasks.map((t) => t.id) }, null, 2) + "\n"
|
|
8068
|
+
);
|
|
8069
|
+
});
|
|
8070
|
+
}
|
|
8071
|
+
function formatExportSummary(s) {
|
|
8072
|
+
if (s.dryRun) return `Dry run: ${plural(s.planned, "task")} would be exported to ${s.out}`;
|
|
8073
|
+
const lines = [
|
|
8074
|
+
`Exported slice "${s.slice}" to ${s.out}`,
|
|
8075
|
+
` tasks: ${s.fetched} fetched, ${s.skipped} already present, ${s.failed.length} failed`,
|
|
8076
|
+
` attachments: ${s.attachmentsDownloaded} downloaded, ${s.attachmentsFailed} failed`
|
|
8077
|
+
];
|
|
8078
|
+
for (const f of s.failed.slice(0, 10)) lines.push(` failed ${f.id}: ${f.error}`);
|
|
8079
|
+
if (s.failed.length > 10) lines.push(` ... and ${s.failed.length - 10} more`);
|
|
8080
|
+
return lines.join("\n");
|
|
8081
|
+
}
|
|
8082
|
+
function writeSliceFiles(root, sliceName, readme, meta) {
|
|
8083
|
+
const dir = join6(root, "slices", sliceName);
|
|
8084
|
+
mkdirSync4(dir, { recursive: true });
|
|
8085
|
+
writeFileSync4(join6(dir, "README.md"), readme);
|
|
8086
|
+
writeFileSync4(join6(dir, "tasks.json"), JSON.stringify(meta, null, 2) + "\n");
|
|
8087
|
+
}
|
|
8088
|
+
async function exportTeam(config, spaceRef, opts) {
|
|
8089
|
+
const teamId = requireTeam(config);
|
|
8090
|
+
const client = makeClient(config, opts.rpm);
|
|
8091
|
+
const plan = await discoverTeamTasks(client, teamId, spaceRef);
|
|
8092
|
+
return execute(client, teamId, plan, opts, async (root) => {
|
|
8093
|
+
const manifest = loadManifest(root);
|
|
8094
|
+
const relatedSlices = Object.entries(manifest.slices).filter(([, s]) => s.kind === "roadmap" || s.kind === "initiatives").map(([name, s]) => ({ name, listId: s.scope }));
|
|
8095
|
+
const tasks = Object.values(plan.tasksById ?? {});
|
|
8096
|
+
const typeNames = await fetchTypeNames(client, teamId);
|
|
8097
|
+
writeSliceFiles(
|
|
8098
|
+
root,
|
|
8099
|
+
plan.slice.name,
|
|
8100
|
+
renderTeamIndex(plan.hierarchy, tasks, {
|
|
8101
|
+
exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
8102
|
+
relatedSlices,
|
|
8103
|
+
initiativeItemId: opts.initiativeItemId,
|
|
8104
|
+
typeNames
|
|
8105
|
+
}),
|
|
8106
|
+
{ hierarchy: plan.hierarchy, taskIds: plan.tasks.map((t) => t.id) }
|
|
8107
|
+
);
|
|
8108
|
+
});
|
|
8109
|
+
}
|
|
8110
|
+
async function exportRoadmap(config, listId, opts) {
|
|
8111
|
+
const teamId = requireTeam(config);
|
|
8112
|
+
const client = makeClient(config, opts.rpm);
|
|
8113
|
+
const plan = await discoverListTasks(client, teamId, listId, { kind: "roadmap" });
|
|
8114
|
+
return execute(client, teamId, plan, opts, async (root) => {
|
|
8115
|
+
const tasks = Object.values(plan.tasksById ?? {});
|
|
8116
|
+
writeSliceFiles(
|
|
8117
|
+
root,
|
|
8118
|
+
plan.slice.name,
|
|
8119
|
+
renderRoadmapIndex(plan.list, tasks, {
|
|
8120
|
+
exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
8121
|
+
initiativeItemId: opts.initiativeItemId
|
|
8122
|
+
}),
|
|
8123
|
+
{
|
|
8124
|
+
list: plan.list,
|
|
8125
|
+
initiativeItemId: opts.initiativeItemId ?? null,
|
|
8126
|
+
taskIds: plan.tasks.map((t) => t.id)
|
|
8127
|
+
}
|
|
8128
|
+
);
|
|
8129
|
+
});
|
|
8130
|
+
}
|
|
8131
|
+
async function exportInitiatives(config, listId, opts) {
|
|
8132
|
+
const teamId = requireTeam(config);
|
|
8133
|
+
if (opts.initiativeItemId === void 0) {
|
|
8134
|
+
throw new Error(
|
|
8135
|
+
"initiatives export needs --item-id <n>: the custom_item_id your workspace uses for initiatives (see `cup task-types`)"
|
|
8136
|
+
);
|
|
8137
|
+
}
|
|
8138
|
+
const client = makeClient(config, opts.rpm);
|
|
8139
|
+
const plan = await discoverListTasks(client, teamId, listId, {
|
|
8140
|
+
kind: "initiatives",
|
|
8141
|
+
initiativeItemId: opts.initiativeItemId
|
|
8142
|
+
});
|
|
8143
|
+
return execute(client, teamId, plan, opts, async (root) => {
|
|
8144
|
+
const { readBundleData: readBundleData2 } = await import("./writer-UZHLSQMW.js");
|
|
8145
|
+
const manifest = loadManifest(root);
|
|
8146
|
+
const wanted = /* @__PURE__ */ new Set();
|
|
8147
|
+
const queue = plan.tasks.map((t) => t.id);
|
|
8148
|
+
while (queue.length > 0) {
|
|
8149
|
+
const id = queue.pop();
|
|
8150
|
+
if (wanted.has(id) || !manifest.tasks[id]) continue;
|
|
8151
|
+
wanted.add(id);
|
|
8152
|
+
const b = await readBundleData2(root, id);
|
|
8153
|
+
queue.push(...b.subtaskIds);
|
|
8154
|
+
}
|
|
8155
|
+
const tasks = await Promise.all(
|
|
8156
|
+
[...wanted].map((id) => readBundleData2(root, id).then((b) => b.task))
|
|
8157
|
+
);
|
|
8158
|
+
writeSliceFiles(
|
|
8159
|
+
root,
|
|
8160
|
+
plan.slice.name,
|
|
8161
|
+
renderRoadmapIndex(plan.list, tasks, {
|
|
8162
|
+
exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
8163
|
+
initiativeItemId: opts.initiativeItemId
|
|
8164
|
+
}),
|
|
8165
|
+
{
|
|
8166
|
+
list: plan.list,
|
|
8167
|
+
initiativeItemId: opts.initiativeItemId,
|
|
8168
|
+
taskIds: plan.tasks.map((t) => t.id)
|
|
8169
|
+
}
|
|
8170
|
+
);
|
|
8171
|
+
});
|
|
8172
|
+
}
|
|
8173
|
+
async function exportDocs2(config, opts) {
|
|
8174
|
+
const teamId = requireTeam(config);
|
|
8175
|
+
const client = makeClient(config, opts.rpm);
|
|
8176
|
+
const root = resolve(opts.out);
|
|
8177
|
+
if (opts.dryRun) {
|
|
8178
|
+
const docs = await client.getAllDocs(teamId);
|
|
8179
|
+
opts.log(`Plan [docs]: ${plural(docs.length, "doc")}`);
|
|
8180
|
+
return {
|
|
8181
|
+
slice: "docs",
|
|
8182
|
+
out: root,
|
|
8183
|
+
dryRun: true,
|
|
8184
|
+
docs: docs.length,
|
|
8185
|
+
pages: 0,
|
|
8186
|
+
skipped: 0,
|
|
8187
|
+
failed: []
|
|
8188
|
+
};
|
|
8189
|
+
}
|
|
8190
|
+
const summary = await exportDocs(client, teamId, {
|
|
8191
|
+
root,
|
|
8192
|
+
refresh: opts.refresh,
|
|
8193
|
+
log: opts.log
|
|
8194
|
+
});
|
|
8195
|
+
writeRootReadme(root, loadManifest(root));
|
|
8196
|
+
return { slice: "docs", out: root, dryRun: false, ...summary };
|
|
8197
|
+
}
|
|
8198
|
+
function formatDocsSummary(s) {
|
|
8199
|
+
if (s.dryRun) return `Dry run: ${plural(s.docs, "doc")} would be exported to ${s.out}`;
|
|
8200
|
+
const lines = [
|
|
8201
|
+
`Exported docs to ${s.out}/docs`,
|
|
8202
|
+
` docs: ${s.docs} fetched (${s.pages} pages), ${s.skipped} already present, ${s.failed.length} failed`
|
|
8203
|
+
];
|
|
8204
|
+
for (const f of s.failed.slice(0, 10)) lines.push(` failed ${f.id}: ${f.error}`);
|
|
8205
|
+
return lines.join("\n");
|
|
8206
|
+
}
|
|
8207
|
+
var REQUESTS_PER_TASK = 3;
|
|
8208
|
+
async function exportAll(config, opts) {
|
|
8209
|
+
const teamId = requireTeam(config);
|
|
8210
|
+
const client = makeClient(config, opts.rpm);
|
|
8211
|
+
const root = resolve(opts.out);
|
|
8212
|
+
const [spaces, docs, teams] = await Promise.all([
|
|
8213
|
+
client.getSpaces(teamId),
|
|
8214
|
+
client.getAllDocs(teamId),
|
|
8215
|
+
client.getTeams()
|
|
8216
|
+
]);
|
|
8217
|
+
const workspaceName = teams.find((t) => t.id === teamId)?.name ?? teamId;
|
|
8218
|
+
const plans = [];
|
|
8219
|
+
for (const space of spaces) plans.push(await discoverTeamTasks(client, teamId, space.id));
|
|
8220
|
+
const manifest = loadManifest(root);
|
|
8221
|
+
const allTaskIds = new Set(plans.flatMap((p) => p.tasks.map((t) => t.id)));
|
|
8222
|
+
const alreadyExported = [...allTaskIds].filter((id) => manifest.tasks[id]).length;
|
|
8223
|
+
const toFetch = allTaskIds.size - alreadyExported;
|
|
8224
|
+
const listCount = plans.reduce(
|
|
8225
|
+
(n, p) => n + (p.hierarchy?.lists.length ?? 0) + (p.hierarchy?.folders.reduce((m, f) => m + f.lists.length, 0) ?? 0),
|
|
8226
|
+
0
|
|
8227
|
+
);
|
|
8228
|
+
const estRequests = toFetch * REQUESTS_PER_TASK + docs.length;
|
|
8229
|
+
const estMinutes = Math.ceil(estRequests / opts.rpm);
|
|
8230
|
+
opts.log(`Workspace export plan for "${workspaceName}":`);
|
|
8231
|
+
opts.log(
|
|
8232
|
+
` ${plural(spaces.length, "space")}, ${plural(listCount, "list")}, ${plural(allTaskIds.size, "task")}, ${plural(docs.length, "doc")}`
|
|
8233
|
+
);
|
|
8234
|
+
opts.log(` Already exported: ${plural(alreadyExported, "task")} (will be skipped)`);
|
|
8235
|
+
opts.log(` Estimated requests: ~${estRequests}`);
|
|
8236
|
+
opts.log(
|
|
8237
|
+
` Estimated time at ${opts.rpm} req/min: ~${estMinutes < 60 ? `${estMinutes}m` : `${Math.floor(estMinutes / 60)}h ${estMinutes % 60}m`}`
|
|
8238
|
+
);
|
|
8239
|
+
if (opts.attachments) opts.log(" Attachments: downloaded (size unknown until fetched)");
|
|
8240
|
+
const empty = {
|
|
8241
|
+
slice: "all",
|
|
8242
|
+
out: root,
|
|
8243
|
+
dryRun: opts.dryRun,
|
|
8244
|
+
spaces: plans.map((p) => p.slice.name),
|
|
8245
|
+
planned: allTaskIds.size,
|
|
8246
|
+
fetched: 0,
|
|
8247
|
+
skipped: 0,
|
|
8248
|
+
failed: [],
|
|
8249
|
+
attachmentsDownloaded: 0,
|
|
8250
|
+
attachmentsFailed: 0,
|
|
8251
|
+
docs: { docs: docs.length, pages: 0, skipped: 0, failed: [] }
|
|
8252
|
+
};
|
|
8253
|
+
if (opts.dryRun) return empty;
|
|
8254
|
+
if (!opts.yes) {
|
|
8255
|
+
if (!isTTY()) {
|
|
8256
|
+
throw new Error(
|
|
8257
|
+
"This is a long-running operation. Re-run with --yes to confirm in non-interactive mode."
|
|
8258
|
+
);
|
|
8259
|
+
}
|
|
8260
|
+
const { confirm: confirm3 } = await import("@inquirer/prompts");
|
|
8261
|
+
const ok = await confirm3({
|
|
8262
|
+
message: `Export the whole workspace to ${root}?`,
|
|
8263
|
+
default: false
|
|
8264
|
+
});
|
|
8265
|
+
if (!ok) throw new Error("Cancelled");
|
|
8266
|
+
}
|
|
8267
|
+
const spaceNames = Object.fromEntries(spaces.map((s) => [s.id, s.name]));
|
|
8268
|
+
const typeNames = await fetchTypeNames(client, teamId);
|
|
8269
|
+
const summary = { ...empty };
|
|
8270
|
+
for (const plan of plans) {
|
|
8271
|
+
const run2 = await runExport(client, plan, {
|
|
8272
|
+
root,
|
|
8273
|
+
refresh: opts.refresh,
|
|
8274
|
+
downloadAttachments: opts.attachments,
|
|
8275
|
+
concurrency: CONCURRENCY,
|
|
8276
|
+
log: opts.log,
|
|
8277
|
+
spaceNames
|
|
8278
|
+
});
|
|
8279
|
+
summary.fetched += run2.fetched;
|
|
8280
|
+
summary.skipped += run2.skipped;
|
|
8281
|
+
summary.failed.push(...run2.failed);
|
|
8282
|
+
summary.attachmentsDownloaded += run2.attachmentsDownloaded;
|
|
8283
|
+
summary.attachmentsFailed += run2.attachmentsFailed;
|
|
8284
|
+
const tasks = Object.values(plan.tasksById ?? {});
|
|
8285
|
+
writeSliceFiles(
|
|
8286
|
+
root,
|
|
8287
|
+
plan.slice.name,
|
|
8288
|
+
renderTeamIndex(plan.hierarchy, tasks, {
|
|
8289
|
+
exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
8290
|
+
initiativeItemId: opts.initiativeItemId,
|
|
8291
|
+
typeNames
|
|
8292
|
+
}),
|
|
8293
|
+
{ hierarchy: plan.hierarchy, taskIds: plan.tasks.map((t) => t.id) }
|
|
8294
|
+
);
|
|
8295
|
+
}
|
|
8296
|
+
summary.docs = await exportDocs(client, teamId, { root, refresh: opts.refresh, log: opts.log });
|
|
8297
|
+
writeRootReadme(root, loadManifest(root));
|
|
8298
|
+
return summary;
|
|
8299
|
+
}
|
|
8300
|
+
function formatAllSummary(s) {
|
|
8301
|
+
if (s.dryRun)
|
|
8302
|
+
return `Dry run: ${plural(s.planned, "task")} across ${plural(s.spaces.length, "space")} would be exported to ${s.out}`;
|
|
8303
|
+
const lines = [
|
|
8304
|
+
`Exported workspace to ${s.out}`,
|
|
8305
|
+
` spaces: ${s.spaces.length} (${s.spaces.join(", ")})`,
|
|
8306
|
+
` tasks: ${s.fetched} fetched, ${s.skipped} already present, ${s.failed.length} failed`,
|
|
8307
|
+
` attachments: ${s.attachmentsDownloaded} downloaded, ${s.attachmentsFailed} failed`,
|
|
8308
|
+
` docs: ${s.docs.docs} fetched (${s.docs.pages} pages), ${s.docs.skipped} already present, ${s.docs.failed.length} failed`
|
|
8309
|
+
];
|
|
8310
|
+
for (const f of s.failed.slice(0, 10)) lines.push(` failed ${f.id}: ${f.error}`);
|
|
8311
|
+
if (s.failed.length > 10) lines.push(` ... and ${s.failed.length - 10} more`);
|
|
8312
|
+
return lines.join("\n");
|
|
8313
|
+
}
|
|
8314
|
+
|
|
7184
8315
|
// src/commands/docs.ts
|
|
7185
8316
|
var DOC_COLUMNS = [
|
|
7186
8317
|
{ key: "id", label: "ID", maxWidth: 15 },
|
|
@@ -7606,28 +8737,6 @@ function formatFieldsMarkdown(fields) {
|
|
|
7606
8737
|
}).join("\n");
|
|
7607
8738
|
}
|
|
7608
8739
|
|
|
7609
|
-
// src/util/batch.ts
|
|
7610
|
-
async function runInBatches(items, concurrency, fn) {
|
|
7611
|
-
if (!Number.isInteger(concurrency) || concurrency < 1) {
|
|
7612
|
-
throw new Error(`concurrency must be a positive integer, got ${concurrency}`);
|
|
7613
|
-
}
|
|
7614
|
-
const results = [];
|
|
7615
|
-
for (let i = 0; i < items.length; i += concurrency) {
|
|
7616
|
-
const batch = items.slice(i, i + concurrency);
|
|
7617
|
-
const settled = await Promise.allSettled(batch.map(fn));
|
|
7618
|
-
settled.forEach((res, idx) => {
|
|
7619
|
-
const item = batch[idx];
|
|
7620
|
-
if (res.status === "fulfilled") {
|
|
7621
|
-
results.push({ item, ok: true, result: res.value });
|
|
7622
|
-
} else {
|
|
7623
|
-
const error = res.reason instanceof Error ? res.reason : new Error(String(res.reason), { cause: res.reason });
|
|
7624
|
-
results.push({ item, ok: false, error });
|
|
7625
|
-
}
|
|
7626
|
-
});
|
|
7627
|
-
}
|
|
7628
|
-
return results;
|
|
7629
|
-
}
|
|
7630
|
-
|
|
7631
8740
|
// src/commands/field-create.ts
|
|
7632
8741
|
var FIELD_CREATE_CONCURRENCY = 5;
|
|
7633
8742
|
var VALID_FIELD_TYPES = [
|
|
@@ -8744,10 +9853,10 @@ function resolveRequiredMessage(opts) {
|
|
|
8744
9853
|
}
|
|
8745
9854
|
async function resolveMentions(client, teamId, mentions) {
|
|
8746
9855
|
if (mentions.length === 0) return [];
|
|
8747
|
-
const
|
|
9856
|
+
const resolve3 = createCachedMemberResolver(client, teamId);
|
|
8748
9857
|
const ids = [];
|
|
8749
9858
|
for (const mention of mentions) {
|
|
8750
|
-
ids.push(await
|
|
9859
|
+
ids.push(await resolve3(mention));
|
|
8751
9860
|
}
|
|
8752
9861
|
return ids;
|
|
8753
9862
|
}
|
|
@@ -9040,6 +10149,120 @@ function buildProgram(programName = basename(process.argv[1] ?? "cup")) {
|
|
|
9040
10149
|
}
|
|
9041
10150
|
)
|
|
9042
10151
|
);
|
|
10152
|
+
const exportCmd = program.command("export").description("Export tasks and docs to a local archive (lossless JSON + rendered markdown)");
|
|
10153
|
+
function withExportOptions(cmd) {
|
|
10154
|
+
return cmd.option(
|
|
10155
|
+
"--out <dir>",
|
|
10156
|
+
"Archive directory (slices compose into the same dir)",
|
|
10157
|
+
"./clickup-export"
|
|
10158
|
+
).option("--refresh", "Re-fetch tasks already present in the archive").option("--no-attachments", "Skip downloading attachment binaries (metadata still written)").option("--dry-run", "Discover and print the plan without fetching or writing").option("--rpm <n>", "Request throttle per minute (ClickUp Business limit is 100)", "90").option("--yes", "Skip confirmation prompts (required for non-interactive `export all`)").option(
|
|
10159
|
+
"--item-id <n>",
|
|
10160
|
+
"custom_item_id that marks an initiative in your workspace (see `cup task-types`)"
|
|
10161
|
+
).option("--json", "Force JSON output even in terminal");
|
|
10162
|
+
}
|
|
10163
|
+
function toExportOptions(opts) {
|
|
10164
|
+
const rpm = Number(opts.rpm);
|
|
10165
|
+
if (!Number.isFinite(rpm) || rpm <= 0) throw new Error("--rpm must be a positive number");
|
|
10166
|
+
let initiativeItemId;
|
|
10167
|
+
if (opts.itemId !== void 0) {
|
|
10168
|
+
initiativeItemId = Number(opts.itemId);
|
|
10169
|
+
if (!Number.isInteger(initiativeItemId) || initiativeItemId <= 0) {
|
|
10170
|
+
throw new Error("--item-id must be a positive integer");
|
|
10171
|
+
}
|
|
10172
|
+
}
|
|
10173
|
+
return {
|
|
10174
|
+
out: opts.out,
|
|
10175
|
+
refresh: opts.refresh ?? false,
|
|
10176
|
+
attachments: opts.attachments,
|
|
10177
|
+
dryRun: opts.dryRun ?? false,
|
|
10178
|
+
rpm,
|
|
10179
|
+
log: (line) => process.stderr.write(line + "\n"),
|
|
10180
|
+
...initiativeItemId !== void 0 ? { initiativeItemId } : {},
|
|
10181
|
+
yes: opts.yes ?? false
|
|
10182
|
+
};
|
|
10183
|
+
}
|
|
10184
|
+
function printExportSummary(summary, json) {
|
|
10185
|
+
if (shouldOutputJson(json)) {
|
|
10186
|
+
console.log(JSON.stringify(summary, null, 2));
|
|
10187
|
+
} else {
|
|
10188
|
+
console.log(formatExportSummary(summary));
|
|
10189
|
+
}
|
|
10190
|
+
if (summary.failed.length > 0) process.exitCode = 1;
|
|
10191
|
+
}
|
|
10192
|
+
withExportOptions(
|
|
10193
|
+
exportCmd.command("user <userRef>").description(
|
|
10194
|
+
"Export every task assigned to a user (me, id, email, or username), incl. closed and archived"
|
|
10195
|
+
)
|
|
10196
|
+
).action(
|
|
10197
|
+
wrapAction(async (userRef, opts) => {
|
|
10198
|
+
const config = loadConfig(getProfileName());
|
|
10199
|
+
const summary = await exportUser(config, userRef, toExportOptions(opts));
|
|
10200
|
+
printExportSummary(summary, opts.json ?? false);
|
|
10201
|
+
})
|
|
10202
|
+
);
|
|
10203
|
+
withExportOptions(
|
|
10204
|
+
exportCmd.command("team <spaceRef>").description("Export every list in a space (by id or name), incl. archived lists and tasks")
|
|
10205
|
+
).action(
|
|
10206
|
+
wrapAction(async (spaceRef, opts) => {
|
|
10207
|
+
const config = loadConfig(getProfileName());
|
|
10208
|
+
const summary = await exportTeam(config, spaceRef, toExportOptions(opts));
|
|
10209
|
+
printExportSummary(summary, opts.json ?? false);
|
|
10210
|
+
})
|
|
10211
|
+
);
|
|
10212
|
+
withExportOptions(
|
|
10213
|
+
exportCmd.command("roadmap <listId>").description(
|
|
10214
|
+
"Export a list with initiatives grouped and their subtask trees (use --item-id)"
|
|
10215
|
+
)
|
|
10216
|
+
).action(
|
|
10217
|
+
wrapAction(async (listId, opts) => {
|
|
10218
|
+
const config = loadConfig(getProfileName());
|
|
10219
|
+
const summary = await exportRoadmap(config, listId, toExportOptions(opts));
|
|
10220
|
+
printExportSummary(summary, opts.json ?? false);
|
|
10221
|
+
})
|
|
10222
|
+
);
|
|
10223
|
+
withExportOptions(
|
|
10224
|
+
exportCmd.command("docs").description(
|
|
10225
|
+
"Export every workspace doc as markdown page trees (ClickUp has no bulk doc export)"
|
|
10226
|
+
)
|
|
10227
|
+
).action(
|
|
10228
|
+
wrapAction(async (opts) => {
|
|
10229
|
+
const config = loadConfig(getProfileName());
|
|
10230
|
+
const summary = await exportDocs2(config, toExportOptions(opts));
|
|
10231
|
+
if (shouldOutputJson(opts.json ?? false)) {
|
|
10232
|
+
console.log(JSON.stringify(summary, null, 2));
|
|
10233
|
+
} else {
|
|
10234
|
+
console.log(formatDocsSummary(summary));
|
|
10235
|
+
}
|
|
10236
|
+
if (summary.failed.length > 0) process.exitCode = 1;
|
|
10237
|
+
})
|
|
10238
|
+
);
|
|
10239
|
+
withExportOptions(
|
|
10240
|
+
exportCmd.command("all").description(
|
|
10241
|
+
"Export the whole workspace: every space as a team slice, plus all docs. Plans first and asks for confirmation"
|
|
10242
|
+
)
|
|
10243
|
+
).action(
|
|
10244
|
+
wrapAction(async (opts) => {
|
|
10245
|
+
const config = loadConfig(getProfileName());
|
|
10246
|
+
const summary = await exportAll(config, toExportOptions(opts));
|
|
10247
|
+
if (shouldOutputJson(opts.json ?? false)) {
|
|
10248
|
+
console.log(JSON.stringify(summary, null, 2));
|
|
10249
|
+
} else {
|
|
10250
|
+
console.log(formatAllSummary(summary));
|
|
10251
|
+
}
|
|
10252
|
+
if (summary.failed.length > 0 || summary.docs.failed.length > 0) process.exitCode = 1;
|
|
10253
|
+
})
|
|
10254
|
+
);
|
|
10255
|
+
withExportOptions(
|
|
10256
|
+
exportCmd.command("initiatives <listId>").description(
|
|
10257
|
+
"Export only initiative-typed tasks in a list plus their subtask trees (requires --item-id)"
|
|
10258
|
+
)
|
|
10259
|
+
).action(
|
|
10260
|
+
wrapAction(async (listId, opts) => {
|
|
10261
|
+
const config = loadConfig(getProfileName());
|
|
10262
|
+
const summary = await exportInitiatives(config, listId, toExportOptions(opts));
|
|
10263
|
+
printExportSummary(summary, opts.json ?? false);
|
|
10264
|
+
})
|
|
10265
|
+
);
|
|
9043
10266
|
program.command("sprint").description("List my tasks in the current active sprint (auto-detected)").option("--status <status>", "Filter by status").option("--space <nameOrId>", "Narrow sprint search to a specific space (partial name or ID)").option("--folder <folderId>", "Sprint folder ID (overrides config and auto-detection)").option("--include-closed", "Include done/closed tasks").option("--json", "Force JSON output even in terminal").action(
|
|
9044
10267
|
wrapAction(
|
|
9045
10268
|
async (opts) => {
|
|
@@ -11216,7 +12439,7 @@ process.on("SIGINT", () => {
|
|
|
11216
12439
|
});
|
|
11217
12440
|
function checkDirectExecution() {
|
|
11218
12441
|
try {
|
|
11219
|
-
return process.argv[1] !== void 0 && fileURLToPath(import.meta.url) === realpathSync2(
|
|
12442
|
+
return process.argv[1] !== void 0 && fileURLToPath(import.meta.url) === realpathSync2(resolve2(process.argv[1]));
|
|
11220
12443
|
} catch {
|
|
11221
12444
|
return false;
|
|
11222
12445
|
}
|