ui: add Tab 3 '구조 추출' — text form to node structure extractor (v0.7)
- 텍스트 양식 붙여넣기 → 섹션 구조 실시간 추출 + 트리 미리보기 - 구분자 수동 입력 및 자동 감지 (첫 등장 순서로 L1·L2·... 결정) - '내용 포함' 토글: 섹션 헤더만 or 내용도 값으로 포함 - '← Builder에 적용' 버튼으로 추출된 구조를 Tab 1에 로드 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
226
ui/builder.html
226
ui/builder.html
@@ -245,6 +245,33 @@ input,textarea,button,select{font-family:inherit}
|
||||
.ftpl-role{font-size:9px;border-radius:3px;padding:1px 5px;font-weight:700;margin-left:2px;letter-spacing:.03em}
|
||||
.ftpl-role.collect{background:var(--accent-bg);color:var(--accent)}
|
||||
.ftpl-role.fixed{background:var(--surface-alt);color:var(--muted);border:1px solid var(--border)}
|
||||
|
||||
/* ── Tab 3: 구조 추출 ────────────────────────── */
|
||||
#panel-extractor{flex-direction:column}
|
||||
.ext-toolbar{background:var(--surface);border-bottom:1px solid var(--border);padding:8px 12px;display:flex;align-items:center;gap:8px;flex-shrink:0}
|
||||
.ext-main{flex:1;display:flex;overflow:hidden}
|
||||
.ext-input-panel{flex:1;display:flex;flex-direction:column;overflow:hidden;background:var(--bg)}
|
||||
.ext-config{padding:7px 14px;border-bottom:1px solid var(--border);background:var(--surface-alt);display:flex;flex-wrap:wrap;gap:5px 14px;align-items:center;flex-shrink:0}
|
||||
.ext-cfg-row{display:flex;align-items:center;gap:6px}
|
||||
.ext-cfg-lbl{font-size:11px;font-weight:600;color:var(--muted);white-space:nowrap}
|
||||
.ext-marks-inp{font-size:12px;border:1px solid var(--border);border-radius:5px;padding:3px 7px;outline:none;width:190px;background:var(--surface)}
|
||||
.ext-marks-inp:focus{border-color:var(--border-focus)}
|
||||
.ext-textarea-wrap{flex:1;display:flex;flex-direction:column;padding:12px;overflow:hidden}
|
||||
.ext-input-lbl{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-bottom:7px;display:flex;align-items:center;gap:7px}
|
||||
.ext-preview-panel{width:400px;min-width:260px;display:flex;flex-direction:column;border-left:1px solid var(--border);background:var(--surface)}
|
||||
.ext-preview-head{display:flex;align-items:center;gap:8px;padding:7px 12px;border-bottom:1px solid var(--border);background:var(--surface-alt);flex-shrink:0}
|
||||
.ext-tree{flex:1;overflow-y:auto;padding:10px 12px}
|
||||
.ext-tree::-webkit-scrollbar{width:5px}
|
||||
.ext-tree::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}
|
||||
.ext-actions{padding:10px 12px;border-top:1px solid var(--border);flex-shrink:0}
|
||||
.ext-apply-btn{width:100%;padding:9px;background:var(--accent);color:#fff;border:none;border-radius:var(--r);font-size:13px;font-weight:600;cursor:pointer;transition:background .15s}
|
||||
.ext-apply-btn:hover:not(:disabled){background:#3a5ce0}
|
||||
.ext-apply-btn:disabled{background:var(--border);color:var(--muted);cursor:not-allowed}
|
||||
.ext-node{margin-bottom:3px}
|
||||
.ext-node-head{display:flex;align-items:center;gap:5px;padding:5px 8px;background:var(--surface-alt);border:1px solid var(--border);border-radius:5px;border-left-width:3px}
|
||||
.ext-node-name{font-size:12px;font-weight:500;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.ext-node-vals{font-size:10px;color:var(--accent);background:var(--accent-bg);border-radius:3px;padding:1px 5px;white-space:nowrap;flex-shrink:0}
|
||||
.ext-node-empty{font-size:12px;color:var(--muted);font-style:italic;padding:20px;text-align:center;line-height:1.7}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -252,12 +279,13 @@ input,textarea,button,select{font-family:inherit}
|
||||
<header class="app-header">
|
||||
<span style="font-size:18px">🏗️</span>
|
||||
<h1>CTStruct</h1>
|
||||
<span class="badge">v0.6</span>
|
||||
<span class="badge">v0.7</span>
|
||||
</header>
|
||||
|
||||
<nav class="main-nav">
|
||||
<button class="main-tab active" id="mtab-builder" onclick="switchTab('builder')">✏️ 양식 만들기</button>
|
||||
<button class="main-tab" id="mtab-filler" onclick="switchTab('filler')">📋 내용 채우기</button>
|
||||
<button class="main-tab active" id="mtab-builder" onclick="switchTab('builder')">✏️ 양식 만들기</button>
|
||||
<button class="main-tab" id="mtab-filler" onclick="switchTab('filler')">📋 내용 채우기</button>
|
||||
<button class="main-tab" id="mtab-extractor" onclick="switchTab('extractor')">🔍 구조 추출</button>
|
||||
</nav>
|
||||
|
||||
<div id="panel-builder" class="tab-panel">
|
||||
@@ -353,6 +381,61 @@ input,textarea,button,select{font-family:inherit}
|
||||
|
||||
</div><!-- /panel-filler -->
|
||||
|
||||
<div id="panel-extractor" class="tab-panel hidden">
|
||||
|
||||
<div class="ext-toolbar">
|
||||
<span style="font-size:16px">🔍</span>
|
||||
<span style="font-size:13px;font-weight:600;color:var(--text)">구조 추출</span>
|
||||
<div style="flex:1"></div>
|
||||
<button class="hdr-btn" onclick="clearExtractor()">🗑 초기화</button>
|
||||
</div>
|
||||
|
||||
<div class="ext-main">
|
||||
|
||||
<div class="ext-input-panel">
|
||||
<div class="ext-config">
|
||||
<div class="ext-cfg-row">
|
||||
<span class="ext-cfg-lbl">제목 구분자</span>
|
||||
<input class="mark-inp" id="extTitleMark" value="✅" oninput="runExtract()">
|
||||
</div>
|
||||
<div class="ext-cfg-row">
|
||||
<span class="ext-cfg-lbl">섹션 구분자</span>
|
||||
<input class="ext-marks-inp" id="extMarks" placeholder="🔹,🔸 (L1·L2 순서, 쉼표 구분)" oninput="runExtract()">
|
||||
<button class="hdr-btn" onclick="autoDetectMarks()" style="padding:3px 10px;font-size:11px">자동 감지</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ext-textarea-wrap">
|
||||
<div class="ext-input-lbl">
|
||||
텍스트 양식 붙여넣기
|
||||
<span class="filler-inst-count" id="extCount"></span>
|
||||
</div>
|
||||
<textarea class="filler-textarea" id="extInputText"
|
||||
placeholder="텍스트 양식을 붙여넣으세요. 섹션 구분자를 지정하거나 자동 감지를 클릭하세요. 예: ✅예시 양식 🔹1번 항목 홍길동 홍길순 🔹2번 항목 🔸소항목 홍길찬"
|
||||
oninput="runExtract()"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ext-preview-panel">
|
||||
<div class="ext-preview-head">
|
||||
<span style="font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted)">추출된 구조</span>
|
||||
<div style="flex:1"></div>
|
||||
<label style="font-size:11px;color:var(--muted);display:flex;align-items:center;gap:4px;cursor:pointer">
|
||||
<input type="checkbox" id="extKeepValues" onchange="runExtract()">
|
||||
내용 포함
|
||||
</label>
|
||||
</div>
|
||||
<div class="ext-tree" id="extTree">
|
||||
<div class="ext-node-empty">텍스트를 붙여넣으면<br>구조가 여기 표시됩니다</div>
|
||||
</div>
|
||||
<div class="ext-actions">
|
||||
<button class="ext-apply-btn" id="extApplyBtn" onclick="applyExtracted()" disabled>← Builder에 적용</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div><!-- /panel-extractor -->
|
||||
|
||||
<div class="overlay" id="fillerImportModal">
|
||||
<div class="modal">
|
||||
<h2>📂 템플릿 JSON 불러오기</h2>
|
||||
@@ -1394,10 +1477,145 @@ function copyFillerOutput(){
|
||||
});
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════
|
||||
// Tab 3: 구조 추출
|
||||
// ════════════════════════════════════════════════════════
|
||||
let EX = { nodes:[], title:'', titleMark:'' };
|
||||
const EX_COLORS = ['#4f6ef7','#7c3aed','#0891b2','#059669','#dc2626'];
|
||||
|
||||
function runExtract(){
|
||||
const raw = document.getElementById('extInputText').value;
|
||||
const titleMark = document.getElementById('extTitleMark').value||'';
|
||||
const marks = document.getElementById('extMarks').value.split(',').map(m=>m.trim()).filter(Boolean);
|
||||
const keepValues = document.getElementById('extKeepValues').checked;
|
||||
if(!raw.trim()||!marks.length){
|
||||
EX={nodes:[],title:'',titleMark};
|
||||
renderExtractTree([]); return;
|
||||
}
|
||||
EX = extractStructure(raw, titleMark, marks, keepValues);
|
||||
renderExtractTree(EX.nodes);
|
||||
}
|
||||
|
||||
function extractStructure(raw, titleMark, marks, keepValues){
|
||||
const sorted=marks.map((m,i)=>({m,lv:i+1})).sort((a,b)=>b.m.length-a.m.length);
|
||||
let title='';
|
||||
const nodes=[], stack=[];
|
||||
let curNode=null, pending=[];
|
||||
|
||||
function findNode(list,id){
|
||||
for(const n of list){
|
||||
if(n.id===id) return n;
|
||||
for(const v of n.values) if(v.type==='node'){ const f=findNode([v.node],id); if(f) return f; }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function flushPending(){
|
||||
if(curNode&&keepValues&&pending.length){
|
||||
const sv=mkScalarVal('string'); sv.data=pending.join('\n').trim();
|
||||
if(sv.data) curNode.values.push(sv);
|
||||
}
|
||||
pending=[];
|
||||
}
|
||||
|
||||
for(const rawLine of raw.split('\n')){
|
||||
const line=rawLine.trim(); if(!line) continue;
|
||||
if(titleMark&&line.startsWith(titleMark)){ flushPending(); title=line.slice(titleMark.length).trim(); continue; }
|
||||
let matched=false;
|
||||
for(const {m,lv} of sorted){
|
||||
if(line.startsWith(m)){
|
||||
flushPending();
|
||||
const node=mkNode(m, line.slice(m.length).trim());
|
||||
while(stack.length){
|
||||
const top=findNode(nodes,stack[stack.length-1].id);
|
||||
if(top&&stack[stack.length-1].lv>=lv) stack.pop(); else break;
|
||||
}
|
||||
if(!stack.length) nodes.push(node);
|
||||
else{ const p=findNode(nodes,stack[stack.length-1].id); if(p) p.values.push(mkNodeVal(node)); }
|
||||
stack.push({id:node.id,lv});
|
||||
curNode=node; matched=true; break;
|
||||
}
|
||||
}
|
||||
if(!matched&&curNode&&keepValues) pending.push(line);
|
||||
}
|
||||
flushPending();
|
||||
return {nodes, title, titleMark};
|
||||
}
|
||||
|
||||
function renderExtractTree(nodes){
|
||||
const el=document.getElementById('extTree');
|
||||
const countEl=document.getElementById('extCount');
|
||||
const applyBtn=document.getElementById('extApplyBtn');
|
||||
if(!nodes||!nodes.length){
|
||||
el.innerHTML='<div class="ext-node-empty">텍스트를 붙여넣으면<br>구조가 여기 표시됩니다</div>';
|
||||
countEl.textContent=''; applyBtn.disabled=true; return;
|
||||
}
|
||||
let total=0;
|
||||
function countAll(n){ total++; for(const v of n.values) if(v.type==='node') countAll(v.node); }
|
||||
for(const n of nodes) countAll(n);
|
||||
countEl.textContent=`(${total}개 노드)`;
|
||||
applyBtn.disabled=false;
|
||||
|
||||
function renderNode(n, depth){
|
||||
const color=EX_COLORS[depth%EX_COLORS.length];
|
||||
const vals=n.values.filter(v=>v.type!=='node');
|
||||
const kids=n.values.filter(v=>v.type==='node');
|
||||
let html=`<div class="ext-node" style="margin-left:${depth*18}px">
|
||||
<div class="ext-node-head" style="border-left-color:${color}">
|
||||
<span class="cnode-mark">${esc(n.mark||'·')}</span>
|
||||
<span class="ext-node-name">${esc(n.content||'이름 없음')}</span>
|
||||
${vals.length?`<span class="ext-node-vals">값 ${vals.length}</span>`:''}
|
||||
</div>
|
||||
</div>`;
|
||||
for(const v of kids) html+=renderNode(v.node, depth+1);
|
||||
return html;
|
||||
}
|
||||
el.innerHTML=nodes.map(n=>renderNode(n,0)).join('');
|
||||
}
|
||||
|
||||
function autoDetectMarks(){
|
||||
const raw=document.getElementById('extInputText').value;
|
||||
if(!raw.trim()) return;
|
||||
const titleMark=document.getElementById('extTitleMark').value||'';
|
||||
const seen=[], seenSet=new Set();
|
||||
for(const rawLine of raw.split('\n')){
|
||||
const line=rawLine.trim(); if(!line) continue;
|
||||
if(titleMark&&line.startsWith(titleMark)) continue;
|
||||
const chars=[...line];
|
||||
let mark=chars[0]||'';
|
||||
if(!mark||/[\w가-힣a-zA-Z0-9]/.test(mark)) continue;
|
||||
if(chars[1]&&/[️⃣]/.test(chars[1])) mark+=chars[1];
|
||||
if(!seenSet.has(mark)){ seenSet.add(mark); seen.push(mark); }
|
||||
}
|
||||
if(seen.length){
|
||||
document.getElementById('extMarks').value=seen.slice(0,5).join(',');
|
||||
runExtract();
|
||||
}
|
||||
}
|
||||
|
||||
function applyExtracted(){
|
||||
if(!EX.nodes||!EX.nodes.length) return;
|
||||
if(S.nodes.length&&!confirm('현재 Builder 내용을 교체할까요?')) return;
|
||||
S.titleMark=EX.titleMark||'✅';
|
||||
S.title=EX.title||'';
|
||||
S.nodes=JSON.parse(JSON.stringify(EX.nodes));
|
||||
normalizeState(S);
|
||||
selectedNodeId=null;
|
||||
refresh();
|
||||
switchTab('builder');
|
||||
}
|
||||
|
||||
function clearExtractor(){
|
||||
document.getElementById('extInputText').value='';
|
||||
document.getElementById('extMarks').value='';
|
||||
EX={nodes:[],title:'',titleMark:''};
|
||||
renderExtractTree([]);
|
||||
document.getElementById('extCount').textContent='';
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════
|
||||
// 탭 전환
|
||||
// ════════════════════════════════════════════════════════
|
||||
const TAB_IDS = ['builder', 'filler'];
|
||||
const TAB_IDS = ['builder', 'filler', 'extractor'];
|
||||
function switchTab(name){
|
||||
TAB_IDS.forEach(id => {
|
||||
document.getElementById('panel-'+id).classList.toggle('hidden', id !== name);
|
||||
|
||||
Reference in New Issue
Block a user