atlass 2.0.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.
@@ -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]);
@@ -1369,7 +1376,7 @@ function link(text, url) {
1369
1376
  //#endregion
1370
1377
  //#region src/commands/view.ts
1371
1378
  const INDENT = " ";
1372
- function fieldLines(pairs) {
1379
+ function fieldLines$1(pairs) {
1373
1380
  const kept = pairs.filter(([, value]) => value !== "");
1374
1381
  const width = Math.max(...kept.map(([label]) => label.length)) + 1;
1375
1382
  return kept.map(([label, value]) => `${kleur.dim(`${label}:`.padEnd(width))} ${value}`);
@@ -1456,7 +1463,7 @@ function formatPullRequestView(detail, comments, files, nowMs, allComments = fal
1456
1463
  const label = detail.draft ? "DRAFT" : detail.state || "-";
1457
1464
  return [
1458
1465
  kleur.bold(detail.title),
1459
- ...fieldLines([
1466
+ ...fieldLines$1([
1460
1467
  ["State", colorForBitbucketState(label)(label)],
1461
1468
  ["Author", detail.author],
1462
1469
  ["Branch", branchLine(detail)],
@@ -1891,87 +1898,186 @@ function outputOptions(command) {
1891
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");
1892
1899
  }
1893
1900
  //#endregion
1894
- //#region src/api/query.ts
1895
- function quote$1(value) {
1896
- return `"${value.replace(/(["\\])/g, "\\$1")}"`;
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;
1897
1904
  }
1898
- function values(given) {
1899
- return (given ?? []).map((v) => v.trim()).filter((v) => v.length > 0);
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
+ };
1900
1920
  }
1901
- function inClause(field, given) {
1902
- return renderClause(field, values(given).map(quote$1));
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
+ }));
1903
1930
  }
1904
- function userClause(field, given) {
1905
- return renderClause(field, values(given).map((v) => v === "me" ? "currentUser()" : quote$1(v)));
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);
1906
1933
  }
1907
- function renderClause(field, rendered) {
1908
- if (rendered.length === 0) return null;
1909
- if (rendered.length === 1) return `${field} = ${rendered[0]}`;
1910
- return `${field} in (${rendered.join(", ")})`;
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.`);
1911
1938
  }
1912
- function joinClauses(clauses, order) {
1913
- return `${clauses.filter((c) => c !== null).join(" AND ")} ORDER BY ${order}`;
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
+ }));
1914
1945
  }
1915
- //#endregion
1916
- //#region src/util/html.ts
1917
- const MAX_CODE_POINT = 1114111;
1918
- const NAMED = {
1919
- amp: "&",
1920
- lt: "<",
1921
- gt: ">",
1922
- quot: "\"",
1923
- apos: "'",
1924
- nbsp: " "
1925
- };
1926
- function decodeEntities(text) {
1927
- return text.replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (match, body) => {
1928
- if (body[0] === "#") {
1929
- const code = body[1] === "x" || body[1] === "X" ? Number.parseInt(body.slice(2), 16) : Number.parseInt(body.slice(1), 10);
1930
- return isCodePoint(code) ? String.fromCodePoint(code) : match;
1931
- }
1932
- return NAMED[body.toLowerCase()] ?? match;
1933
- });
1946
+ function mediaNodeId(a) {
1947
+ return a.fileId ?? a.id;
1934
1948
  }
1935
- function isCodePoint(code) {
1936
- return Number.isFinite(code) && code >= 0 && code <= MAX_CODE_POINT;
1949
+ function withWikiContextPath(link) {
1950
+ if (link.startsWith("http") || link.startsWith("/wiki")) return link;
1951
+ return `/wiki${link}`;
1937
1952
  }
1938
1953
  //#endregion
1939
- //#region src/api/confluence-search.ts
1940
- async function searchPages(client, site, params) {
1941
- return searchPagesByCql(client, site, buildCql(params), params.limit);
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
+ };
1942
1978
  }
1943
- async function searchPagesByCql(client, site, cql, limit) {
1944
- const query = new URLSearchParams({
1945
- cql,
1946
- limit: String(limit),
1947
- expand: "content.space"
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
+ }
1948
2000
  });
1949
- const results = (await client.getJson(`/wiki/rest/api/search?${query.toString()}`)).results ?? [];
1950
- const serverPageWasFull = results.length === limit;
1951
2001
  return {
1952
- pages: results.filter((r) => r.content?.id).map((r) => ({
1953
- id: r.content?.id ?? "",
1954
- space: r.content?.space?.key ?? spaceKeyOf(r.resultGlobalContainer?.displayUrl),
1955
- title: decodeEntities(r.content?.title ?? r.title ?? ""),
1956
- updated: r.lastModified ?? "",
1957
- url: r.url ? `${site}/wiki${r.url}` : ""
1958
- })),
1959
- hasMore: serverPageWasFull
2002
+ number: res.version?.number ?? params.nextVersion,
2003
+ createdAt: res.version?.createdAt ?? ""
1960
2004
  };
1961
2005
  }
1962
- const SPACE_URL = /\/spaces\/([^/?#]+)/;
1963
- function spaceKeyOf(displayUrl) {
1964
- return SPACE_URL.exec(displayUrl ?? "")?.[1] ?? "";
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
+ }
1965
2013
  }
1966
- function buildCql(params) {
1967
- return joinClauses([
1968
- "type = page",
1969
- params.starred ? "favourite = currentUser()" : null,
1970
- inClause("space", params.space),
1971
- inClause("label", params.label),
1972
- params.updatedSince ? `lastmodified >= ${quote$1(params.updatedSince)}` : null,
1973
- params.text ? `text ~ ${quote$1(params.text)}` : null
1974
- ], "lastmodified DESC");
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"}`;
1975
2081
  }
1976
2082
  //#endregion
1977
2083
  //#region src/adf/from-markdown.ts
@@ -2144,7 +2250,10 @@ function inlineNode(token, ctx, marks) {
2144
2250
  }));
2145
2251
  }
2146
2252
  case "br": return [{ type: "hardBreak" }];
2147
- case "html": return textNode(token.text, marks);
2253
+ case "html": {
2254
+ const html = token.text;
2255
+ return isOnlyComments(html) ? [] : textNode(html, marks);
2256
+ }
2148
2257
  default: return "text" in token && token.text ? textNode(token.text, marks) : [];
2149
2258
  }
