mmm-mcp-server 0.1.0
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/LICENSE +21 -0
- package/README.md +346 -0
- package/dist/bin/login.js +119 -0
- package/dist/src/auth/appleClientSecretRemote.js +29 -0
- package/dist/src/auth/appleLoopback.js +75 -0
- package/dist/src/auth/appleTokenExchange.js +29 -0
- package/dist/src/auth/firebaseAuthRest.js +98 -0
- package/dist/src/auth/googleLoopback.js +93 -0
- package/dist/src/auth/googleOAuthConfig.js +26 -0
- package/dist/src/auth/session.js +58 -0
- package/dist/src/auth/tokenStore.js +32 -0
- package/dist/src/domain/appendForest.js +82 -0
- package/dist/src/domain/body.js +127 -0
- package/dist/src/domain/deleteElements.js +85 -0
- package/dist/src/domain/header.js +27 -0
- package/dist/src/domain/idGen.js +11 -0
- package/dist/src/domain/levels.js +242 -0
- package/dist/src/domain/levelsMode.js +24 -0
- package/dist/src/domain/levelsOps.js +199 -0
- package/dist/src/domain/mapResolver.js +52 -0
- package/dist/src/domain/markdownParser.js +122 -0
- package/dist/src/domain/moveElement.js +93 -0
- package/dist/src/firebaseConfig.js +36 -0
- package/dist/src/firestore/restClient.js +267 -0
- package/dist/src/firestore/valueCodec.js +65 -0
- package/dist/src/mcpContext.js +11 -0
- package/dist/src/server.js +33 -0
- package/dist/src/tools/autoStructureThought.js +87 -0
- package/dist/src/tools/createMap.js +39 -0
- package/dist/src/tools/deleteElements.js +150 -0
- package/dist/src/tools/editElement.js +65 -0
- package/dist/src/tools/importMarkdownContext.js +95 -0
- package/dist/src/tools/listElements.js +97 -0
- package/dist/src/tools/listMaps.js +47 -0
- package/dist/src/tools/moveElement.js +81 -0
- package/dist/src/tools/updateTaskStatus.js +87 -0
- package/package.json +48 -0
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// levels移行: users/{uid}/headers/{headerId}/levels/{levelId} の読み書き。
|
|
2
|
+
// lib/matryoshka/levels_repository.dart(Dart側)と同じデータ形状を前提にする。
|
|
3
|
+
//
|
|
4
|
+
// levels/{levelId}
|
|
5
|
+
// children: [ { id, detail, done }, ... ]
|
|
6
|
+
//
|
|
7
|
+
// levelIdはマップ最上位が'root'、それ以外はその要素自身のid。配列の並び順が
|
|
8
|
+
// そのまま兄弟順。子を持つ要素だけドキュメントが存在する(無ければ葉)。
|
|
9
|
+
//
|
|
10
|
+
// MCPは「新形式のみ対応」(移行ロジックはDart側だけに持たせる)。levels/root
|
|
11
|
+
// が無いマップに対する操作は、アプリで一度開いてもらうよう案内して止める。
|
|
12
|
+
export class LevelsNotInitializedError extends Error {
|
|
13
|
+
constructor(title) {
|
|
14
|
+
super(`「${title}」はまだ新形式(levels)のデータがありません。` +
|
|
15
|
+
"お手数ですが、アプリでこのマップを一度開いてから、もう一度お試しください。");
|
|
16
|
+
this.name = "LevelsNotInitializedError";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function toEntry(raw) {
|
|
20
|
+
if (typeof raw !== "object" || raw === null)
|
|
21
|
+
return null;
|
|
22
|
+
const obj = raw;
|
|
23
|
+
if (typeof obj.id !== "string")
|
|
24
|
+
return null;
|
|
25
|
+
return {
|
|
26
|
+
id: obj.id,
|
|
27
|
+
detail: String(obj.detail ?? ""),
|
|
28
|
+
done: Boolean(obj.done ?? false),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
export function childrenOf(data) {
|
|
32
|
+
const raw = data.children;
|
|
33
|
+
if (!Array.isArray(raw))
|
|
34
|
+
return [];
|
|
35
|
+
const result = [];
|
|
36
|
+
for (const entry of raw) {
|
|
37
|
+
const parsed = toEntry(entry);
|
|
38
|
+
if (parsed)
|
|
39
|
+
result.push(parsed);
|
|
40
|
+
}
|
|
41
|
+
return result;
|
|
42
|
+
}
|
|
43
|
+
/** levels配下の全ドキュメントを読み、levelId -> children[] のMapにする。 */
|
|
44
|
+
export async function readAllLevelDocs(client, levelsPath) {
|
|
45
|
+
const docs = await client.listDocuments(levelsPath);
|
|
46
|
+
const result = new Map();
|
|
47
|
+
for (const doc of docs) {
|
|
48
|
+
result.set(doc.id, childrenOf(doc.data));
|
|
49
|
+
}
|
|
50
|
+
return result;
|
|
51
|
+
}
|
|
52
|
+
/** levels/rootが無ければ、アプリで開いてもらうよう案内して止める。 */
|
|
53
|
+
export function assertLevelsInitialized(levelsById, mapTitle) {
|
|
54
|
+
if (!levelsById.has("root")) {
|
|
55
|
+
throw new LevelsNotInitializedError(mapTitle);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* [levelId]のlevelsドキュメントが存在するかだけを軽く確認する。
|
|
60
|
+
* 「levels/rootが無ければ止める」ような存在チェックのためだけに
|
|
61
|
+
* readAllLevelDocs(コレクション全体のページング読み取り)を使うのは、
|
|
62
|
+
* 子を多く持つ大きなマップほど無駄が大きい。1件のgetDocumentで足りる
|
|
63
|
+
* 場面(list_elementsのようにツリー全体が実際に必要な場合を除く)は
|
|
64
|
+
* こちらを使うこと。
|
|
65
|
+
*/
|
|
66
|
+
export async function levelsDocExists(client, levelsPath, levelId) {
|
|
67
|
+
return (await client.getDocument(`${levelsPath}/${levelId}`)) !== null;
|
|
68
|
+
}
|
|
69
|
+
/** [levelsDocExists]の結果を見て、levels/rootが無ければ止める。 */
|
|
70
|
+
export function assertLevelsRootExists(rootExists, mapTitle) {
|
|
71
|
+
if (!rootExists) {
|
|
72
|
+
throw new LevelsNotInitializedError(mapTitle);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* 'root'から再帰的にlevelsを展開してツリーを組み立てる(bodies版buildTreeの
|
|
77
|
+
* levels版)。levelsは配列そのものが順序を表すため、連結リストの再構築や
|
|
78
|
+
* 分断の復元は不要。循環参照だけは防御的に検出して打ち切る。
|
|
79
|
+
*/
|
|
80
|
+
export function buildTreeFromLevels(levelsById) {
|
|
81
|
+
function walk(levelId, parentId, ancestry) {
|
|
82
|
+
const children = levelsById.get(levelId) ?? [];
|
|
83
|
+
const nextAncestry = new Set(ancestry);
|
|
84
|
+
nextAncestry.add(levelId);
|
|
85
|
+
return children.map((entry) => {
|
|
86
|
+
const hasOwnChildren = levelsById.has(entry.id) && !ancestry.has(entry.id);
|
|
87
|
+
return {
|
|
88
|
+
id: entry.id,
|
|
89
|
+
detail: entry.detail,
|
|
90
|
+
done: entry.done,
|
|
91
|
+
parentId,
|
|
92
|
+
children: hasOwnChildren ? walk(entry.id, entry.id, nextAncestry) : [],
|
|
93
|
+
};
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
return walk("root", null, new Set());
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* forest(bodies用のbuildAppendWritesと同じ入力)から、levels側の書き込み
|
|
100
|
+
* 一覧を組み立てる。forest内で子を持つノードごとに levels/{id} を新規作成し、
|
|
101
|
+
* forestの最上位(ルート)群は挿入先(targetParentId ?? 'root')の既存配列へ
|
|
102
|
+
* 追記する。[targetHasExistingChildren]は、bodies側でlastChildOfが返した
|
|
103
|
+
* 値がnullでなかったか(=挿入先が既に子を持っていたか)をそのまま渡すこと。
|
|
104
|
+
*/
|
|
105
|
+
export function buildAppendLevelWrites(levelsPath, forest, targetParentId, targetHasExistingChildren, isTodo) {
|
|
106
|
+
const writes = [];
|
|
107
|
+
const byParent = new Map();
|
|
108
|
+
for (const node of forest) {
|
|
109
|
+
const list = byParent.get(node.parentId) ?? [];
|
|
110
|
+
list.push(node);
|
|
111
|
+
byParent.set(node.parentId, list);
|
|
112
|
+
}
|
|
113
|
+
const entryOf = (node) => ({
|
|
114
|
+
id: node.id,
|
|
115
|
+
detail: node.detail,
|
|
116
|
+
done: isTodo ? node.done : false,
|
|
117
|
+
});
|
|
118
|
+
// forest内で子を持つノードは全て新規作成なので、既存のlevels/{id}と
|
|
119
|
+
// 衝突することはない(既存ドキュメントは無いはず)。
|
|
120
|
+
for (const [parentId, children] of byParent) {
|
|
121
|
+
if (parentId === null)
|
|
122
|
+
continue; // ルート群は下で挿入先へ追記する
|
|
123
|
+
writes.push({
|
|
124
|
+
path: `${levelsPath}/${parentId}`,
|
|
125
|
+
fields: { children: children.map(entryOf) },
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
const roots = byParent.get(null) ?? [];
|
|
129
|
+
if (roots.length > 0) {
|
|
130
|
+
const targetLevelId = targetParentId ?? "root";
|
|
131
|
+
if (targetHasExistingChildren) {
|
|
132
|
+
writes.push({
|
|
133
|
+
path: `${levelsPath}/${targetLevelId}`,
|
|
134
|
+
arrayUnion: { field: "children", values: roots.map(entryOf) },
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
writes.push({
|
|
139
|
+
path: `${levelsPath}/${targetLevelId}`,
|
|
140
|
+
fields: { children: roots.map(entryOf) },
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return writes;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* [levelId]のlevelsドキュメントのchildren配列から、idが[removeIds]に含まれる
|
|
148
|
+
* エントリを取り除くFirestoreWriteを1件返す。ドキュメントが無ければ何もしない
|
|
149
|
+
* (levelsはbest-effortの書き込みで、bodiesと食い違っていても実害は無いため)。
|
|
150
|
+
* requireUpdateTimeで楽観的ロックする(呼び出し側はrunOptimisticで使うこと)。
|
|
151
|
+
*/
|
|
152
|
+
export async function buildRemoveChildrenWrite(client, levelsPath, levelId, removeIds) {
|
|
153
|
+
const doc = await client.getDocument(`${levelsPath}/${levelId}`);
|
|
154
|
+
if (!doc)
|
|
155
|
+
return null;
|
|
156
|
+
const children = childrenOf(doc.data).filter((c) => !removeIds.includes(c.id));
|
|
157
|
+
return { path: `${levelsPath}/${levelId}`, fields: { children }, requireUpdateTime: doc.updateTime };
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* [levelId]のlevelsドキュメントのchildren配列の[afterId]の直後(nullなら先頭)に
|
|
161
|
+
* [entry]を挿入するFirestoreWriteを1件返す。ドキュメントが無ければ新規作成する
|
|
162
|
+
* (この場合はrequireUpdateTimeを付けない = 何も無い前提で作成する)。[afterId]が
|
|
163
|
+
* 見つからない場合は末尾に足す(Dart版LevelsRepository.insertChildと同じ方針)。
|
|
164
|
+
*/
|
|
165
|
+
/**
|
|
166
|
+
* [levelId]のchildrenの中で[entry]を一度取り除いてから、[afterId]の直後
|
|
167
|
+
* (nullなら先頭)へ入れ直す書き込みを作る。同じ親の中での並べ替え専用。
|
|
168
|
+
*
|
|
169
|
+
* **remove用とinsert用の書き込みを別々に作って両方commitしてはいけない。**
|
|
170
|
+
* どちらも「元のchildren」を読んでから組み立てるため、同じドキュメントへの
|
|
171
|
+
* 書き込みが2つできて後勝ちになり、要素が重複する。
|
|
172
|
+
*/
|
|
173
|
+
export async function buildReorderChildWrite(client, levelsPath, levelId, entry, afterId) {
|
|
174
|
+
const doc = await client.getDocument(`${levelsPath}/${levelId}`);
|
|
175
|
+
const children = (doc ? childrenOf(doc.data) : []).filter((c) => c.id !== entry.id);
|
|
176
|
+
const insertIndex = afterId === null
|
|
177
|
+
? 0
|
|
178
|
+
: (() => {
|
|
179
|
+
const found = children.findIndex((c) => c.id === afterId);
|
|
180
|
+
return found === -1 ? children.length : found + 1;
|
|
181
|
+
})();
|
|
182
|
+
children.splice(Math.min(Math.max(insertIndex, 0), children.length), 0, entry);
|
|
183
|
+
return {
|
|
184
|
+
path: `${levelsPath}/${levelId}`,
|
|
185
|
+
fields: { children },
|
|
186
|
+
requireUpdateTime: doc?.updateTime,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
export async function buildInsertChildWrite(client, levelsPath, levelId, entry, afterId) {
|
|
190
|
+
const doc = await client.getDocument(`${levelsPath}/${levelId}`);
|
|
191
|
+
const children = doc ? childrenOf(doc.data) : [];
|
|
192
|
+
const insertIndex = afterId === null
|
|
193
|
+
? 0
|
|
194
|
+
: (() => {
|
|
195
|
+
const found = children.findIndex((c) => c.id === afterId);
|
|
196
|
+
return found === -1 ? children.length : found + 1;
|
|
197
|
+
})();
|
|
198
|
+
children.splice(Math.min(Math.max(insertIndex, 0), children.length), 0, entry);
|
|
199
|
+
return {
|
|
200
|
+
path: `${levelsPath}/${levelId}`,
|
|
201
|
+
fields: { children },
|
|
202
|
+
requireUpdateTime: doc?.updateTime,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* update_task_status用。[updates]を親ごとにグループ化し、levelsドキュメントを
|
|
207
|
+
* 親ごとに1回だけ読んで該当エントリを書き換えた配列を書き戻すFirestoreWrite[]
|
|
208
|
+
* を組み立てる(Dart版LevelsRepository.updateChildと同じ「読んで置換」方式)。
|
|
209
|
+
* 該当ドキュメントや該当エントリが見つからない場合は、その分だけ静かに
|
|
210
|
+
* スキップする(levelsはbest-effortの書き込みで、失敗しても実害は無いため)。
|
|
211
|
+
*/
|
|
212
|
+
export async function buildUpdateEntryWrites(client, levelsPath, updates) {
|
|
213
|
+
const byLevelId = new Map();
|
|
214
|
+
for (const update of updates) {
|
|
215
|
+
const levelId = update.parentId ?? "root";
|
|
216
|
+
const list = byLevelId.get(levelId) ?? [];
|
|
217
|
+
list.push({ elementId: update.elementId, patch: update.patch });
|
|
218
|
+
byLevelId.set(levelId, list);
|
|
219
|
+
}
|
|
220
|
+
const writes = [];
|
|
221
|
+
for (const [levelId, group] of byLevelId) {
|
|
222
|
+
const doc = await client.getDocument(`${levelsPath}/${levelId}`);
|
|
223
|
+
if (!doc)
|
|
224
|
+
continue;
|
|
225
|
+
const children = childrenOf(doc.data);
|
|
226
|
+
for (const { elementId, patch } of group) {
|
|
227
|
+
const index = children.findIndex((c) => c.id === elementId);
|
|
228
|
+
if (index === -1)
|
|
229
|
+
continue;
|
|
230
|
+
children[index] = { ...children[index], ...patch };
|
|
231
|
+
}
|
|
232
|
+
// requireUpdateTime: 読み取り後に他の操作がこのlevelsドキュメントを
|
|
233
|
+
// 書き換えていた場合、この書き込みはFAILED_PRECONDITIONで失敗する
|
|
234
|
+
// (呼び出し側はrunOptimisticで読み取りからやり直すこと)。
|
|
235
|
+
writes.push({
|
|
236
|
+
path: `${levelsPath}/${levelId}`,
|
|
237
|
+
fields: { children },
|
|
238
|
+
requireUpdateTime: doc.updateTime,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
return writes;
|
|
242
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// levels移行の切替フラグ。アプリと同じく Firestore の config/app.migrationEnabled を
|
|
2
|
+
// 見て、明示的にfalseの間だけ従来どおりbodiesとlevelsの両方へ書く。それ以外
|
|
3
|
+
// (true・フィールドが無い・ドキュメントが無い)はbodiesを一切使わずlevelsだけで
|
|
4
|
+
// 読み書きする(levelsOps.ts)。bodies削除後にフラグを消しても、公開済みの古い版が
|
|
5
|
+
// bodiesへ書きに行かないよう、未設定はlevels側に倒す。
|
|
6
|
+
//
|
|
7
|
+
// フラグを見ずにlevelsだけへ書くと、切替前はアプリ(bodiesを読んで書く)の
|
|
8
|
+
// 書き込みがMCPの変更を上書きしてしまう。逆にbodiesを使い続けると、切替後は
|
|
9
|
+
// アプリで足した要素が見つからず、bodiesを読み取り専用にするルールの適用後は
|
|
10
|
+
// コミットごと失敗する。呼び出しのたびに読み直すので、MCPサーバーを
|
|
11
|
+
// 再起動しなくても切替に追従する。
|
|
12
|
+
import { LevelsNotInitializedError } from "./levels.js";
|
|
13
|
+
import { levelsSnapshotFromDocs } from "./levelsOps.js";
|
|
14
|
+
export async function isLevelsOnly(client) {
|
|
15
|
+
const config = await client.getDocument("config/app");
|
|
16
|
+
return config?.data.migrationEnabled !== false;
|
|
17
|
+
}
|
|
18
|
+
/** levels全件を読む。levels/rootが無ければ、アプリで開いてもらうよう案内して止める。 */
|
|
19
|
+
export async function readLevelsSnapshot(client, levelsPath, mapTitle) {
|
|
20
|
+
const snapshot = levelsSnapshotFromDocs(await client.listDocuments(levelsPath));
|
|
21
|
+
if (!snapshot.has("root"))
|
|
22
|
+
throw new LevelsNotInitializedError(mapTitle);
|
|
23
|
+
return snapshot;
|
|
24
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// levels移行・切替後(config/app.migrationEnabled が true)の書き込み。
|
|
2
|
+
// bodiesを一切読まず書かず、levelsコレクションだけで要素を探して書き換える。
|
|
3
|
+
//
|
|
4
|
+
// どの操作も「levels全件の読み取り結果(LevelsSnapshot)」を受け取る純粋関数で、
|
|
5
|
+
// 触った子一覧を作業用に複製して編集し、最後に元の状態との差分から
|
|
6
|
+
// 書き込みを組み立てる。1つのドキュメントへの書き込みは必ず1件になる
|
|
7
|
+
// (同じpathへ2件積むと後勝ちになり、要素の重複や復活が起きる)。
|
|
8
|
+
//
|
|
9
|
+
// 子を持たなくなった要素の子一覧ドキュメントは消す(アプリのprojectLevelsと
|
|
10
|
+
// 同じ扱い)。rootだけは空配列で残す。
|
|
11
|
+
import { childrenOf } from "./levels.js";
|
|
12
|
+
export function levelsSnapshotFromDocs(docs) {
|
|
13
|
+
return new Map(docs.map((d) => [d.id, { children: childrenOf(d.data), updateTime: d.updateTime }]));
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* rootから辿れる要素だけを対象に、要素id -> 場所 の索引を作る。
|
|
17
|
+
* どこからも辿れない子一覧ドキュメント(過去の不整合の残骸)の中身は数えない。
|
|
18
|
+
*/
|
|
19
|
+
function indexOf(snapshot) {
|
|
20
|
+
const index = new Map();
|
|
21
|
+
const walk = (levelId) => {
|
|
22
|
+
const children = snapshot.get(levelId)?.children ?? [];
|
|
23
|
+
children.forEach((entry, i) => {
|
|
24
|
+
if (index.has(entry.id))
|
|
25
|
+
return; // 循環・重複は最初の位置を採る
|
|
26
|
+
index.set(entry.id, { parentId: levelId === "root" ? null : levelId, levelId, index: i });
|
|
27
|
+
walk(entry.id);
|
|
28
|
+
});
|
|
29
|
+
};
|
|
30
|
+
walk("root");
|
|
31
|
+
return index;
|
|
32
|
+
}
|
|
33
|
+
export function locateInLevels(snapshot, elementId) {
|
|
34
|
+
return indexOf(snapshot).get(elementId) ?? null;
|
|
35
|
+
}
|
|
36
|
+
/** ルートから[elementId]までの祖先(自分自身を含む)のdetailを、根から順に並べる。 */
|
|
37
|
+
export function ancestorDetailsInLevels(snapshot, elementId) {
|
|
38
|
+
const index = indexOf(snapshot);
|
|
39
|
+
const chain = [];
|
|
40
|
+
let current = elementId;
|
|
41
|
+
const seen = new Set();
|
|
42
|
+
while (current !== null && !seen.has(current)) {
|
|
43
|
+
seen.add(current);
|
|
44
|
+
const loc = index.get(current);
|
|
45
|
+
if (!loc)
|
|
46
|
+
break;
|
|
47
|
+
chain.push(snapshot.get(loc.levelId).children[loc.index].detail);
|
|
48
|
+
current = loc.parentId;
|
|
49
|
+
}
|
|
50
|
+
return chain.reverse();
|
|
51
|
+
}
|
|
52
|
+
/** [rootId]自身とその配下(子孫)全てのidを集める。 */
|
|
53
|
+
export function subtreeIdsInLevels(snapshot, rootId) {
|
|
54
|
+
const result = [];
|
|
55
|
+
const seen = new Set();
|
|
56
|
+
const walk = (id) => {
|
|
57
|
+
if (seen.has(id))
|
|
58
|
+
return;
|
|
59
|
+
seen.add(id);
|
|
60
|
+
result.push(id);
|
|
61
|
+
for (const child of snapshot.get(id)?.children ?? [])
|
|
62
|
+
walk(child.id);
|
|
63
|
+
};
|
|
64
|
+
walk(rootId);
|
|
65
|
+
return result;
|
|
66
|
+
}
|
|
67
|
+
/** 編集中の子一覧。触ったものだけを複製して持ち、最後に差分を書き込みにする。 */
|
|
68
|
+
class LevelsDraft {
|
|
69
|
+
levelsPath;
|
|
70
|
+
snapshot;
|
|
71
|
+
edited = new Map();
|
|
72
|
+
removed = new Set();
|
|
73
|
+
constructor(levelsPath, snapshot) {
|
|
74
|
+
this.levelsPath = levelsPath;
|
|
75
|
+
this.snapshot = snapshot;
|
|
76
|
+
}
|
|
77
|
+
children(levelId) {
|
|
78
|
+
let list = this.edited.get(levelId);
|
|
79
|
+
if (!list) {
|
|
80
|
+
list = (this.snapshot.get(levelId)?.children ?? []).map((c) => ({ ...c }));
|
|
81
|
+
this.edited.set(levelId, list);
|
|
82
|
+
this.removed.delete(levelId);
|
|
83
|
+
}
|
|
84
|
+
return list;
|
|
85
|
+
}
|
|
86
|
+
/** 子一覧ドキュメントごと消す(配下ごと削除される要素の分)。 */
|
|
87
|
+
remove(levelId) {
|
|
88
|
+
this.edited.delete(levelId);
|
|
89
|
+
this.removed.add(levelId);
|
|
90
|
+
}
|
|
91
|
+
toWrites() {
|
|
92
|
+
const writes = [];
|
|
93
|
+
const pathOf = (levelId) => `${this.levelsPath}/${levelId}`;
|
|
94
|
+
for (const levelId of this.removed) {
|
|
95
|
+
const original = this.snapshot.get(levelId);
|
|
96
|
+
if (!original)
|
|
97
|
+
continue; // 元から無いものは消す必要が無い
|
|
98
|
+
writes.push({ path: pathOf(levelId), delete: true, requireUpdateTime: original.updateTime });
|
|
99
|
+
}
|
|
100
|
+
for (const [levelId, children] of this.edited) {
|
|
101
|
+
const original = this.snapshot.get(levelId);
|
|
102
|
+
if (children.length === 0 && levelId !== "root") {
|
|
103
|
+
if (original) {
|
|
104
|
+
writes.push({ path: pathOf(levelId), delete: true, requireUpdateTime: original.updateTime });
|
|
105
|
+
}
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
writes.push(original
|
|
109
|
+
? { path: pathOf(levelId), fields: { children }, requireUpdateTime: original.updateTime }
|
|
110
|
+
: { path: pathOf(levelId), fields: { children }, requireMissing: true });
|
|
111
|
+
}
|
|
112
|
+
return writes;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/** edit_element / update_task_status 用。見つからなかったidはfoundに入らない。 */
|
|
116
|
+
export function buildLevelsPatchWrites(levelsPath, snapshot, updates) {
|
|
117
|
+
const index = indexOf(snapshot);
|
|
118
|
+
const draft = new LevelsDraft(levelsPath, snapshot);
|
|
119
|
+
const found = new Set();
|
|
120
|
+
for (const { elementId, patch } of updates) {
|
|
121
|
+
const loc = index.get(elementId);
|
|
122
|
+
if (!loc)
|
|
123
|
+
continue;
|
|
124
|
+
const list = draft.children(loc.levelId);
|
|
125
|
+
list[loc.index] = { ...list[loc.index], ...patch };
|
|
126
|
+
found.add(elementId);
|
|
127
|
+
}
|
|
128
|
+
return { writes: draft.toWrites(), found };
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* auto_structure_thought / import_markdown_context 用。[forest]の最上位を
|
|
132
|
+
* [targetParentId](nullなら最上位)の子の末尾へ足し、forest内で子を持つ
|
|
133
|
+
* 要素の子一覧を新規作成する。挿入先の存在確認は呼び出し側で済ませること。
|
|
134
|
+
*/
|
|
135
|
+
export function buildLevelsAppendWrites(levelsPath, snapshot, forest, targetParentId, isTodo) {
|
|
136
|
+
const draft = new LevelsDraft(levelsPath, snapshot);
|
|
137
|
+
const entryOf = (node) => ({
|
|
138
|
+
id: node.id,
|
|
139
|
+
detail: node.detail,
|
|
140
|
+
done: isTodo ? node.done : false,
|
|
141
|
+
});
|
|
142
|
+
for (const node of forest) {
|
|
143
|
+
draft.children(node.parentId ?? targetParentId ?? "root").push(entryOf(node));
|
|
144
|
+
}
|
|
145
|
+
return draft.toWrites();
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* move_element 用。[elementId](配下ごと)を[newParentId]の子の、[afterId]の
|
|
149
|
+
* 直後(nullなら先頭)へ移す。[afterId]が移動先の子でなければ末尾へ置く。
|
|
150
|
+
* 配下の子一覧ドキュメントはidが変わらないので触らない。
|
|
151
|
+
*/
|
|
152
|
+
export function buildLevelsMoveWrites(levelsPath, snapshot, elementId, newParentId, afterId) {
|
|
153
|
+
const index = indexOf(snapshot);
|
|
154
|
+
const loc = index.get(elementId);
|
|
155
|
+
if (!loc) {
|
|
156
|
+
throw new Error(`elementId(${elementId})がこのマップに見つかりません。list_elementsで確認してください。`);
|
|
157
|
+
}
|
|
158
|
+
if (newParentId !== null && !index.has(newParentId)) {
|
|
159
|
+
throw new Error(`newParentElementId(${newParentId})がこのマップに見つかりません。`);
|
|
160
|
+
}
|
|
161
|
+
if (newParentId !== null && subtreeIdsInLevels(snapshot, elementId).includes(newParentId)) {
|
|
162
|
+
throw new Error("要素を自分自身の配下(子孫)の中へ移動することはできません。");
|
|
163
|
+
}
|
|
164
|
+
const draft = new LevelsDraft(levelsPath, snapshot);
|
|
165
|
+
const from = draft.children(loc.levelId);
|
|
166
|
+
const [entry] = from.splice(from.findIndex((c) => c.id === elementId), 1);
|
|
167
|
+
const to = draft.children(newParentId ?? "root");
|
|
168
|
+
const insertAt = afterId === null
|
|
169
|
+
? 0
|
|
170
|
+
: (() => {
|
|
171
|
+
const found = to.findIndex((c) => c.id === afterId);
|
|
172
|
+
return found === -1 ? to.length : found + 1;
|
|
173
|
+
})();
|
|
174
|
+
to.splice(insertAt, 0, entry);
|
|
175
|
+
return draft.toWrites();
|
|
176
|
+
}
|
|
177
|
+
/** delete_elements 用。[elementIds]を配下ごと消す。既に無いidは無視する。 */
|
|
178
|
+
export function buildLevelsDeleteWrites(levelsPath, snapshot, elementIds) {
|
|
179
|
+
const index = indexOf(snapshot);
|
|
180
|
+
const deletedIds = new Set();
|
|
181
|
+
for (const id of elementIds) {
|
|
182
|
+
if (!index.has(id))
|
|
183
|
+
continue;
|
|
184
|
+
for (const sub of subtreeIdsInLevels(snapshot, id))
|
|
185
|
+
deletedIds.add(sub);
|
|
186
|
+
}
|
|
187
|
+
const draft = new LevelsDraft(levelsPath, snapshot);
|
|
188
|
+
for (const id of deletedIds) {
|
|
189
|
+
const loc = index.get(id);
|
|
190
|
+
// 親も消えるなら、親の子一覧ドキュメントごと消えるので書き換えない。
|
|
191
|
+
if (loc.parentId !== null && deletedIds.has(loc.parentId))
|
|
192
|
+
continue;
|
|
193
|
+
const list = draft.children(loc.levelId);
|
|
194
|
+
list.splice(list.findIndex((c) => c.id === id), 1);
|
|
195
|
+
}
|
|
196
|
+
for (const id of deletedIds)
|
|
197
|
+
draft.remove(id);
|
|
198
|
+
return { writes: draft.toWrites(), deletedIds };
|
|
199
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { canEditHeader, headerFromFirestore } from "./header.js";
|
|
2
|
+
export class MapNotFoundError extends Error {
|
|
3
|
+
constructor(mapId) {
|
|
4
|
+
super(`マップが見つかりません(id: ${mapId})。list_mapsで存在するIDを確認してください。`);
|
|
5
|
+
this.name = "MapNotFoundError";
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* mapId(headerId)から、自分の所有マップか共有されたマップかを判定し、
|
|
10
|
+
* 実際にドキュメントを持っているユーザーのuidを特定する。
|
|
11
|
+
* 自分の所有物を先に見に行き、無ければ自分のrefs(共有されて見えているマップ)
|
|
12
|
+
* から探す(header_list_model.dartの一覧結合と同じ考え方)。
|
|
13
|
+
*/
|
|
14
|
+
export async function resolveMap(client, session, mapId) {
|
|
15
|
+
const own = await client.getDocument(`users/${session.uid}/headers/${mapId}`);
|
|
16
|
+
if (own) {
|
|
17
|
+
return { headerId: mapId, ownerUid: session.uid, header: headerFromFirestore(mapId, session.uid, own.data) };
|
|
18
|
+
}
|
|
19
|
+
const refs = await client.listDocuments(`users/${session.uid}/refs`);
|
|
20
|
+
for (const ref of refs) {
|
|
21
|
+
if (ref.data.headerId !== mapId)
|
|
22
|
+
continue;
|
|
23
|
+
const ownerUid = ref.data.userId;
|
|
24
|
+
if (typeof ownerUid !== "string" || ownerUid === "")
|
|
25
|
+
continue; // 壊れたrefsは無視する
|
|
26
|
+
const doc = await client.getDocument(`users/${ownerUid}/headers/${mapId}`);
|
|
27
|
+
if (doc) {
|
|
28
|
+
return { headerId: mapId, ownerUid, header: headerFromFirestore(mapId, ownerUid, doc.data) };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
throw new MapNotFoundError(mapId);
|
|
32
|
+
}
|
|
33
|
+
export function bodiesPathOf(map) {
|
|
34
|
+
return `users/${map.ownerUid}/headers/${map.headerId}/bodies`;
|
|
35
|
+
}
|
|
36
|
+
/** levels移行: bodiesPathOfと対になる、階層ごとの子一覧を配列で持つ新形式コレクション。 */
|
|
37
|
+
export function levelsPathOf(map) {
|
|
38
|
+
return `users/${map.ownerUid}/headers/${map.headerId}/levels`;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* 書き込み系ツールの入口で呼ぶ。閲覧のみで共有されているマップに対する
|
|
42
|
+
* 書き込みを、Firestoreルールに委ねる前にわかりやすいメッセージで止める。
|
|
43
|
+
*/
|
|
44
|
+
export function assertCanEditMap(map, session) {
|
|
45
|
+
if (!canEditHeader(map.header, session.uid, session.email)) {
|
|
46
|
+
throw new Error(`「${map.header.title}」は閲覧のみで共有されているため編集できません。`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/** マップ名から要素までの祖先チェーンを「>」区切りの1本の文字列にする。 */
|
|
50
|
+
export function formatPath(mapTitle, ancestorDetails) {
|
|
51
|
+
return [mapTitle, ...ancestorDetails].join(">");
|
|
52
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { newDocId } from "./idGen.js";
|
|
2
|
+
const LIST_PATTERN = /^(\s*)(?:[-*+]|\d+\.)\s+(.+)$/;
|
|
3
|
+
const CHECKBOX_PATTERN = /^\[([ xX])\]\s*(.*)$/;
|
|
4
|
+
const HEADING_PATTERN = /^(#{1,6})\s+(.*)$/;
|
|
5
|
+
/**
|
|
6
|
+
* Markdownテキストを解析して取り込める形の[MarkdownNode]の並びにする。
|
|
7
|
+
*
|
|
8
|
+
* `headingsAsNodes`は、見出しを1つの要素として取り込むか
|
|
9
|
+
* (import_markdown_contextツールでは常にtrue相当、Claudeが組み立てた
|
|
10
|
+
* 構造をそのまま反映するため)を切り替える。
|
|
11
|
+
*
|
|
12
|
+
* 署名行(`---`)より後は読み捨てる。エクスポートしたものを再度取り込んでも
|
|
13
|
+
* 署名が要素として入らないようにするため。
|
|
14
|
+
*/
|
|
15
|
+
export function parseMarkdownForImport(markdownText, { headingsAsNodes }) {
|
|
16
|
+
const rawLines = markdownText.split(/\n?\n/);
|
|
17
|
+
const lines = [];
|
|
18
|
+
for (const line of rawLines) {
|
|
19
|
+
if (line.trim() === "---")
|
|
20
|
+
break;
|
|
21
|
+
lines.push(line);
|
|
22
|
+
}
|
|
23
|
+
let headingTitle = null;
|
|
24
|
+
for (const line of lines) {
|
|
25
|
+
const trimLine = line.trim();
|
|
26
|
+
if (trimLine.startsWith("# ")) {
|
|
27
|
+
headingTitle = trimLine.substring(2).trim();
|
|
28
|
+
break;
|
|
29
|
+
}
|
|
30
|
+
else if (trimLine.startsWith("## ")) {
|
|
31
|
+
headingTitle = trimLine.substring(3).trim();
|
|
32
|
+
break;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const nodes = [];
|
|
36
|
+
let currentNode = null;
|
|
37
|
+
let hasCheckbox = false;
|
|
38
|
+
for (const line of lines) {
|
|
39
|
+
const match = LIST_PATTERN.exec(line);
|
|
40
|
+
if (match) {
|
|
41
|
+
const indentStr = match[1] ?? "";
|
|
42
|
+
let detail = match[2] ?? "";
|
|
43
|
+
const indent = indentStr.replace(/\t/g, " ").length;
|
|
44
|
+
let done = false;
|
|
45
|
+
const checkboxMatch = CHECKBOX_PATTERN.exec(detail);
|
|
46
|
+
if (checkboxMatch) {
|
|
47
|
+
hasCheckbox = true;
|
|
48
|
+
done = checkboxMatch[1].toLowerCase() === "x";
|
|
49
|
+
detail = checkboxMatch[2] ?? "";
|
|
50
|
+
}
|
|
51
|
+
currentNode = {
|
|
52
|
+
id: newDocId(),
|
|
53
|
+
detail,
|
|
54
|
+
rawIndent: indent,
|
|
55
|
+
done,
|
|
56
|
+
parentId: null,
|
|
57
|
+
prevId: null,
|
|
58
|
+
nextId: null,
|
|
59
|
+
childId: null,
|
|
60
|
+
};
|
|
61
|
+
nodes.push(currentNode);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
const trimmed = line.trim();
|
|
65
|
+
if (headingsAsNodes) {
|
|
66
|
+
const headingMatch = HEADING_PATTERN.exec(trimmed);
|
|
67
|
+
if (headingMatch) {
|
|
68
|
+
const level = headingMatch[1].length;
|
|
69
|
+
const text = headingMatch[2].trim();
|
|
70
|
+
if (text === "")
|
|
71
|
+
continue;
|
|
72
|
+
currentNode = {
|
|
73
|
+
id: newDocId(),
|
|
74
|
+
detail: text,
|
|
75
|
+
rawIndent: level - 7,
|
|
76
|
+
done: false,
|
|
77
|
+
parentId: null,
|
|
78
|
+
prevId: null,
|
|
79
|
+
nextId: null,
|
|
80
|
+
childId: null,
|
|
81
|
+
};
|
|
82
|
+
nodes.push(currentNode);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (trimmed === "" || trimmed.startsWith("#"))
|
|
87
|
+
continue;
|
|
88
|
+
if (currentNode) {
|
|
89
|
+
currentNode.detail += `\n${trimmed}`;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
buildRelations(nodes);
|
|
93
|
+
return { nodes, headingTitle, hasCheckbox };
|
|
94
|
+
}
|
|
95
|
+
function buildRelations(nodes) {
|
|
96
|
+
if (nodes.length === 0)
|
|
97
|
+
return;
|
|
98
|
+
const uniqueIndents = [...new Set(nodes.map((n) => n.rawIndent))].sort((a, b) => a - b);
|
|
99
|
+
const indentToDepth = new Map();
|
|
100
|
+
uniqueIndents.forEach((indent, i) => indentToDepth.set(indent, i));
|
|
101
|
+
const lastNodeAtDepth = new Map();
|
|
102
|
+
const byId = new Map(nodes.map((n) => [n.id, n]));
|
|
103
|
+
for (const node of nodes) {
|
|
104
|
+
const depth = indentToDepth.get(node.rawIndent) ?? 0;
|
|
105
|
+
if (depth > 0) {
|
|
106
|
+
node.parentId = lastNodeAtDepth.get(depth - 1) ?? null;
|
|
107
|
+
}
|
|
108
|
+
const prevId = lastNodeAtDepth.get(depth);
|
|
109
|
+
if (prevId !== undefined) {
|
|
110
|
+
node.prevId = prevId;
|
|
111
|
+
byId.get(prevId).nextId = node.id;
|
|
112
|
+
}
|
|
113
|
+
else if (node.parentId !== null) {
|
|
114
|
+
byId.get(node.parentId).childId = node.id;
|
|
115
|
+
}
|
|
116
|
+
lastNodeAtDepth.set(depth, node.id);
|
|
117
|
+
for (const key of [...lastNodeAtDepth.keys()]) {
|
|
118
|
+
if (key > depth)
|
|
119
|
+
lastNodeAtDepth.delete(key);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|