BrainKeeper — что изменилось

История деплоев с принципами работы и чеклистами проверки. Открыть приложение · на главную

Последний деплой: 4 августа 2026 · функционал: 26/31 в статусе «Работает»

История деплоевкаждый деплой — отдельной датой

ссылка из «Поделиться»

Ссылка, присланная кнопкой «Поделиться», перестала терять адрес, сайт и тип

Безопасность 1

Ключ рядом со ссылкой снова распознаётся, и бот снова удаляет исходное сообщение

Что и зачем: Побочный эффект правки выше, найденный независимой проверкой до выкатки. Текст с ключом и ссылкой уходил по ссылочной ветке: тело подменялось содержимым страницы, ключ оставался только в заметке, и классификатор его не видел. Тип переставал быть «секретом», а бот удаляет исходное сообщение ровно по этому признаку — пароль так и висел бы в переписке. Теперь наличие секрета в присланном тексте проверяется до выбора ветки: текст с ключом внутри не бывает подписью к ссылке (FR-1.7, D-006).

POST /v1/capture
Чеклист проверки (2)
  • Отправить боту строку с ключом и ссылкой в одном сообщениитип «секрет», бот удалил исходное сообщение и назвал вид ключа
  • Открыть эту запись в приложениизначение скрыто, показывается по нажатию

Исправлено 3

Подпись длиннее сорока символов больше не выбрасывает ссылку целиком

Что и зачем: Текст считался ссылкой с подписью, только если вокруг адреса меньше сорока символов. Всё, что вставляет системная кнопка «Поделиться» — «Посмотри объявление "…" на Авито: <ссылка>», восемьдесят семь символов, — в этот порог не попадало: адрес выбрасывался, запись сохранялась как текст, имя сайта в теги не попадало, а «Посмотри» в начале делало её ещё и инструкцией. Ломался самый частый способ сохранить ссылку с телефона: так устроены Авито, Ozon, Wildberries, YouTube. Правило теперь смотрит не на длину, а на форму: одна ссылка, текст вокруг без пустой строки, потолок в триста символов. Пустая строка означает абзацы — человек писал, а не делился, и его текст важнее страницы. Ссылок больше одной — это список, а не подпись: сохранить можно ровно одну страницу, а выбирать за человека главную не за что. Подпись при этом становится заметкой «что это», то есть ищется, а не оседает в теле. Найдено по двум записям в базе владельца: одна ссылка на Авито сохранилась ссылкой, вторая — инструкцией без единого тега.

POST /v1/capture
Чеклист проверки (3)
  • Нажать «Поделиться» на объявлении Авито и отправить ботузапись типа «ссылка», в тегах avito, подпись стала заметкой
  • Отправить боту заметку в три абзаца со ссылкой внутриосталась заметкой, адрес не подменил её страницей
  • Отправить две ссылки одной строкойзаметка с обеими, ни одна не потеряна

Вторая мысль о той же ссылке дописывается к заметке, а не затирает первую

Что и зачем: Тоже находка проверки. С тех пор как подписью считается свободный текст вокруг ссылки, в ветку дубликата пришла человеческая проза: две разные заметки об одном адресе сводились дедупликацией в одну запись, и вторая затирала первую насмерть. Корзина здесь не спасает — это перезапись поля, а не удаление записи. Теперь заметки склеиваются, а не заменяются, с тем же потолком длины, что у поля на входе.

POST /v1/capture
Чеклист проверки (1)
  • Отправить ссылку с подписью, потом её же с другой подписьюодна запись, в заметке обе строки

Короткий рецепт в три шага остался инструкцией, а фраза со ссылкой ею быть перестала

Что и зачем: «Посмотри объявление "…" на Авито» даёт классификатору шаг и два императива — по сигналам это неотличимо от инструкции. Первая попытка чинила это порогом объёма, и проверка показала, что порог роняет настоящие короткие инструкции: «Тесто: 1. Смешать 2. Добавить 3. Оставить» — тринадцать слов. Признак заменён на точный: текст, вокруг адреса которого меньше двадцати пяти слов, — обёртка вокруг ссылки, а не инструкция. Рецепт ссылки не содержит вовсе, поэтому признак к нему не применяется. Точность разметки на корпусе не изменилась: 95% и 97%.

Чеклист проверки (2)
  • Сохранить рецепт из трёх нумерованных шаговтип «инструкция»
  • Сохранить ссылку с подписью «Посмотри …»тип «ссылка», не «инструкция»

Улучшено 1

Имя сайта берётся и из незнакомой зоны: fwa.fun больше не даёт тег fun

Что и зачем: Список зон в коде конечный, а зон полторы тысячи, и новые появляются каждый год: `fwa.fun` давал тег `fun`, `shop.example.store` — `store`. Список нужен только для составных зон вроде `co.uk`; если он не опознал ничего, последняя метка адреса — всё равно зона, чем бы она ни была, включая `.рф`. Правило применяется только тогда, когда список промолчал: иначе у `avito.ru` снялось бы и `ru`, и само `avito`, и тегом стало бы `www` — эта ошибка была поймана прогоном за минуту до выкатки.

POST /v1/capture
Чеклист проверки (2)
  • Сохранить ссылку на сайт в зоне .fun, .store или .рфв тегах имя сайта, а не имя зоны
  • Сохранить ссылку на avito.ruтег avito

Технические изменения 1

manage.py relink возвращает адрес записям, у которых его отняло старое правило

Что и зачем: Правило исправлено, но сохранённому раньше это уже не помогает. Команда находит записи без адреса, чей текст проходит тот же критерий подписи, проставляет адрес и его хеш (без хеша повторная присылка завела бы вторую запись), пересчитывает тип и теги, кладёт подпись в заметку. Теги сливаются через merge_tags, а не подставляются: проверка показала, что прямая подстановка снесла бы хэштеги из выгрузки Telegram и папки из импорта закладок, а восстановить их нечем. Сухой прогон по умолчанию; проверено на копии боевой базы до запуска на ней самой.

Чеклист проверки (2)
  • python3 manage.py relink --dry на серверепечатает записи и старые → новые теги, ничего не меняет
  • python3 manage.py relinkу записи появился адрес и тег сайта, заголовок и тело не тронуты
changelog на сайте

Список изменений стал публичной страницей сайта, а деплой без записи о нём больше не проходит

Добавлено 1

Страница изменений открыта по адресу brainkeeper.app/changelog и стоит в футере сайта

Что и зачем: Страница существовала и раньше, но лежала внутри приложения (/app/changelog.html) и была помечена noindex — то есть служебной. Теперь у неё короткий публичный адрес, canonical, og-теги и место в карте сайта, а в футере лендинга — ссылка на обоих языках. Копии в каталоге лендинга при этом нет намеренно: его деплой ходит с rsync --delete и снёс бы её при первой же выкладке, а две копии одной страницы разошлись бы ещё раньше. Вместо копии nginx отдаёт по короткому адресу тот же файл, который кладёт server/deploy.sh, — значит страница на сайте обновляется тем же деплоем, что и код, без единого дополнительного действия. Старый адрес /changelog.html отвечает постоянным редиректом.

Чеклист проверки (4)
  • Открыть https://brainkeeper.app/changelog200, страница со списком релизов, без noindex
  • Открыть https://brainkeeper.app/changelog.html301 на /changelog
  • Пролистать лендинг до низав футере «Что изменилось», на /en/ — «What's changed»
  • Запустить landing/deploy.sh и снова открыть /changelogстраница на месте — деплой лендинга её не трогает

Изменено 1

Деплой останавливается, если код правили после последней записи в changelog

Что и зачем: Правило «каждая правка попадает в changelog» держалось на памяти, а память подводит ровно тогда, когда правка срочная. Теперь шаг 0 деплоя сравнивает время последнего коммита в код (app, server, bot, desktop, landing) со временем последнего коммита в releases.json и отказывается ехать, если код новее. Сравнение по времени коммитов, а не по датам внутри файла: релизов в один день бывает несколько, и сравнение дат пропустило бы второй. Обойти можно осознанно — SKIP_CHANGELOG_CHECK=1, для случая, когда раскладывается ровно то же самое. Заодно публичный адрес попал в дымовую проверку: страница, которую забыли выложить, теперь роняет деплой, а не обнаруживается через неделю.

Чеклист проверки (3)
  • Изменить любой файл в app/ и запустить деплой без записи в releases.jsonдеплой останавливается на шаге 0 с объяснением
  • Добавить релиз и повторитьдеплой идёт как обычно
  • Дождаться шага 7/8в списке проб есть https://brainkeeper.app/changelog со статусом 200
вид и язык

Десять тем, два языка, порог для тегов и экран «что нового» после обновления

Добавлено 5

Десять цветовых тем — в приложении и в десктопе, файл палитр один на двоих

Что и зачем: Тёмная тема была единственной, и на солнце приложением пользоваться было нельзя. Теперь их десять: семь тёмных (Ночь, Пустота, Графит, Тайга, Уголь, Глубина, Терминал), три светлых (Бумага, Иней, Сепия) и отдельный пункт «как в системе», который палитрой не является, а выбирает между двумя. Тема задаёт ТОЛЬКО цвета: ни одного размера, ни шрифта, ни правила вёрстки — тема, умеющая двигать элементы, превращается в десять вёрсток, которые чинят по отдельности. Чтобы палитра перекрашивала и полупрозрачные слои (подсветку строки, рамки, тени), каждый цвет хранится ещё и компонентами: вместо зафиксированного rgba везде стоит rgb(var(--x-rgb) / a) — иначе подсветка осталась бы сиреневой на зелёной теме. Образец палитры в настройках красит сам себя: на кнопке стоит data-theme, а селекторы привязаны к атрибуту, а не к корню документа, — второй копии десяти палитр в JavaScript нет и не появится. Выбор хранится на устройстве и не синхронизируется намеренно: телефону ночью нужна «Пустота», рабочему монитору днём — «Бумага».

Чеклист проверки (4)
  • Приложение → Ещё → «Оформление, язык, теги»сетка из 11 образцов, у каждого свои цвета; текущий обведён золотым
  • Выбрать «Бумага»приложение светлеет сразу, полоса состояния и системный цвет вкладки — тоже
  • Перезагрузить страницутема осталась, тёмного кадра при загрузке нет
  • Десктоп → Настройки → Оформлениета же сетка и те же названия, выбор переживает перезапуск

Русский и английский по выбору — в приложении и в десктопе

Что и зачем: Ключ словаря — русская фраза целиком, а не идентификатор вида more.export.title. Продукт написан по-русски, русский здесь исходник, и при таком ключе строка, которую забыли перевести, показывается по-русски, то есть остаётся правдой; при ключе-идентификаторе она показалась бы как «more.export.title» — молчащая дырка превратилась бы в мусор на экране. Разметка переводится атрибутами (data-i18n и три его варианта для placeholder, aria-label и внутреннего HTML), оригинал запоминается при первом проходе в WeakMap, а не в DOM: обратный перевод обязан идти из русского исходника, иначе английский, переведённый второй раз, начал бы искать в словаре сам себя. Словарь общий у приложения и десктопа — половина фраз совпадает дословно, и две копии разошлись бы переводов через пять. Пустое значение в настройке означает не «русский», а «как в браузере»: человек, не открывавший настройки, получает свой язык, а не мой. Три теста сторожат словарь: строка в tr() без перевода, размеченная строка без перевода и мёртвый ключ, которого больше нет в продукте.

GET /v1/tags/labels
Чеклист проверки (4)
  • Ещё → «Оформление, язык, теги» → Englishзаголовки, чипы, подсказки и типы записей — по-английски, разом
  • Открыть запись и посмотреть бейдж типа«secret» / «note», а не «секрет» / «заметка»
  • Вернуть «Русский»всё возвращается, ничего не осталось на английском
  • Выбрать «Как в браузере» на английской системеинтерфейс английский без единого нажатия

«Что нового» показывается один раз на версию и берётся из того же источника, что и changelog

Что и зачем: Обновление приезжает молча — человек видит починенное приложение и не знает, что именно починили. Теперь после смены версии открывается список изменений: заголовок релиза и заголовки правок с пометкой вида. Три правила. Пришедшему впервые список исправлений не показывается — он не видел ни одной из этих ошибок, и «мы починили очередь захвата» для него не новость, а тарабарщина; первый запуск просто ставит отметку. Экран не открывается поверх работы: ни при открытой панели, ни когда в поле поиска уже что-то набрано. Источник один: whatsnew.json делает make_changelog.py из releases.json — два списка изменений разошлись бы на второй правке. Файл едет в кеше оболочки вместе с кодом, который описывает, и весит 17 КБ вместо сотен: за подробностями — ссылка на полный changelog.

Чеклист проверки (4)
  • Открыть приложение после обновленияэкран «Что нового» открылся сам, со списком изменений этой версии
  • Закрыть и перезагрузитьвторой раз не показывается
  • Ещё → «Оформление, язык, теги» → «Показать изменения»три последних релиза, ссылка «Вся история изменений» открывает changelog
  • Начать печатать в поиске и перезагрузить с новой версиейэкран не выскакивает поверх набранного

«Что нового» появилось и в десктопе, где обновление ставят руками

Что и зачем: У десктопа нет автообновления: новая версия — это новый файл, который человек ставит сам. После установки он видит починенное окно и не знает, что именно починили, — то же слепое пятно, что было в приложении. Теперь список берётся с сервера новой ручкой `GET /v1/whatsnew`, которая отдаёт тот же срез releases.json, что лежит рядом с приложением. Именно с сервера, а не из файла в пакете: вшитый список устарел бы ровно в день сборки и рассказывал бы про позапрошлую версию. Правила те же три: впервые запущенному окну показывается не список чужих исправлений, а ничего (отметка ставится молча), экран не лезет поверх работы, источник один. Сеть необязательна — не ответила, значит экрана просто не будет.

GET /v1/whatsnew
Чеклист проверки (4)
  • Поставить новую версию .deb и запустить окноэкран «Что нового» открылся сам, список — этой версии
  • Закрыть и открыть окно сновавторой раз не показывается
  • Настройки → «Что нового» → «Показать изменения»три последних релиза
  • Отключить сеть и запустить окноокно работает, экран изменений просто не появляется

Порог показа тега: редкий тег уходит из панели, но не из записи и не из поиска

Что и зачем: На боевой базе 45 тегов, и 28 из них встречаются ровно один раз — панель превращается в свалку, где нужное не найти. Настройка «показывать тег, если он встречается не реже чем в N записях» убирает такие с глаз. Ровно с глаз: тег остаётся на записи, в индексе и в поиске, потому что редкий тег — это часто как раз то, что ищут («то, что я сохранял с Авито»). По умолчанию единица, то есть всё как раньше. Два исключения обязательны: выбранная сейчас ветка видна всегда, иначе фильтр нечем снять, и ветка, которую переименовывают, — иначе поле ввода исчезло бы из-под рук. Стрелка раскрытия считает видимых детей, а не всех: иначе она раскрывала бы пустоту.

Чеклист проверки (4)
  • Ещё → «Оформление, язык, теги» → поставить 3подпись говорит, сколько тегов скрыто; список сокращается сразу, на каждую цифру
  • Открыть «# теги»остались только ветки от трёх записей
  • Выбрать ветку и поднять порог выше её счётавыбранная ветка осталась видна, фильтр снимается
  • Десктоп → Настройки → Теги → «Не реже чем в»то же в полоске и в колонке

Исправлено 1

Короткий адрес перестал оставаться без тега сайта

Что и зачем: Имя сайта в тегах — одна из самых полезных автоматических пометок: по ней ищут «то, что сохранял с Авито», не помня ни заголовка, ни даты. Проверка на боевой базе показала, что механизм цел: тег есть у всех 25 записей со ссылкой. Но односимвольное имя выбрасывалось целиком — z.ai и t.me не давали ничего, потому что тег из одной буквы бесполезен. Он и правда бесполезен, а вот запись без единого следа источника — тем более: теперь такое имя берётся вместе со своей зоной, и получается z.ai, которое человек узнаёт с первого взгляда. Адрес без имени (голый IP) по-прежнему тега не получает.

POST /v1/capture
Чеклист проверки (3)
  • Сохранить ссылку на avito.ruв тегах записи есть avito
  • Сохранить ссылку на z.aiв тегах есть z.ai, а не пустота и не «z»
  • Сохранить ссылку на http://192.168.1.10/xтега-домена нет вовсе
доставка

Исправление теперь доезжает до открытого приложения, а застрявшая запись уходит сама

Исправлено 5

Записи, отвергнутые устройству, возвращаются в очередь без единого нажатия

Что и зачем: Утренняя правка научила приложение не считать 403 «устройство ещё не пущено» окончательным отказом — но только для НОВЫХ захватов. Те, что уже лежали отложенными навсегда, так и лежали: расколдовывало их только повторное связывание, а человек, подтвердивший устройство в боте, заново не связывается. Пользователь №7 разгрёб очередь руками, нажав «Повторить» на каждой карточке, — и это ровно та работа, которую машина обязана делать сама. Теперь удачная выборка с сервера считается доказательством, что устройство живо и пущено: всё, что раньше отвергли ЕМУ, а не записи, возвращается в очередь и уезжает. Старые задачи, отложенные ещё до того, как код ответа стал храниться, узнаются по тексту ошибки — других следов у них не осталось, а бросать их нельзя, они лежат у живых людей. Проверено на точной копии случая: подложенная задача старого вида уехала при первой же синхронизации.

POST /v1/captureGET /v1/sync
Чеклист проверки (2)
  • Открыть приложение, где записи помечены «сервер отказал (403)»После первой синхронизации пометки уходят, записи отправляются
  • Посмотреть на сервереЗаписи на месте

Окончательным считается отказ записи, а не любой ответ 4xx

Что и зачем: Приговор выносился всему, кроме 429: 408 «попробуй ещё раз», 404 и 405 «ручки нет — сервер не тот или деплой ещё не доехал» — всё это откладывалось навсегда наравне с испорченной посылкой. Теперь окончательные коды перечислены поимённо (400, 403, 409, 413, 415, 422), а всё остальное — «позже», с нарастающей паузой. При таком счёте каждый новый код ответа по умолчанию считается временным, а не приговором тому, что человек сохранил.

Чеклист проверки (1)
  • Уронить сеть на середине отправкиЗапись ждёт и уезжает сама, без пометки «сервер отказал»

Часы очереди заводятся и у того, кто связался в этой же сессии

Что и зачем: Таймер, дожимающий очередь, запускался только на старте и только если токен уже был. Человек, связавший устройство прямо сейчас, оставался без него — и его очередь уезжала лишь по ручной синхронизации. А это ровно тот человек, у которого очередь и есть: он сохранял, пока ждал подтверждения.

Чеклист проверки (1)
  • Связать устройство и сохранить заметку, ничего больше не нажимаяЗапись уходит сама в течение полуминуты

Обновление доезжает до установленного приложения, но не выдёргивает страницу из-под рук

Что и зачем: Установленное приложение живёт открытым неделями, и исправление до него не доезжало почти никогда. Причин было четыре, и все неочевидные: оболочка отдаётся из кеша, поэтому первое обновление страницы физически не может показать новый код — сама эта навигация и есть то, что заставляет браузер сходить за новой версией; «?v=» не помогает, потому что кеш ищется без учёта запроса; браузер сам проверяет воркер при навигации и раз в сутки, а в установленном приложении навигаций не бывает вовсе; и ничто в странице за этим не следило. Теперь приложение спрашивает о новой версии при каждой синхронизации, а когда та готова — говорит об этом в строке состояния и меняет её в момент простоя. Пока человек печатает или открыт экран ввода, страница не перезагружается: сэкономленное нажатие не стоит потерянной заметки. Кнопка ↻ ставит обновление сразу, если ждать не хочется. Заодно установка новой версии перестала зависеть от шрифтов и иконок — полмегабайта оформления на критическом пути доставки исправлений задерживали починку ради вида, а «всё или ничего» в кеше означало, что один неудачный байт шрифта отменял доставку кода молча.

Чеклист проверки (3)
  • Оставить приложение открытым и выложить новую версиюВ строке состояния появляется «есть обновление — ↻», а в простое страница обновляется сама
  • Выложить версию, пока открыт экран ввода с набранным текстомСтраница не перезагружается, текст цел
  • Нажать ↻ при готовом обновленииПриложение сразу перезапускается на новой версии

Десктоп: повторное связывание возвращает «не отправленное» в очередь

Что и зачем: Десктоп с самого начала знал, что 403 «ждём подтверждения» — это пауза, и на этом месте был здоров. Но рядом лежал соседний случай: сервер сносит непущенное устройство через 15 минут и дальше отвечает 401, а 401 — окончательный отказ. Владелец, не заметивший вопроса бота двадцать минут, получал всю очередь в «не отправлено» и разгребал её кнопкой. Теперь новое связывание само возвращает такие строки в очередь: отказ относился к устройству, которого больше нет, и приговором записям он не был.

Чеклист проверки (2)
  • Связать десктоп, сохранить пару записей и не подтверждать полчасаЗаписи уходят в «не отправлено»
  • Связать заново и подтвердитьОни возвращаются в очередь и уезжают сами
очередь и устройства

Запись, сделанная до подтверждения устройства, больше не застревает навсегда

Добавлено 1

Новое устройство можно пустить из приложения и с десктопа, а не только из бота

Что и зачем: До сих пор подтверждал только бот, и человек с телефоном в другой комнате связывался заново вместо того, чтобы разрешить с того клиента, который у него открыт. Сервер это умел с самого начала — подтвердить может любое уже пущенное устройство, — не хватало кнопок. Защита не слабеет: пускает тот, кто уже внутри, а внутрь без подтверждения не попасть; непущенное устройство не может пустить ни себя, ни соседа, и урезанный ключ ярлыка — тоже. В два нажатия, как отвязка, но по обратной причине: отвязку можно переиграть новым кодом, а «пустил чужого» не отменяется ничем.

POST /v1/devices/{id}/confirm
Чеклист проверки (3)
  • Связать новое устройство и открыть «Ещё» → «Устройства» на уже связанномСтрока «ждёт разрешения» с кнопками «Пустить» и «Не пускать»
  • Нажать «Пустить» дваждыПометка ожидания уходит, новое устройство начинает видеть базу
  • Нажать «Не пускать» дваждыУстройство исчезает из списка, его токен мёртв

