미디어위키:SemanticCuration.js

hnn
이동: 둘러보기, 검색

참고: 설정을 저장한 후에 바뀐 점을 확인하기 위해서는 브라우저의 캐시를 새로 고쳐야 합니다.

  • 파이어폭스 / 사파리: Shift 키를 누르면서 새로 고침을 클릭하거나, Ctrl-F5 또는 Ctrl-R 을 입력 (Mac에서는 ⌘-R)
  • 구글 크롬: Ctrl-Shift-R키를 입력 (Mac에서는 ⌘-Shift-R)
  • 인터넷 익스플로러: Ctrl 키를 누르면서 새로 고침을 클릭하거나, Ctrl-F5를 입력.
  • 오페라: 메뉴 → 설정(맥의 경우 오페라 → 환경 설정)으로 이동한 다음 개인 정보 보호 및 보안 → 검색 데이터 지우기 → 캐시한 이미지 및 파일를 누름.
/**
 * Semantic Curation Tools
 * Simple Ontology(.lst) 문서를 작성·수정하는 MediaWiki 편집 도구
 *
 * @name        Semantic Curation Tools
 * @description 이 프로그램은 AI 고전번역학 강의에서 학습자가 고전 자료 속
 *              인물·장소·사건·개념 사이의 관계를 온톨로지 형식으로 기술하고,
 *              이를 네트워크 그래프로 시각화하는 실습을 하기 위해 제작한
 *              시맨틱 데이터 편집 도구입니다.
 * @author      서소리
 * @version     1.0
 * @since       2026-10
 * @see         MediaWiki:SemanticCuration.js
 * @license     이 프로그램은 교육·연구 목적이라면 누구나 자유롭게 사용하고
 *              수정할 수 있습니다. 다만 사용하거나 재배포하실 때에는 제작자를
 *              밝혀 주시고, 이 머리말 주석을 지우지 말아 주시기 바랍니다.
 *              그 밖의 용도로 사용하시려면 제작자에게 문의해 주십시오.
 */
