Last active 1 month ago

在 GitHub 仓库页面添加“备份到 Framagit”按钮,点击后通过 API 创建项目

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