@wonseok-han/confluence-sync 1.5.0 → 1.6.1
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 +63 -33
- package/dist/cli/args.js +14 -0
- package/dist/{help.js → cli/help.js} +11 -3
- package/dist/{init.js → cli/init.js} +1 -1
- package/dist/cli/main.js +38 -0
- package/dist/{confluence.js → confluence/client.js} +1 -1
- package/dist/{config.js → confluence/config.js} +1 -1
- package/dist/{convert.js → conversion/convert.js} +4 -4
- package/dist/{footnotes.js → conversion/footnotes.js} +1 -1
- package/dist/{pdf-pages.js → conversion/pdf-pages.js} +1 -1
- package/dist/{repair.js → conversion/repair.js} +2 -2
- package/dist/conversion/worker.js +9 -0
- package/dist/{docs.js → documents/catalog.js} +3 -1
- package/dist/{markdown.js → documents/markdown.js} +4 -1
- package/dist/{pull.js → sync/pull.js} +8 -8
- package/dist/sync/push.js +302 -0
- package/dist/sync/references.js +96 -0
- package/dist/sync/render.js +47 -0
- package/dist/sync.js +2 -348
- package/dist/web/assets/Monaco-LICENSE.txt +21 -0
- package/dist/web/assets/Monaco-ThirdPartyNotices.txt +448 -0
- package/dist/web/assets/app.css +1 -0
- package/dist/web/assets/app.js +939 -0
- package/dist/web/assets/editor.worker.js +30 -0
- package/dist/web/folder-dialog.js +44 -0
- package/dist/web/output.js +139 -0
- package/dist/web/server.js +346 -0
- package/dist/web/sync.js +226 -0
- package/dist/web/views/help.js +33 -0
- package/dist/web/views/sync-panel.js +26 -0
- package/dist/web/views/workspace.js +39 -0
- package/package.json +5 -2
- package/dist/render.js +0 -27
- /package/dist/{attachments.js → conversion/attachments.js} +0 -0
- /package/dist/{pdf-renderer.js → conversion/pdf-renderer.js} +0 -0
- /package/dist/{anchors.js → documents/anchors.js} +0 -0
- /package/dist/{html2md.js → documents/html-to-markdown.js} +0 -0
- /package/dist/{mermaid.js → documents/mermaid.js} +0 -0
- /package/dist/{obsidian.js → documents/obsidian.js} +0 -0
- /package/dist/{colors.js → shared/colors.js} +0 -0
- /package/dist/{ignore.js → sync/ignore.js} +0 -0
- /package/dist/{mapping.js → sync/mapping.js} +0 -0
package/dist/web/sync.js
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { referenceRoots } from '../sync/references.js';
|
|
2
|
+
/** Web adapter for the existing push CLI. Only preview and incremental push are exposed. */
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
5
|
+
import { readFile, readdir, realpath, stat, lstat } from 'node:fs/promises';
|
|
6
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { parse } from 'dotenv';
|
|
9
|
+
import { within } from './output.js';
|
|
10
|
+
import { collectMarkdown, resolveSelection } from '../documents/catalog.js';
|
|
11
|
+
import { buildIgnorer } from '../sync/ignore.js';
|
|
12
|
+
const keys = ['CONFLUENCE_BASE_URL', 'CONFLUENCE_EMAIL', 'CONFLUENCE_API_TOKEN', 'CONFLUENCE_SPACE_KEY', 'CONFLUENCE_PARENT_ID', 'CONFLUENCE_PARENT_PAGE_ID'];
|
|
13
|
+
async function settings(base, envFile) {
|
|
14
|
+
let source = envFile ? resolve(envFile) : join(base, '.env');
|
|
15
|
+
let values;
|
|
16
|
+
try {
|
|
17
|
+
source = await realpath(source);
|
|
18
|
+
values = parse(await readFile(source));
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
if (envFile || error.code !== 'ENOENT')
|
|
22
|
+
throw new Error('.env 파일을 읽을 수 없습니다. 경로와 권한을 확인해 주세요.');
|
|
23
|
+
source = '웹 서버 실행 환경 (.env / 환경변수)';
|
|
24
|
+
values = process.env;
|
|
25
|
+
}
|
|
26
|
+
const env = {};
|
|
27
|
+
for (const key of keys)
|
|
28
|
+
env[key] = values[key]?.trim() ?? '';
|
|
29
|
+
env.CONFLUENCE_PARENT_ID ||= env.CONFLUENCE_PARENT_PAGE_ID;
|
|
30
|
+
return { env, source };
|
|
31
|
+
}
|
|
32
|
+
function publicConfig(env, source) {
|
|
33
|
+
return { source, baseUrl: env.CONFLUENCE_BASE_URL, email: env.CONFLUENCE_EMAIL,
|
|
34
|
+
spaceKey: env.CONFLUENCE_SPACE_KEY, parentId: env.CONFLUENCE_PARENT_ID,
|
|
35
|
+
hasToken: !!env.CONFLUENCE_API_TOKEN,
|
|
36
|
+
missing: keys.slice(0, 4).filter(key => !env[key]) };
|
|
37
|
+
}
|
|
38
|
+
// The preview is invalidated if documents, attachments, mapping, ignore rules or credentials change.
|
|
39
|
+
async function fingerprint(base, env) {
|
|
40
|
+
const hash = createHash('sha256').update(JSON.stringify(env));
|
|
41
|
+
const walk = async (dir) => {
|
|
42
|
+
const entries = (await readdir(dir, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name));
|
|
43
|
+
for (const entry of entries) {
|
|
44
|
+
if (entry.name === 'node_modules' || (entry.name.startsWith('.') && !['.confluence-sync.json', '.confluence-syncignore'].includes(entry.name)))
|
|
45
|
+
continue;
|
|
46
|
+
const path = join(dir, entry.name);
|
|
47
|
+
if (entry.isDirectory())
|
|
48
|
+
await walk(path);
|
|
49
|
+
else if (entry.isFile()) {
|
|
50
|
+
const info = await stat(path);
|
|
51
|
+
hash.update(JSON.stringify([relative(base, path), info.size, info.mtimeMs, info.ctimeMs]));
|
|
52
|
+
if (/\.md$/i.test(path) || entry.name.startsWith('.'))
|
|
53
|
+
hash.update(await readFile(path));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
await walk(base);
|
|
58
|
+
return hash.digest('hex');
|
|
59
|
+
}
|
|
60
|
+
async function prepare(input) {
|
|
61
|
+
const body = input;
|
|
62
|
+
if (!body || typeof body.base !== 'string' || typeof body.target !== 'string'
|
|
63
|
+
|| typeof body.envFile !== 'string' || typeof body.verify !== 'boolean')
|
|
64
|
+
throw new Error('동기화 옵션을 확인해 주세요.');
|
|
65
|
+
const base = await realpath(resolve(body.base));
|
|
66
|
+
if (dirname(base) === base || !(await stat(base)).isDirectory())
|
|
67
|
+
throw new Error('문서의 기준 폴더를 선택해 주세요.');
|
|
68
|
+
if (body.referenceRoots !== undefined && (!Array.isArray(body.referenceRoots) || body.referenceRoots.some(value => typeof value !== 'string' || !value.trim())))
|
|
69
|
+
throw new Error('참조 폴더 목록을 확인해 주세요.');
|
|
70
|
+
const refs = referenceRoots(base, body.referenceRoots ?? []);
|
|
71
|
+
const target = body.target ? await realpath(resolve(body.target)) : base;
|
|
72
|
+
if (!within(base, target))
|
|
73
|
+
throw new Error('동기화 대상은 기준 폴더 안에 있어야 합니다.');
|
|
74
|
+
const ignorer = buildIgnorer(base, []);
|
|
75
|
+
const docs = collectMarkdown(base).map(path => relative(base, path).split('\\').join('/')).filter(path => !ignorer.ignores(path));
|
|
76
|
+
const picked = target === base ? docs : resolveSelection(docs, [target], base);
|
|
77
|
+
if (!picked.length)
|
|
78
|
+
throw new Error('동기화할 Markdown 문서가 없습니다. 선택 범위와 .confluence-syncignore를 확인해 주세요.');
|
|
79
|
+
// A damaged mapping must not silently turn a whole document set into new pages.
|
|
80
|
+
const mappingPath = join(base, '.confluence-sync.json');
|
|
81
|
+
try {
|
|
82
|
+
if (!(await lstat(mappingPath)).isFile())
|
|
83
|
+
throw new Error('invalid mapping');
|
|
84
|
+
const mapping = JSON.parse(await readFile(mappingPath, 'utf8'));
|
|
85
|
+
if (!mapping || Array.isArray(mapping) || typeof mapping !== 'object'
|
|
86
|
+
|| Object.values(mapping).some(value => !value || typeof value !== 'object' || typeof value.pageId !== 'string'))
|
|
87
|
+
throw new Error('invalid mapping');
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
if (error.code !== 'ENOENT')
|
|
91
|
+
throw new Error('.confluence-sync.json 매핑 파일이 올바르지 않습니다. 기존 매핑을 확인해 주세요.');
|
|
92
|
+
}
|
|
93
|
+
const { env, source } = await settings(base, body.envFile);
|
|
94
|
+
const selection = { base, target: target === base ? '' : target, envFile: body.envFile, verify: body.verify, referenceRoots: refs };
|
|
95
|
+
const snapshots = await Promise.all([base, ...refs].map(async (root) => {
|
|
96
|
+
const config = await readFile(join(root, '.env'), 'utf8').catch(error => {
|
|
97
|
+
if (error.code === 'ENOENT')
|
|
98
|
+
return '';
|
|
99
|
+
throw error;
|
|
100
|
+
});
|
|
101
|
+
return [root, await fingerprint(root, env), createHash('sha256').update(config).digest('hex')];
|
|
102
|
+
}));
|
|
103
|
+
return { selection, env, config: publicConfig(env, source), fingerprint: JSON.stringify(snapshots) };
|
|
104
|
+
}
|
|
105
|
+
export const runSyncCLI = (args, settings, log) => new Promise((resolveJob, reject) => {
|
|
106
|
+
const source = import.meta.url.endsWith('.ts');
|
|
107
|
+
const worker = fileURLToPath(new URL(source ? '../sync.ts' : '../sync.js', import.meta.url));
|
|
108
|
+
const child = spawn(process.execPath, [...(source ? process.execArgv : []), worker, ...args], {
|
|
109
|
+
env: { ...process.env, ...settings, NO_COLOR: '1' }, stdio: ['ignore', 'pipe', 'pipe'],
|
|
110
|
+
});
|
|
111
|
+
// Emit complete lines so secret masking cannot be bypassed by split stream chunks.
|
|
112
|
+
for (const stream of [child.stdout, child.stderr]) {
|
|
113
|
+
let pending = '';
|
|
114
|
+
stream.setEncoding('utf8');
|
|
115
|
+
stream.on('data', (chunk) => {
|
|
116
|
+
pending += chunk;
|
|
117
|
+
const end = pending.lastIndexOf('\n');
|
|
118
|
+
if (end >= 0) {
|
|
119
|
+
log(pending.slice(0, end + 1));
|
|
120
|
+
pending = pending.slice(end + 1);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
stream.on('end', () => { if (pending)
|
|
124
|
+
log(pending); });
|
|
125
|
+
}
|
|
126
|
+
child.once('error', reject);
|
|
127
|
+
child.once('close', code => resolveJob(code ?? 1));
|
|
128
|
+
});
|
|
129
|
+
export function createWebSync(runner = runSyncCLI) {
|
|
130
|
+
let job = null;
|
|
131
|
+
let plan = null;
|
|
132
|
+
let locked = false;
|
|
133
|
+
const running = () => locked || job?.state === 'running';
|
|
134
|
+
const launch = (kind, prepared) => {
|
|
135
|
+
const current = { id: randomUUID(), kind, state: 'running', log: '', selection: prepared.selection, config: prepared.config, planId: null, startedAt: new Date().toISOString() };
|
|
136
|
+
job = current;
|
|
137
|
+
plan = null;
|
|
138
|
+
const { base, target, verify } = prepared.selection;
|
|
139
|
+
const args = ['--base', base];
|
|
140
|
+
for (const root of prepared.selection.referenceRoots)
|
|
141
|
+
args.push('--reference-root', root);
|
|
142
|
+
if (target)
|
|
143
|
+
args.push(target);
|
|
144
|
+
if (kind === 'preview')
|
|
145
|
+
args.push('--dry-run');
|
|
146
|
+
else if (verify)
|
|
147
|
+
args.push('--verify');
|
|
148
|
+
let reportedFailure = false;
|
|
149
|
+
const secrets = [prepared.env.CONFLUENCE_API_TOKEN, Buffer.from(`${prepared.env.CONFLUENCE_EMAIL}:${prepared.env.CONFLUENCE_API_TOKEN}`).toString('base64')].filter(Boolean);
|
|
150
|
+
const log = (text) => {
|
|
151
|
+
if (text.includes('✗') || text.includes('⚠ 이미지 없음'))
|
|
152
|
+
reportedFailure = true; // CLI reports per-document / attachment failures without exiting.
|
|
153
|
+
for (const secret of secrets)
|
|
154
|
+
text = text.split(secret).join('[비공개]');
|
|
155
|
+
current.log = (current.log + text).slice(-512_000);
|
|
156
|
+
};
|
|
157
|
+
void (async () => {
|
|
158
|
+
try {
|
|
159
|
+
const code = await runner(args, prepared.env, log);
|
|
160
|
+
const succeeded = code === 0 && !reportedFailure;
|
|
161
|
+
if (!succeeded)
|
|
162
|
+
log('\n일부 작업이 실패했을 수 있습니다. 위 내역을 확인하고 다시 미리보기해 주세요.\n');
|
|
163
|
+
if (kind === 'preview' && succeeded) {
|
|
164
|
+
const updated = await prepare(prepared.selection);
|
|
165
|
+
if (updated.fingerprint !== prepared.fingerprint)
|
|
166
|
+
throw new Error('미리보기 중 문서나 설정이 바뀌었습니다. 다시 미리보기해 주세요.');
|
|
167
|
+
plan = { id: randomUUID(), prepared };
|
|
168
|
+
current.planId = plan.id;
|
|
169
|
+
}
|
|
170
|
+
current.state = succeeded ? 'succeeded' : 'failed';
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
current.state = 'failed';
|
|
174
|
+
log((error instanceof Error ? error.message : String(error)) + '\n');
|
|
175
|
+
}
|
|
176
|
+
finally {
|
|
177
|
+
current.finishedAt = new Date().toISOString();
|
|
178
|
+
}
|
|
179
|
+
})();
|
|
180
|
+
return current;
|
|
181
|
+
};
|
|
182
|
+
return {
|
|
183
|
+
running,
|
|
184
|
+
current: () => job,
|
|
185
|
+
config: async (base, envFile) => {
|
|
186
|
+
const resolved = await realpath(resolve(base));
|
|
187
|
+
const { env, source } = await settings(resolved, envFile);
|
|
188
|
+
return publicConfig(env, source);
|
|
189
|
+
},
|
|
190
|
+
preview: async (input) => {
|
|
191
|
+
if (running())
|
|
192
|
+
throw new Error('동기화 작업이 진행 중입니다.');
|
|
193
|
+
locked = true;
|
|
194
|
+
plan = null;
|
|
195
|
+
try {
|
|
196
|
+
return launch('preview', await prepare(input));
|
|
197
|
+
}
|
|
198
|
+
finally {
|
|
199
|
+
locked = false;
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
push: async (id) => {
|
|
203
|
+
if (running())
|
|
204
|
+
throw new Error('동기화 작업이 진행 중입니다.');
|
|
205
|
+
if (typeof id !== 'string' || !plan || plan.id !== id)
|
|
206
|
+
throw new Error('먼저 동기화 대상을 미리보기해 주세요.');
|
|
207
|
+
locked = true;
|
|
208
|
+
const approved = plan;
|
|
209
|
+
plan = null;
|
|
210
|
+
try {
|
|
211
|
+
const updated = await prepare(approved.prepared.selection);
|
|
212
|
+
if (updated.fingerprint !== approved.prepared.fingerprint)
|
|
213
|
+
throw new Error('문서나 설정이 변경되었습니다. 다시 미리보기해 주세요.');
|
|
214
|
+
if (updated.config.missing.length)
|
|
215
|
+
throw new Error(`설정 누락: ${updated.config.missing.join(', ')}`);
|
|
216
|
+
const url = new URL(updated.env.CONFLUENCE_BASE_URL);
|
|
217
|
+
if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash)
|
|
218
|
+
throw new Error('Confluence BASE_URL은 인증정보·쿼리가 없는 HTTPS 주소여야 합니다.');
|
|
219
|
+
return launch('push', updated);
|
|
220
|
+
}
|
|
221
|
+
finally {
|
|
222
|
+
locked = false;
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** In-app guide for the local document workflow. */
|
|
2
|
+
export const helpDialog = `
|
|
3
|
+
<dialog id="help-dialog" class="help-dialog" aria-labelledby="help-title">
|
|
4
|
+
<header class="help-heading"><div><span class="eyebrow">WORKSPACE GUIDE</span><h2 id="help-title">사용 도움말</h2></div><form method="dialog"><button aria-label="도움말 닫기" autofocus>닫기</button></form></header>
|
|
5
|
+
<nav class="help-nav" aria-label="도움말 목차"><a href="#help-convert">문서 변환</a><a href="#help-sync">동기화</a><a href="#help-links">참조 링크</a><a href="#help-faq">자주 묻는 질문</a></nav>
|
|
6
|
+
<div class="help-body" tabindex="0" aria-label="사용 도움말 내용">
|
|
7
|
+
<p class="help-intro">Obsidian에서 작업한 문서를 변환한 다음, 변환 결과 폴더를 기준으로 Confluence에 올릴 수 있습니다. 두 탭의 기준 폴더는 각각 설정합니다.</p>
|
|
8
|
+
<section id="help-convert"><h3>문서 변환</h3>
|
|
9
|
+
<ol><li><strong>변환 폴더 선택…</strong>으로 원본 폴더를 엽니다. 화살표는 폴더를 펼치고, 이름을 누르면 변환 대상을 선택합니다.</li><li>파일 하나를 선택하면 그 파일만, 폴더를 선택하면 하위 Markdown 전체를 변환합니다.</li><li>변환 형식과 출력 폴더를 정하고 <strong>미리보기</strong>로 원본과 결과를 비교합니다.</li><li><strong>변환 후 저장</strong>을 누른 뒤 <strong>결과 폴더 열기</strong>로 확인합니다. 웹 변환은 별도 출력 폴더에 저장합니다.</li></ol>
|
|
10
|
+
<dl><dt>Markdown</dt><dd>찾을 수 있는 위키링크를 상대 링크로 바꾸고 PDF 페이지 참조를 이미지로 변환합니다. 찾지 못한 문서 위키링크는 그대로 남습니다.</dd><dt>Obsidian</dt><dd>해석 가능한 Markdown 문서 링크를 위키링크로 바꿉니다.</dd><dt>Confluence 변환 오류 보정</dt><dd>옛 변환 결과의 CSS 잔해, 불필요한 이스케이프와 코드블록 언어 등을 보정합니다. 미리보기로 바뀐 내용을 확인하세요.</dd><dt>출력 위치</dt><dd>파일 하나는 출력 폴더 바로 아래에, 폴더 전체는 선택 폴더 내부 구조를 유지해 저장합니다. 출력 경로를 비우면 원본 기준 폴더 옆에 새 결과 폴더를 만듭니다.</dd></dl>
|
|
11
|
+
</section>
|
|
12
|
+
<section id="help-sync"><h3>Confluence 동기화</h3>
|
|
13
|
+
<ol><li><strong>동기화 폴더 선택…</strong>으로 업로드할 문서가 모인 폴더를 엽니다.</li><li>범위를 고르고 <strong>설정 확인</strong>으로 서버·스페이스·상위 페이지를 확인합니다.</li><li><strong>동기화 미리보기</strong>에서 신규·변경 문서와 링크 경고를 확인합니다. 이 단계는 로컬 비교이며 Confluence에 접속하지 않습니다.</li><li><strong>Confluence에 동기화</strong>를 누르면 실제 업로드합니다. 문서나 설정을 바꾸면 다시 미리보기해야 합니다.</li></ol>
|
|
14
|
+
<dl><dt>선택 문서</dt><dd>왼쪽에서 선택한 Markdown 문서입니다.</dd><dt>현재 폴더</dt><dd>현재 탐색 중인 폴더와 그 하위 문서입니다. 상위 페이지를 연결하기 위한 처리가 함께 수행될 수 있습니다.</dd><dt>기준 폴더 전체</dt><dd>설정한 동기화 루트의 모든 하위 Markdown입니다. 제외 규칙에 해당하는 문서는 빠집니다.</dd><dt>설정 파일</dt><dd>비우면 기준 폴더의 <code>.env</code>를 사용하고, 없으면 웹 서버 실행 환경을 사용합니다. 다른 설정 파일을 직접 지정할 수도 있습니다.</dd></dl>
|
|
15
|
+
<p class="help-note">미리보기는 서버의 권한이나 페이지 존재를 검증하지 않습니다. 실행 중 일부 문서가 실패하면 로그를 확인하고 다시 미리보기하세요.</p>
|
|
16
|
+
</section>
|
|
17
|
+
<section id="help-links"><h3>다른 폴더의 문서와 연결하기</h3>
|
|
18
|
+
<p>업로드할 문서는 프로젝트 폴더에 있고, 연결할 문서는 별도 DBP 폴더에 있다면 <strong>참조 문서 폴더</strong>에 DBP의 동기화 루트를 추가하세요. 여러 경로를 한 줄씩 입력하거나 폴더 선택창으로 추가할 수 있습니다.</p>
|
|
19
|
+
<pre><code>[[원본/긴/경로/문서명|표시 이름]]
|
|
20
|
+
[표시 이름](../이전/문서명.md)</code></pre>
|
|
21
|
+
<p>먼저 경로로 찾고, 찾지 못하면 기준·참조 폴더에서 파일명으로 찾습니다. 후보가 하나일 때만 연결하며 <code>|</code> 오른쪽은 화면에 표시할 이름입니다.</p>
|
|
22
|
+
<ul><li>현재 루트의 문서는 그 문서의 페이지 제목으로 연결합니다. 파일명과 H1이 달라도 됩니다.</li><li>참조 문서는 <code>.confluence-sync.json</code>의 게시된 페이지 ID로 연결합니다. 참조 문서 자체는 업로드하지 않습니다.</li><li>같은 Confluence 서버의 별도 동기화 폴더를 지정하세요. 중복 파일명, 문서 누락, 게시 매핑 누락은 경고로 표시합니다.</li></ul>
|
|
23
|
+
<p class="help-note">링크 경고가 있어도 업로드할 수 있습니다. 해결하지 못한 링크는 원문으로 남으므로 실행 전에 경고를 확인하세요.</p>
|
|
24
|
+
</section>
|
|
25
|
+
<section id="help-faq"><h3>자주 묻는 질문</h3>
|
|
26
|
+
<details><summary>기존 파일이 있는데 덮어쓰기 확인창이 안 나와요.</summary><p>내용이 같으면 파일을 건드리지 않고 재사용합니다. 내용이 다른 경우에만 대상 목록을 보여주고 덮어쓸지 확인합니다. 취소하면 저장하지 않습니다.</p></details>
|
|
27
|
+
<details><summary>문서를 검색했는데 나오지 않아요.</summary><p>검색은 선택한 기준 폴더의 모든 하위 Markdown 파일명을 대상으로 합니다. 본문은 검색하지 않으며 숨김 폴더와 node_modules는 제외됩니다.</p></details>
|
|
28
|
+
<details><summary>폴더를 변환했는데 비교 화면에 일부만 보여요.</summary><p>본문 비교는 최대 20개 문서, 문서당 200KB까지 지원합니다. 실제 저장은 선택 폴더의 전체 변환 대상에 적용됩니다.</p></details>
|
|
29
|
+
<details><summary>실행 내역의 색은 무엇을 뜻하나요?</summary><p>초록은 신규·생성·완료, 황갈색은 변경·갱신, 보라는 참조·연결, 회색은 동일·재사용입니다. 주황 배경은 경고, 빨강 배경은 오류입니다. 함께 표시된 문구로 상세 내용을 확인하세요.</p></details>
|
|
30
|
+
<details><summary>새로고침하면 설정이 사라지나요?</summary><p>기준 폴더, 출력 경로, 참조 폴더와 작업 옵션은 같은 브라우저에 저장됩니다. 브라우저 데이터를 지우거나 다른 주소·포트에서 열면 설정이 다를 수 있습니다. 업로드 승인은 저장하지 않으므로 다시 미리보기하세요.</p></details>
|
|
31
|
+
</section>
|
|
32
|
+
</div>
|
|
33
|
+
</dialog>`;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export const syncPanel = `
|
|
2
|
+
<div id="sync-view" class="workspace-content" role="tabpanel" aria-labelledby="tab-sync" hidden>
|
|
3
|
+
<section class="panel" aria-label="동기화 설정">
|
|
4
|
+
<div class="panel-heading"><h2><span class="section-number">02</span>Confluence 동기화</h2><span class="count">Markdown → Confluence</span></div>
|
|
5
|
+
<div class="settings-body sync-settings">
|
|
6
|
+
<p class="sync-intro">로컬 문서를 Confluence에 올립니다. 신규·변경 문서를 먼저 확인하세요.</p>
|
|
7
|
+
<div class="settings-grid">
|
|
8
|
+
<div class="field"><label for="sync-scope">동기화 범위</label><select id="sync-scope"><option value="file">선택 문서</option><option value="folder">현재 폴더</option><option value="all">기준 폴더 전체</option></select></div>
|
|
9
|
+
<div class="field"><label for="sync-env">설정 파일 <span>선택 사항</span></label><div class="path-row"><input id="sync-env" class="path-input" placeholder="기준 폴더의 .env 자동 사용"><button id="sync-load">설정 확인</button></div></div>
|
|
10
|
+
</div>
|
|
11
|
+
<div class="field"><label for="sync-references">참조 문서 폴더 <span>선택 사항 · 한 줄에 한 경로</span></label><textarea id="sync-references" class="path-input" rows="3" placeholder="다른 동기화 폴더 경로"></textarea><button id="sync-add-reference" type="button">참조 폴더 추가…</button><p class="hint">참조 폴더의 게시 매핑으로 링크를 연결합니다. 같은 Confluence 서버의 폴더를 지정하세요. 참조 문서는 업로드하지 않습니다.</p></div>
|
|
12
|
+
<p id="sync-target" class="sync-target">왼쪽에서 문서를 선택하세요.</p>
|
|
13
|
+
<p class="hint">설정 경로를 비우면 기준 폴더의 .env를 사용합니다. 없으면 웹 서버 실행 환경을 사용합니다.</p>
|
|
14
|
+
<dl id="sync-config" class="sync-config"><dt>연결 설정</dt><dd>설정 확인을 누르면 업로드 위치를 볼 수 있습니다.</dd></dl>
|
|
15
|
+
<p id="sync-config-status" class="hint" role="status"></p>
|
|
16
|
+
<label class="repair-option"><input id="sync-verify" type="checkbox">변경 없는 문서도 Confluence 페이지 존재 확인</label>
|
|
17
|
+
<p class="hint">.confluence-syncignore와 기존 페이지 매핑을 적용합니다. 미리보기는 로컬 변경 비교이며 서버 연결·권한은 검사하지 않습니다.</p>
|
|
18
|
+
</div>
|
|
19
|
+
<div class="action-bar"><span class="save-note">먼저 대상을 확인한 뒤 업로드하세요.</span><div class="actions"><button id="sync-preview" disabled>동기화 미리보기</button><button id="sync-push" class="primary" disabled>Confluence에 동기화</button></div></div>
|
|
20
|
+
<div class="feedback"><p id="sync-status" class="status" role="status" aria-live="polite">문서를 선택하고 동기화 미리보기를 실행하세요.</p><button id="sync-reconnect" hidden>진행 상태 다시 확인</button></div>
|
|
21
|
+
</section>
|
|
22
|
+
<section class="panel sync-results" aria-label="동기화 내역">
|
|
23
|
+
<div class="panel-heading"><h2><span class="section-number">03</span>대상 및 실행 내역</h2><span id="sync-job-state" class="count">대기</span></div>
|
|
24
|
+
<div class="sync-result-body"><p id="sync-job-summary" class="hint" hidden></p><p id="sync-log-empty" class="empty">미리보기하면 문서별 신규·변경·동일 상태와 부모 계층을 확인할 수 있습니다.</p><pre id="sync-log" tabindex="0" aria-label="동기화 로그" hidden></pre></div>
|
|
25
|
+
</section>
|
|
26
|
+
</div>`;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { helpDialog } from './help.js';
|
|
2
|
+
import { syncPanel } from './sync-panel.js';
|
|
3
|
+
/** HTML shell; Monaco and application code are bundled as local assets. */
|
|
4
|
+
export const webPage = (token) => `<!doctype html>
|
|
5
|
+
<html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="csync-token" content="${token}"><title>문서 워크스페이스 · confluence-sync</title><link rel="stylesheet" href="/assets/app.css"></head><body>
|
|
6
|
+
<header class="app-header"><a class="brand" href="/" aria-label="confluence-sync 홈"><span class="brand-symbol" aria-hidden="true">⇄</span>confluence<span class="brand-light">sync</span></a><span class="local-status"><i></i>로컬 워크스페이스</span></header>
|
|
7
|
+
<main class="app-main">
|
|
8
|
+
<div class="page-heading"><div><div class="eyebrow">DOCUMENT WORKSPACE</div><h1>문서 워크스페이스<span class="title-dot">.</span></h1></div><div class="page-heading-actions"><p>문서를 변환하고, Confluence와 동기화하세요.</p><button id="open-help" aria-haspopup="dialog" aria-controls="help-dialog">사용 도움말</button></div></div>
|
|
9
|
+
<div class="mode-tabs" role="tablist" aria-label="문서 작업"><button id="tab-convert" role="tab" aria-selected="true" aria-controls="convert-view">문서 변환</button><button id="tab-sync" role="tab" aria-selected="false" aria-controls="sync-view" tabindex="-1">Confluence 동기화</button></div>
|
|
10
|
+
<div class="workspace">
|
|
11
|
+
<aside class="panel explorer" aria-label="파일 탐색">
|
|
12
|
+
<div class="panel-heading"><h2><span class="section-number">01</span>문서</h2><span id="count" class="count">폴더 탐색</span></div>
|
|
13
|
+
<div class="folder-picker"><button id="choose-base" class="folder-button"><span aria-hidden="true">+</span>문서 폴더 선택…</button><div class="path-row"><input id="base-input" class="path-input" aria-label="문서 폴더 경로" placeholder="폴더 경로 직접 입력"><button id="apply-base" class="quiet">열기</button></div><p class="hint">화살표로 펼치고, 이름을 눌러 선택하세요.</p></div>
|
|
14
|
+
<div class="explorer-navigation"><button id="up" class="quiet" aria-label="상위 폴더" title="상위 폴더">↑</button><p id="path" class="path"></p></div>
|
|
15
|
+
<div class="search-wrap"><span aria-hidden="true">⌕</span><input id="search" type="search" placeholder="하위 폴더까지 파일 이름 검색" aria-label="파일 이름 검색"></div>
|
|
16
|
+
<div id="files" class="files"></div><div class="explorer-footer"><span class="file-type">MD</span>폴더 또는 Markdown을 선택하세요</div>
|
|
17
|
+
</aside>
|
|
18
|
+
<div id="convert-view" class="workspace-content" role="tabpanel" aria-labelledby="tab-convert">
|
|
19
|
+
<section class="panel settings" aria-label="변환 설정">
|
|
20
|
+
<div class="panel-heading"><h2><span class="section-number">02</span>변환 설정</h2><span class="subtle-label">원본 유지</span></div>
|
|
21
|
+
<div class="settings-body">
|
|
22
|
+
<div class="document-summary"><span class="document-symbol" aria-hidden="true">MD</span><div><div id="selected" class="selected-name">변환할 문서를 선택하세요</div><div id="base" class="base">왼쪽에서 폴더를 열어 시작하세요.</div></div></div>
|
|
23
|
+
<p id="convert-output-hint" class="hint"></p><div class="settings-grid"><div class="field"><label for="direction">변환 형식</label><select id="direction"><option value="markdown">Markdown</option><option value="obsidian">Obsidian</option><option value="repair">Confluence 오류 보정만</option></select></div><div class="field output-field"><label for="out-input">출력 폴더 <span>선택 사항</span></label><div class="path-row"><input id="out-input" class="path-input" placeholder="자동으로 새 폴더 생성"><button id="choose-out">폴더 선택…</button></div></div></div>
|
|
24
|
+
<div class="settings-notes"><p id="direction-hint" class="hint">위키링크를 상대 링크로, PDF 페이지 참조를 이미지로 변환합니다.</p><div class="repair-help"><label class="repair-option"><input id="fix" type="checkbox">Confluence 변환 오류 보정</label><button id="repair-help-button" class="info-mark" type="button" aria-label="Confluence 변환 오류 보정 설명" aria-describedby="repair-tooltip">i</button><div id="repair-tooltip" class="help-tooltip" role="tooltip" hidden>옛 Confluence 문서의 CSS 잔해, 중복 제목, 코드블록 언어와 불필요한 이스케이프를 보정합니다. 미리보기에서 변경 내용을 확인하세요.</div></div></div>
|
|
25
|
+
</div>
|
|
26
|
+
<div class="action-bar"><span class="save-note">선택한 대상과 참조 첨부파일을 저장합니다.</span><div class="actions"><button id="next-document" class="quiet" disabled>다른 문서 선택</button><button id="preview" disabled>미리보기</button><button id="save" class="primary" disabled>변환 후 저장 <span aria-hidden="true">↗</span></button></div></div>
|
|
27
|
+
<div class="feedback"><div id="status" class="status" role="status" aria-live="polite"></div><div id="result" class="result" hidden><div><strong>변환 결과 저장 완료</strong><p id="output"></p></div><button id="open">결과 폴더 열기 ↗</button></div><details id="details" hidden><summary>변환 내역</summary><pre id="log"></pre></details></div>
|
|
28
|
+
</section>
|
|
29
|
+
<section class="panel comparison" aria-label="본문 비교">
|
|
30
|
+
<div class="panel-heading comparison-heading"><h2><span class="section-number">03</span>본문 비교</h2><div class="diff-toolbar"><span id="preview-label" class="count">미리보기 대기</span><span class="toolbar-divider"></span><label class="layout-toggle"><input id="side-by-side" type="checkbox" checked>나란히</label><div class="diff-navigation"><button id="previous-change" class="quiet" aria-label="이전 변경" title="이전 변경" disabled>↑</button><button id="next-change" class="quiet" aria-label="다음 변경" title="다음 변경" disabled>↓</button></div></div></div>
|
|
31
|
+
<div id="preview-documents" class="preview-documents" hidden><label for="preview-document">비교할 문서</label><select id="preview-document"></select><span id="preview-count" class="hint"></span></div><div class="diff-labels"><span><i class="legend-before"></i>원본</span><span><i class="legend-after"></i>변환 결과</span><span class="read-only">읽기 전용</span></div>
|
|
32
|
+
<div id="editor-error" role="alert" hidden></div><div class="editor-shell"><div id="diff" class="diff" aria-label="Monaco 본문 비교"></div><div id="comparison-empty" class="comparison-empty"><span class="empty-symbol" aria-hidden="true">⇄</span><strong>변경된 부분을 한눈에</strong><p>문서를 선택하고 미리보기를 누르면<br>원본과 변환 결과를 비교할 수 있습니다.</p><div class="empty-legend"><span><i class="legend-before"></i>삭제</span><span><i class="legend-after"></i>추가</span></div></div></div>
|
|
33
|
+
</section>
|
|
34
|
+
</div>
|
|
35
|
+
${syncPanel}
|
|
36
|
+
</div>
|
|
37
|
+
<footer><span id="workspace-note"><i></i>변환은 이 컴퓨터에서 처리됩니다.</span><span>Markdown · Confluence</span></footer>
|
|
38
|
+
${helpDialog}
|
|
39
|
+
</main><script src="/assets/app.js" type="module"></script></body></html>`;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wonseok-han/confluence-sync",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Confluence Cloud ↔ Markdown 양방향 동기화 CLI — 마크다운을 Confluence 페이지로 발행(push)하고, Confluence 페이지·폴더·스페이스를 마크다운으로 가져옵니다(pull).",
|
|
6
6
|
"keywords": [
|
|
@@ -35,7 +35,8 @@
|
|
|
35
35
|
"LICENSE"
|
|
36
36
|
],
|
|
37
37
|
"scripts": {
|
|
38
|
-
"build": "
|
|
38
|
+
"build": "node scripts/build.mjs",
|
|
39
|
+
"web": "npm run build && node dist/sync.js web",
|
|
39
40
|
"test": "npm run build && node --test tests/*.test.mjs",
|
|
40
41
|
"prepublishOnly": "npm run build",
|
|
41
42
|
"sync": "tsx src/sync.ts",
|
|
@@ -61,6 +62,8 @@
|
|
|
61
62
|
"@types/markdown-it-footnote": "^3.0.4",
|
|
62
63
|
"@types/node": "^22.0.0",
|
|
63
64
|
"@types/turndown": "^5.0.6",
|
|
65
|
+
"esbuild": "^0.28.2",
|
|
66
|
+
"monaco-editor": "^0.57.0",
|
|
64
67
|
"tsx": "^4.19.0",
|
|
65
68
|
"typescript": "^5.6.0"
|
|
66
69
|
}
|
package/dist/render.js
DELETED
|
@@ -1,27 +0,0 @@
|
|
|
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
|
-
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|