Skip to content

Commit

Permalink
WIP: Book reading state sync fix
Browse files Browse the repository at this point in the history
  • Loading branch information
ragusa87 committed Nov 8, 2024
1 parent 425c1a3 commit 9aeff92
Show file tree
Hide file tree
Showing 8 changed files with 225 additions and 93 deletions.
27 changes: 17 additions & 10 deletions src/Controller/Kobo/KoboStateController.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
use App\Kobo\Request\Bookmark;
use App\Kobo\Request\ReadingStates;
use App\Kobo\Request\ReadingStateStatusInfo;
use App\Kobo\Response\ReadingStateResponseFactory;
use App\Kobo\Response\StateResponse;
use App\Kobo\SyncToken;
use App\Repository\BookRepository;
use App\Service\BookProgressionService;
use Doctrine\ORM\EntityManagerInterface;
Expand All @@ -29,7 +31,8 @@ public function __construct(
protected KoboStoreProxy $koboStoreProxy,
protected SerializerInterface $serializer,
protected EntityManagerInterface $em,
protected BookProgressionService $bookProgressionService
protected BookProgressionService $bookProgressionService,
protected ReadingStateResponseFactory $readingStateResponseFactory,
) {
}

Expand Down Expand Up @@ -85,25 +88,29 @@ public function state(KoboDevice $kobo, string $uuid, Request $request): Respons
* @throws GuzzleException
*/
#[Route('/v1/library/{uuid}/state', name: 'api_endpoint_v1_getstate', requirements: ['uuid' => '^[a-zA-Z0-9\-]+$'], methods: ['GET'])]
public function getState(KoboDevice $kobo, string $uuid, Request $request): Response|JsonResponse
public function getState(KoboDevice $kobo, string $uuid, Request $request, SyncToken $syncToken): Response|JsonResponse
{
// Get State returns an empty response
$response = new JsonResponse([]);
$response->headers->set('x-kobo-api-token', 'e30=');

$book = $this->bookRepository->findByUuidAndKoboDevice($uuid, $kobo);

// Empty response if we know the book
if ($book instanceof Book) {
return $response;
}
// Unknown book
if (!$book instanceof Book) {
if ($this->koboStoreProxy->isEnabled()) {
return $this->koboStoreProxy->proxyOrRedirect($request);
}
$response->setData(['error' => 'Book not found']);

// If we do not know the book, we forward the query to the proxy
if ($this->koboStoreProxy->isEnabled()) {
return $this->koboStoreProxy->proxyOrRedirect($request);
return $response->setStatusCode(Response::HTTP_NOT_IMPLEMENTED);
}

return $response->setStatusCode(Response::HTTP_NOT_IMPLEMENTED);
$response->setContent(
$this->readingStateResponseFactory->create($syncToken, $kobo, $book)
);

return $response;
}

private function handleBookmark(KoboDevice $kobo, Book $book, ?Bookmark $currentBookmark): void
Expand Down
94 changes: 94 additions & 0 deletions src/Kobo/Response/ReadingStateResponse.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
<?php

namespace App\Kobo\Response;

use App\Entity\Book;
use App\Entity\BookmarkUser;
use App\Entity\KoboDevice;
use App\Kobo\SyncToken;
use App\Service\BookProgressionService;
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
use Symfony\Component\Serializer\SerializerInterface;

class ReadingStateResponse
{
public function __construct(
protected BookProgressionService $bookProgressionService,
protected SerializerInterface $serializer,
protected SyncToken $syncToken,
protected KoboDevice $kobo,
protected Book $book,
) {
}

/**
* @return array<string, mixed>
*/
public function createReadingState(): array
{
$book = $this->book;
$uuid = $book->getUuid();

$lastModified = $this->syncToken->maxLastModified($book->getUpdated(), $this->syncToken->currentDate, $book->getLastInteraction($this->kobo->getUser())?->getUpdated());

return [
'EntitlementId' => $uuid,
'Created' => $this->syncToken->maxLastCreated($book->getCreated(), $this->syncToken->currentDate, $book->getLastInteraction($this->kobo->getUser())?->getCreated()),
'LastModified' => $lastModified,
'PriorityTimestamp' => $lastModified,
'StatusInfo' => [
'LastModified' => $lastModified,
'Status' => match ($this->isReadingFinished($book)) {
true => SyncResponse::READING_STATUS_FINISHED,
false => SyncResponse::READING_STATUS_IN_PROGRESS,
null => SyncResponse::READING_STATUS_UNREAD,
},
'TimesStartedReading' => 0,
],

// "Statistics"=> get_statistics_response(kobo_reading_state.statistics),
'CurrentBookmark' => $this->createBookmark($this->kobo->getUser()->getBookmarkForBook($book)),
];
}

/**
* @return bool|null Null if we do not now the reading state
*/
private function isReadingFinished(Book $book): ?bool
{
$progression = $this->bookProgressionService->getProgression($book, $this->kobo->getUser());
if ($progression === null) {
return null;
}

return $progression >= 1.0;
}

private function createBookmark(?BookmarkUser $bookMark): array
{
if (!$bookMark instanceof BookmarkUser) {
return [];
}

$values = [
'Location' => [
'Type' => $bookMark->getLocationType(),
'Value' => $bookMark->getLocationValue(),
'Source' => $bookMark->getLocationSource(),
],
'ProgressPercent' => $bookMark->getPercentAsInt(),
'ContentSourceProgressPercent' => $bookMark->getSourcePercentAsInt(),
];

if (false === $bookMark->hasLocation()) {
unset($values['Location']);
}

return array_filter($values); // Remove null values
}

public function __toString(): string
{
return $this->serializer->serialize($this->createReadingState(), 'json', [DateTimeNormalizer::FORMAT_KEY => SyncResponse::DATE_FORMAT]);
}
}
33 changes: 33 additions & 0 deletions src/Kobo/Response/ReadingStateResponseFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

namespace App\Kobo\Response;

use App\Entity\Book;
use App\Entity\KoboDevice;
use App\Kobo\SyncToken;
use App\Service\BookProgressionService;
use Symfony\Component\Serializer\SerializerInterface;

/**
* Inspired by https://github.com/janeczku/calibre-web/blob/master/cps/kobo.py
*/
class ReadingStateResponseFactory
{
public function __construct(
protected MetadataResponseService $metadataResponseService,
protected BookProgressionService $bookProgressionService,
protected SerializerInterface $serializer)
{
}

public function create(SyncToken $syncToken, KoboDevice $kobo, Book $book): ReadingStateResponse
{
return new ReadingStateResponse(
$this->bookProgressionService,
$this->serializer,
$syncToken,
$kobo,
$book
);
}
}
2 changes: 1 addition & 1 deletion src/Kobo/Response/StateResponse.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,6 @@ public function __construct(Book $book)
],
],
],
], Response::HTTP_NO_CONTENT);
], Response::HTTP_OK);
}
}
99 changes: 23 additions & 76 deletions src/Kobo/Response/SyncResponse.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@
namespace App\Kobo\Response;

