|
|
|
Professor Seleznov
|
FSRS-плагин для Obsidian: SQL-подобные запросы, Rust/WASM, производительность Инструмент интервального повторения заметок Obsidian должен использовать современный алгоритм, работать локально с заметками как есть (без переписывания в карточки). Существующие в Obsidian плагины останавливаются на алгоритме SM-2 образца 1987 года. Альтернативные решения есть «где-то еще», вне свободного ПО, вне Markdown‑first архитектуры — привязаны к облаку или проприетарному формату. Я написал свой, потому что не нашёл подходящего. FSRS, вычислительное ядро на Rust, скомпилированное в WebAssembly, и SQL‑подобный синтаксис для табличной выборки. В статье — архитектура с WebAssembly, собственный парсер, лексер, замеры производительности. Любые запросы обрабатываются в сотых долях секунды. Blazingly fast 🦀
Это техническая статья. Если хотите пошаговое руководство для пользователя — вот обзорная статья.

демонстрация плагина: таблица карточек и всплывающий предпросмотр - Зачем четвёртый плагин для повторений? На момент написания в Obsidian есть три популярных решения: obsidian-spaced-repetition, obsidian-recall и obsidian-review. Все используют SM-2 — алгоритм которому скоро 40 лет. Он работает, но требует примерно на 30% больше повторений чем FSRS при том же уровне запоминания. Главные недостатки SM-2:
- Одинаковый интервал для материала любой сложности
- Не обрабатывает пропуски — «сброс» прогресса после перерыва
- Нет понятия извлекаемости (retrievability) — вероятности вспомнить карточку сейчас
FSRS это шаг вперёд. Но его нет в Obsidian. Точнее — не было.

sm-2-vs-fsrs - Как устроен FSRS в двух словах FSRS оперирует DSR-моделью из трёх параметров:
- Difficulty (сложность) — насколько труден материал. Диапазон: 0–10
- Stability (стабильность) — прочность запоминания в днях
- Retrievability (извлекаемость) — вероятность вспомнить карточку прямо сейчас
После каждого ответа (Again / Hard / Good / Easy) алгоритм пересчитывает сложность и стабильность. Извлекаемость меняется непрерывно. Алгоритм использует 21 параметр, подобранный машинным обучением на миллионах реальных повторений.

DSR-schema - Архитектура: почему Rust и WebAssembly Плагин Obsidian — это JavaScript. Но FSRS требует точных вычислений с плавающей точкой на каждом повторении. Я выбрал Rust по трём причинам:
- Производительность — WASM работает на порядок быстрее JS на численных вычислениях
- Экосистема — крейт rs-fsrs от сообщества open-spaced-repetition с эталонной реализацией FSRS
- Безопасность типов — в Rust невозможно перепутать difficulty и stability
Разделение ответственности:
TypeScript Rust/WASM ───────── ───────── • Obsidian API • FSRS-вычисления • UI / рендеринг • Парсинг SQL-подобного синтаксиса • Файловая система • Парсинг YAML/JSON • Жизненный цикл плагина • Фильтрация и сортировка • Кнопки, модалки • Кэш карточек

fsrs-plugin-schema TypeScript — тонкая обвязка над Obsidian API. Вся логика — в WASM. - Производительность: граница WASM ⇆ JS Минимизация копирования через границу
- Кэш внутри WASM — фильтрация и сортировка выполняются там же, в Rust. Через границу передаётся только результат (20–200 строк), а не все 10 000.
- Инкрементальные обновления — при ответе на карточку пересчитывается только одна запись.
metadataCache: не читать файлы Главный выигрыш в скорости — отказ от чтения файлов. Obsidian хранит распарсенный frontmatter в metadataCache — внутреннем кэше, который обновляется при каждом изменении заметки. Плагин проверяет наличие FSRS-полей через metadataCache.getFileCache() — это мгновенный доступ в памяти, без I/O. Из 105 607 файлов реально читаются только те, где frontmatter уже содержит reviews. Для проверки: собственный парсер плагина обрабатывает 105k файлов за 16 секунд, 100к отфильтровывает, а 5к обрабатывает. Obsidian на индексацию 105к тратит ~20 минут, а на свежедобавленные 5000 карточек, ~120 с. Так что корректно сравнивать 16 секунд плагина и 120 Обсидиана. Но он всё равно это делает — так что эффективнее брать готовое. Циферки FSRS-расчёт для всех карточек выполняется один раз при первом сканировании хранилища. После этого кэш живёт в WASM — все последующие операции (загрузка таблицы, тепловая карта, обновление одной карточки) работают с уже готовыми данными и не зависят от объёма.
| Операция |
Большое хранилище (105k файлов, ~5000 карточек) |
Маленькое хранилище (710 файлов, 104 карточки) |
| Первичное сканирование (FSRS для всех карточек) |
3.2 с |
0.04 с |
| Загрузка таблицы (после кэша) |
0.07 с |
0.04 с |
| Тепловая карта |
0.02 с |
0.01 с |
| Обновление одной карточки |
< 0.01 с |
< 0.01 с |
Разница между 5000 и 100 карточек после кэширования — 0.03 с. Логи болшое, Логи малое с плагинами
Любое действие плагина, после первичного расчёта, выполняется в сотые доли секунды.
Узкое место 3.2 секунды на первичную загрузку — это FSRS-расчёт для каждой из 5 000 карточек. Выполняется однократно. Можно было бы сохранять состояния в постоянный кэш на диск, но:
- Сложность синхронизации между устройствами (кэш на диске может устареть)
- 5 000 карточек / 3.2 секунды — приемлемо для реального использования
- После первого запуска последующие открытия плагина работают мгновенно — кэш уже в WASM
Что не так (о компромиссах) LIMIT в текущей реализации не прерывает обработку — чтобы гарантированно получить первые N строк по извлекаемости, всё равно нужно оценить все карточки. Компромисс принят сознательно: размер хранилища реального пользователя редко превышает 5000–10000 записей, полный обход + сортировка занимают 0.005–0.010 с. - Кэш в WASM вместо локального стейта Весь кэш (HashMap<filePath, CachedCard>) живёт внутри WASM как глобальная переменная. Плагин не хранит состояния в TypeScript вообще. Почему:
- Единый источник истины — нет рассинхрона между JS-стейтом и WASM-вычислениями
- Быстрые запросы — фильтрация/сортировка идут там же где данные
- Инкрементальное обновление — точечные команды «обнови эту карточку» / «удали эту»
Где живут данные. Прогресс повторений хранится прямо в YAML-frontmatter заметки:
--- reviews: - date: "2026-05-03T12:00:00Z" rating: 2 - date: "2026-05-04T08:30:00Z" rating: 3 ---
due, stability, difficulty и state не хранятся — WASM-ядро вычисляет их на лету из истории. - SQL-подобный язык для таблиц Главная фича плагина — выборка на повторение. Это блок fsrs-table. Вы пишете в markdown-заметке:
```fsrs-table SELECT file as " ", d as "D", s as "S", r as "R", date_format(due, '%d.%m.%Y') as "Next" LIMIT 20 ```
И получаете живую таблицу с карточками, которая автообновляется при повторениях.

