@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.
@@ -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
@@ -0,0 +1,5 @@
1
+ 'use strict'
2
+
3
+ const { retrieveSpace, slugify, keyToPath } = require('./retrieve')
4
+
5
+ module.exports = { retrieveSpace, slugify, keyToPath }
@@ -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
+ }