GitHub 仓库备注工具

为 GitHub 仓库添加本地备注

当前为 2025-05-15 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name GitHub Repo Notes
  3. // @name:zh-CN GitHub 仓库备注工具
  4. // @namespace http://tampermonkey.net/
  5. // @version 1.2
  6. // @description Add local notes to GitHub repository
  7. // @description:zh-CN 为 GitHub 仓库添加本地备注
  8. // @author Ivans
  9. // @match https://github.com/*
  10. // @grant none
  11. // @icon https://cdn.simpleicons.org/github/808080
  12. // @license MIT
  13. // @supportURL https://github.com/Ivans-11/github-repo-notes/issues
  14. // ==/UserScript==
  15.  
  16. (function() {
  17. 'use strict';
  18.  
  19. const NOTE_KEY_PREFIX = 'gh_repo_note_';
  20.  
  21. // Get the full name of the repository
  22. function getRepoFullName(card) {
  23. const link = card.querySelector('h3 a[itemprop="name codeRepository"]') ||
  24. card.querySelector('h3 a') ||
  25. card.querySelector('.search-title a') ||
  26. card.querySelector('a.Link--primary.Link.text-bold[data-hovercard-type="repository"]');
  27. if (!link) return null;
  28. const href = link.getAttribute('href');
  29. if (!href) return null;
  30. return href.substring(1);
  31. }
  32.  
  33. // Get the note from local storage
  34. function getNote(repoFullName) {
  35. // Convert to lowercase
  36. return localStorage.getItem(NOTE_KEY_PREFIX + repoFullName.toLowerCase()) || '';
  37. }
  38. // Set the note to local storage
  39. function setNote(repoFullName, note) {
  40. if (note) {
  41. localStorage.setItem(NOTE_KEY_PREFIX + repoFullName.toLowerCase(), note);
  42. } else {
  43. localStorage.removeItem(NOTE_KEY_PREFIX + repoFullName.toLowerCase());
  44. }
  45. }
  46.  
  47. // Create the note button
  48. function createNoteButton(repoFullName, note, onClick) {
  49. const btn = document.createElement('button');
  50. const icon = document.createElement('span');
  51. icon.innerHTML = `<svg aria-hidden="true" height="16" viewBox="0 0 16 16" version="1.1" width="16" style="vertical-align: text-bottom; margin-right: 8px; fill: var(--fgColor-muted,var(--color-fg-muted));">
  52. <path d="M0 1.75C0 .784.784 0 1.75 0h12.5C15.216 0 16 .784 16 1.75v9.5A1.75 1.75 0 0 1 14.25 13H8.06l-2.573 2.573A1.458 1.458 0 0 1 3 14.543V13H1.75A1.75 1.75 0 0 1 0 11.25Zm1.75-.25a.25.25 0 0 0-.25.25v9.5c0 .138.112.25.25.25h2a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h6.5a.25.25 0 0 0 .25-.25v-9.5a.25.25 0 0 0-.25-.25Z"></path>
  53. </svg>`;
  54. btn.appendChild(icon);
  55. btn.appendChild(document.createTextNode(note ? 'Edit' : 'Add'));
  56. btn.style.margin = '4px';
  57. btn.style.borderRadius = '6px';
  58. btn.style.padding = '2px 8px';
  59. btn.style.fontSize = '12px';
  60. btn.style.cursor = 'pointer';
  61. btn.style.fontFamily = 'var(--fontStack-sansSerif)';
  62. btn.style.lineHeight = '20px';
  63. btn.style.fontWeight = '600';
  64. btn.style.color = 'var(--button-default-fgColor-rest, var(--color-btn-text))';
  65. btn.style.backgroundColor = 'var(--button-default-bgColor-rest, var(--color-btn-bg))';
  66. btn.style.border = '1px solid var(--button-default-borderColor-rest,var(--color-btn-border))';
  67. btn.style.display = 'flex';
  68. btn.style.alignItems = 'center';
  69. btn.style.height = 'var(--control-small-size,1.75rem)';
  70. btn.addEventListener('click', onClick);
  71. return btn;
  72. }
  73.  
  74. // Create the note display
  75. function createNoteDisplay(note) {
  76. const div = document.createElement('div');
  77. const icon = document.createElement('span');
  78. icon.innerHTML = `<svg aria-hidden="true" height="16" viewBox="0 0 16 16" version="1.1" width="16" style="vertical-align: text-bottom; margin-right: 8px; fill: var(--fgColor-muted,var(--color-fg-muted));">
  79. <path d="M0 1.75C0 .784.784 0 1.75 0h12.5C15.216 0 16 .784 16 1.75v9.5A1.75 1.75 0 0 1 14.25 13H8.06l-2.573 2.573A1.458 1.458 0 0 1 3 14.543V13H1.75A1.75 1.75 0 0 1 0 11.25Zm1.75-.25a.25.25 0 0 0-.25.25v9.5c0 .138.112.25.25.25h2a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h6.5a.25.25 0 0 0 .25-.25v-9.5a.25.25 0 0 0-.25-.25Z"></path>
  80. </svg>`;
  81. div.appendChild(icon);
  82. div.appendChild(document.createTextNode(note));
  83. div.style.borderRadius = '6px';
  84. div.style.padding = '4px 8px';
  85. div.style.marginTop = '2px';
  86. div.style.marginBottom = '6px';
  87. div.style.fontSize = '13px';
  88. div.style.fontFamily = 'var(--fontStack-sansSerif)';
  89. div.style.lineHeight = '20px';
  90. div.style.display = 'flex';
  91. div.style.alignItems = 'center';
  92. return div;
  93. }
  94.  
  95. // Prompt the user to input the note
  96. function promptNote(oldNote) {
  97. let note = prompt('Please input your notes (leave blank to be deleted):', oldNote || '');
  98. if (note === null) return undefined;
  99. note = note.trim();
  100. return note;
  101. }
  102.  
  103. // Insert the button and note on the card
  104. function enhanceCard(card) {
  105. if (card.dataset.noteEnhanced) return; // Avoid duplicate
  106. const repoFullName = getRepoFullName(card);
  107. if (!repoFullName) return;
  108. card.dataset.noteEnhanced = '1';
  109. // Find the star button
  110. let starBtn = card.querySelector('.js-toggler-container.js-social-container.starring-container, .Box-sc-g0xbh4-0.fvaNTI');
  111. if (!starBtn) return;
  112. // Create the button
  113. let note = getNote(repoFullName);
  114. let btn = createNoteButton(repoFullName, note, function() {
  115. let newNote = promptNote(note);
  116. if (typeof newNote === 'undefined') return;
  117. setNote(repoFullName, newNote);
  118. // Re-render
  119. card.dataset.noteEnhanced = '';
  120. enhanceCard(card);
  121. });
  122. // Insert the button
  123. if (starBtn.parentElement) {
  124. // Avoid duplicate insertion
  125. let oldBtn = starBtn.parentElement.querySelector('.gh-note-btn');
  126. if (oldBtn) oldBtn.remove();
  127. btn.classList.add('gh-note-btn');
  128. starBtn.parentElement.appendChild(btn);
  129. }
  130. // Note display
  131. let oldNoteDiv = card.querySelector('.gh-note-display');
  132. if (oldNoteDiv) oldNoteDiv.remove();
  133. if (note) {
  134. let noteDiv = createNoteDisplay(note);
  135. noteDiv.classList.add('gh-note-display');
  136. // Put it before the data bar
  137. let dataInfo = card.querySelector('.f6.color-fg-muted.mt-0.mb-0.width-full, .f6.color-fg-muted.mt-2, .Box-sc-g0xbh4-0.bZkODq');
  138. if (dataInfo && dataInfo.parentElement) {
  139. dataInfo.parentElement.insertBefore(noteDiv, dataInfo);
  140. }
  141. }
  142. }
  143.  
  144. // Select all repository cards
  145. function findAllRepoCards() {
  146. // Adapt to multiple card structures
  147. let cards = Array.from(document.querySelectorAll(`
  148. .col-12.d-block.width-full.py-4.border-bottom.color-border-muted,
  149. li.col-12.d-flex.flex-justify-between.width-full.py-4.border-bottom.color-border-muted,
  150. .Box-sc-g0xbh4-0.iwUbcA,
  151. .Box-sc-g0xbh4-0.flszRz,
  152. .Box-sc-g0xbh4-0.jbaXRR,
  153. .Box-sc-g0xbh4-0.bmHqGc,
  154. .Box-sc-g0xbh4-0.hFxojJ,
  155. section[aria-label="card content"]
  156. `));
  157. // Filter out cards without a repository full name
  158. return cards.filter(card => getRepoFullName(card));
  159. }
  160.  
  161. // Determine if it is a repository page
  162. function isRepoPage() {
  163. const path = window.location.pathname;
  164. const parts = path.split('/').filter(Boolean);
  165. return (parts.length === 2 || (parts.length === 4 && parts[2] === 'tree')) &&
  166. !path.includes('/blob/') &&
  167. !path.includes('/issues/') &&
  168. !path.includes('/pulls/');
  169. }
  170.  
  171. // Repository page processing function
  172. function enhanceRepoPage() {
  173. if (document.body.dataset.noteEnhanced) return; // Avoid duplicate
  174. // Get the repository name from the link
  175. const path = window.location.pathname;
  176. const parts = path.split('/').filter(Boolean);
  177. const repoFullName = parts.slice(0, 2).join('/');
  178. if (!repoFullName) return;
  179. document.body.dataset.noteEnhanced = '1';
  180.  
  181. // Create the first button
  182. let note = getNote(repoFullName);
  183. let btn = createNoteButton(repoFullName, note, function() {
  184. let newNote = promptNote(note);
  185. if (typeof newNote === 'undefined') return;
  186. setNote(repoFullName, newNote);
  187. // Re-render
  188. document.body.dataset.noteEnhanced = '';
  189. enhanceRepoPage();
  190. });
  191.  
  192. // Find the button bar
  193. let actionsList = document.querySelector('.pagehead-actions');
  194. if (actionsList) {
  195. // Create a new li element
  196. let li = document.createElement('li');
  197. li.appendChild(btn);
  198.  
  199. // Avoid duplicate insertion
  200. let oldLi = actionsList.querySelector('.gh-note-li');
  201. if (oldLi) oldLi.remove();
  202. li.classList.add('gh-note-li');
  203.  
  204. // Add to the button bar
  205. actionsList.appendChild(li);
  206. }
  207.  
  208. // Create the second button
  209. let btn2 = createNoteButton(repoFullName, note, function() {
  210. let newNote = promptNote(note);
  211. if (typeof newNote === 'undefined') return;
  212. setNote(repoFullName, newNote);
  213. // Re-render
  214. document.body.dataset.noteEnhanced = '';
  215. enhanceRepoPage();
  216. });
  217.  
  218. // Find the container
  219. let container = document.querySelector('.container-xl:not(.d-flex):not(.clearfix)');
  220. if (container) {
  221. // Find the star button
  222. let starBtn = container.querySelector('div[data-view-component="true"].js-toggler-container.starring-container');
  223. if (starBtn && starBtn.parentElement) {
  224. // Avoid duplicate insertion
  225. let oldBtn = starBtn.parentElement.querySelector('.gh-note-btn');
  226. if (oldBtn) oldBtn.remove();
  227. let newBtn = btn2;
  228. newBtn.classList.add('gh-note-btn');
  229. starBtn.parentElement.appendChild(newBtn);
  230. }
  231. }
  232.  
  233. // Remove the old note display
  234. let oldNoteDiv = document.querySelector('.gh-note-display');
  235. if (oldNoteDiv) oldNoteDiv.remove();
  236. let oldNoteDiv2 = document.querySelector('.gh-note-display2');
  237. if (oldNoteDiv2) oldNoteDiv2.remove();
  238. if (note) {
  239. // Create the first note display
  240. let noteDiv = createNoteDisplay(note);
  241. noteDiv.classList.add('gh-note-display');
  242. // Find the description
  243. let description = document.querySelector('.f4.my-3, .f4.my-3.color-fg-muted.text-italic');
  244. if (description && description.parentElement) {
  245. description.parentElement.insertBefore(noteDiv, description.nextSibling);
  246. }
  247.  
  248. // Create the second note display
  249. let noteDiv2 = createNoteDisplay(note);
  250. noteDiv2.classList.add('gh-note-display2');
  251.  
  252. if (container) {
  253. // Try finding the description
  254. let newDescription = container.querySelector('p.f4.mb-3.color-fg-muted');
  255. if (newDescription && newDescription.parentElement) {
  256. newDescription.parentElement.insertBefore(noteDiv2, newDescription.nextSibling);
  257. } else {
  258. // If there is no description, find the flex container
  259. let flexContainer = container.querySelector('div.d-flex.gap-2.mt-n3.mb-3.flex-wrap');
  260. if (flexContainer && flexContainer.parentElement) {
  261. flexContainer.parentElement.insertBefore(noteDiv2, flexContainer.nextSibling);
  262. }
  263. }
  264. }
  265. }
  266. }
  267.  
  268. // Export notes data
  269. function exportNotes() {
  270. const notes = {};
  271. for (let i = 0; i < localStorage.length; i++) {
  272. const key = localStorage.key(i);
  273. if (key.startsWith(NOTE_KEY_PREFIX)) {
  274. notes[key] = localStorage.getItem(key);
  275. }
  276. }
  277. const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(notes));
  278. const downloadAnchorNode = document.createElement('a');
  279. downloadAnchorNode.setAttribute("href", dataStr);
  280. downloadAnchorNode.setAttribute("download", "github_repo_notes.json");
  281. document.body.appendChild(downloadAnchorNode);
  282. downloadAnchorNode.click();
  283. downloadAnchorNode.remove();
  284. }
  285.  
  286. // Import notes data
  287. function importNotes() {
  288. const input = document.createElement('input');
  289. input.type = 'file';
  290. input.accept = '.json';
  291. input.onchange = (e) => {
  292. const file = e.target.files[0];
  293. if (file) {
  294. const reader = new FileReader();
  295. reader.onload = (event) => {
  296. try {
  297. const notes = JSON.parse(event.target.result);
  298. for (const key in notes) {
  299. if (notes.hasOwnProperty(key) && key.startsWith(NOTE_KEY_PREFIX)) {
  300. localStorage.setItem(key, notes[key]);
  301. }
  302. }
  303. alert('Import successfully!');
  304. enhanceAll();
  305. } catch (error) {
  306. alert('Error:' + error.message);
  307. }
  308. };
  309. reader.readAsText(file);
  310. }
  311. };
  312. input.click();
  313. }
  314.  
  315. // Clear all notes data
  316. function clearNotes() {
  317. for (let i = 0; i < localStorage.length; i++) {
  318. const key = localStorage.key(i);
  319. if (key.startsWith(NOTE_KEY_PREFIX)) {
  320. localStorage.removeItem(key);
  321. }
  322. }
  323. }
  324.  
  325. // Create the floating button
  326. function createFloatingButton(text, onClick) {
  327. const btn = document.createElement('button');
  328. btn.textContent = text;
  329. btn.style.margin = '5px';
  330. btn.style.borderRadius = '6px';
  331. btn.style.backgroundColor = 'var(--button-default-bgColor-rest, var(--color-btn-bg))';
  332. btn.style.color = 'var(--button-default-fgColor-rest, var(--color-btn-text))';
  333. btn.style.border = '1px solid var(--button-default-borderColor-rest,var(--color-btn-border))';
  334. btn.addEventListener('click', onClick);
  335. return btn;
  336. }
  337.  
  338. // Create the bottom floating button
  339. function createBottomButton() {
  340. if (document.querySelector('.gh-import-export-btn')) return; // Avoid duplicate
  341. const bottomBtn = document.createElement('button');
  342. bottomBtn.textContent = '☰';
  343. bottomBtn.classList.add('gh-import-export-btn');
  344. bottomBtn.style.position = 'fixed';
  345. bottomBtn.style.bottom = '20px';
  346. bottomBtn.style.right = '20px';
  347. bottomBtn.style.zIndex = '1000';
  348. bottomBtn.style.padding = '10px 20px';
  349. bottomBtn.style.backgroundColor = 'var(--button-default-bgColor-rest, var(--color-btn-bg))';
  350. bottomBtn.style.color = 'var(--button-default-fgColor-rest, var(--color-btn-text))';
  351. bottomBtn.style.border = '1px solid var(--button-default-borderColor-rest,var(--color-btn-border))';
  352. bottomBtn.style.borderRadius = '50%';
  353. bottomBtn.style.fontSize = '14px';
  354. bottomBtn.style.cursor = 'pointer';
  355. bottomBtn.addEventListener('click', () => {
  356. if (document.querySelector('.gh-import-export-dialog')) {
  357. document.querySelector('.gh-import-export-dialog').remove();
  358. bottomBtn.textContent = '☰';
  359. bottomBtn.style.borderRadius = '50%';
  360. return;
  361. }
  362.  
  363. // Expand the button
  364. bottomBtn.textContent = 'Import/Export notes data';
  365. bottomBtn.style.borderRadius = '8px';
  366.  
  367. // Create the dialog
  368. const dialog = document.createElement('div');
  369. dialog.classList.add('gh-import-export-dialog');
  370. dialog.style.position = 'fixed';
  371. dialog.style.bottom = '60px';
  372. dialog.style.right = '20px';
  373. dialog.style.backgroundColor = 'rgba(255, 255, 255, 0)';
  374. dialog.style.border = 'none';
  375. dialog.style.padding = '10px';
  376. dialog.style.zIndex = '1001';
  377.  
  378. const exportBtn = createFloatingButton('Export', () => {
  379. exportNotes();
  380. dialog.remove();
  381. });
  382.  
  383. const importBtn = createFloatingButton('Import', () => {
  384. importNotes();
  385. dialog.remove();
  386. });
  387.  
  388. const clearBtn = createFloatingButton('Clear', () => {
  389. if (!confirm('Are you sure you want to clear all notes?')) return;
  390. clearNotes();
  391. dialog.remove();
  392. });
  393.  
  394. dialog.appendChild(exportBtn);
  395. dialog.appendChild(importBtn);
  396. dialog.appendChild(clearBtn);
  397. document.body.appendChild(dialog);
  398. });
  399. document.body.appendChild(bottomBtn);
  400. }
  401.  
  402. // Initial processing
  403. function enhanceAll() {
  404. if (isRepoPage()) {
  405. enhanceRepoPage();
  406. } else {
  407. findAllRepoCards().forEach(enhanceCard);
  408. }
  409. createBottomButton();
  410. }
  411.  
  412. // Listen for DOM changes to adapt to dynamic loading
  413. const observer = new MutationObserver(() => {
  414. enhanceAll();
  415. });
  416. observer.observe(document.body, {childList: true, subtree: true});
  417.  
  418. // First load
  419. enhanceAll();
  420. })();