Merge pull request #13 from kvasonaft/Understanding

Understanding
This commit is contained in:
kvasonaft 2026-07-16 15:08:40 +03:00 committed by GitHub
commit 67da77974d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 829 additions and 232 deletions

359
GUIDE.md Normal file
View file

@ -0,0 +1,359 @@
# FB2 Reader: подробное руководство по устройству плагина
Это руководство написано для человека, который **не знает JavaScript и TypeScript**, но хочет понять, как работает этот плагин — вплоть до каждой строчки. Читать лучше по порядку: сначала общая картина, потом основы языка, потом разбор кода.
## Содержание
1. [Что делает плагин](#1-что-делает-плагин)
2. [Общая картина: как код становится плагином](#2-общая-картина-как-код-становится-плагином)
3. [Файлы репозитория: кто за что отвечает](#3-файлы-репозитория-кто-за-что-отвечает)
4. [Минимум JavaScript и TypeScript для чтения кода](#4-минимум-javascript-и-typescript-для-чтения-кода)
5. [Как Obsidian запускает плагин](#5-как-obsidian-запускает-плагин)
6. [Разбор main.ts: путь книги от файла до экрана](#6-разбор-maints-путь-книги-от-файла-до-экрана)
7. [styles.css: как оформляется книга](#7-stylescss-как-оформляется-книга)
8. [Как собрать, установить и менять плагин](#8-как-собрать-установить-и-менять-плагин)
9. [Что нужно для публикации в каталоге Obsidian](#9-что-нужно-для-публикации-в-каталоге-obsidian)
---
## 1. Что делает плагин
FB2 Reader учит Obsidian открывать книги в формате FictionBook (файлы `.fb2` и `.fb2` внутри `.zip`). Возможности:
- **Читалка.** Файл `.fb2` открывается как красиво оформленная книга: обложка, название, авторы, аннотация, главы, стихи, цитаты, сноски, картинки, таблицы.
- **Оглавление.** В правой боковой панели появляется список глав; клик по главе прокручивает книгу к ней.
- **Запоминание позиции.** Плагин помнит, на каком абзаце вы остановились в каждой книге (до 300 книг), и при повторном открытии возвращает вас туда.
- **Настройки вида.** Тема (светлая/тёмная/сепия), шрифт, размер шрифта, межстрочный интервал, цвет текста.
## 2. Общая картина: как код становится плагином
Цепочка выглядит так:
```
src/main.ts ──(сборщик esbuild)──► main.js ──(читает Obsidian)──► работающий плагин
▲ ▲
исходник, который файл, который реально
пишет человек исполняется
```
Зачем эта цепочка нужна:
- **JavaScript (JS)** — язык, который умеют исполнять браузеры. Obsidian построен на технологии Electron (по сути, браузер Chrome в отдельном окне), поэтому плагины для него — это JavaScript.
- **TypeScript (TS)** — это JavaScript плюс **типы**: возможность указать «эта переменная — число», «эта функция возвращает строку». Obsidian не понимает TypeScript, поэтому перед запуском типы «стираются», и остаётся чистый JavaScript. Зато на этапе написания кода компилятор TypeScript проверяет, что вы нигде не перепутали число со строкой и не обратились к несуществующему полю. Для новичка это огромная подмога: множество ошибок ловится до запуска.
- **esbuild** — сборщик. Он делает две вещи: стирает типы из `src/main.ts` и «вклеивает» в результат код используемых библиотек (у нас это fflate для zip-архивов). На выходе — один самодостаточный файл `main.js`.
- **Node.js и npm** — инструменты для запуска всего этого на вашем компьютере. Node.js исполняет JavaScript вне браузера (нужен сборщику), npm — менеджер пакетов: скачивает библиотеки, перечисленные в `package.json`, в папку `node_modules`.
Важное следствие: **`main.js` и `node_modules` не хранятся в git** — это «продукты производства», которые всегда можно получить заново командами `npm install` и `npm run build`. Именно поэтому их нет в этой ветке.
## 3. Файлы репозитория: кто за что отвечает
| Файл | Роль |
|---|---|
| `src/main.ts` | **Весь код плагина.** Единственный исходник. |
| `styles.css` | Оформление: темы, отступы, шрифты, вид оглавления. |
| `manifest.json` | «Паспорт» плагина для Obsidian: id, имя, версия, минимальная версия Obsidian. Без него Obsidian плагин не увидит. |
| `package.json` | Описание проекта для npm: список зависимостей и команды сборки (`npm run build`, `npm run dev`). |
| `package-lock.json` | Автогенерируемый файл: точные версии всех скачанных библиотек, чтобы сборка везде была одинаковой. Руками не редактируется. |
| `tsconfig.json` | Настройки проверки типов TypeScript (насколько строго проверять, какой диалект JS считать целевым). |
| `.gitignore` | Список того, что git не должен отслеживать: `node_modules/`, `main.js`. |
| `GUIDE.md` | Этот документ. |
## 4. Минимум JavaScript и TypeScript для чтения кода
Этот раздел — «словарик», к которому можно возвращаться при чтении `main.ts`.
### Переменные и константы
```ts
const name = "Война и мир"; // константа: перезаписать нельзя
let count = 0; // переменная: можно менять
count = count + 1;
```
В коде плагина почти всё объявлено через `const` — это хороший стиль: сразу видно, что значение не поменяется.
### Типы (это уже TypeScript)
```ts
const size: number = 17; // явно указан тип «число»
const title: string = "FB2"; // «строка»
const ready: boolean = true; // «да/нет»
```
Часто тип не пишут — TypeScript сам его выводит: в `const size = 17` и так понятно, что это число. Тип указывают там, где вывести его неоткуда (параметры функций) или где хочется дополнительной страховки.
Ещё встречаются:
```ts
string[] // массив строк: ["a", "b", "c"]
Record<string, number> // объект-словарь: { "иван": 3, "мария": 5 }
string | null // «строка ИЛИ null (пусто)»
"light" | "dark" | "sepia" // только одна из этих трёх строк и ничего больше
```
### Объекты и интерфейсы
Объект — набор именованных значений:
```ts
const settings = { fontSize: 17, theme: "dark" };
settings.fontSize // → 17 (доступ через точку)
```
`interface` описывает, какой формы должен быть объект. Это чисто проверочная конструкция — в готовый `main.js` она не попадает:
```ts
interface Fb2Settings {
fontSize: number;
theme: string;
}
```
Теперь, если где-то написать `settings.fontSze` (опечатка), TypeScript сразу подчеркнёт ошибку.
### Функции
Функция — именованный кусок кода, который принимает входные значения (параметры) и возвращает результат:
```ts
function double(n: number): number { // принимает число, возвращает число
return n * 2;
}
double(21) // → 42
```
Есть и краткая запись — **стрелочная функция**. Она часто передаётся другой функции как «что делать с каждым элементом»:
```ts
(n) => n * 2 // то же, что double, но без имени
[1, 2, 3].map((n) => n * 2) // → [2, 4, 6]: применить к каждому элементу
[1, 2, 3].filter((n) => n > 1) // → [2, 3]: оставить подходящие
```
В `main.ts` стрелочные функции повсюду: «когда произойдёт клик — выполни вот это», «для каждого автора — склей имя и фамилию».
### Классы
Класс — чертёж объекта: его **поля** (данные) и **методы** (действия). По одному чертежу можно создать много объектов.
```ts
class Book {
title: string; // поле
constructor(title: string) { // вызывается при создании объекта
this.title = title; // this — «этот самый объект»
}
describe(): string { // метод
return "Книга: " + this.title;
}
}
const b = new Book("Муму"); // создание объекта по чертежу
b.describe() // → "Книга: Муму"
```
Ключевое слово `extends`**наследование**: «возьми готовый класс и дострой его».
```ts
class AudioBook extends Book { ... }
```
Весь плагин построен на наследовании: Obsidian даёт заготовки (`Plugin`, `FileView`, `ItemView`, `PluginSettingTab`), а мы наследуемся от них и заполняем нужные методы. `super` внутри метода означает «вызови эту же логику у родителя». Пометка `private` у поля значит «доступно только внутри класса» — снаружи трогать нельзя.
### async / await: ожидание медленных операций
Чтение файла с диска — «медленная» операция. Если бы код просто ждал её, окно Obsidian замирало бы. Поэтому такие операции возвращают **обещание** (`Promise`) — «результат будет позже». Слово `await` означает «дождись результата, но не замораживай программу», а функция, внутри которой есть `await`, помечается словом `async`:
```ts
async function readBook(file: TFile) {
const bytes = await this.app.vault.readBinary(file); // ждём чтения с диска
// сюда придём, когда bytes уже получены
}
```
### Полезные мелкие операторы
```ts
a ?? b // «a, а если a — null/undefined, то b»
obj?.field // «возьми field, а если obj пустой — верни undefined без ошибки»
a || b // «a, а если a пустое/ложное — b»
condition ? x : y // «если condition — то x, иначе y»
`размер: ${n}px` // шаблонная строка: подставляет значение n внутрь текста
```
### DOM: как код «рисует» на экране
Страница в браузере — это дерево элементов (**DOM**): абзацы, заголовки, картинки вложены друг в друга. Код может создавать элементы, менять их и удалять — именно так плагин «рисует» книгу. Obsidian добавляет к стандартным средствам удобные сокращения:
```ts
parent.createEl("p", { text: "Привет", cls: "fb2-p" })
// создать внутри parent абзац <p class="fb2-p">Привет</p>
parent.createDiv({ cls: "fb2-book" }) // создать <div class="fb2-book">
container.empty() // удалить всё содержимое
el.addClass("fb2-reader") // добавить CSS-класс
```
CSS-класс (`cls`) — это «ярлык», по которому файл `styles.css` находит элемент и оформляет его.
## 5. Как Obsidian запускает плагин
1. Obsidian сканирует папку `.obsidian/plugins/` в хранилище. В папке плагина он ожидает три файла: `manifest.json`, `main.js`, `styles.css`.
2. Прочитав `manifest.json`, Obsidian узнаёт id и версию плагина.
3. При включении плагина Obsidian загружает `main.js`, находит в нём класс, помеченный `export default` (у нас это `Fb2ReaderPlugin`), создаёт его и вызывает метод **`onload()`**.
4. В `onload()` плагин «представляется»: регистрирует свои виды, расширения файлов, команды, вкладку настроек. Дальше Obsidian сам вызывает нужные методы в нужные моменты — это называется «инверсия управления»: не мы командуем программой, а программа зовёт нас.
5. При выключении плагина вызывается **`onunload()`** — там нужно прибрать за собой.
Ключевые понятия Obsidian, встречающиеся в коде:
- **Vault (хранилище)** — папка с вашими заметками. `this.app.vault.readBinary(file)` — прочитать файл из хранилища.
- **Workspace (рабочая область)** — расположение окон: вкладки, панели.
- **Leaf (лист)** — одна «ячейка» рабочей области (вкладка или панель), в которую вставляется вид.
- **View (вид)** — содержимое листа. Наша читалка и наше оглавление — это виды.
## 6. Разбор main.ts: путь книги от файла до экрана
Файл состоит из восьми разделов (они отмечены комментариями-линейками). Здесь — логика каждого; построчные пояснения смотрите в комментариях самого файла.
### 6.1. Импорты
Вверху файла подключаются инструменты из пакета `obsidian` (классы-заготовки и функция `debounce`) и функция `unzipSync` из библиотеки fflate. При сборке esbuild вклеивает код fflate внутрь `main.js`, а пакет `obsidian` не вклеивает (флаг `--external:obsidian` в команде сборки) — его предоставляет сам Obsidian во время работы.
### 6.2. Типы и настройки по умолчанию
Интерфейсы `TocItem`, `ReadingPosition`, `Fb2Settings`, `Fb2Data` описывают форму данных плагина. `DEFAULT_SETTINGS` — настройки при первом запуске. `TEXT_COLORS` — варианты для выпадающего списка «цвет текста».
### 6.3. Таблицы тегов — сердце «перевода» FB2 в HTML
FB2 — это XML-формат со своими тегами. Например, глава книги выглядит так:
```xml
<section>
<title><p>Глава первая</p></title>
<p>Все счастливые семьи похожи друг на друга...</p>
</section>
```
Браузер таких тегов не знает, поэтому каждый надо превратить в HTML-аналог. Большинство превращений тривиальны, и они описаны тремя таблицами:
- `BLOCK_CONTAINERS`: `section`, `poem`, `cite`... → обёртка (`div` или `blockquote`) с CSS-классом; содержимое обрабатывается дальше.
- `BLOCK_PARAGRAPHS`: `p`, `subtitle`, `v`, `text-author` → абзац `<p>` с CSS-классом.
- `INLINE_TAGS`: `emphasis``<em>` (курсив), `strong``<strong>` (жирный) и т.п.
Хотите узнать, как отображается тег — ищите его в этих таблицах. Хотите поддержать новый тег — чаще всего достаточно добавить одну строку в таблицу и стиль в `styles.css`.
### 6.4. Вспомогательные функции
- `detectEncoding` / `decodeFb2` — превращают байты файла в текст. Старые FB2-книги часто в кодировке windows-1251, поэтому кодировку приходится угадывать: сначала по служебным байтам BOM, потом по объявлению `encoding="..."` в начале XML.
- `extractFb2FromZip` — достаёт `.fb2` из zip-архива с помощью fflate.
- `getSystemFonts` — запрашивает у системы список установленных шрифтов (для настроек). Результат кэшируется; если возможность недоступна — возвращается пустой список, и настройки покажут текстовое поле вместо выпадающего списка.
- `getHref` — достаёт адрес ссылки, пробуя все варианты записи атрибута (`xlink:href`, `l:href`, `href`), которые встречаются в реальных книгах.
- `copyId` — переносит `id` тега FB2 на HTML-элемент, чтобы работали внутренние ссылки (сноски).
### 6.5. Класс Fb2View — читалка
Наследуется от `FileView` (вид, привязанный к файлу). Когда пользователь открывает `.fb2`, Obsidian создаёт `Fb2View` и вызывает `onLoadFile`. Внутри — вся цепочка:
```
байты файла → (unzip при необходимости) → текст → XML-дерево → HTML-элементы
```
1. **`onLoadFile`** читает файл, распаковывает zip, декодирует текст и разбирает его встроенным `DOMParser` в дерево объектов. Если XML битый — показывает сообщение об ошибке.
2. **`collectBinaries`** собирает картинки. В FB2 они лежат в тегах `<binary>` как текст base64; из них делается словарь «id → data-URL», и `<img>` показывает картинку прямо из этой строки, без внешних файлов.
3. **`renderBook`** рисует титульную страницу (`renderTitleInfo`: обложка, название, авторы, аннотация), затем каждое `<body>` книги. Второе `<body name="notes">` — это сноски; они оформляются отдельно и не попадают в оглавление. В конце вешается один обработчик кликов на всю книгу: клик по внутренней ссылке находит элемент-цель по `data-fb2-id` и плавно прокручивает к нему.
4. **`renderBlock`** — рекурсивный «переводчик» блочных тегов: сначала смотрит в таблицы (случаи 1 и 2), затем обрабатывает особые теги (`title`, `image`, `table`, `empty-line`), а незнакомые теги прозрачно «проходит насквозь», отрисовывая их содержимое. Рекурсия (метод вызывает сам себя для детей элемента) позволяет обойти дерево любой глубины. Параметр `depth` растёт при входе в каждую `<section>` — от него зависит уровень заголовка (`h2`, `h3`...). Попутно заголовки складываются в `tocItems` — этот список потом показывает панель оглавления.
5. **`renderInline`** — то же для строчного содержимого: текст добавляется как есть, простые теги берутся из таблицы `INLINE_TAGS`, ссылки `<a>` обрабатываются отдельно (сноски оборачиваются в `<sup>` — маленькая цифра сверху).
**Позиция чтения.** Хранить позицию в пикселях ненадёжно: смените шрифт — и всё съедет. Поэтому хранится **номер первого видимого блока текста**. При прокрутке (не чаще раза в 800 мс — за это отвечает `debounce`) вычисляется, какой блок сейчас первый на экране, и его номер сохраняется. При открытии книги `restoreReadingPosition` прокручивает к блоку с сохранённым номером.
### 6.6. Класс Fb2TocView — панель оглавления
Простой вид: хранит ссылку на читалку (`source`) и рисует её `tocItems` — по строке на главу, с отступом по глубине вложенности. Клик по строке показывает вкладку книги и прокручивает к главе. Сама панель ничего не вычисляет — всю работу сделала читалка при отрисовке книги.
### 6.7. Класс Fb2ReaderPlugin — «дирижёр»
Тот самый класс с `export default`, который создаёт Obsidian. В `onload()`:
- загружает сохранённые данные (`loadData()` читает `data.json` в папке плагина) и накладывает их на настройки по умолчанию;
- регистрирует оба вида и говорит, что расширения `fb2` и `zip` открываются в читалке (`registerExtensions`);
- добавляет вкладку настроек, кнопку на боковой панели (открывает настройки) и команду «Open table of contents» для палитры команд;
- подписывается на смену активной вкладки, чтобы панель оглавления всегда показывала содержание активной книги.
Также этот класс — хранитель данных: `getPosition`/`setPosition` для позиций чтения (с ограничением в 300 книг — `prunePositions`), `applySettings` для применения настроек. Сохранение на диск отложенное (`debounce`, раз в 2 секунды), чтобы не писать файл слишком часто.
Метод `applySettings` — важное место: он записывает значения настроек в **CSS-переменные** на элементе `<body>` (например, `--fb2-font-size: 17px`). Код не занимается оформлением сам — он лишь «публикует» значения, а `styles.css` их читает. Об этом — следующий раздел.
### 6.8. Класс Fb2SettingTab — вкладка настроек
Obsidian вызывает `display()` при каждом открытии настроек. Каждая настройка создаётся классом `Setting`: имя, описание, поле ввода. Общий приём: обработчик `onChange` записывает новое значение в `plugin.fb2Settings` и вызывает `plugin.saveSettings()` — настройка применяется немедленно, без кнопки «Сохранить».
Две хитрости:
- **Гонка отрисовок.** Список шрифтов загружается не мгновенно. Если пользователь успел закрыть и снова открыть настройки, запускается вторая отрисовка, и первая, «отставшая», могла бы перерисовать всё поверх новой. Счётчик `renderToken` решает это: каждая отрисовка получает номер, и если к моменту готовности номер уже не последний — она тихо выходит.
- **Чужое значение в списке.** Если в настройках сохранён шрифт или цвет, которого нет в списке вариантов, он добавляется отдельным пунктом — иначе выпадающий список молча «слетел» бы на первый вариант.
## 7. styles.css: как оформляется книга
CSS — язык оформления: «у элементов с таким-то классом такой-то шрифт, отступ, цвет». Каждому созданному в коде элементу присвоен класс с префиксом `fb2-` (`fb2-p` — абзац, `fb2-title` — заголовок, `fb2-epigraph` — эпиграф...), и на каждый класс в `styles.css` есть правило.
Два механизма стоит понять:
**CSS-переменные** — мостик между настройками и оформлением:
```css
.fb2-book {
font-size: var(--fb2-font-size, 1.05em);
}
```
Читается так: «размер шрифта — значение переменной `--fb2-font-size`, а если она не задана — 1.05em». Код (`applySettings`) записывает переменную на `<body>`, стиль её подхватывает. Поменяли размер в настройках — книга мгновенно перерисовалась, без участия кода отрисовки.
**Классы тем.** Выбор темы добавляет на `<body>` класс `fb2-theme-dark` / `-light` / `-sepia`. В `styles.css` для каждого класса задан свой набор цветов:
```css
body.fb2-theme-sepia .fb2-reader {
background-color: #f4ecd8 !important;
color: #5b4636;
}
```
Читается: «если на body есть класс fb2-theme-sepia, то внутри читалки — бумажный фон и коричневый текст». Заодно переопределяются стандартные переменные Obsidian (`--text-normal`, `--link-color`...), чтобы и вложенные элементы подхватили цвета темы.
## 8. Как собрать, установить и менять плагин
Один раз после клонирования репозитория:
```bash
npm install # скачать зависимости в node_modules
```
Собрать плагин:
```bash
npm run build # проверка типов + сборка main.js
```
Команда состоит из двух шагов (см. `package.json`): `tsc -noEmit` проверяет типы и ничего не создаёт; `esbuild` собирает `src/main.ts` в `main.js`.
Установить в Obsidian вручную: скопировать `main.js`, `manifest.json`, `styles.css` в папку `<хранилище>/.obsidian/plugins/fb2-reader/` и включить плагин в настройках Obsidian.
Для разработки удобнее режим наблюдения:
```bash
npm run dev # пересобирает main.js при каждом сохранении main.ts
```
После пересборки нужно перезагрузить плагин в Obsidian (выключить-включить в настройках или командой «Reload app without saving»).
Типичный цикл изменения: правите `src/main.ts` → сборка → копирование в хранилище → перезагрузка плагина → проверка на реальной книге.
## 9. Что нужно для публикации в каталоге Obsidian
Когда код станет полностью понятен, для публикации потребуется:
1. **README.md на английском** — описание плагина, скриншоты, инструкция.
2. **LICENSE** — файл лицензии (в `package.json` уже указана MIT).
3. **Согласовать версии**: сейчас в `manifest.json` версия 0.7.1, а в `package.json` — 0.1.0; при публикации версии релизов должны совпадать с `manifest.json`.
4. **GitHub-релиз**: создать релиз с тегом, равным версии из `manifest.json`, и приложить к нему три файла: `main.js`, `manifest.json`, `styles.css` (именно поэтому `main.js` не хранится в репозитории — он прикладывается к релизу).
5. **Заявка в каталог**: форк репозитория [obsidianmd/obsidian-releases](https://github.com/obsidianmd/obsidian-releases), добавление своего плагина в `community-plugins.json`, pull request и прохождение ревью команды Obsidian (они проверяют код на соответствие [гайдлайнам](https://docs.obsidian.md/Plugins/Releasing/Plugin+guidelines)).

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 kvasonaft
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -1,23 +0,0 @@
import esbuild from "esbuild";
import process from "process";
const prod = process.argv[2] === "production";
const context = await esbuild.context({
entryPoints: ["src/main.ts"],
bundle: true,
external: ["obsidian", "electron", "@codemirror/*", "@lezer/*"],
format: "cjs",
target: "es2021",
logLevel: "info",
sourcemap: prod ? false : "inline",
treeShaking: true,
outfile: "main.js",
});
if (prod) {
await context.rebuild();
process.exit(0);
} else {
await context.watch();
}

View file

@ -4,8 +4,8 @@
"description": "Read FictionBook (.fb2) files directly in Obsidian.",
"main": "main.js",
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production"
"dev": "esbuild src/main.ts --bundle --external:obsidian --format=cjs --target=es2021 --outfile=main.js --sourcemap=inline --watch",
"build": "tsc -noEmit -skipLibCheck && esbuild src/main.ts --bundle --external:obsidian --format=cjs --target=es2021 --outfile=main.js"
},
"keywords": [
"obsidian",

File diff suppressed because it is too large Load diff