@kanf/wema-kake 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +15 -0
- package/README.md +123 -2
- package/bin/wema-kake.mjs +220 -0
- package/dist/lib/index.d.ts +13 -0
- package/dist/lib/index.js +14 -0
- package/dist/lib/shared/api.d.ts +97 -0
- package/dist/lib/shared/api.js +23 -0
- package/dist/lib/shared/delta.d.ts +24 -0
- package/dist/lib/shared/delta.js +19 -0
- package/dist/lib/shared/guide.d.ts +32 -0
- package/dist/lib/shared/guide.js +259 -0
- package/dist/lib/shared/hierarchy.d.ts +31 -0
- package/dist/lib/shared/hierarchy.js +40 -0
- package/dist/lib/shared/i18n.d.ts +23 -0
- package/dist/lib/shared/i18n.js +58 -0
- package/dist/lib/shared/note-html.d.ts +10 -0
- package/dist/lib/shared/note-html.js +28 -0
- package/dist/lib/shared/note-info.d.ts +20 -0
- package/dist/lib/shared/note-info.js +29 -0
- package/dist/lib/shared/note-text.d.ts +11 -0
- package/dist/lib/shared/note-text.js +118 -0
- package/dist/lib/shared/op-summary.d.ts +30 -0
- package/dist/lib/shared/op-summary.js +37 -0
- package/dist/lib/shared/protocol.d.ts +74 -0
- package/dist/lib/shared/protocol.js +12 -0
- package/dist/lib/shared/site.d.ts +16 -0
- package/dist/lib/shared/site.js +28 -0
- package/dist/lib/shared/slug.d.ts +4 -0
- package/dist/lib/shared/slug.js +6 -0
- package/dist/lib/shared/tools.d.ts +67 -0
- package/dist/lib/shared/tools.js +353 -0
- package/dist/lib/worker/access.d.ts +34 -0
- package/dist/lib/worker/access.js +114 -0
- package/dist/lib/worker/apply-ops.d.ts +75 -0
- package/dist/lib/worker/apply-ops.js +335 -0
- package/dist/lib/worker/assets.d.ts +4 -0
- package/dist/lib/worker/assets.js +35 -0
- package/dist/lib/worker/config.d.ts +52 -0
- package/dist/lib/worker/config.js +23 -0
- package/dist/lib/worker/first-page.d.ts +10 -0
- package/dist/lib/worker/first-page.js +30 -0
- package/dist/lib/worker/images.d.ts +3 -0
- package/dist/lib/worker/images.js +54 -0
- package/dist/lib/worker/index.d.ts +8 -0
- package/dist/lib/worker/index.js +72 -0
- package/dist/lib/worker/indexer.d.ts +42 -0
- package/dist/lib/worker/indexer.js +93 -0
- package/dist/lib/worker/mcp/board-access.d.ts +28 -0
- package/dist/lib/worker/mcp/board-access.js +67 -0
- package/dist/lib/worker/mcp/server.d.ts +9 -0
- package/dist/lib/worker/mcp/server.js +39 -0
- package/dist/lib/worker/page-do.d.ts +330 -0
- package/dist/lib/worker/page-do.js +1168 -0
- package/dist/lib/worker/page-queries.d.ts +28 -0
- package/dist/lib/worker/page-queries.js +88 -0
- package/dist/lib/worker/pages-api.d.ts +5 -0
- package/dist/lib/worker/pages-api.js +257 -0
- package/dist/lib/worker/plain-text.d.ts +22 -0
- package/dist/lib/worker/plain-text.js +113 -0
- package/dist/lib/worker/revert.d.ts +21 -0
- package/dist/lib/worker/revert.js +126 -0
- package/dist/lib/worker/sanitize.d.ts +4 -0
- package/dist/lib/worker/sanitize.js +135 -0
- package/dist/lib/worker/validate.d.ts +21 -0
- package/dist/lib/worker/validate.js +218 -0
- package/dist/web/assets/index-CVwMafiH.js +5 -0
- package/dist/web/assets/index-Cgi5orcJ.css +1 -0
- package/dist/web/favicon.svg +13 -0
- package/dist/web/index.html +14 -0
- package/migrations/0001_init.sql +18 -0
- package/migrations/0002_fts.sql +9 -0
- package/migrations/0003_hierarchy.sql +9 -0
- package/migrations/0004_settings.sql +12 -0
- package/migrations/0005_bookmarks.sql +8 -0
- package/migrations/0006_page_color.sql +5 -0
- package/package.json +75 -4
- package/template/.dev.vars.example +4 -0
- package/template/README.md +55 -0
- package/template/gitignore +8 -0
- package/template/package.json +25 -0
- package/template/src/config.ts +18 -0
- package/template/src/index.ts +9 -0
- package/template/tsconfig.json +16 -0
- package/template/wrangler.jsonc +57 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// 付箋の text(HTML)のサニタイズ。許可リストは wema の src/utils/sanitize.ts に合わせている。
|
|
2
|
+
// wema は DOMParser で木を作ってから処理するが、HTMLRewriter は字句を順に流すだけなので、
|
|
3
|
+
// 次の 2 点を wema より厳しくしている。
|
|
4
|
+
// - 中身が生のテキストとして扱われる要素や SVG / MathML は、タグを外すと中身がマークアップとして
|
|
5
|
+
// 解釈されるので、中身ごと除去する
|
|
6
|
+
// - 出力が変わらなくなるまで繰り返し、タグを外した結果できた不許可の要素を残さない
|
|
7
|
+
const ALLOWED_TAGS = new Set([
|
|
8
|
+
'b', 'strong', 'i', 'em', 'u', 's', 'br', 'span',
|
|
9
|
+
'ul', 'ol', 'li', 'a', 'input', 'img', 'iframe', 'video', 'audio', 'div', 'p',
|
|
10
|
+
// 文字のツールバーの引用ボタンが作る付箋(wema 0.12.0)
|
|
11
|
+
'blockquote',
|
|
12
|
+
]);
|
|
13
|
+
/** class と style はどのタグでも許可する */
|
|
14
|
+
const TAG_ATTRIBUTES = {
|
|
15
|
+
a: new Set(['href', 'target', 'rel']),
|
|
16
|
+
input: new Set(['type', 'checked']),
|
|
17
|
+
img: new Set(['src', 'alt', 'width', 'height']),
|
|
18
|
+
iframe: new Set(['src', 'width', 'height', 'sandbox', 'allow', 'allowfullscreen', 'frameborder', 'title']),
|
|
19
|
+
video: new Set(['src', 'controls', 'preload', 'width', 'height', 'poster']),
|
|
20
|
+
audio: new Set(['src', 'controls', 'preload']),
|
|
21
|
+
};
|
|
22
|
+
const URL_ATTRIBUTES = new Set(['href', 'src', 'poster']);
|
|
23
|
+
/** 中身ごと除去するタグ */
|
|
24
|
+
const REMOVE_TAGS = new Set([
|
|
25
|
+
'script', 'style', 'noscript', 'object', 'embed', 'applet',
|
|
26
|
+
'textarea', 'title', 'xmp', 'plaintext', 'noembed', 'noframes', 'svg', 'math',
|
|
27
|
+
]);
|
|
28
|
+
const ALLOWED_CSS_PROPS = new Set([
|
|
29
|
+
'color', 'background-color', 'font-size', 'font-weight', 'font-style',
|
|
30
|
+
'text-decoration', 'text-align', 'margin', 'padding', 'display',
|
|
31
|
+
'list-style-type', 'white-space',
|
|
32
|
+
]);
|
|
33
|
+
/** wema の SAFE_URL_SCHEMES と同じ */
|
|
34
|
+
const ALLOWED_SCHEMES = new Set(['http', 'https', 'mailto', 'tel']);
|
|
35
|
+
const MAX_PASSES = 4;
|
|
36
|
+
export class SanitizeError extends Error {
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* スキームを許可リストで判定する。HTMLRewriter は属性値のエンティティを復号しないので、
|
|
40
|
+
* スキームの位置にエンティティがある値(`javascript:` など)は拒否する。
|
|
41
|
+
*
|
|
42
|
+
* wema は画像などの `data:` URL を許可するが、ここでは受け付けない。画像は R2 に置き、
|
|
43
|
+
* text には URL だけを入れる(data URL は text の大きさの上限にすぐ達するため)。
|
|
44
|
+
*/
|
|
45
|
+
function isSafeUrl(value) {
|
|
46
|
+
// ブラウザは URL 中の空白と制御文字を無視してスキームを解釈する
|
|
47
|
+
const compact = value.replace(/[\u0000- ]/g, '');
|
|
48
|
+
const head = compact.split(/[/?#]/, 1)[0];
|
|
49
|
+
if (head.includes('&'))
|
|
50
|
+
return false;
|
|
51
|
+
const colon = head.indexOf(':');
|
|
52
|
+
if (colon === -1)
|
|
53
|
+
return true;
|
|
54
|
+
return ALLOWED_SCHEMES.has(head.slice(0, colon).toLowerCase());
|
|
55
|
+
}
|
|
56
|
+
/** 許可した宣言だけを残す。何も除かなかったときは元の文字列をそのまま返す */
|
|
57
|
+
function sanitizeStyle(value) {
|
|
58
|
+
if (/[&\\]/.test(value))
|
|
59
|
+
return '';
|
|
60
|
+
const declarations = value.split(';').filter((d) => d.trim() !== '');
|
|
61
|
+
const kept = declarations.filter((d) => {
|
|
62
|
+
const colon = d.indexOf(':');
|
|
63
|
+
if (colon === -1)
|
|
64
|
+
return false;
|
|
65
|
+
const prop = d.slice(0, colon).trim().toLowerCase();
|
|
66
|
+
const val = d.slice(colon + 1);
|
|
67
|
+
return ALLOWED_CSS_PROPS.has(prop) && !/url\(|expression\(|@import|[<>]/i.test(val);
|
|
68
|
+
});
|
|
69
|
+
if (kept.length === declarations.length)
|
|
70
|
+
return value;
|
|
71
|
+
return kept.map((d) => d.trim()).join('; ');
|
|
72
|
+
}
|
|
73
|
+
function sanitizeElement(el) {
|
|
74
|
+
const tag = el.tagName.toLowerCase();
|
|
75
|
+
if (REMOVE_TAGS.has(tag)) {
|
|
76
|
+
el.remove();
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (!ALLOWED_TAGS.has(tag)) {
|
|
80
|
+
el.removeAndKeepContent();
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (tag === 'input' && el.getAttribute('type')?.toLowerCase() !== 'checkbox') {
|
|
84
|
+
el.remove();
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const allowed = TAG_ATTRIBUTES[tag];
|
|
88
|
+
for (const [rawName, value] of [...el.attributes]) {
|
|
89
|
+
const name = rawName.toLowerCase();
|
|
90
|
+
if (name === 'class')
|
|
91
|
+
continue;
|
|
92
|
+
if (name === 'style') {
|
|
93
|
+
const style = sanitizeStyle(value);
|
|
94
|
+
if (style === '')
|
|
95
|
+
el.removeAttribute(rawName);
|
|
96
|
+
else if (style !== value)
|
|
97
|
+
el.setAttribute(rawName, style);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (!allowed?.has(name)) {
|
|
101
|
+
el.removeAttribute(rawName);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (URL_ATTRIBUTES.has(name) && !isSafeUrl(value)) {
|
|
105
|
+
el.removeAttribute(rawName);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
// iframe の中身は生のテキストで、表示には使われない
|
|
109
|
+
if (tag === 'iframe')
|
|
110
|
+
el.setInnerContent('');
|
|
111
|
+
}
|
|
112
|
+
function sanitizeOnce(html) {
|
|
113
|
+
return new HTMLRewriter()
|
|
114
|
+
.on('*', { element: sanitizeElement })
|
|
115
|
+
.onDocument({
|
|
116
|
+
comments(comment) {
|
|
117
|
+
comment.remove();
|
|
118
|
+
},
|
|
119
|
+
})
|
|
120
|
+
.transform(new Response(html))
|
|
121
|
+
.text();
|
|
122
|
+
}
|
|
123
|
+
/** 許可していないタグ、属性、URL スキーム、style の宣言を除いた HTML を返す */
|
|
124
|
+
export async function sanitizeHtml(html) {
|
|
125
|
+
if (!html.includes('<'))
|
|
126
|
+
return html;
|
|
127
|
+
let current = html;
|
|
128
|
+
for (let i = 0; i < MAX_PASSES; i++) {
|
|
129
|
+
const next = await sanitizeOnce(current);
|
|
130
|
+
if (next === current)
|
|
131
|
+
return next;
|
|
132
|
+
current = next;
|
|
133
|
+
}
|
|
134
|
+
throw new SanitizeError('text could not be sanitized');
|
|
135
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { BoardContent, HistoryDelta, WemaNote } from '../shared/delta';
|
|
2
|
+
/** ops の 1 行(body と fixups の合計)の上限。DO の SQLite は 1 行 2MB まで */
|
|
3
|
+
export declare const MAX_OP_BYTES = 1800000;
|
|
4
|
+
/** 変更を受け付けられないときに投げる。`current` は送信元が手元の状態を直すためのもの */
|
|
5
|
+
export declare class RejectError extends Error {
|
|
6
|
+
readonly current?: BoardContent | undefined;
|
|
7
|
+
constructor(reason: string, current?: BoardContent | undefined);
|
|
8
|
+
}
|
|
9
|
+
export type Obj = Record<string, unknown>;
|
|
10
|
+
/** 省略できない接続線のフィールド。未設定に戻す指定は無視する */
|
|
11
|
+
export declare const REQUIRED_EDGE_FIELDS: string[];
|
|
12
|
+
/** 件数の指定を整数にして範囲に収める。数値でなければ `fallback` を使う */
|
|
13
|
+
export declare function clampLimit(value: unknown, fallback: number, max: number): number;
|
|
14
|
+
/** `s` の UTF-8 でのバイト数が `max` を超えるか。長さだけで決まるときはエンコードしない */
|
|
15
|
+
export declare function exceedsBytes(s: string, max: number): boolean;
|
|
16
|
+
declare function id(v: unknown, what: string): string;
|
|
17
|
+
export { id as parseId };
|
|
18
|
+
/** 付箋の、id と zIndex 以外のフィールド */
|
|
19
|
+
export type NoteField = Exclude<keyof WemaNote, 'id' | 'zIndex'>;
|
|
20
|
+
/** 外から来たデルタの形を検証し、既知のフィールドだけを持つデルタにして返す */
|
|
21
|
+
export declare function parseDeltas(input: unknown): HistoryDelta[];
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { CHILD_PAGE_KEY } from '../shared/hierarchy';
|
|
2
|
+
import { isValidSlug } from '../shared/slug';
|
|
3
|
+
const MAX_DELTAS = 2000;
|
|
4
|
+
/**
|
|
5
|
+
* text 1 つの上限(JSON 文字列にしたときのバイト数)。text の更新 1 回で ops の行に
|
|
6
|
+
* before / after / fixup の 3 つが入っても MAX_OP_BYTES に収まる大きさにしている
|
|
7
|
+
*/
|
|
8
|
+
const MAX_TEXT_BYTES = 500_000;
|
|
9
|
+
/** ops の 1 行(body と fixups の合計)の上限。DO の SQLite は 1 行 2MB まで */
|
|
10
|
+
export const MAX_OP_BYTES = 1_800_000;
|
|
11
|
+
/** 変更を受け付けられないときに投げる。`current` は送信元が手元の状態を直すためのもの */
|
|
12
|
+
export class RejectError extends Error {
|
|
13
|
+
current;
|
|
14
|
+
constructor(reason, current) {
|
|
15
|
+
super(reason);
|
|
16
|
+
this.current = current;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const ID_RE = /^[\w-]{1,64}$/;
|
|
20
|
+
const COLOR_RE = /^[#\w(),.%\s-]{1,64}$/;
|
|
21
|
+
const ANCHORS = ['top', 'right', 'bottom', 'left', 'auto'];
|
|
22
|
+
const EDGE_STYLES = ['arrow', 'line', 'dashed'];
|
|
23
|
+
const LINE_STYLES = ['solid', 'dashed', 'dotted'];
|
|
24
|
+
const ARROW_HEADS = ['none', 'start', 'end', 'both'];
|
|
25
|
+
const ROUTINGS = ['curve', 'polyline'];
|
|
26
|
+
/** 省略できない接続線のフィールド。未設定に戻す指定は無視する */
|
|
27
|
+
export const REQUIRED_EDGE_FIELDS = ['fromAnchor', 'toAnchor', 'style'];
|
|
28
|
+
/** 件数の指定を整数にして範囲に収める。数値でなければ `fallback` を使う */
|
|
29
|
+
export function clampLimit(value, fallback, max) {
|
|
30
|
+
return Math.min(Math.max(Math.trunc(Number(value)) || fallback, 1), max);
|
|
31
|
+
}
|
|
32
|
+
const encoder = new TextEncoder();
|
|
33
|
+
/** `s` の UTF-8 でのバイト数が `max` を超えるか。長さだけで決まるときはエンコードしない */
|
|
34
|
+
export function exceedsBytes(s, max) {
|
|
35
|
+
if (s.length > max)
|
|
36
|
+
return true;
|
|
37
|
+
if (s.length * 3 <= max)
|
|
38
|
+
return false;
|
|
39
|
+
return encoder.encode(s).length > max;
|
|
40
|
+
}
|
|
41
|
+
function obj(v, what) {
|
|
42
|
+
if (typeof v !== 'object' || v === null || Array.isArray(v)) {
|
|
43
|
+
throw new RejectError(`${what} must be an object`);
|
|
44
|
+
}
|
|
45
|
+
return v;
|
|
46
|
+
}
|
|
47
|
+
function id(v, what) {
|
|
48
|
+
if (typeof v !== 'string' || !ID_RE.test(v))
|
|
49
|
+
throw new RejectError(`invalid ${what}`);
|
|
50
|
+
return v;
|
|
51
|
+
}
|
|
52
|
+
export { id as parseId };
|
|
53
|
+
function num(v, what) {
|
|
54
|
+
if (typeof v !== 'number' || !Number.isFinite(v))
|
|
55
|
+
throw new RejectError(`invalid ${what}`);
|
|
56
|
+
return v;
|
|
57
|
+
}
|
|
58
|
+
function bool(v, what) {
|
|
59
|
+
if (typeof v !== 'boolean')
|
|
60
|
+
throw new RejectError(`invalid ${what}`);
|
|
61
|
+
return v;
|
|
62
|
+
}
|
|
63
|
+
function oneOf(v, values, what) {
|
|
64
|
+
if (typeof v !== 'string' || !values.includes(v))
|
|
65
|
+
throw new RejectError(`invalid ${what}`);
|
|
66
|
+
return v;
|
|
67
|
+
}
|
|
68
|
+
function text(v) {
|
|
69
|
+
if (typeof v !== 'string')
|
|
70
|
+
throw new RejectError('invalid text');
|
|
71
|
+
// JSON にすると 1 文字が最大 6 バイト(\uXXXX)になる。明らかに収まるときは計算を省く
|
|
72
|
+
if (v.length * 6 + 2 > MAX_TEXT_BYTES && exceedsBytes(JSON.stringify(v), MAX_TEXT_BYTES)) {
|
|
73
|
+
throw new RejectError('text too large');
|
|
74
|
+
}
|
|
75
|
+
return v;
|
|
76
|
+
}
|
|
77
|
+
function color(v) {
|
|
78
|
+
if (typeof v !== 'string' || !COLOR_RE.test(v))
|
|
79
|
+
throw new RejectError('invalid color');
|
|
80
|
+
return v;
|
|
81
|
+
}
|
|
82
|
+
function label(v) {
|
|
83
|
+
if (typeof v !== 'string' || v.length > 500)
|
|
84
|
+
throw new RejectError('invalid label');
|
|
85
|
+
return v;
|
|
86
|
+
}
|
|
87
|
+
const META_KEY_RE = /^[\w-]{1,32}$/;
|
|
88
|
+
const MAX_META_KEYS = 8;
|
|
89
|
+
const MAX_META_VALUE_LENGTH = 256;
|
|
90
|
+
/**
|
|
91
|
+
* 付箋の meta(利用側のデータ。wema は中身を解釈しない)。値は文字列だけ。
|
|
92
|
+
* wema は大きさと形を制限しないので、ここで制限する。キーは並べ替えて返す(保存した値を、
|
|
93
|
+
* 文字列にして比べられるようにするため)。
|
|
94
|
+
*
|
|
95
|
+
* `page` は、子ページの付箋が指す子ページのスラッグ(src/shared/hierarchy.ts)
|
|
96
|
+
*/
|
|
97
|
+
function meta(v) {
|
|
98
|
+
const entries = Object.entries(obj(v, 'meta')).sort(([a], [b]) => (a < b ? -1 : 1));
|
|
99
|
+
if (entries.length > MAX_META_KEYS)
|
|
100
|
+
throw new RejectError('invalid meta');
|
|
101
|
+
for (const [key, value] of entries) {
|
|
102
|
+
if (!META_KEY_RE.test(key) || typeof value !== 'string' || value.length > MAX_META_VALUE_LENGTH) {
|
|
103
|
+
throw new RejectError('invalid meta');
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const out = Object.fromEntries(entries);
|
|
107
|
+
if (CHILD_PAGE_KEY in out && !isValidSlug(out[CHILD_PAGE_KEY]))
|
|
108
|
+
throw new RejectError('invalid meta');
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* 付箋の、id と zIndex 以外のフィールド。zIndex は同期しないので更新では捨てる。
|
|
113
|
+
* **wema が足したフィールドは、ここに足すまで型エラーになる**(`pick` は知らないフィールドを黙って
|
|
114
|
+
* 捨てるので、足し忘れると、そのフィールドだけが保存されない)
|
|
115
|
+
*/
|
|
116
|
+
const NOTE_FIELDS = {
|
|
117
|
+
x: (v) => num(v, 'x'),
|
|
118
|
+
y: (v) => num(v, 'y'),
|
|
119
|
+
width: (v) => num(v, 'width'),
|
|
120
|
+
height: (v) => num(v, 'height'),
|
|
121
|
+
text,
|
|
122
|
+
color,
|
|
123
|
+
autoSize: (v) => bool(v, 'autoSize'),
|
|
124
|
+
foldable: (v) => bool(v, 'foldable'),
|
|
125
|
+
meta,
|
|
126
|
+
};
|
|
127
|
+
/** 接続線の、id / from / to 以外のフィールド。from / to は変更できないので更新では捨てる */
|
|
128
|
+
const EDGE_FIELDS = {
|
|
129
|
+
fromAnchor: (v) => oneOf(v, ANCHORS, 'fromAnchor'),
|
|
130
|
+
toAnchor: (v) => oneOf(v, ANCHORS, 'toAnchor'),
|
|
131
|
+
style: (v) => oneOf(v, EDGE_STYLES, 'style'),
|
|
132
|
+
label,
|
|
133
|
+
lineStyle: (v) => oneOf(v, LINE_STYLES, 'lineStyle'),
|
|
134
|
+
strokeWidth: (v) => num(v, 'strokeWidth'),
|
|
135
|
+
arrowHead: (v) => oneOf(v, ARROW_HEADS, 'arrowHead'),
|
|
136
|
+
arrowSize: (v) => num(v, 'arrowSize'),
|
|
137
|
+
routing: (v) => oneOf(v, ROUTINGS, 'routing'),
|
|
138
|
+
collapsed: (v) => bool(v, 'collapsed'),
|
|
139
|
+
};
|
|
140
|
+
/** 既知のフィールドだけを検証して取り出す。値が null / undefined のキーは無いものとして扱う */
|
|
141
|
+
function pick(source, fields) {
|
|
142
|
+
const out = {};
|
|
143
|
+
for (const [key, parse] of Object.entries(fields)) {
|
|
144
|
+
if (source[key] != null)
|
|
145
|
+
out[key] = parse(source[key]);
|
|
146
|
+
}
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
function requireKeys(o, keys, what) {
|
|
150
|
+
for (const key of keys) {
|
|
151
|
+
if (!(key in o))
|
|
152
|
+
throw new RejectError(`${what}.${key} is required`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
function parseNote(v) {
|
|
156
|
+
const o = obj(v, 'note');
|
|
157
|
+
const fields = pick(o, NOTE_FIELDS);
|
|
158
|
+
requireKeys(fields, ['x', 'y', 'width', 'height', 'text', 'color'], 'note');
|
|
159
|
+
const zIndex = o.zIndex == null ? 0 : num(o.zIndex, 'zIndex');
|
|
160
|
+
return { ...fields, id: id(o.id, 'note id'), zIndex: Math.trunc(zIndex) };
|
|
161
|
+
}
|
|
162
|
+
function parseEdge(v) {
|
|
163
|
+
const o = obj(v, 'edge');
|
|
164
|
+
const fields = pick(o, EDGE_FIELDS);
|
|
165
|
+
requireKeys(fields, REQUIRED_EDGE_FIELDS, 'edge');
|
|
166
|
+
return {
|
|
167
|
+
...fields,
|
|
168
|
+
id: id(o.id, 'edge id'),
|
|
169
|
+
from: id(o.from, 'edge from'),
|
|
170
|
+
to: id(o.to, 'edge to'),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
function parseUpdate(d, fields) {
|
|
174
|
+
return {
|
|
175
|
+
before: pick(obj(d.before ?? {}, 'before'), fields),
|
|
176
|
+
after: pick(obj(d.after, 'after'), fields),
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
function parseDelta(v) {
|
|
180
|
+
const d = obj(v, 'delta');
|
|
181
|
+
switch (d.type) {
|
|
182
|
+
case 'note:create':
|
|
183
|
+
case 'note:delete':
|
|
184
|
+
return { type: d.type, note: parseNote(d.note) };
|
|
185
|
+
case 'note:update':
|
|
186
|
+
return { type: d.type, noteId: id(d.noteId, 'noteId'), ...parseUpdate(d, NOTE_FIELDS) };
|
|
187
|
+
case 'edge:create':
|
|
188
|
+
case 'edge:delete':
|
|
189
|
+
return { type: d.type, edge: parseEdge(d.edge) };
|
|
190
|
+
case 'edge:update':
|
|
191
|
+
return { type: d.type, edgeId: id(d.edgeId, 'edgeId'), ...parseUpdate(d, EDGE_FIELDS) };
|
|
192
|
+
default:
|
|
193
|
+
throw new RejectError('unknown delta type');
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
/** 外から来たデルタの形を検証し、既知のフィールドだけを持つデルタにして返す */
|
|
197
|
+
export function parseDeltas(input) {
|
|
198
|
+
if (!Array.isArray(input) || input.length === 0)
|
|
199
|
+
throw new RejectError('invalid deltas');
|
|
200
|
+
if (input.length > MAX_DELTAS) {
|
|
201
|
+
throw new RejectError(`too many deltas (${input.length}, limit ${MAX_DELTAS})`);
|
|
202
|
+
}
|
|
203
|
+
// サニタイズは text の量に比例して時間がかかる。記録できない大きさの入力は、
|
|
204
|
+
// サニタイズに回す前にここで断る(記録時の MAX_OP_BYTES の検査より手前で止める)
|
|
205
|
+
let textLength = 0;
|
|
206
|
+
return input.map((v) => {
|
|
207
|
+
const d = parseDelta(v);
|
|
208
|
+
if (d.type === 'note:create') {
|
|
209
|
+
textLength += d.note.text.length;
|
|
210
|
+
}
|
|
211
|
+
else if (d.type === 'note:update') {
|
|
212
|
+
textLength += (d.before.text?.length ?? 0) + (d.after.text?.length ?? 0);
|
|
213
|
+
}
|
|
214
|
+
if (textLength > MAX_OP_BYTES)
|
|
215
|
+
throw new RejectError('operation too large');
|
|
216
|
+
return d;
|
|
217
|
+
});
|
|
218
|
+
}
|