ui: structured record collection — collect node schema parsing (v0.9)

수집 노드의 하위 노드를 레코드 필드 스키마로 인식:
- buildSchemaMap(): 수집 섹션의 child sections → 파싱 마크 맵
- parseRecordLine(): 한 줄을 inline 마크 기준으로 분리 → {mark: value} 레코드
- parseInstance(): 스키마 있으면 레코드 파싱, 없으면 기존 토큰 분리
- mergedToText(): 레코드 → inline 재구성 (김양모12시), 단순 → per_line 토큰
- mergedToJSON(): 레코드 → 첫 필드=부모SECTION, 나머지=자식SECTION 계층 구조
- renderFillerTemplateView(): 스키마 필드를 칩 안에 인라인 배지로 표시

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-05 17:53:25 +09:00
parent e332e357b9
commit 41c73fecd5

View File

@@ -286,7 +286,7 @@ input,textarea,button,select{font-family:inherit}
<header class="app-header">
<span style="font-size:18px">🏗️</span>
<h1>CTStruct</h1>
<span class="badge">v0.8</span>
<span class="badge">v0.9</span>
</header>
<nav class="main-nav">
@@ -1328,6 +1328,45 @@ function getCollectKeys(root){
return keys;
}
// ── 스키마 맵: 수집 섹션의 하위 섹션 → 레코드 필드 정의 ──
function buildSchemaMap(root){
const map={};
function walk(n){
for(const c of n.children??[]){
if(c.type!=='SECTION') continue;
const key=(c.attributes?.mark??'')+(c.content??'');
const childSecs=(c.children??[]).filter(ch=>ch.type==='SECTION'&&ch.attributes?.mark);
if(childSecs.length) map[key]=childSecs.map(ch=>({mark:ch.attributes.mark,content:ch.content??''}));
walk(c);
}
}
walk(root);
return map;
}
// ── 레코드 한 줄 파싱: inline 마크 기준 분리 ──────────────
function parseRecordLine(line, schema){
const sorted=schema.map(s=>s.mark).filter(Boolean).sort((a,b)=>b.length-a.length);
if(!sorted.length) return null;
const hits=[];
let i=0;
while(i<line.length){
let found=false;
for(const mark of sorted){
if(line.startsWith(mark,i)){ hits.push({pos:i,mark,end:i+mark.length}); i+=mark.length; found=true; break; }
}
if(!found) i++;
}
if(!hits.length) return null;
const rec={};
for(let j=0;j<hits.length;j++){
const{mark,end}=hits[j];
const next=j+1<hits.length?hits[j+1].pos:line.length;
rec[mark]=line.slice(end,next).trim();
}
return rec;
}
// ── 템플릿 구조 표시 ───────────────────────────────────
function renderFillerTemplateView(){
const el=document.getElementById('ftplView');
@@ -1339,8 +1378,14 @@ function renderFillerTemplateView(){
const pl=sec.attributes?.per_line;
const isCol=sec.attributes?.collect==='true';
const roleHtml=selective?`<span class="ftpl-role ${isCol?'collect':'fixed'}">${isCol?'📥수집':'🔒고정'}</span>`:'';
let html=`<span class="ftpl-sec${isCol?' is-collect':''}">${depth?'<span class="ftpl-arr">▸</span>':''}<span class="ftpl-mark">${esc(m)}</span><span class="ftpl-name">${esc(n)}</span>${roleHtml}${pl?`<span class="ftpl-badge">${esc(pl)}개/줄</span>`:''}</span>`;
for(const c of sec.children??[]) if(c.type==='SECTION') html+=walkSec(c,depth+1);
const schemaSecs=(sec.children??[]).filter(c=>c.type==='SECTION'&&c.attributes?.mark);
const hasSchema=isCol&&schemaSecs.length>0;
const schemaHtml=hasSchema
?schemaSecs.map(f=>`<span style="font-size:10px;background:var(--surface);border:1px solid var(--border);border-radius:3px;padding:1px 4px;color:var(--muted);white-space:nowrap">${esc(f.mark)}${esc(f.content)}</span>`).join('')
:'';
let html=`<span class="ftpl-sec${isCol?' is-collect':''}">${depth?'<span class="ftpl-arr">▸</span>':''}<span class="ftpl-mark">${esc(m)}</span><span class="ftpl-name">${esc(n)}</span>${roleHtml}${pl?`<span class="ftpl-badge">${esc(pl)}개/줄</span>`:''}${schemaHtml}</span>`;
// 스키마 자식은 칩에 인라인으로 표시했으므로 재귀 제외
if(!hasSchema) for(const c of sec.children??[]) if(c.type==='SECTION') html+=walkSec(c,depth+1);
return html;
}
const tm=FT.attributes?.mark??'';
@@ -1372,10 +1417,10 @@ function splitIntoInstances(rawText, titleMark){
return instances;
}
function parseInstance(text, titleMark, sectionMarks){
function parseInstance(text, titleMark, sectionMarks, schemaMap){
const sorted=sectionMarks.slice().sort((a,b)=>b.length-a.length);
const result={sections:{}};
let curKey=null;
let curKey=null, curSchema=null;
for(const rawLine of text.split('\n')){
const line=rawLine.trim(); if(!line) continue;
if(titleMark&&line.startsWith(titleMark)) continue;
@@ -1384,11 +1429,18 @@ function parseInstance(text, titleMark, sectionMarks){
if(line.startsWith(mark)){
curKey=mark+line.slice(mark.length).trim();
if(!result.sections[curKey]) result.sections[curKey]=[];
curSchema=schemaMap?.[curKey]??null;
matched=true; break;
}
}
if(!matched&&curKey){
result.sections[curKey].push(...line.split(/\s+/).filter(Boolean));
if(curSchema?.length){
const rec=parseRecordLine(line,curSchema);
if(rec) result.sections[curKey].push(rec);
else result.sections[curKey].push(...line.split(/\s+/).filter(Boolean));
} else {
result.sections[curKey].push(...line.split(/\s+/).filter(Boolean));
}
}
}
return result;
@@ -1408,12 +1460,13 @@ function parseAndMerge(){
fillerMerged={};
const collectKeys=getCollectKeys(FT);
const selective=collectKeys.size>0;
const schemaMap=buildSchemaMap(FT);
for(const text of instances){
const parsed=parseInstance(text,titleMark,sectionMarks);
for(const [key,tokens] of Object.entries(parsed.sections)){
const parsed=parseInstance(text,titleMark,sectionMarks,schemaMap);
for(const [key,entries] of Object.entries(parsed.sections)){
if(selective&&!collectKeys.has(key)) continue;
if(!fillerMerged[key]) fillerMerged[key]=[];
fillerMerged[key].push(...tokens);
fillerMerged[key].push(...entries);
}
}
instEl.textContent=instances.length?`(${instances.length}개 양식)`:'';
@@ -1431,14 +1484,27 @@ function mergedToText(){
const key=mark+content;
const isCol=selective?(sec.attributes?.collect==='true'):true;
const perLine=parseInt(sec.attributes?.per_line??'0')||0;
const schemaSecs=(sec.children??[]).filter(c=>c.type==='SECTION'&&c.attributes?.mark);
const hasSchema=isCol&&schemaSecs.length>0;
let t=mark+content+'\n';
if(isCol){
const tokens=fillerMerged[key]??[];
if(tokens.length){
if(perLine>0){
for(let i=0;i<tokens.length;i+=perLine) t+=tokens.slice(i,i+perLine).join(' ')+'\n';
} else {
t+=tokens.join(' ')+'\n';
const entries=fillerMerged[key]??[];
if(hasSchema){
// 레코드 모드: 각 레코드를 inline 마크로 재구성
for(const entry of entries){
if(typeof entry==='string'){ t+=entry+'\n'; continue; }
const line=schemaSecs.map(f=>entry[f.mark]!=null?f.mark+entry[f.mark]:'').filter(Boolean).join('');
if(line) t+=line+'\n';
}
} else {
// 단순 모드: 토큰 per_line 출력
const tokens=entries.filter(e=>typeof e==='string');
if(tokens.length){
if(perLine>0){
for(let i=0;i<tokens.length;i+=perLine) t+=tokens.slice(i,i+perLine).join(' ')+'\n';
} else {
t+=tokens.join(' ')+'\n';
}
}
}
} else {
@@ -1446,7 +1512,8 @@ function mergedToText(){
if(c.type==='VALUE'&&c.value!=null&&String(c.value).trim()) t+=String(c.value)+'\n';
}
}
for(const c of sec.children??[]) if(c.type==='SECTION') t+=walkSec(c);
// 스키마 자식은 출력에서 제외, 나머지 섹션만 재귀
if(!hasSchema) for(const c of sec.children??[]) if(c.type==='SECTION') t+=walkSec(c);
return t;
}
const titleMark=FT.attributes?.mark??'';
@@ -1466,15 +1533,38 @@ function mergedToJSON(){
const content=sec.content??'';
const key=mark+content;
const isCol=selective?(sec.attributes?.collect==='true'):true;
let valChildren;
const schemaSecs=(sec.children??[]).filter(c=>c.type==='SECTION'&&c.attributes?.mark);
const hasSchema=isCol&&schemaSecs.length>0;
let newChildren;
if(isCol){
const tokens=fillerMerged[key]??[];
valChildren=tokens.map((t,i)=>({id:'mv'+i,type:'VALUE',data_type:'string',value:t}));
const entries=fillerMerged[key]??[];
if(hasSchema){
// 레코드 모드: 첫 필드 = 부모 SECTION, 나머지 = 자식 SECTION
newChildren=entries.flatMap((entry,i)=>{
if(typeof entry==='string') return [{id:'mv'+i,type:'VALUE',data_type:'string',value:entry}];
const fields=schemaSecs.filter(f=>entry[f.mark]!=null);
if(!fields.length) return [];
const[first,...rest]=fields;
return [{
id:'rec'+i, type:'SECTION',
content:entry[first.mark]??'',
attributes:{mark:first.mark},
children:rest.map((f,j)=>({
id:'rf'+i+'_'+j, type:'SECTION',
content:entry[f.mark]??'',
attributes:{mark:f.mark}, children:[]
}))
}];
});
} else {
newChildren=entries.filter(e=>typeof e==='string')
.map((t,i)=>({id:'mv'+i,type:'VALUE',data_type:'string',value:t}));
}
} else {
valChildren=(sec.children??[]).filter(c=>c.type==='VALUE');
newChildren=(sec.children??[]).filter(c=>c.type==='VALUE');
}
const secChildren=(sec.children??[]).filter(c=>c.type==='SECTION').map(buildSec);
return {...sec, children:[...valChildren,...secChildren]};
const secChildren=hasSchema?[]:(sec.children??[]).filter(c=>c.type==='SECTION').map(buildSec);
return{...sec, children:[...newChildren,...secChildren]};
}
const merged={...FT, children:(FT.children??[]).map(c=>c.type==='SECTION'?buildSec(c):c)};
return JSON.stringify(merged,null,2);