atlass 1.5.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.
Files changed (3) hide show
  1. package/README.md +69 -254
  2. package/dist/cli.mjs +1461 -771
  3. 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
- //#region package.json
11
- var version = "1.5.0";
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 put(path, body) {
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
- "X-Atlassian-Token": "nocheck"
68
+ ...DISABLE_XSRF_CHECK
69
69
  },
70
70
  body: form
71
71
  })).json();
72
72
  }
73
- async getBinary(url) {
74
- const path = url.startsWith("http") ? new URL(url).pathname + new URL(url).search : url;
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,20 +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): ${extractError(body) || "the page changed on the server"}`);
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): ${extractError(body) || path}`);
93
- return new HttpError(status, `Request failed (${status}): ${extractError(body) || path}`);
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 extractError(body) {
99
+ function extractErrorMessage(body) {
96
100
  if (!body) return "";
97
- try {
98
- const json = JSON.parse(body);
99
- if (json.errorMessages?.length) return json.errorMessages.join("; ");
100
- if (json.message) return json.message;
101
- } 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;
102
105
  return body.slice(0, 300);
103
106
  }
107
+ function tryParseJson(text) {
108
+ try {
109
+ return JSON.parse(text);
110
+ } catch {
111
+ return null;
112
+ }
113
+ }
104
114
  //#endregion
105
115
  //#region src/config.ts
106
116
  function configDir() {
@@ -113,10 +123,10 @@ async function readConfig() {
113
123
  try {
114
124
  const raw = await readFile(configPath(), "utf8");
115
125
  const parsed = JSON.parse(raw);
116
- if (!parsed.site || !parsed.email) return null;
117
126
  return {
118
127
  site: parsed.site,
119
- email: parsed.email
128
+ email: parsed.email,
129
+ bitbucket: parsed.bitbucket
120
130
  };
121
131
  } catch {
122
132
  return null;
@@ -129,7 +139,7 @@ async function writeConfig(config) {
129
139
  async function clearConfig() {
130
140
  await rm(configPath(), { force: true });
131
141
  }
132
- function normalizeSite(input) {
142
+ function siteOrigin(input) {
133
143
  let value = input.trim();
134
144
  if (!/^https?:\/\//i.test(value)) value = `https://${value}`;
135
145
  return new URL(value).origin;
@@ -137,8 +147,16 @@ function normalizeSite(input) {
137
147
  //#endregion
138
148
  //#region src/credentials.ts
139
149
  const SERVICE = "atlass";
140
- function entry(email) {
141
- return new Entry(SERVICE, email);
150
+ const BITBUCKET_ORIGIN = "https://api.bitbucket.org";
151
+ function entry(key) {
152
+ return new Entry(SERVICE, key);
153
+ }
154
+ function deleteEntryIfPresent(key) {
155
+ try {
156
+ entry(key).deleteCredential();
157
+ } catch {
158
+ return;
159
+ }
142
160
  }
143
161
  function saveToken(email, token) {
144
162
  entry(email).setPassword(token);
@@ -147,24 +165,48 @@ function readToken(email) {
147
165
  return entry(email).getPassword();
148
166
  }
149
167
  function deleteToken(email) {
150
- try {
151
- entry(email).deleteCredential();
152
- } catch {}
168
+ deleteEntryIfPresent(email);
169
+ }
170
+ function bitbucketKey(email) {
171
+ return `${email}:bitbucket`;
172
+ }
173
+ function saveBitbucketToken(email, token) {
174
+ entry(bitbucketKey(email)).setPassword(token);
175
+ }
176
+ function readBitbucketToken(email) {
177
+ return entry(bitbucketKey(email)).getPassword();
178
+ }
179
+ function deleteBitbucketToken(email) {
180
+ deleteEntryIfPresent(bitbucketKey(email));
153
181
  }
154
182
  async function requireAuth() {
155
183
  const config = await readConfig();
156
- if (!config) throw new Error("Not logged in. Run `atlass auth login` first.");
184
+ if (!config || !config.site || !config.email) throw new Error("Not logged in. Run `atlass auth login` first.");
157
185
  const token = readToken(config.email);
158
186
  if (!token) throw new Error("No API token found in keyring. Run `atlass auth login` again.");
159
187
  return {
160
- ...config,
188
+ site: config.site,
189
+ email: config.email,
161
190
  token
162
191
  };
163
192
  }
193
+ async function requireBitbucketAuth() {
194
+ const config = await readConfig();
195
+ if (!config || !config.email || !config.bitbucket?.workspace) throw new Error("Not logged in to Bitbucket. Run `atlass bitbucket login` first.");
196
+ const token = readBitbucketToken(config.email);
197
+ if (!token) throw new Error("No Bitbucket API token found in keyring. Run `atlass bitbucket login` again.");
198
+ return {
199
+ site: BITBUCKET_ORIGIN,
200
+ email: config.email,
201
+ token,
202
+ workspace: config.bitbucket.workspace,
203
+ defaultRepo: config.bitbucket.defaultRepo
204
+ };
205
+ }
164
206
  //#endregion
165
207
  //#region src/commands/auth.ts
166
208
  async function login() {
167
- const site = normalizeSite(await input({
209
+ const site = siteOrigin(await input({
168
210
  message: "Atlassian site (e.g. acme.atlassian.net):",
169
211
  required: true
170
212
  }));
@@ -182,6 +224,7 @@ async function login() {
182
224
  token
183
225
  }).getJson("/rest/api/3/myself");
184
226
  await writeConfig({
227
+ ...await readConfig() ?? {},
185
228
  site,
186
229
  email
187
230
  });
@@ -190,13 +233,18 @@ async function login() {
190
233
  }
191
234
  async function logout() {
192
235
  const config = await readConfig();
193
- if (config) deleteToken(config.email);
194
- await clearConfig();
236
+ if (config?.email) deleteToken(config.email);
237
+ const bitbucketLogin = config?.bitbucket && config.email ? {
238
+ email: config.email,
239
+ bitbucket: config.bitbucket
240
+ } : null;
241
+ if (bitbucketLogin) await writeConfig(bitbucketLogin);
242
+ else await clearConfig();
195
243
  console.log("Logged out. Credentials removed.");
196
244
  }
197
245
  async function status() {
198
246
  const config = await readConfig();
199
- if (!config) {
247
+ if (!config || !config.site || !config.email) {
200
248
  console.log("Not logged in. Run `atlass auth login`.");
201
249
  return;
202
250
  }
@@ -206,9 +254,354 @@ async function status() {
206
254
  console.log(`Token: ${hasToken ? "stored in keyring" : "MISSING (run `atlass auth login`)"}`);
207
255
  }
208
256
  //#endregion
257
+ //#region src/api/bitbucket.ts
258
+ const BITBUCKET_MAX_PAGELEN = 100;
259
+ const BUILD_NUMBER_SCAN_LIMIT = 1e3;
260
+ function pipelineStatus(state) {
261
+ if (!state) return "";
262
+ if (state.name === "COMPLETED") return state.result?.name ?? "COMPLETED";
263
+ return state.name ?? "";
264
+ }
265
+ function pipelinesQuery(limit) {
266
+ return new URLSearchParams({
267
+ sort: "-created_on",
268
+ pagelen: String(Math.min(Math.max(limit, 1), BITBUCKET_MAX_PAGELEN))
269
+ }).toString();
270
+ }
271
+ function wallClockSeconds(startOn, endOn) {
272
+ if (!startOn || !endOn) return null;
273
+ const start = Date.parse(startOn);
274
+ const end = Date.parse(endOn);
275
+ if (Number.isNaN(start) || Number.isNaN(end)) return null;
276
+ return Math.floor((end - start) / 1e3);
277
+ }
278
+ function shortHash(hash) {
279
+ return hash?.slice(0, 7) ?? "";
280
+ }
281
+ function toPipelineSummary(p) {
282
+ return {
283
+ buildNumber: p.build_number,
284
+ status: pipelineStatus(p.state),
285
+ ref: p.target?.ref_name ?? "",
286
+ commit: shortHash(p.target?.commit?.hash),
287
+ durationSeconds: wallClockSeconds(p.created_on, p.completed_on),
288
+ createdOn: p.created_on ?? "",
289
+ creator: p.creator?.display_name ?? "",
290
+ uuid: p.uuid
291
+ };
292
+ }
293
+ function toPipelineDetail(p) {
294
+ return {
295
+ ...toPipelineSummary(p),
296
+ repo: p.repository?.full_name ?? "",
297
+ trigger: p.trigger?.name ?? ""
298
+ };
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
+ }
318
+ async function listPipelines(client, ref, limit) {
319
+ const out = [];
320
+ const first = `${repoPath(ref)}?${pipelinesQuery(limit)}`;
321
+ for await (const value of walkPages(client, first)) {
322
+ out.push(toPipelineSummary(value));
323
+ if (out.length >= limit) break;
324
+ }
325
+ return out;
326
+ }
327
+ async function getPipeline(client, ref, buildNumber) {
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) {
333
+ try {
334
+ return await client.getJson(`${repoPath(ref)}/${encodeURIComponent(String(buildNumber))}`);
335
+ } catch (err) {
336
+ if (err instanceof HttpError && (err.status === 400 || err.status === 404)) return null;
337
+ throw err;
338
+ }
339
+ }
340
+ async function findInRecentRuns(client, ref, buildNumber) {
341
+ const first = `${repoPath(ref)}?${pipelinesQuery(BITBUCKET_MAX_PAGELEN)}`;
342
+ let scanned = 0;
343
+ for await (const pipeline of walkPages(client, first)) {
344
+ if (pipeline.build_number === buildNumber) return pipeline;
345
+ if (++scanned >= BUILD_NUMBER_SCAN_LIMIT) break;
346
+ }
347
+ return null;
348
+ }
349
+ async function listSteps(client, ref, pipelineId) {
350
+ const out = [];
351
+ const first = `${repoPath(ref)}/${encodeURIComponent(pipelineId)}/steps`;
352
+ for await (const step of walkPages(client, first)) out.push(toStepSummary(step));
353
+ return out;
354
+ }
355
+ //#endregion
356
+ //#region src/util/format.ts
357
+ function formatDuration(seconds) {
358
+ if (seconds == null) return "-";
359
+ if (seconds < 60) return `${seconds}s`;
360
+ const m = Math.floor(seconds / 60);
361
+ const s = seconds % 60;
362
+ if (m < 60) return `${m}m${String(s).padStart(2, "0")}s`;
363
+ return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, "0")}m`;
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
+ }
370
+ const MINUTE = 60;
371
+ const HOUR = 60 * MINUTE;
372
+ const DAY = 24 * HOUR;
373
+ function relativeTime(iso, nowMs) {
374
+ if (!iso) return "-";
375
+ const then = Date.parse(iso);
376
+ if (Number.isNaN(then)) return "-";
377
+ const secs = Math.floor((nowMs - then) / 1e3);
378
+ if (secs < MINUTE) return "just now";
379
+ if (secs < HOUR) return `${Math.floor(secs / MINUTE)}m ago`;
380
+ if (secs < DAY) return `${Math.floor(secs / HOUR)}h ago`;
381
+ const days = Math.floor(secs / DAY);
382
+ if (days < 45) return `${days}d ago`;
383
+ if (days < 365) return `${Math.floor(days / 30)}mo ago`;
384
+ return `${Math.floor(days / 365)}y ago`;
385
+ }
386
+ //#endregion
387
+ //#region src/util/parse.ts
388
+ function parseIssueKey(input) {
389
+ const match = input.toUpperCase().match(/[A-Z][A-Z0-9]+-\d+/);
390
+ return match ? match[0] : null;
391
+ }
392
+ function parsePageId(input) {
393
+ const trimmed = input.trim();
394
+ if (/^\d+$/.test(trimmed)) return trimmed;
395
+ const fromPath = trimmed.match(/\/pages\/(\d+)/);
396
+ if (fromPath) return fromPath[1];
397
+ const fromQuery = trimmed.match(/[?&]pageId=(\d+)/);
398
+ if (fromQuery) return fromQuery[1];
399
+ return null;
400
+ }
401
+ function isExternalHref(href) {
402
+ return /^[a-z][a-z0-9+.-]*:\/\//i.test(href);
403
+ }
404
+ function resolveRepo(flag, config) {
405
+ if (flag) {
406
+ if (flag.includes("/")) {
407
+ const parts = flag.split("/");
408
+ const [workspace, repo] = parts;
409
+ if (parts.length !== 2 || !workspace || !repo) throw new Error(`Invalid --repo "${flag}". Expected "workspace/slug" or "slug".`);
410
+ return {
411
+ workspace,
412
+ repo
413
+ };
414
+ }
415
+ if (!config.workspace) throw new Error(`No Bitbucket workspace configured. Pass --repo workspace/slug or run \`atlass bitbucket login\`.`);
416
+ return {
417
+ workspace: config.workspace,
418
+ repo: flag
419
+ };
420
+ }
421
+ if (config.workspace && config.defaultRepo) return {
422
+ workspace: config.workspace,
423
+ repo: config.defaultRepo
424
+ };
425
+ throw new Error(`No repo given. Pass --repo workspace/slug (or a bare slug), or set a default repo at \`atlass bitbucket login\`.`);
426
+ }
427
+ const DEFAULT_LIMIT = 25;
428
+ const MAX_LIMIT = 100;
429
+ function parseLimit(value) {
430
+ if (!value) return DEFAULT_LIMIT;
431
+ const n = Number.parseInt(value, 10);
432
+ if (!Number.isFinite(n) || n < 1) throw new Error(`Invalid --limit "${value}".`);
433
+ return Math.min(n, MAX_LIMIT);
434
+ }
435
+ //#endregion
436
+ //#region src/commands/bitbucket.ts
437
+ async function bitbucketLogin() {
438
+ const existing = await readConfig() ?? {};
439
+ const email = existing.email ?? await input({
440
+ message: "Account email:",
441
+ required: true
442
+ });
443
+ const workspace = (await input({
444
+ message: "Bitbucket workspace (e.g. acme):",
445
+ required: true
446
+ })).trim();
447
+ const defaultRepo = (await input({ message: "Default repo slug (optional):" })).trim() || void 0;
448
+ const token = await password({
449
+ message: "Bitbucket API token (needs read:pipeline + workspace read scopes):",
450
+ mask: true
451
+ });
452
+ const ws = await verifyWorkspace(new AtlassianClient({
453
+ site: BITBUCKET_ORIGIN,
454
+ email,
455
+ token
456
+ }), workspace);
457
+ await writeConfig({
458
+ ...existing,
459
+ email,
460
+ bitbucket: {
461
+ workspace,
462
+ ...defaultRepo ? { defaultRepo } : {}
463
+ }
464
+ });
465
+ saveBitbucketToken(email, token);
466
+ console.log(`Logged in to Bitbucket workspace ${ws.name ?? workspace} as ${email}.`);
467
+ }
468
+ async function bitbucketLogout() {
469
+ const config = await readConfig();
470
+ if (config?.email) deleteBitbucketToken(config.email);
471
+ const jiraLogin = config?.site && config.email ? {
472
+ site: config.site,
473
+ email: config.email
474
+ } : null;
475
+ if (jiraLogin) await writeConfig(jiraLogin);
476
+ else await clearConfig();
477
+ console.log("Logged out of Bitbucket. Credentials removed.");
478
+ }
479
+ async function bitbucketStatus() {
480
+ const config = await readConfig();
481
+ if (!config?.email || !config.bitbucket?.workspace) {
482
+ console.log("Not logged in to Bitbucket. Run `atlass bitbucket login`.");
483
+ return;
484
+ }
485
+ const hasToken = readBitbucketToken(config.email) !== null;
486
+ console.log(`Workspace: ${config.bitbucket.workspace}`);
487
+ console.log(`Email: ${config.email}`);
488
+ console.log(`Default repo: ${config.bitbucket.defaultRepo ?? "(none)"}`);
489
+ console.log(`Token: ${hasToken ? "stored in keyring" : "MISSING (run `atlass bitbucket login`)"}`);
490
+ }
491
+ async function bitbucketPipelines(options) {
492
+ const auth = await requireBitbucketAuth();
493
+ const ref = resolveRepo(options.repo, auth);
494
+ const client = new AtlassianClient(auth);
495
+ const limit = parseLimit(options.limit);
496
+ const pipelines = await withScopeHint(() => listPipelines(client, ref, limit));
497
+ if (options.json) {
498
+ console.log(JSON.stringify(pipelines, null, 2));
499
+ return;
500
+ }
501
+ if (pipelines.length === 0) {
502
+ console.log("No pipelines found.");
503
+ return;
504
+ }
505
+ for (const line of formatPipelineRows(pipelines, Date.now())) console.log(line);
506
+ }
507
+ async function bitbucketPipeline(arg, options) {
508
+ const buildNumber = parseBuildNumber(arg);
509
+ const auth = await requireBitbucketAuth();
510
+ const ref = resolveRepo(options.repo, auth);
511
+ const client = new AtlassianClient(auth);
512
+ const detail = await withScopeHint(() => getPipeline(client, ref, buildNumber));
513
+ printPipelineDetail(detail, await withScopeHint(() => listSteps(client, ref, detail.uuid)), Date.now());
514
+ }
515
+ function formatPipelineRows(pipelines, nowMs) {
516
+ const rows = pipelines.map((p) => ({
517
+ num: `#${p.buildNumber}`,
518
+ status: p.status || "-",
519
+ ref: p.ref || p.commit || "-",
520
+ dur: formatDuration(p.durationSeconds),
521
+ age: relativeTime(p.createdOn, nowMs),
522
+ creator: p.creator || "-"
523
+ }));
524
+ const width = (sel) => Math.max(...rows.map((r) => sel(r).length));
525
+ const wn = width((r) => r.num);
526
+ const ws = width((r) => r.status);
527
+ const wr = width((r) => r.ref);
528
+ const wd = width((r) => r.dur);
529
+ const wa = width((r) => r.age);
530
+ return rows.map((r) => `${r.num.padEnd(wn)} ${r.status.padEnd(ws)} ${r.ref.padEnd(wr)} ${r.dur.padEnd(wd)} ${r.age.padEnd(wa)} ${r.creator}`);
531
+ }
532
+ function formatStepRows(steps) {
533
+ const rows = steps.map((s) => ({
534
+ name: s.name || "-",
535
+ status: s.status || "-",
536
+ dur: formatDuration(s.durationSeconds)
537
+ }));
538
+ const wn = Math.max(...rows.map((r) => r.name.length));
539
+ const ws = Math.max(...rows.map((r) => r.status.length));
540
+ return rows.map((r) => ` ${r.name.padEnd(wn)} ${r.status.padEnd(ws)} ${r.dur}`);
541
+ }
542
+ function printPipelineDetail(detail, steps, nowMs) {
543
+ console.log(`Pipeline #${detail.buildNumber} ${detail.status || "-"}`);
544
+ if (detail.repo) console.log(`Repo: ${detail.repo}`);
545
+ console.log(`Ref: ${refWithCommit(detail)}`);
546
+ if (detail.trigger) console.log(`Trigger: ${detail.trigger}`);
547
+ console.log(`Duration: ${formatDuration(detail.durationSeconds)}`);
548
+ const by = detail.creator ? ` by ${detail.creator}` : "";
549
+ console.log(`Created: ${relativeTime(detail.createdOn, nowMs)}${by}`);
550
+ if (steps.length > 0) {
551
+ console.log("");
552
+ console.log("Steps:");
553
+ for (const line of formatStepRows(steps)) console.log(line);
554
+ }
555
+ }
556
+ function refWithCommit(detail) {
557
+ if (!detail.ref) return detail.commit || "-";
558
+ return detail.commit ? `${detail.ref} (${detail.commit})` : detail.ref;
559
+ }
560
+ async function verifyWorkspace(client, workspace) {
561
+ try {
562
+ return await client.getJson(`/2.0/workspaces/${encodeURIComponent(workspace)}`);
563
+ } catch (err) {
564
+ if (err instanceof HttpError && (err.status === 401 || err.status === 403)) throw new Error(`Could not verify Bitbucket workspace "${workspace}" (401/403). Check the token and that it has workspace read + read:pipeline:bitbucket scopes.`);
565
+ if (err instanceof HttpError && err.status === 404) throw new Error(`Bitbucket workspace "${workspace}" not found (404). Check the slug.`);
566
+ throw err;
567
+ }
568
+ }
569
+ function parseBuildNumber(arg) {
570
+ const raw = (arg ?? "").replace(/^#/, "").trim();
571
+ if (!/^\d+$/.test(raw)) throw new Error(`Invalid pipeline number "${arg ?? ""}". Expected a build number, e.g. 123.`);
572
+ return Number.parseInt(raw, 10);
573
+ }
574
+ async function withScopeHint(fn) {
575
+ try {
576
+ return await fn();
577
+ } catch (err) {
578
+ if (err instanceof HttpError && (err.status === 401 || err.status === 403)) throw new Error("Bitbucket rejected the request (401/403). Check the token has the read:pipeline:bitbucket scope, or run `atlass bitbucket login` to update it.");
579
+ throw err;
580
+ }
581
+ }
582
+ //#endregion
209
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
+ }
210
603
  function markdownToAdf(md, options = {}) {
211
- const ctx = { resolveImage: options.resolveImage ?? defaultResolveImage };
604
+ const ctx = { resolveImage: options.resolveImage ?? externalMedia };
212
605
  return {
213
606
  type: "doc",
214
607
  version: 1,
@@ -228,8 +621,8 @@ function block(token, ctx) {
228
621
  attrs: { level: clampLevel$1(token.depth) },
229
622
  content: inline(token.tokens ?? [], ctx)
230
623
  }];
231
- case "paragraph": return paragraph(token.tokens ?? [], ctx);
232
- case "text": return paragraph(token.tokens ?? [textToken(token.text)], ctx);
624
+ case "paragraph": return paragraphsSplitAtImages(token.tokens ?? [], ctx);
625
+ case "text": return paragraphsSplitAtImages(token.tokens ?? [textToken(token.text)], ctx);
233
626
  case "list": return [list(token, ctx)];
234
627
  case "code": return [codeBlock(token)];
235
628
  case "blockquote": return [{
@@ -238,11 +631,10 @@ function block(token, ctx) {
238
631
  }];
239
632
  case "table": return [table(token, ctx)];
240
633
  case "hr": return [{ type: "rule" }];
241
- case "html": return [];
242
634
  default: return [];
243
635
  }
244
636
  }
245
- function paragraph(tokens, ctx) {
637
+ function paragraphsSplitAtImages(tokens, ctx) {
246
638
  const out = [];
247
639
  let buffer = [];
248
640
  const flush = () => {
@@ -273,7 +665,7 @@ function list(token, ctx) {
273
665
  localId: randomUUID(),
274
666
  state: item.checked ? "DONE" : "TODO"
275
667
  },
276
- content: inline(itemInline(item), ctx)
668
+ content: inline(taskItemInline(item), ctx)
277
669
  }))
278
670
  };
279
671
  const node = {
@@ -286,16 +678,18 @@ function list(token, ctx) {
286
678
  }
287
679
  function listItem(item, ctx) {
288
680
  const children = blocks(item.tokens.filter((t) => t.type !== "checkbox"), ctx);
289
- if (children.length === 0) children.push({
290
- type: "paragraph",
291
- content: []
292
- });
293
681
  return {
294
682
  type: "listItem",
295
- content: children
683
+ content: children.length > 0 ? children : [emptyParagraph()]
684
+ };
685
+ }
686
+ function emptyParagraph() {
687
+ return {
688
+ type: "paragraph",
689
+ content: []
296
690
  };
297
691
  }
298
- function itemInline(item) {
692
+ function taskItemInline(item) {
299
693
  const first = item.tokens.find((t) => t.type === "text");
300
694
  if (first && "tokens" in first && first.tokens) return first.tokens;
301
695
  return [textToken(item.text)];
@@ -344,13 +738,13 @@ function inlineNode(token, ctx, marks) {
344
738
  if ("tokens" in t && t.tokens?.length) return inline(t.tokens, ctx, marks);
345
739
  return textNode(t.text, marks);
346
740
  }
347
- case "strong": return inline(token.tokens, ctx, withMark(marks, { type: "strong" }));
348
- case "em": return inline(token.tokens, ctx, withMark(marks, { type: "em" }));
349
- case "del": return inline(token.tokens, ctx, withMark(marks, { type: "strike" }));
350
- case "codespan": return textNode(token.text, withMark(marks, { type: "code" }));
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" }));
351
745
  case "link": {
352
746
  const link = token;
353
- return inline(link.tokens, ctx, withMark(marks, {
747
+ return inline(link.tokens, ctx, withUniqueMark(marks, {
354
748
  type: "link",
355
749
  attrs: { href: link.href }
356
750
  }));
@@ -369,25 +763,14 @@ function textNode(text, marks) {
369
763
  if (marks.length > 0) node.marks = marks;
370
764
  return [node];
371
765
  }
372
- function withMark(marks, mark) {
766
+ function withUniqueMark(marks, mark) {
373
767
  return [...marks.filter((m) => m.type !== mark.type), mark];
374
768
  }
375
- function defaultResolveImage(href, alt) {
376
- return {
377
- type: "mediaSingle",
378
- attrs: { layout: "center" },
379
- content: [{
380
- type: "media",
381
- attrs: alt ? {
382
- type: "external",
383
- url: href,
384
- alt
385
- } : {
386
- type: "external",
387
- url: href
388
- }
389
- }]
390
- };
769
+ function externalMedia(href, alt) {
770
+ return mediaNode({
771
+ type: "external",
772
+ url: href
773
+ }, alt);
391
774
  }
392
775
  function textToken(text) {
393
776
  return {
@@ -401,72 +784,313 @@ function clampLevel$1(value) {
401
784
  return Math.min(6, Math.max(1, Math.trunc(value) || 1));
402
785
  }
403
786
  //#endregion
404
- //#region src/adf/to-markdown.ts
405
- function adfToMarkdown(doc, options = {}) {
406
- if (!doc) return "";
407
- const ctx = { resolveMedia: options.resolveMedia };
408
- return (doc.type === "doc" ? renderBlocks(doc.content ?? [], ctx, "") : renderBlock(doc, ctx, "")).trim();
409
- }
410
- function renderBlocks(nodes, ctx, indent) {
411
- return nodes.map((n) => renderBlock(n, ctx, indent)).filter((s) => s.length > 0).join("\n\n");
412
- }
413
- function renderBlock(node, ctx, indent) {
414
- switch (node.type) {
415
- case "paragraph": return indent + renderInline(node.content ?? [], ctx);
416
- case "heading": {
417
- const level = clampLevel(node.attrs?.["level"]);
418
- return `${"#".repeat(level)} ${renderInline(node.content ?? [], ctx)}`;
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;
419
802
  }
420
- case "bulletList": return renderList(node, ctx, indent, "bullet");
421
- case "orderedList": return renderList(node, ctx, indent, "ordered");
422
- case "taskList": return renderTaskList(node, ctx, indent);
423
- case "decisionList": return renderDecisionList(node, ctx, indent);
424
- case "codeBlock": return renderCodeBlock(node, indent);
425
- case "blockquote": return prefixLines(renderBlocks(node.content ?? [], ctx, ""), `${indent}> `);
426
- case "panel": return renderPanel(node, ctx, indent);
427
- case "rule": return `${indent}---`;
428
- case "table": return renderTable(node, ctx);
429
- case "mediaSingle":
430
- case "mediaGroup": return renderBlocks(node.content ?? [], ctx, indent);
431
- case "media": return indent + renderMedia(node, ctx);
432
- case "expand":
433
- case "nestedExpand": return renderExpand(node, ctx, indent);
434
- default:
435
- if (node.content?.length) return renderBlocks(node.content, ctx, indent);
436
- return node.text ? indent + node.text : "";
437
- }
438
- }
439
- function renderList(node, ctx, indent, kind) {
440
- const start = kind === "ordered" ? toNumber(node.attrs?.["order"]) ?? 1 : 0;
441
- return (node.content ?? []).filter((n) => n.type === "listItem").map((item, i) => {
442
- return renderListItem(item, ctx, indent, kind === "ordered" ? `${start + i}. ` : "- ");
443
- }).join("\n");
444
- }
445
- function renderListItem(item, ctx, indent, marker) {
446
- const childIndent = `${indent}${" ".repeat(marker.length)}`;
447
- const rendered = (item.content ?? []).map((b, i) => renderBlock(b, ctx, i === 0 ? "" : childIndent));
448
- const parts = [];
449
- rendered.forEach((text, i) => {
450
- if (i === 0) parts.push(`${indent}${marker}${text}`);
451
- else parts.push(text);
803
+ return NAMED[body.toLowerCase()] ?? match;
452
804
  });
453
- return parts.join("\n");
454
- }
455
- function renderTaskList(node, ctx, indent) {
456
- return (node.content ?? []).filter((n) => n.type === "taskItem").map((item) => {
457
- return `${indent}- [${item.attrs?.["state"] === "DONE" ? "x" : " "}] ${renderInline(item.content ?? [], ctx)}`;
458
- }).join("\n");
459
805
  }
460
- function renderDecisionList(node, ctx, indent) {
461
- return (node.content ?? []).filter((n) => n.type === "decisionItem").map((item) => `${indent}- (decision) ${renderInline(item.content ?? [], ctx)}`).join("\n");
806
+ function isCodePoint(code) {
807
+ return Number.isFinite(code) && code >= 0 && code <= MAX_CODE_POINT;
462
808
  }
463
- function renderCodeBlock(node, indent) {
464
- const lang = typeof node.attrs?.["language"] === "string" ? node.attrs["language"] : "";
465
- const code = (node.content ?? []).map((n) => n.text ?? "").join("");
466
- const fence = "```";
467
- return prefixLines(`${fence}${lang}\n${code}\n${fence}`, indent);
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
+ };
468
834
  }
469
- const PANEL_LABELS = {
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
+ };
842
+ }
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
+ }));
850
+ }
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);
853
+ }
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.`);
858
+ }
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;
873
+ }
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
891
+ };
892
+ }
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`;
899
+ }
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 = {
470
1094
  info: "Info",
471
1095
  note: "Note",
472
1096
  warning: "Warning",
@@ -482,7 +1106,7 @@ function renderExpand(node, ctx, indent) {
482
1106
  function renderTable(node, ctx) {
483
1107
  const rows = (node.content ?? []).filter((n) => n.type === "tableRow");
484
1108
  if (rows.length === 0) return "";
485
- const grid = rows.map((row) => (row.content ?? []).map((cell) => renderCell(cell, ctx)));
1109
+ const grid = rows.map((row) => (row.content ?? []).map((cell) => renderCellOnOneLine(cell, ctx)));
486
1110
  const cols = Math.max(...grid.map((r) => r.length));
487
1111
  const pad = (r) => {
488
1112
  const copy = [...r];
@@ -494,7 +1118,7 @@ function renderTable(node, ctx) {
494
1118
  for (const row of grid.slice(1)) lines.push(`| ${pad(row).join(" | ")} |`);
495
1119
  return lines.join("\n");
496
1120
  }
497
- function renderCell(cell, ctx) {
1121
+ function renderCellOnOneLine(cell, ctx) {
498
1122
  return renderBlocks(cell.content ?? [], ctx, "").replace(/\n+/g, " ").replace(/\|/g, "\\|").trim();
499
1123
  }
500
1124
  function renderMedia(node, ctx) {
@@ -518,7 +1142,7 @@ function renderInlineNode(node, ctx) {
518
1142
  const short = node.attrs?.["shortName"];
519
1143
  return typeof short === "string" ? short : "";
520
1144
  }
521
- case "date": return formatDate$1(node.attrs?.["timestamp"]);
1145
+ case "date": return formatDate(node.attrs?.["timestamp"]);
522
1146
  case "status": return `\`[${typeof node.attrs?.["text"] === "string" ? node.attrs["text"] : ""}]\``;
523
1147
  case "inlineCard": {
524
1148
  const url = node.attrs?.["url"];
@@ -566,226 +1190,82 @@ function clampLevel(value) {
566
1190
  function toNumber(value) {
567
1191
  return typeof value === "number" ? value : void 0;
568
1192
  }
569
- function formatDate$1(timestamp) {
1193
+ function formatDate(timestamp) {
570
1194
  const ms = typeof timestamp === "string" ? Number(timestamp) : typeof timestamp === "number" ? timestamp : NaN;
571
1195
  if (Number.isNaN(ms)) return "";
572
1196
  return new Date(ms).toISOString().slice(0, 10);
573
1197
  }
574
1198
  //#endregion
575
1199
  //#region src/api/attachments.ts
576
- async function downloadAttachments(client, attachments, assetsDir, assetsDirName) {
577
- if (attachments.length === 0) return [];
578
- await mkdir(assetsDir, { recursive: true });
579
- const used = /* @__PURE__ */ new Set();
580
- const results = [];
581
- for (const att of attachments) {
582
- const savedAs = uniqueName(safeName(att.filename), used);
583
- try {
584
- const bytes = await client.getBinary(att.url);
585
- await writeFile(join(assetsDir, savedAs), bytes);
586
- results.push({
587
- ...att,
588
- relativePath: `${assetsDirName}/${savedAs}`
589
- });
590
- } catch (err) {
591
- console.warn(` ! could not download ${att.filename}: ${err.message}`);
592
- }
593
- }
594
- return results;
595
- }
596
- function safeName(name) {
597
- return basename(name).replace(/[/\\]/g, "_") || "attachment";
598
- }
599
- function uniqueName(name, used) {
600
- if (!used.has(name)) {
601
- used.add(name);
602
- return name;
603
- }
604
- const dot = name.lastIndexOf(".");
605
- const stem = dot > 0 ? name.slice(0, dot) : name;
606
- const ext = dot > 0 ? name.slice(dot) : "";
607
- let i = 1;
608
- let candidate = `${stem}-${i}${ext}`;
609
- while (used.has(candidate)) {
610
- i += 1;
611
- candidate = `${stem}-${i}${ext}`;
612
- }
613
- used.add(candidate);
614
- return candidate;
615
- }
616
- //#endregion
617
- //#region src/util/html.ts
618
- const NAMED = {
619
- amp: "&",
620
- lt: "<",
621
- gt: ">",
622
- quot: "\"",
623
- apos: "'",
624
- nbsp: " "
625
- };
626
- function decodeEntities(text) {
627
- return text.replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (match, body) => {
628
- if (body[0] === "#") {
629
- const code = body[1] === "x" || body[1] === "X" ? Number.parseInt(body.slice(2), 16) : Number.parseInt(body.slice(1), 10);
630
- return Number.isFinite(code) && code >= 0 && code <= 1114111 ? String.fromCodePoint(code) : match;
631
- }
632
- return NAMED[body.toLowerCase()] ?? match;
633
- });
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
+ };
634
1207
  }
635
1208
  //#endregion
636
- //#region src/api/confluence.ts
637
- async function fetchPage(client, site, id) {
638
- const page = await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}?body-format=atlas_doc_format`);
639
- const names = new UserNames(client);
640
- const [spaceKey, attachments, comments, author] = await Promise.all([
641
- fetchSpaceKey(client, page.spaceId),
642
- fetchAttachments(client, id),
643
- fetchComments$1(client, id, names),
644
- names.resolve(page.version?.authorId ?? page.authorId)
645
- ]);
646
- const webui = page._links?.webui ?? "";
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"] : "";
647
1236
  return {
648
- id: page.id,
649
- title: page.title,
650
- spaceKey,
651
- version: page.version?.number ?? 0,
652
- author,
653
- createdAt: page.createdAt ?? "",
654
- updatedAt: page.version?.createdAt ?? "",
655
- url: webui ? `${site}/wiki${webui}` : "",
656
- body: parseAdf(page.body?.atlas_doc_format?.value),
657
- attachments,
658
- comments
1237
+ fields,
1238
+ title: h1?.title ?? fallbackTitle,
1239
+ body: lines.slice(start, end).join("\n").trim()
659
1240
  };
660
1241
  }
661
- async function fetchPageState(client, id) {
662
- const page = await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}?body-format=atlas_doc_format`);
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.");
663
1246
  return {
664
- version: page.version?.number ?? 0,
665
- title: page.title,
666
- body: parseAdf(page.body?.atlas_doc_format?.value)
1247
+ ...source,
1248
+ key,
1249
+ updatedAtCopy: scalarString(source.fields["updated"])
667
1250
  };
668
1251
  }
669
- async function listAttachments(client, id) {
670
- return (await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}/attachments?limit=250`)).results.map((a) => ({
671
- filename: a.title ?? a.id,
672
- fileId: a.fileId ?? a.id,
673
- size: typeof a.fileSize === "number" ? a.fileSize : -1
674
- }));
675
- }
676
- async function uploadAttachment(client, pageId, filename, bytes) {
677
- const fileId = (await client.postMultipart(`/wiki/rest/api/content/${encodeURIComponent(pageId)}/child/attachment`, filename, bytes)).results?.[0]?.extensions?.fileId;
678
- if (fileId) return fileId;
679
- const match = (await listAttachments(client, pageId)).find((a) => a.filename === filename);
680
- if (match) return match.fileId;
681
- throw new Error(`Upload of "${filename}" did not return a fileId.`);
682
- }
683
- async function updatePage(client, id, params) {
684
- return (await client.putJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}`, {
685
- id,
686
- status: "current",
687
- title: params.title,
688
- body: {
689
- representation: "atlas_doc_format",
690
- value: JSON.stringify(params.body)
691
- },
692
- version: {
693
- number: params.version,
694
- message: params.message
695
- }
696
- })).version?.number ?? params.version;
697
- }
698
- async function searchPages(client, site, params) {
699
- const cql = buildCql(params);
700
- const query = new URLSearchParams({
701
- cql,
702
- limit: String(params.limit),
703
- expand: "space"
704
- });
705
- 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.");
706
1259
  return {
707
- pages: results.filter((r) => r.content?.id).map((r) => ({
708
- id: r.content?.id ?? "",
709
- space: r.space?.key ?? r.resultGlobalContainer?.title ?? "",
710
- title: decodeEntities(r.content?.title ?? r.title ?? ""),
711
- url: r.url ? `${site}/wiki${r.url}` : ""
712
- })),
713
- hasMore: results.length === params.limit
1260
+ ...source,
1261
+ id,
1262
+ version
714
1263
  };
715
1264
  }
716
- function buildCql(params) {
717
- if (params.cql) return params.cql;
718
- const clauses = ["type = page"];
719
- if (params.space) clauses.push(`space = ${cqlValue(params.space)}`);
720
- if (params.text) clauses.push(`text ~ ${cqlValue(params.text)}`);
721
- return `${clauses.join(" AND ")} ORDER BY lastmodified DESC`;
722
- }
723
- function cqlValue(value) {
724
- return `"${value.replace(/(["\\])/g, "\\$1")}"`;
725
- }
726
- async function fetchSpaceKey(client, spaceId) {
727
- if (!spaceId) return "";
728
- try {
729
- return (await client.getJson(`/wiki/api/v2/spaces/${encodeURIComponent(spaceId)}`)).key ?? "";
730
- } catch {
731
- return "";
732
- }
733
- }
734
- async function fetchAttachments(client, id) {
735
- return (await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}/attachments?limit=250`)).results.filter((a) => a.downloadLink).map((a) => ({
736
- id: a.fileId ?? a.id,
737
- filename: a.title ?? a.id,
738
- url: normalizeDownloadLink(a.downloadLink ?? "")
739
- }));
740
- }
741
- async function fetchComments$1(client, id, names) {
742
- const res = await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}/footer-comments?body-format=atlas_doc_format&limit=250`);
743
- return Promise.all(res.results.map(async (c) => ({
744
- author: await names.resolve(c.version?.authorId),
745
- created: c.version?.createdAt ?? "",
746
- body: parseAdf(c.body?.atlas_doc_format?.value)
747
- })));
748
- }
749
- function normalizeDownloadLink(link) {
750
- if (link.startsWith("http") || link.startsWith("/wiki")) return link;
751
- return `/wiki${link}`;
752
- }
753
- function parseAdf(value) {
754
- if (!value) return null;
755
- try {
756
- return JSON.parse(value);
757
- } catch {
758
- return null;
759
- }
760
- }
761
- var UserNames = class {
762
- client;
763
- cache = /* @__PURE__ */ new Map();
764
- constructor(client) {
765
- this.client = client;
766
- }
767
- async resolve(accountId) {
768
- if (!accountId) return "";
769
- const cached = this.cache.get(accountId);
770
- if (cached !== void 0) return cached;
771
- let name = accountId;
772
- try {
773
- const user = await this.client.getJson(`/wiki/rest/api/user?accountId=${encodeURIComponent(accountId)}`);
774
- if (user.displayName) name = user.displayName;
775
- } catch {}
776
- this.cache.set(accountId, name);
777
- return name;
778
- }
779
- };
780
- //#endregion
781
- //#region src/markdown/document.ts
782
- function mediaResolver(downloaded) {
783
- const byId = new Map(downloaded.map((d) => [d.id, d.relativePath]));
784
- const byName = new Map(downloaded.map((d) => [d.filename, d.relativePath]));
785
- return (media) => {
786
- if (media.id && byId.has(media.id)) return byId.get(media.id);
787
- if (media.alt && byName.has(media.alt)) return byName.get(media.alt);
788
- };
1265
+ function scalarString(value) {
1266
+ if (typeof value === "string") return value;
1267
+ if (typeof value === "number") return String(value);
1268
+ return "";
789
1269
  }
790
1270
  function frontmatter(fields) {
791
1271
  const lines = ["---"];
@@ -796,111 +1276,195 @@ function frontmatter(fields) {
796
1276
  lines.push("---");
797
1277
  return lines.join("\n");
798
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
+ }
799
1301
  function quote(value) {
800
1302
  return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
801
1303
  }
802
- function attachmentsSection(downloaded) {
803
- if (downloaded.length === 0) return "";
804
- return [
805
- "## Attachments",
806
- "",
807
- ...downloaded.map((d) => `- [${d.filename}](${d.relativePath})`)
808
- ].join("\n");
1304
+ function unquote(value) {
1305
+ if (!value.startsWith("\"") || !value.endsWith("\"")) return value;
1306
+ return value.slice(1, -1).replace(/\\"/g, "\"").replace(/\\\\/g, "\\");
809
1307
  }
810
- function commentsSection(comments, resolveMedia) {
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;
1317
+ }
1318
+ function commentsSection(comments) {
811
1319
  if (comments.length === 0) return "";
812
1320
  return [
813
- "## Comments",
1321
+ COMMENTS_HEADING,
814
1322
  "",
815
1323
  comments.map((c) => {
816
- const heading = `### ${c.author || "Unknown"}${c.created ? ` - ${formatDate(c.created)}` : ""}`;
817
- const body = adfToMarkdown(c.body, { resolveMedia });
818
- 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;
819
1326
  }).join("\n\n")
820
1327
  ].join("\n");
821
1328
  }
822
- function formatDate(iso) {
823
- const date = new Date(iso);
824
- if (Number.isNaN(date.getTime())) return iso;
825
- return date.toISOString().replace("T", " ").slice(0, 16);
826
- }
827
- function joinSections(sections) {
828
- return `${sections.filter((s) => s.trim().length > 0).join("\n\n")}\n`;
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");
829
1336
  }
830
1337
  //#endregion
831
- //#region src/markdown/update-source.ts
832
- function parseUpdateSource(content) {
833
- const { fields, bodyTitle, body } = splitFile(content);
834
- const id = fields["id"];
835
- if (!id) throw new Error("Frontmatter is missing the page `id`; re-copy the page.");
836
- const version = Number(fields["version"]);
837
- if (!Number.isFinite(version)) throw new Error("Frontmatter is missing a numeric `version`; re-copy the page.");
838
- return {
839
- id,
840
- version,
841
- frontTitle: fields["title"] ?? "",
842
- bodyTitle,
843
- body
844
- };
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
+ });
845
1392
  }
846
- function parseJiraUpdateSource(content) {
847
- const { fields, bodyTitle, body } = splitFile(content);
848
- const key = fields["key"];
849
- 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);
850
1396
  return {
851
- key,
852
- updated: fields["updated"] ?? "",
853
- bodyTitle,
854
- body
1397
+ ...content,
1398
+ filePath: target.filePath,
1399
+ assetsDir: target.assetsDir,
1400
+ downloads: planDownloads(attachments, target)
855
1401
  };
856
1402
  }
857
- function splitFile(content) {
858
- const match = content.match(/^---\n([\s\S]*?)\n---\n?/);
859
- if (!match) throw new Error("Not an atlass file: no YAML frontmatter found.");
860
- const fields = parseFrontmatter(match[1] ?? "");
861
- const { bodyTitle, body } = splitBody(content.slice(match[0].length), fields["title"] ?? "");
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`;
862
1410
  return {
863
- fields,
864
- bodyTitle,
865
- body
1411
+ filePath,
1412
+ assetsDir: join(dir, assetsDirName),
1413
+ assetsDirName
866
1414
  };
867
1415
  }
868
- function parseFrontmatter(block) {
869
- const out = {};
870
- for (const line of block.split("\n")) {
871
- const m = line.match(/^([A-Za-z0-9_]+):\s*(.*)$/);
872
- if (!m) continue;
873
- const key = m[1] ?? "";
874
- let value = (m[2] ?? "").trim();
875
- if (value.startsWith("\"") && value.endsWith("\"")) value = value.slice(1, -1).replace(/\\"/g, "\"").replace(/\\\\/g, "\\");
876
- out[key] = value;
877
- }
878
- return out;
1416
+ function slugify(title) {
1417
+ return title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60).replace(/-+$/g, "") || "page";
879
1418
  }
880
- function splitBody(rest, fallbackTitle) {
881
- const lines = rest.split("\n");
882
- let bodyTitle = fallbackTitle;
883
- let start = 0;
884
- for (let i = 0; i < lines.length; i++) {
885
- const line = lines[i] ?? "";
886
- if (line.startsWith("# ")) {
887
- bodyTitle = line.slice(2).trim();
888
- start = i + 1;
889
- break;
890
- }
891
- if (line.trim().length > 0) break;
892
- }
893
- let end = lines.length;
894
- for (let i = start; i < lines.length; i++) if (/^## (Comments|Attachments)\s*$/.test(lines[i] ?? "")) {
895
- end = i;
896
- break;
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) : ""}`;
1442
+ }
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}`);
897
1460
  }
898
- const body = lines.slice(start, end).join("\n").trim();
899
- return {
900
- bodyTitle,
901
- body
902
- };
1461
+ return landed;
1462
+ }
1463
+ function wroteLine(filePath, attachmentCount) {
1464
+ return `Wrote ${filePath}${attachmentCount > 0 ? ` (+${attachmentCount} attachment${attachmentCount === 1 ? "" : "s"})` : ""}`;
903
1465
  }
1466
+ //#endregion
1467
+ //#region src/adf/lossy.ts
904
1468
  const LOSSY_LABELS = {
905
1469
  panel: "panel",
906
1470
  expand: "expand",
@@ -916,122 +1480,220 @@ const JIRA_LOSSY_LABELS = {
916
1480
  media: "image",
917
1481
  mediaInline: "image"
918
1482
  };
919
- function findLossyNodes(node, labels = LOSSY_LABELS) {
1483
+ function findLossyNodes(node, lossyLabels = LOSSY_LABELS) {
920
1484
  const counts = /* @__PURE__ */ new Map();
921
1485
  const visit = (n) => {
922
- const label = labels[n.type];
1486
+ const label = lossyLabels[n.type];
923
1487
  if (label) counts.set(label, (counts.get(label) ?? 0) + 1);
924
1488
  for (const child of n.content ?? []) visit(child);
925
1489
  };
926
- if (node) visit(node);
927
- return counts;
1490
+ if (node) visit(node);
1491
+ return counts;
1492
+ }
1493
+ function formatLossy(counts) {
1494
+ return [...counts.entries()].map(([label, n]) => `${n} ${label}${n === 1 ? "" : "s"}`).join(", ");
1495
+ }
1496
+ //#endregion
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
+ };
1569
+ return {
1570
+ href,
1571
+ kind: existing ? "changed" : "upload",
1572
+ path: local.path,
1573
+ filename: local.filename
1574
+ };
928
1575
  }
929
- function formatLossy(counts) {
930
- return [...counts.entries()].map(([label, n]) => `${n} ${label}${n === 1 ? "" : "s"}`).join(", ");
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
+ };
931
1582
  }
932
- //#endregion
933
- //#region src/util/output-path.ts
934
- function resolveOutput(defaultBase, out) {
935
- let filePath;
936
- if (!out) filePath = resolve(`${defaultBase}.md`);
937
- else if (out.endsWith(".md")) filePath = isAbsolute(out) ? out : resolve(out);
938
- else filePath = resolve(out, `${defaultBase}.md`);
939
- const dir = dirname(filePath);
940
- const assetsDirName = `${filePath.slice(dir.length + 1).replace(/\.md$/, "")}.assets`;
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;
941
1593
  return {
942
- filePath,
943
- assetsDir: join(dir, assetsDirName),
944
- assetsDirName
1594
+ ...swapped,
1595
+ content: swapped.content.map((n) => withUploadedId(n, ids))
945
1596
  };
946
1597
  }
947
- function slugify(title) {
948
- return title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60).replace(/-+$/g, "") || "page";
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
+ };
949
1632
  }
950
- //#endregion
951
- //#region src/util/parse.ts
952
- function parseIssueKey(input) {
953
- const match = input.toUpperCase().match(/[A-Z][A-Z0-9]+-\d+/);
954
- return match ? match[0] : null;
1633
+ function revision(local, server) {
1634
+ return {
1635
+ local: local || "unknown",
1636
+ server: server || "unknown",
1637
+ stale: local !== server
1638
+ };
955
1639
  }
956
- function parsePageId(input) {
957
- const trimmed = input.trim();
958
- if (/^\d+$/.test(trimmed)) return trimmed;
959
- const fromPath = trimmed.match(/\/pages\/(\d+)/);
960
- if (fromPath) return fromPath[1];
961
- const fromQuery = trimmed.match(/[?&]pageId=(\d+)/);
962
- if (fromQuery) return fromQuery[1];
963
- return null;
1640
+ function revisionText(revision) {
1641
+ return `copied at ${revision.local}, server now ${revision.server}`;
964
1642
  }
965
- function parseLimit(value) {
966
- if (!value) return 25;
967
- const n = Number.parseInt(value, 10);
968
- if (!Number.isFinite(n) || n < 1) throw new Error(`Invalid --limit "${value}".`);
969
- return Math.min(n, 100);
1643
+ function emptyBodyRefusal(body) {
1644
+ if (body.content && body.content.length > 0) return [];
1645
+ return ["Refusing to update: the converted body is empty."];
970
1646
  }
971
- //#endregion
972
- //#region src/commands/search-run.ts
973
- const COPY_CONCURRENCY = 5;
974
- async function runSearch(rows, options, noun, copyOne) {
975
- if (options.json) {
976
- console.log(JSON.stringify(rows.map((r) => r.json), null, 2));
977
- return;
978
- }
979
- if (rows.length === 0) {
980
- console.log(`No matching ${noun.plural}.`);
981
- return;
982
- }
983
- if (options.copy) {
984
- if (options.out?.endsWith(".md")) throw new Error("--out must be a directory when using --copy; a .md file path would overwrite each selection.");
985
- await copySelected(rows, noun, copyOne);
986
- return;
987
- }
988
- for (const row of rows) console.log(formatRow(row));
989
- if (options.hasMore) console.log(`\nShowing first ${options.limit}; refine with flags or raise --limit.`);
1647
+ function withVerdict(plan, force) {
1648
+ return {
1649
+ ...plan,
1650
+ verdict: verdict(plan, force)
1651
+ };
990
1652
  }
991
- async function copySelected(rows, noun, copyOne) {
992
- if (!process.stdin.isTTY) throw new Error("--copy requires an interactive terminal.");
993
- const selected = await checkbox({
994
- message: `Select ${noun.plural} to copy:`,
995
- choices: rows.map((r) => ({
996
- name: formatRow(r),
997
- value: r.id
998
- })),
999
- pageSize: 20
1000
- });
1001
- if (selected.length === 0) {
1002
- console.log("Nothing selected.");
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);
1003
1677
  return;
1004
1678
  }
1005
- let copied = 0;
1006
- const failures = [];
1007
- const queue = [...selected];
1008
- async function worker() {
1009
- for (let id = queue.shift(); id !== void 0; id = queue.shift()) try {
1010
- await copyOne(id);
1011
- copied++;
1012
- } catch (err) {
1013
- failures.push(`${id} (${err instanceof Error ? err.message : String(err)})`);
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;
1014
1688
  }
1015
1689
  }
1016
- await Promise.all(Array.from({ length: Math.min(COPY_CONCURRENCY, selected.length) }, worker));
1017
- const summary = `Copied ${copied} ${copied === 1 ? noun.singular : noun.plural}`;
1018
- if (failures.length === 0) console.log(summary);
1019
- else console.log(`${summary}, failed ${failures.length}: ${failures.join(", ")}`);
1020
- }
1021
- function formatRow(row) {
1022
- const room = (process.stdout.columns ?? 80) - row.prefix.length - 2;
1023
- const text = room > 0 ? truncate(row.text, room) : "";
1024
- return text ? `${row.prefix} ${text}` : row.prefix;
1025
- }
1026
- function truncate(text, max) {
1027
- const clean = text.replace(/\s+/g, " ").trim();
1028
- return clean.length <= max ? clean : `${clean.slice(0, Math.max(0, max - 3))}...`;
1690
+ await push();
1029
1691
  }
1030
1692
  //#endregion
1031
1693
  //#region src/commands/confluence.ts
1032
1694
  async function confluenceCopy(arg, options) {
1033
1695
  const auth = await requireAuth();
1034
- const id = await resolveId(arg);
1696
+ const id = await resolveRef(arg, PAGE_REF);
1035
1697
  await copyPage(new AtlassianClient(auth), auth.site, id, options.out);
1036
1698
  }
1037
1699
  async function confluenceUpdate(arg, options) {
@@ -1039,50 +1701,44 @@ async function confluenceUpdate(arg, options) {
1039
1701
  message: "Path to the page Markdown file:",
1040
1702
  required: true
1041
1703
  });
1042
- const src = parseUpdateSource(await readFile(file, "utf8"));
1704
+ const src = parsePageSource(await readFile(file, "utf8"));
1043
1705
  const client = new AtlassianClient(await requireAuth());
1044
1706
  const state = await fetchPageState(client, src.id);
1045
- if (state.version !== src.version && !options.force) throw new Error(`Page changed on the server since you copied it (local v${src.version}, server v${state.version}). Re-copy the page or pass --force.`);
1046
- const dir = dirname(resolve(file));
1047
- const attachments = await listAttachments(client, src.id);
1048
- const plan = await planImages(dir, collectImageHrefs(src.body), attachments);
1049
- const lossy = findLossyNodes(state.body);
1050
- const nextVersion = state.version + 1;
1051
- const newTitle = options.title && src.bodyTitle ? src.bodyTitle : state.title;
1052
- if (options.dryRun) {
1053
- printDryRun$1(src.id, state.title, newTitle, state.version, nextVersion, lossy, plan);
1054
- return;
1055
- }
1056
- if (lossy.size > 0 && !options.force) {
1057
- if (!await confirm({
1058
- message: `This page contains ${formatLossy(lossy)} that Markdown cannot represent and will be removed. Continue?`,
1059
- default: false
1060
- })) {
1061
- console.log("Aborted.");
1062
- 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));
1063
1714
  }
1064
- }
1065
- const collection = `contentId-${src.id}`;
1066
- const fileIds = /* @__PURE__ */ new Map();
1067
- for (const [href, entry] of plan) if (entry.kind === "upload") {
1068
- console.log(`Uploading ${entry.filename} ...`);
1069
- fileIds.set(href, await uploadAttachment(client, src.id, entry.filename, await readFile(entry.path)));
1070
- } else if (entry.kind === "reuse") fileIds.set(href, entry.fileId);
1071
- const body = markdownToAdf(src.body, { resolveImage: (href, alt) => {
1072
- const entry = plan.get(href);
1073
- if (!entry) return void 0;
1074
- if (entry.kind === "external") return externalMedia(href, alt);
1075
- const fileId = fileIds.get(href);
1076
- return fileId ? fileMedia(fileId, collection, alt) : void 0;
1077
- } });
1078
- if (!body.content || body.content.length === 0) throw new Error("Refusing to update: the converted body is empty.");
1079
- const version = await updatePage(client, src.id, {
1080
- title: newTitle,
1081
- version: nextVersion,
1082
- body,
1083
- 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}.`);
1084
1722
  });
1085
- console.log(`Updated page ${src.id} to version ${version}.`);
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
+ }
1086
1742
  }
1087
1743
  async function confluenceSearch(query, options) {
1088
1744
  if (options.cql && (query || options.space)) throw new Error("--cql cannot be combined with a text query or --space.");
@@ -1098,8 +1754,8 @@ async function confluenceSearch(query, options) {
1098
1754
  });
1099
1755
  await runSearch(pages.map((p) => ({
1100
1756
  id: p.id,
1101
- prefix: `${p.id} ${p.space}`,
1102
- text: p.title,
1757
+ fixedColumns: `${p.id} ${p.space}`,
1758
+ freeText: p.title,
1103
1759
  json: {
1104
1760
  id: p.id,
1105
1761
  space: p.space,
@@ -1119,127 +1775,13 @@ async function confluenceSearch(query, options) {
1119
1775
  }
1120
1776
  async function copyPage(client, site, id, out) {
1121
1777
  console.log(`Fetching page ${id} ...`);
1122
- const page = await fetchPage(client, site, id);
1123
- const target = resolveOutput(`${page.id}-${slugify(page.title)}`, out);
1124
- const downloaded = await downloadAttachments(client, page.attachments, target.assetsDir, target.assetsDirName);
1125
- const resolveMedia = mediaResolver(downloaded);
1126
- const document = joinSections([
1127
- frontmatter({
1128
- title: page.title,
1129
- id: page.id,
1130
- space: page.spaceKey,
1131
- version: page.version,
1132
- author: page.author,
1133
- created: page.createdAt,
1134
- updated: page.updatedAt,
1135
- url: page.url
1136
- }),
1137
- `# ${page.title}`,
1138
- adfToMarkdown(page.body, { resolveMedia }),
1139
- commentsSection(page.comments, resolveMedia),
1140
- attachmentsSection(downloaded)
1141
- ]);
1142
- await writeFile(target.filePath, document, "utf8");
1143
- const suffix = downloaded.length > 0 ? ` (+${downloaded.length} attachment${downloaded.length === 1 ? "" : "s"})` : "";
1144
- console.log(`Wrote ${target.filePath}${suffix}`);
1145
- }
1146
- async function resolveId(arg) {
1147
- const raw = arg ?? await input({
1148
- message: "Confluence page id or URL:",
1149
- required: true
1150
- });
1151
- const id = parsePageId(raw);
1152
- if (!id) throw new Error(`Could not find a page id in "${raw}".`);
1153
- return id;
1154
- }
1155
- function collectImageHrefs(md) {
1156
- const hrefs = /* @__PURE__ */ new Set();
1157
- marked.walkTokens(marked.lexer(md), (token) => {
1158
- if (token.type === "image") hrefs.add(token.href);
1159
- });
1160
- return [...hrefs];
1161
- }
1162
- async function planImages(dir, hrefs, attachments) {
1163
- const byName = new Map(attachments.map((a) => [a.filename, a]));
1164
- const plan = /* @__PURE__ */ new Map();
1165
- const missing = [];
1166
- for (const href of hrefs) {
1167
- if (isExternal(href)) {
1168
- plan.set(href, { kind: "external" });
1169
- continue;
1170
- }
1171
- const path = isAbsolute(href) ? href : resolve(dir, href);
1172
- let size;
1173
- try {
1174
- size = (await stat(path)).size;
1175
- } catch {
1176
- missing.push(href);
1177
- continue;
1178
- }
1179
- const filename = basename(path);
1180
- const existing = byName.get(filename);
1181
- if (existing && existing.size === size) plan.set(href, {
1182
- kind: "reuse",
1183
- fileId: existing.fileId,
1184
- filename
1185
- });
1186
- else plan.set(href, {
1187
- kind: "upload",
1188
- path,
1189
- filename,
1190
- existed: existing !== void 0
1191
- });
1192
- }
1193
- if (missing.length > 0) throw new Error(`Image file(s) not found: ${missing.join(", ")}`);
1194
- return plan;
1195
- }
1196
- function isExternal(href) {
1197
- return /^[a-z][a-z0-9+.-]*:\/\//i.test(href);
1198
- }
1199
- function externalMedia(href, alt) {
1200
- const attrs = {
1201
- type: "external",
1202
- url: href
1203
- };
1204
- if (alt) attrs["alt"] = alt;
1205
- return {
1206
- type: "mediaSingle",
1207
- attrs: { layout: "center" },
1208
- content: [{
1209
- type: "media",
1210
- attrs
1211
- }]
1212
- };
1213
- }
1214
- function fileMedia(fileId, collection, alt) {
1215
- const attrs = {
1216
- type: "file",
1217
- id: fileId,
1218
- collection
1219
- };
1220
- if (alt) attrs["alt"] = alt;
1221
- return {
1222
- type: "mediaSingle",
1223
- attrs: { layout: "center" },
1224
- content: [{
1225
- type: "media",
1226
- attrs
1227
- }]
1228
- };
1229
- }
1230
- function printDryRun$1(id, currentTitle, newTitle, currentVersion, nextVersion, lossy, plan) {
1231
- const entries = [...plan.values()];
1232
- const added = entries.filter((e) => e.kind === "upload" && !e.existed).length;
1233
- const changed = entries.filter((e) => e.kind === "upload" && e.existed).length;
1234
- const reused = entries.filter((e) => e.kind === "reuse").length;
1235
- const external = entries.filter((e) => e.kind === "external").length;
1236
- console.log(`Dry run for page ${id} "${currentTitle}"`);
1237
- console.log(` version: ${currentVersion} -> ${nextVersion}`);
1238
- if (newTitle !== currentTitle) console.log(` title: "${currentTitle}" -> "${newTitle}"`);
1239
- console.log(` images: ${added} new, ${changed} changed, ${reused} reused, ${external} external`);
1240
- if (lossy.size > 0) console.log(` warning: ${formatLossy(lossy)} will be removed`);
1241
- console.log(" nothing was written (dry run)");
1778
+ await runCopy(planPageCopy(await fetchPage(client, site, id), out), (url) => client.getBinary(url));
1242
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
+ };
1243
1785
  //#endregion
1244
1786
  //#region src/api/jira.ts
1245
1787
  const FIELDS = [
@@ -1255,6 +1797,7 @@ const FIELDS = [
1255
1797
  "updated",
1256
1798
  "attachment"
1257
1799
  ].join(",");
1800
+ const RECENT_ISSUES_CLAUSE = "updated >= -30d";
1258
1801
  function browseUrl(site, key) {
1259
1802
  return `${site}/browse/${key}`;
1260
1803
  }
@@ -1268,6 +1811,7 @@ async function fetchIssue(client, site, key) {
1268
1811
  summary: f.summary ?? "",
1269
1812
  type: f.issuetype?.name ?? "",
1270
1813
  status: f.status?.name ?? "",
1814
+ statusCategory: f.status?.statusCategory?.key ?? "",
1271
1815
  assignee: f.assignee?.displayName ?? "Unassigned",
1272
1816
  reporter: f.reporter?.displayName ?? "",
1273
1817
  priority: f.priority?.name ?? "",
@@ -1277,7 +1821,7 @@ async function fetchIssue(client, site, key) {
1277
1821
  description: f.description ?? null,
1278
1822
  comments,
1279
1823
  attachments: (f.attachment ?? []).map((a) => ({
1280
- id: a.id,
1824
+ mediaId: a.id,
1281
1825
  filename: a.filename,
1282
1826
  url: a.content
1283
1827
  }))
@@ -1286,7 +1830,7 @@ async function fetchIssue(client, site, key) {
1286
1830
  async function updateIssue(client, key, update) {
1287
1831
  const fields = { description: update.description };
1288
1832
  if (update.summary !== void 0) fields["summary"] = update.summary;
1289
- await client.put(`/rest/api/3/issue/${encodeURIComponent(key)}`, { fields });
1833
+ await client.putNoContent(`/rest/api/3/issue/${encodeURIComponent(key)}`, { fields });
1290
1834
  }
1291
1835
  async function searchIssues(client, site, params) {
1292
1836
  const jql = buildJql(params);
@@ -1305,11 +1849,11 @@ async function searchIssues(client, site, params) {
1305
1849
  function buildJql(params) {
1306
1850
  if (params.jql) return params.jql;
1307
1851
  const clauses = [];
1308
- if (params.project) clauses.push(`project = ${jqlValue(params.project)}`);
1309
- if (params.assignee) clauses.push(params.assignee === "me" ? "assignee = currentUser()" : `assignee = ${jqlValue(params.assignee)}`);
1310
- if (params.status) clauses.push(`status = ${jqlValue(params.status)}`);
1311
- if (params.text) clauses.push(`text ~ ${jqlValue(params.text)}`);
1312
- if (clauses.length === 0) clauses.push("updated >= -30d");
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);
1313
1857
  return `${clauses.join(" AND ")} ORDER BY updated DESC`;
1314
1858
  }
1315
1859
  const PROJECT_PAGE_SIZE = 50;
@@ -1365,7 +1909,7 @@ const CATEGORY_ORDER = {
1365
1909
  indeterminate: 1,
1366
1910
  done: 2
1367
1911
  };
1368
- const CATEGORY_LAST = Object.keys(CATEGORY_ORDER).length;
1912
+ const UNKNOWN_CATEGORY_RANK = Object.keys(CATEGORY_ORDER).length;
1369
1913
  function dedupeAndSortStatuses(statuses) {
1370
1914
  const byNameCategory = /* @__PURE__ */ new Map();
1371
1915
  for (const s of statuses) {
@@ -1373,11 +1917,11 @@ function dedupeAndSortStatuses(statuses) {
1373
1917
  if (!byNameCategory.has(key)) byNameCategory.set(key, s);
1374
1918
  }
1375
1919
  return [...byNameCategory.values()].sort((a, b) => {
1376
- const rank = (s) => CATEGORY_ORDER[s.categoryKey] ?? CATEGORY_LAST;
1920
+ const rank = (s) => CATEGORY_ORDER[s.categoryKey] ?? UNKNOWN_CATEGORY_RANK;
1377
1921
  return rank(a) - rank(b) || a.name.localeCompare(b.name);
1378
1922
  });
1379
1923
  }
1380
- function jqlValue(value) {
1924
+ function jqlStringLiteral(value) {
1381
1925
  return `"${value.replace(/(["\\])/g, "\\$1")}"`;
1382
1926
  }
1383
1927
  async function fetchComments(client, key) {
@@ -1388,6 +1932,148 @@ async function fetchComments(client, key) {
1388
1932
  }));
1389
1933
  }
1390
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
1391
2077
  //#region src/commands/jira.ts
1392
2078
  async function jiraProjects(query, options) {
1393
2079
  const auth = await requireAuth();
@@ -1426,45 +2112,92 @@ function formatStatusRows(statuses) {
1426
2112
  const width = Math.max(...statuses.map((s) => s.name.length));
1427
2113
  return statuses.map((s) => `${s.name.padEnd(width)} ${s.category}`);
1428
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
+ }
1429
2180
  async function jiraCopy(arg, options) {
1430
2181
  const auth = await requireAuth();
1431
- const key = await resolveKey(arg);
2182
+ const key = await resolveRef(arg, ISSUE_REF);
1432
2183
  await copyIssue(new AtlassianClient(auth), auth.site, key, options.out);
1433
2184
  }
1434
2185
  async function jiraUpdate(arg, options) {
1435
- const src = parseJiraUpdateSource(await readFile(arg ?? await input({
2186
+ const src = parseIssueSource(await readFile(arg ?? await input({
1436
2187
  message: "Path to the issue Markdown file:",
1437
2188
  required: true
1438
2189
  }), "utf8"));
1439
2190
  const auth = await requireAuth();
1440
2191
  const client = new AtlassianClient(auth);
1441
- const issue = await fetchIssue(client, auth.site, src.key);
1442
- const stale = issue.updated !== src.updated;
1443
- const { local, external } = classifyImages(src.body);
1444
- const lossy = findLossyNodes(issue.description, JIRA_LOSSY_LABELS);
1445
- const newSummary = options.summary && src.bodyTitle ? src.bodyTitle : issue.summary;
1446
- if (options.dryRun) {
1447
- printDryRun(src.key, issue.summary, newSummary, stale, external.length, local, lossy);
1448
- return;
1449
- }
1450
- 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(", ")}`);
1451
- 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.`);
1452
- if (lossy.size > 0 && !options.force) {
1453
- if (!await confirm({
1454
- message: `This issue's description contains ${formatLossy(lossy)} that Markdown cannot represent and will be removed. Continue?`,
1455
- default: false
1456
- })) {
1457
- console.log("Aborted.");
1458
- return;
1459
- }
1460
- }
1461
- const description = markdownToAdf(src.body);
1462
- if (!description.content || description.content.length === 0) throw new Error("Refusing to update: the converted description is empty.");
1463
- await updateIssue(client, src.key, {
1464
- description,
1465
- 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}.`);
1466
2200
  });
1467
- console.log(`Updated ${src.key}.`);
1468
2201
  }
1469
2202
  async function jiraSearch(query, options) {
1470
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.");
@@ -1482,8 +2215,8 @@ async function jiraSearch(query, options) {
1482
2215
  });
1483
2216
  await runSearch(issues.map((i) => ({
1484
2217
  id: i.key,
1485
- prefix: `${i.key} ${i.status}`,
1486
- text: i.summary,
2218
+ fixedColumns: `${i.key} ${i.status}`,
2219
+ freeText: i.summary,
1487
2220
  json: {
1488
2221
  key: i.key,
1489
2222
  status: i.status,
@@ -1503,70 +2236,16 @@ async function jiraSearch(query, options) {
1503
2236
  }
1504
2237
  async function copyIssue(client, site, key, out) {
1505
2238
  console.log(`Fetching ${key} ...`);
1506
- const issue = await fetchIssue(client, site, key);
1507
- const target = resolveOutput(issue.key, out);
1508
- const downloaded = await downloadAttachments(client, issue.attachments, target.assetsDir, target.assetsDirName);
1509
- const resolveMedia = mediaResolver(downloaded);
1510
- const document = joinSections([
1511
- frontmatter({
1512
- key: issue.key,
1513
- type: issue.type,
1514
- status: issue.status,
1515
- assignee: issue.assignee,
1516
- reporter: issue.reporter,
1517
- priority: issue.priority,
1518
- labels: issue.labels,
1519
- created: issue.created,
1520
- updated: issue.updated,
1521
- url: issue.url
1522
- }),
1523
- `# ${issue.summary}`,
1524
- adfToMarkdown(issue.description, { resolveMedia }),
1525
- commentsSection(issue.comments, resolveMedia),
1526
- attachmentsSection(downloaded)
1527
- ]);
1528
- await writeFile(target.filePath, document, "utf8");
1529
- report(target.filePath, downloaded.length);
1530
- }
1531
- async function resolveKey(arg) {
1532
- const raw = arg ?? await input({
1533
- message: "Jira issue key or URL:",
1534
- required: true
1535
- });
1536
- const key = parseIssueKey(raw);
1537
- if (!key) throw new Error(`Could not find an issue key in "${raw}" (expected e.g. PROJ-123).`);
1538
- return key;
1539
- }
1540
- function report(filePath, assetCount) {
1541
- const suffix = assetCount > 0 ? ` (+${assetCount} attachment${assetCount === 1 ? "" : "s"})` : "";
1542
- console.log(`Wrote ${filePath}${suffix}`);
1543
- }
1544
- function classifyImages(md) {
1545
- const local = [];
1546
- const external = [];
1547
- const seen = /* @__PURE__ */ new Set();
1548
- marked.walkTokens(marked.lexer(md), (token) => {
1549
- if (token.type !== "image") return;
1550
- const href = token.href;
1551
- if (seen.has(href)) return;
1552
- seen.add(href);
1553
- if (/^[a-z][a-z0-9+.-]*:\/\//i.test(href)) external.push(href);
1554
- else local.push(href);
1555
- });
1556
- return {
1557
- local,
1558
- external
1559
- };
1560
- }
1561
- function printDryRun(key, currentSummary, newSummary, stale, externalImages, localImages, lossy) {
1562
- console.log(`Dry run for ${key} "${currentSummary}"`);
1563
- if (newSummary !== currentSummary) console.log(` summary: "${currentSummary}" -> "${newSummary}"`);
1564
- if (externalImages > 0) console.log(` images: ${externalImages} external`);
1565
- if (localImages.length > 0) console.log(` blocked: ${localImages.length} local image(s) not supported (edit text only)`);
1566
- if (lossy.size > 0) console.log(` warning: ${formatLossy(lossy)} will be removed`);
1567
- if (stale) console.log(` stale: server changed since copy (would refuse without --force)`);
1568
- console.log(" nothing was written (dry run)");
2239
+ await runCopy(planIssueCopy(await fetchIssue(client, site, key), out), (url) => client.getBinary(url));
1569
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";
1570
2249
  //#endregion
1571
2250
  //#region src/cli.ts
1572
2251
  const program = new Command();
@@ -1578,6 +2257,7 @@ auth.command("status").description("Show the current login").action(run(status))
1578
2257
  const jira = program.command("jira").description("Jira commands");
1579
2258
  jira.command("projects [query]").description("List projects (optionally filtered by key or name)").option("--json", "output results as JSON").action(run(jiraProjects));
1580
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));
1581
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));
1582
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));
1583
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));
@@ -1585,6 +2265,13 @@ const confluence = program.command("confluence").description("Confluence command
1585
2265
  confluence.command("copy [page]").description("Copy a Confluence page (id or URL) to a Markdown file").option("-o, --out <path>", "output file or directory").action(run(confluenceCopy));
1586
2266
  confluence.command("update [file]").description("Update a Confluence page from an edited Markdown file").option("--title", "also push the H1 as the page title").option("-m, --message <text>", "version message (default 'Updated via atlass')").option("-f, --force", "skip the stale-version and data-loss checks").option("--dry-run", "show what would change without writing").action(run(confluenceUpdate));
1587
2267
  confluence.command("search [query]").description("Search Confluence pages (text query, --space, or --cql)").option("-s, --space <key>", "limit to a space").option("--cql <cql>", "raw CQL 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(confluenceSearch));
2268
+ const bitbucket = program.command("bitbucket").description("Bitbucket commands");
2269
+ bitbucket.command("login").description("Store Bitbucket workspace and API token").action(run(bitbucketLogin));
2270
+ bitbucket.command("logout").description("Remove stored Bitbucket credentials").action(run(bitbucketLogout));
2271
+ bitbucket.command("status").description("Show the current Bitbucket login").action(run(bitbucketStatus));
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));
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;
1588
2275
  program.parseAsync().catch(fail);
1589
2276
  function run(fn) {
1590
2277
  return async (...args) => {
@@ -1596,9 +2283,12 @@ function run(fn) {
1596
2283
  };
1597
2284
  }
1598
2285
  function fail(err) {
1599
- if (err instanceof Error && err.name === "ExitPromptError") process.exit(130);
2286
+ if (isPromptInterrupt(err)) process.exit(SIGINT_EXIT_CODE);
1600
2287
  console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
1601
2288
  process.exit(1);
1602
2289
  }
2290
+ function isPromptInterrupt(err) {
2291
+ return err instanceof Error && err.name === "ExitPromptError";
2292
+ }
1603
2293
  //#endregion
1604
2294
  export {};