@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.
Files changed (84) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +123 -2
  3. package/bin/wema-kake.mjs +220 -0
  4. package/dist/lib/index.d.ts +13 -0
  5. package/dist/lib/index.js +14 -0
  6. package/dist/lib/shared/api.d.ts +97 -0
  7. package/dist/lib/shared/api.js +23 -0
  8. package/dist/lib/shared/delta.d.ts +24 -0
  9. package/dist/lib/shared/delta.js +19 -0
  10. package/dist/lib/shared/guide.d.ts +32 -0
  11. package/dist/lib/shared/guide.js +259 -0
  12. package/dist/lib/shared/hierarchy.d.ts +31 -0
  13. package/dist/lib/shared/hierarchy.js +40 -0
  14. package/dist/lib/shared/i18n.d.ts +23 -0
  15. package/dist/lib/shared/i18n.js +58 -0
  16. package/dist/lib/shared/note-html.d.ts +10 -0
  17. package/dist/lib/shared/note-html.js +28 -0
  18. package/dist/lib/shared/note-info.d.ts +20 -0
  19. package/dist/lib/shared/note-info.js +29 -0
  20. package/dist/lib/shared/note-text.d.ts +11 -0
  21. package/dist/lib/shared/note-text.js +118 -0
  22. package/dist/lib/shared/op-summary.d.ts +30 -0
  23. package/dist/lib/shared/op-summary.js +37 -0
  24. package/dist/lib/shared/protocol.d.ts +74 -0
  25. package/dist/lib/shared/protocol.js +12 -0
  26. package/dist/lib/shared/site.d.ts +16 -0
  27. package/dist/lib/shared/site.js +28 -0
  28. package/dist/lib/shared/slug.d.ts +4 -0
  29. package/dist/lib/shared/slug.js +6 -0
  30. package/dist/lib/shared/tools.d.ts +67 -0
  31. package/dist/lib/shared/tools.js +353 -0
  32. package/dist/lib/worker/access.d.ts +34 -0
  33. package/dist/lib/worker/access.js +114 -0
  34. package/dist/lib/worker/apply-ops.d.ts +75 -0
  35. package/dist/lib/worker/apply-ops.js +335 -0
  36. package/dist/lib/worker/assets.d.ts +4 -0
  37. package/dist/lib/worker/assets.js +35 -0
  38. package/dist/lib/worker/config.d.ts +52 -0
  39. package/dist/lib/worker/config.js +23 -0
  40. package/dist/lib/worker/first-page.d.ts +10 -0
  41. package/dist/lib/worker/first-page.js +30 -0
  42. package/dist/lib/worker/images.d.ts +3 -0
  43. package/dist/lib/worker/images.js +54 -0
  44. package/dist/lib/worker/index.d.ts +8 -0
  45. package/dist/lib/worker/index.js +72 -0
  46. package/dist/lib/worker/indexer.d.ts +42 -0
  47. package/dist/lib/worker/indexer.js +93 -0
  48. package/dist/lib/worker/mcp/board-access.d.ts +28 -0
  49. package/dist/lib/worker/mcp/board-access.js +67 -0
  50. package/dist/lib/worker/mcp/server.d.ts +9 -0
  51. package/dist/lib/worker/mcp/server.js +39 -0
  52. package/dist/lib/worker/page-do.d.ts +330 -0
  53. package/dist/lib/worker/page-do.js +1168 -0
  54. package/dist/lib/worker/page-queries.d.ts +28 -0
  55. package/dist/lib/worker/page-queries.js +88 -0
  56. package/dist/lib/worker/pages-api.d.ts +5 -0
  57. package/dist/lib/worker/pages-api.js +257 -0
  58. package/dist/lib/worker/plain-text.d.ts +22 -0
  59. package/dist/lib/worker/plain-text.js +113 -0
  60. package/dist/lib/worker/revert.d.ts +21 -0
  61. package/dist/lib/worker/revert.js +126 -0
  62. package/dist/lib/worker/sanitize.d.ts +4 -0
  63. package/dist/lib/worker/sanitize.js +135 -0
  64. package/dist/lib/worker/validate.d.ts +21 -0
  65. package/dist/lib/worker/validate.js +218 -0
  66. package/dist/web/assets/index-CVwMafiH.js +5 -0
  67. package/dist/web/assets/index-Cgi5orcJ.css +1 -0
  68. package/dist/web/favicon.svg +13 -0
  69. package/dist/web/index.html +14 -0
  70. package/migrations/0001_init.sql +18 -0
  71. package/migrations/0002_fts.sql +9 -0
  72. package/migrations/0003_hierarchy.sql +9 -0
  73. package/migrations/0004_settings.sql +12 -0
  74. package/migrations/0005_bookmarks.sql +8 -0
  75. package/migrations/0006_page_color.sql +5 -0
  76. package/package.json +75 -4
  77. package/template/.dev.vars.example +4 -0
  78. package/template/README.md +55 -0
  79. package/template/gitignore +8 -0
  80. package/template/package.json +25 -0
  81. package/template/src/config.ts +18 -0
  82. package/template/src/index.ts +9 -0
  83. package/template/tsconfig.json +16 -0
  84. package/template/wrangler.jsonc +57 -0
