diff --git a/.github/readme/imgs/plugin_stackoverflow_user_id.png b/.github/readme/imgs/plugin_stackoverflow_user_id.png new file mode 100644 index 00000000..61da7248 Binary files /dev/null and b/.github/readme/imgs/plugin_stackoverflow_user_id.png differ diff --git a/source/app/metrics/utils.mjs b/source/app/metrics/utils.mjs index cec8b551..11550fed 100644 --- a/source/app/metrics/utils.mjs +++ b/source/app/metrics/utils.mjs @@ -91,8 +91,8 @@ return string .replace(/</g, u["<"] ? "<" : "<") .replace(/>/g, u[">"] ? ">" : ">") - .replace(/"/g, u['"'] ? '"' : '"') - .replace(/'/g, u["'"] ? "'" : "'") + .replace(/"/g, u['"'] ? '"' : """) + .replace(/&(?:apos|#39);/g, u["'"] ? "'" : "'") .replace(/&/g, u["&"] ? "&" : "&") } diff --git a/source/app/mocks/api/axios/get/stackoverflow.mjs b/source/app/mocks/api/axios/get/stackoverflow.mjs new file mode 100644 index 00000000..9aa28fed --- /dev/null +++ b/source/app/mocks/api/axios/get/stackoverflow.mjs @@ -0,0 +1,99 @@ +/**Mocked data */ + export default function({faker, url, options, login = faker.internet.userName()}) { + //Stackoverflow api + if (/^https:..api.stackexchange.com.2.2.*$/.test(url)) { + //Extract user id + const user_id = url.match(/[/]users[/](?\d+)/)?.groups?.id ?? NaN + const pagesize = Number(url.match(/pagesize=(?\d+)/)?.groups?.pagesize) || 30 + //User account + if (/users[/]\d+[/][?]site=stackoverflow$/.test(url)) { + console.debug(`metrics/compute/mocks > mocking stackoverflow api result > ${url}`) + return ({ + status:200, + data:{ + items:[ + { + badge_counts:{bronze:faker.random.number(500), silver:faker.random.number(300), gold:faker.random.number(100)}, + accept_rate:faker.random.number(100), + answer_count:faker.random.number(1000), + question_count:faker.random.number(1000), + view_count:faker.random.number(10000), + creation_date:faker.date.past(), + display_name:faker.internet.userName(), + user_id, + reputation:faker.random.number(100000), + }, + ], + has_more:false, + quota_max:300, + quota_remaining:faker.random.number(300), + }, + }) + } + //Total metrics + if (/[?]site=stackoverflow&filter=total$/.test(url)) { + console.debug(`metrics/compute/mocks > mocking stackoverflow api result > ${url}`) + return ({ + status:200, + data:{ + total:faker.random.number(10000), + }, + }) + } + //Questions + if ((/questions[?]site=stackoverflow/.test(url))||(/questions[/][\d;]+[?]site=stackoverflow/.test(url))) { + console.debug(`metrics/compute/mocks > mocking stackoverflow api result > ${url}`) + return ({ + status:200, + data:{ + items:new Array(pagesize).fill(null).map(_ => ({ + tags:new Array(5).fill(null).map(_ => faker.lorem.slug()), + owner:{display_name:faker.internet.userName()}, + is_answered:faker.random.boolean(), + view_count:faker.random.number(10000), + accepted_answer_id:faker.random.number(1000000), + answer_count:faker.random.number(100), + score:faker.random.number(1000), + creation_date:faker.time.recent(), + down_vote_count:faker.random.number(1000), + up_vote_count:faker.random.number(1000), + comment_count:faker.random.number(1000), + favorite_count:faker.random.number(1000), + title:faker.lorem.sentence(), + body_markdown:faker.lorem.paragraphs(), + link:faker.internet.url(), + question_id:faker.random.number(1000000), + })), + has_more:false, + quota_max:300, + quota_remaining:faker.random.number(300), + }, + }) + } + //Answers + if ((/answers[?]site=stackoverflow/.test(url))||(/answers[/][\d;]+[?]site=stackoverflow/.test(url))) { + console.debug(`metrics/compute/mocks > mocking stackoverflow api result > ${url}`) + return ({ + status:200, + data:{ + items:new Array(pagesize).fill(null).map(_ => ({ + owner:{display_name:faker.internet.userName()}, + link:faker.internet.url(), + is_accepted:faker.random.boolean(), + score:faker.random.number(1000), + down_vote_count:faker.random.number(1000), + up_vote_count:faker.random.number(1000), + comment_count:faker.random.number(1000), + creation_date:faker.time.recent(), + question_id:faker.random.number(1000000), + body_markdown:faker.lorem.paragraphs(), + answer_id:faker.random.number(1000000), + })), + has_more:false, + quota_max:300, + quota_remaining:faker.random.number(300), + }, + }) + } + } + } diff --git a/source/app/web/statics/app.placeholder.js b/source/app/web/statics/app.placeholder.js index 6f1d00de..5ee8733b 100644 --- a/source/app/web/statics/app.placeholder.js +++ b/source/app/web/statics/app.placeholder.js @@ -651,6 +651,65 @@ duration:options["isocalendar.duration"] } }) : null), + //Stackoverflow + ...(set.plugins.enabled.stackoverflow ? ({ + stackoverflow:{ + sections:options["stackoverflow.sections"].split(",").map(x => x.trim()).filter(x => x), + lines:options["stackoverflow.lines"], + user:{ + reputation:faker.random.number(100000), + badges:faker.random.number(1000), + questions:faker.random.number(1000), + answers:faker.random.number(1000), + comments:faker.random.number(1000), + views:faker.random.number(1000), + }, + "answers-top":new Array(options["stackoverflow.limit"]).fill(null).map(_ => ({ + type:"answer", + body:faker.lorem.paragraphs(), + score:faker.random.number(1000), + upvotes:faker.random.number(1000), + downvotes:faker.random.number(1000), + accepted:faker.random.boolean(), + comments:faker.random.number(1000), + author:set.user, + created:"01/01/1970", + link:null, + id:faker.random.number(100000), + question_id:faker.random.number(100000), + question:{ + title:faker.lorem.sentence(), + tags:[faker.lorem.slug(), faker.lorem.slug()], + } + })), + get ["answers-recent"]() { + return this["answers-top"] + }, + "questions-top":new Array(options["stackoverflow.limit"]).fill(null).map(_ => ({ + type:"question", + title:faker.lorem.sentence(), + body:faker.lorem.paragraphs(), + score:faker.random.number(1000), + upvotes:faker.random.number(1000), + downvotes:faker.random.number(1000), + favorites:faker.random.number(1000), + tags:[faker.lorem.slug(), faker.lorem.slug()], + answered:faker.random.boolean(), + answers:faker.random.number(1000), + comments:faker.random.number(1000), + views:faker.random.number(1000), + author:set.user, + created:"01/01/1970", + link:null, + id:faker.random.number(100000), + accepted_answer_id:faker.random.number(100000), + answer:null, + })), + get ["questions-recent"]() { + return this["questions-top"] + }, + } + }) : null), }, } //Formatters diff --git a/source/plugins/stackoverflow/README.md b/source/plugins/stackoverflow/README.md new file mode 100644 index 00000000..4614bcba --- /dev/null +++ b/source/plugins/stackoverflow/README.md @@ -0,0 +1,36 @@ +### 🗨️ Stackoverflow plugin + +The *stackoverflow* plugin lets you display your metrics, questions and answer from [stackoverflow](https://stackoverflow.com/). + + + +
+ + +
+ +
+💬 Get your user id + +Go to [stackoverflow.com](https://stackoverflow.com/) and click on your account profile. + +Your user id will be in both url and search bar. + +![User id](/.github/readme/imgs/plugin_stackoverflow_user_id.png) + +
+ +#### ℹ️ Examples workflows + +[➡️ Available options for this plugin](metadata.yml) + +```yaml +- uses: lowlighter/metrics@latest + with: + # ... other options + plugin_stackoverflow: yes + plugin_stackoverflow_user: 8332505 # Stackoverflow user id (required) + plugin_stackoverflow_sections: answers-top, questions-recent # Display top answers and recent questions + plugin_stackoverflow_limit: 2 # Display 2 entries per section + plugin_stackoverflow_lines: 4 # Display 4 lines per entry +``` \ No newline at end of file diff --git a/source/plugins/stackoverflow/index.mjs b/source/plugins/stackoverflow/index.mjs new file mode 100644 index 00000000..16483946 --- /dev/null +++ b/source/plugins/stackoverflow/index.mjs @@ -0,0 +1,97 @@ +//Setup + export default async function({login, q, imports, data, account}, {enabled = false} = {}) { + //Plugin execution + try { + //Check if plugin is enabled and requirements are met + if ((!enabled)||(!q.stackoverflow)) + return null + + //Load inputs + let {sections, user, limit, lines} = imports.metadata.plugins.stackoverflow.inputs({data, account, q}) + if (!user) + throw {error:{message:"You must provide a stackoverflow user id"}} + + //Initialization + //See https://api.stackexchange.com/docs + const api = {base:"https://api.stackexchange.com/2.2", user:`https://api.stackexchange.com/2.2/users/${user}`} + const filters = {user:"!0Z-LvgkLYnTCu1858)*D0lcx2", answer:"!7goY5TLWwCz.BaGpe)tv5C6Bks2q8siMH6", question:"!)EhwvzgX*hrClxjLzqxiZHHbTPRE5Pb3B9vvRaqCx5-ZY.vPr"} + const result = {sections, lines} + + //Stackoverflow user metrics + { + //Account metrics + console.debug(`metrics/compute/${login}/plugins > stackoverflow > querying api for user ${user}`) + const {data:{items:[{reputation, badge_counts:{bronze, silver, gold}, answer_count:answers, question_count:questions, view_count:views}]}} = await imports.axios.get(`${api.user}?site=stackoverflow&filter=${filters.user}`) + const {data:{total:comments}} = await imports.axios.get(`${api.user}/comments?site=stackoverflow&filter=total`) + //Save result + result.user = {reputation, badges:bronze+silver+gold, questions, answers, comments, views} + } + + //Answers + for (const {key, sort} of [{key:"answers-recent", sort:"sort=activity&order=desc"}, {key:"answers-top", sort:"sort=votes&order=desc"}].filter(({key}) => sections.includes(key))) { + //Load and format answers + console.debug(`metrics/compute/${login}/plugins > stackoverflow > querying api for ${key}`) + const {data:{items}} = await imports.axios.get(`${api.user}/answers?site=stackoverflow&pagesize=${limit}&filter=${filters.answer}&${sort}`) + result[key] = items.map(item => format.answer(item, {imports, data})) + console.debug(`metrics/compute/${login}/plugins > stackoverflow > loaded ${result[key].length} items`) + //Load related questions + const ids = result[key].map(({question_id}) => question_id).filter(id => id) + if (ids) { + console.debug(`metrics/compute/${login}/plugins > stackoverflow > loading ${ids.length} related items`) + const {data:{items}} = await imports.axios.get(`${api.base}/questions/${ids.join(";")}?site=stackoverflow&filter=${filters.question}`) + items.map(item => format.question(item, {imports, data})) + } + } + + //Questions + for (const {key, sort} of [{key:"questions-recent", sort:"sort=activity&order=desc"}, {key:"questions-top", sort:"sort=votes&order=desc"}].filter(({key}) => sections.includes(key))) { + //Load and format questions + console.debug(`metrics/compute/${login}/plugins > stackoverflow > querying api for ${key}`) + const {data:{items}} = await imports.axios.get(`${api.user}/questions?site=stackoverflow&pagesize=${limit}&filter=${filters.question}&${sort}`) + result[key] = items.map(item => format.question(item, {imports, data})) + console.debug(`metrics/compute/${login}/plugins > stackoverflow > loaded ${result[key].length} items`) + //Load related answers + const ids = result[key].map(({accepted_answer_id}) => accepted_answer_id).filter(id => id) + if (ids) { + console.debug(`metrics/compute/${login}/plugins > stackoverflow > loading ${ids.length} related items`) + const {data:{items}} = await imports.axios.get(`${api.base}/answers/${ids.join(";")}?site=stackoverflow&filter=${filters.answer}`) + items.map(item => format.answer(item, {imports, data})) + } + } + + //Results + return result + } + //Handle errors + catch (error) { + if (error.error?.message) + throw error + throw {error:{message:"An error occured", instance:error}} + } + } + +//Formatters + const format = { + /**Cached */ + cached:new Map(), + /**Format answers */ + answer({body_markdown:body, score, up_vote_count:upvotes, down_vote_count:downvotes, is_accepted:accepted, comment_count:comments = 0, creation_date, owner:{display_name:author}, link, answer_id:id, question_id}, {imports, data}) { + const formatted = {type:"answer", body:imports.htmlunescape(body), score, upvotes, downvotes, accepted, comments, author, created:imports.date(creation_date*1000, {dateStyle:"short", timeZone:data.config.timezone?.name}), link, id, question_id, + get question() { + return format.cached.get(`q${this.question_id}`) ?? null + }, + } + this.cached.set(`a${id}`, formatted) + return formatted + }, + /**Format questions */ + question({title, body_markdown:body, score, up_vote_count:upvotes, down_vote_count:downvotes, favorite_count:favorites, tags, is_answered:answered, answer_count:answers, comment_count:comments, view_count:views, creation_date, owner:{display_name:author}, link, question_id:id, accepted_answer_id = null}, {imports, data}) { + const formatted = {type:"question", title:imports.htmlunescape(title), body:imports.htmlunescape(body), score, upvotes, downvotes, favorites, tags, answered, answers, comments, views, author, created:imports.date(creation_date*1000, {dateStyle:"short", timeZone:data.config.timezone?.name}), link, id, accepted_answer_id, + get answer() { + return format.cached.get(`a${this.accepted_answer_id}`) ?? null + }, + } + this.cached.set(`q${id}`, formatted) + return formatted + }, + } diff --git a/source/plugins/stackoverflow/metadata.yml b/source/plugins/stackoverflow/metadata.yml new file mode 100644 index 00000000..04d35896 --- /dev/null +++ b/source/plugins/stackoverflow/metadata.yml @@ -0,0 +1,48 @@ +name: "🗨️ Stackoverflow plugin" +cost: N/A +categorie: social +supports: + - user + - organization +inputs: + + # Enable or disable plugin + plugin_stackoverflow: + description: Stackoverflow metrics + type: boolean + default: no + + # Stackoverflow user id + # To obtain it, extract the identifier on your account page url + plugin_stackoverflow_user: + description: Stackoverflow user id + type: number + default: 0 + + # Sections to display + plugin_stackoverflow_sections: + description: Sections to display + type: array + format: comma-separated + default: answers-top, questions-recent + values: + - answers-top # Display top answers + - answers-recent # Display recent answers + - questions-top # Display top questions + - questions-recent # Display recent questions + + # Number of entries to display per section + plugin_stackoverflow_limit: + description: Maximum number of entries to display per section + type: number + default: 2 + min: 1 + max: 30 + + # Number of lines to display per question or answer + # Set to 0 to disable limitations + plugin_stackoverflow_lines: + description: Maximum number of lines to display per question or answer + type: number + default: 4 + min: 0 \ No newline at end of file diff --git a/source/plugins/stackoverflow/tests.yml b/source/plugins/stackoverflow/tests.yml new file mode 100644 index 00000000..4688d3d0 --- /dev/null +++ b/source/plugins/stackoverflow/tests.yml @@ -0,0 +1,16 @@ +- name: Stackoverflow plugin (default) + uses: lowlighter/metrics@latest + with: + token: MOCKED_TOKEN + plugin_stackoverflow: yes + plugin_stackoverflow_user: 1 + +- name: Stackoverflow plugin (complete) + uses: lowlighter/metrics@latest + with: + token: MOCKED_TOKEN + plugin_stackoverflow: yes + plugin_stackoverflow_user: 1 + plugin_stackoverflow_sections: answers-top, answers-recent, questions-top, questions-recent + plugin_stackoverflow_limit: 2 + plugin_stackoverflow_lines: 4 \ No newline at end of file diff --git a/source/templates/classic/partials/_.json b/source/templates/classic/partials/_.json index 2c4d8059..2bbc2b72 100644 --- a/source/templates/classic/partials/_.json +++ b/source/templates/classic/partials/_.json @@ -21,5 +21,6 @@ "activity", "anilist", "wakatime", - "skyline" + "skyline", + "stackoverflow" ] \ No newline at end of file diff --git a/source/templates/classic/partials/stackoverflow.ejs b/source/templates/classic/partials/stackoverflow.ejs new file mode 100644 index 00000000..a3fab2ef --- /dev/null +++ b/source/templates/classic/partials/stackoverflow.ejs @@ -0,0 +1,161 @@ +<% if (plugins.stackoverflow) { %> +
+

+ + Stackoverflow metrics +

+ <% if (plugins.stackoverflow.error) { %> +
+
+
+ + <%= plugins.stackoverflow.error.message %> +
+
+
+ <% } else { %> +
+
+
+ + <%= plugins.stackoverflow.user.reputation %> reputation point<%= s(plugins.stackoverflow.user.reputation) %> +
+
+ + <%= plugins.stackoverflow.user.questions %> question<%= s(plugins.stackoverflow.user.questions) %> +
+
+ + <%= plugins.stackoverflow.user.comments %> comment<%= s(plugins.stackoverflow.user.comments) %> +
+
+
+
+ + <%= plugins.stackoverflow.user.badges %> badge<%= s(plugins.stackoverflow.user.badges) %> +
+
+ + <%= plugins.stackoverflow.user.answers %> answer<%= s(plugins.stackoverflow.user.answers) %> +
+
+
+ <% if (plugins.stackoverflow.lines) { %> + + <% } %> + <% for (const section of plugins.stackoverflow.sections) { if (!plugins.stackoverflow[section]?.length) continue %> +
+
+
+

+ + <%= {"questions-recent":"Recent questions", "questions-top":"Top questions", "answers-recent":"Recent answers", "answers-top":"Top answers"}[section] %> +

+ <% for (const {type, ...entry} of plugins.stackoverflow[section]) { %> +
+ <% if (type === "question") { %> +
+ + <%= entry.title %> +
+
+
+ + <%= entry.tags.join(", ") %> +
+ <% if (entry.answered) { %> +
+ + Resolved +
+ <% } %> +
+
+ <%= entry.body %> +
+
+
+ + <%= f(entry.upvotes) %> +
+
+ + <%= f(entry.downvotes) %> +
+
+ + <%= f(entry.views) %> +
+
+ + <%= f(entry.answers) %> +
+
+ + <%= f(entry.comments) %> +
+
+ + <%= entry.created %> +
+
+ <% } else if (type === "answer") { %> +
+ + <%= entry.question?.title %> +
+
+
+ + <%= entry.question?.tags.join(", ") %> +
+ <% if (entry.question?.answered) { %> +
+ + Resolved +
+ <% } %> +
+
+ <%= entry.body %> +
+
+
+ + <%= f(entry.upvotes) %> +
+
+ + <%= f(entry.downvotes) %> +
+
+ + <%= f(entry.comments) %> +
+
+ + <%= entry.created %> +
+ <% if (entry.accepted) { %> +
+ + Accepted +
+ <% } %> +
+ <% } %> +
+ <% } %> +
+
+
+ <% } %> + <% } %> +
+<% } %> diff --git a/source/templates/classic/style.css b/source/templates/classic/style.css index fd1272b5..1e422e99 100644 --- a/source/templates/classic/style.css +++ b/source/templates/classic/style.css @@ -724,6 +724,49 @@ margin: 0 13px 2px; } +/* Stackoverflow */ + .stackoverflow { + margin-left: 38px; + } + .stackoverflow .entry { + margin: 4px 0 12px; + } + .stackoverflow .title { + color: #58a6ff; + white-space: normal; + align-items: flex-start; + } + .stackoverflow .body, .stackoverflow .infos { + color: #666666; + font-size: 13px; + margin-left: 32px; + } + .stackoverflow .infos { + display: flex; + align-items: center; + } + .stackoverflow .infos > div { + display: inline-flex; + align-items: center; + margin-right: 16px; + } + .stackoverflow .infos svg { + fill: currentColor; + height: 12px; + width: 12px; + margin: 0; + margin-right: 4px; + flex-shrink: 0; + } + .stackoverflow .body { + overflow: hidden; + text-overflow: ellipsis; + border-left: 3px solid #777777B2; + padding-left: 6px; + width: 400px; + } + + /* Fade animation */ .af { opacity: 0;