mmm-mcp-server 0.1.2 → 0.2.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.
@@ -1,5 +1,4 @@
1
- // levels移行・切替後(config/app.migrationEnabled が true)の書き込み。
2
- // bodiesを一切読まず書かず、levelsコレクションだけで要素を探して書き換える。
1
+ // levelsコレクションの読み取り結果から、要素を探して書き換える書き込みを組み立てる。
3
2
  //
4
3
  // どの操作も「levels全件の読み取り結果(LevelsSnapshot)」を受け取る純粋関数で、
5
4
  // 触った子一覧を作業用に複製して編集し、最後に元の状態との差分から
@@ -8,9 +7,30 @@
8
7
  //
9
8
  // 子を持たなくなった要素の子一覧ドキュメントは消す(アプリのprojectLevelsと
10
9
  // 同じ扱い)。rootだけは空配列で残す。
11
- import { childrenOf } from "./levels.js";
10
+ //
11
+ // 期日・着手日(schedule)は children と同じドキュメントにあり、同じ1件の書き込みで
12
+ // 丸ごと書き直す。子を別の親へ移すときは日付も移し、消すときは外す。書くときは
13
+ // children に無いidを取り除く(古い版が取り残した分の掃除。docs/schedule.md §4)。
14
+ //
15
+ // マップ間リンク(links)も同じドキュメントにあり、日付と同じ扱いにする
16
+ // (docs/map-links.md §8)。ただしリンクを足す・外すツールは無い。移すときに運び、消すときに外す。
17
+ // header の linkCounts(リンク先ごとの件数の要約)は別ドキュメントなので、
18
+ // [withLinkSummary]で、変わるときだけ同じコミットへ足す。
19
+ import { buildTreeFromLevels, childrenOf, linksOf, scheduleOf, } from "./levels.js";
12
20
  export function levelsSnapshotFromDocs(docs) {
13
- return new Map(docs.map((d) => [d.id, { children: childrenOf(d.data), updateTime: d.updateTime }]));
21
+ return new Map(docs.map((d) => [
22
+ d.id,
23
+ { children: childrenOf(d.data), schedule: scheduleOf(d.data), links: linksOf(d.data), updateTime: d.updateTime },
24
+ ]));
25
+ }
26
+ /**
27
+ * levels全件を読む。levels/rootが無いマップは空のマップとして扱う
28
+ * (rootへの最初の書き込みは、存在しないことを前提条件にした新規作成になる)。
29
+ * アプリで開いてもrootは作られないので、無いときに止めると中身の無い古い
30
+ * マップへ永久に書き込めなくなる。
31
+ */
32
+ export async function readLevelsSnapshot(client, levelsPath) {
33
+ return levelsSnapshotFromDocs(await client.listDocuments(levelsPath));
14
34
  }
15
35
  /**
16
36
  * rootから辿れる要素だけを対象に、要素id -> 場所 の索引を作る。
@@ -64,11 +84,27 @@ export function subtreeIdsInLevels(snapshot, rootId) {
64
84
  walk(rootId);
65
85
  return result;
66
86
  }
87
+ /** list_elements 用。rootから辿れる要素の木に、各要素の期日・着手日を添える。 */
88
+ export function elementTreeOf(snapshot) {
89
+ const tree = buildTreeFromLevels(new Map([...snapshot].map(([id, level]) => [id, level.children])));
90
+ const attach = (nodes) => {
91
+ for (const node of nodes) {
92
+ const schedule = snapshot.get(node.parentId ?? "root")?.schedule.get(node.id);
93
+ if (schedule)
94
+ node.schedule = schedule;
95
+ attach(node.children);
96
+ }
97
+ };
98
+ attach(tree);
99
+ return tree;
100
+ }
67
101
  /** 編集中の子一覧。触ったものだけを複製して持ち、最後に差分を書き込みにする。 */
