mmm-mcp-server 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +346 -0
- package/dist/bin/login.js +119 -0
- package/dist/src/auth/appleClientSecretRemote.js +29 -0
- package/dist/src/auth/appleLoopback.js +75 -0
- package/dist/src/auth/appleTokenExchange.js +29 -0
- package/dist/src/auth/firebaseAuthRest.js +98 -0
- package/dist/src/auth/googleLoopback.js +93 -0
- package/dist/src/auth/googleOAuthConfig.js +26 -0
- package/dist/src/auth/session.js +58 -0
- package/dist/src/auth/tokenStore.js +32 -0
- package/dist/src/domain/appendForest.js +82 -0
- package/dist/src/domain/body.js +127 -0
- package/dist/src/domain/deleteElements.js +85 -0
- package/dist/src/domain/header.js +27 -0
- package/dist/src/domain/idGen.js +11 -0
- package/dist/src/domain/levels.js +242 -0
- package/dist/src/domain/levelsMode.js +24 -0
- package/dist/src/domain/levelsOps.js +199 -0
- package/dist/src/domain/mapResolver.js +52 -0
- package/dist/src/domain/markdownParser.js +122 -0
- package/dist/src/domain/moveElement.js +93 -0
- package/dist/src/firebaseConfig.js +36 -0
- package/dist/src/firestore/restClient.js +267 -0
- package/dist/src/firestore/valueCodec.js +65 -0
- package/dist/src/mcpContext.js +11 -0
- package/dist/src/server.js +33 -0
- package/dist/src/tools/autoStructureThought.js +87 -0
- package/dist/src/tools/createMap.js +39 -0
- package/dist/src/tools/deleteElements.js +150 -0
- package/dist/src/tools/editElement.js +65 -0
- package/dist/src/tools/importMarkdownContext.js +95 -0
- package/dist/src/tools/listElements.js +97 -0
- package/dist/src/tools/listMaps.js +47 -0
- package/dist/src/tools/moveElement.js +81 -0
- package/dist/src/tools/updateTaskStatus.js +87 -0
- package/package.json +48 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { FIREBASE_API_KEY, FIREBASE_AUTH_DOMAIN } from "../firebaseConfig.js";
|
|
2
|
+
const IDENTITY_TOOLKIT_BASE = "https://identitytoolkit.googleapis.com/v1";
|
|
3
|
+
const SECURE_TOKEN_URL = "https://securetoken.googleapis.com/v1/token";
|
|
4
|
+
export class FirebaseAuthError extends Error {
|
|
5
|
+
raw;
|
|
6
|
+
constructor(message, raw) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.raw = raw;
|
|
9
|
+
this.name = "FirebaseAuthError";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
/** レスポンスがJSONでない(障害時のHTMLエラーページ等)場合でも例外にせず読む。 */
|
|
13
|
+
async function parseJsonResponse(res) {
|
|
14
|
+
const text = await res.text();
|
|
15
|
+
try {
|
|
16
|
+
return text ? JSON.parse(text) : {};
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return { error: { message: text || `HTTP ${res.status}` } };
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
async function postJson(url, body) {
|
|
23
|
+
const res = await fetch(url, {
|
|
24
|
+
method: "POST",
|
|
25
|
+
headers: { "Content-Type": "application/json" },
|
|
26
|
+
body: JSON.stringify(body),
|
|
27
|
+
});
|
|
28
|
+
const data = await parseJsonResponse(res);
|
|
29
|
+
if (!res.ok) {
|
|
30
|
+
const message = data?.error?.message ?? `HTTP ${res.status}`;
|
|
31
|
+
throw new FirebaseAuthError(String(message), data);
|
|
32
|
+
}
|
|
33
|
+
return data;
|
|
34
|
+
}
|
|
35
|
+
/** メール+パスワードでサインインする(Firebase Auth REST API)。 */
|
|
36
|
+
export async function signInWithPassword(email, password) {
|
|
37
|
+
const data = await postJson(`${IDENTITY_TOOLKIT_BASE}/accounts:signInWithPassword?key=${FIREBASE_API_KEY}`, {
|
|
38
|
+
email,
|
|
39
|
+
password,
|
|
40
|
+
returnSecureToken: true,
|
|
41
|
+
});
|
|
42
|
+
return {
|
|
43
|
+
idToken: data.idToken,
|
|
44
|
+
refreshToken: data.refreshToken,
|
|
45
|
+
localId: data.localId,
|
|
46
|
+
email: data.email ?? null,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* 第三者IdP(Google/Apple)のIDトークンをFirebaseの認証情報に交換する。
|
|
51
|
+
* `providerId`は"google.com"または"apple.com"。
|
|
52
|
+
* Appleでnonceを使った場合は元のnonce文字列(SHA-256化する前の値)を渡す。
|
|
53
|
+
*/
|
|
54
|
+
export async function signInWithIdp(providerId, idpIdToken, nonce) {
|
|
55
|
+
let postBody = `id_token=${encodeURIComponent(idpIdToken)}&providerId=${providerId}`;
|
|
56
|
+
if (nonce) {
|
|
57
|
+
postBody += `&nonce=${encodeURIComponent(nonce)}`;
|
|
58
|
+
}
|
|
59
|
+
const data = await postJson(`${IDENTITY_TOOLKIT_BASE}/accounts:signInWithIdp?key=${FIREBASE_API_KEY}`, {
|
|
60
|
+
postBody,
|
|
61
|
+
requestUri: `https://${FIREBASE_AUTH_DOMAIN}`,
|
|
62
|
+
returnSecureToken: true,
|
|
63
|
+
returnIdpCredential: true,
|
|
64
|
+
});
|
|
65
|
+
return {
|
|
66
|
+
idToken: data.idToken,
|
|
67
|
+
refreshToken: data.refreshToken,
|
|
68
|
+
localId: data.localId,
|
|
69
|
+
email: data.email ?? null,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/** refresh_tokenから新しいIDトークンを取得する。 */
|
|
73
|
+
export async function refreshIdToken(refreshToken) {
|
|
74
|
+
const res = await fetch(`${SECURE_TOKEN_URL}?key=${FIREBASE_API_KEY}`, {
|
|
75
|
+
method: "POST",
|
|
76
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
77
|
+
body: new URLSearchParams({
|
|
78
|
+
grant_type: "refresh_token",
|
|
79
|
+
refresh_token: refreshToken,
|
|
80
|
+
}),
|
|
81
|
+
});
|
|
82
|
+
const data = (await parseJsonResponse(res));
|
|
83
|
+
if (!res.ok) {
|
|
84
|
+
const message = data.error?.message ?? `HTTP ${res.status}`;
|
|
85
|
+
throw new FirebaseAuthError(String(message), data);
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
idToken: data.id_token,
|
|
89
|
+
refreshToken: data.refresh_token,
|
|
90
|
+
userId: data.user_id,
|
|
91
|
+
expiresInSec: Number(data.expires_in),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
/** 現在のアカウントのメールアドレスなどを取得する(accounts:lookup)。 */
|
|
95
|
+
export async function lookupAccountEmail(idToken) {
|
|
96
|
+
const data = await postJson(`${IDENTITY_TOOLKIT_BASE}/accounts:lookup?key=${FIREBASE_API_KEY}`, { idToken });
|
|
97
|
+
return data.users[0]?.email ?? null;
|
|
98
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { randomBytes, createHash } from "node:crypto";
|
|
3
|
+
import open from "open";
|
|
4
|
+
import { loadGoogleOAuthClientConfig } from "./googleOAuthConfig.js";
|
|
5
|
+
const AUTH_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth";
|
|
6
|
+
const TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token";
|
|
7
|
+
function base64url(buffer) {
|
|
8
|
+
return buffer
|
|
9
|
+
.toString("base64")
|
|
10
|
+
.replace(/\+/g, "-")
|
|
11
|
+
.replace(/\//g, "_")
|
|
12
|
+
.replace(/=+$/, "");
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* `gcloud auth login`と同種のローカルループバック方式でGoogleにサインインし、
|
|
16
|
+
* IDトークン(JWT、Firebaseへの交換に使う)を返す。
|
|
17
|
+
*/
|
|
18
|
+
export async function signInWithGoogleLoopback() {
|
|
19
|
+
const { client_id, client_secret } = loadGoogleOAuthClientConfig();
|
|
20
|
+
const codeVerifier = base64url(randomBytes(32));
|
|
21
|
+
const codeChallenge = base64url(createHash("sha256").update(codeVerifier).digest());
|
|
22
|
+
const state = base64url(randomBytes(16));
|
|
23
|
+
const idToken = await new Promise((resolve, reject) => {
|
|
24
|
+
const server = createServer(async (req, res) => {
|
|
25
|
+
try {
|
|
26
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
27
|
+
if (url.pathname !== "/") {
|
|
28
|
+
res.writeHead(404).end();
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const returnedState = url.searchParams.get("state");
|
|
32
|
+
const code = url.searchParams.get("code");
|
|
33
|
+
const errorParam = url.searchParams.get("error");
|
|
34
|
+
if (errorParam) {
|
|
35
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
36
|
+
res.end("<p>ログインがキャンセルされました。このタブは閉じて構いません。</p>");
|
|
37
|
+
server.close();
|
|
38
|
+
reject(new Error(`Google認証が拒否されました: ${errorParam}`));
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (returnedState !== state || !code) {
|
|
42
|
+
res.writeHead(400).end("invalid state");
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const port = server.address().port;
|
|
46
|
+
const tokenRes = await fetch(TOKEN_ENDPOINT, {
|
|
47
|
+
method: "POST",
|
|
48
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
49
|
+
body: new URLSearchParams({
|
|
50
|
+
code,
|
|
51
|
+
client_id,
|
|
52
|
+
client_secret,
|
|
53
|
+
redirect_uri: `http://127.0.0.1:${port}`,
|
|
54
|
+
grant_type: "authorization_code",
|
|
55
|
+
code_verifier: codeVerifier,
|
|
56
|
+
}),
|
|
57
|
+
});
|
|
58
|
+
const tokenData = await tokenRes.json();
|
|
59
|
+
if (!tokenRes.ok || !tokenData.id_token) {
|
|
60
|
+
throw new Error(tokenData.error_description ?? "Googleトークン交換に失敗しました");
|
|
61
|
+
}
|
|
62
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
63
|
+
res.end("<p>Googleログインが完了しました。このタブは閉じてターミナルに戻ってください。</p>");
|
|
64
|
+
server.close();
|
|
65
|
+
resolve(tokenData.id_token);
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
res.writeHead(500).end("internal error");
|
|
69
|
+
server.close();
|
|
70
|
+
reject(err);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
server.listen(0, "127.0.0.1", () => {
|
|
74
|
+
const port = server.address().port;
|
|
75
|
+
const redirectUri = `http://127.0.0.1:${port}`;
|
|
76
|
+
const authUrl = new URL(AUTH_ENDPOINT);
|
|
77
|
+
authUrl.searchParams.set("client_id", client_id);
|
|
78
|
+
authUrl.searchParams.set("redirect_uri", redirectUri);
|
|
79
|
+
authUrl.searchParams.set("response_type", "code");
|
|
80
|
+
authUrl.searchParams.set("scope", "openid email profile");
|
|
81
|
+
authUrl.searchParams.set("state", state);
|
|
82
|
+
authUrl.searchParams.set("code_challenge", codeChallenge);
|
|
83
|
+
authUrl.searchParams.set("code_challenge_method", "S256");
|
|
84
|
+
authUrl.searchParams.set("prompt", "select_account");
|
|
85
|
+
console.log("ブラウザでGoogleのログイン画面を開きます…");
|
|
86
|
+
open(authUrl.toString()).catch(() => {
|
|
87
|
+
console.log(`自動で開けなかった場合は次のURLを開いてください:\n${authUrl.toString()}`);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
server.on("error", reject);
|
|
91
|
+
});
|
|
92
|
+
return idToken;
|
|
93
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { GOOGLE_OAUTH_CLIENT_ID, GOOGLE_OAUTH_CLIENT_SECRET } from "../firebaseConfig.js";
|
|
5
|
+
const CONFIG_PATH = join(homedir(), ".config", "mmm-mcp", "google-oauth-client.json");
|
|
6
|
+
/**
|
|
7
|
+
* GoogleログインのループバックOAuthに使うクライアント情報。
|
|
8
|
+
* 通常は全ユーザー共有のデフォルト(firebaseConfig.ts参照)をそのまま使う。
|
|
9
|
+
* 自分専用のOAuthクライアントを使いたい場合だけ、Google Cloud Consoleで
|
|
10
|
+
* 作成した「デスクトップアプリ」タイプのクライアントのJSON(`installed`の
|
|
11
|
+
* 中身)をこのパスに保存すれば、そちらが優先される。
|
|
12
|
+
*/
|
|
13
|
+
export function loadGoogleOAuthClientConfig() {
|
|
14
|
+
if (existsSync(CONFIG_PATH)) {
|
|
15
|
+
const raw = JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
|
|
16
|
+
const installed = raw.installed ?? raw;
|
|
17
|
+
if (!installed.client_id || !installed.client_secret) {
|
|
18
|
+
throw new Error(`${CONFIG_PATH} の内容が不正です(client_id/client_secretが必要)。`);
|
|
19
|
+
}
|
|
20
|
+
return { client_id: installed.client_id, client_secret: installed.client_secret };
|
|
21
|
+
}
|
|
22
|
+
return { client_id: GOOGLE_OAUTH_CLIENT_ID, client_secret: GOOGLE_OAUTH_CLIENT_SECRET };
|
|
23
|
+
}
|
|
24
|
+
export function googleOAuthConfigPath() {
|
|
25
|
+
return CONFIG_PATH;
|
|
26
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { refreshIdToken } from "./firebaseAuthRest.js";
|
|
2
|
+
import { loadCredentials, saveCredentials } from "./tokenStore.js";
|
|
3
|
+
const EXPIRY_MARGIN_MS = 60_000;
|
|
4
|
+
/**
|
|
5
|
+
* サインイン済みユーザーの有効なIDトークンを保証するセッション。
|
|
6
|
+
* refresh_tokenは使うたびに新しい値へローテーションされるため、
|
|
7
|
+
* 更新のたびにディスクへ書き戻す。
|
|
8
|
+
*/
|
|
9
|
+
export class AuthSession {
|
|
10
|
+
credentials;
|
|
11
|
+
idToken = null;
|
|
12
|
+
expiresAt = 0;
|
|
13
|
+
// 複数のツール呼び出しがほぼ同時にトークン切れを検知したとき、
|
|
14
|
+
// refresh_tokenを二重に消費すると(Firebaseはローテーションするため)
|
|
15
|
+
// 片方が古いトークンで上書きしてしまう。進行中の更新を使い回して防ぐ。
|
|
16
|
+
refreshing = null;
|
|
17
|
+
constructor(credentials) {
|
|
18
|
+
this.credentials = credentials;
|
|
19
|
+
}
|
|
20
|
+
get uid() {
|
|
21
|
+
return this.credentials.uid;
|
|
22
|
+
}
|
|
23
|
+
get email() {
|
|
24
|
+
return this.credentials.email;
|
|
25
|
+
}
|
|
26
|
+
async getValidIdToken() {
|
|
27
|
+
if (this.idToken && Date.now() < this.expiresAt - EXPIRY_MARGIN_MS) {
|
|
28
|
+
return this.idToken;
|
|
29
|
+
}
|
|
30
|
+
if (!this.refreshing) {
|
|
31
|
+
this.refreshing = this.doRefresh().finally(() => {
|
|
32
|
+
this.refreshing = null;
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
return this.refreshing;
|
|
36
|
+
}
|
|
37
|
+
async doRefresh() {
|
|
38
|
+
const refreshed = await refreshIdToken(this.credentials.refreshToken);
|
|
39
|
+
this.idToken = refreshed.idToken;
|
|
40
|
+
this.expiresAt = Date.now() + refreshed.expiresInSec * 1000;
|
|
41
|
+
this.credentials = { ...this.credentials, refreshToken: refreshed.refreshToken };
|
|
42
|
+
saveCredentials(this.credentials);
|
|
43
|
+
return this.idToken;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
export class NotSignedInError extends Error {
|
|
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`)。");
|
|
49
|
+
this.name = "NotSignedInError";
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export function loadSession() {
|
|
53
|
+
const credentials = loadCredentials();
|
|
54
|
+
if (!credentials) {
|
|
55
|
+
throw new NotSignedInError();
|
|
56
|
+
}
|
|
57
|
+
return new AuthSession(credentials);
|
|
58
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync, existsSync, rmSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
const CONFIG_DIR = join(homedir(), ".config", "mmm-mcp");
|
|
5
|
+
const CREDENTIALS_PATH = join(CONFIG_DIR, "credentials.json");
|
|
6
|
+
function ensureConfigDir() {
|
|
7
|
+
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
8
|
+
}
|
|
9
|
+
export function saveCredentials(credentials) {
|
|
10
|
+
ensureConfigDir();
|
|
11
|
+
writeFileSync(CREDENTIALS_PATH, JSON.stringify(credentials, null, 2), {
|
|
12
|
+
mode: 0o600,
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
export function loadCredentials() {
|
|
16
|
+
if (!existsSync(CREDENTIALS_PATH))
|
|
17
|
+
return null;
|
|
18
|
+
try {
|
|
19
|
+
return JSON.parse(readFileSync(CREDENTIALS_PATH, "utf-8"));
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export function clearCredentials() {
|
|
26
|
+
if (existsSync(CREDENTIALS_PATH)) {
|
|
27
|
+
rmSync(CREDENTIALS_PATH);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export function credentialsPath() {
|
|
31
|
+
return CREDENTIALS_PATH;
|
|
32
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { newDocId } from "./idGen.js";
|
|
2
|
+
import { lastChildOf } from "./body.js";
|
|
3
|
+
/** ネストしたツリー入力を、ID採番済み・prev/next/parent/child関係を組んだ配列に変換する。 */
|
|
4
|
+
export function buildForestFromTree(roots) {
|
|
5
|
+
const all = [];
|
|
6
|
+
function build(inputNodes, parentId) {
|
|
7
|
+
const level = [];
|
|
8
|
+
for (const input of inputNodes) {
|
|
9
|
+
const node = {
|
|
10
|
+
id: newDocId(),
|
|
11
|
+
detail: input.detail,
|
|
12
|
+
done: input.done ?? false,
|
|
13
|
+
parentId,
|
|
14
|
+
prevId: null,
|
|
15
|
+
nextId: null,
|
|
16
|
+
childId: null,
|
|
17
|
+
};
|
|
18
|
+
all.push(node);
|
|
19
|
+
level.push(node);
|
|
20
|
+
if (input.children && input.children.length > 0) {
|
|
21
|
+
const childLevel = build(input.children, node.id);
|
|
22
|
+
node.childId = childLevel[0].id;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
for (let i = 1; i < level.length; i++) {
|
|
26
|
+
level[i].prevId = level[i - 1].id;
|
|
27
|
+
level[i - 1].nextId = level[i].id;
|
|
28
|
+
}
|
|
29
|
+
return level;
|
|
30
|
+
}
|
|
31
|
+
build(roots, null);
|
|
32
|
+
return all;
|
|
33
|
+
}
|
|
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,127 @@
|
|
|
1
|
+
export function bodyFromFirestore(id, data, updateTime) {
|
|
2
|
+
return {
|
|
3
|
+
id,
|
|
4
|
+
detail: String(data.detail ?? ""),
|
|
5
|
+
prev: data.prev ?? null,
|
|
6
|
+
next: data.next ?? null,
|
|
7
|
+
parent: data.parent ?? null,
|
|
8
|
+
child: data.child ?? null,
|
|
9
|
+
done: Boolean(data.done ?? false),
|
|
10
|
+
updateTime,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* 壊れている(prev/nextが循環・欠落している)データでも、要素を1件も
|
|
15
|
+
* 落とさずに返す。壊れ方の修復はアプリ本体(MatryoshkaModel.repair)の
|
|
16
|
+
* 役目であり、ここでは読み取り専用なので直さず、可能な範囲で順序を
|
|
17
|
+
* 復元しつつ全件を出す。
|
|
18
|
+
*/
|
|
19
|
+
export function buildTree(bodies) {
|
|
20
|
+
const globalVisited = new Set();
|
|
21
|
+
function orderedChildrenOf(parentId) {
|
|
22
|
+
const siblings = bodies.filter((b) => b.parent === parentId);
|
|
23
|
+
if (siblings.length === 0)
|
|
24
|
+
return [];
|
|
25
|
+
const bySelfId = new Map(siblings.map((b) => [b.id, b]));
|
|
26
|
+
const head = siblings.find((b) => b.prev === null || !bySelfId.has(b.prev));
|
|
27
|
+
const ordered = [];
|
|
28
|
+
const seen = new Set();
|
|
29
|
+
let current = head?.id ?? null;
|
|
30
|
+
while (current !== null && !seen.has(current)) {
|
|
31
|
+
const node = bySelfId.get(current);
|
|
32
|
+
if (!node)
|
|
33
|
+
break;
|
|
34
|
+
seen.add(current);
|
|
35
|
+
ordered.push(node);
|
|
36
|
+
current = node.next;
|
|
37
|
+
}
|
|
38
|
+
// next/prevの循環や分断で辿れなかった残りも、無くさず末尾に足す。
|
|
39
|
+
for (const s of siblings) {
|
|
40
|
+
if (!seen.has(s.id))
|
|
41
|
+
ordered.push(s);
|
|
42
|
+
}
|
|
43
|
+
return ordered;
|
|
44
|
+
}
|
|
45
|
+
function walk(parentId) {
|
|
46
|
+
const result = [];
|
|
47
|
+
for (const node of orderedChildrenOf(parentId)) {
|
|
48
|
+
if (globalVisited.has(node.id))
|
|
49
|
+
continue; // 循環参照からの二重取り込みを防ぐ
|
|
50
|
+
globalVisited.add(node.id);
|
|
51
|
+
result.push({
|
|
52
|
+
id: node.id,
|
|
53
|
+
detail: node.detail,
|
|
54
|
+
done: node.done,
|
|
55
|
+
parentId: node.parent,
|
|
56
|
+
children: walk(node.id),
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
return result;
|
|
60
|
+
}
|
|
61
|
+
const tree = walk(null);
|
|
62
|
+
// parentが存在しない要素を指している等で上のwalkから漏れた要素も、
|
|
63
|
+
// 最上位に足しておく(取りこぼしが無いことを優先する)。
|
|
64
|
+
for (const b of bodies) {
|
|
65
|
+
if (!globalVisited.has(b.id)) {
|
|
66
|
+
globalVisited.add(b.id);
|
|
67
|
+
tree.push({ id: b.id, detail: b.detail, done: b.done, parentId: b.parent, children: [] });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return tree;
|
|
71
|
+
}
|
|
72
|
+
/** ルートから[elementId]までの祖先(自分自身を含む)のdetailを、根から順に並べる。 */
|
|
73
|
+
export function ancestorDetails(bodies, elementId) {
|
|
74
|
+
if (elementId === null)
|
|
75
|
+
return [];
|
|
76
|
+
const byId = new Map(bodies.map((b) => [b.id, b]));
|
|
77
|
+
const chain = [];
|
|
78
|
+
let current = elementId;
|
|
79
|
+
const seen = new Set();
|
|
80
|
+
while (current !== null && byId.has(current) && !seen.has(current)) {
|
|
81
|
+
seen.add(current);
|
|
82
|
+
const node = byId.get(current);
|
|
83
|
+
chain.push(node.detail);
|
|
84
|
+
current = node.parent;
|
|
85
|
+
}
|
|
86
|
+
return chain.reverse();
|
|
87
|
+
}
|
|
88
|
+
/** [parentId]の子の連結リストの末尾のIDを求める。子が無ければnull。 */
|
|
89
|
+
export function lastChildOf(bodies, parentId) {
|
|
90
|
+
const siblings = bodies.filter((b) => b.parent === parentId);
|
|
91
|
+
if (siblings.length === 0)
|
|
92
|
+
return null;
|
|
93
|
+
const byId = new Map(siblings.map((b) => [b.id, b]));
|
|
94
|
+
let current = siblings.find((b) => b.prev === null || !byId.has(b.prev)) ?? siblings[0];
|
|
95
|
+
const seen = new Set([current.id]);
|
|
96
|
+
while (current.next !== null && byId.has(current.next) && !seen.has(current.next)) {
|
|
97
|
+
current = byId.get(current.next);
|
|
98
|
+
seen.add(current.id);
|
|
99
|
+
}
|
|
100
|
+
return current.id;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* [rootId]自身とその配下(子孫)全ての id を集める。move/deleteのように
|
|
104
|
+
* 「要素ごと配下を丸ごと動かす・消す」操作の対象範囲を求めるために使う。
|
|
105
|
+
* 循環参照があっても無限ループしない。
|
|
106
|
+
*/
|
|
107
|
+
export function collectSubtreeIds(bodies, rootId) {
|
|
108
|
+
const byParent = new Map();
|
|
109
|
+
for (const b of bodies) {
|
|
110
|
+
const list = byParent.get(b.parent) ?? [];
|
|
111
|
+
list.push(b);
|
|
112
|
+
byParent.set(b.parent, list);
|
|
113
|
+
}
|
|
114
|
+
const result = [];
|
|
115
|
+
const seen = new Set();
|
|
116
|
+
function walk(id) {
|
|
117
|
+
if (seen.has(id))
|
|
118
|
+
return;
|
|
119
|
+
seen.add(id);
|
|
120
|
+
result.push(id);
|
|
121
|
+
for (const child of byParent.get(id) ?? []) {
|
|
122
|
+
walk(child.id);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
walk(rootId);
|
|
126
|
+
return result;
|
|
127
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { collectSubtreeIds } from "./body.js";
|
|
2
|
+
import { buildRemoveChildrenWrite } from "./levels.js";
|
|
3
|
+
import { mergeWritesByPath } from "../firestore/restClient.js";
|
|
4
|
+
/**
|
|
5
|
+
* [elementIds](配下ごと)を削除するための書き込みを組み立てる。tools/deleteElements.tsから
|
|
6
|
+
* 切り出した純粋寄りの関数(client.getDocumentだけ呼ぶ)。
|
|
7
|
+
*
|
|
8
|
+
* 1周目で消える要素を全て確定させてから2周目で繋ぎ直すのは、隣り合う要素を
|
|
9
|
+
* 同時に消すとき、1件ずつその場で繋ぎ直すと消える予定の相手を指すポインタを
|
|
10
|
+
* 書いてしまい、存在しないIDを指したまま残る不具合(実データで発生)を防ぐため。
|
|
11
|
+
*/
|
|
12
|
+
export async function buildDeleteElementWrites(client, bodiesPath, levelsPath, currentBodies, elementIds) {
|
|
13
|
+
const currentById = new Map(currentBodies.map((b) => [b.id, b]));
|
|
14
|
+
const writes = [];
|
|
15
|
+
const levelRemovals = new Map();
|
|
16
|
+
const allIds = new Set();
|
|
17
|
+
// 1周目: 消える要素を全て確定させる。繋ぎ直しは2周目で行う。
|
|
18
|
+
for (const elementId of elementIds) {
|
|
19
|
+
const body = currentById.get(elementId);
|
|
20
|
+
if (!body)
|
|
21
|
+
continue; // 既に削除済み(リトライ時など)
|
|
22
|
+
const subtree = collectSubtreeIds(currentBodies, elementId);
|
|
23
|
+
for (const id of subtree)
|
|
24
|
+
allIds.add(id);
|
|
25
|
+
}
|
|
26
|
+
/** [startId]から[key]方向へ、消えない要素に当たるまで辿る。 */
|
|
27
|
+
const survivingNeighbor = (startId, key) => {
|
|
28
|
+
let cursor = startId;
|
|
29
|
+
const seen = new Set();
|
|
30
|
+
while (cursor !== null && allIds.has(cursor) && !seen.has(cursor)) {
|
|
31
|
+
seen.add(cursor);
|
|
32
|
+
cursor = currentById.get(cursor)?.[key] ?? null;
|
|
33
|
+
}
|
|
34
|
+
return cursor;
|
|
35
|
+
};
|
|
36
|
+
// 2周目: 生き残る要素どうしを繋ぎ直す。
|
|
37
|
+
for (const elementId of elementIds) {
|
|
38
|
+
const body = currentById.get(elementId);
|
|
39
|
+
if (!body)
|
|
40
|
+
continue;
|
|
41
|
+
const newPrev = survivingNeighbor(body.prev, "prev");
|
|
42
|
+
const newNext = survivingNeighbor(body.next, "next");
|
|
43
|
+
if (newPrev !== null) {
|
|
44
|
+
writes.push({
|
|
45
|
+
path: `${bodiesPath}/${newPrev}`,
|
|
46
|
+
fields: { next: newNext },
|
|
47
|
+
requireUpdateTime: currentById.get(newPrev)?.updateTime,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
else if (body.parent !== null && !allIds.has(body.parent)) {
|
|
51
|
+
writes.push({
|
|
52
|
+
path: `${bodiesPath}/${body.parent}`,
|
|
53
|
+
fields: { child: newNext },
|
|
54
|
+
requireUpdateTime: currentById.get(body.parent)?.updateTime,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
if (newNext !== null) {
|
|
58
|
+
writes.push({
|
|
59
|
+
path: `${bodiesPath}/${newNext}`,
|
|
60
|
+
fields: { prev: newPrev },
|
|
61
|
+
requireUpdateTime: currentById.get(newNext)?.updateTime,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
const levelId = body.parent ?? "root";
|
|
65
|
+
const list = levelRemovals.get(levelId) ?? [];
|
|
66
|
+
list.push(elementId);
|
|
67
|
+
levelRemovals.set(levelId, list);
|
|
68
|
+
}
|
|
69
|
+
for (const id of allIds) {
|
|
70
|
+
writes.push({ path: `${bodiesPath}/${id}`, delete: true });
|
|
71
|
+
// 配下の要素が子を持っていた場合、そのlevelsドキュメント自体も消す。
|
|
72
|
+
// 存在しないドキュメントへのdeleteはFirestoreでは無害。
|
|
73
|
+
writes.push({ path: `${levelsPath}/${id}`, delete: true });
|
|
74
|
+
}
|
|
75
|
+
for (const [levelId, removeIds] of levelRemovals) {
|
|
76
|
+
// 親自身も消えるなら、levels/{親} は上のdeleteで消える。
|
|
77
|
+
// ここで書き戻すと、消したドキュメントが中身入りで復活する。
|
|
78
|
+
if (allIds.has(levelId))
|
|
79
|
+
continue;
|
|
80
|
+
const write = await buildRemoveChildrenWrite(client, levelsPath, levelId, removeIds);
|
|
81
|
+
if (write)
|
|
82
|
+
writes.push(write);
|
|
83
|
+
}
|
|
84
|
+
return { writes: mergeWritesByPath(writes), deletedIds: allIds };
|
|
85
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
function aiReadPermissionFromFirestore(value) {
|
|
2
|
+
return value === "allow" || value === "deny" ? value : "ask";
|
|
3
|
+
}
|
|
4
|
+
/**
|
|
5
|
+
* Firestoreのheaderドキュメントから組み立てる。
|
|
6
|
+
* 旧`shares`フィールド(役割の区別が無い、常に編集者扱い)しか無い
|
|
7
|
+
* 既存ドキュメントとの後方互換はHeader.fromFirestore(Dart側)と同じ。
|
|
8
|
+
*/
|
|
9
|
+
export function headerFromFirestore(id, ownerUid, data) {
|
|
10
|
+
const editors = data.editors ?? data.shares ?? [];
|
|
11
|
+
return {
|
|
12
|
+
id,
|
|
13
|
+
ownerUid,
|
|
14
|
+
title: String(data.title ?? ""),
|
|
15
|
+
order: Number(data.order ?? 0),
|
|
16
|
+
isTodo: Boolean(data.isTodo ?? false),
|
|
17
|
+
editors,
|
|
18
|
+
viewers: data.viewers ?? [],
|
|
19
|
+
aiReadPermission: aiReadPermissionFromFirestore(data.aiReadPermission),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
/** 自分がこのマップを編集できるか(所有者か、editorsに含まれる共有先か)。Header.canEdit(Dart側)と同じ。 */
|
|
23
|
+
export function canEditHeader(header, uid, email) {
|
|
24
|
+
if (uid === header.ownerUid)
|
|
25
|
+
return true;
|
|
26
|
+
return email !== null && header.editors.includes(email);
|
|
27
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
const CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
|
3
|
+
/** Firestoreの自動ID発行(20文字のランダム文字列)相当のIDを生成する。 */
|
|
4
|
+
export function newDocId() {
|
|
5
|
+
const bytes = randomBytes(20);
|
|
6
|
+
let id = "";
|
|
7
|
+
for (let i = 0; i < 20; i++) {
|
|
8
|
+
id += CHARS[bytes[i] % CHARS.length];
|
|
9
|
+
}
|
|
10
|
+
return id;
|
|
11
|
+
}
|