@kanf/wema-kake 0.1.1 → 0.1.3
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.md +1 -1
- package/dist/lib/shared/hierarchy.d.ts +5 -0
- package/dist/lib/shared/hierarchy.js +2 -0
- package/dist/lib/shared/op-summary.d.ts +5 -0
- package/dist/lib/shared/op-summary.js +4 -0
- package/dist/lib/shared/tools.d.ts +17 -1
- package/dist/lib/shared/tools.js +154 -3
- package/dist/lib/worker/apply-ops.d.ts +13 -1
- package/dist/lib/worker/apply-ops.js +23 -2
- package/dist/lib/worker/mcp/board-access.d.ts +8 -0
- package/dist/lib/worker/mcp/board-access.js +28 -0
- package/dist/lib/worker/page-do.d.ts +86 -4
- package/dist/lib/worker/page-do.js +131 -9
- package/dist/lib/worker/pages-api.js +42 -1
- package/dist/lib/worker/transfer.d.ts +39 -0
- package/dist/lib/worker/transfer.js +51 -0
- package/dist/lib/worker/validate.d.ts +7 -0
- package/dist/lib/worker/validate.js +17 -0
- package/dist/web/assets/{index-CVwMafiH.js → index-DaZ8I4iZ.js} +1 -1
- package/dist/web/index.html +1 -1
- package/package.json +3 -3
- package/template/package.json +1 -1
package/README.md
CHANGED
|
@@ -62,7 +62,7 @@ npm run deploy
|
|
|
62
62
|
- 複数のブラウザで同じページを開くと、変更がその場で反映される
|
|
63
63
|
- ページの中に子ページを置ける。子ページは、中の付箋の配置を縮小した図つきの付箋として表示され、押すとズームして入る
|
|
64
64
|
- ページの一覧もボードで、ページが付箋、ページ間のリンクが線になる。全文検索で絞り込める
|
|
65
|
-
- リモート MCP サーバーを備えていて、claude.ai のコネクタとして登録できる。LLM
|
|
65
|
+
- リモート MCP サーバーを備えていて、claude.ai のコネクタとして登録できる。LLM がボードを読み、付箋を足し、整理する。ページを作り、子ページとしてまとめることもできる
|
|
66
66
|
- LLM の操作は、操作の単位で記録される。ページの画面から、まとめて取り消せる
|
|
67
67
|
|
|
68
68
|
## wema との関係
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import type { HistoryDelta, WemaNote } from './delta';
|
|
2
2
|
/** 子ページの付箋の meta のキー。値は、子ページのスラッグ */
|
|
3
3
|
export declare const CHILD_PAGE_KEY = "page";
|
|
4
|
+
/** 子ページの付箋の大きさ。配置の再現が入るよう、ふつうの付箋より少し大きくする */
|
|
5
|
+
export declare const CHILD_NOTE_SIZE: {
|
|
6
|
+
width: number;
|
|
7
|
+
height: number;
|
|
8
|
+
};
|
|
4
9
|
/** 階層の深さの上限。ルートを 1 段目と数える */
|
|
5
10
|
export declare const MAX_DEPTH = 5;
|
|
6
11
|
/**
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
/** 子ページの付箋の meta のキー。値は、子ページのスラッグ */
|
|
2
2
|
export const CHILD_PAGE_KEY = 'page';
|
|
3
|
+
/** 子ページの付箋の大きさ。配置の再現が入るよう、ふつうの付箋より少し大きくする */
|
|
4
|
+
export const CHILD_NOTE_SIZE = { width: 240, height: 180 };
|
|
3
5
|
/** 階層の深さの上限。ルートを 1 段目と数える */
|
|
4
6
|
export const MAX_DEPTH = 5;
|
|
5
7
|
/** 置けなかったときの `reject` の `reason` の先頭。続けて、理由と、置こうとしたページが入る */
|
|
@@ -8,6 +8,8 @@ export declare const systemSummary: {
|
|
|
8
8
|
revert: (seq: number) => string;
|
|
9
9
|
/** ページ `page` から移した(写した)付箋を置いた */
|
|
10
10
|
notesReceived: (page: string) => string;
|
|
11
|
+
/** 付箋を、ページ `page` へ移したので、このページから消した */
|
|
12
|
+
notesSent: (page: string) => string;
|
|
11
13
|
/** 子ページ `page` の色に、その付箋の色を合わせた */
|
|
12
14
|
childRecolored: (page: string) => string;
|
|
13
15
|
};
|
|
@@ -22,6 +24,9 @@ export type SystemSummary = {
|
|
|
22
24
|
} | {
|
|
23
25
|
kind: 'notesReceived';
|
|
24
26
|
page: string;
|
|
27
|
+
} | {
|
|
28
|
+
kind: 'notesSent';
|
|
29
|
+
page: string;
|
|
25
30
|
} | {
|
|
26
31
|
kind: 'childRecolored';
|
|
27
32
|
page: string;
|
|
@@ -14,6 +14,8 @@ export const systemSummary = {
|
|
|
14
14
|
revert: (seq) => `${PREFIX}revert:${seq}`,
|
|
15
15
|
/** ページ `page` から移した(写した)付箋を置いた */
|
|
16
16
|
notesReceived: (page) => `${PREFIX}notes-received:${page}`,
|
|
17
|
+
/** 付箋を、ページ `page` へ移したので、このページから消した */
|
|
18
|
+
notesSent: (page) => `${PREFIX}notes-sent:${page}`,
|
|
17
19
|
/** 子ページ `page` の色に、その付箋の色を合わせた */
|
|
18
20
|
childRecolored: (page) => `${PREFIX}child-recolored:${page}`,
|
|
19
21
|
};
|
|
@@ -31,6 +33,8 @@ export function parseSystemSummary(summary) {
|
|
|
31
33
|
return { kind: 'revert', seq: Number(value) };
|
|
32
34
|
if (kind === 'notes-received' && value !== '')
|
|
33
35
|
return { kind: 'notesReceived', page: value };
|
|
36
|
+
if (kind === 'notes-sent' && value !== '')
|
|
37
|
+
return { kind: 'notesSent', page: value };
|
|
34
38
|
if (kind === 'child-recolored' && value !== '')
|
|
35
39
|
return { kind: 'childRecolored', page: value };
|
|
36
40
|
return null;
|
|
@@ -42,13 +42,29 @@ export interface BoardAccess {
|
|
|
42
42
|
}[]>;
|
|
43
43
|
/** ページがまだ作られていなければ null */
|
|
44
44
|
readBoard(page: string): Promise<BoardState | null>;
|
|
45
|
+
/** 親ページのスラッグ(ルートなら null)。ページがなければ undefined。索引ではなく、ページの今の値を返す */
|
|
46
|
+
parentOf(page: string): Promise<string | null | undefined>;
|
|
47
|
+
/** ページの親が `parent` なら、ルートへ戻す(親の記録が古く、親ページに子ページの付箋がないときに使う) */
|
|
48
|
+
clearParent(page: string, parent: string): Promise<void>;
|
|
49
|
+
/** ページを、付箋のない状態で作る。すでにあれば作らずに `exists` を返す */
|
|
50
|
+
createPage(page: string, title: string): Promise<'created' | 'exists' | 'invalid title'>;
|
|
45
51
|
/** 付箋の text(HTML)をプレーンテキストにする */
|
|
46
52
|
plainText(html: string): Promise<string>;
|
|
47
53
|
/**
|
|
48
54
|
* デルタを 1 つの操作として適用する。`summary` は履歴に出る説明。
|
|
49
|
-
* ページがなければ適用せず、`ok: false`
|
|
55
|
+
* ページがなければ適用せず、`ok: false` を返すこと(ページを作るのは create_page だけ)
|
|
50
56
|
*/
|
|
51
57
|
apply(page: string, deltas: HistoryDelta[], summary: string): Promise<ApplyOutcome>;
|
|
58
|
+
/**
|
|
59
|
+
* ページ `from` の付箋を、ページ `to` へ移す(作成者は引き継ぐ)。付箋の中身は、実装の側が
|
|
60
|
+
* 元のページから読む。移せなければ ToolError を投げる。`noteIds` は移した後の付箋の id
|
|
61
|
+
* (`ids` と同じ順)、`received` と `removed` は、それぞれのページで記録した操作の番号
|
|
62
|
+
*/
|
|
63
|
+
moveNotes(from: string, to: string, ids: string[], summary: string): Promise<{
|
|
64
|
+
noteIds: string[];
|
|
65
|
+
received: number;
|
|
66
|
+
removed: number | null;
|
|
67
|
+
}>;
|
|
52
68
|
/** 自分(この agent)の最近の操作 */
|
|
53
69
|
ownOps(page: string): Promise<OpSummary[]>;
|
|
54
70
|
/** 自分の操作を取り消す */
|
package/dist/lib/shared/tools.js
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
// 渡せば、同じツールがそのまま使える。
|
|
6
6
|
import { computeAutoLayout } from '@kanf/wema';
|
|
7
7
|
import { z } from 'zod';
|
|
8
|
-
import { MAX_LIST_LIMIT, MAX_QUERY_LENGTH } from './api';
|
|
9
|
-
import { childPageOf } from './hierarchy';
|
|
8
|
+
import { MAX_LIST_LIMIT, MAX_QUERY_LENGTH, MAX_TITLE_LENGTH } from './api';
|
|
9
|
+
import { CHILD_NOTE_SIZE, CHILD_PAGE_KEY, childPageOf, MAX_DEPTH } from './hierarchy';
|
|
10
10
|
import { textToHtml } from './note-text';
|
|
11
11
|
import { SLUG_RE } from './slug';
|
|
12
12
|
/** ツールが入力や状態の問題で実行できなかった。メッセージは LLM に返す */
|
|
@@ -60,7 +60,7 @@ function placeNote(size, near, taken) {
|
|
|
60
60
|
}
|
|
61
61
|
async function existingBoard(access, slug) {
|
|
62
62
|
const board = await access.readBoard(slug);
|
|
63
|
-
//
|
|
63
|
+
// 書き込みのツールは、ページを作らない(作るのは create_page だけ)
|
|
64
64
|
if (!board)
|
|
65
65
|
throw new ToolError(`page not found: ${slug}`);
|
|
66
66
|
return { ...board, notesById: new Map(board.notes.map((n) => [n.id, n])) };
|
|
@@ -87,6 +87,33 @@ function assertUnique(ids) {
|
|
|
87
87
|
}
|
|
88
88
|
}
|
|
89
89
|
const summaryOf = (tool, why) => (why ? `${tool}: ${why}` : tool);
|
|
90
|
+
const messageOf = (e) => (e instanceof Error ? e.message : String(e));
|
|
91
|
+
/** `page` が `ancestor` の子孫(`ancestor` 自身を含む)か。親をたどる(階層の深さの上限までで止まる) */
|
|
92
|
+
async function isDescendant(access, page, ancestor) {
|
|
93
|
+
let name = page;
|
|
94
|
+
for (let depth = 0; name && depth <= MAX_DEPTH; depth++) {
|
|
95
|
+
if (name === ancestor)
|
|
96
|
+
return true;
|
|
97
|
+
name = await access.parentOf(name);
|
|
98
|
+
}
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* `parent` のボードに、`child` の子ページの付箋を置く(既存の付箋の下に、重ならないように)。
|
|
103
|
+
* 置けるかどうか(親は 1 つだけ、輪にならない、深さ)は、ページの DO が確かめる。記録した操作の番号を返す
|
|
104
|
+
*/
|
|
105
|
+
async function placeChild(access, parent, parentSlug, child, summary) {
|
|
106
|
+
const rect = placeNote(CHILD_NOTE_SIZE, undefined, parent.notes);
|
|
107
|
+
const note = {
|
|
108
|
+
id: crypto.randomUUID(),
|
|
109
|
+
...rect,
|
|
110
|
+
text: '',
|
|
111
|
+
color: NOTE_COLOR,
|
|
112
|
+
zIndex: 1,
|
|
113
|
+
meta: { [CHILD_PAGE_KEY]: child },
|
|
114
|
+
};
|
|
115
|
+
return applyOrThrow(access, parentSlug, [{ type: 'note:create', note }], summary);
|
|
116
|
+
}
|
|
90
117
|
function findNote(board, id) {
|
|
91
118
|
const note = board.notesById.get(id);
|
|
92
119
|
if (!note)
|
|
@@ -255,6 +282,27 @@ export const TOOLS = [
|
|
|
255
282
|
return { operation: seq };
|
|
256
283
|
},
|
|
257
284
|
}),
|
|
285
|
+
tool({
|
|
286
|
+
name: 'move_notes',
|
|
287
|
+
description: '付箋を、別のページ(すでにあるページ)へ移す。付箋の作成者は変わらない(delete_notes と add_notes で移すと、作成者が自分に変わるので、移すときはこれを使う)。移す付箋どうしをつなぐ接続線も一緒に移り、残る付箋とつながっていた接続線は消える。移す先では、付箋どうしの位置関係を保ったまま、今ある付箋の下に置く。子ページを表す付箋は移せない(ページごと移すときは move_page を使う)。note_ids は移した後の付箋の id で、指定した順に並ぶ。operations は、変更したページと操作の番号の組で、それぞれのページで revert_operation に渡せる。',
|
|
288
|
+
readOnly: false,
|
|
289
|
+
input: z.object({
|
|
290
|
+
page: page.describe('付箋が今あるページのスラッグ'),
|
|
291
|
+
note_ids: z.array(z.string()).min(1).max(MAX_ITEMS),
|
|
292
|
+
to: page.describe('移す先のページのスラッグ'),
|
|
293
|
+
reason,
|
|
294
|
+
}),
|
|
295
|
+
async run(access, input) {
|
|
296
|
+
if (input.to === input.page)
|
|
297
|
+
throw new ToolError('the notes are already in this page');
|
|
298
|
+
assertUnique(input.note_ids);
|
|
299
|
+
const moved = await access.moveNotes(input.page, input.to, input.note_ids, summaryOf('move_notes', input.reason));
|
|
300
|
+
const operations = [{ page: input.to, operation: moved.received }];
|
|
301
|
+
if (moved.removed !== null)
|
|
302
|
+
operations.push({ page: input.page, operation: moved.removed });
|
|
303
|
+
return { note_ids: moved.noteIds, operations };
|
|
304
|
+
},
|
|
305
|
+
}),
|
|
258
306
|
tool({
|
|
259
307
|
name: 'connect_notes',
|
|
260
308
|
description: '付箋同士を接続線(矢印)でつなぐ。ラベルを付けられる。',
|
|
@@ -321,6 +369,109 @@ export const TOOLS = [
|
|
|
321
369
|
return { operation: seq, moved: deltas.length };
|
|
322
370
|
},
|
|
323
371
|
}),
|
|
372
|
+
tool({
|
|
373
|
+
name: 'create_page',
|
|
374
|
+
description: 'ページを新しく作る。付箋のない空のページができる。parent を指定すると、そのページの中に子ページとして置く(parent のボードに、子ページを表す付箋が足される)。すでにある名前では作れない(既存のページを別のページの中へ移すときは move_page を使う)。ページの作成そのものは、revert_operation では取り消せない。子ページとして置いた操作は、parent のページで取り消せる(取り消すと、作ったページはルートのページとして残る)。',
|
|
375
|
+
readOnly: false,
|
|
376
|
+
input: z.object({
|
|
377
|
+
name: z
|
|
378
|
+
.string()
|
|
379
|
+
.regex(SLUG_RE)
|
|
380
|
+
.describe('新しいページのスラッグ(URL に使う名前。英小文字、数字、ハイフン。後から変えられない)'),
|
|
381
|
+
title: z.string().max(MAX_TITLE_LENGTH).optional().describe('ページの表示名。省略すると、スラッグが表示される'),
|
|
382
|
+
parent: page.optional().describe('子ページとして置く先のページのスラッグ。省略すると、ルートのページになる'),
|
|
383
|
+
reason,
|
|
384
|
+
}),
|
|
385
|
+
async run(access, input) {
|
|
386
|
+
const { name, parent } = input;
|
|
387
|
+
if (parent === name)
|
|
388
|
+
throw new ToolError(`a page cannot be placed in itself: ${name}`);
|
|
389
|
+
// 置く先が無ければ、ページを作る前に断る
|
|
390
|
+
const parentBoard = parent === undefined ? undefined : await existingBoard(access, parent);
|
|
391
|
+
const created = await access.createPage(name, input.title ?? '');
|
|
392
|
+
if (created === 'exists') {
|
|
393
|
+
throw new ToolError(`page already exists: ${name} (use move_page to place an existing page)`);
|
|
394
|
+
}
|
|
395
|
+
if (created === 'invalid title')
|
|
396
|
+
throw new ToolError('invalid title (control characters are not allowed)');
|
|
397
|
+
if (parent === undefined || parentBoard === undefined)
|
|
398
|
+
return { page: name, parent: null };
|
|
399
|
+
try {
|
|
400
|
+
const seq = await placeChild(access, parentBoard, parent, name, summaryOf('create_page', input.reason));
|
|
401
|
+
return { page: name, parent, operation: seq };
|
|
402
|
+
}
|
|
403
|
+
catch (e) {
|
|
404
|
+
// ページはできているので、どんな失敗でも、そのことを伝える(作り直そうとすると exists で断られる)
|
|
405
|
+
throw new ToolError(`created ${name} as a root page, but could not place it in ${parent}: ${messageOf(e)}`);
|
|
406
|
+
}
|
|
407
|
+
},
|
|
408
|
+
}),
|
|
409
|
+
tool({
|
|
410
|
+
name: 'move_page',
|
|
411
|
+
description: '既存のページを、別のページの中へ子ページとして移す(今の親ページにある子ページの付箋を消し、移す先に置く)。parent に null を指定すると、親から外してルートのページにする。子ページを持つページは、子孫ごと移る。今の親ページで子ページの付箋につながっていた接続線は消える。移す先に置けなかった場合(輪になる、階層が深すぎるなど)は、元の親ページに戻してエラーを返す。operations は、変更したページと操作の番号の組で、それぞれのページで revert_operation に渡せる。',
|
|
412
|
+
readOnly: false,
|
|
413
|
+
input: z.object({
|
|
414
|
+
name: page.describe('移すページのスラッグ'),
|
|
415
|
+
parent: page
|
|
416
|
+
.nullable()
|
|
417
|
+
.describe('移す先の親ページのスラッグ。null なら、親から外してルートのページにする'),
|
|
418
|
+
reason,
|
|
419
|
+
}),
|
|
420
|
+
async run(access, input) {
|
|
421
|
+
const { name, parent: target } = input;
|
|
422
|
+
const current = await access.parentOf(name);
|
|
423
|
+
if (current === undefined)
|
|
424
|
+
throw new ToolError(`page not found: ${name}`);
|
|
425
|
+
if (target === name)
|
|
426
|
+
throw new ToolError(`a page cannot be placed in itself: ${name}`);
|
|
427
|
+
if (current === target) {
|
|
428
|
+
throw new ToolError(target === null ? `already a root page: ${name}` : `already in ${target}: ${name}`);
|
|
429
|
+
}
|
|
430
|
+
const summary = summaryOf('move_page', input.reason);
|
|
431
|
+
// 移す先が無いか、移す先が自分の子孫(輪になる)なら、今の親から外す前に断る。
|
|
432
|
+
// 深さの上限は、置くときに DO が確かめる(断られたら、下で元に戻す)
|
|
433
|
+
const targetBoard = target === null ? undefined : await existingBoard(access, target);
|
|
434
|
+
if (target !== null && (await isDescendant(access, target, name))) {
|
|
435
|
+
throw new ToolError(`could not place ${name} in ${target}: ${target} is inside ${name}`);
|
|
436
|
+
}
|
|
437
|
+
const operations = [];
|
|
438
|
+
let removed;
|
|
439
|
+
if (current !== null) {
|
|
440
|
+
const board = await access.readBoard(current);
|
|
441
|
+
const notes = board?.notes.filter((n) => childPageOf(n) === name) ?? [];
|
|
442
|
+
if (notes.length > 0) {
|
|
443
|
+
removed = await applyOrThrow(access, current, notes.map((note) => ({ type: 'note:delete', note })), summary);
|
|
444
|
+
operations.push({ page: current, operation: removed });
|
|
445
|
+
}
|
|
446
|
+
else {
|
|
447
|
+
// 親の記録が古い(親ページに付箋がない、親ページがもうない)。記録だけを消す
|
|
448
|
+
await access.clearParent(name, current);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
if (target === null || targetBoard === undefined)
|
|
452
|
+
return { page: name, from: current, to: null, operations };
|
|
453
|
+
try {
|
|
454
|
+
const placed = await placeChild(access, targetBoard, target, name, summary);
|
|
455
|
+
operations.push({ page: target, operation: placed });
|
|
456
|
+
return { page: name, from: current, to: target, operations };
|
|
457
|
+
}
|
|
458
|
+
catch (e) {
|
|
459
|
+
const why = `could not place ${name} in ${target}: ${messageOf(e)}`;
|
|
460
|
+
if (current === null || removed === undefined)
|
|
461
|
+
throw new ToolError(why);
|
|
462
|
+
// 置けなかったので、外した操作を取り消して、元の親ページに戻す
|
|
463
|
+
await access
|
|
464
|
+
.revert(current, removed, summaryOf('move_page', `restore ${name} after a failed move`))
|
|
465
|
+
.catch(() => undefined);
|
|
466
|
+
// 戻せたかは、取り消しの結果ではなく、今の親で判断する(間に人が別のページへ置いたこともある)
|
|
467
|
+
const now = await access.parentOf(name).catch(() => undefined);
|
|
468
|
+
if (now === current)
|
|
469
|
+
throw new ToolError(`${why} (left it in ${current})`);
|
|
470
|
+
const where = now === null ? 'is now a root page' : now === undefined ? 'could not be checked' : `is now in ${now}`;
|
|
471
|
+
throw new ToolError(`${why}. It was removed from ${current} and ${where}; operation ${removed} on ${current} is the removal`);
|
|
472
|
+
}
|
|
473
|
+
},
|
|
474
|
+
}),
|
|
324
475
|
tool({
|
|
325
476
|
name: 'revert_operation',
|
|
326
477
|
description: '自分が行った操作を取り消す。operation を省略すると、まだ取り消していない直前の操作が対象になる。その後に人が変更した付箋は戻さず、skipped で返す。何も戻せなかった場合(applied が 0)は、それより前の自分の操作の番号を earlier_operations で返すので、必要なら operation に指定して呼び直す。',
|
|
@@ -41,6 +41,16 @@ export declare function readBoardWithInfos(sql: SqlStorage): {
|
|
|
41
41
|
};
|
|
42
42
|
/** 付箋(作成者つき)と接続線。agent に渡す */
|
|
43
43
|
export declare function readBoardWithAuthors(sql: SqlStorage): Pick<BoardState, 'notes' | 'edges'>;
|
|
44
|
+
/** 他のページへ移すために読んだ付箋。作成者と、最後に変更した時刻が付く */
|
|
45
|
+
export type TakenNote = WemaNote & {
|
|
46
|
+
createdBy: string | null;
|
|
47
|
+
updatedAt: number;
|
|
48
|
+
};
|
|
49
|
+
/** 付箋 `ids` と、その付箋どうしをつなぐ接続線。他のページへ移す付箋を読む */
|
|
50
|
+
export declare function readNotesAmong(sql: SqlStorage, ids: string[]): {
|
|
51
|
+
notes: TakenNote[];
|
|
52
|
+
edges: WemaEdge[];
|
|
53
|
+
};
|
|
44
54
|
export declare const readBoard: (sql: SqlStorage) => BoardData;
|
|
45
55
|
export interface Sanitized {
|
|
46
56
|
/** サニタイズで text が変わった付箋について、送信元の手元の状態を直すためのデルタ */
|
|
@@ -71,5 +81,7 @@ export interface Applied {
|
|
|
71
81
|
* 変える前に保存した text を編集できるようにするため。後者は、送信元がサニタイズ前の
|
|
72
82
|
* text を手元に持ったまま続けて編集した場合のため
|
|
73
83
|
* - 更新で `after` にキーがなく `before` にあるものは、未設定に戻す
|
|
84
|
+
* - 作成した付箋の作成者は `actor`。`authors` に付箋の id があれば、その値にする(他のページから
|
|
85
|
+
* 移した付箋の作成者を引き継ぐ)。`authors` はサーバーが読んだ値だけを渡すこと
|
|
74
86
|
*/
|
|
75
|
-
export declare function applyDeltas(sql: SqlStorage, deltas: HistoryDelta[], actor: string, now: number, cleanBefore?: Map<HistoryDelta, string>): Applied;
|
|
87
|
+
export declare function applyDeltas(sql: SqlStorage, deltas: HistoryDelta[], actor: string, now: number, cleanBefore?: Map<HistoryDelta, string>, authors?: ReadonlyMap<string, string | null>): Applied;
|
|
@@ -163,6 +163,25 @@ export function readBoardWithAuthors(sql) {
|
|
|
163
163
|
edges: readEdges(sql),
|
|
164
164
|
};
|
|
165
165
|
}
|
|
166
|
+
/** 付箋 `ids` と、その付箋どうしをつなぐ接続線。他のページへ移す付箋を読む */
|
|
167
|
+
export function readNotesAmong(sql, ids) {
|
|
168
|
+
const json = JSON.stringify(ids);
|
|
169
|
+
const among = `(SELECT value FROM json_each(?))`;
|
|
170
|
+
return {
|
|
171
|
+
notes: sql
|
|
172
|
+
.exec(`SELECT * FROM notes WHERE id IN ${among} ORDER BY rowid`, json)
|
|
173
|
+
.toArray()
|
|
174
|
+
.map((row) => ({
|
|
175
|
+
...rowToNote(row),
|
|
176
|
+
createdBy: row.created_by,
|
|
177
|
+
updatedAt: row.updated_at,
|
|
178
|
+
})),
|
|
179
|
+
edges: sql
|
|
180
|
+
.exec(`SELECT * FROM edges WHERE from_id IN ${among} AND to_id IN ${among} ORDER BY rowid`, json, json)
|
|
181
|
+
.toArray()
|
|
182
|
+
.map(rowToEdge),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
166
185
|
export const readBoard = (sql) => readBoardWithInfos(sql).data;
|
|
167
186
|
/**
|
|
168
187
|
* デルタ中の text をサニタイズする(`note.text` と `after.text` を書き換える)。
|
|
@@ -205,8 +224,10 @@ export async function sanitizeDeltas(deltas, options = { cleanBefore: true }) {
|
|
|
205
224
|
* 変える前に保存した text を編集できるようにするため。後者は、送信元がサニタイズ前の
|
|
206
225
|
* text を手元に持ったまま続けて編集した場合のため
|
|
207
226
|
* - 更新で `after` にキーがなく `before` にあるものは、未設定に戻す
|
|
227
|
+
* - 作成した付箋の作成者は `actor`。`authors` に付箋の id があれば、その値にする(他のページから
|
|
228
|
+
* 移した付箋の作成者を引き継ぐ)。`authors` はサーバーが読んだ値だけを渡すこと
|
|
208
229
|
*/
|
|
209
|
-
export function applyDeltas(sql, deltas, actor, now, cleanBefore) {
|
|
230
|
+
export function applyDeltas(sql, deltas, actor, now, cleanBefore, authors) {
|
|
210
231
|
const applied = [];
|
|
211
232
|
const fixups = [];
|
|
212
233
|
for (const d of deltas) {
|
|
@@ -218,7 +239,7 @@ export function applyDeltas(sql, deltas, actor, now, cleanBefore) {
|
|
|
218
239
|
assertNotPlaced(sql, n, n.id);
|
|
219
240
|
sql.exec(`INSERT INTO notes (id, x, y, width, height, text, color, z_index, auto_size, foldable, extra,
|
|
220
241
|
created_by, updated_at, updated_by)
|
|
221
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, n.id, n.x, n.y, n.width, n.height, n.text, n.color, n.zIndex, n.autoSize ? 1 : 0, n.foldable ? 1 : 0, metaJson(n.meta), actor, now, actor);
|
|
242
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, n.id, n.x, n.y, n.width, n.height, n.text, n.color, n.zIndex, n.autoSize ? 1 : 0, n.foldable ? 1 : 0, metaJson(n.meta), authors?.has(n.id) ? authors.get(n.id) : actor, now, actor);
|
|
222
243
|
applied.push(d);
|
|
223
244
|
break;
|
|
224
245
|
}
|
|
@@ -21,8 +21,16 @@ export declare class ServerBoardAccess implements BoardAccess {
|
|
|
21
21
|
snippet: string | undefined;
|
|
22
22
|
}[]>;
|
|
23
23
|
readBoard(slug: string): Promise<BoardState | null>;
|
|
24
|
+
parentOf(slug: string): Promise<string | null | undefined>;
|
|
25
|
+
clearParent(slug: string, parent: string): Promise<void>;
|
|
26
|
+
createPage(slug: string, title: string): Promise<'created' | 'exists' | 'invalid title'>;
|
|
24
27
|
plainText(html: string): Promise<string>;
|
|
25
28
|
apply(slug: string, deltas: HistoryDelta[], summary: string): Promise<ApplyOutcome>;
|
|
29
|
+
moveNotes(from: string, to: string, ids: string[], summary: string): Promise<{
|
|
30
|
+
noteIds: string[];
|
|
31
|
+
received: number;
|
|
32
|
+
removed: number | null;
|
|
33
|
+
}>;
|
|
26
34
|
ownOps(slug: string): Promise<OpSummary[]>;
|
|
27
35
|
revert(slug: string, seq: number, summary: string): Promise<RevertOutcome>;
|
|
28
36
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { ToolError } from '../../shared/tools';
|
|
2
2
|
import { listPages, searchPages } from '../page-queries';
|
|
3
3
|
import { extractContent } from '../plain-text';
|
|
4
|
+
import { transferNotes } from '../transfer';
|
|
4
5
|
/** ops に記録する clientId。MCP にはブラウザのような接続ごとの id がない */
|
|
5
6
|
const MCP_CLIENT_ID = 'mcp';
|
|
6
7
|
export class ServerBoardAccess {
|
|
@@ -28,6 +29,17 @@ export class ServerBoardAccess {
|
|
|
28
29
|
async readBoard(slug) {
|
|
29
30
|
return this.page(slug).getBoardState();
|
|
30
31
|
}
|
|
32
|
+
async parentOf(slug) {
|
|
33
|
+
const ref = await this.page(slug).getPageRef();
|
|
34
|
+
return ref === null ? undefined : ref.parent;
|
|
35
|
+
}
|
|
36
|
+
async clearParent(slug, parent) {
|
|
37
|
+
await this.page(slug).clearParent(parent);
|
|
38
|
+
}
|
|
39
|
+
async createPage(slug, title) {
|
|
40
|
+
const result = await this.page(slug).createPage(title);
|
|
41
|
+
return result.ok ? 'created' : result.reason;
|
|
42
|
+
}
|
|
31
43
|
async plainText(html) {
|
|
32
44
|
// ページへのリンクは、書くときと同じ書式([[slug]])で残す
|
|
33
45
|
return (await extractContent(html, { siteOrigin: this.env.SITE_ORIGIN })).text;
|
|
@@ -47,6 +59,22 @@ export class ServerBoardAccess {
|
|
|
47
59
|
// すべてのデルタが捨てられた場合、seq は進んでいない(直前の別の操作の番号が入っている)
|
|
48
60
|
return { ok: true, seq: result.deltas.length > 0 ? result.seq : null };
|
|
49
61
|
}
|
|
62
|
+
async moveNotes(from, to, ids, summary) {
|
|
63
|
+
const result = await transferNotes(this.env, {
|
|
64
|
+
actor: this.actor,
|
|
65
|
+
clientId: MCP_CLIENT_ID,
|
|
66
|
+
opId: crypto.randomUUID(),
|
|
67
|
+
from,
|
|
68
|
+
to,
|
|
69
|
+
noteIds: ids,
|
|
70
|
+
copy: false,
|
|
71
|
+
summary,
|
|
72
|
+
});
|
|
73
|
+
if (!result.ok)
|
|
74
|
+
throw new ToolError(result.reason);
|
|
75
|
+
const { ok: _, ...moved } = result;
|
|
76
|
+
return moved;
|
|
77
|
+
}
|
|
50
78
|
async ownOps(slug) {
|
|
51
79
|
return this.page(slug).listOps({ actor: this.actor });
|
|
52
80
|
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { DurableObject } from 'cloudflare:workers';
|
|
2
2
|
import type { WemaKakeEnv } from './config';
|
|
3
3
|
import { type OpSummary, type PageSummary, type RevertOutcome } from '../shared/api';
|
|
4
|
-
import { type BoardContent, type HistoryDelta, type Snapshot } from '../shared/delta';
|
|
4
|
+
import { type BoardContent, type HistoryDelta, type Snapshot, type WemaEdge } from '../shared/delta';
|
|
5
5
|
import type { BoardState } from '../shared/tools';
|
|
6
6
|
import { type ChildRejectCode } from '../shared/hierarchy';
|
|
7
|
+
import { type TakenNote } from './apply-ops';
|
|
7
8
|
/** Worker が WebSocket の接続要求を DO へ転送するときに、認証済みの主体を入れるヘッダー */
|
|
8
9
|
export declare const ACTOR_HEADER = "X-Wema-Actor";
|
|
9
10
|
/** 同じく、認証の期限(UNIX 秒)を入れるヘッダー。期限がなければ付けない */
|
|
@@ -16,7 +17,7 @@ export interface ApplyInput {
|
|
|
16
17
|
/** 外から来た値をそのまま渡す。検証は applyOps が行う */
|
|
17
18
|
deltas: unknown;
|
|
18
19
|
summary?: string;
|
|
19
|
-
/** ページがまだ作られていなければ、適用せずに断る(MCP
|
|
20
|
+
/** ページがまだ作られていなければ、適用せずに断る(MCP の書き込みのツールは、ページを作らない。作るのは create_page だけ) */
|
|
20
21
|
mustExist?: boolean;
|
|
21
22
|
}
|
|
22
23
|
/** ページがないときの `reason`。Worker は、これを 404 にする */
|
|
@@ -28,6 +29,59 @@ export interface ReceiveInput extends Pick<ApplyInput, 'actor' | 'clientId' | 'o
|
|
|
28
29
|
/** 付箋が元あったページ(操作の履歴の要約に出す) */
|
|
29
30
|
from: string;
|
|
30
31
|
}
|
|
32
|
+
export interface TakeInput extends Pick<ApplyInput, 'actor' | 'clientId' | 'opId'> {
|
|
33
|
+
/** 付箋が今あるページ */
|
|
34
|
+
from: string;
|
|
35
|
+
/** 移す(写す)付箋の、元のページでの id。呼び出し側(Worker)が検証する */
|
|
36
|
+
noteIds: string[];
|
|
37
|
+
/** 付箋の id から、元のページでの位置の代わりに使う位置。呼び出し側(Worker)が検証する */
|
|
38
|
+
positions: Map<string, {
|
|
39
|
+
x: number;
|
|
40
|
+
y: number;
|
|
41
|
+
}>;
|
|
42
|
+
/** 付箋の作成者を引き継ぐ(移すとき)。引き継がなければ、作成者は `actor`(写すとき) */
|
|
43
|
+
keepAuthors: boolean;
|
|
44
|
+
/** 操作の履歴に出す説明。省略すると、元のページを表す符号 */
|
|
45
|
+
summary?: string;
|
|
46
|
+
}
|
|
47
|
+
/** 付箋の id から、最後に変更した時刻(notes.updated_at) */
|
|
48
|
+
export type NoteVersions = Record<string, number>;
|
|
49
|
+
/**
|
|
50
|
+
* `noteIds` は、置いた付箋の id。`TakeInput.noteIds` と同じ順に並ぶ。`versions` は、読んだときの
|
|
51
|
+
* 元の付箋の版。同じ操作の再送では、読み直さないので null。`notFound` は、このページ、元のページ、
|
|
52
|
+
* 付箋のどれかがなかった
|
|
53
|
+
*/
|
|
54
|
+
export type TakeResult = {
|
|
55
|
+
ok: true;
|
|
56
|
+
seq: number;
|
|
57
|
+
noteIds: string[];
|
|
58
|
+
versions: NoteVersions | null;
|
|
59
|
+
} | {
|
|
60
|
+
ok: false;
|
|
61
|
+
reason: string;
|
|
62
|
+
notFound?: true;
|
|
63
|
+
};
|
|
64
|
+
export interface RemoveInput extends Pick<ApplyInput, 'actor' | 'clientId' | 'opId'> {
|
|
65
|
+
noteIds: string[];
|
|
66
|
+
/**
|
|
67
|
+
* takeNotes が読んだときの版。これと違う付箋(読んだ後に変わった、消えた)は消さずに、`kept` で返す
|
|
68
|
+
* (送り先には、変わる前の付箋が置かれている)。null なら、今ある付箋をそのまま消す
|
|
69
|
+
*/
|
|
70
|
+
versions: NoteVersions | null;
|
|
71
|
+
/** 付箋を移した先のページ(操作の履歴の要約に出す) */
|
|
72
|
+
to: string;
|
|
73
|
+
/** 操作の履歴に出す説明。省略すると、移した先のページを表す符号 */
|
|
74
|
+
summary?: string;
|
|
75
|
+
}
|
|
76
|
+
/** `seq` は記録した操作の番号。消した付箋がなければ null。`kept` は、版が違って消さなかった付箋 */
|
|
77
|
+
export type RemoveResult = {
|
|
78
|
+
ok: true;
|
|
79
|
+
seq: number | null;
|
|
80
|
+
kept: string[];
|
|
81
|
+
} | {
|
|
82
|
+
ok: false;
|
|
83
|
+
reason: string;
|
|
84
|
+
};
|
|
31
85
|
export type ApplyResult = {
|
|
32
86
|
ok: true;
|
|
33
87
|
seq: number;
|
|
@@ -281,8 +335,35 @@ export declare class PageDO extends DurableObject<WemaKakeEnv> {
|
|
|
281
335
|
* 他のページから移す(写す)付箋と、その間の接続線を受け取って置く。互いの位置関係は保ち、
|
|
282
336
|
* 今ある付箋の下に置く(送る側は、このページの付箋の位置を知らない)。
|
|
283
337
|
* ページがまだ作られていなければ、作らずに断る。
|
|
338
|
+
*
|
|
339
|
+
* 付箋の中身を送る側が渡す形なので、作成者は送った人になる。画面は takeNotes(Worker の
|
|
340
|
+
* transferNotes)を使う。これは、更新の前に開いた画面のタブが使う
|
|
284
341
|
*/
|
|
285
342
|
receiveNotes(input: ReceiveInput): Promise<ApplyResult>;
|
|
343
|
+
/**
|
|
344
|
+
* 他のページ(`from`)の付箋を読んで、このページに置く。置き方は receiveNotes と同じ。
|
|
345
|
+
* 付箋の中身と作成者は、元のページの DO から読む(送る側の申告を使わない)。
|
|
346
|
+
* 元のページからは消さない(消すのは、置けた後に、Worker が removeNotes で行う)。
|
|
347
|
+
* 子ページを表す付箋は、置かずに断る(ページの親が変わることになる)
|
|
348
|
+
*/
|
|
349
|
+
takeNotes(input: TakeInput): Promise<TakeResult>;
|
|
350
|
+
/**
|
|
351
|
+
* 付箋 `ids` と、その付箋どうしをつなぐ接続線。他のページが、移す付箋を読む(takeNotes)。
|
|
352
|
+
* ページがまだ作られていなければ null
|
|
353
|
+
*/
|
|
354
|
+
notesAmong(ids: string[]): {
|
|
355
|
+
notes: TakenNote[];
|
|
356
|
+
edges: WemaEdge[];
|
|
357
|
+
} | null;
|
|
358
|
+
/** takeNotes で読んだ付箋を、新しい id にして置く */
|
|
359
|
+
private placeTaken;
|
|
360
|
+
/**
|
|
361
|
+
* 他のページへ移した付箋を、このページから消す(takeNotes で置けた後に、Worker が呼ぶ)。
|
|
362
|
+
* 付箋を読むのは、順番待ちに入ってから。同じ opId で送り直しても、二重には記録しない
|
|
363
|
+
*/
|
|
364
|
+
removeNotes(input: RemoveInput): Promise<RemoveResult>;
|
|
365
|
+
/** 作成のデルタの付箋を、互いの位置関係を保ったまま、今ある付箋の下へずらす */
|
|
366
|
+
private placeBelowNotes;
|
|
286
367
|
/** 前の書き込みが終わってから `run` を始める */
|
|
287
368
|
private inOrder;
|
|
288
369
|
/** 同じ送信元の同じ操作が記録済みなら、その行を返す */
|
|
@@ -293,9 +374,10 @@ export declare class PageDO extends DurableObject<WemaKakeEnv> {
|
|
|
293
374
|
*/
|
|
294
375
|
private recordOp;
|
|
295
376
|
/**
|
|
296
|
-
* @param prepare 検証の済んだデルタを、適用する前に書き換える(今あるボードに合わせた位置決めなど)。
|
|
377
|
+
* @param options.prepare 検証の済んだデルタを、適用する前に書き換える(今あるボードに合わせた位置決めなど)。
|
|
297
378
|
* 同じ操作の再送(適用済み)では、呼ばない
|
|
298
|
-
* @param matchColors 適用した後に、子ページの色と、その付箋の色をそろえる(matchChildColors
|
|
379
|
+
* @param options.matchColors 適用した後に、子ページの色と、その付箋の色をそろえる(matchChildColors)。既定は true
|
|
380
|
+
* @param options.authors 作成する付箋の作成者(付箋の id から)。applyDeltas を参照
|
|
299
381
|
*/
|
|
300
382
|
private applyOpsInOrder;
|
|
301
383
|
/**
|