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.

  1. // ==UserScript==
  2. // @name RepoNotes
  3. // @namespace http://tampermonkey.net/
  4. // @version 2.5
  5. // @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.
  6. // @author malagebidi
  7. // @match https://github.com/*
  8. // @icon https://github.githubassets.com/favicons/favicon.svg
  9. // @grant GM_setValue
  10. // @grant GM_getValue
  11. // @grant GM_deleteValue
  12. // @grant GM_addStyle
  13. // @license MIT
  14. // ==/UserScript==
  15. (async function() {
  16. 'use strict';
  17. // --- Configuration ---
  18. const NOTE_PLACEHOLDER = 'Enter your note...';
  19. const ADD_BUTTON_TEXT = 'Add Note';
  20. const EDIT_BUTTON_TEXT = 'Edit Note';
  21. const SAVE_BUTTON_TEXT = 'Save';
  22. const CANCEL_BUTTON_TEXT = 'Cancel';
  23. const DELETE_BUTTON_TEXT = 'Delete';
  24. // --- Styles ---
  25. GM_addStyle(`
  26. .ghsn-container {
  27. padding-right: var(--base-size-24, 24px) !important;
  28. color: var(--fgColor-muted, var(--color-fg-muted)) !important;
  29. width: 74.99999997%;
  30. }
  31. .ghsn-display {
  32. font-style: italic;
  33. border: var(--borderWidth-thin) solid var(--borderColor-default, var(--color-border-default, #d2dff0));
  34. border-radius: 100px;
  35. padding: 2.5px 5px;
  36. white-space: nowrap;
  37. overflow: hidden;
  38. text-overflow: ellipsis;
  39. display: block;
  40. max-width: fit-content;
  41. }
  42. .ghsn-textarea {
  43. width: 100%;
  44. min-height: 60px;
  45. margin-bottom: 5px;
  46. padding: 5px;
  47. border: 1px solid var(--color-border-default);
  48. border-radius: 3px;
  49. background-color: var(--color-canvas-default);
  50. color: var(--color-fg-default);
  51. box-sizing: border-box;
  52. }
  53. .ghsn-buttons button {
  54. margin-right: 5px;
  55. padding: 3px 8px;
  56. font-size: 0.9em;
  57. cursor: pointer;
  58. border-radius: 4px;
  59. border: 1px solid var(--color-border-muted);
  60. }
  61. .ghsn-buttons button.ghsn-save {
  62. background-color: var(--color-btn-primary-bg);
  63. color: var(--color-btn-primary-text);
  64. border-color: var(--color-btn-primary-border);
  65. }
  66. .ghsn-buttons button.ghsn-delete {
  67. background-color: var(--color-btn-danger-bg);
  68. color: var(--color-btn-danger-text);
  69. border-color: var(--color-btn-danger-border);
  70. }
  71. .ghsn-buttons button.ghsn-cancel {
  72. background-color: var(--color-btn-bg);
  73. color: var(--color-btn-text);
  74. }
  75. .ghsn-buttons button:hover {
  76. filter: brightness(1.1);
  77. }
  78. .ghsn-hidden {
  79. display: none !important;
  80. }
  81. .ghsn-note-btn {
  82. margin-left: 16px;
  83. color: var(--fgColor-muted);
  84. cursor: pointer;
  85. text-decoration: none;
  86. }
  87. .ghsn-note-btn:hover {
  88. color: var(--fgColor-accent) !important;
  89. -webkit-text-decoration: none;
  90. text-decoration: none;
  91. }
  92. .ghsn-note-btn svg {
  93. margin-right: 4px;
  94. }
  95. `);
  96. // --- Core Logic ---
  97. // Get repo unique identifier (owner/repo)
  98. function getRepoFullName(repoElement) {
  99. const link = repoElement.querySelector('div[itemprop="name codeRepository"] > a, h3 > a, h2 > a');
  100. if (link && link.pathname) {
  101. return link.pathname.substring(1).replace(/\/$/, '');
  102. }
  103. const starForm = repoElement.querySelector('form[action^="/stars/"]');
  104. if (starForm && starForm.action) {
  105. const match = starForm.action.match(/\/stars\/([^/]+\/[^/]+)\/star/);
  106. if (match && match[1]) {
  107. return match[1];
  108. }
  109. }
  110. console.warn('RepoNotes: Could not find repo name for element:', repoElement);
  111. return null;
  112. }
  113. // Create note button with icon
  114. function createNoteButton(isEdit = false) {
  115. const button = document.createElement('a');
  116. button.className = 'ghsn-note-btn';
  117. button.href = 'javascript:void(0);'; // 使用 void(0) 避免页面跳转
  118. // SVG icon (pencil)
  119. const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
  120. svg.setAttribute('aria-hidden', 'true');
  121. svg.setAttribute('height', '16');
  122. svg.setAttribute('width', '16');
  123. svg.setAttribute('viewBox', '0 0 16 16');
  124. svg.setAttribute('fill', 'currentColor');
  125. svg.setAttribute('class', 'octicon octicon-star');
  126. const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
  127. // Pencil icon path data
  128. 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');
  129. svg.appendChild(path);
  130. button.appendChild(svg);
  131. const textNode = document.createTextNode(isEdit ? EDIT_BUTTON_TEXT : ADD_BUTTON_TEXT);
  132. button.appendChild(textNode);
  133. button.updateText = function(isEditing) {
  134. textNode.textContent = isEditing ? EDIT_BUTTON_TEXT : ADD_BUTTON_TEXT;
  135. };
  136. return button;
  137. }
  138. // Add note UI for a single repository
  139. async function addNoteUI(repoElement) {
  140. if (repoElement.querySelector('.ghsn-container')) {
  141. // console.log('RepoNotes: UI already exists for this repo element. Skipping.');
  142. return;
  143. }
  144. const existingButton = repoElement.querySelector('.ghsn-star-row .ghsn-note-btn');
  145. if (existingButton) {
  146. // console.log('RepoNotes: Button already exists in star row. Skipping.');
  147. return;
  148. }
  149. const repoFullName = getRepoFullName(repoElement);
  150. if (!repoFullName) {
  151. // console.warn('RepoNotes: Could not get repo full name. Skipping element:', repoElement);
  152. return;
  153. }
  154. const storageKey = `ghsn_${repoFullName}`;
  155. let currentNote = await GM_getValue(storageKey, '');
  156. const starLink = repoElement.querySelector('a[href$="/stargazers"]');
  157. if (!starLink) {
  158. // console.warn(`RepoNotes: Could not find star link for repo: ${repoFullName}. Skipping.`);
  159. return;
  160. }
  161. let starRow = starLink.parentNode;
  162. if (!starRow.classList.contains('d-flex') && !starRow.classList.contains('float-right')) {
  163. const potentialRow = starLink.closest('span, div.d-inline-block, div.color-fg-muted');
  164. if (potentialRow) {
  165. starRow = potentialRow;
  166. }
  167. }
  168. starRow.classList.add('ghsn-star-row');
  169. const noteButton = createNoteButton(!!currentNote); // !!currentNote 将其转为布尔值
  170. const container = document.createElement('div');
  171. container.className = 'ghsn-container';
  172. if (!currentNote) {
  173. container.classList.add('ghsn-hidden');
  174. }
  175. const displaySpan = document.createElement('span');
  176. displaySpan.className = 'ghsn-display';
  177. displaySpan.textContent = currentNote;
  178. if (!currentNote) {
  179. displaySpan.classList.add('ghsn-hidden');
  180. }
  181. const noteTextarea = document.createElement('textarea');
  182. noteTextarea.className = 'ghsn-textarea ghsn-hidden';
  183. noteTextarea.placeholder = NOTE_PLACEHOLDER;
  184. const buttonsDiv = document.createElement('div');
  185. buttonsDiv.className = 'ghsn-buttons ghsn-hidden';
  186. const saveButton = document.createElement('button');
  187. saveButton.textContent = SAVE_BUTTON_TEXT;
  188. saveButton.className = 'ghsn-save';
  189. const cancelButton = document.createElement('button');
  190. cancelButton.textContent = CANCEL_BUTTON_TEXT;
  191. cancelButton.className = 'ghsn-cancel';
  192. const deleteButton = document.createElement('button');
  193. deleteButton.textContent = DELETE_BUTTON_TEXT;
  194. deleteButton.className = 'ghsn-delete';
  195. noteButton.addEventListener('click', (e) => {
  196. e.preventDefault();
  197. const isEditing = !noteTextarea.classList.contains('ghsn-hidden');
  198. if (!isEditing) {
  199. noteTextarea.value = currentNote;
  200. displaySpan.classList.add('ghsn-hidden');
  201. noteTextarea.classList.remove('ghsn-hidden');
  202. buttonsDiv.classList.remove('ghsn-hidden');
  203. if (currentNote) {
  204. deleteButton.classList.remove('ghsn-hidden');
  205. } else {
  206. deleteButton.classList.add('ghsn-hidden');
  207. }
  208. container.classList.remove('ghsn-hidden');
  209. noteTextarea.focus();
  210. } else {
  211. cancelButton.click();
  212. }
  213. });
  214. cancelButton.addEventListener('click', () => {
  215. noteTextarea.classList.add('ghsn-hidden');
  216. buttonsDiv.classList.add('ghsn-hidden');
  217. if (currentNote) {
  218. displaySpan.textContent = currentNote;
  219. displaySpan.classList.remove('ghsn-hidden');
  220. container.classList.remove('ghsn-hidden');
  221. } else {
  222. container.classList.add('ghsn-hidden');
  223. }
  224. });
  225. saveButton.addEventListener('click', async () => {
  226. const newNote = noteTextarea.value.trim();
  227. await GM_setValue(storageKey, newNote);
  228. currentNote = newNote;
  229. noteButton.updateText(!!newNote);
  230. if (newNote) {
  231. displaySpan.textContent = newNote;
  232. displaySpan.classList.remove('ghsn-hidden');
  233. container.classList.remove('ghsn-hidden');
  234. } else {
  235. displaySpan.classList.add('ghsn-hidden');
  236. container.classList.add('ghsn-hidden');
  237. await GM_deleteValue(storageKey);
  238. }
  239. noteTextarea.classList.add('ghsn-hidden');
  240. buttonsDiv.classList.add('ghsn-hidden');
  241. });
  242. deleteButton.addEventListener('click', async () => {
  243. if (window.confirm(`Are you sure you want to delete the note for "${repoFullName}"?`)) {
  244. await GM_deleteValue(storageKey);
  245. currentNote = '';
  246. noteButton.updateText(false);
  247. displaySpan.classList.add('ghsn-hidden');
  248. noteTextarea.classList.add('ghsn-hidden');
  249. buttonsDiv.classList.add('ghsn-hidden');
  250. container.classList.add('ghsn-hidden');
  251. }
  252. });
  253. buttonsDiv.appendChild(deleteButton);
  254. buttonsDiv.appendChild(saveButton);
  255. buttonsDiv.appendChild(cancelButton);
  256. container.appendChild(displaySpan);
  257. container.appendChild(noteTextarea);
  258. container.appendChild(buttonsDiv);
  259. // 修改这里:将按钮作为starRow的最后一个元素
  260. starRow.appendChild(noteButton);
  261. const description = repoElement.querySelector('p.color-fg-muted');
  262. const topics = repoElement.querySelector('.topic-tag-list');
  263. const insertAfterElement = topics || description || repoElement.querySelector('h3, h2');
  264. if (insertAfterElement && insertAfterElement.parentNode) {
  265. insertAfterElement.parentNode.insertBefore(container, insertAfterElement.nextSibling);
  266. } else {
  267. repoElement.appendChild(container);
  268. console.warn(`RepoNotes: Could not find ideal insertion point for note container in repo: ${repoFullName}. Appending to end.`);
  269. }
  270. }
  271. // --- Process all repositories on the page ---
  272. function processRepositories() {
  273. const repoSelector = 'div.col-12.d-block.width-full.py-4.border-bottom.color-border-muted, article.Box-row';
  274. const repoElements = document.querySelectorAll(repoSelector);
  275. // console.log(`RepoNotes: Found ${repoElements.length} repository elements.`);
  276. if (repoElements.length === 0) {
  277. // console.log("RepoNotes: No repository elements found with selector:", repoSelector);
  278. const fallbackSelector = 'li[data-view-component="true"].Box-row';
  279. const fallbackElements = document.querySelectorAll(fallbackSelector);
  280. fallbackElements.forEach(addNoteUI);
  281. } else {
  282. repoElements.forEach(addNoteUI);
  283. }
  284. }
  285.  
  286. // --- Observe DOM changes (handle dynamic loading like infinite scroll) ---
  287. let observer = null;
  288.  
  289. function setupObserver() {
  290. if (observer) {
  291. observer.disconnect();
  292. }
  293.  
  294. const targetNode = document.getElementById('user-repositories-list') || document.querySelector('main') || document.body;
  295.  
  296. if (!targetNode) {
  297. console.error('RepoNotes: Could not find target node for MutationObserver.');
  298. return;
  299. }
  300. // console.log('RepoNotes: Setting up MutationObserver on target:', targetNode);
  301.  
  302. observer = new MutationObserver(mutations => {
  303. // console.log('RepoNotes: MutationObserver detected changes.');
  304. let needsProcessing = false;
  305. mutations.forEach(mutation => {
  306. mutation.addedNodes.forEach(node => {
  307. if (node.nodeType === 1) {
  308. 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';
  309. if (node.matches(repoSelector)) {
  310. // console.log('RepoNotes: Added node matches repo selector:', node);
  311. addNoteUI(node);
  312. needsProcessing = true;
  313. } else {
  314. const nestedRepos = node.querySelectorAll(repoSelector);
  315. if (nestedRepos.length > 0) {
  316. // console.log(`RepoNotes: Found ${nestedRepos.length} nested repos in added node:`, node);
  317. nestedRepos.forEach(addNoteUI);
  318. needsProcessing = true;
  319. }
  320. }
  321. }
  322. });
  323. });
  324. });
  325.  
  326. observer.observe(targetNode, {
  327. childList: true,
  328. subtree: true
  329. });
  330. }
  331.  
  332. // --- Startup and Navigation Handling ---
  333.  
  334. function initializeOrReinitialize() {
  335. if (window.location.search.includes('tab=stars') || document.querySelector('div.col-12.d-block.width-full.py-4') || document.querySelector('article.Box-row')) {
  336. // console.log('RepoNotes: Running processRepositories.');
  337. processRepositories();
  338. // console.log('RepoNotes: Setting up observer.');
  339. setupObserver();
  340. } else {
  341. // console.log('RepoNotes: Not on a relevant page, skipping processing and observer setup.');
  342. if(observer) {
  343. observer.disconnect();
  344. // console.log('RepoNotes: Disconnected observer.');
  345. }
  346. }
  347. }
  348.  
  349. document.addEventListener('turbo:load', () => {
  350. // console.log('RepoNotes: turbo:load event detected.');
  351. initializeOrReinitialize();
  352. });
  353.  
  354. if (document.readyState === 'loading') {
  355. document.addEventListener('DOMContentLoaded', initializeOrReinitialize);
  356. } else {
  357. initializeOrReinitialize();
  358. }
  359.  
  360. })();