mmm-mcp-server 0.1.2 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/domain/appendForest.js +0 -50
- package/dist/src/domain/levels.js +3 -182
- package/dist/src/domain/levelsOps.js +10 -2
- package/dist/src/domain/mapResolver.js +1 -4
- package/dist/src/domain/markdownParser.js +20 -21
- package/dist/src/firestore/restClient.js +0 -39
- package/dist/src/server.js +1 -1
- package/dist/src/tools/autoStructureThought.js +9 -48
- package/dist/src/tools/createMap.js +3 -7
- package/dist/src/tools/deleteElements.js +17 -91
- package/dist/src/tools/editElement.js +11 -44
- package/dist/src/tools/importMarkdownContext.js +9 -53
- package/dist/src/tools/listElements.js +2 -7
- package/dist/src/tools/moveElement.js +8 -50
- package/dist/src/tools/updateTaskStatus.js +15 -59
- package/package.json +3 -3
- package/dist/src/domain/body.js +0 -127
- package/dist/src/domain/deleteElements.js +0 -85
- package/dist/src/domain/levelsMode.js +0 -25
- package/dist/src/domain/moveElement.js +0 -93
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { newDocId } from "./idGen.js";
|
|
2
|
-
import { lastChildOf } from "./body.js";
|
|
3
2
|
/** ネストしたツリー入力を、ID採番済み・prev/next/parent/child関係を組んだ配列に変換する。 */
|
|
4
3
|
export function buildForestFromTree(roots) {
|
|
5
4
|
const all = [];
|
|
@@ -31,52 +30,3 @@ export function buildForestFromTree(roots) {
|
|
|
31
30
|
build(roots, null);
|
|
32
31
|
return all;
|
|
33
32
|
}
|
|
34
|
-
/**
|
|
35
|
-
* 組み立てたforest(森)を、既存の要素の[targetParentId]配下の末尾に連結する
|
|
36
|
-
* ためのFirestore書き込み一覧を作る。auto_structure_thought /
|
|
37
|
-
* import_markdown_context の両方から共通で使う。
|
|
38
|
-
*
|
|
39
|
-
* `isTodo`がfalseのマップでは、doneは常にfalseとして書き込む
|
|
40
|
-
* (MatryoshkaModel.importMarkdownと同じ挙動)。
|
|
41
|
-
*/
|
|
42
|
-
export function buildAppendWrites(bodiesPath, existingBodies, forest, targetParentId, isTodo) {
|
|
43
|
-
const roots = forest.filter((n) => n.parentId === null);
|
|
44
|
-
const last = lastChildOf(existingBodies, targetParentId);
|
|
45
|
-
if (roots.length > 0) {
|
|
46
|
-
roots[0].prevId = last;
|
|
47
|
-
}
|
|
48
|
-
const writes = forest.map((node) => ({
|
|
49
|
-
path: `${bodiesPath}/${node.id}`,
|
|
50
|
-
fields: {
|
|
51
|
-
detail: node.detail,
|
|
52
|
-
parent: node.parentId ?? targetParentId,
|
|
53
|
-
prev: node.prevId,
|
|
54
|
-
next: node.nextId,
|
|
55
|
-
child: node.childId,
|
|
56
|
-
done: isTodo ? node.done : false,
|
|
57
|
-
},
|
|
58
|
-
}));
|
|
59
|
-
if (roots.length > 0) {
|
|
60
|
-
if (last !== null) {
|
|
61
|
-
// 末尾要素のnextを書き換える。読み取り後に他の操作(並行するappendや
|
|
62
|
-
// アプリ操作)がこの要素を書き換えていた場合、requireUpdateTimeにより
|
|
63
|
-
// FAILED_PRECONDITIONで失敗する(呼び出し側はrunOptimisticで読み取り
|
|
64
|
-
// からやり直す)。
|
|
65
|
-
const lastBody = existingBodies.find((b) => b.id === last);
|
|
66
|
-
writes.push({
|
|
67
|
-
path: `${bodiesPath}/${last}`,
|
|
68
|
-
fields: { next: roots[0].id },
|
|
69
|
-
requireUpdateTime: lastBody?.updateTime,
|
|
70
|
-
});
|
|
71
|
-
}
|
|
72
|
-
else if (targetParentId !== null) {
|
|
73
|
-
const parentBody = existingBodies.find((b) => b.id === targetParentId);
|
|
74
|
-
writes.push({
|
|
75
|
-
path: `${bodiesPath}/${targetParentId}`,
|
|
76
|
-
fields: { child: roots[0].id },
|
|
77
|
-
requireUpdateTime: parentBody?.updateTime,
|
|
78
|
-
});
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
return { writes, createdIds: forest.map((n) => n.id) };
|
|
82
|
-
}
|
|
@@ -7,17 +7,7 @@
|
|
|
7
7
|
// levelIdはマップ最上位が'root'、それ以外はその要素自身のid。配列の並び順が
|
|
8
8
|
// そのまま兄弟順。子を持つ要素だけドキュメントが存在する(無ければ葉)。
|
|
9
9
|
//
|
|
10
|
-
//
|
|
11
|
-
// が無いマップに対する操作は、切替前(bodiesとlevelsの両方へ書く間)だけ、
|
|
12
|
-
// アプリで一度開いてもらうよう案内して止める。切替後は空のマップとして扱う
|
|
13
|
-
// (levelsMode.tsのreadLevelsSnapshot)。
|
|
14
|
-
export class LevelsNotInitializedError extends Error {
|
|
15
|
-
constructor(title) {
|
|
16
|
-
super(`「${title}」はまだ新形式(levels)のデータがありません。` +
|
|
17
|
-
"お手数ですが、アプリでこのマップを一度開いてから、もう一度お試しください。");
|
|
18
|
-
this.name = "LevelsNotInitializedError";
|
|
19
|
-
}
|
|
20
|
-
}
|
|
10
|
+
// 書き込みは levelsOps.ts が行う。ここはデータ形状の解釈と読み取りだけを持つ。
|
|
21
11
|
function toEntry(raw) {
|
|
22
12
|
if (typeof raw !== "object" || raw === null)
|
|
23
13
|
return null;
|
|
@@ -51,33 +41,9 @@ export async function readAllLevelDocs(client, levelsPath) {
|
|
|
51
41
|
}
|
|
52
42
|
return result;
|
|
53
43
|
}
|
|
54
|
-
/** levels/rootが無ければ、アプリで開いてもらうよう案内して止める。 */
|
|
55
|
-
export function assertLevelsInitialized(levelsById, mapTitle) {
|
|
56
|
-
if (!levelsById.has("root")) {
|
|
57
|
-
throw new LevelsNotInitializedError(mapTitle);
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
/**
|
|
61
|
-
* [levelId]のlevelsドキュメントが存在するかだけを軽く確認する。
|
|
62
|
-
* 「levels/rootが無ければ止める」ような存在チェックのためだけに
|
|
63
|
-
* readAllLevelDocs(コレクション全体のページング読み取り)を使うのは、
|
|
64
|
-
* 子を多く持つ大きなマップほど無駄が大きい。1件のgetDocumentで足りる
|
|
65
|
-
* 場面(list_elementsのようにツリー全体が実際に必要な場合を除く)は
|
|
66
|
-
* こちらを使うこと。
|
|
67
|
-
*/
|
|
68
|
-
export async function levelsDocExists(client, levelsPath, levelId) {
|
|
69
|
-
return (await client.getDocument(`${levelsPath}/${levelId}`)) !== null;
|
|
70
|
-
}
|
|
71
|
-
/** [levelsDocExists]の結果を見て、levels/rootが無ければ止める。 */
|
|
72
|
-
export function assertLevelsRootExists(rootExists, mapTitle) {
|
|
73
|
-
if (!rootExists) {
|
|
74
|
-
throw new LevelsNotInitializedError(mapTitle);
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
44
|
/**
|
|
78
|
-
* 'root'から再帰的にlevels
|
|
79
|
-
*
|
|
80
|
-
* 分断の復元は不要。循環参照だけは防御的に検出して打ち切る。
|
|
45
|
+
* 'root'から再帰的にlevelsを展開してツリーを組み立てる。levelsは配列そのものが
|
|
46
|
+
* 順序を表すため、並び順の再構築は不要。循環参照だけは防御的に検出して打ち切る。
|
|
81
47
|
*/
|
|
82
48
|
export function buildTreeFromLevels(levelsById) {
|
|
83
49
|
function walk(levelId, parentId, ancestry) {
|
|
@@ -97,148 +63,3 @@ export function buildTreeFromLevels(levelsById) {
|
|
|
97
63
|
}
|
|
98
64
|
return walk("root", null, new Set());
|
|
99
65
|
}
|
|
100
|
-
/**
|
|
101
|
-
* forest(bodies用のbuildAppendWritesと同じ入力)から、levels側の書き込み
|
|
102
|
-
* 一覧を組み立てる。forest内で子を持つノードごとに levels/{id} を新規作成し、
|
|
103
|
-
* forestの最上位(ルート)群は挿入先(targetParentId ?? 'root')の既存配列へ
|
|
104
|
-
* 追記する。[targetHasExistingChildren]は、bodies側でlastChildOfが返した
|
|
105
|
-
* 値がnullでなかったか(=挿入先が既に子を持っていたか)をそのまま渡すこと。
|
|
106
|
-
*/
|
|
107
|
-
export function buildAppendLevelWrites(levelsPath, forest, targetParentId, targetHasExistingChildren, isTodo) {
|
|
108
|
-
const writes = [];
|
|
109
|
-
const byParent = new Map();
|
|
110
|
-
for (const node of forest) {
|
|
111
|
-
const list = byParent.get(node.parentId) ?? [];
|
|
112
|
-
list.push(node);
|
|
113
|
-
byParent.set(node.parentId, list);
|
|
114
|
-
}
|
|
115
|
-
const entryOf = (node) => ({
|
|
116
|
-
id: node.id,
|
|
117
|
-
detail: node.detail,
|
|
118
|
-
done: isTodo ? node.done : false,
|
|
119
|
-
});
|
|
120
|
-
// forest内で子を持つノードは全て新規作成なので、既存のlevels/{id}と
|
|
121
|
-
// 衝突することはない(既存ドキュメントは無いはず)。
|
|
122
|
-
for (const [parentId, children] of byParent) {
|
|
123
|
-
if (parentId === null)
|
|
124
|
-
continue; // ルート群は下で挿入先へ追記する
|
|
125
|
-
writes.push({
|
|
126
|
-
path: `${levelsPath}/${parentId}`,
|
|
127
|
-
fields: { children: children.map(entryOf) },
|
|
128
|
-
});
|
|
129
|
-
}
|
|
130
|
-
const roots = byParent.get(null) ?? [];
|
|
131
|
-
if (roots.length > 0) {
|
|
132
|
-
const targetLevelId = targetParentId ?? "root";
|
|
133
|
-
if (targetHasExistingChildren) {
|
|
134
|
-
writes.push({
|
|
135
|
-
path: `${levelsPath}/${targetLevelId}`,
|
|
136
|
-
arrayUnion: { field: "children", values: roots.map(entryOf) },
|
|
137
|
-
});
|
|
138
|
-
}
|
|
139
|
-
else {
|
|
140
|
-
writes.push({
|
|
141
|
-
path: `${levelsPath}/${targetLevelId}`,
|
|
142
|
-
fields: { children: roots.map(entryOf) },
|
|
143
|
-
});
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
return writes;
|
|
147
|
-
}
|
|
148
|
-
/**
|
|
149
|
-
* [levelId]のlevelsドキュメントのchildren配列から、idが[removeIds]に含まれる
|
|
150
|
-
* エントリを取り除くFirestoreWriteを1件返す。ドキュメントが無ければ何もしない
|
|
151
|
-
* (levelsはbest-effortの書き込みで、bodiesと食い違っていても実害は無いため)。
|
|
152
|
-
* requireUpdateTimeで楽観的ロックする(呼び出し側はrunOptimisticで使うこと)。
|
|
153
|
-
*/
|
|
154
|
-
export async function buildRemoveChildrenWrite(client, levelsPath, levelId, removeIds) {
|
|
155
|
-
const doc = await client.getDocument(`${levelsPath}/${levelId}`);
|
|
156
|
-
if (!doc)
|
|
157
|
-
return null;
|
|
158
|
-
const children = childrenOf(doc.data).filter((c) => !removeIds.includes(c.id));
|
|
159
|
-
return { path: `${levelsPath}/${levelId}`, fields: { children }, requireUpdateTime: doc.updateTime };
|
|
160
|
-
}
|
|
161
|
-
/**
|
|
162
|
-
* [levelId]のlevelsドキュメントのchildren配列の[afterId]の直後(nullなら先頭)に
|
|
163
|
-
* [entry]を挿入するFirestoreWriteを1件返す。ドキュメントが無ければ新規作成する
|
|
164
|
-
* (この場合はrequireUpdateTimeを付けない = 何も無い前提で作成する)。[afterId]が
|
|
165
|
-
* 見つからない場合は末尾に足す(Dart版LevelsRepository.insertChildと同じ方針)。
|
|
166
|
-
*/
|
|
167
|
-
/**
|
|
168
|
-
* [levelId]のchildrenの中で[entry]を一度取り除いてから、[afterId]の直後
|
|
169
|
-
* (nullなら先頭)へ入れ直す書き込みを作る。同じ親の中での並べ替え専用。
|
|
170
|
-
*
|
|
171
|
-
* **remove用とinsert用の書き込みを別々に作って両方commitしてはいけない。**
|
|
172
|
-
* どちらも「元のchildren」を読んでから組み立てるため、同じドキュメントへの
|
|
173
|
-
* 書き込みが2つできて後勝ちになり、要素が重複する。
|
|
174
|
-
*/
|
|
175
|
-
export async function buildReorderChildWrite(client, levelsPath, levelId, entry, afterId) {
|
|
176
|
-
const doc = await client.getDocument(`${levelsPath}/${levelId}`);
|
|
177
|
-
const children = (doc ? childrenOf(doc.data) : []).filter((c) => c.id !== entry.id);
|
|
178
|
-
const insertIndex = afterId === null
|
|
179
|
-
? 0
|
|
180
|
-
: (() => {
|
|
181
|
-
const found = children.findIndex((c) => c.id === afterId);
|
|
182
|
-
return found === -1 ? children.length : found + 1;
|
|
183
|
-
})();
|
|
184
|
-
children.splice(Math.min(Math.max(insertIndex, 0), children.length), 0, entry);
|
|
185
|
-
return {
|
|
186
|
-
path: `${levelsPath}/${levelId}`,
|
|
187
|
-
fields: { children },
|
|
188
|
-
requireUpdateTime: doc?.updateTime,
|
|
189
|
-
};
|
|
190
|
-
}
|
|
191
|
-
export async function buildInsertChildWrite(client, levelsPath, levelId, entry, afterId) {
|
|
192
|
-
const doc = await client.getDocument(`${levelsPath}/${levelId}`);
|
|
193
|
-
const children = doc ? childrenOf(doc.data) : [];
|
|
194
|
-
const insertIndex = afterId === null
|
|
195
|
-
? 0
|
|
196
|
-
: (() => {
|
|
197
|
-
const found = children.findIndex((c) => c.id === afterId);
|
|
198
|
-
return found === -1 ? children.length : found + 1;
|
|
199
|
-
})();
|
|
200
|
-
children.splice(Math.min(Math.max(insertIndex, 0), children.length), 0, entry);
|
|
201
|
-
return {
|
|
202
|
-
path: `${levelsPath}/${levelId}`,
|
|
203
|
-
fields: { children },
|
|
204
|
-
requireUpdateTime: doc?.updateTime,
|
|
205
|
-
};
|
|
206
|
-
}
|
|
207
|
-
/**
|
|
208
|
-
* update_task_status用。[updates]を親ごとにグループ化し、levelsドキュメントを
|
|
209
|
-
* 親ごとに1回だけ読んで該当エントリを書き換えた配列を書き戻すFirestoreWrite[]
|
|
210
|
-
* を組み立てる(Dart版LevelsRepository.updateChildと同じ「読んで置換」方式)。
|
|
211
|
-
* 該当ドキュメントや該当エントリが見つからない場合は、その分だけ静かに
|
|
212
|
-
* スキップする(levelsはbest-effortの書き込みで、失敗しても実害は無いため)。
|
|
213
|
-
*/
|
|
214
|
-
export async function buildUpdateEntryWrites(client, levelsPath, updates) {
|
|
215
|
-
const byLevelId = new Map();
|
|
216
|
-
for (const update of updates) {
|
|
217
|
-
const levelId = update.parentId ?? "root";
|
|
218
|
-
const list = byLevelId.get(levelId) ?? [];
|
|
219
|
-
list.push({ elementId: update.elementId, patch: update.patch });
|
|
220
|
-
byLevelId.set(levelId, list);
|
|
221
|
-
}
|
|
222
|
-
const writes = [];
|
|
223
|
-
for (const [levelId, group] of byLevelId) {
|
|
224
|
-
const doc = await client.getDocument(`${levelsPath}/${levelId}`);
|
|
225
|
-
if (!doc)
|
|
226
|
-
continue;
|
|
227
|
-
const children = childrenOf(doc.data);
|
|
228
|
-
for (const { elementId, patch } of group) {
|
|
229
|
-
const index = children.findIndex((c) => c.id === elementId);
|
|
230
|
-
if (index === -1)
|
|
231
|
-
continue;
|
|
232
|
-
children[index] = { ...children[index], ...patch };
|
|
233
|
-
}
|
|
234
|
-
// requireUpdateTime: 読み取り後に他の操作がこのlevelsドキュメントを
|
|
235
|
-
// 書き換えていた場合、この書き込みはFAILED_PRECONDITIONで失敗する
|
|
236
|
-
// (呼び出し側はrunOptimisticで読み取りからやり直すこと)。
|
|
237
|
-
writes.push({
|
|
238
|
-
path: `${levelsPath}/${levelId}`,
|
|
239
|
-
fields: { children },
|
|
240
|
-
requireUpdateTime: doc.updateTime,
|
|
241
|
-
});
|
|
242
|
-
}
|
|
243
|
-
return writes;
|
|
244
|
-
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
// levels
|
|
2
|
-
// bodiesを一切読まず書かず、levelsコレクションだけで要素を探して書き換える。
|
|
1
|
+
// levelsコレクションの読み取り結果から、要素を探して書き換える書き込みを組み立てる。
|
|
3
2
|
//
|
|
4
3
|
// どの操作も「levels全件の読み取り結果(LevelsSnapshot)」を受け取る純粋関数で、
|
|
5
4
|
// 触った子一覧を作業用に複製して編集し、最後に元の状態との差分から
|
|
@@ -12,6 +11,15 @@ import { childrenOf } from "./levels.js";
|
|
|
12
11
|
export function levelsSnapshotFromDocs(docs) {
|
|
13
12
|
return new Map(docs.map((d) => [d.id, { children: childrenOf(d.data), updateTime: d.updateTime }]));
|
|
14
13
|
}
|
|
14
|
+
/**
|
|
15
|
+
* levels全件を読む。levels/rootが無いマップは空のマップとして扱う
|
|
16
|
+
* (rootへの最初の書き込みは、存在しないことを前提条件にした新規作成になる)。
|
|
17
|
+
* アプリで開いてもrootは作られないので、無いときに止めると中身の無い古い
|
|
18
|
+
* マップへ永久に書き込めなくなる。
|
|
19
|
+
*/
|
|
20
|
+
export async function readLevelsSnapshot(client, levelsPath) {
|
|
21
|
+
return levelsSnapshotFromDocs(await client.listDocuments(levelsPath));
|
|
22
|
+
}
|
|
15
23
|
/**
|
|
16
24
|
* rootから辿れる要素だけを対象に、要素id -> 場所 の索引を作る。
|
|
17
25
|
* どこからも辿れない子一覧ドキュメント(過去の不整合の残骸)の中身は数えない。
|
|
@@ -30,10 +30,7 @@ export async function resolveMap(client, session, mapId) {
|
|
|
30
30
|
}
|
|
31
31
|
throw new MapNotFoundError(mapId);
|
|
32
32
|
}
|
|
33
|
-
|
|
34
|
-
return `users/${map.ownerUid}/headers/${map.headerId}/bodies`;
|
|
35
|
-
}
|
|
36
|
-
/** levels移行: bodiesPathOfと対になる、階層ごとの子一覧を配列で持つ新形式コレクション。 */
|
|
33
|
+
/** マップの中身(階層ごとの子一覧を配列で持つコレクション)のパス。 */
|
|
37
34
|
export function levelsPathOf(map) {
|
|
38
35
|
return `users/${map.ownerUid}/headers/${map.headerId}/levels`;
|
|
39
36
|
}
|
|
@@ -93,30 +93,29 @@ export function parseMarkdownForImport(markdownText, { headingsAsNodes }) {
|
|
|
93
93
|
return { nodes, headingTitle, hasCheckbox };
|
|
94
94
|
}
|
|
95
95
|
function buildRelations(nodes) {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
const
|
|
102
|
-
|
|
96
|
+
// 親は「自分より字下げが浅い直近の要素」とする。字下げの幅(2・4・タブ)は
|
|
97
|
+
// 浅い深いの比較にしか使わないので、刻み幅が混ざっても同じ結果になる。
|
|
98
|
+
// 段を飛ばした字下げ(`#`の直下の`-`、字下げのばらつき)でも必ず
|
|
99
|
+
// どこかへ繋がり、親も前の兄弟も無い要素ができない。
|
|
100
|
+
// 祖先の並び。末尾が直前の要素で、字下げは末尾ほど深い。
|
|
101
|
+
const ancestors = [];
|
|
102
|
+
// 親のID(最上位はnull)ごとの、いちばん後ろの子。
|
|
103
|
+
const lastChildOf = new Map();
|
|
103
104
|
for (const node of nodes) {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
node.parentId = lastNodeAtDepth.get(depth - 1) ?? null;
|
|
105
|
+
while (ancestors.length > 0 && ancestors[ancestors.length - 1].rawIndent >= node.rawIndent) {
|
|
106
|
+
ancestors.pop();
|
|
107
107
|
}
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
108
|
+
const parent = ancestors.length > 0 ? ancestors[ancestors.length - 1] : null;
|
|
109
|
+
node.parentId = parent?.id ?? null;
|
|
110
|
+
const prev = lastChildOf.get(node.parentId);
|
|
111
|
+
if (prev !== undefined) {
|
|
112
|
+
node.prevId = prev.id;
|
|
113
|
+
prev.nextId = node.id;
|
|
112
114
|
}
|
|
113
|
-
else if (
|
|
114
|
-
|
|
115
|
-
}
|
|
116
|
-
lastNodeAtDepth.set(depth, node.id);
|
|
117
|
-
for (const key of [...lastNodeAtDepth.keys()]) {
|
|
118
|
-
if (key > depth)
|
|
119
|
-
lastNodeAtDepth.delete(key);
|
|
115
|
+
else if (parent !== null) {
|
|
116
|
+
parent.childId = node.id;
|
|
120
117
|
}
|
|
118
|
+
lastChildOf.set(node.parentId, node);
|
|
119
|
+
ancestors.push(node);
|
|
121
120
|
}
|
|
122
121
|
}
|
|
@@ -29,45 +29,6 @@ async function parseJsonResponse(res) {
|
|
|
29
29
|
}
|
|
30
30
|
/** Firestoreの1コミット(:commit)に含められる書き込み件数の上限(500)より余裕を持たせた値。 */
|
|
31
31
|
const COMMIT_CHUNK_SIZE = 400;
|
|
32
|
-
/**
|
|
33
|
-
* 同じドキュメントを指す書き込みを1件にまとめる。
|
|
34
|
-
*
|
|
35
|
-
* **1回のcommitに同じpathの書き込みを2つ以上積んではいけない。** Firestoreは
|
|
36
|
-
* エラーにせず順に適用するため、後の書き込みが前の書き込みを打ち消す。
|
|
37
|
-
* 実データで2度踏んだ(move_elementで要素が重複、delete_elementsで存在しない
|
|
38
|
-
* IDを指すポインタが残る)。writesを返す直前に必ずこれを通すこと。
|
|
39
|
-
*
|
|
40
|
-
* deleteが1つでもあればdeleteが勝つ(消すドキュメントへの部分更新は無意味)。
|
|
41
|
-
* fields同士はマージし、requireUpdateTimeは最初に読み取った値を残す。
|
|
42
|
-
*/
|
|
43
|
-
export function mergeWritesByPath(writes) {
|
|
44
|
-
const byPath = new Map();
|
|
45
|
-
const order = [];
|
|
46
|
-
for (const write of writes) {
|
|
47
|
-
const prev = byPath.get(write.path);
|
|
48
|
-
if (!prev) {
|
|
49
|
-
byPath.set(write.path, { ...write });
|
|
50
|
-
order.push(write.path);
|
|
51
|
-
continue;
|
|
52
|
-
}
|
|
53
|
-
if (write.delete || prev.delete) {
|
|
54
|
-
byPath.set(write.path, { path: write.path, delete: true });
|
|
55
|
-
continue;
|
|
56
|
-
}
|
|
57
|
-
if (write.arrayUnion || prev.arrayUnion) {
|
|
58
|
-
// 配列追記と通常の更新は混ぜられない。後から積んだ方を採る。
|
|
59
|
-
byPath.set(write.path, { ...write });
|
|
60
|
-
continue;
|
|
61
|
-
}
|
|
62
|
-
byPath.set(write.path, {
|
|
63
|
-
path: write.path,
|
|
64
|
-
fields: { ...(prev.fields ?? {}), ...(write.fields ?? {}) },
|
|
65
|
-
requireUpdateTime: prev.requireUpdateTime ?? write.requireUpdateTime,
|
|
66
|
-
requireMissing: prev.requireMissing ?? write.requireMissing,
|
|
67
|
-
});
|
|
68
|
-
}
|
|
69
|
-
return order.map((path) => byPath.get(path));
|
|
70
|
-
}
|
|
71
32
|
export class FirestoreRestClient {
|
|
72
33
|
session;
|
|
73
34
|
constructor(session) {
|
package/dist/src/server.js
CHANGED
|
@@ -12,7 +12,7 @@ import { registerDeleteElements } from "./tools/deleteElements.js";
|
|
|
12
12
|
import { registerMoveElement } from "./tools/moveElement.js";
|
|
13
13
|
const server = new McpServer({
|
|
14
14
|
name: "matryoshka-mindmap",
|
|
15
|
-
version: "0.1.
|
|
15
|
+
version: "0.1.3",
|
|
16
16
|
}, {
|
|
17
17
|
instructions: "このサーバーはリアルタイム同期を行わない。各ツールは呼び出された瞬間の" +
|
|
18
18
|
"Firestoreの状態を返すだけで、アプリ側でユーザーが直接行った変更は" +
|
|
@@ -1,12 +1,8 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { mergeWritesByPath } from "../firestore/restClient.js";
|
|
3
2
|
import { getContext } from "../mcpContext.js";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import { bodyFromFirestore, ancestorDetails } from "../domain/body.js";
|
|
8
|
-
import { buildForestFromTree, buildAppendWrites } from "../domain/appendForest.js";
|
|
9
|
-
import { assertLevelsRootExists, buildAppendLevelWrites, levelsDocExists } from "../domain/levels.js";
|
|
3
|
+
import { ancestorDetailsInLevels, buildLevelsAppendWrites, locateInLevels, readLevelsSnapshot } from "../domain/levelsOps.js";
|
|
4
|
+
import { resolveMap, levelsPathOf, formatPath, assertCanEditMap, } from "../domain/mapResolver.js";
|
|
5
|
+
import { buildForestFromTree } from "../domain/appendForest.js";
|
|
10
6
|
const treeNodeSchema = z.lazy(() => z.object({
|
|
11
7
|
detail: z.string().min(1).describe("要素の本文"),
|
|
12
8
|
done: z.boolean().optional().describe("TODOマップのときのチェック状態(省略時false)"),
|
|
@@ -29,57 +25,22 @@ export function registerAutoStructureThought(server) {
|
|
|
29
25
|
const map = await resolveMap(client, session, mapId);
|
|
30
26
|
assertCanEditMap(map, session);
|
|
31
27
|
const levelsPath = levelsPathOf(map);
|
|
32
|
-
const bodiesPath = bodiesPathOf(map);
|
|
33
28
|
const targetParentId = parentElementId ?? null;
|
|
34
29
|
const forest = buildForestFromTree(nodes);
|
|
35
|
-
|
|
36
|
-
let levelsPathResult = "";
|
|
37
|
-
await client.runOptimistic(async () => {
|
|
38
|
-
const snapshot = await readLevelsSnapshot(client, levelsPath);
|
|
39
|
-
if (targetParentId !== null && !locateInLevels(snapshot, targetParentId)) {
|
|
40
|
-
throw new Error(`parentElementId(${targetParentId})がこのマップに見つかりません。list_elementsで確認してください。`);
|
|
41
|
-
}
|
|
42
|
-
levelsPathResult = formatPath(map.header.title, ancestorDetailsInLevels(snapshot, targetParentId));
|
|
43
|
-
return buildLevelsAppendWrites(levelsPath, snapshot, forest, targetParentId, map.header.isTodo);
|
|
44
|
-
});
|
|
45
|
-
return {
|
|
46
|
-
content: [
|
|
47
|
-
{
|
|
48
|
-
type: "text",
|
|
49
|
-
text: JSON.stringify({ path: levelsPathResult, addedElementCount: forest.length }, null, 2),
|
|
50
|
-
},
|
|
51
|
-
],
|
|
52
|
-
};
|
|
53
|
-
}
|
|
54
|
-
// 読み取り(bodies一覧・levels存在確認)から書き込みまでを楽観的
|
|
55
|
-
// ロックでまとめる。読み取り後に対象ドキュメントが他から変更される
|
|
56
|
-
// とコミットがFAILED_PRECONDITIONになり、読み取りからやり直す。
|
|
57
|
-
let createdIds = [];
|
|
58
|
-
let existingBodiesForPath = [];
|
|
30
|
+
let levelsPathResult = "";
|
|
59
31
|
await client.runOptimistic(async () => {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const existingBodies = existingDocs.map((d) => bodyFromFirestore(d.id, d.data, d.updateTime));
|
|
63
|
-
existingBodiesForPath = existingBodies;
|
|
64
|
-
if (targetParentId !== null && !existingBodies.some((b) => b.id === targetParentId)) {
|
|
32
|
+
const snapshot = await readLevelsSnapshot(client, levelsPath);
|
|
33
|
+
if (targetParentId !== null && !locateInLevels(snapshot, targetParentId)) {
|
|
65
34
|
throw new Error(`parentElementId(${targetParentId})がこのマップに見つかりません。list_elementsで確認してください。`);
|
|
66
35
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
// 挿入先のlevelsドキュメントが実際に存在するかを直接確認する
|
|
70
|
-
// (bodies側の状態から推測すると、levelsとbodiesが食い違っている
|
|
71
|
-
// 場合にarrayUnion書き込みが「ドキュメントが無い」で失敗しうるため)。
|
|
72
|
-
const targetLevelId = targetParentId ?? "root";
|
|
73
|
-
const targetHasLevelsDoc = targetLevelId === "root" || (await levelsDocExists(client, levelsPath, targetLevelId));
|
|
74
|
-
const levelWrites = buildAppendLevelWrites(levelsPath, forest, targetParentId, targetHasLevelsDoc, map.header.isTodo);
|
|
75
|
-
return mergeWritesByPath([...writes, ...levelWrites]);
|
|
36
|
+
levelsPathResult = formatPath(map.header.title, ancestorDetailsInLevels(snapshot, targetParentId));
|
|
37
|
+
return buildLevelsAppendWrites(levelsPath, snapshot, forest, targetParentId, map.header.isTodo);
|
|
76
38
|
});
|
|
77
|
-
const path = formatPath(map.header.title, ancestorDetails(existingBodiesForPath, targetParentId));
|
|
78
39
|
return {
|
|
79
40
|
content: [
|
|
80
41
|
{
|
|
81
42
|
type: "text",
|
|
82
|
-
text: JSON.stringify({ path, addedElementCount:
|
|
43
|
+
text: JSON.stringify({ path: levelsPathResult, addedElementCount: forest.length }, null, 2),
|
|
83
44
|
},
|
|
84
45
|
],
|
|
85
46
|
};
|
|
@@ -12,12 +12,8 @@ export function registerCreateMap(server) {
|
|
|
12
12
|
const { client, session } = getContext();
|
|
13
13
|
const existing = await client.listDocuments(`users/${session.uid}/headers`);
|
|
14
14
|
const maxOrder = existing.reduce((max, doc) => Math.max(max, Number(doc.data.order ?? 0)), 0);
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
// 直後に書き込み系ツールを呼んでも「アプリで一度開いてください」で
|
|
18
|
-
// 弾かれない(移行ではなく単なる初期化なので問題ない)。
|
|
19
|
-
// migratedはアプリの新規作成(header_add_model.dart)と揃える。無いと、
|
|
20
|
-
// 切替前のアプリが開いたときに未移行とみなしてbodiesから作り直してしまう。
|
|
15
|
+
// 自前でIDを採番し、header本体と空のlevels/rootを1回のcommitWritesに
|
|
16
|
+
// まとめて作る(アプリの新規作成 header_add_model.dart と揃える)。
|
|
21
17
|
const headerId = newDocId();
|
|
22
18
|
const headerPath = `users/${session.uid}/headers/${headerId}`;
|
|
23
19
|
await client.commitWrites([
|
|
@@ -25,7 +21,7 @@ export function registerCreateMap(server) {
|
|
|
25
21
|
path: headerPath,
|
|
26
22
|
fields: { title, order: maxOrder + 1, isTodo, editors: [], viewers: [] },
|
|
27
23
|
},
|
|
28
|
-
{ path: `${headerPath}/levels/root`, fields: { children: []
|
|
24
|
+
{ path: `${headerPath}/levels/root`, fields: { children: [] } },
|
|
29
25
|
]);
|
|
30
26
|
return {
|
|
31
27
|
content: [
|