Dernière activité 1 month ago

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

github2framagit.user.js Brut
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})();