atlass 2.2.0 → 2.3.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 CHANGED
@@ -103,10 +103,26 @@ line, or the first `## Comments` or `## Attachments` in older copies.
103
103
  overwriting.
104
104
  - Confluence uploads local images referenced in the body as attachments. Jira
105
105
  update does not support image changes yet.
106
+ - Embedded videos are set to the full width of the text column. Images keep
107
+ their natural size.
106
108
  - After an update, the new `updated` (and for Confluence, `version`, and `title`
107
109
  with `--title`) is written back to the frontmatter, so the file can be pushed
108
110
  again without a fresh copy.
109
111
 
112
+ ## Attach
113
+
114
+ ```bash
115
+ atlass jira attach PROJ-123 screenshot.png logs.zip
116
+ atlass confluence attach 123456 diagram.png --comment "v2 layout"
117
+ ```
118
+
119
+ Uploads files as new attachments, one request per file. Every file is read
120
+ before anything uploads, and the first failed upload stops the run. `--json`
121
+ prints the filename, id, and size of each attachment.
122
+
123
+ Jira keeps duplicate filenames side by side. Confluence refuses a filename the
124
+ page already has, checked before any upload. `--comment` is Confluence only.
125
+
110
126
  ## Create
111
127
 
112
128
  ```bash
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { n as buildStandalone, r as fail } from "./build-H7eTLrf3.mjs";
2
+ import { n as buildStandalone, r as fail } from "./build-6PhpYMjp.mjs";
3
3
  //#region src/bitbucket.ts
4
4
  buildStandalone("bitbucket").parseAsync().catch(fail);
5
5
  //#endregion
@@ -1,6 +1,6 @@
1
1
  import { Command, Option } from "commander";
2
2
  import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
3
- import { basename, dirname, isAbsolute, join, resolve } from "node:path";
3
+ import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path";
4
4
  import { Entry } from "@napi-rs/keyring";
5
5
  import { homedir } from "node:os";
6
6
  import { spawn } from "node:child_process";
@@ -126,10 +126,11 @@ var AtlassianClient = class {
126
126
  headers: { Accept: "application/json" }
127
127
  });
128
128
  }
129
- async postMultipart(path, filename, bytes) {
129
+ async postMultipart(path, filename, bytes, extras = {}) {
130
130
  const form = new FormData();
131
- const blob = new Blob([bytes]);
131
+ const blob = new Blob([bytes], { type: extras.type });
132
132
  form.append("file", blob, filename);
133
+ for (const [name, value] of Object.entries(extras.fields ?? {})) form.append(name, value);
133
134
  return (await this.request(path, {
134
135
  method: "POST",
135
136
  headers: {
@@ -1924,6 +1925,373 @@ function outputOptions(command, limitHelp = "max results (default 25, max 100)")
1924
1925
  return command.option("-l, --limit <n>", limitHelp).option("--json", "output results as JSON").option("-c, --copy", "pick results to copy to Markdown").option("-o, --out <dir>", "output directory for --copy");
1925
1926
  }
1926
1927
  //#endregion
1928
+ //#region src/util/media-type.ts
1929
+ const BY_EXTENSION = {
1930
+ ".png": "image/png",
1931
+ ".jpg": "image/jpeg",
1932
+ ".jpeg": "image/jpeg",
1933
+ ".gif": "image/gif",
1934
+ ".webp": "image/webp",
1935
+ ".svg": "image/svg+xml",
1936
+ ".pdf": "application/pdf",
1937
+ ".txt": "text/plain",
1938
+ ".log": "text/plain",
1939
+ ".md": "text/markdown",
1940
+ ".csv": "text/csv",
1941
+ ".html": "text/html",
1942
+ ".json": "application/json",
1943
+ ".xml": "application/xml",
1944
+ ".zip": "application/zip",
1945
+ ".mp4": "video/mp4",
1946
+ ".m4v": "video/mp4",
1947
+ ".mov": "video/quicktime",
1948
+ ".webm": "video/webm",
1949
+ ".ogv": "video/ogg",
1950
+ ".avi": "video/x-msvideo",
1951
+ ".mkv": "video/x-matroska",
1952
+ ".wmv": "video/x-ms-wmv",
1953
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
1954
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
1955
+ ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation"
1956
+ };
1957
+ function mediaType(filename) {
1958
+ return BY_EXTENSION[extname(filename).toLowerCase()];
1959
+ }
1960
+ function isVideo(href) {
1961
+ return mediaType(href.split(/[?#]/)[0] ?? "")?.startsWith("video/") ?? false;
1962
+ }
1963
+ //#endregion
1964
+ //#region src/api/confluence-attachments.ts
1965
+ const UNKNOWN_SIZE = -1;
1966
+ async function listAttachments(client, id) {
1967
+ return (await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}/attachments?limit=250`)).results.map((a) => ({
1968
+ filename: a.title ?? a.id,
1969
+ fileId: mediaNodeId(a),
1970
+ size: typeof a.fileSize === "number" ? a.fileSize : UNKNOWN_SIZE
1971
+ }));
1972
+ }
1973
+ function uploadPath(pageId) {
1974
+ return `/wiki/rest/api/content/${encodeURIComponent(pageId)}/child/attachment`;
1975
+ }
1976
+ async function uploadAttachment(client, pageId, filename, bytes) {
1977
+ return (await client.postMultipart(uploadPath(pageId), filename, bytes)).results?.[0]?.extensions?.fileId ?? await fileIdByListing(client, pageId, filename);
1978
+ }
1979
+ async function attachToPage(client, pageId, filename, bytes, comment) {
1980
+ const first = (await client.postMultipart(uploadPath(pageId), filename, bytes, {
1981
+ type: mediaType(filename),
1982
+ fields: comment ? { comment } : void 0
1983
+ })).results?.[0];
1984
+ if (!first?.id) throw new Error(`Upload of "${filename}" returned no attachment.`);
1985
+ return {
1986
+ filename: first.title ?? filename,
1987
+ id: first.id,
1988
+ size: first.extensions?.fileSize ?? bytes.byteLength
1989
+ };
1990
+ }
1991
+ async function fileIdByListing(client, pageId, filename) {
1992
+ const match = (await listAttachments(client, pageId)).find((a) => a.filename === filename);
1993
+ if (match) return match.fileId;
1994
+ throw new Error(`Upload of "${filename}" did not return a fileId.`);
1995
+ }
1996
+ async function fetchAttachments(client, id) {
1997
+ return (await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}/attachments?limit=250`)).results.filter((a) => a.downloadLink).map((a) => ({
1998
+ mediaId: mediaNodeId(a),
1999
+ filename: a.title ?? a.id,
2000
+ url: withWikiContextPath(a.downloadLink ?? "")
2001
+ }));
2002
+ }
2003
+ function mediaNodeId(a) {
2004
+ return a.fileId ?? a.id;
2005
+ }
2006
+ function withWikiContextPath(link) {
2007
+ if (link.startsWith("http") || link.startsWith("/wiki")) return link;
2008
+ return `/wiki${link}`;
2009
+ }
2010
+ //#endregion
2011
+ //#region src/commands/attach.ts
2012
+ async function readLocal(files, paths) {
2013
+ const read = await Promise.all(paths.map(async (path) => ({
2014
+ path,
2015
+ bytes: await files.readBytes(path).catch(() => void 0)
2016
+ })));
2017
+ const unreadable = read.filter((r) => !r.bytes).map((r) => r.path);
2018
+ if (unreadable.length > 0) throw new Error(`Cannot read ${unreadable.join(", ")}.`);
2019
+ return read.map((r) => ({
2020
+ filename: basename(r.path),
2021
+ bytes: r.bytes
2022
+ }));
2023
+ }
2024
+ async function attachAll(term, target, local, options, upload) {
2025
+ const attached = [];
2026
+ for (const file of local) {
2027
+ term.err(`Uploading ${file.filename} ...`);
2028
+ try {
2029
+ attached.push(await upload(file));
2030
+ } catch (err) {
2031
+ if (attached.length > 0) term.err(`Already attached to ${target}: ${names(attached)}.`);
2032
+ throw err;
2033
+ }
2034
+ }
2035
+ if (options.json) term.json(attached);
2036
+ else term.out(`Attached ${names(attached)} to ${target}.`);
2037
+ }
2038
+ function names(attached) {
2039
+ return attached.map((a) => a.filename).join(", ");
2040
+ }
2041
+ //#endregion
2042
+ //#region src/commands/resolve-ref.ts
2043
+ async function resolveRef(ask, arg, kind) {
2044
+ const raw = arg ?? await ask.text({
2045
+ message: kind.message,
2046
+ flag: kind.flag,
2047
+ required: true
2048
+ });
2049
+ const ref = kind.parse(raw);
2050
+ if (!ref) throw new Error(kind.notFound(raw));
2051
+ return ref;
2052
+ }
2053
+ function layoutOf(raw) {
2054
+ return {
2055
+ bom: raw.startsWith(""),
2056
+ crlf: /\r?\n/.exec(raw)?.[0] === "\r\n"
2057
+ };
2058
+ }
2059
+ function normalized(raw) {
2060
+ return (raw.startsWith("") ? raw.slice(1) : raw).replace(/\r\n/g, "\n");
2061
+ }
2062
+ function editKeepingLayout(raw, edit) {
2063
+ return withLayout(edit(normalized(raw)), layoutOf(raw));
2064
+ }
2065
+ function withLayout(text, layout) {
2066
+ const lines = layout.crlf ? text.replace(/\n/g, "\r\n") : text;
2067
+ return layout.bom ? `${lines}` : lines;
2068
+ }
2069
+ //#endregion
2070
+ //#region src/markdown/frontmatter.ts
2071
+ const BLOCK = /^---\n([\s\S]*?)\n---\n?/;
2072
+ const KEY = /^([A-Za-z0-9_]+):\s*(.*)$/;
2073
+ const LIST_ITEM = /^ {2}- (.*)$/;
2074
+ const BARE_NUMBER = /^-?\d+(\.\d+)?$/;
2075
+ function splitFrontmatter(text) {
2076
+ const match = text.match(BLOCK);
2077
+ if (!match) return null;
2078
+ return {
2079
+ fields: parseFields(match[1] ?? ""),
2080
+ rest: text.slice(match[0].length)
2081
+ };
2082
+ }
2083
+ function formatFrontmatter(fields) {
2084
+ return [
2085
+ "---",
2086
+ ...Object.entries(fields).flatMap(([key, value]) => fieldLines(key, value)),
2087
+ "---"
2088
+ ].join("\n");
2089
+ }
2090
+ function rewriteFields(raw, fields) {
2091
+ return editKeepingLayout(raw, (text) => withFields(text, fields));
2092
+ }
2093
+ function withFields(text, fields) {
2094
+ const match = text.match(BLOCK);
2095
+ if (!match) return `${formatFrontmatter(fields)}\n\n${text}`;
2096
+ const lines = (match[1] ?? "").split("\n");
2097
+ const pending = new Map(Object.entries(fields));
2098
+ const kept = [];
2099
+ for (let i = 0; i < lines.length; i++) {
2100
+ const line = lines[i] ?? "";
2101
+ const key = line.match(KEY)?.[1];
2102
+ const value = key === void 0 ? void 0 : pending.get(key);
2103
+ if (key === void 0 || value === void 0) {
2104
+ kept.push(line);
2105
+ continue;
2106
+ }
2107
+ kept.push(...fieldLines(key, value));
2108
+ pending.delete(key);
2109
+ while (LIST_ITEM.test(lines[i + 1] ?? "")) i++;
2110
+ }
2111
+ for (const [key, value] of pending) kept.push(...fieldLines(key, value));
2112
+ const closing = match[0].endsWith("\n") ? "\n" : "";
2113
+ return `---\n${kept.join("\n")}\n---${closing}${text.slice(match[0].length)}`;
2114
+ }
2115
+ function fieldLines(key, value) {
2116
+ if (Array.isArray(value)) return value.length === 0 ? [`${key}: []`] : [`${key}:`, ...value.map((v) => ` - ${quote$1(v)}`)];
2117
+ return [`${key}: ${typeof value === "number" ? value : quote$1(value)}`];
2118
+ }
2119
+ function parseFields(block) {
2120
+ const fields = {};
2121
+ const lines = block.split("\n");
2122
+ for (let i = 0; i < lines.length; i++) {
2123
+ const m = lines[i]?.match(KEY);
2124
+ if (!m) continue;
2125
+ const key = m[1] ?? "";
2126
+ const raw = (m[2] ?? "").trim();
2127
+ if (raw === "[]") fields[key] = [];
2128
+ else if (raw === "") {
2129
+ const items = [];
2130
+ for (let item; item = lines[i + 1]?.match(LIST_ITEM); i++) items.push(unquote((item[1] ?? "").trim()));
2131
+ fields[key] = items;
2132
+ } else fields[key] = scalar(raw);
2133
+ }
2134
+ return fields;
2135
+ }
2136
+ function scalar(raw) {
2137
+ if (raw.startsWith("\"")) return unquote(raw);
2138
+ if (BARE_NUMBER.test(raw)) return Number(raw);
2139
+ return raw;
2140
+ }
2141
+ function quote$1(value) {
2142
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
2143
+ }
2144
+ function unquote(value) {
2145
+ if (!value.startsWith("\"") || !value.endsWith("\"")) return value;
2146
+ return value.slice(1, -1).replace(/\\"/g, "\"").replace(/\\\\/g, "\\");
2147
+ }
2148
+ //#endregion
2149
+ //#region src/markdown/copied-document.ts
2150
+ const GENERATED_MARKER = "<!-- atlass:generated -->";
2151
+ const COMMENTS_HEADING = "## Comments";
2152
+ const ATTACHMENTS_HEADING = "## Attachments";
2153
+ const LEGACY_SECTION_HEADING = /^## (Comments|Attachments)\s*$/;
2154
+ function render(doc) {
2155
+ return `${[
2156
+ formatFrontmatter(doc.fields),
2157
+ `# ${doc.title}`,
2158
+ doc.body,
2159
+ GENERATED_MARKER,
2160
+ commentsSection(doc.comments),
2161
+ attachmentsSection(doc.attachments)
2162
+ ].filter((s) => s.trim().length > 0).join("\n\n")}\n`;
2163
+ }
2164
+ function parse(raw) {
2165
+ const split = splitFrontmatter(normalized(raw));
2166
+ if (!split) throw new Error("Not an atlass file: no YAML frontmatter found.");
2167
+ return sourceOf(split, "marker-or-legacy-heading");
2168
+ }
2169
+ function parseDraft(raw) {
2170
+ const text = normalized(raw);
2171
+ return sourceOf(splitFrontmatter(text) ?? {
2172
+ fields: {},
2173
+ rest: text
2174
+ }, "marker");
2175
+ }
2176
+ function withGeneratedMarker(text) {
2177
+ if (text.split("\n").some(isMarker)) return text;
2178
+ return `${text.trimEnd()}\n\n${GENERATED_MARKER}\n`;
2179
+ }
2180
+ function sourceOf({ fields, rest }, ending) {
2181
+ const lines = rest.split("\n");
2182
+ const h1 = leadingH1(lines);
2183
+ const start = h1 ? h1.index + 1 : 0;
2184
+ const end = bodyEnd(lines, start, ending);
2185
+ const fallbackTitle = typeof fields["title"] === "string" ? fields["title"] : "";
2186
+ return {
2187
+ fields,
2188
+ title: h1?.title ?? fallbackTitle,
2189
+ body: lines.slice(start, end).join("\n").trim()
2190
+ };
2191
+ }
2192
+ function parseIssueSource(text) {
2193
+ const source = parse(text);
2194
+ const key = scalarString(source.fields["key"]);
2195
+ if (!key) throw new Error("Frontmatter is missing the issue `key`; re-copy the issue.");
2196
+ return {
2197
+ ...source,
2198
+ key,
2199
+ updatedAtCopy: scalarString(source.fields["updated"])
2200
+ };
2201
+ }
2202
+ function parsePageSource(text) {
2203
+ const source = parse(text);
2204
+ const id = scalarString(source.fields["id"]);
2205
+ if (!id) throw new Error("Frontmatter is missing the page `id`; re-copy the page.");
2206
+ const rawVersion = scalarString(source.fields["version"]);
2207
+ const version = rawVersion === "" ? NaN : Number(rawVersion);
2208
+ if (!Number.isFinite(version)) throw new Error("Frontmatter is missing a numeric `version`; re-copy the page.");
2209
+ return {
2210
+ ...source,
2211
+ id,
2212
+ version
2213
+ };
2214
+ }
2215
+ function scalarString(value) {
2216
+ if (typeof value === "string") return value;
2217
+ if (typeof value === "number") return String(value);
2218
+ return "";
2219
+ }
2220
+ function leadingH1(lines) {
2221
+ for (const [index, line] of lines.entries()) {
2222
+ if (line.startsWith("# ")) return {
2223
+ index,
2224
+ title: line.slice(2).trim()
2225
+ };
2226
+ if (line.trim().length > 0) return null;
2227
+ }
2228
+ return null;
2229
+ }
2230
+ function bodyEnd(lines, start, ending) {
2231
+ const after = (test) => lines.findIndex((line, i) => i >= start && test(line));
2232
+ const marker = after(isMarker);
2233
+ if (marker !== -1) return marker;
2234
+ const legacy = ending === "marker" ? -1 : after((line) => LEGACY_SECTION_HEADING.test(line));
2235
+ return legacy === -1 ? lines.length : legacy;
2236
+ }
2237
+ function isMarker(line) {
2238
+ return line.trimEnd() === GENERATED_MARKER;
2239
+ }
2240
+ function commentsSection(comments) {
2241
+ if (comments.length === 0) return "";
2242
+ const blocks = comments.map((c) => {
2243
+ const heading = `### ${c.author || "Unknown"}${c.created ? ` - ${formatDateTime(c.created)}` : ""}`;
2244
+ return c.body ? `${heading}\n\n${c.body}` : heading;
2245
+ });
2246
+ return [
2247
+ COMMENTS_HEADING,
2248
+ "",
2249
+ blocks.join("\n\n")
2250
+ ].join("\n");
2251
+ }
2252
+ function attachmentsSection(attachments) {
2253
+ if (attachments.length === 0) return "";
2254
+ const items = attachments.map((a) => `- [${a.filename}](${a.relativePath})`);
2255
+ return [
2256
+ ATTACHMENTS_HEADING,
2257
+ "",
2258
+ ...items
2259
+ ].join("\n");
2260
+ }
2261
+ //#endregion
2262
+ //#region src/commands/page-ref.ts
2263
+ const PAGE_REF = {
2264
+ message: "Confluence page id or URL:",
2265
+ flag: "[page]",
2266
+ parse: parsePageId,
2267
+ notFound: (raw) => `Could not find a page id in "${raw}".`
2268
+ };
2269
+ async function pageIdOrFile({ term, files }, arg) {
2270
+ if (!arg?.endsWith(".md")) return resolveRef(term.ask, arg, PAGE_REF);
2271
+ const id = scalarString(parse(await files.readText(arg)).fields["id"]);
2272
+ if (!id) throw new Error(`${arg} has no page \`id\` in its frontmatter.`);
2273
+ return id;
2274
+ }
2275
+ //#endregion
2276
+ //#region src/commands/confluence-attach.ts
2277
+ async function confluenceAttach({ session, term, files }, arg, paths, options) {
2278
+ const id = await resolveRef(term.ask, arg, PAGE_REF);
2279
+ const local = await readLocal(files, paths);
2280
+ await refuseClashes(session, id, local);
2281
+ await attachAll(term, `page ${id}`, local, options, (file) => attachToPage(session, id, file.filename, file.bytes, options.comment));
2282
+ }
2283
+ async function refuseClashes(client, id, local) {
2284
+ const names = local.map((f) => f.filename);
2285
+ const repeated = names.filter((name, i) => names.indexOf(name) !== i);
2286
+ if (repeated.length > 0) throw new Error(`More than one file is named ${unique(repeated).join(", ")}.`);
2287
+ const existing = new Set((await listAttachments(client, id)).map((a) => a.filename));
2288
+ const clashes = names.filter((name) => existing.has(name));
2289
+ if (clashes.length > 0) throw new Error(`${clashes.join(", ")} already attached to page ${id}.`);
2290
+ }
2291
+ function unique(names) {
2292
+ return [...new Set(names)];
2293
+ }
2294
+ //#endregion
1927
2295
  //#region src/api/confluence-create.ts
