// ==UserScript== // @name 备份 GitHub 仓库至 Framagit // @namespace https://docs.scriptcat.org/ // @version 0.1.0 // @description 在 GitHub 仓库页面添加“备份到 Framagit”按钮,点击后通过 API 创建项目 // @author Jetsung Chan // @match https://github.com/*/* // @icon https://www.google.com/s2/favicons?sz=64&domain=github.com // @grant GM_xmlhttpRequest // @grant GM_getValue // @grant GM_setValue // @connect framagit.org // @run-at document-end // ==/UserScript== (async function() { 'use strict'; const FRAMAGIT_BASE = 'https://framagit.org'; const API_BASE = FRAMAGIT_BASE + '/api/v4'; // ---------------- 配置读取与首次设置 ---------------- let config = { namespaceId: GM_getValue('framagit_namespaceId', null), namespaceName: GM_getValue('framagit_namespaceName', null), token: GM_getValue('framagit_token', null) }; // 首次使用或配置缺失时弹出设置 if (!config.namespaceId || !config.namespaceName || !config.token) { const nsId = prompt( '请输入 Framagit 组织/组的 Namespace ID(数字)', config.namespaceId || '224838' ); const nsName = prompt( '请输入 Framagit 组织/组的路径名称(path)', config.namespaceName || 'fork' ); const tokenInput = prompt( '请输入你的 Framagit Personal Access Token', '' ); if (nsId && nsName && tokenInput) { GM_setValue('framagit_namespaceId', parseInt(nsId, 10)); GM_setValue('framagit_namespaceName', nsName.trim()); GM_setValue('framagit_token', tokenInput.trim()); config = { namespaceId: parseInt(nsId, 10), namespaceName: nsName.trim(), token: tokenInput.trim() }; showToast('配置已保存', 'success'); } else { showToast('配置取消或不完整,脚本无法使用', 'error'); return; } } // ---------------- Toast 通知函数 ---------------- function showToast(message, type = 'info', duration = 4000) { const toast = document.createElement('div'); toast.style.position = 'fixed'; toast.style.top = '16px'; toast.style.left = '50%'; toast.style.transform = 'translateX(-50%)'; toast.style.zIndex = '999999'; toast.style.padding = '12px 24px'; toast.style.borderRadius = '6px'; toast.style.fontSize = '14px'; toast.style.fontWeight = '500'; toast.style.maxWidth = '80%'; toast.style.boxShadow = '0 4px 12px rgba(0,0,0,0.15)'; toast.style.transition = 'all 0.3s ease'; toast.style.opacity = '0'; toast.style.pointerEvents = 'auto'; toast.style.whiteSpace = 'pre-wrap'; // 保留换行,方便显示多行文本 toast.style.wordBreak = 'break-all'; // 长 URL 自动换行 // 根据类型设置颜色(模仿 GitHub 风格) if (type === 'success') { toast.style.backgroundColor = '#2ea44f'; toast.style.color = 'white'; } else if (type === 'error') { toast.style.backgroundColor = '#d73a49'; toast.style.color = 'white'; } else if (type === 'warning') { toast.style.backgroundColor = '#dbab09'; toast.style.color = '#1c1e26'; } else { toast.style.backgroundColor = '#0366d6'; toast.style.color = 'white'; } // 支持链接 toast.innerHTML = message.replace( /(https?:\/\/[^\s<]+)/g, '$1' ); document.body.appendChild(toast); // 淡入 setTimeout(() => { toast.style.opacity = '1'; }, 10); // 自动消失 setTimeout(() => { toast.style.opacity = '0'; setTimeout(() => toast.remove(), 300); }, duration); } // ---------------- gmFetch 封装 ---------------- function gmFetch(url, options = {}) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: options.method || 'GET', url, headers: { ...options.headers, }, data: options.body, responseType: 'json', anonymous: true, timeout: 15000, onload: (response) => { const resp = { ok: response.status >= 200 && response.status < 300, status: response.status, json: () => { try { return JSON.parse(response.responseText); } catch { return {}; } }, text: () => Promise.resolve(response.responseText || ''), }; resolve(resp); }, onerror: reject, ontimeout: () => reject(new Error('请求超时')), }); }); } function getRepoInfo() { const pathParts = location.pathname.split('/').filter(Boolean); if (pathParts.length < 2) return null; const owner = pathParts[0]; const repo = pathParts[1]; let description = ''; const descEl = document.querySelector('.BorderGrid .f4'); if (descEl) description = descEl.textContent.trim(); return { owner, repo, description }; } async function projectExists(repoName) { const encodedPath = `${config.namespaceName}%2F${encodeURIComponent(repoName)}`; const url = `${API_BASE}/projects/${encodedPath}`; try { const resp = await gmFetch(url, { method: 'GET', headers: { 'PRIVATE-TOKEN': config.token }, }); if (resp.status === 200) { return { exists: true, project: await resp.json() }; } if (resp.status === 404) { return { exists: false }; } const errText = await resp.text(); throw new Error(`检查失败 ${resp.status} - ${errText}`); } catch (err) { console.error('检查项目是否存在失败', err); return { exists: false, error: err.message }; } } async function createFramagitProject(owner, repoName, description) { const repoDescription = `::${owner}/${repoName} [GitHub](https://github.com/${owner}/${repoName}) | ` + (description || ''); const url = `${API_BASE}/projects`; const payload = { name: repoName, path: repoName, visibility: 'private', description: repoDescription, namespace_id: config.namespaceId, }; const resp = await gmFetch(url, { method: 'POST', headers: { 'PRIVATE-TOKEN': config.token, 'Content-Type': 'application/json', }, body: JSON.stringify(payload), }); if (resp.ok) { return await resp.json(); } // 处理非 2xx 响应 const err = await resp.json().catch(() => ({})); throw new Error(err.message || err.error || `HTTP ${resp.status}`); } function addButton() { let actionsUl = document.querySelector('ul.pagehead-actions'); if (!actionsUl) return; if (document.getElementById('btn-to-framagit')) return; const li = document.createElement('li'); li.style.marginLeft = '4px'; const btn = document.createElement('button'); btn.id = 'btn-to-framagit'; btn.type = 'button'; btn.className = 'btn-sm btn BtnGroup-item'; btn.innerHTML = ` 备份到 Framagit `; btn.title = "在 Framagit 创建同名项目(仅创建项目,不自动导入代码)"; btn.addEventListener('click', async () => { const info = getRepoInfo(); if (!info) { showToast('无法获取当前仓库信息', 'error'); return; } const { owner, repo, description } = info; // ★ 保存原始按钮内容(图标 + 文字) const originalHTML = btn.innerHTML; btn.disabled = true; btn.textContent = '检查中...'; const check = await projectExists(repo); if (check.exists) { const proj = check.project; const url = `${FRAMAGIT_BASE}/${proj.path_with_namespace}`; // 项目已存在时 showToast( `该项目已在 Framagit 存在!\n\n` + `项目名称:${proj.name}\n` + `地址:${url}\n\n` + `操作已取消。`, 'warning', 6000 ); btn.disabled = false; btn.innerHTML = originalHTML; return; } if (check.error) { showToast(`检查失败:${check.error}`, 'error'); btn.disabled = false; btn.innerHTML = originalHTML; return; } btn.textContent = '创建中...'; try { const data = await createFramagitProject(owner, repo, description); const url = `${FRAMAGIT_BASE}/${data.path_with_namespace}`; // 创建成功时 showToast( `创建成功!\n` + `项目地址:${url}`, 'success', 8000 ); } catch (err) { // 错误提示 showToast(`创建失败:${err.message}`, 'error', 6000); console.error(err); } finally { btn.disabled = false; btn.innerHTML = originalHTML; } }); li.appendChild(btn); actionsUl.appendChild(li); } setTimeout(addButton, 1200); const observer = new MutationObserver(() => { if (document.querySelector('ul.pagehead-actions')) { addButton(); } }); observer.observe(document.body, { childList: true, subtree: true }); })();