@wonseok-han/confluence-sync 1.4.0 → 1.6.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 +64 -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} +11 -5
- package/dist/documents/mermaid.js +13 -0
- package/dist/{pull.js → sync/pull.js} +8 -8
- package/dist/sync/push.js +302 -0
- package/dist/sync/references.js +93 -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/{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
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
import { sep } from 'node:path';
|
|
4
|
+
const execute = promisify(execFile);
|
|
5
|
+
/** Paths are passed as arguments/environment values, never interpolated into scripts. */
|
|
6
|
+
export async function chooseFolder(start, title) {
|
|
7
|
+
try {
|
|
8
|
+
if (process.platform === 'darwin') {
|
|
9
|
+
const script = `on run argv
|
|
10
|
+
try
|
|
11
|
+
set initialFolder to POSIX file (item 1 of argv)
|
|
12
|
+
set dialogTitle to item 2 of argv
|
|
13
|
+
tell application "Finder"
|
|
14
|
+
activate
|
|
15
|
+
set selectedFolder to choose folder with prompt dialogTitle default location initialFolder
|
|
16
|
+
end tell
|
|
17
|
+
return POSIX path of selectedFolder
|
|
18
|
+
on error number -128
|
|
19
|
+
return ""
|
|
20
|
+
end try
|
|
21
|
+
end run`;
|
|
22
|
+
const { stdout } = await execute('/usr/bin/osascript', ['-e', script, start, title], { timeout: 300_000 });
|
|
23
|
+
return stdout.trim() || null;
|
|
24
|
+
}
|
|
25
|
+
if (process.platform === 'win32') {
|
|
26
|
+
const script = `Add-Type -AssemblyName System.Windows.Forms
|
|
27
|
+
$d = New-Object System.Windows.Forms.FolderBrowserDialog
|
|
28
|
+
$d.Description = $env:CSYNC_DIALOG_TITLE
|
|
29
|
+
$d.SelectedPath = $env:CSYNC_DIALOG_START
|
|
30
|
+
try { if ($d.ShowDialog() -eq 'OK') { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8; [Console]::Write($d.SelectedPath) } } finally { $d.Dispose() }`;
|
|
31
|
+
const { stdout } = await execute('powershell.exe', ['-NoProfile', '-STA', '-Command', script], {
|
|
32
|
+
env: { ...process.env, CSYNC_DIALOG_TITLE: title, CSYNC_DIALOG_START: start }, timeout: 300_000,
|
|
33
|
+
});
|
|
34
|
+
return stdout.trim() || null;
|
|
35
|
+
}
|
|
36
|
+
const { stdout } = await execute('zenity', ['--file-selection', '--directory', '--filename', start + sep, '--title', title], { timeout: 300_000 });
|
|
37
|
+
return stdout.trim() || null;
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
if (process.platform === 'linux' && error.code === 1)
|
|
41
|
+
return null;
|
|
42
|
+
throw new Error('폴더 다이얼로그를 열 수 없습니다. 경로를 직접 입력해 주세요. Linux에서는 zenity가 필요합니다.', { cause: error });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { lstat, realpath, readdir, mkdir, copyFile, unlink, rmdir, mkdtemp, rename, rm } from 'node:fs/promises';
|
|
2
|
+
import { constants, createReadStream } from 'node:fs';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { resolve, relative, isAbsolute, dirname, basename, join } from 'node:path';
|
|
5
|
+
export const within = (base, file) => {
|
|
6
|
+
const rel = relative(base, file);
|
|
7
|
+
return !isAbsolute(rel) && rel !== '..' && !rel.startsWith('../') && !rel.startsWith('..\\');
|
|
8
|
+
};
|
|
9
|
+
const missing = (error) => error.code === 'ENOENT';
|
|
10
|
+
export async function canonical(path) {
|
|
11
|
+
try {
|
|
12
|
+
return await realpath(path);
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
if (!missing(error))
|
|
16
|
+
throw error;
|
|
17
|
+
const parent = dirname(path);
|
|
18
|
+
if (parent === path)
|
|
19
|
+
throw error;
|
|
20
|
+
return join(await canonical(parent), basename(path));
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
async function digest(path) {
|
|
24
|
+
const hash = createHash('sha256');
|
|
25
|
+
for await (const chunk of createReadStream(path))
|
|
26
|
+
hash.update(chunk);
|
|
27
|
+
return hash.digest('hex');
|
|
28
|
+
}
|
|
29
|
+
export class OverwriteRequired extends Error {
|
|
30
|
+
conflicts;
|
|
31
|
+
constructor(conflicts) {
|
|
32
|
+
super(`내용이 다른 기존 파일 ${conflicts.length}개가 있습니다. 덮어쓰기를 확인해 주세요.`);
|
|
33
|
+
this.conflicts = conflicts;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/** Reuse identical files; approved replacements are backed up until the export completes. */
|
|
37
|
+
export async function publishOutput(stage, output, approved = {}) {
|
|
38
|
+
const files = [];
|
|
39
|
+
const collect = async (path) => {
|
|
40
|
+
for (const entry of await readdir(path, { withFileTypes: true })) {
|
|
41
|
+
const abs = join(path, entry.name);
|
|
42
|
+
if (entry.isDirectory())
|
|
43
|
+
await collect(abs);
|
|
44
|
+
else if (entry.isFile())
|
|
45
|
+
files.push(relative(stage, abs));
|
|
46
|
+
else
|
|
47
|
+
throw new Error('출력에 지원하지 않는 파일 형식이 있습니다.');
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
await collect(stage);
|
|
51
|
+
const reusable = new Set(), replace = new Set();
|
|
52
|
+
const conflicts = [];
|
|
53
|
+
// Check all destinations before writing, including directory symlinks.
|
|
54
|
+
for (const rel of files) {
|
|
55
|
+
const target = join(output, rel);
|
|
56
|
+
let cursor = target;
|
|
57
|
+
while (within(output, cursor)) {
|
|
58
|
+
try {
|
|
59
|
+
const info = await lstat(cursor);
|
|
60
|
+
if (cursor === target && info.isFile() && !info.isSymbolicLink()) {
|
|
61
|
+
const hash = await digest(cursor);
|
|
62
|
+
if (info.size === (await lstat(join(stage, rel))).size && hash === await digest(join(stage, rel)))
|
|
63
|
+
reusable.add(rel);
|
|
64
|
+
else {
|
|
65
|
+
replace.add(rel);
|
|
66
|
+
if (approved[rel] !== hash)
|
|
67
|
+
conflicts.push({ path: rel, hash });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
else if (cursor === target || info.isSymbolicLink() || !info.isDirectory()) {
|
|
71
|
+
throw new Error(`출력 경로가 이미 사용 중입니다: ${cursor}\n다른 출력 폴더를 지정해 주세요.`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
if (!missing(error))
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
if (cursor === output)
|
|
79
|
+
break;
|
|
80
|
+
cursor = dirname(cursor);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (conflicts.length)
|
|
84
|
+
throw new OverwriteRequired(conflicts);
|
|
85
|
+
let backup;
|
|
86
|
+
const replaced = [];
|
|
87
|
+
const createdDirs = [], copied = [];
|
|
88
|
+
const ensureDir = async (path) => {
|
|
89
|
+
try {
|
|
90
|
+
const info = await lstat(path);
|
|
91
|
+
if (!info.isDirectory() || info.isSymbolicLink())
|
|
92
|
+
throw new Error(`출력 폴더를 확인해 주세요: ${path}`);
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
if (!missing(error))
|
|
96
|
+
throw error;
|
|
97
|
+
await ensureDir(dirname(path));
|
|
98
|
+
await mkdir(path);
|
|
99
|
+
createdDirs.push(path);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
try {
|
|
103
|
+
for (const rel of files) {
|
|
104
|
+
if (reusable.has(rel))
|
|
105
|
+
continue;
|
|
106
|
+
const target = resolve(output, rel);
|
|
107
|
+
await ensureDir(dirname(target));
|
|
108
|
+
if (replace.has(rel)) {
|
|
109
|
+
const info = await lstat(target);
|
|
110
|
+
if (!info.isFile() || info.isSymbolicLink())
|
|
111
|
+
throw new Error(`출력 파일이 변경되었습니다: ${target}`);
|
|
112
|
+
const hash = await digest(target);
|
|
113
|
+
if (approved[rel] !== hash)
|
|
114
|
+
throw new OverwriteRequired([{ path: rel, hash }]);
|
|
115
|
+
backup ??= await mkdtemp(join(output, '.csync-backup-'));
|
|
116
|
+
const saved = join(backup, String(replaced.length));
|
|
117
|
+
await rename(target, saved);
|
|
118
|
+
replaced.push({ target, backup: saved });
|
|
119
|
+
}
|
|
120
|
+
await copyFile(join(stage, rel), target, constants.COPYFILE_EXCL);
|
|
121
|
+
copied.push(target);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
for (const path of copied.reverse())
|
|
126
|
+
await unlink(path);
|
|
127
|
+
for (const entry of replaced.reverse()) {
|
|
128
|
+
await copyFile(entry.backup, entry.target, constants.COPYFILE_EXCL);
|
|
129
|
+
}
|
|
130
|
+
if (backup)
|
|
131
|
+
await rm(backup, { recursive: true, force: true });
|
|
132
|
+
for (const path of createdDirs.reverse())
|
|
133
|
+
await rmdir(path);
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
136
|
+
if (backup)
|
|
137
|
+
await rm(backup, { recursive: true, force: true });
|
|
138
|
+
return { created: copied.length - replaced.length, overwritten: replaced.length, reused: reusable.size };
|
|
139
|
+
}
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import { readdir, realpath, stat, readFile, mkdtemp, rm } from 'node:fs/promises';
|
|
4
|
+
import { resolve, dirname, basename, join, relative, extname } from 'node:path';
|
|
5
|
+
import { homedir, tmpdir } from 'node:os';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { execFile, spawn } from 'node:child_process';
|
|
8
|
+
import { promisify } from 'node:util';
|
|
9
|
+
import { collectMarkdown } from '../documents/catalog.js';
|
|
10
|
+
import { webPage } from './views/workspace.js';
|
|
11
|
+
import { createWebSync } from './sync.js';
|
|
12
|
+
import { chooseFolder } from './folder-dialog.js';
|
|
13
|
+
import { within, canonical, publishOutput, OverwriteRequired } from './output.js';
|
|
14
|
+
const execute = promisify(execFile);
|
|
15
|
+
const message = (error) => error instanceof Error ? error.message : String(error);
|
|
16
|
+
export async function startWeb(options = {}) {
|
|
17
|
+
const start = await realpath(resolve(options.start ?? homedir()));
|
|
18
|
+
if (!(await stat(start)).isDirectory())
|
|
19
|
+
throw new Error('시작 경로가 폴더가 아닙니다.');
|
|
20
|
+
const token = randomBytes(32).toString('hex');
|
|
21
|
+
let origin = '';
|
|
22
|
+
const allowedHosts = new Set();
|
|
23
|
+
let busy = false;
|
|
24
|
+
let picking = false;
|
|
25
|
+
const assetsRoot = fileURLToPath(new URL('../../dist/web/assets/', import.meta.url));
|
|
26
|
+
const results = new Set();
|
|
27
|
+
const sync = createWebSync(options.syncRunner);
|
|
28
|
+
const server = createServer(async (req, res) => {
|
|
29
|
+
const json = (status, value) => {
|
|
30
|
+
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
31
|
+
res.end(JSON.stringify(value));
|
|
32
|
+
};
|
|
33
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
34
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
35
|
+
res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self'; worker-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'");
|
|
36
|
+
if (!req.headers.host || !allowedHosts.has(req.headers.host)
|
|
37
|
+
|| (req.headers.origin && req.headers.origin !== `http://${req.headers.host}`)) {
|
|
38
|
+
json(403, { error: '이 웹 화면에서만 요청할 수 있습니다.' });
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
const url = new URL(req.url ?? '/', origin);
|
|
43
|
+
if (req.method === 'GET' && url.pathname === '/') {
|
|
44
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
45
|
+
res.end(webPage(token));
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (req.method === 'GET' && url.pathname.startsWith('/assets/')) {
|
|
49
|
+
const path = resolve(assetsRoot, decodeURIComponent(url.pathname.slice('/assets/'.length)));
|
|
50
|
+
if (!within(assetsRoot, path)) {
|
|
51
|
+
json(403, { error: '잘못된 파일 경로입니다.' });
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const types = { '.js': 'text/javascript', '.css': 'text/css', '.ttf': 'font/ttf' };
|
|
55
|
+
if (!types[extname(path)]) {
|
|
56
|
+
json(404, { error: '없는 파일입니다.' });
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
res.setHeader('Content-Type', types[extname(path)]);
|
|
60
|
+
res.end(await readFile(path));
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (req.headers['x-csync-token'] !== token) {
|
|
64
|
+
json(403, { error: '페이지를 새로고침해 주세요.' });
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (req.method === 'GET' && url.pathname === '/api/sync/job') {
|
|
68
|
+
json(200, { job: sync.current() });
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (req.method === 'GET' && url.pathname === '/api/browse') {
|
|
72
|
+
const path = await realpath(url.searchParams.get('path') || start);
|
|
73
|
+
const entries = (await readdir(path, { withFileTypes: true }))
|
|
74
|
+
.filter(e => !e.name.startsWith('.') && e.name !== 'node_modules' && (e.isDirectory() || (e.isFile() && /\.md$/i.test(e.name))))
|
|
75
|
+
.map(e => ({ name: e.name, path: join(path, e.name), directory: e.isDirectory() }))
|
|
76
|
+
.sort((a, b) => Number(b.directory) - Number(a.directory) || a.name.localeCompare(b.name, 'ko'));
|
|
77
|
+
json(200, { path, parent: dirname(path), home: homedir(), entries });
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (req.method === 'GET' && url.pathname === '/api/search') {
|
|
81
|
+
const root = await realpath(url.searchParams.get('path') || start);
|
|
82
|
+
const query = (url.searchParams.get('q') || '').trim().normalize('NFC').toLocaleLowerCase();
|
|
83
|
+
if (!(await stat(root)).isDirectory())
|
|
84
|
+
throw new Error('검색 기준 폴더를 확인해 주세요.');
|
|
85
|
+
const entries = [];
|
|
86
|
+
let skipped = 0;
|
|
87
|
+
const pending = query ? [root] : [];
|
|
88
|
+
while (pending.length) {
|
|
89
|
+
if (res.destroyed)
|
|
90
|
+
return;
|
|
91
|
+
const dir = pending.pop();
|
|
92
|
+
let children;
|
|
93
|
+
try {
|
|
94
|
+
children = await readdir(dir, { withFileTypes: true });
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
if (dir === root)
|
|
98
|
+
throw error;
|
|
99
|
+
skipped++;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
for (const entry of children) {
|
|
103
|
+
if (entry.name.startsWith('.') || entry.name === 'node_modules')
|
|
104
|
+
continue;
|
|
105
|
+
const path = join(dir, entry.name);
|
|
106
|
+
// Do not follow symlinks: avoid cycles and paths outside the selected tree.
|
|
107
|
+
if (entry.isDirectory())
|
|
108
|
+
pending.push(path);
|
|
109
|
+
else if (entry.isFile() && /\.md$/i.test(entry.name)
|
|
110
|
+
&& entry.name.normalize('NFC').toLocaleLowerCase().includes(query)) {
|
|
111
|
+
entries.push({ name: entry.name, path, relativePath: relative(root, path), directory: false });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
entries.sort((a, b) => a.relativePath.localeCompare(b.relativePath, 'ko'));
|
|
116
|
+
json(200, { root, entries, skipped });
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (req.method !== 'POST' || !['/api/convert', '/api/open', '/api/pick-folder', '/api/sync/config', '/api/sync/preview', '/api/sync/push'].includes(url.pathname)) {
|
|
120
|
+
json(404, { error: '없는 요청입니다.' });
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (!req.headers['content-type']?.startsWith('application/json')) {
|
|
124
|
+
json(415, { error: 'JSON 요청이 필요합니다.' });
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
let raw = '';
|
|
128
|
+
for await (const chunk of req) {
|
|
129
|
+
raw += chunk;
|
|
130
|
+
if (Buffer.byteLength(raw) > 16_384) {
|
|
131
|
+
json(413, { error: '요청이 너무 큽니다.' });
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const body = JSON.parse(raw);
|
|
136
|
+
if (!body || typeof body !== 'object' || Array.isArray(body))
|
|
137
|
+
throw new Error('요청 내용을 확인해 주세요.');
|
|
138
|
+
if (url.pathname === '/api/sync/config') {
|
|
139
|
+
if (typeof body.base !== 'string' || typeof body.envFile !== 'string')
|
|
140
|
+
throw new Error('설정 경로를 확인해 주세요.');
|
|
141
|
+
json(200, await sync.config(body.base, body.envFile));
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (url.pathname === '/api/sync/preview' || url.pathname === '/api/sync/push') {
|
|
145
|
+
if (busy || sync.running()) {
|
|
146
|
+
json(409, { error: '다른 작업이 진행 중입니다. 완료 후 다시 시도해 주세요.' });
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const job = url.pathname.endsWith('/preview') ? await sync.preview(body) : await sync.push(body.planId);
|
|
150
|
+
json(202, { job });
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (url.pathname === '/api/pick-folder') {
|
|
154
|
+
if (picking) {
|
|
155
|
+
json(409, { error: '이미 열린 폴더 다이얼로그를 먼저 닫아 주세요.' });
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (body.kind !== 'base' && body.kind !== 'out' && body.kind !== 'reference')
|
|
159
|
+
throw new Error('폴더 선택 용도를 확인해 주세요.');
|
|
160
|
+
let initial = start;
|
|
161
|
+
if (typeof body.start === 'string' && body.start) {
|
|
162
|
+
try {
|
|
163
|
+
const path = await realpath(body.start);
|
|
164
|
+
if ((await stat(path)).isDirectory())
|
|
165
|
+
initial = path;
|
|
166
|
+
}
|
|
167
|
+
catch { /* Use initial folder for new paths. */ }
|
|
168
|
+
}
|
|
169
|
+
if (picking) {
|
|
170
|
+
json(409, { error: '이미 열린 폴더 다이얼로그를 먼저 닫아 주세요.' });
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
picking = true;
|
|
174
|
+
try {
|
|
175
|
+
const selected = await (options.pickFolder ?? chooseFolder)(initial, body.kind === 'reference' ? '참조 문서의 동기화 폴더 선택' : body.kind === 'base' ? '문서의 기준 폴더 선택' : '변환 결과를 저장할 출력 폴더 선택');
|
|
176
|
+
const path = selected ? await realpath(selected) : null;
|
|
177
|
+
if (path && !(await stat(path)).isDirectory())
|
|
178
|
+
throw new Error('폴더를 선택해 주세요.');
|
|
179
|
+
json(200, { path });
|
|
180
|
+
}
|
|
181
|
+
finally {
|
|
182
|
+
picking = false;
|
|
183
|
+
}
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (url.pathname === '/api/open') {
|
|
187
|
+
if (typeof body.path !== 'string' || !results.has(body.path))
|
|
188
|
+
throw new Error('이번 실행에서 저장한 결과만 열 수 있습니다.');
|
|
189
|
+
const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'explorer.exe' : 'xdg-open';
|
|
190
|
+
await execute(command, [body.path], { timeout: 10_000 });
|
|
191
|
+
json(200, { ok: true });
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (busy || sync.running()) {
|
|
195
|
+
json(409, { error: '다른 작업이 진행 중입니다. 완료 후 다시 시도해 주세요.' });
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (typeof body.base !== 'string' || typeof body.file !== 'string'
|
|
199
|
+
|| !['markdown', 'obsidian', 'repair'].includes(body.to)
|
|
200
|
+
|| typeof body.fix !== 'boolean' || typeof body.preview !== 'boolean')
|
|
201
|
+
throw new Error('변환 옵션을 확인해 주세요.');
|
|
202
|
+
const browseBase = await realpath(body.base);
|
|
203
|
+
const file = await realpath(body.file);
|
|
204
|
+
const scope = body.scope ?? 'legacy';
|
|
205
|
+
if (!['file', 'folder', 'legacy'].includes(scope))
|
|
206
|
+
throw new Error('파일 또는 폴더를 선택해 주세요.');
|
|
207
|
+
const info = await stat(file);
|
|
208
|
+
if (!(await stat(browseBase)).isDirectory() || !within(browseBase, file)
|
|
209
|
+
|| (scope === 'folder' ? !info.isDirectory() : !info.isFile() || !/\.md$/i.test(file))) {
|
|
210
|
+
throw new Error('탐색 폴더 안의 Markdown 파일 또는 폴더를 선택해 주세요.');
|
|
211
|
+
}
|
|
212
|
+
const components = relative(browseBase, file).split(/[\\/]/);
|
|
213
|
+
if ((scope === 'folder' ? components : components.slice(0, -1)).some(p => p.startsWith('.') || p === 'node_modules')) {
|
|
214
|
+
throw new Error('숨김 폴더와 node_modules 안의 문서는 변환 대상이 아닙니다.');
|
|
215
|
+
}
|
|
216
|
+
const base = scope === 'legacy' ? browseBase : scope === 'folder' ? file : dirname(file);
|
|
217
|
+
if (dirname(base) === base)
|
|
218
|
+
throw new Error('문서가 있는 하위 폴더를 선택해 주세요.');
|
|
219
|
+
const documents = scope === 'folder' ? collectMarkdown(file) : [file];
|
|
220
|
+
if (!documents.length)
|
|
221
|
+
throw new Error('선택한 폴더에 변환할 Markdown 문서가 없습니다.');
|
|
222
|
+
if (body.out !== undefined && typeof body.out !== 'string')
|
|
223
|
+
throw new Error('출력 경로를 확인해 주세요.');
|
|
224
|
+
const approved = body.approvedOverwrites ?? {};
|
|
225
|
+
if (!approved || typeof approved !== 'object' || Array.isArray(approved)
|
|
226
|
+
|| Object.values(approved).some(value => typeof value !== 'string' || !/^[a-f0-9]{64}$/.test(value)))
|
|
227
|
+
throw new Error('덮어쓰기 확인 정보를 확인해 주세요.');
|
|
228
|
+
const requestedOut = body.out?.trim() ? await canonical(resolve(body.out.trim())) : null;
|
|
229
|
+
if (requestedOut && (within(base, requestedOut) || within(requestedOut, base))) {
|
|
230
|
+
throw new Error('출력 폴더는 기준 폴더와 겹치지 않는 별도 경로로 지정해 주세요.');
|
|
231
|
+
}
|
|
232
|
+
// Validation above awaits filesystem reads; another request may have acquired the slot.
|
|
233
|
+
if (busy || sync.running()) {
|
|
234
|
+
json(409, { error: '다른 작업이 진행 중입니다. 완료 후 다시 시도해 주세요.' });
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
busy = true;
|
|
238
|
+
let out;
|
|
239
|
+
let destination = null;
|
|
240
|
+
let generatedDestination = false;
|
|
241
|
+
let response;
|
|
242
|
+
let responseStatus = 200;
|
|
243
|
+
let saved;
|
|
244
|
+
try {
|
|
245
|
+
out = await mkdtemp(join(tmpdir(), 'csync-preview-'));
|
|
246
|
+
const args = ['--base', base, file, '--out', out];
|
|
247
|
+
if (body.to !== 'repair')
|
|
248
|
+
args.push('--to', body.to);
|
|
249
|
+
if (body.fix || body.to === 'repair')
|
|
250
|
+
args.push('--fix');
|
|
251
|
+
const fromSource = import.meta.url.endsWith('.ts');
|
|
252
|
+
const worker = fileURLToPath(new URL(fromSource ? '../conversion/worker.ts' : '../conversion/worker.js', import.meta.url));
|
|
253
|
+
const { stdout } = await execute(process.execPath, [...(fromSource ? process.execArgv : []), worker, ...args], {
|
|
254
|
+
env: { ...process.env, NO_COLOR: '1' }, timeout: 120_000, maxBuffer: 2 * 1024 * 1024,
|
|
255
|
+
});
|
|
256
|
+
const readPreview = async (path) => {
|
|
257
|
+
if ((await stat(path)).size > 200_000)
|
|
258
|
+
return '문서가 200KB를 넘어 본문 미리보기를 생략합니다.';
|
|
259
|
+
return readFile(path, 'utf8');
|
|
260
|
+
};
|
|
261
|
+
const previews = await Promise.all(documents.slice(0, 20).map(async (path) => ({
|
|
262
|
+
path: relative(base, path), before: await readPreview(path), after: await readPreview(join(out, relative(base, path))),
|
|
263
|
+
})));
|
|
264
|
+
const { before, after } = previews[0];
|
|
265
|
+
if (!body.preview) {
|
|
266
|
+
destination = requestedOut ?? await mkdtemp(join(dirname(base), `${basename(base)}-converted-`));
|
|
267
|
+
generatedDestination = !requestedOut;
|
|
268
|
+
saved = await publishOutput(out, destination, approved);
|
|
269
|
+
results.add(destination);
|
|
270
|
+
}
|
|
271
|
+
response = { before, after, previews, saved, documentCount: documents.length, log: stdout.split(out).join(destination ?? '(미리보기)'), output: destination };
|
|
272
|
+
}
|
|
273
|
+
catch (error) {
|
|
274
|
+
if (destination && generatedDestination)
|
|
275
|
+
await rm(destination, { recursive: true, force: true });
|
|
276
|
+
if (error instanceof OverwriteRequired) {
|
|
277
|
+
responseStatus = 409;
|
|
278
|
+
response = { error: error.message, code: 'OVERWRITE_REQUIRED', conflicts: error.conflicts };
|
|
279
|
+
}
|
|
280
|
+
else {
|
|
281
|
+
const failure = error;
|
|
282
|
+
throw new Error(failure.killed ? '변환 시간이 2분을 초과했습니다.' : failure.stderr?.trim() || message(error));
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
finally {
|
|
286
|
+
try {
|
|
287
|
+
if (out)
|
|
288
|
+
await rm(out, { recursive: true, force: true });
|
|
289
|
+
}
|
|
290
|
+
finally {
|
|
291
|
+
busy = false;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
json(responseStatus, response);
|
|
295
|
+
}
|
|
296
|
+
catch (error) {
|
|
297
|
+
if (!res.writableEnded)
|
|
298
|
+
json(400, { error: message(error) });
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
await new Promise((done, reject) => {
|
|
302
|
+
server.once('error', reject);
|
|
303
|
+
server.listen(options.port ?? 4318, '127.0.0.1', () => {
|
|
304
|
+
const address = server.address();
|
|
305
|
+
if (!address || typeof address === 'string') {
|
|
306
|
+
reject(new Error('서버 주소를 찾지 못했습니다.'));
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
origin = `http://127.0.0.1:${address.port}`;
|
|
310
|
+
for (const host of ['127.0.0.1', 'localhost', '[::1]'])
|
|
311
|
+
allowedHosts.add(new URL(`http://${host}:${address.port}`).host);
|
|
312
|
+
done();
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
// localhost may resolve to ::1 first. Bind both loopback addresses, never all interfaces.
|
|
316
|
+
const ipv6 = createServer((req, res) => { server.emit('request', req, res); });
|
|
317
|
+
server.once('close', () => { ipv6.close(); ipv6.closeAllConnections(); });
|
|
318
|
+
try {
|
|
319
|
+
await new Promise((done, reject) => {
|
|
320
|
+
ipv6.once('error', reject);
|
|
321
|
+
ipv6.listen({ port: Number(new URL(origin).port || 80), host: '::1', ipv6Only: true }, done);
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
catch (error) {
|
|
325
|
+
if (!['EAFNOSUPPORT', 'EADDRNOTAVAIL'].includes(error.code ?? '')) {
|
|
326
|
+
server.close();
|
|
327
|
+
throw error;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return { server, url: origin };
|
|
331
|
+
}
|
|
332
|
+
export async function runWeb(argv) {
|
|
333
|
+
const value = (name) => { const i = argv.indexOf(name); return i < 0 ? undefined : argv[i + 1]; };
|
|
334
|
+
const port = Number(value('--port') ?? 4318);
|
|
335
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
336
|
+
throw new Error('--port는 1~65535 사이의 숫자여야 합니다.');
|
|
337
|
+
const { url, server } = await startWeb({ start: value('--base'), port });
|
|
338
|
+
console.log(`문서 웹: ${url}\n로컬 주소: ${url.replace('127.0.0.1', 'localhost')}\n종료: Ctrl+C`);
|
|
339
|
+
if (!argv.includes('--no-open')) {
|
|
340
|
+
const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'explorer.exe' : 'xdg-open';
|
|
341
|
+
const child = spawn(command, [url], { stdio: 'ignore' });
|
|
342
|
+
child.on('error', () => console.log(`브라우저에서 ${url} 을 열어 주세요.`));
|
|
343
|
+
child.unref();
|
|
344
|
+
}
|
|
345
|
+
await new Promise(done => server.once('close', done));
|
|
346
|
+
}
|