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 +42 -10
- package/README.md +46 -10
- package/dist/src/auth/session.js +1 -1
- package/dist/src/domain/appendForest.js +0 -50
- package/dist/src/domain/detailLimit.js +18 -0
- package/dist/src/domain/headerOrder.js +40 -0
- package/dist/src/domain/levels.js +56 -185
- package/dist/src/domain/levelsOps.js +195 -8
- package/dist/src/domain/mapResolver.js +4 -3
- package/dist/src/domain/markdownParser.js +40 -22
- package/dist/src/domain/scheduleText.js +29 -0
- package/dist/src/firestore/restClient.js +0 -39
- package/dist/src/firestore/valueCodec.js +6 -0
- package/dist/src/server.js +3 -1
- package/dist/src/tools/autoStructureThought.js +16 -49
- package/dist/src/tools/createMap.js +6 -16
- package/dist/src/tools/deleteElements.js +19 -92
- package/dist/src/tools/editElement.js +17 -45
- package/dist/src/tools/importMarkdownContext.js +12 -54
- package/dist/src/tools/listElements.js +16 -15
- package/dist/src/tools/moveElement.js +10 -51
- package/dist/src/tools/setSchedule.js +54 -0
- 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
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`)に運用ルールを書く**
|
package/dist/src/auth/session.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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
|
|
55
|
-
export function
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
|
79
|
-
*
|
|
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
|
-
}
|