您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
为github的star增加标签管理功能
// ==UserScript== // github-stars-tagger // 需要在页面https://github.com/stars中管理标签, 所以在导航栏上增加了 /stars 的链接 // 代码来自: https://github.com/artisologic/github-stars-tagger // 存储改为了 localStorage // // @name github-stars-tagger // @namespace http://tampermonkey.net/ // @version 0.1 // @description 为github的star增加标签管理功能 // @author You // @match https://github.com/* // @grant none // ==/UserScript== // libs/EventEmitter.js ((window) => { 'use strict'; /** * @class EventEmitter */ class EventEmitter { constructor() { this._listeners = []; } on(eventName, callback) { this._listeners.push({ name: eventName, callback: callback }); return this; } off(eventName, callback) { this._listeners.forEach((listener, index) => { if (listener.name === eventName && listener.callback === callback) { this._listeners.splice(index, 1); } }); return this; } emit(eventName, data) { this._listeners .filter(listener => listener.name === eventName) .forEach(listener => listener.callback(data, this, eventName)); return this; } } window.GSM = window.GSM || {}; GSM.EventEmitter = EventEmitter; })(window); // libs/Model.js ((window) => { 'use strict'; /** * @class Model */ class Model extends GSM.EventEmitter { constructor(data) { super(); this.data = data; } } window.GSM = window.GSM || {}; GSM.Model = Model; })(window); // libs/TagsStore.js ((window) => { 'use strict'; const KEY = 'github-stars-tagger'; /** * @class TagsStore */ class TagsStore { constructor() { } get(key) { // console.log('get', key); const promise = new Promise((resolve, reject) => { var items = localStorage.getItem(KEY); var data = {}; if (items) { data = JSON.parse(items); } if (key === undefined) { resolve(data); } else if (typeof data[key] !== 'undefined') { resolve(data[key]); } else { reject('TagsStore.get('+key+')获取失败'); } }); promise.catch(error => { GSM.utils.track('Sync', 'get', 'error', error); }); return promise; } set(key, value) { // console.log('set', key, value); var items = localStorage.getItem(KEY); var data = {}; if (items) { data = JSON.parse(items); } data[key] = value; const promise = new Promise((resolve, reject) => { if (localStorage.setItem(KEY, JSON.stringify(data))) { resolve(); } else { reject(); } }); promise.catch(error => { GSM.utils.track('Sync', 'set', 'error', error); }); return promise; } remove(key) { // console.log('remove', key); const promise = new Promise((resolve, reject) => { if (localStorage.setItem(key, null)) { resolve(); } else { reject('remove error'); } }); promise.catch(error => { GSM.utils.track('Sync', 'remove', 'error', error); }); return promise; } clear() { // console.log('clear'); const promise = new Promise((resolve, reject) => { }); promise.catch(error => { GSM.utils.track('Sync', 'clear', 'error', error); }); return promise; } } window.GSM = window.GSM || {}; GSM.TagsStore = TagsStore; })(window); // libs/utils.js ((window) => { 'use strict'; const utils = { insertAfter(newNode, referenceNode) { referenceNode.parentNode.insertBefore(newNode, referenceNode.nextSibling); }, unique(array) { const hash = {}; const res = []; for (let i = 0; i < array.length; i++) { const item = array[i]; if (!hash[item]) { hash[item] = true; res.push(item); } } return res; }, message(command, data) { // chrome.runtime.sendMessage({ command, data }); }, track(category, action, label, value) { utils.message('trackEvent', { category, action, label, value }); } }; window.GSM = window.GSM || {}; GSM.utils = utils; })(window); // libs/View.js ((window) => { 'use strict'; /** * @class View */ class View extends GSM.EventEmitter { constructor() { super(); this.refs = { root: this.createRootElement() }; this.handlers = {}; } static getRootClass() { // override this method return ''; } createRootElement() { const rootElem = document.createElement('div'); rootElem.classList.add(this.constructor.getRootClass()); return rootElem; } render() { // override this method } getElement(selector) { if (typeof selector === 'undefined') { return this.refs.root; } else { return this.refs.root.querySelector(selector); } } injectInto(parentElem) { parentElem.appendChild(this.getElement()); } injectAfter(siblingElem) { GSM.utils.insertAfter(this.getElement(), siblingElem); } } window.GSM = window.GSM || {}; GSM.View = View; })(window); // models/Tags.js ((window) => { 'use strict'; /** * @class Tags */ class Tags extends GSM.Model { constructor(data) { super(data); } getTagsForRepo(repoId) { return this.data[repoId] || []; } setTagsForRepo(repoId, unserializedTags) { const serializedTags = unserializedTags.split(',') .map(tag => tag.trim()) .filter(tag => tag !== ''); const hasNoTags = serializedTags.length === 0; const repoChangeEventName = 'change:' + repoId; if (hasNoTags) { delete this.data[repoId]; const changeData = { key: repoId, deleted: true }; this.emit('change', changeData); this.emit(repoChangeEventName, changeData); } else { const newTags = GSM.utils.unique(serializedTags); const changeData = { key: repoId, value: newTags }; this.data[repoId] = newTags; this.emit('change', changeData); this.emit(repoChangeEventName, changeData); } } getDeserializedTagsForRepo(repoId) { return this.getTagsForRepo(repoId).join(', '); } byTag() { const pivotedData = {}; for (const repoId in this.data) { const tags = this.getTagsForRepo(repoId); tags.forEach(tag => { if (!(tag in pivotedData)) { pivotedData[tag] = []; } pivotedData[tag].push(repoId); }); } return pivotedData; } byTagSortedByUse() { const modelByTag = this.byTag(); return Object.keys(modelByTag) .map(tag => createTagObject(tag)) .sort(byMostUsed); function createTagObject(tag) { return { name: tag, repos: modelByTag[tag] }; } function byMostUsed(tagObject1, tagObject2) { const diff = tagObject2.repos.length - tagObject1.repos.length; // default to alphanumerical sort if (diff === 0) { return tagObject2.name < tagObject1.name ? 1 : -1; } return diff; } } } window.GSM = window.GSM || {}; GSM.Tags = Tags; })(window); // views/TagLineView.js ((window) => { 'use strict'; /** * @class TagLineView */ class TagLineView extends GSM.View { constructor(model, repoId) { super(); this.model = model; this.repoId = repoId; } static getRootClass() { return 'GsmTagLine'; } createRootElement() { const rootElem = document.createElement('p'); rootElem.classList.add(TagLineView.getRootClass(), 'f6', 'text-gray', 'mt-2'); return rootElem; } render() { if (this.rendered) { this.removeEvents(); } const tags = this.model.getDeserializedTagsForRepo(this.repoId); const noTagsModifierClass = 'GsmTagLine--noTags'; this.getElement().classList.toggle(noTagsModifierClass, !tags); this.getElement().innerHTML = ` <svg class="octicon octicon-tag GsmTagLine-icon" viewBox="0 0 14 16" version="1.1" width="14" height="16" aria-hidden="true"> <path fill-rule="evenodd" d="M7.73 1.73C7.26 1.26 6.62 1 5.96 1H3.5C2.13 1 1 2.13 1 3.5v2.47c0 .66.27 1.3.73 1.77l6.06 6.06c.39.39 1.02.39 1.41 0l4.59-4.59a.996.996 0 0 0 0-1.41L7.73 1.73zM2.38 7.09c-.31-.3-.47-.7-.47-1.13V3.5c0-.88.72-1.59 1.59-1.59h2.47c.42 0 .83.16 1.13.47l6.14 6.13-4.73 4.73-6.13-6.15zM3.01 3h2v2H3V3h.01z"></path> </svg> <span class="GsmTagLine-tags">${ tags }</span> <span class="GsmTagLine-separator"> — </span> <button class="GsmTagLine-editButton" type="button" title="Click to edit">Edit</button> <input class="GsmTagLine-tagsInput form-control input-sm" type="text" value="${ tags }" placeholder="Enter comma-separated tags" spellcheck="false" autocomplete="off" /> `; this.refs.editButton = this.getElement('.GsmTagLine-editButton'); this.refs.tagsInput = this.getElement('.GsmTagLine-tagsInput'); this.addEvents(); this.rendered = true; } addEvents() { this.handlers = { modelChange: (changeData, target, eventName) => this.onModelChanged(changeData, target, eventName), editButtonClick: event => this.onEditButtonClicked(event), tagsInputKeydown: event => this.onTagsInputKeydowned(event), tagsInputBlur: event => this.onTagsInputBlurred(event) }; this.model.on('change:' + this.repoId, this.handlers.modelChange); this.refs.editButton.addEventListener('click', this.handlers.editButtonClick); this.refs.tagsInput.addEventListener('keydown', this.handlers.tagsInputKeydown); this.refs.tagsInput.addEventListener('blur', this.handlers.tagsInputBlur); } removeEvents() { this.model.off('change:' + this.repoId, this.handlers.modelChange); this.refs.editButton.removeEventListener('click', this.handlers.editButtonClick); this.refs.tagsInput.removeEventListener('keydown', this.handlers.tagsInputKeydown); this.refs.tagsInput.removeEventListener('blur', this.handlers.tagsInputBlur); this.handlers = {}; } onModelChanged() { this.render(); } onEditButtonClicked() { this.enterEditMode(); GSM.utils.track('TagLine', 'edit'); } onTagsInputKeydowned(event) { const ENTER = 13; const ESCAPE = 27; if (event.keyCode === ESCAPE) { this.exitEditMode(); GSM.utils.track('TagLine', 'escape'); } else if (event.keyCode === ENTER) { const newTags = event.currentTarget.value; this.exitEditMode(newTags); GSM.utils.track('TagLine', 'save'); } } onTagsInputBlurred() { this.exitEditMode(); GSM.utils.track('TagLine', 'blur'); } enterEditMode() { this.getElement().classList.add('-is-editing'); // help entering next tag if (this.refs.tagsInput.value !== '') { this.refs.tagsInput.value += ', '; } // focus at the end of input this.refs.tagsInput.focus(); const length = this.refs.tagsInput.value.length; this.refs.tagsInput.setSelectionRange(length, length); } exitEditMode(newTags) { if (typeof newTags === 'undefined') { this.render(); } else { this.model.setTagsForRepo(this.repoId, newTags); } this.getElement().classList.remove('-is-editing'); } } window.GSM = window.GSM || {}; GSM.TagLineView = TagLineView; })(window); // views/TagSidebarView.js ((window) => { 'use strict'; /** * @class TagSidebarView */ class TagSidebarView extends GSM.View { constructor(model) { super(); this.model = model; } static getRootClass() { return 'GsmTagSidebar'; } render() { if (this.rendered) { this.removeEvents(); } const sortedTags = this.model.byTagSortedByUse(); const tagsCount = sortedTags.length; const tagsCountIndicator = tagsCount ? `<span class="count">${ tagsCount }</span>` : ''; this.getElement().innerHTML = ` <h3 class="h4 mb-2"> Filter by tags ${ tagsCountIndicator } </h3> <ul class="filter-list small GsmTagSidebar-tagList"> ${ this.renderTags(sortedTags) } </ul> <hr /> `; this.addEvents(); this.rendered = true; } renderTags(sortedTags) { if (sortedTags.length === 0) { return `<span class="filter-item GsmTagSidebar-noTagsMessage">No tags</span>`; } return sortedTags.map(tagModel => this.renderTag(tagModel)).join(''); } renderTag(tagModel) { return ` <li> <label class="GsmTagSidebar-label"> <span class="filter-item"> ${ tagModel.name } <span class="count">${ tagModel.repos.length }</span> </span> <input class="GsmTagSidebar-checkbox" type="checkbox" /> <ul class="GsmRepoList"> ${ this.renderTagRepos(tagModel) } </ul> </label> </li> `; } renderTagRepos(tagModel) { return tagModel.repos.map(tagModel => this.renderTagRepo(tagModel)).join(''); } renderTagRepo(repoId) { return ` <li class="GsmRepoList-item css-truncate"> <a class="css-truncate-target" href="/${ repoId }">${ repoId }</a> </li> `; } addEvents() { this.handlers = { modelChange: (changeData, target, eventName) => this.onModelChanged(changeData, target, eventName), click: (event) => this.onClicked(event) }; this.model.on('change', this.handlers.modelChange); this.getElement().addEventListener('click', this.handlers.click, false); } removeEvents() { this.model.off('change', this.handlers.modelChange); this.getElement().removeEventListener('click', this.handlers.click, false); this.handlers = {}; } onModelChanged() { this.render(); } onClicked(event) { if (event.target && event.target.classList.contains('filter-item')) { GSM.utils.track('TagSidebar', 'click', 'tag'); } } } window.GSM = window.GSM || {}; GSM.TagSidebarView = TagSidebarView; })(window); githubStarsTaggerInit(); // main.js function githubStarsTaggerInit() { 'use strict'; addStarPageBtn(); const tagsStore = new GSM.TagsStore(); if (isStarPage(location.href)) { addStyle(); tagsStore.get() .then(createModel) .then(initViews) .then(initSync); } function addStarPageBtn() { var navs = document.querySelector('ul.flex-items-center.text-bold'); if (navs) { navs.innerHTML += '<li><a href="/stars" class="js-selected-navigation-item HeaderNavlink px-2">Stars</a></li>'; } } function isStarPage(path) { return path === '/stars' || path === '/stars/' || Boolean(path.match(/\/stars/)); } function createModel(data) { return new GSM.Tags(data); } function initViews(tagsModel) { initTagLines(tagsModel); initTagSidebar(tagsModel); return tagsModel; function initTagLines(model) { const repoItemSelector = '.repo-list > li'; // on page load addTagLines(); // when sorting, filtering, paginating was used addAjaxPageRefreshEventListener(onAjaxPageRefreshed); function onAjaxPageRefreshed(newPath) { removeTagLines(); const shouldAddTagLines = isCurrentPathSupported(newPath); if (shouldAddTagLines) { addTagLines(); } } function addTagLines() { const starredRepoElems = document.querySelectorAll(repoItemSelector); Array.from(starredRepoElems).forEach(starredRepoElem => addTagLine(starredRepoElem)); function addTagLine(starredRepoElem) { const repoId = starredRepoElem.querySelector('h3 a').getAttribute('href').substring(1); const view = new GSM.TagLineView(model, repoId); view.render(); view.injectInto(starredRepoElem); } } function removeTagLines() { const starredRepoElems = document.querySelectorAll(repoItemSelector); Array.from(starredRepoElems).forEach(starredRepoElem => removeTagLine(starredRepoElem)); function removeTagLine(starredRepoElem) { const oldTagLineElem = starredRepoElem.querySelector('.' + GSM.TagLineView.getRootClass()); if (oldTagLineElem) { oldTagLineElem.remove(); } } } function isCurrentPathSupported(path) { return path === '/stars' || path === '/stars/' || Boolean(path.match(/\/stars\/?\?.+/)); } } function initTagSidebar(model) { const ajaxContentElem = document.querySelector('.explore-pjax-container'); // on page load addSidebar(); // when sorting, filtering, paginating was used addAjaxPageRefreshEventListener(onAjaxPageRefreshed); function onAjaxPageRefreshed(newPath) { removeSidebar(); const shouldAddSidebar = isCurrentPathSupported(newPath); if (shouldAddSidebar) { addSidebar(); } } function addSidebar() { const firstSidebarSeparatorElem = ajaxContentElem.querySelector('.col-md-3.float-md-left.mt-3 hr:first-of-type'); const view = new GSM.TagSidebarView(model); view.render(); view.injectAfter(firstSidebarSeparatorElem); } function removeSidebar() { const oldTagSidebarElem = ajaxContentElem.querySelector('.' + GSM.TagSidebarView.getRootClass()); if (oldTagSidebarElem) { oldTagSidebarElem.remove(); } } } function addAjaxPageRefreshEventListener(callback) { const ajaxContentElem = document.querySelector('.explore-pjax-container'); const observer = new MutationObserver(mutations => { mutations.forEach(mutation => { if (mutation.addedNodes.length > 0) { callback(document.location.pathname); } }); }); const config = { childList: true }; observer.observe(ajaxContentElem, config); } } function initSync(tagsModel) { tagsModel.on('change', onModelChanged); function onModelChanged(changeData) { if (changeData.deleted) { tagsStore.remove(changeData.key); } else { tagsStore.set(changeData.key, changeData.value); } } } function addStyle() { var cssText = '<style>.GsmTagLine{position:relative}.GsmTagLine-icon{color:currentColor}.GsmTagLine--noTags .GsmTagLine-icon{opacity:.35}.GsmTagLine.-is-editing .GsmTagLine-icon{position:absolute;top:7px;left:7px}.GsmTagLine.-is-editing .GsmTagLine-tags{display:none}.GsmTagLine--noTags .GsmTagLine-tags{opacity:.35}.GsmTagLine-separator{opacity:.35}.GsmTagLine.-is-editing .GsmTagLine-separator{display:none}.GsmTagLine-editButton{padding:.5em;position:relative;margin:-0.5em;background-color:transparent;border:0;opacity:.35;outline:0;-webkit-appearance:none;-moz-appearance:none;appearance:none}.GsmTagLine-editButton:hover,.GsmTagLine-editButton:focus{opacity:1}.GsmTagLine.-is-editing .GsmTagLine-editButton{display:none}.GsmTagLine-tagsInput{display:none;width:400px;padding-left:25px !important}.GsmTagLine.-is-editing .GsmTagLine-tagsInput{display:inline-block} .GsmTagSidebar h3 .count{float:right;margin-right:10px}.GsmTagSidebar-noTagsMessage{margin-bottom:15px !important}.GsmTagSidebar-noTagsMessage:hover{background-color:transparent !important;cursor:auto}.GsmTagSidebar-tagList{max-height:19.2em;overflow:auto}.GsmTagSidebar-tagList>li:last-child{margin-bottom:15px}.GsmTagSidebar-tagList+hr{margin-top:0}.GsmTagSidebar-label{font-size:inherit;font-weight:inherit}.GsmTagSidebar-checkbox{display:none}.GsmRepoList{display:none;list-style:none}.GsmRepoList-item{display:block;padding:4px 10px;margin:0 0 2px;font-size:12px}.GsmRepoList-item a{display:block;max-width:210px !important}.GsmTagSidebar-checkbox:checked+.GsmRepoList{display:block}</style>'; document.body.innerHTML += cssText; } }