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.
package/README.en.md CHANGED
@@ -11,7 +11,7 @@ A tool that lets you read and write your own maps in Matryoshka Mind Map (a mind
11
11
 
12
12
  This tool runs entirely on your computer, with no always-on server. It starts only when needed and accesses your own maps with your own login credentials (the developer cannot see your data).
13
13
 
14
- > **For users who installed via `git clone` before September 30, 2026**: Due to a data format migration in the app, older versions will fail when writing to maps. Please replace the registration in Step 5 below with `npx -y mmm-mcp-server` (you do not need to log in again). If you want to keep using your cloned copy, run `git pull && npm install && npm run build` in the `mmm_mcp` folder and restart your AI agent.
14
+ > **For users who installed via `git clone` before September 30, 2026**: Due to a data format migration in the app, older versions will fail when writing to maps. Please replace the registration in Step 5 below with `npx -y mmm-mcp-server@latest` (you do not need to log in again). If you want to keep using your cloned copy, run `git pull && npm install && npm run build` in the `mmm_mcp` folder and restart your AI agent.
15
15
 
16
16
  > This guide walks you through every step, starting with how to open the terminal (the window where you type commands), so that you can follow along even if you are not used to working with a computer.
17
17
  >
@@ -66,7 +66,7 @@ Choose one of the following three options based on how you usually log in to the
66
66
  ### If you log in with Google
67
67
 
68
68
  ```bash
69
- npx -y -p mmm-mcp-server mmm-login login --method google
69
+ npx -y -p mmm-mcp-server@latest mmm-login login --method google
70
70
  ```
71
71
 
72
72
  Your browser opens automatically; log in with the Google account you usually use.
@@ -74,7 +74,7 @@ Your browser opens automatically; log in with the Google account you usually use
74
74
  ### If you log in with Apple
75
75
 
76
76
  ```bash
77
- npx -y -p mmm-mcp-server mmm-login login --method apple
77
+ npx -y -p mmm-mcp-server@latest mmm-login login --method apple
78
78
  ```
79
79
 
80
80
  Your browser opens automatically; log in with your Apple ID.
@@ -82,7 +82,7 @@ Your browser opens automatically; log in with your Apple ID.
82
82
  ### If you log in with an email address and password
83
83
 
84
84
  ```bash
85
- npx -y -p mmm-mcp-server mmm-login login --method email
85
+ npx -y -p mmm-mcp-server@latest mmm-login login --method email
86
86
  ```
87
87
 
88
88
  You will be asked for your email address and password (the password is not shown on screen).
@@ -92,23 +92,23 @@ You will be asked for your email address and password (the password is not shown
92
92
  Once you have logged in, you do not need to log in again each time (your credentials are stored only on your computer, in a form that only you can read).
93
93
 
94
94
  ```bash
95
- npx -y -p mmm-mcp-server mmm-login status
95
+ npx -y -p mmm-mcp-server@latest mmm-login status
96
96
  ```
97
97
 
98
98
  shows your login status. To log out:
99
99
 
100
100
  ```bash
101
- npx -y -p mmm-mcp-server mmm-login logout
101
+ npx -y -p mmm-mcp-server@latest mmm-login logout
102
102
  ```
103
103
 
104
104
  ## 5. Register It with Your AI Agent
105
105
 
106
- Below are the instructions for Claude Desktop and Claude Code, which have been tested. If you use another MCP-compatible client (e.g., Codex), in most cases you only need to register the server launch command (`npx -y mmm-mcp-server`) in that client's MCP server settings. See your client's documentation for the exact steps.
106
+ Below are the instructions for Claude Desktop and Claude Code, which have been tested. If you use another MCP-compatible client (e.g., Codex), in most cases you only need to register the server launch command (`npx -y mmm-mcp-server@latest`) in that client's MCP server settings. See your client's documentation for the exact steps.
107
107
 
108
108
  ### Claude Code
109
109
 
110
110
  ```bash
111
- claude mcp add mmm -- npx -y mmm-mcp-server
111
+ claude mcp add mmm -- npx -y mmm-mcp-server@latest
112
112
  ```
113
113
 
114
114
  ### Claude Desktop
@@ -120,7 +120,7 @@ Open the Claude Desktop configuration file (`claude_desktop_config.json`) and ad
120
120
  "mcpServers": {
121
121
  "mmm": {
122
122
  "command": "npx",
123
- "args": ["-y", "mmm-mcp-server"]
123
+ "args": ["-y", "mmm-mcp-server@latest"]
124
124
  }
125
125
  }
126
126
  }
@@ -164,6 +164,38 @@ The reasons:
164
164
  - **Do not mix URLs with text**: A URL in the same element as other text is not treated as a link in the app, so tapping it does nothing.
165
165
  - **Chronological order**: Compared with ordering by task name, checking items off in order makes it easier to notice anything you missed.
166
166
 
167
+ ## Updating to the Latest Version
168
+
169
+ If your registration in Step 5 uses `mmm-mcp-server@latest`, the latest version is used every time your AI agent starts, so there is nothing to do.
170
+
171
+ If you registered with `npx -y mmm-mcp-server` (without `@latest`), you may still be running the old version that was downloaded the first time. Update the registration as follows (you do not need to log in again).
172
+
173
+ ### Claude Code
174
+
175
+ Remove the registration, then register it again.
176
+
177
+ ```bash
178
+ claude mcp remove mmm
179
+ ```
180
+
181
+ ```bash
182
+ claude mcp add mmm -- npx -y mmm-mcp-server@latest
183
+ ```
184
+
185
+ If you registered with `--scope user`, add `--scope user` to both commands.
186
+
187
+ ```bash
188
+ claude mcp remove mmm --scope user
189
+ ```
190
+
191
+ ```bash
192
+ claude mcp add mmm --scope user -- npx -y mmm-mcp-server@latest
193
+ ```
194
+
195
+ ### Claude Desktop
196
+
197
+ Change `"args"` in `claude_desktop_config.json` to `["-y", "mmm-mcp-server@latest"]`, save it, and restart Claude Desktop.
198
+
167
199
  ## Troubleshooting
168
200
 
169
201
  | Symptom | Cause / Solution |
@@ -189,7 +221,7 @@ What follows is **just one example of how it can be used**. It is not a standard
189
221
  When registering with Claude Code in Step 5, the `mmm` tool is only available in the current folder by default. To use it no matter which folder you start `claude` in, register it with `--scope user`:
190
222
 
191
223
  ```bash
192
- claude mcp add mmm --scope user -- npx -y mmm-mcp-server
224
+ claude mcp add mmm --scope user -- npx -y mmm-mcp-server@latest
193
225
  ```
194
226
 
195
227
  **(2) Write the workflow rules in your global CLAUDE.md (`~/.claude/CLAUDE.md`)**
package/README.md CHANGED
@@ -20,7 +20,7 @@ Desktop・Claude Codeに限らず、MCPに対応したAIエージェントであ
20
20
 
21
21
  > **2026年9月30日より前に `git clone` で導入した方へ**: アプリ側のデータ形式の
22
22
  > 切り替えに伴い、古い版ではマップへの書き込みが失敗するようになります。下の手順5の
23
- > 登録を `npx -y mmm-mcp-server` に置き換えてください(ログインし直す必要はありません)。
23
+ > 登録を `npx -y mmm-mcp-server@latest` に置き換えてください(ログインし直す必要はありません)。
24
24
  > clone したまま使い続ける場合は、`mmm_mcp` フォルダで `git pull && npm install && npm run build`
25
25
  > を実行してからAIエージェントを再起動してください。
26
26
 
@@ -98,7 +98,7 @@ Matryoshka Mind Mapアプリで普段どのログイン方法を使っている
98
98
  ### Googleでログインしている場合
99
99
 
100
100
  ```bash
101
- npx -y -p mmm-mcp-server mmm-login login --method google
101
+ npx -y -p mmm-mcp-server@latest mmm-login login --method google
102
102
  ```
103
103
 
104
104
  ブラウザが自動で開くので、いつも使っているGoogleアカウントでログインします。
@@ -106,7 +106,7 @@ npx -y -p mmm-mcp-server mmm-login login --method google
106
106
  ### Appleでログインしている場合
107
107
 
108
108
  ```bash
109
- npx -y -p mmm-mcp-server mmm-login login --method apple
109
+ npx -y -p mmm-mcp-server@latest mmm-login login --method apple
110
110
  ```
111
111
 
112
112
  こちらもブラウザが自動で開くので、Apple IDでログインします。
@@ -114,7 +114,7 @@ npx -y -p mmm-mcp-server mmm-login login --method apple
114
114
  ### メールアドレスとパスワードでログインしている場合
115
115
 
116
116
  ```bash
117
- npx -y -p mmm-mcp-server mmm-login login --method email
117
+ npx -y -p mmm-mcp-server@latest mmm-login login --method email
118
118
  ```
119
119
 
120
120
  メールアドレスとパスワードを聞かれるので入力してください(パスワードは
@@ -131,27 +131,27 @@ npx -y -p mmm-mcp-server mmm-login login --method email
131
131
  あなたのパソコンの中だけに、あなた以外読めない形で保存されます)。
132
132
 
133
133
  ```bash
134
- npx -y -p mmm-mcp-server mmm-login status
134
+ npx -y -p mmm-mcp-server@latest mmm-login status
135
135
  ```
136
136
 
137
137
  でログイン状態を確認できます。ログアウトしたい場合は次のコマンドです。
138
138
 
139
139
  ```bash
140
- npx -y -p mmm-mcp-server mmm-login logout
140
+ npx -y -p mmm-mcp-server@latest mmm-login logout
141
141
  ```
142
142
 
143
143
  ## 5. AIエージェントにMatryoshkaを教える
144
144
 
145
145
  ここでは動作確認済みのClaude Desktop / Claude Codeへの登録方法を示します。
146
146
  他のMCP対応クライアント(例: Codexなど)を使っている場合も、多くは同じように
147
- 「サーバーを起動するコマンド」(`npx -y mmm-mcp-server`)をそのクライアントの
147
+ 「サーバーを起動するコマンド」(`npx -y mmm-mcp-server@latest`)をそのクライアントの
148
148
  MCPサーバー設定に登録するだけで使えるはずです。具体的な登録方法はお使いの
149
149
  クライアントのドキュメントを確認してください。
150
150
 
151
151
  ### Claude Codeを使っている場合
152
152
 
153
153
  ```bash
154
- claude mcp add mmm -- npx -y mmm-mcp-server
154
+ claude mcp add mmm -- npx -y mmm-mcp-server@latest
155
155
  ```
156
156
 
157
157
  ### Claude Desktopを使っている場合
@@ -164,7 +164,7 @@ Claude Desktopの設定ファイル(`claude_desktop_config.json`)を開き
164
164
  "mcpServers": {
165
165
  "mmm": {
166
166
  "command": "npx",
167
- "args": ["-y", "mmm-mcp-server"]
167
+ "args": ["-y", "mmm-mcp-server@latest"]
168
168
  }
169
169
  }
170
170
  }
