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 без заголовка Authorization→401, тело не раскрывает существование пользователей
Импорт из 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/status→JSON с 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.env→0 или переменной нет вовсе — по умолчанию выключено
Приложение научилось сохранять, править, читать и импортировать
Что и зачем: До этого /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 МБ