@baiyibai-antora/confluence-retriever 1.0.0-beta.5
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/bin/confluence-retrieve.js +94 -0
- package/lib/index.js +5 -0
- package/lib/retrieve.js +143 -0
- package/package.json +42 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict'
|
|
3
|
+
|
|
4
|
+
// confluence-retrieve: pull a Confluence space (or a page subtree) down as
|
|
5
|
+
// AsciiDoc files plus attachments. Credentials from the environment only.
|
|
6
|
+
|
|
7
|
+
const fsp = require('node:fs/promises')
|
|
8
|
+
const ospath = require('node:path')
|
|
9
|
+
const { AtlassianClient, credentialsFromEnv } = require('@baiyibai-antora/atlassian-client')
|
|
10
|
+
const { retrieveSpace } = require('../lib')
|
|
11
|
+
|
|
12
|
+
const USAGE = `Usage: confluence-retrieve [options] <SPACE-KEY>
|
|
13
|
+
|
|
14
|
+
Writes every page of the space as an .adoc file (attachments alongside in
|
|
15
|
+
_attachments/), mirroring the page hierarchy. Pages published by
|
|
16
|
+
confluence-publisher are restored to their original component paths.
|
|
17
|
+
|
|
18
|
+
Options:
|
|
19
|
+
--out <dir> output directory (default: ./<SPACE-KEY>)
|
|
20
|
+
--page <id> only this page and its descendants
|
|
21
|
+
--keep-colors keep text/background colors as role spans
|
|
22
|
+
--no-keys ignore publisher page keys; use slugified titles only
|
|
23
|
+
-h, --help show this help
|
|
24
|
+
|
|
25
|
+
Environment: ATLASSIAN_BASE_URL (or CONFLUENCE_BASE_URL), ATLASSIAN_EMAIL,
|
|
26
|
+
ATLASSIAN_API_TOKEN`
|
|
27
|
+
|
|
28
|
+
function parseArgs (argv) {
|
|
29
|
+
const opts = { out: null, page: null, styles: 'drop', restoreKeys: true, space: null }
|
|
30
|
+
for (let i = 0; i < argv.length; i++) {
|
|
31
|
+
const arg = argv[i]
|
|
32
|
+
if (arg === '-h' || arg === '--help') return { help: true }
|
|
33
|
+
else if (arg === '--out') opts.out = argv[++i]
|
|
34
|
+
else if (arg === '--page') opts.page = argv[++i]
|
|
35
|
+
else if (arg === '--keep-colors') opts.styles = 'roles'
|
|
36
|
+
else if (arg === '--no-keys') opts.restoreKeys = false
|
|
37
|
+
else if (arg.startsWith('-')) throw new Error(`unknown option ${arg}`)
|
|
38
|
+
else if (opts.space) throw new Error('only one space key may be given')
|
|
39
|
+
else opts.space = arg
|
|
40
|
+
}
|
|
41
|
+
if (!opts.space) throw new Error('a space key is required')
|
|
42
|
+
return opts
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function main () {
|
|
46
|
+
let opts
|
|
47
|
+
try {
|
|
48
|
+
opts = parseArgs(process.argv.slice(2))
|
|
49
|
+
} catch (err) {
|
|
50
|
+
process.stderr.write(`confluence-retrieve: ${err.message}\n${USAGE}\n`)
|
|
51
|
+
return 1
|
|
52
|
+
}
|
|
53
|
+
if (opts.help) {
|
|
54
|
+
process.stdout.write(USAGE + '\n')
|
|
55
|
+
return 0
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let client
|
|
59
|
+
try {
|
|
60
|
+
client = new AtlassianClient({ ...credentialsFromEnv(), serviceName: 'Confluence' })
|
|
61
|
+
} catch (err) {
|
|
62
|
+
process.stderr.write(`confluence-retrieve: ${err.message}\n`)
|
|
63
|
+
return 1
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const logger = { warn: (msg) => process.stderr.write(`confluence-retrieve: ${msg}\n`) }
|
|
67
|
+
const { files, pages } = await retrieveSpace({
|
|
68
|
+
client,
|
|
69
|
+
spaceKey: opts.space,
|
|
70
|
+
styles: opts.styles,
|
|
71
|
+
restoreKeys: opts.restoreKeys,
|
|
72
|
+
rootPageId: opts.page,
|
|
73
|
+
logger,
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
const outDir = opts.out || opts.space
|
|
77
|
+
for (const file of files) {
|
|
78
|
+
const dest = ospath.join(outDir, ...file.path.split('/'))
|
|
79
|
+
await fsp.mkdir(ospath.dirname(dest), { recursive: true })
|
|
80
|
+
await fsp.writeFile(dest, file.contents)
|
|
81
|
+
}
|
|
82
|
+
process.stdout.write(`retrieved ${pages.length} page(s) and ${files.length - pages.length} attachment(s) to ${outDir}\n`)
|
|
83
|
+
return 0
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
main().then(
|
|
87
|
+
(code) => {
|
|
88
|
+
process.exitCode = code
|
|
89
|
+
},
|
|
90
|
+
(err) => {
|
|
91
|
+
process.stderr.write(`confluence-retrieve: ${err.message}\n`)
|
|
92
|
+
process.exitCode = 1
|
|
93
|
+
}
|
|
94
|
+
)
|
package/lib/index.js
ADDED
package/lib/retrieve.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { posix: path } = require('node:path')
|
|
4
|
+
const { toAdoc } = require('@baiyibai-antora/asciidoc-adf-converter')
|
|
5
|
+
|
|
6
|
+
// Retrieve a Confluence space as an in-memory AsciiDoc file tree. The core
|
|
7
|
+
// is Antora-agnostic: it returns { files, pages, warnings } where files are
|
|
8
|
+
// { path, contents } entries (pages as strings, attachments as Buffers) and
|
|
9
|
+
// callers decide where they land (the CLI writes them to a directory).
|
|
10
|
+
//
|
|
11
|
+
// Page paths mirror the Confluence page hierarchy with slugified titles.
|
|
12
|
+
// Pages that confluence-publisher published carry their original source
|
|
13
|
+
// identity in the `antora-adf-publisher` content property
|
|
14
|
+
// (`component:version:module:file`); with restoreKeys (default) those pages
|
|
15
|
+
// come back under their original component/module paths instead.
|
|
16
|
+
|
|
17
|
+
const PAGE_PROPERTY_KEY = 'antora-adf-publisher'
|
|
18
|
+
|
|
19
|
+
function slugify (title) {
|
|
20
|
+
const slug = String(title)
|
|
21
|
+
.toLowerCase()
|
|
22
|
+
.normalize('NFKD')
|
|
23
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
24
|
+
.replace(/^-+|-+$/g, '')
|
|
25
|
+
return slug || 'untitled'
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function keyToPath (pageKey) {
|
|
29
|
+
// docs:1.2.0:ROOT:guides/intro.adoc -> docs/1.2.0/ROOT/guides/intro.adoc
|
|
30
|
+
const parts = String(pageKey).split(':')
|
|
31
|
+
if (parts.length < 4) return null
|
|
32
|
+
const [component, version, module_, ...file] = parts
|
|
33
|
+
return path.join(component, version, module_, file.join(':'))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function retrieveSpace ({ client, spaceKey, styles, restoreKeys = true, rootPageId, logger }) {
|
|
37
|
+
const warnings = []
|
|
38
|
+
const warn = (msg) => {
|
|
39
|
+
warnings.push(msg)
|
|
40
|
+
if (logger) logger.warn(msg)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const spaces = await client.request('GET', `/wiki/api/v2/spaces?keys=${encodeURIComponent(spaceKey)}`)
|
|
44
|
+
const space = spaces && spaces.results && spaces.results[0]
|
|
45
|
+
if (!space) throw new Error(`space "${spaceKey}" not found`)
|
|
46
|
+
|
|
47
|
+
const raw = await client.getAll(`/wiki/api/v2/spaces/${space.id}/pages?body-format=atlas_doc_format&limit=100`)
|
|
48
|
+
const byId = new Map(raw.map((p) => [String(p.id), p]))
|
|
49
|
+
|
|
50
|
+
// Optional subtree filter: keep pages that descend from rootPageId.
|
|
51
|
+
const inScope = (page) => {
|
|
52
|
+
if (!rootPageId) return true
|
|
53
|
+
for (let id = String(page.id); id; ) {
|
|
54
|
+
if (id === String(rootPageId)) return true
|
|
55
|
+
const parent = byId.get(id)
|
|
56
|
+
id = parent && parent.parentId != null ? String(parent.parentId) : null
|
|
57
|
+
}
|
|
58
|
+
return false
|
|
59
|
+
}
|
|
60
|
+
const pagesRaw = raw.filter(inScope)
|
|
61
|
+
|
|
62
|
+
// Ancestor slug chains (the space homepage is the tree root, not a folder).
|
|
63
|
+
const homeId = space.homepageId == null ? null : String(space.homepageId)
|
|
64
|
+
const slugById = new Map(pagesRaw.map((p) => [String(p.id), slugify(p.title)]))
|
|
65
|
+
const dirFor = (page) => {
|
|
66
|
+
const chain = []
|
|
67
|
+
for (let id = page.parentId == null ? null : String(page.parentId); id && id !== homeId; ) {
|
|
68
|
+
const parent = byId.get(id)
|
|
69
|
+
if (!parent) break
|
|
70
|
+
chain.unshift(slugById.get(id) || slugify(parent.title))
|
|
71
|
+
id = parent.parentId == null ? null : String(parent.parentId)
|
|
72
|
+
}
|
|
73
|
+
return chain.join('/')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const files = []
|
|
77
|
+
const pages = []
|
|
78
|
+
const usedPaths = new Set()
|
|
79
|
+
const uniquePath = (candidate) => {
|
|
80
|
+
let result = candidate
|
|
81
|
+
for (let n = 2; usedPaths.has(result); n++) {
|
|
82
|
+
result = candidate.replace(/\.adoc$/, `-${n}.adoc`)
|
|
83
|
+
}
|
|
84
|
+
usedPaths.add(result)
|
|
85
|
+
return result
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
for (const page of pagesRaw) {
|
|
89
|
+
const id = String(page.id)
|
|
90
|
+
const body = page.body && page.body.atlas_doc_format && page.body.atlas_doc_format.value
|
|
91
|
+
if (!body) {
|
|
92
|
+
warn(`${page.title} (${id}): no atlas_doc_format body; skipped`)
|
|
93
|
+
continue
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let key = null
|
|
97
|
+
if (restoreKeys) {
|
|
98
|
+
const props = await client.request(
|
|
99
|
+
'GET',
|
|
100
|
+
`/wiki/api/v2/pages/${id}/properties?key=${PAGE_PROPERTY_KEY}`
|
|
101
|
+
)
|
|
102
|
+
const prop = props && props.results && props.results[0]
|
|
103
|
+
key = (prop && prop.value && prop.value.pageKey) || null
|
|
104
|
+
}
|
|
105
|
+
const pagePath = uniquePath(
|
|
106
|
+
(key && keyToPath(key)) || path.join(dirFor(page), `${slugById.get(id)}.adoc`)
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
// Attachments: ADF media ids reference the attachment *file id*; map
|
|
110
|
+
// them onto downloaded files next to the page.
|
|
111
|
+
const attachments = await client.getAll(`/wiki/api/v2/pages/${id}/attachments?limit=100`)
|
|
112
|
+
const byFileId = new Map(attachments.map((att) => [String(att.fileId), att]))
|
|
113
|
+
const wanted = new Map()
|
|
114
|
+
const attachmentDir = path.join(path.dirname(pagePath), '_attachments')
|
|
115
|
+
const resolveMedia = ({ id: mediaId }) => {
|
|
116
|
+
const att = byFileId.get(String(mediaId))
|
|
117
|
+
if (!att) return null
|
|
118
|
+
const target = path.join(attachmentDir, att.title)
|
|
119
|
+
wanted.set(target, att)
|
|
120
|
+
return path.relative(path.dirname(pagePath), target)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const result = toAdoc(JSON.parse(body), { styles, resolveMedia })
|
|
124
|
+
for (const warning of result.warnings) warn(`${page.title} (${id}): ${warning}`)
|
|
125
|
+
|
|
126
|
+
for (const [target, att] of wanted) {
|
|
127
|
+
const downloadPath = att._links && att._links.download
|
|
128
|
+
const bytes = downloadPath ? await client.download(`/wiki${downloadPath}`) : null
|
|
129
|
+
if (bytes) {
|
|
130
|
+
files.push({ path: target, contents: bytes })
|
|
131
|
+
} else {
|
|
132
|
+
warn(`${page.title} (${id}): attachment ${att.title} could not be downloaded`)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
files.push({ path: pagePath, contents: `= ${page.title}\n\n${result.adoc}` })
|
|
137
|
+
pages.push({ id, title: page.title, path: pagePath, key })
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return { files, pages, warnings }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
module.exports = { retrieveSpace, slugify, keyToPath }
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@baiyibai-antora/confluence-retriever",
|
|
3
|
+
"version": "1.0.0-beta.5",
|
|
4
|
+
"description": "Retrieve a Confluence Cloud space as AsciiDoc: pages via the v2 REST API in ADF, converted with @baiyibai-antora/asciidoc-adf-converter, attachments downloaded alongside",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "baiyibai",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://gitlab.com/baiyibai-antora/confluora.git",
|
|
10
|
+
"directory": "packages/confluence-retriever"
|
|
11
|
+
},
|
|
12
|
+
"type": "commonjs",
|
|
13
|
+
"main": "lib/index.js",
|
|
14
|
+
"bin": {
|
|
15
|
+
"confluence-retrieve": "bin/confluence-retrieve.js"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"lib",
|
|
19
|
+
"bin"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"test": "node --test test/*-test.js"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@baiyibai-antora/asciidoc-adf-converter": "^1.0.0-beta.5",
|
|
26
|
+
"@baiyibai-antora/atlassian-client": "^1.0.0-beta.5"
|
|
27
|
+
},
|
|
28
|
+
"keywords": [
|
|
29
|
+
"confluora",
|
|
30
|
+
"confluence",
|
|
31
|
+
"asciidoc",
|
|
32
|
+
"adf",
|
|
33
|
+
"retriever",
|
|
34
|
+
"export"
|
|
35
|
+
],
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=18"
|
|
38
|
+
},
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public"
|
|
41
|
+
}
|
|
42
|
+
}
|