note 26.300.0: у Базы знаний 2.0 появились события, история версий и подписки
Модуль note в коробочном Битрикс24 отвечает за «Базу знаний 2.0»: коллекции с деревом markdown-документов, совместное редактирование на Yjs, свои права доступа и REST v3 note.*. В 26.300.0 он получил первые публичные события, историю версий, подписки с уведомлениями в IM, массовые операции над деревом, наследование прав по поддереву и выгрузку документа в ZIP. Дифф весит около 7 МБ, и почти всё в нём занимают JS-бандлы, один editor.bundle.js изменился на 28 665 строк (+16 332 / −12 333).
Ваш код релиз заденет, если вы создаёте команды модуля напрямую или ходите в его AJAX и REST из интеграций. Пути ниже указаны от bitrix/modules/note/.
Что сломается
Передавайте параметры команд по имени
В конструкторы нескольких команд Bitrix\Note\Public\Command новые параметры вставили в середину списка, и позиционные вызовы съедут:
CreateDocumentCommand: на девятую позицию, передbool $notifyInitiator, встал?DocumentRepository $repository, а передstring $analyticsTypeдобавлены?DomainEventPublisherи?EventLogService;ArchiveDocumentCommandиDeleteDocumentCommand: перед$analyticsTypeпоявились$eventPublisher,$eventLogService,bool $withNested = trueи$reparentService;ArchiveCollectionCommand: перед$analyticsTypeстоит?DomainEventPublisher;DeleteCollectionCommand: перед$analyticsTypeстоят$eventPublisher,$documentRepositoryи$hardDeleteService.
MoveDocumentCommand, UpdateDocumentCommand, RestoreDocumentCommand, CompactDocumentCommand, OverwriteDocumentContentCommand, HardDeleteDocumentCommand и Restore*FromRecycleBinCommand расширили только в конце списка, их вызовы работают как раньше. Если собираете команды сами, передавайте хвост по имени (analyticsType: '…', notifyInitiator: false), тогда следующая вставка в середину вас не заденет.
Проверьте интеграции на тарифе без Базы знаний
В 26.250 тариф проверяли только компоненты note.editor и note.config.permissions и мобильный коннектор. Теперь проверка стоит на всех ajax-экшенах модуля (фильтр NoteAccess), на REST v3 (NoteRestAccess), на ajax компонента прав, в EditorUploaderController и в провайдерах entity-selector. Доступ закрывается, если тарифная опция недоступна или выключен инструмент menu_note_base, а решение принимает Internal\Service\License\LicenseService::isAccessBlocked().
Интеграция, которая ходила в REST модуля на портале с выключенным инструментом, после обновления получит отказ. Компонент прав вместо заглушки теперь монтирует интерфейс и открывает поверх него тарифный слайдер.
Читайте описание коллекции из markdownDescription
У коллекции появился главный документ, в нём хранится описание базы знаний. REST v3 такой документ прячет. note.document.get, update, archive и delete отвечают на него EntityNotFoundException, как на несуществующий, а add с главным документом в parentId бросает InvalidParentException. Описание коллекции REST v3 отдаёт в read-only поле markdownDescription. Команды ArchiveDocumentCommand и DeleteDocumentCommand на главный документ бросают MainDocumentOperationException.
Замените deleteUpToId()
Из Internal\Repository\DocumentUpdateRepository удалён deleteUpToId(). Вместо него deleteUpToIdReturningAuthors(int $documentId, int $maxId), который возвращает ещё и авторов вычищенных патчей. Класс лежит в Internal, к публичному API модуль его не относит, но если вы его всё-таки вызывали, код упадёт.
Новое
Подпишитесь на события документов
Это первые публичные события модуля, до 26.300.0 своему коду в note зацепиться было не за что.
note:onDocumentLifecycle (класс Public\Event\OnDocumentLifecycleEvent) приходит с параметрами:
reason—created,archived,deleted,hardDeleted,restored,collectionDeletedилиmoved;collectionId;documentIds— весь каскад приходит одним событием;fromиto, только дляmoved.
note:onDocumentContentSettled (Public\Event\OnDocumentContentSettledEvent) несёт reason (compacted, overwritten или titleChanged), collectionId и documentId.
Оба события отправляет Internal\Service\DomainEventPublisher через Application::addBackgroundJob(), уже после ответа пользователю. Событие может прийти и после откатившейся транзакции, то есть рассказать о том, чего в базе нет. Поэтому remove() и reindex() в примере ниже должны быть идемпотентными, а reindex() пусть сам перечитывает документ и убирает его из индекса, если документа уже нет.
Регистрация в install/index.php своего модуля:
<?php declare(strict_types=1);
use Bitrix\Main\EventManager;
use Vendor\KbSync\Integration\Note\DocumentEventHandler;
// DoInstall(); в DoUninstall() те же вызовы через unRegisterEventHandler()
$events = EventManager::getInstance();
$events->registerEventHandler('note', 'onDocumentLifecycle', 'vendor.kbsync', DocumentEventHandler::class, 'onLifecycle');
$events->registerEventHandler('note', 'onDocumentContentSettled', 'vendor.kbsync', DocumentEventHandler::class, 'onContentSettled');
Сам обработчик. DocumentIndex здесь ваш сервис, зарегистрированный в .settings.php модуля:
<?php declare(strict_types=1);
namespace Vendor\KbSync\Integration\Note;
use Bitrix\Main\DI\ServiceLocator;
use Bitrix\Main\Event;
use Vendor\KbSync\Search\DocumentIndex;
final class DocumentEventHandler
{
public static function onLifecycle(Event $event): void
{
$index = self::index();
$apply = match ((string) $event->getParameter('reason')) {
'archived', 'deleted', 'hardDeleted', 'collectionDeleted' => $index->remove(...),
'created', 'restored', 'moved' => $index->reindex(...),
default => null,
};
if ($apply === null) {
return;
}
$documentIds = array_unique(array_map('intval', (array) $event->getParameter('documentIds')));
foreach ($documentIds as $documentId) {
$apply($documentId);
}
}
public static function onContentSettled(Event $event): void
{
self::index()->reindex((int) $event->getParameter('documentId'));
}
private static function index(): DocumentIndex
{
return ServiceLocator::getInstance()->get(DocumentIndex::class);
}
}
Покажите историю версий
История хранится в новых таблицах с ORM-классами EventTable, EventAuthorTable, DocumentVersionTable и DocumentViewTable, а записывает её EventLogService::record(). Читать её из PHP можно через Public\Provider\FeedProvider::listByDocument(), VersionProvider::getVersionBody() и ViewProvider::getViews(), откатывать версию командой RestoreDocumentVersionCommand. Фронтенду те же операции отдают экшены DocumentController: listFeedAction, getVersionAction, restoreVersionAction и getViewsAction.
VersionCleanupAgent удаляет версии старше version_ttl_days дней (по умолчанию 90) и вместе с ними файлы, на которые после этого никто не ссылается.
Настройте подписки и уведомления в IM
Подписки хранит SubscriptionTable. На документ бывают режимы self, subtree и muted, на коллекцию режим all. Управляют ими экшены SubscriptionController::getStateAction, setAction и removeAction, команды SetSubscriptionCommand и RemoveSubscriptionCommand, читать их можно через Public\Provider\SubscriptionProvider.
Доставкой занимается NotificationDrainAgent. Раз в 300 с он читает b_note_event и через Internal\Integration\Im\ImNotificationGateway вызывает CIMNotify::Add. Если в пачке у одного получателя набралось больше одного документа, он получит одно сводное уведомление. Свои типы в настройках IM модуль объявляет обработчиком NotifySchemaHandler::onGetNotifySchema(). По умолчанию у них включён канал SITE, а MAIL и PUSH выключены. Размер пачки задаёт опция notify_batch_size (по умолчанию 500). Опция notify_interval в Configuration объявлена, но её геттер никто не вызывает, а интервал агента задан при регистрации (300 с).
Архивируйте и переносите пачкой
Массовые команды построены на AbstractBulkCommand: Bulk*DocumentsCommand для архива, удаления, удаления насовсем, переноса и восстановления, плюс ArchiveAllInCollectionCommand и DeleteAllInCollectionCommand. Для фронтенда есть экшены archiveManyAction, deleteManyAction, moveManyAction, restoreManyAction и resolveBulkSelectionAction, а для корзины RecycleBinController::hardDeleteManyAction и restoreManyAction. Итог операции приходит объектом Internal\Service\Bulk\BulkOutcome.
После раскрытия поддеревьев в выборке может оказаться не больше bulk_selection_limit документов, по умолчанию 1000. Лимит проверяет SelectionResolver.
Выдавайте права сразу на поддерево
Internal\Service\Access\SubtreeAclReconciler раскладывает грант на поддерево в производные строки b_note_document_access с заполненным SOURCE_DOCUMENT_ID. Если строк выходит больше 500 (узлы × субъекты), расширение прав уходит в агент SubtreeAclReconcileAgent, который регистрирует себя сам.
Новые гранты на поддерево создаются только при включённой опции subtree_inheritance_enabled, по умолчанию она выключена. Перенос, который без прав модератора открывает ветку шире, бросает MoveAccessEscalationException.
Выгружайте документ в ZIP
Выгрузка идёт в два запроса. ExportController::prepareZipAction(int $documentId, string $content) возвращает токен TimeSigner на 15 минут, с солью note.export.zip и id пользователя в подписи. downloadZipAction(string $token) отдаёт архив без проверки CSRF, на чужой токен отвечает 404, на просроченный 410.
Архив собирает DocumentZipExportService. В него попадает не больше 100 вложений общим объёмом до 256 МБ, а готовый пакет лежит в CTempFile два часа.
Включите нужные флаги
Опция activity_enabled включена по умолчанию, а UI-флаги history_enabled, notifications_enabled, hotkeys_enabled и markdown_io_enabled выключены, как и subtree_inheritance_enabled. Включаются они обычными опциями модуля note:
<?php declare(strict_types=1);
use Bitrix\Main\Config\Option;
Option::set('note', 'history_enabled', 'Y'); // история версий в интерфейсе
Option::set('note', 'notifications_enabled', 'Y'); // подписки в интерфейсе
Option::set('note', 'subtree_inheritance_enabled', 'Y'); // новые гранты на поддерево
Option::set('note', 'version_ttl_days', '30'); // версии живут 30 дней вместо 90
По мелочи
- Главный документ коллекции создаёт
MainDocumentService::ensureMainDocument(), для старых коллекций есть агентMainDocumentBackfillAgent. Судя по его докблоку, агента регистрирует апдейтер. DocumentController::archiveActionиdeleteActionполучилиbool $withNested = true. По умолчанию каскад работает как раньше, а сfalseдочерние документы переезжают к родителю черезReparentService.createActionтеперь принимаетstring $markdown = ''.listAccessibleTreeActionиlistAccessibleChildrenActionстроят дерево документов, доступных пользователю (Internal\Service\Sidebar\AccessibleTreeService).OrphanFileCleanupAgentза один проход чистит файлы, которые осиротели ещё до появления версий.- У
PushNotificationServiceпоявилисьsendToUserChannels()иnotifyDocumentGrantees(). - В
AccessController::check(),loadItem()иBaseRule::execute()параметры со значениемnullпо умолчанию стали явно nullable.
БД
Схема модуля описана в install/migrations/tables.php, изменения там такие:
- в
b_note_documentдобавлены полеIS_MAIN char(1) default 'N'и индексIX_NOTE_DOC_MAIN (COLLECTION_ID, IS_MAIN); - в
b_note_document_accessдобавлены полеSOURCE_DOCUMENT_ID int not null default 0(ноль у явного гранта) и индексIX_NOTE_DOC_ACCESS_SOURCE; - уникальный индекс
IX_NOTE_DOCUMENT_ACCESS_UNIQ (DOCUMENT_ID, SUBJECT_CODE)заменён наIX_NOTE_DOC_ACCESS_SRC_UNIQ (DOCUMENT_ID, SUBJECT_CODE, SOURCE_DOCUMENT_ID), на установленных порталах замену по комментарию делает апдейтер; - новые таблицы:
b_note_event(bigintID,SCOPE,ENTITY_ID,EVENT_TYPE,USER_ID,VERSION_ID,CREATED_ATи индексы по ленте),b_note_event_author,b_note_document_version(MARKDOWNв mediumtext, индекс поCREATED_ATдля TTL),b_note_document_view(первичный ключDOCUMENT_ID+USER_ID) иb_note_subscription(уникальный индексUSER_ID+SCOPE+ENTITY_ID).
Если читаете b_note_document_access своими запросами, учтите, что на пару «документ + субъект» теперь может прийтись несколько строк: явный грант и производные от разных поддеревьев.
В install/migrations/agents.php зарегистрированы VersionCleanupAgent::run раз в 7200 с и NotificationDrainAgent::run раз в 300 с, SubtreeAclReconcileAgent там намеренно нет. В install/migrations/events.php добавлен обработчик im:OnGetNotifySchema.
Поля TITLE и MARKDOWN в DocumentTable и NAME в CollectionTable получили модификаторы Emoji::encode на запись и Emoji::decode на чтение. По комментарию, соединение MySQL в utf8mb3 молча обрезает четырёхбайтовые эмодзи. Запись в эти поля мимо ORM модификаторы обойдёт, а прямое чтение вернёт эмодзи в виде :f09f9880:.
Мелочи и находки
Экспорт в ZIP обходит четыре особенности ядрового CZip, и все четыре видны в bitrix/modules/main/classes/general/zip.php:
- файлам проставляются external attributes
0xFE49FFE0, и распаковщик мобильного приложения показывает все записи папками, поэтому модуль после упаковки патчит central directory прямо в байтах; - имена пишутся в CP866 без UTF-8-флага, кириллица в сторонних распаковщиках превращается в кракозябры, и модуль транслитерирует имена;
ZIP_START_TIMEзадаётся черезdefine()один раз на процесс, так что в долгоживущем процессе каждый следующийPack()сразу уходит в ветку продолжения, и модуль ставит бюджет шага 86 400 с;_openFile()при ошибке возвращает массив ошибок, а проверкаif (!$this->_openFile("wb"))принимает его за успех, поэтому целевой файл модуль создаёт заранее черезMain\IO.
Уведомление content_changed отправляется без NOTIFY_TAG. С непустым тегом CIMMessenger::Add перезаписывает PARAMS['USERS'] набором для схлопывания уведомлений и теряет настоящий список соавторов (lib/Internal/Integration/Im/ImNotificationGateway.php).
Отдельного outbox у уведомлений нет, очередью служит b_note_event. Курсор NotificationDrainAgent хранит в опции event_notify_last_id и сдвигает только после доставки всей пачки. Пересечение тиков исключает Connection::lock('note_notify_drain'). Через 10 секунд watchdog перестаёт брать новые пачки, а начатую агент дорабатывает. Если хвост остался, агент ставит global $pPERIOD = 1 и дожимает его на следующем хите. События старше суток не рассылаются, иначе ленивый бэкфилл created с историческими датами завалил бы подписчиков уведомлениями.
SubtreeAclReconcileAgent держит всё состояние в NAME агента, run(sourceId, collectionId, cursor). Тик возвращает выражение с новым курсором, пустая строка снимает агента. AddAgent дедуплицирует агентов по NAME, а $existError = false не даёт засорить $APPLICATION исключением CAdminException.
DomainEventPublisher в комментарии называет потребителем событий идемпотентный «RAG sync subscriber», но подписчиков на onDocumentLifecycle и onDocumentContentSettled в дистрибутиве нет.
Появился lang/en/lib/Infrastructure/Controller/AiChatController.php с фразой «Chat is not available». Самого контроллера в этой версии нет, он приходит в note 26.400.0.
Опцию phase4_broadcast_enabled оставили с номером фазы в имени, потому что переименование молча сбросило бы переключатель на порталах. В комментариях кода встречаются ссылки на внутреннюю разметку спецификаций: [P7.T1 / ALG-03 NORMATIVE], [PRD §6.2], SDD P7.