friday-mcp-v2 3.1.0 → 3.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,8 +14,9 @@ import fetch from "node-fetch";
14
14
  import { randomUUID } from "node:crypto";
15
15
  import { readFileSync, statSync, realpathSync, appendFileSync, mkdirSync, existsSync, writeFileSync, renameSync } from "node:fs";
16
16
  import { execFile } from "node:child_process";
17
- import { resolve as resolvePath, sep, dirname, join as joinPath, extname } from "node:path";
17
+ import { resolve as resolvePath, sep, dirname, join as joinPath, extname, basename } from "node:path";
18
18
  import { marked } from "marked";
19
+ import { parse as parseHTML } from "node-html-parser";
19
20
  import os from "node:os";
20
21
  import { fileURLToPath } from "node:url";
21
22
  // package.json からバージョンを取得
@@ -457,6 +458,117 @@ function readHTMLFromFile(filePath, maxSizeBytes = 2 * 1024 * 1024) {
457
458
  return { html };
458
459
  }
459
460
 
461
+ // ========================================
462
+ // Markdown → Gutenberg Block Converter
463
+ // ========================================
464
+
465
+ function _convertHeading(node) {
466
+ const tag = node.tagName.toLowerCase();
467
+ const level = parseInt(tag[1]);
468
+ const attrs = level === 2 ? '' : ` {"level":${level}}`;
469
+ return `<!-- wp:heading${attrs} -->\n<${tag} class="wp-block-heading">${node.innerHTML}</${tag}>\n<!-- /wp:heading -->`;
470
+ }
471
+
472
+ function _convertParagraph(node) {
473
+ const img = node.querySelector('img');
474
+ if (img && node.childNodes.length === 1) {
475
+ return _convertImage(img);
476
+ }
477
+ return `<!-- wp:paragraph -->\n<p>${node.innerHTML}</p>\n<!-- /wp:paragraph -->`;
478
+ }
479
+
480
+ function _convertList(node) {
481
+ const tag = node.tagName.toLowerCase();
482
+ const ordered = tag === 'ol';
483
+ const attrs = ordered ? ' {"ordered":true}' : '';
484
+ const outerTag = ordered ? 'ol' : 'ul';
485
+ return `<!-- wp:list${attrs} -->\n<${outerTag} class="wp-block-list">${node.innerHTML}</${outerTag}>\n<!-- /wp:list -->`;
486
+ }
487
+
488
+ function _convertQuote(node) {
489
+ let inner = node.innerHTML.trim();
490
+ if (!inner.startsWith('<')) {
491
+ inner = `<p>${inner}</p>`;
492
+ }
493
+ return `<!-- wp:quote -->\n<blockquote class="wp-block-quote">${inner}</blockquote>\n<!-- /wp:quote -->`;
494
+ }
495
+
496
+ function _convertCode(node) {
497
+ const inner = node.innerHTML.replace(/<code\s+class="[^"]*">/g, '<code>');
498
+ return `<!-- wp:code -->\n<pre class="wp-block-code">${inner}</pre>\n<!-- /wp:code -->`;
499
+ }
500
+
501
+ function _convertTable(node) {
502
+ return `<!-- wp:table -->\n<figure class="wp-block-table"><table>${node.innerHTML}</table></figure>\n<!-- /wp:table -->`;
503
+ }
504
+
505
+ function _convertImage(node) {
506
+ const img = node.tagName?.toLowerCase() === 'img' ? node : node.querySelector('img');
507
+ if (!img) return `<!-- wp:html -->\n${node.outerHTML}\n<!-- /wp:html -->`;
508
+ const src = img.getAttribute('src') || '';
509
+ const alt = img.getAttribute('alt') || '';
510
+ return `<!-- wp:image -->\n<figure class="wp-block-image"><img src="${src}" alt="${alt}"/></figure>\n<!-- /wp:image -->`;
511
+ }
512
+
513
+ function htmlToGutenbergBlocks(html) {
514
+ const root = parseHTML(html);
515
+ const blocks = [];
516
+ for (const node of root.childNodes) {
517
+ if (node.nodeType === 3) {
518
+ const text = node.text.trim();
519
+ if (!text) continue;
520
+ blocks.push(`<!-- wp:paragraph -->\n<p>${text}</p>\n<!-- /wp:paragraph -->`);
521
+ continue;
522
+ }
523
+ if (node.nodeType !== 1) continue;
524
+ const tag = node.tagName?.toLowerCase();
525
+ switch (tag) {
526
+ case 'h1': case 'h2': case 'h3': case 'h4': case 'h5': case 'h6':
527
+ blocks.push(_convertHeading(node));
528
+ break;
529
+ case 'p':
530
+ blocks.push(_convertParagraph(node));
531
+ break;
532
+ case 'ul': case 'ol':
533
+ blocks.push(_convertList(node));
534
+ break;
535
+ case 'blockquote':
536
+ blocks.push(_convertQuote(node));
537
+ break;
538
+ case 'pre':
539
+ blocks.push(_convertCode(node));
540
+ break;
541
+ case 'hr':
542
+ blocks.push(`<!-- wp:separator -->\n<hr class="wp-block-separator has-alpha-channel-opacity"/>\n<!-- /wp:separator -->`);
543
+ break;
544
+ case 'table':
545
+ blocks.push(_convertTable(node));
546
+ break;
547
+ case 'figure':
548
+ case 'img':
549
+ blocks.push(_convertImage(node));
550
+ break;
551
+ default:
552
+ blocks.push(`<!-- wp:html -->\n${node.outerHTML}\n<!-- /wp:html -->`);
553
+ break;
554
+ }
555
+ }
556
+ return blocks.join('\n\n');
557
+ }
558
+
559
+ async function resolveFileToBlockHTML(filePath) {
560
+ const ext = extname(filePath).toLowerCase();
561
+ const raw = readHTMLFromFile(filePath).html;
562
+ if (ext === '.md') {
563
+ const html = await marked.parse(raw);
564
+ return htmlToGutenbergBlocks(html);
565
+ } else if (ext === '.html' || ext === '.htm') {
566
+ return raw;
567
+ } else {
568
+ throw new Error(`対応していないファイル形式です: ${ext} (.md または .html のみ)`);
569
+ }
570
+ }
571
+
460
572
  // ========================================
461
573
  // Media Utilities
462
574
  // ========================================
