commit a5913efeaa4469fabecc03357a460c67a310f199 Author: DrakeTDL Date: Fri Oct 6 23:26:33 2023 -0700 init diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..e40716f --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "deno.enable": true, + "deno.lint": true, + "deno.unstable": true +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..3547bdc --- /dev/null +++ b/README.md @@ -0,0 +1,134 @@ +# Anilist Wrapper + +An UNOFFICIAL wrapper for the anilist api written in typescript. + +You can visit the official graphql docs for anilist [here](https://anilist.github.io/ApiV2-GraphQL-Docs/) to find out +everything you can do[^*]. + +[[_TOC_]] + +## Status + +To see the current status of the wrapper check the [todo](TODO.md) list. + +> **Warning** As of v0.12 the only way to create queries is `query.` + +## Usage + +### Creating a query + +```js +const query = Client().query.MediaList() +``` + +### Query arguments + +The queries can accept either an object of `MediaListArguments`. + +```js +const queryByUserName = Client().query.MediaList({ userName: "Josh" }) +/* +query ($id: Int) { + MediaList(id: $id) { + id + } +} +*/ + +const queryById = Client().query.MediaList({ id: 1 }) +/* +query ($userName: String) { + MediaList(userName: $userName) { + id + } +} +*/ +``` + +### Creating the query + +#### Other Queries + +##### Fetching without building the query + +If you build the query and try to fetch it without telling which fields to return it will default to returning `id` to +avoid errors. + +```js +const query = Client().query.MediaList({ userName: "Josh" }) + +await query.fetch() +``` +=> +```json +{ + "data": { + "MediaList": { + "id": 5318 + } + } +} +``` + +##### Creating a complete search query + +As the library follows the builder pattern you can just nest functions until you have every field you want. + +```js +const query = Client().query.MediaList({ userName: "Josh" }, (fields) => { + fields.withId() + fields.withUserId() + .withPrivate() + .withStartedAt((fields) => fields.withYear().withMonth().withDay()) + .withCompletedAt((fields) => fields.withYear().withMonth().withDay()) +}) + +await query.fetch() +``` +=> +```json +{ + "data": { + "MediaList": { + "id": 5318, + "userId": 1, + "private": false, + "startedAt": { "year": null, "month": null, "day": null }, + "completedAt": { "year": 2017, "month": 11, "day": 24 } + } + } +} +``` + +## OAuth + +The library provides 2 helpers methods for OAuth and i will explain them here + +### Using Token +Once you have a token, you can pass it into `Client({ token })`. + +### Getting a token from an authorization code grant + +This helper method allows you to convert the Authorization Code Grant into an access token. The response is a type `Authorization`. + +```ts +const token = Client().auth.getToken({ + client_id: "", + client_secret: "", + code: "", + redirect_uri: "" +}) +``` +=> +```json +{ + "token_type": "string", + /* A full year in seconds */ + "expires_in": "number", + "access_token": "string", + /* Currently not used for anything */ + "refresh_token": "string" +} +``` +[^*]: Not everything is supported yet, please refer to the todo list to see what has full implementation or open an +issue to talk about it diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..d6d5382 --- /dev/null +++ b/deno.json @@ -0,0 +1,6 @@ +{ + "fmt": { + "lineWidth": 120, + "semiColons": false + } +} diff --git a/mod.ts b/mod.ts new file mode 100644 index 0000000..ff3511c --- /dev/null +++ b/mod.ts @@ -0,0 +1 @@ +export { Client } from "./src/anilist.ts" diff --git a/src/anilist.ts b/src/anilist.ts new file mode 100644 index 0000000..d0513ed --- /dev/null +++ b/src/anilist.ts @@ -0,0 +1,671 @@ +import { Fetch, MediaListArguments, ThreadCommentSort, UpdateQuery, Variables } from "./types/Anilist.ts" +import type { AtLeastOne } from "./types/AtLeastOne.ts" + +const typeMap = { + User: { sort: "[UserSort]" }, + about: { asHtml: "Boolean" }, + releaseYear: { sort: "[UserStatisticsSort]" }, + misc: { + page: "Int", + perPage: "Int", + id: "Int", + userId: "Int", + comment: "String", + name: "String", + userName: "String", + isModerator: "Boolean", + search: "String", + limit: "Int", + format: "ScoreFormat", + asArray: "Boolean", + mediaId: "Int", + }, +} + +const tab = (n: number) => new Array(n + 1).fill("").join("\t") +const t = tab + +const updateOperation = ( + { query, variables, field, level, hasSubField }: UpdateQuery, +) => { + if (!query) query = `${field[level]} (%root) {\n\t%${field[level]}\n}` + const convertedType: [string[], string[]] = [[], []] + if (variables) { + const variable = Object.entries(variables) + for (let i = 0; i <= variable.length - 1; i++) { + convertedType[0].push(`${variable[i][0]}: $${variable[i][0]}`) + convertedType[1].push( + `$${variable[i][0]}: ${ + variable[i][0] in typeMap + ? typeMap[variable[i][0] as keyof typeof typeMap] + : typeMap["misc"][variable[i][0] as keyof typeof typeMap["misc"]] + }`, + ) + } + query = query.replace( + `%root`, + `${convertedType[1].join(", ")}, %root`, + ) + } + query = query + .replace( + `%${field[level - 1]}`, + `${field[level]}${variables ? ` (${convertedType[0].join(", ")})` : ""}${ + hasSubField ? `${` {\n${t(level + 1)}%${field.at(-1)}\n${t(level)}}`}` : "" + }\n${t(level)}%${field[level - 1]}`, + ) + // console.log({ query, variables, field, level }) + + return { + get() { + // return query + return query!.replace(/(\n|,)?(\t+| )%\w+\b/g, "") + }, + set( + { subField, variables, hasSubField, level }: + & Pick< + UpdateQuery, + "hasSubField" | "variables" + > + & { subField: string; level: number }, + ) { + // const f = [...field] + field.splice(level, 0, subField) + while (field.length - 1 > level) field.pop() + + return updateOperation({ + query, + variables, + // level: depth ? depth : level + 1, + level, + field, + hasSubField, + }) + }, + } +} + +const fetch: Fetch = async (init) => { + let host: + | "https://graphql.anilist.co" + | "https://anilist.co/api/v2/oauth/token" + const body = {} + const headers = new Headers({ + "Content-Type": "application/json", + "Accept": "application/json", + }) + if ("code" in init) { + const { + client_id, + client_secret, + code, + redirect_uri, + grant_type = "authorization_code", + } = init + host = "https://anilist.co/api/v2/oauth/token" + Object.assign(body, { + grant_type, + client_id, + client_secret, + redirect_uri, + code, + }) + } else { + host = "https://graphql.anilist.co" + const { query, variables, token } = init + Object.assign(body, { query, variables }) + if (token) headers.set("Authorization", `Bearer ${token}`) + } + const res = await self.fetch(host, { + method: "POST", + headers, + body: JSON.stringify(body), + }) + if (res.status !== 200) { + throw new Error("Something went wrong.", { + cause: JSON.stringify(await res.json()), + }) + } + return await res.json() +} + +const fuzzyDateFields = ( + query: ReturnType[], + level: number, +) => ({ + withDay() { + query[0] = query[0].set({ subField: "day", level }) + return this + }, + withMonth() { + query[0] = query[0].set({ subField: "month", level }) + return this + }, + withYear() { + query[0] = query[0].set({ subField: "year", level }) + return this + }, +}) +const MediaListFields = ( + query: ReturnType[], + _level: number, +) => ({ + withCompletedAt(fn: (fields: ReturnType) => void) { + query[0] = query[0].set({ + subField: "completedAt", + level: 2, + hasSubField: true, + }) + let tmpQuery + fn(fuzzyDateFields(tmpQuery = [query[0]], 3)) + query[0] = tmpQuery[0] + return this + }, + withStartedAt(fn: (fields: ReturnType) => void) { + query[0] = query[0].set({ + subField: "startedAt", + level: 2, + hasSubField: true, + }) + let tmpQuery + fn(fuzzyDateFields(tmpQuery = [query[0]], 3)) + query[0] = tmpQuery[0] + return this + }, + withId() { + query[0] = query[0].set({ subField: "id", level: 2 }) + return this + }, + withUserId() { + query[0] = query[0].set({ subField: "userId", level: 2 }) + return this + }, + withMediaId() { + query[0] = query[0].set({ subField: "mediaId", level: 2 }) + return this + }, + withStatus() { + query[0] = query[0].set({ subField: "status", level: 2 }) + return this + }, + withProgress() { + query[0] = query[0].set({ subField: "progress", level: 2 }) + return this + }, + withProgressVolumes() { + query[0] = query[0].set({ subField: "progressVolumes", level: 2 }) + return this + }, + withRepeat() { + query[0] = query[0].set({ subField: "repeat", level: 2 }) + return this + }, + withPriority() { + query[0] = query[0].set({ subField: "priority", level: 2 }) + return this + }, + withPrivate() { + query[0] = query[0].set({ subField: "private", level: 2 }) + return this + }, + withNotes() { + query[0] = query[0].set({ subField: "notes", level: 2 }) + return this + }, + withAdvancedScores() { + query[0] = query[0].set({ subField: "advancedScores", level: 2 }) + return this + }, + withHiddenFromStatusLists() { + query[0] = query[0].set({ + subField: "hiddenFromStatusLists", + level: 2, + }) + return this + }, + withUpdatedAt() { + query[0] = query[0].set({ subField: "updatedAt", level: 2 }) + return this + }, + withCreatedAt() { + query[0] = query[0].set({ subField: "createdAt", level: 2 }) + return this + }, +}) +const threadCommentFields = ( + query: ReturnType[], + level: number, +) => ({ + /** The id of the comment */ + withId() { + query[0] = query[0].set({ subField: "id", level }) + return this + }, + /** The user id of the comment's owner */ + withUserId() { + query[0] = query[0].set({ subField: "userId", level }) + return this + }, + /** The id of thread the comment belongs to */ + withThreadId() { + query[0] = query[0].set({ subField: "threadId", level }) + return this + }, + /** The text content of the comment (Markdown) */ + withComment(args?: { asHtml: boolean }) { + query[0] = query[0].set({ subField: "comment", level, variables: args }) + return this + }, + /** The amount of likes the comment has */ + withLikeCount() { + query[0] = query[0].set({ subField: "likeCount", level }) + return this + }, + /** If the currently authenticated user liked the comment */ + withIsLiked() { + query[0] = query[0].set({ subField: "isLiked", level }) + return this + }, + /** The url for the comment page on the AniList website */ + withSiteUrl() { + query[0] = query[0].set({ subField: "siteUrl", level }) + return this + }, + /** The time of the comments creation */ + withCreatedAt() { + query[0] = query[0].set({ subField: "createdAt", level }) + return this + }, + /** The time of the comments last update */ + withUpdatedAt() { + query[0] = query[0].set({ subField: "updatedAt", level }) + return this + }, + /** The thread the comment belongs to */ + withThread() { + query[0] = query[0].set({ subField: "thread", level }) + return this + }, + /** The user who created the comment */ + withUser() { + query[0] = query[0].set({ subField: "user", level }) + return this + }, + /** The users who liked the comment */ + withLikes() { + query[0] = query[0].set({ subField: "likes", level }) + return this + }, + /** The comment's child reply comments */ + withChildComments() { + query[0] = query[0].set({ subField: "childComments", level }) + return this + }, + /** If the comment tree is locked and may not receive replies or edits */ + withIsLocked() { + query[0] = query[0].set({ subField: "isLocked", level }) + return this + }, +}) + +export const Client = function (auth?: { token: string }) { + const variables: Variables = {} + let operation: ReturnType + return { + get auth() { + return { + getToken(init: { + code: string + client_id: string + client_secret: string + redirect_uri: string + }) { + return fetch(init) + }, + } + }, + get query() { + operation = updateOperation({ + field: ["query"], + level: 0, + }) + + return { + get raw() { + return { + get variable() { + return variables + }, + get() { + return operation.get() + }, + } + }, + fetch() { + return fetch({ query: this.raw.get()!, variables }) + }, + Page() { + throw "To be Implemented" + }, + /** Media query */ + Media() { + throw "To be Implemented" + }, + /** Media Trend query */ + MediaTrend() { + throw "To be Implemented" + }, + /** Airing schedule query */ + AiringSchedule() { + throw "To be Implemented" + }, + /** Character query */ + Character() { + throw "To be Implemented" + }, + /** Staff query */ + Staff() { + throw "To be Implemented" + }, + /** Media list query */ + MediaList( + args: AtLeastOne & Variables, + fn?: (fields: ReturnType) => void, + ) { + Object.assign(variables, args) + operation = operation.set({ + subField: "MediaList", + variables: args, + hasSubField: true, + level: 1, + }) + if (!fn) operation = operation.set({ subField: "id", level: 2 }) + else { + let tmpQuery + fn(MediaListFields(tmpQuery = [operation], 2)) + operation = tmpQuery[0] + } + return this + }, + /** Media list collection query, provides list pre-grouped by status & custom lists. User ID and Media Type arguments required. */ + MediaListCollection() { + throw "To be Implemented" + }, + /** Collection of all the possible media genres */ + GenreCollection() { + throw "To be Implemented" + }, + /** Collection of all the possible media tags */ + MediaTagCollection() { + throw "To be Implemented" + }, + /** User query */ + User() { + throw "To be Implemented" + }, + /** Get the currently authenticated user */ + Viewer() { + throw "To be Implemented" + }, + /** Notification query */ + Notification() { + throw "To be Implemented" + }, + /** Studio query */ + Studio() { + throw "To be Implemented" + }, + /** Review query */ + Review() { + throw "To be Implemented" + }, + /** Activity query */ + Activity() { + throw "To be Implemented" + }, + /** Activity reply query */ + ActivityReply() { + throw "To be Implemented" + }, + /** Follow query */ + Following() { + throw "To be Implemented" + }, + /** Follow query */ + Follower() { + throw "To be Implemented" + }, + /** Thread query */ + Thread() { + throw "To be Implemented" + }, + /** Comment query */ + ThreadComment( + args: AtLeastOne< + { + /** Filter by the comment id */ + id: number + /** Filter by the thread id */ + threadId: number + /** Filter by the user id of the comment's creator */ + userId: number + /** The order the results will be returned in */ + sort: ThreadCommentSort[] + } & Variables + >, + fn?: (fields: ReturnType) => void, + ) { + Object.assign(variables, args) + operation = operation.set({ + level: 1, + subField: "ThreadComment", + hasSubField: true, + variables: args, + }) + if (!fn) operation = operation.set({ level: 2, subField: "id" }) + else { + let tmpQuery + fn(threadCommentFields(tmpQuery = [operation], 2)) + operation = tmpQuery[0] + } + return this + }, + /** Recommendation query */ + Recommendation() { + throw "To be Implemented" + }, + /** Like query */ + Like() { + throw "To be Implemented" + }, + /** Provide AniList markdown to be converted to html (Requires auth) */ + Markdown() { + throw "To be Implemented" + }, + AniChartUser() { + throw "To be Implemented" + }, + /** Site statistics query */ + SiteStatistics() { + throw "To be Implemented" + }, + /** ExternalLinkSource collection query */ + ExternalLinkSourceCollection() { + throw "To be Implemented" + }, + } + }, + get mutation() { + operation = updateOperation({ + field: ["mutation"], + level: 0, + }) + + return { + get raw() { + return { + get variable() { + return variables + }, + get() { + return operation.get() + }, + } + }, + fetch() { + return fetch({ + query: this.raw.get()!, + variables, + token: auth?.token, + }) + }, + /** Update current user options */ + UpdateUser() { + throw "To be Implemented" + }, + /** Create or update a media list entry */ + SaveMediaListEntry() { + throw "To be Implemented" + }, + /** Update multiple media list entries to the same values */ + UpdateMediaListEntries() { + throw "To be Implemented" + }, + /** Delete a media list entry */ + DeleteMediaListEntry() { + throw "To be Implemented" + }, + /** Delete a custom list and remove the list entries from it */ + DeleteCustomList() { + throw "To be Implemented" + }, + /** Create or update text activity for the currently authenticated user */ + SaveTextActivity() { + throw "To be Implemented" + }, + /** Create or update message activity for the currently authenticated user */ + SaveMessageActivity() { + throw "To be Implemented" + }, + /** Update list activity (Mod Only) */ + SaveListActivity() { + throw "To be Implemented" + }, + /** Delete an activity item of the authenticated users */ + DeleteActivity() { + throw "To be Implemented" + }, + /** Toggle activity to be pinned to the top of the user's activity feed */ + ToggleActivityPin() { + throw "To be Implemented" + }, + /** Toggle the subscription of an activity item */ + ToggleActivitySubscription() { + throw "To be Implemented" + }, + /** Create or update an activity reply */ + SaveActivityReply() { + throw "To be Implemented" + }, + /** Delete an activity reply of the authenticated users */ + DeleteActivityReply() { + throw "To be Implemented" + }, + /** Add or remove a like from a likeable type. Returns all the users who liked the same model */ + ToggleLike() { + throw "To be Implemented" + }, + /** Add or remove a like from a likeable type. */ + ToggleLikeV2() { + throw "To be Implemented" + }, + /** Toggle the un/following of a user */ + ToggleFollow() { + throw "To be Implemented" + }, + /** Favourite or unfavourite an anime, manga, character, staff member, or studio */ + ToggleFavourite() { + throw "To be Implemented" + }, + /** Update the order favourites are displayed in */ + UpdateFavouriteOrder() { + throw "To be Implemented" + }, + /** Create or update a review */ + SaveReview() { + throw "To be Implemented" + }, + /** Delete a review */ + DeleteReview() { + throw "To be Implemented" + }, + /** Rate a review */ + RateReview() { + throw "To be Implemented" + }, + /** Recommendation a media */ + SaveRecommendation() { + throw "To be Implemented" + }, + /** Create or update a forum thread */ + SaveThread() { + throw "To be Implemented" + }, + /** Delete a thread */ + DeleteThread() { + throw "To be Implemented" + }, + /** Toggle the subscription of a forum thread */ + ToggleThreadSubscription() { + throw "To be Implemented" + }, + /** Create or update a thread comment */ + SaveThreadComment( + args: + & ({ + /** The comment id, required for updating */ + id: number + } | { + /** The id of thread the comment belongs to */ + threadId: number + } | { + /** The id of thread comment to reply to */ + parentCommentId: number + }) + & { + /** The comment markdown text */ + comment: string + /** If the comment tree should be locked. (Mod Only) */ + locked?: boolean + }, + fn?: (fields: ReturnType) => void, + ) { + Object.assign(variables, args) + operation = operation.set({ + level: 1, + subField: "SaveThreadComment", + hasSubField: true, + variables: args, + }) + if (!fn) operation = operation.set({ level: 2, subField: "id" }) + else { + let tmpQuery + fn(threadCommentFields(tmpQuery = [operation], 2)) + operation = tmpQuery[0] + } + return this + }, + /** Delete a thread comment */ + DeleteThreadComment() { + throw "To be Implemented" + }, + UpdateAniChartSettings() { + throw "To be Implemented" + }, + UpdateAniChartHighlights() { + throw "To be Implemented" + }, + } + }, + } +} diff --git a/src/types/Anilist.ts b/src/types/Anilist.ts new file mode 100644 index 0000000..d38c2dd --- /dev/null +++ b/src/types/Anilist.ts @@ -0,0 +1,159 @@ +type Int = number +type Float = number +type String = string +type Boolean = boolean +type Json = Record + +/** Media list sort enums */ +export enum MediaListSort { + MEDIA_ID = "MEDIA_ID", + MEDIA_ID_DESC = "MEDIA_ID_DESC", + SCORE = "SCORE", + SCORE_DESC = "SCORE_DESC", + STATUS = "STATUS", + STATUS_DESC = "STATUS_DESC", + PROGRESS = "PROGRESS", + PROGRESS_DESC = "PROGRESS_DESC", + PROGRESS_VOLUMES = "PROGRESS_VOLUMES", + PROGRESS_VOLUMES_DESC = "PROGRESS_VOLUMES_DESC", + REPEAT = "REPEAT", + REPEAT_DESC = "REPEAT_DESC", + PRIORITY = "PRIORITY", + PRIORITY_DESC = "PRIORITY_DESC", + STARTED_ON = "STARTED_ON", + STARTED_ON_DESC = "STARTED_ON_DESC", + FINISHED_ON = "FINISHED_ON", + FINISHED_ON_DESC = "FINISHED_ON_DESC", + ADDED_TIME = "ADDED_TIME", + ADDED_TIME_DESC = "ADDED_TIME_DESC", + UPDATED_TIME = "UPDATED_TIME", + UPDATED_TIME_DESC = "UPDATED_TIME_DESC", + MEDIA_TITLE_ROMAJI = "MEDIA_TITLE_ROMAJI", + MEDIA_TITLE_ROMAJI_DESC = "MEDIA_TITLE_ROMAJI_DESC", + MEDIA_TITLE_ENGLISH = "MEDIA_TITLE_ENGLISH", + MEDIA_TITLE_ENGLISH_DESC = "MEDIA_TITLE_ENGLISH_DESC", + MEDIA_TITLE_NATIVE = "MEDIA_TITLE_NATIVE", + MEDIA_TITLE_NATIVE_DESC = "MEDIA_TITLE_NATIVE_DESC", + MEDIA_POPULARITY = "MEDIA_POPULARITY", + MEDIA_POPULARITY_DESC = "MEDIA_POPULARITY_DESC", +} + +/** 8 digit long date integer (YYYYMMDD). Unknown dates represented by 0. */ +export type FuzzyDateInt = string + +/** Thread comments sort enums */ +export enum ThreadCommentSort { + ID = "ID", + ID_DESC = "ID_DESC", +} + +/** Media list scoring type */ +export enum ScoreFormat { + /** An integer from 0-100 */ + POINT_100 = "POINT_100", + /** A float from 0-10 with 1 decimal place */ + POINT_10_DECIMAL = "POINT_10_DECIMAL", + /** An integer from 0-10 */ + POINT_10 = "POINT_10", + /** An integer from 0-5. Should be represented in Stars */ + POINT_5 = "POINT_5", + /** An integer from 0-3. Should be represented in Smileys. 0 => No Score, 1 => :(, 2 => :|, 3 => :) */ + POINT_3 = "POINT_3", +} + +export type Variables = { [arg: string]: string | number | boolean | string[] } + +export type UpdateQuery = { + query?: string + variables?: Variables + field: string[] + level: number + hasSubField?: true +} + +export type Authorization = { + token_type: string + expires_in: number + access_token: string + /** Currently not used for anything */ + refresh_token: string +} + +export type Fetch = { + ( + init: + & Record< + | "code" + | "client_id" + | "client_secret" + | "redirect_uri", + string + > + & { "grant_type"?: "authorization_code" }, + ): Promise + ( + init: { query: string; variables: Variables; token?: string }, + ): Promise +} + +export type Fields = (Readonly< + [string] | [string, true] | [string, true, Fields] +>)[] + +export type MediaListArguments = { + /** Filter by a list entry's id */ + id: number + /** Filter by a user's id */ + userId: number + /** Filter by a user's name */ + userName: string + /** Filter by the list entries media type */ + type: MediaType + /** Filter by the watching/reading status */ + status: MediaListStatus + /** Filter by the media id of the list entry */ + mediaId: number + /** Filter list entries to users who are being followed by the authenticated user */ + isFollowing: boolean + /** Filter by note words and #tags */ + notes: string + /** Filter by the date the user started the media */ + startedAt: FuzzyDateInt + /** Filter by the date the user completed the media */ + completedAt: FuzzyDateInt + /** Limit to only entries also on the auth user's list. Requires user id or name arguments */ + compareWithAuthList: boolean + /** Filter by a user's id */ + userId_in: number[] + /** Filter by the watching/reading status */ + status_in: MediaListStatus[] + /** Filter by the watching/reading status */ + status_not_in: MediaListStatus[] + /** Filter by the watching/reading status */ + status_not: MediaListStatus + /** Filter by the media id of the list entry */ + mediaId_in: number[] + /** Filter by the media id of the list entry */ + mediaId_not_in: number[] + /** Filter by note words and #tags */ + notes_like: string + /** Filter by the date the user started the media */ + startedAt_greater: FuzzyDateInt + /** Filter by the date the user started the media */ + startedAt_lesser: FuzzyDateInt + /** Filter by the date the user started the media */ + startedAt_like: string + /** Filter by the date the user completed the media */ + completedAt_greater: FuzzyDateInt + /** Filter by the date the user completed the media */ + completedAt_lesser: FuzzyDateInt + /** Filter by the date the user completed the media */ + completedAt_like: string + /** The order the results will be returned in */ + sort: MediaListSort[] +} + +export type Response> = { + data: T +} + diff --git a/src/types/AtLeastOne.ts b/src/types/AtLeastOne.ts new file mode 100644 index 0000000..4d5a6bc --- /dev/null +++ b/src/types/AtLeastOne.ts @@ -0,0 +1,5 @@ +export type AtLeastOne = { + [P in keyof O]: + & { [L in P]: O[L] } + & { [L in Exclude]?: O[L] } +}[keyof O] diff --git a/src/utils/toCase.ts b/src/utils/toCase.ts new file mode 100644 index 0000000..091a9b4 --- /dev/null +++ b/src/utils/toCase.ts @@ -0,0 +1,7 @@ +const Case = { + up: "toUpperCase", + down: "toLowerCase", +} as const + +export const toCase = (type: keyof typeof Case, str: T) => + str[0][Case[type]]() + str.slice(1) as Capitalize