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