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