68
102
  class LevelsDraft {
69
103
  levelsPath;
70
104
  snapshot;
71
105
  edited = new Map();
106
+ editedSchedule = new Map();
107
+ editedLinks = new Map();
72
108
  removed = new Set();
73
109
  constructor(levelsPath, snapshot) {
74
110
  this.levelsPath = levelsPath;
@@ -79,15 +115,58 @@ class LevelsDraft {
79
115
  if (!list) {
80
116
  list = (this.snapshot.get(levelId)?.children ?? []).map((c) => ({ ...c }));
81
117
  this.edited.set(levelId, list);
118
+ this.editedSchedule.set(levelId, new Map(this.snapshot.get(levelId)?.schedule));
119
+ this.editedLinks.set(levelId, new Map(this.snapshot.get(levelId)?.links));
82
120
  this.removed.delete(levelId);
83
121
  }
84
122
  return list;
85
123
  }
124
+ /** [levelId]の子の日付。触ると、その子一覧も書き込みの対象になる。 */
125
+ schedule(levelId) {
126
+ this.children(levelId);
127
+ return this.editedSchedule.get(levelId);
128
+ }
129
+ /** [levelId]の子のマップ間リンク。触ると、その子一覧も書き込みの対象になる。 */
130
+ links(levelId) {
131
+ this.children(levelId);
132
+ return this.editedLinks.get(levelId);
133
+ }
86
134
  /** 子一覧ドキュメントごと消す(配下ごと削除される要素の分)。 */
87
135
  remove(levelId) {
88
136
  this.edited.delete(levelId);
137
+ this.editedSchedule.delete(levelId);
138
+ this.editedLinks.delete(levelId);
89
139
  this.removed.add(levelId);
90
140
  }
141
+ /**
142
+ * 書き込む schedule。children に無いidは除く。元も今も日付が無ければ
143
+ * undefined を返し、schedule のフィールドに触れない(日付を使わないマップの
144
+ * 書き込みを今までと同じに保つ)。
145
+ */
146
+ scheduleToWrite(levelId, children) {
147
+ const ids = new Set(children.map((c) => c.id));
148
+ const result = {};
149
+ for (const [id, entry] of this.editedSchedule.get(levelId) ?? []) {
150
+ if (ids.has(id))
151
+ result[id] = entry;
152
+ }
153
+ const hadSchedule = (this.snapshot.get(levelId)?.schedule.size ?? 0) > 0;
154
+ return hadSchedule || Object.keys(result).length > 0 ? result : undefined;
155
+ }
156
+ /**
157
+ * 書き込む links。children に無いidは除く。元も今もリンクが無ければ undefined を返し、
158
+ * links のフィールドに触れない(リンクを使わないマップの書き込みを今までと同じに保つ)。
159
+ */
160
+ linksToWrite(levelId, children) {
161
+ const ids = new Set(children.map((c) => c.id));
162
+ const result = {};
163
+ for (const [id, targets] of this.editedLinks.get(levelId) ?? []) {
164
+ if (ids.has(id))
165
+ result[id] = targets;
166
+ }
167
+ const hadLinks = (this.snapshot.get(levelId)?.links.size ?? 0) > 0;
168
+ return hadLinks || Object.keys(result).length > 0 ? result : undefined;
169
+ }
91
170
  toWrites() {
92
171
  const writes = [];
93
172
  const pathOf = (levelId) => `${this.levelsPath}/${levelId}`;
@@ -105,9 +184,16 @@ class LevelsDraft {
105
184
  }
106
185
  continue;
107
186
  }
187
+ const schedule = this.scheduleToWrite(levelId, children);
188
+ const links = this.linksToWrite(levelId, children);
189
+ const fields = {
190
+ children,
191
+ ...(schedule ? { schedule } : {}),
192
+ ...(links ? { links } : {}),
193
+ };
108
194
  writes.push(original
109
- ? { path: pathOf(levelId), fields: { children }, requireUpdateTime: original.updateTime }
110
- : { path: pathOf(levelId), fields: { children }, requireMissing: true });
195
+ ? { path: pathOf(levelId), fields, requireUpdateTime: original.updateTime }
196
+ : { path: pathOf(levelId), fields, requireMissing: true });
111
197
  }
