friday-mcp-v2 3.0.0 → 3.0.2
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 +1788 -100
- package/package.json +1 -1
package/dist/mcp-server.js
CHANGED
|
@@ -11,6 +11,7 @@ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextpro
|
|
|
11
11
|
import { FridayWPClient, ConnectionRegistry } from "./wordpress-api.js";
|
|
12
12
|
import { BridgeClient } from "./bridge-client.js";
|
|
13
13
|
import fetch from "node-fetch";
|
|
14
|
+
import { randomUUID } from "node:crypto";
|
|
14
15
|
import { readFileSync, statSync, realpathSync } from "node:fs";
|
|
15
16
|
import { execFile } from "node:child_process";
|
|
16
17
|
import { resolve as resolvePath, sep, dirname } from "node:path";
|
|
@@ -102,6 +103,207 @@ const statusCache = {
|
|
|
102
103
|
}
|
|
103
104
|
};
|
|
104
105
|
|
|
106
|
+
// ========================================
|
|
107
|
+
// Snapshot キャッシュ(Phase 1: インデックスズレ対処)
|
|
108
|
+
// ========================================
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* djb2 ハッシュ → base36 文字列
|
|
112
|
+
* @param {string} str
|
|
113
|
+
* @returns {string}
|
|
114
|
+
*/
|
|
115
|
+
function simpleHash(str) {
|
|
116
|
+
let hash = 5381;
|
|
117
|
+
for (let i = 0; i < str.length; i++) {
|
|
118
|
+
hash = ((hash << 5) + hash) + str.charCodeAt(i);
|
|
119
|
+
hash = hash & hash;
|
|
120
|
+
}
|
|
121
|
+
return (hash >>> 0).toString(36);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function generateSnapshotId() {
|
|
125
|
+
return 'snap_' + randomUUID().replace(/-/g, '').slice(0, 12);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function generateBlockRef(index) {
|
|
129
|
+
return 'r' + index;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* ブロックの fingerprint を生成
|
|
134
|
+
* @param {object} block - state.allBlocks の要素
|
|
135
|
+
* @param {string} mode - "editor" | "headless"
|
|
136
|
+
* @returns {string}
|
|
137
|
+
*/
|
|
138
|
+
function computeFingerprint(block, mode) {
|
|
139
|
+
const type = block.type || 'unknown';
|
|
140
|
+
let contentSource;
|
|
141
|
+
if (mode === 'headless' && block.html) {
|
|
142
|
+
contentSource = block.html.slice(0, 200);
|
|
143
|
+
} else if (block.attributes) {
|
|
144
|
+
contentSource = JSON.stringify(block.attributes).slice(0, 200);
|
|
145
|
+
} else {
|
|
146
|
+
contentSource = '';
|
|
147
|
+
}
|
|
148
|
+
return type + ':' + simpleHash(contentSource);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* revision を計算(内容ベースハッシュ)
|
|
153
|
+
* serialize_blocks 相当の粒度で内容変更を検知する
|
|
154
|
+
* @param {{ allBlocks: Array }} state
|
|
155
|
+
* @param {string} mode
|
|
156
|
+
* @returns {string}
|
|
157
|
+
*/
|
|
158
|
+
function computeRevision(state, mode) {
|
|
159
|
+
const source = state.allBlocks.map(b => {
|
|
160
|
+
const content = (mode === 'headless' && b.html)
|
|
161
|
+
? b.html.slice(0, 200)
|
|
162
|
+
: JSON.stringify(b.attributes || {}).slice(0, 200);
|
|
163
|
+
return `${b.type}:${b.index}:${content}`;
|
|
164
|
+
}).join('|');
|
|
165
|
+
return 'rev_' + simpleHash(source);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* 既に取得済みの currentState から snapshot を組み立て、キャッシュに登録して返す。
|
|
170
|
+
* buildResponseSnapshot() の「state を受け取る版」— getCurrentStructure() を呼ばない。
|
|
171
|
+
*/
|
|
172
|
+
function buildSnapshotFromState(currentState, mode, postId, sessionId, siteName) {
|
|
173
|
+
if (!currentState?.allBlocks || currentState.allBlocks.length === 0) return null;
|
|
174
|
+
|
|
175
|
+
const newSnapshotId = generateSnapshotId();
|
|
176
|
+
const newSnapshotBlocks = buildSnapshotBlocks(currentState, mode);
|
|
177
|
+
const newRevision = computeRevision(currentState, mode);
|
|
178
|
+
|
|
179
|
+
const snapshotRecord = {
|
|
180
|
+
snapshotId: newSnapshotId, postId, mode,
|
|
181
|
+
sessionId: mode === 'editor' ? sessionId : null,
|
|
182
|
+
siteName: siteName || 'default',
|
|
183
|
+
createdAt: Date.now(),
|
|
184
|
+
revision: newRevision,
|
|
185
|
+
blocks: newSnapshotBlocks,
|
|
186
|
+
displayMode: 'full',
|
|
187
|
+
};
|
|
188
|
+
snapshotCache.set(snapshotRecord);
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
snapshotId: newSnapshotId,
|
|
192
|
+
revision: newRevision,
|
|
193
|
+
blocks: newSnapshotBlocks.map(b => ({
|
|
194
|
+
ref: b.ref, index: b.index, type: b.type,
|
|
195
|
+
depth: b.depth, parentIndex: b.parentIndex,
|
|
196
|
+
})),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* expectedRevision と現在の revision を比較する。
|
|
202
|
+
* 一致すれば null を返す。不一致の場合は最新 snapshot 付きのエラーレスポンスを返す。
|
|
203
|
+
* currentState から直接 snapshot を組み立てるため、追加の通信コストなし。
|
|
204
|
+
* @returns {object|null} null=一致(続行可)、object=不一致エラーレスポンス
|
|
205
|
+
*/
|
|
206
|
+
function checkRevisionMismatch(expectedRevision, currentState, mode, postId, sessionId, siteName) {
|
|
207
|
+
const currentRevision = computeRevision(currentState, mode);
|
|
208
|
+
if (expectedRevision === currentRevision) return null;
|
|
209
|
+
|
|
210
|
+
// 不一致 — snapshot をキャッシュに登録(AI が新 snapshotId で refetch 可能に)
|
|
211
|
+
// Phase 2: blocks 一覧は出力しない(コンテキスト軽量化)
|
|
212
|
+
const latestSnapshot = buildSnapshotFromState(currentState, mode, postId, sessionId, siteName);
|
|
213
|
+
const snapshotLine = latestSnapshot
|
|
214
|
+
? `\n[snapshot:${latestSnapshot.snapshotId} rev:${latestSnapshot.revision}]`
|
|
215
|
+
: '';
|
|
216
|
+
const text = `❌ REVISION_MISMATCH: expected ${expectedRevision}, actual ${currentRevision}` +
|
|
217
|
+
snapshotLine +
|
|
218
|
+
`\n→ get_article_structure で最新を取得してください`;
|
|
219
|
+
return { content: [{ type: "text", text }], isError: true };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* state.allBlocks から SnapshotBlock[] を構築
|
|
224
|
+
* @param {{ allBlocks: Array }} state
|
|
225
|
+
* @param {string} mode
|
|
226
|
+
* @returns {Array}
|
|
227
|
+
*/
|
|
228
|
+
function buildSnapshotBlocks(state, mode) {
|
|
229
|
+
return state.allBlocks.map(b => ({
|
|
230
|
+
ref: generateBlockRef(b.index),
|
|
231
|
+
index: b.index,
|
|
232
|
+
type: b.type,
|
|
233
|
+
depth: b.depth || 0,
|
|
234
|
+
parentIndex: b.parentIndex ?? null,
|
|
235
|
+
fingerprint: computeFingerprint(b, mode),
|
|
236
|
+
section: b.section || null,
|
|
237
|
+
}));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const snapshotCache = {
|
|
241
|
+
/** @type {Map<string, object>} snapshotId → SnapshotRecord */
|
|
242
|
+
_entries: new Map(),
|
|
243
|
+
/** @type {Map<string, string[]>} "postId:siteName" → snapshotId[] (FIFO) */
|
|
244
|
+
_byArticle: new Map(),
|
|
245
|
+
TTL: 300_000,
|
|
246
|
+
MAX_PER_ARTICLE: 3,
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* snapshotId で取得(TTL チェック付き)
|
|
250
|
+
* @param {string} snapshotId
|
|
251
|
+
* @returns {object|null}
|
|
252
|
+
*/
|
|
253
|
+
get(snapshotId) {
|
|
254
|
+
const entry = this._entries.get(snapshotId);
|
|
255
|
+
if (!entry) return null;
|
|
256
|
+
if ((Date.now() - entry.createdAt) >= this.TTL) {
|
|
257
|
+
this._remove(snapshotId);
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
return entry;
|
|
261
|
+
},
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* snapshot を登録(記事単位 eviction 付き)
|
|
265
|
+
* @param {object} record - SnapshotRecord
|
|
266
|
+
*/
|
|
267
|
+
set(record) {
|
|
268
|
+
const articleKey = `${record.postId}:${record.siteName}`;
|
|
269
|
+
let articleSnaps = this._byArticle.get(articleKey) || [];
|
|
270
|
+
articleSnaps.push(record.snapshotId);
|
|
271
|
+
while (articleSnaps.length > this.MAX_PER_ARTICLE) {
|
|
272
|
+
const oldest = articleSnaps.shift();
|
|
273
|
+
this._entries.delete(oldest);
|
|
274
|
+
}
|
|
275
|
+
this._byArticle.set(articleKey, articleSnaps);
|
|
276
|
+
this._entries.set(record.snapshotId, record);
|
|
277
|
+
},
|
|
278
|
+
|
|
279
|
+
/** @param {string} snapshotId */
|
|
280
|
+
_remove(snapshotId) {
|
|
281
|
+
const entry = this._entries.get(snapshotId);
|
|
282
|
+
if (entry) {
|
|
283
|
+
const articleKey = `${entry.postId}:${entry.siteName}`;
|
|
284
|
+
const articleSnaps = this._byArticle.get(articleKey);
|
|
285
|
+
if (articleSnaps) {
|
|
286
|
+
const idx = articleSnaps.indexOf(snapshotId);
|
|
287
|
+
if (idx >= 0) articleSnaps.splice(idx, 1);
|
|
288
|
+
if (articleSnaps.length === 0) this._byArticle.delete(articleKey);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
this._entries.delete(snapshotId);
|
|
292
|
+
},
|
|
293
|
+
|
|
294
|
+
/** @param {string} [siteName] 指定時はそのサイトのみ。省略時は全破棄。 */
|
|
295
|
+
clear(siteName) {
|
|
296
|
+
if (siteName) {
|
|
297
|
+
for (const [id, record] of this._entries) {
|
|
298
|
+
if (record.siteName === siteName) this._remove(id);
|
|
299
|
+
}
|
|
300
|
+
} else {
|
|
301
|
+
this._entries.clear();
|
|
302
|
+
this._byArticle.clear();
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
|
|
105
307
|
// ヘルパー関数: 正規表現用に文字列をエスケープ
|
|
106
308
|
function escapeRegExp(string) {
|
|
107
309
|
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
@@ -536,8 +738,17 @@ const targetSchema = {
|
|
|
536
738
|
required: ["level", "contains"],
|
|
537
739
|
description: "Select from heading to next same-level heading.",
|
|
538
740
|
},
|
|
741
|
+
ref: {
|
|
742
|
+
type: "string",
|
|
743
|
+
description: "Block ref from snapshot (e.g. 'r5'). Requires snapshotId.",
|
|
744
|
+
},
|
|
745
|
+
refs: {
|
|
746
|
+
type: "array",
|
|
747
|
+
items: { type: "string" },
|
|
748
|
+
description: "Multiple block refs from snapshot. Requires snapshotId.",
|
|
749
|
+
},
|
|
539
750
|
},
|
|
540
|
-
description: "Target: one of selected/index/indices/range/heading + optional filters.",
|
|
751
|
+
description: "Target: one of selected/index/indices/range/heading/ref/refs + optional filters.",
|
|
541
752
|
};
|
|
542
753
|
|
|
543
754
|
const targetSchemaNoSelected = {
|
|
@@ -574,12 +785,16 @@ function normalizeTarget(target) {
|
|
|
574
785
|
target.indices && 'indices',
|
|
575
786
|
target.range && 'range',
|
|
576
787
|
target.heading && 'heading',
|
|
788
|
+
target.ref && 'ref',
|
|
789
|
+
target.refs && 'refs',
|
|
577
790
|
].filter(Boolean);
|
|
578
791
|
if (primaries.length > 1) {
|
|
579
792
|
throw new Error(`target に複数の primary selector が指定されています: ${primaries.join(', ')}。1つだけ指定してください。`);
|
|
580
793
|
}
|
|
581
794
|
|
|
582
795
|
const result = {};
|
|
796
|
+
if (target.ref) result._ref = target.ref;
|
|
797
|
+
if (target.refs) result._refs = target.refs;
|
|
583
798
|
if (target.selected) result.target = "selected";
|
|
584
799
|
if (target.index !== undefined) result.index = target.index;
|
|
585
800
|
if (target.indices) result.indices = target.indices;
|
|
@@ -628,6 +843,1051 @@ function normalizeInsert(insert) {
|
|
|
628
843
|
return { insertOnly: true, insertPosition: pos };
|
|
629
844
|
}
|
|
630
845
|
|
|
846
|
+
// ========================================
|
|
847
|
+
// ref 解決ヘルパー(Phase 2a)
|
|
848
|
+
// ========================================
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* 現在のブロック構造を取得する(ref 解決・レスポンス snapshot 用)
|
|
852
|
+
* @param {string} mode - "editor" | "headless"
|
|
853
|
+
* @param {object} client - WordPressAPI インスタンス
|
|
854
|
+
* @param {number} postId
|
|
855
|
+
* @param {string|null} sessionId
|
|
856
|
+
* @returns {Promise<object>} { allBlocks: Block[] }
|
|
857
|
+
*/
|
|
858
|
+
async function getCurrentStructure(mode, client, postId, sessionId, { safetyCritical = false } = {}) {
|
|
859
|
+
if (mode === 'editor') {
|
|
860
|
+
// Phase 3.1: editor からライブ状態を即時取得(session store キャッシュをバイパス)
|
|
861
|
+
try {
|
|
862
|
+
const result = await client.sendEditorCommand(
|
|
863
|
+
'get_fresh_state', {}, 10000, postId, sessionId
|
|
864
|
+
);
|
|
865
|
+
if (result?.success && result.allBlocks) {
|
|
866
|
+
return result;
|
|
867
|
+
}
|
|
868
|
+
} catch (_e) {
|
|
869
|
+
// get_fresh_state 失敗
|
|
870
|
+
}
|
|
871
|
+
// safety-critical 経路: stale キャッシュにフォールバックしない
|
|
872
|
+
if (safetyCritical) {
|
|
873
|
+
throw new Error('editor のライブ状態を取得できませんでした。拡張機能の接続を確認してください。');
|
|
874
|
+
}
|
|
875
|
+
// 非 safety-critical: 従来キャッシュにフォールバック
|
|
876
|
+
return await client.getEditorState(postId, sessionId);
|
|
877
|
+
} else {
|
|
878
|
+
const structure = await client.headlessGetStructure(postId);
|
|
879
|
+
const blocksData = await client.headlessGetBlocks(postId);
|
|
880
|
+
structure.allBlocks = blocksData.blocks;
|
|
881
|
+
return structure;
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* snapshot の ref を現在構造上の index に解決する
|
|
887
|
+
* @param {string} snapshotId
|
|
888
|
+
* @param {string} ref
|
|
889
|
+
* @param {string} mode - 現在の mode
|
|
890
|
+
* @param {string|null} sessionId - 現在の sessionId
|
|
891
|
+
* @param {number} postId
|
|
892
|
+
* @param {object} currentState - getCurrentStructure() の結果(キャッシュ用)
|
|
893
|
+
* @returns {number} 解決された現在の index
|
|
894
|
+
* @throws {Error} 解決不能な場合
|
|
895
|
+
*/
|
|
896
|
+
function resolveRefFromState(snapshotId, ref, mode, sessionId, postId, currentState) {
|
|
897
|
+
// 1. snapshot 取得
|
|
898
|
+
const snap = snapshotCache.get(snapshotId);
|
|
899
|
+
if (!snap) {
|
|
900
|
+
throw new Error(`snapshot "${snapshotId}" が見つかりません(期限切れまたは無効)。get_article_structure を再取得してください。`);
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
// 2. 状態ソース束縛チェック
|
|
904
|
+
if (snap.mode !== mode) {
|
|
905
|
+
throw new Error(`snapshot は ${snap.mode} モードで取得されましたが、現在は ${mode} モードです。get_article_structure を再取得してください。`);
|
|
906
|
+
}
|
|
907
|
+
if (mode === 'editor' && snap.sessionId && sessionId && snap.sessionId !== sessionId) {
|
|
908
|
+
throw new Error(`snapshot は sessionId "${snap.sessionId}" で取得されましたが、現在の sessionId は "${sessionId}" です。get_article_structure を再取得してください。`);
|
|
909
|
+
}
|
|
910
|
+
if (snap.postId !== postId) {
|
|
911
|
+
throw new Error(`snapshot は postId ${snap.postId} 用ですが、postId ${postId} に対して使用されています。`);
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
// 3. snapshot 内の ref 情報を取得
|
|
915
|
+
const snapBlock = snap.blocks.find(b => b.ref === ref);
|
|
916
|
+
if (!snapBlock) {
|
|
917
|
+
throw new Error(`ref "${ref}" は snapshot "${snapshotId}" 内に存在しません。`);
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
// 4. 現在構造の検証
|
|
921
|
+
const currentBlocks = currentState?.allBlocks;
|
|
922
|
+
if (!currentBlocks || currentBlocks.length === 0) {
|
|
923
|
+
throw new Error('現在のブロック構造を取得できませんでした。');
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
// 5. fingerprint 照合(2段、曖昧ならエラー)
|
|
927
|
+
const candidates = [];
|
|
928
|
+
for (const cb of currentBlocks) {
|
|
929
|
+
const fp = computeFingerprint(cb, mode);
|
|
930
|
+
if (fp === snapBlock.fingerprint) {
|
|
931
|
+
candidates.push(cb);
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// 5a. 完全一致 1件 → 確定
|
|
936
|
+
if (candidates.length === 1) return candidates[0].index;
|
|
937
|
+
|
|
938
|
+
// 5b. 完全一致 複数件 → depth + parentIndex で絞り込み
|
|
939
|
+
if (candidates.length > 1) {
|
|
940
|
+
const refined = candidates.filter(c =>
|
|
941
|
+
(c.depth || 0) === snapBlock.depth &&
|
|
942
|
+
(c.parentIndex ?? null) === snapBlock.parentIndex
|
|
943
|
+
);
|
|
944
|
+
if (refined.length === 1) return refined[0].index;
|
|
945
|
+
throw new Error(
|
|
946
|
+
`ref "${ref}" (type: ${snapBlock.type}) の解決先が ${refined.length > 0 ? refined.length : candidates.length} 件見つかりました。` +
|
|
947
|
+
`一意に特定できません。get_article_structure を再取得してください。`
|
|
948
|
+
);
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
// 5c. 一致 0件 → エラー
|
|
952
|
+
throw new Error(
|
|
953
|
+
`ref "${ref}" (${snapBlock.type}, fingerprint: ${snapBlock.fingerprint}) に一致するブロックが見つかりません。` +
|
|
954
|
+
`構造が大幅に変更された可能性があります。get_article_structure を再取得してください。`
|
|
955
|
+
);
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
/**
|
|
959
|
+
* safety-critical な fresh state 取得 + expectedRevision チェック。
|
|
960
|
+
* state 取得と revision 判定の入口を統一し、分岐ごとのズレを防ぐ。
|
|
961
|
+
* @returns {{ currentState, error? }}
|
|
962
|
+
*/
|
|
963
|
+
async function acquireFreshState({
|
|
964
|
+
expectedRevision, mode, client, postId, sessionId, siteName,
|
|
965
|
+
}) {
|
|
966
|
+
let currentState;
|
|
967
|
+
try {
|
|
968
|
+
currentState = await getCurrentStructure(mode, client, postId, sessionId, { safetyCritical: true });
|
|
969
|
+
} catch (e) {
|
|
970
|
+
return { error: {
|
|
971
|
+
content: [{ type: "text", text: `❌ 構造取得に失敗しました: ${e.message}${expectedRevision ? '\nexpectedRevision が指定されているため、更新を中止します。' : ''}` }],
|
|
972
|
+
isError: true,
|
|
973
|
+
}};
|
|
974
|
+
}
|
|
975
|
+
if (expectedRevision) {
|
|
976
|
+
const mismatch = checkRevisionMismatch(expectedRevision, currentState, mode, postId, sessionId, siteName);
|
|
977
|
+
if (mismatch) return { error: mismatch };
|
|
978
|
+
}
|
|
979
|
+
return { currentState };
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
/**
|
|
983
|
+
* ref/refs → index/indices 解決 + expectedRevision チェックの共通処理。
|
|
984
|
+
* delete_block / duplicate_block / update_blocks / table_operations で共通利用する。
|
|
985
|
+
* @returns {{ index?, indices?, error? }}
|
|
986
|
+
* - 成功: { index } or { indices } or {}(ref なしで revision OK の場合)
|
|
987
|
+
* - 失敗: { error: MCP エラーレスポンス }(呼び出し元は return error で即返却)
|
|
988
|
+
*/
|
|
989
|
+
async function resolveRefsAndCheckRevision({
|
|
990
|
+
snapshotId, ref, refs, expectedRevision,
|
|
991
|
+
mode, client, postId, sessionId, siteName,
|
|
992
|
+
}) {
|
|
993
|
+
const hasRef = ref || refs;
|
|
994
|
+
|
|
995
|
+
// Case 1: ref/refs 指定あり
|
|
996
|
+
if (hasRef) {
|
|
997
|
+
if (!snapshotId) {
|
|
998
|
+
return { error: {
|
|
999
|
+
content: [{ type: "text", text: "❌ ref/refs を使用するには snapshotId が必要です。get_article_structure で取得してください。" }],
|
|
1000
|
+
isError: true,
|
|
1001
|
+
}};
|
|
1002
|
+
}
|
|
1003
|
+
const { currentState, error } = await acquireFreshState({
|
|
1004
|
+
expectedRevision, mode, client, postId, sessionId, siteName,
|
|
1005
|
+
});
|
|
1006
|
+
if (error) return { error };
|
|
1007
|
+
try {
|
|
1008
|
+
if (ref) {
|
|
1009
|
+
const index = resolveRefFromState(snapshotId, ref, mode, sessionId, postId, currentState);
|
|
1010
|
+
return { index, currentState };
|
|
1011
|
+
}
|
|
1012
|
+
if (refs) {
|
|
1013
|
+
const indices = refs.map(r => resolveRefFromState(snapshotId, r, mode, sessionId, postId, currentState));
|
|
1014
|
+
return { indices, currentState };
|
|
1015
|
+
}
|
|
1016
|
+
} catch (e) {
|
|
1017
|
+
return { error: {
|
|
1018
|
+
content: [{ type: "text", text: `❌ ref 解決エラー: ${e.message}` }],
|
|
1019
|
+
isError: true,
|
|
1020
|
+
}};
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// Case 2: ref なし + expectedRevision のみ
|
|
1025
|
+
if (expectedRevision) {
|
|
1026
|
+
const { currentState, error } = await acquireFreshState({
|
|
1027
|
+
expectedRevision, mode, client, postId, sessionId, siteName,
|
|
1028
|
+
});
|
|
1029
|
+
if (error) return { error };
|
|
1030
|
+
return { currentState };
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
// Case 3: どちらもなし
|
|
1034
|
+
return {};
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
/**
|
|
1038
|
+
* 下流レスポンスの blocks を buildSnapshotBlocks / computeRevision が
|
|
1039
|
+
* 期待する state.allBlocks 形式に正規化する。
|
|
1040
|
+
* @param {Array} blocks - 下流 formatBlocksForSnapshot() の結果
|
|
1041
|
+
* @returns {{ allBlocks: Array }}
|
|
1042
|
+
*/
|
|
1043
|
+
function normalizeDownstreamBlocks(blocks) {
|
|
1044
|
+
return {
|
|
1045
|
+
allBlocks: blocks.map(b => ({
|
|
1046
|
+
index: b.index,
|
|
1047
|
+
type: b.type,
|
|
1048
|
+
html: b.html || '',
|
|
1049
|
+
attributes: b.attributes || {},
|
|
1050
|
+
section: b.section || null,
|
|
1051
|
+
depth: b.depth || 0,
|
|
1052
|
+
parentIndex: b.parentIndex ?? null,
|
|
1053
|
+
})),
|
|
1054
|
+
};
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
/**
|
|
1058
|
+
* 下流レスポンスの result.blocks から snapshot を構築する。
|
|
1059
|
+
* blocks がない場合は null を返す(呼び出し元でフォールバック判断)。
|
|
1060
|
+
* @param {object} result - 下流レスポンス
|
|
1061
|
+
* @param {string} mode
|
|
1062
|
+
* @param {number} postId
|
|
1063
|
+
* @param {string|null} sessionId
|
|
1064
|
+
* @param {string} siteName
|
|
1065
|
+
* @returns {object|null} { snapshotId, revision, blocks[] } or null
|
|
1066
|
+
*/
|
|
1067
|
+
function buildResponseSnapshotFromResult(result, mode, postId, sessionId, siteName) {
|
|
1068
|
+
const blocks = result?.blocks;
|
|
1069
|
+
if (!blocks || !Array.isArray(blocks) || blocks.length === 0) {
|
|
1070
|
+
return null;
|
|
1071
|
+
}
|
|
1072
|
+
const state = normalizeDownstreamBlocks(blocks);
|
|
1073
|
+
return buildSnapshotFromState(state, mode, postId, sessionId, siteName);
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
/**
|
|
1077
|
+
* 更新後のレスポンス snapshot を構築する。
|
|
1078
|
+
* result が渡され、result.blocks があればそこから構築(再取得不要)。
|
|
1079
|
+
* なければ従来通り getCurrentStructure() で再取得する。
|
|
1080
|
+
* @param {string} mode
|
|
1081
|
+
* @param {object} client
|
|
1082
|
+
* @param {number} postId
|
|
1083
|
+
* @param {string|null} sessionId
|
|
1084
|
+
* @param {string} siteName
|
|
1085
|
+
* @param {object} [result] - 下流レスポンス(Phase 1: blocks 再利用)
|
|
1086
|
+
* @returns {Promise<object|null>} { snapshotId, revision, blocks[] } or null
|
|
1087
|
+
*/
|
|
1088
|
+
async function buildResponseSnapshot(mode, client, postId, sessionId, siteName, result) {
|
|
1089
|
+
// Phase 1: 下流 result.blocks があればそこから構築(再取得不要)
|
|
1090
|
+
if (result) {
|
|
1091
|
+
const fromResult = buildResponseSnapshotFromResult(result, mode, postId, sessionId, siteName);
|
|
1092
|
+
if (fromResult) return fromResult;
|
|
1093
|
+
}
|
|
1094
|
+
let newState;
|
|
1095
|
+
try {
|
|
1096
|
+
newState = await getCurrentStructure(mode, client, postId, sessionId);
|
|
1097
|
+
} catch {
|
|
1098
|
+
return null;
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
if (!newState?.allBlocks || newState.allBlocks.length === 0) {
|
|
1102
|
+
return null;
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
const newSnapshotId = generateSnapshotId();
|
|
1106
|
+
const newSnapshotBlocks = buildSnapshotBlocks(newState, mode);
|
|
1107
|
+
const newRevision = computeRevision(newState, mode);
|
|
1108
|
+
|
|
1109
|
+
const snapshotRecord = {
|
|
1110
|
+
snapshotId: newSnapshotId,
|
|
1111
|
+
postId,
|
|
1112
|
+
mode,
|
|
1113
|
+
sessionId: mode === 'editor' ? sessionId : null,
|
|
1114
|
+
siteName: siteName || 'default',
|
|
1115
|
+
createdAt: Date.now(),
|
|
1116
|
+
revision: newRevision,
|
|
1117
|
+
blocks: newSnapshotBlocks,
|
|
1118
|
+
displayMode: 'full',
|
|
1119
|
+
};
|
|
1120
|
+
snapshotCache.set(snapshotRecord);
|
|
1121
|
+
|
|
1122
|
+
return {
|
|
1123
|
+
snapshotId: newSnapshotId,
|
|
1124
|
+
revision: newRevision,
|
|
1125
|
+
blocks: newSnapshotBlocks.map(b => ({
|
|
1126
|
+
ref: b.ref,
|
|
1127
|
+
index: b.index,
|
|
1128
|
+
type: b.type,
|
|
1129
|
+
depth: b.depth,
|
|
1130
|
+
parentIndex: b.parentIndex,
|
|
1131
|
+
})),
|
|
1132
|
+
};
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
/**
|
|
1136
|
+
* @deprecated Phase 7 で削除予定。batch 経路および index 経路の後方互換用。
|
|
1137
|
+
* snapshot 情報をテキストレスポンスに付加する
|
|
1138
|
+
* blocks[] を含めて AI が新しい ref を取得できるようにする
|
|
1139
|
+
* @param {string} text - 元のレスポンステキスト
|
|
1140
|
+
* @param {object|null} snapshot - buildResponseSnapshot の結果
|
|
1141
|
+
* @param {object} [refInfo] - 単体操作の ref 解決情報(1→N 展開追跡用)
|
|
1142
|
+
* @param {string} [refInfo.oldSnapshotId] - 元の snapshotId
|
|
1143
|
+
* @param {string} [refInfo.usedRef] - 使用した ref
|
|
1144
|
+
* @param {number} [refInfo.resolvedIndex] - 解決された元の index
|
|
1145
|
+
* @returns {string} snapshot 行と blocks 一覧が付加されたテキスト
|
|
1146
|
+
*/
|
|
1147
|
+
function appendSnapshotToTextLegacy(text, snapshot, refInfo) {
|
|
1148
|
+
if (!snapshot) return text;
|
|
1149
|
+
|
|
1150
|
+
// 1→N 展開チェック(単体操作時のみ)
|
|
1151
|
+
let expandedLine = '';
|
|
1152
|
+
if (refInfo?.oldSnapshotId && refInfo?.usedRef) {
|
|
1153
|
+
const oldSnap = snapshotCache.get(refInfo.oldSnapshotId);
|
|
1154
|
+
if (oldSnap) {
|
|
1155
|
+
const oldCount = oldSnap.blocks.length;
|
|
1156
|
+
const newCount = snapshot.blocks.length;
|
|
1157
|
+
const delta = newCount - oldCount;
|
|
1158
|
+
if (delta > 0) {
|
|
1159
|
+
// 単体操作での展開: 元 index 位置から delta+1 個が新ブロック
|
|
1160
|
+
const newRefs = snapshot.blocks
|
|
1161
|
+
.filter(b => b.index >= refInfo.resolvedIndex && b.index < refInfo.resolvedIndex + 1 + delta)
|
|
1162
|
+
.map(b => b.ref);
|
|
1163
|
+
expandedLine = `\nexpanded: ${refInfo.usedRef} → [${newRefs.join(', ')}]`;
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
let out = text + `\n\n[snapshot:${snapshot.snapshotId} rev:${snapshot.revision}]${expandedLine}`;
|
|
1169
|
+
if (snapshot.blocks && snapshot.blocks.length > 0) {
|
|
1170
|
+
out += '\nblocks:';
|
|
1171
|
+
for (const b of snapshot.blocks) {
|
|
1172
|
+
const parent = b.parentIndex !== null ? `,p:${b.parentIndex}` : '';
|
|
1173
|
+
out += `\n [${b.index}|${b.ref}] ${b.type} (d:${b.depth}${parent})`;
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
return out;
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
/**
|
|
1180
|
+
* Phase 5: update_blocks / insert_block の差分/Legacy 自動分岐。
|
|
1181
|
+
* inputRef が null なら即 Legacy。差分構築失敗時も Legacy フォールバック。
|
|
1182
|
+
* @param {string} text - メッセージ本文
|
|
1183
|
+
* @param {object|null} snap - buildResponseSnapshot の結果
|
|
1184
|
+
* @param {object} result - 下流レスポンス
|
|
1185
|
+
* @param {object|null} preState - _preState
|
|
1186
|
+
* @param {string|null} inputRef - 入力 ref(null = Legacy 固定)
|
|
1187
|
+
* @param {boolean} isInsert - insertOnly 操作か
|
|
1188
|
+
* @param {object|null} refInfo - Legacy フォールバック用 _refInfo
|
|
1189
|
+
* @returns {string}
|
|
1190
|
+
*/
|
|
1191
|
+
function buildUpdateDiffResponse(text, snap, result, preState, inputRef, isInsert, refInfo) {
|
|
1192
|
+
if (!inputRef || !snap) {
|
|
1193
|
+
return appendSnapshotToTextLegacy(text, snap, refInfo);
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
if (isInsert) {
|
|
1197
|
+
const changeInfo = buildChangeInfoFromResult('inserted', snap, result, preState);
|
|
1198
|
+
if (changeInfo) return appendRefChangesToText(text, changeInfo);
|
|
1199
|
+
return appendSnapshotToTextLegacy(text, snap, refInfo);
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
// update / expand 判定
|
|
1203
|
+
// success:false の change を除外(invalid regex, parse failure 等)
|
|
1204
|
+
const changes = result?.changes?.filter(c => c.success !== false);
|
|
1205
|
+
if (!changes || changes.length === 0) {
|
|
1206
|
+
return appendSnapshotToTextLegacy(text, snap, refInfo);
|
|
1207
|
+
}
|
|
1208
|
+
// filtered 済み changes で result を差し替え(buildChangeInfoFromResult が result.changes を直接参照するため)
|
|
1209
|
+
const filteredResult = { ...result, changes };
|
|
1210
|
+
if (changes.length === 1 && changes[0]?.newIndices?.length > 1) {
|
|
1211
|
+
// 1→N 展開(単体 target のみ)
|
|
1212
|
+
const changeInfo = buildChangeInfoFromResult('expanded', snap, filteredResult, preState, { inputRef });
|
|
1213
|
+
if (changeInfo) return appendRefChangesToText(text, changeInfo);
|
|
1214
|
+
} else if (changes.length === 1) {
|
|
1215
|
+
// 1→1 更新(単体 ref で成功 1 件のときだけ差分化)
|
|
1216
|
+
const changeInfo = buildChangeInfoFromResult('updated', snap, filteredResult, preState);
|
|
1217
|
+
if (changeInfo) return appendRefChangesToText(text, changeInfo);
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
return appendSnapshotToTextLegacy(text, snap, refInfo);
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
// ============================================================
|
|
1224
|
+
// Phase 2: ref 変化分類ロジック(差分通知方式の基盤)
|
|
1225
|
+
// ============================================================
|
|
1226
|
+
|
|
1227
|
+
/**
|
|
1228
|
+
* @typedef {Object} DeletedChange
|
|
1229
|
+
* @property {string} snapshotId
|
|
1230
|
+
* @property {string} revision
|
|
1231
|
+
* @property {'deleted'} type
|
|
1232
|
+
* @property {string[]} deletedRefs - 削除された ref 配列 ['r5', 'r8']
|
|
1233
|
+
* @property {boolean} [refetchRequired]
|
|
1234
|
+
*/
|
|
1235
|
+
|
|
1236
|
+
/**
|
|
1237
|
+
* @typedef {Object} InsertedChange
|
|
1238
|
+
* @property {string} snapshotId
|
|
1239
|
+
* @property {string} revision
|
|
1240
|
+
* @property {'inserted'} type
|
|
1241
|
+
* @property {Array<{index: number, ref: string, blockType: string, depth: number}>} inserted
|
|
1242
|
+
* @property {boolean} [refetchRequired]
|
|
1243
|
+
*/
|
|
1244
|
+
|
|
1245
|
+
/**
|
|
1246
|
+
* @typedef {Object} MovedChange
|
|
1247
|
+
* @property {string} snapshotId
|
|
1248
|
+
* @property {string} revision
|
|
1249
|
+
* @property {'moved'} type
|
|
1250
|
+
* @property {Array<{oldRef: string, newRef: string}>} moved
|
|
1251
|
+
* @property {boolean} [refetchRequired]
|
|
1252
|
+
*/
|
|
1253
|
+
|
|
1254
|
+
/**
|
|
1255
|
+
* @typedef {Object} UpdatedChange
|
|
1256
|
+
* @property {string} snapshotId
|
|
1257
|
+
* @property {string} revision
|
|
1258
|
+
* @property {'updated'} type
|
|
1259
|
+
* @property {string[]} updatedRefs - fingerprint 変化した ref 配列
|
|
1260
|
+
* @property {boolean} [refetchRequired]
|
|
1261
|
+
*/
|
|
1262
|
+
|
|
1263
|
+
/**
|
|
1264
|
+
* @typedef {Object} ExpandedChange
|
|
1265
|
+
* @property {string} snapshotId
|
|
1266
|
+
* @property {string} revision
|
|
1267
|
+
* @property {'expanded'} type
|
|
1268
|
+
* @property {{oldRef: string, newRefs: string[]}} expanded
|
|
1269
|
+
* @property {boolean} [refetchRequired]
|
|
1270
|
+
*/
|
|
1271
|
+
|
|
1272
|
+
/**
|
|
1273
|
+
* @typedef {Object} DuplicatedChange
|
|
1274
|
+
* @property {string} snapshotId
|
|
1275
|
+
* @property {string} revision
|
|
1276
|
+
* @property {'duplicated'} type
|
|
1277
|
+
* @property {string} sourceRef
|
|
1278
|
+
* @property {string} newRef
|
|
1279
|
+
* @property {number} sourceIndex
|
|
1280
|
+
* @property {number} newIndex
|
|
1281
|
+
* @property {boolean} [refetchRequired]
|
|
1282
|
+
*/
|
|
1283
|
+
|
|
1284
|
+
/**
|
|
1285
|
+
* @typedef {DeletedChange|InsertedChange|MovedChange|UpdatedChange|ExpandedChange|DuplicatedChange} ChangeInfo
|
|
1286
|
+
*/
|
|
1287
|
+
|
|
1288
|
+
/**
|
|
1289
|
+
* ref 変化情報をテキストに付加する��差分モード)��
|
|
1290
|
+
* blocks 一��は出力しない。
|
|
1291
|
+
* @param {string} text
|
|
1292
|
+
* @param {ChangeInfo|null} changeInfo
|
|
1293
|
+
* @returns {string}
|
|
1294
|
+
*/
|
|
1295
|
+
function appendRefChangesToText(text, changeInfo) {
|
|
1296
|
+
if (!changeInfo) return text;
|
|
1297
|
+
|
|
1298
|
+
let out = text + `\n\n[snapshot:${changeInfo.snapshotId} rev:${changeInfo.revision}]`;
|
|
1299
|
+
|
|
1300
|
+
switch (changeInfo.type) {
|
|
1301
|
+
case 'deleted':
|
|
1302
|
+
out += `\ndeleted: [${changeInfo.deletedRefs.join(', ')}] (invalid in new snapshot)`;
|
|
1303
|
+
break;
|
|
1304
|
+
case 'inserted':
|
|
1305
|
+
out += '\ninserted: ' + changeInfo.inserted
|
|
1306
|
+
.map(b => `[${b.index}|${b.ref}] ${b.blockType} (d:${b.depth})`)
|
|
1307
|
+
.join(', ');
|
|
1308
|
+
break;
|
|
1309
|
+
case 'moved':
|
|
1310
|
+
out += '\nmoved: ' + changeInfo.moved
|
|
1311
|
+
.map(m => `${m.oldRef}\u2192${m.newRef}`)
|
|
1312
|
+
.join(', ');
|
|
1313
|
+
break;
|
|
1314
|
+
case 'updated':
|
|
1315
|
+
out += `\nupdated: [${changeInfo.updatedRefs.join(', ')}] (use new snapshot)`;
|
|
1316
|
+
break;
|
|
1317
|
+
case 'expanded':
|
|
1318
|
+
out += `\nexpanded: ${changeInfo.expanded.oldRef} \u2192 [${changeInfo.expanded.newRefs.join(', ')}]`;
|
|
1319
|
+
break;
|
|
1320
|
+
case 'duplicated':
|
|
1321
|
+
out += `\nduplicated: ${changeInfo.sourceRef} \u2192 ${changeInfo.newRef} (source:[${changeInfo.sourceIndex}], new:[${changeInfo.newIndex}]) (use new snapshot)`;
|
|
1322
|
+
break;
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
if (changeInfo.refetchRequired) {
|
|
1326
|
+
out += '\n\u26a0 refetchRequired: \u5b50\u30d6\u30ed\u30c3\u30af\u5909\u66f4\u3042\u308a\u3002get_article_structure \u3067\u6700\u65b0\u3092\u53d6\u5f97\u3057\u3066\u304f\u3060\u3055\u3044\u3002';
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
return out;
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
/**
|
|
1333
|
+
* @typedef {Object} BatchDiffEntry
|
|
1334
|
+
* @property {number} opIndex
|
|
1335
|
+
* @property {'updated'|'expanded'|'failed'|'skipped'} type
|
|
1336
|
+
* @property {string} [ref] - updated/failed/skipped 時
|
|
1337
|
+
* @property {string} [oldRef] - expanded 時
|
|
1338
|
+
* @property {string[]} [newRefs] - expanded 時
|
|
1339
|
+
* @property {string} [error] - failed 時
|
|
1340
|
+
* @property {boolean} [refetchRequired] - この entry 単体の refetch 判定
|
|
1341
|
+
*/
|
|
1342
|
+
|
|
1343
|
+
/**
|
|
1344
|
+
* @typedef {Object} BatchDiffInfo
|
|
1345
|
+
* @property {string} snapshotId
|
|
1346
|
+
* @property {string} revision
|
|
1347
|
+
* @property {'batch'} type
|
|
1348
|
+
* @property {BatchDiffEntry[]} entries
|
|
1349
|
+
* @property {boolean} [refetchRequired] - batch 全体の OR
|
|
1350
|
+
*/
|
|
1351
|
+
|
|
1352
|
+
/**
|
|
1353
|
+
* BatchDiffInfo をテキストに付加する(batch 差分モード)。
|
|
1354
|
+
* @param {string} text
|
|
1355
|
+
* @param {BatchDiffInfo|null} batchInfo
|
|
1356
|
+
* @returns {string}
|
|
1357
|
+
*/
|
|
1358
|
+
function appendBatchRefChangesToText(text, batchInfo) {
|
|
1359
|
+
if (!batchInfo) return text;
|
|
1360
|
+
|
|
1361
|
+
let out = text + `\n\n[snapshot:${batchInfo.snapshotId} rev:${batchInfo.revision}]`;
|
|
1362
|
+
out += '\nchanges:';
|
|
1363
|
+
|
|
1364
|
+
for (const entry of batchInfo.entries) {
|
|
1365
|
+
switch (entry.type) {
|
|
1366
|
+
case 'updated':
|
|
1367
|
+
out += `\n [${entry.opIndex}] updated: [${entry.ref}]`;
|
|
1368
|
+
break;
|
|
1369
|
+
case 'expanded':
|
|
1370
|
+
out += `\n [${entry.opIndex}] expanded: ${entry.oldRef} \u2192 [${entry.newRefs.join(', ')}]`;
|
|
1371
|
+
break;
|
|
1372
|
+
case 'failed':
|
|
1373
|
+
out += `\n [${entry.opIndex}] failed: ${entry.ref}`;
|
|
1374
|
+
if (entry.error) out += ` \u2014 ${entry.error}`;
|
|
1375
|
+
break;
|
|
1376
|
+
case 'skipped':
|
|
1377
|
+
out += `\n [${entry.opIndex}] skipped: ${entry.ref}`;
|
|
1378
|
+
break;
|
|
1379
|
+
}
|
|
1380
|
+
if (entry.refetchRequired) out += ' (refetch)';
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
if (batchInfo.refetchRequired) {
|
|
1384
|
+
out += '\n\u26a0 refetchRequired: \u5b50\u30d6\u30ed\u30c3\u30af\u5909\u66f4\u3042\u308a\u3002get_article_structure \u3067\u6700\u65b0\u3092\u53d6\u5f97\u3057\u3066\u304f\u3060\u3055\u3044\u3002';
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
return out;
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
// ============================================================
|
|
1391
|
+
// Phase 7: batch 差分化 helper
|
|
1392
|
+
// ============================================================
|
|
1393
|
+
|
|
1394
|
+
/**
|
|
1395
|
+
* allBlocks の parentIndex チェーンを辿り、resolvedOps 間に祖先/子孫関係があるか検出。
|
|
1396
|
+
* @param {Array<{resolvedIndex: number, ref: string}>} resolvedOps
|
|
1397
|
+
* @param {Array<{index: number, parentIndex: number|null}>} allBlocks
|
|
1398
|
+
* @returns {{childRef: string, childIndex: number, ancestorRef: string, ancestorIndex: number}|null}
|
|
1399
|
+
*/
|
|
1400
|
+
function checkAncestorOverlap(resolvedOps, allBlocks) {
|
|
1401
|
+
const targetMap = new Map(resolvedOps.map(o => [o.resolvedIndex, o.ref]));
|
|
1402
|
+
const parentMap = new Map(allBlocks.map(b => [b.index, b.parentIndex ?? null]));
|
|
1403
|
+
|
|
1404
|
+
for (const op of resolvedOps) {
|
|
1405
|
+
let current = parentMap.get(op.resolvedIndex);
|
|
1406
|
+
while (current != null) {
|
|
1407
|
+
if (targetMap.has(current)) {
|
|
1408
|
+
return {
|
|
1409
|
+
childRef: op.ref,
|
|
1410
|
+
childIndex: op.resolvedIndex,
|
|
1411
|
+
ancestorRef: targetMap.get(current),
|
|
1412
|
+
ancestorIndex: current,
|
|
1413
|
+
};
|
|
1414
|
+
}
|
|
1415
|
+
current = parentMap.get(current) ?? null;
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
return null;
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
/**
|
|
1422
|
+
* allBlocks 内の rootIndex を含む subtree の flat size を返す。
|
|
1423
|
+
* @param {Array<{index: number, parentIndex: number|null}>} allBlocks
|
|
1424
|
+
* @param {number} rootIndex
|
|
1425
|
+
* @returns {number}
|
|
1426
|
+
*/
|
|
1427
|
+
function countFlatSubtreeSize(allBlocks, rootIndex) {
|
|
1428
|
+
const childrenOf = new Map();
|
|
1429
|
+
for (const b of allBlocks) {
|
|
1430
|
+
if (b.parentIndex != null) {
|
|
1431
|
+
if (!childrenOf.has(b.parentIndex)) childrenOf.set(b.parentIndex, []);
|
|
1432
|
+
childrenOf.get(b.parentIndex).push(b.index);
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
let count = 1;
|
|
1436
|
+
const stack = childrenOf.get(rootIndex) ? [...childrenOf.get(rootIndex)] : [];
|
|
1437
|
+
while (stack.length > 0) {
|
|
1438
|
+
const idx = stack.pop();
|
|
1439
|
+
count++;
|
|
1440
|
+
const children = childrenOf.get(idx);
|
|
1441
|
+
if (children) stack.push(...children);
|
|
1442
|
+
}
|
|
1443
|
+
return count;
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
/**
|
|
1447
|
+
* opResult.blocks から新 subtree の flat size を計算する。
|
|
1448
|
+
* rawNewIndices のうち集合内に親を持たないものを root とみなし、各 root の subtree size を合算。
|
|
1449
|
+
* editor(top-level のみ)/ headless(flat 全体)両対応。
|
|
1450
|
+
* @param {Array<{index: number, parentIndex?: number|null}>} opResultBlocks
|
|
1451
|
+
* @param {number[]} rawNewIndices
|
|
1452
|
+
* @returns {number}
|
|
1453
|
+
*/
|
|
1454
|
+
function computeNewFlatSize(opResultBlocks, rawNewIndices) {
|
|
1455
|
+
if (!rawNewIndices || rawNewIndices.length === 0) return 0;
|
|
1456
|
+
const indexSet = new Set(rawNewIndices);
|
|
1457
|
+
const newRoots = rawNewIndices.filter(idx => {
|
|
1458
|
+
const b = opResultBlocks.find(bl => bl.index === idx);
|
|
1459
|
+
return !b || b.parentIndex == null || !indexSet.has(b.parentIndex);
|
|
1460
|
+
});
|
|
1461
|
+
let total = 0;
|
|
1462
|
+
const blocksForSize = opResultBlocks.map(b => ({ index: b.index, parentIndex: b.parentIndex ?? null }));
|
|
1463
|
+
for (const root of newRoots) {
|
|
1464
|
+
total += countFlatSubtreeSize(blocksForSize, root);
|
|
1465
|
+
}
|
|
1466
|
+
return total;
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
/**
|
|
1470
|
+
* [pre-state 戦略] 操作前の状態で対象ブロックにネスト子があるか判定。
|
|
1471
|
+
* delete, move, duplicate 用。操作後は対象が消える/移動するため pre-state で見る。
|
|
1472
|
+
* 下流 hasChildren フラグがあればそちらを優先(duplicate, move が返す)。
|
|
1473
|
+
* @param {object|null} preState - 操作前の state
|
|
1474
|
+
* @param {number[]} targetIndices - 操作対象 index 配列
|
|
1475
|
+
* @param {object} [downstream] - 下流 result(hasChildren があれば優先)
|
|
1476
|
+
* @returns {boolean}
|
|
1477
|
+
*/
|
|
1478
|
+
function checkRefetchFromPreState(preState, targetIndices, downstream) {
|
|
1479
|
+
if (downstream?.hasChildren === true) return true;
|
|
1480
|
+
if (downstream?.hasChildren === false) return false;
|
|
1481
|
+
|
|
1482
|
+
if (!preState?.allBlocks) return true;
|
|
1483
|
+
|
|
1484
|
+
for (const idx of targetIndices) {
|
|
1485
|
+
const hasChild = preState.allBlocks.some(b => b.parentIndex === idx);
|
|
1486
|
+
if (hasChild) return true;
|
|
1487
|
+
}
|
|
1488
|
+
return false;
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
/**
|
|
1492
|
+
* [post-result 戦略] 操作結果にネストブロックが含ま���るか判定。
|
|
1493
|
+
* insert, update 用��非ネストだったブロックを nested HTML に更新したケースを検知。
|
|
1494
|
+
* 新 snapshot の blocks から、対象 index を parentIndex に持つブロックがあるかで判定。
|
|
1495
|
+
* @param {object} snapshot - buildResponseSnapshot() の結果(新 snapshot)
|
|
1496
|
+
* @param {number[]} resultIndices - 操作結果の index 配列(挿入/更新された位置)
|
|
1497
|
+
* @returns {boolean}
|
|
1498
|
+
*/
|
|
1499
|
+
function checkRefetchFromResult(snapshot, resultIndices) {
|
|
1500
|
+
if (!snapshot?.blocks || resultIndices.length === 0) return false;
|
|
1501
|
+
|
|
1502
|
+
for (const idx of resultIndices) {
|
|
1503
|
+
const hasNested = snapshot.blocks.some(b => b.parentIndex === idx);
|
|
1504
|
+
if (hasNested) return true;
|
|
1505
|
+
}
|
|
1506
|
+
return false;
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
/**
|
|
1510
|
+
* 下流 result + 新 snapshot → ChangeInfo を構築する。
|
|
1511
|
+
* 構築不能な場合は null を返す(呼び出し側は Legacy フォールバック必須)。
|
|
1512
|
+
* @param {'deleted'|'inserted'|'moved'|'updated'|'expanded'|'duplicated'} type
|
|
1513
|
+
* @param {object} snapshot - buildResponseSnapshot() の結果
|
|
1514
|
+
* @param {object} result - 下流レスポンス
|
|
1515
|
+
* @param {object|null} preState - _preState
|
|
1516
|
+
* @param {object} [opts] - 追加オプション
|
|
1517
|
+
* @returns {ChangeInfo|null} null = Legacy フォールバック
|
|
1518
|
+
*/
|
|
1519
|
+
function buildChangeInfoFromResult(type, snapshot, result, preState, opts) {
|
|
1520
|
+
if (!snapshot?.snapshotId || !result) return null;
|
|
1521
|
+
|
|
1522
|
+
const base = { snapshotId: snapshot.snapshotId, revision: snapshot.revision };
|
|
1523
|
+
|
|
1524
|
+
switch (type) {
|
|
1525
|
+
case 'deleted': {
|
|
1526
|
+
const deleted = result.deleted;
|
|
1527
|
+
if (!deleted || deleted.length === 0) return null;
|
|
1528
|
+
const deletedRefs = opts?.inputRefs || deleted.map(d => generateBlockRef(d.index));
|
|
1529
|
+
const refetch = checkRefetchFromPreState(preState, deleted.map(d => d.index), result);
|
|
1530
|
+
return { ...base, type: 'deleted', deletedRefs, ...(refetch ? { refetchRequired: true } : {}) };
|
|
1531
|
+
}
|
|
1532
|
+
case 'inserted': {
|
|
1533
|
+
let indices = result.insertedIndices;
|
|
1534
|
+
if (!indices && result.changes) {
|
|
1535
|
+
indices = result.changes.flatMap(c => c.newIndices || []).filter(i => i != null);
|
|
1536
|
+
}
|
|
1537
|
+
if (!indices || indices.length === 0) return null;
|
|
1538
|
+
// refetchRequired は全 indices で判定(子孫含む)
|
|
1539
|
+
const refetch = checkRefetchFromResult(snapshot, indices);
|
|
1540
|
+
// top-level のみ: 他の inserted index を parentIndex に持つものを除外
|
|
1541
|
+
// (headless は subtree 全体の flat index を返すが、editor は top-level のみ。差分レスポンスは top-level に統一)
|
|
1542
|
+
const indexSet = new Set(indices);
|
|
1543
|
+
const topLevelIndices = indices.filter(idx => {
|
|
1544
|
+
const b = snapshot.blocks?.find(bl => bl.index === idx);
|
|
1545
|
+
return !b || b.parentIndex == null || !indexSet.has(b.parentIndex);
|
|
1546
|
+
});
|
|
1547
|
+
const inserted = topLevelIndices.map(idx => {
|
|
1548
|
+
const b = snapshot.blocks?.find(bl => bl.index === idx);
|
|
1549
|
+
return b
|
|
1550
|
+
? { index: b.index, ref: b.ref, blockType: b.type, depth: b.depth }
|
|
1551
|
+
: { index: idx, ref: generateBlockRef(idx), blockType: 'unknown', depth: 0 };
|
|
1552
|
+
});
|
|
1553
|
+
if (inserted.length === 0) return null;
|
|
1554
|
+
return { ...base, type: 'inserted', inserted, ...(refetch ? { refetchRequired: true } : {}) };
|
|
1555
|
+
}
|
|
1556
|
+
case 'moved': {
|
|
1557
|
+
const mapping = result.movedMapping;
|
|
1558
|
+
if (!mapping || mapping.length === 0) return null;
|
|
1559
|
+
// inputRefs 長さ不一致 → Legacy フォールバック(count > 1 安全弁)
|
|
1560
|
+
if (opts?.inputRefs && opts.inputRefs.length !== mapping.length) return null;
|
|
1561
|
+
const moved = mapping.map((m, i) => ({
|
|
1562
|
+
oldRef: opts?.inputRefs ? opts.inputRefs[i] : generateBlockRef(m.oldIndex),
|
|
1563
|
+
newRef: generateBlockRef(m.newIndex),
|
|
1564
|
+
}));
|
|
1565
|
+
const refetch = checkRefetchFromPreState(preState, mapping.map(m => m.oldIndex), result);
|
|
1566
|
+
return { ...base, type: 'moved', moved, ...(refetch ? { refetchRequired: true } : {}) };
|
|
1567
|
+
}
|
|
1568
|
+
case 'updated': {
|
|
1569
|
+
const changes = result.changes;
|
|
1570
|
+
if (!changes || changes.length === 0) return null;
|
|
1571
|
+
const updatedRefs = changes.map(c => generateBlockRef(c.newIndices?.[0] ?? c.oldIndex));
|
|
1572
|
+
const resultIndices = changes.flatMap(c => c.newIndices || []).filter(i => i != null);
|
|
1573
|
+
const refetch = checkRefetchFromResult(snapshot, resultIndices);
|
|
1574
|
+
return { ...base, type: 'updated', updatedRefs, ...(refetch ? { refetchRequired: true } : {}) };
|
|
1575
|
+
}
|
|
1576
|
+
case 'expanded': {
|
|
1577
|
+
const changes = result.changes || [];
|
|
1578
|
+
if (changes.length !== 1) return null;
|
|
1579
|
+
const change = changes[0];
|
|
1580
|
+
if (!change || !change.newIndices || change.newIndices.length <= 1) return null;
|
|
1581
|
+
const oldRef = opts?.inputRef || generateBlockRef(change.oldIndex);
|
|
1582
|
+
const newRefs = change.newIndices.map(idx => generateBlockRef(idx));
|
|
1583
|
+
const refetch = checkRefetchFromResult(snapshot, change.newIndices);
|
|
1584
|
+
return { ...base, type: 'expanded', expanded: { oldRef, newRefs }, ...(refetch ? { refetchRequired: true } : {}) };
|
|
1585
|
+
}
|
|
1586
|
+
case 'duplicated': {
|
|
1587
|
+
const srcIdx = result.sourceIndex ?? result.duplicated?.index;
|
|
1588
|
+
const newIdx = result.newIndex ?? result.duplicated?.newIndex;
|
|
1589
|
+
if (srcIdx == null || newIdx == null) return null;
|
|
1590
|
+
const refetch = checkRefetchFromPreState(preState, [srcIdx], result);
|
|
1591
|
+
return {
|
|
1592
|
+
...base, type: 'duplicated',
|
|
1593
|
+
sourceRef: opts?.inputRef || generateBlockRef(srcIdx),
|
|
1594
|
+
newRef: generateBlockRef(newIdx),
|
|
1595
|
+
sourceIndex: srcIdx, newIndex: newIdx,
|
|
1596
|
+
...(refetch ? { refetchRequired: true } : {}),
|
|
1597
|
+
};
|
|
1598
|
+
}
|
|
1599
|
+
default:
|
|
1600
|
+
return null;
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
/**
|
|
1605
|
+
* 実行完了した resolvedOps + snapshot から BatchDiffInfo を構築する。
|
|
1606
|
+
* @param {Array} resolvedOps - rebasing 済みの op 配列
|
|
1607
|
+
* @param {object|null} snapshot - 最終 snapshot
|
|
1608
|
+
* @returns {BatchDiffInfo|null}
|
|
1609
|
+
*/
|
|
1610
|
+
function buildBatchDiffInfo(resolvedOps, snapshot) {
|
|
1611
|
+
if (!snapshot?.snapshotId) return null;
|
|
1612
|
+
|
|
1613
|
+
let refetchRequired = false;
|
|
1614
|
+
const entries = [];
|
|
1615
|
+
|
|
1616
|
+
// opIndex 順(入力順)でソート
|
|
1617
|
+
const opsByInput = [...resolvedOps].sort((a, b) => a.opIndex - b.opIndex);
|
|
1618
|
+
|
|
1619
|
+
for (const op of opsByInput) {
|
|
1620
|
+
if (op.status === 'failed') {
|
|
1621
|
+
entries.push({ opIndex: op.opIndex, type: 'failed', ref: op.ref, error: op.error || null });
|
|
1622
|
+
continue;
|
|
1623
|
+
}
|
|
1624
|
+
if (op.status === 'skipped') {
|
|
1625
|
+
entries.push({ opIndex: op.opIndex, type: 'skipped', ref: op.ref });
|
|
1626
|
+
continue;
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
// rebased な新 ref を snapshot から取得
|
|
1630
|
+
const newRefs = (op.rebasedNewIndices || []).map(idx => {
|
|
1631
|
+
const b = snapshot.blocks?.find(bl => bl.index === idx);
|
|
1632
|
+
return b ? b.ref : generateBlockRef(idx);
|
|
1633
|
+
});
|
|
1634
|
+
|
|
1635
|
+
// refetch 判定
|
|
1636
|
+
const opRefetch = checkRefetchFromResult(snapshot, op.rebasedNewIndices || []);
|
|
1637
|
+
if (opRefetch) refetchRequired = true;
|
|
1638
|
+
|
|
1639
|
+
if (op.status === 'expanded') {
|
|
1640
|
+
entries.push({
|
|
1641
|
+
opIndex: op.opIndex, type: 'expanded',
|
|
1642
|
+
oldRef: op.ref, newRefs,
|
|
1643
|
+
...(opRefetch ? { refetchRequired: true } : {}),
|
|
1644
|
+
});
|
|
1645
|
+
} else {
|
|
1646
|
+
// updated
|
|
1647
|
+
entries.push({
|
|
1648
|
+
opIndex: op.opIndex, type: 'updated',
|
|
1649
|
+
ref: newRefs[0] || op.ref,
|
|
1650
|
+
...(opRefetch ? { refetchRequired: true } : {}),
|
|
1651
|
+
});
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
return {
|
|
1656
|
+
snapshotId: snapshot.snapshotId,
|
|
1657
|
+
revision: snapshot.revision,
|
|
1658
|
+
type: 'batch',
|
|
1659
|
+
entries,
|
|
1660
|
+
...(refetchRequired ? { refetchRequired: true } : {}),
|
|
1661
|
+
};
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
/**
|
|
1665
|
+
* batch operations の実行
|
|
1666
|
+
* @param {Array} operations - 各 operation: { target, newHTML?, replacements?, attributeUpdates? }
|
|
1667
|
+
* @param {string} snapshotId
|
|
1668
|
+
* @param {string} mode
|
|
1669
|
+
* @param {object} client
|
|
1670
|
+
* @param {number} postId
|
|
1671
|
+
* @param {string|null} sessionId
|
|
1672
|
+
* @param {string} siteName
|
|
1673
|
+
* @param {string} _modeTag
|
|
1674
|
+
* @param {string} toolName
|
|
1675
|
+
*/
|
|
1676
|
+
async function handleBatchOperations(operations, snapshotId, mode, client, postId, sessionId, siteName, _modeTag, toolName, expectedRevision) {
|
|
1677
|
+
// 1. 全 operation の target を正規化 & ref を一括解決
|
|
1678
|
+
let currentState;
|
|
1679
|
+
try {
|
|
1680
|
+
currentState = await getCurrentStructure(mode, client, postId, sessionId, { safetyCritical: true });
|
|
1681
|
+
} catch (e) {
|
|
1682
|
+
return { content: [{ type: "text", text: `❌ 現在のブロック構造を取得できませんでした: ${e.message}` }], isError: true };
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
// revision 楽観的ロック(expectedRevision が指定されている場合のみ)
|
|
1686
|
+
if (expectedRevision) {
|
|
1687
|
+
const mismatch = checkRevisionMismatch(
|
|
1688
|
+
expectedRevision, currentState, mode, postId, sessionId, siteName
|
|
1689
|
+
);
|
|
1690
|
+
if (mismatch) return mismatch;
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
// batch は ref のみ許可(1 operation = 1 ref = 1 block)
|
|
1694
|
+
const resolvedOps = [];
|
|
1695
|
+
try {
|
|
1696
|
+
for (let i = 0; i < operations.length; i++) {
|
|
1697
|
+
const op = operations[i];
|
|
1698
|
+
const ref = op.target?.ref;
|
|
1699
|
+
if (!ref || typeof ref !== 'string') {
|
|
1700
|
+
return {
|
|
1701
|
+
content: [{ type: "text", text: `❌ operations[${i}]: batch の target には ref(文字列)が必須です。index/section/range 等は batch では使用できません。` }],
|
|
1702
|
+
isError: true,
|
|
1703
|
+
};
|
|
1704
|
+
}
|
|
1705
|
+
// ref 以外の selector が指定されていないか確認
|
|
1706
|
+
const extraKeys = Object.keys(op.target).filter(k => k !== 'ref');
|
|
1707
|
+
if (extraKeys.length > 0) {
|
|
1708
|
+
return {
|
|
1709
|
+
content: [{ type: "text", text: `❌ operations[${i}]: batch の target は ref のみ指定可能です。不正なキー: ${extraKeys.join(', ')}` }],
|
|
1710
|
+
isError: true,
|
|
1711
|
+
};
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
// 変更種別がちょうど1つか検証(1 op = 1 ref = 1 change-kind)
|
|
1715
|
+
const changeKinds = [
|
|
1716
|
+
op.replacements && op.replacements.length > 0 && 'replacements',
|
|
1717
|
+
op.newHTML && 'newHTML',
|
|
1718
|
+
op.attributeUpdates && 'attributeUpdates',
|
|
1719
|
+
].filter(Boolean);
|
|
1720
|
+
if (changeKinds.length === 0) {
|
|
1721
|
+
return {
|
|
1722
|
+
content: [{ type: "text", text: `❌ operations[${i}]: 変更内容が指定されていません。replacements, newHTML, attributeUpdates のいずれか1つを指定してください。` }],
|
|
1723
|
+
isError: true,
|
|
1724
|
+
};
|
|
1725
|
+
}
|
|
1726
|
+
if (changeKinds.length > 1) {
|
|
1727
|
+
return {
|
|
1728
|
+
content: [{ type: "text", text: `❌ operations[${i}]: 複数の変更種別が指定されています: ${changeKinds.join(', ')}。1つだけ指定してください。` }],
|
|
1729
|
+
isError: true,
|
|
1730
|
+
};
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
const resolvedIndex = resolveRefFromState(snapshotId, ref, mode, sessionId, postId, currentState);
|
|
1734
|
+
|
|
1735
|
+
resolvedOps.push({
|
|
1736
|
+
opIndex: i,
|
|
1737
|
+
ref,
|
|
1738
|
+
resolvedIndex,
|
|
1739
|
+
newHTML: op.newHTML,
|
|
1740
|
+
replacements: op.replacements,
|
|
1741
|
+
attributeUpdates: op.attributeUpdates,
|
|
1742
|
+
});
|
|
1743
|
+
}
|
|
1744
|
+
} catch (e) {
|
|
1745
|
+
return { content: [{ type: "text", text: `❌ batch ref 解決エラー: ${e.message}` }], isError: true };
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
// 同一 index への複数 operation を排他チェック
|
|
1749
|
+
const indexSet = new Set();
|
|
1750
|
+
for (const op of resolvedOps) {
|
|
1751
|
+
if (indexSet.has(op.resolvedIndex)) {
|
|
1752
|
+
return {
|
|
1753
|
+
content: [{ type: "text", text: `❌ index ${op.resolvedIndex} (ref: ${op.ref}) に対して複数の operation が指定されています。同一ブロックへの複数操作は許可されていません。` }],
|
|
1754
|
+
isError: true,
|
|
1755
|
+
};
|
|
1756
|
+
}
|
|
1757
|
+
indexSet.add(op.resolvedIndex);
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
// ancestor/descendant overlap チェック(親子関係の target は batch 不可)
|
|
1761
|
+
const ancestorOverlap = checkAncestorOverlap(resolvedOps, currentState.allBlocks);
|
|
1762
|
+
if (ancestorOverlap) {
|
|
1763
|
+
return {
|
|
1764
|
+
content: [{ type: "text", text: `❌ operations に親子関係の target が含まれるため batch 不可です。ref ${ancestorOverlap.childRef} (index ${ancestorOverlap.childIndex}) は ref ${ancestorOverlap.ancestorRef} (index ${ancestorOverlap.ancestorIndex}) の子孫です。` }],
|
|
1765
|
+
isError: true,
|
|
1766
|
+
};
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
// 2. oldFlatSize を計算(rebasing 用、降順ソート前に実行)
|
|
1770
|
+
for (const op of resolvedOps) {
|
|
1771
|
+
op.oldFlatSize = countFlatSubtreeSize(currentState.allBlocks, op.resolvedIndex);
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
// 3. 降順ソート(index が大きい方から処理 — 1 op = 1 index なので安全)
|
|
1775
|
+
resolvedOps.sort((a, b) => b.resolvedIndex - a.resolvedIndex);
|
|
1776
|
+
|
|
1777
|
+
// 4. 各 operation を順次実行(結果情報を保持)
|
|
1778
|
+
let lastSuccessfulResult = null;
|
|
1779
|
+
for (const op of resolvedOps) {
|
|
1780
|
+
const index = op.resolvedIndex;
|
|
1781
|
+
|
|
1782
|
+
let opResult;
|
|
1783
|
+
try {
|
|
1784
|
+
const downstreamParams = {
|
|
1785
|
+
index,
|
|
1786
|
+
replacements: op.replacements,
|
|
1787
|
+
newHTML: op.newHTML,
|
|
1788
|
+
attributeUpdates: op.attributeUpdates,
|
|
1789
|
+
insertOnly: false,
|
|
1790
|
+
dryRun: false,
|
|
1791
|
+
_fromInsertBlock: false,
|
|
1792
|
+
};
|
|
1793
|
+
if (mode === 'headless') {
|
|
1794
|
+
opResult = await client.headlessUpdate(postId, downstreamParams);
|
|
1795
|
+
} else {
|
|
1796
|
+
opResult = await client.sendEditorCommand("update_blocks", downstreamParams, 10000, postId, sessionId);
|
|
1797
|
+
}
|
|
1798
|
+
} catch (e) {
|
|
1799
|
+
op.status = 'failed';
|
|
1800
|
+
op.error = e.message;
|
|
1801
|
+
op.rawNewIndices = [];
|
|
1802
|
+
op.newFlatSize = op.oldFlatSize;
|
|
1803
|
+
op.opResult = null;
|
|
1804
|
+
continue;
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
// op 成功判定
|
|
1808
|
+
const topLevelFail = !opResult || opResult.success === false || opResult.ok === false;
|
|
1809
|
+
if (topLevelFail) {
|
|
1810
|
+
op.status = 'failed';
|
|
1811
|
+
op.error = opResult?.error || 'unknown error';
|
|
1812
|
+
op.rawNewIndices = [];
|
|
1813
|
+
op.newFlatSize = op.oldFlatSize;
|
|
1814
|
+
op.opResult = opResult;
|
|
1815
|
+
continue;
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
// skipped 判定(attributeUpdates の filter mismatch)
|
|
1819
|
+
const successfulChanges = (opResult.changes || []).filter(c => c.success !== false);
|
|
1820
|
+
const hasSkippedResult = opResult.results?.some(r => r.skipped === true);
|
|
1821
|
+
if (successfulChanges.length === 0 && !topLevelFail && !opResult.error) {
|
|
1822
|
+
if (hasSkippedResult || op.attributeUpdates != null) {
|
|
1823
|
+
op.status = 'skipped';
|
|
1824
|
+
op.rawNewIndices = [];
|
|
1825
|
+
op.newFlatSize = op.oldFlatSize;
|
|
1826
|
+
op.opResult = opResult;
|
|
1827
|
+
continue;
|
|
1828
|
+
}
|
|
1829
|
+
// changes 空で skipped でもない → failed
|
|
1830
|
+
op.status = 'failed';
|
|
1831
|
+
op.error = 'no successful changes';
|
|
1832
|
+
op.rawNewIndices = [];
|
|
1833
|
+
op.newFlatSize = op.oldFlatSize;
|
|
1834
|
+
op.opResult = opResult;
|
|
1835
|
+
continue;
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
// 成功: rawNewIndices を取得
|
|
1839
|
+
op.rawNewIndices = successfulChanges.flatMap(c => c.newIndices || []);
|
|
1840
|
+
op.newFlatSize = (opResult.blocks && opResult.blocks.length > 0)
|
|
1841
|
+
? computeNewFlatSize(opResult.blocks, op.rawNewIndices)
|
|
1842
|
+
: op.rawNewIndices.length || op.oldFlatSize;
|
|
1843
|
+
op.status = (op.rawNewIndices.length > 1) ? 'expanded' : 'updated';
|
|
1844
|
+
op.opResult = opResult;
|
|
1845
|
+
lastSuccessfulResult = opResult;
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
// 5. rebasing(resolvedIndex 昇順で走査、低 index の展開が高 index をずらす)
|
|
1849
|
+
const opsForRebase = [...resolvedOps].sort((a, b) => a.resolvedIndex - b.resolvedIndex);
|
|
1850
|
+
let cumulativeDelta = 0;
|
|
1851
|
+
for (const op of opsForRebase) {
|
|
1852
|
+
if (op.status === 'failed' || op.status === 'skipped') {
|
|
1853
|
+
op.rebasedNewIndices = [];
|
|
1854
|
+
continue;
|
|
1855
|
+
}
|
|
1856
|
+
op.rebasedNewIndices = op.rawNewIndices.map(idx => idx + cumulativeDelta);
|
|
1857
|
+
cumulativeDelta += (op.newFlatSize - op.oldFlatSize);
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
// 6. 最終 snapshot 構築
|
|
1861
|
+
let snapshot;
|
|
1862
|
+
if (lastSuccessfulResult) {
|
|
1863
|
+
snapshot = buildResponseSnapshotFromResult(lastSuccessfulResult, mode, postId, sessionId, siteName);
|
|
1864
|
+
}
|
|
1865
|
+
if (!snapshot) {
|
|
1866
|
+
// 全 op 失敗/skipped or result.blocks なし → currentState から構築
|
|
1867
|
+
snapshot = buildSnapshotFromState(currentState, mode, postId, sessionId, siteName);
|
|
1868
|
+
}
|
|
1869
|
+
if (!snapshot) {
|
|
1870
|
+
// 最終手段: 再取得
|
|
1871
|
+
snapshot = await buildResponseSnapshot(mode, client, postId, sessionId, siteName);
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1874
|
+
// 7. BatchDiffInfo 構築
|
|
1875
|
+
const batchDiff = buildBatchDiffInfo(resolvedOps, snapshot);
|
|
1876
|
+
|
|
1877
|
+
// 8. レスポンス組み立て
|
|
1878
|
+
const successCount = resolvedOps.filter(r => r.status !== 'failed' && r.status !== 'skipped').length;
|
|
1879
|
+
const totalCount = resolvedOps.length;
|
|
1880
|
+
let text = `\u2705 batch \u5b8c\u4e86 (${successCount}/${totalCount} \u6210\u529f)`;
|
|
1881
|
+
|
|
1882
|
+
if (batchDiff) {
|
|
1883
|
+
text = appendBatchRefChangesToText(text + _modeTag, batchDiff);
|
|
1884
|
+
} else {
|
|
1885
|
+
text = appendSnapshotToTextLegacy(text + _modeTag, snapshot);
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
return { content: [{ type: "text", text }] };
|
|
1889
|
+
}
|
|
1890
|
+
|
|
631
1891
|
// フィードバック送信ヘルパー
|
|
632
1892
|
const FEEDBACK_URL = process.env.FRIDAY_FEEDBACK_URL || '';
|
|
633
1893
|
async function sendFeedback(data) {
|
|
@@ -776,7 +2036,7 @@ const tools = [
|
|
|
776
2036
|
},
|
|
777
2037
|
{
|
|
778
2038
|
name: "delete_block",
|
|
779
|
-
description: "Delete block(s) by index or selection.",
|
|
2039
|
+
description: "Delete block(s) by index, ref, or selection.",
|
|
780
2040
|
inputSchema: {
|
|
781
2041
|
type: "object",
|
|
782
2042
|
properties: {
|
|
@@ -785,12 +2045,16 @@ const tools = [
|
|
|
785
2045
|
index: { type: "number", description: "Index (0-based). Omit for selected." },
|
|
786
2046
|
count: { type: "number", description: "Consecutive count (default: 1)" },
|
|
787
2047
|
indices: { type: "array", items: { type: "number" }, description: "Multiple indices (exclusive with index/count)" },
|
|
2048
|
+
snapshotId: { type: "string", description: "Snapshot ID from get_article_structure. Required when using ref/refs." },
|
|
2049
|
+
ref: { type: "string", description: "Block ref from snapshot (exclusive with index/indices)." },
|
|
2050
|
+
refs: { type: "array", items: { type: "string" }, description: "Multiple block refs (exclusive with index/indices)." },
|
|
2051
|
+
expectedRevision: { type: "string", description: "Revision from snapshot. Rejects if structure changed." },
|
|
788
2052
|
},
|
|
789
2053
|
},
|
|
790
2054
|
},
|
|
791
2055
|
{
|
|
792
2056
|
name: "move_block",
|
|
793
|
-
description: "Move block(s). Use from/to (top-level)
|
|
2057
|
+
description: "Move block(s). Use from/to (top-level), fromFlat/toFlat (nested), or fromRef+beforeRef/afterRef (ref-based).",
|
|
794
2058
|
inputSchema: {
|
|
795
2059
|
type: "object",
|
|
796
2060
|
properties: {
|
|
@@ -801,6 +2065,11 @@ const tools = [
|
|
|
801
2065
|
fromFlat: { type: "number", description: "Source flattened index" },
|
|
802
2066
|
toFlat: { type: "number", description: "Target flattened position" },
|
|
803
2067
|
count: { type: "integer", description: "Consecutive count (default: 1)" },
|
|
2068
|
+
snapshotId: { type: "string", description: "Snapshot ID from get_article_structure. Required when using ref." },
|
|
2069
|
+
expectedRevision: { type: "string", description: "Revision from snapshot. Rejects if structure changed." },
|
|
2070
|
+
fromRef: { type: "string", description: "Source block ref (exclusive with from/fromFlat)." },
|
|
2071
|
+
beforeRef: { type: "string", description: "Move before this ref (exclusive with to/toFlat/afterRef)." },
|
|
2072
|
+
afterRef: { type: "string", description: "Move after this ref (exclusive with to/toFlat/beforeRef)." },
|
|
804
2073
|
},
|
|
805
2074
|
},
|
|
806
2075
|
},
|
|
@@ -830,13 +2099,16 @@ const tools = [
|
|
|
830
2099
|
},
|
|
831
2100
|
{
|
|
832
2101
|
name: "duplicate_block",
|
|
833
|
-
description: "Duplicate block by index or selection.",
|
|
2102
|
+
description: "Duplicate block by index, ref, or selection.",
|
|
834
2103
|
inputSchema: {
|
|
835
2104
|
type: "object",
|
|
836
2105
|
properties: {
|
|
837
2106
|
postId: postIdParam,
|
|
838
2107
|
site: siteParam,
|
|
839
2108
|
index: { type: "number", description: "Index (0-based). Omit for selected." },
|
|
2109
|
+
snapshotId: { type: "string", description: "Snapshot ID from get_article_structure. Required when using ref." },
|
|
2110
|
+
ref: { type: "string", description: "Block ref from snapshot (exclusive with index)." },
|
|
2111
|
+
expectedRevision: { type: "string", description: "Revision from snapshot. Rejects if structure changed." },
|
|
840
2112
|
},
|
|
841
2113
|
},
|
|
842
2114
|
},
|
|
@@ -919,7 +2191,7 @@ const tools = [
|
|
|
919
2191
|
},
|
|
920
2192
|
{
|
|
921
2193
|
name: "insert_block",
|
|
922
|
-
description: "Insert Gutenberg HTML. Omit index to append.",
|
|
2194
|
+
description: "Insert Gutenberg HTML. Omit index to append. Use beforeRef/afterRef for ref-based positioning.",
|
|
923
2195
|
inputSchema: {
|
|
924
2196
|
type: "object",
|
|
925
2197
|
properties: {
|
|
@@ -928,6 +2200,11 @@ const tools = [
|
|
|
928
2200
|
rawHTML: { type: "string", description: "HTML to insert (exclusive with filePath)" },
|
|
929
2201
|
filePath: { type: "string", description: "Local file path (exclusive with rawHTML)" },
|
|
930
2202
|
index: { type: "number", description: "Position (0-based). Omit to append." },
|
|
2203
|
+
position: { type: "string", enum: ["before", "after"], description: "Insert relative to index: 'before' (default) or 'after'. Requires index." },
|
|
2204
|
+
snapshotId: { type: "string", description: "Snapshot ID. Required when using beforeRef/afterRef." },
|
|
2205
|
+
expectedRevision: { type: "string", description: "Revision from snapshot. Rejects if structure changed." },
|
|
2206
|
+
beforeRef: { type: "string", description: "Insert before this ref (exclusive with index)." },
|
|
2207
|
+
afterRef: { type: "string", description: "Insert after this ref (exclusive with index)." },
|
|
931
2208
|
},
|
|
932
2209
|
},
|
|
933
2210
|
},
|
|
@@ -957,13 +2234,59 @@ const tools = [
|
|
|
957
2234
|
},
|
|
958
2235
|
{
|
|
959
2236
|
name: "update_blocks",
|
|
960
|
-
description: "Update block(s). Supports replacements, newHTML, or attributeUpdates.",
|
|
2237
|
+
description: "Update block(s). Supports replacements, newHTML, or attributeUpdates. Use ref+snapshotId for safe index resolution.",
|
|
961
2238
|
inputSchema: {
|
|
962
2239
|
type: "object",
|
|
963
2240
|
properties: {
|
|
964
2241
|
postId: postIdParam,
|
|
965
2242
|
site: siteParam,
|
|
2243
|
+
snapshotId: {
|
|
2244
|
+
type: "string",
|
|
2245
|
+
description: "Snapshot ID from get_article_structure. Required when using ref/refs in target.",
|
|
2246
|
+
},
|
|
2247
|
+
expectedRevision: {
|
|
2248
|
+
type: "string",
|
|
2249
|
+
description: "Revision from snapshot. If provided, rejects update when structure has changed.",
|
|
2250
|
+
},
|
|
966
2251
|
target: targetSchema,
|
|
2252
|
+
operations: {
|
|
2253
|
+
type: "array",
|
|
2254
|
+
items: {
|
|
2255
|
+
type: "object",
|
|
2256
|
+
properties: {
|
|
2257
|
+
target: {
|
|
2258
|
+
type: "object",
|
|
2259
|
+
properties: {
|
|
2260
|
+
ref: { type: "string", description: "Block ref from snapshot (required)." },
|
|
2261
|
+
},
|
|
2262
|
+
required: ["ref"],
|
|
2263
|
+
description: "Batch target: ref only (1 block per operation).",
|
|
2264
|
+
},
|
|
2265
|
+
replacements: {
|
|
2266
|
+
type: "array",
|
|
2267
|
+
items: {
|
|
2268
|
+
type: "object",
|
|
2269
|
+
properties: {
|
|
2270
|
+
old: { type: "string" },
|
|
2271
|
+
new: { type: "string" },
|
|
2272
|
+
regex: { type: "boolean" },
|
|
2273
|
+
},
|
|
2274
|
+
required: ["old", "new"],
|
|
2275
|
+
},
|
|
2276
|
+
},
|
|
2277
|
+
newHTML: { type: "string" },
|
|
2278
|
+
attributeUpdates: {
|
|
2279
|
+
type: "object",
|
|
2280
|
+
properties: {
|
|
2281
|
+
filter: { type: "object" },
|
|
2282
|
+
set: { type: "object" },
|
|
2283
|
+
},
|
|
2284
|
+
},
|
|
2285
|
+
},
|
|
2286
|
+
required: ["target"],
|
|
2287
|
+
},
|
|
2288
|
+
description: "Batch operations: each targets a single block by ref. Requires snapshotId.",
|
|
2289
|
+
},
|
|
967
2290
|
replacements: {
|
|
968
2291
|
type: "array",
|
|
969
2292
|
items: {
|
|
@@ -999,12 +2322,12 @@ const tools = [
|
|
|
999
2322
|
description: "Preview only",
|
|
1000
2323
|
},
|
|
1001
2324
|
},
|
|
1002
|
-
|
|
2325
|
+
// target は operations[] 使用時は不要
|
|
1003
2326
|
},
|
|
1004
2327
|
},
|
|
1005
2328
|
{
|
|
1006
2329
|
name: "table_operations",
|
|
1007
|
-
description: "Table operations (get/update/add/delete rows/columns/cells).",
|
|
2330
|
+
description: "Table operations (get/update/add/delete rows/columns/cells). Use ref+snapshotId for safe index resolution.",
|
|
1008
2331
|
inputSchema: {
|
|
1009
2332
|
type: "object",
|
|
1010
2333
|
properties: {
|
|
@@ -1040,8 +2363,11 @@ const tools = [
|
|
|
1040
2363
|
items: { type: "string" },
|
|
1041
2364
|
description: "add_row: new cells, add_column: init values, update_row/column: replacements. Omit for empty.",
|
|
1042
2365
|
},
|
|
2366
|
+
snapshotId: { type: "string", description: "Snapshot ID from get_article_structure. Required when using ref." },
|
|
2367
|
+
ref: { type: "string", description: "Block ref from snapshot (exclusive with index)." },
|
|
2368
|
+
expectedRevision: { type: "string", description: "Revision from snapshot. Rejects if structure changed." },
|
|
1043
2369
|
},
|
|
1044
|
-
required: ["
|
|
2370
|
+
required: ["action"],
|
|
1045
2371
|
},
|
|
1046
2372
|
},
|
|
1047
2373
|
{
|
|
@@ -1114,11 +2440,12 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
1114
2440
|
*/
|
|
1115
2441
|
async function handleUpdateBlocksTool(args, toolName) {
|
|
1116
2442
|
const name = toolName || "update_blocks";
|
|
1117
|
-
let { mode, postId: _postId, sessionId: _sessionId, message, client } = await resolveMode(args, name);
|
|
2443
|
+
let { mode, postId: _postId, sessionId: _sessionId, message, client, siteName } = await resolveMode(args, name);
|
|
1118
2444
|
if (mode === 'error') {
|
|
1119
2445
|
return errorResponse(name, message, args?.site);
|
|
1120
2446
|
}
|
|
1121
2447
|
const _modeTag = process.env.FRIDAY_DEBUG === '1' ? `\n[DEBUG] mode=${mode}` : '';
|
|
2448
|
+
const _siteName = siteName || args?.site || 'default';
|
|
1122
2449
|
|
|
1123
2450
|
// slug → postId 解決(headless モード時のみ)
|
|
1124
2451
|
const _resolved = mode === 'headless' ? await resolvePostId(args?.postId, client) : { postId: _postId };
|
|
@@ -1140,6 +2467,27 @@ async function handleUpdateBlocksTool(args, toolName) {
|
|
|
1140
2467
|
};
|
|
1141
2468
|
}
|
|
1142
2469
|
|
|
2470
|
+
// --- operations[] 排他チェック ---
|
|
2471
|
+
const snapshotId = args?.snapshotId;
|
|
2472
|
+
if (args?.operations && args.operations.length > 0) {
|
|
2473
|
+
const conflicting = ['target', 'replacements', 'newHTML', 'attributeUpdates', 'insert', 'filePath']
|
|
2474
|
+
.filter(k => args[k] !== undefined);
|
|
2475
|
+
if (conflicting.length > 0) {
|
|
2476
|
+
return {
|
|
2477
|
+
content: [{ type: "text", text: `❌ operations と ${conflicting.join(', ')} は同時に指定できません。operations を使う場合、各操作は operations 内に記述してください。` }],
|
|
2478
|
+
isError: true,
|
|
2479
|
+
};
|
|
2480
|
+
}
|
|
2481
|
+
if (!snapshotId) {
|
|
2482
|
+
return {
|
|
2483
|
+
content: [{ type: "text", text: `❌ operations には snapshotId が必要です。get_article_structure で取得してください。` }],
|
|
2484
|
+
isError: true,
|
|
2485
|
+
};
|
|
2486
|
+
}
|
|
2487
|
+
// batch 実行に委譲
|
|
2488
|
+
return await handleBatchOperations(args.operations, snapshotId, mode, client, postId, _sessionId, _siteName, _modeTag, name, args?.expectedRevision);
|
|
2489
|
+
}
|
|
2490
|
+
|
|
1143
2491
|
// --- normalizeTarget / normalizeInsert ---
|
|
1144
2492
|
let tp, ins;
|
|
1145
2493
|
try {
|
|
@@ -1149,6 +2497,32 @@ async function handleUpdateBlocksTool(args, toolName) {
|
|
|
1149
2497
|
return { content: [{ type: "text", text: `❌ ${e.message}` }], isError: true };
|
|
1150
2498
|
}
|
|
1151
2499
|
|
|
2500
|
+
// --- ref 解決 + revision チェック(共通ヘルパー) ---
|
|
2501
|
+
const resolved = await resolveRefsAndCheckRevision({
|
|
2502
|
+
snapshotId,
|
|
2503
|
+
ref: tp._ref,
|
|
2504
|
+
refs: tp._refs,
|
|
2505
|
+
expectedRevision: args?.expectedRevision,
|
|
2506
|
+
mode, client, postId, sessionId: _sessionId, siteName: _siteName,
|
|
2507
|
+
});
|
|
2508
|
+
if (resolved.error) return resolved.error;
|
|
2509
|
+
const _preState = resolved.currentState || null; // Phase 2: 差分計算用
|
|
2510
|
+
if (resolved.index !== undefined) {
|
|
2511
|
+
tp.index = resolved.index;
|
|
2512
|
+
delete tp._ref;
|
|
2513
|
+
}
|
|
2514
|
+
if (resolved.indices !== undefined) {
|
|
2515
|
+
tp.indices = resolved.indices;
|
|
2516
|
+
delete tp._refs;
|
|
2517
|
+
}
|
|
2518
|
+
|
|
2519
|
+
// 1→N 展開追跡用の refInfo(単体 ref + newHTML 置換時のみ。insert 系は対象外)
|
|
2520
|
+
const _refInfo = (snapshotId && args?.target?.ref && args?.newHTML && !args?.insert) ? {
|
|
2521
|
+
oldSnapshotId: snapshotId,
|
|
2522
|
+
usedRef: args.target.ref,
|
|
2523
|
+
resolvedIndex: tp.index,
|
|
2524
|
+
} : null;
|
|
2525
|
+
|
|
1152
2526
|
const { target, index, indices, startIndex, endIndex, section, blockType, typeIndex,
|
|
1153
2527
|
contains, headingLevel, headingContains } = tp;
|
|
1154
2528
|
const { insertOnly, insertPosition } = ins;
|
|
@@ -1156,6 +2530,17 @@ async function handleUpdateBlocksTool(args, toolName) {
|
|
|
1156
2530
|
_fromInsertBlock, appendToEnd } = (args || {});
|
|
1157
2531
|
let { newHTML } = (args || {});
|
|
1158
2532
|
|
|
2533
|
+
// Phase 5: 差分化オプション
|
|
2534
|
+
// - 単体 target.ref かつ snapshotId ありのみ差分化
|
|
2535
|
+
// - appendToEnd は Legacy 固定(早期リターンで自然に除外)
|
|
2536
|
+
const _inputRef = (snapshotId && args?.target?.ref) ? args.target.ref : null;
|
|
2537
|
+
const _isRefInsert = !!(insertOnly && _inputRef);
|
|
2538
|
+
|
|
2539
|
+
// [INSERT_OBSERVE] Step 5: insert_block 経由時の送信パラメータログ
|
|
2540
|
+
if (process.env.FRIDAY_DEBUG === '1' && _fromInsertBlock) {
|
|
2541
|
+
console.log(`[INSERT_OBSERVE] mcp-send: index=${index}, postId=${postId}, sessionId=${_sessionId || 'none'}, mode=${mode}, appendToEnd=${appendToEnd}, htmlLen=${(newHTML || '').length}`);
|
|
2542
|
+
}
|
|
2543
|
+
|
|
1159
2544
|
// --- filePath → newHTML 解決(update_blocks 直接呼び出し時) ---
|
|
1160
2545
|
if (args?.filePath) {
|
|
1161
2546
|
if (newHTML) {
|
|
@@ -1196,7 +2581,7 @@ async function handleUpdateBlocksTool(args, toolName) {
|
|
|
1196
2581
|
};
|
|
1197
2582
|
}
|
|
1198
2583
|
|
|
1199
|
-
// --- ターゲットチェック(appendToEnd 時は不要) ---
|
|
2584
|
+
// --- ターゲットチェック(appendToEnd / _parentIndex 時は不要) ---
|
|
1200
2585
|
const hasTarget = target || index !== undefined || indices ||
|
|
1201
2586
|
startIndex !== undefined || section || blockType || contains ||
|
|
1202
2587
|
(headingLevel && headingContains);
|
|
@@ -1238,7 +2623,8 @@ async function handleUpdateBlocksTool(args, toolName) {
|
|
|
1238
2623
|
_fromInsertBlock: _fromInsertBlock || false,
|
|
1239
2624
|
});
|
|
1240
2625
|
const count = result.results?.[0]?.count || 1;
|
|
1241
|
-
|
|
2626
|
+
const _snap = await buildResponseSnapshot(mode, client, postId, _sessionId, _siteName, result);
|
|
2627
|
+
return { content: [{ type: "text", text: appendSnapshotToTextLegacy(`✅ ${count}ブロック挿入 at end${deprecationWarning}${_modeTag}`, _snap, _refInfo) }] };
|
|
1242
2628
|
}
|
|
1243
2629
|
|
|
1244
2630
|
const result = await client.headlessUpdate(postId, {
|
|
@@ -1263,13 +2649,14 @@ async function handleUpdateBlocksTool(args, toolName) {
|
|
|
1263
2649
|
return { content: [{ type: "text", text: `🔍 プレビュー\n\n対象: ${result.results.length} ブロック\n\n${previewText}${deprecationWarning}` }] };
|
|
1264
2650
|
}
|
|
1265
2651
|
|
|
2652
|
+
const _snap = await buildResponseSnapshot(mode, client, postId, _sessionId, _siteName, result);
|
|
1266
2653
|
if (result.results) {
|
|
1267
2654
|
const successCount = result.results.filter(r => r.success).length;
|
|
1268
2655
|
const failCount = result.results.filter(r => !r.success).length;
|
|
1269
|
-
return { content: [{ type: "text", text: `✅ 完了\n\n成功: ${successCount} / 失敗: ${failCount}${deprecationWarning}
|
|
2656
|
+
return { content: [{ type: "text", text: buildUpdateDiffResponse(`✅ 完了\n\n成功: ${successCount} / 失敗: ${failCount}${deprecationWarning}`, _snap, result, _preState, _inputRef, _isRefInsert, _refInfo) }] };
|
|
1270
2657
|
}
|
|
1271
2658
|
|
|
1272
|
-
return { content: [{ type: "text", text: `✅ 更新完了${deprecationWarning}${_modeTag}
|
|
2659
|
+
return { content: [{ type: "text", text: buildUpdateDiffResponse(`✅ 更新完了${deprecationWarning}${_modeTag}`, _snap, result, _preState, _inputRef, _isRefInsert, _refInfo) }] };
|
|
1273
2660
|
} catch (e) {
|
|
1274
2661
|
const formatted = formatHeadlessConflictError(e);
|
|
1275
2662
|
if (formatted) return formatted;
|
|
@@ -1291,7 +2678,8 @@ async function handleUpdateBlocksTool(args, toolName) {
|
|
|
1291
2678
|
if (!result || result.timeout) return timeoutResponse(name, client, args?.site);
|
|
1292
2679
|
if (!result.success) return errorResponse(name, result.error, args?.site);
|
|
1293
2680
|
const count = result.inserted || 1;
|
|
1294
|
-
|
|
2681
|
+
const _snap = await buildResponseSnapshot(mode, client, postId, _sessionId, _siteName, result);
|
|
2682
|
+
return { content: [{ type: "text", text: appendSnapshotToTextLegacy(`✅ ${count}ブロック挿入 at end${deprecationWarning}${_modeTag}`, _snap, _refInfo) }] };
|
|
1295
2683
|
}
|
|
1296
2684
|
|
|
1297
2685
|
// target: "selected" の場合 → エディタ状態から選択情報を取得してMCP側で処理
|
|
@@ -1344,7 +2732,10 @@ async function handleUpdateBlocksTool(args, toolName) {
|
|
|
1344
2732
|
return timeoutResponse(name, client, args?.site);
|
|
1345
2733
|
if (!result.success)
|
|
1346
2734
|
return errorResponse(name, result.error, args?.site);
|
|
1347
|
-
|
|
2735
|
+
{
|
|
2736
|
+
const _snap = await buildResponseSnapshot(mode, client, postId, _sessionId, _siteName, result);
|
|
2737
|
+
return { content: [{ type: "text", text: appendSnapshotToTextLegacy(`✅ ${result.mode === "insert" ? "挿入" : "更新"}完了${hasTextSelection ? "(カーソル選択モード)" : "(差分)"}${deprecationWarning}`, _snap, _refInfo) }] };
|
|
2738
|
+
}
|
|
1348
2739
|
}
|
|
1349
2740
|
|
|
1350
2741
|
// newHTMLの場合
|
|
@@ -1360,7 +2751,10 @@ async function handleUpdateBlocksTool(args, toolName) {
|
|
|
1360
2751
|
return timeoutResponse(name, client, args?.site);
|
|
1361
2752
|
if (!result.success)
|
|
1362
2753
|
return errorResponse(name, result.error, args?.site);
|
|
1363
|
-
|
|
2754
|
+
{
|
|
2755
|
+
const _snap = await buildResponseSnapshot(mode, client, postId, _sessionId, _siteName, result);
|
|
2756
|
+
return { content: [{ type: "text", text: appendSnapshotToTextLegacy(`✅ ${result.mode === "insert" ? "挿入" : "更新"}完了${deprecationWarning}`, _snap, _refInfo) }] };
|
|
2757
|
+
}
|
|
1364
2758
|
}
|
|
1365
2759
|
|
|
1366
2760
|
// attributeUpdatesの場合
|
|
@@ -1374,12 +2768,15 @@ async function handleUpdateBlocksTool(args, toolName) {
|
|
|
1374
2768
|
return timeoutResponse(name, client, args?.site);
|
|
1375
2769
|
if (!result.success)
|
|
1376
2770
|
return errorResponse(name, result.error, args?.site);
|
|
1377
|
-
|
|
2771
|
+
{
|
|
2772
|
+
const _snap = await buildResponseSnapshot(mode, client, postId, _sessionId, _siteName, result);
|
|
2773
|
+
return { content: [{ type: "text", text: appendSnapshotToTextLegacy(`✅ 属性更新完了${deprecationWarning}`, _snap, _refInfo) }] };
|
|
2774
|
+
}
|
|
1378
2775
|
}
|
|
1379
2776
|
|
|
1380
2777
|
// その他のターゲット → 全てWP側で解決&実行
|
|
1381
2778
|
const maxWait = (section || blockType || contains) ? 15000 : 10000;
|
|
1382
|
-
const
|
|
2779
|
+
const editorParams = {
|
|
1383
2780
|
index, indices, startIndex, endIndex,
|
|
1384
2781
|
section, blockType, typeIndex, contains,
|
|
1385
2782
|
headingLevel, headingContains,
|
|
@@ -1388,7 +2785,8 @@ async function handleUpdateBlocksTool(args, toolName) {
|
|
|
1388
2785
|
insertPosition,
|
|
1389
2786
|
dryRun: dryRun || false,
|
|
1390
2787
|
_fromInsertBlock: _fromInsertBlock || false,
|
|
1391
|
-
}
|
|
2788
|
+
};
|
|
2789
|
+
const result = await client.sendEditorCommand("update_blocks", editorParams, maxWait, _postId, _sessionId);
|
|
1392
2790
|
if (!result || result.timeout)
|
|
1393
2791
|
return timeoutResponse(name, client, args?.site);
|
|
1394
2792
|
if (!result.success)
|
|
@@ -1412,15 +2810,16 @@ async function handleUpdateBlocksTool(args, toolName) {
|
|
|
1412
2810
|
return { content: [{ type: "text", text: `🔍 プレビュー\n\n対象: ${result.results.length} ブロック\n\n${previewText || "(対象なし)"}${deprecationWarning}` }] };
|
|
1413
2811
|
}
|
|
1414
2812
|
|
|
2813
|
+
const _snap = await buildResponseSnapshot(mode, client, postId, _sessionId, _siteName, result);
|
|
1415
2814
|
if (result.results) {
|
|
1416
2815
|
const successCount = result.results.filter(r => r.success && !r.skipped).length;
|
|
1417
2816
|
const skipCount = result.results.filter(r => r.skipped).length;
|
|
1418
2817
|
const failCount = result.results.filter(r => !r.success).length;
|
|
1419
2818
|
const detailText = result.results.filter(r => r.success && r.replaceCount > 0).map(r => ` [${r.index}]: ${r.replaceCount}箇所`).join('\n');
|
|
1420
|
-
return { content: [{ type: "text", text: `✅ 完了\n\n成功: ${successCount} / スキップ: ${skipCount} / 失敗: ${failCount}\n\n${detailText}${deprecationWarning}${_modeTag}
|
|
2819
|
+
return { content: [{ type: "text", text: buildUpdateDiffResponse(`✅ 完了\n\n成功: ${successCount} / スキップ: ${skipCount} / 失敗: ${failCount}\n\n${detailText}${deprecationWarning}${_modeTag}`, _snap, result, _preState, _inputRef, _isRefInsert, _refInfo) }] };
|
|
1421
2820
|
}
|
|
1422
2821
|
|
|
1423
|
-
return { content: [{ type: "text", text: `✅ ${result.mode === "insert" ? "挿入" : "更新"}完了${deprecationWarning}${_modeTag}
|
|
2822
|
+
return { content: [{ type: "text", text: buildUpdateDiffResponse(`✅ ${result.mode === "insert" ? "挿入" : "更新"}完了${deprecationWarning}${_modeTag}`, _snap, result, _preState, _inputRef, _isRefInsert, _refInfo) }] };
|
|
1424
2823
|
}
|
|
1425
2824
|
|
|
1426
2825
|
// ツール実行のハンドラ
|
|
@@ -1475,6 +2874,46 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1475
2874
|
state = await client.getEditorState(_postId, _sessionId);
|
|
1476
2875
|
}
|
|
1477
2876
|
|
|
2877
|
+
// --- snapshot 作成(allBlocks がある場合のみ) ---
|
|
2878
|
+
let _refTag = (_idx, _prefix) => '';
|
|
2879
|
+
let _snapshotLine = '';
|
|
2880
|
+
|
|
2881
|
+
if (state.allBlocks && state.allBlocks.length > 0) {
|
|
2882
|
+
const snapshotId = generateSnapshotId();
|
|
2883
|
+
const snapshotBlocks = buildSnapshotBlocks(state, mode);
|
|
2884
|
+
const revision = computeRevision(state, mode);
|
|
2885
|
+
const snapshotRecord = {
|
|
2886
|
+
snapshotId, postId, mode,
|
|
2887
|
+
sessionId: mode === 'editor' ? _sessionId : null,
|
|
2888
|
+
siteName: siteName || 'default',
|
|
2889
|
+
createdAt: Date.now(),
|
|
2890
|
+
revision,
|
|
2891
|
+
blocks: snapshotBlocks,
|
|
2892
|
+
displayMode: full ? 'full' : contains ? 'contains' : section ? (blockType ? 'section+blockType' : 'section') : blockType ? 'blockType' : headingLevel ? 'headingLevel' : 'default',
|
|
2893
|
+
};
|
|
2894
|
+
snapshotCache.set(snapshotRecord);
|
|
2895
|
+
|
|
2896
|
+
const _refMap = new Map(snapshotBlocks.map(b => [b.index, b.ref]));
|
|
2897
|
+
_refTag = (idx, prefix = '|') => { const r = _refMap.get(idx); return r ? `${prefix}${r}` : ''; };
|
|
2898
|
+
_snapshotLine = `\n[snapshot:${snapshotId} rev:${revision}]`;
|
|
2899
|
+
}
|
|
2900
|
+
|
|
2901
|
+
// ツリー構造情報のフォーマットヘルパー
|
|
2902
|
+
function formatTreeLine(b) {
|
|
2903
|
+
const depth = b.depth || 0;
|
|
2904
|
+
const indent = ' '.repeat(depth + 1);
|
|
2905
|
+
let marker = '';
|
|
2906
|
+
let childInfo = '';
|
|
2907
|
+
if (b.isContainer) {
|
|
2908
|
+
marker = '▼ ';
|
|
2909
|
+
if (b.columnCount) childInfo = ` [${b.columnCount}カラム]`;
|
|
2910
|
+
else if (b.tabNames && b.tabNames.length > 0) childInfo = ` [${b.tabNames.join(", ")}]`;
|
|
2911
|
+
else if (b.childCount) childInfo = ` [${b.childCount}子]`;
|
|
2912
|
+
}
|
|
2913
|
+
const parentInfo = (depth > 0 && b.parentIndex != null) ? `, p:${b.parentIndex}` : '';
|
|
2914
|
+
return { indent, marker, childInfo, depthInfo: `(d:${depth}${parentInfo})` };
|
|
2915
|
+
}
|
|
2916
|
+
|
|
1478
2917
|
// ========================================
|
|
1479
2918
|
// 全ブロック情報取得(full=true)
|
|
1480
2919
|
// ========================================
|
|
@@ -1505,7 +2944,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1505
2944
|
|
|
1506
2945
|
const blockList = blocks.map(b => {
|
|
1507
2946
|
const attrPreview = JSON.stringify(b.attributes || {}).slice(0, 200);
|
|
1508
|
-
|
|
2947
|
+
const t = formatTreeLine(b);
|
|
2948
|
+
return `${t.indent}[${b.index}${_refTag(b.index)}] ${t.marker}${b.type}${t.childInfo} - ${b.section || "(記事冒頭)"} ${t.depthInfo}\n${t.indent} ${attrPreview}${attrPreview.length >= 200 ? '...' : ''}`;
|
|
1509
2949
|
}).join("\n\n");
|
|
1510
2950
|
|
|
1511
2951
|
let paginationInfo = '';
|
|
@@ -1519,7 +2959,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1519
2959
|
return {
|
|
1520
2960
|
content: [{
|
|
1521
2961
|
type: "text",
|
|
1522
|
-
text: `📊 get_article_structure(${paramLabel})\n\n全ブロック情報 (${blocks.length}件):\n\n${blockList}${paginationInfo}${_modeTag_gas}`,
|
|
2962
|
+
text: `📊 get_article_structure(${paramLabel})\n\n全ブロック情報 (${blocks.length}件):\n\n${blockList}${paginationInfo}${_snapshotLine}${_modeTag_gas}`,
|
|
1523
2963
|
}],
|
|
1524
2964
|
};
|
|
1525
2965
|
}
|
|
@@ -1556,6 +2996,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1556
2996
|
type: block.type,
|
|
1557
2997
|
section: block.section,
|
|
1558
2998
|
depth: block.depth,
|
|
2999
|
+
parentIndex: block.parentIndex,
|
|
3000
|
+
isContainer: block.isContainer,
|
|
3001
|
+
childCount: block.childCount,
|
|
1559
3002
|
preview: preview
|
|
1560
3003
|
});
|
|
1561
3004
|
}
|
|
@@ -1572,9 +3015,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1572
3015
|
|
|
1573
3016
|
const matchList = matches
|
|
1574
3017
|
.map(m => {
|
|
1575
|
-
|
|
3018
|
+
const t = formatTreeLine(m);
|
|
3019
|
+
let line = `${t.indent}index ${m.index}${_refTag(m.index, ' ')}: ${t.marker}${m.type}${t.childInfo} - ${m.section || "(記事冒頭)"} ${t.depthInfo}`;
|
|
1576
3020
|
if (m.preview) {
|
|
1577
|
-
line += `\n
|
|
3021
|
+
line += `\n${t.indent} "${m.preview}"`;
|
|
1578
3022
|
}
|
|
1579
3023
|
return line;
|
|
1580
3024
|
})
|
|
@@ -1583,7 +3027,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1583
3027
|
return {
|
|
1584
3028
|
content: [{
|
|
1585
3029
|
type: "text",
|
|
1586
|
-
text: `📊 get_article_structure(${paramLabel})\n\n"${contains}" を含むブロック (${matches.length}個):\n\n${matchList}${_modeTag_gas}`,
|
|
3030
|
+
text: `📊 get_article_structure(${paramLabel})\n\n"${contains}" を含むブロック (${matches.length}個):\n\n${matchList}${_snapshotLine}${_modeTag_gas}`,
|
|
1587
3031
|
}],
|
|
1588
3032
|
};
|
|
1589
3033
|
}
|
|
@@ -1613,9 +3057,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1613
3057
|
|
|
1614
3058
|
const matchList = (searchResult.matches || [])
|
|
1615
3059
|
.map(m => {
|
|
1616
|
-
|
|
3060
|
+
const t = formatTreeLine(m);
|
|
3061
|
+
let line = `${t.indent}index ${m.index}${_refTag(m.index, ' ')}: ${t.marker}${m.type}${t.childInfo} - ${m.section || "(記事冒頭)"} ${t.depthInfo}`;
|
|
1617
3062
|
if (m.preview) {
|
|
1618
|
-
line += `\n
|
|
3063
|
+
line += `\n${t.indent} "${m.preview}"`;
|
|
1619
3064
|
}
|
|
1620
3065
|
return line;
|
|
1621
3066
|
})
|
|
@@ -1624,7 +3069,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1624
3069
|
return {
|
|
1625
3070
|
content: [{
|
|
1626
3071
|
type: "text",
|
|
1627
|
-
text: `📊 get_article_structure(${paramLabel})\n\n"${contains}" を含むブロック (${(searchResult.matches || []).length}個):\n\n${matchList}${_modeTag_gas}`,
|
|
3072
|
+
text: `📊 get_article_structure(${paramLabel})\n\n"${contains}" を含むブロック (${(searchResult.matches || []).length}個):\n\n${matchList}${_snapshotLine}${_modeTag_gas}`,
|
|
1628
3073
|
}],
|
|
1629
3074
|
};
|
|
1630
3075
|
}
|
|
@@ -1698,12 +3143,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1698
3143
|
}
|
|
1699
3144
|
|
|
1700
3145
|
const blockList = filteredBlocks
|
|
1701
|
-
.map(b =>
|
|
3146
|
+
.map(b => {
|
|
3147
|
+
const t = formatTreeLine(b);
|
|
3148
|
+
return `${t.indent}index ${b.index}${_refTag(b.index, ' ')}: ${t.marker}${blockType}${t.childInfo} ${t.depthInfo}`;
|
|
3149
|
+
})
|
|
1702
3150
|
.join("\n");
|
|
1703
3151
|
return {
|
|
1704
3152
|
content: [{
|
|
1705
3153
|
type: "text",
|
|
1706
|
-
text: `📊 get_article_structure(${paramLabel})\n\n${sectionHeading.text} セクション内の ${blockType} (${filteredBlocks.length}個):\n\n${blockList}${_modeTag_gas}`,
|
|
3154
|
+
text: `📊 get_article_structure(${paramLabel})\n\n${sectionHeading.text} セクション内の ${blockType} (${filteredBlocks.length}個):\n\n${blockList}${_snapshotLine}${_modeTag_gas}`,
|
|
1707
3155
|
}],
|
|
1708
3156
|
};
|
|
1709
3157
|
}
|
|
@@ -1723,19 +3171,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1723
3171
|
const typeData = state.blockSummary[blockType];
|
|
1724
3172
|
const blockList = typeData.blocks
|
|
1725
3173
|
.map(b => {
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
info += ` [${b.tabNames.join(", ")}]`;
|
|
1729
|
-
} else if (b.columnCount) {
|
|
1730
|
-
info += ` [${b.columnCount}カラム]`;
|
|
1731
|
-
}
|
|
1732
|
-
return info;
|
|
3174
|
+
const t = formatTreeLine(b);
|
|
3175
|
+
return `${t.indent}index ${b.index}${_refTag(b.index, ' ')}: ${t.marker}${b.section || "(記事冒頭)"} (H${b.sectionLevel || "-"})${t.childInfo} ${t.depthInfo}`;
|
|
1733
3176
|
})
|
|
1734
3177
|
.join("\n");
|
|
1735
3178
|
return {
|
|
1736
3179
|
content: [{
|
|
1737
3180
|
type: "text",
|
|
1738
|
-
text: `📊 get_article_structure(${paramLabel})\n\n${blockType} の一覧 (${typeData.count}個):\n\n${blockList}${_modeTag_gas}`,
|
|
3181
|
+
text: `📊 get_article_structure(${paramLabel})\n\n${blockType} の一覧 (${typeData.count}個):\n\n${blockList}${_snapshotLine}${_modeTag_gas}`,
|
|
1739
3182
|
}],
|
|
1740
3183
|
};
|
|
1741
3184
|
}
|
|
@@ -1793,20 +3236,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1793
3236
|
|
|
1794
3237
|
const blockList = sectionBlocks
|
|
1795
3238
|
.map(b => {
|
|
1796
|
-
const
|
|
1797
|
-
|
|
1798
|
-
let info = '';
|
|
1799
|
-
if (b.isContainer) {
|
|
1800
|
-
marker = '▼ ';
|
|
1801
|
-
if (b.columnCount) {
|
|
1802
|
-
info = ` [${b.columnCount}カラム]`;
|
|
1803
|
-
} else if (b.tabNames && b.tabNames.length > 0) {
|
|
1804
|
-
info = ` [${b.tabNames.join(", ")}]`;
|
|
1805
|
-
} else if (b.childCount) {
|
|
1806
|
-
info = ` [${b.childCount}子]`;
|
|
1807
|
-
}
|
|
1808
|
-
}
|
|
1809
|
-
return `${indent}${b.index}: ${marker}${b.type}${info} (d:${b.depth || 0})`;
|
|
3239
|
+
const t = formatTreeLine(b);
|
|
3240
|
+
return `${t.indent}${b.index}${_refTag(b.index, ' ')}: ${t.marker}${b.type}${t.childInfo} ${t.depthInfo}`;
|
|
1810
3241
|
})
|
|
1811
3242
|
.join("\n");
|
|
1812
3243
|
|
|
@@ -1817,7 +3248,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1817
3248
|
return {
|
|
1818
3249
|
content: [{
|
|
1819
3250
|
type: "text",
|
|
1820
|
-
text: `📊 get_article_structure(${paramLabel})\n\n${sectionHeading.text} セクション (index:${sectionHeading.index}):\n\n${blockList}${nextInfo}${_modeTag_gas}`,
|
|
3251
|
+
text: `📊 get_article_structure(${paramLabel})\n\n${sectionHeading.text} セクション (index:${sectionHeading.index}):\n\n${blockList}${nextInfo}${_snapshotLine}${_modeTag_gas}`,
|
|
1821
3252
|
}],
|
|
1822
3253
|
};
|
|
1823
3254
|
}
|
|
@@ -1836,12 +3267,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1836
3267
|
};
|
|
1837
3268
|
}
|
|
1838
3269
|
const headingList = filteredHeadings
|
|
1839
|
-
.map(h => ` ${"#".repeat(h.level)} ${h.text} (index:${h.index})`)
|
|
3270
|
+
.map(h => ` ${"#".repeat(h.level)} ${h.text} (index:${h.index}${_refTag(h.index)})`)
|
|
1840
3271
|
.join("\n");
|
|
1841
3272
|
return {
|
|
1842
3273
|
content: [{
|
|
1843
3274
|
type: "text",
|
|
1844
|
-
text: `📊 get_article_structure(${paramLabel})\n\nH${headingLevel}見出し一覧 (${filteredHeadings.length}個):\n\n${headingList}${_modeTag_gas}`,
|
|
3275
|
+
text: `📊 get_article_structure(${paramLabel})\n\nH${headingLevel}見出し一覧 (${filteredHeadings.length}個):\n\n${headingList}${_snapshotLine}${_modeTag_gas}`,
|
|
1845
3276
|
}],
|
|
1846
3277
|
};
|
|
1847
3278
|
}
|
|
@@ -1854,11 +3285,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1854
3285
|
for (const heading of state.headings) {
|
|
1855
3286
|
const indent = heading.level === 2 ? "" : " ";
|
|
1856
3287
|
const prefix = "#".repeat(heading.level);
|
|
1857
|
-
output += `${indent}${prefix} ${heading.text} (H${heading.level}, index:${heading.index})\n`;
|
|
3288
|
+
output += `${indent}${prefix} ${heading.text} (H${heading.level}, index:${heading.index}${_refTag(heading.index)})\n`;
|
|
1858
3289
|
}
|
|
1859
3290
|
|
|
1860
3291
|
return {
|
|
1861
|
-
content: [{ type: "text", text: output + _modeTag_gas }],
|
|
3292
|
+
content: [{ type: "text", text: output + _snapshotLine + _modeTag_gas }],
|
|
1862
3293
|
};
|
|
1863
3294
|
}
|
|
1864
3295
|
|
|
@@ -1916,7 +3347,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1916
3347
|
}
|
|
1917
3348
|
|
|
1918
3349
|
case "delete_block": {
|
|
1919
|
-
let { mode, postId: _postId, sessionId: _sessionId, message, client } = await resolveMode(args, name);
|
|
3350
|
+
let { mode, postId: _postId, sessionId: _sessionId, message, client, siteName } = await resolveMode(args, name);
|
|
1920
3351
|
if (mode === 'error') {
|
|
1921
3352
|
return errorResponse(name, message, args?.site);
|
|
1922
3353
|
}
|
|
@@ -1925,13 +3356,35 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1925
3356
|
if (_resolved.editorConnected) mode = 'editor';
|
|
1926
3357
|
const postId = _resolved.postId ?? _postId;
|
|
1927
3358
|
const _mt_db = process.env.FRIDAY_DEBUG === '1' ? `\n[DEBUG] mode=${mode}` : '';
|
|
3359
|
+
const _siteName = siteName || args?.site || 'default';
|
|
1928
3360
|
|
|
1929
3361
|
if (mode === 'headless') {
|
|
1930
3362
|
const _guard = await guardHeadlessConflict(postId, client, name);
|
|
1931
3363
|
if (_guard) return _guard;
|
|
1932
3364
|
}
|
|
1933
3365
|
|
|
1934
|
-
|
|
3366
|
+
// ref/refs と index/indices 排他チェック
|
|
3367
|
+
if ((args?.ref || args?.refs) && (args?.index !== undefined || args?.indices !== undefined)) {
|
|
3368
|
+
return { content: [{ type: "text", text: "❌ ref/refs と index/indices は同時に指定できません。" }], isError: true };
|
|
3369
|
+
}
|
|
3370
|
+
|
|
3371
|
+
// ref 解決 + revision チェック
|
|
3372
|
+
const resolved = await resolveRefsAndCheckRevision({
|
|
3373
|
+
snapshotId: args?.snapshotId, ref: args?.ref, refs: args?.refs,
|
|
3374
|
+
expectedRevision: args?.expectedRevision,
|
|
3375
|
+
mode, client, postId, sessionId: _sessionId, siteName: _siteName,
|
|
3376
|
+
});
|
|
3377
|
+
if (resolved.error) return resolved.error;
|
|
3378
|
+
const _preState = resolved.currentState || null; // Phase 2: 差分計算用
|
|
3379
|
+
|
|
3380
|
+
let { index, count, indices } = args;
|
|
3381
|
+
if (resolved.index !== undefined) { index = resolved.index; count = count || 1; }
|
|
3382
|
+
if (resolved.indices !== undefined) { indices = resolved.indices; }
|
|
3383
|
+
|
|
3384
|
+
// Phase 3: 入力 ref を保持(ref + count > 1 は差分化できないので Legacy)
|
|
3385
|
+
const _inputRefs = args?.refs ? [...new Set(args.refs)]
|
|
3386
|
+
: (args?.ref && (count || 1) <= 1) ? [args.ref]
|
|
3387
|
+
: null;
|
|
1935
3388
|
|
|
1936
3389
|
// indices と index/count の排他バリデーション
|
|
1937
3390
|
if (indices !== undefined && (index !== undefined || count !== undefined)) {
|
|
@@ -1953,7 +3406,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1953
3406
|
try {
|
|
1954
3407
|
const result = await client.headlessDeleteMultiple(postId, uniqueIndices);
|
|
1955
3408
|
const details = result.deleted.map(d => ` [${d.index}] ${d.type}`).join('\n');
|
|
1956
|
-
|
|
3409
|
+
const _snap = await buildResponseSnapshot(mode, client, postId, _sessionId, _siteName, result);
|
|
3410
|
+
const _msg = `✅ ${result.count}ブロック削除\n\n${details}${_mt_db}`;
|
|
3411
|
+
if (_inputRefs) {
|
|
3412
|
+
const changeInfo = buildChangeInfoFromResult('deleted', _snap, result, _preState, { inputRefs: _inputRefs });
|
|
3413
|
+
if (changeInfo) return { content: [{ type: "text", text: appendRefChangesToText(_msg, changeInfo) }] };
|
|
3414
|
+
}
|
|
3415
|
+
return { content: [{ type: "text", text: appendSnapshotToTextLegacy(_msg, _snap) }] };
|
|
1957
3416
|
} catch (e) {
|
|
1958
3417
|
const formatted = formatHeadlessConflictError(e);
|
|
1959
3418
|
if (formatted) return formatted;
|
|
@@ -1967,7 +3426,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1967
3426
|
if (!result.success)
|
|
1968
3427
|
return errorResponse(name, result.error, args?.site);
|
|
1969
3428
|
const details = result.deleted.map(d => ` [${d.index}] ${d.type}`).join('\n');
|
|
1970
|
-
|
|
3429
|
+
const _snap = await buildResponseSnapshot(mode, client, postId, _sessionId, _siteName, result);
|
|
3430
|
+
const _msg = `✅ ${result.count}ブロック削除\n\n${details}${_mt_db}`;
|
|
3431
|
+
if (_inputRefs) {
|
|
3432
|
+
const changeInfo = buildChangeInfoFromResult('deleted', _snap, result, _preState, { inputRefs: _inputRefs });
|
|
3433
|
+
if (changeInfo) return { content: [{ type: "text", text: appendRefChangesToText(_msg, changeInfo) }] };
|
|
3434
|
+
}
|
|
3435
|
+
return { content: [{ type: "text", text: appendSnapshotToTextLegacy(_msg, _snap) }] };
|
|
1971
3436
|
}
|
|
1972
3437
|
|
|
1973
3438
|
// 既存モード(index + count)
|
|
@@ -1978,7 +3443,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1978
3443
|
try {
|
|
1979
3444
|
const result = await client.headlessDelete(postId, index, count || 1);
|
|
1980
3445
|
const details = result.deleted.map(d => ` [${d.index}] ${d.type}`).join('\n');
|
|
1981
|
-
|
|
3446
|
+
const _snap = await buildResponseSnapshot(mode, client, postId, _sessionId, _siteName, result);
|
|
3447
|
+
const _msg = `✅ ${result.count}ブロック削除\n\n${details}${_mt_db}`;
|
|
3448
|
+
if (_inputRefs) {
|
|
3449
|
+
const changeInfo = buildChangeInfoFromResult('deleted', _snap, result, _preState, { inputRefs: _inputRefs });
|
|
3450
|
+
if (changeInfo) return { content: [{ type: "text", text: appendRefChangesToText(_msg, changeInfo) }] };
|
|
3451
|
+
}
|
|
3452
|
+
return { content: [{ type: "text", text: appendSnapshotToTextLegacy(_msg, _snap) }] };
|
|
1982
3453
|
} catch (e) {
|
|
1983
3454
|
const formatted = formatHeadlessConflictError(e);
|
|
1984
3455
|
if (formatted) return formatted;
|
|
@@ -1992,11 +3463,17 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1992
3463
|
if (!result.success)
|
|
1993
3464
|
return errorResponse(name, result.error, args?.site);
|
|
1994
3465
|
const details = result.deleted.map(d => ` [${d.index}] ${d.type}`).join('\n');
|
|
1995
|
-
|
|
3466
|
+
const _snap = await buildResponseSnapshot(mode, client, postId, _sessionId, _siteName, result);
|
|
3467
|
+
const _msg = `✅ ${result.count}ブロック削除\n\n${details}${_mt_db}`;
|
|
3468
|
+
if (_inputRefs) {
|
|
3469
|
+
const changeInfo = buildChangeInfoFromResult('deleted', _snap, result, _preState, { inputRefs: _inputRefs });
|
|
3470
|
+
if (changeInfo) return { content: [{ type: "text", text: appendRefChangesToText(_msg, changeInfo) }] };
|
|
3471
|
+
}
|
|
3472
|
+
return { content: [{ type: "text", text: appendSnapshotToTextLegacy(_msg, _snap) }] };
|
|
1996
3473
|
}
|
|
1997
3474
|
|
|
1998
3475
|
case "move_block": {
|
|
1999
|
-
let { mode, postId: _postId, sessionId: _sessionId, message, client } = await resolveMode(args, name);
|
|
3476
|
+
let { mode, postId: _postId, sessionId: _sessionId, message, client, siteName } = await resolveMode(args, name);
|
|
2000
3477
|
if (mode === 'error') {
|
|
2001
3478
|
return errorResponse(name, message, args?.site);
|
|
2002
3479
|
}
|
|
@@ -2005,13 +3482,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2005
3482
|
if (_resolved.editorConnected) mode = 'editor';
|
|
2006
3483
|
const postId = _resolved.postId ?? _postId;
|
|
2007
3484
|
const _mt_mb = process.env.FRIDAY_DEBUG === '1' ? `\n[DEBUG] mode=${mode}` : '';
|
|
3485
|
+
const _siteName = siteName || args?.site || 'default';
|
|
2008
3486
|
|
|
2009
3487
|
if (mode === 'headless') {
|
|
2010
3488
|
const _guard = await guardHeadlessConflict(postId, client, name);
|
|
2011
3489
|
if (_guard) return _guard;
|
|
2012
3490
|
}
|
|
2013
3491
|
|
|
2014
|
-
const { from, to
|
|
3492
|
+
const { from, to } = args;
|
|
3493
|
+
let { fromFlat, toFlat } = args;
|
|
2015
3494
|
const count = args.count ?? 1;
|
|
2016
3495
|
|
|
2017
3496
|
// count バリデーション
|
|
@@ -2019,16 +3498,71 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2019
3498
|
return { content: [{ type: "text", text: "❌ count は1以上の整数を指定してください。" }], isError: true };
|
|
2020
3499
|
}
|
|
2021
3500
|
|
|
2022
|
-
//
|
|
3501
|
+
// 3 モード排他バリデーション
|
|
2023
3502
|
const hasTopLevel = from !== undefined || to !== undefined;
|
|
2024
3503
|
const hasFlat = fromFlat !== undefined || toFlat !== undefined;
|
|
2025
|
-
|
|
2026
|
-
|
|
3504
|
+
const hasRef = args?.fromRef !== undefined || args?.beforeRef !== undefined || args?.afterRef !== undefined;
|
|
3505
|
+
const _modeCount = [hasTopLevel, hasFlat, hasRef].filter(Boolean).length;
|
|
3506
|
+
|
|
3507
|
+
if (_modeCount > 1) {
|
|
3508
|
+
return { content: [{ type: "text", text: "❌ from/to, fromFlat/toFlat, ref(fromRef/beforeRef/afterRef) は同時に指定できません。" }], isError: true };
|
|
3509
|
+
}
|
|
3510
|
+
if (_modeCount === 0) {
|
|
3511
|
+
return { content: [{ type: "text", text: "❌ from/to, fromFlat/toFlat, または fromRef+beforeRef/afterRef を指定してください。" }], isError: true };
|
|
3512
|
+
}
|
|
3513
|
+
|
|
3514
|
+
// expectedRevision チェック(全モード共通)
|
|
3515
|
+
// ref モード以外でも expectedRevision が指定されていれば revision チェックを実施
|
|
3516
|
+
let _preState = null; // Phase 2: 差分計算用
|
|
3517
|
+
if (args?.expectedRevision && !hasRef) {
|
|
3518
|
+
const { currentState, error: _revError } = await acquireFreshState({
|
|
3519
|
+
expectedRevision: args.expectedRevision,
|
|
3520
|
+
mode, client, postId, sessionId: _sessionId, siteName: _siteName,
|
|
3521
|
+
});
|
|
3522
|
+
if (_revError) return _revError;
|
|
3523
|
+
_preState = currentState || null;
|
|
2027
3524
|
}
|
|
2028
|
-
|
|
2029
|
-
|
|
3525
|
+
|
|
3526
|
+
// ref モード: fromRef + beforeRef/afterRef
|
|
3527
|
+
if (hasRef) {
|
|
3528
|
+
if (!args.fromRef) {
|
|
3529
|
+
return { content: [{ type: "text", text: "❌ ref モードでは fromRef が必須です。" }], isError: true };
|
|
3530
|
+
}
|
|
3531
|
+
if (!args.beforeRef && !args.afterRef) {
|
|
3532
|
+
return { content: [{ type: "text", text: "❌ ref モードでは beforeRef または afterRef が必須です。" }], isError: true };
|
|
3533
|
+
}
|
|
3534
|
+
if (args.beforeRef && args.afterRef) {
|
|
3535
|
+
return { content: [{ type: "text", text: "❌ beforeRef と afterRef は同時に指定できません。" }], isError: true };
|
|
3536
|
+
}
|
|
3537
|
+
if (!args.snapshotId) {
|
|
3538
|
+
return { content: [{ type: "text", text: "❌ ref を使用するには snapshotId が必要です。get_article_structure で取得してください。" }], isError: true };
|
|
3539
|
+
}
|
|
3540
|
+
|
|
3541
|
+
// acquireFreshState で 1 回だけ state 取得 + revision チェック
|
|
3542
|
+
const { currentState, error: _stateError } = await acquireFreshState({
|
|
3543
|
+
expectedRevision: args.expectedRevision,
|
|
3544
|
+
mode, client, postId, sessionId: _sessionId, siteName: _siteName,
|
|
3545
|
+
});
|
|
3546
|
+
if (_stateError) return _stateError;
|
|
3547
|
+
_preState = currentState || null; // Phase 2: 差分計算用
|
|
3548
|
+
|
|
3549
|
+
// ref 解決(同じ currentState で 2 つの ref を解決)
|
|
3550
|
+
try {
|
|
3551
|
+
fromFlat = resolveRefFromState(args.snapshotId, args.fromRef, mode, _sessionId, postId, currentState);
|
|
3552
|
+
const destRef = args.beforeRef || args.afterRef;
|
|
3553
|
+
const resolvedDest = resolveRefFromState(args.snapshotId, destRef, mode, _sessionId, postId, currentState);
|
|
3554
|
+
// 位置計算: beforeRef → そのまま, afterRef → +1
|
|
3555
|
+
toFlat = args.beforeRef ? resolvedDest : resolvedDest + 1;
|
|
3556
|
+
} catch (e) {
|
|
3557
|
+
return { content: [{ type: "text", text: `❌ ref 解決エラー: ${e.message}` }], isError: true };
|
|
3558
|
+
}
|
|
3559
|
+
|
|
3560
|
+
// ref → flat に変換完了。以降 flat モードに合流
|
|
2030
3561
|
}
|
|
2031
3562
|
|
|
3563
|
+
// Phase 4: 入力 ref 保持(ref モード + count <= 1 のみ差分化)
|
|
3564
|
+
const _inputRefs = (hasRef && count <= 1) ? [args.fromRef] : null;
|
|
3565
|
+
|
|
2032
3566
|
// 移動結果のレスポンス生成ヘルパー
|
|
2033
3567
|
const moveMsg = (moved) => {
|
|
2034
3568
|
if (moved?.noop) return `✅ 移動不要(同じ位置)${_mt_mb}`;
|
|
@@ -2039,8 +3573,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2039
3573
|
: `✅ ブロック移動 (${typeLabel})${_mt_mb}`;
|
|
2040
3574
|
};
|
|
2041
3575
|
|
|
2042
|
-
// fromFlat/toFlat
|
|
2043
|
-
if (hasFlat) {
|
|
3576
|
+
// fromFlat/toFlat モード(フラットインデックス移動)— ref モードもここに合流
|
|
3577
|
+
if (hasFlat || hasRef) {
|
|
2044
3578
|
if (fromFlat === undefined || toFlat === undefined) {
|
|
2045
3579
|
return { content: [{ type: "text", text: "❌ fromFlat と toFlat の両方を指定してください。" }], isError: true };
|
|
2046
3580
|
}
|
|
@@ -2051,7 +3585,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2051
3585
|
if (mode === 'headless') {
|
|
2052
3586
|
try {
|
|
2053
3587
|
const result = await client.headlessMoveFlat(postId, fromFlat, toFlat, count);
|
|
2054
|
-
|
|
3588
|
+
const _snap = await buildResponseSnapshot(mode, client, postId, _sessionId, _siteName, result);
|
|
3589
|
+
const _msg = moveMsg(result.moved);
|
|
3590
|
+
if (_inputRefs) {
|
|
3591
|
+
const changeInfo = buildChangeInfoFromResult('moved', _snap, result, _preState, { inputRefs: _inputRefs });
|
|
3592
|
+
if (changeInfo) return { content: [{ type: "text", text: appendRefChangesToText(_msg, changeInfo) }] };
|
|
3593
|
+
}
|
|
3594
|
+
return { content: [{ type: "text", text: appendSnapshotToTextLegacy(_msg, _snap) }] };
|
|
2055
3595
|
} catch (e) {
|
|
2056
3596
|
const formatted = formatHeadlessConflictError(e);
|
|
2057
3597
|
if (formatted) return formatted;
|
|
@@ -2064,7 +3604,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2064
3604
|
return timeoutResponse(name, client, args?.site);
|
|
2065
3605
|
if (!result.success)
|
|
2066
3606
|
return errorResponse(name, result.error, args?.site);
|
|
2067
|
-
|
|
3607
|
+
const _snap = await buildResponseSnapshot(mode, client, postId, _sessionId, _siteName, result);
|
|
3608
|
+
const _msg = moveMsg(result.moved);
|
|
3609
|
+
if (_inputRefs) {
|
|
3610
|
+
const changeInfo = buildChangeInfoFromResult('moved', _snap, result, _preState, { inputRefs: _inputRefs });
|
|
3611
|
+
if (changeInfo) return { content: [{ type: "text", text: appendRefChangesToText(_msg, changeInfo) }] };
|
|
3612
|
+
}
|
|
3613
|
+
return { content: [{ type: "text", text: appendSnapshotToTextLegacy(_msg, _snap) }] };
|
|
2068
3614
|
}
|
|
2069
3615
|
|
|
2070
3616
|
// 既存モード(from/to トップレベル)
|
|
@@ -2075,7 +3621,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2075
3621
|
if (mode === 'headless') {
|
|
2076
3622
|
try {
|
|
2077
3623
|
const result = await client.headlessMove(postId, from, to, count);
|
|
2078
|
-
|
|
3624
|
+
const _snap = await buildResponseSnapshot(mode, client, postId, _sessionId, _siteName, result);
|
|
3625
|
+
return { content: [{ type: "text", text: appendSnapshotToTextLegacy(moveMsg(result.moved), _snap) }] };
|
|
2079
3626
|
} catch (e) {
|
|
2080
3627
|
const formatted = formatHeadlessConflictError(e);
|
|
2081
3628
|
if (formatted) return formatted;
|
|
@@ -2088,7 +3635,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2088
3635
|
return timeoutResponse(name, client, args?.site);
|
|
2089
3636
|
if (!result.success)
|
|
2090
3637
|
return errorResponse(name, result.error, args?.site);
|
|
2091
|
-
|
|
3638
|
+
const _snap = await buildResponseSnapshot(mode, client, postId, _sessionId, _siteName, result);
|
|
3639
|
+
return { content: [{ type: "text", text: appendSnapshotToTextLegacy(moveMsg(result.moved), _snap) }] };
|
|
2092
3640
|
}
|
|
2093
3641
|
|
|
2094
3642
|
case "undo": {
|
|
@@ -2124,7 +3672,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2124
3672
|
}
|
|
2125
3673
|
|
|
2126
3674
|
case "duplicate_block": {
|
|
2127
|
-
let { mode, postId: _postId, sessionId: _sessionId, message, client } = await resolveMode(args, name);
|
|
3675
|
+
let { mode, postId: _postId, sessionId: _sessionId, message, client, siteName } = await resolveMode(args, name);
|
|
2128
3676
|
if (mode === 'error') {
|
|
2129
3677
|
return errorResponse(name, message, args?.site);
|
|
2130
3678
|
}
|
|
@@ -2133,8 +3681,26 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2133
3681
|
if (_resolved.editorConnected) mode = 'editor';
|
|
2134
3682
|
const postId = _resolved.postId ?? _postId;
|
|
2135
3683
|
const _mt_dup = process.env.FRIDAY_DEBUG === '1' ? `\n[DEBUG] mode=${mode}` : '';
|
|
3684
|
+
const _siteName = siteName || args?.site || 'default';
|
|
3685
|
+
|
|
3686
|
+
// ref と index 排他チェック
|
|
3687
|
+
if (args?.ref && args?.index !== undefined) {
|
|
3688
|
+
return { content: [{ type: "text", text: "❌ ref と index は同時に指定できません。" }], isError: true };
|
|
3689
|
+
}
|
|
2136
3690
|
|
|
2137
|
-
|
|
3691
|
+
// ref 解決 + revision チェック
|
|
3692
|
+
const resolved = await resolveRefsAndCheckRevision({
|
|
3693
|
+
snapshotId: args?.snapshotId, ref: args?.ref,
|
|
3694
|
+
expectedRevision: args?.expectedRevision,
|
|
3695
|
+
mode, client, postId, sessionId: _sessionId, siteName: _siteName,
|
|
3696
|
+
});
|
|
3697
|
+
if (resolved.error) return resolved.error;
|
|
3698
|
+
const _preState = resolved.currentState || null; // Phase 2: 差分計算用
|
|
3699
|
+
|
|
3700
|
+
const index = resolved.index !== undefined ? resolved.index : args?.index;
|
|
3701
|
+
|
|
3702
|
+
// Phase 3: 入力 ref を保持(index 経路は差分化しない)
|
|
3703
|
+
const _inputRef = args?.ref || null;
|
|
2138
3704
|
|
|
2139
3705
|
if (mode === 'headless') {
|
|
2140
3706
|
const _guard = await guardHeadlessConflict(postId, client, name);
|
|
@@ -2144,7 +3710,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2144
3710
|
}
|
|
2145
3711
|
try {
|
|
2146
3712
|
const result = await client.headlessDuplicate(postId, index);
|
|
2147
|
-
|
|
3713
|
+
const _snap = await buildResponseSnapshot(mode, client, postId, _sessionId, _siteName, result);
|
|
3714
|
+
const _msg = `✅ ブロック複製完了 (index ${result.duplicated.index} → ${result.duplicated.newIndex})${_mt_dup}`;
|
|
3715
|
+
if (_inputRef) {
|
|
3716
|
+
const changeInfo = buildChangeInfoFromResult('duplicated', _snap, result, _preState, { inputRef: _inputRef });
|
|
3717
|
+
if (changeInfo) return { content: [{ type: "text", text: appendRefChangesToText(_msg, changeInfo) }] };
|
|
3718
|
+
}
|
|
3719
|
+
return { content: [{ type: "text", text: appendSnapshotToTextLegacy(_msg, _snap) }] };
|
|
2148
3720
|
} catch (e) {
|
|
2149
3721
|
const formatted = formatHeadlessConflictError(e);
|
|
2150
3722
|
if (formatted) return formatted;
|
|
@@ -2157,7 +3729,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2157
3729
|
return timeoutResponse(name, client, args?.site);
|
|
2158
3730
|
if (!result.success)
|
|
2159
3731
|
return errorResponse(name, result.error, args?.site);
|
|
2160
|
-
|
|
3732
|
+
const _snap = await buildResponseSnapshot(mode, client, postId, _sessionId, _siteName, result);
|
|
3733
|
+
const _msg = `✅ ブロック複製完了${_mt_dup}`;
|
|
3734
|
+
if (_inputRef) {
|
|
3735
|
+
const changeInfo = buildChangeInfoFromResult('duplicated', _snap, result, _preState, { inputRef: _inputRef });
|
|
3736
|
+
if (changeInfo) return { content: [{ type: "text", text: appendRefChangesToText(_msg, changeInfo) }] };
|
|
3737
|
+
}
|
|
3738
|
+
return { content: [{ type: "text", text: appendSnapshotToTextLegacy(_msg, _snap) }] };
|
|
2161
3739
|
}
|
|
2162
3740
|
|
|
2163
3741
|
case "save_post": {
|
|
@@ -2362,13 +3940,38 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2362
3940
|
}
|
|
2363
3941
|
|
|
2364
3942
|
case "insert_block": {
|
|
2365
|
-
let { rawHTML, filePath, index } = (args || {});
|
|
3943
|
+
let { rawHTML, filePath, index, position } = (args || {});
|
|
3944
|
+
|
|
3945
|
+
// parentIndex は廃止済み(v3.0.0 Phase 5)— position を使用
|
|
3946
|
+
if (args?.parentIndex !== undefined) {
|
|
3947
|
+
return { content: [{ type: "text", text: "❌ parentIndex は廃止されました。代わりに index + position ('before'/'after') を使用してください。" }], isError: true };
|
|
3948
|
+
}
|
|
2366
3949
|
|
|
2367
3950
|
// 排他チェック
|
|
2368
3951
|
if (rawHTML && filePath) {
|
|
2369
3952
|
return { content: [{ type: "text", text: "❌ rawHTML と filePath は同時に指定できません。どちらか一方を指定してください。" }], isError: true };
|
|
2370
3953
|
}
|
|
2371
3954
|
|
|
3955
|
+
// Phase 4b: beforeRef/afterRef と index/position の排他チェック
|
|
3956
|
+
const _hasRefPosition = args?.beforeRef !== undefined || args?.afterRef !== undefined;
|
|
3957
|
+
const _hasIndexPosition = index !== undefined || position !== undefined;
|
|
3958
|
+
if (_hasRefPosition && _hasIndexPosition) {
|
|
3959
|
+
return { content: [{ type: "text", text: "❌ beforeRef/afterRef と index/position は同時に指定できません。" }], isError: true };
|
|
3960
|
+
}
|
|
3961
|
+
if (args?.beforeRef && args?.afterRef) {
|
|
3962
|
+
return { content: [{ type: "text", text: "❌ beforeRef と afterRef は同時に指定できません。" }], isError: true };
|
|
3963
|
+
}
|
|
3964
|
+
|
|
3965
|
+
// position 指定時は index 必須(ref モードでないとき)
|
|
3966
|
+
if (!_hasRefPosition && position !== undefined && index === undefined) {
|
|
3967
|
+
return { content: [{ type: "text", text: "❌ position を指定する場合は index も指定してください。末尾追加は index を省略してください。" }], isError: true };
|
|
3968
|
+
}
|
|
3969
|
+
|
|
3970
|
+
// position バリデーション
|
|
3971
|
+
if (position !== undefined && position !== "before" && position !== "after") {
|
|
3972
|
+
return { content: [{ type: "text", text: `❌ position は 'before' または 'after' を指定してください: ${position}` }], isError: true };
|
|
3973
|
+
}
|
|
3974
|
+
|
|
2372
3975
|
// filePath → rawHTML 解決
|
|
2373
3976
|
if (filePath) {
|
|
2374
3977
|
try { rawHTML = readHTMLFromFile(filePath).html; }
|
|
@@ -2378,21 +3981,39 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2378
3981
|
return { content: [{ type: "text", text: "❌ rawHTML または filePath を指定してください。Gutenberg HTML 形式のブロックマークアップが必要です。" }], isError: true };
|
|
2379
3982
|
}
|
|
2380
3983
|
|
|
2381
|
-
// index バリデーション(現行 Editor 互換: 整数 >= 0
|
|
2382
|
-
if (index !== undefined && (!Number.isInteger(index) || index < 0)) {
|
|
3984
|
+
// index バリデーション(現行 Editor 互換: 整数 >= 0)— ref モードでないとき
|
|
3985
|
+
if (!_hasRefPosition && index !== undefined && (!Number.isInteger(index) || index < 0)) {
|
|
2383
3986
|
return { content: [{ type: "text", text: `❌ Invalid index: ${index}` }], isError: true };
|
|
2384
3987
|
}
|
|
2385
3988
|
|
|
2386
|
-
// update_blocks
|
|
3989
|
+
// update_blocks コードパスに委譲
|
|
3990
|
+
// expectedRevision は全モードで委譲(ref/index/append 問わず)
|
|
2387
3991
|
const delegatedArgs = {
|
|
2388
3992
|
postId: args.postId,
|
|
2389
3993
|
site: args.site,
|
|
2390
3994
|
newHTML: rawHTML,
|
|
2391
|
-
insert: { position: 'before' },
|
|
2392
3995
|
_fromInsertBlock: true,
|
|
2393
|
-
|
|
3996
|
+
expectedRevision: args.expectedRevision,
|
|
2394
3997
|
};
|
|
2395
|
-
|
|
3998
|
+
|
|
3999
|
+
if (_hasRefPosition) {
|
|
4000
|
+
// ref モード: target.ref + insert.position で委譲
|
|
4001
|
+
const _ref = args.beforeRef || args.afterRef;
|
|
4002
|
+
delegatedArgs.target = { ref: _ref };
|
|
4003
|
+
delegatedArgs.insert = { position: args.beforeRef ? 'before' : 'after' };
|
|
4004
|
+
delegatedArgs.snapshotId = args.snapshotId;
|
|
4005
|
+
delegatedArgs.appendToEnd = false;
|
|
4006
|
+
} else if (index !== undefined) {
|
|
4007
|
+
// 既存: index モード
|
|
4008
|
+
delegatedArgs.target = { index };
|
|
4009
|
+
delegatedArgs.insert = { position: position ?? 'before' };
|
|
4010
|
+
delegatedArgs.appendToEnd = false;
|
|
4011
|
+
} else {
|
|
4012
|
+
// 末尾追加
|
|
4013
|
+
delegatedArgs.insert = { position: 'before' };
|
|
4014
|
+
delegatedArgs.appendToEnd = true;
|
|
4015
|
+
}
|
|
4016
|
+
|
|
2396
4017
|
return await handleUpdateBlocksTool(delegatedArgs, name);
|
|
2397
4018
|
}
|
|
2398
4019
|
|
|
@@ -2468,7 +4089,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2468
4089
|
}
|
|
2469
4090
|
|
|
2470
4091
|
case "get_selection": {
|
|
2471
|
-
const { mode, postId: _postId, sessionId: _sessionId, message, client } = await resolveMode(args, name);
|
|
4092
|
+
const { mode, postId: _postId, sessionId: _sessionId, message, client, siteName } = await resolveMode(args, name);
|
|
2472
4093
|
if (mode === 'error') {
|
|
2473
4094
|
return errorResponse(name, message, args?.site);
|
|
2474
4095
|
}
|
|
@@ -2484,16 +4105,48 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2484
4105
|
content: [{ type: "text", text: `ブロックが選択されていません。${_mt_gs}` }],
|
|
2485
4106
|
};
|
|
2486
4107
|
}
|
|
4108
|
+
|
|
4109
|
+
// ref 対応: fresh state から snapshot を毎回新規作成
|
|
4110
|
+
// safetyCritical: true — stale Store へのフォールバックを防ぐ
|
|
4111
|
+
const _siteName_gs = siteName || args?.site || 'default';
|
|
4112
|
+
let _snapInfo = null;
|
|
4113
|
+
try {
|
|
4114
|
+
const freshState = await getCurrentStructure(mode, client, _postId, _sessionId, { safetyCritical: true });
|
|
4115
|
+
if (freshState?.allBlocks?.length > 0) {
|
|
4116
|
+
_snapInfo = buildSnapshotFromState(freshState, mode, _postId, _sessionId, _siteName_gs);
|
|
4117
|
+
}
|
|
4118
|
+
} catch (_e) {
|
|
4119
|
+
// fresh state 取得失敗 → ref なしで index-only にフォールバック
|
|
4120
|
+
}
|
|
4121
|
+
|
|
2487
4122
|
// 複数選択
|
|
2488
4123
|
if (sel.isMultiSelect && sel.blockIds) {
|
|
2489
4124
|
let text = `選択中: ${sel.blockIds.length}ブロック\n` +
|
|
2490
4125
|
`タイプ: ${sel.blockTypes?.join(", ")}\n` +
|
|
2491
|
-
`位置: ${sel.blockIndices?.join(", ")}
|
|
4126
|
+
`位置: ${sel.blockIndices?.join(", ")}`;
|
|
4127
|
+
if (_snapInfo && sel.blockIndices?.length > 0) {
|
|
4128
|
+
const refs = sel.blockIndices
|
|
4129
|
+
.map(idx => _snapInfo.blocks?.find(b => b.index === idx)?.ref)
|
|
4130
|
+
.filter(Boolean);
|
|
4131
|
+
// 全数一致のときだけ refs を返す(部分一致は AI を誤誘導するため)
|
|
4132
|
+
if (refs.length === sel.blockIndices.length) {
|
|
4133
|
+
text += `\nrefs: ${refs.join(", ")}`;
|
|
4134
|
+
text += `\n[snapshot:${_snapInfo.snapshotId} rev:${_snapInfo.revision}]`;
|
|
4135
|
+
}
|
|
4136
|
+
}
|
|
4137
|
+
text += _mt_gs;
|
|
2492
4138
|
return { content: [{ type: "text", text }] };
|
|
2493
4139
|
}
|
|
2494
4140
|
// 単一選択
|
|
2495
4141
|
let text = `選択中: ${sel.blockType}\n` +
|
|
2496
4142
|
`位置: index ${sel.blockIndex}`;
|
|
4143
|
+
if (_snapInfo) {
|
|
4144
|
+
const refEntry = _snapInfo.blocks?.find(b => b.index === sel.blockIndex);
|
|
4145
|
+
if (refEntry) {
|
|
4146
|
+
text += `\nref: ${refEntry.ref}`;
|
|
4147
|
+
text += `\n[snapshot:${_snapInfo.snapshotId} rev:${_snapInfo.revision}]`;
|
|
4148
|
+
}
|
|
4149
|
+
}
|
|
2497
4150
|
if (sel.textSelection?.text) {
|
|
2498
4151
|
text += `\n\nカーソル選択テキスト: "${sel.textSelection.text}"`;
|
|
2499
4152
|
if (sel.textSelection.context) {
|
|
@@ -2510,7 +4163,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2510
4163
|
}
|
|
2511
4164
|
|
|
2512
4165
|
case "table_operations": {
|
|
2513
|
-
let { mode, postId: _postId, sessionId: _sessionId, message, client } = await resolveMode(args, name);
|
|
4166
|
+
let { mode, postId: _postId, sessionId: _sessionId, message, client, siteName } = await resolveMode(args, name);
|
|
2514
4167
|
if (mode === 'error') {
|
|
2515
4168
|
return errorResponse(name, message, args?.site);
|
|
2516
4169
|
}
|
|
@@ -2518,15 +4171,33 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2518
4171
|
if (_resolved.error) return { content: [{ type: "text", text: _resolved.error }], isError: true };
|
|
2519
4172
|
if (_resolved.editorConnected) mode = 'editor';
|
|
2520
4173
|
const postId = _resolved.postId ?? _postId;
|
|
4174
|
+
const _siteName = siteName || args?.site || 'default';
|
|
2521
4175
|
|
|
2522
|
-
const {
|
|
2523
|
-
|
|
2524
|
-
return { content: [{ type: "text", text: "❌ index は必須です" }], isError: true };
|
|
2525
|
-
}
|
|
4176
|
+
const { action } = (args || {});
|
|
4177
|
+
const _inputRef = args?.ref || null;
|
|
2526
4178
|
if (!action) {
|
|
2527
4179
|
return { content: [{ type: "text", text: "❌ action は必須です" }], isError: true };
|
|
2528
4180
|
}
|
|
2529
4181
|
|
|
4182
|
+
// ref と index 排他チェック
|
|
4183
|
+
if (args?.ref && args?.index !== undefined) {
|
|
4184
|
+
return { content: [{ type: "text", text: "❌ ref と index は同時に指定できません。" }], isError: true };
|
|
4185
|
+
}
|
|
4186
|
+
|
|
4187
|
+
// ref 解決 + revision チェック
|
|
4188
|
+
const resolved = await resolveRefsAndCheckRevision({
|
|
4189
|
+
snapshotId: args?.snapshotId, ref: args?.ref,
|
|
4190
|
+
expectedRevision: args?.expectedRevision,
|
|
4191
|
+
mode, client, postId, sessionId: _sessionId, siteName: _siteName,
|
|
4192
|
+
});
|
|
4193
|
+
if (resolved.error) return resolved.error;
|
|
4194
|
+
const _preState = resolved.currentState || null; // Phase 2: 差分計算用
|
|
4195
|
+
|
|
4196
|
+
const index = resolved.index !== undefined ? resolved.index : args?.index;
|
|
4197
|
+
if (index === undefined) {
|
|
4198
|
+
return { content: [{ type: "text", text: "❌ table_operations では index または ref のいずれかが必要です。" }], isError: true };
|
|
4199
|
+
}
|
|
4200
|
+
|
|
2530
4201
|
const tableParams = {
|
|
2531
4202
|
index, action,
|
|
2532
4203
|
row: args.row, col: args.col,
|
|
@@ -2557,16 +4228,25 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2557
4228
|
return text;
|
|
2558
4229
|
};
|
|
2559
4230
|
|
|
4231
|
+
const _isWrite = action !== 'get_structure';
|
|
2560
4232
|
const _mt_to = process.env.FRIDAY_DEBUG === '1' ? `\n[DEBUG] mode=${mode}` : '';
|
|
2561
4233
|
if (mode === 'headless') {
|
|
2562
4234
|
const _guard = await guardHeadlessConflict(postId, client, name);
|
|
2563
4235
|
if (_guard) return _guard;
|
|
2564
4236
|
try {
|
|
2565
4237
|
const result = await client.headlessTableOperation(postId, tableParams);
|
|
2566
|
-
if (
|
|
4238
|
+
if (!_isWrite) {
|
|
2567
4239
|
return { content: [{ type: "text", text: formatStructure(result) + _mt_to }] };
|
|
2568
4240
|
}
|
|
2569
|
-
|
|
4241
|
+
const _snap = await buildResponseSnapshot(mode, client, postId, _sessionId, _siteName, result);
|
|
4242
|
+
const _msg = `✅ ${action} 完了: ${JSON.stringify(result.detail || result)}${_mt_to}`;
|
|
4243
|
+
if (_inputRef) {
|
|
4244
|
+
const changedIndex = result.targetIndex ?? index;
|
|
4245
|
+
const syntheticResult = { changes: [{ oldIndex: changedIndex, newIndices: [changedIndex], success: true }] };
|
|
4246
|
+
const changeInfo = buildChangeInfoFromResult('updated', _snap, syntheticResult, _preState);
|
|
4247
|
+
if (changeInfo) return { content: [{ type: "text", text: appendRefChangesToText(_msg, changeInfo) }] };
|
|
4248
|
+
}
|
|
4249
|
+
return { content: [{ type: "text", text: appendSnapshotToTextLegacy(_msg, _snap) }] };
|
|
2570
4250
|
} catch (e) {
|
|
2571
4251
|
const formatted = formatHeadlessConflictError(e);
|
|
2572
4252
|
if (formatted) return formatted;
|
|
@@ -2581,10 +4261,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2581
4261
|
if (!result.success)
|
|
2582
4262
|
return errorResponse(name, result.error, args?.site);
|
|
2583
4263
|
|
|
2584
|
-
if (
|
|
4264
|
+
if (!_isWrite) {
|
|
2585
4265
|
return { content: [{ type: "text", text: formatStructure(result.structure) + _mt_to }] };
|
|
2586
4266
|
}
|
|
2587
|
-
|
|
4267
|
+
const _snap = await buildResponseSnapshot(mode, client, postId, _sessionId, _siteName, result);
|
|
4268
|
+
const _msg = `✅ ${action} 完了: ${JSON.stringify(result.detail || {})}${_mt_to}`;
|
|
4269
|
+
if (_inputRef) {
|
|
4270
|
+
const changedIndex = result.targetIndex ?? index;
|
|
4271
|
+
const syntheticResult = { changes: [{ oldIndex: changedIndex, newIndices: [changedIndex], success: true }] };
|
|
4272
|
+
const changeInfo = buildChangeInfoFromResult('updated', _snap, syntheticResult, _preState);
|
|
4273
|
+
if (changeInfo) return { content: [{ type: "text", text: appendRefChangesToText(_msg, changeInfo) }] };
|
|
4274
|
+
}
|
|
4275
|
+
return { content: [{ type: "text", text: appendSnapshotToTextLegacy(_msg, _snap) }] };
|
|
2588
4276
|
}
|
|
2589
4277
|
|
|
2590
4278
|
case "open_in_browser": {
|