friday-mcp-v2 3.1.2 → 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,7 +14,7 @@ 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
19
  import { parse as parseHTML } from "node-html-parser";
20
20
  import os from "node:os";
@@ -586,15 +586,18 @@ function validateImageFile(filePath) {
586
586
  let stat;
587
587
  try { stat = statSync(resolved); }
588
588
  catch (e) {
589
- if (e.code === 'ENOENT') throw new Error(`File not found: ${resolved}`);
590
- 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);
591
594
  }
592
595
  if (stat.size > MAX_IMAGE_SIZE_BYTES) {
593
- 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);
594
597
  }
595
- const ext = resolved.slice(resolved.lastIndexOf('.')).toLowerCase();
598
+ const ext = extname(resolved).toLowerCase();
596
599
  if (!ALLOWED_IMAGE_EXTENSIONS.has(ext)) {
597
- 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);
598
601
  }
599
602
  const contentType = MIME_MAP[ext] || 'application/octet-stream';
600
603
  return { resolved, stat, ext, contentType };
@@ -722,14 +725,775 @@ function generateSearchVariants(query, strip = [], prefixes = []) {
722
725
  return [...variants];
723
726
  }
724
727
 
725
- function flattenFileBirdFolders(folders, result = []) {
726
- for (const f of folders) {
727
- result.push({ id: f.id, name: f.text || f.title || f.name || '', count: parseInt(f['data-count'], 10) || 0 });
728
- if (f.children && f.children.length > 0) {
729
- flattenFileBirdFolders(f.children, result);
728
+ /**
729
+ * フォルダパス文字列の正規化 (T8)
730
+ * trim NFC 全角スラッシュ拒否 先頭末尾スラッシュ strip 連続スラッシュ統合 空セグメント拒否
731
+ * @param {string} input パス文字列
732
+ * @returns {string} 正規化済みパス (ルート相対、先頭スラッシュなし)
733
+ */
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
+ }
752
+
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);
730
788
  }
789
+ return nodes;
731
790
  }
732
- return result;
791
+
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);
808
+
809
+ return { tree, byId, byPath, allNodes };
810
+ }
811
+
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);
830
+ }
831
+ return matches[0].id;
832
+ }
833
+
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);
845
+ }
846
+
847
+ /**
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] を昇順ソート
854
+ */
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
+ }
865
+
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 取得並列度
870
+
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
+ }
891
+ }
892
+ await Promise.all(
893
+ Array.from({ length: Math.min(SEARCH_FETCH_CONCURRENCY, folderIds.length) }, worker)
894
+ );
895
+
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);
910
+ }
911
+ }
912
+ return { unionIds, fetchedFolderCount, failedFolderCount };
913
+ }
914
+
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);
929
+ }
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
+ }
943
+
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);
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
+ }
963
+
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;
973
+ }
974
+ return '';
975
+ }
976
+
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);
1003
+ }
1004
+
1005
+ /**
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 };
733
1497
  }
734
1498
 
735
1499
  /**
@@ -1986,14 +2750,26 @@ function buildBatchDiffInfo(resolvedOps, snapshot) {
1986
2750
  * @param {string} siteName
1987
2751
  * @param {string} _modeTag
1988
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 は付かない。
1989
2761
  */
