github-stars-tagger

为github的star增加标签管理功能

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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;
    }
}