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