atlass 1.6.0 → 1.8.0

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