mmm-mcp-server 0.1.3 → 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/detailLimit.js +18 -0
- package/dist/src/domain/headerOrder.js +40 -0
- package/dist/src/domain/levels.js +55 -5
- package/dist/src/domain/levelsOps.js +185 -6
- package/dist/src/domain/mapResolver.js +4 -0
- package/dist/src/domain/markdownParser.js +20 -1
- package/dist/src/domain/scheduleText.js +29 -0
- package/dist/src/firestore/valueCodec.js +6 -0
- package/dist/src/server.js +3 -1
- package/dist/src/tools/autoStructureThought.js +7 -1
- package/dist/src/tools/createMap.js +6 -12
- package/dist/src/tools/deleteElements.js +4 -3
- package/dist/src/tools/editElement.js +7 -2
- package/dist/src/tools/importMarkdownContext.js +3 -1
- package/dist/src/tools/listElements.js +15 -9
- package/dist/src/tools/moveElement.js +4 -3
- package/dist/src/tools/setSchedule.js +54 -0
- package/package.json +1 -1
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
|
}
|
|
@@ -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,6 +3,10 @@
|
|
|
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
|
// そのまま兄弟順。子を持つ要素だけドキュメントが存在する(無ければ葉)。
|
|
@@ -32,12 +36,58 @@ export function childrenOf(data) {
|
|
|
32
36
|
}
|
|
33
37
|
return result;
|
|
34
38
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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) {
|
|
49
|
+
const result = new Map();
|
|
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);
|
|
66
|
+
}
|
|
67
|
+
return result;
|
|
68
|
+
}
|
|
69
|
+
/** levelsドキュメントの links を子のid -> リンク先にする。形の崩れたもの・空のもの・重複は読み飛ばす。 */
|
|
70
|
+
export function linksOf(data) {
|
|
38
71
|
const result = new Map();
|
|
39
|
-
|
|
40
|
-
|
|
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);
|
|
41
91
|
}
|
|
42
92
|
return result;
|
|
43
93
|
}
|
|
@@ -7,9 +7,21 @@
|
|
|
7
7
|
//
|
|
8
8
|
// 子を持たなくなった要素の子一覧ドキュメントは消す(アプリのprojectLevelsと
|
|
9
9
|
// 同じ扱い)。rootだけは空配列で残す。
|
|
10
|
-
|
|
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";
|
|
11
20
|
export function levelsSnapshotFromDocs(docs) {
|
|
12
|
-
return new Map(docs.map((d) => [
|
|
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
|
+
]));
|
|
13
25
|
}
|
|
14
26
|
/**
|
|
15
27
|
* levels全件を読む。levels/rootが無いマップは空のマップとして扱う
|
|
@@ -72,11 +84,27 @@ export function subtreeIdsInLevels(snapshot, rootId) {
|
|
|
72
84
|
walk(rootId);
|
|
73
85
|
return result;
|
|
74
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
|
+
}
|
|
75
101
|
/** 編集中の子一覧。触ったものだけを複製して持ち、最後に差分を書き込みにする。 */
|
|
76
102
|
class LevelsDraft {
|
|
77
103
|
levelsPath;
|
|
78
104
|
snapshot;
|
|
79
105
|
edited = new Map();
|
|
106
|
+
editedSchedule = new Map();
|
|
107
|
+
editedLinks = new Map();
|
|
80
108
|
removed = new Set();
|
|
81
109
|
constructor(levelsPath, snapshot) {
|
|
82
110
|
this.levelsPath = levelsPath;
|
|
@@ -87,15 +115,58 @@ class LevelsDraft {
|
|
|
87
115
|
if (!list) {
|
|
88
116
|
list = (this.snapshot.get(levelId)?.children ?? []).map((c) => ({ ...c }));
|
|
89
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));
|
|
90
120
|
this.removed.delete(levelId);
|
|
91
121
|
}
|
|
92
122
|
return list;
|
|
93
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
|
+
}
|
|
94
134
|
/** 子一覧ドキュメントごと消す(配下ごと削除される要素の分)。 */
|
|
95
135
|
remove(levelId) {
|
|
96
136
|
this.edited.delete(levelId);
|
|
137
|
+
this.editedSchedule.delete(levelId);
|
|
138
|
+
this.editedLinks.delete(levelId);
|
|
97
139
|
this.removed.add(levelId);
|
|
98
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
|
+
}
|
|
99
170
|
toWrites() {
|
|
100
171
|
const writes = [];
|
|
101
172
|
const pathOf = (levelId) => `${this.levelsPath}/${levelId}`;
|
|
@@ -113,9 +184,16 @@ class LevelsDraft {
|
|
|
113
184
|
}
|
|
114
185
|
continue;
|
|
115
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
|
+
};
|
|
116
194
|
writes.push(original
|
|
117
|
-
? { path: pathOf(levelId), fields
|
|
118
|
-
: { path: pathOf(levelId), fields
|
|
195
|
+
? { path: pathOf(levelId), fields, requireUpdateTime: original.updateTime }
|
|
196
|
+
: { path: pathOf(levelId), fields, requireMissing: true });
|
|
119
197
|
}
|
|
120
198
|
return writes;
|
|
121
199
|
}
|
|
@@ -135,6 +213,33 @@ export function buildLevelsPatchWrites(levelsPath, snapshot, updates) {
|
|
|
135
213
|
}
|
|
136
214
|
return { writes: draft.toWrites(), found };
|
|
137
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
|
+
}
|
|
138
243
|
/**
|
|
139
244
|
* auto_structure_thought / import_markdown_context 用。[forest]の最上位を
|
|
140
245
|
* [targetParentId](nullなら最上位)の子の末尾へ足し、forest内で子を持つ
|
|
@@ -148,7 +253,10 @@ export function buildLevelsAppendWrites(levelsPath, snapshot, forest, targetPare
|
|
|
148
253
|
done: isTodo ? node.done : false,
|
|
149
254
|
});
|
|
150
255
|
for (const node of forest) {
|
|
151
|
-
|
|
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);
|
|
152
260
|
}
|
|
153
261
|
return draft.toWrites();
|
|
154
262
|
}
|
|
@@ -172,7 +280,20 @@ export function buildLevelsMoveWrites(levelsPath, snapshot, elementId, newParent
|
|
|
172
280
|
const draft = new LevelsDraft(levelsPath, snapshot);
|
|
173
281
|
const from = draft.children(loc.levelId);
|
|
174
282
|
const [entry] = from.splice(from.findIndex((c) => c.id === elementId), 1);
|
|
175
|
-
|
|
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);
|
|
176
297
|
const insertAt = afterId === null
|
|
177
298
|
? 0
|
|
178
299
|
: (() => {
|
|
@@ -200,8 +321,66 @@ export function buildLevelsDeleteWrites(levelsPath, snapshot, elementIds) {
|
|
|
200
321
|
continue;
|
|
201
322
|
const list = draft.children(loc.levelId);
|
|
202
323
|
list.splice(list.findIndex((c) => c.id === id), 1);
|
|
324
|
+
draft.schedule(loc.levelId).delete(id);
|
|
325
|
+
draft.links(loc.levelId).delete(id);
|
|
203
326
|
}
|
|
204
327
|
for (const id of deletedIds)
|
|
205
328
|
draft.remove(id);
|
|
206
329
|
return { writes: draft.toWrites(), deletedIds };
|
|
207
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,6 +30,10 @@ export async function resolveMap(client, session, mapId) {
|
|
|
30
30
|
}
|
|
31
31
|
throw new MapNotFoundError(mapId);
|
|
32
32
|
}
|
|
33
|
+
/** マップの header ドキュメントのパス。 */
|
|
34
|
+
export function headerPathOf(map) {
|
|
35
|
+
return `users/${map.ownerUid}/headers/${map.headerId}`;
|
|
36
|
+
}
|
|
33
37
|
/** マップの中身(階層ごとの子一覧を配列で持つコレクション)のパス。 */
|
|
34
38
|
export function levelsPathOf(map) {
|
|
35
39
|
return `users/${map.ownerUid}/headers/${map.headerId}/levels`;
|
|
@@ -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;
|
|
@@ -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
|
+
}
|
|
@@ -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
|
}
|
package/dist/src/server.js
CHANGED
|
@@ -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.
|
|
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,13 +1,18 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { getContext } from "../mcpContext.js";
|
|
3
|
+
import { assertDetailLengths } from "../domain/detailLimit.js";
|
|
3
4
|
import { ancestorDetailsInLevels, buildLevelsAppendWrites, locateInLevels, readLevelsSnapshot } from "../domain/levelsOps.js";
|
|
4
5
|
import { resolveMap, levelsPathOf, formatPath, assertCanEditMap, } from "../domain/mapResolver.js";
|
|
5
6
|
import { buildForestFromTree } from "../domain/appendForest.js";
|
|
6
7
|
const treeNodeSchema = z.lazy(() => z.object({
|
|
7
|
-
detail: z.string().min(1).describe("要素の本文"),
|
|
8
|
+
detail: z.string().min(1).describe("要素の本文(1要素5000文字まで)"),
|
|
8
9
|
done: z.boolean().optional().describe("TODOマップのときのチェック状態(省略時false)"),
|
|
9
10
|
children: z.array(treeNodeSchema).optional().describe("この要素の内側に入れ子にする子要素"),
|
|
10
11
|
}));
|
|
12
|
+
/** [nodes]とその配下すべての本文。 */
|
|
13
|
+
function allDetails(nodes) {
|
|
14
|
+
return nodes.flatMap((node) => [node.detail, ...allDetails(node.children ?? [])]);
|
|
15
|
+
}
|
|
11
16
|
export function registerAutoStructureThought(server) {
|
|
12
17
|
server.tool("auto_structure_thought", "会話中にClaudeが考えた階層構造を、既存のマップに追加する。深さは3階層固定ではなく任意。" +
|
|
13
18
|
"構造化のロジック(何をどう分解するか)はClaude側の判断で行い、このツールはFirestoreへの" +
|
|
@@ -21,6 +26,7 @@ export function registerAutoStructureThought(server) {
|
|
|
21
26
|
.describe("この要素の内側(子)として追加する。マップの最上位に追加する場合はnullまたは省略"),
|
|
22
27
|
nodes: z.array(treeNodeSchema).min(1).describe("追加する階層構造(ルートの配列)"),
|
|
23
28
|
}, async ({ mapId, parentElementId, nodes }) => {
|
|
29
|
+
assertDetailLengths(allDetails(nodes));
|
|
24
30
|
const { client, session } = getContext();
|
|
25
31
|
const map = await resolveMap(client, session, mapId);
|
|
26
32
|
assertCanEditMap(map, session);
|
|
@@ -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,19 +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
|
|
14
|
-
|
|
15
|
-
//
|
|
16
|
-
//
|
|
14
|
+
const headersPath = `users/${session.uid}/headers`;
|
|
15
|
+
// 振り直しは読んだ時点の updateTime を前提条件にするので、読んでから書くまでに
|
|
16
|
+
// ほかの端末で見出しが変わると失敗する。そのときは読み取りからやり直す。
|
|
17
|
+
// id はやり直しても同じものを使う(失敗した commit は何も書いていない)。
|
|
17
18
|
const headerId = newDocId();
|
|
18
|
-
|
|
19
|
-
await client.commitWrites([
|
|
20
|
-
{
|
|
21
|
-
path: headerPath,
|
|
22
|
-
fields: { title, order: maxOrder + 1, isTodo, editors: [], viewers: [] },
|
|
23
|
-
},
|
|
24
|
-
{ path: `${headerPath}/levels/root`, fields: { children: [] } },
|
|
25
|
-
]);
|
|
19
|
+
await client.runOptimistic(async () => buildCreateMapWrites(headersPath, await client.listDocuments(headersPath), headerId, title, isTodo));
|
|
26
20
|
return {
|
|
27
21
|
content: [
|
|
28
22
|
{
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { getContext } from "../mcpContext.js";
|
|
3
|
-
import { ancestorDetailsInLevels, buildLevelsDeleteWrites, locateInLevels, subtreeIdsInLevels, readLevelsSnapshot } from "../domain/levelsOps.js";
|
|
4
|
-
import { resolveMap, levelsPathOf, formatPath, assertCanEditMap, } from "../domain/mapResolver.js";
|
|
3
|
+
import { ancestorDetailsInLevels, buildLevelsDeleteWrites, locateInLevels, subtreeIdsInLevels, readLevelsSnapshot, withLinkSummary, } from "../domain/levelsOps.js";
|
|
4
|
+
import { resolveMap, levelsPathOf, headerPathOf, formatPath, assertCanEditMap, } from "../domain/mapResolver.js";
|
|
5
5
|
export function registerDeleteElements(server) {
|
|
6
6
|
server.tool("delete_elements", "既存マップから要素を削除する。指定した要素とその配下(子孫)を丸ごと削除する。" +
|
|
7
7
|
"削除は取り返しがつかないため、まずconfirmedを省略(またはfalse)で呼び、" +
|
|
@@ -66,7 +66,8 @@ export function registerDeleteElements(server) {
|
|
|
66
66
|
}
|
|
67
67
|
const { writes, deletedIds } = buildLevelsDeleteWrites(levelsPath, snapshot, elementIds);
|
|
68
68
|
levelsTotal = deletedIds.size;
|
|
69
|
-
|
|
69
|
+
// リンクの要約(header の linkCounts)が変わるときだけ、同じコミットで書き直す
|
|
70
|
+
return withLinkSummary(headerPathOf(map), levelsPath, snapshot, writes);
|
|
70
71
|
});
|
|
71
72
|
return {
|
|
72
73
|
content: [
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { getContext } from "../mcpContext.js";
|
|
3
|
+
import { assertDetailLengths } from "../domain/detailLimit.js";
|
|
3
4
|
import { ancestorDetailsInLevels, buildLevelsPatchWrites, readLevelsSnapshot } from "../domain/levelsOps.js";
|
|
4
5
|
import { resolveMap, levelsPathOf, formatPath, assertCanEditMap, } from "../domain/mapResolver.js";
|
|
5
6
|
export function registerEditElement(server) {
|
|
@@ -7,8 +8,9 @@ export function registerEditElement(server) {
|
|
|
7
8
|
"import_markdown_contextを、チェック状態の変更はupdate_task_statusを使うこと。", {
|
|
8
9
|
mapId: z.string().describe("list_mapsで得たマップのID"),
|
|
9
10
|
elementId: z.string().describe("list_elementsで得た要素のID"),
|
|
10
|
-
detail: z.string().min(1).describe("書き換え後の本文"),
|
|
11
|
+
detail: z.string().min(1).describe("書き換え後の本文(1要素5000文字まで)"),
|
|
11
12
|
}, async ({ mapId, elementId, detail }) => {
|
|
13
|
+
assertDetailLengths([detail]);
|
|
12
14
|
const { client, session } = getContext();
|
|
13
15
|
const map = await resolveMap(client, session, mapId);
|
|
14
16
|
assertCanEditMap(map, session);
|
|
@@ -22,7 +24,10 @@ export function registerEditElement(server) {
|
|
|
22
24
|
if (!found.has(elementId)) {
|
|
23
25
|
throw new Error(`elementId(${elementId})がこのマップに見つかりません。list_elementsで確認してください。`);
|
|
24
26
|
}
|
|
25
|
-
|
|
27
|
+
// snapshotは書き込み前なので、末尾(この要素自身)だけ書き換え後の本文にする。
|
|
28
|
+
const chain = ancestorDetailsInLevels(snapshot, elementId);
|
|
29
|
+
chain[chain.length - 1] = detail;
|
|
30
|
+
levelsPathResult = formatPath(map.header.title, chain);
|
|
26
31
|
return writes;
|
|
27
32
|
});
|
|
28
33
|
return {
|
|
@@ -3,6 +3,7 @@ import { getContext } from "../mcpContext.js";
|
|
|
3
3
|
import { ancestorDetailsInLevels, buildLevelsAppendWrites, locateInLevels, readLevelsSnapshot } from "../domain/levelsOps.js";
|
|
4
4
|
import { resolveMap, levelsPathOf, formatPath, assertCanEditMap, } from "../domain/mapResolver.js";
|
|
5
5
|
import { parseMarkdownForImport } from "../domain/markdownParser.js";
|
|
6
|
+
import { assertDetailLengths } from "../domain/detailLimit.js";
|
|
6
7
|
export function registerImportMarkdownContext(server) {
|
|
7
8
|
server.tool("import_markdown_context", "任意の深さのMarkdown(インデントで階層を表現したリスト、見出しは`#`)を組み立てて、" +
|
|
8
9
|
"既存のマップに流し込む。見出しも1つの要素として取り込む。" +
|
|
@@ -10,7 +11,7 @@ export function registerImportMarkdownContext(server) {
|
|
|
10
11
|
"チェック状態も反映されない(アプリ本体の挙動と同じ)。" +
|
|
11
12
|
"対象は既存マップのみ(新規作成が必要ならcreate_mapを先に呼ぶ)。", {
|
|
12
13
|
mapId: z.string().describe("list_mapsで得たマップのID"),
|
|
13
|
-
markdown: z.string().min(1).describe("取り込むMarkdownテキスト"),
|
|
14
|
+
markdown: z.string().min(1).describe("取り込むMarkdownテキスト(1要素5000文字まで。超える要素があれば何も書かない)。要素の1行目に「📅 2026-10-15」(期日)・「🛫 2026-10-12」(着手日)と書くと日付として取り込む。時刻付きは「📅 2026-10-15 09:00」(このPCの現地時刻)"),
|
|
14
15
|
parentElementId: z
|
|
15
16
|
.string()
|
|
16
17
|
.nullable()
|
|
@@ -26,6 +27,7 @@ export function registerImportMarkdownContext(server) {
|
|
|
26
27
|
if (parsed.nodes.length === 0) {
|
|
27
28
|
throw new Error("Markdownからリスト項目・見出しが見つかりませんでした。");
|
|
28
29
|
}
|
|
30
|
+
assertDetailLengths(parsed.nodes.map((node) => node.detail));
|
|
29
31
|
let levelsPathResult = "";
|
|
30
32
|
await client.runOptimistic(async () => {
|
|
31
33
|
const snapshot = await readLevelsSnapshot(client, levelsPath);
|
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { getContext } from "../mcpContext.js";
|
|
3
3
|
import { resolveMap, levelsPathOf } from "../domain/mapResolver.js";
|
|
4
|
-
import {
|
|
4
|
+
import { elementTreeOf, readLevelsSnapshot } from "../domain/levelsOps.js";
|
|
5
|
+
import { formatScheduleDate } from "../domain/scheduleText.js";
|
|
5
6
|
// ElementNodeの`done`を`checked`という名前で公開する(仕様書の用語に合わせる)。
|
|
6
7
|
function toChecked(nodes) {
|
|
7
8
|
return nodes.map((n) => ({
|
|
8
9
|
id: n.id,
|
|
9
10
|
detail: n.detail,
|
|
10
11
|
checked: n.done,
|
|
12
|
+
// 日付は付いているときだけ返す(docs/schedule.md §3 MCP)
|
|
13
|
+
...(n.schedule?.due ? { due: formatScheduleDate(n.schedule.due) } : {}),
|
|
14
|
+
...(n.schedule?.start ? { start: formatScheduleDate(n.schedule.start) } : {}),
|
|
11
15
|
children: toChecked(n.children),
|
|
12
16
|
}));
|
|
13
17
|
}
|
|
@@ -37,21 +41,24 @@ class AiReadDeniedError extends Error {
|
|
|
37
41
|
}
|
|
38
42
|
}
|
|
39
43
|
export function registerListElements(server) {
|
|
40
|
-
server.tool("list_elements", "指定したマップ内の全要素と親子関係、チェック状態(checked)を返す。" +
|
|
44
|
+
server.tool("list_elements", "指定したマップ内の全要素と親子関係、チェック状態(checked)、期日(due)・着手日(start)を返す。" +
|
|
45
|
+
"dueとstartは付いている要素にだけあり、終日なら\"YYYY-MM-DD\"、時刻付きなら" +
|
|
46
|
+
"\"YYYY-MM-DD HH:mm\"(このPCの現地時刻)。" +
|
|
41
47
|
"checkedは各要素にFirestoreへ実際に保存されている生の値であり、" +
|
|
42
48
|
"「祖先がチェック済みなら配下も完了とみなす」という加工は行っていない。" +
|
|
43
49
|
"そう解釈したい場合は、返ってきたツリーを見てこちら(呼び出す側)で判断すること。" +
|
|
44
|
-
"
|
|
45
|
-
"
|
|
46
|
-
"
|
|
47
|
-
"
|
|
50
|
+
"呼ぶ前にユーザーへ確認しないこと。まずconfirmedを付けずに呼ぶ。" +
|
|
51
|
+
"アプリでAIからの読み取りを「常に許可」にしたマップは、そのまま実データを返す。" +
|
|
52
|
+
"それ以外のマップは、実データの代わりに確認を促すメッセージだけを返すので、" +
|
|
53
|
+
"そのときに限りユーザーに直接確認してからconfirmed: trueで呼び直すこと。" +
|
|
48
54
|
"アプリ側での直接の編集はこのツールを呼ぶまで反映されないため、" +
|
|
49
55
|
"ユーザーから進捗や状態を尋ねられたときは記憶に頼らず必ず呼び直すこと。", {
|
|
50
56
|
mapId: z.string().describe("list_mapsで得たマップのID"),
|
|
51
57
|
confirmed: z
|
|
52
58
|
.boolean()
|
|
53
59
|
.optional()
|
|
54
|
-
.describe("
|
|
60
|
+
.describe("最初は付けない。確認を促すメッセージが返ったときだけ、ユーザー本人に直接" +
|
|
61
|
+
"「中身を確認してよいか」と尋ね、同意を得てからtrueを付けて呼び直す。" +
|
|
55
62
|
"確認前にtrueを付けてはならない。"),
|
|
56
63
|
}, async ({ mapId, confirmed }) => {
|
|
57
64
|
const { client, session } = getContext();
|
|
@@ -78,8 +85,7 @@ export function registerListElements(server) {
|
|
|
78
85
|
}
|
|
79
86
|
}
|
|
80
87
|
// levels/rootが無いマップは空のマップとして返す(アプリで開いてもrootは作られない)。
|
|
81
|
-
const
|
|
82
|
-
const tree = buildTreeFromLevels(levelsById);
|
|
88
|
+
const tree = elementTreeOf(await readLevelsSnapshot(client, levelsPathOf(map)));
|
|
83
89
|
return {
|
|
84
90
|
content: [
|
|
85
91
|
{
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { getContext } from "../mcpContext.js";
|
|
3
|
-
import { ancestorDetailsInLevels, buildLevelsMoveWrites, locateInLevels, readLevelsSnapshot } from "../domain/levelsOps.js";
|
|
4
|
-
import { resolveMap, levelsPathOf, formatPath, assertCanEditMap, } from "../domain/mapResolver.js";
|
|
3
|
+
import { ancestorDetailsInLevels, buildLevelsMoveWrites, locateInLevels, readLevelsSnapshot, withLinkSummary, } from "../domain/levelsOps.js";
|
|
4
|
+
import { resolveMap, levelsPathOf, headerPathOf, formatPath, assertCanEditMap, } from "../domain/mapResolver.js";
|
|
5
5
|
export function registerMoveElement(server) {
|
|
6
6
|
server.tool("move_element", "既存マップの要素1件(配下ごと)を、同じマップ内の別の場所へ移動する。" +
|
|
7
7
|
"階層をまたいだ移動(昇格・降格)も、同じ階層内の並べ替えも、これ1つで行える。", {
|
|
@@ -34,7 +34,8 @@ export function registerMoveElement(server) {
|
|
|
34
34
|
}
|
|
35
35
|
const writes = buildLevelsMoveWrites(levelsPath, snapshot, elementId, newParentElementId, afterElementId);
|
|
36
36
|
levelsPathResult = formatPath(map.header.title, ancestorDetailsInLevels(snapshot, newParentElementId));
|
|
37
|
-
|
|
37
|
+
// リンクの要約(header の linkCounts)が変わるときだけ、同じコミットで書き直す
|
|
38
|
+
return withLinkSummary(headerPathOf(map), levelsPath, snapshot, writes);
|
|
38
39
|
});
|
|
39
40
|
return {
|
|
40
41
|
content: [{ type: "text", text: JSON.stringify({ path: levelsPathResult }, null, 2) }],
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { getContext } from "../mcpContext.js";
|
|
3
|
+
import { ancestorDetailsInLevels, buildLevelsScheduleWrites, readLevelsSnapshot, } from "../domain/levelsOps.js";
|
|
4
|
+
import { resolveMap, levelsPathOf, formatPath, assertCanEditMap } from "../domain/mapResolver.js";
|
|
5
|
+
import { parseScheduleDate } from "../domain/scheduleText.js";
|
|
6
|
+
const dateSchema = z
|
|
7
|
+
.string()
|
|
8
|
+
.nullable()
|
|
9
|
+
.optional()
|
|
10
|
+
.describe("終日なら\"YYYY-MM-DD\"、時刻付きなら\"YYYY-MM-DD HH:mm\"(このPCの現地時刻)。" +
|
|
11
|
+
"nullで外す。省略するとその日付には触れない");
|
|
12
|
+
export function registerSetSchedule(server) {
|
|
13
|
+
server.tool("set_schedule", "要素の期日(due)と着手日(start)を付ける・外す。1件でも複数件でもよい。" +
|
|
14
|
+
"親の日付を子に引き継ぐことはしない(親と子の日付は独立している)。" +
|
|
15
|
+
"今の日付はlist_elementsで確認できる。", {
|
|
16
|
+
mapId: z.string().describe("list_mapsで得たマップのID"),
|
|
17
|
+
updates: z
|
|
18
|
+
.array(z.object({
|
|
19
|
+
elementId: z.string().describe("list_elementsで得た要素のID"),
|
|
20
|
+
due: dateSchema,
|
|
21
|
+
start: dateSchema,
|
|
22
|
+
}))
|
|
23
|
+
.min(1),
|
|
24
|
+
}, async ({ mapId, updates }) => {
|
|
25
|
+
// 日付の書き方の誤りは、何も読まないうちに返す
|
|
26
|
+
const parsed = updates.map((u) => ({
|
|
27
|
+
elementId: u.elementId,
|
|
28
|
+
due: u.due == null ? u.due : parseScheduleDate(u.due),
|
|
29
|
+
start: u.start == null ? u.start : parseScheduleDate(u.start),
|
|
30
|
+
}));
|
|
31
|
+
const { client, session } = getContext();
|
|
32
|
+
const map = await resolveMap(client, session, mapId);
|
|
33
|
+
assertCanEditMap(map, session);
|
|
34
|
+
const levelsPath = levelsPathOf(map);
|
|
35
|
+
let results = [];
|
|
36
|
+
await client.runOptimistic(async () => {
|
|
37
|
+
const snapshot = await readLevelsSnapshot(client, levelsPath);
|
|
38
|
+
const { writes, found } = buildLevelsScheduleWrites(levelsPath, snapshot, parsed);
|
|
39
|
+
results = updates.map((u) => ({
|
|
40
|
+
elementId: u.elementId,
|
|
41
|
+
path: found.has(u.elementId)
|
|
42
|
+
? formatPath(map.header.title, ancestorDetailsInLevels(snapshot, u.elementId))
|
|
43
|
+
: null,
|
|
44
|
+
...(u.due !== undefined ? { due: u.due } : {}),
|
|
45
|
+
...(u.start !== undefined ? { start: u.start } : {}),
|
|
46
|
+
found: found.has(u.elementId),
|
|
47
|
+
}));
|
|
48
|
+
return writes;
|
|
49
|
+
});
|
|
50
|
+
return {
|
|
51
|
+
content: [{ type: "text", text: JSON.stringify({ results }, null, 2) }],
|
|
52
|
+
};
|
|
53
|
+
});
|
|
54
|
+
}
|