RepoNotes

RepoNotes is a lightweight browser extension script for Tampermonkey that enhances your GitHub stars with personalized notes. Ever starred a repository but later forgot why? RepoNotes solves this problem by allowing you to attach custom annotations to your starred repositories.

当前为 2025-03-30 提交的版本,查看 最新版本

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         RepoNotes
// @namespace    http://tampermonkey.net/
// @version      2.2
// @description  RepoNotes is a lightweight browser extension script for Tampermonkey that enhances your GitHub stars with personalized notes. Ever starred a repository but later forgot why? RepoNotes solves this problem by allowing you to attach custom annotations to your starred repositories.
// @author       malagebidi
// @match        https://github.com/*?tab=stars*
// @icon         https://github.githubassets.com/favicons/favicon.svg
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_deleteValue
// @grant        GM_addStyle
// @license      MIT
// ==/UserScript==

(async function() {
    'use strict';

    // --- Configuration ---
    const NOTE_PLACEHOLDER = 'Enter your note...';
    const ADD_BUTTON_TEXT = 'Add Note';
    const EDIT_BUTTON_TEXT = 'Edit Note';
    const SAVE_BUTTON_TEXT = 'Save';
    const CANCEL_BUTTON_TEXT = 'Cancel';
    const DELETE_BUTTON_TEXT = 'Delete';

    // --- Styles ---
    GM_addStyle(`
        .ghsn-container {
            padding-right: var(--base-size-24, 24px) !important;
            color: var(--fgColor-muted, var(--color-fg-muted)) !important;
            width: 74.99999997%;
        }
        .ghsn-display {
            font-style: italic;
            border: var(--borderWidth-thin) solid var(--borderColor-default, var(--color-border-default, #d2dff0));
            border-radius: 100px;
            padding: 2.5px 5px;
            white-space: nowrap;
            overflow: hidden;
            text-overflow: ellipsis;
            display: block;
            max-width: fit-content;
        }
        .ghsn-textarea {
            width: 100%;
            min-height: 60px;
            margin-bottom: 5px;
            padding: 5px;
            border: 1px solid var(--color-border-default);
            border-radius: 3px;
            background-color: var(--color-canvas-default);
            color: var(--color-fg-default);
            box-sizing: border-box;
        }
        .ghsn-buttons button {
            margin-right: 5px;
            padding: 3px 8px;
            font-size: 0.9em;
            cursor: pointer;
            border-radius: 4px;
            border: 1px solid var(--color-border-muted);
        }
        .ghsn-buttons button.ghsn-save {
            background-color: var(--color-btn-primary-bg);
            color: var(--color-btn-primary-text);
            border-color: var(--color-btn-primary-border);
        }
        .ghsn-buttons button.ghsn-delete {
            background-color: var(--color-btn-danger-bg);
            color: var(--color-btn-danger-text);
            border-color: var(--color-btn-danger-border);
        }
        .ghsn-buttons button.ghsn-cancel {
            background-color: var(--color-btn-bg);
            color: var(--color-btn-text);
        }
        .ghsn-buttons button:hover {
            filter: brightness(1.1);
        }
        .ghsn-hidden {
            display: none !important;
        }
        .ghsn-note-btn {
            margin-left: 16px;
            color: var(--fgColor-muted);
            cursor: pointer;
            text-decoration: none;
        }
        .ghsn-note-btn:hover {
            color: var(--fgColor-accent) !important;
            -webkit-text-decoration: none;
            text-decoration: none;
        }
        .ghsn-note-btn svg {
            margin-right: 4px;
        }
    `);

    // --- Core Logic ---

    // Get repo unique identifier (owner/repo)
    function getRepoFullName(repoElement) {
        const link = repoElement.querySelector('h3 a, h2 a');
        if (link && link.pathname) {
            return link.pathname.substring(1); // Remove leading '/'
        }
        return null;
    }

    // Create note button with icon
    function createNoteButton(isEdit = false) {
        const button = document.createElement('a');
        button.className = 'ghsn-note-btn';
        button.href = 'javascript:void(0);';

        // SVG icon (notebook)
        const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
        svg.setAttribute('aria-hidden', 'true');
        svg.setAttribute('height', '16');
        svg.setAttribute('width', '16');
        svg.setAttribute('viewBox', '0 0 16 16');
        svg.setAttribute('fill', 'currentColor');
        svg.setAttribute('class', 'octicon octicon-star');

        // Notebook icon
        const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
        path.setAttribute('d', 'M11.013 1.427a1.75 1.75 0 0 1 2.474 0l1.086 1.086a1.75 1.75 0 0 1 0 2.474l-8.61 8.61c-.21.21-.47.364-.756.445l-3.251.93a.75.75 0 0 1-.927-.928l.929-3.25c.081-.286.235-.547.445-.758l8.61-8.61Zm.176 4.823L9.75 4.81l-6.286 6.287a.253.253 0 0 0-.064.108l-.558 1.953 1.953-.558a.253.253 0 0 0 .108-.064Zm1.238-3.763a.25.25 0 0 0-.354 0L10.811 3.75l1.439 1.44 1.263-1.263a.25.25 0 0 0 0-.354Z');

        svg.appendChild(path);
        button.appendChild(svg);

        const text = document.createTextNode(isEdit ? EDIT_BUTTON_TEXT : ADD_BUTTON_TEXT);
        button.appendChild(text);

        return button;
    }

    // Add note UI for a single repository
    async function addNoteUI(repoElement) {
        // Prevent duplicate
        if (repoElement.querySelector('.ghsn-note-btn')) {
            return;
        }

        const repoFullName = getRepoFullName(repoElement);
        if (!repoFullName) {
            console.warn('Could not find repo name for element:', repoElement);
            return;
        }

        const storageKey = `ghsn_${repoFullName}`;
        let currentNote = await GM_getValue(storageKey, '');

        // Find the star count element to place our button next to it
        const starCountElement = repoElement.querySelector('.Link--muted.mr-3');
        if (!starCountElement) {
            console.warn('Could not find star count element for repo:', repoFullName);
            return;
        }

        // Create Add/Edit Note button
        const noteButton = createNoteButton(currentNote ? true : false);

        // Find the parent row that contains the star count
        const starRow = starCountElement.parentNode;

        // Insert button at the end of the row that contains the star count
        starRow.appendChild(noteButton);

        // Create note container (initially hidden if no note)
        const container = document.createElement('div');
        container.className = 'ghsn-container';
        if (!currentNote) {
            container.classList.add('ghsn-hidden');
        }

        const displaySpan = document.createElement('span');
        displaySpan.className = 'ghsn-display';
        displaySpan.textContent = currentNote;

        const noteTextarea = document.createElement('textarea');
        noteTextarea.className = 'ghsn-textarea ghsn-hidden';
        noteTextarea.placeholder = NOTE_PLACEHOLDER;

        const buttonsDiv = document.createElement('div');
        buttonsDiv.className = 'ghsn-buttons';

        const saveButton = document.createElement('button');
        saveButton.textContent = SAVE_BUTTON_TEXT;
        saveButton.className = 'ghsn-save ghsn-hidden';

        const cancelButton = document.createElement('button');
        cancelButton.textContent = CANCEL_BUTTON_TEXT;
        cancelButton.className = 'ghsn-cancel ghsn-hidden';

        const deleteButton = document.createElement('button');
        deleteButton.textContent = DELETE_BUTTON_TEXT;
        deleteButton.className = 'ghsn-delete ghsn-hidden';

        // --- Event Listeners ---

        // Add/Edit Note button click
        noteButton.addEventListener('click', () => {
            if (container.classList.contains('ghsn-hidden')) {
                container.classList.remove('ghsn-hidden');
            }

            noteTextarea.value = currentNote;
            displaySpan.classList.add('ghsn-hidden');

            noteTextarea.classList.remove('ghsn-hidden');
            saveButton.classList.remove('ghsn-hidden');
            cancelButton.classList.remove('ghsn-hidden');

            // Only show delete button in edit mode if there's an existing note
            if (currentNote) {
                deleteButton.classList.remove('ghsn-hidden');
            }

            noteTextarea.focus();
        });

        // Cancel button click
        cancelButton.addEventListener('click', () => {
            noteTextarea.classList.add('ghsn-hidden');
            deleteButton.classList.add('ghsn-hidden');
            saveButton.classList.add('ghsn-hidden');
            cancelButton.classList.add('ghsn-hidden');

            // If there's no note and user cancels, hide the container
            if (!currentNote) {
                container.classList.add('ghsn-hidden');
            } else {
                displaySpan.classList.remove('ghsn-hidden');
            }
        });

        // Save button click
        saveButton.addEventListener('click', async () => {
            const newNote = noteTextarea.value.trim();
            await GM_setValue(storageKey, newNote);
            currentNote = newNote;

            // Update button text based on whether there's a note
            noteButton.textContent = newNote ? EDIT_BUTTON_TEXT : ADD_BUTTON_TEXT;

            // Prepend the icon to the button
            const icon = createNoteButton().firstChild;
            noteButton.insertBefore(icon, noteButton.firstChild);

            if (newNote) {
                displaySpan.textContent = newNote;
                displaySpan.classList.remove('ghsn-hidden');
            } else {
                // If note is deleted/empty, hide the container
                container.classList.add('ghsn-hidden');
            }

            noteTextarea.classList.add('ghsn-hidden');
            deleteButton.classList.add('ghsn-hidden');
            saveButton.classList.add('ghsn-hidden');
            cancelButton.classList.add('ghsn-hidden');
        });

        // Delete button click
        deleteButton.addEventListener('click', async () => {
            if (confirm(`Are you sure you want to delete the note for "${repoFullName}"?`)) {
                await GM_deleteValue(storageKey);
                currentNote = '';

                // Update button
                noteButton.textContent = ADD_BUTTON_TEXT;
                const icon = createNoteButton().firstChild;
                noteButton.insertBefore(icon, noteButton.firstChild);

                // Hide container
                container.classList.add('ghsn-hidden');

                // Hide all edit elements
                noteTextarea.classList.add('ghsn-hidden');
                deleteButton.classList.add('ghsn-hidden');
                saveButton.classList.add('ghsn-hidden');
                cancelButton.classList.add('ghsn-hidden');
            }
        });

        // --- Assemble UI ---
        buttonsDiv.appendChild(deleteButton);
        buttonsDiv.appendChild(saveButton);
        buttonsDiv.appendChild(cancelButton);

        container.appendChild(displaySpan);
        container.appendChild(noteTextarea);
        container.appendChild(buttonsDiv);

        // --- Insert into page ---
        const description = repoElement.querySelector('p.col-9');
        const topics = repoElement.querySelector('.topic-tag-list');
        const insertAfterElement = topics || description || repoElement.querySelector('h3, h2');

        if (insertAfterElement && insertAfterElement.parentNode) {
            insertAfterElement.parentNode.insertBefore(container, insertAfterElement.nextSibling);
        } else {
            repoElement.appendChild(container);
        }
    }

    // --- Process all repositories on the page ---
    function processRepositories() {
        const repoElements = document.querySelectorAll('div.col-12.d-block.width-full.py-4');
        repoElements.forEach(addNoteUI);
    }

    // --- Observe DOM changes (handle dynamic loading) ---
    const observer = new MutationObserver(mutations => {
        mutations.forEach(mutation => {
            mutation.addedNodes.forEach(node => {
                if (node.nodeType === 1) {
                    if (node.matches('div.col-12.d-block.width-full.py-4')) {
                        addNoteUI(node);
                    } else {
                        const nestedRepos = node.querySelectorAll('div.col-12.d-block.width-full.py-4');
                        nestedRepos.forEach(addNoteUI);
                    }
                }
            });
        });
    });

    // --- Startup ---
    processRepositories();

    const targetNode = document.querySelector('div.position-relative') || document.querySelector('main') || document.body;
    observer.observe(targetNode, { childList: true, subtree: true });
})();