( function () {
	'use strict';

	var conf = mw.config.get( [ 'wgAction', 'wgPageName', 'wgPageContentModel' ] );
	if ( !/\.lst$/i.test( conf.wgPageName || '' ) ) { return; }
	if ( conf.wgAction !== 'edit' && conf.wgAction !== 'submit' ) { return; }
	if ( conf.wgPageContentModel && conf.wgPageContentModel !== 'wikitext' ) { return; }
	if ( window.SemanticCurationLoaded ) { return; }
	window.SemanticCurationLoaded = true;

	/* ====================================================================
	 * 설정
	 * ================================================================== */
	var CFG = {
		// ▼ 편집 화면 '시맨틱 큐레이션' 탭에 보일 버튼 (true: 보임 / false: 숨김)
		buttons: {
			newOntology: true,
			lstEditor: true,
			dbUpload: false
		},
		// ▲ 세 버튼을 모두 false로 하면 '시맨틱 큐레이션' 탭도 나타나지 않습니다.

		defaultClasses: [],
		defaultRelations: [],
		defaultLangs: [ 'English' ],
		summary: 'Semantic Curation Tools로 수정',
		// 도구 모음 '시맨틱 큐레이션' 탭
		toolbarLabel: '시맨틱 큐레이션',
		// 버튼 아이콘 (위키 서버 기준 경로). 이미지를 못 불러오면 글자만 표시됩니다.
		iconEditor: '/classics/wiki/icons/script.png',
		iconSkeleton: '/classics/wiki/icons/hashtag.png',
		iconUpload: '/classics/wiki/icons/database.png',
		// DB 업로드: 문서마다 {{SemanticDBUpload|server=…|db=…|project=…}} 틀로 정합니다.
		// 업로드 요청은 아래 업로드 서버(API)로 보냅니다.
		uploadTemplate: 'SemanticDBUpload',
		uploadApi: 'https://rnd.vaquitalab.com/lst/upload',
		// 새 문서(빈 편집창)일 때 들어갈 문서 모양. @@LST@@ 자리에 .lst 텍스트가 들어갑니다.
		newPage: [
			'<pre>',
			'@@LST@@',
			'</pre>',
			'',
			'== Visualize ==',
			'{{NetworkGraph|title={{PAGENAME}}}}',
			'',
			'[[분류:Simple Ontology]]'
		].join( '\n' )
	};
	// MediaWiki:Common.js에서 window.SemanticCurationConfig로 설정을 덮어쓸 수 있습니다.
	if ( window.SemanticCurationConfig ) { $.extend( true, CFG, window.SemanticCurationConfig ); }

	/* ====================================================================
	 * 파싱 / 출력
	 * ================================================================== */
	function blank() {
		return {
			project: '',
			classes: CFG.defaultClasses.slice(),
			relations: CFG.defaultRelations.slice(),
			nodes: [],
			links: [],
			langs: CFG.defaultLangs.slice(),
			tr: {},
			extra: []
		};
	}

	// 쉼표로 나누되, 큰따옴표로 감싼 값 안의 쉼표는 유지 ("" 는 따옴표 한 개)
	function splitCSV( line ) {
		var out = [], cur = '', q = false, i, c;
		for ( i = 0; i < line.length; i++ ) {
			c = line.charAt( i );
			if ( q ) {
				if ( c === '"' ) {
					if ( line.charAt( i + 1 ) === '"' ) { cur += '"'; i++; } else { q = false; }
				} else { cur += c; }
			} else if ( c === '"' && cur.trim() === '' ) {
				q = true; cur = '';
			} else if ( c === ',' ) {
				out.push( cur.trim() ); cur = '';
			} else { cur += c; }
		}
		out.push( cur.trim() );
		return out;
	}
	function un( v ) {
		v = ( v || '' ).trim();
		return /^null$/i.test( v ) ? '' : v;
	}
	function rest( f, from ) {
		return un( f.slice( from ).join( ', ' ) );
	}

	// 본문에서 #Project ~ #End 위치 찾기
	function extract( text ) {
		var mA = /^[ \t]*#Project[ \t]*$/m.exec( text );
		if ( !mA ) { return null; }
		var after = text.slice( mA.index );
		var mB = /^[ \t]*#End[ \t]*$/m.exec( after );
		var end = mB ? mA.index + mB.index + mB[ 0 ].length : text.length;
		return { start: mA.index, end: end, body: text.slice( mA.index, end ) };
	}

	function parse( body ) {
		var s = blank(), sec = null, extraSec = null;
		s.classes = []; s.relations = []; s.langs = [];
		body.replace( /\r/g, '' ).split( '\n' ).forEach( function ( raw ) {
			var line = raw.trim(), m, f, lang, val;
			if ( !line ) { return; }
			m = line.match( /^#(\w+)$/ );
			if ( m ) {
				sec = m[ 1 ].toLowerCase();
				if ( [ 'project', 'class', 'relation', 'nodes', 'links', 'translation', 'end' ].indexOf( sec ) < 0 ) {
					extraSec = { name: m[ 1 ], lines: [] };
					s.extra.push( extraSec );
					sec = 'extra';
				}
				return;
			}
			switch ( sec ) {
				case 'project':
					s.project = line.replace( /^"(.*)"$/, '$1' );
					break;
				case 'class':
					s.classes.push( normClass( line ) );
					break;
				case 'relation':
					s.relations.push( line );
					break;
				case 'nodes':
					f = splitCSV( line );
					s.nodes.push( {
						id: f[ 0 ] || '', cls: un( f[ 1 ] ), label: un( f[ 2 ] ),
						infoUrl: un( f[ 3 ] ), iconUrl: un( f[ 4 ] ), remark: rest( f, 5 )
					} );
					break;
				case 'links':
					f = splitCSV( line );
					s.links.push( {
						source: f[ 0 ] || '', target: f[ 1 ] || '', relation: un( f[ 2 ] ),
						attribute: un( f[ 3 ] ), remark: rest( f, 4 )
					} );
					break;
				case 'translation':
					f = splitCSV( line );
					m = ( f[ f.length - 1 ] || '' ).match( /^has(.+)Translation$/ );
					lang = m ? m[ 1 ] : 'Other';
					val = f.slice( 1, m ? f.length - 1 : f.length ).join( ', ' );
					if ( s.langs.indexOf( lang ) < 0 ) { s.langs.push( lang ); }
					( s.tr[ f[ 0 ] ] = s.tr[ f[ 0 ] ] || {} )[ lang ] = val;
					break;
				case 'extra':
					extraSec.lines.push( raw );
					break;
			}
		} );
		if ( !s.langs.length ) { s.langs = CFG.defaultLangs.slice(); }
		return s;
	}

	// #Class 한 줄 = 클래스 이름[, 색상[, 모양]]
	function clsName( line ) { return String( line || '' ).split( ',' )[ 0 ].trim(); }
	function clsNames( list ) { return list.map( clsName ); }
	var SHAPES = [ 'box', 'circle', 'ellipse', 'star', 'triangle', 'square', 'dot', 'text' ];
	function clsParts( line ) {
		var p = String( line || '' ).split( ',' ).map( function ( x ) { return x.trim(); } );
		return {
			name: ( p[ 0 ] || '' ).replace( /\s+/g, '' ),
			color: /^null$/i.test( p[ 1 ] || '' ) ? '' : ( p[ 1 ] || '' ),
			shape: /^null$/i.test( p[ 2 ] || '' ) ? '' : ( p[ 2 ] || '' )
		};
	}
	// 모양만 있고 색상이 없으면 색상 자리에 Null을 둔다
	function clsJoin( o ) {
		if ( o.shape ) { return o.name + ', ' + ( o.color || 'Null' ) + ', ' + o.shape; }
		if ( o.color ) { return o.name + ', ' + o.color; }
		return o.name;
	}
	function normClass( line ) { return clsJoin( clsParts( line ) ); }

	function fld( v ) {
		v = ( v || '' ).trim();
		if ( !v ) { return 'Null'; }
		// 쉼표·큰따옴표가 들어간 값은 큰따옴표로 감싼다
		return /[,"]/.test( v ) ? '"' + v.replace( /"/g, '""' ) + '"' : v;
	}

	/* 엑셀에서 복사한 탭 구분 텍스트 → 2차원 배열
	 * (엑셀은 탭·줄바꿈·큰따옴표가 든 셀을 "..."로 감싸고, 안의 " 는 "" 로 씁니다) */
	function parseTSV( text ) {
		var rows = [], row = [], cur = '', q = false, i, c;
		text = text.replace( /\r\n?/g, '\n' );
		for ( i = 0; i < text.length; i++ ) {
			c = text.charAt( i );
			if ( q ) {
				if ( c === '"' ) {
					if ( text.charAt( i + 1 ) === '"' ) { cur += '"'; i++; } else { q = false; }
				} else { cur += c; }
			} else if ( c === '"' && cur === '' ) {
				q = true;
			} else if ( c === '\t' ) {
				row.push( cur ); cur = '';
			} else if ( c === '\n' ) {
				row.push( cur ); rows.push( row ); row = []; cur = '';
			} else { cur += c; }
		}
		if ( cur !== '' || row.length ) { row.push( cur ); rows.push( row ); }
		return rows;
	}

	var IMPORT_FIELDS = {
		nodes: { fields: [ 'id', 'class', 'label', 'infoUrl', 'iconUrl', 'remark' ], required: [ 'id', 'class', 'label' ] },
		links: { fields: [ 'source', 'target', 'relation', 'attribute', 'remark' ], required: [ 'source', 'target', 'relation' ] }
	};

	// 머리글을 읽어 열 → 필드 대응을 만들고, 행을 객체로 바꾼다
	function readImport( kind, text ) {
		var def = IMPORT_FIELDS[ kind ], rows = parseTSV( text ), map = {}, ignored = [], missing, items = [];
		rows = rows.filter( function ( r ) { return r.some( function ( v ) { return v.trim(); } ); } );
		if ( !rows.length ) { return { error: '붙여넣은 내용이 없습니다.' }; }
		rows[ 0 ].forEach( function ( h, col ) {
			var key = h.trim().toLowerCase(), f = null;
			def.fields.forEach( function ( name ) { if ( name.toLowerCase() === key ) { f = name; } } );
			if ( f && !( f in map ) ) { map[ f ] = col; } else if ( h.trim() ) { ignored.push( h.trim() ); }
		} );
		missing = def.required.filter( function ( f ) { return !( f in map ); } );
		if ( missing.length ) {
			return { error: '첫 줄(머리글)에 필수 열이 없습니다: ' + missing.join( ', ' ) + '. 머리글을 포함해서 붙여넣으세요.' };
		}
		rows.slice( 1 ).forEach( function ( r ) {
			var o = {};
			def.fields.forEach( function ( f ) {
				var v = f in map ? ( r[ map[ f ] ] || '' ) : '';
				o[ f ] = un( v.replace( /\s*\n\s*/g, ' ' ) );
			} );
			items.push( kind === 'nodes' ?
				{ id: o.id, cls: o[ 'class' ], label: o.label, infoUrl: o.infoUrl, iconUrl: o.iconUrl, remark: o.remark } :
				{ source: o.source, target: o.target, relation: o.relation, attribute: o.attribute, remark: o.remark } );
		} );
		return {
			items: items,
			columns: def.fields.filter( function ( f ) { return f in map; } ),
			ignored: ignored
		};
	}

	function serialize( S ) {
		var L = [], ids, first = true;
		L.push( '#Project', '"' + S.project.replace( /"/g, '' ) + '"', '' );
		L.push( '#Class' ); S.classes.forEach( function ( c ) { L.push( c ); } ); L.push( '' );
		L.push( '#Relation' ); S.relations.forEach( function ( r ) { L.push( r ); } ); L.push( '' );
		L.push( '#Nodes' );
		S.nodes.forEach( function ( n ) {
			L.push( [ n.id, n.cls, n.label, n.infoUrl, n.iconUrl, n.remark ].map( fld ).join( ', ' ) );
		} );
		L.push( '' );
		L.push( '#Links' );
		S.links.forEach( function ( k ) {
			L.push( [ k.source, k.target, k.relation, k.attribute, k.remark ].map( fld ).join( ', ' ) );
		} );
		L.push( '' );
		L.push( '#Translation' );
		ids = [];
		S.nodes.map( function ( n ) { return n.id; } ).concat( Object.keys( S.tr ) ).forEach( function ( k ) {
			if ( ids.indexOf( k ) < 0 ) { ids.push( k ); }
		} );
		S.langs.forEach( function ( lang ) {
			var rows = [];
			ids.forEach( function ( id ) {
				var v = S.tr[ id ] && S.tr[ id ][ lang ];
				if ( id && v && v.trim() ) { rows.push( fld( id ) + ', ' + fld( v ) + ', has' + lang + 'Translation' ); }
			} );
			if ( !rows.length ) { return; }
			if ( !first ) { L.push( '' ); }
			first = false;
			L.push.apply( L, rows );
		} );
		L.push( '' );
		S.extra.forEach( function ( x ) {
			L.push( '#' + x.name );
			x.lines.forEach( function ( l ) { L.push( l ); } );
			L.push( '' );
		} );
		L.push( '#End' );
		return L.join( '\n' );
	}

	/* ====================================================================
	 * 검증  ( e: 오류, w: 경고, i: 안내 )
	 * ================================================================== */
	function validate( S ) {
		var out = [], ids = {}, seen = {}, used = {};
		if ( !S.project.trim() ) { out.push( [ 'w', '프로젝트 이름이 비어 있습니다.' ] ); }
		S.nodes.forEach( function ( n, i ) {
			var no = '노드 ' + ( i + 1 ) + ( n.id ? ' (' + n.id + ')' : '' );
			if ( !n.id.trim() ) { out.push( [ 'e', no + ': id가 비어 있습니다.' ] ); } else {
				if ( seen[ n.id ] ) { out.push( [ 'e', no + ': id가 중복됩니다.' ] ); }
				seen[ n.id ] = ( seen[ n.id ] || 0 ) + 1;
				ids[ n.id ] = 1;
				if ( /\s/.test( n.id ) ) { out.push( [ 'e', no + ': id에 공백이 있습니다.' ] ); }
				if ( /[,"]/.test( n.id ) ) { out.push( [ 'e', no + ': id에 쉼표나 큰따옴표를 쓸 수 없습니다.' ] ); }
			}
			if ( !n.cls ) { out.push( [ 'e', no + ': class가 비어 있습니다.' ] ); } else if ( clsNames( S.classes ).indexOf( n.cls ) < 0 ) {
				out.push( [ 'w', no + ': class "' + n.cls + '"가 #Class 목록에 없습니다.' ] );
			}
			if ( !n.label.trim() ) { out.push( [ 'e', no + ': label이 비어 있습니다.' ] ); }
			[ 'infoUrl', 'iconUrl' ].forEach( function ( f ) {
				if ( n[ f ] && n[ f ].trim() && !/^https?:\/\//i.test( n[ f ].trim() ) ) {
					out.push( [ 'w', no + ': ' + f + '이 http:// 또는 https://로 시작하지 않습니다.' ] );
				}
			} );
		} );
		S.links.forEach( function ( k, i ) {
			var no = '링크 ' + ( i + 1 );
			if ( !k.source.trim() ) { out.push( [ 'e', no + ': source가 비어 있습니다.' ] ); } else if ( !ids[ k.source ] ) {
				out.push( [ 'e', no + ': source "' + k.source + '"가 노드 목록에 없습니다.' ] );
			} else if ( seen[ k.source ] > 1 ) {
				out.push( [ 'e', no + ': source "' + k.source + '"는 id가 중복된 노드입니다.' ] );
			}
			if ( !k.target.trim() ) { out.push( [ 'e', no + ': target이 비어 있습니다.' ] ); } else if ( !ids[ k.target ] ) {
				out.push( [ 'e', no + ': target "' + k.target + '"가 노드 목록에 없습니다.' ] );
			} else if ( seen[ k.target ] > 1 ) {
				out.push( [ 'e', no + ': target "' + k.target + '"는 id가 중복된 노드입니다.' ] );
			}
			if ( !k.relation ) { out.push( [ 'e', no + ': relation이 비어 있습니다.' ] ); } else if ( S.relations.indexOf( k.relation ) < 0 ) {
				out.push( [ 'w', no + ': relation "' + k.relation + '"가 #Relation 목록에 없습니다.' ] );
			}
			used[ k.source ] = used[ k.target ] = 1;
		} );
		if ( S.links.length ) {
			S.nodes.forEach( function ( n ) {
				if ( n.id && !used[ n.id ] ) { out.push( [ 'w', '노드 "' + n.id + '"는 어느 링크에도 연결되지 않았습니다.' ] ); }
			} );
		}
		Object.keys( S.tr ).forEach( function ( k ) {
			var has = Object.keys( S.tr[ k ] ).some( function ( l ) { return ( S.tr[ k ][ l ] || '' ).trim(); } );
			if ( has && !ids[ k ] ) { out.push( [ 'w', '번역 대상 "' + k + '"가 노드 목록에 없습니다.' ] ); }
		} );
		return out;
	}

	/* ====================================================================
	 * 스타일
	 * ================================================================== */
	var CSS = [
		'.lsted-tbbtn{display:inline-block;padding:0 8px;line-height:26px;margin:0 2px;font-size:13px;font-weight:bold;color:#202122;border-radius:2px;cursor:pointer;text-decoration:none!important;white-space:nowrap}',
		'.lsted-tbbtn:hover{background:#eaecf0}',
		'.lsted-tbbtn img{height:20px;width:auto;vertical-align:middle;display:inline-block}',
		'.lsted-tbbtn span{vertical-align:middle;margin-left:4px;font-weight:normal}',
		'.lsted-fallbar{margin:0 0 4px}',
		'.lsted-fallbar .lsted-tbbtn{border:1px solid #a2a9b1;background:#f8f9fa}',
		'html.lsted-lock,html.lsted-lock body{overflow:hidden}',
		'#lsted-ov{position:fixed;top:0;left:0;right:0;bottom:0;z-index:1000;background:rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center;font:14px/1.5 sans-serif;color:#202122}',
		'#lsted-ov *{box-sizing:border-box}',
		'#lsted-ov .box{background:#fff;width:96vw;max-width:1280px;height:92vh;display:flex;flex-direction:column;border-radius:6px;box-shadow:0 4px 24px rgba(0,0,0,.3)}',
		'#lsted-ov .hd{display:flex;align-items:center;gap:10px;padding:10px 14px;border-bottom:1px solid #c8ccd1}',
		'#lsted-ov .hd b{font-size:16px}',
		'#lsted-ov .hd .pg{color:#72777d;font-size:13px;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
		'#lsted-ov .tabs{display:flex;flex-wrap:wrap;border-bottom:1px solid #c8ccd1;padding:0 10px}',
		'#lsted-ov .tabs button{border:none;border-bottom:3px solid transparent;border-radius:0;background:none;padding:8px 12px;font:inherit;cursor:pointer}',
		'#lsted-ov .tabs button.on{border-bottom-color:#36c;color:#36c;font-weight:bold}',
		'#lsted-ov .tabs .cnt{font-size:11px;color:#72777d;margin-left:3px}',
		'#lsted-ov .bd{flex:1;overflow:auto;padding:12px 14px}',
		'#lsted-ov .pane{display:none}',
		'#lsted-ov .pane.on{display:block}',
		'#lsted-ov .ft{display:flex;align-items:center;gap:8px;padding:10px 14px;border-top:1px solid #c8ccd1;background:#f8f9fa;border-radius:0 0 6px 6px}',
		'#lsted-ov .ft .st{flex:1;font-size:13px}',
		'#lsted-ov .ft .st.err{color:#d33}',
		'#lsted-ov input[type=text],#lsted-ov select,#lsted-ov textarea{font:inherit;border:1px solid #c8ccd1;border-radius:3px;padding:3px 6px;background:#fff;width:100%;color:inherit}',
		'#lsted-ov textarea{font-family:monospace;font-size:13px}',
		'#lsted-ov button.b{font:inherit;border:1px solid #a2a9b1;border-radius:3px;padding:4px 12px;background:#fff;cursor:pointer;white-space:nowrap;color:inherit}',
		'#lsted-ov button.b:hover{background:#eaf3ff}',
		'#lsted-ov button.pri{background:#36c;border-color:#36c;color:#fff;font-weight:bold}',
		'#lsted-ov button.pri:hover{background:#2a4b8d}',
		'#lsted-ov button.sm{font:inherit;font-size:12px;border:1px solid #c8ccd1;border-radius:3px;padding:0 6px;background:#fff;cursor:pointer;color:inherit}',
		'#lsted-ov button.sm:hover{background:#eaf3ff}',
		'#lsted-ov .x{border:none;background:none;font-size:20px;cursor:pointer;color:#54595d;padding:0 4px}',
		'#lsted-ov table{border-collapse:collapse;width:100%}',
		'#lsted-ov th,#lsted-ov td{border:1px solid #eaecf0;padding:3px;vertical-align:top}',
		'#lsted-ov th{background:#f8f9fa;font-weight:normal;font-size:12px;color:#54595d;text-align:left;white-space:nowrap}',
		'#lsted-ov th .req{color:#d33}',
		'#lsted-ov td.n{color:#72777d;font-size:12px;text-align:right;width:28px}',
		'#lsted-ov td.op{white-space:nowrap;width:1%}',
		'#lsted-ov td.miss input{background:#fff8e5}',
		'#lsted-ov td.bad input,#lsted-ov td.bad select{border-color:#d33;background:#fee7e6}',
		'#lsted-ov td.dup input{border-color:#14866d;background:#d5fdf4}',
		'#lsted-ov td.warn input{border-color:#ac6600;background:#fef6e7}',
		'#lsted-ov .url{position:relative}',
		'#lsted-ov .url.ico-w input{padding-left:26px}',
		'#lsted-ov .url.info-w input{padding-right:24px}',
		'#lsted-ov .url .ico{position:absolute;left:5px;top:50%;margin-top:-8px;width:16px;height:16px;object-fit:contain}',
		'#lsted-ov .url .err{position:absolute;left:7px;top:50%;margin-top:-9px;color:#d33;font-size:12px;display:none;cursor:help}',
		'#lsted-ov .url.none .ico,#lsted-ov .url.broken .ico{display:none}',
		'#lsted-ov .url.broken .err{display:inline}',
		'#lsted-ov .url .go{position:absolute;right:4px;top:50%;margin-top:-10px;line-height:20px;padding:0 3px;color:#36c;text-decoration:none;font-size:13px}',
		'#lsted-ov .url .go:hover{background:#eaf3ff;border-radius:2px}',
		'#lsted-ov .legend{padding:8px 12px;margin:0 0 10px;background:#f8f9fa;border:1px solid #eaecf0;border-radius:4px;font-size:12px;color:#54595d}',
		'#lsted-ov .legend .lgrow{display:grid;grid-template-columns:22px 64px 1fr;gap:8px;align-items:center;margin:2px 0}',
		'#lsted-ov .legend .lgrow b{color:#202122}',
		'#lsted-ov .legend .lgfoot{margin:6px 0 2px;padding-top:6px;border-top:1px solid #eaecf0}',
		'#lsted-ov .legend .sw{display:inline-block;width:22px;height:14px;border-radius:2px;border:1px solid}',
		'#lsted-ov .legend .sw.bad{background:#fee7e6;border-color:#d33}',
		'#lsted-ov .legend .sw.dup{background:#d5fdf4;border-color:#14866d}',
		'#lsted-ov .legend .sw.warn{background:#fef6e7;border-color:#ac6600}',
		'#lsted-ov .legend .req{color:#d33;font-weight:bold}',
		'#lsted-ov .legend .notes{margin:4px 0 0;padding-left:16px}',
		'#lsted-ov .row{display:flex;flex-wrap:wrap;gap:16px}',
		'#lsted-ov .col{flex:1 1 280px}',
		'#lsted-ov h4{margin:4px 0 6px;font-size:14px;font-weight:bold}',
		'#lsted-ov .hint{font-size:12px;color:#72777d;margin:2px 0 8px}',
		'#lsted-ov .chips{display:flex;flex-wrap:wrap;gap:4px;margin-bottom:6px}',
		'#lsted-ov table.otb{width:100%;table-layout:fixed;margin-bottom:4px}',
		'#lsted-ov table.otb td{vertical-align:middle}',
		'#lsted-ov table.otb .c-clr{width:140px}',
		'#lsted-ov table.otb .c-shp{width:110px}',
		'#lsted-ov table.otb .c-op{width:34px}',
		'#lsted-ov table.otb td.nm{font-weight:bold;padding:3px 8px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
		'#lsted-ov table.otb td.empty{color:#72777d;font-size:12px;padding:6px 8px}',
		'#lsted-ov table.otb .clr{display:flex;gap:4px;align-items:center}',
		'#lsted-ov table.otb input[type=color]{width:30px;height:26px;padding:1px;border:1px solid #c8ccd1;border-radius:3px;background:#fff;cursor:pointer;flex:none}',
		'#lsted-ov table.otb input[type=color].unset{opacity:.3}',
		'#lsted-ov table.otb input[type=text]{min-width:0;width:100%}',
		'#lsted-ov .chip{background:#eaf3ff;border:1px solid #b6d4fb;border-radius:12px;padding:0 4px 0 10px;font-size:13px}',
		'#lsted-ov .chip button{border:none;background:none;padding:0 4px;color:#72777d;cursor:pointer}',
		'#lsted-ov .add{display:flex;gap:6px;margin-top:6px}',
		'#lsted-ov .add input{max-width:280px}',
		'#lsted-ov .imp{display:none;border:1px solid #b6d4fb;background:#f5f9ff;border-radius:4px;padding:10px 12px;margin:0 0 10px}',
		'#lsted-ov .imp.on{display:block}',
		'#lsted-ov .imp h4{margin-top:0}',
		'#lsted-ov .imp code{background:#fff;border:1px solid #eaecf0;border-radius:2px;padding:0 4px;font-size:12px}',
		'#lsted-ov .imp textarea{white-space:pre;overflow:auto}',
		'#lsted-ov .imp .opt{display:flex;flex-wrap:wrap;gap:4px 16px;margin:8px 0 4px;font-size:13px}',
		'#lsted-ov .imp .opt label{display:inline-flex;align-items:center;gap:4px;cursor:pointer}',
		'#lsted-ov .imp .pv{font-size:12px;color:#54595d;margin:4px 0 8px}',
		'#lsted-ov .imp .pv.err{color:#d33}',
		'#lsted-ov .imp .btns{display:flex;justify-content:flex-end;gap:6px}',
		'#lsted-ov ul.val{margin:6px 0 12px;padding-left:20px;font-size:13px}',
		'#lsted-ov ul.val .e{color:#d33}',
		'#lsted-ov ul.val .w{color:#b35900}',
		'#lsted-ov ul.val .i{color:#54595d}',
		'#lsted-ov ul.val .ok{color:#14866d}',
		'#lsted-up{position:fixed;top:0;left:0;right:0;bottom:0;z-index:1000;background:rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center;font:14px/1.5 sans-serif;color:#202122}',
		'#lsted-up *{box-sizing:border-box}',
		'#lsted-up .box{background:#fff;width:94vw;max-width:560px;max-height:90vh;display:flex;flex-direction:column;border-radius:6px;box-shadow:0 4px 24px rgba(0,0,0,.3)}',
		'#lsted-up .hd{display:flex;align-items:center;padding:10px 14px;border-bottom:1px solid #c8ccd1}',
		'#lsted-up .hd b{flex:1;font-size:16px}',
		'#lsted-up .x{border:none;background:none;font-size:20px;cursor:pointer;color:#54595d;padding:0 4px}',
		'#lsted-up .bd{padding:12px 14px;overflow:auto}',
		'#lsted-up table{border-collapse:collapse;width:100%;margin-bottom:10px}',
		'#lsted-up th,#lsted-up td{border:1px solid #eaecf0;padding:4px 8px;text-align:left;vertical-align:top;font-size:13px}',
		'#lsted-up th{background:#f8f9fa;font-weight:normal;color:#54595d;white-space:nowrap;width:1%}',
		'#lsted-up label.k{display:block;font-size:13px;margin:4px 0 2px}',
		'#lsted-up input[type=password],#lsted-up input.t{font:inherit;border:1px solid #a2a9b1;border-radius:3px;padding:4px 6px;width:100%;margin-bottom:6px}',
		'#lsted-up code{background:#f8f9fa;border:1px solid #eaecf0;border-radius:2px;padding:0 4px;font-size:12px}',
		'#lsted-up .rem{font-size:12px;color:#54595d;margin:4px 0 0;display:flex;align-items:center;gap:4px}',
		'#lsted-up .hint{font-size:12px;color:#72777d;margin:8px 0 0}',
		'#lsted-up .msg{margin-top:10px;font-size:13px;padding:8px 10px;border-radius:3px;display:none}',
		'#lsted-up .msg.on{display:block}',
		'#lsted-up .msg.err{background:#fee7e6;color:#d33}',
		'#lsted-up .msg.ok{background:#d5fdf4;color:#14866d}',
		'#lsted-up .msg.info{background:#eaf3ff;color:#202122}',
		'#lsted-up .msg ul{margin:4px 0 0;padding-left:18px}',
		'#lsted-up .ft{display:flex;justify-content:flex-end;gap:8px;padding:10px 14px;border-top:1px solid #c8ccd1;background:#f8f9fa;border-radius:0 0 6px 6px}',
		'#lsted-up button.b{font:inherit;border:1px solid #a2a9b1;border-radius:3px;padding:4px 12px;background:#fff;cursor:pointer;color:inherit}',
		'#lsted-up button.pri{background:#36c;border-color:#36c;color:#fff;font-weight:bold}',
		'#lsted-up button:disabled{opacity:.5;cursor:default}'
	].join( '\n' );

	/* ====================================================================
	 * 편집기 화면
	 * ================================================================== */
	var S, mode, dirty, $ov;

	function esc( v ) {
		return String( v === undefined || v === null ? '' : v )
			.replace( /&/g, '&amp;' ).replace( /</g, '&lt;' ).replace( />/g, '&gt;' ).replace( /"/g, '&quot;' );
	}
	function q( sel ) { return $ov.find( sel ); }
	function ops() {
		return '<td class="op">' +
			'<button class="sm" data-a="up" title="위로">↑</button> ' +
			'<button class="sm" data-a="dn" title="아래로">↓</button> ' +
			'<button class="sm" data-a="cp" title="복제">⧉</button> ' +
			'<button class="sm" data-a="rm" title="삭제">✕</button></td>';
	}
	function th( name, req, tip ) {
		return '<th title="' + esc( tip ) + '">' + name + ( req ? ' <span class="req">*</span>' : '' ) + '</th>';
	}
	function chips( $el, list, kind ) {
		$el.html( list.map( function ( v, i ) {
			return '<span class="chip">' + esc( v ) + '<button data-k="' + kind + '" data-i="' + i + '" title="삭제">✕</button></span>';
		} ).join( '' ) );
	}

	var SKELETON = [
		'<div class="box" role="dialog" aria-label="Semantic LST Editor">',
		'<div class="hd"><b>Semantic LST Editor</b><span class="pg"></span><button class="x" data-act="cancel" title="닫기 (Esc)">✕</button></div>',
		'<div class="tabs">',
		'<button data-t="meta" class="on">Ontology</button>',
		'<button data-t="nodes">Node<span class="cnt" data-c="n"></span></button>',
		'<button data-t="links">Link<span class="cnt" data-c="l"></span></button>',
		'<button data-t="tr">Translation</button>',
		'<button data-t="out">Validation<span class="cnt" data-c="v"></span></button>',
		'</div>',
		'<div class="bd">',

		'<div class="pane on" data-p="meta">',
		'<h4>프로젝트 이름 (#Project)</h4>',
		'<input type="text" data-in="project" placeholder="예: AI와 함께 배우는 한국 문화">',
		'<div class="row" style="margin-top:16px">',
		'<div class="col"><h4>클래스 (#Class)</h4>',
		'<table class="otb"><thead><tr><th>이름</th><th class="c-clr">색상</th><th class="c-shp">모양</th><th class="c-op"></th></tr></thead><tbody data-otb="classes"></tbody></table>',
		'<div class="add"><input type="text" data-add="classes" placeholder="클래스 이름 (Enter)"><button class="b" data-addb="classes">+ Add</button></div>',
		'<div class="hint">이름은 추가한 뒤 바꿀 수 없습니다. 바꾸려면 지우고 다시 추가하세요. 색상과 모양은 표에서 바로 바꿀 수 있습니다.</div></div>',
		'<div class="col"><h4>관계 (#Relation)</h4>',
		'<table class="otb"><thead><tr><th>이름</th><th class="c-op"></th></tr></thead><tbody data-otb="relations"></tbody></table>',
		'<div class="add"><input type="text" data-add="relations" placeholder="관계 이름 (Enter)"><button class="b" data-addb="relations">+ Add</button></div>',
		'<div class="hint">이름은 추가한 뒤 바꿀 수 없습니다. 바꾸려면 지우고 다시 추가하세요.</div></div>',
		'</div>',
		'</div>',

		'<div class="pane" data-p="nodes">',
		'<div class="legend">',
		'<div class="lgrow"><span class="sw bad"></span><b>오류</b><span>필수 필드(id·class·label)가 비어 있음 · id에 공백·쉼표·큰따옴표가 있음</span></div>',
		'<div class="lgrow"><span class="sw dup"></span><b>중복</b><span>다른 노드와 같은 id</span></div>',
		'<div class="lgrow"><span class="sw warn"></span><b>확인 필요</b><span>class가 #Class 목록에 없음 · infoUrl·iconUrl이 http(s)://로 시작하지 않음</span></div>',
		'<div class="lgfoot">빨간색·초록색은 Validation의 오류, 노란색은 경고와 같습니다. <span class="req">*</span> 표시는 필수 필드입니다.</div>',
		'<ul class="notes"><li>id를 바꾸면 그 노드를 가리키는 링크와 번역도 함께 바뀝니다.</li>',
		'<li>class는 목록에서 고르거나 직접 입력합니다.</li>',
		'<li>iconUrl은 칸 왼쪽에 미리보기가 나타나고(⚠: 이미지를 불러오지 못함), infoUrl은 ↗로 새 탭에서 열어 볼 수 있습니다. 칸에 마우스를 올리면 전체 주소가 보입니다.</li>',
		'<li>빈 칸은 Null로 기록됩니다.</li></ul>',
		'</div>',
		'<div class="imp" data-imp-panel="nodes">',
		'<h4>Node 가져오기 (엑셀)</h4>',
		'<div class="hint">엑셀에서 <b>머리글(첫 줄)을 포함해</b> 범위를 복사해 붙여넣으세요. 열 순서는 상관없고, 머리글 이름으로 필드를 찾습니다. 사용할 수 있는 머리글: <code>id</code>* <code>class</code>* <code>label</code>* <code>infoUrl</code> <code>iconUrl</code> <code>remark</code></div>',
		'<textarea data-imp-text="nodes" rows="7" spellcheck="false"></textarea>',
		'<div class="opt"><label><input type="radio" name="lsted-imp-nodes" value="append" checked> 기존 데이터 뒤에 추가</label><label><input type="radio" name="lsted-imp-nodes" value="replace"> 전부 교체</label></div>',
		'<div class="pv" data-imp-pv="nodes"></div>',
		'<div class="btns"><button class="b" data-imp-cancel="nodes">취소</button><button class="b pri" data-imp-go="nodes">가져오기</button></div>',
		'</div>',
		'<table><thead><tr><th></th>',
		th( 'id', true, '노드 식별자' ), th( 'class', true, '#Class 목록에서 고르거나 직접 입력' ), th( 'label', true, '그래프에 표시할 이름' ),
		th( 'infoUrl', false, '정보 페이지 URL' ), th( 'iconUrl', false, '아이콘 이미지 URL' ), th( 'remark', false, '설명' ),
		'<th></th></tr></thead><tbody data-tb="nodes"></tbody></table>',
		'<datalist id="lsted-cls"></datalist>',
		'<div class="add"><button class="b" data-act="addnode">+ Add Node</button><button class="b" data-imp="nodes">⇩ Import</button></div>',
		'</div>',

		'<div class="pane" data-p="links">',
		'<div class="legend">',
		'<div class="lgrow"><span class="sw bad"></span><b>오류</b><span>필수 필드(source·target·relation)가 비어 있음 · source·target이 노드 목록에 없거나 오류·중복 표시된 노드를 가리킴</span></div>',
		'<div class="lgrow"><span class="sw warn"></span><b>확인 필요</b><span>relation이 #Relation 목록에 없음</span></div>',
		'<div class="lgfoot">빨간색은 Validation의 오류, 노란색은 경고와 같습니다. <span class="req">*</span> 표시는 필수 필드입니다.</div>',
		'<ul class="notes"><li>source·target은 목록에서 고르거나 노드 id를 직접 입력합니다. 목록에는 id 규칙에 맞는 노드만 나옵니다.</li>',
		'<li>relation은 목록에서 고르거나 직접 입력합니다.</li>',
		'<li>attribute는 그래프의 엣지 위에 표시되는 짧은 말, remark는 부가 설명입니다.</li>',
		'<li>빈 칸은 Null로 기록됩니다.</li></ul>',
		'</div>',
		'<div class="imp" data-imp-panel="links">',
		'<h4>Link 가져오기 (엑셀)</h4>',
		'<div class="hint">엑셀에서 <b>머리글(첫 줄)을 포함해</b> 범위를 복사해 붙여넣으세요. 열 순서는 상관없고, 머리글 이름으로 필드를 찾습니다. 사용할 수 있는 머리글: <code>source</code>* <code>target</code>* <code>relation</code>* <code>attribute</code> <code>remark</code></div>',
		'<textarea data-imp-text="links" rows="7" spellcheck="false"></textarea>',
		'<div class="opt"><label><input type="radio" name="lsted-imp-links" value="append" checked> 기존 데이터 뒤에 추가</label><label><input type="radio" name="lsted-imp-links" value="replace"> 전부 교체</label></div>',
		'<div class="pv" data-imp-pv="links"></div>',
		'<div class="btns"><button class="b" data-imp-cancel="links">취소</button><button class="b pri" data-imp-go="links">가져오기</button></div>',
		'</div>',
		'<table><thead><tr><th></th>',
		th( 'source', true, '출발 노드 id' ), th( 'target', true, '도착 노드 id' ), th( 'relation', true, '#Relation 목록에서 고르거나 직접 입력' ),
		th( 'attribute', false, '엣지 라벨' ), th( 'remark', false, '설명' ),
		'<th></th></tr></thead><tbody data-tb="links"></tbody></table>',
		'<datalist id="lsted-ids"></datalist>',
		'<datalist id="lsted-rels"></datalist>',
		'<div class="add"><button class="b" data-act="addlink">+ Add Link</button><button class="b" data-imp="links">⇩ Import</button></div>',
		'</div>',

		'<div class="pane" data-p="tr">',
		'<div class="hint">언어별 번역어를 입력하세요. 관계 이름은 has{언어}Translation 형식으로 자동으로 붙습니다. 빈 칸은 출력에서 빠지고 노란색으로 표시됩니다.</div>',
		'<div class="chips" data-chips="langs"></div>',
		'<div class="add" style="margin-bottom:10px"><input type="text" data-add="langs" placeholder="언어 추가 (영문, 예: German)"><button class="b" data-addb="langs">+ Add</button></div>',
		'<table><thead data-tb="trh"></thead><tbody data-tb="trb"></tbody></table>',
		'</div>',

		'<div class="pane" data-p="out">',
		'<h4>검증</h4><ul class="val" data-val></ul>',
		'<h4>.lst 텍스트</h4>',
		'<textarea data-in="out" rows="18" readonly></textarea>',
		'</div>',

		'</div>',
		'<div class="ft"><span class="st"></span>',
		'<button class="b" data-act="cancel">취소</button>',
		'<button class="b pri" data-act="apply">편집창에 적용</button></div>',
		'</div>'
	].join( '' );

	function counts() {
		var v = validate( S ), ne = v.filter( function ( x ) { return x[ 0 ] === 'e'; } ).length;
		q( '[data-c=n]' ).text( S.nodes.length );
		q( '[data-c=l]' ).text( S.links.length );
		q( '[data-c=v]' ).text( ne ? '⚠' + ne : '' );
		q( '.ft .st' ).toggleClass( 'err', !!ne )
			.text( ne ? '검증 오류 ' + ne + '건 (Validation 탭에서 확인)' : '노드 ' + S.nodes.length + '개 · 링크 ' + S.links.length + '개' );
	}
	function touch() { dirty = true; counts(); }

	// 색 이름·코드 → #rrggbb (색 고르기 칸에 표시). 알 수 없는 값이면 ''
	var colorCtx = null;
	function colorHex( v ) {
		v = ( v || '' ).trim();
		if ( !v ) { return ''; }
		if ( /^#[0-9a-f]{6}$/i.test( v ) ) { return v.toLowerCase(); }
		try {
			colorCtx = colorCtx || document.createElement( 'canvas' ).getContext( '2d' );
			colorCtx.fillStyle = '#010203';
			colorCtx.fillStyle = v;
			return ( colorCtx.fillStyle === '#010203' && v.toLowerCase() !== '#010203' ) ? '' : colorCtx.fillStyle;
		} catch ( e ) { return ''; }
	}
	function shapeOpts( cur ) {
		var h = '<option value="">(기본)</option>', has = !cur;
		SHAPES.forEach( function ( v ) {
			if ( v === cur ) { has = true; }
			h += '<option' + ( v === cur ? ' selected' : '' ) + '>' + v + '</option>';
		} );
		if ( !has ) { h += '<option selected>' + esc( cur ) + '</option>'; }
		return h;
	}
	function setPicker( $pick, color ) {
		var hex = colorHex( color );
		$pick.val( hex || '#ffffff' ).toggleClass( 'unset', !hex )
			.attr( 'title', hex ? '색 고르기' : ( color ? '알 수 없는 색: ' + color : '색 고르기 (지정 안 됨)' ) );
	}
	function renderMeta() {
		q( '[data-in=project]' ).val( S.project );
		q( '[data-otb=classes]' ).html( S.classes.length ? S.classes.map( function ( line, i ) {
			var o = clsParts( line );
			return '<tr data-i="' + i + '"><td class="nm">' + esc( o.name ) + '</td>' +
				'<td><div class="clr"><input type="color" data-c="pick"><input type="text" data-c="color" value="' + esc( o.color ) + '" placeholder="(기본)"></div></td>' +
				'<td><select data-c="shape">' + shapeOpts( o.shape ) + '</select></td>' +
				'<td class="op"><button class="sm" data-orm="classes" title="삭제">✕</button></td></tr>';
		} ).join( '' ) : '<tr><td class="empty" colspan="4">클래스가 없습니다. 아래에서 추가하세요.</td></tr>' );
		q( '[data-otb=classes] tr[data-i]' ).each( function () {
			setPicker( $( this ).find( '[data-c=pick]' ), clsParts( S.classes[ +this.getAttribute( 'data-i' ) ] ).color );
		} );
		q( '[data-otb=relations]' ).html( S.relations.length ? S.relations.map( function ( r, i ) {
			return '<tr data-i="' + i + '"><td class="nm">' + esc( r ) + '</td>' +
				'<td class="op"><button class="sm" data-orm="relations" title="삭제">✕</button></td></tr>';
		} ).join( '' ) : '<tr><td class="empty" colspan="2">관계가 없습니다. 아래에서 추가하세요.</td></tr>' );
	}
	function badId( v ) { return !v || /[\s,"]/.test( v ); }
	function idCounts() {
		var c = {};
		S.nodes.forEach( function ( n ) { c[ n.id ] = ( c[ n.id ] || 0 ) + 1; } );
		return c;
	}
	// 'bad'(빨강) / 'dup'(초록) / ''
	function idState( v, c ) { return badId( v ) ? 'bad' : ( c[ v ] > 1 ? 'dup' : '' ); }
	// 링크에 쓸 수 있는 노드 id (규칙에 맞고 중복이 없는 것)
	function validIds() {
		var c = idCounts(), ok = {};
		S.nodes.forEach( function ( n ) { if ( !idState( n.id, c ) ) { ok[ n.id ] = 1; } } );
		return ok;
	}
	function markIds() {
		var c = idCounts();
		q( '[data-tb=nodes] input[data-f=id]' ).each( function () {
			var st = idState( this.value, c );
			$( this ).parent().toggleClass( 'bad', st === 'bad' ).toggleClass( 'dup', st === 'dup' );
		} );
	}
	// 칸 상태: 'bad'(빨강) / 'warn'(노랑) / ''
	function isUrl( v ) { return /^https?:\/\//i.test( v ); }
	function cellState( kind, f, v, ids ) {
		v = ( v || '' ).trim();
		if ( kind === 'nodes' ) {
			if ( f === 'cls' ) { return !v ? 'bad' : ( clsNames( S.classes ).indexOf( v ) < 0 ? 'warn' : '' ); }
			if ( f === 'label' ) { return v ? '' : 'bad'; }
			if ( f === 'infoUrl' || f === 'iconUrl' ) { return v && !isUrl( v ) ? 'warn' : ''; }
		} else {
			if ( f === 'source' || f === 'target' ) { return ( ids || validIds() )[ v ] ? '' : 'bad'; }
			if ( f === 'relation' ) { return !v ? 'bad' : ( S.relations.indexOf( v ) < 0 ? 'warn' : '' ); }
		}
		return '';
	}
	function td( state, inner ) { return '<td' + ( state ? ' class="' + state + '"' : '' ) + '>' + inner + '</td>'; }
	function txt( f, v, list ) {
		return '<input type="text"' + ( list ? ' list="' + list + '"' : '' ) + ' data-f="' + f + '" value="' + esc( v ) + '">';
	}
	// URL 칸: 툴팁(전체 주소), iconUrl 미리보기, infoUrl 새 탭 열기
	function refreshUrl( input ) {
		var $in = $( input ), v = $in.val().trim(), $w = $in.parent(), $img, $go;
		$in.attr( 'title', v );
		if ( $w.hasClass( 'info-w' ) ) {
			$go = $w.find( '.go' );
			if ( isUrl( v ) ) { $go.attr( 'href', v ).show(); } else { $go.removeAttr( 'href' ).hide(); }
		}
		if ( $w.hasClass( 'ico-w' ) ) {
			$img = $w.find( '.ico' );
			if ( !v ) { $w.addClass( 'none' ).removeClass( 'broken' ); $img.removeAttr( 'src' ); return; }
			$w.removeClass( 'none' );
			if ( $img.attr( 'src' ) !== v ) { $w.removeClass( 'broken' ); $img.attr( 'src', v ); }
		}
	}
	function bindIcons( $scope ) {
		$scope.find( 'img.ico' ).on( 'load', function () { $( this ).parent().removeClass( 'broken' ); } )
			.on( 'error', function () { if ( $( this ).attr( 'src' ) ) { $( this ).parent().addClass( 'broken' ); } } );
		$scope.find( '.url input' ).each( function () { refreshUrl( this ); } );
	}
	function renderNodes() {
		var c = idCounts(), $tb = q( '[data-tb=nodes]' );
		q( '#lsted-cls' ).html( clsNames( S.classes ).map( function ( n ) { return '<option value="' + esc( n ) + '"></option>'; } ).join( '' ) );
		$tb.html( S.nodes.map( function ( n, i ) {
			return '<tr data-i="' + i + '"><td class="n">' + ( i + 1 ) + '</td>' +
				td( idState( n.id, c ), txt( 'id', n.id ) ) +
				td( cellState( 'nodes', 'cls', n.cls ), txt( 'cls', n.cls, 'lsted-cls' ) ) +
				td( cellState( 'nodes', 'label', n.label ), txt( 'label', n.label ) ) +
				td( cellState( 'nodes', 'infoUrl', n.infoUrl ), '<div class="url info-w">' + txt( 'infoUrl', n.infoUrl ) +
					'<a class="go" target="_blank" rel="noopener noreferrer" title="새 탭에서 열기">↗</a></div>' ) +
				td( cellState( 'nodes', 'iconUrl', n.iconUrl ), '<div class="url ico-w"><img class="ico" alt="">' +
					'<span class="err" title="이미지를 불러오지 못했습니다">⚠</span>' + txt( 'iconUrl', n.iconUrl ) + '</div>' ) +
				td( '', txt( 'remark', n.remark ) ) + ops() + '</tr>';
		} ).join( '' ) );
		bindIcons( $tb );
	}
	function renderLinks() {
		var ids = validIds();
		q( '#lsted-ids' ).html( S.nodes.filter( function ( n ) { return ids[ n.id ]; } ).map( function ( n ) {
			return '<option value="' + esc( n.id ) + '"></option>';
		} ).join( '' ) );
		q( '#lsted-rels' ).html( S.relations.map( function ( r ) { return '<option value="' + esc( r ) + '"></option>'; } ).join( '' ) );
		q( '[data-tb=links]' ).html( S.links.map( function ( k, i ) {
			return '<tr data-i="' + i + '"><td class="n">' + ( i + 1 ) + '</td>' +
				td( cellState( 'links', 'source', k.source, ids ), txt( 'source', k.source, 'lsted-ids' ) ) +
				td( cellState( 'links', 'target', k.target, ids ), txt( 'target', k.target, 'lsted-ids' ) ) +
				td( cellState( 'links', 'relation', k.relation ), txt( 'relation', k.relation, 'lsted-rels' ) ) +
				td( '', txt( 'attribute', k.attribute ) ) +
				td( '', txt( 'remark', k.remark ) ) + ops() + '</tr>';
		} ).join( '' ) );
	}
	function renderTr() {
		chips( q( '[data-chips=langs]' ), S.langs, 'langs' );
		q( '[data-tb=trh]' ).html( '<tr><th>id</th><th>label</th>' +
			S.langs.map( function ( l ) { return '<th>' + esc( l ) + '</th>'; } ).join( '' ) + '</tr>' );
		q( '[data-tb=trb]' ).html( S.nodes.filter( function ( n ) { return n.id; } ).map( function ( n ) {
			var t = S.tr[ n.id ] || {};
			return '<tr data-id="' + esc( n.id ) + '"><td>' + esc( n.id ) + '</td><td>' + esc( n.label ) + '</td>' +
				S.langs.map( function ( l ) {
					var v = t[ l ] || '';
					return '<td' + ( v.trim() ? '' : ' class="miss"' ) + '><input type="text" data-l="' + esc( l ) + '" value="' + esc( v ) + '"></td>';
				} ).join( '' ) + '</tr>';
		} ).join( '' ) );
	}
	function renderOut() {
		var v = validate( S );
		q( '[data-in=out]' ).val( serialize( S ) );
		q( '[data-val]' ).html( v.length ? v.map( function ( x ) {
			return '<li class="' + x[ 0 ] + '">' + esc( x[ 1 ] ) + '</li>';
		} ).join( '' ) : '<li class="ok">문제가 발견되지 않았습니다.</li>' );
	}
	function renderAll() { renderMeta(); renderNodes(); renderLinks(); renderTr(); renderOut(); counts(); }

	function addChip( kind ) {
		var $in = q( '[data-add=' + kind + ']' ), v = $in.val().trim(), dup;
		if ( kind === 'classes' ) {
			v = normClass( v );
			dup = clsNames( S.classes ).indexOf( clsName( v ) ) >= 0;
		} else {
			v = v.replace( /\s+/g, '' );
			if ( kind === 'langs' ) { v = v.replace( /[^A-Za-z0-9]/g, '' ); }
			dup = S[ kind ].indexOf( v ) >= 0;
		}
		if ( v && !dup ) { S[ kind ].push( v ); touch(); }
		if ( v && dup ) { mw.notify( '"' + ( kind === 'classes' ? clsName( v ) : v ) + '"는 이미 목록에 있습니다.', { type: 'warn' } ); }
		$in.val( '' );
		renderMeta(); renderTr();
	}

	function bindRows( kind, render ) {
		var $tb = q( '[data-tb=' + kind + ']' );
		$tb.on( 'input change', 'input,select', function () {
			var f = this.getAttribute( 'data-f' ), i = +$( this ).closest( 'tr' ).attr( 'data-i' );
			if ( f === 'id' ) { return; } // id는 아래 별도 처리
			S[ kind ][ i ][ f ] = this.value;
			$( this ).closest( 'td' ).removeClass( 'bad warn' ).addClass( cellState( kind, f, this.value ) );
			if ( f === 'infoUrl' ) { refreshUrl( this ); }
			if ( f === 'iconUrl' ) {
				var el = this;
				clearTimeout( el.lstedT );
				el.lstedT = setTimeout( function () { refreshUrl( el ); }, 400 );
			}
			touch();
		} );
		$tb.on( 'click', 'button', function () {
			var L = S[ kind ], i = +$( this ).closest( 'tr' ).attr( 'data-i' ), a = this.getAttribute( 'data-a' ), c;
			if ( a === 'rm' ) { L.splice( i, 1 ); }
			else if ( a === 'cp' ) {
				c = $.extend( {}, L[ i ] );
				if ( kind === 'nodes' ) { c.id = c.id + '-복사'; delete c.ref; }
				L.splice( i + 1, 0, c );
			}
			else if ( a === 'up' && i > 0 ) { L.splice( i - 1, 0, L.splice( i, 1 )[ 0 ] ); }
			else if ( a === 'dn' && i < L.length - 1 ) { L.splice( i + 1, 0, L.splice( i, 1 )[ 0 ] ); }
			else { return; }
			render(); touch();
		} );
	}

	function bind() {
		// 탭
		q( '.tabs' ).on( 'click', 'button', function () {
			var t = this.getAttribute( 'data-t' );
			q( '.tabs button' ).removeClass( 'on' ); $( this ).addClass( 'on' );
			q( '.pane' ).removeClass( 'on' ); q( '[data-p=' + t + ']' ).addClass( 'on' );
			if ( t === 'nodes' ) { renderNodes(); }
			if ( t === 'links' ) { renderLinks(); }
			if ( t === 'tr' ) { renderTr(); }
			if ( t === 'out' ) { renderOut(); }
		} );

		// 기본 정보
		q( '[data-in=project]' ).on( 'input', function () { S.project = this.value; touch(); } );
		q( '[data-addb]' ).on( 'click', function () { addChip( this.getAttribute( 'data-addb' ) ); } );
		q( '[data-add]' ).on( 'keydown', function ( e ) {
			if ( e.key === 'Enter' ) { e.preventDefault(); addChip( this.getAttribute( 'data-add' ) ); }
		} );
		// 클래스 색상·모양
		function updClass( $tr, f, val ) {
			var i = +$tr.attr( 'data-i' ), o = clsParts( S.classes[ i ] );
			o[ f ] = val.trim();
			S.classes[ i ] = clsJoin( o );
			touch();
		}
		q( '[data-otb=classes]' ).on( 'input', '[data-c=color]', function () {
			var $tr = $( this ).closest( 'tr' );
			updClass( $tr, 'color', this.value );
			setPicker( $tr.find( '[data-c=pick]' ), this.value );
		} );
		q( '[data-otb=classes]' ).on( 'input change', '[data-c=pick]', function () {
			var $tr = $( this ).closest( 'tr' );
			$tr.find( '[data-c=color]' ).val( this.value );
			updClass( $tr, 'color', this.value );
			$( this ).removeClass( 'unset' );
		} );
		q( '[data-otb=classes]' ).on( 'change', '[data-c=shape]', function () {
			updClass( $( this ).closest( 'tr' ), 'shape', this.value );
		} );
		// 클래스·관계 삭제 (사용 중이면 확인)
		$ov.on( 'click', '[data-orm]', function () {
			var k = this.getAttribute( 'data-orm' ), i = +$( this ).closest( 'tr' ).attr( 'data-i' ),
				name = k === 'classes' ? clsName( S.classes[ i ] ) : S.relations[ i ],
				n = k === 'classes' ?
					S.nodes.filter( function ( x ) { return x.cls === name; } ).length :
					S.links.filter( function ( x ) { return x.relation === name; } ).length;
			if ( n && !window.confirm( ( k === 'classes' ? '노드 ' : '링크 ' ) + n + '개가 "' + name + '"' +
				( k === 'classes' ? ' 클래스' : ' 관계' ) + '를 쓰고 있습니다.\n' +
				'지우면 이 ' + ( k === 'classes' ? '노드들의 class' : '링크들의 relation' ) +
				'는 목록에 없는 값으로 남아 경고가 표시됩니다. 새로 추가한 항목으로 다시 골라 주세요.\n\n지울까요?' ) ) { return; }
			S[ k ].splice( i, 1 );
			renderMeta(); touch();
		} );

		$ov.on( 'click', '.chip button', function () {
			var k = this.getAttribute( 'data-k' ), i = +this.getAttribute( 'data-i' ), l;
			if ( k === 'langs' ) {
				l = S.langs[ i ];
				if ( !window.confirm( '"' + l + '" 언어와 그 번역을 모두 지울까요?' ) ) { return; }
				Object.keys( S.tr ).forEach( function ( id ) { delete S.tr[ id ][ l ]; } );
			}
			S[ k ].splice( i, 1 );
			renderMeta(); renderTr(); touch();
		} );

		// 노드 / 링크 표
		bindRows( 'nodes', renderNodes );
		bindRows( 'links', renderLinks );

		// 노드 id 변경 → 링크·번역 참조도 함께 변경
		var $nodes = q( '[data-tb=nodes]' );
		// node.ref: 링크·번역이 가리키고 있는 (마지막으로 유효했던) id
		$nodes.on( 'focusin', 'input[data-f=id]', function () {
			var node = S.nodes[ +$( this ).closest( 'tr' ).attr( 'data-i' ) ];
			if ( !( 'ref' in node ) ) { node.ref = node.id; }
		} );
		$nodes.on( 'input', 'input[data-f=id]', function () {
			S.nodes[ +$( this ).closest( 'tr' ).attr( 'data-i' ) ].id = this.value;
			markIds();
			touch();
		} );
		$nodes.on( 'change', 'input[data-f=id]', function () {
			var node = S.nodes[ +$( this ).closest( 'tr' ).attr( 'data-i' ) ],
				o = node.ref, n = this.value, cnt = 0;
			node.id = n;
			// 새 id가 규칙에 맞지 않거나 중복이면 링크·번역은 그대로 둔다 (다른 노드와 섞이지 않도록)
			if ( idState( n, idCounts() ) ) { return; }
			node.ref = n;
			if ( !o || o === n ) { return; }
			S.links.forEach( function ( k ) {
				if ( k.source === o ) { k.source = n; cnt++; }
				if ( k.target === o ) { k.target = n; cnt++; }
			} );
			if ( S.tr[ o ] && !S.tr[ n ] ) { S.tr[ n ] = S.tr[ o ]; delete S.tr[ o ]; }
			touch();
			if ( cnt ) { mw.notify( 'id "' + o + '" → "' + n + '": 링크 ' + cnt + '곳도 함께 바꿨습니다.' ); }
		} );
		q( '[data-act=addnode]' ).on( 'click', function () {
			S.nodes.push( { id: '', cls: clsName( S.classes[ 0 ] ), label: '', infoUrl: '', iconUrl: '', remark: '' } );
			renderNodes(); touch();
			q( '[data-tb=nodes] tr:last-child input' ).first().trigger( 'focus' );
		} );
		q( '[data-act=addlink]' ).on( 'click', function () {
			S.links.push( { source: '', target: '', relation: '', attribute: '', remark: '' } );
			renderLinks(); touch();
			q( '[data-tb=links] tr:last-child input' ).first().trigger( 'focus' );
		} );

		// 엑셀 가져오기
		function impMode( kind ) { return q( 'input[name=lsted-imp-' + kind + ']:checked' ).val(); }
		function impPreview( kind ) {
			var t = q( '[data-imp-text=' + kind + ']' ).val(), r, $pv = q( '[data-imp-pv=' + kind + ']' ), info, nc, nr;
			if ( !t.trim() ) { $pv.removeClass( 'err' ).text( '' ); return null; }
			r = readImport( kind, t );
			if ( r.error ) { $pv.addClass( 'err' ).text( r.error ); return null; }
			info = [ ( kind === 'nodes' ? '노드 ' : '링크 ' ) + r.items.length + '개', '인식한 열: ' + r.columns.join( ', ' ) ];
			if ( r.ignored.length ) { info.push( '무시한 열: ' + r.ignored.join( ', ' ) ); }
			if ( kind === 'nodes' ) {
				nc = newValues( r.items, 'cls', clsNames( S.classes ) );
				if ( nc.length ) { info.push( '#Class에 추가: ' + nc.join( ', ' ) ); }
			} else {
				nr = newValues( r.items, 'relation', S.relations );
				if ( nr.length ) { info.push( '#Relation에 추가: ' + nr.join( ', ' ) ); }
			}
			$pv.removeClass( 'err' ).text( info.join( ' · ' ) );
			return r;
		}
		function newValues( items, f, list ) {
			var out = [];
			items.forEach( function ( o ) {
				var v = ( o[ f ] || '' ).replace( /\s+/g, '' );
				if ( v && list.indexOf( v ) < 0 && out.indexOf( v ) < 0 ) { out.push( v ); }
			} );
			return out;
		}
		$ov.on( 'click', '[data-imp]', function () {
			var kind = this.getAttribute( 'data-imp' ), $p = q( '[data-imp-panel=' + kind + ']' );
			$p.toggleClass( 'on' );
			if ( $p.hasClass( 'on' ) ) { $p.find( 'textarea' ).trigger( 'focus' ); q( '.bd' ).scrollTop( 0 ); }
		} );
		$ov.on( 'input', '[data-imp-text]', function () { impPreview( this.getAttribute( 'data-imp-text' ) ); } );
		$ov.on( 'click', '[data-imp-cancel]', function () {
			var kind = this.getAttribute( 'data-imp-cancel' );
			q( '[data-imp-text=' + kind + ']' ).val( '' ); impPreview( kind );
			q( '[data-imp-panel=' + kind + ']' ).removeClass( 'on' );
		} );
		$ov.on( 'click', '[data-imp-go]', function () {
			var kind = this.getAttribute( 'data-imp-go' ), r = impPreview( kind ), mode = impMode( kind ), list, added, name;
			if ( !r ) {
				if ( !q( '[data-imp-text=' + kind + ']' ).val().trim() ) { mw.notify( '붙여넣은 내용이 없습니다.', { type: 'warn' } ); }
				return;
			}
			if ( !r.items.length ) { mw.notify( '머리글 아래에 데이터 행이 없습니다.', { type: 'warn' } ); return; }
			name = kind === 'nodes' ? '노드' : '링크';
			if ( mode === 'replace' && S[ kind ].length &&
				!window.confirm( '기존 ' + name + ' ' + S[ kind ].length + '개를 지우고 가져온 ' + r.items.length + '개로 바꿀까요?' ) ) { return; }
			// 목록에 없는 class / relation 자동 추가
			list = kind === 'nodes' ? S.classes : S.relations;
			added = newValues( r.items, kind === 'nodes' ? 'cls' : 'relation', kind === 'nodes' ? clsNames( list ) : list );
			added.forEach( function ( v ) { list.push( v ); } );
			r.items.forEach( function ( o ) {
				if ( kind === 'nodes' ) { o.cls = o.cls.replace( /\s+/g, '' ); } else { o.relation = o.relation.replace( /\s+/g, '' ); }
			} );
			S[ kind ] = mode === 'replace' ? r.items : S[ kind ].concat( r.items );
			q( '[data-imp-text=' + kind + ']' ).val( '' ); impPreview( kind );
			q( '[data-imp-panel=' + kind + ']' ).removeClass( 'on' );
			renderAll(); touch();
			mw.notify( name + ' ' + r.items.length + '개를 가져왔습니다' +
				( added.length ? ' (' + ( kind === 'nodes' ? '#Class' : '#Relation' ) + '에 ' + added.join( ', ' ) + ' 추가)' : '' ) + '.' );
		} );

		// 번역
		q( '[data-tb=trb]' ).on( 'input', 'input', function () {
			var id = $( this ).closest( 'tr' ).attr( 'data-id' ), l = this.getAttribute( 'data-l' );
			( S.tr[ id ] = S.tr[ id ] || {} )[ l ] = this.value;
			$( this ).parent().toggleClass( 'miss', !this.value.trim() );
			touch();
		} );

		// 취소 / 적용
		$ov.on( 'click', '[data-act=cancel]', close );
		$ov.on( 'click', '[data-act=apply]', apply );
		$ov.on( 'mousedown', function ( e ) { if ( e.target === $ov[ 0 ] ) { close(); } } );
		$( document ).on( 'keydown.lsted', function ( e ) { if ( e.key === 'Escape' ) { close(); } } );
	}

	/* ====================================================================
	 * 열기 / 닫기 / 적용
	 * ================================================================== */
	function $textbox() { return $( '#wpTextbox1' ); }
	function isSectionEdit() {
		var v = $( 'input[name="wpSection"]' ).val();
		return !!( v && v !== '' );
	}

	function open() {
		if ( $ov ) { return; }
		var text = $textbox().textSelection( 'getContents' ), x = extract( text );
		if ( !x ) {
			if ( isSectionEdit() ) {
				window.alert( '이 문단에는 .lst 부분(#Project ~ #End)이 없습니다.\n문서 전체 편집 화면에서 Semantic LST Editor를 사용하세요.' );
				return;
			}
			if ( text.trim() ) {
				if ( !window.confirm( '본문에 .lst 부분(#Project ~ #End)이 없습니다.\n문서 맨 앞에 새로 추가할까요?' ) ) { return; }
				mode = 'prepend';
			} else {
				mode = 'new';
			}
			S = blank();
		} else {
			mode = 'replace';
			S = parse( x.body );
		}
		dirty = false;
		$ov = $( '<div id="lsted-ov"></div>' ).html( SKELETON ).appendTo( document.body );
		q( '.hd .pg' ).text( conf.wgPageName.replace( /_/g, ' ' ) +
			( mode === 'new' ? ' (새 문서)' : mode === 'prepend' ? ' (.lst 부분 새로 추가)' : '' ) );
		$( 'html' ).addClass( 'lsted-lock' );
		bind();
		renderAll();
	}

	function close( force ) {
		if ( !$ov ) { return; }
		if ( force !== true && dirty && !window.confirm( '편집기에서 바꾼 내용을 편집창에 적용하지 않고 닫을까요?' ) ) { return; }
		$( document ).off( 'keydown.lsted' );
		$ov.remove(); $ov = null;
		$( 'html' ).removeClass( 'lsted-lock' );
	}

	function apply() {
		var errs = validate( S ).filter( function ( x ) { return x[ 0 ] === 'e'; } ), lst, text, x, out, $sum;
		if ( errs.length && !window.confirm( '검증 오류가 ' + errs.length + '건 있습니다.\n(' + errs[ 0 ][ 1 ] + ( errs.length > 1 ? ' 외' : '' ) + ')\n\n그래도 편집창에 적용할까요?' ) ) { return; }
		lst = serialize( S );
		text = $textbox().textSelection( 'getContents' );
		x = extract( text );
		if ( x ) {
			out = text.slice( 0, x.start ) + lst + text.slice( x.end );
		} else if ( mode === 'new' && !text.trim() ) {
			out = CFG.newPage.replace( '@@LST@@', function () { return lst; } );
		} else {
			out = '<pre>\n' + lst + '\n</pre>\n\n' + text;
		}
		$textbox().textSelection( 'setContents', out );
		$textbox().trigger( 'input' ).trigger( 'change' );
		$sum = $( '#wpSummary' );
		if ( $sum.length && !$sum.val().trim() ) { $sum.val( CFG.summary ); }
		close( true );
		mw.notify( '편집창에 적용했습니다. 위키에 반영하려면 [변경사항 저장]을 누르세요.', { type: 'success' } );
	}

	// [New Ontology]: 편집창에 빈 .lst 구조만 넣는다 (Class·Relation도 비움)
	function insertSkeleton() {
		var empty = blank(), text = $textbox().textSelection( 'getContents' ), lst, out;
		empty.classes = []; empty.relations = [];
		lst = serialize( empty );
		if ( extract( text ) ) {
			mw.notify( '이미 .lst 부분(#Project ~ #End)이 있어서 구조를 넣지 않았습니다.', { type: 'warn' } );
			return;
		}
		if ( isSectionEdit() ) {
			window.alert( '문단 편집 화면에서는 구조를 넣을 수 없습니다.\n문서 전체 편집 화면에서 사용하세요.' );
			return;
		}
		if ( !text.trim() ) {
			out = CFG.newPage.replace( '@@LST@@', function () { return lst; } );
		} else {
			if ( !window.confirm( '본문에 .lst 부분(#Project ~ #End)이 없습니다.\n문서 맨 앞에 구조를 넣을까요?' ) ) { return; }
			out = '<pre>\n' + lst + '\n</pre>\n\n' + text;
		}
		$textbox().textSelection( 'setContents', out );
		$textbox().trigger( 'input' ).trigger( 'change' );
		mw.notify( '.lst 구조를 넣었습니다.', { type: 'success' } );
	}

	/* ====================================================================
	 * DB Upload: 위키에 저장된 판을 API 서버로 보내 DB에 기록
	 * ================================================================== */
	var $up = null;
	var NAME_RE = /^[A-Za-z][A-Za-z0-9_]{0,100}$/, DB_RE = /^[A-Za-z0-9_.\-]{1,100}$/,
		SERVER_RE = /^[A-Za-z0-9][A-Za-z0-9.\-]{0,200}(:\d{1,5})?$/;

	// 본문에서 {{SemanticDBUpload|…}} 틀의 값을 읽는다. 없으면 null
	function readUploadTemplate( text ) {
		var re = new RegExp( '\\{\\{\\s*[' + CFG.uploadTemplate.charAt( 0 ).toUpperCase() + CFG.uploadTemplate.charAt( 0 ).toLowerCase() + ']' +
			CFG.uploadTemplate.slice( 1 ) + '\\s*((?:\\|[^{}]*)?)\\}\\}' ), m = re.exec( text ), o = {};
		if ( !m ) { return null; }
		m[ 1 ].split( '|' ).forEach( function ( part ) {
			var i = part.indexOf( '=' );
			if ( i > 0 ) { o[ part.slice( 0, i ).trim().toLowerCase() ] = part.slice( i + 1 ).trim(); }
		} );
		return { api: CFG.uploadApi, server: o.server || '', db: o.db || '', project: o.project || '' };
	}
	function keyStore( target, v ) {
		var k = 'lsted-upload-key-' + target;
		try {
			if ( v === undefined ) { return localStorage.getItem( k ) || ''; }
			if ( v ) { localStorage.setItem( k, v ); } else { localStorage.removeItem( k ); }
		} catch ( e ) {}
		return '';
	}
	// 저장하지 않은 변경이 있으면 안내 문구, 없으면 null
	function unsavedReason() {
		var tb = $textbox()[ 0 ], cur = $textbox().textSelection( 'getContents' );
		if ( !mw.config.get( 'wgArticleId' ) ) { return '아직 저장되지 않은 새 문서입니다. 먼저 [변경사항 저장]으로 문서를 저장하세요.'; }
		if ( mw.config.get( 'wgAction' ) === 'submit' ) { return '미리 보기나 차이 보기 중인 편집 화면입니다. 먼저 [변경사항 저장]으로 저장한 뒤 업로드하세요.'; }
		if ( mw.config.get( 'wgRevisionId' ) && mw.config.get( 'wgRevisionId' ) !== mw.config.get( 'wgCurRevisionId' ) ) {
			return '이전 판을 편집하고 있습니다. 최신 판에서 업로드하세요.';
		}
		if ( cur.replace( /\s+$/, '' ) !== String( tb.defaultValue ).replace( /\s+$/, '' ) ) {
			return '편집창에 저장하지 않은 변경이 있습니다. 먼저 [변경사항 저장]으로 저장한 뒤 업로드하세요.';
		}
		return null;
	}
	function upMsg( cls, html ) { $up.find( '.msg' ).attr( 'class', 'msg on ' + cls ).html( html ); }
	function closeUpload() {
		if ( !$up ) { return; }
		$( document ).off( 'keydown.lstedup' );
		$up.remove(); $up = null;
	}
	function upDialog( bodyHtml, footHtml ) {
		$up = $( '<div id="lsted-up"></div>' ).html(
			'<div class="box" role="dialog" aria-label="DB Upload">' +
			'<div class="hd"><b>DB Upload</b><button class="x" data-act="close" title="닫기 (Esc)">✕</button></div>' +
			'<div class="bd">' + bodyHtml + '<div class="msg"></div></div>' +
			'<div class="ft"><button class="b" data-act="close">닫기</button>' + footHtml + '</div></div>'
		).appendTo( document.body );
		$up.on( 'click', '[data-act=close]', closeUpload );
		$up.on( 'mousedown', function ( e ) { if ( e.target === $up[ 0 ] ) { closeUpload(); } } );
		$( document ).on( 'keydown.lstedup', function ( e ) { if ( e.key === 'Escape' ) { closeUpload(); } } );
	}

	function openUpload() {
		var user = mw.config.get( 'wgUserName' ), why, rev, title;
		if ( $up || $ov ) { return; }
		if ( !user ) { window.alert( '위키에 로그인한 사용자만 DB에 업로드할 수 있습니다.' ); return; }
		why = unsavedReason();
		if ( why ) { window.alert( why ); return; }
		rev = mw.config.get( 'wgCurRevisionId' );
		title = conf.wgPageName;
		// 서버가 읽을 것과 같은 '저장된 판'을 읽는다
		$.ajax( {
			url: mw.config.get( 'wgScript' ),
			data: { title: title, action: 'raw', oldid: rev },
			dataType: 'text',
			cache: false
		} ).then( function ( text ) {
			var U = readUploadTemplate( text );
			if ( !U || !SERVER_RE.test( U.server ) || !DB_RE.test( U.db ) || !NAME_RE.test( U.project ) ) {
				askTemplate( U );
			} else {
				uploadDialog( text, U, user, rev, title );
			}
		}, function () {
			window.alert( '저장된 문서를 읽지 못했습니다. 잠시 뒤 다시 시도하세요.' );
		} );
	}

	// 틀이 없거나 값이 잘못됐을 때: 값을 받아 편집창에 틀을 넣는다
	function askTemplate( U ) {
		var bad = !!U;
		upDialog( [
			'<p style="margin-top:0">' + ( bad ?
				'이 문서의 <code>{{' + CFG.uploadTemplate + '}}</code> 틀 값이 올바르지 않습니다.' :
				'이 문서에는 DB 업로드 설정(<code>{{' + CFG.uploadTemplate + '}}</code> 틀)이 없습니다.' ) +
			' 아래 값을 입력하면 편집창에 틀을 넣어 드립니다.</p>',
			'<label class="k" for="lsted-up-server">Server (DB 서버)</label>',
			'<input type="text" id="lsted-up-server" class="t" placeholder="예: kstorydata.visualasia.com">',
			'<label class="k" for="lsted-up-db">DB</label>',
			'<input type="text" id="lsted-up-db" class="t" placeholder="예: aiclassics2602">',
			'<label class="k" for="lsted-up-project">Project (테이블 접두어)</label>',
			'<input type="text" id="lsted-up-project" class="t" placeholder="예: Test → TestData, TestLinks, TestStory">',
			'<div class="hint">Project는 영문으로 시작하고 영문·숫자·밑줄만 쓸 수 있습니다. 틀을 넣은 뒤 [변경사항 저장]으로 저장하고 다시 [DB Upload]를 누르세요.</div>'
		].join( '' ), '<button class="b pri" data-act="insert">틀 넣기</button>' );
		if ( U ) { $up.find( '#lsted-up-server' ).val( U.server ); $up.find( '#lsted-up-db' ).val( U.db ); $up.find( '#lsted-up-project' ).val( U.project ); }
		$up.find( '#lsted-up-server' ).trigger( 'focus' );
		$up.on( 'click', '[data-act=insert]', function () {
			var server = $up.find( '#lsted-up-server' ).val().trim(), dbName = $up.find( '#lsted-up-db' ).val().trim(), project = $up.find( '#lsted-up-project' ).val().trim(),
				tpl, text, out, m;
			if ( !SERVER_RE.test( server ) ) { upMsg( 'err', 'Server(DB 서버 주소)를 입력하세요. 예: kstorydata.visualasia.com' ); return; }
			if ( !DB_RE.test( dbName ) ) { upMsg( 'err', 'DB 이름을 입력하세요. 영문·숫자·밑줄·점·하이픈만 쓸 수 있습니다.' ); return; }
			if ( !NAME_RE.test( project ) ) { upMsg( 'err', 'Project는 영문으로 시작하고 영문·숫자·밑줄만 쓸 수 있습니다.' ); return; }
			tpl = '{{' + CFG.uploadTemplate + '|server=' + server + '|db=' + dbName + '|project=' + project + '}}';
			text = $textbox().textSelection( 'getContents' );
			if ( readUploadTemplate( text ) ) {
				// 잘못된 기존 틀은 새 값으로 바꾼다
				out = text.replace( new RegExp( '\\{\\{\\s*' + CFG.uploadTemplate + '\\s*(?:\\|[^{}]*)?\\}\\}', 'i' ), function () { return tpl; } );
			} else {
				// 분류 줄이 있으면 그 앞에, 없으면 문서 끝에 넣는다
				m = /^\[\[(분류|Category):/im.exec( text );
				tpl = '== DB Upload ==\n' + tpl + '\n';
				out = m ? text.slice( 0, m.index ) + tpl + '\n' + text.slice( m.index ) : text.replace( /\s*$/, '' ) + '\n\n' + tpl;
			}
			$textbox().textSelection( 'setContents', out );
			$textbox().trigger( 'input' ).trigger( 'change' );
			closeUpload();
			mw.notify( '편집창에 {{' + CFG.uploadTemplate + '}} 틀을 넣었습니다. [변경사항 저장]으로 저장한 뒤 다시 DB Upload를 누르세요.', { type: 'success', autoHide: false } );
		} );
	}

	function uploadDialog( text, U, user, rev, title ) {
		var storyId = title.replace( /_/g, ' ' ).replace( /\.lst$/i, '' ), x = extract( text ), data, errs, saved;
		upDialog( [
			'<table>',
			'<tr><th>Story ID</th><td>' + esc( storyId ) + '</td></tr>',
			'<tr><th>Server</th><td>' + esc( U.server ) + '</td></tr>',
			'<tr><th>DB</th><td>' + esc( U.db ) + '</td></tr>',
			'<tr><th>Project</th><td>' + esc( U.project ) + ' <span style="color:#72777d">(' +
				esc( U.project ) + 'Data, ' + esc( U.project ) + 'Links, ' + esc( U.project ) + 'Story)</span></td></tr>',
			'<tr><th>작업자</th><td>' + esc( user ) + '</td></tr>',
			'<tr><th>업로드 대상</th><td data-cnt>-</td></tr>',
			'</table>',
			'<label class="k" for="lsted-up-key">업로드 키</label>',
			'<input type="password" id="lsted-up-key" autocomplete="off">',
			'<label class="rem"><input type="checkbox" data-remember> 이 브라우저에 키 기억하기</label>',
			'<div class="hint">이미 DB에 있는 노드(id)와 링크(source·target·relation)는 바꾸지 않고 새 항목만 추가합니다. ',
			'Story는 이 문서의 내용으로 새로 씁니다. Server, DB, Project는 문서의 <code>{{' + CFG.uploadTemplate + '}}</code> 틀에서 읽었습니다.</div>'
		].join( '' ), '<button class="b pri" data-act="send" disabled>업로드</button>' );

		saved = keyStore( U.server + '/' + U.db );
		$up.find( '#lsted-up-key' ).val( saved );
		$up.find( '[data-remember]' ).prop( 'checked', !!saved );

		if ( !x ) { upMsg( 'err', '저장된 문서에 .lst 부분(#Project ~ #End)이 없습니다.' ); return; }
		data = parse( x.body );
		errs = validate( data ).filter( function ( v ) { return v[ 0 ] === 'e'; } );
		$up.find( '[data-cnt]' ).text( '노드 ' + data.nodes.length + '개 · 링크 ' + data.links.length + '개 (저장된 판 ' + rev + ')' );
		if ( errs.length ) {
			upMsg( 'err', '검증 오류가 ' + errs.length + '건 있어 업로드할 수 없습니다. LST Editor에서 고친 뒤 저장하세요.<ul>' +
				errs.slice( 0, 10 ).map( function ( v ) { return '<li>' + esc( v[ 1 ] ) + '</li>'; } ).join( '' ) +
				( errs.length > 10 ? '<li>외 ' + ( errs.length - 10 ) + '건</li>' : '' ) + '</ul>' );
			return;
		}
		$up.find( '[data-act=send]' ).prop( 'disabled', false );
		$up.find( '#lsted-up-key' ).trigger( 'focus' );

		$up.on( 'keydown', '#lsted-up-key', function ( e ) {
			if ( e.key === 'Enter' ) { e.preventDefault(); $up.find( '[data-act=send]:enabled' ).trigger( 'click' ); }
		} );
		$up.on( 'click', '[data-act=send]', function () {
			var $btn = $( this ), key = $up.find( '#lsted-up-key' ).val().trim();
			if ( !key ) { upMsg( 'err', '업로드 키를 입력하세요.' ); $up.find( '#lsted-up-key' ).trigger( 'focus' ); return; }
			$btn.prop( 'disabled', true );
			upMsg( 'info', 'DB에 업로드하는 중…' );
			fetch( U.api, {
				method: 'POST',
				headers: { 'Content-Type': 'application/json' },
				body: JSON.stringify( {
					server: U.server, db: U.db, project: U.project,
					wiki: location.protocol + '//' + location.host + mw.config.get( 'wgScript' ),
					title: title, revid: rev, user: user, key: key
				} )
			} ).then( function ( r ) {
				return r.json().catch( function () { return {}; } ).then( function ( j ) { return { status: r.status, body: j }; } );
			} ).then( function ( r ) {
				if ( !$up ) { return; }
				var b = r.body, d = b.detail || {};
				if ( r.status === 200 && b.ok ) {
					keyStore( U.server + '/' + U.db, $up.find( '[data-remember]' ).prop( 'checked' ) ? key : '' );
					upMsg( 'ok', '<b>업로드했습니다.</b><ul>' +
						'<li>' + esc( b.tables.Data ) + ': 새로 추가 ' + b.data.inserted + '개, 이미 있어 그대로 둠 ' + b.data.skipped + '개</li>' +
						'<li>' + esc( b.tables.Links ) + ': 새로 추가 ' + b.links.inserted + '개, 이미 있어 그대로 둠 ' + b.links.skipped + '개</li>' +
						'<li>' + esc( b.tables.Story ) + ': 기존 ' + b.story.deleted + '행을 지우고 ' + b.story.inserted + '행 기록</li></ul>' );
					$up.find( '[data-act=send]' ).remove();
					return;
				}
				if ( r.status === 403 ) { keyStore( U.server + '/' + U.db, '' ); }
				upMsg( 'err', esc( d.message || ( '업로드하지 못했습니다 (HTTP ' + r.status + ').' ) ) +
					( d.errors && d.errors.length ? '<ul>' + d.errors.slice( 0, 10 ).map( function ( v ) { return '<li>' + esc( v ) + '</li>'; } ).join( '' ) + '</ul>' : '' ) );
				$btn.prop( 'disabled', false );
			}, function () {
				if ( !$up ) { return; }
				upMsg( 'err', '업로드 서버에 연결하지 못했습니다(' + esc( U.api ) + '). 네트워크 상태를 확인하거나 관리자에게 문의하세요.' );
				$btn.prop( 'disabled', false );
			} );
		} );
	}

	/* ====================================================================
	 * 버튼 설치
	 * ================================================================== */
	var installed = false;
	function makeBtn( icon, text, action ) {
		var $a = $( '<a>' ).attr( { href: '#', role: 'button', title: text } )
			.addClass( 'lsted-tbbtn' )
			.on( 'click', function ( e ) { e.preventDefault(); action(); } );
		$( '<img>' ).attr( { src: icon, alt: '' } )
			.on( 'error', function () { $( this ).remove(); } )
			.appendTo( $a );
		$( '<span>' ).text( text ).appendTo( $a );
		return $a;
	}
	function btnSkeleton() { return makeBtn( CFG.iconSkeleton, 'New Ontology', insertSkeleton ); }
	function btnEditor() { return makeBtn( CFG.iconEditor, 'LST Editor', open ); }
	function btnUpload() { return makeBtn( CFG.iconUpload, 'DB Upload', openUpload ); }
	function shown( name ) { return !CFG.buttons || CFG.buttons[ name ] !== false; }
	function anyShown() { return shown( 'newOntology' ) || shown( 'lstEditor' ) || shown( 'dbUpload' ); }

	function addToWikiEditor( $textarea ) {
		if ( installed ) { return; }
		var tools = {};
		if ( shown( 'newOntology' ) ) { tools.skeleton = { label: 'New Ontology', type: 'element', element: btnSkeleton }; }
		if ( shown( 'lstEditor' ) ) { tools.editor = { label: 'LST Editor', type: 'element', element: btnEditor }; }
		if ( shown( 'dbUpload' ) ) { tools.upload = { label: 'DB Upload', type: 'element', element: btnUpload }; }
		try {
			// 도구 모음에 '시맨틱 큐레이션' 탭(>고급, >특수 문자와 같은 형식) 추가
			$textarea.wikiEditor( 'addToToolbar', {
				sections: {
					semantic: { type: 'toolbar', label: CFG.toolbarLabel }
				}
			} );
			$textarea.wikiEditor( 'addToToolbar', {
				section: 'semantic',
				groups: {
					lst: { tools: tools }
				}
			} );
			installed = $( '.lsted-tbbtn' ).length > 0;
		} catch ( e ) {
			installed = false;
		}
		if ( !installed ) { addFallback(); }
	}
	function addFallback() {
		if ( installed || !$textbox().length ) { return; }
		var $bar = $( '<div class="lsted-fallbar"></div>' );
		if ( shown( 'newOntology' ) ) { $bar.append( btnSkeleton(), ' ' ); }
		if ( shown( 'lstEditor' ) ) { $bar.append( btnEditor(), ' ' ); }
		if ( shown( 'dbUpload' ) ) { $bar.append( btnUpload() ); }
		$bar.insertBefore( $textbox() );
		installed = true;
	}

	mw.loader.using( [ 'jquery.textSelection', 'mediawiki.util' ] ).then( function () {
		mw.util.addCSS( CSS );
		$( function () {
			if ( !$textbox().length || !anyShown() ) { return; }
			if ( mw.loader.getState( 'ext.wikiEditor' ) ) {
				mw.hook( 'wikiEditor.toolbarReady' ).add( addToWikiEditor );
				// 개인 설정에서 도구 모음을 끈 경우 등: 3초 안에 도구 모음이 안 뜨면 편집창 위에 버튼 생성
				setTimeout( addFallback, 3000 );
			} else {
				addFallback();
			}
		} );
	} );

	// 테스트용 노출
	window.SemanticCuration = { parse: parse, serialize: serialize, validate: validate, extract: extract, readImport: readImport, open: open, insertSkeleton: insertSkeleton, openUpload: openUpload };
}() );