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