Добавлен BookfusionBackSync (начальная версия)

This commit is contained in:
Dmitriy Kazimirov 2026-04-26 14:00:29 +06:00
parent 7ff5c1a557
commit fc87f695fa
11 changed files with 595 additions and 12 deletions

View file

@ -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()

View file

@ -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(
'<b>BookFusion Back Sync</b><br>'
'Reads last-read dates from the BookFusion private API and writes them<br>'
'to the selected Calibre custom column for books matched by their<br>'
'<tt>bookfusion</tt> 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(
'<small>Only <i>Date</i>-type custom columns are listed.<br>'
'Books are matched via the <tt>bookfusion</tt> identifier '
'(set by the BookFusion Calibre plugin).</small>'
)
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()

View file

@ -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)

View file

@ -0,0 +1 @@
bookfusionbacksync

View file

@ -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) — вся логика синка |

View file

@ -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

28
BookfusionBackSync/ui.py Normal file
View file

@ -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

View file

@ -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

View file

@ -1,3 +1,4 @@
# BookfusionPluginsResearch
Исследование доступных плагинов Bookfusion
Исследование доступных плагинов Bookfusion.
Попытка сделать плагин к Calibre для синхронизации last-read date "назад"

View file

@ -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://<storage-host>/<bucket> 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="<param2>"
AKIAIOSFODNN7EXAMPLE
<value2>
------Boundary
Content-Disposition: form-data; name="policy"
Content-Disposition: form-data; name="<param3>"
eyJleHBpcmF0aW9uIjoiMjAyNi0wNC0yN1QxMjowMDowMFoifQ==
------Boundary
Content-Disposition: form-data; name="signature"
bWq2s1WEIj+Ydj0vQ685zfW47oA=
<value3>
------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--
```
> Конкретные имена и значения полей (`<param2>`, `<param3>`, ...) определяются объектом `params` из ответа `POST /uploads/init`. В исходниках плагина они не зафиксированы — передаются итерацией по всем ключам `params`. Гарантированно присутствует только `key`. # UNVERIFIED
| Поле | Тип | Описание |
|------|-----|----------|
| *(все ключи из `params`)* | `string` | Поля из объекта `params` ответа `/uploads/init` |

View file

@ -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 не производится.