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.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         RepoNotes
// @namespace    http://tampermonkey.net/
// @version      2.6
// @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/*
// @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 {
            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('div[itemprop="name codeRepository"] > a, h3 > a, h2 > a');
        if (link && link.pathname) {
            return link.pathname.substring(1).replace(/\/$/, '');
        }
        const starForm = repoElement.querySelector('form[action^="/stars/"]');
        if (starForm && starForm.action) {
            const match = starForm.action.match(/\/stars\/([^/]+\/[^/]+)\/star/);
            if (match && match[1]) {
                return match[1];
            }
        }
        console.warn('RepoNotes: Could not find repo name for element:', repoElement);
        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);'; // 使用 void(0) 避免页面跳转
        // SVG icon (pencil)
        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');
        const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
        // Pencil icon path data
        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 textNode = document.createTextNode(isEdit ? EDIT_BUTTON_TEXT : ADD_BUTTON_TEXT);
        button.appendChild(textNode);
        button.updateText = function(isEditing) {
            textNode.textContent = isEditing ? EDIT_BUTTON_TEXT : ADD_BUTTON_TEXT;
        };
        return button;
    }
    // Add note UI for a single repository
    async function addNoteUI(repoElement) {
        if (repoElement.querySelector('.ghsn-container')) {
            // console.log('RepoNotes: UI already exists for this repo element. Skipping.');
            return;
        }
        const existingButton = repoElement.querySelector('.ghsn-star-row .ghsn-note-btn');
         if (existingButton) {
            // console.log('RepoNotes: Button already exists in star row. Skipping.');
            return;
         }
        const repoFullName = getRepoFullName(repoElement);
        if (!repoFullName) {
            // console.warn('RepoNotes: Could not get repo full name. Skipping element:', repoElement);
            return;
        }
        const storageKey = `ghsn_${repoFullName}`;
        let currentNote = await GM_getValue(storageKey, '');
        const starLink = repoElement.querySelector('a[href$="/stargazers"]');
        if (!starLink) {
            // console.warn(`RepoNotes: Could not find star link for repo: ${repoFullName}. Skipping.`);
            return;
        }
        let starRow = starLink.parentNode;
        if (!starRow.classList.contains('d-flex') && !starRow.classList.contains('float-right')) {
             const potentialRow = starLink.closest('span, div.d-inline-block, div.color-fg-muted');
             if (potentialRow) {
                 starRow = potentialRow;
             }
        }
        starRow.classList.add('ghsn-star-row');
        const noteButton = createNoteButton(!!currentNote); // !!currentNote 将其转为布尔值
        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;
        if (!currentNote) {
            displaySpan.classList.add('ghsn-hidden');
        }
        const noteTextarea = document.createElement('textarea');
        noteTextarea.className = 'ghsn-textarea ghsn-hidden';
        noteTextarea.placeholder = NOTE_PLACEHOLDER;
        const buttonsDiv = document.createElement('div');
        buttonsDiv.className = 'ghsn-buttons ghsn-hidden';
        const saveButton = document.createElement('button');
        saveButton.textContent = SAVE_BUTTON_TEXT;
        saveButton.className = 'ghsn-save';
        const cancelButton = document.createElement('button');
        cancelButton.textContent = CANCEL_BUTTON_TEXT;
        cancelButton.className = 'ghsn-cancel';
        const deleteButton = document.createElement('button');
        deleteButton.textContent = DELETE_BUTTON_TEXT;
        deleteButton.className = 'ghsn-delete';
        noteButton.addEventListener('click', (e) => {
            e.preventDefault();
            const isEditing = !noteTextarea.classList.contains('ghsn-hidden');
            if (!isEditing) {
                noteTextarea.value = currentNote;
                displaySpan.classList.add('ghsn-hidden');
                noteTextarea.classList.remove('ghsn-hidden');
                buttonsDiv.classList.remove('ghsn-hidden');
                if (currentNote) {
                    deleteButton.classList.remove('ghsn-hidden');
                } else {
                    deleteButton.classList.add('ghsn-hidden');
                }
                container.classList.remove('ghsn-hidden');
                noteTextarea.focus();
            } else {
                 cancelButton.click();
            }
        });
        cancelButton.addEventListener('click', () => {
            noteTextarea.classList.add('ghsn-hidden');
            buttonsDiv.classList.add('ghsn-hidden');
            if (currentNote) {
                displaySpan.textContent = currentNote;
                displaySpan.classList.remove('ghsn-hidden');
                container.classList.remove('ghsn-hidden');
            } else {
                container.classList.add('ghsn-hidden');
            }
        });
        saveButton.addEventListener('click', async () => {
            const newNote = noteTextarea.value.trim();
            await GM_setValue(storageKey, newNote);
            currentNote = newNote;
            noteButton.updateText(!!newNote);
            if (newNote) {
                displaySpan.textContent = newNote;
                displaySpan.classList.remove('ghsn-hidden');
                container.classList.remove('ghsn-hidden');
            } else {
                displaySpan.classList.add('ghsn-hidden');
                container.classList.add('ghsn-hidden');
                await GM_deleteValue(storageKey);
            }
            noteTextarea.classList.add('ghsn-hidden');
            buttonsDiv.classList.add('ghsn-hidden');
        });
        deleteButton.addEventListener('click', async () => {
            if (window.confirm(`Are you sure you want to delete the note for "${repoFullName}"?`)) {
                await GM_deleteValue(storageKey);
                currentNote = '';
                noteButton.updateText(false);
                displaySpan.classList.add('ghsn-hidden');
                noteTextarea.classList.add('ghsn-hidden');
                buttonsDiv.classList.add('ghsn-hidden');
                container.classList.add('ghsn-hidden');
            }
        });
        buttonsDiv.appendChild(deleteButton);
        buttonsDiv.appendChild(saveButton);
        buttonsDiv.appendChild(cancelButton);
        container.appendChild(displaySpan);
        container.appendChild(noteTextarea);
        container.appendChild(buttonsDiv);
        // 修改这里:将按钮作为starRow的最后一个元素
        starRow.appendChild(noteButton);
        const description = repoElement.querySelector('p.color-fg-muted');
        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);
            console.warn(`RepoNotes: Could not find ideal insertion point for note container in repo: ${repoFullName}. Appending to end.`);
        }
    }
    // --- Process all repositories on the page ---
    function processRepositories() {
        const repoSelector = 'div.col-12.d-block.width-full.py-4.border-bottom.color-border-muted, article.Box-row';
        const repoElements = document.querySelectorAll(repoSelector);
        // console.log(`RepoNotes: Found ${repoElements.length} repository elements.`);
        if (repoElements.length === 0) {
             // console.log("RepoNotes: No repository elements found with selector:", repoSelector);
             const fallbackSelector = 'li[data-view-component="true"].Box-row';
             const fallbackElements = document.querySelectorAll(fallbackSelector);
             fallbackElements.forEach(addNoteUI);
        } else {
            repoElements.forEach(addNoteUI);
        }
    }

    // --- Observe DOM changes (handle dynamic loading like infinite scroll) ---
    let observer = null;

    function setupObserver() {
        if (observer) {
            observer.disconnect();
        }

        const targetNode = document.getElementById('user-repositories-list') || document.querySelector('main') || document.body;

        if (!targetNode) {
            console.error('RepoNotes: Could not find target node for MutationObserver.');
            return;
        }
        // console.log('RepoNotes: Setting up MutationObserver on target:', targetNode);

        observer = new MutationObserver(mutations => {
            // console.log('RepoNotes: MutationObserver detected changes.');
            let needsProcessing = false;
            mutations.forEach(mutation => {
                mutation.addedNodes.forEach(node => {
                    if (node.nodeType === 1) {
                        const repoSelector = 'div.col-12.d-block.width-full.py-4.border-bottom.color-border-muted, article.Box-row, li[data-view-component="true"].Box-row';
                        if (node.matches(repoSelector)) {
                            // console.log('RepoNotes: Added node matches repo selector:', node);
                            addNoteUI(node);
                            needsProcessing = true;
                        } else {
                            const nestedRepos = node.querySelectorAll(repoSelector);
                            if (nestedRepos.length > 0) {
                                // console.log(`RepoNotes: Found ${nestedRepos.length} nested repos in added node:`, node);
                                nestedRepos.forEach(addNoteUI);
                                needsProcessing = true;
                            }
                        }
                    }
                });
            });
        });

        observer.observe(targetNode, {
            childList: true,
            subtree: true
        });
    }

    // --- Startup and Navigation Handling ---

    function initializeOrReinitialize() {
        if (window.location.search.includes('tab=stars') || document.querySelector('div.col-12.d-block.width-full.py-4') || document.querySelector('article.Box-row')) {
            // console.log('RepoNotes: Running processRepositories.');
            processRepositories();
            // console.log('RepoNotes: Setting up observer.');
            setupObserver();
        } else {
             // console.log('RepoNotes: Not on a relevant page, skipping processing and observer setup.');
             if(observer) {
                observer.disconnect();
                // console.log('RepoNotes: Disconnected observer.');
             }
        }
    }

    document.addEventListener('turbo:load', () => {
        // console.log('RepoNotes: turbo:load event detected.');
        initializeOrReinitialize();
    });

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', initializeOrReinitialize);
    } else {
        initializeOrReinitialize();
    }

})();