@@ -223,6 +223,42 @@ AIエージェントに任せると楽な反面、そのままだと読みにく
223
223
  - **時系列で並べる** … 作業の名前で並べるより、順に消していく形になるぶん、
224
224
  抜けに気づきやすくなります
225
225
 
226
+ ## 最新版にする
227
+
228
+ 手順5の登録が `mmm-mcp-server@latest` になっていれば、AIエージェントを起動するたびに
229
+ 最新版が使われるので、何もする必要はありません。
230
+
231
+ `@latest` の付いていない `npx -y mmm-mcp-server` で登録した方は、最初に取得した古い版を
232
+ 使い続けていることがあります。次のように登録を書き換えてください(ログインし直す必要は
233
+ ありません)。
234
+
235
+ ### Claude Codeを使っている場合
236
+
237
+ 登録を一度消してから、登録し直します。
238
+
239
+ ```bash
240
+ claude mcp remove mmm
241
+ ```
242
+
243
+ ```bash
244
+ claude mcp add mmm -- npx -y mmm-mcp-server@latest
245
+ ```
246
+
247
+ `--scope user` で登録していた方は、消すときも登録するときも `--scope user` を付けます。
248
+
249
+ ```bash
250
+ claude mcp remove mmm --scope user
251
+ ```
252
+
253
+ ```bash
254
+ claude mcp add mmm --scope user -- npx -y mmm-mcp-server@latest
255
+ ```
256
+
257
+ ### Claude Desktopを使っている場合
258
+
259
+ `claude_desktop_config.json` の `"args"` を `["-y", "mmm-mcp-server@latest"]` に書き換えて保存し、
260
+ Claude Desktopを再起動してください。
261
+
226
262
  ## よくあるトラブル
