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.
- package/README.md +49 -6
- package/dist/cli.mjs +493 -10
- 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
|
|
203
|
-
- `src/adf/` ADF to Markdown
|
|
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.
|
|
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,
|
|
21
|
-
const res = await fetch(`${this.site}${path}`, {
|
|
22
|
-
|
|
23
|
-
|
|
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.
|
|
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",
|