atlass 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +92 -6
  2. package/dist/cli.mjs +734 -19
  3. package/package.json +8 -3
package/README.md CHANGED
@@ -1,10 +1,12 @@
1
1
  # atlass
2
2
 
3
- CLI to copy Jira issues and Confluence pages to Markdown.
3
+ CLI to copy Jira issues and Confluence pages to Markdown, and push edits back to
4
+ Confluence.
4
5
 
5
6
  Fetches an issue or page from Atlassian Cloud, converts its rich content to
6
7
  Markdown, writes a `.md` file with YAML frontmatter, and downloads any
7
- attachments alongside it.
8
+ attachments alongside it. Confluence pages can be edited locally and updated
9
+ back on the server.
8
10
 
9
11
  ## Install
10
12
 
@@ -72,6 +74,87 @@ atlass confluence copy # prompts for the id or URL
72
74
  Accepts a numeric page id or a page URL. Writes `123456-title-slug.md` to the
73
75
  current directory.
74
76
 
77
+ ### Update a Confluence page
78
+
79
+ Copy a page, edit the Markdown, then push it back:
80
+
81
+ ```bash
82
+ atlass confluence update 123456-title-slug.md
83
+ atlass confluence update # prompts for the file path
84
+ atlass confluence update file.md --dry-run # show what would change, write nothing
85
+ atlass confluence update file.md --title # also rename the page to the H1
86
+ atlass confluence update file.md -m "fix typo"
87
+ ```
88
+
89
+ The page id and version come from the file's frontmatter, so the file is
90
+ self-describing. The body is everything between the H1 and the `## Comments`
91
+ section; the frontmatter, the H1, and the `## Comments` / `## Attachments`
92
+ sections are not sent as page content.
93
+
94
+ Only the body is updated by default. Pass `--title` to also push the H1 as the
95
+ new page title.
96
+
97
+ Notes and safety:
98
+
99
+ - The body is converted from Markdown to ADF. Only the standard constructs the
100
+ copy produces round-trip (headings, lists, task lists, code, blockquotes,
101
+ tables, rules, inline marks, links, images). Confluence-specific content
102
+ (panels, expands, macros, layouts) was flattened to plain Markdown on copy
103
+ and cannot be rebuilt. When the live page still contains such content, the
104
+ update warns and asks for confirmation before overwriting.
105
+ - Before writing, the current server version is checked against the frontmatter
106
+ version. If the page changed since you copied it, the update aborts so you can
107
+ re-copy. `--force` overrides this and the data-loss confirmation.
108
+ - Images referenced in the body are uploaded as attachments (matched by name and
109
+ size, so unchanged images are not re-uploaded). Local paths resolve relative
110
+ to the Markdown file; a missing local image aborts the update. External image
111
+ URLs are kept as external media.
112
+ - Each update adds a version with the message `Updated via atlass` (override
113
+ with `--message`).
114
+
115
+ ### Search Jira issues
116
+
117
+ ```bash
118
+ atlass jira search "safari login" # free text
119
+ atlass jira search --project PROJ --assignee me # my open issues in PROJ
120
+ atlass jira search --status "In Progress"
121
+ atlass jira search --jql "project = PROJ AND labels = regression"
122
+ atlass jira search # recent issues
123
+ ```
124
+
125
+ Friendly filters (`--project`, `--assignee`, `--status`, text) are AND'd
126
+ together and ordered by most recently updated. `--assignee me` maps to the
127
+ current user. `--jql` takes a raw query and cannot be combined with the friendly
128
+ filters. Prints one issue per line (`KEY status summary`); use `--json` for
129
+ machine output. Only the first `--limit` results are shown (default 25, max
130
+ 100).
131
+
132
+ ### Search Confluence pages
133
+
134
+ ```bash
135
+ atlass confluence search "onboarding"
136
+ atlass confluence search --space DOCS
137
+ atlass confluence search --cql "label = runbook ORDER BY created DESC"
138
+ atlass confluence search # recent pages
139
+ ```
140
+
141
+ Friendly mode always constrains to pages, so every result is copy-able. `--cql`
142
+ takes a raw query and cannot be combined with `--space` or text. Prints one page
143
+ per line (`id space title`); `--json` and `--limit` work as for Jira.
144
+
145
+ ### Copy from search results
146
+
147
+ Add `--copy` to any search to pick results interactively and copy each to
148
+ Markdown (multi-select, needs an interactive terminal). With `--copy`, `--out`
149
+ is a directory that every selected file is written into:
150
+
151
+ ```bash
152
+ atlass jira search --project PROJ --copy --out ./tickets/
153
+ atlass confluence search --space DOCS --copy
154
+ ```
155
+
156
+ Copying continues on failure and reports a summary at the end.
157
+
75
158
  ### Output location
76
159
 
77
160
  By default files are written to the current directory, named after the issue
@@ -156,12 +239,15 @@ pnpm dev # build in watch mode
156
239
 
157
240
  - `src/cli.ts` command wiring (commander)
158
241
  - `src/commands/` `auth`, `jira`, `confluence` command handlers
159
- - `src/api/` fetch client, Jira and Confluence endpoints, attachment downloader
160
- - `src/adf/` ADF to Markdown converter (unit tested)
161
- - `src/markdown/` frontmatter, comments, attachments, media resolver
242
+ - `src/api/` fetch client, Jira and Confluence endpoints, attachment up/download
243
+ - `src/adf/` ADF to Markdown and Markdown to ADF converters (unit tested)
244
+ - `src/markdown/` frontmatter, comments, attachments, media resolver, update source
162
245
  - `src/config.ts`, `src/credentials.ts` config file and keyring
163
246
  - `src/util/` key/id parsing and output path resolution
164
247
 
165
248
  The ADF to Markdown converter in `src/adf/to-markdown.ts` is a single hand
