anki_confДата: 2026-09-08. Отправитель: репозиторий yomitan_phrases/src — форк Yomitan,
расширение Flib-club. Получатель: anki_conf_deploy/turmalin_build — владелец
dict_base.db и Go-инструментов сборки Yomitan-словарей.
Что этот документ делает: описывает артефакт, который обязан произвести anki_conf,
чтобы расширение показывало русские переводы рядом с английскими статьями. Читателю не нужно
читать код расширения: всё, что от него требуется, названо здесь, и у каждого требования указан
конкретный символ-читатель в форке — чтобы требование можно было опровергнуть, а не принять на веру.
Чего этот документ не делает: не пересказывает ТЗ расширения. Внутренняя постановка —
docs/superpowers/specs/2026-09-07-russian-translation-and-chunk-search-design.md (далее
ТЗ-расширения); сюда вынесено только то, что пересекает границу репозиториев.
Пометки достоверности (те же, что в ТЗ-расширения):
ИЗ КОДА — прочитано в файле форка на коммите 6d5074fb, указан символ;
ЗАМЕРЕНО — проверено командой; РЕШЕНИЕ — продуктовый выбор;
ОТКРЫТО — ответа нет; ⚠️ — грабля.
⚠️ Якоря — по символам, а не по номерам строк. Номера строк в этом проекте протухают;
где ниже назван номер, он снят на 6d5074fb. Ищите по имени функции.
Редакция 2 (после состязательной проверки). Что изменилось по существу:
wty-en-en
уже занят апстримным словарём, а импорт словаря с занятым заголовком пропускается.
Симптом при этом неотличим от разъехавшегося отпечатка.isUpdatable / indexUrl / downloadUrl, унаследованные
из апстримного index.json, дают пользователю кнопку, заменяющую нашу пару апстримом.1 962 075 из A5 не воспроизводится; правило «мерить, а не
переписывать» применено к самому этому документу.Нужен не «русский zip», а пересобранная пара
wty-en-en.zip+flibooster-en-ru.zip, выпущенная одним прогоном, где каждая строка обоих банков несёт ненулевойsequence, русская строка несётsequenceсвоей английской строки, аindex.json.revisionу обоих zip побайтно одинаков.
Формулировка владельца была: плагину нужен словарь, «содержащий и английские артикли и русские артикли». Намерение верное — пользователь обязан получить и английские, и русские статьи. Уточняется только форма: это не один словарь с двумя языками внутри, а пара словарей, поставляемая вместе и связанная штампом. Почему именно так — §2, и это утверждение выводится из кода, а не из вкуса.
⚠️ Сегодняшний апстримный wty-en-en для этой задачи непригоден. У него sequence всех строк
равен нулю, а нулевые штампы расширение отбрасывает (§5.1). Поэтому английский словарь тоже
пересобирается, а не берётся как есть. Это главная новость этого документа: объём работы —
не «сделать русский zip», а «выпустить пару».
ИЗ КОДАРасширение отделяет перевод от оригинала по имени словаря, и делает это в трёх независимых местах. Если бы обе стороны лежали в одном словаре, все три места сломались бы одновременно.
ИЗ КОДА pairDefinitions (ext/js/display/translation-pairing.js) раскладывает определения
одной статьи на «хозяев» и «переводы» единственным предикатом:
if (definition.dictionary === translationDictionary) {
/* это перевод */
}
Одно имя словаря на оба языка — и каждая английская статья была бы объявлена переводом.
ИЗ КОДА DisplayTranslation._hideTranslationSourceDefinitions
(ext/js/display/display-translation.js) убирает русские определения из обычного списка
.definition-list, отбирая узлы по атрибуту data-dictionary; атрибут ставит
DisplayGenerator._createTermDefinition (ext/js/display/display-generator.js,
node.dataset.dictionary = dictionary). Тот же ключ — имя словаря.
ИЗ КОДА DisplayTranslation._shouldShowButton включает кнопку Ru, только если среди
установленных словарей есть словарь с именем translationDictionary
(info.some((d) => d.title === options.flibTranslation.translationDictionary)).
⇒ Имя словаря — единственный признак «это перевод». Оно приходит из index.json.title и
живёт до самого DOM. Один словарь на два языка обнулил бы колонку, блюр (§4) и пересчёт счётчиков
определений сразу.
РЕШЕНИЕ Два zip, выпускаемые вместе, связанные штампом sequence и общим revision.
Пользователь при этом получает ровно то, что просил владелец: и английские статьи, и русские —
в одной карточке, потому что расширение сливает статьи двух словарей по общему заголовку
(§5.2), а не потому, что они лежат в одном файле.
Расширение готово и слито — колонка, блюр, уборка русских строк из обычного списка, учёт
раскрытий. Не хватает только данных, и они целиком на стороне anki_conf.
| что | состояние на 6d5074fb |
|---|---|
| код паринга и колонки в расширении | есть: ext/js/display/translation-pairing.js, ext/js/display/display-translation.js |
| имена словарей в настройках | есть: ext/data/schemas/options-schema.json (flibTranslation) + миграция OptionsUtil._updateVersion78 (options-util.js) |
сам словарь flibooster-en-ru |
⚠️ не существует нигде — только как строка в схеме, миграции и тестах (test/options-util.test.js) |
словарь wty-en-en со штампами |
⚠️ не существует; апстримный zip (ext/data/recommended-dictionaries.json → HuggingFace) имеет sequence = 0 у всех строк |
манифест провижининга ext/data/provisioning/dictionaries.json |
ЗАМЕРЕНО содержит []; каталога ext/data/provisioning/dictionaries/ в дереве нет |
| инструмент экспорта пары | ⚠️ не написан — ТЗ-расширения §12 числит его за turmalin_build, исполнитель не назначен |
| замена уже установленной пары новым отпечатком | ⚠️ не написана (§9.1); работа форка, но anki_conf она бьёт по приёмке |
⇒ anki_conf — блокирующая зависимость. Пока пары нет, расширение ведёт себя корректно
(показывает переводы сиротами), но пользователь не видит ни одной пары, потому что видеть нечего.
Короткая картинка результата — чтобы сборщик понимал, ради чего штампы.
Ru
(DisplayTranslation._ensureButton → общая панель ensurePopupToolbar)._hideTranslationSourceDefinitions) — иначе они выглядели бы просто ещё одним определением.Ru каждый перевод рисуется правой ячейкой в одном ряду со своей английской
статьёй (pairDefinitions → _buildCell). Ряд = статья: ячейка живёт внутри узла
.definition-item своего хозяина.flib.club и поддоменах ячейка заблюрена до клика (isFlibHost,
ext/js/data/flib-host.js); вне flib.club — видна сразу._appendOrphanRow).⇒ Именно поиск хозяина (findHost) и есть та операция, ради которой нужен штамп. Без штампа
интерфейс работает, но состоит из одних сирот.
sequence — ненулевой в обоих банкахИЗ КОДА findHost (translation-pairing.js):
const stamps = new Set(translation.sequences.filter((s) => s > 0));
if (stamps.size === 0) { return null; }
…
if (host.sequences.some((s) => stamps.has(s))) { return row; }
Требования, вытекающие буквально:
0 и -1 отфильтровываются. -1 расширение
ставит само, когда поля нет (DictionaryDatabase._createTerm: typeof sequence === 'number' ? sequence : -1).findHost берёт первого подходящего непарного
хозяина; два английских определения одной карточки с одинаковым штампом дадут произвольный
выбор.ИЗ КОДА findHost пропускает
ряды, у которых хозяин уже занят (if (host === null || row.translation !== null) { continue; }),
а pairDefinitions отправляет непристроенный перевод в сироты. ⇒ Два русских перевода с
одинаковым sequence — например два смысла одной английской статьи — дадут одну пару
и одну сироту. Желание дать два перевода на статью естественно, но сегодняшний интерфейс
его не выражает: ряд один, ячейка одна. Если два смысла нужны — они кладутся в один
glossary одной строки, а не в две строки с общим штампом.⚠️ ГЛАВНАЯ ГРАБЛЯ: включатель — это поле СТРОКИ банка, а не флаг в index.json.
Это ровно тот случай, когда «сначала порт включателя, потом украшения»: сборщик, который выставил
"sequenced": true и не тронул поле строки, не сделал ничего.
ИЗ КОДА DictionaryImporter._convertTermBankEntryV3 (ext/js/dictionary/dictionary-importer.js)
разбирает строку банка v3 как
[expression, reading, definitionTags, rules, score, glossary, sequence, termTags] — позиция 6
— и кладёт sequence в запись базы безусловно, ни на что не глядя.ИЗ КОДА index.json.sequenced читается только в DictionaryImporter._createSummary
(sequenced: typeof indexSequenced === 'boolean' && indexSequenced), и этот булев флаг сводки
имеет ровно двух читателей, оба — про другое:
DictionaryImportController._addDictionarySettings (автовыбор general.mainDictionary при первом
импорте) и DictionaryController._updateMainDictionarySelectOptions (какие словари показать
в выпадающем списке главного словаря). К парингу флаг отношения не имеет.
⇒ Симметрично: словарь, у которого поле строки заполнено, а флаг индекса не выставлен, будет париться нормально. Флаг — вопрос удобства настроек, а не работоспособности.
РЕШЕНИЕ "sequenced": true — только у английского zip. ⚠️ У флага есть побочный эффект:
ИЗ КОДА DictionaryImportController._addDictionarySettings при пустом general.mainDictionary
назначает главным первый импортированный sequenced-словарь. Если первым встанет русский,
главным станет он. В режиме group это ничего не ломает, но в выпадающем списке главного словаря
появятся оба, а в свежем профиле главным окажется словарь переводов. Ставить флаг обоим — допустимо;
ставить его только английскому — безопаснее и ничего не стоит.
Перевод попадает в ту же карточку, что оригинал, только если расширение сгруппировало обе статьи в одну.
ИЗ КОДА Translator._groupDictionaryEntriesByHeadword (ext/js/language/translator.js), ключ
группы:
this._createMapKey([term, normalizedReading, ...inflectionRuleChainCandidates]);
Что из этого чем является для сборщика:
| поле строки банка | роль |
|---|---|
expression (поз. 0) |
несущее: это term в ключе группы. Расхождение — разные карточки, все переводы сироты |
reading (поз. 1) |
несущее: normalizedReading в ключе. Пустое поле подменяется на expression (_convertTermBankEntryV3) |
rules (поз. 3) |
несущее косвенно: rules решают, какие деинфляции применимы, а цепочка деинфляции входит в ключ группы |
definitionTags (поз. 2), termTags (7) |
на ключ группы не влияют; расхождение даёт разные ярлыки у двух половин ряда, а не потерю пары |
glossary (поз. 5) |
обязано отличаться — это и есть содержимое перевода |
sequence (поз. 6) |
§5.1 |
РЕШЕНИЕ Практическое правило остаётся прежним и более строгим, чем минимум: русская строка —
побайтная копия английской по expression, reading, rules, definitionTags, termTags,
и отличается только glossary и sequence. Это ровно требование Ш-2 / критерий A4
ТЗ-расширения; здесь лишь названо, какая его часть несущая, а какая — гигиена.
⚠️ Исключение по reading, иначе побайтная сверка забракует исправную пару. ИЗ КОДА
_convertTermBankEntryV3 подменяет пустое чтение на выражение
(reading = reading.length > 0 ? reading : expression), и в ключ группы уходит уже подменённое
значение. ЗАМЕРЕНО (проверяющим, по апстримному zip) в wty-en-en все reading пусты.
⇒ Пара, где английская строка несёт "", а русская — expression, спарится нормально.
Правило сверки формулируется как «reading русской строки пуст или равен expression»,
а не «побайтно равен английскому».
revision — побайтно одинаковый и непустойИЗ КОДА isStrictPairingAvailable (translation-pairing.js):
return host.revision.length > 0 && host.revision === translation.revision;
revision берётся из index.json каждого zip (DictionaryImporter._createSummary) и приходит
на фронт через api.getDictionaryInfo().strict === false → pairDefinitions вызывается с strict: false →
findHost не вызывается вовсе → все переводы сироты.revision до паринга не доходит — его ловит импорт. ИЗ КОДА
DictionaryImporter._readAndValidateIndex: if (typeof version !== 'number' || !title ||
!revision) { throw new Error('Unrecognized dictionary format'); }. Требование «непустой»
остаётся в силе, но нарушение проявится как отказ импорта, а не как отсутствие пар.rowid в dict_base.db двигается при пересборке и при
VACUUM, и два zip из разных сборок молча поставили бы перевод под чужую статью.⇒ revision — отпечаток сборки, а не «версия словаря» в человеческом смысле. Формат
предложен ТЗ-расширения §3.1: <дата сборки>+<rows>-<max_rowid>. Расширение формат не проверяет —
ему важно только совпадение и непустота. Владелец формата — anki_conf.
wty-en-en и flibooster-en-ruИЗ КОДА ext/data/schemas/options-schema.json, ветка flibTranslation:
"translationDictionary": {"type": "string", "default": "flibooster-en-ru"},
"hostDictionary": {"type": "string", "default": "wty-en-en"}
Те же значения проставляет миграция OptionsUtil._updateVersion78 (ext/js/data/options-util.js) —
и она же, по своему комментарию, единственный источник этих значений: сид
ext/data/provisioning/default-options.json их не содержит и содержать не должен.
ЗАМЕРЕНО Грепом по ext/ (*.html, *.js, *.json): элемента интерфейса настроек для этих
двух полей нет. Единственные читатели — display-translation.js (_shouldShowButton,
_hideTranslationSourceDefinitions, _renderColumns) и translation-pairing.js.
⇒ ⚠️ index.json.title обоих zip обязан совпадать с этими строками побайтно. Иначе
пользователю придётся править экспорт настроек руками — интерфейса, которым это чинится, нет.
⚠️ Заголовок английского zip — это ТРЕТЬЕ условие паринга, а не только косметика. ИЗ КОДА
findHost отбрасывает хозяев чужих словарей поимённо: if (host.dictionary !== hostDictionary)
{ continue; }. Хозяин назван по имени намеренно — у пользователя может стоять второй английский
словарь с чужими sequence. ⇒ Английский zip с заголовком wty-en-en-stamped (или любым другим)
установится, будет виден в списке, и не спарится ни разу, даже если штампы и revision
безупречны.
О «необычном» в заголовках: обе строки — ASCII, нижний регистр, дефисы, без пробелов и кавычек.
ИЗ КОДА заголовок словаря попадает в CSS-селектор атрибута (Display._getCustomCss:
[data-dictionary="${escapedTitle}"], кавычки экранируются; результат уходит в публичный
setCustomCss) и в dataset.dictionary. Ограничений на символы схема dictionaryIndex
не накладывает, но менять эти строки не нужно и нельзя — их значение зашито в дефолты настроек.
index.jsonФорматные ограничения названы явно, чтобы их не приходилось угадывать — все проверяются ajv-схемами при импорте, то есть их нарушение даёт отказ импорта, а не тихую потерю пар.
format (или его алиас version) = 3. ИЗ КОДА диспетчеризация — version === 1 ? v1 : v3,
и она сделана в двух разных местах: имя схемы выбирает
DictionaryImporter._getDataBankSchemas ('dictionaryTermBankV1' / 'dictionaryTermBankV3'),
а конвертер строк — тело DictionaryImporter.importDictionary, в цикле по termFiles
(version === 1 ? this._convertTermBankEntryV1 : this._convertTermBankEntryV3).
⇒ Формально format: 2
тоже разбирается конвертером v3 и позицию 6 сохранит; но РЕШЕНИЕ — писать 3, потому что
именно v3 описывает строку, которую мы требуем. format: 1 непригоден: ИЗ КОДА
_convertTermBankEntryV1 разбирает строку как
[expression, reading, definitionTags, rules, score, ...glossary] — поля sequence
в v1 нет вообще.ИЗ КОДА dictionary-term-bank-v3-schema.json:
minItems: 8, maxItems: 8, additionalItems: false. Ни укоротить, ни удлинить нельзя.sequence (поз. 6) — JSON integer. Не строка, не null, не число с точкой.
⇒ Строка с пропущенным или нечисловым штампом не импортируется вовсе (ajv), а не паринг
тихо теряет.definitionTags (поз. 2) — string | null. Пустая строка трактуется как «тегов нет».index.json (ext/data/schemas/dictionary-index-schema.json):
title, revision (оба непустые — см. §5.3). format/version — целое из [1, 2, 3].index.json, перечислено в DictionaryImporter._createSummary.
Ни sourceLanguage, ни targetLanguage, ни frequencyMode на паринг не влияют.isUpdatable / indexUrl / downloadUrl — этих полей в обоих index.json быть НЕ ДОЛЖНОЭто самый тихий способ откатить всю работу, и он срабатывает у сборщика, который поступает
естественно: регенерирует банки, а метаданные index.json берёт из апстримного zip.
ЗАМЕРЕНО (проверяющим, чтением index.json из скачанного zip): апстримный wty-en-en несёт
"isUpdatable": true и адреса на HuggingFace.
Что тогда произойдёт — по коду:
ИЗ КОДА DictionaryEntry.checkForUpdate (ext/js/pages/settings/dictionary-controller.js)
вызывается для каждого словаря с isUpdatable и валидными URL, когда пользователь нажимает
«Check for updates» в настройках (_onCheckUpdatesButtonClick → _checkForUpdates): качает
апстримный index.json и сравнивает ревизии через compareRevisions.
⚠️ Проверка запускается нажатием, а не сама по себе, — но это лишь означает, что бомба
с часовым механизмом ждёт первого визита в настройки, а не что её нет.ИЗ КОДА compareRevisions (ext/js/dictionary/dictionary-data-util.js) сначала проверяет обе
строки регуляркой ^(\d+\.)*\d+$. Наш отпечаток <дата>+<строки>-<max_rowid> её не проходит,
поэтому сравнение вырождается в строковое current < latest — результат произволен и почти
наверняка даст «доступно обновление».DictionaryController.updateDictionary) удаляет наш словарь и ставит апстримный zip.
⇒ Штампы исчезли, revision разъехался, вся пара мертва — а по симптомам это неотличимо
от ошибки сборки.Обратный случай не лучше: ИЗ КОДА DictionaryImporter._createSummary —
if (typeof isUpdatable === 'boolean') { if (!isUpdatable || !this._validateUrl(indexUrl) ||
!this._validateUrl(downloadUrl)) throw new Error('Invalid index data for updatable dictionary'); }.
⇒ Поле, присутствующее со значением false, роняет импорт; поле true без валидных
indexUrl и downloadUrl — тоже. Схема индекса вдобавок объявляет isUpdatable как const: true,
так что false не проходит и ajv.
РЕШЕНИЕ Полей isUpdatable, indexUrl, downloadUrl в index.json обоих zip нет.
Не false, не пустая строка — отсутствуют. Обновление пары делается пересборкой и
перевыкладкой (§9), а не механизмом автообновления Yomitan: он не умеет держать два словаря
в согласии и разведёт их при первом же срабатывании.
Таблица существует для проверки: поле без названного читателя — признак ошибочного требования.
| поле артефакта | цепочка читателей в форке |
|---|---|
строка банка, поз. 6 sequence |
DictionaryImporter._convertTermBankEntryV3 → DictionaryDatabase._createTerm → Translator._createTermDictionaryEntryFromDatabaseEntry (sequences) → findHost |
| строка банка, поз. 0/1/3 | Translator._groupDictionaryEntriesByHeadword (ключ группы) |
строка банка, поз. 5 glossary |
DisplayGenerator._createTermDefinitionEntry → содержимое ячейки перевода (DisplayTranslation._buildCell) |
index.json.title |
DictionaryImporter._createSummary → isStrictPairingAvailable, _shouldShowButton, _hideTranslationSourceDefinitions, pairDefinitions |
index.json.revision |
DictionaryImporter._createSummary → isStrictPairingAvailable |
index.json.format / version |
DictionaryImporter (выбор схемы банка) |
index.json.sequenced |
⚠️ только DictionaryImportController._addDictionarySettings и DictionaryController._updateMainDictionarySelectOptions — к парингу отношения не имеет |
index.json.isUpdatable / indexUrl / downloadUrl |
⚠️ DictionaryImporter._createSummary (валидация, бросает) → DictionaryEntry.checkForUpdate → замена словаря апстримным. Полей быть не должно (§5.5) |
findHost пересекает списки штампов, а не сравнивает sequences[0]:
if (host.sequences.some((s) => stamps.has(s))) {
return row;
}
Комментарий в коде объясняет это слиянием определений с одинаковым глоссарием
(Translator._addTermDefinitions → _addUniqueSimple(definition.sequences, sequences)).
⚠️ Уточнение, важное для сборщика. ИЗ КОДА это слияние в том режиме, где колонка вообще
работает, не происходит:
resultOutputMode === 'group' — это первый гейт
DisplayTranslation._shouldShowButton и такой же гейт в _hideTranslationSourceDefinitions;ИЗ КОДА в режимах group/term группировка идёт через Translator._groupDictionaryEntries,
который зовёт _createGroupedDictionaryEntry(..., checkDuplicateDefinitions = false, ...) →
ветка _addTermDefinitionsFast, без definitionsMap и без _addUniqueSimple;_addTermDefinitions) достижимо только из
_getRelatedDictionaryEntries, то есть в режиме merge, где кнопка Ru выключена.⇒ На практике в рабочем режиме sequences каждого определения — список из одного элемента
(своего sequence, либо -1). Пересечение списков — оборонительный код, а не рабочий режим.
Для anki_conf это означает более строгое требование, чем «штамп где-то в списке»: штамп русской
строки обязан быть РАВЕН штампу английской строки. Именно так и сформулирован критерий A2
ТЗ-расширения.
ИЗ КОДА комментарий pairDefinitions, дословно:
⚠️ СМЕШАННОГО РЕЖИМА НЕТ: либо все пары строгие, либо все переводы сироты.
Механика: strict вычисляется один раз на выдачу
(DisplayTranslation._onContentUpdateComplete → isStrictPairingAvailable) и передаётся во все
вызовы pairDefinitions. Фолбэка «не нашли по штампу — привяжем по заголовку» нет намеренно:
состояние, в котором часть рядов собрана одним способом, а часть другим, неотличимо от ошибки.
⇒ Следствие для сборщика: поставка, где штампы проставлены только части статей, стоит ровно столько же, сколько поставка без штампов вообще, — для непроштампованной части. Частичный охват допустим по содержанию (у слова может не быть перевода — тогда в ряду прочерк), но недопустим по механике: если строка перевода существует, её штамп обязан быть валиден.
Это самая дорогая ошибка, потому что выглядит как частичный успех.
Симптом. Кнопка Ru есть, нажимается, переводы видны — но каждый идёт отдельной строкой
с серым чипом «только перевод», а справа от английских статей — прочерки.
Что это значит. ИЗ КОДА _appendOrphanRow рисуется, когда findHost вернул null. Это
полный отказ паринга, а не частичный: колонка прочерков рядом с сиротами — подпись «пары нет
вообще», а не «часть пар не нашлась».
Пять причин, в порядке вероятности:
| причина | как отличить |
|---|---|
sequence равен 0 или не совпадает хотя бы на одной стороне |
⚠️ ни консоли, ни бейджа — stamps.size === 0 (и промах по штампу) возвращают null молча |
| английский словарь не установлен или установлен под другим заголовком (⚠️ см. §9 — самая вероятная причина у вас) | предупреждение (ниже) есть; в списке словарей нет строки ровно с именем wty-en-en |
revision разный у двух zip |
предупреждение (ниже) есть; в настройках у двух словарей видны разные строки ревизии |
| штампы совпадают, но нарушен Ш-2 | предупреждения нет; статьи не слились: русская и английская — разные карточки, перевод сирота в своей |
словарь перевода установлен, но выключен в активном профиле (ИЗ КОДА _getTranslatorEnabledDictionaryMap, ext/js/background/backend.js) |
⚠️ единственная строка без единой сироты: pairDefinitions (translation-pairing.js) не получает ни одного определения с dictionary === translationDictionary, класть в сироты нечего; предупреждения и бейджа тоже нет — колонка перевода везде прочерк |
⚠️ Последняя строка — не «частичный успех», а самая тихая из всех. _shouldShowButton
(условие 3 ниже) и isStrictPairingAvailable оба читают api.getDictionaryInfo(), а тот, ИЗ
КОДА (Backend._onApiGetDictionaryInfo), отдаёт все установленные словари — включённость в
профиле не проверяет никто из них. Кнопка получает право показаться, паринг — право считаться
строгим, а до перевода дело просто не доходит: словарь, которого нет в
_getTranslatorEnabledDictionaryMap, переводчик не запрашивает вообще, ни для одного слова. Итог
выглядит не как «пары нет», а как «словарь-перевода пуст» — и чинится он не пересборкой артефакта,
а галочкой в настройках (§9.2).
Предупреждение, о котором речь: flib: dictionary build fingerprints differ, translations shown unpaired.
ИЗ КОДА оно пишется, когда isStrictPairingAvailable вернула false, а она возвращает false
в трёх случаях: (а) в getDictionaryInfo() нет словаря с именем hostDictionary,
(б) нет словаря с именем translationDictionary, (в) revision пустой или различается.
На практике случаев два: ветка (б) до предупреждения не доходит — _onContentUpdateComplete
выходит раньше по _shouldShowButton, которая этот же словарь и требует (§8, условие 3), так что
(б) достижима только гонкой (словарь удалён между двумя вызовами getDictionaryInfo()).
⚠️ Текст предупреждения врёт про причину: он говорит про отпечатки, а сработать мог из-за
отсутствующего английского словаря. Не начинайте отладку с revision.
Ещё две поправки к тому, как это предупреждение выглядит:
_onContentUpdateComplete вызывается на каждую выдачу, то есть запись
в консоли появляется на каждый поиск.ИЗ КОДА log.warn → Application._onLogGenericError →
api.logGenericErrorBackend → Backend._onLogGenericError → _updateBadge() → на иконке
расширения загорается оранжевый бейдж !. Пользователь увидит его без всякой консоли.ИЗ КОДА
открытие попапа иконки расширения зовёт api.logIndicatorClear()
(ext/js/pages/action-popup-main.js) → Backend._onApiLogIndicatorClear → бейдж снят.
⇒ Кликнули по значку — предупреждение «исчезло», хотя причина осталась. Не считайте
погасший бейдж признаком починки; он вернётся на следующем же поиске.⚠️ Асимметрия, из-за которой тишина обманчива — но она уже, чем кажется. Молча проваливается
только случай «целочисленный штамп, но <= 0 либо не совпадающий». Всё остальное ловится
раньше и громче:
null поле sequence → отказ импорта (ajv: строка v3 — ровно
8 элементов, позиция 6 — integer, §5.5);revision → отказ импорта (Unrecognized dictionary format, §5.3);revision → предупреждение + бейдж.⇒ Не считайте валидацию импорта защитой от главной ошибки: она закрывает соседнюю половину.
Проверять sequence > 0 и совпадение штампов надо самому (§13, П1–П3).
Кроме того, кнопки Ru не будет вовсе, если не выполнено любое из трёх условий
_shouldShowButton (ИЗ КОДА, все три проверяются по порядку):
options.general.resultOutputMode === 'group' — в split/merge словари не сливаются в одну
карточку, хозяина не найти никогда;display.contentType === 'terms' — на выдаче kanji/phrase переводить нечего;flibTranslation.translationDictionary
(api.getDictionaryInfo()) — ⚠️ «установлен», а не «включён»: выключенный в профиле словарь
тоже проходит это условие (см. последнюю строку таблицы выше).⇒ Отладочный порядок для сборщика: нет кнопки — смотрите 1–3; кнопка есть, все сироты — смотрите таблицу выше, начиная со второй строки, а не с первой; кнопка есть, сирот нет вовсе, везде прочерк — смотрите последнюю строку таблицы и §9.2.
⚠️ Перед любой приёмкой удалите ранее установленный wty-en-en — иначе вы будете проверять
не свой артефакт. Почему это не паранойя, а типичный случай — §9.
Этот раздел — про то, чего нет в требованиях к артефакту, но без чего безупречно собранная пара
не доедет до паринга на типичной машине. Оба провала ниже выглядят как «fingerprints differ»
и отправляют искать ошибку в revision, которой там нет.
ИЗ КОДА DictionaryImporter.importDictionary:
if (await dictionaryDatabase.dictionaryExists(dictionaryTitle)) {
return {
errors: [
new Error(
`Dictionary ${dictionaryTitle} is already imported, skipped it.`,
),
],
result: null,
};
}
Импорт словаря с уже занятым заголовком пропускается. Механизма «заменить» нет: замена — это
удаление плюс импорт, и делает её только пользователь руками в настройках. ИЗ КОДА
runDictionaryProvisioning (ext/js/pages/common/provisioning-controller.js) отбирает кандидатов
тем же ключом — entries.filter((entry) => !importedTitles.has(entry.title)) — то есть тоже
пропустит.
⚠️ А заголовок wty-en-en на типичной машине уже занят — нашими же дефолтами.
ЗАМЕРЕНО ext/data/provisioning/default-options.json содержит "mainDictionary": "wty-en-en",
и он же значится в ext/data/recommended-dictionaries.json с адресом на HuggingFace. То есть
у пользователя, поставившего английский словарь до нашей пары, стоит апстримный wty-en-en
без штампов.
Сценарий провала целиком:
sequence = 0._shouldShowButton проходит (русский словарь установлен) → кнопка Ru появляется.isStrictPairingAvailable сравнивает revision апстримного (2026.08.29) и нашего
(<дата>+<строки>-<max_rowid>) → false → «fingerprints differ», все переводы сироты.⇒ Симптом идентичен разъехавшемуся отпечатку, а причина другая: пара не собралась, потому что половина не установилась.
ОТКРЫТО Пути «выкатить новый отпечаток поверх старого» сегодня нет. Каждая пересборка требует
на стороне пользователя удаления обоих словарей и переимпорта по фиксированным заголовкам — а
автоматизации этого в форке не написано. Это работа форка, не anki_conf, и она названа здесь
только потому, что без неё anki_conf не сможет ни принять свою работу, ни объяснить отчёт о поле.
Владелец — форк; вносится в кросс-репозиторные зависимости ТЗ-расширения §12.
Что из этого следует для anki_conf практически:
revision у выпущенной пары, чтобы поддержка
могла отличить «стоит старая пара» от «стоит половина пары».Свойство установки, а не артефакта, но живёт здесь же: определения приходят только из словарей,
включённых в текущем профиле (ИЗ КОДА Backend._getTranslatorEnabledDictionaryMap,
ext/js/background/backend.js, — фильтрует options.dictionaries по dictionary.enabled).
⚠️ Обе половины этой ловушки молчаливые, и это стоит знать заранее.
isStrictPairingAvailable (§8) читает api.getDictionaryInfo(), а тот отдаёт установленные
словари независимо от того, включены ли они в профиле — предупреждение и бейдж реагируют только
на установку, не на включённость. ⇒ Включённость словарей не диагностируется ничем: ни
консолью, ни бейджем, ни текстом в интерфейсе.
findHost ищет среди
хозяйских строк, а их нет ни одной) → колонка полностью из сирот с чипом «только перевод»,
без предупреждения и без бейджа.ИЗ КОДА При импорте DictionaryImportController._addDictionarySettings включает словарь
в текущем профиле и добавляет выключенным в остальные. ⇒ У пользователя с несколькими
профилями пара будет работать в одном и молчать в других — это ожидаемо и не является дефектом
артефакта.
См. ловушку в §5.5: isUpdatable в index.json даёт пользователю кнопку, заменяющую наш
проштампованный словарь апстримным. Это тот же класс проблемы — «что происходит с парой после
установки», — и закрывается он отсутствием трёх полей в индексе.
anki_confРЕШЕНИЕ В-5 (ТЗ-расширения §3.5). Словари не кладутся в сборку расширения — расширение
скачивает их при первом запуске, тем же способом, каким сегодня качается Flibooster.exe.
Адресация трёхступенчатая, и каждая ступень решает свою задачу:
В сборке расширения лежит только указатель:
{ "pointer": "https://<хост-раздачи>/dictionaries/latest.json" }
⚠️ Отпечаток сборки словаря не зашивается в расширение. Иначе каждая пересборка словаря требовала бы релиза расширения.
Указатель latest.json (публикует anki_conf) называет один каталог отпечатка и оба
zip с sha256:
[
{
"title": "wty-en-en",
"url": "https://<хост-раздачи>/dictionaries/<отпечаток>/wty-en-en.zip",
"sha256": "…"
},
{
"title": "flibooster-en-ru",
"url": "https://<хост-раздачи>/dictionaries/<отпечаток>/flibooster-en-ru.zip",
"sha256": "…"
}
]
Каталог отпечатка содержит оба zip.
⚠️ Оба адреса обязаны лежать под одним каталогом отпечатка. Тогда «скачали половину пары»
невозможно по построению адреса, а не по дисциплине сборщика. Это второй слой защиты поверх
Ш-3: revision ловит разъезд после установки, единый каталог не даёт ему случиться.
⚠️ sha256 обязателен. ⚠️ Формулировка «расширение сверяет» — это требование к будущей
реализации форка, а не описание сегодняшнего поведения: провижининг по указателю ещё не написан
(см. ниже). Публиковать суммы надо с самого начала: иначе первая же версия загрузчика поедет
без сверки, потому что сверять будет нечего. Приём — тот же, которым Taskfile ядра сверяет
скачанные сборки расширения.
⚠️ Заголовки кэширования. Указатель latest.json обязан отдаваться с коротким
cache-control; ориентир — то, что ядро ставит своему манифесту релизов: max-age=60. Долгий кэш
на указателе означает пользователей, сидящих на разъехавшейся паре. Сами zip лежат под адресом
с отпечатком и потому иммутабельны — им длинный max-age не только можно, но и нужно.
Плейсхолдеры, которые заполняет anki_conf / выкладка, а не этот репозиторий:
<хост-раздачи> (бакет/CDN), <отпечаток>, значения sha256, точный формат строки отпечатка,
регламент и частота пересборки. ОТКРЫТО О-11 ТЗ-расширения: бакет и шаг выкладки не назначены.
ЗАМЕРЕНО Со стороны форка это пока не сделано. runDictionaryProvisioning
(ext/js/pages/common/provisioning-controller.js) сегодня читает манифест вида {file, title} и
собирает локальный адрес getUrl('data/provisioning/dictionaries/' + entry.file). Переход на
указатель — работа форка (ТЗ-расширения §3.5, §12), и она не блокирует сборку артефакта:
пару можно принимать импортом zip вручную.
⚠️ Ни одно число в отчёте о приёмке не берётся из документации — только из произведённого артефакта.
Это не абстрактная осторожность. ЗАМЕРЕНО (ТЗ-расширения §4.1, 2026-09-07): все числа
в anki_conf/docs/api/chunks/README.md оказались протухшими — они писались до пополнения матчсета
коммитом 7c0e035a, и разошлись с реальностью в разы (выражений: README 6 764 против
замеренных 22 690; размер: ~1.6 МБ против 6.44 МБ). Ошибка была не в цифрах, а в способе:
их прочитали из прозы, а не сняли с артефакта.
Правило. Любая величина в приёмочном отчёте — количество статей, доля статей с переводом,
max_rowid, размеры файлов, sha256 — снимается командой по выпущенному zip, и команда
приводится рядом с числом, чтобы её можно было повторить. Число без воспроизводимой команды
считается отсутствующим.
⚠️ Это правило применяется и к самому этому документу — включая базовое число из A5.
Критерий A5 ТЗ-расширения называет 1 962 075 статей в пересобранном wty-en-en.zip и велит
сверять их с term_count апстримного zip. ЗАМЕРЕНО (проверяющим, по zip с адреса из
ext/data/recommended-dictionaries.json, revision: 2026.08.29): в апстримном zip 67 банков
и 1 643 040 строк, а поля term_count в его index.json нет вообще.
⇒ Из этого следуют две вещи, и обе важны для сборщика:
Сверять не с чем в том виде, как написано в A5. Величина, которую надо получить, —
это число строк term-банков, и оно считается командой по zip, например:
unzip -p wty-en-en.zip 'term_bank_*.json' | jq -s 'map(length) | add'
(команда — иллюстрация ожидаемой формы отчёта, а не догма; годится любая, чей вывод воспроизводим).
Чем является 1 962 075 — неизвестно. Это не число строк банков апстримного zip.
Возможно, число строк в dict_base.db, возможно — значение до какой-то фильтрации.
⇒ Не подгоняйте под него ничего и не объясняйте «потерю» 319 035 статей, которой,
возможно, никогда не было. Замерьте своё число, приведите команду, и назовите, что именно
вы посчитали. Это часть ответа на О-Р1 (§12).
anki_conf — форк ответить не можетОТКРЫТО О-Р1. Может ли каждая английская статья получить стабильный ненулевой sequence,
и что при этом происходит с числом статей?
Постановка:
ИЗ ДОКА anki_conf (через ТЗ-расширения §3.1, нами не перепроверено): на русской стороне
штамп уже проставляется (cmd/lexicon-dict-enrich/export.go, src.Sequence = int(rowid)),
а комментарий там же говорит, что у всех строк словаря sequence равен нулю — то есть
на английской стороне штампа нет.Отсюда следует, что английский словарь придётся регенерировать из dict_base.db, а не
переупаковывать апстримный zip. И вот чего форк не знает:
wty-en-en имеет строку-прообраз в dict_base.db,
то есть всякая ли получит rowid — или регенерация неизбежно теряет часть статей?ЗАМЕРЕНО 1 643 040, §11), а не константа 1 962 075 из A5: что она
считает — неизвестно, и term_count, на который A5 ссылается, в апстримном index.json
отсутствует. Меньший размер проштампованного словаря не блокер сам по себе, но A5
тогда обязан быть переформулирован с новым замеренным числом и объяснением расхождения,
а не тихо подогнан. Заодно назовите, что считает 1 962 075 — форк этого не знает.rowid двигается при VACUUM и при DELETE + INSERT выкладки. Гарантируется ли, что оба
zip выпускаются из одного снимка базы, между которыми выкладка не вклинивается?
Ш-3 ловит нарушение постфактум, но лучше его не допускать.⚠️ Связанный риск, названный в ТЗ-расширения §3.5 ИЗ ЗАДАЧИ §9: релизный конвейер ядра сегодня
безусловно перезаписывает dict_base.db.gz необогащённой базой (обход — SKIP_DICT_BAKE=1).
Это бьёт прямо в Ш-1/Ш-3: «пересборка из одного прогона» на сегодняшнем конвейере
не воспроизводится. Владелец вопроса — anki_conf.
anki_confКритерии A1–A5, A6, A6а ТЗ-расширения остаются в силе и здесь не повторяются —
они описывают, что проверяет форк. Ниже — дополнительные критерии, которые anki_conf
проверяет у себя. Каждый — с воспроизводимой командой и числом, снятым с артефакта (§11).
⚠️ Все проверки группы А гоняются по ВЫПУЩЕННЫМ zip, а не по промежуточным таблицам базы.
Конвейер, где выпечка словаря пропускается или подменяется (ТЗ-расширения §3.5, SKIP_DICT_BAKE),
закроет критерии по данным, которые пользователю не уедут. Проверяется тот файл, который уйдёт
в выкладку, — и он же потом публикуется без пересборки.
wty-en-en.zip доля строк банка с sequence > 0 равна 100 %.
Команда и абсолютное число строк приводятся. Нулевых и отрицательных sequence — ноль.flibooster-en-ru.zip: строк с
sequence <= 0 — ноль.flibooster-en-ru.zip существует строка
в wty-en-en.zip с тем же sequence. Число нерезолвящихся штампов — ноль. Это более
сильная проверка, чем выборочный A2 на 20 словах, и она делается программно.wty-en-en.zip нет двух строк с одинаковым sequence
и одинаковой парой (expression, reading): такая пара сделала бы выбор хозяина
произвольным (§5.1).flibooster-en-ru.zip нет двух строк с одинаковым
sequence. ⚠️ Второй перевод на ту же английскую статью не станет второй ячейкой — он станет
сиротой (§5.1), а сирота читается как полный отказ паринга (§8). Число дублирующихся
штампов — ноль.expression
и rules; reading русской строки пуст или равен expression (⚠️ не «побайтно равен
английскому» — §5.2). Расхождения по definitionTags / termTags перечисляются в отчёте
(они не ломают пару, но их наличие — сигнал, что экспорт разошёлся).index.json: format (или version) = 3; title
равен точно wty-en-en и flibooster-en-ru; revision непустой и побайтно одинаков
в двух файлах (diff двух значений приводится). Строка банка — ровно 8 элементов,
sequence — JSON integer (§5.5).index.json нет полей isUpdatable,
indexUrl, downloadUrl — ни в каком значении (§5.5). Проверяется грепом по обоим индексам;
ожидаемый результат — пусто. Нарушение даёт либо отказ импорта, либо тихую замену нашего
словаря апстримным.wty-en-en.zip, ту же величину для апстримного zip (ЗАМЕРЕНО 1 643 040) и объяснение
расхождения; отдельно — что считает константа 1 962 075 из A5 (§11, §12).⚠️ Эти три «до публикации» не проверяются в принципе: П7 — утверждение о процессе, П8 и П9 требуют уже опубликованных адресов. Они закрываются после выкладки и до объявления поставки.
revision вычислена по dict_base.db
в момент экспорта обоих zip, и между экспортами не было выкладки (DELETE + INSERT)
и VACUUM. В отчёте — Rows и MaxRowID на момент экспорта. Форма проверки — журнал
прогона, а не команда по файлу.<отпечаток>/; latest.json
указывает на них обоих; sha256 в latest.json совпадает с посчитанным по скачанным
по опубликованным адресам файлам (обе суммы приводятся).curl -I по latest.json показывает короткий cache-control
(ориентир max-age=60); по zip — длинный/иммутабельный. Вывод заголовков приводится.docs/ любого репозитория —
включая этот документ — в отчёт не попадают (§11).anki_conf закрыть не может.installer.nsi: удаление секции SEC_YOMITAN_DICT и копирования zip установщиком — после
решения В-5 это лишняя работа установщика (ТЗ-расширения §12), но к артефакту отношения не имеет.LIMIT 1 в cmd/lexicon-dict-enrich/source.go (задача anki_conf 9f3c71a8): из-за него
у слова переведена одна статья из многих. ⚠️ Это значит, что даже на идеальной паре колонка
в основном будет из прочерков — и это не дефект интерфейса и не признак сломанного паринга
(признак сломанного паринга — сироты, §8).