1990
- async function handleBatchOperations(operations, snapshotId, mode, client, postId, sessionId, siteName, _modeTag, toolName, expectedRevision) {
2762
+ async function handleBatchOperations(operations, snapshotId, mode, client, postId, sessionId, siteName, _modeTag, toolName, expectedRevision, options = {}) {
1991
2763
  // 1. 全 operation の target を正規化 & ref を一括解決
1992
2764
  let currentState;
1993
- try {
1994
- currentState = await getCurrentStructure(mode, client, postId, sessionId, { safetyCritical: true });
1995
- } catch (e) {
1996
- return { content: [{ type: "text", text: `❌ 現在のブロック構造を取得できませんでした: ${e.message}` }], isError: true };
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
+ }
1997
2773
  }
1998
2774
 
1999
2775
  // revision 楽観的ロック(expectedRevision が指定されている場合のみ)
@@ -2064,7 +2840,7 @@ async function handleBatchOperations(operations, snapshotId, mode, client, postI
2064
2840
  for (const op of resolvedOps) {
2065
2841
  if (indexSet.has(op.resolvedIndex)) {
2066
2842
  return {
2067
- 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.` }],
2068
2844
  isError: true,
2069
2845
  };
2070
2846
  }
@@ -2199,7 +2975,21 @@ async function handleBatchOperations(operations, snapshotId, mode, client, postI
2199
2975
  text = appendSnapshotToTextLegacy(text + _modeTag, snapshot);
2200
2976
  }
2201
2977
 
2202
- 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 };
2203
2993
  }
2204
2994
 
2205
2995
  // フィードバック送信ヘルパー
@@ -2820,37 +3610,62 @@ const tools = [
2820
3610
  },
2821
3611
  {
2822
3612
  name: "upload_media",
2823
- 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.",
2824
3614
  inputSchema: {
2825
3615
  type: "object",
2826
3616
  properties: {
2827
3617
  site: siteParam,
2828
- filePath: { type: "string", description: "Absolute path to image file on local machine" },
2829
- name: { type: "string", description: "New filename (without extension, ASCII/hyphens recommended)" },
2830
- alt: { type: "string", description: "Alt text and title (Japanese OK)" },
2831
- 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." },
2832
3646
  },
2833
- required: ["filePath", "name", "alt"],
2834
3647
  },
2835
3648
  },
2836
3649
  {
2837
3650
  name: "search_media",
2838
- 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).",
2839
3652
  inputSchema: {
2840
3653
  type: "object",
2841
3654
  properties: {
2842
3655
  site: siteParam,
2843
- query: { type: "string", description: "Search keyword" },
2844
- folder: { type: "string", description: "FileBird folder name (partial match). Filters media to this folder." },
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." },
2845
3660
  strip: { type: "array", items: { type: "string" }, description: "Suffixes to strip (e.g. ['先生', 'さん'])" },
2846
3661
  prefix: { type: "array", items: { type: "string" }, description: "Prefixes to add (e.g. ['エキサイト-'])" },
2847
- 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 },
2848
3663
  },
2849
3664
  },
2850
3665
  },
2851
3666
  {
2852
3667
  name: "list_media_folders",
2853
- description: "List FileBird media folders. Returns folder names, IDs, and file counts.",
3668
+ description: "List FileBird media folders. Returns structured JSON with nested folders, IDs, paths, and file counts.",
2854
3669
  inputSchema: {
2855
3670
  type: "object",
2856
3671
  properties: {
@@ -2858,6 +3673,90 @@ const tools = [
2858
3673
  },
2859
3674
  },
2860
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." },
3757
+ },
3758
+ },
3759
+ },
2861
3760
  {
2862
3761
  name: "list_connections",
2863
3762
  description: "List connections.",
@@ -2905,7 +3804,7 @@ const tools = [
2905
3804
  },
2906
3805
  {
2907
3806
  name: "register_asp_link",
2908
- 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.",
2909
3808
  inputSchema: {
2910
3809
  type: "object",
2911
3810
  properties: {
@@ -2918,8 +3817,86 @@ const tools = [
2918
3817
  asp_url_sp: { type: "string", description: "ASP URL (B) for A/B testing (optional)" },
2919
3818
  direct_url: { type: "string", description: "Direct URL bypassing ASP (optional)" },
2920
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
+ },
2921
3898
  },
2922
- required: ["title", "slug", "asp_url"],
3899
+ required: ["postId", "snapshotId", "assignments"],
2923
3900
  },
2924
3901
  },
2925
3902
  ];
@@ -3032,10 +4009,10 @@ async function handleUpdateBlocksTool(args, toolName) {
3032
4009
  const _inputRef = (snapshotId && args?.target?.ref) ? args.target.ref : null;
3033
4010
  const _isRefInsert = !!(insertOnly && _inputRef);
3034
4011
 
3035
- // [INSERT_OBSERVE] Step 5: insert_block 経由時の送信パラメータログ
3036
- if (process.env.FRIDAY_DEBUG === '1' && _fromInsertBlock) {
3037
- console.log(`[INSERT_OBSERVE] mcp-send: index=${index}, postId=${postId}, sessionId=${_sessionId || 'none'}, mode=${mode}, appendToEnd=${appendToEnd}, htmlLen=${(newHTML || '').length}`);
3038
- }
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
+ }
3039
4016
 
3040
4017
  // --- filePath → newHTML 解決(update_blocks 直接呼び出し時) ---
3041
4018
  if (args?.filePath) {
@@ -3319,7 +4296,7 @@ async function handleUpdateBlocksTool(args, toolName) {
3319
4296
  }
3320
4297
 
3321
4298
  // ツール実行のハンドラ
3322
- const _WRITE_TOOLS = new Set(['update_blocks', 'delete_block', 'move_block', 'duplicate_block', 'insert_block', 'table_operations', 'update_blog_parts', 'insert_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']);
3323
4300
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
3324
4301
  const { name, arguments: args } = request.params;
3325
4302
  const _toolLogStart = Date.now();
@@ -5268,172 +6245,620 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
5268
6245
  const siteName = args?.site || 'default';
5269
6246
  let client;
5270
6247
  try { client = registry.get(siteName); }
5271
- catch (e) { return errorResponse("upload_media", e.message, siteName); }
5272
-
5273
- if (!args?.filePath) return errorResponse("upload_media", "filePath is required", siteName);
5274
- if (!args?.name) return errorResponse("upload_media", "name is required", siteName);
5275
- if (!args?.alt) return errorResponse("upload_media", "alt is required", siteName);
5276
-
5277
- let fileInfo;
5278
- try { fileInfo = validateImageFile(args.filePath); }
5279
- catch (e) { return errorResponse("upload_media", e.message, siteName); }
6248
+ catch (e) { return jsonToolResponse({ success: false, error: toolError('VALIDATION_ERROR', e.message, false) }, true); }
5280
6249
 
5281
- const uploadFilename = `${args.name}${fileInfo.ext}`;
5282
- const fileBuffer = readFileSync(fileInfo.resolved);
5283
-
5284
- let media;
5285
6250
  try {
5286
- media = await client.uploadMedia(fileBuffer, uploadFilename, fileInfo.contentType);
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);
5287
6266
  } catch (e) {
5288
- return errorResponse("upload_media", `Upload failed: ${e.message}`, siteName);
6267
+ return jsonErrorResponse(e);
5289
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'); }
5290
6276
 
5291
- const titleText = args?.title || args.alt;
5292
6277
  try {
5293
- await client.updateMediaMeta(media.id, { title: titleText, alt_text: args.alt });
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
+ });
5294
6449
  } catch (e) {
5295
- // meta 更新失敗は警告のみ(アップロード自体は成功)
5296
- }
5297
-
5298
- const url = media.source_url || '';
5299
- const width = media.media_details?.width || null;
5300
- const height = media.media_details?.height || null;
5301
- const text = `✅ Uploaded: ID=${media.id}\n` +
5302
- ` Title: ${titleText}\n` +
5303
- ` Alt: ${args.alt}\n` +
5304
- ` URL: ${url}\n` +
5305
- (width && height ? ` Size: ${width}x${height}\n` : '') +
5306
- ` Filename: ${uploadFilename}`;
5307
- return { content: [{ type: "text", text }] };
6450
+ return jsonErrorResponse(e);
6451
+ }
5308
6452
  }
5309
6453
 
5310
- case "search_media": {
6454
+ case "list_media_folders": {
5311
6455
  const siteName = args?.site || 'default';
5312
6456
  let client;
5313
6457
  try { client = registry.get(siteName); }
5314
- catch (e) { return errorResponse("search_media", e.message, siteName); }
6458
+ catch (e) { return errorResponse("list_media_folders", e.message, siteName); }
5315
6459
 
5316
- if (!args?.query && !args?.folder) return errorResponse("search_media", "query または folder のどちらかは必須です", siteName);
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
+ }
5317
6471
 
5318
- const perPage = args?.per_page || 10;
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); }
5319
6477
 
5320
- // --- FileBird folder filtering ---
5321
- let folderIncludeIds = null;
5322
- let folderInfo = '';
5323
- if (args?.folder) {
5324
- if (!client.filebirdToken) {
5325
- return errorResponse("search_media", "FileBird APIキーが未設定です。connections.json に filebirdToken を追加してください", siteName);
6478
+ try {
6479
+ if (typeof args?.name !== 'string') {
6480
+ throwToolError('VALIDATION_ERROR', 'name は必須の文字列です', false);
5326
6481
  }
5327
- const folders = await client.getFileBirdFolders();
5328
- if (!folders) {
5329
- return errorResponse("search_media", "FileBird API へのアクセスに失敗しました", siteName);
6482
+ const folderName = args.name.trim().normalize('NFC');
6483
+ if (!folderName) {
6484
+ throwToolError('VALIDATION_ERROR', 'name は空にできません', false);
5330
6485
  }
5331
- const flat = flattenFileBirdFolders(folders);
5332
- const matched = flat.filter(f => f.name.includes(args.folder));
5333
- if (matched.length === 0) {
5334
- return errorResponse("search_media", `フォルダ "${args.folder}" が見つかりません。\n利用可能: ${flat.map(f => f.name).join(', ')}`, siteName);
6486
+ if (folderName.includes('/') || folderName.includes('/')) {
6487
+ throwToolError('VALIDATION_ERROR', 'name にスラッシュは使用できません。親フォルダは parent または parentId で指定してください', false);
5335
6488
  }
5336
- if (matched.length > 1) {
5337
- let text = `📁 "${args.folder}" に複数のフォルダがマッチしました。絞り込んでください:\n\n`;
5338
- for (const f of matched) {
5339
- text += ` [ID: ${f.id}] ${f.name} (${f.count}件)\n`;
5340
- }
5341
- return { content: [{ type: "text", text }] };
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);
5342
6513
  }
5343
- const folder = matched[0];
5344
- const attachmentIds = await client.getFileBirdAttachmentIds(folder.id);
5345
- if (!attachmentIds || attachmentIds.length === 0) {
5346
- return { content: [{ type: "text", text: `📁 ${folder.name} (0件)\n\n❌ フォルダ内にメディアがありません。` }] };
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);
5347
6534
  }
5348
- folderIncludeIds = attachmentIds;
5349
- const totalCount = attachmentIds.length;
5350
- const limited = totalCount > 100;
5351
- folderInfo = `📁 Folder: ${folder.name} (${totalCount}件${limited ? '、先頭100件から検索' : ''})\n`;
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);
5352
6549
  }
6550
+ }
5353
6551
 
5354
- // --- keyword search (with or without folder filter) ---
5355
- if (args?.query) {
5356
- const variants = generateSearchVariants(args.query, args?.strip, args?.prefix);
5357
- const results = await Promise.allSettled(
5358
- variants.map(v => client.searchMedia(v, perPage, folderIncludeIds))
5359
- );
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); }
5360
6557
 
5361
- const seenIds = new Set();
5362
- const hits = [];
5363
- for (let i = 0; i < results.length; i++) {
5364
- if (results[i].status !== 'fulfilled') continue;
5365
- for (const item of results[i].value) {
5366
- if (seenIds.has(item.id)) continue;
5367
- seenIds.add(item.id);
5368
- hits.push({
5369
- id: item.id,
5370
- title: item.title?.rendered || '',
5371
- alt: item.alt_text || '',
5372
- url: item.source_url || '',
5373
- width: item.media_details?.width || null,
5374
- height: item.media_details?.height || null,
5375
- matched: variants[i],
5376
- });
5377
- }
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);
5378
6566
  }
6567
+ if (mediaIds.length > 100) {
6568
+ throwToolError('VALIDATION_ERROR', 'mediaIds は1コール最大100件までです', false);
6569
+ }
6570
+ for (const id of mediaIds) assertPositiveInteger(id, 'mediaId');
5379
6571
 
5380
- let text = folderInfo;
5381
- text += `🔍 Search: "${args.query}"\n`;
5382
- text += `🔄 Variants (${variants.length}): ${variants.join(', ')}\n\n`;
6572
+ const normalized = await getNormalizedFileBirdFolders(client);
6573
+ const target = resolveFolderTarget(normalized, { folder: args?.folder, folderId: args?.folderId });
5383
6574
 
5384
- if (hits.length === 0) {
5385
- text += '❌ No results found.';
5386
- } else {
5387
- text += `📷 ${hits.length} result(s):\n\n`;
5388
- for (const h of hits) {
5389
- text += ` [ID: ${h.id}] ${h.title}\n`;
5390
- text += ` Alt: ${h.alt}\n`;
5391
- text += ` URL: ${h.url}\n`;
5392
- if (h.width && h.height) text += ` Size: ${h.width}x${h.height}\n`;
5393
- text += ` Matched: "${h.matched}"\n\n`;
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);
5394
6590
  }
6591
+ throw validationError;
5395
6592
  }
5396
- return { content: [{ type: "text", text }] };
6593
+
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
+ });
6617
+ } catch (e) {
6618
+ return jsonErrorResponse(e);
5397
6619
  }
6620
+ }
5398
6621
 
5399
- // --- folder only (no query) ---
5400
- const mediaItems = await client.searchMedia(null, perPage, folderIncludeIds);
5401
- let text = folderInfo + '\n';
5402
- if (!mediaItems || mediaItems.length === 0) {
5403
- text += ' No results found.';
5404
- } else {
5405
- text += `📷 ${mediaItems.length} result(s):\n\n`;
5406
- for (const item of mediaItems) {
5407
- text += ` [ID: ${item.id}] ${item.title?.rendered || ''}\n`;
5408
- text += ` Alt: ${item.alt_text || ''}\n`;
5409
- text += ` URL: ${item.source_url || ''}\n`;
5410
- const w = item.media_details?.width, h = item.media_details?.height;
5411
- if (w && h) text += ` Size: ${w}x${h}\n`;
5412
- text += '\n';
6622
+ case "update_media_meta": {
6623
+ const siteName = args?.site || 'default';
6624
+ let client;
6625
+ try { client = registry.get(siteName); }
6626
+ catch (e) { return jsonToolResponse({ success: false, error: toolError('VALIDATION_ERROR', e.message, false) }, true); }
6627
+
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);
5413
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
+ }
6644
+
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);
5414
6661
  }
5415
- return { content: [{ type: "text", text }] };
5416
6662
  }
5417
6663
 
5418
- case "list_media_folders": {
6664
+ case "get_media": {
5419
6665
  const siteName = args?.site || 'default';
5420
6666
  let client;
5421
6667
  try { client = registry.get(siteName); }
5422
- catch (e) { return errorResponse("list_media_folders", e.message, siteName); }
6668
+ catch (e) { return jsonToolResponse({ success: false, error: toolError('VALIDATION_ERROR', e.message, false) }, true); }
5423
6669
 
5424
- if (!client.filebirdToken) {
5425
- return { content: [{ type: "text", text: "⚠️ FileBird 未導入または APIキーが未設定です。\nconnections.json に filebirdToken を追加してください。" }] };
6670
+ try {
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);
6689
+ } catch (e) {
6690
+ return jsonErrorResponse(e);
5426
6691
  }
5427
- const folders = await client.getFileBirdFolders();
5428
- if (!folders) {
5429
- return errorResponse("list_media_folders", "FileBird API へのアクセスに失敗しました", siteName);
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); }
6699
+
6700
+ try {
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
+ });
6765
+ } catch (e) {
6766
+ return jsonErrorResponse(e);
5430
6767
  }
5431
- const flat = flattenFileBirdFolders(folders);
5432
- let text = `📁 FileBird フォルダ一覧 (${flat.length}件)\n\n`;
5433
- for (const f of flat) {
5434
- text += ` [ID: ${f.id}] ${f.name} (${f.count}件)\n`;
6768
+ }
6769
+
6770
+ case "delete_media_folder": {
6771
+ const siteName = args?.site || 'default';
6772
+ let client;
6773
+ try { client = registry.get(siteName); }
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 */ }
6788
+ }
6789
+ // display は subtree 込みのため root の count をそのまま採用(合算しない)
6790
+ return { attachmentCount: rootNode?.count ?? 0, approximate: true, failedFolderCount: 0 };
6791
+ };
6792
+
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
+ }
6799
+
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
+ });
6827
+ }
6828
+
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);
5435
6861
  }
5436
- return { content: [{ type: "text", text }] };
5437
6862
  }
5438
6863
 
5439
6864
  case "list_connections": {
@@ -5536,6 +6961,66 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
5536
6961
  return { content: [{ type: "text", text: `❌ ${e.message}` }], isError: true };
5537
6962
  }
5538
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
+
5539
7024
  if (!args?.title || !args?.slug || !args?.asp_url) {
5540
7025
  return { content: [{ type: "text", text: "❌ title, slug, asp_url は必須です。" }], isError: true };
5541
7026
  }
@@ -5573,6 +7058,656 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
5573
7058
  return { content: [{ type: "text", text: lines.join('\n') }] };
5574
7059
  }
5575
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}`,
7101
+ ` 案件ID: ${result.data_id}`,
7102
+ ];
7103
+ if (result.asp_url) lines.push(` ASP URL: ${result.asp_url}`);
7104
+ if (result.asp_url_sp) lines.push(` ASP URL(B): ${result.asp_url_sp}`);
7105
+ if (result.direct_url) lines.push(` 直リンク: ${result.direct_url}`);
7106
+ if (result.warning) lines.push(` 警告: ${result.warning}`);
7107
+ lines.push(` tag: ${result.tag}`);
7108
+
7109
+ return { content: [{ type: "text", text: lines.join('\n') }] };
7110
+ }
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
+
5576
7711
  default:
5577
7712
  throw new Error(`Unknown tool: ${name}`);
5578
7713
  } })();