atlass 1.6.0 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +75 -303
- package/dist/cli.mjs +1290 -836
- package/package.json +9 -1
package/dist/cli.mjs
CHANGED
|
@@ -7,10 +7,11 @@ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
|
7
7
|
import { Entry } from "@napi-rs/keyring";
|
|
8
8
|
import { marked } from "marked";
|
|
9
9
|
import { randomUUID } from "node:crypto";
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
import { stripVTControlCharacters } from "node:util";
|
|
11
|
+
import kleur from "kleur";
|
|
12
|
+
import { common, createLowlight } from "lowlight";
|
|
13
13
|
//#region src/api/client.ts
|
|
14
|
+
const DISABLE_XSRF_CHECK = { "X-Atlassian-Token": "nocheck" };
|
|
14
15
|
var AtlassianClient = class {
|
|
15
16
|
site;
|
|
16
17
|
authHeader;
|
|
@@ -47,7 +48,7 @@ var AtlassianClient = class {
|
|
|
47
48
|
body: JSON.stringify(body)
|
|
48
49
|
})).json();
|
|
49
50
|
}
|
|
50
|
-
async
|
|
51
|
+
async putNoContent(path, body) {
|
|
51
52
|
await this.request(path, {
|
|
52
53
|
method: "PUT",
|
|
53
54
|
headers: {
|
|
@@ -65,17 +66,21 @@ var AtlassianClient = class {
|
|
|
65
66
|
method: "POST",
|
|
66
67
|
headers: {
|
|
67
68
|
Accept: "application/json",
|
|
68
|
-
|
|
69
|
+
...DISABLE_XSRF_CHECK
|
|
69
70
|
},
|
|
70
71
|
body: form
|
|
71
72
|
})).json();
|
|
72
73
|
}
|
|
73
|
-
async getBinary(
|
|
74
|
-
const path =
|
|
74
|
+
async getBinary(urlOrPath) {
|
|
75
|
+
const path = urlOrPath.startsWith("http") ? pathAndQuery(urlOrPath) : urlOrPath;
|
|
75
76
|
const res = await this.request(path, { headers: { Accept: "*/*" } });
|
|
76
77
|
return new Uint8Array(await res.arrayBuffer());
|
|
77
78
|
}
|
|
78
79
|
};
|
|
80
|
+
function pathAndQuery(absoluteUrl) {
|
|
81
|
+
const u = new URL(absoluteUrl);
|
|
82
|
+
return u.pathname + u.search;
|
|
83
|
+
}
|
|
79
84
|
var HttpError = class extends Error {
|
|
80
85
|
status;
|
|
81
86
|
constructor(status, message) {
|
|
@@ -87,21 +92,26 @@ var HttpError = class extends Error {
|
|
|
87
92
|
function httpError(status, path, body = "") {
|
|
88
93
|
if (status === 401 || status === 403) return new HttpError(status, "Authentication failed (401/403). Run `atlass auth login` to update your token.");
|
|
89
94
|
if (status === 404) return new HttpError(status, `Not found (404): ${path}`);
|
|
90
|
-
if (status === 409) return new HttpError(status, `Conflict (409): ${
|
|
95
|
+
if (status === 409) return new HttpError(status, `Conflict (409): ${extractErrorMessage(body) || "the page changed on the server"}`);
|
|
91
96
|
if (status === 413) return new HttpError(status, "Payload too large (413): the page or an attachment exceeds the size limit.");
|
|
92
|
-
if (status === 400) return new HttpError(status, `Bad request (400): ${
|
|
93
|
-
return new HttpError(status, `Request failed (${status}): ${
|
|
97
|
+
if (status === 400) return new HttpError(status, `Bad request (400): ${extractErrorMessage(body) || path}`);
|
|
98
|
+
return new HttpError(status, `Request failed (${status}): ${extractErrorMessage(body) || path}`);
|
|
94
99
|
}
|
|
95
|
-
function
|
|
100
|
+
function extractErrorMessage(body) {
|
|
96
101
|
if (!body) return "";
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
if (json.error?.message) return json.error.message;
|
|
102
|
-
} catch {}
|
|
102
|
+
const json = tryParseJson(body);
|
|
103
|
+
if (json?.errorMessages?.length) return json.errorMessages.join("; ");
|
|
104
|
+
if (json?.message) return json.message;
|
|
105
|
+
if (json?.error?.message) return json.error.message;
|
|
103
106
|
return body.slice(0, 300);
|
|
104
107
|
}
|
|
108
|
+
function tryParseJson(text) {
|
|
109
|
+
try {
|
|
110
|
+
return JSON.parse(text);
|
|
111
|
+
} catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
105
115
|
//#endregion
|
|
106
116
|
//#region src/config.ts
|
|
107
117
|
function configDir() {
|
|
@@ -130,7 +140,7 @@ async function writeConfig(config) {
|
|
|
130
140
|
async function clearConfig() {
|
|
131
141
|
await rm(configPath(), { force: true });
|
|
132
142
|
}
|
|
133
|
-
function
|
|
143
|
+
function siteOrigin(input) {
|
|
134
144
|
let value = input.trim();
|
|
135
145
|
if (!/^https?:\/\//i.test(value)) value = `https://${value}`;
|
|
136
146
|
return new URL(value).origin;
|
|
@@ -142,6 +152,13 @@ const BITBUCKET_ORIGIN = "https://api.bitbucket.org";
|
|
|
142
152
|
function entry(key) {
|
|
143
153
|
return new Entry(SERVICE, key);
|
|
144
154
|
}
|
|
155
|
+
function deleteEntryIfPresent(key) {
|
|
156
|
+
try {
|
|
157
|
+
entry(key).deleteCredential();
|
|
158
|
+
} catch {
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
145
162
|
function saveToken(email, token) {
|
|
146
163
|
entry(email).setPassword(token);
|
|
147
164
|
}
|
|
@@ -149,9 +166,7 @@ function readToken(email) {
|
|
|
149
166
|
return entry(email).getPassword();
|
|
150
167
|
}
|
|
151
168
|
function deleteToken(email) {
|
|
152
|
-
|
|
153
|
-
entry(email).deleteCredential();
|
|
154
|
-
} catch {}
|
|
169
|
+
deleteEntryIfPresent(email);
|
|
155
170
|
}
|
|
156
171
|
function bitbucketKey(email) {
|
|
157
172
|
return `${email}:bitbucket`;
|
|
@@ -163,9 +178,7 @@ function readBitbucketToken(email) {
|
|
|
163
178
|
return entry(bitbucketKey(email)).getPassword();
|
|
164
179
|
}
|
|
165
180
|
function deleteBitbucketToken(email) {
|
|
166
|
-
|
|
167
|
-
entry(bitbucketKey(email)).deleteCredential();
|
|
168
|
-
} catch {}
|
|
181
|
+
deleteEntryIfPresent(bitbucketKey(email));
|
|
169
182
|
}
|
|
170
183
|
async function requireAuth() {
|
|
171
184
|
const config = await readConfig();
|
|
@@ -194,7 +207,7 @@ async function requireBitbucketAuth() {
|
|
|
194
207
|
//#endregion
|
|
195
208
|
//#region src/commands/auth.ts
|
|
196
209
|
async function login() {
|
|
197
|
-
const site =
|
|
210
|
+
const site = siteOrigin(await input({
|
|
198
211
|
message: "Atlassian site (e.g. acme.atlassian.net):",
|
|
199
212
|
required: true
|
|
200
213
|
}));
|
|
@@ -222,10 +235,11 @@ async function login() {
|
|
|
222
235
|
async function logout() {
|
|
223
236
|
const config = await readConfig();
|
|
224
237
|
if (config?.email) deleteToken(config.email);
|
|
225
|
-
|
|
238
|
+
const bitbucketLogin = config?.bitbucket && config.email ? {
|
|
226
239
|
email: config.email,
|
|
227
240
|
bitbucket: config.bitbucket
|
|
228
|
-
}
|
|
241
|
+
} : null;
|
|
242
|
+
if (bitbucketLogin) await writeConfig(bitbucketLogin);
|
|
229
243
|
else await clearConfig();
|
|
230
244
|
console.log("Logged out. Credentials removed.");
|
|
231
245
|
}
|
|
@@ -242,6 +256,8 @@ async function status() {
|
|
|
242
256
|
}
|
|
243
257
|
//#endregion
|
|
244
258
|
//#region src/api/bitbucket.ts
|
|
259
|
+
const BITBUCKET_MAX_PAGELEN = 100;
|
|
260
|
+
const BUILD_NUMBER_SCAN_LIMIT = 1e3;
|
|
245
261
|
function pipelineStatus(state) {
|
|
246
262
|
if (!state) return "";
|
|
247
263
|
if (state.name === "COMPLETED") return state.result?.name ?? "COMPLETED";
|
|
@@ -250,88 +266,91 @@ function pipelineStatus(state) {
|
|
|
250
266
|
function pipelinesQuery(limit) {
|
|
251
267
|
return new URLSearchParams({
|
|
252
268
|
sort: "-created_on",
|
|
253
|
-
pagelen: String(Math.min(Math.max(limit, 1),
|
|
269
|
+
pagelen: String(Math.min(Math.max(limit, 1), BITBUCKET_MAX_PAGELEN))
|
|
254
270
|
}).toString();
|
|
255
271
|
}
|
|
256
|
-
function
|
|
272
|
+
function wallClockSeconds(startOn, endOn) {
|
|
257
273
|
if (!startOn || !endOn) return null;
|
|
258
274
|
const start = Date.parse(startOn);
|
|
259
275
|
const end = Date.parse(endOn);
|
|
260
276
|
if (Number.isNaN(start) || Number.isNaN(end)) return null;
|
|
261
277
|
return Math.floor((end - start) / 1e3);
|
|
262
278
|
}
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
return `/2.0/repositories/${encodeURIComponent(ref.workspace)}/${encodeURIComponent(ref.repo)}/pipelines`;
|
|
266
|
-
}
|
|
267
|
-
function toPath(url) {
|
|
268
|
-
const u = new URL(url);
|
|
269
|
-
return u.pathname + u.search;
|
|
270
|
-
}
|
|
271
|
-
async function* paginate(client, firstPath) {
|
|
272
|
-
let path = firstPath;
|
|
273
|
-
while (path) {
|
|
274
|
-
const page = await client.getJson(path);
|
|
275
|
-
for (const value of page.values ?? []) yield value;
|
|
276
|
-
path = page.next ? toPath(page.next) : null;
|
|
277
|
-
}
|
|
279
|
+
function shortHash(hash) {
|
|
280
|
+
return hash?.slice(0, 7) ?? "";
|
|
278
281
|
}
|
|
279
|
-
function
|
|
282
|
+
function toPipelineSummary(p) {
|
|
280
283
|
return {
|
|
281
284
|
buildNumber: p.build_number,
|
|
282
285
|
status: pipelineStatus(p.state),
|
|
283
286
|
ref: p.target?.ref_name ?? "",
|
|
284
|
-
commit: p.target?.commit?.hash
|
|
285
|
-
durationSeconds:
|
|
287
|
+
commit: shortHash(p.target?.commit?.hash),
|
|
288
|
+
durationSeconds: wallClockSeconds(p.created_on, p.completed_on),
|
|
286
289
|
createdOn: p.created_on ?? "",
|
|
287
290
|
creator: p.creator?.display_name ?? "",
|
|
288
291
|
uuid: p.uuid
|
|
289
292
|
};
|
|
290
293
|
}
|
|
291
|
-
function
|
|
294
|
+
function toPipelineDetail(p) {
|
|
292
295
|
return {
|
|
293
|
-
...
|
|
296
|
+
...toPipelineSummary(p),
|
|
294
297
|
repo: p.repository?.full_name ?? "",
|
|
295
298
|
trigger: p.trigger?.name ?? ""
|
|
296
299
|
};
|
|
297
300
|
}
|
|
301
|
+
function toStepSummary(step) {
|
|
302
|
+
return {
|
|
303
|
+
name: step.name ?? "",
|
|
304
|
+
status: pipelineStatus(step.state),
|
|
305
|
+
durationSeconds: wallClockSeconds(step.started_on, step.completed_on)
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
function repoPath(ref) {
|
|
309
|
+
return `/2.0/repositories/${encodeURIComponent(ref.workspace)}/${encodeURIComponent(ref.repo)}/pipelines`;
|
|
310
|
+
}
|
|
311
|
+
async function* walkPages(client, firstPath) {
|
|
312
|
+
let path = firstPath;
|
|
313
|
+
while (path) {
|
|
314
|
+
const page = await client.getJson(path);
|
|
315
|
+
for (const value of page.values ?? []) yield value;
|
|
316
|
+
path = page.next ? pathAndQuery(page.next) : null;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
298
319
|
async function listPipelines(client, ref, limit) {
|
|
299
320
|
const out = [];
|
|
300
321
|
const first = `${repoPath(ref)}?${pipelinesQuery(limit)}`;
|
|
301
|
-
for await (const value of
|
|
302
|
-
out.push(
|
|
322
|
+
for await (const value of walkPages(client, first)) {
|
|
323
|
+
out.push(toPipelineSummary(value));
|
|
303
324
|
if (out.length >= limit) break;
|
|
304
325
|
}
|
|
305
326
|
return out;
|
|
306
327
|
}
|
|
307
328
|
async function getPipeline(client, ref, buildNumber) {
|
|
308
|
-
const
|
|
329
|
+
const found = await fetchByBuildNumber(client, ref, buildNumber) ?? await findInRecentRuns(client, ref, buildNumber);
|
|
330
|
+
if (!found) throw new Error(`Could not find pipeline #${buildNumber} in the ${BUILD_NUMBER_SCAN_LIMIT} most recent runs. It may be too old.`);
|
|
331
|
+
return toPipelineDetail(found);
|
|
332
|
+
}
|
|
333
|
+
async function fetchByBuildNumber(client, ref, buildNumber) {
|
|
309
334
|
try {
|
|
310
|
-
return
|
|
335
|
+
return await client.getJson(`${repoPath(ref)}/${encodeURIComponent(String(buildNumber))}`);
|
|
311
336
|
} catch (err) {
|
|
312
|
-
if (
|
|
337
|
+
if (err instanceof HttpError && (err.status === 400 || err.status === 404)) return null;
|
|
338
|
+
throw err;
|
|
313
339
|
}
|
|
314
|
-
const found = await scanForBuild(client, ref, buildNumber);
|
|
315
|
-
if (!found) throw new Error(`Could not find pipeline #${buildNumber} in the ${MAX_SCAN} most recent runs. It may be too old.`);
|
|
316
|
-
return mapDetail(found);
|
|
317
340
|
}
|
|
318
|
-
async function
|
|
319
|
-
const first = `${repoPath(ref)}?${pipelinesQuery(
|
|
341
|
+
async function findInRecentRuns(client, ref, buildNumber) {
|
|
342
|
+
const first = `${repoPath(ref)}?${pipelinesQuery(BITBUCKET_MAX_PAGELEN)}`;
|
|
320
343
|
let scanned = 0;
|
|
321
|
-
for await (const pipeline of
|
|
344
|
+
for await (const pipeline of walkPages(client, first)) {
|
|
322
345
|
if (pipeline.build_number === buildNumber) return pipeline;
|
|
323
|
-
if (++scanned >=
|
|
346
|
+
if (++scanned >= BUILD_NUMBER_SCAN_LIMIT) break;
|
|
324
347
|
}
|
|
325
348
|
return null;
|
|
326
349
|
}
|
|
327
350
|
async function listSteps(client, ref, pipelineId) {
|
|
328
351
|
const out = [];
|
|
329
352
|
const first = `${repoPath(ref)}/${encodeURIComponent(pipelineId)}/steps`;
|
|
330
|
-
for await (const step of
|
|
331
|
-
name: step.name ?? "",
|
|
332
|
-
status: pipelineStatus(step.state),
|
|
333
|
-
durationSeconds: elapsedSeconds(step.started_on, step.completed_on)
|
|
334
|
-
});
|
|
353
|
+
for await (const step of walkPages(client, first)) out.push(toStepSummary(step));
|
|
335
354
|
return out;
|
|
336
355
|
}
|
|
337
356
|
//#endregion
|
|
@@ -344,6 +363,11 @@ function formatDuration(seconds) {
|
|
|
344
363
|
if (m < 60) return `${m}m${String(s).padStart(2, "0")}s`;
|
|
345
364
|
return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, "0")}m`;
|
|
346
365
|
}
|
|
366
|
+
function formatDateTime(iso) {
|
|
367
|
+
const date = new Date(iso);
|
|
368
|
+
if (Number.isNaN(date.getTime())) return iso;
|
|
369
|
+
return date.toISOString().replace("T", " ").slice(0, 16);
|
|
370
|
+
}
|
|
347
371
|
const MINUTE = 60;
|
|
348
372
|
const HOUR = 60 * MINUTE;
|
|
349
373
|
const DAY = 24 * HOUR;
|
|
@@ -375,6 +399,9 @@ function parsePageId(input) {
|
|
|
375
399
|
if (fromQuery) return fromQuery[1];
|
|
376
400
|
return null;
|
|
377
401
|
}
|
|
402
|
+
function isExternalHref(href) {
|
|
403
|
+
return /^[a-z][a-z0-9+.-]*:\/\//i.test(href);
|
|
404
|
+
}
|
|
378
405
|
function resolveRepo(flag, config) {
|
|
379
406
|
if (flag) {
|
|
380
407
|
if (flag.includes("/")) {
|
|
@@ -398,11 +425,13 @@ function resolveRepo(flag, config) {
|
|
|
398
425
|
};
|
|
399
426
|
throw new Error(`No repo given. Pass --repo workspace/slug (or a bare slug), or set a default repo at \`atlass bitbucket login\`.`);
|
|
400
427
|
}
|
|
428
|
+
const DEFAULT_LIMIT = 25;
|
|
429
|
+
const MAX_LIMIT = 100;
|
|
401
430
|
function parseLimit(value) {
|
|
402
|
-
if (!value) return
|
|
431
|
+
if (!value) return DEFAULT_LIMIT;
|
|
403
432
|
const n = Number.parseInt(value, 10);
|
|
404
433
|
if (!Number.isFinite(n) || n < 1) throw new Error(`Invalid --limit "${value}".`);
|
|
405
|
-
return Math.min(n,
|
|
434
|
+
return Math.min(n, MAX_LIMIT);
|
|
406
435
|
}
|
|
407
436
|
//#endregion
|
|
408
437
|
//#region src/commands/bitbucket.ts
|
|
@@ -440,10 +469,11 @@ async function bitbucketLogin() {
|
|
|
440
469
|
async function bitbucketLogout() {
|
|
441
470
|
const config = await readConfig();
|
|
442
471
|
if (config?.email) deleteBitbucketToken(config.email);
|
|
443
|
-
|
|
472
|
+
const jiraLogin = config?.site && config.email ? {
|
|
444
473
|
site: config.site,
|
|
445
474
|
email: config.email
|
|
446
|
-
}
|
|
475
|
+
} : null;
|
|
476
|
+
if (jiraLogin) await writeConfig(jiraLogin);
|
|
447
477
|
else await clearConfig();
|
|
448
478
|
console.log("Logged out of Bitbucket. Credentials removed.");
|
|
449
479
|
}
|
|
@@ -513,8 +543,7 @@ function formatStepRows(steps) {
|
|
|
513
543
|
function printPipelineDetail(detail, steps, nowMs) {
|
|
514
544
|
console.log(`Pipeline #${detail.buildNumber} ${detail.status || "-"}`);
|
|
515
545
|
if (detail.repo) console.log(`Repo: ${detail.repo}`);
|
|
516
|
-
|
|
517
|
-
console.log(`Ref: ${refLine}`);
|
|
546
|
+
console.log(`Ref: ${refWithCommit(detail)}`);
|
|
518
547
|
if (detail.trigger) console.log(`Trigger: ${detail.trigger}`);
|
|
519
548
|
console.log(`Duration: ${formatDuration(detail.durationSeconds)}`);
|
|
520
549
|
const by = detail.creator ? ` by ${detail.creator}` : "";
|
|
@@ -525,6 +554,10 @@ function printPipelineDetail(detail, steps, nowMs) {
|
|
|
525
554
|
for (const line of formatStepRows(steps)) console.log(line);
|
|
526
555
|
}
|
|
527
556
|
}
|
|
557
|
+
function refWithCommit(detail) {
|
|
558
|
+
if (!detail.ref) return detail.commit || "-";
|
|
559
|
+
return detail.commit ? `${detail.ref} (${detail.commit})` : detail.ref;
|
|
560
|
+
}
|
|
528
561
|
async function verifyWorkspace(client, workspace) {
|
|
529
562
|
try {
|
|
530
563
|
return await client.getJson(`/2.0/workspaces/${encodeURIComponent(workspace)}`);
|
|
@@ -549,8 +582,27 @@ async function withScopeHint(fn) {
|
|
|
549
582
|
}
|
|
550
583
|
//#endregion
|
|
551
584
|
//#region src/adf/from-markdown.ts
|
|
585
|
+
function mediaNode(source, alt) {
|
|
586
|
+
const attrs = { ...source };
|
|
587
|
+
if (alt) attrs["alt"] = alt;
|
|
588
|
+
return {
|
|
589
|
+
type: "mediaSingle",
|
|
590
|
+
attrs: { layout: "center" },
|
|
591
|
+
content: [{
|
|
592
|
+
type: "media",
|
|
593
|
+
attrs
|
|
594
|
+
}]
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
function imageHrefs(md) {
|
|
598
|
+
const hrefs = /* @__PURE__ */ new Set();
|
|
599
|
+
marked.walkTokens(marked.lexer(md), (token) => {
|
|
600
|
+
if (token.type === "image") hrefs.add(token.href);
|
|
601
|
+
});
|
|
602
|
+
return [...hrefs];
|
|
603
|
+
}
|
|
552
604
|
function markdownToAdf(md, options = {}) {
|
|
553
|
-
const ctx = { resolveImage: options.resolveImage ??
|
|
605
|
+
const ctx = { resolveImage: options.resolveImage ?? externalMedia };
|
|
554
606
|
return {
|
|
555
607
|
type: "doc",
|
|
556
608
|
version: 1,
|
|
@@ -570,8 +622,8 @@ function block(token, ctx) {
|
|
|
570
622
|
attrs: { level: clampLevel$1(token.depth) },
|
|
571
623
|
content: inline(token.tokens ?? [], ctx)
|
|
572
624
|
}];
|
|
573
|
-
case "paragraph": return
|
|
574
|
-
case "text": return
|
|
625
|
+
case "paragraph": return paragraphsSplitAtImages(token.tokens ?? [], ctx);
|
|
626
|
+
case "text": return paragraphsSplitAtImages(token.tokens ?? [textToken(token.text)], ctx);
|
|
575
627
|
case "list": return [list(token, ctx)];
|
|
576
628
|
case "code": return [codeBlock(token)];
|
|
577
629
|
case "blockquote": return [{
|
|
@@ -580,11 +632,10 @@ function block(token, ctx) {
|
|
|
580
632
|
}];
|
|
581
633
|
case "table": return [table(token, ctx)];
|
|
582
634
|
case "hr": return [{ type: "rule" }];
|
|
583
|
-
case "html": return [];
|
|
584
635
|
default: return [];
|
|
585
636
|
}
|
|
586
637
|
}
|
|
587
|
-
function
|
|
638
|
+
function paragraphsSplitAtImages(tokens, ctx) {
|
|
588
639
|
const out = [];
|
|
589
640
|
let buffer = [];
|
|
590
641
|
const flush = () => {
|
|
@@ -615,7 +666,7 @@ function list(token, ctx) {
|
|
|
615
666
|
localId: randomUUID(),
|
|
616
667
|
state: item.checked ? "DONE" : "TODO"
|
|
617
668
|
},
|
|
618
|
-
content: inline(
|
|
669
|
+
content: inline(taskItemInline(item), ctx)
|
|
619
670
|
}))
|
|
620
671
|
};
|
|
621
672
|
const node = {
|
|
@@ -628,16 +679,18 @@ function list(token, ctx) {
|
|
|
628
679
|
}
|
|
629
680
|
function listItem(item, ctx) {
|
|
630
681
|
const children = blocks(item.tokens.filter((t) => t.type !== "checkbox"), ctx);
|
|
631
|
-
if (children.length === 0) children.push({
|
|
632
|
-
type: "paragraph",
|
|
633
|
-
content: []
|
|
634
|
-
});
|
|
635
682
|
return {
|
|
636
683
|
type: "listItem",
|
|
637
|
-
content: children
|
|
684
|
+
content: children.length > 0 ? children : [emptyParagraph()]
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
function emptyParagraph() {
|
|
688
|
+
return {
|
|
689
|
+
type: "paragraph",
|
|
690
|
+
content: []
|
|
638
691
|
};
|
|
639
692
|
}
|
|
640
|
-
function
|
|
693
|
+
function taskItemInline(item) {
|
|
641
694
|
const first = item.tokens.find((t) => t.type === "text");
|
|
642
695
|
if (first && "tokens" in first && first.tokens) return first.tokens;
|
|
643
696
|
return [textToken(item.text)];
|
|
@@ -686,13 +739,13 @@ function inlineNode(token, ctx, marks) {
|
|
|
686
739
|
if ("tokens" in t && t.tokens?.length) return inline(t.tokens, ctx, marks);
|
|
687
740
|
return textNode(t.text, marks);
|
|
688
741
|
}
|
|
689
|
-
case "strong": return inline(token.tokens, ctx,
|
|
690
|
-
case "em": return inline(token.tokens, ctx,
|
|
691
|
-
case "del": return inline(token.tokens, ctx,
|
|
692
|
-
case "codespan": return textNode(token.text,
|
|
742
|
+
case "strong": return inline(token.tokens, ctx, withUniqueMark(marks, { type: "strong" }));
|
|
743
|
+
case "em": return inline(token.tokens, ctx, withUniqueMark(marks, { type: "em" }));
|
|
744
|
+
case "del": return inline(token.tokens, ctx, withUniqueMark(marks, { type: "strike" }));
|
|
745
|
+
case "codespan": return textNode(token.text, withUniqueMark(marks, { type: "code" }));
|
|
693
746
|
case "link": {
|
|
694
747
|
const link = token;
|
|
695
|
-
return inline(link.tokens, ctx,
|
|
748
|
+
return inline(link.tokens, ctx, withUniqueMark(marks, {
|
|
696
749
|
type: "link",
|
|
697
750
|
attrs: { href: link.href }
|
|
698
751
|
}));
|
|
@@ -711,25 +764,14 @@ function textNode(text, marks) {
|
|
|
711
764
|
if (marks.length > 0) node.marks = marks;
|
|
712
765
|
return [node];
|
|
713
766
|
}
|
|
714
|
-
function
|
|
767
|
+
function withUniqueMark(marks, mark) {
|
|
715
768
|
return [...marks.filter((m) => m.type !== mark.type), mark];
|
|
716
769
|
}
|
|
717
|
-
function
|
|
718
|
-
return {
|
|
719
|
-
type: "
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
type: "media",
|
|
723
|
-
attrs: alt ? {
|
|
724
|
-
type: "external",
|
|
725
|
-
url: href,
|
|
726
|
-
alt
|
|
727
|
-
} : {
|
|
728
|
-
type: "external",
|
|
729
|
-
url: href
|
|
730
|
-
}
|
|
731
|
-
}]
|
|
732
|
-
};
|
|
770
|
+
function externalMedia(href, alt) {
|
|
771
|
+
return mediaNode({
|
|
772
|
+
type: "external",
|
|
773
|
+
url: href
|
|
774
|
+
}, alt);
|
|
733
775
|
}
|
|
734
776
|
function textToken(text) {
|
|
735
777
|
return {
|
|
@@ -743,108 +785,353 @@ function clampLevel$1(value) {
|
|
|
743
785
|
return Math.min(6, Math.max(1, Math.trunc(value) || 1));
|
|
744
786
|
}
|
|
745
787
|
//#endregion
|
|
746
|
-
//#region src/
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
const
|
|
760
|
-
return
|
|
788
|
+
//#region src/util/html.ts
|
|
789
|
+
const MAX_CODE_POINT = 1114111;
|
|
790
|
+
const NAMED = {
|
|
791
|
+
amp: "&",
|
|
792
|
+
lt: "<",
|
|
793
|
+
gt: ">",
|
|
794
|
+
quot: "\"",
|
|
795
|
+
apos: "'",
|
|
796
|
+
nbsp: " "
|
|
797
|
+
};
|
|
798
|
+
function decodeEntities(text) {
|
|
799
|
+
return text.replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (match, body) => {
|
|
800
|
+
if (body[0] === "#") {
|
|
801
|
+
const code = body[1] === "x" || body[1] === "X" ? Number.parseInt(body.slice(2), 16) : Number.parseInt(body.slice(1), 10);
|
|
802
|
+
return isCodePoint(code) ? String.fromCodePoint(code) : match;
|
|
761
803
|
}
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
case "taskList": return renderTaskList(node, ctx, indent);
|
|
765
|
-
case "decisionList": return renderDecisionList(node, ctx, indent);
|
|
766
|
-
case "codeBlock": return renderCodeBlock(node, indent);
|
|
767
|
-
case "blockquote": return prefixLines(renderBlocks(node.content ?? [], ctx, ""), `${indent}> `);
|
|
768
|
-
case "panel": return renderPanel(node, ctx, indent);
|
|
769
|
-
case "rule": return `${indent}---`;
|
|
770
|
-
case "table": return renderTable(node, ctx);
|
|
771
|
-
case "mediaSingle":
|
|
772
|
-
case "mediaGroup": return renderBlocks(node.content ?? [], ctx, indent);
|
|
773
|
-
case "media": return indent + renderMedia(node, ctx);
|
|
774
|
-
case "expand":
|
|
775
|
-
case "nestedExpand": return renderExpand(node, ctx, indent);
|
|
776
|
-
default:
|
|
777
|
-
if (node.content?.length) return renderBlocks(node.content, ctx, indent);
|
|
778
|
-
return node.text ? indent + node.text : "";
|
|
779
|
-
}
|
|
804
|
+
return NAMED[body.toLowerCase()] ?? match;
|
|
805
|
+
});
|
|
780
806
|
}
|
|
781
|
-
function
|
|
782
|
-
|
|
783
|
-
return (node.content ?? []).filter((n) => n.type === "listItem").map((item, i) => {
|
|
784
|
-
return renderListItem(item, ctx, indent, kind === "ordered" ? `${start + i}. ` : "- ");
|
|
785
|
-
}).join("\n");
|
|
807
|
+
function isCodePoint(code) {
|
|
808
|
+
return Number.isFinite(code) && code >= 0 && code <= MAX_CODE_POINT;
|
|
786
809
|
}
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
const
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
810
|
+
//#endregion
|
|
811
|
+
//#region src/api/confluence.ts
|
|
812
|
+
async function fetchPage(client, site, id) {
|
|
813
|
+
const page = await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}?body-format=atlas_doc_format`);
|
|
814
|
+
const names = new UserNames(client);
|
|
815
|
+
const [spaceKey, attachments, comments, author] = await Promise.all([
|
|
816
|
+
fetchSpaceKey(client, page.spaceId),
|
|
817
|
+
fetchAttachments(client, id),
|
|
818
|
+
fetchComments$1(client, id, names),
|
|
819
|
+
names.resolve(page.version?.authorId ?? page.authorId)
|
|
820
|
+
]);
|
|
821
|
+
const webui = page._links?.webui ?? "";
|
|
822
|
+
return {
|
|
823
|
+
id: page.id,
|
|
824
|
+
title: page.title,
|
|
825
|
+
spaceKey,
|
|
826
|
+
version: page.version?.number ?? 0,
|
|
827
|
+
author,
|
|
828
|
+
createdAt: page.createdAt ?? "",
|
|
829
|
+
updatedAt: page.version?.createdAt ?? "",
|
|
830
|
+
url: webui ? `${site}/wiki${webui}` : "",
|
|
831
|
+
body: parseAdfJson(page.body?.atlas_doc_format?.value),
|
|
832
|
+
attachments,
|
|
833
|
+
comments
|
|
834
|
+
};
|
|
796
835
|
}
|
|
797
|
-
function
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
836
|
+
async function fetchPageState(client, id) {
|
|
837
|
+
const page = await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}?body-format=atlas_doc_format`);
|
|
838
|
+
return {
|
|
839
|
+
version: page.version?.number ?? 0,
|
|
840
|
+
title: page.title,
|
|
841
|
+
body: parseAdfJson(page.body?.atlas_doc_format?.value)
|
|
842
|
+
};
|
|
801
843
|
}
|
|
802
|
-
|
|
803
|
-
|
|
844
|
+
const UNKNOWN_SIZE = -1;
|
|
845
|
+
async function listAttachments(client, id) {
|
|
846
|
+
return (await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}/attachments?limit=250`)).results.map((a) => ({
|
|
847
|
+
filename: a.title ?? a.id,
|
|
848
|
+
fileId: mediaNodeId(a),
|
|
849
|
+
size: typeof a.fileSize === "number" ? a.fileSize : UNKNOWN_SIZE
|
|
850
|
+
}));
|
|
804
851
|
}
|
|
805
|
-
function
|
|
806
|
-
|
|
807
|
-
const code = (node.content ?? []).map((n) => n.text ?? "").join("");
|
|
808
|
-
const fence = "```";
|
|
809
|
-
return prefixLines(`${fence}${lang}\n${code}\n${fence}`, indent);
|
|
852
|
+
async function uploadAttachment(client, pageId, filename, bytes) {
|
|
853
|
+
return (await client.postMultipart(`/wiki/rest/api/content/${encodeURIComponent(pageId)}/child/attachment`, filename, bytes)).results?.[0]?.extensions?.fileId ?? await fileIdByListing(client, pageId, filename);
|
|
810
854
|
}
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
success: "Success",
|
|
816
|
-
error: "Error"
|
|
817
|
-
};
|
|
818
|
-
function renderPanel(node, ctx, indent) {
|
|
819
|
-
return prefixLines(`**${PANEL_LABELS[typeof node.attrs?.["panelType"] === "string" ? node.attrs["panelType"] : "info"] ?? "Note"}**\n\n${renderBlocks(node.content ?? [], ctx, "")}`, `${indent}> `);
|
|
855
|
+
async function fileIdByListing(client, pageId, filename) {
|
|
856
|
+
const match = (await listAttachments(client, pageId)).find((a) => a.filename === filename);
|
|
857
|
+
if (match) return match.fileId;
|
|
858
|
+
throw new Error(`Upload of "${filename}" did not return a fileId.`);
|
|
820
859
|
}
|
|
821
|
-
function
|
|
822
|
-
return
|
|
860
|
+
async function updatePage(client, id, params) {
|
|
861
|
+
return (await client.putJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}`, {
|
|
862
|
+
id,
|
|
863
|
+
status: "current",
|
|
864
|
+
title: params.title,
|
|
865
|
+
body: {
|
|
866
|
+
representation: "atlas_doc_format",
|
|
867
|
+
value: JSON.stringify(params.body)
|
|
868
|
+
},
|
|
869
|
+
version: {
|
|
870
|
+
number: params.nextVersion,
|
|
871
|
+
message: params.message
|
|
872
|
+
}
|
|
873
|
+
})).version?.number ?? params.nextVersion;
|
|
823
874
|
}
|
|
824
|
-
function
|
|
825
|
-
const
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
875
|
+
async function searchPages(client, site, params) {
|
|
876
|
+
const cql = buildCql(params);
|
|
877
|
+
const query = new URLSearchParams({
|
|
878
|
+
cql,
|
|
879
|
+
limit: String(params.limit),
|
|
880
|
+
expand: "space"
|
|
881
|
+
});
|
|
882
|
+
const results = (await client.getJson(`/wiki/rest/api/search?${query.toString()}`)).results ?? [];
|
|
883
|
+
const serverPageWasFull = results.length === params.limit;
|
|
884
|
+
return {
|
|
885
|
+
pages: results.filter((r) => r.content?.id).map((r) => ({
|
|
886
|
+
id: r.content?.id ?? "",
|
|
887
|
+
space: r.space?.key ?? r.resultGlobalContainer?.title ?? "",
|
|
888
|
+
title: decodeEntities(r.content?.title ?? r.title ?? ""),
|
|
889
|
+
url: r.url ? `${site}/wiki${r.url}` : ""
|
|
890
|
+
})),
|
|
891
|
+
hasMore: serverPageWasFull
|
|
833
892
|
};
|
|
834
|
-
const header = pad(grid[0] ?? []);
|
|
835
|
-
const lines = [`| ${header.join(" | ")} |`, `| ${header.map(() => "---").join(" | ")} |`];
|
|
836
|
-
for (const row of grid.slice(1)) lines.push(`| ${pad(row).join(" | ")} |`);
|
|
837
|
-
return lines.join("\n");
|
|
838
893
|
}
|
|
839
|
-
function
|
|
840
|
-
|
|
894
|
+
function buildCql(params) {
|
|
895
|
+
if (params.cql) return params.cql;
|
|
896
|
+
const clauses = ["type = page"];
|
|
897
|
+
if (params.space) clauses.push(`space = ${cqlStringLiteral(params.space)}`);
|
|
898
|
+
if (params.text) clauses.push(`text ~ ${cqlStringLiteral(params.text)}`);
|
|
899
|
+
return `${clauses.join(" AND ")} ORDER BY lastmodified DESC`;
|
|
841
900
|
}
|
|
842
|
-
function
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
if (
|
|
847
|
-
|
|
901
|
+
function cqlStringLiteral(value) {
|
|
902
|
+
return `"${value.replace(/(["\\])/g, "\\$1")}"`;
|
|
903
|
+
}
|
|
904
|
+
async function fetchSpaceKey(client, spaceId) {
|
|
905
|
+
if (!spaceId) return "";
|
|
906
|
+
try {
|
|
907
|
+
return (await client.getJson(`/wiki/api/v2/spaces/${encodeURIComponent(spaceId)}`)).key ?? "";
|
|
908
|
+
} catch {
|
|
909
|
+
return "";
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
async function fetchAttachments(client, id) {
|
|
913
|
+
return (await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}/attachments?limit=250`)).results.filter((a) => a.downloadLink).map((a) => ({
|
|
914
|
+
mediaId: mediaNodeId(a),
|
|
915
|
+
filename: a.title ?? a.id,
|
|
916
|
+
url: withWikiContextPath(a.downloadLink ?? "")
|
|
917
|
+
}));
|
|
918
|
+
}
|
|
919
|
+
function mediaNodeId(a) {
|
|
920
|
+
return a.fileId ?? a.id;
|
|
921
|
+
}
|
|
922
|
+
async function fetchComments$1(client, id, names) {
|
|
923
|
+
const res = await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}/footer-comments?body-format=atlas_doc_format&limit=250`);
|
|
924
|
+
return Promise.all(res.results.map(async (c) => ({
|
|
925
|
+
author: await names.resolve(c.version?.authorId),
|
|
926
|
+
created: c.version?.createdAt ?? "",
|
|
927
|
+
body: parseAdfJson(c.body?.atlas_doc_format?.value)
|
|
928
|
+
})));
|
|
929
|
+
}
|
|
930
|
+
function withWikiContextPath(link) {
|
|
931
|
+
if (link.startsWith("http") || link.startsWith("/wiki")) return link;
|
|
932
|
+
return `/wiki${link}`;
|
|
933
|
+
}
|
|
934
|
+
function parseAdfJson(value) {
|
|
935
|
+
if (!value) return null;
|
|
936
|
+
try {
|
|
937
|
+
return JSON.parse(value);
|
|
938
|
+
} catch {
|
|
939
|
+
return null;
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
var UserNames = class {
|
|
943
|
+
client;
|
|
944
|
+
cache = /* @__PURE__ */ new Map();
|
|
945
|
+
constructor(client) {
|
|
946
|
+
this.client = client;
|
|
947
|
+
}
|
|
948
|
+
async resolve(accountId) {
|
|
949
|
+
if (!accountId) return "";
|
|
950
|
+
const cached = this.cache.get(accountId);
|
|
951
|
+
if (cached !== void 0) return cached;
|
|
952
|
+
const name = await this.fetchDisplayName(accountId) ?? accountId;
|
|
953
|
+
this.cache.set(accountId, name);
|
|
954
|
+
return name;
|
|
955
|
+
}
|
|
956
|
+
async fetchDisplayName(accountId) {
|
|
957
|
+
try {
|
|
958
|
+
return (await this.client.getJson(`/wiki/rest/api/user?accountId=${encodeURIComponent(accountId)}`)).displayName || null;
|
|
959
|
+
} catch {
|
|
960
|
+
return null;
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
};
|
|
964
|
+
//#endregion
|
|
965
|
+
//#region src/commands/resolve-ref.ts
|
|
966
|
+
async function resolveRef(arg, kind) {
|
|
967
|
+
const raw = arg ?? await input({
|
|
968
|
+
message: kind.message,
|
|
969
|
+
required: true
|
|
970
|
+
});
|
|
971
|
+
const ref = kind.parse(raw);
|
|
972
|
+
if (!ref) throw new Error(kind.notFound(raw));
|
|
973
|
+
return ref;
|
|
974
|
+
}
|
|
975
|
+
//#endregion
|
|
976
|
+
//#region src/commands/search-run.ts
|
|
977
|
+
const COPY_CONCURRENCY = 5;
|
|
978
|
+
async function runSearch(rows, options, noun, copyOne) {
|
|
979
|
+
if (options.json) {
|
|
980
|
+
console.log(JSON.stringify(rows.map((r) => r.json), null, 2));
|
|
981
|
+
return;
|
|
982
|
+
}
|
|
983
|
+
if (rows.length === 0) {
|
|
984
|
+
console.log(options.empty);
|
|
985
|
+
return;
|
|
986
|
+
}
|
|
987
|
+
if (options.copy) {
|
|
988
|
+
if (options.out?.endsWith(".md")) throw new Error("--out must be a directory when using --copy; a .md file path would overwrite each selection.");
|
|
989
|
+
await copySelected(rows, noun, copyOne);
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
for (const row of rows) console.log(formatRow(row));
|
|
993
|
+
if (options.footer) console.log(options.footer);
|
|
994
|
+
}
|
|
995
|
+
function searchFooter(limit) {
|
|
996
|
+
return `
|
|
997
|
+
Showing first ${limit}; refine with flags or raise --limit.`;
|
|
998
|
+
}
|
|
999
|
+
async function copySelected(rows, noun, copyOne) {
|
|
1000
|
+
if (!process.stdin.isTTY) throw new Error("--copy requires an interactive terminal.");
|
|
1001
|
+
const selected = await checkbox({
|
|
1002
|
+
message: `Select ${noun.plural} to copy:`,
|
|
1003
|
+
choices: rows.map((r) => ({
|
|
1004
|
+
name: formatRow(r),
|
|
1005
|
+
value: r.id
|
|
1006
|
+
})),
|
|
1007
|
+
pageSize: 20
|
|
1008
|
+
});
|
|
1009
|
+
if (selected.length === 0) {
|
|
1010
|
+
console.log("Nothing selected.");
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
1013
|
+
let copied = 0;
|
|
1014
|
+
const failures = [];
|
|
1015
|
+
const queue = [...selected];
|
|
1016
|
+
async function worker() {
|
|
1017
|
+
for (let id = queue.shift(); id !== void 0; id = queue.shift()) try {
|
|
1018
|
+
await copyOne(id);
|
|
1019
|
+
copied++;
|
|
1020
|
+
} catch (err) {
|
|
1021
|
+
failures.push(`${id} (${err instanceof Error ? err.message : String(err)})`);
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
await Promise.all(Array.from({ length: Math.min(COPY_CONCURRENCY, selected.length) }, worker));
|
|
1025
|
+
const summary = `Copied ${copied} ${copied === 1 ? noun.singular : noun.plural}`;
|
|
1026
|
+
if (failures.length === 0) console.log(summary);
|
|
1027
|
+
else console.log(`${summary}, failed ${failures.length}: ${failures.join(", ")}`);
|
|
1028
|
+
}
|
|
1029
|
+
function formatRow(row) {
|
|
1030
|
+
const room = (process.stdout.columns ?? 80) - stripVTControlCharacters(row.fixedColumns).length - 2;
|
|
1031
|
+
const text = room > 0 ? truncate(row.freeText, room) : "";
|
|
1032
|
+
return text ? `${row.fixedColumns} ${text}` : row.fixedColumns;
|
|
1033
|
+
}
|
|
1034
|
+
function truncate(text, max) {
|
|
1035
|
+
const clean = text.replace(/\s+/g, " ").trim();
|
|
1036
|
+
return clean.length <= max ? clean : `${clean.slice(0, Math.max(0, max - 3))}...`;
|
|
1037
|
+
}
|
|
1038
|
+
//#endregion
|
|
1039
|
+
//#region src/adf/to-markdown.ts
|
|
1040
|
+
function adfToMarkdown(doc, options = {}) {
|
|
1041
|
+
if (!doc) return "";
|
|
1042
|
+
const ctx = { resolveMedia: options.resolveMedia };
|
|
1043
|
+
return (doc.type === "doc" ? renderBlocks(doc.content ?? [], ctx, "") : renderBlock(doc, ctx, "")).trim();
|
|
1044
|
+
}
|
|
1045
|
+
function renderBlocks(nodes, ctx, indent) {
|
|
1046
|
+
return nodes.map((n) => renderBlock(n, ctx, indent)).filter((s) => s.length > 0).join("\n\n");
|
|
1047
|
+
}
|
|
1048
|
+
function renderBlock(node, ctx, indent) {
|
|
1049
|
+
switch (node.type) {
|
|
1050
|
+
case "paragraph": return indent + renderInline(node.content ?? [], ctx);
|
|
1051
|
+
case "heading": {
|
|
1052
|
+
const level = clampLevel(node.attrs?.["level"]);
|
|
1053
|
+
return `${"#".repeat(level)} ${renderInline(node.content ?? [], ctx)}`;
|
|
1054
|
+
}
|
|
1055
|
+
case "bulletList": return renderList(node, ctx, indent, "bullet");
|
|
1056
|
+
case "orderedList": return renderList(node, ctx, indent, "ordered");
|
|
1057
|
+
case "taskList": return renderTaskList(node, ctx, indent);
|
|
1058
|
+
case "decisionList": return renderDecisionList(node, ctx, indent);
|
|
1059
|
+
case "codeBlock": return renderCodeBlock(node, indent);
|
|
1060
|
+
case "blockquote": return prefixLines(renderBlocks(node.content ?? [], ctx, ""), `${indent}> `);
|
|
1061
|
+
case "panel": return renderPanel(node, ctx, indent);
|
|
1062
|
+
case "rule": return `${indent}---`;
|
|
1063
|
+
case "table": return renderTable(node, ctx);
|
|
1064
|
+
case "mediaSingle":
|
|
1065
|
+
case "mediaGroup": return renderBlocks(node.content ?? [], ctx, indent);
|
|
1066
|
+
case "media": return indent + renderMedia(node, ctx);
|
|
1067
|
+
case "expand":
|
|
1068
|
+
case "nestedExpand": return renderExpand(node, ctx, indent);
|
|
1069
|
+
default:
|
|
1070
|
+
if (node.content?.length) return renderBlocks(node.content, ctx, indent);
|
|
1071
|
+
return node.text ? indent + node.text : "";
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
function renderList(node, ctx, indent, kind) {
|
|
1075
|
+
const start = kind === "ordered" ? toNumber(node.attrs?.["order"]) ?? 1 : 0;
|
|
1076
|
+
return (node.content ?? []).filter((n) => n.type === "listItem").map((item, i) => {
|
|
1077
|
+
return renderListItem(item, ctx, indent, kind === "ordered" ? `${start + i}. ` : "- ");
|
|
1078
|
+
}).join("\n");
|
|
1079
|
+
}
|
|
1080
|
+
function renderListItem(item, ctx, indent, marker) {
|
|
1081
|
+
const continuationIndent = `${indent}${" ".repeat(marker.length)}`;
|
|
1082
|
+
return (item.content ?? []).map((block, i) => i === 0 ? `${indent}${marker}${renderBlock(block, ctx, "")}` : renderBlock(block, ctx, continuationIndent)).join("\n");
|
|
1083
|
+
}
|
|
1084
|
+
function renderTaskList(node, ctx, indent) {
|
|
1085
|
+
return (node.content ?? []).filter((n) => n.type === "taskItem").map((item) => {
|
|
1086
|
+
return `${indent}- [${item.attrs?.["state"] === "DONE" ? "x" : " "}] ${renderInline(item.content ?? [], ctx)}`;
|
|
1087
|
+
}).join("\n");
|
|
1088
|
+
}
|
|
1089
|
+
function renderDecisionList(node, ctx, indent) {
|
|
1090
|
+
return (node.content ?? []).filter((n) => n.type === "decisionItem").map((item) => `${indent}- (decision) ${renderInline(item.content ?? [], ctx)}`).join("\n");
|
|
1091
|
+
}
|
|
1092
|
+
function renderCodeBlock(node, indent) {
|
|
1093
|
+
const lang = typeof node.attrs?.["language"] === "string" ? node.attrs["language"] : "";
|
|
1094
|
+
const code = (node.content ?? []).map((n) => n.text ?? "").join("");
|
|
1095
|
+
const fence = "```";
|
|
1096
|
+
return prefixLines(`${fence}${lang}\n${code}\n${fence}`, indent);
|
|
1097
|
+
}
|
|
1098
|
+
const PANEL_LABELS = {
|
|
1099
|
+
info: "Info",
|
|
1100
|
+
note: "Note",
|
|
1101
|
+
warning: "Warning",
|
|
1102
|
+
success: "Success",
|
|
1103
|
+
error: "Error"
|
|
1104
|
+
};
|
|
1105
|
+
function renderPanel(node, ctx, indent) {
|
|
1106
|
+
return prefixLines(`**${PANEL_LABELS[typeof node.attrs?.["panelType"] === "string" ? node.attrs["panelType"] : "info"] ?? "Note"}**\n\n${renderBlocks(node.content ?? [], ctx, "")}`, `${indent}> `);
|
|
1107
|
+
}
|
|
1108
|
+
function renderExpand(node, ctx, indent) {
|
|
1109
|
+
return `${indent}<details><summary>${typeof node.attrs?.["title"] === "string" ? node.attrs["title"] : "Details"}</summary>\n\n${renderBlocks(node.content ?? [], ctx, "")}\n\n${indent}</details>`;
|
|
1110
|
+
}
|
|
1111
|
+
function renderTable(node, ctx) {
|
|
1112
|
+
const rows = (node.content ?? []).filter((n) => n.type === "tableRow");
|
|
1113
|
+
if (rows.length === 0) return "";
|
|
1114
|
+
const grid = rows.map((row) => (row.content ?? []).map((cell) => renderCellOnOneLine(cell, ctx)));
|
|
1115
|
+
const cols = Math.max(...grid.map((r) => r.length));
|
|
1116
|
+
const pad = (r) => {
|
|
1117
|
+
const copy = [...r];
|
|
1118
|
+
while (copy.length < cols) copy.push("");
|
|
1119
|
+
return copy;
|
|
1120
|
+
};
|
|
1121
|
+
const header = pad(grid[0] ?? []);
|
|
1122
|
+
const lines = [`| ${header.join(" | ")} |`, `| ${header.map(() => "---").join(" | ")} |`];
|
|
1123
|
+
for (const row of grid.slice(1)) lines.push(`| ${pad(row).join(" | ")} |`);
|
|
1124
|
+
return lines.join("\n");
|
|
1125
|
+
}
|
|
1126
|
+
function renderCellOnOneLine(cell, ctx) {
|
|
1127
|
+
return renderBlocks(cell.content ?? [], ctx, "").replace(/\n+/g, " ").replace(/\|/g, "\\|").trim();
|
|
1128
|
+
}
|
|
1129
|
+
function renderMedia(node, ctx) {
|
|
1130
|
+
const attrs = node.attrs ?? {};
|
|
1131
|
+
const alt = attrs.alt ?? "";
|
|
1132
|
+
const resolved = ctx.resolveMedia?.(attrs);
|
|
1133
|
+
if (resolved) return ``;
|
|
1134
|
+
return `[embedded media: ${alt || attrs.id || "media"}]`;
|
|
848
1135
|
}
|
|
849
1136
|
function renderInline(nodes, ctx) {
|
|
850
1137
|
return nodes.map((n) => renderInlineNode(n, ctx)).join("");
|
|
@@ -860,7 +1147,7 @@ function renderInlineNode(node, ctx) {
|
|
|
860
1147
|
const short = node.attrs?.["shortName"];
|
|
861
1148
|
return typeof short === "string" ? short : "";
|
|
862
1149
|
}
|
|
863
|
-
case "date": return formatDate
|
|
1150
|
+
case "date": return formatDate(node.attrs?.["timestamp"]);
|
|
864
1151
|
case "status": return `\`[${typeof node.attrs?.["text"] === "string" ? node.attrs["text"] : ""}]\``;
|
|
865
1152
|
case "inlineCard": {
|
|
866
1153
|
const url = node.attrs?.["url"];
|
|
@@ -908,226 +1195,82 @@ function clampLevel(value) {
|
|
|
908
1195
|
function toNumber(value) {
|
|
909
1196
|
return typeof value === "number" ? value : void 0;
|
|
910
1197
|
}
|
|
911
|
-
function formatDate
|
|
1198
|
+
function formatDate(timestamp) {
|
|
912
1199
|
const ms = typeof timestamp === "string" ? Number(timestamp) : typeof timestamp === "number" ? timestamp : NaN;
|
|
913
1200
|
if (Number.isNaN(ms)) return "";
|
|
914
1201
|
return new Date(ms).toISOString().slice(0, 10);
|
|
915
1202
|
}
|
|
916
1203
|
//#endregion
|
|
917
1204
|
//#region src/api/attachments.ts
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
try {
|
|
926
|
-
const bytes = await client.getBinary(att.url);
|
|
927
|
-
await writeFile(join(assetsDir, savedAs), bytes);
|
|
928
|
-
results.push({
|
|
929
|
-
...att,
|
|
930
|
-
relativePath: `${assetsDirName}/${savedAs}`
|
|
931
|
-
});
|
|
932
|
-
} catch (err) {
|
|
933
|
-
console.warn(` ! could not download ${att.filename}: ${err.message}`);
|
|
934
|
-
}
|
|
935
|
-
}
|
|
936
|
-
return results;
|
|
937
|
-
}
|
|
938
|
-
function safeName(name) {
|
|
939
|
-
return basename(name).replace(/[/\\]/g, "_") || "attachment";
|
|
940
|
-
}
|
|
941
|
-
function uniqueName(name, used) {
|
|
942
|
-
if (!used.has(name)) {
|
|
943
|
-
used.add(name);
|
|
944
|
-
return name;
|
|
945
|
-
}
|
|
946
|
-
const dot = name.lastIndexOf(".");
|
|
947
|
-
const stem = dot > 0 ? name.slice(0, dot) : name;
|
|
948
|
-
const ext = dot > 0 ? name.slice(dot) : "";
|
|
949
|
-
let i = 1;
|
|
950
|
-
let candidate = `${stem}-${i}${ext}`;
|
|
951
|
-
while (used.has(candidate)) {
|
|
952
|
-
i += 1;
|
|
953
|
-
candidate = `${stem}-${i}${ext}`;
|
|
954
|
-
}
|
|
955
|
-
used.add(candidate);
|
|
956
|
-
return candidate;
|
|
957
|
-
}
|
|
958
|
-
//#endregion
|
|
959
|
-
//#region src/util/html.ts
|
|
960
|
-
const NAMED = {
|
|
961
|
-
amp: "&",
|
|
962
|
-
lt: "<",
|
|
963
|
-
gt: ">",
|
|
964
|
-
quot: "\"",
|
|
965
|
-
apos: "'",
|
|
966
|
-
nbsp: " "
|
|
967
|
-
};
|
|
968
|
-
function decodeEntities(text) {
|
|
969
|
-
return text.replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (match, body) => {
|
|
970
|
-
if (body[0] === "#") {
|
|
971
|
-
const code = body[1] === "x" || body[1] === "X" ? Number.parseInt(body.slice(2), 16) : Number.parseInt(body.slice(1), 10);
|
|
972
|
-
return Number.isFinite(code) && code >= 0 && code <= 1114111 ? String.fromCodePoint(code) : match;
|
|
973
|
-
}
|
|
974
|
-
return NAMED[body.toLowerCase()] ?? match;
|
|
975
|
-
});
|
|
1205
|
+
function mediaResolver(downloaded) {
|
|
1206
|
+
const byMediaId = new Map(downloaded.map((d) => [d.mediaId, d.relativePath]));
|
|
1207
|
+
const byFilename = new Map(downloaded.map((d) => [d.filename, d.relativePath]));
|
|
1208
|
+
return (media) => {
|
|
1209
|
+
if (media.id && byMediaId.has(media.id)) return byMediaId.get(media.id);
|
|
1210
|
+
if (media.alt && byFilename.has(media.alt)) return byFilename.get(media.alt);
|
|
1211
|
+
};
|
|
976
1212
|
}
|
|
977
1213
|
//#endregion
|
|
978
|
-
//#region src/
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
1214
|
+
//#region src/markdown/copied-document.ts
|
|
1215
|
+
const COMMENTS_HEADING = "## Comments";
|
|
1216
|
+
const ATTACHMENTS_HEADING = "## Attachments";
|
|
1217
|
+
const FRONTMATTER_BLOCK = /^---\n([\s\S]*?)\n---\n?/;
|
|
1218
|
+
const FRONTMATTER_KEY = /^([A-Za-z0-9_]+):\s*(.*)$/;
|
|
1219
|
+
const FRONTMATTER_LIST_ITEM = /^ {2}- (.*)$/;
|
|
1220
|
+
const BARE_NUMBER = /^-?\d+(\.\d+)?$/;
|
|
1221
|
+
const TRAILING_SECTION_HEADING = /^## (Comments|Attachments)\s*$/;
|
|
1222
|
+
function render(doc) {
|
|
1223
|
+
return `${[
|
|
1224
|
+
frontmatter(doc.fields),
|
|
1225
|
+
`# ${doc.title}`,
|
|
1226
|
+
doc.body,
|
|
1227
|
+
commentsSection(doc.comments),
|
|
1228
|
+
attachmentsSection(doc.attachments)
|
|
1229
|
+
].filter((s) => s.trim().length > 0).join("\n\n")}\n`;
|
|
1230
|
+
}
|
|
1231
|
+
function parse(text) {
|
|
1232
|
+
const match = text.match(FRONTMATTER_BLOCK);
|
|
1233
|
+
if (!match) throw new Error("Not an atlass file: no YAML frontmatter found.");
|
|
1234
|
+
const fields = parseFrontmatter(match[1] ?? "");
|
|
1235
|
+
const lines = text.slice(match[0].length).split("\n");
|
|
1236
|
+
const h1 = leadingH1(lines);
|
|
1237
|
+
const start = h1 ? h1.index + 1 : 0;
|
|
1238
|
+
const trailing = lines.findIndex((line, i) => i >= start && TRAILING_SECTION_HEADING.test(line));
|
|
1239
|
+
const end = trailing === -1 ? lines.length : trailing;
|
|
1240
|
+
const fallbackTitle = typeof fields["title"] === "string" ? fields["title"] : "";
|
|
989
1241
|
return {
|
|
990
|
-
|
|
991
|
-
title:
|
|
992
|
-
|
|
993
|
-
version: page.version?.number ?? 0,
|
|
994
|
-
author,
|
|
995
|
-
createdAt: page.createdAt ?? "",
|
|
996
|
-
updatedAt: page.version?.createdAt ?? "",
|
|
997
|
-
url: webui ? `${site}/wiki${webui}` : "",
|
|
998
|
-
body: parseAdf(page.body?.atlas_doc_format?.value),
|
|
999
|
-
attachments,
|
|
1000
|
-
comments
|
|
1242
|
+
fields,
|
|
1243
|
+
title: h1?.title ?? fallbackTitle,
|
|
1244
|
+
body: lines.slice(start, end).join("\n").trim()
|
|
1001
1245
|
};
|
|
1002
1246
|
}
|
|
1003
|
-
|
|
1004
|
-
const
|
|
1247
|
+
function parseIssueSource(text) {
|
|
1248
|
+
const source = parse(text);
|
|
1249
|
+
const key = scalarString(source.fields["key"]);
|
|
1250
|
+
if (!key) throw new Error("Frontmatter is missing the issue `key`; re-copy the issue.");
|
|
1005
1251
|
return {
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1252
|
+
...source,
|
|
1253
|
+
key,
|
|
1254
|
+
updatedAtCopy: scalarString(source.fields["updated"])
|
|
1009
1255
|
};
|
|
1010
1256
|
}
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
async function uploadAttachment(client, pageId, filename, bytes) {
|
|
1019
|
-
const fileId = (await client.postMultipart(`/wiki/rest/api/content/${encodeURIComponent(pageId)}/child/attachment`, filename, bytes)).results?.[0]?.extensions?.fileId;
|
|
1020
|
-
if (fileId) return fileId;
|
|
1021
|
-
const match = (await listAttachments(client, pageId)).find((a) => a.filename === filename);
|
|
1022
|
-
if (match) return match.fileId;
|
|
1023
|
-
throw new Error(`Upload of "${filename}" did not return a fileId.`);
|
|
1024
|
-
}
|
|
1025
|
-
async function updatePage(client, id, params) {
|
|
1026
|
-
return (await client.putJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}`, {
|
|
1027
|
-
id,
|
|
1028
|
-
status: "current",
|
|
1029
|
-
title: params.title,
|
|
1030
|
-
body: {
|
|
1031
|
-
representation: "atlas_doc_format",
|
|
1032
|
-
value: JSON.stringify(params.body)
|
|
1033
|
-
},
|
|
1034
|
-
version: {
|
|
1035
|
-
number: params.version,
|
|
1036
|
-
message: params.message
|
|
1037
|
-
}
|
|
1038
|
-
})).version?.number ?? params.version;
|
|
1039
|
-
}
|
|
1040
|
-
async function searchPages(client, site, params) {
|
|
1041
|
-
const cql = buildCql(params);
|
|
1042
|
-
const query = new URLSearchParams({
|
|
1043
|
-
cql,
|
|
1044
|
-
limit: String(params.limit),
|
|
1045
|
-
expand: "space"
|
|
1046
|
-
});
|
|
1047
|
-
const results = (await client.getJson(`/wiki/rest/api/search?${query.toString()}`)).results ?? [];
|
|
1257
|
+
function parsePageSource(text) {
|
|
1258
|
+
const source = parse(text);
|
|
1259
|
+
const id = scalarString(source.fields["id"]);
|
|
1260
|
+
if (!id) throw new Error("Frontmatter is missing the page `id`; re-copy the page.");
|
|
1261
|
+
const rawVersion = scalarString(source.fields["version"]);
|
|
1262
|
+
const version = rawVersion === "" ? NaN : Number(rawVersion);
|
|
1263
|
+
if (!Number.isFinite(version)) throw new Error("Frontmatter is missing a numeric `version`; re-copy the page.");
|
|
1048
1264
|
return {
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
title: decodeEntities(r.content?.title ?? r.title ?? ""),
|
|
1053
|
-
url: r.url ? `${site}/wiki${r.url}` : ""
|
|
1054
|
-
})),
|
|
1055
|
-
hasMore: results.length === params.limit
|
|
1265
|
+
...source,
|
|
1266
|
+
id,
|
|
1267
|
+
version
|
|
1056
1268
|
};
|
|
1057
1269
|
}
|
|
1058
|
-
function
|
|
1059
|
-
if (
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
if (params.text) clauses.push(`text ~ ${cqlValue(params.text)}`);
|
|
1063
|
-
return `${clauses.join(" AND ")} ORDER BY lastmodified DESC`;
|
|
1064
|
-
}
|
|
1065
|
-
function cqlValue(value) {
|
|
1066
|
-
return `"${value.replace(/(["\\])/g, "\\$1")}"`;
|
|
1067
|
-
}
|
|
1068
|
-
async function fetchSpaceKey(client, spaceId) {
|
|
1069
|
-
if (!spaceId) return "";
|
|
1070
|
-
try {
|
|
1071
|
-
return (await client.getJson(`/wiki/api/v2/spaces/${encodeURIComponent(spaceId)}`)).key ?? "";
|
|
1072
|
-
} catch {
|
|
1073
|
-
return "";
|
|
1074
|
-
}
|
|
1075
|
-
}
|
|
1076
|
-
async function fetchAttachments(client, id) {
|
|
1077
|
-
return (await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}/attachments?limit=250`)).results.filter((a) => a.downloadLink).map((a) => ({
|
|
1078
|
-
id: a.fileId ?? a.id,
|
|
1079
|
-
filename: a.title ?? a.id,
|
|
1080
|
-
url: normalizeDownloadLink(a.downloadLink ?? "")
|
|
1081
|
-
}));
|
|
1082
|
-
}
|
|
1083
|
-
async function fetchComments$1(client, id, names) {
|
|
1084
|
-
const res = await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}/footer-comments?body-format=atlas_doc_format&limit=250`);
|
|
1085
|
-
return Promise.all(res.results.map(async (c) => ({
|
|
1086
|
-
author: await names.resolve(c.version?.authorId),
|
|
1087
|
-
created: c.version?.createdAt ?? "",
|
|
1088
|
-
body: parseAdf(c.body?.atlas_doc_format?.value)
|
|
1089
|
-
})));
|
|
1090
|
-
}
|
|
1091
|
-
function normalizeDownloadLink(link) {
|
|
1092
|
-
if (link.startsWith("http") || link.startsWith("/wiki")) return link;
|
|
1093
|
-
return `/wiki${link}`;
|
|
1094
|
-
}
|
|
1095
|
-
function parseAdf(value) {
|
|
1096
|
-
if (!value) return null;
|
|
1097
|
-
try {
|
|
1098
|
-
return JSON.parse(value);
|
|
1099
|
-
} catch {
|
|
1100
|
-
return null;
|
|
1101
|
-
}
|
|
1102
|
-
}
|
|
1103
|
-
var UserNames = class {
|
|
1104
|
-
client;
|
|
1105
|
-
cache = /* @__PURE__ */ new Map();
|
|
1106
|
-
constructor(client) {
|
|
1107
|
-
this.client = client;
|
|
1108
|
-
}
|
|
1109
|
-
async resolve(accountId) {
|
|
1110
|
-
if (!accountId) return "";
|
|
1111
|
-
const cached = this.cache.get(accountId);
|
|
1112
|
-
if (cached !== void 0) return cached;
|
|
1113
|
-
let name = accountId;
|
|
1114
|
-
try {
|
|
1115
|
-
const user = await this.client.getJson(`/wiki/rest/api/user?accountId=${encodeURIComponent(accountId)}`);
|
|
1116
|
-
if (user.displayName) name = user.displayName;
|
|
1117
|
-
} catch {}
|
|
1118
|
-
this.cache.set(accountId, name);
|
|
1119
|
-
return name;
|
|
1120
|
-
}
|
|
1121
|
-
};
|
|
1122
|
-
//#endregion
|
|
1123
|
-
//#region src/markdown/document.ts
|
|
1124
|
-
function mediaResolver(downloaded) {
|
|
1125
|
-
const byId = new Map(downloaded.map((d) => [d.id, d.relativePath]));
|
|
1126
|
-
const byName = new Map(downloaded.map((d) => [d.filename, d.relativePath]));
|
|
1127
|
-
return (media) => {
|
|
1128
|
-
if (media.id && byId.has(media.id)) return byId.get(media.id);
|
|
1129
|
-
if (media.alt && byName.has(media.alt)) return byName.get(media.alt);
|
|
1130
|
-
};
|
|
1270
|
+
function scalarString(value) {
|
|
1271
|
+
if (typeof value === "string") return value;
|
|
1272
|
+
if (typeof value === "number") return String(value);
|
|
1273
|
+
return "";
|
|
1131
1274
|
}
|
|
1132
1275
|
function frontmatter(fields) {
|
|
1133
1276
|
const lines = ["---"];
|
|
@@ -1138,111 +1281,195 @@ function frontmatter(fields) {
|
|
|
1138
1281
|
lines.push("---");
|
|
1139
1282
|
return lines.join("\n");
|
|
1140
1283
|
}
|
|
1284
|
+
function parseFrontmatter(block) {
|
|
1285
|
+
const fields = {};
|
|
1286
|
+
const lines = block.split("\n");
|
|
1287
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1288
|
+
const m = lines[i]?.match(FRONTMATTER_KEY);
|
|
1289
|
+
if (!m) continue;
|
|
1290
|
+
const key = m[1] ?? "";
|
|
1291
|
+
const raw = (m[2] ?? "").trim();
|
|
1292
|
+
if (raw === "[]") fields[key] = [];
|
|
1293
|
+
else if (raw === "") {
|
|
1294
|
+
const items = [];
|
|
1295
|
+
for (let item; item = lines[i + 1]?.match(FRONTMATTER_LIST_ITEM); i++) items.push(unquote((item[1] ?? "").trim()));
|
|
1296
|
+
fields[key] = items;
|
|
1297
|
+
} else fields[key] = scalar(raw);
|
|
1298
|
+
}
|
|
1299
|
+
return fields;
|
|
1300
|
+
}
|
|
1301
|
+
function scalar(raw) {
|
|
1302
|
+
if (raw.startsWith("\"")) return unquote(raw);
|
|
1303
|
+
if (BARE_NUMBER.test(raw)) return Number(raw);
|
|
1304
|
+
return raw;
|
|
1305
|
+
}
|
|
1141
1306
|
function quote(value) {
|
|
1142
1307
|
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
|
|
1143
1308
|
}
|
|
1144
|
-
function
|
|
1145
|
-
if (
|
|
1146
|
-
return
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1309
|
+
function unquote(value) {
|
|
1310
|
+
if (!value.startsWith("\"") || !value.endsWith("\"")) return value;
|
|
1311
|
+
return value.slice(1, -1).replace(/\\"/g, "\"").replace(/\\\\/g, "\\");
|
|
1312
|
+
}
|
|
1313
|
+
function leadingH1(lines) {
|
|
1314
|
+
for (const [index, line] of lines.entries()) {
|
|
1315
|
+
if (line.startsWith("# ")) return {
|
|
1316
|
+
index,
|
|
1317
|
+
title: line.slice(2).trim()
|
|
1318
|
+
};
|
|
1319
|
+
if (line.trim().length > 0) return null;
|
|
1320
|
+
}
|
|
1321
|
+
return null;
|
|
1151
1322
|
}
|
|
1152
|
-
function commentsSection(comments
|
|
1323
|
+
function commentsSection(comments) {
|
|
1153
1324
|
if (comments.length === 0) return "";
|
|
1154
1325
|
return [
|
|
1155
|
-
|
|
1326
|
+
COMMENTS_HEADING,
|
|
1156
1327
|
"",
|
|
1157
1328
|
comments.map((c) => {
|
|
1158
|
-
const heading = `### ${c.author || "Unknown"}${c.created ? ` - ${
|
|
1159
|
-
|
|
1160
|
-
return body ? `${heading}\n\n${body}` : heading;
|
|
1329
|
+
const heading = `### ${c.author || "Unknown"}${c.created ? ` - ${formatDateTime(c.created)}` : ""}`;
|
|
1330
|
+
return c.body ? `${heading}\n\n${c.body}` : heading;
|
|
1161
1331
|
}).join("\n\n")
|
|
1162
1332
|
].join("\n");
|
|
1163
1333
|
}
|
|
1164
|
-
function
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1334
|
+
function attachmentsSection(attachments) {
|
|
1335
|
+
if (attachments.length === 0) return "";
|
|
1336
|
+
return [
|
|
1337
|
+
ATTACHMENTS_HEADING,
|
|
1338
|
+
"",
|
|
1339
|
+
...attachments.map((a) => `- [${a.filename}](${a.relativePath})`)
|
|
1340
|
+
].join("\n");
|
|
1171
1341
|
}
|
|
1172
1342
|
//#endregion
|
|
1173
|
-
//#region src/
|
|
1174
|
-
function
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1343
|
+
//#region src/copy/plan.ts
|
|
1344
|
+
function planIssueCopy(issue, out) {
|
|
1345
|
+
return planCopy({
|
|
1346
|
+
baseName: issue.key,
|
|
1347
|
+
fields: {
|
|
1348
|
+
key: issue.key,
|
|
1349
|
+
type: issue.type,
|
|
1350
|
+
status: issue.status,
|
|
1351
|
+
assignee: issue.assignee,
|
|
1352
|
+
reporter: issue.reporter,
|
|
1353
|
+
priority: issue.priority,
|
|
1354
|
+
labels: issue.labels,
|
|
1355
|
+
created: issue.created,
|
|
1356
|
+
updated: issue.updated,
|
|
1357
|
+
url: issue.url
|
|
1358
|
+
},
|
|
1359
|
+
title: issue.summary,
|
|
1360
|
+
body: issue.description,
|
|
1361
|
+
comments: issue.comments,
|
|
1362
|
+
attachments: issue.attachments
|
|
1363
|
+
}, out);
|
|
1364
|
+
}
|
|
1365
|
+
function planPageCopy(page, out) {
|
|
1366
|
+
return planCopy({
|
|
1367
|
+
baseName: `${page.id}-${slugify(page.title)}`,
|
|
1368
|
+
fields: {
|
|
1369
|
+
title: page.title,
|
|
1370
|
+
id: page.id,
|
|
1371
|
+
space: page.spaceKey,
|
|
1372
|
+
version: page.version,
|
|
1373
|
+
author: page.author,
|
|
1374
|
+
created: page.createdAt,
|
|
1375
|
+
updated: page.updatedAt,
|
|
1376
|
+
url: page.url
|
|
1377
|
+
},
|
|
1378
|
+
title: page.title,
|
|
1379
|
+
body: page.body,
|
|
1380
|
+
comments: page.comments,
|
|
1381
|
+
attachments: page.attachments
|
|
1382
|
+
}, out);
|
|
1383
|
+
}
|
|
1384
|
+
function renderCopy(plan, landed) {
|
|
1385
|
+
const resolveMedia = mediaResolver(landed);
|
|
1386
|
+
return render({
|
|
1387
|
+
fields: plan.fields,
|
|
1388
|
+
title: plan.title,
|
|
1389
|
+
body: adfToMarkdown(plan.body, { resolveMedia }),
|
|
1390
|
+
comments: plan.comments.map((c) => ({
|
|
1391
|
+
author: c.author,
|
|
1392
|
+
created: c.created,
|
|
1393
|
+
body: adfToMarkdown(c.body, { resolveMedia })
|
|
1394
|
+
})),
|
|
1395
|
+
attachments: landed
|
|
1396
|
+
});
|
|
1187
1397
|
}
|
|
1188
|
-
function
|
|
1189
|
-
const {
|
|
1190
|
-
const
|
|
1191
|
-
if (!key) throw new Error("Frontmatter is missing the issue `key`; re-copy the issue.");
|
|
1398
|
+
function planCopy(source, out) {
|
|
1399
|
+
const { baseName, attachments, ...content } = source;
|
|
1400
|
+
const target = outputTarget(baseName, out);
|
|
1192
1401
|
return {
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1402
|
+
...content,
|
|
1403
|
+
filePath: target.filePath,
|
|
1404
|
+
assetsDir: target.assetsDir,
|
|
1405
|
+
downloads: planDownloads(attachments, target)
|
|
1197
1406
|
};
|
|
1198
1407
|
}
|
|
1199
|
-
function
|
|
1200
|
-
|
|
1201
|
-
if (!
|
|
1202
|
-
|
|
1203
|
-
|
|
1408
|
+
function outputTarget(base, out) {
|
|
1409
|
+
let filePath;
|
|
1410
|
+
if (!out) filePath = resolve(`${base}.md`);
|
|
1411
|
+
else if (out.endsWith(".md")) filePath = isAbsolute(out) ? out : resolve(out);
|
|
1412
|
+
else filePath = resolve(out, `${base}.md`);
|
|
1413
|
+
const dir = dirname(filePath);
|
|
1414
|
+
const assetsDirName = `${filePath.slice(dir.length + 1).replace(/\.md$/, "")}.assets`;
|
|
1204
1415
|
return {
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1416
|
+
filePath,
|
|
1417
|
+
assetsDir: join(dir, assetsDirName),
|
|
1418
|
+
assetsDirName
|
|
1208
1419
|
};
|
|
1209
1420
|
}
|
|
1210
|
-
function
|
|
1211
|
-
|
|
1212
|
-
for (const line of block.split("\n")) {
|
|
1213
|
-
const m = line.match(/^([A-Za-z0-9_]+):\s*(.*)$/);
|
|
1214
|
-
if (!m) continue;
|
|
1215
|
-
const key = m[1] ?? "";
|
|
1216
|
-
let value = (m[2] ?? "").trim();
|
|
1217
|
-
if (value.startsWith("\"") && value.endsWith("\"")) value = value.slice(1, -1).replace(/\\"/g, "\"").replace(/\\\\/g, "\\");
|
|
1218
|
-
out[key] = value;
|
|
1219
|
-
}
|
|
1220
|
-
return out;
|
|
1421
|
+
function slugify(title) {
|
|
1422
|
+
return title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60).replace(/-+$/g, "") || "page";
|
|
1221
1423
|
}
|
|
1222
|
-
function
|
|
1223
|
-
const
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1424
|
+
function planDownloads(attachments, target) {
|
|
1425
|
+
const used = /* @__PURE__ */ new Set();
|
|
1426
|
+
return attachments.map((att) => {
|
|
1427
|
+
const savedAs = uniqueName(bareFilename(att.filename), used);
|
|
1428
|
+
return {
|
|
1429
|
+
...att,
|
|
1430
|
+
relativePath: `${target.assetsDirName}/${savedAs}`,
|
|
1431
|
+
path: join(target.assetsDir, savedAs)
|
|
1432
|
+
};
|
|
1433
|
+
});
|
|
1434
|
+
}
|
|
1435
|
+
function bareFilename(name) {
|
|
1436
|
+
return basename(name).replace(/[/\\]/g, "_") || "attachment";
|
|
1437
|
+
}
|
|
1438
|
+
function uniqueName(name, used) {
|
|
1439
|
+
let candidate = name;
|
|
1440
|
+
for (let suffix = 1; used.has(candidate); suffix++) candidate = numberedName(name, suffix);
|
|
1441
|
+
used.add(candidate);
|
|
1442
|
+
return candidate;
|
|
1443
|
+
}
|
|
1444
|
+
function numberedName(name, suffix) {
|
|
1445
|
+
const dot = name.lastIndexOf(".");
|
|
1446
|
+
return `${dot > 0 ? name.slice(0, dot) : name}-${suffix}${dot > 0 ? name.slice(dot) : ""}`;
|
|
1447
|
+
}
|
|
1448
|
+
//#endregion
|
|
1449
|
+
//#region src/copy/run.ts
|
|
1450
|
+
async function runCopy(plan, fetchBytes) {
|
|
1451
|
+
const landed = await download(plan, fetchBytes);
|
|
1452
|
+
await mkdir(dirname(plan.filePath), { recursive: true });
|
|
1453
|
+
await writeFile(plan.filePath, renderCopy(plan, landed), "utf8");
|
|
1454
|
+
console.log(wroteLine(plan.filePath, landed.length));
|
|
1455
|
+
}
|
|
1456
|
+
async function download(plan, fetchBytes) {
|
|
1457
|
+
if (plan.downloads.length === 0) return [];
|
|
1458
|
+
await mkdir(plan.assetsDir, { recursive: true });
|
|
1459
|
+
const landed = [];
|
|
1460
|
+
for (const { path, ...attachment } of plan.downloads) try {
|
|
1461
|
+
await writeFile(path, await fetchBytes(attachment.url));
|
|
1462
|
+
landed.push(attachment);
|
|
1463
|
+
} catch (err) {
|
|
1464
|
+
console.warn(` ! could not download ${attachment.filename}: ${err.message}`);
|
|
1239
1465
|
}
|
|
1240
|
-
|
|
1241
|
-
return {
|
|
1242
|
-
bodyTitle,
|
|
1243
|
-
body
|
|
1244
|
-
};
|
|
1466
|
+
return landed;
|
|
1245
1467
|
}
|
|
1468
|
+
function wroteLine(filePath, attachmentCount) {
|
|
1469
|
+
return `Wrote ${filePath}${attachmentCount > 0 ? ` (+${attachmentCount} attachment${attachmentCount === 1 ? "" : "s"})` : ""}`;
|
|
1470
|
+
}
|
|
1471
|
+
//#endregion
|
|
1472
|
+
//#region src/adf/lossy.ts
|
|
1246
1473
|
const LOSSY_LABELS = {
|
|
1247
1474
|
panel: "panel",
|
|
1248
1475
|
expand: "expand",
|
|
@@ -1258,101 +1485,220 @@ const JIRA_LOSSY_LABELS = {
|
|
|
1258
1485
|
media: "image",
|
|
1259
1486
|
mediaInline: "image"
|
|
1260
1487
|
};
|
|
1261
|
-
function findLossyNodes(node,
|
|
1488
|
+
function findLossyNodes(node, lossyLabels = LOSSY_LABELS) {
|
|
1262
1489
|
const counts = /* @__PURE__ */ new Map();
|
|
1263
1490
|
const visit = (n) => {
|
|
1264
|
-
const label =
|
|
1491
|
+
const label = lossyLabels[n.type];
|
|
1265
1492
|
if (label) counts.set(label, (counts.get(label) ?? 0) + 1);
|
|
1266
1493
|
for (const child of n.content ?? []) visit(child);
|
|
1267
1494
|
};
|
|
1268
|
-
if (node) visit(node);
|
|
1269
|
-
return counts;
|
|
1495
|
+
if (node) visit(node);
|
|
1496
|
+
return counts;
|
|
1497
|
+
}
|
|
1498
|
+
function formatLossy(counts) {
|
|
1499
|
+
return [...counts.entries()].map(([label, n]) => `${n} ${label}${n === 1 ? "" : "s"}`).join(", ");
|
|
1500
|
+
}
|
|
1501
|
+
//#endregion
|
|
1502
|
+
//#region src/update/plan.ts
|
|
1503
|
+
function planIssueUpdate(source, issue, options) {
|
|
1504
|
+
const images = imageHrefs(source.body).map((href) => ({
|
|
1505
|
+
href,
|
|
1506
|
+
kind: isExternalHref(href) ? "external" : "unsupported"
|
|
1507
|
+
}));
|
|
1508
|
+
const unsupported = images.filter((i) => i.kind === "unsupported").map((i) => i.href);
|
|
1509
|
+
const body = markdownToAdf(source.body, { resolveImage: (href, alt) => isExternalHref(href) ? externalMedia(href, alt) : void 0 });
|
|
1510
|
+
const refusals = [...unsupported.length > 0 ? [`jira update does not support image changes yet. Remove local image reference(s) or edit text only: ${unsupported.join(", ")}`] : [], ...emptyBodyRefusal(body)];
|
|
1511
|
+
return withVerdict({
|
|
1512
|
+
noun: "issue",
|
|
1513
|
+
id: source.key,
|
|
1514
|
+
headline: headline("summary", issue.summary, source.title, options.summary),
|
|
1515
|
+
revision: revision(source.updatedAtCopy, issue.updated),
|
|
1516
|
+
lossy: findLossyNodes(issue.description, JIRA_LOSSY_LABELS),
|
|
1517
|
+
images,
|
|
1518
|
+
uploads: [],
|
|
1519
|
+
body,
|
|
1520
|
+
refusals
|
|
1521
|
+
}, options.force ?? false);
|
|
1522
|
+
}
|
|
1523
|
+
function planPageUpdate(source, state, attachments, localImages, options) {
|
|
1524
|
+
const collection = `contentId-${source.id}`;
|
|
1525
|
+
const entries = imageHrefs(source.body).map((href) => pageImage(href, localImages, attachments));
|
|
1526
|
+
const byHref = new Map(entries.map((e) => [e.href, e]));
|
|
1527
|
+
const body = markdownToAdf(source.body, { resolveImage: (href, alt) => {
|
|
1528
|
+
const entry = byHref.get(href);
|
|
1529
|
+
if (!entry || entry.kind === "missing") return void 0;
|
|
1530
|
+
if (entry.kind === "external") return externalMedia(href, alt);
|
|
1531
|
+
return mediaNode({
|
|
1532
|
+
type: "file",
|
|
1533
|
+
id: entry.kind === "reuse" ? entry.fileId : href,
|
|
1534
|
+
collection
|
|
1535
|
+
}, alt);
|
|
1536
|
+
} });
|
|
1537
|
+
const missing = entries.filter((e) => e.kind === "missing").map((e) => e.href);
|
|
1538
|
+
const refusals = [...missing.length > 0 ? [`Image file(s) not found: ${missing.join(", ")}`] : [], ...emptyBodyRefusal(body)];
|
|
1539
|
+
return withVerdict({
|
|
1540
|
+
noun: "page",
|
|
1541
|
+
id: source.id,
|
|
1542
|
+
headline: headline("title", state.title, source.title, options.title),
|
|
1543
|
+
revision: revision(`v${source.version}`, `v${state.version}`),
|
|
1544
|
+
lossy: findLossyNodes(state.body),
|
|
1545
|
+
images: entries.map(({ href, kind }) => ({
|
|
1546
|
+
href,
|
|
1547
|
+
kind
|
|
1548
|
+
})),
|
|
1549
|
+
uploads: entries.flatMap((e) => e.kind === "upload" || e.kind === "changed" ? [{
|
|
1550
|
+
href: e.href,
|
|
1551
|
+
path: e.path,
|
|
1552
|
+
filename: e.filename
|
|
1553
|
+
}] : []),
|
|
1554
|
+
body,
|
|
1555
|
+
refusals
|
|
1556
|
+
}, options.force ?? false);
|
|
1557
|
+
}
|
|
1558
|
+
function pageImage(href, localImages, attachments) {
|
|
1559
|
+
if (isExternalHref(href)) return {
|
|
1560
|
+
href,
|
|
1561
|
+
kind: "external"
|
|
1562
|
+
};
|
|
1563
|
+
const local = localImages.find((l) => l.href === href);
|
|
1564
|
+
if (!local || local.size === void 0) return {
|
|
1565
|
+
href,
|
|
1566
|
+
kind: "missing"
|
|
1567
|
+
};
|
|
1568
|
+
const existing = attachments.find((a) => a.filename === local.filename);
|
|
1569
|
+
if (existing && existing.size === local.size) return {
|
|
1570
|
+
href,
|
|
1571
|
+
kind: "reuse",
|
|
1572
|
+
fileId: existing.fileId
|
|
1573
|
+
};
|
|
1574
|
+
return {
|
|
1575
|
+
href,
|
|
1576
|
+
kind: existing ? "changed" : "upload",
|
|
1577
|
+
path: local.path,
|
|
1578
|
+
filename: local.filename
|
|
1579
|
+
};
|
|
1580
|
+
}
|
|
1581
|
+
function withUploadedIds(doc, ids) {
|
|
1582
|
+
if (!doc.content) return doc;
|
|
1583
|
+
return {
|
|
1584
|
+
...doc,
|
|
1585
|
+
content: doc.content.map((n) => withUploadedId(n, ids))
|
|
1586
|
+
};
|
|
1270
1587
|
}
|
|
1271
|
-
function
|
|
1272
|
-
|
|
1588
|
+
function withUploadedId(node, ids) {
|
|
1589
|
+
const id = node.type === "media" ? node.attrs?.["id"] : void 0;
|
|
1590
|
+
const swapped = typeof id === "string" && ids.has(id) ? {
|
|
1591
|
+
...node,
|
|
1592
|
+
attrs: {
|
|
1593
|
+
...node.attrs,
|
|
1594
|
+
id: ids.get(id)
|
|
1595
|
+
}
|
|
1596
|
+
} : node;
|
|
1597
|
+
if (!swapped.content) return swapped;
|
|
1598
|
+
return {
|
|
1599
|
+
...swapped,
|
|
1600
|
+
content: swapped.content.map((n) => withUploadedId(n, ids))
|
|
1601
|
+
};
|
|
1273
1602
|
}
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
if (
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1603
|
+
function formatPlan(plan) {
|
|
1604
|
+
const lines = [`Dry run for ${plan.noun} ${plan.id} "${plan.headline.current}"`];
|
|
1605
|
+
if (plan.headline.next !== plan.headline.current) lines.push(row(plan.headline.label, `"${plan.headline.current}" -> "${plan.headline.next}"`));
|
|
1606
|
+
const counts = imageCounts(plan.images);
|
|
1607
|
+
if (counts) lines.push(row("images", counts));
|
|
1608
|
+
for (const refusal of plan.refusals) lines.push(row("blocked", refusal));
|
|
1609
|
+
if (plan.lossy.size > 0) lines.push(row("warning", `${formatLossy(plan.lossy)} will be removed`));
|
|
1610
|
+
if (plan.revision.stale) lines.push(row("stale", `${revisionText(plan.revision)} (would refuse without --force)`));
|
|
1611
|
+
lines.push(" nothing was written (dry run)");
|
|
1612
|
+
return lines;
|
|
1613
|
+
}
|
|
1614
|
+
const IMAGE_COUNT_LABELS = [
|
|
1615
|
+
["upload", "new"],
|
|
1616
|
+
["changed", "changed"],
|
|
1617
|
+
["reuse", "reused"],
|
|
1618
|
+
["external", "external"],
|
|
1619
|
+
["unsupported", "unsupported"],
|
|
1620
|
+
["missing", "missing"]
|
|
1621
|
+
];
|
|
1622
|
+
function imageCounts(images) {
|
|
1623
|
+
return IMAGE_COUNT_LABELS.map(([kind, label]) => ({
|
|
1624
|
+
n: images.filter((i) => i.kind === kind).length,
|
|
1625
|
+
label
|
|
1626
|
+
})).filter(({ n }) => n > 0).map(({ n, label }) => `${n} ${label}`).join(", ");
|
|
1627
|
+
}
|
|
1628
|
+
function row(label, value) {
|
|
1629
|
+
return ` ${`${label}:`.padEnd(9)}${value}`;
|
|
1630
|
+
}
|
|
1631
|
+
function headline(label, current, title, useTitle) {
|
|
1283
1632
|
return {
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1633
|
+
label,
|
|
1634
|
+
current,
|
|
1635
|
+
next: useTitle && title ? title : current
|
|
1287
1636
|
};
|
|
1288
1637
|
}
|
|
1289
|
-
function
|
|
1290
|
-
return
|
|
1638
|
+
function revision(local, server) {
|
|
1639
|
+
return {
|
|
1640
|
+
local: local || "unknown",
|
|
1641
|
+
server: server || "unknown",
|
|
1642
|
+
stale: local !== server
|
|
1643
|
+
};
|
|
1291
1644
|
}
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
const COPY_CONCURRENCY = 5;
|
|
1295
|
-
async function runSearch(rows, options, noun, copyOne) {
|
|
1296
|
-
if (options.json) {
|
|
1297
|
-
console.log(JSON.stringify(rows.map((r) => r.json), null, 2));
|
|
1298
|
-
return;
|
|
1299
|
-
}
|
|
1300
|
-
if (rows.length === 0) {
|
|
1301
|
-
console.log(`No matching ${noun.plural}.`);
|
|
1302
|
-
return;
|
|
1303
|
-
}
|
|
1304
|
-
if (options.copy) {
|
|
1305
|
-
if (options.out?.endsWith(".md")) throw new Error("--out must be a directory when using --copy; a .md file path would overwrite each selection.");
|
|
1306
|
-
await copySelected(rows, noun, copyOne);
|
|
1307
|
-
return;
|
|
1308
|
-
}
|
|
1309
|
-
for (const row of rows) console.log(formatRow(row));
|
|
1310
|
-
if (options.hasMore) console.log(`\nShowing first ${options.limit}; refine with flags or raise --limit.`);
|
|
1645
|
+
function revisionText(revision) {
|
|
1646
|
+
return `copied at ${revision.local}, server now ${revision.server}`;
|
|
1311
1647
|
}
|
|
1312
|
-
|
|
1313
|
-
if (
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1648
|
+
function emptyBodyRefusal(body) {
|
|
1649
|
+
if (body.content && body.content.length > 0) return [];
|
|
1650
|
+
return ["Refusing to update: the converted body is empty."];
|
|
1651
|
+
}
|
|
1652
|
+
function withVerdict(plan, force) {
|
|
1653
|
+
return {
|
|
1654
|
+
...plan,
|
|
1655
|
+
verdict: verdict(plan, force)
|
|
1656
|
+
};
|
|
1657
|
+
}
|
|
1658
|
+
function verdict(plan, force) {
|
|
1659
|
+
if (plan.refusals.length > 0) return {
|
|
1660
|
+
kind: "refuse",
|
|
1661
|
+
message: plan.refusals.join("\n")
|
|
1662
|
+
};
|
|
1663
|
+
const noun = plan.noun;
|
|
1664
|
+
if (plan.revision.stale && !force) return {
|
|
1665
|
+
kind: "refuse",
|
|
1666
|
+
message: `${capitalize(noun)} changed on the server since you copied it (${revisionText(plan.revision)}). Re-copy the ${noun} or pass --force.`
|
|
1667
|
+
};
|
|
1668
|
+
if (plan.lossy.size > 0 && !force) return {
|
|
1669
|
+
kind: "confirm",
|
|
1670
|
+
message: `This ${noun} contains ${formatLossy(plan.lossy)} that Markdown cannot represent and will be removed. Continue?`
|
|
1671
|
+
};
|
|
1672
|
+
return { kind: "proceed" };
|
|
1673
|
+
}
|
|
1674
|
+
function capitalize(text) {
|
|
1675
|
+
return text.charAt(0).toUpperCase() + text.slice(1);
|
|
1676
|
+
}
|
|
1677
|
+
//#endregion
|
|
1678
|
+
//#region src/update/run.ts
|
|
1679
|
+
async function runPlan(plan, options, push) {
|
|
1680
|
+
if (options.dryRun) {
|
|
1681
|
+
for (const line of formatPlan(plan)) console.log(line);
|
|
1324
1682
|
return;
|
|
1325
1683
|
}
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1684
|
+
const verdict = plan.verdict;
|
|
1685
|
+
if (verdict.kind === "refuse") throw new Error(verdict.message);
|
|
1686
|
+
if (verdict.kind === "confirm") {
|
|
1687
|
+
if (!await confirm({
|
|
1688
|
+
message: verdict.message,
|
|
1689
|
+
default: false
|
|
1690
|
+
})) {
|
|
1691
|
+
console.log("Aborted.");
|
|
1692
|
+
return;
|
|
1335
1693
|
}
|
|
1336
1694
|
}
|
|
1337
|
-
await
|
|
1338
|
-
const summary = `Copied ${copied} ${copied === 1 ? noun.singular : noun.plural}`;
|
|
1339
|
-
if (failures.length === 0) console.log(summary);
|
|
1340
|
-
else console.log(`${summary}, failed ${failures.length}: ${failures.join(", ")}`);
|
|
1341
|
-
}
|
|
1342
|
-
function formatRow(row) {
|
|
1343
|
-
const room = (process.stdout.columns ?? 80) - row.prefix.length - 2;
|
|
1344
|
-
const text = room > 0 ? truncate(row.text, room) : "";
|
|
1345
|
-
return text ? `${row.prefix} ${text}` : row.prefix;
|
|
1346
|
-
}
|
|
1347
|
-
function truncate(text, max) {
|
|
1348
|
-
const clean = text.replace(/\s+/g, " ").trim();
|
|
1349
|
-
return clean.length <= max ? clean : `${clean.slice(0, Math.max(0, max - 3))}...`;
|
|
1695
|
+
await push();
|
|
1350
1696
|
}
|
|
1351
1697
|
//#endregion
|
|
1352
1698
|
//#region src/commands/confluence.ts
|
|
1353
1699
|
async function confluenceCopy(arg, options) {
|
|
1354
1700
|
const auth = await requireAuth();
|
|
1355
|
-
const id = await
|
|
1701
|
+
const id = await resolveRef(arg, PAGE_REF);
|
|
1356
1702
|
await copyPage(new AtlassianClient(auth), auth.site, id, options.out);
|
|
1357
1703
|
}
|
|
1358
1704
|
async function confluenceUpdate(arg, options) {
|
|
@@ -1360,50 +1706,44 @@ async function confluenceUpdate(arg, options) {
|
|
|
1360
1706
|
message: "Path to the page Markdown file:",
|
|
1361
1707
|
required: true
|
|
1362
1708
|
});
|
|
1363
|
-
const src =
|
|
1709
|
+
const src = parsePageSource(await readFile(file, "utf8"));
|
|
1364
1710
|
const client = new AtlassianClient(await requireAuth());
|
|
1365
1711
|
const state = await fetchPageState(client, src.id);
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
if (options.dryRun) {
|
|
1374
|
-
printDryRun$1(src.id, state.title, newTitle, state.version, nextVersion, lossy, plan);
|
|
1375
|
-
return;
|
|
1376
|
-
}
|
|
1377
|
-
if (lossy.size > 0 && !options.force) {
|
|
1378
|
-
if (!await confirm({
|
|
1379
|
-
message: `This page contains ${formatLossy(lossy)} that Markdown cannot represent and will be removed. Continue?`,
|
|
1380
|
-
default: false
|
|
1381
|
-
})) {
|
|
1382
|
-
console.log("Aborted.");
|
|
1383
|
-
return;
|
|
1712
|
+
const plan = planPageUpdate(src, state, await listAttachments(client, src.id), await statLocalImages(dirname(resolve(file)), src.body), options);
|
|
1713
|
+
await runPlan(plan, options, async () => {
|
|
1714
|
+
const ids = /* @__PURE__ */ new Map();
|
|
1715
|
+
for (const upload of plan.uploads) {
|
|
1716
|
+
console.log(`Uploading ${upload.filename} ...`);
|
|
1717
|
+
const bytes = await readFile(upload.path);
|
|
1718
|
+
ids.set(upload.href, await uploadAttachment(client, src.id, upload.filename, bytes));
|
|
1384
1719
|
}
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
const body = markdownToAdf(src.body, { resolveImage: (href, alt) => {
|
|
1393
|
-
const entry = plan.get(href);
|
|
1394
|
-
if (!entry) return void 0;
|
|
1395
|
-
if (entry.kind === "external") return externalMedia(href, alt);
|
|
1396
|
-
const fileId = fileIds.get(href);
|
|
1397
|
-
return fileId ? fileMedia(fileId, collection, alt) : void 0;
|
|
1398
|
-
} });
|
|
1399
|
-
if (!body.content || body.content.length === 0) throw new Error("Refusing to update: the converted body is empty.");
|
|
1400
|
-
const version = await updatePage(client, src.id, {
|
|
1401
|
-
title: newTitle,
|
|
1402
|
-
version: nextVersion,
|
|
1403
|
-
body,
|
|
1404
|
-
message: options.message ?? "Updated via atlass"
|
|
1720
|
+
const version = await updatePage(client, src.id, {
|
|
1721
|
+
title: plan.headline.next,
|
|
1722
|
+
nextVersion: state.version + 1,
|
|
1723
|
+
body: withUploadedIds(plan.body, ids),
|
|
1724
|
+
message: options.message ?? "Updated via atlass"
|
|
1725
|
+
});
|
|
1726
|
+
console.log(`Updated page ${src.id} to version ${version}.`);
|
|
1405
1727
|
});
|
|
1406
|
-
|
|
1728
|
+
}
|
|
1729
|
+
async function statLocalImages(dir, md) {
|
|
1730
|
+
const hrefs = imageHrefs(md).filter((href) => !isExternalHref(href));
|
|
1731
|
+
return Promise.all(hrefs.map(async (href) => {
|
|
1732
|
+
const path = isAbsolute(href) ? href : resolve(dir, href);
|
|
1733
|
+
return {
|
|
1734
|
+
href,
|
|
1735
|
+
path,
|
|
1736
|
+
filename: basename(path),
|
|
1737
|
+
...await fileSize(path)
|
|
1738
|
+
};
|
|
1739
|
+
}));
|
|
1740
|
+
}
|
|
1741
|
+
async function fileSize(path) {
|
|
1742
|
+
try {
|
|
1743
|
+
return { size: (await stat(path)).size };
|
|
1744
|
+
} catch {
|
|
1745
|
+
return {};
|
|
1746
|
+
}
|
|
1407
1747
|
}
|
|
1408
1748
|
async function confluenceSearch(query, options) {
|
|
1409
1749
|
if (options.cql && (query || options.space)) throw new Error("--cql cannot be combined with a text query or --space.");
|
|
@@ -1419,8 +1759,8 @@ async function confluenceSearch(query, options) {
|
|
|
1419
1759
|
});
|
|
1420
1760
|
await runSearch(pages.map((p) => ({
|
|
1421
1761
|
id: p.id,
|
|
1422
|
-
|
|
1423
|
-
|
|
1762
|
+
fixedColumns: `${p.id} ${p.space}`,
|
|
1763
|
+
freeText: p.title,
|
|
1424
1764
|
json: {
|
|
1425
1765
|
id: p.id,
|
|
1426
1766
|
space: p.space,
|
|
@@ -1430,9 +1770,9 @@ async function confluenceSearch(query, options) {
|
|
|
1430
1770
|
})), {
|
|
1431
1771
|
json: options.json,
|
|
1432
1772
|
copy: options.copy,
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1773
|
+
out: options.out,
|
|
1774
|
+
empty: "No matching pages.",
|
|
1775
|
+
footer: hasMore ? searchFooter(limit) : void 0
|
|
1436
1776
|
}, {
|
|
1437
1777
|
singular: "page",
|
|
1438
1778
|
plural: "pages"
|
|
@@ -1440,127 +1780,13 @@ async function confluenceSearch(query, options) {
|
|
|
1440
1780
|
}
|
|
1441
1781
|
async function copyPage(client, site, id, out) {
|
|
1442
1782
|
console.log(`Fetching page ${id} ...`);
|
|
1443
|
-
|
|
1444
|
-
const target = resolveOutput(`${page.id}-${slugify(page.title)}`, out);
|
|
1445
|
-
const downloaded = await downloadAttachments(client, page.attachments, target.assetsDir, target.assetsDirName);
|
|
1446
|
-
const resolveMedia = mediaResolver(downloaded);
|
|
1447
|
-
const document = joinSections([
|
|
1448
|
-
frontmatter({
|
|
1449
|
-
title: page.title,
|
|
1450
|
-
id: page.id,
|
|
1451
|
-
space: page.spaceKey,
|
|
1452
|
-
version: page.version,
|
|
1453
|
-
author: page.author,
|
|
1454
|
-
created: page.createdAt,
|
|
1455
|
-
updated: page.updatedAt,
|
|
1456
|
-
url: page.url
|
|
1457
|
-
}),
|
|
1458
|
-
`# ${page.title}`,
|
|
1459
|
-
adfToMarkdown(page.body, { resolveMedia }),
|
|
1460
|
-
commentsSection(page.comments, resolveMedia),
|
|
1461
|
-
attachmentsSection(downloaded)
|
|
1462
|
-
]);
|
|
1463
|
-
await writeFile(target.filePath, document, "utf8");
|
|
1464
|
-
const suffix = downloaded.length > 0 ? ` (+${downloaded.length} attachment${downloaded.length === 1 ? "" : "s"})` : "";
|
|
1465
|
-
console.log(`Wrote ${target.filePath}${suffix}`);
|
|
1466
|
-
}
|
|
1467
|
-
async function resolveId(arg) {
|
|
1468
|
-
const raw = arg ?? await input({
|
|
1469
|
-
message: "Confluence page id or URL:",
|
|
1470
|
-
required: true
|
|
1471
|
-
});
|
|
1472
|
-
const id = parsePageId(raw);
|
|
1473
|
-
if (!id) throw new Error(`Could not find a page id in "${raw}".`);
|
|
1474
|
-
return id;
|
|
1475
|
-
}
|
|
1476
|
-
function collectImageHrefs(md) {
|
|
1477
|
-
const hrefs = /* @__PURE__ */ new Set();
|
|
1478
|
-
marked.walkTokens(marked.lexer(md), (token) => {
|
|
1479
|
-
if (token.type === "image") hrefs.add(token.href);
|
|
1480
|
-
});
|
|
1481
|
-
return [...hrefs];
|
|
1482
|
-
}
|
|
1483
|
-
async function planImages(dir, hrefs, attachments) {
|
|
1484
|
-
const byName = new Map(attachments.map((a) => [a.filename, a]));
|
|
1485
|
-
const plan = /* @__PURE__ */ new Map();
|
|
1486
|
-
const missing = [];
|
|
1487
|
-
for (const href of hrefs) {
|
|
1488
|
-
if (isExternal(href)) {
|
|
1489
|
-
plan.set(href, { kind: "external" });
|
|
1490
|
-
continue;
|
|
1491
|
-
}
|
|
1492
|
-
const path = isAbsolute(href) ? href : resolve(dir, href);
|
|
1493
|
-
let size;
|
|
1494
|
-
try {
|
|
1495
|
-
size = (await stat(path)).size;
|
|
1496
|
-
} catch {
|
|
1497
|
-
missing.push(href);
|
|
1498
|
-
continue;
|
|
1499
|
-
}
|
|
1500
|
-
const filename = basename(path);
|
|
1501
|
-
const existing = byName.get(filename);
|
|
1502
|
-
if (existing && existing.size === size) plan.set(href, {
|
|
1503
|
-
kind: "reuse",
|
|
1504
|
-
fileId: existing.fileId,
|
|
1505
|
-
filename
|
|
1506
|
-
});
|
|
1507
|
-
else plan.set(href, {
|
|
1508
|
-
kind: "upload",
|
|
1509
|
-
path,
|
|
1510
|
-
filename,
|
|
1511
|
-
existed: existing !== void 0
|
|
1512
|
-
});
|
|
1513
|
-
}
|
|
1514
|
-
if (missing.length > 0) throw new Error(`Image file(s) not found: ${missing.join(", ")}`);
|
|
1515
|
-
return plan;
|
|
1516
|
-
}
|
|
1517
|
-
function isExternal(href) {
|
|
1518
|
-
return /^[a-z][a-z0-9+.-]*:\/\//i.test(href);
|
|
1519
|
-
}
|
|
1520
|
-
function externalMedia(href, alt) {
|
|
1521
|
-
const attrs = {
|
|
1522
|
-
type: "external",
|
|
1523
|
-
url: href
|
|
1524
|
-
};
|
|
1525
|
-
if (alt) attrs["alt"] = alt;
|
|
1526
|
-
return {
|
|
1527
|
-
type: "mediaSingle",
|
|
1528
|
-
attrs: { layout: "center" },
|
|
1529
|
-
content: [{
|
|
1530
|
-
type: "media",
|
|
1531
|
-
attrs
|
|
1532
|
-
}]
|
|
1533
|
-
};
|
|
1534
|
-
}
|
|
1535
|
-
function fileMedia(fileId, collection, alt) {
|
|
1536
|
-
const attrs = {
|
|
1537
|
-
type: "file",
|
|
1538
|
-
id: fileId,
|
|
1539
|
-
collection
|
|
1540
|
-
};
|
|
1541
|
-
if (alt) attrs["alt"] = alt;
|
|
1542
|
-
return {
|
|
1543
|
-
type: "mediaSingle",
|
|
1544
|
-
attrs: { layout: "center" },
|
|
1545
|
-
content: [{
|
|
1546
|
-
type: "media",
|
|
1547
|
-
attrs
|
|
1548
|
-
}]
|
|
1549
|
-
};
|
|
1550
|
-
}
|
|
1551
|
-
function printDryRun$1(id, currentTitle, newTitle, currentVersion, nextVersion, lossy, plan) {
|
|
1552
|
-
const entries = [...plan.values()];
|
|
1553
|
-
const added = entries.filter((e) => e.kind === "upload" && !e.existed).length;
|
|
1554
|
-
const changed = entries.filter((e) => e.kind === "upload" && e.existed).length;
|
|
1555
|
-
const reused = entries.filter((e) => e.kind === "reuse").length;
|
|
1556
|
-
const external = entries.filter((e) => e.kind === "external").length;
|
|
1557
|
-
console.log(`Dry run for page ${id} "${currentTitle}"`);
|
|
1558
|
-
console.log(` version: ${currentVersion} -> ${nextVersion}`);
|
|
1559
|
-
if (newTitle !== currentTitle) console.log(` title: "${currentTitle}" -> "${newTitle}"`);
|
|
1560
|
-
console.log(` images: ${added} new, ${changed} changed, ${reused} reused, ${external} external`);
|
|
1561
|
-
if (lossy.size > 0) console.log(` warning: ${formatLossy(lossy)} will be removed`);
|
|
1562
|
-
console.log(" nothing was written (dry run)");
|
|
1783
|
+
await runCopy(planPageCopy(await fetchPage(client, site, id), out), (url) => client.getBinary(url));
|
|
1563
1784
|
}
|
|
1785
|
+
const PAGE_REF = {
|
|
1786
|
+
message: "Confluence page id or URL:",
|
|
1787
|
+
parse: parsePageId,
|
|
1788
|
+
notFound: (raw) => `Could not find a page id in "${raw}".`
|
|
1789
|
+
};
|
|
1564
1790
|
//#endregion
|
|
1565
1791
|
//#region src/api/jira.ts
|
|
1566
1792
|
const FIELDS = [
|
|
@@ -1576,6 +1802,7 @@ const FIELDS = [
|
|
|
1576
1802
|
"updated",
|
|
1577
1803
|
"attachment"
|
|
1578
1804
|
].join(",");
|
|
1805
|
+
const RECENT_ISSUES_CLAUSE = "updated >= -30d";
|
|
1579
1806
|
function browseUrl(site, key) {
|
|
1580
1807
|
return `${site}/browse/${key}`;
|
|
1581
1808
|
}
|
|
@@ -1589,6 +1816,7 @@ async function fetchIssue(client, site, key) {
|
|
|
1589
1816
|
summary: f.summary ?? "",
|
|
1590
1817
|
type: f.issuetype?.name ?? "",
|
|
1591
1818
|
status: f.status?.name ?? "",
|
|
1819
|
+
statusCategory: f.status?.statusCategory?.key ?? "",
|
|
1592
1820
|
assignee: f.assignee?.displayName ?? "Unassigned",
|
|
1593
1821
|
reporter: f.reporter?.displayName ?? "",
|
|
1594
1822
|
priority: f.priority?.name ?? "",
|
|
@@ -1598,7 +1826,7 @@ async function fetchIssue(client, site, key) {
|
|
|
1598
1826
|
description: f.description ?? null,
|
|
1599
1827
|
comments,
|
|
1600
1828
|
attachments: (f.attachment ?? []).map((a) => ({
|
|
1601
|
-
|
|
1829
|
+
mediaId: a.id,
|
|
1602
1830
|
filename: a.filename,
|
|
1603
1831
|
url: a.content
|
|
1604
1832
|
}))
|
|
@@ -1607,32 +1835,81 @@ async function fetchIssue(client, site, key) {
|
|
|
1607
1835
|
async function updateIssue(client, key, update) {
|
|
1608
1836
|
const fields = { description: update.description };
|
|
1609
1837
|
if (update.summary !== void 0) fields["summary"] = update.summary;
|
|
1610
|
-
await client.
|
|
1838
|
+
await client.putNoContent(`/rest/api/3/issue/${encodeURIComponent(key)}`, { fields });
|
|
1611
1839
|
}
|
|
1840
|
+
const SEARCH_FIELDS = "summary,status,updated";
|
|
1612
1841
|
async function searchIssues(client, site, params) {
|
|
1613
|
-
|
|
1842
|
+
return ((await fetchSearchPage(client, buildJql(params), params.limit)).issues ?? []).map((i) => toIssueSummary(site, i));
|
|
1843
|
+
}
|
|
1844
|
+
async function fetchSearchPage(client, jql, maxResults, nextPageToken) {
|
|
1614
1845
|
const query = new URLSearchParams({
|
|
1615
1846
|
jql,
|
|
1616
|
-
maxResults: String(
|
|
1617
|
-
fields:
|
|
1847
|
+
maxResults: String(maxResults),
|
|
1848
|
+
fields: SEARCH_FIELDS
|
|
1618
1849
|
});
|
|
1619
|
-
|
|
1850
|
+
if (nextPageToken) query.set("nextPageToken", nextPageToken);
|
|
1851
|
+
return client.getJson(`/rest/api/3/search/jql?${query.toString()}`);
|
|
1852
|
+
}
|
|
1853
|
+
function toIssueSummary(site, i) {
|
|
1854
|
+
return {
|
|
1620
1855
|
key: i.key,
|
|
1621
1856
|
status: i.fields?.status?.name ?? "",
|
|
1857
|
+
statusCategory: i.fields?.status?.statusCategory?.key ?? "",
|
|
1622
1858
|
summary: decodeEntities(i.fields?.summary ?? ""),
|
|
1859
|
+
updated: i.fields?.updated ?? "",
|
|
1623
1860
|
url: browseUrl(site, i.key)
|
|
1624
|
-
}
|
|
1861
|
+
};
|
|
1625
1862
|
}
|
|
1626
1863
|
function buildJql(params) {
|
|
1627
1864
|
if (params.jql) return params.jql;
|
|
1628
1865
|
const clauses = [];
|
|
1629
|
-
if (params.project) clauses.push(`project = ${
|
|
1630
|
-
if (params.assignee) clauses.push(params.assignee === "me" ? "assignee = currentUser()" : `assignee = ${
|
|
1631
|
-
if (params.status) clauses.push(`status = ${
|
|
1632
|
-
if (params.text) clauses.push(`text ~ ${
|
|
1633
|
-
if (clauses.length === 0) clauses.push(
|
|
1866
|
+
if (params.project) clauses.push(`project = ${jqlStringLiteral(params.project)}`);
|
|
1867
|
+
if (params.assignee) clauses.push(params.assignee === "me" ? "assignee = currentUser()" : `assignee = ${jqlStringLiteral(params.assignee)}`);
|
|
1868
|
+
if (params.status) clauses.push(`status = ${jqlStringLiteral(params.status)}`);
|
|
1869
|
+
if (params.text) clauses.push(`text ~ ${jqlStringLiteral(params.text)}`);
|
|
1870
|
+
if (clauses.length === 0) clauses.push(RECENT_ISSUES_CLAUSE);
|
|
1871
|
+
return `${clauses.join(" AND ")} ORDER BY updated DESC`;
|
|
1872
|
+
}
|
|
1873
|
+
const OPEN_CLAUSE = "statusCategory != Done";
|
|
1874
|
+
function buildListJql(params) {
|
|
1875
|
+
const clauses = ["assignee = currentUser()"];
|
|
1876
|
+
if (params.project) clauses.push(`project = ${jqlStringLiteral(params.project)}`);
|
|
1877
|
+
clauses.push(params.all ? `(${OPEN_CLAUSE} OR ${RECENT_ISSUES_CLAUSE})` : OPEN_CLAUSE);
|
|
1634
1878
|
return `${clauses.join(" AND ")} ORDER BY updated DESC`;
|
|
1635
1879
|
}
|
|
1880
|
+
const LIST_PAGE_SIZE = 100;
|
|
1881
|
+
const LIST_CAP = 500;
|
|
1882
|
+
async function listAssignedIssues(client, site, params) {
|
|
1883
|
+
const jql = buildListJql(params);
|
|
1884
|
+
const issues = [];
|
|
1885
|
+
let nextPageToken;
|
|
1886
|
+
for (;;) {
|
|
1887
|
+
const room = LIST_CAP - issues.length;
|
|
1888
|
+
const res = await fetchSearchPage(client, jql, Math.min(LIST_PAGE_SIZE, room), nextPageToken);
|
|
1889
|
+
const page = res.issues ?? [];
|
|
1890
|
+
issues.push(...page.map((i) => toIssueSummary(site, i)));
|
|
1891
|
+
if (res.isLast || !res.nextPageToken || page.length === 0) return {
|
|
1892
|
+
issues,
|
|
1893
|
+
truncated: false
|
|
1894
|
+
};
|
|
1895
|
+
if (issues.length >= LIST_CAP) return {
|
|
1896
|
+
issues,
|
|
1897
|
+
truncated: true
|
|
1898
|
+
};
|
|
1899
|
+
nextPageToken = res.nextPageToken;
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
const LIST_CATEGORY_ORDER = {
|
|
1903
|
+
indeterminate: 0,
|
|
1904
|
+
new: 1,
|
|
1905
|
+
done: 2
|
|
1906
|
+
};
|
|
1907
|
+
const LIST_UNKNOWN_CATEGORY_RANK = Object.keys(LIST_CATEGORY_ORDER).length;
|
|
1908
|
+
function sortByCategoryThenUpdated(issues) {
|
|
1909
|
+
const rank = (i) => LIST_CATEGORY_ORDER[i.statusCategory] ?? LIST_UNKNOWN_CATEGORY_RANK;
|
|
1910
|
+
const updatedMs = (i) => Date.parse(i.updated) || 0;
|
|
1911
|
+
return [...issues].sort((a, b) => rank(a) - rank(b) || updatedMs(b) - updatedMs(a));
|
|
1912
|
+
}
|
|
1636
1913
|
const PROJECT_PAGE_SIZE = 50;
|
|
1637
1914
|
async function listProjects(client, site, query) {
|
|
1638
1915
|
const projects = [];
|
|
@@ -1686,7 +1963,7 @@ const CATEGORY_ORDER = {
|
|
|
1686
1963
|
indeterminate: 1,
|
|
1687
1964
|
done: 2
|
|
1688
1965
|
};
|
|
1689
|
-
const
|
|
1966
|
+
const UNKNOWN_CATEGORY_RANK = Object.keys(CATEGORY_ORDER).length;
|
|
1690
1967
|
function dedupeAndSortStatuses(statuses) {
|
|
1691
1968
|
const byNameCategory = /* @__PURE__ */ new Map();
|
|
1692
1969
|
for (const s of statuses) {
|
|
@@ -1694,11 +1971,11 @@ function dedupeAndSortStatuses(statuses) {
|
|
|
1694
1971
|
if (!byNameCategory.has(key)) byNameCategory.set(key, s);
|
|
1695
1972
|
}
|
|
1696
1973
|
return [...byNameCategory.values()].sort((a, b) => {
|
|
1697
|
-
const rank = (s) => CATEGORY_ORDER[s.categoryKey] ??
|
|
1974
|
+
const rank = (s) => CATEGORY_ORDER[s.categoryKey] ?? UNKNOWN_CATEGORY_RANK;
|
|
1698
1975
|
return rank(a) - rank(b) || a.name.localeCompare(b.name);
|
|
1699
1976
|
});
|
|
1700
1977
|
}
|
|
1701
|
-
function
|
|
1978
|
+
function jqlStringLiteral(value) {
|
|
1702
1979
|
return `"${value.replace(/(["\\])/g, "\\$1")}"`;
|
|
1703
1980
|
}
|
|
1704
1981
|
async function fetchComments(client, key) {
|
|
@@ -1709,6 +1986,148 @@ async function fetchComments(client, key) {
|
|
|
1709
1986
|
}));
|
|
1710
1987
|
}
|
|
1711
1988
|
//#endregion
|
|
1989
|
+
//#region src/markdown/highlight.ts
|
|
1990
|
+
const HEADING = /^#{1,6} /;
|
|
1991
|
+
const QUOTE = /^(\s*)>( |$)/;
|
|
1992
|
+
const LIST = /^(\s*)(-|\d+\.) (\[[ x]\] )?/;
|
|
1993
|
+
const RULE = /^\s*---$/;
|
|
1994
|
+
const TABLE_ROW = /^\|.*\|$/;
|
|
1995
|
+
const TABLE_SEPARATOR = /^\|( --- \|)+$/;
|
|
1996
|
+
const CELL_DIVIDER = /(?<!\\)\|/;
|
|
1997
|
+
const DETAILS_OPEN = /^(\s*)<details><summary>(.*)<\/summary>$/;
|
|
1998
|
+
const DETAILS_CLOSE = /^\s*<\/details>$/;
|
|
1999
|
+
const FENCE_OPEN = /^((?:\s|> |- |\d+\. )*)```(\S*)$/;
|
|
2000
|
+
const FENCE = "```";
|
|
2001
|
+
const INLINE = /(`[^`]+`)|\[([^\]]+)\]\(((?:[^()]|\([^()]*\))+)\)|\*\*\*(.+?)\*\*\*|\*\*(.+?)\*\*|\*(?!\*)(.+?)(?<!\*)\*(?!\*)|~~(.+?)~~/g;
|
|
2002
|
+
const TOKEN_STYLES = {
|
|
2003
|
+
keyword: kleur.magenta,
|
|
2004
|
+
built_in: kleur.magenta,
|
|
2005
|
+
literal: kleur.magenta,
|
|
2006
|
+
string: kleur.green,
|
|
2007
|
+
regexp: kleur.green,
|
|
2008
|
+
addition: kleur.green,
|
|
2009
|
+
number: kleur.yellow,
|
|
2010
|
+
symbol: kleur.yellow,
|
|
2011
|
+
meta: kleur.yellow,
|
|
2012
|
+
attr: kleur.yellow,
|
|
2013
|
+
comment: kleur.dim,
|
|
2014
|
+
quote: kleur.dim,
|
|
2015
|
+
deletion: kleur.dim,
|
|
2016
|
+
title: kleur.cyan,
|
|
2017
|
+
type: kleur.cyan,
|
|
2018
|
+
section: kleur.cyan
|
|
2019
|
+
};
|
|
2020
|
+
const lowlight = createLowlight(common);
|
|
2021
|
+
function highlightMarkdown(md) {
|
|
2022
|
+
const lines = md.split("\n");
|
|
2023
|
+
const out = [];
|
|
2024
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2025
|
+
const line = lines[i] ?? "";
|
|
2026
|
+
const fence = line.match(FENCE_OPEN);
|
|
2027
|
+
if (!fence) {
|
|
2028
|
+
out.push(highlightLine(line, lines[i + 1]));
|
|
2029
|
+
continue;
|
|
2030
|
+
}
|
|
2031
|
+
const [, prefix = "", lang = ""] = fence;
|
|
2032
|
+
const styledPrefix = prefix.replace(/\S+/g, (marker) => kleur.dim(marker));
|
|
2033
|
+
out.push(`${styledPrefix}${kleur.dim(`${FENCE}${lang}`)}`);
|
|
2034
|
+
const code = [];
|
|
2035
|
+
const prefixed = [];
|
|
2036
|
+
let j = i + 1;
|
|
2037
|
+
for (; j < lines.length; j++) {
|
|
2038
|
+
const raw = lines[j] ?? "";
|
|
2039
|
+
const hasPrefix = raw.startsWith(prefix) || raw === prefix.trimEnd();
|
|
2040
|
+
const body = raw.startsWith(prefix) ? raw.slice(prefix.length) : hasPrefix ? "" : raw;
|
|
2041
|
+
if (body === FENCE) break;
|
|
2042
|
+
code.push(body);
|
|
2043
|
+
prefixed.push(hasPrefix);
|
|
2044
|
+
}
|
|
2045
|
+
if (code.length > 0) highlightCode(code.join("\n"), lang).split("\n").forEach((codeLine, k) => {
|
|
2046
|
+
const lead = prefixed[k] ? styledPrefix : "";
|
|
2047
|
+
out.push(codeLine ? `${lead}${codeLine}` : lead.trimEnd());
|
|
2048
|
+
});
|
|
2049
|
+
if (j < lines.length) {
|
|
2050
|
+
const lead = (lines[j] ?? "").startsWith(prefix) ? styledPrefix : "";
|
|
2051
|
+
out.push(`${lead}${kleur.dim(FENCE)}`);
|
|
2052
|
+
}
|
|
2053
|
+
i = j;
|
|
2054
|
+
}
|
|
2055
|
+
return out.join("\n");
|
|
2056
|
+
}
|
|
2057
|
+
function highlightCode(code, lang) {
|
|
2058
|
+
if (!lang || !lowlight.registered(lang)) return styleEachLine(code, kleur.yellow);
|
|
2059
|
+
return renderTree(lowlight.highlight(lang, code), (text) => text);
|
|
2060
|
+
}
|
|
2061
|
+
function renderTree(node, style) {
|
|
2062
|
+
if (node.type === "text") return styleEachLine(node.value, style);
|
|
2063
|
+
if (node.type !== "root" && node.type !== "element") return "";
|
|
2064
|
+
const own = node.type === "element" ? tokenStyle(node.properties["className"]) : void 0;
|
|
2065
|
+
const next = own ? (text) => style(own(text)) : style;
|
|
2066
|
+
return node.children.map((child) => renderTree(child, next)).join("");
|
|
2067
|
+
}
|
|
2068
|
+
function tokenStyle(className) {
|
|
2069
|
+
if (!Array.isArray(className)) return void 0;
|
|
2070
|
+
for (const name of className) {
|
|
2071
|
+
if (typeof name !== "string" || !name.startsWith("hljs-")) continue;
|
|
2072
|
+
const style = TOKEN_STYLES[name.slice(5)];
|
|
2073
|
+
if (style) return style;
|
|
2074
|
+
}
|
|
2075
|
+
}
|
|
2076
|
+
function styleEachLine(text, style) {
|
|
2077
|
+
return text.split("\n").map((line) => line ? style(line) : line).join("\n");
|
|
2078
|
+
}
|
|
2079
|
+
function highlightLine(line, nextLine) {
|
|
2080
|
+
const quote = line.match(QUOTE);
|
|
2081
|
+
if (quote) {
|
|
2082
|
+
const [whole, indent = ""] = quote;
|
|
2083
|
+
const rest = line.slice(whole.length);
|
|
2084
|
+
const gap = whole.endsWith(" ") ? " " : "";
|
|
2085
|
+
return `${indent}${kleur.dim(">")}${gap}${highlightLine(rest, stripPrefix(nextLine, whole))}`;
|
|
2086
|
+
}
|
|
2087
|
+
const list = line.match(LIST);
|
|
2088
|
+
if (list) {
|
|
2089
|
+
const [whole, indent = "", marker = "", checkbox] = list;
|
|
2090
|
+
const rest = line.slice(whole.length);
|
|
2091
|
+
const box = checkbox ? `${kleur.bold(checkbox.trim())} ` : "";
|
|
2092
|
+
return `${indent}${kleur.dim(marker)} ${box}${highlightLine(rest, void 0)}`;
|
|
2093
|
+
}
|
|
2094
|
+
if (HEADING.test(line)) return kleur.bold().cyan(line);
|
|
2095
|
+
if (RULE.test(line)) return kleur.dim(line);
|
|
2096
|
+
if (TABLE_SEPARATOR.test(line)) return kleur.dim(line);
|
|
2097
|
+
if (TABLE_ROW.test(line)) return tableRow(line, nextLine !== void 0 && TABLE_SEPARATOR.test(nextLine));
|
|
2098
|
+
const details = line.match(DETAILS_OPEN);
|
|
2099
|
+
if (details) {
|
|
2100
|
+
const [, indent = "", title = ""] = details;
|
|
2101
|
+
return `${indent}${kleur.dim("<details><summary>")}${highlightInline(title)}${kleur.dim("</summary>")}`;
|
|
2102
|
+
}
|
|
2103
|
+
if (DETAILS_CLOSE.test(line)) return kleur.dim(line);
|
|
2104
|
+
return highlightInline(line);
|
|
2105
|
+
}
|
|
2106
|
+
function stripPrefix(line, prefix) {
|
|
2107
|
+
return line?.startsWith(prefix) ? line.slice(prefix.length) : void 0;
|
|
2108
|
+
}
|
|
2109
|
+
function tableRow(line, header) {
|
|
2110
|
+
const styled = line.split(CELL_DIVIDER).slice(1, -1).map((cell) => header ? kleur.bold(highlightInline(cell)) : highlightInline(cell));
|
|
2111
|
+
const pipe = kleur.dim("|");
|
|
2112
|
+
return `${pipe}${styled.join(pipe)}${pipe}`;
|
|
2113
|
+
}
|
|
2114
|
+
function highlightInline(text) {
|
|
2115
|
+
return text.replace(INLINE, (match, code, linkText, url, boldItalic, bold, italic, strike) => {
|
|
2116
|
+
if (code !== void 0) return kleur.yellow(code);
|
|
2117
|
+
if (linkText !== void 0 && url !== void 0) return link(linkText, url);
|
|
2118
|
+
if (boldItalic !== void 0) return kleur.bold().italic(match);
|
|
2119
|
+
if (bold !== void 0) return kleur.bold(`**${highlightInline(bold)}**`);
|
|
2120
|
+
if (italic !== void 0) return kleur.italic(`*${highlightInline(italic)}*`);
|
|
2121
|
+
if (strike !== void 0) return kleur.strikethrough(`~~${highlightInline(strike)}~~`);
|
|
2122
|
+
return match;
|
|
2123
|
+
});
|
|
2124
|
+
}
|
|
2125
|
+
function link(text, url) {
|
|
2126
|
+
const open = kleur.dim("[");
|
|
2127
|
+
const middle = kleur.dim("](");
|
|
2128
|
+
return `${open}${highlightInline(text)}${middle}${kleur.dim().underline(url)}${kleur.dim(")")}`;
|
|
2129
|
+
}
|
|
2130
|
+
//#endregion
|
|
1712
2131
|
//#region src/commands/jira.ts
|
|
1713
2132
|
async function jiraProjects(query, options) {
|
|
1714
2133
|
const auth = await requireAuth();
|
|
@@ -1747,45 +2166,95 @@ function formatStatusRows(statuses) {
|
|
|
1747
2166
|
const width = Math.max(...statuses.map((s) => s.name.length));
|
|
1748
2167
|
return statuses.map((s) => `${s.name.padEnd(width)} ${s.category}`);
|
|
1749
2168
|
}
|
|
2169
|
+
async function jiraView(arg, options) {
|
|
2170
|
+
const auth = await requireAuth();
|
|
2171
|
+
const key = await resolveRef(arg, ISSUE_REF);
|
|
2172
|
+
const issue = await fetchIssue(new AtlassianClient(auth), auth.site, key);
|
|
2173
|
+
for (const line of formatIssueView(issue, Date.now(), options.allComments ?? false)) console.log(line);
|
|
2174
|
+
}
|
|
2175
|
+
const CATEGORY_COLORS = {
|
|
2176
|
+
new: kleur.cyan,
|
|
2177
|
+
indeterminate: kleur.yellow,
|
|
2178
|
+
done: kleur.green
|
|
2179
|
+
};
|
|
2180
|
+
function colorForCategory(category) {
|
|
2181
|
+
return CATEGORY_COLORS[category] ?? ((text) => text);
|
|
2182
|
+
}
|
|
2183
|
+
function formatIssueView(issue, nowMs, allComments) {
|
|
2184
|
+
return [
|
|
2185
|
+
`${kleur.bold(issue.key)} ${issue.summary}`,
|
|
2186
|
+
...fieldLines(issue, nowMs),
|
|
2187
|
+
...bodyLines(adfToMarkdown(issue.description)),
|
|
2188
|
+
...commentSection(issue.comments, allComments),
|
|
2189
|
+
...attachmentSection(issue.attachments)
|
|
2190
|
+
];
|
|
2191
|
+
}
|
|
2192
|
+
function fieldLines(issue, nowMs) {
|
|
2193
|
+
const colorStatus = colorForCategory(issue.statusCategory);
|
|
2194
|
+
const kept = [
|
|
2195
|
+
["Type", issue.type],
|
|
2196
|
+
["Status", issue.status && colorStatus(issue.status)],
|
|
2197
|
+
["Assignee", issue.assignee],
|
|
2198
|
+
["Reporter", issue.reporter],
|
|
2199
|
+
["Priority", issue.priority],
|
|
2200
|
+
["Labels", issue.labels.join(", ")],
|
|
2201
|
+
["Created", dateWithAge(issue.created, nowMs)],
|
|
2202
|
+
["Updated", dateWithAge(issue.updated, nowMs)],
|
|
2203
|
+
["URL", issue.url]
|
|
2204
|
+
].filter(([, value]) => value !== "");
|
|
2205
|
+
const width = Math.max(...kept.map(([label]) => label.length)) + 1;
|
|
2206
|
+
return kept.map(([label, value]) => `${kleur.dim(`${label}:`.padEnd(width))} ${value}`);
|
|
2207
|
+
}
|
|
2208
|
+
function dateWithAge(iso, nowMs) {
|
|
2209
|
+
if (!iso) return "";
|
|
2210
|
+
return `${formatDateTime(iso).slice(0, 10)} (${relativeTime(iso, nowMs)})`;
|
|
2211
|
+
}
|
|
2212
|
+
function bodyLines(body) {
|
|
2213
|
+
return body ? ["", highlightMarkdown(body)] : [];
|
|
2214
|
+
}
|
|
2215
|
+
function commentSection(comments, allComments) {
|
|
2216
|
+
if (comments.length === 0) return [];
|
|
2217
|
+
const visible = allComments ? comments : comments.slice(-5);
|
|
2218
|
+
const heading = comments.length - visible.length > 0 ? `Comments (${comments.length}, showing last ${visible.length} — --all-comments for all)` : `Comments (${comments.length})`;
|
|
2219
|
+
const lines = ["", kleur.bold(heading)];
|
|
2220
|
+
for (const comment of visible) {
|
|
2221
|
+
const body = adfToMarkdown(comment.body);
|
|
2222
|
+
lines.push("", commentHeader(comment), ...body ? [highlightMarkdown(body)] : []);
|
|
2223
|
+
}
|
|
2224
|
+
return lines;
|
|
2225
|
+
}
|
|
2226
|
+
function commentHeader(comment) {
|
|
2227
|
+
return `─ ${kleur.bold(comment.author || "Unknown")}${comment.created ? ` · ${kleur.dim(formatDateTime(comment.created))}` : ""}`;
|
|
2228
|
+
}
|
|
2229
|
+
function attachmentSection(attachments) {
|
|
2230
|
+
if (attachments.length === 0) return [];
|
|
2231
|
+
return [
|
|
2232
|
+
"",
|
|
2233
|
+
kleur.bold("Attachments"),
|
|
2234
|
+
...attachments.map((a) => `- ${a.filename}`)
|
|
2235
|
+
];
|
|
2236
|
+
}
|
|
1750
2237
|
async function jiraCopy(arg, options) {
|
|
1751
2238
|
const auth = await requireAuth();
|
|
1752
|
-
const key = await
|
|
2239
|
+
const key = await resolveRef(arg, ISSUE_REF);
|
|
1753
2240
|
await copyIssue(new AtlassianClient(auth), auth.site, key, options.out);
|
|
1754
2241
|
}
|
|
1755
2242
|
async function jiraUpdate(arg, options) {
|
|
1756
|
-
const src =
|
|
2243
|
+
const src = parseIssueSource(await readFile(arg ?? await input({
|
|
1757
2244
|
message: "Path to the issue Markdown file:",
|
|
1758
2245
|
required: true
|
|
1759
2246
|
}), "utf8"));
|
|
1760
2247
|
const auth = await requireAuth();
|
|
1761
2248
|
const client = new AtlassianClient(auth);
|
|
1762
|
-
const
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
}
|
|
1771
|
-
if (local.length > 0) throw new Error(`jira update does not support image changes yet. Remove local image reference(s) or edit text only: ${local.join(", ")}`);
|
|
1772
|
-
if (stale && !options.force) throw new Error(`Issue changed on the server since you copied it (local ${src.updated || "unknown"}, server ${issue.updated}). Re-copy the issue or pass --force.`);
|
|
1773
|
-
if (lossy.size > 0 && !options.force) {
|
|
1774
|
-
if (!await confirm({
|
|
1775
|
-
message: `This issue's description contains ${formatLossy(lossy)} that Markdown cannot represent and will be removed. Continue?`,
|
|
1776
|
-
default: false
|
|
1777
|
-
})) {
|
|
1778
|
-
console.log("Aborted.");
|
|
1779
|
-
return;
|
|
1780
|
-
}
|
|
1781
|
-
}
|
|
1782
|
-
const description = markdownToAdf(src.body);
|
|
1783
|
-
if (!description.content || description.content.length === 0) throw new Error("Refusing to update: the converted description is empty.");
|
|
1784
|
-
await updateIssue(client, src.key, {
|
|
1785
|
-
description,
|
|
1786
|
-
summary: options.summary && newSummary !== issue.summary ? newSummary : void 0
|
|
2249
|
+
const plan = planIssueUpdate(src, await fetchIssue(client, auth.site, src.key), options);
|
|
2250
|
+
await runPlan(plan, options, async () => {
|
|
2251
|
+
const { current, next } = plan.headline;
|
|
2252
|
+
await updateIssue(client, src.key, {
|
|
2253
|
+
description: plan.body,
|
|
2254
|
+
summary: next !== current ? next : void 0
|
|
2255
|
+
});
|
|
2256
|
+
console.log(`Updated ${src.key}.`);
|
|
1787
2257
|
});
|
|
1788
|
-
console.log(`Updated ${src.key}.`);
|
|
1789
2258
|
}
|
|
1790
2259
|
async function jiraSearch(query, options) {
|
|
1791
2260
|
if (options.jql && (query || options.project || options.assignee || options.status)) throw new Error("--jql cannot be combined with a text query or other filters.");
|
|
@@ -1803,8 +2272,8 @@ async function jiraSearch(query, options) {
|
|
|
1803
2272
|
});
|
|
1804
2273
|
await runSearch(issues.map((i) => ({
|
|
1805
2274
|
id: i.key,
|
|
1806
|
-
|
|
1807
|
-
|
|
2275
|
+
fixedColumns: `${i.key} ${i.status}`,
|
|
2276
|
+
freeText: i.summary,
|
|
1808
2277
|
json: {
|
|
1809
2278
|
key: i.key,
|
|
1810
2279
|
status: i.status,
|
|
@@ -1814,80 +2283,59 @@ async function jiraSearch(query, options) {
|
|
|
1814
2283
|
})), {
|
|
1815
2284
|
json: options.json,
|
|
1816
2285
|
copy: options.copy,
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
},
|
|
1821
|
-
singular: "issue",
|
|
1822
|
-
plural: "issues"
|
|
1823
|
-
}, (key) => copyIssue(client, auth.site, key, options.out));
|
|
1824
|
-
}
|
|
1825
|
-
async function copyIssue(client, site, key, out) {
|
|
1826
|
-
console.log(`Fetching ${key} ...`);
|
|
1827
|
-
const issue = await fetchIssue(client, site, key);
|
|
1828
|
-
const target = resolveOutput(issue.key, out);
|
|
1829
|
-
const downloaded = await downloadAttachments(client, issue.attachments, target.assetsDir, target.assetsDirName);
|
|
1830
|
-
const resolveMedia = mediaResolver(downloaded);
|
|
1831
|
-
const document = joinSections([
|
|
1832
|
-
frontmatter({
|
|
1833
|
-
key: issue.key,
|
|
1834
|
-
type: issue.type,
|
|
1835
|
-
status: issue.status,
|
|
1836
|
-
assignee: issue.assignee,
|
|
1837
|
-
reporter: issue.reporter,
|
|
1838
|
-
priority: issue.priority,
|
|
1839
|
-
labels: issue.labels,
|
|
1840
|
-
created: issue.created,
|
|
1841
|
-
updated: issue.updated,
|
|
1842
|
-
url: issue.url
|
|
1843
|
-
}),
|
|
1844
|
-
`# ${issue.summary}`,
|
|
1845
|
-
adfToMarkdown(issue.description, { resolveMedia }),
|
|
1846
|
-
commentsSection(issue.comments, resolveMedia),
|
|
1847
|
-
attachmentsSection(downloaded)
|
|
1848
|
-
]);
|
|
1849
|
-
await writeFile(target.filePath, document, "utf8");
|
|
1850
|
-
report(target.filePath, downloaded.length);
|
|
2286
|
+
out: options.out,
|
|
2287
|
+
empty: "No matching issues.",
|
|
2288
|
+
footer: issues.length === limit ? searchFooter(limit) : void 0
|
|
2289
|
+
}, ISSUE_NOUN, (key) => copyIssue(client, auth.site, key, options.out));
|
|
1851
2290
|
}
|
|
1852
|
-
async function
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
2291
|
+
async function jiraList(options) {
|
|
2292
|
+
if (options.json && options.copy) throw new Error("--json and --copy cannot be used together.");
|
|
2293
|
+
const auth = await requireAuth();
|
|
2294
|
+
const client = new AtlassianClient(auth);
|
|
2295
|
+
const { issues, truncated } = await listAssignedIssues(client, auth.site, {
|
|
2296
|
+
all: options.all,
|
|
2297
|
+
project: options.project
|
|
1856
2298
|
});
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
}
|
|
1865
|
-
function
|
|
1866
|
-
const
|
|
1867
|
-
const
|
|
1868
|
-
const
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
2299
|
+
await runSearch(formatListRows(sortByCategoryThenUpdated(issues), Date.now()), {
|
|
2300
|
+
json: options.json,
|
|
2301
|
+
copy: options.copy,
|
|
2302
|
+
out: options.out,
|
|
2303
|
+
empty: options.all ? "No issues assigned to you." : "No open issues assigned to you.",
|
|
2304
|
+
footer: truncated ? `\nShowing the first ${issues.length}; narrow with --project.` : void 0
|
|
2305
|
+
}, ISSUE_NOUN, (key) => copyIssue(client, auth.site, key, options.out));
|
|
2306
|
+
}
|
|
2307
|
+
function formatListRows(issues, nowMs) {
|
|
2308
|
+
const ages = issues.map((i) => relativeTime(i.updated, nowMs));
|
|
2309
|
+
const width = (values) => Math.max(...values.map((v) => v.length));
|
|
2310
|
+
const keyWidth = width(issues.map((i) => i.key));
|
|
2311
|
+
const statusWidth = width(issues.map((i) => i.status));
|
|
2312
|
+
const ageWidth = width(ages);
|
|
2313
|
+
return issues.map((i, n) => {
|
|
2314
|
+
const status = colorForCategory(i.statusCategory)(i.status.padEnd(statusWidth));
|
|
2315
|
+
return {
|
|
2316
|
+
id: i.key,
|
|
2317
|
+
fixedColumns: `${i.key.padEnd(keyWidth)} ${status} ${(ages[n] ?? "").padEnd(ageWidth)}`,
|
|
2318
|
+
freeText: i.summary,
|
|
2319
|
+
json: { ...i }
|
|
2320
|
+
};
|
|
1876
2321
|
});
|
|
1877
|
-
return {
|
|
1878
|
-
local,
|
|
1879
|
-
external
|
|
1880
|
-
};
|
|
1881
2322
|
}
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
console.log(" nothing was written (dry run)");
|
|
2323
|
+
const ISSUE_NOUN = {
|
|
2324
|
+
singular: "issue",
|
|
2325
|
+
plural: "issues"
|
|
2326
|
+
};
|
|
2327
|
+
async function copyIssue(client, site, key, out) {
|
|
2328
|
+
console.log(`Fetching ${key} ...`);
|
|
2329
|
+
await runCopy(planIssueCopy(await fetchIssue(client, site, key), out), (url) => client.getBinary(url));
|
|
1890
2330
|
}
|
|
2331
|
+
const ISSUE_REF = {
|
|
2332
|
+
message: "Jira issue key or URL:",
|
|
2333
|
+
parse: parseIssueKey,
|
|
2334
|
+
notFound: (raw) => `Could not find an issue key in "${raw}" (expected e.g. PROJ-123).`
|
|
2335
|
+
};
|
|
2336
|
+
//#endregion
|
|
2337
|
+
//#region package.json
|
|
2338
|
+
var version = "1.8.0";
|
|
1891
2339
|
//#endregion
|
|
1892
2340
|
//#region src/cli.ts
|
|
1893
2341
|
const program = new Command();
|
|
@@ -1899,8 +2347,10 @@ auth.command("status").description("Show the current login").action(run(status))
|
|
|
1899
2347
|
const jira = program.command("jira").description("Jira commands");
|
|
1900
2348
|
jira.command("projects [query]").description("List projects (optionally filtered by key or name)").option("--json", "output results as JSON").action(run(jiraProjects));
|
|
1901
2349
|
jira.command("statuses [query]").description("List statuses (optionally filtered by name, scoped with --project)").option("-p, --project <key>", "limit to statuses used by a project").option("--json", "output results as JSON").action(run(jiraStatuses));
|
|
2350
|
+
jira.command("view [issue]").description("Show a Jira issue (key or URL) in the terminal").option("--all-comments", "show all comments instead of the last 5").action(run(jiraView));
|
|
1902
2351
|
jira.command("copy [issue]").description("Copy a Jira issue (key or URL) to a Markdown file").option("-o, --out <path>", "output file or directory").action(run(jiraCopy));
|
|
1903
2352
|
jira.command("update [file]").description("Update a Jira issue description from an edited Markdown file").option("--summary", "also push the H1 as the issue summary").option("-f, --force", "skip the stale-issue and data-loss checks").option("--dry-run", "show what would change without writing").action(run(jiraUpdate));
|
|
2353
|
+
jira.command("list").description("List open issues assigned to you").option("-p, --project <key>", "limit to a project").option("-a, --all", "include Done issues updated in the last 30 days").option("--json", "output results as JSON").option("-c, --copy", "pick results to copy to Markdown").option("-o, --out <dir>", "output directory for --copy").action(run(jiraList));
|
|
1904
2354
|
jira.command("search [query]").description("Search Jira issues (text query, filters, or --jql)").option("-p, --project <key>", "limit to a project").option("-a, --assignee <who>", "limit to an assignee (or 'me')").option("-s, --status <status>", "limit to a status").option("--jql <jql>", "raw JQL query (ignores other filters)").option("-l, --limit <n>", "max results (default 25, max 100)").option("--json", "output results as JSON").option("-c, --copy", "pick results to copy to Markdown").option("-o, --out <dir>", "output directory for --copy").action(run(jiraSearch));
|
|
1905
2355
|
const confluence = program.command("confluence").description("Confluence commands");
|
|
1906
2356
|
confluence.command("copy [page]").description("Copy a Confluence page (id or URL) to a Markdown file").option("-o, --out <path>", "output file or directory").action(run(confluenceCopy));
|
|
@@ -1912,6 +2362,7 @@ bitbucket.command("logout").description("Remove stored Bitbucket credentials").a
|
|
|
1912
2362
|
bitbucket.command("status").description("Show the current Bitbucket login").action(run(bitbucketStatus));
|
|
1913
2363
|
bitbucket.command("pipelines").description("List recent pipeline runs for a repo").option("-r, --repo <repo>", "workspace/slug, or a bare slug (defaults to config)").option("-l, --limit <n>", "max results (default 25, max 100)").option("--json", "output results as JSON").action(run(bitbucketPipelines));
|
|
1914
2364
|
bitbucket.command("pipeline <number>").description("Show one pipeline run and its steps").option("-r, --repo <repo>", "workspace/slug, or a bare slug (defaults to config)").action(run(bitbucketPipeline));
|
|
2365
|
+
const SIGINT_EXIT_CODE = 130;
|
|
1915
2366
|
program.parseAsync().catch(fail);
|
|
1916
2367
|
function run(fn) {
|
|
1917
2368
|
return async (...args) => {
|
|
@@ -1923,9 +2374,12 @@ function run(fn) {
|
|
|
1923
2374
|
};
|
|
1924
2375
|
}
|
|
1925
2376
|
function fail(err) {
|
|
1926
|
-
if (err
|
|
2377
|
+
if (isPromptInterrupt(err)) process.exit(SIGINT_EXIT_CODE);
|
|
1927
2378
|
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
|
1928
2379
|
process.exit(1);
|
|
1929
2380
|
}
|
|
2381
|
+
function isPromptInterrupt(err) {
|
|
2382
|
+
return err instanceof Error && err.name === "ExitPromptError";
|
|
2383
|
+
}
|
|
1930
2384
|
//#endregion
|
|
1931
2385
|
export {};
|