├── .eslintrc.js
├── .github
└── workflows
│ └── main.yml
├── .gitignore
├── .vscode
└── settings.json
├── DeveloperAccounts.md
├── LICENSE
├── README.md
├── package-lock.json
├── package.json
├── src
├── Anilist
│ ├── AlSettings.ts
│ ├── Anilist.ts
│ ├── includes
│ │ └── icon.png
│ └── models
│ │ ├── anilist-manga.ts
│ │ ├── anilist-page.ts
│ │ ├── anilist-result.ts
│ │ ├── anilist-user.ts
│ │ └── graphql-queries.ts
├── MangaUpdates
│ ├── MangaUpdates.ts
│ ├── includes
│ │ └── icon.png
│ ├── models
│ │ ├── README.md
│ │ ├── index.d.ts
│ │ └── mu-api.d.ts
│ └── utils
│ │ ├── mu-manga.ts
│ │ ├── mu-search.ts
│ │ └── mu-session.ts
└── MyAnimeList
│ ├── MALSettings.ts
│ ├── MyAnimeList.ts
│ ├── includes
│ └── icon.png
│ └── models
│ ├── mal-manga.ts
│ ├── mal-page.ts
│ ├── mal-result.ts
│ ├── mal-token.ts
│ └── mal-user.ts
└── tsconfig.json
/.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 | '@typescript-eslint/indent': [
21 | 'error',
22 | 4
23 | ],
24 | 'quotes': [
25 | 'error',
26 | 'single'
27 | ],
28 | 'semi': [
29 | 'error',
30 | 'never'
31 | ],
32 | 'prefer-arrow-callback': 'error',
33 | 'modules-newline/import-declaration-newline': 'error',
34 | 'modules-newline/export-declaration-newline': 'error'
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/.github/workflows/main.yml:
--------------------------------------------------------------------------------
1 | on: push
2 | name: Bundle and Publish Sources
3 | jobs:
4 | build:
5 | name: Bundle and Publish Sources
6 | runs-on: ubuntu-latest
7 |
8 | strategy:
9 | matrix:
10 | node-version: [14.x]
11 |
12 | steps:
13 | - name: Checkout Branch
14 | uses: actions/checkout@v2
15 |
16 | - name: Setup Node.js environment
17 | uses: actions/setup-node@v2.1.2
18 | with:
19 | node-version: ${{ matrix.node-version }}
20 |
21 | - name: Extract branch name
22 | shell: bash
23 | run: echo "##[set-output name=branch;]$(echo ${GITHUB_REF#refs/heads/})"
24 | id: extract_branch
25 |
26 | - name: Checkout existing bundles
27 | uses: actions/checkout@v2
28 | continue-on-error: true
29 | with:
30 | ref: gh-pages
31 | path: bundles
32 |
33 | - run: npm install
34 | - run: npm run bundle -- --folder=${{ steps.extract_branch.outputs.branch }}
35 |
36 | - name: Deploy to GitHub Pages
37 | uses: JamesIves/github-pages-deploy-action@4.1.0
38 | with:
39 | branch: gh-pages
40 | folder: bundles
41 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | lib/
2 |
3 | # Logs
4 | logs
5 | *.log
6 | npm-debug.log*
7 | yarn-debug.log*
8 | yarn-error.log*
9 | lerna-debug.log*
10 | .pnpm-debug.log*
11 |
12 | # Diagnostic reports (https://nodejs.org/api/report.html)
13 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
14 |
15 | # Runtime data
16 | pids
17 | *.pid
18 | *.seed
19 | *.pid.lock
20 |
21 | # Directory for instrumented libs generated by jscoverage/JSCover
22 | lib-cov
23 |
24 | # Coverage directory used by tools like istanbul
25 | coverage
26 | *.lcov
27 |
28 | # nyc test coverage
29 | .nyc_output
30 |
31 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
32 | .grunt
33 |
34 | # Bower dependency directory (https://bower.io/)
35 | bower_components
36 |
37 | # node-waf configuration
38 | .lock-wscript
39 |
40 | # Compiled binary addons (https://nodejs.org/api/addons.html)
41 | build/Release
42 |
43 | # Dependency directories
44 | node_modules/
45 | jspm_packages/
46 |
47 | # TypeScript v1 declaration files
48 | typings/
49 | # Snowpack dependency directory (https://snowpack.dev/)
50 | web_modules/
51 |
52 | # TypeScript cache
53 | *.tsbuildinfo
54 |
55 | # Optional npm cache directory
56 | .npm
57 |
58 | # Optional eslint cache
59 | .eslintcache
60 |
61 | # Microbundle cache
62 | .rpt2_cache/
63 | .rts2_cache_cjs/
64 | .rts2_cache_es/
65 | .rts2_cache_umd/
66 |
67 | # Optional REPL history
68 | .node_repl_history
69 |
70 | # Output of 'npm pack'
71 | *.tgz
72 |
73 | # Yarn Integrity file
74 | .yarn-integrity
75 |
76 | # dotenv environment variables file
77 | .env
78 | .env.test
79 | .env.production
80 |
81 | # parcel-bundler cache (https://parceljs.org/)
82 | .cache
83 | .parcel-cache
84 |
85 | # Next.js build output
86 | .next
87 | out
88 |
89 | # Nuxt.js build / generate output
90 | .nuxt
91 | dist
92 |
93 | # Gatsby files
94 | .cache/
95 | # Comment in the public line in if your project uses Gatsby and not Next.js
96 | # https://nextjs.org/blog/next-9-1#public-directory-support
97 | # public
98 |
99 | # vuepress build output
100 | .vuepress/dist
101 |
102 | # Serverless directories
103 | .serverless/
104 |
105 | # FuseBox cache
106 | .fusebox/
107 |
108 | # DynamoDB Local files
109 | .dynamodb/
110 |
111 | # TernJS port file
112 | .tern-port
113 |
114 | # Stores VSCode versions used for testing VSCode extensions
115 | .vscode-test
116 |
117 | # yarn v2
118 | .yarn/cache
119 | .yarn/unplugged
120 | .yarn/build-state.yml
121 | .yarn/install-state.gz
122 | .pnp.*
123 |
124 | bundles/
125 |
126 | .DS_Store
127 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "typescript.tsdk": "node_modules/typescript/lib"
3 | }
4 |
--------------------------------------------------------------------------------
/DeveloperAccounts.md:
--------------------------------------------------------------------------------
1 | # Developer Accounts
2 |
3 | These are accounts to use while developing tracker extensions for Paperback. Use these to prevent possibly damaging your personal accounts.
4 |
5 | **Protonmail:** Use this mail account for all tracker extensions.
6 |
7 | - E-mail: pbioseta@proton.me
8 | - Password: ibRrF23:nFA5Wyj
9 |
10 | ---
11 |
12 | ## Tracker extensions
13 |
14 | **Anilist:**
15 |
16 | - E-mail: pbioseta@proton.me
17 | - Password: ibRrF23:nFA5Wyj
18 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | GNU GENERAL PUBLIC LICENSE
2 | Version 3, 29 June 2007
3 |
4 | Copyright (C) 2007 Free Software Foundation, Inc.
5 | Everyone is permitted to copy and distribute verbatim copies
6 | of this license document, but changing it is not allowed.
7 |
8 | Preamble
9 |
10 | The GNU General Public License is a free, copyleft license for
11 | software and other kinds of works.
12 |
13 | The licenses for most software and other practical works are designed
14 | to take away your freedom to share and change the works. By contrast,
15 | the GNU General Public License is intended to guarantee your freedom to
16 | share and change all versions of a program--to make sure it remains free
17 | software for all its users. We, the Free Software Foundation, use the
18 | GNU General Public License for most of our software; it applies also to
19 | any other work released this way by its authors. You can apply it to
20 | your programs, too.
21 |
22 | When we speak of free software, we are referring to freedom, not
23 | price. Our General Public Licenses are designed to make sure that you
24 | have the freedom to distribute copies of free software (and charge for
25 | them if you wish), that you receive source code or can get it if you
26 | want it, that you can change the software or use pieces of it in new
27 | free programs, and that you know you can do these things.
28 |
29 | To protect your rights, we need to prevent others from denying you
30 | these rights or asking you to surrender the rights. Therefore, you have
31 | certain responsibilities if you distribute copies of the software, or if
32 | you modify it: responsibilities to respect the freedom of others.
33 |
34 | For example, if you distribute copies of such a program, whether
35 | gratis or for a fee, you must pass on to the recipients the same
36 | freedoms that you received. You must make sure that they, too, receive
37 | or can get the source code. And you must show them these terms so they
38 | know their rights.
39 |
40 | Developers that use the GNU GPL protect your rights with two steps:
41 | (1) assert copyright on the software, and (2) offer you this License
42 | giving you legal permission to copy, distribute and/or modify it.
43 |
44 | For the developers' and authors' protection, the GPL clearly explains
45 | that there is no warranty for this free software. For both users' and
46 | authors' sake, the GPL requires that modified versions be marked as
47 | changed, so that their problems will not be attributed erroneously to
48 | authors of previous versions.
49 |
50 | Some devices are designed to deny users access to install or run
51 | modified versions of the software inside them, although the manufacturer
52 | can do so. This is fundamentally incompatible with the aim of
53 | protecting users' freedom to change the software. The systematic
54 | pattern of such abuse occurs in the area of products for individuals to
55 | use, which is precisely where it is most unacceptable. Therefore, we
56 | have designed this version of the GPL to prohibit the practice for those
57 | products. If such problems arise substantially in other domains, we
58 | stand ready to extend this provision to those domains in future versions
59 | of the GPL, as needed to protect the freedom of users.
60 |
61 | Finally, every program is threatened constantly by software patents.
62 | States should not allow patents to restrict development and use of
63 | software on general-purpose computers, but in those that do, we wish to
64 | avoid the special danger that patents applied to a free program could
65 | make it effectively proprietary. To prevent this, the GPL assures that
66 | patents cannot be used to render the program non-free.
67 |
68 | The precise terms and conditions for copying, distribution and
69 | modification follow.
70 |
71 | TERMS AND CONDITIONS
72 |
73 | 0. Definitions.
74 |
75 | "This License" refers to version 3 of the GNU General Public License.
76 |
77 | "Copyright" also means copyright-like laws that apply to other kinds of
78 | works, such as semiconductor masks.
79 |
80 | "The Program" refers to any copyrightable work licensed under this
81 | License. Each licensee is addressed as "you". "Licensees" and
82 | "recipients" may be individuals or organizations.
83 |
84 | To "modify" a work means to copy from or adapt all or part of the work
85 | in a fashion requiring copyright permission, other than the making of an
86 | exact copy. The resulting work is called a "modified version" of the
87 | earlier work or a work "based on" the earlier work.
88 |
89 | A "covered work" means either the unmodified Program or a work based
90 | on the Program.
91 |
92 | To "propagate" a work means to do anything with it that, without
93 | permission, would make you directly or secondarily liable for
94 | infringement under applicable copyright law, except executing it on a
95 | computer or modifying a private copy. Propagation includes copying,
96 | distribution (with or without modification), making available to the
97 | public, and in some countries other activities as well.
98 |
99 | To "convey" a work means any kind of propagation that enables other
100 | parties to make or receive copies. Mere interaction with a user through
101 | a computer network, with no transfer of a copy, is not conveying.
102 |
103 | An interactive user interface displays "Appropriate Legal Notices"
104 | to the extent that it includes a convenient and prominently visible
105 | feature that (1) displays an appropriate copyright notice, and (2)
106 | tells the user that there is no warranty for the work (except to the
107 | extent that warranties are provided), that licensees may convey the
108 | work under this License, and how to view a copy of this License. If
109 | the interface presents a list of user commands or options, such as a
110 | menu, a prominent item in the list meets this criterion.
111 |
112 | 1. Source Code.
113 |
114 | The "source code" for a work means the preferred form of the work
115 | for making modifications to it. "Object code" means any non-source
116 | form of a work.
117 |
118 | A "Standard Interface" means an interface that either is an official
119 | standard defined by a recognized standards body, or, in the case of
120 | interfaces specified for a particular programming language, one that
121 | is widely used among developers working in that language.
122 |
123 | The "System Libraries" of an executable work include anything, other
124 | than the work as a whole, that (a) is included in the normal form of
125 | packaging a Major Component, but which is not part of that Major
126 | Component, and (b) serves only to enable use of the work with that
127 | Major Component, or to implement a Standard Interface for which an
128 | implementation is available to the public in source code form. A
129 | "Major Component", in this context, means a major essential component
130 | (kernel, window system, and so on) of the specific operating system
131 | (if any) on which the executable work runs, or a compiler used to
132 | produce the work, or an object code interpreter used to run it.
133 |
134 | The "Corresponding Source" for a work in object code form means all
135 | the source code needed to generate, install, and (for an executable
136 | work) run the object code and to modify the work, including scripts to
137 | control those activities. However, it does not include the work's
138 | System Libraries, or general-purpose tools or generally available free
139 | programs which are used unmodified in performing those activities but
140 | which are not part of the work. For example, Corresponding Source
141 | includes interface definition files associated with source files for
142 | the work, and the source code for shared libraries and dynamically
143 | linked subprograms that the work is specifically designed to require,
144 | such as by intimate data communication or control flow between those
145 | subprograms and other parts of the work.
146 |
147 | The Corresponding Source need not include anything that users
148 | can regenerate automatically from other parts of the Corresponding
149 | Source.
150 |
151 | The Corresponding Source for a work in source code form is that
152 | same work.
153 |
154 | 2. Basic Permissions.
155 |
156 | All rights granted under this License are granted for the term of
157 | copyright on the Program, and are irrevocable provided the stated
158 | conditions are met. This License explicitly affirms your unlimited
159 | permission to run the unmodified Program. The output from running a
160 | covered work is covered by this License only if the output, given its
161 | content, constitutes a covered work. This License acknowledges your
162 | rights of fair use or other equivalent, as provided by copyright law.
163 |
164 | You may make, run and propagate covered works that you do not
165 | convey, without conditions so long as your license otherwise remains
166 | in force. You may convey covered works to others for the sole purpose
167 | of having them make modifications exclusively for you, or provide you
168 | with facilities for running those works, provided that you comply with
169 | the terms of this License in conveying all material for which you do
170 | not control copyright. Those thus making or running the covered works
171 | for you must do so exclusively on your behalf, under your direction
172 | and control, on terms that prohibit them from making any copies of
173 | your copyrighted material outside their relationship with you.
174 |
175 | Conveying under any other circumstances is permitted solely under
176 | the conditions stated below. Sublicensing is not allowed; section 10
177 | makes it unnecessary.
178 |
179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
180 |
181 | No covered work shall be deemed part of an effective technological
182 | measure under any applicable law fulfilling obligations under article
183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or
184 | similar laws prohibiting or restricting circumvention of such
185 | measures.
186 |
187 | When you convey a covered work, you waive any legal power to forbid
188 | circumvention of technological measures to the extent such circumvention
189 | is effected by exercising rights under this License with respect to
190 | the covered work, and you disclaim any intention to limit operation or
191 | modification of the work as a means of enforcing, against the work's
192 | users, your or third parties' legal rights to forbid circumvention of
193 | technological measures.
194 |
195 | 4. Conveying Verbatim Copies.
196 |
197 | You may convey verbatim copies of the Program's source code as you
198 | receive it, in any medium, provided that you conspicuously and
199 | appropriately publish on each copy an appropriate copyright notice;
200 | keep intact all notices stating that this License and any
201 | non-permissive terms added in accord with section 7 apply to the code;
202 | keep intact all notices of the absence of any warranty; and give all
203 | recipients a copy of this License along with the Program.
204 |
205 | You may charge any price or no price for each copy that you convey,
206 | and you may offer support or warranty protection for a fee.
207 |
208 | 5. Conveying Modified Source Versions.
209 |
210 | You may convey a work based on the Program, or the modifications to
211 | produce it from the Program, in the form of source code under the
212 | terms of section 4, provided that you also meet all of these conditions:
213 |
214 | a) The work must carry prominent notices stating that you modified
215 | it, and giving a relevant date.
216 |
217 | b) The work must carry prominent notices stating that it is
218 | released under this License and any conditions added under section
219 | 7. This requirement modifies the requirement in section 4 to
220 | "keep intact all notices".
221 |
222 | c) You must license the entire work, as a whole, under this
223 | License to anyone who comes into possession of a copy. This
224 | License will therefore apply, along with any applicable section 7
225 | additional terms, to the whole of the work, and all its parts,
226 | regardless of how they are packaged. This License gives no
227 | permission to license the work in any other way, but it does not
228 | invalidate such permission if you have separately received it.
229 |
230 | d) If the work has interactive user interfaces, each must display
231 | Appropriate Legal Notices; however, if the Program has interactive
232 | interfaces that do not display Appropriate Legal Notices, your
233 | work need not make them do so.
234 |
235 | A compilation of a covered work with other separate and independent
236 | works, which are not by their nature extensions of the covered work,
237 | and which are not combined with it such as to form a larger program,
238 | in or on a volume of a storage or distribution medium, is called an
239 | "aggregate" if the compilation and its resulting copyright are not
240 | used to limit the access or legal rights of the compilation's users
241 | beyond what the individual works permit. Inclusion of a covered work
242 | in an aggregate does not cause this License to apply to the other
243 | parts of the aggregate.
244 |
245 | 6. Conveying Non-Source Forms.
246 |
247 | You may convey a covered work in object code form under the terms
248 | of sections 4 and 5, provided that you also convey the
249 | machine-readable Corresponding Source under the terms of this License,
250 | in one of these ways:
251 |
252 | a) Convey the object code in, or embodied in, a physical product
253 | (including a physical distribution medium), accompanied by the
254 | Corresponding Source fixed on a durable physical medium
255 | customarily used for software interchange.
256 |
257 | b) Convey the object code in, or embodied in, a physical product
258 | (including a physical distribution medium), accompanied by a
259 | written offer, valid for at least three years and valid for as
260 | long as you offer spare parts or customer support for that product
261 | model, to give anyone who possesses the object code either (1) a
262 | copy of the Corresponding Source for all the software in the
263 | product that is covered by this License, on a durable physical
264 | medium customarily used for software interchange, for a price no
265 | more than your reasonable cost of physically performing this
266 | conveying of source, or (2) access to copy the
267 | Corresponding Source from a network server at no charge.
268 |
269 | c) Convey individual copies of the object code with a copy of the
270 | written offer to provide the Corresponding Source. This
271 | alternative is allowed only occasionally and noncommercially, and
272 | only if you received the object code with such an offer, in accord
273 | with subsection 6b.
274 |
275 | d) Convey the object code by offering access from a designated
276 | place (gratis or for a charge), and offer equivalent access to the
277 | Corresponding Source in the same way through the same place at no
278 | further charge. You need not require recipients to copy the
279 | Corresponding Source along with the object code. If the place to
280 | copy the object code is a network server, the Corresponding Source
281 | may be on a different server (operated by you or a third party)
282 | that supports equivalent copying facilities, provided you maintain
283 | clear directions next to the object code saying where to find the
284 | Corresponding Source. Regardless of what server hosts the
285 | Corresponding Source, you remain obligated to ensure that it is
286 | available for as long as needed to satisfy these requirements.
287 |
288 | e) Convey the object code using peer-to-peer transmission, provided
289 | you inform other peers where the object code and Corresponding
290 | Source of the work are being offered to the general public at no
291 | charge under subsection 6d.
292 |
293 | A separable portion of the object code, whose source code is excluded
294 | from the Corresponding Source as a System Library, need not be
295 | included in conveying the object code work.
296 |
297 | A "User Product" is either (1) a "consumer product", which means any
298 | tangible personal property which is normally used for personal, family,
299 | or household purposes, or (2) anything designed or sold for incorporation
300 | into a dwelling. In determining whether a product is a consumer product,
301 | doubtful cases shall be resolved in favor of coverage. For a particular
302 | product received by a particular user, "normally used" refers to a
303 | typical or common use of that class of product, regardless of the status
304 | of the particular user or of the way in which the particular user
305 | actually uses, or expects or is expected to use, the product. A product
306 | is a consumer product regardless of whether the product has substantial
307 | commercial, industrial or non-consumer uses, unless such uses represent
308 | the only significant mode of use of the product.
309 |
310 | "Installation Information" for a User Product means any methods,
311 | procedures, authorization keys, or other information required to install
312 | and execute modified versions of a covered work in that User Product from
313 | a modified version of its Corresponding Source. The information must
314 | suffice to ensure that the continued functioning of the modified object
315 | code is in no case prevented or interfered with solely because
316 | modification has been made.
317 |
318 | If you convey an object code work under this section in, or with, or
319 | specifically for use in, a User Product, and the conveying occurs as
320 | part of a transaction in which the right of possession and use of the
321 | User Product is transferred to the recipient in perpetuity or for a
322 | fixed term (regardless of how the transaction is characterized), the
323 | Corresponding Source conveyed under this section must be accompanied
324 | by the Installation Information. But this requirement does not apply
325 | if neither you nor any third party retains the ability to install
326 | modified object code on the User Product (for example, the work has
327 | been installed in ROM).
328 |
329 | The requirement to provide Installation Information does not include a
330 | requirement to continue to provide support service, warranty, or updates
331 | for a work that has been modified or installed by the recipient, or for
332 | the User Product in which it has been modified or installed. Access to a
333 | network may be denied when the modification itself materially and
334 | adversely affects the operation of the network or violates the rules and
335 | protocols for communication across the network.
336 |
337 | Corresponding Source conveyed, and Installation Information provided,
338 | in accord with this section must be in a format that is publicly
339 | documented (and with an implementation available to the public in
340 | source code form), and must require no special password or key for
341 | unpacking, reading or copying.
342 |
343 | 7. Additional Terms.
344 |
345 | "Additional permissions" are terms that supplement the terms of this
346 | License by making exceptions from one or more of its conditions.
347 | Additional permissions that are applicable to the entire Program shall
348 | be treated as though they were included in this License, to the extent
349 | that they are valid under applicable law. If additional permissions
350 | apply only to part of the Program, that part may be used separately
351 | under those permissions, but the entire Program remains governed by
352 | this License without regard to the additional permissions.
353 |
354 | When you convey a copy of a covered work, you may at your option
355 | remove any additional permissions from that copy, or from any part of
356 | it. (Additional permissions may be written to require their own
357 | removal in certain cases when you modify the work.) You may place
358 | additional permissions on material, added by you to a covered work,
359 | for which you have or can give appropriate copyright permission.
360 |
361 | Notwithstanding any other provision of this License, for material you
362 | add to a covered work, you may (if authorized by the copyright holders of
363 | that material) supplement the terms of this License with terms:
364 |
365 | a) Disclaiming warranty or limiting liability differently from the
366 | terms of sections 15 and 16 of this License; or
367 |
368 | b) Requiring preservation of specified reasonable legal notices or
369 | author attributions in that material or in the Appropriate Legal
370 | Notices displayed by works containing it; or
371 |
372 | c) Prohibiting misrepresentation of the origin of that material, or
373 | requiring that modified versions of such material be marked in
374 | reasonable ways as different from the original version; or
375 |
376 | d) Limiting the use for publicity purposes of names of licensors or
377 | authors of the material; or
378 |
379 | e) Declining to grant rights under trademark law for use of some
380 | trade names, trademarks, or service marks; or
381 |
382 | f) Requiring indemnification of licensors and authors of that
383 | material by anyone who conveys the material (or modified versions of
384 | it) with contractual assumptions of liability to the recipient, for
385 | any liability that these contractual assumptions directly impose on
386 | those licensors and authors.
387 |
388 | All other non-permissive additional terms are considered "further
389 | restrictions" within the meaning of section 10. If the Program as you
390 | received it, or any part of it, contains a notice stating that it is
391 | governed by this License along with a term that is a further
392 | restriction, you may remove that term. If a license document contains
393 | a further restriction but permits relicensing or conveying under this
394 | License, you may add to a covered work material governed by the terms
395 | of that license document, provided that the further restriction does
396 | not survive such relicensing or conveying.
397 |
398 | If you add terms to a covered work in accord with this section, you
399 | must place, in the relevant source files, a statement of the
400 | additional terms that apply to those files, or a notice indicating
401 | where to find the applicable terms.
402 |
403 | Additional terms, permissive or non-permissive, may be stated in the
404 | form of a separately written license, or stated as exceptions;
405 | the above requirements apply either way.
406 |
407 | 8. Termination.
408 |
409 | You may not propagate or modify a covered work except as expressly
410 | provided under this License. Any attempt otherwise to propagate or
411 | modify it is void, and will automatically terminate your rights under
412 | this License (including any patent licenses granted under the third
413 | paragraph of section 11).
414 |
415 | However, if you cease all violation of this License, then your
416 | license from a particular copyright holder is reinstated (a)
417 | provisionally, unless and until the copyright holder explicitly and
418 | finally terminates your license, and (b) permanently, if the copyright
419 | holder fails to notify you of the violation by some reasonable means
420 | prior to 60 days after the cessation.
421 |
422 | Moreover, your license from a particular copyright holder is
423 | reinstated permanently if the copyright holder notifies you of the
424 | violation by some reasonable means, this is the first time you have
425 | received notice of violation of this License (for any work) from that
426 | copyright holder, and you cure the violation prior to 30 days after
427 | your receipt of the notice.
428 |
429 | Termination of your rights under this section does not terminate the
430 | licenses of parties who have received copies or rights from you under
431 | this License. If your rights have been terminated and not permanently
432 | reinstated, you do not qualify to receive new licenses for the same
433 | material under section 10.
434 |
435 | 9. Acceptance Not Required for Having Copies.
436 |
437 | You are not required to accept this License in order to receive or
438 | run a copy of the Program. Ancillary propagation of a covered work
439 | occurring solely as a consequence of using peer-to-peer transmission
440 | to receive a copy likewise does not require acceptance. However,
441 | nothing other than this License grants you permission to propagate or
442 | modify any covered work. These actions infringe copyright if you do
443 | not accept this License. Therefore, by modifying or propagating a
444 | covered work, you indicate your acceptance of this License to do so.
445 |
446 | 10. Automatic Licensing of Downstream Recipients.
447 |
448 | Each time you convey a covered work, the recipient automatically
449 | receives a license from the original licensors, to run, modify and
450 | propagate that work, subject to this License. You are not responsible
451 | for enforcing compliance by third parties with this License.
452 |
453 | An "entity transaction" is a transaction transferring control of an
454 | organization, or substantially all assets of one, or subdividing an
455 | organization, or merging organizations. If propagation of a covered
456 | work results from an entity transaction, each party to that
457 | transaction who receives a copy of the work also receives whatever
458 | licenses to the work the party's predecessor in interest had or could
459 | give under the previous paragraph, plus a right to possession of the
460 | Corresponding Source of the work from the predecessor in interest, if
461 | the predecessor has it or can get it with reasonable efforts.
462 |
463 | You may not impose any further restrictions on the exercise of the
464 | rights granted or affirmed under this License. For example, you may
465 | not impose a license fee, royalty, or other charge for exercise of
466 | rights granted under this License, and you may not initiate litigation
467 | (including a cross-claim or counterclaim in a lawsuit) alleging that
468 | any patent claim is infringed by making, using, selling, offering for
469 | sale, or importing the Program or any portion of it.
470 |
471 | 11. Patents.
472 |
473 | A "contributor" is a copyright holder who authorizes use under this
474 | License of the Program or a work on which the Program is based. The
475 | work thus licensed is called the contributor's "contributor version".
476 |
477 | A contributor's "essential patent claims" are all patent claims
478 | owned or controlled by the contributor, whether already acquired or
479 | hereafter acquired, that would be infringed by some manner, permitted
480 | by this License, of making, using, or selling its contributor version,
481 | but do not include claims that would be infringed only as a
482 | consequence of further modification of the contributor version. For
483 | purposes of this definition, "control" includes the right to grant
484 | patent sublicenses in a manner consistent with the requirements of
485 | this License.
486 |
487 | Each contributor grants you a non-exclusive, worldwide, royalty-free
488 | patent license under the contributor's essential patent claims, to
489 | make, use, sell, offer for sale, import and otherwise run, modify and
490 | propagate the contents of its contributor version.
491 |
492 | In the following three paragraphs, a "patent license" is any express
493 | agreement or commitment, however denominated, not to enforce a patent
494 | (such as an express permission to practice a patent or covenant not to
495 | sue for patent infringement). To "grant" such a patent license to a
496 | party means to make such an agreement or commitment not to enforce a
497 | patent against the party.
498 |
499 | If you convey a covered work, knowingly relying on a patent license,
500 | and the Corresponding Source of the work is not available for anyone
501 | to copy, free of charge and under the terms of this License, through a
502 | publicly available network server or other readily accessible means,
503 | then you must either (1) cause the Corresponding Source to be so
504 | available, or (2) arrange to deprive yourself of the benefit of the
505 | patent license for this particular work, or (3) arrange, in a manner
506 | consistent with the requirements of this License, to extend the patent
507 | license to downstream recipients. "Knowingly relying" means you have
508 | actual knowledge that, but for the patent license, your conveying the
509 | covered work in a country, or your recipient's use of the covered work
510 | in a country, would infringe one or more identifiable patents in that
511 | country that you have reason to believe are valid.
512 |
513 | If, pursuant to or in connection with a single transaction or
514 | arrangement, you convey, or propagate by procuring conveyance of, a
515 | covered work, and grant a patent license to some of the parties
516 | receiving the covered work authorizing them to use, propagate, modify
517 | or convey a specific copy of the covered work, then the patent license
518 | you grant is automatically extended to all recipients of the covered
519 | work and works based on it.
520 |
521 | A patent license is "discriminatory" if it does not include within
522 | the scope of its coverage, prohibits the exercise of, or is
523 | conditioned on the non-exercise of one or more of the rights that are
524 | specifically granted under this License. You may not convey a covered
525 | work if you are a party to an arrangement with a third party that is
526 | in the business of distributing software, under which you make payment
527 | to the third party based on the extent of your activity of conveying
528 | the work, and under which the third party grants, to any of the
529 | parties who would receive the covered work from you, a discriminatory
530 | patent license (a) in connection with copies of the covered work
531 | conveyed by you (or copies made from those copies), or (b) primarily
532 | for and in connection with specific products or compilations that
533 | contain the covered work, unless you entered into that arrangement,
534 | or that patent license was granted, prior to 28 March 2007.
535 |
536 | Nothing in this License shall be construed as excluding or limiting
537 | any implied license or other defenses to infringement that may
538 | otherwise be available to you under applicable patent law.
539 |
540 | 12. No Surrender of Others' Freedom.
541 |
542 | If conditions are imposed on you (whether by court order, agreement or
543 | otherwise) that contradict the conditions of this License, they do not
544 | excuse you from the conditions of this License. If you cannot convey a
545 | covered work so as to satisfy simultaneously your obligations under this
546 | License and any other pertinent obligations, then as a consequence you may
547 | not convey it at all. For example, if you agree to terms that obligate you
548 | to collect a royalty for further conveying from those to whom you convey
549 | the Program, the only way you could satisfy both those terms and this
550 | License would be to refrain entirely from conveying the Program.
551 |
552 | 13. Use with the GNU Affero General Public License.
553 |
554 | Notwithstanding any other provision of this License, you have
555 | permission to link or combine any covered work with a work licensed
556 | under version 3 of the GNU Affero General Public License into a single
557 | combined work, and to convey the resulting work. The terms of this
558 | License will continue to apply to the part which is the covered work,
559 | but the special requirements of the GNU Affero General Public License,
560 | section 13, concerning interaction through a network will apply to the
561 | combination as such.
562 |
563 | 14. Revised Versions of this License.
564 |
565 | The Free Software Foundation may publish revised and/or new versions of
566 | the GNU General Public License from time to time. Such new versions will
567 | be similar in spirit to the present version, but may differ in detail to
568 | address new problems or concerns.
569 |
570 | Each version is given a distinguishing version number. If the
571 | Program specifies that a certain numbered version of the GNU General
572 | Public License "or any later version" applies to it, you have the
573 | option of following the terms and conditions either of that numbered
574 | version or of any later version published by the Free Software
575 | Foundation. If the Program does not specify a version number of the
576 | GNU General Public License, you may choose any version ever published
577 | by the Free Software Foundation.
578 |
579 | If the Program specifies that a proxy can decide which future
580 | versions of the GNU General Public License can be used, that proxy's
581 | public statement of acceptance of a version permanently authorizes you
582 | to choose that version for the Program.
583 |
584 | Later license versions may give you additional or different
585 | permissions. However, no additional obligations are imposed on any
586 | author or copyright holder as a result of your choosing to follow a
587 | later version.
588 |
589 | 15. Disclaimer of Warranty.
590 |
591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
599 |
600 | 16. Limitation of Liability.
601 |
602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
610 | SUCH DAMAGES.
611 |
612 | 17. Interpretation of Sections 15 and 16.
613 |
614 | If the disclaimer of warranty and limitation of liability provided
615 | above cannot be given local legal effect according to their terms,
616 | reviewing courts shall apply local law that most closely approximates
617 | an absolute waiver of all civil liability in connection with the
618 | Program, unless a warranty or assumption of liability accompanies a
619 | copy of the Program in return for a fee.
620 |
621 | END OF TERMS AND CONDITIONS
622 |
623 | How to Apply These Terms to Your New Programs
624 |
625 | If you develop a new program, and you want it to be of the greatest
626 | possible use to the public, the best way to achieve this is to make it
627 | free software which everyone can redistribute and change under these terms.
628 |
629 | To do so, attach the following notices to the program. It is safest
630 | to attach them to the start of each source file to most effectively
631 | state the exclusion of warranty; and each file should have at least
632 | the "copyright" line and a pointer to where the full notice is found.
633 |
634 |
635 | Copyright (C)
636 |
637 | This program is free software: you can redistribute it and/or modify
638 | it under the terms of the GNU General Public License as published by
639 | the Free Software Foundation, either version 3 of the License, or
640 | (at your option) any later version.
641 |
642 | This program is distributed in the hope that it will be useful,
643 | but WITHOUT ANY WARRANTY; without even the implied warranty of
644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
645 | GNU General Public License for more details.
646 |
647 | You should have received a copy of the GNU General Public License
648 | along with this program. If not, see .
649 |
650 | Also add information on how to contact you by electronic and paper mail.
651 |
652 | If the program does terminal interaction, make it output a short
653 | notice like this when it starts in an interactive mode:
654 |
655 | Copyright (C)
656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
657 | This is free software, and you are welcome to redistribute it
658 | under certain conditions; type `show c' for details.
659 |
660 | The hypothetical commands `show w' and `show c' should show the appropriate
661 | parts of the General Public License. Of course, your program's commands
662 | might be different; for a GUI interface, you would use an "about box".
663 |
664 | You should also get your employer (if you work as a programmer) or school,
665 | if any, to sign a "copyright disclaimer" for the program, if necessary.
666 | For more information on this, and how to apply and follow the GNU GPL, see
667 | .
668 |
669 | The GNU General Public License does not permit incorporating your program
670 | into proprietary programs. If your program is a subroutine library, you
671 | may consider it more useful to permit linking proprietary applications with
672 | the library. If this is what you want to do, use the GNU Lesser General
673 | Public License instead of this License. But first, please read
674 | .
675 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Extensions
2 |
3 | Default extensions for [Paperback](https://paperback.moe/) 0.8.
4 |
5 | The extensions are either tracker extensions or the official Paperback source.
6 |
7 | Tracking helps you automatically send read manga chapters to supported trackers, so you can keep track of what and when you read it online.
8 |
9 | Paperback does not officially support other third party source extensions (extensions with content), so you will not find any in this repository.
10 |
11 | ## Supported trackers
12 |
13 | - [AniList](https://anilist.co/)
14 | - [MyAnimeList](https://myanimelist.net/)
15 | - [MangaUpdates](https://mangaupdates.com/)
16 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "extensions",
3 | "version": "1.0.0",
4 | "repositoryName": "Default extensions",
5 | "description": "Default extensions for Paperback 0.8.",
6 | "main": "lib/index.js",
7 | "scripts": {
8 | "migrate": "npx paperback migrate",
9 | "bundle": "npx paperback bundle",
10 | "serve": "npx paperback serve",
11 | "dev": "npx nodemon --watch \"**/*.ts\" --ext \"js ts\" --exec \"npm run serve\""
12 | },
13 | "repository": {
14 | "type": "git",
15 | "url": "git+https://github.com/Paperback-iOS/extensions"
16 | },
17 | "author": "Paperback",
18 | "license": "GPL-3.0-or-later",
19 | "bugs": {
20 | "url": "https://github.com/Paperback-iOS/extensions/issues"
21 | },
22 | "homepage": "https://github.com/Paperback-iOS/extensions#readme",
23 | "devDependencies": {
24 | "@typescript-eslint/eslint-plugin": "^4.25.0",
25 | "@typescript-eslint/parser": "^4.25.0",
26 | "eslint": "^7.27.0",
27 | "eslint-plugin-modules-newline": "^0.0.6",
28 | "typescript": "^4.3.2"
29 | },
30 | "dependencies": {
31 | "@paperback/toolchain": "^0.8.0-alpha.38",
32 | "@paperback/types": "^0.8.0-alpha.38"
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/src/Anilist/AlSettings.ts:
--------------------------------------------------------------------------------
1 | import {
2 | DUINavigationButton,
3 | SourceStateManager
4 | } from '@paperback/types'
5 |
6 | export const getDefaultStatus = async (stateManager: SourceStateManager): Promise => {
7 | return (await stateManager.retrieve('defaultStatus') as string[]) ?? ['NONE']
8 | }
9 | export const getDefaultPrivate = async (stateManager: SourceStateManager): Promise => {
10 | return (await stateManager.retrieve('defaultPrivate') as string[]) ?? ['NEVER']
11 | }
12 | export const getDefaultHideFromStatusLists = async (stateManager: SourceStateManager): Promise => {
13 | return (await stateManager.retrieve('defaultHideFromActivity') as string[]) ?? ['NEVER']
14 | }
15 |
16 | export const trackerSettings = (stateManager: SourceStateManager): DUINavigationButton => {
17 | return App.createDUINavigationButton({
18 | id: 'tracker_settings',
19 | label: 'Tracker Settings',
20 | form: App.createDUIForm({
21 | sections: () => {
22 | return Promise.resolve([
23 | App.createDUISection({
24 | id: 'status_settings',
25 | header: 'Status Settings',
26 | isHidden: false,
27 | rows: async () => [
28 | App.createDUISelect({
29 | id: 'defaultStatus',
30 | label: 'Default Status',
31 | allowsMultiselect: false,
32 | value: App.createDUIBinding({
33 | get: () => getDefaultStatus(stateManager),
34 | set: async (newValue) => await stateManager.store('defaultStatus', newValue)
35 | }),
36 | labelResolver: async (value) => {
37 | switch (value) {
38 | case 'CURRENT': return 'Reading'
39 | case 'PLANNING': return 'Planned'
40 | case 'COMPLETED': return 'Completed'
41 | case 'DROPPED': return 'Dropped'
42 | case 'PAUSED': return 'On-Hold'
43 | case 'REPEATING': return 'Re-Reading'
44 | default: return 'None'
45 | }
46 | },
47 | options: [
48 | 'NONE',
49 | 'CURRENT',
50 | 'PLANNING',
51 | 'COMPLETED',
52 | 'DROPPED',
53 | 'PAUSED',
54 | 'REPEATING'
55 | ]
56 | })
57 | ]
58 | }),
59 | App.createDUISection({
60 | id: 'privacy_settings',
61 | header: 'Privacy Settings',
62 | isHidden: false,
63 | rows: async () => [
64 | App.createDUISelect({
65 | id: 'defaultPrivate',
66 | label: 'Private by Default',
67 | allowsMultiselect: false,
68 | value: App.createDUIBinding({
69 | get: () => getDefaultPrivate(stateManager),
70 | set: async (newValue) => await stateManager.store('defaultPrivate', newValue)
71 | }),
72 | labelResolver: async (value) => {
73 | switch (value) {
74 | case 'ALWAYS': return 'Always'
75 | case 'ADULTONLY': return 'Adult Only'
76 | default: return 'Never'
77 | }
78 | },
79 | options: [
80 | 'NEVER',
81 | 'ADULTONLY',
82 | 'ALWAYS'
83 | ]
84 | }),
85 | App.createDUISelect({
86 | id: 'defaultHideFromStatusLists',
87 | label: 'Hide from Status List by Default',
88 | allowsMultiselect: false,
89 | value: App.createDUIBinding({
90 | get: () => getDefaultHideFromStatusLists(stateManager),
91 | set: async (newValue) => await stateManager.store('defaultHideFromActivity', newValue)
92 | }),
93 | labelResolver: async (value) => {
94 | switch (value) {
95 | case 'ALWAYS': return 'Always'
96 | case 'ADULTONLY': return 'Adult Only'
97 | default: return 'Never'
98 | }
99 | },
100 | options: [
101 | 'NEVER',
102 | 'ADULTONLY',
103 | 'ALWAYS'
104 | ]
105 | })
106 | ]
107 | })
108 | ])
109 | }
110 | })
111 | })
112 | }
113 |
--------------------------------------------------------------------------------
/src/Anilist/Anilist.ts:
--------------------------------------------------------------------------------
1 | import {
2 | ContentRating,
3 | DUIForm,
4 | PagedResults,
5 | SearchRequest,
6 | DUISection,
7 | SourceInfo,
8 | Request,
9 | Response,
10 | TrackerActionQueue,
11 | Searchable,
12 | MangaProgressProviding,
13 | SourceManga,
14 | MangaProgress,
15 | SourceIntents
16 | } from '@paperback/types'
17 |
18 | import {
19 | deleteMangaProgressMutation,
20 | getMangaProgressQuery,
21 | getMangaQuery,
22 | GraphQLQuery,
23 | saveMangaProgressMutation,
24 | searchMangaQuery,
25 | userProfileQuery
26 | } from './models/graphql-queries'
27 |
28 | import * as AnilistUser from './models/anilist-user'
29 | import * as AnilistPage from './models/anilist-page'
30 | import * as AnilistManga from './models/anilist-manga'
31 | import { AnilistResult } from './models/anilist-result'
32 |
33 | import {
34 | getDefaultStatus,
35 | getDefaultPrivate,
36 | getDefaultHideFromStatusLists,
37 | trackerSettings
38 | } from './AlSettings'
39 |
40 | const ANILIST_GRAPHQL_ENDPOINT = 'https://graphql.anilist.co/'
41 |
42 | export const AnilistInfo: SourceInfo = {
43 | name: 'Anilist',
44 | author: 'Faizan Durrani ♥ Netsky',
45 | contentRating: ContentRating.EVERYONE,
46 | icon: 'icon.png',
47 | version: '1.1.7',
48 | description: 'Anilist Tracker',
49 | websiteBaseURL: 'https://anilist.co',
50 | intents: SourceIntents.MANGA_TRACKING | SourceIntents.SETTINGS_UI
51 | }
52 |
53 | export class Anilist implements Searchable, MangaProgressProviding {
54 | stateManager = App.createSourceStateManager();
55 |
56 | requestManager = App.createRequestManager({
57 | requestsPerSecond: 2.5,
58 | requestTimeout: 20_000,
59 | interceptor: {
60 | // Authorization injector
61 | interceptRequest: async (request: Request): Promise => {
62 | const accessToken = await this.accessToken.get()
63 | request.headers = {
64 | ...(request.headers ?? {}),
65 | ...({
66 | 'content-type': 'application/json',
67 | 'accept': 'application/json'
68 | }),
69 | ...(accessToken != null ? {
70 | 'authorization': `Bearer ${accessToken}`
71 | } : {})
72 | }
73 | return request
74 | },
75 | interceptResponse: async (response: Response): Promise => {
76 | return response
77 | }
78 | }
79 | });
80 |
81 | accessToken = {
82 | get: async (): Promise => {
83 | return this.stateManager.keychain.retrieve('access_token') as Promise
84 | },
85 | set: async (token: string | undefined): Promise => {
86 | await this.stateManager.keychain.store('access_token', token)
87 | await this.userInfo.refresh()
88 | },
89 | isValid: async (): Promise => {
90 | return (await this.accessToken.get()) != null
91 | }
92 | };
93 |
94 | userInfo = {
95 | get: async (): Promise => {
96 | return this.stateManager.retrieve('userInfo') as Promise
97 | },
98 | isLoggedIn: async (): Promise => {
99 | return (await this.userInfo.get()) != null
100 | },
101 | refresh: async (): Promise => {
102 | const accessToken = await this.accessToken.get()
103 | if (accessToken == null) {
104 | return this.stateManager.store('userInfo', undefined)
105 | }
106 | const response = await this.requestManager.schedule(App.createRequest({
107 | url: ANILIST_GRAPHQL_ENDPOINT,
108 | method: 'POST',
109 | data: userProfileQuery()
110 | }), 0)
111 | const userInfo = AnilistResult(response.data).data?.Viewer
112 | await this.stateManager.store('userInfo', userInfo)
113 | }
114 | };
115 |
116 | async getSearchResults(query: SearchRequest, metadata: unknown): Promise {
117 | const pageInfo = metadata as AnilistPage.PageInfo | undefined
118 | // If there are no more results, we don't want to make extra calls to Anilist
119 | if (pageInfo?.hasNextPage === false) {
120 | return App.createPagedResults({ results: [], metadata: pageInfo })
121 | }
122 |
123 | const nextPage = (pageInfo?.currentPage ?? 0) + 1
124 | const response = await this.requestManager.schedule(App.createRequest({
125 | url: ANILIST_GRAPHQL_ENDPOINT,
126 | method: 'POST',
127 | data: searchMangaQuery(nextPage, query.title ?? '')
128 | }), 1)
129 |
130 | const anilistPage = AnilistResult(response.data).data?.Page
131 |
132 | //console.log(JSON.stringify(anilistPage, null, 2)) // Log request data
133 |
134 | return App.createPagedResults({
135 | results: anilistPage?.media.map(manga => App.createPartialSourceManga({
136 | image: manga.coverImage.large ?? '',
137 | title: manga.title.userPreferred,
138 | mangaId: manga.id.toString(),
139 | subtitle: undefined
140 | })) ?? [],
141 | metadata: anilistPage?.pageInfo
142 | })
143 | }
144 |
145 | async getMangaProgress(mangaId: string): Promise {
146 | const response = await this.requestManager.schedule(App.createRequest({
147 | url: ANILIST_GRAPHQL_ENDPOINT,
148 | method: 'POST',
149 | data: getMangaProgressQuery(parseInt(mangaId))
150 | }), 1)
151 |
152 | const anilistManga = AnilistResult(response.data).data?.Media
153 |
154 | if (!anilistManga?.mediaListEntry) { return undefined }
155 |
156 | return App.createMangaProgress({
157 | mangaId: mangaId,
158 |
159 | lastReadChapterNumber: anilistManga.mediaListEntry.progress ?? 0,
160 | lastReadVolumeNumber: anilistManga.mediaListEntry.progressVolumes,
161 |
162 | trackedListName: anilistManga.mediaListEntry.status,
163 | userRating: anilistManga.mediaListEntry.score
164 | })
165 | }
166 |
167 | async getMangaProgressManagementForm(mangaId: string): Promise {
168 | const tempData: any = {} // Temp solution, app is ass
169 |
170 | return App.createDUIForm({
171 | sections: async () => {
172 | const [response] = await Promise.all([
173 | this.requestManager.schedule(App.createRequest({
174 | url: ANILIST_GRAPHQL_ENDPOINT,
175 | method: 'POST',
176 | data: getMangaProgressQuery(parseInt(mangaId))
177 | }), 1),
178 | this.userInfo.refresh()
179 | ])
180 |
181 | const anilistManga = AnilistResult(response.data).data?.Media
182 | const user = await this.userInfo.get()
183 | if (user == null) {
184 | return [
185 | App.createDUISection({
186 | id: 'notLoggedInSection',
187 | isHidden: false,
188 | rows: async () => [
189 | App.createDUILabel({
190 | id: 'notLoggedIn',
191 | label: 'Not Logged In'
192 | })
193 | ]
194 | })
195 | ]
196 | }
197 |
198 | if (anilistManga == null) {
199 | throw new Error(`Unable to find Manga on Anilist with id ${mangaId}`)
200 | }
201 |
202 | Object.assign(tempData, { id: anilistManga.mediaListEntry?.id, mediaId: anilistManga.id }) // Temp solution
203 |
204 | return [
205 | App.createDUISection({
206 | id: 'userInfo',
207 | isHidden: false,
208 | rows: async () => [
209 | App.createDUIHeader({
210 | id: 'header',
211 | imageUrl: user.avatar?.large || '',
212 | title: user.name ?? 'NOT LOGGED IN',
213 | subtitle: ''
214 | })
215 | ]
216 | }),
217 | // Static items
218 | App.createDUISection({
219 | id: 'information',
220 | header: 'Information',
221 | isHidden: false,
222 | rows: async () => [
223 | // This allows us to get the id when the form is submitted
224 | ...(anilistManga.mediaListEntry != null ? [App.createDUILabel({
225 | id: 'id',
226 | label: 'Entry ID',
227 | value: anilistManga.mediaListEntry?.id?.toString()
228 | })] : []),
229 | App.createDUILabel({
230 | id: 'mediaId',
231 | label: 'Manga ID',
232 | value: anilistManga.id?.toString()
233 | }),
234 | App.createDUILabel({
235 | id: 'mangaTitle',
236 | label: 'Title',
237 | value: anilistManga.title?.userPreferred ?? 'N/A'
238 | }),
239 | App.createDUILabel({
240 | id: 'mangaPopularity',
241 | value: anilistManga.popularity?.toString() ?? 'N/A',
242 | label: 'Popularity'
243 | }),
244 | App.createDUILabel({
245 | id: 'mangaRating',
246 | value: anilistManga.averageScore?.toString() ?? 'N/A',
247 | label: 'Rating'
248 | }),
249 | App.createDUILabel({
250 | id: 'mangaStatus',
251 | value: this.formatStatus(anilistManga.status),
252 | label: 'Status'
253 | }),
254 | App.createDUILabel({
255 | id: 'mangaIsAdult',
256 | value: anilistManga.isAdult ? 'Yes' : 'No',
257 | label: 'Is Adult'
258 | })
259 | ]
260 | }),
261 | // User interactive items
262 | // Status
263 | App.createDUISection({
264 | id: 'trackStatus',
265 | header: 'Manga Status',
266 | footer: 'Warning: Setting this to NONE will delete the listing from Anilist',
267 | isHidden: false,
268 | rows: async () => [
269 | App.createDUISelect({
270 | id: 'status',
271 | //@ts-ignore
272 | value: anilistManga.mediaListEntry?.status ? [anilistManga.mediaListEntry.status] : (await getDefaultStatus(this.stateManager)),
273 | allowsMultiselect: false,
274 | label: 'Status',
275 | labelResolver: async (value) => {
276 | return this.formatStatus(value)
277 | },
278 | options: [
279 | 'NONE',
280 | 'CURRENT',
281 | 'PLANNING',
282 | 'COMPLETED',
283 | 'DROPPED',
284 | 'PAUSED',
285 | 'REPEATING'
286 | ]
287 | })
288 | ]
289 | }),
290 | // Progress
291 | App.createDUISection({
292 | id: 'manage',
293 | header: 'Progress',
294 | isHidden: false,
295 | rows: async () => [
296 | App.createDUIStepper({
297 | id: 'progress',
298 | label: 'Chapter',
299 | //@ts-ignore
300 | value: anilistManga.mediaListEntry?.progress ?? 0,
301 | min: 0,
302 | step: 1
303 | }),
304 | App.createDUIStepper({
305 | id: 'progressVolumes',
306 | label: 'Volume',
307 | //@ts-ignore
308 | value: anilistManga.mediaListEntry?.progressVolumes ?? 0,
309 | min: 0,
310 | step: 1
311 | }),
312 | App.createDUIStepper({
313 | id: 'repeat',
314 | label: 'Times Re-Read',
315 | //@ts-ignore
316 | value: anilistManga.mediaListEntry?.repeat != undefined ? anilistManga.mediaListEntry?.repeat : 0,
317 | min: 0,
318 | step: 1
319 | }),
320 | ]
321 | }),
322 | // Rating
323 | App.createDUISection({
324 | id: 'rateSection',
325 | header: 'Rating',
326 | footer: 'This uses your rating preference set on AniList',
327 | isHidden: false,
328 | rows: async () => [
329 | App.createDUIStepper({
330 | id: 'score',
331 | label: 'Score',
332 | //@ts-ignore
333 | value: anilistManga.mediaListEntry?.score ?? 0,
334 | min: 0,
335 | max: this.scoreFormatLimit(user.mediaListOptions?.scoreFormat ?? 'POINT_10'),
336 | step: user.mediaListOptions?.scoreFormat?.includes('DECIMAL') === true ? 0.1 : 1
337 | })
338 | ]
339 | }),
340 | // privacy
341 | App.createDUISection({
342 | id: 'privacy_settings',
343 | header: 'Privacy Settings',
344 | isHidden: false,
345 | rows: async () => [
346 | App.createDUISwitch({
347 | id: 'private',
348 | label: 'Private',
349 | //@ts-ignore
350 | value: anilistManga.mediaListEntry?.private != undefined ? anilistManga.mediaListEntry.private : ((await getDefaultPrivate(this.stateManager) == 'ADULTONLY' && anilistManga.isAdult || await getDefaultPrivate(this.stateManager) == 'ALWAYS') ? true : false)
351 | }),
352 | App.createDUISwitch({
353 | id: 'hiddenFromStatusLists',
354 | label: 'Hide From Status List',
355 | //@ts-ignore
356 | value: anilistManga.mediaListEntry?.hiddenFromStatusLists != undefined ? anilistManga.mediaListEntry.hiddenFromStatusLists : ((await getDefaultHideFromStatusLists(this.stateManager) == 'ADULTONLY' && anilistManga.isAdult || await getDefaultHideFromStatusLists(this.stateManager) == 'ALWAYS') ? true : false)
357 | })
358 | ]
359 | }),
360 | // Notes
361 | App.createDUISection({
362 | id: 'mangaNotes',
363 | header: 'Notes',
364 | isHidden: false,
365 | rows: async () => [
366 | App.createDUIInputField({
367 | id: 'notes',
368 | label: 'Notes',
369 | //@ts-ignore
370 | value: anilistManga.mediaListEntry?.notes ?? ''
371 | })
372 | ]
373 | })
374 | ]
375 | },
376 | onSubmit: async (values) => {
377 |
378 | let mutation: GraphQLQuery
379 | const status = values['status']?.[0] ?? ''
380 | const id = tempData.id ? Number(tempData.id) : undefined //values['id'] != null ? Number(values['id']) : undefined
381 | const mediaId = Number(tempData.mediaId) //Number(values['mediaId'])
382 |
383 | if (status == 'NONE' && id != null) {
384 | mutation = deleteMangaProgressMutation(id)
385 | } else {
386 | mutation = saveMangaProgressMutation({
387 | id: id,
388 | mediaId: mediaId,
389 | status: status,
390 | notes: values['notes'],
391 | progress: values['progress'],
392 | progressVolumes: values['progressVolumes'],
393 | repeat: values['repeat'],
394 | private: values['private'],
395 | hiddenFromStatusLists: values['hiddenFromStatusLists'],
396 | score: Number(values['score'])
397 | })
398 | }
399 |
400 | console.log(JSON.stringify(mutation, null, 2)) // Log request data
401 |
402 | await this.requestManager.schedule(App.createRequest({
403 | url: ANILIST_GRAPHQL_ENDPOINT,
404 | method: 'POST',
405 | data: mutation
406 | }), 1)
407 | }
408 | })
409 | }
410 |
411 | async getMangaDetails(mangaId: string): Promise {
412 | const response = await this.requestManager.schedule(App.createRequest({
413 | url: ANILIST_GRAPHQL_ENDPOINT,
414 | method: 'POST',
415 | data: getMangaQuery(parseInt(mangaId))
416 | }), 1)
417 |
418 | const anilistManga = AnilistResult(response.data).data?.Media
419 | if (anilistManga == null) {
420 | return Promise.reject()
421 | }
422 |
423 | return App.createSourceManga({
424 | id: mangaId,
425 | mangaInfo: App.createMangaInfo({
426 | image: anilistManga.coverImage?.extraLarge ?? '',
427 | titles: [
428 | anilistManga.title?.romaji,
429 | anilistManga.title?.english,
430 | anilistManga.title?.native
431 | ].filter(x => x != null) as string[],
432 | artist: anilistManga.staff?.edges?.find(x => x?.role?.toLowerCase() == 'art')?.node?.name?.full ?? 'Unknown',
433 | author: anilistManga.staff?.edges?.find(x => x?.role?.toLowerCase() == 'story')?.node?.name?.full ?? 'Unknown',
434 | desc: anilistManga?.description || '',
435 | hentai: anilistManga.isAdult,
436 | rating: anilistManga.averageScore,
437 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
438 | // @ts-ignore
439 | status: anilistManga.status,
440 | banner: anilistManga.bannerImage
441 | })
442 | })
443 | }
444 |
445 | async getSourceMenu(): Promise {
446 | return App.createDUISection({
447 | id: 'sourceMenu',
448 | header: 'Source Menu',
449 | isHidden: false,
450 | rows: async () => {
451 | const isLoggedIn = await this.userInfo.isLoggedIn()
452 | if (isLoggedIn) {
453 | return [
454 | trackerSettings(this.stateManager),
455 | App.createDUILabel({
456 | id: 'userInfo',
457 | label: 'Logged-in as',
458 | value: (await this.userInfo.get())?.name ?? 'ERROR'
459 | }),
460 | App.createDUIButton({
461 | id: 'logout',
462 | label: 'Logout',
463 | onTap: async () => {
464 | await this.accessToken.set(undefined)
465 | }
466 | })
467 | ]
468 | } else {
469 | return [
470 | trackerSettings(this.stateManager),
471 | App.createDUIOAuthButton({
472 | id: 'anilistLogin',
473 | authorizeEndpoint: 'https://anilist.co/api/v2/oauth/authorize',
474 | clientId: '5459',
475 | label: 'Login with Anilist',
476 | responseType: {
477 | type: 'token'
478 | },
479 | successHandler: async (token) => {
480 | await this.accessToken.set(token)
481 | }
482 | })
483 | ]
484 | }
485 | }
486 | })
487 | }
488 |
489 | async processChapterReadActionQueue(actionQueue: TrackerActionQueue): Promise {
490 | await this.userInfo.refresh()
491 |
492 | const chapterReadActions = await actionQueue.queuedChapterReadActions()
493 |
494 | type PartialMediaListEntry = { mediaListEntry?: { progress?: number, progressVolumes?: number } }
495 | const anilistMangaCache: Record = {}
496 |
497 | for (const readAction of chapterReadActions) {
498 |
499 | try {
500 | let anilistManga = anilistMangaCache[readAction.mangaId]
501 |
502 | if (!anilistManga) {
503 | const _response = await this.requestManager.schedule(App.createRequest({
504 | url: ANILIST_GRAPHQL_ENDPOINT,
505 | method: 'POST',
506 | data: getMangaProgressQuery(parseInt(readAction.mangaId))
507 | }), 0)
508 |
509 | anilistManga = AnilistResult(_response.data).data?.Media
510 | anilistMangaCache[readAction.mangaId] = anilistManga
511 | }
512 |
513 | if (anilistManga?.mediaListEntry) {
514 | // If the Anilist chapter is higher or equal, skip
515 | if (anilistManga.mediaListEntry.progress && anilistManga.mediaListEntry.progress >= Math.floor(readAction.chapterNumber)) {
516 | await actionQueue.discardChapterReadAction(readAction)
517 | continue
518 | }
519 | }
520 |
521 | let params = {}
522 | if (Math.floor(readAction.chapterNumber) == 1 && !readAction.volumeNumber) {
523 | params = {
524 | mediaId: readAction.mangaId,
525 | progress: 1,
526 | progressVolumes: 1
527 | }
528 | } else {
529 | params = {
530 | mediaId: readAction.mangaId,
531 | progress: Math.floor(readAction.chapterNumber),
532 | progressVolumes: readAction.volumeNumber ? Math.floor(readAction.volumeNumber) : undefined
533 | }
534 | }
535 |
536 | const response = await this.requestManager.schedule(App.createRequest({
537 | url: ANILIST_GRAPHQL_ENDPOINT,
538 | method: 'POST',
539 | data: saveMangaProgressMutation(params)
540 | }), 0)
541 |
542 | if (response.status < 400) {
543 | await actionQueue.discardChapterReadAction(readAction)
544 | anilistMangaCache[readAction.mangaId] = {
545 | mediaListEntry: {
546 | progress: Math.floor(readAction.chapterNumber),
547 | progressVolumes: readAction.volumeNumber ? Math.floor(readAction.volumeNumber) : undefined
548 | }
549 | }
550 | } else {
551 | console.log(`Action failed: ${response.data}`)
552 | await actionQueue.retryChapterReadAction(readAction)
553 | }
554 |
555 | } catch (error) {
556 | console.log(error)
557 | await actionQueue.retryChapterReadAction(readAction)
558 | }
559 | }
560 | }
561 |
562 | // Utility
563 | scoreFormatLimit(format: AnilistUser.ScoreFormat): number | undefined {
564 | const extracted = /\d+/gi.exec(format)?.[0]
565 | return extracted != null ? Number(extracted) : undefined
566 | }
567 |
568 | formatStatus(value: string | undefined): string {
569 | switch (value) {
570 | case 'CURRENT': return 'Reading'
571 | case 'PLANNING': return 'Planned'
572 | case 'COMPLETED': return 'Completed'
573 | case 'DROPPED': return 'Dropped'
574 | case 'PAUSED': return 'On-Hold'
575 | case 'REPEATING': return 'Re-Reading'
576 |
577 | case 'FINISHED': return 'Finished'
578 | case 'RELEASING': return 'Releasing'
579 | case 'NOT_YET_RELEASED': return 'Not Yet Released'
580 | case 'CANCELLED': return 'Cancelled'
581 | case 'HIATUS': return 'Hiatus'
582 |
583 | case 'NONE': return 'None'
584 | default: return 'N/A'
585 | }
586 | }
587 | }
588 |
--------------------------------------------------------------------------------
/src/Anilist/includes/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Paperback-iOS/extensions/89853d7f63328744a67d5ddde8b548671e6f804d/src/Anilist/includes/icon.png
--------------------------------------------------------------------------------
/src/Anilist/models/anilist-manga.ts:
--------------------------------------------------------------------------------
1 | export interface Result {
2 | Media: Media;
3 | }
4 |
5 | export interface Media {
6 | id?: number;
7 | description?: string;
8 | title?: Title;
9 | coverImage?: CoverImage;
10 | bannerImage?: string;
11 | averageScore?: number;
12 | isAdult?: boolean;
13 | popularity?: number;
14 | characters?: Characters;
15 | staff?: Staff;
16 | status?: string;
17 | mediaListEntry?: MediaListEntry;
18 | }
19 |
20 | export interface Characters {
21 | edges?: CharactersEdge[];
22 | }
23 |
24 | export interface CharactersEdge {
25 | node?: CharacterNode;
26 | name?: null;
27 | role?: Role;
28 | }
29 |
30 | export interface CharacterNode {
31 | image?: Image;
32 | age?: null | string;
33 | }
34 |
35 | export interface Image {
36 | large?: string;
37 | }
38 |
39 | export enum Role {
40 | Background = 'BACKGROUND',
41 | Main = 'MAIN',
42 | Supporting = 'SUPPORTING'
43 | }
44 |
45 | export interface CoverImage {
46 | extraLarge?: string;
47 | }
48 |
49 | export interface MediaListEntry {
50 | id?: number;
51 | status?: string;
52 | progress?: number;
53 | progressVolumes?: number;
54 | repeat?: number;
55 | private?: boolean;
56 | hiddenFromStatusLists?: boolean;
57 | score?: number;
58 | notes?: null;
59 | }
60 |
61 | export interface Staff {
62 | edges?: StaffEdge[];
63 | }
64 |
65 | export interface StaffEdge {
66 | node?: StaffNode;
67 | role?: string;
68 | }
69 |
70 | export interface StaffNode {
71 | name?: Name;
72 | image?: Image;
73 | }
74 |
75 | export interface Name {
76 | full?: string;
77 | }
78 |
79 | export interface Title {
80 | romaji?: string;
81 | english?: string;
82 | native?: string;
83 | userPreferred?: string;
84 | }
85 |
--------------------------------------------------------------------------------
/src/Anilist/models/anilist-page.ts:
--------------------------------------------------------------------------------
1 | export interface Result {
2 | Page: Page;
3 | }
4 |
5 | export interface Page {
6 | pageInfo: PageInfo;
7 | media: Media[];
8 | }
9 |
10 | export interface Media {
11 | id: number;
12 | title: Title;
13 | coverImage: CoverImage;
14 | }
15 |
16 | export interface CoverImage {
17 | large: string;
18 | }
19 |
20 | export interface Title {
21 | userPreferred: string;
22 | }
23 |
24 | export interface PageInfo {
25 | total: number;
26 | currentPage: number;
27 | lastPage: number;
28 | hasNextPage: boolean;
29 | perPage: number;
30 | }
31 |
--------------------------------------------------------------------------------
/src/Anilist/models/anilist-result.ts:
--------------------------------------------------------------------------------
1 | export function AnilistResult(json: string | unknown): AnilistResult {
2 | const result: AnilistResult = typeof json == 'string' ? JSON.parse(json) : json
3 |
4 | if (result.errors?.length ?? 0 > 0) {
5 | result.errors?.map(error => {
6 | console.log(`[ANILIST-ERROR(${error.status})] ${error.message}`)
7 | })
8 | throw new Error('Error while fetching data from Anilist, check logs for more info')
9 | }
10 |
11 | return result
12 | }
13 |
14 | interface AnilistResult {
15 | data?: Data;
16 | errors?: AnilistError[];
17 | }
18 | interface AnilistError {
19 | message: string;
20 | status: number;
21 | }
22 |
--------------------------------------------------------------------------------
/src/Anilist/models/anilist-user.ts:
--------------------------------------------------------------------------------
1 | export interface Result {
2 | Viewer: Viewer
3 | }
4 |
5 | export interface Viewer {
6 | id?: number;
7 | name?: string;
8 | options?: Options;
9 | mediaListOptions?: MediaListOptions;
10 | avatar?: Avatar;
11 | }
12 |
13 | export interface Avatar {
14 | large?: string;
15 | }
16 |
17 | export interface MediaListOptions {
18 | scoreFormat?: ScoreFormat;
19 | }
20 |
21 | export type ScoreFormat = 'POINT_100'
22 | | 'POINT_10_DECIMAL'
23 | | 'POINT_10'
24 | | 'POINT_5'
25 | | 'POINT_3'
26 |
27 | export interface Options {
28 | displayAdultContent?: boolean;
29 | }
30 |
--------------------------------------------------------------------------------
/src/Anilist/models/graphql-queries.ts:
--------------------------------------------------------------------------------
1 | export interface GraphQLQuery {
2 | query: string
3 | variables?: unknown
4 | }
5 |
6 | export const userProfileQuery = (): GraphQLQuery => ({
7 | query: `{
8 | Viewer {
9 | id
10 | name
11 | avatar {
12 | large
13 | }
14 | mediaListOptions {
15 | scoreFormat
16 | }
17 | siteUrl
18 | }
19 | }`
20 | })
21 |
22 | export const searchMangaQuery = (page: number, search: string): GraphQLQuery => ({
23 | query: `query($page: Int, $search: String) {
24 | Page(page: $page) {
25 | pageInfo {
26 | currentPage
27 | hasNextPage
28 | }
29 | media(type: MANGA, search: $search, format_not: NOVEL) {
30 | id
31 | title {
32 | userPreferred
33 | }
34 | coverImage {
35 | large
36 | }
37 | }
38 | }
39 | }`,
40 | variables: {
41 | page,
42 | search
43 | }
44 | })
45 |
46 | export const getMangaQuery = (id: number): GraphQLQuery => ({
47 | query: `query($id: Int){
48 | Media(id: $id){
49 | id
50 | description(asHtml: false)
51 | title {
52 | romaji
53 | english
54 | native
55 | }
56 | coverImage{
57 | extraLarge
58 | }
59 | bannerImage
60 | averageScore
61 | isAdult
62 | popularity
63 | characters(sort: RELEVANCE, perPage: 25) {
64 | edges {
65 | node {
66 | image {
67 | large
68 | }
69 | age
70 | }
71 | name
72 | role
73 | }
74 | }
75 | staff {
76 | edges {
77 | node {
78 | name {
79 | full
80 | }
81 | image {
82 | large
83 | }
84 | }
85 | role
86 | }
87 | }
88 | status
89 | }
90 | }`,
91 | variables: {
92 | id
93 | }
94 | })
95 |
96 | export const getMangaProgressQuery = (id: number): GraphQLQuery => ({
97 | query: `query($id: Int) {
98 | Media(id: $id) {
99 | id
100 | mediaListEntry {
101 | id
102 | status
103 | progress
104 | progressVolumes
105 | repeat
106 | private
107 | hiddenFromStatusLists
108 | score
109 | notes
110 | }
111 | title {
112 | romaji
113 | english
114 | native
115 | userPreferred
116 | }
117 | coverImage {
118 | extraLarge
119 | }
120 | bannerImage
121 | averageScore
122 | isAdult
123 | popularity
124 | status
125 | }
126 | }`,
127 | variables: {
128 | id
129 | }
130 | })
131 |
132 | export interface SaveMangaProgressVariables {
133 | id?: number;
134 | mediaId?: number | string;
135 | status?: string;
136 | score?: number;
137 | private?: boolean;
138 | hiddenFromStatusLists?: boolean;
139 | progress?: number;
140 | progressVolumes?: number;
141 | repeat?: number,
142 | notes?: string;
143 | }
144 |
145 | export const saveMangaProgressMutation = (variables: SaveMangaProgressVariables): GraphQLQuery => ({
146 | query: `mutation($id: Int, $mediaId: Int, $status: MediaListStatus, $score: Float, $progress: Int, $progressVolumes: Int, $repeat: Int, $notes: String, $private: Boolean, $hiddenFromStatusLists: Boolean) {
147 | SaveMediaListEntry(id: $id, mediaId: $mediaId, status: $status, score: $score, progress: $progress, progressVolumes: $progressVolumes, repeat: $repeat, notes: $notes, private: $private, hiddenFromStatusLists: $hiddenFromStatusLists){
148 | id
149 | }
150 | }`,
151 | variables: variables
152 | })
153 |
154 | export const deleteMangaProgressMutation = (id: number): GraphQLQuery => ({
155 | query: `mutation($id: Int) {
156 | DeleteMediaListEntry(id: $id){
157 | deleted
158 | }
159 | }`,
160 | variables: {
161 | id
162 | }
163 | })
164 |
--------------------------------------------------------------------------------
/src/MangaUpdates/MangaUpdates.ts:
--------------------------------------------------------------------------------
1 | import {
2 | SourceInfo,
3 | SourceManga,
4 | SearchRequest,
5 | PagedResults,
6 | TrackerActionQueue,
7 | TrackedMangaChapterReadAction,
8 | ContentRating,
9 | Searchable,
10 | MangaProgressProviding,
11 | SourceIntents,
12 | MangaProgress,
13 | DUIForm,
14 | DUISection,
15 | } from '@paperback/types'
16 |
17 | import {
18 | Credentials,
19 | validateCredentials,
20 | getUserCredentials,
21 | setUserCredentials,
22 | clearUserCredentials,
23 | getSessionToken,
24 | setSessionToken,
25 | clearSessionToken,
26 | getLoginTime,
27 | loggableRequest,
28 | loggableResponse,
29 | } from './utils/mu-session'
30 | import {
31 | parseMangaInfo,
32 | assertNotLegacyMangaId,
33 | MangaInfoInput
34 | } from './utils/mu-manga'
35 | import { parseSearchResults } from './utils/mu-search'
36 | import type {
37 | Endpoint,
38 | Verb,
39 | Request,
40 | Response,
41 | BaseRequest
42 | } from './models'
43 | import type { MUListsSeriesModelUpdateV1 } from './models/mu-api'
44 |
45 | interface MangaFormValues {
46 | listId: [string]
47 | chapterProgress: number
48 | volumeProgress: number
49 | userRating: number
50 | }
51 |
52 | interface ParsedAction {
53 | action: TrackedMangaChapterReadAction
54 | isUpdate: boolean
55 | payload: MUListsSeriesModelUpdateV1
56 | }
57 |
58 | const FALLBACK_PROFILE_IMAGE = 'https://cdn.mangaupdates.com/avatar/a0.gif'
59 |
60 | const DEFAULT_LIST_ID = 0 // Reading List
61 |
62 | export const MangaUpdatesInfo: SourceInfo = {
63 | name: 'MangaUpdates',
64 | author: 'IntermittentlyRupert',
65 | contentRating: ContentRating.EVERYONE,
66 | icon: 'icon.png',
67 | version: '3.0.0',
68 | description: 'MangaUpdates Tracker',
69 | websiteBaseURL: 'https://www.mangaupdates.com',
70 | intents: SourceIntents.MANGA_TRACKING | SourceIntents.SETTINGS_UI,
71 | }
72 |
73 | export class MangaUpdates implements Searchable, MangaProgressProviding {
74 | stateManager = App.createSourceStateManager()
75 |
76 | requestManager = App.createRequestManager({
77 | requestsPerSecond: 5,
78 | requestTimeout: 10000,
79 | })
80 |
81 | ////////////////////
82 | // Public API
83 | ////////////////////
84 |
85 | async getMangaDetails(mangaId: string): Promise {
86 | const logPrefix = '[getMangaDetails]'
87 | console.log(`${logPrefix} starts`)
88 | try {
89 | console.log(`${logPrefix} loading id=${mangaId}`)
90 |
91 | const mangaInfo = await this.getMangaInfo(mangaId)
92 | const trackedManga = App.createSourceManga({
93 | id: mangaId,
94 | mangaInfo: App.createMangaInfo(mangaInfo),
95 | })
96 |
97 | console.log(`${logPrefix} complete`)
98 | return trackedManga
99 | } catch (e) {
100 | console.log(`${logPrefix} error`)
101 | console.log(e)
102 | throw e
103 | }
104 | }
105 |
106 | async getMangaProgress(mangaId: string): Promise {
107 | const logPrefix = '[getMangaProgress]'
108 | console.log(`${logPrefix} starts`)
109 | try {
110 | console.log(`${logPrefix} loading id=${mangaId}`)
111 |
112 | assertNotLegacyMangaId(mangaId)
113 |
114 | const [progressInfo, ratingInfo, mangaLists] = await Promise.all([
115 | this.request('/v1/lists/series/{seriesId}', 'GET', { params: { seriesId: mangaId }, query: {} }, false),
116 | this.request('/v1/series/{id}/rating', 'GET', { params: { id: mangaId } }, false),
117 | this.request('/v1/lists', 'GET', {}),
118 | ])
119 |
120 | if (progressInfo?.list_id == null) {
121 | console.log(`${logPrefix} no progress to return`)
122 | return undefined
123 | }
124 |
125 | const progress = App.createMangaProgress({
126 | mangaId: mangaId,
127 |
128 | lastReadChapterNumber: progressInfo.status?.chapter ?? 0,
129 | lastReadVolumeNumber: progressInfo.status?.volume ?? 0,
130 |
131 | trackedListName: mangaLists.find((list) => list.list_id === progressInfo.list_id)?.title ?? 'None',
132 | userRating: ratingInfo?.rating ?? 0,
133 | })
134 |
135 | console.log(`${logPrefix} complete`)
136 | return progress
137 | } catch (e) {
138 | console.log(`${logPrefix} error`)
139 | console.log(e)
140 | throw e
141 | }
142 | }
143 |
144 | async getMangaProgressManagementForm(mangaId: string): Promise {
145 | let isInList = false
146 |
147 | return App.createDUIForm({
148 | sections: async () => {
149 | try {
150 | assertNotLegacyMangaId(mangaId)
151 |
152 | const username = (await getUserCredentials(this.stateManager))?.username
153 | if (!username) {
154 | return [
155 | App.createDUISection({
156 | id: 'notLoggedInSection',
157 | isHidden: false,
158 | rows: () =>
159 | Promise.resolve([
160 | App.createDUILabel({
161 | id: 'notLoggedIn',
162 | label: 'Not Logged In',
163 | value: undefined,
164 | }),
165 | ]),
166 | }),
167 | ]
168 | }
169 |
170 | const [userProfile, mangaInfo, mangaLists, progressInfo, ratingInfo] = await Promise.all([
171 | this.request('/v1/account/profile', 'GET', {}),
172 | this.getMangaInfo(mangaId),
173 | this.request('/v1/lists', 'GET', {}),
174 | this.request(
175 | '/v1/lists/series/{seriesId}',
176 | 'GET',
177 | { params: { seriesId: mangaId }, query: {} },
178 | false,
179 | ),
180 | this.request('/v1/series/{id}/rating', 'GET', { params: { id: mangaId } }, false),
181 | ])
182 |
183 | const listNamesById = Object.fromEntries([
184 | ...mangaLists
185 | .filter((list) => list.list_id != undefined && list.title != undefined)
186 | .map((list) => [String(list.list_id), list.title || '']),
187 | ['-1', 'None'],
188 | ])
189 | const listOptions = Object.keys(listNamesById)
190 |
191 | isInList = progressInfo != null
192 |
193 | const listId = String(progressInfo?.list_id ?? -1)
194 | const chapterProgress = progressInfo?.status?.chapter ?? 0
195 | const volumeProgress = progressInfo?.status?.volume ?? 0
196 |
197 | const userRating = ratingInfo?.rating ?? 0
198 |
199 | return [
200 | App.createDUISection({
201 | id: 'userInfo',
202 | isHidden: false,
203 | rows: () =>
204 | Promise.resolve([
205 | App.createDUIHeader({
206 | id: 'header',
207 | imageUrl: userProfile.avatar?.url || FALLBACK_PROFILE_IMAGE,
208 | title: username,
209 | subtitle: '',
210 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
211 | //@ts-ignore also accepts a raw value, not just a DUIBinding
212 | value: undefined,
213 | }),
214 | ]),
215 | }),
216 | App.createDUISection({
217 | id: 'information',
218 | header: 'Information',
219 | isHidden: false,
220 | rows: () =>
221 | Promise.resolve([
222 | App.createDUILabel({
223 | id: 'mangaId',
224 | label: 'Manga ID',
225 | value: mangaId,
226 | }),
227 |
228 | App.createDUILabel({
229 | id: 'mangaTitle',
230 | label: 'Title',
231 | value: mangaInfo.titles[0],
232 | }),
233 | App.createDUILabel({
234 | id: 'mangaRating',
235 | value: mangaInfo.rating?.toString() ?? 'N/A',
236 | label: 'Rating',
237 | }),
238 | App.createDUILabel({
239 | id: 'mangaStatus',
240 | value: mangaInfo.status.toString(),
241 | label: 'Status',
242 | }),
243 | App.createDUILabel({
244 | id: 'mangaIsAdult',
245 | value: mangaInfo.hentai?.toString() ?? 'N/A',
246 | label: 'Is Adult',
247 | }),
248 | ]),
249 | }),
250 | App.createDUISection({
251 | id: 'trackList',
252 | header: 'Manga List',
253 | footer: 'Warning: Setting this to "None" will delete the listing from MangaUpdates',
254 | isHidden: false,
255 | rows: async () => [
256 | App.createDUISelect({
257 | id: 'listId',
258 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
259 | //@ts-ignore also accepts a raw value, not just a DUIBinding
260 | value: [listId],
261 | allowsMultiselect: false,
262 | label: 'List',
263 | displayLabel: (value: string) => listNamesById[value] || '',
264 | options: listOptions,
265 | }),
266 | ],
267 | }),
268 | App.createDUISection({
269 | id: 'manage',
270 | header: 'Progress',
271 | isHidden: false,
272 | rows: () =>
273 | Promise.resolve([
274 | App.createDUIStepper({
275 | id: 'chapterProgress',
276 | label: 'Chapter',
277 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
278 | //@ts-ignore also accepts a raw value, not just a DUIBinding
279 | value: chapterProgress,
280 | min: 0,
281 | step: 1,
282 | }),
283 | App.createDUIStepper({
284 | id: 'volumeProgress',
285 | label: 'Volume',
286 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
287 | //@ts-ignore also accepts a raw value, not just a DUIBinding
288 | value: volumeProgress,
289 | min: 0,
290 | step: 1,
291 | }),
292 | ]),
293 | }),
294 | App.createDUISection({
295 | id: 'rating',
296 | header: 'User Rating',
297 | footer: 'Warning: Setting this to 0 will delete the rating from MangaUpdates',
298 | isHidden: false,
299 | rows: () =>
300 | Promise.resolve([
301 | App.createDUIStepper({
302 | id: 'userRating',
303 | label: 'My Rating',
304 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
305 | //@ts-ignore also accepts a raw value, not just a DUIBinding
306 | value: Math.round(userRating),
307 | min: 0,
308 | max: 10,
309 | step: 1,
310 | }),
311 | ]),
312 | }),
313 | ]
314 | } catch (e) {
315 | console.log('[getMangaForm] failed to render manga form')
316 | console.log(e)
317 | return [
318 | App.createDUISection({
319 | id: 'errorInfo',
320 | isHidden: false,
321 | rows: () =>
322 | Promise.resolve([
323 | App.createDUILabel({
324 | id: 'errorMessage',
325 | label: String(e),
326 | value: undefined,
327 | }),
328 | ]),
329 | }),
330 | ]
331 | }
332 | },
333 | onSubmit: (values) => this.handleMangaFormChanges(mangaId, isInList, values as MangaFormValues),
334 | })
335 | }
336 |
337 | async getSourceMenu(): Promise {
338 | return App.createDUISection({
339 | id: 'sourceMenu',
340 | isHidden: false,
341 | rows: async () => {
342 | const [credentials, sessionToken] = await Promise.all([
343 | getUserCredentials(this.stateManager),
344 | getSessionToken(this.stateManager),
345 | ])
346 |
347 | if (credentials?.username) {
348 | return [
349 | App.createDUILabel({
350 | id: 'userInfo',
351 | label: 'Logged-in as',
352 | value: credentials.username,
353 | }),
354 | App.createDUILabel({
355 | id: 'loginTime',
356 | label: 'Session started at',
357 | value: getLoginTime(sessionToken),
358 | }),
359 | App.createDUIButton({
360 | id: 'refresh',
361 | label: 'Refresh session',
362 | onTap: async () => this.refreshSession(),
363 | }),
364 | App.createDUIButton({
365 | id: 'logout',
366 | label: 'Logout',
367 | onTap: async () => this.logout(),
368 | }),
369 | ]
370 | }
371 |
372 | return [
373 | App.createDUINavigationButton({
374 | id: 'loginButton',
375 | label: 'Login',
376 | form: App.createDUIForm({
377 | sections: async () => [
378 | App.createDUISection({
379 | id: 'usernameSection',
380 | header: 'Username',
381 | footer: 'Enter your MangaUpdates account username',
382 | isHidden: false,
383 | rows: () =>
384 | Promise.resolve([
385 | App.createDUIInputField({
386 | id: 'username',
387 | placeholder: 'Username',
388 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
389 | //@ts-ignore also accepts a raw value, not just a DUIBinding
390 | value: '',
391 | maskInput: false,
392 | }),
393 | ]),
394 | }),
395 | App.createDUISection({
396 | id: 'passwordSection',
397 | header: 'Password',
398 | footer: 'Enter the password associated with your MangaUpdates account Username',
399 | isHidden: false,
400 | rows: () =>
401 | Promise.resolve([
402 | App.createDUIInputField({
403 | id: 'password',
404 | placeholder: 'Password',
405 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
406 | //@ts-ignore also accepts a raw value, not just a DUIBinding
407 | value: '',
408 | maskInput: true,
409 | }),
410 | ]),
411 | }),
412 | ],
413 | onSubmit: (values) => this.login(values as Credentials),
414 | }),
415 | }),
416 | ]
417 | },
418 | })
419 | }
420 |
421 | async getSearchResults(query: SearchRequest, metadata: unknown): Promise {
422 | const logPrefix = '[getSearchResults]'
423 | console.log(`${logPrefix} starts`)
424 | try {
425 | const search = query.title || ''
426 | const page = (metadata as { nextPage?: number } | undefined)?.nextPage ?? 1
427 | const perpage = 25
428 |
429 | // MangaUpdates will return an error for empty search strings
430 | if (!search || page < 1) {
431 | console.log(`${logPrefix} no need to search: ${JSON.stringify({ search, page })}`)
432 | return App.createPagedResults({ results: [], metadata: { nextPage: -1 } })
433 | }
434 |
435 | console.log(`${logPrefix} searching for "${search}" (page=${page})`)
436 | const response = await this.request('/v1/series/search', 'POST', {
437 | body: {
438 | search,
439 | page,
440 | perpage,
441 | },
442 | })
443 | const results = parseSearchResults(response.results || [])
444 |
445 | const hasNextPage = page * perpage < (response.total_hits ?? 0)
446 | const nextPage = hasNextPage ? page + 1 : -1
447 |
448 | console.log(`${logPrefix} got results: ${JSON.stringify({ results, nextPage })}`)
449 |
450 | const pagedResults = App.createPagedResults({
451 | results: results.map((result) => App.createPartialSourceManga(result)),
452 | metadata: { nextPage },
453 | })
454 |
455 | console.log(`${logPrefix} complete`)
456 | return pagedResults
457 | } catch (e) {
458 | console.log(`${logPrefix} error`)
459 | console.log(e)
460 | throw e
461 | }
462 | }
463 |
464 | async processChapterReadActionQueue(actionQueue: TrackerActionQueue): Promise {
465 | const logPrefix = '[processChapterReadActionQueue]'
466 | console.log(`${logPrefix} starts`)
467 |
468 | const chapterReadActions = await actionQueue.queuedChapterReadActions()
469 | console.log(`${logPrefix} found ${chapterReadActions.length} action(s)`)
470 |
471 | const operations = await Promise.all(chapterReadActions.map((action) => this.parseAction(action)))
472 |
473 | // Apply the operations in bulk (MU has a ~5 second rate limit for these
474 | // APIs).
475 | //
476 | // There will almost always be 0 or 1 queued action so I'm not super
477 | // fussed about maximally parallelising this.
478 |
479 | const listUpdates = operations.filter((operation) => operation.isUpdate)
480 | if (listUpdates.length > 0) {
481 | try {
482 | const updateBody = listUpdates.map(({ payload }) => payload)
483 | console.log(`${logPrefix} applying list updates: ${JSON.stringify(updateBody)}`)
484 | await this.request('/v1/lists/series/update', 'POST', { body: updateBody })
485 | await Promise.all(listUpdates.map(({ action }) => actionQueue.discardChapterReadAction(action)))
486 | } catch (e) {
487 | console.log(`${logPrefix} list updates failed`)
488 | console.log(e)
489 | await Promise.all(listUpdates.map(({ action }) => actionQueue.retryChapterReadAction(action)))
490 | }
491 | }
492 |
493 | const listAdditions = operations.filter((operation) => !operation.isUpdate)
494 | if (listAdditions.length > 0) {
495 | try {
496 | const additionBody = listAdditions.map(({ payload }) => payload)
497 | console.log(`${logPrefix} applying list additions: ${JSON.stringify(additionBody)}`)
498 | await this.request('/v1/lists/series', 'POST', { body: additionBody })
499 | await Promise.all(listAdditions.map(({ action }) => actionQueue.discardChapterReadAction(action)))
500 | } catch (e) {
501 | console.log(`${logPrefix} list additions failed`)
502 | console.log(e)
503 | await Promise.all(listAdditions.map(({ action }) => actionQueue.retryChapterReadAction(action)))
504 | }
505 | }
506 |
507 | console.log(`${logPrefix} complete`)
508 | }
509 |
510 | ////////////////////
511 | // Session Management
512 | ////////////////////
513 |
514 | private async login(credentials: Credentials): Promise {
515 | const logPrefix = '[login]'
516 | console.log(`${logPrefix} starts`)
517 |
518 | if (!validateCredentials(credentials)) {
519 | console.error(`${logPrefix} login called with invalid credentials: ${JSON.stringify(credentials)}`)
520 | throw new Error('Must provide a username and password!')
521 | }
522 |
523 | try {
524 | const result = await this.request('/v1/account/login', 'PUT', {
525 | body: credentials,
526 | })
527 | const sessionToken = result.context?.session_token
528 | if (!sessionToken) {
529 | console.log(`${logPrefix} no session token on response: ${JSON.stringify(result)}`)
530 | throw new Error('no session token on response')
531 | }
532 |
533 | await Promise.all([
534 | setUserCredentials(this.stateManager, credentials),
535 | setSessionToken(this.stateManager, sessionToken),
536 | ])
537 |
538 | console.log(`${logPrefix} complete`)
539 | } catch (e) {
540 | console.log(`${logPrefix} failed to log in`)
541 | console.log(e)
542 | throw new Error('Login failed!')
543 | }
544 | }
545 |
546 | private async refreshSession(): Promise {
547 | const logPrefix = '[refreshSession]'
548 | console.log(`${logPrefix} starts`)
549 |
550 | const credentials = await getUserCredentials(this.stateManager)
551 | if (!credentials) {
552 | console.log(`${logPrefix} no credentials available, unable to refresh`)
553 | throw new Error('Could not find login credentials!')
554 | }
555 |
556 | await this.logout()
557 | await this.login(credentials)
558 |
559 | console.log(`${logPrefix} complete`)
560 | }
561 |
562 | private async logout(): Promise {
563 | try {
564 | await this.request('/v1/account/logout', 'POST', {})
565 | } catch (e) {
566 | console.log('[logout] failed to delete session token')
567 | console.log(e)
568 | }
569 |
570 | await Promise.all([clearUserCredentials(this.stateManager), clearSessionToken(this.stateManager)])
571 | }
572 |
573 | ////////////////////
574 | // Request Handlers
575 | ////////////////////
576 |
577 | private async getMangaInfo(canonicalId: string): Promise {
578 | const logPrefix = '[getMangaInfo]'
579 | console.log(`${logPrefix} start: ${canonicalId}`)
580 |
581 | const series = await this.request('/v1/series/{id}', 'GET', {
582 | params: { id: canonicalId },
583 | query: {},
584 | })
585 |
586 | const mangaInfo = parseMangaInfo(series)
587 |
588 | console.log(`${logPrefix} complete: ${JSON.stringify(mangaInfo)}`)
589 |
590 | return mangaInfo
591 | }
592 |
593 | private async handleMangaFormChanges(mangaId: string, isInList: boolean, values: MangaFormValues): Promise {
594 | const logPrefix = '[handleMangaFormChanges]'
595 | console.log(`${logPrefix} starts: ${JSON.stringify(values)}`)
596 |
597 | try {
598 | const numericId = parseInt(mangaId)
599 | const shouldDelete = values.listId[0] === '-1'
600 |
601 | const actions: Promise[] = []
602 |
603 | if (shouldDelete) {
604 | console.log(`${logPrefix} deleting from list`)
605 | actions.push(
606 | this.request('/v1/lists/series/delete', 'POST', {
607 | body: [numericId],
608 | }),
609 | )
610 | } else {
611 | console.log(`${logPrefix} updating in list`)
612 | actions.push(
613 | this.request(isInList ? '/v1/lists/series/update' : '/v1/lists/series', 'POST', {
614 | body: [
615 | {
616 | series: { id: numericId },
617 | list_id: parseInt(values.listId[0]),
618 | status: {
619 | volume: values.volumeProgress,
620 | chapter: values.chapterProgress,
621 | },
622 | },
623 | ],
624 | }),
625 | )
626 | }
627 |
628 | if (values.userRating > 0) {
629 | actions.push(
630 | this.request('/v1/series/{id}/rating', 'PUT', {
631 | params: { id: mangaId },
632 | body: { rating: values.userRating },
633 | }),
634 | )
635 | } else {
636 | actions.push(
637 | this.request('/v1/series/{id}/rating', 'DELETE', {
638 | params: { id: mangaId },
639 | }),
640 | )
641 | }
642 |
643 | await Promise.all(actions)
644 | console.log(`${logPrefix} complete`)
645 | } catch (e) {
646 | console.log(`${logPrefix} failed`)
647 | console.log(e)
648 | throw e
649 | }
650 | }
651 |
652 | private async parseAction(action: TrackedMangaChapterReadAction): Promise {
653 | const listInfo = await this.request(
654 | '/v1/lists/series/{seriesId}',
655 | 'GET',
656 | {
657 | params: { seriesId: action.mangaId },
658 | query: {},
659 | },
660 | false,
661 | )
662 |
663 | return {
664 | action,
665 | isUpdate: !!listInfo,
666 | payload: {
667 | series: { id: parseInt(action.mangaId) },
668 | list_id: listInfo?.list_id ?? DEFAULT_LIST_ID,
669 | status: {
670 | volume: Math.floor(action.volumeNumber) || 1,
671 | chapter: Math.floor(action.chapterNumber) || 1,
672 | },
673 | },
674 | }
675 | }
676 |
677 | ////////////////////
678 | // API Request
679 | ////////////////////
680 |
681 | private async getAuthHeader(): Promise {
682 | const existingSessionToken = await getSessionToken(this.stateManager)
683 | if (existingSessionToken) {
684 | return `Bearer ${existingSessionToken}`
685 | }
686 |
687 | // If this is the user's first request after upgrading to v2 they may
688 | // have credentials but no API session token.
689 | const credentials = await getUserCredentials(this.stateManager)
690 | if (credentials) {
691 | await this.login(credentials)
692 | const newSessionToken = await getSessionToken(this.stateManager)
693 | if (newSessionToken) {
694 | return `Bearer ${newSessionToken}`
695 | }
696 | }
697 |
698 | throw new Error('You must be logged in!')
699 | }
700 |
701 | /** Will **resolve to undefined** if the response has a non-2xx status. */
702 | private async request>(
703 | endpoint: E,
704 | verb: V,
705 | request: Request,
706 | failOnErrorStatus: false,
707 | retryCount?: number,
708 | ): Promise | undefined>
709 | /** Will **reject** if the response has a non-2xx status. */
710 | private async request>(
711 | endpoint: E,
712 | verb: V,
713 | request: Request,
714 | failOnErrorStatus?: boolean,
715 | retryCount?: number,
716 | ): Promise>
717 | /** Will **reject** if the response has a non-2xx status. */
718 | private async request>(
719 | endpoint: E,
720 | verb: V,
721 | request: Request,
722 | failOnErrorStatus = true,
723 | retryCount = 1,
724 | ): Promise> {
725 | const logPrefix = `[request] ${verb} ${endpoint}`
726 | const isLogin = endpoint === '/v1/account/login'
727 | const baseRequest: Partial = request
728 |
729 | console.log(
730 | `${logPrefix} starts (failOnErrorStatus=${failOnErrorStatus}, retryCount=${retryCount}): ${loggableRequest(
731 | baseRequest,
732 | )}`,
733 | )
734 |
735 | const path = Object.entries(baseRequest.params || {})
736 | .filter((entry) => entry[1] != undefined)
737 | .map(([name, value]) => [`{${name}}`, String(value)] as const)
738 | .reduce((partialPath, [token, value]) => {
739 | if (!partialPath.includes(token)) {
740 | console.log(`${logPrefix} endpoint '${endpoint}' does not contain ${token}!`)
741 | throw new Error('endpoint is missing path parameter')
742 | }
743 | return endpoint.replace(token, String(value))
744 | }, endpoint as string)
745 |
746 | const query = Object.entries(baseRequest.query || {})
747 | .filter((entry) => entry[1] != undefined)
748 | .map(([name, value]) => `${name}=${encodeURIComponent(String(value))}`)
749 | .join('&')
750 |
751 | const headers: Record = {
752 | accept: 'application/json',
753 | }
754 | if (baseRequest.body) {
755 | headers['content-type'] = 'application/json'
756 | }
757 | if (!isLogin) {
758 | headers.authorization = await this.getAuthHeader()
759 | }
760 |
761 | const start = Date.now()
762 | const response = await this.requestManager.schedule(
763 | App.createRequest({
764 | url: `https://api.mangaupdates.com${path}`,
765 | method: verb,
766 | param: query,
767 | data: baseRequest.body ? JSON.stringify(baseRequest.body) : undefined,
768 | headers,
769 | }),
770 | retryCount,
771 | )
772 | const duration = Date.now() - start
773 |
774 | const responseBody = response.data ? JSON.parse(response.data) : undefined
775 | console.log(
776 | `${logPrefix} response: (HTTP ${response.status}, ${duration}ms): ${loggableResponse(responseBody)}`,
777 | )
778 |
779 | const ok = response.status >= 200 && response.status < 300
780 | if (failOnErrorStatus && !ok) {
781 | console.log(`${logPrefix} failed`)
782 | throw new Error('Request failed!')
783 | }
784 |
785 | console.log(`${logPrefix} complete`)
786 | return responseBody
787 | }
788 | }
789 |
--------------------------------------------------------------------------------
/src/MangaUpdates/includes/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Paperback-iOS/extensions/89853d7f63328744a67d5ddde8b548671e6f804d/src/MangaUpdates/includes/icon.png
--------------------------------------------------------------------------------
/src/MangaUpdates/models/README.md:
--------------------------------------------------------------------------------
1 | # MangaUpdates OpenAPI Types
2 |
3 | This folder contains auto-generated types for the [MangaUpdates API](https://api.mangaupdates.com/), as well as
4 | some helper types.
5 |
6 | > **WARNING:**
7 | >
8 | > Do not modify the auto-generated files manually!
9 |
10 | ## To re-generate the API types...
11 |
12 | Run the following commands from the root of the repository:
13 |
14 | ```sh
15 | curl -o mu-openapi.yaml https://api.mangaupdates.com/openapi.yaml
16 |
17 | npx swagger-typescript-api@^9.3.1 \
18 | --path mu-openapi.yaml \
19 | --output src/MangaUpdates/models \
20 | --name mu-api.d.ts \
21 | --type-prefix MU \
22 | --route-types \
23 | --no-client
24 |
25 | rm mu-openapi.yaml
26 | ```
27 |
28 | If routes have been added/removed from the API, you may need to manually modify
29 | `index.d.ts` for `MangaUpdates.request` to work correctly.
30 |
--------------------------------------------------------------------------------
/src/MangaUpdates/models/index.d.ts:
--------------------------------------------------------------------------------
1 | import * as API from './mu-api'
2 |
3 | type HttpVerb = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
4 |
5 | type Stringable = string | number | boolean
6 |
7 | export interface BaseRequest {
8 | params: Record
9 | query: Record
10 | body: Record | never
11 | }
12 |
13 | /**
14 | * The MangaUpdates API swagger doesn't define the full structure of the login
15 | * response. This interface contains the rest of the owl.
16 | */
17 | interface EnhancedLoginResponse extends API.Account.Login.ResponseBody {
18 | context?: {
19 | session_token?: string;
20 | uid?: number;
21 | }
22 | }
23 |
24 | interface MangaUpdatesApi {
25 | '/v1/account/login': {
26 | PUT: {
27 | request: {
28 | params: API.Account.Login.RequestParams;
29 | query: API.Account.Login.RequestQuery;
30 | body: API.Account.Login.RequestBody;
31 | };
32 | response: EnhancedLoginResponse;
33 | };
34 | };
35 | '/v1/account/logout': {
36 | POST: {
37 | request: {
38 | params: API.Account.Logout.RequestParams;
39 | query: API.Account.Logout.RequestQuery;
40 | body: API.Account.Logout.RequestBody;
41 | };
42 | response: API.Account.Logout.ResponseBody;
43 | };
44 | };
45 | '/v1/account/profile': {
46 | GET: {
47 | request: {
48 | params: API.Account.Profile.RequestParams;
49 | query: API.Account.Profile.RequestQuery;
50 | body: API.Account.Profile.RequestBody;
51 | };
52 | response: API.Account.Profile.ResponseBody;
53 | }
54 | }
55 |
56 | '/v1/series/{id}': {
57 | GET: {
58 | request: {
59 | params: API.Series.RetrieveSeries.RequestParams;
60 | query: API.Series.RetrieveSeries.RequestQuery;
61 | body: API.Series.RetrieveSeries.RequestBody;
62 | };
63 | response: API.Series.RetrieveSeries.ResponseBody;
64 | }
65 | }
66 | '/v1/series/{id}/rating': {
67 | GET: {
68 | request: {
69 | params: API.Series.RetrieveUserSeriesRating.RequestParams;
70 | query: API.Series.RetrieveUserSeriesRating.RequestQuery;
71 | body: API.Series.RetrieveUserSeriesRating.RequestBody;
72 | };
73 | response: API.Series.RetrieveUserSeriesRating.ResponseBody;
74 | },
75 | PUT: {
76 | request: {
77 | params: API.Series.UpdateUserSeriesRating.RequestParams;
78 | query: API.Series.UpdateUserSeriesRating.RequestQuery;
79 | body: API.Series.UpdateUserSeriesRating.RequestBody;
80 | };
81 | response: API.Series.UpdateUserSeriesRating.ResponseBody;
82 | },
83 | DELETE: {
84 | request: {
85 | params: API.Series.DeleteUserSeriesRating.RequestParams;
86 | query: API.Series.DeleteUserSeriesRating.RequestQuery;
87 | body: API.Series.DeleteUserSeriesRating.RequestBody;
88 | };
89 | response: API.Series.DeleteUserSeriesRating.ResponseBody;
90 | }
91 | }
92 | '/v1/series/search': {
93 | POST: {
94 | request: {
95 | params: API.Series.SearchSeriesPost.RequestParams;
96 | query: API.Series.SearchSeriesPost.RequestQuery;
97 | body: API.Series.SearchSeriesPost.RequestBody;
98 | };
99 | response: API.Series.SearchSeriesPost.ResponseBody;
100 | }
101 |
102 | }
103 |
104 | '/v1/lists': {
105 | GET: {
106 | request: {
107 | params: API.Lists.RetrieveLists.RequestParams;
108 | query: API.Lists.RetrieveLists.RequestQuery;
109 | body: API.Lists.RetrieveLists.RequestBody;
110 | };
111 | response: API.Lists.RetrieveLists.ResponseBody;
112 | }
113 | }
114 | '/v1/lists/series/{seriesId}': {
115 | GET: {
116 | request: {
117 | params: API.Lists.RetrieveListSeries.RequestParams;
118 | query: API.Lists.RetrieveListSeries.RequestQuery;
119 | body: API.Lists.RetrieveListSeries.RequestBody;
120 | };
121 | response: API.Lists.RetrieveListSeries.ResponseBody;
122 | }
123 | }
124 | '/v1/lists/series': {
125 | POST: {
126 | request: {
127 | params: API.Lists.AddListSeries.RequestParams;
128 | query: API.Lists.AddListSeries.RequestQuery;
129 | body: API.Lists.AddListSeries.RequestBody;
130 | };
131 | response: API.Lists.AddListSeries.ResponseBody;
132 | }
133 | }
134 | '/v1/lists/series/update': {
135 | POST: {
136 | request: {
137 | params: API.Lists.UpdateListSeries.RequestParams;
138 | query: API.Lists.UpdateListSeries.RequestQuery;
139 | body: API.Lists.UpdateListSeries.RequestBody;
140 | };
141 | response: API.Lists.UpdateListSeries.ResponseBody;
142 | }
143 | }
144 | '/v1/lists/series/delete': {
145 | POST: {
146 | request: {
147 | params: API.Lists.DeleteListSeries.RequestParams;
148 | query: API.Lists.DeleteListSeries.RequestQuery;
149 | body: API.Lists.DeleteListSeries.RequestBody;
150 | };
151 | response: API.Lists.DeleteListSeries.ResponseBody;
152 | }
153 | }
154 |
155 | // ... add other endpoints here as needed ...
156 | }
157 |
158 | type IsNonEmpty =
159 | [Obj] extends [never]
160 | ? false
161 | : Obj extends unknown[]
162 | ? true
163 | : Required extends Record
164 | ? [keyof Obj] extends [never]
165 | ? false
166 | : true
167 | : false
168 |
169 | type FilterRequestFields = {
170 | [Key in keyof R]: IsNonEmpty extends true ? Key : never
171 | }
172 |
173 | type NonEmptyRequestFields = Pick[keyof R]>
174 |
175 | type PermitStringValues = { [Key in keyof T]: T[Key] | string }
176 |
177 | interface MungeApiTypes {
178 | // allow pre-stringified path/query params
179 | params: PermitStringValues;
180 | query: PermitStringValues;
181 |
182 | body: T['body'];
183 | }
184 |
185 | export type Endpoint = keyof MangaUpdatesApi
186 |
187 | export type Verb = Extract
188 |
189 | export type Request> = NonEmptyRequestFields>
190 |
191 | export type Response> = MangaUpdatesApi[E][V]['response']
192 |
--------------------------------------------------------------------------------
/src/MangaUpdates/utils/mu-manga.ts:
--------------------------------------------------------------------------------
1 | import type { MUSeriesModelV1 } from '../models/mu-api'
2 |
3 | export type MangaInfoInput = Parameters[0]
4 |
5 | const HTML_ENTITIES = {
6 | ' ': ' ',
7 | '¢': '¢',
8 | '£': '£',
9 | '¥': '¥',
10 | '€': '€',
11 | '©': '©',
12 | '®': '®',
13 | '<': '<',
14 | '>': '>',
15 | '"': '"',
16 | '&': '&',
17 | ''': '\'',
18 | }
19 |
20 | const IS_HENTAI_GENRE: Record = {
21 | Adult: true,
22 | Hentai: true,
23 | Smut: true,
24 | }
25 |
26 | function parseStatus(status: string): 'ONGOING' | 'ABANDONED' | 'HIATUS' | 'COMPLETED' | 'UNKNOWN' {
27 | // NOTE: There can be a decent amount of variation in the format here.
28 | //
29 | // Series with multiple seasons (e.g. manhwa) may have something like:
30 | //
31 | // > 38 Chapters (Ongoing)
32 | // >
33 | // > S1: 38 Chapters (Complete) 1~38
34 | // > S2: (TBA)
35 | //
36 | // It might also be in reverse order (with the most recent season first)
37 | //
38 | // Cancelled series can have something like:
39 | //
40 | // > 4 Volumes (Incomplete due to the artist's death)
41 | //
42 | // Make sure to handle everything we reasonably can.
43 | const statusMatches =
44 | /\(([a-zA-Z]+)\)/g
45 | .exec(status)
46 | ?.slice(1)
47 | .map((match) => match.toLowerCase()) || []
48 |
49 | if (statusMatches.some((match) => match.includes('ongoing'))) {
50 | return 'ONGOING'
51 | }
52 |
53 | if (statusMatches.some((match) => match.includes('hiatus'))) {
54 | return 'HIATUS'
55 | }
56 |
57 | if (statusMatches.some((match) => match.includes('incomplete') || match.includes('discontinued'))) {
58 | return 'ABANDONED'
59 | }
60 |
61 | if (statusMatches.some((match) => match.includes('complete'))) {
62 | return 'COMPLETED'
63 | }
64 |
65 | return 'UNKNOWN'
66 | }
67 |
68 | function isHentai(manga: MUSeriesModelV1): boolean {
69 | return manga.genres?.some((genre) => IS_HENTAI_GENRE[genre?.genre || '']) || false
70 | }
71 |
72 | export function sanitiseString(str: string): string {
73 | return str
74 | .replace(/&[^;]+;/g, (entity) => {
75 | if (entity in HTML_ENTITIES) {
76 | return HTML_ENTITIES[entity as keyof typeof HTML_ENTITIES]
77 | }
78 |
79 | const hexMatch = entity.match(/^([\da-fA-F]+);$/)
80 | const hexCode = hexMatch != null ? hexMatch[1] : undefined
81 | if (hexCode != null) {
82 | return String.fromCharCode(parseInt(hexCode, 16))
83 | }
84 |
85 | const decimalMatch = entity.match(/^(\d+);$/)
86 | const decimalCode = decimalMatch != null ? decimalMatch[1] : undefined
87 | if (decimalCode != null) {
88 | return String.fromCharCode(parseInt(decimalCode, 10))
89 | }
90 |
91 | return entity
92 | })
93 | .replace(/
/gi, '\n')
94 | .replace(/<\/?(i|u|b|em|a|span|div|!--)[^>]*>/gi, '')
95 | }
96 |
97 | export function parseMangaInfo(series: MUSeriesModelV1): MangaInfoInput {
98 | return {
99 | titles: [series.title, ...(series.associated || []).map((associated) => associated?.title)]
100 | .filter((title): title is string => !!title)
101 | .map(sanitiseString),
102 | desc: sanitiseString(series.description || ''),
103 | image: series.image?.url?.original || '',
104 | author:
105 | series.authors
106 | ?.filter((author) => author?.type === 'Author' && author.name)
107 | .map((author) => author.name)
108 | .join(', ') || 'Unknown',
109 | artist:
110 | series.authors
111 | ?.filter((author) => author?.type === 'Artist' && author.name)
112 | .map((author) => author.name)
113 | .join(', ') || 'Unknown',
114 | // The type for `status` is lies - it actually expects the string name of the enum value
115 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
116 | status: parseStatus(series.status || '') as any,
117 | rating: series?.bayesian_rating,
118 | hentai: isHentai(series),
119 | }
120 | }
121 |
122 | export function assertNotLegacyMangaId(mangaId: string): void {
123 | // The shortest new ID I could find was 8 decimal digits, but all old
124 | // IDs are definitely <=6 decimal digits.
125 | if (mangaId.length <= 6) {
126 | throw new Error('This manga is tracked using a legacy ID. Please un-track and re-track it')
127 | }
128 | }
129 |
--------------------------------------------------------------------------------
/src/MangaUpdates/utils/mu-search.ts:
--------------------------------------------------------------------------------
1 | import type { MUSeriesSearchResponseV1 } from '../models/mu-api'
2 | import { sanitiseString } from './mu-manga'
3 |
4 | type ApiResult = Exclude[0]
5 |
6 | export interface ResultInfo {
7 | mangaId: string
8 | image: string
9 | title: string
10 | }
11 |
12 | export function parseSearchResults(results: ApiResult[]): ResultInfo[] {
13 | return results.map((result) => {
14 | const id = result.record?.series_id
15 | const title = result.hit_title
16 | const image = result.record?.image?.url?.original ?? ''
17 |
18 | if (!id || !title) {
19 | console.log(`[parseSearchResults] ignoring invalid search result: ${JSON.stringify(result)}`)
20 | return null
21 | }
22 |
23 | return {
24 | mangaId: String(id),
25 | title: sanitiseString(title),
26 | image,
27 | }
28 | })
29 | .filter((info): info is ResultInfo => info !== null)
30 | }
31 |
--------------------------------------------------------------------------------
/src/MangaUpdates/utils/mu-session.ts:
--------------------------------------------------------------------------------
1 | import type { SourceStateManager } from '@paperback/types'
2 | import type { BaseRequest } from '../models'
3 |
4 | const logPrefix = '[mu-session]'
5 | const STATE_MU_CREDENTIALS = 'mu_credentials'
6 | const STATE_MU_SESSION = 'mu_sessiontoken'
7 |
8 | export interface Credentials {
9 | username: string
10 | password: string
11 | }
12 |
13 | interface SessionTokenJwtPayload {
14 | session: string
15 | time_created: number
16 | }
17 |
18 | export function validateCredentials(credentials: unknown): credentials is Credentials {
19 | return (
20 | credentials != null &&
21 | typeof credentials === 'object' &&
22 | typeof (credentials as Credentials).username === 'string' &&
23 | typeof (credentials as Credentials).password === 'string'
24 | )
25 | }
26 |
27 | export async function getUserCredentials(stateManager: SourceStateManager): Promise {
28 | const credentialsString = await stateManager.keychain.retrieve(STATE_MU_CREDENTIALS)
29 | if (typeof credentialsString !== 'string') {
30 | return undefined
31 | }
32 |
33 | const credentials = JSON.parse(credentialsString)
34 | if (!validateCredentials(credentials)) {
35 | console.log(`${logPrefix} store contains invalid credentials!`)
36 | return undefined
37 | }
38 |
39 | return credentials
40 | }
41 |
42 | export async function setUserCredentials(stateManager: SourceStateManager, credentials: Credentials): Promise {
43 | if (!validateCredentials(credentials)) {
44 | console.log(`${logPrefix} tried to store invalid mu_credentials: ${JSON.stringify(credentials)}`)
45 | throw new Error('tried to store invalid mu_credentials')
46 | }
47 |
48 | await stateManager.keychain.store(STATE_MU_CREDENTIALS, JSON.stringify(credentials))
49 | }
50 |
51 | export async function clearUserCredentials(stateManager: SourceStateManager): Promise {
52 | await stateManager.keychain.store(STATE_MU_CREDENTIALS, undefined)
53 | }
54 |
55 | export async function getSessionToken(stateManager: SourceStateManager): Promise {
56 | const sessionToken = await stateManager.keychain.retrieve(STATE_MU_SESSION)
57 | return typeof sessionToken === 'string' ? sessionToken : undefined
58 | }
59 |
60 | export async function setSessionToken(stateManager: SourceStateManager, sessionToken: string): Promise {
61 | if (typeof sessionToken !== 'string') {
62 | console.log(`${logPrefix} tried to store invalid mu_sessiontoken: ${sessionToken}`)
63 | throw new Error('tried to store invalid mu_sessiontoken')
64 | }
65 |
66 | await stateManager.keychain.store(STATE_MU_SESSION, sessionToken)
67 | }
68 |
69 | export async function clearSessionToken(stateManager: SourceStateManager): Promise {
70 | await stateManager.keychain.store(STATE_MU_SESSION, undefined)
71 | }
72 |
73 | export function getLoginTime(sessionToken: string | undefined): string {
74 | if (!sessionToken) {
75 | return '-'
76 | }
77 |
78 | try {
79 | const payloadBase64 = sessionToken.split('.')[1] || ''
80 | const payloadJson = Buffer.from(payloadBase64, 'base64').toString()
81 | const payload: SessionTokenJwtPayload = JSON.parse(payloadJson)
82 |
83 | const loginTime = new Date(payload.time_created * 1000)
84 | if (isNaN(loginTime.getTime())) {
85 | throw new Error('invalid date')
86 | }
87 |
88 | return loginTime.toLocaleString()
89 | } catch (e) {
90 | console.log(`${logPrefix} failed to parse login time`)
91 | console.log(e)
92 | return '-'
93 | }
94 | }
95 |
96 | export function loggableRequest(request: Partial): string {
97 | let censoredRequest = request
98 |
99 | // e.g. on login, register, change password
100 | if (censoredRequest.body?.password) {
101 | censoredRequest = {
102 | ...request,
103 | body: {
104 | ...request.body,
105 | password: '***',
106 | },
107 | }
108 | }
109 |
110 | // e.g. on confirm registration, change password, delete account
111 | if (censoredRequest.params?.authHash) {
112 | censoredRequest = {
113 | ...censoredRequest,
114 | params: {
115 | ...censoredRequest.params,
116 | authHash: '***',
117 | },
118 | }
119 | }
120 |
121 | return JSON.stringify(censoredRequest)
122 | }
123 |
124 | export function loggableResponse(response: unknown): string {
125 | if (!response) {
126 | return ''
127 | }
128 |
129 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
130 | let censoredResponse: any = response
131 |
132 | // e.g. on login
133 | if (censoredResponse.context?.session_token) {
134 | censoredResponse = {
135 | ...censoredResponse,
136 | context: {
137 | ...censoredResponse.context,
138 | session_token: '***',
139 | },
140 | }
141 | }
142 |
143 | return JSON.stringify(censoredResponse)
144 | }
145 |
--------------------------------------------------------------------------------
/src/MyAnimeList/MALSettings.ts:
--------------------------------------------------------------------------------
1 | import {
2 | DUINavigationButton,
3 | SourceStateManager
4 | } from '@paperback/types'
5 |
6 | export const getDefaultStatus = async (stateManager: SourceStateManager): Promise => {
7 | return (await stateManager.retrieve('defaultStatus') as string[]) ?? ['NONE']
8 | }
9 | export const trackerSettings = (stateManager: SourceStateManager): DUINavigationButton => {
10 | return App.createDUINavigationButton({
11 | id: 'tracker_settings',
12 | label: 'Tracker Settings',
13 | form: App.createDUIForm({
14 | sections: () => {
15 | return Promise.resolve([
16 | App.createDUISection({
17 | id: 'settings',
18 | isHidden: false,
19 | header: 'Status Settings',
20 | rows: async () => [
21 | App.createDUISelect({
22 | id: 'defaultStatus',
23 | label: 'Default Status',
24 | allowsMultiselect: false,
25 | value: App.createDUIBinding({
26 | get: () => getDefaultStatus(stateManager),
27 | set: async (newValue) => await stateManager.store('defaultStatus', newValue)
28 | }),
29 | labelResolver: async (value) => {
30 | switch (value) {
31 | case 'reading': return 'Reading'
32 | case 'plan_to_read': return 'Planned'
33 | case 'completed': return 'Completed'
34 | case 'dropped': return 'Dropped'
35 | case 'on_hold': return 'On-Hold'
36 | default: return 'None'
37 | }
38 | },
39 | options: [
40 | 'NONE',
41 | 'reading',
42 | 'plan_to_read',
43 | 'completed',
44 | 'dropped',
45 | 'on_hold'
46 | ]
47 | })
48 | ]
49 | })
50 | ])
51 | }
52 | })
53 | })
54 | }
--------------------------------------------------------------------------------
/src/MyAnimeList/MyAnimeList.ts:
--------------------------------------------------------------------------------
1 | import {
2 | ContentRating,
3 | DUIForm,
4 | PagedResults,
5 | SearchRequest,
6 | DUISection,
7 | SourceInfo,
8 | Request,
9 | Response,
10 | TrackerActionQueue,
11 | Searchable,
12 | MangaProgressProviding,
13 | SourceManga,
14 | MangaProgress,
15 | SourceIntents
16 | } from '@paperback/types'
17 |
18 | import * as MalUser from './models/mal-user'
19 | import * as MalManga from './models/mal-manga'
20 | import * as MalPage from './models/mal-page'
21 | import * as MalToken from './models/mal-token'
22 | import { MalResult } from './models/mal-result'
23 |
24 | import { stringify } from 'querystring'
25 |
26 | import {
27 | getDefaultStatus,
28 | trackerSettings
29 | } from './MALSettings'
30 |
31 | const MYANIMELIST_API = 'https://api.myanimelist.net/v2'
32 |
33 | export const MyAnimeListInfo: SourceInfo = {
34 | name: 'MyAnimeList',
35 | author: 'Netsky',
36 | contentRating: ContentRating.EVERYONE,
37 | icon: 'icon.png',
38 | version: '1.0.1',
39 | description: 'MyAnimeList Tracker',
40 | websiteBaseURL: 'https://myanimelist.net',
41 | intents: SourceIntents.MANGA_TRACKING | SourceIntents.SETTINGS_UI
42 | }
43 |
44 | export class MyAnimeList implements Searchable, MangaProgressProviding {
45 | stateManager = App.createSourceStateManager();
46 |
47 | requestManager = App.createRequestManager({
48 | requestsPerSecond: 2.5,
49 | requestTimeout: 20_000,
50 | interceptor: {
51 | // Authorization injector
52 | interceptRequest: async (request: Request): Promise => {
53 | const tokenData = await this.tokenData.get()
54 |
55 | if (tokenData?.expires_in && (tokenData.expires_in - 1296000) < (Date.now() / 1000)) {
56 | console.log('Access token has expired, refreshing the access token!')
57 | await this.tokenData.refresh()
58 | }
59 |
60 | request.headers = {
61 | ...(request.headers ?? {}),
62 | ...({
63 | 'accept': 'application/json'
64 | }),
65 | ...(tokenData != null ? {
66 | 'authorization': `Bearer ${tokenData.access_token}`
67 | } : {})
68 | }
69 | return request
70 | },
71 | interceptResponse: async (response: Response): Promise => {
72 | return response
73 | }
74 | }
75 | });
76 |
77 | tokenData = {
78 | get: async (): Promise => {
79 | return this.stateManager.keychain.retrieve('token_data') as Promise
80 | },
81 | set: async (tokenData: MalToken.Data | undefined): Promise => {
82 | await this.stateManager.keychain.store('token_data', tokenData)
83 | await this.userInfo.refresh()
84 | },
85 | isValid: async (): Promise => {
86 | return (await this.tokenData.get()) != null
87 | },
88 | refresh: async (): Promise => {
89 | await this.refreshAccessToken()
90 | }
91 | };
92 |
93 | userInfo = {
94 | get: async (): Promise => {
95 | return this.stateManager.retrieve('userInfo') as Promise
96 | },
97 | isLoggedIn: async (): Promise => {
98 | return (await this.userInfo.get()) != null
99 | },
100 | refresh: async (): Promise => {
101 | const tokenData = await this.tokenData.get()
102 | if (tokenData == null) {
103 | return this.stateManager.store('userInfo', undefined)
104 | }
105 | const response = await this.requestManager.schedule(App.createRequest({
106 | url: `${MYANIMELIST_API}/users/@me`,
107 | method: 'GET'
108 | }), 0)
109 |
110 | const userInfo = MalResult(response)
111 | await this.stateManager.store('userInfo', userInfo)
112 | }
113 | };
114 |
115 |
116 | async getSearchResults(query: SearchRequest, metadata: unknown): Promise {
117 | const pageURL = metadata as string
118 |
119 | const response = await this.requestManager.schedule(App.createRequest({
120 | url: pageURL ?? `${MYANIMELIST_API}/manga?q=${encodeURI(query.title ?? '')}&nsfw=true`,
121 | method: 'GET'
122 | }), 1)
123 |
124 | const malPage = MalResult(response)
125 |
126 | //console.log(JSON.stringify(malPage, null, 2)) // Log request data
127 |
128 | if (!malPage || malPage.data.length == 0) {
129 | return App.createPagedResults({ results: [], metadata: undefined })
130 | }
131 |
132 | return App.createPagedResults({
133 | results: malPage.data?.map(manga => App.createPartialSourceManga({
134 | image: manga.node.main_picture?.large ?? '',
135 | title: manga.node.title,
136 | mangaId: manga.node.id.toString(),
137 | subtitle: undefined
138 | })) ?? [],
139 | metadata: malPage.paging?.next
140 | })
141 |
142 | }
143 |
144 | async getMangaDetails(mangaId: string): Promise {
145 | const response = await this.requestManager.schedule(App.createRequest({
146 | url: encodeURI(`${MYANIMELIST_API}/manga/${parseInt(mangaId)}?fields=id,title,main_picture,alternative_titles,synopsis,mean,rank,popularity,nsfw,media_type,status,my_list_status,num_volumes,num_chapters,authors{first_name,last_name}`),
147 | method: 'GET'
148 | }), 1)
149 |
150 | const malManga = MalResult(response)
151 |
152 | //console.log(JSON.stringify(malManga, null, 2)) // Log request data
153 |
154 | if (malManga == null) {
155 | return Promise.reject()
156 | }
157 |
158 | return App.createSourceManga({
159 | id: mangaId,
160 | mangaInfo: App.createMangaInfo({
161 | image: malManga.main_picture?.large ?? '',
162 | titles: [
163 | malManga.title,
164 | malManga.alternative_titles?.en,
165 | malManga.alternative_titles?.ja
166 | ].filter(x => x != null) as string[],
167 | artist: this.formatStaffName(malManga.authors?.find(x => x?.role?.toLowerCase().includes('art'))?.node),
168 | author: this.formatStaffName(malManga.authors?.find(x => x?.role?.toLowerCase().includes('story'))?.node),
169 | desc: malManga?.synopsis || '',
170 | hentai: this.formatNSFW(malManga?.nsfw ?? ''),
171 | rating: malManga.mean,
172 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
173 | // @ts-ignore
174 | status: this.formatStatus(malManga.status)
175 | })
176 | })
177 | }
178 |
179 | async getMangaProgress(mangaId: string): Promise {
180 | const response = await this.requestManager.schedule(App.createRequest({
181 | url: encodeURI(`${MYANIMELIST_API}/manga/${parseInt(mangaId)}?fields=my_list_status`),
182 | method: 'GET'
183 | }), 1)
184 |
185 | const malManga = MalResult(response)
186 |
187 | //console.log(JSON.stringify(malManga, null, 2)) // Log request data
188 |
189 | if (!malManga?.my_list_status) { return undefined }
190 |
191 | return App.createMangaProgress({
192 | mangaId: mangaId,
193 |
194 | lastReadChapterNumber: malManga?.my_list_status.num_chapters_read ?? 0,
195 | lastReadVolumeNumber: malManga?.my_list_status.num_volumes_read,
196 |
197 | trackedListName: malManga?.my_list_status.status ?? undefined,
198 | userRating: malManga?.my_list_status.score
199 | })
200 | }
201 |
202 | async getMangaProgressManagementForm(mangaId: string): Promise {
203 | return App.createDUIForm({
204 | sections: async () => {
205 | const [response] = await Promise.all([
206 | this.requestManager.schedule(App.createRequest({
207 | url: encodeURI(`${MYANIMELIST_API}/manga/${parseInt(mangaId)}?fields=id,title,main_picture,alternative_titles,synopsis,mean,rank,popularity,nsfw,media_type,status,my_list_status,num_volumes,num_chapters,authors{first_name,last_name}`),
208 | method: 'GET'
209 | }), 1),
210 |
211 | this.userInfo.refresh()
212 | ])
213 |
214 | const malManga = MalResult(response)
215 |
216 | //console.log(JSON.stringify(malManga, null, 2)) // Log request data
217 |
218 | const user = await this.userInfo.get()
219 | if (user == null) {
220 | return [
221 | App.createDUISection({
222 | id: 'notLoggedInSection',
223 | isHidden: false,
224 | rows: async () => [
225 | App.createDUILabel({
226 | id: 'notLoggedIn',
227 | label: 'Not Logged In'
228 | })
229 | ]
230 | })
231 | ]
232 | }
233 |
234 | if (malManga == null) {
235 | throw new Error(`Unable to find Manga on MyAnimeList with id ${mangaId}`)
236 | }
237 |
238 | return [
239 | App.createDUISection({
240 | id: 'userInfo',
241 | isHidden: false,
242 | rows: async () => [
243 | App.createDUIHeader({
244 | id: 'header',
245 | imageUrl: user.picture || '',
246 | title: user.name ?? 'NOT LOGGED IN',
247 | subtitle: ''
248 | })
249 | ]
250 | }),
251 | // Static items
252 | App.createDUISection({
253 | id: 'information',
254 | header: 'Information',
255 | isHidden: false,
256 | rows: async () => [
257 | App.createDUILabel({
258 | id: 'mediaId',
259 | label: 'Manga ID',
260 | value: malManga.id?.toString()
261 | }),
262 | App.createDUILabel({
263 | id: 'mangaTitle',
264 | label: 'Title',
265 | value: malManga.title ?? 'N/A'
266 | }),
267 | App.createDUILabel({
268 | id: 'mangaRank',
269 | value: malManga.rank?.toString() ?? 'N/A',
270 | label: 'Rank'
271 | }),
272 | App.createDUILabel({
273 | id: 'mangaPopularity',
274 | value: malManga.popularity?.toString() ?? 'N/A',
275 | label: 'Popularity'
276 | }),
277 | App.createDUILabel({
278 | id: 'mangaRating',
279 | value: malManga.mean?.toString() ?? 'N/A',
280 | label: 'Rating'
281 | }),
282 | App.createDUILabel({
283 | id: 'mangaStatus',
284 | value: this.formatStatus(malManga.status),
285 | label: 'Status'
286 | }),
287 | App.createDUILabel({
288 | id: 'mangaIsAdult',
289 | value: this.formatNSFW(malManga.nsfw ?? '') ? 'Yes' : 'No',
290 | label: 'Is Adult'
291 | })
292 | ]
293 | }),
294 | // User interactive items
295 | // Status
296 | App.createDUISection({
297 | id: 'trackStatus',
298 | header: 'Manga Status',
299 | footer: 'Warning: Setting this to NONE will delete the listing from MyAnimeList!',
300 | isHidden: false,
301 | rows: async () => [
302 | App.createDUISelect({
303 | id: 'status',
304 | //@ts-ignore
305 | value: malManga.my_list_status?.status ? [malManga.my_list_status.status] : await getDefaultStatus(this.stateManager),
306 | allowsMultiselect: false,
307 | label: 'Status',
308 | labelResolver: async (value) => {
309 | return this.formatStatus(value)
310 | },
311 | options: [
312 | 'NONE',
313 | 'reading',
314 | 'plan_to_read',
315 | 'completed',
316 | 'dropped',
317 | 'on_hold'
318 | ]
319 | })
320 | ]
321 | }),
322 | // Progress
323 | App.createDUISection({
324 | id: 'manage',
325 | header: 'Progress',
326 | isHidden: false,
327 | rows: async () => [
328 | App.createDUIStepper({
329 | id: 'num_chapters_read',
330 | label: 'Chapter',
331 | //@ts-ignore
332 | value: malManga.my_list_status?.num_chapters_read ?? 0,
333 | min: 0,
334 | step: 1
335 | }),
336 | App.createDUIStepper({
337 | id: 'num_volumes_read',
338 | label: 'Volume',
339 | //@ts-ignore
340 | value: malManga.my_list_status?.num_volumes_read ?? 0,
341 | min: 0,
342 | step: 1
343 | })
344 | ]
345 | }),
346 | // Rating
347 | App.createDUISection({
348 | id: 'rateSection',
349 | header: 'Rating',
350 | isHidden: false,
351 | rows: async () => [
352 | App.createDUIStepper({
353 | id: 'score',
354 | label: 'Score',
355 | //@ts-ignore
356 | value: malManga.my_list_status?.score ?? 0,
357 | min: 0,
358 | max: 10,
359 | step: 1
360 | })
361 | ]
362 | }),
363 | // Re-read
364 | App.createDUISection({
365 | id: 'mangaReread',
366 | header: 'Times Re-read',
367 | isHidden: false,
368 | rows: async () => [
369 | App.createDUIStepper({
370 | id: 'num_times_reread',
371 | label: 'Re-read Amount',
372 | //@ts-ignore
373 | value: malManga.my_list_status?.reread_value ?? 0,
374 | min: 0,
375 | max: 100,
376 | step: 1
377 | })
378 | ]
379 | }),
380 | // Notes
381 | App.createDUISection({
382 | id: 'mangaNotes',
383 | header: 'Notes',
384 | isHidden: false,
385 | rows: async () => [
386 | App.createDUIInputField({
387 | id: 'notes',
388 | label: 'Notes',
389 | //@ts-ignore
390 | value: malManga.my_list_status?.comments ?? ''
391 | })
392 | ]
393 | })
394 | ]
395 | },
396 | onSubmit: async (values) => {
397 | const status = values['status']?.[0] ?? ''
398 |
399 | if (status == 'NONE' && mangaId != null) {
400 | await this.requestManager.schedule(App.createRequest({
401 | url: `${MYANIMELIST_API}/manga/${parseInt(mangaId)}/my_list_status`,
402 | method: 'DELETE'
403 | }), 1)
404 |
405 | } else {
406 | await this.requestManager.schedule(App.createRequest({
407 | url: `${MYANIMELIST_API}/manga/${parseInt(mangaId)}/my_list_status`,
408 | method: 'PUT',
409 | headers: {
410 | 'content-type': 'application/x-www-form-urlencoded'
411 | },
412 | data: stringify({
413 | status: status,
414 | num_chapters_read: Number(values['num_chapters_read']),
415 | num_volumes_read: Number(values['num_volumes_read']),
416 | score: Number(values['score']),
417 | num_times_reread: Number(values['num_times_reread']),
418 | comments: values['notes']
419 | })
420 | }), 1)
421 | }
422 | }
423 | })
424 | }
425 |
426 | async processChapterReadActionQueue(actionQueue: TrackerActionQueue): Promise {
427 | await this.userInfo.refresh()
428 |
429 | const chapterReadActions = await actionQueue.queuedChapterReadActions()
430 |
431 | for (const readAction of chapterReadActions) {
432 | const mangaId = readAction.mangaId
433 |
434 | try {
435 | let params = {}
436 |
437 | if (Math.floor(readAction.chapterNumber) == 1 && !readAction.volumeNumber) {
438 | params = {
439 | status: 'reading', // Required for API the work properly
440 | num_chapters_read: 1,
441 | num_volumes_read: 1
442 | }
443 | } else {
444 | params = {
445 | status: 'reading', // Required for API the work properly
446 | num_chapters_read: Math.floor(readAction.chapterNumber),
447 | num_volumes_read: readAction.volumeNumber ? Math.floor(readAction.volumeNumber) : undefined
448 | }
449 | }
450 |
451 | const response = await this.requestManager.schedule(App.createRequest({
452 | url: `${MYANIMELIST_API}/manga/${parseInt(mangaId)}/my_list_status`,
453 | method: 'PUT',
454 | headers: {
455 | 'content-type': 'application/x-www-form-urlencoded'
456 | },
457 | data: stringify(params)
458 | }), 0)
459 |
460 | if (response.status < 400) {
461 | await actionQueue.discardChapterReadAction(readAction)
462 | } else {
463 | console.log(`Action failed: ${response.data}`)
464 | await actionQueue.retryChapterReadAction(readAction)
465 | }
466 |
467 | } catch (error) {
468 | console.log(error)
469 | await actionQueue.retryChapterReadAction(readAction)
470 | }
471 | }
472 | }
473 |
474 | async getSourceMenu(): Promise {
475 | return App.createDUISection({
476 | id: 'sourceMenu',
477 | header: 'Source Menu',
478 | isHidden: false,
479 | rows: async () => {
480 | const isLoggedIn = await this.userInfo.isLoggedIn()
481 | if (isLoggedIn) {
482 | return [
483 | trackerSettings(this.stateManager),
484 | App.createDUILabel({
485 | id: 'userInfo',
486 | label: 'Logged-in as',
487 | value: (await this.userInfo.get())?.name ?? 'ERROR'
488 | }),
489 | App.createDUIButton({
490 | id: 'logout',
491 | label: 'Logout',
492 | onTap: async () => {
493 | await this.tokenData.set(undefined)
494 | }
495 | })
496 | ]
497 | } else {
498 | return [
499 | App.createDUIOAuthButton({
500 | id: 'malLogin',
501 | authorizeEndpoint: 'https://myanimelist.net/v1/oauth2/authorize',
502 | clientId: '004e72f9c4d8f5e6e8737d320246c0e3',
503 | label: 'Login with MyAnimeList',
504 | redirectUri: 'paperback://malAuth',
505 | responseType: {
506 | type: 'pkce',
507 | pkceCodeLength: 64,
508 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
509 | // @ts-ignore
510 | pkceCodeMethod: 'plain',
511 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
512 | // @ts-ignore
513 | formEncodeGrant: true,
514 | tokenEndpoint: 'https://myanimelist.net/v1/oauth2/token',
515 | },
516 | successHandler: async (accessToken: string, refreshToken?: string): Promise => {
517 | //eslint-disable-next-line @typescript-eslint/ban-ts-comment
518 | //@ts-ignore
519 | const tokenBody = JSON.parse(Buffer.from(accessToken?.split('.')[1], 'base64'))
520 |
521 | await this.tokenData.set({
522 | expires_in: tokenBody['exp'],
523 | access_token: accessToken,
524 | refresh_token: refreshToken
525 | })
526 | }
527 | })
528 | ]
529 | }
530 | }
531 | })
532 | }
533 |
534 | // Utility
535 | formatNSFW(label: string | null): boolean {
536 | switch (label) {
537 | case 'white':
538 | return false
539 | case 'gray':
540 | return false
541 | case 'black':
542 | return true
543 | default:
544 | return false
545 | }
546 | }
547 |
548 | formatStaffName(authorNode: MalManga.AuthorNode | undefined): string {
549 | if (!authorNode) {
550 | return 'Unknown'
551 | }
552 |
553 | return `${authorNode.first_name} ${authorNode.last_name}`
554 | }
555 |
556 | formatStatus(value: string | undefined): string {
557 | switch (value) {
558 | case 'reading': return 'Reading'
559 | case 'plan_to_read': return 'Planned'
560 | case 'completed': return 'Completed'
561 | case 'dropped': return 'Dropped'
562 | case 'on_hold': return 'On-Hold'
563 |
564 | case 'finished': return 'Finished'
565 | case 'currently_publishing': return 'Releasing'
566 | case 'not_yet_published': return 'Not Yet Released'
567 |
568 | case 'NONE': return 'None'
569 |
570 | default: return 'N/A'
571 | }
572 | }
573 |
574 | async refreshAccessToken(): Promise {
575 | try {
576 | const tokenData = await this.tokenData.get()
577 |
578 | //console.log(JSON.stringify(tokenData, null, 2)) // Log request data
579 |
580 | const response = await this.requestManager.schedule(App.createRequest({
581 | url: 'https://myanimelist.net/v1/oauth2/token',
582 | method: 'POST',
583 | headers: {
584 | 'content-type': 'application/x-www-form-urlencoded'
585 | },
586 | data: {
587 | grant_type: 'refresh_token',
588 | refresh_token: tokenData?.refresh_token,
589 | client_id: '004e72f9c4d8f5e6e8737d320246c0e3'
590 | }
591 | }), 1)
592 |
593 | const newTokenData = MalResult(response)
594 | if (newTokenData.access_token == null) {
595 | throw new Error('Unable to request new "access token", try logging out and back in!')
596 | }
597 | if (newTokenData.refresh_token == null) {
598 | throw new Error('Unable to request new "refresh token", try logging out and back in!')
599 | }
600 |
601 | //eslint-disable-next-line @typescript-eslint/ban-ts-comment
602 | //@ts-ignore
603 | const tokenBody = JSON.parse(Buffer.from(newTokenData.split('.')[1], 'base64'))
604 |
605 | // Set the new token data
606 | await this.tokenData.set({
607 | expires_in: tokenBody['exp'],
608 | access_token: newTokenData.access_token,
609 | refresh_token: newTokenData.refresh_token
610 | })
611 |
612 | } catch (error) {
613 | throw new Error(error as string)
614 | }
615 | }
616 | }
617 |
--------------------------------------------------------------------------------
/src/MyAnimeList/includes/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Paperback-iOS/extensions/89853d7f63328744a67d5ddde8b548671e6f804d/src/MyAnimeList/includes/icon.png
--------------------------------------------------------------------------------
/src/MyAnimeList/models/mal-manga.ts:
--------------------------------------------------------------------------------
1 | export interface Result {
2 | id?: number;
3 | synopsis?: string;
4 | title?: string;
5 | main_picture?: Image;
6 | mean?: number;
7 | alternative_titles?: Titles;
8 | nsfw?: 'white' | 'gray' | 'black' | null;
9 | popularity?: number;
10 | rank?: number;
11 | authors?: Authors[];
12 | status?: string;
13 | media_type?: 'unknown' | 'manga' | 'novel' | 'one_shot' | 'doujinshi' | 'manhwa' | 'manhua' | 'oel';
14 | my_list_status?: ListItem;
15 | }
16 |
17 | export interface ListItem {
18 | status?: string | null;
19 | score?: number;
20 | num_volumes_read?: number;
21 | num_chapters_read?: number;
22 | is_rereading?: boolean;
23 | start_date?: string | null;
24 | finish_date?: string | null;
25 | priority?: number;
26 | num_times_reread?: number;
27 | reread_value?: number;
28 | updated_at?: number;
29 | comments: string;
30 | }
31 |
32 | export interface Image {
33 | large?: string;
34 | medium?: string;
35 | }
36 |
37 | export interface Authors {
38 | node?: AuthorNode;
39 | role?: string;
40 | }
41 |
42 | export interface AuthorNode {
43 | id?: number;
44 | first_name?: string;
45 | last_name?: string;
46 | }
47 |
48 | export interface Titles {
49 | synonyms?: string[];
50 | en?: string;
51 | ja?: string;
52 | }
53 |
--------------------------------------------------------------------------------
/src/MyAnimeList/models/mal-page.ts:
--------------------------------------------------------------------------------
1 | export interface Results {
2 | data: Result[]
3 | paging: PageInfo
4 | }
5 |
6 | export interface Result {
7 | node: Media
8 | }
9 |
10 | export interface Media {
11 | id: number;
12 | title: string;
13 | main_picture?: Image;
14 | }
15 |
16 | export interface Image {
17 | large?: string;
18 | medium?: string;
19 | }
20 |
21 | export interface PageInfo {
22 | previous?: string;
23 | next?: string;
24 | }
--------------------------------------------------------------------------------
/src/MyAnimeList/models/mal-result.ts:
--------------------------------------------------------------------------------
1 | import { Response } from '@paperback/types'
2 |
3 | export function MalResult(response: Response): MalResult {
4 | if (response.status !== 200) {
5 | console.log(`[MAL-ERROR(${response.status})] ${JSON.stringify(response, null, 2)}`)
6 | throw new Error('Error while fetching data from MyAnimeList, check logs for more info')
7 | }
8 |
9 | const result: MalResult = typeof response.data == 'string' ? JSON.parse(response.data) : response.data
10 | return result
11 | }
12 |
13 | interface MalResult {
14 | data?: string;
15 | }
--------------------------------------------------------------------------------
/src/MyAnimeList/models/mal-token.ts:
--------------------------------------------------------------------------------
1 | export interface Data {
2 | expires_in: number;
3 | access_token: string;
4 | refresh_token?: string;
5 | }
--------------------------------------------------------------------------------
/src/MyAnimeList/models/mal-user.ts:
--------------------------------------------------------------------------------
1 | export interface User {
2 | id?: number;
3 | name?: string;
4 | picture?: string;
5 | }
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */
4 |
5 | /* Basic Options */
6 | // "incremental": true, /* Enable incremental compilation */
7 | "target": "es6", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', 'ES2021', or 'ESNEXT'. */
8 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */
9 | // "lib": [], /* Specify library files to be included in the compilation. */
10 | // "allowJs": true, /* Allow javascript files to be compiled. */
11 | // "checkJs": true, /* Report errors in .js files. */
12 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', 'react', 'react-jsx' or 'react-jsxdev'. */
13 | // "declaration": true, /* Generates corresponding '.d.ts' file. */
14 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */
15 | // "sourceMap": true, /* Generates corresponding '.map' file. */
16 | // "outFile": "./", /* Concatenate and emit output to single file. */
17 | "outDir": "./lib", /* Redirect output structure to the directory. */
18 | "rootDir": "./src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
19 | // "composite": true, /* Enable project compilation */
20 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */
21 | // "removeComments": true, /* Do not emit comments to output. */
22 | // "noEmit": true, /* Do not emit outputs. */
23 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */
24 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
25 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
26 |
27 | /* Strict Type-Checking Options */
28 | "strict": true, /* Enable all strict type-checking options. */
29 | "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
30 | // "strictNullChecks": true, /* Enable strict null checks. */
31 | "strictFunctionTypes": true, /* Enable strict checking of function types. */
32 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
33 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
34 | "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
35 | "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
36 |
37 | /* Additional Checks */
38 | "noUnusedLocals": true, /* Report errors on unused locals. */
39 | "noUnusedParameters": true, /* Report errors on unused parameters. */
40 | "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
41 | "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
42 | "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */
43 | "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an 'override' modifier. */
44 | // "noPropertyAccessFromIndexSignature": true, /* Require undeclared properties from index signatures to use element accesses. */
45 |
46 | /* Module Resolution Options */
47 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
48 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
49 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
50 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
51 | // "typeRoots": [], /* List of folders to include type definitions from. */
52 | // "types": [], /* Type declaration files to be included in compilation. */
53 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
54 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
55 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
56 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
57 |
58 | /* Source Map Options */
59 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
60 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
61 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
62 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
63 |
64 | /* Experimental Options */
65 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
66 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
67 |
68 | /* Advanced Options */
69 | "skipLibCheck": true, /* Skip type checking of declaration files. */
70 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */
71 | }
72 | }
73 |
--------------------------------------------------------------------------------