2150
2259
  }
@@ -2178,132 +2287,301 @@ function clampLevel(value) {
2178
2287
  return Math.min(6, Math.max(1, Math.trunc(value) || 1));
2179
2288
  }
2180
2289
  //#endregion
2181
- //#region src/api/confluence-attachments.ts
2182
- const UNKNOWN_SIZE = -1;
2183
- async function listAttachments(client, id) {
2184
- return (await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}/attachments?limit=250`)).results.map((a) => ({
2185
- filename: a.title ?? a.id,
2186
- fileId: mediaNodeId(a),
2187
- size: typeof a.fileSize === "number" ? a.fileSize : UNKNOWN_SIZE
2188
- }));
2189
- }
2190
- async function uploadAttachment(client, pageId, filename, bytes) {
2191
- return (await client.postMultipart(`/wiki/rest/api/content/${encodeURIComponent(pageId)}/child/attachment`, filename, bytes)).results?.[0]?.extensions?.fileId ?? await fileIdByListing(client, pageId, filename);
2192
- }
2193
- async function fileIdByListing(client, pageId, filename) {
2194
- const match = (await listAttachments(client, pageId)).find((a) => a.filename === filename);
2195
- if (match) return match.fileId;
2196
- throw new Error(`Upload of "${filename}" did not return a fileId.`);
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;
2197
2315
  }
2198
- async function fetchAttachments(client, id) {
2199
- return (await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}/attachments?limit=250`)).results.filter((a) => a.downloadLink).map((a) => ({
2200
- mediaId: mediaNodeId(a),
2201
- filename: a.title ?? a.id,
2202
- url: withWikiContextPath(a.downloadLink ?? "")
2203
- }));
2316
+ function formatLossy(counts) {
2317
+ return [...counts.entries()].map(([label, n]) => `${n} ${label}${n === 1 ? "" : "s"}`).join(", ");
2204
2318
  }
2205
- function mediaNodeId(a) {
2206
- return a.fileId ?? a.id;
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
+ ];
2207
2331
  }
2208
- function withWikiContextPath(link) {
2209
- if (link.startsWith("http") || link.startsWith("/wiki")) return link;
2210
- return `/wiki${link}`;
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;
2211
2338
  }
2212
2339
  //#endregion
2213
- //#region src/api/confluence-pages.ts
2214
- async function fetchPage(client, site, id) {
2215
- const page = await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}?body-format=atlas_doc_format`);
2216
- const names = new UserNames(client);
2217
- const [spaceKey, attachments, comments, author] = await Promise.all([
2218
- fetchSpaceKey(client, page.spaceId),
2219
- fetchAttachments(client, id),
2220
- fetchComments$1(client, id, names),
2221
- names.resolve(page.version?.authorId ?? page.authorId)
2222
- ]);
2223
- const webui = page._links?.webui ?? "";
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"
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);
2362
+ }
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;
2375
+ }
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) {
2224
2394
  return {
2225
- id: page.id,
2226
- title: page.title,
2227
- spaceKey,
2228
- version: page.version?.number ?? 0,
2229
- author,
2230
- createdAt: page.createdAt ?? "",
2231
- updatedAt: page.version?.createdAt ?? "",
2232
- url: webui ? `${site}/wiki${webui}` : "",
2233
- body: parseAdfJson(page.body?.atlas_doc_format?.value),
2234
- attachments,
2235
- comments
2395
+ label,
2396
+ current,
2397
+ next: useTitle && title ? title : current
2236
2398
  };
2237
2399
  }
2238
- async function fetchPageState(client, id) {
2239
- const page = await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}?body-format=atlas_doc_format`);
2400
+ function revision(local, server) {
2240
2401
  return {
2241
- version: page.version?.number ?? 0,
2242
- title: page.title,
2243
- body: parseAdfJson(page.body?.atlas_doc_format?.value)
2402
+ local: local || "unknown",
2403
+ server: server || "unknown",
2404
+ stale: local !== server
2244
2405
  };
2245
2406
  }
