@wonseok-han/confluence-sync 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 +138 -6
- package/dist/anchors.js +157 -0
- package/dist/confluence.js +7 -2
- package/dist/convert.js +257 -0
- package/dist/docs.js +137 -12
- package/dist/help.js +38 -4
- package/dist/html2md.js +3 -1
- package/dist/markdown.js +81 -14
- package/dist/obsidian.js +100 -0
- package/dist/pull.js +149 -15
- package/dist/repair.js +145 -0
- package/dist/sync.js +61 -12
- package/package.json +3 -2
package/dist/pull.js
CHANGED
|
@@ -2,16 +2,27 @@
|
|
|
2
2
|
* `confluence-sync pull <pageId|url> [--out <dir>] [--children]`
|
|
3
3
|
* Confluence 페이지를 읽어 Markdown(.md) 으로 생성한다(역방향).
|
|
4
4
|
* 자식이 있는 페이지는 push 관례를 역으로 적용해 <slug>/README.md 폴더로 펼친다.
|
|
5
|
-
* 첨부는
|
|
5
|
+
* 첨부는 attachments/<문서명>/ 하위에 내려받고 이미지 링크를 그 경로로 재작성한다.
|
|
6
|
+
*
|
|
7
|
+
* 함께 받은 페이지끼리의 내부 링크는 마지막에 상대 .md 경로로 이어 붙인다(Obsidian 그래프·백링크가 살아난다).
|
|
8
|
+
* 각 문서 머리에 pageId 를 담은 frontmatter 를 남겨, 매핑 파일 없이도 push 가 원본 페이지를 다시 찾아간다.
|
|
6
9
|
*/
|
|
7
|
-
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
8
|
-
import { resolve, join, dirname, basename } from 'node:path';
|
|
10
|
+
import { mkdirSync, writeFileSync, readFileSync } from 'node:fs';
|
|
11
|
+
import { resolve, join, dirname, basename, relative } from 'node:path';
|
|
9
12
|
import { readEnv, requireEnv } from './config.js';
|
|
10
13
|
import { createClient } from './confluence.js';
|
|
11
14
|
import { htmlToMarkdown, codeLanguagesFromStorage } from './html2md.js';
|
|
15
|
+
import { buildFrontmatter, splitFrontmatter } from './obsidian.js';
|
|
16
|
+
import { collectHeadings, matchConfluenceAnchor } from './anchors.js';
|
|
12
17
|
import { cyan, dim, green, red, yellow } from './colors.js';
|
|
13
18
|
// 첨부 이미지는 항상 <md위치>/attachments/<문서명>/ 하위에 저장한다.
|
|
14
19
|
const ASSETS_DIR = 'attachments';
|
|
20
|
+
/** 마크다운 링크 중 Confluence 페이지를 가리키는 것: [text](https://.../pages/<id>/...) */
|
|
21
|
+
const PAGE_LINK = /\[([^\]]*)\]\(<?(https?:\/\/[^\s)<>]*?\/pages\/(\d+)[^\s)<>]*?)>?\)/g;
|
|
22
|
+
/** 같은 문서 안의 앵커: [text](#앵커) */
|
|
23
|
+
const SELF_ANCHOR = /\[([^\]]*)\]\(#([^)\s]+)\)/g;
|
|
24
|
+
/** 다른 페이지의 섹션: [text](https://.../pages/<id>/제목#앵커) — 앵커만 따로 잡는다 */
|
|
25
|
+
const PAGE_ANCHOR = /(\[[^\]]*\]\(<?https?:\/\/[^\s)<>]*?\/pages\/(\d+)[^\s)<>#]*)#([^\s)<>]+?)(>?\))/g;
|
|
15
26
|
function optVal(argv, name) {
|
|
16
27
|
const i = argv.indexOf(name);
|
|
17
28
|
return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined;
|
|
@@ -28,8 +39,11 @@ function parseContentId(input) {
|
|
|
28
39
|
function slug(title) {
|
|
29
40
|
return title.replace(/[\/\\:*?"<>|]/g, '-').replace(/\s+/g, ' ').trim() || 'untitled';
|
|
30
41
|
}
|
|
42
|
+
/** 공백이 있으면 <...> 로 감싼 마크다운 링크 목적지 */
|
|
43
|
+
const dest = (p) => (/\s/.test(p) ? `<${p}>` : p);
|
|
31
44
|
/** 페이지 1건을 .md 로 생성(자식 페이지/폴더가 있으면 <slug>/README.md 폴더로). */
|
|
32
|
-
async function pullPage(
|
|
45
|
+
async function pullPage(ctx, node, destDir) {
|
|
46
|
+
const { client, withChildren } = ctx;
|
|
33
47
|
const { id, title, html, storage } = node;
|
|
34
48
|
// 페이지도 하위 페이지 + 하위 폴더를 모두 가질 수 있다(폴더가 페이지 밑에 올 수 있음).
|
|
35
49
|
const childFolders = withChildren ? await client.getChildFolders(id) : [];
|
|
@@ -66,19 +80,33 @@ async function pullPage(client, node, destDir, withChildren) {
|
|
|
66
80
|
}
|
|
67
81
|
}
|
|
68
82
|
}
|
|
69
|
-
|
|
83
|
+
// frontmatter 의 pageId 는 push 왕복의 앵커다(매핑 파일이 없어도 원본 페이지를 찾아간다).
|
|
84
|
+
// 취향 옵션이 아니라 중복 페이지 생성을 막는 장치이므로 항상 쓴다. Obsidian 은 이를 속성으로 보여준다.
|
|
85
|
+
const fm = buildFrontmatter({
|
|
86
|
+
title,
|
|
87
|
+
pageId: id,
|
|
88
|
+
spaceKey: ctx.spaceKey,
|
|
89
|
+
source: node.url ?? '',
|
|
90
|
+
updated: node.updated ?? '',
|
|
91
|
+
});
|
|
92
|
+
// 제목은 frontmatter 의 title 하나로 충분하다. 본문에 `# 제목` 을 또 넣으면
|
|
93
|
+
// 제목을 따로 표시하는 뷰어(Obsidian 등)에서 두 번 보인다. push 도 title 속성을 먼저 읽는다.
|
|
94
|
+
writeFileSync(filePath, fm ? `${fm}${body}` : `# ${title}\n\n${body}`);
|
|
95
|
+
ctx.written.push(filePath);
|
|
96
|
+
ctx.pathById.set(id, filePath);
|
|
70
97
|
const imgNote = referenced.size ? `, 이미지 ${referenced.size}` : '';
|
|
71
98
|
console.log(` ${green('+ 생성')} ${filePath} ${dim(`(#${id}${imgNote})`)}`);
|
|
72
99
|
const counts = { pages: 1, folders: 0 };
|
|
73
100
|
for (const f of childFolders)
|
|
74
|
-
add(counts, await pullNode(
|
|
101
|
+
add(counts, await pullNode(ctx, f.id, folder));
|
|
75
102
|
for (const c of childPages)
|
|
76
|
-
add(counts, await pullNode(
|
|
103
|
+
add(counts, await pullNode(ctx, c.id, folder));
|
|
77
104
|
return counts;
|
|
78
105
|
}
|
|
79
106
|
function add(acc, c) { acc.pages += c.pages; acc.folders += c.folders; }
|
|
80
107
|
/** 노드(페이지/폴더)를 재귀적으로 가져온다. 폴더는 디렉토리(본문 없음)로 만든다. 노드 하나 실패는 스킵. */
|
|
81
|
-
async function pullNode(
|
|
108
|
+
async function pullNode(ctx, id, destDir) {
|
|
109
|
+
const { client, withChildren } = ctx;
|
|
82
110
|
try {
|
|
83
111
|
const node = await client.getNode(id);
|
|
84
112
|
if (node.type === 'folder') {
|
|
@@ -88,9 +116,9 @@ async function pullNode(client, id, destDir, withChildren) {
|
|
|
88
116
|
const counts = { pages: 0, folders: 1 };
|
|
89
117
|
if (withChildren) {
|
|
90
118
|
for (const f of await client.getChildFolders(id))
|
|
91
|
-
add(counts, await pullNode(
|
|
119
|
+
add(counts, await pullNode(ctx, f.id, dir));
|
|
92
120
|
for (const p of await client.getChildPages(id))
|
|
93
|
-
add(counts, await pullNode(
|
|
121
|
+
add(counts, await pullNode(ctx, p.id, dir));
|
|
94
122
|
}
|
|
95
123
|
else {
|
|
96
124
|
console.log(dim(' (하위 내용은 --children 으로 가져옵니다)'));
|
|
@@ -98,13 +126,104 @@ async function pullNode(client, id, destDir, withChildren) {
|
|
|
98
126
|
return counts;
|
|
99
127
|
}
|
|
100
128
|
// page: getNode 결과(html + storage) 그대로 사용
|
|
101
|
-
return await pullPage(
|
|
129
|
+
return await pullPage(ctx, node, destDir);
|
|
102
130
|
}
|
|
103
131
|
catch (e) {
|
|
104
132
|
console.error(red(` ✗ 실패 #${id}`) + `\n${e.message}`);
|
|
105
133
|
return { pages: 0, folders: 0 };
|
|
106
134
|
}
|
|
107
135
|
}
|
|
136
|
+
function readDocInfo(file) {
|
|
137
|
+
const { data, body } = splitFrontmatter(readFileSync(file, 'utf8'));
|
|
138
|
+
const headings = collectHeadings(body);
|
|
139
|
+
return {
|
|
140
|
+
title: data.title ?? '',
|
|
141
|
+
headings,
|
|
142
|
+
textBySlug: new Map(headings.map((h) => [h.slug, h.text])),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Confluence 가 만든 헤딩 앵커를 마크다운 슬러그로 바꾼다.
|
|
147
|
+
*
|
|
148
|
+
* Confluence 의 앵커 id 는 `<페이지제목><헤딩텍스트>`(공백 제거) 라서 Obsidian·GitHub 에서는
|
|
149
|
+
* 아무 데도 가리키지 못한다. 실제 헤딩을 찾아 그 문서의 슬러그로 바꿔 줘야 링크가 산다.
|
|
150
|
+
* relinkPass 보다 **먼저** 돈다 — 아직 링크가 절대 URL 이라 어느 페이지인지 id 로 알 수 있기 때문이다.
|
|
151
|
+
*/
|
|
152
|
+
export function anchorPass(written, pathById) {
|
|
153
|
+
const info = new Map();
|
|
154
|
+
for (const f of written)
|
|
155
|
+
info.set(f, readDocInfo(f));
|
|
156
|
+
let count = 0;
|
|
157
|
+
for (const file of written) {
|
|
158
|
+
const self = info.get(file);
|
|
159
|
+
const before = readFileSync(file, 'utf8');
|
|
160
|
+
// 우리가 올린 페이지는 Anchor 매크로 이름이 이미 마크다운 슬러그라 그대로 두면 된다.
|
|
161
|
+
// Confluence 에서 직접 쓴 페이지만 헤딩을 찾아 맞춘다.
|
|
162
|
+
const toSlug = (frag, d) => {
|
|
163
|
+
if (d.textBySlug.has(frag))
|
|
164
|
+
return null; // 이미 올바른 슬러그
|
|
165
|
+
return matchConfluenceAnchor(frag, d.headings, d.title)?.slug ?? null;
|
|
166
|
+
};
|
|
167
|
+
let after = before.replace(SELF_ANCHOR, (whole, label, frag) => {
|
|
168
|
+
const slug = toSlug(frag, self);
|
|
169
|
+
if (!slug)
|
|
170
|
+
return whole; // 이미 맞거나 못 찾음 → 손대지 않는다(엉뚱한 곳으로 보내지 않기 위해)
|
|
171
|
+
count++;
|
|
172
|
+
return `[${label}](#${slug})`;
|
|
173
|
+
});
|
|
174
|
+
after = after.replace(PAGE_ANCHOR, (whole, head, id, frag, tail) => {
|
|
175
|
+
const target = pathById.get(id);
|
|
176
|
+
const ti = target && info.get(target);
|
|
177
|
+
if (!ti)
|
|
178
|
+
return whole; // 이번에 안 받은 페이지 → 원본 URL 이 살아 있어야 한다
|
|
179
|
+
const slug = toSlug(frag, ti);
|
|
180
|
+
if (!slug)
|
|
181
|
+
return whole;
|
|
182
|
+
count++;
|
|
183
|
+
return `${head}#${slug}${tail}`;
|
|
184
|
+
});
|
|
185
|
+
if (after !== before)
|
|
186
|
+
writeFileSync(file, after);
|
|
187
|
+
}
|
|
188
|
+
return { count, info };
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* 함께 받은 페이지를 가리키는 절대 Confluence URL 을 상대 .md 링크(또는 [[wikilink]])로 바꾼다.
|
|
192
|
+
* 이번에 받지 않은 페이지의 링크는 절대 URL 그대로 둔다(깨진 링크를 만들지 않는다).
|
|
193
|
+
* 반환값은 재작성한 링크 수.
|
|
194
|
+
*/
|
|
195
|
+
export function relinkPass(written, pathById, wikilinks, info) {
|
|
196
|
+
const noExt = (p) => basename(p).replace(/\.md$/i, '');
|
|
197
|
+
// wikilink 는 vault 어디서든 "이름"으로 찾으므로, 파일명이 유일할 때만 안전하게 쓸 수 있다.
|
|
198
|
+
const nameCount = new Map();
|
|
199
|
+
for (const p of pathById.values())
|
|
200
|
+
nameCount.set(noExt(p), (nameCount.get(noExt(p)) ?? 0) + 1);
|
|
201
|
+
let count = 0;
|
|
202
|
+
for (const file of written) {
|
|
203
|
+
const before = readFileSync(file, 'utf8');
|
|
204
|
+
const after = before.replace(PAGE_LINK, (whole, text, url, id) => {
|
|
205
|
+
const target = pathById.get(id);
|
|
206
|
+
if (!target || target === file)
|
|
207
|
+
return whole; // 못 받은 페이지·자기 자신 → 원본 URL 유지
|
|
208
|
+
count++;
|
|
209
|
+
const name = noExt(target);
|
|
210
|
+
const label = (text || name).trim();
|
|
211
|
+
const slug = url.includes('#') ? url.split('#').slice(1).join('#') : '';
|
|
212
|
+
if (wikilinks && nameCount.get(name) === 1) {
|
|
213
|
+
// Obsidian 은 슬러그가 아니라 헤딩 텍스트로 섹션을 가리킨다: [[문서#헤딩 텍스트]]
|
|
214
|
+
const heading = slug ? info?.get(target)?.textBySlug.get(slug) : undefined;
|
|
215
|
+
if (!heading)
|
|
216
|
+
return label === name ? `[[${name}]]` : `[[${name}|${label}]]`;
|
|
217
|
+
return `[[${name}#${heading}|${label}]]`;
|
|
218
|
+
}
|
|
219
|
+
const rel = relative(dirname(file), target).split('\\').join('/');
|
|
220
|
+
return `[${label}](${dest(rel + (slug ? `#${slug}` : ''))})`;
|
|
221
|
+
});
|
|
222
|
+
if (after !== before)
|
|
223
|
+
writeFileSync(file, after);
|
|
224
|
+
}
|
|
225
|
+
return count;
|
|
226
|
+
}
|
|
108
227
|
export async function runPull(argv) {
|
|
109
228
|
const withChildren = argv.includes('--children');
|
|
110
229
|
const wholeSpace = argv.includes('--space');
|
|
@@ -112,6 +231,22 @@ export async function runPull(argv) {
|
|
|
112
231
|
const env = readEnv();
|
|
113
232
|
requireEnv(env);
|
|
114
233
|
const client = createClient({ baseUrl: env.baseUrl, email: env.email, token: env.token }, { force: false, verify: false });
|
|
234
|
+
const ctx = {
|
|
235
|
+
client,
|
|
236
|
+
withChildren,
|
|
237
|
+
obsidian: argv.includes('--obsidian'),
|
|
238
|
+
spaceKey: env.spaceKey,
|
|
239
|
+
written: [],
|
|
240
|
+
pathById: new Map(),
|
|
241
|
+
};
|
|
242
|
+
const done = (c) => {
|
|
243
|
+
// 앵커를 먼저 슬러그로 바꾼 뒤 링크를 상대경로로 옮긴다(순서가 바뀌면 어느 페이지의 헤딩인지 알 수 없다).
|
|
244
|
+
const { count: anchors, info } = anchorPass(ctx.written, ctx.pathById);
|
|
245
|
+
const relinked = relinkPass(ctx.written, ctx.pathById, ctx.obsidian, info);
|
|
246
|
+
console.log(`\n${green('완료')} 페이지 ${c.pages}개${c.folders ? `, 폴더 ${c.folders}개` : ''} 생성` +
|
|
247
|
+
(relinked ? ` ${cyan(`내부링크 ${relinked}개 연결`)}` : '') +
|
|
248
|
+
(anchors ? ` ${cyan(`섹션링크 ${anchors}개 복원`)}` : ''));
|
|
249
|
+
};
|
|
115
250
|
// --space: 스페이스 홈페이지(콘텐츠 트리 루트)부터 전체를 재귀적으로 가져옴
|
|
116
251
|
if (wholeSpace) {
|
|
117
252
|
const { homepageId } = await client.getSpaceInfo(env.spaceKey);
|
|
@@ -120,8 +255,8 @@ export async function runPull(argv) {
|
|
|
120
255
|
process.exit(1);
|
|
121
256
|
}
|
|
122
257
|
console.log(`${dim('pull:')} space ${cyan(env.spaceKey)} ${dim('→')} ${cyan(outDir)} ${dim('(전체)')}`);
|
|
123
|
-
|
|
124
|
-
|
|
258
|
+
ctx.withChildren = true;
|
|
259
|
+
done(await pullNode(ctx, homepageId, outDir));
|
|
125
260
|
return;
|
|
126
261
|
}
|
|
127
262
|
// 위치 인자: ['pull', <pageId|url>, ...]
|
|
@@ -146,6 +281,5 @@ export async function runPull(argv) {
|
|
|
146
281
|
process.exit(1);
|
|
147
282
|
}
|
|
148
283
|
console.log(`${dim('pull:')} #${contentId} ${dim('→')} ${cyan(outDir)}${withChildren ? dim(' (+children)') : ''}`);
|
|
149
|
-
|
|
150
|
-
console.log(`\n${green('완료')} 페이지 ${pages}개${folders ? `, 폴더 ${folders}개` : ''} 생성`);
|
|
284
|
+
done(await pullNode(ctx, contentId, outDir));
|
|
151
285
|
}
|
package/dist/repair.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 이미 내려받아 둔 .md 를 고친다(사후 보정).
|
|
3
|
+
*
|
|
4
|
+
* html2md 가 개선되기 전에 pull 한 문서에는 변환기 결함의 흔적이 남아 있다.
|
|
5
|
+
* 스페이스를 통째로 다시 받지 않고도 같은 결과를 얻도록, 그 흔적들만 골라 되돌린다.
|
|
6
|
+
* 파일을 덮어쓰므로 **확실한 것만** 고치고, 애매하면 손대지 않는다.
|
|
7
|
+
*/
|
|
8
|
+
import { relaxEscapes } from './html2md.js';
|
|
9
|
+
import { splitFrontmatter } from './obsidian.js';
|
|
10
|
+
const emptyStats = () => ({ cssJunk: 0, codeLang: 0, trailingWs: 0, escapes: 0, tightList: 0, dupTitle: 0 });
|
|
11
|
+
export const totalFixes = (s) => Object.values(s).reduce((a, b) => a + b, 0);
|
|
12
|
+
const FENCE = /^\s*(?:```|~~~)/;
|
|
13
|
+
/** 리스트 항목이면 종류('ul'|'ol'), 아니면 null. 종류가 다르면 별개의 리스트다. */
|
|
14
|
+
function listKind(line) {
|
|
15
|
+
if (/^[ \t]*[-*+]\s/.test(line))
|
|
16
|
+
return 'ul';
|
|
17
|
+
if (/^[ \t]*\d+[.)]\s/.test(line))
|
|
18
|
+
return 'ol';
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Confluence export_view 는 색상 텍스트용 인라인 <style> 을 본문에(제목 안까지) 끼워 넣는데,
|
|
23
|
+
* 예전 변환기는 그 CSS 텍스트를 그대로 뱉었다. `[data-colorid=xxx]{color:#fff;}` 같은 잔해다.
|
|
24
|
+
* (turndown 이 이스케이프해 `\[data-colorid=...\]` 형태로 남아 있기도 하다.)
|
|
25
|
+
*/
|
|
26
|
+
function stripCssJunk(line) {
|
|
27
|
+
return line
|
|
28
|
+
.replace(/\\?\[data-colorid=[^\]]*\\?\]\s*\{[^}]*\}/g, '') // 선택자 + 규칙 블록
|
|
29
|
+
.replace(/\\?\[data-colorid=[^\]]*\\?\]/g, '') // 선택자만 남은 것
|
|
30
|
+
.replace(/\{\s*color\s*:\s*[^}]*\}/g, ''); // 규칙만 남은 것
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* 실제 Java 코드로 보이는가?
|
|
34
|
+
* Confluence 는 언어를 지정하지 않은 code 매크로를 export 할 때 `brush: java` 를 기본으로 붙인다.
|
|
35
|
+
* 그래서 예전 변환기는 거의 모든 코드블록을 ```java 로 찍었다. 진짜 Java 만 남기고 plaintext 로 되돌린다.
|
|
36
|
+
*/
|
|
37
|
+
function looksLikeJava(code) {
|
|
38
|
+
return (/^\s*(package|import)\s+(java|javax|com|org)\b/m.test(code) ||
|
|
39
|
+
/\b(public|private|protected)\s+(static\s+)?(final\s+)?(class|interface|enum|void)\b/.test(code) ||
|
|
40
|
+
/\bSystem\.(out|err)\.print/.test(code) ||
|
|
41
|
+
/@Override\b/.test(code));
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* 리스트 항목 사이의 빈 줄을 없앤다(Confluence 의 <li><p> 때문에 생긴 loose list).
|
|
45
|
+
* 앞뒤가 **같은 종류**의 항목일 때만 붙인다 — 번호 목록과 글머리 목록이 잇달아 나오는 경우
|
|
46
|
+
* 그 사이의 빈 줄은 두 리스트를 갈라놓는 구분자라 지우면 하나로 합쳐져 버린다.
|
|
47
|
+
*/
|
|
48
|
+
function tightenLists(lines, stats) {
|
|
49
|
+
const out = [];
|
|
50
|
+
let inFence = false;
|
|
51
|
+
for (let i = 0; i < lines.length; i++) {
|
|
52
|
+
const line = lines[i];
|
|
53
|
+
if (FENCE.test(line))
|
|
54
|
+
inFence = !inFence;
|
|
55
|
+
if (!inFence && line.trim() === '' && out.length) {
|
|
56
|
+
const prev = listKind(out[out.length - 1]);
|
|
57
|
+
const next = listKind(lines[i + 1] ?? '');
|
|
58
|
+
if (prev && prev === next) {
|
|
59
|
+
stats.tightList++;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
out.push(line);
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* frontmatter 에 title 이 있는데 본문 첫 헤딩이 같은 제목이면 그 H1 을 지운다.
|
|
69
|
+
* 제목을 따로 표시하는 뷰어(Obsidian 등)에서 제목이 두 번 보이기 때문이다.
|
|
70
|
+
* 제목이 다르면(본문이 실제로 다른 이야기로 시작하면) 손대지 않는다.
|
|
71
|
+
*/
|
|
72
|
+
function dropDuplicateTitle(src, stats) {
|
|
73
|
+
const { data, body } = splitFrontmatter(src);
|
|
74
|
+
if (!data.title)
|
|
75
|
+
return src;
|
|
76
|
+
const lines = body.split('\n');
|
|
77
|
+
const i = lines.findIndex((l) => l.trim() !== '');
|
|
78
|
+
if (i === -1)
|
|
79
|
+
return src;
|
|
80
|
+
const h1 = lines[i].match(/^#\s+(.*)$/);
|
|
81
|
+
if (!h1 || h1[1].trim() !== data.title.trim())
|
|
82
|
+
return src;
|
|
83
|
+
lines.splice(i, 1);
|
|
84
|
+
while (lines[i]?.trim() === '')
|
|
85
|
+
lines.splice(i, 1); // H1 뒤에 붙어 있던 빈 줄까지
|
|
86
|
+
stats.dupTitle++;
|
|
87
|
+
const fmBlock = src.slice(0, src.length - body.length);
|
|
88
|
+
return fmBlock + lines.join('\n');
|
|
89
|
+
}
|
|
90
|
+
/** 문서 하나를 보정한다. 반환된 text 가 원본과 같으면 고칠 것이 없었다는 뜻. */
|
|
91
|
+
export function repairMarkdown(input) {
|
|
92
|
+
const stats = emptyStats();
|
|
93
|
+
const src = dropDuplicateTitle(input, stats);
|
|
94
|
+
const lines = src.split('\n');
|
|
95
|
+
const out = [];
|
|
96
|
+
let inFence = false;
|
|
97
|
+
let fenceStart = -1; // out 기준, 현재 코드블록 여는 줄의 인덱스
|
|
98
|
+
for (const raw of lines) {
|
|
99
|
+
if (FENCE.test(raw)) {
|
|
100
|
+
if (!inFence) {
|
|
101
|
+
inFence = true;
|
|
102
|
+
fenceStart = out.length;
|
|
103
|
+
out.push(raw);
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
// 코드블록 종료 — 모아둔 내용으로 언어를 판정한다
|
|
107
|
+
inFence = false;
|
|
108
|
+
const opener = out[fenceStart];
|
|
109
|
+
if (/^\s*(?:```|~~~)java\s*$/.test(opener)) {
|
|
110
|
+
const code = out.slice(fenceStart + 1).join('\n');
|
|
111
|
+
if (!looksLikeJava(code)) {
|
|
112
|
+
out[fenceStart] = opener.replace(/java\s*$/, 'plaintext');
|
|
113
|
+
stats.codeLang++;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
out.push(raw);
|
|
117
|
+
}
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (inFence) {
|
|
121
|
+
// 코드 안에서는 줄 끝 공백만 정리한다(코드 의미는 불변). 이스케이프·CSS 는 손대지 않는다.
|
|
122
|
+
const trimmed = raw.replace(/[ \t]+$/, '');
|
|
123
|
+
if (trimmed !== raw)
|
|
124
|
+
stats.trailingWs++;
|
|
125
|
+
out.push(trimmed);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
let line = raw;
|
|
129
|
+
const noCss = stripCssJunk(raw);
|
|
130
|
+
if (noCss !== raw) {
|
|
131
|
+
stats.cssJunk++;
|
|
132
|
+
// 잔해를 걷어낸 자리에 공백이 남는다: "# {제거됨} 제목" → "# 제목"
|
|
133
|
+
line = noCss.replace(/[ \t]{2,}/g, ' ').replace(/[ \t]+$/, '');
|
|
134
|
+
}
|
|
135
|
+
out.push(line);
|
|
136
|
+
}
|
|
137
|
+
// 이스케이프 완화는 코드블록을 알아서 건너뛴다(html2md 와 같은 규칙을 재사용)
|
|
138
|
+
let text = relaxEscapes(tightenLists(out, stats).join('\n'));
|
|
139
|
+
// relaxEscapes 가 어디를 바꿨는지 역추적하긴 어려우니, 사라진 백슬래시 개수로 센다
|
|
140
|
+
const backslashes = (s) => (s.match(/\\/g) || []).length;
|
|
141
|
+
stats.escapes = Math.max(0, backslashes(src) - backslashes(text));
|
|
142
|
+
// CSS 잔해를 걷어내면 빈 껍데기 제목(`#` 만 남은 줄)이 생길 수 있다
|
|
143
|
+
text = text.replace(/^#{1,6}[ \t]*$/gm, '');
|
|
144
|
+
return { text, stats };
|
|
145
|
+
}
|
package/dist/sync.js
CHANGED
|
@@ -14,15 +14,16 @@
|
|
|
14
14
|
* 세부 로직은 ./markdown ./docs ./mapping ./confluence ./config ./init ./help 모듈에 있다.
|
|
15
15
|
*/
|
|
16
16
|
import 'dotenv/config';
|
|
17
|
-
import { existsSync,
|
|
17
|
+
import { existsSync, statSync } from 'node:fs';
|
|
18
18
|
import { resolve, relative } from 'node:path';
|
|
19
19
|
import { readEnv, requireEnv } from './config.js';
|
|
20
|
-
import { toStorage,
|
|
21
|
-
import { collectMarkdown,
|
|
20
|
+
import { toStorage, docHash } from './markdown.js';
|
|
21
|
+
import { collectMarkdown, collectAssets, buildDocIndex, resolveAnchorTargets, buildFolderIndex, buildVault, vaultResolver, readDoc, parentKeyOf, containerKeyOf, sortForSync, resolveSelection, withParents, } from './docs.js';
|
|
22
22
|
import { loadMapping, saveMapping } from './mapping.js';
|
|
23
23
|
import { createClient } from './confluence.js';
|
|
24
24
|
import { runInit } from './init.js';
|
|
25
25
|
import { runPull } from './pull.js';
|
|
26
|
+
import { runConvert } from './convert.js';
|
|
26
27
|
import { buildIgnorer } from './ignore.js';
|
|
27
28
|
import { printHelp, readPkgVersion } from './help.js';
|
|
28
29
|
import { bold, dim, red, green, yellow, magenta, cyan, gray } from './colors.js';
|
|
@@ -51,7 +52,8 @@ const EXCLUDES = optVals('--exclude');
|
|
|
51
52
|
// 위치 인자(선택 경로/서브커맨드): 플래그·옵션값 제외
|
|
52
53
|
const positionals = [];
|
|
53
54
|
for (let i = 0; i < argv.length; i++) {
|
|
54
|
-
if (argv[i] === '--base' || argv[i] === '--mapping' || argv[i] === '--exclude'
|
|
55
|
+
if (argv[i] === '--base' || argv[i] === '--mapping' || argv[i] === '--exclude'
|
|
56
|
+
|| argv[i] === '--to' || argv[i] === '--out') {
|
|
55
57
|
i++;
|
|
56
58
|
continue;
|
|
57
59
|
}
|
|
@@ -76,6 +78,10 @@ if (positionals[0] === 'pull') {
|
|
|
76
78
|
await runPull(argv);
|
|
77
79
|
process.exit(0);
|
|
78
80
|
}
|
|
81
|
+
if (positionals[0] === 'convert') {
|
|
82
|
+
await runConvert(argv);
|
|
83
|
+
process.exit(0);
|
|
84
|
+
}
|
|
79
85
|
const baseInput = optVal('--base') ?? process.env.CONFLUENCE_SYNC_BASE;
|
|
80
86
|
if (!baseInput) {
|
|
81
87
|
console.error(red('✗ 동기화 루트가 지정되지 않았습니다.') + '\n' +
|
|
@@ -102,7 +108,20 @@ async function main() {
|
|
|
102
108
|
const allRel = collected.filter((r) => !ignorer.ignores(r)); // 제외된 문서는 모든 단계에서 빠짐
|
|
103
109
|
const ignoredCount = collected.length - allRel.length;
|
|
104
110
|
const folderIndex = buildFolderIndex(allRel);
|
|
105
|
-
|
|
111
|
+
// 링크·섹션 링크 변환은 항상 전체 기준(선택 동기화여도 대상 문서의 제목·헤딩을 알아야 한다)
|
|
112
|
+
const { titles: titleIndex, anchors: anchorIndex, docs } = buildDocIndex(allRel, BASE_DIR);
|
|
113
|
+
// Obsidian [[wikilink]] 해석용 이름 인덱스. 제외된 문서는 링크 대상에서도 빠진다.
|
|
114
|
+
const vault = buildVault(allRel, collectAssets(BASE_DIR).map((f) => relative(BASE_DIR, f)));
|
|
115
|
+
// 누가 어느 헤딩을 가리키는지 먼저 모은다 — 그 헤딩에만 Anchor 매크로를 심는다.
|
|
116
|
+
// 선택 동기화여도 트리 전체를 봐야 한다(다른 문서가 이 문서의 섹션을 가리킬 수 있으므로).
|
|
117
|
+
const anchorTargets = resolveAnchorTargets(docs, vault);
|
|
118
|
+
const render = (rel, body, title) => toStorage(body, rel, titleIndex, BASE_DIR, {
|
|
119
|
+
resolveLink: vaultResolver(rel, vault),
|
|
120
|
+
anchorIndex,
|
|
121
|
+
title,
|
|
122
|
+
anchorNames: anchorTargets[rel],
|
|
123
|
+
bodySlugs: docs[rel]?.bodySlugs,
|
|
124
|
+
});
|
|
106
125
|
const selected = positionals.length
|
|
107
126
|
? withParents(resolveSelection(allRel, positionals, BASE_DIR), folderIndex)
|
|
108
127
|
: allRel;
|
|
@@ -148,13 +167,21 @@ async function main() {
|
|
|
148
167
|
const fex = mapping[`${d}/`];
|
|
149
168
|
console.log(` [${fex?.pageId ? gray('폴더') : green('폴더+')}] 📁 ${d}/`);
|
|
150
169
|
}
|
|
151
|
-
const { title, body } =
|
|
152
|
-
const r =
|
|
170
|
+
const { title, body, fm } = readDoc(BASE_DIR, rel);
|
|
171
|
+
const r = render(rel, body, title);
|
|
153
172
|
const hash = docHash(title, r);
|
|
154
173
|
const ex = mapping[rel];
|
|
155
|
-
|
|
174
|
+
// 매핑엔 없지만 frontmatter 에 pageId 가 있으면 신규 생성이 아니라 기존 페이지에 연결된다
|
|
175
|
+
const status = !ex?.pageId
|
|
176
|
+
? (fm.pageId ? cyan('연결') : green('신규'))
|
|
177
|
+
: ex.hash === hash ? gray('동일') : yellow('변경');
|
|
156
178
|
const pk = parentKeyOf(rel, folderIndex);
|
|
157
|
-
|
|
179
|
+
const anchorNote = r.anchorLinks || r.anchorsPlaced
|
|
180
|
+
? `, 섹션링크: ${r.anchorLinks}, 앵커: ${r.anchorsPlaced}` : '';
|
|
181
|
+
console.log(` [${status}] ${rel} ${dim('→')} ${cyan(`"${title}"`)} ${dim(`(부모: ${pk ?? 'ROOT'}, 내부링크: ${r.internalLinks}${anchorNote}, 이미지: ${r.images.length})`)}`);
|
|
182
|
+
if (r.deadAnchors.length) {
|
|
183
|
+
console.error(yellow(` ⚠ 대응 헤딩 없는 앵커 ${r.deadAnchors.length}개: `) + dim(r.deadAnchors.slice(0, 3).join(' ')));
|
|
184
|
+
}
|
|
158
185
|
}
|
|
159
186
|
console.log('\n' + dim('--dry-run: 실제 호출 없음.'));
|
|
160
187
|
return;
|
|
@@ -166,7 +193,7 @@ async function main() {
|
|
|
166
193
|
saveMapping(MAPPING_PATH, {});
|
|
167
194
|
}
|
|
168
195
|
const work = REBUILD ? {} : mapping;
|
|
169
|
-
let created = 0, updated = 0, skipped = 0, recreated = 0, relinked = 0, foldersMade = 0;
|
|
196
|
+
let created = 0, updated = 0, skipped = 0, recreated = 0, relinked = 0, foldersMade = 0, linked = 0;
|
|
170
197
|
const rendered = new Map();
|
|
171
198
|
const recreatedTitles = new Set(); // 이번에 재생성된 문서의 제목
|
|
172
199
|
const touched = new Set(); // 이번에 발행(생성/갱신/재생성)된 문서
|
|
@@ -216,16 +243,37 @@ async function main() {
|
|
|
216
243
|
console.error(yellow(` ✗ 건너뜀 ${rel} (상위 폴더 미생성)`));
|
|
217
244
|
continue;
|
|
218
245
|
}
|
|
219
|
-
const { title, body } =
|
|
220
|
-
const r =
|
|
246
|
+
const { title, body, fm } = readDoc(BASE_DIR, rel);
|
|
247
|
+
const r = render(rel, body, title);
|
|
221
248
|
const hash = docHash(title, r);
|
|
222
249
|
const pk = parentKeyOf(rel, folderIndex);
|
|
250
|
+
if (r.deadAnchors.length) {
|
|
251
|
+
console.error(yellow(` ⚠ 대응 헤딩 없는 앵커 ${rel}`) + dim(` ${r.deadAnchors.slice(0, 3).join(' ')}${r.deadAnchors.length > 3 ? ` 외 ${r.deadAnchors.length - 3}` : ''}`));
|
|
252
|
+
}
|
|
223
253
|
const parentId = pk ? work[pk]?.pageId : env.parentId;
|
|
224
254
|
rendered.set(rel, { ...r, title, hash, parentId });
|
|
225
255
|
if (pk && !parentId) {
|
|
226
256
|
console.error(yellow(` ✗ 건너뜀 ${rel} (부모 '${pk}' 가 아직 없음)`));
|
|
227
257
|
continue;
|
|
228
258
|
}
|
|
259
|
+
// pull 로 받은 문서(frontmatter 에 pageId)는 매핑이 없어도 원본 페이지에 다시 연결한다.
|
|
260
|
+
// 매핑 파일을 잃었거나 vault 를 다른 곳에 복제한 경우 중복 생성을 막아준다.
|
|
261
|
+
if (!work[rel]?.pageId && fm.pageId) {
|
|
262
|
+
try {
|
|
263
|
+
if (await client.getContentOrNull(fm.pageId)) {
|
|
264
|
+
work[rel] = { pageId: fm.pageId, title };
|
|
265
|
+
saveMapping(MAPPING_PATH, work);
|
|
266
|
+
console.log(` ${cyan('🔗 연결')} ${rel} ${dim(`→ 기존 페이지 #${fm.pageId}`)}`);
|
|
267
|
+
linked++;
|
|
268
|
+
}
|
|
269
|
+
else {
|
|
270
|
+
console.error(yellow(` ⚠ frontmatter 의 pageId #${fm.pageId} 를 찾을 수 없습니다 → 새로 생성 (${rel})`));
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
catch (e) {
|
|
274
|
+
console.error(yellow(` ⚠ pageId 확인 실패 #${fm.pageId}: ${e.message}`));
|
|
275
|
+
}
|
|
276
|
+
}
|
|
229
277
|
try {
|
|
230
278
|
const result = await client.upsertPage(work, spaceId, rel, title, r.storage, hash, parentId);
|
|
231
279
|
saveMapping(MAPPING_PATH, work);
|
|
@@ -282,6 +330,7 @@ async function main() {
|
|
|
282
330
|
` ${magenta(`재생성 ${recreated}`)}` +
|
|
283
331
|
` ${cyan(`링크재연결 ${relinked}`)}` +
|
|
284
332
|
` ${gray(`변경없음 ${skipped}`)}` +
|
|
333
|
+
(linked ? ` ${cyan(`연결 ${linked}`)}` : '') +
|
|
285
334
|
(foldersMade ? ` ${cyan(`폴더 ${foldersMade}`)}` : '') +
|
|
286
335
|
` ${dim(`대상 ${files.length}`)}`);
|
|
287
336
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wonseok-han/confluence-sync",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Confluence Cloud ↔ Markdown 양방향 동기화 CLI — 마크다운을 Confluence 페이지로 발행(push)하고, Confluence 페이지·폴더·스페이스를 마크다운으로 가져옵니다(pull).",
|
|
6
6
|
"keywords": [
|
|
@@ -26,7 +26,8 @@
|
|
|
26
26
|
"node": ">=20"
|
|
27
27
|
},
|
|
28
28
|
"bin": {
|
|
29
|
-
"confluence-sync": "dist/sync.js"
|
|
29
|
+
"confluence-sync": "dist/sync.js",
|
|
30
|
+
"csync": "dist/sync.js"
|
|
30
31
|
},
|
|
31
32
|
"files": [
|
|
32
33
|
"dist",
|