1928
2296
  async function findSpaceId(client, key) {
1929
2297
  return (await client.getJson(`/wiki/api/v2/spaces?keys=${encodeURIComponent(key)}`)).results[0]?.id ?? null;
@@ -1945,38 +2313,6 @@ async function createPage(client, page) {
1945
2313
  };
1946
2314
  }
1947
2315
  //#endregion
1948
- //#region src/api/confluence-attachments.ts
1949
- const UNKNOWN_SIZE = -1;
1950
- async function listAttachments(client, id) {
1951
- return (await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}/attachments?limit=250`)).results.map((a) => ({
1952
- filename: a.title ?? a.id,
1953
- fileId: mediaNodeId(a),
1954
- size: typeof a.fileSize === "number" ? a.fileSize : UNKNOWN_SIZE
1955
- }));
1956
- }
1957
- async function uploadAttachment(client, pageId, filename, bytes) {
1958
- return (await client.postMultipart(`/wiki/rest/api/content/${encodeURIComponent(pageId)}/child/attachment`, filename, bytes)).results?.[0]?.extensions?.fileId ?? await fileIdByListing(client, pageId, filename);
1959
- }
1960
- async function fileIdByListing(client, pageId, filename) {
1961
- const match = (await listAttachments(client, pageId)).find((a) => a.filename === filename);
1962
- if (match) return match.fileId;
1963
- throw new Error(`Upload of "${filename}" did not return a fileId.`);
1964
- }
1965
- async function fetchAttachments(client, id) {
1966
- return (await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}/attachments?limit=250`)).results.filter((a) => a.downloadLink).map((a) => ({
1967
- mediaId: mediaNodeId(a),
1968
- filename: a.title ?? a.id,
1969
- url: withWikiContextPath(a.downloadLink ?? "")
1970
- }));
1971
- }
1972
- function mediaNodeId(a) {
1973
- return a.fileId ?? a.id;
1974
- }
1975
- function withWikiContextPath(link) {
1976
- if (link.startsWith("http") || link.startsWith("/wiki")) return link;
1977
- return `/wiki${link}`;
1978
- }
1979
- //#endregion
1980
2316
  //#region src/api/confluence-pages.ts
