atlass 1.14.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { Command } from "commander";
1
+ import { Command, Option } from "commander";
2
2
  import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
3
3
  import { basename, dirname, isAbsolute, join, resolve } from "node:path";
4
4
  import { Entry } from "@napi-rs/keyring";
@@ -10,6 +10,7 @@ import kleur from "kleur";
10
10
  import { common, createLowlight } from "lowlight";
11
11
  import { marked } from "marked";
12
12
  import { randomUUID } from "node:crypto";
13
+ import { structuredPatch } from "diff";
13
14
  //#region src/api/http-error.ts
14
15
  var HttpError = class extends Error {
15
16
  status;
@@ -119,6 +120,12 @@ var AtlassianClient = class {
119
120
  body: JSON.stringify(body)
120
121
  });
121
122
  }
123
+ async deleteNoContent(path) {
124
+ await this.request(path, {
125
+ method: "DELETE",
126
+ headers: { Accept: "application/json" }
127
+ });
128
+ }
122
129
  async postMultipart(path, filename, bytes) {
123
130
  const form = new FormData();
124
131
  const blob = new Blob([bytes]);
@@ -528,6 +535,41 @@ function siteOrigin(input) {
528
535
  if (!/^https?:\/\//i.test(value)) value = `https://${value}`;
529
536
  return new URL(value).origin;
530
537
  }
538
+ const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
539
+ const RELATIVE = /^(\d+)([dwm])$/i;
540
+ function parseSince(value, nowMs) {
541
+ const raw = value.trim();
542
+ const iso = ISO_DATE.exec(raw);
543
+ if (iso) {
544
+ if (!isRealDate(iso)) throw invalidSince(value);
545
+ return raw;
546
+ }
547
+ const relative = RELATIVE.exec(raw);
548
+ if (!relative) throw invalidSince(value);
549
+ const amount = Number.parseInt(relative[1], 10);
550
+ const date = new Date(nowMs);
551
+ switch (relative[2].toLowerCase()) {
552
+ case "d":
553
+ date.setDate(date.getDate() - amount);
554
+ break;
555
+ case "w":
556
+ date.setDate(date.getDate() - amount * 7);
557
+ break;
558
+ default: date.setMonth(date.getMonth() - amount);
559
+ }
560
+ return localDate(date);
561
+ }
562
+ function isRealDate([, year = "", month = "", day = ""]) {
563
+ const date = new Date(Number(year), Number(month) - 1, Number(day));
564
+ return date.getFullYear() === Number(year) && date.getMonth() === Number(month) - 1 && date.getDate() === Number(day);
565
+ }
566
+ function localDate(date) {
567
+ const month = String(date.getMonth() + 1).padStart(2, "0");
568
+ return `${date.getFullYear()}-${month}-${String(date.getDate()).padStart(2, "0")}`;
569
+ }
570
+ function invalidSince(value) {
571
+ return /* @__PURE__ */ new Error(`Invalid --updated "${value}". Expected 7d, 2w, 3m, or YYYY-MM-DD.`);
572
+ }
531
573
  //#endregion
532
574
  //#region src/commands/auth.ts
533
575
  async function login({ term, profile }) {
@@ -1334,7 +1376,7 @@ function link(text, url) {
1334
1376
  //#endregion
1335
1377
  //#region src/commands/view.ts
1336
1378
  const INDENT = " ";
1337
- function fieldLines(pairs) {
1379
+ function fieldLines$1(pairs) {
1338
1380
  const kept = pairs.filter(([, value]) => value !== "");
1339
1381
  const width = Math.max(...kept.map(([label]) => label.length)) + 1;
1340
1382
  return kept.map(([label, value]) => `${kleur.dim(`${label}:`.padEnd(width))} ${value}`);
@@ -1421,7 +1463,7 @@ function formatPullRequestView(detail, comments, files, nowMs, allComments = fal
1421
1463
  const label = detail.draft ? "DRAFT" : detail.state || "-";
1422
1464
  return [
1423
1465
  kleur.bold(detail.title),
1424
- ...fieldLines([
1466
+ ...fieldLines$1([
1425
1467
  ["State", colorForBitbucketState(label)(label)],
1426
1468
  ["Author", detail.author],
1427
1469
  ["Branch", branchLine(detail)],
@@ -1520,6 +1562,19 @@ function hyperlink(text, url) {
1520
1562
  }
1521
1563
  //#endregion
1522
1564
  //#region src/commands/search-run.ts
1565
+ function checkedLimit(options) {
1566
+ if (options.json && options.copy) throw new Error("--json and --copy cannot be used together.");
1567
+ return parseLimit(options.limit);
1568
+ }
1569
+ async function showRows(term, rows, listing, options, noun, copyOne) {
1570
+ await runSearch(term, rows, {
1571
+ json: options.json,
1572
+ copy: options.copy,
1573
+ out: options.out,
1574
+ empty: listing.empty,
1575
+ footer: listing.hasMore ? searchFooter(listing.limit) : void 0
1576
+ }, noun, copyOne);
1577
+ }
1523
1578
  function alignedRows(items, nowMs, cells) {
1524
1579
  const rows = items.map((item) => {
1525
1580
  const cell = cells(item);
@@ -1829,6 +1884,202 @@ function registerBitbucket(bitbucket) {
1829
1884
  return bitbucket;
1830
1885
  }
1831
1886
  //#endregion
1887
+ //#region src/cli/search-options.ts
1888
+ function collect(value, previous = []) {
1889
+ return [...previous, value];
1890
+ }
1891
+ function moved(flags, command) {
1892
+ const flag = flags.split(" ")[0];
1893
+ return new Option(flags).hideHelp().argParser(() => {
1894
+ throw new Error(`${flag} is now its own command: \`atlass ${command} "<query>"\`.`);
1895
+ });
1896
+ }
1897
+ function outputOptions(command) {
1898
+ return command.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");
1899
+ }
1900
+ //#endregion
1901
+ //#region src/api/confluence-create.ts
1902
+ async function findSpaceId(client, key) {
1903
+ return (await client.getJson(`/wiki/api/v2/spaces?keys=${encodeURIComponent(key)}`)).results[0]?.id ?? null;
1904
+ }
1905
+ async function createPage(client, page) {
1906
+ const res = await client.postJson("/wiki/api/v2/pages", {
1907
+ spaceId: page.spaceId,
1908
+ status: "current",
1909
+ title: page.title,
1910
+ ...page.parentId ? { parentId: page.parentId } : {},
1911
+ body: {
1912
+ representation: "atlas_doc_format",
1913
+ value: JSON.stringify(page.body)
1914
+ }
1915
+ });
1916
+ return {
1917
+ id: res.id,
1918
+ version: res.version?.number ?? 1
1919
+ };
1920
+ }
1921
+ //#endregion
1922
+ //#region src/api/confluence-attachments.ts
1923
+ const UNKNOWN_SIZE = -1;
1924
+ async function listAttachments(client, id) {
1925
+ return (await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}/attachments?limit=250`)).results.map((a) => ({
1926
+ filename: a.title ?? a.id,
1927
+ fileId: mediaNodeId(a),
1928
+ size: typeof a.fileSize === "number" ? a.fileSize : UNKNOWN_SIZE
1929
+ }));
1930
+ }
1931
+ async function uploadAttachment(client, pageId, filename, bytes) {
1932
+ return (await client.postMultipart(`/wiki/rest/api/content/${encodeURIComponent(pageId)}/child/attachment`, filename, bytes)).results?.[0]?.extensions?.fileId ?? await fileIdByListing(client, pageId, filename);
1933
+ }
1934
+ async function fileIdByListing(client, pageId, filename) {
1935
+ const match = (await listAttachments(client, pageId)).find((a) => a.filename === filename);
1936
+ if (match) return match.fileId;
1937
+ throw new Error(`Upload of "${filename}" did not return a fileId.`);
1938
+ }
1939
+ async function fetchAttachments(client, id) {
1940
+ return (await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}/attachments?limit=250`)).results.filter((a) => a.downloadLink).map((a) => ({
1941
+ mediaId: mediaNodeId(a),
1942
+ filename: a.title ?? a.id,
1943
+ url: withWikiContextPath(a.downloadLink ?? "")
1944
+ }));
1945
+ }
1946
+ function mediaNodeId(a) {
1947
+ return a.fileId ?? a.id;
1948
+ }
1949
+ function withWikiContextPath(link) {
1950
+ if (link.startsWith("http") || link.startsWith("/wiki")) return link;
1951
+ return `/wiki${link}`;
1952
+ }
1953
+ //#endregion
1954
+ //#region src/api/confluence-pages.ts
1955
+ async function fetchPage(client, site, id) {
1956
+ const page = await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}?body-format=atlas_doc_format`);
1957
+ const names = new UserNames(client);
1958
+ const [spaceKey, attachments, comments, author] = await Promise.all([
1959
+ fetchSpaceKey(client, page.spaceId),
1960
+ fetchAttachments(client, id),
1961
+ fetchComments$1(client, id, names),
1962
+ names.resolve(page.version?.authorId ?? page.authorId)
1963
+ ]);
1964
+ const webui = page._links?.webui ?? "";
1965
+ return {
1966
+ id: page.id,
1967
+ title: page.title,
1968
+ spaceKey,
1969
+ version: page.version?.number ?? 0,
1970
+ author,
1971
+ createdAt: page.createdAt ?? "",
1972
+ updatedAt: page.version?.createdAt ?? "",
1973
+ url: webui ? `${site}/wiki${webui}` : "",
1974
+ body: parseAdfJson(page.body?.atlas_doc_format?.value),
1975
+ attachments,
1976
+ comments
1977
+ };
1978
+ }
1979
+ async function fetchPageState(client, id) {
1980
+ const page = await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}?body-format=atlas_doc_format`);
1981
+ return {
1982
+ version: page.version?.number ?? 0,
1983
+ title: page.title,
1984
+ body: parseAdfJson(page.body?.atlas_doc_format?.value)
1985
+ };
1986
+ }
1987
+ async function updatePage(client, id, params) {
1988
+ const res = await client.putJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}`, {
1989
+ id,
1990
+ status: "current",
1991
+ title: params.title,
1992
+ body: {
1993
+ representation: "atlas_doc_format",
1994
+ value: JSON.stringify(params.body)
1995
+ },
1996
+ version: {
1997
+ number: params.nextVersion,
1998
+ message: params.message
1999
+ }
2000
+ });
2001
+ return {
2002
+ number: res.version?.number ?? params.nextVersion,
2003
+ createdAt: res.version?.createdAt ?? ""
2004
+ };
2005
+ }
2006
+ async function fetchSpaceKey(client, spaceId) {
2007
+ if (!spaceId) return "";
2008
+ try {
2009
+ return (await client.getJson(`/wiki/api/v2/spaces/${encodeURIComponent(spaceId)}`)).key ?? "";
2010
+ } catch {
2011
+ return "";
2012
+ }
2013
+ }
2014
+ async function fetchComments$1(client, id, names) {
2015
+ const res = await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}/footer-comments?body-format=atlas_doc_format&limit=250`);
2016
+ return Promise.all(res.results.map(async (c) => ({
2017
+ author: await names.resolve(c.version?.authorId),
2018
+ created: c.version?.createdAt ?? "",
2019
+ body: parseAdfJson(c.body?.atlas_doc_format?.value)
2020
+ })));
2021
+ }
2022
+ function parseAdfJson(value) {
2023
+ if (!value) return null;
2024
+ try {
2025
+ return JSON.parse(value);
2026
+ } catch {
2027
+ return null;
2028
+ }
2029
+ }
2030
+ var UserNames = class {
2031
+ client;
2032
+ cache = /* @__PURE__ */ new Map();
2033
+ constructor(client) {
2034
+ this.client = client;
2035
+ }
2036
+ async resolve(accountId) {
2037
+ if (!accountId) return "";
2038
+ const cached = this.cache.get(accountId);
2039
+ if (cached !== void 0) return cached;
2040
+ const name = await this.fetchDisplayName(accountId) ?? accountId;
2041
+ this.cache.set(accountId, name);
2042
+ return name;
2043
+ }
2044
+ async fetchDisplayName(accountId) {
2045
+ try {
2046
+ return (await this.client.getJson(`/wiki/rest/api/user?accountId=${encodeURIComponent(accountId)}`)).displayName || null;
2047
+ } catch {
2048
+ return null;
2049
+ }
2050
+ }
2051
+ };
2052
+ //#endregion
2053
+ //#region src/adf/html.ts
2054
+ const COMMENT = /<!--[\s\S]*?-->/g;
2055
+ function isOnlyComments(html) {
2056
+ return html.replace(COMMENT, "").trim().length === 0;
2057
+ }
2058
+ function withoutComments(md) {
2059
+ const kept = [];
2060
+ let skipSpace = false;
2061
+ for (const token of marked.lexer(md)) {
2062
+ if (skipSpace && token.type === "space") continue;
2063
+ skipSpace = token.type === "html" && isOnlyComments(token.text);
2064
+ if (skipSpace) continue;
2065
+ kept.push(token.type === "code" ? token.raw : token.raw.replace(COMMENT, ""));
2066
+ }
2067
+ return kept.join("").trim();
2068
+ }
2069
+ function htmlWarnings(md) {
2070
+ let blocks = 0;
2071
+ let inline = 0;
2072
+ marked.walkTokens(marked.lexer(md), (token) => {
2073
+ if (token.type !== "html" || isOnlyComments(token.text)) return;
2074
+ if (token.block) blocks++;
2075
+ else inline++;
2076
+ });
2077
+ return [...blocks > 0 ? [`${counted(blocks, "HTML block")} will be dropped`] : [], ...inline > 0 ? [`${counted(inline, "inline HTML tag")} will be sent as text`] : []];
2078
+ }
2079
+ function counted(n, noun) {
2080
+ return `${n} ${noun}${n === 1 ? "" : "s"}`;
2081
+ }
2082
+ //#endregion
1832
2083
  //#region src/adf/from-markdown.ts
1833
2084
  function mediaNode(source, alt) {
1834
2085
  const attrs = { ...source };
@@ -1999,7 +2250,10 @@ function inlineNode(token, ctx, marks) {
1999
2250
  }));
2000
2251
  }
2001
2252
  case "br": return [{ type: "hardBreak" }];
2002
- case "html": return textNode(token.text, marks);
2253
+ case "html": {
2254
+ const html = token.text;
2255
+ return isOnlyComments(html) ? [] : textNode(html, marks);
2256
+ }
2003
2257
  default: return "text" in token && token.text ? textNode(token.text, marks) : [];
2004
2258
  }
2005
2259
  }
@@ -2033,187 +2287,300 @@ function clampLevel(value) {
2033
2287
  return Math.min(6, Math.max(1, Math.trunc(value) || 1));
2034
2288
  }
2035
2289
  //#endregion
2036
- //#region src/api/confluence-attachments.ts
2037
- const UNKNOWN_SIZE = -1;
2038
- async function listAttachments(client, id) {
2039
- return (await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}/attachments?limit=250`)).results.map((a) => ({
2040
- filename: a.title ?? a.id,
2041
- fileId: mediaNodeId(a),
2042
- size: typeof a.fileSize === "number" ? a.fileSize : UNKNOWN_SIZE
2043
- }));
2290
+ //#region src/adf/lossy.ts
2291
+ const LOSSY_LABELS = {
2292
+ panel: "panel",
2293
+ expand: "expand",
2294
+ nestedExpand: "expand",
2295
+ decisionList: "decision list",
2296
+ layoutSection: "layout",
2297
+ extension: "macro",
2298
+ bodiedExtension: "macro",
2299
+ inlineExtension: "macro"
2300
+ };
2301
+ const JIRA_LOSSY_LABELS = {
2302
+ ...LOSSY_LABELS,
2303
+ media: "image",
2304
+ mediaInline: "image"
2305
+ };
2306
+ function findLossyNodes(node, lossyLabels = LOSSY_LABELS) {
2307
+ const counts = /* @__PURE__ */ new Map();
2308
+ const visit = (n) => {
2309
+ const label = lossyLabels[n.type];
2310
+ if (label) counts.set(label, (counts.get(label) ?? 0) + 1);
2311
+ for (const child of n.content ?? []) visit(child);
2312
+ };
2313
+ if (node) visit(node);
2314
+ return counts;
2044
2315
  }
2045
- async function uploadAttachment(client, pageId, filename, bytes) {
2046
- return (await client.postMultipart(`/wiki/rest/api/content/${encodeURIComponent(pageId)}/child/attachment`, filename, bytes)).results?.[0]?.extensions?.fileId ?? await fileIdByListing(client, pageId, filename);
2316
+ function formatLossy(counts) {
2317
+ return [...counts.entries()].map(([label, n]) => `${n} ${label}${n === 1 ? "" : "s"}`).join(", ");
2047
2318
  }
2048
- async function fileIdByListing(client, pageId, filename) {
2049
- const match = (await listAttachments(client, pageId)).find((a) => a.filename === filename);
2050
- if (match) return match.fileId;
2051
- throw new Error(`Upload of "${filename}" did not return a fileId.`);
2319
+ //#endregion
2320
+ //#region src/update/body-diff.ts
2321
+ const CONTEXT_LINES = 3;
2322
+ function unifiedDiff(before, after, labels) {
2323
+ if (before === after) return [];
2324
+ const [from, to] = labels;
2325
+ const patch = structuredPatch(from, to, `${before}\n`, `${after}\n`, void 0, void 0, { context: CONTEXT_LINES });
2326
+ return [
2327
+ `--- ${from}`,
2328
+ `+++ ${to}`,
2329
+ ...patch.hunks.flatMap((h) => [`@@ -${h.oldStart},${h.oldLines} +${h.newStart},${h.newLines} @@`, ...h.lines])
2330
+ ];
2052
2331
  }
2053
- async function fetchAttachments(client, id) {
2054
- return (await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}/attachments?limit=250`)).results.filter((a) => a.downloadLink).map((a) => ({
2055
- mediaId: mediaNodeId(a),
2056
- filename: a.title ?? a.id,
2057
- url: withWikiContextPath(a.downloadLink ?? "")
2332
+ function colorDiffLine(line) {
2333
+ if (line.startsWith("+++") || line.startsWith("---")) return kleur.bold(line);
2334
+ if (line.startsWith("@@")) return kleur.cyan(line);
2335
+ if (line.startsWith("+")) return kleur.green(line);
2336
+ if (line.startsWith("-")) return kleur.red(line);
2337
+ return line;
2338
+ }
2339
+ //#endregion
2340
+ //#region src/update/plan.ts
2341
+ function planIssueUpdate(source, issue, options) {
2342
+ const images = imageHrefs(source.body).map((href) => ({
2343
+ href,
2344
+ kind: isExternalHref(href) ? "external" : "unsupported"
2058
2345
  }));
2346
+ const unsupported = images.filter((i) => i.kind === "unsupported").map((i) => i.href);
2347
+ const body = markdownToAdf(source.body, { resolveImage: externalImagesOnly });
2348
+ 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)];
2349
+ return withVerdict({
2350
+ noun: "issue",
2351
+ id: source.key,
2352
+ headline: headline("summary", issue.summary, source.title, options.summary),
2353
+ revision: revision(source.updatedAtCopy, issue.updated),
2354
+ lossy: findLossyNodes(issue.description, JIRA_LOSSY_LABELS),
2355
+ warnings: htmlWarnings(source.body),
2356
+ diff: [],
2357
+ images,
2358
+ uploads: [],
2359
+ body,
2360
+ refusals
2361
+ }, options.force ?? false);
2059
2362
  }
2060
- function mediaNodeId(a) {
2061
- return a.fileId ?? a.id;
2363
+ function formatPlan(plan) {
2364
+ const lines = [`Dry run for ${plan.noun} ${plan.id} "${plan.headline.current}"`];
2365
+ if (plan.headline.next !== plan.headline.current) lines.push(row(plan.headline.label, `"${plan.headline.current}" -> "${plan.headline.next}"`));
2366
+ const counts = imageCounts(plan.images);
2367
+ if (counts) lines.push(row("images", counts));
2368
+ for (const refusal of plan.refusals) lines.push(row("blocked", refusal));
2369
+ if (plan.lossy.size > 0) lines.push(row("warning", `${formatLossy(plan.lossy)} will be removed`));
2370
+ for (const warning of plan.warnings) lines.push(row("warning", warning));
2371
+ if (plan.revision.stale) lines.push(row("stale", `${revisionText(plan.revision)} (would refuse without --force)`));
2372
+ lines.push(...plan.diff.map(colorDiffLine));
2373
+ lines.push(" nothing was written (dry run)");
2374
+ return lines;
2062
2375
  }
2063
- function withWikiContextPath(link) {
2064
- if (link.startsWith("http") || link.startsWith("/wiki")) return link;
2065
- return `/wiki${link}`;
2376
+ const IMAGE_COUNT_LABELS = [
2377
+ ["upload", "new"],
2378
+ ["changed", "changed"],
2379
+ ["reuse", "reused"],
2380
+ ["external", "external"],
2381
+ ["unsupported", "unsupported"],
2382
+ ["missing", "missing"]
2383
+ ];
2384
+ function imageCounts(images) {
2385
+ return IMAGE_COUNT_LABELS.map(([kind, label]) => ({
2386
+ n: images.filter((i) => i.kind === kind).length,
2387
+ label
2388
+ })).filter(({ n }) => n > 0).map(({ n, label }) => `${n} ${label}`).join(", ");
2389
+ }
2390
+ function row(label, value) {
2391
+ return ` ${`${label}:`.padEnd(9)}${value}`;
2392
+ }
2393
+ function headline(label, current, title, useTitle) {
2394
+ return {
2395
+ label,
2396
+ current,
2397
+ next: useTitle && title ? title : current
2398
+ };
2399
+ }
2400
+ function revision(local, server) {
2401
+ return {
2402
+ local: local || "unknown",
2403
+ server: server || "unknown",
2404
+ stale: local !== server
2405
+ };
2406
+ }
2407
+ function revisionText(revision) {
2408
+ return `copied at ${revision.local}, server now ${revision.server}`;
2409
+ }
2410
+ function externalImagesOnly(href, alt) {
2411
+ return isExternalHref(href) ? externalMedia(href, alt) : void 0;
2412
+ }
2413
+ function missingImagesRefusal(missing) {
2414
+ return missing.length > 0 ? [`Image file(s) not found: ${missing.join(", ")}`] : [];
2415
+ }
2416
+ function emptyBodyRefusal(body) {
2417
+ if (body.content && body.content.length > 0) return [];
2418
+ return ["Refusing to update: the converted body is empty."];
2419
+ }
2420
+ function withVerdict(plan, force) {
2421
+ return {
2422
+ ...plan,
2423
+ verdict: verdict(plan, force)
2424
+ };
2425
+ }
2426
+ function verdict(plan, force) {
2427
+ if (plan.refusals.length > 0) return {
2428
+ kind: "refuse",
2429
+ message: plan.refusals.join("\n")
2430
+ };
2431
+ const noun = plan.noun;
2432
+ if (plan.revision.stale && !force) return {
2433
+ kind: "refuse",
2434
+ message: `${capitalize(noun)} changed on the server since you copied it (${revisionText(plan.revision)}). Re-copy the ${noun} or pass --force.`
2435
+ };
2436
+ if (plan.lossy.size > 0 && !force) return {
2437
+ kind: "confirm",
2438
+ message: `This ${noun} contains ${formatLossy(plan.lossy)} that Markdown cannot represent and will be removed. Continue?`
2439
+ };
2440
+ return { kind: "proceed" };
2441
+ }
2442
+ function capitalize(text) {
2443
+ return text.charAt(0).toUpperCase() + text.slice(1);
2066
2444
  }
2067
2445
  //#endregion
2068
- //#region src/api/confluence-pages.ts
2069
- async function fetchPage(client, site, id) {
2070
- const page = await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}?body-format=atlas_doc_format`);
2071
- const names = new UserNames(client);
2072
- const [spaceKey, attachments, comments, author] = await Promise.all([
2073
- fetchSpaceKey(client, page.spaceId),
2074
- fetchAttachments(client, id),
2075
- fetchComments$1(client, id, names),
2076
- names.resolve(page.version?.authorId ?? page.authorId)
2077
- ]);
2078
- const webui = page._links?.webui ?? "";
2446
+ //#region src/update/plan-page.ts
2447
+ function planPageBody(page, attachments, localImages) {
2448
+ const collection = `contentId-${page.id}`;
2449
+ const images = imageHrefs(page.body).map((href) => pageImage(href, localImages, attachments));
2450
+ const byHref = new Map(images.map((e) => [e.href, e]));
2079
2451
  return {
2080
- id: page.id,
2081
- title: page.title,
2082
- spaceKey,
2083
- version: page.version?.number ?? 0,
2084
- author,
2085
- createdAt: page.createdAt ?? "",
2086
- updatedAt: page.version?.createdAt ?? "",
2087
- url: webui ? `${site}/wiki${webui}` : "",
2088
- body: parseAdfJson(page.body?.atlas_doc_format?.value),
2089
- attachments,
2090
- comments
2452
+ images,
2453
+ body: markdownToAdf(page.body, { resolveImage: (href, alt) => {
2454
+ const entry = byHref.get(href);
2455
+ if (!entry || entry.kind === "missing") return void 0;
2456
+ if (entry.kind === "external") return externalMedia(href, alt);
2457
+ return mediaNode({
2458
+ type: "file",
2459
+ id: entry.kind === "reuse" ? entry.fileId : href,
2460
+ collection
2461
+ }, alt);
2462
+ } }),
2463
+ uploads: images.flatMap((e) => e.kind === "upload" || e.kind === "changed" ? [{
2464
+ href: e.href,
2465
+ path: e.path,
2466
+ filename: e.filename
2467
+ }] : [])
2091
2468
  };
2092
2469
  }
2093
- async function fetchPageState(client, id) {
2094
- const page = await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}?body-format=atlas_doc_format`);
2470
+ function planPageUpdate(source, state, attachments, localImages, options) {
2471
+ const { images, body, uploads } = planPageBody(source, attachments, localImages);
2472
+ const missing = images.filter((e) => e.kind === "missing").map((e) => e.href);
2473
+ return withVerdict({
2474
+ noun: "page",
2475
+ id: source.id,
2476
+ headline: headline("title", state.title, source.title, options.title),
2477
+ revision: revision(`v${source.version}`, `v${state.version}`),
2478
+ lossy: findLossyNodes(state.body),
2479
+ warnings: htmlWarnings(source.body),
2480
+ diff: unifiedDiff(serverMarkdown(state, attachments, images), withoutComments(source.body), [`server v${state.version}`, options.file]),
2481
+ images: images.map(({ href, kind }) => ({
2482
+ href,
2483
+ kind
2484
+ })),
2485
+ uploads,
2486
+ body,
2487
+ refusals: [...missingImagesRefusal(missing), ...emptyBodyRefusal(body)]
2488
+ }, options.force ?? false);
2489
+ }
2490
+ function serverMarkdown(state, attachments, images) {
2491
+ const reused = new Map(images.flatMap((e) => e.kind === "reuse" ? [[e.fileId, e.href]] : []));
2492
+ return adfToMarkdown(state.body, { resolveMedia: ({ id }) => reused.get(id ?? "") ?? attachments.find((a) => a.fileId === id)?.filename });
2493
+ }
2494
+ function pageImage(href, localImages, attachments) {
2495
+ if (isExternalHref(href)) return {
2496
+ href,
2497
+ kind: "external"
2498
+ };
2499
+ const local = localImages.find((l) => l.href === href);
2500
+ if (!local || local.size === void 0) return {
2501
+ href,
2502
+ kind: "missing"
2503
+ };
2504
+ const existing = attachments.find((a) => a.filename === local.filename);
2505
+ if (existing && existing.size === local.size) return {
2506
+ href,
2507
+ kind: "reuse",
2508
+ fileId: existing.fileId
2509
+ };
2095
2510
  return {
2096
- version: page.version?.number ?? 0,
2097
- title: page.title,
2098
- body: parseAdfJson(page.body?.atlas_doc_format?.value)
2511
+ href,
2512
+ kind: existing ? "changed" : "upload",
2513
+ path: local.path,
2514
+ filename: local.filename
2099
2515
  };
2100
2516
  }
2101
- async function updatePage(client, id, params) {
2102
- return (await client.putJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}`, {
2103
- id,
2104
- status: "current",
2105
- title: params.title,
2106
- body: {
2107
- representation: "atlas_doc_format",
2108
- value: JSON.stringify(params.body)
2109
- },
2110
- version: {
2111
- number: params.nextVersion,
2112
- message: params.message
2517
+ function withUploadedIds(doc, ids) {
2518
+ if (!doc.content) return doc;
2519
+ return {
2520
+ ...doc,
2521
+ content: doc.content.map((n) => withUploadedId(n, ids))
2522
+ };
2523
+ }
2524
+ function withUploadedId(node, ids) {
2525
+ const id = node.type === "media" ? node.attrs?.["id"] : void 0;
2526
+ const swapped = typeof id === "string" && ids.has(id) ? {
2527
+ ...node,
2528
+ attrs: {
2529
+ ...node.attrs,
2530
+ id: ids.get(id)
2113
2531
  }
2114
- })).version?.number ?? params.nextVersion;
2532
+ } : node;
2533
+ if (!swapped.content) return swapped;
2534
+ return {
2535
+ ...swapped,
2536
+ content: swapped.content.map((n) => withUploadedId(n, ids))
2537
+ };
2115
2538
  }
2116
- async function fetchSpaceKey(client, spaceId) {
2117
- if (!spaceId) return "";
2118
- try {
2119
- return (await client.getJson(`/wiki/api/v2/spaces/${encodeURIComponent(spaceId)}`)).key ?? "";
2120
- } catch {
2121
- return "";
2539
+ //#endregion
2540
+ //#region src/commands/confluence-push.ts
2541
+ async function pushPage({ session, term, files }, content, push) {
2542
+ const ids = /* @__PURE__ */ new Map();
2543
+ for (const upload of content.uploads) {
2544
+ term.err(`Uploading ${upload.filename} ...`);
2545
+ const bytes = await files.readBytes(upload.path);
2546
+ ids.set(upload.href, await uploadAttachment(session, push.id, upload.filename, bytes));
2122
2547
  }
2548
+ return updatePage(session, push.id, {
2549
+ title: content.title,
2550
+ nextVersion: push.nextVersion,
2551
+ body: withUploadedIds(content.body, ids),
2552
+ message: push.message
2553
+ });
2123
2554
  }
2124
- async function fetchComments$1(client, id, names) {
2125
- const res = await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}/footer-comments?body-format=atlas_doc_format&limit=250`);
2126
- return Promise.all(res.results.map(async (c) => ({
2127
- author: await names.resolve(c.version?.authorId),
2128
- created: c.version?.createdAt ?? "",
2129
- body: parseAdfJson(c.body?.atlas_doc_format?.value)
2130
- })));
2555
+ async function statLocalImages(files, dir, md) {
2556
+ const hrefs = imageHrefs(md).filter((href) => !isExternalHref(href));
2557
+ return Promise.all(hrefs.map(async (href) => {
2558
+ const path = isAbsolute(href) ? href : resolve(dir, href);
2559
+ return {
2560
+ href,
2561
+ path,
2562
+ filename: basename(path),
2563
+ ...await fileSize(files, path)
2564
+ };
2565
+ }));
2131
2566
  }
2132
- function parseAdfJson(value) {
2133
- if (!value) return null;
2567
+ async function fileSize(files, path) {
2134
2568
  try {
2135
- return JSON.parse(value);
2569
+ return { size: await files.size(path) };
2136
2570
  } catch {
2137
- return null;
2571
+ return {};
2138
2572
  }
2139
2573
  }
2140
- var UserNames = class {
2141
- client;
2142
- cache = /* @__PURE__ */ new Map();
2143
- constructor(client) {
2144
- this.client = client;
2145
- }
2146
- async resolve(accountId) {
2147
- if (!accountId) return "";
2148
- const cached = this.cache.get(accountId);
2149
- if (cached !== void 0) return cached;
2150
- const name = await this.fetchDisplayName(accountId) ?? accountId;
2151
- this.cache.set(accountId, name);
2152
- return name;
2153
- }
2154
- async fetchDisplayName(accountId) {
2155
- try {
2156
- return (await this.client.getJson(`/wiki/rest/api/user?accountId=${encodeURIComponent(accountId)}`)).displayName || null;
2157
- } catch {
2158
- return null;
2159
- }
2160
- }
2161
- };
2162
2574
  //#endregion
2163
- //#region src/util/html.ts
2164
- const MAX_CODE_POINT = 1114111;
2165
- const NAMED = {
2166
- amp: "&",
2167
- lt: "<",
2168
- gt: ">",
2169
- quot: "\"",
2170
- apos: "'",
2171
- nbsp: " "
2172
- };
2173
- function decodeEntities(text) {
2174
- return text.replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (match, body) => {
2175
- if (body[0] === "#") {
2176
- const code = body[1] === "x" || body[1] === "X" ? Number.parseInt(body.slice(2), 16) : Number.parseInt(body.slice(1), 10);
2177
- return isCodePoint(code) ? String.fromCodePoint(code) : match;
2178
- }
2179
- return NAMED[body.toLowerCase()] ?? match;
2180
- });
2181
- }
2182
- function isCodePoint(code) {
2183
- return Number.isFinite(code) && code >= 0 && code <= MAX_CODE_POINT;
2575
+ //#region src/api/confluence-favourites.ts
2576
+ function favouritePath(id) {
2577
+ return `/wiki/rest/api/relation/favourite/from/user/current/to/content/${encodeURIComponent(id)}`;
2184
2578
  }
2185
- //#endregion
2186
- //#region src/api/confluence-search.ts
2187
- async function searchPages(client, site, params) {
2188
- const cql = buildCql(params);
2189
- const query = new URLSearchParams({
2190
- cql,
2191
- limit: String(params.limit),
2192
- expand: "space"
2193
- });
2194
- const results = (await client.getJson(`/wiki/rest/api/search?${query.toString()}`)).results ?? [];
2195
- const serverPageWasFull = results.length === params.limit;
2196
- return {
2197
- pages: results.filter((r) => r.content?.id).map((r) => ({
2198
- id: r.content?.id ?? "",
2199
- space: r.space?.key ?? r.resultGlobalContainer?.title ?? "",
2200
- title: decodeEntities(r.content?.title ?? r.title ?? ""),
2201
- updated: r.lastModified ?? "",
2202
- url: r.url ? `${site}/wiki${r.url}` : ""
2203
- })),
2204
- hasMore: serverPageWasFull
2205
- };
2579
+ async function starPage(client, id) {
2580
+ await client.putNoContent(favouritePath(id), void 0);
2206
2581
  }
2207
- function buildCql(params) {
2208
- if (params.cql) return params.cql;
2209
- const clauses = ["type = page"];
2210
- if (params.starred) clauses.push("favourite = currentUser()");
2211
- if (params.space) clauses.push(`space = ${cqlStringLiteral(params.space)}`);
2212
- if (params.text) clauses.push(`text ~ ${cqlStringLiteral(params.text)}`);
2213
- return `${clauses.join(" AND ")} ORDER BY lastmodified DESC`;
2214
- }
2215
- function cqlStringLiteral(value) {
2216
- return `"${value.replace(/(["\\])/g, "\\$1")}"`;
2582
+ async function unstarPage(client, id) {
2583
+ await client.deleteNoContent(favouritePath(id));
2217
2584
  }
2218
2585
  //#endregion
2219
2586
  //#region src/commands/resolve-ref.ts
@@ -2227,43 +2594,138 @@ async function resolveRef(ask, arg, kind) {
2227
2594
  if (!ref) throw new Error(kind.notFound(raw));
2228
2595
  return ref;
2229
2596
  }
2597
+ function layoutOf(raw) {
2598
+ return {
2599
+ bom: raw.startsWith(""),
2600
+ crlf: /\r?\n/.exec(raw)?.[0] === "\r\n"
2601
+ };
2602
+ }
2603
+ function normalized(raw) {
2604
+ return (raw.startsWith("") ? raw.slice(1) : raw).replace(/\r\n/g, "\n");
2605
+ }
2606
+ function editKeepingLayout(raw, edit) {
2607
+ return withLayout(edit(normalized(raw)), layoutOf(raw));
2608
+ }
2609
+ function withLayout(text, layout) {
2610
+ const lines = layout.crlf ? text.replace(/\n/g, "\r\n") : text;
2611
+ return layout.bom ? `${lines}` : lines;
2612
+ }
2230
2613
  //#endregion
2231
- //#region src/api/attachments.ts
2232
- function mediaResolver(downloaded) {
2233
- const byMediaId = new Map(downloaded.map((d) => [d.mediaId, d.relativePath]));
2234
- const byFilename = new Map(downloaded.map((d) => [d.filename, d.relativePath]));
2235
- return (media) => {
2236
- if (media.id && byMediaId.has(media.id)) return byMediaId.get(media.id);
2237
- if (media.alt && byFilename.has(media.alt)) return byFilename.get(media.alt);
2614
+ //#region src/markdown/frontmatter.ts
2615
+ const BLOCK = /^---\n([\s\S]*?)\n---\n?/;
2616
+ const KEY = /^([A-Za-z0-9_]+):\s*(.*)$/;
2617
+ const LIST_ITEM = /^ {2}- (.*)$/;
2618
+ const BARE_NUMBER = /^-?\d+(\.\d+)?$/;
2619
+ function splitFrontmatter(text) {
2620
+ const match = text.match(BLOCK);
2621
+ if (!match) return null;
2622
+ return {
2623
+ fields: parseFields(match[1] ?? ""),
2624
+ rest: text.slice(match[0].length)
2238
2625
  };
2239
2626
  }
2627
+ function formatFrontmatter(fields) {
2628
+ return [
2629
+ "---",
2630
+ ...Object.entries(fields).flatMap(([key, value]) => fieldLines(key, value)),
2631
+ "---"
2632
+ ].join("\n");
2633
+ }
2634
+ function rewriteFields(raw, fields) {
2635
+ return editKeepingLayout(raw, (text) => withFields(text, fields));
2636
+ }
2637
+ function withFields(text, fields) {
2638
+ const match = text.match(BLOCK);
2639
+ if (!match) return `${formatFrontmatter(fields)}\n\n${text}`;
2640
+ const lines = (match[1] ?? "").split("\n");
2641
+ const pending = new Map(Object.entries(fields));
2642
+ const kept = [];
2643
+ for (let i = 0; i < lines.length; i++) {
2644
+ const line = lines[i] ?? "";
2645
+ const key = line.match(KEY)?.[1];
2646
+ const value = key === void 0 ? void 0 : pending.get(key);
2647
+ if (key === void 0 || value === void 0) {
2648
+ kept.push(line);
2649
+ continue;
2650
+ }
2651
+ kept.push(...fieldLines(key, value));
2652
+ pending.delete(key);
2653
+ while (LIST_ITEM.test(lines[i + 1] ?? "")) i++;
2654
+ }
2655
+ for (const [key, value] of pending) kept.push(...fieldLines(key, value));
2656
+ const closing = match[0].endsWith("\n") ? "\n" : "";
2657
+ return `---\n${kept.join("\n")}\n---${closing}${text.slice(match[0].length)}`;
2658
+ }
2659
+ function fieldLines(key, value) {
2660
+ if (Array.isArray(value)) return value.length === 0 ? [`${key}: []`] : [`${key}:`, ...value.map((v) => ` - ${quote$1(v)}`)];
2661
+ return [`${key}: ${typeof value === "number" ? value : quote$1(value)}`];
2662
+ }
2663
+ function parseFields(block) {
2664
+ const fields = {};
2665
+ const lines = block.split("\n");
2666
+ for (let i = 0; i < lines.length; i++) {
2667
+ const m = lines[i]?.match(KEY);
2668
+ if (!m) continue;
2669
+ const key = m[1] ?? "";
2670
+ const raw = (m[2] ?? "").trim();
2671
+ if (raw === "[]") fields[key] = [];
2672
+ else if (raw === "") {
2673
+ const items = [];
2674
+ for (let item; item = lines[i + 1]?.match(LIST_ITEM); i++) items.push(unquote((item[1] ?? "").trim()));
2675
+ fields[key] = items;
2676
+ } else fields[key] = scalar(raw);
2677
+ }
2678
+ return fields;
2679
+ }
2680
+ function scalar(raw) {
2681
+ if (raw.startsWith("\"")) return unquote(raw);
2682
+ if (BARE_NUMBER.test(raw)) return Number(raw);
2683
+ return raw;
2684
+ }
2685
+ function quote$1(value) {
2686
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
2687
+ }
2688
+ function unquote(value) {
2689
+ if (!value.startsWith("\"") || !value.endsWith("\"")) return value;
2690
+ return value.slice(1, -1).replace(/\\"/g, "\"").replace(/\\\\/g, "\\");
2691
+ }
2240
2692
  //#endregion
2241
2693
  //#region src/markdown/copied-document.ts
2694
+ const GENERATED_MARKER = "<!-- atlass:generated -->";
2242
2695
  const COMMENTS_HEADING = "## Comments";
2243
2696
  const ATTACHMENTS_HEADING = "## Attachments";
2244
- const FRONTMATTER_BLOCK = /^---\n([\s\S]*?)\n---\n?/;
2245
- const FRONTMATTER_KEY = /^([A-Za-z0-9_]+):\s*(.*)$/;
2246
- const FRONTMATTER_LIST_ITEM = /^ {2}- (.*)$/;
2247
- const BARE_NUMBER = /^-?\d+(\.\d+)?$/;
2248
- const TRAILING_SECTION_HEADING = /^## (Comments|Attachments)\s*$/;
2697
+ const LEGACY_SECTION_HEADING = /^## (Comments|Attachments)\s*$/;
2249
2698
  function render(doc) {
2250
2699
  return `${[
2251
- frontmatter(doc.fields),
2700
+ formatFrontmatter(doc.fields),
2252
2701
  `# ${doc.title}`,
2253
2702
  doc.body,
2703
+ GENERATED_MARKER,
2254
2704
  commentsSection(doc.comments),
2255
2705
  attachmentsSection(doc.attachments)
2256
2706
  ].filter((s) => s.trim().length > 0).join("\n\n")}\n`;
2257
2707
  }
2258
- function parse(text) {
2259
- const match = text.match(FRONTMATTER_BLOCK);
2260
- if (!match) throw new Error("Not an atlass file: no YAML frontmatter found.");
2261
- const fields = parseFrontmatter(match[1] ?? "");
2262
- const lines = text.slice(match[0].length).split("\n");
2708
+ function parse(raw) {
2709
+ const split = splitFrontmatter(normalized(raw));
2710
+ if (!split) throw new Error("Not an atlass file: no YAML frontmatter found.");
2711
+ return sourceOf(split, "marker-or-legacy-heading");
2712
+ }
2713
+ function parseDraft(raw) {
2714
+ const text = normalized(raw);
2715
+ return sourceOf(splitFrontmatter(text) ?? {
2716
+ fields: {},
2717
+ rest: text
2718
+ }, "marker");
2719
+ }
2720
+ function withGeneratedMarker(text) {
2721
+ if (text.split("\n").some(isMarker)) return text;
2722
+ return `${text.trimEnd()}\n\n${GENERATED_MARKER}\n`;
2723
+ }
2724
+ function sourceOf({ fields, rest }, ending) {
2725
+ const lines = rest.split("\n");
2263
2726
  const h1 = leadingH1(lines);
2264
2727
  const start = h1 ? h1.index + 1 : 0;
2265
- const trailing = lines.findIndex((line, i) => i >= start && TRAILING_SECTION_HEADING.test(line));
2266
- const end = trailing === -1 ? lines.length : trailing;
2728
+ const end = bodyEnd(lines, start, ending);
2267
2729
  const fallbackTitle = typeof fields["title"] === "string" ? fields["title"] : "";
2268
2730
  return {
2269
2731
  fields,
@@ -2299,45 +2761,6 @@ function scalarString(value) {
2299
2761
  if (typeof value === "number") return String(value);
2300
2762
  return "";
2301
2763
  }
2302
- function frontmatter(fields) {
2303
- const lines = ["---"];
2304
- for (const [key, value] of Object.entries(fields)) if (Array.isArray(value)) {
2305
- if (value.length === 0) lines.push(`${key}: []`);
2306
- else lines.push(`${key}:`, ...value.map((v) => ` - ${quote(v)}`));
2307
- } else if (typeof value === "number") lines.push(`${key}: ${value}`);
2308
- else lines.push(`${key}: ${quote(value)}`);
2309
- lines.push("---");
2310
- return lines.join("\n");
2311
- }
2312
- function parseFrontmatter(block) {
2313
- const fields = {};
2314
- const lines = block.split("\n");
2315
- for (let i = 0; i < lines.length; i++) {
2316
- const m = lines[i]?.match(FRONTMATTER_KEY);
2317
- if (!m) continue;
2318
- const key = m[1] ?? "";
2319
- const raw = (m[2] ?? "").trim();
2320
- if (raw === "[]") fields[key] = [];
2321
- else if (raw === "") {
2322
- const items = [];
2323
- for (let item; item = lines[i + 1]?.match(FRONTMATTER_LIST_ITEM); i++) items.push(unquote((item[1] ?? "").trim()));
2324
- fields[key] = items;
2325
- } else fields[key] = scalar(raw);
2326
- }
2327
- return fields;
2328
- }
2329
- function scalar(raw) {
2330
- if (raw.startsWith("\"")) return unquote(raw);
2331
- if (BARE_NUMBER.test(raw)) return Number(raw);
2332
- return raw;
2333
- }
2334
- function quote(value) {
2335
- return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
2336
- }
2337
- function unquote(value) {
2338
- if (!value.startsWith("\"") || !value.endsWith("\"")) return value;
2339
- return value.slice(1, -1).replace(/\\"/g, "\"").replace(/\\\\/g, "\\");
2340
- }
2341
2764
  function leadingH1(lines) {
2342
2765
  for (const [index, line] of lines.entries()) {
2343
2766
  if (line.startsWith("# ")) return {
@@ -2348,6 +2771,16 @@ function leadingH1(lines) {
2348
2771
  }
2349
2772
  return null;
2350
2773
  }
2774
+ function bodyEnd(lines, start, ending) {
2775
+ const after = (test) => lines.findIndex((line, i) => i >= start && test(line));
2776
+ const marker = after(isMarker);
2777
+ if (marker !== -1) return marker;
2778
+ const legacy = ending === "marker" ? -1 : after((line) => LEGACY_SECTION_HEADING.test(line));
2779
+ return legacy === -1 ? lines.length : legacy;
2780
+ }
2781
+ function isMarker(line) {
2782
+ return line.trimEnd() === GENERATED_MARKER;
2783
+ }
2351
2784
  function commentsSection(comments) {
2352
2785
  if (comments.length === 0) return "";
2353
2786
  const blocks = comments.map((c) => {
@@ -2370,6 +2803,44 @@ function attachmentsSection(attachments) {
2370
2803
  ].join("\n");
2371
2804
  }
2372
2805
  //#endregion
2806
+ //#region src/commands/page-ref.ts
2807
+ const PAGE_REF = {
2808
+ message: "Confluence page id or URL:",
2809
+ flag: "[page]",
2810
+ parse: parsePageId,
2811
+ notFound: (raw) => `Could not find a page id in "${raw}".`
2812
+ };
2813
+ async function pageIdOrFile({ term, files }, arg) {
2814
+ if (!arg?.endsWith(".md")) return resolveRef(term.ask, arg, PAGE_REF);
2815
+ const id = scalarString(parse(await files.readText(arg)).fields["id"]);
2816
+ if (!id) throw new Error(`${arg} has no page \`id\` in its frontmatter.`);
2817
+ return id;
2818
+ }
2819
+ //#endregion
2820
+ //#region src/commands/confluence-star.ts
2821
+ async function confluenceStar(env, arg) {
2822
+ await starById(env, await pageIdOrFile(env, arg));
2823
+ }
2824
+ async function starById({ session, term }, id) {
2825
+ await starPage(session, id);
2826
+ term.out(`Starred page ${id}.`);
2827
+ }
2828
+ async function confluenceUnstar(env, arg) {
2829
+ const id = await pageIdOrFile(env, arg);
2830
+ await unstarPage(env.session, id);
2831
+ env.term.out(`Unstarred page ${id}.`);
2832
+ }
2833
+ //#endregion
2834
+ //#region src/api/attachments.ts
2835
+ function mediaResolver(downloaded) {
2836
+ const byMediaId = new Map(downloaded.map((d) => [d.mediaId, d.relativePath]));
2837
+ const byFilename = new Map(downloaded.map((d) => [d.filename, d.relativePath]));
2838
+ return (media) => {
2839
+ if (media.id && byMediaId.has(media.id)) return byMediaId.get(media.id);
2840
+ if (media.alt && byFilename.has(media.alt)) return byFilename.get(media.alt);
2841
+ };
2842
+ }
2843
+ //#endregion
2373
2844
  //#region src/copy/plan.ts
2374
2845
  function planIssueCopy(issue, out) {
2375
2846
  return planCopy({
@@ -2392,19 +2863,22 @@ function planIssueCopy(issue, out) {
2392
2863
  attachments: issue.attachments
2393
2864
  }, out);
2394
2865
  }
2866
+ function pageFields(page) {
2867
+ return {
2868
+ title: page.title,
2869
+ id: page.id,
2870
+ space: page.spaceKey,
2871
+ version: page.version,
2872
+ author: page.author,
2873
+ created: page.createdAt,
2874
+ updated: page.updatedAt,
2875
+ url: page.url
2876
+ };
2877
+ }
2395
2878
  function planPageCopy(page, out) {
2396
2879
  return planCopy({
2397
2880
  baseName: `${page.id}-${slugify(page.title)}`,
2398
- fields: {
2399
- title: page.title,
2400
- id: page.id,
2401
- space: page.spaceKey,
2402
- version: page.version,
2403
- author: page.author,
2404
- created: page.createdAt,
2405
- updated: page.updatedAt,
2406
- url: page.url
2407
- },
2881
+ fields: pageFields(page),
2408
2882
  title: page.title,
2409
2883
  body: page.body,
2410
2884
  comments: page.comments,
@@ -2475,246 +2949,235 @@ function numberedName(name, suffix) {
2475
2949
  return `${dot > 0 ? name.slice(0, dot) : name}-${suffix}${dot > 0 ? name.slice(dot) : ""}`;
2476
2950
  }
2477
2951
  //#endregion
2478
- //#region src/copy/run.ts
2479
- async function runCopy(env, plan) {
2480
- const landed = await download(env, plan);
2481
- await env.files.writeText(plan.filePath, renderCopy(plan, landed));
2482
- env.term.out(wroteLine(plan.filePath, landed.length));
2483
- }
2484
- async function download({ term, files, session }, plan) {
2485
- const landed = [];
2486
- for (const { path, ...attachment } of plan.downloads) try {
2487
- await files.writeBytes(path, await session.getBinary(attachment.url));
2488
- landed.push(attachment);
2489
- } catch (err) {
2490
- term.err(` ! could not download ${attachment.filename}: ${err.message}`);
2491
- }
2492
- return landed;
2493
- }
2494
- function wroteLine(filePath, attachmentCount) {
2495
- return `Wrote ${filePath}${attachmentCount > 0 ? ` (+${attachmentCount} attachment${attachmentCount === 1 ? "" : "s"})` : ""}`;
2496
- }
2497
- //#endregion
2498
- //#region src/adf/lossy.ts
2499
- const LOSSY_LABELS = {
2500
- panel: "panel",
2501
- expand: "expand",
2502
- nestedExpand: "expand",
2503
- decisionList: "decision list",
2504
- layoutSection: "layout",
2505
- extension: "macro",
2506
- bodiedExtension: "macro",
2507
- inlineExtension: "macro"
2508
- };
2509
- const JIRA_LOSSY_LABELS = {
2510
- ...LOSSY_LABELS,
2511
- media: "image",
2512
- mediaInline: "image"
2513
- };
2514
- function findLossyNodes(node, lossyLabels = LOSSY_LABELS) {
2515
- const counts = /* @__PURE__ */ new Map();
2516
- const visit = (n) => {
2517
- const label = lossyLabels[n.type];
2518
- if (label) counts.set(label, (counts.get(label) ?? 0) + 1);
2519
- for (const child of n.content ?? []) visit(child);
2520
- };
2521
- if (node) visit(node);
2522
- return counts;
2523
- }
2524
- function formatLossy(counts) {
2525
- return [...counts.entries()].map(([label, n]) => `${n} ${label}${n === 1 ? "" : "s"}`).join(", ");
2526
- }
2527
- //#endregion
2528
- //#region src/update/plan.ts
2529
- function planIssueUpdate(source, issue, options) {
2530
- const images = imageHrefs(source.body).map((href) => ({
2952
+ //#region src/create/page-plan.ts
2953
+ function planPageCreate(draft, target, localImages) {
2954
+ const images = imageHrefs(draft.body).map((href) => ({
2531
2955
  href,
2532
- kind: isExternalHref(href) ? "external" : "unsupported"
2533
- }));
2534
- const unsupported = images.filter((i) => i.kind === "unsupported").map((i) => i.href);
2535
- const body = markdownToAdf(source.body, { resolveImage: (href, alt) => isExternalHref(href) ? externalMedia(href, alt) : void 0 });
2536
- 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)];
2537
- return withVerdict({
2538
- noun: "issue",
2539
- id: source.key,
2540
- headline: headline("summary", issue.summary, source.title, options.summary),
2541
- revision: revision(source.updatedAtCopy, issue.updated),
2542
- lossy: findLossyNodes(issue.description, JIRA_LOSSY_LABELS),
2543
- images,
2544
- uploads: [],
2545
- body,
2546
- refusals
2547
- }, options.force ?? false);
2548
- }
2549
- function formatPlan(plan) {
2550
- const lines = [`Dry run for ${plan.noun} ${plan.id} "${plan.headline.current}"`];
2551
- if (plan.headline.next !== plan.headline.current) lines.push(row(plan.headline.label, `"${plan.headline.current}" -> "${plan.headline.next}"`));
2552
- const counts = imageCounts(plan.images);
2553
- if (counts) lines.push(row("images", counts));
2554
- for (const refusal of plan.refusals) lines.push(row("blocked", refusal));
2555
- if (plan.lossy.size > 0) lines.push(row("warning", `${formatLossy(plan.lossy)} will be removed`));
2556
- if (plan.revision.stale) lines.push(row("stale", `${revisionText(plan.revision)} (would refuse without --force)`));
2557
- lines.push(" nothing was written (dry run)");
2558
- return lines;
2559
- }
2560
- const IMAGE_COUNT_LABELS = [
2561
- ["upload", "new"],
2562
- ["changed", "changed"],
2563
- ["reuse", "reused"],
2564
- ["external", "external"],
2565
- ["unsupported", "unsupported"],
2566
- ["missing", "missing"]
2567
- ];
2568
- function imageCounts(images) {
2569
- return IMAGE_COUNT_LABELS.map(([kind, label]) => ({
2570
- n: images.filter((i) => i.kind === kind).length,
2571
- label
2572
- })).filter(({ n }) => n > 0).map(({ n, label }) => `${n} ${label}`).join(", ");
2573
- }
2574
- function row(label, value) {
2575
- return ` ${`${label}:`.padEnd(9)}${value}`;
2576
- }
2577
- function headline(label, current, title, useTitle) {
2578
- return {
2579
- label,
2580
- current,
2581
- next: useTitle && title ? title : current
2582
- };
2583
- }
2584
- function revision(local, server) {
2956
+ kind: imageKind(href, localImages)
2957
+ }));
2958
+ const missing = images.filter((i) => i.kind === "missing").map((i) => i.href);
2585
2959
  return {
2586
- local: local || "unknown",
2587
- server: server || "unknown",
2588
- stale: local !== server
2960
+ ...target,
2961
+ title: draft.title,
2962
+ images,
2963
+ body: markdownToAdf(draft.body, { resolveImage: externalImagesOnly }),
2964
+ warnings: htmlWarnings(draft.body),
2965
+ refusals: [
2966
+ ...draft.title ? [] : ["No title. Add an H1 or a `title` to the frontmatter."],
2967
+ ...spaceRefusal(target),
2968
+ ...missingImagesRefusal(missing)
2969
+ ]
2589
2970
  };
2590
2971
  }
2591
- function revisionText(revision) {
2592
- return `copied at ${revision.local}, server now ${revision.server}`;
2972
+ function formatCreatePlan(plan) {
2973
+ const lines = [`Dry run for new page "${plan.title}"`];
2974
+ if (plan.spaceId) lines.push(row("space", plan.space));
2975
+ lines.push(row("parent", plan.parentId ?? "space homepage"));
2976
+ const counts = imageCounts(plan.images);
2977
+ if (counts) lines.push(row("images", counts));
2978
+ for (const refusal of plan.refusals) lines.push(row("blocked", refusal));
2979
+ for (const warning of plan.warnings) lines.push(row("warning", warning));
2980
+ lines.push(" nothing was written (dry run)");
2981
+ return lines;
2593
2982
  }
2594
- function emptyBodyRefusal(body) {
2595
- if (body.content && body.content.length > 0) return [];
2596
- return ["Refusing to update: the converted body is empty."];
2983
+ function imageKind(href, localImages) {
2984
+ if (isExternalHref(href)) return "external";
2985
+ return localImages.find((l) => l.href === href)?.size === void 0 ? "missing" : "upload";
2597
2986
  }
2598
- function withVerdict(plan, force) {
2599
- return {
2600
- ...plan,
2601
- verdict: verdict(plan, force)
2602
- };
2987
+ function spaceRefusal({ space, spaceId }) {
2988
+ if (!space) return ["No space. Pass --space or add `space` to the frontmatter."];
2989
+ return spaceId ? [] : [`Space "${space}" not found.`];
2603
2990
  }
2604
- function verdict(plan, force) {
2605
- if (plan.refusals.length > 0) return {
2606
- kind: "refuse",
2607
- message: plan.refusals.join("\n")
2608
- };
2609
- const noun = plan.noun;
2610
- if (plan.revision.stale && !force) return {
2611
- kind: "refuse",
2612
- message: `${capitalize(noun)} changed on the server since you copied it (${revisionText(plan.revision)}). Re-copy the ${noun} or pass --force.`
2613
- };
2614
- if (plan.lossy.size > 0 && !force) return {
2615
- kind: "confirm",
2616
- message: `This ${noun} contains ${formatLossy(plan.lossy)} that Markdown cannot represent and will be removed. Continue?`
2991
+ //#endregion
2992
+ //#region src/commands/confluence-create.ts
2993
+ async function confluenceCreate(env, arg, options) {
2994
+ const { session, term, files } = env;
2995
+ const file = arg ?? await term.ask.text({
2996
+ message: "Path to the Markdown file:",
2997
+ flag: "[file]",
2998
+ required: true
2999
+ });
3000
+ const raw = await files.readText(file);
3001
+ const draft = parseDraft(raw);
3002
+ const existing = scalarString(draft.fields["id"]);
3003
+ if (existing) throw new Error(`${file} already has page id ${existing}. Use \`atlass confluence update\` instead.`);
3004
+ const parent = parentOf(options.parent);
3005
+ const space = options.space ?? scalarString(draft.fields["space"]);
3006
+ const spaceId = space ? await findSpaceId(session, space) : null;
3007
+ const localImages = await statLocalImages(files, dirname(resolve(file)), draft.body);
3008
+ const plan = planPageCreate(draft, {
3009
+ space,
3010
+ spaceId,
3011
+ ...parent
3012
+ }, localImages);
3013
+ if (options.dryRun) term.out(formatCreatePlan(plan));
3014
+ if (plan.refusals.length > 0 || !plan.spaceId) throw new Error(plan.refusals.join("\n"));
3015
+ if (options.dryRun) return;
3016
+ for (const warning of plan.warnings) term.err(`warning: ${warning}`);
3017
+ const created = await createPage(session, {
3018
+ ...plan,
3019
+ spaceId: plan.spaceId
3020
+ });
3021
+ const createdFields = {
3022
+ title: plan.title,
3023
+ id: created.id,
3024
+ space,
3025
+ version: created.version
2617
3026
  };
2618
- return { kind: "proceed" };
3027
+ await files.writeText(file, editKeepingLayout(raw, (text) => withGeneratedMarker(withFields(text, createdFields))));
3028
+ if (plan.images.some((i) => i.kind === "upload")) await addImages(env, draft, created, localImages);
3029
+ const page = await fetchPage(session, session.site, created.id);
3030
+ await files.writeText(file, rewriteFields(await files.readText(file), pageFields(page)));
3031
+ term.out(`Created page ${page.id} in ${page.spaceKey}: ${page.url}`);
3032
+ if (options.star) await starCreated(env, page.id, file);
3033
+ }
3034
+ function parentOf(parent) {
3035
+ if (parent === void 0) return {};
3036
+ const parentId = parsePageId(parent);
3037
+ if (!parentId) throw new Error(`Could not find a page id in --parent "${parent}".`);
3038
+ return { parentId };
3039
+ }
3040
+ async function addImages(env, draft, created, localImages) {
3041
+ const { body, uploads } = planPageBody({
3042
+ id: created.id,
3043
+ body: draft.body
3044
+ }, [], localImages);
3045
+ await pushPage(env, {
3046
+ title: draft.title,
3047
+ body,
3048
+ uploads
3049
+ }, {
3050
+ id: created.id,
3051
+ nextVersion: created.version + 1,
3052
+ message: "Added images via atlass"
3053
+ });
2619
3054
  }
2620
- function capitalize(text) {
2621
- return text.charAt(0).toUpperCase() + text.slice(1);
3055
+ async function starCreated(env, id, file) {
3056
+ try {
3057
+ await starById(env, id);
3058
+ } catch (err) {
3059
+ const reason = err instanceof Error ? err.message : String(err);
3060
+ throw new Error(`Created page ${id} but could not star it: ${reason}. Run \`atlass confluence star ${file}\`.`);
3061
+ }
2622
3062
  }
2623
3063
  //#endregion
2624
- //#region src/update/plan-page.ts
2625
- function planPageUpdate(source, state, attachments, localImages, options) {
2626
- const collection = `contentId-${source.id}`;
2627
- const entries = imageHrefs(source.body).map((href) => pageImage(href, localImages, attachments));
2628
- const byHref = new Map(entries.map((e) => [e.href, e]));
2629
- const body = markdownToAdf(source.body, { resolveImage: (href, alt) => {
2630
- const entry = byHref.get(href);
2631
- if (!entry || entry.kind === "missing") return void 0;
2632
- if (entry.kind === "external") return externalMedia(href, alt);
2633
- return mediaNode({
2634
- type: "file",
2635
- id: entry.kind === "reuse" ? entry.fileId : href,
2636
- collection
2637
- }, alt);
2638
- } });
2639
- const missing = entries.filter((e) => e.kind === "missing").map((e) => e.href);
2640
- const refusals = [...missing.length > 0 ? [`Image file(s) not found: ${missing.join(", ")}`] : [], ...emptyBodyRefusal(body)];
2641
- return withVerdict({
2642
- noun: "page",
2643
- id: source.id,
2644
- headline: headline("title", state.title, source.title, options.title),
2645
- revision: revision(`v${source.version}`, `v${state.version}`),
2646
- lossy: findLossyNodes(state.body),
2647
- images: entries.map(({ href, kind }) => ({
2648
- href,
2649
- kind
2650
- })),
2651
- uploads: entries.flatMap((e) => e.kind === "upload" || e.kind === "changed" ? [{
2652
- href: e.href,
2653
- path: e.path,
2654
- filename: e.filename
2655
- }] : []),
2656
- body,
2657
- refusals
2658
- }, options.force ?? false);
3064
+ //#region src/api/query.ts
3065
+ function quote(value) {
3066
+ return `"${value.replace(/(["\\])/g, "\\$1")}"`;
2659
3067
  }
2660
- function pageImage(href, localImages, attachments) {
2661
- if (isExternalHref(href)) return {
2662
- href,
2663
- kind: "external"
2664
- };
2665
- const local = localImages.find((l) => l.href === href);
2666
- if (!local || local.size === void 0) return {
2667
- href,
2668
- kind: "missing"
2669
- };
2670
- const existing = attachments.find((a) => a.filename === local.filename);
2671
- if (existing && existing.size === local.size) return {
2672
- href,
2673
- kind: "reuse",
2674
- fileId: existing.fileId
2675
- };
2676
- return {
2677
- href,
2678
- kind: existing ? "changed" : "upload",
2679
- path: local.path,
2680
- filename: local.filename
2681
- };
3068
+ function values(given) {
3069
+ return (given ?? []).map((v) => v.trim()).filter((v) => v.length > 0);
2682
3070
  }
2683
- function withUploadedIds(doc, ids) {
2684
- if (!doc.content) return doc;
2685
- return {
2686
- ...doc,
2687
- content: doc.content.map((n) => withUploadedId(n, ids))
2688
- };
3071
+ function inClause(field, given) {
3072
+ return renderClause(field, values(given).map(quote));
2689
3073
  }
2690
- function withUploadedId(node, ids) {
2691
- const id = node.type === "media" ? node.attrs?.["id"] : void 0;
2692
- const swapped = typeof id === "string" && ids.has(id) ? {
2693
- ...node,
2694
- attrs: {
2695
- ...node.attrs,
2696
- id: ids.get(id)
3074
+ function userClause(field, given) {
3075
+ return renderClause(field, values(given).map((v) => v === "me" ? "currentUser()" : quote(v)));
3076
+ }
3077
+ function renderClause(field, rendered) {
3078
+ if (rendered.length === 0) return null;
3079
+ if (rendered.length === 1) return `${field} = ${rendered[0]}`;
3080
+ return `${field} in (${rendered.join(", ")})`;
3081
+ }
3082
+ function joinClauses(clauses, order) {
3083
+ return `${clauses.filter((c) => c !== null).join(" AND ")} ORDER BY ${order}`;
3084
+ }
3085
+ //#endregion
3086
+ //#region src/util/html.ts
3087
+ const MAX_CODE_POINT = 1114111;
3088
+ const NAMED = {
3089
+ amp: "&",
3090
+ lt: "<",
3091
+ gt: ">",
3092
+ quot: "\"",
3093
+ apos: "'",
3094
+ nbsp: " "
3095
+ };
3096
+ function decodeEntities(text) {
3097
+ return text.replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (match, body) => {
3098
+ if (body[0] === "#") {
3099
+ const code = body[1] === "x" || body[1] === "X" ? Number.parseInt(body.slice(2), 16) : Number.parseInt(body.slice(1), 10);
3100
+ return isCodePoint(code) ? String.fromCodePoint(code) : match;
2697
3101
  }
2698
- } : node;
2699
- if (!swapped.content) return swapped;
3102
+ return NAMED[body.toLowerCase()] ?? match;
3103
+ });
3104
+ }
3105
+ function isCodePoint(code) {
3106
+ return Number.isFinite(code) && code >= 0 && code <= MAX_CODE_POINT;
3107
+ }
3108
+ //#endregion
3109
+ //#region src/api/confluence-search.ts
3110
+ async function searchPages(client, site, params) {
3111
+ return searchPagesByCql(client, site, buildCql(params), params.limit);
3112
+ }
3113
+ async function searchPagesByCql(client, site, cql, limit) {
3114
+ const query = new URLSearchParams({
3115
+ cql,
3116
+ limit: String(limit),
3117
+ expand: "content.space"
3118
+ });
3119
+ const results = (await client.getJson(`/wiki/rest/api/search?${query.toString()}`)).results ?? [];
3120
+ const serverPageWasFull = results.length === limit;
2700
3121
  return {
2701
- ...swapped,
2702
- content: swapped.content.map((n) => withUploadedId(n, ids))
3122
+ pages: results.filter((r) => r.content?.id).map((r) => ({
3123
+ id: r.content?.id ?? "",
3124
+ space: r.content?.space?.key ?? spaceKeyOf(r.resultGlobalContainer?.displayUrl),
3125
+ title: decodeEntities(r.content?.title ?? r.title ?? ""),
3126
+ updated: r.lastModified ?? "",
3127
+ url: r.url ? `${site}/wiki${r.url}` : ""
3128
+ })),
3129
+ hasMore: serverPageWasFull
2703
3130
  };
2704
3131
  }
3132
+ const SPACE_URL = /\/spaces\/([^/?#]+)/;
3133
+ function spaceKeyOf(displayUrl) {
3134
+ return SPACE_URL.exec(displayUrl ?? "")?.[1] ?? "";
3135
+ }
3136
+ function buildCql(params) {
3137
+ return joinClauses([
3138
+ "type = page",
3139
+ params.starred ? "favourite = currentUser()" : null,
3140
+ inClause("space", params.space),
3141
+ inClause("label", params.label),
3142
+ params.updatedSince ? `lastmodified >= ${quote(params.updatedSince)}` : null,
3143
+ params.text ? `text ~ ${quote(params.text)}` : null
3144
+ ], "lastmodified DESC");
3145
+ }
3146
+ //#endregion
3147
+ //#region src/copy/run.ts
3148
+ async function runCopy(env, plan) {
3149
+ const landed = await download(env, plan);
3150
+ await env.files.writeText(plan.filePath, renderCopy(plan, landed));
3151
+ env.term.out(wroteLine(plan.filePath, landed.length));
3152
+ }
3153
+ async function download({ term, files, session }, plan) {
3154
+ const landed = [];
3155
+ for (const { path, ...attachment } of plan.downloads) try {
3156
+ await files.writeBytes(path, await session.getBinary(attachment.url));
3157
+ landed.push(attachment);
3158
+ } catch (err) {
3159
+ term.err(` ! could not download ${attachment.filename}: ${err.message}`);
3160
+ }
3161
+ return landed;
3162
+ }
3163
+ function wroteLine(filePath, attachmentCount) {
3164
+ return `Wrote ${filePath}${attachmentCount > 0 ? ` (+${attachmentCount} attachment${attachmentCount === 1 ? "" : "s"})` : ""}`;
3165
+ }
2705
3166
  //#endregion
2706
3167
  //#region src/update/run.ts
2707
3168
  async function runPlan(term, plan, options, push) {
3169
+ const verdict = plan.verdict;
2708
3170
  if (options.dryRun) {
2709
3171
  term.out(formatPlan(plan));
3172
+ if (verdict.kind === "refuse") throw new Error(verdict.message);
3173
+ if (verdict.kind === "confirm" && !term.interactive) throw new NotInteractiveError(forcePrompt(verdict.message));
2710
3174
  return;
2711
3175
  }
2712
- const verdict = plan.verdict;
3176
+ for (const warning of plan.warnings) term.err(`warning: ${warning}`);
2713
3177
  if (verdict.kind === "refuse") throw new Error(verdict.message);
2714
3178
  if (verdict.kind === "confirm") {
2715
3179
  if (!await term.ask.confirm({
2716
- message: verdict.message,
2717
- flag: "--force",
3180
+ ...forcePrompt(verdict.message),
2718
3181
  default: false
2719
3182
  })) {
2720
3183
  term.out("Aborted.");
@@ -2723,6 +3186,12 @@ async function runPlan(term, plan, options, push) {
2723
3186
  }
2724
3187
  await push();
2725
3188
  }
3189
+ function forcePrompt(message) {
3190
+ return {
3191
+ message,
3192
+ flag: "--force"
3193
+ };
3194
+ }
2726
3195
  //#endregion
2727
3196
  //#region src/commands/confluence.ts
2728
3197
  async function confluenceView({ session, term }, arg, options) {
@@ -2733,7 +3202,7 @@ async function confluenceView({ session, term }, arg, options) {
2733
3202
  function formatPageView(page, nowMs, allComments) {
2734
3203
  return [
2735
3204
  kleur.bold(page.title),
2736
- ...fieldLines([
3205
+ ...fieldLines$1([
2737
3206
  ["Space", page.spaceKey],
2738
3207
  ["ID", page.id],
2739
3208
  ["Version", page.version ? String(page.version) : ""],
@@ -2750,79 +3219,80 @@ function formatPageView(page, nowMs, allComments) {
2750
3219
  async function confluenceCopy(env, arg, options) {
2751
3220
  await copyPage(env, await resolveRef(env.term.ask, arg, PAGE_REF), options.out);
2752
3221
  }
2753
- async function confluenceUpdate({ session, term, files }, arg, options) {
3222
+ async function confluenceUpdate(env, arg, options) {
3223
+ const { session, term, files } = env;
2754
3224
  const file = arg ?? await term.ask.text({
2755
3225
  message: "Path to the page Markdown file:",
2756
3226
  flag: "[file]",
2757
3227
  required: true
2758
3228
  });
2759
- const src = parsePageSource(await files.readText(file));
3229
+ const raw = await files.readText(file);
3230
+ const src = parsePageSource(raw);
2760
3231
  const state = await fetchPageState(session, src.id);
2761
- const plan = planPageUpdate(src, state, await listAttachments(session, src.id), await statLocalImages(files, dirname(resolve(file)), src.body), options);
3232
+ const plan = planPageUpdate(src, state, await listAttachments(session, src.id), await statLocalImages(files, dirname(resolve(file)), src.body), {
3233
+ ...options,
3234
+ file
3235
+ });
2762
3236
  await runPlan(term, plan, options, async () => {
2763
- const ids = /* @__PURE__ */ new Map();
2764
- for (const upload of plan.uploads) {
2765
- term.err(`Uploading ${upload.filename} ...`);
2766
- const bytes = await files.readBytes(upload.path);
2767
- ids.set(upload.href, await uploadAttachment(session, src.id, upload.filename, bytes));
2768
- }
2769
- const version = await updatePage(session, src.id, {
3237
+ const version = await pushPage(env, {
2770
3238
  title: plan.headline.next,
3239
+ body: plan.body,
3240
+ uploads: plan.uploads
3241
+ }, {
3242
+ id: src.id,
2771
3243
  nextVersion: state.version + 1,
2772
- body: withUploadedIds(plan.body, ids),
2773
3244
  message: options.message ?? "Updated via atlass"
2774
3245
  });
2775
- term.out(`Updated page ${src.id} to version ${version}.`);
3246
+ term.out(`Updated page ${src.id} to version ${version.number}.`);
3247
+ await files.writeText(file, rewriteFields(raw, pushedFields(plan, version)));
2776
3248
  });
2777
3249
  }
2778
- async function statLocalImages(files, dir, md) {
2779
- const hrefs = imageHrefs(md).filter((href) => !isExternalHref(href));
2780
- return Promise.all(hrefs.map(async (href) => {
2781
- const path = isAbsolute(href) ? href : resolve(dir, href);
2782
- return {
2783
- href,
2784
- path,
2785
- filename: basename(path),
2786
- ...await fileSize(files, path)
2787
- };
2788
- }));
3250
+ function pushedFields(plan, version) {
3251
+ const fields = { version: version.number };
3252
+ if (version.createdAt) fields["updated"] = version.createdAt;
3253
+ if (plan.headline.next !== plan.headline.current) fields["title"] = plan.headline.next;
3254
+ return fields;
2789
3255
  }
2790
- async function fileSize(files, path) {
2791
- try {
2792
- return { size: await files.size(path) };
2793
- } catch {
2794
- return {};
2795
- }
3256
+ async function copyPage(env, id, out) {
3257
+ env.term.err(`Fetching page ${id} ...`);
3258
+ await runCopy(env, planPageCopy(await fetchPage(env.session, env.session.site, id), out));
2796
3259
  }
3260
+ //#endregion
3261
+ //#region src/commands/confluence-search.ts
2797
3262
  async function confluenceSearch(env, query, options) {
2798
- if (options.cql && (query || options.space)) throw new Error("--cql cannot be combined with a text query or --space.");
2799
3263
  await listPages(env, {
2800
3264
  text: query,
2801
3265
  space: options.space,
2802
- cql: options.cql
3266
+ label: options.label,
3267
+ starred: options.starred,
3268
+ updatedSince: options.updated ? parseSince(options.updated, Date.now()) : void 0
2803
3269
  }, "No matching pages.", options);
2804
3270
  }
2805
3271
  async function confluenceList(env, options) {
2806
3272
  await listPages(env, {
2807
3273
  starred: true,
2808
- space: options.space
3274
+ space: options.space ? [options.space] : void 0
2809
3275
  }, options.space ? `No starred pages in ${options.space}.` : "No starred pages.", options);
2810
3276
  }
3277
+ async function confluenceCql(env, query, options) {
3278
+ const limit = checkedLimit(options);
3279
+ const { pages, hasMore } = await searchPagesByCql(env.session, env.session.site, query, limit);
3280
+ await showPages(env, pages, hasMore, limit, options);
3281
+ }
2811
3282
  async function listPages(env, filter, empty, options) {
2812
- const { session, term } = env;
2813
- if (options.json && options.copy) throw new Error("--json and --copy cannot be used together.");
2814
- const limit = parseLimit(options.limit);
2815
- const { pages, hasMore } = await searchPages(session, session.site, {
3283
+ const limit = checkedLimit(options);
3284
+ const { pages, hasMore } = await searchPages(env.session, env.session.site, {
2816
3285
  ...filter,
2817
3286
  limit
2818
3287
  });
2819
- await runSearch(term, formatPageRows(pages, Date.now()), {
2820
- json: options.json,
2821
- copy: options.copy,
2822
- out: options.out,
3288
+ await showPages(env, pages, hasMore, limit, options, empty);
3289
+ }
3290
+ async function showPages(env, pages, hasMore, limit, options, empty = "No matching pages.") {
3291
+ await showRows(env.term, formatPageRows(pages, Date.now()), {
2823
3292
  empty,
2824
- footer: hasMore ? searchFooter(limit) : void 0
2825
- }, PAGE_NOUN, (id) => copyPage(env, id, options.out));
3293
+ hasMore,
3294
+ limit
3295
+ }, options, PAGE_NOUN, (id) => copyPage(env, id, options.out));
2826
3296
  }
2827
3297
  function formatPageRows(pages, nowMs) {
2828
3298
  return alignedRows(pages, nowMs, (p) => ({
@@ -2850,16 +3320,6 @@ const PAGE_NOUN = {
2850
3320
  singular: "page",
2851
3321
  plural: "pages"
2852
3322
  };
2853
- async function copyPage(env, id, out) {
2854
- env.term.err(`Fetching page ${id} ...`);
2855
- await runCopy(env, planPageCopy(await fetchPage(env.session, env.session.site, id), out));
2856
- }
2857
- const PAGE_REF = {
2858
- message: "Confluence page id or URL:",
2859
- flag: "[page]",
2860
- parse: parsePageId,
2861
- notFound: (raw) => `Could not find a page id in "${raw}".`
2862
- };
2863
3323
  //#endregion
2864
3324
  //#region src/cli/confluence.ts
2865
3325
  function registerConfluence(confluence) {
@@ -2867,8 +3327,12 @@ function registerConfluence(confluence) {
2867
3327
  confluence.command("view [page]").description("Show a Confluence page (id or URL) in the terminal").option("--all-comments", "show all comments instead of the last 5").option("--no-pager", "print directly instead of paging long output").action(jiraAction(confluenceView));
2868
3328
  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(jiraAction(confluenceCopy));
2869
3329
  confluence.command("update [file]").description("Update a Confluence page from an edited Markdown file").option("--title", "also push the H1 as the page title").option("-m, --message <text>", "version message (default 'Updated via atlass')").option("-f, --force", "skip the stale-version and data-loss checks").option("--dry-run", "show what would change without writing").action(jiraAction(confluenceUpdate));
2870
- confluence.command("list").description("List pages you starred").option("-s, --space <key>", "limit to a space").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(jiraAction(confluenceList));
2871
- confluence.command("search [query]").description("Search Confluence pages (text query, --space, or --cql)").option("-s, --space <key>", "limit to a space").option("--cql <cql>", "raw CQL query (ignores other filters)").option("-l, --limit <n>", "max results (default 25, max 100)").option("--json", "output results as JSON").option("-c, --copy", "pick results to copy to Markdown").option("-o, --out <dir>", "output directory for --copy").action(jiraAction(confluenceSearch));
3330
+ confluence.command("create [file]").description("Create a Confluence page from a Markdown file").option("-s, --space <key>", "space to create it in (default: the frontmatter space)").option("--parent <page>", "parent page id or URL (default: the space homepage)").option("--star", "star the new page").option("--dry-run", "show what would be created without writing").action(jiraAction(confluenceCreate));
3331
+ confluence.command("star [page]").description("Star a Confluence page (id, URL, or copied file)").action(jiraAction(confluenceStar));
3332
+ confluence.command("unstar [page]").description("Unstar a Confluence page (id, URL, or copied file)").action(jiraAction(confluenceUnstar));
3333
+ outputOptions(confluence.command("list").description("List pages you starred").option("-s, --space <key>", "limit to a space")).action(jiraAction(confluenceList));
3334
+ outputOptions(confluence.command("search [query]").description("Search Confluence pages by text and filters").option("-s, --space <key>", "limit to a space (repeatable)", collect).option("--label <label>", "limit to a label (repeatable)", collect).option("--starred", "limit to pages you starred").option("-u, --updated <when>", "changed since 7d, 2w, 3m, or YYYY-MM-DD").addOption(moved("--cql <cql>", "confluence cql"))).action(jiraAction(confluenceSearch));
3335
+ outputOptions(confluence.command("cql <query>").description("Search Confluence pages with a raw CQL query")).action(jiraAction(confluenceCql));
2872
3336
  return confluence;
2873
3337
  }
2874
3338
  //#endregion
@@ -2937,6 +3401,24 @@ async function searchAssignableUsers(client, project, query) {
2937
3401
  });
2938
3402
  return (await client.getJson(`/rest/api/3/user/assignable/search?${params.toString()}`)).map(toJiraUser);
2939
3403
  }
3404
+ const ACCOUNT_ID = /^(?:[0-9a-z]{24}|[a-z0-9]+:[0-9a-f-]{36}(?::[0-9a-f-]{36})?)$/i;
3405
+ async function searchUsers(client, query) {
3406
+ const params = new URLSearchParams({
3407
+ query,
3408
+ maxResults: "20"
3409
+ });
3410
+ return (await client.getJson(`/rest/api/3/user/search?${params.toString()}`)).map(toJiraUser);
3411
+ }
3412
+ function matchUser(users, query, noun) {
3413
+ const active = users.filter((u) => u.active);
3414
+ const needle = query.toLowerCase();
3415
+ const exact = active.filter((u) => u.displayName.toLowerCase() === needle || u.email.toLowerCase() === needle);
3416
+ const candidates = exact.length === 1 ? exact : active;
3417
+ if (candidates.length === 1) return candidates[0].accountId;
3418
+ if (candidates.length === 0) throw new Error(`no ${noun} matches "${query}".`);
3419
+ const names = candidates.map((u) => u.displayName).join(", ");
3420
+ throw new Error(`"${query}" matches ${candidates.length} users: ${names}.`);
3421
+ }
2940
3422
  async function fetchMyself(client) {
2941
3423
  return toJiraUser(await client.getJson("/rest/api/3/myself"));
2942
3424
  }
@@ -3014,18 +3496,11 @@ function matchType(types, arg) {
3014
3496
  const needle = arg.trim().toLowerCase();
3015
3497
  return types.find((t) => t.id === arg.trim()) ?? types.find((t) => t.name.toLowerCase() === needle);
3016
3498
  }
3017
- const ACCOUNT_ID = /^(?:[0-9a-f]{24}|[a-z0-9]+:[0-9a-f-]{36}(?::[0-9a-f-]{36})?)$/i;
3018
3499
  function userResolver(client, project) {
3019
3500
  return async (query) => {
3020
3501
  if (query === "me") return (await fetchMyself(client)).accountId;
3021
3502
  if (ACCOUNT_ID.test(query)) return query;
3022
- const users = (await searchAssignableUsers(client, project, query)).filter((u) => u.active);
3023
- const exact = users.filter((u) => u.displayName.toLowerCase() === query.toLowerCase() || u.email.toLowerCase() === query.toLowerCase());
3024
- const candidates = exact.length === 1 ? exact : users;
3025
- if (candidates.length === 1) return candidates[0].accountId;
3026
- if (candidates.length === 0) throw new Error(`no assignable user matches "${query}".`);
3027
- const names = candidates.map((u) => u.displayName).join(", ");
3028
- throw new Error(`"${query}" matches ${candidates.length} users: ${names}.`);
3503
+ return matchUser(await searchAssignableUsers(client, project, query), query, "assignable user");
3029
3504
  };
3030
3505
  }
3031
3506
  //#endregion
@@ -3639,9 +4114,13 @@ async function jiraFields({ session, term }, projectArg, typeArg, options) {
3639
4114
  //#endregion
3640
4115
  //#region src/api/jira-search.ts
3641
4116
  const RECENT_ISSUES_CLAUSE = "updated >= -30d";
4117
+ const OPEN_CLAUSE = "statusCategory != Done";
3642
4118
  const SEARCH_FIELDS = "summary,status,updated";
3643
4119
  async function searchIssues(client, site, params) {
3644
- return ((await fetchSearchPage(client, buildJql(params), params.limit)).issues ?? []).map((i) => toIssueSummary(site, i));
4120
+ return searchIssuesByJql(client, site, buildJql(params), params.limit);
4121
+ }
4122
+ async function searchIssuesByJql(client, site, jql, limit) {
4123
+ return ((await fetchSearchPage(client, jql, limit)).issues ?? []).map((i) => toIssueSummary(site, i));
3645
4124
  }
3646
4125
  async function fetchSearchPage(client, jql, maxResults, nextPageToken) {
3647
4126
  const query = new URLSearchParams({
@@ -3662,22 +4141,28 @@ function toIssueSummary(site, i) {
3662
4141
  url: browseUrl(site, i.key)
3663
4142
  };
3664
4143
  }
4144
+ const ISSUE_ORDER = "updated DESC";
3665
4145
  function buildJql(params) {
3666
- if (params.jql) return params.jql;
3667
- const clauses = [];
3668
- if (params.project) clauses.push(`project = ${jqlStringLiteral(params.project)}`);
3669
- if (params.assignee) clauses.push(params.assignee === "me" ? "assignee = currentUser()" : `assignee = ${jqlStringLiteral(params.assignee)}`);
3670
- if (params.status) clauses.push(`status = ${jqlStringLiteral(params.status)}`);
3671
- if (params.text) clauses.push(`text ~ ${jqlStringLiteral(params.text)}`);
4146
+ const clauses = [
4147
+ inClause("project", params.project),
4148
+ inClause("type", params.type),
4149
+ inClause("status", params.status),
4150
+ params.open ? OPEN_CLAUSE : null,
4151
+ userClause("assignee", params.assignee),
4152
+ userClause("reporter", params.reporter),
4153
+ inClause("labels", params.label),
4154
+ params.updatedSince ? `updated >= ${quote(params.updatedSince)}` : null,
4155
+ params.text ? `text ~ ${quote(params.text)}` : null
4156
+ ].filter((c) => c !== null);
3672
4157
  if (clauses.length === 0) clauses.push(RECENT_ISSUES_CLAUSE);
3673
- return `${clauses.join(" AND ")} ORDER BY updated DESC`;
4158
+ return joinClauses(clauses, ISSUE_ORDER);
3674
4159
  }
3675
- const OPEN_CLAUSE = "statusCategory != Done";
3676
4160
  function buildListJql(params) {
3677
- const clauses = ["assignee = currentUser()"];
3678
- if (params.project) clauses.push(`project = ${jqlStringLiteral(params.project)}`);
3679
- clauses.push(params.all ? `(${OPEN_CLAUSE} OR ${RECENT_ISSUES_CLAUSE})` : OPEN_CLAUSE);
3680
- return `${clauses.join(" AND ")} ORDER BY updated DESC`;
4161
+ return joinClauses([
4162
+ "assignee = currentUser()",
4163
+ params.project ? `project = ${quote(params.project)}` : null,
4164
+ params.all ? `(${OPEN_CLAUSE} OR ${RECENT_ISSUES_CLAUSE})` : OPEN_CLAUSE
4165
+ ], ISSUE_ORDER);
3681
4166
  }
3682
4167
  const LIST_PAGE_SIZE = 100;
3683
4168
  const LIST_CAP = 500;
@@ -3712,8 +4197,59 @@ function sortByCategoryThenUpdated(issues) {
3712
4197
  const updatedMs = (i) => Date.parse(i.updated) || 0;
3713
4198
  return [...issues].sort((a, b) => rank(a) - rank(b) || updatedMs(b) - updatedMs(a));
3714
4199
  }
3715
- function jqlStringLiteral(value) {
3716
- return `"${value.replace(/(["\\])/g, "\\$1")}"`;
4200
+ //#endregion
4201
+ //#region src/commands/jira-filters.ts
4202
+ const FILTER_FLAGS = [
4203
+ "project",
4204
+ "assignee",
4205
+ "reporter",
4206
+ "status",
4207
+ "type",
4208
+ "label"
4209
+ ];
4210
+ const ISSUE_KEY = /^[A-Z][A-Z0-9]+-\d+$/i;
4211
+ function hasFilters(filters) {
4212
+ return FILTER_FLAGS.some((flag) => values(filters[flag]).length > 0) || filters.updated !== void 0 || filters.open === true;
4213
+ }
4214
+ function checkQuery(query, filters) {
4215
+ if (query && ISSUE_KEY.test(query.trim())) throw new Error(`"${query}" looks like an issue key. Run \`jira view ${query}\` to open it.`);
4216
+ if (!query && !hasFilters(filters)) throw new Error("Give a text query or at least one filter. `jira list` shows your open issues.");
4217
+ }
4218
+ async function searchParams(client, query, filters, limit, nowMs) {
4219
+ return {
4220
+ text: query,
4221
+ project: filters.project,
4222
+ assignee: await resolveUsers(client, filters.assignee),
4223
+ reporter: await resolveUsers(client, filters.reporter),
4224
+ status: filters.status,
4225
+ type: filters.type,
4226
+ label: filters.label,
4227
+ updatedSince: filters.updated ? parseSince(filters.updated, nowMs) : void 0,
4228
+ open: filters.open,
4229
+ limit
4230
+ };
4231
+ }
4232
+ async function resolveUsers(client, given) {
4233
+ const list = values(given);
4234
+ if (list.length === 0) return void 0;
4235
+ const resolved = [];
4236
+ for (const who of list) if (who === "me" || ACCOUNT_ID.test(who)) resolved.push(who);
4237
+ else resolved.push(matchUser(await searchUsers(client, who), who, "user"));
4238
+ return resolved;
4239
+ }
4240
+ const FIELD_IN_MESSAGE = /for the field '([^']+)'/;
4241
+ const createForm = (project) => `jira fields ${project ?? "<project>"}`;
4242
+ const DISCOVERY = {
4243
+ project: () => "jira projects",
4244
+ status: (p) => p ? `jira statuses --project ${p}` : "jira statuses",
4245
+ issuetype: createForm,
4246
+ type: createForm
4247
+ };
4248
+ function searchHint(err, project) {
4249
+ if (!(err instanceof HttpError) || err.status !== 400) return err;
4250
+ const field = FIELD_IN_MESSAGE.exec(err.message)?.[1]?.toLowerCase();
4251
+ const command = field ? DISCOVERY[field]?.(values(project)[0]) : void 0;
4252
+ return command ? /* @__PURE__ */ new Error(`${err.message} Run \`${command}\` to see the options.`) : err;
3717
4253
  }
3718
4254
  //#endregion
3719
4255
  //#region src/api/jira-issues.ts
@@ -3759,7 +4295,9 @@ async function fetchIssue(client, site, key) {
3759
4295
  async function updateIssue(client, key, update) {
3760
4296
  const fields = { description: update.description };
3761
4297
  if (update.summary !== void 0) fields["summary"] = update.summary;
3762
- await client.putNoContent(`/rest/api/3/issue/${encodeURIComponent(key)}`, { fields });
4298
+ const path = `/rest/api/3/issue/${encodeURIComponent(key)}`;
4299
+ await client.putNoContent(path, { fields });
4300
+ return (await client.getJson(`${path}?fields=updated`)).fields.updated ?? "";
3763
4301
  }
3764
4302
  async function fetchComments(client, key) {
3765
4303
  return (await client.getJson(`/rest/api/3/issue/${encodeURIComponent(key)}/comment?maxResults=100&orderBy=created`)).comments.map((c) => ({
@@ -3851,7 +4389,7 @@ function formatIssueView(issue, nowMs, allComments) {
3851
4389
  const colorStatus = colorForCategory(issue.statusCategory);
3852
4390
  return [
3853
4391
  `${kleur.bold(issue.key)} ${issue.summary}`,
3854
- ...fieldLines([
4392
+ ...fieldLines$1([
3855
4393
  ["Type", issue.type],
3856
4394
  ["Status", issue.status && colorStatus(issue.status)],
3857
4395
  ["Assignee", issue.assignee],
@@ -3876,15 +4414,17 @@ async function jiraUpdate({ session, term, files }, arg, options) {
3876
4414
  flag: "[file]",
3877
4415
  required: true
3878
4416
  });
3879
- const src = parseIssueSource(await files.readText(file));
4417
+ const raw = await files.readText(file);
4418
+ const src = parseIssueSource(raw);
3880
4419
  const plan = planIssueUpdate(src, await fetchIssue(session, session.site, src.key), options);
3881
4420
  await runPlan(term, plan, options, async () => {
3882
4421
  const { current, next } = plan.headline;
3883
- await updateIssue(session, src.key, {
4422
+ const updated = await updateIssue(session, src.key, {
3884
4423
  description: plan.body,
3885
4424
  summary: next !== current ? next : void 0
3886
4425
  });
3887
4426
  term.out(`Updated ${src.key}.`);
4427
+ if (updated) await files.writeText(file, rewriteFields(raw, { updated }));
3888
4428
  });
3889
4429
  }
3890
4430
  async function copyIssue(env, key, out) {
@@ -3900,29 +4440,29 @@ const ISSUE_REF = {
3900
4440
  //#endregion
3901
4441
  //#region src/commands/jira-search.ts
3902
4442
  async function jiraSearch(env, query, options) {
3903
- const { session, term } = env;
3904
- if (options.jql && (query || options.project || options.assignee || options.status)) throw new Error("--jql cannot be combined with a text query or other filters.");
3905
- if (options.json && options.copy) throw new Error("--json and --copy cannot be used together.");
3906
- const limit = parseLimit(options.limit);
3907
- const issues = await searchIssues(session, session.site, {
3908
- text: query,
3909
- project: options.project,
3910
- assignee: options.assignee,
3911
- status: options.status,
3912
- jql: options.jql,
4443
+ const { session } = env;
4444
+ checkQuery(query, options);
4445
+ const limit = checkedLimit(options);
4446
+ const params = await searchParams(session, query, options, limit, Date.now());
4447
+ await showIssues(env, await searchIssues(session, session.site, params).catch((err) => {
4448
+ throw searchHint(err, options.project);
4449
+ }), limit, options);
4450
+ }
4451
+ async function jiraJql(env, query, options) {
4452
+ const { session } = env;
4453
+ const limit = checkedLimit(options);
4454
+ await showIssues(env, await searchIssuesByJql(session, session.site, query, limit), limit, options);
4455
+ }
4456
+ async function showIssues(env, issues, limit, options, empty = "No matching issues.") {
4457
+ await showRows(env.term, formatIssueRows(issues, Date.now()), {
4458
+ empty,
4459
+ hasMore: issues.length === limit,
3913
4460
  limit
3914
- });
3915
- await runSearch(term, formatIssueRows(issues, Date.now()), {
3916
- json: options.json,
3917
- copy: options.copy,
3918
- out: options.out,
3919
- empty: "No matching issues.",
3920
- footer: issues.length === limit ? searchFooter(limit) : void 0
3921
- }, ISSUE_NOUN, (key) => copyIssue(env, key, options.out));
4461
+ }, options, ISSUE_NOUN, (key) => copyIssue(env, key, options.out));
3922
4462
  }
3923
4463
  async function jiraList(env, options) {
3924
4464
  const { session, term } = env;
3925
- if (options.json && options.copy) throw new Error("--json and --copy cannot be used together.");
4465
+ checkedLimit(options);
3926
4466
  const { issues, truncated } = await listAssignedIssues(session, session.site, {
3927
4467
  all: options.all,
3928
4468
  project: options.project
@@ -3951,25 +4491,23 @@ const ISSUE_NOUN = {
3951
4491
  };
3952
4492
  //#endregion
3953
4493
  //#region src/cli/jira.ts
3954
- function collect(value, previous = []) {
3955
- return [...previous, value];
3956
- }
3957
4494
  function registerJira(jira) {
3958
4495
  jira.description("Jira commands");
3959
4496
  jira.command("projects [query]").description("List projects (optionally filtered by key or name)").option("--json", "output results as JSON").action(jiraAction(jiraProjects));
3960
4497
  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(jiraAction(jiraStatuses));
3961
- jira.command("create [project] [type]").description("Create a Jira issue, prompting for fields or taking them all as flags").option("-s, --summary <text>", "issue summary").option("-d, --description <markdown>", "description as Markdown").option("--description-file <path>", "read the description from a Markdown file").option("-a, --assignee <who>", "assignee: me, an account id, or a name to look up").option("--priority <name>", "priority name").option("-l, --label <label>", "label (repeatable, or comma separated)", collect).option("-c, --component <name>", "component (repeatable, or comma separated)", collect).option("--parent <key>", "parent issue key for subtasks").option("-f, --field <name=value>", "any other create-screen field (repeatable)", collect).option("--dry-run", "print the resolved payload instead of creating").option("--json", "print the created issue as JSON").action(jiraAction(jiraCreate));
4498
+ jira.command("create [project] [type]").description("Create a Jira issue, prompting for fields or taking them all as flags").option("-s, --summary <text>", "issue summary").option("-d, --description <markdown>", "description as Markdown").option("--description-file <path>", "read the description from a Markdown file").option("-a, --assignee <who>", "assignee: me, an account id, or a name to look up").option("--priority <name>", "priority name").option("-l, --label <label>", "label (repeatable)", collect).option("-c, --component <name>", "component (repeatable)", collect).option("--parent <key>", "parent issue key for subtasks").option("-f, --field <name=value>", "any other create-screen field (repeatable)", collect).option("--dry-run", "print the resolved payload instead of creating").option("--json", "print the created issue as JSON").action(jiraAction(jiraCreate));
3962
4499
  jira.command("fields <project> [type]").description("Show the issue types you can create in a project, or the create form for one type").option("--json", "output results as JSON").action(jiraAction(jiraFields));
3963
4500
  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").option("--no-pager", "print directly instead of paging long output").action(jiraAction(jiraView));
3964
4501
  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(jiraAction(jiraCopy));
3965
4502
  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(jiraAction(jiraUpdate));
3966
- 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(jiraAction(jiraList));
3967
- 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(jiraAction(jiraSearch));
4503
+ outputOptions(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")).action(jiraAction(jiraList));
4504
+ outputOptions(jira.command("search [query]").description("Search Jira issues by text and filters").option("-p, --project <key>", "limit to a project (repeatable)", collect).option("-a, --assignee <who>", "limit to an assignee: me, a name, or an account id", collect).option("--reporter <who>", "limit to a reporter: me, a name, or an account id", collect).option("-s, --status <status>", "limit to a status (repeatable)", collect).option("-t, --type <type>", "limit to an issue type (repeatable)", collect).option("--label <label>", "limit to a label (repeatable)", collect).option("-u, --updated <when>", "changed since 7d, 2w, 3m, or YYYY-MM-DD").option("--open", "exclude issues in the Done category").addOption(moved("--jql <jql>", "jira jql"))).action(jiraAction(jiraSearch));
4505
+ outputOptions(jira.command("jql <query>").description("Search Jira issues with a raw JQL query")).action(jiraAction(jiraJql));
3968
4506
  return jira;
3969
4507
  }
3970
4508
  //#endregion
3971
4509
  //#region package.json
3972
- var version = "1.14.0";
4510
+ var version = "2.1.0";
3973
4511
  //#endregion
3974
4512
  //#region src/cli/build.ts
3975
4513
  const PRODUCTS = {