diff --git a/.vscode/settings.json b/.vscode/settings.json index 1474f5d..020e13a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -25,5 +25,19 @@ "workbench.statusBar.feedback.visible": false, "xo.format.enable": true, "xo.enable": true, - "gitlens.views.repositories.files.layout": "list" + "gitlens.views.repositories.files.layout": "list", + "cSpell.words": [ + "atcoder", + "betalib", + "brainfuck", + "caml", + "clar", + "commonlib", + "drafear", + "dropdown", + "glyphicon", + "o", + "oninstall", + "unlambda" + ] } diff --git a/package.json b/package.json index 1571a13..123a633 100644 --- a/package.json +++ b/package.json @@ -3,13 +3,13 @@ "description": "Comfort your atcoder life. For more detail, visit https://github.com/drafear/comfortable-atcoder", "scripts": { "build": "run-s build:*", - "build:xo": "xo src/**/*.ts", "build:init": "rm -rf dist", "build:cp": "cp -r src dist && rm -rf dist/**/*.less", + "build:css": "less-watch-compiler --run-once src dist", "build:ts": "parcel build --target node src/**/*.ts", "start": "npm run watch", "watch": "run-p watch:*", - "watch:less": "less-watch-compiler src src", + "watch:less": "less-watch-compiler src dist", "watch:build": "parcel --target node src/**/*.ts", "watch:test": "jest --watch", "package": "npm run build && zip -rq Release.zip dist" @@ -101,6 +101,7 @@ } ], "typescript/explicit-function-return-type": 0, + "no-useless-constructor": 0, "no-alert": 0, "no-negated-condition": 0, "capitalized-comments": 0, diff --git a/src/content/add-tweet-button.ts b/src/content/add-tweet-button.ts new file mode 100644 index 0000000..a852576 --- /dev/null +++ b/src/content/add-tweet-button.ts @@ -0,0 +1,80 @@ +import * as Commonlib from './all'; +import * as Betalib from './betalib'; + +Commonlib.runIfEnableAndLoad('add-tweet-button', async () => { + const isMyPage = $('#user-nav-tabs .glyphicon-cog').length >= 1; + if (!isMyPage) { + return; + } + + const isDetailPage = /^\/users\/[^/]+\/history\/?$/.test(location.pathname); + const userId = (location.pathname.match(/^\/users\/([^/]+)/) as string[])[1]; + + async function getTable(): Promise> { + // if (isDetailPage) { + // return $('#history'); + // } + // else { + const html = await (await fetch(`/users/${userId}/history`)).text(); + return $(html).find('#history'); + // } + } + + function getLatestContestResult(contestResults: Betalib.ContestResult[]): Betalib.ContestResult | null { + if (contestResults.length === 0) { + return null; + } + let res = contestResults[0]; + for (const result of contestResults) { + if (result.date > res.date) { + res = result; + } + } + return res; + } + + function makeTweetText(contestResult: Betalib.ContestResult, isHighest = false): string { + const r = contestResult; + if (r instanceof Betalib.RatedContestResult) { + const highestStr = isHighest ? ', Highest!!' : ''; + return `I took ${r.getRankStr()} place in ${r.contestName}\n\nRating: ${r.newRating - r.diff} -> ${r.newRating} (${r.getDiffStr()}${highestStr})\nPerformance: ${r.performance}\n#${r.contestId}`; + } + else { + return `I took ${r.getRankStr()} place in ${r.contestName}\n#${r.contestId}`; + } + } + + function isHighest(targetContestResult: Betalib.ContestResult, contestResults: Betalib.ContestResult[]) { + if (!(targetContestResult instanceof Betalib.RatedContestResult)) { + return false; + } + for (const result of contestResults) { + if (result.contestId === targetContestResult.contestId) { + continue; + } + if (!(result instanceof Betalib.RatedContestResult)) { + continue; + } + if (result.newRating >= targetContestResult.newRating) { + return false; + } + } + return true; + } + + const $table = await getTable(); + const contestResults = Betalib.GetContestResultsFromTable($table); + const latestContestResult = getLatestContestResult(contestResults); + // 一度も参加したことがない + if (latestContestResult === null) { + return; + } + const tweetContent = makeTweetText(latestContestResult, isHighest(latestContestResult, contestResults)); + const text = navigator.language === 'ja' ? '最新のコンテスト結果をツイート' : 'Tweet the result of the latest contest'; + const $tweetButton = $('').addClass('tweet').text(text) + .prop('href', `https://twitter.com/share?url=''&text=${encodeURIComponent(tweetContent)}`) + .prop('target', '_blank'); + if (isDetailPage) { + $('#history_wrapper > div.row:first-child > .col-sm-6:first-child').eq(0).prepend($tweetButton); + } +}); diff --git a/src/content/betalib.ts b/src/content/betalib.ts index dcf69cc..d9160fd 100644 --- a/src/content/betalib.ts +++ b/src/content/betalib.ts @@ -122,6 +122,45 @@ export class JudgeStatus { } } +export abstract class ContestResult { + constructor(public readonly date: Date, public readonly contestName: string, public readonly contestId: string, public readonly rank: number, public readonly diff: number) { } + abstract isRated(): boolean; + getRankStr(): string { + switch (this.rank % 10) { + case 1: + return `${this.rank}st`; + case 2: + return `${this.rank}nd`; + case 3: + return `${this.rank}rd`; + default: + return `${this.rank}th`; + } + } + getDiffStr(): string { + if (this.diff > 0) { + return `+${this.diff}`; + } + if (this.diff < 0) { + return this.diff.toString(); + } + return '±0'; + } +} +export class UnRatedContestResult extends ContestResult { + isRated() { + return false; + } +} +export class RatedContestResult extends ContestResult { + constructor(date: Date, contestName: string, contestId: string, rank: number, diff: number, public readonly performance: number, public readonly newRating: number) { + super(date, contestName, contestId, rank, diff); + } + isRated() { + return true; + } +} + export function parseJudgeStatus(text: string): JudgeStatus { const reg = /[ \s]/g; // WJ @@ -230,3 +269,32 @@ export async function getProblems(): Promise { }); return res; } + +export function GetContestResultsFromTable($table: JQuery): ContestResult[] { + const res: ContestResult[] = []; + const $th = $('thead > tr > th', $table); + const indexes = getIndexes($th, { + date: ['Date', '日付'], + contest: ['Contest', 'コンテスト'], + rank: ['Rank', '順位'], + performance: ['Performance', 'パフォーマンス'], + newRating: ['NewRating', '新Rating'], + diff: ['Diff', '差分'], + }); + $('tbody > tr', $table).each((idx, tr) => { + const $tds = $(tr).children('td'); + const date = new Date($tds.eq(indexes.date).text()); + const $contest = $tds.eq(indexes.contest).children('a').eq(0); + const contestName = $contest.text(); + const contestId = (($contest.prop('href') as string).match(/\/contests\/([^\/]+)\/?$/) as string[])[1]; + const rank = Number($tds.eq(indexes.rank).text()); + const performanceStr = $tds.eq(indexes.performance).text(); + const newRatingStr = $tds.eq(indexes.newRating).text(); + const diff = Number($tds.eq(indexes.diff).text().replace(/[^0-9]/g, '')); + const isRated = performanceStr !== '-'; + res[idx] = + isRated ? new RatedContestResult(date, contestName, contestId, rank, diff, Number(performanceStr), Number(newRatingStr)) + : new UnRatedContestResult(date, contestName, contestId, rank, diff); + }); + return res; +} diff --git a/src/css/add-twitter-button.css b/src/css/add-twitter-button.css new file mode 100644 index 0000000..4171fb1 --- /dev/null +++ b/src/css/add-twitter-button.css @@ -0,0 +1,9 @@ +.row { + display: flex; + justify-content: space-between; +} +.tweet { + padding: 3px; + background-color: hsl(220, 70%, 60%); + color: white; +} diff --git a/src/css/add-twitter-button.less b/src/css/add-twitter-button.less new file mode 100644 index 0000000..3bf1851 --- /dev/null +++ b/src/css/add-twitter-button.less @@ -0,0 +1,25 @@ +#history_wrapper > .row { + display: flex; + justify-content: space-between; + align-items: center; +} + +.tweet { + display: inline-block; + padding: 5px; + background-color: hsl(220, 70%, 60%); + border-radius: 3px; + color: white; + text-decoration: none; + transition: all .2s; + &:hover, + &:active, + &:focus, + &:visited { + color: white; + text-decoration: none; + } + &:hover { + background-color: hsl(220, 70%, 50%); + } +} diff --git a/src/manifest.json b/src/manifest.json index ec0407c..35bab6e 100755 --- a/src/manifest.json +++ b/src/manifest.json @@ -1,35 +1,83 @@ { "name": "Comfortable Atcoder", - "version": "1.5.3", + "version": "1.5.4", "manifest_version": 2, - "description": "Comfort your atcoder life. For more detail, visit https://github.com/drafear/comfortable-atcoder", - "author": "drafear", + "description": "Comfort your atcoder life. For more detail, visit https://github.com/drafear/comfortable-atcoder", + "author": "drafear", "content_scripts": [ { - "matches": ["*://atcoder.jp/contests/*", "*://*.contest.atcoder.jp/*"], - "exclude_matches": ["*://*.contest.atcoder.jp/users/*"], - "js": ["lib/jquery.min.js", "lib/jquery.cookie.js", "content/all.js"], - "css": ["css/all.css"], + "matches": [ + "*://atcoder.jp/contests/*", + "*://atcoder.jp/users/*", + "*://*.contest.atcoder.jp/*" + ], + "exclude_matches": [ + "*://*.contest.atcoder.jp/users/*" + ], + "js": [ + "lib/jquery.min.js", + "lib/jquery.cookie.js", + "content/all.js" + ], + "css": [ + "css/all.css" + ], "run_at": "document_start" }, { - "matches": ["*://atcoder.jp/contests/*"], - "js": ["content/betalib.js", "content/dropdown-modify.js", "content/clar-notify.js"], - "css": ["css/dropdown-modify.css"], + "matches": [ + "*://atcoder.jp/contests/*" + ], + "js": [ + "content/betalib.js", + "content/dropdown-modify.js", + "content/clar-notify.js" + ], + "css": [ + "css/dropdown-modify.css" + ], "run_at": "document_start" }, { - "matches": ["*://atcoder.jp/contests/*/submissions/me"], - "js": ["content/result-notify.js"], + "matches": [ + "*://atcoder.jp/contests/*/submissions/me" + ], + "js": [ + "content/result-notify.js" + ], "run_at": "document_start" }, { - "matches": ["*://atcoder.jp/contests/*/submit*", "*://atcoder.jp/contests/*/tasks/*"], - "js": ["content/submission-warning.js"], + "matches": [ + "*://atcoder.jp/contests/*/submit*", + "*://atcoder.jp/contests/*/tasks/*" + ], + "js": [ + "content/submission-warning.js" + ], + "run_at": "document_start" + }, + { + "matches": [ + "*://atcoder.jp/users/*/history*" + ], + "js": [ + "content/add-tweet-button.js" + ], + "css": [ + "css/add-twitter-button.css" + ], + "run_at": "document_start" + }, + { + "matches": ["*://*.contest.atcoder.jp/*"], + "js": ["content/link-to-beta.js"], "run_at": "document_start" } ], - "web_accessible_resources": ["image/beta.png"], + "web_accessible_resources": [ + "image/beta.png" + ], "background": { "scripts": [ "lib/jquery.min.js", @@ -42,7 +90,11 @@ "persistent": false }, "options_page": "options-page/options.html", - "permissions": ["notifications", "storage", ""], + "permissions": [ + "notifications", + "storage", + "" + ], "icons": { "16": "image/icon.png", "48": "image/icon.png", diff --git a/src/options-page/options.js b/src/options-page/options.ts similarity index 63% rename from src/options-page/options.js rename to src/options-page/options.ts index 337e8ee..a5f0f09 100644 --- a/src/options-page/options.js +++ b/src/options-page/options.ts @@ -1,49 +1,47 @@ class Language { - constructor(id, name) { - this.id = id; - this.name = name; - } + constructor(public readonly id: string, public readonly name: string) { } } class Switch { - constructor(onText, offText, storageKey, defaultValue = true) { - this.onText = onText; - this.offText = offText; - this.storageKey = storageKey; + public readonly defaultValue: boolean; + + constructor( + public readonly onText: string, + public readonly offText: string, + public readonly storageKey: string, + defaultValue = true, + ) { this.defaultValue = defaultValue; } - readValue() { - return new Promise( - resolve => chrome.storage.sync.get( - [this.storageKey], - result => { - if (this.storageKey in result) { - resolve(Boolean(result[this.storageKey])); - } else { - chrome.storage.sync.set({[this.storageKey]: this.defaultValue}); - resolve(this.defaultValue); - } + readValue(): Promise { + return new Promise(resolve => + chrome.storage.sync.get([this.storageKey], result => { + if (this.storageKey in result) { + resolve(Boolean(result[this.storageKey])); + } else { + chrome.storage.sync.set({ [this.storageKey]: this.defaultValue }); + resolve(this.defaultValue); } - ) + }), ); } - cbChandeListener(e) { - const value = e.currentTarget.checked; - const data = {}; + cbChangeListener(e: Event) { + const value = (e.currentTarget as HTMLInputElement).checked; + const data: { [key: string]: boolean } = {}; data[this.storageKey] = value; chrome.storage.sync.set(data); } async toElem() { - const value = await this.readValue(); + const value: boolean = await this.readValue(); const label = document.createElement('label'); label.classList.add('switch'); const cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = value; - cb.addEventListener('change', this.cbChandeListener.bind(this)); + cb.addEventListener('change', this.cbChangeListener.bind(this)); label.appendChild(cb); const div = document.createElement('div'); div.dataset.on = this.onText; @@ -52,11 +50,8 @@ class Switch { return label; } } -class Option { - constructor(name, component) { - this.name = name; - this.component = component; - } +class Choice { + constructor(public readonly name: string, public readonly component: Switch) { } async toElem() { const li = document.createElement('li'); @@ -71,10 +66,7 @@ class Option { } class Group { - constructor(name, options) { - this.name = name; - this.options = options; - } + constructor(public readonly name: string, public readonly choices: Choice[]) { } async toElem() { const div = document.createElement('div'); @@ -83,7 +75,7 @@ class Group { div.appendChild(header); const ul = document.createElement('ul'); ul.classList.add('settings-list'); - for (const option of this.options) { + for (const option of this.choices) { ul.appendChild(await option.toElem()); } div.appendChild(ul); @@ -91,21 +83,16 @@ class Group { } } -function makeWarnOptions(langs) { +const makeWarnChoices = (langs: Language[]): Choice[] => { // Bash, Text const defaults = new Set(['3001', '3027']); - const options = []; + const choices: Choice[] = []; for (const lang of langs) { const defaultValue = Boolean(defaults.has(lang.id)); - options.push( - new Option( - lang.name, - new Switch('on', 'off', `warn-${lang.id}`, defaultValue) - ) - ); + choices.push(new Choice(lang.name, new Switch('on', 'off', `warn-${lang.id}`, defaultValue))); } - return options; -} + return choices; +}; const languages = [ new Language('3003', 'C++14 (GCC)'), @@ -167,48 +154,20 @@ const languages = [ ]; const groups = [ - new Group( - 'Non-beta', - [ - new Option( - 'Beta Tab', - new Switch('enable', 'disable', 'beta-tab'), - ), - ], - ), - new Group( - 'Notification', - [ - new Option( - 'Judge Result', - new Switch('on', 'off', 'notify-judge-result'), - ), - new Option( - 'Clarification', - new Switch('on', 'off', 'notify-clarification'), - ), - ], - ), - new Group( - 'Dropdown', - [ - new Option( - 'Hover', - new Switch('hover', 'click', 'dropdown-hover'), - ), - new Option( - 'Problem Tab', - new Switch('enable', 'disable', 'dropdown-problem'), - ), - ], - ), - new Group( - 'Warning on Submission', - [ - new Option('Enable', new Switch('enable', 'disable', 'submission-warning')), - ...makeWarnOptions(languages), - ], - ), + new Group('Non-beta', [new Choice('Beta Tab', new Switch('enable', 'disable', 'beta-tab'))]), + new Group('Notification', [ + new Choice('Judge Result', new Switch('on', 'off', 'notify-judge-result')), + new Choice('Clarification', new Switch('on', 'off', 'notify-clarification')), + ]), + new Group('Dropdown', [ + new Choice('Hover', new Switch('hover', 'click', 'dropdown-hover')), + new Choice('Problem Tab', new Switch('enable', 'disable', 'dropdown-problem')), + ]), + new Group('Tweet Button', [new Choice('Enable', new Switch('enable', 'disable', 'add-tweet-button'))]), + new Group('Warning on Submission', [ + new Choice('Enable', new Switch('enable', 'disable', 'submission-warning')), + ...makeWarnChoices(languages), + ]), ]; document.addEventListener('DOMContentLoaded', async () => {