1981
2317
  async function fetchPage(client, site, id) {
1982
2318
  const page = await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}?body-format=atlas_doc_format`);
@@ -2107,12 +2443,18 @@ function counted(n, noun) {
2107
2443
  }
2108
2444
  //#endregion
2109
2445
  //#region src/adf/from-markdown.ts
2110
- function mediaNode(source, alt) {
2446
+ const NATURAL_SIZE = { layout: "center" };
2447
+ const TEXT_WIDTH = {
2448
+ layout: "center",
2449
+ width: 100,
2450
+ widthType: "percentage"
2451
+ };
2452
+ function mediaNode(source, alt, href) {
2111
2453
  const attrs = { ...source };
2112
2454
  if (alt) attrs["alt"] = alt;
2113
2455
  return {
2114
2456
  type: "mediaSingle",
2115
- attrs: { layout: "center" },
2457
+ attrs: isVideo(href) ? TEXT_WIDTH : NATURAL_SIZE,
2116
2458
  content: [{
2117
2459
  type: "media",
2118
2460
  attrs
@@ -2299,7 +2641,7 @@ function externalMedia(href, alt) {
2299
2641
  return mediaNode({
2300
2642
  type: "external",
2301
2643
  url: href
2302
- }, alt);
2644
+ }, alt, href);
2303
2645
  }
2304
2646
  function textToken(text) {
2305
2647
  return {
@@ -2484,7 +2826,7 @@ function planPageBody(page, attachments, localImages) {
2484
2826
  type: "file",
2485
2827
  id: entry.kind === "reuse" ? entry.fileId : href,
2486
2828
  collection
2487
- }, alt);
2829
+ }, alt, href);
2488
2830
  } }),
2489
2831
  uploads: images.flatMap((e) => e.kind === "upload" || e.kind === "changed" ? [{
2490
2832
  href: e.href,
@@ -2573,274 +2915,40 @@ async function pushPage({ session, term, files }, content, push) {
2573
2915
  }
2574
2916
  return updatePage(session, push.id, {
2575
2917
  title: content.title,
2576
- nextVersion: push.nextVersion,
2577
- body: withUploadedIds(content.body, ids),
2578
- message: push.message
2579
- });
2580
- }
2581
- async function statLocalImages(files, dir, md) {
2582
- const hrefs = imageHrefs(md).filter((href) => !isExternalHref(href));
2583
- return Promise.all(hrefs.map(async (href) => {
2584
- const path = isAbsolute(href) ? href : resolve(dir, href);
2585
- return {
2586
- href,
2587
- path,
2588
- filename: basename(path),
2589
- ...await fileSize(files, path)
2590
- };
2591
- }));
2592
- }
2593
- async function fileSize(files, path) {
2594
- try {
2595
- return { size: await files.size(path) };
2596
- } catch {
2597
- return {};
2598
- }
2599
- }
2600
- //#endregion
2601
- //#region src/api/confluence-favourites.ts
2602
- function favouritePath(id) {
2603
- return `/wiki/rest/api/relation/favourite/from/user/current/to/content/${encodeURIComponent(id)}`;
2604
- }
2605
- async function starPage(client, id) {
2606
- await client.putNoContent(favouritePath(id), void 0);
2607
- }
2608
- async function unstarPage(client, id) {
2609
- await client.deleteNoContent(favouritePath(id));
2610
- }
2611
- //#endregion
2612
- //#region src/commands/resolve-ref.ts
2613
- async function resolveRef(ask, arg, kind) {
2614
- const raw = arg ?? await ask.text({
2615
- message: kind.message,
2616
- flag: kind.flag,
2617
- required: true
2618
- });
2619
- const ref = kind.parse(raw);
2620
- if (!ref) throw new Error(kind.notFound(raw));
2621
- return ref;
2622
- }
2623
- function layoutOf(raw) {
2624
- return {
2625
- bom: raw.startsWith(""),
2626
- crlf: /\r?\n/.exec(raw)?.[0] === "\r\n"
2627
- };
2628
- }
2629
- function normalized(raw) {
2630
- return (raw.startsWith("") ? raw.slice(1) : raw).replace(/\r\n/g, "\n");
2631
- }
2632
- function editKeepingLayout(raw, edit) {
2633
- return withLayout(edit(normalized(raw)), layoutOf(raw));
2634
- }
2635
- function withLayout(text, layout) {
2636
- const lines = layout.crlf ? text.replace(/\n/g, "\r\n") : text;
2637
- return layout.bom ? `${lines}` : lines;
2638
- }
2639
- //#endregion
2640
- //#region src/markdown/frontmatter.ts
2641
- const BLOCK = /^---\n([\s\S]*?)\n---\n?/;
2642
- const KEY = /^([A-Za-z0-9_]+):\s*(.*)$/;
2643
- const LIST_ITEM = /^ {2}- (.*)$/;
2644
- const BARE_NUMBER = /^-?\d+(\.\d+)?$/;
2645
- function splitFrontmatter(text) {
2646
- const match = text.match(BLOCK);
2647
- if (!match) return null;
2648
- return {
2649
- fields: parseFields(match[1] ?? ""),
2650
- rest: text.slice(match[0].length)
2651
- };
2652
- }
2653
- function formatFrontmatter(fields) {
2654
- return [
2655
- "---",
2656
- ...Object.entries(fields).flatMap(([key, value]) => fieldLines(key, value)),
2657
- "---"
2658
- ].join("\n");
2659
- }
2660
- function rewriteFields(raw, fields) {
2661
- return editKeepingLayout(raw, (text) => withFields(text, fields));
2662
- }
2663
- function withFields(text, fields) {
2664
- const match = text.match(BLOCK);
2665
- if (!match) return `${formatFrontmatter(fields)}\n\n${text}`;
2666
- const lines = (match[1] ?? "").split("\n");
2667
- const pending = new Map(Object.entries(fields));
2668
- const kept = [];
2669
- for (let i = 0; i < lines.length; i++) {
2670
- const line = lines[i] ?? "";
2671
- const key = line.match(KEY)?.[1];
2672
- const value = key === void 0 ? void 0 : pending.get(key);
2673
- if (key === void 0 || value === void 0) {
2674
- kept.push(line);
2675
- continue;
2676
- }
2677
- kept.push(...fieldLines(key, value));
2678
- pending.delete(key);
2679
- while (LIST_ITEM.test(lines[i + 1] ?? "")) i++;
2680
- }
2681
- for (const [key, value] of pending) kept.push(...fieldLines(key, value));
2682
- const closing = match[0].endsWith("\n") ? "\n" : "";
2683
- return `---\n${kept.join("\n")}\n---${closing}${text.slice(match[0].length)}`;
2684
- }
2685
- function fieldLines(key, value) {
2686
- if (Array.isArray(value)) return value.length === 0 ? [`${key}: []`] : [`${key}:`, ...value.map((v) => ` - ${quote$1(v)}`)];
2687
- return [`${key}: ${typeof value === "number" ? value : quote$1(value)}`];
2688
- }
2689
- function parseFields(block) {
2690
- const fields = {};
2691
- const lines = block.split("\n");
2692
- for (let i = 0; i < lines.length; i++) {
2693
- const m = lines[i]?.match(KEY);
2694
- if (!m) continue;
2695
- const key = m[1] ?? "";
2696
- const raw = (m[2] ?? "").trim();
2697
- if (raw === "[]") fields[key] = [];
2698
- else if (raw === "") {
2699
- const items = [];
2700
- for (let item; item = lines[i + 1]?.match(LIST_ITEM); i++) items.push(unquote((item[1] ?? "").trim()));
2701
- fields[key] = items;
2702
- } else fields[key] = scalar(raw);
2703
- }
2704
- return fields;
2705
- }
2706
- function scalar(raw) {
2707
- if (raw.startsWith("\"")) return unquote(raw);
2708
- if (BARE_NUMBER.test(raw)) return Number(raw);
2709
- return raw;
2710
- }
2711
- function quote$1(value) {
2712
- return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
2713
- }
2714
- function unquote(value) {
2715
- if (!value.startsWith("\"") || !value.endsWith("\"")) return value;
2716
- return value.slice(1, -1).replace(/\\"/g, "\"").replace(/\\\\/g, "\\");
2717
- }
2718
- //#endregion
2719
- //#region src/markdown/copied-document.ts
2720
- const GENERATED_MARKER = "<!-- atlass:generated -->";
2721
- const COMMENTS_HEADING = "## Comments";
2722
- const ATTACHMENTS_HEADING = "## Attachments";
2723
- const LEGACY_SECTION_HEADING = /^## (Comments|Attachments)\s*$/;
2724
- function render(doc) {
2725
- return `${[
2726
- formatFrontmatter(doc.fields),
2727
- `# ${doc.title}`,
2728
- doc.body,
2729
- GENERATED_MARKER,
2730
- commentsSection(doc.comments),
2731
- attachmentsSection(doc.attachments)
2732
- ].filter((s) => s.trim().length > 0).join("\n\n")}\n`;
2733
- }
2734
- function parse(raw) {
2735
- const split = splitFrontmatter(normalized(raw));
2736
- if (!split) throw new Error("Not an atlass file: no YAML frontmatter found.");
2737
- return sourceOf(split, "marker-or-legacy-heading");
2738
- }
2739
- function parseDraft(raw) {
2740
- const text = normalized(raw);
2741
- return sourceOf(splitFrontmatter(text) ?? {
2742
- fields: {},
2743
- rest: text
2744
- }, "marker");
2745
- }
2746
- function withGeneratedMarker(text) {
2747
- if (text.split("\n").some(isMarker)) return text;
2748
- return `${text.trimEnd()}\n\n${GENERATED_MARKER}\n`;
2749
- }
2750
- function sourceOf({ fields, rest }, ending) {
2751
- const lines = rest.split("\n");
2752
- const h1 = leadingH1(lines);
2753
- const start = h1 ? h1.index + 1 : 0;
2754
- const end = bodyEnd(lines, start, ending);
2755
- const fallbackTitle = typeof fields["title"] === "string" ? fields["title"] : "";
2756
- return {
2757
- fields,
2758
- title: h1?.title ?? fallbackTitle,
2759
- body: lines.slice(start, end).join("\n").trim()
2760
- };
2761
- }
2762
- function parseIssueSource(text) {
2763
- const source = parse(text);
2764
- const key = scalarString(source.fields["key"]);
2765
- if (!key) throw new Error("Frontmatter is missing the issue `key`; re-copy the issue.");
2766
- return {
2767
- ...source,
2768
- key,
2769
- updatedAtCopy: scalarString(source.fields["updated"])
2770
- };
2771
- }
2772
- function parsePageSource(text) {
2773
- const source = parse(text);
2774
- const id = scalarString(source.fields["id"]);
2775
- if (!id) throw new Error("Frontmatter is missing the page `id`; re-copy the page.");
2776
- const rawVersion = scalarString(source.fields["version"]);
2777
- const version = rawVersion === "" ? NaN : Number(rawVersion);
2778
- if (!Number.isFinite(version)) throw new Error("Frontmatter is missing a numeric `version`; re-copy the page.");
2779
- return {
2780
- ...source,
2781
- id,
2782
- version
2783
- };
2784
- }
2785
- function scalarString(value) {
2786
- if (typeof value === "string") return value;
2787
- if (typeof value === "number") return String(value);
2788
- return "";
2789
- }
2790
- function leadingH1(lines) {
2791
- for (const [index, line] of lines.entries()) {
2792
- if (line.startsWith("# ")) return {
2793
- index,
2794
- title: line.slice(2).trim()
2795
- };
2796
- if (line.trim().length > 0) return null;
2797
- }
2798
- return null;
2918
+ nextVersion: push.nextVersion,
2919
+ body: withUploadedIds(content.body, ids),
2920
+ message: push.message
2921
+ });
2799
2922
  }
2800
- function bodyEnd(lines, start, ending) {
2801
- const after = (test) => lines.findIndex((line, i) => i >= start && test(line));
2802
- const marker = after(isMarker);
2803
- if (marker !== -1) return marker;
2804
- const legacy = ending === "marker" ? -1 : after((line) => LEGACY_SECTION_HEADING.test(line));
2805
- return legacy === -1 ? lines.length : legacy;
2923
+ async function statLocalImages(files, dir, md) {
2924
+ const hrefs = imageHrefs(md).filter((href) => !isExternalHref(href));
2925
+ return Promise.all(hrefs.map(async (href) => {
2926
+ const path = isAbsolute(href) ? href : resolve(dir, href);
2927
+ return {
2928
+ href,
2929
+ path,
2930
+ filename: basename(path),
2931
+ ...await fileSize(files, path)
2932
+ };
2933
+ }));
2806
2934
  }
2807
- function isMarker(line) {
2808
- return line.trimEnd() === GENERATED_MARKER;
2935
+ async function fileSize(files, path) {
2936
+ try {
2937
+ return { size: await files.size(path) };
2938
+ } catch {
2939
+ return {};
2940
+ }
2809
2941
  }
2810
- function commentsSection(comments) {
2811
- if (comments.length === 0) return "";
2812
- const blocks = comments.map((c) => {
2813
- const heading = `### ${c.author || "Unknown"}${c.created ? ` - ${formatDateTime(c.created)}` : ""}`;
2814
- return c.body ? `${heading}\n\n${c.body}` : heading;
2815
- });
2816
- return [
2817
- COMMENTS_HEADING,
2818
- "",
2819
- blocks.join("\n\n")
2820
- ].join("\n");
2942
+ //#endregion
2943
+ //#region src/api/confluence-favourites.ts
2944
+ function favouritePath(id) {
2945
+ return `/wiki/rest/api/relation/favourite/from/user/current/to/content/${encodeURIComponent(id)}`;
2821
2946
  }
