@kangthink/present
Version:
Transform Markdown into beautiful animated presentations with CLI and web editor support
1,073 lines (892 loc) • 40.6 kB
JavaScript
#!/usr/bin/env node
require('dotenv').config();
const { program } = require('commander');
const fs = require('fs');
const path = require('path');
const MarkdownIt = require('markdown-it');
const mdAnchor = require('markdown-it-anchor');
const mdTable = require('markdown-it-multimd-table');
const puppeteer = require('puppeteer');
const slug = require('slug');
const chokidar = require('chokidar');
const express = require('express');
const WebSocket = require('ws');
const multer = require('multer');
const os = require('os');
const OpenAI = require('openai');
const crypto = require('crypto');
const PRESET_DIR = process.env.PRESENT_STORAGE_DIR || path.join(os.homedir(), '.preset');
const LOCK_METADATA_FILE = path.join(PRESET_DIR, 'file_locks.json');
// 세션별 임시 접근 권한 저장 (메모리)
const temporaryAccess = new Map(); // sessionId -> Set(filenames)
// OpenAI 설정
const openai = process.env.OPENAI_API_KEY ? new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
}) : null;
// package.json에서 버전 읽기
const packageJson = require('./package.json');
program
.version(packageJson.version)
.description('A markdown presentation tool with web and CLI modes.')
.option('--md <path>', 'Path to the input markdown file (CLI mode)')
.option('--template <path>', 'Path to the HTML template file (CLI mode)', 'template.html')
.option('--output <path>', 'Path for the output HTML file (CLI mode)', 'output.html')
.option('--pdf', 'Generate a PDF file from the output (CLI mode)')
.option('--web', 'Run in web server mode')
.option('--port <number>', 'Port for the web server', '8090')
.parse(process.argv);
const options = program.opts();
// --- Helper Functions ---
function createMarkdownParser() {
const toc = [];
const md = new MarkdownIt({ html: true })
.use(mdAnchor, {
slugify: s => slug(s, { lower: true }),
callback: (token, { slug, title }) => {
if (token.tag === 'h1' || token.tag === 'h2' || token.tag === 'h3') {
toc.push({ level: parseInt(token.tag.substring(1)), slug: slug, title: title });
}
}
})
.use(mdTable, {
multiline: true,
rowspan: true,
headerless: true,
multibody: true
});
return { md, toc };
}
function generateTocHtml(toc) {
let html = '<ul>';
toc.forEach(item => {
html += `<li class="toc-level-${item.level}"><a href="#${item.slug}">${item.title}</a></li>`;
});
html += '</ul>';
return html;
}
// --- CLI Mode ---
async function runCli(options) {
const { md: mdPath, template: templatePath, output: outputPath, pdf: createPdf } = options;
if (!fs.existsSync(mdPath)) {
console.error(`Error: Markdown file not found at ${mdPath}`);
process.exit(1);
}
if (!fs.existsSync(templatePath)) {
console.error(`Error: Template file not found at ${templatePath}`);
process.exit(1);
}
const markdownContent = fs.readFileSync(mdPath, 'utf8');
const templateContent = fs.readFileSync(templatePath, 'utf8');
const { md, toc } = createMarkdownParser();
const contentHtml = md.render(markdownContent);
const tocHtml = generateTocHtml(toc);
const bodyClass = createPdf ? 'export-mode pdf-export-mode' : 'export-mode';
let finalHtml = templateContent
.replace('{{TOC_HTML}}', tocHtml)
.replace('{{CONTENT}}', contentHtml)
.replace('<body>', `<body class="${bodyClass}">`) // Use export styles
.replace(
'</body>',
'<script>window.IS_EXPORTED=true;</script></body>'
);
if (createPdf) {
// PDF 모드에서는 HTML 파일을 저장하지 않고 직접 PDF 생성
const browser = await puppeteer.launch({ headless: "new" });
const page = await browser.newPage();
// HTML 콘텐츠를 직접 설정 (파일 저장 없이)
await page.setContent(finalHtml, { waitUntil: 'networkidle0' });
// 애니메이션 완료 대기
await new Promise(resolve => setTimeout(resolve, 2000));
const pdfOutputPath = outputPath.replace(/\.html$/, '.pdf');
await page.pdf({ path: pdfOutputPath, format: 'A4', printBackground: false });
await browser.close();
console.log(`Successfully generated PDF file at: ${pdfOutputPath}`);
} else {
// HTML 모드에서만 HTML 파일 저장
fs.writeFileSync(outputPath, finalHtml, 'utf8');
console.log(`Successfully generated HTML file at: ${outputPath}`);
}
}
// --- Web Server Mode ---
function serveApp(options) {
// Ensure .preset directory exists for web mode
if (!fs.existsSync(PRESET_DIR)) {
fs.mkdirSync(PRESET_DIR);
}
const storage = multer.diskStorage({
destination: (req, file, cb) => cb(null, PRESET_DIR),
filename: (req, file, cb) => cb(null, file.originalname)
});
const upload = multer({ storage: storage });
const port = parseInt(options.port, 10);
const app = express();
app.use(express.json());
app.use(express.static(__dirname));
// 세션 관리를 위한 간단한 세션 ID 생성
function generateSessionId() {
return crypto.randomBytes(32).toString('hex');
}
// 쿠키 파서 미들웨어
app.use((req, res, next) => {
req.cookies = {};
const cookieHeader = req.headers.cookie;
if (cookieHeader) {
cookieHeader.split(';').forEach(cookie => {
const [name, value] = cookie.trim().split('=');
req.cookies[name] = value;
});
}
next();
});
// 세션 ID 미들웨어
app.use((req, res, next) => {
if (!req.cookies.sessionId) {
req.sessionId = generateSessionId();
res.setHeader('Set-Cookie', `sessionId=${req.sessionId}; HttpOnly; Path=/; Max-Age=86400`);
} else {
req.sessionId = req.cookies.sessionId;
}
next();
});
// 마크다운 파일 직접 접근 제한 미들웨어
app.use('/storage', (req, res, next) => {
if (req.path.endsWith('.md')) {
return res.status(403).json({
error: 'Direct access to markdown files is not allowed. Use /api/get-file endpoint instead.'
});
}
next();
});
app.use('/storage', express.static(PRESET_DIR));
app.get('/', (req, res) => {
const files = fs.readdirSync(PRESET_DIR).filter(file => file.endsWith('.md'));
const lockMetadata = loadFileLockMetadata();
let fileList = files.map(file => {
const isLocked = lockMetadata[file] && lockMetadata[file].isLocked;
const lockIcon = isLocked ? '<span style="color: #ff6b35; margin-left: 8px;">🔒</span>' : '';
const lockClass = isLocked ? 'locked-file' : '';
return `<li class="${lockClass}">
<a href="/view?file=${file}">${file}</a>${lockIcon}
${isLocked ? '<span style="color: #666; font-size: 12px; margin-left: 8px;">(잠김)</span>' : ''}
</li>`;
}).join('');
res.send(`
<!DOCTYPE html>
<html>
<head><title>Presentations</title>
<style>
body { font-family: sans-serif; padding: 2em; }
.container { max-width: 800px; margin: auto; }
ul { list-style: none; padding: 0; }
li { padding: 0.8em; border-bottom: 1px solid #eee; display: flex; align-items: center; }
li.locked-file { background-color: #fff8f0; border-left: 3px solid #ff6b35; }
a { text-decoration: none; color: #0366d6; flex-grow: 1; }
a:hover { text-decoration: underline; }
.actions { margin-top: 2em; display: flex; gap: 1em; }
.actions form { display: flex; gap: 0.5em; }
.actions input[type="file"], .actions input[type="text"] { padding: 0.3em; }
.actions button { padding: 0.3em 0.8em; background: #0366d6; color: white; border: none; border-radius: 3px; cursor: pointer; }
.actions button:hover { background: #0256cc; }
</style>
</head>
<body>
<div class="container">
<h1>📄 Presentations</h1>
<ul>${fileList}</ul>
<div class="actions">
<form action="/upload" method="post" enctype="multipart/form-data">
<input type="file" name="markdown" accept=".md" required>
<button type="submit">📤 Upload</button>
</form>
<form action="/create" method="post">
<input type="text" name="filename" placeholder="new-presentation.md" required>
<button type="submit">➕ Create New</button>
</form>
</div>
</div>
</body>
</html>
`);
});
app.get('/view', (req, res) => {
const file = req.query.file;
if (!file || !fs.existsSync(path.join(PRESET_DIR, file))) {
return res.status(404).send('File not found.');
}
// 웹 모드에서는 빈 template을 보내고 JavaScript에서 동적으로 콘텐츠 로드
const templatePath = path.join(__dirname, 'template.html');
let templateContent = fs.readFileSync(templatePath, 'utf8');
// 플레이스홀더를 빈 값으로 치환 (JavaScript에서 동적으로 채울 예정)
templateContent = templateContent
.replace('{{CONTENT}}', '')
.replace('{{TOC_HTML}}', '');
res.send(templateContent);
});
app.post('/upload', upload.single('markdown'), (req, res) => {
console.log(`Uploaded: ${req.file.filename}`);
res.redirect('/');
});
app.post('/create', express.urlencoded({ extended: true }), (req, res) => {
let filename = req.body.filename;
if (!filename.endsWith('.md')) filename += '.md';
const filepath = path.join(PRESET_DIR, filename);
if (!fs.existsSync(filepath)) {
fs.writeFileSync(filepath, '# New Presentation\n\nStart writing here.', 'utf8');
console.log(`Created: ${filename}`);
}
res.redirect('/');
});
app.post('/api/render', (req, res) => {
try {
const { markdown } = req.body;
if (typeof markdown !== 'string') return res.status(400).json({ error: 'Invalid content.' });
const { md, toc } = createMarkdownParser();
const contentHtml = md.render(markdown);
const tocHtml = generateTocHtml(toc);
res.json({ contentHtml, tocHtml });
} catch (error) {
console.error('Error in /api/render:', error);
res.status(500).json({ error: 'Server failed to render markdown.' });
}
});
app.post('/save-md', (req, res) => {
const { content, file } = req.body;
if (typeof content !== 'string' || !file) return res.status(400).send('Invalid request.');
// 파일 잠금 상태 확인
const metadata = loadFileLockMetadata();
const fileMetadata = metadata[file];
// 세션별 임시 접근 권한 확인
const sessionAccess = temporaryAccess.get(req.sessionId) || new Set();
const hasTemporaryAccess = sessionAccess.has(file);
if (fileMetadata && fileMetadata.isLocked && !hasTemporaryAccess) {
return res.status(403).json({ error: 'File is locked. Please unlock it first.' });
}
fs.writeFile(path.join(PRESET_DIR, file), content, 'utf8', (err) => {
if (err) return res.status(500).send('Error saving file.');
res.status(200).send('File saved.');
});
});
// 파일 내용 가져오기 (잠금 상태 확인 포함)
app.get('/api/get-file', (req, res) => {
try {
const { filename } = req.query;
console.log('파일 요청:', { filename, sessionId: req.sessionId });
if (!filename) {
return res.status(400).json({ error: 'Filename is required' });
}
const filePath = path.join(PRESET_DIR, filename);
if (!fs.existsSync(filePath)) {
return res.status(404).json({ error: 'File not found' });
}
// 파일 잠금 상태 확인
let metadata = {};
try {
if (fs.existsSync(LOCK_METADATA_FILE)) {
const data = fs.readFileSync(LOCK_METADATA_FILE, 'utf8');
metadata = JSON.parse(data);
}
} catch (error) {
console.error('파일 잠금 메타데이터 로드 오류:', error);
}
const fileMetadata = metadata[filename];
// 세션별 임시 접근 권한 확인
const sessionAccess = temporaryAccess.get(req.sessionId) || new Set();
const hasTemporaryAccess = sessionAccess.has(filename);
if (fileMetadata && fileMetadata.isLocked && !hasTemporaryAccess) {
// 잠긴 파일의 경우 비밀번호 입력 UI 반환
return res.json({
content: '',
isLocked: true,
needsPassword: true,
filename: filename
});
}
// 잠기지 않은 파일이거나 임시 접근 권한이 있는 경우 실제 내용 반환
const content = fs.readFileSync(filePath, 'utf8');
res.json({ content: content, isLocked: false });
} catch (error) {
console.error('Error in /api/get-file:', error);
res.status(500).json({ error: 'Failed to load file' });
}
});
// 임시 접근 권한 부여 (비밀번호 확인)
app.post('/api/temporary-access', (req, res) => {
try {
const { filename, password } = req.body;
if (!filename || !password) {
return res.status(400).json({ error: 'Filename and password are required' });
}
// 파일 잠금 메타데이터 로드
let metadata = {};
try {
if (fs.existsSync(LOCK_METADATA_FILE)) {
const data = fs.readFileSync(LOCK_METADATA_FILE, 'utf8');
metadata = JSON.parse(data);
}
} catch (error) {
console.error('파일 잠금 메타데이터 로드 오류:', error);
return res.status(500).json({ error: 'Failed to load file metadata' });
}
const fileMetadata = metadata[filename];
if (!fileMetadata || !fileMetadata.isLocked) {
return res.status(400).json({ error: 'File is not locked' });
}
// 비밀번호 확인
function hashPassword(password, salt) {
return crypto.pbkdf2Sync(password, salt, 100000, 64, 'sha512').toString('hex');
}
const providedHash = hashPassword(password, fileMetadata.salt);
if (providedHash !== fileMetadata.passwordHash) {
return res.status(401).json({ error: 'Invalid password' });
}
// 세션에 임시 접근 권한 부여
if (!temporaryAccess.has(req.sessionId)) {
temporaryAccess.set(req.sessionId, new Set());
}
temporaryAccess.get(req.sessionId).add(filename);
res.json({ success: true, message: 'Temporary access granted' });
} catch (error) {
console.error('Error in /api/temporary-access:', error);
res.status(500).json({ error: 'Failed to grant temporary access' });
}
});
app.post('/export-pdf', async (req, res) => {
try {
const { markdown } = req.body;
if (typeof markdown !== 'string') return res.status(400).send('Invalid content.');
const { md, toc } = createMarkdownParser();
const templatePath = path.resolve(__dirname, 'template.html');
const templateContent = fs.readFileSync(templatePath, 'utf8');
const htmlContent = md.render(markdown);
const tocHtml = generateTocHtml(toc);
let finalHtml = templateContent
.replace('{{CONTENT}}', htmlContent)
.replace('{{TOC_HTML}}', tocHtml);
// Add a class to the body for PDF export to hide all interactive elements
finalHtml = finalHtml.replace('<body>', '<body class="export-mode pdf-export-mode">');
// Inject a flag to prevent client-side re-rendering in Puppeteer
finalHtml = finalHtml.replace(
'</body>',
'<script>window.IS_PDF_EXPORT = true;</script></body>'
);
const browser = await puppeteer.launch({ headless: "new" });
const page = await browser.newPage();
await page.setContent(finalHtml, { waitUntil: 'networkidle0' });
await new Promise(resolve => setTimeout(resolve, 1000));
const pdfBuffer = await page.pdf({ format: 'A4', printBackground: false });
await browser.close();
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', 'attachment; filename=presentation.pdf');
res.send(pdfBuffer);
} catch (error) {
console.error('Error exporting PDF:', error);
res.status(500).send('Error generating PDF.');
}
});
app.post('/api/ai-suggest', async (req, res) => {
try {
const {
text,
cursorPosition,
mode = 'continue',
selectedText = '',
apiKey = '',
provider = 'openai',
model = 'gpt-4o-mini',
endpoint = '',
customContinuePrompt = '',
customImprovePrompt = ''
} = req.body;
console.log('\n=== AI 추천 요청 시작 ===');
console.log('🔧 요청 정보:', {
모드: mode,
제공자: provider,
모델: model,
전체문서길이: text?.length,
커서위치: cursorPosition,
선택된텍스트길이: selectedText?.length,
API키있음: !!apiKey
});
// 디버깅용: 커서 위치 확인
const beforeCursorText = text.substring(0, cursorPosition);
const afterCursorText = text.substring(cursorPosition);
console.log('📍 컨텍스트 분석:', {
전체문서미리보기: text.length > 200 ? text.substring(0, 100) + '...' + text.substring(text.length - 100) : text,
커서앞텍스트: beforeCursorText.slice(-100), // 커서 바로 앞 100자
커서뒤텍스트: afterCursorText.slice(0, 100) // 커서 바로 뒤 100자
});
// API 키가 제공되지 않은 경우
if (!apiKey && !process.env.OPENAI_API_KEY) {
console.log('API 키가 제공되지 않음');
const fallbackSuggestions = mode === 'improve'
? ['더 명확하게 표현', '구체적인 설명 추가', '간결하게 정리']
: ['계속 작성하세요...', '더 자세히 설명', '예시를 추가'];
return res.status(400).json({
error: 'API key required',
suggestions: fallbackSuggestions
});
}
// 현재는 OpenAI와 OpenAI 호환 API만 지원
if (provider !== 'openai' && provider !== 'openai-compatible') {
console.log(`지원하지 않는 제공자: ${provider}. OpenAI로 대체합니다.`);
// 다른 제공자는 추후 구현
const fallbackSuggestions = mode === 'improve'
? ['더 명확하게 표현', '구체적인 설명 추가', '간결하게 정리']
: ['계속 작성하세요...', '더 자세히 설명', '예시를 추가'];
return res.json({
suggestions: fallbackSuggestions,
message: `${provider} 제공자는 아직 지원하지 않습니다. OpenAI를 사용해주세요.`
});
}
// OpenAI 클라이언트 설정
let currentOpenai;
if (provider === 'openai-compatible' && endpoint) {
// OpenAI 호환 API 사용
currentOpenai = new OpenAI({
apiKey: apiKey,
baseURL: endpoint.endsWith('/v1') ? endpoint : endpoint + '/v1'
});
} else {
// 기본 OpenAI API 사용
currentOpenai = apiKey ? new OpenAI({ apiKey }) : openai;
}
if (typeof text !== 'string' || typeof cursorPosition !== 'number') {
return res.status(400).json({ error: 'Invalid request parameters.' });
}
const beforeCursor = text.substring(0, cursorPosition);
const afterCursor = text.substring(cursorPosition);
// 모드에 따라 다른 프롬프트 생성
let prompt;
if (mode === 'improve') {
// 개선 모드 프롬프트 (커스텀 프롬프트가 있으면 사용)
const basePrompt = customImprovePrompt || `당신은 전문적인 글쓰기 도구입니다. 선택된 텍스트의 핵심 의도와 메시지를 보존하면서 더 효과적인 표현으로 개선해주세요.
<선택된텍스트>
{selectedText}
</선택된텍스트>
<전체문서>
{text}
</전체문서>
위 정보를 참고하여 선택된 텍스트를 개선해주세요. 원본의 의도와 포맷을 반드시 유지하면서 문법, 명확성, 표현력만 개선해주세요.`;
// 변수 치환 (커스텀 프롬프트든 기본 프롬프트든 항상 수행)
const userPrompt = basePrompt
.replace(/{text}/g, text)
.replace(/{selectedText}/g, selectedText)
.replace(/{beforeCursor}/g, beforeCursor)
.replace(/{afterCursor}/g, afterCursor);
// 항상 동일한 구조로 프롬프트 생성 (출력 형식 보장)
prompt = userPrompt + `
글쓰기 개선 지침:
1. **원본의 의도와 핵심 메시지를 절대 변경하지 마세요**
2. **원본과 동일한 톤, 문체, 격식 수준을 유지**하세요 (존댓말/반말, 어조 등)
3. **마크다운 포맷을 완전히 보존**하세요 (헤딩, 리스트, 굵기, 링크 등)
4. **원본과 비슷한 길이를 유지**하며 문법, 명확성, 표현력만 개선하세요
5. **불필요한 장식이나 과도한 표현은 피하고** 자연스럽게 개선하세요
6. 전체 문서의 **맥락과 일관성**을 고려하여 적절한 수준으로 개선하세요
7. **3-5개의 다양한 개선 버전을 제공**하되, 모두 원본의 성격을 유지해야 합니다
8. 반드시 순수 JSON 배열 형태로만 반환하세요 (코드 블록이나 설명 없이)
응답 형식 (이 형태 그대로):
["개선된 표현 1", "개선된 표현 2", "개선된 표현 3", "개선된 표현 4", "개선된 표현 5"]`;
} else {
// 연속 작성 모드 프롬프트 (커스텀 프롬프트가 있으면 사용)
const basePrompt = customContinuePrompt || `당신은 전문적인 글쓰기 도구입니다. 커서 위치의 구조적 맥락을 정확히 파악하고, 그에 맞는 적절한 내용을 제안해주세요.
<커서앞내용>
{beforeCursor}
</커서앞내용>
<커서뒤내용>
{afterCursor}
</커서뒤내용>
<전체문서>
{text}
</전체문서>
위 정보를 참고하여 커서 위치에 적절한 내용을 제안해주세요. 커서 앞 내용의 구조와 맥락을 면밀히 분석하여 가장 자연스러운 후속 내용을 생성하세요.`;
// 변수 치환 (커스텀 프롬프트든 기본 프롬프트든 항상 수행)
const userPrompt = basePrompt
.replace(/{text}/g, text)
.replace(/{beforeCursor}/g, beforeCursor)
.replace(/{afterCursor}/g, afterCursor);
// 항상 동일한 구조로 프롬프트 생성 (출력 형식 보장)
prompt = userPrompt + `
글쓰기 지원 지침:
1. **커서 위치의 구조적 맥락을 정확히 파악**하세요:
- 리스트 중간 → 리스트 항목 완성/추가
- 문단 중간 → 문장 완성/연결
- 섹션 끝 → 다음 섹션 또는 내용 확장
- 테이블/코드블록 → 해당 형식 유지
2. **기존 문체와 톤을 완전히 유지**하며 자연스럽게 이어지는 내용을 생성하세요
3. **기존 마크다운 포맷을 정확히 따라**하세요 (헤딩 레벨, 리스트 형식, 굵기 등)
4. 각 제안은 **간결하고 핵심적인 내용**으로 작성하세요 (20-150자 정도)
5. 현재 문단/섹션의 **주제와 목적에 정확히 맞는** 내용을 제안하세요
6. **커서 앞 마지막 부분의 맥락**을 우선 고려하여 자연스럽게 이어지도록 하세요
7. **번화하거나 불필요한 내용은 피하고** 핵심만 간결하게 표현하세요
8. 전체 문서의 **구조와 일관성**을 고려하여 적절한 내용을 생성하세요
9. **5개 정도의 다양한 제안을 제공**하되, 모두 유용하고 구체적이어야 합니다
10. 반드시 순수 JSON 배열 형태로만 반환하세요 (코드 블록이나 설명 없이)
응답 형식 (이 형태 그대로):
["제안 1", "제안 2", "제안 3", "제안 4", "제안 5"]`;
}
console.log('\n📤 AI에게 전송할 프롬프트:');
console.log('---BEGIN PROMPT---');
console.log(prompt);
console.log('---END PROMPT---');
// 변수 치환 확인
console.log('\n🔍 변수 치환 확인:', {
프롬프트길이: prompt.length,
text변수포함: prompt.includes('{text}'),
beforeCursor변수포함: prompt.includes('{beforeCursor}'),
afterCursor변수포함: prompt.includes('{afterCursor}'),
selectedText변수포함: prompt.includes('{selectedText}')
});
console.log('\n🤖 AI 호출 중...', `${provider} (${model})`);
// 디버깅용: 프롬프트 변수 확인
if (mode === 'continue') {
console.log('📝 연속 작성 모드 세부 정보:', {
커서앞마지막줄: beforeCursor.split('\n').pop(),
커서뒤첫줄: afterCursor.split('\n')[0],
변수치환여부: customContinuePrompt ? '사용자정의' : '기본'
});
} else if (mode === 'improve') {
console.log('✨ 개선 모드 세부 정보:', {
선택된텍스트: selectedText.slice(0, 100) + (selectedText.length > 100 ? '...' : ''),
변수치환여부: customImprovePrompt ? '사용자정의' : '기본'
});
}
const completion = await currentOpenai.chat.completions.create({
model: model,
messages: [
{
role: "system",
content: "당신은 전문적인 글쓰기 도구입니다. 사용자의 요청에 따라 적절한 개수의 제안을 JSON 배열 형식으로만 응답하세요. 다른 어떤 텍스트, 설명, 마크다운도 포함하지 마세요. 오직 [\"제안1\", \"제안2\", \"제안3\", \"제안4\", \"제안5\"] 형식처럼 순수한 JSON 배열만 반환하세요."
},
{ role: "user", content: prompt + "\n\n중요: 사용자가 프롬프트에서 요청한 개수만큼 항목을 가진 JSON 배열만 반환하세요. 개수가 명시되지 않으면 3-5개를 기본으로 하되, 품질을 위해 유용한 만큼 제안하세요." }
],
max_completion_tokens: 1000,
temperature: 0.3
});
const rawResponse = completion.choices[0].message.content;
console.log('\n📥 AI 원본 응답:');
console.log('---BEGIN RESPONSE---');
console.log(rawResponse);
console.log('---END RESPONSE---');
console.log('\n🔍 응답 파싱 중...');
let suggestions;
try {
let responseContent = rawResponse.trim();
console.log('📊 응답 정보:', {
길이: responseContent.length,
타입: typeof responseContent,
첫100자: responseContent.substring(0, 100)
});
// 1. 마크다운 코드 블록 제거
responseContent = responseContent.replace(/^```(?:json)?\s*/gm, '').replace(/\s*```$/gm, '');
// 2. JSON 배열 패턴 찾기 (더 강력한 정규식)
const jsonArrayPattern = /\[\s*"[^"]*"(?:\s*,\s*"[^"]*")*\s*\]/;
let jsonMatch = responseContent.match(jsonArrayPattern);
if (!jsonMatch) {
// 3. 대안: 더 유연한 패턴으로 시도 (따옴표가 섞여있을 수 있음)
const flexiblePattern = /\[[\s\S]*?\]/;
jsonMatch = responseContent.match(flexiblePattern);
}
if (jsonMatch) {
responseContent = jsonMatch[0];
} else {
// 4. JSON 배열이 없는 경우: 텍스트에서 문장을 추출해서 배열로 만들기
console.log('JSON 패턴을 찾을 수 없음. 텍스트에서 추출 시도...');
// 텍스트를 줄바꿈으로 분리하여 처리
const lines = responseContent.split('\n').filter(line => line.trim().length > 0);
const extractedSuggestions = [];
for (const line of lines) {
const cleanLine = line.replace(/^[-*\d\.)\s]+/, '').trim(); // 리스트 마커 제거
if (cleanLine.length > 5 && cleanLine.length < 200) { // 적절한 길이의 문장만
extractedSuggestions.push(cleanLine);
if (extractedSuggestions.length >= 3) break;
}
}
if (extractedSuggestions.length >= 3) {
suggestions = extractedSuggestions.slice(0, 3);
} else {
throw new Error('텍스트에서 적절한 제안을 추출할 수 없음');
}
}
// 5. JSON 파싱 시도 (위에서 배열을 만들지 않은 경우)
if (!suggestions) {
console.log('🔍 JSON 파싱 시도:', responseContent);
// 불완전한 JSON 수정 시도
let fixedJson = responseContent;
// 끝이 잘린 경우 수정
if (!fixedJson.endsWith(']')) {
const lastQuoteIndex = fixedJson.lastIndexOf('"');
if (lastQuoteIndex > 0) {
fixedJson = fixedJson.substring(0, lastQuoteIndex + 1) + ']';
}
}
console.log('🔧 수정된 JSON:', fixedJson);
suggestions = JSON.parse(fixedJson);
}
if (!Array.isArray(suggestions) || suggestions.length === 0) {
throw new Error('Invalid response format');
}
// 최대 10개로 제한 (너무 많으면 UI가 복잡해짐)
suggestions = suggestions.slice(0, 10);
console.log('\n✅ 파싱 성공!');
} catch (parseError) {
console.log('\n❌ JSON 파싱 실패:', parseError.message);
console.log('🔄 기본 추천으로 대체');
// 파싱 실패 시 모드에 따른 기본 추천 제공
if (mode && mode === 'improve') {
suggestions = [
"더 명확하게 표현",
"구체적인 설명으로 개선",
"간결하고 효과적으로 수정"
];
} else {
suggestions = [
"을 계속 작성하세요",
"에 대한 자세한 설명",
"의 구체적인 예시"
];
}
}
console.log('\n🎯 최종 추천 결과:');
console.log('개수:', suggestions.length);
console.log('내용:', suggestions);
console.log('=== AI 추천 요청 완료 ===\n');
res.json({ suggestions });
} catch (error) {
console.log('\n💥 AI 추천 요청 실패:', error.message);
console.log('=== AI 추천 요청 완료 (실패) ===\n');
const errorSuggestions = (mode && mode === 'improve')
? ["더 명확하게 표현", "구체적인 설명 추가", "간결하게 정리"]
: ["계속 작성하기...", "더 자세히 설명", "예시 추가하기"];
res.status(500).json({
error: 'Failed to generate suggestions',
suggestions: errorSuggestions
});
}
});
// === 파일 잠금 관련 헬퍼 함수들 ===
// 파일 메타데이터 로드
function loadFileLockMetadata() {
try {
if (fs.existsSync(LOCK_METADATA_FILE)) {
const data = fs.readFileSync(LOCK_METADATA_FILE, 'utf8');
return JSON.parse(data);
}
} catch (error) {
console.error('파일 잠금 메타데이터 로드 오류:', error);
}
return {};
}
// 파일 메타데이터 저장
function saveFileLockMetadata(metadata) {
try {
// PRESET_DIR이 없으면 생성
if (!fs.existsSync(PRESET_DIR)) {
fs.mkdirSync(PRESET_DIR, { recursive: true });
}
fs.writeFileSync(LOCK_METADATA_FILE, JSON.stringify(metadata, null, 2));
} catch (error) {
console.error('파일 잠금 메타데이터 저장 오류:', error);
}
}
// 비밀번호 해시 생성
function hashPassword(password, salt) {
return crypto.pbkdf2Sync(password, salt, 100000, 64, 'sha512').toString('hex');
}
// 랜덤 salt 생성
function generateSalt() {
return crypto.randomBytes(32).toString('hex');
}
// 파일 내용 암호화
function encryptFileContent(content, password) {
const algorithm = 'aes-256-gcm';
const key = crypto.scryptSync(password, 'salt', 32);
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipher(algorithm, key);
let encrypted = cipher.update(content, 'utf8', 'hex');
encrypted += cipher.final('hex');
return {
encrypted: encrypted,
iv: iv.toString('hex')
};
}
// 파일 내용 복호화
function decryptFileContent(encryptedData, password) {
const algorithm = 'aes-256-gcm';
const key = crypto.scryptSync(password, 'salt', 32);
const decipher = crypto.createDecipher(algorithm, key);
let decrypted = decipher.update(encryptedData.encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
// === 파일 잠금 API 엔드포인트들 ===
// 파일 잠금 상태 확인
app.get('/api/check-file-lock', (req, res) => {
try {
const { filename } = req.query;
if (!filename) {
return res.status(400).json({ error: 'Filename is required' });
}
const metadata = loadFileLockMetadata();
const isLocked = metadata[filename] && metadata[filename].isLocked;
res.json({ isLocked: !!isLocked });
} catch (error) {
console.error('파일 잠금 상태 확인 오류:', error);
res.status(500).json({ error: 'Failed to check file lock status' });
}
});
// 파일 잠금 설정
app.post('/api/set-file-lock', (req, res) => {
try {
const { filename, password } = req.body;
if (!filename || !password) {
return res.status(400).json({ error: 'Filename and password are required' });
}
if (password.length < 4) {
return res.status(400).json({ error: 'Password must be at least 4 characters' });
}
const metadata = loadFileLockMetadata();
const salt = generateSalt();
const hashedPassword = hashPassword(password, salt);
// 파일이 존재하는지 확인하고 내용 읽기
const filePath = path.join(PRESET_DIR, filename);
if (fs.existsSync(filePath)) {
const content = fs.readFileSync(filePath, 'utf8');
const encryptedData = encryptFileContent(content, password);
// 암호화된 내용으로 파일 저장
fs.writeFileSync(filePath + '.encrypted', JSON.stringify(encryptedData));
// 원본 파일 삭제 (선택적)
// fs.unlinkSync(filePath);
}
// 메타데이터 업데이트
metadata[filename] = {
isLocked: true,
passwordHash: hashedPassword,
salt: salt,
createdAt: new Date().toISOString(),
hasEncryptedFile: true
};
saveFileLockMetadata(metadata);
res.json({ success: true, message: 'File locked successfully' });
} catch (error) {
console.error('파일 잠금 설정 오류:', error);
res.status(500).json({ error: 'Failed to set file lock' });
}
});
// 파일 잠금 해제
app.post('/api/remove-file-lock', (req, res) => {
try {
const { filename } = req.body;
if (!filename) {
return res.status(400).json({ error: 'Filename is required' });
}
const metadata = loadFileLockMetadata();
if (metadata[filename]) {
// 암호화된 파일 삭제
const encryptedFilePath = path.join(PRESET_DIR, filename + '.encrypted');
if (fs.existsSync(encryptedFilePath)) {
fs.unlinkSync(encryptedFilePath);
}
// 메타데이터에서 제거
delete metadata[filename];
saveFileLockMetadata(metadata);
}
res.json({ success: true, message: 'File lock removed successfully' });
} catch (error) {
console.error('파일 잠금 해제 오류:', error);
res.status(500).json({ error: 'Failed to remove file lock' });
}
});
// 파일 잠금 해제 (비밀번호로)
app.post('/api/unlock-file', (req, res) => {
try {
const { filename, password } = req.body;
if (!filename || !password) {
return res.status(400).json({ error: 'Filename and password are required' });
}
const metadata = loadFileLockMetadata();
const fileMetadata = metadata[filename];
if (!fileMetadata || !fileMetadata.isLocked) {
return res.status(400).json({ error: 'File is not locked' });
}
// 비밀번호 검증
const hashedPassword = hashPassword(password, fileMetadata.salt);
if (hashedPassword !== fileMetadata.passwordHash) {
return res.json({ success: false, message: 'Incorrect password' });
}
// 암호화된 파일 내용 복호화
const encryptedFilePath = path.join(PRESET_DIR, filename + '.encrypted');
if (fs.existsSync(encryptedFilePath)) {
const encryptedData = JSON.parse(fs.readFileSync(encryptedFilePath, 'utf8'));
const decryptedContent = decryptFileContent(encryptedData, password);
// 복호화된 내용을 원본 파일로 복원
const originalFilePath = path.join(PRESET_DIR, filename);
fs.writeFileSync(originalFilePath, decryptedContent);
}
res.json({ success: true, message: 'File unlocked successfully' });
} catch (error) {
console.error('파일 잠금 해제 오류:', error);
res.status(500).json({ error: 'Failed to unlock file' });
}
});
app.post('/export-html', async (req, res) => {
try {
const { markdown, file } = req.body;
if (typeof markdown !== 'string') return res.status(400).send('Invalid content.');
const { md, toc } = createMarkdownParser();
const templatePath = path.resolve(__dirname, 'template.html');
const templateContent = fs.readFileSync(templatePath, 'utf8');
const contentHtml = md.render(markdown);
const tocHtml = generateTocHtml(toc);
let finalHtml = templateContent
.replace('{{TOC_HTML}}', tocHtml)
.replace('{{CONTENT}}', contentHtml);
// Add a class to the body for HTML export to hide elements via CSS
// and remove the live-reload script.
finalHtml = finalHtml
.replace('<body>', '<body class="export-mode">')
.replace(
'</body>',
'<script>window.IS_EXPORTED=true;</script></body>'
);
const filename = file ? path.basename(file, '.md') + '.html' : 'presentation.html';
res.setHeader('Content-Type', 'text/html');
res.setHeader('Content-Disposition', `attachment; filename=${filename}`);
res.send(finalHtml);
} catch (error) {
console.error('Error exporting HTML:', error);
res.status(500).send('Error generating HTML file.');
}
});
app.post('/export-markdown', async (req, res) => {
try {
const { markdown, file } = req.body;
if (typeof markdown !== 'string') return res.status(400).send('Invalid content.');
const filename = file ? path.basename(file) : 'presentation.md';
res.setHeader('Content-Type', 'text/markdown; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename=${filename}`);
res.send(markdown);
} catch (error) {
console.error('Error exporting Markdown:', error);
res.status(500).send('Error generating Markdown file.');
}
});
const server = app.listen(port, () => console.log(`Server started on http://localhost:${port}`));
const wss = new WebSocket.Server({ server });
wss.on('connection', ws => console.log('Client connected for live reload.'));
const broadcastReload = () => wss.clients.forEach(c => c.readyState === WebSocket.OPEN && c.send('reload'));
chokidar.watch(PRESET_DIR).on('all', (event, path) => {
if (['add', 'change', 'unlink'].includes(event)) {
console.log(`${path} has been ${event}`);
broadcastReload();
}
});
}
// --- Main Execution Logic ---
if (options.web) {
serveApp(options);
} else if (options.md) {
runCli(options);
} else {
// If no relevant options are provided, show help.
program.help();
}