Исправлено 2

403 «устройство ещё не пущено» — это ожидание, а не отказ

Что и зачем: Приложение считало любой ответ 4xx окончательным: раз сервер отказал, повторять бессмысленно, запись остаётся в очереди с пометкой «сервер отказал (403)» и ждёт человека. Для 403 «устройство ещё не пущено» это неверно — оно проходит само, как только владелец ответит боту или нажмёт «Пустить». Всё, что человек сохранил в те минуты, оставалось у него на экране навсегда и на сервер не уезжало: снаружи это выглядит как «на телефоне записи есть, а на втором клиенте и на сервере их нет», то есть как сломанная синхронизация, хотя синхронизация тут ни при чём. Десктоп знал это с самого начала — приложение не знало. Теперь такая запись ждёт в очереди и уезжает сама. Проверено вживую: захват на непущенном устройстве, затем подтверждение — запись доехала до сервера без единого нажатия.

POST /v1/capture
Чеклист проверки (2)
  • Связать новое устройство и сразу, не подтверждая, сохранить заметкуПометка «ждём подтверждения устройства», запись в очереди
  • Подтвердить устройство в боте или на связанном клиентеЗапись уезжает сама, очередь пустеет

После повторного связывания отвергнутое раньше получает второй шанс

Что и зачем: Связывание заново — новый факт, отменяющий старый отказ: токен другой, устройство другое, а «сервер отказал» относилось к тому, которого больше нет. Раньше такие захваты оставались на приколе, человек видел их в своём списке и считал сохранёнными, а на сервере их не было. Теперь при успешном связывании очередь снимается с прикола целиком.

POST /v1/auth/exchange
Чеклист проверки (1)
  • Дождаться, пока устройство отвалится, и связать зановоКрасные пометки с записей уходят, очередь начинает отправляться
хвосты 2.5

Звёздочка, дерево тегов, учёт байтов — и напоминания теперь заводятся не только в чате

Добавлено 5

Звёздочка: пять записей, к которым возвращаешься, лежат сверху

Что и зачем: Сисадмин неделя за неделей трогает одни и те же пять вещей, и до сих пор он искал их заново каждый раз. Теперь у записи есть один бит. Он ведёт список до запроса и умножает оценку поиска на полтора — именно умножает, а не выносит наверх: слабое совпадение со звёздочкой не должно обгонять сильное без неё. Ни вкладки «Избранное», ни пункта в меню не появилось и не появится (D-004): если для функции нужен новый экран, она не строится. Звезда стоит и на записи, и в строке списка — отметить пять штук должно стоить пять нажатий, а не пять открытий и пять возвратов. Нажатие ничего не ждёт от сети: бит меняется на месте, запрос уезжает следом, а если связи нет — уедет позже. Два места сделаны намеренно: строка поискового индекса при этом не переписывается вовсе (для зашифрованной записи лишняя перезапись — ровно то место, где однажды теряется обещание «шифртекст не индексируется»), и очередь у звезды своя, отдельная от очереди правок, потому что одному биту не о чем конфликтовать.

POST /v1/records/{id}/star
Чеклист проверки (4)
  • Нажать звёздочку в списке приложенияОна загорается сразу, запись не открывается
  • Очистить строку поискаОтмеченное идёт первым
  • Открыть ту же запись на десктопеЗвезда на месте
  • Отметить в самолётном режиме и включить сетьЗвезда никуда не пропала и доехала до сервера

Звёздочка есть и в боте — там, где чаще всего и сохраняют

Что и зачем: Бот — та поверхность, где записи появляются, и отмечать их логично там же. Кнопка стоит на карточке записи и меняет состояние на месте, не уводя ни на какой новый экран: отметка — это один бит, а не разговор. Текущее значение бот спрашивает у сервера в момент нажатия, а не помнит в кнопке: сообщение с кнопкой лежит в истории месяцами, и «поставить» из позавчерашней карточки не должно снимать сегодняшнюю отметку. Дерева тегов в боте при этом нет и не будет — три уровня вложенности, нарисованные кнопками в чате, это лестница, с которой не слезешь.

POST /v1/records/{id}/star
Чеклист проверки (3)
  • Открыть «Последние» → любую записьПервой строкой кнопка «☆ Отметить»
  • Нажать еёТа же карточка, кнопка стала «★ Снять отметку»
  • Открыть приложениеТа же запись отмечена и ведёт список

Теги стали деревом — но остались фильтром, а не второй системой раскладывания

Что и зачем: Тем, кто всё-таки хочет раскладывать по полочкам, теперь есть куда: «клиенты/акме/vpn» — обычный тег, а дерево из его частей собирает клиент. Сервер об этом не знает и знать не должен. Дерево живёт в строке фильтров, свёрнутое, и уходит с глаз, как только ветка выбрана: поиск остаётся первым экраном. Выбор ветки берёт и всё, что под ней. Переименование ветки — одно действие: правки ложатся в локальную базу, а уезжают обычной очередью, поэтому оборванная связь оставляет остаток в очереди, а не половину переименованного. В боте дерева нет намеренно — три уровня вложенности, нарисованные кнопками в чате, это лестница, с которой не слезешь. Группировка и переименование идут по хранимому тегу, а на экран попадает подпись на языке человека: дерево, построенное на подписях, раскололо бы одну тему надвое при первой же смене языка.

Чеклист проверки (4)
  • Сохранить запись с тегом «клиенты/акме/vpn»В дереве появляется ветка «клиенты» со счётчиком
  • Нажать на ветку «клиенты»В списке остаются все записи ветки, включая вложенные
  • Нажать на чип «# клиенты ✕»Фильтр снят, список полный
  • Переименовать ветку в приложенииМеняются и вложенные теги, внизу написано, сколько записей поехало

Напоминание можно завести из приложения и с десктопа, а не только в чате

Что и зачем: Напоминания жили в боте, хотя ничто этого не требовало: доставка через Telegram — это про доставку, а не про то, где их заводят. Теперь у записи есть кнопка «Напомнить», а в «Ещё» — список заведённых с отменой. Клиент шлёт точный момент, а не фразу: у кнопки «завтра в 9» разбирать нечего, она уже знает и день, и час, и часовой пояс. Разбор живой речи остаётся на сервере и остаётся в одном месте. Часовой пояс приложение и десктоп не спрашивают — они его знают и присылают молча; бот спрашивает кнопками ровно потому, что чату пояс неизвестен. Напоминание о секрете не несёт заголовка ни на одной поверхности: у секрета заголовок и есть значение. Без сети клиент честно говорит, что напоминание не заведено, — правило «ничего не ждёт сети» про захват, а напоминание, которое молча никуда не уехало, хуже отказа, потому что на него уже понадеялись.

GET /v1/remindersPOST /v1/remindersDELETE /v1/reminders/{id}POST /v1/settings/timezone
Чеклист проверки (4)
  • Открыть запись в приложении и нажать «Напомнить» → «завтра в 9:00»Ответ называет дату и час по местному времени
  • Открыть «Ещё» → «Напоминания»Заведённое в списке, рядом «Отменить»
  • Завести напоминание о секретеЭкран говорит, что название не появится; в списке — «сохранённый секрет»
  • Дождаться срабатыванияСообщение приходит в Telegram, как и раньше

Расход считается в байтах, а не только в сохранениях

Что и зачем: Рычагом против злоупотребления назван бесплатный лимит, а он не видел того, что реально тратится: сто документов по двадцать мегабайт стоят дороже тысячи ссылок, а потолок в сто сохранений против них бессилен. Теперь чисел два, потому что и вопроса два. «Сколько прислал» не уменьшается никогда — лимит, который сбрасывается удалением, это не лимит. «Сколько занимает» считается по факту и честно падает, когда файл действительно стёрт. Начальное значение взято с диска: счётчик, заведённый нулём при полном диске, врал бы с первого дня, а что было удалено раньше — неизвестно, и придумывать это никто не станет. Байты считаются там, где они легли на диск, а не там, где о них рассказали в запросе. Ничего не применяется и никому не показывается: включать лимит — решение владельца, а не деталь выкладки.

GET /v1/billing/status
Чеклист проверки (3)
  • Сохранить файл через ботаВ /v1/billing/status выросли и bytes_total, и bytes_stored
  • Удалить эту запись и стереть её из корзиныbytes_stored упал, bytes_total остался
  • Посмотреть любой экран приложенияНикаких счётчиков расхода нигде не появилось

Исправлено 1

Подписи тегов на русском не работали в приложении со вчерашнего дня

Что и зачем: Вчера ручка со словарём подписей была закрыта авторизацией — правильно закрыта. Но запрос за словарём в приложении уходил в момент загрузки, ДО того как из хранилища читался токен, и с тех пор получал отказ. Молча: словарь просто оставался пустым, и теги показывались ключами — food вместо «рецепты». Тесты при этом были зелёные, потому что проверяли ручку, а не порядок загрузки. Найдено прогоном живого приложения. Теперь словарь запрашивается после токена и только при его наличии.

GET /v1/tags/labels
Чеклист проверки (2)
  • Открыть приложение и посмотреть теги под записями«рецепты», «документы», «здоровье» — по-русски, а не food/documents/health
  • Открыть дерево теговПодписи там же на русском, а фильтр по-прежнему работает по ключу
теги быта

Рецепты, справки и квитанции наконец получают теги — и на своём языке

Добавлено 1

Бытовые теги подписаны на языке человека

Что и зачем: В базе тег хранится один и всегда английский — иначе одна тема расползлась бы по корпусу двумя строками, «рецепты» и food, и перестала находиться; фильтр по тегу тоже работает по ключу. Меняется только подпись: приложение берёт язык браузера, бот — язык, который присылает Telegram, десктоп — язык системы. Технические теги не переводятся: docker и nginx — имена, а не слова. В десктопе словарь подписей лежит локально, потому что поиск там работает без сети, и подпись, за которой надо идти в интернет, в офлайне стала бы пустым местом.

GET /v1/tags/labels
Чеклист проверки (2)
  • Открыть приложение с русским языком браузераТеги подписаны «рецепты», «документы», «дети»
  • Нажать на тег в десктопеФильтр срабатывает — отбор идёт по ключу, а не по подписи

Исправлено 2

Словарь тегов знал только технику — теперь знает и быт

Что и зачем: Владелец заметил, что рецепты не получают тегов, и это оказалось правдой по трём разным причинам сразу. Первая: словарь состоял из сорока семи технических слов — docker, nginx, postgres — и ни одного бытового, поэтому рецепт, присланный текстом, не подходил ни под одно правило. Вторая: у рецептов со ссылкой тегом становился домен сайта — gastronom, iamcook, chefmarket, а у одного даже menu, из адреса 1000.menu; это отвечает на вопрос «откуда взято», а не «про что это». Причина общая: правила писались под корпус технаря, а два дня назад продукт получил второй сегмент, который хранит анализы, справки и документы детей — там технических слов нет вовсе. Теперь словарь знает рецепты, документы, здоровье, детей, платежи, покупки, авто, поездки и дом.

Чеклист проверки (3)
  • Прислать боту рецепт текстомПоявляется тег «рецепты»
  • Сохранить полис или справкуТег «документы»
  • Открыть старые рецепты после пересчётаТеги на месте, прежние теги из импорта закладок не пропали

Тег больше не берётся из служебных ссылок страницы

Что и зачем: Салат «Морковь с майонезом» был помечен тегом javascript. Слово пришло не из содержания, а из мусора страницы: у ссылок на сайте рецептов адрес выглядит как javascript:void(0), и словарь честно нашёл в нём своё слово. Тег, соврамший о записи, хуже отсутствующего — по нему потом ищут и не находят. Теперь адреса внутри текста не считаются содержанием: ссылка на документацию docker больше не делает заметку про docker, а само слово в тексте по-прежнему находится.

Чеклист проверки (2)
  • Сохранить рецепт с сайта, где ссылки сделаны через javascript:void(0)Тега javascript нет, есть «рецепты»
  • Сохранить заметку со словом javascript в текстеТег javascript на месте
фокус

Окно и поле ввода получают фокус сразу, а не через полсекунды

Исправлено 3

Окно десктопа по горячей клавише слушается сразу

Что и зачем: Окно всплывало живым на вид и мёртвым на деле: Escape уходил в прежнее приложение, клик мимо ничего не закрывал, набранное пропадало — пока не ткнёшь мышью внутрь. Дело не в показе, а в фокусе: оконный менеджер защищает того, кто печатает, от окон, лезущих под руку, и отличить нас от такого окна может только по времени запроса. Мы просили фокус так, как умеет библиотека, и со старым временем — замерено, что он доходил через 200 и 459 миллисекунд на одной и той же машине. Теперь окно просит фокус временем самого X-сервера, а если среда рабочего стола выдала запускающему процессу токен активации, он передаётся сюда и предъявляется вместо доказательства «показ затеял человек». Плюс просьба повторяется до результата, а не один раз. Замер после правки: 15–27 мс вместо 200–459.

Чеклист проверки (3)
  • Печатая в другом приложении, вызвать окно горячей клавишейКурсор сразу в поле, набранное попадает в него
  • Сразу после появления нажать EscapeОкно закрывается с первого раза
  • Вызвать окно и кликнуть мимо негоОкно скрывается

В приложении по «плюсу» курсор снова встаёт в поле, и клавиатура выезжает

Что и зачем: Регрессия вчерашней правки, найденная владельцем в тот же день. Починка наезда клавиатуры перенесла установку курсора на кадр позже — а Safari поднимает экранную клавиатуру только тогда, когда курсор ставится внутри самого касания. Отложенный на кадр вызов из касания уже вышел, и клавиатура не выезжала вовсе: приходилось тыкать в поле руками. Курсор снова ставится синхронно с нажатием, а наезд клавиатуры лечится тем, чем и был вылечен, — подъёмом листа по реальной видимой высоте экрана. Обе вещи проверены одновременно: курсор в поле и поле над клавиатурой.

Чеклист проверки (2)
  • На телефоне нажать «+»Клавиатура выезжает сама, курсор уже в поле
  • Посмотреть на поле ввода при открытой клавиатуреВидно целиком, клавиатура его не накрывает

После переноса окна мышью оно по-прежнему слушается

Что и зачем: Перенёс окно за заголовок — и оно переставало закрываться: ни Escape, ни клик мимо, пока не ткнёшь курсором обратно в поле. Причина в том, что окно тащит менеджер окон, и на это время фокус уходит; возвращает он его окну, а внутри страницы курсор достаётся уже не полю, а пустому месту. А вся логика Escape висела на самом поле ввода — то есть клавиша была адресована никому. Теперь Escape ловится на уровне всей страницы и сперва возвращает курсор туда, где ему место, а фокус после переноса запрашивается тем же способом, что и при показе окна, — со свежим временем сервера. Проверено на всех трёх симптомах: Escape закрывает, клик мимо закрывает, а текст, набранный сразу после переноса без единого клика, попадает в поле.

Чеклист проверки (3)
  • Вызвать окно, перетащить его за строку под поиском, нажать EscapeОкно закрывается с первого раза
  • Перетащить окно и сразу печатать, ничего не нажимаяТекст попадает в поле поиска
  • Перетащить окно и кликнуть мимо негоОкно скрывается в трей
поделиться

Поделиться наружу, принять извне — и клавиатура больше не закрывает поле

Добавлено 3

Кнопка «Поделиться» на записи и на каждом вложении

Что и зачем: Отдаётся содержимое, а не ссылка на нас: заголовок, ссылка и заметка, а у вложения — сам файл. Публичная ссылка на запись выглядела удобнее и стоила бы дороже — это второй способ читать базу без входа, ему нужен срок жизни, отзыв и аудит, и он прямо конфликтует с шифрованием третьей фазы: зашифрованную запись на такой странице всё равно не показать. Секретом поделиться нельзя вообще — ни значением, ни заголовком, потому что у секрета заголовок и есть значение; кнопки на нём просто нет. Там, где системного меню обмена не существует (компьютер), та же кнопка честно называется «Скопировать» или «Сохранить» и говорит, что сделала.

Чеклист проверки (4)
  • Открыть запись со ссылкой в приложении и нажать «Поделиться»Системное меню с заголовком, ссылкой и заметкой; на компьютере — «Скопировать»
  • Открыть запись с фотографией и нажать «Поделиться» под нейУходит сам файл, а не ссылка на него
  • Открыть запись-секретКнопки «Поделиться» нет
  • В десктопе раскрыть запись с вложением и нажать «Сохранить файл»Файл в ~/Downloads, в ответе назван полный путь

Приложение принимает то, чем поделились из другого приложения

Что и зачем: «Поделиться» в браузере, галерее или файловом менеджере теперь предлагает BrainKeeper, и присланное — текст, ссылка или файл — попадает в ту же очередь, что и всё остальное: ничего не ждёт сети. Принимает служебный воркер, а не страница, потому что в момент отправки приложение может быть вообще закрыто; он складывает присланное в отдельный кеш, а приложение забирает его оттуда и чистит за собой, чтобы то же самое не приехало во второй раз. Кеш переживает обновление оболочки: прислать документ и тут же потерять его из-за приехавшего обновления — худший из возможных исходов.

POST /v1/capture
Чеклист проверки (3)
  • На Android поделиться ссылкой из браузера в BrainKeeperПриложение открывается, запись в списке, статус «в очереди»
  • Поделиться фотографией документаЗапись типа «файл», картинка открывается внутри записи
  • Поделиться, когда устройство не подключеноЧестно сказано «сначала подключите устройство», ничего не теряется молча

iPhone: ярлык в «Командах» и ключ, который умеет только сохранять

Что и зачем: На iOS веб-приложение не может появиться в системном меню «Поделиться» — так устроена система, и обойти это нечем, кроме «Команд». Поэтому приложение выдаёт ключ для ярлыка и пошаговую инструкцию. Ключ урезан намеренно: он живёт в чужом приложении, где его прочитает всякий, кто держит телефон, и считать его секретом наравне с токеном компьютера нельзя. Этим ключом можно только сохранить запись — ни прочитать, ни удалить, ни выгрузить архив, ни выпустить второй такой же. Утечка ярлыка значит «чужой пишет мне в инбокс», а не «чужой прочитал всё, что я сохранил». Запрет стоит в авторизации, а не в отдельных ручках: маршрут, добавленный завтра, иначе оказался бы открыт по умолчанию.

POST /v1/devices/shortcut
Чеклист проверки (4)
  • Настройки → «Сделать ключ для ярлыка»Ключ показан один раз, под ним семь шагов с полным адресом API
  • Собрать ярлык по шагам и поделиться в него ссылкой с айфонаЗапись появляется в приложении
  • Попробовать этим же ключом открыть список записейОтказ scope_capture_only
  • Найти ярлык в списке устройствПомечен «только сохраняет», отвязывается той же кнопкой, что и остальные

Исправлено 3

Экранная клавиатура больше не закрывает поле ввода

Что и зачем: Нажатие «+» ставило курсор в поле, клавиатура выезжала и накрывала это самое поле: приходилось нажимать «Готово» и тыкать в него руками, чтобы увидеть, что пишешь. Причина в том, что на iOS клавиатура не уменьшает окно страницы — прижатый к низу лист остаётся там, где был. Теперь приложение спрашивает у системы, сколько экрана реально видно, и поднимает лист ровно на закрытую часть; на Android ту же работу делает сама страница. Проверено измерением: в окне высотой 729 точек низ поля был на 618 при клавиатуре, начинающейся с 409, — то есть под ней; после правки он на 300, то есть над ней.

Чеклист проверки (3)
  • На телефоне нажать «+» в приложенииПоле ввода видно целиком над клавиатурой, курсор уже в нём
  • Закрыть клавиатуру кнопкой «Готово»Лист опускается обратно, пустой полосы внизу не остаётся
  • Открыть правку записи и поставить курсор в текстПоследние строки не уезжают под клавиатуру

Ключ ярлыка больше не читает записи через единственную разрешённую ручку

Что и зачем: Ключ для ярлыка задумывался как «умеет только сохранять», и список разрешённых адресов это обеспечивал — все остальные отвечали отказом. Проверка перед выкладкой показала, что этого мало: разрешённый адрес сам рассказывал лишнее. Сохранение отвечает «это уже есть» вместе с заголовком, заметкой и тегами, когда узнаёт присланное — по ключу повтора (бот делает его из номера сообщения, а это небольшое растущее число) или по совпадению содержимого. Повторяя такие ключи, урезанный токен читал чужие записи, а у секрета заголовок и есть значение. Тем же путём он мог переписать чужую заметку, которую не вернёт ничто: корзина спасает удалённую запись, а не переписанное поле. Теперь урезанному ключу отвечают одинаково всегда — одним «сохранено», с одним и тем же кодом состояния, потому что «двести против двести одного» само по себе отвечает на вопрос «а это у него уже сохранено?». Урок записан в решении: список разрешённых адресов говорит, куда токен может пойти, а не что он там узнает.

POST /v1/capture
Чеклист проверки (3)
  • Сохранить ключом ярлыка запись с известным ключом повтораВ ответе только «сохранено» — ни заголовка, ни заметки, ни тегов
  • Прислать тем же ключом уже сохранённую ссылку и заведомо новуюОтветы и коды состояния неразличимы
  • Прислать ключом ярлыка уже сохранённую запись с другой заметкойПрежняя заметка на месте

«Поделиться» с айфона не умирает на странице ошибки

Что и зачем: Присланное системой приходит POST-запросом, и отвечает на него служебный воркер внутри браузера. Но когда воркера нет — сразу после установки, во время обновления или если браузер его выселил — запрос доходит до сервера, а статика на POST отвечает «метод не разрешён». Человек видел страницу ошибки вместо приложения. Теперь на этот адрес отдаётся само приложение: присланное всё равно потеряно, но открытое приложение об этом хотя бы говорит, а страница ошибки не говорит ничего.

Чеклист проверки (2)
  • curl -X POST https://brainkeeper.app/app/share200 и разметка приложения, а не 405
  • Поделиться в приложение сразу после установкиПриложение открывается