2822
- function attachmentsSection(attachments) {
2823
- if (attachments.length === 0) return "";
2824
- const items = attachments.map((a) => `- [${a.filename}](${a.relativePath})`);
2825
- return [
2826
- ATTACHMENTS_HEADING,
2827
- "",
2828
- ...items
2829
- ].join("\n");
2947
+ async function starPage(client, id) {
2948
+ await client.putNoContent(favouritePath(id), void 0);
2830
2949
  }
2831
- //#endregion
2832
- //#region src/commands/page-ref.ts
2833
- const PAGE_REF = {
2834
- message: "Confluence page id or URL:",
2835
- flag: "[page]",
2836
- parse: parsePageId,
2837
- notFound: (raw) => `Could not find a page id in "${raw}".`
2838
- };
2839
- async function pageIdOrFile({ term, files }, arg) {
2840
- if (!arg?.endsWith(".md")) return resolveRef(term.ask, arg, PAGE_REF);
2841
- const id = scalarString(parse(await files.readText(arg)).fields["id"]);
2842
- if (!id) throw new Error(`${arg} has no page \`id\` in its frontmatter.`);
2843
- return id;
2950
+ async function unstarPage(client, id) {
2951
+ await client.deleteNoContent(favouritePath(id));
2844
2952
  }
2845
2953
  //#endregion
2846
2954
  //#region src/commands/confluence-star.ts
@@ -3359,6 +3467,7 @@ function registerConfluence(confluence) {
3359
3467
  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));
3360
3468
  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));
