diff --git a/migrations/Version20240812195147.php b/migrations/Version20240812195147.php new file mode 100644 index 00000000..f97ba6ee --- /dev/null +++ b/migrations/Version20240812195147.php @@ -0,0 +1,35 @@ +addSql('CREATE TABLE bookmark_user (id INT AUTO_INCREMENT NOT NULL, user_id INT NOT NULL, book_id INT NOT NULL, percent DOUBLE PRECISION DEFAULT NULL, source_percent DOUBLE PRECISION DEFAULT NULL, location_value VARCHAR(255) DEFAULT NULL, location_type VARCHAR(255) DEFAULT NULL, location_source VARCHAR(255) DEFAULT NULL, INDEX IDX_6F0BEE95A76ED395 (user_id), INDEX IDX_6F0BEE9516A2B381 (book_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8 COLLATE `utf8_unicode_ci` ENGINE = InnoDB'); + $this->addSql('ALTER TABLE bookmark_user ADD CONSTRAINT FK_6F0BEE95A76ED395 FOREIGN KEY (user_id) REFERENCES `user` (id)'); + $this->addSql('ALTER TABLE bookmark_user ADD CONSTRAINT FK_6F0BEE9516A2B381 FOREIGN KEY (book_id) REFERENCES book (id)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE bookmark_user DROP FOREIGN KEY FK_6F0BEE95A76ED395'); + $this->addSql('ALTER TABLE bookmark_user DROP FOREIGN KEY FK_6F0BEE9516A2B381'); + $this->addSql('DROP TABLE bookmark_user'); + } +} diff --git a/src/Controller/Kobo/KoboStateController.php b/src/Controller/Kobo/KoboStateController.php index a87727ac..60a0a82f 100644 --- a/src/Controller/Kobo/KoboStateController.php +++ b/src/Controller/Kobo/KoboStateController.php @@ -3,8 +3,10 @@ namespace App\Controller\Kobo; use App\Entity\Book; +use App\Entity\BookmarkUser; use App\Entity\KoboDevice; use App\Kobo\Proxy\KoboStoreProxy; +use App\Kobo\Request\Bookmark; use App\Kobo\Request\ReadingStates; use App\Kobo\Request\ReadingStateStatusInfo; use App\Kobo\Response\StateResponse; @@ -72,6 +74,9 @@ public function state(KoboDevice $kobo, string $uuid, Request $request): Respons case null: break; } + + $this->handleBookmark($kobo, $book, $state->currentBookmark); + $this->em->flush(); return new StateResponse($book); @@ -88,4 +93,22 @@ public function getState(KoboDevice $kobo, string $uuid, Request $request): Resp } throw new HttpException(200, 'Not implemented'); } + + private function handleBookmark(KoboDevice $kobo, Book $book, ?Bookmark $currentBookmark): void + { + if ($currentBookmark === null) { + $kobo->getUser()->removeBookmarkForBook($book); + + return; + } + + $bookmark = $kobo->getUser()->getBookmarkForBook($book) ?? new BookmarkUser($book, $kobo->getUser()); + $this->em->persist($bookmark); + + $bookmark->setPercent($currentBookmark->progressPercent === null ? null : $currentBookmark->progressPercent / 100); + $bookmark->setLocationType($currentBookmark->location?->type); + $bookmark->setLocationSource($currentBookmark->location?->source); + $bookmark->setLocationValue($currentBookmark->location?->value); + $bookmark->setSourcePercent($currentBookmark->contentSourceProgressPercent === null ? null : $currentBookmark->contentSourceProgressPercent / 100); + } } diff --git a/src/Entity/Book.php b/src/Entity/Book.php index a32f10f1..c29686fc 100644 --- a/src/Entity/Book.php +++ b/src/Entity/Book.php @@ -89,7 +89,11 @@ class Book #[ORM\OneToMany(mappedBy: 'book', targetEntity: BookInteraction::class, cascade: ['remove'], orphanRemoval: true)] #[ORM\OrderBy(['updated' => 'ASC'])] private Collection $bookInteractions; - + /** + * @var Collection + */ + #[ORM\OneToMany(mappedBy: 'book', targetEntity: BookmarkUser::class, orphanRemoval: true)] + private Collection $bookmarkUsers; /** * @var array|null */ @@ -120,6 +124,7 @@ public function __construct() $this->shelves = new ArrayCollection(); $this->uuid = $this->generateUuid(); $this->koboSyncedBooks = new ArrayCollection(); + $this->bookmarkUsers = new ArrayCollection(); } public function getId(): ?int @@ -566,4 +571,21 @@ public function setUuid(?string $uuid): self return $this; } + + /** + * @return Collection + */ + public function getBookmarkUsers(): Collection + { + return $this->bookmarkUsers; + } + /** + * @param Collection $bookmarkUsers + */ + public function setBookmarkUsers(Collection $bookmarkUsers): self + { + $this->bookmarkUsers = $bookmarkUsers; + + return $this; + } } diff --git a/src/Entity/BookmarkUser.php b/src/Entity/BookmarkUser.php new file mode 100644 index 00000000..2cd22e4e --- /dev/null +++ b/src/Entity/BookmarkUser.php @@ -0,0 +1,138 @@ +book = $book; + $this->user = $user; + } + + public function getId(): ?int + { + return $this->id; + } + + public function getPercent(): ?float + { + return $this->percent; + } + + public function setPercent(?float $percent): static + { + $this->percent = $percent; + + return $this; + } + + public function getSourcePercent(): ?float + { + return $this->sourcePercent; + } + + public function setSourcePercent(?float $sourcePercent): static + { + $this->sourcePercent = $sourcePercent; + + return $this; + } + + public function getLocationValue(): ?string + { + return $this->locationValue; + } + + public function setLocationValue(?string $locationValue): static + { + $this->locationValue = $locationValue; + + return $this; + } + + public function getLocationType(): ?string + { + return $this->locationType; + } + + public function setLocationType(?string $locationType): static + { + $this->locationType = $locationType; + + return $this; + } + + public function getLocationSource(): ?string + { + return $this->locationSource; + } + + public function setLocationSource(?string $locationSource): static + { + $this->locationSource = $locationSource; + + return $this; + } + + public function getUser(): ?User + { + return $this->user; + } + + public function setUser(?User $user): static + { + $this->user = $user; + + return $this; + } + + public function hasLocation(): bool + { + return $this->locationValue !== null; + } + + public function setBook(?Book $book): self + { + $this->book = $book; + + return $this; + } + + public function getBook(): ?Book + { + return $this->book; + } +} diff --git a/src/Entity/User.php b/src/Entity/User.php index bff5143e..14be5589 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -104,11 +104,18 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface #[ORM\Column] private bool $useKoboDevices = true; + /** + * @var Collection + */ + #[ORM\OneToMany(mappedBy: 'user', targetEntity: BookmarkUser::class, orphanRemoval: true)] + private Collection $bookmarkUsers; + public function __construct() { $this->bookInteractions = new ArrayCollection(); $this->shelves = new ArrayCollection(); $this->kobos = new ArrayCollection(); + $this->bookmarkUsers = new ArrayCollection(); } public function getId(): ?int @@ -434,4 +441,52 @@ public function setUseKoboDevices(bool $useKoboDevices): static return $this; } + + /** + * @return Collection + */ + public function getBookmarkUsers(): Collection + { + return $this->bookmarkUsers; + } + + public function addBookmarkUser(BookmarkUser $bookmarkUser): static + { + if (!$this->bookmarkUsers->contains($bookmarkUser)) { + $this->bookmarkUsers->add($bookmarkUser); + $bookmarkUser->setUser($this); + } + + return $this; + } + + public function removeBookmarkUser(BookmarkUser $bookmarkUser): static + { + if ($this->bookmarkUsers->removeElement($bookmarkUser)) { + // set the owning side to null (unless already changed) + if ($bookmarkUser->getUser() === $this) { + $bookmarkUser->setUser(null); + } + } + + return $this; + } + + public function getBookmarkForBook(Book $book): ?BookmarkUser + { + foreach ($this->bookmarkUsers as $bookmarkUser) { + if ($bookmarkUser->getBook() === $book) { + return $bookmarkUser; + } + } + + return null; + } + + public function removeBookmarkForBook(Book $book): self + { + $this->getBookmarkForBook($book)?->setUser(null)->setBook(null); + + return $this; + } } diff --git a/src/Kobo/Response/SyncResponse.php b/src/Kobo/Response/SyncResponse.php index 68e04b77..ec0ab72c 100644 --- a/src/Kobo/Response/SyncResponse.php +++ b/src/Kobo/Response/SyncResponse.php @@ -4,9 +4,11 @@ use App\Entity\Book; use App\Entity\BookInteraction; +use App\Entity\BookmarkUser; use App\Entity\KoboDevice; use App\Entity\Shelf; use App\Kobo\SyncToken; +use App\Service\BookProgressionService; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer; use Symfony\Component\Serializer\SerializerInterface; @@ -32,7 +34,12 @@ class SyncResponse public const READING_STATUS_FINISHED = 'Finished'; public const READING_STATUS_IN_PROGRESS = 'Reading'; - public function __construct(protected MetadataResponseService $metadataResponse, protected SyncToken $syncToken, protected KoboDevice $kobo, protected SerializerInterface $serializer) + public function __construct( + protected MetadataResponseService $metadataResponse, + protected BookProgressionService $bookProgressionService, + protected SyncToken $syncToken, + protected KoboDevice $kobo, + protected SerializerInterface $serializer) { } @@ -120,7 +127,7 @@ private function createReadingState(Book $book): array 'PriorityTimestamp' => $this->syncToken->maxLastCreated($book->getCreated(), $this->syncToken->currentDate), 'StatusInfo' => [ - 'LastModified' => $book->getUpdated(), + 'LastModified' => $book->getLastInteraction($this->kobo->getUser())?->getUpdated(), 'Status' => match ($this->isReadingFinished($book)) { true => SyncResponse::READING_STATUS_FINISHED, false => SyncResponse::READING_STATUS_IN_PROGRESS, @@ -130,7 +137,7 @@ private function createReadingState(Book $book): array ], // "Statistics"=> get_statistics_response(kobo_reading_state.statistics), - // "CurrentBookmark"=> get_current_bookmark_response(kobo_reading_state.current_bookmark), + 'CurrentBookmark' => $this->createBookmark($this->kobo->getUser()->getBookmarkForBook($book)), ]; } @@ -139,17 +146,12 @@ private function createReadingState(Book $book): array */ private function isReadingFinished(Book $book): ?bool { - // Read the latest interaction to know it. - $interaction = $book->getLastInteraction($this->kobo->getUser()); - if (!$interaction instanceof BookInteraction) { + $progression = $this->bookProgressionService->getProgression($book, $this->kobo->getUser()); + if ($progression === null) { return null; } - if ($interaction->getReadPages() === null) { - return null; - } - - return $interaction->isFinished(); + return $progression >= 1.0; } /** @@ -263,4 +265,27 @@ private function createBookEntitlement(Book $book): array 'ReadingState' => $this->createReadingState($book), ]; } + + private function createBookmark(?BookmarkUser $bookMark): array + { + if ($bookMark === null) { + return []; + } + + $values = [ + 'Location' => [ + 'Type' => $bookMark->getLocationType(), + 'Value' => $bookMark->getLocationValue(), + 'Source' => $bookMark->getLocationSource(), + ], + 'ProgressPercent' => $bookMark->getPercent(), + 'ContentSourceProgressPercent' => $bookMark->getSourcePercent(), + ]; + + if (false === $bookMark->hasLocation()) { + unset($values['Location']); + } + + return array_filter($values); // Remove null values + } } diff --git a/src/Kobo/Response/SyncResponseFactory.php b/src/Kobo/Response/SyncResponseFactory.php index 1b943aed..435d3570 100644 --- a/src/Kobo/Response/SyncResponseFactory.php +++ b/src/Kobo/Response/SyncResponseFactory.php @@ -5,6 +5,7 @@ use App\Entity\Book; use App\Entity\KoboDevice; use App\Kobo\SyncToken; +use App\Service\BookProgressionService; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\Serializer\SerializerInterface; @@ -13,13 +14,22 @@ */ class SyncResponseFactory { - public function __construct(protected MetadataResponseService $metadataResponseService, protected SerializerInterface $serializer) + public function __construct( + protected MetadataResponseService $metadataResponseService, + protected BookProgressionService $bookProgressionService, + protected SerializerInterface $serializer) { } public function create(SyncToken $syncToken, KoboDevice $kobo): SyncResponse { - return new SyncResponse($this->metadataResponseService, $syncToken, $kobo, $this->serializer); + return new SyncResponse( + $this->metadataResponseService, + $this->bookProgressionService, + $syncToken, + $kobo, + $this->serializer + ); } public function createMetadata(KoboDevice $kobo, Book $book): JsonResponse