Read in:
Русский

Приложение поверх trip2g

Свой шаблон не обязан рисовать страницу. Он может отдать JavaScript-приложение, которое показывает заметку как что-то интерактивное и записывает изменения обратно. Заметка остаётся обычным markdown: Obsidian, агент и приложение правят один и тот же файл.

Так устроен шаблон канбан-доски. Он превращает заметку в формате obsidian-kanban в доску с перетаскиванием карточек, и каждое перемещение сохраняется обратно в markdown заметки. Эта страница описывает схему, по которой он сделан, чтобы вы могли собрать своё приложение: чек-лист, редактор таблицы, конструктор форм, дашборд.

Если вы ещё не делали шаблоны, начните с Шаблонов.

Три части

Часть Где живёт Что делает
Шаблон _layouts/app.html в хранилище Кладёт данные заметки в страницу, элемент для монтирования и <script>, который загружает приложение
Бандл приложения Рядом с шаблоном или по любому URL Читает данные со страницы, рисует интерфейс
GraphQL API /_system/graphql на вашем сайте Сохраняет отредактированный markdown обратно в заметку

Заметка включает приложение строкой layout: app во frontmatter, как и любой другой шаблон.

Шаблон: заметка внутри страницы

Шаблон даёт приложению три вещи: путь заметки в хранилище, её исходный 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>
  • note.ContentString() — исходный markdown заметки вместе с frontmatter. Это тот же текст, от которого сервер считает хеш для проверки конфликтов, поэтому приложение может его изменить и отправить обратно.
  • json() экранирует <, > и & как <…, поэтому markdown с </script> внутри не закроет элемент. JSON.parse вернёт текст в точности.
  • currentUser.IsAdmin() решает только, показывает ли приложение кнопки редактирования. Каждое сохранение сервер проверяет заново (см. ниже).
  • Обращение к currentUser делает страницу персонализированной: она не отдаётся из анонимного кэша страниц, поэтому приложение всегда стартует с текущей версии заметки.

Шаблон канбана передаёт те же данные иначе: markdown — в скрытой <textarea>, путь — в скрытом <span>, оба выведены обычным {{ … }}. Это тоже безопасно, потому что вывод экранируется по умолчанию: заметка с </textarea> попадёт на страницу как &lt;/textarea&gt;, и браузер декодирует её обратно. Но у <textarea> две особенности: браузер приводит переводы строк к \n и выбрасывает перевод строки сразу после открывающего тега. У JSON-острова их нет.

Чтобы на странице приложения была кнопка входа trip2g, добавьте в <head> {{ defaultTemplate.UserSpaceScripts() }} и элемент для неё, как это делает шаблон канбана.

Бандл

Отдать 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

Сохранение: мутация updateNotes

Приложение записывает заметку мутацией GraphQL updateNotes на /_system/graphql. fetch с того же домена с credentials: 'include' отправляет cookie сессии посетителя. Писать может вошедший админ сайта, остальные получат ошибку, поэтому браузер посетителя не изменит заметку, даже если приложение по ошибке покажет кнопки.

const UPDATE = `mutation ($i: UpdateNotesInput!) {
  updateNotes(input: $i) {
    __typename
    ... on UpdateNotesHashMismatchPayload { actualHash }
    ... on ErrorPayload { message }
  }
}`

async function save(path, content, expectedHash) {
  const change = { upsert: { path, content, expectedHash } }
  const res = await fetch('/_system/graphql', {
    method: 'POST',
    credentials: 'include',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      query: UPDATE,
      variables: { i: { changes: [change] } },
    }),
  })
  const body = await res.json()
  if (body.errors) throw new Error(body.errors[0].message)
  return body.data.updateNotes
}

Результат показывает __typename:

__typename Что значит
UpdateNotesSuccessPayload Сохранено
UpdateNotesHashMismatchPayload Кто-то изменил заметку после того, как приложение её загрузило; actualHash — текущий хеш
UpdateNotesPatchNotFoundPayload Изменение patch не нашло свой текст find или нашло его больше одного раза
ErrorPayload Отказ, причина в message

Посетитель, который не админ, до результата не доходит: в ответе будет запись в errors и не будет data.updateNotes, и save выше превратит это в исключение.

Не затирайте чужую правку. expectedHash делает сохранение условным: сервер сохраняет, только если заметка всё ещё даёт этот хеш. Хеш — 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 hash = await contentHash(data.content)
const result = await save(data.path, newContent, hash)

На UpdateNotesHashMismatchPayload перезагрузите страницу или получите новое содержимое и примените своё изменение к нему. Канбан перечитывает последнюю версию админскими запросами noteVersionHistory и noteVersion и повторяет перемещение.

Маленькие правки. Вместо upsert, который заменяет заметку целиком, изменение может быть patch: { patch: { path, find, replace, expectedHash } } заменяет один точный кусок текста. Так канбан переключает чекбокс, и текст, который он не понимает, никогда не переписывается. Обе формы, пакеты изменений и ошибки — в updateNotes.

Живые обновления

Чтобы видеть правки из Obsidian или от агента, пока приложение открыто, подпишитесь на noteChanges с фильтром по пути заметки. Подписка идёт через server-sent events на том же /_system/graphql и принимает cookie сессии админа. Канбан так обновляет доску без перезагрузки; рабочий клиент — в его src/api.ts.

