├── .gitignore ├── .prettierrc ├── art └── screenshot.png ├── src ├── userSettings.ts ├── types.ts ├── constants.ts ├── options.html ├── options.ts ├── index.ts └── dom.ts ├── tsconfig.json ├── manifest.json ├── LICENSE.md ├── .github └── workflows │ └── release.yml ├── package.json ├── README.md └── icon └── icon.svg /.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | node_modules/ 3 | dist/ 4 | .cache/ 5 | .vscode/ 6 | web-ext-artifacts/ -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "trailingComma": "es5" 5 | } -------------------------------------------------------------------------------- /art/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Shywim/github-repo-size/HEAD/art/screenshot.png -------------------------------------------------------------------------------- /src/userSettings.ts: -------------------------------------------------------------------------------- 1 | export const getStoredSetting = async (key: string) => { 2 | const storage = await browser.storage.local.get() 3 | return storage[key] 4 | } 5 | 6 | export const setSetting = async (key: string, value: unknown) => { 7 | await browser.storage.local.set({ 8 | [key]: value, 9 | }) 10 | } 11 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export type HumanSize = { 2 | size: string 3 | unit: string 4 | } 5 | 6 | export type RepoInfo = { 7 | owner: string 8 | name: string 9 | } 10 | 11 | export type PartialGitHubRepo = { 12 | data: { 13 | repository: { 14 | diskUsage: number 15 | } 16 | } 17 | } 18 | 19 | export type PartialGitHubRepoRestV3 = { 20 | size: number 21 | } 22 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "moduleResolution": "node", 4 | "strict": true, 5 | "module": "es2015", 6 | "lib": ["es2015", "es2016", "es2017", "dom", "dom.iterable"], 7 | "esModuleInterop": true, 8 | "target": "ES2015", 9 | "noUnusedLocals": true, 10 | "noImplicitReturns": true, 11 | "skipLibCheck": true, 12 | "noFallthroughCasesInSwitch": true 13 | } 14 | } -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const TOKEN_KEY = 'grs_gh_token' 2 | export const GITHUB_API = 'https://api.github.com/graphql' 3 | export const GITHUB_API_V3 = 'https://api.github.com/repos/' 4 | export const REPO_STATS_CLASS = 'numbers-summary' 5 | export const REPO_REFRESH_STATS_QUERY = 6 | '.repository-content .Box-header .Details ul' 7 | export const REPO_SIZE_ID = 'addon-repo-size' 8 | export const SIZE_KILO = 1024 9 | export const UNITS = [ 10 | 'B', 11 | 'KiB', 12 | 'MiB', 13 | 'GiB', 14 | 'TiB', 15 | 'PiB', 16 | 'EiB', 17 | 'ZiB', 18 | 'YiB', 19 | ] 20 | export const AUTO_ASK_KEY = 'grs_auto_ask' 21 | export const MODAL_ID = 'grs_token_modal' 22 | export const TOKEN_INPUT_ID = 'grs_token_input' 23 | 24 | export const ERROR_UNAUTHORIZED = '401' 25 | export const ERROR_UNKNOWN = '-1' 26 | -------------------------------------------------------------------------------- /src/options.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 12 | 13 | 14 | 15 |
16 |
17 |
18 | 19 | 20 |
21 | 22 |
23 | 24 |
25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "name": "Github Repo Size", 4 | "author": "Matthieu Harlé", 5 | "homepage_url": "https://github.com/Shywim/github-repo-size", 6 | "version": "1.7.0", 7 | "description": "Add repository size to their github homepage.", 8 | "options_ui": { 9 | "page": "options.html", 10 | "browser_style": true 11 | }, 12 | "browser_specific_settings": { 13 | "gecko": { 14 | "id": "github-repo-size@mattelrah.com" 15 | } 16 | }, 17 | "icons": { 18 | "32": "icon/icon.svg", 19 | "48": "icon/icon.svg", 20 | "64": "icon/icon.svg", 21 | "96": "icon/icon.svg", 22 | "128": "icon/icon.svg", 23 | "256": "icon/icon.svg" 24 | }, 25 | "content_scripts": [{ 26 | "matches": ["*://github.com/*"], 27 | "js": ["index.js"] 28 | }], 29 | "permissions": [ 30 | "*://api.github.com/repos/*", 31 | "storage" 32 | ] 33 | } -------------------------------------------------------------------------------- /src/options.ts: -------------------------------------------------------------------------------- 1 | import { TOKEN_KEY } from './constants' 2 | import { getStoredSetting, setSetting } from './userSettings' 3 | 4 | async function initForm() { 5 | const form = document.getElementById('ghs-options-form') as HTMLFormElement 6 | const existingTokenElmt = document.getElementById('existing-token') 7 | 8 | if (form == null || existingTokenElmt == null) { 9 | return 10 | } 11 | 12 | const token = await getStoredSetting(TOKEN_KEY) 13 | if (token) { 14 | const input = form.elements.namedItem('ghs-token') 15 | if (input == null) { 16 | return 17 | } 18 | ;(input as HTMLInputElement).placeholder = 19 | '****************************************' 20 | existingTokenElmt.style.display = 'block' 21 | } 22 | 23 | form.addEventListener('submit', (e) => { 24 | e.preventDefault() 25 | const input = form.elements.namedItem('ghs-token') 26 | if (input == null) { 27 | return 28 | } 29 | const token = (input as HTMLInputElement).value 30 | setSetting(TOKEN_KEY, token) 31 | }) 32 | } 33 | 34 | initForm() 35 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | 2 | The MIT License (MIT) 3 | 4 | Copyright (c) 2017 Matthieu Harlé 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: 6 | - created 7 | 8 | jobs: 9 | deploy: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | 14 | # cache dependencies 15 | - name: Get yarn cache directory 16 | id: yarn-cache 17 | run: echo "::set-output name=dir::$(yarn cache dir)" 18 | - name: "Cache: yarn" 19 | uses: actions/cache@v1.1.2 20 | with: 21 | path: ${{ steps.yarn-cache.outputs.dir }} 22 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 23 | restore-keys: | 24 | ${{ runner.os }}-yarn- 25 | - name: Use Node.js 12 26 | uses: actions/setup-node@v1 27 | with: 28 | node-version: "12" 29 | registry-url: "https://npm.pkg.github.com" 30 | 31 | - run: yarn install --pure-lockfile 32 | 33 | - run: yarn build 34 | 35 | - name: Upload Webext Artifact 36 | uses: actions/upload-release-asset@v1 37 | env: 38 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 39 | with: 40 | upload_url: ${{ github.event.release.upload_url }} 41 | asset_path: ./web-ext-artifacts/github_repo_size-${{ github.event.release.tag_name }}.zip 42 | asset_name: github-repo-size.zip 43 | asset_content_type: application/zip 44 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "github-repo-size", 3 | "version": "1.7.0", 4 | "description": "Firefox addon to add repository size to their Github homepage", 5 | "main": "src/index.js", 6 | "scripts": { 7 | "build": "npm-run-all build:sources build:package", 8 | "build:package": "web-ext build -s ./dist", 9 | "build:sources": "npm-run-all --parallel build:main build:options copyAssets copyIcons", 10 | "build:main": "parcel build src/index.ts --public-url ./", 11 | "build:options": "parcel build src/options.ts --public-url ./", 12 | "watch": "npm-run-all --parallel watch:main watch:options copyAssets copyIcons", 13 | "watch:main": "parcel watch src/index.ts --public-url ./", 14 | "watch:options": "parcel watch src/options.ts --public-url ./", 15 | "copyAssets": "cpy manifest.json LICENSE.md README.md package.json tsconfig.json yarn.lock src/options.html dist/", 16 | "copyIcons": "cpy --parents icon/icon.svg dist/", 17 | "webext:run": "web-ext run -s ./dist", 18 | "webext:lint": "web-ext lint -s ./dist" 19 | }, 20 | "author": "Matthieu Harlé", 21 | "license": "MIT", 22 | "repository": { 23 | "type": "git", 24 | "url": "https://github.com/Shywim/github-repo-size.git" 25 | }, 26 | "devDependencies": { 27 | "@types/firefox-webext-browser": "^82.0.1", 28 | "cpy-cli": "^3.1.1", 29 | "npm-run-all": "^4.1.5", 30 | "parcel-bundler": "^1.12.5", 31 | "prettier": "^2.4.1", 32 | "rimraf": "^3.0.2", 33 | "typescript": "^4.4.3", 34 | "web-ext": "^6.4.0" 35 | }, 36 | "dependencies": { 37 | "dom-loaded": "^3.0.0" 38 | }, 39 | "browserslist": [ 40 | "last 1 Firefox version" 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # A Firefox addon to display a Github repository's size 2 | 3 | [![Mozilla Add-on](https://img.shields.io/amo/v/github-repo-size.svg?style=flat-square)][amo] 4 | 5 | Add repository size to the Github's summary. 6 | 7 | ![Addon screenshot](art/screenshot.png) 8 | 9 | **⚠ This addon use the size as returned by the GitHub API and may be 10 | innacurate due to how GitHub stores git repositories! See [here][soq] and 11 | [here][ghb] for more informations.** 12 | 13 | ## Usage 14 | 15 | Download the addon from **[addons.mozilla.org][amo]** or, if you prefer, you 16 | can download this project as a userscript from the **[GitHub releases page][ghreleases]**. 17 | 18 | ### Private Repositories 19 | 20 | A **Personal Access Token** from an account with access to the private repository is 21 | required for this addon to work. You can create a Personal Access Token 22 | [here][ghsettings]. **Don't forget to check the `repo` scope.** 23 | 24 | You can also show the dialog to save your token by clicking on the element added 25 | by the addon on any repository, public or private, or you can visit the addon's 26 | settings page. 27 | 28 | ## Building 29 | 30 | - Use `yarn build` to build the Firefox webextension 31 | - Use `yarn watch` to have an automated build on changes and `yarn webext:run` to test the addon 32 | 33 | [amo]: https://addons.mozilla.org/firefox/addon/github-repo-size/ 34 | [ujs]: https://github.com/Shywim/github-repo-size/releases/latest/download/github-repo-size.user.js 35 | [ghreleases]: https://github.com/Shywim/github-repo-size/releases 36 | [soq]: https://stackoverflow.com/a/8679592/1424030 37 | [ghb]: https://git-blame.blogspot.fr/2012/08/bringing-bit-more-sanity-to-alternates.html 38 | [ghsettings]: https://github.com/settings/tokens 39 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import domLoaded from 'dom-loaded' 2 | import { 3 | AUTO_ASK_KEY, 4 | ERROR_UNAUTHORIZED, 5 | ERROR_UNKNOWN, 6 | GITHUB_API, 7 | GITHUB_API_V3, 8 | REPO_REFRESH_STATS_QUERY, 9 | REPO_SIZE_ID, 10 | SIZE_KILO, 11 | TOKEN_KEY, 12 | UNITS, 13 | } from './constants' 14 | import { 15 | askForToken, 16 | createErrorElement, 17 | createMissingTokenElement, 18 | createSizeElements, 19 | createSizeWrapperElement, 20 | } from './dom' 21 | import { 22 | HumanSize, 23 | PartialGitHubRepo, 24 | PartialGitHubRepoRestV3, 25 | RepoInfo, 26 | } from './types' 27 | import { getStoredSetting } from './userSettings' 28 | 29 | const checkIsPrivate = () => { 30 | return ( 31 | document.querySelector( 32 | '#repository-container-header .Label.Label--secondary' 33 | )?.innerHTML === 'Private' 34 | ) 35 | } 36 | 37 | const getRepoInfo = (url: string): RepoInfo | null => { 38 | const paths = url.split('/') 39 | 40 | if (paths.length < 2) { 41 | return null 42 | } 43 | 44 | return { owner: paths[0], name: paths[1] } 45 | } 46 | 47 | const getRepoDataAnon = (repoInfo: RepoInfo) => { 48 | const url = `${GITHUB_API_V3}${repoInfo.owner}/${repoInfo.name}` 49 | const request = new window.Request(url) 50 | 51 | return window 52 | .fetch(request) 53 | .then(checkResponse) 54 | .then((repoData) => repoData.size) 55 | } 56 | 57 | const getRepoData = (repoInfo: RepoInfo, token: string) => { 58 | const headers = new window.Headers() 59 | headers.set('Content-Type', 'application/json') 60 | if (token) headers.set('Authorization', `Bearer ${token}`) 61 | 62 | const request = new window.Request(GITHUB_API, { 63 | headers: headers, 64 | method: 'POST', 65 | body: JSON.stringify({ 66 | query: `query { repository(owner: "${repoInfo.owner}", name: "${repoInfo.name}") { diskUsage } }`, 67 | }), 68 | }) 69 | 70 | return window 71 | .fetch(request) 72 | .then(checkResponse) 73 | .then(getRepoSize) 74 | } 75 | 76 | const checkResponse = (resp: Response): Promise => { 77 | if (resp.status >= 200 && resp.status < 300) { 78 | return resp.json() as Promise 79 | } 80 | 81 | if (resp.status === 401) { 82 | throw new Error(ERROR_UNAUTHORIZED) 83 | } 84 | 85 | throw new Error(ERROR_UNKNOWN) 86 | } 87 | 88 | const getRepoSize = (data: PartialGitHubRepo) => { 89 | return data.data.repository.diskUsage 90 | } 91 | 92 | const getHumanFileSize = (size: number): HumanSize => { 93 | if (size === 0) { 94 | return { 95 | size: '0', 96 | unit: UNITS[0], 97 | } 98 | } 99 | 100 | const order = Math.floor(Math.log(size) / Math.log(SIZE_KILO)) 101 | return { 102 | size: (size / Math.pow(SIZE_KILO, order)).toFixed(2), 103 | unit: UNITS[order], 104 | } 105 | } 106 | 107 | const injectRepoSize = async () => { 108 | const repoInfo = getRepoInfo(window.location.pathname.substring(1)) 109 | 110 | if (repoInfo != null) { 111 | let statsElt 112 | const statsRow = document.querySelector(REPO_REFRESH_STATS_QUERY) 113 | if (statsRow == null) { 114 | // can't find any element to add our stats element, we stop here 115 | return 116 | } 117 | statsElt = statsRow 118 | 119 | const repoSizeElt = document.getElementById(REPO_SIZE_ID) 120 | if (repoSizeElt != null) { 121 | repoSizeElt.remove() 122 | } 123 | 124 | const token = await getStoredSetting(TOKEN_KEY) 125 | if ((token == null || token === '') && checkIsPrivate()) { 126 | const autoAsk = await getStoredSetting(AUTO_ASK_KEY) 127 | if (autoAsk == null || autoAsk === true) { 128 | askForToken() 129 | } 130 | 131 | createSizeWrapperElement(statsElt, createMissingTokenElement()) 132 | return 133 | } 134 | 135 | let repoSize 136 | try { 137 | if (token == null) { 138 | repoSize = await getRepoDataAnon(repoInfo) 139 | } else { 140 | repoSize = await getRepoData(repoInfo, token) 141 | } 142 | } catch (err: unknown) { 143 | if (err instanceof Error) { 144 | if (err.message === ERROR_UNAUTHORIZED) { 145 | createSizeWrapperElement( 146 | statsElt, 147 | createErrorElement('Unauthorized Token!') 148 | ) 149 | } else { 150 | createSizeWrapperElement( 151 | statsElt, 152 | createErrorElement('Unknown Error!') 153 | ) 154 | } 155 | } 156 | } 157 | 158 | if (repoSize == null) { 159 | return 160 | } 161 | 162 | const humanSize = getHumanFileSize(repoSize * 1024) 163 | const sizeElt = createSizeElements(humanSize) 164 | createSizeWrapperElement(statsElt, sizeElt) 165 | } 166 | } 167 | 168 | // Update to each ajax event 169 | document.addEventListener('pjax:end', injectRepoSize, false) 170 | 171 | // Update displayed size when a new token is saved 172 | browser.storage.onChanged.addListener((changes) => { 173 | console.log(changes) 174 | if (changes[TOKEN_KEY]) { 175 | injectRepoSize() 176 | } 177 | }) 178 | 179 | domLoaded.then(injectRepoSize) 180 | -------------------------------------------------------------------------------- /icon/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 20 | 22 | 25 | 29 | 33 | 34 | 37 | 41 | 45 | 46 | 56 | 66 | 67 | 89 | 91 | 92 | 94 | image/svg+xml 95 | 97 | 98 | 99 | 100 | 101 | 106 | 112 | 117 | 118 | 119 | -------------------------------------------------------------------------------- /src/dom.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AUTO_ASK_KEY, 3 | MODAL_ID, 4 | REPO_SIZE_ID, 5 | TOKEN_INPUT_ID, 6 | TOKEN_KEY, 7 | } from './constants' 8 | import { HumanSize } from './types' 9 | import { getStoredSetting, setSetting } from './userSettings' 10 | 11 | export const askForToken = async (e?: Event) => { 12 | if (e != null) { 13 | e.preventDefault() 14 | } 15 | 16 | document 17 | .getElementById(`${MODAL_ID}-size-stat-wrapper`) 18 | ?.setAttribute('open', '') 19 | } 20 | 21 | const saveToken = (e: Event) => { 22 | e.preventDefault() 23 | const token = ( 24 | (e.target as HTMLFormElement | null)?.elements.namedItem( 25 | TOKEN_INPUT_ID 26 | ) as HTMLInputElement | null 27 | )?.value 28 | setSetting(TOKEN_KEY, token) 29 | closeModal() 30 | } 31 | 32 | const closeModal = () => { 33 | document 34 | .getElementById(`${MODAL_ID}-size-stat-wrapper`) 35 | ?.removeAttribute('open') 36 | setSetting(AUTO_ASK_KEY, false) 37 | } 38 | 39 | export const createMissingTokenElement = () => { 40 | return createErrorElement('Missing token!') 41 | } 42 | 43 | export const createErrorElement = (message: string) => { 44 | const text = document.createTextNode(message) 45 | 46 | return text 47 | } 48 | 49 | export const createSizeElements = (repoSizeHuman: HumanSize) => { 50 | const sizeContainer = document.createElement('span') 51 | sizeContainer.className = 'd-none d-sm-inline' 52 | 53 | const size = document.createElement('strong') 54 | const sizeText = document.createTextNode(repoSizeHuman.size) 55 | size.appendChild(sizeText) 56 | 57 | const whiteSpace = document.createTextNode(' ') 58 | 59 | const unitContainer = document.createElement('span') 60 | unitContainer.className = 'color-text-secondary d-none d-lg-inline' 61 | unitContainer.ariaLabel = `Size of repository in ${repoSizeHuman.unit}` 62 | const unitText = document.createTextNode(repoSizeHuman.unit) 63 | unitContainer.appendChild(unitText) 64 | 65 | sizeContainer.appendChild(size) 66 | sizeContainer.appendChild(whiteSpace) 67 | sizeContainer.appendChild(unitContainer) 68 | 69 | return sizeContainer 70 | } 71 | 72 | export const createSizeWrapperElement = async ( 73 | parent: Element, 74 | children: Node 75 | ) => { 76 | const storedToken = await getStoredSetting(TOKEN_KEY) 77 | let tokenInfo = '', 78 | tokenPlaceholder = '' 79 | if (storedToken) { 80 | tokenPlaceholder = '****************************************' 81 | tokenInfo = ` 82 |
83 | A token is already saved, but is not displayed for obvious security reasons. 84 |
85 | ` 86 | } 87 | const li = document.createElement('li') 88 | li.id = REPO_SIZE_ID 89 | li.className = 'ml-0 ml-md-3' 90 | 91 | li.innerHTML = ` 92 |
93 | 94 | 95 | 96 | 97 | 98 | 99 |
100 |
101 | 104 |

