atlass 1.1.0 → 1.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.
Files changed (3) hide show
  1. package/README.md +83 -6
  2. package/dist/cli.mjs +594 -10
  3. package/package.json +4 -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
 
@@ -61,6 +63,40 @@ atlass jira copy # prompts for the key or URL
61
63
  Accepts an issue key or any URL containing one. Writes `PROJ-123.md` to the
62
64
  current directory.
63
65
 
66
+ ### Update a Jira issue
67
+
68
+ Copy an issue, edit the Markdown, then push the description back:
69
+
70
+ ```bash
71
+ atlass jira update PROJ-123.md
72
+ atlass jira update # prompts for the file path
73
+ atlass jira update file.md --dry-run # show what would change, write nothing
74
+ atlass jira update file.md --summary # also push the H1 as the issue summary
75
+ ```
76
+
77
+ The issue key comes from the file's frontmatter. The body is everything between
78
+ the H1 and the `## Comments` section; the frontmatter, the H1, and the
79
+ `## Comments` / `## Attachments` sections are not sent.
80
+
81
+ Only the description is updated by default. Pass `--summary` to also push the H1
82
+ as the new issue summary.
83
+
84
+ Notes and safety:
85
+
86
+ - The body is converted from Markdown to ADF. Only the standard constructs the
87
+ copy produces round-trip (headings, lists, task lists, code, blockquotes,
88
+ tables, rules, inline marks, links). Jira-specific content (panels, macros)
89
+ was flattened to plain Markdown on copy and cannot be rebuilt. When the live
90
+ description still contains such content, the update warns and asks for
91
+ confirmation before overwriting.
92
+ - Jira has no page-style version number, so staleness is checked against the
93
+ frontmatter `updated` timestamp. If the issue changed since you copied it, the
94
+ update aborts so you can re-copy. `--force` overrides this and the data-loss
95
+ confirmation.
96
+ - Image changes are not supported yet. External image URLs are kept as external
97
+ media, but a local image reference aborts the update (edit text only), and a
98
+ server-side image in the description is reported before it would be removed.
99
+
64
100
  ### Copy a Confluence page
65
101
 
66
102
  ```bash
@@ -72,6 +108,44 @@ atlass confluence copy # prompts for the id or URL
72
108
  Accepts a numeric page id or a page URL. Writes `123456-title-slug.md` to the
73
109
  current directory.
74
110
 
111
+ ### Update a Confluence page
112
+
113
+ Copy a page, edit the Markdown, then push it back:
114
+
115
+ ```bash
116
+ atlass confluence update 123456-title-slug.md
117
+ atlass confluence update # prompts for the file path
118
+ atlass confluence update file.md --dry-run # show what would change, write nothing
119
+ atlass confluence update file.md --title # also rename the page to the H1
120
+ atlass confluence update file.md -m "fix typo"
121
+ ```
122
+
123
+ The page id and version come from the file's frontmatter, so the file is
124
+ self-describing. The body is everything between the H1 and the `## Comments`
125
+ section; the frontmatter, the H1, and the `## Comments` / `## Attachments`
126
+ sections are not sent as page content.
127
+
128
+ Only the body is updated by default. Pass `--title` to also push the H1 as the
129
+ new page title.
130
+
131
+ Notes and safety:
132
+
133
+ - The body is converted from Markdown to ADF. Only the standard constructs the
134
+ copy produces round-trip (headings, lists, task lists, code, blockquotes,
135
+ tables, rules, inline marks, links, images). Confluence-specific content
136
+ (panels, expands, macros, layouts) was flattened to plain Markdown on copy
137
+ and cannot be rebuilt. When the live page still contains such content, the
138
+ update warns and asks for confirmation before overwriting.
139
+ - Before writing, the current server version is checked against the frontmatter
140
+ version. If the page changed since you copied it, the update aborts so you can
141
+ re-copy. `--force` overrides this and the data-loss confirmation.
142
+ - Images referenced in the body are uploaded as attachments (matched by name and
143
+ size, so unchanged images are not re-uploaded). Local paths resolve relative
144
+ to the Markdown file; a missing local image aborts the update. External image
145
+ URLs are kept as external media.
146
+ - Each update adds a version with the message `Updated via atlass` (override
147
+ with `--message`).
148
+
75
149
  ### Search Jira issues
76
150
 