166
249
  rolled walker shared by both commands. Confluence page bodies are requested as
167
- `atlas_doc_format` so they flow through the same converter as Jira.
250
+ `atlas_doc_format` so they flow through the same converter as Jira. The reverse
251
+ direction, `src/adf/from-markdown.ts`, tokenizes Markdown with `marked` and
252
+ emits ADF for the Confluence update command; it covers the same clean subset the
253
+ copy produces.
package/dist/cli.mjs CHANGED
@@ -1,12 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from "commander";
3
- import { input, password } from "@inquirer/prompts";
4
- import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { checkbox, confirm, input, password } from "@inquirer/prompts";
4
+ import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
5
5
  import { homedir } from "node:os";
6
6
  import { basename, dirname, isAbsolute, join, resolve } from "node:path";
7
7
  import { Entry } from "@napi-rs/keyring";
8
+ import { marked } from "marked";
9
+ import { randomUUID } from "node:crypto";
8
10
  //#region package.json
9
- var version = "1.0.0";
11
+ var version = "1.2.0";
10
12
  //#endregion
11
13
  //#region src/api/client.ts
12
14
  var AtlassianClient = class {
@@ -17,27 +19,76 @@ var AtlassianClient = class {
17
19
  const basic = Buffer.from(`${auth.email}:${auth.token}`).toString("base64");
18
20
  this.authHeader = `Basic ${basic}`;
19
21
  }
20
- async request(path, accept) {
21
- const res = await fetch(`${this.site}${path}`, { headers: {
22
- Authorization: this.authHeader,
23
- Accept: accept
24
- } });
25
- if (!res.ok) throw httpError(res.status, path);
22
+ async request(path, init) {
23
+ const res = await fetch(`${this.site}${path}`, {
24
+ method: init.method,
25
+ body: init.body,
26
+ headers: {
27
+ Authorization: this.authHeader,
28
+ ...init.headers
29
+ }
30
+ });
31
+ if (!res.ok) {
32
+ const body = await res.text().catch(() => "");
33
+ throw httpError(res.status, path, body);
34
+ }
26
35
  return res;
27
36
  }
28
37
  async getJson(path) {
29
- return (await this.request(path, "application/json")).json();
38
+ return (await this.request(path, { headers: { Accept: "application/json" } })).json();
39
+ }
40
+ async putJson(path, body) {
41
+ return (await this.request(path, {
42
+ method: "PUT",
43
+ headers: {
44
+ Accept: "application/json",
45
+ "Content-Type": "application/json"
46
+ },
47
+ body: JSON.stringify(body)
48
+ })).json();
49
+ }
50
+ async postMultipart(path, filename, bytes) {
51
+ const form = new FormData();
52
+ const blob = new Blob([bytes]);
53
+ form.append("file", blob, filename);
54
+ return (await this.request(path, {
55
+ method: "POST",
56
+ headers: {
57
+ Accept: "application/json",
58
+ "X-Atlassian-Token": "nocheck"
59
+ },
60
+ body: form
61
+ })).json();
30
62
  }
31
63
  async getBinary(url) {
32
64
  const path = url.startsWith("http") ? new URL(url).pathname + new URL(url).search : url;
33
- const res = await this.request(path, "*/*");
65
+ const res = await this.request(path, { headers: { Accept: "*/*" } });
34
66
  return new Uint8Array(await res.arrayBuffer());
35
67
  }
36
68
  };
37
- function httpError(status, path) {
69
+ function httpError(status, path, body = "") {
38
70
  if (status === 401 || status === 403) return /* @__PURE__ */ new Error("Authentication failed (401/403). Run `atlass auth login` to update your token.");
39
71
  if (status === 404) return /* @__PURE__ */ new Error(`Not found (404): ${path}`);
40
- return /* @__PURE__ */ new Error(`Request failed (${status}): ${path}`);
72
+ if (status === 409) {
73
+ const detail = extractError(body);
74
+ return /* @__PURE__ */ new Error(`Conflict (409): ${detail || "the page changed on the server"}`);
75
+ }
76
+ if (status === 413) return /* @__PURE__ */ new Error("Payload too large (413): the page or an attachment exceeds the size limit.");
77
+ if (status === 400) {
78
+ const detail = extractError(body);
79
+ return /* @__PURE__ */ new Error(`Bad request (400): ${detail || path}`);
80
+ }
81
+ const detail = extractError(body);
82
+ return /* @__PURE__ */ new Error(`Request failed (${status}): ${detail || path}`);
83
+ }
84
+ function extractError(body) {
85
+ if (!body) return "";
86
+ try {
87
+ const json = JSON.parse(body);
88
+ if (json.errorMessages?.length) return json.errorMessages.join("; ");
89
+ if (json.message) return json.message;
90
+ } catch {}
91
+ return body.slice(0, 300);
41
92
  }
42
93
  //#endregion
43
94
  //#region src/config.ts
@@ -144,6 +195,201 @@ async function status() {
144
195
  console.log(`Token: ${hasToken ? "stored in keyring" : "MISSING (run `atlass auth login`)"}`);
145
196
  }
146
197
  //#endregion
198
+ //#region src/adf/from-markdown.ts
199
+ function markdownToAdf(md, options = {}) {
200
+ const ctx = { resolveImage: options.resolveImage ?? defaultResolveImage };
201
+ return {
202
+ type: "doc",
203
+ version: 1,
204
+ content: blocks(marked.lexer(md), ctx)
205
+ };
206
+ }
207
+ function blocks(tokens, ctx) {
208
+ const out = [];
209
+ for (const token of tokens) out.push(...block(token, ctx));
210
+ return out;
211
+ }
212
+ function block(token, ctx) {
213
+ switch (token.type) {
214
+ case "space": return [];
215
+ case "heading": return [{
216
+ type: "heading",
217
+ attrs: { level: clampLevel$1(token.depth) },
218
+ content: inline(token.tokens ?? [], ctx)
219
+ }];
220
+ case "paragraph": return paragraph(token.tokens ?? [], ctx);
221
+ case "text": return paragraph(token.tokens ?? [textToken(token.text)], ctx);
222
+ case "list": return [list(token, ctx)];
223
+ case "code": return [codeBlock(token)];
224
+ case "blockquote": return [{
225
+ type: "blockquote",
226
+ content: blocks(token.tokens ?? [], ctx)
227
+ }];
228
+ case "table": return [table(token, ctx)];
229
+ case "hr": return [{ type: "rule" }];
230
+ case "html": return [];
231
+ default: return [];
232
+ }
233
+ }
234
+ function paragraph(tokens, ctx) {
235
+ const out = [];
236
+ let buffer = [];
237
+ const flush = () => {
238
+ if (buffer.length === 0) return;
239
+ const nodes = inline(buffer, ctx);
240
+ if (nodes.length > 0) out.push({
241
+ type: "paragraph",
242
+ content: nodes
243
+ });
244
+ buffer = [];
245
+ };
246
+ for (const token of tokens) if (token.type === "image") {
247
+ flush();
248
+ const image = token;
249
+ const node = ctx.resolveImage(image.href, image.text ?? "");
250
+ if (node) out.push(node);
251
+ } else buffer.push(token);
252
+ flush();
253
+ return out;
254
+ }
255
+ function list(token, ctx) {
256
+ if (token.items.length > 0 && token.items.every((i) => i.task)) return {
257
+ type: "taskList",
258
+ attrs: { localId: randomUUID() },
259
+ content: token.items.map((item) => ({
260
+ type: "taskItem",
261
+ attrs: {
262
+ localId: randomUUID(),
263
+ state: item.checked ? "DONE" : "TODO"
264
+ },
265
+ content: inline(itemInline(item), ctx)
266
+ }))
267
+ };
268
+ const node = {
269
+ type: token.ordered ? "orderedList" : "bulletList",
270
+ content: token.items.map((item) => listItem(item, ctx))
271
+ };
272
+ const start = Number(token.start);
273
+ if (token.ordered && Number.isFinite(start) && start !== 1) node.attrs = { order: start };
274
+ return node;
275
+ }
276
+ function listItem(item, ctx) {
277
+ const children = blocks(item.tokens.filter((t) => t.type !== "checkbox"), ctx);
278
+ if (children.length === 0) children.push({
279
+ type: "paragraph",
280
+ content: []
281
+ });
282
+ return {
283
+ type: "listItem",
284
+ content: children
285
+ };
286
+ }
287
+ function itemInline(item) {
288
+ const first = item.tokens.find((t) => t.type === "text");
289
+ if (first && "tokens" in first && first.tokens) return first.tokens;
290
+ return [textToken(item.text)];
291
+ }
292
+ function codeBlock(token) {
293
+ return {
294
+ type: "codeBlock",
295
+ attrs: token.lang ? { language: token.lang } : {},
296
+ content: token.text.length > 0 ? [{
297
+ type: "text",
298
+ text: token.text
299
+ }] : []
300
+ };
301
+ }
302
+ function table(token, ctx) {
303
+ return {
304
+ type: "table",
305
+ content: [{
306
+ type: "tableRow",
307
+ content: token.header.map((cell) => tableCell(cell, "tableHeader", ctx))
308
+ }, ...token.rows.map((row) => ({
309
+ type: "tableRow",
310
+ content: row.map((cell) => tableCell(cell, "tableCell", ctx))
311
+ }))]
312
+ };
313
+ }
314
+ function tableCell(cell, type, ctx) {
315
+ return {
316
+ type,
317
+ content: [{
318
+ type: "paragraph",
319
+ content: inline(cell.tokens, ctx)
320
+ }]
321
+ };
322
+ }
323
+ function inline(tokens, ctx, marks = []) {
324
+ const out = [];
325
+ for (const token of tokens) out.push(...inlineNode(token, ctx, marks));
326
+ return out;
327
+ }
328
+ function inlineNode(token, ctx, marks) {
329
+ switch (token.type) {
330
+ case "text":
331
+ case "escape": {
332
+ const t = token;
333
+ if ("tokens" in t && t.tokens?.length) return inline(t.tokens, ctx, marks);
334
+ return textNode(t.text, marks);
335
+ }
336
+ case "strong": return inline(token.tokens, ctx, withMark(marks, { type: "strong" }));
337
+ case "em": return inline(token.tokens, ctx, withMark(marks, { type: "em" }));
338
+ case "del": return inline(token.tokens, ctx, withMark(marks, { type: "strike" }));
339
+ case "codespan": return textNode(token.text, withMark(marks, { type: "code" }));
340
+ case "link": {
341
+ const link = token;
342
+ return inline(link.tokens, ctx, withMark(marks, {
343
+ type: "link",
344
+ attrs: { href: link.href }
345
+ }));
346
+ }
347
+ case "br": return [{ type: "hardBreak" }];
348
+ case "html": return textNode(token.text, marks);
349
+ default: return "text" in token && token.text ? textNode(token.text, marks) : [];
350
+ }
351
+ }
352
+ function textNode(text, marks) {
353
+ if (text.length === 0) return [];
354
+ const node = {
355
+ type: "text",
356
+ text
357
+ };
358
+ if (marks.length > 0) node.marks = marks;
359
+ return [node];
360
+ }
361
+ function withMark(marks, mark) {
362
+ return [...marks.filter((m) => m.type !== mark.type), mark];
363
+ }
364
+ function defaultResolveImage(href, alt) {
365
+ return {
366
+ type: "mediaSingle",
367
+ attrs: { layout: "center" },
368
+ content: [{
369
+ type: "media",
370
+ attrs: alt ? {
371
+ type: "external",
372
+ url: href,
373
+ alt
374
+ } : {
375
+ type: "external",
376
+ url: href
377
+ }
378
+ }]
379
+ };
380
+ }
381
+ function textToken(text) {
382
+ return {
383
+ type: "text",
384
+ raw: text,
385
+ text,
386
+ escaped: false
387
+ };
388
+ }
389
+ function clampLevel$1(value) {
390
+ return Math.min(6, Math.max(1, Math.trunc(value) || 1));
391
+ }
392
+ //#endregion
147
393
  //#region src/adf/to-markdown.ts
148
394
  function adfToMarkdown(doc, options = {}) {
149
395
  if (!doc) return "";
@@ -357,6 +603,25 @@ function uniqueName(name, used) {
357
603
  return candidate;
358
604
  }
359
605
  //#endregion
606
+ //#region src/util/html.ts
607
+ const NAMED = {
608
+ amp: "&",
609
+ lt: "<",
610
+ gt: ">",
611
+ quot: "\"",
612
+ apos: "'",
613
+ nbsp: " "
614
+ };
615
+ function decodeEntities(text) {
616
+ return text.replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (match, body) => {
617
+ if (body[0] === "#") {
618
+ const code = body[1] === "x" || body[1] === "X" ? Number.parseInt(body.slice(2), 16) : Number.parseInt(body.slice(1), 10);
619
+ return Number.isFinite(code) && code >= 0 && code <= 1114111 ? String.fromCodePoint(code) : match;
620
+ }
621
+ return NAMED[body.toLowerCase()] ?? match;
622
+ });
623
+ }
624
+ //#endregion
360
625
  //#region src/api/confluence.ts
361
626
  async function fetchPage(client, site, id) {
362
627
  const page = await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}?body-format=atlas_doc_format`);
@@ -382,6 +647,71 @@ async function fetchPage(client, site, id) {
382
647
  comments
383
648
  };
384
649
  }
650
+ async function fetchPageState(client, id) {
651
+ const page = await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}?body-format=atlas_doc_format`);
652
+ return {
653
+ version: page.version?.number ?? 0,
654
+ title: page.title,
655
+ body: parseAdf(page.body?.atlas_doc_format?.value)
656
+ };
657
+ }
658
+ async function listAttachments(client, id) {
659
+ return (await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}/attachments?limit=250`)).results.map((a) => ({
660
+ filename: a.title ?? a.id,
661
+ fileId: a.fileId ?? a.id,
662
+ size: typeof a.fileSize === "number" ? a.fileSize : -1
663
+ }));
664
+ }
665
+ async function uploadAttachment(client, pageId, filename, bytes) {
666
+ const fileId = (await client.postMultipart(`/wiki/rest/api/content/${encodeURIComponent(pageId)}/child/attachment`, filename, bytes)).results?.[0]?.extensions?.fileId;
667
+ if (fileId) return fileId;
668
+ const match = (await listAttachments(client, pageId)).find((a) => a.filename === filename);
669
+ if (match) return match.fileId;
670
+ throw new Error(`Upload of "${filename}" did not return a fileId.`);
671
+ }
672
+ async function updatePage(client, id, params) {
673
+ return (await client.putJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}`, {
674
+ id,
675
+ status: "current",
676
+ title: params.title,
677
+ body: {
678
+ representation: "atlas_doc_format",
679
+ value: JSON.stringify(params.body)
680
+ },
681
+ version: {
682
+ number: params.version,
683
+ message: params.message
684
+ }
685
+ })).version?.number ?? params.version;
686
+ }
687
+ async function searchPages(client, site, params) {
688
+ const cql = buildCql(params);
689
+ const query = new URLSearchParams({
690
+ cql,
691
+ limit: String(params.limit),
692
+ expand: "space"
693
+ });
694
+ const results = (await client.getJson(`/wiki/rest/api/search?${query.toString()}`)).results ?? [];
695
+ return {
696
+ pages: results.filter((r) => r.content?.id).map((r) => ({
697
+ id: r.content?.id ?? "",
698
+ space: r.space?.key ?? r.resultGlobalContainer?.title ?? "",
699
+ title: decodeEntities(r.content?.title ?? r.title ?? ""),
700
+ url: r.url ? `${site}/wiki${r.url}` : ""
701
+ })),
702
+ hasMore: results.length === params.limit
703
+ };
704
+ }
705
+ function buildCql(params) {
706
+ if (params.cql) return params.cql;
707
+ const clauses = ["type = page"];
708
+ if (params.space) clauses.push(`space = ${cqlValue(params.space)}`);
709
+ if (params.text) clauses.push(`text ~ ${cqlValue(params.text)}`);
710
+ return `${clauses.join(" AND ")} ORDER BY lastmodified DESC`;
711
+ }
712
+ function cqlValue(value) {
713
+ return `"${value.replace(/(["\\])/g, "\\$1")}"`;
714
+ }
385
715
  async function fetchSpaceKey(client, spaceId) {
386
716
  if (!spaceId) return "";
387
717
  try {
@@ -487,6 +817,84 @@ function joinSections(sections) {
487
817
  return `${sections.filter((s) => s.trim().length > 0).join("\n\n")}\n`;
488
818
  }
489
819
  //#endregion
820
+ //#region src/markdown/update-source.ts
821
+ function parseUpdateSource(content) {
822
+ const match = content.match(/^---\n([\s\S]*?)\n---\n?/);
823
+ if (!match) throw new Error("Not an atlass page file: no YAML frontmatter found.");
824
+ const fields = parseFrontmatter(match[1] ?? "");
825
+ const id = fields["id"];
826
+ if (!id) throw new Error("Frontmatter is missing the page `id`; re-copy the page.");
827
+ const version = Number(fields["version"]);
828
+ if (!Number.isFinite(version)) throw new Error("Frontmatter is missing a numeric `version`; re-copy the page.");
829
+ const { bodyTitle, body } = splitBody(content.slice(match[0].length), fields["title"] ?? "");
830
+ return {
831
+ id,
832
+ version,
833
+ frontTitle: fields["title"] ?? "",
834
+ bodyTitle,
835
+ body
836
+ };
837
+ }
838
+ function parseFrontmatter(block) {
839
+ const out = {};
840
+ for (const line of block.split("\n")) {
841
+ const m = line.match(/^([A-Za-z0-9_]+):\s*(.*)$/);
842
+ if (!m) continue;
843
+ const key = m[1] ?? "";
844
+ let value = (m[2] ?? "").trim();
845
+ if (value.startsWith("\"") && value.endsWith("\"")) value = value.slice(1, -1).replace(/\\"/g, "\"").replace(/\\\\/g, "\\");
846
+ out[key] = value;
847
+ }
848
+ return out;
849
+ }
850
+ function splitBody(rest, fallbackTitle) {
851
+ const lines = rest.split("\n");
852
+ let bodyTitle = fallbackTitle;
853
+ let start = 0;
854
+ for (let i = 0; i < lines.length; i++) {
855
+ const line = lines[i] ?? "";
856
+ if (line.startsWith("# ")) {
857
+ bodyTitle = line.slice(2).trim();
858
+ start = i + 1;
859
+ break;
860
+ }
861
+ if (line.trim().length > 0) break;
862
+ }
863
+ let end = lines.length;
864
+ for (let i = start; i < lines.length; i++) if (/^## (Comments|Attachments)\s*$/.test(lines[i] ?? "")) {
865
+ end = i;
866
+ break;
867
+ }
868
+ const body = lines.slice(start, end).join("\n").trim();
869
+ return {
870
+ bodyTitle,
871
+ body
872
+ };
873
+ }
874
+ const LOSSY_LABELS = {
875
+ panel: "panel",
876
+ expand: "expand",
877
+ nestedExpand: "expand",
878
+ decisionList: "decision list",
879
+ layoutSection: "layout",
880
+ extension: "macro",
881
+ bodiedExtension: "macro",
882
+ inlineExtension: "macro"
883
+ };
884
+ function findLossyNodes(node) {
885
+ const counts = /* @__PURE__ */ new Map();
886
+ const visit = (n) => {
887
+ const label = LOSSY_LABELS[n.type];
888
+ if (label) counts.set(label, (counts.get(label) ?? 0) + 1);
889
+ for (const child of n.content ?? []) visit(child);
890
+ };
891
+ if (node) visit(node);
892
+ return counts;
893
+ }
894
+ function formatLossy(counts) {
895
+ return [...counts.entries()].map(([label, n]) => `${n} ${label}${n === 1 ? "" : "s"}`).join(", ");
896
+ }
897
+ //#endregion
490
898
  //#region src/util/output-path.ts
491
899
  function resolveOutput(defaultBase, out) {
492
900
  let filePath;
@@ -519,15 +927,165 @@ function parsePageId(input) {
519
927
  if (fromQuery) return fromQuery[1];
520
928
  return null;
521
929
  }
930
+ function parseLimit(value) {
931
+ if (!value) return 25;
932
+ const n = Number.parseInt(value, 10);
933
+ if (!Number.isFinite(n) || n < 1) throw new Error(`Invalid --limit "${value}".`);
934
+ return Math.min(n, 100);
935
+ }
936
+ //#endregion
937
+ //#region src/commands/search-run.ts
938
+ const COPY_CONCURRENCY = 5;
939
+ async function runSearch(rows, options, noun, copyOne) {
940
+ if (options.json) {
941
+ console.log(JSON.stringify(rows.map((r) => r.json), null, 2));
942
+ return;
943
+ }
944
+ if (rows.length === 0) {
945
+ console.log(`No matching ${noun.plural}.`);
946
+ return;
947
+ }
948
+ if (options.copy) {
949
+ if (options.out?.endsWith(".md")) throw new Error("--out must be a directory when using --copy; a .md file path would overwrite each selection.");
950
+ await copySelected(rows, noun, copyOne);
951
+ return;
952
+ }
953
+ for (const row of rows) console.log(formatRow(row));
954
+ if (options.hasMore) console.log(`\nShowing first ${options.limit}; refine with flags or raise --limit.`);
955
+ }
956
+ async function copySelected(rows, noun, copyOne) {
957
+ if (!process.stdin.isTTY) throw new Error("--copy requires an interactive terminal.");
958
+ const selected = await checkbox({
959
+ message: `Select ${noun.plural} to copy:`,
960
+ choices: rows.map((r) => ({
961
+ name: formatRow(r),
962
+ value: r.id
963
+ })),
964
+ pageSize: 20
965
+ });
966
+ if (selected.length === 0) {
967
+ console.log("Nothing selected.");
968
+ return;
969
+ }
970
+ let copied = 0;
971
+ const failures = [];
972
+ const queue = [...selected];
973
+ async function worker() {
974
+ for (let id = queue.shift(); id !== void 0; id = queue.shift()) try {
975
+ await copyOne(id);
976
+ copied++;
977
+ } catch (err) {
978
+ failures.push(`${id} (${err instanceof Error ? err.message : String(err)})`);
979
+ }
980
+ }
981
+ await Promise.all(Array.from({ length: Math.min(COPY_CONCURRENCY, selected.length) }, worker));
982
+ const summary = `Copied ${copied} ${copied === 1 ? noun.singular : noun.plural}`;
983
+ if (failures.length === 0) console.log(summary);
984
+ else console.log(`${summary}, failed ${failures.length}: ${failures.join(", ")}`);
985
+ }
986
+ function formatRow(row) {
987
+ const room = (process.stdout.columns ?? 80) - row.prefix.length - 2;
988
+ const text = room > 0 ? truncate(row.text, room) : "";
989
+ return text ? `${row.prefix} ${text}` : row.prefix;
990
+ }
991
+ function truncate(text, max) {
992
+ const clean = text.replace(/\s+/g, " ").trim();
993
+ return clean.length <= max ? clean : `${clean.slice(0, Math.max(0, max - 3))}...`;
994
+ }
522
995
  //#endregion
523
996
  //#region src/commands/confluence.ts
524
997
  async function confluenceCopy(arg, options) {
525
998
  const auth = await requireAuth();
526
999
  const id = await resolveId(arg);
1000
+ await copyPage(new AtlassianClient(auth), auth.site, id, options.out);
1001
+ }
1002
+ async function confluenceUpdate(arg, options) {
1003
+ const file = arg ?? await input({
1004
+ message: "Path to the page Markdown file:",
1005
+ required: true
1006
+ });
1007
+ const src = parseUpdateSource(await readFile(file, "utf8"));
1008
+ const client = new AtlassianClient(await requireAuth());
1009
+ const state = await fetchPageState(client, src.id);
1010
+ if (state.version !== src.version && !options.force) throw new Error(`Page changed on the server since you copied it (local v${src.version}, server v${state.version}). Re-copy the page or pass --force.`);
1011
+ const dir = dirname(resolve(file));
1012
+ const attachments = await listAttachments(client, src.id);
1013
+ const plan = await planImages(dir, collectImageHrefs(src.body), attachments);
1014
+ const lossy = findLossyNodes(state.body);
1015
+ const nextVersion = state.version + 1;
1016
+ const newTitle = options.title && src.bodyTitle ? src.bodyTitle : state.title;
1017
+ if (options.dryRun) {
1018
+ printDryRun(src.id, state.title, newTitle, state.version, nextVersion, lossy, plan);
1019
+ return;
1020
+ }
1021
+ if (lossy.size > 0 && !options.force) {
1022
+ if (!await confirm({
1023
+ message: `This page contains ${formatLossy(lossy)} that Markdown cannot represent and will be removed. Continue?`,
1024
+ default: false
1025
+ })) {
1026
+ console.log("Aborted.");
1027
+ return;
1028
+ }
1029
+ }
1030
+ const collection = `contentId-${src.id}`;
1031
+ const fileIds = /* @__PURE__ */ new Map();
1032
+ for (const [href, entry] of plan) if (entry.kind === "upload") {
1033
+ console.log(`Uploading ${entry.filename} ...`);
1034
+ fileIds.set(href, await uploadAttachment(client, src.id, entry.filename, await readFile(entry.path)));
1035
+ } else if (entry.kind === "reuse") fileIds.set(href, entry.fileId);
1036
+ const body = markdownToAdf(src.body, { resolveImage: (href, alt) => {
1037
+ const entry = plan.get(href);
1038
+ if (!entry) return void 0;
1039
+ if (entry.kind === "external") return externalMedia(href, alt);
1040
+ const fileId = fileIds.get(href);
1041
+ return fileId ? fileMedia(fileId, collection, alt) : void 0;
1042
+ } });
1043
+ if (!body.content || body.content.length === 0) throw new Error("Refusing to update: the converted body is empty.");
1044
+ const version = await updatePage(client, src.id, {
1045
+ title: newTitle,
1046
+ version: nextVersion,
1047
+ body,
1048
+ message: options.message ?? "Updated via atlass"
1049
+ });
1050
+ console.log(`Updated page ${src.id} to version ${version}.`);
1051
+ }
1052
+ async function confluenceSearch(query, options) {
1053
+ if (options.cql && (query || options.space)) throw new Error("--cql cannot be combined with a text query or --space.");
1054
+ if (options.json && options.copy) throw new Error("--json and --copy cannot be used together.");
1055
+ const auth = await requireAuth();
527
1056
  const client = new AtlassianClient(auth);
1057
+ const limit = parseLimit(options.limit);
1058
+ const { pages, hasMore } = await searchPages(client, auth.site, {
1059
+ text: query,
1060
+ space: options.space,
1061
+ cql: options.cql,
1062
+ limit
1063
+ });
1064
+ await runSearch(pages.map((p) => ({
1065
+ id: p.id,
1066
+ prefix: `${p.id} ${p.space}`,
1067
+ text: p.title,
1068
+ json: {
1069
+ id: p.id,
1070
+ space: p.space,
1071
+ title: p.title,
1072
+ url: p.url
1073
+ }
1074
+ })), {
1075
+ json: options.json,
1076
+ copy: options.copy,
1077
+ limit,
1078
+ hasMore,
1079
+ out: options.out
1080
+ }, {
1081
+ singular: "page",
1082
+ plural: "pages"
1083
+ }, (id) => copyPage(client, auth.site, id, options.out));
1084
+ }
1085
+ async function copyPage(client, site, id, out) {
528
1086
  console.log(`Fetching page ${id} ...`);
529
- const page = await fetchPage(client, auth.site, id);
530
- const target = resolveOutput(`${page.id}-${slugify(page.title)}`, options.out);
1087
+ const page = await fetchPage(client, site, id);
1088
+ const target = resolveOutput(`${page.id}-${slugify(page.title)}`, out);
531
1089
  const downloaded = await downloadAttachments(client, page.attachments, target.assetsDir, target.assetsDirName);
532
1090
  const resolveMedia = mediaResolver(downloaded);
533
1091
  const document = joinSections([
@@ -559,6 +1117,94 @@ async function resolveId(arg) {
559
1117
  if (!id) throw new Error(`Could not find a page id in "${raw}".`);
560
1118
  return id;
561
1119
  }
1120
+ function collectImageHrefs(md) {
1121
+ const hrefs = /* @__PURE__ */ new Set();
1122
+ marked.walkTokens(marked.lexer(md), (token) => {
1123
+ if (token.type === "image") hrefs.add(token.href);
1124
+ });
1125
+ return [...hrefs];
1126
+ }
1127
+ async function planImages(dir, hrefs, attachments) {
1128
+ const byName = new Map(attachments.map((a) => [a.filename, a]));
1129
+ const plan = /* @__PURE__ */ new Map();
1130
+ const missing = [];
1131
+ for (const href of hrefs) {
1132
+ if (isExternal(href)) {
1133
+ plan.set(href, { kind: "external" });
1134
+ continue;
1135
+ }
1136
+ const path = isAbsolute(href) ? href : resolve(dir, href);
1137
+ let size;
1138
+ try {
1139
+ size = (await stat(path)).size;
1140
+ } catch {
1141
+ missing.push(href);
1142
+ continue;
1143
+ }
1144
+ const filename = basename(path);
1145
+ const existing = byName.get(filename);
1146
+ if (existing && existing.size === size) plan.set(href, {
1147
+ kind: "reuse",
1148
+ fileId: existing.fileId,
1149
+ filename
1150
+ });
1151
+ else plan.set(href, {
1152
+ kind: "upload",
1153
+ path,
1154
+ filename,
1155
+ existed: existing !== void 0
1156
+ });
1157
+ }
1158
+ if (missing.length > 0) throw new Error(`Image file(s) not found: ${missing.join(", ")}`);
1159
+ return plan;
1160
+ }
1161
+ function isExternal(href) {
1162
+ return /^[a-z][a-z0-9+.-]*:\/\//i.test(href);
1163
+ }
1164
+ function externalMedia(href, alt) {
1165
+ const attrs = {
1166
+ type: "external",
1167
+ url: href
1168
+ };
1169
+ if (alt) attrs["alt"] = alt;
1170
+ return {
1171
+ type: "mediaSingle",
1172
+ attrs: { layout: "center" },
1173
+ content: [{
1174
+ type: "media",
1175
+ attrs
1176
+ }]
1177
+ };
1178
+ }
1179
+ function fileMedia(fileId, collection, alt) {
1180
+ const attrs = {
1181
+ type: "file",
1182
+ id: fileId,
1183
+ collection
1184
+ };
1185
+ if (alt) attrs["alt"] = alt;
1186
+ return {
1187
+ type: "mediaSingle",
1188
+ attrs: { layout: "center" },
1189
+ content: [{
1190
+ type: "media",
1191
+ attrs
1192
+ }]
1193
+ };
1194
+ }
1195
+ function printDryRun(id, currentTitle, newTitle, currentVersion, nextVersion, lossy, plan) {
1196
+ const entries = [...plan.values()];
1197
+ const added = entries.filter((e) => e.kind === "upload" && !e.existed).length;
1198
+ const changed = entries.filter((e) => e.kind === "upload" && e.existed).length;
1199
+ const reused = entries.filter((e) => e.kind === "reuse").length;
1200
+ const external = entries.filter((e) => e.kind === "external").length;
1201
+ console.log(`Dry run for page ${id} "${currentTitle}"`);
1202
+ console.log(` version: ${currentVersion} -> ${nextVersion}`);
1203
+ if (newTitle !== currentTitle) console.log(` title: "${currentTitle}" -> "${newTitle}"`);
1204
+ console.log(` images: ${added} new, ${changed} changed, ${reused} reused, ${external} external`);
1205
+ if (lossy.size > 0) console.log(` warning: ${formatLossy(lossy)} will be removed`);
1206
+ console.log(" nothing was written (dry run)");
1207
+ }
562
1208
  //#endregion
563
1209
  //#region src/api/jira.ts
564
1210
  const FIELDS = [
@@ -599,6 +1245,33 @@ async function fetchIssue(client, site, key) {
599
1245
  }))
600
1246
  };
601
1247
  }
1248
+ async function searchIssues(client, site, params) {
1249
+ const jql = buildJql(params);
1250
+ const query = new URLSearchParams({
1251
+ jql,
1252
+ maxResults: String(params.limit),
1253
+ fields: "summary,status"
1254
+ });
1255
+ return ((await client.getJson(`/rest/api/3/search/jql?${query.toString()}`)).issues ?? []).map((i) => ({
1256
+ key: i.key,
1257
+ status: i.fields?.status?.name ?? "",
1258
+ summary: decodeEntities(i.fields?.summary ?? ""),
1259
+ url: `${site}/browse/${i.key}`
1260
+ }));
1261
+ }
1262
+ function buildJql(params) {
1263
+ if (params.jql) return params.jql;
1264
+ const clauses = [];
1265
+ if (params.project) clauses.push(`project = ${jqlValue(params.project)}`);
1266
+ if (params.assignee) clauses.push(params.assignee === "me" ? "assignee = currentUser()" : `assignee = ${jqlValue(params.assignee)}`);
1267
+ if (params.status) clauses.push(`status = ${jqlValue(params.status)}`);
1268
+ if (params.text) clauses.push(`text ~ ${jqlValue(params.text)}`);
1269
+ if (clauses.length === 0) clauses.push("updated >= -30d");
1270
+ return `${clauses.join(" AND ")} ORDER BY updated DESC`;
1271
+ }
1272
+ function jqlValue(value) {
1273
+ return `"${value.replace(/(["\\])/g, "\\$1")}"`;
1274
+ }
602
1275
  async function fetchComments(client, key) {
603
1276
  return (await client.getJson(`/rest/api/3/issue/${encodeURIComponent(key)}/comment?maxResults=100&orderBy=created`)).comments.map((c) => ({
604
1277
  author: c.author?.displayName ?? "",
@@ -611,10 +1284,47 @@ async function fetchComments(client, key) {
611
1284
  async function jiraCopy(arg, options) {
612
1285
  const auth = await requireAuth();
613
1286
  const key = await resolveKey(arg);
1287
+ await copyIssue(new AtlassianClient(auth), auth.site, key, options.out);
1288
+ }
1289
+ async function jiraSearch(query, options) {
1290
+ if (options.jql && (query || options.project || options.assignee || options.status)) throw new Error("--jql cannot be combined with a text query or other filters.");
1291
+ if (options.json && options.copy) throw new Error("--json and --copy cannot be used together.");
1292
+ const auth = await requireAuth();
614
1293
  const client = new AtlassianClient(auth);
1294
+ const limit = parseLimit(options.limit);
1295
+ const issues = await searchIssues(client, auth.site, {
1296
+ text: query,
1297
+ project: options.project,
1298
+ assignee: options.assignee,
1299
+ status: options.status,
1300
+ jql: options.jql,
1301
+ limit
1302
+ });
1303
+ await runSearch(issues.map((i) => ({
1304
+ id: i.key,
1305
+ prefix: `${i.key} ${i.status}`,
1306
+ text: i.summary,
1307
+ json: {
1308
+ key: i.key,
1309
+ status: i.status,
1310
+ summary: i.summary,
1311
+ url: i.url
1312
+ }
1313
+ })), {
1314
+ json: options.json,
1315
+ copy: options.copy,
1316
+ limit,
1317
+ hasMore: issues.length === limit,
1318
+ out: options.out
1319
+ }, {
1320
+ singular: "issue",
1321
+ plural: "issues"
1322
+ }, (key) => copyIssue(client, auth.site, key, options.out));
1323
+ }
1324
+ async function copyIssue(client, site, key, out) {
615
1325
  console.log(`Fetching ${key} ...`);
616
- const issue = await fetchIssue(client, auth.site, key);
617
- const target = resolveOutput(issue.key, options.out);
1326
+ const issue = await fetchIssue(client, site, key);
1327
+ const target = resolveOutput(issue.key, out);
618
1328
  const downloaded = await downloadAttachments(client, issue.attachments, target.assetsDir, target.assetsDirName);
619
1329
  const resolveMedia = mediaResolver(downloaded);
620
1330
  const document = joinSections([
@@ -659,8 +1369,13 @@ const auth = program.command("auth").description("Manage Atlassian credentials")
659
1369
  auth.command("login").description("Store site, email, and API token").action(run(login));
660
1370
  auth.command("logout").description("Remove stored credentials").action(run(logout));
661
1371
  auth.command("status").description("Show the current login").action(run(status));
662
- program.command("jira").description("Jira commands").command("copy [issue]").description("Copy a Jira issue (key or URL) to a Markdown file").option("-o, --out <path>", "output file or directory").action(run(jiraCopy));
663
- program.command("confluence").description("Confluence commands").command("copy [page]").description("Copy a Confluence page (id or URL) to a Markdown file").option("-o, --out <path>", "output file or directory").action(run(confluenceCopy));
1372
+ const jira = program.command("jira").description("Jira commands");
1373
+ jira.command("copy [issue]").description("Copy a Jira issue (key or URL) to a Markdown file").option("-o, --out <path>", "output file or directory").action(run(jiraCopy));
1374
+ jira.command("search [query]").description("Search Jira issues (text query, filters, or --jql)").option("-p, --project <key>", "limit to a project").option("-a, --assignee <who>", "limit to an assignee (or 'me')").option("-s, --status <status>", "limit to a status").option("--jql <jql>", "raw JQL query (ignores other filters)").option("-l, --limit <n>", "max results (default 25, max 100)").option("--json", "output results as JSON").option("-c, --copy", "pick results to copy to Markdown").option("-o, --out <dir>", "output directory for --copy").action(run(jiraSearch));
1375
+ const confluence = program.command("confluence").description("Confluence commands");
1376
+ confluence.command("copy [page]").description("Copy a Confluence page (id or URL) to a Markdown file").option("-o, --out <path>", "output file or directory").action(run(confluenceCopy));
1377
+ 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(run(confluenceUpdate));
1378
+ confluence.command("search [query]").description("Search Confluence pages (text query, --space, or --cql)").option("-s, --space <key>", "limit to a space").option("--cql <cql>", "raw CQL query (ignores other filters)").option("-l, --limit <n>", "max results (default 25, max 100)").option("--json", "output results as JSON").option("-c, --copy", "pick results to copy to Markdown").option("-o, --out <dir>", "output directory for --copy").action(run(confluenceSearch));
664
1379
  program.parseAsync().catch(fail);
665
1380
  function run(fn) {
666
1381
  return async (...args) => {
package/package.json CHANGED
@@ -1,8 +1,12 @@
1
1
  {
2
2
  "name": "atlass",
3
- "version": "1.0.0",
4
- "description": "CLI to copy Jira issues and Confluence pages to Markdown.",
3
+ "version": "1.2.0",
4
+ "description": "CLI to copy Jira issues and Confluence pages to Markdown, and update Confluence pages.",
5
5
  "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/dan-livingston/atlass.git"
9
+ },
6
10
  "bin": {
7
11
  "atlass": "./dist/cli.mjs"
8
12
  },
@@ -29,7 +33,8 @@
29
33
  "dependencies": {
30
34
  "@inquirer/prompts": "^8.5.2",
31
35
  "@napi-rs/keyring": "^1.3.0",
32
- "commander": "^15.0.0"
36
+ "commander": "^15.0.0",
37
+ "marked": "^18.0.5"
33
38
  },
34
39
  "devDependencies": {
35
40
  "@types/node": "^25.6.2",