Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions pocketbooksync/README.md
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 15 additions & 0 deletions pocketbooksync/appinfo/app.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace OCA\PocketBookSync\AppInfo;

use OCP\AppFramework\App;

class Application extends App {
public const APP_ID = 'pocketbooksync';

public function __construct() {
parent::__construct(self::APP_ID);
}
}
21 changes: 21 additions & 0 deletions pocketbooksync/appinfo/info.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?xml version="1.0"?>
<info>
<id>pocketbooksync</id>
<name>PocketBook Sync</name>
<summary>Sync PocketBook Cloud highlights and notes into markdown files</summary>
<description>Connects a user's PocketBook Cloud account and regularly syncs book highlights into one markdown file per book.</description>
<version>0.1.0</version>
<licence>agpl</licence>
<author mail="dev@example.com">Codex</author>
<namespace>PocketBookSync</namespace>
<category>productivity</category>
<settings>
<personal>OCA\PocketBookSync\Settings\Personal</personal>
</settings>
<background-jobs>
<job>OCA\PocketBookSync\BackgroundJob\SyncJob</job>
</background-jobs>
<dependencies>
<nextcloud min-version="29" max-version="31"/>
</dependencies>
</info>
28 changes: 28 additions & 0 deletions pocketbooksync/appinfo/routes.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare(strict_types=1);

return [
'routes' => [
[
'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',
],
],
];
87 changes: 87 additions & 0 deletions pocketbooksync/js/settings.js
Original file line number Diff line number Diff line change
@@ -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 + ')';
});
})();
15 changes: 15 additions & 0 deletions pocketbooksync/lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace OCA\PocketBookSync\AppInfo;

use OCP\AppFramework\App;

class Application extends App {
public const APP_ID = 'pocketbooksync';

public function __construct() {
parent::__construct(self::APP_ID);
}
}
48 changes: 48 additions & 0 deletions pocketbooksync/lib/BackgroundJob/SyncJob.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php

declare(strict_types=1);

namespace OCA\PocketBookSync\BackgroundJob;

use OCA\PocketBookSync\Service\ConfigService;
use OCA\PocketBookSync\Service\SyncService;
use OCP\BackgroundJob\TimedJob;
use OCP\IUserManager;
use Psr\Log\LoggerInterface;
use Throwable;

class SyncJob extends TimedJob {
public function __construct(
private IUserManager $userManager,
private ConfigService $configService,
private SyncService $syncService,
private LoggerInterface $logger,
) {
parent::__construct();
$this->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,
]);
}
}
}
}
58 changes: 58 additions & 0 deletions pocketbooksync/lib/Controller/SettingsController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?php

declare(strict_types=1);

namespace OCA\PocketBookSync\Controller;

use OCA\PocketBookSync\Service\ConfigService;
use OCA\PocketBookSync\Service\SyncService;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\Http\JSONResponse;
use OCP\IRequest;
use OCP\IUserSession;
use Throwable;

class SettingsController extends Controller {
private string $userId;

public function __construct(
string $appName,
IRequest $request,
IUserSession $userSession,
private ConfigService $configService,
private SyncService $syncService,
) {
parent::__construct($appName, $request);
$this->userId = $userSession->getUser()?->getUID() ?? '';
}

public function get(): DataResponse {
return new DataResponse($this->configService->getSettings($this->userId));
}

/** @param array<string, mixed> $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);
}
}
}
67 changes: 67 additions & 0 deletions pocketbooksync/lib/Service/ConfigService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<?php

declare(strict_types=1);

namespace OCA\PocketBookSync\Service;

use OCA\PocketBookSync\AppInfo\Application;
use OCP\IConfig;

class ConfigService {
public const KEY_BASE_URL = 'base_url';
public const KEY_USERNAME = 'username';
public const KEY_PASSWORD = 'password';
public const KEY_TARGET_FOLDER = 'target_folder';
public const KEY_SYNC_INTERVAL_MIN = 'sync_interval_min';
public const KEY_LAST_SYNC = 'last_sync';
public const KEY_LAST_STATUS = 'last_status';

public function __construct(private IConfig $config) {
}

/** @return array<string, mixed> */
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<string, mixed> $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);
}
}
Loading
Loading