From 81b7414b2a5c86d66f2d7acb398e5d3857a31dad Mon Sep 17 00:00:00 2001 From: Simon Lecoq <22963968+lowlighter@users.noreply.github.com> Date: Sun, 24 Jan 2021 21:50:56 +0100 Subject: [PATCH] Add anilist plugin (#69) --- README.md | 71 +++++++++- action.yml | 40 ++++++ settings.example.json | 3 + source/app/action/index.mjs | 12 ++ source/app/metrics.mjs | 7 +- source/app/mocks.mjs | 124 ++++++++++++++++- source/app/web/statics/app.js | 14 ++ source/app/web/statics/app.placeholder.js | 55 ++++++++ source/plugins/anilist/index.mjs | 122 ++++++++++++++++ .../anilist/queries/characters.graphql | 21 +++ .../plugins/anilist/queries/favorites.graphql | 34 +++++ source/plugins/anilist/queries/medias.graphql | 50 +++++++ .../anilist/queries/statistics.graphql | 25 ++++ source/plugins/languages/index.mjs | 1 - source/queries/people.repository.graphql | 16 +++ source/templates/classic/partials/_.json | 3 +- source/templates/classic/partials/anilist.ejs | 130 ++++++++++++++++++ source/templates/classic/style.css | 71 ++++++++++ tests/metrics.test.js | 36 +++++ 19 files changed, 828 insertions(+), 7 deletions(-) create mode 100644 source/plugins/anilist/index.mjs create mode 100644 source/plugins/anilist/queries/characters.graphql create mode 100644 source/plugins/anilist/queries/favorites.graphql create mode 100644 source/plugins/anilist/queries/medias.graphql create mode 100644 source/plugins/anilist/queries/statistics.graphql create mode 100644 source/queries/people.repository.graphql create mode 100644 source/templates/classic/partials/anilist.ejs diff --git a/README.md b/README.md index 250ece56..499b0f8d 100644 --- a/README.md +++ b/README.md @@ -184,16 +184,30 @@ But there's more with [plugins](https://github.com/lowlighter/metrics/tree/maste + 🌸 Anilist plugin đŸ—ƒī¸ Header special features - + + + + +
Manga version + + + +
+
Favorites characters version + + + +
+ - @@ -558,6 +572,7 @@ The default template is `classic`. ✨ đŸŽĢ 🧑‍🤝‍🧑 + 🌸 Classic @@ -579,6 +594,7 @@ The default template is `classic`. âœ”ī¸ âœ”ī¸N âœ”ī¸ + âœ”ī¸M Terminal @@ -600,6 +616,7 @@ The default template is `classic`. ❌ âœ”ī¸N ❌ + ❌ RepositoryR @@ -621,6 +638,7 @@ The default template is `classic`. âœ”ī¸ ❌ ❌ + ❌ @@ -1070,7 +1088,7 @@ You can specify either an index with a color, or a language name (case insensiti Colors can be either in hexadecimal format or a [named color](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value). Use the special value `rainbow` to use rainbow colors. Use `complementary` to use [complementary colors](https://en.wikipedia.org/wiki/Complementary_colors). - + ### đŸŽŸī¸ Follow-up @@ -1541,6 +1559,53 @@ It is possible to use [identicons](https://github.blog/2013-08-14-identicons/) i +### 🌸 Anilist + + 🚧 This feature is available as pre-release on @master branch (unstable) + +The *anilist* plugin lets you display your favorites animes, mangas and characters from [AniList](https://anilist.co) data. + +![Anilist plugin](https://github.com/lowlighter/lowlighter/blob/master/metrics.plugin.anilist.svg) + + â„šī¸ This plugin significantly increase file size, it is advised to run it as standalone + +
+đŸ’Ŧ About + +![Anilist plugin](https://github.com/lowlighter/lowlighter/blob/master/metrics.plugin.anilist.full.svg) + +This plugin is composed of the following sections, which can be displayed or hidden through `plugin_anilist_sections` option: +- `favorites` will display your favorites mangas and animes +- `watching` will display animes currently in your watching list +- `reading` will display manga currently in your reading list +- `characters` will display characters you liked + +These sections can also be filtered by media type, which can be either `anime`, `manga` or both. + +Add the following to your workflow: +```yaml +- uses: lowlighter/metrics@master + with: + # ... other options + plugin_anilist: yes + plugin_anilist_medias: anime, manga + plugin_anilist_sections: favorites, watching, reading, characters + plugin_anilist_limit: 2 + plugin_anilist_shuffle: yes # Shuffle data from AniList for varied outputs +``` + +It is possible to use a different username from your GitHub account by using `plugin_anilist_user` option. + +Add the following to your workflow: +```yaml +- uses: lowlighter/metrics@master + with: + # ... other options + plugin_anilist_user: ******** +``` + +
+ ### 🔧 Other options A few additional options are available. diff --git a/action.yml b/action.yml index 860333ca..2bf1b6d2 100644 --- a/action.yml +++ b/action.yml @@ -482,6 +482,46 @@ inputs: description: Use identicons instead of real avatars default: no + # Display your favorites animes and mangas from AniList + plugin_anilist: + description: Display your favorites animes and mangas from AniList + default: no + + # Medias to display from AniList (comma-separated list) + # Supported values are: + # - "anime" + # - "manga" + plugin_anilist_medias: + description: Medias to display from AniList data + default: anime, manga + + # Sections to display from AniList data (comma-separated list) + # Values in "plugin_anilist_medias" may also impact displayed sections + # Supported values are: + # - "favorites" for favorites animes/mangas + # - "watching" for currently watched animes + # - "reading" for currently read mangas + # - "characters" for favorites characters + plugin_anilist_sections: + description: Sections to display from AniList data + default: favorites + + # Maximum number of medias to display per section from AniList Data + plugin_anilist_limit: + description: Medias to display + default: 2 + + # Shuffle AniList data + plugin_anilist_shuffle: + description: Shuffle AniList data + default: yes + + # Username on AniList + # Default to GitHub username + plugin_anilist_user: + description: AniList login + default: "" + # ==================================================================================== # Options below are mostly used for testing diff --git a/settings.example.json b/settings.example.json index 405e8d95..0cd98447 100644 --- a/settings.example.json +++ b/settings.example.json @@ -74,6 +74,9 @@ }, "people":{ "//":"People plugin", "enabled":false, "//":"Enable or disable people display" + }, + "anilist":{ "//":"Anilist plugin", + "enabled":false, "//":"Enable or disable anilist display" } } } \ No newline at end of file diff --git a/source/app/action/index.mjs b/source/app/action/index.mjs index bab95f2f..e14e6e86 100644 --- a/source/app/action/index.mjs +++ b/source/app/action/index.mjs @@ -151,6 +151,7 @@ stargazers:{enabled:input.bool("plugin_stargazers")}, activity:{enabled:input.bool("plugin_activity")}, people:{enabled:input.bool("plugin_people")}, + anilist:{enabled:input.bool("plugin_anilist")}, } let q = Object.fromEntries(Object.entries(plugins).filter(([key, plugin]) => plugin.enabled).map(([key]) => [key, true])) info("Plugins enabled", Object.entries(plugins).filter(([key, plugin]) => plugin.enabled).map(([key]) => key)) @@ -243,6 +244,17 @@ for (const option of ["identicons"]) info(`People ${option}`, q[`people.${option}`] = input.bool(`plugin_people_${option}`)) } + //Anilist + if (plugins.anilist.enabled) { + for (const option of ["limit"]) + info(`Anilist ${option}`, q[`anilist.${option}`] = input.number(`plugin_anilist_${option}`)) + for (const option of ["medias", "sections"]) + info(`Anilist ${option}`, q[`anilist.${option}`] = input.array(`plugin_anilist_${option}`)) + for (const option of ["shuffle"]) + info(`Anilist ${option}`, q[`anilist.${option}`] = input.bool(`plugin_anilist_${option}`)) + for (const option of ["user"]) + info(`Anilist ${option}`, q[`anilist.${option}`] = input.string(`plugin_anilist_${option}`)) + } //Repositories to use const repositories = input.number("repositories") diff --git a/source/app/metrics.mjs b/source/app/metrics.mjs index 6476823c..042daed6 100644 --- a/source/app/metrics.mjs +++ b/source/app/metrics.mjs @@ -68,7 +68,7 @@ //Compute metrics console.debug(`metrics/compute/${login} > compute`) const computer = Templates[template].default || Templates[template] - await computer({login, q, dflags}, {conf, data, rest, graphql, plugins, queries}, {s, pending, imports:{plugins:Plugins, url, imgb64, axios, puppeteer, run, fs, os, paths, util, format, bytes, shuffle, htmlescape, urlexpand}}) + await computer({login, q, dflags}, {conf, data, rest, graphql, plugins, queries}, {s, pending, imports:{plugins:Plugins, url, imgb64, axios, puppeteer, run, fs, os, paths, util, format, bytes, shuffle, htmlescape, urlexpand, __module}}) const promised = await Promise.all(pending) //Check plugins errors @@ -123,6 +123,11 @@ } } +/** Returns module __dirname */ + function __module(module) { + return paths.join(paths.dirname(url.fileURLToPath(module))) + } + /** Formatter */ function format(n, {sign = false} = {}) { for (const {u, v} of [{u:"b", v:10**9}, {u:"m", v:10**6}, {u:"k", v:10**3}]) diff --git a/source/app/mocks.mjs b/source/app/mocks.mjs index 9c61d69e..57b48da2 100644 --- a/source/app/mocks.mjs +++ b/source/app/mocks.mjs @@ -925,6 +925,128 @@ }) } } + //Anilist api + if (/^https:..graphql.anilist.co/.test(url)) { + //Initialization and media generator + const query = body.query + const media = ({type}) => ({ + title:{romaji:faker.lorem.words(), english:faker.lorem.words(), native:faker.lorem.words()}, + description:faker.lorem.paragraphs(), + type, + status:faker.random.arrayElement(["FINISHED", "RELEASING", "NOT_YET_RELEASED", "CANCELLED", "HIATUS"]), + episodes:100+faker.random.number(100), + volumes:faker.random.number(100), + chapters:100+faker.random.number(1000), + averageScore:faker.random.number(100), + countryOfOrigin:"JP", + genres:new Array(6).fill(null).map(_ => faker.lorem.word()), + coverImage:{medium:null}, + startDate:{year:faker.date.past(20).getFullYear()} + }) + //User statistics query + if (/^query Statistics /.test(query)) { + console.debug(`metrics/compute/mocks > mocking anilist api result > Statistics`) + return ({ + status:200, + data:{ + data:{ + User:{ + id:faker.random.number(100000), + name:faker.internet.userName(), + about:null, + statistics:{ + anime:{ + count:faker.random.number(1000), + minutesWatched:faker.random.number(100000), + episodesWatched:faker.random.number(10000), + genres:new Array(4).fill(null).map(_ => ({genre:faker.lorem.word()})), + }, + manga:{ + count:faker.random.number(1000), + chaptersRead:faker.random.number(100000), + volumesRead:faker.random.number(10000), + genres:new Array(4).fill(null).map(_ => ({genre:faker.lorem.word()})), + }, + } + } + } + } + }) + } + //Favorites characters + if (/^query FavoritesCharacters /.test(query)) { + console.debug(`metrics/compute/mocks > mocking anilist api result > Favorites characters`) + return ({ + status:200, + data:{ + data:{ + User:{ + favourites:{ + characters:{ + nodes:new Array(2+faker.random.number(16)).fill(null).map(_ => ({ + name:{full:faker.name.findName(), native:faker.name.findName()}, + image:{medium:null} + }), + ), + pageInfo:{currentPage:1, hasNextPage:false} + } + } + } + } + } + }) + } + //Favorites anime/manga query + if (/^query Favorites /.test(query)) { + console.debug(`metrics/compute/mocks > mocking anilist api result > Favorites`) + const type = /anime[(]/.test(query) ? "ANIME" : /manga[(]/.test(query) ? "MANGA" : "OTHER" + return ({ + status:200, + data:{ + data:{ + User:{ + favourites:{ + [type.toLocaleLowerCase()]:{ + nodes:new Array(16).fill(null).map(_ => media({type})), + pageInfo:{currentPage:1, hasNextPage:false}, + } + } + } + } + } + }) + } + //Medias query + if (/^query Medias /.test(query)) { + console.debug(`metrics/compute/mocks > mocking anilist api result > Medias`) + const type = body.variables.type + return ({ + status:200, + data:{ + data:{ + MediaListCollection:{ + lists:[ + { + name:{ANIME:"Watching", MANGA:"Reading", OTHER:"Completed"}[type], + isCustomList:false, + entries:new Array(16).fill(null).map(_ => ({ + status:faker.random.arrayElement(["CURRENT", "PLANNING", "COMPLETED", "DROPPED", "PAUSED", "REPEATING"]), + progress:faker.random.number(100), + progressVolumes: null, + score:0, + startedAt:{year:null, month:null, day:null}, + completedAt:{year:null, month:null, day:null}, + media:media({type}) + })), + } + ] + } + } + } + }) + } + + } return target(...args) } }) @@ -1098,7 +1220,7 @@ } } //Last.fm api - if (/^https:..ws.audioscrobbler.com/.test(url)) { + if (/^https:..ws.audioscrobbler.com.*$/.test(url)) { //Get recently played tracks if (/user.getrecenttracks/.test(url)) { console.debug(`metrics/compute/mocks > mocking lastfm api result > ${url}`) diff --git a/source/app/web/statics/app.js b/source/app/web/statics/app.js index eaffea9c..d745b741 100644 --- a/source/app/web/statics/app.js +++ b/source/app/web/statics/app.js @@ -4,6 +4,7 @@ const {data:plugins} = await axios.get("/.plugins") const {data:base} = await axios.get("/.plugins.base") const {data:version} = await axios.get("/.version") + templates.sort((a, b) => (a.name.startsWith("@") ^ b.name.startsWith("@")) ? (a.name.startsWith("@") ? 1 : -1) : a.name.localeCompare(b.name)) //App return new Vue({ //Initialization @@ -76,6 +77,7 @@ stargazers:"✨ Stargazers over last weeks", activity:"📰 Recent activity", people:"🧑‍🤝‍🧑 Followers and followed", + anilist:"🌸 Anilist", base:"đŸ—ƒī¸ Base content", "base.header":"Header", "base.activity":"Account activity", @@ -87,6 +89,7 @@ descriptions:{ "languages.ignored":{text:"Ignored languages", placeholder:"lang-0, lang-1, ..."}, "languages.skipped":{text:"Skipped repositories", placeholder:"repo-0, repo-1, ..."}, + "languages.colors":{text:"Custom language colors", placeholder:"0:#ff0000, javascript:yellow, ..."}, "pagespeed.detailed":{text:"Detailed audit", type:"boolean"}, "pagespeed.screenshot":{text:"Audit screenshot", type:"boolean"}, "pagespeed.url":{text:"Url", placeholder:"(default to GitHub attached)"}, @@ -104,6 +107,7 @@ "isocalendar.duration":{text:"Duration", type:"select", values:["half-year", "full-year"]}, "projects.limit":{text:"Limit", type:"number", min:0, max:100}, "projects.repositories":{text:"Repositories projects", placeholder:"user/repo/projects/1, ..."}, + "projects.descriptions":{text:"Projects descriptions", type:"boolean"}, "topics.mode":{text:"Mode", type:"select", values:["starred", "mastered"]}, "topics.sort":{text:"Sort by", type:"select", values:["starred", "activity", "stars", "random"]}, "topics.limit":{text:"Limit", type:"number", min:0, max:20}, @@ -117,6 +121,11 @@ "people.limit":{text:"Limit", type:"number", min:1, max:9999}, "people.types":{text:"Types", placeholder:"followers, following"}, "people.identicons":{text:"Use identicons", type:"boolean"}, + "anilist.medias":{text:"Medias to display", placeholder:"anime, manga"}, + "anilist.sections":{text:"Sections to display", placeholder:"favorites, watching, reading, characters"}, + "anilist.limit":{text:"Limit", type:"number", min:0, max:9999}, + "anilist.shuffle":{text:"Shuffle data", type:"boolean"}, + "anilist.user":{text:"Username", placeholder:"(default to GitHub login)"}, }, "languages.ignored":"", "languages.skipped":"", @@ -149,6 +158,11 @@ "people.limit":28, "people.types":"followers, following", "people.identicons":false, + "anilist.medias":"anime, manga", + "anilist.sections":"favorites", + "anilist.limit":2, + "anilist.shuffle":true, + "anilist.user":"", }, }, templates:{ diff --git a/source/app/web/statics/app.placeholder.js b/source/app/web/statics/app.placeholder.js index 5f936065..bfc65b78 100644 --- a/source/app/web/statics/app.placeholder.js +++ b/source/app/web/statics/app.placeholder.js @@ -302,8 +302,10 @@ ...(set.plugins.enabled.projects ? ({ projects:{ totalCount:options["projects.limit"]+faker.random.number(10), + descriptions:options["projects.descriptions"], list:new Array(Number(options["projects.limit"])).fill(null).map(_ => ({ name:faker.lorem.sentence(), + description:faker.lorem.paragraph(), updated:`${2+faker.random.number(8)} days ago`, progress:{enabled:true, todo:faker.random.number(50), doing:faker.random.number(50), done:faker.random.number(50), get total() { return this.todo + this.doing + this.done } } })) @@ -403,6 +405,59 @@ return result } }) : null), + //Anilist + ...(set.plugins.enabled.anilist ? ({ + anilist:{ + user:{ + stats:{ + anime:{ + count:faker.random.number(1000), + minutesWatched:faker.random.number(100000), + episodesWatched:faker.random.number(10000), + genres:new Array(4).fill(null).map(_ => ({genre:faker.lorem.word()})), + }, + manga:{ + count:faker.random.number(1000), + chaptersRead:faker.random.number(100000), + volumesRead:faker.random.number(10000), + genres:new Array(4).fill(null).map(_ => ({genre:faker.lorem.word()})), + }, + }, + genres:new Array(4).fill(null).map(_ => ({genre:faker.lorem.word()})), + }, + get lists() { + const media = (type) => ({ + name:faker.lorem.words(), + type, + status:faker.random.arrayElement(["FINISHED", "RELEASING", "NOT_YET_RELEASED", "CANCELLED", "HIATUS"]), + release:faker.date.past(20).getFullYear(), + genres:new Array(6).fill(null).map(_ => faker.lorem.word()), + progress:faker.random.number(100), + description:faker.lorem.paragraphs(), + scores:{user:faker.random.number(100), community:faker.random.number(100)}, + released:100+faker.random.number(1000), + artwork:"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mOcOnfpfwAGfgLYttYINwAAAABJRU5ErkJggg==", + }) + const sections = options["anilist.sections"].split(",").map(x => x.trim()).filter(x => x) + const medias = options["anilist.medias"].split(",").map(x => x.trim()).filter(x => x) + return { + ...(medias.includes("anime") ? {anime:{ + ...(sections.includes("watching") ? {watching:new Array(Number(options["anilist.limit"])||4).fill(null).map(_ => media("ANIME"))} : {}), + ...(sections.includes("favorites") ? {favorites:new Array(Number(options["anilist.limit"])||4).fill(null).map(_ => media("ANIME"))} : {}), + }} : {}), + ...(medias.includes("manga") ? {manga:{ + ...(sections.includes("reading") ? {reading:new Array(Number(options["anilist.limit"])||4).fill(null).map(_ => media("MANGA"))} : {}), + ...(sections.includes("favorites") ? {favorites:new Array(Number(options["anilist.limit"])||4).fill(null).map(_ => media("MANGA"))} : {}), + }} : {}), + } + }, + characters:new Array(11).fill(null).map(_ => ({ + name:faker.name.findName(), + artwork:"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mOcOnfpfwAGfgLYttYINwAAAABJRU5ErkJggg==", + })), + sections:options["anilist.sections"].split(",").map(x => x.trim()).filter(x => x) + } + }) : null), //Activity ...(set.plugins.enabled.activity ? ({ activity:{ diff --git a/source/plugins/anilist/index.mjs b/source/plugins/anilist/index.mjs new file mode 100644 index 00000000..3ff377e9 --- /dev/null +++ b/source/plugins/anilist/index.mjs @@ -0,0 +1,122 @@ +//Setup + export default async function ({login, imports, q}, {enabled = false} = {}) { + //Plugin execution + try { + //Check if plugin is enabled and requirements are met + if ((!enabled)||(!q.anilist)) + return null + //Parameters override + let {"anilist.medias":medias = ["anime", "manga"], "anilist.sections":sections = ["favorites"], "anilist.limit":limit = 2, "anilist.shuffle":shuffle = true, "anilist.user":user = login} = q + //Medias types + medias = decodeURIComponent(medias).split(",").map(x => x.trim().toLocaleLowerCase()).filter(x => ["anime", "manga"].includes(x)) + //Sections + sections = decodeURIComponent(sections).split(",").map(x => x.trim().toLocaleLowerCase()).filter(x => ["favorites", "watching", "reading", "characters"].includes(x)) + //Limit medias + limit = Math.max(0, Number(limit)) + //GraphQL queries + const query = { + statistics:`${await imports.fs.readFile(`${imports.__module(import.meta.url)}/queries/statistics.graphql`)}`, + characters:`${await imports.fs.readFile(`${imports.__module(import.meta.url)}/queries/characters.graphql`)}`, + medias:`${await imports.fs.readFile(`${imports.__module(import.meta.url)}/queries/medias.graphql`)}`, + favorites:`${await imports.fs.readFile(`${imports.__module(import.meta.url)}/queries/favorites.graphql`)}`, + } + //Initialization + const result = {user:{stats:null, genres:[]}, lists:Object.fromEntries(medias.map(type => [type, {}])), characters:[], sections} + //User statistics + { + //Query API + console.debug(`metrics/compute/${login}/plugins > anilist > querying api (user statistics)`) + const {data:{data:{User:{statistics:stats}}}} = await imports.axios.post("https://graphql.anilist.co", {variables:{name:user}, query:query.statistics}) + //Format and save results + result.user.stats = stats + result.user.genres = [...new Set([...stats.anime.genres.map(({genre}) => genre), ...stats.manga.genres.map(({genre}) => genre)])] + } + //Medias lists + if ((sections.includes("watching"))||(sections.includes("reading"))) { + for (const type of medias) { + //Query API + console.debug(`metrics/compute/${login}/plugins > anilist > querying api (medias lists - ${type})`) + const {data:{data:{MediaListCollection:{lists}}}} = await imports.axios.post("https://graphql.anilist.co", {variables:{name:user, type:type.toLocaleUpperCase()}, query:query.medias}) + //Format and save results + for (const {name, entries} of lists) { + //Format results + const list = await Promise.all(entries.map(async media => await format({media, imports}))) + result.lists[type][name.toLocaleLowerCase()] = shuffle ? imports.shuffle(list) : list + //Limit results + if (limit > 0) { + console.debug(`metrics/compute/${login}/plugins > anilist > keeping only ${limit} medias`) + result.lists[type][name.toLocaleLowerCase()].splice(limit) + } + } + } + } + //Favorites anime/manga + if (sections.includes("favorites")) { + for (const type of medias) { + //Query API + console.debug(`metrics/compute/${login}/plugins > anilist > querying api (favorites ${type}s)`) + const list = [] + let page = 1 + let next = false + do { + console.debug(`metrics/compute/${login}/plugins > anilist > querying api (favorites ${type}s - page ${page})`) + const {data:{data:{User:{favourites:{[type]:{nodes, pageInfo:cursor}}}}}} = await imports.axios.post("https://graphql.anilist.co", {variables:{name:user, page}, query:query.favorites.replace(/[$]type/g, type)}) + page = cursor.currentPage + next = cursor.hasNextPage + list.push(...await Promise.all(nodes.map(media => format({media:{progess:null, score:null, media}, imports})))) + } while (next) + //Format and save results + result.lists[type].favorites = shuffle ? imports.shuffle(list) : list + //Limit results + if (limit > 0) { + console.debug(`metrics/compute/${login}/plugins > anilist > keeping only ${limit} medias`) + result.lists[type].favorites.splice(limit) + } + } + } + //Favorites characters + if (sections.includes("characters")) { + //Query API + console.debug(`metrics/compute/${login}/plugins > anilist > querying api (favorites characters)`) + const characters = [] + let page = 1 + let next = false + do { + console.debug(`metrics/compute/${login}/plugins > anilist > querying api (favorites characters - page ${page})`) + const {data:{data:{User:{favourites:{characters:{nodes, pageInfo:cursor}}}}}} = await imports.axios.post("https://graphql.anilist.co", {variables:{name:user, page}, query:query.characters}) + page = cursor.currentPage + next = cursor.hasNextPage + for (const {name:{full:name}, image:{medium:artwork}} of nodes) + characters.push({name, artwork:artwork ? await imports.imgb64(artwork) : "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mOcOnfpfwAGfgLYttYINwAAAABJRU5ErkJggg=="}) + } while (next) + //Format and save results + result.characters = shuffle ? imports.shuffle(characters) : characters + } + //Results + return result + } + //Handle errors + catch (error) { + let message = "An error occured" + if (error.isAxiosError) { + const status = error.response?.status + console.log(error.response.data) + message = `API returned ${status}` + error = error.response?.data ?? null + } + throw {error:{message, instance:error}} + } + } + +/** Media formatter */ + async function format({media, imports}) { + const {progress, score:userScore, media:{title, description, status, startDate:{year:release}, genres, averageScore, episodes, chapters, type, coverImage:{medium:artwork}}} = media + return { + name:title.romaji, + type, status, release, genres, progress, + description:description.replace(//g, " "), + scores:{user:userScore, community:averageScore}, + released:type === "ANIME" ? episodes : chapters, + artwork:artwork ? await imports.imgb64(artwork) : "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mOcOnfpfwAGfgLYttYINwAAAABJRU5ErkJggg==" + } + } diff --git a/source/plugins/anilist/queries/characters.graphql b/source/plugins/anilist/queries/characters.graphql new file mode 100644 index 00000000..0fc01cc6 --- /dev/null +++ b/source/plugins/anilist/queries/characters.graphql @@ -0,0 +1,21 @@ +query FavoritesCharacters ($name: String, $page:Int) { + User(name: $name) { + favourites { + characters(page: $page) { + nodes { + name { + full + native + } + image { + medium + } + } + pageInfo { + currentPage + hasNextPage + } + } + } + } +} \ No newline at end of file diff --git a/source/plugins/anilist/queries/favorites.graphql b/source/plugins/anilist/queries/favorites.graphql new file mode 100644 index 00000000..c5631741 --- /dev/null +++ b/source/plugins/anilist/queries/favorites.graphql @@ -0,0 +1,34 @@ +query Favorites ($name: String, $page:Int) { + User(name: $name) { + favourites { + $type(page: $page) { + nodes { + title { + romaji + english + native + } + description + type + status(version: 2) + episodes + volumes + chapters + averageScore + countryOfOrigin + genres + coverImage { + medium + } + startDate { + year + } + } + pageInfo { + currentPage + hasNextPage + } + } + } + } +} \ No newline at end of file diff --git a/source/plugins/anilist/queries/medias.graphql b/source/plugins/anilist/queries/medias.graphql new file mode 100644 index 00000000..9001a49d --- /dev/null +++ b/source/plugins/anilist/queries/medias.graphql @@ -0,0 +1,50 @@ +query Medias ($name: String, $type: MediaType) { + MediaListCollection(userName: $name, type: $type) { + lists { + name + isCustomList + entries { + ...mediaListEntry + } + } + } +} + +fragment mediaListEntry on MediaList { + status + progress + progressVolumes + score + startedAt { + year + month + day + } + completedAt { + year + month + day + } + media { + title { + romaji + english + native + } + description + type + status(version: 2) + episodes + volumes + chapters + averageScore + countryOfOrigin + genres + coverImage { + medium + } + startDate { + year + } + } +} diff --git a/source/plugins/anilist/queries/statistics.graphql b/source/plugins/anilist/queries/statistics.graphql new file mode 100644 index 00000000..683c3e20 --- /dev/null +++ b/source/plugins/anilist/queries/statistics.graphql @@ -0,0 +1,25 @@ +query Statistics ($name: String) { + User(name: $name) { + id + name + about + statistics { + anime { + count + minutesWatched + episodesWatched + genres(limit: 4) { + genre + } + } + manga { + count + chaptersRead + volumesRead + genres(limit: 4) { + genre + } + } + } + } +} \ No newline at end of file diff --git a/source/plugins/languages/index.mjs b/source/plugins/languages/index.mjs index 36d0fc28..b64159d5 100644 --- a/source/plugins/languages/index.mjs +++ b/source/plugins/languages/index.mjs @@ -16,7 +16,6 @@ colors = ["0:#ff0000", "1:#ffa500", "2:#ffff00", "3:#008000", "4:#0000ff", "5:#4b0082", "6:#ee82ee", "7:#162221"] if (`${colors}` === "complementary") colors = ["0:#ff0000", "1:#008000", "2:#ffa500", "3:#0000ff", "4:#ffff00", "5:#4b0082", "6:#162221", "7:#ee82ee"] - colors = Object.fromEntries(decodeURIComponent(colors).split(",").map(x => x.trim().toLocaleLowerCase()).filter(x => x).map(x => x.split(":").map(x => x.trim()))) console.debug(`metrics/compute/${login}/plugins > languages > custom colors ${JSON.stringify(colors)}`) //Iterate through user's repositories and retrieve languages data diff --git a/source/queries/people.repository.graphql b/source/queries/people.repository.graphql new file mode 100644 index 00000000..94f1d5b3 --- /dev/null +++ b/source/queries/people.repository.graphql @@ -0,0 +1,16 @@ +query Repository { + user(login: "$login") { + repository(name: "$repository") { + $type(first: 100) { + pageInfo { + hasNextPage + endCursor + } + nodes { + avatarUrl(size: 24) + login + } + } + } + } +} \ No newline at end of file diff --git a/source/templates/classic/partials/_.json b/source/templates/classic/partials/_.json index a5992dab..cd6658ec 100644 --- a/source/templates/classic/partials/_.json +++ b/source/templates/classic/partials/_.json @@ -16,5 +16,6 @@ "stars", "stargazers", "people", - "activity" + "activity", + "anilist" ] \ No newline at end of file diff --git a/source/templates/classic/partials/anilist.ejs b/source/templates/classic/partials/anilist.ejs new file mode 100644 index 00000000..26c05aaf --- /dev/null +++ b/source/templates/classic/partials/anilist.ejs @@ -0,0 +1,130 @@ +<% if (plugins.anilist) { %> +
+

+ + Anilist +

+ <% if (plugins.anilist.error) { %> +
+
+
+ + <%= plugins.anilist.error.message %> +
+
+
+ <% } else { %> +
+
+
+ + Favorites genres: <%= plugins.anilist.user.genres.join(", ") %> +
+
+
+
+
+
+ + <%= f(plugins.anilist.user.stats.anime.minutesWatched) %> minute<%= s(plugins.anilist.user.stats.anime.minutesWatched) %> watched +
+
+
+
+ + <%= f(plugins.anilist.user.stats.manga.chaptersRead) %> chapter<%= s(plugins.anilist.user.stats.manga.chaptersRead) %> read +
+
+
+
+
+ <% for (const media of Object.keys(plugins.anilist.lists)) { %> + <% for (const list of plugins.anilist.sections) { %> + <% if (plugins.anilist.lists?.[media]?.[list]?.length) { %> +
+

+ + <%= {favorites:`Favorites ${media}s`, watching:"Currently watching", reading:"Currently reading"}[list] %> +

+ <% for (const {name, type, description, release, status, genres, scores, progress, released, artwork} of plugins.anilist.lists[media][list]) { %> +
+ +
+
+ <%= name %> +
+
+
+ <% if (type === "ANIME") { %> + + Anime + <% } else if (type === "MANGA") { %> + + Manga + <% } else { %> + + Other + <% } %> +
+
+ + <%= status === "NOT_YET_RELEASED" ? "Not yet released" : `${release} ${{FINISHED:"", RELEASING:"(releasing)", NOT_YET_RELEASED:"(unreleased)", CANCELLED:"(cancelled)", HIATUS:"(hiatus)"}[status]}` %> +
+ <% if (scores.community) { %> +
+ + <%= scores.community %>% +
+ <% } %> + <% if (Number.isFinite(progress)) { %> +
+ <% if (progress === released) { %> + + <% } else { %> + + <% } %> + <%= type === "ANIME" ? "Episode" : type === "MANGA" ? "Chapter" : "" %> <%= progress %><%= released ? `/${released}` : "" %> +
+ <% } else { %> +
+ + <%= progress %><%= released %> <%= type === "ANIME" ? `episode${s(released)}` : type === "MANGA" ? `chapter${s(released)}` : "" %> +
+ <% } %> +
+
+
+ + <%= genres.join(", ") %> +
+
+
+ <%= description %> +
+
+
+ <% } %> +
+ <% } %> + <% } %> + <% } %> + <% if (plugins.anilist.sections.includes("characters")) { %> +
+

+ + Favorites characters +

+
+ <% for (const {name, artwork} of plugins.anilist.characters) { %> + + <% } %> +
+
+ <% } %> +
+
+ <% } %> +
+<% } %> + + diff --git a/source/templates/classic/style.css b/source/templates/classic/style.css index 46b05d9a..06855c2f 100644 --- a/source/templates/classic/style.css +++ b/source/templates/classic/style.css @@ -527,6 +527,77 @@ -webkit-box-orient: vertical; } +/* Anilist */ + .anilist { + display: flex; + flex-direction: column; + align-items: flex-start; + margin-left: 28px; + margin-top: 4px; + } + + .anilist .media { + display: flex; + margin-bottom: 4px; + width: 450px; + } + .anilist .media img { + margin: 0 10px; + border-radius: 7px; + } + + .anilist .media .about { + flex-grow: 1; + } + .anilist .media .name { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 14px; + line-height: 14px; + color: #58a6ff; + } + .anilist .media .infos { + font-size: 12px; + color: #666666; + } + .anilist .media .infos > div { + display: inline-flex; + align-items: center; + margin-right: 16px; + } + .anilist .media .infos svg { + fill: currentColor; + height: 12px; + width: 12px; + margin: 0; + margin-right: 4px; + } + + .anilist .media .description { + overflow: hidden; + text-overflow: ellipsis; + display: block; + width: 380px; + max-height: 38px; + font-size: 12px; + white-space: normal; + /* May not work in all browsers */ + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + } + + .anilist .characters { + display: flex; + flex-wrap: wrap; + } + + .anilist .characters img { + margin: 2px; + border-radius: 7px; + } + /* Fade animation */ .af { opacity: 0; diff --git a/tests/metrics.test.js b/tests/metrics.test.js index 80e84d5b..cbbfb22c 100644 --- a/tests/metrics.test.js +++ b/tests/metrics.test.js @@ -290,6 +290,42 @@ plugin_people:true, plugin_people_identicons:true, }, {skip:["terminal", "repository"]}], + ["Anilist plugin (default)", { + plugin_anilist:true, + }, {skip:["terminal", "repository"]}], + ["Anilist plugin (manga only)", { + plugin_anilist:true, + plugin_anilist_medias:"manga", + }, {skip:["terminal", "repository"]}], + ["Anilist plugin (anime only)", { + plugin_anilist:true, + plugin_anilist_medias:"anime", + }, {skip:["terminal", "repository"]}], + ["Anilist plugin (favorites section)", { + plugin_anilist:true, + plugin_anilist_sections:"favorites", + }, {skip:["terminal", "repository"]}], + ["Anilist plugin (watching/reading section)", { + plugin_anilist:true, + plugin_anilist_sections:"watching, reading", + }, {skip:["terminal", "repository"]}], + ["Anilist plugin (characters section)", { + plugin_anilist:true, + plugin_anilist_sections:"characters", + }, {skip:["terminal", "repository"]}], + ["Anilist plugin (additional options)", { + plugin_anilist:true, + plugin_anilist_limit:0, + plugin_anilist_shuffle:false, + plugin_anilist_user:"anilist", + }, {skip:["terminal", "repository"]}], + ["Anilist plugin (complete)", { + plugin_anilist:true, + plugin_anilist_medias:"manga, anime", + plugin_anilist_sections:"favorites, watching, reading, characters", + plugin_anilist_limit:0, + plugin_anilist_shuffle:false, + }, {skip:["terminal", "repository"]}], ] //Tests run