112
198
  return writes;
113
199
  }
@@ -127,6 +213,33 @@ export function buildLevelsPatchWrites(levelsPath, snapshot, updates) {
127
213
  }
128
214
  return { writes: draft.toWrites(), found };
129
215
  }
216
+ /** set_schedule 用。見つからなかったidはfoundに入らない。同じ要素への指定は後が勝つ。 */
217
+ export function buildLevelsScheduleWrites(levelsPath, snapshot, updates) {
218
+ const index = indexOf(snapshot);
219
+ const draft = new LevelsDraft(levelsPath, snapshot);
220
+ const found = new Set();
221
+ for (const { elementId, due, start } of updates) {
222
+ const loc = index.get(elementId);
223
+ if (!loc)
224
+ continue;
225
+ const schedule = draft.schedule(loc.levelId);
226
+ const next = { ...schedule.get(elementId) };
227
+ if (due === null)
228
+ delete next.due;
229
+ else if (due !== undefined)
230
+ next.due = due;
231
+ if (start === null)
232
+ delete next.start;
233
+ else if (start !== undefined)
234
+ next.start = start;
235
+ if (next.due || next.start)
236
+ schedule.set(elementId, next);
237
+ else
238
+ schedule.delete(elementId);
239
+ found.add(elementId);
240
+ }
241
+ return { writes: draft.toWrites(), found };
242
+ }
130
243
  /**
131
244
  * auto_structure_thought / import_markdown_context 用。[forest]の最上位を
132
245
  * [targetParentId](nullなら最上位)の子の末尾へ足し、forest内で子を持つ
@@ -140,7 +253,10 @@ export function buildLevelsAppendWrites(levelsPath, snapshot, forest, targetPare
140
253
  done: isTodo ? node.done : false,
141
254
  });
142
255
  for (const node of forest) {
143
- draft.children(node.parentId ?? targetParentId ?? "root").push(entryOf(node));
256
+ const levelId = node.parentId ?? targetParentId ?? "root";
257
+ draft.children(levelId).push(entryOf(node));
258
+ if (node.schedule)
259
+ draft.schedule(levelId).set(node.id, node.schedule);
144
260
  }
145
261
  return draft.toWrites();
146
262
  }
@@ -164,7 +280,20 @@ export function buildLevelsMoveWrites(levelsPath, snapshot, elementId, newParent
164
280
  const draft = new LevelsDraft(levelsPath, snapshot);
165
281
  const from = draft.children(loc.levelId);
166
282
  const [entry] = from.splice(from.findIndex((c) => c.id === elementId), 1);
167
- const to = draft.children(newParentId ?? "root");
283
+ // 日付も一緒に移す(同じ親の中の並べ替えなら、外して同じ所へ戻すだけ)
284
+ const fromSchedule = draft.schedule(loc.levelId);
285
+ const dates = fromSchedule.get(elementId);
286
+ fromSchedule.delete(elementId);
287
+ // リンクも一緒に移す
288
+ const fromLinks = draft.links(loc.levelId);
289
+ const linkTargets = fromLinks.get(elementId);
290
+ fromLinks.delete(elementId);
291
+ const toLevelId = newParentId ?? "root";
292
+ const to = draft.children(toLevelId);
293
+ if (dates)
294
+ draft.schedule(toLevelId).set(elementId, dates);
295
+ if (linkTargets)
296
+ draft.links(toLevelId).set(elementId, linkTargets);
168
297
  const insertAt = afterId === null
169
298
  ? 0
170
299
  : (() => {
@@ -192,8 +321,66 @@ export function buildLevelsDeleteWrites(levelsPath, snapshot, elementIds) {
192
321
  continue;
193
322
  const list = draft.children(loc.levelId);
194
323
  list.splice(list.findIndex((c) => c.id === id), 1);
324
+ draft.schedule(loc.levelId).delete(id);
325
+ draft.links(loc.levelId).delete(id);
195
326
  }
196
327
  for (const id of deletedIds)
197
328
  draft.remove(id);
198
329
  return { writes: draft.toWrites(), deletedIds };
199
330
  }
331
+ /**
332
+ * header の linkCounts に書く要約: リンク先のキー(`uid:headerId`)-> そのマップへリンクして
333
+ * いる要素の数。いま子一覧にある要素のリンクだけを数え、1つの要素が同じマップへ
334
+ * 複数張っていても1つと数える(アプリの linkCountsOf と同じ)。
335
+ */
336
+ export function linkCountsOfSnapshot(snapshot) {
337
+ const counts = {};
338
+ for (const level of snapshot.values()) {
339
+ const ids = new Set(level.children.map((c) => c.id));
340
+ for (const [id, targets] of level.links) {
341
+ if (!ids.has(id))
342
+ continue;
343
+ for (const key of new Set(targets.map((t) => `${t.uid}:${t.headerId}`))) {
344
+ counts[key] = (counts[key] ?? 0) + 1;
345
+ }
346
+ }
347
+ }
348
+ return counts;
349
+ }
350
+ /** [writes]を[snapshot]へ適用した後の状態(書き込みを組み立て直さず、要約の比較に使う)。 */
351
+ function applyWritesToSnapshot(levelsPath, snapshot, writes) {
352
+ const after = new Map(snapshot);
353
+ const prefix = `${levelsPath}/`;
354
+ for (const write of writes) {
355
+ if (!write.path.startsWith(prefix))
356
+ continue;
357
+ const levelId = write.path.slice(prefix.length);
358
+ if (write.delete) {
359
+ after.delete(levelId);
360
+ continue;
361
+ }
362
+ if (!write.fields)
363
+ continue;
364
+ const before = after.get(levelId);
365
+ after.set(levelId, {
366
+ children: write.fields.children !== undefined ? childrenOf(write.fields) : (before?.children ?? []),
367
+ schedule: before?.schedule ?? new Map(),
368
+ // links を書いていなければ、書き込み前のまま残る
369
+ links: write.fields.links !== undefined ? linksOf(write.fields) : (before?.links ?? new Map()),
370
+ updateTime: before?.updateTime,
371
+ });
372
+ }
373
+ return after;
374
+ }
375
+ /**
376
+ * [writes]でリンクの要約(linkCounts)が変わるときだけ、header への書き込みを足して返す
377
+ * (変わらなければ[writes]のまま)。levels と同じコミットで書く。header は別ドキュメントなので、
378
+ * levels の「同じドキュメントへ2件書かない」には触れない。
379
+ */
380
+ export function withLinkSummary(headerPath, levelsPath, snapshot, writes) {
381
+ const before = linkCountsOfSnapshot(snapshot);
382
+ const after = linkCountsOfSnapshot(applyWritesToSnapshot(levelsPath, snapshot, writes));
383
+ const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
384
+ const changed = [...keys].some((k) => before[k] !== after[k]);
385
+ return changed ? [...writes, { path: headerPath, fields: { linkCounts: after } }] : writes;
386
+ }
@@ -30,10 +30,11 @@ export async function resolveMap(client, session, mapId) {
30
30
  }
