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.
- package/dist/mcp-server.js +2297 -162
- package/dist/wordpress-api.js +181 -4
- package/package.json +7 -4
package/dist/mcp-server.js
CHANGED
|
@@ -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')
|
|
590
|
-
|
|
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
|
-
|
|
596
|
+
throwToolError('TOO_LARGE', `File too large: ${(stat.size / 1048576).toFixed(1)}MB (max: 10MB)`, false);
|
|
594
597
|
}
|
|
595
|
-
const ext =
|
|
598
|
+
const ext = extname(resolved).toLowerCase();
|
|
596
599
|
if (!ALLOWED_IMAGE_EXTENSIONS.has(ext)) {
|
|
597
|
-
|
|
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
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1994
|
-
currentState =
|
|
1995
|
-
}
|
|
1996
|
-
|
|
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}
|
|
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
|
-
|
|
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
|
|
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: "
|
|
2829
|
-
name: { type: "string", description: "
|
|
2830
|
-
alt: { type: "string", description: "
|
|
2831
|
-
title: { type: "string", description: "
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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: ["
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 "
|
|
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("
|
|
6458
|
+
catch (e) { return errorResponse("list_media_folders", e.message, siteName); }
|
|
5315
6459
|
|
|
5316
|
-
if (!
|
|
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
|
-
|
|
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
|
-
|
|
5321
|
-
|
|
5322
|
-
|
|
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
|
|
5328
|
-
if (!
|
|
5329
|
-
|
|
6482
|
+
const folderName = args.name.trim().normalize('NFC');
|
|
6483
|
+
if (!folderName) {
|
|
6484
|
+
throwToolError('VALIDATION_ERROR', 'name は空にできません', false);
|
|
5330
6485
|
}
|
|
5331
|
-
|
|
5332
|
-
|
|
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
|
-
|
|
5337
|
-
|
|
5338
|
-
|
|
5339
|
-
|
|
5340
|
-
|
|
5341
|
-
|
|
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
|
-
|
|
5344
|
-
const
|
|
5345
|
-
|
|
5346
|
-
|
|
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
|
-
|
|
5349
|
-
|
|
5350
|
-
|
|
5351
|
-
|
|
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
|
-
|
|
5355
|
-
|
|
5356
|
-
|
|
5357
|
-
|
|
5358
|
-
|
|
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
|
-
|
|
5362
|
-
|
|
5363
|
-
|
|
5364
|
-
|
|
5365
|
-
|
|
5366
|
-
|
|
5367
|
-
|
|
5368
|
-
|
|
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
|
-
|
|
5381
|
-
|
|
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
|
-
|
|
5385
|
-
|
|
5386
|
-
}
|
|
5387
|
-
|
|
5388
|
-
|
|
5389
|
-
|
|
5390
|
-
|
|
5391
|
-
|
|
5392
|
-
|
|
5393
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5400
|
-
const
|
|
5401
|
-
let
|
|
5402
|
-
|
|
5403
|
-
|
|
5404
|
-
|
|
5405
|
-
|
|
5406
|
-
|
|
5407
|
-
|
|
5408
|
-
|
|
5409
|
-
|
|
5410
|
-
|
|
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 "
|
|
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
|
|
6668
|
+
catch (e) { return jsonToolResponse({ success: false, error: toolError('VALIDATION_ERROR', e.message, false) }, true); }
|
|
5423
6669
|
|
|
5424
|
-
|
|
5425
|
-
|
|
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
|
-
|
|
5428
|
-
|
|
5429
|
-
|
|
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
|
-
|
|
5432
|
-
|
|
5433
|
-
|
|
5434
|
-
|
|
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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
7175
|
+
// ヘルパー: 基本 HTML エンティティの decode(部分一致比較用)
|
|
7176
|
+
const decodeHtml = (s) => String(s)
|
|
7177
|
+
.replace(/</g, '<')
|
|
7178
|
+
.replace(/>/g, '>')
|
|
7179
|
+
.replace(/"/g, '"')
|
|
7180
|
+
.replace(/"/g, '"')
|
|
7181
|
+
.replace(/'/g, "'")
|
|
7182
|
+
.replace(/'/g, "'")
|
|
7183
|
+
.replace(/ /g, ' ')
|
|
7184
|
+
.replace(/&/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
|
} })();
|