@krodak/clickup-cli 1.43.0 → 1.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "clickup-cli",
3
3
  "description": "ClickUp CLI skills for managing tasks, sprints, comments, checklists, custom fields, tags, and time tracking via the cup command",
4
- "version": "1.43.0",
4
+ "version": "1.45.0",
5
5
  "author": {
6
6
  "name": "Krzysztof Rodak"
7
7
  },
package/README.md CHANGED
@@ -188,6 +188,7 @@ Full CRUD for the core ClickUp workflow:
188
188
  | 🔗 **Webhooks** | List, create, update, delete webhooks; scope to space, folder, list, or task |
189
189
  | 🏢 **Workspace** | Spaces, folders, lists (full CRUD + rename + from template; subfolder parent IDs in JSON), members, user groups, task types, templates, plan, shared hierarchy |
190
190
  | 📎 **Attachments** | Upload files to tasks, list task attachments, shown in detail views |
191
+ | 📦 **Export** | Archive tasks and docs as lossless JSON + markdown: by user, space, roadmap list (initiatives grouped), or whole workspace; comment threads, subtask trees, custom fields, attachment binaries |
191
192
 
192
193
  [Full API coverage details](docs/api-coverage.md) | [Command reference](docs/commands.md)
193
194
 
@@ -0,0 +1,324 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/export/writer.ts
4
+ import { createHash } from "crypto";
5
+ import { existsSync } from "fs";
6
+ import { mkdir, readFile, writeFile } from "fs/promises";
7
+ import { join } from "path";
8
+
9
+ // src/date.ts
10
+ function formatDate(ms) {
11
+ return new Date(Number(ms)).toLocaleDateString("en-US", {
12
+ month: "short",
13
+ day: "numeric",
14
+ year: "numeric"
15
+ });
16
+ }
17
+ function formatTimestamp(ms) {
18
+ return new Date(Number(ms)).toLocaleString("en-US", {
19
+ month: "short",
20
+ day: "numeric",
21
+ hour: "numeric",
22
+ minute: "2-digit"
23
+ });
24
+ }
25
+ function formatDuration(ms) {
26
+ const totalMinutes = Math.round(Math.abs(ms) / 6e4);
27
+ const hours = Math.floor(totalMinutes / 60);
28
+ const minutes = totalMinutes % 60;
29
+ if (hours > 0 && minutes > 0) return `${hours}h ${minutes}m`;
30
+ if (hours > 0) return `${hours}h`;
31
+ return `${minutes}m`;
32
+ }
33
+ function formatLongDuration(ms) {
34
+ const totalMinutes = Math.round(Math.abs(ms) / 6e4);
35
+ if (totalMinutes === 0) return "< 1m";
36
+ const days = Math.floor(totalMinutes / 1440);
37
+ const hours = Math.floor(totalMinutes % 1440 / 60);
38
+ const minutes = totalMinutes % 60;
39
+ const parts = [];
40
+ if (days > 0) parts.push(`${days}d`);
41
+ if (hours > 0) parts.push(`${hours}h`);
42
+ if (minutes > 0) parts.push(`${minutes}m`);
43
+ return parts.join(" ");
44
+ }
45
+ function formatDateISO(ms) {
46
+ const d = new Date(Number(ms));
47
+ const year = d.getUTCFullYear();
48
+ const month = String(d.getUTCMonth() + 1).padStart(2, "0");
49
+ const day = String(d.getUTCDate()).padStart(2, "0");
50
+ return `${year}-${month}-${day}`;
51
+ }
52
+
53
+ // src/export/render.ts
54
+ var TASK_URL = (id) => `https://app.clickup.com/t/${id}`;
55
+ function taskLink(id, name, ctx) {
56
+ const label = name ?? id;
57
+ return ctx.hasTask(id) ? `[${label}](../${id}/task.md)` : `[${label}](${TASK_URL(id)}) (not exported)`;
58
+ }
59
+ function isoDateTime(ms) {
60
+ if (ms == null || ms === "") return void 0;
61
+ const d = new Date(Number(ms));
62
+ return Number.isNaN(d.getTime()) ? void 0 : d.toISOString();
63
+ }
64
+ function optionLabel(field, value) {
65
+ const options = field.type_config?.options ?? [];
66
+ const hit = options.find((o) => String(o.id) === String(value) || o.orderindex === value);
67
+ return hit ? hit.name ?? hit.label ?? String(value) : String(value);
68
+ }
69
+ function isEmptyFieldValue(v) {
70
+ if (v == null || v === "") return true;
71
+ if (Array.isArray(v) && v.length === 0) return true;
72
+ return false;
73
+ }
74
+ function renderFieldValue(field, ctx) {
75
+ const v = field.value;
76
+ switch (field.type) {
77
+ case "drop_down":
78
+ return optionLabel(field, v);
79
+ case "labels":
80
+ return (Array.isArray(v) ? v : [v]).map((x) => optionLabel(field, x)).join(", ");
81
+ case "tasks": {
82
+ const refs = Array.isArray(v) ? v : [];
83
+ return refs.map((r) => taskLink(r.id, r.name, ctx)).join(", ");
84
+ }
85
+ case "users": {
86
+ const users = Array.isArray(v) ? v : [];
87
+ return users.map((u) => u.username ?? u.email ?? "?").join(", ");
88
+ }
89
+ case "date":
90
+ return formatDateISO(v);
91
+ case "checkbox":
92
+ return v === true || v === "true" ? "yes" : "no";
93
+ case "manual_progress":
94
+ return typeof v === "object" && v !== null && "current" in v ? `${String(v.current)}%` : JSON.stringify(v);
95
+ default:
96
+ if (typeof v === "string") return v;
97
+ if (typeof v === "number" || typeof v === "boolean") return String(v);
98
+ return JSON.stringify(v);
99
+ }
100
+ }
101
+ function escapeCell(s) {
102
+ return s.replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
103
+ }
104
+ function renderTaskMarkdown(bundle, ctx) {
105
+ const { task, fetchedAt } = bundle;
106
+ const lines = [`# ${task.name}`, ""];
107
+ const isInitiative = (task.custom_item_id ?? 0) !== 0;
108
+ const header = [
109
+ ["ID", task.id],
110
+ ["URL", task.url],
111
+ ["Type", isInitiative ? "initiative" : "task"],
112
+ ["Status", task.status.status],
113
+ ["Archived", task.archived ? "yes" : void 0],
114
+ ["List", task.list.name],
115
+ ["Folder", task.folder?.name],
116
+ ["Space", task.space?.id ? ctx.spaceName?.(task.space.id) ?? task.space.id : void 0],
117
+ ["Parent", task.parent ? taskLink(task.parent, void 0, ctx) : void 0],
118
+ ["Assignees", task.assignees.map((a) => a.username).join(", ") || void 0],
119
+ ["Creator", task.creator?.username],
120
+ ["Watchers", task.watchers?.map((w) => w.username).join(", ") || void 0],
121
+ ["Priority", task.priority?.priority],
122
+ ["Tags", task.tags?.map((t) => t.name).join(", ") || void 0],
123
+ ["Start Date", task.start_date ? formatDateISO(task.start_date) : void 0],
124
+ ["Due Date", task.due_date ? formatDateISO(task.due_date) : void 0],
125
+ [
126
+ "Time Estimate",
127
+ task.time_estimate != null && task.time_estimate > 0 ? formatDuration(task.time_estimate) : void 0
128
+ ],
129
+ [
130
+ "Time Spent",
131
+ task.time_spent != null && task.time_spent > 0 ? formatDuration(task.time_spent) : void 0
132
+ ],
133
+ ["Created", isoDateTime(task.date_created)],
134
+ ["Updated", isoDateTime(task.date_updated)],
135
+ ["Closed", isoDateTime(task.date_closed)],
136
+ ["Done", isoDateTime(task.date_done)],
137
+ ["Exported", fetchedAt]
138
+ ];
139
+ for (const [label, value] of header) {
140
+ if (value != null && value !== "") lines.push(`**${label}:** ${value}`);
141
+ }
142
+ const fields = (task.custom_fields ?? []).filter((f) => !isEmptyFieldValue(f.value));
143
+ if (fields.length > 0) {
144
+ lines.push("", "## Custom Fields", "", "| Field | Value |", "| --- | --- |");
145
+ for (const f of fields) {
146
+ lines.push(`| ${escapeCell(f.name)} | ${escapeCell(renderFieldValue(f, ctx))} |`);
147
+ }
148
+ }
149
+ const description = task.markdown_description ?? task.description;
150
+ if (description) lines.push("", "## Description", "", description);
151
+ if (task.checklists?.length) {
152
+ lines.push("", "## Checklists", "");
153
+ for (const cl of task.checklists) {
154
+ const resolved = cl.items.filter((i) => i.resolved).length;
155
+ lines.push(`### ${cl.name} (${resolved}/${cl.items.length})`, "");
156
+ for (const item of cl.items) lines.push(`- [${item.resolved ? "x" : " "}] ${item.name}`);
157
+ lines.push("");
158
+ }
159
+ }
160
+ if (task.subtasks?.length) {
161
+ lines.push("", "## Subtasks", "");
162
+ for (const s of task.subtasks) lines.push(`- ${taskLink(s.id, s.name, ctx)}`);
163
+ }
164
+ if (task.dependencies?.length) {
165
+ lines.push("", "## Dependencies", "");
166
+ for (const dep of task.dependencies) {
167
+ const blocks = dep.depends_on === task.id;
168
+ const other = blocks ? dep.task_id : dep.depends_on;
169
+ lines.push(`- ${blocks ? "blocks" : "depends on"} ${taskLink(other, void 0, ctx)}`);
170
+ }
171
+ }
172
+ if (task.linked_tasks?.length) {
173
+ lines.push("", "## Linked Tasks", "");
174
+ for (const lt of task.linked_tasks) lines.push(`- ${taskLink(lt.task_id, void 0, ctx)}`);
175
+ }
176
+ if (task.attachments?.length) {
177
+ lines.push("", "## Attachments", "");
178
+ for (const att of task.attachments) {
179
+ const local = ctx.attachmentPath(att.id);
180
+ lines.push(
181
+ local ? `- [${att.title}](${local})` : `- [${att.title}](${att.url}) (not downloaded)`
182
+ );
183
+ }
184
+ }
185
+ return lines.join("\n") + "\n";
186
+ }
187
+ function renderComment(c, quote) {
188
+ const prefix = quote ? "> " : "";
189
+ const when = isoDateTime(c.date) ?? c.date;
190
+ const body = c.comment_text.split(/\r?\n/).map((l) => `${prefix}${l}`);
191
+ return [`${prefix}**${c.user.username}** (${when})`, prefix.trimEnd(), ...body];
192
+ }
193
+ function renderCommentsMarkdown(bundle) {
194
+ const comments = bundle.comments;
195
+ const lines = [`# Comments (${comments.length})`, ""];
196
+ if (comments.length === 0) {
197
+ lines.push("No comments.");
198
+ return lines.join("\n") + "\n";
199
+ }
200
+ for (const c of comments) {
201
+ lines.push(...renderComment(c, false), "");
202
+ for (const r of c.replies) lines.push(...renderComment(r, true), "");
203
+ lines.push("---", "");
204
+ }
205
+ return lines.join("\n");
206
+ }
207
+
208
+ // src/export/writer.ts
209
+ function safeAttachmentFilename(id, title) {
210
+ const idStem = id.replace(/\.[A-Za-z0-9]+$/, "");
211
+ const base = title.split(/[\\/]/).pop() ?? title;
212
+ const dot = base.lastIndexOf(".");
213
+ const stem = dot > 0 ? base.slice(0, dot) : base;
214
+ const ext = dot > 0 ? base.slice(dot + 1) : "";
215
+ const clean = (s) => s.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^[.-]+|[.-]+$/g, "").slice(0, 120);
216
+ const stemClean = clean(stem) || "file";
217
+ const extClean = clean(ext);
218
+ return extClean ? `${idStem}-${stemClean}.${extClean}` : `${idStem}-${stemClean}`;
219
+ }
220
+ async function defaultDownload(url) {
221
+ const res = await fetch(url, { signal: AbortSignal.timeout(12e4) });
222
+ if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`);
223
+ return Buffer.from(await res.arrayBuffer());
224
+ }
225
+ function taskDir(root, taskId) {
226
+ return join(root, "tasks", taskId);
227
+ }
228
+ async function writeBundleData(root, bundle, opts) {
229
+ const dir = taskDir(root, bundle.task.id);
230
+ await mkdir(dir, { recursive: true });
231
+ const download = opts.download ?? defaultDownload;
232
+ const localPaths = /* @__PURE__ */ new Map();
233
+ const failed = [];
234
+ let downloaded = 0;
235
+ if (opts.downloadAttachments && bundle.attachments.length > 0) {
236
+ const attDir = join(dir, "attachments");
237
+ await mkdir(attDir, { recursive: true });
238
+ for (const att of bundle.attachments) {
239
+ const file = safeAttachmentFilename(att.id, att.title);
240
+ const target = join(attDir, file);
241
+ if (existsSync(target)) {
242
+ localPaths.set(att.id, `attachments/${file}`);
243
+ continue;
244
+ }
245
+ try {
246
+ await writeFile(target, await download(att.url));
247
+ localPaths.set(att.id, `attachments/${file}`);
248
+ downloaded++;
249
+ } catch (err) {
250
+ failed.push({ id: att.id, title: att.title, error: err.message });
251
+ }
252
+ }
253
+ }
254
+ const taskJson = JSON.stringify(bundle.task, null, 2) + "\n";
255
+ const contentHash = createHash("sha256").update(taskJson).digest("hex");
256
+ const meta = { fetchedAt: bundle.fetchedAt, subtaskIds: bundle.subtaskIds };
257
+ await Promise.all([
258
+ writeFile(join(dir, "task.json"), taskJson),
259
+ writeFile(join(dir, "comments.json"), JSON.stringify(bundle.comments, null, 2) + "\n"),
260
+ writeFile(
261
+ join(dir, "attachments.json"),
262
+ JSON.stringify(
263
+ bundle.attachments.map((a) => ({ ...a, local: localPaths.get(a.id) ?? null })),
264
+ null,
265
+ 2
266
+ ) + "\n"
267
+ ),
268
+ writeFile(join(dir, "bundle.json"), JSON.stringify(meta, null, 2) + "\n")
269
+ ]);
270
+ return { dir, contentHash, attachmentsDownloaded: downloaded, attachmentsFailed: failed };
271
+ }
272
+ async function readBundleData(root, taskId) {
273
+ const dir = taskDir(root, taskId);
274
+ const [task, comments, attachments, meta] = await Promise.all([
275
+ readFile(join(dir, "task.json"), "utf8").then((s) => JSON.parse(s)),
276
+ readFile(join(dir, "comments.json"), "utf8").then((s) => JSON.parse(s)),
277
+ readFile(join(dir, "attachments.json"), "utf8").then(
278
+ (s) => JSON.parse(s)
279
+ ),
280
+ readFile(join(dir, "bundle.json"), "utf8").then(
281
+ (s) => JSON.parse(s)
282
+ )
283
+ ]);
284
+ return {
285
+ task,
286
+ comments,
287
+ attachments: attachments.map(({ local: _local, ...a }) => a),
288
+ subtaskIds: meta.subtaskIds,
289
+ fetchedAt: meta.fetchedAt
290
+ };
291
+ }
292
+ async function renderBundleMarkdown(root, bundle, hasTask, spaceName) {
293
+ const dir = taskDir(root, bundle.task.id);
294
+ const attachments = JSON.parse(await readFile(join(dir, "attachments.json"), "utf8"));
295
+ const localPaths = new Map(attachments.map((a) => [a.id, a.local ?? void 0]));
296
+ const ctx = {
297
+ hasTask: (id) => hasTask(id),
298
+ attachmentPath: (id) => localPaths.get(id),
299
+ ...spaceName ? { spaceName: (id) => spaceName(id) } : {}
300
+ };
301
+ await Promise.all([
302
+ writeFile(join(dir, "task.md"), renderTaskMarkdown(bundle, ctx)),
303
+ writeFile(join(dir, "comments.md"), renderCommentsMarkdown(bundle))
304
+ ]);
305
+ }
306
+ async function writeTaskBundle(root, bundle, opts) {
307
+ const result = await writeBundleData(root, bundle, opts);
308
+ await renderBundleMarkdown(root, bundle, opts.hasTask);
309
+ return result;
310
+ }
311
+
312
+ export {
313
+ formatDate,
314
+ formatTimestamp,
315
+ formatDuration,
316
+ formatLongDuration,
317
+ formatDateISO,
318
+ safeAttachmentFilename,
319
+ taskDir,
320
+ writeBundleData,
321
+ readBundleData,
322
+ renderBundleMarkdown,
323
+ writeTaskBundle
324
+ };