227
263
 
228
264
  | 症状 | 原因・対処 |
@@ -257,7 +293,7 @@ AIエージェントに任せると楽な反面、そのままだと読みにく
257
293
  するには、`--scope user`を付けて登録します。
258
294
 
259
295
  ```bash
260
- claude mcp add mmm --scope user -- npx -y mmm-mcp-server
296
+ claude mcp add mmm --scope user -- npx -y mmm-mcp-server@latest
261
297
  ```
262
298
 
263
299
  **(2) グローバルのCLAUDE.md(`~/.claude/CLAUDE.md`)に運用ルールを書く**
@@ -45,7 +45,7 @@ export class AuthSession {
45
45
  }
46
46
  export class NotSignedInError extends Error {
47
47
  constructor() {
48
- super("サインインしていません。ターミナルで `npx -y -p mmm-mcp-server mmm-login login --method google` を実行してください(email / apple も選べます。git clone して使っている場合は、そのフォルダで `npm run login -- login --method google`)。");
48
+ super("サインインしていません。ターミナルで `npx -y -p mmm-mcp-server@latest mmm-login login --method google` を実行してください(email / apple も選べます。git clone して使っている場合は、そのフォルダで `npm run login -- login --method google`)。");
49
49
  this.name = "NotSignedInError";
50
50
  }
51
51
  }
