@wonseok-han/confluence-sync 1.7.0 → 1.8.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 +2 -1
- package/dist/confluence/client.js +37 -30
- package/dist/documents/markdown.js +37 -8
- package/dist/sync/push.js +12 -4
- package/dist/web/sync.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -199,6 +199,7 @@ docs/ → [CONFLUENCE_PARENT_ID 또는 공간 최상위]
|
|
|
199
199
|
- `mermaid` 코드블록 뒤에는 **Mermaid diagram** 매크로를 자동 삽입합니다. Confluence에 [Atlassian Labs Mermaid Diagrams Viewer](https://marketplace.atlassian.com/apps/1232887/mermaid-diagrams-viewer)가 설치되어 있어야 하며, 원본 코드도 함께 표시됩니다. 자동 연결은 등장 순서를 사용하므로 페이지의 모든 Mermaid 코드는 `mermaid` 코드블록으로 작성하세요.
|
|
200
200
|
- **내부 `.md` 링크**와 **`[[wikilink]]`** → Confluence 페이지 링크로 자동 변환.
|
|
201
201
|
- **섹션 링크** `[§1 개요](#1-개요)` · `[§6](./04-구현체.md#6-zkp)` · `[[문서#헤딩 텍스트]]` → Confluence 앵커 링크. [아래 참조](#섹션-링크-앵커)
|
|
202
|
+
- **일반 파일 링크** `[자료](<attachments/자료.pdf>)` → PDF·TXT·MMD·JSON 등 실제 로컬 파일을 페이지 첨부로 업로드하고 다운로드 링크로 변환합니다. 경로는 Markdown 문서 위치 기준이며, 한글·공백 경로와 반복 링크를 지원합니다. 문서 링크(`.md`)·외부 URL·없는 파일은 기존 링크를 유지합니다. 같은 페이지에서 서로 다른 파일의 파일명이 같으면 업로드 이름에 경로 기반 접미사를 붙여 구분합니다.
|
|
202
203
|
- **로컬 이미지**와 **`![[embed]]`** → 페이지 첨부로 업로드 후 참조 (같은 파일명은 새 버전으로 갱신). 외부 URL 이미지는 그대로.
|
|
203
204
|
- **YAML frontmatter** 는 메타데이터로만 쓰이고 본문에 새지 않습니다.
|
|
204
205
|
|
|
@@ -250,7 +251,7 @@ pull 은 반대로 Confluence 앵커를 마크다운 슬러그로 되돌립니
|
|
|
250
251
|
|
|
251
252
|
### 변경 감지와 매핑
|
|
252
253
|
|
|
253
|
-
- 문서의 `제목 + 본문 + 참조
|
|
254
|
+
- 문서의 `제목 + 본문 + 참조 첨부 내용` 해시를 저장해두고, **바뀐 문서만** 새 버전으로 올립니다(`= 변경없음` 은 API 호출조차 하지 않음). `--force` 로 무시할 수 있습니다.
|
|
254
255
|
- 매핑은 **`<base>/.confluence-sync.json`** 에 저장됩니다. **문서셋과 함께 커밋하세요** — 지우면 다음 실행에서 페이지가 중복 생성됩니다. (`--mapping <path>` 로 위치 변경 가능)
|
|
255
256
|
- **pull 도 같은 매핑에 기록합니다** — 그래서 pull 직후 push 하면 `= 변경없음` 입니다. [Pull → 매핑 기록](#매핑-기록) 참고.
|
|
256
257
|
- Confluence 에서 페이지가 삭제됐다면 자동으로 **재생성**하고, 그 페이지를 가리키던 문서들도 **링크를 다시 연결**합니다. 내용이 그대로라 스킵되는 경우까지 잡으려면 `--verify` 를 씁니다.
|
|
@@ -70,54 +70,61 @@ export function createClient(cfg, opts) {
|
|
|
70
70
|
}
|
|
71
71
|
/** 페이지의 기존 첨부 중 같은 파일명의 attachmentId 를 찾는다(없으면 null). */
|
|
72
72
|
async function findAttachmentId(pageId, filename) {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
return data?.results?.[0]?.id ?? null;
|
|
76
|
-
}
|
|
77
|
-
catch {
|
|
78
|
-
return null;
|
|
79
|
-
}
|
|
73
|
+
const data = await api(`/rest/api/content/${pageId}/child/attachment?filename=${encodeURIComponent(filename)}`);
|
|
74
|
+
return data?.results?.[0]?.id ?? null;
|
|
80
75
|
}
|
|
81
76
|
/**
|
|
82
|
-
* 로컬
|
|
77
|
+
* 로컬 파일을 페이지 첨부로 업로드(v1, multipart).
|
|
83
78
|
* 같은 파일명 첨부가 이미 있으면 데이터만 새 버전으로 갱신(중복 생성 400 방지),
|
|
84
79
|
* 없으면 신규 생성한다.
|
|
85
80
|
*/
|
|
86
81
|
async function uploadAttachment(pageId, filename, abs) {
|
|
87
82
|
const buf = readFileSync(abs);
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
83
|
+
// 롤백 오류에만 재시도한다. 매번 첨부를 재조회해 생성/갱신 경로를 다시 결정한다.
|
|
84
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
85
|
+
const attId = await findAttachmentId(pageId, filename);
|
|
86
|
+
const path = attId
|
|
87
|
+
? `/rest/api/content/${pageId}/child/attachment/${attId}/data`
|
|
88
|
+
: `/rest/api/content/${pageId}/child/attachment`;
|
|
89
|
+
const form = new FormData();
|
|
90
|
+
form.append('file', new Blob([buf]), filename);
|
|
91
|
+
form.append('minorEdit', 'true');
|
|
92
|
+
const res = await fetch(`${cfg.baseUrl}${path}`, {
|
|
93
|
+
method: 'POST',
|
|
94
|
+
headers: { Authorization: authHeader(), 'X-Atlassian-Token': 'nocheck' },
|
|
95
|
+
body: form,
|
|
96
|
+
});
|
|
97
|
+
if (res.ok)
|
|
98
|
+
return;
|
|
99
|
+
const detail = await res.text();
|
|
100
|
+
if (res.status === 500 && /UnexpectedRollbackException|rollback-only/i.test(detail) && attempt < 2) {
|
|
101
|
+
console.error(yellow(` ↻ 첨부 트랜잭션 롤백, 재시도 ${attempt + 1}/2: ${filename}`));
|
|
102
|
+
await new Promise(resolve => setTimeout(resolve, 1000 * 2 ** attempt));
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const traceId = res.headers.get('atl-traceid') ?? res.headers.get('x-arequestid');
|
|
106
|
+
throw new Error(`attachment ${res.status} ${res.statusText}\nPOST ${path}${traceId ? `\ntraceId: ${traceId}` : ''}\n${detail}`);
|
|
107
|
+
}
|
|
102
108
|
}
|
|
103
|
-
/** 페이지의 로컬
|
|
104
|
-
async function
|
|
109
|
+
/** 페이지의 로컬 첨부를 업로드. 성공 개수 반환. */
|
|
110
|
+
async function uploadAttachments(pageId, attachments, baseDir) {
|
|
105
111
|
let ok = 0;
|
|
106
|
-
for (const
|
|
107
|
-
if (!existsSync(
|
|
108
|
-
console.error(yellow(` ⚠
|
|
112
|
+
for (const file of attachments) {
|
|
113
|
+
if (!existsSync(file.abs)) {
|
|
114
|
+
console.error(yellow(` ⚠ 첨부 없음: ${relative(baseDir, file.abs)}`));
|
|
109
115
|
continue;
|
|
110
116
|
}
|
|
111
117
|
try {
|
|
112
|
-
await uploadAttachment(pageId,
|
|
118
|
+
await uploadAttachment(pageId, file.filename, file.abs);
|
|
113
119
|
ok++;
|
|
114
120
|
}
|
|
115
121
|
catch (e) {
|
|
116
|
-
console.error(red(` ✗
|
|
122
|
+
console.error(red(` ✗ 첨부 업로드 실패 ${file.filename}`) + `\n${e.message}`);
|
|
117
123
|
}
|
|
118
124
|
}
|
|
119
125
|
return ok;
|
|
120
126
|
}
|
|
127
|
+
const uploadImages = uploadAttachments;
|
|
121
128
|
/** 페이지 생성/갱신. 삭제 감지 시 재생성. 변경 없으면(opts.verify 아니면) 호출 없이 skip. */
|
|
122
129
|
async function upsertPage(mapping, spaceId, key, title, storage, hash, parentId, forceUpdate = false) {
|
|
123
130
|
const existing = mapping[key];
|
|
@@ -362,7 +369,7 @@ export function createClient(cfg, opts) {
|
|
|
362
369
|
await api(`/api/v2/folders/${id}`, { method: 'DELETE' });
|
|
363
370
|
}
|
|
364
371
|
return {
|
|
365
|
-
api, getPageOrNull, getSpaceId, getSpaceInfo, deleteAll, uploadImages, upsertPage,
|
|
372
|
+
api, getPageOrNull, getSpaceId, getSpaceInfo, deleteAll, uploadImages, uploadAttachments, upsertPage,
|
|
366
373
|
getNode, getChildPages, getChildFolders, listAttachments, downloadAttachment,
|
|
367
374
|
getContentOrNull, findFolder, createFolder, upsertFolder, deleteFolder,
|
|
368
375
|
};
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* - 로컬 이미지 → 첨부 참조(ri:attachment), 외부 URL 이미지는 그대로
|
|
6
6
|
* 모든 경로는 baseDir 기준 상대경로로 해석한다.
|
|
7
7
|
*/
|
|
8
|
-
import { readFileSync, existsSync } from 'node:fs';
|
|
9
|
-
import { resolve, relative, dirname, basename } from 'node:path';
|
|
8
|
+
import { readFileSync, existsSync, statSync } from 'node:fs';
|
|
9
|
+
import { resolve, relative, dirname, basename, extname } from 'node:path';
|
|
10
10
|
import { createHash } from 'node:crypto';
|
|
11
11
|
import MarkdownIt from 'markdown-it';
|
|
12
12
|
import { resolveWikilinks } from './obsidian.js';
|
|
@@ -27,7 +27,7 @@ const md = new MarkdownIt({ html: true, linkify: true, breaks: false });
|
|
|
27
27
|
const emptyCtx = () => ({
|
|
28
28
|
fileDir: '', titleIndex: {}, anchorIndex: {}, selfRel: '', selfTitle: '',
|
|
29
29
|
anchorNames: new Set(), bodySlugs: [], headingSeq: 0, mermaidSeq: 0, anchorsPlaced: 0,
|
|
30
|
-
images: [], internalLinks: 0, anchorLinks: 0, deadAnchors: [], linkStack: [], linkedTitles: new Set(),
|
|
30
|
+
images: [], attachments: [], internalLinks: 0, anchorLinks: 0, deadAnchors: [], linkStack: [], linkedTitles: new Set(),
|
|
31
31
|
});
|
|
32
32
|
// 렌더 규칙은 md 인스턴스에 한 번만 등록되므로, 현재 변환 컨텍스트를 모듈 변수로 공유한다.
|
|
33
33
|
let ctx = emptyCtx();
|
|
@@ -91,6 +91,23 @@ md.renderer.rules.fence = (tokens, idx) => {
|
|
|
91
91
|
const code = `<ac:structured-macro ac:name="code">${langParam}<ac:plain-text-body><![CDATA[${safe}]]></ac:plain-text-body></ac:structured-macro>\n`;
|
|
92
92
|
return code + (lang === 'mermaid' ? mermaidMacro(ctx.selfRel, ctx.mermaidSeq++) : '');
|
|
93
93
|
};
|
|
94
|
+
/** 페이지 첨부 이름은 유일해야 한다. 동일 경로는 한 번만 업로드한다. */
|
|
95
|
+
function addAttachment(abs) {
|
|
96
|
+
const existing = ctx.attachments.find(a => a.abs === abs);
|
|
97
|
+
if (existing)
|
|
98
|
+
return existing.filename;
|
|
99
|
+
let filename = basename(abs);
|
|
100
|
+
if (ctx.attachments.some(a => a.filename === filename)) {
|
|
101
|
+
const ext = extname(filename), stem = basename(filename, ext);
|
|
102
|
+
const suffix = hashOf(relative(baseDir, abs).split('\\').join('/')).slice(0, 12);
|
|
103
|
+
filename = `${stem}-${suffix}${ext}`;
|
|
104
|
+
let n = 1;
|
|
105
|
+
while (ctx.attachments.some(a => a.filename === filename))
|
|
106
|
+
filename = `${stem}-${suffix}-${n++}${ext}`;
|
|
107
|
+
}
|
|
108
|
+
ctx.attachments.push({ filename, abs });
|
|
109
|
+
return filename;
|
|
110
|
+
}
|
|
94
111
|
md.renderer.rules.link_open = (tokens, idx, opts, _env, self) => {
|
|
95
112
|
const originalHref = tokens[idx].attrGet('href') || '';
|
|
96
113
|
const href = ctx.rewriteHref?.(originalHref) ?? originalHref;
|
|
@@ -109,6 +126,17 @@ md.renderer.rules.link_open = (tokens, idx, opts, _env, self) => {
|
|
|
109
126
|
const page = t.title ? `<ri:page ri:content-title="${escapeXml(t.title)}" />` : '';
|
|
110
127
|
return `<ac:link${anchor}>${page}<ac:link-body>`;
|
|
111
128
|
}
|
|
129
|
+
// 일반 로컬 파일 링크도 첨부로 전환한다. 문서·외부 URL·없는 대상은 유지한다.
|
|
130
|
+
const path = href.split('#')[0];
|
|
131
|
+
if (path && !/^[a-z][a-z\d+.-]*:|^\/\//i.test(path) && !/\.md$/i.test(decodePath(path))) {
|
|
132
|
+
const abs = resolve(baseDir, ctx.fileDir, decodePath(path));
|
|
133
|
+
if (existsSync(abs) && statSync(abs).isFile()) {
|
|
134
|
+
const filename = addAttachment(abs);
|
|
135
|
+
ctx.linkStack.push(true);
|
|
136
|
+
const fragment = href.includes('#') ? ` ac:anchor="${escapeXml(decodePath(href.slice(href.indexOf('#') + 1)))}"` : '';
|
|
137
|
+
return `<ac:link${fragment}><ri:attachment ri:filename="${escapeXml(filename)}" /><ac:link-body>`;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
112
140
|
ctx.linkStack.push(false);
|
|
113
141
|
return self.renderToken(tokens, idx, opts);
|
|
114
142
|
};
|
|
@@ -128,7 +156,7 @@ md.renderer.rules.image = (tokens, idx) => {
|
|
|
128
156
|
}
|
|
129
157
|
// markdown-it 이 링크 경로를 퍼센트 인코딩하므로 디코드해서 실제 파일명/경로로 복원(한글·공백 대응)
|
|
130
158
|
const abs = resolve(baseDir, ctx.fileDir, decodePath(src.split('#')[0]));
|
|
131
|
-
const filename =
|
|
159
|
+
const filename = addAttachment(abs);
|
|
132
160
|
ctx.images.push({ filename, abs });
|
|
133
161
|
return `<ac:image${widthAttr}><ri:attachment ri:filename="${escapeXml(filename)}" /></ac:image>`;
|
|
134
162
|
};
|
|
@@ -163,6 +191,7 @@ export function toStorage(markdown, rel, titleIndex, base, opts = {}) {
|
|
|
163
191
|
return {
|
|
164
192
|
storage,
|
|
165
193
|
images: ctx.images,
|
|
194
|
+
attachments: ctx.attachments,
|
|
166
195
|
internalLinks: ctx.internalLinks,
|
|
167
196
|
anchorLinks: ctx.anchorLinks,
|
|
168
197
|
anchorsPlaced: ctx.anchorsPlaced,
|
|
@@ -171,13 +200,13 @@ export function toStorage(markdown, rel, titleIndex, base, opts = {}) {
|
|
|
171
200
|
};
|
|
172
201
|
}
|
|
173
202
|
/**
|
|
174
|
-
* 변경 감지용 문서 해시: 제목 + storage + 참조 로컬
|
|
175
|
-
*
|
|
176
|
-
*
|
|
203
|
+
* 변경 감지용 문서 해시: 제목 + storage + 참조 로컬 첨부의 내용 해시.
|
|
204
|
+
* 첨부 내용을 포함하므로, 같은 파일명으로 파일만 교체해도 해시가 바뀌어 재동기화 대상이 된다.
|
|
205
|
+
* 첨부가 없으면 hashOf(title + '\0' + storage) 와 동일(첨부 없는 문서는 기존 해시 유지).
|
|
177
206
|
*/
|
|
178
207
|
export function docHash(title, r) {
|
|
179
208
|
const parts = [title, r.storage];
|
|
180
|
-
for (const img of r.
|
|
209
|
+
for (const img of r.attachments) {
|
|
181
210
|
let digest = 'missing';
|
|
182
211
|
try {
|
|
183
212
|
if (existsSync(img.abs))
|
package/dist/sync/push.js
CHANGED
|
@@ -145,7 +145,7 @@ export async function runPush(argv) {
|
|
|
145
145
|
const pk = parentKeyOf(rel, folderIndex);
|
|
146
146
|
const anchorNote = r.anchorLinks || r.anchorsPlaced
|
|
147
147
|
? `, 섹션링크: ${r.anchorLinks}, 앵커: ${r.anchorsPlaced}` : '';
|
|
148
|
-
console.log(` [${status}] ${rel} ${dim('→')} ${cyan(`"${title}"`)} ${dim(`(부모: ${pk ?? 'ROOT'}, 내부링크: ${r.internalLinks}${anchorNote}, 이미지: ${r.images.length})`)}`);
|
|
148
|
+
console.log(` [${status}] ${rel} ${dim('→')} ${cyan(`"${title}"`)} ${dim(`(부모: ${pk ?? 'ROOT'}, 내부링크: ${r.internalLinks}${anchorNote}, 이미지: ${r.images.length}, 첨부: ${r.attachments.length})`)}`);
|
|
149
149
|
if (r.deadAnchors.length) {
|
|
150
150
|
console.error(yellow(` ⚠ 대응 헤딩 없는 앵커 ${r.deadAnchors.length}개: `) + dim(r.deadAnchors.slice(0, 3).join(' ')));
|
|
151
151
|
}
|
|
@@ -263,8 +263,12 @@ export async function runPush(argv) {
|
|
|
263
263
|
moved++;
|
|
264
264
|
continue;
|
|
265
265
|
}
|
|
266
|
-
const imgOk = await client.
|
|
267
|
-
const imgNote = r.
|
|
266
|
+
const imgOk = await client.uploadAttachments(work[rel].pageId, r.attachments, BASE_DIR);
|
|
267
|
+
const imgNote = r.attachments.length ? `, 첨부 ${imgOk}/${r.attachments.length}` : '';
|
|
268
|
+
if (imgOk !== r.attachments.length) {
|
|
269
|
+
delete work[rel].hash; // 부분 실패는 다음 동기화에서 재시도한다.
|
|
270
|
+
saveMapping(MAPPING_PATH, work);
|
|
271
|
+
}
|
|
268
272
|
const label = result === 'created' ? green('+ 생성')
|
|
269
273
|
: result === 'recreated' ? magenta('♻ 재생성(삭제 복구)')
|
|
270
274
|
: yellow('↻ 갱신');
|
|
@@ -296,7 +300,11 @@ export async function runPush(argv) {
|
|
|
296
300
|
try {
|
|
297
301
|
await client.upsertPage(work, spaceId, rel, r.title, r.storage, r.hash, r.parentId, true);
|
|
298
302
|
saveMapping(MAPPING_PATH, work);
|
|
299
|
-
await client.
|
|
303
|
+
const uploaded = await client.uploadAttachments(work[rel].pageId, r.attachments, BASE_DIR);
|
|
304
|
+
if (uploaded !== r.attachments.length) {
|
|
305
|
+
delete work[rel].hash;
|
|
306
|
+
saveMapping(MAPPING_PATH, work);
|
|
307
|
+
}
|
|
300
308
|
console.log(` ${cyan('🔗 링크 재연결')} ${rel} ${dim('→')} ${cyan(`"${r.title}"`)}`);
|
|
301
309
|
relinked++;
|
|
302
310
|
}
|
package/dist/web/sync.js
CHANGED
|
@@ -148,7 +148,7 @@ export function createWebSync(runner = runSyncCLI) {
|
|
|
148
148
|
let reportedFailure = false;
|
|
149
149
|
const secrets = [prepared.env.CONFLUENCE_API_TOKEN, Buffer.from(`${prepared.env.CONFLUENCE_EMAIL}:${prepared.env.CONFLUENCE_API_TOKEN}`).toString('base64')].filter(Boolean);
|
|
150
150
|
const log = (text) => {
|
|
151
|
-
if (text.includes('✗') || text.includes('⚠ 이미지 없음'))
|
|
151
|
+
if (text.includes('✗') || text.includes('⚠ 이미지 없음') || text.includes('⚠ 첨부 없음'))
|
|
152
152
|
reportedFailure = true; // CLI reports per-document / attachment failures without exiting.
|
|
153
153
|
for (const secret of secrets)
|
|
154
154
|
text = text.split(secret).join('[비공개]');
|
package/package.json
CHANGED