プラグインフォルダにコピペして使ってください!
<?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();


コメント