напоминания

Напоминания: словами, в свой часовой пояс и с повтором

Добавлено 2

«/remind завтра в 9 забрать справку» — напоминание из одной строки

Что и зачем: Просьба пришла снаружи, а не из головы: трое опрошенных 2 августа, все на айфонах, независимо друг от друга сказали, что телефонные напоминания неудобны, и двое назвали это сами, без вопроса. Теперь бот понимает живую фразу: «завтра», «через неделю», «в 7 вечера», «15.08 в 9», «каждый месяц 5 числа». Разбор живёт на сервере, а не в боте, — та же строка должна одинаково пониматься в чате, в приложении и в десктопе, иначе «через неделю» начнёт значить в разных окнах разное. Напоминанию не нужна запись: «забрать справку» само по себе — законный повод. Чего в фразе нет, то не выдумывается: если часа не названо, бот переспрашивает кнопками, а не подставляет свою догадку — напоминание не в тот момент хуже, чем его отсутствие, потому что на него уже понадеялись.

POST /v1/remindersGET /v1/remindersDELETE /v1/reminders/{id}POST /v1/reminders/{id}/snoozePOST /v1/settings/timezone
Чеклист проверки (4)
  • Прислать боту «/remind завтра в 9 забрать справку»Отвечает «Напомню 03.08 в 09:00» и называет, о чём
  • Прислать «/remind каждый месяц 5 числа оплатить садик»В ответе есть «каждый месяц», в списке напоминаний — дата пятого числа
  • Прислать «/remind забрать анализы» — без времениСпрашивает «Когда напомнить?» кнопками, текст сохранён и виден в вопросе
  • Открыть любую запись в боте и нажать «Напомнить»Четыре варианта времени; после выбора — предложение повторять

Часовой пояс спрашивается один раз — и время показывается местное

Что и зачем: «В девять утра» без пояса — не время, а догадка. Пояс спрашивается в момент первого напоминания, а не при знакомстве: до него ответ никому не нужен. Фраза при этом не теряется — она ждёт ответа и превращается в напоминание сразу после выбора города. Показывается тоже местное время: на просьбу «завтра в 9» ответ «напомню в 06:00» выглядит как ошибка ровно там, где всё правильно, — просто отвечено по Гринвичу.

POST /v1/settings/timezone
Чеклист проверки (3)
  • Поставить первое напоминание, не задав поясБот спрашивает город восемью кнопками, ничего не создав
  • Выбрать городНапоминание создаётся из той самой фразы, время названо в местном поясе
  • Поставить второе напоминаниеПро пояс больше не спрашивают

Исправлено 2

«Отложить» работает и на уже пришедшем напоминании

Что и зачем: Кнопка «Отложить» приходит внутри самого напоминания, а разовое к моменту нажатия уже закрыто отправкой — и проверка «только активное» делала эту кнопку мёртвой ровно тогда, когда её нажимают. Найдено прогоном вживую, а не рассуждением. Теперь отложить можно и сработавшее: оно снова становится активным на новое время. Отменённое остаётся отменённым — там человек сказал «нет» сам.

POST /v1/reminders/{id}/snooze
Чеклист проверки (2)
  • Дождаться напоминания и нажать «😴 Через день»«Отложил до …» с местным временем, напоминание снова в списке
  • Отменить напоминание в списке и попробовать отложить его старой кнопкойОтложить не удаётся — отменённое не воскресает

Повтор догоняет, а не отыгрывает пропущенное

Что и зачем: Сервер молчал сутки — «каждый день» не должно высыпать в чат стопку пропущенных сообщений. Ищется ближайшее срабатывание в будущем: человеку важно, что напоминание живо, а не сколько раз оно не пришло. Отдельно: 31 января плюс месяц — это 28 февраля, а не 3 марта, потому что заведший напоминание на последний день месяца имел в виду последний день. И отметка об отправке ставится только после успешной отправки: упавший Telegram не должен выглядеть как доставленное напоминание, иначе оно исчезнет молча и навсегда.

GET /v1/internal/reminders/duePOST /v1/internal/reminders/{id}/fired
Чеклист проверки (2)
  • Остановить бота на час при активном ежечасном напоминании и запустить сноваПриходит одно сообщение, а не стопка за каждый пропущенный час
  • Завести ежемесячное на 31 январяСледующее срабатывание — 28 февраля
заголовки пересланного

Пересланное из канала называется тем, что в нём написано

Исправлено 1

Заголовок берётся из содержания, а не из строки «Переслано от»

Что и зачем: Бот ставит «Переслано от: <канал>» первой строкой, и заголовком становилась именно она. В базе владельца три рецепта из одного канала — лук, сало и мойва — выглядели в списке одной и той же строкой, а поиск по заголовку не находил ни одного: искать «сало» бесполезно, если запись называется именем канала. Теперь служебная строка не претендует на заголовок: он собирается из первой содержательной строки, а откуда пришло — остаётся в теле, где ему и место. Единственное исключение: если кроме пометки в сообщении ничего нет, она и остаётся заголовком — пустая строка была бы хуже. Разбор чинит новые записи, а для уже сохранённых есть разовая пересборка: она правит только заголовок, перестраивает индекс, переписывает markdown и поднимает номер ревизии, чтобы новое имя доехало до всех устройств.

Чеклист проверки (4)
  • Переслать боту сообщение из каналаЗаголовок — первая осмысленная строка сообщения, а не имя канала
  • Переслать второе сообщение из того же каналаЗаголовки разные; в списке видно, что это две разные записи
  • Найти пересланное по слову из его текстаНаходится по заголовку, а не только по телу
  • Открыть старую пересланную запись после пересборкиИмя канала на месте в тексте, заголовок — по содержанию
повтор

Прислал то же самое — получаешь «уже сохранено», а не третье подтверждение

Исправлено 2

Ответ на повтор больше не похож на сохранение

Что и зачем: Владелец прислал одну ссылку три раза и решил, что записей стало три. Сервер всё это время создавал одну — дедуп по канонизированному адресу работает и режет utm-хвосты, регистр и хвостовой слеш, — но отвечал бот одинаково: та же карточка, тот же вид, а «уже было сохранено» приписывалось мелкой строкой под заголовком. Человек верил тому, что видел, и был прав: показали ему именно подтверждение. Теперь повтор — отдельный ответ: «📌 Уже сохранено» с датой первого сохранения и кнопкой, которая открывает ту самую запись. Дата не украшение: без неё непонятно, сохранял это минуту назад или в прошлом месяце. Кнопка тоже: вторая присылка чаще всего и есть попытка найти сохранённое, потому что искать человек не полез. Заголовок секрета при этом по-прежнему не печатается — повтор не повод вернуть значение в переписку.

POST /v1/capture
Чеклист проверки (4)
  • Прислать боту ссылку, которая уже сохранена«📌 Уже сохранено — дата», а не обычная карточка сохранения
  • Нажать «Открыть запись» в этом ответеОткрывается та запись, что уже была
  • Прислать что-то новоеОбычное подтверждение сохранения, как раньше
  • Прислать повторно пароль, который бот уже сохранял«Уже сохранено» без самого значения в тексте

Десктоп ловит повтор до очереди, а приложение называет дату

Что и зачем: В десктопе ответ сервера о дубликате не обрабатывался вовсе: каждое нажатие добавляло строку в очередь, и человек видел три штуки там, где на сервере лежала одна. Теперь повтор ловится на входе, ещё до сети — по точному адресу или точному тексту, как и просили: две страницы одного форума остаются разными записями, «купить хлеб» и «купить хлеб и молоко» тоже. Найдя повтор, окно не закрывается и очередь не растёт: показывается та запись, которая уже есть. Работает и офлайн, включая случай «нажал дважды подряд, пока ничего не уехало». В приложении к сообщению о дубликате добавилась дата первого сохранения.

Чеклист проверки (3)
  • Сохранить в десктопе ссылку, которая уже есть«Уже сохранено — дата», окно остаётся, показывается старая запись
  • Нажать сохранение дважды подряд без сети«Уже в очереди — второй раз не нужно», в очереди одна строка
  • Сохранить ссылку на другую страницу того же сайтаСохраняется как новая запись — это не повтор
корзина

Удалённое можно вернуть — и можно убрать по-настоящему

Добавлено 2

Корзина на 30 дней: «я стёр не то» теперь имеет ответ

Что и зачем: Удаление раскатали на все поверхности — две кнопки в приложении, две в десктопе, /del ответом в боте, — а страховки под ним не было: единственной оставался ночной бэкап, из которого одну запись не достать без ручной работы в SQL. Теперь удалённое лежит 30 дней и возвращается одним нажатием с любой поверхности, включая бота — удаляют-то чаще всего именно там. Возврат возвращает запись и в поиск: индекс перестраивается, номер ревизии растёт, поэтому на остальных устройствах она тоже перестаёт быть удалённой, а не остаётся стёртой навсегда. Один отказ намеренный: если после удаления то же самое сохранили заново, возврат поверх живого двойника не делается — вместо двух одинаковых заголовков в поиске приложение говорит, где лежит живая копия. Корзина серверная: без сети её не открыть, зато удалённое не лежит копией в каждом клиенте. Сохранение по-прежнему ничего не ждёт.

GET /v1/trashPOST /v1/records/{id}/restore
Чеклист проверки (5)
  • Удалить запись, открыть «Ещё» → Корзина в приложенииЗапись в списке с датой удаления и типом
  • Нажать «Вернуть»Запись исчезает из корзины и снова находится поиском
  • В боте: меню → 🗑 Корзина → нажать на запись«Вернул: …» и запись снова ищется
  • Настройки десктопа → Корзина → «Вернуть»То же самое, список обновляется сам
  • Удалить запись, сохранить то же самое заново, попробовать вернуть удалённую«Такая запись уже есть — восстанавливать нечего», дублей в поиске нет

Окончательное удаление: строка, файл, вложения — и честный срок про копии

Что и зачем: Второй вопрос, на который не было ответа: «я вставил боевой пароль клиента, убери его по-настоящему». Мягкое удаление файл не трогает — и правильно, файлы это истина продукта, — но именно поэтому и нужна отдельная дверь. Стирание сносит строку, markdown-файл, байты вложений и запись индекса; это единственное место, где продукт намеренно уничтожает истину, и обставлено оно двумя нажатиями. Мгновенным оно не бывает, и говорится об этом прямо в интерфейсе: из ночных копий запись уйдёт за 14 дней ротации, а не «сразу». В боте стирания нет — и не спрятанной кнопкой, а запретом на стороне сервера: в чате легко нажать не туда, а сообщение с кнопкой лежит в истории месяцами. Отдельно сделано надгробие, переживающее саму запись: клиент, который в момент стирания был офлайн, иначе никогда не узнал бы об удалении, и запись осталась бы у него навсегда. Само надгробие тоже смертно — уходит через тот же срок. Ночная уборка идёт через 23 минуты после бэкапа, чтобы запись, у которой сегодня вышел срок, попала в последнюю копию: иначе «удалено по сроку» и «утеряно» — одно и то же событие.

POST /v1/records/{id}/purgePOST /v1/trash/purge
Чеклист проверки (5)
  • В корзине нажать «Стереть» один разКнопка превращается в «Стереть навсегда?» — одного нажатия недостаточно
  • Подтвердить и посмотреть на диск сервераMarkdown-файл записи и байты вложений исчезли
  • Синхронизировать другое устройство после стиранияЗапись пропадает и там — надгробие доезжает
  • Попробовать стереть из ботаКнопки нет, а прямой запрос сервер отклоняет
  • «Очистить корзину»Тоже в два нажатия; живые записи не тронуты
окно слушается

Окно можно нести за шапку, и оно больше не исчезает посреди движения

Исправлено 2

Перенос окна больше не оборачивается растягиванием

Что и зачем: Ручкой для переноса была полоска в двенадцать пикселей между краем окна и полем ввода: заголовка у трей-попапа нет, а поле занимает почти всю шапку. Целясь в верх окна, попадаешь в шестипиксельную зону растягивания — и вместо переноса получаешь ресайз. Теперь окно носят за всю шапку, за строку подсказки под полем и за нижнюю полосу состояния; кнопки, поле и края исключены в обработчике, а не в разметке, чтобы следующая кнопка в шапке не стала молча ручкой. Заодно ушёл разворот окна по двойному клику — встроенный драг-регион Tauri вешает его сам, а для попапа это бессмыслица.

Чеклист проверки (4)
  • Схватить окно за строку подсказки под полем и перенестиОкно едет за курсором; раньше в этом месте не происходило ничего
  • Схватить за нижнюю полосу с числом записейОкно тоже едет
  • Дважды щёлкнуть по шапкеРазмер и место не меняются — разворота нет
  • Щёлкнуть по полю и набрать текст, нажать скрепку и шестерёнкуПоле и кнопки работают как раньше, окно при этом не двигается

Окно не пропадает, пока его тащат — и после того, как отпустили

Что и зачем: Пока оконный менеджер держит указатель, окно отчитывается о потере фокуса, и попап прятался прямо из-под руки. Защита от этого была сделана для растягивания, но не для переноса — а перенос стал нужен ровно тогда, когда появилась галка «открывать там же, где закрыли». Хуже того, защита ставилась отдельным вызовом: между «поставь щит» и «начни тягу» успевал прийти тот самый сигнал, и решение о прятанье принималось до щита. Это и была та спонтанность. Теперь тягу начинает сам Rust одной командой — гонки нет, — а срок защиты продлевается каждым движением окна, поэтому работает и Alt+drag самого оконного менеджера, о котором приложение узнать не может. После того как окно отпустили, оно просит фокус вместо того, чтобы прятаться: менеджер не обязан возвращать фокус тому, что он нёс, и любая проверка «нет фокуса — прячься» гасила попап ровно в момент установки на новое место.

Чеклист проверки (5)
  • Перенести окно и отпуститьОкно остаётся на новом месте видимым
  • Перенести три-четыре раза подрядНи один перенос не заканчивается исчезновением
  • Потянуть за край, растягивая окноОкно растягивается и остаётся на экране
  • Щёлкнуть по другому приложениюПопап прячется, как и раньше
  • Утащить окно за левый край экрана, закрыть и открытьОкно всплывает по центру: точка вне мониторов не применяется
устройства и окно

Список устройств называет систему и браузер, а окно десктопа тянется за края

Добавлено 2

Каждое устройство теперь опознаётся: тип, система, браузер — с версиями

Что и зачем: В списке стояло «wild · desktop» и сырая строка агента, по которой ничего не понять. Отвязать «вон то» из трёх похожих строк было угадайкой. Теперь каждое устройство называет себя: компьютер это или телефон, какая система и какой версии, какой браузер и какой версии. Строку собирает сервер и печатают все три поверхности одинаково — бот, приложение и десктоп. Отдельная работа была в том, чтобы не соврать: User-Agent давно не отвечает на этот вопрос, Chrome пишет «Windows NT 10.0» и на Windows 11, а macOS у него навсегда 10.15.7. Поэтому браузеры теперь сами присылают высокоэнтропийные Client Hints, а наш десктоп — название дистрибутива и версию ядра, которых в его строке агента нет вовсе. Там, где узнать нечем (Firefox подсказок не умеет), показывается «Windows 10/11», а не выбранный наугад вариант: честное «не знаю» лучше уверенной ошибки. Сырьё хранится как пришло, разбор считается при выдаче — таблица браузерных исключений будет меняться, и миграция ради каждой правки слишком дорога.

GET /v1/devices
Чеклист проверки (4)
  • В боте: «Устройства» → любое устройствоСтроки «тип», «система», при браузере — «браузер», у всех с версиями
  • Открыть приложение с телефона и посмотреть список устройствТелефон подписан телефоном, названы iOS/Android и версия браузера
  • Настройки десктопа → Подключённые устройстваУ своей машины видно дистрибутив и версию ядра, у остальных — их систему
  • Навести курсор на строку устройства в приложенииВо всплывающей подсказке — сырая строка агента, если разбор не удался

Окно десктопа тянется за любой край, и размер запоминается

Что и зачем: Окно было намертво 760×520: декораций у трей-попапа нет намеренно, а значит нет и рамки, за которую тянут. Теперь по периметру восемь невидимых полос, каждая просит оконный менеджер растянуть окно со своей стороны — тянет он сам, поэтому работает и под Wayland. Размер запоминается всегда: попап открывают десятки раз в день, и растягивать его заново никто не станет. Положение — по галке в настройках, потому что центр экрана предсказуем, а запомненная точка после отключения второго монитора оказывается за краем видимого; сохранённая позиция проверяется по мониторам, и если экрана там больше нет, окно всплывает по центру. На случай, когда всё же ужал до нечитаемого, в настройках есть «Сбросить размер». Отдельно пришлось защитить попап от самого себя: во время растягивания окно рапортует о потере фокуса и по прежним правилам пряталось бы прямо из-под тянущей руки.

Чеклист проверки (4)
  • Потянуть окно за правый нижний угол и за левый крайОкно растягивается, попап не исчезает во время движения
  • Закрыть попап и открыть зановоОткрывается тем размером, которым его оставили
  • Настройки → Окно → включить «Открывать там же, где закрыли», подвинуть окно, закрыть и открытьОкно всплывает на прежнем месте
  • Настройки → Окно → «Сбросить размер»Окно возвращается к 760×520 и встаёт по центру экрана
починка кнопок

Кнопки в боте не работали ни одна — маршрут нажатия был оборван

Исправлено 1

Нажатие кнопки снова доходит до меню

Что и зачем: Меню уехало на прод мёртвым: внутри capture маршрут нажатия ссылался на модуль, которого в той области видимости уже не было. Живой бот падал на NameError, нажатие проваливалось в анкету, а та молча ничего не делает — кнопка выглядела просто неживой, при том что команды работали. Причина промаха не в опечатке, а в тестах: все они звали обработчик меню напрямую, минуя точку входа, куда на самом деле приходит апдейт от Telegram. Теперь проверка идёт настоящим путём — через handle_capture_update, — и та же ошибка её валит. Плюс у ветки нажатий появилась своя ловушка: если меню сломается снова, человек увидит «кнопка не сработала», а не тишину.

Чеклист проверки (3)
  • Отправить /help и нажать любую кнопкуЭкран меняется; раньше не происходило ничего
  • Пройти по всем разделам и вернуться в менюРаботает каждая кнопка, включая возврат
  • Нажать кнопку в сообщении, отправленном до обновленияПоказывается меню
устройства и кнопочное меню

Видно, что за устройство, и любое из них можно отвязать; бот стал кнопочным

Добавлено 3

У устройства появилось лицо: адрес, версия клиента, обе даты

Что и зачем: «Отвязать вон то» требует сначала понять, какое из них «то», а имени и платформы для этого мало: три десктопа с одинаковым hostname выглядят как три одинаковые строки. Теперь сервер запоминает адрес при привязке и последний адрес, строку клиента и его версию — клиенты сообщают её сами, а если не сообщили, она достаётся из User-Agent. Гео намеренно нет: определять его можно либо чужим сервисом, которому пришлось бы отдавать адреса владельца, либо базой на десятки мегабайт. Адрес и строка клиента отвечают на тот же вопрос «это я или не я», ничего никому не отдавая. Устройства, привязанные до этой версии, показывают только то, что о них известно, — выдумывать за них нечего.

POST /v1/auth/exchangeGET /v1/devices
Чеклист проверки (3)
  • Открыть список устройств в приложении, десктопе или ботеУ каждого видно платформу, версию клиента, когда привязано, когда виделись последний раз и с какого адреса
  • Связать новое устройство и посмотреть его карточкуЗаполнены адрес, версия и обе даты
  • Посмотреть устройство, привязанное давноПоказано то, что известно; пустых выдуманных полей нет

Выборочная отвязка и «отвязать все, кроме этого»

Что и зачем: Раньше выбор был между «отключить себя» и ничем. Теперь в десктопе и приложении список с кнопкой у каждого устройства, отвязка в два нажатия — она необратима для того клиента, ему придётся связываться заново и получать разрешение. «Все, кроме этого» сервер понимает по токену, с которым пришли, так что запереть себя этой кнопкой нельзя — именно этого от неё и боятся. У бота своего устройства нет, поэтому там та же кнопка называется «оставить только это» и говорит явно, кого отвяжет.

DELETE /v1/devices/{id}POST /v1/devices/revoke-all
Чеклист проверки (4)
  • В десктопе или приложении нажать «Отвязать» у чужого устройстваКнопка становится «Точно отвязать?»; второе нажатие убирает устройство из списка
  • Проверить отвязанный клиентОн получает отказ на следующем запросе и просит новый код
  • Нажать «Отвязать все, кроме этого» дваждыОстаётся только то устройство, с которого нажимали, — сам себя не заперешь
  • В боте открыть устройство и нажать «Оставить только это»Бот перечисляет, кого отвяжет, и делает это только после подтверждения

Бот стал кнопочным — и ни один экран не заканчивается тупиком

Что и зачем: Команды помнит тот, кто их уже знает. Теперь у бота есть меню: найти, последние, устройства, код для приложения, сколько накопилось, экспорт. Правило, из которого выросло всё остальное: с любого экрана есть выход. Последний ряд любой клавиатуры — возврат, у вложенных экранов ещё и «к списку», а кнопка из старого сообщения после обновления бота не молчит, а показывает меню. Это проверяется обходом всего дерева в тестах, а не глазами. Навигация правит то же сообщение, а не шлёт новое, — иначе четыре нажатия оставляют в переписке четыре экрана. Команды никуда не делись: /find nginx одной строкой быстрее четырёх нажатий.

Чеклист проверки (7)
  • Отправить боту /start или /helpПоявляется меню кнопками; текстовые команды продолжают работать
  • Пройтись по всем разделам и вернутьсяС каждого экрана есть выход в меню, переписка не забивается экранами
  • Нажать «Найти» и прислать словоБот ищет и подсказывает, что для сохранения надо прислать это же ещё раз
  • Нажать «Найти», отвлечься и через час прислать ссылкуСсылка сохраняется, а не ищется — намерение протухает
  • Открыть «Последние», выбрать запись, удалитьКарточка записи с заметкой и тегами, удаление спрашивает подтверждение
  • Открыть карточку записи-секретаЗначение не показано ни в заголовке, ни в теле — только ссылка на приложение
  • Нажать кнопку в старом сообщении ботаПоказывается меню, а не тишина
