├── .deepsource.toml ├── .editorconfig ├── .github ├── readme │ ├── calibre-identifier.png │ └── calibreweb-identifier.png ├── renovate.json └── workflows │ ├── build.yaml │ └── release.yml ├── .gitignore ├── .goreleaser.yaml ├── .mise.toml ├── Dockerfile ├── LICENSE ├── README.md ├── Taskfile.yml ├── cliff.toml ├── cmd ├── root.go ├── setup.go └── sync.go ├── go.mod ├── go.sum ├── internal ├── config │ ├── config.go │ └── load.go ├── constants.go ├── form │ ├── form.go │ └── validation.go ├── source │ ├── database.go │ ├── queries.go │ └── source.go ├── sync │ └── sync.go └── target │ ├── anilist.go │ ├── anilist │ └── generated.go │ ├── hardcover.go │ ├── hardcover │ └── generated.go │ └── target.go ├── main.go ├── packaging ├── entrypoint.sh ├── scripts │ ├── postinstall.sh │ └── preremove.sh └── systemd │ ├── readflow.service │ └── readflow.timer └── schemas ├── anilist ├── genqlient.yaml ├── mutations.gql ├── queries.gql └── schema.gql └── hardcover ├── genqlient.yaml ├── hardcover.go ├── mutations.gql ├── queries.gql └── schema.gql /.deepsource.toml: -------------------------------------------------------------------------------- 1 | version = 1 2 | 3 | test_patterns = ["**/*_test.go"] 4 | 5 | exclude_patterns = ["**/generated.go"] 6 | 7 | [[analyzers]] 8 | name = "shell" 9 | 10 | [[analyzers]] 11 | name = "go" 12 | 13 | [analyzers.meta] 14 | import_root = "github.com/RobBrazier/readflow" 15 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.json] 2 | indent_style = space 3 | indent_size = 4 4 | -------------------------------------------------------------------------------- /.github/readme/calibre-identifier.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RobBrazier/readflow/1d7106ad25280e4f9c7e6fc065a7ac82c567c4a9/.github/readme/calibre-identifier.png -------------------------------------------------------------------------------- /.github/readme/calibreweb-identifier.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RobBrazier/readflow/1d7106ad25280e4f9c7e6fc065a7ac82c567c4a9/.github/readme/calibreweb-identifier.png -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended", 5 | ":approveMajorUpdates", 6 | ":rebaseStalePrs", 7 | ":maintainLockFilesWeekly", 8 | ":automergePatch", 9 | ":automergeRequireAllStatusChecks", 10 | "helpers:pinGitHubActionDigests", 11 | "schedule:weekends", 12 | ":prConcurrentLimit10", 13 | ":prHourlyLimit2" 14 | ], 15 | "labels": [ 16 | "dependencies" 17 | ], 18 | "packageRules": [ 19 | { 20 | "groupName": "Github Actions", 21 | "matchManagers": [ 22 | "github-actions" 23 | ], 24 | "addLabels": [ 25 | "github-actions" 26 | ] 27 | }, 28 | { 29 | "matchManagers": [ 30 | "dockerfile" 31 | ], 32 | "addLabels": [ 33 | "dockerfile" 34 | ] 35 | }, 36 | { 37 | "matchManagers": [ 38 | "mise" 39 | ], 40 | "addLabels": [ 41 | "mise" 42 | ] 43 | }, 44 | { 45 | "matchManagers": [ 46 | "gomod" 47 | ], 48 | "addLabels": [ 49 | "go" 50 | ] 51 | } 52 | ] 53 | } 54 | -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-24.04 12 | steps: 13 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 14 | - uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4 15 | with: 16 | path: | 17 | ~/.cache/go-build 18 | ~/go/pkg/mod 19 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 20 | restore-keys: | 21 | ${{ runner.os }}-go- 22 | - uses: jdx/mise-action@5bb8f8c1911837cf42064e6490e7634fc842ee7e # v2 23 | - run: task test || true # no tests right now - remove when first added 24 | build: 25 | runs-on: ubuntu-24.04 26 | needs: test 27 | steps: 28 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 29 | - uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4 30 | with: 31 | path: | 32 | ~/.cache/go-build 33 | ~/go/pkg/mod 34 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 35 | restore-keys: | 36 | ${{ runner.os }}-go- 37 | - uses: jdx/mise-action@5bb8f8c1911837cf42064e6490e7634fc842ee7e # v2 38 | - run: task build 39 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | # run only against tags 6 | tags: 7 | - "*" 8 | 9 | env: 10 | REGISTRY: ghcr.io 11 | 12 | permissions: 13 | contents: write 14 | packages: write 15 | id-token: write 16 | 17 | jobs: 18 | goreleaser: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 22 | with: 23 | fetch-depth: 0 24 | - name: Install tools with mise 25 | uses: jdx/mise-action@5bb8f8c1911837cf42064e6490e7634fc842ee7e # v2 26 | - uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4 27 | with: 28 | path: | 29 | ~/.cache/go-build 30 | ~/go/pkg/mod 31 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 32 | restore-keys: | 33 | ${{ runner.os }}-go- 34 | - name: Log in to the Container registry 35 | uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3 36 | with: 37 | registry: ${{ env.REGISTRY }} 38 | username: ${{ github.actor }} 39 | password: ${{ secrets.GITHUB_TOKEN }} 40 | - name: Generate a changelog 41 | run: > 42 | git cliff --config=cliff.toml --current --strip all \ 43 | --github-repo ${{ github.repository }} \ 44 | --github-token ${{ secrets.GITHUB_TOKEN }} 45 | env: 46 | GIT_CLIFF_OUTPUT: RELEASE_NOTES.md 47 | - name: Run GoReleaser 48 | uses: goreleaser/goreleaser-action@9ed2f89a662bf1735a48bc8557fd212fa902bebf # v6 49 | with: 50 | version: "~> v2" 51 | args: release --clean --release-notes=RELEASE_NOTES.md 52 | env: 53 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # If you prefer the allow list template instead of the deny list, see community template: 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 3 | # 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | dist/ 11 | readflow.* 12 | readflow 13 | !readflow.service 14 | !readflow.timer 15 | 16 | # Test binary, built with `go test -c` 17 | *.test 18 | 19 | # Output of the go coverage tool, specifically when used with LiteIDE 20 | *.out 21 | 22 | # DB Browser for SQLite project file 23 | *.sqbpro 24 | **/*.db 25 | 26 | # Dependency directories (remove the comment below to include it) 27 | # vendor/ 28 | 29 | # Go workspace file 30 | go.work 31 | go.work.sum 32 | 33 | # env file 34 | .env 35 | 36 | # config files 37 | config.yaml 38 | 39 | # Release notes generated by git-cliff for goreleaser 40 | RELEASE_NOTES.md 41 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://goreleaser.com/static/schema.json 2 | # vim: set ts=2 sw=2 tw=0 fo=cnqoj 3 | 4 | version: 2 5 | 6 | before: 7 | hooks: 8 | - task generate 9 | 10 | builds: 11 | - env: 12 | - CGO_ENABLED=0 13 | goos: 14 | - linux 15 | - windows 16 | - darwin 17 | 18 | archives: 19 | - format: binary 20 | 21 | dockers: 22 | - image_templates: 23 | - "ghcr.io/robbrazier/readflow:{{ .Version }}-amd64" 24 | use: buildx 25 | build_flag_templates: 26 | - --platform=linux/amd64 27 | extra_files: 28 | - packaging/entrypoint.sh 29 | # - image_templates: 30 | # - "ghcr.io/robbrazier/readflow:{{ .Version }}-arm64v8" 31 | # use: buildx 32 | # goarch: arm64 33 | # build_flag_templates: 34 | # - --platform=linux/arm64/v8 35 | # extra_files: 36 | # - packaging/entrypoint.sh 37 | 38 | docker_manifests: 39 | - name_template: "ghcr.io/robbrazier/readflow:{{.Version}}" 40 | image_templates: 41 | - "ghcr.io/robbrazier/readflow:{{ .Version }}-amd64" 42 | # - "ghcr.io/robbrazier/readflow:{{ .Version }}-arm64v8" 43 | - name_template: "ghcr.io/robbrazier/readflow:v{{.Major}}" 44 | image_templates: 45 | - "ghcr.io/robbrazier/readflow:{{ .Version }}-amd64" 46 | # - "ghcr.io/robbrazier/readflow:{{ .Version }}-arm64v8" 47 | - name_template: "ghcr.io/robbrazier/readflow:latest" 48 | image_templates: 49 | - "ghcr.io/robbrazier/readflow:{{ .Version }}-amd64" 50 | # - "ghcr.io/robbrazier/readflow:{{ .Version }}-arm64v8" 51 | # nfpms: 52 | # - package_name: readflow 53 | # vendor: Rob Brazier 54 | # homepage: https://github.com/RobBrazier/readflow 55 | # maintainer: Rob Brazier <2453018+RobBrazier@users.noreply.github.com> 56 | # description: Track your Kobo reads on Anilist.co and Hardcover.app using Calibre-Web and Calibre databases 57 | # formats: 58 | # - deb 59 | # - rpm 60 | # bindir: /usr/local/bin 61 | # contents: 62 | # - src: ./packaging/systemd/readflow.timer 63 | # dst: /usr/lib/systemd/system/readflow.timer 64 | # type: config 65 | # file_info: 66 | # mode: 0644 67 | # - src: ./packaging/systemd/readflow.service 68 | # dst: /usr/lib/systemd/system/readflow.service 69 | # type: config 70 | # file_info: 71 | # mode: 0644 72 | # 73 | # scripts: 74 | # postinstall: packaging/scripts/postinstall.sh 75 | # postremove: packaging/scripts/postremove.sh 76 | -------------------------------------------------------------------------------- /.mise.toml: -------------------------------------------------------------------------------- 1 | [tools] 2 | "ubi:benweint/gquil" = "latest" 3 | "ubi:go-task/task" = "latest" 4 | go = "latest" 5 | goreleaser = "latest" 6 | git-cliff = "latest" 7 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM docker.io/alpine:20240923 2 | ENV READFLOW_DOCKER="1" \ 3 | SOURCE="database" \ 4 | COLUMN_CHAPTER="false" \ 5 | TARGETS="anilist,hardcover" \ 6 | DATABASE_CALIBRE="/data/metadata.db" \ 7 | DATABASE_CALIBREWEB="/data/app.db" \ 8 | CRON_SCHEDULE="@hourly" 9 | 10 | COPY packaging/entrypoint.sh / 11 | RUN chmod +x /entrypoint.sh && \ 12 | apk add --no-cache supercronic 13 | COPY readflow /bin 14 | 15 | ENTRYPOINT ["/entrypoint.sh"] 16 | 17 | CMD ["supercronic", "-no-reap", "/tmp/crontab"] 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 1. Definitions 4 | -------------- 5 | 1.1. "Contributor" 6 | means each individual or legal entity that creates, contributes to 7 | the creation of, or owns Covered Software. 8 | 1.2. "Contributor Version" 9 | means the combination of the Contributions of others (if any) used 10 | by a Contributor and that particular Contributor's Contribution. 11 | 1.3. "Contribution" 12 | means Covered Software of a particular Contributor. 13 | 1.4. "Covered Software" 14 | means Source Code Form to which the initial Contributor has attached 15 | the notice in Exhibit A, the Executable Form of such Source Code 16 | Form, and Modifications of such Source Code Form, in each case 17 | including portions thereof. 18 | 1.5. "Incompatible With Secondary Licenses" 19 | means 20 | (a) that the initial Contributor has attached the notice described 21 | in Exhibit B to the Covered Software; or 22 | (b) that the Covered Software was made available under the terms of 23 | version 1.1 or earlier of the License, but not also under the 24 | terms of a Secondary License. 25 | 1.6. "Executable Form" 26 | means any form of the work other than Source Code Form. 27 | 1.7. "Larger Work" 28 | means a work that combines Covered Software with other material, in 29 | a separate file or files, that is not Covered Software. 30 | 1.8. "License" 31 | means this document. 32 | 1.9. "Licensable" 33 | means having the right to grant, to the maximum extent possible, 34 | whether at the time of the initial grant or subsequently, any and 35 | all of the rights conveyed by this License. 36 | 1.10. "Modifications" 37 | means any of the following: 38 | (a) any file in Source Code Form that results from an addition to, 39 | deletion from, or modification of the contents of Covered 40 | Software; or 41 | (b) any new file in Source Code Form that contains any Covered 42 | Software. 43 | 1.11. "Patent Claims" of a Contributor 44 | means any patent claim(s), including without limitation, method, 45 | process, and apparatus claims, in any patent Licensable by such 46 | Contributor that would be infringed, but for the grant of the 47 | License, by the making, using, selling, offering for sale, having 48 | made, import, or transfer of either its Contributions or its 49 | Contributor Version. 50 | 1.12. "Secondary License" 51 | means either the GNU General Public License, Version 2.0, the GNU 52 | Lesser General Public License, Version 2.1, the GNU Affero General 53 | Public License, Version 3.0, or any later versions of those 54 | licenses. 55 | 1.13. "Source Code Form" 56 | means the form of the work preferred for making modifications. 57 | 1.14. "You" (or "Your") 58 | means an individual or a legal entity exercising rights under this 59 | License. For legal entities, "You" includes any entity that 60 | controls, is controlled by, or is under common control with You. For 61 | purposes of this definition, "control" means (a) the power, direct 62 | or indirect, to cause the direction or management of such entity, 63 | whether by contract or otherwise, or (b) ownership of more than 64 | fifty percent (50%) of the outstanding shares or beneficial 65 | ownership of such entity. 66 | 2. License Grants and Conditions 67 | -------------------------------- 68 | 2.1. Grants 69 | Each Contributor hereby grants You a world-wide, royalty-free, 70 | non-exclusive license: 71 | (a) under intellectual property rights (other than patent or trademark) 72 | Licensable by such Contributor to use, reproduce, make available, 73 | modify, display, perform, distribute, and otherwise exploit its 74 | Contributions, either on an unmodified basis, with Modifications, or 75 | as part of a Larger Work; and 76 | (b) under Patent Claims of such Contributor to make, use, sell, offer 77 | for sale, have made, import, and otherwise transfer either its 78 | Contributions or its Contributor Version. 79 | 2.2. Effective Date 80 | The licenses granted in Section 2.1 with respect to any Contribution 81 | become effective for each Contribution on the date the Contributor first 82 | distributes such Contribution. 83 | 2.3. Limitations on Grant Scope 84 | The licenses granted in this Section 2 are the only rights granted under 85 | this License. No additional rights or licenses will be implied from the 86 | distribution or licensing of Covered Software under this License. 87 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 88 | Contributor: 89 | (a) for any code that a Contributor has removed from Covered Software; 90 | or 91 | (b) for infringements caused by: (i) Your and any other third party's 92 | modifications of Covered Software, or (ii) the combination of its 93 | Contributions with other software (except as part of its Contributor 94 | Version); or 95 | (c) under Patent Claims infringed by Covered Software in the absence of 96 | its Contributions. 97 | This License does not grant any rights in the trademarks, service marks, 98 | or logos of any Contributor (except as may be necessary to comply with 99 | the notice requirements in Section 3.4). 100 | 2.4. Subsequent Licenses 101 | No Contributor makes additional grants as a result of Your choice to 102 | distribute the Covered Software under a subsequent version of this 103 | License (see Section 10.2) or under the terms of a Secondary License (if 104 | permitted under the terms of Section 3.3). 105 | 2.5. Representation 106 | Each Contributor represents that the Contributor believes its 107 | Contributions are its original creation(s) or it has sufficient rights 108 | to grant the rights to its Contributions conveyed by this License. 109 | 2.6. Fair Use 110 | This License is not intended to limit any rights You have under 111 | applicable copyright doctrines of fair use, fair dealing, or other 112 | equivalents. 113 | 2.7. Conditions 114 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 115 | in Section 2.1. 116 | 3. Responsibilities 117 | ------------------- 118 | 3.1. Distribution of Source Form 119 | All distribution of Covered Software in Source Code Form, including any 120 | Modifications that You create or to which You contribute, must be under 121 | the terms of this License. You must inform recipients that the Source 122 | Code Form of the Covered Software is governed by the terms of this 123 | License, and how they can obtain a copy of this License. You may not 124 | attempt to alter or restrict the recipients' rights in the Source Code 125 | Form. 126 | 3.2. Distribution of Executable Form 127 | If You distribute Covered Software in Executable Form then: 128 | (a) such Covered Software must also be made available in Source Code 129 | Form, as described in Section 3.1, and You must inform recipients of 130 | the Executable Form how they can obtain a copy of such Source Code 131 | Form by reasonable means in a timely manner, at a charge no more 132 | than the cost of distribution to the recipient; and 133 | (b) You may distribute such Executable Form under the terms of this 134 | License, or sublicense it under different terms, provided that the 135 | license for the Executable Form does not attempt to limit or alter 136 | the recipients' rights in the Source Code Form under this License. 137 | 3.3. Distribution of a Larger Work 138 | You may create and distribute a Larger Work under terms of Your choice, 139 | provided that You also comply with the requirements of this License for 140 | the Covered Software. If the Larger Work is a combination of Covered 141 | Software with a work governed by one or more Secondary Licenses, and the 142 | Covered Software is not Incompatible With Secondary Licenses, this 143 | License permits You to additionally distribute such Covered Software 144 | under the terms of such Secondary License(s), so that the recipient of 145 | the Larger Work may, at their option, further distribute the Covered 146 | Software under the terms of either this License or such Secondary 147 | License(s). 148 | 3.4. Notices 149 | You may not remove or alter the substance of any license notices 150 | (including copyright notices, patent notices, disclaimers of warranty, 151 | or limitations of liability) contained within the Source Code Form of 152 | the Covered Software, except that You may alter any license notices to 153 | the extent required to remedy known factual inaccuracies. 154 | 3.5. Application of Additional Terms 155 | You may choose to offer, and to charge a fee for, warranty, support, 156 | indemnity or liability obligations to one or more recipients of Covered 157 | Software. However, You may do so only on Your own behalf, and not on 158 | behalf of any Contributor. You must make it absolutely clear that any 159 | such warranty, support, indemnity, or liability obligation is offered by 160 | You alone, and You hereby agree to indemnify every Contributor for any 161 | liability incurred by such Contributor as a result of warranty, support, 162 | indemnity or liability terms You offer. You may include additional 163 | disclaimers of warranty and limitations of liability specific to any 164 | jurisdiction. 165 | 4. Inability to Comply Due to Statute or Regulation 166 | --------------------------------------------------- 167 | If it is impossible for You to comply with any of the terms of this 168 | License with respect to some or all of the Covered Software due to 169 | statute, judicial order, or regulation then You must: (a) comply with 170 | the terms of this License to the maximum extent possible; and (b) 171 | describe the limitations and the code they affect. Such description must 172 | be placed in a text file included with all distributions of the Covered 173 | Software under this License. Except to the extent prohibited by statute 174 | or regulation, such description must be sufficiently detailed for a 175 | recipient of ordinary skill to be able to understand it. 176 | 5. Termination 177 | -------------- 178 | 5.1. The rights granted under this License will terminate automatically 179 | if You fail to comply with any of its terms. However, if You become 180 | compliant, then the rights granted under this License from a particular 181 | Contributor are reinstated (a) provisionally, unless and until such 182 | Contributor explicitly and finally terminates Your grants, and (b) on an 183 | ongoing basis, if such Contributor fails to notify You of the 184 | non-compliance by some reasonable means prior to 60 days after You have 185 | come back into compliance. Moreover, Your grants from a particular 186 | Contributor are reinstated on an ongoing basis if such Contributor 187 | notifies You of the non-compliance by some reasonable means, this is the 188 | first time You have received notice of non-compliance with this License 189 | from such Contributor, and You become compliant prior to 30 days after 190 | Your receipt of the notice. 191 | 5.2. If You initiate litigation against any entity by asserting a patent 192 | infringement claim (excluding declaratory judgment actions, 193 | counter-claims, and cross-claims) alleging that a Contributor Version 194 | directly or indirectly infringes any patent, then the rights granted to 195 | You by any and all Contributors for the Covered Software under Section 196 | 2.1 of this License shall terminate. 197 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 198 | end user license agreements (excluding distributors and resellers) which 199 | have been validly granted by You or Your distributors under this License 200 | prior to termination shall survive termination. 201 | ************************************************************************ 202 | * * 203 | * 6. Disclaimer of Warranty * 204 | * ------------------------- * 205 | * * 206 | * Covered Software is provided under this License on an "as is" * 207 | * basis, without warranty of any kind, either expressed, implied, or * 208 | * statutory, including, without limitation, warranties that the * 209 | * Covered Software is free of defects, merchantable, fit for a * 210 | * particular purpose or non-infringing. The entire risk as to the * 211 | * quality and performance of the Covered Software is with You. * 212 | * Should any Covered Software prove defective in any respect, You * 213 | * (not any Contributor) assume the cost of any necessary servicing, * 214 | * repair, or correction. This disclaimer of warranty constitutes an * 215 | * essential part of this License. No use of any Covered Software is * 216 | * authorized under this License except under this disclaimer. * 217 | * * 218 | ************************************************************************ 219 | ************************************************************************ 220 | * * 221 | * 7. Limitation of Liability * 222 | * -------------------------- * 223 | * * 224 | * Under no circumstances and under no legal theory, whether tort * 225 | * (including negligence), contract, or otherwise, shall any * 226 | * Contributor, or anyone who distributes Covered Software as * 227 | * permitted above, be liable to You for any direct, indirect, * 228 | * special, incidental, or consequential damages of any character * 229 | * including, without limitation, damages for lost profits, loss of * 230 | * goodwill, work stoppage, computer failure or malfunction, or any * 231 | * and all other commercial damages or losses, even if such party * 232 | * shall have been informed of the possibility of such damages. This * 233 | * limitation of liability shall not apply to liability for death or * 234 | * personal injury resulting from such party's negligence to the * 235 | * extent applicable law prohibits such limitation. Some * 236 | * jurisdictions do not allow the exclusion or limitation of * 237 | * incidental or consequential damages, so this exclusion and * 238 | * limitation may not apply to You. * 239 | * * 240 | ************************************************************************ 241 | 8. Litigation 242 | ------------- 243 | Any litigation relating to this License may be brought only in the 244 | courts of a jurisdiction where the defendant maintains its principal 245 | place of business and such litigation shall be governed by laws of that 246 | jurisdiction, without reference to its conflict-of-law provisions. 247 | Nothing in this Section shall prevent a party's ability to bring 248 | cross-claims or counter-claims. 249 | 9. Miscellaneous 250 | ---------------- 251 | This License represents the complete agreement concerning the subject 252 | matter hereof. If any provision of this License is held to be 253 | unenforceable, such provision shall be reformed only to the extent 254 | necessary to make it enforceable. Any law or regulation which provides 255 | that the language of a contract shall be construed against the drafter 256 | shall not be used to construe this License against a Contributor. 257 | 10. Versions of the License 258 | --------------------------- 259 | 10.1. New Versions 260 | Mozilla Foundation is the license steward. Except as provided in Section 261 | 10.3, no one other than the license steward has the right to modify or 262 | publish new versions of this License. Each version will be given a 263 | distinguishing version number. 264 | 10.2. Effect of New Versions 265 | You may distribute the Covered Software under the terms of the version 266 | of the License under which You originally received the Covered Software, 267 | or under the terms of any subsequent version published by the license 268 | steward. 269 | 10.3. Modified Versions 270 | If you create software not governed by this License, and you want to 271 | create a new license for such software, you may create and use a 272 | modified version of this License if you rename the license and remove 273 | any references to the name of the license steward (except to note that 274 | such modified license differs from this License). 275 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 276 | Licenses 277 | If You choose to distribute Source Code Form that is Incompatible With 278 | Secondary Licenses under the terms of this version of the License, the 279 | notice described in Exhibit B of this License must be attached. 280 | Exhibit A - Source Code Form License Notice 281 | ------------------------------------------- 282 | This Source Code Form is subject to the terms of the Mozilla Public 283 | License, v. 2.0. If a copy of the MPL was not distributed with this 284 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 285 | If it is not possible or desirable to put the notice in a particular 286 | file, then You may include the notice in a location (such as a LICENSE 287 | file in a relevant directory) where a recipient would be likely to look 288 | for such a notice. 289 | You may add additional accurate notices of copyright ownership. 290 | Exhibit B - "Incompatible With Secondary Licenses" Notice 291 | --------------------------------------------------------- 292 | This Source Code Form is "Incompatible With Secondary Licenses", as 293 | defined by the Mozilla Public License, v. 2.0. 294 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # readflow 2 | 3 | Track your Kobo reads on Anilist and Hardcover using Calibre-Web and Calibre databases 4 | 5 | > [!WARNING] 6 | > This project is an early-stages WIP 7 | 8 | ## Pre-Requisites for this to actually be useful 9 | 10 | Admittedly this is quite a niche tool. It's only really useful in the following scenario: 11 | 12 | 1. You own a Kobo eReader 13 | 2. You store all of your books in a [Calibre](https://calibre-ebook.com/) library 14 | 3. You run a [Calibre-Web](https://github.com/janeczku/calibre-web) server 15 | 4. You have configured your Kobo eReader to use the Calibre-Web 16 | [Kobo Integration](https://github.com/janeczku/calibre-web/wiki/Kobo-Integration) 17 | as the API endpoint 18 | 19 | ## Installation 20 | 21 | 1. Download the [GitHub Release binaries](https://github.com/RobBrazier/readflow/releases/latest) 22 | compiled for MacOS, Linux and Windows (arm64, amd64 and i386) 23 | 2. Install with `go install` 24 | 25 | ```bash 26 | go install github.com/RobBrazier/readflow@latest 27 | ``` 28 | 29 | ## Setup 30 | 31 | Once installed, you'll need to configure the CLI. 32 | This can be done by following the instructions in the below command: 33 | 34 | ```bash 35 | readflow setup 36 | ``` 37 | 38 | This will take you through a guided form to get all the information required 39 | for the application 40 | 41 | ## Sync 42 | 43 | Once setup has been completed, you can run 44 | 45 | ```bash 46 | readflow sync 47 | ``` 48 | 49 | And this will pull recent reads and sync them to the providers configured 50 | 51 | > [!IMPORTANT] 52 | > Currently you are required to set calibre identifiers for the providers in the 53 | > books you want to sync. Any books missing these will be skipped. 54 | > 55 | > e.g. [hardcover:the-hobbit](https://hardcover.app/books/the-hobbit) 56 | or [anilist:53390](https://anilist.co/manga/53390/Attack-on-Titan/) 57 | 58 | ![Calibre-Web Identifier Format](.github/readme/calibreweb-identifier.png) 59 | ![Calibre Identifier Format](.github/readme/calibre-identifier.png) 60 | 61 | ## Running on a Schedule 62 | 63 | This is a `oneshot` CLI tool, so if you want to run it frequently, you'll need 64 | to configure a cron job 65 | 66 | On Linux systems this can be done with 67 | 68 | ```bash 69 | crontab -e 70 | ``` 71 | 72 | As an example, the cron job I use is: 73 | 74 | ```crontab 75 | 0 * * * * /usr/local/bin/readflow sync 2>> /var/log/readflow.log 76 | ``` 77 | 78 | This runs every hour on the hour 79 | 80 | ## Running via Docker 81 | 82 | ```bash 83 | docker pull ghcr.io/robbrazier/readflow 84 | ``` 85 | 86 | ### Environment Variables 87 | 88 | 89 | 90 | | Name | Default | Notes | 91 | |---------------------|-------------------|---------------------------------------------------------------------------------------------------------------------| 92 | | SOURCE | database | Leave as default - there are no other options currently | 93 | | TARGETS | anilist,hardcover | Defaults to all targets | 94 | | COLUMN_CHAPTER | false | Only used for Anilist - set if you want to count chapters | 95 | | DATABASE_CALIBRE | /data/metadata.db | Mount your metadata.db here | 96 | | DATABASE_CALIBREWEB | /data/app.db | Mount your app.db here | 97 | | CRON_SCHEDULE | @hourly | See [crontab.guru](https://crontab.guru/#@hourly) for syntax | 98 | | TOKEN_ANILIST | | Anilist token - Retrieve from [here](https://anilist.co/api/v2/oauth/authorize?client_id=21288&response_type=token) | 99 | | TOKEN_HARDCOVER | | Hardcover token - Retrieve from [here](https://hardcover.app/account/api) | 100 | | SYNC_DAYS | 1 | Number of days of reading history to sync | 101 | 102 | 103 | ### Run modes 104 | 105 | #### Cron Job 106 | 107 | ```bash 108 | docker run -e CRON_SCHEDULE="@daily" ghcr.io/robbrazier/readflow 109 | ``` 110 | 111 | #### One-off Sync 112 | 113 | ```bash 114 | docker run ghcr.io/robbrazier/readflow sync 115 | ``` 116 | -------------------------------------------------------------------------------- /Taskfile.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | dotenv: 4 | - .env 5 | 6 | tasks: 7 | default: 8 | silent: true 9 | cmd: task --list-all 10 | 11 | build: 12 | deps: 13 | - generate 14 | env: 15 | CGO_ENABLED: 0 16 | cmd: go build -o readflow main.go 17 | 18 | generate: 19 | vars: 20 | GENQLIENT_VERSION: 21 | sh: grep -m 1 genqlient go.mod | awk '{ print $2 }' 22 | cmds: 23 | - go get github.com/Khan/genqlient@{{ .GENQLIENT_VERSION }} 24 | - defer: go mod tidy 25 | - go generate ./... 26 | 27 | test: 28 | cmd: go test ./... 29 | 30 | download-schemas: 31 | cmds: 32 | - "gquil introspection generate-sdl https://graphql.anilist.co > schemas/anilist/schema.gql" 33 | - "gquil introspection generate-sdl https://api.hardcover.app/v1/graphql -H 'Authorization: {{ .HARDCOVER_TOKEN }}' > schemas/hardcover/schema.gql" 34 | requires: 35 | vars: [HARDCOVER_TOKEN] 36 | 37 | version: 38 | vars: 39 | NEW_VERSION: 40 | sh: git cliff --bumped-version 41 | prompt: "Bumping version to {{ .NEW_VERSION }}, continue?" 42 | cmds: 43 | - git tag -f {{ .NEW_VERSION }} 44 | -------------------------------------------------------------------------------- /cliff.toml: -------------------------------------------------------------------------------- 1 | # git-cliff ~ configuration file 2 | # https://git-cliff.org/docs/configuration 3 | 4 | [changelog] 5 | # template for the changelog header 6 | header = """ 7 | # Changelog\n 8 | All notable changes to this project will be documented in this file. 9 | 10 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 11 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n 12 | """ 13 | # template for the changelog body 14 | # https://keats.github.io/tera/docs/#introduction 15 | body = """ 16 | {%- macro remote_url() -%} 17 | https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }} 18 | {%- endmacro -%} 19 | 20 | {% if version -%} 21 | ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} 22 | {% else -%} 23 | ## [Unreleased] 24 | {% endif -%} 25 | 26 | {% for group, commits in commits | group_by(attribute="group") %} 27 | ### {{ group | upper_first }} 28 | {%- for commit in commits %} 29 | - {{ commit.message | split(pat="\n") | first | upper_first | trim }}\ 30 | {% if commit.remote.username %} by @{{ commit.remote.username }}{%- endif -%} 31 | {% if commit.remote.pr_number %} in \ 32 | [#{{ commit.remote.pr_number }}]({{ self::remote_url() }}/pull/{{ commit.remote.pr_number }}) \ 33 | {%- endif -%} 34 | {% endfor %} 35 | {% endfor %} 36 | 37 | {%- if github.contributors | filter(attribute="is_first_time", value=true) | length != 0 %} 38 | ## New Contributors 39 | {%- endif -%} 40 | 41 | {% for contributor in github.contributors | filter(attribute="is_first_time", value=true) %} 42 | * @{{ contributor.username }} made their first contribution 43 | {%- if contributor.pr_number %} in \ 44 | [#{{ contributor.pr_number }}]({{ self::remote_url() }}/pull/{{ contributor.pr_number }}) \ 45 | {%- endif %} 46 | {%- endfor %}\n 47 | """ 48 | # template for the changelog footer 49 | footer = """ 50 | {%- macro remote_url() -%} 51 | https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }} 52 | {%- endmacro -%} 53 | 54 | {% for release in releases -%} 55 | {% if release.version -%} 56 | {% if release.previous.version -%} 57 | [{{ release.version | trim_start_matches(pat="v") }}]: \ 58 | {{ self::remote_url() }}/compare/{{ release.previous.version }}..{{ release.version }} 59 | {% endif -%} 60 | {% else -%} 61 | [unreleased]: {{ self::remote_url() }}/compare/{{ release.previous.version }}..HEAD 62 | {% endif -%} 63 | {% endfor %} 64 | 65 | """ 66 | # remove the leading and trailing whitespace from the templates 67 | trim = true 68 | 69 | [git] 70 | # parse the commits based on https://www.conventionalcommits.org 71 | conventional_commits = true 72 | # filter out the commits that are not conventional 73 | filter_unconventional = false 74 | # regex for preprocessing the commit messages 75 | commit_preprocessors = [ 76 | # remove issue numbers from commits 77 | { pattern = '\((\w+\s)?#([0-9]+)\)', replace = "" }, 78 | ] 79 | # regex for parsing and grouping commits 80 | commit_parsers = [ 81 | { message = "^chore", skip = true }, 82 | { message = ".*typo.*", skip = true }, 83 | { message = "^[a|A]dd", group = "Added" }, 84 | { message = "^[s|S]upport", group = "Added" }, 85 | { message = "^[r|R]emove", group = "Removed" }, 86 | { message = "^.*: add", group = "Added" }, 87 | { message = "^.*: support", group = "Added" }, 88 | { message = "^.*: remove", group = "Removed" }, 89 | { message = "^.*: delete", group = "Removed" }, 90 | { message = "^test", group = "Fixed" }, 91 | { message = "^fix", group = "Fixed" }, 92 | { message = "^.*: fix", group = "Fixed" }, 93 | { message = "^.*", group = "Changed" }, 94 | ] 95 | # filter out the commits that are not matched by commit parsers 96 | filter_commits = false 97 | # sort the tags topologically 98 | topo_order = false 99 | # sort the commits inside sections by oldest/newest order 100 | sort_commits = "newest" 101 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | "time" 7 | 8 | "github.com/RobBrazier/readflow/internal" 9 | "github.com/RobBrazier/readflow/internal/config" 10 | "github.com/charmbracelet/log" 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | var cfgFile string 15 | var verbose bool 16 | 17 | // rootCmd represents the base command when called without any subcommands 18 | var rootCmd = &cobra.Command{ 19 | Use: internal.NAME, 20 | Short: "Track your Kobo reads on Anilist.co and Hardcover.app using Calibre-Web and Calibre databases", 21 | CompletionOptions: cobra.CompletionOptions{ 22 | DisableDefaultCmd: true, 23 | }, 24 | PersistentPreRun: func(cmd *cobra.Command, args []string) { 25 | if verbose { 26 | log.SetLevel(log.DebugLevel) 27 | } 28 | }, 29 | } 30 | 31 | // Execute adds all child commands to the root command and sets flags appropriately. 32 | // This is called by main.main(). It only needs to happen once to the rootCmd. 33 | func Execute() { 34 | err := rootCmd.Execute() 35 | if err != nil { 36 | os.Exit(1) 37 | } 38 | } 39 | 40 | func init() { 41 | cobra.OnInitialize(initConfig) 42 | 43 | log.SetTimeFormat(time.TimeOnly) 44 | log.SetLevel(log.InfoLevel) 45 | 46 | rootCmd.PersistentFlags().StringVarP(&cfgFile, "config", "c", config.GetConfigPath(&cfgFile), "config file") 47 | rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "Enable verbose logging") 48 | } 49 | 50 | // initConfig reads in config file and ENV variables if set. 51 | func initConfig() { 52 | configFile := config.GetConfigPath(&cfgFile) 53 | err := config.LoadConfig(configFile) 54 | if os.Getenv("READFLOW_DOCKER") == "1" { 55 | err = config.LoadConfigFromEnv() 56 | } 57 | if err != nil { 58 | if errors.Is(err, os.ErrNotExist) { 59 | log.Warnf("Config file doesn't seem to exist! Please run `%s setup -c \"%s\"` to populate the configuration", internal.NAME, cfgFile) 60 | } else { 61 | log.Error("Unable to read config", "error", err) 62 | os.Exit(1) 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /cmd/setup.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "slices" 6 | "strings" 7 | 8 | "github.com/RobBrazier/readflow/internal/config" 9 | "github.com/RobBrazier/readflow/internal/form" 10 | "github.com/RobBrazier/readflow/internal/target" 11 | "github.com/charmbracelet/bubbles/textinput" 12 | "github.com/charmbracelet/huh" 13 | "github.com/charmbracelet/log" 14 | "github.com/cli/browser" 15 | "github.com/spf13/cobra" 16 | ) 17 | 18 | // setupCmd represents the setup command 19 | var setupCmd = &cobra.Command{ 20 | Use: "setup", 21 | Short: "Setup configuration options and login to services", 22 | RunE: func(cmd *cobra.Command, args []string) error { 23 | c := config.GetConfig() 24 | 25 | anilistTokenExists := c.Tokens.Anilist != "" 26 | hardcoverTokenExists := c.Tokens.Hardcover != "" 27 | 28 | fetchAnilistToken := !anilistTokenExists 29 | fetchHardcoverToken := !hardcoverTokenExists 30 | 31 | var anilistToken string 32 | var hardcoverToken string 33 | 34 | // Initial config form 35 | form := huh.NewForm( 36 | // Source/Target 37 | huh.NewGroup( 38 | form.SourceSelect(&c.Source), 39 | form.TargetSelect(&c.Targets), 40 | form.SyncDays(&c.SyncDays), 41 | ), 42 | // Databases 43 | huh.NewGroup( 44 | huh.NewInput(). 45 | Title("Calibre datatabase location"). 46 | Description("e.g. /path/to/metadata.db"). 47 | Validate(form.ValidationRequired[string]()). 48 | Value(&c.Databases.Calibre), 49 | huh.NewInput(). 50 | Title("Calibre-Web datatabase location"). 51 | Description("e.g. /path/to/app.db"). 52 | Validate(form.ValidationRequired[string]()). 53 | Value(&c.Databases.CalibreWeb), 54 | ).WithHideFunc(func() bool { 55 | return c.Source != "database" 56 | }), 57 | // Chapters Column 58 | huh.NewGroup( 59 | huh.NewInput(). 60 | Title("Calibre database 'chapters' custom column"). 61 | Description("e.g. custom_column_15 (if not specified, it'll be searched for in the calibre db)\nNOTE: only used for anilist"). 62 | Value(&c.Columns.Chapter), 63 | ).WithHideFunc(func() bool { 64 | return !slices.Contains(c.Targets, "anilist") || c.Source != "database" 65 | }), 66 | // Prompt for Re-Authentication 67 | huh.NewGroup( 68 | form.Confirm("Token already exists in config for Anilist, Re-authenticate", &fetchAnilistToken), 69 | ).WithHideFunc(func() bool { 70 | return !slices.Contains(c.Targets, "anilist") || fetchAnilistToken 71 | }), 72 | huh.NewGroup( 73 | form.Confirm("Token already exists in config for Hardcover, Re-authenticate", &fetchHardcoverToken), 74 | ).WithHideFunc(func() bool { 75 | return !slices.Contains(c.Targets, "hardcover") || fetchHardcoverToken 76 | }), 77 | // Re-Authorize if requested 78 | huh.NewGroup( 79 | huh.NewInput(). 80 | Title("Authenticating with Anilist - Please paste the token below"). 81 | DescriptionFunc(func() string { 82 | url, _ := getTarget("anilist").Login() 83 | browser.OpenURL(url) 84 | return fmt.Sprintf( 85 | "Please open the following URL in your browser if it hasn't already opened: %s", 86 | url, 87 | ) 88 | }, nil). 89 | EchoMode(huh.EchoMode(textinput.EchoPassword)). 90 | Value(&anilistToken), 91 | ).WithHideFunc(func() bool { 92 | return !slices.Contains(c.Targets, "anilist") || !fetchAnilistToken 93 | }), 94 | huh.NewGroup( 95 | huh.NewInput(). 96 | Title("Authenticating with Hardcover - Please paste the token below"). 97 | DescriptionFunc(func() string { 98 | url, _ := getTarget("hardcover").Login() 99 | browser.OpenURL(url) 100 | return fmt.Sprintf( 101 | "Please open the following URL in your browser if it hasn't already opened: %s", 102 | url, 103 | ) 104 | }, nil). 105 | EchoMode(huh.EchoMode(textinput.EchoPassword)). 106 | Value(&hardcoverToken), 107 | ).WithHideFunc(func() bool { 108 | return !slices.Contains(c.Targets, "hardcover") || !fetchHardcoverToken 109 | }), 110 | ) 111 | err := form.Run() 112 | if err != nil { 113 | return err 114 | } 115 | 116 | if fetchAnilistToken && anilistToken != "" { 117 | c.Tokens.Anilist = anilistToken 118 | } 119 | if fetchHardcoverToken && hardcoverToken != "" { 120 | c.Tokens.Hardcover = strings.TrimSpace(strings.Replace(hardcoverToken, "Bearer", "", 1)) 121 | } 122 | 123 | err = config.SaveConfig(&c) 124 | if err != nil { 125 | return err 126 | } 127 | log.Info("Successfully saved config!") 128 | return nil 129 | }, 130 | } 131 | 132 | func getTarget(name string) target.SyncTarget { 133 | for _, target := range *target.GetTargets() { 134 | if target.GetName() == name { 135 | return target 136 | } 137 | } 138 | return nil 139 | } 140 | 141 | func init() { 142 | rootCmd.AddCommand(setupCmd) 143 | } 144 | -------------------------------------------------------------------------------- /cmd/sync.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "maps" 7 | "slices" 8 | 9 | "github.com/RobBrazier/readflow/internal/source" 10 | "github.com/RobBrazier/readflow/internal/sync" 11 | "github.com/RobBrazier/readflow/internal/target" 12 | "github.com/charmbracelet/log" 13 | "github.com/spf13/cobra" 14 | ) 15 | 16 | var availableSources []string 17 | var activeSource string 18 | 19 | // syncCmd represents the sync command 20 | var syncCmd = &cobra.Command{ 21 | Use: "sync", 22 | Short: "Sync latest reading states to configured targets", 23 | PreRunE: func(cmd *cobra.Command, args []string) error { 24 | if availableSources == nil { 25 | availableSources = slices.Collect(maps.Keys(source.GetSources())) 26 | } 27 | activeSources := source.GetActiveSources() 28 | if len(activeSources) > 0 { 29 | activeSource = activeSources[0] 30 | } 31 | if slices.Contains(availableSources, activeSource) { 32 | return nil 33 | } 34 | return errors.New(fmt.Sprintf("Invalid source. Available sources: %v", availableSources)) 35 | }, 36 | Run: func(cmd *cobra.Command, args []string) { 37 | log.Debug("sync called", "source", activeSource) 38 | targetNames := []string{} 39 | activeTargets := target.GetActiveTargets() 40 | enabledSource := getEnabledSource() 41 | for _, target := range activeTargets { 42 | targetNames = append(targetNames, target.GetName()) 43 | } 44 | log.Debug("target", "active", targetNames) 45 | action := sync.NewSyncAction(enabledSource, activeTargets) 46 | results, err := action.Sync() 47 | cobra.CheckErr(err) 48 | log.Info("sync completed", "results", results) 49 | }, 50 | } 51 | 52 | func getEnabledSource() source.Source { 53 | sources := source.GetSources() 54 | if val, ok := sources[activeSource]; ok { 55 | return val 56 | } 57 | return nil 58 | } 59 | 60 | func init() { 61 | rootCmd.AddCommand(syncCmd) 62 | } 63 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/RobBrazier/readflow 2 | 3 | go 1.23.2 4 | 5 | require ( 6 | github.com/Khan/genqlient v0.7.0 7 | github.com/adrg/xdg v0.5.3 8 | github.com/caarlos0/env/v11 v11.3.0 9 | github.com/charmbracelet/bubbles v0.20.0 10 | github.com/charmbracelet/huh v0.6.0 11 | github.com/charmbracelet/log v0.4.0 12 | github.com/cli/browser v1.3.0 13 | github.com/goccy/go-yaml v1.15.10 14 | github.com/hashicorp/go-retryablehttp v0.7.7 15 | github.com/jmoiron/sqlx v1.4.0 16 | github.com/joho/godotenv v1.5.1 17 | github.com/spf13/cobra v1.8.1 18 | modernc.org/sqlite v1.34.2 19 | ) 20 | 21 | require ( 22 | github.com/atotto/clipboard v0.1.4 // indirect 23 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 24 | github.com/catppuccin/go v0.2.0 // indirect 25 | github.com/charmbracelet/bubbletea v1.1.0 // indirect 26 | github.com/charmbracelet/lipgloss v0.13.0 // indirect 27 | github.com/charmbracelet/x/ansi v0.2.3 // indirect 28 | github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect 29 | github.com/charmbracelet/x/term v0.2.0 // indirect 30 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 31 | github.com/dustin/go-humanize v1.0.1 // indirect 32 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect 33 | github.com/go-logfmt/logfmt v0.6.0 // indirect 34 | github.com/google/uuid v1.6.0 // indirect 35 | github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 36 | github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect 37 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 38 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 39 | github.com/mattn/go-isatty v0.0.20 // indirect 40 | github.com/mattn/go-localereader v0.0.1 // indirect 41 | github.com/mattn/go-runewidth v0.0.16 // indirect 42 | github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect 43 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 44 | github.com/muesli/cancelreader v0.2.2 // indirect 45 | github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a // indirect 46 | github.com/ncruces/go-strftime v0.1.9 // indirect 47 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 48 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 49 | github.com/rivo/uniseg v0.4.7 // indirect 50 | github.com/spf13/pflag v1.0.5 // indirect 51 | github.com/vektah/gqlparser/v2 v2.5.18 // indirect 52 | golang.org/x/exp v0.0.0-20231108232855-2478ac86f678 // indirect 53 | golang.org/x/sync v0.8.0 // indirect 54 | golang.org/x/sys v0.26.0 // indirect 55 | golang.org/x/text v0.18.0 // indirect 56 | modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect 57 | modernc.org/libc v1.55.3 // indirect 58 | modernc.org/mathutil v1.6.0 // indirect 59 | modernc.org/memory v1.8.0 // indirect 60 | modernc.org/strutil v1.2.0 // indirect 61 | modernc.org/token v1.1.0 // indirect 62 | ) 63 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= 2 | filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= 3 | github.com/Khan/genqlient v0.7.0 h1:GZ1meyRnzcDTK48EjqB8t3bcfYvHArCUUvgOwpz1D4w= 4 | github.com/Khan/genqlient v0.7.0/go.mod h1:HNyy3wZvuYwmW3Y7mkoQLZsa/R5n5yIRajS1kPBvSFM= 5 | github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= 6 | github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= 7 | github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= 8 | github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= 9 | github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= 10 | github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= 11 | github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= 12 | github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 13 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 14 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 15 | github.com/caarlos0/env/v11 v11.3.0 h1:CVTN6W6+twFC1jHKUwsw9eOTEiFpzyJOSA2AyHa8uvw= 16 | github.com/caarlos0/env/v11 v11.3.0/go.mod h1:Q5lYHeOsgY20CCV/R+b50Jwg2MnjySid7+3FUBz2BJw= 17 | github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA= 18 | github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= 19 | github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE= 20 | github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU= 21 | github.com/charmbracelet/bubbletea v1.1.0 h1:FjAl9eAL3HBCHenhz/ZPjkKdScmaS5SK69JAK2YJK9c= 22 | github.com/charmbracelet/bubbletea v1.1.0/go.mod h1:9Ogk0HrdbHolIKHdjfFpyXJmiCzGwy+FesYkZr7hYU4= 23 | github.com/charmbracelet/huh v0.6.0 h1:mZM8VvZGuE0hoDXq6XLxRtgfWyTI3b2jZNKh0xWmax8= 24 | github.com/charmbracelet/huh v0.6.0/go.mod h1:GGNKeWCeNzKpEOh/OJD8WBwTQjV3prFAtQPpLv+AVwU= 25 | github.com/charmbracelet/lipgloss v0.13.0 h1:4X3PPeoWEDCMvzDvGmTajSyYPcZM4+y8sCA/SsA3cjw= 26 | github.com/charmbracelet/lipgloss v0.13.0/go.mod h1:nw4zy0SBX/F/eAO1cWdcvy6qnkDUxr8Lw7dvFrAIbbY= 27 | github.com/charmbracelet/log v0.4.0 h1:G9bQAcx8rWA2T3pWvx7YtPTPwgqpk7D68BX21IRW8ZM= 28 | github.com/charmbracelet/log v0.4.0/go.mod h1:63bXt/djrizTec0l11H20t8FDSvA4CRZJ1KH22MdptM= 29 | github.com/charmbracelet/x/ansi v0.2.3 h1:VfFN0NUpcjBRd4DnKfRaIRo53KRgey/nhOoEqosGDEY= 30 | github.com/charmbracelet/x/ansi v0.2.3/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= 31 | github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= 32 | github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= 33 | github.com/charmbracelet/x/term v0.2.0 h1:cNB9Ot9q8I711MyZ7myUR5HFWL/lc3OpU8jZ4hwm0x0= 34 | github.com/charmbracelet/x/term v0.2.0/go.mod h1:GVxgxAbjUrmpvIINHIQnJJKpMlHiZ4cktEQCN6GWyF0= 35 | github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo= 36 | github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk= 37 | github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 38 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 39 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 40 | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 41 | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 42 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= 43 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 44 | github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= 45 | github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= 46 | github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= 47 | github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= 48 | github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= 49 | github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= 50 | github.com/goccy/go-yaml v1.15.10 h1:9exV2CDYm/FWHPptIIgcDiPQS+X/4uTR+HEl+GF9xJU= 51 | github.com/goccy/go-yaml v1.15.10/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= 52 | github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= 53 | github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= 54 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 55 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 56 | github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= 57 | github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= 58 | github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= 59 | github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= 60 | github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= 61 | github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= 62 | github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= 63 | github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= 64 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 65 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 66 | github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= 67 | github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= 68 | github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 69 | github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 70 | github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= 71 | github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 72 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 73 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 74 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 75 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 76 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 77 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 78 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 79 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 80 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 81 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 82 | github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= 83 | github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 84 | github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= 85 | github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= 86 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= 87 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 88 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 89 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 90 | github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a h1:2MaM6YC3mGu54x+RKAA6JiFFHlHDY1UbkxqppT7wYOg= 91 | github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a/go.mod h1:hxSnBBYLK21Vtq/PHd0S2FYCxBXzBua8ov5s1RobyRQ= 92 | github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= 93 | github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= 94 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 95 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 96 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= 97 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 98 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 99 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 100 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 101 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 102 | github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= 103 | github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= 104 | github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= 105 | github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= 106 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 107 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 108 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 109 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 110 | github.com/vektah/gqlparser/v2 v2.5.18 h1:zSND3GtutylAQ1JpWnTHcqtaRZjl+y3NROeW8vuNo6Y= 111 | github.com/vektah/gqlparser/v2 v2.5.18/go.mod h1:6HLzf7JKv9Fi3APymudztFQNmLXR5qJeEo6BOFcXVfc= 112 | golang.org/x/exp v0.0.0-20231108232855-2478ac86f678 h1:mchzmB1XO2pMaKFRqk/+MV3mgGG96aqaPXaMifQU47w= 113 | golang.org/x/exp v0.0.0-20231108232855-2478ac86f678/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE= 114 | golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= 115 | golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 116 | golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= 117 | golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 118 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 119 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 120 | golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= 121 | golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 122 | golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= 123 | golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= 124 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= 125 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= 126 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 127 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 128 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 129 | modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= 130 | modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= 131 | modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y= 132 | modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s= 133 | modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= 134 | modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= 135 | modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw= 136 | modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= 137 | modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI= 138 | modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= 139 | modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U= 140 | modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w= 141 | modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= 142 | modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= 143 | modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= 144 | modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= 145 | modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= 146 | modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= 147 | modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= 148 | modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= 149 | modernc.org/sqlite v1.34.2 h1:J9n76TPsfYYkFkZ9Uy1QphILYifiVEwwOT7yP5b++2Y= 150 | modernc.org/sqlite v1.34.2/go.mod h1:dnR723UrTtjKpoHCAMN0Q/gZ9MT4r+iRvIBb9umWFkU= 151 | modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= 152 | modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= 153 | modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= 154 | modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= 155 | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | type Config struct { 4 | Columns ColumnConfig `yaml:"columns"` 5 | Databases DatabaseConfig `yaml:"databases"` 6 | Source string `yaml:"source" env:"SOURCE"` 7 | Targets []string `yaml:"targets" env:"TARGETS"` 8 | Tokens TokenConfig `yaml:"tokens"` 9 | SyncDays int `yaml:"syncDays" env:"SYNC_DAYS"` 10 | } 11 | 12 | // ColumnConfig represents the columns configuration 13 | type ColumnConfig struct { 14 | Chapter string `yaml:"chapter" env:"COLUMN_CHAPTER"` 15 | } 16 | 17 | // DatabaseConfig represents the database paths configuration 18 | type DatabaseConfig struct { 19 | Calibre string `yaml:"calibre" env:"DATABASE_CALIBRE"` 20 | CalibreWeb string `yaml:"calibreweb" env:"DATABASE_CALIBREWEB"` 21 | } 22 | 23 | // TokenConfig represents the API tokens configuration 24 | type TokenConfig struct { 25 | Anilist string `yaml:"anilist" env:"TOKEN_ANILIST"` 26 | Hardcover string `yaml:"hardcover" env:"TOKEN_HARDCOVER"` 27 | } 28 | 29 | var ( 30 | config Config 31 | configPath string 32 | ) 33 | 34 | func GetConfig() Config { 35 | return config 36 | } 37 | 38 | func GetColumns() ColumnConfig { 39 | return config.Columns 40 | } 41 | 42 | func GetDatabases() DatabaseConfig { 43 | return config.Databases 44 | } 45 | 46 | func GetSource() string { 47 | return config.Source 48 | } 49 | 50 | func GetTargets() []string { 51 | return config.Targets 52 | } 53 | 54 | func GetTokens() TokenConfig { 55 | return config.Tokens 56 | } 57 | 58 | func GetSyncDays() int { 59 | days := config.SyncDays 60 | // if config is unset, default to the old sync days value 61 | // to preserve backwards compatibility 62 | if days == 0 { 63 | days = 7 64 | } 65 | return days 66 | } 67 | -------------------------------------------------------------------------------- /internal/config/load.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | "path/filepath" 7 | 8 | "github.com/RobBrazier/readflow/internal" 9 | "github.com/adrg/xdg" 10 | "github.com/caarlos0/env/v11" 11 | "github.com/charmbracelet/log" 12 | "github.com/goccy/go-yaml" 13 | _ "github.com/joho/godotenv/autoload" 14 | ) 15 | 16 | func GetConfigPath(override *string) string { 17 | // Has the config flag been passed in? - if it's got a value, use it 18 | if override != nil { 19 | if *override != "" { 20 | configPath = *override 21 | } 22 | } 23 | 24 | if configPath == "" { 25 | // look in the XDG_CONFIG_HOME location 26 | var err error 27 | configPath, err = xdg.ConfigFile(filepath.Join(internal.NAME, "config.yaml")) 28 | 29 | if err != nil { 30 | // if that doesn't work for some reason, fall back to the current dir 31 | currentDir, err := os.Getwd() 32 | if err != nil { 33 | currentDir = "." 34 | } 35 | configPath = filepath.Join(currentDir, "readflow.yaml") 36 | } 37 | } 38 | 39 | return configPath 40 | 41 | } 42 | 43 | func LoadConfigFromEnv() error { 44 | err := env.Parse(&config) 45 | return err 46 | } 47 | 48 | func LoadConfig(path string) error { 49 | log.Debug("Loading config from", "file", path) 50 | data, err := os.ReadFile(path) 51 | if err != nil { 52 | return err 53 | } 54 | if err := yaml.Unmarshal(data, &config); err != nil { 55 | return err 56 | } 57 | log.Debug("Successfully loaded config") 58 | return nil 59 | } 60 | 61 | func SaveConfig(cfg *Config) error { 62 | data, err := yaml.Marshal(cfg) 63 | if err != nil { 64 | return err 65 | } 66 | 67 | if _, err = os.Stat(configPath); errors.Is(err, os.ErrNotExist) { 68 | // file doesn't exist - lets create the folder structure required 69 | path := filepath.Dir(configPath) 70 | if path != "." { 71 | err := os.MkdirAll(path, 0755) 72 | if err != nil { 73 | log.Error("Something went wrong when trying to create the folder structure for", "file", configPath, "path", path, "error", err) 74 | } 75 | } 76 | } 77 | 78 | err = os.WriteFile(configPath, data, 0644) 79 | if err != nil { 80 | log.Error("Couldn't save config to", "file", configPath) 81 | return err 82 | } 83 | log.Debug("Successfully saved config to", "file", configPath) 84 | config = *cfg 85 | return nil 86 | } 87 | -------------------------------------------------------------------------------- /internal/constants.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | const NAME = "readflow" 4 | 5 | const CONFIG_SOURCE = "source" 6 | const CONFIG_TARGETS = "targets" 7 | 8 | const CONFIG_CALIBRE_DB = "databases.calibre" 9 | const CONFIG_CALIBREWEB_DB = "databases.calibreweb" 10 | 11 | const CONFIG_CHAPTERS_COLUMN = "columns.chapter" 12 | 13 | const CONFIG_TOKENS_ANILIST = "tokens.anilist" 14 | const CONFIG_TOKENS_HARDCOVER = "tokens.hardcover" 15 | -------------------------------------------------------------------------------- /internal/form/form.go: -------------------------------------------------------------------------------- 1 | package form 2 | 3 | import ( 4 | "errors" 5 | "maps" 6 | "strconv" 7 | 8 | "github.com/RobBrazier/readflow/internal/source" 9 | "github.com/RobBrazier/readflow/internal/target" 10 | "github.com/charmbracelet/huh" 11 | ) 12 | 13 | var sourceLabels = map[string]string{ 14 | "database": "Calibre + Calibre-Web databases", 15 | } 16 | 17 | var targetLabels = map[string]string{ 18 | "anilist": "Anilist.co", 19 | "hardcover": "Hardcover.app", 20 | } 21 | 22 | func getOptionLabel(key string, labels map[string]string) string { 23 | label, ok := labels[key] 24 | if ok { 25 | return label 26 | } 27 | return key 28 | } 29 | 30 | func SourceSelect(value *string) *huh.Select[string] { 31 | sources := source.GetSources() 32 | options := []huh.Option[string]{} 33 | for name := range maps.Keys(sources) { 34 | label := getOptionLabel(name, sourceLabels) 35 | option := huh.NewOption(label, name) 36 | options = append(options, option) 37 | } 38 | return huh.NewSelect[string](). 39 | Options(options...). 40 | Title("Enabled Source"). 41 | Description("Where should we get the reading data from?"). 42 | Value(value) 43 | } 44 | 45 | func TargetSelect(value *[]string) *huh.MultiSelect[string] { 46 | targets := *target.GetTargets() 47 | options := []huh.Option[string]{} 48 | for _, target := range targets { 49 | name := target.GetName() 50 | label := getOptionLabel(name, targetLabels) 51 | option := huh.NewOption(label, name) 52 | 53 | options = append(options, option) 54 | } 55 | return huh.NewMultiSelect[string](). 56 | Options(options...). 57 | Title("Enabled Sync Targets"). 58 | Description("Where do you your reading updates to be sent to?"). 59 | Validate(ValidationMinValues[string](1)). 60 | Value(value) 61 | } 62 | 63 | type intAccessor struct { 64 | Value *int 65 | } 66 | 67 | func (ia intAccessor) Get() string { 68 | return strconv.Itoa(*ia.Value) 69 | } 70 | 71 | func (ia intAccessor) Set(value string) { 72 | val, err := strconv.Atoi(value) 73 | if err == nil { 74 | return 75 | } 76 | *ia.Value = val 77 | } 78 | 79 | func SyncDays(value *int) *huh.Input { 80 | if *value == 0 { 81 | *value = 1 82 | } 83 | 84 | strValue := strconv.Itoa(*value) 85 | 86 | return huh.NewInput(). 87 | Title("Sync Days"). 88 | Description("How many days do you want to look at when syncing?"). 89 | Validate(func(s string) error { 90 | if err := ValidationRequired[string]()(s); err != nil { 91 | return err 92 | } 93 | if val, err := strconv.Atoi(s); err != nil || val < 1 || val > 30 { 94 | return errors.New("Please specify a number between 1 and 30") 95 | } 96 | return nil 97 | }). 98 | Accessor(intAccessor{Value: value}). 99 | Value(&strValue) 100 | } 101 | 102 | func Confirm(message string, value *bool) *huh.Confirm { 103 | return huh.NewConfirm(). 104 | Title(message). 105 | Value(value) 106 | } 107 | -------------------------------------------------------------------------------- /internal/form/validation.go: -------------------------------------------------------------------------------- 1 | package form 2 | 3 | import "errors" 4 | 5 | func ValidationMinValues[T comparable](min int) func([]T) error { 6 | return func(t []T) error { 7 | if len(t) < min { 8 | return errors.New("You must select at least one") 9 | } 10 | return nil 11 | } 12 | } 13 | 14 | func ValidationRequired[T comparable]() func(T) error { 15 | return func(t T) error { 16 | var empty T 17 | if t == empty { 18 | return errors.New("This field is required") 19 | } 20 | return nil 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /internal/source/database.go: -------------------------------------------------------------------------------- 1 | package source 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "strings" 8 | 9 | "github.com/RobBrazier/readflow/internal" 10 | "github.com/RobBrazier/readflow/internal/config" 11 | "github.com/charmbracelet/log" 12 | "github.com/jmoiron/sqlx" 13 | "github.com/spf13/cobra" 14 | _ "modernc.org/sqlite" 15 | ) 16 | 17 | type databaseSource struct { 18 | log *log.Logger 19 | chaptersColumn string 20 | enableChapters bool 21 | syncDays int 22 | } 23 | 24 | type chaptersRow struct { 25 | Id int64 26 | } 27 | 28 | const CHAPTERS_COLUMN = "columns.chapter" 29 | 30 | func (s *databaseSource) getReadOnlyDbString(file string) string { 31 | if _, err := os.Stat(file); errors.Is(err, os.ErrNotExist) { 32 | cobra.CheckErr(fmt.Sprintf("Unable to access database %s. Is the path correct?", file)) 33 | } 34 | return fmt.Sprintf("file:%s?mode=ro", file) 35 | } 36 | 37 | func (s *databaseSource) getDb() *sqlx.DB { 38 | db := sqlx.MustConnect("sqlite", s.getReadOnlyDbString(config.GetDatabases().CalibreWeb)) 39 | db.MustExec("attach database ? as calibre", s.getReadOnlyDbString(config.GetDatabases().Calibre)) 40 | return db 41 | } 42 | 43 | func (s *databaseSource) Init() error { 44 | // figure out the chapters column only if it's enabled 45 | if s.chaptersColumn == "" && s.enableChapters { 46 | column, err := s.getChaptersColumn() 47 | if err != nil { 48 | return errors.New(fmt.Sprintf("Unable to find chapters column - configure via `%s config set %s NAME` (or set to 'false' to disable reading progress tracking)", internal.NAME, CHAPTERS_COLUMN)) 49 | } 50 | s.chaptersColumn = column 51 | c := config.GetConfig() 52 | c.Columns.Chapter = column 53 | 54 | s.log.Info("Stored chapters column", "column", column) 55 | config.SaveConfig(&c) 56 | } 57 | s.log.Debug("column", "enabled", s.enableChapters, "name", s.chaptersColumn) 58 | return nil 59 | } 60 | 61 | func (s *databaseSource) getChaptersColumn() (string, error) { 62 | var row chaptersRow 63 | db := s.getDb() 64 | defer db.Close() 65 | // Search for a custom column with a label of 'chapters' and store the value 66 | err := db.Get(&row, CHAPTERS_QUERY, "chapters") 67 | if err != nil { 68 | return "", err 69 | } 70 | return fmt.Sprintf("custom_column_%d", row.Id), nil 71 | } 72 | 73 | func (s *databaseSource) getRecentReads(db *sqlx.DB) ([]Book, error) { 74 | var books = []Book{} 75 | 76 | query := RECENT_READS_QUERY_NO_CHAPTERS 77 | if s.chaptersColumn != "" { 78 | query = fmt.Sprintf(RECENT_READS_QUERY, s.chaptersColumn) 79 | } 80 | 81 | daysToQuery := fmt.Sprintf("-%d day", s.syncDays) 82 | 83 | log.Debug("Running source query with", "days", daysToQuery) 84 | 85 | err := db.Select(&books, query, daysToQuery) 86 | if err != nil { 87 | return nil, err 88 | } 89 | return books, nil 90 | } 91 | 92 | func (s *databaseSource) GetRecentReads() ([]BookContext, error) { 93 | var recent = []BookContext{} 94 | db := s.getDb() 95 | defer db.Close() 96 | recentReads, err := s.getRecentReads(db) 97 | if err != nil { 98 | return nil, err 99 | } 100 | query := PREVIOUS_BOOKS_IN_SERIES_QUERY_NO_CHAPTERS 101 | if s.chaptersColumn != "" { 102 | query = fmt.Sprintf(PREVIOUS_BOOKS_IN_SERIES_QUERY, s.chaptersColumn) 103 | } 104 | for _, book := range recentReads { 105 | var previous = []Book{} 106 | context := BookContext{ 107 | Current: book, 108 | } 109 | if book.SeriesID != nil { 110 | err := db.Select(&previous, query, book.SeriesID, book.BookSeriesIndex) 111 | if err != nil { 112 | s.log.Error("Unable to get previous books for", "book", book.BookName) 113 | } 114 | context.Previous = previous 115 | } else { 116 | s.log.Info("Skipping retrieval of previous books as this book has no series", "book", book.BookName) 117 | } 118 | recent = append(recent, context) 119 | } 120 | return recent, nil 121 | } 122 | 123 | func NewDatabaseSource() Source { 124 | chapters := config.GetColumns().Chapter 125 | enableChapters := true 126 | if strings.ToLower(chapters) == "false" { 127 | enableChapters = false 128 | chapters = "" 129 | } 130 | logger := log.WithPrefix("database") 131 | return &databaseSource{ 132 | log: logger, 133 | chaptersColumn: chapters, 134 | enableChapters: enableChapters, 135 | syncDays: config.GetSyncDays(), 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /internal/source/queries.go: -------------------------------------------------------------------------------- 1 | package source 2 | 3 | const CHAPTERS_QUERY = "SELECT id FROM calibre.custom_columns WHERE label = ?;" 4 | 5 | // I'd love to compress these into one query, but can't figure out a SQL query that still passes if a table doesn't exist 6 | const RECENT_READS_QUERY = ` 7 | SELECT 8 | b.id as book_id, 9 | b.title AS book_name, 10 | s.id AS series_id, 11 | b.series_index as book_series_index, 12 | i_isbn.val as isbn, 13 | i_anilist.val as anilist_id, 14 | i_hardcover.val as hardcover_id, 15 | kb.progress_percent as progress_percent, 16 | brl.read_status as read_status, 17 | c.value as chapter_count 18 | FROM 19 | book_read_link brl 20 | LEFT JOIN 21 | calibre.books b ON b.id = brl.book_id 22 | LEFT JOIN 23 | calibre.books_series_link bsl ON bsl.book = b.id 24 | LEFT JOIN 25 | calibre.series s ON bsl.series = s.id 26 | LEFT JOIN 27 | calibre.identifiers i_isbn ON i_isbn.book = b.id AND i_isbn.type = 'isbn' 28 | LEFT JOIN 29 | calibre.identifiers i_anilist ON i_anilist.book = b.id AND i_anilist.type = 'anilist' 30 | LEFT JOIN 31 | calibre.identifiers i_hardcover ON i_hardcover.book = b.id AND i_hardcover.type = 'hardcover' 32 | LEFT JOIN 33 | calibre.%s c ON c.book = b.id 34 | LEFT JOIN 35 | kobo_reading_state krs ON krs.book_id = b.id 36 | LEFT JOIN 37 | kobo_bookmark kb ON kb.kobo_reading_state_id = krs.id 38 | WHERE 39 | krs.last_modified > datetime('now', ?) 40 | AND kb.progress_percent IS NOT NULL 41 | AND brl.read_status != 0; 42 | ` 43 | const RECENT_READS_QUERY_RANKED = ` 44 | WITH ranked_books AS ( 45 | SELECT 46 | b.id as book_id, 47 | b.title AS book_name, 48 | s.id AS series_id, 49 | b.series_index as book_series_index, 50 | i_isbn.val as isbn, 51 | i_anilist.val as anilist_id, 52 | i_hardcover.val as hardcover_id, 53 | kb.progress_percent as progress_percent, 54 | brl.read_status as read_status, 55 | c.value as chapter_count, 56 | CASE 57 | WHEN s.id IS NOT NULL THEN ROW_NUMBER() OVER (PARTITION BY s.id ORDER BY b.series_index DESC) 58 | ELSE 1 59 | END as rn 60 | FROM 61 | book_read_link brl 62 | LEFT JOIN 63 | calibre.books b ON b.id = brl.book_id 64 | LEFT JOIN 65 | calibre.books_series_link bsl ON bsl.book = b.id 66 | LEFT JOIN 67 | calibre.series s ON bsl.series = s.id 68 | LEFT JOIN 69 | calibre.identifiers i_isbn ON i_isbn.book = b.id AND i_isbn.type = 'isbn' 70 | LEFT JOIN 71 | calibre.identifiers i_anilist ON i_anilist.book = b.id AND i_anilist.type = 'anilist' 72 | LEFT JOIN 73 | calibre.identifiers i_hardcover ON i_hardcover.book = b.id AND i_hardcover.type = 'hardcover' 74 | LEFT JOIN 75 | calibre.%s c ON c.book = b.id 76 | LEFT JOIN 77 | kobo_reading_state krs ON krs.book_id = b.id 78 | LEFT JOIN 79 | kobo_bookmark kb ON kb.kobo_reading_state_id = krs.id 80 | WHERE 81 | krs.last_modified > datetime('now', ?) 82 | AND kb.progress_percent IS NOT NULL 83 | AND brl.read_status != 0 84 | ) SELECT 85 | book_id, 86 | book_name, 87 | series_id, 88 | book_series_index, 89 | read_status, 90 | isbn, 91 | anilist_id, 92 | hardcover_id, 93 | progress_percent, 94 | chapter_count 95 | FROM 96 | ranked_books 97 | WHERE 98 | rn = 1 99 | AND (anilist_id IS NOT NULL OR hardcover_id IS NOT NULL) 100 | ORDER BY 101 | series_id NULLS LAST, book_series_index DESC; 102 | ` 103 | 104 | const RECENT_READS_QUERY_NO_CHAPTERS = ` 105 | SELECT 106 | b.id as book_id, 107 | b.title AS book_name, 108 | s.id AS series_id, 109 | b.series_index as book_series_index, 110 | i_isbn.val as isbn, 111 | i_anilist.val as anilist_id, 112 | i_hardcover.val as hardcover_id, 113 | kb.progress_percent as progress_percent, 114 | brl.read_status as read_status, 115 | NULL as chapter_count 116 | FROM 117 | book_read_link brl 118 | LEFT JOIN 119 | calibre.books b ON b.id = brl.book_id 120 | LEFT JOIN 121 | calibre.books_series_link bsl ON bsl.book = b.id 122 | LEFT JOIN 123 | calibre.series s ON bsl.series = s.id 124 | LEFT JOIN 125 | calibre.identifiers i_isbn ON i_isbn.book = b.id AND i_isbn.type = 'isbn' 126 | LEFT JOIN 127 | calibre.identifiers i_anilist ON i_anilist.book = b.id AND i_anilist.type = 'anilist' 128 | LEFT JOIN 129 | calibre.identifiers i_hardcover ON i_hardcover.book = b.id AND i_hardcover.type = 'hardcover' 130 | LEFT JOIN 131 | kobo_reading_state krs ON krs.book_id = b.id 132 | LEFT JOIN 133 | kobo_bookmark kb ON kb.kobo_reading_state_id = krs.id 134 | WHERE 135 | krs.last_modified > datetime('now', ?) 136 | AND kb.progress_percent IS NOT NULL 137 | AND brl.read_status != 0 138 | ` 139 | const RECENT_READS_QUERY_NO_CHAPTERS_RANKED = ` 140 | WITH ranked_books AS ( 141 | SELECT 142 | b.id as book_id, 143 | b.title AS book_name, 144 | s.id AS series_id, 145 | b.series_index as book_series_index, 146 | i_isbn.val as isbn, 147 | i_anilist.val as anilist_id, 148 | i_hardcover.val as hardcover_id, 149 | kb.progress_percent as progress_percent, 150 | brl.read_status as read_status, 151 | NULL as chapter_count, 152 | CASE 153 | WHEN s.id IS NOT NULL THEN ROW_NUMBER() OVER (PARTITION BY s.id ORDER BY b.series_index DESC) 154 | ELSE 1 155 | END as rn 156 | FROM 157 | book_read_link brl 158 | LEFT JOIN 159 | calibre.books b ON b.id = brl.book_id 160 | LEFT JOIN 161 | calibre.books_series_link bsl ON bsl.book = b.id 162 | LEFT JOIN 163 | calibre.series s ON bsl.series = s.id 164 | LEFT JOIN 165 | calibre.identifiers i_isbn ON i_isbn.book = b.id AND i_isbn.type = 'isbn' 166 | LEFT JOIN 167 | calibre.identifiers i_anilist ON i_anilist.book = b.id AND i_anilist.type = 'anilist' 168 | LEFT JOIN 169 | calibre.identifiers i_hardcover ON i_hardcover.book = b.id AND i_hardcover.type = 'hardcover' 170 | LEFT JOIN 171 | kobo_reading_state krs ON krs.book_id = b.id 172 | LEFT JOIN 173 | kobo_bookmark kb ON kb.kobo_reading_state_id = krs.id 174 | WHERE 175 | krs.last_modified > datetime('now', ?) 176 | AND kb.progress_percent IS NOT NULL 177 | AND brl.read_status != 0 178 | ) SELECT 179 | book_id, 180 | book_name, 181 | series_id, 182 | book_series_index, 183 | read_status, 184 | isbn, 185 | anilist_id, 186 | hardcover_id, 187 | progress_percent, 188 | chapter_count 189 | FROM 190 | ranked_books 191 | WHERE 192 | rn = 1 193 | AND (anilist_id IS NOT NULL OR hardcover_id IS NOT NULL) 194 | ORDER BY 195 | series_id NULLS LAST, book_series_index DESC; 196 | ` 197 | 198 | const PREVIOUS_BOOKS_IN_SERIES_QUERY = ` 199 | SELECT 200 | b.id AS book_id, 201 | b.title AS book_name, 202 | bsl.series AS series_id, 203 | b.series_index AS book_series_index, 204 | brl.read_status as read_status, 205 | i_isbn.val AS isbn, 206 | i_anilist.val AS anilist_id, 207 | i_hardcover.val AS hardcover_id, 208 | NULL AS progress_percent, 209 | c.value AS chapter_count 210 | FROM 211 | calibre.books b 212 | INNER JOIN 213 | calibre.books_series_link bsl ON b.id = bsl.book 214 | LEFT JOIN 215 | calibre.identifiers i_isbn ON i_isbn.book = b.id AND i_isbn.type = 'isbn' 216 | LEFT JOIN 217 | calibre.identifiers i_anilist ON i_anilist.book = b.id AND i_anilist.type = 'anilist' 218 | LEFT JOIN 219 | calibre.identifiers i_hardcover ON i_hardcover.book = b.id AND i_hardcover.type = 'hardcover' 220 | LEFT JOIN 221 | calibre.%s c ON b.id = c.book 222 | LEFT JOIN 223 | book_read_link brl ON brl.book_id = b.id 224 | WHERE 225 | bsl.series = ? 226 | AND b.series_index < ? 227 | ORDER BY 228 | b.series_index; 229 | ` 230 | 231 | const PREVIOUS_BOOKS_IN_SERIES_QUERY_NO_CHAPTERS = ` 232 | SELECT 233 | b.id AS book_id, 234 | b.title AS book_name, 235 | bsl.series AS series_id, 236 | b.series_index AS book_series_index, 237 | brl.read_status as read_status, 238 | i_isbn.val AS isbn, 239 | i_anilist.val AS anilist_id, 240 | i_hardcover.val AS hardcover_id, 241 | NULL AS progress_percent, 242 | NULL AS chapter_count 243 | FROM 244 | calibre.books b 245 | INNER JOIN 246 | calibre.books_series_link bsl ON b.id = bsl.book 247 | LEFT JOIN 248 | calibre.identifiers i_isbn ON i_isbn.book = b.id AND i_isbn.type = 'isbn' 249 | LEFT JOIN 250 | calibre.identifiers i_anilist ON i_anilist.book = b.id AND i_anilist.type = 'anilist' 251 | LEFT JOIN 252 | calibre.identifiers i_hardcover ON i_hardcover.book = b.id AND i_hardcover.type = 'hardcover' 253 | LEFT JOIN 254 | book_read_link brl ON brl.book_id = b.id 255 | WHERE 256 | bsl.series = ? 257 | AND b.series_index < ? 258 | ORDER BY 259 | b.series_index; 260 | ` 261 | -------------------------------------------------------------------------------- /internal/source/source.go: -------------------------------------------------------------------------------- 1 | package source 2 | 3 | import ( 4 | "sync" 5 | "sync/atomic" 6 | 7 | "github.com/RobBrazier/readflow/internal/config" 8 | ) 9 | 10 | type Source interface { 11 | Init() error 12 | GetRecentReads() ([]BookContext, error) 13 | } 14 | 15 | var ( 16 | sources atomic.Pointer[map[string]Source] 17 | sourcesOnce sync.Once 18 | ) 19 | 20 | type Book struct { 21 | BookID int `db:"book_id"` 22 | BookName string `db:"book_name"` 23 | SeriesID *int `db:"series_id"` 24 | BookSeriesIndex *int `db:"book_series_index"` 25 | ReadStatus int `db:"read_status"` 26 | ISBN *string `db:"isbn"` 27 | AnilistID *string `db:"anilist_id"` 28 | HardcoverID *string `db:"hardcover_id"` 29 | ProgressPercent *float64 `db:"progress_percent"` 30 | ChapterCount *int `db:"chapter_count"` 31 | } 32 | 33 | type BookContext struct { 34 | Current Book 35 | Previous []Book 36 | } 37 | 38 | func GetSources() map[string]Source { 39 | s := sources.Load() 40 | if s == nil { 41 | sourcesOnce.Do(func() { 42 | sources.CompareAndSwap(nil, &map[string]Source{ 43 | "database": NewDatabaseSource(), 44 | }) 45 | }) 46 | s = sources.Load() 47 | } 48 | return *s 49 | } 50 | 51 | func GetActiveSources() []string { 52 | active := []string{} 53 | selectedSources := config.GetSource() 54 | active = append(active, selectedSources) 55 | return active 56 | } 57 | -------------------------------------------------------------------------------- /internal/sync/sync.go: -------------------------------------------------------------------------------- 1 | package sync 2 | 3 | import ( 4 | "sync" 5 | 6 | "github.com/RobBrazier/readflow/internal/source" 7 | "github.com/RobBrazier/readflow/internal/target" 8 | "github.com/charmbracelet/log" 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | type SyncResult struct { 13 | Name string 14 | } 15 | 16 | type SyncAction interface { 17 | Sync() ([]SyncResult, error) 18 | } 19 | 20 | type syncAction struct { 21 | log *log.Logger 22 | targets []target.SyncTarget 23 | source source.Source 24 | } 25 | 26 | func (a *syncAction) Sync() ([]SyncResult, error) { 27 | // if the chapters column doesn't exist in config, fetch the name and store it 28 | a.source.Init() 29 | recentReads, err := a.source.GetRecentReads() 30 | cobra.CheckErr(err) 31 | var wg sync.WaitGroup 32 | for _, t := range a.targets { 33 | wg.Add(1) 34 | go a.processTarget(t, recentReads, &wg) 35 | } 36 | 37 | wg.Wait() 38 | return []SyncResult{}, nil 39 | } 40 | 41 | func (a *syncAction) processTarget(t target.SyncTarget, reads []source.BookContext, wg *sync.WaitGroup) { 42 | defer wg.Done() 43 | for _, book := range reads { 44 | name := book.Current.BookName 45 | log := log.With("target", t.GetName(), "book", name) 46 | if !t.ShouldProcess(book) { 47 | log.Debug("Skipping processing of ineligible book") 48 | continue 49 | } 50 | log.Info("Processing") 51 | err := t.UpdateReadStatus(book) 52 | if err != nil { 53 | log.Error("failed to update reading status", "error", err) 54 | } 55 | } 56 | } 57 | 58 | func NewSyncAction(enabledSource source.Source, targets []target.SyncTarget) SyncAction { 59 | return &syncAction{ 60 | targets: targets, 61 | source: enabledSource, 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /internal/target/anilist.go: -------------------------------------------------------------------------------- 1 | package target 2 | 3 | import ( 4 | "context" 5 | "math" 6 | "strconv" 7 | 8 | "github.com/Khan/genqlient/graphql" 9 | "github.com/RobBrazier/readflow/internal/config" 10 | "github.com/RobBrazier/readflow/internal/source" 11 | "github.com/RobBrazier/readflow/internal/target/anilist" 12 | "github.com/charmbracelet/log" 13 | "github.com/spf13/cobra" 14 | ) 15 | 16 | //go:generate go run github.com/Khan/genqlient ../../schemas/anilist/genqlient.yaml 17 | 18 | type AnilistTarget struct { 19 | GraphQLTarget 20 | Target 21 | client graphql.Client 22 | ctx context.Context 23 | log *log.Logger 24 | } 25 | 26 | func dereferenceDefault[T any](pointer *T, defaultValue T) T { 27 | if pointer == nil { 28 | return defaultValue 29 | } 30 | return *pointer 31 | } 32 | 33 | func (t AnilistTarget) Login() (string, error) { 34 | return "https://anilist.co/api/v2/oauth/authorize?client_id=21288&response_type=token", nil 35 | } 36 | 37 | func (t *AnilistTarget) getClient() graphql.Client { 38 | if t.client == nil { 39 | t.client = t.GraphQLTarget.getClient(t.ApiUrl, t.GetToken()) 40 | } 41 | return t.client 42 | } 43 | 44 | func (t AnilistTarget) GetToken() string { 45 | return config.GetTokens().Anilist 46 | } 47 | 48 | func (t AnilistTarget) ShouldProcess(book source.BookContext) bool { 49 | id := book.Current.AnilistID 50 | if id == nil { 51 | return false 52 | } 53 | if *id == "" { 54 | return false 55 | } 56 | return true 57 | } 58 | 59 | func (t *AnilistTarget) GetCurrentUser() string { 60 | response, err := anilist.GetCurrentUser(t.ctx, t.getClient()) 61 | cobra.CheckErr(err) 62 | return response.Viewer.Name 63 | } 64 | 65 | func (t *AnilistTarget) getLocalVolumes(book source.Book, maxVolumes int) int { 66 | // lets just assume it's volume 1 if the pointer is null (i.e. no series) 67 | volume := dereferenceDefault(book.BookSeriesIndex, 1) 68 | 69 | if maxVolumes > 0 && volume > maxVolumes { 70 | t.log.Warn("Volume number exceeds the volume count on anilist - capping value", "book", book.BookName, "volume", volume, "max", maxVolumes) 71 | 72 | } 73 | 74 | return volume 75 | } 76 | 77 | func (t *AnilistTarget) getLocalChapters(book source.BookContext) (current int, previous int) { 78 | currentVolumeChapters := dereferenceDefault(book.Current.ChapterCount, 0) 79 | previousVolumeChapters := 0 80 | if len(book.Previous) > 0 { 81 | for _, book := range book.Previous { 82 | previousVolumeChapters += dereferenceDefault(book.ChapterCount, 0) 83 | } 84 | } 85 | return currentVolumeChapters, previousVolumeChapters 86 | } 87 | 88 | func (t *AnilistTarget) getEstimatedNewChapterCount(book source.BookContext, maxChapters int) int { 89 | chapter, localPreviousChapters := t.getLocalChapters(book) 90 | 91 | progress := dereferenceDefault(book.Current.ProgressPercent, 0.0) / 100 92 | latestVolumeChapter := int(math.Round(float64(chapter) * progress)) 93 | 94 | estimatedChapter := localPreviousChapters + latestVolumeChapter 95 | 96 | t.log.Debug("Estimated current chapter", "book", book.Current.BookName, "progress", progress, "chapter", estimatedChapter) 97 | 98 | if maxChapters > 0 && estimatedChapter > maxChapters { 99 | t.log.Warn("Estimated chapter exceeds the chapter count on anilist - capping value", "book", book.Current.BookName, "estimated", estimatedChapter, "max", maxChapters) 100 | estimatedChapter = maxChapters 101 | } 102 | 103 | return estimatedChapter 104 | } 105 | 106 | func (t *AnilistTarget) UpdateReadStatus(book source.BookContext) error { 107 | anilistId, err := strconv.Atoi(*book.Current.AnilistID) 108 | if err != nil { 109 | t.log.Error("Invalid anilist id", "id", *book.Current.AnilistID) 110 | return err 111 | } 112 | ctx := t.ctx 113 | client := t.getClient() 114 | current, err := anilist.GetUserMediaById(ctx, client, anilistId) 115 | if err != nil { 116 | return err 117 | } 118 | 119 | bookName := book.Current.BookName 120 | title := current.Media.Title.UserPreferred 121 | maxVolumes := current.Media.Volumes 122 | maxChapters := current.Media.Chapters 123 | status := current.Media.MediaListEntry.Status 124 | 125 | remoteVolumes := current.Media.MediaListEntry.ProgressVolumes 126 | remoteChapters := current.Media.MediaListEntry.Progress 127 | 128 | localVolumes := t.getLocalVolumes(book.Current, maxVolumes) 129 | estimatedChapter := t.getEstimatedNewChapterCount(book, maxChapters) 130 | 131 | if localVolumes <= remoteVolumes && estimatedChapter <= remoteChapters { 132 | t.log. 133 | With("book", bookName, "title", title). 134 | Info("Skipping update as target is already up-to-date") 135 | return nil 136 | } 137 | if status == "" { 138 | status = anilist.MediaListStatusCurrent 139 | } 140 | if estimatedChapter == maxChapters { 141 | status = anilist.MediaListStatusCompleted 142 | } 143 | t.log.Info("Updating progress for", "book", bookName, "volume", localVolumes, "chapter", estimatedChapter) 144 | _, err = anilist.UpdateProgress(ctx, client, anilistId, estimatedChapter, localVolumes, status) 145 | if err != nil { 146 | t.log.Error("error updating progress", "error", err) 147 | return err 148 | } 149 | 150 | return nil 151 | } 152 | 153 | func NewAnilistTarget() SyncTarget { 154 | name := "anilist" 155 | logger := log.WithPrefix(name) 156 | target := &AnilistTarget{ 157 | ctx: context.Background(), 158 | log: logger, 159 | Target: Target{ 160 | Name: name, 161 | ApiUrl: "https://graphql.anilist.co", 162 | }, 163 | } 164 | return target 165 | } 166 | -------------------------------------------------------------------------------- /internal/target/anilist/generated.go: -------------------------------------------------------------------------------- 1 | // Code generated by github.com/Khan/genqlient, DO NOT EDIT. 2 | 3 | package anilist 4 | 5 | import ( 6 | "context" 7 | 8 | "github.com/Khan/genqlient/graphql" 9 | ) 10 | 11 | // GetCurrentUserResponse is returned by GetCurrentUser on success. 12 | type GetCurrentUserResponse struct { 13 | // Get the currently authenticated user 14 | Viewer GetCurrentUserViewerUser `json:"Viewer"` 15 | } 16 | 17 | // GetViewer returns GetCurrentUserResponse.Viewer, and is useful for accessing the field via an interface. 18 | func (v *GetCurrentUserResponse) GetViewer() GetCurrentUserViewerUser { return v.Viewer } 19 | 20 | // GetCurrentUserViewerUser includes the requested fields of the GraphQL type User. 21 | // The GraphQL type's documentation follows. 22 | // 23 | // A user 24 | type GetCurrentUserViewerUser struct { 25 | // The id of the user 26 | Id int `json:"id"` 27 | // The name of the user 28 | Name string `json:"name"` 29 | } 30 | 31 | // GetId returns GetCurrentUserViewerUser.Id, and is useful for accessing the field via an interface. 32 | func (v *GetCurrentUserViewerUser) GetId() int { return v.Id } 33 | 34 | // GetName returns GetCurrentUserViewerUser.Name, and is useful for accessing the field via an interface. 35 | func (v *GetCurrentUserViewerUser) GetName() string { return v.Name } 36 | 37 | // GetUserMediaByIdMedia includes the requested fields of the GraphQL type Media. 38 | // The GraphQL type's documentation follows. 39 | // 40 | // Anime or Manga 41 | type GetUserMediaByIdMedia struct { 42 | // The amount of volumes the manga has when complete 43 | Volumes int `json:"volumes"` 44 | // The amount of chapters the manga has when complete 45 | Chapters int `json:"chapters"` 46 | // The authenticated user's media list entry for the media 47 | MediaListEntry GetUserMediaByIdMediaMediaListEntryMediaList `json:"mediaListEntry"` 48 | // The official titles of the media in various languages 49 | Title GetUserMediaByIdMediaTitle `json:"title"` 50 | } 51 | 52 | // GetVolumes returns GetUserMediaByIdMedia.Volumes, and is useful for accessing the field via an interface. 53 | func (v *GetUserMediaByIdMedia) GetVolumes() int { return v.Volumes } 54 | 55 | // GetChapters returns GetUserMediaByIdMedia.Chapters, and is useful for accessing the field via an interface. 56 | func (v *GetUserMediaByIdMedia) GetChapters() int { return v.Chapters } 57 | 58 | // GetMediaListEntry returns GetUserMediaByIdMedia.MediaListEntry, and is useful for accessing the field via an interface. 59 | func (v *GetUserMediaByIdMedia) GetMediaListEntry() GetUserMediaByIdMediaMediaListEntryMediaList { 60 | return v.MediaListEntry 61 | } 62 | 63 | // GetTitle returns GetUserMediaByIdMedia.Title, and is useful for accessing the field via an interface. 64 | func (v *GetUserMediaByIdMedia) GetTitle() GetUserMediaByIdMediaTitle { return v.Title } 65 | 66 | // GetUserMediaByIdMediaMediaListEntryMediaList includes the requested fields of the GraphQL type MediaList. 67 | // The GraphQL type's documentation follows. 68 | // 69 | // List of anime or manga 70 | type GetUserMediaByIdMediaMediaListEntryMediaList struct { 71 | // The amount of volumes read by the user 72 | ProgressVolumes int `json:"progressVolumes"` 73 | // The amount of episodes/chapters consumed by the user 74 | Progress int `json:"progress"` 75 | // The watching/reading status 76 | Status MediaListStatus `json:"status"` 77 | } 78 | 79 | // GetProgressVolumes returns GetUserMediaByIdMediaMediaListEntryMediaList.ProgressVolumes, and is useful for accessing the field via an interface. 80 | func (v *GetUserMediaByIdMediaMediaListEntryMediaList) GetProgressVolumes() int { 81 | return v.ProgressVolumes 82 | } 83 | 84 | // GetProgress returns GetUserMediaByIdMediaMediaListEntryMediaList.Progress, and is useful for accessing the field via an interface. 85 | func (v *GetUserMediaByIdMediaMediaListEntryMediaList) GetProgress() int { return v.Progress } 86 | 87 | // GetStatus returns GetUserMediaByIdMediaMediaListEntryMediaList.Status, and is useful for accessing the field via an interface. 88 | func (v *GetUserMediaByIdMediaMediaListEntryMediaList) GetStatus() MediaListStatus { return v.Status } 89 | 90 | // GetUserMediaByIdMediaTitle includes the requested fields of the GraphQL type MediaTitle. 91 | // The GraphQL type's documentation follows. 92 | // 93 | // The official titles of the media in various languages 94 | type GetUserMediaByIdMediaTitle struct { 95 | // The currently authenticated users preferred title language. Default romaji for non-authenticated 96 | UserPreferred string `json:"userPreferred"` 97 | } 98 | 99 | // GetUserPreferred returns GetUserMediaByIdMediaTitle.UserPreferred, and is useful for accessing the field via an interface. 100 | func (v *GetUserMediaByIdMediaTitle) GetUserPreferred() string { return v.UserPreferred } 101 | 102 | // GetUserMediaByIdResponse is returned by GetUserMediaById on success. 103 | type GetUserMediaByIdResponse struct { 104 | // Media query 105 | Media GetUserMediaByIdMedia `json:"Media"` 106 | } 107 | 108 | // GetMedia returns GetUserMediaByIdResponse.Media, and is useful for accessing the field via an interface. 109 | func (v *GetUserMediaByIdResponse) GetMedia() GetUserMediaByIdMedia { return v.Media } 110 | 111 | // Media list watching/reading status enum. 112 | type MediaListStatus string 113 | 114 | const ( 115 | // Currently watching/reading 116 | MediaListStatusCurrent MediaListStatus = "CURRENT" 117 | // Planning to watch/read 118 | MediaListStatusPlanning MediaListStatus = "PLANNING" 119 | // Finished watching/reading 120 | MediaListStatusCompleted MediaListStatus = "COMPLETED" 121 | // Stopped watching/reading before completing 122 | MediaListStatusDropped MediaListStatus = "DROPPED" 123 | // Paused watching/reading 124 | MediaListStatusPaused MediaListStatus = "PAUSED" 125 | // Re-watching/reading 126 | MediaListStatusRepeating MediaListStatus = "REPEATING" 127 | ) 128 | 129 | // UpdateProgressResponse is returned by UpdateProgress on success. 130 | type UpdateProgressResponse struct { 131 | // Create or update a media list entry 132 | SaveMediaListEntry UpdateProgressSaveMediaListEntryMediaList `json:"SaveMediaListEntry"` 133 | } 134 | 135 | // GetSaveMediaListEntry returns UpdateProgressResponse.SaveMediaListEntry, and is useful for accessing the field via an interface. 136 | func (v *UpdateProgressResponse) GetSaveMediaListEntry() UpdateProgressSaveMediaListEntryMediaList { 137 | return v.SaveMediaListEntry 138 | } 139 | 140 | // UpdateProgressSaveMediaListEntryMediaList includes the requested fields of the GraphQL type MediaList. 141 | // The GraphQL type's documentation follows. 142 | // 143 | // List of anime or manga 144 | type UpdateProgressSaveMediaListEntryMediaList struct { 145 | // The id of the list entry 146 | Id int `json:"id"` 147 | // The id of the media 148 | MediaId int `json:"mediaId"` 149 | // The amount of episodes/chapters consumed by the user 150 | Progress int `json:"progress"` 151 | // The amount of volumes read by the user 152 | ProgressVolumes int `json:"progressVolumes"` 153 | // The watching/reading status 154 | Status MediaListStatus `json:"status"` 155 | } 156 | 157 | // GetId returns UpdateProgressSaveMediaListEntryMediaList.Id, and is useful for accessing the field via an interface. 158 | func (v *UpdateProgressSaveMediaListEntryMediaList) GetId() int { return v.Id } 159 | 160 | // GetMediaId returns UpdateProgressSaveMediaListEntryMediaList.MediaId, and is useful for accessing the field via an interface. 161 | func (v *UpdateProgressSaveMediaListEntryMediaList) GetMediaId() int { return v.MediaId } 162 | 163 | // GetProgress returns UpdateProgressSaveMediaListEntryMediaList.Progress, and is useful for accessing the field via an interface. 164 | func (v *UpdateProgressSaveMediaListEntryMediaList) GetProgress() int { return v.Progress } 165 | 166 | // GetProgressVolumes returns UpdateProgressSaveMediaListEntryMediaList.ProgressVolumes, and is useful for accessing the field via an interface. 167 | func (v *UpdateProgressSaveMediaListEntryMediaList) GetProgressVolumes() int { 168 | return v.ProgressVolumes 169 | } 170 | 171 | // GetStatus returns UpdateProgressSaveMediaListEntryMediaList.Status, and is useful for accessing the field via an interface. 172 | func (v *UpdateProgressSaveMediaListEntryMediaList) GetStatus() MediaListStatus { return v.Status } 173 | 174 | // __GetUserMediaByIdInput is used internally by genqlient 175 | type __GetUserMediaByIdInput struct { 176 | MediaId int `json:"mediaId"` 177 | } 178 | 179 | // GetMediaId returns __GetUserMediaByIdInput.MediaId, and is useful for accessing the field via an interface. 180 | func (v *__GetUserMediaByIdInput) GetMediaId() int { return v.MediaId } 181 | 182 | // __UpdateProgressInput is used internally by genqlient 183 | type __UpdateProgressInput struct { 184 | MediaId int `json:"mediaId"` 185 | Progress int `json:"progress"` 186 | ProgressVolumes int `json:"progressVolumes"` 187 | Status MediaListStatus `json:"status"` 188 | } 189 | 190 | // GetMediaId returns __UpdateProgressInput.MediaId, and is useful for accessing the field via an interface. 191 | func (v *__UpdateProgressInput) GetMediaId() int { return v.MediaId } 192 | 193 | // GetProgress returns __UpdateProgressInput.Progress, and is useful for accessing the field via an interface. 194 | func (v *__UpdateProgressInput) GetProgress() int { return v.Progress } 195 | 196 | // GetProgressVolumes returns __UpdateProgressInput.ProgressVolumes, and is useful for accessing the field via an interface. 197 | func (v *__UpdateProgressInput) GetProgressVolumes() int { return v.ProgressVolumes } 198 | 199 | // GetStatus returns __UpdateProgressInput.Status, and is useful for accessing the field via an interface. 200 | func (v *__UpdateProgressInput) GetStatus() MediaListStatus { return v.Status } 201 | 202 | // The query or mutation executed by GetCurrentUser. 203 | const GetCurrentUser_Operation = ` 204 | query GetCurrentUser { 205 | Viewer { 206 | id 207 | name 208 | } 209 | } 210 | ` 211 | 212 | func GetCurrentUser( 213 | ctx_ context.Context, 214 | client_ graphql.Client, 215 | ) (*GetCurrentUserResponse, error) { 216 | req_ := &graphql.Request{ 217 | OpName: "GetCurrentUser", 218 | Query: GetCurrentUser_Operation, 219 | } 220 | var err_ error 221 | 222 | var data_ GetCurrentUserResponse 223 | resp_ := &graphql.Response{Data: &data_} 224 | 225 | err_ = client_.MakeRequest( 226 | ctx_, 227 | req_, 228 | resp_, 229 | ) 230 | 231 | return &data_, err_ 232 | } 233 | 234 | // The query or mutation executed by GetUserMediaById. 235 | const GetUserMediaById_Operation = ` 236 | query GetUserMediaById ($mediaId: Int) { 237 | Media(id: $mediaId, type: MANGA) { 238 | volumes 239 | chapters 240 | mediaListEntry { 241 | progressVolumes 242 | progress 243 | status 244 | } 245 | title { 246 | userPreferred 247 | } 248 | } 249 | } 250 | ` 251 | 252 | func GetUserMediaById( 253 | ctx_ context.Context, 254 | client_ graphql.Client, 255 | mediaId int, 256 | ) (*GetUserMediaByIdResponse, error) { 257 | req_ := &graphql.Request{ 258 | OpName: "GetUserMediaById", 259 | Query: GetUserMediaById_Operation, 260 | Variables: &__GetUserMediaByIdInput{ 261 | MediaId: mediaId, 262 | }, 263 | } 264 | var err_ error 265 | 266 | var data_ GetUserMediaByIdResponse 267 | resp_ := &graphql.Response{Data: &data_} 268 | 269 | err_ = client_.MakeRequest( 270 | ctx_, 271 | req_, 272 | resp_, 273 | ) 274 | 275 | return &data_, err_ 276 | } 277 | 278 | // The query or mutation executed by UpdateProgress. 279 | const UpdateProgress_Operation = ` 280 | mutation UpdateProgress ($mediaId: Int, $progress: Int, $progressVolumes: Int, $status: MediaListStatus) { 281 | SaveMediaListEntry(progress: $progress, progressVolumes: $progressVolumes, mediaId: $mediaId, status: $status) { 282 | id 283 | mediaId 284 | progress 285 | progressVolumes 286 | status 287 | } 288 | } 289 | ` 290 | 291 | func UpdateProgress( 292 | ctx_ context.Context, 293 | client_ graphql.Client, 294 | mediaId int, 295 | progress int, 296 | progressVolumes int, 297 | status MediaListStatus, 298 | ) (*UpdateProgressResponse, error) { 299 | req_ := &graphql.Request{ 300 | OpName: "UpdateProgress", 301 | Query: UpdateProgress_Operation, 302 | Variables: &__UpdateProgressInput{ 303 | MediaId: mediaId, 304 | Progress: progress, 305 | ProgressVolumes: progressVolumes, 306 | Status: status, 307 | }, 308 | } 309 | var err_ error 310 | 311 | var data_ UpdateProgressResponse 312 | resp_ := &graphql.Response{Data: &data_} 313 | 314 | err_ = client_.MakeRequest( 315 | ctx_, 316 | req_, 317 | resp_, 318 | ) 319 | 320 | return &data_, err_ 321 | } 322 | -------------------------------------------------------------------------------- /internal/target/hardcover.go: -------------------------------------------------------------------------------- 1 | package target 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "math" 7 | "time" 8 | 9 | "github.com/Khan/genqlient/graphql" 10 | "github.com/RobBrazier/readflow/internal/config" 11 | "github.com/RobBrazier/readflow/internal/source" 12 | "github.com/RobBrazier/readflow/internal/target/hardcover" 13 | "github.com/charmbracelet/log" 14 | "github.com/spf13/cobra" 15 | ) 16 | 17 | //go:generate go run github.com/Khan/genqlient ../../schemas/hardcover/genqlient.yaml 18 | 19 | type HardcoverTarget struct { 20 | Target 21 | GraphQLTarget 22 | ctx context.Context 23 | client graphql.Client 24 | log *log.Logger 25 | } 26 | 27 | type hardcoverProgress struct { 28 | bookId int 29 | readId *int 30 | status int 31 | pages int 32 | progress float32 33 | startTime *time.Time 34 | edition int 35 | } 36 | 37 | func (t HardcoverTarget) Login() (string, error) { 38 | return "https://hardcover.app/account/api", nil 39 | } 40 | 41 | func (t *HardcoverTarget) getClient() graphql.Client { 42 | if t.client == nil { 43 | t.client = t.GraphQLTarget.getClient(t.ApiUrl, t.GetToken()) 44 | } 45 | return t.client 46 | } 47 | 48 | func (t HardcoverTarget) GetToken() string { 49 | return config.GetTokens().Hardcover 50 | } 51 | 52 | func (t *HardcoverTarget) GetCurrentUser() string { 53 | response, err := hardcover.GetCurrentUser(t.ctx, t.getClient()) 54 | cobra.CheckErr(err) 55 | return response.GetMe()[0].GetUsername() 56 | } 57 | 58 | func (t HardcoverTarget) ShouldProcess(book source.BookContext) bool { 59 | id := book.Current.HardcoverID 60 | if id == nil { 61 | return false 62 | } 63 | if *id == "" { 64 | return false 65 | } 66 | return true 67 | } 68 | 69 | // Yes this is absolutely horrible, but the generated code is horrible too... 70 | func (t *HardcoverTarget) getCurrentBookProgress(slug string) (*hardcoverProgress, error) { 71 | current, err := hardcover.GetUserBooksBySlug(t.ctx, t.getClient(), slug) 72 | if err != nil { 73 | return nil, err 74 | } 75 | me := current.Me[0] 76 | userBooks := me.User_books 77 | 78 | if len(userBooks) == 0 { 79 | return nil, errors.New("Book not found in User Books - Skipping") 80 | } 81 | userBook := userBooks[0] 82 | status := userBook.Status_id 83 | reads := userBook.User_book_reads 84 | pages := userBook.Edition.Pages 85 | bookId := userBook.Book_id 86 | result := hardcoverProgress{ 87 | bookId: bookId, 88 | status: status, 89 | pages: pages, 90 | edition: userBook.Edition.Id, 91 | } 92 | if len(reads) == 0 { 93 | // book hasn't been started yet - assuming 0 progress 94 | return &result, nil 95 | } 96 | read := reads[0] 97 | id := read.Id 98 | result.readId = &id 99 | if read.Edition.Id != 0 { 100 | result.edition = read.Edition.Id 101 | result.pages = read.Edition.Pages 102 | } 103 | 104 | result.startTime = &read.Started_at 105 | progress := read.Progress 106 | result.progress = progress 107 | return &result, nil 108 | } 109 | 110 | func (t *HardcoverTarget) updateProgress(readId, bookId, pages, edition, status int, startTime time.Time) error { 111 | ctx := t.ctx 112 | client := t.getClient() 113 | if status != 2 { // in progress 114 | _, err := hardcover.ChangeBookStatus(ctx, client, bookId, 2) 115 | if err != nil { 116 | return err 117 | } 118 | } 119 | _, err := hardcover.UpdateBookProgress(ctx, client, readId, pages, edition, startTime) 120 | return err 121 | } 122 | 123 | func (t *HardcoverTarget) finishProgress(readId, bookId, pages, edition int, startTime time.Time) error { 124 | finishTime := time.Now() 125 | ctx := t.ctx 126 | client := t.getClient() 127 | _, err := hardcover.FinishBookProgress(ctx, client, readId, pages, edition, startTime, finishTime) 128 | if err != nil { 129 | return err 130 | } 131 | _, err = hardcover.ChangeBookStatus(ctx, client, bookId, 3) // finished 132 | return err 133 | } 134 | 135 | func (t *HardcoverTarget) startProgress(bookId, pages, edition, status int) error { 136 | startTime := time.Now() 137 | ctx := t.ctx 138 | client := t.getClient() 139 | if status != 2 { // in progress 140 | _, err := hardcover.ChangeBookStatus(ctx, client, bookId, 2) 141 | if err != nil { 142 | return err 143 | } 144 | } 145 | _, err := hardcover.StartBookProgress(ctx, client, bookId, pages, edition, startTime) 146 | return err 147 | } 148 | 149 | func (t *HardcoverTarget) UpdateReadStatus(book source.BookContext) error { 150 | slug := book.Current.HardcoverID 151 | localProgressPointer := book.Current.ProgressPercent 152 | if localProgressPointer == nil { 153 | // no error, but nothing to update as we have no progress 154 | return nil 155 | } 156 | localProgress := *localProgressPointer 157 | bookProgress, err := t.getCurrentBookProgress(*slug) 158 | if err != nil { 159 | return err 160 | } 161 | // round to 0 decimal places to match the source progress 162 | remoteProgress := math.Round(float64(bookProgress.progress)) 163 | log := t.log.With("book", book.Current.BookName) 164 | 165 | log.Info("Retrieved book data", "localProgress", localProgress, "remoteProgress", remoteProgress) 166 | 167 | if localProgress <= remoteProgress { 168 | log.Info("Skipping update as target is already up-to-date") 169 | return nil 170 | } 171 | pages := float64(bookProgress.pages) 172 | progress := float64(localProgress / 100) 173 | newPagesCount := int(math.Round(pages * progress)) 174 | 175 | if bookProgress.readId != nil { 176 | log.Info("Updating progress for", "pages", newPagesCount) 177 | startTime := time.Now() 178 | if bookProgress.startTime != nil { 179 | startTime = *bookProgress.startTime 180 | } 181 | if progress == 1 { // 100% 182 | log.Info("Marking book as finished", "book", book.Current.BookName) 183 | err := t.finishProgress(*bookProgress.readId, bookProgress.bookId, newPagesCount, bookProgress.edition, startTime) 184 | if err != nil { 185 | log.Error("error finishing book", "error", err) 186 | } 187 | } else { 188 | err := t.updateProgress(*bookProgress.readId, bookProgress.bookId, newPagesCount, bookProgress.edition, bookProgress.status, startTime) 189 | if err != nil { 190 | log.Error("error updating progress", "error", err) 191 | } 192 | } 193 | } else { 194 | log.Info("Starting progress for", "pages", newPagesCount) 195 | err := t.startProgress(bookProgress.bookId, newPagesCount, bookProgress.edition, bookProgress.status) 196 | if err != nil { 197 | t.log.Error("error starting progress", "error", err) 198 | } 199 | } 200 | 201 | return nil 202 | } 203 | 204 | func NewHardcoverTarget() SyncTarget { 205 | name := "hardcover" 206 | logger := log.WithPrefix(name) 207 | target := &HardcoverTarget{ 208 | ctx: context.Background(), 209 | log: logger, 210 | Target: Target{ 211 | Name: name, 212 | ApiUrl: "https://api.hardcover.app/v1/graphql", 213 | }, 214 | } 215 | return target 216 | } 217 | -------------------------------------------------------------------------------- /internal/target/hardcover/generated.go: -------------------------------------------------------------------------------- 1 | // Code generated by github.com/Khan/genqlient, DO NOT EDIT. 2 | 3 | package hardcover 4 | 5 | import ( 6 | "context" 7 | "encoding/json" 8 | "fmt" 9 | "time" 10 | 11 | "github.com/Khan/genqlient/graphql" 12 | "github.com/RobBrazier/readflow/schemas/hardcover" 13 | ) 14 | 15 | // ChangeBookStatusInsert_user_bookUserBookIdType includes the requested fields of the GraphQL type UserBookIdType. 16 | type ChangeBookStatusInsert_user_bookUserBookIdType struct { 17 | Id int `json:"id"` 18 | } 19 | 20 | // GetId returns ChangeBookStatusInsert_user_bookUserBookIdType.Id, and is useful for accessing the field via an interface. 21 | func (v *ChangeBookStatusInsert_user_bookUserBookIdType) GetId() int { return v.Id } 22 | 23 | // ChangeBookStatusResponse is returned by ChangeBookStatus on success. 24 | type ChangeBookStatusResponse struct { 25 | // insert_user_book 26 | Insert_user_book ChangeBookStatusInsert_user_bookUserBookIdType `json:"insert_user_book"` 27 | } 28 | 29 | // GetInsert_user_book returns ChangeBookStatusResponse.Insert_user_book, and is useful for accessing the field via an interface. 30 | func (v *ChangeBookStatusResponse) GetInsert_user_book() ChangeBookStatusInsert_user_bookUserBookIdType { 31 | return v.Insert_user_book 32 | } 33 | 34 | // FinishBookProgressResponse is returned by FinishBookProgress on success. 35 | type FinishBookProgressResponse struct { 36 | // update_user_book_read 37 | Update_user_book_read FinishBookProgressUpdate_user_book_readUserBookReadIdType `json:"update_user_book_read"` 38 | } 39 | 40 | // GetUpdate_user_book_read returns FinishBookProgressResponse.Update_user_book_read, and is useful for accessing the field via an interface. 41 | func (v *FinishBookProgressResponse) GetUpdate_user_book_read() FinishBookProgressUpdate_user_book_readUserBookReadIdType { 42 | return v.Update_user_book_read 43 | } 44 | 45 | // FinishBookProgressUpdate_user_book_readUserBookReadIdType includes the requested fields of the GraphQL type UserBookReadIdType. 46 | type FinishBookProgressUpdate_user_book_readUserBookReadIdType struct { 47 | Id int `json:"id"` 48 | } 49 | 50 | // GetId returns FinishBookProgressUpdate_user_book_readUserBookReadIdType.Id, and is useful for accessing the field via an interface. 51 | func (v *FinishBookProgressUpdate_user_book_readUserBookReadIdType) GetId() int { return v.Id } 52 | 53 | // GetCurrentUserMeUsers includes the requested fields of the GraphQL type users. 54 | // The GraphQL type's documentation follows. 55 | // 56 | // columns and relationships of "users" 57 | type GetCurrentUserMeUsers struct { 58 | Name string `json:"name"` 59 | Username string `json:"username"` 60 | } 61 | 62 | // GetName returns GetCurrentUserMeUsers.Name, and is useful for accessing the field via an interface. 63 | func (v *GetCurrentUserMeUsers) GetName() string { return v.Name } 64 | 65 | // GetUsername returns GetCurrentUserMeUsers.Username, and is useful for accessing the field via an interface. 66 | func (v *GetCurrentUserMeUsers) GetUsername() string { return v.Username } 67 | 68 | // GetCurrentUserResponse is returned by GetCurrentUser on success. 69 | type GetCurrentUserResponse struct { 70 | // execute function "me" which returns "users" 71 | Me []GetCurrentUserMeUsers `json:"me"` 72 | } 73 | 74 | // GetMe returns GetCurrentUserResponse.Me, and is useful for accessing the field via an interface. 75 | func (v *GetCurrentUserResponse) GetMe() []GetCurrentUserMeUsers { return v.Me } 76 | 77 | // GetUserBooksBySlugMeUsers includes the requested fields of the GraphQL type users. 78 | // The GraphQL type's documentation follows. 79 | // 80 | // columns and relationships of "users" 81 | type GetUserBooksBySlugMeUsers struct { 82 | // An array relationship 83 | User_books []GetUserBooksBySlugMeUsersUser_books `json:"user_books"` 84 | } 85 | 86 | // GetUser_books returns GetUserBooksBySlugMeUsers.User_books, and is useful for accessing the field via an interface. 87 | func (v *GetUserBooksBySlugMeUsers) GetUser_books() []GetUserBooksBySlugMeUsersUser_books { 88 | return v.User_books 89 | } 90 | 91 | // GetUserBooksBySlugMeUsersUser_books includes the requested fields of the GraphQL type user_books. 92 | // The GraphQL type's documentation follows. 93 | // 94 | // columns and relationships of "user_books" 95 | type GetUserBooksBySlugMeUsersUser_books struct { 96 | Status_id int `json:"status_id"` 97 | Book_id int `json:"book_id"` 98 | // An object relationship 99 | Book GetUserBooksBySlugMeUsersUser_booksBookBooks `json:"book"` 100 | // An object relationship 101 | Edition GetUserBooksBySlugMeUsersUser_booksEditionEditions `json:"edition"` 102 | // An array relationship 103 | User_book_reads []GetUserBooksBySlugMeUsersUser_booksUser_book_reads `json:"user_book_reads"` 104 | } 105 | 106 | // GetStatus_id returns GetUserBooksBySlugMeUsersUser_books.Status_id, and is useful for accessing the field via an interface. 107 | func (v *GetUserBooksBySlugMeUsersUser_books) GetStatus_id() int { return v.Status_id } 108 | 109 | // GetBook_id returns GetUserBooksBySlugMeUsersUser_books.Book_id, and is useful for accessing the field via an interface. 110 | func (v *GetUserBooksBySlugMeUsersUser_books) GetBook_id() int { return v.Book_id } 111 | 112 | // GetBook returns GetUserBooksBySlugMeUsersUser_books.Book, and is useful for accessing the field via an interface. 113 | func (v *GetUserBooksBySlugMeUsersUser_books) GetBook() GetUserBooksBySlugMeUsersUser_booksBookBooks { 114 | return v.Book 115 | } 116 | 117 | // GetEdition returns GetUserBooksBySlugMeUsersUser_books.Edition, and is useful for accessing the field via an interface. 118 | func (v *GetUserBooksBySlugMeUsersUser_books) GetEdition() GetUserBooksBySlugMeUsersUser_booksEditionEditions { 119 | return v.Edition 120 | } 121 | 122 | // GetUser_book_reads returns GetUserBooksBySlugMeUsersUser_books.User_book_reads, and is useful for accessing the field via an interface. 123 | func (v *GetUserBooksBySlugMeUsersUser_books) GetUser_book_reads() []GetUserBooksBySlugMeUsersUser_booksUser_book_reads { 124 | return v.User_book_reads 125 | } 126 | 127 | // GetUserBooksBySlugMeUsersUser_booksBookBooks includes the requested fields of the GraphQL type books. 128 | // The GraphQL type's documentation follows. 129 | // 130 | // columns and relationships of "books" 131 | type GetUserBooksBySlugMeUsersUser_booksBookBooks struct { 132 | Slug string `json:"slug"` 133 | Title string `json:"title"` 134 | } 135 | 136 | // GetSlug returns GetUserBooksBySlugMeUsersUser_booksBookBooks.Slug, and is useful for accessing the field via an interface. 137 | func (v *GetUserBooksBySlugMeUsersUser_booksBookBooks) GetSlug() string { return v.Slug } 138 | 139 | // GetTitle returns GetUserBooksBySlugMeUsersUser_booksBookBooks.Title, and is useful for accessing the field via an interface. 140 | func (v *GetUserBooksBySlugMeUsersUser_booksBookBooks) GetTitle() string { return v.Title } 141 | 142 | // GetUserBooksBySlugMeUsersUser_booksEditionEditions includes the requested fields of the GraphQL type editions. 143 | // The GraphQL type's documentation follows. 144 | // 145 | // columns and relationships of "editions" 146 | type GetUserBooksBySlugMeUsersUser_booksEditionEditions struct { 147 | Id int `json:"id"` 148 | Pages int `json:"pages"` 149 | } 150 | 151 | // GetId returns GetUserBooksBySlugMeUsersUser_booksEditionEditions.Id, and is useful for accessing the field via an interface. 152 | func (v *GetUserBooksBySlugMeUsersUser_booksEditionEditions) GetId() int { return v.Id } 153 | 154 | // GetPages returns GetUserBooksBySlugMeUsersUser_booksEditionEditions.Pages, and is useful for accessing the field via an interface. 155 | func (v *GetUserBooksBySlugMeUsersUser_booksEditionEditions) GetPages() int { return v.Pages } 156 | 157 | // GetUserBooksBySlugMeUsersUser_booksUser_book_reads includes the requested fields of the GraphQL type user_book_reads. 158 | // The GraphQL type's documentation follows. 159 | // 160 | // columns and relationships of "user_book_reads" 161 | type GetUserBooksBySlugMeUsersUser_booksUser_book_reads struct { 162 | Id int `json:"id"` 163 | Progress float32 `json:"progress"` 164 | Progress_pages int `json:"progress_pages"` 165 | Started_at time.Time `json:"-"` 166 | Finished_at time.Time `json:"-"` 167 | // An object relationship 168 | Edition GetUserBooksBySlugMeUsersUser_booksUser_book_readsEditionEditions `json:"edition"` 169 | } 170 | 171 | // GetId returns GetUserBooksBySlugMeUsersUser_booksUser_book_reads.Id, and is useful for accessing the field via an interface. 172 | func (v *GetUserBooksBySlugMeUsersUser_booksUser_book_reads) GetId() int { return v.Id } 173 | 174 | // GetProgress returns GetUserBooksBySlugMeUsersUser_booksUser_book_reads.Progress, and is useful for accessing the field via an interface. 175 | func (v *GetUserBooksBySlugMeUsersUser_booksUser_book_reads) GetProgress() float32 { return v.Progress } 176 | 177 | // GetProgress_pages returns GetUserBooksBySlugMeUsersUser_booksUser_book_reads.Progress_pages, and is useful for accessing the field via an interface. 178 | func (v *GetUserBooksBySlugMeUsersUser_booksUser_book_reads) GetProgress_pages() int { 179 | return v.Progress_pages 180 | } 181 | 182 | // GetStarted_at returns GetUserBooksBySlugMeUsersUser_booksUser_book_reads.Started_at, and is useful for accessing the field via an interface. 183 | func (v *GetUserBooksBySlugMeUsersUser_booksUser_book_reads) GetStarted_at() time.Time { 184 | return v.Started_at 185 | } 186 | 187 | // GetFinished_at returns GetUserBooksBySlugMeUsersUser_booksUser_book_reads.Finished_at, and is useful for accessing the field via an interface. 188 | func (v *GetUserBooksBySlugMeUsersUser_booksUser_book_reads) GetFinished_at() time.Time { 189 | return v.Finished_at 190 | } 191 | 192 | // GetEdition returns GetUserBooksBySlugMeUsersUser_booksUser_book_reads.Edition, and is useful for accessing the field via an interface. 193 | func (v *GetUserBooksBySlugMeUsersUser_booksUser_book_reads) GetEdition() GetUserBooksBySlugMeUsersUser_booksUser_book_readsEditionEditions { 194 | return v.Edition 195 | } 196 | 197 | func (v *GetUserBooksBySlugMeUsersUser_booksUser_book_reads) UnmarshalJSON(b []byte) error { 198 | 199 | if string(b) == "null" { 200 | return nil 201 | } 202 | 203 | var firstPass struct { 204 | *GetUserBooksBySlugMeUsersUser_booksUser_book_reads 205 | Started_at json.RawMessage `json:"started_at"` 206 | Finished_at json.RawMessage `json:"finished_at"` 207 | graphql.NoUnmarshalJSON 208 | } 209 | firstPass.GetUserBooksBySlugMeUsersUser_booksUser_book_reads = v 210 | 211 | err := json.Unmarshal(b, &firstPass) 212 | if err != nil { 213 | return err 214 | } 215 | 216 | { 217 | dst := &v.Started_at 218 | src := firstPass.Started_at 219 | if len(src) != 0 && string(src) != "null" { 220 | err = hardcover.UnmarshalHardcoverDate( 221 | src, dst) 222 | if err != nil { 223 | return fmt.Errorf( 224 | "unable to unmarshal GetUserBooksBySlugMeUsersUser_booksUser_book_reads.Started_at: %w", err) 225 | } 226 | } 227 | } 228 | 229 | { 230 | dst := &v.Finished_at 231 | src := firstPass.Finished_at 232 | if len(src) != 0 && string(src) != "null" { 233 | err = hardcover.UnmarshalHardcoverDate( 234 | src, dst) 235 | if err != nil { 236 | return fmt.Errorf( 237 | "unable to unmarshal GetUserBooksBySlugMeUsersUser_booksUser_book_reads.Finished_at: %w", err) 238 | } 239 | } 240 | } 241 | return nil 242 | } 243 | 244 | type __premarshalGetUserBooksBySlugMeUsersUser_booksUser_book_reads struct { 245 | Id int `json:"id"` 246 | 247 | Progress float32 `json:"progress"` 248 | 249 | Progress_pages int `json:"progress_pages"` 250 | 251 | Started_at json.RawMessage `json:"started_at"` 252 | 253 | Finished_at json.RawMessage `json:"finished_at"` 254 | 255 | Edition GetUserBooksBySlugMeUsersUser_booksUser_book_readsEditionEditions `json:"edition"` 256 | } 257 | 258 | func (v *GetUserBooksBySlugMeUsersUser_booksUser_book_reads) MarshalJSON() ([]byte, error) { 259 | premarshaled, err := v.__premarshalJSON() 260 | if err != nil { 261 | return nil, err 262 | } 263 | return json.Marshal(premarshaled) 264 | } 265 | 266 | func (v *GetUserBooksBySlugMeUsersUser_booksUser_book_reads) __premarshalJSON() (*__premarshalGetUserBooksBySlugMeUsersUser_booksUser_book_reads, error) { 267 | var retval __premarshalGetUserBooksBySlugMeUsersUser_booksUser_book_reads 268 | 269 | retval.Id = v.Id 270 | retval.Progress = v.Progress 271 | retval.Progress_pages = v.Progress_pages 272 | { 273 | 274 | dst := &retval.Started_at 275 | src := v.Started_at 276 | var err error 277 | *dst, err = hardcover.MarshalHardcoverDate( 278 | &src) 279 | if err != nil { 280 | return nil, fmt.Errorf( 281 | "unable to marshal GetUserBooksBySlugMeUsersUser_booksUser_book_reads.Started_at: %w", err) 282 | } 283 | } 284 | { 285 | 286 | dst := &retval.Finished_at 287 | src := v.Finished_at 288 | var err error 289 | *dst, err = hardcover.MarshalHardcoverDate( 290 | &src) 291 | if err != nil { 292 | return nil, fmt.Errorf( 293 | "unable to marshal GetUserBooksBySlugMeUsersUser_booksUser_book_reads.Finished_at: %w", err) 294 | } 295 | } 296 | retval.Edition = v.Edition 297 | return &retval, nil 298 | } 299 | 300 | // GetUserBooksBySlugMeUsersUser_booksUser_book_readsEditionEditions includes the requested fields of the GraphQL type editions. 301 | // The GraphQL type's documentation follows. 302 | // 303 | // columns and relationships of "editions" 304 | type GetUserBooksBySlugMeUsersUser_booksUser_book_readsEditionEditions struct { 305 | Id int `json:"id"` 306 | Pages int `json:"pages"` 307 | } 308 | 309 | // GetId returns GetUserBooksBySlugMeUsersUser_booksUser_book_readsEditionEditions.Id, and is useful for accessing the field via an interface. 310 | func (v *GetUserBooksBySlugMeUsersUser_booksUser_book_readsEditionEditions) GetId() int { return v.Id } 311 | 312 | // GetPages returns GetUserBooksBySlugMeUsersUser_booksUser_book_readsEditionEditions.Pages, and is useful for accessing the field via an interface. 313 | func (v *GetUserBooksBySlugMeUsersUser_booksUser_book_readsEditionEditions) GetPages() int { 314 | return v.Pages 315 | } 316 | 317 | // GetUserBooksBySlugResponse is returned by GetUserBooksBySlug on success. 318 | type GetUserBooksBySlugResponse struct { 319 | // execute function "me" which returns "users" 320 | Me []GetUserBooksBySlugMeUsers `json:"me"` 321 | } 322 | 323 | // GetMe returns GetUserBooksBySlugResponse.Me, and is useful for accessing the field via an interface. 324 | func (v *GetUserBooksBySlugResponse) GetMe() []GetUserBooksBySlugMeUsers { return v.Me } 325 | 326 | // StartBookProgressInsert_user_book_readUserBookReadIdType includes the requested fields of the GraphQL type UserBookReadIdType. 327 | type StartBookProgressInsert_user_book_readUserBookReadIdType struct { 328 | Id int `json:"id"` 329 | } 330 | 331 | // GetId returns StartBookProgressInsert_user_book_readUserBookReadIdType.Id, and is useful for accessing the field via an interface. 332 | func (v *StartBookProgressInsert_user_book_readUserBookReadIdType) GetId() int { return v.Id } 333 | 334 | // StartBookProgressResponse is returned by StartBookProgress on success. 335 | type StartBookProgressResponse struct { 336 | Insert_user_book_read StartBookProgressInsert_user_book_readUserBookReadIdType `json:"insert_user_book_read"` 337 | } 338 | 339 | // GetInsert_user_book_read returns StartBookProgressResponse.Insert_user_book_read, and is useful for accessing the field via an interface. 340 | func (v *StartBookProgressResponse) GetInsert_user_book_read() StartBookProgressInsert_user_book_readUserBookReadIdType { 341 | return v.Insert_user_book_read 342 | } 343 | 344 | // UpdateBookProgressResponse is returned by UpdateBookProgress on success. 345 | type UpdateBookProgressResponse struct { 346 | // update_user_book_read 347 | Update_user_book_read UpdateBookProgressUpdate_user_book_readUserBookReadIdType `json:"update_user_book_read"` 348 | } 349 | 350 | // GetUpdate_user_book_read returns UpdateBookProgressResponse.Update_user_book_read, and is useful for accessing the field via an interface. 351 | func (v *UpdateBookProgressResponse) GetUpdate_user_book_read() UpdateBookProgressUpdate_user_book_readUserBookReadIdType { 352 | return v.Update_user_book_read 353 | } 354 | 355 | // UpdateBookProgressUpdate_user_book_readUserBookReadIdType includes the requested fields of the GraphQL type UserBookReadIdType. 356 | type UpdateBookProgressUpdate_user_book_readUserBookReadIdType struct { 357 | Id int `json:"id"` 358 | } 359 | 360 | // GetId returns UpdateBookProgressUpdate_user_book_readUserBookReadIdType.Id, and is useful for accessing the field via an interface. 361 | func (v *UpdateBookProgressUpdate_user_book_readUserBookReadIdType) GetId() int { return v.Id } 362 | 363 | // __ChangeBookStatusInput is used internally by genqlient 364 | type __ChangeBookStatusInput struct { 365 | BookId int `json:"bookId"` 366 | Status int `json:"status"` 367 | } 368 | 369 | // GetBookId returns __ChangeBookStatusInput.BookId, and is useful for accessing the field via an interface. 370 | func (v *__ChangeBookStatusInput) GetBookId() int { return v.BookId } 371 | 372 | // GetStatus returns __ChangeBookStatusInput.Status, and is useful for accessing the field via an interface. 373 | func (v *__ChangeBookStatusInput) GetStatus() int { return v.Status } 374 | 375 | // __FinishBookProgressInput is used internally by genqlient 376 | type __FinishBookProgressInput struct { 377 | Id int `json:"id"` 378 | Pages int `json:"pages"` 379 | EditionId int `json:"editionId"` 380 | StartedAt time.Time `json:"-"` 381 | FinishedAt time.Time `json:"-"` 382 | } 383 | 384 | // GetId returns __FinishBookProgressInput.Id, and is useful for accessing the field via an interface. 385 | func (v *__FinishBookProgressInput) GetId() int { return v.Id } 386 | 387 | // GetPages returns __FinishBookProgressInput.Pages, and is useful for accessing the field via an interface. 388 | func (v *__FinishBookProgressInput) GetPages() int { return v.Pages } 389 | 390 | // GetEditionId returns __FinishBookProgressInput.EditionId, and is useful for accessing the field via an interface. 391 | func (v *__FinishBookProgressInput) GetEditionId() int { return v.EditionId } 392 | 393 | // GetStartedAt returns __FinishBookProgressInput.StartedAt, and is useful for accessing the field via an interface. 394 | func (v *__FinishBookProgressInput) GetStartedAt() time.Time { return v.StartedAt } 395 | 396 | // GetFinishedAt returns __FinishBookProgressInput.FinishedAt, and is useful for accessing the field via an interface. 397 | func (v *__FinishBookProgressInput) GetFinishedAt() time.Time { return v.FinishedAt } 398 | 399 | func (v *__FinishBookProgressInput) UnmarshalJSON(b []byte) error { 400 | 401 | if string(b) == "null" { 402 | return nil 403 | } 404 | 405 | var firstPass struct { 406 | *__FinishBookProgressInput 407 | StartedAt json.RawMessage `json:"startedAt"` 408 | FinishedAt json.RawMessage `json:"finishedAt"` 409 | graphql.NoUnmarshalJSON 410 | } 411 | firstPass.__FinishBookProgressInput = v 412 | 413 | err := json.Unmarshal(b, &firstPass) 414 | if err != nil { 415 | return err 416 | } 417 | 418 | { 419 | dst := &v.StartedAt 420 | src := firstPass.StartedAt 421 | if len(src) != 0 && string(src) != "null" { 422 | err = hardcover.UnmarshalHardcoverDate( 423 | src, dst) 424 | if err != nil { 425 | return fmt.Errorf( 426 | "unable to unmarshal __FinishBookProgressInput.StartedAt: %w", err) 427 | } 428 | } 429 | } 430 | 431 | { 432 | dst := &v.FinishedAt 433 | src := firstPass.FinishedAt 434 | if len(src) != 0 && string(src) != "null" { 435 | err = hardcover.UnmarshalHardcoverDate( 436 | src, dst) 437 | if err != nil { 438 | return fmt.Errorf( 439 | "unable to unmarshal __FinishBookProgressInput.FinishedAt: %w", err) 440 | } 441 | } 442 | } 443 | return nil 444 | } 445 | 446 | type __premarshal__FinishBookProgressInput struct { 447 | Id int `json:"id"` 448 | 449 | Pages int `json:"pages"` 450 | 451 | EditionId int `json:"editionId"` 452 | 453 | StartedAt json.RawMessage `json:"startedAt"` 454 | 455 | FinishedAt json.RawMessage `json:"finishedAt"` 456 | } 457 | 458 | func (v *__FinishBookProgressInput) MarshalJSON() ([]byte, error) { 459 | premarshaled, err := v.__premarshalJSON() 460 | if err != nil { 461 | return nil, err 462 | } 463 | return json.Marshal(premarshaled) 464 | } 465 | 466 | func (v *__FinishBookProgressInput) __premarshalJSON() (*__premarshal__FinishBookProgressInput, error) { 467 | var retval __premarshal__FinishBookProgressInput 468 | 469 | retval.Id = v.Id 470 | retval.Pages = v.Pages 471 | retval.EditionId = v.EditionId 472 | { 473 | 474 | dst := &retval.StartedAt 475 | src := v.StartedAt 476 | var err error 477 | *dst, err = hardcover.MarshalHardcoverDate( 478 | &src) 479 | if err != nil { 480 | return nil, fmt.Errorf( 481 | "unable to marshal __FinishBookProgressInput.StartedAt: %w", err) 482 | } 483 | } 484 | { 485 | 486 | dst := &retval.FinishedAt 487 | src := v.FinishedAt 488 | var err error 489 | *dst, err = hardcover.MarshalHardcoverDate( 490 | &src) 491 | if err != nil { 492 | return nil, fmt.Errorf( 493 | "unable to marshal __FinishBookProgressInput.FinishedAt: %w", err) 494 | } 495 | } 496 | return &retval, nil 497 | } 498 | 499 | // __GetUserBooksBySlugInput is used internally by genqlient 500 | type __GetUserBooksBySlugInput struct { 501 | Slug string `json:"slug"` 502 | } 503 | 504 | // GetSlug returns __GetUserBooksBySlugInput.Slug, and is useful for accessing the field via an interface. 505 | func (v *__GetUserBooksBySlugInput) GetSlug() string { return v.Slug } 506 | 507 | // __StartBookProgressInput is used internally by genqlient 508 | type __StartBookProgressInput struct { 509 | BookId int `json:"bookId"` 510 | Pages int `json:"pages"` 511 | EditionId int `json:"editionId"` 512 | StartedAt time.Time `json:"-"` 513 | } 514 | 515 | // GetBookId returns __StartBookProgressInput.BookId, and is useful for accessing the field via an interface. 516 | func (v *__StartBookProgressInput) GetBookId() int { return v.BookId } 517 | 518 | // GetPages returns __StartBookProgressInput.Pages, and is useful for accessing the field via an interface. 519 | func (v *__StartBookProgressInput) GetPages() int { return v.Pages } 520 | 521 | // GetEditionId returns __StartBookProgressInput.EditionId, and is useful for accessing the field via an interface. 522 | func (v *__StartBookProgressInput) GetEditionId() int { return v.EditionId } 523 | 524 | // GetStartedAt returns __StartBookProgressInput.StartedAt, and is useful for accessing the field via an interface. 525 | func (v *__StartBookProgressInput) GetStartedAt() time.Time { return v.StartedAt } 526 | 527 | func (v *__StartBookProgressInput) UnmarshalJSON(b []byte) error { 528 | 529 | if string(b) == "null" { 530 | return nil 531 | } 532 | 533 | var firstPass struct { 534 | *__StartBookProgressInput 535 | StartedAt json.RawMessage `json:"startedAt"` 536 | graphql.NoUnmarshalJSON 537 | } 538 | firstPass.__StartBookProgressInput = v 539 | 540 | err := json.Unmarshal(b, &firstPass) 541 | if err != nil { 542 | return err 543 | } 544 | 545 | { 546 | dst := &v.StartedAt 547 | src := firstPass.StartedAt 548 | if len(src) != 0 && string(src) != "null" { 549 | err = hardcover.UnmarshalHardcoverDate( 550 | src, dst) 551 | if err != nil { 552 | return fmt.Errorf( 553 | "unable to unmarshal __StartBookProgressInput.StartedAt: %w", err) 554 | } 555 | } 556 | } 557 | return nil 558 | } 559 | 560 | type __premarshal__StartBookProgressInput struct { 561 | BookId int `json:"bookId"` 562 | 563 | Pages int `json:"pages"` 564 | 565 | EditionId int `json:"editionId"` 566 | 567 | StartedAt json.RawMessage `json:"startedAt"` 568 | } 569 | 570 | func (v *__StartBookProgressInput) MarshalJSON() ([]byte, error) { 571 | premarshaled, err := v.__premarshalJSON() 572 | if err != nil { 573 | return nil, err 574 | } 575 | return json.Marshal(premarshaled) 576 | } 577 | 578 | func (v *__StartBookProgressInput) __premarshalJSON() (*__premarshal__StartBookProgressInput, error) { 579 | var retval __premarshal__StartBookProgressInput 580 | 581 | retval.BookId = v.BookId 582 | retval.Pages = v.Pages 583 | retval.EditionId = v.EditionId 584 | { 585 | 586 | dst := &retval.StartedAt 587 | src := v.StartedAt 588 | var err error 589 | *dst, err = hardcover.MarshalHardcoverDate( 590 | &src) 591 | if err != nil { 592 | return nil, fmt.Errorf( 593 | "unable to marshal __StartBookProgressInput.StartedAt: %w", err) 594 | } 595 | } 596 | return &retval, nil 597 | } 598 | 599 | // __UpdateBookProgressInput is used internally by genqlient 600 | type __UpdateBookProgressInput struct { 601 | Id int `json:"id"` 602 | Pages int `json:"pages"` 603 | EditionId int `json:"editionId"` 604 | StartedAt time.Time `json:"-"` 605 | } 606 | 607 | // GetId returns __UpdateBookProgressInput.Id, and is useful for accessing the field via an interface. 608 | func (v *__UpdateBookProgressInput) GetId() int { return v.Id } 609 | 610 | // GetPages returns __UpdateBookProgressInput.Pages, and is useful for accessing the field via an interface. 611 | func (v *__UpdateBookProgressInput) GetPages() int { return v.Pages } 612 | 613 | // GetEditionId returns __UpdateBookProgressInput.EditionId, and is useful for accessing the field via an interface. 614 | func (v *__UpdateBookProgressInput) GetEditionId() int { return v.EditionId } 615 | 616 | // GetStartedAt returns __UpdateBookProgressInput.StartedAt, and is useful for accessing the field via an interface. 617 | func (v *__UpdateBookProgressInput) GetStartedAt() time.Time { return v.StartedAt } 618 | 619 | func (v *__UpdateBookProgressInput) UnmarshalJSON(b []byte) error { 620 | 621 | if string(b) == "null" { 622 | return nil 623 | } 624 | 625 | var firstPass struct { 626 | *__UpdateBookProgressInput 627 | StartedAt json.RawMessage `json:"startedAt"` 628 | graphql.NoUnmarshalJSON 629 | } 630 | firstPass.__UpdateBookProgressInput = v 631 | 632 | err := json.Unmarshal(b, &firstPass) 633 | if err != nil { 634 | return err 635 | } 636 | 637 | { 638 | dst := &v.StartedAt 639 | src := firstPass.StartedAt 640 | if len(src) != 0 && string(src) != "null" { 641 | err = hardcover.UnmarshalHardcoverDate( 642 | src, dst) 643 | if err != nil { 644 | return fmt.Errorf( 645 | "unable to unmarshal __UpdateBookProgressInput.StartedAt: %w", err) 646 | } 647 | } 648 | } 649 | return nil 650 | } 651 | 652 | type __premarshal__UpdateBookProgressInput struct { 653 | Id int `json:"id"` 654 | 655 | Pages int `json:"pages"` 656 | 657 | EditionId int `json:"editionId"` 658 | 659 | StartedAt json.RawMessage `json:"startedAt"` 660 | } 661 | 662 | func (v *__UpdateBookProgressInput) MarshalJSON() ([]byte, error) { 663 | premarshaled, err := v.__premarshalJSON() 664 | if err != nil { 665 | return nil, err 666 | } 667 | return json.Marshal(premarshaled) 668 | } 669 | 670 | func (v *__UpdateBookProgressInput) __premarshalJSON() (*__premarshal__UpdateBookProgressInput, error) { 671 | var retval __premarshal__UpdateBookProgressInput 672 | 673 | retval.Id = v.Id 674 | retval.Pages = v.Pages 675 | retval.EditionId = v.EditionId 676 | { 677 | 678 | dst := &retval.StartedAt 679 | src := v.StartedAt 680 | var err error 681 | *dst, err = hardcover.MarshalHardcoverDate( 682 | &src) 683 | if err != nil { 684 | return nil, fmt.Errorf( 685 | "unable to marshal __UpdateBookProgressInput.StartedAt: %w", err) 686 | } 687 | } 688 | return &retval, nil 689 | } 690 | 691 | // The query or mutation executed by ChangeBookStatus. 692 | const ChangeBookStatus_Operation = ` 693 | mutation ChangeBookStatus ($bookId: Int!, $status: Int) { 694 | insert_user_book(object: {book_id:$bookId,status_id:$status}) { 695 | id 696 | } 697 | } 698 | ` 699 | 700 | func ChangeBookStatus( 701 | ctx_ context.Context, 702 | client_ graphql.Client, 703 | bookId int, 704 | status int, 705 | ) (*ChangeBookStatusResponse, error) { 706 | req_ := &graphql.Request{ 707 | OpName: "ChangeBookStatus", 708 | Query: ChangeBookStatus_Operation, 709 | Variables: &__ChangeBookStatusInput{ 710 | BookId: bookId, 711 | Status: status, 712 | }, 713 | } 714 | var err_ error 715 | 716 | var data_ ChangeBookStatusResponse 717 | resp_ := &graphql.Response{Data: &data_} 718 | 719 | err_ = client_.MakeRequest( 720 | ctx_, 721 | req_, 722 | resp_, 723 | ) 724 | 725 | return &data_, err_ 726 | } 727 | 728 | // The query or mutation executed by FinishBookProgress. 729 | const FinishBookProgress_Operation = ` 730 | mutation FinishBookProgress ($id: Int!, $pages: Int, $editionId: Int, $startedAt: date, $finishedAt: date) { 731 | update_user_book_read(id: $id, object: {progress_pages:$pages,edition_id:$editionId,started_at:$startedAt,finished_at:$finishedAt}) { 732 | id 733 | } 734 | } 735 | ` 736 | 737 | func FinishBookProgress( 738 | ctx_ context.Context, 739 | client_ graphql.Client, 740 | id int, 741 | pages int, 742 | editionId int, 743 | startedAt time.Time, 744 | finishedAt time.Time, 745 | ) (*FinishBookProgressResponse, error) { 746 | req_ := &graphql.Request{ 747 | OpName: "FinishBookProgress", 748 | Query: FinishBookProgress_Operation, 749 | Variables: &__FinishBookProgressInput{ 750 | Id: id, 751 | Pages: pages, 752 | EditionId: editionId, 753 | StartedAt: startedAt, 754 | FinishedAt: finishedAt, 755 | }, 756 | } 757 | var err_ error 758 | 759 | var data_ FinishBookProgressResponse 760 | resp_ := &graphql.Response{Data: &data_} 761 | 762 | err_ = client_.MakeRequest( 763 | ctx_, 764 | req_, 765 | resp_, 766 | ) 767 | 768 | return &data_, err_ 769 | } 770 | 771 | // The query or mutation executed by GetCurrentUser. 772 | const GetCurrentUser_Operation = ` 773 | query GetCurrentUser { 774 | me { 775 | name 776 | username 777 | } 778 | } 779 | ` 780 | 781 | func GetCurrentUser( 782 | ctx_ context.Context, 783 | client_ graphql.Client, 784 | ) (*GetCurrentUserResponse, error) { 785 | req_ := &graphql.Request{ 786 | OpName: "GetCurrentUser", 787 | Query: GetCurrentUser_Operation, 788 | } 789 | var err_ error 790 | 791 | var data_ GetCurrentUserResponse 792 | resp_ := &graphql.Response{Data: &data_} 793 | 794 | err_ = client_.MakeRequest( 795 | ctx_, 796 | req_, 797 | resp_, 798 | ) 799 | 800 | return &data_, err_ 801 | } 802 | 803 | // The query or mutation executed by GetUserBooksBySlug. 804 | const GetUserBooksBySlug_Operation = ` 805 | query GetUserBooksBySlug ($slug: String) { 806 | me { 807 | user_books(where: {book:{slug:{_eq:$slug}}}) { 808 | status_id 809 | book_id 810 | book { 811 | slug 812 | title 813 | } 814 | edition { 815 | id 816 | pages 817 | } 818 | user_book_reads(order_by: {started_at:desc}, limit: 1) { 819 | id 820 | progress 821 | progress_pages 822 | started_at 823 | finished_at 824 | edition { 825 | id 826 | pages 827 | } 828 | } 829 | } 830 | } 831 | } 832 | ` 833 | 834 | func GetUserBooksBySlug( 835 | ctx_ context.Context, 836 | client_ graphql.Client, 837 | slug string, 838 | ) (*GetUserBooksBySlugResponse, error) { 839 | req_ := &graphql.Request{ 840 | OpName: "GetUserBooksBySlug", 841 | Query: GetUserBooksBySlug_Operation, 842 | Variables: &__GetUserBooksBySlugInput{ 843 | Slug: slug, 844 | }, 845 | } 846 | var err_ error 847 | 848 | var data_ GetUserBooksBySlugResponse 849 | resp_ := &graphql.Response{Data: &data_} 850 | 851 | err_ = client_.MakeRequest( 852 | ctx_, 853 | req_, 854 | resp_, 855 | ) 856 | 857 | return &data_, err_ 858 | } 859 | 860 | // The query or mutation executed by StartBookProgress. 861 | const StartBookProgress_Operation = ` 862 | mutation StartBookProgress ($bookId: Int!, $pages: Int, $editionId: Int, $startedAt: date) { 863 | insert_user_book_read(user_book_id: $bookId, user_book_read: {progress_pages:$pages,edition_id:$editionId,started_at:$startedAt}) { 864 | id 865 | } 866 | } 867 | ` 868 | 869 | func StartBookProgress( 870 | ctx_ context.Context, 871 | client_ graphql.Client, 872 | bookId int, 873 | pages int, 874 | editionId int, 875 | startedAt time.Time, 876 | ) (*StartBookProgressResponse, error) { 877 | req_ := &graphql.Request{ 878 | OpName: "StartBookProgress", 879 | Query: StartBookProgress_Operation, 880 | Variables: &__StartBookProgressInput{ 881 | BookId: bookId, 882 | Pages: pages, 883 | EditionId: editionId, 884 | StartedAt: startedAt, 885 | }, 886 | } 887 | var err_ error 888 | 889 | var data_ StartBookProgressResponse 890 | resp_ := &graphql.Response{Data: &data_} 891 | 892 | err_ = client_.MakeRequest( 893 | ctx_, 894 | req_, 895 | resp_, 896 | ) 897 | 898 | return &data_, err_ 899 | } 900 | 901 | // The query or mutation executed by UpdateBookProgress. 902 | const UpdateBookProgress_Operation = ` 903 | mutation UpdateBookProgress ($id: Int!, $pages: Int, $editionId: Int, $startedAt: date) { 904 | update_user_book_read(id: $id, object: {progress_pages:$pages,edition_id:$editionId,started_at:$startedAt}) { 905 | id 906 | } 907 | } 908 | ` 909 | 910 | func UpdateBookProgress( 911 | ctx_ context.Context, 912 | client_ graphql.Client, 913 | id int, 914 | pages int, 915 | editionId int, 916 | startedAt time.Time, 917 | ) (*UpdateBookProgressResponse, error) { 918 | req_ := &graphql.Request{ 919 | OpName: "UpdateBookProgress", 920 | Query: UpdateBookProgress_Operation, 921 | Variables: &__UpdateBookProgressInput{ 922 | Id: id, 923 | Pages: pages, 924 | EditionId: editionId, 925 | StartedAt: startedAt, 926 | }, 927 | } 928 | var err_ error 929 | 930 | var data_ UpdateBookProgressResponse 931 | resp_ := &graphql.Response{Data: &data_} 932 | 933 | err_ = client_.MakeRequest( 934 | ctx_, 935 | req_, 936 | resp_, 937 | ) 938 | 939 | return &data_, err_ 940 | } 941 | -------------------------------------------------------------------------------- /internal/target/target.go: -------------------------------------------------------------------------------- 1 | package target 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | "net/http" 7 | "slices" 8 | "sync" 9 | "sync/atomic" 10 | 11 | "github.com/Khan/genqlient/graphql" 12 | "github.com/RobBrazier/readflow/internal/config" 13 | "github.com/RobBrazier/readflow/internal/source" 14 | "github.com/charmbracelet/log" 15 | "github.com/hashicorp/go-retryablehttp" 16 | ) 17 | 18 | var ( 19 | targets atomic.Pointer[[]SyncTarget] 20 | targetsOnce sync.Once 21 | ) 22 | 23 | type Target struct { 24 | Name string 25 | ApiUrl string 26 | SyncTarget 27 | } 28 | 29 | type GraphQLTarget struct{} 30 | 31 | type authTransport struct { 32 | key string 33 | wrapped http.RoundTripper 34 | } 35 | 36 | type SyncTarget interface { 37 | Login() (string, error) 38 | GetToken() string 39 | GetName() string 40 | GetCurrentUser() string 41 | ShouldProcess(book source.BookContext) bool 42 | UpdateReadStatus(book source.BookContext) error 43 | } 44 | 45 | func (t *authTransport) RoundTrip(req *http.Request) (*http.Response, error) { 46 | req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", t.key)) 47 | return t.wrapped.RoundTrip(req) 48 | } 49 | 50 | func (g *GraphQLTarget) getClient(url, token string) graphql.Client { 51 | retryClient := retryablehttp.NewClient() 52 | retryClient.HTTPClient = &http.Client{ 53 | Transport: &authTransport{ 54 | key: token, 55 | wrapped: http.DefaultTransport, 56 | }, 57 | } 58 | retryClient.Logger = slog.New(log.WithPrefix("graphql")) 59 | httpClient := retryClient.StandardClient() 60 | return graphql.NewClient(url, httpClient) 61 | } 62 | 63 | func (t *Target) GetName() string { 64 | return t.Name 65 | } 66 | 67 | func GetTargets() *[]SyncTarget { 68 | t := targets.Load() 69 | if t == nil { 70 | targetsOnce.Do(func() { 71 | targets.CompareAndSwap(nil, &[]SyncTarget{ 72 | NewAnilistTarget(), 73 | NewHardcoverTarget(), 74 | }) 75 | }) 76 | t = targets.Load() 77 | } 78 | return t 79 | } 80 | 81 | func GetActiveTargets() []SyncTarget { 82 | active := []SyncTarget{} 83 | selectedTargets := config.GetTargets() 84 | for _, target := range *GetTargets() { 85 | if slices.Contains(selectedTargets, target.GetName()) { 86 | active = append(active, target) 87 | } 88 | } 89 | return active 90 | } 91 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/RobBrazier/readflow/cmd" 5 | ) 6 | 7 | func main() { 8 | cmd.Execute() 9 | } 10 | -------------------------------------------------------------------------------- /packaging/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | set -e 4 | 5 | cronjob="$CRON_SCHEDULE /bin/readflow sync" 6 | echo "$cronjob" > /tmp/crontab 7 | chmod 644 /tmp/crontab 8 | 9 | # first arg is `-f` or `--some-option` 10 | if [ "${1#-}" != "$1" ] || [ "$1" == "sync" ]; then 11 | set -- /bin/readflow "$@" 12 | fi 13 | 14 | exec "$@" 15 | 16 | -------------------------------------------------------------------------------- /packaging/scripts/postinstall.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Step 1, decide if we should use SystemD or init/upstart 4 | use_systemctl="True" 5 | systemd_version=0 6 | if ! command -V systemctl >/dev/null 2>&1; then 7 | use_systemctl="False" 8 | else 9 | systemd_version=$(systemctl --version | head -1 | sed 's/systemd //g') 10 | fi 11 | 12 | cleanup() { 13 | # This is where you remove files that were not needed on this platform / system 14 | if [ "${use_systemctl}" = "False" ]; then 15 | rm -f /usr/lib/systemd/system/readflow.service 16 | rm -f /usr/lib/systemd/system/readflow.timer 17 | fi 18 | } 19 | 20 | cleanInstall() { 21 | printf "\033[32m Post Install of an clean install\033[0m\n" 22 | # Step 3 (clean install), enable the service in the proper way for this platform 23 | if [ "${use_systemctl}" = "True" ]; then 24 | # rhel/centos7 cannot use ExecStartPre=+ to specify the pre start should be run as root 25 | # even if you want your service to run as non root. 26 | if [ "${systemd_version}" -lt 231 ]; then 27 | printf "\033[31m systemd version %s is less then 231, fixing the service file \033[0m\n" "${systemd_version}" 28 | sed -i "s/=+/=/g" /usr/lib/systemd/system/readflow.service 29 | fi 30 | printf "\033[32m Reload the service unit from disk\033[0m\n" 31 | systemctl daemon-reload || true 32 | printf "\033[32m Unmask the service and timer\033[0m\n" 33 | systemctl unmask readflow.service || true 34 | systemctl unmask readflow.timer || true 35 | printf "\033[32m Set the preset flag for the service and timer units\033[0m\n" 36 | systemctl preset readflow.service || true 37 | systemctl preset readflow.timer || true 38 | printf "\033[32m Set the enabled flag for the service and timer units\033[0m\n" 39 | systemctl enable readflow.service || true 40 | systemctl enable readflow.timer || true 41 | printf "\033[32m Start the timer unit\033[0m\n" 42 | systemctl start readflow.timer || true 43 | fi 44 | } 45 | 46 | upgrade() { 47 | printf "\033[32m Post Install of an upgrade\033[0m\n" 48 | # Step 3(upgrade), do what you need 49 | } 50 | 51 | # Step 2, check if this is a clean install or an upgrade 52 | action="$1" 53 | if [ "$1" = "configure" ] && [ -z "$2" ]; then 54 | # Alpine linux does not pass args, and deb passes $1=configure 55 | action="install" 56 | elif [ "$1" = "configure" ] && [ -n "$2" ]; then 57 | # deb passes $1=configure $2= 58 | action="upgrade" 59 | fi 60 | 61 | case "$action" in 62 | "1" | "install") 63 | cleanInstall 64 | ;; 65 | "2" | "upgrade") 66 | printf "\033[32m Post Install of an upgrade\033[0m\n" 67 | upgrade 68 | ;; 69 | *) 70 | # $1 == version being installed 71 | printf "\033[32m Alpine\033[0m" 72 | cleanInstall 73 | ;; 74 | esac 75 | 76 | # Step 4, clean up unused files, yes you get a warning when you remove the package, but that is ok. 77 | cleanup 78 | -------------------------------------------------------------------------------- /packaging/scripts/preremove.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Step 1, decide if we should use SystemD or init/upstart 4 | use_systemctl="True" 5 | systemd_version=0 6 | if ! command -V systemctl >/dev/null 2>&1; then 7 | use_systemctl="False" 8 | fi 9 | 10 | 11 | if [ "${use_systemctl}" = "True" ]; then 12 | printf "\033[32m Stop the service and timer units\033[0m\n" 13 | systemctl stop readflow.timer ||: 14 | systemctl stop readflow.service ||: 15 | printf "\033[32m Set the disabled flag for the service and timer units\033[0m\n" 16 | systemctl disable readflow.service ||: 17 | systemctl disable readflow.timer ||: 18 | printf "\033[32m Mask the service and timer\033[0m\n" 19 | systemctl mask readflow.service ||: 20 | systemctl mask readflow.timer ||: 21 | printf "\033[32m Reload the service unit from disk\033[0m\n" 22 | systemctl daemon-reload ||: 23 | fi 24 | -------------------------------------------------------------------------------- /packaging/systemd/readflow.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Readflow sync service 3 | Wants=network-online.target 4 | After=network-online.target 5 | 6 | [Service] 7 | Type=oneshot 8 | ExecStart=/usr/local/bin/readflow sync -c /etc/readflow/config.yaml 9 | 10 | [Install] 11 | WantedBy=default.target 12 | -------------------------------------------------------------------------------- /packaging/systemd/readflow.timer: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Readflow sync timer 3 | 4 | [Timer] 5 | OnBootSec=1min 6 | OnUnitActiveSec=1h 7 | 8 | [Install] 9 | WantedBy=timers.target 10 | -------------------------------------------------------------------------------- /schemas/anilist/genqlient.yaml: -------------------------------------------------------------------------------- 1 | schema: schema.gql 2 | operations: 3 | - queries.gql 4 | - mutations.gql 5 | generated: ../../internal/target/anilist/generated.go 6 | -------------------------------------------------------------------------------- /schemas/anilist/mutations.gql: -------------------------------------------------------------------------------- 1 | mutation UpdateProgress($mediaId: Int, $progress: Int, $progressVolumes: Int, $status: MediaListStatus) { 2 | SaveMediaListEntry(progress: $progress, progressVolumes: $progressVolumes, mediaId: $mediaId, status: $status) { 3 | id 4 | mediaId 5 | progress 6 | progressVolumes 7 | status 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /schemas/anilist/queries.gql: -------------------------------------------------------------------------------- 1 | query GetCurrentUser { 2 | Viewer { 3 | id 4 | name 5 | } 6 | } 7 | 8 | 9 | query GetUserMediaById($mediaId: Int) { 10 | Media(id: $mediaId, type: MANGA) { 11 | volumes 12 | chapters 13 | mediaListEntry { 14 | progressVolumes 15 | progress 16 | status 17 | } 18 | title { 19 | userPreferred 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /schemas/hardcover/genqlient.yaml: -------------------------------------------------------------------------------- 1 | schema: schema.gql 2 | operations: 3 | - queries.gql 4 | - mutations.gql 5 | generated: ../../internal/target/hardcover/generated.go 6 | bindings: 7 | citext: 8 | type: string 9 | timestamp: 10 | type: time.Time 11 | date: 12 | type: time.Time 13 | marshaler: github.com/RobBrazier/readflow/schemas/hardcover.MarshalHardcoverDate 14 | unmarshaler: github.com/RobBrazier/readflow/schemas/hardcover.UnmarshalHardcoverDate 15 | numeric: 16 | type: int 17 | float8: 18 | type: float32 19 | -------------------------------------------------------------------------------- /schemas/hardcover/hardcover.go: -------------------------------------------------------------------------------- 1 | package hardcover 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "time" 7 | ) 8 | 9 | func UnmarshalHardcoverDate(b []byte, v *time.Time) error { 10 | var input string 11 | json.Unmarshal(b, &input) 12 | parsedTime, err := time.Parse(time.DateOnly, input) 13 | if err != nil { 14 | return err 15 | } 16 | *v = parsedTime 17 | return nil 18 | } 19 | 20 | func MarshalHardcoverDate(v *time.Time) ([]byte, error) { 21 | if v == nil { 22 | return nil, errors.New("nil time value") 23 | } 24 | 25 | formattedTime := v.Format(time.DateOnly) 26 | return json.Marshal(formattedTime) 27 | } 28 | -------------------------------------------------------------------------------- /schemas/hardcover/mutations.gql: -------------------------------------------------------------------------------- 1 | mutation ChangeBookStatus($bookId: Int!, $status: Int) { 2 | insert_user_book(object: {book_id: $bookId, status_id: $status}) { 3 | id 4 | } 5 | } 6 | 7 | mutation StartBookProgress($bookId: Int!, $pages: Int, $editionId: Int, $startedAt: date) { 8 | insert_user_book_read(user_book_id: $bookId, user_book_read: { 9 | progress_pages: $pages, 10 | edition_id: $editionId, 11 | started_at: $startedAt, 12 | }) { 13 | id 14 | } 15 | } 16 | 17 | mutation UpdateBookProgress($id: Int!, $pages: Int, $editionId: Int, $startedAt: date) { 18 | update_user_book_read(id: $id, object: { 19 | progress_pages: $pages, 20 | edition_id: $editionId, 21 | started_at: $startedAt, 22 | }) { 23 | id 24 | } 25 | } 26 | 27 | mutation FinishBookProgress($id: Int!, $pages: Int, $editionId: Int, $startedAt: date, $finishedAt: date) { 28 | update_user_book_read(id: $id, object: { 29 | progress_pages: $pages, 30 | edition_id: $editionId, 31 | started_at: $startedAt, 32 | finished_at: $finishedAt, 33 | }) { 34 | id 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /schemas/hardcover/queries.gql: -------------------------------------------------------------------------------- 1 | query GetCurrentUser { 2 | me { 3 | name 4 | username 5 | } 6 | } 7 | 8 | query GetUserBooksBySlug($slug: String) { 9 | me { 10 | user_books(where: {book: {slug: {_eq: $slug}}}) { 11 | status_id 12 | book_id 13 | book { 14 | slug 15 | title 16 | } 17 | edition { 18 | id 19 | pages 20 | } 21 | user_book_reads(order_by: {started_at: desc}, limit: 1) { 22 | id 23 | progress 24 | progress_pages 25 | started_at 26 | finished_at 27 | edition { 28 | id 29 | pages 30 | } 31 | } 32 | } 33 | } 34 | } 35 | --------------------------------------------------------------------------------