Готовые GraphQL-запросы

Эти запросы можно копировать в приложение. Каждый уходит на /_system/graphql как POST с JSON-телом { query, variables }: операция — это query, JSON-блок под ней — variables. Эта функция отправляет запрос и возвращает data:

async function gql(query, variables) {
  const res = await fetch('/_system/graphql', {
    method: 'POST',
    credentials: 'include',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ query, variables }),
  })
  const body = await res.json()
  if (body.errors) throw new Error(body.errors[0].message)
  return body.data
}

Большинство результатов — union-типы: запросите __typename и по фрагменту ... on на каждый тип, потом ветвитесь по __typename. Отказ, который приложение должно показать, приходит типом результата, например ErrorPayload. Вызов без доступа получает запись в errors, и gql превращает её в исключение.

Кто что может вызвать. Подробности — в updateNotes → Авторизация.

Кто Что отправляет Что доступно
Вошедший админ Cookie сессии или Authorization: Bearer t2g_… Всё ниже, включая admin { … }, кроме скрытия заметок
API-ключ X-Api-Key: … Всё, кроме admin { … }; запись только в пределах write patterns ключа
Токен вебхука Authorization: Bearer eyJ… То же, что API-ключу, в пределах read и write patterns токена, кроме скрытия заметок
Любой посетитель Ничего search, а после входа — 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
          title
        }
      }
    }
  }
}
{ "input": { "query": "release plan" } }

Это поиск сайта, он открыт любому посетителю. Заметки, которые посетителю читать нельзя, идут в конце: с заголовком и URL, но без document. Админ ищет по последним версиям, остальные — по опубликованным, если сайт не показывает черновики всем.

Сохранить заметку целиком

mutation SaveNotes($input: UpdateNotesInput!) {
  updateNotes(input: $input) {
    __typename
    ... on UpdateNotesSuccessPayload {
      updated {
        path
        versionId
      }
    }
    ... 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 — версия, которую записало сохранение.

Заменить один кусок текста

Та же мутация SaveNotes, но с изменением patch:

{
  "input": {
    "changes": [
      {
        "patch": {
          "path": "boards/todo.md",
          "find": "- [ ] Ship it",
          "replace": "- [x] Ship it",
          "expectedHash": "latestContentHash from ReadNote"
        }
      }
    ]
  }
}

find должен встречаться в заметке ровно один раз. Если его нет или он встречается больше одного раза, заметка остаётся как была, а результат — UpdateNotesPatchNotFoundPayload. Один вызов может нести несколько изменений для нескольких заметок, см. 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-ключ. С сессией админа или личным токеном и hideNotes, и изменение hide падают с записью в errors. Токен вебхука получает ErrorPayload от hideNotes и ту же ошибку от изменения hide.

Загрузить файл

Файл уходит запросом 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, который на него ссылается:

  1. Сохраните заметку через SaveNotes, например с ![Plan](plan.png) в markdown.
  2. Возьмите versionId из updated и передайте его как noteId.
  3. Передайте 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-шаблоны: * не выходит за пределы папки, ** проходит через папки, обычный путь соответствует одной заметке. Одно событие может нести несколько изменений, если одно сохранение записало несколько заметок.

Транспорт — server-sent events, не WebSocket: POST на /_system/graphql с Accept: text/event-stream. EventSource не умеет POST, поэтому читайте поток ответа:

async function watch(query, 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 data = frame.match(/^data: (.*)$/m)
      if (frame.startsWith('event: next') && data) {
        onData(JSON.parse(data[1]).data.noteChanges)
      }
    }
  }
}

Сервер шлёт event: next с результатом, event: complete в конце и комментарий : ping каждые 30 секунд. Поток может оборваться, например при перезапуске сервера, поэтому после паузы вызовите watch снова. Канбан делает это с задержкой в своём src/api.ts.

Прочитать старую версию (админ)

Этими двумя запросами канбан повторяет перемещение поверх чужой правки. Версии идут от новых к старым, поэтому 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 }

Где искать остальное

Запросы выше проверены тестом по схеме. Для всего остального:

  • Схема. В 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
    

    Всё внутри admin { … } требует вошедшего админа: cookie сессии или личный токен, не API-ключ. Это подходит приложению, которое открывают только админы, например внутреннему дашборду или редактору.

Как сделать своё

  1. Сначала решите формат markdown. Заметка должна читаться и правиться без вашего приложения: список, таблица, заголовки. Канбан взял формат плагина obsidian-kanban, поэтому та же заметка — доска и в Obsidian.
  2. Напишите парсер и сериализатор, которые возвращают формат в точности, и проверьте тестом, что serialize(parse(text)) === text. Всё, что приложение не понимает, должно пережить сохранение без изменений.
  3. Напишите шаблон как выше, со своим элементом для монтирования и своим бандлом.
  4. Прочитайте данные со страницы, нарисуйте их и сохраняйте через updateNotes с expectedHash.
  5. Показывайте кнопки редактирования, только когда editable — true. Сервер всё равно проверит.

Смотрите также