@escalay/kit 0.0.0-stage → 1.8.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 +21 -0
- package/README.md +142 -2
- package/cli.mjs +438 -0
- package/docs/STANDARD.md +342 -0
- package/docs/decisions/0001-features-drizzle-scaffold.md +35 -0
- package/docs/decisions/0002-node-and-workers.md +45 -0
- package/docs/releasing.md +97 -0
- package/package.json +59 -3
- package/registry.json +548 -0
- package/src/a2a/README.md +25 -0
- package/src/a2a/index.ts +262 -0
- package/src/boards/README.md +35 -0
- package/src/boards/handlers.ts +87 -0
- package/src/boards/index.ts +64 -0
- package/src/boards/refs.ts +23 -0
- package/src/boards/repo.ts +50 -0
- package/src/boards/routes.ts +83 -0
- package/src/boards/schema.ts +28 -0
- package/src/boards/tools.ts +76 -0
- package/src/browser/README.md +31 -0
- package/src/browser/boards-client.ts +74 -0
- package/src/browser/boards-react.ts +73 -0
- package/src/browser/search-client.ts +258 -0
- package/src/browser/search-react.ts +116 -0
- package/src/browser/vector-search-block.tsx +94 -0
- package/src/core/README.md +37 -0
- package/src/core/api.ts +239 -0
- package/src/core/auth.ts +147 -0
- package/src/core/cache.ts +53 -0
- package/src/core/discovery.ts +315 -0
- package/src/core/guards.ts +109 -0
- package/src/core/hal.ts +63 -0
- package/src/core/kv.ts +29 -0
- package/src/core/problem.ts +124 -0
- package/src/db/README.md +39 -0
- package/src/db/guards.ts +61 -0
- package/src/db/index.ts +122 -0
- package/src/guide/README.md +38 -0
- package/src/guide/guide.ts +152 -0
- package/src/guide/reference.ts +204 -0
- package/src/index.ts +15 -0
- package/src/mcp/README.md +34 -0
- package/src/mcp/index.ts +251 -0
- package/src/oauth/README.md +29 -0
- package/src/oauth/index.ts +439 -0
- package/src/oauth/schema.ts +7 -0
- package/src/oauth/store.ts +34 -0
- package/src/pagination/README.md +31 -0
- package/src/pagination/index.ts +65 -0
- package/src/testing/README.md +31 -0
- package/src/testing/index.ts +162 -0
- package/src/vector-search/README.md +27 -0
- package/src/vector-search/index.ts +367 -0
- package/src/vectors/README.md +30 -0
- package/src/vectors/index.ts +326 -0
- package/src/web/README.md +21 -0
- package/src/web/index.ts +100 -0
- package/templates/app/AGENTS.md +41 -0
- package/templates/app/CLAUDE.md +3 -0
- package/templates/app/Dockerfile +12 -0
- package/templates/app/container/entrypoint.sh +7 -0
- package/templates/app/gitignore +4 -0
- package/templates/app/guide/llms.txt +11 -0
- package/templates/app/pnpm-workspace.yaml +5 -0
- package/templates/app/scripts/guide.ts +8 -0
- package/templates/app/src/app.ts +39 -0
- package/templates/app/src/features/index.ts +30 -0
- package/templates/app/src/server.ts +14 -0
- package/templates/app/test/api.test.ts +34 -0
- package/templates/app/tsconfig.json +16 -0
- package/templates/feature/feature.test.ts +19 -0
- package/templates/feature/handlers.ts +43 -0
- package/templates/feature/index.ts +34 -0
- package/templates/feature/repo.ts +24 -0
- package/templates/feature/routes.ts +43 -0
- package/templates/feature/schema.ts +14 -0
- package/templates/feature/tools.ts +46 -0
- package/templates/npm-release/release-please-config.json +60 -0
- package/templates/npm-release/release-please-manifest.json +3 -0
- package/templates/runtime-cloudflare/dev.vars.example +3 -0
- package/templates/runtime-cloudflare/scripts/migrations.ts +43 -0
- package/templates/runtime-cloudflare/src/worker.ts +19 -0
- package/templates/runtime-cloudflare/test/workers/api.test.ts +37 -0
- package/templates/runtime-cloudflare/test/workers/apply-migrations.ts +5 -0
- package/templates/runtime-cloudflare/test/workers/env.d.ts +11 -0
- package/templates/runtime-cloudflare/test/workers/key.ts +2 -0
- package/templates/runtime-cloudflare/test/workers/tsconfig.json +7 -0
- package/templates/runtime-cloudflare/vitest.config.ts +23 -0
- package/templates/runtime-cloudflare/wrangler.jsonc +16 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/** The board routes as MCP tools, so an agent can keep what it finds for the owner. */
|
|
2
|
+
import { z } from '@hono/zod-openapi'
|
|
3
|
+
import { mcpTool, type McpTool } from '../mcp/index.js'
|
|
4
|
+
|
|
5
|
+
type BoardJson = { id: string; name: string; items: number; description?: string | null; _embedded?: { items: { ref: string; note: string | null; item: unknown }[] } }
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The MCP tools for the same routes, so an agent can keep what it finds for the owner: list, make, open, save to and
|
|
9
|
+
* take off boards (and suggestions, when the app has them).
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* boardTools({ noun: 'recipes', label: (recipe) => `${recipe.title} (${recipe.year})`, suggestions: true })
|
|
13
|
+
*/
|
|
14
|
+
export const boardTools = ({ path = '/api/boards', noun, label, suggestions = false }: { path?: string; noun: string; label: (item: any) => string; suggestions?: boolean }): McpTool[] => {
|
|
15
|
+
const line = (board: BoardJson) => `${board.name} · ${board.items} ${noun} · id ${board.id}`
|
|
16
|
+
const tools: McpTool[] = [
|
|
17
|
+
mcpTool({
|
|
18
|
+
name: 'list_boards',
|
|
19
|
+
title: 'The owner’s boards',
|
|
20
|
+
description: `Every board (a named collection of ${noun} for a project) with how much is on it; give \`item\` to see which boards hold one.`,
|
|
21
|
+
input: z.object({ item: z.string().optional().describe('Only boards holding this ref.') }),
|
|
22
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
23
|
+
call: ({ item }) => ({ path, query: item ? { item } : {} }),
|
|
24
|
+
summarize: (json: { _embedded: { boards: BoardJson[] } }) => json._embedded.boards.map(line).join('\n') || 'No boards yet. create_board makes one.',
|
|
25
|
+
}),
|
|
26
|
+
mcpTool({
|
|
27
|
+
name: 'create_board',
|
|
28
|
+
title: 'Make a board',
|
|
29
|
+
description: `A new, empty board to collect ${noun} for a project. Returns its id.`,
|
|
30
|
+
input: z.object({ name: z.string().min(1).describe('A short name.'), description: z.string().optional().describe('What it is for.') }),
|
|
31
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
32
|
+
call: (body) => ({ method: 'POST', path, body }),
|
|
33
|
+
summarize: (json: BoardJson) => `Made ${line(json)}`,
|
|
34
|
+
}),
|
|
35
|
+
mcpTool({
|
|
36
|
+
name: 'get_board',
|
|
37
|
+
title: 'Open a board',
|
|
38
|
+
description: `A board and the ${noun} on it, newest first, with their notes.`,
|
|
39
|
+
input: z.object({ id: z.string().describe('The board’s id.') }),
|
|
40
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
41
|
+
call: ({ id }) => ({ path: `${path}/${encodeURIComponent(id)}` }),
|
|
42
|
+
summarize: (json: BoardJson) => [line(json), ...(json._embedded?.items ?? []).map((entry) => `- ${entry.item ? label(entry.item) : `${entry.ref} (no longer in the catalog)`}${entry.note ? ` — ${entry.note}` : ''}`)].join('\n'),
|
|
43
|
+
}),
|
|
44
|
+
mcpTool({
|
|
45
|
+
name: 'save_to_board',
|
|
46
|
+
title: 'Save to a board',
|
|
47
|
+
description: `Puts one of the ${noun} on a board, with an optional note on why; saving it again only changes the note.`,
|
|
48
|
+
input: z.object({ id: z.string().describe('The board’s id.'), ref: z.string().describe('The item’s ref, from a search result (e.g. its id).'), note: z.string().optional().describe('Why it belongs here.') }),
|
|
49
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
50
|
+
call: ({ id, ref, note }) => ({ method: 'PUT', path: `${path}/${encodeURIComponent(id)}/items/${encodeURIComponent(ref)}`, body: note ? { note } : {} }),
|
|
51
|
+
summarize: (json: BoardJson) => `Saved. ${line(json)}`,
|
|
52
|
+
}),
|
|
53
|
+
mcpTool({
|
|
54
|
+
name: 'remove_from_board',
|
|
55
|
+
title: 'Take off a board',
|
|
56
|
+
description: `Takes one of the ${noun} off a board.`,
|
|
57
|
+
input: z.object({ id: z.string().describe('The board’s id.'), ref: z.string().describe('The item’s ref.') }),
|
|
58
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
|
|
59
|
+
call: ({ id, ref }) => ({ method: 'DELETE', path: `${path}/${encodeURIComponent(id)}/items/${encodeURIComponent(ref)}` }),
|
|
60
|
+
summarize: () => 'Removed.',
|
|
61
|
+
}),
|
|
62
|
+
]
|
|
63
|
+
if (suggestions)
|
|
64
|
+
tools.push(
|
|
65
|
+
mcpTool({
|
|
66
|
+
name: 'board_suggestions',
|
|
67
|
+
title: 'More like a board',
|
|
68
|
+
description: `${noun[0]!.toUpperCase()}${noun.slice(1)} like the ones on a board, best first, leaving out what is already on it.`,
|
|
69
|
+
input: z.object({ id: z.string().describe('The board’s id.'), limit: z.number().int().min(1).max(100).optional() }),
|
|
70
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
71
|
+
call: ({ id, limit }) => ({ path: `${path}/${encodeURIComponent(id)}/suggestions`, query: limit ? { limit } : {} }),
|
|
72
|
+
summarize: (json: { _embedded: { items: unknown[] } }) => json._embedded.items.map((item) => `- ${label(item)}`).join('\n') || 'Nothing yet: add a few items first.',
|
|
73
|
+
}),
|
|
74
|
+
)
|
|
75
|
+
return tools
|
|
76
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# browser
|
|
2
|
+
|
|
3
|
+
The browser side, for the app's frontend (not the API): typed clients for the search and board routes, headless
|
|
4
|
+
React hooks over them, and an optional shadcn search block. Logic only; each app draws its own UI.
|
|
5
|
+
|
|
6
|
+
| Part | Files | What |
|
|
7
|
+
|---|---|---|
|
|
8
|
+
| `search-client` | `search-client.ts` | `createSearchClient`, `createSearchController` (debounce, cancel, picture paste and drop) |
|
|
9
|
+
| `search-react` | `search-react.ts` | `useVectorSearch`, `usePictureInput`, `useSimilar` |
|
|
10
|
+
| `vector-search-ui` | `vector-search-block.tsx` | an optional search block (shadcn `button` + `input`) |
|
|
11
|
+
| `boards-client` | `boards-client.ts` + `../boards/refs.ts` | `createBoardsClient`: list, create, save, unsave, suggestions; `BoardsError` carries the problem |
|
|
12
|
+
| `boards-react` | `boards-react.ts` | `useBoards`, `useItemBoards` |
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
# into the frontend, tracked by the API's kit.json
|
|
16
|
+
npx @escalay/kit add boards-react --dir apps/api/src/kit --web-dir ../../web/src/kit
|
|
17
|
+
# or one part with the shadcn CLI, into src/kit
|
|
18
|
+
npx shadcn add escalay/api-kit/boards-react
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
const boards = createBoardsClient<RecipeCard>()
|
|
23
|
+
const saved = (await boards.list()).find((b) => b.name === 'Saved') ?? (await boards.create('Saved'))
|
|
24
|
+
await boards.save(saved.id, boardRef('recipe', slug), 'the sun')
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Rules
|
|
28
|
+
|
|
29
|
+
- The clients use the page's own sign-in (same-origin cookies); a failure is a typed error with the API's `detail`.
|
|
30
|
+
- Typechecked with bundler resolution (`tsconfig.web.json`).
|
|
31
|
+
- Standard: `docs/STANDARD.md` §16.
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The browser side of `boards`, without a framework: a typed client for the board routes, run with the page's own
|
|
3
|
+
* sign-in. A plain viewer uses it directly; React apps use `boards-react.ts`. A failed call is a `BoardsError` carrying
|
|
4
|
+
* the API's problem (`code`, `detail`), so an app can show what to do.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* const boards = createBoardsClient<RecipeCard>()
|
|
8
|
+
* const saved = (await boards.list()).find((board) => board.name === 'Saved') ?? (await boards.create('Saved'))
|
|
9
|
+
* await boards.save(saved.id, 'recipe:soup', 'the sun')
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export { BOARD_REF, boardRef, parseBoardRef } from '../boards/refs.js'
|
|
13
|
+
|
|
14
|
+
export type BoardEntry<Item> = { ref: string; note: string | null; addedAt: string; item: Item | null }
|
|
15
|
+
export type Board<Item> = { id: string; name: string; description: string | null; items: number; createdAt: string; updatedAt: string; entries?: BoardEntry<Item>[] }
|
|
16
|
+
|
|
17
|
+
/** A failed board call, as the API's problem+json described it. */
|
|
18
|
+
export class BoardsError extends Error {
|
|
19
|
+
readonly status: number
|
|
20
|
+
readonly code: string
|
|
21
|
+
readonly detail: string
|
|
22
|
+
|
|
23
|
+
constructor(status: number, code: string, detail: string) {
|
|
24
|
+
super(detail)
|
|
25
|
+
this.status = status
|
|
26
|
+
this.code = code
|
|
27
|
+
this.detail = detail
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type BoardJson<Item> = Omit<Board<Item>, 'entries'> & { _embedded?: { items: BoardEntry<Item>[] } }
|
|
32
|
+
const boardOf = <Item>({ _embedded, ...board }: BoardJson<Item>): Board<Item> => ({ ...board, ...(_embedded ? { entries: _embedded.items } : {}) })
|
|
33
|
+
|
|
34
|
+
export type BoardsClient<Item> = {
|
|
35
|
+
/** Every board, newest change first; with `ref`, only the boards holding it. */
|
|
36
|
+
list: (ref?: string) => Promise<Board<Item>[]>
|
|
37
|
+
create: (name: string, description?: string) => Promise<Board<Item>>
|
|
38
|
+
/** A board with its entries. */
|
|
39
|
+
get: (id: string) => Promise<Board<Item>>
|
|
40
|
+
rename: (id: string, change: { name?: string; description?: string }) => Promise<Board<Item>>
|
|
41
|
+
remove: (id: string) => Promise<void>
|
|
42
|
+
/** Puts an item on a board (or changes its note); harmless to repeat. */
|
|
43
|
+
save: (id: string, ref: string, note?: string) => Promise<Board<Item>>
|
|
44
|
+
unsave: (id: string, ref: string) => Promise<void>
|
|
45
|
+
suggestions: (id: string, limit?: number) => Promise<Item[]>
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A client for the routes `registerBoards` serves. `fetchImpl` and `headers` are for tests and scripts; a page needs
|
|
50
|
+
* neither.
|
|
51
|
+
*
|
|
52
|
+
* @example
|
|
53
|
+
* const boards = createBoardsClient<RecipeCard>({ path: '/api/boards' })
|
|
54
|
+
* const holding = await boards.list('recipe:soup') // where it is saved
|
|
55
|
+
*/
|
|
56
|
+
export const createBoardsClient = <Item>({ path = '/api/boards', fetchImpl = (...args) => fetch(...args), headers = {} }: { path?: string; fetchImpl?: typeof fetch; headers?: Record<string, string> } = {}): BoardsClient<Item> => {
|
|
57
|
+
const call = async (url: string, init: RequestInit = {}) => {
|
|
58
|
+
const response = await fetchImpl(url, { credentials: 'same-origin', ...init, headers: { accept: 'application/json', ...(init.body ? { 'content-type': 'application/json' } : {}), ...headers } })
|
|
59
|
+
const body = response.status === 204 ? null : await response.json().catch(() => null)
|
|
60
|
+
if (!response.ok) throw new BoardsError(response.status, body?.code ?? 'error', body?.detail ?? body?.title ?? `The board call failed (${response.status}).`)
|
|
61
|
+
return body
|
|
62
|
+
}
|
|
63
|
+
const at = (id: string) => `${path}/${encodeURIComponent(id)}`
|
|
64
|
+
return {
|
|
65
|
+
list: async (ref) => ((await call(ref ? `${path}?item=${encodeURIComponent(ref)}` : path))._embedded.boards as BoardJson<Item>[]).map(boardOf),
|
|
66
|
+
create: async (name, description) => boardOf(await call(path, { method: 'POST', body: JSON.stringify({ name, ...(description ? { description } : {}) }) })),
|
|
67
|
+
get: async (id) => boardOf(await call(at(id))),
|
|
68
|
+
rename: async (id, change) => boardOf(await call(at(id), { method: 'PATCH', body: JSON.stringify(change) })),
|
|
69
|
+
remove: async (id) => void (await call(at(id), { method: 'DELETE' })),
|
|
70
|
+
save: async (id, ref, note) => boardOf(await call(`${at(id)}/items/${encodeURIComponent(ref)}`, { method: 'PUT', body: JSON.stringify(note ? { note } : {}) })),
|
|
71
|
+
unsave: async (id, ref) => void (await call(`${at(id)}/items/${encodeURIComponent(ref)}`, { method: 'DELETE' })),
|
|
72
|
+
suggestions: async (id, limit) => (await call(`${at(id)}/suggestions${limit ? `?limit=${limit}` : ''}`))._embedded.items as Item[],
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React hooks over `boards-client.ts`: the owner's boards and where one item is saved, with no markup. Each app draws
|
|
3
|
+
* its own lists, menus and save buttons.
|
|
4
|
+
*
|
|
5
|
+
* useBoards every board, and making, renaming and deleting them
|
|
6
|
+
* useItemBoards the boards holding one item, and saving it to or taking it off a board
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* const boards = useMemo(() => createBoardsClient<RecipeCard>(), [])
|
|
10
|
+
* const { boards: all, create } = useBoards(boards)
|
|
11
|
+
* const { holding, toggle } = useItemBoards(boards, `recipe:${recipe.slug}`)
|
|
12
|
+
* all.map((board) => <label key={board.id}><input type="checkbox" checked={holding.has(board.id)} onChange={() => toggle(board.id)} />{board.name}</label>)
|
|
13
|
+
*/
|
|
14
|
+
import { useCallback, useEffect, useState } from 'react'
|
|
15
|
+
import type { Board, BoardsClient, BoardsError } from './boards-client'
|
|
16
|
+
|
|
17
|
+
type Status = 'loading' | 'ready' | 'error'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Every board, newest change first, reloaded after each change made through it.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* const { boards, status, create, rename, remove } = useBoards(client)
|
|
24
|
+
*/
|
|
25
|
+
export const useBoards = <Item>(client: BoardsClient<Item>) => {
|
|
26
|
+
const [boards, setBoards] = useState<Board<Item>[]>([])
|
|
27
|
+
const [status, setStatus] = useState<Status>('loading')
|
|
28
|
+
const [error, setError] = useState<BoardsError | null>(null)
|
|
29
|
+
const reload = useCallback(async () => {
|
|
30
|
+
try {
|
|
31
|
+
setBoards(await client.list())
|
|
32
|
+
setStatus('ready')
|
|
33
|
+
setError(null)
|
|
34
|
+
} catch (caught) {
|
|
35
|
+
setStatus('error')
|
|
36
|
+
setError(caught as BoardsError)
|
|
37
|
+
}
|
|
38
|
+
}, [client])
|
|
39
|
+
useEffect(() => void reload(), [reload])
|
|
40
|
+
const after =
|
|
41
|
+
<Args extends unknown[], Result>(change: (...args: Args) => Promise<Result>) =>
|
|
42
|
+
async (...args: Args) => {
|
|
43
|
+
const result = await change(...args)
|
|
44
|
+
await reload()
|
|
45
|
+
return result
|
|
46
|
+
}
|
|
47
|
+
return { boards, status, error, reload, create: after(client.create), rename: after(client.rename), remove: after(client.remove) }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Where one item is saved: the ids of the boards holding it, and saving it to or taking it off one. Stays idle without
|
|
52
|
+
* a ref.
|
|
53
|
+
*
|
|
54
|
+
* @example
|
|
55
|
+
* const { holding, toggle, save } = useItemBoards(client, `recipe:${slug}`)
|
|
56
|
+
*/
|
|
57
|
+
export const useItemBoards = <Item>(client: BoardsClient<Item>, ref: string | null | undefined) => {
|
|
58
|
+
const [holding, setHolding] = useState<Set<string>>(new Set())
|
|
59
|
+
const reload = useCallback(async () => setHolding(ref ? new Set((await client.list(ref)).map((board) => board.id)) : new Set()), [client, ref])
|
|
60
|
+
useEffect(() => void reload().catch(() => setHolding(new Set())), [reload])
|
|
61
|
+
const save = async (boardId: string, note?: string) => {
|
|
62
|
+
if (!ref) return
|
|
63
|
+
await client.save(boardId, ref, note)
|
|
64
|
+
await reload()
|
|
65
|
+
}
|
|
66
|
+
const toggle = async (boardId: string) => {
|
|
67
|
+
if (!ref) return
|
|
68
|
+
if (holding.has(boardId)) await client.unsave(boardId, ref)
|
|
69
|
+
else await client.save(boardId, ref)
|
|
70
|
+
await reload()
|
|
71
|
+
}
|
|
72
|
+
return { holding, save, toggle, reload }
|
|
73
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The browser side of `vector-search`, without a framework: a typed client for its three routes, a controller that
|
|
3
|
+
* holds one search's state (words debounced, a picture at once, every new search cancelling the last), and the
|
|
4
|
+
* helpers that turn a paste or a drop into a picture. A plain viewer uses these directly; React apps use
|
|
5
|
+
* `search-react.ts`, which wraps the same controller. Nothing here draws anything: each app renders its own results.
|
|
6
|
+
*
|
|
7
|
+
* Requests run with the page's own sign-in (same-origin cookies), so the browser never holds an API key. A failed
|
|
8
|
+
* call is a `SearchError` carrying the API's problem: `code`, `detail`, and `retryAfter` for a 429 or 503.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* const client = createSearchClient<RecipeCard>({ path: '/api/search', similarPath: '/api/recipes/{id}/similar' })
|
|
12
|
+
* const search = createSearchController(client)
|
|
13
|
+
* search.subscribe(() => render(search.getState()))
|
|
14
|
+
* input.addEventListener('input', () => search.setWords(input.value))
|
|
15
|
+
* listenForPictures(window, (picture) => search.setPicture(picture))
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export type SearchBy = 'look' | 'meaning' | 'both'
|
|
19
|
+
export type SearchResult<Item> = { score: number; z: number | null; space: 'look' | 'meaning'; item: Item }
|
|
20
|
+
/** `closest`: only when nothing cleared the bar, the nearest few below it (not matches; show them as such). */
|
|
21
|
+
export type SearchAnswer<Item> = { query: string | null; by: SearchBy; results: SearchResult<Item>[]; closest: SearchResult<Item>[] }
|
|
22
|
+
export type SearchFilters = Record<string, string | number | boolean | undefined>
|
|
23
|
+
export type SearchOptions = { by?: SearchBy; limit?: number; filters?: SearchFilters; signal?: AbortSignal }
|
|
24
|
+
|
|
25
|
+
/** A failed search, as the API's problem+json described it. */
|
|
26
|
+
export class SearchError extends Error {
|
|
27
|
+
readonly status: number
|
|
28
|
+
readonly code: string
|
|
29
|
+
readonly detail: string
|
|
30
|
+
readonly retryAfter: number | null
|
|
31
|
+
|
|
32
|
+
constructor(status: number, code: string, detail: string, retryAfter: number | null = null) {
|
|
33
|
+
super(detail)
|
|
34
|
+
this.status = status
|
|
35
|
+
this.code = code
|
|
36
|
+
this.detail = detail
|
|
37
|
+
this.retryAfter = retryAfter
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type SearchClient<Item> = {
|
|
42
|
+
/** Items that match words. */
|
|
43
|
+
byWords: (text: string, options?: SearchOptions) => Promise<SearchAnswer<Item>>
|
|
44
|
+
/** Items that look like a picture: a file or blob, or a data URL. */
|
|
45
|
+
byPicture: (picture: Blob | string, options?: SearchOptions) => Promise<SearchAnswer<Item>>
|
|
46
|
+
/** Items like one, by its id. */
|
|
47
|
+
similar: (id: string, options?: SearchOptions) => Promise<SearchAnswer<Item>>
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const queryOf = ({ by, limit, filters = {} }: SearchOptions) => {
|
|
51
|
+
const params = new URLSearchParams()
|
|
52
|
+
if (by) params.set('by', by)
|
|
53
|
+
if (limit) params.set('limit', String(limit))
|
|
54
|
+
for (const [name, value] of Object.entries(filters)) if (value !== undefined && value !== '') params.set(name, String(value))
|
|
55
|
+
return params
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A client for the routes `registerVectorSearch` serves. `fetchImpl` and `headers` are for tests and for callers
|
|
60
|
+
* outside the page (a key in a script); a page needs neither.
|
|
61
|
+
*
|
|
62
|
+
* @example
|
|
63
|
+
* const client = createSearchClient<RecipeCard>({ path: '/api/search', similarPath: '/api/recipes/{slug}/similar' })
|
|
64
|
+
* const { results } = await client.byWords('a fox made of triangles', { by: 'look', filters: { decade: '1970s' } })
|
|
65
|
+
*/
|
|
66
|
+
export const createSearchClient = <Item>({ path, similarPath, fetchImpl = (...args) => fetch(...args), headers = {} }: { path: string; similarPath?: string; fetchImpl?: typeof fetch; headers?: Record<string, string> }): SearchClient<Item> => {
|
|
67
|
+
const call = async (url: string, init: RequestInit = {}): Promise<SearchAnswer<Item>> => {
|
|
68
|
+
const response = await fetchImpl(url, { credentials: 'same-origin', ...init, headers: { accept: 'application/json', ...headers, ...(init.headers as Record<string, string>) } })
|
|
69
|
+
const body = (await response.json().catch(() => null)) as { query?: string | null; by?: SearchBy; _embedded?: { results?: SearchResult<Item>[]; closest?: SearchResult<Item>[] }; code?: string; detail?: string; title?: string } | null
|
|
70
|
+
if (!response.ok) {
|
|
71
|
+
const retry = Number(response.headers.get('retry-after'))
|
|
72
|
+
throw new SearchError(response.status, body?.code ?? 'error', body?.detail ?? body?.title ?? `The search failed (${response.status}).`, Number.isFinite(retry) && retry > 0 ? retry : null)
|
|
73
|
+
}
|
|
74
|
+
return { query: body?.query ?? null, by: body?.by ?? 'both', results: body?._embedded?.results ?? [], closest: body?._embedded?.closest ?? [] }
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
byWords: (text, options = {}) => {
|
|
78
|
+
const params = queryOf(options)
|
|
79
|
+
params.set('q', text)
|
|
80
|
+
return call(`${path}?${params}`, { signal: options.signal })
|
|
81
|
+
},
|
|
82
|
+
byPicture: (picture, options = {}) => {
|
|
83
|
+
const params = queryOf(options)
|
|
84
|
+
const suffix = params.size ? `?${params}` : ''
|
|
85
|
+
if (typeof picture === 'string') return call(`${path}${suffix}`, { method: 'POST', body: JSON.stringify({ image: picture }), headers: { 'content-type': 'application/json' }, signal: options.signal })
|
|
86
|
+
const form = new FormData()
|
|
87
|
+
form.append('image', picture)
|
|
88
|
+
return call(`${path}${suffix}`, { method: 'POST', body: form, signal: options.signal })
|
|
89
|
+
},
|
|
90
|
+
similar: (id, options = {}) => {
|
|
91
|
+
if (!similarPath) return Promise.reject(new SearchError(404, 'not_found', 'This client has no similarPath.'))
|
|
92
|
+
const params = queryOf(options)
|
|
93
|
+
return call(`${similarPath.replace(/\{\w+\}/, encodeURIComponent(id))}${params.size ? `?${params}` : ''}`, { signal: options.signal })
|
|
94
|
+
},
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** What is being searched for: nothing yet, words, a picture (with an object URL to show it), or one item's likeness. */
|
|
99
|
+
export type SearchInput = { kind: 'idle' } | { kind: 'words'; text: string } | { kind: 'picture'; picture: Blob; preview: string } | { kind: 'similar'; id: string }
|
|
100
|
+
|
|
101
|
+
export type SearchState<Item> = {
|
|
102
|
+
input: SearchInput
|
|
103
|
+
by: SearchBy
|
|
104
|
+
filters: SearchFilters
|
|
105
|
+
status: 'idle' | 'loading' | 'done' | 'error'
|
|
106
|
+
results: SearchResult<Item>[]
|
|
107
|
+
/** The nearest below the bar when `results` is empty. */
|
|
108
|
+
closest: SearchResult<Item>[]
|
|
109
|
+
error: SearchError | null
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export type SearchController<Item> = {
|
|
113
|
+
getState: () => SearchState<Item>
|
|
114
|
+
/** Calls `listener` after every change; returns the unsubscribe. */
|
|
115
|
+
subscribe: (listener: () => void) => () => void
|
|
116
|
+
/** Words, debounced; fewer than `minLength` characters clears the results. */
|
|
117
|
+
setWords: (text: string) => void
|
|
118
|
+
setPicture: (picture: Blob) => void
|
|
119
|
+
similarTo: (id: string) => void
|
|
120
|
+
/** Reruns the current search with the new setting. */
|
|
121
|
+
setBy: (by: SearchBy) => void
|
|
122
|
+
setFilters: (filters: SearchFilters) => void
|
|
123
|
+
retry: () => void
|
|
124
|
+
clear: () => void
|
|
125
|
+
/** Cancels what is running and frees the picture preview. */
|
|
126
|
+
dispose: () => void
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* One search's state over a client: words wait `debounceMs` after the last keystroke, a picture or a likeness runs at
|
|
131
|
+
* once, and a newer search aborts an older one, so results never arrive out of order. `by` defaults to `both` for
|
|
132
|
+
* words and `look` for pictures and likeness, until set.
|
|
133
|
+
*
|
|
134
|
+
* @example
|
|
135
|
+
* const search = createSearchController(client, { limit: 48 })
|
|
136
|
+
* search.subscribe(() => paint(search.getState()))
|
|
137
|
+
* search.setWords('bauhaus lettermark')
|
|
138
|
+
*/
|
|
139
|
+
export const createSearchController = <Item>(client: SearchClient<Item>, { debounceMs = 250, minLength = 2, limit, by: fixedBy, filters: initialFilters = {} }: { debounceMs?: number; minLength?: number; limit?: number; by?: SearchBy; filters?: SearchFilters } = {}): SearchController<Item> => {
|
|
140
|
+
let state: SearchState<Item> = { input: { kind: 'idle' }, by: fixedBy ?? 'both', filters: initialFilters, status: 'idle', results: [], closest: [], error: null }
|
|
141
|
+
let chosenBy = fixedBy
|
|
142
|
+
const listeners = new Set<() => void>()
|
|
143
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
144
|
+
let running: AbortController | undefined
|
|
145
|
+
|
|
146
|
+
const set = (next: Partial<SearchState<Item>>) => {
|
|
147
|
+
state = { ...state, ...next }
|
|
148
|
+
listeners.forEach((listener) => listener())
|
|
149
|
+
}
|
|
150
|
+
const freePreview = (input: SearchInput) => {
|
|
151
|
+
if (input.kind === 'picture') URL.revokeObjectURL(input.preview)
|
|
152
|
+
}
|
|
153
|
+
const byFor = (input: SearchInput): SearchBy => chosenBy ?? (input.kind === 'words' ? 'both' : 'look')
|
|
154
|
+
|
|
155
|
+
const run = () => {
|
|
156
|
+
clearTimeout(timer)
|
|
157
|
+
running?.abort()
|
|
158
|
+
const { input } = state
|
|
159
|
+
if (input.kind === 'idle') return set({ status: 'idle', results: [], closest: [], error: null })
|
|
160
|
+
const controller = new AbortController()
|
|
161
|
+
running = controller
|
|
162
|
+
const by = byFor(input)
|
|
163
|
+
const options = { by: input.kind === 'similar' && by === 'both' ? 'look' : by, limit, filters: state.filters, signal: controller.signal } as const
|
|
164
|
+
set({ status: 'loading', by, error: null })
|
|
165
|
+
const answer = input.kind === 'words' ? client.byWords(input.text, options) : input.kind === 'picture' ? client.byPicture(input.picture, options) : client.similar(input.id, options)
|
|
166
|
+
answer.then(
|
|
167
|
+
({ results, closest }) => controller === running && set({ status: 'done', results, closest }),
|
|
168
|
+
(error: unknown) => {
|
|
169
|
+
if (controller !== running || controller.signal.aborted) return
|
|
170
|
+
set({ status: 'error', error: error instanceof SearchError ? error : new SearchError(0, 'network', 'The search could not reach the server. Check the connection and retry.') })
|
|
171
|
+
},
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
const start = (input: SearchInput, wait = 0) => {
|
|
175
|
+
freePreview(state.input)
|
|
176
|
+
clearTimeout(timer)
|
|
177
|
+
set({ input })
|
|
178
|
+
if (wait) timer = setTimeout(run, wait)
|
|
179
|
+
else run()
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
getState: () => state,
|
|
184
|
+
subscribe: (listener) => {
|
|
185
|
+
listeners.add(listener)
|
|
186
|
+
return () => listeners.delete(listener)
|
|
187
|
+
},
|
|
188
|
+
setWords: (text) => (text.trim().length < minLength ? start({ kind: 'idle' }) : start({ kind: 'words', text: text.trim() }, debounceMs)),
|
|
189
|
+
setPicture: (picture) => start({ kind: 'picture', picture, preview: URL.createObjectURL(picture) }),
|
|
190
|
+
similarTo: (id) => start({ kind: 'similar', id }),
|
|
191
|
+
setBy: (by) => {
|
|
192
|
+
chosenBy = by
|
|
193
|
+
set({ by })
|
|
194
|
+
run()
|
|
195
|
+
},
|
|
196
|
+
setFilters: (filters) => {
|
|
197
|
+
set({ filters })
|
|
198
|
+
run()
|
|
199
|
+
},
|
|
200
|
+
retry: run,
|
|
201
|
+
clear: () => start({ kind: 'idle' }),
|
|
202
|
+
dispose: () => {
|
|
203
|
+
clearTimeout(timer)
|
|
204
|
+
running?.abort()
|
|
205
|
+
freePreview(state.input)
|
|
206
|
+
},
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const IMAGE = /^image\/(png|jpeg|webp|gif)$/
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* The first picture in a paste, a drop or a file list, or null.
|
|
214
|
+
*
|
|
215
|
+
* @example
|
|
216
|
+
* window.addEventListener('paste', (event) => { const picture = pictureFrom(event.clipboardData) })
|
|
217
|
+
*/
|
|
218
|
+
export const pictureFrom = (source: DataTransfer | FileList | null | undefined): File | null => {
|
|
219
|
+
if (!source) return null
|
|
220
|
+
const files = 'items' in source ? Array.from(source.items, (item) => (item.kind === 'file' ? item.getAsFile() : null)) : Array.from(source)
|
|
221
|
+
return files.find((file): file is File => !!file && IMAGE.test(file.type)) ?? null
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Listens on `target` (the window, or a drop zone) for a pasted (⌘V) or dropped picture. A paste without a picture,
|
|
226
|
+
* or one another handler already took, is left alone, so typing and pasting text keep working. Returns the cleanup.
|
|
227
|
+
*
|
|
228
|
+
* @example
|
|
229
|
+
* const stop = listenForPictures(window, (picture) => search.setPicture(picture))
|
|
230
|
+
*/
|
|
231
|
+
export const listenForPictures = (target: EventTarget, onPicture: (picture: File) => void, { paste = true, drop = true }: { paste?: boolean; drop?: boolean } = {}) => {
|
|
232
|
+
const onPaste = (event: Event) => {
|
|
233
|
+
const clipboard = (event as ClipboardEvent).clipboardData
|
|
234
|
+
const picture = event.defaultPrevented ? null : pictureFrom(clipboard)
|
|
235
|
+
if (!picture) return
|
|
236
|
+
event.preventDefault()
|
|
237
|
+
onPicture(picture)
|
|
238
|
+
}
|
|
239
|
+
const onDragOver = (event: Event) => {
|
|
240
|
+
if ((event as DragEvent).dataTransfer?.types.includes('Files')) event.preventDefault()
|
|
241
|
+
}
|
|
242
|
+
const onDrop = (event: Event) => {
|
|
243
|
+
const picture = pictureFrom((event as DragEvent).dataTransfer?.files)
|
|
244
|
+
if (!picture) return
|
|
245
|
+
event.preventDefault()
|
|
246
|
+
onPicture(picture)
|
|
247
|
+
}
|
|
248
|
+
if (paste) target.addEventListener('paste', onPaste)
|
|
249
|
+
if (drop) {
|
|
250
|
+
target.addEventListener('dragover', onDragOver)
|
|
251
|
+
target.addEventListener('drop', onDrop)
|
|
252
|
+
}
|
|
253
|
+
return () => {
|
|
254
|
+
target.removeEventListener('paste', onPaste)
|
|
255
|
+
target.removeEventListener('dragover', onDragOver)
|
|
256
|
+
target.removeEventListener('drop', onDrop)
|
|
257
|
+
}
|
|
258
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React hooks over `search-client.ts`: the state and actions of a search, with no markup. Each app draws its own search
|
|
3
|
+
* box, picture chip and results; `vector-search.tsx` is one suggested way to, which an app can copy, restyle or skip.
|
|
4
|
+
*
|
|
5
|
+
* useVectorSearch one search (words, a picture, or one item's likeness) and what to do with it
|
|
6
|
+
* usePictureInput paste (⌘V), drop and a file picker that hand over a picture
|
|
7
|
+
* useSimilar items like one, reloaded when the id changes
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* const client = useMemo(() => createSearchClient<RecipeCard>({ path: '/api/search', similarPath: '/api/recipes/{slug}/similar' }), [])
|
|
11
|
+
* const search = useVectorSearch(client, { limit: 48 })
|
|
12
|
+
* const picture = usePictureInput({ onPicture: search.setPicture })
|
|
13
|
+
* <input value={text} onChange={(event) => { setText(event.target.value); search.setWords(event.target.value) }} />
|
|
14
|
+
* <div {...picture.dropProps}>{search.results.map((result) => <Card key={result.item.id} {...result.item} />)}</div>
|
|
15
|
+
*/
|
|
16
|
+
import { useCallback, useEffect, useRef, useState, useSyncExternalStore, type ChangeEvent, type DragEvent } from 'react'
|
|
17
|
+
import { createSearchController, listenForPictures, pictureFrom, type SearchBy, type SearchClient, type SearchController, type SearchFilters, type SearchState } from './search-client'
|
|
18
|
+
|
|
19
|
+
type ControllerOptions = Parameters<typeof createSearchController>[1]
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* One search's state and actions. The controller lives as long as the component; a new client starts a new one.
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* const { status, results, error, input, setWords, setPicture, setBy, clear } = useVectorSearch(client)
|
|
26
|
+
*/
|
|
27
|
+
export const useVectorSearch = <Item>(client: SearchClient<Item>, options: ControllerOptions = {}): SearchState<Item> & Omit<SearchController<Item>, 'getState' | 'subscribe' | 'dispose'> => {
|
|
28
|
+
const [controller, setController] = useState(() => createSearchController(client, options))
|
|
29
|
+
const made = useRef(client)
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
if (made.current === client) return
|
|
32
|
+
made.current = client
|
|
33
|
+
setController(createSearchController(client, options))
|
|
34
|
+
// Options are read once per client, like a store's initial state
|
|
35
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
36
|
+
}, [client])
|
|
37
|
+
useEffect(() => () => controller.dispose(), [controller])
|
|
38
|
+
const state = useSyncExternalStore(controller.subscribe, controller.getState, controller.getState)
|
|
39
|
+
const { setWords, setPicture, similarTo, setBy, setFilters, retry, clear } = controller
|
|
40
|
+
return { ...state, setWords, setPicture, similarTo, setBy, setFilters, retry, clear }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const ACCEPT = 'image/png,image/jpeg,image/webp,image/gif'
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Paste, drop and pick a picture. Pastes are heard on the whole window by default (`paste: 'window'`), or only while
|
|
47
|
+
* focus is inside `scope`; `dropProps` go on whatever should take a drop, `inputProps` on a hidden file input.
|
|
48
|
+
*
|
|
49
|
+
* @example
|
|
50
|
+
* const picture = usePictureInput({ onPicture: search.setPicture })
|
|
51
|
+
* <div {...picture.dropProps} data-dragging={picture.dragging}>…<button onClick={picture.openPicker}>Choose a picture</button><input {...picture.inputProps} /></div>
|
|
52
|
+
*/
|
|
53
|
+
export const usePictureInput = ({ onPicture, paste = 'window', scope }: { onPicture: (picture: File) => void; paste?: 'window' | 'scope' | false; scope?: { current: HTMLElement | null } }) => {
|
|
54
|
+
const [dragging, setDragging] = useState(false)
|
|
55
|
+
const input = useRef<HTMLInputElement>(null)
|
|
56
|
+
const latest = useRef(onPicture)
|
|
57
|
+
latest.current = onPicture
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
const target = paste === 'window' ? window : paste === 'scope' ? scope?.current : null
|
|
60
|
+
return target ? listenForPictures(target, (picture) => latest.current(picture), { drop: false }) : undefined
|
|
61
|
+
}, [paste, scope])
|
|
62
|
+
const take = useCallback((picture: File | null) => picture && latest.current(picture), [])
|
|
63
|
+
return {
|
|
64
|
+
dragging,
|
|
65
|
+
openPicker: () => input.current?.click(),
|
|
66
|
+
dropProps: {
|
|
67
|
+
onDragOver: (event: DragEvent) => {
|
|
68
|
+
if (!event.dataTransfer.types.includes('Files')) return
|
|
69
|
+
event.preventDefault()
|
|
70
|
+
setDragging(true)
|
|
71
|
+
},
|
|
72
|
+
onDragLeave: () => setDragging(false),
|
|
73
|
+
onDrop: (event: DragEvent) => {
|
|
74
|
+
setDragging(false)
|
|
75
|
+
const picture = pictureFrom(event.dataTransfer.files)
|
|
76
|
+
if (!picture) return
|
|
77
|
+
event.preventDefault()
|
|
78
|
+
take(picture)
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
inputProps: {
|
|
82
|
+
ref: input,
|
|
83
|
+
type: 'file' as const,
|
|
84
|
+
accept: ACCEPT,
|
|
85
|
+
hidden: true,
|
|
86
|
+
onChange: (event: ChangeEvent<HTMLInputElement>) => {
|
|
87
|
+
take(pictureFrom(event.target.files))
|
|
88
|
+
event.target.value = ''
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Items like one, by look (default) or meaning; reloads when the id, `by` or the filters change, and stays idle
|
|
96
|
+
* without an id.
|
|
97
|
+
*
|
|
98
|
+
* @example
|
|
99
|
+
* const { status, results } = useSimilar(client, recipe.slug, { by: 'look', limit: 12 })
|
|
100
|
+
*/
|
|
101
|
+
export const useSimilar = <Item>(client: SearchClient<Item>, id: string | null | undefined, { by = 'look', limit, filters }: { by?: Exclude<SearchBy, 'both'>; limit?: number; filters?: SearchFilters } = {}) => {
|
|
102
|
+
const search = useVectorSearch(client, { by, limit, filters })
|
|
103
|
+
const filterKey = JSON.stringify(filters ?? {})
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
if (!id) return search.clear()
|
|
106
|
+
search.similarTo(id)
|
|
107
|
+
// The controller's actions are stable; the inputs are the id and settings
|
|
108
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
109
|
+
}, [id])
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
if (search.by !== by) search.setBy(by)
|
|
112
|
+
if (JSON.stringify(search.filters) !== filterKey) search.setFilters(filters ?? {})
|
|
113
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
114
|
+
}, [by, filterKey])
|
|
115
|
+
return { status: search.status, results: search.results, error: search.error, retry: search.retry }
|
|
116
|
+
}
|