├── .eslintignore ├── src └── Kavya │ ├── includes │ └── icon.png │ ├── CacheManager.ts │ ├── Search.ts │ ├── Common.ts │ ├── Settings.ts │ └── Kavya.ts ├── README.md ├── .github ├── workflows │ └── main.yml └── ISSUE_TEMPLATE │ └── bug_report.md ├── package.json ├── .eslintrc.js ├── LICENSE ├── .gitignore └── tsconfig.json /.eslintignore: -------------------------------------------------------------------------------- 1 | /lib 2 | -------------------------------------------------------------------------------- /src/Kavya/includes/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ACK72/kavya-paperback/HEAD/src/Kavya/includes/icon.png -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Kavya ![Generic badge](https://img.shields.io/badge/version-1.3.6-green.svg) 2 | Kavya, A [Kavita](https://www.kavitareader.com/) client extension, for [Paperback](https://paperback.moe/) 3 | 4 | 5 | ## Installation source 6 | You can install paperback extension source from below url 7 | 8 | [`https://ACK72.github.io/kavya-paperback`](https://ACK72.github.io/kavya-paperback) 9 | 10 | ## Requirements 11 | - 0.8 or newer version of Paperback 12 | - Stable version of Kavita (Tested with [linuxserver/kavita](https://docs.linuxserver.io/images/docker-kavita)) 13 | 14 | 15 | ## Setting up Page Size 16 | Set up page size in kavya setting page, 20 for iOS and 40 for iPadOS (Default is set to 40) 17 | 18 | ## Limitations 19 | 20 | - Each series you want to track have to be added to a collection and track list. 21 | - Tracking only works when you read the comic in the viewer (does not work with mark as read). 22 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - main 5 | pull_request: 6 | types: [closed] 7 | branches: 8 | - main 9 | name: Bundle and Publish Sources 10 | jobs: 11 | build: 12 | name: Bundle and Publish Sources 13 | runs-on: ubuntu-latest 14 | 15 | strategy: 16 | matrix: 17 | node-version: [14.x] 18 | 19 | steps: 20 | - name: Checkout Branch 21 | uses: actions/checkout@v2 22 | - name: Setup Node.js environment 23 | uses: actions/setup-node@v1 24 | with: 25 | node-version: ${{ matrix.node-version }} 26 | 27 | - run: npm install 28 | - run: npm run bundle 29 | 30 | - name: Deploy 31 | uses: s0/git-publish-subdir-action@master 32 | env: 33 | REPO: self 34 | BRANCH: gh-pages 35 | FOLDER: bundles 36 | GITHUB_TOKEN: ${{ secrets.TOKEN }} 37 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[BUG]" 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Additional logs** 27 | Paperback log files are a great help in identifying and resolving issues. 28 | 29 | Log files contain sensitive information such as server address and API key, 30 | so please send them via . 31 | And it is recommended to change related API key. 32 | 33 | You can get app logs at paperback settings > export app logs, 34 | and extension logs at paperback settings > external sources > Kavya > share logs. 35 | -------------------------------------------------------------------------------- /src/Kavya/CacheManager.ts: -------------------------------------------------------------------------------- 1 | export class CacheManager { 2 | private cachedData: { [key: number]: { time: Date, data: any } }; 3 | 4 | constructor() { 5 | this.cachedData = {}; 6 | } 7 | 8 | getHash(str: string): number { 9 | let hash = 0 10 | let chr; 11 | 12 | for (let i = 0;i < str.length;i++) { 13 | chr = str.charCodeAt(i); 14 | hash = ((hash << 5) - hash) + chr; 15 | hash |= 0; // Convert to 32bit integer 16 | } 17 | 18 | return hash; 19 | } 20 | 21 | getCachedData(str: string) { 22 | const time = new Date(); 23 | const key = this.getHash(str); 24 | 25 | this.cachedData = Object.fromEntries( 26 | Object.entries(this.cachedData).filter( 27 | ([_, value]) => 0 < (time.getTime() - value.time.getTime()) && (time.getTime() - value.time.getTime()) < 180 * 1000 28 | ) 29 | ); 30 | 31 | return this.cachedData[key]?.data; 32 | } 33 | 34 | setCachedData(str: string, data: any) { 35 | const hash = this.getHash(str); 36 | let cacheTime = this.cachedData[hash]?.time ?? new Date(); 37 | this.cachedData[hash] = { time: cacheTime, data: data }; 38 | } 39 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "extensions-main", 3 | "repositoryName": "ACK72's Extensions", 4 | "version": "1.3.6", 5 | "description": "ACK72's Paperback extension repository", 6 | "main": "index.js", 7 | "scripts": { 8 | "start": "tsc && node dist/api.js", 9 | "build": "tsc", 10 | "test": "node_modules/.bin/mocha --timeout 300000 -r ts-node/register src/**/*.test.ts", 11 | "coverage": "nyc -r lcov -e .ts -x \"*.test.ts\" npm run test", 12 | "bundle": "paperback bundle", 13 | "serve": "paperback serve" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/ACK72/kavya-paperback.git" 18 | }, 19 | "author": "ACK72", 20 | "license": "BSD-2-Clause", 21 | "devDependencies": { 22 | "@typescript-eslint/eslint-plugin": "^5.62.0", 23 | "@typescript-eslint/parser": "^5.62.0", 24 | "eslint": "^7.32.0", 25 | "eslint-plugin-modules-newline": "^0.0.6", 26 | "mocha": "^10.4.0", 27 | "ts-node": "^10.9.2", 28 | "typescript": "^4.9.5" 29 | }, 30 | "dependencies": { 31 | "@paperback/toolchain": "^0.8.0-alpha.47", 32 | "@paperback/types": "^0.8.0-alpha.47" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'env': { 3 | 'es2021': true, 4 | 'node': true 5 | }, 6 | 'extends': [ 7 | 'eslint:recommended', 8 | 'plugin:@typescript-eslint/recommended' 9 | ], 10 | 'parser': '@typescript-eslint/parser', 11 | 'parserOptions': { 12 | 'ecmaVersion': 12, 13 | 'sourceType': 'module' 14 | }, 15 | 'plugins': [ 16 | 'modules-newline', 17 | '@typescript-eslint' 18 | ], 19 | 'rules': { 20 | "unicorn/filename-case": ["ignore", { "case": "kebabCase" }], 21 | '@typescript-eslint/indent': [ 22 | 'error', 23 | 4 24 | ], 25 | 'linebreak-style': [ 26 | 'error', 27 | 'unix' 28 | ], 29 | 'quotes': [ 30 | 'error', 31 | 'single' 32 | ], 33 | 'semi': [ 34 | 'error', 35 | 'never' 36 | ], 37 | 'prefer-arrow-callback': 'error', 38 | 'modules-newline/import-declaration-newline': 'error', 39 | 'modules-newline/export-declaration-newline': 'error', 40 | // These checks were disabled since there are `metadata: any` declarations in the codebase 41 | // due to how metadata is handled in the Paperback app. 42 | '@typescript-eslint/explicit-module-boundary-types': 'off', 43 | '@typescript-eslint/no-explicit-any': 'off' 44 | } 45 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 2-Clause License 2 | 3 | Copyright (c) 2023, ACK72 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 16 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 17 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 18 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 19 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 20 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 21 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 22 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 23 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 24 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | temp_build 2 | bundles 3 | lib 4 | tmp 5 | 6 | #compiled sources 7 | package-lock.json 8 | reference 9 | coverage 10 | docs 11 | 12 | .idea 13 | 14 | # mac stuff 15 | .DS_Store 16 | 17 | # Logs 18 | logs 19 | *.log 20 | npm-debug.log* 21 | yarn-debug.log* 22 | yarn-error.log* 23 | lerna-debug.log* 24 | .pnpm-debug.log* 25 | 26 | # Diagnostic reports (https://nodejs.org/api/report.html) 27 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 28 | 29 | # Runtime data 30 | pids 31 | *.pid 32 | *.seed 33 | *.pid.lock 34 | 35 | # Directory for instrumented libs generated by jscoverage/JSCover 36 | lib-cov 37 | 38 | # Coverage directory used by tools like istanbul 39 | coverage 40 | *.lcov 41 | 42 | # nyc test coverage 43 | .nyc_output 44 | 45 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 46 | .grunt 47 | 48 | # Bower dependency directory (https://bower.io/) 49 | bower_components 50 | 51 | # node-waf configuration 52 | .lock-wscript 53 | 54 | # Compiled binary addons (https://nodejs.org/api/addons.html) 55 | build/Release 56 | 57 | # Dependency directories 58 | node_modules/ 59 | jspm_packages/ 60 | 61 | # Snowpack dependency directory (https://snowpack.dev/) 62 | web_modules/ 63 | 64 | # TypeScript cache 65 | *.tsbuildinfo 66 | 67 | # Optional npm cache directory 68 | .npm 69 | 70 | # Optional eslint cache 71 | .eslintcache 72 | 73 | # Microbundle cache 74 | .rpt2_cache/ 75 | .rts2_cache_cjs/ 76 | .rts2_cache_es/ 77 | .rts2_cache_umd/ 78 | 79 | # Optional REPL history 80 | .node_repl_history 81 | 82 | # Output of 'npm pack' 83 | *.tgz 84 | 85 | # Yarn Integrity file 86 | .yarn-integrity 87 | 88 | # dotenv environment variables file 89 | .env 90 | .env.test 91 | .env.production 92 | 93 | # parcel-bundler cache (https://parceljs.org/) 94 | .cache 95 | .parcel-cache 96 | 97 | # Next.js build output 98 | .next 99 | out 100 | 101 | # Nuxt.js build / generate output 102 | .nuxt 103 | dist 104 | 105 | # Gatsby files 106 | .cache/ 107 | # Comment in the public line in if your project uses Gatsby and not Next.js 108 | # https://nextjs.org/blog/next-9-1#public-directory-support 109 | # public 110 | 111 | # vuepress build output 112 | .vuepress/dist 113 | 114 | # Serverless directories 115 | .serverless/ 116 | 117 | # FuseBox cache 118 | .fusebox/ 119 | 120 | # DynamoDB Local files 121 | .dynamodb/ 122 | 123 | # TernJS port file 124 | .tern-port 125 | 126 | # Stores VSCode versions used for testing VSCode extensions 127 | .vscode-test 128 | 129 | # yarn v2 130 | .yarn/cache 131 | .yarn/unplugged 132 | .yarn/build-state.yml 133 | .yarn/install-state.gz 134 | .pnp.* 135 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | /* Basic Options */ 5 | "target": "ES2020", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', 'ES2021', or 'ESNEXT'. */ 6 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ 7 | // "lib": [], /* Specify library files to be included in the compilation. */ 8 | // "allowJs": true, /* Allow javascript files to be compiled. */ 9 | // "checkJs": true, /* Report errors in .js files. */ 10 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', 'react', 'react-jsx' or 'react-jsxdev'. */ 11 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 12 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 13 | "sourceMap": true, /* Generates corresponding '.map' file. */ 14 | // "outFile": "./", /* Concatenate and emit output to single file. */ 15 | "outDir": "./lib", /* Redirect output structure to the directory. */ 16 | "rootDir": "./src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 17 | // "composite": true, /* Enable project compilation */ 18 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 19 | // "removeComments": true, /* Do not emit comments to output. */ 20 | // "noEmit": true, /* Do not emit outputs. */ 21 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 22 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 23 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 24 | /* Strict Type-Checking Options */ 25 | "strict": true, /* Enable all strict type-checking options. */ 26 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 27 | "strictNullChecks": true, /* Enable strict null checks. */ 28 | "strictFunctionTypes": true, /* Enable strict checking of function types. */ 29 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 30 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 31 | "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 32 | "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 33 | /* Additional Checks */ 34 | "noUnusedLocals": true, /* Report errors on unused locals. */ 35 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 36 | "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 37 | "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 38 | "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ 39 | "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an 'override' modifier. */ 40 | // "noPropertyAccessFromIndexSignature": true, /* Require undeclared properties from index signatures to use element accesses. */ 41 | /* Module Resolution Options */ 42 | "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 43 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 44 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 45 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 46 | // "typeRoots": [], /* List of folders to include type definitions from. */ 47 | // "types": [], /* Type declaration files to be included in compilation. */ 48 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 49 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 50 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 51 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 52 | /* Source Map Options */ 53 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 54 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 55 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 56 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 57 | /* Experimental Options */ 58 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 59 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 60 | /* Advanced Options */ 61 | "skipLibCheck": true, /* Skip type checking of declaration files. */ 62 | "forceConsistentCasingInFileNames": true, /* Disallow inconsistently-cased references to the same file. */ 63 | "resolveJsonModule": true 64 | } 65 | } -------------------------------------------------------------------------------- /src/Kavya/Search.ts: -------------------------------------------------------------------------------- 1 | import { 2 | PartialSourceManga, 3 | Request, 4 | RequestManager, 5 | SearchRequest, 6 | SourceStateManager 7 | } from '@paperback/types'; 8 | import { CacheManager } from './CacheManager'; 9 | import { 10 | KavitaRequestInterceptor, 11 | getKavitaAPI, 12 | getOptions, 13 | getServerUnavailableMangaTiles, 14 | searchRequestToString 15 | } from './Common'; 16 | 17 | const KAVITA_PERSON_ROLES: any = { 18 | '1': 'other', 19 | '2': 'artist', 20 | '3': 'writers', // KavitaAPI /api/series/all uses 'writers' instead of 'writer' 21 | '4': 'penciller', 22 | '5': 'inker', 23 | '6': 'colorist', 24 | '7': 'letterer', 25 | '8': 'coverArtist', 26 | '9': 'editor', 27 | '10': 'publisher', 28 | '11': 'character', 29 | '12': 'translators' // KavitaAPI /api/series/all uses 'translators' instead of 'translator' 30 | } 31 | 32 | export async function searchRequest( 33 | searchQuery: SearchRequest, 34 | metadata: any, 35 | requestManager: RequestManager, 36 | interceptor: KavitaRequestInterceptor, 37 | stateManager: SourceStateManager, 38 | cacheManager: CacheManager 39 | ) { 40 | // This function is also called when the user search in an other source. It should not throw if the server is unavailable. 41 | if (!(await interceptor.isServerAvailable())) { 42 | return App.createPagedResults({ 43 | results: getServerUnavailableMangaTiles(), 44 | }); 45 | } 46 | 47 | const kavitaAPI = await getKavitaAPI(stateManager); 48 | const { enableRecursiveSearch, excludeUnsupportedLibrary, pageSize } = await getOptions(stateManager); 49 | const page: number = metadata?.page ?? 0; 50 | 51 | const excludeLibraryIds: number[] = []; 52 | 53 | if (excludeUnsupportedLibrary) { 54 | const request = App.createRequest({ 55 | url: `${kavitaAPI.url}/Library/libraries`, 56 | method: 'GET' 57 | }); 58 | 59 | const response = await requestManager.schedule(request, 1); 60 | const result = JSON.parse(response.data ?? '[]'); 61 | 62 | for (const library of result) { 63 | if (library.type === 2 || library.type === 4) { 64 | excludeLibraryIds.push(library.id); 65 | } 66 | } 67 | } 68 | 69 | const titleSearchIds: string[] = []; 70 | 71 | const tagSearchTiles: PartialSourceManga[] = []; 72 | const titleSearchTiles: PartialSourceManga[] = []; 73 | 74 | let result: any = cacheManager.getCachedData(searchRequestToString(searchQuery)); 75 | if (result === undefined) { 76 | if (typeof searchQuery.title === 'string' && searchQuery.title !== '') { 77 | const titleRequest = App.createRequest({ 78 | url: `${kavitaAPI.url}/Search/search`, 79 | param: `?queryString=${encodeURIComponent(searchQuery.title)}`, 80 | method: 'GET' 81 | }); 82 | 83 | // We don't want to throw if the server is unavailable 84 | const titleResponse = await requestManager.schedule(titleRequest, 1); 85 | const titleResult = JSON.parse(titleResponse.data ?? '[]'); 86 | 87 | for (const manga of titleResult.series) { 88 | if (excludeLibraryIds.includes(manga.libraryId)) { 89 | continue; 90 | } 91 | 92 | titleSearchIds.push(manga.seriesId); 93 | titleSearchTiles.push( 94 | App.createPartialSourceManga({ 95 | title: manga.name, 96 | image: `${kavitaAPI.url}/image/series-cover?seriesId=${manga.seriesId}&apiKey=${kavitaAPI.key}`, 97 | mangaId: `${manga.seriesId}`, 98 | subtitle: undefined 99 | }) 100 | ); 101 | } 102 | 103 | if (enableRecursiveSearch) { 104 | const tagNames: string[] = ['persons', 'genres', 'tags']; 105 | 106 | for (const tagName of tagNames) { 107 | for (const item of titleResult[tagName]) { 108 | let titleTagRequest: Request; 109 | 110 | switch (tagName) { 111 | case 'persons': 112 | titleTagRequest = App.createRequest({ 113 | url: `${kavitaAPI.url}/Series/all`, 114 | data: JSON.stringify({[KAVITA_PERSON_ROLES[item.role]]: [item.id]}), 115 | method: 'POST' 116 | }); 117 | break; 118 | default: 119 | titleTagRequest = App.createRequest({ 120 | url: `${kavitaAPI.url}/Series/all`, 121 | data: JSON.stringify({[tagName]: [item.id]}), 122 | method: 'POST' 123 | }); 124 | } 125 | 126 | const titleTagResponse = await requestManager.schedule(titleTagRequest, 1); 127 | const titleTagResult = JSON.parse(titleTagResponse.data ?? '[]'); 128 | 129 | for (const manga of titleTagResult) { 130 | if (!titleSearchIds.includes(manga.id)) { 131 | titleSearchIds.push(manga.id); 132 | titleSearchTiles.push( 133 | App.createPartialSourceManga({ 134 | title: manga.name, 135 | image: `${kavitaAPI.url}/image/series-cover?seriesId=${manga.id}&apiKey=${kavitaAPI.key}`, 136 | mangaId: `${manga.id}`, 137 | subtitle: undefined 138 | }) 139 | ); 140 | } 141 | } 142 | } 143 | } 144 | } 145 | } 146 | 147 | if (typeof searchQuery.includedTags !== 'undefined') { 148 | const body: any = {}; 149 | const peopleTags: string[] = []; 150 | 151 | searchQuery.includedTags.forEach(async (tag) => { 152 | switch (tag.id.split('-')[0]) { 153 | case 'people': 154 | peopleTags.push(tag.label); 155 | break; 156 | default: 157 | body[tag.id.split('-')[0] ?? ''] = body[tag.id.split('-')[0] ?? ''] ?? [] 158 | body[tag.id.split('-')[0] ?? ''].push(parseInt(tag.id.split('-')[1] ?? '0')); 159 | } 160 | }); 161 | 162 | const peopleRequest = App.createRequest({ 163 | url: `${kavitaAPI.url}/Metadata/people`, 164 | method: 'GET' 165 | }); 166 | 167 | const peopleResponse = await requestManager.schedule(peopleRequest, 1); 168 | const peopleResult = JSON.parse(peopleResponse.data ?? '[]'); 169 | 170 | for (const people of peopleResult) { 171 | if (peopleTags.includes(people.name)) { 172 | body[KAVITA_PERSON_ROLES[people.role]] = body[KAVITA_PERSON_ROLES[people.role]] ?? []; 173 | body[KAVITA_PERSON_ROLES[people.role]].push(people.id); 174 | } 175 | } 176 | 177 | const tagRequst = App.createRequest({ 178 | url: `${kavitaAPI.url}/Series/all`, 179 | data: JSON.stringify(body), 180 | method: 'POST' 181 | }); 182 | 183 | const tagResponse = await requestManager.schedule(tagRequst, 1); 184 | const tagResult = JSON.parse(tagResponse.data ?? '[]'); 185 | 186 | for (const manga of tagResult) { 187 | tagSearchTiles.push( 188 | App.createPartialSourceManga({ 189 | title: manga.name, 190 | image: `${kavitaAPI.url}/image/series-cover?seriesId=${manga.id}&apiKey=${kavitaAPI.key}`, 191 | mangaId: `${manga.id}`, 192 | subtitle: undefined 193 | }) 194 | ); 195 | } 196 | } 197 | 198 | result = (tagSearchTiles.length > 0 && titleSearchTiles.length > 0) ? tagSearchTiles.filter((value) => titleSearchTiles.some((target) => target.image === value.image)) : titleSearchTiles.concat(tagSearchTiles) 199 | cacheManager.setCachedData(searchRequestToString(searchQuery), result) 200 | } 201 | 202 | result = result.slice(page * pageSize, (page + 1) * pageSize); 203 | metadata = result.length === 0 ? undefined : { page: page + 1 }; 204 | 205 | return App.createPagedResults({ 206 | results: result, 207 | metadata: metadata 208 | }); 209 | } -------------------------------------------------------------------------------- /src/Kavya/Common.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Request, 3 | RequestManager, 4 | Response, 5 | SearchRequest, 6 | SourceInterceptor, 7 | SourceStateManager, 8 | Tag, 9 | TagSection 10 | } from '@paperback/types'; 11 | 12 | const KAVITA_PUBLICATION_STATUS: any = { 13 | 0: 'Ongoing', 14 | 1: 'Hiatus', 15 | 2: 'Completed', 16 | 3: 'Cancelled', 17 | 4: 'Ended', 18 | } 19 | 20 | // 21 | // Kavya Common Class & Methods 22 | // 23 | export class KavitaRequestInterceptor implements SourceInterceptor { 24 | stateManager: SourceStateManager; 25 | authorization: string; 26 | 27 | constructor(stateManager: SourceStateManager) { 28 | this.stateManager = stateManager; 29 | this.authorization = ''; 30 | } 31 | 32 | async isServerAvailable(): Promise { 33 | await this.getAuthorizationString(); 34 | return this.authorization.startsWith('Bearer '); 35 | } 36 | 37 | async getAuthorizationString(): Promise { 38 | if (this.authorization === '') { 39 | this.authorization = await getAuthorization(this.stateManager); 40 | } 41 | 42 | return this.authorization; 43 | } 44 | 45 | clearAuthorizationString(): void { 46 | this.authorization = ''; 47 | } 48 | 49 | async interceptResponse(response: Response): Promise { 50 | return response; 51 | } 52 | 53 | async interceptRequest(request: Request): Promise { 54 | 55 | request.headers = { 56 | ...request.headers, 57 | ...(typeof request.data === 'string' ? { 'Content-Type': 'application/json' } : {}), 58 | 59 | 'Authorization': await this.getAuthorizationString() 60 | } 61 | 62 | if (request.url.startsWith('FAKE*')) { 63 | request.url = request.url.split('*REAL*').pop() ?? ''; 64 | } 65 | 66 | return request; 67 | } 68 | } 69 | 70 | export function getServerUnavailableMangaTiles() { 71 | // This tile is used as a placeholder when the server is unavailable 72 | return [ 73 | App.createPartialSourceManga({ 74 | title: 'Server', 75 | image: '', 76 | mangaId: 'placeholder-id', 77 | subtitle: 'unavailable', 78 | }), 79 | ]; 80 | } 81 | 82 | export async function getSeriesDetails(mangaId: string, requestManager: RequestManager, stateManager: SourceStateManager) { 83 | const kavitaAPI = await getKavitaAPI(stateManager); 84 | 85 | const seriesRequest = App.createRequest({ 86 | url: `${kavitaAPI.url}/Series/${mangaId}`, 87 | method: 'GET', 88 | }); 89 | const metadataRequest = App.createRequest({ 90 | url: `${kavitaAPI.url}/Series/metadata`, 91 | param: `?seriesId=${mangaId}`, 92 | method: 'GET', 93 | }); 94 | 95 | const promises: Promise[] = []; 96 | 97 | promises.push(requestManager.schedule(seriesRequest, 1)); 98 | promises.push(requestManager.schedule(metadataRequest, 1)); 99 | 100 | const responses: Response[] = await Promise.all(promises); 101 | 102 | const seriesResult = typeof responses[0]?.data === 'string' ? JSON.parse(responses[0]?.data) : responses[0]?.data; 103 | const metadataResult = typeof responses[1]?.data === 'string' ? JSON.parse(responses[1]?.data) : responses[1]?.data; 104 | 105 | // exclude people tags for now 106 | const tagNames = ['genres', 'tags'] 107 | const tagSections: TagSection[] = []; 108 | 109 | for (const tagName of tagNames) { 110 | const tags: Tag[] = []; 111 | 112 | for (const tag of metadataResult[tagName]) { 113 | tags.push(App.createTag({ 114 | id: `${tagName}-${tag.id}`, 115 | label: tag.title 116 | })); 117 | } 118 | 119 | tagSections.push(App.createTagSection({ 120 | id: tagName, 121 | label: tagName, 122 | tags: tags 123 | })); 124 | } 125 | 126 | let artists = []; 127 | for (const penciller of metadataResult.pencillers) { 128 | artists.push(penciller.name); 129 | } 130 | 131 | let authors = []; 132 | for (const writer of metadataResult.writers) { 133 | authors.push(writer.name); 134 | } 135 | 136 | return { 137 | image: `${kavitaAPI.url}/image/series-cover?seriesId=${mangaId}&apiKey=${kavitaAPI.key}`, 138 | artist: artists.join(', '), 139 | author: authors.join(', '), 140 | desc: metadataResult.summary.replace(/<[^>]+>/g, ''), 141 | status: KAVITA_PUBLICATION_STATUS[metadataResult.publicationStatus] ?? 'Unknown', 142 | hentai: false, 143 | titles: [seriesResult.name], 144 | rating: seriesResult.userRating, 145 | tags: tagSections, 146 | //additionalInfo: Record 147 | }; 148 | } 149 | 150 | export function reqeustToString(request: Request): string { 151 | return JSON.stringify({ 152 | url: request.url, 153 | data: request.data, 154 | method: request.method 155 | }); 156 | } 157 | 158 | export function searchRequestToString(searchQuery: SearchRequest): string { 159 | return JSON.stringify({ 160 | title: searchQuery.title, 161 | tags: searchQuery.includedTags?.map(tag => tag.id) 162 | }); 163 | } 164 | 165 | 166 | // 167 | // Kavya Setting State Methods 168 | // 169 | export const DEFAULT_VALUES: any = { 170 | kavitaAddress: 'https://demo.kavitareader.com', 171 | kavitaAPIUrl: 'https://demo.kavitareader.com/api', 172 | kavitaAPIKey: '', 173 | pageSize: 40, 174 | 175 | showOnDeck: true, 176 | showRecentlyUpdated: true, 177 | showNewlyAdded: true, 178 | excludeUnsupportedLibrary: false, 179 | 180 | enableRecursiveSearch: false 181 | } 182 | 183 | export async function getKavitaAPI(stateManager: SourceStateManager): Promise<{url: string, key: string}> { 184 | const kavitaAPIUrl = (await stateManager.retrieve('kavitaAPIUrl') as string | undefined) ?? DEFAULT_VALUES.kavitaAPIUrl; 185 | const kavitaAPIKey = (await stateManager.keychain.retrieve('kavitaAPIKey') as string | undefined) ?? DEFAULT_VALUES.kavitaAPIKey; 186 | 187 | return {url: kavitaAPIUrl, key: kavitaAPIKey}; 188 | } 189 | 190 | export async function getAuthorization(stateManager: SourceStateManager): Promise { 191 | const kavitaAPI = await getKavitaAPI(stateManager); 192 | const manager = App.createRequestManager({ 193 | requestsPerSecond: 8, 194 | requestTimeout: 20000 195 | }); 196 | const request = App.createRequest({ 197 | url: `${kavitaAPI.url}/Plugin/authenticate`, 198 | param: `?apiKey=${kavitaAPI.key}&pluginName=Kavya`, 199 | method: 'POST' 200 | }); 201 | const response = await manager.schedule(request, 1); 202 | const token = typeof response.data === 'string' ? JSON.parse(response.data).token : undefined; 203 | 204 | return token ? `Bearer ${token}` : ''; 205 | } 206 | 207 | export async function getOptions( 208 | stateManager: SourceStateManager 209 | ): Promise<{ 210 | pageSize: number; 211 | showOnDeck: boolean; 212 | showRecentlyUpdated: boolean; 213 | showNewlyAdded: boolean; 214 | excludeUnsupportedLibrary: boolean; 215 | enableRecursiveSearch: boolean; 216 | }> { 217 | const pageSize = (await stateManager.retrieve('pageSize') as number) ?? DEFAULT_VALUES.pageSize; 218 | const showOnDeck = (await stateManager.retrieve('showOnDeck') as boolean) ?? DEFAULT_VALUES.showOnDeck; 219 | const showRecentlyUpdated = (await stateManager.retrieve('showRecentlyUpdated') as boolean) ?? DEFAULT_VALUES.showRecentlyUpdated; 220 | const showNewlyAdded = (await stateManager.retrieve('showNewlyAdded') as boolean) ?? DEFAULT_VALUES.showNewlyAdded; 221 | const excludeUnsupportedLibrary = (await stateManager.retrieve('excludeUnsupportedLibrary') as boolean) ?? DEFAULT_VALUES.excludeUnsupportedLibrary; 222 | 223 | const enableRecursiveSearch = (await stateManager.retrieve('enableRecursiveSearch') as boolean) ?? DEFAULT_VALUES.enableRecursiveSearch; 224 | 225 | return { pageSize, showOnDeck, showRecentlyUpdated, showNewlyAdded, excludeUnsupportedLibrary, enableRecursiveSearch }; 226 | } -------------------------------------------------------------------------------- /src/Kavya/Settings.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DUINavigationButton, 3 | SourceStateManager, 4 | } from '@paperback/types'; 5 | import { 6 | DEFAULT_VALUES, 7 | KavitaRequestInterceptor 8 | } from './Common'; 9 | 10 | /* UI definition */ 11 | // NOTE: Submitted data won't be tested 12 | export const serverSettingsMenu = ( 13 | stateManager: SourceStateManager, 14 | interceptor: KavitaRequestInterceptor 15 | ): DUINavigationButton => { 16 | return App.createDUINavigationButton({ 17 | id: "server_settings", 18 | label: "Server Settings", 19 | form: App.createDUIForm({ 20 | sections: async () => [ 21 | App.createDUISection({ 22 | id: "information", 23 | header: undefined, 24 | isHidden: false, 25 | rows: async () => [ 26 | App.createDUIMultilineLabel({ 27 | label: "Demo Server", 28 | value: "Server URL: https://demo.kavitareader.com\nUsername: demouser\nPassword: Demouser64\n\nNote: Values are case-sensitive.", 29 | id: "description", 30 | }), 31 | ], 32 | }), 33 | App.createDUISection({ 34 | id: "serverSettings", 35 | header: "Server Settings", 36 | isHidden: false, 37 | rows: async () => retrieveStateData(stateManager).then((values) => [ 38 | App.createDUIInputField({ 39 | id: "kavitaAddress", 40 | label: "Server URL", 41 | value: App.createDUIBinding({ 42 | async get() { 43 | return values.kavitaAddress; 44 | }, 45 | async set(value) { 46 | values.kavitaAddress = value; 47 | await setStateData(stateManager, interceptor, values); 48 | } 49 | }) 50 | }), 51 | App.createDUISecureInputField({ 52 | id: 'kavitaAPIKey', 53 | label: 'API Key', 54 | value: App.createDUIBinding({ 55 | async get() { 56 | return values.kavitaAPIKey 57 | }, 58 | async set(newValue) { 59 | values.kavitaAPIKey = newValue 60 | await setStateData(stateManager, interceptor, values) 61 | } 62 | }) 63 | }), 64 | App.createDUIInputField({ 65 | id: 'pageSize', 66 | label: 'Page Size', 67 | value: App.createDUIBinding({ 68 | async get() { 69 | return typeof values.pageSize === 'string' ? values.pageSize : values.pageSize.toString(); 70 | }, 71 | async set(value) { 72 | values.pageSize = value; 73 | await setStateData(stateManager, interceptor, values); 74 | } 75 | }) 76 | }) 77 | ]), 78 | }), 79 | App.createDUISection({ 80 | id: "sourceOptions", 81 | header: "Source Options", 82 | isHidden: false, 83 | footer: "", 84 | rows: async () => retrieveStateData(stateManager).then((values) => [ 85 | App.createDUISwitch({ 86 | id: 'showOnDeck', 87 | label: 'Show On Deck',value: App.createDUIBinding({ 88 | async get() { 89 | return values.showOnDeck; 90 | }, 91 | async set(value) { 92 | values.showOnDeck = value; 93 | await setStateData(stateManager, interceptor, values); 94 | } 95 | }) 96 | }), 97 | App.createDUISwitch({ 98 | id: 'showRecentlyUpdated', 99 | label: 'Show Recently Updated', 100 | value: App.createDUIBinding({ 101 | async get() { 102 | return values.showRecentlyUpdated; 103 | }, 104 | async set(value) { 105 | values.showRecentlyUpdated = value; 106 | await setStateData(stateManager, interceptor, values); 107 | } 108 | }) 109 | }), 110 | App.createDUISwitch({ 111 | id: 'showNewlyAdded', 112 | label: 'Show Newly Added', 113 | value: App.createDUIBinding({ 114 | async get() { 115 | return values.showNewlyAdded; 116 | }, 117 | async set(value) { 118 | values.showNewlyAdded = value; 119 | await setStateData(stateManager, interceptor, values); 120 | } 121 | }) 122 | }), 123 | App.createDUISwitch({ 124 | id: 'excludeUnsupportedLibrary', 125 | label: 'Exclude Book & Novel Type Libraries', 126 | value: App.createDUIBinding({ 127 | async get() { 128 | return values.excludeUnsupportedLibrary; 129 | }, 130 | async set(value) { 131 | values.excludeUnsupportedLibrary = value; 132 | await setStateData(stateManager, interceptor, values); 133 | } 134 | }) 135 | }) 136 | ]), 137 | }), 138 | App.createDUISection({ 139 | id: "experimentalOptions", 140 | header: "Experimental Options", 141 | isHidden: false, 142 | footer: "", 143 | rows: async () => retrieveStateData(stateManager).then((values) => [ 144 | App.createDUISwitch({ 145 | id: 'enableRecursiveSearch', 146 | label: 'Enable Recursive Search', 147 | value: App.createDUIBinding({ 148 | async get() { 149 | return values.enableRecursiveSearch; 150 | }, 151 | async set(value) { 152 | values.enableRecursiveSearch = value; 153 | await setStateData(stateManager, interceptor, values); 154 | } 155 | }) 156 | }) 157 | ]), 158 | }), 159 | ], 160 | }), 161 | }); 162 | }; 163 | 164 | export async function retrieveStateData(stateManager: SourceStateManager) { 165 | const kavitaAddress = (await stateManager.retrieve('kavitaAddress') as string) ?? DEFAULT_VALUES.kavitaAddress; 166 | const kavitaAPIKey = (await stateManager.keychain.retrieve('kavitaAPIKey') as string) ?? DEFAULT_VALUES.kavitaAPIKey; 167 | const pageSize = (await stateManager.retrieve('pageSize') as number) ?? DEFAULT_VALUES.pageSize; 168 | 169 | const showOnDeck = (await stateManager.retrieve('showOnDeck') as boolean) ?? DEFAULT_VALUES.showOnDeck; 170 | const showRecentlyUpdated = (await stateManager.retrieve('showRecentlyUpdated') as boolean) ?? DEFAULT_VALUES.showRecentlyUpdated; 171 | const showNewlyAdded = (await stateManager.retrieve('showNewlyAdded') as boolean) ?? DEFAULT_VALUES.showNewlyAdded; 172 | const excludeUnsupportedLibrary = (await stateManager.retrieve('excludeUnsupportedLibrary') as boolean) ?? DEFAULT_VALUES.excludeUnsupportedLibrary; 173 | 174 | const enableRecursiveSearch = (await stateManager.retrieve('enableRecursiveSearch') as boolean) ?? DEFAULT_VALUES.enableRecursiveSearch; 175 | 176 | return { kavitaAddress, kavitaAPIKey, pageSize, showOnDeck, showRecentlyUpdated, showNewlyAdded, excludeUnsupportedLibrary, enableRecursiveSearch } 177 | } 178 | 179 | export async function setStateData(stateManager: SourceStateManager, interceptor: KavitaRequestInterceptor, data: Record) { 180 | const promises: Promise[] = []; 181 | const prevStateData: any = await retrieveStateData(stateManager); 182 | 183 | let clear = false; 184 | for (const [key, value] of Object.entries(data)) { 185 | if (prevStateData[key] !== value) { 186 | switch (key) { 187 | case 'kavitaAddress': 188 | promises.push(stateManager.store(key, value)); 189 | promises.push(stateManager.store('kavitaAPIUrl', value + (value.slice(-1) === '/' ? 'api' : '/api'))); 190 | clear = true; 191 | break; 192 | case 'kavitaAPIKey': 193 | promises.push(stateManager.keychain.store(key, value)); 194 | clear = true; 195 | break; 196 | case 'pageSize': 197 | const pageSize = typeof value === 'string' ? parseInt(value) === 0 ? DEFAULT_VALUES.pageSize : parseInt(value) : DEFAULT_VALUES.pageSize; 198 | promises.push(stateManager.store(key, pageSize)); 199 | break; 200 | default: 201 | promises.push(stateManager.store(key, value)); 202 | } 203 | } 204 | } 205 | 206 | await Promise.all(promises); 207 | if (clear) interceptor.clearAuthorizationString(); 208 | } -------------------------------------------------------------------------------- /src/Kavya/Kavya.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BadgeColor, 3 | Chapter, 4 | ChapterDetails, 5 | ChapterProviding, 6 | ContentRating, 7 | DUIForm, 8 | DUISection, 9 | HomePageSectionsProviding, 10 | HomeSection, 11 | MangaProgress, 12 | MangaProgressProviding, 13 | MangaProviding, 14 | PagedResults, 15 | PartialSourceManga, 16 | RequestManagerProviding, 17 | SearchRequest, 18 | SearchResultsProviding, 19 | SourceInfo, 20 | SourceIntents, 21 | SourceManga, 22 | Tag, 23 | TagSection, 24 | TrackerActionQueue 25 | } from '@paperback/types'; 26 | import { 27 | serverSettingsMenu 28 | } from './Settings'; 29 | import { 30 | KavitaRequestInterceptor, 31 | getKavitaAPI, 32 | getOptions, 33 | getSeriesDetails, 34 | getServerUnavailableMangaTiles, 35 | reqeustToString 36 | } from './Common'; 37 | import { searchRequest } from './Search'; 38 | import { CacheManager } from './CacheManager'; 39 | 40 | const sortHelper = (a: any, b: any) => { 41 | if (a.volume === b.volume) return a.chapNum === b.chapNum ? a._index - b._index : a.chapNum - b.chapNum; 42 | return a.volume === 0 || b.volume === 0 ? b.volume - a.volume : a.volume - b.volume; 43 | } 44 | 45 | export const KavyaInfo: SourceInfo = { 46 | version: '1.3.6', 47 | name: 'Kavya', 48 | icon: 'icon.png', 49 | author: 'ACK72', 50 | authorWebsite: 'https://github.com/ACK72', 51 | description: 'Kavita client extension for Paperback', 52 | contentRating: ContentRating.EVERYONE, 53 | websiteBaseURL: 'https://www.kavitareader.com/', 54 | sourceTags: [ 55 | { 56 | text: 'Kavita', 57 | type: BadgeColor.GREEN, 58 | }, 59 | ], 60 | intents: SourceIntents.COLLECTION_MANAGEMENT | SourceIntents.HOMEPAGE_SECTIONS | SourceIntents.MANGA_CHAPTERS | SourceIntents.MANGA_TRACKING | SourceIntents.SETTINGS_UI 61 | }; 62 | 63 | export class Kavya implements ChapterProviding, HomePageSectionsProviding, MangaProgressProviding, MangaProviding, RequestManagerProviding, SearchResultsProviding { 64 | stateManager = App.createSourceStateManager(); 65 | 66 | cacheManager = new CacheManager(); 67 | interceptor = new KavitaRequestInterceptor(this.stateManager); 68 | 69 | requestManager = App.createRequestManager({ 70 | requestsPerSecond: 8, 71 | requestTimeout: 20000, 72 | interceptor: this.interceptor 73 | }); 74 | 75 | async getSourceMenu(): Promise { 76 | return App.createDUISection({ 77 | id: 'main', 78 | header: 'Source Settings', 79 | isHidden: false, 80 | rows: async () => [ 81 | serverSettingsMenu(this.stateManager, this.interceptor) 82 | ], 83 | }); 84 | } 85 | 86 | async getMangaDetails(mangaId: string): Promise { 87 | return App.createSourceManga({ 88 | id: mangaId, 89 | mangaInfo: App.createMangaInfo({ 90 | ...(await getSeriesDetails(mangaId, this.requestManager, this.stateManager)) 91 | }) 92 | }); 93 | } 94 | 95 | async getChapters(mangaId: string): Promise { 96 | const kavitaAPI = await getKavitaAPI(this.stateManager); 97 | 98 | const request = App.createRequest({ 99 | url: `${kavitaAPI.url}/Series/volumes`, 100 | param: `?seriesId=${mangaId}`, 101 | method: 'GET', 102 | }); 103 | 104 | const response = await this.requestManager.schedule(request, 1); 105 | const result = typeof response.data === 'string' ? JSON.parse(response.data) : response.data; 106 | 107 | const chapters: any[] = [], specials: any[] = []; 108 | 109 | let i = 0; 110 | let j = 1; 111 | for (const volume of result) { 112 | for (const chapter of volume.chapters) { 113 | const name = chapter.number === chapter.range ? chapter.titleName ?? '' : `${chapter.range.replace(`${chapter.number}-`, '')}${chapter.titleName ? ` - ${chapter.titleName}` : ''}`; 114 | const title: string = chapter.range.endsWith('.epub') ? chapter.range.slice(0, -5) : chapter.range.slice(0, -4); 115 | const progress: string = chapter.pagesRead === 0 ? '' : chapter.pages === chapter.pagesRead ? '· Read' : `· Reading ${chapter.pagesRead} page`; 116 | 117 | const item: any = { 118 | id: `${chapter.id}`, 119 | mangaId: mangaId, 120 | chapNum: chapter.number === '-100000' ? 1 : (chapter.isSpecial ? j++ : parseFloat(chapter.number)), // chapter.number is 0 when it's a special 121 | name: chapter.isSpecial ? title : name, 122 | time: new Date(chapter.releaseDate === '0001-01-01T00:00:00' ? chapter.created : chapter.releaseDate), 123 | volume: chapter.isSpecial ? 0 : volume.name === '-100000' ? 0 : parseFloat(volume.name) , // assign both special and chapters w/o volumes w/ volume 0 as it's hidden by paperback 124 | group: `${(chapter.isSpecial ? 'Specials · ' : '')}${chapter.pages} pages ${progress}`, 125 | _index: i++, 126 | // sortIndex is unused, as it seems to have an issue when changing the sort order 127 | }; 128 | 129 | if (chapter.isSpecial) specials.push(item); 130 | else chapters.push(item); 131 | } 132 | } 133 | 134 | chapters.sort(sortHelper); 135 | return chapters.concat(specials).map((item, index) => { 136 | return App.createChapter({ 137 | ...item, 138 | sortingIndex: index 139 | }); 140 | }); 141 | } 142 | 143 | async getChapterDetails( 144 | mangaId: string, 145 | chapterId: string 146 | ): Promise { 147 | const kavitaAPI = await getKavitaAPI(this.stateManager); 148 | 149 | const request = App.createRequest({ 150 | url: `${kavitaAPI.url}/Series/chapter`, 151 | param: `?chapterId=${chapterId}`, 152 | method: 'GET', 153 | }); 154 | 155 | const response = await this.requestManager.schedule(request, 1); 156 | const result = typeof response.data === 'string' ? JSON.parse(response.data) : response.data; 157 | 158 | const pages: string[] = []; 159 | for (let i = 0;i < result.pages;i++) { 160 | pages.push(`FAKE*/${i}?*REAL*${kavitaAPI.url}/Reader/image?chapterId=${chapterId}&page=${i}&apiKey=${kavitaAPI.key}&extractPdf=true`); 161 | } 162 | 163 | return App.createChapterDetails({ 164 | id: chapterId, 165 | mangaId: mangaId, 166 | pages: pages 167 | }); 168 | } 169 | 170 | async getSearchResults( 171 | searchQuery: SearchRequest, 172 | metadata: any 173 | ): Promise { 174 | return await searchRequest(searchQuery, metadata, this.requestManager, this.interceptor, this.stateManager, this.cacheManager); 175 | } 176 | 177 | async getSearchTags(): Promise { 178 | // This function is also called when the user search in an other source. It should not throw if the server is unavailable. 179 | if (!(await this.interceptor.isServerAvailable())) { 180 | return []; 181 | } 182 | 183 | const kavitaAPI = await getKavitaAPI(this.stateManager); 184 | const { excludeUnsupportedLibrary } = await getOptions(this.stateManager); 185 | 186 | const includeLibraryIds: string[] = []; 187 | 188 | const libraryRequest = App.createRequest({ 189 | url: `${kavitaAPI.url}/Library/libraries`, 190 | method: 'GET', 191 | }); 192 | 193 | const libraryResponse = await this.requestManager.schedule(libraryRequest, 1); 194 | const libraryResult = JSON.parse(libraryResponse.data ?? '[]'); 195 | 196 | for (const library of libraryResult) { 197 | if (excludeUnsupportedLibrary && library.type === 2) continue; 198 | includeLibraryIds.push(library.id); 199 | } 200 | 201 | const tagNames: string[] = ['genres', 'people', 'tags']; 202 | const tagSections: any = []; 203 | 204 | const promises: Promise[] = []; 205 | 206 | for (const tagName of tagNames) { 207 | const request = App.createRequest({ 208 | url: `${kavitaAPI.url}/Metadata/${tagName}`, 209 | param: `?libraryIds=${includeLibraryIds.join(',')}`, 210 | method: 'GET', 211 | }); 212 | 213 | promises.push( 214 | this.requestManager.schedule(request, 1).then((response) => { 215 | const result = JSON.parse(response.data ?? '[]'); 216 | 217 | const names: string[] = []; 218 | const tags: Tag[] = []; 219 | 220 | result.forEach(async (item: any) => { 221 | switch (tagName) { 222 | case 'people': 223 | if (!names.includes(item.name)) { 224 | names.push(item.name); 225 | tags.push(App.createTag({id: `${tagName}-${item.role}.${item.id}`, label: item.name})) 226 | } 227 | break; 228 | default: 229 | tags.push(App.createTag({id: `${tagName}-${item.id}`, label: item.title})) 230 | } 231 | }); 232 | 233 | tagSections[tagName] = App.createTagSection({ 234 | id: tagName, 235 | label: tagName, 236 | tags: tags 237 | }); 238 | }) 239 | ); 240 | } 241 | 242 | await Promise.all(promises); 243 | return tagNames.map((tag) => tagSections[tag]); 244 | } 245 | 246 | async getHomePageSections( 247 | sectionCallback: (section: HomeSection) => void 248 | ): Promise { 249 | // This function is called on the homepage and should not throw if the server is unavailable 250 | if (!(await this.interceptor.isServerAvailable())) { 251 | sectionCallback( 252 | App.createHomeSection({ 253 | id: 'placeholder-id', 254 | title: 'Library', 255 | items: getServerUnavailableMangaTiles(), 256 | containsMoreItems: false, 257 | type: 'singleRowNormal' 258 | }) 259 | ); 260 | return; 261 | } 262 | 263 | // We won't use `await this.getKavitaAPI()` as we do not want to throw an error on 264 | // the homepage when server settings are not set 265 | const kavitaAPI = await getKavitaAPI(this.stateManager); 266 | const { showOnDeck, showRecentlyUpdated, showNewlyAdded, excludeUnsupportedLibrary } = await getOptions(this.stateManager); 267 | const pageSize = (await getOptions(this.stateManager)).pageSize / 2; 268 | 269 | // The source define two homepage sections: new and latest 270 | const sections = []; 271 | 272 | if (showOnDeck) { 273 | sections.push(App.createHomeSection({ 274 | id: 'ondeck', 275 | title: 'On Deck', 276 | containsMoreItems: false, 277 | type: 'singleRowNormal' 278 | })); 279 | } 280 | 281 | if (showRecentlyUpdated) { 282 | sections.push(App.createHomeSection({ 283 | id: 'recentlyupdated', 284 | title: 'Recently Updated Series', 285 | containsMoreItems: false, 286 | type: 'singleRowNormal' 287 | })); 288 | } 289 | 290 | if (showNewlyAdded) { 291 | sections.push(App.createHomeSection({ 292 | id: 'newlyadded', 293 | title: 'Newly Added Series', 294 | containsMoreItems: false, 295 | type: 'singleRowNormal' 296 | })); 297 | } 298 | 299 | const request = App.createRequest({ 300 | url: `${kavitaAPI.url}/Library/libraries`, 301 | method: 'GET', 302 | }); 303 | 304 | const response = await this.requestManager.schedule(request, 1); 305 | const result = JSON.parse(response.data ?? '[]'); 306 | 307 | const excludeLibraryIds: number[] = []; 308 | 309 | for (const library of result) { 310 | if (excludeUnsupportedLibrary && library.type === 2) { 311 | excludeLibraryIds.push(library.id); 312 | continue; 313 | } 314 | 315 | sections.push(App.createHomeSection({ 316 | id: `${library.id}`, 317 | title: library.name, 318 | containsMoreItems: false, 319 | type: 'singleRowNormal' 320 | })); 321 | } 322 | 323 | const promises: Promise[] = []; 324 | 325 | for (const section of sections) { 326 | sectionCallback(section); 327 | } 328 | 329 | for (const section of sections) { 330 | let apiPath: string, body: any = {}, id: string = 'id', title: string = 'name'; 331 | switch (section.id) { 332 | case 'ondeck': 333 | apiPath = `${kavitaAPI.url}/Series/on-deck?PageNumber=1&PageSize=${pageSize}`; 334 | break; 335 | case 'recentlyupdated': 336 | apiPath = `${kavitaAPI.url}/Series/recently-updated-series`; 337 | id = 'seriesId', title = 'seriesName'; 338 | break; 339 | case 'newlyadded': 340 | apiPath = `${kavitaAPI.url}/Series/recently-added-v2?PageNumber=1&PageSize=${pageSize}`; 341 | break; 342 | default: 343 | apiPath = `${kavitaAPI.url}/Series/v2?PageNumber=1&PageSize=${pageSize}`; 344 | body = { 345 | statements: [{ comparison: 0, field: 19, value: section.id }], 346 | combination: 1, 347 | sortOptions: { sortField: 1, isAscending: true }, 348 | limitTo: 0 349 | }; 350 | break; 351 | } 352 | 353 | const request = App.createRequest({ 354 | url: apiPath, 355 | data: JSON.stringify(body), 356 | method: 'POST', 357 | }); 358 | 359 | // Get the section data 360 | promises.push( 361 | this.requestManager.schedule(request, 1).then((response) => { 362 | const result = JSON.parse(response.data ?? '[]'); 363 | this.cacheManager.setCachedData(reqeustToString(request), result); 364 | 365 | const tiles: PartialSourceManga[] = []; 366 | 367 | for (const series of result) { 368 | if (excludeUnsupportedLibrary && excludeLibraryIds.includes(series.libraryId)) continue; 369 | tiles.push(App.createPartialSourceManga({ 370 | title: series[title], 371 | image: `${kavitaAPI.url}/image/series-cover?seriesId=${series[id]}&apiKey=${kavitaAPI.key}`, 372 | mangaId: `${series[id]}`, 373 | subtitle: undefined 374 | })); 375 | } 376 | 377 | section.items = tiles; 378 | if (tiles.length === pageSize) section.containsMoreItems = true; 379 | sectionCallback(section); 380 | }) 381 | ); 382 | } 383 | 384 | // Make sure the function completes 385 | await Promise.all(promises); 386 | } 387 | 388 | async getViewMoreItems( 389 | homepageSectionId: string, 390 | metadata: any 391 | ): Promise { 392 | const kavitaAPI = await getKavitaAPI(this.stateManager); 393 | const { pageSize, excludeUnsupportedLibrary } = await getOptions(this.stateManager); 394 | const excludeLibraryIds: number[] = []; 395 | const page: number = (metadata?.page ?? 0) + 1; 396 | 397 | let apiPath: string, body: any = {}, id: string = 'id', title: string = 'name', checkLibraryId = false, useBuiltInCache: boolean = false; 398 | switch (homepageSectionId) { 399 | case 'ondeck': 400 | apiPath = `${kavitaAPI.url}/Series/on-deck?PageNumber=${page}&PageSize=${pageSize}`; 401 | checkLibraryId = true; 402 | break; 403 | case 'recentlyupdated': 404 | apiPath = `${kavitaAPI.url}/Series/recently-updated-series`; 405 | id = 'seriesId', title = 'seriesName'; 406 | checkLibraryId = true; 407 | useBuiltInCache = true; 408 | break; 409 | case 'newlyadded': 410 | apiPath = `${kavitaAPI.url}/Series/recently-added-v2?PageNumber=${page}&PageSize=${pageSize}`; 411 | checkLibraryId = true; 412 | break; 413 | default: 414 | apiPath = `${kavitaAPI.url}/Series/v2?PageNumber=${page}&PageSize=${pageSize}`; 415 | body = { 416 | statements: [{ comparison: 0, field: 19, value: homepageSectionId }], 417 | combination: 1, 418 | sortOptions: { sortField: 1, isAscending: true }, 419 | limitTo: 0 420 | }; 421 | break; 422 | } 423 | 424 | const request = App.createRequest({ 425 | url: apiPath, 426 | data: JSON.stringify(body), 427 | method: 'POST' 428 | }); 429 | 430 | let result: any; 431 | if (useBuiltInCache) { 432 | result = this.cacheManager.getCachedData(reqeustToString(request)); 433 | } 434 | 435 | if (result === undefined) { 436 | const response = await this.requestManager.schedule(request, 1); 437 | result = JSON.parse(response.data ?? '[]'); 438 | } 439 | 440 | if (useBuiltInCache) { 441 | this.cacheManager.setCachedData(reqeustToString(request), result); 442 | result = result.slice((page - 1) * pageSize, page * pageSize); 443 | } 444 | 445 | if (checkLibraryId) { 446 | const libraryRequest = App.createRequest({ 447 | url: `${kavitaAPI.url}/Library/libraries`, 448 | method: 'GET', 449 | }); 450 | 451 | const libraryResponse = await this.requestManager.schedule(libraryRequest, 1); 452 | const libraryResult = JSON.parse(libraryResponse.data ?? '[]'); 453 | 454 | for (const library of libraryResult) { 455 | if (library.type === 2) excludeLibraryIds.push(library.id); 456 | } 457 | } 458 | 459 | const tiles: PartialSourceManga[] = []; 460 | for (const series of result) { 461 | if (checkLibraryId && excludeUnsupportedLibrary && excludeLibraryIds.includes(series.libraryId)) continue; 462 | tiles.push(App.createPartialSourceManga({ 463 | title: series[title], 464 | image: `${kavitaAPI.url}/image/series-cover?seriesId=${series[id]}&apiKey=${kavitaAPI.key}`, 465 | mangaId: `${series[id]}`, 466 | subtitle: undefined 467 | })); 468 | } 469 | 470 | metadata = tiles.length === 0 ? undefined : { page: page }; 471 | return App.createPagedResults({ 472 | results: tiles, 473 | metadata: metadata 474 | }); 475 | } 476 | 477 | async getMangaProgress(mangaId: string): Promise { 478 | const kavitaAPI = await getKavitaAPI(this.stateManager); 479 | const request = App.createRequest({ 480 | url: `${kavitaAPI.url}/Series/volumes`, 481 | param: `?seriesId=${mangaId}`, 482 | method: 'GET', 483 | }); 484 | 485 | const response = await this.requestManager.schedule(request, 1); 486 | const result = typeof response.data === 'string' ? JSON.parse(response.data) : response.data; 487 | 488 | const chapters: any[] = []; 489 | 490 | let i = 0; 491 | for (const volume of result) { 492 | for (const chapter of volume.chapters) { 493 | const item: any = { 494 | chapNum: parseFloat(chapter.number), 495 | volume: volume.number, 496 | time: new Date(chapter.lastReadingProgressUtc), 497 | read: chapter.pagesRead === chapter.pages, 498 | _index: i++, 499 | }; 500 | 501 | if (!chapter.isSpecial) chapters.push(item); 502 | } 503 | } 504 | 505 | chapters.sort(sortHelper); 506 | 507 | let lastReadChapterNumber = 0; 508 | let lastReadVolumeNumber = 0; 509 | let lastReadTime = new Date(0); 510 | 511 | for (const chapter of chapters) { 512 | if (chapter.read) { 513 | lastReadChapterNumber = chapter.chapNum; 514 | lastReadVolumeNumber = chapter.volume; 515 | lastReadTime = chapter.time; 516 | } else { 517 | break; 518 | } 519 | } 520 | 521 | return App.createMangaProgress({ 522 | mangaId: mangaId, 523 | lastReadChapterNumber: lastReadChapterNumber, 524 | lastReadVolumeNumber: lastReadVolumeNumber, 525 | trackedListName: 'TEST', 526 | lastReadTime: lastReadTime 527 | }); 528 | } 529 | 530 | async getMangaProgressManagementForm(mangaId: string): Promise { 531 | return App.createDUIForm({ 532 | sections: async () => { 533 | const kavitaAPI = await getKavitaAPI(this.stateManager); 534 | 535 | const request = App.createRequest({ 536 | url: `${kavitaAPI.url}/Series/${mangaId}`, 537 | method: 'GET', 538 | }); 539 | const response = await this.requestManager.schedule(request, 1); 540 | const result = JSON.parse(response?.data ?? '{}'); 541 | 542 | return [ 543 | App.createDUISection({ 544 | id: 'seriesInfo', 545 | header: 'Info', 546 | isHidden: false, 547 | rows: async () => [ 548 | App.createDUILabel({ 549 | id: 'seriesId', 550 | label: 'SeriesID', 551 | value: mangaId 552 | }), 553 | App.createDUILabel({ 554 | id: 'libraryId', 555 | label: 'LibraryID', 556 | value: `${result.libraryId}` 557 | }), 558 | App.createDUILabel({ 559 | id: 'pagesRead', 560 | label: 'Pages Read', 561 | value: `${result.pagesRead} / ${result.pages}` 562 | }) 563 | ] 564 | }), 565 | App.createDUISection({ 566 | id: 'userReview', 567 | header: 'Rating & Review', 568 | isHidden: false, 569 | rows: async () => [ 570 | App.createDUIStepper({ 571 | id: 'rating', 572 | label: 'Rating', 573 | value: result.userRating ?? 0, 574 | min: 0, 575 | max: 5, 576 | step: 1 577 | }), 578 | App.createDUIInputField({ 579 | id: 'review', 580 | label: '', 581 | value: result.userReview ?? '', 582 | }) 583 | ] 584 | }) 585 | ] 586 | }, 587 | onSubmit: async (values) => { 588 | const kavitaAPI = await getKavitaAPI(this.stateManager); 589 | 590 | await this.requestManager.schedule(App.createRequest({ 591 | url: `${kavitaAPI.url}/Series/update-rating`, 592 | data: JSON.stringify({seriesId: mangaId, userRating: values.rating, userReview: values.review}), 593 | method: 'POST' 594 | }), 1); 595 | } 596 | }); 597 | } 598 | 599 | async processChapterReadActionQueue(actionQueue: TrackerActionQueue): Promise { 600 | const chapterReadActions = await actionQueue.queuedChapterReadActions(); 601 | const kavitaAPI = await getKavitaAPI(this.stateManager); 602 | 603 | for (const readAction of chapterReadActions) { 604 | if (!(await this.interceptor.isServerAvailable())) { 605 | await actionQueue.retryChapterReadAction(readAction); 606 | continue; 607 | } 608 | 609 | try { 610 | const chapterRequest = App.createRequest({ 611 | url: `${kavitaAPI.url}/Reader/chapter-info`, 612 | param: `?chapterId=${readAction.sourceChapterId}`, 613 | method: 'GET', 614 | }); 615 | 616 | const chapterResponse = await this.requestManager.schedule(chapterRequest, 1); 617 | const chapterResult = JSON.parse(chapterResponse?.data ?? '{}'); 618 | 619 | const progressRequest = App.createRequest({ 620 | url: `${kavitaAPI.url}/Reader/progress`, 621 | data: JSON.stringify({ 622 | volumeId: chapterResult.volumeId, 623 | chapterId: parseInt(readAction.sourceChapterId), 624 | pageNum: chapterResult.pages, 625 | seriesId: chapterResult.seriesId, 626 | libraryId: chapterResult.libraryId 627 | }), 628 | method: 'POST', 629 | }); 630 | 631 | const progressResponse = await this.requestManager.schedule(progressRequest, 1); 632 | 633 | if(progressResponse.status < 400) { 634 | await actionQueue.discardChapterReadAction(readAction); 635 | } else { 636 | await actionQueue.retryChapterReadAction(readAction); 637 | } 638 | } catch(error) { 639 | await actionQueue.retryChapterReadAction(readAction); 640 | } 641 | } 642 | } 643 | } --------------------------------------------------------------------------------