@@ -0,0 +1,28 @@
1
+ import { type RootHit } from '../shared/api';
2
+ import type { PageInfo } from '../shared/tools';
3
+ /**
4
+ * ページ一覧(更新の新しい順)。`updatedAfter`(ミリ秒)より後に更新されたものに絞れる。
5
+ * `name` を渡すと、表示名かスラッグにその文字を含むページに絞る(本文は見ない。ページへ移るための
6
+ * 絞り込み用)。長すぎる分は、先頭だけを使う
7
+ */
8
+ export declare function listPages(db: D1Database, options: {
9
+ updatedAfter?: unknown;
10
+ limit?: unknown;
11
+ name?: unknown;
12
+ }): Promise<PageInfo[]>;
13
+ export interface SearchHit {
14
+ name: string;
15
+ title: string | null;
16
+ updated_at: number;
17
+ /** 親ページのスラッグ。ルートのページなら null */
18
+ parent: string | null;
19
+ snippet?: string;
20
+ }
21
+ /** 全文検索(表示名と本文)。検索語が空か長すぎるときは null を返す */
22
+ export declare function searchPages(db: D1Database, query: string): Promise<SearchHit[] | null>;
23
+ /**
24
+ * 一覧の絞り込み用の検索。一致したページ(表示名、本文、スラッグ)と、そのルートを返す。
25
+ * 一覧にはルートのページしか出ないので、子孫のページが一致したときは、ルートを残して知らせる。
26
+ * 入力のたびに呼ばれるので、抜粋の生成と関連度での並び替えはしない。
27
+ */
28
+ export declare function searchRoots(db: D1Database, query: string): Promise<RootHit[] | null>;
@@ -0,0 +1,88 @@
1
+ // D1 の索引に対する、ページの一覧と検索のクエリ。HTTP の API と MCP のツールの両方が使う。
2
+ import { MAX_LIST_LIMIT, MAX_QUERY_LENGTH } from '../shared/api';
3
+ import { MAX_DEPTH } from '../shared/hierarchy';
4
+ import { clampLimit } from './validate';
5
+ const DEFAULT_LIST_LIMIT = 100;
6
+ /** 一覧の絞り込みに使うので、一覧の上限とそろえる */
7
+ const SEARCH_LIMIT = MAX_LIST_LIMIT;
8
+ /** trigram の FTS が一致を返せる最短の検索語(文字数) */
9
+ const MIN_FTS_QUERY_LENGTH = 3;
10
+ /**
11
+ * ページ一覧(更新の新しい順)。`updatedAfter`(ミリ秒)より後に更新されたものに絞れる。
12
+ * `name` を渡すと、表示名かスラッグにその文字を含むページに絞る(本文は見ない。ページへ移るための
13
+ * 絞り込み用)。長すぎる分は、先頭だけを使う
14
+ */
15
+ export async function listPages(db, options) {
16
+ const name = typeof options.name === 'string' ? options.name.trim().slice(0, MAX_QUERY_LENGTH) : '';
17
+ // LIKE は使わない(D1 は、LIKE のパターンを 50 バイトまでに限っていて、日本語の 16 文字で超える)。
18
+ // lower() は、LIKE と同じく、英字だけを小文字にする
19
+ const { results } = await db
20
+ .prepare(`SELECT name, title, note_count, updated_at, parent FROM pages
21
+ WHERE updated_at > ?1
22
+ AND (?3 = '' OR instr(lower(name), lower(?3)) > 0 OR instr(lower(title), lower(?3)) > 0)
23
+ ORDER BY updated_at DESC LIMIT ?2`)
24
+ .bind(Number(options.updatedAfter) || 0, clampLimit(options.limit, DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT), name)
25
+ .all();
26
+ return results;
27
+ }
28
+ /** 検索語を整える。空か長すぎるときは null */
29
+ function normalizeQuery(query) {
30
+ const q = query.trim();
31
+ return q === '' || q.length > MAX_QUERY_LENGTH ? null : q;
32
+ }
33
+ /** 3 文字未満の検索語は trigram に一致しないので、LIKE で探す */
34
+ const usesFts = (q) => [...q].length >= MIN_FTS_QUERY_LENGTH;
35
+ /** 検索語全体を 1 つの語句として扱う(FTS5 の演算子として解釈させない) */
36
+ const ftsPhrase = (q) => `"${q.replaceAll('"', '""')}"`;
37
+ const likePattern = (q) => `%${q.replace(/[\\%_]/g, '\\$&')}%`;
38
+ /** 全文検索(表示名と本文)。検索語が空か長すぎるときは null を返す */
39
+ export async function searchPages(db, query) {
40
+ const q = normalizeQuery(query);
41
+ if (q === null)
42
+ return null;
43
+ if (usesFts(q)) {
44
+ const { results } = await db
45
+ .prepare(`SELECT p.name, p.title, p.updated_at, p.parent, snippet(pages_fts, 1, '', '', '…', 24) AS snippet
46
+ FROM pages_fts JOIN pages p ON p.id = pages_fts.rowid
47
+ WHERE pages_fts MATCH ? ORDER BY rank LIMIT ?`)
48
+ .bind(ftsPhrase(q), SEARCH_LIMIT)
49
+ .all();
50
+ return results;
51
+ }
52
+ const { results } = await db
53
+ .prepare(`SELECT name, title, updated_at, parent FROM pages
54
+ WHERE title LIKE ?1 ESCAPE '\\' OR plain_text LIKE ?1 ESCAPE '\\'
55
+ ORDER BY updated_at DESC LIMIT ?2`)
56
+ .bind(likePattern(q), SEARCH_LIMIT)
57
+ .all();
58
+ return results;
59
+ }
60
+ /**
61
+ * 一覧の絞り込み用の検索。一致したページ(表示名、本文、スラッグ)と、そのルートを返す。
62
+ * 一覧にはルートのページしか出ないので、子孫のページが一致したときは、ルートを残して知らせる。
63
+ * 入力のたびに呼ばれるので、抜粋の生成と関連度での並び替えはしない。
64
+ */
65
+ export async function searchRoots(db, query) {
66
+ const q = normalizeQuery(query);
67
+ if (q === null)
68
+ return null;
69
+ const pattern = likePattern(q);
70
+ const matched = usesFts(q)
71
+ ? `SELECT p.name FROM pages_fts JOIN pages p ON p.id = pages_fts.rowid WHERE pages_fts MATCH ?1
72
+ UNION SELECT name FROM pages WHERE name LIKE ?2 ESCAPE '\\'`
73
+ : `SELECT name FROM pages
74
+ WHERE title LIKE ?2 ESCAPE '\\' OR plain_text LIKE ?2 ESCAPE '\\' OR name LIKE ?2 ESCAPE '\\'`;
75
+ const { results } = await db
76
+ .prepare(`WITH RECURSIVE matched(name) AS (${matched} LIMIT ?3),
77
+ up(name, cur, parent, depth) AS (
78
+ SELECT m.name, p.name, p.parent, 1 FROM matched m JOIN pages p ON p.name = m.name
79
+ UNION ALL
80
+ SELECT up.name, p.name, p.parent, up.depth + 1
81
+ FROM up JOIN pages p ON p.name = up.parent WHERE up.depth <= ?4
82
+ )
83
+ SELECT name, cur AS root FROM up WHERE parent IS NULL`)
84
+ // LIKE だけの検索では ?1 を使わないが、番号をそろえるために渡す
85
+ .bind(usesFts(q) ? ftsPhrase(q) : '', pattern, SEARCH_LIMIT, MAX_DEPTH)
86
+ .all();
87
+ return results;
88
+ }
@@ -0,0 +1,5 @@
1
+ import { Hono } from 'hono';
2
+ import type { AuthEnv } from './access';
3
+ /** パスの `:slug` が不正なら 400 を返す */
4
+ export declare const validSlug: import("hono").MiddlewareHandler<AuthEnv, string, {}, Response>;
5
+ export declare const pagesApi: Hono<AuthEnv, import("hono/types").BlankSchema, "/">;
@@ -0,0 +1,257 @@
1
+ // ページの一覧、検索、バックリンクは D1 の索引から返す。ページの中身と表示名は DO から返す。
2
+ import { Hono } from 'hono';
3
+ import { createMiddleware } from 'hono/factory';
4
+ import { INDEX_EXCERPT_LENGTH, MAX_BOOKMARKS, MAX_LIST_LIMIT } from '../shared/api';
5
+ import { requestLang } from '../shared/i18n';
6
+ import { isValidSlug } from '../shared/slug';
7
+ import { createFirstPage } from './first-page';
8
+ import { REASON_PAGE_NOT_FOUND } from './page-do';
9
+ import { listPages, searchPages, searchRoots } from './page-queries';
10
+ const REVERT_STATUS = {
11
+ 'not-found': 404,
12
+ conflict: 409,
13
+ forbidden: 403,
14
+ invalid: 400,
15
+ };
16
+ /** パスの `:slug` が不正なら 400 を返す */
17
+ export const validSlug = createMiddleware(async (c, next) => {
18
+ if (!isValidSlug(c.req.param('slug') ?? ''))
19
+ return c.json({ error: 'invalid slug' }, 400);
20
+ await next();
21
+ });
22
+ export const pagesApi = new Hono();
23
+ /** 認証が有効かを確かめるための、軽い問い合わせ先(切断中に認証が切れたかをブラウザが調べる) */
24
+ pagesApi.get('/session', (c) => c.json({ actor: c.get('actor') }));
25
+ /**
26
+ * 一覧のボード用。ルートのページ(親のないページ)を新しい順に返す。子ページと孫ページは、
27
+ * 親ページの中からたどるので、ここには出さない。`child_count` は、直接の子ページの数。
28
+ *
29
+ * リンクは、ルートのページから出ているもののうち、リンク先がルートのページか、存在しない
30
+ * ページ(未作成。`missing` が 1)のものだけを返す。子孫のページが関わるリンクは、線にしない。
31
+ */
32
+ pagesApi.get('/index', async (c) => {
33
+ const db = c.env.DB;
34
+ const roots = `SELECT name FROM pages WHERE parent IS NULL ORDER BY updated_at DESC LIMIT ?`;
35
+ const read = () => db.batch([
36
+ db
37
+ .prepare(`SELECT name, title, color, note_count, updated_at, substr(plain_text, 1, ?) AS excerpt,
38
+ (SELECT count(*) FROM pages c WHERE c.parent = pages.name) AS child_count
39
+ FROM pages WHERE parent IS NULL ORDER BY updated_at DESC LIMIT ?`)
40
+ .bind(INDEX_EXCERPT_LENGTH, MAX_LIST_LIMIT),
41
+ db
42
+ .prepare(`SELECT l.from_page, l.to_page, p.name IS NULL AS missing
43
+ FROM links l LEFT JOIN pages p ON p.name = l.to_page
44
+ WHERE l.from_page IN (${roots}) AND p.parent IS NULL`)
45
+ .bind(MAX_LIST_LIMIT),
46
+ ]);
47
+ let [pages, links] = await read();
48
+ // ページが 1 つもない環境では、使い方の付箋を置いた最初のページを作る(1 回だけ。設定の firstPage で止められる)
49
+ const { firstPage, defaultLang } = c.get('config');
50
+ if (pages.results.length === 0 &&
51
+ firstPage &&
52
+ (await createFirstPage(c.env, requestLang(c.req.raw, defaultLang)))) {
53
+ [pages, links] = await read();
54
+ }
55
+ return c.json({ pages: pages.results, links: links.results });
56
+ });
57
+ /** 一度に問い合わせられるページの数 */
58
+ const MAX_INFO_NAMES = 200;
59
+ /**
60
+ * ページの概要をまとめて返す(本文は `{ "names": ["..."] }`)。子ページの付箋の表示に使う。
61
+ * 索引にないページ(未作成、削除済み)は、結果に入らない。
62
+ */
63
+ pagesApi.post('/pages-info', async (c) => {
64
+ const body = await c.req.json().catch(() => null);
65
+ const names = Array.isArray(body?.names) ? body.names.filter((n) => typeof n === 'string') : [];
66
+ if (names.length === 0 || names.length > MAX_INFO_NAMES || !names.every(isValidSlug)) {
67
+ return c.json({ error: 'invalid names' }, 400);
68
+ }
69
+ const { results } = await c.env.DB.prepare(`SELECT name, title, color, note_count, parent, layout FROM pages WHERE name IN (SELECT value FROM json_each(?))`)
70
+ .bind(JSON.stringify(names))
71
+ .all();
72
+ // 索引への反映は、変更の数秒後。作ったばかりのページは、まだ索引にないので、DO から補う
73
+ // (付箋の配置は、索引ができるまで出さない)
74
+ const indexed = new Set(results.map((page) => page.name));
75
+ for (const name of new Set(names)) {
76
+ if (indexed.has(name))
77
+ continue;
78
+ const ref = await c.env.PAGE.getByName(name).getPageRef();
79
+ if (ref) {
80
+ const { title, color, parent } = ref;
81
+ results.push({ name, title, color, note_count: ref.noteCount, parent, layout: null });
82
+ }
83
+ }
84
+ return c.json({ pages: results });
85
+ });
86
+ /** 先祖のページを、ルートから順に返す(パンくず用)。DO をたどるので、直前の変更も反映される */
87
+ pagesApi.get('/pages/:slug/ancestors', validSlug, async (c) => {
88
+ // DO は、親に近い順で返す
89
+ const ancestors = await c.env.PAGE.getByName(c.req.param('slug')).ancestors();
90
+ return c.json({ ancestors: ancestors.reverse() });
91
+ });
92
+ /**
93
+ * ページ一覧。`updated_after`(ミリ秒)より後に更新されたものに絞れる。`name` を渡すと、表示名か
94
+ * スラッグにその文字を含むページに絞る
95
+ */
96
+ pagesApi.get('/pages', async (c) => {
97
+ const pages = await listPages(c.env.DB, {
98
+ updatedAfter: c.req.query('updated_after'),
99
+ limit: c.req.query('limit'),
100
+ name: c.req.query('name'),
101
+ });
102
+ return c.json({ pages });
103
+ });
104
+ /**
105
+ * 全文検索。`roots=1` なら、一致したページのスラッグと、そのルートのスラッグだけを返す
106
+ * (一覧の絞り込み用。一覧にはルートのページしか出ない)
107
+ */
108
+ pagesApi.get('/search', async (c) => {
109
+ const q = c.req.query('q') ?? '';
110
+ const pages = c.req.query('roots') === '1' ? await searchRoots(c.env.DB, q) : await searchPages(c.env.DB, q);
111
+ return pages ? c.json({ pages }) : c.json({ error: 'invalid query' }, 400);
112
+ });
113
+ pagesApi.get('/pages/:slug', validSlug, async (c) => {
114
+ const slug = c.req.param('slug');
115
+ return c.json({ slug, ...(await c.env.PAGE.getByName(slug).getSnapshot()) });
116
+ });
117
+ /** ページの新規作成(本文は `{ "title": "..." }`。表示名は省略できる)。すでにあれば 409 */
118
+ pagesApi.post('/pages/:slug', validSlug, async (c) => {
119
+ const body = await c.req.json().catch(() => null);
120
+ const result = await c.env.PAGE.getByName(c.req.param('slug')).createPage(body?.title);
121
+ if (result.ok)
122
+ return c.json(result, 201);
123
+ return c.json({ error: result.reason }, result.reason === 'exists' ? 409 : 400);
124
+ });
125
+ /** ページの削除。取り消しはできない */
126
+ pagesApi.delete('/pages/:slug', validSlug, async (c) => {
127
+ await c.env.PAGE.getByName(c.req.param('slug')).deletePage(c.get('actor'));
128
+ return c.json({ ok: true });
129
+ });
130
+ /**
131
+ * 自分のブックマーク(付けた順)。表示名と更新の時刻は索引から取るので、作ったばかりのページ
132
+ * (索引への反映の前)は null になる
133
+ */
134
+ pagesApi.get('/bookmarks', async (c) => {
135
+ const { results } = await c.env.DB.prepare(`SELECT b.page AS name, p.title, p.updated_at FROM bookmarks b LEFT JOIN pages p ON p.name = b.page
136
+ WHERE b.actor = ? ORDER BY b.created_at, b.page`)
137
+ .bind(c.get('actor'))
138
+ .all();
139
+ return c.json({ pages: results });
140
+ });
141
+ /** ブックマークを付ける。すでに付いていれば、何もしない */
142
+ pagesApi.put('/bookmarks/:slug', validSlug, async (c) => {
143
+ const slug = c.req.param('slug');
144
+ const actor = c.get('actor');
145
+ // 索引への反映を待たずに確かめる(作ったばかりのページにも付けられる)
146
+ if ((await c.env.PAGE.getByName(slug).getPageRef()) === null)
147
+ return c.json({ error: 'page not found' }, 404);
148
+ // 上限は、付ける行の条件にする(数えてから付けると、同時に届いた分で超える)
149
+ const added = await c.env.DB.prepare(`INSERT OR IGNORE INTO bookmarks (actor, page, created_at)
150
+ SELECT ?1, ?2, ?3 WHERE (SELECT count(*) FROM bookmarks WHERE actor = ?1) < ?4`)
151
+ .bind(actor, slug, Date.now(), MAX_BOOKMARKS)
152
+ .run();
153
+ // 付かなかったのは、すでに付いているか、上限に達しているか
154
+ if (added.meta.changes === 0) {
155
+ const exists = await c.env.DB.prepare(`SELECT 1 FROM bookmarks WHERE actor = ? AND page = ?`)
156
+ .bind(actor, slug)
157
+ .first();
158
+ if (!exists)
159
+ return c.json({ error: `too many bookmarks (limit ${MAX_BOOKMARKS})` }, 400);
160
+ }
161
+ return c.json({ ok: true });
162
+ });
163
+ /** ブックマークを外す。付いていなくても、成功として返す */
164
+ pagesApi.delete('/bookmarks/:slug', validSlug, async (c) => {
165
+ await c.env.DB.prepare(`DELETE FROM bookmarks WHERE actor = ? AND page = ?`)
166
+ .bind(c.get('actor'), c.req.param('slug'))
167
+ .run();
168
+ return c.json({ ok: true });
169
+ });
170
+ /** このページへリンクしているページ */
171
+ pagesApi.get('/pages/:slug/backlinks', validSlug, async (c) => {
172
+ const { results } = await c.env.DB.prepare(`SELECT p.name, p.title FROM links l JOIN pages p ON p.name = l.from_page
173
+ WHERE l.to_page = ? ORDER BY p.updated_at DESC`)
174
+ .bind(c.req.param('slug'))
175
+ .all();
176
+ return c.json({ pages: results });
177
+ });
178
+ /** 最近の操作。`agent=1` で agent(MCP 経由)の操作に絞る */
179
+ pagesApi.get('/pages/:slug/ops', validSlug, async (c) => {
180
+ const ops = await c.env.PAGE.getByName(c.req.param('slug')).listOps({
181
+ limit: Number(c.req.query('limit')),
182
+ agentOnly: c.req.query('agent') === '1',
183
+ });
184
+ return c.json({ ops });
185
+ });
186
+ /**
187
+ * 操作の取り消し。本文は `{ clientId, opId }`(同じ値で送り直しても二重に取り消さない)。
188
+ * その後に変更されていて取り消さなかったものは `skipped` で返す。
189
+ */
190
+ pagesApi.post('/pages/:slug/ops/:seq/revert', validSlug, async (c) => {
191
+ const body = await c.req.json().catch(() => null);
192
+ const result = await c.env.PAGE.getByName(c.req.param('slug')).revert({
193
+ seq: Number(c.req.param('seq')),
194
+ actor: c.get('actor'),
195
+ clientId: String(body?.clientId ?? ''),
196
+ opId: String(body?.opId ?? ''),
197
+ });
198
+ if (result.ok)
199
+ return c.json(result);
200
+ return c.json({ error: result.reason }, REVERT_STATUS[result.code]);
201
+ });
202
+ /**
203
+ * 他のページから移す(写す)付箋を置く。本文は `{ clientId, opId, from, notes, edges }`
204
+ * (`from` は、付箋が元あったページ。同じ `opId` で送り直しても、二重には置かない)。
205
+ * 今ある付箋の下に置く。ページがなければ 404(作らない)
206
+ */
207
+ pagesApi.post('/pages/:slug/notes', validSlug, async (c) => {
208
+ const body = await c.req
209
+ .json()
210
+ .catch(() => null);
211
+ const from = body?.from;
212
+ if (typeof from !== 'string' || !isValidSlug(from))
213
+ return c.json({ error: 'invalid from' }, 400);
214
+ const slug = c.req.param('slug');
215
+ const stub = c.env.PAGE.getByName(slug);
216
+ const result = await stub.receiveNotes({
217
+ actor: c.get('actor'),
218
+ clientId: String(body?.clientId ?? ''),
219
+ opId: String(body?.opId ?? ''),
220
+ from,
221
+ notes: body?.notes,
222
+ edges: body?.edges,
223
+ });
224
+ if (result.ok) {
225
+ // 置いた後の概要も返す(送った側が、索引への反映を待たずに、子ページの付箋を描き直せる)
226
+ const summary = await stub.getSummary();
227
+ return c.json({ ok: true, seq: result.seq, page: summary && { name: slug, ...summary } });
228
+ }
229
+ return c.json({ error: result.reason }, result.reason === REASON_PAGE_NOT_FOUND ? 404 : 400);
230
+ });
231
+ /**
232
+ * 表示名の変更。まだ書き込みのないページに対して呼ぶと、ページが作られる。
233
+ * 本文に `"mustExist": true` があれば、作らずに 404 を返す
234
+ */
235
+ pagesApi.put('/pages/:slug/title', validSlug, async (c) => {
236
+ const body = await c.req.json().catch(() => null);
237
+ const result = await c.env.PAGE.getByName(c.req.param('slug')).setTitle(body?.title, {
238
+ mustExist: body?.mustExist === true,
239
+ });
240
+ if (result.ok)
241
+ return c.json(result);
242
+ return c.json({ error: result.reason }, result.reason === REASON_PAGE_NOT_FOUND ? 404 : 400);
243
+ });
244
+ /**
245
+ * ページの色の変更(本文は `{ "color": "#BBDEFB" }`。`PAGE_COLORS` のどれか。null で、付けていない
246
+ * 状態に戻す)。ページがなければ 404(作らない)
247
+ */
248
+ pagesApi.put('/pages/:slug/color', validSlug, async (c) => {
249
+ const body = await c.req.json().catch(() => null);
250
+ // `color` のない本文を、「付けていない状態に戻す」として扱わない
251
+ if (body?.color === undefined)
252
+ return c.json({ error: 'invalid color' }, 400);
253
+ const result = await c.env.PAGE.getByName(c.req.param('slug')).setColor(body.color, c.get('actor'));
254
+ if (result.ok)
255
+ return c.json(result);
256
+ return c.json({ error: result.reason }, result.reason === REASON_PAGE_NOT_FOUND ? 404 : 400);
257
+ });
@@ -0,0 +1,22 @@
1
+ export interface NoteContent {
2
+ /** タグを除いたテキスト。ブロックの区切りは改行にする */
3
+ text: string;
4
+ /** `<a>` の href(エンティティを復号したもの) */
5
+ hrefs: string[];
6
+ }
7
+ /**
8
+ * 付箋の text(サニタイズ済みの HTML)から、テキストとリンク先を取り出す。
9
+ *
10
+ * `pageLinks` を渡すと、ページへのリンクを `[[slug]]` の書式でテキストに残す(LLM に渡すとき。
11
+ * 書くときの書式は src/shared/note-text.ts)。リンクの文字がスラッグと違えば、文字の後ろに付ける。
12
+ * 外部へのリンク(http / https)は、URL を文字として残す(文字が URL と違えば、山かっこで後ろに付ける)。
13
+ * 渡さなければ、リンクの文字だけが残る(検索の索引を作るとき)
14
+ */
15
+ export declare function extractContent(html: string, pageLinks?: {
16
+ siteOrigin: string | undefined;
17
+ }): Promise<NoteContent>;
18
+ /**
19
+ * href がこのサイトのページ(`/p/<slug>`)を指していれば、そのスラッグを返す。
20
+ * 対象は相対 URL と、`siteOrigin` を持つ絶対 URL。
21
+ */
22
+ export declare function linkedSlug(href: string, siteOrigin: string | undefined): string | null;
@@ -0,0 +1,113 @@
1
+ import { BLOCK_TAGS, decodeEntities } from '../shared/note-html';
2
+ import { pageLink, TRAILING_PUNCTUATION, URL_CHARS } from '../shared/note-text';
3
+ import { isValidSlug } from '../shared/slug';
4
+ /**
5
+ * 付箋の text(サニタイズ済みの HTML)から、テキストとリンク先を取り出す。
6
+ *
7
+ * `pageLinks` を渡すと、ページへのリンクを `[[slug]]` の書式でテキストに残す(LLM に渡すとき。
8
+ * 書くときの書式は src/shared/note-text.ts)。リンクの文字がスラッグと違えば、文字の後ろに付ける。
9
+ * 外部へのリンク(http / https)は、URL を文字として残す(文字が URL と違えば、山かっこで後ろに付ける)。
10
+ * 渡さなければ、リンクの文字だけが残る(検索の索引を作るとき)
11
+ */
12
+ export async function extractContent(html, pageLinks) {
13
+ if (!html.includes('<'))
14
+ return { text: decodeEntities(html).trim(), hrefs: [] };
15
+ const parts = [];
16
+ const hrefs = [];
17
+ const rewritten = new HTMLRewriter()
18
+ .on('a[href]', {
19
+ element(el) {
20
+ const href = decodeEntities(el.getAttribute('href') ?? '');
21
+ hrefs.push(href);
22
+ if (!pageLinks)
23
+ return;
24
+ const slug = linkedSlug(href, pageLinks.siteOrigin);
25
+ // 外部へのリンクは、リンク先の URL を文字として残す。それ以外(mailto など)は、文字だけ
26
+ const url = slug ? null : writableUrl(href);
27
+ if (!slug && !url)
28
+ return;
29
+ // リンクの文字を集めておき、閉じタグで、書式に直して置き換える
30
+ const start = parts.length;
31
+ el.onEndTag(() => {
32
+ // 文字は復号しないまま戻す(最後に、全体をまとめて復号する。ここで復号すると 2 回になる)
33
+ const raw = parts.splice(start).join('').trim();
34
+ const label = decodeEntities(raw);
35
+ if (slug) {
36
+ parts.push(label === '' || label === slug ? pageLink(slug) : `${raw} ${pageLink(slug)}`);
37
+ }
38
+ else if (url) {
39
+ // 人が貼ったリンクは、文字とリンク先が違うことがある。そのときは、URL を山かっこで後ろに付ける
40
+ // (山かっこは URL に使えない文字なので、書き戻したときに、URL の一部にならない)
41
+ const escaped = url.replace(/&/g, '&amp;');
42
+ parts.push(label === '' || label === href || label === url ? escaped : `${raw} &lt;${escaped}&gt;`);
43
+ }
44
+ });
45
+ },
46
+ })
47
+ .on(BLOCK_TAGS.join(', '), {
48
+ element(el) {
49
+ parts.push('\n');
50
+ if (el.tagName !== 'br')
51
+ el.onEndTag(() => void parts.push('\n'));
52
+ },
53
+ })
54
+ .onDocument({
55
+ text(chunk) {
56
+ parts.push(chunk.text);
57
+ },
58
+ })
59
+ .transform(new Response(html));
60
+ // 変換後の HTML は使わない。読み捨てて、ハンドラだけを走らせる
61
+ await rewritten.body.pipeTo(new WritableStream());
62
+ const text = decodeEntities(parts.join(''))
63
+ .replace(/[ \t]*\n\s*/g, '\n')
64
+ .trim();
65
+ return { text, hrefs };
66
+ }
67
+ const UNWRITABLE_CHAR_RE = new RegExp(`[^${URL_CHARS}]`, 'g');
68
+ const percentEncode = (char) => `%${char.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0')}`;
69
+ /**
70
+ * 外部へのリンク(http / https)の href を、本文に書き戻しても同じリンク先になる形の URL にする
71
+ * (src/shared/note-text.ts は、URL に使える文字の並びだけをリンクにし、末尾の句読点と、余った
72
+ * 閉じかっこを外す)。外部へのリンクでなければ null
73
+ */
74
+ function writableUrl(href) {
75
+ let url;
76
+ try {
77
+ url = new URL(href);
78
+ }
79
+ catch {
80
+ return null;
81
+ }
82
+ if (url.protocol !== 'http:' && url.protocol !== 'https:')
83
+ return null;
84
+ // 解釈した後の URL は、ASCII だけになる(日本語のパスは、パーセントエンコードされる)
85
+ let text = url.href.replace(UNWRITABLE_CHAR_RE, percentEncode);
86
+ // かっこの対が取れていなければ、かっこをすべてエンコードする
87
+ if (text.split('(').length !== text.split(')').length)
88
+ text = text.replace(/[()]/g, percentEncode);
89
+ // 末尾の句読点は、エンコードする。空のクエリの `?` は、外しても同じリンク先なので、そのまま
90
+ const last = text.at(-1);
91
+ if (last !== '?' && TRAILING_PUNCTUATION.includes(last))
92
+ text = text.slice(0, -1) + percentEncode(last);
93
+ return text;
94
+ }
95
+ /** 相対 URL を解決するための仮のオリジン */
96
+ const RELATIVE_BASE = 'https://relative.invalid';
97
+ /**
98
+ * href がこのサイトのページ(`/p/<slug>`)を指していれば、そのスラッグを返す。
99
+ * 対象は相対 URL と、`siteOrigin` を持つ絶対 URL。
100
+ */
101
+ export function linkedSlug(href, siteOrigin) {
102
+ let url;
103
+ try {
104
+ url = new URL(href, RELATIVE_BASE);
105
+ }
106
+ catch {
107
+ return null;
108
+ }
109
+ if (url.origin !== RELATIVE_BASE && url.origin !== siteOrigin)
110
+ return null;
111
+ const slug = /^\/p\/([^/]+)\/?$/.exec(url.pathname)?.[1];
112
+ return slug && isValidSlug(slug) ? slug : null;
113
+ }
@@ -0,0 +1,21 @@
1
+ import type { Skipped } from '../shared/api';
2
+ import type { HistoryDelta } from '../shared/delta';
3
+ /**
4
+ * 逆向きのデルタ(`invertDeltas` の結果)を、取り消してよいものだけ順に適用する。
5
+ * `transactionSync` の中で呼ぶこと。
6
+ *
7
+ * 取り消してよいかは、対象が「取り消す操作が残した状態」のままかで決める。
8
+ *
9
+ * - 更新: フィールドごとに判定し、その後に変更されたフィールドは戻さない
10
+ * - 作成(付箋): text が変わっていたら消さない。位置、大きさ、色だけの変更なら消す
11
+ * (agent が貼った付箋を動かしただけで取り消せなくなるのを避ける)。
12
+ * 取り消さない接続線がつながっている付箋も消さない
13
+ * - 作成(接続線): label が変わっていたら消さない
14
+ * - 削除: 同じ id のものがすでにあるか、接続線の両端がなければ戻さない
15
+ *
16
+ * 対象がすでにない作成の取り消しは、することがないので `skipped` にも入れない。
17
+ */
18
+ export declare function applyRevert(sql: SqlStorage, inverse: HistoryDelta[], actor: string, now: number): {
19
+ deltas: HistoryDelta[];
20
+ skipped: Skipped[];
21
+ };
@@ -0,0 +1,126 @@
1
+ import { applyDeltas, exists, readEdge, readNote, readNoteFields, sameNoteValue } from './apply-ops';
2
+ /**
3
+ * 更新の取り消しのうち、現在値が「取り消す操作が書いた値」のままのキーだけを残す。
4
+ * `expected` は取り消す操作が書いた値、`restore` は戻す値。キーがないことは未設定を表す。
5
+ */
6
+ function unchangedKeys(current, expected, restore, same) {
7
+ const before = {};
8
+ const after = {};
9
+ let conflicted = false;
10
+ for (const key of new Set([...Object.keys(expected), ...Object.keys(restore)])) {
11
+ if (!same(key, current[key], expected[key])) {
12
+ conflicted = true;
13
+ continue;
14
+ }
15
+ if (key in expected)
16
+ before[key] = expected[key];
17
+ if (key in restore)
18
+ after[key] = restore[key];
19
+ }
20
+ return { before, after, conflicted };
21
+ }
22
+ const sameEdgeValue = (_key, a, b) => a === b;
23
+ /**
24
+ * 逆向きのデルタ(`invertDeltas` の結果)を、取り消してよいものだけ順に適用する。
25
+ * `transactionSync` の中で呼ぶこと。
26
+ *
27
+ * 取り消してよいかは、対象が「取り消す操作が残した状態」のままかで決める。
28
+ *
29
+ * - 更新: フィールドごとに判定し、その後に変更されたフィールドは戻さない
30
+ * - 作成(付箋): text が変わっていたら消さない。位置、大きさ、色だけの変更なら消す
31
+ * (agent が貼った付箋を動かしただけで取り消せなくなるのを避ける)。
32
+ * 取り消さない接続線がつながっている付箋も消さない
33
+ * - 作成(接続線): label が変わっていたら消さない
34
+ * - 削除: 同じ id のものがすでにあるか、接続線の両端がなければ戻さない
35
+ *
36
+ * 対象がすでにない作成の取り消しは、することがないので `skipped` にも入れない。
37
+ */
38
+ export function applyRevert(sql, inverse, actor, now) {
39
+ const deltas = [];
40
+ const skipped = [];
41
+ // 1 つずつ適用する。後のデルタの判定が、先のデルタの結果(復活した付箋など)を見られるようにする
42
+ const apply = (d) => {
43
+ deltas.push(...applyDeltas(sql, [d], actor, now).deltas);
44
+ };
45
+ /** 更新の取り消しで、戻してよいフィールドだけの before / after を返す。戻さない分は skipped に入れる */
46
+ const restorable = (target, id, current, d, same) => {
47
+ if (!current) {
48
+ skipped.push({ target, id, reason: 'deleted' });
49
+ return undefined;
50
+ }
51
+ const { before, after, conflicted } = unchangedKeys(current, d.before, d.after, same);
52
+ if (conflicted)
53
+ skipped.push({ target, id, reason: 'modified' });
54
+ return { before, after };
55
+ };
56
+ for (const d of inverse) {
57
+ switch (d.type) {
58
+ case 'note:delete': {
59
+ const cur = readNote(sql, d.note.id);
60
+ if (!cur)
61
+ break;
62
+ // 付箋を消すと、つながっている接続線も消える。この時点で残っている接続線は、
63
+ // この取り消しで戻さなかったもの(変更されていた)か、他の人が後からつないだものなので、
64
+ // 付箋ごと残す
65
+ const connected = sql.exec(`SELECT 1 FROM edges WHERE from_id = ? OR to_id = ? LIMIT 1`, cur.id, cur.id)
66
+ .toArray().length > 0;
67
+ if (cur.text !== d.note.text)
68
+ skipped.push({ target: 'note', id: cur.id, reason: 'modified' });
69
+ else if (connected)
70
+ skipped.push({ target: 'note', id: cur.id, reason: 'connected' });
71
+ else
72
+ apply(d);
73
+ break;
74
+ }
75
+ case 'note:create': {
76
+ if (exists(sql, 'notes', d.note.id)) {
77
+ skipped.push({ target: 'note', id: d.note.id, reason: 'exists' });
78
+ }
79
+ else {
80
+ apply(d);
81
+ }
82
+ break;
83
+ }
84
+ case 'note:update': {
85
+ // 関わるフィールドだけを読む(移動の取り消しで大きな text を読まない)
86
+ const keys = [...Object.keys(d.before), ...Object.keys(d.after)];
87
+ const cur = readNoteFields(sql, d.noteId, keys);
88
+ const patch = restorable('note', d.noteId, cur, d, sameNoteValue);
89
+ if (patch)
90
+ apply({ type: 'note:update', noteId: d.noteId, ...patch });
91
+ break;
92
+ }
93
+ case 'edge:delete': {
94
+ const cur = readEdge(sql, d.edge.id);
95
+ if (!cur)
96
+ break;
97
+ if (cur.label !== d.edge.label)
98
+ skipped.push({ target: 'edge', id: cur.id, reason: 'modified' });
99
+ else
100
+ apply(d);
101
+ break;
102
+ }
103
+ case 'edge:create': {
104
+ const e = d.edge;
105
+ if (exists(sql, 'edges', e.id)) {
106
+ skipped.push({ target: 'edge', id: e.id, reason: 'exists' });
107
+ }
108
+ else if (!exists(sql, 'notes', e.from) || !exists(sql, 'notes', e.to)) {
109
+ skipped.push({ target: 'edge', id: e.id, reason: 'endpoint-missing' });
110
+ }
111
+ else {
112
+ apply(d);
113
+ }
114
+ break;
115
+ }
116
+ case 'edge:update': {
117
+ const cur = readEdge(sql, d.edgeId);
118
+ const patch = restorable('edge', d.edgeId, cur && { ...cur }, d, sameEdgeValue);
119
+ if (patch)
120
+ apply({ type: 'edge:update', edgeId: d.edgeId, ...patch });
121
+ break;
122
+ }
123
+ }
124
+ }
125
+ return { deltas, skipped };
126
+ }