수집 노드의 하위 노드를 레코드 필드 스키마로 인식:
- 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>
1761 lines
87 KiB
HTML
1761 lines
87 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="ko">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>CTStruct</title>
|
||
<style>
|
||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||
:root{
|
||
--bg:#f0f2f5;--surface:#fff;--surface-alt:#f8f9fa;
|
||
--border:#e2e5ea;--border-focus:#4f6ef7;
|
||
--text:#1a1d23;--muted:#7c8594;
|
||
--accent:#4f6ef7;--accent-bg:#eef0ff;
|
||
--red:#ef4444;--red-bg:#fee2e2;
|
||
--green:#16a34a;--green-bg:#dcfce7;
|
||
--r:8px;--sh:0 1px 3px rgba(0,0,0,.08),0 1px 2px rgba(0,0,0,.04);
|
||
--dk:#1e1e2e;--dk2:#181825;--dk3:#313244;--dk4:#45475a;
|
||
--dt:#cdd6f4;--dt2:#bac2de;--dt3:#6c7086;
|
||
}
|
||
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:var(--bg);color:var(--text);height:100vh;overflow:hidden;display:flex;flex-direction:column;font-size:14px}
|
||
|
||
/* ── 헤더 ─────────────────────────────────── */
|
||
.app-header{background:var(--surface);border-bottom:1px solid var(--border);padding:10px 16px;display:flex;align-items:center;gap:10px;box-shadow:var(--sh);z-index:10;flex-shrink:0}
|
||
.app-header h1{font-size:15px;font-weight:600}
|
||
.badge{font-size:10px;background:var(--accent-bg);color:var(--accent);padding:2px 7px;border-radius:99px;font-weight:600}
|
||
.hdr-spacer{flex:1}
|
||
.hdr-btn{display:flex;align-items:center;gap:5px;font-size:12px;padding:5px 12px;border-radius:6px;cursor:pointer;border:1px solid var(--border);background:var(--surface);color:var(--muted);transition:all .15s;font-weight:500}
|
||
.hdr-btn:hover{background:var(--surface-alt);color:var(--text);border-color:var(--border-focus)}
|
||
|
||
/* ── 메인 탭 네비게이션 ───────────────────── */
|
||
.main-nav{background:var(--surface);border-bottom:2px solid var(--border);padding:0 20px;display:flex;gap:4px;flex-shrink:0}
|
||
.main-tab{font-size:13px;font-weight:600;padding:11px 18px;border:none;background:transparent;color:var(--muted);cursor:pointer;border-bottom:2.5px solid transparent;margin-bottom:-2px;transition:all .15s;display:flex;align-items:center;gap:6px;letter-spacing:.01em}
|
||
.main-tab:hover:not(.active){color:var(--text)}
|
||
.main-tab.active{color:var(--accent);border-bottom-color:var(--accent)}
|
||
|
||
/* ── 탭 패널 ─────────────────────────────── */
|
||
.tab-panel{flex:1;display:flex;overflow:hidden}
|
||
.tab-panel.hidden{display:none!important}
|
||
|
||
/* ── 레이아웃 ─────────────────────────────── */
|
||
.app-body{display:flex;flex:1;overflow:hidden}
|
||
.builder-panel{width:460px;min-width:340px;background:var(--surface);border-right:1px solid var(--border);display:flex;flex-direction:column;overflow:hidden}
|
||
.panel-lbl{padding:8px 12px 7px;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);border-bottom:1px solid var(--border);flex-shrink:0;background:var(--surface-alt);display:flex;align-items:center;gap:6px}
|
||
.panel-lbl-title{flex:1}
|
||
.builder-scroll{flex:1;overflow-y:auto;padding:12px}
|
||
.builder-scroll::-webkit-scrollbar{width:5px}
|
||
.builder-scroll::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}
|
||
|
||
/* ── 문서 루트 ────────────────────────────── */
|
||
.root-row{display:flex;align-items:center;gap:8px;background:var(--surface-alt);border:1.5px solid var(--border);border-radius:var(--r);padding:7px 10px;margin-bottom:10px;transition:border-color .15s}
|
||
.root-row:focus-within{border-color:var(--border-focus);background:var(--surface)}
|
||
.root-lbl{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);flex-shrink:0}
|
||
.root-title{flex:1;border:none;outline:none;font-size:13px;font-weight:500;background:transparent}
|
||
.root-title::placeholder{color:var(--muted);font-weight:400}
|
||
|
||
/* ── 공통 입력 ────────────────────────────── */
|
||
.mark-inp{width:34px;text-align:center;font-size:15px;border:1px solid var(--border);border-radius:5px;padding:2px 2px;background:var(--surface);flex-shrink:0;outline:none}
|
||
.mark-inp:focus{border-color:var(--border-focus)}
|
||
input,textarea,button,select{font-family:inherit}
|
||
|
||
/* ── 버튼 공통 ────────────────────────────── */
|
||
.iBtn{width:22px;height:22px;display:flex;align-items:center;justify-content:center;border-radius:4px;border:none;cursor:pointer;font-size:12px;background:transparent;color:var(--muted);transition:all .1s;flex-shrink:0}
|
||
.iBtn:hover{background:var(--red-bg);color:var(--red)}
|
||
.iBtn.mv:hover{background:var(--accent-bg);color:var(--accent)}
|
||
.iBtn.eye{font-size:13px}
|
||
.iBtn.eye.off{color:var(--border)}
|
||
.add-node-btn{display:flex;align-items:center;justify-content:center;gap:6px;width:100%;padding:8px;background:var(--accent-bg);color:var(--accent);border:1.5px dashed var(--accent);border-radius:var(--r);cursor:pointer;font-size:12px;font-weight:600;margin-bottom:10px;transition:all .15s}
|
||
.add-node-btn:hover{background:var(--accent);color:#fff;border-style:solid}
|
||
|
||
/* ── 노드 카드 ────────────────────────────── */
|
||
.node-list{display:flex;flex-direction:column;gap:8px}
|
||
.node-card{border:1.5px solid var(--border);border-radius:var(--r);background:var(--surface);box-shadow:var(--sh)}
|
||
.node-card.nested{border-color:var(--accent);border-left-width:3px}
|
||
.node-head{display:flex;align-items:center;gap:5px;padding:6px 8px;background:var(--surface-alt);border-bottom:1px solid var(--border);border-radius:calc(var(--r) - 1.5px) calc(var(--r) - 1.5px) 0 0}
|
||
.node-head.is-hidden{border-bottom:none;border-radius:calc(var(--r) - 1.5px)}
|
||
.name-inp{flex:1;border:none;outline:none;font-size:13px;font-weight:500;background:transparent;min-width:0}
|
||
.name-inp::placeholder{color:var(--muted);font-weight:400}
|
||
.node-body{padding:8px;display:flex;flex-direction:column;gap:6px}
|
||
|
||
/* ── 섹션 (값 / 노드설정) ──────────────────── */
|
||
.body-sec{border:1px solid var(--border);border-radius:6px;overflow:hidden}
|
||
.body-sec-hdr{display:flex;align-items:center;gap:6px;padding:5px 8px;background:var(--surface-alt);border-bottom:1px solid var(--border)}
|
||
.body-sec-title{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);flex:1}
|
||
.add-sel{font-size:11px;padding:2px 6px;border:1px solid var(--border);border-radius:4px;background:var(--surface);color:var(--text);cursor:pointer;outline:none;max-width:140px}
|
||
.add-sel:focus{border-color:var(--border-focus)}
|
||
.all-set{font-size:10px;color:var(--muted);font-style:italic}
|
||
.empty-hint{font-size:11px;color:var(--muted);font-style:italic;padding:7px 8px}
|
||
.body-sec-body{padding:7px 8px;display:flex;flex-direction:column;gap:6px}
|
||
|
||
/* ── 값 아이템 ────────────────────────────── */
|
||
.val-item{border:1px solid var(--border);border-radius:5px;background:var(--surface-alt);overflow:hidden}
|
||
.val-item.val-node{border:none;background:transparent;padding:0}
|
||
.val-head{display:flex;align-items:center;gap:5px;padding:4px 6px;background:var(--surface);border-bottom:1px solid var(--border)}
|
||
.val-type-badge{font-size:10px;font-weight:700;color:var(--muted);background:var(--surface-alt);border:1px solid var(--border);border-radius:3px;padding:1px 5px;text-transform:uppercase;letter-spacing:.04em;flex-shrink:0}
|
||
.val-body{padding:6px}
|
||
.val-data-inp{width:100%;font-size:12px;border:1px solid var(--border);border-radius:4px;padding:4px 6px;background:var(--surface);outline:none}
|
||
.val-data-inp:focus{border-color:var(--border-focus)}
|
||
.sep-chip{font-size:11px;padding:2px 8px;border-radius:4px;border:1px solid var(--border);background:var(--surface);color:var(--muted);cursor:pointer;font-weight:500;transition:all .1s;white-space:nowrap}
|
||
.sep-chip:hover{border-color:var(--accent);color:var(--accent)}
|
||
.sep-chip.on{background:var(--accent);color:#fff;border-color:var(--accent)}
|
||
|
||
/* ── 구분자/이름줄바꿈 설정 (노드 설정 내부) ─ */
|
||
.struct-sep{display:flex;flex-direction:column;gap:4px;padding-bottom:6px;margin-bottom:6px;border-bottom:1px dashed var(--border)}
|
||
.sep-bar-row{display:flex;align-items:center;gap:5px}
|
||
.sep-bar-lbl{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);width:52px;flex-shrink:0}
|
||
|
||
/* ── 노드 설정 ────────────────────────────── */
|
||
.setting-row{display:flex;align-items:center;gap:5px;padding:3px 0}
|
||
.setting-lbl{font-size:11px;color:var(--muted);width:54px;flex-shrink:0}
|
||
.setting-inp{flex:1;font-size:12px;border:1px solid var(--border);border-radius:4px;padding:3px 6px;background:var(--surface-alt);outline:none}
|
||
.setting-inp:focus{border-color:var(--border-focus);background:#fff}
|
||
.attrs-hdr{display:flex;align-items:center;gap:5px;margin-bottom:4px}
|
||
.attrs-lbl{font-size:11px;color:var(--muted);flex:1}
|
||
.attr-row{display:flex;align-items:center;gap:4px;margin-bottom:3px}
|
||
.attr-key-lbl{font-size:11px;font-weight:600;color:var(--accent);background:var(--accent-bg);border:1px solid var(--accent);border-radius:4px;padding:2px 7px;white-space:nowrap;max-width:150px;overflow:hidden;text-overflow:ellipsis;flex-shrink:0}
|
||
.attr-val{flex:1;font-size:12px;border:1px solid var(--border);border-radius:4px;padding:2px 5px;background:var(--surface-alt);outline:none}
|
||
.attr-val:focus{border-color:var(--border-focus);background:#fff}
|
||
|
||
/* ── 미리보기 패널 ────────────────────────── */
|
||
.preview-panel{flex:1;display:flex;flex-direction:column;overflow:hidden;background:var(--dk)}
|
||
.preview-head{display:flex;align-items:center;gap:7px;padding:9px 14px;background:var(--dk2);border-bottom:1px solid var(--dk3);flex-shrink:0}
|
||
.tabs{display:flex;gap:2px}
|
||
.tab{font-size:12px;padding:4px 11px;border-radius:5px;cursor:pointer;color:var(--dt3);border:none;background:transparent;font-weight:500;transition:all .15s}
|
||
.tab.active{background:var(--dk3);color:var(--dt)}
|
||
.p-spacer{flex:1}
|
||
.copy-btn{display:flex;align-items:center;gap:5px;font-size:12px;padding:5px 11px;background:var(--dk3);color:var(--dt2);border:1px solid var(--dk4);border-radius:6px;cursor:pointer;transition:all .15s;font-weight:500}
|
||
.copy-btn:hover{background:var(--dk4)}
|
||
.copy-btn.ok{background:#1a3a2a;color:#a6e3a1;border-color:#a6e3a1}
|
||
.preview-body{flex:1;overflow-y:auto;padding:20px 22px}
|
||
.preview-body::-webkit-scrollbar{width:5px}
|
||
.preview-body::-webkit-scrollbar-track{background:var(--dk)}
|
||
.preview-body::-webkit-scrollbar-thumb{background:var(--dk4);border-radius:3px}
|
||
.preview-text{font-family:'Cascadia Code','Fira Code','Consolas',monospace;font-size:13px;line-height:1.75;color:var(--dt);white-space:pre-wrap;word-break:break-word}
|
||
.preview-text.dim{color:var(--dt3);font-style:italic}
|
||
.j-key{color:#89b4fa}.j-str{color:#a6e3a1}.j-num{color:#fab387}.j-bool{color:#f38ba8}.j-null{color:#f38ba8;font-style:italic}
|
||
|
||
/* ── 임포트 탭 ────────────────────────────── */
|
||
.import-tabs{display:flex;gap:0;margin-bottom:12px;border-bottom:2px solid var(--border)}
|
||
.itab{font-size:12px;padding:6px 16px;border:none;background:transparent;color:var(--muted);cursor:pointer;font-weight:500;border-bottom:2px solid transparent;margin-bottom:-2px;transition:all .15s}
|
||
.itab.active{color:var(--accent);border-bottom-color:var(--accent);font-weight:700}
|
||
.itab:hover:not(.active){color:var(--text)}
|
||
|
||
/* ── 모달 ─────────────────────────────────── */
|
||
.overlay{position:fixed;inset:0;background:rgba(0,0,0,.45);display:none;align-items:center;justify-content:center;z-index:200}
|
||
.overlay.open{display:flex}
|
||
.modal{background:var(--surface);border-radius:12px;padding:20px;width:500px;max-width:92vw;box-shadow:0 20px 60px rgba(0,0,0,.25)}
|
||
.modal h2{font-size:15px;font-weight:700;margin-bottom:6px}
|
||
.modal p{font-size:12px;color:var(--muted);margin-bottom:10px}
|
||
.modal-field{display:flex;align-items:center;gap:8px;margin-bottom:8px}
|
||
.modal-field label{font-size:12px;color:var(--muted);width:80px;flex-shrink:0}
|
||
.modal-field input{font-size:13px;border:1.5px solid var(--border);border-radius:6px;padding:4px 8px;flex:1;outline:none}
|
||
.modal-field input:focus{border-color:var(--border-focus)}
|
||
.modal textarea{width:100%;height:175px;font-family:monospace;font-size:12px;border:1.5px solid var(--border);border-radius:6px;padding:9px;resize:vertical;line-height:1.6;outline:none}
|
||
.modal textarea:focus{border-color:var(--border-focus)}
|
||
.modal-actions{display:flex;gap:8px;justify-content:flex-end;margin-top:12px}
|
||
.mBtn{font-size:13px;padding:7px 16px;border-radius:6px;border:1px solid;cursor:pointer;font-weight:600}
|
||
.mBtn.ok{background:var(--accent);color:#fff;border-color:var(--accent)}
|
||
.mBtn.ok:hover{background:#3a5ce0}
|
||
.mBtn.cancel{background:var(--surface);color:var(--muted);border-color:var(--border)}
|
||
.mBtn.cancel:hover{background:var(--surface-alt)}
|
||
.hsep{height:1px;background:var(--border);margin:10px 0}
|
||
|
||
/* ── Step 2: 캔버스 빌더 레이아웃 ─────────── */
|
||
#panel-builder{flex-direction:column}
|
||
.canvas-toolbar{background:var(--surface);border-bottom:1px solid var(--border);padding:8px 12px;display:flex;align-items:center;gap:8px;flex-shrink:0}
|
||
.canvas-toolbar .root-row{flex:1;margin:0;border:none;background:transparent;padding:0;box-shadow:none}
|
||
.canvas-toolbar .root-row:focus-within{border:none;background:transparent}
|
||
.builder-main{flex:1;display:flex;overflow:hidden}
|
||
.canvas-wrap{flex:1;overflow:auto;background:#eaecf2;position:relative}
|
||
.canvas-inner{position:relative}
|
||
.canvas-svg{position:absolute;top:0;left:0;pointer-events:none;overflow:visible}
|
||
/* 캔버스 노드 카드 */
|
||
.cnode{position:absolute;width:164px;background:var(--surface);border:1.5px solid var(--border);border-radius:var(--r);box-shadow:var(--sh);cursor:pointer;transition:border-color .15s,box-shadow .15s;user-select:none}
|
||
.cnode:hover{border-color:var(--border-focus)}
|
||
.cnode.selected{border-color:var(--accent);box-shadow:0 0 0 3px rgba(79,110,247,.12)}
|
||
.cnode-head{display:flex;align-items:center;gap:5px;padding:7px 9px;background:var(--surface-alt);border-bottom:1px solid var(--border);border-radius:calc(var(--r) - 1.5px) calc(var(--r) - 1.5px) 0 0}
|
||
.cnode.selected .cnode-head{background:var(--accent-bg)}
|
||
.cnode-mark{font-size:14px;flex-shrink:0;line-height:1}
|
||
.cnode-name{font-size:12px;font-weight:500;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
.cnode-foot{display:flex;align-items:center;padding:4px 8px;gap:4px;min-height:28px}
|
||
.cnode-vals{font-size:10px;color:var(--muted);flex:1}
|
||
.cnode-btns{display:flex;gap:2px;opacity:0;transition:opacity .1s}
|
||
.cnode:hover .cnode-btns,.cnode.selected .cnode-btns{opacity:1}
|
||
.cnode-btn{width:18px;height:18px;display:flex;align-items:center;justify-content:center;border-radius:3px;border:none;cursor:pointer;font-size:10px;background:transparent;color:var(--muted);transition:all .1s;padding:0;flex-shrink:0}
|
||
.cnode-btn:hover{background:var(--accent-bg);color:var(--accent)}
|
||
.cnode-btn.del:hover{background:var(--red-bg);color:var(--red)}
|
||
.canvas-empty{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);text-align:center;color:var(--muted);pointer-events:none;line-height:1.8}
|
||
/* 우측 패널 */
|
||
.right-panel{width:400px;min-width:260px;display:flex;flex-direction:column;overflow:hidden;border-left:1px solid var(--border)}
|
||
.detail-panel{flex:1;overflow-y:auto;background:var(--surface);display:flex;flex-direction:column}
|
||
.detail-panel::-webkit-scrollbar{width:5px}
|
||
.detail-panel::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}
|
||
.detail-empty{flex:1;display:flex;align-items:center;justify-content:center;color:var(--muted);font-size:12px;font-style:italic;text-align:center;padding:20px}
|
||
.detail-hdr-lbl{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);padding:7px 12px 6px;border-bottom:1px solid var(--border);background:var(--surface-alt);flex-shrink:0}
|
||
.detail-node-head{display:flex;align-items:center;gap:5px;padding:8px 10px;border-bottom:1px solid var(--border);background:var(--surface-alt);flex-shrink:0}
|
||
.detail-body{padding:10px;display:flex;flex-direction:column;gap:8px}
|
||
|
||
/* ── Tab 2: 내용 채우기 ──────────────────── */
|
||
#panel-filler{flex-direction:column}
|
||
.filler-toolbar{background:var(--surface);border-bottom:1px solid var(--border);padding:8px 12px;display:flex;align-items:center;gap:8px;flex-shrink:0}
|
||
.filler-title{font-size:13px;font-weight:600;color:var(--text);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding:0 4px}
|
||
.filler-main{flex:1;display:flex;overflow:hidden}
|
||
.filler-form-wrap{flex:1;overflow-y:auto;padding:18px 20px;background:var(--bg)}
|
||
.filler-form-wrap::-webkit-scrollbar{width:5px}
|
||
.filler-form-wrap::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}
|
||
.filler-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;gap:8px;color:var(--muted);text-align:center;line-height:1.7}
|
||
.filler-preview{width:400px;min-width:260px;display:flex;flex-direction:column;border-left:1px solid var(--border)}
|
||
/* 섹션 카드 */
|
||
.fsec{margin-bottom:10px}
|
||
.fsec-head{display:flex;align-items:center;gap:7px;padding:7px 10px;background:var(--surface);border:1.5px solid var(--border);border-radius:var(--r) var(--r) 0 0}
|
||
.fsec-head.alone{border-radius:var(--r)}
|
||
.fsec-body{padding:8px;background:var(--surface);border:1.5px solid var(--border);border-top:none;border-radius:0 0 var(--r) var(--r);display:flex;flex-direction:column;gap:6px}
|
||
.fsec-body>.fsec{margin-bottom:0}
|
||
.fsec-body>.fsec .fsec-head{border-left:3px solid var(--accent);padding-left:8px}
|
||
.fsec-mark{font-size:15px;flex-shrink:0;line-height:1}
|
||
.fsec-name{font-size:13px;font-weight:500}
|
||
/* 값 입력 */
|
||
.fval-row{display:flex;flex-direction:column;gap:3px}
|
||
.fval-lbl{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted)}
|
||
.fval-inp{width:100%;font-size:13px;border:1.5px solid var(--border);border-radius:6px;padding:6px 8px;outline:none;font-family:inherit;background:var(--surface-alt);resize:vertical;line-height:1.5}
|
||
.fval-inp:focus{border-color:var(--border-focus);background:#fff}
|
||
|
||
/* ── 양식 합치기: 입력 패널 ──────────────── */
|
||
.filler-input-panel{flex:1;display:flex;flex-direction:column;overflow:hidden;background:var(--bg)}
|
||
.ftpl-view{padding:8px 14px;border-bottom:1px solid var(--border);background:var(--surface-alt);flex-shrink:0;display:flex;flex-wrap:wrap;align-items:center;gap:4px 8px;min-height:38px;max-height:90px;overflow-y:auto}
|
||
.ftpl-view::-webkit-scrollbar{height:4px}
|
||
.ftpl-empty{font-size:11px;color:var(--muted);font-style:italic}
|
||
.ftpl-title{display:flex;align-items:center;gap:4px;font-size:12px;font-weight:700;color:var(--text);padding-right:8px;border-right:1px solid var(--border);margin-right:4px}
|
||
.ftpl-sec{display:inline-flex;align-items:center;gap:3px;font-size:11px;background:var(--surface);border:1px solid var(--border);border-radius:4px;padding:2px 7px;white-space:nowrap}
|
||
.ftpl-mark{font-size:13px;line-height:1}
|
||
.ftpl-name{font-weight:500;color:var(--text)}
|
||
.ftpl-arr{color:var(--muted);font-size:9px}
|
||
.ftpl-badge{font-size:9px;background:var(--accent-bg);color:var(--accent);border-radius:3px;padding:1px 5px;font-weight:700;margin-left:2px;letter-spacing:.04em}
|
||
.filler-textarea-wrap{flex:1;display:flex;flex-direction:column;padding:12px;overflow:hidden}
|
||
.filler-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}
|
||
.filler-inst-count{font-size:11px;font-weight:600;color:var(--accent);background:var(--accent-bg);border-radius:99px;padding:1px 9px;text-transform:none;letter-spacing:0}
|
||
.filler-textarea{flex:1;width:100%;font-size:13px;font-family:inherit;border:1.5px solid var(--border);border-radius:var(--r);padding:10px 12px;outline:none;resize:none;line-height:1.75;background:var(--surface);color:var(--text)}
|
||
.filler-textarea:focus{border-color:var(--border-focus)}
|
||
.filler-textarea::placeholder{color:var(--muted)}
|
||
|
||
/* ── 수집/고정 구분 ──────────────────────────── */
|
||
.cnode.collect .cnode-head{background:#e8eeff}
|
||
.cnode.collect.selected .cnode-head{background:var(--accent-bg)}
|
||
.ftpl-sec.is-collect{border-color:var(--accent);background:var(--accent-bg)}
|
||
.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)}
|
||
/* per_line 전용 UI */
|
||
.perline-block{display:flex;align-items:center;gap:7px;padding:5px 6px;background:var(--surface-alt);border:1px solid var(--border);border-radius:5px}
|
||
.perline-lbl{font-size:11px;font-weight:600;color:var(--muted);white-space:nowrap}
|
||
.perline-inp{width:72px;font-size:14px;font-weight:600;border:1.5px solid var(--border);border-radius:5px;padding:3px 7px;outline:none;background:var(--surface);text-align:center;color:var(--text)}
|
||
.perline-inp:focus{border-color:var(--border-focus)}
|
||
.perline-inp::placeholder{font-size:10px;font-weight:400;color:var(--muted)}
|
||
.perline-unit{font-size:11px;color:var(--muted)}
|
||
|
||
/* ── 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>
|
||
|
||
<header class="app-header">
|
||
<span style="font-size:18px">🏗️</span>
|
||
<h1>CTStruct</h1>
|
||
<span class="badge">v0.9</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" id="mtab-extractor" onclick="switchTab('extractor')">🔍 구조 추출</button>
|
||
</nav>
|
||
|
||
<div id="panel-builder" class="tab-panel">
|
||
|
||
<div class="canvas-toolbar">
|
||
<span style="font-size:16px">🌳</span>
|
||
<div class="root-row">
|
||
<span class="root-lbl">문서</span>
|
||
<input class="mark-inp" id="rootMark" title="제목 구분자"
|
||
oninput="S.titleMark=this.value;updatePreview();save()">
|
||
<input class="root-title" id="docTitle" placeholder="제목 없음"
|
||
oninput="S.title=this.value;updatePreview();save()">
|
||
</div>
|
||
<button class="hdr-btn" onclick="addTopNode()">+ 노드 추가</button>
|
||
<button class="hdr-btn" onclick="openImport()">📥 불러오기</button>
|
||
<button class="hdr-btn" onclick="newDoc()">🗒 새 문서</button>
|
||
</div>
|
||
|
||
<div class="builder-main">
|
||
<div class="canvas-wrap" id="canvasWrap">
|
||
<div class="canvas-inner" id="canvasInner">
|
||
<svg id="canvasSvg" class="canvas-svg"></svg>
|
||
<div id="canvasNodes"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="right-panel">
|
||
<div class="detail-panel" id="detailPanel">
|
||
<div class="detail-empty">← 노드를 클릭하면<br>상세 편집이 여기에 표시됩니다</div>
|
||
</div>
|
||
<div class="preview-panel">
|
||
<div class="preview-head">
|
||
<div class="tabs">
|
||
<button class="tab active" id="tab-text" onclick="setTab('text')">텍스트</button>
|
||
<button class="tab" id="tab-json" onclick="setTab('json')">JSON slim</button>
|
||
<button class="tab" id="tab-json-full" onclick="setTab('json-full')">JSON full</button>
|
||
</div>
|
||
<div class="p-spacer"></div>
|
||
<button class="copy-btn" id="copyBtn" onclick="copyOutput()">📋 복사</button>
|
||
</div>
|
||
<div class="preview-body">
|
||
<pre class="preview-text" id="previewOut"></pre>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div><!-- /panel-builder -->
|
||
|
||
<div id="panel-filler" class="tab-panel hidden">
|
||
|
||
<div class="filler-toolbar">
|
||
<span style="font-size:16px">📋</span>
|
||
<span class="filler-title" id="fillerTitle">템플릿 없음</span>
|
||
<button class="hdr-btn" onclick="openFillerImport()">📂 템플릿 불러오기</button>
|
||
<button class="hdr-btn" onclick="clearFiller()">🗑 초기화</button>
|
||
</div>
|
||
|
||
<div class="filler-main">
|
||
|
||
<div class="filler-input-panel">
|
||
<!-- 템플릿 섹션 구조 표시 -->
|
||
<div class="ftpl-view" id="ftplView">
|
||
<span class="ftpl-empty">← 템플릿을 먼저 불러오세요</span>
|
||
</div>
|
||
<!-- 텍스트 입력 -->
|
||
<div class="filler-textarea-wrap">
|
||
<div class="filler-input-lbl">
|
||
양식 붙여넣기
|
||
<span class="filler-inst-count" id="instCount"></span>
|
||
</div>
|
||
<textarea class="filler-textarea" id="fillerInputText"
|
||
placeholder="여러 양식을 여기에 붙여넣으세요. 제목 구분자(예: 📝)가 있으면 자동으로 인스턴스를 구분합니다."
|
||
oninput="parseAndMerge()"></textarea>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="filler-preview">
|
||
<div class="preview-head">
|
||
<div class="tabs">
|
||
<button class="tab ftab active" id="ftab-text" onclick="setFillerTab('text')">텍스트</button>
|
||
<button class="tab ftab" id="ftab-json" onclick="setFillerTab('json')">JSON</button>
|
||
</div>
|
||
<div class="p-spacer"></div>
|
||
<button class="copy-btn" id="fCopyBtn" onclick="copyFillerOutput()">📋 복사</button>
|
||
</div>
|
||
<div class="preview-body">
|
||
<pre class="preview-text dim" id="fillerOut">양식을 붙여넣으면 합치기 결과가 표시됩니다</pre>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
</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>
|
||
<p>Builder의 <strong>JSON slim</strong> 또는 <strong>JSON full</strong> 출력을 붙여넣으세요.</p>
|
||
<textarea id="fillerImportTxt" placeholder='{"type":"ROOT", "children":[...]}'></textarea>
|
||
<div class="modal-actions">
|
||
<button class="mBtn cancel" onclick="closeFillerImport()">취소</button>
|
||
<button class="mBtn ok" onclick="doLoadFillerTemplate()">불러오기</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="overlay" id="importModal">
|
||
<div class="modal">
|
||
<h2>📥 불러오기</h2>
|
||
<div class="import-tabs">
|
||
<button class="itab active" id="itab-text" onclick="switchImportTab('text')">텍스트</button>
|
||
<button class="itab" id="itab-json" onclick="switchImportTab('json')">JSON</button>
|
||
</div>
|
||
|
||
<!-- 텍스트 탭 -->
|
||
<div id="import-text-body">
|
||
<p>기존 양식 텍스트를 붙여넣으면 편집기로 불러옵니다.</p>
|
||
<div class="modal-field">
|
||
<label>제목 구분자</label>
|
||
<input id="importTitleMark" style="width:60px;flex:none" value="✅">
|
||
</div>
|
||
<div class="modal-field">
|
||
<label>섹션 구분자들</label>
|
||
<input id="importMarks" placeholder="🔹,🔸 (쉼표 구분, L1·L2 순서)">
|
||
</div>
|
||
<div class="hsep"></div>
|
||
<textarea id="importTxt" placeholder="✅예시 양식 🔹1번 항목 홍길동 홍길순 🔹2번 항목 🔸소항목 (건강) 홍길찬"></textarea>
|
||
</div>
|
||
|
||
<!-- JSON 탭 -->
|
||
<div id="import-json-body" style="display:none">
|
||
<p>JSON slim 또는 JSON full 출력을 붙여넣으면 노드로 복원합니다.<br>
|
||
<span style="font-size:11px;color:var(--muted)">※ 구분자·줄바꿈 설정은 기본값으로 초기화됩니다.</span></p>
|
||
<textarea id="importJsonTxt" placeholder='{"type":"ROOT", "children":[...]}'></textarea>
|
||
</div>
|
||
|
||
<div class="modal-actions">
|
||
<button class="mBtn cancel" onclick="closeImport()">취소</button>
|
||
<button class="mBtn ok" onclick="doImportActive()">불러오기</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
// ════════════════════════════════════════════════════════
|
||
// 상수
|
||
// ════════════════════════════════════════════════════════
|
||
const VALUE_TYPES = [
|
||
{v:'node', lbl:'노드 (Node)'},
|
||
{v:'string', lbl:'문자열 (String)'},
|
||
{v:'int', lbl:'정수 (Int)'},
|
||
{v:'bool', lbl:'불리언 (Bool)'},
|
||
{v:'date', lbl:'날짜 (Date)'},
|
||
];
|
||
|
||
const SETTING_OPTIONS = [
|
||
{v:'prefix', lbl:'앞 문자열 (prefix)'},
|
||
{v:'suffix', lbl:'뒤 문자열 (suffix)'},
|
||
{v:'attributes', lbl:'어트리뷰트'},
|
||
];
|
||
|
||
const ATTR_KEYS = [
|
||
{v:'lang', lbl:'lang — 언어'},
|
||
{v:'id', lbl:'id — 식별자'},
|
||
{v:'class', lbl:'class — 분류'},
|
||
{v:'href', lbl:'href — 링크'},
|
||
{v:'align', lbl:'align — 정렬'},
|
||
{v:'order', lbl:'order — 순서'},
|
||
{v:'required', lbl:'required — 필수'},
|
||
{v:'readonly', lbl:'readonly — 읽기전용'},
|
||
{v:'hidden', lbl:'hidden — 숨김'},
|
||
{v:'weight', lbl:'weight — 가중치'},
|
||
{v:'style', lbl:'style — 스타일'},
|
||
{v:'_custom', lbl:'직접 입력...'},
|
||
];
|
||
|
||
// ════════════════════════════════════════════════════════
|
||
// 팩토리
|
||
// ════════════════════════════════════════════════════════
|
||
function uid(){ return Math.random().toString(36).slice(2,9) }
|
||
|
||
function mkNode(mark='', content=''){
|
||
return { id:uid(), mark, content, hidden:false, values:[], settings:{},
|
||
nameNewline:true };
|
||
}
|
||
function mkNodeVal(childNode){
|
||
return { id:uid(), type:'node', node: childNode ?? mkNode() };
|
||
}
|
||
function mkScalarVal(type){
|
||
return { id:uid(), type, data:'' };
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════
|
||
// 상태
|
||
// ════════════════════════════════════════════════════════
|
||
let S = { titleMark:'✅', title:'예시 양식', nodes:[] };
|
||
|
||
// ID 맵 (렌더 전 재구성)
|
||
let nodeMap = {}; // nodeId → node
|
||
let valueMap = {}; // valueId → {val, nodeId}
|
||
let valNodeMap = {};// childNodeId → valueId (node-type value 역참조)
|
||
|
||
function rebuildMaps(){
|
||
nodeMap={}; valueMap={}; valNodeMap={};
|
||
function idx(n){
|
||
nodeMap[n.id]=n;
|
||
for(const v of n.values){
|
||
valueMap[v.id]={val:v, nodeId:n.id};
|
||
if(v.type==='node'){ valNodeMap[v.node.id]=v.id; idx(v.node); }
|
||
}
|
||
}
|
||
for(const n of S.nodes) idx(n);
|
||
}
|
||
|
||
let currentTab = 'text';
|
||
|
||
// ── 영속성 ─────────────────────────────────────────────
|
||
function save(){ try{ localStorage.setItem('ctstruct-v4', JSON.stringify(S)) }catch(_){} }
|
||
function load(){
|
||
try{
|
||
const raw=localStorage.getItem('ctstruct-v4');
|
||
if(raw){ S=JSON.parse(raw); normalizeState(S); return; }
|
||
const old=localStorage.getItem('ctstruct-builder');
|
||
if(old){ const o=JSON.parse(old); S=migrateOld(o); save(); return; }
|
||
}catch(_){}
|
||
S=buildExample();
|
||
}
|
||
|
||
const VALID_TYPES=new Set(['node','string','int','bool','date']);
|
||
function normalizeNode(n){
|
||
if(n.nameNewline===undefined) n.nameNewline=true;
|
||
// list → string, 지원하지 않는 타입 → string
|
||
n.values=(n.values||[]).map(v=>{
|
||
if(v.type==='list'){
|
||
const sv=mkScalarVal('string'); sv.data=(v.names??'').trim(); return sv;
|
||
}
|
||
if(v.type==='node'){ normalizeNode(v.node); return v; }
|
||
if(v.type==='integer') v.type='int';
|
||
if(v.type==='boolean') v.type='bool';
|
||
if(!VALID_TYPES.has(v.type)) v.type='string';
|
||
return v;
|
||
});
|
||
}
|
||
function normalizeState(s){
|
||
for(const n of s.nodes||[]) normalizeNode(n);
|
||
}
|
||
|
||
function migrateOld(o){
|
||
const titleMark = o.titleMark ?? o.cfg?.titleMark ?? '✅';
|
||
const oldMarks = o.cfg?.marks ?? [];
|
||
const sections = o.sections ?? o.nodes ?? [];
|
||
return {
|
||
titleMark,
|
||
title: o.title ?? '',
|
||
nodes: sections.map(s => migSec(s, oldMarks))
|
||
};
|
||
}
|
||
function migSec(s, oldMarks){
|
||
const mark = s.mark ?? oldMarks[(s.lv??1)-1] ?? '';
|
||
const n = mkNode(mark, s.content ?? s.name ?? '');
|
||
// groups → string values (names 합치기)
|
||
for(const g of s.groups??[]){
|
||
const names=(g.names??'').trim(); if(!names) continue;
|
||
const sv=mkScalarVal('string'); sv.data=names; n.values.push(sv);
|
||
}
|
||
for(const c of s.children??[]) n.values.push(mkNodeVal(migSec(c, oldMarks)));
|
||
if(s.value!=null && s.data_type && s.data_type!=='unset'){
|
||
const sv=mkScalarVal('string'); sv.data=String(s.value??''); n.values.push(sv);
|
||
}
|
||
if(s.prefix) n.settings.prefix=s.prefix;
|
||
if(s.suffix) n.settings.suffix=s.suffix;
|
||
const filtAttrs=Object.entries(s.attributes??{}).filter(([k])=>k!=='level'&&k!=='category'&&k!=='mark');
|
||
if(filtAttrs.length) n.settings.attributes=Object.fromEntries(filtAttrs);
|
||
return n;
|
||
}
|
||
|
||
function buildExample(){
|
||
const n1=mkNode('🔹','1번 항목');
|
||
const sv1=mkScalarVal('string'); sv1.data='홍길동 홍길순 홍길영 홍길준'; n1.values.push(sv1);
|
||
const n2=mkNode('🔹','2번 항목');
|
||
const c1=mkNode('🔸','2-1 항목'); const sv2=mkScalarVal('string'); sv2.data='홍길찬 홍길만'; c1.values.push(sv2);
|
||
const c2=mkNode('🔸','2-2 항목'); const sv3=mkScalarVal('string'); sv3.data='홍길주'; c2.values.push(sv3);
|
||
n2.values.push(mkNodeVal(c1)); n2.values.push(mkNodeVal(c2));
|
||
return {titleMark:'✅', title:'예시 양식', nodes:[n1,n2]};
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════
|
||
// CRUD — 노드
|
||
// ════════════════════════════════════════════════════════
|
||
function addTopNode(){
|
||
const node=mkNode();
|
||
S.nodes.push(node);
|
||
selectedNodeId=node.id;
|
||
refresh();
|
||
}
|
||
function deleteNode(nid){
|
||
const vid=valNodeMap[nid];
|
||
if(vid){ removeValue(vid); return; }
|
||
S.nodes=S.nodes.filter(n=>n.id!==nid);
|
||
refresh();
|
||
}
|
||
function moveNode(nid,dir){
|
||
const vid=valNodeMap[nid];
|
||
if(vid){ moveValue(vid,dir); return; }
|
||
const i=S.nodes.findIndex(n=>n.id===nid), j=i+dir;
|
||
if(j<0||j>=S.nodes.length) return;
|
||
[S.nodes[i],S.nodes[j]]=[S.nodes[j],S.nodes[i]];
|
||
refresh();
|
||
}
|
||
function toggleHidden(nid){
|
||
const n=nodeMap[nid]; if(!n) return;
|
||
n.hidden=!n.hidden; refresh();
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════
|
||
// CRUD — 값
|
||
// ════════════════════════════════════════════════════════
|
||
function addValue(nid, type){
|
||
if(!type) return;
|
||
const n=nodeMap[nid]; if(!n) return;
|
||
const v = type==='node' ? mkNodeVal() : mkScalarVal(type);
|
||
n.values.push(v);
|
||
if(type==='node') selectedNodeId=v.node.id;
|
||
refresh();
|
||
}
|
||
function removeValue(vid){
|
||
const e=valueMap[vid]; if(!e) return;
|
||
const n=nodeMap[e.nodeId]; if(!n) return;
|
||
n.values=n.values.filter(v=>v.id!==vid); refresh();
|
||
}
|
||
function moveValue(vid,dir){
|
||
const e=valueMap[vid]; if(!e) return;
|
||
const n=nodeMap[e.nodeId]; if(!n) return;
|
||
const i=n.values.findIndex(v=>v.id===vid), j=i+dir;
|
||
if(j<0||j>=n.values.length) return;
|
||
[n.values[i],n.values[j]]=[n.values[j],n.values[i]];
|
||
refresh();
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════
|
||
// CRUD — 설정
|
||
// ════════════════════════════════════════════════════════
|
||
function addSetting(nid, key){
|
||
if(!key) return;
|
||
const n=nodeMap[nid]; if(!n||key in n.settings) return;
|
||
n.settings[key]=key==='attributes'?{}:''; refresh();
|
||
}
|
||
function removeSetting(nid, key){
|
||
const n=nodeMap[nid]; if(!n) return;
|
||
delete n.settings[key]; refresh();
|
||
}
|
||
function setSettingVal(nid, key, val){
|
||
const n=nodeMap[nid]; if(!n) return;
|
||
n.settings[key]=val||null; updatePreview(); save();
|
||
}
|
||
|
||
// ── 어트리뷰트 ─────────────────────────────────────────
|
||
function handleAddAttr(nid, key){
|
||
if(!key) return;
|
||
if(key==='_custom'){
|
||
const k=(prompt('어트리뷰트 키 이름 입력:')??'').trim();
|
||
if(k) addAttr(nid, k);
|
||
} else {
|
||
addAttr(nid, key);
|
||
}
|
||
}
|
||
function addAttr(nid, key){
|
||
const n=nodeMap[nid]; if(!n||!n.settings.attributes||key in n.settings.attributes) return;
|
||
n.settings.attributes[key]=''; refresh();
|
||
setTimeout(()=>{
|
||
const inp=document.querySelector(`[data-attrnode="${nid}"][data-attrkey] .attr-val`);
|
||
// focus the last added attr-val
|
||
const all=document.querySelectorAll(`[data-attrnode="${nid}"] .attr-val`);
|
||
if(all.length) all[all.length-1].focus();
|
||
},40);
|
||
}
|
||
function setAttrVal(nid, key, val){
|
||
const n=nodeMap[nid]; if(!n||!n.settings.attributes) return;
|
||
n.settings.attributes[key]=val; updatePreview(); save();
|
||
}
|
||
function removeAttr(nid, key){
|
||
const n=nodeMap[nid]; if(!n||!n.settings.attributes) return;
|
||
delete n.settings.attributes[key]; refresh();
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════
|
||
// 헬퍼
|
||
// ════════════════════════════════════════════════════════
|
||
function refresh(){ rebuildMaps(); renderCanvas(); renderDetail(); updatePreview(); save(); }
|
||
function esc(s){ return String(s??'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"') }
|
||
// 입력값의 \n \t → 실제 문자 / 실제 문자 → 표시용 이스케이프
|
||
function unesc(s){ return s.replace(/\\n/g,'\n').replace(/\\t/g,'\t').replace(/\\r/g,'\r') }
|
||
function reesc(s){ return s.replace(/\r/g,'\\r').replace(/\n/g,'\\n').replace(/\t/g,'\\t') }
|
||
|
||
// ════════════════════════════════════════════════════════
|
||
// 캔버스 레이아웃
|
||
// ════════════════════════════════════════════════════════
|
||
let selectedNodeId = null;
|
||
const C_W=164, C_H=54, C_COL=252, C_ROW=72, C_PAD=28;
|
||
|
||
function subtreeH(node){
|
||
const kids=node.values.filter(v=>v.type==='node').map(v=>v.node);
|
||
if(!kids.length) return 1;
|
||
return kids.reduce((s,c)=>s+subtreeH(c),0);
|
||
}
|
||
|
||
function computeLayout(){
|
||
const pos=new Map();
|
||
function place(node,col,ySlot){
|
||
const kids=node.values.filter(v=>v.type==='node').map(v=>v.node);
|
||
const h=subtreeH(node);
|
||
pos.set(node.id,{x:C_PAD+col*C_COL, y:C_PAD+C_ROW*(ySlot+h/2)-C_H/2});
|
||
let y=ySlot;
|
||
for(const kid of kids){ place(kid,col+1,y); y+=subtreeH(kid); }
|
||
}
|
||
let y=0;
|
||
for(const node of S.nodes){ place(node,0,y); y+=subtreeH(node); }
|
||
return pos;
|
||
}
|
||
|
||
function renderCanvas(){
|
||
rebuildMaps();
|
||
document.getElementById('rootMark').value=S.titleMark;
|
||
document.getElementById('docTitle').value=S.title;
|
||
|
||
if(S.nodes.length===0){
|
||
document.getElementById('canvasNodes').innerHTML=
|
||
`<div class="canvas-empty"><div style="font-size:38px">🌱</div><div style="margin-top:8px;font-size:13px;font-weight:500">노드가 없습니다</div><div style="font-size:11px;margin-top:4px;color:var(--muted)">위의 + 노드 추가 버튼으로 시작하세요</div></div>`;
|
||
document.getElementById('canvasSvg').innerHTML='';
|
||
return;
|
||
}
|
||
|
||
const pos=computeLayout();
|
||
const wrap=document.getElementById('canvasWrap');
|
||
const inner=document.getElementById('canvasInner');
|
||
let maxX=wrap.clientWidth||600, maxY=wrap.clientHeight||400;
|
||
pos.forEach(({x,y})=>{ maxX=Math.max(maxX,x+C_W+C_PAD); maxY=Math.max(maxY,y+C_H+C_PAD); });
|
||
inner.style.width=maxX+'px'; inner.style.height=maxY+'px';
|
||
|
||
// SVG 연결선
|
||
let paths='';
|
||
function drawLines(node){
|
||
const p=pos.get(node.id); if(!p) return;
|
||
const px=p.x+C_W, py=p.y+C_H/2;
|
||
const kids=node.values.filter(v=>v.type==='node').map(v=>v.node);
|
||
for(const kid of kids){
|
||
const c=pos.get(kid.id); if(!c) continue;
|
||
const cx=c.x, cy=c.y+C_H/2;
|
||
const mx=(px+cx)/2;
|
||
const sel=(node.id===selectedNodeId||kid.id===selectedNodeId);
|
||
paths+=`<path d="M${px},${py} C${mx},${py} ${mx},${cy} ${cx},${cy}" fill="none" stroke="${sel?'#4f6ef7':'#c5cae0'}" stroke-width="${sel?2:1.5}"/>`;
|
||
drawLines(kid);
|
||
}
|
||
}
|
||
for(const node of S.nodes) drawLines(node);
|
||
const svg=document.getElementById('canvasSvg');
|
||
svg.style.width=maxX+'px'; svg.style.height=maxY+'px';
|
||
svg.setAttribute('viewBox',`0 0 ${maxX} ${maxY}`);
|
||
svg.innerHTML=paths;
|
||
|
||
// 노드 카드
|
||
let cards='';
|
||
function renderCNode(node){
|
||
const p=pos.get(node.id); if(!p) return;
|
||
const sel=node.id===selectedNodeId;
|
||
const col=node.settings?.attributes?.collect==='true';
|
||
const pl=node.settings?.attributes?.per_line;
|
||
const vals=node.values.filter(v=>v.type!=='node').length;
|
||
const kids=node.values.filter(v=>v.type==='node').length;
|
||
const hint=[vals?`값 ${vals}`:'',kids?`하위 ${kids}`:''].filter(Boolean).join(' · ')||'비어있음';
|
||
cards+=`<div class="cnode${sel?' selected':''}${col?' collect':''}" style="left:${p.x}px;top:${p.y}px" onclick="selectNode('${node.id}')">
|
||
<div class="cnode-head">
|
||
<span class="cnode-mark">${esc(node.mark||'·')}</span>
|
||
<span class="cnode-name" title="${esc(node.content||'')}">${esc(node.content||'이름 없음')}</span>
|
||
${col?'<span style="font-size:9px;color:var(--accent);font-weight:700;flex-shrink:0;line-height:1">📥</span>':''}
|
||
</div>
|
||
<div class="cnode-foot">
|
||
<span class="cnode-vals">${hint}</span>
|
||
${pl?`<span style="font-size:9px;color:var(--muted);background:var(--surface-alt);border:1px solid var(--border);border-radius:3px;padding:1px 4px;flex-shrink:0">×${esc(pl)}</span>`:''}
|
||
<div class="cnode-btns">
|
||
<button class="cnode-btn" title="위로" onclick="event.stopPropagation();moveNode('${node.id}',-1)">↑</button>
|
||
<button class="cnode-btn" title="아래로" onclick="event.stopPropagation();moveNode('${node.id}',1)">↓</button>
|
||
<button class="cnode-btn" title="하위 노드 추가" onclick="event.stopPropagation();addChildNode('${node.id}')">+</button>
|
||
<button class="cnode-btn del" title="삭제" onclick="event.stopPropagation();deleteFromCanvas('${node.id}')">✕</button>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
for(const v of node.values) if(v.type==='node') renderCNode(v.node);
|
||
}
|
||
for(const node of S.nodes) renderCNode(node);
|
||
document.getElementById('canvasNodes').innerHTML=cards;
|
||
}
|
||
|
||
function selectNode(nid){
|
||
selectedNodeId=nid;
|
||
renderCanvas();
|
||
renderDetail();
|
||
}
|
||
|
||
function addChildNode(parentNid){
|
||
const parent=nodeMap[parentNid]; if(!parent) return;
|
||
const child=mkNode();
|
||
parent.values.push(mkNodeVal(child));
|
||
selectedNodeId=child.id;
|
||
refresh();
|
||
}
|
||
|
||
function deleteFromCanvas(nid){
|
||
if(nid===selectedNodeId) selectedNodeId=null;
|
||
deleteNode(nid);
|
||
}
|
||
|
||
function renderDetail(){
|
||
const panel=document.getElementById('detailPanel'); if(!panel) return;
|
||
if(!selectedNodeId||!nodeMap[selectedNodeId]){
|
||
panel.innerHTML='<div class="detail-empty">← 노드를 클릭하면<br>상세 편집이 여기에 표시됩니다</div>';
|
||
return;
|
||
}
|
||
const node=nodeMap[selectedNodeId];
|
||
const valItems=node.values.map(v=>renderValue(node.id,v)).join('');
|
||
const typeOpts=VALUE_TYPES.map(t=>`<option value="${t.v}">${t.lbl}</option>`).join('');
|
||
const availSet=SETTING_OPTIONS.filter(o=>!(o.v in node.settings));
|
||
const setOpts=availSet.map(o=>`<option value="${o.v}">${o.lbl}</option>`).join('');
|
||
const settingItems=Object.entries(node.settings).map(([k,v])=>renderSetting(node.id,k,v)).join('');
|
||
panel.innerHTML=`
|
||
<div class="detail-hdr-lbl">📌 노드 편집</div>
|
||
<div class="detail-node-head">
|
||
<input class="mark-inp" title="구분자" value="${esc(node.mark??'')}"
|
||
oninput="nodeMap['${node.id}'].mark=this.value;updatePreview();save();renderCanvas()">
|
||
<input class="name-inp" placeholder="노드 이름" value="${esc(node.content??'')}"
|
||
oninput="nodeMap['${node.id}'].content=this.value;updatePreview();save();renderCanvas()">
|
||
<button class="iBtn eye${node.hidden?' off':''}" title="숨기기 토글" onclick="toggleHidden('${node.id}')">👁</button>
|
||
<button class="iBtn mv" onclick="moveNode('${node.id}',-1)" title="위로">↑</button>
|
||
<button class="iBtn mv" onclick="moveNode('${node.id}',1)" title="아래로">↓</button>
|
||
<button class="iBtn" onclick="deleteFromCanvas('${node.id}')" title="삭제">✕</button>
|
||
</div>
|
||
<div class="detail-body">
|
||
<div class="body-sec">
|
||
<div class="body-sec-hdr">
|
||
<span class="body-sec-title">값</span>
|
||
<select class="add-sel" onchange="addValue('${node.id}',this.value);this.selectedIndex=0">
|
||
<option value="">+ 추가</option>${typeOpts}
|
||
</select>
|
||
</div>
|
||
${valItems?`<div class="body-sec-body">${valItems}</div>`:'<div class="empty-hint">값 없음 — 위에서 추가</div>'}
|
||
</div>
|
||
<div class="body-sec">
|
||
<div class="body-sec-hdr">
|
||
<span class="body-sec-title">노드 설정</span>
|
||
${availSet.length?`<select class="add-sel" onchange="addSetting('${node.id}',this.value);this.selectedIndex=0">
|
||
<option value="">+ 옵션 추가</option>${setOpts}</select>`:''}
|
||
</div>
|
||
<div class="body-sec-body">${renderStructSettings(node)}${renderPerLineSetting(node)}${settingItems}</div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function renderValue(nid, val){
|
||
const mvDel=`
|
||
<button class="iBtn mv" onclick="moveValue('${val.id}',-1)" title="위로">↑</button>
|
||
<button class="iBtn mv" onclick="moveValue('${val.id}',1)" title="아래로">↓</button>
|
||
<button class="iBtn" onclick="removeValue('${val.id}')" title="삭제">✕</button>`;
|
||
|
||
if(val.type==='node'){
|
||
const cn=val.node;
|
||
return `<div class="val-item" data-vid="${val.id}">
|
||
<div class="val-head">
|
||
<span class="val-type-badge">NODE</span>
|
||
<span style="flex:1;font-size:12px;padding:0 5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc((cn.mark||'')+' '+(cn.content||'이름 없음'))}</span>
|
||
<button class="iBtn" title="이 노드 선택" onclick="event.stopPropagation();selectNode('${cn.id}')">→</button>
|
||
<button class="iBtn mv" onclick="event.stopPropagation();moveValue('${val.id}',-1)" title="위로">↑</button>
|
||
<button class="iBtn mv" onclick="event.stopPropagation();moveValue('${val.id}',1)" title="아래로">↓</button>
|
||
<button class="iBtn" onclick="event.stopPropagation();removeValue('${val.id}')" title="삭제">✕</button>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
const typeLbl=VALUE_TYPES.find(t=>t.v===val.type)?.lbl??val.type.toUpperCase();
|
||
return `
|
||
<div class="val-item" data-vid="${val.id}">
|
||
<div class="val-head">
|
||
<span class="val-type-badge">${val.type.toUpperCase()}</span>
|
||
<span style="flex:1"></span>
|
||
${mvDel}
|
||
</div>
|
||
<div class="val-body">
|
||
<input class="val-data-inp" placeholder="${esc(typeLbl)} 입력"
|
||
value="${esc(val.data??'')}"
|
||
oninput="valueMap['${val.id}'].val.data=this.value;updatePreview();save()">
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function setNameNL(nid, val){
|
||
const n=nodeMap[nid]; if(!n) return;
|
||
n.nameNewline=val; refresh();
|
||
}
|
||
|
||
function setCollect(nid, on){
|
||
const n=nodeMap[nid]; if(!n) return;
|
||
if(on){
|
||
if(!n.settings.attributes) n.settings.attributes={};
|
||
n.settings.attributes.collect='true';
|
||
} else {
|
||
if(n.settings.attributes){ delete n.settings.attributes.collect; }
|
||
if(n.settings.attributes && !Object.keys(n.settings.attributes).length) delete n.settings.attributes;
|
||
}
|
||
refresh();
|
||
}
|
||
|
||
function setPerLine(nid, val){
|
||
const n=nodeMap[nid]; if(!n) return;
|
||
const v=parseInt(val);
|
||
if(!n.settings.attributes) n.settings.attributes={};
|
||
if(v>0) n.settings.attributes.per_line=String(v);
|
||
else {
|
||
delete n.settings.attributes.per_line;
|
||
if(!Object.keys(n.settings.attributes).length) delete n.settings.attributes;
|
||
}
|
||
renderCanvas(); updatePreview(); save();
|
||
}
|
||
|
||
function renderStructSettings(node){
|
||
const nid=node.id, nl=node.nameNewline??true;
|
||
const col=node.settings?.attributes?.collect==='true';
|
||
return `
|
||
<div class="struct-sep">
|
||
<div class="sep-bar-row">
|
||
<span class="sep-bar-lbl">이름 뒤</span>
|
||
<span class="sep-chip${nl?' on':''}" onclick="setNameNL('${nid}',true)">줄바꿈</span>
|
||
<span class="sep-chip${!nl?' on':''}" onclick="setNameNL('${nid}',false)">없음</span>
|
||
</div>
|
||
<div class="sep-bar-row">
|
||
<span class="sep-bar-lbl">역할</span>
|
||
<span class="sep-chip${!col?' on':''}" onclick="setCollect('${nid}',false)" title="값이 항상 고정 출력됩니다">🔒 고정</span>
|
||
<span class="sep-chip${col?' on':''}" onclick="setCollect('${nid}',true)" title="합치기 시 값이 수집됩니다">📥 수집</span>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function renderPerLineSetting(node){
|
||
const nid=node.id;
|
||
const pl=node.settings?.attributes?.per_line||'';
|
||
return `
|
||
<div class="perline-block">
|
||
<span class="perline-lbl">한 줄에</span>
|
||
<input class="perline-inp" type="number" min="1" max="999"
|
||
value="${esc(pl)}" placeholder="∞"
|
||
oninput="setPerLine('${nid}',this.value)">
|
||
<span class="perline-unit">개씩 출력</span>
|
||
</div>`;
|
||
}
|
||
|
||
function renderSetting(nid, key, val){
|
||
if(key==='attributes'){
|
||
const attrs=val??{};
|
||
const usedKeys=Object.keys(attrs);
|
||
const availKeys=ATTR_KEYS.filter(o=>o.v==='_custom'||!usedKeys.includes(o.v));
|
||
const attrKeyOpts=availKeys.map(o=>`<option value="${esc(o.v)}">${esc(o.lbl)}</option>`).join('');
|
||
const rows=Object.entries(attrs).map(([k,v])=>{
|
||
const def=ATTR_KEYS.find(o=>o.v===k);
|
||
const lbl=def?def.lbl:k;
|
||
return `
|
||
<div class="attr-row" data-attrnode="${nid}" data-attrkey="${esc(k)}">
|
||
<span class="attr-key-lbl" title="${esc(k)}">${esc(lbl)}</span>
|
||
<input class="attr-val" placeholder="값" value="${esc(String(v??''))}"
|
||
oninput="setAttrVal('${nid}','${esc(k)}',this.value)">
|
||
<button class="iBtn" onclick="removeAttr('${nid}','${esc(k)}')">✕</button>
|
||
</div>`;
|
||
}).join('');
|
||
return `
|
||
<div>
|
||
<div class="attrs-hdr">
|
||
<span class="attrs-lbl">어트리뷰트</span>
|
||
<select class="add-sel" onchange="handleAddAttr('${nid}',this.value);this.selectedIndex=0">
|
||
<option value="">+ 추가</option>
|
||
${attrKeyOpts}
|
||
</select>
|
||
<button class="iBtn" onclick="removeSetting('${nid}','attributes')">✕</button>
|
||
</div>
|
||
${rows||'<div class="empty-hint" style="padding:2px 0">없음</div>'}
|
||
</div>`;
|
||
}
|
||
return `
|
||
<div class="setting-row">
|
||
<span class="setting-lbl">${key}</span>
|
||
<input class="setting-inp" placeholder="\\n \\t 사용 가능" value="${esc(reesc(String(val??'')))}"
|
||
oninput="setSettingVal('${nid}','${key}',unesc(this.value))">
|
||
<button class="iBtn" onclick="removeSetting('${nid}','${key}')">✕</button>
|
||
</div>`;
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════
|
||
// 텍스트 변환
|
||
// ════════════════════════════════════════════════════════
|
||
function parseNames(raw){ return (raw??'').trim().split(/[\s,\n]+/).filter(Boolean) }
|
||
|
||
function toText(){
|
||
function rNode(node){
|
||
const pre=node.settings.prefix??'';
|
||
const suf=node.settings.suffix??'';
|
||
const nl=(node.nameNewline??true)?'\n':'';
|
||
let t=pre+`${node.mark??''}${node.content||''}`;
|
||
let first=true;
|
||
for(const v of node.values){
|
||
if(v.type==='node'){
|
||
t+=rNode(v.node); first=false;
|
||
} else if(v.data!==undefined&&v.data!==null&&String(v.data).length){
|
||
t+=(first?nl:'\n')+String(v.data); first=false;
|
||
}
|
||
}
|
||
t+=suf;
|
||
return t;
|
||
}
|
||
const title=S.title.trim();
|
||
let out=title?`${S.titleMark}${title}\n`:'';
|
||
out+=S.nodes.map(rNode).join('');
|
||
return out.replace(/\n{3,}/g,'\n\n').trimEnd()+'\n';
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════
|
||
// JSON 변환
|
||
// ════════════════════════════════════════════════════════
|
||
function stripNulls(obj){
|
||
if(Array.isArray(obj)) return obj.map(stripNulls);
|
||
if(obj&&typeof obj==='object'){
|
||
const r={};
|
||
for(const [k,v] of Object.entries(obj)){
|
||
if(v===null) continue;
|
||
if(k==='data_type'&&v==='unset') continue;
|
||
if(k==='attributes'&&typeof v==='object'&&!Object.keys(v).length) continue;
|
||
r[k]=stripNulls(v);
|
||
}
|
||
return r;
|
||
}
|
||
return obj;
|
||
}
|
||
|
||
function buildNode(o){
|
||
return {content:null,prefix:null,suffix:null,
|
||
attributes:{},data_type:'unset',value:null,children:[],...o};
|
||
}
|
||
|
||
function toJSON(slim=false){
|
||
let depth=0;
|
||
function serNode(node){
|
||
depth++;
|
||
const children=node.values.map(v=>{
|
||
if(v.type==='node') return serNode(v.node);
|
||
return buildNode({id:v.id,type:'VALUE',data_type:v.type,value:v.data??null});
|
||
});
|
||
const attrs={mark:node.mark,level:depth,...(node.settings.attributes??{})};
|
||
const r=buildNode({
|
||
id:node.id,type:'SECTION',content:node.content,
|
||
prefix:node.settings.prefix??null,
|
||
suffix:node.settings.suffix??null,
|
||
attributes:attrs,children,
|
||
nameNewline:node.nameNewline??true,
|
||
});
|
||
depth--;
|
||
return r;
|
||
}
|
||
const root=buildNode({
|
||
id:'root',type:'ROOT',
|
||
attributes:S.title.trim()?{title:S.title.trim(),mark:S.titleMark}:{mark:S.titleMark},
|
||
children:S.nodes.map(serNode)
|
||
});
|
||
return JSON.stringify(slim?stripNulls(root):root,null,2);
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════
|
||
// JSON 구문 강조
|
||
// ════════════════════════════════════════════════════════
|
||
function highlight(s){
|
||
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')
|
||
.replace(/("(?:[^"\\]|\\.)*")(\s*:)/g,'<span class="j-key">$1</span>$2')
|
||
.replace(/:\s*("(?:[^"\\]|\\.)*")/g,': <span class="j-str">$1</span>')
|
||
.replace(/:\s*(-?\d+\.?\d*)/g,': <span class="j-num">$1</span>')
|
||
.replace(/:\s*(true|false)/g,': <span class="j-bool">$1</span>')
|
||
.replace(/:\s*(null)/g,': <span class="j-null">$1</span>');
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════
|
||
// 미리보기
|
||
// ════════════════════════════════════════════════════════
|
||
function setTab(t){
|
||
currentTab=t;
|
||
document.querySelectorAll('.tab').forEach(el=>el.classList.remove('active'));
|
||
document.getElementById('tab-'+t).classList.add('active');
|
||
updatePreview();
|
||
}
|
||
function updatePreview(){
|
||
const el=document.getElementById('previewOut');
|
||
if(currentTab==='text'){
|
||
const c=toText();
|
||
if(!c.trim()){ el.className='preview-text dim'; el.textContent='내용을 입력하면 미리보기가 표시됩니다'; return; }
|
||
el.className='preview-text'; el.textContent=c;
|
||
} else {
|
||
el.className='preview-text'; el.innerHTML=highlight(toJSON(currentTab==='json'));
|
||
}
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════
|
||
// 복사
|
||
// ════════════════════════════════════════════════════════
|
||
function copyOutput(){
|
||
const c=currentTab==='text'?toText():toJSON(currentTab==='json');
|
||
navigator.clipboard.writeText(c).then(()=>{
|
||
const btn=document.getElementById('copyBtn');
|
||
btn.textContent='✅ 복사됨'; btn.classList.add('ok');
|
||
setTimeout(()=>{ btn.innerHTML='📋 복사'; btn.classList.remove('ok'); },2000);
|
||
});
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════
|
||
// 불러오기
|
||
// ════════════════════════════════════════════════════════
|
||
let importTab='text';
|
||
|
||
function switchImportTab(tab){
|
||
importTab=tab;
|
||
document.getElementById('import-text-body').style.display=tab==='text'?'':'none';
|
||
document.getElementById('import-json-body').style.display=tab==='json'?'':'none';
|
||
document.querySelectorAll('.itab').forEach(el=>el.classList.remove('active'));
|
||
document.getElementById('itab-'+tab).classList.add('active');
|
||
}
|
||
|
||
function openImport(){
|
||
document.getElementById('importTitleMark').value=S.titleMark;
|
||
const marks=[]; function col(nodes){ for(const n of nodes){ if(n.mark&&!marks.includes(n.mark)) marks.push(n.mark); for(const v of n.values) if(v.type==='node') col([v.node]); } }
|
||
col(S.nodes);
|
||
document.getElementById('importMarks').value=marks.join(',')||'🔹,🔸';
|
||
document.getElementById('importModal').classList.add('open');
|
||
}
|
||
function closeImport(){ document.getElementById('importModal').classList.remove('open') }
|
||
|
||
function doImportActive(){
|
||
if(importTab==='json') doImportJson(); else doImport();
|
||
}
|
||
|
||
// ── JSON 파서 ──────────────────────────────────────────
|
||
function doImportJson(){
|
||
const raw=document.getElementById('importJsonTxt').value.trim();
|
||
if(!raw) return;
|
||
let obj;
|
||
try{ obj=JSON.parse(raw); }
|
||
catch(e){ alert('JSON 파싱 오류:\n'+e.message); return; }
|
||
|
||
if(obj.type!=='ROOT'){ alert('최상위 타입이 ROOT가 아닙니다.'); return; }
|
||
|
||
S.titleMark = obj.attributes?.mark ?? '✅';
|
||
S.title = obj.attributes?.title ?? '';
|
||
S.nodes = (obj.children??[]).filter(c=>c.type==='SECTION').map(parseJsonSection);
|
||
|
||
refresh(); closeImport();
|
||
document.getElementById('importJsonTxt').value='';
|
||
}
|
||
|
||
function parseJsonSection(sec){
|
||
const mark=sec.attributes?.mark??'';
|
||
const n=mkNode(mark, sec.content??'');
|
||
if(sec.nameNewline!==undefined) n.nameNewline=sec.nameNewline;
|
||
if(sec.prefix) n.settings.prefix=sec.prefix;
|
||
if(sec.suffix) n.settings.suffix=sec.suffix;
|
||
const filtAttrs=Object.entries(sec.attributes??{}).filter(([k])=>k!=='mark'&&k!=='level');
|
||
if(filtAttrs.length) n.settings.attributes=Object.fromEntries(filtAttrs);
|
||
for(const child of sec.children??[]){
|
||
if(child.type==='SECTION'){
|
||
n.values.push(mkNodeVal(parseJsonSection(child)));
|
||
} else if(child.type==='VALUE'){
|
||
const sv=mkScalarVal(child.data_type??'string');
|
||
sv.data=String(child.value??'');
|
||
n.values.push(sv);
|
||
} else if(child.type==='LIST'){
|
||
// 구버전 호환: LIST → string value
|
||
const names=(child.children??[]).filter(c=>c.type==='LIST_ITEM').map(c=>c.content??'').join(' ');
|
||
const sv=mkScalarVal('string'); sv.data=names; n.values.push(sv);
|
||
}
|
||
}
|
||
return n;
|
||
}
|
||
|
||
function doImport(){
|
||
const raw=document.getElementById('importTxt').value.trim(); if(!raw) return;
|
||
const titleMark=document.getElementById('importTitleMark').value||'✅';
|
||
const marks=document.getElementById('importMarks').value.split(',').map(m=>m.trim()).filter(Boolean);
|
||
const sorted=marks.map((m,i)=>({m,lv:i+1})).sort((a,b)=>b.m.length-a.m.length);
|
||
const reCat=/^\(([^)]+)\)\s*(.*)/;
|
||
let title=''; const nodes=[]; const stack=[]; let pend=null;
|
||
|
||
function flush(){
|
||
if(!pend||!pend.names.length){ pend=null; return; }
|
||
const parent=pend.parentId?findNodeInList(nodes,pend.parentId):null;
|
||
const sv=mkScalarVal('string'); sv.data=pend.names.join(' ');
|
||
if(parent) parent.values.push(sv); pend=null;
|
||
}
|
||
function findNodeInList(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=findNodeInList([v.node],id); if(f) return f; } } return null; }
|
||
function topId(){ return stack.length?stack[stack.length-1].id:null }
|
||
|
||
for(const rawLine of raw.split('\n')){
|
||
const line=rawLine.trim(); if(!line){ flush(); continue; }
|
||
if(line.startsWith(titleMark)){ flush(); title=line.slice(titleMark.length).trim(); continue; }
|
||
let matched=false;
|
||
for(const {m,lv} of sorted){
|
||
if(line.startsWith(m)){
|
||
flush();
|
||
const node=mkNode(m, line.slice(m.length).trim());
|
||
while(stack.length){
|
||
const top=findNodeInList(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=findNodeInList(nodes,stack[stack.length-1].id); if(p) p.values.push(mkNodeVal(node)); }
|
||
stack.push({id:node.id,lv}); matched=true; break;
|
||
}
|
||
}
|
||
if(matched) continue;
|
||
const cm=reCat.exec(line);
|
||
if(cm){ flush(); pend={parentId:topId(),cat:cm[1].trim(),names:[]}; const rest=cm[2].trim(); if(rest) pend.names.push(...rest.split(/[\s,]+/).filter(Boolean)); continue; }
|
||
if(!pend) pend={parentId:topId(),cat:'',names:[]};
|
||
pend.names.push(...line.split(/[\s,]+/).filter(Boolean));
|
||
}
|
||
flush();
|
||
S.titleMark=titleMark; S.title=title; S.nodes=nodes;
|
||
refresh(); closeImport(); document.getElementById('importTxt').value='';
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════
|
||
// 새 문서
|
||
// ════════════════════════════════════════════════════════
|
||
function newDoc(){
|
||
if(!confirm('현재 내용을 모두 지우고 새 문서를 시작할까요?')) return;
|
||
S={titleMark:'✅',title:'',nodes:[]}; refresh();
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════
|
||
// Tab 2: 양식 합치기 (텍스트 붙여넣기 방식)
|
||
// ════════════════════════════════════════════════════════
|
||
let FT = null; // filler template (parsed JSON root)
|
||
let fillerTab = 'text';
|
||
let fillerMerged = {}; // sectionKey → string[]
|
||
let fillerInstCount = 0;
|
||
|
||
function openFillerImport(){ document.getElementById('fillerImportModal').classList.add('open') }
|
||
function closeFillerImport(){ document.getElementById('fillerImportModal').classList.remove('open') }
|
||
|
||
function doLoadFillerTemplate(){
|
||
const raw=document.getElementById('fillerImportTxt').value.trim(); if(!raw) return;
|
||
let obj;
|
||
try{ obj=JSON.parse(raw); }catch(e){ alert('JSON 파싱 오류:\n'+e.message); return; }
|
||
if(obj.type!=='ROOT'){ alert('최상위 타입이 ROOT가 아닙니다.'); return; }
|
||
FT=obj;
|
||
closeFillerImport();
|
||
document.getElementById('fillerImportTxt').value='';
|
||
const mark=FT.attributes?.mark??'';
|
||
const title=FT.attributes?.title??'(제목 없음)';
|
||
document.getElementById('fillerTitle').textContent=(mark?mark+' ':'')+title;
|
||
renderFillerTemplateView();
|
||
parseAndMerge();
|
||
}
|
||
|
||
function clearFiller(){
|
||
if(!confirm('템플릿과 입력 내용을 모두 지울까요?')) return;
|
||
FT=null; fillerMerged={}; fillerInstCount=0;
|
||
document.getElementById('fillerTitle').textContent='템플릿 없음';
|
||
document.getElementById('fillerInputText').value='';
|
||
document.getElementById('instCount').textContent='';
|
||
renderFillerTemplateView();
|
||
updateFillerPreview();
|
||
}
|
||
|
||
// ── 수집 섹션 키 추출 ──────────────────────────────────
|
||
function getCollectKeys(root){
|
||
const keys=new Set();
|
||
function walk(n){ for(const c of n.children??[]){ if(c.type==='SECTION'){ if(c.attributes?.collect==='true'){ keys.add((c.attributes?.mark??'')+(c.content??'')); } walk(c); } } }
|
||
walk(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');
|
||
if(!FT){ el.innerHTML='<span class="ftpl-empty">← 템플릿을 먼저 불러오세요</span>'; return; }
|
||
const selective=getCollectKeys(FT).size>0;
|
||
function walkSec(sec, depth){
|
||
const m=sec.attributes?.mark??'';
|
||
const n=sec.content??'';
|
||
const pl=sec.attributes?.per_line;
|
||
const isCol=sec.attributes?.collect==='true';
|
||
const roleHtml=selective?`<span class="ftpl-role ${isCol?'collect':'fixed'}">${isCol?'📥수집':'🔒고정'}</span>`:'';
|
||
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??'';
|
||
const tt=FT.attributes?.title??'';
|
||
let html=tt?`<span class="ftpl-title"><span class="ftpl-mark">${esc(tm)}</span><span class="ftpl-name">${esc(tt)}</span></span>`:'';
|
||
for(const c of FT.children??[]) if(c.type==='SECTION') html+=walkSec(c,0);
|
||
el.innerHTML=html;
|
||
}
|
||
|
||
// ── 파싱 & 합치기 ──────────────────────────────────────
|
||
function extractSectionMarks(node){
|
||
const marks=new Set();
|
||
function walk(n){ for(const c of n.children??[]){ if(c.type==='SECTION'){ const m=c.attributes?.mark??''; if(m) marks.add(m); walk(c); } } }
|
||
walk(node);
|
||
return [...marks];
|
||
}
|
||
|
||
function splitIntoInstances(rawText, titleMark){
|
||
if(!titleMark) return rawText.split(/\n{3,}/).map(s=>s.trim()).filter(Boolean);
|
||
const lines=rawText.split('\n');
|
||
const instances=[]; let current=[];
|
||
for(const line of lines){
|
||
if(line.trim().startsWith(titleMark)&¤t.some(l=>l.trim())){
|
||
instances.push(current.join('\n')); current=[];
|
||
}
|
||
current.push(line);
|
||
}
|
||
if(current.some(l=>l.trim())) instances.push(current.join('\n'));
|
||
return instances;
|
||
}
|
||
|
||
function parseInstance(text, titleMark, sectionMarks, schemaMap){
|
||
const sorted=sectionMarks.slice().sort((a,b)=>b.length-a.length);
|
||
const result={sections:{}};
|
||
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;
|
||
let matched=false;
|
||
for(const mark of sorted){
|
||
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;
|
||
}
|
||
|
||
function parseAndMerge(){
|
||
const rawText=document.getElementById('fillerInputText').value;
|
||
const instEl=document.getElementById('instCount');
|
||
if(!FT||!rawText.trim()){
|
||
fillerMerged={}; fillerInstCount=0;
|
||
instEl.textContent=''; updateFillerPreview(); return;
|
||
}
|
||
const titleMark=FT.attributes?.mark??'';
|
||
const sectionMarks=extractSectionMarks(FT);
|
||
const instances=splitIntoInstances(rawText,titleMark);
|
||
fillerInstCount=instances.length;
|
||
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,schemaMap);
|
||
for(const [key,entries] of Object.entries(parsed.sections)){
|
||
if(selective&&!collectKeys.has(key)) continue;
|
||
if(!fillerMerged[key]) fillerMerged[key]=[];
|
||
fillerMerged[key].push(...entries);
|
||
}
|
||
}
|
||
instEl.textContent=instances.length?`(${instances.length}개 양식)`:'';
|
||
updateFillerPreview();
|
||
}
|
||
|
||
// ── 텍스트 출력 ────────────────────────────────────────
|
||
function mergedToText(){
|
||
if(!FT) return '';
|
||
const collectKeys=getCollectKeys(FT);
|
||
const selective=collectKeys.size>0;
|
||
function walkSec(sec){
|
||
const mark=sec.attributes?.mark??'';
|
||
const content=sec.content??'';
|
||
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 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 {
|
||
for(const c of sec.children??[]){
|
||
if(c.type==='VALUE'&&c.value!=null&&String(c.value).trim()) t+=String(c.value)+'\n';
|
||
}
|
||
}
|
||
// 스키마 자식은 출력에서 제외, 나머지 섹션만 재귀
|
||
if(!hasSchema) for(const c of sec.children??[]) if(c.type==='SECTION') t+=walkSec(c);
|
||
return t;
|
||
}
|
||
const titleMark=FT.attributes?.mark??'';
|
||
const title=FT.attributes?.title??'';
|
||
let out=title?`${titleMark}${title}\n`:'';
|
||
for(const c of FT.children??[]) if(c.type==='SECTION') out+=walkSec(c);
|
||
return out.replace(/\n{3,}/g,'\n\n').trimEnd()+'\n';
|
||
}
|
||
|
||
// ── JSON 출력 ───────────────────────────────────────────
|
||
function mergedToJSON(){
|
||
if(!FT) return '{}';
|
||
const collectKeys=getCollectKeys(FT);
|
||
const selective=collectKeys.size>0;
|
||
function buildSec(sec){
|
||
const mark=sec.attributes?.mark??'';
|
||
const content=sec.content??'';
|
||
const key=mark+content;
|
||
const isCol=selective?(sec.attributes?.collect==='true'):true;
|
||
const schemaSecs=(sec.children??[]).filter(c=>c.type==='SECTION'&&c.attributes?.mark);
|
||
const hasSchema=isCol&&schemaSecs.length>0;
|
||
let newChildren;
|
||
if(isCol){
|
||
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 {
|
||
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);
|
||
}
|
||
|
||
// ── 미리보기 & 공통 ────────────────────────────────────
|
||
function updateFillerPreview(){
|
||
const el=document.getElementById('fillerOut');
|
||
if(!FT||!fillerInstCount){
|
||
el.className='preview-text dim';
|
||
el.textContent='양식을 붙여넣으면 합치기 결과가 표시됩니다';
|
||
return;
|
||
}
|
||
if(fillerTab==='text'){
|
||
el.className='preview-text'; el.textContent=mergedToText();
|
||
} else {
|
||
el.className='preview-text'; el.innerHTML=highlight(mergedToJSON());
|
||
}
|
||
}
|
||
|
||
function setFillerTab(t){
|
||
fillerTab=t;
|
||
document.querySelectorAll('.ftab').forEach(el=>el.classList.remove('active'));
|
||
document.getElementById('ftab-'+t).classList.add('active');
|
||
updateFillerPreview();
|
||
}
|
||
|
||
function copyFillerOutput(){
|
||
const txt=fillerTab==='text'?mergedToText():mergedToJSON();
|
||
navigator.clipboard.writeText(txt).then(()=>{
|
||
const btn=document.getElementById('fCopyBtn');
|
||
btn.textContent='✅ 복사됨'; btn.classList.add('ok');
|
||
setTimeout(()=>{ btn.innerHTML='📋 복사'; btn.classList.remove('ok'); },2000);
|
||
});
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════
|
||
// 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', 'extractor'];
|
||
function switchTab(name){
|
||
TAB_IDS.forEach(id => {
|
||
document.getElementById('panel-'+id).classList.toggle('hidden', id !== name);
|
||
document.getElementById('mtab-'+id).classList.toggle('active', id === name);
|
||
});
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════
|
||
// 초기화
|
||
// ════════════════════════════════════════════════════════
|
||
load();
|
||
refresh();
|
||
document.getElementById('importModal').addEventListener('click',e=>{ if(e.target===e.currentTarget) closeImport(); });
|
||
document.getElementById('fillerImportModal').addEventListener('click',e=>{ if(e.target===e.currentTarget) closeFillerImport(); });
|
||
document.addEventListener('keydown',e=>{ if(e.key==='Escape'){ closeImport(); closeFillerImport(); } });
|
||
</script>
|
||
</body>
|
||
</html>
|