├── .mill-version
├── finito
├── persistence
│ ├── resources
│ │ └── db
│ │ │ └── migration
│ │ │ ├── V0__settings.sql
│ │ │ ├── V2__add_reviews.sql
│ │ │ └── V1__create_database.sql
│ ├── src
│ │ └── fin
│ │ │ └── persistence
│ │ │ ├── FlywaySetup.scala
│ │ │ ├── package.scala
│ │ │ ├── BookRepository.scala
│ │ │ ├── TransactorSetup.scala
│ │ │ ├── CollectionRepository.scala
│ │ │ └── SqliteBookRepository.scala
│ └── test
│ │ └── src
│ │ └── fin
│ │ └── persistence
│ │ ├── SqliteSuite.scala
│ │ └── SqliteBookRepositoryTest.scala
├── core
│ ├── src
│ │ └── fin
│ │ │ └── service
│ │ │ ├── summary
│ │ │ ├── SummaryService.scala
│ │ │ ├── MontageService.scala
│ │ │ ├── SummaryServiceImpl.scala
│ │ │ ├── ImageStitch.scala
│ │ │ └── BufferedImageMontageService.scala
│ │ │ ├── book
│ │ │ ├── SeriesInfoService.scala
│ │ │ ├── BookManagementService.scala
│ │ │ ├── BookManagementServiceImpl.scala
│ │ │ ├── WikidataSeriesInfoService.scala
│ │ │ └── SpecialBookService.scala
│ │ │ ├── port
│ │ │ ├── CollectionExportService.scala
│ │ │ ├── ImportService.scala
│ │ │ ├── GoodreadsExportService.scala
│ │ │ └── GoodreadsImportService.scala
│ │ │ ├── collection
│ │ │ ├── CollectionHook.scala
│ │ │ ├── CollectionService.scala
│ │ │ ├── SBindings.scala
│ │ │ ├── HookExecutionService.scala
│ │ │ ├── CollectionServiceImpl.scala
│ │ │ └── SpecialCollectionService.scala
│ │ │ └── search
│ │ │ ├── BookInfoService.scala
│ │ │ ├── BookInfoAugmentationService.scala
│ │ │ ├── GoogleBooksAPIDecoding.scala
│ │ │ └── GoogleBookInfoService.scala
│ └── test
│ │ └── src
│ │ └── fin
│ │ └── service
│ │ ├── book
│ │ ├── BookInfoServiceUsingTitles.scala
│ │ ├── WikidataSeriesInfoServiceTest.scala
│ │ ├── InMemoryBookRepository.scala
│ │ ├── SpecialBookServiceTest.scala
│ │ └── BookManagementServiceImplTest.scala
│ │ ├── port
│ │ ├── PortTest.scala
│ │ ├── GoodreadsExportServiceTest.scala
│ │ └── GoodreadsImportServiceTest.scala
│ │ ├── summary
│ │ ├── SummaryServiceImplTest.scala
│ │ └── BufferedImageMontageServiceTest.scala
│ │ ├── search
│ │ ├── BookInfoAugmentationServiceTest.scala
│ │ └── GoogleBookInfoServiceTest.scala
│ │ └── collection
│ │ └── InMemoryCollectionRepository.scala
├── api
│ └── src
│ │ └── fin
│ │ ├── SortConversions.scala
│ │ ├── BookConversions.scala
│ │ ├── implicits.scala
│ │ └── FinitoError.scala
├── benchmark
│ └── src
│ │ └── fin
│ │ └── FinitoBenchmark.scala
└── main
│ ├── resources
│ ├── logback.xml
│ └── graphiql.html
│ ├── src
│ └── fin
│ │ ├── config
│ │ └── ServiceConfig.scala
│ │ ├── SpecialCollectionSetup.scala
│ │ ├── Services.scala
│ │ ├── Routes.scala
│ │ ├── Main.scala
│ │ └── FinitoFiles.scala
│ └── it
│ └── src
│ └── fin
│ └── FinitoFilesTest.scala
├── bin
├── montage.png
├── perf.py
└── montage.py
├── .scalafmt.conf
├── .gitignore
├── .scalafix.conf
├── assets
└── sample_goodreads_export.csv
├── LICENSE
├── mill
├── .github
└── workflows
│ └── actions.yml
├── plugins
├── jmh.sc
└── calibanSchemaGen.sc
├── schema.gql
├── CHANGELOG.md
├── README.md
└── planning.org
/.mill-version:
--------------------------------------------------------------------------------
1 | 0.11.12
--------------------------------------------------------------------------------
/finito/persistence/resources/db/migration/V0__settings.sql:
--------------------------------------------------------------------------------
1 | PRAGMA foreign_keys = ON;
2 |
--------------------------------------------------------------------------------
/bin/montage.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LaurenceWarne/libro-finito/HEAD/bin/montage.png
--------------------------------------------------------------------------------
/finito/persistence/resources/db/migration/V2__add_reviews.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE books
2 | ADD COLUMN review TEXT NULL;
3 |
--------------------------------------------------------------------------------
/.scalafmt.conf:
--------------------------------------------------------------------------------
1 | version = "3.8.3"
2 | runner.dialect = scala213source3 # Required by '-Xsource:3' added by mill tpolecat
3 | align.preset = more
4 |
--------------------------------------------------------------------------------
/finito/core/src/fin/service/summary/SummaryService.scala:
--------------------------------------------------------------------------------
1 | package fin.service.summary
2 |
3 | import fin.Types._
4 |
5 | trait SummaryService[F[_]] {
6 | def summary(args: QuerySummaryArgs): F[Summary]
7 | }
8 |
--------------------------------------------------------------------------------
/finito/core/src/fin/service/book/SeriesInfoService.scala:
--------------------------------------------------------------------------------
1 | package fin.service.book
2 |
3 | import fin.Types._
4 |
5 | trait SeriesInfoService[F[_]] {
6 | def series(args: QuerySeriesArgs): F[List[UserBook]]
7 | }
8 |
--------------------------------------------------------------------------------
/finito/core/src/fin/service/summary/MontageService.scala:
--------------------------------------------------------------------------------
1 | package fin.service.summary
2 |
3 | import fin.Types._
4 |
5 | trait MontageService[F[_]] {
6 | def montage(
7 | books: List[UserBook],
8 | maybeSpecification: Option[MontageInput]
9 | ): F[String]
10 | }
11 |
--------------------------------------------------------------------------------
/finito/core/src/fin/service/port/CollectionExportService.scala:
--------------------------------------------------------------------------------
1 | package fin.service.port
2 |
3 | import fin.Types._
4 |
5 | /** https://www.goodreads.com/review/import
6 | */
7 | trait CollectionExportService[F[_]] {
8 | def exportCollection(exportArgs: QueryExportArgs): F[String]
9 | }
10 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.class
2 | *.log
3 | *.iml
4 | *.ipr
5 | *.iws
6 | .idea
7 | out
8 | .cache/
9 | .history/
10 | .lib/
11 | dist/*
12 | target/
13 | libexec/
14 | lib_managed/
15 | src_managed/
16 | project/boot/
17 | project/plugins/project/
18 | logs/
19 | project/*-shim.sbt
20 | project/project/
21 | project/target/
22 | target/
23 | .scala_dependencies
24 | .worksheet
25 | *~
26 | .bloop
27 | .metals
28 | /.ammonite/
29 | /.bsp/
30 |
--------------------------------------------------------------------------------
/finito/persistence/src/fin/persistence/FlywaySetup.scala:
--------------------------------------------------------------------------------
1 | package fin.persistence
2 |
3 | import cats.effect.Sync
4 | import cats.implicits._
5 | import org.flywaydb.core.Flyway
6 |
7 | object FlywaySetup {
8 |
9 | def init[F[_]: Sync](uri: String, user: String, password: String): F[Unit] = {
10 | for {
11 | flyway <- Sync[F].blocking(
12 | Flyway.configure().dataSource(uri, user, password).load()
13 | )
14 | _ <- Sync[F].blocking(flyway.migrate())
15 | } yield ()
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/finito/core/test/src/fin/service/book/BookInfoServiceUsingTitles.scala:
--------------------------------------------------------------------------------
1 | package fin.service.book
2 |
3 | import cats.effect._
4 | import cats.implicits._
5 |
6 | import fin.Types._
7 | import fin.service.search.BookInfoService
8 |
9 | class BookInfoServiceUsingTitles(books: List[UserBook])
10 | extends BookInfoService[IO] {
11 |
12 | override def search(booksArgs: QueryBooksArgs): IO[List[UserBook]] =
13 | books.filter(b => booksArgs.titleKeywords.exists(_ === b.title)).pure[IO]
14 |
15 | override def fromIsbn(bookArgs: QueryBookArgs): IO[List[UserBook]] = ???
16 | }
17 |
--------------------------------------------------------------------------------
/.scalafix.conf:
--------------------------------------------------------------------------------
1 | rules = [
2 | DisableSyntax
3 | LeakingImplicitClassVal
4 | NoValInForComprehension
5 | ProcedureSyntax
6 | RedundantSyntax
7 | RemoveUnused
8 |
9 | OrganizeImports
10 |
11 | TypelevelUnusedIO
12 | TypelevelMapSequence
13 | TypelevelAs
14 | TypelevelUnusedShowInterpolator
15 | TypelevelFs2SyncCompiler
16 | TypelevelHttp4sLiteralsSyntax
17 | ]
18 |
19 | OrganizeImports {
20 | blankLines = Auto
21 | coalesceToWildcardImportThreshold = 3
22 | groupedImports = Merge
23 | groups = [
24 | "re:javax?\\."
25 | "scala."
26 | "*"
27 | "fin."
28 | ]
29 | }
--------------------------------------------------------------------------------
/finito/core/src/fin/service/collection/CollectionHook.scala:
--------------------------------------------------------------------------------
1 | package fin.service.collection
2 |
3 | import cats.Eq
4 |
5 | sealed trait HookType extends Product with Serializable
6 |
7 | object HookType {
8 | implicit val hookTypeEq: Eq[HookType] = Eq.fromUniversalEquals
9 |
10 | case object ReadStarted extends HookType
11 | case object ReadCompleted extends HookType
12 | case object Rate extends HookType
13 | case object Add extends HookType
14 | }
15 |
16 | final case class CollectionHook(
17 | collection: String,
18 | `type`: HookType,
19 | code: String
20 | )
21 |
--------------------------------------------------------------------------------
/finito/persistence/src/fin/persistence/package.scala:
--------------------------------------------------------------------------------
1 | package fin.persistence
2 |
3 | import java.time.LocalDate
4 | import java.util.Properties
5 |
6 | import cats.Functor
7 | import cats.effect.Clock
8 | import cats.implicits._
9 |
10 | object DbProperties {
11 |
12 | def properties: Properties = {
13 | val props = new Properties()
14 | props.setProperty("connectionInitSql", "PRAGMA foreign_keys=1")
15 | props
16 | }
17 | }
18 |
19 | object Dates {
20 |
21 | def currentDate[F[_]: Functor](clock: Clock[F]): F[LocalDate] =
22 | clock.realTime
23 | .map(fd => LocalDate.ofEpochDay(fd.toDays))
24 | }
25 |
--------------------------------------------------------------------------------
/finito/core/src/fin/service/search/BookInfoService.scala:
--------------------------------------------------------------------------------
1 | package fin.service.search
2 |
3 | import fin.Types._
4 |
5 | trait BookInfoService[F[_]] {
6 |
7 | /** Find books satisfying the specified arguments.
8 | *
9 | * @param booksArgs
10 | * books arguments
11 | * @return
12 | * books satisfying booksArgs
13 | */
14 | def search(booksArgs: QueryBooksArgs): F[List[UserBook]]
15 |
16 | /** Find a book given an isbn.
17 | *
18 | * @param bookArgs
19 | * isbn data
20 | * @return
21 | * a book with the given isbn
22 | */
23 | def fromIsbn(bookArgs: QueryBookArgs): F[List[UserBook]]
24 | }
25 |
--------------------------------------------------------------------------------
/finito/api/src/fin/SortConversions.scala:
--------------------------------------------------------------------------------
1 | package fin
2 |
3 | import cats.implicits._
4 |
5 | import fin.Types._
6 |
7 | object SortConversions {
8 | def fromString(sortType: String): Either[InvalidSortStringError, SortType] =
9 | sortType.toLowerCase match {
10 | case "dateadded" => SortType.DateAdded.asRight
11 | case "title" => SortType.Title.asRight
12 | case "author" => SortType.Author.asRight
13 | case "rating" => SortType.Rating.asRight
14 | case "lastread" => SortType.LastRead.asRight
15 | case unmatchedString => InvalidSortStringError(unmatchedString).asLeft
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/finito/core/src/fin/service/book/BookManagementService.scala:
--------------------------------------------------------------------------------
1 | package fin.service.book
2 |
3 | import fin.Types._
4 |
5 | trait BookManagementService[F[_]] {
6 | def books: F[List[UserBook]]
7 | def createBook(args: MutationCreateBookArgs): F[UserBook]
8 | def createBooks(books: List[UserBook]): F[List[UserBook]]
9 | def rateBook(args: MutationRateBookArgs): F[UserBook]
10 | def addBookReview(args: MutationAddBookReviewArgs): F[UserBook]
11 | def startReading(args: MutationStartReadingArgs): F[UserBook]
12 | def finishReading(args: MutationFinishReadingArgs): F[UserBook]
13 | def deleteBookData(args: MutationDeleteBookDataArgs): F[Unit]
14 | }
15 |
--------------------------------------------------------------------------------
/finito/core/src/fin/service/collection/CollectionService.scala:
--------------------------------------------------------------------------------
1 | package fin.service.collection
2 |
3 | import fin.Types._
4 |
5 | trait CollectionService[F[_]] {
6 | def collections: F[List[Collection]]
7 | def createCollections(names: Set[String]): F[List[Collection]]
8 | def createCollection(args: MutationCreateCollectionArgs): F[Collection]
9 | def collection(args: QueryCollectionArgs): F[Collection]
10 | def deleteCollection(args: MutationDeleteCollectionArgs): F[Unit]
11 | def updateCollection(args: MutationUpdateCollectionArgs): F[Collection]
12 | def addBookToCollection(args: MutationAddBookArgs): F[Collection]
13 | def removeBookFromCollection(args: MutationRemoveBookArgs): F[Unit]
14 | }
15 |
--------------------------------------------------------------------------------
/finito/persistence/src/fin/persistence/BookRepository.scala:
--------------------------------------------------------------------------------
1 | package fin.persistence
2 |
3 | import java.time.LocalDate
4 |
5 | import fin.Types._
6 |
7 | trait BookRepository[F[_]] {
8 | def books: F[List[UserBook]]
9 | def retrieveBook(isbn: String): F[Option[UserBook]]
10 | def retrieveMultipleBooks(isbns: List[String]): F[List[UserBook]]
11 | def createBook(book: BookInput, date: LocalDate): F[Unit]
12 | def createBooks(books: List[UserBook]): F[Unit]
13 | def rateBook(book: BookInput, rating: Int): F[Unit]
14 | def addBookReview(book: BookInput, review: String): F[Unit]
15 | def startReading(book: BookInput, date: LocalDate): F[Unit]
16 | def finishReading(book: BookInput, date: LocalDate): F[Unit]
17 | def deleteBookData(isbn: String): F[Unit]
18 | def retrieveBooksInside(from: LocalDate, to: LocalDate): F[List[UserBook]]
19 | }
20 |
--------------------------------------------------------------------------------
/finito/benchmark/src/fin/FinitoBenchmark.scala:
--------------------------------------------------------------------------------
1 | package fin
2 |
3 | import java.util.concurrent.TimeUnit
4 |
5 | import scala.collection.mutable.ListBuffer
6 |
7 | import org.openjdk.jmh.annotations._
8 |
9 | @OutputTimeUnit(TimeUnit.MILLISECONDS)
10 | @BenchmarkMode(Array(Mode.All))
11 | @State(Scope.Thread)
12 | class FinitoBenchmark {
13 |
14 | @Benchmark
15 | @Fork(value = 2)
16 | @Measurement(iterations = 10, time = 1)
17 | @Warmup(iterations = 5, time = 1)
18 | def listImmutable() = {
19 | var ls = List[Int]()
20 | for (i <- (1 to 1000)) ls = i :: ls
21 | ls
22 | }
23 |
24 | @Benchmark
25 | @Fork(value = 2)
26 | @Measurement(iterations = 10, time = 1)
27 | @Warmup(iterations = 5, time = 1)
28 | def listMutable() = {
29 | val ls = ListBuffer[Int]()
30 | for (i <- (1 to 1000)) ls.addOne(i)
31 | ls
32 | }
33 |
34 | }
35 |
--------------------------------------------------------------------------------
/assets/sample_goodreads_export.csv:
--------------------------------------------------------------------------------
1 | Title, Author, ISBN, My Rating, Average Rating, Publisher, Binding, Year Published, Original Publication Year, Date Read, Date Added, Bookshelves, My Review
2 | memoirs of a geisha, arthur golden, 0099498189, 4, 4.00, vintage, paperback, 2005, 2005, , 2007-02-14, fiction,
3 | Blink: The Power of Thinking Without Thinking, Malcolm Gladwell, 0316172324, 3, 4.17, Little Brown and Company, Hardcover, 2005, 2005, , 2007-02-14, nonfiction marketing,
4 | Power of One, Bryce Courtenay, 034541005X, 5, 5.00, Ballantine Books, Paperback, 1996, 1996, , 2007-02-14, fiction,
5 | Harry Potter and the Half-Blood Prince (Book 6), J.K. Rowling, 0439785960, 4, 4.38, Scholastic Paperbacks, Paperback, 2006, 2006, , 2007-02-14, fiction fantasy,
6 | Dune (Dune Chronicles Book 1), Frank Herbert, 0441172717, 5, 4.55, Ace, Paperback, 1990, 1977, , 2007-02-14, fiction scifi,
7 |
--------------------------------------------------------------------------------
/finito/persistence/src/fin/persistence/TransactorSetup.scala:
--------------------------------------------------------------------------------
1 | package fin.persistence
2 |
3 | import cats.effect.{Async, Resource}
4 | import com.zaxxer.hikari.{HikariConfig, HikariDataSource}
5 | import doobie._
6 | import doobie.hikari._
7 | import org.typelevel.log4cats.Logger
8 |
9 | object TransactorSetup {
10 | def sqliteTransactor[F[_]: Async: Logger](
11 | uri: String
12 | ): Resource[F, Transactor[F]] = {
13 | val config = new HikariConfig(DbProperties.properties)
14 | config.setDriverClassName("org.sqlite.JDBC")
15 | config.setJdbcUrl(uri)
16 | config.setMaximumPoolSize(4)
17 | config.setMinimumIdle(2)
18 | val logHandler = new doobie.LogHandler[F] {
19 | def run(logEvent: doobie.util.log.LogEvent): F[Unit] =
20 | Logger[F].debug(logEvent.sql)
21 | }
22 | HikariTransactor.fromHikariConfig[F](
23 | new HikariDataSource(config),
24 | logHandler = Some(logHandler)
25 | )
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/finito/persistence/src/fin/persistence/CollectionRepository.scala:
--------------------------------------------------------------------------------
1 | package fin.persistence
2 |
3 | import java.time.LocalDate
4 |
5 | import fin.Types._
6 |
7 | trait CollectionRepository[F[_]] {
8 | def collections: F[List[Collection]]
9 | def createCollection(name: String, preferredSort: Sort): F[Unit]
10 | def createCollections(
11 | names: Set[String],
12 | preferredSort: Sort
13 | ): F[Unit]
14 | def collection(
15 | name: String,
16 | bookLimit: Option[Int],
17 | bookOffset: Option[Int]
18 | ): F[Option[Collection]]
19 | def deleteCollection(name: String): F[Unit]
20 | def updateCollection(
21 | currentName: String,
22 | newName: String,
23 | preferredSort: Sort
24 | ): F[Unit]
25 | def addBookToCollection(
26 | collectionName: String,
27 | book: BookInput,
28 | date: LocalDate
29 | ): F[Unit]
30 | def removeBookFromCollection(
31 | collectionName: String,
32 | isbn: String
33 | ): F[Unit]
34 | }
35 |
--------------------------------------------------------------------------------
/finito/core/test/src/fin/service/port/PortTest.scala:
--------------------------------------------------------------------------------
1 | package fin.service.port
2 |
3 | import cats.effect._
4 | import weaver._
5 |
6 | import fin.service.book._
7 | import fin.service.collection._
8 | import fin.{Types, fixtures}
9 |
10 | object PortTest extends SimpleIOSuite {
11 |
12 | val client = fixtures.HTTPClient(
13 | fixtures.SeriesResponses
14 | .trilogy(fixtures.title1, fixtures.title2, fixtures.title3)
15 | )
16 | val books = List(fixtures.title1, fixtures.title2, fixtures.title3).map(t =>
17 | fixtures.emptyBook.copy(title = t, authors = List(fixtures.author))
18 | )
19 | val bookInfoService = new BookInfoServiceUsingTitles(books)
20 |
21 | test("foo".ignore) {
22 | for {
23 | colRef <- Ref.of[IO, List[Types.Collection]](List.empty)
24 | repo = new InMemoryCollectionRepository(colRef)
25 | _ <- repo.collections
26 | // _ <- new GoodreadsImport[IO](None, bookInfoService).importResource(
27 | // "./assets/sample_goodreads_export.csv",
28 | // None
29 | // )
30 | } yield success
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/finito/api/src/fin/BookConversions.scala:
--------------------------------------------------------------------------------
1 | package fin
2 |
3 | import fin.Types._
4 | import java.time.LocalDate
5 |
6 | object BookConversions {
7 |
8 | implicit class BookInputSyntax(book: BookInput) {
9 |
10 | def toUserBook(
11 | dateAdded: Option[LocalDate] = None,
12 | rating: Option[Int] = None,
13 | startedReading: Option[LocalDate] = None,
14 | lastRead: Option[LocalDate] = None,
15 | review: Option[String] = None
16 | ): UserBook =
17 | UserBook(
18 | book.title,
19 | book.authors,
20 | book.description,
21 | book.isbn,
22 | book.thumbnailUri,
23 | dateAdded,
24 | rating,
25 | startedReading,
26 | lastRead,
27 | review
28 | )
29 | }
30 |
31 | implicit class UserBookSyntax(userBook: UserBook) {
32 |
33 | def toBookInput: BookInput =
34 | BookInput(
35 | userBook.title,
36 | userBook.authors,
37 | userBook.description,
38 | userBook.isbn,
39 | userBook.thumbnailUri
40 | )
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 Laurence Warne
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/finito/main/resources/logback.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | %d{HH:mm:ss.SSS} %magenta([%thread]) %highlight(%-5level) %cyan(%logger{36}) - %msg%n
5 |
6 |
7 |
8 |
9 | ${HOME}/.config/libro-finito/logs/out.log
10 |
11 | %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n
12 |
13 |
14 |
15 | ${HOME}/.config/libro-finito/out-%i.log
16 | 1
17 | 10
18 |
19 |
20 |
21 | 2MB
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
--------------------------------------------------------------------------------
/finito/core/src/fin/service/port/ImportService.scala:
--------------------------------------------------------------------------------
1 | package fin.service.port
2 |
3 | import cats.MonadThrow
4 |
5 | import fin.Types._
6 |
7 | trait ImportService[F[_]] {
8 | def importResource(args: MutationImportArgs): F[ImportResult]
9 | }
10 |
11 | trait ApplicationImportService[F[_]] {
12 | def importResource(
13 | content: String,
14 | langRestrict: Option[String]
15 | ): F[ImportResult]
16 | }
17 |
18 | /** https://www.goodreads.com/review/import
19 | */
20 | class ImportServiceImpl[F[_]: MonadThrow](
21 | goodreadsImportService: ApplicationImportService[F]
22 | ) extends ImportService[F] {
23 |
24 | override def importResource(args: MutationImportArgs): F[ImportResult] =
25 | args.importType match {
26 | case PortType.Goodreads =>
27 | goodreadsImportService.importResource(args.content, args.langRestrict)
28 | case PortType.Finito =>
29 | MonadThrow[F].raiseError(
30 | new Exception("Finito import not yet supported")
31 | )
32 | }
33 | }
34 |
35 | object ImportServiceImpl {
36 | def apply[F[_]: MonadThrow](
37 | goodreadsImportService: ApplicationImportService[F]
38 | ): ImportServiceImpl[F] =
39 | new ImportServiceImpl(goodreadsImportService)
40 | }
41 |
--------------------------------------------------------------------------------
/bin/perf.py:
--------------------------------------------------------------------------------
1 | """
2 | 49e76b6: 0.5760772399999999
3 | b219a5e: 0.5664049800000002
4 | """
5 | import requests
6 |
7 | req = """query {{
8 | books(
9 | authorKeywords: {author_kw},
10 | titleKeywords: {title_kw},
11 | maxResults: 30
12 | ) {{
13 | authors title description isbn
14 | }}
15 | }}
16 | """
17 |
18 | SEARCHES = [
19 | ("tolkien", "lord"),
20 | ("tolkien", None),
21 | ("Gene Wolfe", None),
22 | ("sanderson", None),
23 | (None, "Emacs"),
24 | (None, "Python"),
25 | ("Dan Simmons", None),
26 | ]
27 |
28 |
29 | def perf_test(iterations=10, searches=SEARCHES, body_skeleton=req):
30 | total_time = 0
31 | for i in range(iterations):
32 | author_kw, title_kw = searches[i % len(searches)]
33 | body = body_skeleton.format(
34 | author_kw="null" if author_kw is None else "\"" + author_kw + "\"",
35 | title_kw="null" if title_kw is None else "\"" + title_kw + "\""
36 | )
37 | print(body)
38 | response = requests.post(
39 | "http://localhost:56848/api/graphql",
40 | json={"query": body},
41 | headers={
42 | "Content-Type": "application/json",
43 | "Accept": "application/json"
44 | }
45 | )
46 | total_time += response.elapsed.total_seconds()
47 | return total_time / iterations
48 |
--------------------------------------------------------------------------------
/finito/persistence/resources/db/migration/V1__create_database.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE IF NOT EXISTS books(
2 | isbn TEXT NOT NULL PRIMARY KEY,
3 | title TEXT NOT NULL,
4 | authors TEXT NOT NULL,
5 | description TEXT NOT NULL,
6 | thumbnail_uri TEXT NOT NULL,
7 | added DATE NOT NULL
8 | );
9 |
10 | CREATE TABLE IF NOT EXISTS collections(
11 | name TEXT NOT NULL PRIMARY KEY,
12 | preferred_sort TEXT COLLATE NOCASE NOT NULL,
13 | sort_ascending BOOLEAN NOT NULL
14 | );
15 |
16 | CREATE TABLE IF NOT EXISTS collection_books(
17 | collection_name TEXT NOT NULL,
18 | isbn TEXT NOT NULL,
19 | FOREIGN KEY(collection_name) REFERENCES collections(name) ON DELETE CASCADE,
20 | FOREIGN KEY(isbn) REFERENCES books(isbn),
21 | PRIMARY KEY(collection_name, isbn)
22 | );
23 |
24 | CREATE TABLE IF NOT EXISTS currently_reading_books(
25 | isbn TEXT NOT NULL PRIMARY KEY,
26 | started DATE NOT NULL,
27 | FOREIGN KEY(isbn) REFERENCES books(isbn)
28 | );
29 |
30 | CREATE TABLE IF NOT EXISTS read_books(
31 | isbn TEXT NOT NULL,
32 | started DATE,
33 | finished DATE NOT NULL,
34 | FOREIGN KEY(isbn) REFERENCES books(isbn),
35 | PRIMARY KEY(isbn, started)
36 | );
37 |
38 | CREATE TABLE IF NOT EXISTS rated_books(
39 | isbn TEXT NOT NULL PRIMARY KEY,
40 | rating INTEGER NOT NULL,
41 | FOREIGN KEY(isbn) REFERENCES books(isbn)
42 | );
43 |
--------------------------------------------------------------------------------
/finito/persistence/test/src/fin/persistence/SqliteSuite.scala:
--------------------------------------------------------------------------------
1 | package fin.persistence
2 |
3 | import cats.effect.{IO, Resource}
4 | import cats.implicits._
5 | import doobie._
6 | import doobie.implicits._
7 | import fs2.io.file._
8 | import org.typelevel.log4cats.Logger
9 | import org.typelevel.log4cats.slf4j.Slf4jLogger
10 | import weaver._
11 |
12 | trait SqliteSuite extends IOSuite {
13 |
14 | val dbFile = Path(".").normalize.absolute / "tmp.db"
15 | val (uri, user, password) = (show"jdbc:sqlite:$dbFile", "", "")
16 |
17 | implicit val logger: Logger[IO] = Slf4jLogger.getLogger
18 | def transactor: Resource[IO, Transactor[IO]] =
19 | TransactorSetup.sqliteTransactor[IO](uri)
20 |
21 | // We can't use the in memory db since that is killed whenever no connections
22 | // exist
23 | val deleteDb: IO[Unit] = Files[IO].delete(dbFile)
24 |
25 | override type Res = Transactor[IO]
26 | override def sharedResource: Resource[IO, Transactor[IO]] =
27 | Resource.make(
28 | FlywaySetup.init[IO](
29 | uri,
30 | user,
31 | password
32 | )
33 | )(_ => deleteDb) *> transactor
34 |
35 | // See https://www.sqlite.org/faq.html#q5 of why generally it's a bad idea
36 | // to run sqlite writes in parallel
37 | override def maxParallelism = 1
38 |
39 | def testDoobie(name: String)(block: => ConnectionIO[Expectations]) =
40 | test(name)(xa => block.transact(xa))
41 | }
42 |
--------------------------------------------------------------------------------
/finito/core/src/fin/service/search/BookInfoAugmentationService.scala:
--------------------------------------------------------------------------------
1 | package fin.service.search
2 |
3 | import cats.implicits._
4 | import cats.{Monad, ~>}
5 |
6 | import fin.Types._
7 | import fin.persistence.BookRepository
8 |
9 | class BookInfoAugmentationService[F[_]: Monad, G[_]] private (
10 | wrappedInfoService: BookInfoService[F],
11 | bookRepo: BookRepository[G],
12 | transact: G ~> F
13 | ) extends BookInfoService[F] {
14 |
15 | override def search(
16 | booksArgs: QueryBooksArgs
17 | ): F[List[UserBook]] =
18 | for {
19 | searchResult <- wrappedInfoService.search(booksArgs)
20 | userBooks <- transact(
21 | bookRepo.retrieveMultipleBooks(searchResult.map(_.isbn))
22 | )
23 | } yield searchResult.map(book =>
24 | userBooks.find(_.isbn === book.isbn).getOrElse(book)
25 | )
26 |
27 | override def fromIsbn(bookArgs: QueryBookArgs): F[List[UserBook]] =
28 | for {
29 | matchingBooks <- wrappedInfoService.fromIsbn(bookArgs)
30 | userBooks <- transact(
31 | bookRepo.retrieveMultipleBooks(matchingBooks.map(_.isbn))
32 | )
33 | } yield matchingBooks.map(book =>
34 | userBooks.find(_.isbn === book.isbn).getOrElse(book)
35 | )
36 | }
37 |
38 | object BookInfoAugmentationService {
39 | def apply[F[_]: Monad, G[_]](
40 | wrappedInfoService: BookInfoService[F],
41 | bookRepo: BookRepository[G],
42 | transact: G ~> F
43 | ) =
44 | new BookInfoAugmentationService[F, G](
45 | wrappedInfoService,
46 | bookRepo,
47 | transact
48 | )
49 | }
50 |
--------------------------------------------------------------------------------
/finito/api/src/fin/implicits.scala:
--------------------------------------------------------------------------------
1 | package fin
2 |
3 | import cats.syntax.all._
4 | import cats.kernel.Eq
5 | import cats.Show
6 | import io.circe._
7 | import io.circe.generic.semiauto._
8 |
9 | import fin.Types._
10 |
11 | object implicits {
12 | implicit val collectionEq: Eq[Collection] = Eq.fromUniversalEquals
13 | implicit val bookEq: Eq[BookInput] = Eq.fromUniversalEquals
14 | implicit val userBookEq: Eq[UserBook] = Eq.fromUniversalEquals
15 | implicit val sortEq: Eq[Sort] = Eq.fromUniversalEquals
16 | implicit val summaryEq: Eq[Summary] = Eq.fromUniversalEquals
17 |
18 | implicit val collectionShow: Show[Collection] = Show.fromToString
19 | implicit val userBookShow: Show[BookInput] = Show.fromToString
20 | implicit val bookShow: Show[UserBook] = Show.fromToString
21 | implicit val sortShow: Show[Sort] = Show.fromToString
22 | implicit val summaryShow: Show[Summary] = s =>
23 | s.copy(montage = "").toString
24 |
25 | implicit val userBookEncoder: Encoder[UserBook] = deriveEncoder
26 | implicit val pageInfoEncoder: Encoder[PageInfo] = deriveEncoder
27 | implicit val sortTypeEncoder: Encoder[SortType] = deriveEncoder
28 | implicit val sortEncoder: Encoder[Sort] = deriveEncoder
29 | implicit val collectionEncoder: Encoder[Collection] = deriveEncoder
30 |
31 | implicit val sortTypeDecoder: Decoder[SortType] = Decoder[String].emap { s =>
32 | SortConversions.fromString(s).leftMap(_ => show"Invalid sort type: '$s'")
33 | }
34 | implicit val sortDecoder: Decoder[Sort] = deriveDecoder
35 | }
36 |
--------------------------------------------------------------------------------
/finito/core/src/fin/service/summary/SummaryServiceImpl.scala:
--------------------------------------------------------------------------------
1 | package fin.service.summary
2 |
3 | import cats.effect._
4 | import cats.implicits._
5 | import cats.~>
6 |
7 | import fin.Types._
8 | import fin.persistence.{BookRepository, Dates}
9 |
10 | class SummaryServiceImpl[F[_]: Async, G[_]] private (
11 | bookRepo: BookRepository[G],
12 | montageService: MontageService[F],
13 | clock: Clock[F],
14 | transact: G ~> F
15 | ) extends SummaryService[F] {
16 |
17 | override def summary(args: QuerySummaryArgs): F[Summary] =
18 | for {
19 | currentDate <- Async[F].memoize(Dates.currentDate(clock))
20 | from <- args.from.fold(currentDate.map(_.withDayOfYear(1)))(_.pure[F])
21 | to <- args.to.fold(currentDate)(_.pure[F])
22 | books <- transact(bookRepo.retrieveBooksInside(from, to))
23 | readBooks = books.filter(_.lastRead.nonEmpty)
24 | ratingAvg = mean(books.flatMap(_.rating.toList))
25 | montage <- montageService.montage(
26 | if (args.includeAdded) books else readBooks,
27 | args.montageInput
28 | )
29 | } yield Summary(readBooks.length, books.length, ratingAvg, montage)
30 |
31 | private def mean(ls: List[Int]): Float =
32 | if (ls.isEmpty) 0f else (ls.sum / ls.size).toFloat
33 | }
34 |
35 | object SummaryServiceImpl {
36 | def apply[F[_]: Async, G[_]](
37 | bookRepo: BookRepository[G],
38 | montageService: MontageService[F],
39 | clock: Clock[F],
40 | transact: G ~> F
41 | ) =
42 | new SummaryServiceImpl[F, G](
43 | bookRepo,
44 | montageService,
45 | clock,
46 | transact
47 | )
48 | }
49 |
--------------------------------------------------------------------------------
/finito/core/src/fin/service/search/GoogleBooksAPIDecoding.scala:
--------------------------------------------------------------------------------
1 | package fin.service.search
2 |
3 | import cats.implicits._
4 | import io.circe._
5 | import io.circe.generic.semiauto._
6 |
7 | object GoogleBooksAPIDecoding {
8 |
9 | implicit val googleIsbnInfoDecoder: Decoder[GoogleIsbnInfo] =
10 | deriveDecoder[GoogleIsbnInfo]
11 |
12 | implicit val googleImageLinksDecoder: Decoder[GoogleImageLinks] =
13 | deriveDecoder[GoogleImageLinks]
14 |
15 | implicit val googleBookItemDecoder: Decoder[GoogleBookItem] =
16 | deriveDecoder[GoogleBookItem]
17 |
18 | implicit val googleVolumeDecoder: Decoder[GoogleVolume] =
19 | deriveDecoder[GoogleVolume]
20 |
21 | implicit val googleResponseDecoder: Decoder[GoogleResponse] =
22 | deriveDecoder[GoogleResponse]
23 |
24 | val fieldsSelector =
25 | "items/volumeInfo(title,authors,description,imageLinks,industryIdentifiers)"
26 | }
27 |
28 | final case class GoogleResponse(items: Option[List[GoogleVolume]])
29 |
30 | final case class GoogleVolume(volumeInfo: GoogleBookItem)
31 |
32 | final case class GoogleBookItem(
33 | // These are optional... because the API sometimes decides not to return them...
34 | title: Option[String],
35 | authors: Option[List[String]],
36 | description: Option[String],
37 | imageLinks: Option[GoogleImageLinks],
38 | industryIdentifiers: Option[List[GoogleIsbnInfo]]
39 | )
40 |
41 | final case class GoogleImageLinks(
42 | smallThumbnail: String,
43 | thumbnail: String
44 | )
45 |
46 | final case class GoogleIsbnInfo(
47 | `type`: String,
48 | identifier: String
49 | ) {
50 | def getIsbn13: String =
51 | if (identifier.length === 10) "978" + identifier else identifier
52 | }
53 |
--------------------------------------------------------------------------------
/finito/core/src/fin/service/port/GoodreadsExportService.scala:
--------------------------------------------------------------------------------
1 | package fin.service.port
2 |
3 | import cats.effect.kernel.Async
4 | import cats.implicits._
5 |
6 | import fin.DefaultCollectionNotSupportedError
7 | import fin.Types._
8 | import fin.service.collection._
9 |
10 | class GoodreadsExportService[F[_]: Async](
11 | maybeDefaultCollection: Option[String],
12 | collectionService: CollectionService[F]
13 | ) extends CollectionExportService[F] {
14 |
15 | private val firstRow =
16 | "Title, Author, ISBN, My Rating, Average Rating, Publisher, Binding, Year Published, Original Publication Year, Date Read, Date Added, Bookshelves, My Review"
17 |
18 | override def exportCollection(exportArgs: QueryExportArgs): F[String] = {
19 | for {
20 | collection <- Async[F].fromOption(
21 | exportArgs.collection.orElse(maybeDefaultCollection),
22 | DefaultCollectionNotSupportedError
23 | )
24 | collection <-
25 | collectionService.collection(QueryCollectionArgs(collection, None))
26 | rows = collection.books.map { book =>
27 | show"""|${book.title.replaceAll(",", "")},
28 | |${book.authors.mkString(" ")},
29 | |${book.isbn},
30 | |${book.rating.fold("")(_.toString)},,,,,,
31 | |${book.lastRead.fold("")(_.toString)},
32 | |${book.dateAdded.fold("")(_.toString)},,""".stripMargin
33 | .replace("\n", "")
34 | }
35 | } yield (firstRow :: rows).mkString("\n")
36 | }
37 | }
38 |
39 | object GoodreadsExportService {
40 | def apply[F[_]: Async](
41 | maybeDefaultCollection: Option[String],
42 | collectionService: CollectionService[F]
43 | ) = new GoodreadsExportService[F](maybeDefaultCollection, collectionService)
44 | }
45 |
--------------------------------------------------------------------------------
/mill:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 |
3 | # This is a wrapper script, that automatically download mill from GitHub release pages
4 | # You can give the required mill version with MILL_VERSION env variable
5 | # If no version is given, it falls back to the value of DEFAULT_MILL_VERSION
6 | DEFAULT_MILL_VERSION=0.10.0
7 |
8 | set -e
9 |
10 | if [ -z "$MILL_VERSION" ] ; then
11 | if [ -f ".mill-version" ] ; then
12 | MILL_VERSION="$(head -n 1 .mill-version 2> /dev/null)"
13 | elif [ -f "mill" ] && [ "$0" != "mill" ] ; then
14 | MILL_VERSION=$(grep -F "DEFAULT_MILL_VERSION=" "mill" | head -n 1 | cut -d= -f2)
15 | else
16 | MILL_VERSION=$DEFAULT_MILL_VERSION
17 | fi
18 | fi
19 |
20 | if [ "x${XDG_CACHE_HOME}" != "x" ] ; then
21 | MILL_DOWNLOAD_PATH="${XDG_CACHE_HOME}/mill/download"
22 | else
23 | MILL_DOWNLOAD_PATH="${HOME}/.cache/mill/download"
24 | fi
25 | MILL_EXEC_PATH="${MILL_DOWNLOAD_PATH}/${MILL_VERSION}"
26 |
27 | version_remainder="$MILL_VERSION"
28 | MILL_MAJOR_VERSION="${version_remainder%%.*}"; version_remainder="${version_remainder#*.}"
29 | MILL_MINOR_VERSION="${version_remainder%%.*}"; version_remainder="${version_remainder#*.}"
30 |
31 | if [ ! -s "$MILL_EXEC_PATH" ] ; then
32 | mkdir -p "$MILL_DOWNLOAD_PATH"
33 | if [ "$MILL_MAJOR_VERSION" -gt 0 ] || [ "$MILL_MINOR_VERSION" -ge 5 ] ; then
34 | ASSEMBLY="-assembly"
35 | fi
36 | DOWNLOAD_FILE=$MILL_EXEC_PATH-tmp-download
37 | MILL_VERSION_TAG=$(echo $MILL_VERSION | sed -E 's/([^-]+)(-M[0-9]+)?(-.*)?/\1\2/')
38 | MILL_DOWNLOAD_URL="https://github.com/lihaoyi/mill/releases/download/${MILL_VERSION_TAG}/$MILL_VERSION${ASSEMBLY}"
39 | curl --fail -L -o "$DOWNLOAD_FILE" "$MILL_DOWNLOAD_URL"
40 | chmod +x "$DOWNLOAD_FILE"
41 | mv "$DOWNLOAD_FILE" "$MILL_EXEC_PATH"
42 | unset DOWNLOAD_FILE
43 | unset MILL_DOWNLOAD_URL
44 | fi
45 |
46 | unset MILL_DOWNLOAD_PATH
47 | unset MILL_VERSION
48 |
49 | exec $MILL_EXEC_PATH "$@"
50 |
--------------------------------------------------------------------------------
/.github/workflows/actions.yml:
--------------------------------------------------------------------------------
1 | name: ci
2 |
3 | on:
4 | push:
5 | pull_request:
6 | branches:
7 | - master
8 |
9 | jobs:
10 | run:
11 | runs-on: ubuntu-latest
12 | steps:
13 | - uses: actions/checkout@v4
14 |
15 | # https://github.com/actions/setup-java
16 | - uses: actions/setup-java@v4
17 | with:
18 | distribution: temurin
19 | java-version: 11
20 |
21 | - name: Compile
22 | run: ./mill __.compile
23 |
24 | - name: Check Formatting
25 | run: ./mill __.checkFormat
26 |
27 | - name: Scalafix
28 | run: ./mill __.fix --check
29 |
30 | - name: Create docker Image
31 | run: ./mill finito.main.docker.build
32 |
33 | - name: Run tests
34 | run: ./mill __.test
35 |
36 | ##################
37 | # Coverage Stuff #
38 | ##################
39 | # https://github.com/codecov/codecov-action
40 | - name: Generate Coverage Reports
41 | run: ./mill scoverage.xmlReportAll
42 |
43 | - name: Send Coverage Reports
44 | uses: codecov/codecov-action@v5
45 | with:
46 | files: out/scoverage/xmlReportAll.dest/scoverage.xml
47 | flags: unittests # optional
48 | fail_ci_if_error: true # optional (default = false)
49 | env:
50 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
51 |
52 | ####################
53 | # GH Release Stuff #
54 | ####################
55 | - name: Assembly
56 | if: startsWith(github.ref, 'refs/tags/')
57 | run: ./mill finito.main.assembly
58 |
59 | # https://github.com/softprops/action-gh-release
60 | - name: Release
61 | uses: softprops/action-gh-release@v2
62 | if: startsWith(github.ref, 'refs/tags/')
63 | with:
64 | files: |
65 | out/finito/main/assembly.dest/*.jar
66 | env:
67 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
68 |
--------------------------------------------------------------------------------
/plugins/jmh.sc:
--------------------------------------------------------------------------------
1 | import mill._, scalalib._, modules._
2 |
3 | trait Jmh extends ScalaModule {
4 |
5 | def ivyDeps = super.ivyDeps() ++ Agg(ivy"org.openjdk.jmh:jmh-core:1.19")
6 |
7 | def runJmh(args: String*) =
8 | T.command {
9 | val (_, resources) = generateBenchmarkSources()
10 | Jvm.runSubprocess(
11 | "org.openjdk.jmh.Main",
12 | classPath = (runClasspath() ++ generatorDeps()).map(_.path) ++
13 | Seq(compileGeneratedSources().path, resources),
14 | mainArgs = args,
15 | workingDir = T.ctx.dest
16 | )
17 | }
18 |
19 | def compileGeneratedSources =
20 | T {
21 | val dest = T.ctx.dest
22 | val (sourcesDir, _) = generateBenchmarkSources()
23 | val sources = os.walk(sourcesDir).filter(os.isFile)
24 | os.proc(
25 | "javac",
26 | sources.map(_.toString),
27 | "-cp",
28 | (runClasspath() ++ generatorDeps()).map(_.path.toString).mkString(":"),
29 | "-d",
30 | dest
31 | ).call(dest)
32 | PathRef(dest)
33 | }
34 |
35 | // returns sources and resources directories
36 | def generateBenchmarkSources =
37 | T {
38 | val dest = T.ctx.dest
39 |
40 | val sourcesDir = dest / "jmh_sources"
41 | val resourcesDir = dest / "jmh_resources"
42 |
43 | os.remove.all(sourcesDir)
44 | os.makeDir.all(sourcesDir)
45 | os.remove.all(resourcesDir)
46 | os.makeDir.all(resourcesDir)
47 |
48 | Jvm.runSubprocess(
49 | "org.openjdk.jmh.generators.bytecode.JmhBytecodeGenerator",
50 | (runClasspath() ++ generatorDeps()).map(_.path),
51 | mainArgs = Array(
52 | compile().classes.path,
53 | sourcesDir,
54 | resourcesDir,
55 | "default"
56 | ).map(_.toString)
57 | )
58 |
59 | (sourcesDir, resourcesDir)
60 | }
61 |
62 | def generatorDeps =
63 | resolveDeps(
64 | T { Agg(ivy"org.openjdk.jmh:jmh-generator-bytecode:1.19") }
65 | )
66 | }
67 |
--------------------------------------------------------------------------------
/finito/core/test/src/fin/service/summary/SummaryServiceImplTest.scala:
--------------------------------------------------------------------------------
1 | package fin.service.summary
2 |
3 | import cats.arrow.FunctionK
4 | import cats.effect._
5 | import cats.implicits._
6 | import org.typelevel.log4cats.Logger
7 | import org.typelevel.log4cats.slf4j.Slf4jLogger
8 | import weaver._
9 |
10 | import fin.Types._
11 | import fin.fixtures
12 | import fin.persistence.BookRepository
13 | import fin.service.book.InMemoryBookRepository
14 |
15 | object SummaryServiceImplTest extends IOSuite {
16 |
17 | val imgUri =
18 | "https://user-images.githubusercontent.com/17688577/144673930-add9233d-9308-4972-8043-2f519d808874.png"
19 | val (imgWidth, imgHeight) = (128, 195)
20 |
21 | implicit def unsafeLogger: Logger[IO] = Slf4jLogger.getLogger
22 |
23 | override type Res = (BookRepository[IO], SummaryService[IO])
24 | override def sharedResource
25 | : Resource[IO, (BookRepository[IO], SummaryService[IO])] =
26 | Resource.eval(Ref.of[IO, List[UserBook]](List.empty).map { ref =>
27 | val repo = new InMemoryBookRepository(ref)
28 | val montageService = BufferedImageMontageService[IO]
29 | (
30 | repo,
31 | SummaryServiceImpl[IO, IO](
32 | repo,
33 | montageService,
34 | fixtures.clock,
35 | FunctionK.id[IO]
36 | )
37 | )
38 | })
39 |
40 | test("summary has correct number of books added") {
41 | case (repo, summaryService) =>
42 | val noImages = 16
43 | for {
44 | _ <- (1 to noImages).toList.traverse { idx =>
45 | repo.createBook(
46 | fixtures.bookInput.copy(
47 | title = show"book-$idx",
48 | isbn = show"isbn-$idx",
49 | thumbnailUri = imgUri
50 | ),
51 | fixtures.date.plusDays(idx.toLong)
52 | )
53 | }
54 | summary <- summaryService.summary(
55 | QuerySummaryArgs(
56 | fixtures.date.some,
57 | fixtures.date.plusYears(1).some,
58 | None,
59 | true
60 | )
61 | )
62 | } yield expect(summary.added == noImages)
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/finito/core/src/fin/service/collection/SBindings.scala:
--------------------------------------------------------------------------------
1 | package fin.service.collection
2 |
3 | import java.util.HashMap
4 | import javax.script.{Bindings, SimpleBindings}
5 |
6 | import scala.jdk.CollectionConverters._
7 |
8 | import cats.kernel.Monoid
9 | import cats.syntax.all._
10 | import cats.{Contravariant, Show}
11 |
12 | import fin.Types._
13 |
14 | final case class SBindings(bindings: Map[String, Object]) {
15 | def asJava: Bindings = {
16 | val javaBindings = new SimpleBindings(new HashMap(bindings.asJava))
17 | javaBindings
18 | }
19 | }
20 |
21 | object SBindings {
22 |
23 | val empty = SBindings(Map.empty)
24 |
25 | implicit val sBindingsMonoid: Monoid[SBindings] = new Monoid[SBindings] {
26 | override def empty: SBindings = SBindings.empty
27 | override def combine(x: SBindings, y: SBindings): SBindings =
28 | SBindings(x.bindings ++ y.bindings)
29 | }
30 | }
31 |
32 | trait Bindable[-T] {
33 | def asBindings(b: T): SBindings
34 | }
35 |
36 | object Bindable {
37 | def apply[T](implicit b: Bindable[T]): Bindable[T] = b
38 |
39 | def asBindings[T: Bindable](b: T): SBindings = Bindable[T].asBindings(b)
40 |
41 | implicit class BindableOps[T: Bindable](b: T) {
42 | def asBindings: SBindings = Bindable[T].asBindings(b)
43 | }
44 |
45 | implicit val bindableContravariant: Contravariant[Bindable] =
46 | new Contravariant[Bindable] {
47 | def contramap[A, B](fa: Bindable[A])(f: B => A): Bindable[B] =
48 | b => fa.asBindings(f(b))
49 | }
50 |
51 | implicit def mapAnyRefBindable[K: Show, T <: AnyRef]: Bindable[Map[K, T]] =
52 | m => SBindings(m.map(t => t.leftMap(_.show)))
53 |
54 | implicit def mapShowBindable[K: Show]: Bindable[Map[K, Int]] =
55 | Bindable[Map[String, Object]].contramap(mp =>
56 | mp.map(t => t.bimap(_.show, x => x: java.lang.Integer)).toMap
57 | )
58 |
59 | implicit val collectionBindable: Bindable[Collection] =
60 | Bindable[Map[String, String]].contramap { c =>
61 | Map("collection" -> c.name, "sort" -> c.preferredSort.toString)
62 | }
63 |
64 | implicit val bookBindable: Bindable[BookInput] =
65 | Bindable[Map[String, AnyRef]].contramap { b =>
66 | Map("title" -> b.title, "isbn" -> b.isbn, "authors" -> b.authors)
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/finito/main/src/fin/config/ServiceConfig.scala:
--------------------------------------------------------------------------------
1 | package fin.config
2 |
3 | import cats.Show
4 | import cats.kernel.Eq
5 |
6 | import fin.service.collection._
7 |
8 | final case class ServiceConfig(
9 | databaseUser: String,
10 | databasePassword: String,
11 | host: String,
12 | port: Int,
13 | defaultCollection: Option[String],
14 | specialCollections: List[SpecialCollection]
15 | )
16 |
17 | object ServiceConfig {
18 | implicit val serviceConfigEq: Eq[ServiceConfig] =
19 | Eq.fromUniversalEquals[ServiceConfig]
20 | implicit val serviceConfigShow: Show[ServiceConfig] =
21 | Show.fromToString[ServiceConfig]
22 |
23 | val defaultDatabaseUser: String = ""
24 | val defaultDatabasePassword: String = ""
25 | val defaultHost: String = "127.0.0.1"
26 | val defaultPort: Int = 56848
27 | val defaultDefaultCollection: String = "My Books"
28 | val defaultSpecialCollections: List[SpecialCollection] = List(
29 | SpecialCollection(
30 | name = "My Books",
31 | `lazy` = Some(false),
32 | addHook = Some("add = true"),
33 | readStartedHook = Some("add = true"),
34 | readCompletedHook = Some("add = true"),
35 | rateHook = Some("add = true"),
36 | preferredSort = None
37 | ),
38 | SpecialCollection(
39 | name = "Currently Reading",
40 | `lazy` = Some(true),
41 | addHook = None,
42 | readStartedHook = Some("add = true"),
43 | readCompletedHook = Some("remove = true"),
44 | rateHook = None,
45 | preferredSort = None
46 | ),
47 | SpecialCollection(
48 | name = "Read",
49 | `lazy` = Some(true),
50 | addHook = None,
51 | readStartedHook = None,
52 | readCompletedHook = Some("add = true"),
53 | rateHook = None,
54 | preferredSort = None
55 | ),
56 | SpecialCollection(
57 | name = "Favourites",
58 | `lazy` = Some(true),
59 | addHook = None,
60 | readStartedHook = None,
61 | readCompletedHook = None,
62 | rateHook = Some("""
63 | |if(rating >= 5) then
64 | | add = true
65 | |else
66 | | remove = true
67 | |end""".stripMargin),
68 | preferredSort = None
69 | )
70 | )
71 | }
72 |
--------------------------------------------------------------------------------
/finito/core/src/fin/service/collection/HookExecutionService.scala:
--------------------------------------------------------------------------------
1 | package fin.service.collection
2 |
3 | import javax.script._
4 |
5 | import scala.util.Try
6 |
7 | import cats.effect.Sync
8 | import cats.implicits._
9 | import org.luaj.vm2.LuaBoolean
10 | import org.typelevel.log4cats.Logger
11 |
12 | import fin.Types._
13 |
14 | trait HookExecutionService[F[_]] {
15 | def processHooks(
16 | hooks: List[CollectionHook],
17 | additionalBindings: SBindings,
18 | book: BookInput
19 | ): F[List[(CollectionHook, ProcessResult)]]
20 | }
21 |
22 | class HookExecutionServiceImpl[F[_]: Sync: Logger] private ()
23 | extends HookExecutionService[F] {
24 |
25 | private val scriptEngineManager: ScriptEngineManager = new ScriptEngineManager
26 |
27 | override def processHooks(
28 | collectionHooks: List[CollectionHook],
29 | additionalBindings: SBindings,
30 | book: BookInput
31 | ): F[List[(CollectionHook, ProcessResult)]] =
32 | for {
33 | engine <- Sync[F].delay(scriptEngineManager.getEngineByName("luaj"))
34 | results <-
35 | collectionHooks
36 | .traverse { hook =>
37 | processHook(hook, engine, additionalBindings)
38 | .tupleLeft(hook)
39 | .flatTap { case (hook, result) =>
40 | Logger[F].debug(
41 | s"Hook for ${hook.collection} ran with result $result"
42 | )
43 | }
44 |
45 | }
46 | } yield results.collect { case (hook, Some(result)) => (hook, result) }
47 |
48 | def processHook(
49 | hook: CollectionHook,
50 | engine: ScriptEngine,
51 | bindings: SBindings
52 | ): F[Option[ProcessResult]] = {
53 | val allBindings = bindings.asJava
54 | for {
55 | _ <- Sync[F].delay(engine.eval(hook.code, allBindings))
56 | addStr <- Sync[F].delay(allBindings.get("add"))
57 | rmStr <- Sync[F].delay(allBindings.get("remove"))
58 | maybeAdd = Try(
59 | Option(addStr.asInstanceOf[LuaBoolean])
60 | ).toOption.flatten.map(_.booleanValue)
61 | maybeRemove = Try(
62 | Option(rmStr.asInstanceOf[LuaBoolean])
63 | ).toOption.flatten.map(_.booleanValue)
64 | } yield maybeAdd
65 | .collect { case true => ProcessResult.Add }
66 | .orElse(maybeRemove.collect { case true => ProcessResult.Remove })
67 | }
68 | }
69 |
70 | object HookExecutionServiceImpl {
71 | def apply[F[_]: Sync: Logger] = new HookExecutionServiceImpl[F]
72 | }
73 |
74 | sealed trait ProcessResult extends Product with Serializable
75 |
76 | object ProcessResult {
77 | case object Add extends ProcessResult
78 | case object Remove extends ProcessResult
79 | }
80 |
--------------------------------------------------------------------------------
/finito/core/test/src/fin/service/book/WikidataSeriesInfoServiceTest.scala:
--------------------------------------------------------------------------------
1 | package fin.service.book
2 |
3 | import cats.effect._
4 | import cats.implicits._
5 | import org.typelevel.log4cats.Logger
6 | import org.typelevel.log4cats.slf4j.Slf4jLogger
7 | import weaver._
8 |
9 | import fin.Types._
10 | import fin.fixtures
11 |
12 | object WikidataSeriesInfoServiceTest extends SimpleIOSuite {
13 |
14 | implicit def unsafeLogger: Logger[IO] = Slf4jLogger.getLogger
15 |
16 | test("series returns correct response") {
17 | val client =
18 | fixtures.HTTPClient(
19 | fixtures.SeriesResponses
20 | .trilogy(fixtures.title1, fixtures.title2, fixtures.title3)
21 | )
22 | val books = List(fixtures.title1, fixtures.title2, fixtures.title3).map(t =>
23 | fixtures.emptyBook.copy(title = t, authors = List(fixtures.author))
24 | )
25 | val bookInfoService = new BookInfoServiceUsingTitles(books)
26 | val service = WikidataSeriesInfoService(client, bookInfoService)
27 | for {
28 | response <-
29 | service
30 | .series(
31 | QuerySeriesArgs(
32 | BookInput(fixtures.title1, List(fixtures.author), "", "", "")
33 | )
34 | )
35 | } yield expect(response.toSet === books.toSet)
36 | }
37 |
38 | test("series skips book when not found by book info service") {
39 | val client =
40 | fixtures.HTTPClient(
41 | fixtures.SeriesResponses
42 | .trilogy(fixtures.title1, fixtures.title2, fixtures.title3)
43 | )
44 | val books = List(fixtures.title1, fixtures.title3).map(t =>
45 | fixtures.emptyBook.copy(title = t, authors = List(fixtures.author))
46 | )
47 | val bookInfoService = new BookInfoServiceUsingTitles(books)
48 | val service = WikidataSeriesInfoService(client, bookInfoService)
49 | for {
50 | response <-
51 | service
52 | .series(
53 | QuerySeriesArgs(
54 | BookInput(fixtures.title1, List(fixtures.author), "", "", "")
55 | )
56 | )
57 | } yield expect(response.toSet === books.toSet)
58 | }
59 |
60 | test("series returns error when ordinal not integral") {
61 | val client = fixtures.HTTPClient(fixtures.SeriesResponses.badOrdinal)
62 | val bookInfoService = new BookInfoServiceUsingTitles(List.empty)
63 | val service = WikidataSeriesInfoService(client, bookInfoService)
64 | for {
65 | response <-
66 | service
67 | .series(
68 | QuerySeriesArgs(BookInput("", List(fixtures.author), "", "", ""))
69 | )
70 | .attempt
71 | } yield expect(response.isLeft)
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/finito/core/test/src/fin/service/search/BookInfoAugmentationServiceTest.scala:
--------------------------------------------------------------------------------
1 | package fin.service.search
2 |
3 | import java.time.LocalDate
4 |
5 | import cats.arrow.FunctionK
6 | import cats.effect.{Ref, _}
7 | import cats.implicits._
8 | import weaver._
9 |
10 | import fin.BookConversions._
11 | import fin.Types._
12 | import fin.implicits._
13 | import fin.service.book.InMemoryBookRepository
14 |
15 | object BookInfoAugmentationServiceTest extends SimpleIOSuite {
16 |
17 | val date = LocalDate.of(2021, 5, 22)
18 | val baseBook = BookInput("title", List("author"), "my desc", "isbn", "uri")
19 | val repo =
20 | new InMemoryBookRepository[IO](Ref.unsafe[IO, List[UserBook]](List.empty))
21 |
22 | test("search is augemented with data") {
23 | val book1 = baseBook.copy(isbn = "isbn for search #1")
24 | val book2 = baseBook.copy(isbn = "isbn for search #2")
25 | val service =
26 | BookInfoAugmentationService[IO, IO](
27 | new MockedInfoService(book2.toUserBook()),
28 | repo,
29 | FunctionK.id[IO]
30 | )
31 | val rating = 4
32 | for {
33 | _ <- repo.createBook(book1, date)
34 | _ <- repo.createBook(book2, date)
35 | _ <- repo.rateBook(book2, rating)
36 | _ <- repo.startReading(book2, date)
37 | response <- service.search(QueryBooksArgs(None, None, None, None))
38 | } yield expect(
39 | response === List(
40 | book2.toUserBook(
41 | dateAdded = date.some,
42 | rating = rating.some,
43 | startedReading = date.some
44 | )
45 | )
46 | )
47 | }
48 |
49 | test("fromIsbn is augmented with data") {
50 | val book = baseBook.copy(isbn = "isbn for fromIsbn")
51 | val service =
52 | BookInfoAugmentationService[IO, IO](
53 | new MockedInfoService(book.toUserBook()),
54 | repo,
55 | FunctionK.id[IO]
56 | )
57 | val rating = 4
58 | for {
59 | _ <- repo.createBook(book, date)
60 | _ <- repo.rateBook(book, rating)
61 | _ <- repo.startReading(book, date)
62 | bookResponse <- service.fromIsbn(QueryBookArgs(book.isbn, None))
63 | } yield expect(
64 | bookResponse === List(
65 | book.toUserBook(
66 | dateAdded = date.some,
67 | rating = rating.some,
68 | startedReading = date.some
69 | )
70 | )
71 | )
72 | }
73 | }
74 |
75 | class MockedInfoService(book: UserBook) extends BookInfoService[IO] {
76 |
77 | override def search(booksArgs: QueryBooksArgs): IO[List[UserBook]] =
78 | List(book).pure[IO]
79 |
80 | override def fromIsbn(bookArgs: QueryBookArgs): IO[List[UserBook]] =
81 | List(book).pure[IO]
82 | }
83 |
--------------------------------------------------------------------------------
/finito/main/it/src/fin/FinitoFilesTest.scala:
--------------------------------------------------------------------------------
1 | package fin
2 |
3 | import scala.collection.immutable
4 |
5 | import cats.effect._
6 | import cats.effect.std.Env
7 | import cats.implicits._
8 | import fs2._
9 | import fs2.io.file._
10 | import org.typelevel.log4cats.Logger
11 | import org.typelevel.log4cats.slf4j.Slf4jLogger
12 | import weaver._
13 |
14 | import fin.config.ServiceConfig
15 |
16 | object FinitoFilesTest extends SimpleIOSuite {
17 |
18 | implicit val logger: Logger[IO] = Slf4jLogger.getLogger[IO]
19 | val testDir = Path("./out/conf-test").normalize.absolute
20 | val configPath = testDir / "libro-finito"
21 |
22 | override def maxParallelism = 1
23 |
24 | val testEnv = new Env[IO] {
25 | private val mp = Map("XDG_CONFIG_HOME" -> testDir.toString)
26 | override def get(name: String): IO[Option[String]] = IO.pure(mp.get(name))
27 | override def entries: IO[immutable.Iterable[(String, String)]] =
28 | IO.pure(mp.toList)
29 | }
30 |
31 | test("creates config directory if not exists") {
32 | for {
33 | _ <- Files[IO].deleteRecursively(testDir).recover {
34 | case _: NoSuchFileException => ()
35 | }
36 | _ <- FinitoFiles.config(testEnv)
37 | exists <- Files[IO].exists(testDir)
38 | _ <- Files[IO].deleteRecursively(testDir)
39 | } yield expect(exists)
40 | }
41 |
42 | test("no error if config directory already exists") {
43 | for {
44 | _ <- Files[IO].deleteRecursively(testDir).recover {
45 | case _: NoSuchFileException => ()
46 | }
47 | _ <- FinitoFiles.config(testEnv)
48 | _ <- Files[IO].deleteRecursively(testDir)
49 | } yield success
50 | }
51 |
52 | test("config file respected if it exists") {
53 | val port = 1337
54 | val defaultCollection = "Bookies"
55 | val configContents =
56 | show"""{
57 | | port = $port,
58 | | default-collection = $defaultCollection
59 | |}""".stripMargin
60 | for {
61 | _ <- Files[IO].createDirectories(configPath)
62 | _ <-
63 | Stream
64 | .emits(configContents.getBytes("UTF-8"))
65 | .through(
66 | Files[IO].writeAll(configPath / "service.conf")
67 | )
68 | .compile
69 | .drain
70 | conf <- FinitoFiles.config(testEnv)
71 | _ <- Files[IO].deleteRecursively(testDir)
72 | } yield expect(
73 | ServiceConfig(
74 | ServiceConfig.defaultDatabaseUser,
75 | ServiceConfig.defaultDatabasePassword,
76 | ServiceConfig.defaultHost,
77 | port,
78 | Some(defaultCollection),
79 | ServiceConfig.defaultSpecialCollections
80 | ) === conf
81 | )
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/finito/core/test/src/fin/service/search/GoogleBookInfoServiceTest.scala:
--------------------------------------------------------------------------------
1 | package fin.service.search
2 |
3 | import cats.effect._
4 | import cats.implicits._
5 | import org.http4s.client.Client
6 | import org.typelevel.log4cats.Logger
7 | import org.typelevel.log4cats.slf4j.Slf4jLogger
8 | import weaver._
9 |
10 | import fin.Types._
11 | import fin._
12 |
13 | object GoogleBookInfoServiceTest extends SimpleIOSuite {
14 |
15 | implicit val logger: Logger[IO] = Slf4jLogger.getLogger[IO]
16 |
17 | test("search parses title, author and description from json") {
18 | val title = "The Casual Vacancy"
19 | val author = "J K Rowling"
20 | val description = "Not Harry Potter"
21 | val client: Client[IO] =
22 | fixtures.HTTPClient(
23 | fixtures.BooksResponses.response(title, author, description)
24 | )
25 | val bookAPI: BookInfoService[IO] = GoogleBookInfoService(client)
26 | for {
27 | result <-
28 | bookAPI.search(QueryBooksArgs("non-empty".some, None, None, None))
29 | maybeBook = result.headOption
30 | } yield expect(result.length === 1) and
31 | expect(maybeBook.map(_.title) === title.some) and
32 | expect(maybeBook.map(_.authors) === List(author).some) and
33 | expect(maybeBook.map(_.description) === description.some)
34 | }
35 |
36 | test("search errors with empty strings") {
37 | val client: Client[IO] =
38 | fixtures.HTTPClient(fixtures.BooksResponses.response("", "", ""))
39 | val bookAPI: BookInfoService[IO] = GoogleBookInfoService(client)
40 | for {
41 | response <-
42 | bookAPI
43 | .search(QueryBooksArgs("".some, "".some, None, None))
44 | .attempt
45 | } yield expect(response == NoKeywordsSpecifiedError.asLeft)
46 | }
47 |
48 | test("search errors with empty optionals") {
49 | val client: Client[IO] =
50 | fixtures.HTTPClient(fixtures.BooksResponses.response("", "", ""))
51 | val bookAPI: BookInfoService[IO] = GoogleBookInfoService(client)
52 | for {
53 | response <-
54 | bookAPI
55 | .search(QueryBooksArgs(None, None, None, None))
56 | .attempt
57 | } yield expect(response == NoKeywordsSpecifiedError.asLeft)
58 | }
59 |
60 | test("fromIsbn parses title, author and description from json") {
61 | val isbn = "1568658079"
62 | val client: Client[IO] =
63 | fixtures.HTTPClient(fixtures.BooksResponses.isbnResponse(isbn))
64 | val bookAPI: BookInfoService[IO] = GoogleBookInfoService(client)
65 | for {
66 | response <- bookAPI.fromIsbn(QueryBookArgs(isbn, None))
67 | maybeBook = response.headOption
68 | } yield expect(response.length === 1) and expect(
69 | maybeBook.map(_.isbn) === ("978" + isbn).some
70 | )
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/finito/core/test/src/fin/service/book/InMemoryBookRepository.scala:
--------------------------------------------------------------------------------
1 | package fin.service.book
2 |
3 | import java.time.LocalDate
4 |
5 | import scala.math.Ordering.Implicits._
6 |
7 | import cats.Monad
8 | import cats.effect.Ref
9 | import cats.implicits._
10 |
11 | import fin.BookConversions._
12 | import fin.Types._
13 | import fin.implicits._
14 | import fin.persistence.BookRepository
15 |
16 | class InMemoryBookRepository[F[_]: Monad](booksRef: Ref[F, List[UserBook]])
17 | extends BookRepository[F] {
18 |
19 | override def books: F[List[UserBook]] = booksRef.get
20 |
21 | override def createBook(book: BookInput, date: LocalDate): F[Unit] =
22 | booksRef.update(book.toUserBook(dateAdded = date.some) :: _)
23 |
24 | override def createBooks(books: List[UserBook]): F[Unit] =
25 | booksRef.update { ls =>
26 | books
27 | .filterNot(b => ls.contains_(b))
28 | .map(_.copy(startedReading = None, lastRead = None)) ::: ls
29 | }
30 |
31 | override def retrieveBook(isbn: String): F[Option[UserBook]] =
32 | booksRef.get.map(_.find(_.isbn === isbn))
33 |
34 | override def retrieveMultipleBooks(isbns: List[String]): F[List[UserBook]] =
35 | booksRef.get.map(_.filter(book => isbns.contains(book.isbn)))
36 |
37 | override def rateBook(book: BookInput, rating: Int): F[Unit] =
38 | booksRef.getAndUpdate { ls =>
39 | ls.map { b =>
40 | if (b.isbn === book.isbn)
41 | b.copy(rating = rating.some)
42 | else b
43 | }
44 | }.void
45 |
46 | override def addBookReview(book: BookInput, review: String): F[Unit] =
47 | booksRef.getAndUpdate { ls =>
48 | ls.map { b =>
49 | if (b.isbn === book.isbn)
50 | b.copy(review = review.some)
51 | else b
52 | }
53 | }.void
54 |
55 | override def startReading(book: BookInput, date: LocalDate): F[Unit] =
56 | for {
57 | _ <- booksRef.getAndUpdate(_.map { b =>
58 | if (b.isbn === book.isbn)
59 | b.copy(startedReading = date.some)
60 | else b
61 | })
62 | } yield ()
63 |
64 | override def finishReading(book: BookInput, date: LocalDate): F[Unit] =
65 | for {
66 | _ <- booksRef.getAndUpdate(_.map { b =>
67 | if (b.isbn === book.isbn)
68 | b.copy(lastRead = date.some)
69 | else b
70 | })
71 | } yield ()
72 |
73 | override def deleteBookData(isbn: String): F[Unit] =
74 | booksRef
75 | .getAndUpdate(_.map { b =>
76 | if (b.isbn === isbn)
77 | b.copy(rating = None, startedReading = None, lastRead = None)
78 | else b
79 | })
80 | .void
81 |
82 | override def retrieveBooksInside(
83 | from: LocalDate,
84 | to: LocalDate
85 | ): F[List[UserBook]] = {
86 | val inRange = (d: LocalDate) => from <= d && d <= to
87 | booksRef.get.map {
88 | _.filter(b => b.dateAdded.exists(inRange) || b.lastRead.exists(inRange))
89 | }
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/finito/core/test/src/fin/service/port/GoodreadsExportServiceTest.scala:
--------------------------------------------------------------------------------
1 | package fin.service.port
2 |
3 | import cats.arrow.FunctionK
4 | import cats.effect._
5 | import cats.implicits._
6 | import weaver._
7 |
8 | import fin.Types._
9 | import fin.fixtures
10 | import fin.service.collection._
11 | import fin.service.port._
12 |
13 | object GoodreadsExportServiceTest extends IOSuite {
14 |
15 | val defaultCollectionBook = fixtures.bookInput
16 | val defaultCollection = "default collection"
17 |
18 | override type Res = GoodreadsExportService[IO]
19 | override def sharedResource: Resource[IO, GoodreadsExportService[IO]] =
20 | Resource.eval(Ref.of[IO, List[Collection]](List.empty).flatMap { ref =>
21 | val collectionService = CollectionServiceImpl[IO, IO](
22 | new InMemoryCollectionRepository(ref),
23 | Clock[IO],
24 | FunctionK.id[IO]
25 | )
26 | collectionService
27 | .createCollection(
28 | MutationCreateCollectionArgs(
29 | defaultCollection,
30 | None,
31 | None,
32 | None
33 | )
34 | ) *> collectionService
35 | .addBookToCollection(
36 | MutationAddBookArgs(defaultCollection.some, defaultCollectionBook)
37 | )
38 | .as(GoodreadsExportService(defaultCollection.some, collectionService))
39 | })
40 |
41 | private def exportArgs(collection: Option[String] = defaultCollection.some) =
42 | QueryExportArgs(PortType.Goodreads, collection)
43 |
44 | test("exportCollection csv contains collection data") { exportService =>
45 | val args = exportArgs()
46 | for {
47 | csv <- exportService.exportCollection(args)
48 | } yield expect(csv.contains(defaultCollectionBook.title)) &&
49 | expect(csv.contains(defaultCollectionBook.isbn)) &&
50 | expect(
51 | csv.contains(defaultCollectionBook.authors.headOption.getOrElse(""))
52 | )
53 | }
54 |
55 | test("exportCollection defaults to exporting default collection") {
56 | exportService =>
57 | val args = exportArgs(None)
58 | for {
59 | csv <- exportService.exportCollection(args)
60 | } yield expect(csv.contains(defaultCollectionBook.title)) &&
61 | expect(csv.contains(defaultCollectionBook.isbn)) &&
62 | expect(
63 | csv.contains(defaultCollectionBook.authors.headOption.getOrElse(""))
64 | )
65 | }
66 |
67 | test("exportCollection errors when no collection specified") { _ =>
68 | val args = exportArgs()
69 | for {
70 | ref <- Ref.of[IO, List[Collection]](List.empty)
71 | collectionService = CollectionServiceImpl[IO, IO](
72 | new InMemoryCollectionRepository(ref),
73 | Clock[IO],
74 | FunctionK.id[IO]
75 | )
76 | exportService = GoodreadsExportService(None, collectionService)
77 | response <- exportService.exportCollection(args).attempt
78 | } yield expect(response.isLeft)
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/finito/core/test/src/fin/service/collection/InMemoryCollectionRepository.scala:
--------------------------------------------------------------------------------
1 | package fin.service.collection
2 |
3 | import java.time.LocalDate
4 |
5 | import cats.MonadThrow
6 | import cats.effect.Ref
7 | import cats.implicits._
8 |
9 | import fin.BookConversions._
10 | import fin.Types._
11 | import fin._
12 | import fin.persistence.CollectionRepository
13 |
14 | class InMemoryCollectionRepository[F[_]: MonadThrow](
15 | collectionsRef: Ref[F, List[Collection]]
16 | ) extends CollectionRepository[F] {
17 |
18 | override def collections: F[List[Collection]] = collectionsRef.get
19 |
20 | override def createCollection(name: String, preferredSort: Sort): F[Unit] = {
21 | val collection = Collection(name, List.empty, preferredSort, None)
22 | collectionsRef.update(collection :: _)
23 | }
24 |
25 | override def createCollections(
26 | names: Set[String],
27 | preferredSort: Sort
28 | ): F[Unit] = {
29 | val collections = names.map(Collection(_, List.empty, preferredSort, None))
30 | collectionsRef.update(collections.toList ::: _)
31 | }
32 |
33 | override def collection(
34 | name: String,
35 | bookLimit: Option[Int],
36 | bookOffset: Option[Int]
37 | ): F[Option[Collection]] =
38 | collectionsRef.get.map(_.find(_.name === name))
39 |
40 | override def deleteCollection(name: String): F[Unit] =
41 | collectionsRef.update(_.filterNot(_.name === name))
42 |
43 | override def updateCollection(
44 | currentName: String,
45 | newName: String,
46 | preferredSort: Sort
47 | ): F[Unit] =
48 | for {
49 | _ <- collectionOrError(currentName)
50 | _ <- collectionsRef.getAndUpdate(_.map { col =>
51 | if (col.name === currentName)
52 | col.copy(name = newName, preferredSort = preferredSort)
53 | else col
54 | })
55 | } yield ()
56 |
57 | override def addBookToCollection(
58 | collectionName: String,
59 | book: BookInput,
60 | date: LocalDate
61 | ): F[Unit] =
62 | for {
63 | _ <- collectionOrError(collectionName)
64 | _ <- collectionsRef.getAndUpdate(_.map { col =>
65 | if (col.name === collectionName)
66 | col.copy(books = book.toUserBook() :: col.books)
67 | else col
68 | })
69 | } yield ()
70 |
71 | override def removeBookFromCollection(
72 | collectionName: String,
73 | isbn: String
74 | ): F[Unit] =
75 | for {
76 | _ <- collectionOrError(collectionName)
77 | _ <- collectionsRef.getAndUpdate(_.map { col =>
78 | if (col.name === collectionName)
79 | col.copy(books = col.books.filterNot(_.isbn === isbn))
80 | else col
81 | })
82 | } yield ()
83 |
84 | private def collectionOrError(collectionName: String): F[Collection] =
85 | for {
86 | maybeCollection <- collection(collectionName, None, None)
87 | retrievedCollection <- MonadThrow[F].fromOption(
88 | maybeCollection,
89 | CollectionDoesNotExistError(collectionName)
90 | )
91 | } yield retrievedCollection
92 | }
93 |
--------------------------------------------------------------------------------
/bin/montage.py:
--------------------------------------------------------------------------------
1 | """
2 | Usage: python3 montage.py f
3 |
4 | Where f is a file consisting of lines of the form
5 | 'title,image_path,bit'
6 | e.g.
7 | 'old man's war,/home/images/oldmanswar.jpeg,0'
8 |
9 | The bit here controls whether the image is rendered large or small
10 | """
11 |
12 | import shutil, collections, os, sys, requests
13 |
14 |
15 | DIM = (DIM_WIDTH := 128, DIM_HEIGHT := 196)
16 | TILE_WIDTH = 6
17 | Entry = collections.namedtuple("Entry", "title, img, large")
18 |
19 |
20 | def proc_small_entry(path):
21 | name, ext = os.path.splitext(os.path.basename(path))
22 | os.system(
23 | f"convert {path} -resize {DIM_WIDTH//2}x{DIM_HEIGHT//2}\! /tmp/{name}{ext}"
24 | )
25 | return f"/tmp/{name}{ext}"
26 |
27 |
28 | def proc_large_entry(path):
29 | name, ext = os.path.splitext(os.path.basename(path))
30 | os.system(
31 | f"convert {path} -resize {DIM_WIDTH}x{DIM_HEIGHT}\! /tmp/{name}"
32 | )
33 | os.system(
34 | f"convert /tmp/{name} -crop 2x2@ +repage +adjoin /tmp/{name}_2x2@_%d{ext}"
35 | )
36 | return [f"/tmp/{name}_2x2@_{i}{ext}" for i in range(4)]
37 |
38 |
39 | def main():
40 | with open(sys.argv[1], "r") as f:
41 | inp = [s.split(",") for s in f.read().splitlines()]
42 | entries = [Entry(title, img, large == "1") for (title, img, large) in inp]
43 | files, current_row, nxt_row = [], [False]*TILE_WIDTH, [False]*TILE_WIDTH
44 | entries = entries[::-1]
45 | print(len(entries))
46 | while entries:
47 | entry = entries.pop()
48 | while all(current_row):
49 | files.extend(current_row)
50 | current_row, nxt_row = nxt_row, [False]*TILE_WIDTH
51 | if entry.large:
52 | tp_left, tp_right, btm_left, btm_right = proc_large_entry(entry.img)
53 | idx = current_row.index(False)
54 | if idx == TILE_WIDTH - 1:
55 | nxt_small = next(filter(lambda e: not e.large, entries[::-1]), None)
56 | if nxt_small:
57 | entries.remove(nxt_small)
58 | entries.append(entry)
59 | entries.append(nxt_small)
60 | else:
61 | current_row[TILE_WIDTH - 1] = "null:"
62 | entries.append(entry)
63 | else:
64 | current_row[idx], current_row[idx + 1] = tp_left, tp_right
65 | nxt_row[idx], nxt_row[idx + 1] = btm_left, btm_right
66 | else:
67 | current_row[current_row.index(False)] = proc_small_entry(entry.img)
68 | files.extend([f or "null:" for f in current_row])
69 | mx_idx = max([i for i in range(TILE_WIDTH) if nxt_row[i]] + [False])
70 | if mx_idx:
71 | files.extend([f or "null:" for f in nxt_row[:mx_idx + 1]])
72 | print(len(files))
73 | os.system(
74 | f"montage -tile {TILE_WIDTH}x -geometry {DIM_WIDTH//2}x{DIM_HEIGHT//2}+0+0 -background transparent " +
75 | " ".join(files) + " montage.png"
76 | )
77 | os.system("eog montage.png")
78 |
79 |
80 | if __name__ == "__main__":
81 | main()
82 |
--------------------------------------------------------------------------------
/finito/core/src/fin/service/summary/ImageStitch.scala:
--------------------------------------------------------------------------------
1 | package fin.service.summary
2 |
3 | import java.awt.image.BufferedImage
4 |
5 | import scala.annotation.tailrec
6 |
7 | import cats.implicits._
8 |
9 | object ImageStitch {
10 |
11 | def stitch(
12 | images: List[ImageChunk],
13 | columns: Int
14 | ): Map[(Int, Int), SingularChunk] = {
15 | val gridStream = LazyList.iterate((0, 0)) { case (r, c) =>
16 | (r + (c + 1) / columns, (c + 1) % columns)
17 | }
18 | stitchRec(gridStream, images, Map.empty, columns)
19 | }
20 |
21 | @tailrec
22 | private def stitchRec(
23 | gridStream: LazyList[(Int, Int)],
24 | unprocessedChunks: List[ImageChunk],
25 | chunkMapping: Map[(Int, Int), SingularChunk],
26 | columns: Int
27 | ): Map[(Int, Int), SingularChunk] = {
28 | val head #:: tail = gridStream
29 | val fitInFn =
30 | ImageChunk.fitsAt(head, columns - head._2, chunkMapping.keySet)(_)
31 | unprocessedChunks match {
32 | case (c: SingularChunk) :: chunksTail =>
33 | stitchRec(tail, chunksTail, chunkMapping + (head -> c), columns)
34 | case (c: CompositeChunk) :: chunksTail if fitInFn(c) =>
35 | val subChunks = c.flatten(head)
36 | val fStream = tail.filterNot(subChunks.map(_._1).contains(_))
37 | stitchRec(fStream, chunksTail, chunkMapping ++ subChunks.toMap, columns)
38 | case (_: CompositeChunk) :: _ =>
39 | val (maybeMatch, chunks) =
40 | findFirstAndRemove(unprocessedChunks, fitInFn)
41 | stitchRec(
42 | if (maybeMatch.isEmpty) gridStream.init else gridStream,
43 | chunks.prependedAll(maybeMatch),
44 | chunkMapping,
45 | columns
46 | )
47 | case Nil => chunkMapping
48 | }
49 | }
50 |
51 | private def findFirstAndRemove[A](
52 | list: List[A],
53 | pred: A => Boolean
54 | ): (Option[A], List[A]) = {
55 | val (maybeElt, checkedList) =
56 | list.foldLeft((Option.empty[A], List.empty[A])) {
57 | case ((maybeElt, ls), elt) =>
58 | maybeElt match {
59 | case Some(_) => (maybeElt, ls :+ elt)
60 | case None if pred(elt) => (Some(elt), ls)
61 | case _ => (None, ls)
62 | }
63 | }
64 | (maybeElt, checkedList)
65 | }
66 | }
67 |
68 | sealed trait ImageChunk extends Product with Serializable
69 |
70 | object ImageChunk {
71 | def fitsAt(rowColumn: (Int, Int), width: Int, filled: Set[(Int, Int)])(
72 | chunk: ImageChunk
73 | ): Boolean = {
74 | chunk match {
75 | case c @ CompositeChunk(w, _)
76 | if (w > width || c.flatten(rowColumn).exists(c => filled(c._1))) =>
77 | false
78 | case _ => true
79 | }
80 | }
81 | }
82 |
83 | final case class SingularChunk(img: BufferedImage) extends ImageChunk
84 |
85 | final case class CompositeChunk(
86 | width: Int,
87 | chunks: List[SingularChunk]
88 | ) extends ImageChunk {
89 |
90 | def flatten(at: (Int, Int)): List[((Int, Int), SingularChunk)] =
91 | LazyList
92 | .iterate((0, 0)) { case (r, c) =>
93 | (r + (c + 1) / width, (c + 1) % width)
94 | }
95 | .map(_ |+| at)
96 | .zip(chunks)
97 | .toList
98 | }
99 |
--------------------------------------------------------------------------------
/finito/main/src/fin/SpecialCollectionSetup.scala:
--------------------------------------------------------------------------------
1 | package fin
2 |
3 | import cats.effect.Sync
4 | import cats.implicits._
5 | import cats.{Monad, ~>}
6 | import org.typelevel.log4cats.Logger
7 |
8 | import fin.implicits._
9 | import fin.persistence.CollectionRepository
10 | import fin.service.book._
11 | import fin.service.collection.SpecialCollection._
12 | import fin.service.collection._
13 |
14 | object SpecialCollectionSetup {
15 | def setup[F[_]: Sync: Logger, G[_]: Monad](
16 | collectionRepo: CollectionRepository[G],
17 | collectionService: CollectionService[F],
18 | bookService: BookManagementService[F],
19 | defaultCollection: Option[String],
20 | specialCollections: List[SpecialCollection],
21 | transact: G ~> F
22 | ): F[(BookManagementService[F], CollectionService[F])] =
23 | for {
24 | _ <- Logger[F].info(
25 | "Found special collections: " + specialCollections
26 | .map(_.name)
27 | .mkString(", ")
28 | )
29 | _ <- Logger[F].debug(show"Special collection info: $specialCollections")
30 | _ <- transact(
31 | specialCollections
32 | .traverse(c => processSpecialCollection[G](collectionRepo, c))
33 | ).flatMap(_.traverse(s => Logger[F].info(s)))
34 | hookExecutionService = HookExecutionServiceImpl[F]
35 | wrappedCollectionService = SpecialCollectionService[F](
36 | defaultCollection,
37 | collectionService,
38 | specialCollections,
39 | hookExecutionService
40 | )
41 | wrappedBookService = SpecialBookService[F](
42 | collectionService,
43 | bookService,
44 | specialCollections,
45 | hookExecutionService
46 | )
47 | } yield (wrappedBookService, wrappedCollectionService)
48 |
49 | private def processSpecialCollection[G[_]: Monad](
50 | collectionRepo: CollectionRepository[G],
51 | collection: SpecialCollection
52 | ): G[String] =
53 | for {
54 | maybeCollection <-
55 | collectionRepo.collection(collection.name, 1.some, 0.some)
56 | createCollection =
57 | maybeCollection.isEmpty && collection.`lazy`.contains(false)
58 | _ <- Monad[G].whenA(createCollection) {
59 | val sort = collection.preferredSort.getOrElse(
60 | CollectionServiceImpl.defaultSort
61 | )
62 | collectionRepo.createCollection(collection.name, sort)
63 | }
64 | maybeUpdatedCollection <-
65 | maybeCollection
66 | .zip(collection.preferredSort)
67 | .collect {
68 | case (c, sort) if c.preferredSort =!= sort => sort
69 | }
70 | .traverse { sort =>
71 | collectionRepo
72 | .updateCollection(collection.name, collection.name, sort)
73 | }
74 | } yield maybeUpdatedCollection
75 | .as(show"Updated collection '${collection.name}'")
76 | .orElse(
77 | maybeCollection.as(
78 | show"No changes for special collection '${collection.name}'"
79 | )
80 | )
81 | .getOrElse(
82 | if (createCollection)
83 | show"Created collection marked as not lazy: '${collection.name}'"
84 | else show"Left lazy collection '${collection.name}' unitialized"
85 | )
86 | }
87 |
--------------------------------------------------------------------------------
/finito/main/src/fin/Services.scala:
--------------------------------------------------------------------------------
1 | package fin
2 |
3 | import cats.Parallel
4 | import cats.arrow.FunctionK
5 | import cats.effect._
6 | import cats.effect.kernel.Clock
7 | import cats.implicits._
8 | import doobie._
9 | import doobie.implicits._
10 | import fs2.compression.Compression
11 | import org.http4s.client.middleware.GZip
12 | import org.typelevel.log4cats.Logger
13 |
14 | import fin.persistence.{SqliteBookRepository, SqliteCollectionRepository}
15 | import fin.service.book._
16 | import fin.service.collection._
17 | import fin.service.port._
18 | import fin.service.search._
19 | import fin.service.summary._
20 |
21 | final case class Services[F[_]](
22 | bookInfoService: BookInfoService[F],
23 | seriesInfoService: SeriesInfoService[F],
24 | bookManagementService: BookManagementService[F],
25 | collectionService: CollectionService[F],
26 | collectionExportService: CollectionExportService[F],
27 | summaryService: SummaryService[F],
28 | importService: ImportService[F]
29 | )
30 |
31 | object Services {
32 | def apply[F[_]: Async: Parallel: Logger: Compression](
33 | serviceResources: ServiceResources[F]
34 | ): F[Services[F]] = {
35 | val ServiceResources(client, config, transactor, _, _) = serviceResources
36 | val clock = Clock[F]
37 | val collectionRepo = SqliteCollectionRepository
38 | val bookRepo = SqliteBookRepository
39 | val bookInfoService = GoogleBookInfoService[F](GZip()(client))
40 | val connectionIOToF = λ[FunctionK[ConnectionIO, F]](_.transact(transactor))
41 | val wrappedInfoService = BookInfoAugmentationService[F, ConnectionIO](
42 | bookInfoService,
43 | bookRepo,
44 | connectionIOToF
45 | )
46 | val collectionService = CollectionServiceImpl[F, ConnectionIO](
47 | collectionRepo,
48 | clock,
49 | connectionIOToF
50 | )
51 | val bookManagementService = BookManagementServiceImpl[F, ConnectionIO](
52 | bookRepo,
53 | clock,
54 | connectionIOToF
55 | )
56 | val seriesInfoService =
57 | WikidataSeriesInfoService[F](client, wrappedInfoService)
58 | val exportService =
59 | GoodreadsExportService[F](config.defaultCollection, collectionService)
60 | val summaryService = SummaryServiceImpl[F, ConnectionIO](
61 | bookRepo,
62 | BufferedImageMontageService[F],
63 | clock,
64 | connectionIOToF
65 | )
66 | SpecialCollectionSetup
67 | .setup[F, ConnectionIO](
68 | collectionRepo,
69 | collectionService,
70 | bookManagementService,
71 | config.defaultCollection,
72 | config.specialCollections,
73 | connectionIOToF
74 | )
75 | .map { case (wrappedBookManagementService, wrappedCollectionService) =>
76 | val goodreadsImportService = GoodreadsImportService(
77 | bookInfoService,
78 | collectionService,
79 | bookManagementService,
80 | wrappedBookManagementService
81 | )
82 | val importService = ImportServiceImpl(goodreadsImportService)
83 | Services[F](
84 | bookInfoService,
85 | seriesInfoService,
86 | wrappedBookManagementService,
87 | wrappedCollectionService,
88 | exportService,
89 | summaryService,
90 | importService
91 | )
92 | }
93 | }
94 | }
95 |
--------------------------------------------------------------------------------
/plugins/calibanSchemaGen.sc:
--------------------------------------------------------------------------------
1 | import $ivy.`com.github.ghostdogpr::caliban-tools:2.9.0`
2 | import caliban.tools.Codegen.GenType
3 | import caliban.tools._
4 | import mill._, scalalib._, scalafmt._
5 | import mill.api.PathRef
6 | import zio.Runtime
7 |
8 | trait CalibanModule {
9 |
10 | def schemaPath: String
11 | def outputFileName = "Schema.scala"
12 | def fmtPath: String = ".scalafmt.conf"
13 | def headers: List[Options.Header] = List.empty
14 | def clientName: String = "Client"
15 | def packageName: String
16 | def genView: Boolean = false
17 | def effect: String = if (abstractEffectType) "F" else "zio.UIO"
18 | def scalarMappings: Map[String, String] = Map.empty
19 | def imports: List[String] = List.empty
20 | def splitFiles: Boolean = false
21 | def enableFmt: Boolean = false
22 | def extensibleEnums: Boolean = false
23 | def abstractEffectType: Boolean = false
24 | def preserveInputNames: Boolean = true
25 | }
26 |
27 | trait CalibanSchemaModule extends ScalaModule with CalibanModule {
28 |
29 | override def generatedSources =
30 | T {
31 | val schemaPathRef = schema()
32 | super.generatedSources() :+ schemaPathRef
33 | }
34 |
35 | def schema: T[PathRef] =
36 | T {
37 | val outputPath = T.dest / outputFileName
38 | val options = Options(
39 | schemaPath,
40 | outputPath.toString,
41 | Some(fmtPath),
42 | Some(headers),
43 | Some(packageName),
44 | Some(clientName),
45 | Some(genView),
46 | Some(effect),
47 | Some(scalarMappings),
48 | Some(imports),
49 | Some(abstractEffectType),
50 | Some(splitFiles),
51 | Some(enableFmt),
52 | Some(extensibleEnums),
53 | Some(preserveInputNames),
54 | None,
55 | None,
56 | None,
57 | None,
58 | None
59 | )
60 | zio.Unsafe.unsafe { implicit unsafe =>
61 | Runtime.default.unsafe
62 | .run(Codegen.generate(options, GenType.Schema).unit)
63 | .getOrThrowFiberFailure()
64 | }
65 | PathRef(outputPath)
66 | }
67 | }
68 |
69 | trait CalibanClientModule extends ScalaModule with CalibanModule {
70 |
71 | override def generatedSources =
72 | T {
73 | val schemaPathRef = schema()
74 | super.generatedSources() :+ schemaPathRef
75 | }
76 |
77 | def schema: T[PathRef] =
78 | T {
79 | val outputPath = T.dest / outputFileName
80 | val options = Options(
81 | schemaPath,
82 | outputPath.toString,
83 | Some(fmtPath),
84 | Some(headers),
85 | Some(packageName),
86 | Some(clientName),
87 | Some(genView),
88 | Some(effect),
89 | Some(scalarMappings),
90 | Some(imports),
91 | Some(abstractEffectType),
92 | Some(splitFiles),
93 | Some(enableFmt),
94 | Some(extensibleEnums),
95 | Some(preserveInputNames),
96 | None,
97 | None,
98 | None,
99 | None,
100 | None
101 | )
102 | zio.Unsafe.unsafe { implicit unsafe =>
103 | Runtime.default.unsafe
104 | .run(Codegen.generate(options, GenType.Client).unit)
105 | .getOrThrowFiberFailure()
106 | }
107 | PathRef(outputPath)
108 | }
109 | }
110 |
--------------------------------------------------------------------------------
/finito/api/src/fin/FinitoError.scala:
--------------------------------------------------------------------------------
1 | package fin
2 |
3 | import cats.implicits._
4 |
5 | import implicits._
6 | import Types._
7 |
8 | trait FinitoError extends Throwable {
9 | def errorCode: String
10 | }
11 |
12 | case object NoKeywordsSpecifiedError extends FinitoError {
13 | override def getMessage =
14 | "At least one of 'author keywords' and 'title keywords' must be specified."
15 | override def errorCode = "NO_KEYWORDS_SPECIFIED"
16 | }
17 |
18 | final case class NoBooksFoundForIsbnError(isbn: String) extends FinitoError {
19 | override def getMessage = show"No books found for isbn: '$isbn'"
20 | override def errorCode = "NO_BOOKS_FOR_ISBN"
21 | }
22 |
23 | case object CannotChangeNameOfSpecialCollectionError extends FinitoError {
24 | override def getMessage = "Cannot update the name of a special collection"
25 | override def errorCode = "CANNOT_CHANGE_NAME_OF_SPECIAL_COLLECTION"
26 | }
27 |
28 | case object CannotDeleteSpecialCollectionError extends FinitoError {
29 | override def getMessage =
30 | """
31 | |Cannot delete a special collection! In order to delete a special
32 | |collection, first remove it's special collection definition from your
33 | |config file, and then delete it.""".stripMargin.replace("\n", " ")
34 | override def errorCode = "CANNOT_DELETE_SPECIAL_COLLECTION"
35 | }
36 |
37 | final case class CollectionDoesNotExistError(collection: String)
38 | extends FinitoError {
39 | override def getMessage = show"Collection '$collection' does not exist!"
40 | override def errorCode = "NOT_ENOUGH_ARGS_FOR_UPDATE"
41 | }
42 |
43 | final case class CollectionAlreadyExistsError(collection: String)
44 | extends FinitoError {
45 | override def getMessage = show"Collection '$collection' already exists!"
46 | override def errorCode = "NOT_ENOUGH_ARGS_FOR_UPDATE"
47 | }
48 |
49 | case object NotEnoughArgumentsForUpdateError extends FinitoError {
50 | override def getMessage =
51 | """
52 | |At least one of 'newName', 'preferredSortType' or 'sortAscending'
53 | |must be specified""".stripMargin.replace("\n", " ")
54 | override def errorCode = "NOT_ENOUGH_ARGS_FOR_UPDATE"
55 | }
56 |
57 | case object DefaultCollectionNotSupportedError extends FinitoError {
58 | override def getMessage = "The default collection is not known!"
59 | override def errorCode = "DEFAULT_COLLECTION_NOT_SUPPORTED"
60 | }
61 |
62 | final case class BookAlreadyInCollectionError(
63 | collectionName: String,
64 | bookTitle: String
65 | ) extends FinitoError {
66 | override def getMessage =
67 | show"The book '$bookTitle' is already in '$collectionName'!"
68 | override def errorCode = "BOOK_ALREADY_IN_COLLECTION"
69 | }
70 |
71 | final case class BookAlreadyBeingReadError(book: BookInput)
72 | extends FinitoError {
73 | override def getMessage =
74 | show"The book '${book.title}' is already being read!"
75 | override def errorCode = "BOOK_ALREADY_BEING_READ"
76 | }
77 |
78 | final case class BookAlreadyExistsError(book: BookInput) extends FinitoError {
79 | override def getMessage =
80 | show"A book with isbn ${book.isbn} already exists: $book!"
81 | override def errorCode = "BOOK_ALREADY_EXISTS"
82 | }
83 |
84 | final case class InvalidSortStringError(string: String) extends FinitoError {
85 | override def getMessage = show"$string is not a valid sort type!"
86 | override def errorCode = "INVALID_SORT_STRING"
87 | }
88 |
89 | case object NoBooksFoundForMontageError extends FinitoError {
90 | override def getMessage = "No Books were found to use to create a montage"
91 | override def errorCode = "NO_BOOKS_FOUND_FOR_MONTAGE"
92 | }
93 |
--------------------------------------------------------------------------------
/finito/main/src/fin/Routes.scala:
--------------------------------------------------------------------------------
1 | package fin
2 |
3 | import scala.concurrent.duration._
4 |
5 | import caliban.interop.tapir.HttpInterpreter
6 | import caliban.{CalibanError, GraphQLInterpreter, Http4sAdapter}
7 | import cats.data.{Kleisli, OptionT}
8 | import cats.effect._
9 | import cats.implicits._
10 | import fs2.Stream
11 | import org.http4s._
12 | import org.http4s.client.Client
13 | import org.http4s.implicits._
14 | import org.http4s.server.Router
15 | import org.http4s.server.middleware.ResponseTiming
16 | import org.typelevel.ci._
17 | import org.typelevel.log4cats.Logger
18 |
19 | object Routes {
20 |
21 | type Env = zio.Clock
22 |
23 | def routes[F[_]: Async](
24 | interpreter: GraphQLInterpreter[Any, CalibanError],
25 | debug: Boolean
26 | )(implicit
27 | runtime: zio.Runtime[Env]
28 | ): HttpApp[F] = {
29 | val serviceRoutes: HttpRoutes[F] =
30 | Http4sAdapter.makeHttpServiceF[F, Any, CalibanError](
31 | HttpInterpreter(interpreter)
32 | )
33 | val app = Router[F](
34 | "/version" -> Kleisli.liftF(
35 | OptionT.pure[F](
36 | Response[F](body = Stream.emits(BuildInfo.version.getBytes("UTF-8")))
37 | )
38 | ),
39 | "/api/graphql" -> serviceRoutes,
40 | "/graphiql" -> Kleisli.liftF(
41 | StaticFile.fromResource("/graphql-playground.html", None)
42 | )
43 | ).orNotFound
44 | finitoLoggingMiddleware[F](debug, ResponseTiming[F](app))
45 | }
46 |
47 | private val queryJson = """{
48 | "operationName": null,
49 | "query": "{collection(name: \"My Books\", booksPagination: {first: 5, after: 0}) {name books {title}}}",
50 | "variables": {}}"""
51 | private val headers =
52 | Headers(("Accept", "application/json"), ("X-Client-Id", "finito"))
53 |
54 | def keepFresh[F[_]: Async: Logger](
55 | client: Client[F],
56 | timer: Temporal[F],
57 | port: Int,
58 | host: String
59 | ): F[Unit] = {
60 | val uriStr = show"http://$host:$port/api/graphql"
61 | val body = fs2.Stream.emits(queryJson.getBytes("UTF-8"))
62 | val result = for {
63 | uri <- Concurrent[F].fromEither(Uri.fromString(uriStr))
64 | request = Request[F](Method.POST, uri, headers = headers, body = body)
65 | _ <- client.expect[String](request).void.handleErrorWith { e =>
66 | Logger[F].error(show"Error running freshness query '${e.getMessage()}'")
67 | }
68 | } yield ()
69 | (result >> timer.sleep(1.minutes)).foreverM
70 | }
71 |
72 | private def finitoLoggingMiddleware[F[_]: Async](
73 | debug: Boolean,
74 | app: HttpApp[F]
75 | ): HttpApp[F] = {
76 | val fromFinito = (r: Request[F]) =>
77 | r.headers.get(ci"X-Client-Id").exists(nel => nel.head.value === "finito")
78 | conditionalServerResponseLogger(
79 | logHeadersWhen = (r: Request[F]) => !fromFinito(r) || debug,
80 | logBodyWhen = Function.const(debug) _
81 | )(app)
82 | }
83 |
84 | // https://github.com/http4s/http4s/issues/4528
85 | private def conditionalServerResponseLogger[F[_]: Async](
86 | logHeadersWhen: Request[F] => Boolean,
87 | logBodyWhen: Request[F] => Boolean
88 | )(app: HttpApp[F]): HttpApp[F] = {
89 | val logger = org.log4s.getLogger
90 | val logAction: String => F[Unit] = s => Async[F].delay(logger.info(s))
91 |
92 | Kleisli { req =>
93 | app(req).flatTap { res =>
94 | Async[F].whenA(logHeadersWhen(req)) {
95 | org.http4s.internal.Logger
96 | .logMessage[F](res)(logHeadersWhen(req), logBodyWhen(req))(
97 | logAction
98 | )
99 | }
100 | }
101 | }
102 | }
103 | }
104 |
--------------------------------------------------------------------------------
/schema.gql:
--------------------------------------------------------------------------------
1 | scalar Date
2 |
3 | enum SortType {
4 | DateAdded
5 | LastRead
6 | Title
7 | Author
8 | Rating
9 | }
10 |
11 | enum PortType {
12 | Finito
13 | Goodreads
14 | }
15 |
16 | input BookInput {
17 | title: String!
18 | authors: [String!]!
19 | description: String!
20 | isbn: String!
21 | thumbnailUri: String!
22 | }
23 |
24 | input MontageInput {
25 | columns: Int! = 6
26 | largeImageWidth: Int! = 128
27 | largeImageHeight: Int! = 196
28 | largeImgScaleFactor: Int! = 2
29 | largeImageRatingThreshold: Int! = 5
30 | }
31 |
32 | input PaginationInput {
33 | first: Int! = 15
34 | after: Int! = 0
35 | }
36 |
37 | type PageInfo {
38 | totalBooks: Int!
39 | }
40 |
41 | type Sort {
42 | type: SortType!
43 | sortAscending: Boolean!
44 | }
45 |
46 | type UserBook {
47 | title: String!
48 | authors: [String!]!
49 | description: String!
50 | isbn: String!
51 | thumbnailUri: String!
52 | dateAdded: Date
53 | rating: Int
54 | startedReading: Date
55 | lastRead: Date
56 | review: String
57 | }
58 |
59 | type Collection {
60 | name: String!
61 | books: [UserBook!]!
62 | preferredSort: Sort!
63 | pageInfo: PageInfo
64 | }
65 |
66 | type Summary {
67 | read: Int!
68 | added: Int!
69 | averageRating: Float!
70 | montage: String!
71 | }
72 |
73 | type ImportResult {
74 | successful: [UserBook!]!
75 | partiallySuccessful: [UserBook!]!
76 | unsuccessful: [UserBook!]!
77 | }
78 |
79 |
80 | type Query {
81 | """
82 | Search for books matching the specified parameters. langRestrict should be
83 | a two-letter ISO-639-1 code, such as "en" or "fr".
84 | """
85 | books(
86 | titleKeywords: String,
87 | authorKeywords: String,
88 | maxResults: Int = 10,
89 | langRestrict: String = "en"
90 | ): [UserBook!]!
91 | book(isbn: String!, langRestrict: String = "en"): [UserBook!]!
92 | series(book: BookInput!): [UserBook!]!
93 | collections: [Collection!]!
94 | collection(name: String!, booksPagination: PaginationInput): Collection!
95 | export(exportType: PortType!, collection: String): String!
96 | summary(
97 | from: Date,
98 | to: Date,
99 | montageInput: MontageInput,
100 | includeAdded: Boolean! = true
101 | ): Summary!
102 | }
103 |
104 | type Mutation {
105 | createCollection(
106 | name: String!,
107 | books: [BookInput!],
108 | preferredSortType: SortType,
109 | sortAscending: Boolean
110 | ): Collection!
111 | """
112 | Delete a collection, will error if the collection does not exist.
113 | """
114 | deleteCollection(name: String!): Boolean
115 | updateCollection(
116 | currentName: String!,
117 | newName: String,
118 | preferredSortType: SortType,
119 | sortAscending: Boolean
120 | ): Collection!
121 | addBook(collection: String, book: BookInput!): Collection!
122 | removeBook(collection: String!, isbn: String!): Boolean
123 | startReading(book: BookInput!, date: Date): UserBook!
124 | finishReading(book: BookInput!, date: Date): UserBook!
125 | rateBook(book: BookInput!, rating: Int!): UserBook!
126 | addBookReview(book: BookInput!, review: String!): UserBook!
127 | """
128 | Create a custom book, useful for when you can't find a book when searching.
129 | """
130 | createBook(book: BookInput!): UserBook!
131 | """
132 | Deletes all data held about a book, is nop if no data is held about the book.
133 | """
134 | deleteBookData(isbn: String!): Boolean
135 | """
136 | Import books from a given resource. How books are added to collections is
137 | determined by the port type.
138 |
139 | For Goodreads, bookshelves will be assumed to correspond to collections, and
140 | will be created if they do not exist.
141 | """
142 | import(importType: PortType!, content: String!, langRestrict: String = "en"): ImportResult!
143 | }
144 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | ## v0.8.3
4 |
5 | * [fc74700](https://github.com/LaurenceWarne/libro-finito/commit/fc74700ce2662d7765237e65a3f569f95ce2621f): Fix over-permissive address binding
6 |
7 | ## v0.8.2
8 |
9 | * [e083fe8](https://github.com/LaurenceWarne/libro-finito/commit/644b6b6320e323f1fd90258d36ba96ba7351c154): Improve book series lookups
10 |
11 | ## v0.8.1
12 |
13 | * [8107ace](https://github.com/LaurenceWarne/libro-finito/commit/2c22e99edc4108ae8ed544401938311c29cd3d4b): Make Goodreads import less error prone
14 | * [8107ace](https://github.com/LaurenceWarne/libro-finito/commit/8107acef724cbf2419172755a51fd5801768efc7): Backup the DB whenever the service starts
15 |
16 | ## v0.8.0
17 |
18 | * [1a5c684](https://github.com/LaurenceWarne/libro-finito/commit/1a5c6849b695a55f4333e86f5966b2d711c47d18): Implement Goodreads import
19 |
20 | ## v0.7.5
21 |
22 | * [635701c](https://github.com/LaurenceWarne/libro-finito/commit/635701c7af61ef2510157729b782c0520b36d4e7): Add `includeAdded` parameter to `summary` query to allow showing only read books in montage images
23 | * [88623de](https://github.com/LaurenceWarne/libro-finito/commit/88623de9efdd629926bcedf2e2fc05c4e674d5a9): Improve server performance after periods of inactivity
24 |
25 | ## v0.7.4
26 |
27 | * [d3321fe](https://github.com/LaurenceWarne/libro-finito/commit/d3321fed2b53a1ecfcc003c58a990588204e297b): Implement pagination for collections
28 |
29 | ## v0.7.3
30 |
31 | * [f7075ff](https://github.com/LaurenceWarne/libro-finito/commit/f7075ffa9930155aedb4f256e67c547011bfab1f): Update dependencies and log info about the server at startup
32 |
33 | ## v0.7.2
34 |
35 | * [fa8d24e](https://github.com/LaurenceWarne/libro-finito/commit/fa8d24e8ee480850fe248bbe9c233475770805d5): Improve performance by keeping the server warm
36 |
37 | ## v0.7.1
38 |
39 | * [a1d8951](https://github.com/LaurenceWarne/libro-finito/commit/a1d8951caf1f894408bdfcb1082b76c6079c17cc): Performance tidbits
40 |
41 | ## v0.7.0
42 |
43 | * [de8f239](https://github.com/LaurenceWarne/libro-finito/commit/de8f239ad7e45a1af41a7d3caa34eb42020a8d67): Support for (yearly) summaries
44 | * [4d0b1c8](https://github.com/LaurenceWarne/libro-finito/commit/4d0b1c81dc548ea49188ec031a1ef9d5143bf65e): Read books are sorted by last read in descending order by default
45 |
46 | ## v0.6.2
47 |
48 | * [49b4300](https://github.com/LaurenceWarne/libro-finito/commit/49b43001f90229e731279e3e5f34aea6f1146ce4): Fix Schema scalars mis-translated
49 |
50 | ## v0.6.1
51 |
52 | * [384c0ef](https://github.com/LaurenceWarne/libro-finito/commit/384c0efbf5ba46303c8bd91c186808da168ab15c): Streamline logging
53 |
54 | ## v0.6.0
55 |
56 | * [c4ab43f](https://github.com/LaurenceWarne/libro-finito/commit/c4ab43f6384c10ad556a9bf05cfb57ebfac011d5): Provisional support for looking up book series
57 | * [7b6bf9b](https://github.com/LaurenceWarne/libro-finito/commit/7b6bf9b7826d9cf2c1de67a7f9883834174b8395): Slight improvment in the ordering of search results
58 |
59 | ## v0.5.0
60 |
61 | * [aa62ace](https://github.com/LaurenceWarne/libro-finito/commit/aa62acee063d84c78419fbe29db82ca6e57dbacb): Add a special collection for read books
62 | * [57180be](https://github.com/LaurenceWarne/libro-finito/commit/57180be031110c612e0b00d2b628cbe595274525): Migrate to CE3
63 | * [0cc4ab9](https://github.com/LaurenceWarne/libro-finito/commit/0cc4ab9da4a2759a4fe3a4bd1d331a805ccb7abd): Make the "not found" image the same size as the rest of the thumbnails
64 |
65 | ## v0.4.2
66 |
67 | * [89e6b12](https://github.com/LaurenceWarne/libro-finito/commit/89e6b1276edbd3427a4beb6f760d18bc03967808): Use hikari connection pool
68 | * [b219a5e](https://github.com/LaurenceWarne/libro-finito/commit/b219a5e7015b81a65c00fe4a87fb052c1fe3352e): Ask for gzip responses from Google Books
69 | * [2a73a07](https://github.com/LaurenceWarne/libro-finito/commit/2a73a072d5a58f11922a9119f3649e3616d269b6): Ask for partial responses from Google Books
70 |
71 | ## v0.4.1
72 |
73 | * Fix bug when Google provides us no titles
74 | * Return an informative error when `addBook` asks to add a book to a collection it's already in
75 |
76 | ## v0.4.0
77 |
78 | * `book` query now returns multiple books
79 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Fin
2 | [](https://codecov.io/gh/LaurenceWarne/libro-finito)
3 |
4 | `libro-finito` is a HTTP server which provides a local book management service. Its main features are searching for books and aggregating books into user defined collections, which are persisted on disk on an sqlite db. The main entry point is a graphql API located [here](/schema.gql). Currently the only client application is [finito.el](https://github.com/LaurenceWarne/finito.el) (for Emacs).
5 |
6 | Also check out the [Changelog](/CHANGELOG.md).
7 |
8 | # Configuration
9 |
10 | The server may be configured in a number of ways via a [HOCON](https://github.com/lightbend/config/blob/main/HOCON.md) file whose expected location is `$XDG_CONFIG_HOME/libro-finito/service.conf`:
11 |
12 | ```hocon
13 | port = 56848
14 | default-collection = "My Books"
15 | special-collections = [
16 | {
17 | name = "My Books",
18 | lazy = false,
19 | add-hook = "add = true"
20 | },
21 | {
22 | name = "Currently Reading",
23 | read-started-hook = "add = true",
24 | read-completed-hook = "remove = true"
25 | },
26 | {
27 | name = "Read",
28 | read-completed-hook = "add = true"
29 | },
30 | {
31 | name = "Favourites",
32 | rate-hook = """
33 | if(rating >= 5) then
34 | add = true
35 | else
36 | remove = true
37 | end
38 | """
39 | }
40 | ]
41 | ```
42 |
43 | `default-collection` is the collection which books will be added to in the case no collection is specified in the `addBook` mutation.
44 |
45 | The sqlite database is located in `$XDG_DATA_HOME/libro-finito/db.sqlite`.
46 |
47 | ## Special Collections
48 |
49 | `libro-finito` allows us to mark some collections as **special**, these collections allow for books to be added or removed automatically via **hooks** whose behaviours are described in [lua](https://www.lua.org/).
50 |
51 | For example, the `My Books` special collection defines one hook, the `add-hook`, which simply sets the variable `add` to `true`. The `add-hook` is called whenever a book is added to a collection. It receives the book attributes as variable bindings and books will be added or removed from the collection according to the values of the `add` and `remove` variables set by the hook (setting neither of these is a nop).
52 |
53 | Therefore the `add-hook` above on the `My Books` special collection will simply add any book added to any other collection to the `My Books` collection. Available hooks are:
54 |
55 | * `add-hook` called when a book is added to a collection
56 | * `remove-hook` called when a book is removed from a collection
57 | * `rate-hook` called when a book is rated
58 | * `read-begun-hook` called when a book has been started (ie marked as "in progress")
59 | * `read-completed-hook` called when a book has been finished
60 |
61 | In the configuration above some special collections have been marked as not `lazy`, which means the service will create them on startup if it detects they do not exist as opposed to the default which is creating them as soon as a book is added to them via a hook (they can also be manually created).
62 |
63 | The special collections enabled by default are those defined in the above snippet - so `My Books`, `Currently Reading`, `Read` and `Favourites`.
64 |
65 | # Local Development
66 |
67 | Optionally install [mill](https://com-lihaoyi.github.io/mill/mill/Intro_to_Mill.html#_installation) (otherwise swap `mill` for `./mill` below). You can start the server via:
68 |
69 | ```bash
70 | mill finito.main.run
71 | ```
72 |
73 | You can then open the playground at http://localhost:56848/graphiql, alternatively you can curl:
74 |
75 | ```bash
76 | curl 'http://localhost:56848/api/graphql' -H 'Accept-Encoding: gzip, deflate, br' -H 'Content-Type: application/json' -H 'Accept: application/json' --data-binary '{"query":"query {\n collection(name: \"My Books\") {\n name\n books {\n title\n }\n }\n}"}' --compressed
77 | ```
78 |
79 | Setting `LOG_LEVEL` to `DEBUG` will prompt more verbose output.
80 |
81 | All tests can be run using:
82 |
83 | ```bash
84 | mill __.test
85 | ```
86 |
--------------------------------------------------------------------------------
/finito/core/src/fin/service/book/BookManagementServiceImpl.scala:
--------------------------------------------------------------------------------
1 | package fin.service.book
2 |
3 | import java.time.LocalDate
4 |
5 | import cats.effect._
6 | import cats.implicits._
7 | import cats.{MonadThrow, ~>}
8 |
9 | import fin.BookConversions._
10 | import fin.Types._
11 | import fin._
12 | import fin.persistence.{BookRepository, Dates}
13 |
14 | class BookManagementServiceImpl[F[_]: MonadThrow, G[_]: MonadThrow] private (
15 | bookRepo: BookRepository[G],
16 | clock: Clock[F],
17 | transact: G ~> F
18 | ) extends BookManagementService[F] {
19 |
20 | override def books: F[List[UserBook]] = transact(bookRepo.books)
21 |
22 | override def createBook(args: MutationCreateBookArgs): F[UserBook] = {
23 | val transaction: LocalDate => G[UserBook] = date =>
24 | for {
25 | maybeBook <- bookRepo.retrieveBook(args.book.isbn)
26 | _ <- maybeBook.fold(bookRepo.createBook(args.book, date)) { _ =>
27 | MonadThrow[G].raiseError(BookAlreadyExistsError(args.book))
28 | }
29 | } yield args.book.toUserBook()
30 | Dates
31 | .currentDate(clock)
32 | .flatMap(date => transact(transaction(date)))
33 | }
34 |
35 | override def createBooks(
36 | books: List[UserBook]
37 | ): F[List[UserBook]] = transact(bookRepo.createBooks(books)).as(books)
38 |
39 | override def rateBook(args: MutationRateBookArgs): F[UserBook] = {
40 | val transaction: LocalDate => G[UserBook] = date =>
41 | for {
42 | book <- createIfNotExists(args.book, date)
43 | _ <- bookRepo.rateBook(args.book, args.rating)
44 | } yield book.copy(rating = args.rating.some)
45 | Dates
46 | .currentDate(clock)
47 | .flatMap(date => transact(transaction(date)))
48 | }
49 |
50 | override def addBookReview(args: MutationAddBookReviewArgs): F[UserBook] = {
51 | val transaction: LocalDate => G[UserBook] = date =>
52 | for {
53 | book <- createIfNotExists(args.book, date)
54 | _ <- bookRepo.addBookReview(args.book, args.review)
55 | } yield book.copy(review = args.review.some)
56 | Dates
57 | .currentDate(clock)
58 | .flatMap(date => transact(transaction(date)))
59 | }
60 |
61 | override def startReading(args: MutationStartReadingArgs): F[UserBook] = {
62 | val transaction: (LocalDate, LocalDate) => G[UserBook] =
63 | (currentDate, startDate) =>
64 | for {
65 | book <- createIfNotExists(args.book, currentDate)
66 | _ <- MonadThrow[G].raiseWhen(book.startedReading.nonEmpty) {
67 | BookAlreadyBeingReadError(args.book)
68 | }
69 | _ <- bookRepo.startReading(args.book, startDate)
70 | } yield book.copy(startedReading = startDate.some)
71 | Dates
72 | .currentDate(clock)
73 | .flatMap(date => transact(transaction(date, args.date.getOrElse(date))))
74 | }
75 |
76 | override def finishReading(args: MutationFinishReadingArgs): F[UserBook] = {
77 | val transaction: (LocalDate, LocalDate) => G[UserBook] =
78 | (currentDate, finishDate) =>
79 | for {
80 | book <- createIfNotExists(args.book, currentDate)
81 | _ <- bookRepo.finishReading(args.book, finishDate)
82 | } yield book.copy(startedReading = None, lastRead = finishDate.some)
83 | Dates
84 | .currentDate(clock)
85 | .flatMap(date => transact(transaction(date, args.date.getOrElse(date))))
86 | }
87 |
88 | override def deleteBookData(args: MutationDeleteBookDataArgs): F[Unit] =
89 | transact(bookRepo.deleteBookData(args.isbn)).void
90 |
91 | private def createIfNotExists(
92 | book: BookInput,
93 | date: LocalDate
94 | ): G[UserBook] =
95 | for {
96 | maybeBook <- bookRepo.retrieveBook(book.isbn)
97 | _ <- MonadThrow[G].whenA(maybeBook.isEmpty)(
98 | bookRepo.createBook(book, date)
99 | )
100 | } yield maybeBook.getOrElse(book.toUserBook(dateAdded = date.some))
101 | }
102 |
103 | object BookManagementServiceImpl {
104 | def apply[F[_]: MonadThrow, G[_]: MonadThrow](
105 | bookRepo: BookRepository[G],
106 | clock: Clock[F],
107 | transact: G ~> F
108 | ) = new BookManagementServiceImpl[F, G](bookRepo, clock, transact)
109 | }
110 |
--------------------------------------------------------------------------------
/planning.org:
--------------------------------------------------------------------------------
1 | #+TITLE: Planning
2 |
3 | * Scoping
4 |
5 | ** APIs
6 |
7 | *** Google
8 | Google Books API: https://developers.google.com/books/
9 | relevant API doc: https://developers.google.com/books/docs/v1/using
10 |
11 | Allows for searching by title, author and provides links for thumbnails.
12 |
13 | Example:
14 | #+BEGIN_SRC bash
15 | curl -X GET 'https://www.googleapis.com/books/v1/volumes?q=intitle:flowers+inauthor:keyes'
16 | #+END_SRC
17 |
18 | *** Openlibrary
19 |
20 | #+BEGIN_SRC bash
21 | curl -X GET 'http://openlibrary.org/search.json?title=azkaban&fields=title,cover_edition_key,author_name,id_wikidata,isbn'
22 | #+END_SRC
23 |
24 | Then, you'd have to get the description through the works api. The wikidata ids returned also seems to not be great.
25 |
26 | **** Covers
27 | #+BEGIN_SRC bash
28 | curl -X GET 'https://covers.openlibrary.org/b/isbn/9780575097933-L.jpg'
29 | #+END_SRC
30 |
31 |
32 | *** [[https://www.wikidata.org/wiki/Wikidata:SPARQL_tutorial][Wikidata]]
33 |
34 | Get series info from the isbn of one book in the series:
35 |
36 | #+BEGIN_SRC sql
37 | SELECT ?series ?seriesBook ?seriesBookLabel ?ordinal WHERE {
38 | ?book wdt:P212 '978-85-7657-049-3'.
39 | ?book wdt:P179 ?series.
40 | ?series wdt:P527 ?seriesBook.
41 | ?seriesBook p:P179 ?membership.
42 | ?membership pq:P1545 ?ordinal.
43 | SERVICE wikibase:label { bd:serviceParam wikibase:language "en".}
44 | }
45 | #+END_SRC
46 |
47 | Alternatively, get series info from the title/author:
48 |
49 | #+BEGIN_SRC sql
50 | SELECT ?book ?seriesBookLabel ?ordinal WHERE {
51 | ?book wdt:P31 wd:Q7725634.
52 | ?book wdt:P1476 "Harry Potter and the Prisoner of Azkaban"@en.
53 | ?book wdt:P50 ?author.
54 | ?author rdfs:label "J. K. Rowling"@en.
55 | ?book wdt:P179 ?series.
56 | ?series wdt:P527 ?seriesBook.
57 | ?seriesBook p:P179 ?membership.
58 | ?membership pq:P1545 ?ordinal.
59 | SERVICE wikibase:label { bd:serviceParam wikibase:language "en".}
60 | } limit 100
61 | #+END_SRC
62 |
63 |
64 | #+BEGIN_SRC bash
65 | curl -H "Accept: application/json" -G https://query.wikidata.org/sparql --data-urlencode query="
66 | SELECT ?series ?seriesBook ?seriesBookLabel ?ordinal WHERE {
67 | ?book wdt:P212 '978-85-7657-049-3'.
68 | ?book wdt:P179 ?series.
69 | ?series wdt:P527 ?seriesBook.
70 | ?seriesBook p:P179 ?membership.
71 | ?membership pq:P1545 ?ordinal.
72 | SERVICE wikibase:label { bd:serviceParam wikibase:language 'en'.}
73 | }"
74 | #+END_SRC
75 |
76 | The problem here is that the returned isbns are no good since they point to french translations. Proposed solution is to rely on author and title to fill in the data.
77 |
78 | * TODO list
79 |
80 | ** Bigger things
81 | *** DONE Remove poc package
82 | *** DONE Add test framework to sbt
83 | *** DONE Add docker build (with docker-compose)
84 | *** DONE Integrate with GH Actions
85 | *** DONE db backend
86 | *** TODO Import Goodreads shelves
87 | *** TODO Import Calibre collections (what are they called??)
88 | *** DONE Implement langRestrict
89 | *** DONE Add asc/desc to collection sorting
90 | *** DONE Implement adding to default collection
91 | *** DONE Add integration tests
92 | *** DONE Make isbn return multiple books
93 |
94 | ** Smaller Things
95 | *** DONE Add a logging framework
96 | *** DONE Log when decoding fails (ie with title: hi, author: there)
97 | *** DONE Sort out logging middleware
98 | *** DONE Add scalafix (for imports, etc)
99 | *** DONE Decrease/investigate memory usage
100 | *** DONE Get better errors than "Effect Failure"
101 | *** DONE Add error classes for better testing than ~isLeft~
102 | *** DONE Add typeclass to put objects into ~Bindings~
103 | *** DONE Add logging to file in config directory
104 | https://gist.github.com/greenlaw110/e32d0cb433ee89b12790ad75e94d3a91
105 | *** DONE Add IOCaseApp for flags
106 | *** DONE Add tracing
107 | *** TODO Add cli option for just outputting the default config
108 | *** DONE Replace betterfiles with fs2 File ops
109 | *** DONE Why do we get mulitple:
110 | 18:29:07.639 [blaze-selector-0] INFO o.h.b.c.nio1.NIO1SocketServerGroup - Accepted connection from /0:0:0:0:0:0:0:1:43412
111 | *** DONE Log response timings on the INFO log level
112 | *** TODO Add generic fixtures, and functions to get e.g. mocked services
113 |
114 | ** Bugs
115 | *** DONE Adding the same book to a collection results in an uhelpful sql error
116 | *** DONE Author search of 'tolkien' returns an error (bad google data?)
117 | *** TODO ~books~ parameter for ~createCollection~ does not result in books being added to the collection
118 |
119 |
--------------------------------------------------------------------------------
/finito/core/src/fin/service/summary/BufferedImageMontageService.scala:
--------------------------------------------------------------------------------
1 | package fin.service.summary
2 |
3 | import java.awt.Image
4 | import java.awt.image.BufferedImage
5 | import java.io.ByteArrayOutputStream
6 | import java.util.Base64
7 | import javax.imageio.ImageIO
8 |
9 | import cats.Parallel
10 | import cats.effect.kernel.Async
11 | import cats.implicits._
12 | import org.typelevel.log4cats.Logger
13 |
14 | import fin.NoBooksFoundForMontageError
15 | import fin.Types._
16 |
17 | class BufferedImageMontageService[F[_]: Async: Parallel: Logger]
18 | extends MontageService[F] {
19 |
20 | private val imageType = BufferedImage.TYPE_INT_ARGB
21 |
22 | override def montage(
23 | books: List[UserBook],
24 | maybeSpecification: Option[MontageInput]
25 | ): F[String] = {
26 | val specification = maybeSpecification.getOrElse(MontageInputs.default)
27 | for {
28 | chunksEither <- books.parTraverse { b =>
29 | download(b.thumbnailUri).map { img =>
30 | val width = specification.largeImageWidth
31 | val height = specification.largeImageHeight
32 | if (b.rating.exists(_ >= specification.largeImageRatingThreshold)) {
33 | val resizedImg = resize(img, width, height)
34 | split(resizedImg, specification)
35 | } else {
36 | val resizedImg = resize(img, width / 2, height / 2)
37 | (SingularChunk(resizedImg): ImageChunk)
38 | }
39 | }.attempt
40 | }
41 | (errors, chunks) = chunksEither.partitionEither(identity)
42 | _ <- Logger[F].error(errors.toString)
43 | _ <- Async[F].raiseWhen(chunks.isEmpty)(NoBooksFoundForMontageError)
44 | map = ImageStitch.stitch(chunks, specification.columns)
45 | img = collageBufferedImages(map, specification)
46 | b64 <- imgToBase64(img)
47 | } yield b64
48 | }
49 |
50 | private def imgToBase64(img: BufferedImage): F[String] =
51 | for {
52 | os <- Async[F].delay(new ByteArrayOutputStream())
53 | _ <- Async[F].delay(ImageIO.write(img, "png", os))
54 | b64 <-
55 | Async[F].delay(Base64.getEncoder().encodeToString(os.toByteArray()))
56 | } yield b64
57 |
58 | private def collageBufferedImages(
59 | chunkMapping: Map[(Int, Int), SingularChunk],
60 | specification: MontageInput
61 | ): BufferedImage = {
62 | val columns = specification.columns
63 | val (w, h) = MontageInputs.smallImageDim(specification)
64 | val rows = (chunkMapping.keySet.map(_._1) + 0).max
65 | val img = new BufferedImage(columns * w, (rows + 1) * h, imageType)
66 | val g2d = img.createGraphics()
67 | chunkMapping.foreach { case ((r, c), chunk) =>
68 | g2d.drawImage(chunk.img, c * w, r * h, null)
69 | }
70 | img
71 | }
72 |
73 | private def download(uri: String): F[BufferedImage] =
74 | for {
75 | url <- Async[F].delay(new java.net.URL(uri))
76 | img <- Async[F].blocking(ImageIO.read(url))
77 | } yield img
78 |
79 | private def resize(img: BufferedImage, w: Int, h: Int): BufferedImage = {
80 | // https://stackoverflow.com/questions/9417356/bufferedimage-resize
81 | val tmp = img.getScaledInstance(w, h, Image.SCALE_SMOOTH)
82 | val dimg = new BufferedImage(w, h, imageType)
83 | val g2d = dimg.createGraphics()
84 | g2d.drawImage(tmp, 0, 0, null)
85 | g2d.dispose()
86 | dimg
87 | }
88 |
89 | private def split(
90 | img: BufferedImage,
91 | specification: MontageInput
92 | ): CompositeChunk = {
93 | val imgScaleFactor = specification.largeImgScaleFactor
94 | val (w, h) = MontageInputs.smallImageDim(specification)
95 | val subImages = List
96 | .tabulate(imgScaleFactor, imgScaleFactor) { case (y, x) =>
97 | SingularChunk(img.getSubimage(x * w, y * h, w, h))
98 | }
99 | .flatten
100 | CompositeChunk(imgScaleFactor, subImages.toList)
101 | }
102 | }
103 |
104 | object BufferedImageMontageService {
105 | def apply[F[_]: Async: Parallel: Logger] = new BufferedImageMontageService[F]
106 | }
107 |
108 | object MontageInputs {
109 | def default: MontageInput = new MontageInput(6, 128, 196, 2, 5)
110 | def smallImageDim(specification: MontageInput): (Int, Int) =
111 | (smallImageWidth(specification), smallImageHeight(specification))
112 | def smallImageWidth(specification: MontageInput): Int =
113 | specification.largeImageWidth / specification.largeImgScaleFactor
114 | def smallImageHeight(specification: MontageInput): Int =
115 | specification.largeImageHeight / specification.largeImgScaleFactor
116 | }
117 |
--------------------------------------------------------------------------------
/finito/main/src/fin/Main.scala:
--------------------------------------------------------------------------------
1 | package fin
2 |
3 | import scala.concurrent.duration._
4 |
5 | import cats.effect._
6 | import cats.effect.std.{Dispatcher, Env}
7 | import cats.implicits._
8 | import com.comcast.ip4s._
9 | import doobie._
10 | import org.http4s.client.Client
11 | import org.http4s.ember.client.EmberClientBuilder
12 | import org.http4s.ember.server.EmberServerBuilder
13 | import org.typelevel.ci._
14 | import org.typelevel.log4cats.Logger
15 | import org.typelevel.log4cats.slf4j.Slf4jLogger
16 | import zio.Runtime
17 |
18 | import fin.config._
19 | import fin.persistence._
20 |
21 | object Main extends IOApp {
22 |
23 | implicit val zioRuntime: zio.Runtime[zio.Clock with zio.Console] =
24 | Runtime.default.withEnvironment(
25 | zio.ZEnvironment[zio.Clock, zio.Console](
26 | zio.Clock.ClockLive,
27 | zio.Console.ConsoleLive
28 | )
29 | )
30 | implicit val logger: Logger[IO] = Slf4jLogger.getLogger
31 |
32 | val env = Env[IO]
33 |
34 | def run(arg: List[String]): IO[ExitCode] = {
35 | val server = serviceResources(env).use { serviceResources =>
36 | implicit val dispatcherEv = serviceResources.dispatcher
37 | val config = serviceResources.config
38 | val timer = Temporal[IO]
39 |
40 | for {
41 | _ <- FlywaySetup.init[IO](
42 | serviceResources.databaseUri,
43 | config.databaseUser,
44 | config.databasePassword
45 | )
46 | _ <- logger.info(
47 | show"Starting finito server version ${BuildInfo.version}"
48 | )
49 | _ <- logger.debug("Creating services...")
50 | services <- Services[IO](serviceResources)
51 | _ <- logger.debug("Bootstrapping caliban...")
52 | interpreter <- CalibanSetup.interpreter[IO](services)
53 |
54 | logLevel <- env.get("LOG_LEVEL")
55 | debug = logLevel.exists(CIString(_) === ci"DEBUG")
56 | refresherIO = (timer.sleep(1.minute) >> Routes.keepFresh[IO](
57 | serviceResources.client,
58 | timer,
59 | config.port,
60 | config.host
61 | )).background.useForever
62 | _ <- logger.debug("Starting http4s server...")
63 | port <- IO.fromOption(Port.fromInt(config.port))(
64 | new Exception(show"Invalid value for port: '${config.port}'")
65 | )
66 | host <- IO.fromOption(Host.fromString(config.host))(
67 | new Exception(show"Invalid value for host: '${config.host}'")
68 | )
69 | _ <-
70 | EmberServerBuilder
71 | .default[IO]
72 | .withPort(port)
73 | .withHost(host)
74 | .withHttpApp(Routes.routes[IO](interpreter, debug))
75 | .build
76 | .use(_ => IO.never)
77 | .both(refresherIO)
78 | } yield ()
79 | }
80 | server.as(ExitCode.Success)
81 | }
82 |
83 | private def serviceResources(
84 | env: Env[IO]
85 | ): Resource[IO, ServiceResources[IO]] =
86 | for {
87 | client <- EmberClientBuilder.default[IO].build
88 | config <- Resource.eval(FinitoFiles.config[IO](env))
89 | dbPath <- Resource.eval(FinitoFiles.databasePath[IO](env))
90 | _ <- Resource.eval(FinitoFiles.backupPath[IO](dbPath))
91 | dbUri = FinitoFiles.databaseUri(dbPath)
92 | transactor <- TransactorSetup.sqliteTransactor[IO](dbUri)
93 | dispatcher <- Dispatcher.parallel[IO]
94 | } yield ServiceResources(client, config, transactor, dispatcher, dbUri)
95 | }
96 |
97 | object Banner {
98 | val value: String = """
99 | _________________
100 | < Server started! >
101 | -----------------
102 | \ . .
103 | \ / `. .' "
104 | \ .---. < > < > .---.
105 | \ | \ \ - ~ ~ - / / |
106 | _____ ..-~ ~-..-~
107 | | | \~~~\.' `./~~~/
108 | --------- \__/ \__/
109 | .' O \ / / \ "
110 | (_____, `._.' | } \/~~~/
111 | `----. / } | / \__/
112 | `-. | / | / `. ,~~|
113 | ~-.__| /_ - ~ ^| /- _ `..-'
114 | | / | / ~-. `-. _ _ _
115 | |_____| |_____| ~ - . _ _ _ _ _>
116 | """
117 | }
118 |
119 | final case class ServiceResources[F[_]](
120 | client: Client[F],
121 | config: ServiceConfig,
122 | transactor: Transactor[F],
123 | dispatcher: Dispatcher[F],
124 | databaseUri: String
125 | )
126 |
--------------------------------------------------------------------------------
/finito/core/src/fin/service/search/GoogleBookInfoService.scala:
--------------------------------------------------------------------------------
1 | package fin.service.search
2 |
3 | import cats.MonadThrow
4 | import cats.effect.Concurrent
5 | import cats.implicits._
6 | import io.circe.parser.decode
7 | import org.http4s._
8 | import org.http4s.client._
9 | import org.http4s.implicits._
10 | import org.typelevel.log4cats.Logger
11 |
12 | import fin.Types._
13 | import fin._
14 |
15 | import GoogleBooksAPIDecoding._
16 |
17 | /** A BookInfoService implementation which uses the Google Books
19 | * API
20 | *
21 | * @param client
22 | * http client
23 | */
24 | class GoogleBookInfoService[F[_]: Concurrent: Logger] private (
25 | client: Client[F]
26 | ) extends BookInfoService[F] {
27 |
28 | import GoogleBookInfoService._
29 |
30 | def search(booksArgs: QueryBooksArgs): F[List[UserBook]] =
31 | for {
32 | uri <- MonadThrow[F].fromEither(uriFromBooksArgs(booksArgs))
33 | _ <- Logger[F].debug(uri.toString)
34 | books <- booksFromUri(uri, searchPartialFn)
35 | } yield books
36 |
37 | def fromIsbn(bookArgs: QueryBookArgs): F[List[UserBook]] = {
38 | val uri = uriFromBookArgs(bookArgs)
39 | for {
40 | _ <- Logger[F].debug(uri.toString)
41 | books <- booksFromUri(uri, isbnPartialFn)
42 | } yield books
43 | }
44 |
45 | private def booksFromUri(
46 | uri: Uri,
47 | pf: PartialFunction[GoogleVolume, UserBook]
48 | ): F[List[UserBook]] = {
49 | val request = Request[F](uri = uri, headers = headers)
50 | for {
51 | json <- client.expect[String](request)
52 | // We would have to use implicitly[MonadThrow[F]] without
53 | // import cats.effect.syntax._
54 | googleResponse <- MonadThrow[F].fromEither(decode[GoogleResponse](json))
55 | _ <- Logger[F].debug("DECODED: " + googleResponse)
56 | } yield googleResponse.items
57 | .getOrElse(List.empty)
58 | .sorted(responseOrdering)
59 | .collect(pf)
60 | }
61 | }
62 |
63 | /** Utilities for decoding responses from the google books API
64 | */
65 | object GoogleBookInfoService {
66 |
67 | val headers = Headers(
68 | ("Accept-Encoding", "gzip"),
69 | ("User-Agent", "finito (gzip)")
70 | )
71 |
72 | val noDescriptionFillIn = "No Description!"
73 |
74 | val responseOrdering: Ordering[GoogleVolume] =
75 | Ordering.by(gVolume => gVolume.volumeInfo.description.isEmpty)
76 |
77 | val searchPartialFn: PartialFunction[GoogleVolume, UserBook] = {
78 | case GoogleVolume(
79 | GoogleBookItem(
80 | Some(title),
81 | Some(authors),
82 | maybeDescription,
83 | Some(GoogleImageLinks(_, largeThumbnail)),
84 | Some(industryIdentifier :: _)
85 | )
86 | ) =>
87 | UserBook(
88 | title,
89 | authors,
90 | maybeDescription.getOrElse(noDescriptionFillIn),
91 | industryIdentifier.getIsbn13,
92 | largeThumbnail,
93 | None,
94 | None,
95 | None,
96 | None,
97 | None
98 | )
99 | }
100 |
101 | private val emptyThumbnailUri =
102 | "https://user-images.githubusercontent.com/17688577/131221362-c9fdb33a-e833-4469-8705-2c99a2b00fe3.png"
103 |
104 | val isbnPartialFn: PartialFunction[GoogleVolume, UserBook] = {
105 | case GoogleVolume(bookItem) =>
106 | UserBook(
107 | bookItem.title.getOrElse("???"),
108 | bookItem.authors.getOrElse(List("???")),
109 | bookItem.description.getOrElse(noDescriptionFillIn),
110 | bookItem.industryIdentifiers
111 | .getOrElse(Nil)
112 | .headOption
113 | .fold("???")(_.getIsbn13),
114 | bookItem.imageLinks.fold(emptyThumbnailUri)(_.thumbnail),
115 | None,
116 | None,
117 | None,
118 | None,
119 | None
120 | )
121 | }
122 |
123 | private val baseUri = uri"https://www.googleapis.com/books/v1/volumes"
124 |
125 | def apply[F[_]: Concurrent: Logger](client: Client[F]) =
126 | new GoogleBookInfoService[F](client)
127 |
128 | def uriFromBooksArgs(booksArgs: QueryBooksArgs): Either[Throwable, Uri] =
129 | Either.cond(
130 | booksArgs.authorKeywords.exists(_.nonEmpty) ||
131 | booksArgs.titleKeywords.exists(_.nonEmpty),
132 | baseUri +? (
133 | (
134 | "q",
135 | (booksArgs.titleKeywords.filterNot(_.isEmpty).map("intitle:" + _) ++
136 | booksArgs.authorKeywords.filterNot(_.isEmpty).map("inauthor:" + _))
137 | .mkString("+")
138 | )
139 | ) +? (("fields", GoogleBooksAPIDecoding.fieldsSelector))
140 | +?? (("maxResults", booksArgs.maxResults))
141 | +?? (("langRestrict", booksArgs.langRestrict)),
142 | NoKeywordsSpecifiedError
143 | )
144 |
145 | def uriFromBookArgs(bookArgs: QueryBookArgs): Uri =
146 | baseUri +? (("q", "isbn:" + bookArgs.isbn))
147 | }
148 |
--------------------------------------------------------------------------------
/finito/core/test/src/fin/service/summary/BufferedImageMontageServiceTest.scala:
--------------------------------------------------------------------------------
1 | package fin.service.summary
2 |
3 | import java.io.{ByteArrayInputStream, File}
4 | import java.util.Base64
5 | import javax.imageio.ImageIO
6 |
7 | import scala.util.Random
8 |
9 | import cats.effect.IO
10 | import cats.implicits._
11 | import org.typelevel.log4cats.Logger
12 | import org.typelevel.log4cats.slf4j.Slf4jLogger
13 | import weaver._
14 |
15 | import fin.BookConversions._
16 | import fin.NoBooksFoundForMontageError
17 | import fin.Types._
18 |
19 | object BufferedImageMontageServiceTest extends SimpleIOSuite {
20 |
21 | implicit def unsafeLogger: Logger[IO] = Slf4jLogger.getLogger
22 | def service = BufferedImageMontageService[IO]
23 |
24 | val uris = List(
25 | "http://books.google.com/books/content?id=sMHmCwAAQBAJ&printsec=frontcover&img=1&zoom=1&edge=curl&source=gbs_api",
26 | "http://books.google.com/books/content?id=OV4eQgAACAAJ&printsec=frontcover&img=1&zoom=1&source=gbs_api",
27 | "http://books.google.com/books/content?id=JYMLR4gzSR8C&printsec=frontcover&img=1&zoom=1&edge=curl&source=gbs_api",
28 | "http://books.google.com/books/content?id=E8Zp238yVY0C&printsec=frontcover&img=1&zoom=1&edge=curl&source=gbs_api",
29 | "http://books.google.com/books/content?id=reNQtm7Nv9kC&printsec=frontcover&img=1&zoom=1&edge=curl&source=gbs_api",
30 | "http://books.google.com/books/content?id=B91TKeLQ54EC&printsec=frontcover&img=1&zoom=1&edge=curl&source=gbs_api",
31 | "http://books.google.com/books/content?id=gnwETwF8Zb4C&printsec=frontcover&img=1&zoom=1&edge=curl&source=gbs_api",
32 | "http://books.google.com/books/content?id=_0YB05NPhJUC&printsec=frontcover&img=1&zoom=1&source=gbs_api",
33 | "http://books.google.com/books/content?id=TnzyrQEACAAJ&printsec=frontcover&img=1&zoom=1&source=gbs_api",
34 | "http://books.google.com/books/content?id=cIMdYAAACAAJ&printsec=frontcover&img=1&zoom=1&source=gbs_api",
35 | "http://books.google.com/books/content?id=kd1XlWVAIWQC&printsec=frontcover&img=1&zoom=1&edge=curl&source=gbs_api",
36 | "http://books.google.com/books/content?id=Jmv6DwAAQBAJ&printsec=frontcover&img=1&zoom=1&edge=curl&source=gbs_api",
37 | "http://books.google.com/books/content?id=75C5DAAAQBAJ&printsec=frontcover&img=1&zoom=1&edge=curl&source=gbs_api",
38 | "http://books.google.com/books/content?id=1FrJqcRILaoC&printsec=frontcover&img=1&zoom=1&edge=curl&source=gbs_api",
39 | "http://books.google.com/books/content?id=pilZDwAAQBAJ&printsec=frontcover&img=1&zoom=1&edge=curl&source=gbs_api",
40 | "http://books.google.com/books/content?id=oct4DwAAQBAJ&printsec=frontcover&img=1&zoom=1&edge=curl&source=gbs_api",
41 | "http://books.google.com/books/content?id=CVBObgUR2zcC&printsec=frontcover&img=1&zoom=1&edge=curl&source=gbs_api",
42 | "http://books.google.com/books/content?id=V5s14nks9I8C&printsec=frontcover&img=1&zoom=1&edge=curl&source=gbs_api",
43 | "http://books.google.com/books/content?id=MoEO9onVftUC&printsec=frontcover&img=1&zoom=1&edge=curl&source=gbs_api",
44 | "http://books.google.com/books/content?id=DTS-zQEACAAJ&printsec=frontcover&img=1&zoom=1&source=gbs_api"
45 | )
46 |
47 | test("montage runs successfully") {
48 | val books =
49 | uris.map(uri =>
50 | UserBook(
51 | uri.drop(38),
52 | List.empty,
53 | "",
54 | "",
55 | uri,
56 | None,
57 | Some(5 - (Random.nextInt() % 5)),
58 | None,
59 | None,
60 | None
61 | )
62 | )
63 | for {
64 | montage <- service.montage(books, None)
65 | b64 <- IO(Base64.getDecoder().decode(montage))
66 | is <- IO(new ByteArrayInputStream(b64))
67 | img <- IO(ImageIO.read(is))
68 | _ <- IO(ImageIO.write(img, "png", new File("montage.png")))
69 | } yield success
70 | }
71 |
72 | test("montage has image of correct height") {
73 | val noImages = 16
74 | val book =
75 | BookInput("title", List("author"), "cool description", "???", "uri")
76 | val imgUri =
77 | "https://user-images.githubusercontent.com/17688577/144673930-add9233d-9308-4972-8043-2f519d808874.png"
78 | val books = (1 to noImages).toList.map { idx =>
79 | book
80 | .copy(
81 | title = show"book-$idx",
82 | isbn = show"isbn-$idx",
83 | thumbnailUri = imgUri
84 | )
85 | .toUserBook()
86 | }
87 | for {
88 | montage <- service.montage(books, None)
89 | b64 <- IO(Base64.getDecoder().decode(montage))
90 | is <- IO(new ByteArrayInputStream(b64))
91 | img <- IO(ImageIO.read(is))
92 | } yield expect(
93 | Math
94 | .ceil(noImages.toDouble / MontageInputs.default.columns)
95 | .toInt * MontageInputs.smallImageHeight(MontageInputs.default) == img
96 | .getHeight()
97 | )
98 | }
99 |
100 | test("montage errors with no books") {
101 | for {
102 | result <- service.montage(List.empty, None).attempt
103 | } yield expect(result == NoBooksFoundForMontageError.asLeft)
104 | }
105 | }
106 |
--------------------------------------------------------------------------------
/finito/core/src/fin/service/book/WikidataSeriesInfoService.scala:
--------------------------------------------------------------------------------
1 | package fin.service.book
2 |
3 | import cats.effect.Concurrent
4 | import cats.implicits._
5 | import cats.{MonadThrow, Parallel}
6 | import io.circe._
7 | import io.circe.generic.semiauto._
8 | import io.circe.parser.decode
9 | import org.http4s._
10 | import org.http4s.client._
11 | import org.http4s.implicits._
12 | import org.typelevel.log4cats.Logger
13 |
14 | import fin.Types._
15 | import fin.service.search.BookInfoService
16 |
17 | class WikidataSeriesInfoService[F[_]: Concurrent: Parallel: Logger] private (
18 | client: Client[F],
19 | bookInfoService: BookInfoService[F]
20 | ) extends SeriesInfoService[F] {
21 |
22 | import WikidataDecoding._
23 |
24 | private val uri = uri"https://query.wikidata.org/sparql"
25 | private val headers = Headers(("Accept", "application/json"))
26 |
27 | override def series(args: QuerySeriesArgs): F[List[UserBook]] = {
28 | val BookInput(title, authors, _, _, _) = args.book
29 | val author = authors.headOption.getOrElse("???")
30 | // Here we and try work around Wikidata using author names like 'Iain Banks' rather than 'Iain M. Banks'
31 | val authorFallback = author.split(" ").toList match {
32 | case first :: (_ +: List(last)) => s"$first $last"
33 | case _ => author
34 | }
35 | val body = sparqlQuery(List(author, authorFallback).distinct, title)
36 | val request =
37 | Request[F](uri = uri +? (("query", body)), headers = headers)
38 | for {
39 | json <- client.expect[String](request)
40 | response <- MonadThrow[F].fromEither(decode[WikidataSeriesResponse](json))
41 | titlesAndStrOrdinals =
42 | response.results.bindings
43 | .map(e => (e.seriesBookLabel.value, e.ordinal.value))
44 | .distinct
45 | titlesAndOrdinals <- titlesAndStrOrdinals.traverse {
46 | case (title, ordinalStr) =>
47 | MonadThrow[F]
48 | .fromOption(
49 | ordinalStr.toIntOption,
50 | new Exception(
51 | show"Expected int for ordinal of $title, but was $ordinalStr"
52 | )
53 | )
54 | .tupleLeft(title)
55 | }
56 | booksAndOrdinals <- titlesAndOrdinals.parFlatTraverse {
57 | case (title, ordinal) =>
58 | topSearchResult(author, title).map(_.tupleRight(ordinal).toList)
59 | }
60 | } yield booksAndOrdinals.sortBy(_._2).map(_._1)
61 | }
62 |
63 | private def topSearchResult(
64 | author: String,
65 | title: String
66 | ): F[Option[UserBook]] =
67 | for {
68 | books <-
69 | bookInfoService
70 | .search(
71 | QueryBooksArgs(title.some, author.some, None, None)
72 | )
73 | _ <- MonadThrow[F].whenA(books.isEmpty) {
74 | Logger[F].warn(
75 | show"No book information found for $title and $author, not showing in series"
76 | )
77 | }
78 | } yield books.headOption
79 |
80 | private def sparqlQuery(authors: List[String], title: String): String = {
81 | val authorFilter = authors
82 | .map(a => s"""?authorLabel = "$a"@en""")
83 | .mkString("FILTER(", " || ", ")")
84 |
85 | s"""
86 | |SELECT ?book ?seriesBookLabel ?ordinal WHERE {
87 | | ?book wdt:P31 wd:Q7725634.
88 | | ?book rdfs:label "$title"@en.
89 | | ?book wdt:P50 ?author.
90 | | ?author rdfs:label ?authorLabel.
91 | | $authorFilter
92 | | ?book wdt:P179 ?series.
93 | | ?series wdt:P527 ?seriesBook.
94 | | ?seriesBook p:P179 ?membership.
95 | | ?membership pq:P1545 ?ordinal.
96 | | SERVICE wikibase:label { bd:serviceParam wikibase:language "en".}
97 | |} limit 100""".stripMargin
98 |
99 | }
100 | }
101 |
102 | object WikidataSeriesInfoService {
103 | def apply[F[_]: Concurrent: Parallel: Logger](
104 | client: Client[F],
105 | bookInfoService: BookInfoService[F]
106 | ) = new WikidataSeriesInfoService[F](client, bookInfoService)
107 | }
108 |
109 | object WikidataDecoding {
110 | implicit val wikidataBookOrdinalDecoder: Decoder[WikidataBookOrdinal] =
111 | deriveDecoder[WikidataBookOrdinal]
112 |
113 | implicit val wikidataBookLabelDecoder: Decoder[WikidataBookLabel] =
114 | deriveDecoder[WikidataBookLabel]
115 |
116 | implicit val wikidatSeriesEntryDecoder: Decoder[WikidataSeriesEntry] =
117 | deriveDecoder[WikidataSeriesEntry]
118 |
119 | implicit val wikidataBindingsDecoder: Decoder[WikidataBindings] =
120 | deriveDecoder[WikidataBindings]
121 |
122 | implicit val wikidataSeriesResponseDecoder: Decoder[WikidataSeriesResponse] =
123 | deriveDecoder[WikidataSeriesResponse]
124 | }
125 |
126 | final case class WikidataSeriesResponse(results: WikidataBindings)
127 |
128 | final case class WikidataBindings(bindings: List[WikidataSeriesEntry])
129 |
130 | final case class WikidataSeriesEntry(
131 | seriesBookLabel: WikidataBookLabel,
132 | ordinal: WikidataBookOrdinal
133 | )
134 |
135 | final case class WikidataBookLabel(value: String)
136 |
137 | final case class WikidataBookOrdinal(value: String)
138 |
--------------------------------------------------------------------------------
/finito/core/src/fin/service/book/SpecialBookService.scala:
--------------------------------------------------------------------------------
1 | package fin.service.book
2 |
3 | import cats.effect.Sync
4 | import cats.syntax.all._
5 | import org.typelevel.log4cats.Logger
6 |
7 | import fin.CollectionAlreadyExistsError
8 | import fin.Types._
9 | import fin.service.collection._
10 |
11 | import HookType._
12 | import Bindable._
13 |
14 | class SpecialBookService[F[_]: Sync: Logger] private (
15 | wrappedCollectionService: CollectionService[F],
16 | wrappedBookService: BookManagementService[F],
17 | specialCollections: List[SpecialCollection],
18 | hookExecutionService: HookExecutionService[F]
19 | ) extends BookManagementService[F] {
20 |
21 | private val collectionHooks = specialCollections.flatMap(_.collectionHooks)
22 |
23 | override def books: F[List[UserBook]] = wrappedBookService.books
24 |
25 | override def createBook(args: MutationCreateBookArgs): F[UserBook] =
26 | wrappedBookService.createBook(args)
27 |
28 | override def createBooks(books: List[UserBook]): F[List[UserBook]] =
29 | wrappedBookService.createBooks(books)
30 |
31 | override def rateBook(args: MutationRateBookArgs): F[UserBook] =
32 | for {
33 | response <- wrappedBookService.rateBook(args)
34 | bindings = Map("rating" -> args.rating).asBindings
35 | _ <- processHooks(
36 | collectionHooks.filter(_.`type` === HookType.Rate),
37 | bindings,
38 | args.book
39 | )
40 | } yield response
41 |
42 | override def addBookReview(args: MutationAddBookReviewArgs): F[UserBook] =
43 | wrappedBookService.addBookReview(args)
44 |
45 | override def startReading(args: MutationStartReadingArgs): F[UserBook] =
46 | for {
47 | response <- wrappedBookService.startReading(args)
48 | _ <- processHooks(
49 | collectionHooks.filter(_.`type` === HookType.ReadStarted),
50 | SBindings.empty,
51 | args.book
52 | )
53 | } yield response
54 |
55 | override def finishReading(args: MutationFinishReadingArgs): F[UserBook] =
56 | for {
57 | response <- wrappedBookService.finishReading(args)
58 | _ <- processHooks(
59 | collectionHooks.filter(_.`type` === HookType.ReadCompleted),
60 | SBindings.empty,
61 | args.book
62 | )
63 | } yield response
64 |
65 | override def deleteBookData(args: MutationDeleteBookDataArgs): F[Unit] =
66 | wrappedBookService.deleteBookData(args)
67 |
68 | private def processHooks(
69 | hooks: List[CollectionHook],
70 | bindings: SBindings,
71 | book: BookInput
72 | ): F[Unit] =
73 | for {
74 | hookResponses <- hookExecutionService.processHooks(hooks, bindings, book)
75 | _ <- hookResponses.traverse {
76 | case (hook, ProcessResult.Add) =>
77 | specialCollections
78 | .find(_.name === hook.collection)
79 | .traverse(sc => addHookCollection(sc, book))
80 | case (hook, ProcessResult.Remove) =>
81 | specialCollections
82 | .find(_.name === hook.collection)
83 | .traverse(sc => removeHookCollection(sc, book))
84 | }
85 | } yield ()
86 |
87 | private def addHookCollection(
88 | collection: SpecialCollection,
89 | book: BookInput
90 | ): F[Unit] = {
91 | Logger[F].info(
92 | show"Adding ${book.title} to special collection '${collection.name}'"
93 | ) *>
94 | createCollectionIfNotExists(collection.name, collection.preferredSort) *>
95 | wrappedCollectionService
96 | .addBookToCollection(
97 | MutationAddBookArgs(collection.name.some, book)
98 | )
99 | .void
100 | .handleErrorWith { err =>
101 | Logger[F].error(
102 | show"""
103 | |Unable to add book to special collection '${collection.name}',
104 | |reason: ${err.getMessage}""".stripMargin.replace("\n", " ")
105 | )
106 | }
107 | }
108 |
109 | private def removeHookCollection(
110 | collection: SpecialCollection,
111 | book: BookInput
112 | ): F[Unit] = {
113 | Logger[F].info(
114 | show"Removing ${book.title} from special collection '${collection.name}'"
115 | ) *>
116 | createCollectionIfNotExists(collection.name, collection.preferredSort) *>
117 | wrappedCollectionService
118 | .removeBookFromCollection(
119 | MutationRemoveBookArgs(collection.name, book.isbn)
120 | )
121 | .void
122 | .handleErrorWith { err =>
123 | Logger[F].error(
124 | show"""
125 | |Unable to remove book from special collection
126 | |'${collection.name}', reason: ${err.getMessage}""".stripMargin
127 | .replace("\n", " ")
128 | )
129 | }
130 | }
131 |
132 | private def createCollectionIfNotExists(
133 | collection: String,
134 | maybeSort: Option[Sort]
135 | ): F[Unit] =
136 | wrappedCollectionService
137 | .createCollection(
138 | MutationCreateCollectionArgs(
139 | collection,
140 | None,
141 | maybeSort.map(_.`type`),
142 | maybeSort.map(_.sortAscending)
143 | )
144 | )
145 | .void
146 | .recover { case _: CollectionAlreadyExistsError => () }
147 | }
148 |
149 | object SpecialBookService {
150 | def apply[F[_]: Sync: Logger](
151 | wrappedCollectionService: CollectionService[F],
152 | wrappedBookService: BookManagementService[F],
153 | specialCollections: List[SpecialCollection],
154 | hookExecutionService: HookExecutionService[F]
155 | ) =
156 | new SpecialBookService[F](
157 | wrappedCollectionService,
158 | wrappedBookService,
159 | specialCollections,
160 | hookExecutionService
161 | )
162 | }
163 |
--------------------------------------------------------------------------------
/finito/core/src/fin/service/collection/CollectionServiceImpl.scala:
--------------------------------------------------------------------------------
1 | package fin.service.collection
2 |
3 | import java.time.LocalDate
4 |
5 | import cats.effect._
6 | import cats.implicits._
7 | import cats.{MonadThrow, ~>}
8 |
9 | import fin.BookConversions._
10 | import fin.Types._
11 | import fin._
12 | import fin.persistence.{CollectionRepository, Dates}
13 |
14 | import CollectionServiceImpl._
15 |
16 | class CollectionServiceImpl[F[_]: MonadThrow, G[_]: MonadThrow] private (
17 | collectionRepo: CollectionRepository[G],
18 | clock: Clock[F],
19 | transact: G ~> F
20 | ) extends CollectionService[F] {
21 |
22 | override def collections: F[List[Collection]] =
23 | transact(collectionRepo.collections)
24 |
25 | override def createCollection(
26 | args: MutationCreateCollectionArgs
27 | ): F[Collection] = {
28 | val transaction = for {
29 | maybeExistingCollection <-
30 | collectionRepo.collection(args.name, None, None)
31 | sort = Sort(
32 | args.preferredSortType.getOrElse(defaultSort.`type`),
33 | args.sortAscending.getOrElse(defaultSort.sortAscending)
34 | )
35 | _ <- maybeExistingCollection.fold(
36 | collectionRepo.createCollection(args.name, sort)
37 | ) { collection =>
38 | MonadThrow[G].raiseError(
39 | CollectionAlreadyExistsError(collection.name)
40 | )
41 | }
42 | } yield Collection(
43 | args.name,
44 | args.books.fold(List.empty[UserBook])(_.map(_.toUserBook())),
45 | sort,
46 | None
47 | )
48 | transact(transaction)
49 | }
50 |
51 | override def createCollections(
52 | names: Set[String]
53 | ): F[List[Collection]] =
54 | transact(
55 | collectionRepo.createCollections(
56 | names,
57 | defaultSort
58 | )
59 | ).as(names.map(n => Collection(n, List.empty, defaultSort, None)).toList)
60 |
61 | override def collection(
62 | args: QueryCollectionArgs
63 | ): F[Collection] =
64 | transact(
65 | collectionOrError(
66 | args.name,
67 | args.booksPagination.map(_.first),
68 | args.booksPagination.map(_.after)
69 | )
70 | )
71 |
72 | override def deleteCollection(
73 | args: MutationDeleteCollectionArgs
74 | ): F[Unit] = transact(collectionRepo.deleteCollection(args.name))
75 |
76 | override def updateCollection(
77 | args: MutationUpdateCollectionArgs
78 | ): F[Collection] = {
79 | val transaction = for {
80 | _ <- MonadThrow[G].raiseUnless(
81 | List(args.newName, args.preferredSortType, args.sortAscending)
82 | .exists(_.nonEmpty)
83 | )(NotEnoughArgumentsForUpdateError)
84 | collection <- collectionOrError(args.currentName)
85 | _ <- args.newName.traverse(errorIfCollectionExists)
86 | sort = Sort(
87 | args.preferredSortType.getOrElse(collection.preferredSort.`type`),
88 | args.sortAscending.getOrElse(collection.preferredSort.sortAscending)
89 | )
90 | _ <- collectionRepo.updateCollection(
91 | args.currentName,
92 | args.newName.getOrElse(collection.name),
93 | sort
94 | )
95 | } yield collection.copy(
96 | name = args.newName.getOrElse(collection.name),
97 | preferredSort = sort
98 | )
99 | transact(transaction)
100 | }
101 |
102 | override def addBookToCollection(
103 | args: MutationAddBookArgs
104 | ): F[Collection] = {
105 | val transaction: LocalDate => G[Collection] = date =>
106 | for {
107 | collectionName <- MonadThrow[G].fromOption(
108 | args.collection,
109 | DefaultCollectionNotSupportedError
110 | )
111 | collection <- collectionOrError(collectionName).ensureOr { c =>
112 | BookAlreadyInCollectionError(c.name, args.book.title)
113 | } { c =>
114 | c.books.forall(_.isbn =!= args.book.isbn)
115 | }
116 | _ <- collectionRepo.addBookToCollection(collectionName, args.book, date)
117 | } yield collection.copy(books =
118 | args.book.toUserBook() :: collection.books
119 | )
120 | Dates.currentDate(clock).flatMap(date => transact(transaction(date)))
121 | }
122 |
123 | override def removeBookFromCollection(
124 | args: MutationRemoveBookArgs
125 | ): F[Unit] = {
126 | val transaction =
127 | for {
128 | collection <- collectionOrError(args.collection)
129 | _ <- collectionRepo.removeBookFromCollection(
130 | args.collection,
131 | args.isbn
132 | )
133 | } yield collection.copy(books =
134 | collection.books.filterNot(_.isbn === args.isbn)
135 | )
136 | transact(transaction).void
137 | }
138 |
139 | private def collectionOrError(
140 | collection: String,
141 | bookLimit: Option[Int] = None,
142 | bookOffset: Option[Int] = None
143 | ): G[Collection] =
144 | for {
145 | maybeCollection <-
146 | collectionRepo.collection(collection, bookLimit, bookOffset)
147 | collection <- MonadThrow[G].fromOption(
148 | maybeCollection,
149 | CollectionDoesNotExistError(collection)
150 | )
151 | } yield collection
152 |
153 | private def errorIfCollectionExists(collection: String): G[Unit] =
154 | collectionRepo
155 | .collection(collection, None, None)
156 | .ensure(CollectionAlreadyExistsError(collection))(_.isEmpty)
157 | .void
158 | }
159 |
160 | object CollectionServiceImpl {
161 |
162 | val defaultSort: Sort =
163 | Sort(`type` = SortType.DateAdded, sortAscending = false)
164 |
165 | def apply[F[_]: MonadThrow, G[_]: MonadThrow](
166 | collectionRepo: CollectionRepository[G],
167 | clock: Clock[F],
168 | transact: G ~> F
169 | ) =
170 | new CollectionServiceImpl[F, G](collectionRepo, clock, transact)
171 | }
172 |
--------------------------------------------------------------------------------
/finito/main/resources/graphiql.html:
--------------------------------------------------------------------------------
1 |
2 |
9 |
10 |
11 |
12 |
23 |
24 |
31 |
32 |
33 |
34 |
35 |
36 |
41 |
42 |
43 |
44 |
45 |
46 | Loading...
47 |
154 |
155 |
156 |
157 |
--------------------------------------------------------------------------------
/finito/main/src/fin/FinitoFiles.scala:
--------------------------------------------------------------------------------
1 | package fin
2 |
3 | import cats.effect.kernel.Sync
4 | import cats.effect.std.Env
5 | import cats.syntax.all._
6 | import fs2.io.file._
7 | import io.circe._
8 | import io.circe.generic.extras.Configuration
9 | import io.circe.generic.extras.semiauto._
10 | import io.circe.parser.decode
11 | import org.typelevel.log4cats.Logger
12 |
13 | import fin.config.ServiceConfig
14 | import fin.service.collection._
15 |
16 | object FinitoFiles {
17 |
18 | private val FinitoDir = "libro-finito"
19 |
20 | def databaseUri(databasePath: Path): String =
21 | s"jdbc:sqlite:${databasePath.absolute}"
22 |
23 | def databasePath[F[_]: Sync: Files: Logger](env: Env[F]): F[Path] = {
24 | val dbName = "db.sqlite"
25 | for {
26 | xdgDataDir <- xdgDirectory(env, "XDG_DATA_HOME", ".local/share")
27 | dbPath = xdgDataDir / dbName
28 |
29 | finitoDataDirExists <- Files[F].exists(xdgDataDir)
30 | _ <- Sync[F].unlessA(finitoDataDirExists) {
31 | Files[F].createDirectories(xdgDataDir) *>
32 | xdgConfigDirectory(env).flatMap { confPath =>
33 | val deprecatedPath = confPath / dbName
34 | Sync[F].ifM(Files[F].exists(deprecatedPath))(
35 | Files[F].move(deprecatedPath, dbPath) *> Logger[F].info(
36 | show"Moved db in config directory '$deprecatedPath' to new path '$dbPath' (see https://specifications.freedesktop.org/basedir-spec/latest/index.html for more information)"
37 | ),
38 | Sync[F].unit
39 | )
40 | }
41 | }
42 | _ <- Logger[F].info(show"Using data directory $xdgDataDir")
43 | } yield dbPath.absolute
44 | }
45 |
46 | def backupPath[F[_]: Sync: Files: Logger](path: Path): F[Option[Path]] = {
47 | lazy val backupPath = path.resolveSibling(path.fileName.toString + ".bkp")
48 | Sync[F].ifM(Files[F].exists(path))(
49 | Files[F].copy(path, backupPath, CopyFlags(CopyFlag.ReplaceExisting)) *>
50 | Logger[F]
51 | .info(show"Backed up $path to $backupPath")
52 | .as(Some(backupPath)),
53 | Logger[F].info(show"$path does not exist, not backing up").as(None)
54 | )
55 | }
56 |
57 | def config[F[_]: Sync: Files: Logger](env: Env[F]): F[ServiceConfig] =
58 | for {
59 | configDir <- xdgConfigDirectory(env)
60 | _ <- Logger[F].info(show"Using config directory $configDir")
61 | _ <- Files[F].createDirectories(configDir)
62 | configPath = configDir / "service.conf"
63 |
64 | configPathExists <- Files[F].exists(configPath)
65 | (config, msg) <-
66 | if (configPathExists)
67 | readUserConfig[F](configPath).tupleRight(
68 | show"Found config file at $configPath"
69 | )
70 | else
71 | Sync[F].pure(
72 | (
73 | emptyServiceConfig.toServiceConfig(configExists = false),
74 | show"No config file found at $configPath, using defaults"
75 | )
76 | )
77 | _ <- Logger[F].info(msg)
78 | } yield config
79 |
80 | private def xdgConfigDirectory[F[_]: Sync](env: Env[F]): F[Path] =
81 | xdgDirectory(env, "XDG_CONFIG_HOME", ".config")
82 |
83 | private def xdgDirectory[F[_]: Sync](
84 | env: Env[F],
85 | envVar: String,
86 | fallback: String
87 | ): F[Path] = {
88 | lazy val fallbackConfigDir = Sync[F]
89 | .delay(System.getProperty("user.home"))
90 | .map(s => Path(s) / fallback)
91 |
92 | env
93 | .get(envVar)
94 | .flatMap { opt =>
95 | opt.fold(fallbackConfigDir)(s => Sync[F].pure(Path(s)))
96 | }
97 | .map(path => (path / FinitoDir).absolute)
98 | }
99 |
100 | private def readUserConfig[F[_]: Sync: Files: Logger](
101 | configPath: Path
102 | ): F[ServiceConfig] = {
103 | for {
104 | configContents <- Files[F].readUtf8(configPath).compile.string
105 | // Working with typesafe config is such a nightmare 🤮 so we read and then straight encode to
106 | // JSON and then decode that (it was a mistake using HOCON).
107 | configObj <- Sync[F].delay(
108 | com.typesafe.config.ConfigFactory.parseString(configContents)
109 | )
110 | configStr <- Sync[F].delay(
111 | configObj
112 | .root()
113 | .render(
114 | com.typesafe.config.ConfigRenderOptions.concise()
115 | )
116 | )
117 | configNoDefaults <-
118 | Sync[F].fromEither(decode[ServiceConfigNoDefaults](configStr))
119 | config = configNoDefaults.toServiceConfig(
120 | configExists = true
121 | )
122 | _ <- Logger[F].debug(show"Config: $config")
123 | } yield config
124 | }
125 |
126 | private final case class ServiceConfigNoDefaults(
127 | databasePath: Option[String],
128 | databaseUser: Option[String],
129 | databasePassword: Option[String],
130 | host: Option[String],
131 | port: Option[Int],
132 | defaultCollection: Option[String],
133 | specialCollections: Option[List[SpecialCollection]]
134 | ) {
135 | def toServiceConfig(
136 | configExists: Boolean
137 | ): ServiceConfig =
138 | ServiceConfig(
139 | databaseUser =
140 | databaseUser.getOrElse(ServiceConfig.defaultDatabaseUser),
141 | databasePassword =
142 | databasePassword.getOrElse(ServiceConfig.defaultDatabasePassword),
143 | host = host.getOrElse(ServiceConfig.defaultHost),
144 | port = port.getOrElse(ServiceConfig.defaultPort),
145 | // The only case when we don't set a default collection is when a config file exists
146 | // and it doesn't specify a default collection.
147 | defaultCollection =
148 | if (configExists)
149 | defaultCollection
150 | else
151 | Some(ServiceConfig.defaultDefaultCollection),
152 | specialCollections =
153 | specialCollections.getOrElse(ServiceConfig.defaultSpecialCollections)
154 | )
155 | }
156 |
157 | private val emptyServiceConfig = ServiceConfigNoDefaults(
158 | None,
159 | None,
160 | None,
161 | None,
162 | None,
163 | None,
164 | None
165 | )
166 |
167 | private implicit val customConfig: Configuration =
168 | Configuration.default.withKebabCaseMemberNames.withDefaults
169 |
170 | private implicit val serviceConfigOptionDecoder
171 | : Decoder[ServiceConfigNoDefaults] =
172 | deriveConfiguredDecoder
173 | }
174 |
--------------------------------------------------------------------------------
/finito/core/test/src/fin/service/port/GoodreadsImportServiceTest.scala:
--------------------------------------------------------------------------------
1 | package fin.service.port
2 |
3 | import java.time.LocalDate
4 | import java.time.format.DateTimeFormatter
5 |
6 | import cats.arrow.FunctionK
7 | import cats.effect._
8 | import cats.effect.std.Random
9 | import cats.implicits._
10 | import org.typelevel.cats.time._
11 | import org.typelevel.log4cats.Logger
12 | import org.typelevel.log4cats.slf4j.Slf4jLogger
13 | import weaver._
14 |
15 | import fin.Types._
16 | import fin.fixtures
17 | import fin.persistence.BookRepository
18 | import fin.service.book._
19 | import fin.service.collection._
20 | import fin.service.port._
21 |
22 | object GoodreadsImportServiceTest extends IOSuite {
23 |
24 | val defaultCollectionBook = fixtures.bookInput
25 | val collection1 = "cool-stuff-collection"
26 | val collection2 = "the-best-collection"
27 | val (title1, title2) = ("Gardens of the Moon", "The Caves of Steel")
28 | val rating = 5
29 | val dateRead = LocalDate.of(2023, 1, 13)
30 | val dateReadStr = dateRead.format(DateTimeFormatter.ofPattern("yyyy/MM/dd"))
31 |
32 | def csv(random: Random[IO]): IO[(String, String, String)] =
33 | for {
34 | isbn1 <- random.nextString(10)
35 | isbn2 <- random.nextString(10)
36 | } yield (
37 | isbn1,
38 | isbn2,
39 | show"""Book Id,Title,Author,Author l-f,Additional Authors,ISBN,ISBN13,My Rating,Average Rating,Publisher,Binding,Number of Pages,Year Published,Original Publication Year,Date Read,Date Added,Bookshelves,Bookshelves with positions,Exclusive Shelf,My Review,Spoiler,Private Notes,Read Count,Owned Copies
40 | 13450209,"$title1",Steven Erikson,"Erikson, Steven",,$isbn1,9781409083108,$rating,3.92,Transworld Publishers,ebook,768,2009,1999,$dateReadStr,2023/01/01,"$collection1, favorites, $collection2","$collection1 (#2), favorites (#1), $collection2 (#1)",read,,,,1,0
41 | 11097712,"$title2",Isaac Asimov,"Asimov, Isaac",,$isbn2,=9780307792419,0,4.19,Spectra,Kindle Edition,271,2011,1953,,2021/09/04,,,currently-reading,,,,1,0"""
42 | )
43 |
44 | implicit val logger: Logger[IO] = Slf4jLogger.getLogger[IO]
45 |
46 | override type Res =
47 | (
48 | GoodreadsImportService[IO],
49 | BookRepository[IO],
50 | CollectionService[IO],
51 | Random[IO]
52 | )
53 |
54 | override def sharedResource: Resource[IO, Res] =
55 | for {
56 | colRef <- Resource.eval(Ref.of[IO, List[Collection]](List.empty))
57 | collectionService = CollectionServiceImpl[IO, IO](
58 | new InMemoryCollectionRepository(colRef),
59 | Clock[IO],
60 | FunctionK.id[IO]
61 | )
62 | bookRef <- Resource.eval(Ref.of[IO, List[UserBook]](List.empty))
63 | bookRepo = new InMemoryBookRepository[IO](bookRef)
64 | bookService = BookManagementServiceImpl[IO, IO](
65 | bookRepo,
66 | Clock[IO],
67 | FunctionK.id[IO]
68 | )
69 | books = List(
70 | fixtures.userBook.copy(title = title1),
71 | fixtures.userBook.copy(title = title2)
72 | )
73 | bookInfoService = new BookInfoServiceUsingTitles(books)
74 |
75 | importService = GoodreadsImportService[IO](
76 | bookInfoService,
77 | collectionService,
78 | bookService,
79 | bookService
80 | )
81 | random <- Resource.eval(Random.scalaUtilRandom[IO])
82 | } yield (importService, bookRepo, collectionService, random)
83 |
84 | test("importResource creates books") {
85 | case (importService, bookRepo, _, rnd) =>
86 | for {
87 | (isbn1, isbn2, csv) <- csv(rnd)
88 | importResult <- importService.importResource(csv, None)
89 | book1 <- bookRepo.retrieveBook(isbn1)
90 | book2 <- bookRepo.retrieveBook(isbn2)
91 | } yield expect(book1.nonEmpty) &&
92 | expect(book2.nonEmpty) &&
93 | expect(importResult.successful.length == 2) &&
94 | expect(importResult.partiallySuccessful.length == 0) &&
95 | expect(importResult.unsuccessful.length == 0)
96 | }
97 |
98 | test(
99 | "importResource doesn't fail when called when CSV contains already existing books"
100 | ) { case (importService, bookRepo, _, rnd) =>
101 | for {
102 | (isbn1, isbn2, csv) <- csv(rnd)
103 | _ <- importService.importResource(csv, None)
104 | importResult <- importService.importResource(csv, None)
105 | book1 <- bookRepo.retrieveBook(isbn1)
106 | book2 <- bookRepo.retrieveBook(isbn2)
107 | } yield expect(book1.nonEmpty) &&
108 | expect(book2.nonEmpty) &&
109 | expect(importResult.successful.length == 0) &&
110 | expect(importResult.partiallySuccessful.length == 0) &&
111 | expect(importResult.unsuccessful.length == 0)
112 | }
113 |
114 | test("importResource adds books to correct collections") {
115 | case (importService, _, collectionService, rnd) =>
116 | for {
117 | (_, _, csv) <- csv(rnd)
118 | _ <- importService.importResource(csv, None)
119 | collection <- collectionService.collection(
120 | QueryCollectionArgs(collection1, None)
121 | )
122 | books = collection.books
123 | bookTitles = books.map(_.title)
124 | } yield expect(bookTitles.contains_(title1)) &&
125 | expect(!bookTitles.contains_(title2))
126 | }
127 |
128 | test("importResource marks books as started") {
129 | case (importService, bookRepo, _, rnd) =>
130 | for {
131 | (isbn1, isbn2, csv) <- csv(rnd)
132 | _ <- importService.importResource(csv, None)
133 | book1 <- bookRepo.retrieveBook(isbn1)
134 | book2 <- bookRepo.retrieveBook(isbn2)
135 | } yield expect(book1.exists(_.startedReading.isEmpty)) &&
136 | expect(book2.exists(_.startedReading.nonEmpty))
137 | }
138 |
139 | test("importResource marks books as finished") {
140 | case (importService, bookRepo, _, rnd) =>
141 | for {
142 | (isbn1, isbn2, csv) <- csv(rnd)
143 | _ <- importService.importResource(csv, None)
144 | book1 <- bookRepo.retrieveBook(isbn1)
145 | book2 <- bookRepo.retrieveBook(isbn2)
146 | } yield expect(book1.flatMap(_.lastRead).contains_(dateRead)) &&
147 | expect(book2.exists(_.lastRead.isEmpty))
148 | }
149 |
150 | test("importResource rates books") { case (importService, bookRepo, _, rnd) =>
151 | for {
152 | (isbn1, isbn2, csv) <- csv(rnd)
153 | _ <- importService.importResource(csv, None)
154 | book1 <- bookRepo.retrieveBook(isbn1)
155 | book2 <- bookRepo.retrieveBook(isbn2)
156 | } yield expect(book1.flatMap(_.rating).contains_(rating)) &&
157 | expect(book2.exists(_.rating.isEmpty))
158 | }
159 | }
160 |
--------------------------------------------------------------------------------
/finito/core/src/fin/service/collection/SpecialCollectionService.scala:
--------------------------------------------------------------------------------
1 | package fin.service.collection
2 |
3 | import cats.Show
4 | import cats.effect.Sync
5 | import cats.implicits._
6 | import io.circe._
7 | import io.circe.generic.extras.Configuration
8 | import io.circe.generic.extras.semiauto._
9 | import org.typelevel.log4cats.Logger
10 |
11 | import fin.Types._
12 | import fin._
13 | import fin.implicits._
14 |
15 | import HookType._
16 | import Bindable._
17 |
18 | /** This class manages special collection hooks on top of a collection service.
19 | * Essentially, for each method, we run special collection hooks and then
20 | * delegate to the service.
21 | *
22 | * @param maybeDefaultCollection
23 | * the default collection
24 | * @param wrappedCollectionService
25 | * the wrapped collection service
26 | * @param specialCollections
27 | * special collections
28 | * @param hookExecutionService
29 | * the hook execution service
30 | */
31 | class SpecialCollectionService[F[_]: Sync: Logger] private (
32 | maybeDefaultCollection: Option[String],
33 | wrappedCollectionService: CollectionService[F],
34 | specialCollections: List[SpecialCollection],
35 | hookExecutionService: HookExecutionService[F]
36 | ) extends CollectionService[F] {
37 |
38 | private val collectionHooks = specialCollections.flatMap(_.collectionHooks)
39 |
40 | override def collections: F[List[Collection]] =
41 | wrappedCollectionService.collections
42 |
43 | override def createCollection(
44 | args: MutationCreateCollectionArgs
45 | ): F[Collection] = wrappedCollectionService.createCollection(args)
46 |
47 | override def createCollections(names: Set[String]): F[List[Collection]] =
48 | wrappedCollectionService.createCollections(names)
49 |
50 | override def collection(
51 | args: QueryCollectionArgs
52 | ): F[Collection] = wrappedCollectionService.collection(args)
53 |
54 | override def deleteCollection(
55 | args: MutationDeleteCollectionArgs
56 | ): F[Unit] =
57 | Sync[F].raiseWhen(
58 | collectionHooks.exists(_.collection === args.name)
59 | )(CannotDeleteSpecialCollectionError) *>
60 | wrappedCollectionService.deleteCollection(args)
61 |
62 | override def updateCollection(
63 | args: MutationUpdateCollectionArgs
64 | ): F[Collection] =
65 | Sync[F].raiseWhen(
66 | args.newName.nonEmpty && collectionHooks.exists(
67 | _.collection === args.currentName
68 | )
69 | )(CannotChangeNameOfSpecialCollectionError) *>
70 | wrappedCollectionService.updateCollection(args)
71 |
72 | override def addBookToCollection(
73 | args: MutationAddBookArgs
74 | ): F[Collection] = {
75 | val maybeCollectionName = args.collection.orElse(maybeDefaultCollection)
76 | for {
77 | collectionName <- Sync[F].fromOption(
78 | maybeCollectionName,
79 | DefaultCollectionNotSupportedError
80 | )
81 | response <- wrappedCollectionService.addBookToCollection(
82 | args.copy(collection = collectionName.some)
83 | )
84 | hookResponses <- hookExecutionService.processHooks(
85 | collectionHooks.filter { h =>
86 | h.`type` === HookType.Add && args.collection.exists(
87 | _ =!= h.collection
88 | )
89 | },
90 | Map("collection" -> collectionName).asBindings |+| args.book.asBindings,
91 | args.book
92 | )
93 | _ <- hookResponses.traverse {
94 | case (hook, ProcessResult.Add) =>
95 | specialCollections
96 | .find(_.name === hook.collection)
97 | .traverse(sc => addHookCollection(sc, args.book))
98 | case (hook, ProcessResult.Remove) =>
99 | specialCollections
100 | .find(_.name === hook.collection)
101 | .traverse(sc => removeHookCollection(sc, args.book))
102 | }
103 | } yield response
104 | }
105 |
106 | override def removeBookFromCollection(
107 | args: MutationRemoveBookArgs
108 | ): F[Unit] = wrappedCollectionService.removeBookFromCollection(args)
109 |
110 | private def addHookCollection(
111 | collection: SpecialCollection,
112 | book: BookInput
113 | ): F[Unit] = {
114 | Logger[F].info(
115 | show"Adding ${book.title} to special collection '${collection.name}'"
116 | ) *>
117 | createCollectionIfNotExists(collection.name, collection.preferredSort) *>
118 | wrappedCollectionService
119 | .addBookToCollection(
120 | MutationAddBookArgs(collection.name.some, book)
121 | )
122 | .void
123 | .handleErrorWith { err =>
124 | Logger[F].error(
125 | show"""
126 | |Unable to add book to special collection '${collection.name}',
127 | |reason: ${err.getMessage}""".stripMargin.replace("\n", " ")
128 | )
129 | }
130 | }
131 |
132 | private def removeHookCollection(
133 | collection: SpecialCollection,
134 | book: BookInput
135 | ): F[Unit] = {
136 | Logger[F].info(
137 | show"Removing ${book.title} from special collection '${collection.name}'"
138 | ) *>
139 | wrappedCollectionService
140 | .removeBookFromCollection(
141 | MutationRemoveBookArgs(collection.name, book.isbn)
142 | )
143 | .void
144 | .handleErrorWith { err =>
145 | Logger[F].error(
146 | // TODO don't log error if it's a CollectionAlreadyExistsError
147 | // use .recover instead
148 | show"""
149 | |Unable to remove book from special collection
150 | |'${collection.name}', reason: ${err.getMessage}""".stripMargin
151 | .replace("\n", " ")
152 | )
153 | }
154 | }
155 |
156 | private def createCollectionIfNotExists(
157 | collection: String,
158 | maybeSort: Option[Sort]
159 | ): F[Unit] =
160 | createCollection(
161 | MutationCreateCollectionArgs(
162 | collection,
163 | None,
164 | maybeSort.map(_.`type`),
165 | maybeSort.map(_.sortAscending)
166 | )
167 | ).void.recover { case _: CollectionAlreadyExistsError => () }
168 | }
169 |
170 | object SpecialCollectionService {
171 | def apply[F[_]: Sync: Logger](
172 | maybeDefaultCollection: Option[String],
173 | wrappedCollectionService: CollectionService[F],
174 | specialCollections: List[SpecialCollection],
175 | hookExecutionService: HookExecutionService[F]
176 | ) =
177 | new SpecialCollectionService[F](
178 | maybeDefaultCollection,
179 | wrappedCollectionService,
180 | specialCollections,
181 | hookExecutionService
182 | )
183 |
184 | }
185 |
186 | final case class SpecialCollection(
187 | name: String,
188 | `lazy`: Option[Boolean],
189 | addHook: Option[String],
190 | readStartedHook: Option[String],
191 | readCompletedHook: Option[String],
192 | rateHook: Option[String],
193 | preferredSort: Option[Sort]
194 | ) {
195 | def collectionHooks: List[CollectionHook] =
196 | (addHook.map(CollectionHook(name, HookType.Add, _)) ++
197 | readStartedHook.map(CollectionHook(name, HookType.ReadStarted, _)) ++
198 | readCompletedHook.map(CollectionHook(name, HookType.ReadCompleted, _)) ++
199 | rateHook.map(CollectionHook(name, HookType.Rate, _))).toList
200 | }
201 |
202 | object SpecialCollection {
203 | implicit val specialCollectionShow: Show[SpecialCollection] =
204 | Show.fromToString
205 |
206 | implicit val customConfig: Configuration =
207 | Configuration.default.withKebabCaseMemberNames
208 | implicit val specialCollectionDecoder: Decoder[SpecialCollection] =
209 | deriveConfiguredDecoder
210 | }
211 |
--------------------------------------------------------------------------------
/finito/core/test/src/fin/service/book/SpecialBookServiceTest.scala:
--------------------------------------------------------------------------------
1 | package fin.service.book
2 |
3 | import cats.arrow.FunctionK
4 | import cats.effect._
5 | import cats.implicits._
6 | import org.typelevel.log4cats.Logger
7 | import org.typelevel.log4cats.slf4j.Slf4jLogger
8 | import weaver._
9 |
10 | import fin.BookConversions._
11 | import fin.Types._
12 | import fin.fixtures
13 | import fin.service.collection._
14 |
15 | object SpecialBookServiceTest extends IOSuite {
16 |
17 | val triggerRating = 1337
18 |
19 | val onRateHookCollection = "rated books collection"
20 | val onStartHookCollection = "started books collection"
21 | val onFinishHookCollection = "finished books collection"
22 | val hookAlwaysFalseCollection = "hook evals to always false"
23 | val lazyCollection = "lazy collection"
24 | val specialCollections = List(
25 | SpecialCollection(
26 | onRateHookCollection,
27 | false.some,
28 | None,
29 | None,
30 | None,
31 | "if rating >= 5 then add = true else remove = true end".some,
32 | None
33 | ),
34 | SpecialCollection(
35 | onStartHookCollection,
36 | false.some,
37 | None,
38 | "add = true".some,
39 | None,
40 | None,
41 | None
42 | ),
43 | SpecialCollection(
44 | onFinishHookCollection,
45 | false.some,
46 | None,
47 | None,
48 | "add = true".some,
49 | None,
50 | None
51 | ),
52 | SpecialCollection(
53 | hookAlwaysFalseCollection,
54 | false.some,
55 | "add = false".some,
56 | "add = false".some,
57 | "add = false".some,
58 | "add = false".some,
59 | None
60 | ),
61 | SpecialCollection(
62 | lazyCollection,
63 | true.some,
64 | None,
65 | None,
66 | None,
67 | show"if rating == $triggerRating then add = true else add = false end".some,
68 | None
69 | )
70 | )
71 |
72 | implicit def unsafeLogger: Logger[IO] = Slf4jLogger.getLogger
73 |
74 | override type Res = (CollectionService[IO], BookManagementService[IO])
75 | override def sharedResource
76 | : Resource[IO, (CollectionService[IO], BookManagementService[IO])] =
77 | for {
78 | colRef <- Resource.eval(Ref.of[IO, List[Collection]](List.empty))
79 | wrappedCollectionService = CollectionServiceImpl[IO, IO](
80 | new InMemoryCollectionRepository(colRef),
81 | Clock[IO],
82 | FunctionK.id[IO]
83 | )
84 | bookRef <- Resource.eval(Ref.of[IO, List[UserBook]](List.empty))
85 | wrappedBookService = BookManagementServiceImpl[IO, IO](
86 | new InMemoryBookRepository[IO](bookRef),
87 | Clock[IO],
88 | FunctionK.id[IO]
89 | )
90 | hookExecutionService = HookExecutionServiceImpl[IO]
91 | specialBookService = SpecialBookService[IO](
92 | wrappedCollectionService,
93 | wrappedBookService,
94 | specialCollections,
95 | hookExecutionService
96 | )
97 | _ <- specialCollections.filter(_.`lazy`.contains(false)).traverse {
98 | collection =>
99 | Resource.eval(
100 | wrappedCollectionService.createCollection(
101 | MutationCreateCollectionArgs(
102 | collection.name,
103 | None,
104 | collection.preferredSort.map(_.`type`),
105 | collection.preferredSort.map(_.sortAscending)
106 | )
107 | )
108 | )
109 | }
110 | } yield (wrappedCollectionService, specialBookService)
111 |
112 | test("rateBook adds for matching hook, but not for others") {
113 | case (collectionService, bookService) =>
114 | val book = fixtures.bookInput.copy(isbn = "book to rate")
115 | val rateArgs = MutationRateBookArgs(book, 5)
116 | for {
117 | _ <- bookService.rateBook(rateArgs)
118 | rateHookResponse <- collectionService.collection(
119 | QueryCollectionArgs(onRateHookCollection, None)
120 | )
121 | alwaysFalseHookResponse <- collectionService.collection(
122 | QueryCollectionArgs(hookAlwaysFalseCollection, None)
123 | )
124 | } yield expect(
125 | rateHookResponse.books.contains(book.toUserBook())
126 | ) and expect(
127 | !alwaysFalseHookResponse.books.contains(book.toUserBook())
128 | )
129 | }
130 |
131 | test("startReading adds for matching hook, but not for others") {
132 | case (collectionService, bookService) =>
133 | val book = fixtures.bookInput.copy(isbn = "book to start reading")
134 | val startReadingArgs = MutationStartReadingArgs(book, None)
135 | for {
136 | _ <- bookService.startReading(startReadingArgs)
137 | startReadingHookResponse <- collectionService.collection(
138 | QueryCollectionArgs(onStartHookCollection, None)
139 | )
140 | alwaysFalseHookResponse <- collectionService.collection(
141 | QueryCollectionArgs(hookAlwaysFalseCollection, None)
142 | )
143 | } yield expect(
144 | startReadingHookResponse.books.contains(book.toUserBook())
145 | ) and expect(
146 | !alwaysFalseHookResponse.books.contains(book.toUserBook())
147 | )
148 | }
149 |
150 | test("finishReading adds for matching hook, but not for others") {
151 | case (collectionService, bookService) =>
152 | val book = fixtures.bookInput.copy(isbn = "book to finish reading")
153 | val finishReadingArgs = MutationFinishReadingArgs(book, None)
154 | for {
155 | _ <- bookService.finishReading(finishReadingArgs)
156 | finishReadingHookResponse <- collectionService.collection(
157 | QueryCollectionArgs(onFinishHookCollection, None)
158 | )
159 | alwaysFalseHookResponse <- collectionService.collection(
160 | QueryCollectionArgs(hookAlwaysFalseCollection, None)
161 | )
162 | } yield expect(
163 | finishReadingHookResponse.books.contains(book.toUserBook())
164 | ) and expect(
165 | !alwaysFalseHookResponse.books.contains(book.toUserBook())
166 | )
167 | }
168 |
169 | test("rateBook creates collection if not exists") {
170 | case (collectionService, bookService) =>
171 | val book = fixtures.bookInput.copy(isbn = "book to trigger creation")
172 | val rateArgs = MutationRateBookArgs(book, triggerRating)
173 | for {
174 | _ <- bookService.rateBook(rateArgs)
175 | rateHookResponse <- collectionService.collection(
176 | QueryCollectionArgs(lazyCollection, None)
177 | )
178 | } yield expect(rateHookResponse.books.contains(book.toUserBook()))
179 | }
180 |
181 | test("rateBook silent error if add to special collection fails") {
182 | case (collectionService, bookService) =>
183 | val book = fixtures.bookInput.copy(isbn = "book to rate twice")
184 | val rateArgs = MutationRateBookArgs(book, 5)
185 | for {
186 | _ <- bookService.rateBook(rateArgs)
187 | // We should get a failure here by adding it again
188 | _ <- bookService.rateBook(rateArgs.copy(rating = 6))
189 | rateHookResponse <- collectionService.collection(
190 | QueryCollectionArgs(onRateHookCollection, None)
191 | )
192 | } yield expect(rateHookResponse.books.contains(book.toUserBook()))
193 | }
194 |
195 | test("rateBook removes from collection") {
196 | case (collectionService, bookService) =>
197 | val book = fixtures.bookInput.copy(isbn = "book to rate good then bad")
198 | val rateArgs = MutationRateBookArgs(book, 5)
199 | for {
200 | _ <- bookService.rateBook(rateArgs)
201 | _ <- bookService.rateBook(rateArgs.copy(rating = 2))
202 | rateHookResponse <- collectionService.collection(
203 | QueryCollectionArgs(onRateHookCollection, None)
204 | )
205 | } yield expect(!rateHookResponse.books.contains(book.toUserBook()))
206 | }
207 | }
208 |
--------------------------------------------------------------------------------
/finito/persistence/src/fin/persistence/SqliteBookRepository.scala:
--------------------------------------------------------------------------------
1 | package fin.persistence
2 |
3 | import java.time.LocalDate
4 |
5 | import scala.math.Ordering.Implicits._
6 |
7 | import cats.Monad
8 | import cats.data.NonEmptyList
9 | import cats.implicits._
10 | import doobie.Fragments._
11 | import doobie._
12 | import doobie.implicits._
13 |
14 | import fin.Types._
15 |
16 | object SqliteBookRepository extends BookRepository[ConnectionIO] {
17 |
18 | import BookFragments._
19 |
20 | override def books: ConnectionIO[List[UserBook]] =
21 | allBooks.query[BookRow].to[List].nested.map(_.toBook).value
22 |
23 | override def retrieveBook(isbn: String): ConnectionIO[Option[UserBook]] =
24 | BookFragments
25 | .retrieveBook(isbn)
26 | .query[BookRow]
27 | .option
28 | .nested
29 | .map(_.toBook)
30 | .value
31 |
32 | override def retrieveMultipleBooks(
33 | isbns: List[String]
34 | ): ConnectionIO[List[UserBook]] =
35 | NonEmptyList.fromList(isbns).fold(List.empty[UserBook].pure[ConnectionIO]) {
36 | isbnNel =>
37 | BookFragments
38 | .retrieveMultipleBooks(isbnNel)
39 | .query[BookRow]
40 | .to[List]
41 | .nested
42 | .map(_.toBook)
43 | .value
44 | }
45 |
46 | override def createBook(
47 | book: BookInput,
48 | date: LocalDate
49 | ): ConnectionIO[Unit] =
50 | BookFragments.insert(book, date).update.run.void
51 |
52 | override def createBooks(books: List[UserBook]): ConnectionIO[Unit] =
53 | Monad[ConnectionIO].whenA(books.nonEmpty) {
54 | BookFragments.insertMany(books).update.run.void
55 | }
56 |
57 | override def rateBook(book: BookInput, rating: Int): ConnectionIO[Unit] =
58 | BookFragments.insertRating(book.isbn, rating).update.run.void
59 |
60 | override def addBookReview(
61 | book: BookInput,
62 | review: String
63 | ): ConnectionIO[Unit] =
64 | BookFragments.addReview(book.isbn, review).update.run.void
65 |
66 | override def startReading(
67 | book: BookInput,
68 | date: LocalDate
69 | ): ConnectionIO[Unit] =
70 | BookFragments
71 | .insertCurrentlyReading(book.isbn, date)
72 | .update
73 | .run
74 | .void
75 |
76 | override def finishReading(
77 | book: BookInput,
78 | date: LocalDate
79 | ): ConnectionIO[Unit] =
80 | for {
81 | maybeStarted <-
82 | BookFragments
83 | .retrieveStartedFromCurrentlyReading(book.isbn)
84 | .query[LocalDate]
85 | .option
86 | _ <- maybeStarted.traverse { _ =>
87 | BookFragments.deleteCurrentlyReading(book.isbn).update.run
88 | }
89 | _ <- BookFragments.insertRead(book.isbn, maybeStarted, date).update.run
90 | } yield ()
91 |
92 | override def deleteBookData(isbn: String): ConnectionIO[Unit] =
93 | for {
94 | _ <- BookFragments.deleteCurrentlyReading(isbn).update.run
95 | _ <- BookFragments.deleteRead(isbn).update.run
96 | _ <- BookFragments.deleteRated(isbn).update.run
97 | } yield ()
98 |
99 | override def retrieveBooksInside(
100 | from: LocalDate,
101 | to: LocalDate
102 | ): ConnectionIO[List[UserBook]] =
103 | for {
104 | rawBooks <- BookFragments.allBooks.query[BookRow].to[List]
105 | inRange = (d: LocalDate) => from <= d && d <= to
106 | books =
107 | rawBooks
108 | .map(_.toBook)
109 | .filter { b =>
110 | b.dateAdded.exists(inRange) || b.lastRead.exists(inRange)
111 | }
112 | } yield books
113 | }
114 |
115 | object BookFragments {
116 |
117 | implicit val localDatePut: Put[LocalDate] =
118 | Put[String].contramap(_.toString)
119 |
120 | implicit val localDateGet: Get[LocalDate] =
121 | Get[String].map(LocalDate.parse(_))
122 |
123 | val lastRead: Fragment =
124 | fr"""
125 | |SELECT isbn, MAX(finished) AS finished
126 | |FROM read_books
127 | |GROUP BY isbn""".stripMargin
128 |
129 | def retrieveBook(isbn: String): Fragment =
130 | selectBook ++ fr"WHERE b.isbn=$isbn"
131 |
132 | def retrieveMultipleBooks(isbns: NonEmptyList[String]): Fragment =
133 | selectBook ++ fr"WHERE" ++ in(fr"b.isbn", isbns)
134 |
135 | def checkIsbn(isbn: String): Fragment =
136 | fr"SELECT isbn from books WHERE isbn=$isbn"
137 |
138 | def insert(book: BookInput, date: LocalDate): Fragment =
139 | fr"""
140 | |INSERT INTO books (isbn, title, authors, description, thumbnail_uri, added) VALUES (
141 | | ${book.isbn},
142 | | ${book.title},
143 | | ${book.authors.mkString(",")},
144 | | ${book.description},
145 | | ${book.thumbnailUri},
146 | | $date
147 | |)""".stripMargin
148 |
149 | def insertMany(books: List[UserBook]): Fragment =
150 | books
151 | .map { b =>
152 | fr"""(
153 | | ${b.isbn},
154 | | ${b.title},
155 | | ${b.authors.mkString(",")},
156 | | ${b.description},
157 | | ${b.thumbnailUri},
158 | | ${b.dateAdded},
159 | | ${b.review}
160 | |)""".stripMargin
161 | }
162 | .foldSmash(
163 | fr"INSERT OR IGNORE INTO books (isbn, title, authors, description, thumbnail_uri, added, review) VALUES",
164 | fr",",
165 | Fragment.empty
166 | )
167 |
168 | def addToCollection(collectionName: String, isbn: String): Fragment =
169 | fr"INSERT INTO collection_books VALUES ($collectionName, $isbn)"
170 |
171 | def insertCurrentlyReading(isbn: String, start: LocalDate): Fragment =
172 | fr"""
173 | |INSERT INTO currently_reading_books (isbn, started)
174 | |VALUES ($isbn, $start)""".stripMargin
175 |
176 | def retrieveStartedFromCurrentlyReading(isbn: String): Fragment =
177 | fr"""
178 | |SELECT started FROM currently_reading_books
179 | |WHERE isbn=$isbn""".stripMargin
180 |
181 | def deleteCurrentlyReading(isbn: String): Fragment =
182 | fr"""
183 | |DELETE FROM currently_reading_books
184 | |WHERE isbn = $isbn""".stripMargin
185 |
186 | def insertRead(
187 | isbn: String,
188 | maybeStarted: Option[LocalDate],
189 | finished: LocalDate
190 | ): Fragment =
191 | fr"""
192 | |INSERT OR IGNORE INTO read_books (isbn, started, finished)
193 | |VALUES ($isbn, $maybeStarted, $finished)""".stripMargin
194 |
195 | def insertRating(isbn: String, rating: Int): Fragment =
196 | fr"""
197 | |INSERT INTO rated_books (isbn, rating)
198 | |VALUES ($isbn, $rating)
199 | |ON CONFLICT(isbn)
200 | |DO UPDATE SET rating=excluded.rating""".stripMargin
201 |
202 | def addReview(isbn: String, review: String): Fragment =
203 | fr"""
204 | |UPDATE books
205 | |SET review = $review
206 | |WHERE isbn = $isbn""".stripMargin
207 |
208 | def deleteRead(isbn: String): Fragment =
209 | fr"""
210 | |DELETE FROM read_books
211 | |WHERE isbn = $isbn""".stripMargin
212 |
213 | def deleteRated(isbn: String): Fragment =
214 | fr"""
215 | |DELETE FROM rated_books
216 | |WHERE isbn = $isbn""".stripMargin
217 |
218 | def allBooks: Fragment = selectBook
219 |
220 | private def selectBook: Fragment =
221 | fr"""
222 | |SELECT
223 | | b.title,
224 | | b.authors,
225 | | b.description,
226 | | b.isbn,
227 | | b.thumbnail_uri,
228 | | b.added,
229 | | b.review,
230 | | cr.started,
231 | | lr.finished,
232 | | r.rating
233 | |FROM books b
234 | |LEFT JOIN currently_reading_books cr ON b.isbn = cr.isbn
235 | |LEFT JOIN (${lastRead}) lr ON b.isbn = lr.isbn
236 | |LEFT JOIN rated_books r ON b.isbn = r.isbn""".stripMargin
237 | }
238 |
239 | final case class BookRow(
240 | title: String,
241 | authors: String,
242 | description: String,
243 | isbn: String,
244 | thumbnailUri: String,
245 | maybeAdded: Option[LocalDate],
246 | maybeReview: Option[String],
247 | maybeStarted: Option[LocalDate],
248 | maybeFinished: Option[LocalDate],
249 | maybeRating: Option[Int]
250 | ) {
251 | def toBook: UserBook =
252 | UserBook(
253 | title,
254 | authors.split(",").toList,
255 | description,
256 | isbn,
257 | thumbnailUri,
258 | maybeAdded,
259 | maybeRating,
260 | maybeStarted,
261 | maybeFinished,
262 | maybeReview
263 | )
264 | }
265 |
--------------------------------------------------------------------------------
/finito/core/test/src/fin/service/book/BookManagementServiceImplTest.scala:
--------------------------------------------------------------------------------
1 | package fin.service.book
2 |
3 | import java.time.LocalDate
4 |
5 | import cats.arrow.FunctionK
6 | import cats.effect._
7 | import cats.implicits._
8 | import weaver._
9 |
10 | import fin.BookConversions._
11 | import fin.Types._
12 | import fin.implicits._
13 | import fin.{fixtures, _}
14 |
15 | object BookManagementServiceImplTest extends IOSuite {
16 |
17 | override type Res = BookManagementService[IO]
18 | override def sharedResource: Resource[IO, BookManagementService[IO]] =
19 | Resource.eval(Ref.of[IO, List[UserBook]](List.empty).map { ref =>
20 | val repo = new InMemoryBookRepository(ref)
21 | BookManagementServiceImpl[IO, IO](
22 | repo,
23 | fixtures.clock,
24 | FunctionK.id[IO]
25 | )
26 | })
27 |
28 | test("createBook creates book") { case bookService =>
29 | for {
30 | _ <- bookService.createBook(MutationCreateBookArgs(fixtures.bookInput))
31 | } yield success
32 | }
33 |
34 | test("createBook errors if book already exists") { bookService =>
35 | val copiedBook = fixtures.bookInput.copy(isbn = "copied")
36 | for {
37 | _ <- bookService.createBook(MutationCreateBookArgs(copiedBook))
38 | response <-
39 | bookService.createBook(MutationCreateBookArgs(copiedBook)).attempt
40 | } yield expect(
41 | response.swap.exists(_ == BookAlreadyExistsError(copiedBook))
42 | )
43 | }
44 |
45 | test("rateBook rates book") { bookService =>
46 | val bookToRate = fixtures.bookInput.copy(isbn = "rate")
47 | val rating = 4
48 | for {
49 | _ <- bookService.createBook(MutationCreateBookArgs(bookToRate))
50 | ratedBook <-
51 | bookService.rateBook(MutationRateBookArgs(bookToRate, rating))
52 | } yield expect(
53 | ratedBook ===
54 | bookToRate.toUserBook(
55 | rating = rating.some,
56 | dateAdded = fixtures.date.some
57 | )
58 | )
59 | }
60 |
61 | test("rateBook creates book if not exists") { bookService =>
62 | val bookToRate = fixtures.bookInput.copy(isbn = "rate no book")
63 | val rating = 4
64 | for {
65 | ratedBook <-
66 | bookService.rateBook(MutationRateBookArgs(bookToRate, rating))
67 | } yield expect(
68 | ratedBook ===
69 | bookToRate.toUserBook(
70 | dateAdded = fixtures.date.some,
71 | rating = rating.some
72 | )
73 | )
74 | }
75 |
76 | test("addBookReview adds review to book") { bookService =>
77 | val bookToReview = fixtures.bookInput.copy(isbn = "review")
78 | val review = "Excellent book"
79 | for {
80 | _ <- bookService.createBook(MutationCreateBookArgs(bookToReview))
81 | reviewdBook <- bookService.addBookReview(
82 | MutationAddBookReviewArgs(bookToReview, review)
83 | )
84 | } yield expect(
85 | reviewdBook ===
86 | bookToReview.toUserBook(
87 | review = review.some,
88 | dateAdded = fixtures.date.some
89 | )
90 | )
91 | }
92 |
93 | test("addBookReview creates book if not exists") { bookService =>
94 | val bookToReview = fixtures.bookInput.copy(isbn = "review with no book")
95 | val review = "Very excellent book"
96 | for {
97 | reviewdBook <- bookService.addBookReview(
98 | MutationAddBookReviewArgs(bookToReview, review)
99 | )
100 | } yield expect(
101 | reviewdBook ===
102 | bookToReview.toUserBook(
103 | dateAdded = fixtures.date.some,
104 | review = review.some
105 | )
106 | )
107 | }
108 |
109 | test("startReading starts reading") { bookService =>
110 | val bookToRead = fixtures.bookInput.copy(isbn = "read")
111 | val startedReading = LocalDate.parse("2018-11-30")
112 | for {
113 | _ <- bookService.createBook(MutationCreateBookArgs(bookToRead))
114 | updatedBook <- bookService.startReading(
115 | MutationStartReadingArgs(bookToRead, startedReading.some)
116 | )
117 | } yield expect(
118 | updatedBook ===
119 | bookToRead.toUserBook(
120 | dateAdded = fixtures.date.some,
121 | startedReading = startedReading.some
122 | )
123 | )
124 | }
125 |
126 | test("startReading gets time from clock if not specified in args") {
127 | bookService =>
128 | val bookToRead = fixtures.bookInput.copy(isbn = "read no date")
129 | for {
130 | _ <- bookService.createBook(MutationCreateBookArgs(bookToRead))
131 | updatedBook <-
132 | bookService.startReading(MutationStartReadingArgs(bookToRead, None))
133 | } yield expect(
134 | updatedBook ===
135 | bookToRead.toUserBook(
136 | dateAdded = fixtures.date.some,
137 | startedReading = fixtures.date.some
138 | )
139 | )
140 | }
141 |
142 | test("startReading errors if already reading") { bookService =>
143 | val copiedBook = fixtures.bookInput.copy(isbn = "copied reading")
144 | for {
145 | _ <- bookService.createBook(MutationCreateBookArgs(copiedBook))
146 | _ <- bookService.startReading(MutationStartReadingArgs(copiedBook, None))
147 | response <-
148 | bookService
149 | .startReading(MutationStartReadingArgs(copiedBook, None))
150 | .attempt
151 | } yield expect(
152 | response.swap.exists(_ == BookAlreadyBeingReadError(copiedBook))
153 | )
154 | }
155 |
156 | test("startReading returns lastRead info when applicable") { bookService =>
157 | val popularBook = fixtures.bookInput.copy(isbn = "popular")
158 | for {
159 | _ <- bookService.createBook(MutationCreateBookArgs(popularBook))
160 | _ <- bookService.finishReading(
161 | MutationFinishReadingArgs(popularBook, None)
162 | )
163 | book <-
164 | bookService.startReading(MutationStartReadingArgs(popularBook, None))
165 | } yield expect(
166 | book ===
167 | popularBook.toUserBook(
168 | dateAdded = fixtures.date.some,
169 | startedReading = fixtures.date.some,
170 | lastRead = fixtures.date.some
171 | )
172 | )
173 | }
174 |
175 | test("finishReading finishes reading") { bookService =>
176 | val bookToRead = fixtures.bookInput.copy(isbn = "finished")
177 | val finishedReading = LocalDate.parse("2018-11-30")
178 | for {
179 | _ <- bookService.createBook(MutationCreateBookArgs(bookToRead))
180 | updatedBook <- bookService.finishReading(
181 | MutationFinishReadingArgs(bookToRead, finishedReading.some)
182 | )
183 | } yield expect(
184 | updatedBook ===
185 | bookToRead.toUserBook(
186 | lastRead = finishedReading.some,
187 | dateAdded = fixtures.date.some
188 | )
189 | )
190 | }
191 |
192 | test("finishReading time from clock if not specified in args") {
193 | bookService =>
194 | val bookToRead = fixtures.bookInput.copy(isbn = "finished no date")
195 | for {
196 | _ <- bookService.createBook(MutationCreateBookArgs(bookToRead))
197 | updatedBook <- bookService.finishReading(
198 | MutationFinishReadingArgs(bookToRead, None)
199 | )
200 | } yield expect(
201 | updatedBook ===
202 | bookToRead.toUserBook(
203 | lastRead = fixtures.date.some,
204 | dateAdded = fixtures.date.some
205 | )
206 | )
207 | }
208 |
209 | test("deleteBookData deletes book data") { _ =>
210 | val bookToClear = fixtures.bookInput.copy(isbn = "book to delete data from")
211 | val finishedReading = LocalDate.parse("2018-11-30")
212 | val bookRef = Ref.unsafe[IO, List[UserBook]](List.empty)
213 | val repo = new InMemoryBookRepository(bookRef)
214 | val service =
215 | BookManagementServiceImpl[IO, IO](
216 | repo,
217 | fixtures.clock,
218 | FunctionK.id[IO]
219 | )
220 |
221 | for {
222 | _ <- service.finishReading(
223 | MutationFinishReadingArgs(bookToClear, finishedReading.some)
224 | )
225 | _ <- service.startReading(MutationStartReadingArgs(bookToClear, None))
226 | _ <- service.rateBook(MutationRateBookArgs(bookToClear, 3))
227 | _ <- service.deleteBookData(
228 | MutationDeleteBookDataArgs(bookToClear.isbn)
229 | )
230 | book <- repo.retrieveBook(bookToClear.isbn)
231 | } yield expect(
232 | book.exists(_ == bookToClear.toUserBook(dateAdded = fixtures.date.some))
233 | )
234 | }
235 | }
236 |
--------------------------------------------------------------------------------
/finito/persistence/test/src/fin/persistence/SqliteBookRepositoryTest.scala:
--------------------------------------------------------------------------------
1 | package fin.persistence
2 |
3 | import java.time.LocalDate
4 |
5 | import cats.implicits._
6 | import cats.kernel.Eq
7 | import doobie._
8 | import doobie.implicits._
9 |
10 | import fin.BookConversions._
11 | import fin.fixtures
12 | import fin.implicits._
13 |
14 | object SqliteBookRepositoryTest extends SqliteSuite {
15 |
16 | import BookFragments._
17 |
18 | implicit val dateEq: Eq[LocalDate] = Eq.fromUniversalEquals
19 |
20 | val repo = SqliteBookRepository
21 |
22 | testDoobie("createBook creates book") {
23 | for {
24 | _ <- repo.createBook(fixtures.bookInput, fixtures.date)
25 | maybeBook <- repo.retrieveBook(fixtures.bookInput.isbn)
26 | } yield expect(
27 | maybeBook.exists(
28 | _ === fixtures.bookInput.toUserBook(dateAdded = fixtures.date.some)
29 | )
30 | )
31 | }
32 |
33 | testDoobie("rateBook rates book") {
34 | val bookToRate = fixtures.bookInput.copy(isbn = "rateme")
35 | val rating = 5
36 | for {
37 | _ <- repo.createBook(bookToRate, fixtures.date)
38 | _ <- repo.rateBook(bookToRate, rating)
39 | maybeRating <- retrieveRating(bookToRate.isbn)
40 | } yield expect(maybeRating.contains_(rating))
41 | }
42 |
43 | testDoobie("addBookReview adds review") {
44 | val bookToRate = fixtures.bookInput.copy(isbn = "to-review")
45 | val review = "A great book!"
46 | for {
47 | _ <- repo.createBook(bookToRate, fixtures.date)
48 | _ <- repo.addBookReview(bookToRate, review)
49 | maybeReview <- retrieveReview(bookToRate.isbn)
50 | } yield expect(maybeReview.contains_(review))
51 | }
52 |
53 | testDoobie("startReading starts book reading") {
54 | val bookToRead = fixtures.bookInput.copy(isbn = "reading")
55 | for {
56 | _ <- repo.createBook(bookToRead, fixtures.date)
57 | _ <- repo.startReading(bookToRead, fixtures.date)
58 | maybeEpoch <- retrieveReading(bookToRead.isbn)
59 | } yield expect(maybeEpoch.contains_(fixtures.date))
60 | }
61 |
62 | testDoobie("finishReading finishes book reading") {
63 | val finishedDate = LocalDate.parse("2020-03-24")
64 | val bookToFinish = fixtures.bookInput.copy(isbn = "finished")
65 | for {
66 | _ <- repo.createBook(bookToFinish, fixtures.date)
67 | _ <- repo.startReading(bookToFinish, fixtures.date)
68 | _ <- repo.finishReading(bookToFinish, finishedDate)
69 | maybeDates <- retrieveFinished(bookToFinish.isbn)
70 | (maybeStarted, maybeFinished) = maybeDates.unzip
71 | } yield expect(maybeStarted.flatten.contains_(fixtures.date)) and
72 | expect(maybeFinished.contains_(finishedDate))
73 | }
74 |
75 | testDoobie("finishReading deletes row from currently_reading table") {
76 | val finishedDate = LocalDate.parse("2020-03-24")
77 | val bookToFinish = fixtures.bookInput.copy(isbn = "finished-and-delete")
78 | for {
79 | _ <- repo.createBook(bookToFinish, fixtures.date)
80 | _ <- repo.startReading(bookToFinish, fixtures.date)
81 | _ <- repo.finishReading(bookToFinish, finishedDate)
82 | maybeDate <- retrieveReading(bookToFinish.isbn)
83 | } yield expect(maybeDate.isEmpty)
84 | }
85 |
86 | testDoobie(
87 | "finishReading sets started to null when no existing currently reading"
88 | ) {
89 | val finishedDate = LocalDate.parse("2020-03-24")
90 | val bookToFinish = fixtures.bookInput.copy(isbn = "finished-no-reading")
91 | for {
92 | _ <- repo.createBook(bookToFinish, fixtures.date)
93 | _ <- repo.finishReading(bookToFinish, finishedDate)
94 | maybeRead <- retrieveFinished(bookToFinish.isbn).map(_._1F)
95 | // maybeRead should be Some(None) => ie found a date but was null
96 | } yield expect(maybeRead.exists(_.isEmpty))
97 | }
98 |
99 | testDoobie(
100 | "finishReading ignores duplicate entries"
101 | ) {
102 | val finishedDate = LocalDate.parse("2020-03-24")
103 | val bookToFinish = fixtures.bookInput.copy(isbn = "finished-duplicated")
104 | for {
105 | _ <- repo.createBook(bookToFinish, fixtures.date)
106 | _ <- repo.finishReading(bookToFinish, finishedDate)
107 | response <- repo.finishReading(bookToFinish, finishedDate).attempt
108 | } yield expect(response.isRight)
109 | }
110 |
111 | testDoobie("retrieveBook retrieves all parts of book") {
112 | val bookToUse = fixtures.bookInput.copy(isbn = "megabook")
113 | val rating = 3
114 | val startedReadingDate = LocalDate.parse("2020-03-28")
115 | for {
116 | _ <- repo.createBook(bookToUse, fixtures.date)
117 | _ <- repo.rateBook(bookToUse, rating)
118 | _ <- repo.finishReading(bookToUse, fixtures.date)
119 | _ <- repo.startReading(bookToUse, startedReadingDate)
120 | maybeBook <- repo.retrieveBook(bookToUse.isbn)
121 | } yield expect(
122 | maybeBook.exists(
123 | _ ===
124 | bookToUse.toUserBook(
125 | dateAdded = fixtures.date.some,
126 | rating = rating.some,
127 | startedReading = startedReadingDate.some,
128 | lastRead = fixtures.date.some
129 | )
130 | )
131 | )
132 | }
133 |
134 | testDoobie("deleteBookData deletes all book data") {
135 | val bookToUse = fixtures.bookInput.copy(isbn = "book to delete data from")
136 | val startedReadingDate = LocalDate.parse("2020-03-28")
137 | for {
138 | _ <- repo.createBook(bookToUse, fixtures.date)
139 | _ <- repo.rateBook(bookToUse, 3)
140 | _ <- repo.finishReading(bookToUse, fixtures.date)
141 | _ <- repo.startReading(bookToUse, startedReadingDate)
142 | _ <- repo.deleteBookData(bookToUse.isbn)
143 | maybeBook <- repo.retrieveBook(bookToUse.isbn)
144 | } yield expect(
145 | maybeBook.exists(
146 | _ === bookToUse.toUserBook(dateAdded = fixtures.date.some)
147 | )
148 | )
149 | }
150 |
151 | testDoobie("retrieveMultipleBooks retrieves all matching books") {
152 | val (isbn1, isbn2, isbn3) = ("book1", "book2", "book3")
153 | val isbns = List(isbn1, isbn2, isbn3)
154 | val book1 = fixtures.bookInput.copy(isbn = isbn1)
155 | val book2 = fixtures.bookInput.copy(isbn = isbn2)
156 | val book3 = fixtures.bookInput.copy(isbn = isbn3)
157 | for {
158 | _ <- repo.createBook(book1, fixtures.date)
159 | _ <- repo.createBook(book2, fixtures.date)
160 | _ <- repo.createBook(book3, fixtures.date)
161 | books <- repo.retrieveMultipleBooks(isbns)
162 | } yield expect(books.size == 3) and expect(
163 | books.contains(book1.toUserBook(dateAdded = fixtures.date.some))
164 | ) and expect(
165 | books.contains(book2.toUserBook(dateAdded = fixtures.date.some))
166 | ) and expect(
167 | books.contains(book3.toUserBook(dateAdded = fixtures.date.some))
168 | )
169 | }
170 |
171 | testDoobie("retrieveBooksInside retrieves books within interval") {
172 | val date1 = LocalDate.parse("1920-03-20")
173 | val date2 = LocalDate.parse("1920-05-13")
174 | val book1 = fixtures.bookInput.copy(isbn = "old book 1")
175 | val book2 = fixtures.bookInput.copy(isbn = "old book 2")
176 | for {
177 | _ <- repo.createBook(book1, date1)
178 | _ <- repo.createBook(book2, date2)
179 | books <- repo.retrieveBooksInside(
180 | LocalDate.parse("1920-01-01"),
181 | LocalDate.parse("1921-01-01")
182 | )
183 | } yield expect(
184 | books.sameElements(
185 | List(
186 | book1.toUserBook(dateAdded = date1.some),
187 | book2.toUserBook(dateAdded = date2.some)
188 | )
189 | )
190 | )
191 | }
192 |
193 | testDoobie("retrieveBooksInside returns nothing for empty interval") {
194 | for {
195 | books <- repo.retrieveBooksInside(
196 | LocalDate.parse("1820-01-01"),
197 | LocalDate.parse("1821-01-01")
198 | )
199 | } yield expect(books.isEmpty)
200 | }
201 |
202 | private def retrieveRating(isbn: String): ConnectionIO[Option[Int]] =
203 | fr"SELECT rating FROM rated_books WHERE isbn=$isbn".stripMargin
204 | .query[Int]
205 | .option
206 |
207 | private def retrieveReview(isbn: String): ConnectionIO[Option[String]] =
208 | fr"SELECT review FROM books WHERE isbn=$isbn".stripMargin
209 | .query[String]
210 | .option
211 |
212 | private def retrieveReading(isbn: String): ConnectionIO[Option[LocalDate]] =
213 | fr"SELECT started FROM currently_reading_books WHERE isbn=$isbn"
214 | .query[LocalDate]
215 | .option
216 |
217 | private def retrieveFinished(
218 | isbn: String
219 | ): ConnectionIO[Option[(Option[LocalDate], LocalDate)]] =
220 | fr"SELECT started, finished FROM read_books WHERE isbn=$isbn"
221 | .query[(Option[LocalDate], LocalDate)]
222 | .option
223 | }
224 |
--------------------------------------------------------------------------------
/finito/core/src/fin/service/port/GoodreadsImportService.scala:
--------------------------------------------------------------------------------
1 | package fin.service.port
2 |
3 | import java.time.LocalDate
4 | import java.time.format.DateTimeFormatter
5 |
6 | import scala.concurrent.duration._
7 |
8 | import cats.effect._
9 | import cats.effect.implicits._
10 | import cats.effect.kernel.Async
11 | import cats.implicits._
12 | import fs2.Fallible
13 | import fs2.data.csv._
14 | import fs2.data.csv.generic.semiauto._
15 | import org.typelevel.log4cats.Logger
16 |
17 | import fin.BookConversions._
18 | import fin.Types._
19 | import fin.service.book._
20 | import fin.service.collection._
21 | import fin.service.search.BookInfoService
22 | import fin.{BookAlreadyBeingReadError, BookAlreadyInCollectionError}
23 |
24 | /** https://www.goodreads.com/review/import
25 | */
26 | class GoodreadsImportService[F[_]: Async: Logger](
27 | bookInfoService: BookInfoService[F],
28 | collectionService: CollectionService[F],
29 | bookManagementService: BookManagementService[F],
30 | specialBookManagementService: BookManagementService[F]
31 | ) extends ApplicationImportService[F] {
32 |
33 | import GoodreadsImportService._
34 | private val parallelism = 1
35 | private val timer = Temporal[F]
36 |
37 | override def importResource(
38 | content: String,
39 | langRestrict: Option[String]
40 | ): F[ImportResult] = {
41 | val result =
42 | fs2.Stream
43 | .emit(content.replace("\\n", "\n").replace("\\\"", "\""))
44 | .covary[Fallible]
45 | .through(decodeSkippingHeaders[GoodreadsCSVRow]())
46 | .compile
47 | .toList
48 | for {
49 | // Books stuff
50 | _ <- Logger[F].debug(
51 | show"Received ${content.length} chars worth of content"
52 | )
53 | rows <- Async[F].fromEither(result)
54 |
55 | userBooks <- createBooks(rows, langRestrict)
56 | _ <- markBooks(userBooks)
57 |
58 | // Collections stuff
59 | rawBookShelfMap = rows
60 | .map(row => row.sanitizedIsbn -> row.bookshelves)
61 | .toMap
62 | existingCollections <- collectionService.collections.map { ls =>
63 | ls.map(_.name).toSet
64 | }
65 | bookShelfMap = rawBookShelfMap.map { case (isbn, shelves) =>
66 | isbn -> {
67 | val filtered = shelves.filterNot(SpecialGoodreadsShelves.contains)
68 | // Match e.g. 'wishlist' to 'Wishlist'
69 | filtered.map { shelf =>
70 | existingCollections
71 | .find(_.toLowerCase === shelf.toLowerCase)
72 | .getOrElse(shelf)
73 | }
74 | }
75 | }
76 | inputBookshelves = bookShelfMap.values.flatten.toSet
77 | collectionsToCreate = inputBookshelves.filterNot { shelf =>
78 | existingCollections.contains(shelf) ||
79 | SpecialGoodreadsShelves.contains(shelf)
80 | }
81 | _ <- Logger[F].info(
82 | show"Creating collections ${collectionsToCreate.toList}"
83 | )
84 | _ <- collectionService.createCollections(collectionsToCreate.toSet)
85 |
86 | _ <- userBooks
87 | .flatMap { b =>
88 | bookShelfMap.getOrElse(b.isbn, Set.empty).toList.tupleLeft(b)
89 | }
90 | .traverse { case (book, shelf) =>
91 | Logger[F].info(s"Adding ${book.title} to ${shelf}") *>
92 | collectionService
93 | .addBookToCollection(
94 | MutationAddBookArgs(Some(shelf), book.toBookInput)
95 | )
96 | .void
97 | .recover { case BookAlreadyInCollectionError(_, _) => () }
98 | }
99 |
100 | (imported, partiallyImported) = userBooks.partition(
101 | _.thumbnailUri.nonEmpty
102 | )
103 | } yield ImportResult(
104 | successful = imported,
105 | partiallySuccessful = partiallyImported,
106 | unsuccessful = List.empty
107 | )
108 | }
109 |
110 | private def createBooks(
111 | rows: List[GoodreadsCSVRow],
112 | langRestrict: Option[String]
113 | ): F[List[UserBook]] = {
114 | for {
115 | existing <- specialBookManagementService.books
116 | existingIsbs = existing.map(_.isbn).toSet
117 | userBooks <- rows
118 | .filterNot(r => existingIsbs.contains(r.sanitizedIsbn))
119 | .map { b =>
120 | b.title match {
121 | case s"$title ($_ #$_)" => b.copy(title = title)
122 | case _ => b
123 | }
124 | }
125 | .parTraverseN(parallelism) { row =>
126 | bookInfoService
127 | .search(
128 | QueryBooksArgs(
129 | titleKeywords = Some(row.title),
130 | authorKeywords = Some(row.author),
131 | maxResults = Some(5),
132 | langRestrict = langRestrict
133 | )
134 | )
135 | .flatMap { books =>
136 | books.headOption.fold {
137 | Logger[F]
138 | .error(
139 | show"Failed obtaining extra information for: ${row.title}"
140 | )
141 | .as(row.toUserBook("", ""))
142 | } { book =>
143 | Logger[F]
144 | .info(
145 | show"Succeeded obtaining extra information for: ${row.title}"
146 | )
147 | .as(row.toUserBook(book.description, book.thumbnailUri))
148 | }
149 | } <* timer.sleep(500.millis)
150 | }
151 | _ <- bookManagementService.createBooks(userBooks)
152 | } yield userBooks
153 | }
154 |
155 | private def markBooks(books: List[UserBook]): F[Unit] = {
156 | for {
157 | _ <- books.map(b => (b, b.lastRead)).traverseCollect {
158 | case (b, Some(date)) =>
159 | specialBookManagementService
160 | .finishReading(
161 | MutationFinishReadingArgs(b.toBookInput, Some(date))
162 | ) *> Logger[F]
163 | .info(show"Marked ${b.title} as finished on ${date.toString}")
164 | }
165 | _ <- books.map(b => (b, b.startedReading)).traverseCollect {
166 | case (b, Some(date)) =>
167 | specialBookManagementService
168 | .startReading(MutationStartReadingArgs(b.toBookInput, Some(date)))
169 | .void
170 | .recover { case BookAlreadyBeingReadError(_) => () } *>
171 | Logger[F]
172 | .info(show"Marked ${b.title} as started on ${date.toString}")
173 | }
174 | _ <- books.map(b => (b, b.rating)).traverseCollect {
175 | case (b, Some(rating)) =>
176 | specialBookManagementService.rateBook(
177 | MutationRateBookArgs(b.toBookInput, rating)
178 | ) *> Logger[F].info(show"Gave ${b.title} a rating of $rating")
179 | }
180 | } yield ()
181 | }
182 | }
183 |
184 | object GoodreadsImportService {
185 |
186 | val GoodreadsCurrentlyReadingShelf = "currently-reading"
187 | private val SpecialGoodreadsShelves =
188 | Set(GoodreadsCurrentlyReadingShelf, "read", "to-read", "favorites")
189 |
190 | def apply[F[_]: Async: Logger](
191 | bookInfoService: BookInfoService[F],
192 | collectionService: CollectionService[F],
193 | bookManagementService: BookManagementService[F],
194 | specialBookManagementService: BookManagementService[F]
195 | ): GoodreadsImportService[F] =
196 | new GoodreadsImportService(
197 | bookInfoService,
198 | collectionService,
199 | bookManagementService,
200 | specialBookManagementService
201 | )
202 | }
203 |
204 | // Book Id,Title,Author,Author l-f,Additional Authors,ISBN,ISBN13,My Rating,Average Rating,Publisher,Binding,Number of Pages,Year Published,Original Publication Year,Date Read,Date Added,Bookshelves,Bookshelves with positions,Exclusive Shelf,My Review,Spoiler,Private Notes,Read Count,Owned Copies
205 | // 13450209,"Gardens of the Moon (The Malazan Book of the Fallen, #1)",Steven Erikson,"Erikson, Steven",,"=""1409083101""","=""9781409083108""",5,3.92,Transworld Publishers,ebook,768,2009,1999,2023/01/13,2023/01/10,"favorites, fantasy, fiction","favorites (#2), fantasy (#2), fiction (#3)",read,,,,1,0
206 | final case class GoodreadsCSVRow(
207 | goodreadsBookId: Int,
208 | title: String,
209 | author: String,
210 | authorLf: String,
211 | additionalAuthors: Option[String],
212 | isbn: String,
213 | isbn13: Option[String],
214 | rating: Option[Int],
215 | averageRating: Float,
216 | publisher: Option[String],
217 | binding: String,
218 | numPages: Option[String],
219 | yearPublished: Option[Int],
220 | originalPublicationYear: Option[Int],
221 | dateRead: Option[LocalDate],
222 | dateAdded: LocalDate,
223 | bookshelvesStr: String,
224 | bookshelvesWithPositions: String,
225 | exclusiveShelf: String,
226 | myReview: Option[String],
227 | spoiler: String,
228 | privateNotes: Option[String],
229 | readCount: String,
230 | ownedCopies: String
231 | ) {
232 | import GoodreadsImportService.GoodreadsCurrentlyReadingShelf
233 |
234 | def sanitizedIsbn = isbn.replace("\"", "")
235 |
236 | def toUserBook(description: String, thumbnailUri: String): UserBook =
237 | UserBook(
238 | title = title,
239 | authors = author :: additionalAuthors.fold(List.empty[String])(
240 | _.split(", ").toList
241 | ),
242 | description = description,
243 | isbn = sanitizedIsbn,
244 | thumbnailUri = thumbnailUri,
245 | dateAdded = Some(dateAdded),
246 | // For non-rated books, Goodreads outputs '0', which is unambiguous since you can't rate lower than 1
247 | rating = rating.filter(_ =!= 0),
248 | // Goodreads doesn't export the date a user started reading a book, so we just use the date added
249 | startedReading = Option.when(
250 | bookshelves.contains(GoodreadsCurrentlyReadingShelf)
251 | )(dateAdded),
252 | lastRead = dateRead,
253 | review = myReview
254 | )
255 |
256 | def bookshelves: Set[String] =
257 | (bookshelvesStr.split(", ").toSet + exclusiveShelf)
258 | .map(_.strip())
259 | .filter(_.nonEmpty)
260 | }
261 |
262 | object GoodreadsCSVRow {
263 | implicit val localDateDecoder: CellDecoder[LocalDate] =
264 | CellDecoder.stringDecoder.emap { s =>
265 | Either
266 | .catchNonFatal(
267 | LocalDate.parse(s, DateTimeFormatter.ofPattern("yyyy/MM/dd"))
268 | )
269 | .leftMap(e => new DecoderError(e.getMessage()))
270 | }
271 | implicit val decoder: RowDecoder[GoodreadsCSVRow] = deriveRowDecoder
272 | }
273 |
--------------------------------------------------------------------------------