2246
- async function updatePage(client, id, params) {
2247
- return (await client.putJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}`, {
2248
- id,
2249
- status: "current",
2250
- title: params.title,
2251
- body: {
2252
- representation: "atlas_doc_format",
2253
- value: JSON.stringify(params.body)
2254
- },
2255
- version: {
2256
- number: params.nextVersion,
2257
- message: params.message
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);
2444
+ }
2445
+ //#endregion
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]));
2451
+ return {
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
+ }] : [])
2468
+ };
2469
+ }
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
+ };
2510
+ return {
2511
+ href,
2512
+ kind: existing ? "changed" : "upload",
2513
+ path: local.path,
2514
+ filename: local.filename
2515
+ };
2516
+ }
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)
2258
2531
  }
2259
- })).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
+ };
2260
2538
  }
2261
- async function fetchSpaceKey(client, spaceId) {
2262
- if (!spaceId) return "";
2263
- try {
2264
- return (await client.getJson(`/wiki/api/v2/spaces/${encodeURIComponent(spaceId)}`)).key ?? "";
2265
- } catch {
2266
- 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));
2267
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
+ });
2268
2554
  }
2269
- async function fetchComments$1(client, id, names) {
2270
- const res = await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}/footer-comments?body-format=atlas_doc_format&limit=250`);
2271
- return Promise.all(res.results.map(async (c) => ({
2272
- author: await names.resolve(c.version?.authorId),
2273
- created: c.version?.createdAt ?? "",
2274
- body: parseAdfJson(c.body?.atlas_doc_format?.value)
2275
- })));
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
+ }));
2276
2566
  }
