atlass 1.1.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 +49 -6
  2. package/dist/cli.mjs +493 -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
 
@@ -72,6 +74,44 @@ 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
+
75
115
  ### Search Jira issues
76
116
 
77
117
  ```bash
@@ -199,12 +239,15 @@ pnpm dev # build in watch mode
199
239
 
200
240
  - `src/cli.ts` command wiring (commander)
201
241
  - `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
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
205
245
  - `src/config.ts`, `src/credentials.ts` config file and keyring
206
246
  - `src/util/` key/id parsing and output path resolution
207
247
 
208
248
  The ADF to Markdown converter in `src/adf/to-markdown.ts` is a single hand
209
249
  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.
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 { 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.2.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,45 @@ 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 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();
33
62
  }
34
63
  async getBinary(url) {
35
64
  const path = url.startsWith("http") ? new URL(url).pathname + new URL(url).search : url;
36
- const res = await this.request(path, "*/*");
65
+ const res = await this.request(path, { headers: { Accept: "*/*" } });
37
66
  return new Uint8Array(await res.arrayBuffer());
38
67
  }
39
68
  };
40
69
  function httpError(status, path, body = "") {
41
70
  if (status === 401 || status === 403) return /* @__PURE__ */ new Error("Authentication failed (401/403). Run `atlass auth login` to update your token.");
42
71
  if (status === 404) return /* @__PURE__ */ new Error(`Not found (404): ${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.");
43
77
  if (status === 400) {
44
78
  const detail = extractError(body);
45
79
  return /* @__PURE__ */ new Error(`Bad request (400): ${detail || path}`);
@@ -161,6 +195,201 @@ async function status() {
161
195
  console.log(`Token: ${hasToken ? "stored in keyring" : "MISSING (run `atlass auth login`)"}`);
162
196
  }
163
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
164
393
  //#region src/adf/to-markdown.ts
165
394
  function adfToMarkdown(doc, options = {}) {
166
395
  if (!doc) return "";
@@ -418,6 +647,43 @@ async function fetchPage(client, site, id) {
418
647
  comments
419
648
  };
420
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
+ }
421
687
  async function searchPages(client, site, params) {
422
688
  const cql = buildCql(params);
423
689
  const query = new URLSearchParams({
@@ -551,6 +817,84 @@ function joinSections(sections) {
551
817
  return `${sections.filter((s) => s.trim().length > 0).join("\n\n")}\n`;
552
818
  }
553
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
554
898
  //#region src/util/output-path.ts
555
899
  function resolveOutput(defaultBase, out) {
556
900
  let filePath;
@@ -655,6 +999,56 @@ async function confluenceCopy(arg, options) {
655
999
  const id = await resolveId(arg);
656
1000
  await copyPage(new AtlassianClient(auth), auth.site, id, options.out);
657
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
+ }
658
1052
  async function confluenceSearch(query, options) {
659
1053
  if (options.cql && (query || options.space)) throw new Error("--cql cannot be combined with a text query or --space.");
660
1054
  if (options.json && options.copy) throw new Error("--json and --copy cannot be used together.");
@@ -723,6 +1117,94 @@ async function resolveId(arg) {
723
1117
  if (!id) throw new Error(`Could not find a page id in "${raw}".`);
724
1118
  return id;
725
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
+ }
726
1208
  //#endregion
727
1209
  //#region src/api/jira.ts
728
1210
  const FIELDS = [
@@ -892,6 +1374,7 @@ jira.command("copy [issue]").description("Copy a Jira issue (key or URL) to a Ma
892
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));
893
1375
  const confluence = program.command("confluence").description("Confluence commands");
894
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));
895
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));
896
1379
  program.parseAsync().catch(fail);
897
1380
  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.2.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",