экспорт с вложениями, подтверждение устройств

Архив уносит и картинки, а новое устройство теперь надо пустить

Добавлено 1

Новое устройство не видит базу, пока владелец не пустит его в боте

Что и зачем: Код из /link был единственным, что стояло между чужим человеком и всей базой: подсмотрел через плечо, увидел на скриншоте или в демонстрации экрана — вошёл. Десяти минут жизни кода для этого более чем достаточно. Теперь обмен кода заводит устройство «на подтверждении»: токен выдан, но сервер отвечает ему 403, пока владелец не скажет «да» в чате с ботом — то есть в канале, которого у подсмотревшего нет. Ответ «нет» отключает устройство совсем. Неподтверждённое умирает само через 15 минут: токен, который может ожить через неделю, — тот же подсмотренный код, только без срока. Уже связанные устройства считаются подтверждёнными — иначе владелец мгновенно теряет все свои клиенты ради защиты, которой в момент их связывания не было.

POST /v1/auth/exchangeGET /v1/devicesPOST /v1/devices/{id}/confirm
Чеклист проверки (6)
  • Ввести код в приложении или десктопеСказано «код принят — ответь боту», а не «связано»; в настройках десктопа то же самое
  • Дождаться вопроса бота и ответить «да»«Пустил», записи подтягиваются, состояние меняется на «Связано»
  • Ответить «нет»«Отключил», устройство получает 401 — токена больше нет
  • Отправить боту /devicesСписок устройств; ждущее помечено, и бот тут же спрашивает про него
  • Попробовать читать базу неподтверждённым токеном403 device_not_confirmed, и сохранённое за это время остаётся в очереди, а не уходит в «не отправлено»
  • Проверить свои старые устройства после обновленияРаботают как работали, подтверждать заново ничего не надо

Исправлено 1

Экспорт кладёт в архив вложения и говорит, к какой записи они относятся

Что и зачем: D-007 — «данные твои, забирай» — главный актив доверия в продукте, и он был выполнен наполовину: запись, вся ценность которой в скриншоте, уезжала markdown-файлом со ссылкой на картинку, которой в архиве нет. Теперь байты лежат в media/, а манифест связывает запись с файлом — без этой карты не понять, чей файл какой, потому что разметка зовёт вложение голым именем. Ссылочные media-строки без байтов на диске в архив не идут: класть туда нечего.

GET /v1/export
Чеклист проверки (3)
  • Сохранить картинку с подписью и забрать /exportВ архиве есть media/… с теми же байтами, что и оригинал
  • Открыть MANIFEST.mdРаздел Attachments связывает id записи с путём файла; в шапке — сколько вложений и сколько весят
  • Экспортировать базу без вложенийРаздела Attachments нет, архив как раньше
десктоп: удаление в очередь

Удаление в десктопе больше не ждёт сети, а неотправленное можно забрать назад

Добавлено 1

Неотправленную запись можно убрать из очереди

Что и зачем: Пока файлов не было, застрявшую в очереди запись можно было только повторять — убрать её было нечем. С приходом вложений это стало дырой: ошибочно прикреплённый файл лежал бы в очереди вечно и утаскивал за собой копию на диске. Теперь у неотправленного есть ровно одно действие — убрать, и оно уносит и строку очереди, и копию файла. Копировать и править там нечего: записи ещё нет, есть намерение её создать.

Чеклист проверки (3)
  • Без сети сохранить что-нибудь и найти это в поискеПоказано с меткой «в очереди» и единственной кнопкой «Убрать из очереди»
  • Нажать её дваждыСначала «Точно убрать из очереди?», потом строка пропадает и очередь пустеет
  • Убрать из очереди прикреплённый файлКопия файла в outbox тоже удалена

Исправлено 1

Удаление уходит через очередь, а не прямым запросом

Что и зачем: Это была единственная во всём приложении операция, которая вела себя как онлайновая: без связи удалить было нельзя, хотя в вебе — можно. Теперь локальная копия исчезает сразу, а сервер узнаёт следующим кругом синхронизации — то же правило, что у сохранения. Порядок внутри круга держит воркер: очередь уходит до подтягивания, поэтому удалённая запись не успевает вернуться обратно. Удаление в очереди не показывается в списке «не отправлено» — там то, что человек сохранил, а не то, что он стёр.

DELETE /v1/records/{id}
Чеклист проверки (3)
  • Отключить сеть, найти запись и удалить её в два нажатияЗапись пропала из списка сразу, ошибки нет; в состоянии видно «в очереди: 1»
  • Вернуть сеть и дождаться синхронизацииОчередь опустела, на сервере записи нет — ни в поиске, ни в приложении
  • Удалить одну и ту же запись дваждыВ очереди одна строка, а не две
бот: удаление

Бот научился удалять — ответом на своё же сообщение о сохранении

Добавлено 1

Ответ «/del» на сообщение о сохранении удаляет запись, с подтверждением

Что и зачем: Удалить из бота было нельзя вообще: в переписке нет способа показать на запись — кроме как ответить на то сообщение, которым бот о ней отчитался. Связка «сообщение бота → запись» уже была нужна заметкам, к ней добавился вид разговора, иначе ответ «да» сохранился бы заметкой со словом «да». Подтверждение спрашивается по той же причине, по которой оно есть в клиентах: удаление необратимо, а промахнуться в чате проще всего. Всё, что не согласие, читается как отказ — молчаливое удаление худший из возможных исходов. Вопрос одноразовый: ответить на него второй раз нечем.

GET /v1/records/{id}DELETE /v1/records/{id}
Чеклист проверки (6)
  • Ответить «/del» на сообщение бота о сохраненииБот спрашивает «Удалить «заголовок»?» и называет запись; ничего ещё не удалено
  • Ответить «да» на этот вопрос«Удалил «заголовок»»; запись пропала из /find и из приложений
  • Ответить «нет» или что угодно другое«Отменил. Запись на месте», запись цела
  • Ответить «да» на тот же вопрос ещё разОбрабатывается как обычная заметка — снятый вопрос не оживает
  • Прислать «/del» просто так, не ответомБот объясняет, что ответить надо на сообщение о сохранении
  • Ответить «да» на сообщение о сохранении, которого никто не спрашивал«да» становится заметкой, ничего не удаляется
десктоп: файл прикрепить

Десктоп научился прикреплять файлы — перетаскиванием, выбором и из буфера

Добавлено 1

Файл в десктопе: перетащить в окно, выбрать на диске или вставить из буфера

Что и зачем: Ссылку с подписью десктоп сохранял, а картинку с диска — нет: не было ни приёма файла, ни вида записи в очереди, который несёт путь. Теперь есть три входа, и все три ведут в одну очередь. Перетаскивание работает только потому, что вместе с ним появился экран «прикрепить»: пока он открыт, окно объявлено модальным и не прячется на потере фокуса — иначе попап исчезает на первом же клике по проводнику, и бросать в него нечего. Байты копируются в outbox сразу, а не читаются потом по пути: между Enter и синхронизацией оригинал могут переименовать или удалить, а очередь обязана это пережить. Отправленная копия удаляется, осиротевшие подчищаются при старте. Потолок 25 МБ — тот же, что у сервера, чтобы не узнавать про ограничение по 413 после долгой отправки.

POST /v1/capture
Чеклист проверки (6)
  • Нажать скрепку в поле и перетащить файл из проводника в окноОкно не спряталось, пока шли за файлом; под полем появилась полоска с именем файла
  • Написать «что это» и нажать Enter«Файл в очереди», окно закрылось, запись появилась после синхронизации — с картинкой и заметкой
  • Нажать Enter, не написав заметкуПредупреждение «без заметки файл потом не найти»; второй Enter всё равно сохраняет
  • Скопировать скриншот в буфер и нажать Ctrl+V в полеКартинка прикрепилась так же, как выбранная на диске
  • Найти файл по словам из заметкиНаходится — и пока он ещё в очереди, и после отправки
  • brainkeeper --attach ~/shot.png "щиток в подъезде"queued file …, запись уезжает на сервер следующим кругом

Исправлено 1

Подпись к файлу становится заметкой на сервере

Что и зачем: Раньше подпись к картинке оседала только в теле записи, а колонка note оставалась пустой — то есть подписанная картинка всё равно не находилась поиском, ради которого заметку и придумали. Теперь для kind=file действует то же правило, что уже было у ссылки: нет явной заметки — ею становится подпись. Явная заметка по-прежнему сильнее.

POST /v1/capture
Чеклист проверки (2)
  • Отправить боту картинку с подписью «щиток в подъезде»У записи заметка «щиток в подъезде», поиск по слову «щиток» её находит
  • Отправить файл, задав и text, и noteЗаметкой стало note, а не подпись
десктоп: ссылка с подписью

В десктопе ссылку можно подписать прямо в поле

Добавлено 1

Ссылка плюс строка в том же поле — строка становится заметкой

Что и зачем: Так человек и пишет: вставил ссылку, дописал, что это. Раньше вся строка целиком уезжала в поле адреса — подпись либо ломала адрес, либо терялась по дороге, потому что для ссылок текст вообще не отправлялся. Теперь первое слово читается как адрес, остальное — как заметка, и она доезжает до сервера вместе с захватом. Подсказка под полем говорит об этом прямо, иначе про возможность никто не узнает.

POST /v1/capture
Чеклист проверки (3)
  • Вставить в поле ссылку, дописать через пробел «палатка на выезд» и нажать EnterСохранилась ссылка, а «палатка на выезд» стала заметкой — видно в списке
  • Вставить ссылку без подписиСохранилась как раньше, пустой заметки не появилось
  • Найти запись по слову из подписиНаходится
разбор ссылок

Частичный разбор: два починенных случая и понятные причины отказа

Исправлено 1

Страницы, которые разговаривают только с построителями превью, теперь разбираются

Что и зачем: vkvideo.ru и ещё несколько площадок отдают обычному клиенту пустую оболочку, которую заполняет скрипт, а построителю превью — нормальные Open Graph теги с заголовком и описанием. Наш разбор мимикрирует под браузер и получал пустоту. Теперь при пустом результате идёт второй заход краулерным заголовком, и он же берётся, только если реально лучше — иначе мы бы меняли одну пустышку на другую и врали про «разобрано». Лишний запрос уходит только на неудачных страницах, то есть у большинства ссылок стоимость нулевая.

Чеклист проверки (2)
  • Отправить боту ссылку на видео с vkvideo.ruСохранился осмысленный заголовок и описание, а не «Video 1_2»
  • Отправить обычную статьюРазбирается как раньше, лишних запросов нет

Улучшено 1

Причина отказа перестала быть загадкой

Что и зачем: Строка «fetch_failed: ConnectError» склеивала отказ TCP-соединения и провал проверки сертификата — по записи в базе их было не отличить, а лечатся они по-разному. Теперь причины различимы: просроченный сертификат, сертификат на чужое имя, самоподписанный, отказ соединения, мёртвый домен. Подробность ищется по всей цепочке исключений: внешнее говорит «certificate verify failed», а «истёк» написано только во вложенном.\n\nОтдельно: мёртвый домен больше не выдаётся за отказ по безопасности. Раньше NXDOMAIN приезжал с префиксом blocked, бот писал «по этому адресу ходить нельзя», и человек шёл выяснять, что он нарушил, вместо того чтобы взглянуть на сам адрес.

Чеклист проверки (2)
  • Отправить ссылку на сайт с просроченным сертификатомБот говорит про просроченный сертификат, а не «не смог достучаться»
  • Отправить ссылку на несуществующий домен«Такого домена больше нет — проверь адрес», без слова «нельзя»
бот: заметка

Бот спрашивает «что это?» и принимает ответ заметкой

Добавлено 1

Ответ на сообщение бота дописывает заметку к той самой записи

Что и зачем: Картинку переслали на бегу, а объяснить её удаётся через час и три сохранения спустя. Поэтому заметка привязана не к «последней записи», а к конкретному сообщению бота: он помнит, о какой записи отчитался каким сообщением, и ответ на это сообщение уходит заметкой именно к ней. Проверка стоит раньше всего остального разбора — иначе такой ответ сохранился бы отдельной записью, и объяснение уехало бы мимо того, что объясняли. Ответ на постороннее сообщение по-прежнему сохраняется как обычно. Связок хранится две сотни на чат: это не история, а подсказка «на что можно ответить».

PATCH /v1/records/{id}
Чеклист проверки (3)
  • Переслать боту картинку без подписи и ответить на его сообщение строкой «план на квартал»Бот отвечает «Записал: план на квартал», запись находится по слову «квартал»
  • Ответить на давнее сообщение бота, а не на последнееЗаметка приклеилась к той записи, о которой было то сообщение
  • Ответить на чужое сообщение в чате обычным текстомСохранилось новой заметкой, как и раньше

Улучшено 1

Бот спрашивает «что это?» только там, где ответ нужен

Что и зачем: Вопрос появляется у файла без подписи и у ссылки, которую не удалось разобрать: у первого нет ни одного слова для поиска, у второй остаётся один адрес. У разобранной статьи есть и заголовок, и текст — там вопрос был бы просто шумом в переписке. Отвечать необязательно: игнор ничего не ломает, запись уже сохранена.

Чеклист проверки (3)
  • Отправить боту обычную статью ссылкойБот отчитался и ничего не спросил
  • Отправить ссылку на AvitoБот сказал, что разобрать не вышло, и спросил «что это?»
  • Отправить картинку с подписьюВопроса нет — подпись уже всё объяснила
заметка и правка в клиентах

Заметку видно и можно править — в приложении и в трей-клиенте

Добавлено 2

В трей-приложении появилась правка записи

Что и зачем: Править запись из десктопа было нельзя вовсе — ни заголовок, ни теги, ни текст. Теперь у каждой записи есть «Править»: отдельный лист поверх всего с заголовком, заметкой «что это», тегами и телом целиком. Пока лист открыт, окно держится модальным — правка ровно тот случай, когда человек уходит свериться в другое окно и возвращается, а попап на потерю фокуса прячется. Сохранение идёт на сервер и тут же кладёт ответ сервера в локальную копию: иначе правка была бы видна только после следующей синхронизации, и выглядело бы это как «не сохранилось». Ctrl+Enter сохраняет, Escape закрывает.

PATCH /v1/records/{id}
Чеклист проверки (3)
  • Выбрать запись в десктопе и нажать «Править»Открылся лист с заполненными заголовком, заметкой, тегами и телом
  • Изменить заметку и нажать «Сохранить»Лист закрылся, новая заметка видна в списке и доехала на сервер
  • Открыть правку и нажать EscapeЛист закрылся, ничего не изменилось

Заметку видно там, где она нужна: в списке, на экране записи и в редакторе

Что и зачем: В десктопе заметка выводится прямо в списке — для записи с картинкой или неразобранной ссылкой это единственное осмысленное, что в ней есть, и прятать это за раскрытием бессмысленно. В приложении она стоит первой строкой после заголовка и добавлена в форму правки. Локальная база десктопа получила колонку под заметку: у уже установленных копий она дописывается на первом запуске, потому что CREATE TABLE IF NOT EXISTS существующую таблицу не трогает. Поиск в десктопе видит заметку так же, как на сервере — она индексируется внутри тела.

Чеклист проверки (3)
  • Обновить десктоп поверх старой версии и открыть егоПриложение запускается, записи на месте, заметки приезжают с синхронизацией
  • Найти запись по слову, которое есть только в заметкеНаходится и в десктопе, и в приложении
  • Открыть запись с заметкой в приложенииЗаметка видна сразу под заголовком, отчёркнутая слева
миграция 003: заметка

Заметка «что это»: сервер научился принимать её и искать по ней

Добавлено 1

У записи появилась заметка — то, что человек пишет о ней своими словами

Что и зачем: Картинку иначе не найти: у фотографии нет ни одного слова, по которому её можно искать. Ссылка с Avito не разбирается, а недоступная без VPN не разберётся тем более — и от неё остаётся один адрес. Заметка — это то единственное, что в таких записях осмысленно.\n\nОна живёт отдельной колонкой, а не дописывается в тело. Тело — это то, что разобрал сервер, и смешивать его с тем, что думает человек, значит потерять границу между ними; вдобавок тело участвует в дедупликации, и заметка сдвигала бы её. В поисковый индекс заметка попадает внутри колонки тела: FTS5 не умеет добавлять колонки, и пятая означала бы пересборку индекса на сервере и во всех клиентах. Цена решения честная — заметка весит в поиске как тело, а не отдельно.\n\nЗаметка пишется и в markdown-файл, а не только в базу: файлы — источник истины (D-007), и заметка, пережившая лишь в индексе, при восстановлении из файлов пропала бы.

POST /v1/capturePATCH /v1/records/{id}
Чеклист проверки (3)
  • На сервере: sqlite3 /var/lib/brainkeeper/brain.sqlite3 "PRAGMA table_info(records)" | grep noteКолонка note на месте, тип TEXT
  • Сохранить что-нибудь с заметкой и поискать по слову только из заметкиЗапись находится
  • Открыть markdown-файл этой записиЗаметка видна отдельным блоком в начале тела

Исправлено 1

Текст рядом со ссылкой больше не выбрасывается

Что и зачем: Так человек и пишет: кидает ссылку и подписывает её одной строкой. Сервер эту строку молча терял — в ветке для ссылок поле текста не читалось вообще. Расширение обходило это, подменяя тип на «текст», то есть отказываясь от разбора страницы ради сохранения подписи. Теперь текст рядом со ссылкой становится заметкой, и обходной путь расширению больше не нужен.\n\nОтдельно починен сценарий «пришлю ссылку ещё раз, теперь с заметкой»: раньше дедупликация возвращала старую запись и заметку теряла. Теперь заметка дописывается к уже сохранённой записи — второй записи не появляется, дедупликация цела.

POST /v1/capture
Чеклист проверки (3)
  • Отправить боту ссылку и текст в одном сообщенииСсылка разобрана как ссылка, текст сохранён заметкой
  • Отправить ту же ссылку второй раз, дописав объяснениеНовой записи не создалось, а заметка у старой появилась
  • Найти запись по слову из заметкиНаходится
удаление: отмена подтверждения

Из «Точно удалить?» теперь есть выход назад

Исправлено 1

Взведённое подтверждение удаления нечем было снять

Что и зачем: Кнопка «Удалить» превращалась в «Точно удалить?» и оставалась в этом виде: подтвердить человек мог, а передумать — нет. Единственным выходом было уйти с записи. Теперь рядом с вопросом появляется «Отмена», и то же делает Escape — он снимает вопрос раньше, чем закрывает запись или окно, потому что здесь Escape читается как «передумал». В трей-приложении подтверждение вдобавок само снимается, когда список меняется под ним: при переходе на другую запись стрелками или мышью и при любом новом запросе. Вопрос, заданный про одну запись, не должен молча оказаться про другую.

Чеклист проверки (3)
  • Нажать «Удалить» и затем «Отмена»Кнопка снова «Удалить», запись на месте
  • Нажать «Удалить» и нажать EscapeВопрос снят, запись и окно на месте; второй Escape закрывает как обычно
  • В десктопе взвести вопрос и перейти стрелкой на соседнюю записьВопрос снят сам — у соседней записи обычная кнопка «Удалить»
удаление записей

Записи стало можно удалять из десктопа, а в приложении удаление вышло на видное место

Добавлено 1

Удаление записи в трей-приложении

Что и зачем: Раньше из десктопа удалить запись было нечем вовсе. Теперь у выбранной записи есть «Удалить», и оно в два нажатия: первое превращает кнопку в вопрос, второе удаляет. Диалога намеренно нет — окно и так модальное, а лишний диалог в лончере это лишний перехват фокуса. Порядок важен: сначала сервер, потом локальная копия. Наоборот запись исчезла бы с глаз и вернулась на следующей синхронизации. Вместе с записью уходят её вложения и скачанные файлы из кэша — картинка, пережившая «Удалить», это ровно то, чего человек не ждёт. На сервере это надгробие: markdown-файл остаётся на диске (D-007). Без сети удалить нельзя, и об этом говорится прямо, а не молча.

DELETE /v1/records/{id}
Чеклист проверки (3)
  • Выбрать запись в десктопе и нажать «Удалить»Кнопка стала «Точно удалить?»; запись на месте
  • Нажать второй разЗапись исчезла из списка и из локальной базы, на сервере появилось надгробие
  • Удалить запись с картинкой и заглянуть в кэш вложенийСкачанного файла там больше нет

Улучшено 1

В приложении «Удалить» перестало прятаться внутри «Править»

Что и зачем: Удаление в приложении было и раньше — и даже офлайн-совместимое: запись помечается удалённой сразу, а до сервера доезжает, когда появится сеть. Но лежало оно на два тапа вглубь, внутри «Править», и найти его человек не мог. Теперь кнопка стоит прямо на экране записи, рядом с «Править» и «Копировать всё», и работает так же в два нажатия. Поведение самого удаления не изменилось.

Чеклист проверки (3)
  • Открыть любую запись в приложенииВ шапке видна кнопка «Удалить»
  • Нажать её и уйти назад, не подтверждаяНичего не удалилось; при следующем открытии кнопка снова спрашивает с нуля
  • Удалить запись в самолётном режимеЗапись исчезает сразу, а на сервер уезжает, когда сеть вернётся
десктоп 0.1.0, мелкие правки

Десктоп: правый клик больше не роняет окно, а Настройки перестали предлагать невозможное

Исправлено 1

Правый клик по окну выглядел как «окно свернулось само»

Что и зачем: Правый клик открывал системное меню вебвью. Это отдельное окно, оно забирает фокус, а попап на потерю фокуса прячется — так и получалось, что окно исчезает от щелчка правой кнопкой. Меню это лончеру не нужно: копирование живёт на кнопках у записи и на Ctrl+C, вставка на Ctrl+V. Теперь оно не открывается, и окно остаётся на месте.

