wordpressカテゴリ整理PHP 公開!

プラグインフォルダにコピペして使ってください!

<?php
/**
 * Plugin Name: Bulk Category Parent Setter
 * Description: ファイルマネージャー風ドラッグ&ドロップ+追加・名前変更・削除
 * Version: 3.0.0
 * Author: Custom
 */

if (!defined('ABSPATH')) exit;

class Bulk_Category_Parent_Setter {

    public function __construct() {
        add_action('admin_menu', [$this, 'add_menu']);
        add_action('wp_ajax_bcps_save',   [$this, 'ajax_save']);
        add_action('wp_ajax_bcps_rename', [$this, 'ajax_rename']);
        add_action('wp_ajax_bcps_add',    [$this, 'ajax_add']);
        add_action('wp_ajax_bcps_delete', [$this, 'ajax_delete']);
    }

    public function add_menu() {
        add_menu_page(
            'カテゴリマネージャー', '⚡ カテゴリ整理',
            'manage_categories', 'bulk-category-parent',
            [$this, 'render_page'], 'dashicons-category', 26
        );
    }

    public function render_page() {
        $all_cats = get_terms([
            'taxonomy' => 'category', 'hide_empty' => false,
            'orderby' => 'name', 'order' => 'ASC', 'number' => 0,
        ]);
        if (is_wp_error($all_cats)) {
            echo '<div class="notice notice-error"><p>カテゴリの取得に失敗しました。</p></div>'; return;
        }
        $nonce     = wp_create_nonce('bcps_nonce');
        $cats_json = json_encode(array_values(array_map(fn($c) => [
            'id' => (int)$c->term_id, 'name' => $c->name,
            'parent' => (int)$c->parent, 'count' => (int)$c->count, 'slug' => $c->slug,
        ], $all_cats)));
        ?>
        <div id="bcps-app">

            <div class="bcps-toolbar">
                <div class="bcps-toolbar-left">
                    <span class="bcps-logo">📂</span>
                    <span class="bcps-title">カテゴリマネージャー</span>
                    <span class="bcps-divider">|</span>
                    <span id="bcps-breadcrumb" class="bcps-breadcrumb">ルート</span>
                </div>
                <div class="bcps-toolbar-right">
                    <div class="bcps-search-wrap">
                        <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
                        <input type="text" id="bcps-search" placeholder="検索...">
                    </div>
                    <button class="bcps-btn bcps-btn-green" id="bcps-add-btn">
                        <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
                        新規追加
                    </button>
                    <button class="bcps-btn bcps-btn-secondary" id="bcps-undo-btn" disabled>
                        <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 14 4 9 9 4"/><path d="M20 20v-7a4 4 0 0 0-4-4H4"/></svg>
                        元に戻す
                    </button>
                    <button class="bcps-btn bcps-btn-danger" id="bcps-reset-btn">リセット</button>
                    <button class="bcps-btn bcps-btn-primary" id="bcps-save-btn" disabled>
                        <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
                        保存 <span id="bcps-change-badge" class="bcps-badge" style="display:none">0</span>
                    </button>
                </div>
            </div>

            <div class="bcps-main">
                <div class="bcps-sidebar">
                    <div class="bcps-sidebar-header">フォルダツリー</div>
                    <div id="bcps-tree-nav"></div>
                </div>
                <div class="bcps-pane">
                    <div class="bcps-pane-header">
                        <div class="bcps-pane-path">
                            <span class="bcps-path-icon">🏠</span>
                            <span id="bcps-path-label">ルート</span>
                        </div>
                        <div class="bcps-view-toggle">
                            <button class="bcps-view-btn active" id="bcps-view-grid" title="グリッド">
                                <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
                            </button>
                            <button class="bcps-view-btn" id="bcps-view-list" title="リスト">
                                <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>
                            </button>
                        </div>
                    </div>
                    <div class="bcps-drop-zone" id="bcps-drop-root">
                        <div class="bcps-drop-hint">ここにドロップ → 親なし(ルート)へ移動</div>
                    </div>
                    <div class="bcps-files" id="bcps-files"></div>
                </div>
            </div>

            <div id="bcps-notice" style="display:none;"></div>
            <div id="bcps-ghost" style="display:none;"><span>📁</span><span id="bcps-ghost-label"></span></div>

            <!-- コンテキストメニュー -->
            <div id="bcps-ctx" style="display:none;">
                <div class="ctx-item" id="ctx-rename">✏️ 名前を変更</div>
                <div class="ctx-item" id="ctx-add-child">📁 子カテゴリを追加</div>
                <div class="ctx-sep"></div>
                <div class="ctx-item ctx-danger" id="ctx-delete">🗑️ 削除</div>
            </div>

            <!-- モーダル -->
            <div id="bcps-overlay" style="display:none;">
                <div id="bcps-modal">
                    <div id="bcps-modal-title"></div>
                    <input type="text" id="bcps-modal-input" placeholder="カテゴリ名を入力">
                    <div id="bcps-modal-btns">
                        <button class="bcps-btn bcps-btn-secondary" id="bcps-modal-cancel">キャンセル</button>
                        <button class="bcps-btn bcps-btn-primary"   id="bcps-modal-ok">OK</button>
                    </div>
                </div>
            </div>
        </div>

        <style>
        *{box-sizing:border-box;}
        #bcps-app{font-family:'Hiragino Sans','Meiryo',-apple-system,sans-serif;background:#0f1117;color:#e2e8f0;min-height:calc(100vh - 32px);margin:-10px -20px 0;display:flex;flex-direction:column;}
        .bcps-toolbar{display:flex;align-items:center;justify-content:space-between;padding:10px 18px;background:#161b27;border-bottom:1px solid #2d3748;gap:12px;flex-wrap:wrap;}
        .bcps-toolbar-left{display:flex;align-items:center;gap:10px;}
        .bcps-toolbar-right{display:flex;align-items:center;gap:8px;flex-wrap:wrap;}
        .bcps-logo{font-size:20px;}.bcps-title{font-weight:700;font-size:15px;color:#fff;}.bcps-divider{color:#4a5568;}.bcps-breadcrumb{font-size:12px;color:#718096;}
        .bcps-search-wrap{display:flex;align-items:center;gap:6px;background:#1a2035;border:1px solid #2d3748;border-radius:6px;padding:5px 10px;color:#718096;}
        #bcps-search{background:none;border:none;outline:none;color:#e2e8f0;font-size:13px;width:140px;}
        #bcps-search::placeholder{color:#4a5568;}
        .bcps-btn{display:flex;align-items:center;gap:6px;padding:6px 14px;border-radius:6px;border:none;font-size:13px;font-weight:500;cursor:pointer;transition:all .15s;}
        .bcps-btn-primary{background:#3b82f6;color:#fff;}.bcps-btn-primary:hover:not(:disabled){background:#2563eb;}.bcps-btn-primary:disabled{background:#1e3a5f;color:#4a5568;cursor:not-allowed;}
        .bcps-btn-secondary{background:#1e2a3a;color:#94a3b8;border:1px solid #2d3748;}.bcps-btn-secondary:hover:not(:disabled){background:#263447;}.bcps-btn-secondary:disabled{opacity:.4;cursor:not-allowed;}
        .bcps-btn-danger{background:#1e1a2e;color:#f87171;border:1px solid #3d1a1a;}.bcps-btn-danger:hover{background:#2d1a1a;}
        .bcps-btn-green{background:#064e3b;color:#6ee7b7;border:1px solid #065f46;}.bcps-btn-green:hover{background:#065f46;}
        .bcps-badge{background:#fff;color:#2563eb;border-radius:10px;padding:1px 7px;font-size:11px;font-weight:700;}
        .bcps-main{display:flex;flex:1;overflow:hidden;min-height:600px;}
        .bcps-sidebar{width:220px;flex-shrink:0;background:#0d1120;border-right:1px solid #1e2a3a;overflow-y:auto;}
        .bcps-sidebar-header{font-size:10px;font-weight:700;letter-spacing:1px;color:#4a5568;text-transform:uppercase;padding:14px 14px 6px;}
        .bcps-tree-item{display:flex;align-items:center;gap:6px;padding:6px 10px 6px 12px;cursor:pointer;font-size:13px;color:#94a3b8;border-radius:4px;margin:1px 6px;transition:all .12s;user-select:none;}
        .bcps-tree-item:hover{background:#1a2035;color:#e2e8f0;}.bcps-tree-item.active{background:#1e3a5f;color:#60a5fa;}
        .bcps-tree-item .ti-toggle{width:14px;font-size:10px;color:#4a5568;flex-shrink:0;text-align:center;}
        .bcps-tree-item .ti-icon{font-size:13px;flex-shrink:0;}.bcps-tree-item .ti-label{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
        .bcps-tree-children{display:none;}.bcps-tree-children.open{display:block;}
        .bcps-tree-item.drag-over{background:#1e3a5f !important;outline:1px dashed #3b82f6;}
        .bcps-pane{flex:1;display:flex;flex-direction:column;overflow:hidden;}
        .bcps-pane-header{display:flex;align-items:center;justify-content:space-between;padding:10px 16px;background:#0f1117;border-bottom:1px solid #1e2a3a;}
        .bcps-pane-path{display:flex;align-items:center;gap:8px;font-size:13px;color:#94a3b8;}.bcps-path-icon{font-size:15px;}
        .bcps-view-toggle{display:flex;gap:4px;}
        .bcps-view-btn{width:28px;height:28px;display:flex;align-items:center;justify-content:center;background:#1a2035;border:1px solid #2d3748;border-radius:5px;color:#4a5568;cursor:pointer;transition:all .12s;}
        .bcps-view-btn.active,.bcps-view-btn:hover{background:#1e3a5f;color:#60a5fa;border-color:#3b82f6;}
        .bcps-drop-zone{border:2px dashed transparent;border-radius:8px;margin:8px 12px 0;transition:all .15s;min-height:4px;}
        .bcps-drop-zone.drag-over{border-color:#3b82f6;background:rgba(59,130,246,.07);}
        .bcps-drop-hint{display:none;text-align:center;padding:10px;font-size:12px;color:#4a5568;}
        .bcps-drop-zone.drag-over .bcps-drop-hint{display:block;color:#3b82f6;}
        .bcps-files{flex:1;overflow-y:auto;padding:10px 12px 20px;display:flex;flex-wrap:wrap;gap:8px;align-content:flex-start;}
        .bcps-files.list-view{flex-direction:column;gap:2px;}
        .bcps-item{background:#161b27;border:1px solid #1e2a3a;border-radius:8px;padding:12px 10px 10px;width:110px;cursor:grab;user-select:none;transition:all .15s;position:relative;display:flex;flex-direction:column;align-items:center;gap:6px;text-align:center;}
        .bcps-item:hover{background:#1a2035;border-color:#2d3a50;transform:translateY(-1px);box-shadow:0 4px 12px rgba(0,0,0,.3);}
        .bcps-item.dragging{opacity:.35;transform:scale(.96);}
        .bcps-item.changed{border-color:#d97706;background:#1a1508;}
        .bcps-item.changed::after{content:'●';position:absolute;top:5px;right:7px;font-size:8px;color:#f59e0b;}
        .bcps-item.is-new{border-color:#10b981;background:#022c22;}
        .bcps-item.is-new::after{content:'NEW';position:absolute;top:5px;right:5px;font-size:8px;color:#10b981;font-weight:700;}
        .bcps-item.drop-target{border-color:#3b82f6 !important;background:#0e1f3d !important;box-shadow:0 0 0 2px #3b82f6;}
        .bcps-item-icon{font-size:32px;line-height:1;}
        .bcps-item-name{font-size:11px;color:#cbd5e0;line-height:1.3;word-break:break-all;max-width:100%;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;}
        .bcps-files.list-view .bcps-item{flex-direction:row;align-items:center;width:100%;padding:8px 12px;text-align:left;gap:10px;border-radius:5px;}
        .bcps-files.list-view .bcps-item-icon{font-size:18px;}
        .bcps-files.list-view .bcps-item-name{flex:1;font-size:13px;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
        .bcps-empty{width:100%;text-align:center;padding:60px 20px;color:#2d3748;font-size:14px;}
        .bcps-empty-icon{font-size:48px;margin-bottom:10px;display:block;}
        #bcps-ghost{position:fixed;pointer-events:none;z-index:9999;background:#1e3a5f;border:1px solid #3b82f6;border-radius:8px;padding:8px 14px;display:flex;align-items:center;gap:8px;font-size:13px;color:#e2e8f0;box-shadow:0 8px 24px rgba(0,0,0,.5);transform:translate(-50%,-50%);}
        #bcps-ctx{position:fixed;z-index:99999;background:#1e2a3a;border:1px solid #2d3748;border-radius:8px;padding:6px 0;min-width:180px;box-shadow:0 8px 24px rgba(0,0,0,.5);}
        .ctx-item{padding:8px 16px;font-size:13px;color:#cbd5e0;cursor:pointer;transition:background .1s;}.ctx-item:hover{background:#263447;}.ctx-danger{color:#f87171;}
        .ctx-sep{border-top:1px solid #2d3748;margin:4px 0;}
        #bcps-overlay{position:fixed;inset:0;background:rgba(0,0,0,.6);display:flex;align-items:center;justify-content:center;z-index:999999;}
        #bcps-modal{background:#1e2a3a;border:1px solid #2d3748;border-radius:12px;padding:24px;width:340px;display:flex;flex-direction:column;gap:14px;}
        #bcps-modal-title{font-size:15px;font-weight:700;color:#fff;}
        #bcps-modal-input{background:#0d1120;border:1px solid #2d3748;border-radius:6px;padding:9px 12px;color:#e2e8f0;font-size:14px;outline:none;}
        #bcps-modal-input:focus{border-color:#3b82f6;}
        #bcps-modal-btns{display:flex;gap:8px;justify-content:flex-end;}
        #bcps-notice{position:fixed;bottom:24px;right:24px;padding:12px 20px;border-radius:10px;font-size:13px;font-weight:600;z-index:99999;box-shadow:0 8px 24px rgba(0,0,0,.4);}
        #bcps-notice.success{background:#064e3b;color:#6ee7b7;border:1px solid #065f46;}
        #bcps-notice.error{background:#450a0a;color:#fca5a5;border:1px solid #7f1d1d;}
        ::-webkit-scrollbar{width:5px;height:5px;}::-webkit-scrollbar-track{background:#0d1120;}::-webkit-scrollbar-thumb{background:#2d3748;border-radius:3px;}
        @keyframes spin{to{transform:rotate(360deg);}}
        </style>

        <script>
        (function(){
            const NONCE   = '<?= $nonce ?>';
            const rawCats = <?= $cats_json ?>;

            let cats          = rawCats.map(c=>({...c}));
            const original    = rawCats.map(c=>({...c}));
            let history       = [];
            let currentParent = 0;
            let viewMode      = 'grid';
            let dragItem      = null;
            let searchQ       = '';
            let ctxTargetId   = null;
            let tmpIdCounter  = -1;

            const getCat      = id => cats.find(c=>c.id===id);
            const getOrig     = id => original.find(c=>c.id===id);
            const childrenOf  = pid => cats.filter(c=>c.parent===pid);
            const hasChildren = id  => cats.some(c=>c.parent===id);
            const isNew       = id  => id < 0;
            const isChanged   = id  => {
                if(isNew(id)) return false;
                const c=getCat(id), o=getOrig(id);
                return c && o && (o.parent!==c.parent || o.name!==c.name);
            };
            const changedCount = () => cats.filter(c=>isNew(c.id)||isChanged(c.id)).length;

            function isDescendant(maybeDesc, ancestorId){
                let cur=getCat(maybeDesc);
                while(cur && cur.parent!==0){ if(cur.parent===ancestorId)return true; cur=getCat(cur.parent); }
                return false;
            }

            function moveItem(id, newParent){
                if(id===newParent)return false;
                if(isDescendant(newParent,id)){ showNotice('❌ 循環参照になるため移動できません','error'); return false; }
                const cat=getCat(id);
                if(cat.parent===newParent)return false;
                history.push({type:'move',id,oldParent:cat.parent,newParent});
                cat.parent=newParent; return true;
            }

            // ---- SIDEBAR ----
            function renderSidebar(){
                const nav=document.getElementById('bcps-tree-nav');
                nav.innerHTML='';
                nav.appendChild(makeSidebarItem(0,'🏠','ルート',0));
                renderSidebarChildren(nav,0,1);
            }
            function renderSidebarChildren(container,pid,depth){
                childrenOf(pid).filter(c=>hasChildren(c.id)).forEach(c=>{
                    container.appendChild(makeSidebarItem(c.id,'📁',c.name,depth));
                    const sub=document.createElement('div');
                    sub.className='bcps-tree-children'; sub.id='tcsub-'+c.id;
                    renderSidebarChildren(sub,c.id,depth+1);
                    container.appendChild(sub);
                });
            }
            function makeSidebarItem(id,icon,name,depth){
                const div=document.createElement('div');
                div.className='bcps-tree-item'+(id===currentParent?' active':'');
                div.dataset.id=id; div.style.paddingLeft=(12+depth*14)+'px';
                div.innerHTML=`<span class="ti-toggle">${(id===0||hasChildren(id))?'▶':''}</span><span class="ti-icon">${icon}</span><span class="ti-label" title="${name}">${name}</span>`;
                div.addEventListener('click',()=>navigateTo(id));
                div.addEventListener('dragover',e=>{e.preventDefault();div.classList.add('drag-over');});
                div.addEventListener('dragleave',()=>div.classList.remove('drag-over'));
                div.addEventListener('drop',e=>{e.preventDefault();div.classList.remove('drag-over');if(dragItem!==null&&moveItem(dragItem,id))render();});
                div.querySelector('.ti-toggle').addEventListener('click',e=>{
                    e.stopPropagation();
                    const sub=document.getElementById('tcsub-'+id); if(!sub)return;
                    const open=sub.classList.toggle('open');
                    div.querySelector('.ti-toggle').textContent=open?'▼':'▶';
                });
                return div;
            }

            // ---- PANE ----
            function renderPane(){
                const filesEl=document.getElementById('bcps-files');
                const pathEl=document.getElementById('bcps-path-label');
                const dropZone=document.getElementById('bcps-drop-root');
                filesEl.innerHTML='';
                pathEl.textContent=currentParent===0?'ルート(親なしのカテゴリ)':(getCat(currentParent)?.name??'');
                dropZone.style.display=currentParent===0?'none':'block';

                let items=searchQ?cats.filter(c=>c.name.toLowerCase().includes(searchQ)):childrenOf(currentParent);
                if(!items.length){
                    filesEl.innerHTML='<div class="bcps-empty"><span class="bcps-empty-icon">📭</span>このフォルダは空です<br><small style="color:#4a5568">ここにドロップ、または「新規追加」</small></div>';
                }

                items.forEach(c=>{
                    const item=document.createElement('div');
                    const cls=['bcps-item'];
                    if(isNew(c.id)) cls.push('is-new');
                    else if(isChanged(c.id)) cls.push('changed');
                    item.className=cls.join(' ');
                    item.dataset.id=c.id; item.draggable=true;
                    item.innerHTML=`<span class="bcps-item-icon">📁</span><span class="bcps-item-name" title="${c.name}">${c.name}</span>`;

                    item.addEventListener('dblclick',()=>navigateTo(c.id));
                    item.addEventListener('dragover',e=>{e.preventDefault();item.classList.add('drop-target');});
                    item.addEventListener('dragleave',()=>item.classList.remove('drop-target'));
                    item.addEventListener('drop',e=>{
                        e.preventDefault();item.classList.remove('drop-target');
                        if(dragItem!==null&&dragItem!==c.id&&moveItem(dragItem,c.id))render();
                    });
                    item.addEventListener('dragstart',e=>{
                        dragItem=c.id; item.classList.add('dragging');
                        document.getElementById('bcps-ghost-label').textContent=c.name;
                        document.getElementById('bcps-ghost').style.display='flex';
                        e.dataTransfer.effectAllowed='move';
                        e.dataTransfer.setDragImage(document.getElementById('bcps-ghost'),0,0);
                    });
                    item.addEventListener('dragend',()=>{
                        dragItem=null; item.classList.remove('dragging');
                        document.getElementById('bcps-ghost').style.display='none';
                        document.querySelectorAll('.drop-target,.drag-over').forEach(el=>el.classList.remove('drop-target','drag-over'));
                    });
                    item.addEventListener('contextmenu',e=>{e.preventDefault();showCtx(e.clientX,e.clientY,c.id);});
                    filesEl.appendChild(item);
                });
                filesEl.className='bcps-files'+(viewMode==='list'?' list-view':'');
            }

            // ---- CONTEXT MENU ----
            function showCtx(x,y,id){
                ctxTargetId=id;
                const ctx=document.getElementById('bcps-ctx');
                ctx.style.display='block'; ctx.style.left=x+'px'; ctx.style.top=y+'px';
                const r=ctx.getBoundingClientRect();
                if(r.right>window.innerWidth)  ctx.style.left=(x-r.width)+'px';
                if(r.bottom>window.innerHeight) ctx.style.top=(y-r.height)+'px';
            }
            function hideCtx(){ document.getElementById('bcps-ctx').style.display='none'; ctxTargetId=null; }
            document.addEventListener('click',hideCtx);
            document.getElementById('bcps-ctx').addEventListener('click',e=>e.stopPropagation());

            document.getElementById('ctx-rename').addEventListener('click',()=>{
                const cat=getCat(ctxTargetId); hideCtx(); if(!cat)return;
                openModal('✏️ 名前を変更:'+cat.name, cat.name, newName=>{
                    if(!newName||newName===cat.name)return;
                    history.push({type:'rename',id:cat.id,oldName:cat.name,newName});
                    cat.name=newName; render(); showNotice('✏️ 名前を変更しました(未保存)','success');
                });
            });
            document.getElementById('ctx-add-child').addEventListener('click',()=>{
                const pid=ctxTargetId; hideCtx();
                openModal('📁 子カテゴリを追加','',name=>{ if(!name)return; addCat(name,pid); });
            });
            document.getElementById('ctx-delete').addEventListener('click',()=>{
                const cat=getCat(ctxTargetId); hideCtx(); if(!cat)return;
                const childCount=cats.filter(c=>c.parent===cat.id).length;
                let msg=`「${cat.name}」を削除しますか?`;
                if(childCount) msg+=`\n\n⚠️ 子カテゴリが${childCount}件あります。子は「親なし」に移動されます。`;
                if(!confirm(msg))return;

                if(isNew(cat.id)){
                    cats=cats.filter(c=>c.id!==cat.id);
                    cats.filter(c=>c.parent===cat.id).forEach(c=>{c.parent=0;});
                    render();
                } else {
                    fetch(ajaxurl,{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},
                        body:new URLSearchParams({action:'bcps_delete',nonce:NONCE,term_id:cat.id})})
                    .then(r=>r.json()).then(res=>{
                        if(res.success){
                            cats=cats.filter(c=>c.id!==cat.id);
                            const oi=original.findIndex(o=>o.id===cat.id);
                            if(oi>=0) original.splice(oi,1);
                            cats.filter(c=>c.parent===cat.id).forEach(c=>{c.parent=0;});
                            showNotice('🗑️ 削除しました','success'); render();
                        } else { showNotice('❌ '+(res.data||'削除失敗'),'error'); }
                    }).catch(()=>showNotice('❌ 通信エラー','error'));
                }
            });

            // ---- ADD ----
            function addCat(name,parentId){
                const tmpId=tmpIdCounter--;
                cats.push({id:tmpId,name,parent:parentId,count:0,slug:''});
                history.push({type:'add',id:tmpId});
                navigateTo(parentId);
                showNotice('📁 追加しました(未保存)','success');
            }
            document.getElementById('bcps-add-btn').addEventListener('click',()=>{
                openModal('📁 カテゴリを追加','',name=>{ if(!name)return; addCat(name,currentParent); });
            });

            // ---- MODAL ----
            function openModal(title,defaultVal,cb){
                document.getElementById('bcps-modal-title').textContent=title;
                const inp=document.getElementById('bcps-modal-input');
                inp.value=defaultVal;
                document.getElementById('bcps-overlay').style.display='flex';
                inp.focus(); inp.select();
                const doOk=()=>{
                    const val=inp.value.trim();
                    document.getElementById('bcps-overlay').style.display='none';
                    cb(val);
                };
                const doCancel=()=>{ document.getElementById('bcps-overlay').style.display='none'; };
                const ok=document.getElementById('bcps-modal-ok');
                const cancel=document.getElementById('bcps-modal-cancel');
                const newOk=ok.cloneNode(true); ok.parentNode.replaceChild(newOk,ok);
                const newCn=cancel.cloneNode(true); cancel.parentNode.replaceChild(newCn,cancel);
                newOk.addEventListener('click',doOk);
                newCn.addEventListener('click',doCancel);
                inp.onkeydown=e=>{ if(e.key==='Enter')doOk(); if(e.key==='Escape')doCancel(); };
            }

            // ---- DROP ROOT ----
            document.getElementById('bcps-drop-root').addEventListener('dragover',e=>{e.preventDefault();document.getElementById('bcps-drop-root').classList.add('drag-over');});
            document.getElementById('bcps-drop-root').addEventListener('dragleave',()=>document.getElementById('bcps-drop-root').classList.remove('drag-over'));
            document.getElementById('bcps-drop-root').addEventListener('drop',e=>{
                e.preventDefault(); document.getElementById('bcps-drop-root').classList.remove('drag-over');
                if(dragItem!==null&&moveItem(dragItem,0))render();
            });

            function navigateTo(id){ currentParent=id; render(); }

            function render(){
                renderSidebar(); renderPane();
                if(currentParent===0){ document.getElementById('bcps-breadcrumb').textContent='ルート'; }
                else {
                    const parts=[]; let cur=getCat(currentParent);
                    while(cur){ parts.unshift(cur.name); cur=cur.parent?getCat(cur.parent):null; }
                    document.getElementById('bcps-breadcrumb').textContent=parts.join(' › ');
                }
                const n=changedCount();
                document.getElementById('bcps-change-badge').textContent=n;
                document.getElementById('bcps-change-badge').style.display=n?'inline':'none';
                document.getElementById('bcps-save-btn').disabled=n===0;
                document.getElementById('bcps-undo-btn').disabled=history.length===0;
            }

            // ---- SEARCH ----
            document.getElementById('bcps-search').addEventListener('input',function(){ searchQ=this.value.toLowerCase().trim(); render(); });

            // ---- VIEW TOGGLE ----
            document.getElementById('bcps-view-grid').addEventListener('click',function(){ viewMode='grid'; this.classList.add('active'); document.getElementById('bcps-view-list').classList.remove('active'); renderPane(); });
            document.getElementById('bcps-view-list').addEventListener('click',function(){ viewMode='list'; this.classList.add('active'); document.getElementById('bcps-view-grid').classList.remove('active'); renderPane(); });

            // ---- UNDO ----
            document.getElementById('bcps-undo-btn').addEventListener('click',()=>{
                if(!history.length)return;
                const h=history.pop();
                if(h.type==='move')   getCat(h.id).parent=h.oldParent;
                if(h.type==='rename') getCat(h.id).name=h.oldName;
                if(h.type==='add')    cats=cats.filter(c=>c.id!==h.id);
                render();
            });

            // ---- RESET ----
            document.getElementById('bcps-reset-btn').addEventListener('click',()=>{
                if(!confirm('すべての変更をリセットしますか?'))return;
                cats=original.map(c=>({...c})); history=[]; render();
            });

            // ---- SAVE ----
            document.getElementById('bcps-save-btn').addEventListener('click',async function(){
                const btn=this;
                btn.disabled=true;
                btn.innerHTML='<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="animation:spin 1s linear infinite"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg> 保存中...';

                let ok=0, ng=0;
                const idMap={};

                // 1) 新規追加を先に保存
                for(const nc of cats.filter(c=>isNew(c.id))){
                    const realParent=nc.parent<0?(idMap[nc.parent]??0):nc.parent;
                    try {
                        const res=await fetch(ajaxurl,{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},
                            body:new URLSearchParams({action:'bcps_add',nonce:NONCE,name:nc.name,parent:realParent})}).then(r=>r.json());
                        if(res.success){ idMap[nc.id]=res.data.term_id; nc.id=res.data.term_id; original.push({...nc}); ok++; }
                        else ng++;
                    } catch(e){ ng++; }
                }

                // 2) 変更(移動・名前変更)を保存
                const changes=cats.filter(c=>!isNew(c.id)&&isChanged(c.id)).map(c=>({term_id:c.id,parent:c.parent,name:c.name}));
                if(changes.length){
                    try {
                        const res=await fetch(ajaxurl,{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},
                            body:new URLSearchParams({action:'bcps_save',nonce:NONCE,changes:JSON.stringify(changes)})}).then(r=>r.json());
                        if(res.success){ changes.forEach(c=>{ const o=getOrig(c.term_id); if(o){o.parent=c.parent;o.name=c.name;} }); ok+=res.data.saved; }
                        else ng++;
                    } catch(e){ ng++; }
                }

                history=[];
                showNotice(ng===0?`✅ ${ok}件 保存しました!`:`⚠️ ${ok}件成功、${ng}件失敗`, ng===0?'success':'error');
                btn.disabled=false;
                btn.innerHTML='<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg> 保存 <span id="bcps-change-badge" class="bcps-badge" style="display:none">0</span>';
                render();
            });

            function showNotice(msg,type){
                const n=document.getElementById('bcps-notice');
                n.className=type; n.textContent=msg; n.style.display='block';
                clearTimeout(n._t); n._t=setTimeout(()=>{n.style.display='none';},4000);
            }

            render();
        })();
        </script>
        <?php
    }

    public function ajax_save() {
        check_ajax_referer('bcps_nonce', 'nonce');
        if (!current_user_can('manage_categories')) wp_send_json_error('権限がありません');
        $changes = json_decode(stripslashes($_POST['changes']), true);
        if (!is_array($changes)) wp_send_json_error('不正なデータ');
        $saved=0; $errors=[];
        foreach ($changes as $ch) {
            $id=intval($ch['term_id']); $parent=intval($ch['parent']); $name=sanitize_text_field($ch['name']??'');
            if ($parent!==0 && $this->is_descendant($parent,$id)) { $errors[]="ID:{$id} 循環参照"; continue; }
            $args=['parent'=>$parent]; if($name) $args['name']=$name;
            $r=wp_update_term($id,'category',$args);
            is_wp_error($r) ? $errors[]="ID:{$id} ".$r->get_error_message() : $saved++;
        }
        $saved>0 ? wp_send_json_success(['saved'=>$saved,'errors'=>$errors]) : wp_send_json_error(implode('/',$errors)?:'保存失敗');
    }

    public function ajax_rename() {
        check_ajax_referer('bcps_nonce', 'nonce');
        if (!current_user_can('manage_categories')) wp_send_json_error('権限がありません');
        $id=intval($_POST['term_id']); $name=sanitize_text_field($_POST['name']??'');
        if (!$id||!$name) wp_send_json_error('パラメータ不足');
        $r=wp_update_term($id,'category',['name'=>$name]);
        is_wp_error($r) ? wp_send_json_error($r->get_error_message()) : wp_send_json_success(['term_id'=>$id]);
    }

    public function ajax_add() {
        check_ajax_referer('bcps_nonce', 'nonce');
        if (!current_user_can('manage_categories')) wp_send_json_error('権限がありません');
        $name=sanitize_text_field($_POST['name']??''); $parent=intval($_POST['parent']??0);
        if (!$name) wp_send_json_error('名前が空です');
        $r=wp_insert_term($name,'category',['parent'=>$parent]);
        is_wp_error($r) ? wp_send_json_error($r->get_error_message()) : wp_send_json_success(['term_id'=>$r['term_id']]);
    }

    public function ajax_delete() {
        check_ajax_referer('bcps_nonce', 'nonce');
        if (!current_user_can('manage_categories')) wp_send_json_error('権限がありません');
        $id=intval($_POST['term_id']??0);
        if (!$id) wp_send_json_error('IDが不正です');
        $r=wp_delete_term($id,'category');
        is_wp_error($r) ? wp_send_json_error($r->get_error_message()) : wp_send_json_success(['deleted'=>$id]);
    }

    private function is_descendant($maybe_desc,$ancestor_id) {
        $term=get_term($maybe_desc,'category');
        while($term && !is_wp_error($term) && $term->parent!=0){
            if((int)$term->parent===(int)$ancestor_id) return true;
            $term=get_term($term->parent,'category');
        }
        return false;
    }
}

new Bulk_Category_Parent_Setter();

コメント