3361
3469
  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));
3470
+ confluence.command("attach <page> <files...>").description("Upload files as attachments to a Confluence page (id or URL)").option("--comment <text>", "attachment comment, applied to every file").option("--json", "print the uploaded attachments as JSON").action(jiraAction(confluenceAttach));
3362
3471
  confluence.command("star [page]").description("Star a Confluence page (id, URL, or copied file)").action(jiraAction(confluenceStar));
3363
3472
  confluence.command("unstar [page]").description("Unstar a Confluence page (id, URL, or copied file)").action(jiraAction(confluenceUnstar));
3364
3473
  outputOptions(confluence.command("list").description("List pages you starred").option("-s, --space <key>", "limit to a space")).action(jiraAction(confluenceList));
@@ -3367,6 +3476,112 @@ function registerConfluence(confluence) {
3367
3476
  return confluence;
3368
3477
  }
3369
3478
  //#endregion
3479
+ //#region src/api/jira-attachments.ts
3480
+ async function attachToIssue(client, key, filename, bytes) {
3481
+ const [first] = await client.postMultipart(`/rest/api/3/issue/${encodeURIComponent(key)}/attachments`, filename, bytes, { type: mediaType(filename) });
3482
+ if (!first) throw new Error(`Upload of "${filename}" returned no attachment.`);
3483
+ return {
3484
+ filename: first.filename,
3485
+ id: first.id,
3486
+ size: first.size
3487
+ };
3488
+ }
3489
+ //#endregion
3490
+ //#region src/api/jira-url.ts
3491
+ function browseUrl(site, key) {
3492
+ return `${site}/browse/${key}`;
3493
+ }
3494
+ function boardUrl(site, id) {
3495
+ return `${site}/secure/RapidBoard.jspa?rapidView=${id}`;
3496
+ }
3497
+ //#endregion
3498
+ //#region src/api/jira-issues.ts
3499
+ const FIELDS = [
3500
+ "summary",
3501
+ "description",
3502
+ "issuetype",
3503
+ "status",
3504
+ "assignee",
3505
+ "reporter",
3506
+ "priority",
3507
+ "labels",
3508
+ "created",
3509
+ "updated",
3510
+ "attachment"
3511
+ ].join(",");
3512
+ async function fetchIssue(client, site, key) {
3513
+ const issue = await client.getJson(`/rest/api/3/issue/${encodeURIComponent(key)}?fields=${FIELDS}`);
3514
+ const comments = await fetchComments(client, key);
3515
+ const f = issue.fields;
3516
+ return {
3517
+ key: issue.key,
3518
+ url: browseUrl(site, issue.key),
3519
+ summary: f.summary ?? "",
3520
+ type: f.issuetype?.name ?? "",
3521
+ status: f.status?.name ?? "",
3522
+ statusCategory: f.status?.statusCategory?.key ?? "",
3523
+ assignee: f.assignee?.displayName ?? "Unassigned",
3524
+ reporter: f.reporter?.displayName ?? "",
3525
+ priority: f.priority?.name ?? "",
3526
+ labels: f.labels ?? [],
3527
+ created: f.created ?? "",
3528
+ updated: f.updated ?? "",
3529
+ description: f.description ?? null,
3530
+ comments,
3531
+ attachments: (f.attachment ?? []).map((a) => ({
3532
+ mediaId: a.id,
3533
+ filename: a.filename,
3534
+ url: a.content
3535
+ }))
3536
+ };
3537
+ }
3538
+ async function updateIssue(client, key, update) {
3539
+ const fields = { description: update.description };
3540
+ if (update.summary !== void 0) fields["summary"] = update.summary;
3541
+ const path = `/rest/api/3/issue/${encodeURIComponent(key)}`;
3542
+ await client.putNoContent(path, { fields });
3543
+ return (await client.getJson(`${path}?fields=updated`)).fields.updated ?? "";
3544
+ }
3545
+ async function fetchComments(client, key) {
3546
+ return (await client.getJson(`/rest/api/3/issue/${encodeURIComponent(key)}/comment?maxResults=100&orderBy=created`)).comments.map((c) => ({
3547
+ author: c.author?.displayName ?? "",
3548
+ created: c.created ?? "",
3549
+ body: c.body ?? null
3550
+ }));
3551
+ }
3552
+ //#endregion
3553
+ //#region src/api/offset-pages.ts
3554
+ async function allOffsetPages(client, pathAt) {
3555
+ const all = [];
3556
+ for (let startAt = 0;;) {
3557
+ const res = await client.getJson(pathAt(startAt));
3558
+ const values = res.values ?? [];
3559
+ all.push(...values);
3560
+ if (res.isLast || values.length === 0) return all;
3561
+ startAt += values.length;
3562
+ }
3563
+ }
3564
+ //#endregion
3565
+ //#region src/api/jira-projects.ts
3566
+ async function listProjects(client, site, query) {
3567
+ return (await allOffsetPages(client, (startAt) => `/rest/api/3/project/search?${projectSearchQuery(query, startAt)}`)).map((p) => ({
3568
+ key: p.key,
3569
+ name: p.name,
3570
+ id: p.id,
3571
+ type: p.projectTypeKey ?? "",
3572
+ url: browseUrl(site, p.key)
3573
+ }));
3574
+ }
3575
+ function projectSearchQuery(query, startAt) {
3576
+ const params = new URLSearchParams({
3577
+ orderBy: "key",
3578
+ maxResults: String(50),
3579
+ startAt: String(startAt)
3580
+ });
3581
+ if (query) params.set("query", query);
3582
+ return params.toString();
3583
+ }
3584
+ //#endregion
3370
3585
  //#region src/api/jira-statuses.ts