Чеклист проверки (2)
  • Вызвать окно и щёлкнуть правой кнопкой по пустому местуОкно на месте, системное меню не появляется
  • Щёлкнуть правой кнопкой по полю вводаТо же самое; вставка по-прежнему работает через Ctrl+V

Улучшено 1

В Настройках связанное устройство больше не просят связать заново

Что и зачем: Раньше поле кода, «Связать» и «Отвязать» висели рядом всегда. Связанному устройству поле не нужно — код одноразовый, и второй тык по «Связать» мог дать только ошибку. Не связанному, наоборот, нечего отвязывать. Теперь видно ровно одно действие: связано — «Отвязать», не связано — поле и «Связать» вместе с подсказкой про /link.

Чеклист проверки (2)
  • Открыть Настройки на связанном устройствеНаписано «Связано», видна только кнопка «Отвязать»
  • Нажать «Отвязать» и остаться в НастройкахПоявились поле кода и «Связать», кнопка «Отвязать» исчезла
десктоп 0.1.0, вложения

Десктоп показывает вложения и держит их у себя

Добавлено 1

Картинка из записи видна в трей-приложении, а со второго раза — и без сети

Что и зачем: Десктоп до сих пор не знал о вложениях вовсе: поля не было ни в том, что приезжает с сервера, ни в локальной базе. Теперь список вложений приезжает вместе с записью, а байты — отдельно и только когда запись раскрыли: показать её важнее, чем дождаться картинки. Скачанный файл ложится рядом с базой, и следующее открытие обходится без сети — то же обещание, по которому поиск не ходит в сеть никогда. Синхронизация переписывает список вложений целиком, но путь к уже скачанному переживает её, иначе каждый круг выбрасывал бы кэш. Токен уходит заголовком и не появляется в адресе. Большое вложение внутрь окна не тащится: строка base64 обошлась бы дороже, чем стоит показ.

GET /v1/media/{id}
Чеклист проверки (4)
  • Найти в приложении запись с фотографией и нажать TabПод заголовком видно саму фотографию
  • Свернуть, раскрыть сноваКартинка появляется сразу — она уже в кэше рядом с базой
  • Отключить сеть и раскрыть ту же записьКартинка на месте: второй раз она берётся с диска
  • Раскрыть запись с PDFСтрока с типом и размером, без попытки нарисовать картинку
приложение: вложения

Картинки в записях наконец видно

Исправлено 1

Приложение не показывало вложения вообще — ни одной картинки с момента запуска

Что и зачем: Файлы всё это время лежали на сервере целыми и отдавались правильно — проверено запросом, возвращается картинка нужного размера. Рвалось последнее звено: в приложении не было ни одной строки, которая читала бы вложения записи и создавала бы картинку на экране. За всё время работы сервера к отдаче вложений не пришло ни одного запроса от клиента — только проверочные. Теперь запись с фотографией показывает фотографию. Две тонкости, о которые легко разбиться. Первая: отдача требует заголовок с токеном, а токен в адресе картинки оставил бы его в логах — поэтому байты забираются обычным запросом с заголовком и показываются из памяти. Вторая: на одно вложение сервер заводит две записи — настоящую, с байтами на диске, и остаточную от разбора, у которой байтов нет. Остаточная идёт первой и выглядит как картинка, а настоящая помечена как файл, так что выбор «первой картинки» дал бы ошибку вместо изображения. Признак настоящей — пустая ссылка.

GET /v1/media/{id}
Чеклист проверки (3)
  • Отправить боту фотографию и открыть эту запись в приложенииПод заголовком видно саму фотографию, а не только имя файла и размер
  • Открыть запись с PDF или другим не-изображениемСтрока с типом, размером и кнопкой «Открыть» — картинку никто не подделывает
  • Выключить сеть и открыть запись с фотографией, которую ещё не смотрелиНаписано «нет сети — картинка не скачана», запись при этом открывается и читается
десктоп 0.1.0, теги

Десктоп: по тегам теперь можно фильтровать, и вид тегов выбирается в Настройках

Добавлено 2

Фильтр по тегам: клик по тегу сужает выдачу, два тега сужают вместе

Что и зачем: Теги рисовались под каждой записью и раньше, но были картинкой: кликнуть нельзя, отфильтровать нечем. Теперь тег — переключатель в трёх местах сразу: под записью, в полоске и в колонке. Несколько тегов складываются по И, а не по ИЛИ: выбрал proxy и secret — остались записи, у которых есть оба. Фильтр применяется в SQL, а не поверх готовой выдачи — иначе LIMIT срезал бы совпадения раньше, чем фильтр до них добрался. Совпадение по целому тегу: теги лежат в одной колонке через пробел, и наивный поиск подстроки по «go» забрал бы всё, что помечено «google». Записи из очереди под фильтром не показываются — их ещё никто не типизировал, тегов у них нет, и делать вид, что они подошли, нечестно.

Чеклист проверки (3)
  • Кликнуть по тегу под любой записьюВыдача сузилась до этого тега, тег подсвечен, рядом появилась кнопка «сбросить»
  • Добавить второй тег и посмотреть счётчик в подсказкеЗаписей стало меньше или столько же — теги сужают, а не расширяют
  • Набрать слово при активном фильтреПоиск идёт только внутри отфильтрованного

Два вида тегов на выбор: полоской под полем или колонкой слева

Что и зачем: Владелец попросил сравнить оба вживую, поэтому собраны оба и переключаются в Настройках: полоска показывает четырнадцать самых частых тегов и не отъедает ширину, колонка показывает все со счётчиками и забирает 168 пикселей у результатов. Выбранный тег всегда остаётся видимым, даже если вывалился из верхушки списка — иначе получился бы фильтр, который нечем снять. Есть и третий вариант — не показывать: поиск это продукт (D-004), и занимать первый экран чем-то ещё должно быть решением человека.

Чеклист проверки (3)
  • Настройки → Теги → «колонкой слева», закрыть настройкиСлева появилась колонка со всеми тегами и счётчиками, результаты сузились по ширине
  • Переключить на «полоской под полем»Колонка исчезла, под полем строка тегов; выбранный фильтр не сбросился
  • Переключить на «не показывать»Ни полоски, ни колонки; теги под записями остались и по-прежнему кликаются
десктоп 0.1.0, третий проход

Десктоп: окно со скруглёнными углами и клавиатура, которая доходит до поля

Исправлено 2

Углы окна были квадратными, хотя рамка рисовалась скруглённой

Что и зачем: Окно без системной рамки рисует свою сама. Но фон, заданный на body, браузер поднимает на canvas и заливает им весь кадр — скругление при этом игнорируется в принципе, никакой border-radius на body не поможет. Плюс окно было непрозрачным, так что за скруглением всё равно оказался бы собственный фон окна. Теперь рамка живёт на отдельном контейнере, окно объявлено прозрачным, и за кривой видно рабочий стол. Без композитора углы станут чёрными — ровно то, что было до правки, так что хуже не будет.

Чеклист проверки (2)
  • Вызвать окно поверх любого светлого или цветного окнаЧетыре угла скруглены, за кривой видно то, что под окном, а не тёмный квадрат
  • Открыть Настройки и онбордингУ них те же скругления, накладываются ровно, без торчащих квадратных углов

Набранное после вызова окна уходило в поиск GNOME, а не в поле

Что и зачем: Тот же корень, что и у исчезающего окна: окно пряталось через четыре миллисекунды после показа, поэтому клавиатура доставалась оболочке рабочего стола. Отдельной починки не потребовалось — проверено синтетическим вводом через XTest: после вызова горячей клавишей набранный текст попадает в поле приложения, курсор в поле, активное окно — приложение.

Чеклист проверки (1)
  • Вызвать окно горячей клавишей и сразу печатать, не кликая мышьюТекст появляется в поле приложения; поиск GNOME не открывается
десктоп 0.1.0, второй проход

Десктоп: окно больше не исчезает от клика, а связанное устройство не просит код заново

Исправлено 1

Окно пряталось от любого клика — и чаще всего не успевало показаться вовсе

Что и зачем: Попап прячется, когда теряет фокус — так и должен вести себя лончер. Беда в том, что оконный менеджер выдаёт и отбирает фокус несколько раз, пока окно только появляется. Замер на GNOME/X11: фокус выдан на 34-й миллисекунде, отобран на 38-й, возвращён на 187-й. Решение принималось по тому самому blur на 38-й миллисекунде, поэтому окно исчезало прямо из-под курсора, а вызванное горячей клавишей часто пропадало раньше, чем человек успевал его увидеть. Работал только один путь — открыть Настройки: модальное окно отключает прятание целиком, и именно поэтому «через настройки в поиск» выглядело как единственный рабочий способ. Теперь решение выжидает 300 мс и переспрашивает окно, всё ли ещё оно без фокуса; заодно проверяет, не открылась ли за это время модалка и не показали ли окно заново. Клик мимо окна по-прежнему прячет его — это проверено отдельно, чтобы починка не обменяла одно сломанное поведение на другое.

Чеклист проверки (3)
  • Вызвать окно горячей клавишей и кликнуть внутрь него — по пустому месту под полемОкно остаётся на экране; раньше исчезало от любого клика
  • Вызвать окно и кликнуть мимо — в другое окно или на рабочий столОкно прячется, как и должно; курсор остаётся там, куда кликнули
  • Запустить с BK_TRACE_FOCUS=1 и посмотреть stderr при вызове окнаВидны строки show_popup, Focused(true/false) с таймингами и решение keep или hide

Улучшено 1

После успешной привязки поле кода и кнопка «Связать» исчезают

Что и зачем: Коды у бота одноразовые и живут десять минут. Пока поле и кнопка оставались активными после успешной привязки, единственное, что мог дать повторный тык, — красная ошибка под шагом, который на самом деле отработал. Теперь строка с полем и кнопкой уходит совсем, остаётся зелёная строка «Связано», а фокус переезжает на «Дальше». То же самое при возврате на этот шаг: если устройство уже связано, поле не показывается вовсе. Заодно починена ловушка вёрстки — у .row свой display: flex, который перебивает браузерное правило для атрибута hidden, поэтому строка пряталась только на словах, а на экране оставалась. В том же файле про эту ловушку уже был комментарий у другого правила; теперь она закрыта и здесь.

Чеклист проверки (3)
  • На шаге «Подключение» ввести код из /link и нажать «Связать»Поле и кнопка пропадают, остаётся «Связано. Записи подтянутся сами», кнопка «Дальше» активна
  • Нажать «Назад» и вернуться на шаг подключенияПоля ввода нет, написано «Устройство уже связано» — просить код второй раз не у кого
  • Ввести заведомо неверный кодПоле и кнопка остаются на месте, под ними объяснение про одноразовый код и /link
десктоп 0.1.0 — собран под Linux, в сторы не подан

Десктоп: первый запуск ведёт по шагам, ставит автозапуск и горячую клавишу

Добавлено 2

Первый запуск: пять экранов, шаг запоминается, ничего не рапортует успех за пользователя

Что и зачем: «Первый запуск» определяется одним признаком — флагом onboarded в settings.json. Пока он false, приложение показывает пять экранов: что это, привязка устройства кодом из бота, горячая клавиша, автозапуск, готово. Номер шага пишется в settings.json после каждого перехода, поэтому закрытое на третьем экране окно открывается снова на третьем, а не с начала. Esc и крестик прячут окно в трей и не обрывают поток — выйти из онбординга можно только пройдя его. Ни один экран не пишет «готово» за пользователя: экран горячей клавиши печатает то, что реально ответила система, и если назначить программно нельзя — показывает точную команду, которую надо привязать руками (D-014). Отвязка устройства сбрасывает флаг: новая база тому же человеку — это снова первый запуск.

Чеклист проверки (3)
  • Удалить ~/.config/brainkeeper/settings.json и запустить приложение из менюОкно открывается само на экране «Что это», счётчик показывает 1 из 5
  • Дойти до шага 3, закрыть окно крестиком, открыть заново из треяОткрывается шаг 3, а не первый; в settings.json onboarding_step = 3
  • Пройти все пять экранов до конца и перезапустить приложениеОкна нет, только иконка в трее — онбординг больше не показывается

Автозапуск и горячая клавиша ставятся прямо из онбординга, а не инструкцией в README

Что и зачем: Горячая клавиша под GNOME прописывается программно через gsettings и работает в том числе под Wayland — клавишу перехватывает сама среда, а это ровно то, что D-014 разрешает. Где программного пути нет, приложение не делает вид, что справилось: показывает команду brainkeeper --capture и куда её привязать. Автозапуск на Linux — файл .desktop в ~/.config/autostart, на Windows — ключ в Run, на macOS — LaunchAgent. Честная граница: собрать и проверить здесь можно только Linux; код под Windows и macOS проходит проверку компиляции, но ни разу не исполнялся, и до первого запуска на живой машине считать его рабочим нельзя.

Чеклист проверки (3)
  • На экране «Горячая клавиша» нажать «Назначить» (GNOME)Написано «назначено через настройки GNOME»; Ctrl+Alt+B открывает окно поверх всего
  • gsettings get org.gnome.settings-daemon.plugins.media-keys custom-keybindingsВ списке есть путь с brainkeeper — запись реально в системе, а не только в интерфейсе
  • Включить галочку автозапуска и посмотреть ls ~/.config/autostart/Появился brainkeeper.desktop; после перезахода в систему иконка сама в трее

Исправлено 1

Первый запуск был невидимым: окно с онбордингом никто не показывал

Что и зачем: Приложение — трей-клиент, его окно создаётся скрытым, и показать его могли только три вещи: клик по иконке в трее, горячая клавиша и запуск с --capture. Обычный запуск из меню уходил в трей молча. Онбординг при этом честно рисовался — в окне, которого нет на экране, поэтому человек, поставивший обновление поверх старой версии, никакого первого запуска не видел и решил, что его нет. Флаг был ни при чём: он всё это время был false. Теперь решение вынесено в одну функцию shows_window_on_start: пока онбординг не пройден, окно показывается само при любом способе запуска. Показывает его фронтенд сразу после отрисовки первого экрана, чтобы не мелькала пустая строка поиска; если фронтенд не доехал, Rust показывает окно сам через 1,5 секунды. На время онбординга держится modal — иначе окно схлопывалось от потери фокуса ровно тогда, когда пользователь уходит в Telegram за кодом /link.

Чеклист проверки (4)
  • Поставить .deb поверх старой версии, не проходив онбординг, и запустить из менюОкно открывается само на «Что это» — раньше приложение молча уходило в трей
  • Пройти онбординг до конца и перезапустить приложениеОкно не открывается, приложение сидит в трее — обычное поведение вернулось
  • cd desktop && cargo testЗелёные, среди них shows_window_on_start — обе стороны решения закрыты тестом
  • После обновления: сначала «Выход» в трее, потом запускСтартует новая сборка. Живой старый процесс перехватывает запуск через сокет и показывает своё окно
фаза 2: миграция 002 + четыре новых модуля

Фаза 2: push-синхронизация, импорт из Pocket/Telegram/Obsidian, раздел «Почитать», биллинг выключенным

Безопасность 2

Зашифрованные записи проходят синхронизацию нетронутыми

Что и зачем: Колонки encrypted, enc_alg, enc_wrapped_key и enc_nonce живут в схеме с миграции 001, хотя сама криптография — фаза 3. Фаза 2 добавила второй путь записи (push-синк) и третий (импорт), и в каждом из них действует одно правило: строку с encrypted=1 сервер не индексирует в records_fts, не пересчитывает ей body_hash и не переписывает тело. Индексировать шифротекст бессмысленно и вредно — это утечка длины и структуры того, что сервер по замыслу прочитать не может. Именно поэтому records_fts пишется явно из store.py, а не триггерами: один путь записи проще удержать честным, чем четыре триггера.

Чеклист проверки (2)
  • Вставить в базу тестовую запись с encrypted=1 и телом-шифротекстом, затем прогнать её через POST /v1/syncТело возвращается байт в байт таким же, enc_* поля не изменились
  • sqlite3 brain.sqlite3 "SELECT count(*) FROM records_fts WHERE body MATCH '<кусок шифротекста>'"0 — зашифрованная запись в полнотекстовый индекс не попала

Воронка принимала секреты молча — теперь удаляет, предупреждает и ничего не пишет

Что и зачем: У бота две роли, и защита от секретов была только у одной. Захват находит токен, удаляет исходное сообщение и честно говорит, что значение прошло через серверы Telegram открытым. А воронка — та самая, до которой первым делом добирается человек, не прочитавший про продукт ни строки, — писала что угодно в базу и пересылала владельцу через тот же Telegram второй раз. Через неё прошёл живой токен бота. Теперь свободный текст просеивается до записи: сообщение удаляется, человеку говорится прямо, что значение скомпрометировано, в базу не попадает ничего, а владельцу уходит только вид секрета — без значения. Проверка не может провалиться молча: если сам детектор сломается, сообщение считается секретом и не сохраняется.

Чеклист проверки (3)
  • Прислать боту с неподключённого аккаунта строку вида 1234567890:AA…35 символовСообщение удалено, пришло предупреждение, в базе ничего
  • Проверить уведомление владельцуУказан вид секрета, самого значения нет
  • Прислать обычный текстСохраняется и пересылается как раньше

Добавлено 8

Push-синхронизация: POST /v1/sync — записи, созданные без сети, доезжают до сервера

Что и зачем: До этого деплоя синхронизация была односторонней: приложение только скачивало (GET /v1/sync?since=<rev>). Всё, что человек набирал в приложении в метро, оставалось в телефоне. Теперь клиент отправляет пачку записей в POST /v1/sync и получает обратно applied, mapping и conflicts. Правило разрешения конфликтов — last-write-wins по updated_at; для базы с одним владельцем это безопасно, потому что два устройства одного человека почти никогда не правят одну запись в одну секунду. Проигравшая правка не выбрасывается молча: сервер возвращает победившую строку в conflicts, чтобы клиент мог показать её человеку. Запись с client_id, которого сервер ещё не видел, создаётся, а её настоящий серверный id возвращается в mapping — так офлайновый черновик становится полноценной записью, и клиенту не приходится выдумывать серверные идентификаторы. Строки с encrypted=1 проходят насквозь: сервер не переиндексирует, не пересчитывает хеши и не переписывает тело.

POST /v1/syncGET /v1/sync
Чеклист проверки (4)
  • Включить авиарежим, в /app/ создать заметку «проверка пуша», выйти из авиарежима и нажать кнопку синхронизацииЗаметка перестаёт быть локальной; после перезагрузки страницы она на месте, у неё появился серверный id
  • Отправить ту же заметку боту командой /find проверка пушаБот находит её — значит запись доехала до серверной базы, а не осталась в IndexedDB
  • Отредактировать одну и ту же запись на двух устройствах, синхронизировать сначала первое, потом второеПобеждает правка с более поздним updated_at; второе устройство показывает предупреждение о конфликте, а не тихо теряет текст
  • curl -X POST https://brainkeeper.app/api/v1/sync без заголовка Authorization401, тело не раскрывает существование пользователей

Импорт из Pocket, Telegram, Obsidian и закладок браузера — с предпросмотром до сохранения

Что и зачем: Четыре формата, один конвейер: HTML-экспорт Pocket (li a с time_added и tags), result.json из Telegram Desktop (только Saved Messages), zip с хранилищем Obsidian (.md сохраняют свой frontmatter и ссылки) и Netscape-закладки из любого браузера. Смысл в предпросмотре: файл загружается через POST /v1/import, но ничего не сохраняется — сначала GET /v1/import/{id}/preview отдаёт разобранные элементы, человек ставит галочки и только затем POST /v1/import/{id}/commit пишет выбранное. Импортёр, который молча вываливает 4 000 закладок в базу, просто меняет одно болото на другое. Импорт идёт в фоновом потоке, по одной задаче на пользователя одновременно, прогресс опрашивается через GET /v1/import/{id}. Ссылки ставятся в очередь на парсинг медленно — импорт двух тысяч закладок не должен превратиться в две тысячи запросов к чужому сайту за минуту.

POST /v1/importGET /v1/importGET /v1/import/{id}GET /v1/import/{id}/previewPOST /v1/import/{id}/commit
Чеклист проверки (4)
  • Экспортировать закладки из браузера в HTML и загрузить файл через форму импорта в /app/Возвращается import_id и total — сколько элементов найдено; ни одной записи в базе ещё нет
  • Открыть предпросмотр импортаСписок разобранных элементов с заголовком и URL, у каждого галочка; по умолчанию ничего не сохранено
  • Снять галочки со всех, кроме двух, и нажать «Импортировать»В базе появляются ровно две записи; /find по заголовку одной из них находит её, остальные не сохранены
  • Во время импорта опросить статус (GET /v1/import/{id})JSON со status, total, done, skipped, failed — числа растут, done не превышает total

Раздел «Почитать»: 5–7 карточек, фильтр по минутам, «не сейчас» на месяц

Что и зачем: Это pull-поверхность из D-010, а не очередь задач. GET /v1/reading отдаёт максимум 7 карточек — никогда весь список, потому что 300 непрочитанных статей давят одинаково сильно, развёрнуты они или нет. Отбор: type='reading', opened_at пуст, запись не удалена, defer_until пуст или уже в прошлом; порядок — смесь свежести и времени чтения. Фильтр по минутам (read_minutes = слова ÷ 200) стоит рядом с фильтром по тегу, потому что человек надёжнее знает, сколько у него есть свободных минут, чем на какую тему он хочет читать. Кнопка «не сейчас» (POST /v1/records/{id}/defer) убирает карточку на месяц, ничего не удаляя. Раз в квартал GET /v1/reading/cleanup показывает годовалые ни разу не открытые записи простым списком — на выброс или на второй заход. Счётчиков за пределами этого раздела нет и не будет: D-009 запрещает и бейдж на иконке, и поле unread в /v1/stats.

