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.
- package/README.md +50 -15
- package/dist/bitbucket.mjs +1 -1
- package/dist/{build-CFyTL_x2.mjs → build-_4VuIcqF.mjs} +1086 -548
- package/dist/cli.mjs +1 -1
- package/dist/confluence.mjs +1 -1
- package/dist/jira.mjs +1 -1
- package/package.json +2 -1
|
@@ -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":
|
|
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/
|
|
2037
|
-
const
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
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
|
-
|
|
2046
|
-
return (
|
|
2316
|
+
function formatLossy(counts) {
|
|
2317
|
+
return [...counts.entries()].map(([label, n]) => `${n} ${label}${n === 1 ? "" : "s"}`).join(", ");
|
|
2047
2318
|
}
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
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
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
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
|
|
2061
|
-
|
|
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
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
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/
|
|
2069
|
-
|
|
2070
|
-
const
|
|
2071
|
-
const
|
|
2072
|
-
const
|
|
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
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
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
|
-
|
|
2094
|
-
const
|
|
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
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2511
|
+
href,
|
|
2512
|
+
kind: existing ? "changed" : "upload",
|
|
2513
|
+
path: local.path,
|
|
2514
|
+
filename: local.filename
|
|
2099
2515
|
};
|
|
2100
2516
|
}
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
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
|
|
2125
|
-
const
|
|
2126
|
-
return Promise.all(
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
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
|
|
2133
|
-
if (!value) return null;
|
|
2567
|
+
async function fileSize(files, path) {
|
|
2134
2568
|
try {
|
|
2135
|
-
return
|
|
2569
|
+
return { size: await files.size(path) };
|
|
2136
2570
|
} catch {
|
|
2137
|
-
return
|
|
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/
|
|
2164
|
-
|
|
2165
|
-
|
|
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
|
-
|
|
2186
|
-
|
|
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
|
|
2208
|
-
|
|
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/
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
2259
|
-
const
|
|
2260
|
-
if (!
|
|
2261
|
-
|
|
2262
|
-
|
|
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
|
|
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/
|
|
2479
|
-
|
|
2480
|
-
const
|
|
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:
|
|
2533
|
-
}));
|
|
2534
|
-
const
|
|
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
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
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
|
|
2592
|
-
|
|
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
|
|
2595
|
-
if (
|
|
2596
|
-
return
|
|
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
|
|
2599
|
-
return
|
|
2600
|
-
|
|
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
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
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
|
-
|
|
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
|
|
2621
|
-
|
|
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/
|
|
2625
|
-
function
|
|
2626
|
-
|
|
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
|
|
2661
|
-
|
|
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
|
|
2684
|
-
|
|
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
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
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
|
-
|
|
2699
|
-
|
|
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
|
-
|
|
2702
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
|
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),
|
|
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
|
|
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
|
-
|
|
2779
|
-
const
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
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
|
|
2791
|
-
|
|
2792
|
-
|
|
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
|
-
|
|
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
|
|
2813
|
-
|
|
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
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
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
|
-
|
|
2825
|
-
|
|
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("
|
|
2871
|
-
confluence.command("
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
3667
|
-
|
|
3668
|
-
|
|
3669
|
-
|
|
3670
|
-
|
|
3671
|
-
|
|
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
|
|
4158
|
+
return joinClauses(clauses, ISSUE_ORDER);
|
|
3674
4159
|
}
|
|
3675
|
-
const OPEN_CLAUSE = "statusCategory != Done";
|
|
3676
4160
|
function buildListJql(params) {
|
|
3677
|
-
|
|
3678
|
-
|
|
3679
|
-
|
|
3680
|
-
|
|
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
|
-
|
|
3716
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
3904
|
-
|
|
3905
|
-
|
|
3906
|
-
const
|
|
3907
|
-
|
|
3908
|
-
|
|
3909
|
-
|
|
3910
|
-
|
|
3911
|
-
|
|
3912
|
-
|
|
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
|
-
|
|
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
|
|
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")
|
|
3967
|
-
jira.command("search [query]").description("Search Jira issues
|
|
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.
|
|
4510
|
+
var version = "2.1.0";
|
|
3973
4511
|
//#endregion
|
|
3974
4512
|
//#region src/cli/build.ts
|
|
3975
4513
|
const PRODUCTS = {
|