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