77
151
  ```bash
@@ -199,12 +273,15 @@ pnpm dev # build in watch mode
199
273
 
200
274
  - `src/cli.ts` command wiring (commander)
201
275
  - `src/commands/` `auth`, `jira`, `confluence` command handlers
202
- - `src/api/` fetch client, Jira and Confluence endpoints, attachment downloader
203
- - `src/adf/` ADF to Markdown converter (unit tested)
204
- - `src/markdown/` frontmatter, comments, attachments, media resolver
276
+ - `src/api/` fetch client, Jira and Confluence endpoints, attachment up/download
277
+ - `src/adf/` ADF to Markdown and Markdown to ADF converters (unit tested)
278
+ - `src/markdown/` frontmatter, comments, attachments, media resolver, update source
205
279
  - `src/config.ts`, `src/credentials.ts` config file and keyring
206
280
  - `src/util/` key/id parsing and output path resolution
207
281
 
208
282
  The ADF to Markdown converter in `src/adf/to-markdown.ts` is a single hand
209
283
  rolled walker shared by both commands. Confluence page bodies are requested as
210
- `atlas_doc_format` so they flow through the same converter as Jira.
284
+ `atlas_doc_format` so they flow through the same converter as Jira. The reverse
285
+ direction, `src/adf/from-markdown.ts`, tokenizes Markdown with `marked` and
286
+ emits ADF for the Confluence update command; it covers the same clean subset the
287
+ 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 { checkbox, 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.1.0";
11
+ var version = "1.3.0";
10
12
  //#endregion
11
13
  //#region src/api/client.ts
12
14
  var AtlassianClient = class {
@@ -17,11 +19,15 @@ 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
- } });
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
+ });
25
31
  if (!res.ok) {
26
32
  const body = await res.text().catch(() => "");
27
33
  throw httpError(res.status, path, body);
@@ -29,17 +35,55 @@ var AtlassianClient = class {
29
35
  return res;
30
36
  }
31
37
  async getJson(path) {
32
- 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 put(path, body) {
51
+ await this.request(path, {
52
+ method: "PUT",
53
+ headers: {
54
+ Accept: "application/json",
55
+ "Content-Type": "application/json"
56
+ },
57
+ body: JSON.stringify(body)
58
+ });
59
+ }
60
+ async postMultipart(path, filename, bytes) {
61
+ const form = new FormData();
62
+ const blob = new Blob([bytes]);
63
+ form.append("file", blob, filename);
64
+ return (await this.request(path, {
65
+ method: "POST",
66
+ headers: {
67
+ Accept: "application/json",
68
+ "X-Atlassian-Token": "nocheck"
69
+ },
70
+ body: form
71
+ })).json();
33
72
  }
34
73
  async getBinary(url) {
35
74
  const path = url.startsWith("http") ? new URL(url).pathname + new URL(url).search : url;
36
- const res = await this.request(path, "*/*");
75
+ const res = await this.request(path, { headers: { Accept: "*/*" } });
37
76
  return new Uint8Array(await res.arrayBuffer());
38
77
  }
39
78
  };
40
79
  function httpError(status, path, body = "") {
41
80
  if (status === 401 || status === 403) return /* @__PURE__ */ new Error("Authentication failed (401/403). Run `atlass auth login` to update your token.");
42
81
  if (status === 404) return /* @__PURE__ */ new Error(`Not found (404): ${path}`);
82
+ if (status === 409) {
83
+ const detail = extractError(body);
84
+ return /* @__PURE__ */ new Error(`Conflict (409): ${detail || "the page changed on the server"}`);
85
+ }
86
+ if (status === 413) return /* @__PURE__ */ new Error("Payload too large (413): the page or an attachment exceeds the size limit.");
43
87
  if (status === 400) {
44
88
  const detail = extractError(body);
45
89
  return /* @__PURE__ */ new Error(`Bad request (400): ${detail || path}`);
@@ -161,6 +205,201 @@ async function status() {
161
205
  console.log(`Token: ${hasToken ? "stored in keyring" : "MISSING (run `atlass auth login`)"}`);
162
206
  }
163
207
  //#endregion
208
+ //#region src/adf/from-markdown.ts
209
+ function markdownToAdf(md, options = {}) {
210
+ const ctx = { resolveImage: options.resolveImage ?? defaultResolveImage };
211
+ return {
212
+ type: "doc",
213
+ version: 1,
214
+ content: blocks(marked.lexer(md), ctx)
215
+ };
216
+ }
217
+ function blocks(tokens, ctx) {
218
+ const out = [];
219
+ for (const token of tokens) out.push(...block(token, ctx));
220
+ return out;
221
+ }
222
+ function block(token, ctx) {
223
+ switch (token.type) {
224
+ case "space": return [];
225
+ case "heading": return [{
226
+ type: "heading",
227
+ attrs: { level: clampLevel$1(token.depth) },
228
+ content: inline(token.tokens ?? [], ctx)
229
+ }];
230
+ case "paragraph": return paragraph(token.tokens ?? [], ctx);
231
+ case "text": return paragraph(token.tokens ?? [textToken(token.text)], ctx);
232
+ case "list": return [list(token, ctx)];
233
+ case "code": return [codeBlock(token)];
234
+ case "blockquote": return [{
235
+ type: "blockquote",
236
+ content: blocks(token.tokens ?? [], ctx)
237
+ }];
238
+ case "table": return [table(token, ctx)];
239
+ case "hr": return [{ type: "rule" }];
240
+ case "html": return [];
241
+ default: return [];
242
+ }
243
+ }
244
+ function paragraph(tokens, ctx) {
245
+ const out = [];
246
+ let buffer = [];
247
+ const flush = () => {
248
+ if (buffer.length === 0) return;
249
+ const nodes = inline(buffer, ctx);
250
+ if (nodes.length > 0) out.push({
251
+ type: "paragraph",
252
+ content: nodes
253
+ });
254
+ buffer = [];
255
+ };
256
+ for (const token of tokens) if (token.type === "image") {
257
+ flush();
258
+ const image = token;
259
+ const node = ctx.resolveImage(image.href, image.text ?? "");
260
+ if (node) out.push(node);
261
+ } else buffer.push(token);
262
+ flush();
263
+ return out;
264
+ }
265
+ function list(token, ctx) {
266
+ if (token.items.length > 0 && token.items.every((i) => i.task)) return {
267
+ type: "taskList",
268
+ attrs: { localId: randomUUID() },
269
+ content: token.items.map((item) => ({
270
+ type: "taskItem",
271
+ attrs: {
272
+ localId: randomUUID(),
273
+ state: item.checked ? "DONE" : "TODO"
274
+ },
275
+ content: inline(itemInline(item), ctx)
276
+ }))
277
+ };
278
+ const node = {
279
+ type: token.ordered ? "orderedList" : "bulletList",
280
+ content: token.items.map((item) => listItem(item, ctx))
281
+ };
282
+ const start = Number(token.start);
283
+ if (token.ordered && Number.isFinite(start) && start !== 1) node.attrs = { order: start };
284
+ return node;
285
+ }
286
+ function listItem(item, ctx) {
287
+ const children = blocks(item.tokens.filter((t) => t.type !== "checkbox"), ctx);
288
+ if (children.length === 0) children.push({
289
+ type: "paragraph",
290
+ content: []
291
+ });
292
+ return {
293
+ type: "listItem",
294
+ content: children
295
+ };
296
+ }
297
+ function itemInline(item) {
298
+ const first = item.tokens.find((t) => t.type === "text");
299
+ if (first && "tokens" in first && first.tokens) return first.tokens;
300
+ return [textToken(item.text)];
301
+ }
302
+ function codeBlock(token) {
303
+ return {
304
+ type: "codeBlock",
305
+ attrs: token.lang ? { language: token.lang } : {},
306
+ content: token.text.length > 0 ? [{
307
+ type: "text",
308
+ text: token.text
309
+ }] : []
310
+ };
311
+ }
312
+ function table(token, ctx) {
313
+ return {
314
+ type: "table",
315
+ content: [{
316
+ type: "tableRow",
317
+ content: token.header.map((cell) => tableCell(cell, "tableHeader", ctx))
318
+ }, ...token.rows.map((row) => ({
319
+ type: "tableRow",
320
+ content: row.map((cell) => tableCell(cell, "tableCell", ctx))
321
+ }))]
322
+ };
323
+ }
324
+ function tableCell(cell, type, ctx) {
325
+ return {
326
+ type,
327
+ content: [{
328
+ type: "paragraph",
329
+ content: inline(cell.tokens, ctx)
330
+ }]
331
+ };
332
+ }
333
+ function inline(tokens, ctx, marks = []) {
334
+ const out = [];
335
+ for (const token of tokens) out.push(...inlineNode(token, ctx, marks));
336
+ return out;
337
+ }
338
+ function inlineNode(token, ctx, marks) {
339
+ switch (token.type) {
340
+ case "text":
341
+ case "escape": {
342
+ const t = token;
343
+ if ("tokens" in t && t.tokens?.length) return inline(t.tokens, ctx, marks);
344
+ return textNode(t.text, marks);
345
+ }
346
+ case "strong": return inline(token.tokens, ctx, withMark(marks, { type: "strong" }));
347
+ case "em": return inline(token.tokens, ctx, withMark(marks, { type: "em" }));
348
+ case "del": return inline(token.tokens, ctx, withMark(marks, { type: "strike" }));
349
+ case "codespan": return textNode(token.text, withMark(marks, { type: "code" }));
350
+ case "link": {
351
+ const link = token;
352
+ return inline(link.tokens, ctx, withMark(marks, {
353
+ type: "link",
354
+ attrs: { href: link.href }
355
+ }));
356
+ }
357
+ case "br": return [{ type: "hardBreak" }];
358
+ case "html": return textNode(token.text, marks);
359
+ default: return "text" in token && token.text ? textNode(token.text, marks) : [];
360
+ }
361
+ }
362
+ function textNode(text, marks) {
363
+ if (text.length === 0) return [];
364
+ const node = {
365
+ type: "text",
366
+ text
367
+ };
368
+ if (marks.length > 0) node.marks = marks;
369
+ return [node];
370
+ }
371
+ function withMark(marks, mark) {
372
+ return [...marks.filter((m) => m.type !== mark.type), mark];
373
+ }
374
+ function defaultResolveImage(href, alt) {
375
+ return {
376
+ type: "mediaSingle",
377
+ attrs: { layout: "center" },
378
+ content: [{
379
+ type: "media",
380
+ attrs: alt ? {
381
+ type: "external",
382
+ url: href,
383
+ alt
384
+ } : {
385
+ type: "external",
386
+ url: href
387
+ }
388
+ }]
389
+ };
390
+ }
391
+ function textToken(text) {
392
+ return {
393
+ type: "text",
394
+ raw: text,
395
+ text,
396
+ escaped: false
397
+ };
398
+ }
399
+ function clampLevel$1(value) {
400
+ return Math.min(6, Math.max(1, Math.trunc(value) || 1));
401
+ }
402
+ //#endregion
164
403
  //#region src/adf/to-markdown.ts
165
404
  function adfToMarkdown(doc, options = {}) {
166
405
  if (!doc) return "";
@@ -418,6 +657,43 @@ async function fetchPage(client, site, id) {
418
657
  comments
419
658
  };
420
659
  }
660
+ async function fetchPageState(client, id) {
661
+ const page = await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}?body-format=atlas_doc_format`);
662
+ return {
663
+ version: page.version?.number ?? 0,
664
+ title: page.title,
665
+ body: parseAdf(page.body?.atlas_doc_format?.value)
666
+ };
667
+ }
668
+ async function listAttachments(client, id) {
669
+ return (await client.getJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}/attachments?limit=250`)).results.map((a) => ({
670
+ filename: a.title ?? a.id,
671
+ fileId: a.fileId ?? a.id,
672
+ size: typeof a.fileSize === "number" ? a.fileSize : -1
673
+ }));
674
+ }
675
+ async function uploadAttachment(client, pageId, filename, bytes) {
676
+ const fileId = (await client.postMultipart(`/wiki/rest/api/content/${encodeURIComponent(pageId)}/child/attachment`, filename, bytes)).results?.[0]?.extensions?.fileId;
677
+ if (fileId) return fileId;
678
+ const match = (await listAttachments(client, pageId)).find((a) => a.filename === filename);
679
+ if (match) return match.fileId;
680
+ throw new Error(`Upload of "${filename}" did not return a fileId.`);
681
+ }
682
+ async function updatePage(client, id, params) {
683
+ return (await client.putJson(`/wiki/api/v2/pages/${encodeURIComponent(id)}`, {
684
+ id,
685
+ status: "current",
686
+ title: params.title,
687
+ body: {
688
+ representation: "atlas_doc_format",
689
+ value: JSON.stringify(params.body)
690
+ },
691
+ version: {
692
+ number: params.version,
693
+ message: params.message
694
+ }
695
+ })).version?.number ?? params.version;
696
+ }
421
697
  async function searchPages(client, site, params) {
422
698
  const cql = buildCql(params);
423
699
  const query = new URLSearchParams({
@@ -551,6 +827,108 @@ function joinSections(sections) {
551
827
  return `${sections.filter((s) => s.trim().length > 0).join("\n\n")}\n`;
552
828
  }
553
829
  //#endregion
830
+ //#region src/markdown/update-source.ts
831
+ function parseUpdateSource(content) {
832
+ const { fields, bodyTitle, body } = splitFile(content);
833
+ const id = fields["id"];
834
+ if (!id) throw new Error("Frontmatter is missing the page `id`; re-copy the page.");
835
+ const version = Number(fields["version"]);
836
+ if (!Number.isFinite(version)) throw new Error("Frontmatter is missing a numeric `version`; re-copy the page.");
837
+ return {
838
+ id,
839
+ version,
840
+ frontTitle: fields["title"] ?? "",
841
+ bodyTitle,
842
+ body
843
+ };
844
+ }
845
+ function parseJiraUpdateSource(content) {
846
+ const { fields, bodyTitle, body } = splitFile(content);
847
+ const key = fields["key"];
848
+ if (!key) throw new Error("Frontmatter is missing the issue `key`; re-copy the issue.");
849
+ return {
850
+ key,
851
+ updated: fields["updated"] ?? "",
852
+ bodyTitle,
853
+ body
854
+ };
855
+ }
856
+ function splitFile(content) {
857
+ const match = content.match(/^---\n([\s\S]*?)\n---\n?/);
858
+ if (!match) throw new Error("Not an atlass file: no YAML frontmatter found.");
859
+ const fields = parseFrontmatter(match[1] ?? "");
860
+ const { bodyTitle, body } = splitBody(content.slice(match[0].length), fields["title"] ?? "");
861
+ return {
862
+ fields,
863
+ bodyTitle,
864
+ body
865
+ };
866
+ }
867
+ function parseFrontmatter(block) {
868
+ const out = {};
869
+ for (const line of block.split("\n")) {
870
+ const m = line.match(/^([A-Za-z0-9_]+):\s*(.*)$/);
871
+ if (!m) continue;
872
+ const key = m[1] ?? "";
873
+ let value = (m[2] ?? "").trim();
874
+ if (value.startsWith("\"") && value.endsWith("\"")) value = value.slice(1, -1).replace(/\\"/g, "\"").replace(/\\\\/g, "\\");
875
+ out[key] = value;
876
+ }
877
+ return out;
878
+ }
879
+ function splitBody(rest, fallbackTitle) {
880
+ const lines = rest.split("\n");
881
+ let bodyTitle = fallbackTitle;
882
+ let start = 0;
883
+ for (let i = 0; i < lines.length; i++) {
884
+ const line = lines[i] ?? "";
885
+ if (line.startsWith("# ")) {
886
+ bodyTitle = line.slice(2).trim();
887
+ start = i + 1;
888
+ break;
889
+ }
890
+ if (line.trim().length > 0) break;
891
+ }
892
+ let end = lines.length;
893
+ for (let i = start; i < lines.length; i++) if (/^## (Comments|Attachments)\s*$/.test(lines[i] ?? "")) {
894
+ end = i;
895
+ break;
896
+ }
897
+ const body = lines.slice(start, end).join("\n").trim();
898
+ return {
899
+ bodyTitle,
900
+ body
901
+ };
902
+ }
903
+ const LOSSY_LABELS = {
904
+ panel: "panel",
905
+ expand: "expand",
906
+ nestedExpand: "expand",
907
+ decisionList: "decision list",
908
+ layoutSection: "layout",
909
+ extension: "macro",
910
+ bodiedExtension: "macro",
911
+ inlineExtension: "macro"
912
+ };
913
+ const JIRA_LOSSY_LABELS = {
914
+ ...LOSSY_LABELS,
915
+ media: "image",
916
+ mediaInline: "image"
917
+ };
918
+ function findLossyNodes(node, labels = LOSSY_LABELS) {
919
+ const counts = /* @__PURE__ */ new Map();
920
+ const visit = (n) => {
921
+ const label = labels[n.type];
922
+ if (label) counts.set(label, (counts.get(label) ?? 0) + 1);
923
+ for (const child of n.content ?? []) visit(child);
924
+ };
925
+ if (node) visit(node);
926
+ return counts;
927
+ }
928
+ function formatLossy(counts) {
929
+ return [...counts.entries()].map(([label, n]) => `${n} ${label}${n === 1 ? "" : "s"}`).join(", ");
930
+ }
931
+ //#endregion
554
932
  //#region src/util/output-path.ts
555
933
  function resolveOutput(defaultBase, out) {
556
934
  let filePath;
@@ -655,6 +1033,56 @@ async function confluenceCopy(arg, options) {
655
1033
  const id = await resolveId(arg);
656
1034
  await copyPage(new AtlassianClient(auth), auth.site, id, options.out);
657
1035
  }
1036
+ async function confluenceUpdate(arg, options) {
1037
+ const file = arg ?? await input({
1038
+ message: "Path to the page Markdown file:",
1039
+ required: true
1040
+ });
1041
+ const src = parseUpdateSource(await readFile(file, "utf8"));
1042
+ const client = new AtlassianClient(await requireAuth());
1043
+ const state = await fetchPageState(client, src.id);
1044
+ 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.`);
1045
+ const dir = dirname(resolve(file));
1046
+ const attachments = await listAttachments(client, src.id);
1047
+ const plan = await planImages(dir, collectImageHrefs(src.body), attachments);
1048
+ const lossy = findLossyNodes(state.body);
1049
+ const nextVersion = state.version + 1;
1050
+ const newTitle = options.title && src.bodyTitle ? src.bodyTitle : state.title;
1051
+ if (options.dryRun) {
1052
+ printDryRun$1(src.id, state.title, newTitle, state.version, nextVersion, lossy, plan);
1053
+ return;
1054
+ }
1055
+ if (lossy.size > 0 && !options.force) {
1056
+ if (!await confirm({
1057
+ message: `This page contains ${formatLossy(lossy)} that Markdown cannot represent and will be removed. Continue?`,
1058
+ default: false
1059
+ })) {
1060
+ console.log("Aborted.");
1061
+ return;
1062
+ }
1063
+ }
1064
+ const collection = `contentId-${src.id}`;
1065
+ const fileIds = /* @__PURE__ */ new Map();
1066
+ for (const [href, entry] of plan) if (entry.kind === "upload") {
1067
+ console.log(`Uploading ${entry.filename} ...`);
1068
+ fileIds.set(href, await uploadAttachment(client, src.id, entry.filename, await readFile(entry.path)));
1069
+ } else if (entry.kind === "reuse") fileIds.set(href, entry.fileId);
1070
+ const body = markdownToAdf(src.body, { resolveImage: (href, alt) => {
1071
+ const entry = plan.get(href);
1072
+ if (!entry) return void 0;
1073
+ if (entry.kind === "external") return externalMedia(href, alt);
1074
+ const fileId = fileIds.get(href);
1075
+ return fileId ? fileMedia(fileId, collection, alt) : void 0;
1076
+ } });
1077
+ if (!body.content || body.content.length === 0) throw new Error("Refusing to update: the converted body is empty.");
1078
+ const version = await updatePage(client, src.id, {
1079
+ title: newTitle,
1080
+ version: nextVersion,
1081
+ body,
1082
+ message: options.message ?? "Updated via atlass"
1083
+ });
1084
+ console.log(`Updated page ${src.id} to version ${version}.`);
1085
+ }
658
1086
  async function confluenceSearch(query, options) {
659
1087
  if (options.cql && (query || options.space)) throw new Error("--cql cannot be combined with a text query or --space.");
660
1088
  if (options.json && options.copy) throw new Error("--json and --copy cannot be used together.");
@@ -723,6 +1151,94 @@ async function resolveId(arg) {
723
1151
  if (!id) throw new Error(`Could not find a page id in "${raw}".`);
724
1152
  return id;
725
1153
  }
1154
+ function collectImageHrefs(md) {
1155
+ const hrefs = /* @__PURE__ */ new Set();
1156
+ marked.walkTokens(marked.lexer(md), (token) => {
1157
+ if (token.type === "image") hrefs.add(token.href);
1158
+ });
1159
+ return [...hrefs];
1160
+ }
1161
+ async function planImages(dir, hrefs, attachments) {
1162
+ const byName = new Map(attachments.map((a) => [a.filename, a]));
1163
+ const plan = /* @__PURE__ */ new Map();
1164
+ const missing = [];
1165
+ for (const href of hrefs) {
1166
+ if (isExternal(href)) {
1167
+ plan.set(href, { kind: "external" });
1168
+ continue;
1169
+ }
1170
+ const path = isAbsolute(href) ? href : resolve(dir, href);
1171
+ let size;
1172
+ try {
1173
+ size = (await stat(path)).size;
1174
+ } catch {
1175
+ missing.push(href);
1176
+ continue;
1177
+ }
1178
+ const filename = basename(path);
1179
+ const existing = byName.get(filename);
1180
+ if (existing && existing.size === size) plan.set(href, {
1181
+ kind: "reuse",
1182
+ fileId: existing.fileId,
1183
+ filename
1184
+ });
1185
+ else plan.set(href, {
1186
+ kind: "upload",
1187
+ path,
1188
+ filename,
1189
+ existed: existing !== void 0
1190
+ });
1191
+ }
1192
+ if (missing.length > 0) throw new Error(`Image file(s) not found: ${missing.join(", ")}`);
1193
+ return plan;
1194
+ }
1195
+ function isExternal(href) {
1196
+ return /^[a-z][a-z0-9+.-]*:\/\//i.test(href);
1197
+ }
1198
+ function externalMedia(href, alt) {
1199
+ const attrs = {
1200
+ type: "external",
1201
+ url: href
1202
+ };
1203
+ if (alt) attrs["alt"] = alt;
1204
+ return {
1205
+ type: "mediaSingle",
1206
+ attrs: { layout: "center" },
1207
+ content: [{
1208
+ type: "media",
1209
+ attrs
1210
+ }]
1211
+ };
1212
+ }
1213
+ function fileMedia(fileId, collection, alt) {
1214
+ const attrs = {
1215
+ type: "file",
1216
+ id: fileId,
1217
+ collection
1218
+ };
1219
+ if (alt) attrs["alt"] = alt;
1220
+ return {
1221
+ type: "mediaSingle",
1222
+ attrs: { layout: "center" },
1223
+ content: [{
1224
+ type: "media",
1225
+ attrs
1226
+ }]
1227
+ };
1228
+ }
1229
+ function printDryRun$1(id, currentTitle, newTitle, currentVersion, nextVersion, lossy, plan) {
1230
+ const entries = [...plan.values()];
1231
+ const added = entries.filter((e) => e.kind === "upload" && !e.existed).length;
1232
+ const changed = entries.filter((e) => e.kind === "upload" && e.existed).length;
1233
+ const reused = entries.filter((e) => e.kind === "reuse").length;
1234
+ const external = entries.filter((e) => e.kind === "external").length;
1235
+ console.log(`Dry run for page ${id} "${currentTitle}"`);
1236
+ console.log(` version: ${currentVersion} -> ${nextVersion}`);
1237
+ if (newTitle !== currentTitle) console.log(` title: "${currentTitle}" -> "${newTitle}"`);
1238
+ console.log(` images: ${added} new, ${changed} changed, ${reused} reused, ${external} external`);
1239
+ if (lossy.size > 0) console.log(` warning: ${formatLossy(lossy)} will be removed`);
1240
+ console.log(" nothing was written (dry run)");
1241
+ }
726
1242
  //#endregion
727
1243
  //#region src/api/jira.ts
728
1244
  const FIELDS = [
@@ -763,6 +1279,11 @@ async function fetchIssue(client, site, key) {
763
1279
  }))
