@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.
@@ -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
  /** 自分の操作を取り消す */
@@ -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
- }, undefined, false);
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) => placeBelow(deltas.flatMap((d) => (d.type === 'note:create' ? [d.note] : [])), this.sql
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, prepare, matchColors = true) {
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>;