use App\Entity\Book;
use App\Entity\BookInteraction;
use App\Entity\BookmarkUser;
use App\Entity\KoboDevice;
use App\Entity\Shelf;
use App\Kobo\SyncToken;
Expand Down Expand Up @@ -33,21 +31,25 @@ class SyncResponse
public const READING_STATUS_UNREAD = 'ReadyToRead';
public const READING_STATUS_FINISHED = 'Finished';
public const READING_STATUS_IN_PROGRESS = 'Reading';
private SyncResponseHelper $helper;

public function __construct(
protected MetadataResponseService $metadataResponse,
protected BookProgressionService $bookProgressionService,
protected SyncToken $syncToken,
protected KoboDevice $kobo,
protected SerializerInterface $serializer)
{
protected SerializerInterface $serializer,
protected ReadingStateResponseFactory $readingStateResponseFactory,
) {
$this->helper = new SyncResponseHelper();
}

public function toJsonResponse(): JsonResponse
{
$list = [];
array_push($list, ...$this->getNewEntitlement());
array_push($list, ...$this->getChangedEntitlement());
array_push($list, ...$this->getChangedReadingState());
array_push($list, ...$this->getNewTags());
array_push($list, ...$this->getChangedTag());
array_filter($list);
Expand Down Expand Up @@ -114,44 +116,10 @@ private function createEntitlement(Book $book, bool $removed = false): array
/**
* @return BookReadingState
*/
private function createReadingState(Book $book): array
{
$uuid = $book->getUuid();

return [
'EntitlementId' => $uuid,
'Created' => $this->syncToken->maxLastCreated($book->getCreated(), $this->syncToken->currentDate),
'LastModified' => $this->syncToken->maxLastModified($book->getUpdated(), $this->syncToken->currentDate),

// PriorityTimestamp is always equal to LastModified.
'PriorityTimestamp' => $this->syncToken->maxLastCreated($book->getCreated(), $this->syncToken->currentDate),

'StatusInfo' => [
'LastModified' => $this->syncToken->maxLastModified($book->getLastInteraction($this->kobo->getUser())?->getUpdated(), $this->getLastBookmarkDate($book), $this->syncToken->currentDate),
'Status' => match ($this->isReadingFinished($book)) {
true => SyncResponse::READING_STATUS_FINISHED,
false => SyncResponse::READING_STATUS_IN_PROGRESS,
null => SyncResponse::READING_STATUS_UNREAD,
},
'TimesStartedReading' => 0,
],

// "Statistics"=> get_statistics_response(kobo_reading_state.statistics),
'CurrentBookmark' => $this->createBookmark($this->kobo->getUser()->getBookmarkForBook($book)),
];
}

/**
* @return bool|null Null if we do not now the reading state
*/
private function isReadingFinished(Book $book): ?bool
public function createReadingState(Book $book): array
{
$progression = $this->bookProgressionService->getProgression($book, $this->kobo->getUser());
if ($progression === null) {
return null;
}

return $progression >= 1.0;
return $this->readingStateResponseFactory->create($this->syncToken, $this->kobo, $book)
->createReadingState();
}

/**
Expand All @@ -160,17 +128,7 @@ private function isReadingFinished(Book $book): ?bool
private function getChangedEntitlement(): array
{
$books = array_filter($this->books, function (Book $book) {
// This book has not been synced before, so it's a NewEntitlement
if ($book->getKoboSyncedBook()->isEmpty()) {
return false;
}

$lastInteraction = $book->getLastInteraction($this->kobo->getUser());

return $book->getUpdated() >= $this->syncToken->lastModified
|| !$book->getUpdated() instanceof \DateTimeInterface
|| $book->getCreated() >= $this->syncToken->lastCreated
|| ($lastInteraction instanceof BookInteraction && $lastInteraction->getUpdated() >= $this->syncToken->lastModified);
return $this->helper->isChangedEntitlement($book, $this->kobo, $this->syncToken);
});

return array_map(function (Book $book) {
Expand All @@ -188,7 +146,7 @@ private function getNewEntitlement(): array
{
$books = array_filter($this->books, function (Book $book) {
// This book has never been synced before
return $book->getKoboSyncedBook()->isEmpty();
return $this->helper->isNewEntitlement($book, $this->syncToken);
});

return array_map(function (Book $book) {
Expand Down Expand Up @@ -266,31 +224,20 @@ private function createBookEntitlement(Book $book): array
];
}

private function createBookmark(?BookmarkUser $bookMark): array
/**
* @return array<int, object>
*/
private function getChangedReadingState(): array
{
if (!$bookMark instanceof BookmarkUser) {
return [];
}

$values = [
'Location' => [
'Type' => $bookMark->getLocationType(),
'Value' => $bookMark->getLocationValue(),
'Source' => $bookMark->getLocationSource(),
],
'ProgressPercent' => $bookMark->getPercentAsInt(),
'ContentSourceProgressPercent' => $bookMark->getSourcePercentAsInt(),
];

if (false === $bookMark->hasLocation()) {
unset($values['Location']);
}
$books = array_filter($this->books, function (Book $book) {
return $this->helper->isChangedReadingState($book, $this->kobo, $this->syncToken);
});

return array_filter($values); // Remove null values
}
return array_map(function (Book $book) {
$response = new \stdClass();
$response->ChangedReadingState = $this->createReadingState($book);

private function getLastBookmarkDate(Book $book): ?\DateTimeInterface
{
return $this->kobo->getUser()->getBookmarkForBook($book)?->getUpdated();
return $response;
}, $books);
}
}
8 changes: 5 additions & 3 deletions src/Kobo/Response/SyncResponseFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@ class SyncResponseFactory
public function __construct(
protected MetadataResponseService $metadataResponseService,
protected BookProgressionService $bookProgressionService,
protected SerializerInterface $serializer)
{
protected SerializerInterface $serializer,
protected ReadingStateResponseFactory $readingStateResponseFactory,
) {
}

public function create(SyncToken $syncToken, KoboDevice $kobo): SyncResponse
Expand All @@ -28,7 +29,8 @@ public function create(SyncToken $syncToken, KoboDevice $kobo): SyncResponse
$this->bookProgressionService,
$syncToken,
$kobo,
$this->serializer
$this->serializer,
$this->readingStateResponseFactory,
);
}

Expand Down
Loading

0 comments on commit 9aeff92

Please sign in to comment.