@wonseok-han/confluence-sync 1.2.1 → 1.4.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 +36 -1
- package/dist/anchors.js +27 -5
- package/dist/attachments.js +59 -0
- package/dist/convert.js +89 -17
- package/dist/docs.js +3 -2
- package/dist/footnotes.js +110 -0
- package/dist/help.js +10 -2
- package/dist/mapping.js +2 -0
- package/dist/markdown.js +8 -1
- package/dist/obsidian.js +20 -12
- package/dist/pdf-pages.js +148 -0
- package/dist/pdf-renderer.js +63 -0
- package/dist/pull.js +90 -4
- package/dist/render.js +27 -0
- package/dist/sync.js +7 -17
- package/package.json +9 -3
package/README.md
CHANGED
|
@@ -20,7 +20,7 @@ confluence-sync --version
|
|
|
20
20
|
confluence-sync --help
|
|
21
21
|
```
|
|
22
22
|
|
|
23
|
-
공개 npm 패키지라 **토큰이나 `.npmrc` 설정이 필요 없습니다.** (Node
|
|
23
|
+
공개 npm 패키지라 **토큰이나 `.npmrc` 설정이 필요 없습니다.** (Node 22.13+)
|
|
24
24
|
|
|
25
25
|
> 짧은 이름 **`csync`** 도 같이 설치됩니다 — 아래 예시의 `confluence-sync` 는 전부 `csync` 로 바꿔 쓸 수 있습니다.
|
|
26
26
|
|
|
@@ -144,6 +144,7 @@ docs/ → [CONFLUENCE_PARENT_ID 또는 공간 최상위]
|
|
|
144
144
|
| 쓰는 것 | Confluence 에서 |
|
|
145
145
|
| --- | --- |
|
|
146
146
|
| `[§1 개요](#1-개요)` | 같은 페이지의 그 헤딩으로 이동 |
|
|
147
|
+
| `[[#1. 개요]]` · `[[#1. 개요\|§1]]` | 위와 동일 (Obsidian 표기) |
|
|
147
148
|
| `[§6 ZKP](./04-구현체.md#6-zkp)` | 그 페이지의 그 헤딩으로 이동 |
|
|
148
149
|
| `[[04-구현체#6. ZKP]]` | 위와 동일 (Obsidian 표기) |
|
|
149
150
|
|
|
@@ -165,6 +166,7 @@ pull 은 반대로 Confluence 앵커를 마크다운 슬러그로 되돌립니
|
|
|
165
166
|
|
|
166
167
|
- 문서의 `제목 + 본문 + 참조 이미지 내용` 해시를 저장해두고, **바뀐 문서만** 새 버전으로 올립니다(`= 변경없음` 은 API 호출조차 하지 않음). `--force` 로 무시할 수 있습니다.
|
|
167
168
|
- 매핑은 **`<base>/.confluence-sync.json`** 에 저장됩니다. **문서셋과 함께 커밋하세요** — 지우면 다음 실행에서 페이지가 중복 생성됩니다. (`--mapping <path>` 로 위치 변경 가능)
|
|
169
|
+
- **pull 도 같은 매핑에 기록합니다** — 그래서 pull 직후 push 하면 `= 변경없음` 입니다. [Pull → 매핑 기록](#매핑-기록) 참고.
|
|
168
170
|
- Confluence 에서 페이지가 삭제됐다면 자동으로 **재생성**하고, 그 페이지를 가리키던 문서들도 **링크를 다시 연결**합니다. 내용이 그대로라 스킵되는 경우까지 잡으려면 `--verify` 를 씁니다.
|
|
169
171
|
|
|
170
172
|
### 동기화 제외 (로컬 전용 문서 유지)
|
|
@@ -211,6 +213,25 @@ confluence-sync pull --space --obsidian --out ~/MyVault # 내부 링크를 [[w
|
|
|
211
213
|
|
|
212
214
|
> **무손실 왕복은 아닙니다.** Confluence storage → Markdown 은 근사 변환이라 일부 매크로·레이아웃은 단순화됩니다.
|
|
213
215
|
|
|
216
|
+
### 매핑 기록
|
|
217
|
+
|
|
218
|
+
pull 은 받아온 문서를 **매핑 파일에 함께 기록합니다** — 페이지 id 와, push 가 계산하는 것과 **같은 방식의 변경 해시**를 남깁니다. 그래서 **pull 직후 push 하면 `= 변경없음`** 입니다. 이게 없으면 매핑에 해시가 없어 방금 받아온 내용을 그대로 되올려 보내게 됩니다.
|
|
219
|
+
|
|
220
|
+
```bash
|
|
221
|
+
$ confluence-sync pull --space --out ./docs
|
|
222
|
+
…
|
|
223
|
+
완료 페이지 42개, 폴더 3개 생성 내부링크 88개 연결 매핑 45건
|
|
224
|
+
매핑: /repo/docs/.confluence-sync.json (base: /repo/docs)
|
|
225
|
+
|
|
226
|
+
$ confluence-sync --base ./docs --dry-run
|
|
227
|
+
[동일] … # 되올려 보낼 것이 없다
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
- **base 는 `--out` 에서 위로 올라가며 `.confluence-sync.json` 을 찾아 정합니다.** 매핑 키는 base 기준 상대경로여야 이어지는 push 가 같은 문서를 알아보기 때문입니다. 하위 폴더로 받아도(`--out <base>/일부`) 루트의 매핑에 제대로 들어갑니다.
|
|
231
|
+
- 못 찾으면 `--out` 자체가 base 가 됩니다(그 디렉토리가 곧 동기화 루트가 됩니다).
|
|
232
|
+
- **`--base <dir>`** · **`--mapping <path>`** 로 직접 지정할 수 있고, **`--no-mapping`** 이면 아무것도 쓰지 않습니다.
|
|
233
|
+
- 같은 페이지가 다른 키에 남아 있으면(문서를 옮겼으면) 옛 키를 걷어내 중복 항목을 만들지 않습니다.
|
|
234
|
+
|
|
214
235
|
---
|
|
215
236
|
|
|
216
237
|
## Obsidian
|
|
@@ -275,6 +296,16 @@ confluence-sync convert --to obsidian --fix ./docs --out ~/MyVault
|
|
|
275
296
|
- 파일명이 트리 안에서 겹치면(`README.md` 여러 개 등) `[[wikilink]]` 로 바꾸지 않고 상대 링크를 유지합니다 — 이름만으로는 어느 노트인지 확정할 수 없기 때문입니다.
|
|
276
297
|
- 코드블록·인라인 코드 안의 링크는 손대지 않습니다.
|
|
277
298
|
- **섹션 링크는 표기가 다릅니다** — 표준 마크다운은 슬러그(`문서.md#6-zkp`), Obsidian 은 헤딩 텍스트(`[[문서#6. ZKP]]`)를 씁니다. 대상 문서의 헤딩을 찾아 옮기고, 못 찾으면 섹션 없이 문서 링크로 둡니다. Obsidian 블록 참조(`#^id`)는 마크다운에 대응물이 없어 같은 방식으로 처리합니다.
|
|
299
|
+
- **같은 문서 섹션**도 마찬가지입니다 — `[§4](#4-버전)` ↔ `[[#4. 버전|§4]]`. 표 안에서 `\|` 로 이스케이프된 별칭도 인식합니다.
|
|
300
|
+
|
|
301
|
+
### 각주와 첨부
|
|
302
|
+
|
|
303
|
+
`--to markdown`은 각주를 위첨자 링크와 작은 제목으로 변환하고, 각주에 연결된 PDF의
|
|
304
|
+
`#page=N` 페이지를 이미지로 표시합니다. 첨부파일은 `attachments/`에 모아 연결합니다.
|
|
305
|
+
PDF 이미지 추출은 패키지에 포함된 PDF.js로 처리하므로 별도 도구 설치가 필요 없습니다.
|
|
306
|
+
|
|
307
|
+
`--to obsidian`은 변환 정보와 원본 PDF가 남아 있는 로컬 변환본에서 각주와 PDF 참조를 복원합니다.
|
|
308
|
+
Confluence에서 다시 내려받은 문서의 복원은 보장하지 않습니다.
|
|
278
309
|
|
|
279
310
|
### 보정 (`--fix`)
|
|
280
311
|
|
|
@@ -296,6 +327,8 @@ confluence-sync convert --to obsidian --fix ./docs --out ~/MyVault
|
|
|
296
327
|
| vault 에서 쓴 것 | Confluence 에 올라가는 것 |
|
|
297
328
|
| --- | --- |
|
|
298
329
|
| `[[설계]]` · `[[구조/설계\|설계 문서]]` | 페이지 링크 (짧은 이름·경로 모두 인식) |
|
|
330
|
+
| `[[설계#3. 구성]]` | 그 페이지의 그 섹션 |
|
|
331
|
+
| `[[#3. 구성]]` · `[[#3. 구성\|§3]]` | **같은 페이지**의 그 섹션 |
|
|
299
332
|
| `![[diagram.png]]` | 첨부 이미지 |
|
|
300
333
|
| frontmatter `title:` | 페이지 제목 |
|
|
301
334
|
| frontmatter `pageId:` | **그 페이지를 갱신** (새로 만들지 않음) |
|
|
@@ -310,6 +343,8 @@ confluence-sync convert --to obsidian --fix ./docs --out ~/MyVault
|
|
|
310
343
|
|
|
311
344
|
## 개발
|
|
312
345
|
|
|
346
|
+
`npm test`로 전체 테스트, `npm run test:coverage`로 커버리지를 확인합니다. 테스트는 실제 Confluence에 접속하지 않습니다.
|
|
347
|
+
|
|
313
348
|
```bash
|
|
314
349
|
git clone https://github.com/wonseok-han/confluence-sync.git
|
|
315
350
|
cd confluence-sync
|
package/dist/anchors.js
CHANGED
|
@@ -10,11 +10,33 @@
|
|
|
10
10
|
* 이름은 마크다운 슬러그를 그대로 쓰므로 왕복도 정확해진다 — 양쪽 다 우리가 정한 값이다.
|
|
11
11
|
*/
|
|
12
12
|
const FENCE = /^\s*(?:```|~~~)/;
|
|
13
|
+
/**
|
|
14
|
+
* `[[대상]]` · `[[대상|별칭]]` · `[[대상#헤딩]]` · `[[#헤딩]]` · `![[임베드]]`
|
|
15
|
+
*
|
|
16
|
+
* 대상이 **비어 있을 수 있다** — `[[#헤딩]]` 은 "이 문서의 이 섹션"이라는 Obsidian 표기다.
|
|
17
|
+
* 표 안에서는 별칭 앞 파이프가 `\|` 로 이스케이프되므로 그 백슬래시도 함께 받아 준다.
|
|
18
|
+
*/
|
|
19
|
+
export const WIKILINK_RE = /(!?)\[\[([^[\]|#]*)(#[^[\]|]+)?(?:\\?\|([^[\]]*))?\]\]/g;
|
|
20
|
+
/**
|
|
21
|
+
* WIKILINK_RE 의 캡처 그룹을 해석한다. 대상도 섹션도 없으면(`[[]]`) 링크가 아니므로 null.
|
|
22
|
+
* 표의 `\|` 때문에 앞 그룹 끝에 딸려온 백슬래시는 여기서 떼어낸다.
|
|
23
|
+
*/
|
|
24
|
+
export function parseWikilink(bang, target, hash, alias) {
|
|
25
|
+
const unesc = (s) => s.replace(/\\$/, '').trim();
|
|
26
|
+
const t = unesc(target ?? '');
|
|
27
|
+
const f = hash ? unesc(hash.slice(1)) : null;
|
|
28
|
+
if (!t && !f)
|
|
29
|
+
return null;
|
|
30
|
+
return { embed: bang === '!', target: t, fragment: f || null, alias: alias?.trim() || null };
|
|
31
|
+
}
|
|
13
32
|
/** 인라인 마크다운 표기를 벗긴다 — 슬러그는 렌더된 텍스트 기준이다. */
|
|
14
33
|
function plainText(s) {
|
|
15
34
|
return s
|
|
16
35
|
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1') // [label](url) → label
|
|
17
|
-
.replace(
|
|
36
|
+
.replace(WIKILINK_RE, (whole, ...g) => {
|
|
37
|
+
const w = parseWikilink(g[0], g[1], g[2], g[3]);
|
|
38
|
+
return w ? (w.alias ?? (w.target || w.fragment) ?? whole) : whole;
|
|
39
|
+
})
|
|
18
40
|
.replace(/`+/g, '')
|
|
19
41
|
.replace(/\*\*|~~|\*/g, '') // _ 는 남긴다: 슬러그가 밑줄을 보존하므로(foo_bar → foo_bar)
|
|
20
42
|
.trim();
|
|
@@ -114,7 +136,6 @@ export function wikilinkAnchorToSlug(anchor) {
|
|
|
114
136
|
return slugifyHeading(last) || null;
|
|
115
137
|
}
|
|
116
138
|
const MD_LINK = /\[([^\]]*)\]\((<[^>]+>|[^()\s]+)\)/g;
|
|
117
|
-
const WIKILINK = /(!?)\[\[([^\]|#]+)(#[^\]|]+)?(?:\|([^\]]+))?\]\]/g;
|
|
118
139
|
/**
|
|
119
140
|
* 본문에서 **앵커가 붙은 링크만** 훑는다 — 어느 문서의 어느 헤딩에 앵커를 심어야 하는지 알아내기 위해서다.
|
|
120
141
|
* 전체 렌더보다 훨씬 싸고, 코드블록·인라인 코드·이미지는 제외한다.
|
|
@@ -142,10 +163,11 @@ export function collectLinkAnchors(md) {
|
|
|
142
163
|
continue;
|
|
143
164
|
out.push({ dest: d.slice(0, i), fragment: d.slice(i + 1), wiki: false });
|
|
144
165
|
}
|
|
145
|
-
for (const m of prose.matchAll(
|
|
146
|
-
|
|
166
|
+
for (const m of prose.matchAll(WIKILINK_RE)) {
|
|
167
|
+
const w = parseWikilink(m[1], m[2], m[3], m[4]);
|
|
168
|
+
if (!w || w.embed || !w.fragment)
|
|
147
169
|
continue; // 임베드·앵커 없는 wikilink
|
|
148
|
-
out.push({ dest:
|
|
170
|
+
out.push({ dest: w.target, fragment: w.fragment, wiki: true }); // dest 가 비면 같은 문서
|
|
149
171
|
}
|
|
150
172
|
}
|
|
151
173
|
return out;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync, statSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { resolve, relative, dirname, basename, isAbsolute } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { mapMarkdownText } from './pdf-pages.js';
|
|
6
|
+
/** 출력 트리의 경로와 실제 입력 파일을 연결한다. dry-run에서도 파일 복사 없이 사용한다. */
|
|
7
|
+
export function attachmentMapper(file, base) {
|
|
8
|
+
const copies = new Map(); // base 상대 출력 경로 → 입력 절대경로
|
|
9
|
+
function localPath(raw) {
|
|
10
|
+
if (!raw || raw.startsWith('#') || raw.startsWith('//'))
|
|
11
|
+
return null;
|
|
12
|
+
if (/^file:/i.test(raw))
|
|
13
|
+
return fileURLToPath(raw);
|
|
14
|
+
if (!isAbsolute(raw) && /^[a-z][a-z\d+.-]*:/i.test(raw))
|
|
15
|
+
return null;
|
|
16
|
+
try {
|
|
17
|
+
return resolve(dirname(file), decodeURIComponent(raw));
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function relocate(raw, wiki = false) {
|
|
24
|
+
const hash = raw.indexOf('#');
|
|
25
|
+
const path = hash < 0 ? raw : raw.slice(0, hash), fragment = hash < 0 ? '' : raw.slice(hash);
|
|
26
|
+
const abs = wiki && !path.startsWith('/') && !path.startsWith('.') && !/^file:/i.test(path)
|
|
27
|
+
? resolve(base, path) : localPath(path);
|
|
28
|
+
if (!abs || /\.md$/i.test(abs))
|
|
29
|
+
return null;
|
|
30
|
+
// 일반 외부 URL 및 없는 링크는 그대로 남긴다.
|
|
31
|
+
if (!existsSync(abs) || !statSync(abs).isFile())
|
|
32
|
+
return null;
|
|
33
|
+
let rel = relative(base, abs).split('\\').join('/');
|
|
34
|
+
if (!rel.startsWith('attachments/')) {
|
|
35
|
+
const hash = createHash('sha256').update(readFileSync(abs)).digest('hex').slice(0, 24);
|
|
36
|
+
rel = `attachments/files/${hash}/${basename(abs)}`;
|
|
37
|
+
}
|
|
38
|
+
copies.set(rel, abs);
|
|
39
|
+
const target = wiki ? rel : relative(dirname(file), resolve(base, rel)).split('\\').join('/');
|
|
40
|
+
return target + fragment;
|
|
41
|
+
}
|
|
42
|
+
function rewrite(text) {
|
|
43
|
+
return mapMarkdownText(text, part => {
|
|
44
|
+
const md = part.replace(/(?<!!)(!?)\[([^\]\n]*)\]\((<[^>\n]+>|[^()\s]+)\)/g, (all, bang, label, raw, offset) => {
|
|
45
|
+
if ((part.slice(0, offset).match(/\\+$/)?.[0].length ?? 0) % 2)
|
|
46
|
+
return all;
|
|
47
|
+
const dest = relocate(raw.replace(/^<(.*)>$/, '$1'));
|
|
48
|
+
return dest === null ? all : `${bang}[${label}](<${dest.replace(/</g, '%3C').replace(/>/g, '%3E')}>)`;
|
|
49
|
+
});
|
|
50
|
+
return md.replace(/(!?)\[\[([^\]|\n]+)(?:\|([^\]\n]*))?\]\]/g, (all, bang, target, label, offset) => {
|
|
51
|
+
if ((md.slice(0, offset).match(/\\+$/)?.[0].length ?? 0) % 2)
|
|
52
|
+
return all;
|
|
53
|
+
const dest = relocate(target, true);
|
|
54
|
+
return dest === null ? all : `${bang}[[${dest}${label === undefined ? '' : '|' + label}]]`;
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
return { copies, rewrite, source: (abs) => copies.get(relative(base, abs).split('\\').join('/')) ?? abs };
|
|
59
|
+
}
|
package/dist/convert.js
CHANGED
|
@@ -9,11 +9,14 @@
|
|
|
9
9
|
* 스페이스를 통째로 다시 받지 않고도 최신 변환 품질을 얻기 위한 것이다.
|
|
10
10
|
* 링크는 대상이 트리 안에 실제로 있을 때만 바꾼다(외부 URL·이미지·깨진 링크는 손대지 않는다).
|
|
11
11
|
*/
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
12
|
+
import { attachmentMapper } from './attachments.js';
|
|
13
|
+
import { readFileSync, writeFileSync, existsSync, statSync, mkdirSync, copyFileSync, realpathSync } from 'node:fs';
|
|
14
|
+
import { resolve, relative, dirname, basename, join, isAbsolute } from 'node:path';
|
|
14
15
|
import { collectMarkdown, collectAssets, buildVault, vaultResolver } from './docs.js';
|
|
15
16
|
import { linksToWikilinks, resolveWikilinks, splitFrontmatter } from './obsidian.js';
|
|
16
17
|
import { buildAnchorIndex } from './anchors.js';
|
|
18
|
+
import { footnotesToLinks, linksToFootnotes } from './footnotes.js';
|
|
19
|
+
import { extractPdfPages, restorePdfReferences, mapMarkdownText } from './pdf-pages.js';
|
|
17
20
|
import { repairMarkdown, totalFixes } from './repair.js';
|
|
18
21
|
import { bold, cyan, dim, gray, green, red, yellow } from './colors.js';
|
|
19
22
|
function optVal(argv, name) {
|
|
@@ -27,17 +30,27 @@ catch {
|
|
|
27
30
|
return s;
|
|
28
31
|
} };
|
|
29
32
|
const posix = (s) => s.split('\\').join('/');
|
|
30
|
-
|
|
31
|
-
const
|
|
33
|
+
const isWithin = (base, path) => {
|
|
34
|
+
const rel = relative(base, path);
|
|
35
|
+
return rel === '' || (rel !== '..' && !rel.startsWith('../') && !rel.startsWith('..\\') && !isAbsolute(rel));
|
|
36
|
+
};
|
|
37
|
+
function canonical(path) {
|
|
38
|
+
if (existsSync(path))
|
|
39
|
+
return realpathSync(path);
|
|
40
|
+
const parent = dirname(path);
|
|
41
|
+
return parent === path ? path : join(canonical(parent), basename(path));
|
|
42
|
+
}
|
|
43
|
+
// 이미지·일반 링크의 로컬 첨부(--out 으로 복사할 때 PDF도 같이 옮긴다).
|
|
44
|
+
const ASSET_LINK = /!?\[[^\]]*\]\((<[^>]+>|[^()\s]+)\)/g;
|
|
32
45
|
/** md 본문이 참조하는 로컬 첨부의 절대경로들(외부 URL 제외, 실제 존재하는 것만). */
|
|
33
46
|
function referencedAssets(text, fileAbs) {
|
|
34
47
|
const out = [];
|
|
35
|
-
for (const m of text.matchAll(
|
|
48
|
+
for (const m of text.matchAll(ASSET_LINK)) {
|
|
36
49
|
const dest = decode(m[1].replace(/^<(.*)>$/, '$1')).split('#')[0];
|
|
37
50
|
if (/^(https?:|data:)/i.test(dest))
|
|
38
51
|
continue;
|
|
39
52
|
const abs = resolve(dirname(fileAbs), dest);
|
|
40
|
-
if (existsSync(abs))
|
|
53
|
+
if (existsSync(abs) && statSync(abs).isFile() && !/\.md$/i.test(abs))
|
|
41
54
|
out.push(abs);
|
|
42
55
|
}
|
|
43
56
|
return out;
|
|
@@ -62,11 +75,27 @@ function commonAncestor(paths) {
|
|
|
62
75
|
* wikilink 는 vault 어디서든 "이름"으로 찾으므로, 파일명이 트리 안에서 유일할 때만 바꾼다
|
|
63
76
|
* (겹치면 엉뚱한 노트로 이어질 수 있어 상대 링크를 그대로 둔다).
|
|
64
77
|
*/
|
|
65
|
-
function toObsidian(text, fileAbs, baseDir, nameCount, headingOf) {
|
|
78
|
+
function toObsidian(text, fileAbs, baseDir, nameCount, headingOf, selfHeadingOf, source = p => p) {
|
|
66
79
|
return linksToWikilinks(text, (dest, label) => {
|
|
67
|
-
if (/^(https?:|mailto
|
|
68
|
-
return null; // 외부
|
|
80
|
+
if (/^(https?:|mailto:)/i.test(dest))
|
|
81
|
+
return null; // 외부 링크
|
|
82
|
+
// 같은 문서 섹션: [§4](#슬러그) → [[#헤딩 텍스트|§4]]
|
|
83
|
+
// Obsidian 은 슬러그가 아니라 헤딩 텍스트로 섹션을 가리키므로, 헤딩을 못 찾으면 손대지 않는다.
|
|
84
|
+
if (dest.startsWith('#')) {
|
|
85
|
+
const heading = selfHeadingOf(decode(dest.slice(1)));
|
|
86
|
+
if (!heading)
|
|
87
|
+
return null;
|
|
88
|
+
const l = label.trim();
|
|
89
|
+
return l && l !== heading ? `[[#${heading}|${l}]]` : `[[#${heading}]]`;
|
|
90
|
+
}
|
|
69
91
|
const [pathPart, ...hash] = decode(dest).split('#');
|
|
92
|
+
if (/\.pdf$/i.test(pathPart)) {
|
|
93
|
+
const target = resolve(dirname(fileAbs), pathPart);
|
|
94
|
+
const rel = relative(baseDir, target).split('\\').join('/');
|
|
95
|
+
if (!existsSync(source(target)) || rel.startsWith('../'))
|
|
96
|
+
return null;
|
|
97
|
+
return `[[${rel}${hash.length ? '#' + hash.join('#') : ''}|${label}]]`;
|
|
98
|
+
}
|
|
70
99
|
if (!/\.md$/i.test(pathPart))
|
|
71
100
|
return null;
|
|
72
101
|
const targetAbs = resolve(dirname(fileAbs), pathPart);
|
|
@@ -158,7 +187,7 @@ export async function runConvert(argv) {
|
|
|
158
187
|
const selected = targets.length
|
|
159
188
|
? allMd.filter((f) => targets.some((t) => {
|
|
160
189
|
const abs = resolve(t);
|
|
161
|
-
return
|
|
190
|
+
return isWithin(abs, f);
|
|
162
191
|
}))
|
|
163
192
|
: allMd;
|
|
164
193
|
// base 밖의 .md 를 직접 지목한 경우(예: 이웃 폴더 파일) 는 위 필터에 안 걸린다
|
|
@@ -180,7 +209,7 @@ export async function runConvert(argv) {
|
|
|
180
209
|
const scope = mdAbs.length === allMd.length
|
|
181
210
|
? `${mdAbs.length}건`
|
|
182
211
|
: `선택 ${mdAbs.length}/${allMd.length}건`;
|
|
183
|
-
if (outDir && (
|
|
212
|
+
if (outDir && isWithin(canonical(baseDir), canonical(outDir))) {
|
|
184
213
|
console.error(red(`✗ --out 이 base 안에 있습니다: ${outDir}`) + dim('\n 원본 트리를 덮어쓰지 않도록 base 밖의 경로를 쓰세요.'));
|
|
185
214
|
process.exit(1);
|
|
186
215
|
}
|
|
@@ -195,7 +224,9 @@ export async function runConvert(argv) {
|
|
|
195
224
|
const rel = posix(relative(baseDir, abs));
|
|
196
225
|
const before = readFileSync(abs, 'utf8');
|
|
197
226
|
let text = before;
|
|
227
|
+
const sourceAssets = new Set(to === 'obsidian' ? [] : referencedAssets(before, abs));
|
|
198
228
|
const notes = [];
|
|
229
|
+
const attachments = attachmentMapper(abs, baseDir);
|
|
199
230
|
// 보정을 먼저 한다 — 이스케이프가 풀려야(\[…\] → […]) 링크 인식이 정확해진다
|
|
200
231
|
if (fix) {
|
|
201
232
|
const r = repairMarkdown(text);
|
|
@@ -207,20 +238,61 @@ export async function runConvert(argv) {
|
|
|
207
238
|
notes.push(dim(`보정 ${n}`));
|
|
208
239
|
}
|
|
209
240
|
}
|
|
210
|
-
if (to === 'obsidian')
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
241
|
+
if (to === 'obsidian') {
|
|
242
|
+
const restored = restorePdfReferences(text, abs, baseDir);
|
|
243
|
+
restored.assets.forEach(a => sourceAssets.add(a));
|
|
244
|
+
referencedAssets(restored.text, abs).forEach(a => sourceAssets.add(a));
|
|
245
|
+
text = attachments.rewrite(linksToFootnotes(restored.text));
|
|
246
|
+
// 같은 문서 앵커는 보정(--fix)까지 끝난 **지금 본문**의 헤딩으로 풀어야 맞는다
|
|
247
|
+
const selfIdx = buildAnchorIndex(splitFrontmatter(text).body);
|
|
248
|
+
text = toObsidian(text, abs, baseDir, nameCount, headingOf, (s) => selfIdx.get(s.toLowerCase()), attachments.source);
|
|
249
|
+
}
|
|
250
|
+
else if (to === 'markdown') {
|
|
251
|
+
const lookup = vaultResolver(rel, vault);
|
|
252
|
+
// 명시된 첨부 경로를 먼저 출력 트리로 옮긴 뒤 wiki 이름을 해석한다.
|
|
253
|
+
text = attachments.rewrite(text);
|
|
254
|
+
text = resolveWikilinks(text, (target, embed) => {
|
|
255
|
+
const imported = resolve(baseDir, target);
|
|
256
|
+
if (attachments.copies.has(posix(relative(baseDir, imported)))) {
|
|
257
|
+
return posix(relative(dirname(abs), imported));
|
|
258
|
+
}
|
|
259
|
+
return lookup(target, embed);
|
|
260
|
+
});
|
|
261
|
+
text = footnotesToLinks(attachments.rewrite(text));
|
|
262
|
+
mapMarkdownText(text, part => {
|
|
263
|
+
if (/\[\[[^\]\n]*\.pdf#page=[^\]\n]*\]\]/i.test(part)) {
|
|
264
|
+
throw new Error(`PDF 위키링크를 해석하지 못했습니다 (${rel}). --base에 PDF가 포함되는지 확인하세요.`);
|
|
265
|
+
}
|
|
266
|
+
return part;
|
|
267
|
+
});
|
|
268
|
+
const pdf = await extractPdfPages(text, abs, baseDir, outDir ?? baseDir, dryRun, attachments.source);
|
|
269
|
+
pdf.assets.forEach(a => sourceAssets.add(a));
|
|
270
|
+
text = pdf.text;
|
|
271
|
+
if (pdf.pages)
|
|
272
|
+
notes.push(dim(`PDF ${pdf.pages}쪽 이미지`));
|
|
273
|
+
}
|
|
214
274
|
// 제자리 변환이면 바뀐 것만 쓰지만, --out 이면 대상 전부를 내보낸다(온전한 트리가 나와야 하므로)
|
|
215
275
|
if (!outDir && text === before)
|
|
216
276
|
continue;
|
|
277
|
+
if (!dryRun) {
|
|
278
|
+
for (const [assetRel, source] of attachments.copies) {
|
|
279
|
+
const destination = join(outDir ?? baseDir, assetRel);
|
|
280
|
+
if (destination !== source) {
|
|
281
|
+
mkdirSync(dirname(destination), { recursive: true });
|
|
282
|
+
copyFileSync(source, destination);
|
|
283
|
+
}
|
|
284
|
+
copiedAssets.add(assetRel);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
217
287
|
if (outDir) {
|
|
218
288
|
const target = join(outDir, rel);
|
|
219
289
|
if (!dryRun) {
|
|
220
290
|
mkdirSync(dirname(target), { recursive: true });
|
|
221
291
|
writeFileSync(target, text);
|
|
222
|
-
//
|
|
223
|
-
for (const asset of referencedAssets(text, abs)) {
|
|
292
|
+
// 참조된 첨부를 같은 상대 위치로 복사한다.
|
|
293
|
+
for (const asset of new Set([...sourceAssets, ...referencedAssets(text, abs)])) {
|
|
294
|
+
if ([...attachments.copies.values()].includes(asset))
|
|
295
|
+
continue;
|
|
224
296
|
const arel = posix(relative(baseDir, asset));
|
|
225
297
|
if (arel.startsWith('..') || copiedAssets.has(arel))
|
|
226
298
|
continue; // base 밖 첨부는 두고 온다
|
package/dist/docs.js
CHANGED
|
@@ -97,7 +97,8 @@ export function resolveAnchorTargets(docs, vault) {
|
|
|
97
97
|
let target;
|
|
98
98
|
let slug;
|
|
99
99
|
if (la.wiki) {
|
|
100
|
-
|
|
100
|
+
// dest 가 비면 `[[#헤딩]]` — 같은 문서의 섹션이다
|
|
101
|
+
target = la.dest === '' ? rel : (vault.notes[la.dest.toLowerCase().replace(/\.md$/i, '')] ?? null);
|
|
101
102
|
slug = wikilinkAnchorToSlug(la.fragment);
|
|
102
103
|
}
|
|
103
104
|
else {
|
|
@@ -218,7 +219,7 @@ export function resolveSelection(rels, positionals, baseDir) {
|
|
|
218
219
|
const set = new Set();
|
|
219
220
|
for (const p of positionals) {
|
|
220
221
|
const abs = resolve(baseDir, p);
|
|
221
|
-
const relToBase = relative(baseDir, abs);
|
|
222
|
+
const relToBase = relative(baseDir, abs).split('\\').join('/');
|
|
222
223
|
if (relToBase.startsWith('..')) {
|
|
223
224
|
console.error(` ⚠ base 밖 경로 무시: ${p}`);
|
|
224
225
|
continue;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import MarkdownIt from 'markdown-it';
|
|
2
|
+
import footnote from 'markdown-it-footnote';
|
|
3
|
+
import { collectHeadings } from './anchors.js';
|
|
4
|
+
import { mapMarkdownText, replaceGeneratedBlocks } from './pdf-pages.js';
|
|
5
|
+
/** 각주 확장을 일반 제목 앵커 + 위첨자 Markdown 링크로 내보낸다. */
|
|
6
|
+
export function footnotesToLinks(source) {
|
|
7
|
+
if (!source.includes('[^'))
|
|
8
|
+
return source;
|
|
9
|
+
const md = new MarkdownIt({ html: true }).use(footnote);
|
|
10
|
+
const definitions = [];
|
|
11
|
+
const lines = source.split('\n');
|
|
12
|
+
const offsets = [0];
|
|
13
|
+
for (const line of lines)
|
|
14
|
+
offsets.push(offsets[offsets.length - 1] + line.length + 1);
|
|
15
|
+
const defRule = md.block.ruler.getRules('').find((r) => r.name === 'footnote_def');
|
|
16
|
+
md.block.ruler.at('footnote_def', (state, start, end, silent) => {
|
|
17
|
+
const ok = defRule(state, start, end, silent);
|
|
18
|
+
if (ok && !silent) {
|
|
19
|
+
const first = lines[start].match(/^ {0,3}\[\^([^\]]+)\]:\s*(.*)$/);
|
|
20
|
+
if (first)
|
|
21
|
+
definitions.push({
|
|
22
|
+
start: offsets[start], end: Math.min(offsets[state.line], source.length), label: first[1],
|
|
23
|
+
body: [first[2], ...lines.slice(start + 1, state.line).map((l) => l.replace(/^(?: {4}|\t)/, ''))].join('\n').trimEnd(),
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
return ok;
|
|
27
|
+
}, { alt: ['paragraph', 'reference'] });
|
|
28
|
+
const env = {};
|
|
29
|
+
const tokens = md.parse(source, env);
|
|
30
|
+
if (!definitions.length)
|
|
31
|
+
return source;
|
|
32
|
+
const used = new Set(collectHeadings(source).map((h) => h.slug));
|
|
33
|
+
const labels = new Set(definitions.map((d) => d.label));
|
|
34
|
+
const notes = new Map();
|
|
35
|
+
const edits = [];
|
|
36
|
+
const refRule = md.inline.ruler.getRules('').find((r) => r.name === 'footnote_ref');
|
|
37
|
+
let localEdits = [];
|
|
38
|
+
md.inline.ruler.at('footnote_ref', (state, silent) => {
|
|
39
|
+
const start = state.pos;
|
|
40
|
+
const ok = refRule(state, silent);
|
|
41
|
+
if (ok && !silent) {
|
|
42
|
+
const label = state.src.slice(start + 2, state.pos - 1);
|
|
43
|
+
if (!labels.has(label))
|
|
44
|
+
return ok; // 지원 범위 밖의 중첩 정의는 원문으로 남긴다.
|
|
45
|
+
let note = notes.get(label);
|
|
46
|
+
if (!note) {
|
|
47
|
+
const number = notes.size + 1;
|
|
48
|
+
let heading = `각주 ${number}`, anchor = `각주-${number}`, suffix = 0;
|
|
49
|
+
while (used.has(anchor)) {
|
|
50
|
+
heading = `각주 ${number}-${++suffix}`;
|
|
51
|
+
anchor = `각주-${number}-${suffix}`;
|
|
52
|
+
}
|
|
53
|
+
used.add(anchor);
|
|
54
|
+
note = { number, heading, anchor };
|
|
55
|
+
notes.set(label, note);
|
|
56
|
+
}
|
|
57
|
+
localEdits.push({ start, end: state.pos, text: `<sup>[${note.number}](#${note.anchor})</sup>` });
|
|
58
|
+
}
|
|
59
|
+
return ok;
|
|
60
|
+
});
|
|
61
|
+
// 인라인 각주(^[...])는 이 변환의 대상이 아니다.
|
|
62
|
+
md.inline.ruler.disable('footnote_inline');
|
|
63
|
+
const replaceRefs = (text) => {
|
|
64
|
+
localEdits = [];
|
|
65
|
+
md.inline.parse(text, md, env, []);
|
|
66
|
+
for (const e of localEdits.sort((a, b) => b.start - a.start))
|
|
67
|
+
text = text.slice(0, e.start) + e.text + text.slice(e.end);
|
|
68
|
+
return text;
|
|
69
|
+
};
|
|
70
|
+
// 코드블록과 정의를 제외한 원문 구간을 그대로 보존한다.
|
|
71
|
+
const excluded = [
|
|
72
|
+
...definitions.map((d) => ({ start: d.start, end: d.end, definition: true })),
|
|
73
|
+
...tokens.filter((t) => (t.type === 'fence' || t.type === 'code_block' || t.type === 'html_block') && t.map)
|
|
74
|
+
.map((t) => ({ start: offsets[t.map[0]], end: Math.min(offsets[t.map[1]], source.length), definition: false })),
|
|
75
|
+
].sort((a, b) => a.start - b.start);
|
|
76
|
+
let cursor = 0;
|
|
77
|
+
for (const span of excluded) {
|
|
78
|
+
if (span.start < cursor)
|
|
79
|
+
continue;
|
|
80
|
+
edits.push({ start: cursor, end: span.start, text: replaceRefs(source.slice(cursor, span.start)) });
|
|
81
|
+
if (span.definition)
|
|
82
|
+
edits.push({ start: span.start, end: span.end, text: '' });
|
|
83
|
+
cursor = span.end;
|
|
84
|
+
}
|
|
85
|
+
edits.push({ start: cursor, end: source.length, text: replaceRefs(source.slice(cursor)) });
|
|
86
|
+
// 참조하지 않은 정의도 버리지 않는다.
|
|
87
|
+
for (const d of definitions)
|
|
88
|
+
if (!notes.has(d.label))
|
|
89
|
+
replaceRefs(`[^${d.label}]`);
|
|
90
|
+
let result = source;
|
|
91
|
+
for (const e of edits.sort((a, b) => b.start - a.start))
|
|
92
|
+
result = result.slice(0, e.start) + e.text + result.slice(e.end);
|
|
93
|
+
const bodies = new Map(definitions.map((d) => [d.label, d.body]));
|
|
94
|
+
return result.trimEnd() + '\n\n' + [...notes].map(([label, note]) => `<!-- csync-footnote:v1 ${Buffer.from(JSON.stringify({ label, anchor: note.anchor })).toString('base64')} -->\n###### ${note.heading}\n\n${replaceRefs(bodies.get(label) ?? '')}\n<!-- /csync-footnote -->`).join('\n\n') + '\n';
|
|
95
|
+
}
|
|
96
|
+
/** 생성 표식이 있는 각주만 복원한다. 설명의 사용자 수정은 유지한다. */
|
|
97
|
+
export function linksToFootnotes(source) {
|
|
98
|
+
const refs = new Map();
|
|
99
|
+
const definitions = [];
|
|
100
|
+
const text = replaceGeneratedBlocks(source, /<!-- csync-footnote:v1 ([A-Za-z0-9+/=]+) -->\n#{1,6} [^\n]+\n\n([\s\S]*?)\n<!-- \/csync-footnote -->/g, (_all, encoded, body) => {
|
|
101
|
+
const { label, anchor } = JSON.parse(Buffer.from(encoded, 'base64').toString());
|
|
102
|
+
refs.set(anchor, label);
|
|
103
|
+
definitions.push(`[^${label}]: ${body.trimEnd().split('\n').join('\n ')}`);
|
|
104
|
+
return '';
|
|
105
|
+
});
|
|
106
|
+
if (!refs.size)
|
|
107
|
+
return source;
|
|
108
|
+
const replace = (s) => mapMarkdownText(s, p => p.replace(/<sup>\[\d+\]\(#([^\s)]+)\)<\/sup>/g, (all, anchor) => refs.has(decodeURIComponent(anchor)) ? `[^${refs.get(decodeURIComponent(anchor))}]` : all));
|
|
109
|
+
return replace(text).trimEnd() + '\n\n' + definitions.map(replace).join('\n\n') + '\n';
|
|
110
|
+
}
|
package/dist/help.js
CHANGED
|
@@ -50,14 +50,22 @@ ${h('pull(역방향) 옵션:')}
|
|
|
50
50
|
${o('--out <dir>')} .md 를 생성할 디렉토리(기본: 현재 폴더)
|
|
51
51
|
${o('--children')} 하위 페이지까지 재귀적으로 가져와 폴더 트리로 복원
|
|
52
52
|
${o('--space')} 대상 없이 스페이스(CONFLUENCE_SPACE_KEY) 홈페이지부터 전체를 가져옴
|
|
53
|
-
${o('--obsidian')}
|
|
53
|
+
${o('--obsidian')} 내부 링크를 [[wikilink]] 로 출력(기본: 어디서나 열리는 상대 .md 링크)
|
|
54
|
+
${o('--base <dir>')} 매핑 키의 기준(기본: --out 에서 위로 올라가며 .confluence-sync.json 을 찾음)
|
|
55
|
+
${o('--mapping <path>')} 매핑 파일 위치 (기본: <base>/.confluence-sync.json)
|
|
56
|
+
${o('--no-mapping')} 매핑을 기록하지 않음
|
|
54
57
|
(이미지는 항상 attachments/<문서명>/ 하위에 저장됨)
|
|
58
|
+
받아온 문서는 pageId 와 ${dim('변경 해시')}가 매핑에 기록되어, ${bold('pull 직후 push 하면 "변경없음"')} 입니다
|
|
59
|
+
${dim('(기록하지 않으면 방금 받아온 내용을 그대로 되올려 보내게 됩니다).')}
|
|
55
60
|
예) ${o('confluence-sync pull https://.../pages/12345/Title --out ./docs --children')}
|
|
56
61
|
${o('confluence-sync pull --space --out ./docs')}
|
|
57
62
|
|
|
58
63
|
${h('convert(이미 받아둔 .md 손보기) 옵션:')} ${dim('Confluence 호출·인증 없음')}
|
|
59
64
|
${o('--to obsidian')} 상대 .md 링크 → [[wikilink]]
|
|
60
65
|
${o('--to markdown')} [[wikilink]] · ![[embed]] → 상대 .md 링크 (되돌리기)
|
|
66
|
+
각주 → 위첨자 링크·제목 앵커, PDF #page=N → 해당 쪽 이미지
|
|
67
|
+
--to obsidian은 변환 표식으로 각주·PDF 참조를 복원
|
|
68
|
+
절대경로·file://·../ 로컬 첨부는 출력 트리에 복사하고 링크 변경
|
|
61
69
|
${o('--fix')} 옛 pull 결과 보정 — 코드블록 언어(java→plaintext)·CSS 잔해·
|
|
62
70
|
불필요한 \\ 이스케이프·리스트 사이 빈 줄
|
|
63
71
|
${o('--base <dir>')} 링크를 해석할 문서 트리 루트(기본: 준 경로들의 공통 상위 폴더)
|
|
@@ -72,7 +80,7 @@ ${h('convert(이미 받아둔 .md 손보기) 옵션:')} ${dim('Confluence 호출
|
|
|
72
80
|
|
|
73
81
|
${h('섹션 링크(앵커):')}
|
|
74
82
|
${o('[§1 개요](#1-개요)')} 처럼 헤딩을 가리키는 링크가 Confluence 에서도 동작합니다.
|
|
75
|
-
같은 문서(${o('#슬러그')})·다른 문서(${o('문서.md#슬러그')}
|
|
83
|
+
같은 문서(${o('#슬러그')} · ${o('[[#헤딩]]')})·다른 문서(${o('문서.md#슬러그')} · ${o('[[문서#헤딩]]')}) 모두 지원합니다.
|
|
76
84
|
Confluence 의 자동 헤딩 id 는 배포판마다 달라서, 가리켜지는 헤딩 앞에 ${dim('Anchor 매크로')}로
|
|
77
85
|
이름을 직접 심고 그 이름을 가리킵니다. pull 은 반대로 슬러그를 되돌립니다.
|
|
78
86
|
대응하는 헤딩을 못 찾으면 링크를 바꾸지 않고 ${dim('⚠ 대응 헤딩 없는 앵커')} 로 알려 줍니다
|
package/dist/mapping.js
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
* 기본 위치는 <base>/.confluence-sync.json 이며 문서셋과 함께 버전관리된다.
|
|
4
4
|
*/
|
|
5
5
|
import { readFileSync, writeFileSync } from 'node:fs';
|
|
6
|
+
/** base 루트에 놓이는 매핑 파일 이름. 이 파일이 있는 곳이 곧 동기화 루트다. */
|
|
7
|
+
export const MAPPING_FILE = '.confluence-sync.json';
|
|
6
8
|
export function loadMapping(path) {
|
|
7
9
|
try {
|
|
8
10
|
return JSON.parse(readFileSync(path, 'utf8'));
|
package/dist/markdown.js
CHANGED
|
@@ -57,7 +57,7 @@ function resolveInternalLink(href) {
|
|
|
57
57
|
const [pathPart, ...rest] = href.split('#');
|
|
58
58
|
if (!pathPart || !/\.md$/i.test(pathPart))
|
|
59
59
|
return null;
|
|
60
|
-
const rel = relative(baseDir, resolve(baseDir, ctx.fileDir, decodePath(pathPart)));
|
|
60
|
+
const rel = relative(baseDir, resolve(baseDir, ctx.fileDir, decodePath(pathPart))).split('\\').join('/');
|
|
61
61
|
const title = ctx.titleIndex[rel];
|
|
62
62
|
if (!title)
|
|
63
63
|
return null;
|
|
@@ -77,6 +77,13 @@ md.renderer.rules.heading_open = (tokens, idx, opts, _env, self) => {
|
|
|
77
77
|
md.renderer.rules.fence = (tokens, idx) => {
|
|
78
78
|
const t = tokens[idx];
|
|
79
79
|
const lang = (t.info || '').trim().split(/\s+/)[0];
|
|
80
|
+
// 별도 호스팅한 PDF.js 등 웹 뷰어를 Confluence iFrame 매크로로 표시.
|
|
81
|
+
if (lang === 'confluence-iframe') {
|
|
82
|
+
const url = t.content.trim();
|
|
83
|
+
if (!/^https?:\/\/[^\s<>]+$/i.test(url))
|
|
84
|
+
throw new Error('confluence-iframe에는 HTTP(S) URL 한 줄이 필요합니다.');
|
|
85
|
+
return `<ac:structured-macro ac:name="iframe"><ac:parameter ac:name="url">${escapeXml(url)}</ac:parameter><ac:parameter ac:name="width">100%</ac:parameter><ac:parameter ac:name="height">900</ac:parameter></ac:structured-macro>\n`;
|
|
86
|
+
}
|
|
80
87
|
const safe = t.content.split(']]>').join(']]]]><![CDATA[>');
|
|
81
88
|
const langParam = lang ? `<ac:parameter ac:name="language">${lang}</ac:parameter>` : '';
|
|
82
89
|
return `<ac:structured-macro ac:name="code">${langParam}<ac:plain-text-body><![CDATA[${safe}]]></ac:plain-text-body></ac:structured-macro>\n`;
|
package/dist/obsidian.js
CHANGED
|
@@ -9,10 +9,8 @@
|
|
|
9
9
|
* frontmatter 는 평면 `key: value` 만 다룬다. 중첩·리스트가 필요할 만큼 복잡한 메타데이터는
|
|
10
10
|
* 이 도구의 관심사가 아니며, 알 수 없는 키는 건드리지 않고 보존한다.
|
|
11
11
|
*/
|
|
12
|
-
import { wikilinkAnchorToSlug } from './anchors.js';
|
|
12
|
+
import { wikilinkAnchorToSlug, WIKILINK_RE, parseWikilink } from './anchors.js';
|
|
13
13
|
const FENCE = /^\s*(?:```|~~~)/;
|
|
14
|
-
// [[대상]] · [[대상|별칭]] · [[대상#헤딩]] · ![[임베드]]
|
|
15
|
-
const WIKILINK = /(!?)\[\[([^\]|#]+)(#[^\]|]+)?(?:\|([^\]]+))?\]\]/g;
|
|
16
14
|
/**
|
|
17
15
|
* 문서 맨 앞의 YAML frontmatter 를 분리한다. 없으면 data 는 빈 객체.
|
|
18
16
|
* `key: value` 한 줄짜리만 인식하고, 값의 감싼 따옴표는 벗긴다.
|
|
@@ -69,17 +67,27 @@ function mapProse(md, fn) {
|
|
|
69
67
|
* 해석에 실패한 링크는 원문 그대로 남겨 (조용히 사라지지 않고) 눈에 띄게 한다.
|
|
70
68
|
*/
|
|
71
69
|
export function resolveWikilinks(md, resolve) {
|
|
72
|
-
return mapProse(md, (s) => s.replace(
|
|
73
|
-
const
|
|
74
|
-
|
|
70
|
+
return mapProse(md, (s) => s.replace(WIKILINK_RE, (whole, bang, target, hash, alias) => {
|
|
71
|
+
const w = parseWikilink(bang, target, hash, alias);
|
|
72
|
+
if (!w)
|
|
73
|
+
return whole;
|
|
74
|
+
// Obsidian 은 섹션을 헤딩 텍스트로 가리키지만(`#1. 신뢰 — Trust Registry`)
|
|
75
|
+
// 표준 마크다운은 슬러그를 쓴다. 블록 참조(`#^id`)는 대응물이 없어 섹션을 떼어 낸다.
|
|
76
|
+
const slug = w.fragment
|
|
77
|
+
? (/\.pdf$/i.test(w.target) ? w.fragment : wikilinkAnchorToSlug(w.fragment))
|
|
78
|
+
: null;
|
|
79
|
+
// [[#헤딩]] — 대상이 없으면 "이 문서의 이 섹션"이다. vault 조회 없이 앵커 링크가 된다.
|
|
80
|
+
if (!w.target) {
|
|
81
|
+
if (!slug)
|
|
82
|
+
return whole; // 블록 참조뿐 → 갈 곳이 없으므로 원문 보존
|
|
83
|
+
return `[${w.alias ?? w.fragment}](#${slug})`;
|
|
84
|
+
}
|
|
85
|
+
const found = resolve(w.target, w.embed);
|
|
75
86
|
if (!found)
|
|
76
87
|
return whole; // 해석 실패 → 원문 보존
|
|
77
|
-
const label =
|
|
78
|
-
if (embed)
|
|
79
|
-
return `})`;
|
|
80
|
-
// Obsidian 은 섹션을 헤딩 텍스트로 가리키지만(`#1. 신뢰 — Trust Registry`)
|
|
81
|
-
// 표준 마크다운은 슬러그를 쓴다. 블록 참조(`#^id`)는 대응물이 없어 링크만 남긴다.
|
|
82
|
-
const slug = hash ? wikilinkAnchorToSlug(hash.slice(1)) : null;
|
|
88
|
+
const label = w.alias ?? w.target;
|
|
89
|
+
if (w.embed)
|
|
90
|
+
return ` && slug ? `#${slug}` : ''))})`;
|
|
83
91
|
return `[${label}](${dest(found + (slug ? `#${slug}` : ''))})`;
|
|
84
92
|
}));
|
|
85
93
|
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFileSync, existsSync, realpathSync } from 'node:fs';
|
|
3
|
+
import { resolve, relative, dirname } from 'node:path';
|
|
4
|
+
import { renderPdfPages } from './pdf-renderer.js';
|
|
5
|
+
import MarkdownIt from 'markdown-it';
|
|
6
|
+
import { collectHeadings } from './anchors.js';
|
|
7
|
+
const encode = (value) => Buffer.from(JSON.stringify(value)).toString('base64');
|
|
8
|
+
const decode = (value) => JSON.parse(Buffer.from(value, 'base64').toString());
|
|
9
|
+
const parser = new MarkdownIt({ html: true });
|
|
10
|
+
/** 소스 위치를 유지하면서 코드·HTML 블록을 제외한 일반 텍스트를 처리한다. */
|
|
11
|
+
export function mapMarkdownText(text, fn) {
|
|
12
|
+
const lines = text.split('\n'), offsets = [0];
|
|
13
|
+
for (const line of lines)
|
|
14
|
+
offsets.push(offsets[offsets.length - 1] + line.length + 1);
|
|
15
|
+
const spans = parser.parse(text, {}).filter(t => ['fence', 'code_block', 'html_block'].includes(t.type) && t.map)
|
|
16
|
+
.map(t => [offsets[t.map[0]], Math.min(offsets[t.map[1]], text.length)]);
|
|
17
|
+
let cursor = 0, result = '';
|
|
18
|
+
const prose = (s) => protectInline(s, fn);
|
|
19
|
+
for (const [start, end] of spans) {
|
|
20
|
+
if (start < cursor)
|
|
21
|
+
continue;
|
|
22
|
+
result += prose(text.slice(cursor, start)) + text.slice(start, end);
|
|
23
|
+
cursor = end;
|
|
24
|
+
}
|
|
25
|
+
return result + prose(text.slice(cursor));
|
|
26
|
+
}
|
|
27
|
+
function protectInline(text, fn) {
|
|
28
|
+
const re = /(`+)([\s\S]*?)(?<!`)\1(?!`)/g;
|
|
29
|
+
let cursor = 0, out = '';
|
|
30
|
+
for (const m of text.matchAll(re)) {
|
|
31
|
+
out += fn(text.slice(cursor, m.index)) + m[0];
|
|
32
|
+
cursor = m.index + m[0].length;
|
|
33
|
+
}
|
|
34
|
+
return out + fn(text.slice(cursor));
|
|
35
|
+
}
|
|
36
|
+
const REF = /\[([^\]\n]*)\]\(#[^\s)]+\)<!-- csync-pdf:v1 ([A-Za-z0-9+/=]+) -->/g;
|
|
37
|
+
const INLINE_REF = /!\[([^\]\n]*)\]\((?:<[^>\n]+>|[^()\s]+)\)<!-- csync-pdf-inline:v1 ([A-Za-z0-9+/=]+) -->/g;
|
|
38
|
+
const GALLERY = /\n*<!-- csync-pdf-pages:v1 -->\n[\s\S]*?<!-- \/csync-pdf-pages -->\n*/g;
|
|
39
|
+
/** 자동 생성한 참조만 복원한다. 일반 이미지·제목은 유지한다. */
|
|
40
|
+
export function restorePdfReferences(text, file, base) {
|
|
41
|
+
const assets = [];
|
|
42
|
+
// 매크로 표식은 코드 예제에서 복원하지 않도록 실제 HTML 토큰만 검사한다.
|
|
43
|
+
const restore = (_all, currentLabel, data) => {
|
|
44
|
+
const ref = decode(data);
|
|
45
|
+
if (typeof ref.path !== 'string' || !/\.pdf$/i.test(ref.path) || !Number.isSafeInteger(ref.page) || ref.page < 1) {
|
|
46
|
+
throw new Error('PDF 역변환 정보의 경로 또는 쪽 번호가 올바르지 않습니다.');
|
|
47
|
+
}
|
|
48
|
+
const abs = resolve(dirname(file), ref.path);
|
|
49
|
+
requireLocal(abs, base);
|
|
50
|
+
if (!existsSync(abs))
|
|
51
|
+
throw new Error(`역변환에 필요한 원본 PDF가 없습니다: ${abs}`);
|
|
52
|
+
assets.push(abs);
|
|
53
|
+
const path = relative(base, abs).split('\\').join('/');
|
|
54
|
+
return `${ref.embed ? '!' : ''}[[${path}#page=${ref.page}|${currentLabel || ref.label}]]`;
|
|
55
|
+
};
|
|
56
|
+
const restored = replaceGeneratedBlocks(replaceGeneratedBlocks(text, INLINE_REF, restore), REF, restore);
|
|
57
|
+
return { text: replaceGeneratedBlocks(restored, GALLERY, () => '\n\n'), assets: [...new Set(assets)], pages: 0 };
|
|
58
|
+
}
|
|
59
|
+
/** 생성 표식을 처리하되 fenced/indented code는 그대로 보존한다. */
|
|
60
|
+
export function replaceGeneratedBlocks(text, re, fn) {
|
|
61
|
+
const lines = text.split('\n'), offsets = [0];
|
|
62
|
+
for (const line of lines)
|
|
63
|
+
offsets.push(offsets[offsets.length - 1] + line.length + 1);
|
|
64
|
+
const spans = parser.parse(text, {}).filter(t => ['fence', 'code_block'].includes(t.type) && t.map)
|
|
65
|
+
.map(t => [offsets[t.map[0]], Math.min(offsets[t.map[1]], text.length)]);
|
|
66
|
+
for (const m of text.matchAll(/(`+)([\s\S]*?)(?<!`)\1(?!`)/g))
|
|
67
|
+
spans.push([m.index, m.index + m[0].length]);
|
|
68
|
+
return text.replace(re, (...args) => {
|
|
69
|
+
const offset = args[args.length - 2];
|
|
70
|
+
const markerOffset = offset + args[0].search(/\S/);
|
|
71
|
+
return spans.some(([s, e]) => markerOffset >= s && markerOffset < e) ? args[0] : fn(...args);
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
function requireLocal(abs, base) {
|
|
75
|
+
const rel = relative(realpathSync(base), existsSync(abs) ? realpathSync(abs) : abs);
|
|
76
|
+
if (rel === '..' || rel.startsWith('../') || rel.startsWith('..\\'))
|
|
77
|
+
throw new Error(`PDF는 --base 안에 있어야 합니다: ${abs}`);
|
|
78
|
+
}
|
|
79
|
+
export async function extractPdfPages(text, file, base, output, dryRun, source = p => p) {
|
|
80
|
+
const assets = new Set(), headings = new Set(collectHeadings(text).map(h => h.slug));
|
|
81
|
+
const pages = new Map();
|
|
82
|
+
const hashes = new Map();
|
|
83
|
+
const jobs = [];
|
|
84
|
+
const link = /(?<!!)(!?)\[([^\]\n]*)\]\((<[^>\n]+>|[^()\s]+)\)/g;
|
|
85
|
+
const convertLinks = (input, inline) => mapMarkdownText(input, part => part.replace(link, (all, bang, label, raw, offset) => {
|
|
86
|
+
// 홀수 개의 백슬래시로 이스케이프한 링크는 예제 텍스트다.
|
|
87
|
+
if ((part.slice(0, offset).match(/\\+$/)?.[0].length ?? 0) % 2)
|
|
88
|
+
return all;
|
|
89
|
+
let dest;
|
|
90
|
+
try {
|
|
91
|
+
dest = decodeURIComponent(raw.replace(/^<(.*)>$/, '$1'));
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return all;
|
|
95
|
+
}
|
|
96
|
+
if (/^[a-z][a-z\d+.-]*:/i.test(dest))
|
|
97
|
+
return all;
|
|
98
|
+
const m = dest.match(/^(.*\.pdf)#page=(\d+)$/i);
|
|
99
|
+
if (!m)
|
|
100
|
+
return all;
|
|
101
|
+
const local = resolve(dirname(file), m[1]);
|
|
102
|
+
const abs = source(local);
|
|
103
|
+
if (abs === local)
|
|
104
|
+
requireLocal(local, base);
|
|
105
|
+
if (!existsSync(abs))
|
|
106
|
+
throw new Error(`PDF 파일을 찾을 수 없습니다: ${abs}`);
|
|
107
|
+
const page = Number(m[2]);
|
|
108
|
+
if (!Number.isSafeInteger(page) || page < 1)
|
|
109
|
+
throw new Error(`잘못된 PDF 쪽 번호: ${dest}`);
|
|
110
|
+
assets.add(abs);
|
|
111
|
+
let hash = hashes.get(abs);
|
|
112
|
+
if (!hash) {
|
|
113
|
+
hash = createHash('sha256').update(readFileSync(abs)).digest('hex').slice(0, 24);
|
|
114
|
+
hashes.set(abs, hash);
|
|
115
|
+
}
|
|
116
|
+
const key = `${hash}-${page}`;
|
|
117
|
+
let entry = pages.get(key);
|
|
118
|
+
if (!entry) {
|
|
119
|
+
let heading = `PDF 원문 ${pages.size + 1} - ${page}쪽`;
|
|
120
|
+
let anchor = `pdf-원문-${pages.size + 1}---${page}쪽`, suffix = 0;
|
|
121
|
+
const initialHeading = heading, initialAnchor = anchor;
|
|
122
|
+
while (headings.has(anchor)) {
|
|
123
|
+
heading = `${initialHeading}-${++suffix}`;
|
|
124
|
+
anchor = `${initialAnchor}-${suffix}`;
|
|
125
|
+
}
|
|
126
|
+
headings.add(anchor);
|
|
127
|
+
const assetRel = `attachments/pdf-pages/pdfjs-${key}.png`;
|
|
128
|
+
const target = resolve(output, assetRel);
|
|
129
|
+
jobs.push({ file: abs, page, target });
|
|
130
|
+
const outputFile = resolve(output, relative(base, file));
|
|
131
|
+
const image = relative(dirname(outputFile), target).split('\\').join('/');
|
|
132
|
+
entry = { heading, anchor, image, gallery: false };
|
|
133
|
+
pages.set(key, entry);
|
|
134
|
+
}
|
|
135
|
+
const ref = { path: m[1], page, label, embed: bang === '!' };
|
|
136
|
+
if (inline)
|
|
137
|
+
return `<!-- csync-pdf-inline:v1 ${encode(ref)} -->`;
|
|
138
|
+
entry.gallery = true;
|
|
139
|
+
return `[${label || `PDF ${page}쪽`}](#${entry.anchor})<!-- csync-pdf:v1 ${encode(ref)} -->`;
|
|
140
|
+
}));
|
|
141
|
+
const withInlineImages = replaceGeneratedBlocks(text, /<!-- csync-footnote:v1 [A-Za-z0-9+/=]+ -->\n[\s\S]*?<!-- \/csync-footnote -->/g, block => convertLinks(block, true));
|
|
142
|
+
const converted = convertLinks(withInlineImages, false);
|
|
143
|
+
if (!pages.size)
|
|
144
|
+
return { text, assets: restorePdfReferences(text, file, base).assets, pages: 0 };
|
|
145
|
+
await renderPdfPages(jobs, dryRun);
|
|
146
|
+
const gallery = [...pages.values()].filter(p => p.gallery).map(p => `### ${p.heading}\n\n`).join('\n\n');
|
|
147
|
+
return { text: converted.trimEnd() + (gallery ? `\n\n<!-- csync-pdf-pages:v1 -->\n${gallery}\n<!-- /csync-pdf-pages -->\n` : '\n'), assets: [...assets], pages: pages.size };
|
|
148
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { readFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
/** npm으로 설치한 렌더러와 글꼴/CMap/WASM 리소스만 사용한다. */
|
|
5
|
+
export async function renderPdfPages(jobs, dryRun) {
|
|
6
|
+
if (!jobs.length)
|
|
7
|
+
return;
|
|
8
|
+
const { getDocument, VerbosityLevel } = await import('pdfjs-dist/legacy/build/pdf.mjs');
|
|
9
|
+
const { createCanvas } = await import('@napi-rs/canvas');
|
|
10
|
+
const require = createRequire(import.meta.url);
|
|
11
|
+
const root = dirname(require.resolve('pdfjs-dist/package.json'));
|
|
12
|
+
// PDF.js는 Windows에서도 '/'로 끝나는 리소스 경로를 요구한다.
|
|
13
|
+
const resourceDir = (name) => join(root, name).split('\\').join('/') + '/';
|
|
14
|
+
const files = new Map();
|
|
15
|
+
for (const job of jobs) {
|
|
16
|
+
const group = files.get(job.file) ?? [];
|
|
17
|
+
group.push(job);
|
|
18
|
+
files.set(job.file, group);
|
|
19
|
+
}
|
|
20
|
+
for (const [file, pages] of files) {
|
|
21
|
+
const task = getDocument({
|
|
22
|
+
data: new Uint8Array(readFileSync(file)),
|
|
23
|
+
cMapUrl: resourceDir('cmaps'),
|
|
24
|
+
cMapPacked: true,
|
|
25
|
+
standardFontDataUrl: resourceDir('standard_fonts'),
|
|
26
|
+
wasmUrl: resourceDir('wasm'),
|
|
27
|
+
useSystemFonts: false,
|
|
28
|
+
verbosity: VerbosityLevel.ERRORS,
|
|
29
|
+
});
|
|
30
|
+
try {
|
|
31
|
+
const pdf = await task.promise;
|
|
32
|
+
for (const { page } of pages) {
|
|
33
|
+
if (page > pdf.numPages)
|
|
34
|
+
throw new Error(`PDF 쪽 범위 초과: ${page} (총 ${pdf.numPages}쪽)`);
|
|
35
|
+
}
|
|
36
|
+
if (dryRun)
|
|
37
|
+
continue;
|
|
38
|
+
for (const { page: pageNumber, target } of pages) {
|
|
39
|
+
if (existsSync(target))
|
|
40
|
+
continue;
|
|
41
|
+
const page = await pdf.getPage(pageNumber);
|
|
42
|
+
const viewport = page.getViewport({ scale: 160 / 72 });
|
|
43
|
+
const canvas = createCanvas(Math.ceil(viewport.width), Math.ceil(viewport.height));
|
|
44
|
+
try {
|
|
45
|
+
await page.render({ canvas: canvas, viewport }).promise;
|
|
46
|
+
const png = await canvas.encode('png');
|
|
47
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
48
|
+
writeFileSync(target, png);
|
|
49
|
+
}
|
|
50
|
+
finally {
|
|
51
|
+
page.cleanup();
|
|
52
|
+
canvas.width = canvas.height = 1;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
throw new Error(`PDF 이미지 변환 실패 (${file}): ${error.message}`);
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
await task.destroy();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
package/dist/pull.js
CHANGED
|
@@ -7,13 +7,18 @@
|
|
|
7
7
|
* 함께 받은 페이지끼리의 내부 링크는 마지막에 상대 .md 경로로 이어 붙인다(Obsidian 그래프·백링크가 살아난다).
|
|
8
8
|
* 각 문서 머리에 pageId 를 담은 frontmatter 를 남겨, 매핑 파일 없이도 push 가 원본 페이지를 다시 찾아간다.
|
|
9
9
|
*/
|
|
10
|
-
import { mkdirSync, writeFileSync, readFileSync } from 'node:fs';
|
|
10
|
+
import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
|
|
11
11
|
import { resolve, join, dirname, basename, relative } from 'node:path';
|
|
12
12
|
import { readEnv, requireEnv } from './config.js';
|
|
13
13
|
import { createClient } from './confluence.js';
|
|
14
14
|
import { htmlToMarkdown, codeLanguagesFromStorage } from './html2md.js';
|
|
15
15
|
import { buildFrontmatter, splitFrontmatter } from './obsidian.js';
|
|
16
16
|
import { collectHeadings, matchConfluenceAnchor } from './anchors.js';
|
|
17
|
+
import { collectMarkdown } from './docs.js';
|
|
18
|
+
import { buildTreeRenderer } from './render.js';
|
|
19
|
+
import { docHash } from './markdown.js';
|
|
20
|
+
import { buildIgnorer } from './ignore.js';
|
|
21
|
+
import { loadMapping, saveMapping, MAPPING_FILE } from './mapping.js';
|
|
17
22
|
import { cyan, dim, green, red, yellow } from './colors.js';
|
|
18
23
|
// 첨부 이미지는 항상 <md위치>/attachments/<문서명>/ 하위에 저장한다.
|
|
19
24
|
const ASSETS_DIR = 'attachments';
|
|
@@ -112,6 +117,7 @@ async function pullNode(ctx, id, destDir) {
|
|
|
112
117
|
if (node.type === 'folder') {
|
|
113
118
|
const dir = join(destDir, slug(node.title));
|
|
114
119
|
mkdirSync(dir, { recursive: true });
|
|
120
|
+
ctx.folderIds.set(dir, id);
|
|
115
121
|
console.log(` ${cyan('📁 폴더')} ${dir} ${dim(`(#${id})`)}`);
|
|
116
122
|
const counts = { pages: 0, folders: 1 };
|
|
117
123
|
if (withChildren) {
|
|
@@ -201,7 +207,7 @@ export function relinkPass(written, pathById, wikilinks, info) {
|
|
|
201
207
|
let count = 0;
|
|
202
208
|
for (const file of written) {
|
|
203
209
|
const before = readFileSync(file, 'utf8');
|
|
204
|
-
|
|
210
|
+
let after = before.replace(PAGE_LINK, (whole, text, url, id) => {
|
|
205
211
|
const target = pathById.get(id);
|
|
206
212
|
if (!target || target === file)
|
|
207
213
|
return whole; // 못 받은 페이지·자기 자신 → 원본 URL 유지
|
|
@@ -219,15 +225,89 @@ export function relinkPass(written, pathById, wikilinks, info) {
|
|
|
219
225
|
const rel = relative(dirname(file), target).split('\\').join('/');
|
|
220
226
|
return `[${label}](${dest(rel + (slug ? `#${slug}` : ''))})`;
|
|
221
227
|
});
|
|
228
|
+
// 같은 문서 섹션도 vault 표기로: [§4](#슬러그) → [[#헤딩 텍스트|§4]]
|
|
229
|
+
if (wikilinks) {
|
|
230
|
+
after = after.replace(SELF_ANCHOR, (whole, text, frag) => {
|
|
231
|
+
const heading = info?.get(file)?.textBySlug.get(frag);
|
|
232
|
+
if (!heading)
|
|
233
|
+
return whole;
|
|
234
|
+
count++;
|
|
235
|
+
const label = text.trim();
|
|
236
|
+
return label && label !== heading ? `[[#${heading}|${label}]]` : `[[#${heading}]]`;
|
|
237
|
+
});
|
|
238
|
+
}
|
|
222
239
|
if (after !== before)
|
|
223
240
|
writeFileSync(file, after);
|
|
224
241
|
}
|
|
225
242
|
return count;
|
|
226
243
|
}
|
|
244
|
+
/**
|
|
245
|
+
* 매핑 파일(.confluence-sync.json)이 있는 가장 가까운 상위 디렉토리 = 동기화 루트.
|
|
246
|
+
* pull 을 base 하위 폴더로 받아도(`--out <base>/어딘가`) 매핑 키가 base 기준으로 남아야
|
|
247
|
+
* 이어지는 push 가 같은 문서를 알아본다.
|
|
248
|
+
*/
|
|
249
|
+
function findSyncBase(from) {
|
|
250
|
+
for (let d = from;; d = dirname(d)) {
|
|
251
|
+
if (existsSync(join(d, MAPPING_FILE)))
|
|
252
|
+
return d;
|
|
253
|
+
if (dirname(d) === d)
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* 받아온 문서를 매핑 파일에 기록한다.
|
|
259
|
+
*
|
|
260
|
+
* 이게 없으면 pull 직후의 push 가 모든 문서를 "변경"으로 본다 — 매핑에 해시가 없으니
|
|
261
|
+
* 방금 받아온 내용을 그대로 되올려 보내려 한다. 해시는 push 가 쓰는 것과 **같은 렌더러**로
|
|
262
|
+
* 계산해야 하므로(buildTreeRenderer) 트리 전체를 한 번 훑는다.
|
|
263
|
+
*
|
|
264
|
+
* 반환값은 기록한 항목 수.
|
|
265
|
+
*/
|
|
266
|
+
export function mappingPass(baseDir, mappingPath, pathById, folderIds) {
|
|
267
|
+
const posix = (p) => p.split('\\').join('/');
|
|
268
|
+
const ignorer = buildIgnorer(baseDir, []);
|
|
269
|
+
const rels = collectMarkdown(baseDir)
|
|
270
|
+
.map((f) => posix(relative(baseDir, f)))
|
|
271
|
+
.filter((r) => !ignorer.ignores(r));
|
|
272
|
+
const { docs, render } = buildTreeRenderer(baseDir, rels);
|
|
273
|
+
const m = loadMapping(mappingPath);
|
|
274
|
+
// 같은 페이지가 다른 키에 남아 있으면(문서가 옮겨졌으면) 중복 항목이 된다 → 옛 키를 걷어낸다
|
|
275
|
+
const ids = new Set([...pathById.keys(), ...folderIds.values()]);
|
|
276
|
+
for (const [k, v] of Object.entries(m))
|
|
277
|
+
if (ids.has(v.pageId))
|
|
278
|
+
delete m[k];
|
|
279
|
+
let n = 0;
|
|
280
|
+
for (const [id, abs] of pathById) {
|
|
281
|
+
const rel = posix(relative(baseDir, abs));
|
|
282
|
+
const doc = rel.startsWith('..') ? undefined : docs[rel];
|
|
283
|
+
if (!doc)
|
|
284
|
+
continue; // base 밖으로 받았거나 제외 규칙에 걸린 문서는 기록하지 않는다
|
|
285
|
+
m[rel] = { pageId: id, title: doc.title, hash: docHash(doc.title, render(rel, doc.body, doc.title)) };
|
|
286
|
+
n++;
|
|
287
|
+
}
|
|
288
|
+
for (const [dir, id] of folderIds) {
|
|
289
|
+
const rel = posix(relative(baseDir, dir));
|
|
290
|
+
if (!rel || rel.startsWith('..'))
|
|
291
|
+
continue;
|
|
292
|
+
m[`${rel}/`] = { pageId: id, title: basename(dir), type: 'folder' };
|
|
293
|
+
n++;
|
|
294
|
+
}
|
|
295
|
+
saveMapping(mappingPath, m);
|
|
296
|
+
return n;
|
|
297
|
+
}
|
|
227
298
|
export async function runPull(argv) {
|
|
228
299
|
const withChildren = argv.includes('--children');
|
|
229
300
|
const wholeSpace = argv.includes('--space');
|
|
230
301
|
const outDir = resolve(optVal(argv, '--out') ?? process.cwd());
|
|
302
|
+
// 매핑을 어디에 남길지. base 는 매핑 키의 기준이므로 push 때의 --base 와 같아야 한다.
|
|
303
|
+
const noMapping = argv.includes('--no-mapping');
|
|
304
|
+
const baseOpt = optVal(argv, '--base');
|
|
305
|
+
const baseDir = resolve(baseOpt ?? findSyncBase(outDir) ?? outDir);
|
|
306
|
+
const mappingPath = resolve(optVal(argv, '--mapping') ?? join(baseDir, MAPPING_FILE));
|
|
307
|
+
if (!noMapping && relative(baseDir, outDir).startsWith('..')) {
|
|
308
|
+
console.error(red(`✗ --out 이 base 밖입니다: ${outDir}`) + dim(`\n base: ${baseDir} (--base 로 맞추거나 --no-mapping 을 쓰세요)`));
|
|
309
|
+
process.exit(1);
|
|
310
|
+
}
|
|
231
311
|
const env = readEnv();
|
|
232
312
|
requireEnv(env);
|
|
233
313
|
const client = createClient({ baseUrl: env.baseUrl, email: env.email, token: env.token }, { force: false, verify: false });
|
|
@@ -238,14 +318,20 @@ export async function runPull(argv) {
|
|
|
238
318
|
spaceKey: env.spaceKey,
|
|
239
319
|
written: [],
|
|
240
320
|
pathById: new Map(),
|
|
321
|
+
folderIds: new Map(),
|
|
241
322
|
};
|
|
242
323
|
const done = (c) => {
|
|
243
324
|
// 앵커를 먼저 슬러그로 바꾼 뒤 링크를 상대경로로 옮긴다(순서가 바뀌면 어느 페이지의 헤딩인지 알 수 없다).
|
|
244
325
|
const { count: anchors, info } = anchorPass(ctx.written, ctx.pathById);
|
|
245
326
|
const relinked = relinkPass(ctx.written, ctx.pathById, ctx.obsidian, info);
|
|
327
|
+
// 본문이 다 정해진 뒤에 기록해야 해시가 맞는다
|
|
328
|
+
const mapped = noMapping ? 0 : mappingPass(baseDir, mappingPath, ctx.pathById, ctx.folderIds);
|
|
246
329
|
console.log(`\n${green('완료')} 페이지 ${c.pages}개${c.folders ? `, 폴더 ${c.folders}개` : ''} 생성` +
|
|
247
330
|
(relinked ? ` ${cyan(`내부링크 ${relinked}개 연결`)}` : '') +
|
|
248
|
-
(anchors ? ` ${cyan(`섹션링크 ${anchors}개 복원`)}` : '')
|
|
331
|
+
(anchors ? ` ${cyan(`섹션링크 ${anchors}개 복원`)}` : '') +
|
|
332
|
+
(mapped ? ` ${cyan(`매핑 ${mapped}건`)}` : ''));
|
|
333
|
+
if (mapped)
|
|
334
|
+
console.log(dim(` 매핑: ${mappingPath} (base: ${baseDir})`));
|
|
249
335
|
};
|
|
250
336
|
// --space: 스페이스 홈페이지(콘텐츠 트리 루트)부터 전체를 재귀적으로 가져옴
|
|
251
337
|
if (wholeSpace) {
|
|
@@ -262,7 +348,7 @@ export async function runPull(argv) {
|
|
|
262
348
|
// 위치 인자: ['pull', <pageId|url>, ...]
|
|
263
349
|
const positionals = [];
|
|
264
350
|
for (let i = 0; i < argv.length; i++) {
|
|
265
|
-
if (argv[i] === '--out') {
|
|
351
|
+
if (argv[i] === '--out' || argv[i] === '--base' || argv[i] === '--mapping') {
|
|
266
352
|
i++;
|
|
267
353
|
continue;
|
|
268
354
|
}
|
package/dist/render.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 문서 트리 하나를 storage format 으로 옮기는 렌더러.
|
|
3
|
+
*
|
|
4
|
+
* 링크·섹션 링크는 **트리 전체**를 알아야 풀린다(다른 문서의 제목과 헤딩을 참조하므로),
|
|
5
|
+
* 그래서 문서 하나가 아니라 트리 단위로 만든다.
|
|
6
|
+
*
|
|
7
|
+
* push(sync)와 pull 의 매핑 기록이 **같은 문서에 같은 해시**를 내야 하므로 —
|
|
8
|
+
* 어긋나면 pull 직후 push 가 전부 "변경"으로 보인다 — 두 곳이 이 함수 하나를 함께 쓴다.
|
|
9
|
+
*/
|
|
10
|
+
import { relative } from 'node:path';
|
|
11
|
+
import { toStorage } from './markdown.js';
|
|
12
|
+
import { buildDocIndex, buildVault, collectAssets, vaultResolver, resolveAnchorTargets, } from './docs.js';
|
|
13
|
+
export function buildTreeRenderer(baseDir, rels) {
|
|
14
|
+
const { titles, anchors, docs } = buildDocIndex(rels, baseDir);
|
|
15
|
+
// Obsidian [[wikilink]] 해석용 이름 인덱스
|
|
16
|
+
const vault = buildVault(rels, collectAssets(baseDir).map((f) => relative(baseDir, f)));
|
|
17
|
+
// 누가 어느 헤딩을 가리키는지 먼저 모은다 — 그 헤딩에만 Anchor 매크로를 심는다.
|
|
18
|
+
const anchorTargets = resolveAnchorTargets(docs, vault);
|
|
19
|
+
const render = (rel, body, title) => toStorage(body, rel, titles, baseDir, {
|
|
20
|
+
resolveLink: vaultResolver(rel, vault),
|
|
21
|
+
anchorIndex: anchors,
|
|
22
|
+
title,
|
|
23
|
+
anchorNames: anchorTargets[rel],
|
|
24
|
+
bodySlugs: docs[rel]?.bodySlugs,
|
|
25
|
+
});
|
|
26
|
+
return { titles, docs, render };
|
|
27
|
+
}
|
package/dist/sync.js
CHANGED
|
@@ -17,8 +17,9 @@ import 'dotenv/config';
|
|
|
17
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 {
|
|
21
|
-
import {
|
|
20
|
+
import { docHash } from './markdown.js';
|
|
21
|
+
import { buildTreeRenderer } from './render.js';
|
|
22
|
+
import { collectMarkdown, buildFolderIndex, readDoc, parentKeyOf, containerKeyOf, sortForSync, resolveSelection, withParents, } from './docs.js';
|
|
22
23
|
import { loadMapping, saveMapping } from './mapping.js';
|
|
23
24
|
import { createClient } from './confluence.js';
|
|
24
25
|
import { runInit } from './init.js';
|
|
@@ -104,24 +105,13 @@ async function main() {
|
|
|
104
105
|
if (!DRY_RUN && !LIST)
|
|
105
106
|
requireEnv(env); // list/dry 는 Confluence 호출이 없어 인증 불필요
|
|
106
107
|
const ignorer = buildIgnorer(BASE_DIR, EXCLUDES);
|
|
107
|
-
const collected = collectMarkdown(BASE_DIR).map((f) => relative(BASE_DIR, f));
|
|
108
|
+
const collected = collectMarkdown(BASE_DIR).map((f) => relative(BASE_DIR, f).split('\\').join('/'));
|
|
108
109
|
const allRel = collected.filter((r) => !ignorer.ignores(r)); // 제외된 문서는 모든 단계에서 빠짐
|
|
109
110
|
const ignoredCount = collected.length - allRel.length;
|
|
110
111
|
const folderIndex = buildFolderIndex(allRel);
|
|
111
|
-
// 링크·섹션 링크 변환은 항상 전체
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
});
|
|
112
|
+
// 링크·섹션 링크 변환은 항상 트리 전체 기준이다. 선택 동기화여도 대상 문서의 제목·헤딩을 알아야 하고,
|
|
113
|
+
// 다른 문서가 이 문서의 섹션을 가리킬 수 있다. 제외된 문서는 링크 대상에서도 빠진다.
|
|
114
|
+
const { titles: titleIndex, render } = buildTreeRenderer(BASE_DIR, allRel);
|
|
125
115
|
// 사용자가 실제로 고른 문서. 경로 인자가 없으면 전체가 대상이다.
|
|
126
116
|
const picked = positionals.length ? resolveSelection(allRel, positionals, BASE_DIR) : allRel;
|
|
127
117
|
const explicit = new Set(picked);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wonseok-han/confluence-sync",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Confluence Cloud ↔ Markdown 양방향 동기화 CLI — 마크다운을 Confluence 페이지로 발행(push)하고, Confluence 페이지·폴더·스페이스를 마크다운으로 가져옵니다(pull).",
|
|
6
6
|
"keywords": [
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"url": "https://github.com/wonseok-han/confluence-sync/issues"
|
|
24
24
|
},
|
|
25
25
|
"engines": {
|
|
26
|
-
"node": ">=
|
|
26
|
+
"node": ">=22.13.0"
|
|
27
27
|
},
|
|
28
28
|
"bin": {
|
|
29
29
|
"confluence-sync": "dist/sync.js",
|
|
@@ -36,23 +36,29 @@
|
|
|
36
36
|
],
|
|
37
37
|
"scripts": {
|
|
38
38
|
"build": "tsc",
|
|
39
|
+
"test": "npm run build && node --test tests/*.test.mjs",
|
|
39
40
|
"prepublishOnly": "npm run build",
|
|
40
41
|
"sync": "tsx src/sync.ts",
|
|
41
42
|
"sync:dry": "tsx src/sync.ts --dry-run",
|
|
42
43
|
"sync:rebuild": "tsx src/sync.ts --rebuild",
|
|
43
|
-
"list": "tsx src/sync.ts --list"
|
|
44
|
+
"list": "tsx src/sync.ts --list",
|
|
45
|
+
"test:coverage": "npm run build && node --test --experimental-test-coverage --test-coverage-include=\"dist/**/*.js\" tests/*.test.mjs"
|
|
44
46
|
},
|
|
45
47
|
"publishConfig": {
|
|
46
48
|
"access": "public"
|
|
47
49
|
},
|
|
48
50
|
"dependencies": {
|
|
51
|
+
"@napi-rs/canvas": "^1.0.9",
|
|
49
52
|
"dotenv": "^16.4.5",
|
|
50
53
|
"ignore": "^7.0.5",
|
|
51
54
|
"markdown-it": "^14.1.0",
|
|
55
|
+
"markdown-it-footnote": "^4.0.0",
|
|
56
|
+
"pdfjs-dist": "^6.3.289",
|
|
52
57
|
"turndown": "^7.2.4"
|
|
53
58
|
},
|
|
54
59
|
"devDependencies": {
|
|
55
60
|
"@types/markdown-it": "^14.1.2",
|
|
61
|
+
"@types/markdown-it-footnote": "^3.0.4",
|
|
56
62
|
"@types/node": "^22.0.0",
|
|
57
63
|
"@types/turndown": "^5.0.6",
|
|
58
64
|
"tsx": "^4.19.0",
|