github2framagit.user.js
· 11 KiB · JavaScript
Исходник
// ==UserScript==
// @name 备份 GitHub 仓库至 Framagit
// @namespace https://docs.scriptcat.org/
// @version 0.1.0
// @description 在 GitHub 仓库页面添加“备份到 Framagit”按钮,点击后通过 API 创建项目
// @author Jetsung Chan <[email protected]>
// @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,
'<a href="$1" target="_blank" style="color:inherit;text-decoration:underline;">$1</a>'
);
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 = `
<svg aria-hidden="true" height="16" viewBox="0 0 16 16" width="16" class="octicon octicon-repo-forked mr-1">
<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>
</svg>
备份到 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 });
})();
| 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 | })(); |