31
31
  throw new MapNotFoundError(mapId);
32
32
  }
33
- export function bodiesPathOf(map) {
34
- return `users/${map.ownerUid}/headers/${map.headerId}/bodies`;
33
+ /** マップの header ドキュメントのパス。 */
34
+ export function headerPathOf(map) {
35
+ return `users/${map.ownerUid}/headers/${map.headerId}`;
35
36
  }
36
- /** levels移行: bodiesPathOfと対になる、階層ごとの子一覧を配列で持つ新形式コレクション。 */
37
+ /** マップの中身(階層ごとの子一覧を配列で持つコレクション)のパス。 */
37
38
  export function levelsPathOf(map) {
38
39
  return `users/${map.ownerUid}/headers/${map.headerId}/levels`;
39
40
  }
@@ -1,7 +1,24 @@
1
1
  import { newDocId } from "./idGen.js";
2
+ import { parseScheduleDate } from "./scheduleText.js";
2
3
  const LIST_PATTERN = /^(\s*)(?:[-*+]|\d+\.)\s+(.+)$/;
3
4
  const CHECKBOX_PATTERN = /^\[([ xX])\]\s*(.*)$/;
4
5
  const HEADING_PATTERN = /^(#{1,6})\s+(.*)$/;
6
+ // Obsidian Tasks の書き方(docs/schedule.md §7)。時刻付きは日付のあとに続ける。
7
+ const SCHEDULE_PATTERN = /\s*(📅|🛫)\s*(\d{4}-\d{2}-\d{2}(?: \d{2}:\d{2})?)/gu;
8
+ /** 要素の1行目から 📅・🛫 の日付を取り出す。読めない日付は本文に残す。 */
9
+ function extractSchedule(detail) {
10
+ const schedule = {};
11
+ const rest = detail.replace(SCHEDULE_PATTERN, (whole, mark, text) => {
12
+ try {
13
+ schedule[mark === "📅" ? "due" : "start"] = parseScheduleDate(text);
14
+ return "";
15
+ }
16
+ catch {
17
+ return whole;
18
+ }
19
+ });
20
+ return schedule.due || schedule.start ? { detail: rest.trim(), schedule } : { detail };
21
+ }
5
22
  /**
6
23
  * Markdownテキストを解析して取り込める形の[MarkdownNode]の並びにする。
7
24
  *
@@ -48,15 +65,17 @@ export function parseMarkdownForImport(markdownText, { headingsAsNodes }) {
48
65
  done = checkboxMatch[1].toLowerCase() === "x";
49
66
  detail = checkboxMatch[2] ?? "";
50
67
  }
68
+ const extracted = extractSchedule(detail);
51
69
  currentNode = {
52
70
  id: newDocId(),
53
- detail,
71
+ detail: extracted.detail,
54
72
  rawIndent: indent,
55
73
  done,
56
74
  parentId: null,
57
75
  prevId: null,
58
76
  nextId: null,
59
77
  childId: null,
78
+ ...(extracted.schedule ? { schedule: extracted.schedule } : {}),
60
79
  };
61
80
  nodes.push(currentNode);
62
81
  continue;
@@ -93,30 +112,29 @@ export function parseMarkdownForImport(markdownText, { headingsAsNodes }) {
93
112
  return { nodes, headingTitle, hasCheckbox };
94
113
  }
95
114
  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]));
115
+ // 親は「自分より字下げが浅い直近の要素」とする。字下げの幅(2・4・タブ)は
116
+ // 浅い深いの比較にしか使わないので、刻み幅が混ざっても同じ結果になる。
117
+ // 段を飛ばした字下げ(`#`の直下の`-`、字下げのばらつき)でも必ず
118
+ // どこかへ繋がり、親も前の兄弟も無い要素ができない。
119
+ // 祖先の並び。末尾が直前の要素で、字下げは末尾ほど深い。
120
+ const ancestors = [];
121
+ // 親のID(最上位はnull)ごとの、いちばん後ろの子。
122
+ const lastChildOf = new Map();
103
123
  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;
124
+ while (ancestors.length > 0 && ancestors[ancestors.length - 1].rawIndent >= node.rawIndent) {
125
+ ancestors.pop();
112
126
  }
113
- else if (node.parentId !== null) {
114
- byId.get(node.parentId).childId = node.id;
127
+ const parent = ancestors.length > 0 ? ancestors[ancestors.length - 1] : null;
128
+ node.parentId = parent?.id ?? null;
129
+ const prev = lastChildOf.get(node.parentId);
130
+ if (prev !== undefined) {
131
+ node.prevId = prev.id;
132
+ prev.nextId = node.id;
115
133
  }
116
- lastNodeAtDepth.set(depth, node.id);
117
- for (const key of [...lastNodeAtDepth.keys()]) {
118
- if (key > depth)
119
- lastNodeAtDepth.delete(key);
134
+ else if (parent !== null) {
135
+ parent.childId = node.id;
120
136
  }
137
+ lastChildOf.set(node.parentId, node);
138
+ ancestors.push(node);
121
139
  }
122
140
  }
@@ -0,0 +1,29 @@
1
+ const PATTERN = /^(\d{4})-(\d{2})-(\d{2})(?: (\d{2}):(\d{2}))?$/;
2
+ const pad = (n) => String(n).padStart(2, "0");
3
+ export function formatScheduleDate(date) {
4
+ const d = date.at;
5
+ if (date.allDay) {
6
+ return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}`;
7
+ }
8
+ return (`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` +
9
+ `${pad(d.getHours())}:${pad(d.getMinutes())}`);
10
+ }
11
+ export function parseScheduleDate(text) {
12
+ const m = PATTERN.exec(text.trim());
13
+ const invalid = () => new Error(`日付「${text}」を読めません。終日なら "YYYY-MM-DD"、時刻付きなら "YYYY-MM-DD HH:mm" で指定してください。`);
14
+ if (!m)
15
+ throw invalid();
16
+ const [y, mo, d] = [Number(m[1]), Number(m[2]), Number(m[3])];
17
+ const hasTime = m[4] !== undefined;
18
+ const [h, mi] = hasTime ? [Number(m[4]), Number(m[5])] : [0, 0];
19
+ if (h > 23 || mi > 59)
20
+ throw invalid();
21
+ const at = hasTime ? new Date(y, mo - 1, d, h, mi) : new Date(Date.UTC(y, mo - 1, d));
22
+ // 2/30 のような存在しない日付は、Date が翌月へ繰り越すので突き合わせて弾く
23
+ const [cy, cm, cd] = hasTime
24
+ ? [at.getFullYear(), at.getMonth() + 1, at.getDate()]
25
+ : [at.getUTCFullYear(), at.getUTCMonth() + 1, at.getUTCDate()];
26
+ if (cy !== y || cm !== mo || cd !== d)
27
+ throw invalid();
28
+ return { at, allDay: !hasTime };
29
+ }
@@ -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) {
@@ -15,6 +15,9 @@ export function encodeValue(value) {
15
15
  if (typeof value === "string") {
16
16
  return { stringValue: value };
17
17
  }
18
+ if (value instanceof Date) {
19
+ return { timestampValue: value.toISOString() };
20
+ }
18
21
  if (Array.isArray(value)) {
19
22
  return { arrayValue: { values: value.map(encodeValue) } };
20
23
  }
@@ -36,6 +39,9 @@ export function decodeValue(value) {
36
39
  return value.doubleValue;
37
40
  if ("stringValue" in value)
38
41
  return value.stringValue;
42
+ // RFC 3339。ナノ秒まで来ることがあるが、Dateはミリ秒までしか持たない
43
+ if ("timestampValue" in value)
44
+ return new Date(value.timestampValue);
39
45
  if ("arrayValue" in value) {
40
46
  return (value.arrayValue.values ?? []).map(decodeValue);
41
47
  }
@@ -10,9 +10,10 @@ import { registerImportMarkdownContext } from "./tools/importMarkdownContext.js"
10
10
  import { registerEditElement } from "./tools/editElement.js";
11
11
  import { registerDeleteElements } from "./tools/deleteElements.js";
12
12
  import { registerMoveElement } from "./tools/moveElement.js";
13
+ import { registerSetSchedule } from "./tools/setSchedule.js";
13
14
  const server = new McpServer({
14
15
  name: "matryoshka-mindmap",
15
- version: "0.1.2",
16
+ version: "0.2.0",
16
17
  }, {
17
18
  instructions: "このサーバーはリアルタイム同期を行わない。各ツールは呼び出された瞬間の" +
18
19
  "Firestoreの状態を返すだけで、アプリ側でユーザーが直接行った変更は" +
@@ -29,5 +30,6 @@ registerImportMarkdownContext(server);
29
30
  registerEditElement(server);
30
31
  registerDeleteElements(server);
31
32
  registerMoveElement(server);
33
+ registerSetSchedule(server);
32
34
  const transport = new StdioServerTransport();
33
35
  await server.connect(transport);
@@ -1,17 +1,18 @@
1
1
  import { z } from "zod";
2
- import { mergeWritesByPath } from "../firestore/restClient.js";
3
2
  import { getContext } from "../mcpContext.js";
4
- import { isLevelsOnly, readLevelsSnapshot } from "../domain/levelsMode.js";
5
- import { ancestorDetailsInLevels, buildLevelsAppendWrites, locateInLevels } from "../domain/levelsOps.js";
6
- import { resolveMap, bodiesPathOf, levelsPathOf, formatPath, assertCanEditMap, } from "../domain/mapResolver.js";
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 { assertDetailLengths } from "../domain/detailLimit.js";
4
+ import { ancestorDetailsInLevels, buildLevelsAppendWrites, locateInLevels, readLevelsSnapshot } from "../domain/levelsOps.js";
5
+ import { resolveMap, levelsPathOf, formatPath, assertCanEditMap, } from "../domain/mapResolver.js";
6
+ import { buildForestFromTree } from "../domain/appendForest.js";
10
7
  const treeNodeSchema = z.lazy(() => z.object({
11
- detail: z.string().min(1).describe("要素の本文"),
8
+ detail: z.string().min(1).describe("要素の本文(1要素5000文字まで)"),
12
9
  done: z.boolean().optional().describe("TODOマップのときのチェック状態(省略時false)"),
13
10
  children: z.array(treeNodeSchema).optional().describe("この要素の内側に入れ子にする子要素"),
14
11
  }));
12
+ /** [nodes]とその配下すべての本文。 */
13
+ function allDetails(nodes) {
14
+ return nodes.flatMap((node) => [node.detail, ...allDetails(node.children ?? [])]);
15
+ }
15
16
  export function registerAutoStructureThought(server) {
16
17
  server.tool("auto_structure_thought", "会話中にClaudeが考えた階層構造を、既存のマップに追加する。深さは3階層固定ではなく任意。" +
17
18
  "構造化のロジック(何をどう分解するか)はClaude側の判断で行い、このツールはFirestoreへの" +
@@ -25,61 +26,27 @@ export function registerAutoStructureThought(server) {
25
26
  .describe("この要素の内側(子)として追加する。マップの最上位に追加する場合はnullまたは省略"),
26
27
  nodes: z.array(treeNodeSchema).min(1).describe("追加する階層構造(ルートの配列)"),
27
28
  }, async ({ mapId, parentElementId, nodes }) => {
29
+ assertDetailLengths(allDetails(nodes));
28
30
  const { client, session } = getContext();
29
31
  const map = await resolveMap(client, session, mapId);
30
32
  assertCanEditMap(map, session);
31
33
  const levelsPath = levelsPathOf(map);
32
- const bodiesPath = bodiesPathOf(map);
33
34
  const targetParentId = parentElementId ?? null;
34
35
  const forest = buildForestFromTree(nodes);
35
- if (await isLevelsOnly(client)) {
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 = [];
36
+ let levelsPathResult = "";
59
37
  await client.runOptimistic(async () => {
60
- assertLevelsRootExists(await levelsDocExists(client, levelsPath, "root"), map.header.title);
61
- const existingDocs = await client.listDocuments(bodiesPath);
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)) {
38
+ const snapshot = await readLevelsSnapshot(client, levelsPath);
39
+ if (targetParentId !== null && !locateInLevels(snapshot, targetParentId)) {
65
40
  throw new Error(`parentElementId(${targetParentId})がこのマップに見つかりません。list_elementsで確認してください。`);
66
41
  }
67
- const { writes, createdIds: ids } = buildAppendWrites(bodiesPath, existingBodies, forest, targetParentId, map.header.isTodo);
68
- createdIds = ids;
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]);
42
+ levelsPathResult = formatPath(map.header.title, ancestorDetailsInLevels(snapshot, targetParentId));
43
+ return buildLevelsAppendWrites(levelsPath, snapshot, forest, targetParentId, map.header.isTodo);
76
44
  });
77
- const path = formatPath(map.header.title, ancestorDetails(existingBodiesForPath, targetParentId));
78
45
  return {
79
46
  content: [
80
47
  {
81
48
  type: "text",
82
- text: JSON.stringify({ path, addedElementCount: createdIds.length }, null, 2),
49
+ text: JSON.stringify({ path: levelsPathResult, addedElementCount: forest.length }, null, 2),
83
50
  },
84
51
  ],
85
52
  };
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { getContext } from "../mcpContext.js";
3
3
  import { newDocId } from "../domain/idGen.js";
4
+ import { buildCreateMapWrites } from "../domain/headerOrder.js";
4
5
  export function registerCreateMap(server) {
5
6
  server.tool("create_map", "新しいマインドマップ(マップ)を1つ作成する。" +
6
7
  "list_mapsで確認して該当するマップが無いときだけ呼ぶこと。" +
@@ -10,23 +11,12 @@ export function registerCreateMap(server) {
10
11
  isTodo: z.boolean().describe("TODOマップ(要素にチェックボックスを付ける)にするかどうか"),
11
12
  }, async ({ title, isTodo }) => {
12
13
  const { client, session } = getContext();
13
- const existing = await client.listDocuments(`users/${session.uid}/headers`);
14
- const maxOrder = existing.reduce((max, doc) => Math.max(max, Number(doc.data.order ?? 0)), 0);
15
- // levels移行: 自前でIDを採番し、header本体と空のlevels/rootを1回の
16
- // commitWritesにまとめて作る。levels/rootを最初から持たせておくことで、
17
- // 直後に書き込み系ツールを呼んでも「アプリで一度開いてください」で
18
- // 弾かれない(移行ではなく単なる初期化なので問題ない)。
19
- // migratedはアプリの新規作成(header_add_model.dart)と揃える。無いと、
20
- // 切替前のアプリが開いたときに未移行とみなしてbodiesから作り直してしまう。
14
+ const headersPath = `users/${session.uid}/headers`;
15
+ // 振り直しは読んだ時点の updateTime を前提条件にするので、読んでから書くまでに
16
+ // ほかの端末で見出しが変わると失敗する。そのときは読み取りからやり直す。
17
+ // id はやり直しても同じものを使う(失敗した commit は何も書いていない)。
21
18
  const headerId = newDocId();
22
- const headerPath = `users/${session.uid}/headers/${headerId}`;
23
- await client.commitWrites([
24
- {
25
- path: headerPath,
26
- fields: { title, order: maxOrder + 1, isTodo, editors: [], viewers: [] },
27
- },
28
- { path: `${headerPath}/levels/root`, fields: { children: [], migrated: true } },
29
- ]);
19
+ await client.runOptimistic(async () => buildCreateMapWrites(headersPath, await client.listDocuments(headersPath), headerId, title, isTodo));
30
20
  return {
31
21
  content: [
32
22
  {