GitHub Repository Size Settings

105 |
106 |
107 |

You need to provide a Personal Access Token to access size of private repositories.
108 | You can create one in your GitHub settings. (don't forget to check the "repo" permission)
109 | (to show this dialog again, click on the size element in any public or private repository)

110 |
111 | 112 | 113 |
114 |
115 | ${tokenInfo} 116 |
117 | Beware if you use a public device! The token will be saved locally, in the browser's storage. 118 |
119 |
120 | 123 |
124 |
125 |
126 |
127 | ` 128 | 129 | parent.appendChild(li) 130 | 131 | const elt = document.getElementById(`${MODAL_ID}-size-stat-content`) 132 | if (elt == null) { 133 | return 134 | } 135 | elt.addEventListener('click', askForToken) 136 | elt.appendChild(document.createTextNode(' ')) 137 | 138 | const closeModalBtn = document.getElementById(`${MODAL_ID}-modal-close`) 139 | if (closeModalBtn == null) { 140 | return 141 | } 142 | closeModalBtn.addEventListener('click', closeModal) 143 | 144 | const form = document.getElementById(`${MODAL_ID}-form`) 145 | if (form == null) { 146 | return 147 | } 148 | form.addEventListener('submit', saveToken) 149 | 150 | elt.appendChild(children) 151 | } 152 | --------------------------------------------------------------------------------