173 lines
11 KiB
TypeScript
173 lines
11 KiB
TypeScript
import { spawn, type ChildProcess, type SpawnOptions } from 'node:child_process';
|
|
import { existsSync, statSync, realpathSync } from 'node:fs';
|
|
import { mkdir, readFile } from 'node:fs/promises';
|
|
import { randomUUID } from 'node:crypto';
|
|
import path from 'node:path';
|
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
import type { Plugin } from 'vite';
|
|
|
|
export const DRAWING_API_PATH = '/__pierzainer/drawing-extractor';
|
|
type Book = { name: string; xlsx_path: string; review_count: number };
|
|
type Result = { status: string; output_dir: string; review_count: number; failed_file_count: number; workbooks: Book[] };
|
|
type Job = { id: string; folder: string; output: string; status: 'running' | 'completed' | 'needs_review' | 'error'; logs: string[]; error?: string; result?: Result; child?: ChildProcess };
|
|
type Runtime = {
|
|
platform?: NodeJS.Platform;
|
|
exists?: (file: string) => boolean;
|
|
isDirectory?: (folder: string) => boolean;
|
|
start?: (file: string, args: string[], options: SpawnOptions) => ChildProcess;
|
|
};
|
|
|
|
/** Popup adapter: reuse the packaged engine headlessly; keep outputs inside this project. */
|
|
export function drawingLauncherPlugin(root: string, runtime: Runtime = {}): Plugin {
|
|
const app = path.join(root, '도면추출', 'app');
|
|
const pythonHome = path.join(app, 'runtime', 'python');
|
|
const executable = path.join(pythonHome, 'python.exe');
|
|
const script = path.join(root, '랜딩페이지', 'drawing-worker.py');
|
|
const outputRoot = path.join(root, '도면추출', 'output');
|
|
const exists = runtime.exists ?? existsSync;
|
|
const isDirectory = runtime.isDirectory ?? ((folder: string) => { try { return statSync(folder).isDirectory(); } catch { return false; } });
|
|
const start = runtime.start ?? spawn;
|
|
const jobs = new Map<string, Job>();
|
|
let latest: Job | undefined;
|
|
let choosing: Promise<string> | undefined;
|
|
const children = new Set<ChildProcess>();
|
|
|
|
const runWorker = (args: string[], event: (value: Record<string, unknown>) => void): { child: ChildProcess; done: Promise<void> } => {
|
|
const child = start(executable, ['-S', script, ...args], {
|
|
cwd: root, shell: false, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'],
|
|
env: { ...process.env, PYTHONHOME: pythonHome,
|
|
PYTHONPATH: [path.join(app, 'Code', 'bridge_extractor_package', 'src'), path.join(pythonHome, 'Lib', 'site-packages')].join(path.delimiter),
|
|
PYTHONUTF8: '1', PYTHONDONTWRITEBYTECODE: '1' },
|
|
});
|
|
children.add(child);
|
|
let pending = '', details = '', workerError = '';
|
|
const consume = (line: string): void => {
|
|
if (!line.trim()) return;
|
|
let value: Record<string, unknown>;
|
|
try { value = JSON.parse(line) as Record<string, unknown>; }
|
|
catch { event({ type: 'log', message: line }); return; }
|
|
if (value.type === 'error') workerError = String(value.message ?? '도면 추출 실패');
|
|
event(value);
|
|
};
|
|
child.stdout?.setEncoding('utf8');
|
|
child.stdout?.on('data', (chunk: string) => {
|
|
pending += chunk;
|
|
let index: number;
|
|
while ((index = pending.indexOf('\n')) >= 0) { consume(pending.slice(0, index)); pending = pending.slice(index + 1); }
|
|
if (pending.length > 4_000_000) { workerError = '추출 응답이 너무 큽니다.'; child.kill(); }
|
|
});
|
|
child.stderr?.setEncoding('utf8');
|
|
child.stderr?.on('data', (chunk: string) => { if (details.length < 4000) details += chunk.slice(0, 4000-details.length); });
|
|
const done = new Promise<void>((resolve, reject) => {
|
|
child.once('error', error => { children.delete(child); reject(error); });
|
|
child.once('close', code => {
|
|
children.delete(child);
|
|
consume(pending);
|
|
if (code === 0 && !workerError) resolve();
|
|
else reject(new Error(workerError || details || `도면 추출 프로세스가 종료됐습니다 (${code}).`));
|
|
});
|
|
});
|
|
return { child, done };
|
|
};
|
|
const snapshot = (job?: Job) => job ? {
|
|
id: job.id, folder: job.folder, status: job.status, logs: job.logs, error: job.error,
|
|
result: job.result ? { status: job.result.status, outputDir: job.result.output_dir,
|
|
reviewCount: job.result.review_count, failedFileCount: job.result.failed_file_count,
|
|
workbooks: job.result.workbooks.map((b, index) => ({ index, name: b.name, fileName: path.basename(b.xlsx_path), reviewCount: b.review_count })) } : undefined,
|
|
} : null;
|
|
|
|
const handle = async (req: IncomingMessage, res: ServerResponse, next: () => void): Promise<void> => {
|
|
const route = new URL(req.url ?? '/', 'http://localhost').pathname;
|
|
if (!route.startsWith(DRAWING_API_PATH + '/')) { next(); return; }
|
|
const reply = (status: number, body: object): void => {
|
|
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', Allow: 'POST' });
|
|
res.end(JSON.stringify(body));
|
|
};
|
|
if (req.method !== 'POST') { reply(405, { error: 'POST 요청만 지원합니다.' }); return; }
|
|
const host = req.headers.host ?? '', address = req.socket.remoteAddress ?? '', origin = req.headers.origin;
|
|
const local = /^(localhost|127\.0\.0\.1|\[::1\])(?::\d+)?$/i.test(host) && ['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(address);
|
|
if (!local || (origin && origin !== `http://${host}` && origin !== `https://${host}`) || req.headers['x-pierzainer-action'] !== 'drawing-extractor') {
|
|
reply(403, { error: '이 PC의 Pierzainer 화면에서 실행해 주세요.' }); return;
|
|
}
|
|
if ((runtime.platform ?? process.platform) !== 'win32') { reply(501, { error: '도면추출은 Windows PC에서 실행할 수 있습니다.' }); return; }
|
|
if (!exists(executable) || !exists(script)) { reply(503, { error: '도면추출 실행 파일을 찾을 수 없습니다.' }); return; }
|
|
let body: Record<string, unknown>;
|
|
try {
|
|
let text = '';
|
|
req.setEncoding('utf8');
|
|
for await (const chunk of req) { text += String(chunk); if (text.length > 8192) { reply(413, { error: '요청이 너무 큽니다.' }); return; } }
|
|
body = JSON.parse(text || '{}') as Record<string, unknown>;
|
|
if (!body || Array.isArray(body) || typeof body !== 'object') throw new Error();
|
|
} catch { reply(400, { error: '요청 형식이 맞지 않습니다.' }); return; }
|
|
const action = route.slice(DRAWING_API_PATH.length + 1);
|
|
if (action === 'status') {
|
|
const job = typeof body.jobId === 'string' ? jobs.get(body.jobId) : latest;
|
|
reply(200, { job: snapshot(job) }); return;
|
|
}
|
|
if (action === 'choose-folder') {
|
|
if (!choosing) choosing = (async () => {
|
|
let folder = '';
|
|
const worker = runWorker(['choose'], value => { if (value.type === 'folder') folder = String(value.folder ?? ''); });
|
|
await worker.done;
|
|
return folder;
|
|
})();
|
|
try { reply(200, { folder: await choosing }); }
|
|
finally { choosing = undefined; }
|
|
return;
|
|
}
|
|
if (action === 'start') {
|
|
if (latest?.status === 'running') { reply(409, { error: '이미 추출 중입니다.', job: snapshot(latest) }); return; }
|
|
if (typeof body.folder !== 'string' || !body.folder.trim() || body.folder.includes('\0') || !isDirectory(body.folder.trim())) {
|
|
reply(400, { error: 'DWG 도면이 들어 있는 폴더를 선택하세요.' }); return;
|
|
}
|
|
const id = randomUUID();
|
|
const job: Job = { id, folder: path.resolve(body.folder.trim()), output: path.join(outputRoot, id), status: 'running', logs: ['도면을 읽고 있습니다…'] };
|
|
jobs.set(id, job); latest = job;
|
|
try { await mkdir(job.output, { recursive: true }); }
|
|
catch (error) { job.status = 'error'; job.error = (error as Error).message; reply(500, { error: job.error }); return; }
|
|
for (const [key, old] of jobs) { if (jobs.size <= 10) break; if (old !== job && old.status !== 'running') jobs.delete(key); }
|
|
try {
|
|
const worker = runWorker(['extract', job.folder, '--output', job.output], value => {
|
|
if (value.type === 'log') { job.logs.push(String(value.message ?? '').slice(0, 1500)); if (job.logs.length > 500) job.logs.shift(); }
|
|
if (value.type === 'result') job.result = value.result as Result;
|
|
});
|
|
job.child = worker.child;
|
|
void worker.done.then(() => {
|
|
if (!job.result || !Array.isArray(job.result.workbooks) || !job.result.workbooks.length) throw new Error('생성된 Excel이 없습니다. 도면 판독 로그를 확인하세요.');
|
|
job.status = job.result.status === 'needs_review' ? 'needs_review' : 'completed';
|
|
}).catch(error => { job.status = 'error'; job.error = String(error.message); job.logs.push(job.error!); });
|
|
} catch (error) { job.status = 'error'; job.error = (error as Error).message; }
|
|
reply(202, { job: snapshot(job) }); return;
|
|
}
|
|
if (action === 'workbook') {
|
|
const job = typeof body.jobId === 'string' ? jobs.get(body.jobId) : undefined;
|
|
const index = body.index;
|
|
if (!job?.result || !['completed', 'needs_review'].includes(job.status) || !Number.isInteger(index) || (index as number) < 0) {
|
|
reply(400, { error: '추출이 완료된 Excel을 선택하세요.' }); return;
|
|
}
|
|
const book = job.result.workbooks[index as number];
|
|
if (!book || !exists(book.xlsx_path)) { reply(404, { error: '결과 파일을 찾을 수 없습니다.' }); return; }
|
|
const file = realpathSync(book.xlsx_path), output = realpathSync(job.output);
|
|
if (!file.toLowerCase().startsWith((output + path.sep).toLowerCase()) || path.extname(file).toLowerCase() !== '.xlsx') {
|
|
reply(403, { error: '이 작업의 결과 Excel만 불러올 수 있습니다.' }); return;
|
|
}
|
|
const bytes = await readFile(file);
|
|
res.writeHead(200, { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'Cache-Control': 'no-store' });
|
|
res.end(bytes); return;
|
|
}
|
|
reply(404, { error: '지원하지 않는 요청입니다.' });
|
|
};
|
|
const middleware = (req: IncomingMessage, res: ServerResponse, next: () => void): void => {
|
|
void handle(req, res, next).catch(error => {
|
|
console.error('[drawing-extractor]', error);
|
|
if (!res.headersSent) { res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify({ error: (error as Error).message || '도면추출 요청에 실패했습니다.' })); }
|
|
});
|
|
};
|
|
const cleanup = (): void => { for (const child of children) child.kill(); };
|
|
return {
|
|
name: 'pierzainer-drawing-popup',
|
|
configureServer(server) { server.middlewares.use(middleware); server.httpServer?.once('close', cleanup); },
|
|
configurePreviewServer(server) { server.middlewares.use(middleware); server.httpServer.once('close', cleanup); },
|
|
};
|
|
}
|