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:
122
ui/builder.html
122
ui/builder.html
@@ -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,13 +1429,20 @@ 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){
|
||||
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,9 +1484,21 @@ 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]??[];
|
||||
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';
|
||||
@@ -1441,12 +1506,14 @@ function mergedToText(){
|
||||
t+=tokens.join(' ')+'\n';
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for(const c of sec.children??[]){
|
||||
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 {
|
||||
valChildren=(sec.children??[]).filter(c=>c.type==='VALUE');
|
||||
newChildren=entries.filter(e=>typeof e==='string')
|
||||
.map((t,i)=>({id:'mv'+i,type:'VALUE',data_type:'string',value:t}));
|
||||
}
|
||||
const secChildren=(sec.children??[]).filter(c=>c.type==='SECTION').map(buildSec);
|
||||
return {...sec, children:[...valChildren,...secChildren]};
|
||||
} else {
|
||||
newChildren=(sec.children??[]).filter(c=>c.type==='VALUE');
|
||||
}
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user