@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.
@@ -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>;
@@ -0,0 +1,51 @@
1
+ import { isValidSlug } from '../shared/slug';
2
+ import { parseId, parseNoteIds, parsePositions, RejectError } from './validate';
3
+ /**
4
+ * 元のページから付箋を消す操作の clientId。送った画面の clientId を使うと、その画面は、自分の操作の
5
+ * 確定と見なして(覚えのない opId なので無視して)、消したことを手元に反映しない
6
+ */
7
+ const REMOVE_CLIENT_ID = 'transfer';
8
+ export async function transferNotes(env, input) {
9
+ const { actor, clientId, opId, from, to, copy, summary } = input;
10
+ let noteIds;
11
+ let positions;
12
+ try {
13
+ if (!isValidSlug(from) || !isValidSlug(to))
14
+ throw new RejectError('invalid page');
15
+ // 消す操作は別の clientId で記録するので、ここで確かめておく(送り先は、置く前に確かめる)
16
+ parseId(opId, 'opId');
17
+ noteIds = parseNoteIds(input.noteIds);
18
+ positions = parsePositions(input.positions);
19
+ }
20
+ catch (e) {
21
+ if (e instanceof RejectError)
22
+ return { ok: false, code: 'invalid', reason: e.message };
23
+ throw e;
24
+ }
25
+ const taken = await env.PAGE.getByName(to).takeNotes({
26
+ actor, clientId, opId, from, noteIds, positions, keepAuthors: !copy, summary,
27
+ });
28
+ if (!taken.ok)
29
+ return { ok: false, code: taken.notFound ? 'not-found' : 'invalid', reason: taken.reason };
30
+ if (copy)
31
+ return { ok: true, noteIds: taken.noteIds, received: taken.seq, removed: null };
32
+ const removed = await env.PAGE.getByName(from)
33
+ .removeNotes({ actor, clientId: REMOVE_CLIENT_ID, opId, noteIds, versions: taken.versions, to, summary })
34
+ .catch((e) => ({ ok: false, reason: e instanceof Error ? e.message : String(e) }));
35
+ if (!removed.ok) {
36
+ return {
37
+ ok: false,
38
+ code: 'partial',
39
+ reason: `placed the notes in ${to} (operation ${taken.seq}), but could not remove them from ${from}: ${removed.reason}`,
40
+ };
41
+ }
42
+ if (removed.kept.length > 0) {
43
+ return {
44
+ ok: false,
45
+ code: 'partial',
46
+ reason: `placed the notes in ${to} (operation ${taken.seq}), but left ${removed.kept.join(', ')} in ${from}` +
47
+ ` because they were changed or removed there while moving (the copies in ${to} are from before the change)`,
48
+ };
49
+ }
50
+ return { ok: true, noteIds: taken.noteIds, received: taken.seq, removed: removed.seq };
51
+ }
@@ -17,5 +17,12 @@ declare function id(v: unknown, what: string): string;
17
17
  export { id as parseId };
18
18
  /** 付箋の、id と zIndex 以外のフィールド */
19
19
  export type NoteField = Exclude<keyof WemaNote, 'id' | 'zIndex'>;
20
+ /** 他のページへ移す(写す)付箋の id の一覧。重複は 1 つにする */
21
+ export declare function parseNoteIds(input: unknown): string[];
22
+ /** 付箋の id から、位置(`{ x, y }`)。省略されていれば空 */
23
+ export declare function parsePositions(input: unknown): Map<string, {
24
+ x: number;
25
+ y: number;
26
+ }>;
20
27
  /** 外から来たデルタの形を検証し、既知のフィールドだけを持つデルタにして返す */
21
28
  export declare function parseDeltas(input: unknown): HistoryDelta[];
@@ -193,6 +193,23 @@ function parseDelta(v) {
193
193
  throw new RejectError('unknown delta type');
194
194
  }
195
195
  }
196
+ /** 他のページへ移す(写す)付箋の id の一覧。重複は 1 つにする */
197
+ export function parseNoteIds(input) {
198
+ if (!Array.isArray(input) || input.length === 0)
199
+ throw new RejectError('invalid noteIds');
200
+ if (input.length > MAX_DELTAS)
201
+ throw new RejectError(`too many notes (${input.length}, limit ${MAX_DELTAS})`);
202
+ return [...new Set(input.map((v) => id(v, 'note id')))];
203
+ }
204
+ /** 付箋の id から、位置(`{ x, y }`)。省略されていれば空 */
205
+ export function parsePositions(input) {
206
+ if (input === undefined || input === null)
207
+ return new Map();
208
+ return new Map(Object.entries(obj(input, 'positions')).map(([key, v]) => {
209
+ const p = obj(v, 'position');
210
+ return [id(key, 'note id'), { x: num(p.x, 'x'), y: num(p.y, 'y') }];
211
+ }));
212
+ }
196
213
  /** 外から来たデルタの形を検証し、既知のフィールドだけを持つデルタにして返す */
197
214
  export function parseDeltas(input) {
198
215
  if (!Array.isArray(input) || input.length === 0)