3371
3586
  async function listStatuses(client, project) {
3372
3587
  return dedupeAndSortStatuses((project ? await fetchProjectStatuses(client, project) : await fetchAllStatuses(client)).map(toStatusSummary));
@@ -3375,51 +3590,140 @@ async function statusesById(client) {
3375
3590
  const raw = await fetchAllStatuses(client);
3376
3591
  return new Map(raw.map((s) => [s.id, toStatusSummary(s)]));
3377
3592
  }
3378
- function fetchAllStatuses(client) {
3379
- return client.getJson("/rest/api/3/status");
3593
+ function fetchAllStatuses(client) {
3594
+ return client.getJson("/rest/api/3/status");
3595
+ }
3596
+ async function fetchProjectStatuses(client, project) {
3597
+ let groups;
3598
+ try {
3599
+ groups = await client.getJson(`/rest/api/3/project/${encodeURIComponent(project)}/statuses`);
3600
+ } catch (err) {
3601
+ if (err instanceof HttpError && err.status === 404) throw new Error(`No project found with key "${project}".`);
3602
+ throw err;
3603
+ }
3604
+ return groups.flatMap((g) => g.statuses ?? []);
3605
+ }
3606
+ function toStatusSummary(s) {
3607
+ return {
3608
+ name: s.name,
3609
+ id: s.id,
3610
+ category: s.statusCategory?.name ?? "",
3611
+ categoryKey: s.statusCategory?.key ?? ""
3612
+ };
3613
+ }
3614
+ const CATEGORY_ORDER = {
3615
+ new: 0,
3616
+ indeterminate: 1,
3617
+ done: 2
3618
+ };
3619
+ const UNKNOWN_CATEGORY_RANK = Object.keys(CATEGORY_ORDER).length;
3620
+ function dedupeAndSortStatuses(statuses) {
3621
+ const byNameCategory = /* @__PURE__ */ new Map();
3622
+ for (const s of statuses) {
3623
+ const key = `${s.name}\0${s.categoryKey}`;
3624
+ if (!byNameCategory.has(key)) byNameCategory.set(key, s);
3625
+ }
3626
+ return [...byNameCategory.values()].sort((a, b) => {
3627
+ const rank = (s) => CATEGORY_ORDER[s.categoryKey] ?? UNKNOWN_CATEGORY_RANK;
3628
+ return rank(a) - rank(b) || a.name.localeCompare(b.name);
3629
+ });
3630
+ }
3631
+ //#endregion
3632
+ //#region src/commands/jira.ts
3633
+ async function jiraProjects({ session, term }, query, options) {
3634
+ const projects = await listProjects(session, session.site, query);
3635
+ if (options.json) term.json(projects);
3636
+ else if (projects.length === 0) term.out("No matching projects.");
3637
+ else term.out(formatProjectRows(projects));
3638
+ }
3639
+ function formatProjectRows(projects) {
3640
+ const width = Math.max(...projects.map((p) => p.key.length));
3641
+ return projects.map((p) => `${p.key.padEnd(width)} ${p.name}`);
3642
+ }
3643
+ async function jiraStatuses({ session, term }, query, options) {
3644
+ let statuses = await listStatuses(session, options.project);
3645
+ if (query) {
3646
+ const needle = query.toLowerCase();
3647
+ statuses = statuses.filter((s) => s.name.toLowerCase().includes(needle));
3648
+ }
3649
+ if (options.json) term.json(statuses);
3650
+ else if (statuses.length === 0) term.out("No matching statuses.");
3651
+ else term.out(formatStatusRows(statuses));
3652
+ }
3653
+ function formatStatusRows(statuses) {
3654
+ const width = Math.max(...statuses.map((s) => s.name.length));
3655
+ return statuses.map((s) => `${s.name.padEnd(width)} ${s.category}`);
3656
+ }
3657
+ async function jiraView({ session, term }, arg, options) {
3658
+ const key = await resolveRef(term.ask, arg, ISSUE_REF);
3659
+ const lines = formatIssueView(await fetchIssue(session, session.site, key), Date.now(), options.allComments ?? false);
3660
+ await term.page(lines.join("\n"), { pager: options.pager });
3661
+ }
3662
+ const CATEGORY_COLORS = {
3663
+ new: kleur.cyan,
3664
+ indeterminate: kleur.yellow,
3665
+ done: kleur.green
3666
+ };
3667
+ function colorForCategory(category) {
3668
+ return CATEGORY_COLORS[category] ?? ((text) => text);
3669
+ }
3670
+ function formatIssueView(issue, nowMs, allComments) {
3671
+ const colorStatus = colorForCategory(issue.statusCategory);
3672
+ return [
3673
+ `${kleur.bold(issue.key)} ${issue.summary}`,
3674
+ ...fieldLines$1([
3675
+ ["Type", issue.type],
3676
+ ["Status", issue.status && colorStatus(issue.status)],
3677
+ ["Assignee", issue.assignee],
3678
+ ["Reporter", issue.reporter],
3679
+ ["Priority", issue.priority],
3680
+ ["Labels", issue.labels.join(", ")],
3681
+ ["Created", dateWithAge(issue.created, nowMs)],
3682
+ ["Updated", dateWithAge(issue.updated, nowMs)],
3683
+ ["URL", issue.url]
3684
+ ]),
3685
+ ...bodyLines(issue.description),
3686
+ ...commentSection(renderedComments(issue.comments), { allComments }),
3687
+ ...attachmentSection(issue.attachments)
3688
+ ];
3689
+ }
3690
+ async function jiraCopy(env, arg, options) {
3691
+ await copyIssue(env, await resolveRef(env.term.ask, arg, ISSUE_REF), options.out);
3380
3692
  }
3381
- async function fetchProjectStatuses(client, project) {
3382
- let groups;
3383
- try {
3384
- groups = await client.getJson(`/rest/api/3/project/${encodeURIComponent(project)}/statuses`);
3385
- } catch (err) {
3386
- if (err instanceof HttpError && err.status === 404) throw new Error(`No project found with key "${project}".`);
3387
- throw err;
3388
- }
3389
- return groups.flatMap((g) => g.statuses ?? []);
3693
+ async function jiraUpdate({ session, term, files }, arg, options) {
3694
+ const file = arg ?? await term.ask.text({
3695
+ message: "Path to the issue Markdown file:",
3696
+ flag: "[file]",
3697
+ required: true
3698
+ });
3699
+ const raw = await files.readText(file);
3700
+ const src = parseIssueSource(raw);
3701
+ const plan = planIssueUpdate(src, await fetchIssue(session, session.site, src.key), options);
3702
+ await runPlan(term, plan, options, async () => {
3703
+ const { current, next } = plan.headline;
3704
+ const updated = await updateIssue(session, src.key, {
3705
+ description: plan.body,
3706
+ summary: next !== current ? next : void 0
3707
+ });
3708
+ term.out(`Updated ${src.key}.`);
3709
+ if (updated) await files.writeText(file, rewriteFields(raw, { updated }));
3710
+ });
3390
3711
  }
3391
- function toStatusSummary(s) {
3392
- return {
3393
- name: s.name,
3394
- id: s.id,
3395
- category: s.statusCategory?.name ?? "",
3396
- categoryKey: s.statusCategory?.key ?? ""
3397
- };
3712
+ async function copyIssue(env, key, out) {
3713
+ env.term.err(`Fetching ${key} ...`);
3714
+ await runCopy(env, planIssueCopy(await fetchIssue(env.session, env.session.site, key), out));
3398
3715
  }
3399
- const CATEGORY_ORDER = {
3400
- new: 0,
3401
- indeterminate: 1,
3402
- done: 2
3716
+ const ISSUE_REF = {
3717
+ message: "Jira issue key or URL:",
3718
+ flag: "[issue]",
3719
+ parse: parseIssueKey,
3720
+ notFound: (raw) => `Could not find an issue key in "${raw}" (expected e.g. PROJ-123).`
3403
3721
  };
3404
- const UNKNOWN_CATEGORY_RANK = Object.keys(CATEGORY_ORDER).length;
3405
- function dedupeAndSortStatuses(statuses) {
3406
- const byNameCategory = /* @__PURE__ */ new Map();
3407
- for (const s of statuses) {
3408
- const key = `${s.name}\0${s.categoryKey}`;
3409
- if (!byNameCategory.has(key)) byNameCategory.set(key, s);
3410
- }
3411
- return [...byNameCategory.values()].sort((a, b) => {
3412
- const rank = (s) => CATEGORY_ORDER[s.categoryKey] ?? UNKNOWN_CATEGORY_RANK;
3413
- return rank(a) - rank(b) || a.name.localeCompare(b.name);
3414
- });
3415
- }
3416
3722
  //#endregion
3417
- //#region src/api/jira-url.ts
3418
- function browseUrl(site, key) {
3419
- return `${site}/browse/${key}`;
3420
- }
3421
- function boardUrl(site, id) {
3422
- return `${site}/secure/RapidBoard.jspa?rapidView=${id}`;
3723
+ //#region src/commands/jira-attach.ts
3724
+ async function jiraAttach({ session, term, files }, arg, paths, options) {
3725
+ const key = await resolveRef(term.ask, arg, ISSUE_REF);
3726
+ await attachAll(term, key, await readLocal(files, paths), options, (file) => attachToIssue(session, key, file.filename, file.bytes));
3423
3727
  }
3424
3728
  //#endregion
3425
3729
  //#region src/api/jira-board.ts
@@ -3464,18 +3768,6 @@ function estimationOf(config) {
3464
3768
  } : null;
3465
3769
  }
3466
3770
  //#endregion
3467
- //#region src/api/offset-pages.ts
3468
- async function allOffsetPages(client, pathAt) {
3469
- const all = [];
3470
- for (let startAt = 0;;) {
3471
- const res = await client.getJson(pathAt(startAt));
3472
- const values = res.values ?? [];
3473
- all.push(...values);
3474
- if (res.isLast || values.length === 0) return all;
3475
- startAt += values.length;
3476
- }
3477
- }
3478
- //#endregion
3479
3771
  //#region src/api/jira-boards.ts
3480
3772
  async function listBoards(client, site) {
3481
3773
  return (await allOffsetPages(client, (startAt) => `/rest/agile/1.0/board?startAt=${startAt}&maxResults=50`)).map((b) => ({
@@ -3655,26 +3947,6 @@ function toJiraUser(u) {
3655
3947
  };
3656
3948
  }
3657
3949
  //#endregion
3658
- //#region src/api/jira-projects.ts
3659
- async function listProjects(client, site, query) {
3660
- return (await allOffsetPages(client, (startAt) => `/rest/api/3/project/search?${projectSearchQuery(query, startAt)}`)).map((p) => ({
3661
- key: p.key,
3662
- name: p.name,
3663
- id: p.id,
3664
- type: p.projectTypeKey ?? "",
3665
- url: browseUrl(site, p.key)
3666
- }));
3667
- }
3668
- function projectSearchQuery(query, startAt) {
3669
- const params = new URLSearchParams({
3670
- orderBy: "key",
3671
- maxResults: String(50),
3672
- startAt: String(startAt)
3673
- });
3674
- if (query) params.set("query", query);
3675
- return params.toString();
3676
- }
3677
- //#endregion
3678
3950
  //#region src/commands/jira-create-resolve.ts
3679
3951
  async function resolveProject(ask, client, site, arg, strict) {
3680
3952
  if (arg) return arg.toUpperCase();
@@ -4570,152 +4842,6 @@ function searchHint(err, project) {
4570
4842
  return command ? /* @__PURE__ */ new Error(`${err.message} Run \`${command}\` to see the options.`) : err;
4571
4843
  }
4572
4844
  //#endregion
4573
- //#region src/api/jira-issues.ts
4574
- const FIELDS = [
4575
- "summary",
4576
- "description",
4577
- "issuetype",
4578
- "status",
4579
- "assignee",
4580
- "reporter",
4581
- "priority",
4582
- "labels",
4583
- "created",
4584
- "updated",
4585
- "attachment"
4586
- ].join(",");
4587
- async function fetchIssue(client, site, key) {
4588
- const issue = await client.getJson(`/rest/api/3/issue/${encodeURIComponent(key)}?fields=${FIELDS}`);
4589
- const comments = await fetchComments(client, key);
4590
- const f = issue.fields;
4591
- return {
4592
- key: issue.key,
4593
- url: browseUrl(site, issue.key),
4594
- summary: f.summary ?? "",
4595
- type: f.issuetype?.name ?? "",
4596
- status: f.status?.name ?? "",
4597
- statusCategory: f.status?.statusCategory?.key ?? "",
4598
- assignee: f.assignee?.displayName ?? "Unassigned",
4599
- reporter: f.reporter?.displayName ?? "",
4600
- priority: f.priority?.name ?? "",
4601
- labels: f.labels ?? [],
4602
- created: f.created ?? "",
4603
- updated: f.updated ?? "",
4604
- description: f.description ?? null,
4605
- comments,
4606
- attachments: (f.attachment ?? []).map((a) => ({
4607
- mediaId: a.id,
4608
- filename: a.filename,
4609
- url: a.content
4610
- }))
4611
- };
4612
- }
4613
- async function updateIssue(client, key, update) {
4614
- const fields = { description: update.description };
4615
- if (update.summary !== void 0) fields["summary"] = update.summary;
4616
- const path = `/rest/api/3/issue/${encodeURIComponent(key)}`;
4617
- await client.putNoContent(path, { fields });
4618
- return (await client.getJson(`${path}?fields=updated`)).fields.updated ?? "";
4619
- }
4620
- async function fetchComments(client, key) {
4621
- return (await client.getJson(`/rest/api/3/issue/${encodeURIComponent(key)}/comment?maxResults=100&orderBy=created`)).comments.map((c) => ({
4622
- author: c.author?.displayName ?? "",
4623
- created: c.created ?? "",
4624
- body: c.body ?? null
4625
- }));
4626
- }
4627
- //#endregion
4628
- //#region src/commands/jira.ts
4629
- async function jiraProjects({ session, term }, query, options) {
4630
- const projects = await listProjects(session, session.site, query);
4631
- if (options.json) term.json(projects);
4632
- else if (projects.length === 0) term.out("No matching projects.");
4633
- else term.out(formatProjectRows(projects));
4634
- }
4635
- function formatProjectRows(projects) {
4636
- const width = Math.max(...projects.map((p) => p.key.length));
4637
- return projects.map((p) => `${p.key.padEnd(width)} ${p.name}`);
4638
- }
4639
- async function jiraStatuses({ session, term }, query, options) {
4640
- let statuses = await listStatuses(session, options.project);
4641
- if (query) {
4642
- const needle = query.toLowerCase();
4643
- statuses = statuses.filter((s) => s.name.toLowerCase().includes(needle));
4644
- }
4645
- if (options.json) term.json(statuses);
4646
- else if (statuses.length === 0) term.out("No matching statuses.");
4647
- else term.out(formatStatusRows(statuses));
4648
- }
4649
- function formatStatusRows(statuses) {
4650
- const width = Math.max(...statuses.map((s) => s.name.length));
4651
- return statuses.map((s) => `${s.name.padEnd(width)} ${s.category}`);
4652
- }
4653
- async function jiraView({ session, term }, arg, options) {
4654
- const key = await resolveRef(term.ask, arg, ISSUE_REF);
4655
- const lines = formatIssueView(await fetchIssue(session, session.site, key), Date.now(), options.allComments ?? false);
4656
- await term.page(lines.join("\n"), { pager: options.pager });
4657
- }
4658
- const CATEGORY_COLORS = {
4659
- new: kleur.cyan,
4660
- indeterminate: kleur.yellow,
4661
- done: kleur.green
4662
- };
4663
- function colorForCategory(category) {
4664
- return CATEGORY_COLORS[category] ?? ((text) => text);
4665
- }
4666
- function formatIssueView(issue, nowMs, allComments) {
4667
- const colorStatus = colorForCategory(issue.statusCategory);
4668
- return [
4669
- `${kleur.bold(issue.key)} ${issue.summary}`,
4670
- ...fieldLines$1([
4671
- ["Type", issue.type],
4672
- ["Status", issue.status && colorStatus(issue.status)],
4673
- ["Assignee", issue.assignee],
4674
- ["Reporter", issue.reporter],
4675
- ["Priority", issue.priority],
4676
- ["Labels", issue.labels.join(", ")],
4677
- ["Created", dateWithAge(issue.created, nowMs)],
4678
- ["Updated", dateWithAge(issue.updated, nowMs)],
4679
- ["URL", issue.url]
4680
- ]),
4681
- ...bodyLines(issue.description),
4682
- ...commentSection(renderedComments(issue.comments), { allComments }),
4683
- ...attachmentSection(issue.attachments)
4684
- ];
4685
- }
4686
- async function jiraCopy(env, arg, options) {
4687
- await copyIssue(env, await resolveRef(env.term.ask, arg, ISSUE_REF), options.out);
4688
- }
4689
- async function jiraUpdate({ session, term, files }, arg, options) {
4690
- const file = arg ?? await term.ask.text({
4691
- message: "Path to the issue Markdown file:",
4692
- flag: "[file]",
4693
- required: true
4694
- });
4695
- const raw = await files.readText(file);
4696
- const src = parseIssueSource(raw);
4697
- const plan = planIssueUpdate(src, await fetchIssue(session, session.site, src.key), options);
4698
- await runPlan(term, plan, options, async () => {
4699
- const { current, next } = plan.headline;
4700
- const updated = await updateIssue(session, src.key, {
4701
- description: plan.body,
4702
- summary: next !== current ? next : void 0
4703
- });
4704
- term.out(`Updated ${src.key}.`);
4705
- if (updated) await files.writeText(file, rewriteFields(raw, { updated }));
4706
- });
4707
- }
4708
- async function copyIssue(env, key, out) {
4709
- env.term.err(`Fetching ${key} ...`);
4710
- await runCopy(env, planIssueCopy(await fetchIssue(env.session, env.session.site, key), out));
4711
- }
4712
- const ISSUE_REF = {
4713
- message: "Jira issue key or URL:",
4714
- flag: "[issue]",
4715
- parse: parseIssueKey,
4716
- notFound: (raw) => `Could not find an issue key in "${raw}" (expected e.g. PROJ-123).`
4717
- };
4718
- //#endregion
4719
4845
  //#region src/commands/jira-search.ts
4720
4846
  async function jiraSearch(env, query, options) {
4721
4847
  const { session } = env;
@@ -4855,6 +4981,7 @@ function registerJira(jira) {
4855
4981
  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));
4856
4982
  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));
4857
4983
  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));
4984
+ jira.command("attach <issue> <files...>").description("Upload files as attachments to a Jira issue (key or URL)").option("--json", "print the uploaded attachments as JSON").action(jiraAction(jiraAttach));
4858
4985
  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").option(...FIELD_OPTION), "max results (default all, max 100)").action(jiraAction(jiraList));
4859
4986
  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("--sprint <sprint>", "limit to a sprint: active, future, an id, or a name (repeatable)", collect).option("-u, --updated <when>", "changed since 7d, 2w, 3m, or YYYY-MM-DD").option("--open", "exclude issues in the Done category").option(...FIELD_OPTION).addOption(moved("--jql <jql>", "jira jql"))).action(jiraAction(jiraSearch));
4860
4987
  outputOptions(jira.command("jql <query>").description("Search Jira issues with a raw JQL query").option(...FIELD_OPTION)).action(jiraAction(jiraJql));
@@ -4862,7 +4989,7 @@ function registerJira(jira) {
4862
4989
  }
4863
4990
  //#endregion
4864
4991
  //#region package.json
4865
- var version = "2.2.0";
4992
+ var version = "2.3.0";
4866
4993
  //#endregion
4867
4994
  //#region src/cli/build.ts
4868
4995
  const PRODUCTS = {
package/dist/cli.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { r as fail, t as buildAtlass } from "./build-H7eTLrf3.mjs";
2
+ import { r as fail, t as buildAtlass } from "./build-6PhpYMjp.mjs";
3
3
  //#region src/cli.ts
4
4
  buildAtlass().parseAsync().catch(fail);
5
5
  //#endregion
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { n as buildStandalone, r as fail } from "./build-H7eTLrf3.mjs";
2
+ import { n as buildStandalone, r as fail } from "./build-6PhpYMjp.mjs";
3
3
  //#region src/confluence.ts
4
4
  buildStandalone("confluence").parseAsync().catch(fail);
5
5
  //#endregion
package/dist/jira.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { n as buildStandalone, r as fail } from "./build-H7eTLrf3.mjs";
2
+ import { n as buildStandalone, r as fail } from "./build-6PhpYMjp.mjs";
3
3
  //#region src/jira.ts
4
4
  buildStandalone("jira").parseAsync().catch(fail);
5
5
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atlass",
3
- "version": "2.2.0",
3
+ "version": "2.3.0",
4
4
  "description": "CLI to copy Jira issues and Confluence pages to Markdown, and update Confluence pages.",
5
5
  "license": "MIT",
6
6
  "repository": {