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.
当前为
// ==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 });
})();