Русский
Приложение поверх trip2g
Свой шаблон не обязан рисовать страницу. Он может отдать JavaScript-приложение, которое показывает заметку как что-то интерактивное и записывает изменения обратно. Заметка остаётся обычным markdown: Obsidian, агент и приложение правят один и тот же файл.
По сути это интерактивное приложение-представление: заметка, показанная как приложение, а markdown-файлы — его база данных. Одна заметка — дом приложения, а читать и писать оно может и многие другие.
Так устроен шаблон канбан-доски. Он превращает заметку в формате obsidian-kanban в доску с перетаскиванием карточек, и каждое перемещение сохраняется обратно в markdown заметки. Эта страница описывает схему, по которой он сделан, чтобы вы могли собрать своё приложение: чек-лист, редактор таблицы, конструктор форм, дашборд.
Если вы ещё не делали шаблоны, начните с Шаблонов.
Три части
| Часть | Где живёт | Что делает |
|---|---|---|
| Шаблон | _layouts/app.html в хранилище |
Кладёт данные заметки в страницу, элемент для монтирования и <script>, который загружает приложение |
| Бандл приложения | Рядом с шаблоном или по любому URL | Читает данные со страницы, рисует интерфейс |
| GraphQL API | /_system/graphql на вашем сайте |
Сохраняет отредактированный markdown обратно в заметку |
Заметка включает приложение строкой layout: app во frontmatter, как и любой другой шаблон.
Как приложение получает доступ: cookie сессии
Своего входа у приложения нет. Оно пользуется сессией посетителя в trip2g:
- Посетитель входит кнопкой в стандартной шапке. Шаблон выводит шапку trip2g на страницу, см. Обернуть приложение в стандартную шапку и подвал. После успешного входа страница перезагружается.
- Сервер ставит cookie сессии. Она
HttpOnly(JavaScript страницы её не прочитает),SameSite=Laxи принадлежит хосту, на котором посетитель вошёл. - Запросы самого приложения несут её.
fetch('/_system/graphql', …)со страницы идёт на тот же origin, и браузер сам прикладывает cookie. Сервер читает её и выполняет запрос от имени этого пользователя: вошедший админ может читать и записывать заметки.
Поэтому перезагруженная страница рисуется уже для вошедшего пользователя: у админа currentUser.IsAdmin() теперь true, а с ним и флаг editable приложения.
Делайте так:
- Обращайтесь к API по относительному адресу
/_system/graphqlсо страницы, которую отдаёт сам сайт: шаблон и его файлы изasset()или бандл, который этот шаблон загружает. - Оставьте у
fetchcredentials по умолчанию (same-origin) или укажитеcredentials: 'include', как в функциях на этой странице; для запроса на тот же origin это одно и то же. Никогда не ставьтеcredentials: 'omit'. - Отправляйте операции методом
POSTсContent-Type: application/json; загрузка файла идёт какmultipart/form-data. Отправленное иначе эндпоинт не выполняет. - Решайте, показывать ли кнопки редактирования, по
currentUser.IsAdmin()в шаблоне и передавайте это приложению флагом, напримерeditable.
Не делайте так:
- Не кладите в страницу API-ключ или личный токен. Их прочитает любой, кто откроет страницу. Учётные данные — это cookie.
- Не делайте свой экран входа. Вход — работа шапки; после него страница перезагружается уже с сессией.
- Не отдавайте приложение с другого origin, например с отдельного dev-сервера. Браузер не отправляет cookie
SameSite=Laxс кросс-сайтовымPOST, а запросы с другого origin сервер разрешает только плагину Obsidian.
Ни CSRF-токена, ни особого заголовка. Кроме cookie эндпоинт ничего не требует: ни CSRF-токена, ни своего заголовка. Форма на чужом сайте сессией не воспользуется, потому что браузер не прикладывает cookie SameSite=Lax к кросс-сайтовому POST.
Посетитель, который не вошёл, открывает страницу и приложение как обычно. currentUser.IsAdmin() — false, значит и editable — false: покажите заметку только для чтения и укажите на кнопку входа в шапке, например строкой «Войдите, чтобы редактировать». В API по-прежнему работает search; попытка записи вернёт запись в errors. Вошедший читатель, который не админ, получит на запись тот же отказ. Кто до чего дотягивается — в таблице раздела Готовые GraphQL-запросы.
Шаблон: заметка внутри страницы
Шаблон даёт приложению три вещи: путь заметки в хранилище, её исходный markdown и то, может ли посетитель редактировать. json() кладёт их в элемент <script type="application/json">:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{{ title }}</title>
</head>
<body>
<div id="app"></div>
<script type="application/json" id="app-data">
{{ json(map(
"path", note.Path(),
"content", note.ContentString(),
"editable", currentUser.IsAdmin()
)) }}
</script>
<script src="{{ asset("app.js") }}"></script>
</body>
</html>
title— переменная, которую получает каждый свой шаблон: заголовок заметки, пропущенный через шаблон заголовка сайта, готовый для<title>. С шаблоном по умолчанию,%s, это сам заголовок. Голый заголовок в другом месте страницы даётnote.Title().note.ContentString()— исходный markdown заметки вместе с frontmatter. Это тот же текст, от которого сервер считает хеш для проверки конфликтов, поэтому приложение может его изменить и отправить обратно.json()экранирует<,>и&как<…, поэтому markdown с</script>внутри не закроет элемент.JSON.parseвернёт текст в точности.currentUser.IsAdmin()решает только, показывает ли приложение кнопки редактирования. Каждое сохранение сервер проверяет заново (см. ниже).- Обращение к
currentUserделает страницу персонализированной: она не отдаётся из анонимного кэша страниц, поэтому приложение всегда стартует с текущей версии заметки.
Шаблон канбана передаёт те же данные иначе: markdown — в скрытой <textarea>, путь — в скрытом <span>, оба выведены обычным {{ … }}. Это тоже безопасно, потому что вывод экранируется по умолчанию: заметка с </textarea> попадёт на страницу как </textarea>, и браузер декодирует её обратно. Но у <textarea> две особенности: браузер приводит переводы строк к \n и выбрасывает перевод строки сразу после открывающего тега. У JSON-острова их нет.
Настройки приложения во frontmatter
Frontmatter заметки — удобное место для настроек приложения. Кто правит заметку, в Obsidian или агентом, меняет их вместе с содержимым, а один бандл обслуживает много заметок, у каждой свои настройки:
---
title: Todo
layout: app
columns: [Todo, Doing, Done]
wip_limit: 3
show_archive: false
---
Шаблон читает ключи через note.M() и добавляет их в блок данных как config:
<script type="application/json" id="app-data">
{{ json(map(
"path", note.Path(),
"content", note.ContentString(),
"editable", currentUser.IsAdmin(),
"config", map(
"columns", note.M().GetStrings("columns"),
"wipLimit", note.M().GetInt("wip_limit", 0),
"showArchive", note.M().GetBool("show_archive", true)
)
)) }}
</script>
Приложение находит их в data.config:
const { columns, wipLimit, showArchive } = data.config
Каждый метод возвращает значение по умолчанию, если ключа нет или у него не тот тип, поэтому заметка без настроек тоже откроется; GetStrings вернёт пустой список. Чтобы отдать приложению весь frontmatter, напишите "config", note.M().Raw(). Методы описаны в Шаблонах. Настройки — тоже часть content, поэтому сохранение, которое не трогает frontmatter, их сохраняет.
Обернуть приложение в стандартную шапку и подвал
Шаблон выше — голая страница. Чтобы у приложения была шапка сайта с кнопкой входа и подвал, выведите части дефолтного шаблона вокруг элемента для монтирования:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{{ title }}</title>
{{ defaultTemplate.Styles() }}
{{ defaultTemplate.UserSpaceScripts() }}
</head>
<body>
{{ defaultTemplate.Header() }}
<main id="app"></main>
<script type="application/json" id="app-data">
{{ json(map(
"path", note.Path(),
"content", note.ContentString(),
"editable", currentUser.IsAdmin()
)) }}
</script>
<script src="{{ asset("app.js") }}"></script>
{{ defaultTemplate.Footer() }}
</body>
</html>
| Вызов | Что выводит |
|---|---|
defaultTemplate.Styles() |
Теги <link> со стилями дефолтного шаблона, чтобы шапка и подвал выглядели как на других страницах |
defaultTemplate.UserSpaceScripts() |
<script> с настройками бандла, затем бандл user space. Бандл рисует в шапке кнопку входа и поиск. Настройки выводятся только при первом вызове |
defaultTemplate.Header() |
Шапку сайта: логотип, навигацию, кнопку входа и поиск |
defaultTemplate.Footer() |
Подвал сайта |
Header() и Footer() ничего не выводят, если у страницы нет заметки-шапки или заметки-подвала. Заметка берётся из header: / footer: во frontmatter страницы, подходящей layout-секции или заметки _header / _footer в хранилище: см. Функциональные заметки. Без шапки нет и кнопки входа. Чтобы она осталась, поставьте элемент монтирования бандла там, где нужна кнопка, и оставьте UserSpaceScripts() в <head>:
<div mol_view_root="$trip2g_user_space"></div>
В превью шаблона (/_system/renderlayout) все четыре вызова ничего не выводят. Полный список того, что получает шаблон, — в Функции Jet → Переменные в каждом своём шаблоне.
Бандл
Отдать JavaScript можно двумя способами:
- Рядом с шаблоном. Положите
app.jsрядом с_layouts/app.htmlи подключите через{{ asset("app.js") }}, как выше. В URL будет хеш для сброса кэша. См. yield_blocks, раздел проasset(). - Из релиза. Шаблон канбана загружает бандл по ссылке на релиз GitHub,
…/releases/latest/download/kanban.js. Пользователь ставит один HTML-файл и получает обновления, ничего не синхронизируя.
Бандл может быть на любом фреймворке или без него. Канбан — React-приложение, которое esbuild собирает в один самодостаточный файл.
Приложение читает данные при старте:
const data = JSON.parse(
document.getElementById('app-data').textContent
)
// data.path, data.content, data.editable
Списки других заметок
Приложение, которое показывает другие заметки, например посты с тегом или задачи из папки, получает их через запрос к заметкам: программу на Jsonnet в вашем хранилище, которая возвращает ровно тот JSON, который нужен приложению, и выполняется с правами посетителя.
Сохранение: мутация updateNotes
Приложение записывает заметку мутацией GraphQL updateNotes на /_system/graphql. Писать может вошедший админ сайта, остальные получат ошибку, поэтому браузер посетителя не изменит заметку, даже если приложение по ошибке покажет кнопки.
Любая операция — один и тот же POST с JSON-телом { query, variables }, поэтому хватит одной маленькой функции. makeRequest принимает операцию и возвращает функцию от её переменных:
const makeRequest = (query) => (variables) =>
fetch('/_system/graphql', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, variables }),
})
.then((res) => res.json())
.then((body) => {
if (body.errors) throw new Error(body.errors[0].message)
return body.data
})
const updateNotes = makeRequest(`mutation ($input: UpdateNotesInput!) {
updateNotes(input: $input) {
__typename
... on UpdateNotesSuccessPayload {
updated { path versionId warnings { level message } }
}
... on UpdateNotesHashMismatchPayload { path actualHash }
... on UpdateNotesPatchNotFoundPayload { path find }
... on ErrorPayload { message }
}
}`)
const change = { upsert: { path: data.path, content: newContent, expectedHash } }
const { updateNotes: result } = await updateNotes({ input: { changes: [change] } })
newContent — отредактированный markdown, expectedHash разобран ниже. Запрос идёт на тот же origin, поэтому браузер сам прикладывает cookie сессии; credentials: 'include' лишь говорит это явно. Результат показывает __typename:
__typename |
Что значит |
|---|---|
UpdateNotesSuccessPayload |
Сохранено; в updated каждая заметка с versionId, который записало сохранение, и её warnings |
UpdateNotesHashMismatchPayload |
Ничего не записано: заметка больше не соответствует expectedHash; actualHash — её текущий хеш |
UpdateNotesPatchNotFoundPayload |
Изменение patch не нашло свой текст find или нашло его больше одного раза |
ErrorPayload |
Отказ, причина в message |
Посетитель, который не админ, до результата не доходит: в ответе будет запись в errors и не будет data, и makeRequest превратит это в исключение.
expectedHash: сохранение применяется один раз и к тому тексту, от которого сделано
expectedHash — поле изменений upsert и patch в updateNotes. Оно привязывает изменение к версии заметки, от которой оно сделано. Перед записью сервер считает хеш текущего markdown заметки и сравнивает:
- Совпал: изменение записывается.
- Не совпал: ничего не записывается, а результат —
UpdateNotesHashMismatchPayloadс путём заметкиpathи её текущимactualHash.
Поэтому изменение применяется не больше одного раза. То же сохранение, отправленное дважды — повтором после таймаута, двойным кликом или из второй открытой вкладки, — несёт тот же expectedHash. Первое меняет заметку, второе уже не совпадает и получает отказ, а не применяется ещё раз. Эта же проверка не даёт приложению затереть правку, которую кто-то успел сделать в Obsidian или агентом.
Передавать нужно хеш того markdown, с которого начало приложение: latestContentHash из запроса ReadNote ниже или хеш data.content, который отдал шаблон. Хеш — SHA-256 от markdown в URL-safe base64 с дополнением =:
async function contentHash(text) {
const bytes = new TextEncoder().encode(text)
const digest = await crypto.subtle.digest('SHA-256', bytes)
return btoa(String.fromCharCode(...new Uint8Array(digest)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
}
const expectedHash = await contentHash(data.content)
Новый хеш успешное сохранение не возвращает. После upsert в заметке ровно тот markdown, который вы отправили, поэтому expectedHash следующего сохранения — contentHash(newContent); после patch перечитайте latestContentHash. Два значения особые: пустой expectedHash значит «только создать», и на существующей заметке сохранение не пройдёт, а изменение без expectedHash записывается без проверки.
На UpdateNotesHashMismatchPayload перезагрузите страницу или получите новое содержимое и примените своё изменение к нему. Канбан перечитывает последнюю версию админскими запросами noteVersionHistory и noteVersion и повторяет перемещение.
Маленькие правки. Вместо upsert, который заменяет заметку целиком, изменение может быть patch: { patch: { path, find, replace, expectedHash } } заменяет один точный кусок текста. Так канбан переключает чекбокс, и текст, который он не понимает, никогда не переписывается. Чтобы поменять frontmatter, есть изменение meta: { meta: { path, set, unset, expectedHash } } задаёт или удаляет ключи верхнего уровня и оставляет остальные строки заметки как были, так что приложению не нужен точный текст старого значения. Все три формы, пакеты изменений и ошибки — в updateNotes.
Валидаторы: держите данные приложения в порядке
Заметка — база данных приложения, и править её может кто угодно: вы в Obsidian, агент, само приложение. Ничто не мешает сломанному значению попасть в файл, и приложение на нём не падает. columns: Todo, Doing, Done без скобок — одна строка, поэтому GetStrings вернёт одну колонку с именем «Todo, Doing, Done». wip_limit: three — слово, а не число, поэтому GetInt вернёт значение по умолчанию. Доска откроется, будет выглядеть неправильно, и никто не поймёт почему. Подключите валидатор к каждой заметке, которую приложение читает как данные, и такая ошибка станет предупреждением на заметке.
Валидатор для настроек из раздела Настройки приложения во frontmatter, в заметке validators/app_config.md:
local isInt(v) = std.isNumber(v) && std.floor(v) == v;
local columns = std.get(meta, 'columns');
(
if !std.isArray(columns) || std.length(columns) == 0
then [{level: 'error', msg: 'columns must be a non-empty list'}]
else []
) + (
if std.objectHas(meta, 'wip_limit') && !isInt(meta.wip_limit)
then [{level: 'error', msg: 'wip_limit must be a whole number'}]
else []
) + (
if std.objectHas(meta, 'show_archive')
&& !std.isBoolean(meta.show_archive)
then [{level: 'warning', msg: 'show_archive must be true or false'}]
else []
)
Заметка с данными называет его во frontmatter:
validate: "[[app_config]]"
Валидатор не блокирует сохранение. Заметка записывается даже с error, а сообщения возвращаются в updated[].warnings ответа updateNotes вместе с остальными предупреждениями заметки. level — CRITICAL для error и WARNING для warning. Проверяйте их после каждого сохранения и показывайте пользователю, иначе сломанное значение, которое записало само приложение, останется незамеченным:
if (result.__typename === 'UpdateNotesSuccessPayload') {
const messages = result.updated
.flatMap((note) => note.warnings)
.map((w) => `${w.level}: ${w.message}`)
if (messages.length > 0) alert(messages.join('\n'))
}
alert — самый короткий способ; настоящее приложение показывает сообщения в своём интерфейсе. Те же сообщения видны в CLI синхронизации и в админке, см. Где виден результат.
Живые обновления
Чтобы видеть правки из Obsidian или от агента, пока приложение открыто, подпишитесь на noteChanges с фильтром по пути заметки. Подписки эндпоинт отдаёт только через server-sent events, на том же /_system/graphql: POST с Accept: text/event-stream. Транспорта WebSocket у него нет, так что клиент graphql-ws не подключится. EventSource не умеет POST, поэтому функция сама читает поток ответа:
const makeSubscription = (query) => async (variables, onData) => {
const res = await fetch('/_system/graphql', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Accept: 'text/event-stream',
},
body: JSON.stringify({ query, variables }),
})
const reader = res.body
.pipeThrough(new TextDecoderStream())
.getReader()
let buffer = ''
for (;;) {
const { done, value } = await reader.read()
if (done) return
buffer += value
const frames = buffer.split('\n\n')
buffer = frames.pop()
for (const frame of frames) {
const line = frame.match(/^data: (.*)$/m)
if (!frame.startsWith('event: next') || !line) continue
const body = JSON.parse(line[1])
if (body.errors) throw new Error(body.errors[0].message)
onData(body.data)
}
}
}
const watchNotes = makeSubscription(`subscription ($filter: NoteChangesFilter!) {
noteChanges(filter: $filter) {
changes {
__typename
... on NoteUpsertEvent { path versionId }
... on NoteHideEvent { path }
}
}
}`)
async function follow(path, onChange) {
for (;;) {
await watchNotes({ filter: { includePatterns: [path] } }, onChange)
.catch(() => {})
await new Promise((resolve) => setTimeout(resolve, 5000))
}
}
const ownVersions = new Set()
follow(data.path, ({ noteChanges }) => {
const foreign = noteChanges.changes
.some((change) => !ownVersions.has(change.versionId))
if (foreign) location.reload()
})
Сервер шлёт event: next с результатом, event: complete в конце и комментарий : ping каждые 30 секунд. Поток может оборваться, например при перезапуске сервера, поэтому follow после паузы подписывается снова.
Ваше собственное сохранение тоже придёт событием. Добавляйте каждый versionId из updated сохранения в ownVersions, и приложение пропустит своё эхо. location.reload() — самая простая реакция на чужую правку. Чтобы сохранить состояние приложения, перечитайте заметку через ReadNote и слейте изменения, как делает src/api.ts канбана. Подписке нужен вошедший посетитель: админ получает все подходящие заметки, другой вошедший читатель — только те, что ему можно читать. Glob-шаблоны фильтра разобраны в разделе Следить за изменениями.
Готовые GraphQL-запросы
Эти запросы можно копировать в приложение. Каждый уходит на /_system/graphql как POST с JSON-телом { query, variables }: операция — это query, JSON-блок под ней — variables. Оберните операцию в makeRequest из раздела Сохранение и вызовите с переменными. Здесь в READ_NOTE лежит операция ReadNote ниже:
const readNote = makeRequest(READ_NOTE)
const { notePaths } = await readNote({ paths: [data.path] })
Большинство результатов — union-типы: запросите __typename и по фрагменту ... on на каждый тип, потом ветвитесь по __typename. Отказ, который приложение должно показать, приходит типом результата, например ErrorPayload. Вызов без доступа получает запись в errors, и makeRequest превращает её в исключение.
Кто что может вызвать. Подробности — в updateNotes → Авторизация.
| Кто | Что отправляет | Что доступно |
|---|---|---|
| Вошедший админ | Cookie сессии или Authorization: Bearer t2g_… |
Всё ниже, включая admin { … } |
| API-ключ | X-Api-Key: … |
Всё, кроме admin { … }; запись только в пределах write patterns ключа |
| Токен вебхука | Authorization: Bearer eyJ… |
То же, что API-ключу, в пределах read и write patterns токена, кроме hideNotes, pushNotes и commitNotes |
| Любой посетитель | Ничего | search, submitForm, а после входа — noteChanges |
Приложение на странице, которую открывают посетители, авторизуется cookie сессии админа, её отправляет credentials: 'include'. Не кладите API-ключ в бандл: его прочитает любой, кто загрузит страницу.
Прочитать markdown заметки
query ReadNote($paths: [String!]) {
notePaths(filter: { paths: $paths }) {
value
content
latestContentHash
}
}
{ "paths": ["boards/todo.md"] }
value — путь в хранилище, content — markdown вместе с frontmatter. latestContentHash — тот самый хеш, который принимает expectedHash, так что считать его в приложении не нужно. Несуществующего пути в списке просто не будет.
Список заметок
query ListNotes($filter: NotePathsFilter) {
notePaths(filter: $filter) {
value
latestContentHash
latestNoteView {
title
url
}
}
}
{ "filter": { "like": "boards/%" } }
{ "filter": { "frontmatter": [{ "key": "layout", "equals": "app" }] } }
like — шаблон SQL LIKE: % — любая последовательность символов, _ — один символ. Фильтр принимает ещё search (полнотекстовый поиск по заметкам) и paths. Если задано несколько, paths важнее search, а search важнее like. frontmatter сужает любой из них. Без фильтра вернутся все пути хранилища, кроме скрытых.
Поиск
query Search($input: SearchInput!) {
search(input: $input) {
totalCount
nodes {
url
highlightedTitle
highlightedContent
document {
... on PublicNote {
path
notePath
title
}
}
}
}
}
{ "input": { "query": "release plan" } }
Это поиск сайта, он открыт любому посетителю. Заметки, которые посетителю читать нельзя, идут в конце: с заголовком и URL, но без document. Админ ищет по последним версиям, остальные — по опубликованным, если сайт не показывает черновики всем. path — путь заметки в URL (/people/anna), notePath — её путь в хранилище (people/Anna.md), тот, что принимают notePaths и updateNotes.
Сохранить заметку целиком
mutation SaveNotes($input: UpdateNotesInput!) {
updateNotes(input: $input) {
__typename
... on UpdateNotesSuccessPayload {
updated {
path
versionId
warnings {
level
message
}
}
}
... on UpdateNotesHashMismatchPayload {
path
actualHash
}
... on UpdateNotesPatchNotFoundPayload {
path
find
}
... on ErrorPayload {
message
}
}
}
{
"input": {
"changes": [
{
"upsert": {
"path": "boards/todo.md",
"content": "# Todo\n\n- [ ] Ship it\n",
"expectedHash": "latestContentHash from ReadNote"
}
}
]
}
}
upsert заменяет заметку или создаёт её. С expectedHash сохранение пройдёт, только если с момента чтения заметку никто не менял, иначе придёт UpdateNotesHashMismatchPayload. Пустой expectedHash значит «только создать»: если заметка уже есть, сохранения не будет. Без expectedHash сохранение всегда перезаписывает. updated[].versionId — версия, которую записало сохранение. updated[].warnings — предупреждения заметки после сохранения, включая сообщения валидаторов: level — INFO, WARNING или CRITICAL. Заметка с предупреждением CRITICAL всё равно сохраняется, поэтому покажите сообщения пользователю.
Заменить один кусок текста
Та же мутация SaveNotes, но с изменением patch:
{
"input": {
"changes": [
{
"patch": {
"path": "boards/todo.md",
"find": "- [ ] Ship it",
"replace": "- [x] Ship it",
"expectedHash": "latestContentHash from ReadNote"
}
}
]
}
}
find должен встречаться в заметке ровно один раз. Если его нет или он встречается больше одного раза, заметка остаётся как была, а результат — UpdateNotesPatchNotFoundPayload. Один вызов может нести несколько изменений для нескольких заметок, см. updateNotes.
Изменить ключи frontmatter
Та же мутация SaveNotes, но с изменением meta. Она задаёт status и priority и удаляет due; остальные ключи заметки, их порядок и комментарии, и её текст остаются как были:
{
"input": {
"changes": [
{
"meta": {
"path": "boards/todo.md",
"set": { "status": "done", "priority": 2 },
"unset": ["due"]
}
}
]
}
}
Из приложения, с updateNotes из Сохранения:
async function setMeta(path, set, unset = []) {
const { updateNotes: result } = await updateNotes({ input: { changes: [{ meta: { path, set, unset } }] } })
if (result.__typename !== 'UpdateNotesSuccessPayload') {
throw new Error(result.message || result.__typename)
}
}
await setMeta('boards/todo.md', { hidden: true })
await setMeta('boards/todo.md', {}, ['hidden'])
Меняются только ключи верхнего уровня. Если frontmatter нельзя править по ключам (невалидный YAML, flow-запись {...}, ключ объявлен дважды), вернётся ErrorPayload, и ничего не запишется. expectedHash необязателен и работает как у patch. Правила — в updateNotes.
Скрыть заметки
mutation HideNotes($input: HideNotesInput!) {
hideNotes(input: $input) {
__typename
... on HideNotesPayload {
success
}
... on ErrorPayload {
message
}
}
}
{ "input": { "paths": ["boards/old.md"] } }
Скрытая заметка пропадает с сайта. Новое сохранение через updateNotes возвращает её. Изменение hide в updateNotes, { "hide": { "path": "boards/old.md" } }, делает то же внутри пакета.
Скрытие записывается на админа: на вошедшего админа, на админа, создавшего API-ключ, или на админа, создавшего вебхук, который выдал токен. Токен вебхука получает ErrorPayload от hideNotes; скрывает он изменением hide, в пределах своих write patterns.
Загрузить файл
Файл уходит запросом multipart/form-data в формате GraphQL multipart request: часть operations с запросом и переменными, часть map, которая говорит, какую переменную заполняет файл, и сам файл.
mutation UploadAsset($input: UploadNoteAssetInput!) {
uploadNoteAsset(input: $input) {
__typename
... on UploadNoteAssetPayload {
uploadSkipped
}
... on ErrorPayload {
message
}
}
}
async function sha256Hex(blob) {
const bytes = await blob.arrayBuffer()
const digest = await crypto.subtle.digest('SHA-256', bytes)
return Array.from(new Uint8Array(digest))
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
}
async function upload(file, noteId, path, absolutePath) {
const input = {
file: null,
noteId,
sha256Hash: await sha256Hex(file),
path,
absolutePath,
}
const form = new FormData()
form.append('operations', JSON.stringify({
query: UPLOAD_ASSET,
variables: { input },
}))
form.append('map', JSON.stringify({ 0: ['variables.input.file'] }))
form.append('0', file, file.name)
const res = await fetch('/_system/graphql', {
method: 'POST',
credentials: 'include',
body: form,
})
const body = await res.json()
if (body.errors) throw new Error(body.errors[0].message)
return body.data.uploadNoteAsset
}
UPLOAD_ASSET — операция выше. Не ставьте Content-Type сами: браузер добавит его вместе с границей multipart.
Файл принадлежит версии заметки, поэтому сначала сохраните markdown, который на него ссылается:
- Сохраните заметку через
SaveNotes, например св markdown. - Возьмите
versionIdизupdatedи передайте его какnoteId. - Передайте
pathровно так, как ссылка записана в markdown (plan.png), аabsolutePath— как путь файла в хранилище (boards/plan.png).
sha256Hash — SHA-256 файла в hex, а не в base64, как у expectedHash; сервер сверяет его после загрузки. path, на который заметка не ссылается, отклоняется с ErrorPayload, где перечислены ссылки заметки. uploadSkipped: true значит, что такой файл на сервере уже был и его только привязали к новой версии.
Следить за изменениями
subscription WatchNotes($filter: NoteChangesFilter!) {
noteChanges(filter: $filter) {
changes {
__typename
... on NoteUpsertEvent {
path
eventType
versionId
}
... on NoteHideEvent {
path
}
}
}
}
{ "filter": { "includePatterns": ["boards/**"] } }
includePatterns (обязателен) и excludePatterns — glob-шаблоны: * не выходит за пределы папки, ** проходит через папки, обычный путь соответствует одной заметке. Одно событие может нести несколько изменений, если одно сохранение записало несколько заметок.
Клиент, который выполняет эту подписку и переподключается, — в разделе Живые обновления.
Прочитать старую версию (админ)
Этими двумя запросами канбан повторяет перемещение поверх чужой правки. Версии идут от новых к старым, поэтому limit: 1 даёт последнюю.
query NoteVersions($filter: AdminNoteVersionHistoryFilter!) {
admin {
noteVersionHistory(filter: $filter) {
nodes {
versionId
version
createdAt
}
}
}
}
{ "filter": { "path": "boards/todo.md", "limit": 1 } }
query NoteVersion($id: Int64!) {
admin {
noteVersion(versionId: $id) {
path
content
createdAt
}
}
}
{ "id": 42 }
Вызовы API и админские вызовы
Эндпоинт несёт операции двух видов, и обещают они разное:
| Вызовы API | Админские вызовы | |
|---|---|---|
| Где находятся | На верхнем уровне операции: notePaths, search, updateNotes, noteChanges |
Внутри admin { … }, в типах AdminQuery и AdminMutation схемы |
| Кто на них опирается | Плагин Obsidian, агенты с API-ключом, приложения вроде этого | Собственная админка trip2g |
| Стабильность | Поверхность, на которой построены эти клиенты | Гарантий меньше: они меняются вместе с админкой и могут поменяться в любом релизе |
| Кто может вызвать | См. таблицу в разделе Готовые GraphQL-запросы | Вошедший админ: cookie сессии или личный токен |
Приложению можно пользоваться админскими вызовами — канбан читает ими историю версий. Оставляйте их для функций, которые видят только админы, и проверяйте заново после обновления trip2g. Всё остальное на этой странице, кроме раздела с пометкой «(админ)», — API.
Поверхность API описана в updateNotes, GraphQL API и в запросах выше, а файл операций плагина Obsidian ниже показывает, что он выполняет.
Приложение над всей базой знаний: MCP из браузера
Приложение не обязано быть про ту заметку, на которой оно стоит. MCP Graph Walk показывает, как модель вживую ищет и читает этот сайт документации: каждый вызов — шаг на анимированной карте. Его страница — одна заметка, docs/search_visualizer.md с layout: search_visualizer, а шаблон, docs/_layouts/search_visualizer.html, и есть всё приложение. Оно:
- вызывает
/_system/mcpиз браузера через JSON-RPCtools/call:search,note_html,expand,similarи их федеративные версии; - читает граф знаний всего сайта и ничего не пишет;
- работает на весь экран со своей шапкой, без
defaultTemplate.Header(); - говорит с моделью по API-ключу, который вставляет посетитель. Страница хранит ключ в
localStorageбраузера и отправляет его только провайдеру модели; trip2g его не видит.
| Заметка — это | Приложение говорит с | |
|---|---|---|
| Канбан | Данные: приложение правит заметку, на которой стоит | GraphQL: updateNotes, noteChanges |
| Graph walk | Дом приложения: данные — весь сайт | MCP: search, note_html |
MCP или GraphQL. MCP — чтобы искать и читать по всей базе: результаты поиска со сниппетами и match_id у каждого попадания, чтение одного раздела по его toc_path, обход оглавления заметки через expand, похожие заметки через similar и поиск по подключённым базам. Это та же поверхность инструментов, которой пользуются агенты, поэтому страницу, которая показывает, что нашёл бы агент, строят на ней. Инструменты описаны в MCP-сервере. GraphQL — для всего остального: запись (updateNotes), живые обновления (noteChanges), типизированные запросы, которые возвращают ровно запрошенные поля, и viewer.
Вызов. Эндпоинт без состояния, поэтому tools/call работает без предварительного initialize. initialize нужен, только чтобы прочитать инструкции сайта для агентов, а tools/list — чтобы получить схему входа каждого инструмента; graph walk отправляет оба, потому что передаёт схемы модели.
let rpcId = 0
async function callTool(name, args) {
const res = await fetch('/_system/mcp', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
id: ++rpcId,
method: 'tools/call',
params: { name, arguments: args },
}),
})
const body = await res.json()
if (body.error) throw new Error(body.error.message)
if (body.result.isError) throw new Error(body.result.content[0].text)
return body.result
}
const found = await callTool('search', { query: 'release plan', limit: 5 })
const hits = found.structuredContent.results
const section = await callTool('note_html', { path: hits[0].note_path })
const html = section.content.map((part) => part.text).join('\n')
У каждого попадания в structuredContent.results есть title, note_path, url и matches; в content тот же ответ текстом, и для note_html этот текст — HTML заметки. Запрос должен быть POST с Content-Type: application/json, на всё остальное ответ HTTP 415. Accept оставьте браузерным по умолчанию: на один Accept: application/json эндпоинт отвечает HTTP 400, ему нужен application/json, text/event-stream или */*.
Доступ. MCP читает ту же cookie сессии, что и GraphQL. Запрос идёт на тот же origin, браузер прикладывает cookie, и сервер проверяет её, прежде чем запустить инструмент:
- Посетитель, который не вошёл, находит и читает только заметки, открытые всем. Остальные поиск не показывает вовсе, в отличие от GraphQL
search, который ставит их в конец безdocument. - Вошедший читатель получает ещё и заметки подграфов, к которым у него есть доступ: те же заметки, что он мог бы открыть на сайте.
- Вошедший админ получает все заметки.
- На заметку, которую посетитель читать не может,
note_htmlотвечает ошибкойNote not found— так же, как на несуществующую. - Ничто из того, что шлёт браузер, не открывает запись. Инструментам, которые выполняют GraphQL от имени админа,
graphql_requestиgraphql_introspection, нужен API-ключ с включёнными админскими MCP-инструментами; cookie сессии, даже админская, до них не дотягивается. Со страницы MCP только читает.
Вызывайте его со страницы того же сайта. CORS-заголовки сервер отдаёт только плагину Obsidian, поэтому страница с другого origin ответов не прочитает.
Своё API рядом с trip2g, а доступ проверяет trip2g
Приложению может понадобиться больше, чем заметки: отчёт из другой базы данных, вызов платного сервиса, задача на минуту. Вынесите это в своё API, а решать, кому его можно вызывать, оставьте trip2g. API спрашивает trip2g от имени пользователя, поэтому своих пользователей, паролей и правил доступа у него нет.
Разместите его на том же хосте. Cookie сессии, trip2g_token, — HttpOnly, Secure, SameSite=Lax с Path=/, и атрибута Domain у неё нет. Без Domain браузер отправляет её только тому хосту, который её поставил: ни поддомену, ни родительскому домену. Поэтому API живёт по пути на хосте самого сайта, а не на api.example.com.
Под /_system/extra/. Этот префикс зарезервирован за владельцем сайта: trip2g ничего под ним не отдаёт и никогда не будет, а тест в роутере trip2g падает, если какой-то из его маршрутов начинается с этого префикса. Размещайте каждое API по адресу /_system/extra/<имя>, например https://notes.example.com/_system/extra/report, и направьте /_system/extra/ в свои API в обратном прокси перед trip2g; всё остальное по-прежнему уходит в trip2g. Два соседних варианта небезопасны:
/api/…— это ещё и путь заметки: заметкаapi/report.mdоткрывается по адресу/api/report, и правило прокси её спрячет./_system/<имя>— место собственных маршрутов trip2g:/_system/graphql,/_system/mcp,/_system/admin,/_system/auth/…и другие. В следующем релизе там может появиться маршрут с вашим именем.
Под /_system/ заметки не отдаются никогда, поэтому и контент этот префикс спрятать не может. Запрос на /_system/extra/…, который дошёл до trip2g, потому что его не забрало ни одно правило прокси, получает страницу сайта «Страница не найдена» с HTTP 404 — и на GET, и на POST.
Как это работает.
- Приложение вызывает
fetch('/_system/extra/report', …). Запрос same-origin, поэтому браузер прикладывает cookie. - API берёт заголовок
Cookieиз запроса и без изменений отправляет его в/_system/graphqltrip2g по внутренней сети, с запросом «кто это». - trip2g отвечает так же, как ответил бы браузеру. API решает по этому ответу и отказывает всякий раз, когда отказал trip2g.
Шаблон отдаёт приложению id заметки — добавьте "pathId", note.PathID() в блок данных из шаблона, — и приложение отправляет его:
const res = await fetch('/_system/extra/report', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pathId: data.pathId }),
})
if (!res.ok) throw new Error(`report: ${res.status}`)
const report = await res.json()
API, здесь на Node 18 или новее, спрашивает trip2g, кто вызывает:
import { createServer } from 'node:http'
const askTrip2g = (query) => async (cookie, variables) => {
const res = await fetch(process.env.TRIP2G_GRAPHQL, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Cookie: cookie },
body: JSON.stringify({ query, variables }),
})
const body = await res.json()
return body.errors ? null : body.data
}
const whoIs = askTrip2g(`query { viewer { role user { email } } }`)
createServer(async (req, res) => {
if (req.method !== 'POST') return res.writeHead(405).end()
const cookie = req.headers.cookie ?? ''
const who = await whoIs(cookie)
if (!who || who.viewer.role === 'GUEST') return res.writeHead(401).end()
let raw = ''
for await (const chunk of req) raw += chunk
const { pathId } = JSON.parse(raw)
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify(await buildReport(pathId, who.viewer.user)))
}).listen(8080)
TRIP2G_GRAPHQL — GraphQL-эндпоинт trip2g по внутреннему адресу, buildReport — собственная работа API. viewer.role — GUEST, USER или ADMIN. Посетитель, который не вошёл или у которого истекла cookie, получает GUEST и user: null; заблокированный пользователь получает запись в errors, и askTrip2g превращает её в null. Id пользователя в схеме нет: человека определяет user.email, и он null у аккаунта, вошедшего без почты.
Строже: проверяйте ресурс, а не только человека. Прежде чем отдать данные о заметке, спросите у trip2g эту заметку от имени пользователя. Публичный запрос note отвечает, только если пользователь может открыть заметку на сайте; иначе он возвращает запись в errors (Need auth, Need subscription, Sign in required, page not found) и не возвращает заметку. Отказывайте всякий раз, когда отказал trip2g:
const canRead = askTrip2g(`query ($pathId: Int64!) {
note(input: { pathId: $pathId, referer: "" }) { pathId }
}`)
const allowed = await canRead(cookie, { pathId })
if (!allowed?.note) return res.writeHead(403).end()
Теперь правила доступа живут в одном месте — в подграфах и платном доступе хранилища, — и API не может от них разойтись. Отдавайте данные ровно по тому pathId, который проверили. Успешная проверка вошедшего читателя засчитывается как просмотр заметки в его истории чтения, как если бы он открыл страницу.
Правила.
- Пересланная cookie — это вся сессия пользователя. С ней работает любой вызов trip2g, доступный этому пользователю, у админа — включая запись. Отправляйте её только в trip2g; никогда не пишите в лог, не сохраняйте и не передавайте другому сервису. Выход из аккаунта только удаляет cookie из браузера: копия, которую кто-то сохранил, действует, пока не истечёт, по умолчанию 30 дней.
- Не принимайте
GETтам, где что-то меняется.SameSite=Laxне пускает cookie в межсайтовыйPOST, но браузер всё равно отправляет её при переходе по ссылкеGETс другого сайта. Эндпоинт, который только читает, может отвечать наGET. - Каждый вызов API стоит вызова trip2g. Ответ trip2g можно кешировать на несколько секунд по значению cookie. Тогда блокировка или отозванная подписка дойдёт до API, только когда истечёт закешированный ответ.
- Обращайтесь к trip2g по внутреннему адресу, а не по публичному домену. Тогда cookie не идёт через интернет второй раз, а вызов не проходит снова через прокси.
Большего вызов из другого процесса trip2g не требует: ни Origin, ни Referer, ни CSRF-токена или своего заголовка. Сервер читает cookie из заголовка Cookie любого POST с Content-Type: application/json — так же, как от браузера. Флаг Secure только запрещает браузерам слать cookie по простому HTTP; сервер принимает её в любом случае, поэтому вызов по простому HTTP во внутренней сети работает.
Принимать данные без своего бэкенда: формы
Приложению, которое что-то собирает — заказ, заявку, отзыв, — свой сервер для хранения не нужен. Форма trip2g уже хранит отправки, решает, кому можно отправлять, и защищает от спама. Объявите форму во frontmatter заметки, как описано в Формах в заметках, а приложение отправит её мутацией submitForm:
- Поля из описания формы. Шаблон выводит
note.FormSpecJSON()в<script type="application/json" id="form-spec">, и приложение строит поля ввода по нему: имена, типы,required, ограничения. Там жеnote_version_id, который отправка передаёт какnoteVersionId. См. Формы → Кастомный layout. - Отправка идёт в
/_system/graphql, как и все остальные вызовы на этой странице. Поля и все варианты результата — в Формы → Сабмит через GraphQL. - Защита от спама. Cloudflare Turnstile включён у каждой формы, если она не задаёт
turnstile: false. Без действительного токена результат —TurnstileRequiredPayloadсsiteKeyсайта: приложение показывает виджет и отправляет тот же ввод ещё раз с токеном. - Отправки видны в админке в разделе Forms, и о каждой админам сайта уходит письмо. Админ читает их через GraphQL запросом
admin { formSubmits … }— это админский вызов: Формы → Чтение сабмитов.
Рабочий пример — живая демо-форма; её шаблон — один файл, который можно прочитать и скопировать.
const submitForm = makeRequest(`mutation ($input: SubmitFormInput!) {
submitForm(input: $input) {
__typename
... on SubmitFormPayload { submitId }
... on FormSubmitDeniedPayload { reason }
... on TurnstileRequiredPayload { siteKey }
... on ErrorPayload { message }
}
}`)
const spec = JSON.parse(document.getElementById('form-spec').textContent)
async function send(fields, turnstileToken) {
const input = { noteVersionId: spec.note_version_id, formId: '', fields, turnstileToken }
const { submitForm: result } = await submitForm({ input })
if (result.__typename === 'TurnstileRequiredPayload') {
turnstile.render('#captcha', {
sitekey: result.siteKey,
callback: (token) => send(fields, token),
})
}
return result
}
await send([{ name: 'email', stringValue: 'alice@example.com' }])
turnstile берётся из скрипта Cloudflare, <script src="https://challenges.cloudflare.com/turnstile/v0/api.js"></script>, в шаблоне, а #captcha — пустой элемент для виджета. formId — "" для одиночного form: или ключ одной из форм под forms:.
Кому можно отправлять. Сервер проверяет две вещи, в таком порядке:
- Может ли посетитель читать заметку? На заметку, которую посетитель открыть не может, ответ —
ErrorPayloadсform_not_found, как будто формы у неё нет. can_submit. Не задан илиguest: любой, кто прошёл первую проверку, вошёл он или нет.admin: только вошедший админ; остальные получаютFormSubmitDeniedPayloadсreason: "admin_required".paid_userпока не реализован и отказывает всем, включая админа, сreason: "not_implemented". Значение, которого trip2g не знает, напримерuser, считаетсяguestи открывает форму всем.
Что это значит для участников. Значения can_submit «только вошедшие участники» нет. Сузить форму до них может только доступ к самой заметке: поставьте форму на заметку без free: true в подграфе с флагом Require sign-in — отправить сможет любой вошедший пользователь, а гость получит form_not_found; в платном подграфе — только те, у кого есть доступ. Форма на заметке, открытой всем, принимает отправки от всех. Поэтому магазин с аккаунтами покупателей пока не может сделать форму только для участников через can_submit — остаётся опираться на доступ к заметке, см. Подграфы. Если отправитель вошёл, отправка сохраняет его аккаунт — он виден как user в formSubmits.
Например: небольшой магазин
Как части этой страницы могли бы сложиться в магазин. Это иллюстрация, а не функция магазина: своего каталога, корзины и оформления заказа у trip2g нет.
- Товары — заметки, по одной на товар, с ценой и остатком во frontmatter:
price: 1200,stock: 5. - Витрина — шаблон, который перечисляет их через
nvs.ByGlob("shop/*.md").Public()и читает у каждогоM().GetFloat("price", 0); см. Справочник функций Jet. - Корзина живёт в браузере, в
localStorage, или в своём API по адресу/_system/extra/cart, которое спрашивает уviewertrip2g, кто покупатель, как в разделе Своё API. - Заказ — форма trip2g, а не ваше API: заметка оформления объявляет
form:с контактами покупателя и текстовым полем для корзины, и приложение отправляет её, как в разделе Формы. Заказ приходит в админку и на почту админам.
Ничто здесь не уменьшает остаток и не проверяет цену: в заказе то, что прислал браузер, а остаток уменьшается, когда кто-то правит заметку товара.
Где искать остальное
Запросы выше проверены тестом по схеме. Для всего остального:
-
Схема. В internal/graph/schema.graphqls перечислены все запросы, мутации и подписки с типами входа и результата. Войдя как админ, откройте
/_system/graphqlв браузере, чтобы изучать её в GraphiQL. -
Клиент синхронизации Obsidian. Его файл операций, src/operations.graphql в github.com/trip2g/obsidian-sync, содержит запросы с API-ключом, которые он выполняет: чтение заметок и их файлов,
pushNotes,hideNotes,uploadNoteAsset,commitNotes. В репозитории trip2g клиент лежит сабмодулемobsidian-sync; файлы сабмодуля GitHub внутри trip2g не показывает, поэтому вот тот же файл на коммите, который закреплён в trip2g. -
Админка. Каждый экран хранит свои операции в файлах
.graphqlрядом с кодом, в assets/ui/:admin/— админка,editor/— редактор заметок,user/— сторона читателя. Нужный файл ищите по полю, которое он вызывает, поиском GitHub по этим файлам (допишите имя поля в запрос) или в клоне:grep -rl --include='*.graphql' 'noteVersionHistory' assets/ui
Как сделать своё
- Сначала решите формат markdown. Заметка должна читаться и правиться без вашего приложения: список, таблица, заголовки. Канбан взял формат плагина obsidian-kanban, поэтому та же заметка — доска и в Obsidian.
- Напишите парсер и сериализатор, которые возвращают формат в точности, и проверьте тестом, что
serialize(parse(text)) === text. Всё, что приложение не понимает, должно пережить сохранение без изменений. - Напишите шаблон как выше, со своим элементом для монтирования и своим бандлом.
- Прочитайте данные со страницы, нарисуйте их и сохраняйте через
updateNotesсexpectedHash. - Показывайте кнопки редактирования, только когда
editable—true. Сервер всё равно проверит.
Чек-лист
- У заметки
layout: app, а шаблон выводитdefaultTemplate.Styles()иdefaultTemplate.UserSpaceScripts()в<head>,Header()перед приложением иFooter()после него. - У страницы есть заметка-шапка, или в шаблоне есть свой элемент монтирования
$trip2g_user_space: иначе нет кнопки входа. - Приложение обращается к
/_system/graphqlпо относительному адресу, методомPOSTсContent-Type: application/json, с credentials по умолчанию илиcredentials: 'include'. - В странице нет API-ключа или токена, у приложения нет своего экрана входа.
- Кнопки редактирования видны, только когда так решил
currentUser.IsAdmin()в шаблоне; посетитель видит заметку только для чтения и кнопку входа в шапке. - Каждое сохранение передаёт
expectedHashи обрабатываетUpdateNotesHashMismatchPayloadи запись вerrors. - К каждой заметке, которую приложение читает как данные, подключён валидатор через
validate, и приложение показываетupdated[].warningsпосле сохранения. - Вызовы MCP идут в
/_system/mcpпо относительному адресу, методомPOSTсContent-Type: application/jsonи браузернымAcceptпо умолчанию, и обрабатывают и JSON-RPCerror, и результат сisError. - Своё API живёт под
/_system/extra/на хосте сайта, а не на поддомене, не под/api/и не по другому пути/_system/; отправляет заголовокCookieтолько в trip2g по внутреннему адресу, не пишет его в лог и не сохраняет, и не принимаетGETна эндпоинтах, которые что-то меняют. - Данные, которые собирает приложение, идут через форму trip2g: приложение обрабатывает
TurnstileRequiredPayload,FormSubmitDeniedPayloadиErrorPayload, а форма для участников стоит на заметке, которую читают только они.
Смотрите также
- Шаблон канбан-доски и его исходники, github.com/trip2g/kanban_template
- updateNotes — API записи целиком
- GraphQL API
- MCP-сервер — инструменты, которые даёт
/_system/mcp - Формы в заметках — поля формы,
can_submit,turnstileи чтение отправок - Подграфы — кто может читать заметку, а значит и отправлять её форму
- MCP Graph Walk и его шаблон, docs/_layouts/search_visualizer.html
- json() и writeJson()
- Шаблоны