2277
- function parseAdfJson(value) {
2278
- if (!value) return null;
2567
+ async function fileSize(files, path) {
2279
2568
  try {
2280
- return JSON.parse(value);
2569
+ return { size: await files.size(path) };
2281
2570
  } catch {
2282
- return null;
2571
+ return {};
2283
2572
  }
2284
2573
  }
2285
- var UserNames = class {
2286
- client;
2287
- cache = /* @__PURE__ */ new Map();
2288
- constructor(client) {
2289
- this.client = client;
2290
- }
2291
- async resolve(accountId) {
2292
- if (!accountId) return "";
2293
- const cached = this.cache.get(accountId);
2294
- if (cached !== void 0) return cached;
2295
- const name = await this.fetchDisplayName(accountId) ?? accountId;
2296
- this.cache.set(accountId, name);
2297
- return name;
2298
- }
2299
- async fetchDisplayName(accountId) {
2300
- try {
2301
- return (await this.client.getJson(`/wiki/rest/api/user?accountId=${encodeURIComponent(accountId)}`)).displayName || null;
2302
- } catch {
2303
- return null;
2304
- }
2305
- }
2306
- };
2574
+ //#endregion
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)}`;
2578
+ }
2579
+ async function starPage(client, id) {
2580
+ await client.putNoContent(favouritePath(id), void 0);
2581
+ }
2582
+ async function unstarPage(client, id) {
2583
+ await client.deleteNoContent(favouritePath(id));
2584
+ }
2307
2585
  //#endregion
2308
2586
  //#region src/commands/resolve-ref.ts
2309
2587
  async function resolveRef(ask, arg, kind) {
@@ -2316,43 +2594,138 @@ async function resolveRef(ask, arg, kind) {
2316
2594
  if (!ref) throw new Error(kind.notFound(raw));
2317
2595
  return ref;
2318
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
+ }
2319
2613
  //#endregion
2320
- //#region src/api/attachments.ts
2321
- function mediaResolver(downloaded) {
2322
- const byMediaId = new Map(downloaded.map((d) => [d.mediaId, d.relativePath]));
2323
- const byFilename = new Map(downloaded.map((d) => [d.filename, d.relativePath]));
2324
- return (media) => {
2325
- if (media.id && byMediaId.has(media.id)) return byMediaId.get(media.id);
2326
- 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)
2327
2625
  };
2328
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
+ }
2329
2692
  //#endregion
2330
2693
  //#region src/markdown/copied-document.ts
2694
+ const GENERATED_MARKER = "<!-- atlass:generated -->";
2331
2695
  const COMMENTS_HEADING = "## Comments";
2332
2696
  const ATTACHMENTS_HEADING = "## Attachments";
2333
- const FRONTMATTER_BLOCK = /^---\n([\s\S]*?)\n---\n?/;
2334
- const FRONTMATTER_KEY = /^([A-Za-z0-9_]+):\s*(.*)$/;
2335
- const FRONTMATTER_LIST_ITEM = /^ {2}- (.*)$/;
2336
- const BARE_NUMBER = /^-?\d+(\.\d+)?$/;
2337
- const TRAILING_SECTION_HEADING = /^## (Comments|Attachments)\s*$/;
2697
+ const LEGACY_SECTION_HEADING = /^## (Comments|Attachments)\s*$/;
2338
2698
  function render(doc) {
2339
2699
  return `${[
2340
- frontmatter(doc.fields),
2700
+ formatFrontmatter(doc.fields),
2341
2701
  `# ${doc.title}`,
2342
2702
  doc.body,
2703
+ GENERATED_MARKER,
2343
2704
  commentsSection(doc.comments),
2344
2705
  attachmentsSection(doc.attachments)
2345
2706
  ].filter((s) => s.trim().length > 0).join("\n\n")}\n`;
2346
2707
  }
2347
- function parse(text) {
2348
- const match = text.match(FRONTMATTER_BLOCK);
2349
- if (!match) throw new Error("Not an atlass file: no YAML frontmatter found.");
2350
- const fields = parseFrontmatter(match[1] ?? "");
2351
- 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");
2352
2726
  const h1 = leadingH1(lines);
2353
2727
  const start = h1 ? h1.index + 1 : 0;
2354
- const trailing = lines.findIndex((line, i) => i >= start && TRAILING_SECTION_HEADING.test(line));
2355
- const end = trailing === -1 ? lines.length : trailing;
2728
+ const end = bodyEnd(lines, start, ending);
2356
2729
  const fallbackTitle = typeof fields["title"] === "string" ? fields["title"] : "";
2357
2730
  return {
2358
2731
  fields,
@@ -2388,45 +2761,6 @@ function scalarString(value) {
2388
2761
  if (typeof value === "number") return String(value);
2389
2762
  return "";
2390
2763
  }
2391
- function frontmatter(fields) {
2392
- const lines = ["---"];
2393
- for (const [key, value] of Object.entries(fields)) if (Array.isArray(value)) {
2394
- if (value.length === 0) lines.push(`${key}: []`);
2395
- else lines.push(`${key}:`, ...value.map((v) => ` - ${quote(v)}`));
2396
- } else if (typeof value === "number") lines.push(`${key}: ${value}`);
2397
- else lines.push(`${key}: ${quote(value)}`);
2398
- lines.push("---");
2399
- return lines.join("\n");
2400
- }
2401
- function parseFrontmatter(block) {
2402
- const fields = {};
2403
- const lines = block.split("\n");
2404
- for (let i = 0; i < lines.length; i++) {
2405
- const m = lines[i]?.match(FRONTMATTER_KEY);
2406
- if (!m) continue;
2407
- const key = m[1] ?? "";
2408
- const raw = (m[2] ?? "").trim();
2409
- if (raw === "[]") fields[key] = [];
2410
- else if (raw === "") {
2411
- const items = [];
2412
- for (let item; item = lines[i + 1]?.match(FRONTMATTER_LIST_ITEM); i++) items.push(unquote((item[1] ?? "").trim()));
2413
- fields[key] = items;
2414
- } else fields[key] = scalar(raw);
2415
- }
2416
- return fields;
2417
- }
2418
- function scalar(raw) {
2419
- if (raw.startsWith("\"")) return unquote(raw);
2420
- if (BARE_NUMBER.test(raw)) return Number(raw);
2421
- return raw;
2422
- }
2423
- function quote(value) {
2424
- return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
2425
- }
2426
- function unquote(value) {
2427
- if (!value.startsWith("\"") || !value.endsWith("\"")) return value;
2428
- return value.slice(1, -1).replace(/\\"/g, "\"").replace(/\\\\/g, "\\");
2429
- }
2430
2764
  function leadingH1(lines) {
2431
2765
  for (const [index, line] of lines.entries()) {
2432
2766
  if (line.startsWith("# ")) return {
@@ -2437,6 +2771,16 @@ function leadingH1(lines) {
2437
2771
  }
2438
2772
  return null;
2439
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
+ }
2440
2784
  function commentsSection(comments) {
2441
2785
  if (comments.length === 0) return "";
2442
2786
  const blocks = comments.map((c) => {
@@ -2459,6 +2803,44 @@ function attachmentsSection(attachments) {
2459
2803
  ].join("\n");
2460
2804
  }
2461
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
2462
2844
  //#region src/copy/plan.ts
2463
2845
  function planIssueCopy(issue, out) {
2464
2846
  return planCopy({
@@ -2481,19 +2863,22 @@ function planIssueCopy(issue, out) {
2481
2863
  attachments: issue.attachments
2482
2864
  }, out);
2483
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
+ }
2484
2878
  function planPageCopy(page, out) {
2485
2879
  return planCopy({
2486
2880
  baseName: `${page.id}-${slugify(page.title)}`,
2487
- fields: {
2488
- title: page.title,
2489
- id: page.id,
2490
- space: page.spaceKey,
2491
- version: page.version,
2492
- author: page.author,
2493
- created: page.createdAt,
2494
- updated: page.updatedAt,
2495
- url: page.url
2496
- },
2881
+ fields: pageFields(page),
2497
2882
  title: page.title,
2498
2883
  body: page.body,
2499
2884
  comments: page.comments,
@@ -2564,246 +2949,235 @@ function numberedName(name, suffix) {
2564
2949
  return `${dot > 0 ? name.slice(0, dot) : name}-${suffix}${dot > 0 ? name.slice(dot) : ""}`;
2565
2950
  }
2566
2951
  //#endregion
2567
- //#region src/copy/run.ts
2568
- async function runCopy(env, plan) {
2569
- const landed = await download(env, plan);
2570
- await env.files.writeText(plan.filePath, renderCopy(plan, landed));
2571
- env.term.out(wroteLine(plan.filePath, landed.length));
2572
- }
2573
- async function download({ term, files, session }, plan) {
2574
- const landed = [];
2575
- for (const { path, ...attachment } of plan.downloads) try {
2576
- await files.writeBytes(path, await session.getBinary(attachment.url));
2577
- landed.push(attachment);
2578
- } catch (err) {
2579
- term.err(` ! could not download ${attachment.filename}: ${err.message}`);
2580
- }
2581
- return landed;
2582
- }
2583
- function wroteLine(filePath, attachmentCount) {
2584
- return `Wrote ${filePath}${attachmentCount > 0 ? ` (+${attachmentCount} attachment${attachmentCount === 1 ? "" : "s"})` : ""}`;
2585
- }
2586
- //#endregion
2587
- //#region src/adf/lossy.ts
2588
- const LOSSY_LABELS = {
2589
- panel: "panel",
2590
- expand: "expand",
2591
- nestedExpand: "expand",
2592
- decisionList: "decision list",
2593
- layoutSection: "layout",
2594
- extension: "macro",
2595
- bodiedExtension: "macro",
2596
- inlineExtension: "macro"
2597
- };
2598
- const JIRA_LOSSY_LABELS = {
2599
- ...LOSSY_LABELS,
2600
- media: "image",
2601
- mediaInline: "image"
2602
- };
2603
- function findLossyNodes(node, lossyLabels = LOSSY_LABELS) {
2604
- const counts = /* @__PURE__ */ new Map();
2605
- const visit = (n) => {
2606
- const label = lossyLabels[n.type];
2607
- if (label) counts.set(label, (counts.get(label) ?? 0) + 1);
2608
- for (const child of n.content ?? []) visit(child);
2609
- };
2610
- if (node) visit(node);
2611
- return counts;
2612
- }
2613
- function formatLossy(counts) {
2614
- return [...counts.entries()].map(([label, n]) => `${n} ${label}${n === 1 ? "" : "s"}`).join(", ");
2615
- }
2616
- //#endregion
2617
- //#region src/update/plan.ts
2618
- function planIssueUpdate(source, issue, options) {
2619
- const images = imageHrefs(source.body).map((href) => ({
2620
- href,
2621
- kind: isExternalHref(href) ? "external" : "unsupported"
2622
- }));
2623
- const unsupported = images.filter((i) => i.kind === "unsupported").map((i) => i.href);
2624
- const body = markdownToAdf(source.body, { resolveImage: (href, alt) => isExternalHref(href) ? externalMedia(href, alt) : void 0 });
2625
- 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)];
2626
- return withVerdict({
2627
- noun: "issue",
2628
- id: source.key,
2629
- headline: headline("summary", issue.summary, source.title, options.summary),
2630
- revision: revision(source.updatedAtCopy, issue.updated),
2631
- lossy: findLossyNodes(issue.description, JIRA_LOSSY_LABELS),
2952
+ //#region src/create/page-plan.ts
2953
+ function planPageCreate(draft, target, localImages) {
2954
+ const images = imageHrefs(draft.body).map((href) => ({
2955
+ href,
2956
+ kind: imageKind(href, localImages)
2957
+ }));
2958
+ const missing = images.filter((i) => i.kind === "missing").map((i) => i.href);
2959
+ return {
2960
+ ...target,
2961
+ title: draft.title,
2632
2962
  images,
2633
- uploads: [],
2634
- body,
2635
- refusals
2636
- }, options.force ?? false);
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
+ ]
2970
+ };
2637
2971
  }
2638
- function formatPlan(plan) {
2639
- const lines = [`Dry run for ${plan.noun} ${plan.id} "${plan.headline.current}"`];
2640
- if (plan.headline.next !== plan.headline.current) lines.push(row(plan.headline.label, `"${plan.headline.current}" -> "${plan.headline.next}"`));
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"));
2641
2976
  const counts = imageCounts(plan.images);
2642
2977
  if (counts) lines.push(row("images", counts));
2643
2978
  for (const refusal of plan.refusals) lines.push(row("blocked", refusal));
2644
- if (plan.lossy.size > 0) lines.push(row("warning", `${formatLossy(plan.lossy)} will be removed`));
2645
- if (plan.revision.stale) lines.push(row("stale", `${revisionText(plan.revision)} (would refuse without --force)`));
2979
+ for (const warning of plan.warnings) lines.push(row("warning", warning));
2646
2980
  lines.push(" nothing was written (dry run)");
2647
2981
  return lines;
2648
2982
  }
2649
- const IMAGE_COUNT_LABELS = [
2650
- ["upload", "new"],
2651
- ["changed", "changed"],
2652
- ["reuse", "reused"],
2653
- ["external", "external"],
2654
- ["unsupported", "unsupported"],
2655
- ["missing", "missing"]
2656
- ];
2657
- function imageCounts(images) {
2658
- return IMAGE_COUNT_LABELS.map(([kind, label]) => ({
2659
- n: images.filter((i) => i.kind === kind).length,
2660
- label
2661
- })).filter(({ n }) => n > 0).map(({ n, label }) => `${n} ${label}`).join(", ");
2983
+ function imageKind(href, localImages) {
2984
+ if (isExternalHref(href)) return "external";
2985
+ return localImages.find((l) => l.href === href)?.size === void 0 ? "missing" : "upload";
2662
2986
  }
2663
- function row(label, value) {
2664
- return ` ${`${label}:`.padEnd(9)}${value}`;
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.`];
2665
2990
  }