отрендеренная таблица fsrs-table с карточками Реализован полноценный парсер с нуля — лексер → парсер → AST → evaluator:
- Лексер — разбивает строку запроса на токены
- Парсер — строит синтаксическое дерево с учётом приоритета операторов
- Evaluator — обходит AST и вычисляет условие для каждой карточки
Поддерживается:
- SELECT — выбор полей и переименование через AS
- WHERE — условия со сравнениями (=, !=, <, >, <=, >=) и логическими операторами (AND, OR)
- ORDER BY — сортировка по возрастанию/убыванию
- LIMIT — ограничение количества строк
- date_format() — форматирование дат
Для difficulty, stability и retrievability доступны однобуквенные псевдонимы d, s, r — сокращения, принятые в сообществе FSRS. Чего fsrs-table не умеет:
- Вложенные запросы, JOIN, агрегации (COUNT, SUM…) — нет
- Порядок колонок в SELECT — можно менять как угодно ✅
- Несуществующее поле в WHERE или SELECT — ошибка с указанием
- LIMIT ограничивает вывод, но не прерывает обработку (подробнее выше)
- Только чтение. INSERT, UPDATE, DELETE через SQL нельзя
- Отказ от моков как следствие архитектуры Моки не нужны не потому что «запрещены», а потому что нечего мокать. TypeScript в плагине — тонкая обёртка над Obsidian API. Вся логика — в Rust/WASM. Мокать Obsidian значит проверить, закроет ли плагин <div>, или вызвал ли vault.read() — тривиальный клей, не стоящий тестов. Стоит проверять другое: связку своего TypeScript со своим же WASM. Поэтому тесты делятся на два уровня:
- Rust-тесты (184 штуки) — чистые функции, изолированные от окружения
- TypeScript-тесты (86 штук) — unit-тесты чистых функций и интеграционные TS → WASM
Интеграционные тесты начинаются и заканчиваются на собственном коде: сырая строка, параметр либо вызов на входе (TS) → парсинг (WASM) → запрос к WASM-кэшу (WASM) → результат (TS).

tests-terminal - CI/CD: сборка, тесты, релиз одной кнопкой Пайплайн в GitLab CI из семи стадий:
- check — cargo fmt, cargo clippy, cargo test
- build-wasm — wasm-pack build
- encode-wasm — встраивание WASM в base64 для бандла
- test — TypeScript-тесты (vitest)
- lint — tsc --noEmit + ESLint
- build — финальная сборка main.js
- release — автоматический релиз на GitHub

ci-green - Текущее состояние Плагин готов к использованию. Протестирован на Ubuntu, Windows и на Android. Плагин доступен в каталоге сообщества Obsidian. Что уже готово:
- Фильтрация и сортировка через SQL-подобный синтаксис
- Тепловая карта повторений (Heatmap)
- Локализация на русский, английский и китайский
- CI/CD, который сам собирает и публикует релизы
- Прозрачное хранение — все данные в YAML-frontmatter ваших .md-файлов
- Интеграционные тесты TS → WASM (сырой SQL, без моков)
- Тепловая карта
Что планируется:
- Собрать обратную связь
- Доработать интерфейс для мобилок
- Возможно, расширить SQL-синтаксис
- Как установить Плагин доступен в каталоге сообщества Obsidian.
- Settings → Community plugins → Browse
- Найдите FSRS → Install
- Включите плагин в Settings → Community plugins
Также можно установить через BRAT для самых свежих сборок:
- Стек
- TypeScript — Obsidian API, UI
- Rust — вычислительное ядро (WASM)
- esbuild — сборка JS-бандла
- wasm-pack — сборка WASM
- Vitest — тесты TypeScript
- GitLab CI/CD — пайплайн
- Ссылки
Е. Копылов, 2026-Источник
|
|
|
|