├── .eslintrc.yml ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .husky └── pre-push ├── .prettierrc ├── LICENSE ├── README.md ├── anilist-codegen.yml ├── anilist-schema.json ├── annict-codegen.yml ├── annict-schema.gql ├── annictGqlNaming.js ├── gql-documents ├── anilist │ ├── lib.gql │ ├── me.gql │ └── update.gql └── annict │ ├── lib.gql │ └── me.gql ├── index.html ├── netlify.toml ├── netlify └── functions │ ├── anilist-callback.ts │ ├── annict-callback.ts │ └── mal-callback.ts ├── package.json ├── postcss.config.js ├── src ├── App.tsx ├── aniList.ts ├── aniListApiEntry.ts ├── aniListGql.ts ├── annictApiEntry.ts ├── annictGql.ts ├── components │ ├── AniListLogin.tsx │ ├── AniListUserInfo.tsx │ ├── AnnictLogin.tsx │ ├── AnnictUserInfo.tsx │ ├── CheckDiff.tsx │ ├── DiffFetchButton.tsx │ ├── DiffTable.tsx │ ├── DoSync.tsx │ ├── FirstView.tsx │ ├── MALLogin.tsx │ ├── MALUserInfo.tsx │ ├── Main.tsx │ └── MissingWorkTable.tsx ├── constants.ts ├── index.tsx ├── mal.ts ├── query.ts ├── types.ts ├── utils.ts └── vite-env.d.ts ├── tsconfig.eslint.json ├── tsconfig.json ├── vite.config.ts └── yarn.lock /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | --- 2 | extends: 3 | - "@ci7lus/eslint-config" 4 | - "plugin:react/recommended" 5 | - "plugin:eslint-plugin-react-hooks/recommended" 6 | parserOptions: 7 | project: 8 | - "./tsconfig.eslint.json" 9 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - "**" 7 | tags-ignore: 8 | - "**" 9 | pull_request: 10 | 11 | jobs: 12 | lint: 13 | runs-on: ubuntu-latest 14 | 15 | strategy: 16 | matrix: 17 | node-version: [18.x] 18 | 19 | steps: 20 | - uses: actions/checkout@v3 21 | - name: Use Node.js ${{ matrix.node-version }} 22 | uses: actions/setup-node@v3 23 | with: 24 | node-version: ${{ matrix.node-version }} 25 | cache: "yarn" 26 | - name: Install 27 | run: | 28 | yarn 29 | - name: Lint 30 | run: | 31 | yarn lint:prettier 32 | yarn lint:eslint 33 | build: 34 | runs-on: ubuntu-latest 35 | 36 | strategy: 37 | matrix: 38 | node-version: [18.x] 39 | 40 | needs: lint 41 | 42 | steps: 43 | - uses: actions/checkout@v3 44 | - name: Use Node.js ${{ matrix.node-version }} 45 | uses: actions/setup-node@v3 46 | with: 47 | node-version: ${{ matrix.node-version }} 48 | cache: "yarn" 49 | - name: Install 50 | run: | 51 | yarn 52 | - name: Build 53 | run: | 54 | yarn build 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/node 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=node 3 | 4 | ### Node ### 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | lerna-debug.log* 12 | 13 | # Diagnostic reports (https://nodejs.org/api/report.html) 14 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 15 | 16 | # Runtime data 17 | pids 18 | *.pid 19 | *.seed 20 | *.pid.lock 21 | 22 | # Directory for instrumented libs generated by jscoverage/JSCover 23 | lib-cov 24 | 25 | # Coverage directory used by tools like istanbul 26 | coverage 27 | *.lcov 28 | 29 | # nyc test coverage 30 | .nyc_output 31 | 32 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 33 | .grunt 34 | 35 | # Bower dependency directory (https://bower.io/) 36 | bower_components 37 | 38 | # node-waf configuration 39 | .lock-wscript 40 | 41 | # Compiled binary addons (https://nodejs.org/api/addons.html) 42 | build/Release 43 | 44 | # Dependency directories 45 | node_modules/ 46 | jspm_packages/ 47 | 48 | # TypeScript v1 declaration files 49 | typings/ 50 | 51 | # TypeScript cache 52 | *.tsbuildinfo 53 | 54 | # Optional npm cache directory 55 | .npm 56 | 57 | # Optional eslint cache 58 | .eslintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variables file 76 | .env 77 | .env.test 78 | 79 | # parcel-bundler cache (https://parceljs.org/) 80 | .cache 81 | 82 | # Next.js build output 83 | .next 84 | 85 | # Nuxt.js build / generate output 86 | .nuxt 87 | dist 88 | 89 | # Gatsby files 90 | .cache/ 91 | # Comment in the public line in if your project uses Gatsby and not Next.js 92 | # https://nextjs.org/blog/next-9-1#public-directory-support 93 | # public 94 | 95 | # vuepress build output 96 | .vuepress/dist 97 | 98 | # Serverless directories 99 | .serverless/ 100 | 101 | # FuseBox cache 102 | .fusebox/ 103 | 104 | # DynamoDB Local files 105 | .dynamodb/ 106 | 107 | # TernJS port file 108 | .tern-port 109 | 110 | # Stores VSCode versions used for testing VSCode extensions 111 | .vscode-test 112 | 113 | # End of https://www.toptal.com/developers/gitignore/api/node 114 | 115 | lib 116 | .netlify -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn lint-staged 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "semi": false, 4 | "singleQuote": false 5 | } 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 ci7lus 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # imau 2 | 3 | Sync your viewing status from Annict to MAL/AniList.
4 | 5 | [![Image from Gyazo](https://i.gyazo.com/2c86edeea8e8245b3b317f9462bbfbca.png)](https://gyazo.com/2c86edeea8e8245b3b317f9462bbfbca) 6 | 7 | CLI version: [SlashNephy/annict2anilist](https://github.com/SlashNephy/annict2anilist) 8 | 9 | ## dev 10 | 11 | ```bash 12 | npm install -g netlify-cli 13 | yarn 14 | netlify dev 15 | ``` 16 | 17 | ## "imau" means 18 | 19 | 今鵜凪咲 from [SELECTION PROJECT](https://annict.com/works/7836). 20 | -------------------------------------------------------------------------------- /anilist-codegen.yml: -------------------------------------------------------------------------------- 1 | schema: ./anilist-schema.json 2 | documents: ./gql-documents/anilist/*.gql 3 | generates: 4 | ./src/aniListGql.ts: 5 | plugins: 6 | - add: 7 | content: "/* eslint-disable */" 8 | - typescript 9 | - typescript-operations 10 | - typescript-graphql-request 11 | config: 12 | avoidOptionals: true 13 | -------------------------------------------------------------------------------- /annict-codegen.yml: -------------------------------------------------------------------------------- 1 | schema: ./annict-schema.gql 2 | documents: ./gql-documents/annict/*.gql 3 | config: 4 | namingConvention: ./annictGqlNaming.js 5 | generates: 6 | ./src/annictGql.ts: 7 | plugins: 8 | - add: 9 | content: "/* eslint-disable */" 10 | - typescript 11 | - typescript-operations 12 | - typescript-graphql-request 13 | config: 14 | avoidOptionals: true 15 | -------------------------------------------------------------------------------- /annict-schema.gql: -------------------------------------------------------------------------------- 1 | type Activity implements Node { 2 | annictId: Int! 3 | # ID of the object. 4 | id: ID! 5 | user: User! 6 | } 7 | 8 | enum ActivityAction { 9 | CREATE 10 | } 11 | 12 | # The connection type for Activity. 13 | type ActivityConnection { 14 | # A list of edges. 15 | edges: [ActivityEdge] 16 | # A list of nodes. 17 | nodes: [Activity] 18 | # Information to aid in pagination. 19 | pageInfo: PageInfo! 20 | } 21 | 22 | # An edge in a connection. 23 | type ActivityEdge { 24 | action: ActivityAction! 25 | annictId: Int! 26 | # A cursor for use in pagination. 27 | cursor: String! 28 | item: ActivityItem 29 | # Deprecated: Use `item` instead. 30 | node: ActivityItem @deprecated(reason: "Use `item` instead.") 31 | user: User! 32 | } 33 | 34 | union ActivityItem = MultipleRecord | Record | Review | Status 35 | input ActivityOrder { 36 | field: ActivityOrderField! 37 | direction: OrderDirection! 38 | } 39 | 40 | enum ActivityOrderField { 41 | CREATED_AT 42 | } 43 | 44 | type Cast implements Node { 45 | annictId: Int! 46 | character: Character! 47 | id: ID! 48 | name: String! 49 | nameEn: String! 50 | person: Person! 51 | sortNumber: Int! 52 | work: Work! 53 | } 54 | 55 | # The connection type for Cast. 56 | type CastConnection { 57 | # A list of edges. 58 | edges: [CastEdge] 59 | # A list of nodes. 60 | nodes: [Cast] 61 | # Information to aid in pagination. 62 | pageInfo: PageInfo! 63 | } 64 | 65 | # An edge in a connection. 66 | type CastEdge { 67 | # A cursor for use in pagination. 68 | cursor: String! 69 | # The item at the end of the edge. 70 | node: Cast 71 | } 72 | 73 | input CastOrder { 74 | field: CastOrderField! 75 | direction: OrderDirection! 76 | } 77 | 78 | enum CastOrderField { 79 | CREATED_AT 80 | SORT_NUMBER 81 | } 82 | 83 | type Channel implements Node { 84 | annictId: Int! 85 | channelGroup: ChannelGroup! 86 | id: ID! 87 | name: String! 88 | programs( 89 | # Returns the elements in the list that come after the specified cursor. 90 | after: String 91 | # Returns the elements in the list that come before the specified cursor. 92 | before: String 93 | # Returns the first _n_ elements from the list. 94 | first: Int 95 | # Returns the last _n_ elements from the list. 96 | last: Int 97 | ): ProgramConnection 98 | published: Boolean! 99 | scChid: Int! 100 | } 101 | 102 | # The connection type for Channel. 103 | type ChannelConnection { 104 | # A list of edges. 105 | edges: [ChannelEdge] 106 | # A list of nodes. 107 | nodes: [Channel] 108 | # Information to aid in pagination. 109 | pageInfo: PageInfo! 110 | } 111 | 112 | # An edge in a connection. 113 | type ChannelEdge { 114 | # A cursor for use in pagination. 115 | cursor: String! 116 | # The item at the end of the edge. 117 | node: Channel 118 | } 119 | 120 | type ChannelGroup implements Node { 121 | annictId: Int! 122 | channels( 123 | # Returns the elements in the list that come after the specified cursor. 124 | after: String 125 | # Returns the elements in the list that come before the specified cursor. 126 | before: String 127 | # Returns the first _n_ elements from the list. 128 | first: Int 129 | # Returns the last _n_ elements from the list. 130 | last: Int 131 | ): ChannelConnection 132 | id: ID! 133 | name: String! 134 | sortNumber: Int! 135 | } 136 | 137 | type Character implements Node { 138 | age: String! 139 | ageEn: String! 140 | annictId: Int! 141 | birthday: String! 142 | birthdayEn: String! 143 | bloodType: String! 144 | bloodTypeEn: String! 145 | description: String! 146 | descriptionEn: String! 147 | descriptionSource: String! 148 | descriptionSourceEn: String! 149 | favoriteCharactersCount: Int! 150 | height: String! 151 | heightEn: String! 152 | id: ID! 153 | name: String! 154 | nameEn: String! 155 | nameKana: String! 156 | nationality: String! 157 | nationalityEn: String! 158 | nickname: String! 159 | nicknameEn: String! 160 | occupation: String! 161 | occupationEn: String! 162 | series: Series! 163 | weight: String! 164 | weightEn: String! 165 | } 166 | 167 | # The connection type for Character. 168 | type CharacterConnection { 169 | # A list of edges. 170 | edges: [CharacterEdge] 171 | # A list of nodes. 172 | nodes: [Character] 173 | # Information to aid in pagination. 174 | pageInfo: PageInfo! 175 | } 176 | 177 | # An edge in a connection. 178 | type CharacterEdge { 179 | # A cursor for use in pagination. 180 | cursor: String! 181 | # The item at the end of the edge. 182 | node: Character 183 | } 184 | 185 | input CharacterOrder { 186 | field: CharacterOrderField! 187 | direction: OrderDirection! 188 | } 189 | 190 | enum CharacterOrderField { 191 | CREATED_AT 192 | FAVORITE_CHARACTERS_COUNT 193 | } 194 | 195 | # Autogenerated input type of CreateRecord 196 | input CreateRecordInput { 197 | episodeId: ID! 198 | comment: String 199 | ratingState: RatingState 200 | shareTwitter: Boolean 201 | shareFacebook: Boolean 202 | # A unique identifier for the client performing the mutation. 203 | clientMutationId: String 204 | } 205 | 206 | # Autogenerated return type of CreateRecord 207 | type CreateRecordPayload { 208 | # A unique identifier for the client performing the mutation. 209 | clientMutationId: String 210 | record: Record 211 | } 212 | 213 | # Autogenerated input type of CreateReview 214 | input CreateReviewInput { 215 | workId: ID! 216 | title: String 217 | body: String! 218 | ratingOverallState: RatingState 219 | ratingAnimationState: RatingState 220 | ratingMusicState: RatingState 221 | ratingStoryState: RatingState 222 | ratingCharacterState: RatingState 223 | shareTwitter: Boolean 224 | shareFacebook: Boolean 225 | # A unique identifier for the client performing the mutation. 226 | clientMutationId: String 227 | } 228 | 229 | # Autogenerated return type of CreateReview 230 | type CreateReviewPayload { 231 | # A unique identifier for the client performing the mutation. 232 | clientMutationId: String 233 | review: Review 234 | } 235 | 236 | scalar DateTime 237 | 238 | # Autogenerated input type of DeleteRecord 239 | input DeleteRecordInput { 240 | recordId: ID! 241 | # A unique identifier for the client performing the mutation. 242 | clientMutationId: String 243 | } 244 | 245 | # Autogenerated return type of DeleteRecord 246 | type DeleteRecordPayload { 247 | # A unique identifier for the client performing the mutation. 248 | clientMutationId: String 249 | episode: Episode 250 | } 251 | 252 | # Autogenerated input type of DeleteReview 253 | input DeleteReviewInput { 254 | reviewId: ID! 255 | # A unique identifier for the client performing the mutation. 256 | clientMutationId: String 257 | } 258 | 259 | # Autogenerated return type of DeleteReview 260 | type DeleteReviewPayload { 261 | # A unique identifier for the client performing the mutation. 262 | clientMutationId: String 263 | work: Work 264 | } 265 | 266 | # An episode of a work 267 | type Episode implements Node { 268 | annictId: Int! 269 | id: ID! 270 | nextEpisode: Episode 271 | number: Int 272 | numberText: String 273 | prevEpisode: Episode 274 | recordCommentsCount: Int! 275 | records( 276 | # Returns the elements in the list that come after the specified cursor. 277 | after: String 278 | # Returns the elements in the list that come before the specified cursor. 279 | before: String 280 | # Returns the first _n_ elements from the list. 281 | first: Int 282 | # Returns the last _n_ elements from the list. 283 | last: Int 284 | orderBy: RecordOrder 285 | hasComment: Boolean 286 | ): RecordConnection 287 | recordsCount: Int! 288 | satisfactionRate: Float 289 | sortNumber: Int! 290 | title: String 291 | viewerDidTrack: Boolean! 292 | viewerRecordsCount: Int! 293 | work: Work! 294 | } 295 | 296 | # The connection type for Episode. 297 | type EpisodeConnection { 298 | # A list of edges. 299 | edges: [EpisodeEdge] 300 | # A list of nodes. 301 | nodes: [Episode] 302 | # Information to aid in pagination. 303 | pageInfo: PageInfo! 304 | } 305 | 306 | # An edge in a connection. 307 | type EpisodeEdge { 308 | # A cursor for use in pagination. 309 | cursor: String! 310 | # The item at the end of the edge. 311 | node: Episode 312 | } 313 | 314 | input EpisodeOrder { 315 | field: EpisodeOrderField! 316 | direction: OrderDirection! 317 | } 318 | 319 | enum EpisodeOrderField { 320 | CREATED_AT 321 | SORT_NUMBER 322 | } 323 | 324 | type LibraryEntry implements Node { 325 | id: ID! 326 | nextEpisode: Episode 327 | nextProgram: Program 328 | note: String! 329 | status: Status 330 | user: User! 331 | work: Work! 332 | } 333 | 334 | # The connection type for LibraryEntry. 335 | type LibraryEntryConnection { 336 | # A list of edges. 337 | edges: [LibraryEntryEdge] 338 | # A list of nodes. 339 | nodes: [LibraryEntry] 340 | # Information to aid in pagination. 341 | pageInfo: PageInfo! 342 | } 343 | 344 | # An edge in a connection. 345 | type LibraryEntryEdge { 346 | # A cursor for use in pagination. 347 | cursor: String! 348 | # The item at the end of the edge. 349 | node: LibraryEntry 350 | } 351 | 352 | input LibraryEntryOrder { 353 | field: LibraryEntryOrderField! 354 | direction: OrderDirection! 355 | } 356 | 357 | enum LibraryEntryOrderField { 358 | # 最後に記録またはスキップした日時 359 | LAST_TRACKED_AT 360 | } 361 | 362 | # Media of anime 363 | enum Media { 364 | TV 365 | OVA 366 | MOVIE 367 | WEB 368 | OTHER 369 | } 370 | 371 | type MultipleRecord implements Node { 372 | annictId: Int! 373 | createdAt: DateTime! 374 | id: ID! 375 | records( 376 | # Returns the elements in the list that come after the specified cursor. 377 | after: String 378 | # Returns the elements in the list that come before the specified cursor. 379 | before: String 380 | # Returns the first _n_ elements from the list. 381 | first: Int 382 | # Returns the last _n_ elements from the list. 383 | last: Int 384 | ): RecordConnection 385 | user: User! 386 | work: Work! 387 | } 388 | 389 | type Mutation { 390 | createRecord( 391 | # Parameters for CreateRecord 392 | input: CreateRecordInput! 393 | ): CreateRecordPayload 394 | createReview( 395 | # Parameters for CreateReview 396 | input: CreateReviewInput! 397 | ): CreateReviewPayload 398 | deleteRecord( 399 | # Parameters for DeleteRecord 400 | input: DeleteRecordInput! 401 | ): DeleteRecordPayload 402 | deleteReview( 403 | # Parameters for DeleteReview 404 | input: DeleteReviewInput! 405 | ): DeleteReviewPayload 406 | updateRecord( 407 | # Parameters for UpdateRecord 408 | input: UpdateRecordInput! 409 | ): UpdateRecordPayload 410 | updateReview( 411 | # Parameters for UpdateReview 412 | input: UpdateReviewInput! 413 | ): UpdateReviewPayload 414 | updateStatus( 415 | # Parameters for UpdateStatus 416 | input: UpdateStatusInput! 417 | ): UpdateStatusPayload 418 | } 419 | 420 | # An object with an ID. 421 | interface Node { 422 | # ID of the object. 423 | id: ID! 424 | } 425 | 426 | enum OrderDirection { 427 | ASC 428 | DESC 429 | } 430 | 431 | type Organization implements Node { 432 | annictId: Int! 433 | favoriteOrganizationsCount: Int! 434 | id: ID! 435 | name: String! 436 | nameEn: String! 437 | nameKana: String! 438 | staffsCount: Int! 439 | twitterUsername: String! 440 | twitterUsernameEn: String! 441 | url: String! 442 | urlEn: String! 443 | wikipediaUrl: String! 444 | wikipediaUrlEn: String! 445 | } 446 | 447 | # The connection type for Organization. 448 | type OrganizationConnection { 449 | # A list of edges. 450 | edges: [OrganizationEdge] 451 | # A list of nodes. 452 | nodes: [Organization] 453 | # Information to aid in pagination. 454 | pageInfo: PageInfo! 455 | } 456 | 457 | # An edge in a connection. 458 | type OrganizationEdge { 459 | # A cursor for use in pagination. 460 | cursor: String! 461 | # The item at the end of the edge. 462 | node: Organization 463 | } 464 | 465 | input OrganizationOrder { 466 | field: OrganizationOrderField! 467 | direction: OrderDirection! 468 | } 469 | 470 | enum OrganizationOrderField { 471 | CREATED_AT 472 | FAVORITE_ORGANIZATIONS_COUNT 473 | } 474 | 475 | # Information about pagination in a connection. 476 | type PageInfo { 477 | # When paginating forwards, the cursor to continue. 478 | endCursor: String 479 | # When paginating forwards, are there more items? 480 | hasNextPage: Boolean! 481 | # When paginating backwards, are there more items? 482 | hasPreviousPage: Boolean! 483 | # When paginating backwards, the cursor to continue. 484 | startCursor: String 485 | } 486 | 487 | type Person implements Node { 488 | annictId: Int! 489 | birthday: String! 490 | bloodType: String! 491 | castsCount: Int! 492 | favoritePeopleCount: Int! 493 | genderText: String! 494 | height: String! 495 | id: ID! 496 | name: String! 497 | nameEn: String! 498 | nameKana: String! 499 | nickname: String! 500 | nicknameEn: String! 501 | prefecture: Prefecture! 502 | staffsCount: Int! 503 | twitterUsername: String! 504 | twitterUsernameEn: String! 505 | url: String! 506 | urlEn: String! 507 | wikipediaUrl: String! 508 | wikipediaUrlEn: String! 509 | } 510 | 511 | # The connection type for Person. 512 | type PersonConnection { 513 | # A list of edges. 514 | edges: [PersonEdge] 515 | # A list of nodes. 516 | nodes: [Person] 517 | # Information to aid in pagination. 518 | pageInfo: PageInfo! 519 | } 520 | 521 | # An edge in a connection. 522 | type PersonEdge { 523 | # A cursor for use in pagination. 524 | cursor: String! 525 | # The item at the end of the edge. 526 | node: Person 527 | } 528 | 529 | input PersonOrder { 530 | field: PersonOrderField! 531 | direction: OrderDirection! 532 | } 533 | 534 | enum PersonOrderField { 535 | CREATED_AT 536 | FAVORITE_PEOPLE_COUNT 537 | } 538 | 539 | type Prefecture implements Node { 540 | annictId: Int! 541 | id: ID! 542 | name: String! 543 | } 544 | 545 | type Program implements Node { 546 | annictId: Int! 547 | channel: Channel! 548 | episode: Episode! 549 | id: ID! 550 | rebroadcast: Boolean! 551 | scPid: Int 552 | startedAt: DateTime! 553 | state: ProgramState! 554 | work: Work! 555 | } 556 | 557 | # The connection type for Program. 558 | type ProgramConnection { 559 | # A list of edges. 560 | edges: [ProgramEdge] 561 | # A list of nodes. 562 | nodes: [Program] 563 | # Information to aid in pagination. 564 | pageInfo: PageInfo! 565 | } 566 | 567 | # An edge in a connection. 568 | type ProgramEdge { 569 | # A cursor for use in pagination. 570 | cursor: String! 571 | # The item at the end of the edge. 572 | node: Program 573 | } 574 | 575 | input ProgramOrder { 576 | field: ProgramOrderField! 577 | direction: OrderDirection! 578 | } 579 | 580 | enum ProgramOrderField { 581 | STARTED_AT 582 | } 583 | 584 | enum ProgramState { 585 | PUBLISHED 586 | HIDDEN 587 | } 588 | 589 | type Query { 590 | # Fetches an object given its ID. 591 | node( 592 | # ID of the object. 593 | id: ID! 594 | ): Node 595 | # Fetches a list of objects given a list of IDs. 596 | nodes( 597 | # IDs of the objects. 598 | ids: [ID!]! 599 | ): [Node]! 600 | searchCharacters( 601 | # Returns the elements in the list that come after the specified cursor. 602 | after: String 603 | # Returns the elements in the list that come before the specified cursor. 604 | before: String 605 | # Returns the first _n_ elements from the list. 606 | first: Int 607 | # Returns the last _n_ elements from the list. 608 | last: Int 609 | annictIds: [Int!] 610 | names: [String!] 611 | orderBy: CharacterOrder 612 | ): CharacterConnection 613 | searchEpisodes( 614 | # Returns the elements in the list that come after the specified cursor. 615 | after: String 616 | # Returns the elements in the list that come before the specified cursor. 617 | before: String 618 | # Returns the first _n_ elements from the list. 619 | first: Int 620 | # Returns the last _n_ elements from the list. 621 | last: Int 622 | annictIds: [Int!] 623 | orderBy: EpisodeOrder 624 | ): EpisodeConnection 625 | searchOrganizations( 626 | # Returns the elements in the list that come after the specified cursor. 627 | after: String 628 | # Returns the elements in the list that come before the specified cursor. 629 | before: String 630 | # Returns the first _n_ elements from the list. 631 | first: Int 632 | # Returns the last _n_ elements from the list. 633 | last: Int 634 | annictIds: [Int!] 635 | names: [String!] 636 | orderBy: OrganizationOrder 637 | ): OrganizationConnection 638 | searchPeople( 639 | # Returns the elements in the list that come after the specified cursor. 640 | after: String 641 | # Returns the elements in the list that come before the specified cursor. 642 | before: String 643 | # Returns the first _n_ elements from the list. 644 | first: Int 645 | # Returns the last _n_ elements from the list. 646 | last: Int 647 | annictIds: [Int!] 648 | names: [String!] 649 | orderBy: PersonOrder 650 | ): PersonConnection 651 | searchWorks( 652 | # Returns the elements in the list that come after the specified cursor. 653 | after: String 654 | # Returns the elements in the list that come before the specified cursor. 655 | before: String 656 | # Returns the first _n_ elements from the list. 657 | first: Int 658 | # Returns the last _n_ elements from the list. 659 | last: Int 660 | annictIds: [Int!] 661 | seasons: [String!] 662 | titles: [String!] 663 | orderBy: WorkOrder 664 | ): WorkConnection 665 | user(username: String!): User 666 | viewer: User 667 | } 668 | 669 | enum RatingState { 670 | GREAT 671 | GOOD 672 | AVERAGE 673 | BAD 674 | } 675 | 676 | type Record implements Node { 677 | annictId: Int! 678 | comment: String 679 | commentsCount: Int! 680 | createdAt: DateTime! 681 | episode: Episode! 682 | facebookClickCount: Int! 683 | id: ID! 684 | likesCount: Int! 685 | modified: Boolean! 686 | rating: Float 687 | ratingState: RatingState 688 | twitterClickCount: Int! 689 | updatedAt: DateTime! 690 | user: User! 691 | work: Work! 692 | } 693 | 694 | # The connection type for Record. 695 | type RecordConnection { 696 | # A list of edges. 697 | edges: [RecordEdge] 698 | # A list of nodes. 699 | nodes: [Record] 700 | # Information to aid in pagination. 701 | pageInfo: PageInfo! 702 | } 703 | 704 | # An edge in a connection. 705 | type RecordEdge { 706 | # A cursor for use in pagination. 707 | cursor: String! 708 | # The item at the end of the edge. 709 | node: Record 710 | } 711 | 712 | input RecordOrder { 713 | field: RecordOrderField! 714 | direction: OrderDirection! 715 | } 716 | 717 | enum RecordOrderField { 718 | CREATED_AT 719 | LIKES_COUNT 720 | } 721 | 722 | type Review implements Node { 723 | annictId: Int! 724 | body: String! 725 | createdAt: DateTime! 726 | id: ID! 727 | impressionsCount: Int! 728 | likesCount: Int! 729 | modifiedAt: DateTime 730 | ratingAnimationState: RatingState 731 | ratingCharacterState: RatingState 732 | ratingMusicState: RatingState 733 | ratingOverallState: RatingState 734 | ratingStoryState: RatingState 735 | title: String 736 | updatedAt: DateTime! 737 | user: User! 738 | work: Work! 739 | } 740 | 741 | # The connection type for Review. 742 | type ReviewConnection { 743 | # A list of edges. 744 | edges: [ReviewEdge] 745 | # A list of nodes. 746 | nodes: [Review] 747 | # Information to aid in pagination. 748 | pageInfo: PageInfo! 749 | } 750 | 751 | # An edge in a connection. 752 | type ReviewEdge { 753 | # A cursor for use in pagination. 754 | cursor: String! 755 | # The item at the end of the edge. 756 | node: Review 757 | } 758 | 759 | input ReviewOrder { 760 | field: ReviewOrderField! 761 | direction: OrderDirection! 762 | } 763 | 764 | enum ReviewOrderField { 765 | CREATED_AT 766 | LIKES_COUNT 767 | } 768 | 769 | # Season name 770 | enum SeasonName { 771 | WINTER 772 | SPRING 773 | SUMMER 774 | AUTUMN 775 | } 776 | 777 | type Series implements Node { 778 | annictId: Int! 779 | id: ID! 780 | name: String! 781 | nameEn: String! 782 | nameRo: String! 783 | works( 784 | # Returns the elements in the list that come after the specified cursor. 785 | after: String 786 | # Returns the elements in the list that come before the specified cursor. 787 | before: String 788 | # Returns the first _n_ elements from the list. 789 | first: Int 790 | # Returns the last _n_ elements from the list. 791 | last: Int 792 | orderBy: SeriesWorkOrder 793 | ): SeriesWorkConnection 794 | } 795 | 796 | # The connection type for Series. 797 | type SeriesConnection { 798 | # A list of edges. 799 | edges: [SeriesEdge] 800 | # A list of nodes. 801 | nodes: [Series] 802 | # Information to aid in pagination. 803 | pageInfo: PageInfo! 804 | } 805 | 806 | # An edge in a connection. 807 | type SeriesEdge { 808 | # A cursor for use in pagination. 809 | cursor: String! 810 | # The item at the end of the edge. 811 | node: Series 812 | } 813 | 814 | # The connection type for Work. 815 | type SeriesWorkConnection { 816 | # A list of edges. 817 | edges: [SeriesWorkEdge] 818 | # A list of nodes. 819 | nodes: [Work] 820 | # Information to aid in pagination. 821 | pageInfo: PageInfo! 822 | } 823 | 824 | # An edge in a connection. 825 | type SeriesWorkEdge { 826 | # A cursor for use in pagination. 827 | cursor: String! 828 | item: Work! 829 | # Deprecated: Use `item` instead. 830 | node: Work! @deprecated(reason: "Use `item` instead.") 831 | summary: String 832 | summaryEn: String 833 | } 834 | 835 | input SeriesWorkOrder { 836 | field: SeriesWorkOrderField! 837 | direction: OrderDirection! 838 | } 839 | 840 | enum SeriesWorkOrderField { 841 | SEASON 842 | } 843 | 844 | type Staff implements Node { 845 | annictId: Int! 846 | id: ID! 847 | name: String! 848 | nameEn: String! 849 | resource: StaffResourceItem! 850 | roleOther: String! 851 | roleOtherEn: String! 852 | roleText: String! 853 | sortNumber: Int! 854 | work: Work! 855 | } 856 | 857 | # The connection type for Staff. 858 | type StaffConnection { 859 | # A list of edges. 860 | edges: [StaffEdge] 861 | # A list of nodes. 862 | nodes: [Staff] 863 | # Information to aid in pagination. 864 | pageInfo: PageInfo! 865 | } 866 | 867 | # An edge in a connection. 868 | type StaffEdge { 869 | # A cursor for use in pagination. 870 | cursor: String! 871 | # The item at the end of the edge. 872 | node: Staff 873 | } 874 | 875 | input StaffOrder { 876 | field: StaffOrderField! 877 | direction: OrderDirection! 878 | } 879 | 880 | enum StaffOrderField { 881 | CREATED_AT 882 | SORT_NUMBER 883 | } 884 | 885 | union StaffResourceItem = Organization | Person 886 | type Status implements Node { 887 | annictId: Int! 888 | createdAt: DateTime! 889 | id: ID! 890 | likesCount: Int! 891 | state: StatusState! 892 | user: User! 893 | work: Work! 894 | } 895 | 896 | enum StatusState { 897 | WANNA_WATCH 898 | WATCHING 899 | WATCHED 900 | ON_HOLD 901 | STOP_WATCHING 902 | NO_STATE 903 | } 904 | 905 | # Autogenerated input type of UpdateRecord 906 | input UpdateRecordInput { 907 | recordId: ID! 908 | comment: String 909 | ratingState: RatingState 910 | shareTwitter: Boolean 911 | shareFacebook: Boolean 912 | # A unique identifier for the client performing the mutation. 913 | clientMutationId: String 914 | } 915 | 916 | # Autogenerated return type of UpdateRecord 917 | type UpdateRecordPayload { 918 | # A unique identifier for the client performing the mutation. 919 | clientMutationId: String 920 | record: Record 921 | } 922 | 923 | # Autogenerated input type of UpdateReview 924 | input UpdateReviewInput { 925 | reviewId: ID! 926 | title: String 927 | body: String! 928 | ratingOverallState: RatingState! 929 | ratingAnimationState: RatingState! 930 | ratingMusicState: RatingState! 931 | ratingStoryState: RatingState! 932 | ratingCharacterState: RatingState! 933 | shareTwitter: Boolean 934 | shareFacebook: Boolean 935 | # A unique identifier for the client performing the mutation. 936 | clientMutationId: String 937 | } 938 | 939 | # Autogenerated return type of UpdateReview 940 | type UpdateReviewPayload { 941 | # A unique identifier for the client performing the mutation. 942 | clientMutationId: String 943 | review: Review 944 | } 945 | 946 | # Autogenerated input type of UpdateStatus 947 | input UpdateStatusInput { 948 | workId: ID! 949 | state: StatusState! 950 | # A unique identifier for the client performing the mutation. 951 | clientMutationId: String 952 | } 953 | 954 | # Autogenerated return type of UpdateStatus 955 | type UpdateStatusPayload { 956 | # A unique identifier for the client performing the mutation. 957 | clientMutationId: String 958 | work: Work 959 | } 960 | 961 | type User implements Node { 962 | activities( 963 | # Returns the elements in the list that come after the specified cursor. 964 | after: String 965 | # Returns the elements in the list that come before the specified cursor. 966 | before: String 967 | # Returns the first _n_ elements from the list. 968 | first: Int 969 | # Returns the last _n_ elements from the list. 970 | last: Int 971 | orderBy: ActivityOrder 972 | ): ActivityConnection 973 | annictId: Int! 974 | avatarUrl: String 975 | backgroundImageUrl: String 976 | createdAt: DateTime! 977 | description: String! 978 | email: String 979 | followers( 980 | # Returns the elements in the list that come after the specified cursor. 981 | after: String 982 | # Returns the elements in the list that come before the specified cursor. 983 | before: String 984 | # Returns the first _n_ elements from the list. 985 | first: Int 986 | # Returns the last _n_ elements from the list. 987 | last: Int 988 | ): UserConnection 989 | followersCount: Int! 990 | following( 991 | # Returns the elements in the list that come after the specified cursor. 992 | after: String 993 | # Returns the elements in the list that come before the specified cursor. 994 | before: String 995 | # Returns the first _n_ elements from the list. 996 | first: Int 997 | # Returns the last _n_ elements from the list. 998 | last: Int 999 | ): UserConnection 1000 | followingActivities( 1001 | # Returns the elements in the list that come after the specified cursor. 1002 | after: String 1003 | # Returns the elements in the list that come before the specified cursor. 1004 | before: String 1005 | # Returns the first _n_ elements from the list. 1006 | first: Int 1007 | # Returns the last _n_ elements from the list. 1008 | last: Int 1009 | orderBy: ActivityOrder 1010 | ): ActivityConnection 1011 | followingsCount: Int! 1012 | id: ID! 1013 | libraryEntries( 1014 | # Returns the elements in the list that come after the specified cursor. 1015 | after: String 1016 | # Returns the elements in the list that come before the specified cursor. 1017 | before: String 1018 | # Returns the first _n_ elements from the list. 1019 | first: Int 1020 | # Returns the last _n_ elements from the list. 1021 | last: Int 1022 | # 視聴ステータス 1023 | states: [StatusState!] 1024 | # 指定したシーズンの作品を取得する 1025 | seasons: [String!] 1026 | # 指定したシーズンからの作品を取得する 1027 | seasonFrom: String 1028 | # 指定したシーズンまでの作品を取得する 1029 | seasonUntil: String 1030 | orderBy: LibraryEntryOrder 1031 | ): LibraryEntryConnection 1032 | name: String! 1033 | notificationsCount: Int 1034 | onHoldCount: Int! 1035 | programs( 1036 | # Returns the elements in the list that come after the specified cursor. 1037 | after: String 1038 | # Returns the elements in the list that come before the specified cursor. 1039 | before: String 1040 | # Returns the first _n_ elements from the list. 1041 | first: Int 1042 | # Returns the last _n_ elements from the list. 1043 | last: Int 1044 | unwatched: Boolean 1045 | orderBy: ProgramOrder 1046 | ): ProgramConnection 1047 | records( 1048 | # Returns the elements in the list that come after the specified cursor. 1049 | after: String 1050 | # Returns the elements in the list that come before the specified cursor. 1051 | before: String 1052 | # Returns the first _n_ elements from the list. 1053 | first: Int 1054 | # Returns the last _n_ elements from the list. 1055 | last: Int 1056 | orderBy: RecordOrder 1057 | hasComment: Boolean 1058 | ): RecordConnection 1059 | recordsCount: Int! 1060 | stopWatchingCount: Int! 1061 | url: String 1062 | username: String! 1063 | viewerCanFollow: Boolean! 1064 | viewerIsFollowing: Boolean! 1065 | wannaWatchCount: Int! 1066 | watchedCount: Int! 1067 | watchingCount: Int! 1068 | works( 1069 | # Returns the elements in the list that come after the specified cursor. 1070 | after: String 1071 | # Returns the elements in the list that come before the specified cursor. 1072 | before: String 1073 | # Returns the first _n_ elements from the list. 1074 | first: Int 1075 | # Returns the last _n_ elements from the list. 1076 | last: Int 1077 | annictIds: [Int!] 1078 | seasons: [String!] 1079 | titles: [String!] 1080 | state: StatusState 1081 | orderBy: WorkOrder 1082 | ): WorkConnection 1083 | } 1084 | 1085 | # The connection type for User. 1086 | type UserConnection { 1087 | # A list of edges. 1088 | edges: [UserEdge] 1089 | # A list of nodes. 1090 | nodes: [User] 1091 | # Information to aid in pagination. 1092 | pageInfo: PageInfo! 1093 | } 1094 | 1095 | # An edge in a connection. 1096 | type UserEdge { 1097 | # A cursor for use in pagination. 1098 | cursor: String! 1099 | # The item at the end of the edge. 1100 | node: User 1101 | } 1102 | 1103 | # An anime title 1104 | type Work implements Node { 1105 | annictId: Int! 1106 | casts( 1107 | # Returns the elements in the list that come after the specified cursor. 1108 | after: String 1109 | # Returns the elements in the list that come before the specified cursor. 1110 | before: String 1111 | # Returns the first _n_ elements from the list. 1112 | first: Int 1113 | # Returns the last _n_ elements from the list. 1114 | last: Int 1115 | orderBy: CastOrder 1116 | ): CastConnection 1117 | episodes( 1118 | # Returns the elements in the list that come after the specified cursor. 1119 | after: String 1120 | # Returns the elements in the list that come before the specified cursor. 1121 | before: String 1122 | # Returns the first _n_ elements from the list. 1123 | first: Int 1124 | # Returns the last _n_ elements from the list. 1125 | last: Int 1126 | orderBy: EpisodeOrder 1127 | ): EpisodeConnection 1128 | episodesCount: Int! 1129 | id: ID! 1130 | image: WorkImage 1131 | malAnimeId: String 1132 | media: Media! 1133 | noEpisodes: Boolean! 1134 | officialSiteUrl: String 1135 | officialSiteUrlEn: String 1136 | programs( 1137 | # Returns the elements in the list that come after the specified cursor. 1138 | after: String 1139 | # Returns the elements in the list that come before the specified cursor. 1140 | before: String 1141 | # Returns the first _n_ elements from the list. 1142 | first: Int 1143 | # Returns the last _n_ elements from the list. 1144 | last: Int 1145 | orderBy: ProgramOrder 1146 | ): ProgramConnection 1147 | reviews( 1148 | # Returns the elements in the list that come after the specified cursor. 1149 | after: String 1150 | # Returns the elements in the list that come before the specified cursor. 1151 | before: String 1152 | # Returns the first _n_ elements from the list. 1153 | first: Int 1154 | # Returns the last _n_ elements from the list. 1155 | last: Int 1156 | orderBy: ReviewOrder 1157 | hasBody: Boolean 1158 | ): ReviewConnection 1159 | reviewsCount: Int! 1160 | satisfactionRate: Float 1161 | seasonName: SeasonName 1162 | seasonYear: Int 1163 | seriesList( 1164 | # Returns the elements in the list that come after the specified cursor. 1165 | after: String 1166 | # Returns the elements in the list that come before the specified cursor. 1167 | before: String 1168 | # Returns the first _n_ elements from the list. 1169 | first: Int 1170 | # Returns the last _n_ elements from the list. 1171 | last: Int 1172 | ): SeriesConnection 1173 | staffs( 1174 | # Returns the elements in the list that come after the specified cursor. 1175 | after: String 1176 | # Returns the elements in the list that come before the specified cursor. 1177 | before: String 1178 | # Returns the first _n_ elements from the list. 1179 | first: Int 1180 | # Returns the last _n_ elements from the list. 1181 | last: Int 1182 | orderBy: StaffOrder 1183 | ): StaffConnection 1184 | syobocalTid: Int 1185 | title: String! 1186 | titleEn: String 1187 | titleKana: String 1188 | titleRo: String 1189 | twitterHashtag: String 1190 | twitterUsername: String 1191 | viewerStatusState: StatusState 1192 | watchersCount: Int! 1193 | wikipediaUrl: String 1194 | wikipediaUrlEn: String 1195 | } 1196 | 1197 | # The connection type for Work. 1198 | type WorkConnection { 1199 | # A list of edges. 1200 | edges: [WorkEdge] 1201 | # A list of nodes. 1202 | nodes: [Work] 1203 | # Information to aid in pagination. 1204 | pageInfo: PageInfo! 1205 | } 1206 | 1207 | # An edge in a connection. 1208 | type WorkEdge { 1209 | # A cursor for use in pagination. 1210 | cursor: String! 1211 | # The item at the end of the edge. 1212 | node: Work 1213 | } 1214 | 1215 | type WorkImage implements Node { 1216 | annictId: Int 1217 | copyright: String 1218 | facebookOgImageUrl: String 1219 | id: ID! 1220 | internalUrl(size: String!): String 1221 | recommendedImageUrl: String 1222 | twitterAvatarUrl: String 1223 | twitterBiggerAvatarUrl: String 1224 | twitterMiniAvatarUrl: String 1225 | twitterNormalAvatarUrl: String 1226 | work: Work 1227 | } 1228 | 1229 | input WorkOrder { 1230 | field: WorkOrderField! 1231 | direction: OrderDirection! 1232 | } 1233 | 1234 | enum WorkOrderField { 1235 | CREATED_AT 1236 | SEASON 1237 | WATCHERS_COUNT 1238 | } 1239 | -------------------------------------------------------------------------------- /annictGqlNaming.js: -------------------------------------------------------------------------------- 1 | const FixedConstantCase = (str) => { 2 | if (str === "Record") { 3 | return "WatchRecord" 4 | } 5 | return str 6 | } 7 | module.exports = FixedConstantCase 8 | -------------------------------------------------------------------------------- /gql-documents/anilist/lib.gql: -------------------------------------------------------------------------------- 1 | query queryLibrary($userId: Int!, $sort: [MediaListSort!]!, $perChunk: Int!, $chunk: Int!, $status: MediaListStatus!) { 2 | MediaListCollection(userId: $userId, sort: $sort, perChunk: $perChunk, chunk: $chunk, type: ANIME, forceSingleCompletedList: true, status: $status) { 3 | lists { 4 | entries { 5 | id 6 | progress 7 | status 8 | media { 9 | id 10 | idMal 11 | title { 12 | english 13 | romaji 14 | native 15 | } 16 | episodes 17 | } 18 | } 19 | } 20 | hasNextChunk 21 | } 22 | } 23 | 24 | query queryWorks($workIds: [Int!]!) { 25 | MediaList(mediaId_in: $workIds) { 26 | status 27 | progress 28 | media { 29 | id 30 | idMal 31 | title { 32 | english 33 | romaji 34 | native 35 | } 36 | episodes 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /gql-documents/anilist/me.gql: -------------------------------------------------------------------------------- 1 | query getMe { 2 | Viewer { 3 | id 4 | name 5 | avatar { 6 | large 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /gql-documents/anilist/update.gql: -------------------------------------------------------------------------------- 1 | mutation createMediaStatus($id: Int!, $status: MediaListStatus!, $numWatchedEpisodes: Int!) { 2 | SaveMediaListEntry(mediaId: $id, status: $status, progress: $numWatchedEpisodes) { 3 | id 4 | } 5 | } 6 | 7 | mutation updateMediaStatus($id: Int!, $status: MediaListStatus!, $numWatchedEpisodes: Int!) { 8 | UpdateMediaListEntries(ids: [$id], status: $status, progress: $numWatchedEpisodes) { 9 | id 10 | } 11 | } 12 | 13 | mutation deleteMediaStatus($id: Int!) { 14 | DeleteMediaListEntry(id: $id) { 15 | deleted 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /gql-documents/annict/lib.gql: -------------------------------------------------------------------------------- 1 | query queryLibrary($states: [StatusState!], $after: String, $amount: Int) { 2 | viewer { 3 | libraryEntries(states: $states, after: $after, first: $amount) { 4 | nodes { 5 | work { 6 | id 7 | annictId 8 | malAnimeId 9 | titleEn 10 | titleRo 11 | title 12 | noEpisodes 13 | episodes { 14 | nodes { 15 | viewerDidTrack 16 | } 17 | } 18 | viewerStatusState 19 | } 20 | } 21 | pageInfo { 22 | hasNextPage 23 | hasPreviousPage 24 | endCursor 25 | } 26 | } 27 | } 28 | } 29 | 30 | query queryWorks($workIds: [Int!]) { 31 | searchWorks(annictIds: $workIds) { 32 | nodes { 33 | id 34 | annictId 35 | malAnimeId 36 | titleEn 37 | titleRo 38 | title 39 | noEpisodes 40 | episodes { 41 | nodes { 42 | viewerDidTrack 43 | } 44 | } 45 | viewerStatusState 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /gql-documents/annict/me.gql: -------------------------------------------------------------------------------- 1 | query getMe { 2 | viewer { 3 | username 4 | name 5 | avatarUrl 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Imau - Sync your viewing status from Annict to MAL/AniList. 7 | 8 | 9 | 10 | 11 | 15 | 19 | 20 | 21 | 22 |
23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [[redirects]] 2 | from = "/mal/*" 3 | to = "https://api.myanimelist.net/v2/:splat" 4 | status = 200 -------------------------------------------------------------------------------- /netlify/functions/anilist-callback.ts: -------------------------------------------------------------------------------- 1 | import { Handler } from "@netlify/functions" 2 | import axios from "axios" 3 | import cookie from "cookie" 4 | 5 | const handler: Handler = async (event) => { 6 | const { code, state, error } = event.queryStringParameters 7 | if (typeof error === "string") { 8 | return { 9 | statusCode: 403, 10 | body: "access denied.", 11 | headers: { 12 | Location: "/", 13 | }, 14 | } 15 | } 16 | if (typeof code !== "string" || typeof state !== "string") { 17 | return { 18 | statusCode: 400, 19 | body: "Bad request", 20 | } 21 | } 22 | const cookies = cookie.parse(event.headers.cookie || "") 23 | const challange = cookies.challlange 24 | if (!challange) { 25 | return { 26 | statusCode: 400, 27 | body: "Bad request(challlange not found)", 28 | } 29 | } 30 | const result = await axios.post( 31 | "https://anilist.co/api/v2/oauth/token", 32 | new URLSearchParams({ 33 | grant_type: "authorization_code", 34 | code, 35 | client_id: process.env.VITE_ANILIST_CLIENT_ID, 36 | client_secret: process.env.ANILIST_CLIENT_SECRET, 37 | redirect_uri: process.env.VITE_ANILIST_REDIRECT_URL, 38 | code_verifier: challange, 39 | }), 40 | { 41 | headers: { 42 | "User-Agent": "imau/1.0 (+https://imau.netlify.app)", 43 | }, 44 | } 45 | ) 46 | if (typeof result.data.access_token !== "string") { 47 | return { 48 | statusCode: 400, 49 | body: "try again", 50 | } 51 | } 52 | return { 53 | statusCode: 200, 54 | headers: { 55 | "Content-Type": "text/html", 56 | }, 57 | body: `` 58 | } 59 | } 60 | 61 | export { handler } 62 | -------------------------------------------------------------------------------- /netlify/functions/annict-callback.ts: -------------------------------------------------------------------------------- 1 | import { Handler } from "@netlify/functions" 2 | import axios from "axios" 3 | 4 | const handler: Handler = async (event) => { 5 | const { code, error } = event.queryStringParameters 6 | if (typeof error === "string") { 7 | return { 8 | statusCode: 403, 9 | body: "access denied.", 10 | headers: { 11 | Location: "/", 12 | }, 13 | } 14 | } 15 | if (typeof code !== "string") { 16 | return { 17 | statusCode: 400, 18 | body: "Bad request", 19 | } 20 | } 21 | const result = await axios.post( 22 | "https://api.annict.com/oauth/token", 23 | { 24 | grant_type: "authorization_code", 25 | code, 26 | client_id: process.env.VITE_ANNICT_CLIENT_ID, 27 | client_secret: process.env.ANNICT_CLIENT_SECRET, 28 | redirect_uri: process.env.VITE_ANNICT_REDIRECT_URL, 29 | }, 30 | { 31 | headers: { 32 | "User-Agent": "imau/1.0 (+https://imau.netlify.app)", 33 | }, 34 | } 35 | ) 36 | if (typeof result.data.access_token !== "string") { 37 | return { 38 | statusCode: 400, 39 | body: "try again", 40 | } 41 | } 42 | return { 43 | statusCode: 200, 44 | headers: { 45 | "Content-Type": "text/html", 46 | }, 47 | body: `` 48 | } 49 | } 50 | 51 | export { handler } 52 | -------------------------------------------------------------------------------- /netlify/functions/mal-callback.ts: -------------------------------------------------------------------------------- 1 | import { Handler } from "@netlify/functions" 2 | import axios from "axios" 3 | import cookie from "cookie" 4 | 5 | const handler: Handler = async (event) => { 6 | const { code, state, error } = event.queryStringParameters 7 | if (typeof error === "string") { 8 | return { 9 | statusCode: 403, 10 | body: "access denied.", 11 | headers: { 12 | Location: "/", 13 | }, 14 | } 15 | } 16 | if (typeof code !== "string" || typeof state !== "string") { 17 | return { 18 | statusCode: 400, 19 | body: "Bad request", 20 | } 21 | } 22 | const cookies = cookie.parse(event.headers.cookie || "") 23 | const challange = cookies.challlange 24 | if (!challange) { 25 | return { 26 | statusCode: 400, 27 | body: "Bad request(challlange not found)", 28 | } 29 | } 30 | const result = await axios.post( 31 | "https://myanimelist.net/v1/oauth2/token", 32 | new URLSearchParams({ 33 | grant_type: "authorization_code", 34 | code, 35 | client_id: process.env.VITE_MAL_CLIENT_ID, 36 | client_secret: process.env.MAL_CLIENT_SECRET, 37 | redirect_uri: process.env.VITE_MAL_REDIRECT_URL, 38 | code_verifier: challange, 39 | }), 40 | { 41 | headers: { 42 | "User-Agent": "imau/1.0 (+https://imau.netlify.app)", 43 | }, 44 | } 45 | ) 46 | if (typeof result.data.access_token !== "string") { 47 | return { 48 | statusCode: 400, 49 | body: "try again", 50 | } 51 | } 52 | return { 53 | statusCode: 200, 54 | headers: { 55 | "Content-Type": "text/html", 56 | }, 57 | body: `` 58 | } 59 | } 60 | 61 | export { handler } 62 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "imau", 3 | "version": "1.0.0", 4 | "description": "Sync your viewing status from Annict to MAL/AniList.", 5 | "author": "ci7lus <7887955+ci7lus@users.noreply.github.com>", 6 | "license": "MIT", 7 | "repository": { 8 | "type": "git", 9 | "url": "git@github.com:ci7lus/imau.git" 10 | }, 11 | "scripts": { 12 | "dev": "vite", 13 | "build": "vite build", 14 | "lint:prettier": "prettier --check './src/**/*.{js,ts,tsx}'", 15 | "format:prettier": "prettier --write './src/**/*.{js,ts,tsx}'", 16 | "lint:eslint": "eslint --max-warnings 0 --cache './src/**/*.{js,ts,tsx}'", 17 | "format:eslint": "eslint './src/**/*.{js,ts,tsx}' --cache --fix", 18 | "codegen": "concurrently -n codegen: 'yarn:codegen:*'", 19 | "codegen:annict": "graphql-codegen --config ./annict-codegen.yml && yarn format:prettier", 20 | "codegen:anilist": "graphql-codegen --config ./anilist-codegen.yml && yarn format:prettier" 21 | }, 22 | "devDependencies": { 23 | "@graphql-codegen/add": "^5.0.0", 24 | "@graphql-codegen/cli": "^5.0.0", 25 | "@graphql-codegen/typescript": "^4.0.1", 26 | "@graphql-codegen/typescript-graphql-request": "^6.0.0", 27 | "@graphql-codegen/typescript-operations": "^4.0.1", 28 | "@types/cookie": "^0.5.3", 29 | "@types/react": "^18.2.31", 30 | "@types/react-dom": "^18.2.14", 31 | "@vitejs/plugin-react": "^4.1.0", 32 | "concurrently": "^8.2.2", 33 | "eslint": "^8.52.0", 34 | "eslint-plugin-react": "^7.33.2", 35 | "eslint-plugin-react-hooks": "^4.6.0", 36 | "husky": "^8.0.3", 37 | "lint-staged": "^15.0.2", 38 | "postcss": "^8.4.31", 39 | "postcss-preset-mantine": "^1.9.0", 40 | "postcss-simple-vars": "^7.0.1", 41 | "prettier": "^3.0.3", 42 | "ts-node": "^10.9.1", 43 | "typescript": "^5.2.2", 44 | "vite": "^4.5.0" 45 | }, 46 | "lint-staged": { 47 | "*.{js,ts,tsx}": "eslint --max-warnings 0 --cache", 48 | "*.{js,ts,tsx,md}": "prettier" 49 | }, 50 | "dependencies": { 51 | "@ci7lus/eslint-config": "^1.2.1", 52 | "@mantine/core": "^7.1.5", 53 | "@mantine/hooks": "^7.1.5", 54 | "@mantine/notifications": "^7.1.5", 55 | "@netlify/functions": "^2.3.0", 56 | "axios": "^1.5.1", 57 | "cookie": "^0.5.0", 58 | "graphql": "^16.8.1", 59 | "mal-ts": "^1.1.0", 60 | "react": "^18.2.0", 61 | "react-dom": "^18.2.0", 62 | "react-query": "^3.39.3", 63 | "tabler-icons-react": "^1.56.0" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | "postcss-preset-mantine": {}, 4 | "postcss-simple-vars": { 5 | variables: { 6 | "mantine-breakpoint-xs": "36em", 7 | "mantine-breakpoint-sm": "48em", 8 | "mantine-breakpoint-md": "62em", 9 | "mantine-breakpoint-lg": "75em", 10 | "mantine-breakpoint-xl": "88em", 11 | }, 12 | }, 13 | }, 14 | } 15 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | MantineProvider, 3 | Container, 4 | Text, 5 | Space, 6 | Anchor, 7 | Center, 8 | } from "@mantine/core" 9 | import { Notifications } from "@mantine/notifications" 10 | import React from "react" 11 | import { QueryClientProvider } from "react-query" 12 | import { Main } from "./components/Main" 13 | import { queryClient } from "./query" 14 | // eslint-disable-next-line import/no-unresolved 15 | import "@mantine/core/styles.css" 16 | // eslint-disable-next-line import/no-unresolved 17 | import "@mantine/notifications/styles.css" 18 | 19 | export const App = () => { 20 | return ( 21 | 22 | 23 | 24 | 25 | 26 |
27 | 28 | 29 | 30 |
31 | 32 | imau made with ❤️ by{" "} 33 | 38 | @kokoro@annict.com 39 | {" "} 40 | ( 41 | 46 | @kanoshiho@myanimelist.net 47 | 48 | ,{" "} 49 | 54 | @neneka@anilist.co 55 | 56 | ). 57 | 58 | 64 | Source code 65 | 66 |
67 | 68 |
69 | 70 | 71 | ) 72 | } 73 | -------------------------------------------------------------------------------- /src/aniList.ts: -------------------------------------------------------------------------------- 1 | import { MediaListStatus } from "./aniListGql" 2 | import { StatusState } from "./annictGql" 3 | 4 | export const ANILIST_TO_ANNICT_STATUS_MAP: { 5 | [key in MediaListStatus]: keyof typeof StatusState 6 | } = { 7 | CURRENT: StatusState.WATCHING, 8 | COMPLETED: StatusState.WATCHED, 9 | PAUSED: StatusState.ON_HOLD, 10 | DROPPED: StatusState.STOP_WATCHING, 11 | PLANNING: StatusState.WANNA_WATCH, 12 | REPEATING: StatusState.WATCHING, 13 | } 14 | 15 | export const ANNICT_TO_ANILIST_STATUS_MAP: { 16 | [key in keyof typeof StatusState]: MediaListStatus 17 | } = { 18 | WATCHING: MediaListStatus.Current, 19 | WATCHED: MediaListStatus.Completed, 20 | ON_HOLD: MediaListStatus.Paused, 21 | STOP_WATCHING: MediaListStatus.Dropped, 22 | WANNA_WATCH: MediaListStatus.Planning, 23 | // 実際には来ない 24 | NO_STATE: MediaListStatus.Dropped, 25 | } 26 | -------------------------------------------------------------------------------- /src/aniListApiEntry.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLClient } from "graphql-request" 2 | import { getSdk } from "./aniListGql" 3 | 4 | const ANILIST_GRAPHQL_ENDPOINT = "https://graphql.anilist.co" 5 | 6 | export const generateGqlClient = (accessToken: string) => { 7 | const client = new GraphQLClient(ANILIST_GRAPHQL_ENDPOINT, { 8 | headers: { 9 | Authorization: `Bearer ${accessToken}`, 10 | }, 11 | }) 12 | return getSdk(client) 13 | } 14 | -------------------------------------------------------------------------------- /src/annictApiEntry.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLClient } from "graphql-request" 2 | import { getSdk } from "./annictGql" 3 | 4 | export const ANNICT_GRAPHQL_ENDPOINT = "https://api.annict.com/graphql" 5 | 6 | export const generateGqlClient = (accessToken: string) => { 7 | const client = new GraphQLClient(ANNICT_GRAPHQL_ENDPOINT, { 8 | headers: { 9 | Authorization: `Bearer ${accessToken}`, 10 | }, 11 | }) 12 | return getSdk(client) 13 | } 14 | -------------------------------------------------------------------------------- /src/annictGql.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | import { GraphQLClient } from "graphql-request" 3 | import { GraphQLClientRequestHeaders } from "graphql-request/build/cjs/types" 4 | import gql from "graphql-tag" 5 | export type Maybe = T | null 6 | export type InputMaybe = Maybe 7 | export type Exact = { 8 | [K in keyof T]: T[K] 9 | } 10 | export type MakeOptional = Omit & { 11 | [SubKey in K]?: Maybe 12 | } 13 | export type MakeMaybe = Omit & { 14 | [SubKey in K]: Maybe 15 | } 16 | export type MakeEmpty< 17 | T extends { [key: string]: unknown }, 18 | K extends keyof T, 19 | > = { [_ in K]?: never } 20 | export type Incremental = 21 | | T 22 | | { 23 | [P in keyof T]?: P extends " $fragmentName" | "__typename" ? T[P] : never 24 | } 25 | /** All built-in and custom scalars, mapped to their actual values */ 26 | export type Scalars = { 27 | ID: { input: string; output: string } 28 | String: { input: string; output: string } 29 | Boolean: { input: boolean; output: boolean } 30 | Int: { input: number; output: number } 31 | Float: { input: number; output: number } 32 | DateTime: { input: any; output: any } 33 | } 34 | 35 | export type Activity = Node & { 36 | __typename?: "Activity" 37 | annictId: Scalars["Int"]["output"] 38 | id: Scalars["ID"]["output"] 39 | user: User 40 | } 41 | 42 | export enum ActivityAction { 43 | CREATE = "CREATE", 44 | } 45 | 46 | export type ActivityConnection = { 47 | __typename?: "ActivityConnection" 48 | edges: Maybe>> 49 | nodes: Maybe>> 50 | pageInfo: PageInfo 51 | } 52 | 53 | export type ActivityEdge = { 54 | __typename?: "ActivityEdge" 55 | action: ActivityAction 56 | annictId: Scalars["Int"]["output"] 57 | cursor: Scalars["String"]["output"] 58 | item: Maybe 59 | /** @deprecated Use `item` instead. */ 60 | node: Maybe 61 | user: User 62 | } 63 | 64 | export type ActivityItem = MultipleRecord | WatchRecord | Review | Status 65 | 66 | export type ActivityOrder = { 67 | direction: OrderDirection 68 | field: ActivityOrderField 69 | } 70 | 71 | export enum ActivityOrderField { 72 | CREATED_AT = "CREATED_AT", 73 | } 74 | 75 | export type Cast = Node & { 76 | __typename?: "Cast" 77 | annictId: Scalars["Int"]["output"] 78 | character: Character 79 | id: Scalars["ID"]["output"] 80 | name: Scalars["String"]["output"] 81 | nameEn: Scalars["String"]["output"] 82 | person: Person 83 | sortNumber: Scalars["Int"]["output"] 84 | work: Work 85 | } 86 | 87 | export type CastConnection = { 88 | __typename?: "CastConnection" 89 | edges: Maybe>> 90 | nodes: Maybe>> 91 | pageInfo: PageInfo 92 | } 93 | 94 | export type CastEdge = { 95 | __typename?: "CastEdge" 96 | cursor: Scalars["String"]["output"] 97 | node: Maybe 98 | } 99 | 100 | export type CastOrder = { 101 | direction: OrderDirection 102 | field: CastOrderField 103 | } 104 | 105 | export enum CastOrderField { 106 | CREATED_AT = "CREATED_AT", 107 | SORT_NUMBER = "SORT_NUMBER", 108 | } 109 | 110 | export type Channel = Node & { 111 | __typename?: "Channel" 112 | annictId: Scalars["Int"]["output"] 113 | channelGroup: ChannelGroup 114 | id: Scalars["ID"]["output"] 115 | name: Scalars["String"]["output"] 116 | programs: Maybe 117 | published: Scalars["Boolean"]["output"] 118 | scChid: Scalars["Int"]["output"] 119 | } 120 | 121 | export type ChannelprogramsArgs = { 122 | after: InputMaybe 123 | before: InputMaybe 124 | first: InputMaybe 125 | last: InputMaybe 126 | } 127 | 128 | export type ChannelConnection = { 129 | __typename?: "ChannelConnection" 130 | edges: Maybe>> 131 | nodes: Maybe>> 132 | pageInfo: PageInfo 133 | } 134 | 135 | export type ChannelEdge = { 136 | __typename?: "ChannelEdge" 137 | cursor: Scalars["String"]["output"] 138 | node: Maybe 139 | } 140 | 141 | export type ChannelGroup = Node & { 142 | __typename?: "ChannelGroup" 143 | annictId: Scalars["Int"]["output"] 144 | channels: Maybe 145 | id: Scalars["ID"]["output"] 146 | name: Scalars["String"]["output"] 147 | sortNumber: Scalars["Int"]["output"] 148 | } 149 | 150 | export type ChannelGroupchannelsArgs = { 151 | after: InputMaybe 152 | before: InputMaybe 153 | first: InputMaybe 154 | last: InputMaybe 155 | } 156 | 157 | export type Character = Node & { 158 | __typename?: "Character" 159 | age: Scalars["String"]["output"] 160 | ageEn: Scalars["String"]["output"] 161 | annictId: Scalars["Int"]["output"] 162 | birthday: Scalars["String"]["output"] 163 | birthdayEn: Scalars["String"]["output"] 164 | bloodType: Scalars["String"]["output"] 165 | bloodTypeEn: Scalars["String"]["output"] 166 | description: Scalars["String"]["output"] 167 | descriptionEn: Scalars["String"]["output"] 168 | descriptionSource: Scalars["String"]["output"] 169 | descriptionSourceEn: Scalars["String"]["output"] 170 | favoriteCharactersCount: Scalars["Int"]["output"] 171 | height: Scalars["String"]["output"] 172 | heightEn: Scalars["String"]["output"] 173 | id: Scalars["ID"]["output"] 174 | name: Scalars["String"]["output"] 175 | nameEn: Scalars["String"]["output"] 176 | nameKana: Scalars["String"]["output"] 177 | nationality: Scalars["String"]["output"] 178 | nationalityEn: Scalars["String"]["output"] 179 | nickname: Scalars["String"]["output"] 180 | nicknameEn: Scalars["String"]["output"] 181 | occupation: Scalars["String"]["output"] 182 | occupationEn: Scalars["String"]["output"] 183 | series: Series 184 | weight: Scalars["String"]["output"] 185 | weightEn: Scalars["String"]["output"] 186 | } 187 | 188 | export type CharacterConnection = { 189 | __typename?: "CharacterConnection" 190 | edges: Maybe>> 191 | nodes: Maybe>> 192 | pageInfo: PageInfo 193 | } 194 | 195 | export type CharacterEdge = { 196 | __typename?: "CharacterEdge" 197 | cursor: Scalars["String"]["output"] 198 | node: Maybe 199 | } 200 | 201 | export type CharacterOrder = { 202 | direction: OrderDirection 203 | field: CharacterOrderField 204 | } 205 | 206 | export enum CharacterOrderField { 207 | CREATED_AT = "CREATED_AT", 208 | FAVORITE_CHARACTERS_COUNT = "FAVORITE_CHARACTERS_COUNT", 209 | } 210 | 211 | export type CreateRecordInput = { 212 | clientMutationId: InputMaybe 213 | comment: InputMaybe 214 | episodeId: Scalars["ID"]["input"] 215 | ratingState: InputMaybe 216 | shareFacebook: InputMaybe 217 | shareTwitter: InputMaybe 218 | } 219 | 220 | export type CreateRecordPayload = { 221 | __typename?: "CreateRecordPayload" 222 | clientMutationId: Maybe 223 | record: Maybe 224 | } 225 | 226 | export type CreateReviewInput = { 227 | body: Scalars["String"]["input"] 228 | clientMutationId: InputMaybe 229 | ratingAnimationState: InputMaybe 230 | ratingCharacterState: InputMaybe 231 | ratingMusicState: InputMaybe 232 | ratingOverallState: InputMaybe 233 | ratingStoryState: InputMaybe 234 | shareFacebook: InputMaybe 235 | shareTwitter: InputMaybe 236 | title: InputMaybe 237 | workId: Scalars["ID"]["input"] 238 | } 239 | 240 | export type CreateReviewPayload = { 241 | __typename?: "CreateReviewPayload" 242 | clientMutationId: Maybe 243 | review: Maybe 244 | } 245 | 246 | export type DeleteRecordInput = { 247 | clientMutationId: InputMaybe 248 | recordId: Scalars["ID"]["input"] 249 | } 250 | 251 | export type DeleteRecordPayload = { 252 | __typename?: "DeleteRecordPayload" 253 | clientMutationId: Maybe 254 | episode: Maybe 255 | } 256 | 257 | export type DeleteReviewInput = { 258 | clientMutationId: InputMaybe 259 | reviewId: Scalars["ID"]["input"] 260 | } 261 | 262 | export type DeleteReviewPayload = { 263 | __typename?: "DeleteReviewPayload" 264 | clientMutationId: Maybe 265 | work: Maybe 266 | } 267 | 268 | export type Episode = Node & { 269 | __typename?: "Episode" 270 | annictId: Scalars["Int"]["output"] 271 | id: Scalars["ID"]["output"] 272 | nextEpisode: Maybe 273 | number: Maybe 274 | numberText: Maybe 275 | prevEpisode: Maybe 276 | recordCommentsCount: Scalars["Int"]["output"] 277 | records: Maybe 278 | recordsCount: Scalars["Int"]["output"] 279 | satisfactionRate: Maybe 280 | sortNumber: Scalars["Int"]["output"] 281 | title: Maybe 282 | viewerDidTrack: Scalars["Boolean"]["output"] 283 | viewerRecordsCount: Scalars["Int"]["output"] 284 | work: Work 285 | } 286 | 287 | export type EpisoderecordsArgs = { 288 | after: InputMaybe 289 | before: InputMaybe 290 | first: InputMaybe 291 | hasComment: InputMaybe 292 | last: InputMaybe 293 | orderBy: InputMaybe 294 | } 295 | 296 | export type EpisodeConnection = { 297 | __typename?: "EpisodeConnection" 298 | edges: Maybe>> 299 | nodes: Maybe>> 300 | pageInfo: PageInfo 301 | } 302 | 303 | export type EpisodeEdge = { 304 | __typename?: "EpisodeEdge" 305 | cursor: Scalars["String"]["output"] 306 | node: Maybe 307 | } 308 | 309 | export type EpisodeOrder = { 310 | direction: OrderDirection 311 | field: EpisodeOrderField 312 | } 313 | 314 | export enum EpisodeOrderField { 315 | CREATED_AT = "CREATED_AT", 316 | SORT_NUMBER = "SORT_NUMBER", 317 | } 318 | 319 | export type LibraryEntry = Node & { 320 | __typename?: "LibraryEntry" 321 | id: Scalars["ID"]["output"] 322 | nextEpisode: Maybe 323 | nextProgram: Maybe 324 | note: Scalars["String"]["output"] 325 | status: Maybe 326 | user: User 327 | work: Work 328 | } 329 | 330 | export type LibraryEntryConnection = { 331 | __typename?: "LibraryEntryConnection" 332 | edges: Maybe>> 333 | nodes: Maybe>> 334 | pageInfo: PageInfo 335 | } 336 | 337 | export type LibraryEntryEdge = { 338 | __typename?: "LibraryEntryEdge" 339 | cursor: Scalars["String"]["output"] 340 | node: Maybe 341 | } 342 | 343 | export type LibraryEntryOrder = { 344 | direction: OrderDirection 345 | field: LibraryEntryOrderField 346 | } 347 | 348 | export enum LibraryEntryOrderField { 349 | LAST_TRACKED_AT = "LAST_TRACKED_AT", 350 | } 351 | 352 | export enum Media { 353 | MOVIE = "MOVIE", 354 | OTHER = "OTHER", 355 | OVA = "OVA", 356 | TV = "TV", 357 | WEB = "WEB", 358 | } 359 | 360 | export type MultipleRecord = Node & { 361 | __typename?: "MultipleRecord" 362 | annictId: Scalars["Int"]["output"] 363 | createdAt: Scalars["DateTime"]["output"] 364 | id: Scalars["ID"]["output"] 365 | records: Maybe 366 | user: User 367 | work: Work 368 | } 369 | 370 | export type MultipleRecordrecordsArgs = { 371 | after: InputMaybe 372 | before: InputMaybe 373 | first: InputMaybe 374 | last: InputMaybe 375 | } 376 | 377 | export type Mutation = { 378 | __typename?: "Mutation" 379 | createRecord: Maybe 380 | createReview: Maybe 381 | deleteRecord: Maybe 382 | deleteReview: Maybe 383 | updateRecord: Maybe 384 | updateReview: Maybe 385 | updateStatus: Maybe 386 | } 387 | 388 | export type MutationcreateRecordArgs = { 389 | input: CreateRecordInput 390 | } 391 | 392 | export type MutationcreateReviewArgs = { 393 | input: CreateReviewInput 394 | } 395 | 396 | export type MutationdeleteRecordArgs = { 397 | input: DeleteRecordInput 398 | } 399 | 400 | export type MutationdeleteReviewArgs = { 401 | input: DeleteReviewInput 402 | } 403 | 404 | export type MutationupdateRecordArgs = { 405 | input: UpdateRecordInput 406 | } 407 | 408 | export type MutationupdateReviewArgs = { 409 | input: UpdateReviewInput 410 | } 411 | 412 | export type MutationupdateStatusArgs = { 413 | input: UpdateStatusInput 414 | } 415 | 416 | export type Node = { 417 | id: Scalars["ID"]["output"] 418 | } 419 | 420 | export enum OrderDirection { 421 | ASC = "ASC", 422 | DESC = "DESC", 423 | } 424 | 425 | export type Organization = Node & { 426 | __typename?: "Organization" 427 | annictId: Scalars["Int"]["output"] 428 | favoriteOrganizationsCount: Scalars["Int"]["output"] 429 | id: Scalars["ID"]["output"] 430 | name: Scalars["String"]["output"] 431 | nameEn: Scalars["String"]["output"] 432 | nameKana: Scalars["String"]["output"] 433 | staffsCount: Scalars["Int"]["output"] 434 | twitterUsername: Scalars["String"]["output"] 435 | twitterUsernameEn: Scalars["String"]["output"] 436 | url: Scalars["String"]["output"] 437 | urlEn: Scalars["String"]["output"] 438 | wikipediaUrl: Scalars["String"]["output"] 439 | wikipediaUrlEn: Scalars["String"]["output"] 440 | } 441 | 442 | export type OrganizationConnection = { 443 | __typename?: "OrganizationConnection" 444 | edges: Maybe>> 445 | nodes: Maybe>> 446 | pageInfo: PageInfo 447 | } 448 | 449 | export type OrganizationEdge = { 450 | __typename?: "OrganizationEdge" 451 | cursor: Scalars["String"]["output"] 452 | node: Maybe 453 | } 454 | 455 | export type OrganizationOrder = { 456 | direction: OrderDirection 457 | field: OrganizationOrderField 458 | } 459 | 460 | export enum OrganizationOrderField { 461 | CREATED_AT = "CREATED_AT", 462 | FAVORITE_ORGANIZATIONS_COUNT = "FAVORITE_ORGANIZATIONS_COUNT", 463 | } 464 | 465 | export type PageInfo = { 466 | __typename?: "PageInfo" 467 | endCursor: Maybe 468 | hasNextPage: Scalars["Boolean"]["output"] 469 | hasPreviousPage: Scalars["Boolean"]["output"] 470 | startCursor: Maybe 471 | } 472 | 473 | export type Person = Node & { 474 | __typename?: "Person" 475 | annictId: Scalars["Int"]["output"] 476 | birthday: Scalars["String"]["output"] 477 | bloodType: Scalars["String"]["output"] 478 | castsCount: Scalars["Int"]["output"] 479 | favoritePeopleCount: Scalars["Int"]["output"] 480 | genderText: Scalars["String"]["output"] 481 | height: Scalars["String"]["output"] 482 | id: Scalars["ID"]["output"] 483 | name: Scalars["String"]["output"] 484 | nameEn: Scalars["String"]["output"] 485 | nameKana: Scalars["String"]["output"] 486 | nickname: Scalars["String"]["output"] 487 | nicknameEn: Scalars["String"]["output"] 488 | prefecture: Prefecture 489 | staffsCount: Scalars["Int"]["output"] 490 | twitterUsername: Scalars["String"]["output"] 491 | twitterUsernameEn: Scalars["String"]["output"] 492 | url: Scalars["String"]["output"] 493 | urlEn: Scalars["String"]["output"] 494 | wikipediaUrl: Scalars["String"]["output"] 495 | wikipediaUrlEn: Scalars["String"]["output"] 496 | } 497 | 498 | export type PersonConnection = { 499 | __typename?: "PersonConnection" 500 | edges: Maybe>> 501 | nodes: Maybe>> 502 | pageInfo: PageInfo 503 | } 504 | 505 | export type PersonEdge = { 506 | __typename?: "PersonEdge" 507 | cursor: Scalars["String"]["output"] 508 | node: Maybe 509 | } 510 | 511 | export type PersonOrder = { 512 | direction: OrderDirection 513 | field: PersonOrderField 514 | } 515 | 516 | export enum PersonOrderField { 517 | CREATED_AT = "CREATED_AT", 518 | FAVORITE_PEOPLE_COUNT = "FAVORITE_PEOPLE_COUNT", 519 | } 520 | 521 | export type Prefecture = Node & { 522 | __typename?: "Prefecture" 523 | annictId: Scalars["Int"]["output"] 524 | id: Scalars["ID"]["output"] 525 | name: Scalars["String"]["output"] 526 | } 527 | 528 | export type Program = Node & { 529 | __typename?: "Program" 530 | annictId: Scalars["Int"]["output"] 531 | channel: Channel 532 | episode: Episode 533 | id: Scalars["ID"]["output"] 534 | rebroadcast: Scalars["Boolean"]["output"] 535 | scPid: Maybe 536 | startedAt: Scalars["DateTime"]["output"] 537 | state: ProgramState 538 | work: Work 539 | } 540 | 541 | export type ProgramConnection = { 542 | __typename?: "ProgramConnection" 543 | edges: Maybe>> 544 | nodes: Maybe>> 545 | pageInfo: PageInfo 546 | } 547 | 548 | export type ProgramEdge = { 549 | __typename?: "ProgramEdge" 550 | cursor: Scalars["String"]["output"] 551 | node: Maybe 552 | } 553 | 554 | export type ProgramOrder = { 555 | direction: OrderDirection 556 | field: ProgramOrderField 557 | } 558 | 559 | export enum ProgramOrderField { 560 | STARTED_AT = "STARTED_AT", 561 | } 562 | 563 | export enum ProgramState { 564 | HIDDEN = "HIDDEN", 565 | PUBLISHED = "PUBLISHED", 566 | } 567 | 568 | export type Query = { 569 | __typename?: "Query" 570 | node: Maybe 571 | nodes: Array> 572 | searchCharacters: Maybe 573 | searchEpisodes: Maybe 574 | searchOrganizations: Maybe 575 | searchPeople: Maybe 576 | searchWorks: Maybe 577 | user: Maybe 578 | viewer: Maybe 579 | } 580 | 581 | export type QuerynodeArgs = { 582 | id: Scalars["ID"]["input"] 583 | } 584 | 585 | export type QuerynodesArgs = { 586 | ids: Array 587 | } 588 | 589 | export type QuerysearchCharactersArgs = { 590 | after: InputMaybe 591 | annictIds: InputMaybe> 592 | before: InputMaybe 593 | first: InputMaybe 594 | last: InputMaybe 595 | names: InputMaybe> 596 | orderBy: InputMaybe 597 | } 598 | 599 | export type QuerysearchEpisodesArgs = { 600 | after: InputMaybe 601 | annictIds: InputMaybe> 602 | before: InputMaybe 603 | first: InputMaybe 604 | last: InputMaybe 605 | orderBy: InputMaybe 606 | } 607 | 608 | export type QuerysearchOrganizationsArgs = { 609 | after: InputMaybe 610 | annictIds: InputMaybe> 611 | before: InputMaybe 612 | first: InputMaybe 613 | last: InputMaybe 614 | names: InputMaybe> 615 | orderBy: InputMaybe 616 | } 617 | 618 | export type QuerysearchPeopleArgs = { 619 | after: InputMaybe 620 | annictIds: InputMaybe> 621 | before: InputMaybe 622 | first: InputMaybe 623 | last: InputMaybe 624 | names: InputMaybe> 625 | orderBy: InputMaybe 626 | } 627 | 628 | export type QuerysearchWorksArgs = { 629 | after: InputMaybe 630 | annictIds: InputMaybe> 631 | before: InputMaybe 632 | first: InputMaybe 633 | last: InputMaybe 634 | orderBy: InputMaybe 635 | seasons: InputMaybe> 636 | titles: InputMaybe> 637 | } 638 | 639 | export type QueryuserArgs = { 640 | username: Scalars["String"]["input"] 641 | } 642 | 643 | export enum RatingState { 644 | AVERAGE = "AVERAGE", 645 | BAD = "BAD", 646 | GOOD = "GOOD", 647 | GREAT = "GREAT", 648 | } 649 | 650 | export type WatchRecord = Node & { 651 | __typename?: "Record" 652 | annictId: Scalars["Int"]["output"] 653 | comment: Maybe 654 | commentsCount: Scalars["Int"]["output"] 655 | createdAt: Scalars["DateTime"]["output"] 656 | episode: Episode 657 | facebookClickCount: Scalars["Int"]["output"] 658 | id: Scalars["ID"]["output"] 659 | likesCount: Scalars["Int"]["output"] 660 | modified: Scalars["Boolean"]["output"] 661 | rating: Maybe 662 | ratingState: Maybe 663 | twitterClickCount: Scalars["Int"]["output"] 664 | updatedAt: Scalars["DateTime"]["output"] 665 | user: User 666 | work: Work 667 | } 668 | 669 | export type RecordConnection = { 670 | __typename?: "RecordConnection" 671 | edges: Maybe>> 672 | nodes: Maybe>> 673 | pageInfo: PageInfo 674 | } 675 | 676 | export type RecordEdge = { 677 | __typename?: "RecordEdge" 678 | cursor: Scalars["String"]["output"] 679 | node: Maybe 680 | } 681 | 682 | export type RecordOrder = { 683 | direction: OrderDirection 684 | field: RecordOrderField 685 | } 686 | 687 | export enum RecordOrderField { 688 | CREATED_AT = "CREATED_AT", 689 | LIKES_COUNT = "LIKES_COUNT", 690 | } 691 | 692 | export type Review = Node & { 693 | __typename?: "Review" 694 | annictId: Scalars["Int"]["output"] 695 | body: Scalars["String"]["output"] 696 | createdAt: Scalars["DateTime"]["output"] 697 | id: Scalars["ID"]["output"] 698 | impressionsCount: Scalars["Int"]["output"] 699 | likesCount: Scalars["Int"]["output"] 700 | modifiedAt: Maybe 701 | ratingAnimationState: Maybe 702 | ratingCharacterState: Maybe 703 | ratingMusicState: Maybe 704 | ratingOverallState: Maybe 705 | ratingStoryState: Maybe 706 | title: Maybe 707 | updatedAt: Scalars["DateTime"]["output"] 708 | user: User 709 | work: Work 710 | } 711 | 712 | export type ReviewConnection = { 713 | __typename?: "ReviewConnection" 714 | edges: Maybe>> 715 | nodes: Maybe>> 716 | pageInfo: PageInfo 717 | } 718 | 719 | export type ReviewEdge = { 720 | __typename?: "ReviewEdge" 721 | cursor: Scalars["String"]["output"] 722 | node: Maybe 723 | } 724 | 725 | export type ReviewOrder = { 726 | direction: OrderDirection 727 | field: ReviewOrderField 728 | } 729 | 730 | export enum ReviewOrderField { 731 | CREATED_AT = "CREATED_AT", 732 | LIKES_COUNT = "LIKES_COUNT", 733 | } 734 | 735 | export enum SeasonName { 736 | AUTUMN = "AUTUMN", 737 | SPRING = "SPRING", 738 | SUMMER = "SUMMER", 739 | WINTER = "WINTER", 740 | } 741 | 742 | export type Series = Node & { 743 | __typename?: "Series" 744 | annictId: Scalars["Int"]["output"] 745 | id: Scalars["ID"]["output"] 746 | name: Scalars["String"]["output"] 747 | nameEn: Scalars["String"]["output"] 748 | nameRo: Scalars["String"]["output"] 749 | works: Maybe 750 | } 751 | 752 | export type SeriesworksArgs = { 753 | after: InputMaybe 754 | before: InputMaybe 755 | first: InputMaybe 756 | last: InputMaybe 757 | orderBy: InputMaybe 758 | } 759 | 760 | export type SeriesConnection = { 761 | __typename?: "SeriesConnection" 762 | edges: Maybe>> 763 | nodes: Maybe>> 764 | pageInfo: PageInfo 765 | } 766 | 767 | export type SeriesEdge = { 768 | __typename?: "SeriesEdge" 769 | cursor: Scalars["String"]["output"] 770 | node: Maybe 771 | } 772 | 773 | export type SeriesWorkConnection = { 774 | __typename?: "SeriesWorkConnection" 775 | edges: Maybe>> 776 | nodes: Maybe>> 777 | pageInfo: PageInfo 778 | } 779 | 780 | export type SeriesWorkEdge = { 781 | __typename?: "SeriesWorkEdge" 782 | cursor: Scalars["String"]["output"] 783 | item: Work 784 | /** @deprecated Use `item` instead. */ 785 | node: Work 786 | summary: Maybe 787 | summaryEn: Maybe 788 | } 789 | 790 | export type SeriesWorkOrder = { 791 | direction: OrderDirection 792 | field: SeriesWorkOrderField 793 | } 794 | 795 | export enum SeriesWorkOrderField { 796 | SEASON = "SEASON", 797 | } 798 | 799 | export type Staff = Node & { 800 | __typename?: "Staff" 801 | annictId: Scalars["Int"]["output"] 802 | id: Scalars["ID"]["output"] 803 | name: Scalars["String"]["output"] 804 | nameEn: Scalars["String"]["output"] 805 | resource: StaffResourceItem 806 | roleOther: Scalars["String"]["output"] 807 | roleOtherEn: Scalars["String"]["output"] 808 | roleText: Scalars["String"]["output"] 809 | sortNumber: Scalars["Int"]["output"] 810 | work: Work 811 | } 812 | 813 | export type StaffConnection = { 814 | __typename?: "StaffConnection" 815 | edges: Maybe>> 816 | nodes: Maybe>> 817 | pageInfo: PageInfo 818 | } 819 | 820 | export type StaffEdge = { 821 | __typename?: "StaffEdge" 822 | cursor: Scalars["String"]["output"] 823 | node: Maybe 824 | } 825 | 826 | export type StaffOrder = { 827 | direction: OrderDirection 828 | field: StaffOrderField 829 | } 830 | 831 | export enum StaffOrderField { 832 | CREATED_AT = "CREATED_AT", 833 | SORT_NUMBER = "SORT_NUMBER", 834 | } 835 | 836 | export type StaffResourceItem = Organization | Person 837 | 838 | export type Status = Node & { 839 | __typename?: "Status" 840 | annictId: Scalars["Int"]["output"] 841 | createdAt: Scalars["DateTime"]["output"] 842 | id: Scalars["ID"]["output"] 843 | likesCount: Scalars["Int"]["output"] 844 | state: StatusState 845 | user: User 846 | work: Work 847 | } 848 | 849 | export enum StatusState { 850 | NO_STATE = "NO_STATE", 851 | ON_HOLD = "ON_HOLD", 852 | STOP_WATCHING = "STOP_WATCHING", 853 | WANNA_WATCH = "WANNA_WATCH", 854 | WATCHED = "WATCHED", 855 | WATCHING = "WATCHING", 856 | } 857 | 858 | export type UpdateRecordInput = { 859 | clientMutationId: InputMaybe 860 | comment: InputMaybe 861 | ratingState: InputMaybe 862 | recordId: Scalars["ID"]["input"] 863 | shareFacebook: InputMaybe 864 | shareTwitter: InputMaybe 865 | } 866 | 867 | export type UpdateRecordPayload = { 868 | __typename?: "UpdateRecordPayload" 869 | clientMutationId: Maybe 870 | record: Maybe 871 | } 872 | 873 | export type UpdateReviewInput = { 874 | body: Scalars["String"]["input"] 875 | clientMutationId: InputMaybe 876 | ratingAnimationState: RatingState 877 | ratingCharacterState: RatingState 878 | ratingMusicState: RatingState 879 | ratingOverallState: RatingState 880 | ratingStoryState: RatingState 881 | reviewId: Scalars["ID"]["input"] 882 | shareFacebook: InputMaybe 883 | shareTwitter: InputMaybe 884 | title: InputMaybe 885 | } 886 | 887 | export type UpdateReviewPayload = { 888 | __typename?: "UpdateReviewPayload" 889 | clientMutationId: Maybe 890 | review: Maybe 891 | } 892 | 893 | export type UpdateStatusInput = { 894 | clientMutationId: InputMaybe 895 | state: StatusState 896 | workId: Scalars["ID"]["input"] 897 | } 898 | 899 | export type UpdateStatusPayload = { 900 | __typename?: "UpdateStatusPayload" 901 | clientMutationId: Maybe 902 | work: Maybe 903 | } 904 | 905 | export type User = Node & { 906 | __typename?: "User" 907 | activities: Maybe 908 | annictId: Scalars["Int"]["output"] 909 | avatarUrl: Maybe 910 | backgroundImageUrl: Maybe 911 | createdAt: Scalars["DateTime"]["output"] 912 | description: Scalars["String"]["output"] 913 | email: Maybe 914 | followers: Maybe 915 | followersCount: Scalars["Int"]["output"] 916 | following: Maybe 917 | followingActivities: Maybe 918 | followingsCount: Scalars["Int"]["output"] 919 | id: Scalars["ID"]["output"] 920 | libraryEntries: Maybe 921 | name: Scalars["String"]["output"] 922 | notificationsCount: Maybe 923 | onHoldCount: Scalars["Int"]["output"] 924 | programs: Maybe 925 | records: Maybe 926 | recordsCount: Scalars["Int"]["output"] 927 | stopWatchingCount: Scalars["Int"]["output"] 928 | url: Maybe 929 | username: Scalars["String"]["output"] 930 | viewerCanFollow: Scalars["Boolean"]["output"] 931 | viewerIsFollowing: Scalars["Boolean"]["output"] 932 | wannaWatchCount: Scalars["Int"]["output"] 933 | watchedCount: Scalars["Int"]["output"] 934 | watchingCount: Scalars["Int"]["output"] 935 | works: Maybe 936 | } 937 | 938 | export type UseractivitiesArgs = { 939 | after: InputMaybe 940 | before: InputMaybe 941 | first: InputMaybe 942 | last: InputMaybe 943 | orderBy: InputMaybe 944 | } 945 | 946 | export type UserfollowersArgs = { 947 | after: InputMaybe 948 | before: InputMaybe 949 | first: InputMaybe 950 | last: InputMaybe 951 | } 952 | 953 | export type UserfollowingArgs = { 954 | after: InputMaybe 955 | before: InputMaybe 956 | first: InputMaybe 957 | last: InputMaybe 958 | } 959 | 960 | export type UserfollowingActivitiesArgs = { 961 | after: InputMaybe 962 | before: InputMaybe 963 | first: InputMaybe 964 | last: InputMaybe 965 | orderBy: InputMaybe 966 | } 967 | 968 | export type UserlibraryEntriesArgs = { 969 | after: InputMaybe 970 | before: InputMaybe 971 | first: InputMaybe 972 | last: InputMaybe 973 | orderBy: InputMaybe 974 | seasonFrom: InputMaybe 975 | seasonUntil: InputMaybe 976 | seasons: InputMaybe> 977 | states: InputMaybe> 978 | } 979 | 980 | export type UserprogramsArgs = { 981 | after: InputMaybe 982 | before: InputMaybe 983 | first: InputMaybe 984 | last: InputMaybe 985 | orderBy: InputMaybe 986 | unwatched: InputMaybe 987 | } 988 | 989 | export type UserrecordsArgs = { 990 | after: InputMaybe 991 | before: InputMaybe 992 | first: InputMaybe 993 | hasComment: InputMaybe 994 | last: InputMaybe 995 | orderBy: InputMaybe 996 | } 997 | 998 | export type UserworksArgs = { 999 | after: InputMaybe 1000 | annictIds: InputMaybe> 1001 | before: InputMaybe 1002 | first: InputMaybe 1003 | last: InputMaybe 1004 | orderBy: InputMaybe 1005 | seasons: InputMaybe> 1006 | state: InputMaybe 1007 | titles: InputMaybe> 1008 | } 1009 | 1010 | export type UserConnection = { 1011 | __typename?: "UserConnection" 1012 | edges: Maybe>> 1013 | nodes: Maybe>> 1014 | pageInfo: PageInfo 1015 | } 1016 | 1017 | export type UserEdge = { 1018 | __typename?: "UserEdge" 1019 | cursor: Scalars["String"]["output"] 1020 | node: Maybe 1021 | } 1022 | 1023 | export type Work = Node & { 1024 | __typename?: "Work" 1025 | annictId: Scalars["Int"]["output"] 1026 | casts: Maybe 1027 | episodes: Maybe 1028 | episodesCount: Scalars["Int"]["output"] 1029 | id: Scalars["ID"]["output"] 1030 | image: Maybe 1031 | malAnimeId: Maybe 1032 | media: Media 1033 | noEpisodes: Scalars["Boolean"]["output"] 1034 | officialSiteUrl: Maybe 1035 | officialSiteUrlEn: Maybe 1036 | programs: Maybe 1037 | reviews: Maybe 1038 | reviewsCount: Scalars["Int"]["output"] 1039 | satisfactionRate: Maybe 1040 | seasonName: Maybe 1041 | seasonYear: Maybe 1042 | seriesList: Maybe 1043 | staffs: Maybe 1044 | syobocalTid: Maybe 1045 | title: Scalars["String"]["output"] 1046 | titleEn: Maybe 1047 | titleKana: Maybe 1048 | titleRo: Maybe 1049 | twitterHashtag: Maybe 1050 | twitterUsername: Maybe 1051 | viewerStatusState: Maybe 1052 | watchersCount: Scalars["Int"]["output"] 1053 | wikipediaUrl: Maybe 1054 | wikipediaUrlEn: Maybe 1055 | } 1056 | 1057 | export type WorkcastsArgs = { 1058 | after: InputMaybe 1059 | before: InputMaybe 1060 | first: InputMaybe 1061 | last: InputMaybe 1062 | orderBy: InputMaybe 1063 | } 1064 | 1065 | export type WorkepisodesArgs = { 1066 | after: InputMaybe 1067 | before: InputMaybe 1068 | first: InputMaybe 1069 | last: InputMaybe 1070 | orderBy: InputMaybe 1071 | } 1072 | 1073 | export type WorkprogramsArgs = { 1074 | after: InputMaybe 1075 | before: InputMaybe 1076 | first: InputMaybe 1077 | last: InputMaybe 1078 | orderBy: InputMaybe 1079 | } 1080 | 1081 | export type WorkreviewsArgs = { 1082 | after: InputMaybe 1083 | before: InputMaybe 1084 | first: InputMaybe 1085 | hasBody: InputMaybe 1086 | last: InputMaybe 1087 | orderBy: InputMaybe 1088 | } 1089 | 1090 | export type WorkseriesListArgs = { 1091 | after: InputMaybe 1092 | before: InputMaybe 1093 | first: InputMaybe 1094 | last: InputMaybe 1095 | } 1096 | 1097 | export type WorkstaffsArgs = { 1098 | after: InputMaybe 1099 | before: InputMaybe 1100 | first: InputMaybe 1101 | last: InputMaybe 1102 | orderBy: InputMaybe 1103 | } 1104 | 1105 | export type WorkConnection = { 1106 | __typename?: "WorkConnection" 1107 | edges: Maybe>> 1108 | nodes: Maybe>> 1109 | pageInfo: PageInfo 1110 | } 1111 | 1112 | export type WorkEdge = { 1113 | __typename?: "WorkEdge" 1114 | cursor: Scalars["String"]["output"] 1115 | node: Maybe 1116 | } 1117 | 1118 | export type WorkImage = Node & { 1119 | __typename?: "WorkImage" 1120 | annictId: Maybe 1121 | copyright: Maybe 1122 | facebookOgImageUrl: Maybe 1123 | id: Scalars["ID"]["output"] 1124 | internalUrl: Maybe 1125 | recommendedImageUrl: Maybe 1126 | twitterAvatarUrl: Maybe 1127 | twitterBiggerAvatarUrl: Maybe 1128 | twitterMiniAvatarUrl: Maybe 1129 | twitterNormalAvatarUrl: Maybe 1130 | work: Maybe 1131 | } 1132 | 1133 | export type WorkImageinternalUrlArgs = { 1134 | size: Scalars["String"]["input"] 1135 | } 1136 | 1137 | export type WorkOrder = { 1138 | direction: OrderDirection 1139 | field: WorkOrderField 1140 | } 1141 | 1142 | export enum WorkOrderField { 1143 | CREATED_AT = "CREATED_AT", 1144 | SEASON = "SEASON", 1145 | WATCHERS_COUNT = "WATCHERS_COUNT", 1146 | } 1147 | 1148 | export type queryLibraryQueryVariables = Exact<{ 1149 | states: InputMaybe | StatusState> 1150 | after: InputMaybe 1151 | amount: InputMaybe 1152 | }> 1153 | 1154 | export type queryLibraryQuery = { 1155 | __typename?: "Query" 1156 | viewer: { 1157 | __typename?: "User" 1158 | libraryEntries: { 1159 | __typename?: "LibraryEntryConnection" 1160 | nodes: Array<{ 1161 | __typename?: "LibraryEntry" 1162 | work: { 1163 | __typename?: "Work" 1164 | id: string 1165 | annictId: number 1166 | malAnimeId: string | null 1167 | titleEn: string | null 1168 | titleRo: string | null 1169 | title: string 1170 | noEpisodes: boolean 1171 | viewerStatusState: StatusState | null 1172 | episodes: { 1173 | __typename?: "EpisodeConnection" 1174 | nodes: Array<{ 1175 | __typename?: "Episode" 1176 | viewerDidTrack: boolean 1177 | } | null> | null 1178 | } | null 1179 | } 1180 | } | null> | null 1181 | pageInfo: { 1182 | __typename?: "PageInfo" 1183 | hasNextPage: boolean 1184 | hasPreviousPage: boolean 1185 | endCursor: string | null 1186 | } 1187 | } | null 1188 | } | null 1189 | } 1190 | 1191 | export type queryWorksQueryVariables = Exact<{ 1192 | workIds: InputMaybe | Scalars["Int"]["input"]> 1193 | }> 1194 | 1195 | export type queryWorksQuery = { 1196 | __typename?: "Query" 1197 | searchWorks: { 1198 | __typename?: "WorkConnection" 1199 | nodes: Array<{ 1200 | __typename?: "Work" 1201 | id: string 1202 | annictId: number 1203 | malAnimeId: string | null 1204 | titleEn: string | null 1205 | titleRo: string | null 1206 | title: string 1207 | noEpisodes: boolean 1208 | viewerStatusState: StatusState | null 1209 | episodes: { 1210 | __typename?: "EpisodeConnection" 1211 | nodes: Array<{ 1212 | __typename?: "Episode" 1213 | viewerDidTrack: boolean 1214 | } | null> | null 1215 | } | null 1216 | } | null> | null 1217 | } | null 1218 | } 1219 | 1220 | export type getMeQueryVariables = Exact<{ [key: string]: never }> 1221 | 1222 | export type getMeQuery = { 1223 | __typename?: "Query" 1224 | viewer: { 1225 | __typename?: "User" 1226 | username: string 1227 | name: string 1228 | avatarUrl: string | null 1229 | } | null 1230 | } 1231 | 1232 | export const queryLibraryDocument = gql` 1233 | query queryLibrary($states: [StatusState!], $after: String, $amount: Int) { 1234 | viewer { 1235 | libraryEntries(states: $states, after: $after, first: $amount) { 1236 | nodes { 1237 | work { 1238 | id 1239 | annictId 1240 | malAnimeId 1241 | titleEn 1242 | titleRo 1243 | title 1244 | noEpisodes 1245 | episodes { 1246 | nodes { 1247 | viewerDidTrack 1248 | } 1249 | } 1250 | viewerStatusState 1251 | } 1252 | } 1253 | pageInfo { 1254 | hasNextPage 1255 | hasPreviousPage 1256 | endCursor 1257 | } 1258 | } 1259 | } 1260 | } 1261 | ` 1262 | export const queryWorksDocument = gql` 1263 | query queryWorks($workIds: [Int!]) { 1264 | searchWorks(annictIds: $workIds) { 1265 | nodes { 1266 | id 1267 | annictId 1268 | malAnimeId 1269 | titleEn 1270 | titleRo 1271 | title 1272 | noEpisodes 1273 | episodes { 1274 | nodes { 1275 | viewerDidTrack 1276 | } 1277 | } 1278 | viewerStatusState 1279 | } 1280 | } 1281 | } 1282 | ` 1283 | export const getMeDocument = gql` 1284 | query getMe { 1285 | viewer { 1286 | username 1287 | name 1288 | avatarUrl 1289 | } 1290 | } 1291 | ` 1292 | 1293 | export type SdkFunctionWrapper = ( 1294 | action: (requestHeaders?: Record) => Promise, 1295 | operationName: string, 1296 | operationType?: string 1297 | ) => Promise 1298 | 1299 | const defaultWrapper: SdkFunctionWrapper = ( 1300 | action, 1301 | _operationName, 1302 | _operationType 1303 | ) => action() 1304 | 1305 | export function getSdk( 1306 | client: GraphQLClient, 1307 | withWrapper: SdkFunctionWrapper = defaultWrapper 1308 | ) { 1309 | return { 1310 | queryLibrary( 1311 | variables?: queryLibraryQueryVariables, 1312 | requestHeaders?: GraphQLClientRequestHeaders 1313 | ): Promise { 1314 | return withWrapper( 1315 | (wrappedRequestHeaders) => 1316 | client.request(queryLibraryDocument, variables, { 1317 | ...requestHeaders, 1318 | ...wrappedRequestHeaders, 1319 | }), 1320 | "queryLibrary", 1321 | "query" 1322 | ) 1323 | }, 1324 | queryWorks( 1325 | variables?: queryWorksQueryVariables, 1326 | requestHeaders?: GraphQLClientRequestHeaders 1327 | ): Promise { 1328 | return withWrapper( 1329 | (wrappedRequestHeaders) => 1330 | client.request(queryWorksDocument, variables, { 1331 | ...requestHeaders, 1332 | ...wrappedRequestHeaders, 1333 | }), 1334 | "queryWorks", 1335 | "query" 1336 | ) 1337 | }, 1338 | getMe( 1339 | variables?: getMeQueryVariables, 1340 | requestHeaders?: GraphQLClientRequestHeaders 1341 | ): Promise { 1342 | return withWrapper( 1343 | (wrappedRequestHeaders) => 1344 | client.request(getMeDocument, variables, { 1345 | ...requestHeaders, 1346 | ...wrappedRequestHeaders, 1347 | }), 1348 | "getMe", 1349 | "query" 1350 | ) 1351 | }, 1352 | } 1353 | } 1354 | export type Sdk = ReturnType 1355 | -------------------------------------------------------------------------------- /src/components/AniListLogin.tsx: -------------------------------------------------------------------------------- 1 | import { Button, SimpleGrid } from "@mantine/core" 2 | import { useMemo } from "react" 3 | import React from "react" 4 | import { AniListUserInfo } from "./AniListUserInfo" 5 | import { generateRandomString } from "../utils" 6 | 7 | export const AniListLogin = ({ 8 | aniListAccessToken, 9 | setAniListConnected, 10 | }: { 11 | aniListAccessToken: string 12 | setAniListAccessToken: (s: string) => void 13 | setAniListConnected: (s: boolean) => void 14 | }) => { 15 | const authUrl = useMemo(() => { 16 | const ANILIST_CLIENT_ID = import.meta.env.VITE_ANILIST_CLIENT_ID 17 | const ANILIST_REDIRECT_URL = import.meta.env.VITE_ANILIST_REDIRECT_URL 18 | if ( 19 | typeof ANILIST_CLIENT_ID !== "string" || 20 | typeof ANILIST_REDIRECT_URL !== "string" 21 | ) { 22 | return 23 | } 24 | const challenge = generateRandomString(50) 25 | const state = generateRandomString(10) 26 | document.cookie = `challlange=${challenge}` 27 | sessionStorage.setItem(state, challenge) 28 | const url = new URL("https://anilist.co/api/v2/oauth/authorize") 29 | url.searchParams.set("client_id", ANILIST_CLIENT_ID) 30 | url.searchParams.set("response_type", "code") 31 | url.searchParams.set("state", state) 32 | url.searchParams.set("code_challenge", challenge) 33 | url.searchParams.set("code_challenge_method", "plain") 34 | url.searchParams.set("redirect_uri", ANILIST_REDIRECT_URL) 35 | return url.href 36 | }, []) 37 | 38 | return ( 39 | 40 | 43 | {aniListAccessToken && ( 44 | 48 | )} 49 | 50 | ) 51 | } 52 | -------------------------------------------------------------------------------- /src/components/AniListUserInfo.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Center, Avatar, Text, Flex } from "@mantine/core" 2 | import { memo, useEffect } from "react" 3 | import React from "react" 4 | import { useQuery } from "react-query" 5 | import { generateGqlClient } from "../aniListApiEntry" 6 | 7 | export const AniListUserInfo = memo(function AniListUserInfo({ 8 | aniListAccessToken, 9 | setAniListConnected, 10 | }: { 11 | aniListAccessToken: string 12 | setAniListConnected: (s: boolean) => void 13 | }) { 14 | const aniList = generateGqlClient(aniListAccessToken) 15 | const { data, isLoading } = useQuery( 16 | ["ANILIST_PROFILE", aniListAccessToken], 17 | () => aniList.getMe(), 18 | { 19 | cacheTime: 6000, 20 | staleTime: 6000, 21 | } 22 | ) 23 | useEffect(() => { 24 | setAniListConnected(!!data) 25 | }, [data, setAniListConnected]) 26 | if (isLoading) { 27 | return
Loading...
28 | } 29 | return ( 30 | 31 |
32 | 33 | 34 | @{data?.Viewer?.name} 35 | 36 |
37 |
38 | ) 39 | }) 40 | -------------------------------------------------------------------------------- /src/components/AnnictLogin.tsx: -------------------------------------------------------------------------------- 1 | import { Button, SimpleGrid } from "@mantine/core" 2 | import { useMemo } from "react" 3 | import React from "react" 4 | import { AnnictUserInfo } from "./AnnictUserInfo" 5 | 6 | export const AnnictLogin = ({ 7 | annictToken, 8 | setAnnictConnected, 9 | }: { 10 | annictToken: string 11 | setAnnictToken: (s: string) => void 12 | setAnnictConnected: (s: boolean) => void 13 | }) => { 14 | const authUrl = useMemo(() => { 15 | const ANNICT_CLIENT_ID = import.meta.env.VITE_ANNICT_CLIENT_ID 16 | const ANNICT_REDIRECT_URL = import.meta.env.VITE_ANNICT_REDIRECT_URL 17 | if ( 18 | typeof ANNICT_CLIENT_ID !== "string" || 19 | typeof ANNICT_REDIRECT_URL !== "string" 20 | ) { 21 | return 22 | } 23 | const url = new URL( 24 | "https://api.annict.com/oauth/authorize?response_type=code" 25 | ) 26 | url.searchParams.set("client_id", ANNICT_CLIENT_ID) 27 | url.searchParams.set("redirect_uri", ANNICT_REDIRECT_URL) 28 | return url.href 29 | }, []) 30 | 31 | return ( 32 | 33 | 36 | {annictToken && ( 37 | 41 | )} 42 | 43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /src/components/AnnictUserInfo.tsx: -------------------------------------------------------------------------------- 1 | import { Avatar, Box, Center, Flex, Text } from "@mantine/core" 2 | import { memo, useEffect } from "react" 3 | import React from "react" 4 | import { useQuery } from "react-query" 5 | import { generateGqlClient } from "../annictApiEntry" 6 | 7 | export const AnnictUserInfo = memo(function AnnictUserInfo({ 8 | annictToken, 9 | setAnnictConnected, 10 | }: { 11 | annictToken: string 12 | setAnnictConnected: (s: boolean) => void 13 | }) { 14 | const sdk = generateGqlClient(annictToken) 15 | const { isLoading, data } = useQuery( 16 | ["ANNICT_PROFILE", annictToken], 17 | () => sdk.getMe(), 18 | { cacheTime: 6000, staleTime: 6000 } 19 | ) 20 | useEffect(() => { 21 | setAnnictConnected(!!data) 22 | }, [data, setAnnictConnected]) 23 | if (isLoading) { 24 | return
Loading...
25 | } 26 | return ( 27 | 28 |
29 | 30 | 31 | 32 | {data?.viewer?.name} @{data?.viewer?.username} 33 | 34 | 35 |
36 |
37 | ) 38 | }) 39 | -------------------------------------------------------------------------------- /src/components/CheckDiff.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | ActionIcon, 3 | Checkbox, 4 | Flex, 5 | ScrollArea, 6 | Space, 7 | Title, 8 | } from "@mantine/core" 9 | import { useLocalStorage } from "@mantine/hooks" 10 | import React, { useCallback } from "react" 11 | import { useMemo, useState } from "react" 12 | import { Eraser } from "tabler-icons-react" 13 | import { DiffFetchButton } from "./DiffFetchButton" 14 | import { DiffTable } from "./DiffTable" 15 | import { DoSync } from "./DoSync" 16 | import { MissingWorkTable } from "./MissingWorkTable" 17 | import { StatusState } from "../annictGql" 18 | import { TargetService, TARGET_SERVICE_MAL } from "../constants" 19 | import { AnimeWork, StatusDiff } from "../types" 20 | 21 | export const CheckDiff = ({ 22 | annictAccessToken, 23 | targetService, 24 | targetAccessToken, 25 | }: { 26 | annictAccessToken: string 27 | targetService: TargetService 28 | targetAccessToken: string 29 | }) => { 30 | const [checks, setChecks] = useState(new Set()) 31 | const [diffs, setDiffs] = useState([]) 32 | const [missingWorks, setMissingWorks] = useState([]) 33 | const idMap = useMemo(() => diffs.map((diff) => diff.work.annictId), [diffs]) 34 | const [ignores, setIgnores] = useLocalStorage({ 35 | key: `ignoreList${ 36 | targetService !== TARGET_SERVICE_MAL ? targetService : "" 37 | }`, 38 | defaultValue: [], 39 | serialize: (list) => JSON.stringify(list), 40 | deserialize: (str) => { 41 | if (!str) { 42 | return [] 43 | } 44 | try { 45 | return JSON.parse(str) 46 | } catch { 47 | return [] 48 | } 49 | }, 50 | }) 51 | 52 | const isAllChecked = useMemo( 53 | () => 54 | diffs.filter((diff) => !ignores?.includes(diff.work.annictId)).length <= 55 | checks.size, 56 | [checks.size, diffs, ignores] 57 | ) 58 | const handleCheckAll = useCallback(() => { 59 | const isEveryChecked = idMap 60 | .filter((id) => !ignores?.includes(id)) 61 | .every((id) => checks.has(id)) 62 | setChecks( 63 | isEveryChecked 64 | ? new Set() 65 | : new Set(idMap.filter((id) => !ignores?.includes(id))) 66 | ) 67 | }, [checks, idMap, ignores]) 68 | const handleReset = useCallback(() => { 69 | const confirmed = confirm("Are you sure you want to reset?") 70 | if (!confirmed) { 71 | return 72 | } 73 | setIgnores((ignores) => { 74 | setChecks((checks) => { 75 | const copiedChecks = new Set(checks) 76 | ignores.forEach((id) => { 77 | if (!copiedChecks.has(id)) { 78 | copiedChecks.add(id) 79 | } 80 | }) 81 | return copiedChecks 82 | }) 83 | return [] 84 | }) 85 | }, [setIgnores]) 86 | 87 | return ( 88 | <> 89 | 90 | 91 | 107 | 108 | 109 | 110 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 130 | 131 | {targetAccessToken && ( 132 | <> 133 | 134 | 141 | 142 | )} 143 | {0 < missingWorks.length && ( 144 | <> 145 | 146 | Untethered works 147 | 148 | 152 | 153 | )} 154 | 155 | 156 | ) 157 | } 158 | -------------------------------------------------------------------------------- /src/components/DiffFetchButton.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@mantine/core" 2 | import { notifications } from "@mantine/notifications" 3 | import axios from "axios" 4 | import { ClientError } from "graphql-request" 5 | import React from "react" 6 | import { useCallback, useMemo, useRef, useState } from "react" 7 | import { ANILIST_TO_ANNICT_STATUS_MAP } from "../aniList" 8 | import { generateGqlClient as generateAniListGqlClient } from "../aniListApiEntry" 9 | import { MediaListSort, MediaListStatus } from "../aniListGql" 10 | import { generateGqlClient } from "../annictApiEntry" 11 | import { queryLibraryQuery, StatusState } from "../annictGql" 12 | import { TARGET_SERVICE_MAL, TargetService } from "../constants" 13 | import { MAL_TO_ANNICT_STATUS_MAP, MALAPI } from "../mal" 14 | import { AnimeWork, ServiceStatus, StatusDiff } from "../types" 15 | import { sleep } from "../utils" 16 | 17 | export const DiffFetchButton: React.FC<{ 18 | annictAccessToken: string 19 | targetService: TargetService 20 | targetAccessToken: string 21 | statuses: StatusState[] 22 | setDiffs: React.Dispatch> 23 | setChecks: React.Dispatch>> 24 | setMissingWorks: React.Dispatch> 25 | ignores: number[] 26 | }> = ({ 27 | annictAccessToken, 28 | targetService, 29 | targetAccessToken, 30 | statuses, 31 | setDiffs, 32 | setChecks, 33 | setMissingWorks, 34 | ignores, 35 | }) => { 36 | const annict = generateGqlClient(annictAccessToken) 37 | const mal = useMemo(() => new MALAPI(targetAccessToken), [targetAccessToken]) 38 | const aniList = useMemo( 39 | () => generateAniListGqlClient(targetAccessToken), 40 | [targetAccessToken] 41 | ) 42 | const [isFetching, setIsFetching] = useState(false) 43 | const abortRef = useRef(false) 44 | 45 | const handleClick = useCallback(async () => { 46 | if (isFetching) { 47 | abortRef.current = true 48 | return 49 | } 50 | setIsFetching(true) 51 | 52 | try { 53 | const serviceStatuses: ServiceStatus[] = [] 54 | 55 | const armReq = axios.get< 56 | { mal_id?: number; annict_id?: number; anilist_id?: number }[] 57 | >( 58 | "https://cdn.jsdelivr.net/gh/SlashNephy/arm-supplementary@master/dist/arm.json" 59 | ) 60 | 61 | if (targetService === TARGET_SERVICE_MAL) { 62 | let offset = 0 63 | // eslint-disable-next-line no-constant-condition 64 | while (true) { 65 | const result = await mal.getAnimeStatuses({ 66 | sort: "anime_start_date", 67 | limit: 1000, 68 | offset, 69 | fields: "list_status", 70 | nsfw: "true", 71 | }) 72 | serviceStatuses.push( 73 | ...result.data.data.map((d) => ({ 74 | id: d.node.id.toString(), 75 | recordId: null, 76 | title: d.node.title, 77 | status: MAL_TO_ANNICT_STATUS_MAP[d.list_status.status], 78 | watchedEpisodeCount: d.list_status.num_episodes_watched, 79 | })) 80 | ) 81 | const next = result.data.paging.next 82 | if (next) { 83 | const nextUrl = new URL(next) 84 | const parsedOffset = parseInt( 85 | nextUrl.searchParams.get("offset") ?? "NaN" 86 | ) 87 | if (!Number.isNaN(parsedOffset)) { 88 | offset = parsedOffset 89 | } else { 90 | break 91 | } 92 | } else { 93 | break 94 | } 95 | if (abortRef.current) { 96 | break 97 | } 98 | await sleep(500) 99 | } 100 | 101 | if (abortRef.current) { 102 | return 103 | } 104 | } else { 105 | const user = await aniList.getMe() 106 | const userId = user.Viewer?.id 107 | if (!userId) { 108 | return 109 | } 110 | 111 | const fetch = async ( 112 | status: MediaListStatus 113 | ): Promise => { 114 | const result: ServiceStatus[] = [] 115 | 116 | let chunk = 0 117 | // eslint-disable-next-line no-constant-condition 118 | while (true) { 119 | const response = await aniList.queryLibrary({ 120 | userId, 121 | sort: [MediaListSort.StartedOn], 122 | perChunk: 500, 123 | chunk, 124 | status, 125 | }) 126 | 127 | const media = response.MediaListCollection?.lists?.flatMap( 128 | (list) => list?.entries ?? [] 129 | ) 130 | if (media) { 131 | result.push( 132 | ...media 133 | .map((m) => { 134 | if ( 135 | !m?.id || 136 | !m.media?.title || 137 | !m.status || 138 | m.progress === null 139 | ) { 140 | return 141 | } 142 | 143 | return { 144 | id: m.media.id.toString(), 145 | recordId: m.id.toString(), 146 | title: 147 | m.media.title.english ?? 148 | m.media.title.romaji ?? 149 | m.media.title.native ?? 150 | "", 151 | status: ANILIST_TO_ANNICT_STATUS_MAP[m.status], 152 | watchedEpisodeCount: m.progress, 153 | } 154 | }) 155 | .filter((x): x is Exclude => !!x) 156 | ) 157 | } 158 | 159 | if (abortRef.current) { 160 | break 161 | } 162 | if (!response.MediaListCollection?.hasNextChunk) { 163 | break 164 | } 165 | 166 | chunk++ 167 | } 168 | 169 | return result 170 | } 171 | 172 | const result = await Promise.all( 173 | [ 174 | MediaListStatus.Current, 175 | MediaListStatus.Completed, 176 | MediaListStatus.Repeating, 177 | MediaListStatus.Paused, 178 | MediaListStatus.Dropped, 179 | MediaListStatus.Planning, 180 | ].map((status) => fetch(status)) 181 | ) 182 | serviceStatuses.push(...result.flat()) 183 | } 184 | 185 | const arm = (await armReq).data 186 | 187 | // Annict のレスポンスには AniList ID が含まれないので arm を利用する 188 | const queryAniListIdByAnnictId = (annictId: number) => 189 | arm.find((entry) => entry.annict_id === annictId)?.anilist_id ?? null 190 | 191 | let after: string | null = null 192 | const works: AnimeWork[] = [] 193 | // eslint-disable-next-line no-constant-condition 194 | while (true) { 195 | const result: queryLibraryQuery = await annict.queryLibrary({ 196 | after, 197 | states: statuses, 198 | amount: 100, 199 | }) 200 | const structedWorks = 201 | result.viewer?.libraryEntries?.nodes 202 | ?.map((entry) => { 203 | const work = entry?.work 204 | if (!work || !work.annictId || !work.title) { 205 | return 206 | } 207 | const w: AnimeWork = { 208 | annictId: work.annictId, 209 | malId: work.malAnimeId, 210 | aniListId: queryAniListIdByAnnictId(work.annictId), 211 | title: work.title, 212 | titleEn: work.titleEn || null, 213 | titleRo: work.titleRo || null, 214 | noEpisodes: work.noEpisodes, 215 | watchedEpisodeCount: 216 | work?.episodes?.nodes?.filter( 217 | (episode) => episode?.viewerDidTrack 218 | ).length ?? 0, 219 | status: work.viewerStatusState || StatusState.NO_STATE, 220 | } 221 | return w 222 | }) 223 | .filter((work): work is AnimeWork => !!work) || [] 224 | works.push(...structedWorks) 225 | after = result.viewer?.libraryEntries?.pageInfo?.endCursor ?? null 226 | if ( 227 | !after || 228 | result.viewer?.libraryEntries?.pageInfo.hasNextPage === false || 229 | abortRef.current 230 | ) { 231 | break 232 | } 233 | await sleep(500) 234 | } 235 | 236 | // 現在有効なターゲットの作品IDなどを取得する関数群 237 | const getTargetWorkId = (work: AnimeWork): string | null => { 238 | switch (targetService) { 239 | case "mal": 240 | return work.malId 241 | case "anilist": 242 | return work.aniListId?.toString() || null 243 | default: 244 | throw new Error("Unknown target service") 245 | } 246 | } 247 | const getTargetArmId = (entry: { 248 | mal_id?: number 249 | anilist_id?: number 250 | }): string | null => { 251 | switch (targetService) { 252 | case "mal": 253 | return entry.mal_id?.toString() ?? null 254 | default: 255 | return entry.anilist_id?.toString() ?? null 256 | } 257 | } 258 | 259 | const missingWorks: AnimeWork[] = [] 260 | const diffs = works 261 | .map((work) => { 262 | const workId = getTargetWorkId(work) 263 | if (!workId) { 264 | missingWorks.push(work) 265 | return false 266 | } 267 | const status = serviceStatuses.find((status) => status.id === workId) 268 | if ( 269 | !status || 270 | status.status !== work.status || 271 | (!work.noEpisodes && 272 | status.watchedEpisodeCount !== work.watchedEpisodeCount) 273 | ) { 274 | const diff: StatusDiff = { 275 | work, 276 | target: status 277 | ? { 278 | status: status.status, 279 | watchedEpisodeCount: status.watchedEpisodeCount, 280 | title: status.title, 281 | id: status.recordId ?? status.id, 282 | } 283 | : undefined, 284 | } 285 | return diff 286 | } 287 | return false 288 | }) 289 | .filter((diff): diff is StatusDiff => !!diff) 290 | 291 | const missingInOriginWorks = serviceStatuses 292 | .filter( 293 | (serviceWork) => 294 | !works.find((work) => serviceWork.id === getTargetWorkId(work)) 295 | ) 296 | .map((serviceWork) => { 297 | const armRelation = arm.find( 298 | (entry) => serviceWork.id === getTargetArmId(entry) 299 | ) 300 | if (!armRelation || !armRelation.annict_id) { 301 | return 302 | } 303 | return { 304 | ...serviceWork, 305 | annict_id: armRelation.annict_id, 306 | } 307 | }) 308 | .filter((x): x is Exclude => !!x) 309 | 310 | const missingWorksAnnictQuery = 311 | 0 < missingInOriginWorks.length 312 | ? await annict.queryWorks({ 313 | workIds: missingInOriginWorks.map(({ annict_id }) => annict_id), 314 | }) 315 | : null 316 | 317 | const additionalDiffs: StatusDiff[] = missingInOriginWorks 318 | .map((serviceWork) => { 319 | const work = missingWorksAnnictQuery?.searchWorks?.nodes?.find( 320 | (work) => work?.annictId === serviceWork.annict_id 321 | ) 322 | if (!work) { 323 | return 324 | } 325 | const diff: StatusDiff = { 326 | work: { 327 | annictId: work.annictId, 328 | malId: work.malAnimeId, 329 | aniListId: queryAniListIdByAnnictId(work.annictId), 330 | title: work.title, 331 | titleEn: work.titleEn || null, 332 | titleRo: work.titleRo || null, 333 | noEpisodes: work.noEpisodes, 334 | watchedEpisodeCount: 335 | work?.episodes?.nodes?.filter( 336 | (episode) => episode?.viewerDidTrack 337 | ).length ?? 0, 338 | status: work.viewerStatusState || StatusState.NO_STATE, 339 | }, 340 | target: { 341 | status: serviceWork.status, 342 | watchedEpisodeCount: serviceWork.watchedEpisodeCount, 343 | title: serviceWork.title, 344 | id: serviceWork.recordId ?? serviceWork.id, 345 | }, 346 | } 347 | return diff 348 | }) 349 | .filter((x): x is Exclude => !!x) 350 | 351 | diffs.push(...additionalDiffs) 352 | setDiffs(diffs) 353 | setChecks( 354 | new Set( 355 | diffs 356 | .map(({ work }) => work.annictId) 357 | .filter((x) => !ignores.includes(x)) 358 | ) 359 | ) 360 | setMissingWorks(missingWorks) 361 | } catch (error) { 362 | console.error(error) 363 | let message: string | undefined = undefined 364 | if (error instanceof ClientError) { 365 | message = `Annict returns ${error.response.status}` 366 | } else if (error instanceof Error) { 367 | message = error.message 368 | } 369 | notifications.show({ 370 | id: "prepare-error", 371 | title: "Failed to prepare...", 372 | message, 373 | autoClose: false, 374 | }) 375 | } finally { 376 | setIsFetching(false) 377 | abortRef.current = false 378 | } 379 | }, [ 380 | aniList, 381 | annict, 382 | ignores, 383 | isFetching, 384 | mal, 385 | setChecks, 386 | setDiffs, 387 | setMissingWorks, 388 | statuses, 389 | targetService, 390 | ]) 391 | 392 | return ( 393 | 396 | ) 397 | } 398 | -------------------------------------------------------------------------------- /src/components/DiffTable.tsx: -------------------------------------------------------------------------------- 1 | import { ActionIcon, Anchor, Checkbox, Table, Text } from "@mantine/core" 2 | import { useCallback, useMemo, useState } from "react" 3 | import React from "react" 4 | import { Forbid } from "tabler-icons-react" 5 | import { 6 | TARGET_SERVICE_NAMES, 7 | TARGET_SERVICE_URLS, 8 | TargetService, 9 | WATCH_STATUS_MAP, 10 | TARGET_SERVICE_MAL, 11 | TARGET_SERVICE_ANILIST, 12 | } from "../constants" 13 | import { AnimeWork, StatusDiff } from "../types" 14 | 15 | export const DiffTable = ({ 16 | diffs, 17 | checks, 18 | setChecks, 19 | ignores, 20 | setIgnores, 21 | targetService, 22 | }: { 23 | diffs: StatusDiff[] 24 | checks: Set 25 | setChecks: React.Dispatch>> 26 | ignores: number[] 27 | setIgnores: React.Dispatch> 28 | targetService: TargetService 29 | }) => { 30 | const [keepsInSort, setKeepsInSort] = useState(new Set()) 31 | const sortedMemo = useMemo( 32 | () => 33 | diffs.sort((_, b) => 34 | ignores.includes(b.work.annictId) && !keepsInSort.has(b.work.annictId) 35 | ? -1 36 | : 0 37 | ), 38 | [diffs, ignores, keepsInSort] 39 | ) 40 | const getRelationId = useCallback( 41 | (work: AnimeWork) => { 42 | if (targetService === TARGET_SERVICE_MAL) { 43 | return work.malId 44 | } else if (targetService === TARGET_SERVICE_ANILIST) { 45 | return work.aniListId 46 | } 47 | }, 48 | [targetService] 49 | ) 50 | return ( 51 | 52 | 53 | 54 | Include? 55 | Title 56 | Annict 57 | {TARGET_SERVICE_NAMES[targetService]} 58 | Ignore 59 | 60 | 61 | 62 | {sortedMemo.map(({ work, target }) => ( 63 | 69 | 70 | { 73 | setChecks((checks) => { 74 | const copiedChecks = new Set(checks) 75 | if (checks.has(work.annictId)) { 76 | copiedChecks.delete(work.annictId) 77 | } else { 78 | copiedChecks.add(work.annictId) 79 | } 80 | return copiedChecks 81 | }) 82 | }} 83 | readOnly={true} 84 | /> 85 | 86 | 87 | 91 | {`${work.title} (${work.annictId})`} 92 | 93 | 94 | <> 95 |
96 | 103 | {target 104 | ? `${target.title} (${getRelationId(work)})` 105 | : `${TARGET_SERVICE_URLS[targetService]}${getRelationId( 106 | work 107 | )}`} 108 | 109 | 110 |
111 | 112 | 113 | {WATCH_STATUS_MAP[work.status]} 114 | {!work.noEpisodes && ` (${work.watchedEpisodeCount})`} 115 | 116 | 117 | 118 | {target ? ( 119 | 120 | {`${WATCH_STATUS_MAP[target.status]} (${ 121 | target.watchedEpisodeCount 122 | })`} 123 | 124 | ) : ( 125 | 126 | No status 127 | 128 | )} 129 | 130 | 131 | { 134 | setIgnores((ignores) => { 135 | const includes = ignores.includes(work.annictId) 136 | setChecks((checks) => { 137 | const copiedChecks = new Set(checks) 138 | if (checks.has(work.annictId) && !includes) { 139 | copiedChecks.delete(work.annictId) 140 | } else if (includes) { 141 | copiedChecks.add(work.annictId) 142 | } 143 | return copiedChecks 144 | }) 145 | setKeepsInSort((ignores) => { 146 | const copiedIgnores = new Set(ignores) 147 | copiedIgnores.add(work.annictId) 148 | return copiedIgnores 149 | }) 150 | return includes 151 | ? ignores.filter((ignore) => ignore !== work.annictId) 152 | : [...ignores, work.annictId] 153 | }) 154 | }} 155 | size="lg" 156 | color={ignores.includes(work.annictId) ? "red" : "gray"} 157 | > 158 | 159 | 160 | 161 |
162 | ))} 163 |
164 |
165 | ) 166 | } 167 | -------------------------------------------------------------------------------- /src/components/DoSync.tsx: -------------------------------------------------------------------------------- 1 | import { Anchor, Button, Center, Progress, Space, Text } from "@mantine/core" 2 | import { useCallback, useRef, useState } from "react" 3 | import React from "react" 4 | import { ANNICT_TO_ANILIST_STATUS_MAP } from "../aniList" 5 | import { generateGqlClient } from "../aniListApiEntry" 6 | import { StatusState } from "../annictGql" 7 | import { 8 | TARGET_SERVICE_URLS, 9 | TargetService, 10 | TARGET_SERVICE_MAL, 11 | TARGET_SERVICE_ANILIST, 12 | } from "../constants" 13 | import { ANNICT_TO_MAL_STATUS_MAP, MALAPI } from "../mal" 14 | import { AnimeWork, StatusDiff } from "../types" 15 | import { sleep } from "../utils" 16 | 17 | export const DoSync = ({ 18 | checks, 19 | setChecks, 20 | diffs, 21 | targetService, 22 | targetAccessToken, 23 | }: { 24 | checks: number[] 25 | setChecks: React.Dispatch>> 26 | diffs: StatusDiff[] 27 | targetService: TargetService 28 | targetAccessToken: string 29 | }) => { 30 | const [isStarted, setIsStarted] = useState(false) 31 | const [checkCountOnStart, setCheckCountOnStart] = useState(checks.length) 32 | const [successCount, setSuccessCount] = useState(0) 33 | const [failedCount, setFailedCount] = useState(0) 34 | const success = (successCount / checkCountOnStart) * 100 35 | const failed = (failedCount / checkCountOnStart) * 100 36 | const [failedWorks, setFailedWorks] = useState([]) 37 | const [processing, setProcessing] = useState(null) 38 | 39 | const aniList = generateGqlClient(targetAccessToken) 40 | const abortRef = useRef(false) 41 | 42 | const handleSync = useCallback(async () => { 43 | if (isStarted) { 44 | abortRef.current = true 45 | return 46 | } 47 | abortRef.current = false 48 | const mal = new MALAPI(targetAccessToken) 49 | setCheckCountOnStart(checks.length) 50 | setSuccessCount(0) 51 | setFailedCount(0) 52 | setFailedWorks([]) 53 | setIsStarted(true) 54 | for (const annictId of checks) { 55 | const diff = diffs.find(({ work }) => work.annictId === annictId) 56 | if (!diff) { 57 | continue 58 | } 59 | const { work, target } = diff 60 | setProcessing(work) 61 | try { 62 | if (targetService === TARGET_SERVICE_MAL && work.malId) { 63 | if (work.status === StatusState.NO_STATE) { 64 | await mal.deleteAnimeStatus({ id: work.malId }) 65 | } else { 66 | await mal.updateAnimeStatus({ 67 | id: work.malId, 68 | status: ANNICT_TO_MAL_STATUS_MAP[work.status], 69 | num_watched_episodes: work.noEpisodes 70 | ? work.status === StatusState.WATCHED 71 | ? 1 72 | : undefined 73 | : work.watchedEpisodeCount, 74 | }) 75 | } 76 | } else if (targetService === TARGET_SERVICE_ANILIST && work.aniListId) { 77 | if (work.status === StatusState.NO_STATE && target?.id) { 78 | await aniList.deleteMediaStatus({ id: parseInt(target.id) }) 79 | } else if (target?.id) { 80 | // 既存エントリ更新 81 | await aniList.updateMediaStatus({ 82 | id: parseInt(target.id), 83 | status: ANNICT_TO_ANILIST_STATUS_MAP[work.status], 84 | numWatchedEpisodes: work.noEpisodes 85 | ? work.status === StatusState.WATCHED 86 | ? 1 87 | : 0 88 | : work.watchedEpisodeCount, 89 | }) 90 | } else { 91 | // 新規エントリ 92 | await aniList.createMediaStatus({ 93 | id: work.aniListId, 94 | status: ANNICT_TO_ANILIST_STATUS_MAP[work.status], 95 | numWatchedEpisodes: work.noEpisodes 96 | ? work.status === StatusState.WATCHED 97 | ? 1 98 | : 0 99 | : work.watchedEpisodeCount, 100 | }) 101 | } 102 | } else { 103 | setProcessing(null) 104 | setSuccessCount((i) => i + 1) 105 | continue 106 | } 107 | 108 | await sleep(500) 109 | setSuccessCount((i) => i + 1) 110 | setChecks((checks) => { 111 | const copied = new Set(checks) 112 | copied.delete(work.annictId) 113 | return copied 114 | }) 115 | } catch (error) { 116 | console.error(error) 117 | setFailedWorks((works) => [...works, work]) 118 | setFailedCount((i) => i + 1) 119 | await sleep(500) 120 | } 121 | if (abortRef.current) { 122 | break 123 | } 124 | } 125 | setProcessing(null) 126 | setIsStarted(false) 127 | }, [ 128 | aniList, 129 | checks, 130 | diffs, 131 | isStarted, 132 | setChecks, 133 | targetAccessToken, 134 | targetService, 135 | ]) 136 | 137 | return ( 138 | <> 139 |
140 | 147 |
148 | 149 | {isStarted && ( 150 | 151 | 157 | 163 | 164 | )} 165 | {processing && ( 166 | <> 167 | 168 |
{processing.title}
169 | 170 | )} 171 | {!processing && 0 < failedWorks.length && ( 172 | <> 173 | 174 | {failedWorks.map((work) => ( 175 | 179 | {work.title} 180 | 181 | ))} 182 | 183 | )} 184 | 185 | ) 186 | } 187 | -------------------------------------------------------------------------------- /src/components/FirstView.tsx: -------------------------------------------------------------------------------- 1 | import { Anchor, Space, Text, Title } from "@mantine/core" 2 | import React from "react" 3 | 4 | export const FirstView = () => { 5 | return ( 6 | <> 7 | imau 8 | 9 | 10 | Sync your viewing status from{" "} 11 | 12 | Annict 13 | {" "} 14 | to{" "} 15 | 16 | MAL 17 | 18 | {" / "} 19 | 20 | AniList 21 | 22 | . 23 | 24 | 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /src/components/MALLogin.tsx: -------------------------------------------------------------------------------- 1 | import { Button, SimpleGrid } from "@mantine/core" 2 | import { useMemo } from "react" 3 | import React from "react" 4 | import { MALUserInfo } from "./MALUserInfo" 5 | import { generateRandomString } from "../utils" 6 | 7 | export const MALLogin = ({ 8 | malAccessToken, 9 | setMalConnected, 10 | }: { 11 | malAccessToken: string 12 | setMalAccessToken: (s: string) => void 13 | setMalConnected: (s: boolean) => void 14 | }) => { 15 | const authUrl = useMemo(() => { 16 | const MAL_CLIENT_ID = import.meta.env.VITE_MAL_CLIENT_ID 17 | const MAL_REDIRECT_URL = import.meta.env.VITE_MAL_REDIRECT_URL 18 | if ( 19 | typeof MAL_CLIENT_ID !== "string" || 20 | typeof MAL_REDIRECT_URL !== "string" 21 | ) { 22 | return 23 | } 24 | const challenge = generateRandomString(50) 25 | const state = generateRandomString(10) 26 | document.cookie = `challlange=${challenge}` 27 | sessionStorage.setItem(state, challenge) 28 | const url = new URL("https://myanimelist.net/v1/oauth2/authorize") 29 | url.searchParams.set("client_id", MAL_CLIENT_ID) 30 | url.searchParams.set("response_type", "code") 31 | url.searchParams.set("state", state) 32 | url.searchParams.set("code_challenge", challenge) 33 | url.searchParams.set("code_challenge_method", "plain") 34 | url.searchParams.set("redirect_uri", MAL_REDIRECT_URL) 35 | return url.href 36 | }, []) 37 | 38 | return ( 39 | 40 | 43 | {malAccessToken && ( 44 | 48 | )} 49 | 50 | ) 51 | } 52 | -------------------------------------------------------------------------------- /src/components/MALUserInfo.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Center, Avatar, Text, Flex } from "@mantine/core" 2 | import { memo, useEffect } from "react" 3 | import React from "react" 4 | import { useQuery } from "react-query" 5 | import { MALAPI } from "../mal" 6 | 7 | export const MALUserInfo = memo(function MALUserInfo({ 8 | malAccessToken, 9 | setMalConnected, 10 | }: { 11 | malAccessToken: string 12 | setMalConnected: (s: boolean) => void 13 | }) { 14 | const mal = new MALAPI(malAccessToken) 15 | const { data, isLoading } = useQuery( 16 | ["MAL_PROFILE", malAccessToken], 17 | () => mal.getUsersMe(), 18 | { 19 | cacheTime: 6000, 20 | staleTime: 6000, 21 | } 22 | ) 23 | useEffect(() => { 24 | setMalConnected(!!data) 25 | }, [data, setMalConnected]) 26 | if (isLoading) { 27 | return
Loading...
28 | } 29 | return ( 30 | 31 |
32 | 33 | 34 | @{data?.data.name} 35 | 36 |
37 |
38 | ) 39 | }) 40 | -------------------------------------------------------------------------------- /src/components/Main.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Center, 3 | SegmentedControl, 4 | SimpleGrid, 5 | Space, 6 | Text, 7 | } from "@mantine/core" 8 | import { useLocalStorage } from "@mantine/hooks" 9 | import React from "react" 10 | import { useState } from "react" 11 | import { AniListLogin } from "./AniListLogin" 12 | import { AnnictLogin } from "./AnnictLogin" 13 | import { CheckDiff } from "./CheckDiff" 14 | import { FirstView } from "./FirstView" 15 | import { MALLogin } from "./MALLogin" 16 | import { 17 | TARGET_SERVICE_ANILIST, 18 | TARGET_SERVICE_MAL, 19 | TARGET_SERVICE_NAMES, 20 | TargetService, 21 | } from "../constants" 22 | 23 | export const Main = () => { 24 | const [annictToken, setAnnictToken] = useLocalStorage({ 25 | key: "ANNICT_ACCESS_TOKEN", 26 | defaultValue: "", 27 | }) 28 | const [malAccessToken, setMalAccessToken] = useLocalStorage({ 29 | key: "MAL_ACCESS_TOKEN", 30 | defaultValue: "", 31 | }) 32 | const [aniListAccessToken, setAniListAccessToken] = useLocalStorage({ 33 | key: "ANILIST_ACCESS_TOKEN", 34 | defaultValue: "", 35 | }) 36 | const [annictConnected, setAnnictConnected] = useState(false) 37 | const [malConnected, setMalConnected] = useState(false) 38 | const [aniListConnected, setAniListConnected] = useState(false) 39 | const [target, setTarget] = useLocalStorage({ 40 | key: "TARGET_SERVICE", 41 | defaultValue: TARGET_SERVICE_MAL, 42 | }) 43 | const targetConnected = 44 | target === TARGET_SERVICE_MAL ? malConnected : aniListConnected 45 | const targetAccessToken = 46 | target === TARGET_SERVICE_MAL ? malAccessToken : aniListAccessToken 47 | return ( 48 | <> 49 | 50 | 51 |
52 | setTarget(s as TargetService)} 55 | data={[ 56 | { 57 | label: TARGET_SERVICE_NAMES[TARGET_SERVICE_MAL], 58 | value: TARGET_SERVICE_MAL, 59 | }, 60 | { 61 | label: TARGET_SERVICE_NAMES[TARGET_SERVICE_ANILIST], 62 | value: TARGET_SERVICE_ANILIST, 63 | }, 64 | ]} 65 | /> 66 |
67 | 68 | 69 | 74 | {target === TARGET_SERVICE_MAL && ( 75 | 80 | )} 81 | {target === TARGET_SERVICE_ANILIST && ( 82 | 87 | )} 88 | 89 | 90 | {annictConnected && targetConnected ? ( 91 | 96 | ) : ( 97 |
98 | Connect with both Annict and MAL/AniList! 99 |
100 | )} 101 | 102 | ) 103 | } 104 | -------------------------------------------------------------------------------- /src/components/MissingWorkTable.tsx: -------------------------------------------------------------------------------- 1 | import { Anchor, List, Table, Text } from "@mantine/core" 2 | import React from "react" 3 | import { useCallback } from "react" 4 | import { 5 | TargetService, 6 | TARGET_SERVICE_ANILIST, 7 | TARGET_SERVICE_MAL, 8 | TARGET_SERVICE_NAMES, 9 | WATCH_STATUS_MAP, 10 | } from "../constants" 11 | import { AnimeWork } from "../types" 12 | 13 | export const MissingWorkTable = ({ 14 | works, 15 | targetService, 16 | }: { 17 | works: AnimeWork[] 18 | targetService: TargetService 19 | }) => { 20 | const getSearchPage = useCallback( 21 | (term: string) => { 22 | switch (targetService) { 23 | case TARGET_SERVICE_MAL: 24 | return `https://myanimelist.net/search/all?q=${encodeURIComponent( 25 | term 26 | )}&cat=all` 27 | case TARGET_SERVICE_ANILIST: 28 | return `https://anilist.co/search/anime?search=${encodeURIComponent( 29 | term 30 | )}` 31 | default: 32 | break 33 | } 34 | }, 35 | [targetService] 36 | ) 37 | return ( 38 | 39 | 40 | 41 | Title 42 | Status 43 | 44 | 45 | 46 | {works.map((work) => ( 47 | 48 | 49 | 53 | {`${work.title} (${work.annictId})`} 54 | 55 |
56 | 57 | Search in {TARGET_SERVICE_NAMES[targetService]}: 58 | 59 | 60 | 61 | 66 | {work.title} 67 | 68 | 69 | {work.titleEn && ( 70 | 71 | 76 | {work.titleEn} 77 | 78 | 79 | )} 80 | {work.titleRo && ( 81 | 82 | 87 | {work.titleRo} 88 | 89 | 90 | )} 91 | 92 |
93 | 94 | 95 | {WATCH_STATUS_MAP[work.status]} 96 | {!work.noEpisodes && ` (${work.watchedEpisodeCount})`} 97 | 98 | 99 |
100 | ))} 101 |
102 |
103 | ) 104 | } 105 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | import { StatusState } from "./annictGql" 2 | 3 | type StatusStateKey = keyof typeof StatusState 4 | 5 | export const WATCH_STATUS_MAP: { [key in StatusStateKey]: string } = { 6 | NO_STATE: "No State", 7 | ON_HOLD: "On Hold", 8 | WATCHING: "Watching", 9 | STOP_WATCHING: "Stop Watching", 10 | WANNA_WATCH: "Wanna Watch", 11 | WATCHED: "Watched", 12 | } 13 | 14 | export const TARGET_SERVICE_MAL = "mal" 15 | export const TARGET_SERVICE_ANILIST = "anilist" 16 | export type TargetService = 17 | | typeof TARGET_SERVICE_MAL 18 | | typeof TARGET_SERVICE_ANILIST 19 | export const TARGET_SERVICE_NAMES: Record = { 20 | mal: "MyAnimeList", 21 | anilist: "AniList", 22 | } 23 | export const TARGET_SERVICE_URLS: Record = { 24 | mal: "https://myanimelist.net/anime/", 25 | anilist: "https://anilist.co/anime/", 26 | } 27 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { createRoot } from "react-dom/client" 3 | import { App } from "./App" 4 | 5 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 6 | createRoot(document.getElementById("root")!).render( 7 | 8 | 9 | 10 | ) 11 | -------------------------------------------------------------------------------- /src/mal.ts: -------------------------------------------------------------------------------- 1 | import axios, { Axios } from "axios" 2 | import { StatusState } from "./annictGql" 3 | 4 | export type MALAnimeStatus = 5 | | "watching" 6 | | "completed" 7 | | "on_hold" 8 | | "dropped" 9 | | "plan_to_watch" 10 | | "no_state" // Invalid on MAL 11 | 12 | export const ANNICT_TO_MAL_STATUS_MAP: { 13 | [key in keyof typeof StatusState]: MALAnimeStatus 14 | } = { 15 | NO_STATE: "no_state", 16 | ON_HOLD: "on_hold", 17 | WATCHING: "watching", 18 | STOP_WATCHING: "dropped", 19 | WANNA_WATCH: "plan_to_watch", 20 | WATCHED: "completed", 21 | } 22 | 23 | export const MAL_TO_ANNICT_STATUS_MAP: { 24 | [key in MALAnimeStatus]: keyof typeof StatusState 25 | } = { 26 | watching: StatusState.WATCHING, 27 | completed: StatusState.WATCHED, 28 | on_hold: StatusState.ON_HOLD, 29 | dropped: StatusState.STOP_WATCHING, 30 | plan_to_watch: StatusState.WANNA_WATCH, 31 | no_state: StatusState.NO_STATE, 32 | } 33 | 34 | export type MALAnimeNode = { 35 | id: number 36 | title: string 37 | } 38 | 39 | export type MALListStatus = { 40 | node: MALAnimeNode 41 | list_status: { 42 | status: MALAnimeStatus 43 | num_episodes_watched: number 44 | } 45 | } 46 | 47 | export class MALAPI { 48 | public client: Axios 49 | constructor(public accessToken: string) { 50 | this.client = axios.create({ 51 | headers: { Authorization: `Bearer ${accessToken}` }, 52 | baseURL: "/mal/", 53 | }) 54 | } 55 | 56 | getUsersMe() { 57 | return this.client.get<{ name: string; picture: string }>("/users/@me") 58 | } 59 | 60 | updateAnimeStatus(options: { 61 | id: string 62 | status: MALAnimeStatus 63 | is_rewatching?: boolean 64 | score?: number 65 | num_watched_episodes?: number 66 | priority?: number 67 | }) { 68 | const payload = new URLSearchParams( 69 | Object.entries(options) 70 | .filter(([k, v]) => v && k !== "id") 71 | .map(([k, v]) => [k, v.toString()]) 72 | ) 73 | return this.client.patch(`/anime/${options.id}/my_list_status`, payload) 74 | } 75 | 76 | deleteAnimeStatus(options: { id: string }) { 77 | return this.client.delete(`/anime/${options.id}/my_list_status`) 78 | } 79 | 80 | getAnimeStatuses(params: { 81 | status?: MALAnimeStatus 82 | sort?: "anime_start_date" 83 | limit?: number 84 | offset?: number 85 | fields?: "list_status" 86 | nsfw?: "true" 87 | }) { 88 | return this.client.get<{ 89 | data: MALListStatus[] 90 | paging: { next: string } 91 | }>("/users/@me/animelist", { 92 | params, 93 | }) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/query.ts: -------------------------------------------------------------------------------- 1 | import { QueryClient } from "react-query" 2 | 3 | export const queryClient = new QueryClient({ 4 | defaultOptions: { 5 | queries: { 6 | refetchOnWindowFocus: false, 7 | }, 8 | }, 9 | }) 10 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { StatusState } from "./annictGql" 2 | 3 | export type AnimeWork = { 4 | annictId: number 5 | malId: string | null 6 | aniListId: number | null 7 | title: string 8 | titleEn: string | null 9 | titleRo: string | null 10 | noEpisodes: boolean 11 | watchedEpisodeCount: number 12 | status: StatusState 13 | } 14 | 15 | export type StatusDiff = { 16 | work: AnimeWork 17 | target?: { 18 | watchedEpisodeCount: number 19 | status: keyof typeof StatusState 20 | title: string 21 | id: string 22 | } 23 | } 24 | 25 | export type ServiceStatus = { 26 | id: string 27 | recordId: string | null 28 | title: string 29 | status: keyof typeof StatusState 30 | watchedEpisodeCount: number 31 | } 32 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | export const generateRandomString = (length = 6) => { 2 | let result = "" 3 | const characters = 4 | "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" 5 | const charactersLength = characters.length 6 | for (let i = 0; i < length; i++) { 7 | result += characters.charAt(Math.floor(Math.random() * charactersLength)) 8 | } 9 | return result 10 | } 11 | 12 | export const sleep = (ms: number) => 13 | new Promise((res) => { 14 | setTimeout(() => res(), ms) 15 | }) 16 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": [ 4 | "src", 5 | "./vite.config.ts", 6 | "./netlify/functions", 7 | "./postcss.config.js" 8 | ], 9 | "exclude": ["node_modules"] 10 | } 11 | -------------------------------------------------------------------------------- /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": "esnext" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */, 8 | "module": "esnext" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, 9 | "lib": [ 10 | "esnext", 11 | "dom" 12 | ] /* Specify library files to be included in the compilation. */, 13 | // "allowJs": true, /* Allow javascript files to be compiled. */ 14 | // "checkJs": true, /* Report errors in .js files. */ 15 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 16 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 17 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 18 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 19 | // "outFile": "./", /* Concatenate and emit output to single file. */ 20 | "outDir": "./dist" /* Redirect output structure to the directory. */, 21 | "rootDir": "./src" /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */, 22 | // "composite": true, /* Enable project compilation */ 23 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 24 | // "removeComments": true, /* Do not emit comments to output. */ 25 | // "noEmit": true, /* Do not emit outputs. */ 26 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 27 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 28 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 29 | 30 | /* Strict Type-Checking Options */ 31 | "strict": true /* Enable all strict type-checking options. */, 32 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 33 | // "strictNullChecks": true, /* Enable strict null checks. */ 34 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 35 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 36 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 37 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 38 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 39 | 40 | /* Additional Checks */ 41 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 42 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 43 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 44 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 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 | "jsx": "react-jsx" 72 | }, 73 | "include": ["src"], 74 | "exclude": ["node_modules"] 75 | } 76 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import react from "@vitejs/plugin-react" 2 | import { defineConfig } from "vite" 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | }) 8 | --------------------------------------------------------------------------------