diff --git a/pocketbooksync/README.md b/pocketbooksync/README.md new file mode 100644 index 0000000..b3ba005 --- /dev/null +++ b/pocketbooksync/README.md @@ -0,0 +1,19 @@ +# PocketBook Sync (Nextcloud App) + +This app syncs PocketBook Cloud highlights/notes into Nextcloud markdown files: + +- One markdown file per book. +- File name format: `Book Title - Author.md`. +- Personal settings page for credentials, target folder, sync interval. +- Connection test + last sync status. +- Background job that checks each user's configured interval. + +## Notes on PocketBook API + +The app currently expects these endpoints: + +- `POST /api/v1/auth/login` with `{ "email": "...", "password": "..." }` and response `{ "token": "..." }` +- `GET /api/v1/library/books?includeAnnotations=true` with Bearer token and response `{ "books": [...] }` + +If your PocketBook Cloud account uses different endpoints/field names, +adapt `lib/Service/PocketBookClient.php` accordingly. diff --git a/pocketbooksync/appinfo/app.php b/pocketbooksync/appinfo/app.php new file mode 100644 index 0000000..547d051 --- /dev/null +++ b/pocketbooksync/appinfo/app.php @@ -0,0 +1,15 @@ + + + pocketbooksync + PocketBook Sync + Sync PocketBook Cloud highlights and notes into markdown files + Connects a user's PocketBook Cloud account and regularly syncs book highlights into one markdown file per book. + 0.1.0 + agpl + Codex + PocketBookSync + productivity + + OCA\PocketBookSync\Settings\Personal + + + OCA\PocketBookSync\BackgroundJob\SyncJob + + + + + diff --git a/pocketbooksync/appinfo/routes.php b/pocketbooksync/appinfo/routes.php new file mode 100644 index 0000000..1b81cec --- /dev/null +++ b/pocketbooksync/appinfo/routes.php @@ -0,0 +1,28 @@ + [ + [ + 'name' => 'Settings#get', + 'url' => '/settings', + 'verb' => 'GET', + ], + [ + 'name' => 'Settings#save', + 'url' => '/settings', + 'verb' => 'POST', + ], + [ + 'name' => 'Settings#testConnection', + 'url' => '/settings/test-connection', + 'verb' => 'POST', + ], + [ + 'name' => 'Settings#syncNow', + 'url' => '/settings/sync-now', + 'verb' => 'POST', + ], + ], +]; diff --git a/pocketbooksync/js/settings.js b/pocketbooksync/js/settings.js new file mode 100644 index 0000000..3b6056a --- /dev/null +++ b/pocketbooksync/js/settings.js @@ -0,0 +1,87 @@ +(function () { + const root = document.getElementById('pocketbooksync-settings'); + if (!root) { + return; + } + + const requestToken = document.querySelector('head').dataset.requesttoken; + const basePath = OC.generateUrl('/apps/pocketbooksync'); + + const fields = { + baseUrl: root.querySelector('#pbs-base-url'), + username: root.querySelector('#pbs-username'), + password: root.querySelector('#pbs-password'), + targetFolder: root.querySelector('#pbs-target-folder'), + syncIntervalMin: root.querySelector('#pbs-sync-interval'), + }; + const status = root.querySelector('#pbs-status'); + const lastSync = root.querySelector('#pbs-last-sync'); + + async function api(path, method, body) { + const response = await fetch(basePath + path, { + method, + headers: { + 'Content-Type': 'application/json', + requesttoken: requestToken, + }, + body: body ? JSON.stringify(body) : undefined, + }); + const payload = await response.json(); + if (!response.ok) { + throw new Error(payload.error || 'Request failed'); + } + return payload; + } + + function fillForm(data) { + fields.baseUrl.value = data.baseUrl || ''; + fields.username.value = data.username || ''; + fields.password.value = data.password || ''; + fields.targetFolder.value = data.targetFolder || '/PocketBook Highlights'; + fields.syncIntervalMin.value = data.syncIntervalMin || 60; + status.textContent = 'Status: ' + (data.lastStatus || 'Never synced'); + if (data.lastSync) { + lastSync.textContent = 'Last sync: ' + new Date(parseInt(data.lastSync, 10) * 1000).toLocaleString(); + } + } + + function collect() { + return { + baseUrl: fields.baseUrl.value, + username: fields.username.value, + password: fields.password.value, + targetFolder: fields.targetFolder.value, + syncIntervalMin: parseInt(fields.syncIntervalMin.value, 10) || 60, + }; + } + + root.querySelector('#pbs-save').addEventListener('click', async () => { + await api('/settings', 'POST', collect()); + status.textContent = 'Status: settings saved'; + }); + + root.querySelector('#pbs-test').addEventListener('click', async () => { + status.textContent = 'Status: testing connection...'; + try { + const result = await api('/settings/test-connection', 'POST'); + status.textContent = result.ok ? 'Status: connection OK' : 'Status: connection failed'; + } catch (e) { + status.textContent = 'Status: connection failed (' + e.message + ')'; + } + }); + + root.querySelector('#pbs-sync').addEventListener('click', async () => { + status.textContent = 'Status: syncing...'; + try { + const result = await api('/settings/sync-now', 'POST'); + status.textContent = `Status: synced ${result.result.files} files`; + lastSync.textContent = 'Last sync: ' + new Date().toLocaleString(); + } catch (e) { + status.textContent = 'Status: sync failed (' + e.message + ')'; + } + }); + + api('/settings', 'GET').then(fillForm).catch((e) => { + status.textContent = 'Status: failed to load settings (' + e.message + ')'; + }); +})(); diff --git a/pocketbooksync/lib/AppInfo/Application.php b/pocketbooksync/lib/AppInfo/Application.php new file mode 100644 index 0000000..547d051 --- /dev/null +++ b/pocketbooksync/lib/AppInfo/Application.php @@ -0,0 +1,15 @@ +setInterval(300); + } + + protected function run($argument): void { + foreach ($this->userManager->search('') as $user) { + $userId = $user->getUID(); + $settings = $this->configService->getSettings($userId); + if ($settings['username'] === '' || $settings['password'] === '') { + continue; + } + + if (!$this->configService->shouldSync($userId)) { + continue; + } + + try { + $this->syncService->syncUser($userId); + } catch (Throwable $e) { + $this->configService->setLastSync($userId, 'Error: ' . $e->getMessage()); + $this->logger->error('PocketBook background sync failed', [ + 'exception' => $e, + 'userId' => $userId, + ]); + } + } + } +} diff --git a/pocketbooksync/lib/Controller/SettingsController.php b/pocketbooksync/lib/Controller/SettingsController.php new file mode 100644 index 0000000..803a405 --- /dev/null +++ b/pocketbooksync/lib/Controller/SettingsController.php @@ -0,0 +1,58 @@ +userId = $userSession->getUser()?->getUID() ?? ''; + } + + public function get(): DataResponse { + return new DataResponse($this->configService->getSettings($this->userId)); + } + + /** @param array $payload */ + public function save(array $payload): DataResponse { + $this->configService->saveSettings($this->userId, $payload); + return new DataResponse(['status' => 'saved']); + } + + public function testConnection(): JSONResponse { + try { + $ok = $this->syncService->testConnection($this->userId); + return new JSONResponse(['ok' => $ok]); + } catch (Throwable $e) { + return new JSONResponse(['ok' => false, 'error' => $e->getMessage()], 500); + } + } + + public function syncNow(): JSONResponse { + try { + $result = $this->syncService->syncUser($this->userId); + return new JSONResponse(['ok' => true, 'result' => $result]); + } catch (Throwable $e) { + $this->configService->setLastSync($this->userId, 'Error: ' . $e->getMessage()); + return new JSONResponse(['ok' => false, 'error' => $e->getMessage()], 500); + } + } +} diff --git a/pocketbooksync/lib/Service/ConfigService.php b/pocketbooksync/lib/Service/ConfigService.php new file mode 100644 index 0000000..dbbb32f --- /dev/null +++ b/pocketbooksync/lib/Service/ConfigService.php @@ -0,0 +1,67 @@ + */ + public function getSettings(string $userId): array { + return [ + 'baseUrl' => $this->getUserValue($userId, self::KEY_BASE_URL, 'https://cloud.pocketbook.digital'), + 'username' => $this->getUserValue($userId, self::KEY_USERNAME, ''), + 'password' => $this->getUserValue($userId, self::KEY_PASSWORD, ''), + 'targetFolder' => $this->getUserValue($userId, self::KEY_TARGET_FOLDER, '/PocketBook Highlights'), + 'syncIntervalMin' => (int)$this->getUserValue($userId, self::KEY_SYNC_INTERVAL_MIN, '60'), + 'lastSync' => $this->getUserValue($userId, self::KEY_LAST_SYNC, ''), + 'lastStatus' => $this->getUserValue($userId, self::KEY_LAST_STATUS, 'Never synced'), + ]; + } + + /** @param array $payload */ + public function saveSettings(string $userId, array $payload): void { + $this->setUserValue($userId, self::KEY_BASE_URL, rtrim((string)($payload['baseUrl'] ?? ''), '/')); + $this->setUserValue($userId, self::KEY_USERNAME, (string)($payload['username'] ?? '')); + $this->setUserValue($userId, self::KEY_PASSWORD, (string)($payload['password'] ?? '')); + $this->setUserValue($userId, self::KEY_TARGET_FOLDER, (string)($payload['targetFolder'] ?? '/PocketBook Highlights')); + $this->setUserValue($userId, self::KEY_SYNC_INTERVAL_MIN, (string)max(5, (int)($payload['syncIntervalMin'] ?? 60))); + } + + public function setLastSync(string $userId, string $status): void { + $this->setUserValue($userId, self::KEY_LAST_SYNC, (string)time()); + $this->setUserValue($userId, self::KEY_LAST_STATUS, $status); + } + + public function shouldSync(string $userId): bool { + $settings = $this->getSettings($userId); + $interval = max(5, (int)$settings['syncIntervalMin']); + $lastSync = (int)$settings['lastSync']; + if ($lastSync === 0) { + return true; + } + + return (time() - $lastSync) >= ($interval * 60); + } + + private function getUserValue(string $userId, string $key, string $default): string { + return $this->config->getUserValue($userId, Application::APP_ID, $key, $default); + } + + private function setUserValue(string $userId, string $key, string $value): void { + $this->config->setUserValue($userId, Application::APP_ID, $key, $value); + } +} diff --git a/pocketbooksync/lib/Service/PocketBookClient.php b/pocketbooksync/lib/Service/PocketBookClient.php new file mode 100644 index 0000000..18834db --- /dev/null +++ b/pocketbooksync/lib/Service/PocketBookClient.php @@ -0,0 +1,60 @@ + $settings */ + public function testConnection(array $settings): bool { + $token = $this->authenticate($settings); + return $token !== ''; + } + + /** + * @param array $settings + * @return array> + */ + public function fetchBooksWithAnnotations(array $settings): array { + $token = $this->authenticate($settings); + $client = $this->clientService->newClient(); + $response = $client->get($settings['baseUrl'] . '/api/v1/library/books', [ + 'headers' => [ + 'Authorization' => 'Bearer ' . $token, + 'Accept' => 'application/json', + ], + 'query' => [ + 'includeAnnotations' => 'true', + ], + 'timeout' => 30, + ]); + + $payload = json_decode($response->getBody(), true, flags: JSON_THROW_ON_ERROR); + return $payload['books'] ?? []; + } + + /** @param array $settings */ + private function authenticate(array $settings): string { + $client = $this->clientService->newClient(); + $response = $client->post($settings['baseUrl'] . '/api/v1/auth/login', [ + 'headers' => [ + 'Accept' => 'application/json', + ], + 'json' => [ + 'email' => $settings['username'], + 'password' => $settings['password'], + ], + 'timeout' => 30, + ]); + + $payload = json_decode($response->getBody(), true, flags: JSON_THROW_ON_ERROR); + return (string)($payload['token'] ?? ''); + } +} diff --git a/pocketbooksync/lib/Service/SyncService.php b/pocketbooksync/lib/Service/SyncService.php new file mode 100644 index 0000000..b7343ff --- /dev/null +++ b/pocketbooksync/lib/Service/SyncService.php @@ -0,0 +1,100 @@ +configService->getSettings($userId); + return $this->client->testConnection($settings); + } + + /** @return array{books:int, files:int} */ + public function syncUser(string $userId): array { + $settings = $this->configService->getSettings($userId); + $books = $this->client->fetchBooksWithAnnotations($settings); + $userFolder = $this->rootFolder->getUserFolder($userId); + $targetFolderPath = trim((string)$settings['targetFolder']); + if ($targetFolderPath === '') { + $targetFolderPath = '/PocketBook Highlights'; + } + + try { + $targetFolder = $userFolder->get($targetFolderPath); + } catch (NotFoundException) { + $targetFolder = $userFolder->newFolder($targetFolderPath); + } + + $written = 0; + foreach ($books as $book) { + $title = trim((string)($book['title'] ?? 'Unknown Title')); + $author = trim((string)($book['author'] ?? 'Unknown Author')); + $annotations = $book['annotations'] ?? []; + $filename = $this->toSafeFilename($title . ' - ' . $author) . '.md'; + $contents = $this->renderBookMarkdown($title, $author, $annotations); + + if ($targetFolder->nodeExists($filename)) { + $file = $targetFolder->get($filename); + $file->putContent($contents); + } else { + $targetFolder->newFile($filename, $contents); + } + $written++; + } + + $this->configService->setLastSync($userId, sprintf('OK: synced %d books', $written)); + return ['books' => count($books), 'files' => $written]; + } + + /** @param array> $annotations */ + private function renderBookMarkdown(string $title, string $author, array $annotations): string { + $lines = [ + '# ' . $title, + '', + '- Author: ' . $author, + '- Synced at: ' . gmdate(DATE_ATOM), + '', + '## Highlights & Notes', + '', + ]; + + foreach ($annotations as $annotation) { + $type = (string)($annotation['type'] ?? 'highlight'); + $text = trim((string)($annotation['text'] ?? '')); + $note = trim((string)($annotation['note'] ?? '')); + $location = (string)($annotation['location'] ?? ''); + if ($text === '' && $note === '') { + continue; + } + + $lines[] = '### ' . ucfirst($type) . ($location !== '' ? ' @ ' . $location : ''); + if ($text !== '') { + $lines[] = '> ' . str_replace("\n", "\n> ", $text); + } + if ($note !== '') { + $lines[] = ''; + $lines[] = '- Note: ' . $note; + } + $lines[] = ''; + } + + return implode("\n", $lines) . "\n"; + } + + private function toSafeFilename(string $name): string { + $name = preg_replace('/[\\\\\/:*?"<>|]+/', '_', $name) ?? $name; + $name = preg_replace('/\s+/', ' ', $name) ?? $name; + return trim($name); + } +} diff --git a/pocketbooksync/lib/Settings/Personal.php b/pocketbooksync/lib/Settings/Personal.php new file mode 100644 index 0000000..3cdb15c --- /dev/null +++ b/pocketbooksync/lib/Settings/Personal.php @@ -0,0 +1,28 @@ + + + PocketBook Sync + Configure your PocketBook Cloud credentials and sync target. + + + PocketBook Cloud URL + + + + Email / Username + + + + Password + + + + Target folder in your Nextcloud files + + + + Sync interval (minutes) + + + + + Save settings + Test connection + Sync now + + + Status: unknown + Last sync: never +
Configure your PocketBook Cloud credentials and sync target.
Status: unknown
Last sync: never