GET /v1/reading?minutes=&tag=&limit=7GET /v1/reading/topicsPOST /v1/records/{id}/deferPOST /v1/records/{id}/undeferGET /v1/reading/cleanup
Чеклист проверки (5)
  • Сохранить боту 8–10 длинных статей, открыть в /app/ раздел «Почитать»Показано не больше 7 карточек, у каждой — время чтения в минутах
  • Поставить фильтр «до 5 минут»Остаются только карточки с read_minutes ≤ 5; длинные статьи исчезают из выдачи
  • Нажать «не сейчас» на верхней карточке и обновить разделКарточка пропала из выдачи, но /find по её заголовку по-прежнему её находит — запись не удалена
  • Открыть любую карточку и вернуться в разделОткрытая запись больше не предлагается (opened_at проставлен)
  • Посмотреть главный экран приложения и иконку установленного PWAНигде нет числа непрочитанного — ни бейджа, ни счётчика в шапке

Биллинг собран целиком и выключен: BK_BILLING_ENABLED=0 по умолчанию

Что и зачем: Проводка под Telegram Stars существует, но денег не берёт. При BK_BILLING_ENABLED=0 запрос POST /v1/billing/invoice отдаёт 503, ни один инвойс не уходит, а лимит бесплатного тарифа считается и показывается, но не применяется — GET /v1/billing/status возвращает enforced: false вместе с saves_total и limit. Это прямая граница из D-023: владелец разрешил делать фазу 2 до валидационных ворот D-016, но брать деньги — отдельное решение, а не следствие фразы «продолжай». Когда флаг включат, цены берутся из D-017: Pro 299 ₽/мес, ранняя птица 199 ₽/мес с фиксацией, пожизненная 4 900 ₽ на первые 200 человек; количество звёзд считается на момент выставления счёта по курсу из конфига, а не зашито в двух местах. Успешная оплата приходит боту как successful_payment и пересылается на POST /v1/billing/stars/confirm с внутренним токеном; charge_id уникален в базе, поэтому повторно доставленный апдейт Telegram не выдаст вторую подписку.

GET /v1/billing/statusGET /v1/billing/tiersPOST /v1/billing/invoicePOST /v1/billing/stars/confirm
Чеклист проверки (4)
  • curl -H 'Authorization: Bearer <токен>' https://brainkeeper.app/api/v1/billing/statusJSON с tier, saves_total, limit и обязательно enforced: false
  • curl -X POST .../v1/billing/invoice -d '{"tier":"pro"}' с валидным токеном503 и внятное сообщение, что биллинг выключен; в Telegram ничего не приходит
  • Сохранить записей больше, чем limit из billing/statusСохранение проходит: лимит показан, но не применяется
  • grep BK_BILLING_ENABLED /etc/brainkeeper/api.env0 или переменной нет вовсе — по умолчанию выключено

Приложение научилось сохранять, править, читать и импортировать

Что и зачем: До этого /app/ умел только искать по тому, что прислали в бот. Теперь там есть окно сохранения (ссылка или заметка), правка заголовка, типа, тегов и тела, раздел «Почитать» и загрузка файла импорта с предпросмотром. Главное правило не изменилось: поиск остаётся первым экраном, всё новое — оверлеи поверх него, Escape всегда возвращает к поиску с сохранённым запросом. Сохранение не ждёт сети: запись уходит в локальный outbox, появляется в списке сразу и досылается в фоне с ретраями, а перезагрузка страницы её не теряет. Замеры после всех добавлений: индекс 1000 записей строится за 35 мс, запрос — 1,6 мс в среднем.

POST /v1/capturePATCH /v1/records/{id}POST /v1/syncGET /v1/readingPOST /v1/import
Чеклист проверки (5)
  • Открыть https://brainkeeper.app/app/ и начать печататьКурсор уже в поле поиска, результаты появляются по мере ввода
  • Включить авиарежим и сохранить заметкуЗаметка появляется в списке сразу, помечена как неотправленная; после возврата сети уходит сама
  • Перезагрузить страницу с неотправленной заметкой в очередиЗаметка на месте, очередь не потеряна
  • Открыть «Почитать»Не больше семи карточек, нигде нет числа непрочитанного
  • Нажать «не сейчас» на карточкеКарточка уходит из подборки и не удаляется

Расширение для Chrome и Firefox и трей-приложение на Tauri — собираются локально, в сторы не поданы

Что и зачем: Расширение (ext/) сохраняет текущую вкладку из попапа, по горячей клавише и из контекстного меню, а при установке предлагает импорт закладок с галочками по каждой папке — это лекарство от пустой базы, которую удаляют через неделю. Права запрошены минимальные: activeTab, storage, bookmarks, contextMenus, scripting, и хост только brainkeeper.app; расширение для захвата, просящее <all_urls>, эта аудитория читает как шпионское. Трей-приложение (desktop/) на Tauri, а не Electron: одно поле и для сохранения, и для поиска, локальная база с FTS5, попап закрывается не дожидаясь сети. На Wayland перехват горячих клавиш запрещён, поэтому основной путь — привязать `brainkeeper --capture` в настройках своей среды, команду приложение показывает прямо при первом запуске. Ни то, ни другое не опубликовано: присутствие в сторах — это уже продажа, а она ждёт решения владельца.

POST /v1/capturePOST /v1/auth/exchangePOST /v1/import
Чеклист проверки (4)
  • chrome://extensions → «Загрузить распакованное» → папка ext/Расширение ставится, открывается страница импорта закладок
  • Посмотреть запрошенные права в карточке расширенияТолько activeTab, storage, bookmarks, contextMenus, scripting и хост brainkeeper.app
  • Не выбрать ни одной закладки и закрыть импортВ базу ничего не попало
  • cargo build --release в desktop/Бинарник собирается; размер укладывается в бюджет трея

Бот ведёт по шагам до результата и показывает меню в самом Telegram

Что и зачем: Список команд в ответе на /start — это не онбординг, а оглавление. Человек всё равно не знает, с чего начать и как понять, что получилось. Теперь бот ведёт: после подключения говорит «Шаг 1 из 3 — кинь любую ссылку», после первого сохранения сам предлагает шаг 2 («найди это через /find слово»), после первой удачной находки — шаг 3 («/link даст код, в приложении поиск работает без интернета»). Шаг засчитывается по результату, а не по факту набора команды: поиск, который ничего не нашёл, второй шаг не закрывает, и сохранённый дубль не закрывает первый. Отдельно появилось меню в самом Telegram, по кнопке «/» — и оно разное у двух ролей бота (D-021): подключённый чат видит find/last/link/whoami/export, все остальные — только start и help. Telegram умеет привязывать список команд к конкретному чату, так что публике не показывается дверь, ключа от которой у неё нет.

Чеклист проверки (6)
  • Отправить /enroll <токен>«Инбокс подключён» и сразу шаг 1 из 3; в меню «/» появились команды захвата
  • Кинуть боту ссылкуСтрока о сохранении, следом сам предлагает шаг 2
  • Отправить /find со словом, которого точно нет«ничего» — и шаг 2 НЕ засчитан
  • Отправить /find со словом из сохранённогоРезультат и предложение шага 3
  • Отправить /helpМеню плюс тот шаг, на котором стоишь
  • С неподключённого аккаунта нажать «/»Только start и help, про enroll ни слова

Пакеты под Ubuntu: .deb на 2,9 МБ и AppImage на 75

Что и зачем: desktop/package.sh собирает .deb — тот формат, который Ubuntu действительно хочет. Пакет весит 2,9 МБ и 6,4 МБ в установленном виде, потому что линкуется с WebKit и GTK, которые на машине уже есть: это укладывается в бюджет NFR-4 в 15 МБ, ради которого и выбирался Tauri вместо Electron. AppImage собирается тем же скриптом командой «all», но тащит свою копию WebKit и GTK и весит 75 МБ — в восемнадцать раз больше того же бюджета. Это не дефект сборки, а цена универсальности AppImage, и браться за него стоит осознанно, для дистрибутивов без deb. По дороге поправлено три вещи: имя пакета было brain-keeper (Tauri режет productName по горбам), зависимости перечислялись дважды, потому что я вписал руками то, что бандлер определяет сам, а шаблон .desktop с первой попытки выдал пустое Name=. Последнее скрипт теперь проверяет и роняет сборку: без .desktop у трей-приложения нет ни пункта в меню, ни действия под горячую клавишу — то есть нет ничего, ради чего оно существует. В пакет входит действие «Сохранить или найти», запускающее brainkeeper --capture: именно его D-014 предлагает вешать на клавишу самому.

Чеклист проверки (4)
  • sudo apt install ./desktop/target/release/bundle/deb/brainkeeper_0.1.0_amd64.debСтавится, зависимости разрешаются из репозиториев Ubuntu
  • Найти BrainKeeper в меню приложенийЕсть пункт с иконкой и действие «Сохранить или найти»
  • Настройки → Клавиатура → свои комбинации → brainkeeper --captureПо клавише открывается окно поиска и сохранения
  • dpkg -l brainkeeperИмя пакета brainkeeper, размер около 6,4 МБ

Исправлено 6

Правки после мульти-агентного ревью: секрет в ответе бота, YAML-фронтматтер, порты SSRF

Что и зачем: Пятнадцать агентов проверяли фазу 1 независимо, подтверждённые дефекты закрыты. Самый серьёзный: бот удалял сообщение с секретом и тут же публиковал его сам — заголовок записи для секрета это и есть секрет, а своё сообщение бот удалить не может, так что удаление исходного не давало ничего. Теперь ответ называет вид секрета, а /find даёт ссылку в приложение вместо значения. Дальше: заголовок с переводом строки и «---» рвал YAML markdown-файла, который по D-007 и есть источник истины и уезжает в /export; is_safe_url разрешал любой порт выше 80, потому что условие обнуляло собственный список; urlsplit падал на битом IPv6-литерале и терял захват целиком вместо частичного; удалённая запись «воскресала» при повторной отправке того же сообщения. Отдельно разведены жёсткие и мягкие секреты: пароль, угаданный в прозе, больше не удаляет сообщение — удалять чужое по догадке нельзя.

Чеклист проверки (4)
  • Прислать боту строку vless://…Сообщение удалено, в ответе написан вид секрета, а не значение
  • Прислать боту текст «поменял пароль, теперь Kotleta2024vsem»Бот предупреждает, но сообщение не удаляет
  • /find по слову из секретаВ чат приходит ссылка в приложение, а не значение
  • Сохранить запись, удалить её и прислать то же сообщение сноваСоздаётся новая запись, а не «сохранено» поверх удалённой

Правки после ревью клиентов: трей-приложение не открывалось, секреты светились, импорт молча терял хвост

Что и зачем: Шесть агентов проверяли клиентов на живой машине, и три дефекта были такими, что фичей их не назовёшь. Трей-приложение пряталось само через 6–29 мс после открытия: окно скрывалось по первому же «потерял фокус», а GNOME на X11 не выдаёт фокус только что показанному окну — то есть приложение не работало ни через горячую клавишу, ни через --capture, и замеры сборщика были сняты с выключенным этим поведением. Теперь окно прячется, только если фокус у него реально был, либо если прошло больше полутора секунд. Секрет, лежащий в очереди на отправку, показывался на первом экране открытым: очередь никогда не звала функцию сокрытия заголовка. Раскрытый секрет мог перерисоваться под чужой записью, потому что тело хранилось в одной переменной на двоих и ни к какому id не было привязано. В приложении удаление записи на другом устройстве стирало неотправленную правку — из памяти, из IndexedDB и с сервера разом; теперь такая запись остаётся с пометкой «удалено на другом устройстве». Предпросмотр импорта показывал первые 200 позиций и коммитил только их: файл на 4000 закладок импортировался на 200, и человек об этом не узнавал. В расширении один плохой захват хоронил всю очередь позади себя.

Чеклист проверки (5)
  • Собрать и запустить трей-приложение, нажать Ctrl+Alt+B на GNOMEОкно открывается и остаётся открытым, пока не уйдёшь из него
  • Без сети сохранить в трее строку vless://…, открыть окноВ списке видно «vless://••••••», а не значение
  • Раскрыть секрет, стрелками перейти на другую запись и раскрыть еёПод чужой записью секрета нет
  • Загрузить в приложении файл с более чем 200 закладками и нажать «выбрать всё»Написано «выбраны все N», и сохраняются все, а не первые 200
  • В расширении поставить в очередь заведомо битый захват и следом обычныйБитый уходит в лог ошибок, обычный всё равно доезжает

Экран привязки говорил невыполнимое: «отправьте /link» без слова про /enroll

Что и зачем: Первое, что видит человек в приложении, — инструкция из трёх шагов, где второй это «отправьте /link». Но /link работает только у подключённого чата, а подключение делается отдельной командой /enroll с токеном, про которую на экране не было ни слова. Владелец на этом и споткнулся в первый же день: отправил /link, получил ответ waitlist-анкеты и решил, что сломалось. Ничего не ломалось — бот исполнял вторую свою роль (D-021), но узнать об этом из приложения было неоткуда. Шаг про /enroll добавлен, и там же сказано, что до подключения бот отвечает как на анкету. Заодно голая команда от неподключённого чата больше не записывается в анкету как свободный ответ: /link в выборке валидации это шум, а не сигнал, и владельца ещё и дёргало уведомлением о собственной опечатке. Две такие записи из базы убраны.

Чеклист проверки (3)
  • Открыть https://brainkeeper.app/app/ на новом устройствеВ инструкции четыре шага, второй — /enroll с токеном
  • Отправить боту /link, не подключившисьОтвет приходит, но в анкетные сообщения он не попадает и уведомление не шлётся
  • Отправить /enroll <токен>, затем /linkПриходит шестизначный код, приложение по нему подключается

Полуотработавший деплой оставлял /app/ недоступным — теперь так не выйдет

Что и зачем: Штамповка версии кеша сервис-воркера собирает файлы во временной папке от mktemp -d, а та создаётся с правами 0700. rsync -a переносит режим исходной папки на целевую, то есть на живой веб-корень; исправлял это чуть более поздний chown в том же скрипте. Стоило деплою умереть между этими двумя шагами — и nginx переставал читать каталог: /app/ отдавал 403, иконки и манифест 500. Ровно это и случилось, когда вывод деплоя был пропущен через head: head закрыл пайп, скрипт получил SIGPIPE и остановился сразу после rsync. Теперь права задаются явно при передаче (--chmod=D755,F644, без переноса владельца), временная папка сразу 755, а режим каталога выставляется отдельной командой. Плюс смоук-проверка после деплоя щупает иконку и changelog, а не только index — этот класс поломки должен ронять деплой, а не доезжать до людей.

Чеклист проверки (3)
  • Задеплоить и сразу открыть https://brainkeeper.app/app/icons/icon-192.png200, картинка отдаётся
  • ls -ld /var/www/brainkeeper-app на сервереdrwxr-xr-x www-data www-data
  • Прервать деплой на середине и проверить сайт/app/ по-прежнему открывается

Кто пришёл до переключателя, застревал в воронке навсегда

Что и зачем: /start открывал инбокс только чатам, которых бот раньше не видел. Значит все, кто написал боту до того, как анкету выключили, оставались снаружи насовсем: /link, /find и что угодно ещё бесконечно уходило в воронку и получало «Записал, спасибо». Владелец поймал это на собственном аккаунте — он же был строкой №1. Теперь, пока анкета выключена, чат без инбокса получает его при первом же обращении, каким бы оно ни было, и то самое сообщение тут же обрабатывается захватом. Заодно проводник перестал перескакивать шаги: /link, отправленный до того, как человек хоть что-то сохранил, больше не поздравляет с прохождением всего пути. Поздравление ни за что ничему не учит и обесценивает следующую подсказку.

Чеклист проверки (3)
  • С аккаунта, который писал боту раньше, отправить /linkИнбокс открывается сам, приходит код
  • Отправить /link, ничего до этого не сохранивКод приходит, но «ты прошёл всё» не пишется
  • Сохранить, найти, затем /linkШаги идут 1 → 2 → 3 по порядку

Регистрация была сломана на проде: колонка добавлена в схему, но не в существующую таблицу

Что и зачем: Локальный кэш бота создаётся через CREATE TABLE IF NOT EXISTS. Колонку invite дописали в схему, а на сервере таблица уже была — и оператор целиком пропустился. Каждая попытка регистрации падала на «no column named invite», чат так и не помечался подключённым, и бот здоровался заново на каждое сообщение, отправляя его следом в воронку. Владелец просидел в этой петле несколько минут, пытаясь сохранить заметку. Отдельная беда в том, что приветствие отправлялось ДО записи: сообщение об успехе уходило раньше, чем становилось известно, был ли успех. Теперь у кэша есть самолечение схемы — недостающие колонки дописываются при открытии, — а приветствие следует за фактом, а не за надеждой. Поймано настоящим прогоном через живой вебхук, а не тестом с моками: именно поэтому проверять надо на том, что задеплоено.

Чеклист проверки (3)
  • Написать боту с аккаунта, который раньше не подключалсяОдно приветствие, инбокс открыт, сообщение сохранено
  • Написать второй разПриветствия больше нет, сразу сохранение
  • journalctl -u brainkeeper-bot | grep 'no column'Пусто

Улучшено 2

Модульная сборка API: каждая поверхность фазы 2 — свой файл с router

Что и зачем: sync.py, importers.py, reading.py и billing.py — четыре отдельных модуля, каждый отдаёт router = APIRouter() с полными путями от /v1, а api.py монтирует их через _mount_features() по списку FEATURE_MODULES. Практический выигрыш: четыре поверхности пишутся параллельно, не редактируя один общий файл, а отсутствующий модуль пишет предупреждение в лог и не роняет сервис — API поднимется даже с половиной фичей. Модули фазы 1 (db.py, store.py, api.py) при этом не менялись ни строкой.

Чеклист проверки (2)
  • systemctl restart brainkeeper-api && journalctl -u brainkeeper-api -n 30Строки о смонтированных модулях, ни одного traceback
  • curl https://brainkeeper.app/api/v1/health{"ok": true, ...} с версией схемы 2

Частичный захват объясняет себя человеку, а не кодом ответа

Что и зачем: FR-1.8 требует честного предупреждения вместо тихой потери — но предупреждение «http_403» честное ровно наполовину: оно ничего не говорит и ничего не предлагает. Владелец прислал объявление с Авито и получил именно такую строку. Авито блокирует серверные адреса наглухо: обычный запрос получает 439, с браузерным UA — 429, достать контент можно только через резидентный прокси или headless-браузер, и то и другое дорого и хрупко. Значит правильный ответ — не пробиваться, а внятно сказать. Теперь бот пишет «сайт закрылся от бота, пришли текст или скриншот — сохраню целиком», за платной стеной — «сохранил заголовок, описание и ссылку», для 404 — «страницы уже нет». Машинная причина остаётся в записи, её видно в приложении и по ней можно грепать. Заодно заголовок, собранный из адреса, перестал таскать за собой номер объявления: «Svezhaya ryba sudak» вместо «svezhaya ryba sudak 3128893922». Для заблокированной страницы это единственный заголовок, который когда-либо будет, — стоит того, чтобы читаться как заголовок.

Чеклист проверки (3)
  • Прислать боту ссылку на объявление Авито«сайт закрылся от бота, пришли текст или скриншот», заголовок без номера
  • Прислать ссылку на несуществующую страницу«страницы уже нет — сохранил только ссылку»
  • Открыть такую запись в приложенииПлашка «частично: сайт закрылся от бота»

Изменено 3

Миграция 002: defer_until, client_id, origin_device и три новые таблицы

Что и зачем: Схема доросла до многоустройственной работы. В records добавлены три колонки: defer_until — дата, до которой запись не показывается в разделе «Почитать» (кнопка «не сейчас» откладывает ровно на месяц, а не удаляет); client_id — идентификатор, который клиент выдаёт записи сам, когда создаёт её без сети; origin_device — какое устройство писало последним. Плюс три таблицы: imports (прогресс фоновых импортов), subscriptions (подписки, charge_id уникален) и usage. В usage.saves_total счётчик всех сохранений за всё время, он не уменьшается при удалении — лимит бесплатного тарифа, который сбрасывается удалением записи, это не лимит. Миграция аддитивная: ни одна колонка фазы 1 не переименована и не удалена, старый клиент продолжает работать против нового сервера.

Чеклист проверки (3)
  • На сервере: sqlite3 /var/lib/brainkeeper/brain.sqlite3 "PRAGMA table_info(records)" | grep -E 'defer_until|client_id|origin_device'Три строки — все три колонки на месте, тип TEXT, NOT NULL нигде не стоит
  • sqlite3 /var/lib/brainkeeper/brain.sqlite3 ".tables"В списке есть imports, subscriptions и usage рядом с таблицами фазы 1 (records, tags, media, devices, link_codes, user_rev)
  • Открыть /app/ на телефоне и найти любую запись, сохранённую до деплояЗапись открывается, тело и теги на месте — миграция ничего не переписала

Регистрация открыта: общий токен удалён, вход через /start за два тапа

Что и зачем: Подключиться к боту можно было только одним статическим токеном из bot.env — и это не механизм регистрации, а тупик. Дать его постороннему значило отдать тот же секрет, которым пользуешься сам; отозвать у одного человека было нельзя, только сменить всем; потолка не было вовсе. Схему построили для фазы 1, где единственный пользователь — владелец, и не пересмотрели, когда D-023 открыл фазу 2. Теперь /start задаёт два вопроса из D-018 и сразу открывает инбокс. Вопросы остались намеренно: это единственный инструмент, которым меряется спрос, и поставить их перед работающим продуктом стоит человеку двух тапов вместо комнаты ожидания. Токен удалён из кода, документации и с сервера. Лендинг переписан по той же причине: страница обещала «запишись, напишу когда откроется», а продукт открывается сразу — живая страница, которая врёт про продукт, хуже страницы, которая его недопродаёт. Потолка на аккаунты нет, по решению владельца; если появится злоупотребление, рычаг — уже готовый лимит бесплатного тарифа, а не новый механизм.

POST /v1/internal/enroll
Чеклист проверки (4)
  • С незнакомого аккаунта нажать /start в @brainkeepertg_botДва вопроса, после второго — «Инбокс открыт» и шаг 1 из 3
  • Сразу прислать боту ссылкуСохраняется, приходит тип и теги
  • Открыть brainkeeper.appКнопка «Начать в Telegram», нигде не обещают написать позже
  • Попробовать /enroll с любым текстомКоманды больше нет, бот отвечает как на обычный текст

Анкета и очередь выключены переключателем, а не удалены

Что и зачем: /start теперь открывает инбокс сразу, без двух вопросов. Сделано флагом BK_ASK_QUESTIONS в bot.env, а не вырезанием кода: все вопросы, обработчики и уже собранные ответы остались на месте, таблица waitlist продолжает вести позиции и счётчик. Флаг меняет, КОГДА открывается инбокс, а не открывается ли он вообще — поэтому вернуть анкету это одна строка и рестарт, без пересборки и без миграции. Что при этом выключено, стоит назвать прямо: анкета была единственным, чем в проекте меряется спрос. Гейт D-016 не пройден, D-023 открыл фазу 2 всё равно, и теперь не собирается вообще ничего. Это осознанный размен, а не недосмотр, и путь назад описан в bot/README.md именно потому, что такую позицию стоит уметь отыграть за минуту.

Чеклист проверки (3)
  • Нажать /start с нового аккаунтаСразу «инбокс уже открыт» и шаг 1 из 3, без вопросов
  • Поставить BK_ASK_QUESTIONS=1 и перезапустить ботаНовый /start снова начинается с двух вопросов
  • Проверить старые ответы после переключения туда и обратноСтроки в waitlist и позиции на месте

Технические изменения 1

Журнал изменений переехал на releases.json

Что и зачем: Раньше changelog вёлся markdown-файлом и парсился регулярками. Теперь источник истины — work/changelog/releases.json: релизы с датой, человеческой пометкой о характере деплоя и списком изменений, где у каждого есть principle (зачем это вообще и что изменилось в поведении) и checklist из пар «что сделать» → «что должно получиться». Отдельным блоком лежит current_functionality — инвентаризация по модулям со статусами working/partial/planned, чтобы через месяц не гадать, что из написанного реально работает. server/deploy/make_changelog.py рендерит из этого app/changelog.html и work/changelog/checklist-<дата>.md; контракт запуска не изменился, deploy.sh вызывает его как раньше.

Чеклист проверки (2)
  • python3 server/deploy/make_changelog.pyДве строки в выводе: путь к app/changelog.html и путь к work/changelog/checklist-<дата>.md
  • Открыть https://brainkeeper.app/app/changelog.html на телефонеСтраница читается, не едет вбок, есть оглавление, карточки релизов и раздел «Что работает сейчас»
фаза 1: новый сервис + миграция 001

Фаза 1: capture API, парсинг ссылок, захват из Telegram и офлайн-PWA

43924cd

Безопасность 2

Детектор секретов: бот удаляет исходное сообщение и говорит правду про Telegram

Что и зачем: Распознаются vless:// и ss://, блоки -----BEGIN … PRIVATE KEY-----, ssh-rsa и ssh-ed25519, ключи AKIA…, токены ghp_ и github_pat_, sk-ant-, xoxb-, тройки JWT, строки подключения вида postgres://user:pass@ и Bearer-блобы. При совпадении происходит три вещи: запись сохраняется с типом secret, бот удаляет исходное сообщение из чата, и бот прямым текстом сообщает, что значение прошло через серверы Telegram в открытом виде и его следует считать скомпрометированным. Это не перестраховка: до фазы 3 секреты лежат на сервере в открытом виде, и продукт говорит об этом вслух, вместо того чтобы намекать на безопасность, которой пока нет. В приложении такие записи отрисованы замаскированными: один тап показывает, один тап копирует, при уходе фокуса маска возвращается.

POST /v1/capture
Чеклист проверки (3)
  • Отправить боту строку, начинающуюся с vless://Исходное сообщение исчезает из чата; бот отвечает предупреждением о транзите через Telegram
  • Открыть эту запись в /app/Значение показано точками; тап открывает, отдельная кнопка копирует; при переключении экрана маска возвращается
  • Отправить приватный SSH-ключ одним сообщениемТип «секрет», сообщение удалено, предупреждение получено

Поля шифрования заведены с первой миграции, хотя криптография — фаза 3

Что и зачем: В миграции 001 у records сразу есть encrypted, enc_alg, enc_wrapped_key и enc_nonce, и они проходят весь конвейер нетронутыми: запись, чтение, синхронизацию, экспорт. Ключевое ограничение — строка с encrypted=1 никогда не попадает в records_fts. Поэтому полнотекстовый индекс пишется явно из store.py, а не триггерами: один честный путь записи вместо четырёх. Криптографии пока нет и это сказано вслух — секреты хранятся открыто до фазы 3. Но сделать это позже означало бы переписывать синхронизацию, а не добавить колонку, поэтому NFR-7 и D-006 требуют схему с первого дня. Отдельно: enc_wrapped_key — это ключ записи, завёрнутый ключом пользователя, а не сам ключ; такая форма нужна, чтобы шаринг в фазе 4 не потребовал перешифровки всей базы.

Чеклист проверки (2)
  • sqlite3 /var/lib/brainkeeper/brain.sqlite3 "PRAGMA table_info(records)" | grep enc_Три строки: enc_alg, enc_wrapped_key, enc_nonce, плюс колонка encrypted
  • Запросить GET /v1/sync и посмотреть на любую записьПоля encrypted и enc_* присутствуют в ответе (в фазе 1 encrypted=0 у всех)

Добавлено 6

Capture API — единственная точка входа для всех клиентов

Что и зачем: Один POST /v1/capture принимает три вида полезной нагрузки: kind=text, kind=url и kind=file (файл в base64). Это прямое следствие D-005: разбор, типизация, теги и дедупликация живут на сервере, клиенты тонкие. Практический смысл — седьмой клиент (расширение, tray, CLI) добавляется за дни, а не за недели, потому что ему не нужно уметь ничего, кроме одного POST. Захват синхронный, но ограниченный: на скачивание и извлечение отведено 15 секунд, после чего запись всё равно сохраняется, только помеченной как partial. Бот всегда получает ответ и никогда не оставляет человека смотреть на индикатор набора. Дубликат не создаёт вторую запись: возвращается 200 с полем duplicate_of. Ограничения частоты: захват 30/мин на пользователя, обмен кода 10/мин на IP, синхронизация 60/мин на устройство.

POST /v1/captureGET /v1/records/{id}PATCH /v1/records/{id}DELETE /v1/records/{id}POST /v1/records/{id}/openGET /v1/searchGET /v1/recentGET /v1/statsGET /v1/health
Чеклист проверки (3)
  • curl https://brainkeeper.app/api/v1/health{"ok": true, "schema": 1} и код 200
  • Отправить боту одну и ту же ссылку дважды подрядВторой раз бот отвечает, что это дубликат; в /app/ одна запись, а не две
  • curl -X POST https://brainkeeper.app/api/v1/capture без токена401

Парсинг ссылок в markdown и честный частичный захват

Что и зачем: Ссылка сначала канонизируется — снимаются utm_*, fbclid, yclid, gclid и ref, хост приводится к нижнему регистру, якорь отбрасывается; без стабильной канонизации дедупликация не работает. Дальше страница скачивается с браузерным User-Agent, потолок 15 секунд, 5 МБ и 3 редиректа, только HTML, и прогоняется через trafilatura в markdown с сохранением заголовков, списков, блоков кода и ссылок. Если извлечено меньше 200 символов, или найден маркер пейволла либо cookie-согласия, или скачивание вообще не удалось — запись всё равно создаётся: заголовок, описание, превью и URL, флаг partial=1 и записанная причина. Молчаливая потеря хуже честного предупреждения. Картинки до 5 МБ скачиваются и хранятся локально, видео и тяжёлые файлы только линкуются — решение по стоимости хранения принято заранее. Отдельно закрыт SSRF: хост резолвится до запроса, loopback, link-local, приватные и CGNAT-диапазоны отвергаются, схема только http(s), и проверка повторяется после каждого редиректа — иначе публичный /capture становится сканером внутренних портов.

POST /v1/captureGET /v1/media/{id}
Чеклист проверки (4)
  • Отправить боту ссылку на обычную статьюОтвет с типом, заголовком и тегами; в /app/ у записи полный текст статьи, а не одна ссылка
  • Отправить ссылку с хвостом ?utm_source=telegram, а потом ту же ссылку без хвостаВторая распознана как дубликат — канонизация сработала
  • Отправить ссылку на страницу за пейволлом или на заведомо мёртвый URLЗапись всё равно сохранена, помечена как частичная, причина видна; ничего не потеряно молча
  • Отправить боту http://127.0.0.1:8092/v1/healthСкачивание отклонено, внутренний сервис не опрошен

Тип и теги без LLM: 95% верных типов на 40 размеченных случаях

Что и зачем: Тип определяется детерминированными правилами по порядку, первое совпадение выигрывает: secret (сработал детектор секретов), file (приложен нетекстовый файл), snippet (≥40% строк выглядят кодом или конфигом, либо весь текст — один блок в тройных кавычках), instruction (нумерованные шаги, «Step N», приглашение $, sudo, «шаг», «выполните» вперемешку с прозой), reading (URL, из которого извлечено ≥600 слов прозы без шагов), link (URL с частичным захватом), note (всё остальное). Теги берутся из трёх дешёвых источников с потолком в 8 штук: технический словарь по заголовку и телу (k8s, docker, nginx, ssh, postgres, vpn, сертификаты, python — русские синонимы сведены к тем же тегам), домен источника (github.com → github) и языки блоков кода. Никакого вызова модели в пути захвата — это D-022: правила бесплатны, работают офлайн, проверяются фикстурами и не ставят каждый захват в зависимость от доступности чужого API. Замер на 40 размеченных случаях: 38 из 40 верных, то есть 95% при планке AC-1.5 в 80%.

POST /v1/capturePATCH /v1/records/{id}
Чеклист проверки (4)
  • Отправить боту кусок nginx-конфигаТип «сниппет», среди тегов nginx
  • Отправить пронумерованную инструкцию с командами sudoТип «инструкция», а не «заметка»
  • Отправить длинную статью ссылкойТип «чтение», в приложении показано время чтения в минутах
  • В приложении открыть запись с неверным типом и поменять егоТип сохраняется через PATCH и переживает синхронизацию

Захват в существующем боте @brainkeepertg_bot, waitlist не тронут

Что и зачем: Один бот, две роли — D-021. Для всех, кто не прошёл enrollment, бот остаётся waitlist-анкетой валидационного лендинга, ничего не изменилось. Владелец делает /enroll <токен>, где токен лежит в /etc/brainkeeper/bot.env, и тот же чат превращается во входящий ящик: обычный текст уходит как kind=text, ссылка — как kind=url, фото и документы до 20 МБ (потолок самого Telegram) — как kind=file, пересланное сообщение сохраняет исходного автора во frontmatter. Команды: /find <запрос> — серверный полнотекстовый поиск, топ-5, каждый с готовым к копированию блоком; /last — пять последних; /link — одноразовый код для приложения; /whoami и /export. Ответ после сохранения — одна строка: тип, заголовок, теги и предупреждение, если захват частичный или увиден секрет. Ни клавиатур, ни подтверждений: захват должен стоить ноль усилий. Второй бот не заводился намеренно — это вторая регистрация, второй вебхук и второй юнит ради фазы с одним пользователем.

POST /v1/internal/enrollPOST /v1/internal/link-codeGET /v1/searchGET /v1/recentGET /v1/export
Чеклист проверки (4)
  • Отправить боту /enroll <токен> из /etc/brainkeeper/bot.envБот подтверждает привязку; с неверным токеном отвечает нейтрально и ничего не подтверждает
  • С другого аккаунта написать боту /startПриходит обычная waitlist-анкета — валидационная воронка не задета
  • Отправить /find nginxДо пяти результатов, у каждого заголовок и блок, который копируется одним тапом
  • Отправить /exportПриходит zip, он открывается, внутри markdown-файлы с frontmatter

PWA с офлайн-поиском: индекс 401 мс, запрос в среднем 5,7 мс на 10 000 записей

Что и зачем: Мобильный клиент фазы 1 — устанавливаемое PWA на /app/, один HTML, ванильный JS, без фреймворка. Поиск — первый экран (D-004): поле в фокусе сразу при открытии, результаты появляются по мере набора. Весь корпус — текст и метаданные целиком — лежит в IndexedDB, медиа остаётся на сервере и подтягивается по требованию. При старте из IndexedDB строится инвертированный индекс в памяти; токенизация приводит к нижнему регистру, схлопывает ё→е, режет по неалфавитно-цифровым и делает префиксное совпадение по последнему токену, поэтому «сертифик» находит «сертификаты» без стеммера. Ранжирование в духе BM25: заголовок ×3, теги ×2, тело ×1, бонус за точную фразу, свежесть как тайбрейк. Замеры: на 10 000 записей построение индекса 401 мс один раз при загрузке, запрос в среднем 5,7 мс и 7,6 мс в худшем случае при планке NFR-2 в 300 мс; на 1 000 записей (AC-1.2) — 1,8 мс в среднем и 3,7 мс в худшем. Service worker кеширует оболочку по принципу cache-first, а /api/ — только по сети, поэтому приложение открывается с выключенной связью. Копирование в один тап есть у каждого блока кода, каждого секрета, каждого URL и у тела целиком.

GET /v1/syncPOST /v1/auth/exchangePOST /v1/auth/link
Чеклист проверки (5)
  • Открыть https://brainkeeper.app/app/ на телефонеПервый экран — поле поиска, курсор уже в нём, ничего нажимать не нужно
  • Отправить боту /link и ввести полученный код в приложенииУстройство привязано, записи подтягиваются; код одноразовый и живёт 10 минут
  • Включить авиарежим и открыть приложение зановоПриложение открывается и ищет по всей базе — данные лежат локально
  • Найти запись со словом, которое встречается только в теле, а не в заголовкеЗапись находится (AC-1.4)
  • Добавить приложение на домашний экранЗапускается отдельным окном с иконкой, без адресной строки браузера

Markdown на диске как источник истины и бесплатный экспорт зипом

Что и зачем: Настоящее хранилище — файлы /var/lib/brainkeeper/records/<uid>/<год>/<id>.md с YAML-frontmatter (id, title, type, tags, url, source, created, partial, encrypted) и телом в markdown. SQLite с FTS5 — это индекс, а не хранилище: потеря индекса стоит переиндексации, потеря markdown стоит всего. Отсюда же дешёвая миграция на Postgres, когда синхронизация вырастет из SQLite — переиндексация это перечитывание файлов, а не миграция данных. GET /v1/export отдаёт всю базу зипом и не закрывается ни тарифом, ни флагом — это D-007 и одновременно ответ на возражение «я не хочу зависеть от вашего сервиса». Ночью cron делает sqlite3 .backup и tar каталога records в /var/backups/brainkeeper/, хранится 14 копий.

GET /v1/export
Чеклист проверки (3)
  • Сохранить запись через бота, затем на сервере посмотреть ls /var/lib/brainkeeper/records/Появился .md-файл с frontmatter и телом записи
  • Отправить боту /export и открыть присланный архивZip открывается, внутри читаемые markdown-файлы — без всякого конвертера
  • ls /var/backups/brainkeeper/ на следующее утро после деплояСвежая копия sqlite и tar каталога records, старше 14 дней ничего нет
лендинг + бот (без API и без миграций)

Валидационный лендинг brainkeeper.app на двух языках и waitlist-бот

6ead66017df6c906c248917f50caf557e54eb34963

Добавлено 2

Двуязычный валидационный лендинг: RU на /, EN на /en/

Что и зачем: Страница ничего не продаёт — она измеряет. Единственный призыв к действию ведёт в Telegram-бота, платежей и почтовых форм нет вовсе. Это осознанное изменение D-016, который отправлял лендинг за валидационные ворота: ворота защищали от продажи до подтверждения спроса, а эта страница спрос как раз и замеряет. Блок цен показывает якорь 299 ₽/мес и раннюю птицу 199 ₽/мес, чтобы проверить реакцию на цифру ещё до того, как появится кнопка оплаты. Побочная польза, ради которой это стоило суток работы: ссылку можно кинуть посреди разговора вместо устного пересказа — десять валидационных бесед из D-016 идут легче с готовой страницей. Собирается статически из landing/src/ скриптом build.py, выкладывается landing/deploy.sh (сборка, rsync, smoke-проверка); страница переделывается в продающую добавлением одного слоя, а не переписыванием.

https://brainkeeper.app/https://brainkeeper.app/en/
Чеклист проверки (3)
  • Открыть https://brainkeeper.app/ на телефонеСтраница читается, не едет вбок, единственная кнопка ведёт в бота
  • Открыть https://brainkeeper.app/en/Тот же лендинг по-английски, цены ведут в долларах
  • Найти на странице любую форму ввода почты или кнопку оплатыИх нет — единственное действие это переход в Telegram

Waitlist-бот: два вопроса на входе и публичный счётчик

Что и зачем: @brainkeepertg_bot принимает вебхук на https://brainkeeper.app/tg/webhook (проксируется на 127.0.0.1:8091), написан на голой стандартной библиотеке — HTTP-сервер, SQLite и urllib, без единой сторонней зависимости. Вошедшего он ставит в очередь с номером и задаёт два вопроса: где сейчас лежат его секреты и инструкции (варианты saved, notes, vault, chaos) и второй уточняющий с ответом да/нет. Ответы падают в /var/lib/brainkeeper/waitlist.sqlite3, свободный текст — в отдельную таблицу messages, владельцу приходит уведомление о каждом новом человеке. Важная граница из D-018: эти ответы приходят от уже отпитченной аудитории и в пороги D-016 не засчитываются — они влияют на продукт, но не заменяют десять холодных разговоров. Публичный счётчик регенерируется в waitlist-count.json и показывается на лендинге только после порога — маленькое число на странице работает против неё.

POST https://brainkeeper.app/tg/webhook
Чеклист проверки (3)
  • Написать боту /start с аккаунта, которого нет в спискеПриходит приветствие с номером в очереди и первый вопрос
  • Ответить на оба вопросаБот благодарит и больше ничего не спрашивает; владельцу приходит уведомление
  • curl https://brainkeeper.app/waitlist-count.jsonJSON со счётчиком; на лендинге число показывается только после порога

Изменено 1

Английская локаль: цены ведут в долларах с привязкой к рублю

Что и зачем: На /en/ первым идёт долларовый ценник, а рублёвый указан как привязка. Курс не выдумывается на глаз: он берётся от якоря D-017 (~85,4 ₽ за доллар), и правило пересчёта записано в §4 N7 спеки лендинга — трогать любую цену, не заглянув туда, запрещено, иначе две локали разъедутся. Причина в том, что OQ-4 (русскоязычный запуск или двуязычный) всё ещё открыт, и владелец сознательно принял издержку: английские тексты поддерживаются раньше, чем появится английский трафик.

https://brainkeeper.app/en/
Чеклист проверки (1)
  • Сравнить блок цен на / и на /en/299 ₽ и 199 ₽ на русской, долларовые эквиваленты по курсу ~85,4 ₽/$ на английской

Технические изменения 1

Счётчик waitlist вынесен за пределы веб-корня

Что и зачем: waitlist-count.json физически лежит в /var/lib/brainkeeper/ и раздаётся nginx как /waitlist-count.json. Причина конкретная: деплой лендинга делает rsync --delete по корню сайта и стёр бы файл при каждой выкладке вместе с накопленным счётчиком. Тот же урок применён в фазе 1 — /app/ и /api/ живут по путям, до которых rsync лендинга не дотягивается, поэтому два деплоя не могут повредить друг другу.

Чеклист проверки (1)
  • Выкатить лендинг заново (landing/deploy.sh) и сразу запросить /waitlist-count.jsonСчётчик на месте и не обнулился

Что работает сейчасинвентаризация по модулям, статусы честные

Захват — бот, API, парсинг, типизация

Работает · 6
Работает

Дедупликация по канонизированному URL и хешу тела

Принцип: store.py считает url_hash = sha256 от канонизированного URL и body_hash = sha256 от нормализованного тела. Повторный захват возвращает 200 с duplicate_of вместо создания второй записи. Именно поэтому канонизация обязана быть стабильной: ссылка с utm-хвостом и без него должны давать один хеш.

POST /v1/capture
Чеклист проверки (1)
  • Отправить одну ссылку дважды, второй раз с ?utm_source=tgВторая распознана дубликатом, в базе одна запись
Работает

Детектор секретов и удаление исходного сообщения

Принцип: detect_secrets() ловит vless://, ss://, PEM-блоки приватных ключей, ssh-rsa и ssh-ed25519, AKIA…, ghp_ и github_pat_, sk-ant-, xoxb-, JWT-тройки, postgres://user:pass@ и Bearer-блобы. При совпадении бот удаляет исходное сообщение из чата и прямо говорит, что значение прошло через серверы Telegram в открытом виде. До фазы 3 секреты хранятся на сервере открыто — продукт говорит это вслух, а не намекает на безопасность, которой нет.

POST /v1/capture
Чеклист проверки (1)
  • Отправить боту строку vless://…Сообщение исчезает из чата, приходит предупреждение про транзит через Telegram
Работает

Единый capture API для всех клиентов

Принцип: server/brainkeeper/api.py отдаёт POST /v1/capture с тремя видами нагрузки и идемпотентным client_ref. Разбор, типизация, теги и дедупликация выполняются на сервере — клиент не умеет ничего, кроме одного запроса (D-005). Дубликат возвращает 200 с duplicate_of и не создаёт записи. Ограничение частоты — 30 захватов в минуту на пользователя, токен-бакет в процессе.

POST /v1/captureGET /v1/health
Чеклист проверки (2)
  • curl https://brainkeeper.app/api/v1/healthok: true и номер версии схемы
  • Отправить 40 захватов за минутуПосле тридцатого приходит 429, сервис не падает
Работает

Захват из Telegram: текст, ссылки, файлы, пересланные сообщения

Принцип: bot/capture.py подключён к @brainkeepertg_bot и обрабатывает апдейт до waitlist-логики, если чат прошёл /enroll. Текст уходит в POST /v1/capture с kind=text, ссылка — kind=url, фото и документ — kind=file (потолок 20 МБ, это ограничение самого Telegram), пересланное сообщение сохраняет исходного автора. Бот ходит в API по внутреннему токену BK_INTERNAL_TOKEN, который не покидает хост, поэтому ему не нужен девайс-токен. Ответ — одна строка с типом, заголовком и тегами.

