github-stars-tagger

为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;
    }
}

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址