764
1280
  };
765
1281
  }
1282
+ async function updateIssue(client, key, update) {
1283
+ const fields = { description: update.description };
1284
+ if (update.summary !== void 0) fields["summary"] = update.summary;
1285
+ await client.put(`/rest/api/3/issue/${encodeURIComponent(key)}`, { fields });
1286
+ }
766
1287
  async function searchIssues(client, site, params) {
767
1288
  const jql = buildJql(params);
768
1289
  const query = new URLSearchParams({
@@ -804,6 +1325,41 @@ async function jiraCopy(arg, options) {
804
1325
  const key = await resolveKey(arg);
805
1326
  await copyIssue(new AtlassianClient(auth), auth.site, key, options.out);
806
1327
  }
1328
+ async function jiraUpdate(arg, options) {
1329
+ const src = parseJiraUpdateSource(await readFile(arg ?? await input({
1330
+ message: "Path to the issue Markdown file:",
1331
+ required: true
1332
+ }), "utf8"));
1333
+ const auth = await requireAuth();
1334
+ const client = new AtlassianClient(auth);
1335
+ const issue = await fetchIssue(client, auth.site, src.key);
1336
+ const stale = issue.updated !== src.updated;
1337
+ const { local, external } = classifyImages(src.body);
1338
+ const lossy = findLossyNodes(issue.description, JIRA_LOSSY_LABELS);
1339
+ const newSummary = options.summary && src.bodyTitle ? src.bodyTitle : issue.summary;
1340
+ if (options.dryRun) {
1341
+ printDryRun(src.key, issue.summary, newSummary, stale, external.length, local, lossy);
1342
+ return;
1343
+ }
1344
+ if (local.length > 0) throw new Error(`jira update does not support image changes yet. Remove local image reference(s) or edit text only: ${local.join(", ")}`);
1345
+ if (stale && !options.force) throw new Error(`Issue changed on the server since you copied it (local ${src.updated || "unknown"}, server ${issue.updated}). Re-copy the issue or pass --force.`);
1346
+ if (lossy.size > 0 && !options.force) {
1347
+ if (!await confirm({
1348
+ message: `This issue's description contains ${formatLossy(lossy)} that Markdown cannot represent and will be removed. Continue?`,
1349
+ default: false
1350
+ })) {
1351
+ console.log("Aborted.");
1352
+ return;
1353
+ }
1354
+ }
1355
+ const description = markdownToAdf(src.body);
1356
+ if (!description.content || description.content.length === 0) throw new Error("Refusing to update: the converted description is empty.");
1357
+ await updateIssue(client, src.key, {
1358
+ description,
1359
+ summary: options.summary && newSummary !== issue.summary ? newSummary : void 0
1360
+ });
1361
+ console.log(`Updated ${src.key}.`);
1362
+ }
807
1363
  async function jiraSearch(query, options) {
808
1364
  if (options.jql && (query || options.project || options.assignee || options.status)) throw new Error("--jql cannot be combined with a text query or other filters.");
809
1365
  if (options.json && options.copy) throw new Error("--json and --copy cannot be used together.");
@@ -879,6 +1435,32 @@ function report(filePath, assetCount) {
879
1435
  const suffix = assetCount > 0 ? ` (+${assetCount} attachment${assetCount === 1 ? "" : "s"})` : "";
880
1436
  console.log(`Wrote ${filePath}${suffix}`);
881
1437
  }
1438
+ function classifyImages(md) {
1439
+ const local = [];
1440
+ const external = [];
1441
+ const seen = /* @__PURE__ */ new Set();
1442
+ marked.walkTokens(marked.lexer(md), (token) => {
1443
+ if (token.type !== "image") return;
1444
+ const href = token.href;
1445
+ if (seen.has(href)) return;
1446
+ seen.add(href);
1447
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(href)) external.push(href);
1448
+ else local.push(href);
1449
+ });
1450
+ return {
1451
+ local,
1452
+ external
1453
+ };
1454
+ }
1455
+ function printDryRun(key, currentSummary, newSummary, stale, externalImages, localImages, lossy) {
1456
+ console.log(`Dry run for ${key} "${currentSummary}"`);
1457
+ if (newSummary !== currentSummary) console.log(` summary: "${currentSummary}" -> "${newSummary}"`);
1458
+ if (externalImages > 0) console.log(` images: ${externalImages} external`);
1459
+ if (localImages.length > 0) console.log(` blocked: ${localImages.length} local image(s) not supported (edit text only)`);
1460
+ if (lossy.size > 0) console.log(` warning: ${formatLossy(lossy)} will be removed`);
1461
+ if (stale) console.log(` stale: server changed since copy (would refuse without --force)`);
1462
+ console.log(" nothing was written (dry run)");
1463
+ }
882
1464
  //#endregion
883
1465
  //#region src/cli.ts
884
1466
  const program = new Command();
@@ -889,9 +1471,11 @@ auth.command("logout").description("Remove stored credentials").action(run(logou
889
1471
  auth.command("status").description("Show the current login").action(run(status));
890
1472
  const jira = program.command("jira").description("Jira commands");
891
1473
  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));
1474
+ 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(run(jiraUpdate));
892
1475
  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));
893
1476
  const confluence = program.command("confluence").description("Confluence commands");
894
1477
  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));
1478
+ 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));
895
1479
  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));
896
1480
  program.parseAsync().catch(fail);
897
1481
  function run(fn) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "atlass",
3
- "version": "1.1.0",
4
- "description": "CLI to copy Jira issues and Confluence pages to Markdown.",
3
+ "version": "1.3.0",
4
+ "description": "CLI to copy Jira issues and Confluence pages to Markdown, and update Confluence pages.",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -33,7 +33,8 @@
33
33
  "dependencies": {
34
34
  "@inquirer/prompts": "^8.5.2",
35
35
  "@napi-rs/keyring": "^1.3.0",
36
- "commander": "^15.0.0"
36
+ "commander": "^15.0.0",
37
+ "marked": "^18.0.5"
37
38
  },
38
39
  "devDependencies": {
39
40
  "@types/node": "^25.6.2",