@wonseok-han/confluence-sync 1.2.1 → 1.3.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 -0
- package/dist/anchors.js +27 -5
- package/dist/convert.js +17 -5
- package/dist/docs.js +2 -1
- package/dist/help.js +7 -2
- package/dist/mapping.js +2 -0
- package/dist/obsidian.js +17 -11
- package/dist/pull.js +90 -4
- package/dist/render.js +27 -0
- package/dist/sync.js +6 -16
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -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,7 @@ 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]]`. 표 안에서 `\|` 로 이스케이프된 별칭도 인식합니다.
|
|
278
300
|
|
|
279
301
|
### 보정 (`--fix`)
|
|
280
302
|
|
|
@@ -296,6 +318,8 @@ confluence-sync convert --to obsidian --fix ./docs --out ~/MyVault
|
|
|
296
318
|
| vault 에서 쓴 것 | Confluence 에 올라가는 것 |
|
|
297
319
|
| --- | --- |
|
|
298
320
|
| `[[설계]]` · `[[구조/설계\|설계 문서]]` | 페이지 링크 (짧은 이름·경로 모두 인식) |
|
|
321
|
+
| `[[설계#3. 구성]]` | 그 페이지의 그 섹션 |
|
|
322
|
+
| `[[#3. 구성]]` · `[[#3. 구성\|§3]]` | **같은 페이지**의 그 섹션 |
|
|
299
323
|
| `![[diagram.png]]` | 첨부 이미지 |
|
|
300
324
|
| frontmatter `title:` | 페이지 제목 |
|
|
301
325
|
| frontmatter `pageId:` | **그 페이지를 갱신** (새로 만들지 않음) |
|
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;
|
package/dist/convert.js
CHANGED
|
@@ -62,10 +62,19 @@ function commonAncestor(paths) {
|
|
|
62
62
|
* wikilink 는 vault 어디서든 "이름"으로 찾으므로, 파일명이 트리 안에서 유일할 때만 바꾼다
|
|
63
63
|
* (겹치면 엉뚱한 노트로 이어질 수 있어 상대 링크를 그대로 둔다).
|
|
64
64
|
*/
|
|
65
|
-
function toObsidian(text, fileAbs, baseDir, nameCount, headingOf) {
|
|
65
|
+
function toObsidian(text, fileAbs, baseDir, nameCount, headingOf, selfHeadingOf) {
|
|
66
66
|
return linksToWikilinks(text, (dest, label) => {
|
|
67
|
-
if (/^(https?:|mailto
|
|
68
|
-
return null; // 외부
|
|
67
|
+
if (/^(https?:|mailto:)/i.test(dest))
|
|
68
|
+
return null; // 외부 링크
|
|
69
|
+
// 같은 문서 섹션: [§4](#슬러그) → [[#헤딩 텍스트|§4]]
|
|
70
|
+
// Obsidian 은 슬러그가 아니라 헤딩 텍스트로 섹션을 가리키므로, 헤딩을 못 찾으면 손대지 않는다.
|
|
71
|
+
if (dest.startsWith('#')) {
|
|
72
|
+
const heading = selfHeadingOf(decode(dest.slice(1)));
|
|
73
|
+
if (!heading)
|
|
74
|
+
return null;
|
|
75
|
+
const l = label.trim();
|
|
76
|
+
return l && l !== heading ? `[[#${heading}|${l}]]` : `[[#${heading}]]`;
|
|
77
|
+
}
|
|
69
78
|
const [pathPart, ...hash] = decode(dest).split('#');
|
|
70
79
|
if (!/\.md$/i.test(pathPart))
|
|
71
80
|
return null;
|
|
@@ -207,8 +216,11 @@ export async function runConvert(argv) {
|
|
|
207
216
|
notes.push(dim(`보정 ${n}`));
|
|
208
217
|
}
|
|
209
218
|
}
|
|
210
|
-
if (to === 'obsidian')
|
|
211
|
-
|
|
219
|
+
if (to === 'obsidian') {
|
|
220
|
+
// 같은 문서 앵커는 보정(--fix)까지 끝난 **지금 본문**의 헤딩으로 풀어야 맞는다
|
|
221
|
+
const selfIdx = buildAnchorIndex(splitFrontmatter(text).body);
|
|
222
|
+
text = toObsidian(text, abs, baseDir, nameCount, headingOf, (s) => selfIdx.get(s.toLowerCase()));
|
|
223
|
+
}
|
|
212
224
|
else if (to === 'markdown')
|
|
213
225
|
text = resolveWikilinks(text, vaultResolver(rel, vault));
|
|
214
226
|
// 제자리 변환이면 바뀐 것만 쓰지만, --out 이면 대상 전부를 내보낸다(온전한 트리가 나와야 하므로)
|
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 {
|
package/dist/help.js
CHANGED
|
@@ -50,8 +50,13 @@ ${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
|
|
|
@@ -72,7 +77,7 @@ ${h('convert(이미 받아둔 .md 손보기) 옵션:')} ${dim('Confluence 호출
|
|
|
72
77
|
|
|
73
78
|
${h('섹션 링크(앵커):')}
|
|
74
79
|
${o('[§1 개요](#1-개요)')} 처럼 헤딩을 가리키는 링크가 Confluence 에서도 동작합니다.
|
|
75
|
-
같은 문서(${o('#슬러그')})·다른 문서(${o('문서.md#슬러그')}
|
|
80
|
+
같은 문서(${o('#슬러그')} · ${o('[[#헤딩]]')})·다른 문서(${o('문서.md#슬러그')} · ${o('[[문서#헤딩]]')}) 모두 지원합니다.
|
|
76
81
|
Confluence 의 자동 헤딩 id 는 배포판마다 달라서, 가리켜지는 헤딩 앞에 ${dim('Anchor 매크로')}로
|
|
77
82
|
이름을 직접 심고 그 이름을 가리킵니다. pull 은 반대로 슬러그를 되돌립니다.
|
|
78
83
|
대응하는 헤딩을 못 찾으면 링크를 바꾸지 않고 ${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/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,25 @@ 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 ? wikilinkAnchorToSlug(w.fragment) : null;
|
|
77
|
+
// [[#헤딩]] — 대상이 없으면 "이 문서의 이 섹션"이다. vault 조회 없이 앵커 링크가 된다.
|
|
78
|
+
if (!w.target) {
|
|
79
|
+
if (!slug)
|
|
80
|
+
return whole; // 블록 참조뿐 → 갈 곳이 없으므로 원문 보존
|
|
81
|
+
return `[${w.alias ?? w.fragment}](#${slug})`;
|
|
82
|
+
}
|
|
83
|
+
const found = resolve(w.target, w.embed);
|
|
75
84
|
if (!found)
|
|
76
85
|
return whole; // 해석 실패 → 원문 보존
|
|
77
|
-
const label =
|
|
78
|
-
if (embed)
|
|
86
|
+
const label = w.alias ?? w.target;
|
|
87
|
+
if (w.embed)
|
|
79
88
|
return `})`;
|
|
80
|
-
// Obsidian 은 섹션을 헤딩 텍스트로 가리키지만(`#1. 신뢰 — Trust Registry`)
|
|
81
|
-
// 표준 마크다운은 슬러그를 쓴다. 블록 참조(`#^id`)는 대응물이 없어 링크만 남긴다.
|
|
82
|
-
const slug = hash ? wikilinkAnchorToSlug(hash.slice(1)) : null;
|
|
83
89
|
return `[${label}](${dest(found + (slug ? `#${slug}` : ''))})`;
|
|
84
90
|
}));
|
|
85
91
|
}
|
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';
|
|
@@ -108,20 +109,9 @@ async function main() {
|
|
|
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