@kanf/wema-kake 0.1.2 → 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/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 +10 -0
- package/dist/lib/shared/tools.js +21 -0
- 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 +5 -0
- package/dist/lib/worker/mcp/board-access.js +17 -0
- package/dist/lib/worker/page-do.d.ts +85 -3
- 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-B8NVsNXL.js → index-DaZ8I4iZ.js} +1 -1
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
- package/template/package.json +1 -1
|
@@ -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;
|
|
@@ -55,6 +55,16 @@ export interface BoardAccess {
|
|
|
55
55
|
* ページがなければ適用せず、`ok: false` を返すこと(ページを作るのは create_page だけ)
|
|
56
56
|
*/
|
|
57
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
|
+
}>;
|
|
58
68
|
/** 自分(この agent)の最近の操作 */
|
|
59
69
|
ownOps(page: string): Promise<OpSummary[]>;
|
|
60
70
|
/** 自分の操作を取り消す */
|
package/dist/lib/shared/tools.js
CHANGED
|
@@ -282,6 +282,27 @@ export const TOOLS = [
|
|
|
282
282
|
return { operation: seq };
|
|
283
283
|
},
|
|
284
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
|
+
}),
|
|
285
306
|
tool({
|
|
286
307
|
name: 'connect_notes',
|
|
287
308
|
description: '付箋同士を接続線(矢印)でつなぐ。ラベルを付けられる。',
|
|
@@ -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
|
}
|
|
@@ -26,6 +26,11 @@ export declare class ServerBoardAccess implements BoardAccess {
|
|
|
26
26
|
createPage(slug: string, title: string): Promise<'created' | 'exists' | 'invalid title'>;
|
|
27
27
|
plainText(html: string): Promise<string>;
|
|
28
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
|
+
}>;
|
|
29
34
|
ownOps(slug: string): Promise<OpSummary[]>;
|
|
30
35
|
revert(slug: string, seq: number, summary: string): Promise<RevertOutcome>;
|
|
31
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 {
|
|
@@ -58,6 +59,22 @@ export class ServerBoardAccess {
|
|
|
58
59
|
// すべてのデルタが捨てられた場合、seq は進んでいない(直前の別の操作の番号が入っている)
|
|
59
60
|
return { ok: true, seq: result.deltas.length > 0 ? result.seq : null };
|
|
60
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
|
+
}
|
|
61
78
|
async ownOps(slug) {
|
|
62
79
|
return this.page(slug).listOps({ actor: this.actor });
|
|
63
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 秒)を入れるヘッダー。期限がなければ付けない */
|
|
@@ -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
|
/**
|
|
@@ -4,7 +4,7 @@ import { invertDeltas, } from '../shared/delta';
|
|
|
4
4
|
import { systemSummary } from '../shared/op-summary';
|
|
5
5
|
import { CLOSE_AUTH_EXPIRED, CLOSE_PAGE_DELETED, PING, PONG, } from '../shared/protocol';
|
|
6
6
|
import { childPageOf, childRejection, MAX_DEPTH, placedPages, touchesChildren, } from '../shared/hierarchy';
|
|
7
|
-
import { applyDeltas, childNoteColors, childPages, readBoard, readBoardWithAuthors, readBoardWithInfos, readNotes, sanitizeDeltas, UNSET_FLAGS, } from './apply-ops';
|
|
7
|
+
import { applyDeltas, childNoteColors, childPages, readBoard, readBoardWithAuthors, readBoardWithInfos, readNotes, readNotesAmong, sanitizeDeltas, UNSET_FLAGS, } from './apply-ops';
|
|
8
8
|
import { buildLayout, buildPageContent, contentHash, indexedChildren, removePage, touchPage, writePage, } from './indexer';
|
|
9
9
|
import { applyRevert } from './revert';
|
|
10
10
|
import { SanitizeError } from './sanitize';
|
|
@@ -30,6 +30,9 @@ function isExpired(attachment) {
|
|
|
30
30
|
}
|
|
31
31
|
/** ページがないときの `reason`。Worker は、これを 404 にする */
|
|
32
32
|
export const REASON_PAGE_NOT_FOUND = 'page not found';
|
|
33
|
+
/** 作成のデルタで作る付箋(デルタの順) */
|
|
34
|
+
const createdNotes = (deltas) => deltas.flatMap((d) => (d.type === 'note:create' ? [d.note] : []));
|
|
35
|
+
const createdNoteIds = (deltas) => createdNotes(deltas).map((note) => note.id);
|
|
33
36
|
/** 受け取った付箋と、今ある付箋の間に空ける幅 */
|
|
34
37
|
const RECEIVE_GAP = 40;
|
|
35
38
|
/**
|
|
@@ -519,7 +522,7 @@ export class PageDO extends DurableObject {
|
|
|
519
522
|
opId: crypto.randomUUID(),
|
|
520
523
|
deltas: notes.map((note) => ({ type: 'note:update', noteId: note.id, before: { color: note.color }, after: { color } })),
|
|
521
524
|
summary: systemSummary.childRecolored(page),
|
|
522
|
-
},
|
|
525
|
+
}, { matchColors: false });
|
|
523
526
|
}
|
|
524
527
|
/**
|
|
525
528
|
* ページを削除する前に、階層から外す。親ページにある自分の付箋を消し、子ページはルートへ戻す
|
|
@@ -740,6 +743,9 @@ export class PageDO extends DurableObject {
|
|
|
740
743
|
* 他のページから移す(写す)付箋と、その間の接続線を受け取って置く。互いの位置関係は保ち、
|
|
741
744
|
* 今ある付箋の下に置く(送る側は、このページの付箋の位置を知らない)。
|
|
742
745
|
* ページがまだ作られていなければ、作らずに断る。
|
|
746
|
+
*
|
|
747
|
+
* 付箋の中身を送る側が渡す形なので、作成者は送った人になる。画面は takeNotes(Worker の
|
|
748
|
+
* transferNotes)を使う。これは、更新の前に開いた画面のタブが使う
|
|
743
749
|
*/
|
|
744
750
|
receiveNotes(input) {
|
|
745
751
|
return this.write(async () => {
|
|
@@ -755,13 +761,127 @@ export class PageDO extends DurableObject {
|
|
|
755
761
|
],
|
|
756
762
|
summary: systemSummary.notesReceived(from),
|
|
757
763
|
mustExist: true,
|
|
758
|
-
}, (deltas) =>
|
|
759
|
-
.exec(`SELECT min(x) AS left, max(y + height) AS bottom FROM notes`)
|
|
760
|
-
.one()));
|
|
764
|
+
}, { prepare: (deltas) => this.placeBelowNotes(deltas) });
|
|
761
765
|
},
|
|
762
766
|
// 呼び出し側(Worker)は、ページがないときと同じに扱う
|
|
763
767
|
{ ok: false, reason: REASON_PAGE_NOT_FOUND });
|
|
764
768
|
}
|
|
769
|
+
/**
|
|
770
|
+
* 他のページ(`from`)の付箋を読んで、このページに置く。置き方は receiveNotes と同じ。
|
|
771
|
+
* 付箋の中身と作成者は、元のページの DO から読む(送る側の申告を使わない)。
|
|
772
|
+
* 元のページからは消さない(消すのは、置けた後に、Worker が removeNotes で行う)。
|
|
773
|
+
* 子ページを表す付箋は、置かずに断る(ページの親が変わることになる)
|
|
774
|
+
*/
|
|
775
|
+
takeNotes(input) {
|
|
776
|
+
const pageNotFound = { ok: false, reason: REASON_PAGE_NOT_FOUND, notFound: true };
|
|
777
|
+
return this.write(async () => {
|
|
778
|
+
const { actor, clientId, opId, from, noteIds } = input;
|
|
779
|
+
if (this.version === 0)
|
|
780
|
+
return pageNotFound;
|
|
781
|
+
// 同じ操作の再送。元のページの付箋は、前の送信で消えていることがあるので、読みに行かない
|
|
782
|
+
const done = this.findOp(actor, clientId, opId);
|
|
783
|
+
if (done) {
|
|
784
|
+
const noteIds = createdNoteIds(JSON.parse(done.body));
|
|
785
|
+
return { ok: true, seq: done.seq, noteIds, versions: null };
|
|
786
|
+
}
|
|
787
|
+
if (from === this.slug())
|
|
788
|
+
return { ok: false, reason: 'cannot move notes to the same page' };
|
|
789
|
+
// 元のページの読み取りは、向こうの順番待ちに入らない。向こうの書き込みが、こちらの順番待ちを
|
|
790
|
+
// 待っていることがあるため(入ると、互いに待って止まる)。読んだ後に向こうで変わった付箋は、
|
|
791
|
+
// removeNotes が版を比べて、消さずに残す
|
|
792
|
+
const source = await this.env.PAGE.getByName(from).notesAmong(noteIds);
|
|
793
|
+
if (!source)
|
|
794
|
+
return { ok: false, reason: `page not found: ${from}`, notFound: true };
|
|
795
|
+
return this.placeTaken(input, source);
|
|
796
|
+
}, pageNotFound);
|
|
797
|
+
}
|
|
798
|
+
/**
|
|
799
|
+
* 付箋 `ids` と、その付箋どうしをつなぐ接続線。他のページが、移す付箋を読む(takeNotes)。
|
|
800
|
+
* ページがまだ作られていなければ null
|
|
801
|
+
*/
|
|
802
|
+
notesAmong(ids) {
|
|
803
|
+
return this.version === 0 ? null : readNotesAmong(this.sql, ids);
|
|
804
|
+
}
|
|
805
|
+
/** takeNotes で読んだ付箋を、新しい id にして置く */
|
|
806
|
+
async placeTaken(input, source) {
|
|
807
|
+
const { from, noteIds, positions, keepAuthors, summary, ...sender } = input;
|
|
808
|
+
const byId = new Map(source.notes.map((note) => [note.id, note]));
|
|
809
|
+
/** 元のページでの id から、このページでの id(同じ付箋を 2 回写しても、重ならない) */
|
|
810
|
+
const newIds = new Map();
|
|
811
|
+
const authors = new Map();
|
|
812
|
+
const versions = {};
|
|
813
|
+
const notes = [];
|
|
814
|
+
for (const id of noteIds) {
|
|
815
|
+
const found = byId.get(id);
|
|
816
|
+
if (!found)
|
|
817
|
+
return { ok: false, reason: `note not found: ${id}`, notFound: true };
|
|
818
|
+
if (childPageOf(found) !== undefined)
|
|
819
|
+
return { ok: false, reason: `cannot move a child page note: ${id}` };
|
|
820
|
+
const { createdBy, updatedAt, ...note } = found;
|
|
821
|
+
const newId = crypto.randomUUID();
|
|
822
|
+
newIds.set(id, newId);
|
|
823
|
+
notes.push({ ...note, ...positions.get(id), id: newId });
|
|
824
|
+
if (keepAuthors)
|
|
825
|
+
authors.set(newId, createdBy);
|
|
826
|
+
versions[id] = updatedAt;
|
|
827
|
+
}
|
|
828
|
+
// 読んだ接続線は、送る付箋どうしをつなぐものだけ(残る付箋との接続線は、置かない)
|
|
829
|
+
const edges = source.edges.map((edge) => ({
|
|
830
|
+
...edge, id: crypto.randomUUID(), from: newIds.get(edge.from), to: newIds.get(edge.to),
|
|
831
|
+
}));
|
|
832
|
+
const result = await this.applyOpsInOrder({
|
|
833
|
+
...sender,
|
|
834
|
+
deltas: [
|
|
835
|
+
...notes.map((note) => ({ type: 'note:create', note })),
|
|
836
|
+
...edges.map((edge) => ({ type: 'edge:create', edge })),
|
|
837
|
+
],
|
|
838
|
+
summary: summary ?? systemSummary.notesReceived(from),
|
|
839
|
+
mustExist: true,
|
|
840
|
+
}, { prepare: (deltas) => this.placeBelowNotes(deltas), authors });
|
|
841
|
+
if (!result.ok)
|
|
842
|
+
return { ok: false, reason: result.reason };
|
|
843
|
+
return { ok: true, seq: result.seq, noteIds: createdNoteIds(result.deltas), versions };
|
|
844
|
+
}
|
|
845
|
+
/**
|
|
846
|
+
* 他のページへ移した付箋を、このページから消す(takeNotes で置けた後に、Worker が呼ぶ)。
|
|
847
|
+
* 付箋を読むのは、順番待ちに入ってから。同じ opId で送り直しても、二重には記録しない
|
|
848
|
+
*/
|
|
849
|
+
removeNotes(input) {
|
|
850
|
+
return this.write(async () => {
|
|
851
|
+
const { noteIds, versions, to, summary, ...sender } = input;
|
|
852
|
+
if (this.version === 0)
|
|
853
|
+
return { ok: false, reason: REASON_PAGE_NOT_FOUND };
|
|
854
|
+
// 同じ操作の再送。付箋は前の送信で消えている
|
|
855
|
+
const done = this.findOp(sender.actor, sender.clientId, sender.opId);
|
|
856
|
+
if (done)
|
|
857
|
+
return { ok: true, seq: done.seq, kept: [] };
|
|
858
|
+
const current = new Map(readNotesAmong(this.sql, noteIds).notes.map((note) => [note.id, note]));
|
|
859
|
+
// 読んだ後に変わった付箋と、誰かが消した付箋(同時に別のページへ移した場合を含む)は消さない
|
|
860
|
+
const kept = versions ? noteIds.filter((id) => current.get(id)?.updatedAt !== versions[id]) : [];
|
|
861
|
+
const keptSet = new Set(kept);
|
|
862
|
+
const notes = [...current.values()]
|
|
863
|
+
.filter((note) => !keptSet.has(note.id))
|
|
864
|
+
.map(({ createdBy: _, updatedAt: __, ...note }) => note);
|
|
865
|
+
// 空のデルタは記録できない
|
|
866
|
+
if (notes.length === 0)
|
|
867
|
+
return { ok: true, seq: null, kept };
|
|
868
|
+
const result = await this.applyOpsInOrder({
|
|
869
|
+
...sender,
|
|
870
|
+
deltas: notes.map((note) => ({ type: 'note:delete', note })),
|
|
871
|
+
summary: summary ?? systemSummary.notesSent(to),
|
|
872
|
+
mustExist: true,
|
|
873
|
+
});
|
|
874
|
+
if (!result.ok)
|
|
875
|
+
return { ok: false, reason: result.reason };
|
|
876
|
+
return { ok: true, seq: result.deltas.length > 0 ? result.seq : null, kept };
|
|
877
|
+
}, { ok: false, reason: REASON_PAGE_NOT_FOUND });
|
|
878
|
+
}
|
|
879
|
+
/** 作成のデルタの付箋を、互いの位置関係を保ったまま、今ある付箋の下へずらす */
|
|
880
|
+
placeBelowNotes(deltas) {
|
|
881
|
+
placeBelow(createdNotes(deltas), this.sql
|
|
882
|
+
.exec(`SELECT min(x) AS left, max(y + height) AS bottom FROM notes`)
|
|
883
|
+
.one());
|
|
884
|
+
}
|
|
765
885
|
/** 前の書き込みが終わってから `run` を始める */
|
|
766
886
|
inOrder(run) {
|
|
767
887
|
const result = this.applyQueue.then(run);
|
|
@@ -809,11 +929,13 @@ export class PageDO extends DurableObject {
|
|
|
809
929
|
return { seq, deliver };
|
|
810
930
|
}
|
|
811
931
|
/**
|
|
812
|
-
* @param prepare 検証の済んだデルタを、適用する前に書き換える(今あるボードに合わせた位置決めなど)。
|
|
932
|
+
* @param options.prepare 検証の済んだデルタを、適用する前に書き換える(今あるボードに合わせた位置決めなど)。
|
|
813
933
|
* 同じ操作の再送(適用済み)では、呼ばない
|
|
814
|
-
* @param matchColors 適用した後に、子ページの色と、その付箋の色をそろえる(matchChildColors
|
|
934
|
+
* @param options.matchColors 適用した後に、子ページの色と、その付箋の色をそろえる(matchChildColors)。既定は true
|
|
935
|
+
* @param options.authors 作成する付箋の作成者(付箋の id から)。applyDeltas を参照
|
|
815
936
|
*/
|
|
816
|
-
async applyOpsInOrder(input,
|
|
937
|
+
async applyOpsInOrder(input, options = {}) {
|
|
938
|
+
const { prepare, matchColors = true, authors } = options;
|
|
817
939
|
let deltas;
|
|
818
940
|
let sanitized;
|
|
819
941
|
try {
|
|
@@ -848,7 +970,7 @@ export class PageDO extends DurableObject {
|
|
|
848
970
|
try {
|
|
849
971
|
const result = this.ctx.storage.transactionSync(() => {
|
|
850
972
|
const now = Date.now();
|
|
851
|
-
const applied = applyDeltas(this.sql, deltas, input.actor, now, sanitized.cleanBefore);
|
|
973
|
+
const applied = applyDeltas(this.sql, deltas, input.actor, now, sanitized.cleanBefore, authors);
|
|
852
974
|
const fixups = [...sanitized.fixups, ...applied.fixups];
|
|
853
975
|
if (applied.deltas.length === 0) {
|
|
854
976
|
return { ok: true, seq: this.getSeq(), deltas: [], fixups, broadcast: false };
|
|
@@ -7,6 +7,7 @@ import { isValidSlug } from '../shared/slug';
|
|
|
7
7
|
import { createFirstPage } from './first-page';
|
|
8
8
|
import { REASON_PAGE_NOT_FOUND } from './page-do';
|
|
9
9
|
import { listPages, searchPages, searchRoots } from './page-queries';
|
|
10
|
+
import { transferNotes } from './transfer';
|
|
10
11
|
const REVERT_STATUS = {
|
|
11
12
|
'not-found': 404,
|
|
12
13
|
conflict: 409,
|
|
@@ -199,10 +200,50 @@ pagesApi.post('/pages/:slug/ops/:seq/revert', validSlug, async (c) => {
|
|
|
199
200
|
return c.json(result);
|
|
200
201
|
return c.json({ error: result.reason }, REVERT_STATUS[result.code]);
|
|
201
202
|
});
|
|
203
|
+
const TRANSFER_STATUS = {
|
|
204
|
+
'not-found': 404,
|
|
205
|
+
invalid: 400,
|
|
206
|
+
partial: 409,
|
|
207
|
+
};
|
|
208
|
+
/**
|
|
209
|
+
* このページの付箋を、他のページ(`to`)へ移す(写す)。本文は
|
|
210
|
+
* `{ clientId, opId, to, noteIds, positions?, copy? }`。付箋の中身はサーバーが読む。
|
|
211
|
+
* 移すときは、作成者を引き継ぎ、このページから消す。写すときは、作成者は写した人になり、このページに残す。
|
|
212
|
+
* `positions`(付箋の id から `{ x, y }`)は、このページでの位置の代わりに使う(ドラッグで動かした
|
|
213
|
+
* 付箋を、動かす前の位置で送る)。送り先では、今ある付箋の下に置く。同じ `opId` で送り直しても、
|
|
214
|
+
* 二重には置かない。送り先がなければ 404(作らない)
|
|
215
|
+
*/
|
|
216
|
+
pagesApi.post('/pages/:slug/transfer', validSlug, async (c) => {
|
|
217
|
+
const body = await c.req
|
|
218
|
+
.json()
|
|
219
|
+
.catch(() => null);
|
|
220
|
+
const to = body?.to;
|
|
221
|
+
// スラッグの形は transferNotes が確かめる
|
|
222
|
+
if (typeof to !== 'string')
|
|
223
|
+
return c.json({ error: 'invalid to' }, 400);
|
|
224
|
+
const result = await transferNotes(c.env, {
|
|
225
|
+
actor: c.get('actor'),
|
|
226
|
+
clientId: String(body?.clientId ?? ''),
|
|
227
|
+
opId: String(body?.opId ?? ''),
|
|
228
|
+
from: c.req.param('slug'),
|
|
229
|
+
to,
|
|
230
|
+
noteIds: body?.noteIds,
|
|
231
|
+
positions: body?.positions,
|
|
232
|
+
copy: body?.copy === true,
|
|
233
|
+
});
|
|
234
|
+
if (!result.ok)
|
|
235
|
+
return c.json({ error: result.reason }, TRANSFER_STATUS[result.code]);
|
|
236
|
+
// 置いた後の送り先の概要も返す(送った側が、索引への反映を待たずに、子ページの付箋を描き直せる)
|
|
237
|
+
const summary = await c.env.PAGE.getByName(to).getSummary();
|
|
238
|
+
return c.json({ ok: true, page: summary && { name: to, ...summary } });
|
|
239
|
+
});
|
|
202
240
|
/**
|
|
203
241
|
* 他のページから移す(写す)付箋を置く。本文は `{ clientId, opId, from, notes, edges }`
|
|
204
242
|
* (`from` は、付箋が元あったページ。同じ `opId` で送り直しても、二重には置かない)。
|
|
205
|
-
* 今ある付箋の下に置く。ページがなければ 404
|
|
243
|
+
* 今ある付箋の下に置く。ページがなければ 404(作らない)。
|
|
244
|
+
*
|
|
245
|
+
* 付箋の中身を送る側が渡すので、作成者は送った人になる。今の画面は `/transfer` を使う。これは、
|
|
246
|
+
* 更新の前に開いた画面のタブのために残している
|
|
206
247
|
*/
|
|
207
248
|
pagesApi.post('/pages/:slug/notes', validSlug, async (c) => {
|
|
208
249
|
const body = await c.req
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { WemaKakeEnv } from './config';
|
|
2
|
+
export interface TransferInput {
|
|
3
|
+
/** 変更の主体。呼び出し側が認証結果から決める */
|
|
4
|
+
actor: string;
|
|
5
|
+
clientId: string;
|
|
6
|
+
/** 同じ値で送り直しても、二重には置かない(消す操作も同じ値で記録する) */
|
|
7
|
+
opId: string;
|
|
8
|
+
from: string;
|
|
9
|
+
to: string;
|
|
10
|
+
/** 外から来た値をそのまま渡す。検証はここで行う */
|
|
11
|
+
noteIds: unknown;
|
|
12
|
+
/** 付箋の id から、元のページでの位置の代わりに使う `{ x, y }`。外から来た値 */
|
|
13
|
+
positions?: unknown;
|
|
14
|
+
/** 写す(元のページに残す。作成者は `actor`)。偽なら移す(作成者を引き継ぐ) */
|
|
15
|
+
copy: boolean;
|
|
16
|
+
/** 操作の履歴に出す説明。省略すると、相手のページを表す符号 */
|
|
17
|
+
summary?: string;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* - `not-found`: 送り先か元のページ、または付箋がない
|
|
21
|
+
* - `invalid`: 入力が不正か、置けなかった
|
|
22
|
+
* - `partial`: 送り先には置けたが、元のページから消せなかった付箋がある(消せなかった、または
|
|
23
|
+
* 読んだ後に元のページで変わったか消えたので、消さなかった)
|
|
24
|
+
*/
|
|
25
|
+
export type TransferFailure = 'not-found' | 'invalid' | 'partial';
|
|
26
|
+
export type TransferResult = {
|
|
27
|
+
ok: true;
|
|
28
|
+
/** 送り先に置いた付箋の id(`noteIds` と同じ順) */
|
|
29
|
+
noteIds: string[];
|
|
30
|
+
/** 送り先で記録した操作の番号 */
|
|
31
|
+
received: number;
|
|
32
|
+
/** 元のページで記録した操作の番号。写したとき、消す付箋がもうなかったときは null */
|
|
33
|
+
removed: number | null;
|
|
34
|
+
} | {
|
|
35
|
+
ok: false;
|
|
36
|
+
code: TransferFailure;
|
|
37
|
+
reason: string;
|
|
38
|
+
};
|
|
39
|
+
export declare function transferNotes(env: WemaKakeEnv, input: TransferInput): Promise<TransferResult>;
|