@@ -474,15 +586,18 @@ function validateImageFile(filePath) {
474
586
  let stat;
475
587
  try { stat = statSync(resolved); }
476
588
  catch (e) {
477
- if (e.code === 'ENOENT') throw new Error(`File not found: ${resolved}`);
478
- throw new Error(`File access error: ${resolved} (${e.message})`);
589
+ if (e.code === 'ENOENT') throwToolError('FILE_NOT_FOUND', `File not found: ${resolved}`, false);
590
+ throwToolError('VALIDATION_ERROR', `File access error: ${resolved} (${e.message})`, false);
591
+ }
592
+ if (!stat.isFile()) {
593
+ throwToolError('VALIDATION_ERROR', `Not a file: ${resolved}`, false);
479
594
  }
480
595
  if (stat.size > MAX_IMAGE_SIZE_BYTES) {
481
- throw new Error(`File too large: ${(stat.size / 1048576).toFixed(1)}MB (max: 10MB)`);
596
+ throwToolError('TOO_LARGE', `File too large: ${(stat.size / 1048576).toFixed(1)}MB (max: 10MB)`, false);
482
597
  }
483
- const ext = resolved.slice(resolved.lastIndexOf('.')).toLowerCase();
598
+ const ext = extname(resolved).toLowerCase();
484
599
  if (!ALLOWED_IMAGE_EXTENSIONS.has(ext)) {
485
- throw new Error(`Unsupported image format: ${ext} (allowed: ${[...ALLOWED_IMAGE_EXTENSIONS].join(', ')})`);
600
+ throwToolError('INVALID_MIME', `Unsupported image format: ${ext || '(none)'} (allowed: ${[...ALLOWED_IMAGE_EXTENSIONS].join(', ')})`, false);
486
601
  }
487
602
  const contentType = MIME_MAP[ext] || 'application/octet-stream';
488
603
  return { resolved, stat, ext, contentType };
@@ -611,204 +726,975 @@ function generateSearchVariants(query, strip = [], prefixes = []) {
611
726
  }
612
727
 
613
728
  /**
614
- * 単一クライアントの Editor/Headless 判定
615
- * @returns {{ mode: 'editor'|'headless'|'error', postId?: number, message?: string, client?: FridayWPClient }}
729
+ * フォルダパス文字列の正規化 (T8)
730
+ * trim NFC 全角スラッシュ拒否 先頭末尾スラッシュ strip 連続スラッシュ統合 → 空セグメント拒否
731
+ * @param {string} input パス文字列
732
+ * @returns {string} 正規化済みパス (ルート相対、先頭スラッシュなし)
616
733
  */
617
- async function checkSingleClient(client, args) {
618
- try {
619
- const rawPostId = args?.postId;
620
- const numPostId = rawPostId != null ? Number(rawPostId) : null;
621
- const isNumeric = numPostId != null && Number.isFinite(numPostId) && numPostId > 0;
622
-
623
- let connectedEditors = [];
734
+ function normalizeFolderPath(input) {
735
+ if (typeof input !== 'string') {
736
+ throwToolError('VALIDATION_ERROR', 'フォルダパスは文字列で指定してください', false);
737
+ }
738
+ let s = input.trim().normalize('NFC');
739
+ if (s.includes('/')) {
740
+ throwToolError('VALIDATION_ERROR', `フォルダパスに全角スラッシュ "/" が含まれています。半角 "/" を使ってください: "${input}"`, false);
741
+ }
742
+ s = s.replace(/^\/+/, '').replace(/\/+$/, '').replace(/\/{2,}/g, '/');
743
+ if (s === '') {
744
+ throwToolError('VALIDATION_ERROR', 'フォルダパスが空です', false);
745
+ }
746
+ const segments = s.split('/').map(seg => seg.trim());
747
+ if (segments.some(seg => seg === '')) {
748
+ throwToolError('VALIDATION_ERROR', `フォルダパスに空のセグメントが含まれています: "${input}"`, false);
749
+ }
750
+ return segments.join('/');
751
+ }
624
752
 
625
- // Bridge 経由で editor 一覧取得(Bridge 必須)
626
- if (bridgeClient) {
627
- const siteUrl = client.wpUrl?.replace(/\/+$/, '');
628
- connectedEditors = await getBridgeEditors(siteUrl);
753
+ /**
754
+ * FileBird /folders 生レスポンスを構造化 (T6)
755
+ * @param {object[]} rawFolders FileBird API の data.folders
756
+ * @param {number} uncategorizedCount Uncategorized 件数 (getFileBirdAttachmentCount(0) の値)
757
+ * @returns {{tree, byId, byPath, allNodes}}
758
+ * tree: T6 スキーマのツリー (Uncategorized 先頭)
759
+ * byId: Map<id, node>
760
+ * byPath: Map<path, node[]> (通常フォルダのみ。同名兄弟は配列)
761
+ * allNodes: フラット配列 (Uncategorized 先頭・含む)
762
+ */
763
+ function normalizeFileBirdFolders(rawFolders, uncategorizedCount = 0) {
764
+ const byId = new Map();
765
+ const byPath = new Map();
766
+ const allNodes = [];
767
+
768
+ function walk(rawList, parentPath, parentId) {
769
+ const nodes = [];
770
+ for (const raw of rawList || []) {
771
+ const name = raw.text || raw.title || '';
772
+ const path = parentPath ? `${parentPath}/${name}` : name;
773
+ const node = {
774
+ id: raw.id,
775
+ name,
776
+ parentId,
777
+ path,
778
+ count: parseInt(raw['data-count'], 10) || 0,
779
+ isSystemFolder: false,
780
+ children: [],
781
+ };
782
+ byId.set(node.id, node);
783
+ if (!byPath.has(path)) byPath.set(path, []);
784
+ byPath.get(path).push(node);
785
+ allNodes.push(node);
786
+ node.children = walk(raw.children, path, node.id);
787
+ nodes.push(node);
629
788
  }
789
+ return nodes;
790
+ }
630
791
 
631
- const hasEditors = connectedEditors.length > 0;
632
- if (!hasEditors) {
633
- if (rawPostId == null) return { mode: 'error', message: 'エディタ未接続' };
634
- if (isNumeric) return { mode: 'headless', postId: numPostId, client, connectedEditors };
635
- return { mode: 'headless', postId: null, client, connectedEditors };
636
- }
792
+ // ルート直下フォルダの parentId 0 (FileBird の parent=0)。
793
+ // id=0 は Uncategorized を指すが、buildFolderPath は parentId===0 をルート停止として扱い区別する。
794
+ const tree = walk(rawFolders, '', 0);
795
+
796
+ const uncategorized = {
797
+ id: 0,
798
+ name: '(未分類)',
799
+ parentId: null,
800
+ path: '',
801
+ count: uncategorizedCount,
802
+ isSystemFolder: true,
803
+ children: [],
804
+ };
805
+ byId.set(0, uncategorized);
806
+ allNodes.unshift(uncategorized);
807
+ tree.unshift(uncategorized);
637
808
 
638
- // connectedEditors 配列を使った editor/headless 判定
639
- if (rawPostId != null) {
640
- if (isNumeric) {
641
- const isEditing = connectedEditors.some(e => Number(e.postId) === numPostId);
642
- if (isEditing) {
643
- const sessions = connectedEditors.filter(e => Number(e.postId) === numPostId);
644
- const sessionId = sessions.length === 1 ? sessions[0].sessionId : null;
645
- return { mode: 'editor', postId: numPostId, client, sessionId, connectedEditors };
646
- }
647
- return { mode: 'headless', postId: numPostId, client, connectedEditors };
648
- }
649
- return { mode: 'headless', postId: null, client, connectedEditors };
650
- }
809
+ return { tree, byId, byPath, allNodes };
810
+ }
651
811
 
652
- // postId 省略
653
- if (connectedEditors.length === 1) {
654
- return { mode: 'editor', postId: connectedEditors[0].postId, client, sessionId: connectedEditors[0].sessionId, connectedEditors };
655
- }
656
- if (connectedEditors.length > 1) {
657
- const list = connectedEditors.map(e =>
658
- ` postId: ${e.postId} (${e.postTitle || 'untitled'}) sessionId: ${e.sessionId}`
659
- ).join('\n');
660
- return {
661
- mode: 'error',
662
- message: `複数のエディタが接続中です。postId を指定してください。\n\n接続中:\n${list}`,
663
- connectedEditors,
664
- };
665
- }
666
- return { mode: 'error', message: 'エディタ未接続' };
667
- } catch (e) {
668
- return { mode: 'error', message: `接続エラー: ${e.message}` };
812
+ /**
813
+ * パス文字列 folder id
814
+ * 同名兄弟で複数マッチなら候補を path 併記でエラー (A4)
815
+ * @returns {number} folder id
816
+ */
817
+ function resolveFolderPath(normalized, pathStr) {
818
+ const norm = normalizeFolderPath(pathStr);
819
+ if (norm === '') {
820
+ throwToolError('VALIDATION_ERROR', '空のフォルダパスは解決できません (ルート指定は folderId=0 を使用)', false);
821
+ }
822
+ const matches = normalized.byPath.get(norm);
823
+ if (!matches || matches.length === 0) {
824
+ const available = normalized.allNodes.filter(n => n.id !== 0).map(n => n.path).join(', ');
825
+ throwToolError('FOLDER_NOT_FOUND', `フォルダパス "${norm}" が見つかりません。利用可能: ${available}`, false);
826
+ }
827
+ if (matches.length > 1) {
828
+ const candidates = matches.map(n => `[ID: ${n.id}] ${n.path}`).join(', ');
829
+ throwToolError('VALIDATION_ERROR', `フォルダパス "${norm}" が複数のフォルダにマッチしました。folderId で一意に指定してください: ${candidates}`, false);
669
830
  }
831
+ return matches[0].id;
670
832
  }
671
833
 
672
- function formatHeadlessConflictError(e) {
673
- if (e.wpErrorCode === 'headless_conflict') {
674
- return {
675
- content: [{ type: "text", text:
676
- "❌ この記事はエディタで開かれているため、headless アクセスがブロックされました。" +
677
- "\nエディタコマンドを使用するか、エディタのタブを閉じてから再試行してください。"
678
- }],
679
- isError: true,
680
- };
681
- }
682
- return null;
834
+ /** folder id → パス文字列。0 (Uncategorized/ルート) は空文字 */
835
+ function buildFolderPath(normalized, folderId) {
836
+ if (folderId === 0) return '';
837
+ const node = normalized.byId.get(folderId);
838
+ return node ? node.path : null;
839
+ }
840
+
841
+ /** パス文字列 → folder node (resolveFolderPath のラッパ) */
842
+ function findFolderByPath(normalized, pathStr) {
843
+ const id = resolveFolderPath(normalized, pathStr);
844
+ return normalized.byId.get(id);
683
845
  }
684
846
 
685
847
  /**
686
- * Editor/Headless モード判定ヘルパー(複数接続自動検出対応)
687
- * @returns {{ mode: 'editor'|'headless'|'error', postId?: number, message?: string, client?: FridayWPClient }}
848
+ * ルートフォルダから子孫を含む folder id 一覧を集める (Phase 2 recursive)
849
+ * 出力は folderId 昇順でソート (切り詰め契約と整合)。
850
+ * Uncategorized(0) は children を持たないため [0] を返す。
851
+ * @param {object} normalized normalizeFileBirdFolders の戻り値
852
+ * @param {number} rootFolderId 起点 folder id
853
+ * @returns {number[]} [rootId, ...全子孫 id] を昇順ソート
688
854
  */
689
- async function resolveMode(args, _callerTool) {
690
- const _debugRM = process.env.FRIDAY_DEBUG === '1';
691
- function _logResolve(result, path) {
692
- if (_debugRM) console.error(`[resolveMode] tool=${_callerTool||'?'} mode=${result.mode} postId=${result.postId||'?'} site=${result.siteName||args?.site||'?'} path=${path}`);
693
- return result;
694
- }
695
- // site 明示指定
696
- if (args?.site) {
697
- let client;
698
- try { client = registry.get(args.site); }
699
- catch (e) { return _logResolve({ mode: 'error', message: e.message }, 'site-error'); }
700
- const result = await checkSingleClient(client, args);
701
- return _logResolve({ ...result, siteName: args.site }, 'site-explicit');
702
- }
855
+ function collectSubtreeFolderIds(normalized, rootFolderId) {
856
+ const root = normalized.byId.get(rootFolderId);
857
+ if (!root) return [];
858
+ const ids = [];
859
+ (function walk(node) {
860
+ ids.push(node.id);
861
+ for (const child of node.children || []) walk(child);
862
+ })(root);
863
+ return ids.sort((a, b) => a - b);
864
+ }
703
865
 
704
- const allConns = registry.getAll();
705
- const isMultiSite = allConns.length > 1;
866
+ // Phase 2 search_media recursive の上限 (P2-5)
867
+ const SEARCH_MAX_SCAN_FOLDERS = 200; // 走査フォルダ数上限
868
+ const SEARCH_MAX_UNION_IDS = 1000; // union ID 総数上限
869
+ const SEARCH_FETCH_CONCURRENCY = 5; // attachment-id 取得並列度
706
870
 
707
- // 単一接続 → 従来通り
708
- if (!isMultiSite) {
709
- const result = await checkSingleClient(registry.get(), args);
710
- return _logResolve({ ...result, siteName: allConns[0].name }, 'single');
871
+ /**
872
+ * 複数フォルダの attachment-id を並列取得し、決定的順序 (folderId昇順→id昇順) で union する。
873
+ * null=取得失敗 []=空フォルダ を区別する (中5)
874
+ * @param {object} client WordPress クライアント
875
+ * @param {number[]} folderIds folderId 昇順想定の配列
876
+ * @returns {Promise<{unionIds:number[], fetchedFolderCount:number, failedFolderCount:number}>}
877
+ */
878
+ async function fetchAttachmentIdsForFolders(client, folderIds) {
879
+ const perFolder = new Array(folderIds.length); // null=失敗, number[]=成功
880
+ let next = 0;
881
+ async function worker() {
882
+ while (next < folderIds.length) {
883
+ const i = next++;
884
+ try {
885
+ const ids = await client.getFileBirdAttachmentIds(folderIds[i]);
886
+ perFolder[i] = Array.isArray(ids) ? ids : null;
887
+ } catch {
888
+ perFolder[i] = null;
889
+ }
890
+ }
711
891
  }
892
+ await Promise.all(
893
+ Array.from({ length: Math.min(SEARCH_FETCH_CONCURRENCY, folderIds.length) }, worker)
894
+ );
712
895
 
713
- // 複数接続 + sticky キャッシュ有効
714
- const cached = statusCache.getLast();
715
- if (cached) {
716
- const cachedResult = await checkSingleClient(cached.client, args);
717
- if (cachedResult.mode === 'editor') {
718
- return _logResolve({ ...cachedResult, siteName: cached.site }, 'cache-hit');
896
+ const seen = new Set();
897
+ const unionIds = [];
898
+ let fetchedFolderCount = 0;
899
+ let failedFolderCount = 0;
900
+ for (let i = 0; i < folderIds.length; i++) {
901
+ const ids = perFolder[i];
902
+ if (ids === null) { failedFolderCount++; continue; }
903
+ fetchedFolderCount++;
904
+ // FileBird の attachment_id は文字列で返るため Number 化(WP media の数値 id と突き合わせるため)
905
+ const numericIds = ids.map(Number).filter(n => Number.isInteger(n) && n > 0).sort((a, b) => a - b);
906
+ for (const id of numericIds) {
907
+ if (seen.has(id)) continue;
908
+ seen.add(id);
909
+ unionIds.push(id);
719
910
  }
720
- statusCache.clear(cached.site);
721
911
  }
912
+ return { unionIds, fetchedFolderCount, failedFolderCount };
913
+ }
722
914
 
723
- // default を先に確認
724
- const defaultClient = registry.get();
725
- const defaultName = allConns.find(c => c.client === defaultClient)?.name || 'default';
726
- const defaultResult = await checkSingleClient(defaultClient, args);
727
- if (defaultResult.mode === 'editor') {
728
- statusCache.set(defaultName, defaultClient, defaultResult.postId);
729
- return _logResolve({ ...defaultResult, siteName: defaultName }, 'default-editor');
915
+ function toolError(code, message, retryable = false, extra = {}) {
916
+ return { code, message, retryable, ...extra };
917
+ }
918
+
919
+ function throwToolError(code, message, retryable = false, extra = {}) {
920
+ const err = new Error(message);
921
+ err.toolError = toolError(code, message, retryable, extra);
922
+ throw err;
923
+ }
924
+
925
+ function toToolError(error, fallbackCode = 'UNKNOWN') {
926
+ if (error?.toolError) return error.toolError;
927
+ if (error?.name === 'AbortError' || error?.name === 'TimeoutError') {
928
+ return toolError('TIMEOUT', error.message || 'Operation timed out', true);
730
929
  }
731
- // defaultResult.mode === 'headless' の場合:
732
- // 他の接続にエディタが接続中の可能性があるため、並列スキャンに進む
930
+ const statusCode = error?.statusCode;
931
+ const wpErrorCode = error?.wpErrorCode || '';
932
+ if (statusCode === 401 || statusCode === 403 || wpErrorCode.includes('forbidden')) {
933
+ return toolError('PERMISSION_DENIED', error.message, false);
934
+ }
935
+ if (statusCode === 413) {
936
+ return toolError('TOO_LARGE', error.message, false);
937
+ }
938
+ if (statusCode === 404) {
939
+ return toolError('MEDIA_NOT_FOUND', error.message, false);
940
+ }
941
+ return toolError(fallbackCode, error?.message || String(error), true);
942
+ }
733
943
 
734
- // Bridge ルート(IPC 経由で即座に判定)
735
- if (bridgeClient) {
736
- let storeConnected;
737
- try {
738
- storeConnected = [];
739
- for (const { name, client } of allConns) {
740
- const siteUrl = client.wpUrl?.replace(/\/+$/, '');
741
- const editors = await getBridgeEditors(siteUrl);
742
- if (editors.length > 0) {
743
- storeConnected.push({ name, client, connectedEditors: editors });
744
- }
745
- }
746
- } catch (bridgeErr) {
747
- return _logResolve({
748
- mode: 'error',
749
- message: `Bridge 接続エラー: ${bridgeErr.message}\nBridge が起動しているか確認してください。`,
750
- }, 'bridge-connection-error');
751
- }
752
- if (storeConnected.length === 1) {
753
- const { name, client, connectedEditors } = storeConnected[0];
754
- if (args?.postId != null) {
755
- const numPid = Number(args.postId);
756
- const isNum = Number.isFinite(numPid) && numPid > 0;
757
- const isEditing = isNum && connectedEditors.some(e => Number(e.postId) === numPid);
758
- if (!isEditing) {
759
- const connNames = allConns.map(c => c.name).join(', ');
760
- return _logResolve({
761
- mode: 'error',
762
- message: `複数サイトが登録されています。Headless モードでは site の指定が必須です。\n利用可能: ${connNames}`,
763
- }, 'store-scan-mismatch');
764
- }
765
- const sessions = connectedEditors.filter(e => Number(e.postId) === numPid);
766
- const sessionId = sessions.length === 1 ? sessions[0].sessionId : null;
767
- statusCache.set(name, client, numPid);
768
- return _logResolve({ mode: 'editor', postId: numPid, client, siteName: name, sessionId, connectedEditors }, 'store-scan-editor');
769
- }
770
- // postId 省略
771
- if (connectedEditors.length === 1) {
772
- statusCache.set(name, client, connectedEditors[0].postId);
773
- return _logResolve({ mode: 'editor', postId: connectedEditors[0].postId, client, siteName: name, sessionId: connectedEditors[0].sessionId, connectedEditors }, 'store-scan-single');
774
- }
775
- if (connectedEditors.length > 1) {
776
- const list = connectedEditors.map(e =>
777
- ` postId: ${e.postId} (${e.postTitle || 'untitled'}) sessionId: ${e.sessionId}`
778
- ).join('\n');
779
- return _logResolve({ mode: 'error', message: `複数のエディタが接続中です。postId を指定してください。\n\n接続中:\n${list}`, connectedEditors }, 'store-scan-multi-editor');
780
- }
781
- }
782
- if (storeConnected.length > 1) {
783
- const list = storeConnected.map(c =>
784
- ` ${c.name}: ${c.connectedEditors.map(e => `postId ${e.postId}`).join(', ')} (${c.client.wpUrl})`
785
- ).join('\n');
786
- return _logResolve({
787
- mode: 'error',
788
- message: `複数のエディタが接続中です。site パラメータで接続先を指定してください。\n\n接続中:\n${list}\n\n例: get_article_structure({ site: "${storeConnected[0].name}" })`
789
- }, 'store-scan-multi-site');
790
- }
791
- // Store で見つからない → Bridge 経由で見つからない = エディタ未接続
944
+ function jsonToolResponse(payload, isError = false) {
945
+ return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }], ...(isError ? { isError: true } : {}) };
946
+ }
947
+
948
+ function jsonErrorResponse(error, fallbackCode = 'UNKNOWN') {
949
+ const err = toToolError(error, fallbackCode);
950
+ return jsonToolResponse({ success: false, error: err }, true);
951
+ }
952
+
953
+ function assertInteger(value, fieldName, { min = null, allowNegativeOne = false } = {}) {
954
+ if (typeof value !== 'number' || !Number.isInteger(value)) {
955
+ throwToolError('VALIDATION_ERROR', `${fieldName} は整数 number で指定してください`, false);
792
956
  }
957
+ if (value === -1 && allowNegativeOne) return value;
958
+ if (min != null && value < min) {
959
+ throwToolError('VALIDATION_ERROR', `${fieldName} は ${min} 以上の整数で指定してください`, false);
960
+ }
961
+ return value;
962
+ }
793
963
 
794
- // Bridge で全接続にエディタが見つからなかった
795
- if (args?.postId != null) {
796
- const connNames = allConns.map(c => c.name).join(', ');
797
- return _logResolve({
798
- mode: 'error',
799
- message: `複数サイトが登録されています。Headless モードでは site の指定が必須です。\n利用可能: ${connNames}`,
800
- }, 'bridge-no-editor-postId');
964
+ function assertPositiveInteger(value, fieldName) {
965
+ return assertInteger(value, fieldName, { min: 1 });
966
+ }
967
+
968
+ function getTextValue(value) {
969
+ if (typeof value === 'string') return value;
970
+ if (value && typeof value === 'object') {
971
+ if (typeof value.raw === 'string') return value.raw;
972
+ if (typeof value.rendered === 'string') return value.rendered;
801
973
  }
974
+ return '';
975
+ }
802
976
 
803
- return _logResolve({
804
- mode: 'error',
805
- message: 'エディタ未接続です。postId を指定して Headless モードを使用するか、エディタで記事を開いてください。\n接続一覧の確認: list_connections()'
806
- }, 'bridge-no-editor-noPid');
977
+ function formatMedia(media, folder = {}) {
978
+ return {
979
+ mediaId: media.id,
980
+ title: getTextValue(media.title),
981
+ alt: media.alt_text || '',
982
+ caption: getTextValue(media.caption),
983
+ description: getTextValue(media.description),
984
+ sourceUrl: media.source_url || '',
985
+ mimeType: media.mime_type || null,
986
+ folderId: folder.folderId ?? null,
987
+ folderPath: folder.folderPath ?? null,
988
+ fileSize: media.media_details?.filesize ?? null,
989
+ date: media.date_gmt || media.date || null,
990
+ };
991
+ }
992
+
993
+ async function getNormalizedFileBirdFolders(client) {
994
+ if (!client.filebirdToken) {
995
+ throwToolError('VALIDATION_ERROR', 'FileBird APIキーが未設定です。connections.json または .env に filebirdToken を追加してください', false);
996
+ }
997
+ const folders = await client.getFileBirdFolders();
998
+ if (!folders) {
999
+ throwToolError('FOLDER_NOT_FOUND', 'FileBird API へのアクセスに失敗しました', true);
1000
+ }
1001
+ const uncategorizedCount = (await client.getFileBirdAttachmentCount(0)) || 0;
1002
+ return normalizeFileBirdFolders(folders, uncategorizedCount);
807
1003
  }
808
1004
 
809
1005
  /**
810
- * postId を解決する(数値はそのまま、文字列は slug として検索)
811
- * slug 解決後にエディタ競合チェックも実施
1006
+ * Phase 3: admin namespace (filebird/v1/get-folders) からツリーを取得して正規化する。
1007
+ * public token を使わず App Password (wpCoreRequest) で認証する。
1008
+ * admin tree は data-count を持たず件数は attachmentsCount.display (id→count・subtree込み) で
1009
+ * 別途返るため、各ノードへ display をマージしてから既存 normalize に渡す。
1010
+ * Phase 1/2 の public 経路 (getNormalizedFileBirdFolders) は変更しない並行実装。
1011
+ */
1012
+ async function getNormalizedFileBirdFoldersAdmin(client) {
1013
+ const res = await client.getFileBirdFoldersAdmin();
1014
+ if (!res || !Array.isArray(res.tree)) {
1015
+ throwToolError('FOLDER_NOT_FOUND', 'FileBird admin API (filebird/v1/get-folders) へのアクセスに失敗しました', true);
1016
+ }
1017
+ const display = (res.attachmentsCount && res.attachmentsCount.display) || {};
1018
+ (function mergeCounts(nodes) {
1019
+ for (const n of nodes || []) {
1020
+ n['data-count'] = display[n.id] ?? 0;
1021
+ if (Array.isArray(n.children)) mergeCounts(n.children);
1022
+ }
1023
+ })(res.tree);
1024
+ // Uncategorized(id=0) は admin display に含まれない。Phase 3 は id=0 を操作対象にしないため 0 でよい。
1025
+ return normalizeFileBirdFolders(res.tree, 0);
1026
+ }
1027
+
1028
+ function getFolderNodeById(normalized, folderId, fieldName = 'folderId') {
1029
+ if (folderId === -1) {
1030
+ throwToolError('VALIDATION_ERROR', `${fieldName}=-1 は All 相当の予約値のため指定できません`, false);
1031
+ }
1032
+ if (folderId === 0) return normalized.byId.get(0);
1033
+ const node = normalized.byId.get(folderId);
1034
+ if (!node) {
1035
+ throwToolError('FOLDER_NOT_FOUND', `${fieldName} ${folderId} が見つかりません`, false);
1036
+ }
1037
+ return node;
1038
+ }
1039
+
1040
+ function resolveFolderTarget(normalized, { folder, folderId }) {
1041
+ const hasFolder = folder !== undefined && folder !== null;
1042
+ const hasFolderId = folderId !== undefined && folderId !== null;
1043
+ if (!hasFolder && !hasFolderId) {
1044
+ throwToolError('VALIDATION_ERROR', 'folder または folderId のどちらかは必須です', false);
1045
+ }
1046
+
1047
+ let pathId = null;
1048
+ if (hasFolder) {
1049
+ pathId = resolveFolderPath(normalized, folder);
1050
+ }
1051
+
1052
+ let idNode = null;
1053
+ if (hasFolderId) {
1054
+ assertInteger(folderId, 'folderId', { min: -1, allowNegativeOne: true });
1055
+ idNode = getFolderNodeById(normalized, folderId, 'folderId');
1056
+ }
1057
+
1058
+ if (hasFolder && hasFolderId && pathId !== folderId) {
1059
+ throwToolError('VALIDATION_ERROR', `指定された folderId (${folderId}) と folder path (${folder}) が一致しません`, false, { pathResolvedFolderId: pathId });
1060
+ }
1061
+
1062
+ const id = hasFolderId ? folderId : pathId;
1063
+ const node = hasFolderId ? idNode : getFolderNodeById(normalized, id, 'folder');
1064
+ return { id, node, path: node?.path ?? '' };
1065
+ }
1066
+
1067
+ function resolveParentTarget(normalized, { parent, parentId }) {
1068
+ const hasParent = parent !== undefined && parent !== null;
1069
+ const hasParentId = parentId !== undefined && parentId !== null;
1070
+ if (!hasParent && !hasParentId) {
1071
+ const node = normalized.byId.get(0);
1072
+ return { id: 0, node, path: '' };
1073
+ }
1074
+
1075
+ let pathId = null;
1076
+ if (hasParent) {
1077
+ pathId = resolveFolderPath(normalized, parent);
1078
+ }
1079
+
1080
+ let idNode = null;
1081
+ if (hasParentId) {
1082
+ assertInteger(parentId, 'parentId', { min: 0 });
1083
+ idNode = getFolderNodeById(normalized, parentId, 'parentId');
1084
+ }
1085
+
1086
+ if (hasParent && hasParentId && pathId !== parentId) {
1087
+ throwToolError('VALIDATION_ERROR', `指定された parentId (${parentId}) と parent path (${parent}) が一致しません`, false, { pathResolvedParentId: pathId });
1088
+ }
1089
+
1090
+ const id = hasParentId ? parentId : pathId;
1091
+ const node = hasParentId ? idNode : getFolderNodeById(normalized, id, 'parent');
1092
+ return { id, node, path: node?.path ?? '' };
1093
+ }
1094
+
1095
+ async function getExistingMediaByIds(client, mediaIds) {
1096
+ const ids = Array.isArray(mediaIds) ? mediaIds : [mediaIds];
1097
+ if (ids.length === 0) {
1098
+ throwToolError('VALIDATION_ERROR', 'mediaIds は1件以上指定してください', false);
1099
+ }
1100
+ if (ids.length > 100) {
1101
+ throwToolError('VALIDATION_ERROR', 'mediaIds は1コール最大100件までです', false);
1102
+ }
1103
+ for (const id of ids) assertPositiveInteger(id, 'mediaId');
1104
+
1105
+ const media = await client.getMediaByIds(ids);
1106
+ const found = new Set((media || []).map(item => item.id));
1107
+ const missing = ids.filter(id => !found.has(id));
1108
+ if (missing.length > 0) {
1109
+ throwToolError('MEDIA_NOT_FOUND', '存在しない mediaId が含まれています', false, { missingMediaIds: missing });
1110
+ }
1111
+ return media;
1112
+ }
1113
+
1114
+ async function findMediaFolder(client, normalized, mediaId) {
1115
+ if (!client.filebirdToken) {
1116
+ return { folderId: null, folderPath: null, warning: toolError('VALIDATION_ERROR', 'FileBird APIキーが未設定のため folder 情報は取得していません', false) };
1117
+ }
1118
+ const nodes = normalized.allNodes.filter(node => !node.isSystemFolder);
1119
+ for (const node of nodes) {
1120
+ const ids = await client.getFileBirdAttachmentIds(node.id);
1121
+ if ((ids || []).map(Number).includes(mediaId)) {
1122
+ return { folderId: node.id, folderPath: node.path };
1123
+ }
1124
+ }
1125
+ const uncategorizedIds = await client.getFileBirdAttachmentIds(0);
1126
+ if ((uncategorizedIds || []).map(Number).includes(mediaId)) {
1127
+ return { folderId: 0, folderPath: '' };
1128
+ }
1129
+ return { folderId: 0, folderPath: '' };
1130
+ }
1131
+
1132
+ const MAX_UPLOAD_FILES = 20;
1133
+ const MAX_ASP_BULK = 30;
1134
+ const MAX_UPLOAD_TOTAL_BYTES = 50 * 1024 * 1024;
1135
+ const DEFAULT_UPLOAD_CONCURRENCY = 3;
1136
+ const MAX_UPLOAD_CONCURRENCY = 5;
1137
+ const UPLOAD_FILE_TIMEOUT_MS = 30_000;
1138
+ const UPLOAD_TOTAL_TIMEOUT_MS = 5 * 60_000;
1139
+
1140
+ function isProvided(value) {
1141
+ return value !== undefined && value !== null;
1142
+ }
1143
+
1144
+ function requireString(value, fieldName, { allowEmpty = false } = {}) {
1145
+ if (typeof value !== 'string') {
1146
+ throwToolError('VALIDATION_ERROR', `${fieldName} は文字列で指定してください`, false);
1147
+ }
1148
+ if (!allowEmpty && value.trim() === '') {
1149
+ throwToolError('VALIDATION_ERROR', `${fieldName} は空にできません`, false);
1150
+ }
1151
+ return value;
1152
+ }
1153
+
1154
+ function optionalString(value, fieldName) {
1155
+ if (!isProvided(value)) return undefined;
1156
+ if (typeof value !== 'string') {
1157
+ throwToolError('VALIDATION_ERROR', `${fieldName} は文字列で指定してください。null は使用できません`, false);
1158
+ }
1159
+ return value;
1160
+ }
1161
+
1162
+ function validateUploadMode(args = {}) {
1163
+ const hasFilePath = isProvided(args.filePath);
1164
+ const hasFiles = isProvided(args.files);
1165
+ if (hasFilePath && hasFiles) {
1166
+ throwToolError('VALIDATION_ERROR', 'filePath と files は同時指定できません', false);
1167
+ }
1168
+ if (!hasFilePath && !hasFiles) {
1169
+ throwToolError('VALIDATION_ERROR', 'filePath または files のどちらかは必須です', false);
1170
+ }
1171
+ return hasFiles ? 'batch' : 'single';
1172
+ }
1173
+
1174
+ function filenameStem(filePath) {
1175
+ const base = basename(resolvePath(filePath));
1176
+ const ext = extname(base);
1177
+ return ext ? base.slice(0, -ext.length) : base;
1178
+ }
1179
+
1180
+ function normalizeUploadItems(args, mode) {
1181
+ if (mode === 'single') {
1182
+ const warnings = [];
1183
+ if (isProvided(args.concurrency)) {
1184
+ warnings.push(toolError('VALIDATION_ERROR', '単一モードでは concurrency は無効なため無視しました', false));
1185
+ }
1186
+ const filePath = requireString(args.filePath, 'filePath');
1187
+ const name = requireString(args.name, 'name').trim();
1188
+ const alt = requireString(args.alt, 'alt');
1189
+ return [{
1190
+ index: 0,
1191
+ filePath,
1192
+ name,
1193
+ alt,
1194
+ title: optionalString(args.title, 'title') ?? alt,
1195
+ caption: optionalString(args.caption, 'caption'),
1196
+ description: optionalString(args.description, 'description'),
1197
+ warnings,
1198
+ }];
1199
+ }
1200
+
1201
+ if (!Array.isArray(args.files)) {
1202
+ throwToolError('VALIDATION_ERROR', 'files は配列で指定してください', false);
1203
+ }
1204
+ if (args.files.length === 0) {
1205
+ throwToolError('VALIDATION_ERROR', 'files は1件以上指定してください', false);
1206
+ }
1207
+ if (args.files.length > MAX_UPLOAD_FILES) {
1208
+ throwToolError('VALIDATION_ERROR', `files は1コール最大${MAX_UPLOAD_FILES}件までです`, false);
1209
+ }
1210
+
1211
+ return args.files.map((file, index) => {
1212
+ if (!file || typeof file !== 'object' || Array.isArray(file)) {
1213
+ throwToolError('VALIDATION_ERROR', `files[${index}] は object で指定してください`, false);
1214
+ }
1215
+ const filePath = requireString(file.filePath, `files[${index}].filePath`);
1216
+ const warnings = [];
1217
+ const rawName = optionalString(file.name, `files[${index}].name`);
1218
+ const name = rawName && rawName.trim() ? rawName.trim() : filenameStem(filePath);
1219
+ const altProvided = Object.prototype.hasOwnProperty.call(file, 'alt');
1220
+ const alt = altProvided ? optionalString(file.alt, `files[${index}].alt`) : '';
1221
+ if (!altProvided) {
1222
+ warnings.push(toolError('VALIDATION_ERROR', 'alt が未指定のため空文字でアップロードします', false));
1223
+ }
1224
+ return {
1225
+ index,
1226
+ filePath,
1227
+ name,
1228
+ alt,
1229
+ title: optionalString(file.title, `files[${index}].title`) ?? name,
1230
+ caption: optionalString(file.caption, `files[${index}].caption`),
1231
+ description: optionalString(file.description, `files[${index}].description`),
1232
+ warnings,
1233
+ };
1234
+ });
1235
+ }
1236
+
1237
+ function getUploadConcurrency(args, mode) {
1238
+ if (mode === 'single') return 1;
1239
+ if (!isProvided(args.concurrency)) return DEFAULT_UPLOAD_CONCURRENCY;
1240
+ const concurrency = assertInteger(args.concurrency, 'concurrency', { min: 1 });
1241
+ if (concurrency > MAX_UPLOAD_CONCURRENCY) {
1242
+ throwToolError('VALIDATION_ERROR', `concurrency は最大${MAX_UPLOAD_CONCURRENCY}です`, false);
1243
+ }
1244
+ return concurrency;
1245
+ }
1246
+
1247
+ function prepareUploadItems(items) {
1248
+ const seenPaths = new Map();
1249
+ let totalBytes = 0;
1250
+ for (const item of items) {
1251
+ const resolved = resolvePath(item.filePath);
1252
+ item.resolvedPath = resolved;
1253
+ if (seenPaths.has(resolved)) {
1254
+ item.warnings.push(toolError('VALIDATION_ERROR', `同一 filePath が重複しています (firstIndex=${seenPaths.get(resolved)})`, false));
1255
+ } else {
1256
+ seenPaths.set(resolved, item.index);
1257
+ }
1258
+ try {
1259
+ const stat = statSync(resolved);
1260
+ if (!stat.isFile()) {
1261
+ item.preError = toolError('VALIDATION_ERROR', `Not a file: ${resolved}`, false);
1262
+ continue;
1263
+ }
1264
+ totalBytes += stat.size;
1265
+ try {
1266
+ item.fileInfo = validateImageFile(item.filePath);
1267
+ } catch (e) {
1268
+ item.preError = toToolError(e);
1269
+ }
1270
+ } catch (e) {
1271
+ item.preError = e.code === 'ENOENT'
1272
+ ? toolError('FILE_NOT_FOUND', `File not found: ${resolved}`, false)
1273
+ : toolError('VALIDATION_ERROR', `File access error: ${resolved} (${e.message})`, false);
1274
+ }
1275
+ }
1276
+ if (totalBytes > MAX_UPLOAD_TOTAL_BYTES) {
1277
+ throwToolError('TOO_LARGE', `Total upload size too large: ${(totalBytes / 1048576).toFixed(1)}MB (max: 50MB)`, false);
1278
+ }
1279
+ return items;
1280
+ }
1281
+
1282
+ function buildUploadMetaData(item) {
1283
+ const data = { title: item.title, alt_text: item.alt };
1284
+ if (item.caption !== undefined) data.caption = item.caption;
1285
+ if (item.description !== undefined) data.description = item.description;
1286
+ return data;
1287
+ }
1288
+
1289
+ async function createMissingUploadFolder(client, normalized, folderPath) {
1290
+ let normalizedPath;
1291
+ try {
1292
+ normalizedPath = normalizeFolderPath(folderPath);
1293
+ } catch (e) {
1294
+ throwToolError('VALIDATION_ERROR', e.message, false);
1295
+ }
1296
+ const segments = normalizedPath.split('/');
1297
+ const folderName = segments[segments.length - 1];
1298
+ const parentPath = segments.slice(0, -1).join('/');
1299
+ let parent;
1300
+ if (parentPath) {
1301
+ let parentId;
1302
+ try {
1303
+ parentId = resolveFolderPath(normalized, parentPath);
1304
+ } catch (_e) {
1305
+ throwToolError('FOLDER_NOT_FOUND', `createIfMissing は最後の1セグメントのみ作成できます。親フォルダ "${parentPath}" が存在しません`, false);
1306
+ }
1307
+ parent = getFolderNodeById(normalized, parentId, 'parent');
1308
+ } else {
1309
+ parent = normalized.byId.get(0);
1310
+ }
1311
+
1312
+ const existing = normalized.allNodes.find(node =>
1313
+ !node.isSystemFolder && node.parentId === parent.id && node.name === folderName
1314
+ );
1315
+ if (existing) {
1316
+ return { id: existing.id, node: existing, path: existing.path, created: false };
1317
+ }
1318
+
1319
+ const created = await client.createFileBirdFolder(folderName, parent.id);
1320
+ if (!created?.id) {
1321
+ throwToolError('UNKNOWN', 'FileBird フォルダ作成 API が id を返しませんでした', true);
1322
+ }
1323
+
1324
+ const after = await getNormalizedFileBirdFolders(client);
1325
+ const node = after.byId.get(created.id);
1326
+ const expectedPath = parent.path ? `${parent.path}/${folderName}` : folderName;
1327
+ if (!node || node.name !== folderName || node.parentId !== parent.id || node.path !== expectedPath) {
1328
+ throwToolError('DUPLICATE_FOLDER_NAME', '作成後検証でフォルダ名またはパスが期待値と一致しません。FileBird が同名フォルダを自動リネームした可能性があります', false, {
1329
+ expected: { name: folderName, parentId: parent.id, path: expectedPath },
1330
+ actualFolder: node ? { id: node.id, name: node.name, parentId: node.parentId, path: node.path, count: node.count } : null,
1331
+ recoveryHint: 'WordPress 管理画面で actualFolder を確認し、必要なら手動整理してください',
1332
+ });
1333
+ }
1334
+ return { id: node.id, node, path: node.path, created: true };
1335
+ }
1336
+
1337
+ async function resolveUploadFolder(client, args = {}) {
1338
+ const hasFolder = isProvided(args.folder);
1339
+ const hasFolderId = isProvided(args.folderId);
1340
+ const createIfMissing = args.createIfMissing === true;
1341
+ if (isProvided(args.createIfMissing) && typeof args.createIfMissing !== 'boolean') {
1342
+ throwToolError('VALIDATION_ERROR', 'createIfMissing は boolean で指定してください', false);
1343
+ }
1344
+ if (!hasFolder && !hasFolderId) {
1345
+ if (createIfMissing) {
1346
+ throwToolError('VALIDATION_ERROR', 'createIfMissing=true の場合は folder を指定してください', false);
1347
+ }
1348
+ return null;
1349
+ }
1350
+ if (createIfMissing && !hasFolder) {
1351
+ throwToolError('VALIDATION_ERROR', 'folderId のみ指定時は createIfMissing を使用できません', false);
1352
+ }
1353
+ if (hasFolder && typeof args.folder !== 'string') {
1354
+ throwToolError('VALIDATION_ERROR', 'folder は文字列で指定してください', false);
1355
+ }
1356
+
1357
+ const normalized = await getNormalizedFileBirdFolders(client);
1358
+ try {
1359
+ return resolveFolderTarget(normalized, { folder: args.folder, folderId: args.folderId });
1360
+ } catch (e) {
1361
+ if (!createIfMissing) {
1362
+ if (e?.toolError) throw e;
1363
+ throwToolError('FOLDER_NOT_FOUND', e.message, false);
1364
+ }
1365
+ if (e?.toolError?.code === 'VALIDATION_ERROR') {
1366
+ throw e;
1367
+ }
1368
+ if (hasFolderId) {
1369
+ throwToolError('VALIDATION_ERROR', 'folder + folderId 指定時に folder が存在しないため createIfMissing できません', false);
1370
+ }
1371
+ return createMissingUploadFolder(client, normalized, args.folder);
1372
+ }
1373
+ }
1374
+
1375
+ async function runWithConcurrency(items, concurrency, worker, deadline = null) {
1376
+ const results = new Array(items.length);
1377
+ let next = 0;
1378
+ async function run() {
1379
+ while (next < items.length) {
1380
+ const current = next++;
1381
+ const item = items[current];
1382
+ if (deadline && Date.now() >= deadline) {
1383
+ results[current] = {
1384
+ index: item.index,
1385
+ success: false,
1386
+ filePath: item.filePath,
1387
+ error: toolError('TIMEOUT', 'upload_media の全体タイムアウトに達したため、このファイルは開始されませんでした', true),
1388
+ warnings: item.warnings || [],
1389
+ };
1390
+ continue;
1391
+ }
1392
+ try {
1393
+ results[current] = await worker(item);
1394
+ } catch (error) {
1395
+ results[current] = {
1396
+ index: item.index,
1397
+ success: false,
1398
+ filePath: item.filePath,
1399
+ error: toToolError(error),
1400
+ warnings: item.warnings || [],
1401
+ };
1402
+ }
1403
+ }
1404
+ }
1405
+ await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, run));
1406
+ return results;
1407
+ }
1408
+
1409
+ async function uploadOneMedia(client, item) {
1410
+ const warnings = [...(item.warnings || [])];
1411
+ if (item.preError) {
1412
+ return { index: item.index, success: false, filePath: item.filePath, error: item.preError, warnings };
1413
+ }
1414
+
1415
+ let fileInfo;
1416
+ try {
1417
+ fileInfo = item.fileInfo || validateImageFile(item.filePath);
1418
+ } catch (e) {
1419
+ return { index: item.index, success: false, filePath: item.filePath, error: toToolError(e), warnings };
1420
+ }
1421
+
1422
+ let media;
1423
+ const uploadFilename = `${item.name}${fileInfo.ext}`;
1424
+ try {
1425
+ const fileBuffer = readFileSync(fileInfo.resolved);
1426
+ media = await client.uploadMedia(fileBuffer, uploadFilename, fileInfo.contentType, { timeoutMs: UPLOAD_FILE_TIMEOUT_MS });
1427
+ } catch (e) {
1428
+ return { index: item.index, success: false, filePath: item.filePath, error: toToolError(e), warnings };
1429
+ }
1430
+
1431
+ try {
1432
+ await client.updateMediaMeta(media.id, buildUploadMetaData(item));
1433
+ } catch (e) {
1434
+ warnings.push(toToolError(e));
1435
+ }
1436
+
1437
+ return {
1438
+ index: item.index,
1439
+ success: true,
1440
+ mediaId: media.id,
1441
+ sourceUrl: media.source_url || '',
1442
+ filePath: item.filePath,
1443
+ filename: uploadFilename,
1444
+ title: item.title,
1445
+ alt: item.alt,
1446
+ width: media.media_details?.width || null,
1447
+ height: media.media_details?.height || null,
1448
+ folderId: null,
1449
+ folderPath: null,
1450
+ folderAssignmentSuccess: null,
1451
+ warnings,
1452
+ };
1453
+ }
1454
+
1455
+ async function applyFolderAssignment(client, results, targetFolder) {
1456
+ if (!targetFolder) return results;
1457
+ const successResults = results.filter(result => result?.success && result.mediaId);
1458
+ if (successResults.length === 0) return results;
1459
+
1460
+ let ok = false;
1461
+ let assignmentError = null;
1462
+ try {
1463
+ ok = await client.setFileBirdAttachmentFolder(successResults.map(result => result.mediaId), targetFolder.id);
1464
+ if (ok !== true) {
1465
+ assignmentError = toolError('FILEBIRD_SET_ATTACHMENT_FAILED', 'アップロードは成功しましたが FileBird フォルダ割当に失敗しました', true);
1466
+ }
1467
+ } catch (e) {
1468
+ assignmentError = toToolError(e, 'FILEBIRD_SET_ATTACHMENT_FAILED');
1469
+ assignmentError.code = assignmentError.code === 'UNKNOWN' ? 'FILEBIRD_SET_ATTACHMENT_FAILED' : assignmentError.code;
1470
+ assignmentError.retryable = assignmentError.retryable ?? true;
1471
+ }
1472
+
1473
+ for (const result of successResults) {
1474
+ if (ok === true) {
1475
+ result.folderId = targetFolder.id;
1476
+ result.folderPath = targetFolder.path;
1477
+ result.folderAssignmentSuccess = true;
1478
+ } else {
1479
+ result.folderId = null;
1480
+ result.folderPath = null;
1481
+ result.folderAssignmentSuccess = false;
1482
+ result.folderAssignmentError = {
1483
+ ...assignmentError,
1484
+ recoveryHint: `move_media_to_folder で mediaId ${result.mediaId} を folderId ${targetFolder.id} に移動可能`,
1485
+ };
1486
+ }
1487
+ }
1488
+ return results;
1489
+ }
1490
+
1491
+ function buildUploadSummary(results) {
1492
+ const total = results.length;
1493
+ const succeeded = results.filter(result => result?.success).length;
1494
+ const failed = total - succeeded;
1495
+ const folderAssignmentFailed = results.filter(result => result?.success && result.folderAssignmentSuccess === false).length;
1496
+ return { total, succeeded, failed, folderAssignmentFailed };
1497
+ }
1498
+
1499
+ /**
1500
+ * 単一クライアントの Editor/Headless 判定
1501
+ * @returns {{ mode: 'editor'|'headless'|'error', postId?: number, message?: string, client?: FridayWPClient }}
1502
+ */
1503
+ async function checkSingleClient(client, args) {
1504
+ try {
1505
+ const rawPostId = args?.postId;
1506
+ const numPostId = rawPostId != null ? Number(rawPostId) : null;
1507
+ const isNumeric = numPostId != null && Number.isFinite(numPostId) && numPostId > 0;
1508
+
1509
+ let connectedEditors = [];
1510
+
1511
+ // Bridge 経由で editor 一覧取得(Bridge 必須)
1512
+ if (bridgeClient) {
1513
+ const siteUrl = client.wpUrl?.replace(/\/+$/, '');
1514
+ connectedEditors = await getBridgeEditors(siteUrl);
1515
+ }
1516
+
1517
+ const hasEditors = connectedEditors.length > 0;
1518
+ if (!hasEditors) {
1519
+ if (rawPostId == null) return { mode: 'error', message: 'エディタ未接続' };
1520
+ if (isNumeric) return { mode: 'headless', postId: numPostId, client, connectedEditors };
1521
+ return { mode: 'headless', postId: null, client, connectedEditors };
1522
+ }
1523
+
1524
+ // connectedEditors 配列を使った editor/headless 判定
1525
+ if (rawPostId != null) {
1526
+ if (isNumeric) {
1527
+ const isEditing = connectedEditors.some(e => Number(e.postId) === numPostId);
1528
+ if (isEditing) {
1529
+ const sessions = connectedEditors.filter(e => Number(e.postId) === numPostId);
1530
+ const sessionId = sessions.length === 1 ? sessions[0].sessionId : null;
1531
+ return { mode: 'editor', postId: numPostId, client, sessionId, connectedEditors };
1532
+ }
1533
+ return { mode: 'headless', postId: numPostId, client, connectedEditors };
1534
+ }
1535
+ return { mode: 'headless', postId: null, client, connectedEditors };
1536
+ }
1537
+
1538
+ // postId 省略
1539
+ if (connectedEditors.length === 1) {
1540
+ return { mode: 'editor', postId: connectedEditors[0].postId, client, sessionId: connectedEditors[0].sessionId, connectedEditors };
1541
+ }
1542
+ if (connectedEditors.length > 1) {
1543
+ const list = connectedEditors.map(e =>
1544
+ ` postId: ${e.postId} (${e.postTitle || 'untitled'}) sessionId: ${e.sessionId}`
1545
+ ).join('\n');
1546
+ return {
1547
+ mode: 'error',
1548
+ message: `複数のエディタが接続中です。postId を指定してください。\n\n接続中:\n${list}`,
1549
+ connectedEditors,
1550
+ };
1551
+ }
1552
+ return { mode: 'error', message: 'エディタ未接続' };
1553
+ } catch (e) {
1554
+ return { mode: 'error', message: `接続エラー: ${e.message}` };
1555
+ }
1556
+ }
1557
+
1558
+ function formatHeadlessConflictError(e) {
1559
+ if (e.wpErrorCode === 'headless_conflict') {
1560
+ return {
1561
+ content: [{ type: "text", text:
1562
+ "❌ この記事はエディタで開かれているため、headless アクセスがブロックされました。" +
1563
+ "\nエディタコマンドを使用するか、エディタのタブを閉じてから再試行してください。"
1564
+ }],
1565
+ isError: true,
1566
+ };
1567
+ }
1568
+ return null;
1569
+ }
1570
+
1571
+ /**
1572
+ * Editor/Headless モード判定ヘルパー(複数接続自動検出対応)
1573
+ * @returns {{ mode: 'editor'|'headless'|'error', postId?: number, message?: string, client?: FridayWPClient }}
1574
+ */
1575
+ async function resolveMode(args, _callerTool) {
1576
+ const _debugRM = process.env.FRIDAY_DEBUG === '1';
1577
+ function _logResolve(result, path) {
1578
+ if (_debugRM) console.error(`[resolveMode] tool=${_callerTool||'?'} mode=${result.mode} postId=${result.postId||'?'} site=${result.siteName||args?.site||'?'} path=${path}`);
1579
+ return result;
1580
+ }
1581
+ // site 明示指定
1582
+ if (args?.site) {
1583
+ let client;
1584
+ try { client = registry.get(args.site); }
1585
+ catch (e) { return _logResolve({ mode: 'error', message: e.message }, 'site-error'); }
1586
+ const result = await checkSingleClient(client, args);
1587
+ return _logResolve({ ...result, siteName: args.site }, 'site-explicit');
1588
+ }
1589
+
1590
+ const allConns = registry.getAll();
1591
+ const isMultiSite = allConns.length > 1;
1592
+
1593
+ // 単一接続 → 従来通り
1594
+ if (!isMultiSite) {
1595
+ const result = await checkSingleClient(registry.get(), args);
1596
+ return _logResolve({ ...result, siteName: allConns[0].name }, 'single');
1597
+ }
1598
+
1599
+ // 複数接続 + sticky キャッシュ有効
1600
+ const cached = statusCache.getLast();
1601
+ if (cached) {
1602
+ const cachedResult = await checkSingleClient(cached.client, args);
1603
+ if (cachedResult.mode === 'editor') {
1604
+ return _logResolve({ ...cachedResult, siteName: cached.site }, 'cache-hit');
1605
+ }
1606
+ statusCache.clear(cached.site);
1607
+ }
1608
+
1609
+ // default を先に確認
1610
+ const defaultClient = registry.get();
1611
+ const defaultName = allConns.find(c => c.client === defaultClient)?.name || 'default';
1612
+ const defaultResult = await checkSingleClient(defaultClient, args);
1613
+ if (defaultResult.mode === 'editor') {
1614
+ statusCache.set(defaultName, defaultClient, defaultResult.postId);
1615
+ return _logResolve({ ...defaultResult, siteName: defaultName }, 'default-editor');
1616
+ }
1617
+ // defaultResult.mode === 'headless' の場合:
1618
+ // 他の接続にエディタが接続中の可能性があるため、並列スキャンに進む
1619
+
1620
+ // Bridge ルート(IPC 経由で即座に判定)
1621
+ if (bridgeClient) {
1622
+ let storeConnected;
1623
+ try {
1624
+ storeConnected = [];
1625
+ for (const { name, client } of allConns) {
1626
+ const siteUrl = client.wpUrl?.replace(/\/+$/, '');
1627
+ const editors = await getBridgeEditors(siteUrl);
1628
+ if (editors.length > 0) {
1629
+ storeConnected.push({ name, client, connectedEditors: editors });
1630
+ }
1631
+ }
1632
+ } catch (bridgeErr) {
1633
+ return _logResolve({
1634
+ mode: 'error',
1635
+ message: `Bridge 接続エラー: ${bridgeErr.message}\nBridge が起動しているか確認してください。`,
1636
+ }, 'bridge-connection-error');
1637
+ }
1638
+ if (storeConnected.length === 1) {
1639
+ const { name, client, connectedEditors } = storeConnected[0];
1640
+ if (args?.postId != null) {
1641
+ const numPid = Number(args.postId);
1642
+ const isNum = Number.isFinite(numPid) && numPid > 0;
1643
+ const isEditing = isNum && connectedEditors.some(e => Number(e.postId) === numPid);
1644
+ if (!isEditing) {
1645
+ const connNames = allConns.map(c => c.name).join(', ');
1646
+ return _logResolve({
1647
+ mode: 'error',
1648
+ message: `複数サイトが登録されています。Headless モードでは site の指定が必須です。\n利用可能: ${connNames}`,
1649
+ }, 'store-scan-mismatch');
1650
+ }
1651
+ const sessions = connectedEditors.filter(e => Number(e.postId) === numPid);
1652
+ const sessionId = sessions.length === 1 ? sessions[0].sessionId : null;
1653
+ statusCache.set(name, client, numPid);
1654
+ return _logResolve({ mode: 'editor', postId: numPid, client, siteName: name, sessionId, connectedEditors }, 'store-scan-editor');
1655
+ }
1656
+ // postId 省略
1657
+ if (connectedEditors.length === 1) {
1658
+ statusCache.set(name, client, connectedEditors[0].postId);
1659
+ return _logResolve({ mode: 'editor', postId: connectedEditors[0].postId, client, siteName: name, sessionId: connectedEditors[0].sessionId, connectedEditors }, 'store-scan-single');
1660
+ }
1661
+ if (connectedEditors.length > 1) {
1662
+ const list = connectedEditors.map(e =>
1663
+ ` postId: ${e.postId} (${e.postTitle || 'untitled'}) sessionId: ${e.sessionId}`
1664
+ ).join('\n');
1665
+ return _logResolve({ mode: 'error', message: `複数のエディタが接続中です。postId を指定してください。\n\n接続中:\n${list}`, connectedEditors }, 'store-scan-multi-editor');
1666
+ }
1667
+ }
1668
+ if (storeConnected.length > 1) {
1669
+ const list = storeConnected.map(c =>
1670
+ ` ${c.name}: ${c.connectedEditors.map(e => `postId ${e.postId}`).join(', ')} (${c.client.wpUrl})`
1671
+ ).join('\n');
1672
+ return _logResolve({
1673
+ mode: 'error',
1674
+ message: `複数のエディタが接続中です。site パラメータで接続先を指定してください。\n\n接続中:\n${list}\n\n例: get_article_structure({ site: "${storeConnected[0].name}" })`
1675
+ }, 'store-scan-multi-site');
1676
+ }
1677
+ // Store で見つからない → Bridge 経由で見つからない = エディタ未接続
1678
+ }
1679
+
1680
+ // Bridge で全接続にエディタが見つからなかった
1681
+ if (args?.postId != null) {
1682
+ const connNames = allConns.map(c => c.name).join(', ');
1683
+ return _logResolve({
1684
+ mode: 'error',
1685
+ message: `複数サイトが登録されています。Headless モードでは site の指定が必須です。\n利用可能: ${connNames}`,
1686
+ }, 'bridge-no-editor-postId');
1687
+ }
1688
+
1689
+ return _logResolve({
1690
+ mode: 'error',
1691
+ message: 'エディタ未接続です。postId を指定して Headless モードを使用するか、エディタで記事を開いてください。\n接続一覧の確認: list_connections()'
1692
+ }, 'bridge-no-editor-noPid');
1693
+ }
1694
+
1695
+ /**
1696
+ * postId を解決する(数値はそのまま、文字列は slug として検索)
1697
+ * slug 解決後にエディタ競合チェックも実施
812
1698
  */
813
1699
  async function resolvePostId(rawPostId, client, { skipConflictCheck = false } = {}) {
814
1700
  if (rawPostId == null) return { postId: null };
@@ -1864,14 +2750,26 @@ function buildBatchDiffInfo(resolvedOps, snapshot) {
1864
2750
  * @param {string} siteName
1865
2751
  * @param {string} _modeTag
1866
2752
  * @param {string} toolName
2753
+ * @param {string} [expectedRevision]
2754
+ * @param {object} [options]
2755
+ * @param {object} [options.preFetchedState] - 呼び出し元が同一 tick で取得済みの currentState を渡せば、
2756
+ * 内部の getCurrentStructure 再実行をスキップして使い回す(重複取得の回避)。
2757
+ * 渡す側は「revision check 済み or 同 tick の state」であることを保証する責任を負う。
2758
+ * @returns {Promise<{content:Array, isError?:boolean, _structuredResult?:object}>}
2759
+ * 成功時は _structuredResult.operations[].status ('updated'|'expanded'|'failed'|'skipped') と
2760
+ * snapshot/successCount/totalCount を含む。エラー早期 return では _structuredResult は付かない。
1867
2761
  */
1868
- async function handleBatchOperations(operations, snapshotId, mode, client, postId, sessionId, siteName, _modeTag, toolName, expectedRevision) {
1869
- // 1. 全 operation の target を正規化 & ref を一括解決
1870
- let currentState;
1871
- try {
1872
- currentState = await getCurrentStructure(mode, client, postId, sessionId, { safetyCritical: true });
1873
- } catch (e) {
1874
- return { content: [{ type: "text", text: `❌ 現在のブロック構造を取得できませんでした: ${e.message}` }], isError: true };
2762
+ async function handleBatchOperations(operations, snapshotId, mode, client, postId, sessionId, siteName, _modeTag, toolName, expectedRevision, options = {}) {
2763
+ // 1. 全 operation の target を正規化 & ref を一括解決
2764
+ let currentState;
2765
+ if (options.preFetchedState && Array.isArray(options.preFetchedState.allBlocks)) {
2766
+ currentState = options.preFetchedState;
2767
+ } else {
2768
+ try {
2769
+ currentState = await getCurrentStructure(mode, client, postId, sessionId, { safetyCritical: true });
2770
+ } catch (e) {
2771
+ return { content: [{ type: "text", text: `❌ 現在のブロック構造を取得できませんでした: ${e.message}` }], isError: true };
2772
+ }
1875
2773
  }
1876
2774
 
1877
2775
  // revision 楽観的ロック(expectedRevision が指定されている場合のみ)
@@ -1942,7 +2840,7 @@ async function handleBatchOperations(operations, snapshotId, mode, client, postI
1942
2840
  for (const op of resolvedOps) {
1943
2841
  if (indexSet.has(op.resolvedIndex)) {
1944
2842
  return {
1945
- content: [{ type: "text", text: `❌ index ${op.resolvedIndex} (ref: ${op.ref}) に対して複数の operation が指定されています。同一ブロックへの複数操作は許可されていません。` }],
2843
+ content: [{ type: "text", text: `❌ duplicate target: multiple operations point to the same block (index ${op.resolvedIndex}, ref ${op.ref}). Each block can be modified at most once per call. To apply multiple changes to one block, split them into separate calls and chain the snapshotId returned by each write response.` }],
1946
2844
  isError: true,
1947
2845
  };
1948
2846
  }
@@ -2077,7 +2975,21 @@ async function handleBatchOperations(operations, snapshotId, mode, client, postI
2077
2975
  text = appendSnapshotToTextLegacy(text + _modeTag, snapshot);
2078
2976
  }
2079
2977
 
2080
- return { content: [{ type: "text", text }] };
2978
+ const _structuredResult = {
2979
+ operations: resolvedOps.map(op => ({
2980
+ opIndex: op.opIndex,
2981
+ ref: op.ref,
2982
+ status: op.status,
2983
+ error: op.error || null,
2984
+ rawNewIndices: op.rawNewIndices || [],
2985
+ rebasedNewIndices: op.rebasedNewIndices || [],
2986
+ })),
2987
+ snapshot: snapshot ? { snapshotId: snapshot.snapshotId, revision: snapshot.revision } : null,
2988
+ successCount,
2989
+ totalCount,
2990
+ };
2991
+
2992
+ return { content: [{ type: "text", text }], _structuredResult };
2081
2993
  }
2082
2994
 
2083
2995
  // フィードバック送信ヘルパー
@@ -2381,13 +3293,14 @@ const tools = [
2381
3293
  },
2382
3294
  {
2383
3295
  name: "list_blog_parts",
2384
- description: "List or search blog parts (reusable blocks). SWELL theme only.",
3296
+ description: "List or search blog parts (reusable blocks). When partsId is specified, returns posts/pages that directly use that blog parts. SWELL theme only.",
2385
3297
  inputSchema: {
2386
3298
  type: "object",
2387
3299
  properties: {
2388
3300
  site: siteParam,
2389
- search: { type: "string", description: "Keyword search" },
2390
- status: { type: "string", enum: ["publish", "draft", "private", "any"], description: "Status filter (default: publish)" },
3301
+ partsId: { type: "integer", description: "Blog parts ID. When specified, returns posts/pages that directly use this blog parts instead of listing blog parts." },
3302
+ search: { type: "string", description: "Keyword search (ignored when partsId is set)" },
3303
+ status: { type: "string", enum: ["publish", "draft", "private", "any"], description: "Status filter (default: publish, ignored when partsId is set)" },
2391
3304
  per_page: { type: "integer", description: "Per page (1-100)", minimum: 1, maximum: 100 },
2392
3305
  page: { type: "integer", description: "Page number", minimum: 1 },
2393
3306
  },
@@ -2411,12 +3324,12 @@ const tools = [
2411
3324
  },
2412
3325
  {
2413
3326
  name: "create_blog_parts",
2414
- description: "Create a new blog parts (reusable block). SWELL theme only. Returns ID and editor URL.",
3327
+ description: "Create a new blog parts (reusable block). SWELL theme only. Returns partsId and editor URL. To insert into an article, use insert_blog_parts with the returned partsId.",
2415
3328
  inputSchema: {
2416
3329
  type: "object",
2417
3330
  properties: {
2418
3331
  title: { type: "string", description: "Blog parts title" },
2419
- status: { type: "string", enum: ["publish", "draft", "pending", "private"], description: "Status (default: draft)" },
3332
+ status: { type: "string", enum: ["publish", "draft", "pending", "private"], description: "Status (default: publish)" },
2420
3333
  slug: { type: "string", description: "URL slug" },
2421
3334
  content: { type: "string", description: "HTML content (exclusive with filePath)" },
2422
3335
  filePath: { type: "string", description: "Local file path (.md or .html, exclusive with content)" },
@@ -2456,6 +3369,23 @@ const tools = [
2456
3369
  required: ["partsId"],
2457
3370
  },
2458
3371
  },
3372
+ {
3373
+ name: "insert_blog_parts",
3374
+ description: "Insert a blog parts block into an article. Builds the correct block HTML automatically.",
3375
+ inputSchema: {
3376
+ type: "object",
3377
+ properties: {
3378
+ partsId: { type: "integer", description: "Blog parts ID (required)" },
3379
+ postId: postIdParam,
3380
+ site: siteParam,
3381
+ snapshotId: { type: "string", description: "Snapshot ID (from get_article_structure or any write response). Required when using beforeRef/afterRef. Omit to append to end." },
3382
+ expectedRevision: { type: "string", description: "Revision from snapshot. Rejects if structure changed." },
3383
+ beforeRef: { type: "string", description: "Insert before this ref." },
3384
+ afterRef: { type: "string", description: "Insert after this ref." },
3385
+ },
3386
+ required: ["partsId"],
3387
+ },
3388
+ },
2459
3389
  {
2460
3390
  name: "list_taxonomies",
2461
3391
  description: "List categories or tags.",
@@ -2504,7 +3434,7 @@ const tools = [
2504
3434
  },
2505
3435
  {
2506
3436
  name: "get_block_html",
2507
- description: "Get block HTML. Use before newHTML to safely edit custom/theme blocks.",
3437
+ description: "Get block HTML. Use before newHTML to safely edit custom/theme blocks. Blog parts (loos/blog-parts) are auto-expanded to show inner content. To edit blog parts content, use update_blog_parts.",
2508
3438
  inputSchema: {
2509
3439
  type: "object",
2510
3440
  properties: {
@@ -2680,32 +3610,151 @@ const tools = [
2680
3610
  },
2681
3611
  {
2682
3612
  name: "upload_media",
2683
- description: "Upload image to WordPress media library. Returns media ID, URL, dimensions.",
3613
+ description: "Upload one or more images to WordPress media library. Supports FileBird folder assignment and structured JSON results.",
2684
3614
  inputSchema: {
2685
3615
  type: "object",
2686
3616
  properties: {
2687
3617
  site: siteParam,
2688
- filePath: { type: "string", description: "Absolute path to image file on local machine" },
2689
- name: { type: "string", description: "New filename (without extension, ASCII/hyphens recommended)" },
2690
- alt: { type: "string", description: "Alt text and title (Japanese OK)" },
2691
- title: { type: "string", description: "Title (defaults to alt if omitted)" },
3618
+ filePath: { type: "string", description: "Single mode: absolute path to image file on local machine. Mutually exclusive with files." },
3619
+ name: { type: "string", description: "Single mode: new filename without extension (required with filePath)" },
3620
+ alt: { type: "string", description: "Single mode: alt text (required with filePath)" },
3621
+ title: { type: "string", description: "Single mode: title (defaults to alt if omitted)" },
3622
+ caption: { type: "string", description: "Single mode: caption. Omit to leave unset; empty string clears." },
3623
+ description: { type: "string", description: "Single mode: description. Omit to leave unset; empty string clears." },
3624
+ files: {
3625
+ type: "array",
3626
+ minItems: 1,
3627
+ maxItems: 20,
3628
+ description: "Batch mode: upload 1-20 files. Mutually exclusive with filePath.",
3629
+ items: {
3630
+ type: "object",
3631
+ properties: {
3632
+ filePath: { type: "string", description: "Absolute path to image file on local machine" },
3633
+ name: { type: "string", description: "Filename without extension. Defaults to basename(filePath)." },
3634
+ alt: { type: "string", description: "Alt text. Defaults to empty string with warning." },
3635
+ title: { type: "string", description: "Title. Defaults to name." },
3636
+ caption: { type: "string", description: "Caption. Omit to leave unset; empty string clears." },
3637
+ description: { type: "string", description: "Description. Omit to leave unset; empty string clears." },
3638
+ },
3639
+ required: ["filePath"],
3640
+ },
3641
+ },
3642
+ folder: { type: "string", description: "FileBird destination folder path, e.g. '親/子'. May be combined with folderId for consistency check." },
3643
+ folderId: { type: "integer", description: "FileBird destination folder ID. 0 means Uncategorized; -1 is invalid." },
3644
+ createIfMissing: { type: "boolean", description: "Create the missing final folder segment when folder path parent exists. Does not create multi-segment paths." },
3645
+ concurrency: { type: "integer", minimum: 1, maximum: 5, description: "Batch mode concurrency. Default 3, max 5. Ignored in single mode with warning." },
2692
3646
  },
2693
- required: ["filePath", "name", "alt"],
2694
3647
  },
2695
3648
  },
2696
3649
  {
2697
3650
  name: "search_media",
2698
- description: "Search WordPress media library. Auto-generates hiragana/katakana/romaji variants for broader matching.",
3651
+ description: "Search WordPress media library (images). Auto-generates hiragana/katakana/romaji variants. Returns structured JSON. Scope to a FileBird folder by path or folderId, optionally recursive (include descendant folders).",
2699
3652
  inputSchema: {
2700
3653
  type: "object",
2701
3654
  properties: {
2702
3655
  site: siteParam,
2703
- query: { type: "string", description: "Search keyword" },
3656
+ query: { type: "string", description: "Search keyword. Either query or folder/folderId is required." },
3657
+ folder: { type: "string", description: "FileBird folder path, e.g. '親/子'. Scopes search/listing to this folder." },
3658
+ folderId: { type: "integer", description: "FileBird folder ID (fallback for folder; 0 = Uncategorized). If both folder and folderId are given they must agree." },
3659
+ recursive: { type: "boolean", description: "When scoping by folder/folderId, also include media in descendant folders. Default false." },
2704
3660
  strip: { type: "array", items: { type: "string" }, description: "Suffixes to strip (e.g. ['先生', 'さん'])" },
2705
3661
  prefix: { type: "array", items: { type: "string" }, description: "Prefixes to add (e.g. ['エキサイト-'])" },
2706
- per_page: { type: "integer", description: "Results per variant (1-20, default: 10)", minimum: 1, maximum: 20 },
3662
+ per_page: { type: "integer", description: "Results per variant for global search (1-20, default 10). Ignored when scoped by folder/folderId (all in-scope matches are returned).", minimum: 1, maximum: 20 },
3663
+ },
3664
+ },
3665
+ },
3666
+ {
3667
+ name: "list_media_folders",
3668
+ description: "List FileBird media folders. Returns structured JSON with nested folders, IDs, paths, and file counts.",
3669
+ inputSchema: {
3670
+ type: "object",
3671
+ properties: {
3672
+ site: siteParam,
3673
+ },
3674
+ },
3675
+ },
3676
+ {
3677
+ name: "create_media_folder",
3678
+ description: "Create a FileBird media folder. Use parent path or parentId; folder names containing / are rejected. Does not delete or rename folders.",
3679
+ inputSchema: {
3680
+ type: "object",
3681
+ properties: {
3682
+ site: siteParam,
3683
+ name: { type: "string", description: "Folder name (single segment; / and full-width slash are rejected)" },
3684
+ parent: { type: "string", description: "Parent folder path, e.g. '親/子'. Omit for root." },
3685
+ parentId: { type: "integer", description: "Parent FileBird folder ID. 0 means root." },
3686
+ },
3687
+ required: ["name"],
3688
+ },
3689
+ },
3690
+ {
3691
+ name: "move_media_to_folder",
3692
+ description: "Move existing WordPress media items to a FileBird folder. This is not deletion. Use folder path or folderId; folderId=0 means Uncategorized, folderId=-1 is rejected.",
3693
+ inputSchema: {
3694
+ type: "object",
3695
+ properties: {
3696
+ site: siteParam,
3697
+ mediaIds: { type: "array", items: { type: "integer" }, minItems: 1, maxItems: 100, description: "Media IDs to move (1-100)" },
3698
+ folder: { type: "string", description: "Destination FileBird folder path, e.g. '親/子'" },
3699
+ folderId: { type: "integer", description: "Destination FileBird folder ID. 0 means Uncategorized; -1 is invalid." },
3700
+ },
3701
+ required: ["mediaIds"],
3702
+ },
3703
+ },
3704
+ {
3705
+ name: "update_media_meta",
3706
+ description: "Update one media item's metadata. PATCH semantics: omitted fields are unchanged, empty string clears a field, null is rejected.",
3707
+ inputSchema: {
3708
+ type: "object",
3709
+ properties: {
3710
+ site: siteParam,
3711
+ mediaId: { type: "integer", description: "Media ID" },
3712
+ title: { type: "string", description: "Title. Omit to keep unchanged; empty string clears." },
3713
+ alt: { type: "string", description: "Alt text. Omit to keep unchanged; empty string clears." },
3714
+ caption: { type: "string", description: "Caption. Omit to keep unchanged; empty string clears." },
3715
+ description: { type: "string", description: "Description. Omit to keep unchanged; empty string clears." },
3716
+ },
3717
+ required: ["mediaId"],
3718
+ },
3719
+ },
3720
+ {
3721
+ name: "get_media",
3722
+ description: "Get one media item's metadata, URL, and FileBird folder assignment when FileBird token is configured.",
3723
+ inputSchema: {
3724
+ type: "object",
3725
+ properties: {
3726
+ site: siteParam,
3727
+ mediaId: { type: "integer", description: "Media ID" },
3728
+ },
3729
+ required: ["mediaId"],
3730
+ },
3731
+ },
3732
+ {
3733
+ name: "rename_media_folder",
3734
+ description: "Rename a FileBird media folder (changes name only, not its parent). Use folder path or folderId. New name containing / is rejected. This does not move or delete the folder. Renaming changes the path of this folder and all its descendants.",
3735
+ inputSchema: {
3736
+ type: "object",
3737
+ properties: {
3738
+ site: siteParam,
3739
+ folder: { type: "string", description: "Target folder path, e.g. '親/子'" },
3740
+ folderId: { type: "integer", description: "Target FileBird folder ID. 0/-1 (system folders) are rejected." },
3741
+ newName: { type: "string", description: "New folder name (single segment; / and full-width slash are rejected)" },
3742
+ },
3743
+ required: ["newName"],
3744
+ },
3745
+ },
3746
+ {
3747
+ name: "delete_media_folder",
3748
+ description: "Delete a FileBird media folder. This deletes ONLY the folder (and recursively its subfolders); the media files inside are NOT deleted but moved to Uncategorized. Two-step confirmation: call first without confirmFolderPath to get a summary (child folder count, attachment count), then call again passing confirmFolderPath equal to the target folder path (or confirmFolderId equal to its ID) to execute. System folders (0/-1) are rejected.",
3749
+ inputSchema: {
3750
+ type: "object",
3751
+ properties: {
3752
+ site: siteParam,
3753
+ folder: { type: "string", description: "Target folder path to delete, e.g. '親/子'" },
3754
+ folderId: { type: "integer", description: "Target FileBird folder ID. 0/-1 (system folders) are rejected." },
3755
+ confirmFolderPath: { type: "string", description: "Echo confirmation: pass the exact target folder path to execute the deletion. If absent or mismatched, the tool returns a summary without deleting." },
3756
+ confirmFolderId: { type: "integer", description: "Echo confirmation alternative: pass the exact target folder ID to execute the deletion." },
2707
3757
  },
2708
- required: ["query"],
2709
3758
  },
2710
3759
  },
2711
3760
  {
@@ -2755,7 +3804,7 @@ const tools = [
2755
3804
  },
2756
3805
  {
2757
3806
  name: "register_asp_link",
2758
- description: "Register a new ASP affiliate link. Creates a jump post with ASP metadata.",
3807
+ description: "Register new ASP affiliate links. Single mode creates one jump post with top-level title/slug/asp_url. Batch mode uses items[] (1-30) and allows partial success. Do not combine top-level single fields with items.",
2759
3808
  inputSchema: {
2760
3809
  type: "object",
2761
3810
  properties: {
@@ -2767,8 +3816,87 @@ const tools = [
2767
3816
  asp_url: { type: "string", description: "ASP affiliate URL (required)" },
2768
3817
  asp_url_sp: { type: "string", description: "ASP URL (B) for A/B testing (optional)" },
2769
3818
  direct_url: { type: "string", description: "Direct URL bypassing ASP (optional)" },
3819
+ permalink: { type: "string", description: "WordPress permalink slug (defaults to asp_id, then slug if omitted)" },
3820
+ items: {
3821
+ type: "array",
3822
+ minItems: 1,
3823
+ maxItems: MAX_ASP_BULK,
3824
+ description: "Batch mode: ASP links to register. Mutually exclusive with top-level title/slug/asp_url fields.",
3825
+ items: {
3826
+ type: "object",
3827
+ properties: {
3828
+ title: { type: "string", description: "Display name" },
3829
+ slug: { type: "string", description: "Fallback ASP ID and legacy slug input" },
3830
+ asp_id: { type: "string", description: "ASP案件ID — data-id attribute used by JSLinkHelper (defaults to slug if omitted)" },
3831
+ asp_name: { type: "string", description: "案件名 (defaults to title if omitted)" },
3832
+ asp_url: { type: "string", description: "ASP affiliate URL" },
3833
+ asp_url_sp: { type: "string", description: "ASP URL (B) for A/B testing (optional)" },
3834
+ direct_url: { type: "string", description: "Direct URL bypassing ASP (optional)" },
3835
+ permalink: { type: "string", description: "WordPress permalink slug (defaults to asp_id, then slug if omitted)" },
3836
+ },
3837
+ required: ["title", "slug", "asp_url"],
3838
+ },
3839
+ },
3840
+ },
3841
+ },
3842
+ },
3843
+ {
3844
+ name: "update_asp_link",
3845
+ description: "Update an existing ASP affiliate link. Only provided non-empty fields are updated. permalink changes the WordPress permalink/post_name; asp_id changes the data-id key and can break existing inserted tags because article tags are not updated automatically.",
3846
+ inputSchema: {
3847
+ type: "object",
3848
+ properties: {
3849
+ site: siteParam,
3850
+ id: { type: "integer", description: "ASP link jump post ID" },
3851
+ title: { type: "string", description: "Display name" },
3852
+ permalink: { type: "string", description: "WordPress permalink/post_name" },
3853
+ slug: { type: "string", description: "Compatibility alias for permalink. Do not use together with permalink." },
3854
+ asp_id: { type: "string", description: "ASP案件ID — data-id attribute used by JSLinkHelper. Existing article tags are not migrated." },
3855
+ asp_name: { type: "string", description: "案件名" },
3856
+ asp_url: { type: "string", description: "ASP affiliate URL" },
3857
+ asp_url_sp: { type: "string", description: "ASP URL (B) for A/B testing" },
3858
+ direct_url: { type: "string", description: "Direct URL bypassing ASP" },
3859
+ },
3860
+ required: ["id"],
3861
+ },
3862
+ },
3863
+ {
3864
+ name: "insert_asp_tag",
3865
+ description: "Insert ASP affiliate tags into target blocks. Supports core/paragraph, core/image, sbd/btn, core/table. Block-type behavior: core/image → sets dataId attribute; sbd/btn → sets ad attribute (span with button label as inner text); core/paragraph → wraps text with inline span; core/table → targets a specific cell via the \"cell\" parameter and auto-detects cell content (inline-button cell vs normal cell). For core/paragraph: omit \"text\" to wrap the entire paragraph; specify \"text\" to wrap only that exact substring (must match uniquely — if it matches multiple times, the tool returns multiple_matches with previews so you can extend \"text\" with surrounding chars until unique). Paragraphs containing non-ASP inline HTML (e.g., <strong>, <a>) are not supported for partial insertion. For core/table: \"cell\" is REQUIRED. The tool inspects the targeted cell's content — if it contains a swl-inline-btn (Gutenberg inline button) wrapping an <a>, the tool overwrites that anchor's href with the ASP url (asp_url field of the registered ASP link); otherwise it wraps the cell content with the inline span template (same logic as core/paragraph, supports \"text\" for partial wrap). To put two spans in the same paragraph/cell, call this tool twice with snapshotId chaining (one assignment per ref per call). Existing jsl-custom-label tags / existing real anchor hrefs are skipped unless force:true (placeholders like #, ###, empty hrefs are treated as unset). Per-assignment result statuses: inserted (success) / skipped (existing tag or existing href, use force) / not_found (text not in paragraph or cell) / multiple_matches (text matches >1 times, extend it) / unsupported_inline_html (non-ASP inline HTML present, partial mode unsupported) / unsupported (block type not supported) / empty_text (text is empty string, omit it instead) / empty_paragraph (no content to wrap) / empty_cell (cell has no content to wrap) / cell_required (core/table requires cell) / cell_out_of_range (row/col exceeds table size) / missing_asp_url (inline-button cell but ASP has no asp_url registered) / inline_btn_no_anchor (inline button without <a> inside) / tag_template_missing (ASP has no inline template) / ref_failed (snapshotId or ref invalid). PREFER THIS over update_blocks / table_operations for ASP tag insertion to avoid block structure corruption.",
3866
+ inputSchema: {
3867
+ type: "object",
3868
+ properties: {
3869
+ site: siteParam,
3870
+ postId: { oneOf: [{ type: "integer" }, { type: "string" }], description: "Post ID (number) or slug (string). Required if no editor connected." },
3871
+ snapshotId: { type: "string", description: "Snapshot ID from get_article_structure or any write response. Required for ref resolution." },
3872
+ expectedRevision: { type: "string", description: "Revision from snapshot. Rejects update when structure has changed." },
3873
+ assignments: {
3874
+ type: "array",
3875
+ maxItems: 30,
3876
+ description: "List of (ref, asp_id) pairs. Same asp_id may repeat. Same ref may NOT repeat in a single call — split into separate calls with snapshotId chaining. Max 30 items.",
3877
+ items: {
3878
+ type: "object",
3879
+ properties: {
3880
+ ref: { type: "string", description: "Block ref from snapshot (e.g. 'r5')" },
3881
+ asp_id: { type: "string", description: "Registered ASP id to insert (e.g. 'mocom')" },
3882
+ text: { type: "string", description: "core/paragraph: omit to wrap the whole paragraph in the inline span; specify an exact substring (case-sensitive, plain text — HTML entities are decoded for matching) to wrap only that substring. Must match uniquely. sbd/btn: inner label text of the ad span (defaults to button content). core/table (normal cell): same semantics as core/paragraph applied to the targeted cell content. Ignored for core/image and for inline-button cells (which use href overwrite instead)." },
3883
+ force: { type: "boolean", description: "Overwrite existing tag/href if true. Default false (skip). For core/paragraph and core/table normal cells, only effective in whole-content mode. For inline-button cells, force overrides a real (non-placeholder) href." },
3884
+ cell: {
3885
+ type: "object",
3886
+ description: "REQUIRED when ref points to a core/table block. Specifies which cell to target. Ignored for other block types.",
3887
+ properties: {
3888
+ section: { enum: ["head", "body"], description: "thead or tbody" },
3889
+ row: { type: "integer", minimum: 0, description: "0-based row index within the section" },
3890
+ col: { type: "integer", minimum: 0, description: "0-based column index within the row" },
3891
+ },
3892
+ required: ["section", "row", "col"],
3893
+ },
3894
+ },
3895
+ required: ["ref", "asp_id"],
3896
+ },
3897
+ },
2770
3898
  },
2771
- required: ["title", "slug", "asp_url"],
3899
+ required: ["postId", "snapshotId", "assignments"],
2772
3900
  },
2773
3901
  },
2774
3902
  ];
@@ -2881,10 +4009,10 @@ async function handleUpdateBlocksTool(args, toolName) {
2881
4009
  const _inputRef = (snapshotId && args?.target?.ref) ? args.target.ref : null;
2882
4010
  const _isRefInsert = !!(insertOnly && _inputRef);
2883
4011
 
2884
- // [INSERT_OBSERVE] Step 5: insert_block 経由時の送信パラメータログ
2885
- if (process.env.FRIDAY_DEBUG === '1' && _fromInsertBlock) {
2886
- console.log(`[INSERT_OBSERVE] mcp-send: index=${index}, postId=${postId}, sessionId=${_sessionId || 'none'}, mode=${mode}, appendToEnd=${appendToEnd}, htmlLen=${(newHTML || '').length}`);
2887
- }
4012
+ // [INSERT_OBSERVE] Step 5: insert_block 経由時の送信パラメータログ
4013
+ if (process.env.FRIDAY_DEBUG === '1' && _fromInsertBlock) {
4014
+ console.error(`[INSERT_OBSERVE] mcp-send: index=${index}, postId=${postId}, sessionId=${_sessionId || 'none'}, mode=${mode}, appendToEnd=${appendToEnd}, htmlLen=${(newHTML || '').length}`);
4015
+ }
2888
4016
 
2889
4017
  // --- filePath → newHTML 解決(update_blocks 直接呼び出し時) ---
2890
4018
  if (args?.filePath) {
@@ -2898,7 +4026,7 @@ async function handleUpdateBlocksTool(args, toolName) {
2898
4026
  return { content: [{ type: "text", text: "❌ filePath は attributeUpdates と併用できません。" }], isError: true };
2899
4027
  }
2900
4028
  try {
2901
- newHTML = readHTMLFromFile(args.filePath).html;
4029
+ newHTML = await resolveFileToBlockHTML(args.filePath);
2902
4030
  } catch (e) {
2903
4031
  return { content: [{ type: "text", text: `❌ ${e.message}` }], isError: true };
2904
4032
  }
@@ -3168,7 +4296,7 @@ async function handleUpdateBlocksTool(args, toolName) {
3168
4296
  }
3169
4297
 
3170
4298
  // ツール実行のハンドラ
3171
- const _WRITE_TOOLS = new Set(['update_blocks', 'delete_block', 'move_block', 'duplicate_block', 'insert_block', 'table_operations', 'update_blog_parts']);
4299
+ const _WRITE_TOOLS = new Set(['update_blocks', 'delete_block', 'move_block', 'duplicate_block', 'insert_block', 'table_operations', 'update_blog_parts', 'insert_blog_parts', 'insert_asp_tag']);
3172
4300
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
3173
4301
  const { name, arguments: args } = request.params;
3174
4302
  const _toolLogStart = Date.now();
@@ -4230,15 +5358,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4230
5358
 
4231
5359
  if (filePath) {
4232
5360
  try {
4233
- const ext = extname(filePath).toLowerCase();
4234
- const raw = readHTMLFromFile(filePath).html;
4235
- if (ext === '.md') {
4236
- content = await marked.parse(raw);
4237
- } else if (ext === '.html' || ext === '.htm') {
4238
- content = raw;
4239
- } else {
4240
- return { content: [{ type: "text", text: `❌ 対応していないファイル形式です: ${ext} (.md または .html のみ)` }], isError: true };
4241
- }
5361
+ content = await resolveFileToBlockHTML(filePath);
4242
5362
  } catch (e) {
4243
5363
  return { content: [{ type: "text", text: `❌ ${e.message}` }], isError: true };
4244
5364
  }
@@ -4360,15 +5480,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4360
5480
 
4361
5481
  if (filePath) {
4362
5482
  try {
4363
- const ext = extname(filePath).toLowerCase();
4364
- const raw = readHTMLFromFile(filePath).html;
4365
- if (ext === '.md') {
4366
- content = await marked.parse(raw);
4367
- } else if (ext === '.html' || ext === '.htm') {
4368
- content = raw;
4369
- } else {
4370
- return { content: [{ type: "text", text: `❌ 対応していないファイル形式です: ${ext} (.md または .html のみ)` }], isError: true };
4371
- }
5483
+ content = await resolveFileToBlockHTML(filePath);
4372
5484
  } catch (e) {
4373
5485
  return { content: [{ type: "text", text: `❌ ${e.message}` }], isError: true };
4374
5486
  }
@@ -4376,7 +5488,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4376
5488
 
4377
5489
  const postData = {
4378
5490
  title,
4379
- status: args?.status || 'draft',
5491
+ status: args?.status || 'publish',
4380
5492
  post_type: 'blog_parts',
4381
5493
  };
4382
5494
  if (args?.slug !== undefined) postData.slug = args.slug;
@@ -4410,6 +5522,24 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4410
5522
  }
4411
5523
 
4412
5524
  const bpResult = await client.searchBlogParts(args || {});
5525
+
5526
+ if (args?.partsId) {
5527
+ const usedBy = bpResult.usedBy || [];
5528
+ if (usedBy.length === 0) {
5529
+ return { content: [{ type: "text", text: `📋 ブログパーツ [${bpResult.partsId}] "${bpResult.partsTitle}" はどの記事でも使用されていません。` }] };
5530
+ }
5531
+ const usageList = usedBy.map(p =>
5532
+ ` [${p.postId}] ${p.title || '(無題)'}\n ステータス: ${p.status} | 更新: ${p.modified || '-'}`
5533
+ ).join('\n\n');
5534
+ let usageText = `📋 ブログパーツ [${bpResult.partsId}] "${bpResult.partsTitle}" の直接使用箇所 (${usedBy.length}件 / 全${bpResult.total}件)\n\n${usageList}`;
5535
+ if (bpResult.total_pages > 1) {
5536
+ const pg = args?.page || 1;
5537
+ usageText += `\n\n📄 ページ: ${pg} / ${bpResult.total_pages}`;
5538
+ if (pg < bpResult.total_pages) usageText += ` (次: page=${pg + 1})`;
5539
+ }
5540
+ return { content: [{ type: "text", text: usageText }] };
5541
+ }
5542
+
4413
5543
  const bpParts = bpResult.items || [];
4414
5544
  const bpTotal = bpResult.total || 0;
4415
5545
  const bpTotalPages = bpResult.total_pages || 0;
@@ -4423,7 +5553,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4423
5553
  return ` [${p.id}] ${p.title || '(無題)'}\n ステータス: ${p.status} | 更新: ${p.modified || '-'}`;
4424
5554
  }).join('\n\n');
4425
5555
 
4426
- let bpText = `📋 ブログ��ーツ一覧 (${bpParts.length}件 / 全${bpTotal}件)\n\n${bpList}`;
5556
+ let bpText = `📋 ブログパーツ一覧 (${bpParts.length}件 / 全${bpTotal}件)\n\n${bpList}`;
4427
5557
  if (bpTotalPages > 1) {
4428
5558
  bpText += `\n\n📄 ページ: ${bpPage} / ${bpTotalPages}`;
4429
5559
  if (bpPage < bpTotalPages) {
@@ -4629,6 +5759,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4629
5759
  return { content: [{ type: "text", text }] };
4630
5760
  }
4631
5761
 
5762
+ case "insert_blog_parts": {
5763
+ if (!args?.partsId) {
5764
+ return { content: [{ type: "text", text: "❌ partsId は必須です。" }], isError: true };
5765
+ }
5766
+ args.rawHTML = `<!-- wp:loos/blog-parts {"partsID":"${args.partsId}"} /-->`;
5767
+ // insert_block に fallthrough
5768
+ }
5769
+
4632
5770
  case "insert_block": {
4633
5771
  let { rawHTML, filePath } = (args || {});
4634
5772
 
@@ -4644,7 +5782,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4644
5782
 
4645
5783
  // filePath → rawHTML 解決
4646
5784
  if (filePath) {
4647
- try { rawHTML = readHTMLFromFile(filePath).html; }
5785
+ try { rawHTML = await resolveFileToBlockHTML(filePath); }
4648
5786
  catch (e) { return { content: [{ type: "text", text: `❌ ${e.message}` }], isError: true }; }
4649
5787
  }
4650
5788
  if (!rawHTML) {
@@ -4757,10 +5895,36 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4757
5895
  blocks = result.blocks || [];
4758
5896
  }
4759
5897
 
5898
+ // エディタモード時、ブログパーツの展開を補完
5899
+ if (mode === 'editor' && client) {
5900
+ for (const b of blocks) {
5901
+ if (b.type === 'loos/blog-parts' && !b.blogParts) {
5902
+ const pid = parseInt(b.html?.match(/partsID["\s:]+["']?(\d+)/)?.[1], 10);
5903
+ if (pid > 0) {
5904
+ try {
5905
+ const bpResult = await client.headlessGetBlockHtml(pid, {});
5906
+ b.blogParts = {
5907
+ partsId: pid,
5908
+ blocks: bpResult?.blocks || [],
5909
+ };
5910
+ } catch (_) {}
5911
+ }
5912
+ }
5913
+ }
5914
+ }
5915
+
4760
5916
  const _modeTag = process.env.FRIDAY_DEBUG === '1' ? `\n[DEBUG] mode=${mode}` : '';
4761
5917
  let text = `📦 ブロックHTML取得 (${blocks.length}件)\n`;
4762
5918
  for (const b of blocks) {
4763
- text += `\n[${b.index}] ${b.type}\n${b.html}\n`;
5919
+ text += `\n[${b.index}] ${b.type}\n`;
5920
+ if (b.blogParts && b.blogParts.blocks) {
5921
+ for (const inner of b.blogParts.blocks) {
5922
+ if ((inner.depth || 0) > 0) continue;
5923
+ text += `${inner.html}\n`;
5924
+ }
5925
+ } else {
5926
+ text += `${b.html}\n`;
5927
+ }
4764
5928
  }
4765
5929
  text += _modeTag;
4766
5930
  return { content: [{ type: "text", text }] };
@@ -5061,116 +6225,640 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
5061
6225
  const profileOpen = openInChromeProfile(url);
5062
6226
  if (profileOpen) {
5063
6227
  try {
5064
- await profileOpen;
5065
- return { content: [{ type: "text", text: `🌐 ${label}をブラウザで開きました(Chrome プロファイル指定)\nURL: ${url}${_mt_oib}` }] };
5066
- } catch (_) {
5067
- // Chrome プロファイル起動失敗 → フォールバック
6228
+ await profileOpen;
6229
+ return { content: [{ type: "text", text: `🌐 ${label}をブラウザで開きました(Chrome プロファイル指定)\nURL: ${url}${_mt_oib}` }] };
6230
+ } catch (_) {
6231
+ // Chrome プロファイル起動失敗 → フォールバック
6232
+ }
6233
+ }
6234
+
6235
+ // 3. OS デフォルト(最終フォールバック)
6236
+ try {
6237
+ await openInBrowser(url);
6238
+ return { content: [{ type: "text", text: `🌐 ${label}をブラウザで開きました(OSデフォルト)\nURL: ${url}\n\n⚠️ プロファイル固定するには FRIDAY_CHROME_PATH と FRIDAY_CHROME_PROFILE を設定してください。${_mt_oib}` }] };
6239
+ } catch (e) {
6240
+ return { content: [{ type: "text", text: `⚠️ ブラウザの起動に失敗しました。URLを手動で開いてください:\n${url}\n\nエラー: ${e.message}` }], isError: true };
6241
+ }
6242
+ }
6243
+
6244
+ case "upload_media": {
6245
+ const siteName = args?.site || 'default';
6246
+ let client;
6247
+ try { client = registry.get(siteName); }
6248
+ catch (e) { return jsonToolResponse({ success: false, error: toolError('VALIDATION_ERROR', e.message, false) }, true); }
6249
+
6250
+ try {
6251
+ const mode = validateUploadMode(args || {});
6252
+ const items = prepareUploadItems(normalizeUploadItems(args || {}, mode));
6253
+ const concurrency = getUploadConcurrency(args || {}, mode);
6254
+ const targetFolder = await resolveUploadFolder(client, args || {});
6255
+ const deadline = Date.now() + UPLOAD_TOTAL_TIMEOUT_MS;
6256
+
6257
+ let results = await runWithConcurrency(items, concurrency, item => uploadOneMedia(client, item), deadline);
6258
+ results.sort((a, b) => a.index - b.index);
6259
+ results = await applyFolderAssignment(client, results, targetFolder);
6260
+ const summary = buildUploadSummary(results);
6261
+ return jsonToolResponse({
6262
+ success: summary.succeeded > 0,
6263
+ results,
6264
+ summary,
6265
+ }, summary.succeeded === 0);
6266
+ } catch (e) {
6267
+ return jsonErrorResponse(e);
6268
+ }
6269
+ }
6270
+
6271
+ case "search_media": {
6272
+ const siteName = args?.site || 'default';
6273
+ let client;
6274
+ try { client = registry.get(siteName); }
6275
+ catch (e) { return jsonErrorResponse(e, 'VALIDATION_ERROR'); }
6276
+
6277
+ try {
6278
+ const hasQuery = typeof args?.query === 'string' && args.query.length > 0;
6279
+ const hasScope = (args?.folder !== undefined && args?.folder !== null) ||
6280
+ (args?.folderId !== undefined && args?.folderId !== null);
6281
+ if (!hasQuery && !hasScope) {
6282
+ throwToolError('VALIDATION_ERROR', 'query または folder/folderId のどちらかは必須です', false);
6283
+ }
6284
+
6285
+ const warnings = [];
6286
+ const recursive = args?.recursive === true;
6287
+ if (recursive && !hasScope) {
6288
+ warnings.push(toolError('VALIDATION_ERROR', 'recursive は folder/folderId 指定時のみ有効です。無視しました', false));
6289
+ }
6290
+
6291
+ // WP media item → 出力行
6292
+ const toRow = (item, matched) => ({
6293
+ id: item.id,
6294
+ title: item.title?.rendered || '',
6295
+ alt: item.alt_text || '',
6296
+ url: item.source_url || '',
6297
+ width: item.media_details?.width || null,
6298
+ height: item.media_details?.height || null,
6299
+ ...(matched !== undefined ? { matched } : {}),
6300
+ });
6301
+ const chunk100 = (arr) => {
6302
+ const out = [];
6303
+ for (let i = 0; i < arr.length; i += 100) out.push(arr.slice(i, i + 100));
6304
+ return out;
6305
+ };
6306
+
6307
+ // ---- スコープ解決 (folder/folderId) ----
6308
+ let scope = null;
6309
+ let includeIds = null;
6310
+ if (hasScope) {
6311
+ const normalized = await getNormalizedFileBirdFolders(client);
6312
+ const target = resolveFolderTarget(normalized, { folder: args?.folder, folderId: args?.folderId });
6313
+
6314
+ let folderIds = recursive ? collectSubtreeFolderIds(normalized, target.id) : [target.id];
6315
+ if (recursive && target.id === 0) {
6316
+ warnings.push(toolError('VALIDATION_ERROR', 'Uncategorized(folderId=0) は子フォルダを持たないため recursive は単一フォルダとして扱いました', false));
6317
+ }
6318
+ const totalFolderCount = folderIds.length;
6319
+ let folderScanTruncated = false;
6320
+ if (folderIds.length > SEARCH_MAX_SCAN_FOLDERS) {
6321
+ folderIds = folderIds.slice(0, SEARCH_MAX_SCAN_FOLDERS);
6322
+ folderScanTruncated = true;
6323
+ warnings.push(toolError('VALIDATION_ERROR', `走査フォルダ数が上限 ${SEARCH_MAX_SCAN_FOLDERS} を超えたため打ち切りました(${totalFolderCount} フォルダ中、folderId 昇順で先頭 ${SEARCH_MAX_SCAN_FOLDERS})`, false));
6324
+ }
6325
+
6326
+ const { unionIds, fetchedFolderCount, failedFolderCount } = await fetchAttachmentIdsForFolders(client, folderIds);
6327
+ const totalInScope = unionIds.length;
6328
+ let truncated = false;
6329
+ let scopedIds = unionIds;
6330
+ if (scopedIds.length > SEARCH_MAX_UNION_IDS) {
6331
+ scopedIds = scopedIds.slice(0, SEARCH_MAX_UNION_IDS);
6332
+ truncated = true;
6333
+ warnings.push(toolError('VALIDATION_ERROR', `スコープ内メディアが上限 ${SEARCH_MAX_UNION_IDS} 件を超えたため先頭 ${SEARCH_MAX_UNION_IDS} 件で切り詰めました(順序: folderId 昇順→id 昇順)`, false));
6334
+ }
6335
+ if (failedFolderCount > 0) {
6336
+ warnings.push(toolError('UNKNOWN', `${folderIds.length} フォルダ中 ${failedFolderCount} フォルダの attachment-id 取得に失敗したため union に欠落の可能性があります`, true));
6337
+ }
6338
+
6339
+ includeIds = scopedIds;
6340
+ scope = {
6341
+ folderId: target.id,
6342
+ folderPath: target.path,
6343
+ recursive,
6344
+ folderCount: folderIds.length,
6345
+ fetchedFolderCount,
6346
+ failedFolderCount,
6347
+ // failedFolderCount>0 のとき totalInScope は「収集できた範囲の件数」であり真の総数ではない (#6)
6348
+ partial: failedFolderCount > 0,
6349
+ totalInScope,
6350
+ nonImageExcluded: null,
6351
+ truncated: truncated || folderScanTruncated,
6352
+ };
6353
+
6354
+ if (includeIds.length === 0) {
6355
+ if (hasQuery) {
6356
+ // 検索パスと応答スキーマを揃える (variants を含める) (#3)
6357
+ const variants = generateSearchVariants(args.query, args?.strip, args?.prefix);
6358
+ return jsonToolResponse({ scope, query: args.query, variants, results: [], count: 0, warnings });
6359
+ }
6360
+ // 列挙パス(query なし): 空スコープは非画像除外0件 (#4)
6361
+ scope.nonImageExcluded = 0;
6362
+ return jsonToolResponse({ scope, results: [], count: 0, warnings });
6363
+ }
6364
+ }
6365
+
6366
+ // ---- 検索パス (query あり) ----
6367
+ if (hasQuery) {
6368
+ const variants = generateSearchVariants(args.query, args?.strip, args?.prefix);
6369
+ const seenIds = new Set();
6370
+ const results = [];
6371
+
6372
+ if (hasScope) {
6373
+ // スコープ検索: per_page 無視 → スコープ内マッチを全件返す (新1)
6374
+ warnings.push(toolError('UNKNOWN', '検索パスでは非画像件数 (nonImageExcluded) は未集計です', false));
6375
+ const chunks = chunk100(includeIds);
6376
+ let scopedCallTotal = 0;
6377
+ let scopedCallFailed = 0;
6378
+ for (let vi = 0; vi < variants.length; vi++) {
6379
+ for (const c of chunks) {
6380
+ scopedCallTotal++;
6381
+ let items;
6382
+ try { items = await client.searchMedia(variants[vi], 100, c); }
6383
+ catch { scopedCallFailed++; continue; }
6384
+ for (const item of items || []) {
6385
+ if (seenIds.has(item.id)) continue;
6386
+ seenIds.add(item.id);
6387
+ results.push(toRow(item, variants[vi]));
6388
+ }
6389
+ }
6390
+ }
6391
+ // 全問い合わせ失敗は error、部分失敗は warnings で可視化 (#5)
6392
+ if (scopedCallTotal > 0 && scopedCallFailed === scopedCallTotal) {
6393
+ throwToolError('UNKNOWN', `スコープ検索の全 ${scopedCallTotal} 件の問い合わせが失敗しました`, true);
6394
+ }
6395
+ if (scopedCallFailed > 0) {
6396
+ warnings.push(toolError('UNKNOWN', `スコープ検索 ${scopedCallTotal} 件中 ${scopedCallFailed} 件が失敗したため結果に欠落の可能性があります`, true));
6397
+ }
6398
+ } else {
6399
+ // グローバル検索: 既存挙動 (per_page 1-20)
6400
+ const perPage = Math.min(Math.max(1, args?.per_page || 10), 20);
6401
+ const settled = await Promise.allSettled(variants.map(v => client.searchMedia(v, perPage, null)));
6402
+ // 全 variant 失敗は error、部分失敗は warnings で可視化 (#7)
6403
+ const failedVariants = settled.filter(s => s.status !== 'fulfilled').length;
6404
+ if (settled.length > 0 && failedVariants === settled.length) {
6405
+ throwToolError('UNKNOWN', `グローバル検索の全 ${settled.length} variant が失敗しました`, true);
6406
+ }
6407
+ if (failedVariants > 0) {
6408
+ warnings.push(toolError('UNKNOWN', `グローバル検索 ${settled.length} variant 中 ${failedVariants} 件が失敗したため結果に欠落の可能性があります`, true));
6409
+ }
6410
+ for (let i = 0; i < settled.length; i++) {
6411
+ if (settled[i].status !== 'fulfilled') continue;
6412
+ for (const item of settled[i].value || []) {
6413
+ if (seenIds.has(item.id)) continue;
6414
+ seenIds.add(item.id);
6415
+ results.push(toRow(item, variants[i]));
6416
+ }
6417
+ }
6418
+ }
6419
+
6420
+ return jsonToolResponse({
6421
+ scope,
6422
+ query: args.query,
6423
+ variants,
6424
+ results,
6425
+ count: results.length,
6426
+ warnings,
6427
+ });
6428
+ }
6429
+
6430
+ // ---- 列挙パス (スコープあり・query なし) ----
6431
+ const media = await client.getMediaByIds(includeIds, { mediaType: 'image' });
6432
+ const byId = new Map((media || []).map(m => [m.id, m]));
6433
+ const results = [];
6434
+ for (const id of includeIds) {
6435
+ const item = byId.get(id);
6436
+ if (item) results.push(toRow(item));
6437
+ }
6438
+ // 切り詰め時は results が union の部分集合のため totalInScope と基準が合わない → null (#8)
6439
+ scope.nonImageExcluded = includeIds.length < scope.totalInScope
6440
+ ? null
6441
+ : includeIds.length - results.length;
6442
+
6443
+ return jsonToolResponse({
6444
+ scope,
6445
+ results,
6446
+ count: results.length,
6447
+ warnings,
6448
+ });
6449
+ } catch (e) {
6450
+ return jsonErrorResponse(e);
6451
+ }
6452
+ }
6453
+
6454
+ case "list_media_folders": {
6455
+ const siteName = args?.site || 'default';
6456
+ let client;
6457
+ try { client = registry.get(siteName); }
6458
+ catch (e) { return errorResponse("list_media_folders", e.message, siteName); }
6459
+
6460
+ if (!client.filebirdToken) {
6461
+ return { content: [{ type: "text", text: "⚠️ FileBird 未導入または APIキーが未設定です。\nconnections.json に filebirdToken を追加してください。" }] };
6462
+ }
6463
+ const folders = await client.getFileBirdFolders();
6464
+ if (!folders) {
6465
+ return errorResponse("list_media_folders", "FileBird API へのアクセスに失敗しました", siteName);
6466
+ }
6467
+ const uncategorizedCount = (await client.getFileBirdAttachmentCount(0)) || 0;
6468
+ const normalized = normalizeFileBirdFolders(folders, uncategorizedCount);
6469
+ return { content: [{ type: "text", text: JSON.stringify({ folders: normalized.tree }, null, 2) }] };
6470
+ }
6471
+
6472
+ case "create_media_folder": {
6473
+ const siteName = args?.site || 'default';
6474
+ let client;
6475
+ try { client = registry.get(siteName); }
6476
+ catch (e) { return jsonToolResponse({ success: false, error: toolError('VALIDATION_ERROR', e.message, false) }, true); }
6477
+
6478
+ try {
6479
+ if (typeof args?.name !== 'string') {
6480
+ throwToolError('VALIDATION_ERROR', 'name は必須の文字列です', false);
6481
+ }
6482
+ const folderName = args.name.trim().normalize('NFC');
6483
+ if (!folderName) {
6484
+ throwToolError('VALIDATION_ERROR', 'name は空にできません', false);
6485
+ }
6486
+ if (folderName.includes('/') || folderName.includes('/')) {
6487
+ throwToolError('VALIDATION_ERROR', 'name にスラッシュは使用できません。親フォルダは parent または parentId で指定してください', false);
6488
+ }
6489
+
6490
+ const normalized = await getNormalizedFileBirdFolders(client);
6491
+ const parent = resolveParentTarget(normalized, { parent: args?.parent, parentId: args?.parentId });
6492
+ const existing = normalized.allNodes.find(node =>
6493
+ !node.isSystemFolder && node.parentId === parent.id && node.name === folderName
6494
+ );
6495
+ if (existing) {
6496
+ return jsonToolResponse({
6497
+ success: true,
6498
+ created: false,
6499
+ folder: {
6500
+ id: existing.id,
6501
+ name: existing.name,
6502
+ parentId: existing.parentId,
6503
+ path: existing.path,
6504
+ count: existing.count,
6505
+ },
6506
+ warning: toolError('DUPLICATE_FOLDER_NAME', '同一親配下に同名フォルダが既に存在するため、既存フォルダを返しました', false),
6507
+ });
6508
+ }
6509
+
6510
+ const created = await client.createFileBirdFolder(folderName, parent.id);
6511
+ if (!created?.id) {
6512
+ throwToolError('UNKNOWN', 'FileBird フォルダ作成 API が id を返しませんでした', true);
6513
+ }
6514
+
6515
+ const after = await getNormalizedFileBirdFolders(client);
6516
+ const node = after.byId.get(created.id);
6517
+ const expectedPath = parent.path ? `${parent.path}/${folderName}` : folderName;
6518
+ if (!node || node.name !== folderName || node.parentId !== parent.id || node.path !== expectedPath) {
6519
+ return jsonToolResponse({
6520
+ success: false,
6521
+ created: true,
6522
+ error: toolError('DUPLICATE_FOLDER_NAME', '作成後検証でフォルダ名またはパスが期待値と一致しません。FileBird が同名フォルダを自動リネームした可能性があります', false, {
6523
+ recoveryHint: 'WordPress 管理画面で actualFolder を確認し、必要なら手動整理してください',
6524
+ }),
6525
+ expected: { name: folderName, parentId: parent.id, path: expectedPath },
6526
+ actualFolder: node ? {
6527
+ id: node.id,
6528
+ name: node.name,
6529
+ parentId: node.parentId,
6530
+ path: node.path,
6531
+ count: node.count,
6532
+ } : null,
6533
+ }, true);
6534
+ }
6535
+
6536
+ return jsonToolResponse({
6537
+ success: true,
6538
+ created: true,
6539
+ folder: {
6540
+ id: node.id,
6541
+ name: node.name,
6542
+ parentId: node.parentId,
6543
+ path: node.path,
6544
+ count: node.count,
6545
+ },
6546
+ });
6547
+ } catch (e) {
6548
+ return jsonErrorResponse(e);
6549
+ }
6550
+ }
6551
+
6552
+ case "move_media_to_folder": {
6553
+ const siteName = args?.site || 'default';
6554
+ let client;
6555
+ try { client = registry.get(siteName); }
6556
+ catch (e) { return jsonToolResponse({ success: false, error: toolError('VALIDATION_ERROR', e.message, false) }, true); }
6557
+
6558
+ let mediaIds = [];
6559
+ try {
6560
+ if (!Array.isArray(args?.mediaIds)) {
6561
+ throwToolError('VALIDATION_ERROR', 'mediaIds は必須の配列です', false);
6562
+ }
6563
+ mediaIds = args.mediaIds;
6564
+ if (mediaIds.length === 0) {
6565
+ throwToolError('VALIDATION_ERROR', 'mediaIds は1件以上指定してください', false);
6566
+ }
6567
+ if (mediaIds.length > 100) {
6568
+ throwToolError('VALIDATION_ERROR', 'mediaIds は1コール最大100件までです', false);
6569
+ }
6570
+ for (const id of mediaIds) assertPositiveInteger(id, 'mediaId');
6571
+
6572
+ const normalized = await getNormalizedFileBirdFolders(client);
6573
+ const target = resolveFolderTarget(normalized, { folder: args?.folder, folderId: args?.folderId });
6574
+
6575
+ try {
6576
+ await getExistingMediaByIds(client, mediaIds);
6577
+ } catch (validationError) {
6578
+ const err = toToolError(validationError);
6579
+ if (err.code === 'MEDIA_NOT_FOUND') {
6580
+ const missing = new Set((err.missingMediaIds || []).map(Number));
6581
+ return jsonToolResponse({
6582
+ success: false,
6583
+ error: err,
6584
+ results: mediaIds.map(mediaId => missing.has(Number(mediaId))
6585
+ ? { mediaId, success: false, error: toolError('MEDIA_NOT_FOUND', `mediaId ${mediaId} が存在しません`, false) }
6586
+ : { mediaId, success: false, skipped: true, error: toolError('VALIDATION_ERROR', '他の mediaId が存在しないため、このメディアも移動していません', false) }
6587
+ ),
6588
+ summary: { total: mediaIds.length, succeeded: 0, failed: mediaIds.length },
6589
+ }, true);
6590
+ }
6591
+ throw validationError;
5068
6592
  }
5069
- }
5070
6593
 
5071
- // 3. OS デフォルト(最終フォールバック)
5072
- try {
5073
- await openInBrowser(url);
5074
- return { content: [{ type: "text", text: `🌐 ${label}をブラウザで開きました(OSデフォルト)\nURL: ${url}\n\n⚠️ プロファイル固定するには FRIDAY_CHROME_PATH と FRIDAY_CHROME_PROFILE を設定してください。${_mt_oib}` }] };
6594
+ const ok = await client.setFileBirdAttachmentFolder(mediaIds, target.id);
6595
+ if (ok !== true) {
6596
+ return jsonToolResponse({
6597
+ success: false,
6598
+ results: mediaIds.map(mediaId => ({
6599
+ mediaId,
6600
+ success: false,
6601
+ error: toolError('FILEBIRD_SET_ATTACHMENT_FAILED', 'FileBird のフォルダ割当 API が失敗しました', true),
6602
+ })),
6603
+ summary: { total: mediaIds.length, succeeded: 0, failed: mediaIds.length },
6604
+ }, true);
6605
+ }
6606
+
6607
+ return jsonToolResponse({
6608
+ success: true,
6609
+ results: mediaIds.map(mediaId => ({
6610
+ mediaId,
6611
+ success: true,
6612
+ newFolderId: target.id,
6613
+ newFolderPath: target.path,
6614
+ })),
6615
+ summary: { total: mediaIds.length, succeeded: mediaIds.length, failed: 0 },
6616
+ });
5075
6617
  } catch (e) {
5076
- return { content: [{ type: "text", text: `⚠️ ブラウザの起動に失敗しました。URLを手動で開いてください:\n${url}\n\nエラー: ${e.message}` }], isError: true };
6618
+ return jsonErrorResponse(e);
5077
6619
  }
5078
6620
  }
5079
6621
 
5080
- case "upload_media": {
6622
+ case "update_media_meta": {
5081
6623
  const siteName = args?.site || 'default';
5082
6624
  let client;
5083
6625
  try { client = registry.get(siteName); }
5084
- catch (e) { return errorResponse("upload_media", e.message, siteName); }
6626
+ catch (e) { return jsonToolResponse({ success: false, error: toolError('VALIDATION_ERROR', e.message, false) }, true); }
5085
6627
 
5086
- if (!args?.filePath) return errorResponse("upload_media", "filePath is required", siteName);
5087
- if (!args?.name) return errorResponse("upload_media", "name is required", siteName);
5088
- if (!args?.alt) return errorResponse("upload_media", "alt is required", siteName);
6628
+ try {
6629
+ const mediaId = assertPositiveInteger(args?.mediaId, 'mediaId');
6630
+ const fields = ['title', 'alt', 'caption', 'description'];
6631
+ const provided = fields.filter(field => Object.prototype.hasOwnProperty.call(args || {}, field));
6632
+ if (provided.length === 0) {
6633
+ throwToolError('VALIDATION_ERROR', 'title / alt / caption / description のうち1つ以上を指定してください', false);
6634
+ }
6635
+ const data = {};
6636
+ for (const field of provided) {
6637
+ const value = args[field];
6638
+ if (typeof value !== 'string') {
6639
+ throwToolError('VALIDATION_ERROR', `${field} は文字列で指定してください。null は使用できません`, false);
6640
+ }
6641
+ if (field === 'alt') data.alt_text = value;
6642
+ else data[field] = value;
6643
+ }
5089
6644
 
5090
- let fileInfo;
5091
- try { fileInfo = validateImageFile(args.filePath); }
5092
- catch (e) { return errorResponse("upload_media", e.message, siteName); }
6645
+ await client.getMedia(mediaId);
6646
+ const updatedMedia = await client.updateMediaMeta(mediaId, data);
6647
+ const formatted = formatMedia(updatedMedia);
6648
+ return jsonToolResponse({
6649
+ success: true,
6650
+ mediaId,
6651
+ updated: {
6652
+ title: provided.includes('title'),
6653
+ alt: provided.includes('alt'),
6654
+ caption: provided.includes('caption'),
6655
+ description: provided.includes('description'),
6656
+ },
6657
+ media: formatted,
6658
+ });
6659
+ } catch (e) {
6660
+ return jsonErrorResponse(e);
6661
+ }
6662
+ }
5093
6663
 
5094
- const uploadFilename = `${args.name}${fileInfo.ext}`;
5095
- const fileBuffer = readFileSync(fileInfo.resolved);
6664
+ case "get_media": {
6665
+ const siteName = args?.site || 'default';
6666
+ let client;
6667
+ try { client = registry.get(siteName); }
6668
+ catch (e) { return jsonToolResponse({ success: false, error: toolError('VALIDATION_ERROR', e.message, false) }, true); }
5096
6669
 
5097
- let media;
5098
6670
  try {
5099
- media = await client.uploadMedia(fileBuffer, uploadFilename, fileInfo.contentType);
6671
+ const mediaId = assertPositiveInteger(args?.mediaId, 'mediaId');
6672
+ const media = await client.getMedia(mediaId);
6673
+ let folderInfo = { folderId: null, folderPath: null };
6674
+ const warnings = [];
6675
+ if (client.filebirdToken) {
6676
+ try {
6677
+ const normalized = await getNormalizedFileBirdFolders(client);
6678
+ folderInfo = await findMediaFolder(client, normalized, mediaId);
6679
+ if (folderInfo.warning) warnings.push(folderInfo.warning);
6680
+ } catch (folderError) {
6681
+ warnings.push(toToolError(folderError, 'FOLDER_NOT_FOUND'));
6682
+ }
6683
+ } else {
6684
+ warnings.push(toolError('VALIDATION_ERROR', 'FileBird APIキーが未設定のため folder 情報は取得していません', false));
6685
+ }
6686
+ const payload = formatMedia(media, folderInfo);
6687
+ if (warnings.length > 0) payload.warnings = warnings;
6688
+ return jsonToolResponse(payload);
5100
6689
  } catch (e) {
5101
- return errorResponse("upload_media", `Upload failed: ${e.message}`, siteName);
6690
+ return jsonErrorResponse(e);
5102
6691
  }
6692
+ }
6693
+
6694
+ case "rename_media_folder": {
6695
+ const siteName = args?.site || 'default';
6696
+ let client;
6697
+ try { client = registry.get(siteName); }
6698
+ catch (e) { return jsonToolResponse({ success: false, error: toolError('VALIDATION_ERROR', e.message, false) }, true); }
5103
6699
 
5104
- const titleText = args?.title || args.alt;
5105
6700
  try {
5106
- await client.updateMediaMeta(media.id, { title: titleText, alt_text: args.alt });
6701
+ const newName = (typeof args?.newName === 'string' ? args.newName : '').trim().normalize('NFC');
6702
+ if (!newName) {
6703
+ throwToolError('VALIDATION_ERROR', 'newName は空にできません', false);
6704
+ }
6705
+ if (newName.includes('/') || newName.includes('/')) {
6706
+ throwToolError('VALIDATION_ERROR', 'newName にスラッシュは使用できません', false);
6707
+ }
6708
+
6709
+ const normalized = await getNormalizedFileBirdFoldersAdmin(client);
6710
+ const target = resolveFolderTarget(normalized, { folder: args?.folder, folderId: args?.folderId });
6711
+ if (target.id <= 0) {
6712
+ throwToolError('VALIDATION_ERROR', 'システムフォルダ (Uncategorized / All) は rename できません', false);
6713
+ }
6714
+
6715
+ const oldName = target.node.name;
6716
+ const parentId = target.node.parentId;
6717
+
6718
+ // 事前同名チェック(同一親配下)
6719
+ const dup = normalized.allNodes.find(n =>
6720
+ !n.isSystemFolder && n.id !== target.id && n.parentId === parentId && n.name === newName
6721
+ );
6722
+ if (dup) {
6723
+ return jsonToolResponse({
6724
+ success: false,
6725
+ error: toolError('DUPLICATE_FOLDER_NAME', `同一親配下に同名フォルダ "${newName}" が既に存在します (ID: ${dup.id})`, false, { existingFolderId: dup.id }),
6726
+ }, true);
6727
+ }
6728
+
6729
+ if (oldName === newName) {
6730
+ return jsonToolResponse({
6731
+ success: true,
6732
+ changed: false,
6733
+ folder: { id: target.id, name: newName, parentId, path: target.path },
6734
+ warning: toolError('VALIDATION_ERROR', '新旧の名前が同一のため変更していません', false),
6735
+ });
6736
+ }
6737
+
6738
+ try {
6739
+ await client.renameFileBirdFolder(target.id, parentId, newName);
6740
+ } catch (renameError) {
6741
+ if ((renameError?.wpErrorCode || '').includes('folder_name_exist')) {
6742
+ throwToolError('DUPLICATE_FOLDER_NAME', `同一親配下に同名フォルダ "${newName}" が既に存在します`, false);
6743
+ }
6744
+ throw renameError;
6745
+ }
6746
+
6747
+ // 作成後検証
6748
+ const after = await getNormalizedFileBirdFoldersAdmin(client);
6749
+ const node = after.byId.get(target.id);
6750
+ if (!node || node.name !== newName) {
6751
+ return jsonToolResponse({
6752
+ success: false,
6753
+ error: toolError('UNKNOWN', 'rename 後検証で名前が期待値と一致しません', true),
6754
+ expected: { id: target.id, name: newName },
6755
+ actualFolder: node ? { id: node.id, name: node.name, parentId: node.parentId, path: node.path } : null,
6756
+ }, true);
6757
+ }
6758
+
6759
+ return jsonToolResponse({
6760
+ success: true,
6761
+ changed: true,
6762
+ folder: { id: node.id, name: node.name, parentId: node.parentId, path: node.path, oldName },
6763
+ note: 'このフォルダと全子孫の path が変わりました。古い path を保持している場合は再取得してください。',
6764
+ });
5107
6765
  } catch (e) {
5108
- // meta 更新失敗は警告のみ(アップロード自体は成功)
5109
- }
5110
-
5111
- const url = media.source_url || '';
5112
- const width = media.media_details?.width || null;
5113
- const height = media.media_details?.height || null;
5114
- const text = `✅ Uploaded: ID=${media.id}\n` +
5115
- ` Title: ${titleText}\n` +
5116
- ` Alt: ${args.alt}\n` +
5117
- ` URL: ${url}\n` +
5118
- (width && height ? ` Size: ${width}x${height}\n` : '') +
5119
- ` Filename: ${uploadFilename}`;
5120
- return { content: [{ type: "text", text }] };
6766
+ return jsonErrorResponse(e);
6767
+ }
5121
6768
  }
5122
6769
 
5123
- case "search_media": {
6770
+ case "delete_media_folder": {
5124
6771
  const siteName = args?.site || 'default';
5125
6772
  let client;
5126
6773
  try { client = registry.get(siteName); }
5127
- catch (e) { return errorResponse("search_media", e.message, siteName); }
5128
-
5129
- if (!args?.query) return errorResponse("search_media", "query is required", siteName);
5130
-
5131
- const variants = generateSearchVariants(args.query, args?.strip, args?.prefix);
5132
- const perPage = args?.per_page || 10;
5133
-
5134
- const results = await Promise.allSettled(
5135
- variants.map(v => client.searchMedia(v, perPage))
5136
- );
5137
-
5138
- const seenIds = new Set();
5139
- const hits = [];
5140
- for (let i = 0; i < results.length; i++) {
5141
- if (results[i].status !== 'fulfilled') continue;
5142
- for (const item of results[i].value) {
5143
- if (seenIds.has(item.id)) continue;
5144
- seenIds.add(item.id);
5145
- hits.push({
5146
- id: item.id,
5147
- title: item.title?.rendered || '',
5148
- alt: item.alt_text || '',
5149
- url: item.source_url || '',
5150
- width: item.media_details?.width || null,
5151
- height: item.media_details?.height || null,
5152
- matched: variants[i],
5153
- });
6774
+ catch (e) { return jsonToolResponse({ success: false, error: toolError('VALIDATION_ERROR', e.message, false) }, true); }
6775
+
6776
+ const safeNormalizePath = (p) => {
6777
+ if (typeof p !== 'string') return null;
6778
+ try { return normalizeFolderPath(p); } catch { return null; }
6779
+ };
6780
+
6781
+ // サブツリーの添付件数を集計(token あれば union=正確、無ければ subtree込み count で近似)
6782
+ const countAttachments = async (normalized, subtreeIds, rootNode) => {
6783
+ if (client.filebirdToken) {
6784
+ try {
6785
+ const { unionIds, failedFolderCount } = await fetchAttachmentIdsForFolders(client, subtreeIds);
6786
+ return { attachmentCount: unionIds.length, approximate: failedFolderCount > 0, failedFolderCount };
6787
+ } catch { /* fall through to approximate */ }
5154
6788
  }
5155
- }
6789
+ // display は subtree 込みのため root の count をそのまま採用(合算しない)
6790
+ return { attachmentCount: rootNode?.count ?? 0, approximate: true, failedFolderCount: 0 };
6791
+ };
5156
6792
 
5157
- let text = `🔍 Search: "${args.query}"\n`;
5158
- text += `🔄 Variants (${variants.length}): ${variants.join(', ')}\n\n`;
6793
+ try {
6794
+ const normalized = await getNormalizedFileBirdFoldersAdmin(client);
6795
+ const target = resolveFolderTarget(normalized, { folder: args?.folder, folderId: args?.folderId });
6796
+ if (target.id <= 0) {
6797
+ throwToolError('VALIDATION_ERROR', 'システムフォルダ (Uncategorized / All) は削除できません', false);
6798
+ }
5159
6799
 
5160
- if (hits.length === 0) {
5161
- text += '❌ No results found.';
5162
- } else {
5163
- text += `📷 ${hits.length} result(s):\n\n`;
5164
- for (const h of hits) {
5165
- text += ` [ID: ${h.id}] ${h.title}\n`;
5166
- text += ` Alt: ${h.alt}\n`;
5167
- text += ` URL: ${h.url}\n`;
5168
- if (h.width && h.height) text += ` Size: ${h.width}x${h.height}\n`;
5169
- text += ` Matched: "${h.matched}"\n\n`;
6800
+ const subtreeIds = collectSubtreeFolderIds(normalized, target.id);
6801
+ const childFolderCount = subtreeIds.length - 1;
6802
+ const counts = await countAttachments(normalized, subtreeIds, target.node);
6803
+
6804
+ // echo confirm 判定
6805
+ const hasFolderIdConfirm = typeof args?.confirmFolderId === 'number';
6806
+ const pathConfirmNorm = safeNormalizePath(args?.confirmFolderPath);
6807
+ const confirmMatch =
6808
+ (hasFolderIdConfirm && args.confirmFolderId === target.id) ||
6809
+ (pathConfirmNorm !== null && pathConfirmNorm === target.path);
6810
+
6811
+ if (!confirmMatch) {
6812
+ return jsonToolResponse({
6813
+ success: true,
6814
+ deleted: false,
6815
+ requireConfirm: true,
6816
+ summary: {
6817
+ folderId: target.id,
6818
+ folderPath: target.path,
6819
+ folderName: target.node.name,
6820
+ childFolderCount,
6821
+ descendantFolderIds: subtreeIds,
6822
+ attachmentCount: counts.attachmentCount,
6823
+ attachmentCountApproximate: counts.approximate,
6824
+ },
6825
+ message: `このフォルダと子フォルダ ${childFolderCount} 個が削除されます。中の画像 ${counts.attachmentCount} 枚は削除されず Uncategorized に移動します。実行するには confirmFolderPath="${target.path}" を指定してください。`,
6826
+ });
5170
6827
  }
5171
- }
5172
6828
 
5173
- return { content: [{ type: "text", text }] };
6829
+ // --- 実行(TOCTOU 対策: 直前に再取得して再集計) ---
6830
+ const fresh = await getNormalizedFileBirdFoldersAdmin(client);
6831
+ const freshNode = fresh.byId.get(target.id);
6832
+ if (!freshNode || target.id <= 0) {
6833
+ throwToolError('FOLDER_NOT_FOUND', `削除対象 (ID: ${target.id}) が見つかりません。既に削除された可能性があります`, false);
6834
+ }
6835
+ const freshSubtreeIds = collectSubtreeFolderIds(fresh, target.id);
6836
+ const freshCounts = await countAttachments(fresh, freshSubtreeIds, freshNode);
6837
+
6838
+ await client.deleteFileBirdFolder(target.id);
6839
+
6840
+ // 削除後検証
6841
+ const verify = await getNormalizedFileBirdFoldersAdmin(client);
6842
+ const remaining = freshSubtreeIds.filter(id => verify.byId.has(id));
6843
+ if (remaining.length > 0) {
6844
+ return jsonToolResponse({
6845
+ success: false,
6846
+ deleted: false,
6847
+ error: toolError('UNKNOWN', `削除後検証でフォルダが残存しています (ID: ${remaining.join(', ')})。FileBird 側の所有権制限 (folder-per-user) で拒否された可能性があります`, true, { remainingFolderIds: remaining }),
6848
+ }, true);
6849
+ }
6850
+
6851
+ return jsonToolResponse({
6852
+ success: true,
6853
+ deleted: true,
6854
+ deletedFolderIds: freshSubtreeIds,
6855
+ detachedAttachmentCount: freshCounts.attachmentCount,
6856
+ detachedAttachmentCountApproximate: freshCounts.approximate,
6857
+ note: '画像は削除されていません。Uncategorized に移動しました。',
6858
+ });
6859
+ } catch (e) {
6860
+ return jsonErrorResponse(e);
6861
+ }
5174
6862
  }
5175
6863
 
5176
6864
  case "list_connections": {
@@ -5248,7 +6936,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
5248
6936
  const list = items.map(item => {
5249
6937
  const lines = [
5250
6938
  ` [${item.id}] ${item.title}`,
5251
- ` 案件ID: ${item.data_id} | slug: ${item.slug}`,
6939
+ ` 案件ID: ${item.data_id} | slug: ${item.slug} | permalink: ${item.permalink}`,
5252
6940
  ];
5253
6941
  if (item.asp_url) lines.push(` ASP URL: ${item.asp_url}`);
5254
6942
  if (item.asp_url_sp) lines.push(` ASP URL(B): ${item.asp_url_sp}`);
@@ -5273,6 +6961,66 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
5273
6961
  return { content: [{ type: "text", text: `❌ ${e.message}` }], isError: true };
5274
6962
  }
5275
6963
 
6964
+ const singleFields = ['title', 'slug', 'asp_id', 'asp_name', 'asp_url', 'asp_url_sp', 'direct_url', 'permalink'];
6965
+ const hasItems = isProvided(args?.items);
6966
+ const hasSingleFields = singleFields.some(field => isProvided(args?.[field]));
6967
+
6968
+ if (hasItems) {
6969
+ if (hasSingleFields) {
6970
+ return { content: [{ type: "text", text: "❌ items と単一登録用フィールド(title/slug/asp_url など)は同時指定できません。" }], isError: true };
6971
+ }
6972
+ if (!Array.isArray(args.items)) {
6973
+ return { content: [{ type: "text", text: "❌ items は配列で指定してください。" }], isError: true };
6974
+ }
6975
+ if (args.items.length === 0) {
6976
+ return { content: [{ type: "text", text: "❌ items は1件以上指定してください。" }], isError: true };
6977
+ }
6978
+ if (args.items.length > MAX_ASP_BULK) {
6979
+ return { content: [{ type: "text", text: `❌ items は最大${MAX_ASP_BULK}件までです。` }], isError: true };
6980
+ }
6981
+ for (let i = 0; i < args.items.length; i++) {
6982
+ const item = args.items[i];
6983
+ if (!item || typeof item !== 'object' || Array.isArray(item)) {
6984
+ return { content: [{ type: "text", text: `❌ items[${i}] は object で指定してください。` }], isError: true };
6985
+ }
6986
+ if (!item.title || !item.slug || !item.asp_url) {
6987
+ return { content: [{ type: "text", text: `❌ items[${i}].title, slug, asp_url は必須です。` }], isError: true };
6988
+ }
6989
+ }
6990
+
6991
+ let result;
6992
+ try {
6993
+ result = await client.bulkCreateAspLinks(args.items);
6994
+ } catch (e) {
6995
+ return { content: [{ type: "text", text: `❌ ASPリンク一括登録エラー: ${e.message}` }], isError: true };
6996
+ }
6997
+
6998
+ const summary = result.summary || {};
6999
+ const lines = [
7000
+ `✅ ASPリンク一括登録完了`,
7001
+ ``,
7002
+ ` total: ${summary.total ?? args.items.length}`,
7003
+ ` registered: ${summary.registered ?? 0}`,
7004
+ ` duplicate: ${summary.duplicate ?? 0}`,
7005
+ ` duplicate_in_batch: ${summary.duplicate_in_batch ?? 0}`,
7006
+ ` error: ${summary.error ?? 0}`,
7007
+ ``,
7008
+ `件別結果:`,
7009
+ ];
7010
+ for (const item of result.items || []) {
7011
+ if (item.status === 'registered') {
7012
+ lines.push(` [${item.index}] registered: ${item.title} (ID: ${item.id}, asp_id: ${item.data_id}, permalink: ${item.permalink})`);
7013
+ } else if (item.status === 'duplicate') {
7014
+ lines.push(` [${item.index}] duplicate: ${item.message}${item.existingId ? ` (existing ID: ${item.existingId})` : ''}`);
7015
+ } else if (item.status === 'duplicate_in_batch') {
7016
+ lines.push(` [${item.index}] duplicate_in_batch: ${item.asp_id} (first index: ${item.firstIndex})`);
7017
+ } else {
7018
+ lines.push(` [${item.index}] error: ${item.code || 'error'} ${item.message || ''}`.trimEnd());
7019
+ }
7020
+ }
7021
+ return { content: [{ type: "text", text: lines.join('\n') }] };
7022
+ }
7023
+
5276
7024
  if (!args?.title || !args?.slug || !args?.asp_url) {
5277
7025
  return { content: [{ type: "text", text: "❌ title, slug, asp_url は必須です。" }], isError: true };
5278
7026
  }
@@ -5287,6 +7035,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
5287
7035
  asp_url: args.asp_url,
5288
7036
  asp_url_sp: args?.asp_url_sp,
5289
7037
  direct_url: args?.direct_url,
7038
+ permalink: args?.permalink,
5290
7039
  });
5291
7040
  } catch (e) {
5292
7041
  return { content: [{ type: "text", text: `❌ ASPリンク登録エラー: ${e.message}` }], isError: true };
@@ -5298,16 +7047,667 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
5298
7047
  ` ID: ${result.id}`,
5299
7048
  ` タイトル: ${result.title}`,
5300
7049
  ` slug: ${result.slug}`,
7050
+ ` permalink: ${result.permalink}`,
7051
+ ` 案件ID: ${result.data_id}`,
7052
+ ];
7053
+ if (result.asp_url) lines.push(` ASP URL: ${result.asp_url}`);
7054
+ if (result.asp_url_sp) lines.push(` ASP URL(B): ${result.asp_url_sp}`);
7055
+ if (result.direct_url) lines.push(` 直リンク: ${result.direct_url}`);
7056
+ lines.push(` tag: ${result.tag}`);
7057
+
7058
+ return { content: [{ type: "text", text: lines.join('\n') }] };
7059
+ }
7060
+
7061
+ case "update_asp_link": {
7062
+ let client;
7063
+ try {
7064
+ client = registry.get(args?.site);
7065
+ } catch (e) {
7066
+ return { content: [{ type: "text", text: `❌ ${e.message}` }], isError: true };
7067
+ }
7068
+
7069
+ const id = Number(args?.id);
7070
+ if (!Number.isInteger(id) || id <= 0) {
7071
+ return { content: [{ type: "text", text: "❌ id は正の整数で指定してください。" }], isError: true };
7072
+ }
7073
+ if (isProvided(args?.permalink) && isProvided(args?.slug)) {
7074
+ return { content: [{ type: "text", text: "❌ ambiguous_permalink: permalink と slug は同時指定できません。" }], isError: true };
7075
+ }
7076
+
7077
+ const updateFields = ['title', 'permalink', 'slug', 'asp_id', 'asp_name', 'asp_url', 'asp_url_sp', 'direct_url'];
7078
+ const data = {};
7079
+ for (const field of updateFields) {
7080
+ if (isProvided(args?.[field])) data[field] = args[field];
7081
+ }
7082
+ const hasEffectiveField = Object.values(data).some(value => typeof value === 'string' ? value.trim() !== '' : isProvided(value));
7083
+ if (!hasEffectiveField) {
7084
+ return { content: [{ type: "text", text: "❌ no_update_fields: 更新する非空フィールドを1つ以上指定してください。" }], isError: true };
7085
+ }
7086
+
7087
+ let result;
7088
+ try {
7089
+ result = await client.updateAspLink(id, data);
7090
+ } catch (e) {
7091
+ return { content: [{ type: "text", text: `❌ ASPリンク更新エラー: ${e.message}` }], isError: true };
7092
+ }
7093
+
7094
+ const lines = [
7095
+ `✅ ASPリンク更新完了`,
7096
+ ``,
7097
+ ` ID: ${result.id}`,
7098
+ ` タイトル: ${result.title}`,
7099
+ ` slug: ${result.slug}`,
7100
+ ` permalink: ${result.permalink}`,
5301
7101
  ` 案件ID: ${result.data_id}`,
5302
7102
  ];
5303
7103
  if (result.asp_url) lines.push(` ASP URL: ${result.asp_url}`);
5304
7104
  if (result.asp_url_sp) lines.push(` ASP URL(B): ${result.asp_url_sp}`);
5305
7105
  if (result.direct_url) lines.push(` 直リンク: ${result.direct_url}`);
7106
+ if (result.warning) lines.push(` 警告: ${result.warning}`);
5306
7107
  lines.push(` tag: ${result.tag}`);
5307
7108
 
5308
7109
  return { content: [{ type: "text", text: lines.join('\n') }] };
5309
7110
  }
5310
7111
 
7112
+ case "insert_asp_tag": {
7113
+ const siteName = args?.site || 'default';
7114
+ let { mode, postId: _postId, sessionId: _sessionId, message, client } = await resolveMode(args, name);
7115
+ if (mode === 'error') {
7116
+ return errorResponse(name, message, args?.site);
7117
+ }
7118
+ const _resolved = mode === 'headless' ? await resolvePostId(args?.postId, client) : { postId: _postId };
7119
+ if (_resolved.error) return { content: [{ type: "text", text: _resolved.error }], isError: true };
7120
+ if (_resolved.editorConnected) mode = 'editor';
7121
+ const postId = _resolved.postId ?? _postId;
7122
+ const _siteName = siteName;
7123
+ const _modeTag = process.env.FRIDAY_DEBUG === '1' ? `\n[DEBUG] mode=${mode}` : '';
7124
+
7125
+ const assignments = Array.isArray(args?.assignments) ? args.assignments : [];
7126
+ if (assignments.length === 0) {
7127
+ return { content: [{ type: "text", text: "❌ assignments は必須です(1件以上)" }], isError: true };
7128
+ }
7129
+ if (assignments.length > 30) {
7130
+ return { content: [{ type: "text", text: "❌ assignments は最大30件までです" }], isError: true };
7131
+ }
7132
+ if (!args?.snapshotId) {
7133
+ return { content: [{ type: "text", text: "❌ snapshotId は必須です" }], isError: true };
7134
+ }
7135
+ for (let i = 0; i < assignments.length; i++) {
7136
+ const a = assignments[i];
7137
+ if (!a || typeof a.ref !== 'string' || !a.ref) {
7138
+ return { content: [{ type: "text", text: `❌ assignments[${i}].ref は必須です` }], isError: true };
7139
+ }
7140
+ if (typeof a.asp_id !== 'string' || !a.asp_id) {
7141
+ return { content: [{ type: "text", text: `❌ assignments[${i}].asp_id は必須です` }], isError: true };
7142
+ }
7143
+ }
7144
+
7145
+ // ASP id 一括解決
7146
+ const aspIds = [...new Set(assignments.map(a => a.asp_id))];
7147
+ let aspMap;
7148
+ try {
7149
+ const res = await client.resolveAspLinksBulk(aspIds);
7150
+ aspMap = res?.items || {};
7151
+ } catch (e) {
7152
+ return { content: [{ type: "text", text: `❌ ASPリンク解決エラー: ${e.message}` }], isError: true };
7153
+ }
7154
+ const unknownIds = aspIds.filter(aid => !aspMap[aid]);
7155
+ if (unknownIds.length > 0) {
7156
+ return { content: [{ type: "text", text: `❌ 未登録の asp_id: ${unknownIds.join(', ')}` }], isError: true };
7157
+ }
7158
+
7159
+ // 現在構造取得
7160
+ let currentState;
7161
+ try {
7162
+ currentState = await getCurrentStructure(mode, client, postId, _sessionId, { safetyCritical: true });
7163
+ } catch (e) {
7164
+ return { content: [{ type: "text", text: `❌ 現在のブロック構造を取得できませんでした: ${e.message}` }], isError: true };
7165
+ }
7166
+
7167
+ // revision check
7168
+ if (args?.expectedRevision) {
7169
+ const mismatch = checkRevisionMismatch(args.expectedRevision, currentState, mode, postId, _sessionId, _siteName);
7170
+ if (mismatch) return mismatch;
7171
+ }
7172
+
7173
+ // ヘルパー: HTML エスケープ
7174
+ const escapeHtml = (s) => String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
7175
+ // ヘルパー: 基本 HTML エンティティの decode(部分一致比較用)
7176
+ const decodeHtml = (s) => String(s)
7177
+ .replace(/&lt;/g, '<')
7178
+ .replace(/&gt;/g, '>')
7179
+ .replace(/&quot;/g, '"')
7180
+ .replace(/&#34;/g, '"')
7181
+ .replace(/&apos;/g, "'")
7182
+ .replace(/&#39;/g, "'")
7183
+ .replace(/&nbsp;/g, ' ')
7184
+ .replace(/&amp;/g, '&'); // & は最後
7185
+ // 既存タグ検知 regex(jsl-custom-label + data-id の組合せ)
7186
+ const inlineTagRegex = /<span[^>]*\bjsl-custom-label\b[^>]*\bdata-id=["'][^"']*["'][^>]*>[\s\S]*?<\/span>/i;
7187
+ // jsl-custom-label span 抽出用(既存タグ領域の範囲特定)
7188
+ const inlineSpanGlobalRegex = /<span[^>]*\bjsl-custom-label\b[^>]*>[\s\S]*?<\/span>/gi;
7189
+ // span テンプレに text を埋める(既に escape 済みの inner HTML を入れる場合は rawInner=true)
7190
+ const fillSpanInner = (spanHtml, text, rawInner = false) => {
7191
+ const inner = rawInner ? String(text || '') : (text ? escapeHtml(text) : '');
7192
+ return spanHtml.replace(/><\/span>\s*$/, `>${inner}</span>`);
7193
+ };
7194
+ // 段落 innerHTML を [text 領域 / jsl span 領域] のパートに分解
7195
+ const splitParagraphParts = (innerHtml) => {
7196
+ const parts = [];
7197
+ let lastIdx = 0;
7198
+ let m;
7199
+ inlineSpanGlobalRegex.lastIndex = 0;
7200
+ while ((m = inlineSpanGlobalRegex.exec(innerHtml)) !== null) {
7201
+ if (m.index > lastIdx) parts.push({ type: 'text', raw: innerHtml.substring(lastIdx, m.index) });
7202
+ parts.push({ type: 'span', raw: m[0] });
7203
+ lastIdx = m.index + m[0].length;
7204
+ }
7205
+ if (lastIdx < innerHtml.length) parts.push({ type: 'text', raw: innerHtml.substring(lastIdx) });
7206
+ return parts;
7207
+ };
7208
+ // text 領域に jsl-custom-label 以外のタグが含まれるか判定(部分付与の可否判定)
7209
+ const hasOtherTags = (textPartRaw) => /<[a-zA-Z!\/]/.test(textPartRaw);
7210
+
7211
+ // === core/table 用ヘルパー ===
7212
+ // テーブル HTML から (section, row, col) のセル位置を特定
7213
+ const findTableCell = (blockHtml, section, row, col) => {
7214
+ const tableMatch = blockHtml.match(/<table[^>]*>[\s\S]*?<\/table>/i);
7215
+ if (!tableMatch) return { error: 'no_table' };
7216
+ const tableStartInBlock = tableMatch.index;
7217
+ const tableHtml = tableMatch[0];
7218
+
7219
+ const sectionTag = section === 'head' ? 'thead' : 'tbody';
7220
+ const sectionRegex = new RegExp(`<${sectionTag}[^>]*>([\\s\\S]*?)</${sectionTag}>`, 'i');
7221
+ const sectionMatch = tableHtml.match(sectionRegex);
7222
+ if (!sectionMatch) return { error: 'section_not_found' };
7223
+ const sectionStartInTable = sectionMatch.index;
7224
+ const sectionContent = sectionMatch[1];
7225
+ const sectionContentStart = sectionStartInTable + sectionMatch[0].indexOf('>') + 1;
7226
+
7227
+ const trRegex = /<tr[^>]*>([\s\S]*?)<\/tr>/gi;
7228
+ let m;
7229
+ let rowIdx = 0;
7230
+ while ((m = trRegex.exec(sectionContent)) !== null) {
7231
+ if (rowIdx === row) {
7232
+ const rowInner = m[1];
7233
+ const rowInnerStart = sectionContentStart + m.index + m[0].indexOf('>') + 1;
7234
+ const cellRegex = /<(th|td)((?:\s[^>]*)?)>([\s\S]*?)<\/\1>/gi;
7235
+ let cm;
7236
+ let colIdx = 0;
7237
+ while ((cm = cellRegex.exec(rowInner)) !== null) {
7238
+ if (colIdx === col) {
7239
+ const cellStartInTable = rowInnerStart + cm.index;
7240
+ const openTagLen = cm[0].indexOf('>') + 1;
7241
+ const closeTagLen = `</${cm[1]}>`.length;
7242
+ return {
7243
+ tag: cm[1],
7244
+ attrs: cm[2],
7245
+ inner: cm[3],
7246
+ innerStart: tableStartInBlock + cellStartInTable + openTagLen,
7247
+ innerEnd: tableStartInBlock + cellStartInTable + cm[0].length - closeTagLen,
7248
+ };
7249
+ }
7250
+ colIdx++;
7251
+ }
7252
+ return { error: 'col_out_of_range', colCount: colIdx };
7253
+ }
7254
+ rowIdx++;
7255
+ }
7256
+ return { error: 'row_out_of_range', rowCount: rowIdx };
7257
+ };
7258
+ // ブロック HTML のセル中身を newInner に置換
7259
+ const replaceCellInner = (blockHtml, cellInfo, newInner) =>
7260
+ blockHtml.substring(0, cellInfo.innerStart) + newInner + blockHtml.substring(cellInfo.innerEnd);
7261
+ // インラインボタン判定(swl-inline-btn を含む)
7262
+ const isInlineButtonCell = (cellInner) => /class=["'][^"']*\bswl-inline-btn\b/.test(cellInner);
7263
+ // 最初の <a> の href 値を取得(href 属性が無い場合は空文字、<a> 自体が無い場合は null)
7264
+ const getFirstAnchorHref = (cellInner) => {
7265
+ const m = cellInner.match(/<a\b([^>]*)>/i);
7266
+ if (!m) return null;
7267
+ const hrefMatch = m[1].match(/\bhref=["']([^"']*)["']/);
7268
+ return hrefMatch ? hrefMatch[1] : '';
7269
+ };
7270
+ // href プレースホルダ判定(未設定として扱う値)
7271
+ const isPlaceholderHref = (href) => {
7272
+ if (typeof href !== 'string') return true;
7273
+ const t = href.trim();
7274
+ return t === '' || t === '#' || t === '###';
7275
+ };
7276
+ // 最初の <a> の href を newHref に書き換え(href 属性が無ければ追加)
7277
+ const setFirstAnchorHref = (cellInner, newHref) => {
7278
+ return cellInner.replace(/<a\b([^>]*)>/i, (_full, attrs) => {
7279
+ if (/\bhref=["'][^"']*["']/.test(attrs)) {
7280
+ return `<a${attrs.replace(/\bhref=["'][^"']*["']/, `href="${newHref}"`)}>`;
7281
+ }
7282
+ return `<a href="${newHref}"${attrs}>`;
7283
+ });
7284
+ };
7285
+
7286
+ // core/table の処理ロジック(switch 内の見通しを保つため関数化)
7287
+ // 戻り値: { preResult, operation? }
7288
+ const processTableCell = (block, a, aspInfo, force) => {
7289
+ const cell = a.cell;
7290
+ if (!cell || typeof cell.row !== 'number' || typeof cell.col !== 'number' || (cell.section !== 'head' && cell.section !== 'body')) {
7291
+ return { preResult: { ref: a.ref, status: 'cell_required', blockType: 'core/table', asp_id: a.asp_id, reason: 'cell { section: "head"|"body", row, col } is required for core/table' } };
7292
+ }
7293
+ if (typeof block.html !== 'string' || !block.html) {
7294
+ return { preResult: { ref: a.ref, status: 'ref_failed', blockType: 'core/table', asp_id: a.asp_id, reason: 'block HTML not available for cell resolution' } };
7295
+ }
7296
+ const cellInfo = findTableCell(block.html, cell.section, cell.row, cell.col);
7297
+ if (cellInfo.error) {
7298
+ let reason;
7299
+ if (cellInfo.error === 'no_table') reason = 'no <table> element found in block';
7300
+ else if (cellInfo.error === 'section_not_found') reason = `section "${cell.section}" (${cell.section === 'head' ? 'thead' : 'tbody'}) not found in table`;
7301
+ else if (cellInfo.error === 'row_out_of_range') reason = `row ${cell.row} out of range (only ${cellInfo.rowCount} rows in ${cell.section})`;
7302
+ else if (cellInfo.error === 'col_out_of_range') reason = `col ${cell.col} out of range (only ${cellInfo.colCount} cols in row ${cell.row} of ${cell.section})`;
7303
+ else reason = `cell lookup error: ${cellInfo.error}`;
7304
+ return { preResult: { ref: a.ref, status: 'cell_out_of_range', blockType: 'core/table', asp_id: a.asp_id, reason } };
7305
+ }
7306
+ const cellInner = cellInfo.inner;
7307
+
7308
+ // セル種別判定: swl-inline-btn → href 書き換え経路
7309
+ if (isInlineButtonCell(cellInner)) {
7310
+ if (!aspInfo.asp_url) {
7311
+ return { preResult: { ref: a.ref, status: 'missing_asp_url', blockType: 'core/table', asp_id: a.asp_id, reason: `ASP "${a.asp_id}" has no asp_url registered (required for inline-button cells)` } };
7312
+ }
7313
+ const currentHref = getFirstAnchorHref(cellInner);
7314
+ if (currentHref === null) {
7315
+ return { preResult: { ref: a.ref, status: 'inline_btn_no_anchor', blockType: 'core/table', asp_id: a.asp_id, reason: 'inline button cell has no <a> element inside' } };
7316
+ }
7317
+ if (currentHref === aspInfo.asp_url) {
7318
+ return { preResult: { ref: a.ref, status: 'skipped', blockType: 'core/table', asp_id: a.asp_id, reason: 'href already matches asp_url' } };
7319
+ }
7320
+ const placeholder = isPlaceholderHref(currentHref);
7321
+ if (!placeholder && !force) {
7322
+ return { preResult: { ref: a.ref, status: 'skipped', blockType: 'core/table', asp_id: a.asp_id, reason: `existing href "${currentHref}" (use force:true to overwrite)` } };
7323
+ }
7324
+ const newInner = setFirstAnchorHref(cellInner, aspInfo.asp_url);
7325
+ const newBlockHtml = replaceCellInner(block.html, cellInfo, newInner);
7326
+ return {
7327
+ preResult: { ref: a.ref, status: 'pending', blockType: 'core/table', asp_id: a.asp_id, mode: 'inline_button_href' },
7328
+ operation: { target: { ref: a.ref }, newHTML: newBlockHtml },
7329
+ };
7330
+ }
7331
+
7332
+ // 通常セル経路: core/paragraph と同じ全体/部分ラップ ロジック
7333
+ const tplInline = aspInfo?.tags?.inline || '';
7334
+ if (!tplInline) {
7335
+ return { preResult: { ref: a.ref, status: 'tag_template_missing', blockType: 'core/table', asp_id: a.asp_id } };
7336
+ }
7337
+ const userTextProvided = typeof a.text === 'string';
7338
+ if (userTextProvided && a.text.length === 0) {
7339
+ return { preResult: { ref: a.ref, status: 'empty_text', blockType: 'core/table', asp_id: a.asp_id, reason: 'text must not be empty string' } };
7340
+ }
7341
+ const isPartial = userTextProvided;
7342
+ const userText = isPartial ? a.text : '';
7343
+ let newCellInner = '';
7344
+
7345
+ if (isPartial) {
7346
+ const parts = splitParagraphParts(cellInner);
7347
+ const hasInlineHtml = parts.some(p => p.type === 'text' && hasOtherTags(p.raw));
7348
+ if (hasInlineHtml) {
7349
+ return { preResult: { ref: a.ref, status: 'unsupported_inline_html', blockType: 'core/table', asp_id: a.asp_id, reason: 'cell contains non-ASP inline HTML; partial insertion not supported' } };
7350
+ }
7351
+ const allMatches = [];
7352
+ for (let pi = 0; pi < parts.length; pi++) {
7353
+ const p = parts[pi];
7354
+ if (p.type !== 'text') continue;
7355
+ const decoded = decodeHtml(p.raw);
7356
+ let pos = 0;
7357
+ while (true) {
7358
+ const idx = decoded.indexOf(userText, pos);
7359
+ if (idx === -1) break;
7360
+ allMatches.push({ partIndex: pi, decodedIndex: idx, decoded });
7361
+ pos = idx + Math.max(1, userText.length);
7362
+ }
7363
+ }
7364
+ if (allMatches.length === 0) {
7365
+ return { preResult: { ref: a.ref, status: 'not_found', blockType: 'core/table', asp_id: a.asp_id, reason: 'text not found in cell' } };
7366
+ }
7367
+ if (allMatches.length > 1) {
7368
+ const matchPreviews = allMatches.slice(0, 5).map(mm => {
7369
+ const before = mm.decoded.substring(Math.max(0, mm.decodedIndex - 12), mm.decodedIndex);
7370
+ const after = mm.decoded.substring(mm.decodedIndex + userText.length, Math.min(mm.decoded.length, mm.decodedIndex + userText.length + 12));
7371
+ return `${before}【${userText}】${after}`;
7372
+ });
7373
+ return { preResult: { ref: a.ref, status: 'multiple_matches', blockType: 'core/table', asp_id: a.asp_id, reason: `text matches ${allMatches.length} times in cell`, matchCount: allMatches.length, matchPreviews } };
7374
+ }
7375
+ const hit = allMatches[0];
7376
+ const inlineSpan = fillSpanInner(tplInline, userText);
7377
+ const before = escapeHtml(hit.decoded.substring(0, hit.decodedIndex));
7378
+ const after = escapeHtml(hit.decoded.substring(hit.decodedIndex + userText.length));
7379
+ const newPartRaw = before + inlineSpan + after;
7380
+ newCellInner = parts.map((p, i) => i === hit.partIndex ? newPartRaw : p.raw).join('');
7381
+ } else {
7382
+ const hasExisting = inlineTagRegex.test(cellInner);
7383
+ if (hasExisting && !force) {
7384
+ return { preResult: { ref: a.ref, status: 'skipped', blockType: 'core/table', asp_id: a.asp_id, reason: 'existing inline tag' } };
7385
+ }
7386
+ const baseInner = hasExisting
7387
+ ? cellInner.replace(inlineSpanGlobalRegex, (_m) => {
7388
+ const im = _m.match(/<span[^>]*>([\s\S]*?)<\/span>/i);
7389
+ return im ? im[1] : '';
7390
+ })
7391
+ : cellInner;
7392
+ if (!baseInner || baseInner.length === 0) {
7393
+ return { preResult: { ref: a.ref, status: 'empty_cell', blockType: 'core/table', asp_id: a.asp_id, reason: 'cell is empty' } };
7394
+ }
7395
+ newCellInner = fillSpanInner(tplInline, baseInner, true);
7396
+ }
7397
+
7398
+ const newBlockHtml = replaceCellInner(block.html, cellInfo, newCellInner);
7399
+ return {
7400
+ preResult: { ref: a.ref, status: 'pending', blockType: 'core/table', asp_id: a.asp_id, mode: isPartial ? 'partial' : 'whole' },
7401
+ operation: { target: { ref: a.ref }, newHTML: newBlockHtml },
7402
+ };
7403
+ };
7404
+
7405
+ // 各 assignment を MCP 側で事前判定 → operations を組み立て
7406
+ const operations = [];
7407
+ const preResults = [];
7408
+ for (let i = 0; i < assignments.length; i++) {
7409
+ const a = assignments[i];
7410
+ const aspInfo = aspMap[a.asp_id];
7411
+ const dataId = aspInfo.data_id;
7412
+ const force = !!a.force;
7413
+
7414
+ let index;
7415
+ try {
7416
+ index = resolveRefFromState(args.snapshotId, a.ref, mode, _sessionId, postId, currentState);
7417
+ } catch (e) {
7418
+ preResults.push({ ref: a.ref, status: 'ref_failed', reason: e.message, asp_id: a.asp_id });
7419
+ continue;
7420
+ }
7421
+ const block = currentState.allBlocks.find(b => b.index === index);
7422
+ if (!block) {
7423
+ preResults.push({ ref: a.ref, status: 'block_not_found', reason: `index ${index} not found`, asp_id: a.asp_id });
7424
+ continue;
7425
+ }
7426
+
7427
+ const blockType = block.type;
7428
+ const attrs = block.attributes || {};
7429
+
7430
+ if (blockType === 'core/image') {
7431
+ const existing = !!(attrs.dataId && String(attrs.dataId).length > 0);
7432
+ if (existing && !force) {
7433
+ preResults.push({ ref: a.ref, status: 'skipped', blockType, reason: 'existing dataId', asp_id: a.asp_id });
7434
+ continue;
7435
+ }
7436
+ operations.push({ target: { ref: a.ref }, attributeUpdates: { set: { dataId } } });
7437
+ preResults.push({ ref: a.ref, status: 'pending', blockType, asp_id: a.asp_id, dataId });
7438
+ } else if (blockType === 'sbd/btn') {
7439
+ const existing = !!(attrs.ad && String(attrs.ad).includes('jsl-custom-label'));
7440
+ if (existing && !force) {
7441
+ preResults.push({ ref: a.ref, status: 'skipped', blockType, reason: 'existing ad', asp_id: a.asp_id });
7442
+ continue;
7443
+ }
7444
+ const text = (typeof a.text === 'string' && a.text.length > 0)
7445
+ ? a.text
7446
+ : (typeof attrs.content === 'string' ? attrs.content : '');
7447
+ const adSpan = fillSpanInner(aspInfo?.tags?.button?.ad || '', text);
7448
+ if (!adSpan) {
7449
+ preResults.push({ ref: a.ref, status: 'tag_template_missing', blockType, asp_id: a.asp_id });
7450
+ continue;
7451
+ }
7452
+ operations.push({ target: { ref: a.ref }, attributeUpdates: { set: { ad: adSpan } } });
7453
+ preResults.push({ ref: a.ref, status: 'pending', blockType, asp_id: a.asp_id });
7454
+ } else if (blockType === 'core/paragraph') {
7455
+ // Gutenberg core/paragraph の content は innerHTML(<p>...</p> の中身)が正本。
7456
+ // attrs.content は標準保存形式では空配列で返ることが多い(parse_blocks の仕様)。
7457
+ // 両モードで安全に動かすため block.html から innerHTML を抽出し、newHTML で全体を再構築する。
7458
+ let oldInnerHtml = '';
7459
+ if (typeof block.html === 'string') {
7460
+ const m = block.html.match(/<p[^>]*>([\s\S]*?)<\/p>/i);
7461
+ if (m) oldInnerHtml = m[1];
7462
+ }
7463
+ if (!oldInnerHtml && typeof attrs.content === 'string') {
7464
+ oldInnerHtml = attrs.content;
7465
+ }
7466
+
7467
+ const tplInline = aspInfo?.tags?.inline || '';
7468
+ if (!tplInline) {
7469
+ preResults.push({ ref: a.ref, status: 'tag_template_missing', blockType, asp_id: a.asp_id });
7470
+ continue;
7471
+ }
7472
+
7473
+ // text の指定モード判定
7474
+ // - undefined → 全体付与
7475
+ // - '' → empty_text エラー
7476
+ // - 文字列 → 部分付与
7477
+ const userTextProvided = typeof a.text === 'string';
7478
+ if (userTextProvided && a.text.length === 0) {
7479
+ preResults.push({ ref: a.ref, status: 'empty_text', blockType, asp_id: a.asp_id, reason: 'text must not be empty string' });
7480
+ continue;
7481
+ }
7482
+ const isPartial = userTextProvided;
7483
+ const userText = isPartial ? a.text : '';
7484
+
7485
+ let newInnerHtml = '';
7486
+
7487
+ if (isPartial) {
7488
+ // === 部分付与 ===
7489
+ const parts = splitParagraphParts(oldInnerHtml);
7490
+ // text 領域に jsl-custom-label 以外のタグが含まれる段落は v1 では未対応
7491
+ const hasInlineHtml = parts.some(p => p.type === 'text' && hasOtherTags(p.raw));
7492
+ if (hasInlineHtml) {
7493
+ preResults.push({ ref: a.ref, status: 'unsupported_inline_html', blockType, asp_id: a.asp_id, reason: 'paragraph contains non-ASP inline HTML; partial insertion not supported' });
7494
+ continue;
7495
+ }
7496
+ // 各 text 領域を decode してマッチ箇所を全列挙
7497
+ const allMatches = [];
7498
+ for (let pi = 0; pi < parts.length; pi++) {
7499
+ const p = parts[pi];
7500
+ if (p.type !== 'text') continue;
7501
+ const decoded = decodeHtml(p.raw);
7502
+ let pos = 0;
7503
+ while (true) {
7504
+ const idx = decoded.indexOf(userText, pos);
7505
+ if (idx === -1) break;
7506
+ allMatches.push({ partIndex: pi, decodedIndex: idx, decoded });
7507
+ pos = idx + Math.max(1, userText.length);
7508
+ }
7509
+ }
7510
+ if (allMatches.length === 0) {
7511
+ preResults.push({ ref: a.ref, status: 'not_found', blockType, asp_id: a.asp_id, reason: `text not found in paragraph` });
7512
+ continue;
7513
+ }
7514
+ if (allMatches.length > 1) {
7515
+ const matchPreviews = allMatches.slice(0, 5).map(mm => {
7516
+ const before = mm.decoded.substring(Math.max(0, mm.decodedIndex - 12), mm.decodedIndex);
7517
+ const after = mm.decoded.substring(mm.decodedIndex + userText.length, Math.min(mm.decoded.length, mm.decodedIndex + userText.length + 12));
7518
+ return `${before}【${userText}】${after}`;
7519
+ });
7520
+ preResults.push({
7521
+ ref: a.ref,
7522
+ status: 'multiple_matches',
7523
+ blockType,
7524
+ asp_id: a.asp_id,
7525
+ reason: `text matches ${allMatches.length} times in paragraph`,
7526
+ matchCount: allMatches.length,
7527
+ matchPreviews,
7528
+ });
7529
+ continue;
7530
+ }
7531
+ // 一意の一致 → そのテキスト領域内で span を挿入
7532
+ const hit = allMatches[0];
7533
+ const inlineSpan = fillSpanInner(tplInline, userText);
7534
+ const before = escapeHtml(hit.decoded.substring(0, hit.decodedIndex));
7535
+ const after = escapeHtml(hit.decoded.substring(hit.decodedIndex + userText.length));
7536
+ const newPartRaw = before + inlineSpan + after;
7537
+ const rebuilt = parts.map((p, i) => i === hit.partIndex ? newPartRaw : p.raw).join('');
7538
+ newInnerHtml = rebuilt;
7539
+ } else {
7540
+ // === 全体付与 ===
7541
+ // 既存 jsl-custom-label span がある場合は force 必須
7542
+ const hasExisting = inlineTagRegex.test(oldInnerHtml);
7543
+ if (hasExisting && !force) {
7544
+ preResults.push({ ref: a.ref, status: 'skipped', blockType, reason: 'existing inline tag', asp_id: a.asp_id });
7545
+ continue;
7546
+ }
7547
+ // 既存 span は中身に展開して取り除き、全体を新 span で包む
7548
+ const baseInnerHtml = hasExisting
7549
+ ? oldInnerHtml.replace(inlineSpanGlobalRegex, (_m, ...rest) => {
7550
+ // capture group は無いので raw 全体を再パース
7551
+ const im = _m.match(/<span[^>]*>([\s\S]*?)<\/span>/i);
7552
+ return im ? im[1] : '';
7553
+ })
7554
+ : oldInnerHtml;
7555
+ if (!baseInnerHtml || baseInnerHtml.length === 0) {
7556
+ preResults.push({ ref: a.ref, status: 'empty_paragraph', blockType, asp_id: a.asp_id, reason: 'paragraph is empty' });
7557
+ continue;
7558
+ }
7559
+ // baseInnerHtml をそのまま span の中身として埋める(タグ構造を保持)
7560
+ newInnerHtml = fillSpanInner(tplInline, baseInnerHtml, true);
7561
+ }
7562
+
7563
+ // 元 attrs から content を除去(content は innerHTML が正本)し、他 attrs(dropCap 等)は保持
7564
+ let attrsStr = '';
7565
+ if (typeof block.html === 'string') {
7566
+ const commentMatch = block.html.match(/^<!--\s*wp:paragraph(\s+(\{[\s\S]*?\}))?\s*-->/);
7567
+ if (commentMatch && commentMatch[2]) {
7568
+ try {
7569
+ const parsedAttrs = JSON.parse(commentMatch[2]);
7570
+ delete parsedAttrs.content;
7571
+ if (Object.keys(parsedAttrs).length > 0) {
7572
+ attrsStr = ' ' + JSON.stringify(parsedAttrs);
7573
+ }
7574
+ } catch (_e) { /* attrs 破棄(安全側) */ }
7575
+ }
7576
+ }
7577
+ // <p> タグの属性(class 等)を保持
7578
+ let pTagOpen = '<p>';
7579
+ if (typeof block.html === 'string') {
7580
+ const pOpenMatch = block.html.match(/<p([^>]*)>/i);
7581
+ if (pOpenMatch) pTagOpen = `<p${pOpenMatch[1]}>`;
7582
+ }
7583
+ const newBlockHtml = `<!-- wp:paragraph${attrsStr} -->\n${pTagOpen}${newInnerHtml}</p>\n<!-- /wp:paragraph -->`;
7584
+ operations.push({ target: { ref: a.ref }, newHTML: newBlockHtml });
7585
+ preResults.push({ ref: a.ref, status: 'pending', blockType, asp_id: a.asp_id, mode: isPartial ? 'partial' : 'whole' });
7586
+ } else if (blockType === 'core/table') {
7587
+ const result = processTableCell(block, a, aspInfo, force);
7588
+ preResults.push(result.preResult);
7589
+ if (result.operation) operations.push(result.operation);
7590
+ } else {
7591
+ preResults.push({ ref: a.ref, status: 'unsupported', blockType, reason: `${blockType} is not supported`, asp_id: a.asp_id });
7592
+ }
7593
+ }
7594
+
7595
+ // operations を handleBatchOperations に委譲
7596
+ // preFetchedState で currentState を渡し、内部での getCurrentStructure 再取得を回避
7597
+ let batchResult = null;
7598
+ if (operations.length > 0) {
7599
+ batchResult = await handleBatchOperations(
7600
+ operations, args.snapshotId, mode, client, postId,
7601
+ _sessionId, _siteName, _modeTag, "insert_asp_tag", args?.expectedRevision,
7602
+ { preFetchedState: currentState }
7603
+ );
7604
+ if (batchResult?.isError) return batchResult;
7605
+ }
7606
+
7607
+ // batch 結果 → 構造化結果から成功/失敗 ref を抽出
7608
+ const successRefs = new Set();
7609
+ const failedRefs = new Set();
7610
+ if (batchResult?._structuredResult?.operations) {
7611
+ for (const op of batchResult._structuredResult.operations) {
7612
+ if (op.status === 'updated' || op.status === 'expanded') {
7613
+ successRefs.add(op.ref);
7614
+ } else if (op.status === 'failed') {
7615
+ failedRefs.add(op.ref);
7616
+ }
7617
+ // 'skipped' は handleBatchOperations 内で発生しうるが、
7618
+ // insert_asp_tag は事前判定で skipped を弾いて operations に積まないため
7619
+ // ここに到達するのは異常系。successRefs に入れず failedRefs にも入れない
7620
+ }
7621
+ }
7622
+
7623
+ // 結果整形
7624
+ let success = 0, failed = 0, skipped = 0, unsupported = 0;
7625
+ const lines = [];
7626
+ for (const r of preResults) {
7627
+ if (r.status === 'pending') {
7628
+ if (successRefs.has(r.ref)) {
7629
+ success++;
7630
+ let detail = '';
7631
+ if (r.blockType === 'core/image') detail = ` (dataId=${r.dataId})`;
7632
+ else if (r.blockType === 'sbd/btn') detail = ' (ad set)';
7633
+ else if (r.blockType === 'core/paragraph') detail = r.mode === 'partial' ? ' (partial span wrapped)' : ' (whole paragraph wrapped)';
7634
+ else if (r.blockType === 'core/table') {
7635
+ if (r.mode === 'inline_button_href') detail = ' (anchor href set)';
7636
+ else if (r.mode === 'partial') detail = ' (partial span wrapped in cell)';
7637
+ else detail = ' (whole cell wrapped)';
7638
+ }
7639
+ lines.push(` [${r.ref}] inserted: ${r.blockType}${detail}`);
7640
+ } else {
7641
+ failed++;
7642
+ lines.push(` [${r.ref}] failed: ${r.blockType}`);
7643
+ }
7644
+ } else if (r.status === 'skipped') {
7645
+ skipped++;
7646
+ lines.push(` [${r.ref}] skipped: ${r.reason} (use force:true to overwrite)`);
7647
+ } else if (r.status === 'unsupported') {
7648
+ unsupported++;
7649
+ lines.push(` [${r.ref}] unsupported: ${r.blockType}`);
7650
+ } else if (r.status === 'unsupported_inline_html') {
7651
+ unsupported++;
7652
+ lines.push(` [${r.ref}] unsupported_inline_html: paragraph contains non-ASP inline HTML; partial insertion not supported (try whole-paragraph insert by omitting "text")`);
7653
+ } else if (r.status === 'not_found') {
7654
+ failed++;
7655
+ lines.push(` [${r.ref}] not_found: target text was not found in paragraph`);
7656
+ } else if (r.status === 'multiple_matches') {
7657
+ failed++;
7658
+ const head = ` [${r.ref}] multiple_matches: text matches ${r.matchCount} times — extend "text" with surrounding characters until unique`;
7659
+ const previews = (r.matchPreviews || []).map((p, i) => ` ${i + 1}. ${p}`).join('\n');
7660
+ lines.push(previews ? `${head}\n${previews}` : head);
7661
+ } else if (r.status === 'empty_paragraph') {
7662
+ failed++;
7663
+ lines.push(` [${r.ref}] empty_paragraph: paragraph has no text to wrap`);
7664
+ } else if (r.status === 'empty_text') {
7665
+ failed++;
7666
+ lines.push(` [${r.ref}] empty_text: text must not be empty string (omit "text" for whole wrap)`);
7667
+ } else if (r.status === 'empty_cell') {
7668
+ failed++;
7669
+ lines.push(` [${r.ref}] empty_cell: ${r.reason || 'cell has no content to wrap'}`);
7670
+ } else if (r.status === 'cell_required') {
7671
+ failed++;
7672
+ lines.push(` [${r.ref}] cell_required: ${r.reason || 'cell { section, row, col } is required for core/table'}`);
7673
+ } else if (r.status === 'cell_out_of_range') {
7674
+ failed++;
7675
+ lines.push(` [${r.ref}] cell_out_of_range: ${r.reason || ''}`);
7676
+ } else if (r.status === 'missing_asp_url') {
7677
+ failed++;
7678
+ lines.push(` [${r.ref}] missing_asp_url: ${r.reason || `ASP "${r.asp_id}" has no asp_url`}`);
7679
+ } else if (r.status === 'inline_btn_no_anchor') {
7680
+ failed++;
7681
+ lines.push(` [${r.ref}] inline_btn_no_anchor: ${r.reason || 'inline button cell missing <a>'}`);
7682
+ } else if (r.status === 'tag_template_missing') {
7683
+ failed++;
7684
+ lines.push(` [${r.ref}] tag_template_missing: ASP "${r.asp_id}" has no inline template`);
7685
+ } else if (r.status === 'ref_failed') {
7686
+ failed++;
7687
+ lines.push(` [${r.ref}] ref_failed: ${r.reason}`);
7688
+ } else {
7689
+ failed++;
7690
+ lines.push(` [${r.ref}] ${r.status}: ${r.reason || ''}`);
7691
+ }
7692
+ }
7693
+
7694
+ // snapshot 抽出(構造化結果から)
7695
+ let snapshotLine = '';
7696
+ const snap = batchResult?._structuredResult?.snapshot;
7697
+ if (snap?.snapshotId) {
7698
+ snapshotLine = `\n[snapshot:${snap.snapshotId}${snap.revision ? ` rev:${snap.revision}` : ''}]`;
7699
+ }
7700
+
7701
+ const total = preResults.length;
7702
+ const summary = [`${success}/${total} 成功`];
7703
+ if (skipped > 0) summary.push(`${skipped} skipped`);
7704
+ if (unsupported > 0) summary.push(`${unsupported} unsupported`);
7705
+ if (failed > 0) summary.push(`${failed} failed`);
7706
+ const headerText = `✅ ASPタグ挿入完了 (${summary.join(', ')})${_modeTag}`;
7707
+ const text = `${headerText}\n${lines.join('\n')}${snapshotLine}`;
7708
+ return { content: [{ type: "text", text }] };
7709
+ }
7710
+
5311
7711
  default:
5312
7712
  throw new Error(`Unknown tool: ${name}`);
5313
7713
  } })();