diff --git a/BookfusionBackSync/__init__.py b/BookfusionBackSync/__init__.py
new file mode 100644
index 0000000..8a478c2
--- /dev/null
+++ b/BookfusionBackSync/__init__.py
@@ -0,0 +1,27 @@
+__license__ = 'GPL v3'
+
+from calibre.customize import InterfaceActionBase
+
+
+class BookFusionBackSyncPlugin(InterfaceActionBase):
+ name = 'BookFusion Back Sync'
+ description = 'Sync last-read dates from BookFusion back into Calibre custom columns.'
+ supported_platforms = ['windows', 'osx', 'linux']
+ author = 'BookFusion'
+ version = (0, 1, 0)
+ minimum_calibre_version = (6, 2, 1)
+
+ actual_plugin = 'calibre_plugins.bookfusionbacksync.ui:InterfacePlugin'
+
+ def is_customizable(self):
+ return True
+
+ def config_widget(self):
+ from calibre_plugins.bookfusionbacksync.config import ConfigWidget
+ return ConfigWidget()
+
+ def save_settings(self, config_widget):
+ config_widget.save_settings()
+ ac = self.actual_plugin_
+ if ac is not None:
+ ac.apply_settings()
diff --git a/BookfusionBackSync/config.py b/BookfusionBackSync/config.py
new file mode 100644
index 0000000..c8e5b90
--- /dev/null
+++ b/BookfusionBackSync/config.py
@@ -0,0 +1,71 @@
+__license__ = 'GPL v3'
+
+from PyQt5.Qt import (
+ QWidget, QVBoxLayout, QFormLayout, QLabel,
+ QLineEdit, QComboBox
+)
+from calibre.utils.config import JSONConfig
+from calibre.gui2 import get_current_db
+
+prefs = JSONConfig('plugins/bookfusionbacksync')
+
+prefs.defaults['email'] = ''
+prefs.defaults['password'] = ''
+prefs.defaults['device'] = '' # generated on first sync, stored forever
+prefs.defaults['last_read_column'] = '#dateread'
+
+
+class ConfigWidget(QWidget):
+ def __init__(self):
+ QWidget.__init__(self)
+
+ layout = QVBoxLayout()
+ layout.setContentsMargins(0, 0, 0, 0)
+ self.setLayout(layout)
+
+ help_label = QLabel(
+ 'BookFusion Back Sync
'
+ 'Reads last-read dates from the BookFusion private API and writes them
'
+ 'to the selected Calibre custom column for books matched by their
'
+ 'bookfusion identifier.'
+ )
+ help_label.setWordWrap(True)
+ layout.addWidget(help_label)
+
+ form = QFormLayout()
+ form.setFieldGrowthPolicy(QFormLayout.ExpandingFieldsGrow)
+ layout.addLayout(form)
+
+ self.email = QLineEdit(self)
+ self.email.setPlaceholderText('your@email.com')
+ self.email.setText(prefs['email'])
+ form.addRow('BookFusion Email:', self.email)
+
+ self.password = QLineEdit(self)
+ self.password.setEchoMode(QLineEdit.Password)
+ self.password.setPlaceholderText('password')
+ self.password.setText(prefs['password'])
+ form.addRow('Password:', self.password)
+
+ self.last_read_column = QComboBox(self)
+ self.last_read_column.addItem('')
+ for key, meta in get_current_db().new_api.field_metadata.custom_iteritems():
+ if meta['datatype'] == 'datetime':
+ self.last_read_column.addItem(key)
+ self.last_read_column.setCurrentText(prefs['last_read_column'])
+ form.addRow('Last Read Column:', self.last_read_column)
+
+ note = QLabel(
+ 'Only Date-type custom columns are listed.
'
+ 'Books are matched via the bookfusion identifier '
+ '(set by the BookFusion Calibre plugin).'
+ )
+ note.setWordWrap(True)
+ layout.addWidget(note)
+
+ layout.addStretch()
+
+ def save_settings(self):
+ prefs['email'] = self.email.text().strip()
+ prefs['password'] = self.password.text()
+ prefs['last_read_column'] = self.last_read_column.currentText()
diff --git a/BookfusionBackSync/main.py b/BookfusionBackSync/main.py
new file mode 100644
index 0000000..762b593
--- /dev/null
+++ b/BookfusionBackSync/main.py
@@ -0,0 +1,99 @@
+__license__ = 'GPL v3'
+
+from PyQt5.Qt import (
+ QDialog, QVBoxLayout, QHBoxLayout,
+ QPushButton, QLabel, QListWidget, QProgressBar
+)
+
+from calibre_plugins.bookfusionbacksync.config import prefs
+from calibre_plugins.bookfusionbacksync.sync_worker import SyncWorker
+
+
+class MainDialog(QDialog):
+ def __init__(self, gui, do_user_config):
+ QDialog.__init__(self, gui)
+ self.gui = gui
+ self.do_user_config = do_user_config
+ self.worker = None
+
+ self.setWindowTitle('BookFusion Back Sync')
+ self.resize(560, 420)
+
+ layout = QVBoxLayout()
+ self.setLayout(layout)
+
+ self.status_label = QLabel('Ready. Press "Sync Now" to start.')
+ layout.addWidget(self.status_label)
+
+ self.progress_bar = QProgressBar()
+ self.progress_bar.setRange(0, 100)
+ self.progress_bar.setValue(0)
+ layout.addWidget(self.progress_bar)
+
+ self.log_list = QListWidget()
+ layout.addWidget(self.log_list)
+
+ btn_layout = QHBoxLayout()
+ layout.addLayout(btn_layout)
+
+ self.settings_btn = QPushButton('Settings')
+ self.settings_btn.clicked.connect(self._open_settings)
+ btn_layout.addWidget(self.settings_btn)
+
+ btn_layout.addStretch()
+
+ self.sync_btn = QPushButton('Sync Now')
+ self.sync_btn.setDefault(True)
+ self.sync_btn.clicked.connect(self._start_sync)
+ btn_layout.addWidget(self.sync_btn)
+
+ self.close_btn = QPushButton('Close')
+ self.close_btn.clicked.connect(self.reject)
+ btn_layout.addWidget(self.close_btn)
+
+ def _open_settings(self):
+ self.do_user_config(parent=self)
+
+ def _start_sync(self):
+ if not prefs['email'] or not prefs['password']:
+ self.status_label.setText('Email and password are required — open Settings.')
+ return
+ if not prefs['last_read_column']:
+ self.status_label.setText('Last Read Column is required — open Settings.')
+ return
+
+ self.sync_btn.setEnabled(False)
+ self.settings_btn.setEnabled(False)
+ self.log_list.clear()
+ self.status_label.setText('Starting…')
+ self.progress_bar.setValue(0)
+
+ self.worker = SyncWorker(self.gui.current_db.new_api)
+ self.worker.log_message.connect(self._on_log)
+ self.worker.progress.connect(self._on_progress)
+ self.worker.status.connect(self.status_label.setText)
+ self.worker.finished.connect(self._on_finished)
+ self.worker.start()
+
+ def _on_log(self, msg):
+ self.log_list.addItem(msg)
+ self.log_list.scrollToBottom()
+
+ def _on_progress(self, val, total):
+ if total > 0:
+ self.progress_bar.setValue(int(val * 100 / total))
+
+ def _on_finished(self, updated, skipped):
+ self.sync_btn.setEnabled(True)
+ self.settings_btn.setEnabled(True)
+ self.status_label.setText(
+ f'Done — {updated} updated, {skipped} skipped.'
+ )
+ self.progress_bar.setValue(100)
+ self.gui.library_view.model().refresh()
+
+ def reject(self):
+ if self.worker and self.worker.isRunning():
+ self.worker.stop()
+ self.worker.wait(3000)
+ QDialog.reject(self)
diff --git a/BookfusionBackSync/plugin-import-name-bookfusionbacksync.txt b/BookfusionBackSync/plugin-import-name-bookfusionbacksync.txt
new file mode 100644
index 0000000..906e360
--- /dev/null
+++ b/BookfusionBackSync/plugin-import-name-bookfusionbacksync.txt
@@ -0,0 +1 @@
+bookfusionbacksync
diff --git a/BookfusionBackSync/readme.md b/BookfusionBackSync/readme.md
new file mode 100644
index 0000000..cea6eba
--- /dev/null
+++ b/BookfusionBackSync/readme.md
@@ -0,0 +1,128 @@
+# BookFusion Back Sync — Calibre Plugin
+
+Calibre-плагин для обратной синхронизации: читает даты последнего чтения из BookFusion
+и записывает их в выбранную custom-колонку Calibre.
+
+Работает параллельно со штатным плагином BookFusion (они не конфликтуют).
+
+---
+
+## Что делает
+
+1. Авторизуется в BookFusion через email и пароль (Private API).
+2. Загружает список всех книг из библиотеки BookFusion.
+3. Сопоставляет книги с Calibre по идентификатору `bookfusion` (который штатный плагин
+ прописывает в поле Identifiers при загрузке книги).
+4. Для каждой совпавшей книги записывает дату последнего чтения в выбранную
+ custom-колонку типа Date.
+
+Источник даты — в порядке приоритета:
+- `last_read_at` из BookFusion API (если не пустое)
+- `reading_position.updated_at` — дата последнего обновления позиции чтения (используется
+ чаще всего, потому что `last_read_at` заполняется только в мобильном приложении)
+
+Лог в диалоге показывает для каждой книги: какая дата записана и из какого поля взята.
+
+---
+
+## Требования
+
+- Calibre 6.2.1 или новее
+- Штатный плагин BookFusion уже установлен и хотя бы один раз синхронизирован
+ (чтобы в книгах был прописан идентификатор `bookfusion`)
+- Аккаунт BookFusion с email и паролем (не OAuth через Facebook/Twitter)
+- Кастомная колонка типа Date в Calibre (например `#dateread`)
+
+---
+
+## Установка
+
+1. Скачать или собрать ZIP-архив из папки `BookfusionBackSync/`:
+ - Выделить все файлы внутри папки (`__init__.py`, `config.py`, `ui.py`, `main.py`,
+ `sync_worker.py`, `plugin-import-name-bookfusionbacksync.txt`)
+ - Упаковать в ZIP (не саму папку, а её содержимое)
+
+2. В Calibre: **Preferences → Plugins → Load plugin from file** → выбрать ZIP.
+
+3. Перезапустить Calibre.
+
+4. Кнопка **BookFusion Back Sync** появится на панели инструментов.
+
+---
+
+## Настройка
+
+Открыть настройки: кнопка **Settings** в диалоге плагина, или
+**Preferences → Plugins → BookFusion Back Sync → Customize**.
+
+| Поле | Описание |
+|------|----------|
+| BookFusion Email | Email аккаунта BookFusion |
+| Password | Пароль аккаунта |
+| Last Read Column | Custom-колонка Calibre типа Date, куда писать дату |
+
+Пароль хранится в открытом виде в файле конфигурации Calibre
+(`plugins/bookfusionbacksync.json`) — аналогично тому, как штатный плагин
+хранит API-ключ.
+
+Device ID генерируется автоматически при первом синке и сохраняется навсегда.
+
+---
+
+## Использование
+
+1. Нажать кнопку **BookFusion Back Sync** на панели инструментов.
+2. Нажать **Sync Now**.
+3. Наблюдать прогресс в лог-окне. После завершения Calibre автоматически
+ обновит список книг.
+
+---
+
+## Как устроен
+
+### API
+
+Плагин использует Private API BookFusion (`https://bookfusion.com/api`) — тот же,
+что использует мобильное приложение. Официальной документации нет; API восстановлен
+методом реверс-инжиниринга из исходников Android-клиента (2014) и fusionfixer (2018).
+
+Авторизация: `POST /v1/auth.json` с полями `device`, `login`, `password` → возвращает `token`.
+Токен используется как query-параметр (`?device=...&token=...`) во всех последующих GET-запросах.
+
+Библиотека: `GET /v3/library/books.json?page=N&per_page=100` — постраничная выгрузка.
+Каждая книга (`BookV3`) содержит:
+- `id` — числовой ID книги в библиотеке пользователя; совпадает с тем, что штатный
+ плагин сохраняет в `identifiers['bookfusion']`
+- `last_read_at` — дата последнего чтения (часто `null`)
+- `reading_position.updated_at` — дата последнего обновления позиции чтения
+
+HTTP-запросы делаются через стандартную библиотеку Python (`urllib.request`) —
+без внешних зависимостей.
+
+### Сопоставление книг
+
+Штатный BookFusion плагин при загрузке книги сохраняет её ID в поле Calibre:
+```
+Identifiers → bookfusion: 66816
+```
+
+Плагин Back Sync читает это значение и сравнивает с `BookV3.id` из API.
+Совпадение — запись даты в выбранную колонку.
+
+### Запись в Calibre
+
+Все обновления записываются одним вызовом Calibre new_api:
+```python
+db.set_field('#dateread', {calibre_book_id: datetime_object, ...})
+```
+
+### Структура файлов
+
+| Файл | Назначение |
+|------|-----------|
+| `__init__.py` | Регистрация плагина, метаданные, точка входа |
+| `plugin-import-name-bookfusionbacksync.txt` | Import-name для Calibre plugin loader |
+| `config.py` | JSONConfig с настройками; ConfigWidget (форма настроек) |
+| `ui.py` | InterfacePlugin — пункт меню и кнопка в Calibre |
+| `main.py` | MainDialog — диалог с прогресс-баром и логом |
+| `sync_worker.py` | SyncWorker (QThread) — вся логика синка |
diff --git a/BookfusionBackSync/sync_worker.py b/BookfusionBackSync/sync_worker.py
new file mode 100644
index 0000000..cd7421a
--- /dev/null
+++ b/BookfusionBackSync/sync_worker.py
@@ -0,0 +1,206 @@
+__license__ = 'GPL v3'
+
+import json
+import uuid
+import urllib.request
+import urllib.parse
+import urllib.error
+from datetime import datetime
+
+from PyQt5.Qt import QThread, pyqtSignal
+
+from calibre_plugins.bookfusionbacksync.config import prefs
+
+_API_BASE = 'https://bookfusion.com/api'
+
+_GET_HEADERS = {
+ 'Accept': 'application/json, application/*+json',
+ 'User-Agent': 'BookFusion/2.22.0 (Android 12; Xiaomi 2201117TG; arm64-v8a)',
+}
+_POST_HEADERS = {
+ **_GET_HEADERS,
+ 'Content-Type': 'application/json',
+}
+
+
+class SyncWorker(QThread):
+ log_message = pyqtSignal(str)
+ progress = pyqtSignal(int, int) # current, total
+ status = pyqtSignal(str)
+ finished = pyqtSignal(int, int) # updated, skipped
+
+ def __init__(self, db):
+ QThread.__init__(self)
+ self.db = db # calibre new_api (Cache)
+ self._stop = False
+
+ def stop(self):
+ self._stop = True
+
+ def run(self):
+ try:
+ self._sync()
+ except Exception as exc:
+ self.status.emit(f'Error: {exc}')
+ self.log_message.emit(f'Fatal error: {exc}')
+ self.finished.emit(0, 0)
+
+ # ── Main sync flow ───────────────────────────────────────────────────────
+
+ def _sync(self):
+ # Ensure a stable device ID exists
+ device = prefs['device']
+ if not device:
+ device = uuid.uuid4().hex[:16]
+ prefs['device'] = device
+
+ email = prefs['email']
+ password = prefs['password']
+ column = prefs['last_read_column']
+
+ # 1. Authenticate
+ self.status.emit('Authenticating…')
+ try:
+ token = self._login(device, email, password)
+ except urllib.error.HTTPError as exc:
+ raise RuntimeError(
+ f'Login failed (HTTP {exc.code}) — check email and password in Settings'
+ )
+ self.log_message.emit(f'Authenticated as {email}')
+ if self._stop:
+ return self.finished.emit(0, 0)
+
+ # 2. Fetch all BookFusion books (paginated)
+ self.status.emit('Fetching BookFusion library…')
+ bf_books = self._fetch_all_books(device, token)
+ self.log_message.emit(f'Fetched {len(bf_books)} books from BookFusion')
+ if self._stop:
+ return self.finished.emit(0, 0)
+
+ # Build lookup: str(BookV3.id) → (date_str, source_field_name)
+ # Primary source: last_read_at
+ # Fallback: reading_position.updated_at (more commonly populated)
+ bf_map = {}
+ for book in bf_books:
+ bf_id = str(book.get('id', ''))
+ if not bf_id:
+ continue
+ date_str = book.get('last_read_at')
+ source = 'last_read_at'
+ if not date_str:
+ rp = book.get('reading_position') or {}
+ date_str = rp.get('updated_at')
+ source = 'reading_position.updated_at'
+ if date_str:
+ bf_map[bf_id] = (date_str, source)
+
+ self.log_message.emit(
+ f'{len(bf_map)} of {len(bf_books)} books have a read date in BookFusion'
+ )
+
+ # 3. Collect Calibre books that carry a bookfusion identifier
+ self.status.emit('Scanning Calibre library…')
+ calibre_books = self._calibre_books_with_bf_id()
+ self.log_message.emit(
+ f'{len(calibre_books)} Calibre books have a BookFusion identifier'
+ )
+
+ total = len(calibre_books)
+ updates = {}
+ skipped = 0
+
+ for i, (cal_id, bf_id, title) in enumerate(calibre_books):
+ if self._stop:
+ break
+ self.progress.emit(i, total)
+
+ entry = bf_map.get(bf_id)
+ if entry is None:
+ skipped += 1
+ continue
+
+ date_str, source = entry
+ dt = _parse_iso(date_str)
+ if dt is None:
+ skipped += 1
+ self.log_message.emit(f'SKIP {title} — unparseable date {date_str!r}')
+ continue
+
+ updates[cal_id] = dt
+ self.log_message.emit(
+ f'OK {title} → {dt.strftime("%Y-%m-%d")} (from {source})'
+ )
+
+ if self._stop:
+ self.log_message.emit('Sync cancelled.')
+ return self.finished.emit(len(updates), skipped)
+
+ # 4. Write all updates to Calibre in a single call
+ if updates:
+ self.status.emit(f'Writing {len(updates)} dates to Calibre…')
+ self.db.set_field(column, updates)
+
+ self.progress.emit(total, total)
+ self.finished.emit(len(updates), skipped)
+
+ # ── HTTP helpers ─────────────────────────────────────────────────────────
+
+ def _login(self, device, email, password):
+ body = json.dumps(
+ {'device': device, 'login': email, 'password': password}
+ ).encode('utf-8')
+ req = urllib.request.Request(
+ f'{_API_BASE}/v1/auth.json',
+ data=body,
+ headers=_POST_HEADERS,
+ method='POST',
+ )
+ with urllib.request.urlopen(req, timeout=15) as resp:
+ return json.loads(resp.read())['token']
+
+ def _fetch_all_books(self, device, token):
+ all_books = []
+ page = 1
+ while not self._stop:
+ params = urllib.parse.urlencode({
+ 'device': device,
+ 'token': token,
+ 'page': page,
+ 'per_page': 100,
+ 'sort': 'last_read_at-desc',
+ })
+ req = urllib.request.Request(
+ f'{_API_BASE}/v3/library/books.json?{params}',
+ headers=_GET_HEADERS,
+ )
+ with urllib.request.urlopen(req, timeout=15) as resp:
+ page_data = json.loads(resp.read())
+ if not page_data:
+ break
+ all_books.extend(page_data)
+ if len(page_data) < 100:
+ break
+ page += 1
+ return all_books
+
+ # ── Calibre helpers ──────────────────────────────────────────────────────
+
+ def _calibre_books_with_bf_id(self):
+ result = []
+ for cal_id in self.db.all_book_ids():
+ ids = self.db.field_for('identifiers', cal_id) or {}
+ bf_id = ids.get('bookfusion')
+ if bf_id:
+ title = self.db.field_for('title', cal_id) or f'Book #{cal_id}'
+ result.append((cal_id, str(bf_id), title))
+ return result
+
+
+# ── Utility ──────────────────────────────────────────────────────────────────
+
+def _parse_iso(s):
+ """Parse ISO 8601 string with trailing Z or +00:00 into a tz-aware datetime."""
+ try:
+ return datetime.fromisoformat(s.replace('Z', '+00:00'))
+ except (ValueError, AttributeError):
+ return None
diff --git a/BookfusionBackSync/ui.py b/BookfusionBackSync/ui.py
new file mode 100644
index 0000000..eba7cae
--- /dev/null
+++ b/BookfusionBackSync/ui.py
@@ -0,0 +1,28 @@
+__license__ = 'GPL v3'
+
+from calibre.gui2.actions import InterfaceAction
+from calibre_plugins.bookfusionbacksync.main import MainDialog
+
+
+class InterfacePlugin(InterfaceAction):
+ name = 'BookFusion Back Sync'
+
+ action_spec = (
+ 'BookFusion Back Sync', None,
+ 'Sync last-read dates from BookFusion to Calibre', None
+ )
+
+ def genesis(self):
+ try:
+ self.qaction.setIcon(get_icons('images/icon.png'))
+ except Exception:
+ pass
+ self.qaction.triggered.connect(self.show_dialog)
+
+ def show_dialog(self):
+ base_plugin_object = self.interface_action_base_plugin
+ do_user_config = base_plugin_object.do_user_config
+ MainDialog(self.gui, do_user_config).show()
+
+ def apply_settings(self):
+ pass
diff --git a/CLAUDE.md b/CLAUDE.md
index 67aed49..8bde02b 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -2,8 +2,9 @@
## Контекст проекта
-Задача: восстановить документацию по semi-public REST API сервиса BookFusion
+Задача#1: восстановить документацию по semi-public REST API сервиса BookFusion
и написать тесты которые проверяют что API работает.
+Задача#2: сделать BookfusionBackSync плагин для синхронизации "назад"
Официальной документации нет. Источники знаний — исходники plugin'ов
- Obsidian Plugin https://github.com/BookFusion/obsidian-plugin
diff --git a/README.md b/README.md
index 19802b4..9e4435a 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,4 @@
# BookfusionPluginsResearch
-Исследование доступных плагинов Bookfusion
\ No newline at end of file
+Исследование доступных плагинов Bookfusion.
+Попытка сделать плагин к Calibre для синхронизации last-read date "назад"
\ No newline at end of file
diff --git a/docs/api.md b/docs/api.md
index 6ef7078..dc9ac1b 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -325,7 +325,7 @@ Content-Type: `multipart/form-data`
Сначала все поля из объекта `params` ответа `/uploads/init`, последним — поле `file`:
```http
-POST https://s3.amazonaws.com/bookfusion-uploads HTTP/1.1
+POST https:/// HTTP/1.1
Content-Type: multipart/form-data; boundary=----Boundary
------Boundary
@@ -333,17 +333,13 @@ Content-Disposition: form-data; name="key"
uploads/abc123def456/the-great-gatsby.epub
------Boundary
-Content-Disposition: form-data; name="AWSAccessKeyId"
+Content-Disposition: form-data; name=""
-AKIAIOSFODNN7EXAMPLE
+
------Boundary
-Content-Disposition: form-data; name="policy"
+Content-Disposition: form-data; name=""
-eyJleHBpcmF0aW9uIjoiMjAyNi0wNC0yN1QxMjowMDowMFoifQ==
-------Boundary
-Content-Disposition: form-data; name="signature"
-
-bWq2s1WEIj+Ydj0vQ685zfW47oA=
+
------Boundary
Content-Disposition: form-data; name="file"; filename="the-great-gatsby.epub"
@@ -351,6 +347,8 @@ Content-Disposition: form-data; name="file"; filename="the-great-gatsby.epub"
------Boundary--
```
+> Конкретные имена и значения полей (``, ``, ...) определяются объектом `params` из ответа `POST /uploads/init`. В исходниках плагина они не зафиксированы — передаются итерацией по всем ключам `params`. Гарантированно присутствует только `key`. # UNVERIFIED
+
| Поле | Тип | Описание |
|------|-----|----------|
| *(все ключи из `params`)* | `string` | Поля из объекта `params` ответа `/uploads/init` |
diff --git a/docs/notes.md b/docs/notes.md
index 0f4578b..aead673 100644
--- a/docs/notes.md
+++ b/docs/notes.md
@@ -20,4 +20,27 @@
Calibre API (`PUT /uploads/{id}`, `POST /uploads/finalize`) принимает metadata: title, authors, bookshelves, cover, isbn, issued_on, language, series, summary, tags. Поля `last_read_date` и `reading_progress` отсутствуют.
-BookFusion не отдаёт данные о прогрессе чтения ни через Calibre API, ни через Obsidian API. Плагин построить невозможно — данных нет.
+BookFusion не отдаёт данные о прогрессе чтения ни через Calibre API, ни через Obsidian API. Через эти API плагин построить невозможно.
+
+**Обновление:** через Private API (`GET /v3/library/books.json`) данные есть. Плагин реализован — см. `BookfusionBackSync/`.
+
+---
+
+## BookfusionBackSync — плагин обратной синхронизации
+
+**Расположение:** `BookfusionBackSync/`
+
+**Как работает:**
+1. Авторизация через Private API: `POST /api/v1/auth.json` (email + password)
+2. Загрузка всех книг: `GET /api/v3/library/books.json` (постраничная, 100 за раз)
+3. Сопоставление по идентификатору: `identifiers['bookfusion']` в Calibre = `BookV3.id` из Private API
+4. Запись даты в выбранную custom column Calibre одним вызовом `db.set_field()`
+
+**Источник даты:** `BookV3.last_read_at` (если не null), иначе `BookV3.reading_position.updated_at`. В тестовых данных `last_read_at` всегда null, `reading_position.updated_at` имеет реальные значения.
+
+**Настройки (через Calibre Preferences → Plugins):**
+- Email и пароль от аккаунта BookFusion
+- Целевое поле Calibre (dropdown по datetime-колонкам, по умолчанию `#dateread`)
+- Device ID генерируется один раз при первом синке и сохраняется навсегда
+
+**Важно:** плагин читает только Books из Private API. Запись обратно в BookFusion не производится.