@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 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(/\[\[([^\]|#]+)(?:#[^\]|]+)?(?:\|([^\]]+))?\]\]/g, (_m, t, a) => a ?? t)
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(WIKILINK)) {
146
- if (m[1] === '!' || !m[3])
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: m[2].trim(), fragment: m[3].slice(1), wiki: true });
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:|#)/i.test(dest))
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
- text = toObsidian(text, abs, baseDir, nameCount, headingOf);
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
- target = vault.notes[la.dest.toLowerCase().replace(/\.md$/i, '')] ?? null;
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')} 내부 링크를 [[wikilink]] 로 출력(기본: 어디서나 열리는 상대 .md 링크)
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#슬러그')})·Obsidian(${o('[[문서#헤딩]]')}) 모두 지원합니다.
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(WIKILINK, (whole, bang, target, hash, alias) => {
73
- const embed = bang === '!';
74
- const found = resolve(target.trim(), embed);
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 = (alias ?? target).trim();
78
- if (embed)
86
+ const label = w.alias ?? w.target;
87
+ if (w.embed)
79
88
  return `![${label}](${dest(found)})`;
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
- const after = before.replace(PAGE_LINK, (whole, text, url, id) => {
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 { toStorage, docHash } from './markdown.js';
21
- import { collectMarkdown, collectAssets, buildDocIndex, resolveAnchorTargets, buildFolderIndex, buildVault, vaultResolver, readDoc, parentKeyOf, containerKeyOf, sortForSync, resolveSelection, withParents, } from './docs.js';
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
- 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
- });
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.2.1",
3
+ "version": "1.3.0",
4
4
  "type": "module",
5
5
  "description": "Confluence Cloud ↔ Markdown 양방향 동기화 CLI — 마크다운을 Confluence 페이지로 발행(push)하고, Confluence 페이지·폴더·스페이스를 마크다운으로 가져옵니다(pull).",
6
6
  "keywords": [