confluence-md-sync 0.4.1 → 0.5.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 +24 -5
- package/dist/cli.js +15 -3
- package/dist/export/export-page.d.ts +7 -0
- package/dist/export/export-page.js +6 -1
- package/dist/export/to-markdown.d.ts +18 -0
- package/dist/export/to-markdown.js +101 -21
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -199,6 +199,10 @@ console.log(downloaded); // Map<filename, localPath> of saved atta
|
|
|
199
199
|
|
|
200
200
|
// Readable variant for humans (writes page.md + ./attachments/ next to it):
|
|
201
201
|
await exportPage('123456789', { outFile: 'page.md', mode: 'readable' }, cfg);
|
|
202
|
+
|
|
203
|
+
// Self-contained (local <img>/links) with wide tables unfolded to records:
|
|
204
|
+
await exportPage('123456789',
|
|
205
|
+
{ outDir: 'doc', attachments: 'local', tables: 'records' }, cfg);
|
|
202
206
|
```
|
|
203
207
|
|
|
204
208
|
Round-trip means *edit the Markdown, then publish it straight back*:
|
|
@@ -228,13 +232,28 @@ schema-version, attribute/parameter order and entity vs literal forms are
|
|
|
228
232
|
normalised away (Confluence itself rewrites these on every save). Use
|
|
229
233
|
`compareStorage(a, b)` directly to diff two storage fragments.
|
|
230
234
|
|
|
231
|
-
|
|
235
|
+
### Which export variant?
|
|
236
|
+
|
|
237
|
+
Attachments download to `attachments/` next to the `.md` in every variant
|
|
238
|
+
(pass `--no-attachments` to skip). Flags compose; `--local` and `--records`
|
|
239
|
+
imply `--readable`.
|
|
240
|
+
|
|
241
|
+
| Command | Use it when | Output |
|
|
242
|
+
| --- | --- | --- |
|
|
243
|
+
| `export <id> --out page.md` | you'll **edit and publish it back** | Faithful, round-trippable; raw HTML kept for what Markdown can't express. |
|
|
244
|
+
| `export <id> --out page.md --readable` | **reading / diffing** in a Markdown viewer | Clean Markdown, no raw HTML; complex tables → GFM. Not round-trippable. |
|
|
245
|
+
| `export <id> --out-dir doc --local` | a **self-contained folder to view offline** | Like `--readable`, but images become `<img src="attachments/…">` (sizes kept) and files become `[name](attachments/…)` links pointing at the downloaded files. |
|
|
246
|
+
| `export <id> --out page.md --records` | tables are **too wide** to read as a grid | Every table row is unfolded into a `**Header:** value` record (records split by `---`). Best for tables that have a header row. |
|
|
247
|
+
| `export <id> --out-dir doc --local --records` | an **offline, wide-table-friendly** read | Both of the above together. |
|
|
248
|
+
| `roundtrip <id>` | **checking** a page converts with no loss (CI) | Writes nothing; exits `2` if faithful conversion would lose markup. |
|
|
232
249
|
|
|
233
250
|
```bash
|
|
234
|
-
|
|
235
|
-
confluence-md-sync export 123456789 --out page.md
|
|
236
|
-
confluence-md-sync export 123456789 --out page.md
|
|
237
|
-
confluence-md-sync
|
|
251
|
+
confluence-md-sync export 123456789 --out page.md # faithful (edit → publish back)
|
|
252
|
+
confluence-md-sync export 123456789 --out page.md --readable # clean Markdown to read
|
|
253
|
+
confluence-md-sync export 123456789 --out-dir doc --local # self-contained: page.md + attachments/
|
|
254
|
+
confluence-md-sync export 123456789 --out page.md --records # wide tables → records
|
|
255
|
+
confluence-md-sync export 123456789 --out-dir doc --local --records # offline + records
|
|
256
|
+
confluence-md-sync roundtrip 123456789 --show-markdown # verify, exit 2 on loss
|
|
238
257
|
```
|
|
239
258
|
|
|
240
259
|
## Read pages and tables
|
package/dist/cli.js
CHANGED
|
@@ -21,7 +21,7 @@ const HELP = `confluence-md-sync — Markdown ⇄ Confluence
|
|
|
21
21
|
|
|
22
22
|
Usage:
|
|
23
23
|
confluence-md-sync publish <markdown-file> [options]
|
|
24
|
-
confluence-md-sync export <page-id> [--out <file> | --out-dir <dir>] [--readable] [--no-attachments]
|
|
24
|
+
confluence-md-sync export <page-id> [--out <file> | --out-dir <dir>] [--readable] [--local] [--records] [--no-attachments]
|
|
25
25
|
confluence-md-sync roundtrip <page-id> [--show-markdown]
|
|
26
26
|
|
|
27
27
|
publish options:
|
|
@@ -46,6 +46,12 @@ export options:
|
|
|
46
46
|
--readable Prefer clean Markdown over fidelity: no raw HTML blocks,
|
|
47
47
|
complex tables flattened to GFM. Not round-trippable —
|
|
48
48
|
loses styling/exact cell merges, keeps the content
|
|
49
|
+
--local Reference downloaded attachments as local files:
|
|
50
|
+
images as HTML <img src="attachments/…">, other files
|
|
51
|
+
as [name](attachments/…) links. Implies --readable
|
|
52
|
+
--records Unfold every table row into a "**Header:** value"
|
|
53
|
+
record (records split by ---). For wide tables that
|
|
54
|
+
don't fit as GFM. Implies --readable
|
|
49
55
|
--no-attachments Do not download referenced attachments
|
|
50
56
|
|
|
51
57
|
roundtrip options:
|
|
@@ -74,6 +80,8 @@ async function main() {
|
|
|
74
80
|
out: { type: 'string' },
|
|
75
81
|
'out-dir': { type: 'string' },
|
|
76
82
|
readable: { type: 'boolean' },
|
|
83
|
+
local: { type: 'boolean' },
|
|
84
|
+
records: { type: 'boolean' },
|
|
77
85
|
'no-attachments': { type: 'boolean' },
|
|
78
86
|
'show-markdown': { type: 'boolean' },
|
|
79
87
|
help: { type: 'boolean' },
|
|
@@ -107,13 +115,17 @@ async function main() {
|
|
|
107
115
|
if (command === 'export') {
|
|
108
116
|
if (!arg)
|
|
109
117
|
throw new Error('export: page id is required');
|
|
118
|
+
// --local и --records подразумевают readable-обработку текста.
|
|
119
|
+
const readable = values.readable || values.local || values.records;
|
|
110
120
|
const result = await exportPage(arg, {
|
|
111
121
|
outFile: values.out,
|
|
112
122
|
outDir: values['out-dir'],
|
|
113
123
|
downloadAttachments: !values['no-attachments'],
|
|
114
|
-
mode:
|
|
124
|
+
mode: readable ? 'readable' : 'faithful',
|
|
125
|
+
attachments: values.local ? 'local' : 'placeholder',
|
|
126
|
+
tables: values.records ? 'records' : 'auto',
|
|
115
127
|
}, cfg);
|
|
116
|
-
const tail =
|
|
128
|
+
const tail = readable
|
|
117
129
|
? `${result.stats.lossy} block(s) simplified`
|
|
118
130
|
: `${result.stats.fenced} raw storage block(s)`;
|
|
119
131
|
console.log(`[cli] exported page ${result.pageId} "${result.title}" v${result.version} → ${result.markdownPath}` +
|
|
@@ -23,6 +23,13 @@ export interface ExportPageOptions {
|
|
|
23
23
|
* 'readable' — чистый Markdown ценой оформления (round-trip не гарантирован).
|
|
24
24
|
*/
|
|
25
25
|
mode?: 'faithful' | 'readable';
|
|
26
|
+
/**
|
|
27
|
+
* 'placeholder' (default) — {{img:}}/{{file:}}; 'local' — картинки как
|
|
28
|
+
* HTML `<img src="attachments/…">`, файлы как md-ссылки. Подразумевает readable.
|
|
29
|
+
*/
|
|
30
|
+
attachments?: 'placeholder' | 'local';
|
|
31
|
+
/** 'auto' (default) или 'records' — строки таблиц как записи. Подразумевает readable. */
|
|
32
|
+
tables?: 'auto' | 'records';
|
|
26
33
|
registry?: MacroRegistry;
|
|
27
34
|
}
|
|
28
35
|
export interface ExportPageResult extends StorageToMarkdownResult {
|
|
@@ -9,7 +9,12 @@ import { storageToMarkdown } from './to-markdown.js';
|
|
|
9
9
|
export async function exportPage(pageId, opts, cfg) {
|
|
10
10
|
const client = new ConfluenceClient(cfg);
|
|
11
11
|
const page = await client.getPageStorage(pageId);
|
|
12
|
-
const converted = storageToMarkdown(page.storage, {
|
|
12
|
+
const converted = storageToMarkdown(page.storage, {
|
|
13
|
+
registry: opts.registry,
|
|
14
|
+
mode: opts.mode,
|
|
15
|
+
attachments: opts.attachments,
|
|
16
|
+
tables: opts.tables,
|
|
17
|
+
});
|
|
13
18
|
const markdownPath = opts.outFile ?? join(opts.outDir ?? `./${pageId}`, 'page.md');
|
|
14
19
|
mkdirSync(dirname(markdownPath), { recursive: true });
|
|
15
20
|
writeFileSync(markdownPath, converted.markdown);
|
|
@@ -28,6 +28,24 @@ export interface StorageToMarkdownOptions {
|
|
|
28
28
|
* 'readable' — чистый Markdown ценой оформления, без round-trip.
|
|
29
29
|
*/
|
|
30
30
|
mode?: 'faithful' | 'readable';
|
|
31
|
+
/**
|
|
32
|
+
* Как ссылаться на аттачи:
|
|
33
|
+
* - 'placeholder' (default) — {{img:name}} / {{file:name}} (для publish);
|
|
34
|
+
* - 'local' — картинки как HTML `<img src="attachments/name">` (с
|
|
35
|
+
* сохранением размеров), файлы как md-ссылки `[name](attachments/name)`.
|
|
36
|
+
* Годится для самодостаточного просмотра рядом со скачанными файлами.
|
|
37
|
+
* Включение 'local' подразумевает readable-обработку текста.
|
|
38
|
+
*/
|
|
39
|
+
attachments?: 'placeholder' | 'local';
|
|
40
|
+
/**
|
|
41
|
+
* Как выводить таблицы:
|
|
42
|
+
* - 'auto' (default) — GFM (readable) / round-trip (faithful);
|
|
43
|
+
* - 'records' — каждую строку разворачивать в запись
|
|
44
|
+
* «**Заголовок:** значение», записи через `---`. Подразумевает readable.
|
|
45
|
+
*/
|
|
46
|
+
tables?: 'auto' | 'records';
|
|
47
|
+
/** Префикс пути к локальным аттачам для attachments:'local'. Default: 'attachments/'. */
|
|
48
|
+
localPrefix?: string;
|
|
31
49
|
}
|
|
32
50
|
export interface StorageToMarkdownResult {
|
|
33
51
|
markdown: string;
|
|
@@ -28,7 +28,17 @@ import { compareStorage } from './canonical.js';
|
|
|
28
28
|
import { decodeEntities, elements, getAttr, hasNamespacedElements, parseStorage, serializeStorage, textContent, } from './xhtml.js';
|
|
29
29
|
/** Конвертирует storage-фрагмент страницы в Markdown. */
|
|
30
30
|
export function storageToMarkdown(storage, opts = {}) {
|
|
31
|
-
const
|
|
31
|
+
const local = opts.attachments === 'local';
|
|
32
|
+
const records = opts.tables === 'records';
|
|
33
|
+
// 'local' и 'records' — заведомо не round-trip, поэтому включают readable-
|
|
34
|
+
// обработку текста (сущности, спаны, переносы), даже без mode:'readable'.
|
|
35
|
+
const readable = opts.mode === 'readable' || local || records;
|
|
36
|
+
const conv = new Converter(opts.registry ?? defaultMacroRegistry, {
|
|
37
|
+
readable,
|
|
38
|
+
localFiles: local,
|
|
39
|
+
tablesAsRecords: records,
|
|
40
|
+
localPrefix: opts.localPrefix ?? 'attachments/',
|
|
41
|
+
});
|
|
32
42
|
const markdown = conv.blocksToMd(parseStorage(storage));
|
|
33
43
|
const attachmentRefs = new Set([...conv.images, ...conv.files]);
|
|
34
44
|
for (const m of storage.matchAll(/ri:filename="([^"]*)"/g))
|
|
@@ -67,13 +77,19 @@ const UNWRAP_BLOCK = new Set([
|
|
|
67
77
|
const SAFE_URL_RE = /^[A-Za-z0-9\-._~:/?#@!$&'*+,;=%]+$/;
|
|
68
78
|
class Converter {
|
|
69
79
|
registry;
|
|
70
|
-
readable;
|
|
71
80
|
images = new Set();
|
|
72
81
|
files = new Set();
|
|
73
82
|
stats = { markers: 0, fenced: 0, rawHtml: 0, lossy: 0 };
|
|
74
|
-
|
|
83
|
+
readable;
|
|
84
|
+
localFiles;
|
|
85
|
+
tablesAsRecords;
|
|
86
|
+
localPrefix;
|
|
87
|
+
constructor(registry, opts) {
|
|
75
88
|
this.registry = registry;
|
|
76
|
-
this.readable = readable;
|
|
89
|
+
this.readable = opts.readable;
|
|
90
|
+
this.localFiles = opts.localFiles;
|
|
91
|
+
this.tablesAsRecords = opts.tablesAsRecords;
|
|
92
|
+
this.localPrefix = opts.localPrefix;
|
|
77
93
|
}
|
|
78
94
|
// ── Блочный уровень ──────────────────────────────────────────────────
|
|
79
95
|
blocksToMd(nodes) {
|
|
@@ -126,11 +142,15 @@ class Converter {
|
|
|
126
142
|
return '---';
|
|
127
143
|
if (el.name === 'ul' || el.name === 'ol')
|
|
128
144
|
return this.listToMd(el);
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
145
|
+
if (el.name === 'table') {
|
|
146
|
+
// tables:'records' — любую таблицу разворачиваем в записи.
|
|
147
|
+
if (this.tablesAsRecords)
|
|
148
|
+
return this.recordsTable(el);
|
|
149
|
+
// Простую таблицу — в чистый GFM (оба режима). Сложную tableToMd
|
|
150
|
+
// отклоняет: faithful → сырой HTML, readable → readableFallback
|
|
151
|
+
// (lossy++ и разворот в GFM через readableTable).
|
|
133
152
|
return this.tableToMd(el);
|
|
153
|
+
}
|
|
134
154
|
if (el.name === 'ac:structured-macro')
|
|
135
155
|
return this.macroToMd(el);
|
|
136
156
|
if (el.name === 'ac:image' || el.name === 'ac:link') {
|
|
@@ -514,12 +534,12 @@ class Converter {
|
|
|
514
534
|
}
|
|
515
535
|
// ── Readable-таблицы: любая таблица → GFM ─────────────────────────────
|
|
516
536
|
/**
|
|
517
|
-
*
|
|
518
|
-
* несколько header-строк)
|
|
519
|
-
*
|
|
520
|
-
*
|
|
537
|
+
* Строит плотную сетку из произвольной таблицы (colspan/rowspan, блочные
|
|
538
|
+
* ячейки, несколько header-строк). Объединения раскрываются: содержимое —
|
|
539
|
+
* в верхней-левой клетке диапазона, остальные клетки пустые. Содержимое
|
|
540
|
+
* ячейки — flatten-строка БЕЗ экранирования пайпов (его делает вызывающий).
|
|
521
541
|
*/
|
|
522
|
-
|
|
542
|
+
buildTableGrid(table) {
|
|
523
543
|
const trs = [];
|
|
524
544
|
let caption = '';
|
|
525
545
|
for (const child of elements(table.children)) {
|
|
@@ -535,7 +555,6 @@ class Converter {
|
|
|
535
555
|
}
|
|
536
556
|
if (trs.length === 0)
|
|
537
557
|
throw new Unrepresentable();
|
|
538
|
-
// Плотная сетка с учётом colspan/rowspan.
|
|
539
558
|
const grid = [];
|
|
540
559
|
const aligns = [];
|
|
541
560
|
trs.forEach((tr, rowIdx) => {
|
|
@@ -549,10 +568,7 @@ class Converter {
|
|
|
549
568
|
col++;
|
|
550
569
|
const colspan = Math.max(1, Number(getAttr(cell, 'colspan') ?? '1') || 1);
|
|
551
570
|
const rowspan = Math.max(1, Number(getAttr(cell, 'rowspan') ?? '1') || 1);
|
|
552
|
-
|
|
553
|
-
// (текст + плейсхолдеры {{img:…|…}} + буллеты), поэтому cellFlatten
|
|
554
|
-
// сам их не трогает (inline с cell:false).
|
|
555
|
-
const content = tidyCell(this.cellFlatten(cell.children)).replace(/\|/g, '\\|');
|
|
571
|
+
const content = tidyCell(this.cellFlatten(cell.children));
|
|
556
572
|
for (let r = 0; r < rowspan; r++) {
|
|
557
573
|
for (let c = 0; c < colspan; c++) {
|
|
558
574
|
const rr = rowIdx + r;
|
|
@@ -580,12 +596,45 @@ class Converter {
|
|
|
580
596
|
}
|
|
581
597
|
while (aligns.length < width)
|
|
582
598
|
aligns.push('none');
|
|
599
|
+
return { grid, aligns, caption };
|
|
600
|
+
}
|
|
601
|
+
/** Разворачивает любую таблицу в GFM (сетка с заполнением объединений). */
|
|
602
|
+
readableTable(table) {
|
|
603
|
+
const { grid, aligns, caption } = this.buildTableGrid(table);
|
|
604
|
+
const esc = (s) => s.replace(/\|/g, '\\|');
|
|
583
605
|
const sep = aligns.map((a) => a === 'left' ? ':---' : a === 'right' ? '---:' : a === 'center' ? ':---:' : '---');
|
|
584
|
-
const rowMd = (cells) => `| ${cells.join(' | ')} |`;
|
|
585
|
-
const lines = [rowMd(grid[0]),
|
|
606
|
+
const rowMd = (cells) => `| ${cells.map(esc).join(' | ')} |`;
|
|
607
|
+
const lines = [rowMd(grid[0]), `| ${sep.join(' | ')} |`, ...grid.slice(1).map(rowMd)];
|
|
586
608
|
const table_ = lines.join('\n');
|
|
587
609
|
return caption !== '' ? `**${caption}**\n\n${table_}` : table_;
|
|
588
610
|
}
|
|
611
|
+
/**
|
|
612
|
+
* tables:'records' — каждую строку тела таблицы разворачивает в запись
|
|
613
|
+
* «**Заголовок:** значение» (пустые ячейки и колонки-филлеры пропускаются),
|
|
614
|
+
* записи разделяются `---`. Заголовки берутся из первой строки сетки.
|
|
615
|
+
*/
|
|
616
|
+
recordsTable(table) {
|
|
617
|
+
this.stats.lossy++;
|
|
618
|
+
const { grid, caption } = this.buildTableGrid(table);
|
|
619
|
+
const header = grid[0];
|
|
620
|
+
const records = [];
|
|
621
|
+
for (const row of grid.slice(1)) {
|
|
622
|
+
const lines = [];
|
|
623
|
+
row.forEach((val, i) => {
|
|
624
|
+
const key = (header[i] ?? '').trim();
|
|
625
|
+
// В записи <br> из ячейки → «; » (одна строка, без HTML).
|
|
626
|
+
const value = val.replace(/<br>/g, '; ').trim();
|
|
627
|
+
if (key !== '' && value !== '')
|
|
628
|
+
lines.push(`**${key}:** ${value}`);
|
|
629
|
+
});
|
|
630
|
+
if (lines.length > 0)
|
|
631
|
+
records.push(lines.join('\n'));
|
|
632
|
+
}
|
|
633
|
+
if (records.length === 0)
|
|
634
|
+
throw new Unrepresentable();
|
|
635
|
+
const body = records.join('\n\n---\n\n');
|
|
636
|
+
return caption !== '' ? `**${caption}**\n\n${body}` : body;
|
|
637
|
+
}
|
|
589
638
|
/**
|
|
590
639
|
* Сплющивает содержимое ячейки в одну строку: абзацы и пункты списков
|
|
591
640
|
* разделяются `<br>` (единственный HTML, идиоматичный для GFM-ячеек),
|
|
@@ -820,6 +869,30 @@ class Converter {
|
|
|
820
869
|
if (kids.length !== 1)
|
|
821
870
|
throw new Unrepresentable();
|
|
822
871
|
const ref = kids[0];
|
|
872
|
+
// attachments:'local' — картинка как HTML <img> (сохраняет размеры).
|
|
873
|
+
// ri:attachment → локальный путь; ri:url → внешний URL как есть.
|
|
874
|
+
if (this.localFiles && (ref.name === 'ri:attachment' || ref.name === 'ri:url')) {
|
|
875
|
+
let src;
|
|
876
|
+
let alt = '';
|
|
877
|
+
if (ref.name === 'ri:attachment') {
|
|
878
|
+
const filename = getAttr(ref, 'ri:filename') ?? '';
|
|
879
|
+
this.images.add(filename);
|
|
880
|
+
src = this.localPrefix + filename;
|
|
881
|
+
alt = filename;
|
|
882
|
+
}
|
|
883
|
+
else {
|
|
884
|
+
src = getAttr(ref, 'ri:value') ?? '';
|
|
885
|
+
}
|
|
886
|
+
const attrs = [['src', escapeXmlAttr(src)]];
|
|
887
|
+
for (const [k] of el.attrs) {
|
|
888
|
+
const plain = k.startsWith('ac:') ? k.slice(3) : k;
|
|
889
|
+
if (plain === 'height' || plain === 'width') {
|
|
890
|
+
attrs.push([plain, escapeXmlAttr(getAttr(el, k) ?? '')]);
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
attrs.push(['alt', escapeXmlAttr(alt)]);
|
|
894
|
+
return serializeStorage([{ kind: 'el', name: 'img', attrs, children: [], selfClosing: true }]);
|
|
895
|
+
}
|
|
823
896
|
if (ref.name === 'ri:url') {
|
|
824
897
|
if (ref.attrs.some(([k]) => k !== 'ri:value'))
|
|
825
898
|
throw new Unrepresentable();
|
|
@@ -894,8 +967,15 @@ class Converter {
|
|
|
894
967
|
throw new Unrepresentable();
|
|
895
968
|
}
|
|
896
969
|
const filename = getAttr(ref, 'ri:filename') ?? '';
|
|
897
|
-
badPlaceholderPart(filename);
|
|
898
970
|
this.files.add(filename);
|
|
971
|
+
// attachments:'local' — md-ссылка на локальный файл вместо {{file:}}.
|
|
972
|
+
if (this.localFiles) {
|
|
973
|
+
const label = (text ?? filename).replace(/[[\]]/g, '\\$&');
|
|
974
|
+
const path = this.localPrefix + filename;
|
|
975
|
+
const target = /[()\s]/.test(path) ? `<${path}>` : path;
|
|
976
|
+
return `[${label}](${target})`;
|
|
977
|
+
}
|
|
978
|
+
badPlaceholderPart(filename);
|
|
899
979
|
return `{{file:${filename}${textAttr}}}`;
|
|
900
980
|
}
|
|
901
981
|
throw new Unrepresentable();
|
package/package.json
CHANGED