미디어위키:AutoCreate.js
외관
참고: 설정을 저장한 후에 바뀐 점을 확인하기 위해서는 브라우저의 캐시를 새로 고쳐야 합니다.
- 파이어폭스 / 사파리: Shift 키를 누르면서 새로 고침을 클릭하거나, Ctrl-F5 또는 Ctrl-R을 입력 (Mac에서는 ⌘-R)
- 구글 크롬: Ctrl-Shift-R키를 입력 (Mac에서는 ⌘-Shift-R)
- 엣지: Ctrl 키를 누르면서 새로 고침을 클릭하거나, Ctrl-F5를 입력.
/* Conaonda Wiki - 텍스트 선택 자동 문서 생성 (MediaWiki:AutoCreate.js) */
/*
* 위키 페이지에서 텍스트를 선택하면 X(트위터) 모바일처럼 플로팅 메뉴가
* 나타난다. '페이지 자동 생성'을 누르면 Worker(/api/generate)가 OpenCode Zen으로
* 위키텍스트 초안을 생성하고, 'Conaonda Wiki:초안/<키워드>' 문서로 저장한다.
* '승인 · 본문으로 이동' 버튼으로 본문 문서로 이동(history 보존)할 수 있다.
* 로그인 사용자만 동작한다.
*/
( function () {
if ( !window.mw || !mw.config ) return;
if ( !mw.config.get( 'wgUserName' ) ) return;
if ( !mw.loader || !mw.loader.using ) return;
mw.loader.using( 'mediawiki.util' ).then( function () { start(); } );
function start() {
var API = mw.config.get( 'wgScriptPath' ) + '/api.php';
var MAX_TITLE = 60;
/* ---------- DOM helpers ---------- */
function el( tag, className, text ) {
var node = document.createElement( tag );
if ( className ) node.className = className;
if ( text !== undefined ) node.textContent = text;
return node;
}
function css( node, props ) {
for ( var key in props ) node.style[key] = props[key];
}
var MENU_STYLE = {
position: 'fixed', zIndex: 99999, background: '#fff', color: '#202122',
border: '1px solid #a2a9b1', borderRadius: '4px',
boxShadow: '0 2px 8px rgba(0,0,0,.25)', padding: '4px 0',
fontFamily: 'inherit', fontSize: '13px', minWidth: '160px'
};
var ITEM_STYLE = { padding: '7px 14px', cursor: 'pointer', whiteSpace: 'nowrap' };
var OVERLAY_STYLE = {
position: 'fixed', top: '0', left: '0', width: '100%', height: '100%',
background: 'rgba(0,0,0,.45)', zIndex: 100000,
display: 'flex', alignItems: 'center', justifyContent: 'center'
};
var PANEL_STYLE = {
background: '#fff', color: '#202122', borderRadius: '6px', padding: '20px 24px',
maxWidth: '440px', width: 'calc(100% - 48px)',
boxShadow: '0 4px 16px rgba(0,0,0,.3)', fontSize: '14px', lineHeight: '1.6'
};
var menuEl = null;
var overlayEl = null;
/* ---------- 선택 감지와 플로팅 메뉴 ---------- */
function isEditable( node ) {
while ( node && node.nodeType === 1 ) {
var tag = node.tagName.toLowerCase();
if ( tag === 'input' || tag === 'textarea' || node.isContentEditable ) return true;
node = node.parentNode;
}
return false;
}
function selectionInfo() {
var sel = window.getSelection();
if ( !sel || sel.isCollapsed || !sel.rangeCount ) return null;
if ( isEditable( sel.anchorNode ) || isEditable( sel.focusNode ) ) return null;
var text = sel.toString().replace( /\s+/g, ' ' ).trim();
if ( text.length < 2 || text.length > MAX_TITLE ) return null;
return { text: text, rect: sel.getRangeAt( 0 ).getBoundingClientRect() };
}
function hideMenu() {
if ( menuEl ) { menuEl.remove(); menuEl = null; }
}
function showMenu( info ) {
hideMenu();
menuEl = el( 'div', 'cx-gen-menu' );
css( menuEl, MENU_STYLE );
var item = el( 'div', 'cx-gen-item', '⚡ 페이지 자동 생성' );
css( item, ITEM_STYLE );
item.addEventListener( 'mouseenter', function () { item.style.background = '#eaf3ff'; } );
item.addEventListener( 'mouseleave', function () { item.style.background = ''; } );
item.addEventListener( 'click', function ( e ) {
e.preventDefault();
e.stopPropagation();
var keep = info.text;
hideMenu();
startGenerate( keep );
} );
menuEl.appendChild( item );
document.body.appendChild( menuEl );
var x = info.rect.left;
var y = ( info.rect.bottom || info.rect.top ) + 6;
var width = menuEl.offsetWidth;
var height = menuEl.offsetHeight;
if ( x + width > window.innerWidth - 8 ) x = Math.max( 8, window.innerWidth - width - 8 );
if ( y + height > window.innerHeight - 8 ) y = Math.max( 8, ( info.rect.top || info.rect.bottom ) - height - 6 );
menuEl.style.left = Math.max( 8, x ) + 'px';
menuEl.style.top = Math.max( 8, y ) + 'px';
}
function onSelection() {
var info = selectionInfo();
if ( !info ) { hideMenu(); return; }
showMenu( info );
}
document.addEventListener( 'mouseup', onSelection );
document.addEventListener( 'touchend', function () { setTimeout( onSelection, 0 ); } );
document.addEventListener( 'scroll', hideMenu, true );
window.addEventListener( 'resize', hideMenu );
document.addEventListener( 'mousedown', function ( e ) {
if ( menuEl && !menuEl.contains( e.target ) ) hideMenu();
} );
/* ---------- 오버레이/결과 UI ---------- */
function hideOverlay() {
if ( overlayEl ) { overlayEl.remove(); overlayEl = null; }
}
function showOverlay( inner ) {
hideOverlay();
overlayEl = el( 'div', 'cx-gen-overlay' );
css( overlayEl, OVERLAY_STYLE );
var panel = el( 'div', 'cx-gen-panel' );
css( panel, PANEL_STYLE );
panel.appendChild( inner );
overlayEl.appendChild( panel );
document.body.appendChild( overlayEl );
}
function styleButton( button, primary ) {
css( button, {
border: primary ? '1px solid #36c' : '1px solid #a2a9b1',
background: primary ? '#36c' : '#f8f9fa', color: primary ? '#fff' : '#202122',
borderRadius: '2px', padding: '6px 14px', cursor: 'pointer', fontSize: '13px'
} );
}
function showGenerating( title ) {
var box = el( 'div' );
var spinner = el( 'div', 'cx-gen-spinner', '⏳' );
css( spinner, { fontSize: '28px', textAlign: 'center', marginBottom: '10px' } );
var msg = el( 'div', null, '「' + title + '」 문서를 생성하는 중입니다...' );
css( msg, { textAlign: 'center', fontWeight: '600' } );
var sub = el( 'div', null, '완료되면 초안을 확인하거나 승인할 수 있습니다.' );
css( sub, { textAlign: 'center', color: '#72777d', fontSize: '12px', marginTop: '6px' } );
box.appendChild( spinner );
box.appendChild( msg );
box.appendChild( sub );
showOverlay( box );
}
function showError( message ) {
var box = el( 'div' );
var msg = el( 'div', null, '❌ ' + message );
css( msg, { color: '#b32424', fontWeight: '600', marginBottom: '14px' } );
var close = el( 'button', null, '닫기' );
styleButton( close, false );
close.addEventListener( 'click', hideOverlay );
box.appendChild( msg );
box.appendChild( close );
showOverlay( box );
}
function showExists( urlOrTitle ) {
var box = el( 'div' );
var msg = el( 'div', null, '이미 작성된 문서입니다.' );
css( msg, { fontWeight: '700', marginBottom: '14px' } );
var open = el( 'button', null, '문서 열기' );
styleButton( open, true );
open.addEventListener( 'click', function () {
location.href = /^https?:/.test( urlOrTitle ) ? urlOrTitle : mw.util.getUrl( urlOrTitle );
} );
var close = el( 'button', null, '닫기' );
styleButton( close, false );
close.addEventListener( 'click', hideOverlay );
box.appendChild( msg );
box.appendChild( open );
box.appendChild( close );
showOverlay( box );
}
function showResult( info ) {
var box = el( 'div' );
var head = el( 'div', null, '✅ 초안이 생성되었습니다.' );
css( head, { fontWeight: '700', marginBottom: '6px' } );
var sub = el( 'div', null, '「' + info.title + '」 초안: ' + info.draftTitle );
css( sub, { color: '#54595d', fontSize: '12px', marginBottom: '16px', wordBreak: 'break-all' } );
var row = el( 'div' );
css( row, { display: 'flex', gap: '8px', flexWrap: 'wrap' } );
var draftBtn = el( 'button', null, '초안 보기' );
styleButton( draftBtn, false );
draftBtn.addEventListener( 'click', function () { location.href = mw.util.getUrl( info.draftTitle ); } );
var approveBtn = el( 'button', null, '승인 · 본문으로 이동' );
styleButton( approveBtn, true );
approveBtn.addEventListener( 'click', function () { approve( info ); } );
var close = el( 'button', null, '닫기' );
styleButton( close, false );
close.addEventListener( 'click', hideOverlay );
row.appendChild( draftBtn );
row.appendChild( approveBtn );
row.appendChild( close );
box.appendChild( head );
box.appendChild( sub );
box.appendChild( row );
showOverlay( box );
}
function showApproved( title ) {
var box = el( 'div' );
var msg = el( 'div', null, '✅ 「' + title + '」 문서로 게시되었습니다.' );
css( msg, { fontWeight: '700', marginBottom: '14px' } );
var open = el( 'button', null, '문서 열기' );
styleButton( open, true );
open.addEventListener( 'click', function () { location.href = mw.util.getUrl( title ); } );
var close = el( 'button', null, '닫기' );
styleButton( close, false );
close.addEventListener( 'click', hideOverlay );
box.appendChild( msg );
box.appendChild( open );
box.appendChild( close );
showOverlay( box );
}
/* ---------- API 호출 ---------- */
function getToken( type ) {
return fetch( API + '?action=query&meta=tokens&type=' + type + '&format=json&formatversion=2' )
.then( function ( r ) { return r.json(); } )
.then( function ( d ) {
var token = d.query && d.query.tokens && d.query.tokens[ type + 'token' ];
if ( !token ) throw new Error( '인증 토큰을 받지 못했습니다.' );
return token;
} );
}
function createDraft( info ) {
return getToken( 'csrf' ).then( function ( token ) {
var body = new URLSearchParams();
body.set( 'action', 'edit' );
body.set( 'title', info.draftTitle );
body.set( 'text', info.wikitext );
body.set( 'token', token );
body.set( 'summary', '자동 생성 초안 (AI)' );
body.set( 'watchlist', 'nochange' );
body.set( 'format', 'json' );
body.set( 'formatversion', '2' );
return fetch( API + '?action=edit&format=json&formatversion=2', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body
} ).then( function ( r ) { return r.json(); } );
} ).then( function ( d ) {
if ( d.error ) throw new Error( d.error.info || d.error.code );
if ( !d.edit || d.edit.result !== 'Success' ) throw new Error( '초안 저장에 실패했습니다.' );
return info;
} );
}
function approve( info ) {
var box = el( 'div', null, '본문 문서로 이동하는 중...' );
css( box, { fontWeight: '600' } );
showOverlay( box );
getToken( 'csrf' ).then( function ( token ) {
var body = new URLSearchParams();
body.set( 'action', 'move' );
body.set( 'from', info.draftTitle );
body.set( 'to', info.title );
body.set( 'token', token );
body.set( 'reason', '자동 생성 초안 승인' );
body.set( 'suppressredirect', '1' );
body.set( 'format', 'json' );
body.set( 'formatversion', '2' );
return fetch( API + '?action=move&format=json&formatversion=2', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body
} ).then( function ( r ) { return r.json(); } );
} ).then( function ( d ) {
if ( d.error ) throw new Error( d.error.info || d.error.code );
if ( !d.move || d.move.from !== info.draftTitle ) throw new Error( '이동 결과를 확인할 수 없습니다.' );
showApproved( info.title );
} ).catch( function ( e ) {
showError( '승인 실패: ' + ( e.message || '알 수 없는 오류' ) );
} );
}
function startGenerate( title ) {
showGenerating( title );
fetch( '/api/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Auto-Create': '1' },
body: JSON.stringify( { title: title } )
} ).then( function ( r ) {
return r.json().then( function ( j ) { return { status: r.status, body: j }; } );
} ).then( function ( res ) {
if ( res.status === 200 && res.body.ok ) return createDraft( res.body );
if ( res.status === 401 ) throw new Error( '로그인이 필요합니다.' );
if ( res.status === 409 ) { showExists( res.body.url || res.body.title ); return null; }
if ( res.status === 429 ) throw new Error( '요청이 너무 잦습니다. 잠시 후 다시 시도하세요.' );
if ( res.status === 400 ) throw new Error( '사용할 수 없는 제목입니다.' );
if ( res.status === 503 ) throw new Error( '자동 생성 기능이 아직 설정되지 않았습니다.' );
throw new Error( '생성에 실패했습니다. (' + res.status + ')' );
} ).then( function ( info ) {
if ( info ) showResult( info );
} ).catch( function ( e ) {
showError( e.message || '알 수 없는 오류입니다.' );
} );
}
}
} )();