2666
- function headline(label, current, title, useTitle) {
2667
- return {
2668
- label,
2669
- current,
2670
- next: useTitle && title ? title : current
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
2671
3026
  };
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
+ });
2672
3054
  }
2673
- function revision(local, server) {
2674
- return {
2675
- local: local || "unknown",
2676
- server: server || "unknown",
2677
- stale: local !== server
2678
- };
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
+ }
2679
3062
  }
2680
- function revisionText(revision) {
2681
- return `copied at ${revision.local}, server now ${revision.server}`;
3063
+ //#endregion
3064
+ //#region src/api/query.ts
3065
+ function quote(value) {
3066
+ return `"${value.replace(/(["\\])/g, "\\$1")}"`;
2682
3067
  }
2683
- function emptyBodyRefusal(body) {
2684
- if (body.content && body.content.length > 0) return [];
2685
- return ["Refusing to update: the converted body is empty."];
3068
+ function values(given) {
3069
+ return (given ?? []).map((v) => v.trim()).filter((v) => v.length > 0);
2686
3070
  }
2687
- function withVerdict(plan, force) {
2688
- return {
2689
- ...plan,
2690
- verdict: verdict(plan, force)
2691
- };
3071
+ function inClause(field, given) {
3072
+ return renderClause(field, values(given).map(quote));
2692
3073
  }
2693
- function verdict(plan, force) {
2694
- if (plan.refusals.length > 0) return {
2695
- kind: "refuse",
2696
- message: plan.refusals.join("\n")
2697
- };
2698
- const noun = plan.noun;
2699
- if (plan.revision.stale && !force) return {
2700
- kind: "refuse",
2701
- message: `${capitalize(noun)} changed on the server since you copied it (${revisionText(plan.revision)}). Re-copy the ${noun} or pass --force.`
2702
- };
2703
- if (plan.lossy.size > 0 && !force) return {
2704
- kind: "confirm",
2705
- message: `This ${noun} contains ${formatLossy(plan.lossy)} that Markdown cannot represent and will be removed. Continue?`
2706
- };
2707
- return { kind: "proceed" };
3074
+ function userClause(field, given) {
3075
+ return renderClause(field, values(given).map((v) => v === "me" ? "currentUser()" : quote(v)));
2708
3076
  }
2709
- function capitalize(text) {
2710
- return text.charAt(0).toUpperCase() + text.slice(1);
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}`;
2711
3084
  }
2712
3085
  //#endregion
2713
- //#region src/update/plan-page.ts
2714
- function planPageUpdate(source, state, attachments, localImages, options) {
2715
- const collection = `contentId-${source.id}`;
2716
- const entries = imageHrefs(source.body).map((href) => pageImage(href, localImages, attachments));
2717
- const byHref = new Map(entries.map((e) => [e.href, e]));
2718
- const body = markdownToAdf(source.body, { resolveImage: (href, alt) => {
2719
- const entry = byHref.get(href);
2720
- if (!entry || entry.kind === "missing") return void 0;
2721
- if (entry.kind === "external") return externalMedia(href, alt);
2722
- return mediaNode({
2723
- type: "file",
2724
- id: entry.kind === "reuse" ? entry.fileId : href,
2725
- collection
2726
- }, alt);
2727
- } });
2728
- const missing = entries.filter((e) => e.kind === "missing").map((e) => e.href);
2729
- const refusals = [...missing.length > 0 ? [`Image file(s) not found: ${missing.join(", ")}`] : [], ...emptyBodyRefusal(body)];
2730
- return withVerdict({
2731
- noun: "page",
2732
- id: source.id,
2733
- headline: headline("title", state.title, source.title, options.title),
2734
- revision: revision(`v${source.version}`, `v${state.version}`),
2735
- lossy: findLossyNodes(state.body),
2736
- images: entries.map(({ href, kind }) => ({
2737
- href,
2738
- kind
2739
- })),
2740
- uploads: entries.flatMap((e) => e.kind === "upload" || e.kind === "changed" ? [{
2741
- href: e.href,
2742
- path: e.path,
2743
- filename: e.filename
2744
- }] : []),
2745
- body,
2746
- refusals
2747
- }, options.force ?? false);
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;
3101
+ }
3102
+ return NAMED[body.toLowerCase()] ?? match;
3103
+ });
2748
3104
  }
2749
- function pageImage(href, localImages, attachments) {
2750
- if (isExternalHref(href)) return {
2751
- href,
2752
- kind: "external"
2753
- };
2754
- const local = localImages.find((l) => l.href === href);
2755
- if (!local || local.size === void 0) return {
2756
- href,
2757
- kind: "missing"
2758
- };
2759
- const existing = attachments.find((a) => a.filename === local.filename);
2760
- if (existing && existing.size === local.size) return {
2761
- href,
2762
- kind: "reuse",
2763
- fileId: existing.fileId
2764
- };
2765
- return {
2766
- href,
2767
- kind: existing ? "changed" : "upload",
2768
- path: local.path,
2769
- filename: local.filename
2770
- };
3105
+ function isCodePoint(code) {
3106
+ return Number.isFinite(code) && code >= 0 && code <= MAX_CODE_POINT;
2771
3107
  }
2772
- function withUploadedIds(doc, ids) {
2773
- if (!doc.content) return doc;
2774
- return {
2775
- ...doc,
2776
- content: doc.content.map((n) => withUploadedId(n, ids))
2777
- };
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);
2778
3112
  }
2779
- function withUploadedId(node, ids) {
2780
- const id = node.type === "media" ? node.attrs?.["id"] : void 0;
2781
- const swapped = typeof id === "string" && ids.has(id) ? {
2782
- ...node,
2783
- attrs: {
2784
- ...node.attrs,
2785
- id: ids.get(id)
2786
- }
2787
- } : node;
2788
- if (!swapped.content) return swapped;
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;
2789
3121
  return {
2790
- ...swapped,
2791
- 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
2792
3130
  };
2793
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
+ }
2794
3166
  //#endregion
2795
3167
  //#region src/update/run.ts
2796
3168
  async function runPlan(term, plan, options, push) {
3169
+ const verdict = plan.verdict;
2797
3170
  if (options.dryRun) {
2798
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));
2799
3174
  return;
2800
3175
  }
2801
- const verdict = plan.verdict;
3176
+ for (const warning of plan.warnings) term.err(`warning: ${warning}`);
2802
3177
  if (verdict.kind === "refuse") throw new Error(verdict.message);
2803
3178
  if (verdict.kind === "confirm") {
2804
3179
  if (!await term.ask.confirm({
2805
- message: verdict.message,
2806
- flag: "--force",
3180
+ ...forcePrompt(verdict.message),
2807
3181
  default: false
2808
3182
  })) {
2809
3183
  term.out("Aborted.");
@@ -2812,6 +3186,12 @@ async function runPlan(term, plan, options, push) {
2812
3186
  }
2813
3187
  await push();
2814
3188
  }
3189
+ function forcePrompt(message) {
3190
+ return {
3191
+ message,
3192
+ flag: "--force"
3193
+ };
3194
+ }
2815
3195
  //#endregion
2816
3196
  //#region src/commands/confluence.ts
2817
3197
  async function confluenceView({ session, term }, arg, options) {
@@ -2822,7 +3202,7 @@ async function confluenceView({ session, term }, arg, options) {
2822
3202
  function formatPageView(page, nowMs, allComments) {
2823
3203
  return [
2824
3204
  kleur.bold(page.title),
2825
- ...fieldLines([
3205
+ ...fieldLines$1([
2826
3206
  ["Space", page.spaceKey],
2827
3207
  ["ID", page.id],
2828
3208
  ["Version", page.version ? String(page.version) : ""],
@@ -2839,60 +3219,44 @@ function formatPageView(page, nowMs, allComments) {
2839
3219
  async function confluenceCopy(env, arg, options) {
2840
3220
  await copyPage(env, await resolveRef(env.term.ask, arg, PAGE_REF), options.out);
2841
3221
  }
2842
- async function confluenceUpdate({ session, term, files }, arg, options) {
3222
+ async function confluenceUpdate(env, arg, options) {
3223
+ const { session, term, files } = env;
2843
3224
  const file = arg ?? await term.ask.text({
2844
3225
  message: "Path to the page Markdown file:",
2845
3226
  flag: "[file]",
2846
3227
  required: true
2847
3228
  });
2848
- const src = parsePageSource(await files.readText(file));
3229
+ const raw = await files.readText(file);
3230
+ const src = parsePageSource(raw);
2849
3231
  const state = await fetchPageState(session, src.id);
2850
- 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
+ });
2851
3236
  await runPlan(term, plan, options, async () => {
2852
- const ids = /* @__PURE__ */ new Map();
2853
- for (const upload of plan.uploads) {
2854
- term.err(`Uploading ${upload.filename} ...`);
2855
- const bytes = await files.readBytes(upload.path);
2856
- ids.set(upload.href, await uploadAttachment(session, src.id, upload.filename, bytes));
2857
- }
2858
- const version = await updatePage(session, src.id, {
3237
+ const version = await pushPage(env, {
2859
3238
  title: plan.headline.next,
3239
+ body: plan.body,
3240
+ uploads: plan.uploads
3241
+ }, {
3242
+ id: src.id,
2860
3243
  nextVersion: state.version + 1,
2861
- body: withUploadedIds(plan.body, ids),
2862
3244
  message: options.message ?? "Updated via atlass"
2863
3245
  });
2864
- 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)));
2865
3248
  });
2866
3249
  }
2867
- async function statLocalImages(files, dir, md) {
2868
- const hrefs = imageHrefs(md).filter((href) => !isExternalHref(href));
2869
- return Promise.all(hrefs.map(async (href) => {
2870
- const path = isAbsolute(href) ? href : resolve(dir, href);
2871
- return {
2872
- href,
2873
- path,
2874
- filename: basename(path),
2875
- ...await fileSize(files, path)
2876
- };
2877
- }));
2878
- }
2879
- async function fileSize(files, path) {
2880
- try {
2881
- return { size: await files.size(path) };
2882
- } catch {
2883
- return {};
2884
- }
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;
2885
3255
  }
2886
3256
  async function copyPage(env, id, out) {
2887
3257
  env.term.err(`Fetching page ${id} ...`);
2888
3258
  await runCopy(env, planPageCopy(await fetchPage(env.session, env.session.site, id), out));
2889
3259
  }
2890
- const PAGE_REF = {
2891
- message: "Confluence page id or URL:",
2892
- flag: "[page]",
2893
- parse: parsePageId,
2894
- notFound: (raw) => `Could not find a page id in "${raw}".`
2895
- };
2896
3260
  //#endregion
2897
3261
  //#region src/commands/confluence-search.ts
2898
3262
  async function confluenceSearch(env, query, options) {
@@ -2963,6 +3327,9 @@ function registerConfluence(confluence) {
2963
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));
2964
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));
2965
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));
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));
2966
3333
  outputOptions(confluence.command("list").description("List pages you starred").option("-s, --space <key>", "limit to a space")).action(jiraAction(confluenceList));
2967
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));
2968
3335
  outputOptions(confluence.command("cql <query>").description("Search Confluence pages with a raw CQL query")).action(jiraAction(confluenceCql));
@@ -3784,8 +4151,8 @@ function buildJql(params) {
3784
4151
  userClause("assignee", params.assignee),
3785
4152
  userClause("reporter", params.reporter),
3786
4153
  inClause("labels", params.label),
3787
- params.updatedSince ? `updated >= ${quote$1(params.updatedSince)}` : null,
3788
- params.text ? `text ~ ${quote$1(params.text)}` : null
4154
+ params.updatedSince ? `updated >= ${quote(params.updatedSince)}` : null,
4155
+ params.text ? `text ~ ${quote(params.text)}` : null
3789
4156
  ].filter((c) => c !== null);
3790
4157
  if (clauses.length === 0) clauses.push(RECENT_ISSUES_CLAUSE);
3791
4158
  return joinClauses(clauses, ISSUE_ORDER);
@@ -3793,7 +4160,7 @@ function buildJql(params) {
3793
4160
  function buildListJql(params) {
3794
4161
  return joinClauses([
3795
4162
  "assignee = currentUser()",
3796
- params.project ? `project = ${quote$1(params.project)}` : null,
4163
+ params.project ? `project = ${quote(params.project)}` : null,
3797
4164
  params.all ? `(${OPEN_CLAUSE} OR ${RECENT_ISSUES_CLAUSE})` : OPEN_CLAUSE
3798
4165
  ], ISSUE_ORDER);
3799
4166
  }
@@ -3928,7 +4295,9 @@ async function fetchIssue(client, site, key) {
3928
4295
  async function updateIssue(client, key, update) {
3929
4296
  const fields = { description: update.description };
3930
4297
  if (update.summary !== void 0) fields["summary"] = update.summary;
3931
- 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 ?? "";
3932
4301
  }
3933
4302
  async function fetchComments(client, key) {
3934
4303
  return (await client.getJson(`/rest/api/3/issue/${encodeURIComponent(key)}/comment?maxResults=100&orderBy=created`)).comments.map((c) => ({
@@ -4020,7 +4389,7 @@ function formatIssueView(issue, nowMs, allComments) {
4020
4389
  const colorStatus = colorForCategory(issue.statusCategory);
4021
4390
  return [
4022
4391
  `${kleur.bold(issue.key)} ${issue.summary}`,
4023
- ...fieldLines([
4392
+ ...fieldLines$1([
4024
4393
  ["Type", issue.type],
4025
4394
  ["Status", issue.status && colorStatus(issue.status)],
4026
4395
  ["Assignee", issue.assignee],
@@ -4045,15 +4414,17 @@ async function jiraUpdate({ session, term, files }, arg, options) {
4045
4414
  flag: "[file]",
4046
4415
  required: true
4047
4416
  });
4048
- const src = parseIssueSource(await files.readText(file));
4417
+ const raw = await files.readText(file);
4418
+ const src = parseIssueSource(raw);
4049
4419
  const plan = planIssueUpdate(src, await fetchIssue(session, session.site, src.key), options);
4050
4420
  await runPlan(term, plan, options, async () => {
4051
4421
  const { current, next } = plan.headline;
4052
- await updateIssue(session, src.key, {
4422
+ const updated = await updateIssue(session, src.key, {
4053
4423
  description: plan.body,
4054
4424
  summary: next !== current ? next : void 0
4055
4425
  });
4056
4426
  term.out(`Updated ${src.key}.`);
4427
+ if (updated) await files.writeText(file, rewriteFields(raw, { updated }));
4057
4428
  });
4058
4429
  }
4059
4430
  async function copyIssue(env, key, out) {
@@ -4136,7 +4507,7 @@ function registerJira(jira) {
4136
4507
  }
4137
4508
  //#endregion
4138
4509
  //#region package.json
4139
- var version = "2.0.0";
4510
+ var version = "2.1.0";
4140
4511
  //#endregion
4141
4512
  //#region src/cli/build.ts
4142
4513
  const PRODUCTS = {