POST /v1/capturePOST /v1/internal/enroll
Чеклист проверки (2)
  • Отправить боту текст «перезапуск nginx: systemctl reload nginx»Ответ в одну строку с типом и тегами, запись видна в /app/ после синхронизации
  • Переслать боту чужое сообщениеЗапись сохранена, исходный автор виден во frontmatter markdown-файла
Работает

Определение типа и тегов без LLM — 95% на фикстурах

Принцип: server/brainkeeper/classify.py — правила по порядку: secret, file, snippet, instruction, reading, link, note. Теги из технического словаря (русские синонимы сведены к тем же тегам), домена источника и языков блоков кода, максимум 8. Проверено на 40 размеченных случаях в server/tests/fixtures/classify_cases.json: 38 верных, 95% при планке AC-1.5 в 80%. Никакого сетевого вызова в пути захвата — D-022.

POST /v1/capturePATCH /v1/records/{id}
Чеклист проверки (2)
  • .venv/bin/python -m pytest server/tests/test_classify.pyТесты зелёные, доля верных типов не ниже 80%
  • Отправить боту фрагмент docker-compose.ymlТип «сниппет», среди тегов docker
Работает

Парсинг ссылок в markdown с частичным захватом

Принцип: server/brainkeeper/parse.py: канонизация URL (снятие utm_*, fbclid, yclid, gclid, ref), скачивание с потолками 15 с / 5 МБ / 3 редиректа, извлечение через trafilatura с сохранением заголовков, списков и блоков кода. Меньше 200 символов, пейволл, cookie-баннер или ошибка сети — запись сохраняется как partial с записанной причиной, а не теряется. Картинки до 5 МБ скачиваются, видео линкуется. Хост проверяется до запроса и после каждого редиректа: loopback, link-local, приватные и CGNAT-адреса отвергаются.

POST /v1/captureGET /v1/media/{id}
Чеклист проверки (2)
  • Отправить боту ссылку на статью и открыть запись в приложенииТекст статьи в markdown, заголовки и блоки кода на месте
  • Отправить ссылку на страницу за пейволломЗапись помечена как частичная, причина видна, URL сохранён

Поиск и извлечение — PWA на /app/

Работает · 10
Работает

Звёздочка и дерево тегов — два способа не искать одно и то же дважды

Принцип: Звезда — один бит: она ведёт список до запроса и поднимает запись в выдаче, но не выносит слабое совпадение выше сильного. Стоит и на записи, и в строке списка, нажимается без сети и едет своей очередью, отдельной от очереди правок. Дерево тегов собирается из «клиенты/акме/vpn» на клиенте — сервер об этом не знает; оно живёт в строке фильтров, свёрнутое, и уходит с глаз, как только ветка выбрана. Выбор ветки берёт и всё, что под ней; переименование ветки — одно действие, правки ложатся локально и уезжают очередью. Ни вкладки «Избранное», ни дерева в боте не появилось намеренно.

POST /v1/records/{id}/starGET /v1/tags/labels
Чеклист проверки (2)
  • Нажать звёздочку в списке и очистить запросОтмеченное первым, запись при нажатии не открылась
  • Открыть «# теги» и выбрать веткуВ списке ветка вместе с вложенными, чип показывает, что фильтр стоит
Работает

Копирование в один тап у каждого блока

Принцип: Кнопка копирования есть у каждого блока кода, каждого секрета, каждого URL и у тела записи целиком («Копировать всё» в шапке). Используется navigator.clipboard с запасным вариантом через execCommand — на iOS первый способ срабатывает не всегда. Это FR-1.6 и AC-1.3: смысл продукта в том, чтобы найденное сразу оказалось в буфере, а не было выделено пальцем по абзацу.

Чеклист проверки (2)
  • Открыть запись со сниппетом и нажать кнопку копирования у блока кодаВсплывает подтверждение, содержимое буфера вставляется в любое другое приложение
  • Нажать «Копировать всё» в шапке записиВ буфер попадает всё тело записи в markdown
Работает

Офлайн-поиск по всей базе — первый экран приложения

Принцип: app/offline-search.js строит инвертированный индекс в памяти из IndexedDB при загрузке. Токенизация: нижний регистр, ё→е, разбиение по неалфавитно-цифровым, префиксное совпадение последнего токена — «сертифик» находит «сертификаты» без стеммера. Ранжирование в духе BM25: заголовок ×3, теги ×2, тело ×1, бонус за точную фразу, свежесть как тайбрейк. Замерено: 10 000 записей — индекс 401 мс при загрузке, запрос 5,7 мс в среднем и 7,6 мс в худшем; 1 000 записей — 1,8 мс в среднем и 3,7 мс в худшем при планке NFR-2 в 300 мс. Поле поиска в фокусе сразу при открытии (D-004).

Чеклист проверки (2)
  • Открыть /app/ и сразу начать печататьРезультаты появляются по мере набора, никуда предварительно нажимать не нужно
  • Включить авиарежим и повторить поискПоиск работает, результаты те же — данные лежат в IndexedDB
Работает

Привязка устройства одноразовым кодом из бота

Принцип: /link в боте выпускает шестизначный код на 10 минут, одноразовый. Приложение отправляет {code, device_name, platform} в POST /v1/auth/exchange и получает 32-байтовый токен; на сервере хранится только sha256(token). Ни паролей, ни почты, ни OAuth — телеграмной личности достаточно для однопользовательской фазы. Обмен кода ограничен десятью попытками в минуту с одного IP.

POST /v1/auth/linkPOST /v1/auth/exchange
Чеклист проверки (2)
  • Отправить боту /link и ввести код в /app/Устройство привязано, начинается загрузка записей
  • Ввести тот же код второй разОтказ — код одноразовый
Работает

Секреты замаскированы по умолчанию

Принцип: Запись с type=secret отрисована точками. Один тап показывает значение, отдельная кнопка копирует его не раскрывая, при уходе фокуса маска возвращается. Это FR-1.7 — защита от чужого взгляда через плечо, а не от сервера; настоящее шифрование это фаза 3.

Чеклист проверки (2)
  • Открыть запись типа «секрет»Значение показано точками, рядом кнопки «показать» и «копировать»
  • Показать значение и переключиться на другой экран приложенияПри возврате значение снова замаскировано
Работает

Синхронизация вверх (push) — записи, созданные офлайн

Принцип: POST /v1/sync принимает пачку записей, созданных без сети. Разрешение конфликтов — last-write-wins по updated_at; проигравшая правка не исчезает, сервер возвращает победившую строку в conflicts, а приложение показывает расхождение и не пытается досылать ту же правку по кругу. Запись с client_id, которого сервер не видел, создаётся, и её настоящий id приезжает в mapping. Проверено на проде: пачка применяется, повтор ничего не дублирует, устаревшая правка возвращается конфликтом.

POST /v1/sync
Чеклист проверки (2)
  • Создать заметку в авиарежиме и синхронизироваться после возврата сетиЗаметка появляется на сервере, /find её находит
  • Править одну запись с двух устройств и синхронизировать по очередиПобеждает более поздний updated_at, проигравшая версия показана как конфликт
Работает

Синхронизация вниз (pull) по курсору ревизии

Принцип: GET /v1/sync?since=<rev>&limit=<n≤500> отдаёт всё, что изменилось после курсора, включая надгробия удалённых записей в поле deleted. rev выдаётся из таблицы user_rev внутри той же транзакции, что и запись, поэтому пропусков не бывает. Приложение тянет при открытии и по pull-to-refresh. Тела зашифрованных записей приезжают шифротекстом с целыми enc_* — расшифровка на клиенте, но в фазе 1 шифрованных записей ещё нет.

GET /v1/sync
Чеклист проверки (2)
  • Сохранить запись через бота и потянуть экран /app/ внизНовая запись появляется в списке
  • Удалить запись и синхронизироваться сноваЗапись исчезает и из приложения — надгробие доехало
Работает

Сохранение, правка и импорт прямо из приложения

Принцип: Раньше в приложение можно было только смотреть: наполнял его бот. Теперь там есть окно сохранения ссылки или заметки, правка заголовка, типа, тегов и тела, и загрузка файла импорта с предпросмотром. Сохранение не ждёт сети: запись уходит в локальный outbox, показывается в списке сразу и досылается фоном с ретраями; перезагрузка страницы очередь не теряет.

POST /v1/capturePATCH /v1/records/{id}POST /v1/import
Чеклист проверки (3)
  • Включить авиарежим и сохранить заметку в приложенииЗаметка сразу в списке с пометкой, что не отправлена
  • Перезагрузить страницу, не включая сетьЗаметка и очередь на месте
  • Вернуть сетьПометка уходит сама, запись появляется на сервере
Работает

Установка на домашний экран и работа без сети

Принцип: app/manifest.webmanifest и app/sw.js: оболочка precache-ится и отдаётся cache-first, запросы к /api/ идут только по сети. Приложение открывается с выключенным радио. На Android доступен share_target; на iOS системного шаринга нет и хранилище может быть вытеснено примерно через 7 дней простоя — цена этого не потерянные данные, а медленный первый запуск с полной пересинхронизацией (D-020).

Чеклист проверки (2)
  • Добавить /app/ на домашний экран и запустить оттудаОтдельное окно с иконкой, без адресной строки
  • Отключить сеть и запустить с иконкиПриложение открывается, поиск работает
Работает

Фильтры по типу и «не открывал»

Принцип: В app/index.html над результатами стоит ряд чипов: «не открывал» плюс тип (секрет, сниппет, инструкция, чтение, ссылка, заметка). Это фильтры к тому же локальному индексу, а не отдельные запросы к серверу, поэтому работают офлайн и мгновенно. Чип «не открывал» опирается на opened_at, который проставляется через POST /v1/records/{id}/open при открытии записи.

POST /v1/records/{id}/open
Чеклист проверки (2)
  • Нажать чип «секрет» с пустым поисковым полемПоказаны только записи типа «секрет»
  • Открыть запись, вернуться назад и включить чип «не открывал»Только что открытая запись в выдаче не появляется

Хранилище, экспорт и бэкапы

Работает · 5Частично · 1
Работает

Markdown-файлы на диске как источник истины

Принцип: /var/lib/brainkeeper/records/<uid>/<год>/<id>.md с YAML-frontmatter (id, title, type, tags, url, source, created, partial, encrypted) и телом в markdown. SQLite с FTS5 — только индекс: потеря индекса стоит переиндексации через store.reindex(), потеря markdown стоит всего. Это D-007 и одновременно страховка от привязки к вендору: файлы читаются любым редактором без конвертера.

Чеклист проверки (1)
  • Сохранить запись и найти соответствующий .md на сервереФайл существует, frontmatter и тело читаются глазами
Работает

Ночные бэкапы индекса и markdown-дерева

Принцип: server/deploy/backup.sh по cron делает sqlite3 .backup базы и tar каталога records в /var/backups/brainkeeper/, хранит 14 копий. Порядок именно такой: markdown — истина, sqlite — производное. Если разъедутся, восстанавливается markdown, а индекс пересобирается.

Чеклист проверки (1)
  • ls -lt /var/backups/brainkeeper/ | headСвежие копии за сегодня, ничего старше 14 дней
Работает

Полнотекстовый индекс FTS5 с явной записью

Принцип: records_fts (unicode61, remove_diacritics 2) пишется явно из store.py, а не триггерами, ровно ради одного правила: строка с encrypted=1 не индексируется никогда. Индексировать шифротекст бессмысленно и вредно — это утечка длины и структуры. Один путь записи проще удержать честным, чем четыре триггера. Этот индекс обслуживает GET /v1/search и команду /find в боте.

GET /v1/search
Чеклист проверки (1)
  • Отправить боту /find по слову из тела записиЗапись найдена — индекс покрывает тело, а не только заголовок
Работает

Полный экспорт зипом, без тарифных ворот

Принцип: GET /v1/export отдаёт всю базу пользователя зипом markdown-файлов и не закрывается ни подпиской, ни флагом биллинга. Та же кнопка есть в боте как /export. Это осознанная позиция D-007: продукт, который держит данные в заложниках, продаётся хуже, чем продукт, из которого можно уйти за один клик.

GET /v1/export
Чеклист проверки (2)
  • Отправить боту /exportПриходит zip, открывается, внутри markdown-файлы
  • Повторить с выключенным биллингом и без подпискиЭкспорт всё равно работает — он не гейтится
Работает

Учёт расхода: сохранения и байты

Принцип: Два числа, потому что и вопроса два: «сколько прислал» не уменьшается никогда, иначе это не лимит, а «сколько занимает» считается по факту и падает, когда файл действительно стёрт. Считается там, где байты легли на диск, а не там, где о них рассказали в запросе. Ничего не применяется, пока биллинг выключен, и никакого счётчика на экранах нет.

GET /v1/billing/status
Чеклист проверки (2)
  • Сохранить файл и запросить статусОба числа выросли на размер файла
  • Стереть запись совсемbytes_stored упал, bytes_total на месте
Частично

Поля клиентского шифрования в схеме

Принцип: encrypted, enc_alg, enc_wrapped_key и enc_nonce есть с миграции 001 и проходят весь конвейер нетронутыми, а users.key_salt и users.key_check зарезервированы под KDF фазы 3. Самой криптографии нет: секреты хранятся на сервере открытым текстом, и бот об этом предупреждает. Статус «частично» — это про схему, готовую к фазе 3, а не про работающее шифрование. Заводить эти поля позже означало бы переписывать синхронизацию (NFR-7, D-006).

Чеклист проверки (2)
  • PRAGMA table_info(records) | grep enc_Все четыре поля на месте с миграции 001
  • Проверить, что запись с encrypted=1 не попадает в records_ftsСовпадений в индексе нет

Лендинг и валидационный waitlist

Работает · 2Частично · 1
Работает

Waitlist-анкета в боте с двумя вопросами

Принцип: bot/waitlist_bot.py на стандартной библиотеке: HTTP-сервер, SQLite, urllib, ноль зависимостей. Вебхук /tg/webhook проксируется на 127.0.0.1:8091. Новичок получает номер в очереди и два вопроса: где сейчас живут его секреты и инструкции (saved, notes, vault, chaos) и уточняющий с ответом да/нет. Ответы в waitlist.sqlite3, свободный текст в таблицу messages, владельцу уходит уведомление. Ответы влияют на продукт, но в пороги D-016 не засчитываются — аудитория уже отпитчена (D-018).

POST https://brainkeeper.app/tg/webhook
Чеклист проверки (2)
  • Написать боту /start с неenrolled-аккаунтаПриветствие с номером в очереди и первый вопрос
  • После /enroll написать боту тот же /startТот же чат работает как ящик захвата, waitlist-анкета не мешает
Работает

Лендинг brainkeeper.app на русском и английском

Принцип: Статическая сборка из landing/src/ через build.py, выкладка landing/deploy.sh (сборка, rsync, smoke-проверка). RU на /, EN на /en/. Единственный призыв к действию — Telegram-бот; ни форм почты, ни оплаты. Цены: 299 ₽/мес якорь и 199 ₽/мес ранняя птица, в EN-локали ведут доллары по привязке ~85,4 ₽/$ из D-017; правило пересчёта записано в §4 N7 спеки лендинга и обязательно к прочтению перед правкой любой цифры.

https://brainkeeper.app/https://brainkeeper.app/en/
Чеклист проверки (1)
  • Открыть обе версии на телефонеОбе читаются, не едут вбок, кнопка ведёт в бота
Частично

Публичный счётчик очереди

Принцип: Счётчик регенерируется в waitlist-count.json при каждой новой записи и лежит в /var/lib/brainkeeper/, вне веб-корня, потому что деплой лендинга делает rsync --delete и стёр бы его. Статус «частично» по делу: файл отдаётся и обновляется, но на самой странице число показывается только после порога COUNTER_MIN — маленькая цифра работает против страницы, поэтому пока лендинг его не выводит.

GET https://brainkeeper.app/waitlist-count.json
Чеклист проверки (2)
  • curl https://brainkeeper.app/waitlist-count.jsonJSON с текущим числом
  • Перевыложить лендинг и запросить файл сноваСчётчик не обнулился

Фаза 2 — импорт, чтение, биллинг, другие клиенты

Работает · 3Частично · 2В планах · 1
Работает

Импорт из Pocket, Telegram, Obsidian и закладок с предпросмотром

Принцип: server/brainkeeper/importers.py по §14 техспеки: загрузка файла (POST /v1/import), разбор в фоновом потоке по одной задаче на пользователя, предпросмотр с галочками до сохранения и явный commit выбранного. Ссылки ставятся в очередь на парсинг медленно, чтобы импорт двух тысяч закладок не превратился в двухтысячный залп по чужому сайту. Статус «частично»: код выкатывается этим деплоем, но на реальных выгрузках Pocket и Obsidian ещё не прогонялся — форматы экспорта у сервисов меняются молча.

POST /v1/importGET /v1/importGET /v1/import/{id}GET /v1/import/{id}/previewPOST /v1/import/{id}/commit
Чеклист проверки (2)
  • Загрузить HTML-экспорт закладок браузераВозвращается import_id и общее число найденных элементов
  • Открыть предпросмотр и снять галочки со всех, кроме двухПосле commit в базе ровно две новые записи
Работает

Напоминания на всех поверхностях — доставка по-прежнему в Telegram

Принцип: Заводятся в боте фразой, в приложении и на десктопе — кнопкой с точным моментом. Разбор живой речи живёт только на сервере, поэтому «через неделю» значит одно и то же везде; кнопке «завтра в 9» разбирать нечего — клиент знает и день, и час, и пояс, и потому пояс он не спрашивает. Повтор: день, неделя, месяц, год. Пропущенное догоняется до ближайшего будущего срабатывания, а не высыпается в чат пачкой. Напоминание о секрете не называет его никогда. Без сети клиент честно отвечает, что напоминание не заведено.

GET /v1/remindersPOST /v1/remindersDELETE /v1/reminders/{id}POST /v1/reminders/{id}/snoozePOST /v1/settings/timezone
Чеклист проверки (2)
  • «Напомнить» на записи в приложенииОтвет называет местное время, напоминание в списке
  • Дождаться срабатыванияСообщение в Telegram с кнопками «Открыть» и «Отложить»
Работает

Раздел «Почитать» — 5–7 карточек, фильтр по минутам, «не сейчас»

Принцип: server/brainkeeper/reading.py по §15: GET /v1/reading отдаёт максимум 7 карточек (type='reading', opened_at пуст, не удалено, defer_until пуст или в прошлом), POST /v1/records/{id}/defer откладывает на месяц, GET /v1/reading/cleanup раз в квартал показывает годовалые ни разу не открытые записи. Счётчиков за пределами раздела нет и быть не должно — D-009 запрещает и бейдж на иконке, и поле unread в /v1/stats. Статус «частично»: серверная часть выкатывается сейчас, полный UI раздела в PWA — работа этого же деплоя и его ещё никто не гонял на живой базе.

GET /v1/readingPOST /v1/records/{id}/deferGET /v1/reading/cleanup
Чеклист проверки (3)
  • Сохранить 8–10 длинных статей и открыть разделНе более 7 карточек, у каждой время чтения
  • Нажать «не сейчас» и обновить разделКарточка ушла на месяц, но находится поиском
  • Посмотреть иконку установленного приложения и главный экранНигде нет числа непрочитанного
Частично

Биллинг через Telegram Stars — собран и выключен

Принцип: server/brainkeeper/billing.py смонтирован и отвечает, но BK_BILLING_ENABLED=0 по умолчанию: POST /v1/billing/invoice отдаёт 503, инвойсы не уходят, лимит бесплатного тарифа считается и показывается, но не применяется (enforced: false). «Частично» здесь означает ровно это: проводка работает, деньги не принимаются и не будут, пока владелец не примет отдельное решение — это граница D-023. Когда включат, цены берутся из D-017 (Pro 299 ₽/мес, ранняя птица 199 ₽/мес с фиксацией, пожизненная 4 900 ₽ на первые 200), а количество звёзд считается на момент выставления счёта по курсу из конфига. charge_id уникален, поэтому повторно доставленный апдейт не выдаст вторую подписку.

GET /v1/billing/statusGET /v1/billing/tiersPOST /v1/billing/invoicePOST /v1/billing/stars/confirm
Чеклист проверки (2)
  • Запросить GET /v1/billing/statusenforced: false, saves_total и limit показаны
  • Попробовать выставить счёт503, в Telegram ничего не приходит
Частично

Расширения для браузера и tray-приложение

Принцип: Расширение под Chrome и Firefox и трей-приложение на Tauri собраны и работают локально: расширение ставится распакованным из ext/, приложение — .deb на 3,6 МБ (бинарник 6,9 МБ при бюджете 15). У приложения есть первый запуск из пяти экранов, установка автозапуска и горячей клавиши. Статус «частично» по двум причинам: в сторы ничего не подано — присутствие в сторах это уже продажа, а она ждёт решения владельца (D-023); и собрать здесь можно только Linux — macOS требует Apple SDK, Windows требует clang и lld.

Чеклист проверки (2)
  • cd desktop && ./package.sh, затем sudo apt install ./target/release/bundle/deb/brainkeeper_0.1.0_amd64.debПакет ставится, в меню появляется BrainKeeper, первый запуск открывает окно сам
  • Загрузить ext/ распакованным расширением в ChromeИконка в панели, Ctrl+Shift+S сохраняет текущую вкладку
В планах

Настоящее клиентское шифрование, семантический поиск, шаринг

Принцип: Явно вне фазы 2 (§18). Криптография — фаза 3: схема под неё готова с миграции 001, но ключи не выводятся и ничего не шифруется, поэтому секреты на сервере лежат открыто и бот об этом предупреждает. Семантический поиск отложен вместе с эмбеддингами — это единственный случай, где LLM реально заработает своё место (D-022). Шаринг — фаза 4 (D-011): своя модель прав, своя семантика синхронизации, отдельный продукт по сути.

Чеклист проверки (1)
  • Открыть запись типа «секрет» и прочитать предупреждение ботаПредупреждение честно говорит, что шифрования пока нет