본문으로 이동

미디어위키:AutoCreate.js

Conaonda Wiki
Admina7ec671ac513 (토론 | 기여)님의 2026년 8월 20일 (목) 13:19 판 (migrate: import legacy Wiki content to Vultr)
(차이) ← 이전 판 | 최신판 (차이) | 다음 판 → (차이)

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

  • 파이어폭스 / 사파리: 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 || '알 수 없는 오류입니다.' );
    } );
  }
  }
} )();