@@ -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
- }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * 1要素の本文の上限(UTF-16の単位、JavaScriptのlength)。子の一覧は1ドキュメントに
3
+ * 入るため、巨大な書き込み1回でドキュメントの上限1MiBを超えないようにする。
4
+ * アプリの lib/domain/detail_limit.dart と同じ値にする。決めごとは docs/paid-plan.md §7。
5
+ */
6
+ export const MAX_DETAIL_LENGTH = 5000;
7
+ /** 上限を超えたときにClaudeへ返す説明。黙って切らず、分けて書き直してもらう。 */
8
+ export function tooLongDetailMessage(length) {
9
+ return (`要素の本文が${length}文字あり、1要素の上限(${MAX_DETAIL_LENGTH}文字)を超えています。` +
10
+ `何も書き込んでいません。複数の要素に分けて書き直してください。`);
11
+ }
12
+ /** [details]のうち上限を超えるものがあれば、何も書かずに済むよう例外を投げる。 */
13
+ export function assertDetailLengths(details) {
14
+ for (const detail of details) {
15
+ if (detail.length > MAX_DETAIL_LENGTH)
16
+ throw new Error(tooLongDetailMessage(detail.length));
17
+ }
18
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * 新しいマップを一覧の先頭に置くとき、既存の自分のマップの`order`を
3
+ * どう書き換えるかを計算する(Firestoreに触れない)。
4
+ * lib/header_list/header_order.dart の prependedHeaderOrders と同じ考え方。
5
+ *
6
+ * 新しいマップを1にし、既存はアプリの一覧と同じ並び(orderの昇順、
7
+ * 同値はドキュメントidの順)のまま2..n+1に振り直す。値が変わるものだけを返す。
8
+ * 欠番と重複もこの1回で解消する。
9
+ */
10
+ export function prependedHeaderOrders(entries) {
11
+ const sorted = [...entries].sort((a, b) => a.order !== b.order ? a.order - b.order : a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
12
+ const updates = [];
13
+ sorted.forEach((entry, position) => {
14
+ if (entry.order !== position + 2)
15
+ updates.push({ id: entry.id, order: position + 2 });
16
+ });
17
+ return updates;
18
+ }
19
+ /**
20
+ * create_map の書き込み一式。既存の自分のマップを2..n+1に振り直し、新しいマップを
21
+ * order 1 で作る。order を持たないマップは一覧に出ないので触らない。
22
+ * 振り直しは読んだ時点の updateTime を前提条件にする(読んだ後に別の端末で消された
23
+ * マップを、order だけのドキュメントとして作り直さないため)。
24
+ */
25
+ export function buildCreateMapWrites(headersPath, existing, headerId, title, isTodo) {
26
+ const updateTimes = new Map(existing.map((doc) => [doc.id, doc.updateTime]));
27
+ const shifts = prependedHeaderOrders(existing
28
+ .filter((doc) => typeof doc.data.order === "number")
29
+ .map((doc) => ({ id: doc.id, order: doc.data.order })));
30
+ const headerPath = `${headersPath}/${headerId}`;
31
+ return [
32
+ ...shifts.map((s) => ({
33
+ path: `${headersPath}/${s.id}`,
34
+ fields: { order: s.order },
35
+ requireUpdateTime: updateTimes.get(s.id),
36
+ })),
37
+ { path: headerPath, fields: { title, order: 1, isTodo, editors: [], viewers: [] } },
38
+ { path: `${headerPath}/levels/root`, fields: { children: [] } },
39
+ ];
40
+ }
@@ -3,21 +3,15 @@
3
3
  //
4
4
  // levels/{levelId}
5
5
  // children: [ { id, detail, done }, ... ]
6
+ // schedule: { 子のid: { due?: {at, allDay}, start?: {at, allDay} }, ... }
7
+ // (日付を持つ子だけ。docs/schedule.md §2)
8
+ // links: { 子のid: [ { uid, headerId }, ... ], ... }
9
+ // (マップ間リンクを持つ子だけ。docs/map-links.md §8)
6
10
  //
7
11
  // levelIdはマップ最上位が'root'、それ以外はその要素自身のid。配列の並び順が
8
12
  // そのまま兄弟順。子を持つ要素だけドキュメントが存在する(無ければ葉)。
9
13
  //
10
- // MCPは「新形式のみ対応」(移行ロジックはDart側だけに持たせる)。levels/root
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
- }
14
+ // 書き込みは levelsOps.ts が行う。ここはデータ形状の解釈と読み取りだけを持つ。
21
15
  function toEntry(raw) {
22
16
  if (typeof raw !== "object" || raw === null)
23
17
  return null;
@@ -42,42 +36,64 @@ export function childrenOf(data) {
42
36
  }
43
37
  return result;
44
38
  }
45
- /** levels配下の全ドキュメントを読み、levelId -> children[] のMapにする。 */
46
- export async function readAllLevelDocs(client, levelsPath) {
47
- const docs = await client.listDocuments(levelsPath);
39
+ function toScheduleDate(raw) {
40
+ if (typeof raw !== "object" || raw === null)
41
+ return undefined;
42
+ const obj = raw;
43
+ if (!(obj.at instanceof Date) || typeof obj.allDay !== "boolean")
44
+ return undefined;
45
+ return { at: obj.at, allDay: obj.allDay };
46
+ }
47
+ /** levelsドキュメントの schedule を子のid -> 日付にする。形の崩れたもの・空のものは読み飛ばす。 */
48
+ export function scheduleOf(data) {
48
49
  const result = new Map();
49
- for (const doc of docs) {
50
- result.set(doc.id, childrenOf(doc.data));
50
+ const raw = data.schedule;
51
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw))
52
+ return result;
53
+ for (const [id, value] of Object.entries(raw)) {
54
+ if (typeof value !== "object" || value === null)
55
+ continue;
56
+ const obj = value;
57
+ const entry = {};
58
+ const due = toScheduleDate(obj.due);
59
+ const start = toScheduleDate(obj.start);
60
+ if (due)
61
+ entry.due = due;
62
+ if (start)
63
+ entry.start = start;
64
+ if (due || start)
65
+ result.set(id, entry);
51
66
  }
52
67
  return result;
53
68
  }
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);
69
+ /** levelsドキュメントの links を子のid -> リンク先にする。形の崩れたもの・空のもの・重複は読み飛ばす。 */
70
+ export function linksOf(data) {
71
+ const result = new Map();
72
+ const raw = data.links;
73
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw))
74
+ return result;
75
+ for (const [id, value] of Object.entries(raw)) {
76
+ if (!Array.isArray(value))
77
+ continue;
78
+ const targets = [];
79
+ for (const item of value) {
80
+ if (typeof item !== "object" || item === null)
81
+ continue;
82
+ const obj = item;
83
+ if (typeof obj.uid !== "string" || typeof obj.headerId !== "string")
84
+ continue;
85
+ if (targets.some((t) => t.uid === obj.uid && t.headerId === obj.headerId))
86
+ continue;
87
+ targets.push({ uid: obj.uid, headerId: obj.headerId });
88
+ }
89
+ if (targets.length > 0)
90
+ result.set(id, targets);
75
91
  }
92
+ return result;
76
93
  }
77
94
  /**
78
- * 'root'から再帰的にlevelsを展開してツリーを組み立てる(bodies版buildTreeの
79
- * levels版)。levelsは配列そのものが順序を表すため、連結リストの再構築や
80
- * 分断の復元は不要。循環参照だけは防御的に検出して打ち切る。
95
+ * 'root'から再帰的にlevelsを展開してツリーを組み立てる。levelsは配列そのものが
96
+ * 順序を表すため、並び順の再構築は不要。循環参照だけは防御的に検出して打ち切る。
81
97
  */
82
98
  export function buildTreeFromLevels(levelsById) {
83
99
  function walk(levelId, parentId, ancestry) {
@@ -97,148 +113,3 @@ export function buildTreeFromLevels(levelsById) {
97
113
  }
98
114
  return walk("root", null, new Set());
99
115
  }
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
- }