├── .github └── workflows │ └── actions.yml ├── .gitignore ├── .mill-version ├── .scalafix.conf ├── .scalafmt.conf ├── CHANGELOG.md ├── LICENSE ├── README.md ├── assets ├── google-api-response-example.json └── sample_goodreads_export.csv ├── bin ├── montage.png ├── montage.py └── perf.py ├── build.sc ├── finito ├── api │ └── src │ │ └── fin │ │ ├── BookConversions.scala │ │ ├── FinitoError.scala │ │ ├── SortConversions.scala │ │ └── implicits.scala ├── benchmark │ └── src │ │ └── fin │ │ └── FinitoBenchmark.scala ├── core │ ├── src │ │ └── fin │ │ │ └── service │ │ │ ├── book │ │ │ ├── BookManagementService.scala │ │ │ ├── BookManagementServiceImpl.scala │ │ │ ├── SeriesInfoService.scala │ │ │ ├── SpecialBookService.scala │ │ │ └── WikidataSeriesInfoService.scala │ │ │ ├── collection │ │ │ ├── CollectionHook.scala │ │ │ ├── CollectionService.scala │ │ │ ├── CollectionServiceImpl.scala │ │ │ ├── HookExecutionService.scala │ │ │ ├── SBindings.scala │ │ │ └── SpecialCollectionService.scala │ │ │ ├── port │ │ │ ├── CollectionExportService.scala │ │ │ ├── GoodreadsExportService.scala │ │ │ ├── GoodreadsImportService.scala │ │ │ └── ImportService.scala │ │ │ ├── search │ │ │ ├── BookInfoAugmentationService.scala │ │ │ ├── BookInfoService.scala │ │ │ ├── GoogleBookInfoService.scala │ │ │ └── GoogleBooksAPIDecoding.scala │ │ │ └── summary │ │ │ ├── BufferedImageMontageService.scala │ │ │ ├── ImageStitch.scala │ │ │ ├── MontageService.scala │ │ │ ├── SummaryService.scala │ │ │ └── SummaryServiceImpl.scala │ └── test │ │ └── src │ │ └── fin │ │ └── service │ │ ├── book │ │ ├── BookInfoServiceUsingTitles.scala │ │ ├── BookManagementServiceImplTest.scala │ │ ├── InMemoryBookRepository.scala │ │ ├── SpecialBookServiceTest.scala │ │ └── WikidataSeriesInfoServiceTest.scala │ │ ├── collection │ │ ├── CollectionServiceImplTest.scala │ │ ├── InMemoryCollectionRepository.scala │ │ └── SpecialCollectionServiceTest.scala │ │ ├── port │ │ ├── GoodreadsExportServiceTest.scala │ │ ├── GoodreadsImportServiceTest.scala │ │ └── PortTest.scala │ │ ├── search │ │ ├── BookInfoAugmentationServiceTest.scala │ │ └── GoogleBookInfoServiceTest.scala │ │ └── summary │ │ ├── BufferedImageMontageServiceTest.scala │ │ └── SummaryServiceImplTest.scala ├── main │ ├── it │ │ └── src │ │ │ └── fin │ │ │ ├── FinitoFilesTest.scala │ │ │ └── IntegrationTests.scala │ ├── resources │ │ ├── graphiql.html │ │ ├── graphql-playground.html │ │ └── logback.xml │ └── src │ │ └── fin │ │ ├── CalibanSetup.scala │ │ ├── FinitoFiles.scala │ │ ├── Main.scala │ │ ├── Routes.scala │ │ ├── Services.scala │ │ ├── SpecialCollectionSetup.scala │ │ └── config │ │ └── ServiceConfig.scala └── persistence │ ├── resources │ └── db │ │ └── migration │ │ ├── V0__settings.sql │ │ ├── V1__create_database.sql │ │ └── V2__add_reviews.sql │ ├── src │ └── fin │ │ └── persistence │ │ ├── BookRepository.scala │ │ ├── CollectionRepository.scala │ │ ├── FlywaySetup.scala │ │ ├── SqliteBookRepository.scala │ │ ├── SqliteCollectionRepository.scala │ │ ├── TransactorSetup.scala │ │ └── package.scala │ └── test │ └── src │ └── fin │ ├── package.scala │ └── persistence │ ├── SqliteBookRepositoryTest.scala │ ├── SqliteCollectionRepositoryTest.scala │ └── SqliteSuite.scala ├── mill ├── planning.org ├── plugins ├── calibanSchemaGen.sc └── jmh.sc └── schema.gql /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.mill-version: -------------------------------------------------------------------------------- 1 | 0.11.12 -------------------------------------------------------------------------------- /.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 | } -------------------------------------------------------------------------------- /.scalafmt.conf: -------------------------------------------------------------------------------- 1 | version = "3.8.3" 2 | runner.dialect = scala213source3 # Required by '-Xsource:3' added by mill tpolecat 3 | align.preset = more 4 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Fin 2 | [![codecov](https://codecov.io/gh/LaurenceWarne/libro-finito/branch/master/graph/badge.svg?token=IFT4R8T4F3)](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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /bin/montage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LaurenceWarne/libro-finito/1fcd956be6d7543eb2d4bcb841bb6f4686589889/bin/montage.png -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/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/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/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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/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/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/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/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/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/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/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/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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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/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/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/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/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/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/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/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 | -------------------------------------------------------------------------------- /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/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/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/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/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 | -------------------------------------------------------------------------------- /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/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/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/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/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/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/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/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/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/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/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/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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/persistence/resources/db/migration/V0__settings.sql: -------------------------------------------------------------------------------- 1 | PRAGMA foreign_keys = ON; 2 | -------------------------------------------------------------------------------- /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/resources/db/migration/V2__add_reviews.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE books 2 | ADD COLUMN review TEXT NULL; 3 | -------------------------------------------------------------------------------- /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/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/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/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/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/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/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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------