jetsung revised this gist 2 months ago. Go to revision
1 file changed, 306 insertions
github2framagit.user.js(file created)
| @@ -0,0 +1,306 @@ | |||
| 1 | + | // ==UserScript== | |
| 2 | + | // @name 备份 GitHub 仓库至 Framagit | |
| 3 | + | // @namespace https://docs.scriptcat.org/ | |
| 4 | + | // @version 0.1.0 | |
| 5 | + | // @description 在 GitHub 仓库页面添加“备份到 Framagit”按钮,点击后通过 API 创建项目 | |
| 6 | + | // @author Jetsung Chan <[email protected]> | |
| 7 | + | // @match https://github.com/*/* | |
| 8 | + | // @icon https://www.google.com/s2/favicons?sz=64&domain=github.com | |
| 9 | + | // @grant GM_xmlhttpRequest | |
| 10 | + | // @grant GM_getValue | |
| 11 | + | // @grant GM_setValue | |
| 12 | + | // @connect framagit.org | |
| 13 | + | // @run-at document-end | |
| 14 | + | // ==/UserScript== | |
| 15 | + | ||
| 16 | + | (async function() { | |
| 17 | + | 'use strict'; | |
| 18 | + | ||
| 19 | + | const FRAMAGIT_BASE = 'https://framagit.org'; | |
| 20 | + | const API_BASE = FRAMAGIT_BASE + '/api/v4'; | |
| 21 | + | ||
| 22 | + | // ---------------- 配置读取与首次设置 ---------------- | |
| 23 | + | let config = { | |
| 24 | + | namespaceId: GM_getValue('framagit_namespaceId', null), | |
| 25 | + | namespaceName: GM_getValue('framagit_namespaceName', null), | |
| 26 | + | token: GM_getValue('framagit_token', null) | |
| 27 | + | }; | |
| 28 | + | ||
| 29 | + | // 首次使用或配置缺失时弹出设置 | |
| 30 | + | if (!config.namespaceId || !config.namespaceName || !config.token) { | |
| 31 | + | const nsId = prompt( | |
| 32 | + | '请输入 Framagit 组织/组的 Namespace ID(数字)', | |
| 33 | + | config.namespaceId || '224838' | |
| 34 | + | ); | |
| 35 | + | const nsName = prompt( | |
| 36 | + | '请输入 Framagit 组织/组的路径名称(path)', | |
| 37 | + | config.namespaceName || 'fork' | |
| 38 | + | ); | |
| 39 | + | const tokenInput = prompt( | |
| 40 | + | '请输入你的 Framagit Personal Access Token', | |
| 41 | + | '' | |
| 42 | + | ); | |
| 43 | + | ||
| 44 | + | if (nsId && nsName && tokenInput) { | |
| 45 | + | GM_setValue('framagit_namespaceId', parseInt(nsId, 10)); | |
| 46 | + | GM_setValue('framagit_namespaceName', nsName.trim()); | |
| 47 | + | GM_setValue('framagit_token', tokenInput.trim()); | |
| 48 | + | config = { | |
| 49 | + | namespaceId: parseInt(nsId, 10), | |
| 50 | + | namespaceName: nsName.trim(), | |
| 51 | + | token: tokenInput.trim() | |
| 52 | + | }; | |
| 53 | + | showToast('配置已保存', 'success'); | |
| 54 | + | } else { | |
| 55 | + | showToast('配置取消或不完整,脚本无法使用', 'error'); | |
| 56 | + | return; | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // ---------------- Toast 通知函数 ---------------- | |
| 61 | + | function showToast(message, type = 'info', duration = 4000) { | |
| 62 | + | const toast = document.createElement('div'); | |
| 63 | + | toast.style.position = 'fixed'; | |
| 64 | + | toast.style.top = '16px'; | |
| 65 | + | toast.style.left = '50%'; | |
| 66 | + | toast.style.transform = 'translateX(-50%)'; | |
| 67 | + | toast.style.zIndex = '999999'; | |
| 68 | + | toast.style.padding = '12px 24px'; | |
| 69 | + | toast.style.borderRadius = '6px'; | |
| 70 | + | toast.style.fontSize = '14px'; | |
| 71 | + | toast.style.fontWeight = '500'; | |
| 72 | + | toast.style.maxWidth = '80%'; | |
| 73 | + | toast.style.boxShadow = '0 4px 12px rgba(0,0,0,0.15)'; | |
| 74 | + | toast.style.transition = 'all 0.3s ease'; | |
| 75 | + | toast.style.opacity = '0'; | |
| 76 | + | toast.style.pointerEvents = 'auto'; | |
| 77 | + | toast.style.whiteSpace = 'pre-wrap'; // 保留换行,方便显示多行文本 | |
| 78 | + | toast.style.wordBreak = 'break-all'; // 长 URL 自动换行 | |
| 79 | + | ||
| 80 | + | // 根据类型设置颜色(模仿 GitHub 风格) | |
| 81 | + | if (type === 'success') { | |
| 82 | + | toast.style.backgroundColor = '#2ea44f'; | |
| 83 | + | toast.style.color = 'white'; | |
| 84 | + | } else if (type === 'error') { | |
| 85 | + | toast.style.backgroundColor = '#d73a49'; | |
| 86 | + | toast.style.color = 'white'; | |
| 87 | + | } else if (type === 'warning') { | |
| 88 | + | toast.style.backgroundColor = '#dbab09'; | |
| 89 | + | toast.style.color = '#1c1e26'; | |
| 90 | + | } else { | |
| 91 | + | toast.style.backgroundColor = '#0366d6'; | |
| 92 | + | toast.style.color = 'white'; | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | // 支持链接 | |
| 96 | + | toast.innerHTML = message.replace( | |
| 97 | + | /(https?:\/\/[^\s<]+)/g, | |
| 98 | + | '<a href="$1" target="_blank" style="color:inherit;text-decoration:underline;">$1</a>' | |
| 99 | + | ); | |
| 100 | + | ||
| 101 | + | document.body.appendChild(toast); | |
| 102 | + | ||
| 103 | + | // 淡入 | |
| 104 | + | setTimeout(() => { | |
| 105 | + | toast.style.opacity = '1'; | |
| 106 | + | }, 10); | |
| 107 | + | ||
| 108 | + | // 自动消失 | |
| 109 | + | setTimeout(() => { | |
| 110 | + | toast.style.opacity = '0'; | |
| 111 | + | setTimeout(() => toast.remove(), 300); | |
| 112 | + | }, duration); | |
| 113 | + | } | |
| 114 | + | // ---------------- gmFetch 封装 ---------------- | |
| 115 | + | function gmFetch(url, options = {}) { | |
| 116 | + | return new Promise((resolve, reject) => { | |
| 117 | + | GM_xmlhttpRequest({ | |
| 118 | + | method: options.method || 'GET', | |
| 119 | + | url, | |
| 120 | + | headers: { | |
| 121 | + | ...options.headers, | |
| 122 | + | }, | |
| 123 | + | data: options.body, | |
| 124 | + | responseType: 'json', | |
| 125 | + | anonymous: true, | |
| 126 | + | timeout: 15000, | |
| 127 | + | onload: (response) => { | |
| 128 | + | const resp = { | |
| 129 | + | ok: response.status >= 200 && response.status < 300, | |
| 130 | + | status: response.status, | |
| 131 | + | json: () => { | |
| 132 | + | try { return JSON.parse(response.responseText); } catch { return {}; } | |
| 133 | + | }, | |
| 134 | + | text: () => Promise.resolve(response.responseText || ''), | |
| 135 | + | }; | |
| 136 | + | resolve(resp); | |
| 137 | + | }, | |
| 138 | + | onerror: reject, | |
| 139 | + | ontimeout: () => reject(new Error('请求超时')), | |
| 140 | + | }); | |
| 141 | + | }); | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | function getRepoInfo() { | |
| 145 | + | const pathParts = location.pathname.split('/').filter(Boolean); | |
| 146 | + | if (pathParts.length < 2) return null; | |
| 147 | + | const owner = pathParts[0]; | |
| 148 | + | const repo = pathParts[1]; | |
| 149 | + | ||
| 150 | + | let description = ''; | |
| 151 | + | const descEl = document.querySelector('.BorderGrid .f4'); | |
| 152 | + | if (descEl) description = descEl.textContent.trim(); | |
| 153 | + | ||
| 154 | + | return { owner, repo, description }; | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | async function projectExists(repoName) { | |
| 158 | + | const encodedPath = `${config.namespaceName}%2F${encodeURIComponent(repoName)}`; | |
| 159 | + | const url = `${API_BASE}/projects/${encodedPath}`; | |
| 160 | + | ||
| 161 | + | try { | |
| 162 | + | const resp = await gmFetch(url, { | |
| 163 | + | method: 'GET', | |
| 164 | + | headers: { 'PRIVATE-TOKEN': config.token }, | |
| 165 | + | }); | |
| 166 | + | ||
| 167 | + | if (resp.status === 200) { | |
| 168 | + | return { exists: true, project: await resp.json() }; | |
| 169 | + | } | |
| 170 | + | if (resp.status === 404) { | |
| 171 | + | return { exists: false }; | |
| 172 | + | } | |
| 173 | + | const errText = await resp.text(); | |
| 174 | + | throw new Error(`检查失败 ${resp.status} - ${errText}`); | |
| 175 | + | } catch (err) { | |
| 176 | + | console.error('检查项目是否存在失败', err); | |
| 177 | + | return { exists: false, error: err.message }; | |
| 178 | + | } | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | async function createFramagitProject(owner, repoName, description) { | |
| 182 | + | const repoDescription = `::${owner}/${repoName} [GitHub](https://github.com/${owner}/${repoName}) | ` + (description || ''); | |
| 183 | + | const url = `${API_BASE}/projects`; | |
| 184 | + | ||
| 185 | + | const payload = { | |
| 186 | + | name: repoName, | |
| 187 | + | path: repoName, | |
| 188 | + | visibility: 'private', | |
| 189 | + | description: repoDescription, | |
| 190 | + | namespace_id: config.namespaceId, | |
| 191 | + | }; | |
| 192 | + | ||
| 193 | + | const resp = await gmFetch(url, { | |
| 194 | + | method: 'POST', | |
| 195 | + | headers: { | |
| 196 | + | 'PRIVATE-TOKEN': config.token, | |
| 197 | + | 'Content-Type': 'application/json', | |
| 198 | + | }, | |
| 199 | + | body: JSON.stringify(payload), | |
| 200 | + | }); | |
| 201 | + | ||
| 202 | + | if (resp.ok) { | |
| 203 | + | return await resp.json(); | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | // 处理非 2xx 响应 | |
| 207 | + | const err = await resp.json().catch(() => ({})); | |
| 208 | + | throw new Error(err.message || err.error || `HTTP ${resp.status}`); | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | function addButton() { | |
| 212 | + | let actionsUl = document.querySelector('ul.pagehead-actions'); | |
| 213 | + | if (!actionsUl) return; | |
| 214 | + | if (document.getElementById('btn-to-framagit')) return; | |
| 215 | + | ||
| 216 | + | const li = document.createElement('li'); | |
| 217 | + | li.style.marginLeft = '4px'; | |
| 218 | + | ||
| 219 | + | const btn = document.createElement('button'); | |
| 220 | + | btn.id = 'btn-to-framagit'; | |
| 221 | + | btn.type = 'button'; | |
| 222 | + | btn.className = 'btn-sm btn BtnGroup-item'; | |
| 223 | + | btn.innerHTML = ` | |
| 224 | + | <svg aria-hidden="true" height="16" viewBox="0 0 16 16" width="16" class="octicon octicon-repo-forked mr-1"> | |
| 225 | + | <path d="M5 5.372v.878c0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75v-.878a2.25 2.25 0 1 1 1.5 0v.878a2.25 2.25 0 0 1-2.25 2.25h-1.5v2.128a2.251 2.251 0 1 1-1.5 0V8.5h-1.5A2.25 2.25 0 0 1 3.5 6.25v-.878a2.25 2.25 0 1 1 1.5 0ZM5 3.25a.75.75 0 1 0-1.5 0 .75.75 0 0 0 1.5 0Zm6.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Zm-3 8.75a.75.75 0 1 0-1.5 0 .75.75 0 0 0 1.5 0Z"></path> | |
| 226 | + | </svg> | |
| 227 | + | 备份到 Framagit | |
| 228 | + | `; | |
| 229 | + | btn.title = "在 Framagit 创建同名项目(仅创建项目,不自动导入代码)"; | |
| 230 | + | ||
| 231 | + | btn.addEventListener('click', async () => { | |
| 232 | + | const info = getRepoInfo(); | |
| 233 | + | if (!info) { | |
| 234 | + | showToast('无法获取当前仓库信息', 'error'); | |
| 235 | + | return; | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | const { owner, repo, description } = info; | |
| 239 | + | // ★ 保存原始按钮内容(图标 + 文字) | |
| 240 | + | const originalHTML = btn.innerHTML; | |
| 241 | + | ||
| 242 | + | btn.disabled = true; | |
| 243 | + | btn.textContent = '检查中...'; | |
| 244 | + | ||
| 245 | + | const check = await projectExists(repo); | |
| 246 | + | ||
| 247 | + | if (check.exists) { | |
| 248 | + | const proj = check.project; | |
| 249 | + | const url = `${FRAMAGIT_BASE}/${proj.path_with_namespace}`; | |
| 250 | + | // 项目已存在时 | |
| 251 | + | showToast( | |
| 252 | + | `该项目已在 Framagit 存在!\n\n` + | |
| 253 | + | `项目名称:${proj.name}\n` + | |
| 254 | + | `地址:${url}\n\n` + | |
| 255 | + | `操作已取消。`, | |
| 256 | + | 'warning', | |
| 257 | + | 6000 | |
| 258 | + | ); | |
| 259 | + | btn.disabled = false; | |
| 260 | + | btn.innerHTML = originalHTML; | |
| 261 | + | return; | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | if (check.error) { | |
| 265 | + | showToast(`检查失败:${check.error}`, 'error'); | |
| 266 | + | btn.disabled = false; | |
| 267 | + | btn.innerHTML = originalHTML; | |
| 268 | + | return; | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | btn.textContent = '创建中...'; | |
| 272 | + | ||
| 273 | + | try { | |
| 274 | + | const data = await createFramagitProject(owner, repo, description); | |
| 275 | + | const url = `${FRAMAGIT_BASE}/${data.path_with_namespace}`; | |
| 276 | + | // 创建成功时 | |
| 277 | + | showToast( | |
| 278 | + | `创建成功!\n` + | |
| 279 | + | `项目地址:${url}`, | |
| 280 | + | 'success', | |
| 281 | + | 8000 | |
| 282 | + | ); | |
| 283 | + | } catch (err) { | |
| 284 | + | // 错误提示 | |
| 285 | + | showToast(`创建失败:${err.message}`, 'error', 6000); | |
| 286 | + | console.error(err); | |
| 287 | + | } finally { | |
| 288 | + | btn.disabled = false; | |
| 289 | + | btn.innerHTML = originalHTML; | |
| 290 | + | } | |
| 291 | + | }); | |
| 292 | + | ||
| 293 | + | li.appendChild(btn); | |
| 294 | + | actionsUl.appendChild(li); | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | setTimeout(addButton, 1200); | |
| 298 | + | ||
| 299 | + | const observer = new MutationObserver(() => { | |
| 300 | + | if (document.querySelector('ul.pagehead-actions')) { | |
| 301 | + | addButton(); | |
| 302 | + | } | |
| 303 | + | }); | |
| 304 | + | observer.observe(document.body, { childList: true, subtree: true }); | |
| 305 | + | ||
| 306 | + | })(); | |
Newer
Older