Release 2.9.1: PPTX wizard project persistence + PDF column extraction

- PDF: PyMuPDF 2-column aware extraction (left col → right col, drop cap merge,
  header/footer/footnote removal), reference stripping, 200k char limit
- Wizard: chunked parallel translation (15k chars/chunk, ~4x faster)
- Wizard: save UPL_ (upload) papers to papers.json (were silently skipped)
- Wizard: restore UPL_ papers from manifest with _isUpload/_localPdf fields
- Wizard: project-papers endpoint correctly resolves UPL_ ko.txt in project dir
- Wizard: images loaded on manifest restore (renderImgGrid unconditional)
- Wizard: outline.json auto-save after generation and slide edits
- Wizard: outline.json fallback load when no PPTX exists
- Wizard: loadProjList() refresh after savePapersToProject()
- Code editor: default font size 14→12px

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
kim
2026-06-01 23:29:54 +09:00
co-authored by Claude Sonnet 4.6
parent 6963ccb9a0
commit d9df800524
4 changed files with 2575 additions and 233 deletions
+1141 -110
View File
File diff suppressed because it is too large Load Diff
+684 -69
View File
@@ -329,9 +329,33 @@ body{background:var(--bg);color:var(--text);font-family:'Segoe UI',system-ui,san
<span id="s2-overall" style="display:none;font-size:11px;color:var(--brand2);font-weight:700"></span>
</div>
<div id="proglist"></div>
<div id="img-search-section" style="margin-top:14px;border-top:1px solid var(--line);padding-top:10px">
<div style="font-size:10px;color:var(--muted);font-weight:600;text-transform:uppercase;letter-spacing:.04em;margin-bottom:6px">🔍 이미지 검색</div>
<div style="display:flex;gap:6px">
<input id="img-search-input" type="text" placeholder="예: 구강 해부도, TMJ X-ray"
style="flex:1;font-size:12px;padding:4px 8px;background:var(--panel2);border:1px solid var(--line);border-radius:5px;color:var(--fg);outline:none"
onkeydown="if(event.key==='Enter')doImgSearch()">
<button class="btn bg" style="font-size:11px;padding:3px 10px;flex-shrink:0" onclick="doImgSearch()">검색</button>
</div>
<div id="img-search-results" style="margin-top:8px;max-height:260px;overflow-y:auto"></div>
</div>
</div>
<div style="flex:2;min-width:0">
<div style="font-size:10px;color:var(--muted);margin-bottom:8px;font-weight:600;text-transform:uppercase;letter-spacing:.04em">캡처 이미지 <span id="imgcnt"></span></div>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
<span style="font-size:10px;color:var(--muted);font-weight:600;text-transform:uppercase;letter-spacing:.04em">캡처 이미지 <span id="imgcnt"></span></span>
<label style="display:flex;align-items:center;gap:4px;font-size:11px;color:var(--brand2);background:rgba(99,102,241,.1);border:1px solid var(--brand);border-radius:5px;padding:2px 8px;cursor:pointer;flex-shrink:0" title="이미지 파일 업로드">
<input type="file" id="img-upload-input" accept="image/*" multiple style="display:none" onchange="handleImgUpload(this.files)">
+ 이미지 업로드
</label>
</div>
<div id="img-dropzone"
style="border:2px dashed var(--line);border-radius:7px;padding:6px 10px;text-align:center;font-size:11px;color:var(--muted);margin-bottom:7px;cursor:pointer;transition:.15s"
onclick="document.getElementById('img-upload-input').click()"
ondragover="event.preventDefault();this.style.borderColor='var(--brand)';this.style.color='var(--brand2)'"
ondragleave="this.style.borderColor='';this.style.color=''"
ondrop="event.preventDefault();this.style.borderColor='';this.style.color='';handleImgUpload(event.dataTransfer.files)">
이미지 파일을 여기에 드래그하거나 클릭
</div>
<div class="igrid" id="imgrid"></div>
</div>
</div>
@@ -365,6 +389,10 @@ body{background:var(--bg);color:var(--text);font-family:'Segoe UI',system-ui,san
<div id="preview-grid" class="preview-grid">
<div style="color:var(--muted);font-size:12px;text-align:center;padding:24px 0;grid-column:1/-1">PPTX 생성 후 프리뷰가 표시됩니다</div>
</div>
<div style="margin-top:14px;padding:8px 12px;border-top:1px solid var(--line);font-size:11px;color:var(--muted);line-height:1.7">
↑ 상단 <strong>1 · 2 · 3</strong> 단계 버튼을 클릭하면 이전 단계로 돌아갈 수 있습니다.&ensp;
논문 추가·변경 → <strong>1단계</strong>, PDF 처리 재실행 → <strong>2단계</strong>, 아웃라인 수정 → <strong>3단계</strong>.
</div>
</div>
<div class="bbar">
<span class="smsg" id="s4msg"></span><div class="sp"></div>
@@ -379,6 +407,9 @@ body{background:var(--bg);color:var(--text);font-family:'Segoe UI',system-ui,san
<div class="sidebar">
<div class="ptitle">설정</div>
<div class="scroll">
<div class="field">
<button class="btn bgreen" style="width:100%;font-size:12px;padding:7px 0" onclick="newProject()">+ 새 프로젝트</button>
</div>
<div class="field">
<label class="flabel">기존 프로젝트 열기</label>
<div style="display:flex;gap:5px">
@@ -386,6 +417,7 @@ body{background:var(--bg);color:var(--text);font-family:'Segoe UI',system-ui,san
<option value="">— 선택 —</option>
</select>
<button class="btn bg" style="font-size:11px;padding:4px 8px;flex-shrink:0" onclick="loadProjList()" title="목록 새로고침">↺</button>
<button class="btn bred" style="font-size:11px;padding:4px 8px;flex-shrink:0" onclick="deleteSelectedProject()" title="선택한 프로젝트 폴더 삭제">🗑</button>
</div>
</div>
<div class="field">
@@ -494,8 +526,10 @@ body{background:var(--bg);color:var(--text);font-family:'Segoe UI',system-ui,san
<div class="mbg" id="txlbg" onclick="if(event.target===this)closeTxlModal()">
<div class="modal txlmodal" onclick="event.stopPropagation()">
<div class="txlmodal-hd">
<span style="font-size:14px">📝</span>
<span id="txl-title" style="font-size:13px;font-weight:700;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">번역</span>
<span style="font-size:14px">📄</span>
<span id="txl-title" style="font-size:13px;font-weight:700;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">전문</span>
<button class="btn bg" style="padding:3px 10px;font-size:11px" id="txl-tab-en" onclick="switchTxlTab('en')">원문</button>
<button class="btn bg" style="padding:3px 10px;font-size:11px" id="txl-tab-ko" onclick="switchTxlTab('ko')">번역</button>
<button class="btn bg" style="padding:3px 10px;font-size:11px" onclick="copyTxlText()">복사</button>
<button class="btn bg" style="padding:3px 10px;font-size:11px" onclick="closeTxlModal()">✕</button>
</div>
@@ -750,7 +784,105 @@ function paperLabel(key) {
return key;
}
// ── Open existing project ────────────────────────────────────────────────────
// ── Save outline to project folder ──────────────────────────────────────────
async function saveOutlineToProject() {
if (!outline) return;
const project = resolveProjectSlug();
if (!project) return;
try {
await fetch('/api/pptx/save-outline', {
...jh(), method: 'POST',
body: JSON.stringify({ project, outline })
});
} catch (e) { console.warn('[wizard] save-outline failed:', e.message); }
}
// ── Save papers to project (PDF paths, extracted texts, translations) ──────
async function savePapersToProject() {
const project = resolveProjectSlug();
if (!project) return;
const paperEntries = [];
const translations = {};
for (const key of selected) {
const p = papers.find(x => paperKey(x) === key);
if (!p) continue;
const isUpl = key.startsWith('UPL_');
const entry = {
key,
title: p.title || (isUpl ? key.slice(4) : ''),
authors: p.authors || '',
year: p.year || '',
journal: p.journal || '',
doi: p.doi || '',
pmcid: p.pmcid || null,
pmid: p.pmid || null,
_source: isUpl ? 'upload' : (p._source || 'pubmed'),
abstract: (paperMeta[key]?.abstract) || p.abstract || '',
pdfPath: prepLog[key]?.pdfPath || null,
txtPath: null,
koPath: null,
};
// Determine txt/ko paths from pdfPath or key
if (entry.pdfPath) {
const baseName = entry.pdfPath.replace(/\.pdf$/i, '');
if (prepLog[key]?.txt === 'done') entry.txtPath = baseName + '.txt';
if (paperTranslations[key] && entry.txtPath) {
entry.koPath = entry.txtPath.replace(/\.txt$/, '.ko.txt');
translations[key] = paperTranslations[key];
}
} else if (prepLog[key]?.txt === 'done') {
// No PDF but text exists (e.g. abstract-only fallback)
entry.txtPath = project + '/' + key.replace(/[^a-zA-Z0-9_\-]/g, '_') + '.txt';
if (paperTranslations[key]) {
entry.koPath = entry.txtPath.replace(/\.txt$/, '.ko.txt');
translations[key] = paperTranslations[key];
}
}
paperEntries.push(entry);
}
if (!paperEntries.length) return;
try {
await fetch('/api/pptx/save-papers', {
...jh(), method: 'POST',
body: JSON.stringify({ project, papers: paperEntries, translations })
});
loadProjList();
} catch (e) { console.warn('[wizard] save-papers failed:', e.message); }
}
// ── New / Open existing project ──────────────────────────────────────────────
function newProject(){
papers=[]; selected=new Set(); paperMeta={};
paperTexts={}; paperTranslations={}; txlStatus={};
prepLog={}; extractedImages={}; abPaper=null;
outline=null; outlineEdits={}; pptxRelPath=null; previewImages=[];
document.querySelectorAll('.preview-card[data-edited]').forEach(c=>delete c.dataset.edited);
const sel=document.getElementById('cfg-open-proj'); if(sel) sel.value='';
const dlBtn=document.getElementById('dl-btn'); if(dlBtn) dlBtn.style.display='none';
const regenBtn=document.getElementById('s4-regen'); if(regenBtn) regenBtn.style.display='none';
document.getElementById('cfg-project').value='';
document.getElementById('cfg-topic').value='';
localStorage.removeItem('pptx-session');
renderPapers(); renderProgList(); renderOutline();
goStep(1);
}
async function deleteSelectedProject(){
const sel=document.getElementById('cfg-open-proj');
const relPath=sel?.value||'';
if(!relPath){ alert('삭제할 프로젝트를 먼저 선택하세요.'); return; }
const _dp=relPath.replace(/\\/g,'/').split('/');
const project=relPath.endsWith('.pptx')&&_dp.length>=3 ? _dp[1] : _dp[0];
if(!confirm(`프로젝트 폴더 "${project}" 를 완전히 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.`)) return;
try{
const r=await fetch('/api/pptx/delete-project',{...jh(),method:'DELETE',body:JSON.stringify({project})});
const d=await r.json();
if(!d.success) throw new Error(d.error||'삭제 실패');
await loadProjList();
sel.value='';
alert(`"${project}" 삭제 완료.`);
}catch(e){ alert('삭제 오류: '+e.message); }
}
async function loadProjList(){
const sel=document.getElementById('cfg-open-proj'); if(!sel)return;
sel.innerHTML='<option value="">로딩 중...</option>';
@@ -758,30 +890,101 @@ async function loadProjList(){
const r=await fetch('/api/pptx/list',cr()); const d=await r.json();
const items=d.items||[];
sel.innerHTML='<option value="">— 선택 —</option>'
+items.map(it=>`<option value="${esc(it.relPath)}">${esc(it.project)} / ${esc(it.filename)}</option>`).join('');
+items.map(it=>`<option value="${esc(it.relPath)}">${esc(it.project)}${it.hasPptx?' / '+esc(it.filename):' 📂'}</option>`).join('');
}catch(e){ sel.innerHTML='<option value="">오류: '+esc(e.message)+'</option>'; }
}
async function onOpenProjChange(relPath){
if(!relPath)return;
const parts=relPath.replace(/\\/g,'/').split('/');
const project=parts[0];
const hasPptx=relPath.endsWith('.pptx');
// PPTX relPath = "pptx/{slug}/{file}.pptx" → parts[1], no-PPTX relPath = "{slug}" → parts[0]
const project=(hasPptx && parts.length>=3) ? parts[1] : parts[0];
const projInp=document.getElementById('cfg-project'); if(projInp) projInp.value=project;
pptxRelPath=relPath;
const dlBtn=document.getElementById('dl-btn');
const fileUrl='/api/files/'+relPath.split('/').map(s=>encodeURIComponent(s)).join('/');
dlBtn.href=fileUrl; dlBtn.style.display='inline-flex'; dlBtn.download=parts[parts.length-1];
const regenBtn=document.getElementById('s4-regen'); if(regenBtn) regenBtn.style.display='';
// 이전 편집 상태 초기화
const regenBtn=document.getElementById('s4-regen');
// 이전 편집/논문 상태 초기화
outlineEdits={};
document.querySelectorAll('.preview-card[data-edited]').forEach(c=>delete c.dataset.edited);
// 아웃라인 역추출 — 편집 기능 활성화
papers=[]; selected=new Set(); paperMeta={};
paperTexts={}; paperTranslations={}; txlStatus={};
prepLog={}; extractedImages=[]; abPaper=null;
// PPTX 없는 프로젝트 — step 1로 이동해서 논문 목록 확인
if(!hasPptx){
pptxRelPath=null;
dlBtn.style.display='none';
if(regenBtn) regenBtn.style.display='none';
await loadExistingProjectPdfs();
goStep(1);
return;
}
pptxRelPath=relPath;
const fileUrl='/api/files/'+relPath.split('/').map(s=>encodeURIComponent(s)).join('/');
dlBtn.href=fileUrl; dlBtn.style.display='inline-flex'; dlBtn.download=parts[parts.length-1];
if(regenBtn) regenBtn.style.display='';
// Reload saved papers for this project
let hasLoadedPapers = false;
try {
const pr = await fetch('/api/pptx/project-papers?project=' + encodeURIComponent(project), cr());
const pd = await pr.json();
if (pd.success && pd.papers && pd.papers.length) {
// Restore papers array (excluding uploads which are session-only)
papers = pd.papers.map(pe => ({
title: pe.title || '',
authors: pe.authors || '',
year: pe.year || '',
journal: pe.journal || '',
doi: pe.doi || '',
pmcid: pe.pmcid || null,
pmid: pe.pmid || null,
_source: pe._source || 'pubmed',
abstract: pe.abstract || '',
_fromSaved: true,
}));
// Restore selection
selected = new Set(pd.papers.map(pe => pe.key));
// Restore paperTexts and paperTranslations from saved files
for (const pe of pd.papers) {
if (pe.txtPath && pe.hasText) {
try {
const tr = await fetch('/api/files/' + pe.txtPath.split('/').map(s => encodeURIComponent(s)).join('/'), cr());
if (tr.ok) paperTexts[pe.key] = await tr.text();
} catch {}
}
if (pe.koPath && pe.hasTranslation) {
try {
const kr = await fetch('/api/files/' + pe.koPath.split('/').map(s => encodeURIComponent(s)).join('/'), cr());
if (kr.ok) paperTranslations[pe.key] = await kr.text();
} catch {}
}
if (pe.abstract) {
paperMeta[pe.key] = { ...(paperMeta[pe.key] || {}), abstract: pe.abstract };
}
// Set prepLog so the prep UI shows completed state
prepLog[pe.key] = {
pdf: pe.pdfPath ? 'done' : 'skip',
img: 'skip',
txt: pe.txtPath && pe.hasText ? 'done' : (pe.abstract ? 'done' : 'skip'),
pdfPath: pe.pdfPath || null,
};
if (pe.koPath && pe.hasTranslation) txlStatus[pe.key] = 'done';
}
renderPapers();
updSelUI();
renderProgList();
hasLoadedPapers = true;
}
} catch (e) { console.warn('[wizard] project-papers load failed:', e.message); }
// 아웃라인 역추출 — PPTX → outline.json 순서로 시도
try{
const r=await fetch('/api/pptx/read-outline?path='+encodeURIComponent(relPath),cr());
const d=await r.json();
if(d.success&&d.outline){
outline=d.outline;
if(outline.slides) outline.slides=cleanSlideTexts(outline.slides);
// 배경 스킨 동기화 — 편집창 색상 일치
const skin=d.outline.background||'cream';
selectedSkin=skin;
document.querySelectorAll('.sed-skin-sw').forEach(sw=>sw.classList.toggle('active',sw.dataset.v===skin));
@@ -789,8 +992,37 @@ async function onOpenProjChange(relPath){
document.getElementById('s3create').disabled=false;
}
}catch(e){ console.warn('[wizard] outline read failed:',e.message); }
goStep(4);
await loadPreview(relPath);
// PPTX에서 못 읽었으면 outline.json 시도
if(!outline){
try{
const proj=resolveProjectSlug();
const ojPath='pptx/'+proj+'/outline.json';
const r=await fetch('/api/files/'+ojPath.split('/').map(encodeURIComponent).join('/'),cr());
if(r.ok){
const d=await r.json();
if(d && d.slides){
outline=d;
if(outline.slides) outline.slides=cleanSlideTexts(outline.slides);
const skin=outline.background||'cream';
selectedSkin=skin;
document.querySelectorAll('.sed-skin-sw').forEach(sw=>sw.classList.toggle('active',sw.dataset.v===skin));
renderOutline();
document.getElementById('s3create').disabled=false;
}
}
}catch{}
}
// 디스크 PDF도 로드 시도 (papers.json 없는 프로젝트 포함)
await loadExistingProjectPdfs();
if (hasLoadedPapers || papers.length > 0) {
goStep(1);
loadPreview(relPath); // 백그라운드로 프리뷰도 미리 로드
} else {
goStep(4);
await loadPreview(relPath);
}
}
(function(){
loadProjList();
@@ -976,7 +1208,8 @@ function goStep(n) {
d.classList.toggle('active',i===n); d.classList.toggle('done',i<n);
}
currentStep=n;
if(n===2){ renderProgList(); startPrep(); }
if(n===1){ renderPapers(); renderUploadList(); updSelUI(); loadExistingProjectPdfs(); }
if(n===2){ renderProgList(); loadExistingProjectPdfs(); }
}
// ── STEP 1: Search ──────────────────────────────────────────────────────────
@@ -1199,36 +1432,74 @@ function closeAbModal(){ document.getElementById('abg').classList.remove('open')
// ── PDF 텍스트 번역 ──────────────────────────────────────────────────────────
const txlStartTime = {};
const CHUNK_SIZE = 15000;
function splitChunks(text, size) {
const chunks = [];
let pos = 0;
while (pos < text.length) {
let end = pos + size;
if (end < text.length) {
const nl = text.lastIndexOf('\n\n', end);
if (nl > pos) end = nl + 2;
else { const n = text.lastIndexOf('\n', end); if (n > pos) end = n + 1; }
}
chunks.push(text.slice(pos, end));
pos = end;
}
return chunks;
}
async function chatStream(msg, sessionId) {
const r = await fetch('/api/chat', {method:'POST', credentials:'include',
headers:{'Content-Type':'application/json','Accept':'text/event-stream'},
body: JSON.stringify({message: msg, sessionId, direct: true})});
if (!r.ok) throw new Error('API ' + r.status);
const reader = r.body.getReader(), dec = new TextDecoder();
let buf = '', reply = '';
while (true) {
const {done, value} = await reader.read(); if (done) break;
buf += dec.decode(value, {stream: true});
const lines = buf.split('\n'); buf = lines.pop();
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
try { const ev = JSON.parse(line.slice(6));
if ((ev.type==='token'||ev.type==='text_delta') && ev.text) reply += ev.text;
else if (ev.type==='final' && ev.text) reply = ev.text;
else if (ev.type==='done' && ev.reply) reply = ev.reply;
} catch {}
}
}
return reply.trim();
}
async function translatePdfText(key, text) {
txlStatus[key]='running'; txlStartTime[key]=Date.now(); updProg(key);
// 경과 시간 갱신 타이머
const timer=setInterval(()=>{ if(txlStatus[key]==='running') updProg(key); else clearInterval(timer); },1000);
const bodyEl=document.getElementById('txl-body');
let reply='';
try{
const msg='다음 의학 논문 본문을 자연스러운 한국어로 번역해주세요. 원문의 단락 구조를 유지하세요:\n\n'+text.slice(0,3000);
const r=await fetch('/api/chat',{method:'POST',credentials:'include',headers:{'Content-Type':'application/json','Accept':'text/event-stream'},body:JSON.stringify({message:msg,sessionId:'translate-pdf-'+key.replace(/[^a-z0-9]/gi,'_').slice(0,30),direct:true})});
if(!r.ok) throw new Error('API '+r.status);
const reader=r.body.getReader(),dec=new TextDecoder();
let buf='';
while(true){
const {done,value}=await reader.read(); if(done)break;
buf+=dec.decode(value,{stream:true});
const lines=buf.split('\n'); buf=lines.pop();
for(const line of lines){
if(!line.startsWith('data: '))continue;
try{ const ev=JSON.parse(line.slice(6));
if((ev.type==='token'||ev.type==='text_delta')&&ev.text) reply+=ev.text;
else if(ev.type==='final'&&ev.text) reply=ev.text;
else if(ev.type==='done'&&ev.reply) reply=ev.reply;
}catch{}
}
}
paperTranslations[key]=reply.trim()||'번역 실패';
txlStatus[key]='done';
}catch(e){ paperTranslations[key]='번역 오류: '+e.message; txlStatus[key]='fail'; }
const sid = 'translate-pdf-' + key.replace(/[^a-z0-9]/gi,'_').slice(0,30);
const chunks = splitChunks(text, CHUNK_SIZE);
const parts = new Array(chunks.length).fill(null);
let failed = false;
try {
// 모든 청크를 병렬로 번역
await Promise.all(chunks.map(async (chunk, i) => {
const prompt = '다음 의학 논문 본문을 자연스러운 한국어로 번역해주세요. 원문의 단락 구조를 유지하세요:\n\n' + chunk;
parts[i] = await chatStream(prompt, sid + '_c' + i);
// 완료된 청크 수 표시
const done = parts.filter(Boolean).length;
paperTranslations[key] = parts.map((p,j)=>p||(j<done?'':'[번역 중...]')).filter(Boolean).join('\n\n');
updProg(key);
}));
paperTranslations[key] = parts.join('\n\n').trim() || '번역 실패';
txlStatus[key] = 'done';
} catch(e) {
paperTranslations[key] = parts.filter(Boolean).join('\n\n') + (parts.filter(Boolean).length ? '\n\n' : '') + '번역 오류: ' + e.message;
txlStatus[key] = 'fail';
}
clearInterval(timer);
updProg(key);
updOutlineBtn();
savePapersToProject();
}
function updOutlineBtn() {
@@ -1255,13 +1526,36 @@ function updOutlineBtn() {
}
}
let txlViewKey=null, txlTabMode='en';
function showPdfTranslation(key){
document.getElementById('txl-title').textContent=paperLabel(key)+' — 번역';
txlViewKey=key; txlTabMode='en';
document.getElementById('txl-title').textContent=paperLabel(key)+' — 전문/번역';
const body=document.getElementById('txl-body');
body.textContent=paperTranslations[key]||'번역 없음';
document.getElementById('txl-status').textContent=txlStatus[key]==='done'?'번역 완료':'번역 실패';
body.textContent=paperTexts[key]||'전문 없음';
document.getElementById('txl-status').textContent=txlStatus[key]==='done'?'번역 완료':txlStatus[key]==='fail'?'번역 실패':'';
document.getElementById('txl-tab-en').style.background='var(--brand)';
document.getElementById('txl-tab-en').style.color='#fff';
document.getElementById('txl-tab-ko').style.background='';
document.getElementById('txl-tab-ko').style.color='';
document.getElementById('txlbg').classList.add('open');
}
function switchTxlTab(t){
txlTabMode=t;
const body=document.getElementById('txl-body');
if(t==='en'){
body.textContent=paperTexts[txlViewKey]||'전문 없음';
document.getElementById('txl-tab-en').style.background='var(--brand)';
document.getElementById('txl-tab-en').style.color='#fff';
document.getElementById('txl-tab-ko').style.background='';
document.getElementById('txl-tab-ko').style.color='';
} else {
body.textContent=paperTranslations[txlViewKey]||'번역 없음';
document.getElementById('txl-tab-ko').style.background='var(--brand)';
document.getElementById('txl-tab-ko').style.color='#fff';
document.getElementById('txl-tab-en').style.background='';
document.getElementById('txl-tab-en').style.color='';
}
}
function closeTxlModal(){ document.getElementById('txlbg').classList.remove('open'); }
function copyTxlText(){
const t=document.getElementById('txl-body').textContent;
@@ -1311,6 +1605,198 @@ function removeUpload(idx){
}
// ── STEP 2: Prep ─────────────────────────────────────────────────────────────
// Load existing PDFs from the project folder and papers.json manifest.
// If the project already has PDFs on disk, add them as selectable papers
// so the user doesn't have to re-download or re-search.
async function loadExistingProjectPdfs() {
const project = resolveProjectSlug();
if (!project) return;
// 1) Try to load papers.json manifest first (has full metadata)
let manifestPapers = [];
try {
const pr = await fetch('/api/pptx/project-papers?project=' + encodeURIComponent(project), cr());
const pd = await pr.json();
if (pd.success && pd.papers && pd.papers.length) {
manifestPapers = pd.papers;
}
} catch {}
// 2) If we have manifest data and papers aren't already loaded, restore them
if (manifestPapers.length && !papers.filter(p => !p._isUpload).length) {
for (const pe of manifestPapers) {
const existingKey = pe.key;
if (selected.has(existingKey)) continue; // already in selection
const isUplEntry = existingKey.startsWith('UPL_') || pe._source === 'upload';
papers.push({
title: pe.title || '',
authors: pe.authors || '',
year: pe.year || '',
journal: pe.journal || '',
doi: pe.doi || '',
pmcid: pe.pmcid || null,
pmid: pe.pmid || null,
_source: pe._source || 'pubmed',
abstract: pe.abstract || '',
_fromSaved: true,
...(isUplEntry ? { _isUpload: true, _localPdf: pe.pdfPath || '' } : {}),
});
selected.add(existingKey);
// Restore prepLog for already-processed papers
if (!prepLog[existingKey]) {
prepLog[existingKey] = {
pdf: pe.pdfPath ? 'done' : 'skip',
img: 'skip',
txt: pe.txtPath && pe.hasText ? 'done' : 'skip',
pdfPath: pe.pdfPath || null,
};
}
// Restore paperTexts from .txt files
if (pe.txtPath && pe.hasText && !paperTexts[existingKey]) {
try {
const tr = await fetch('/api/files/' + pe.txtPath.split('/').map(s => encodeURIComponent(s)).join('/'), cr());
if (tr.ok) paperTexts[existingKey] = await tr.text();
} catch {}
}
// Restore translations
if (pe.koPath && pe.hasTranslation && !paperTranslations[existingKey]) {
try {
const kr = await fetch('/api/files/' + pe.koPath.split('/').map(s => encodeURIComponent(s)).join('/'), cr());
if (kr.ok) { paperTranslations[existingKey] = await kr.text(); txlStatus[existingKey] = 'done'; }
} catch {}
}
if (pe.abstract && !paperMeta[existingKey]) {
paperMeta[existingKey] = { abstract: pe.abstract };
}
}
renderPapers();
updSelUI();
// 프로젝트 폴더 이미지 로드 (UPL_ 전용 프로젝트도 포함)
try {
const ir = await fetch('/api/pptx/project-images?project=' + encodeURIComponent(project), cr());
const id = await ir.json();
if (id.images && id.images.length) {
for (const f of id.images) {
const url = '/api/files/' + f.path.split('/').map(s => encodeURIComponent(s)).join('/');
if (!extractedImages.find(img => img.url === url))
extractedImages.push({ url, label: f.name, pdf: null });
}
renderImgGrid();
document.getElementById('imgcnt').textContent = '(' + extractedImages.length + ')';
}
} catch {}
}
// 3) Also scan for PDFs on disk that aren't in the manifest
// (e.g. manually added, or from before papers.json was introduced)
try {
const r = await fetch('/api/pptx/project-pdfs?project=' + encodeURIComponent(project), cr());
const d = await r.json();
const pdfs = d.pdfs || [];
for (const pdf of pdfs) {
// pdf.path = "sinus/PMC8273047.pdf", pdf.name = "PMC8273047" (no extension)
const pathFile = (pdf.path || '').split('/').pop() || '';
const baseName = pdf.name || pathFile.replace(/\.pdf$/i, '') || '';
if (!baseName) continue;
const key = baseName.startsWith('PMC') ? baseName : 'UPL_' + baseName;
if (selected.has(key) || papers.some(p => paperKey(p) === key)) continue; // already known
// Add as a selectable paper from existing disk file
papers.push({
_isUpload: true,
_localPdf: pdf.path,
_fromSaved: true,
title: baseName,
pmcid: baseName.startsWith('PMC') ? baseName.replace(/^PMC/i, '') : null,
pmid: null, authors: '', journal: '', year: '', doi: '', pub_types: '',
});
selected.add(key);
// Set prepLog as done (PDF already on disk)
if (!prepLog[key]) {
prepLog[key] = { pdf: 'done', img: 'skip', txt: 'skip', pdfPath: pdf.path };
}
// Load .txt text from companion file (server tells us if it exists)
if (pdf.hasText && pdf.txtPath && !paperTexts[key]) {
try {
const tr = await fetch('/api/files/' + pdf.txtPath.split('/').map(s => encodeURIComponent(s)).join('/'), cr());
if (tr.ok) {
paperTexts[key] = await tr.text();
prepLog[key].txt = 'done';
}
} catch {}
}
// Load .ko.txt translation from companion file
if (pdf.hasTranslation && pdf.koPath && !paperTranslations[key]) {
try {
const kr = await fetch('/api/files/' + pdf.koPath.split('/').map(s => encodeURIComponent(s)).join('/'), cr());
if (kr.ok) {
paperTranslations[key] = await kr.text();
txlStatus[key] = 'done';
}
} catch {}
}
// Try to load extracted images from {project}/{baseName}_images/ directory
// Images are at the project directory root, scanned via project-images endpoint
const imgDir = project + '/' + baseName + '_images';
if (!extractedImages.some(img => img.url.includes(baseName + '_images'))) {
try {
const ir = await fetch('/api/pptx/project-images?project=' + encodeURIComponent(imgDir), cr());
const id = await ir.json();
if (id.images && id.images.length) {
for (const f of id.images) {
const url = '/api/files/' + f.path.split('/').map(s => encodeURIComponent(s)).join('/');
if (!extractedImages.find(img => img.url === url)) {
extractedImages.push({ url, label: baseName + ': ' + f.name, pdf: pdf.path });
}
}
prepLog[key].img = 'done';
prepLog[key].imgCount = id.images.length;
}
} catch {}
}
}
// Also load images from the project directory root (extracted figures at root level)
try {
const ir = await fetch('/api/pptx/project-images?project=' + encodeURIComponent(project), cr());
const id = await ir.json();
if (id.images && id.images.length) {
for (const f of id.images) {
const url = '/api/files/' + f.path.split('/').map(s => encodeURIComponent(s)).join('/');
if (!extractedImages.find(img => img.url === url)) {
extractedImages.push({ url, label: f.name, pdf: null });
}
}
}
} catch {}
if (pdfs.length) {
renderPapers();
renderUploadList();
updSelUI();
}
renderImgGrid();
document.getElementById('imgcnt').textContent = '(' + extractedImages.length + ')';
} catch {}
// 5) Update UI — if all selected papers are already prepped, show completed state
const allDone = Array.from(selected).every(k => {
const s = prepLog[k] || {};
return s.pdf === 'done' || s.pdf === 'skip';
});
renderProgList();
if (allDone && selected.size > 0) {
setMsg(2, '✓ 기존 PDF 복원됨 — 바로 아웃라인 생성 가능', 'var(--green)');
// Show the outline button
updOutlineBtn();
// Change start button to "다시 실행"
const btn = document.getElementById('s2start');
if (btn) { btn.disabled = false; btn.textContent = '▶ 다시 실행'; }
}
}
function pspHtml(label, state, extra='') {
const cls = state==='done'?'psp-done':state==='running'?'psp-run':state==='fail'?'psp-fail':state==='skip'?'psp-skip':'psp-wait';
const icon = state==='done'?'✓ ':state==='running'?'⟳ ':state==='fail'?'✗ ':state==='skip'?'— ':'○ ';
@@ -1339,17 +1825,35 @@ function progRowHtml(key, s) {
const sep='<span style="color:var(--muted);font-size:10px;margin:0 3px">→</span>';
const pills=[pdfPill,imgPill,txtPill,txPill].filter(Boolean).join(sep);
const subMsg=s.sub?`<div style="font-size:10px;color:var(--brand2);margin-top:3px;opacity:.85">⟳ ${esc(s.sub)}</div>`:'';
const capBtn=pdfPath?`<button class="btn bg" style="font-size:10px;padding:2px 8px;margin-top:5px" onclick="openPdfCapture('${esc(pdfPath)}','${esc(lbl)}')">📷 이미지 캡처</button>`:'';
const txViewBtn=txSt==='done'?`<button class="btn bg" style="font-size:10px;padding:2px 8px;margin-top:5px;color:var(--green)" onclick="showPdfTranslation('${esc(key)}')">📝 번역 보기</button>`:'';
const txtViewBtn=paperTexts[key]?`<button class="btn bg" style="font-size:10px;padding:2px 8px;margin-top:5px;color:var(--brand2)" onclick="showPdfTranslation('${esc(key)}')">📄 전문 보기</button>`:'';
const txViewBtn=txSt==='done'?`<button class="btn bg" style="font-size:10px;padding:2px 8px;margin-top:5px;color:var(--green)" onclick="switchTxlTab('ko');showPdfTranslation('${esc(key)}');switchTxlTab('ko')">📝 번역 보기</button>`:'';
const txStartBtn=(paperTexts[key]&&txSt!=='done'&&txSt!=='running')?`<button class="btn bg" style="font-size:10px;padding:2px 8px;margin-top:5px" onclick="translatePdfText('${esc(key)}',paperTexts['${esc(key)}'])">🌐 번역</button>`:'';
// 이미 추출된 번역/텍스트 미리보기 (첫 120자)
const previewText = paperTranslations[key]
? paperTranslations[key].slice(0, 120).replace(/\s+/g,' ').trim() + (paperTranslations[key].length > 120 ? '…' : '')
: (paperTexts[key]
? paperTexts[key].slice(0, 120).replace(/\s+/g,' ').trim() + (paperTexts[key].length > 120 ? '…' : '')
: '');
const previewTab = paperTranslations[key] ? 'ko' : 'en';
const previewHtml = previewText
? `<div style="font-size:10px;color:var(--muted);margin-top:4px;line-height:1.5;font-style:italic;cursor:pointer" onclick="switchTxlTab('${previewTab}');showPdfTranslation('${esc(key)}')" title="클릭하면 전문 보기">${esc(previewText)} <span style="color:var(--brand2);font-style:normal">전문 ↗</span></div>`
: '';
return `<div style="font-size:11px;font-weight:600;margin-bottom:5px;word-break:break-all">${esc(lbl)}</div>
<div style="display:flex;align-items:center;flex-wrap:wrap;gap:2px">${pills}</div>
${previewHtml}
${subMsg}
${s.error?(s.error.includes('초록으로 대체')
?`<div style="font-size:10px;color:#f59e0b;margin-top:4px">⚠ PDF 다운로드 불가 — 초록으로 대체됨</div>`
:`<div style="font-size:10px;color:var(--red);margin-top:4px">${errHtml(s.error)}</div>`)
:(s.error.includes('전문 텍스트로 대체')
?`<div style="font-size:10px;color:#f59e0b;margin-top:4px">⚠ PDF 없음 — 전문 텍스트로 대체됨</div>`
:`<div style="font-size:10px;color:var(--red);margin-top:4px">${errHtml(s.error)}</div>`))
:''}
${(capBtn||txViewBtn)?`<div style="display:flex;gap:6px;flex-wrap:wrap">${capBtn}${txViewBtn}</div>`:''}`;
${(capBtn||txtViewBtn||txViewBtn||txStartBtn)?`<div style="display:flex;gap:6px;flex-wrap:wrap">${capBtn}${txtViewBtn}${txViewBtn}${txStartBtn}</div>`:''}`;
}
function renderProgList() {
@@ -1382,16 +1886,20 @@ function updOverall() {
else { el.style.display='none'; }
}
// project slug 결정: cfg-project 값 → 첫 번째 논문 키 기반 자동 생성
// project slug 결정: cfg-project → cfg-topic → 첫 논문 키 → 타임스탬프
function resolveProjectSlug(){
const inp=(document.getElementById('cfg-project')?.value||'').trim();
if(inp) return inp.replace(/[^a-zA-Z0-9가-힣_\-]/g,'_').replace(/_+/g,'_').replace(/^_+|_+$/g,'').toLowerCase().slice(0,60);
// 검색 주제어로 추론
const topic=(document.getElementById('cfg-topic')?.value||'').trim();
if(topic) return topic.replace(/[^a-zA-Z0-9가-힣]/g,'_').replace(/_+/g,'_').replace(/^_+|_+$/g,'').toLowerCase().slice(0,40);
// 첫 번째 선택 항목으로 추론
const firstKey=Array.from(selected)[0]||'';
if(firstKey.startsWith('PMC')) return firstKey.toLowerCase();
const firstPaper=papers.find(p=>paperKey(p)===firstKey);
if(firstPaper?.title) return firstPaper.title.replace(/[^a-zA-Z0-9가-힣]/g,'_').replace(/_+/g,'_').toLowerCase().slice(0,40);
return '';
// 최후 폴백: 날짜 기반
return 'project_'+new Date().toISOString().slice(0,10).replace(/-/g,'');
}
async function startPrep() {
@@ -1399,15 +1907,13 @@ async function startPrep() {
if(btn.disabled) return; // 이미 실행 중
btn.disabled=true; btn.textContent='진행 중...';
setMsg(2,'준비 중...','var(--brand2)');
const keys=Array.from(selected); prepLog={}; renderProgList(); extractedImages=[]; paperTexts={};
const keys=Array.from(selected); prepLog={}; renderProgList(); extractedImages=[];
document.getElementById('imgrid').innerHTML='<div style="color:var(--muted);font-size:11px">📷 이미지 캡처 버튼으로 추가하세요</div>';
// 프로젝트 slug 결정 — PDF/이미지를 프로젝트 폴더에 직접 저장
// 프로젝트 slug 결정 — PDF/이미지를 프로젝트 폴더에 직접 저장 (항상 결정)
const prepProject=resolveProjectSlug();
if(prepProject){
const inp=document.getElementById('cfg-project');
if(inp&&!inp.value) inp.value=prepProject;
}
const inp=document.getElementById('cfg-project');
if(inp&&!inp.value) inp.value=prepProject;
for(const key of keys){
const isUpload=key.startsWith('UPL_');
@@ -1439,11 +1945,45 @@ async function startPrep() {
if(!pdfPath){ prepLog[key].img='fail'; prepLog[key].txt='fail'; prepLog[key].error='PDF 경로 확인 불가'; updProg(key); continue; }
setMsg(2,paperLabel(key)+' 이미지 추출...','var(--brand2)');
} else {
// PMC 다운로드
// PMC 다운로드 (SSE 스트리밍)
setMsg(2,key+' PDF 다운로드...','var(--brand2)');
const dlr=await fetch('/api/pptx/download-pdf',{...jh(),method:'POST',body:JSON.stringify({pmcid:key,project:prepProject})});
const dld=await dlr.json();
const dld = await new Promise(async (resolve)=>{
const r=await fetch('/api/pptx/download-pdf-stream',{...jh(),method:'POST',body:JSON.stringify({pmcid:key,project:prepProject})});
const reader=r.body.getReader(); const dec=new TextDecoder();
let buf='', result=null;
while(true){
const {done,value}=await reader.read(); if(done) break;
buf+=dec.decode(value,{stream:true});
const lines=buf.split('\n'); buf=lines.pop();
for(const line of lines){
if(!line.startsWith('data: ')) continue;
try{
const ev=JSON.parse(line.slice(6));
if(ev.type==='progress'){
prepLog[key].sub=ev.msg; updProg(key);
} else if(ev.type==='done'){
result=ev;
}
}catch{}
}
}
prepLog[key].sub=''; updProg(key);
resolve(result||{success:false,error:'응답 없음'});
});
if(!dld.success&&!dld.stdout) throw new Error(dld.error||'다운로드 실패');
// 텍스트 폴백: PDF 대신 efetch 전문 텍스트를 받은 경우
if(dld._textFallback && dld._txtPath){
prepLog[key].pdf='fail'; prepLog[key].pdfPath=null;
prepLog[key].img='skip'; prepLog[key].txt='done';
prepLog[key].error='PDF 없음 → 전문 텍스트로 대체';
const txtContent=dld.stdout||'';
if(txtContent) paperTexts[key]=txtContent.slice(0,200000);
updProg(key); updOverall();
if(autoTranslate && paperTexts[key]) translatePdfText(key, paperTexts[key]);
continue;
}
const pm=(dld.stdout||dld.path||'').match(/((?:pubmed|[a-z0-9_-]+)\/[^\s'"]+\.pdf)/i);
pdfPath=pm?pm[1]:null;
prepLog[key].pdf='done'; prepLog[key].pdfPath=pdfPath; updProg(key);
@@ -1482,9 +2022,10 @@ async function startPrep() {
prepLog[key].txt='running'; updProg(key);
setMsg(2,paperLabel(key)+' 텍스트 추출...','var(--brand2)');
try{
const txr=await fetch('/api/pptx/read-pdf',{...jh(),method:'POST',body:JSON.stringify({pdf_path:pdfPath})});
const txtSavePath=pdfPath.replace(/\.pdf$/i,'.txt');
const txr=await fetch('/api/pptx/read-pdf',{...jh(),method:'POST',body:JSON.stringify({pdf_path:pdfPath,save_path:txtSavePath,max_chars:200000})});
const txd=await txr.json();
if(txd.success||txd.stdout){ paperTexts[key]=(txd.stdout||txd.data?.text||'').slice(0,4000); prepLog[key].txt='done'; }
if(txd.success||txd.stdout){ paperTexts[key]=(txd.stdout||txd.data?.text||'').slice(0,200000); prepLog[key].txt='done'; }
else{ prepLog[key].txt='fail'; }
}catch(e2){ prepLog[key].txt='fail'; }
} else {
@@ -1522,6 +2063,8 @@ async function startPrep() {
document.getElementById('imgcnt').textContent='('+extractedImages.length+')';
// 모든 논문이 PDF 번역 없이 끝났거나 일부가 실패한 경우에도 아웃라인 버튼을 노출
updOutlineBtn();
// Save paper metadata, texts, and translations to project folder
savePapersToProject();
}
function renderImgGrid(){
@@ -1529,17 +2072,87 @@ function renderImgGrid(){
document.getElementById('imgcnt').textContent='('+extractedImages.length+')';
if(!extractedImages.length){g.innerHTML='<div style="color:var(--muted);font-size:11px">없음</div>';return;}
g.innerHTML=extractedImages.map((img,i)=>
`<div class="icard" title="${esc(img.label)}">
<img src="${esc(img.url)}" loading="lazy" onerror="this.style.display='none'" onclick="showImgPreview('${esc(img.url)}','${esc(img.label)}')" style="cursor:zoom-in">
<div class="ilabel" style="display:flex;align-items:center;gap:3px;padding:2px 4px">
<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:9px">${esc(img.label)}</span>
<button onclick="event.stopPropagation();deleteImg(${i})" style="flex-shrink:0;background:rgba(239,68,68,.75);border:none;color:#fff;border-radius:2px;padding:1px 5px;font-size:9px;cursor:pointer;line-height:1.4" title="삭제">✕</button>
</div>
`<div class="icard" title="${esc(img.label)}" style="position:relative" onmouseenter="this.querySelector('.icard-del').style.opacity=1" onmouseleave="this.querySelector('.icard-del').style.opacity=0">
<img src="${esc(img.url)}" loading="lazy" onerror="this.style.display='none'" onclick="showImgPreview('${esc(img.url)}','${esc(img.label)}')" style="cursor:zoom-in;width:100%;height:100%;object-fit:cover">
<button class="icard-del" onclick="event.stopPropagation();deleteImg(${i})" style="position:absolute;top:4px;right:4px;opacity:0;transition:.15s;background:rgba(220,38,38,.85);border:none;color:#fff;border-radius:4px;width:22px;height:22px;font-size:13px;cursor:pointer;display:flex;align-items:center;justify-content:center;line-height:1" title="삭제">✕</button>
<div class="ilabel" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:9px;padding:2px 5px">${esc(img.label)}</div>
</div>`).join('');
}
function deleteImg(i){
extractedImages.splice(i,1);
async function deleteImg(i){
const img = extractedImages[i];
if (img) {
// /api/files/<relpath> 형태에서 relpath 추출
const url = img.url || '';
const prefix = '/api/files/';
if (url.startsWith(prefix)) {
const relPath = url.slice(prefix.length).split('/').map(decodeURIComponent).join('/');
try {
await fetch('/api/pptx/delete-image', { ...jh(), method: 'DELETE', body: JSON.stringify({ path: relPath }) });
} catch (e) { console.warn('[deleteImg]', e); }
}
}
extractedImages.splice(i, 1);
renderImgGrid();
}
async function doImgSearch() {
const q = document.getElementById('img-search-input').value.trim();
const res = document.getElementById('img-search-results');
if (!q) return;
res.innerHTML = '<div style="font-size:11px;color:var(--muted)">검색 중…</div>';
try {
const r = await fetch('/api/pptx/search-images', { ...jh(), method:'POST', body:JSON.stringify({query:q,count:6}) });
const d = await r.json();
if (!d.images?.length) { res.innerHTML = '<div style="font-size:11px;color:var(--muted)">결과 없음</div>'; return; }
res.innerHTML = `<div class="igrid" style="grid-template-columns:repeat(auto-fill,minmax(85px,1fr))">${d.images.map(img=>`<div class="icard" style="aspect-ratio:4/3"><img src="${esc(img.url)}" loading="lazy" onerror="this.parentNode.style.display='none'" onclick="showImgPreview('${esc(img.url)}','${esc(img.label)}')" style="width:100%;height:100%;object-fit:cover;cursor:zoom-in"><div class="ilabel">${esc(img.label)}</div><button style="position:absolute;top:2px;right:2px;background:rgba(99,102,241,.85);border:none;color:#fff;border-radius:4px;width:20px;height:20px;font-size:13px;cursor:pointer;line-height:1" onclick="addSearchImg('${esc(img.url)}','${esc(img.label)}',this)" title="갤러리에 추가">+</button></div>`).join('')}</div>`;
} catch(e) { res.innerHTML = `<div style="font-size:11px;color:var(--danger)">${esc(e.message)}</div>`; }
}
async function addSearchImg(url, label, btn) {
const project = resolveProjectSlug();
if (!project) { alert('프로젝트를 먼저 선택하세요'); return; }
btn.textContent = '…'; btn.disabled = true;
try {
const r = await fetch('/api/pptx/save-image-url', { ...jh(), method:'POST', body:JSON.stringify({url,project}) });
const d = await r.json();
if (d.success) {
if (!extractedImages.find(img => img.url === d.url)) extractedImages.push({ url: d.url, label, pdf: null });
renderImgGrid();
btn.textContent = '✓'; btn.style.background = 'rgba(34,197,94,.85)';
} else { btn.textContent = '✗'; btn.disabled = false; }
} catch { btn.textContent = '✗'; btn.disabled = false; }
}
async function handleImgUpload(files) {
if (!files || !files.length) return;
const project = resolveProjectSlug();
if (!project) { alert('먼저 프로젝트 폴더명을 입력하거나 PDF 준비를 시작해주세요.'); return; }
const dz = document.getElementById('img-dropzone');
const origTxt = dz ? dz.textContent : '';
const total = files.length;
let done = 0;
for (const file of files) {
if (dz) dz.textContent = `업로드 중... ${done+1}/${total}`;
const fd = new FormData();
fd.append('image', file, file.name);
fd.append('project', project);
try {
const r = await fetch('/api/pptx/upload-image', { method: 'POST', credentials: 'include', body: fd });
const d = await r.json();
if (d.success) {
const label = file.name.replace(/\.[^.]+$/, '');
if (!extractedImages.find(img => img.url === d.url)) {
extractedImages.push({ url: d.url, label, pdf: null });
}
} else {
console.warn('[img upload]', d.error);
}
} catch (e) { console.warn('[img upload]', e); }
done++;
}
if (dz) dz.textContent = origTxt;
document.getElementById('img-upload-input').value = '';
renderImgGrid();
}
@@ -1605,7 +2218,8 @@ async function generateOutline() {
const m=reply.match(/```pptx-outline\s*\n([\s\S]*?)```/);
stopTimer();
if(m){ outline=JSON.parse(m[1]); if(outline.slides) outline.slides=cleanSlideTexts(outline.slides); renderOutline(); document.getElementById('s3create').disabled=false; setMsg(3,'아웃라인 준비 ('+(outline.slides||[]).length+'장)','var(--green)');
const projInp=document.getElementById('cfg-project'); if(projInp&&!projInp.value&&outline.project) projInp.value=outline.project; }
const projInp=document.getElementById('cfg-project'); if(projInp&&!projInp.value&&outline.project) projInp.value=outline.project;
saveOutlineToProject(); }
else setMsg(3,'pptx-outline 블록 없음. 다시 시도하세요.','var(--yellow)');
}catch(e){ stopTimer(); setMsg(3,'오류: '+e.message,'var(--red)'); }
finally{ clearInterval(phTimer); }
@@ -2510,6 +3124,7 @@ function closeSed(){
sedFromPreview=false;
editorIdx=-1;
renderOutline();
if(wasDirty) saveOutlineToProject();
if(wasPreview && wasDirty) afterSedPreview(savedIdx); // 실제 변경이 있을 때만
}
function afterSedPreview(editedIdx){