Release 2.9.6: PPTX wizard search + image extraction improvements

- Add Europe PMC search source (free API, life sciences, PMC full-text)
- Fix Europe PMC field mapping: journalInfo.journal.title, firstPublicationDate
- Add PMC-only filter button with live paper count
- Move sort selector to search row; shrink year input (flex:none, no spinners)
- Uniform source checkbox sizing with per-source accent colors
- Exclude preview/ directory from image grid (project-images endpoint)
- Numeric sort for project images (slide1→2→9→10→11)
- Skip PDF figure re-extraction if figure_* files already exist (pdf-extract.ts)
- Preserve img:done state across wizard prep re-runs (savedImgDone)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
kim
2026-06-03 14:30:16 +09:00
co-authored by Claude Sonnet 4.6
parent b292061c71
commit 613690da55
4 changed files with 163 additions and 43 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "smallclaw", "name": "smallclaw",
"version": "2.9.0", "version": "2.9.6",
"description": "Local AI agent framework powered by Ollama - OpenClaw alternative", "description": "Local AI agent framework powered by Ollama - OpenClaw alternative",
"main": "dist/index.js", "main": "dist/index.js",
"bin": { "bin": {
+42 -1
View File
@@ -9576,6 +9576,43 @@ app.get('/api/semantic/search', requireGatewayAuth, async (req: express.Request,
} }
}); });
app.get('/api/europepmc/search', requireGatewayAuth, async (req: express.Request, res: express.Response) => {
try {
const query = String(req.query.q || req.query.query || '').trim();
const maxResults = Math.min(Number(req.query.max || 20), 50);
const minYear = req.query.min_year ? Number(req.query.min_year) : undefined;
const sort = req.query.sort === 'date' ? 'P_PDATE_D desc' : 'RELEVANCE';
if (!query) { res.status(400).json({ error: 'q parameter required' }); return; }
let q = query;
if (minYear) q += ` AND PUB_YEAR:[${minYear} TO 2099]`;
const url = `https://www.ebi.ac.uk/europepmc/webservices/rest/search?query=${encodeURIComponent(q)}&format=json&resultType=core&pageSize=${maxResults}&sort=${encodeURIComponent(sort)}`;
const resp = await fetch(url, { signal: AbortSignal.timeout(15000) });
if (!resp.ok) { res.status(502).json({ error: `Europe PMC ${resp.status}` }); return; }
const data = await resp.json() as any;
const results = (data.resultList?.result || []).map((p: any) => ({
title: p.title?.replace(/<[^>]+>/g, '').trim() || '',
authors: p.authorString || '',
year: p.firstPublicationDate ? String(p.firstPublicationDate).slice(0, 4) : (p.journalInfo?.yearOfPublication ? String(p.journalInfo.yearOfPublication) : ''),
journal: p.journalInfo?.journal?.title || '',
doi: p.doi || null,
pmid: p.pmid ? String(p.pmid) : null,
pmcid: p.pmcid ? String(p.pmcid).replace(/^PMC/i, '') : null,
pub_types: p.pubTypeList?.pubType?.filter((t: string) => !/^(research|review)-article$/i.test(t)).join(', ') || '',
is_oa: p.isOpenAccess === 'Y',
oa_url: p.isOpenAccess === 'Y' && p.pmcid ? `https://europepmc.org/article/PMC/${p.pmcid}` : null,
abstract: p.abstractText?.replace(/<[^>]+>/g, '') || '',
citations: p.citedByCount || 0,
}));
res.json({ success: true, data: { results } });
} catch (err) {
res.status(500).json({ error: String(err) });
}
});
const PPTX_BASE = 'pptx'; const PPTX_BASE = 'pptx';
function pptxProjectSlug(raw: string) { function pptxProjectSlug(raw: string) {
return String(raw || '').trim().replace(/[^a-zA-Z0-9가-힣_\-]/g, '_').replace(/_+/g, '_').replace(/^_+|_+$/g, '').toLowerCase().slice(0, 60); return String(raw || '').trim().replace(/[^a-zA-Z0-9가-힣_\-]/g, '_').replace(/_+/g, '_').replace(/^_+|_+$/g, '').toLowerCase().slice(0, 60);
@@ -10095,7 +10132,7 @@ app.get('/api/pptx/project-images', requireGatewayAuth, async (req: express.Requ
try { try {
if (fs.statSync(abs).isDirectory()) { scan(abs, relF); continue; } if (fs.statSync(abs).isDirectory()) { scan(abs, relF); continue; }
} catch { continue; } } catch { continue; }
if (IMG_EXT.test(f) && !seenPaths.has(abs)) { if (IMG_EXT.test(f) && !seenPaths.has(abs) && !relF.includes('/preview/')) {
seenPaths.add(abs); seenPaths.add(abs);
images.push({ path: relF, name: f }); images.push({ path: relF, name: f });
} }
@@ -10109,6 +10146,10 @@ app.get('/api/pptx/project-images', requireGatewayAuth, async (req: express.Requ
if (wsRootDir !== projectDir && fs.existsSync(wsRootDir) && fs.statSync(wsRootDir).isDirectory()) { if (wsRootDir !== projectDir && fs.existsSync(wsRootDir) && fs.statSync(wsRootDir).isDirectory()) {
scan(wsRootDir, slugOnly); scan(wsRootDir, slugOnly);
} }
images.sort((a, b) => {
const numOf = (s: string) => { const m = s.match(/(\d+)/g); return m ? m.map(n => n.padStart(6, '0')).join('_') : s; };
return numOf(a.name).localeCompare(numOf(b.name));
});
res.json({ images }); res.json({ images });
} catch (err) { } catch (err) {
res.status(500).json({ error: String(err) }); res.status(500).json({ error: String(err) });
+23 -15
View File
@@ -408,22 +408,30 @@ export const pdfExtractImagesTool = {
// --- figures mode: render pages then auto-crop figures/tables with OpenCV --- // --- figures mode: render pages then auto-crop figures/tables with OpenCV ---
if (mode === 'figures' || mode === 'both') { if (mode === 'figures' || mode === 'both') {
const figDpi = Math.min(300, Math.max(100, dpi)); // Skip expensive re-extraction if figure files already exist in outDir
const ppmArgs = ['-png', '-r', String(figDpi)]; const existingFigs = fs.existsSync(outDir)
if (pageFrom) ppmArgs.push('-f', String(pageFrom)); ? fs.readdirSync(outDir).filter(f => f.startsWith('figure_') && f.endsWith('.png'))
if (pageTo) ppmArgs.push('-l', String(pageTo)); : [];
ppmArgs.push(resolved, path.join(outDir, 'page')); if (existingFigs.length > 0 && !pageFrom && !pageTo) {
try { extracted.push(...existingFigs.sort().map(f => `${outDirRel}/${f}`));
await execFileAsync('pdftoppm', ppmArgs, { timeout: 120_000 }); } else {
} catch (err: any) { const figDpi = Math.min(300, Math.max(100, dpi));
errors.push(`pdftoppm: ${String(err.message || err).slice(0, 200)}`); const ppmArgs = ['-png', '-r', String(figDpi)];
if (pageFrom) ppmArgs.push('-f', String(pageFrom));
if (pageTo) ppmArgs.push('-l', String(pageTo));
ppmArgs.push(resolved, path.join(outDir, 'page'));
try {
await execFileAsync('pdftoppm', ppmArgs, { timeout: 120_000 });
} catch (err: any) {
errors.push(`pdftoppm: ${String(err.message || err).slice(0, 200)}`);
}
const { files: figFiles, errors: figErrors } = await extractFigures(outDir, outDir);
errors.push(...figErrors);
for (const f of fs.readdirSync(outDir).filter(f => f.startsWith('page-') && f.endsWith('.png'))) {
try { fs.unlinkSync(path.join(outDir, f)); } catch {}
}
extracted.push(...figFiles.map(f => `${outDirRel}/${f}`));
} }
const { files: figFiles, errors: figErrors } = await extractFigures(outDir, outDir);
errors.push(...figErrors);
for (const f of fs.readdirSync(outDir).filter(f => f.startsWith('page-') && f.endsWith('.png'))) {
try { fs.unlinkSync(path.join(outDir, f)); } catch {}
}
extracted.push(...figFiles.map(f => `${outDirRel}/${f}`));
} }
if (extracted.length === 0) { if (extracted.length === 0) {
+97 -26
View File
@@ -22,10 +22,21 @@ body.theme-warm{
} }
.theme-btn{background:none;border:1.5px solid var(--line);border-radius:6px;padding:4px 10px;font-size:12px;color:var(--muted);cursor:pointer;transition:.15s;} .theme-btn{background:none;border:1.5px solid var(--line);border-radius:6px;padding:4px 10px;font-size:12px;color:var(--muted);cursor:pointer;transition:.15s;}
.theme-btn:hover,.theme-btn.active{border-color:var(--brand);color:var(--brand2);background:rgba(99,102,241,.08);} .theme-btn:hover,.theme-btn.active{border-color:var(--brand);color:var(--brand2);background:rgba(99,102,241,.08);}
.src-chk{display:flex;align-items:flex-start;gap:6px;font-size:12px;font-weight:600;color:var(--muted);cursor:pointer;background:var(--panel2);border:1.5px solid var(--line);border-radius:7px;padding:7px 12px;transition:.15s;user-select:none;max-width:220px;} .src-chk{display:flex;align-items:flex-start;gap:6px;font-size:12px;font-weight:600;color:var(--muted);cursor:pointer;border:1.5px solid var(--line);border-radius:8px;padding:8px 11px;transition:.15s;user-select:none;width:168px;flex-shrink:0;box-sizing:border-box;}
.src-chk:has(input:checked){border-color:var(--brand);background:rgba(99,102,241,.1);color:var(--brand2);} .src-chk input{width:13px;height:13px;cursor:pointer;margin-top:2px;flex-shrink:0;}
.src-chk input{accent-color:var(--brand);width:13px;height:13px;cursor:pointer;margin-top:2px;flex-shrink:0;} .src-chk-desc{display:block;font-size:10px;font-weight:400;color:var(--muted);margin-top:4px;line-height:1.5;}
.src-chk-desc{display:block;font-size:10px;font-weight:400;color:var(--muted);margin-top:3px;line-height:1.5;} #src-pubmed{background:rgba(239,68,68,.07);}
#src-pubmed:has(input:checked){background:rgba(239,68,68,.18);border-color:#f87171;color:#fca5a5;}
#src-pubmed input{accent-color:#ef4444;}
#src-openalex{background:rgba(34,197,94,.07);}
#src-openalex:has(input:checked){background:rgba(34,197,94,.18);border-color:var(--green);color:#86efac;}
#src-openalex input{accent-color:#22c55e;}
#src-semantic{background:rgba(251,191,36,.07);}
#src-semantic:has(input:checked){background:rgba(251,191,36,.18);border-color:#fbbf24;color:#fde68a;}
#src-semantic input{accent-color:#f59e0b;}
#src-europepmc{background:rgba(96,165,250,.07);}
#src-europepmc:has(input:checked){background:rgba(96,165,250,.18);border-color:#60a5fa;color:#bfdbfe;}
#src-europepmc input{accent-color:#3b82f6;}
*{margin:0;padding:0;box-sizing:border-box;} *{margin:0;padding:0;box-sizing:border-box;}
body{background:var(--bg);color:var(--text);font-family:'Segoe UI',system-ui,sans-serif;height:100vh;display:flex;flex-direction:column;overflow:hidden;font-size:14px;} body{background:var(--bg);color:var(--text);font-family:'Segoe UI',system-ui,sans-serif;height:100vh;display:flex;flex-direction:column;overflow:hidden;font-size:14px;}
@@ -56,7 +67,9 @@ body{background:var(--bg);color:var(--text);font-family:'Segoe UI',system-ui,san
.srow{display:flex;gap:8px;margin-bottom:12px;} .srow{display:flex;gap:8px;margin-bottom:12px;}
.srow input{flex:1;background:var(--panel2);border:1px solid var(--line);border-radius:var(--r);padding:8px 12px;font-size:13px;color:var(--text);outline:none;} .srow input{flex:1;background:var(--panel2);border:1px solid var(--line);border-radius:var(--r);padding:8px 12px;font-size:13px;color:var(--text);outline:none;}
.srow input:focus{border-color:var(--brand);} .srow input:focus{border-color:var(--brand);}
.srow .yin{width:80px;flex-shrink:0;} .srow .yin{width:52px;flex:none;padding:8px 4px;text-align:center;}
.srow .yin::-webkit-inner-spin-button,.srow .yin::-webkit-outer-spin-button{-webkit-appearance:none;}
.srow .yin{-moz-appearance:textfield;}
/* Paper cards */ /* Paper cards */
.pcard{background:var(--panel2);border:1px solid var(--line);border-radius:var(--r);padding:11px 13px;margin-bottom:8px;display:flex;gap:10px;align-items:flex-start;transition:.15s;} .pcard{background:var(--panel2);border:1px solid var(--line);border-radius:var(--r);padding:11px 13px;margin-bottom:8px;display:flex;gap:10px;align-items:flex-start;transition:.15s;}
@@ -276,22 +289,31 @@ body{background:var(--bg);color:var(--text);font-family:'Segoe UI',system-ui,san
<span style="font-size:11px;font-weight:700;color:var(--muted);margin-top:7px">검색 소스:</span> <span style="font-size:11px;font-weight:700;color:var(--muted);margin-top:7px">검색 소스:</span>
<label class="src-chk" id="src-pubmed"> <label class="src-chk" id="src-pubmed">
<input type="checkbox" checked onchange="updSrcState()"> <input type="checkbox" checked onchange="updSrcState()">
<span>🔬 PubMed<span class="src-chk-desc">미국 NLM 의학·생명과학 전문 DB. PMC 등재 논문은 전문 PDF 다운로드 가능.</span></span> <span>🔬 PubMed<span class="src-chk-desc">의학·생명과학 전문. PMC 등재 논문은 전문 PDF 다운로드 가능.</span></span>
</label> </label>
<label class="src-chk" id="src-openalex"> <label class="src-chk" id="src-openalex">
<input type="checkbox" onchange="updSrcState()"> <input type="checkbox" onchange="updSrcState()">
<span>🌐 OpenAlex<span class="src-chk-desc">전 분야 2억+ 논문. API 키 불필요. 인용수·OA 여부 제공. OA 논문은 PDF 다운로드 가능.</span></span> <span>🌐 OpenAlex<span class="src-chk-desc">전 분야 2억+ 논문. Nature·Science 포함. OA PDF 제공.</span></span>
</label> </label>
<label class="src-chk" id="src-semantic"> <label class="src-chk" id="src-semantic">
<input type="checkbox" onchange="updSrcState()"> <input type="checkbox" onchange="updSrcState()">
<span>🧠 Semantic Scholar<span class="src-chk-desc">AI·CS·학제간 연구에 강함. 인용 영향력 지수 및 OA PDF 링크 제공.</span></span> <span>🧠 Semantic<span class="src-chk-desc">AI·CS·학제간 연구에 강함. 인용 영향력 지수 제공.</span></span>
</label>
<label class="src-chk" id="src-europepmc">
<input type="checkbox" onchange="updSrcState()">
<span>🇪🇺 Europe PMC<span class="src-chk-desc">PubMed보다 넓은 생명과학 커버리지. PMC 전문 포함.</span></span>
</label> </label>
</div> </div>
<div class="srow"> <div class="srow">
<input id="qi" placeholder="예: sinus lift augmentation" onkeydown="if(event.key==='Enter')doSearch()"> <input id="qi" placeholder="예: sinus lift augmentation" onkeydown="if(event.key==='Enter')doSearch()">
<input class="yin" id="qy" type="number" placeholder="연도" min="1990" max="2030" value="2017"> <input class="yin" id="qy" type="number" placeholder="연도" min="1990" max="2030" value="2017">
<span style="font-size:11px;color:var(--muted);white-space:nowrap">이후 (선택)</span> <span style="font-size:11px;color:var(--muted);white-space:nowrap">이후 (선택)</span>
<button class="btn bp" onclick="doSearch()" id="sbtn">🔍 검색</button> <select id="cfg-sort" style="background:var(--panel2);border:1px solid var(--line);border-radius:var(--r);padding:7px 9px;font-size:13px;color:var(--text);flex-shrink:0">
<option value="relevance">관련도순</option>
<option value="date">최신순</option>
</select>
<button id="pmc-filter-btn" class="btn bg" onclick="togglePmcFilter()" title="PMC 전문 있는 논문만 표시 (PDF 다운로드 가능)" style="flex-shrink:0;font-size:13px;padding:7px 11px;opacity:.6">PMC <span style="font-size:10px">PDF</span></button>
<button class="btn bp" onclick="doSearch()" id="sbtn" style="font-size:13px;padding:7px 14px">🔍 검색</button>
</div> </div>
<div id="search-legend" style="display:none;padding:7px 12px;background:rgba(99,102,241,.07);border-radius:7px;margin-bottom:6px;font-size:11px;color:var(--muted);line-height:1.7"> <div id="search-legend" style="display:none;padding:7px 12px;background:rgba(99,102,241,.07);border-radius:7px;margin-bottom:6px;font-size:11px;color:var(--muted);line-height:1.7">
<span style="color:var(--green);font-weight:700">■ PMC등재</span> — PubMed Central에 전문(PDF) 있음. 체크박스로 선택해 PPTX에 활용 가능.<br> <span style="color:var(--green);font-weight:700">■ PMC등재</span> — PubMed Central에 전문(PDF) 있음. 체크박스로 선택해 PPTX에 활용 가능.<br>
@@ -443,13 +465,6 @@ body{background:var(--bg);color:var(--text);font-family:'Segoe UI',system-ui,san
<label class="flabel">배경 스킨 <span id="skin-name-lbl" style="font-weight:400;text-transform:none;margin-left:4px"></span></label> <label class="flabel">배경 스킨 <span id="skin-name-lbl" style="font-weight:400;text-transform:none;margin-left:4px"></span></label>
<div class="skin-grid" id="skin-grid"></div> <div class="skin-grid" id="skin-grid"></div>
</div> </div>
<div class="field">
<label class="flabel">정렬</label>
<select id="cfg-sort">
<option value="relevance">관련도순</option>
<option value="date">최신순</option>
</select>
</div>
<div class="field"> <div class="field">
<label class="flabel">PDF 이미지 자동 추출</label> <label class="flabel">PDF 이미지 자동 추출</label>
<label style="display:flex;align-items:flex-start;gap:8px;font-size:12px;color:var(--text);cursor:pointer;line-height:1.5"> <label style="display:flex;align-items:flex-start;gap:8px;font-size:12px;color:var(--text);cursor:pointer;line-height:1.5">
@@ -760,6 +775,20 @@ const SKINS = [
// ── state ─────────────────────────────────────────────────────────────────── // ── state ───────────────────────────────────────────────────────────────────
let papers = [], selected = new Set(), paperMeta = {}; let papers = [], selected = new Set(), paperMeta = {};
let pmcFilterOn = false;
function togglePmcFilter(){
pmcFilterOn=!pmcFilterOn;
const btn=document.getElementById('pmc-filter-btn');
if(btn){
if(pmcFilterOn){
btn.style.cssText='flex-shrink:0;font-size:13px;padding:7px 11px;background:rgba(34,197,94,.25);color:#4ade80;border:2px solid #4ade80;border-radius:var(--r);font-weight:700;cursor:pointer;white-space:nowrap;opacity:1;';
} else {
btn.className='btn bg';
btn.style.cssText='flex-shrink:0;font-size:13px;padding:7px 11px;opacity:.6;';
}
}
renderPapers();
}
function updSrcState(){ function updSrcState(){
// 최소 하나는 선택 유지 // 최소 하나는 선택 유지
const checks=[...document.querySelectorAll('.src-chk input')]; const checks=[...document.querySelectorAll('.src-chk input')];
@@ -1229,6 +1258,7 @@ async function doSearch() {
const usePubmed=document.querySelector('#src-pubmed input')?.checked; const usePubmed=document.querySelector('#src-pubmed input')?.checked;
const useOpenalex=document.querySelector('#src-openalex input')?.checked; const useOpenalex=document.querySelector('#src-openalex input')?.checked;
const useSemantic=document.querySelector('#src-semantic input')?.checked; const useSemantic=document.querySelector('#src-semantic input')?.checked;
const useEuropePmc=document.querySelector('#src-europepmc input')?.checked;
const btn=document.getElementById('sbtn'); const btn=document.getElementById('sbtn');
btn.disabled=true; btn.textContent='검색 중...'; setMsg(1,'검색 중...','var(--brand2)'); btn.disabled=true; btn.textContent='검색 중...'; setMsg(1,'검색 중...','var(--brand2)');
document.getElementById('results').innerHTML='<div style="color:var(--muted);padding:14px;text-align:center;font-size:12px">검색 중...</div>'; document.getElementById('results').innerHTML='<div style="color:var(--muted);padding:14px;text-align:center;font-size:12px">검색 중...</div>';
@@ -1255,6 +1285,14 @@ async function doSearch() {
citations:p.citations, inf_citations:p.inf_citations, oa_pdf:p.oa_pdf, abstract:p.abstract||'', citations:p.citations, inf_citations:p.inf_citations, oa_pdf:p.oa_pdf, abstract:p.abstract||'',
}))).catch(()=>[]) }))).catch(()=>[])
); );
if(useEuropePmc) tasks.push(
fetch('/api/europepmc/search?q='+encodeURIComponent(q)+'&max=15&sort='+sort+(yr?'&min_year='+yr:''),cr())
.then(r=>r.json()).then(d=>(d.data?.results||[]).map(p=>({
_source:'europepmc', title:p.title, authors:p.authors,
year:p.year, journal:p.journal, doi:p.doi, pmcid:p.pmcid, pmid:p.pmid, pub_types:p.pub_types,
citations:p.citations, is_oa:p.is_oa, oa_url:p.oa_url, abstract:p.abstract,
}))).catch(()=>[])
);
const results=await Promise.all(tasks); const results=await Promise.all(tasks);
// 병합 + DOI 기준 중복 제거 (pubmed 우선) // 병합 + DOI 기준 중복 제거 (pubmed 우선)
@@ -1270,7 +1308,7 @@ async function doSearch() {
} }
papers=[...papers.filter(p=>p._isUpload), ...merged]; papers=[...papers.filter(p=>p._isUpload), ...merged];
renderPapers(); renderPapers();
setMsg(1,merged.length+'건 검색됨 ('+[usePubmed&&'PubMed',useOpenalex&&'OpenAlex',useSemantic&&'Semantic'].filter(Boolean).join('+')+')','var(--green)'); setMsg(1,merged.length+'건 검색됨 ('+[usePubmed&&'PubMed',useOpenalex&&'OpenAlex',useSemantic&&'Semantic',useEuropePmc&&'EuropePMC'].filter(Boolean).join('+')+')','var(--green)');
document.getElementById('search-legend').style.display=''; document.getElementById('search-legend').style.display='';
} catch(e){ setMsg(1,'검색 실패: '+e.message,'var(--red)'); document.getElementById('results').innerHTML='<div style="color:var(--red);padding:12px;font-size:12px">'+esc(e.message)+'</div>'; } } catch(e){ setMsg(1,'검색 실패: '+e.message,'var(--red)'); document.getElementById('results').innerHTML='<div style="color:var(--red);padding:12px;font-size:12px">'+esc(e.message)+'</div>'; }
finally{ btn.disabled=false; btn.textContent='🔍 검색'; } finally{ btn.disabled=false; btn.textContent='🔍 검색'; }
@@ -1278,7 +1316,16 @@ async function doSearch() {
function renderPapers() { function renderPapers() {
const el=document.getElementById('results'); const el=document.getElementById('results');
const searchPapers=papers.filter(p=>!p._isUpload); let searchPapers=papers.filter(p=>!p._isUpload);
const pmcPapers=searchPapers.filter(p=>!!p.pmcid||(p._source!=='pubmed'&&!!(p.oa_url||p.oa_pdf)));
if(pmcFilterOn) searchPapers=pmcPapers;
const btn=document.getElementById('pmc-filter-btn');
if(btn&&searchPapers.length){
const cnt=pmcPapers.length;
btn.innerHTML=pmcFilterOn
?`PMC <span style="font-size:10px">PDF</span> <span style="font-size:11px;font-weight:800">${cnt}</span>`
:`PMC <span style="font-size:10px">PDF</span> <span style="font-size:10px;opacity:.7">${cnt}</span>`;
}
if(!searchPapers.length){el.innerHTML='<div style="color:var(--muted);padding:12px;font-size:12px">결과 없음</div>';return;} if(!searchPapers.length){el.innerHTML='<div style="color:var(--muted);padding:12px;font-size:12px">결과 없음</div>';return;}
el.innerHTML=searchPapers.map((p)=>{ el.innerHTML=searchPapers.map((p)=>{
const idx=papers.indexOf(p); const idx=papers.indexOf(p);
@@ -1290,7 +1337,8 @@ function renderPapers() {
const key=paperKey(p); const isSel=key?selected.has(key):false; const key=paperKey(p); const isSel=key?selected.has(key):false;
const canSelect=!!key; const canSelect=!!key;
const srcBadge=src==='openalex'?`<span class="badge" style="background:rgba(34,197,94,.15);color:var(--green)">OpenAlex</span>`: const srcBadge=src==='openalex'?`<span class="badge" style="background:rgba(34,197,94,.15);color:var(--green)">OpenAlex</span>`:
src==='semantic'?`<span class="badge" style="background:rgba(251,191,36,.15);color:var(--yellow)">Semantic Scholar</span>`:''; src==='semantic'?`<span class="badge" style="background:rgba(251,191,36,.15);color:var(--yellow)">Semantic Scholar</span>`:
src==='europepmc'?`<span class="badge" style="background:rgba(96,165,250,.15);color:#60a5fa">Europe PMC</span>`:'';
const citeBadge=p.citations?`<span class="badge" style="background:rgba(148,163,184,.1);color:var(--muted)">인용 ${p.citations}</span>`:''; const citeBadge=p.citations?`<span class="badge" style="background:rgba(148,163,184,.1);color:var(--muted)">인용 ${p.citations}</span>`:'';
const oaBadge=hasOaPdf?`<span class="badge" style="background:rgba(34,197,94,.15);color:var(--green)" title="Open Access PDF 있음">OA</span>` const oaBadge=hasOaPdf?`<span class="badge" style="background:rgba(34,197,94,.15);color:var(--green)" title="Open Access PDF 있음">OA</span>`
:(!hasPdf?`<span class="badge" style="background:rgba(148,163,184,.1);color:var(--muted)" title="PDF 없음 — 초록으로만 사용">초록만</span>`:''); :(!hasPdf?`<span class="badge" style="background:rgba(148,163,184,.1);color:var(--muted)" title="PDF 없음 — 초록으로만 사용">초록만</span>`:'');
@@ -1301,7 +1349,7 @@ function renderPapers() {
<div class="psub">${esc(p.authors||'')} · ${esc(p.journal||'')} ${esc(p.year||'')}</div> <div class="psub">${esc(p.authors||'')} · ${esc(p.journal||'')} ${esc(p.year||'')}</div>
<div style="margin-top:3px;display:flex;flex-wrap:wrap;gap:3px"> <div style="margin-top:3px;display:flex;flex-wrap:wrap;gap:3px">
${srcBadge} ${srcBadge}
${src==='pubmed'?(hp?`<span class="badge bpmc" title="PMC 전문 PDF 가능">${pmcid}</span>`:'<span class="badge bnopmc">PMC미등재</span>'):''} ${(src==='pubmed'||src==='europepmc')?(hp?`<span class="badge bpmc" title="PMC 전문 PDF 가능">${pmcid}</span>`:(src==='pubmed'?'<span class="badge bnopmc">PMC미등재</span>':'')):''}
${p.year?`<span class="badge byear">${p.year}</span>`:''} ${p.year?`<span class="badge byear">${p.year}</span>`:''}
${p.pub_types?`<span class="badge btype">${p.pub_types.split(',')[0].trim()}</span>`:''} ${p.pub_types?`<span class="badge btype">${p.pub_types.split(',')[0].trim()}</span>`:''}
${oaBadge}${citeBadge} ${oaBadge}${citeBadge}
@@ -1930,9 +1978,12 @@ async function startPrep() {
btn.disabled=true; btn.textContent='진행 중...'; btn.disabled=true; btn.textContent='진행 중...';
setMsg(2,'준비 중...','var(--brand2)'); setMsg(2,'준비 중...','var(--brand2)');
const keys=Array.from(selected); const keys=Array.from(selected);
// prepLog 리셋 전에 기존 pdfPath 보존 (재실행 시 재다운로드 방지) // prepLog 리셋 전에 기존 pdfPath·img 완료 상태 보존 (재실행 시 재다운로드/재추출 방지)
const savedPdfPaths={}; const savedPdfPaths={}, savedImgDone={};
for(const k of keys){ if(prepLog[k]?.pdfPath) savedPdfPaths[k]=prepLog[k].pdfPath; } for(const k of keys){
if(prepLog[k]?.pdfPath) savedPdfPaths[k]=prepLog[k].pdfPath;
if(prepLog[k]?.img==='done') savedImgDone[k]={imgCount:prepLog[k].imgCount};
}
prepLog={}; renderProgList(); extractedImages=[]; prepLog={}; renderProgList(); extractedImages=[];
document.getElementById('imgrid').innerHTML='<div style="color:var(--muted);font-size:11px">📷 이미지 캡처 버튼으로 추가하세요</div>'; document.getElementById('imgrid').innerHTML='<div style="color:var(--muted);font-size:11px">📷 이미지 캡처 버튼으로 추가하세요</div>';
@@ -1945,7 +1996,8 @@ async function startPrep() {
const isUpload=key.startsWith('UPL_'); const isUpload=key.startsWith('UPL_');
const paper=papers.find(p=>paperKey(p)===key); const paper=papers.find(p=>paperKey(p)===key);
currentProcessingKey=key; currentProcessingKey=key;
prepLog[key]={pdf:isUpload?'done':'running',img:autoExtractImages?'waiting':'skip',txt:extractText?'waiting':'skip'}; const imgInit=savedImgDone[key]?'done':(autoExtractImages?'waiting':'skip');
prepLog[key]={pdf:isUpload?'done':'running',img:imgInit,imgCount:savedImgDone[key]?.imgCount,txt:extractText?'waiting':'skip'};
updProg(key); updOverall(); updProg(key); updOverall();
try{ try{
@@ -2023,8 +2075,8 @@ async function startPrep() {
setMsg(2,key+' 이미지 추출...','var(--brand2)'); setMsg(2,key+' 이미지 추출...','var(--brand2)');
} }
// 이미지 자동 추출 + mistral vision 분석 (토글 ON일 때만) // 이미지 자동 추출 + mistral vision 분석 (토글 ON, 이전 실행에서 완료되지 않은 경우만)
if(autoExtractImages && pdfPath){ if(autoExtractImages && pdfPath && !savedImgDone[key]){
prepLog[key].img='running'; updProg(key); prepLog[key].img='running'; updProg(key);
setMsg(2,paperLabel(key)+' 이미지 추출...','var(--brand2)'); setMsg(2,paperLabel(key)+' 이미지 추출...','var(--brand2)');
try{ try{
@@ -2060,6 +2112,25 @@ async function startPrep() {
prepLog[key].img='done'; prepLog[key].imgCount=added; prepLog[key].img='done'; prepLog[key].imgCount=added;
}catch(e2){ prepLog[key].img='fail'; } }catch(e2){ prepLog[key].img='fail'; }
updProg(key); updProg(key);
} else if(savedImgDone[key]){
// 이전 실행에서 이미 추출 완료 — 서버에서 기존 파일 목록만 다시 로드
if(pdfPath){
try{
const exr=await fetch('/api/pptx/extract-images',{...jh(),method:'POST',body:JSON.stringify({pdf_path:pdfPath,project:prepProject})});
const exd=await exr.json();
if(exd.success&&exd.data?.files){
const pname=key.startsWith('UPL_')?(pdfPath.split('/').pop()||'').replace(/\.pdf$/i,''):key;
for(const f of exd.data.files){
const url='/api/files/'+f;
if(!extractedImages.find(img=>img.url===url))
extractedImages.push({url,label:pname+': '+f.split('/').pop(),pdf:pdfPath});
}
renderImgGrid();
document.getElementById('imgcnt').textContent='('+extractedImages.length+')';
}
}catch{}
}
prepLog[key].img='done'; updProg(key);
} else { } else {
prepLog[key].img='skip'; updProg(key); prepLog[key].img='skip'; updProg(key);
} }