Добавлен BookfusionBackSync (начальная версия)
This commit is contained in:
parent
7ff5c1a557
commit
fc87f695fa
11 changed files with 595 additions and 12 deletions
27
BookfusionBackSync/__init__.py
Normal file
27
BookfusionBackSync/__init__.py
Normal 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()
|
||||
71
BookfusionBackSync/config.py
Normal file
71
BookfusionBackSync/config.py
Normal 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()
|
||||
99
BookfusionBackSync/main.py
Normal file
99
BookfusionBackSync/main.py
Normal 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)
|
||||
|
|
@ -0,0 +1 @@
|
|||
bookfusionbacksync
|
||||
128
BookfusionBackSync/readme.md
Normal file
128
BookfusionBackSync/readme.md
Normal 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) — вся логика синка |
|
||||
206
BookfusionBackSync/sync_worker.py
Normal file
206
BookfusionBackSync/sync_worker.py
Normal 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
28
BookfusionBackSync/ui.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
# BookfusionPluginsResearch
|
||||
|
||||
Исследование доступных плагинов Bookfusion
|
||||
Исследование доступных плагинов Bookfusion.
|
||||
Попытка сделать плагин к Calibre для синхронизации last-read date "назад"
|
||||
16
docs/api.md
16
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://<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` |
|
||||
|
|
|
|||
|
|
@ -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 не производится.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue