// ==UserScript== // @name Gitea 镜像仓库一键填充 // @namespace ScriptCat Scripts // @version 0.2.4 // @description 在Gitea仓库设置页面一键填充镜像仓库信息,并在镜像列表中添加目标仓库跳转链接 // @author Jetsung Chan // @match https://git.idev.top/*/*/settings // @match https://git.asfd.cn/*/*/settings // @match https://codeberg.org/*/*/settings // @grant GM_getValue // @grant GM_setValue // @grant GM_deleteValue // @grant GM_addStyle // @grant GM_registerMenuCommand // @grant GM_unregisterMenuCommand // @grant GM_notification // @grant GM_xmlhttpRequest // @connect openapi-rdc.aliyuncs.com // @connect codeup.aliyun.com // @connect api.github.com // @connect codeberg.org // @connect atomgit.com // @connect framagit.org // @downloadURL https://gist.asfd.cn/jetsung/gitsync/raw/HEAD/gitea.user.js // @updateURL https://gist.asfd.cn/jetsung/gitsync/raw/HEAD/gitea.user.js // ==/UserScript== (function() { 'use strict'; // 平台配置 const platforms = { github: { name: 'GitHub', url: 'https://github.com/', tokenKey: 'github_token', usernameKey: 'github_username', domain: 'github.com' }, atomgit: { name: 'AtomGit', url: 'https://atomgit.com/', tokenKey: 'atomgit_token', usernameKey: 'atomgit_username', domain: 'atomgit.com' }, codeup: { name: 'CodeUp', url: 'https://codeup.aliyun.com/jetsung/', tokenKey: 'codeup_token', usernameKey: 'codeup_username', domain: 'codeup.aliyun.com' }, codeberg: { name: 'Codeberg', url: 'https://codeberg.org/', tokenKey: 'codeberg_token', usernameKey: 'codeberg_username', domain: 'codeberg.org', type: 'gitea' }, framagit: { name: 'Framagit', url: 'https://framagit.org/', tokenKey: 'framagit_token', usernameKey: 'framagit_username', domain: 'framagit.org', type: 'gitlab' } }; const description = document.getElementById('description').value; // 添加样式 GM_addStyle(` .mirror-helper-container { position: fixed; top: 20px; right: 20px; z-index: 9999; background: white; border: 1px solid #ddd; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); padding: 20px; width: 270px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; } .mirror-helper-header { font-size: 18px; font-weight: 600; margin-bottom: 15px; color: #333; border-bottom: 1px solid #eee; padding-bottom: 10px; } .mirror-helper-section { margin-bottom: 15px; } .mirror-helper-section h4 { font-size: 14px; margin: 0 0 8px 0; color: #555; } .mirror-helper-input { width: 100%; padding: 8px 10px; margin-bottom: 8px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; box-sizing: border-box; } .mirror-helper-button { background: #1f75cb; color: white; border: none; border-radius: 4px; padding: 8px 16px; font-size: 14px; cursor: pointer; margin-right: 8px; margin-bottom: 8px; transition: background 0.2s; } .mirror-helper-button:hover { background: #1b5faa; } .mirror-helper-button.secondary { background: #e6e6e6; color: #333; } .mirror-helper-button.secondary:hover { background: #d0d0d0; } .mirror-helper-select { width: 100%; padding: 8px 10px; margin-bottom: 12px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; box-sizing: border-box; } .mirror-helper-footer { margin-top: 15px; padding-top: 10px; border-top: 1px solid #eee; font-size: 12px; color: #777; } .mirror-helper-close { position: absolute; top: 10px; right: 10px; background: none; border: none; font-size: 18px; cursor: pointer; color: #999; } .mirror-helper-close:hover { color: #333; } /* 目标仓库列样式 */ .target-repo-column { max-width: 200px; } .target-repo-link { color: #0366d6; text-decoration: none; font-weight: 500; display: inline-block; padding: 2px 4px; border-radius: 3px; transition: background-color 0.2s; } .target-repo-link:hover { background-color: rgba(3, 102, 214, 0.1); text-decoration: none; } .target-repo-link svg { width: 14px; height: 14px; margin-right: 4px; vertical-align: middle; } .no-target-link { color: #666; font-style: italic; } .create-repo-td { white-space: nowrap; } .create-repo-btn { background: #28a745; color: #fff; border: none; border-radius: 4px; padding: 4px 10px; font-size: 12px; cursor: pointer; transition: all 0.2s; } .create-repo-btn:hover { background: #218838; } .create-repo-btn:disabled { background: #6c757d; cursor: not-allowed; } .create-repo-btn.processing { background: #fd7e14 !important; } .create-repo-btn.success { background: #1e7e34 !important; } .create-repo-btn.error { background: #dc3545 !important; } `); // 解析镜像URL获取目标仓库信息 function parseMirrorUrl(mirrorUrl) { try { let cleanUrl = mirrorUrl.replace(/^https?:\/\/[^@]+@/, 'https://') .replace(/\.git$/, ''); // console.log(cleanUrl); for (const [key, platform] of Object.entries(platforms)) { if (cleanUrl.includes(platform.domain)) { return { platform: platform.name, url: cleanUrl, key: key }; } } } catch (e) { console.error('解析镜像URL失败:', mirrorUrl, e); } return null; } // 查询群组 ID(异步函数) async function getGitLabGroupId(token, url, ownerPath) { let namespaceId = null; try { const encodedOwnerPath = encodeURIComponent(ownerPath); const groupResp = await fetch(`${url}api/v4/groups/${encodedOwnerPath}`, { method: 'GET', headers: { 'PRIVATE-TOKEN': token, 'Accept': 'application/json' } }); if (groupResp.ok) { const groupData = await groupResp.json(); namespaceId = groupData.id; console.log(`找到群组 ${ownerPath},ID = ${namespaceId}`); } else if (groupResp.status === 404) { console.warn(`群组 ${ownerPath} 不存在,将创建在个人命名空间下`); // namespaceId 保持 null } else { const err = await groupResp.text(); throw new Error(`查询群组失败: ${groupResp.status} - ${err}`); } } catch (err) { console.error('查询群组出错:', err); throw err; // 让调用方处理错误 } return namespaceId; } // 在推送镜像表格的操作列中添加「创建仓库」按钮 function addTargetRepoColumn() { // 匹配推送镜像的表格 const table = document.querySelector('table.ui.table'); if (!table) return; const tbody = table.querySelector('tbody'); if (!tbody) return; // 获取所有行,过滤掉最后的“添加镜像”表单行 const rows = tbody.querySelectorAll('tr'); rows.forEach(row => { // 如果行内包含 form 或者 colspan="4",说明是添加镜像的输入框行,跳过 if (row.querySelector('form[action*="push-mirror-add"]') || row.querySelector('td[colspan]')) return; // 查找操作列(右对齐的那个 td) const actionTd = row.querySelector('td.tw-text-right'); if (!actionTd || actionTd.querySelector('.create-repo-btn')) return; // 获取当前行的仓库 URL const urlTd = row.querySelector('td.tw-break-anywhere'); if (!urlTd) return; const mirrorUrl = urlTd.textContent.trim(); const info = parseMirrorUrl(mirrorUrl); // 创建「创建仓库」按钮 const btn = document.createElement('button'); btn.className = 'create-repo-btn ui primary tiny button'; btn.style.marginRight = '4px'; // 留点间距 btn.innerHTML = ``; btn.title = `创建仓库`; // console.log(info); btn.onclick = async () => { const key = info.key; const p = platforms[key]; const username = GM_getValue(p.usernameKey, ''); const token = GM_getValue(p.tokenKey, ''); if (!username || !token) { alert(`${p.name} 未配置账号信息`); return; } btn.disabled = true; btn.textContent = '创建中...'; btn.classList.add('processing'); try { // === 终极解析:支持多级子组 === const cleanUrl = info.url.replace(/\.git$/, '').replace(/\/+$/, ''); const parts = cleanUrl.split('/').slice(3); // 去掉 https://domain.com if (parts.length < 2) throw new Error('无法解析仓库地址:路径太短'); const repoName = parts.pop(); // 最后一段:仓库名 const ownerPath = parts.join('/'); // 前面所有:完整 owner(支持多级) if (!ownerPath || !repoName) throw new Error('无法解析 owner 或 repo'); let resp, data; let namespaceId = null; // === 统一创建接口 === if (key === 'github') { let reqPath = 'user/repos' if (username !== ownerPath) { reqPath = `orgs/${ownerPath}/repos` } resp = await fetch('https://api.github.com/' + reqPath, { method: 'POST', headers: { 'Authorization': `token ${token}`, 'Content-Type': 'application/json', 'Accept': 'application/vnd.github.v3+json' }, body: JSON.stringify({ name: repoName, private: false, auto_init: false, description: description, }) }); } else if (p.type === 'gitea' || key === 'codeberg') { let reqPath = 'api/v1/user/repos' if (username !== ownerPath) { reqPath = `api/v1/orgs/${ownerPath}/repos` } resp = await fetch(p.url + reqPath, { method: 'POST', headers: { 'Authorization': `token ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ name: repoName, private: false, auto_init: false, description:description, }) }); } else if (p.type === 'gitlab' || key === 'framagit') { let reqPath = 'api/v4/projects' if (username !== ownerPath) { try { namespaceId = await getGitLabGroupId(token, p.url, ownerPath); } catch (err) { console.error("获取群组ID失败", err); } } resp = await fetch(p.url + reqPath, { method: 'POST', headers: { 'PRIVATE-TOKEN': token, 'Content-Type': 'application/json', 'Referer': p.url, 'Origin': p.url }, body: JSON.stringify({ name: repoName, visibility: 'private', description: description, namespace_id: namespaceId, }) }); } else if (key === 'atomgit') { let reqPath = 'api/v5/user/repos' if (username !== ownerPath) { reqPath = `api/v5/orgs/${encodeURIComponent(ownerPath)}/repos` } resp = await fetch(`https://api.atomgit.com/` + reqPath, { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ name: repoName, path: repoName, public: 1, auto_init: false, default_branch: 'main', description: description, }) }); } else if (key === 'codeup') { const domain = 'https://openapi-rdc.aliyuncs.com'; const referer = 'https://codeup.aliyun.com'; // Step 1: 获取组织列表 const orgs = await new Promise((res, rej) => GM_xmlhttpRequest({ method: 'GET', url: `${domain}/oapi/v1/platform/organizations?perPage=100`, headers: { 'x-yunxiao-token': token, 'Content-Type': 'application/json', 'Referer': referer, 'Origin': referer }, onload: r => r.status >= 200 && r.status < 300 ? res(JSON.parse(r.responseText)) : rej(new Error(`组织列表失败 ${r.status}`)), onerror: () => rej(new Error('网络错误')), ontimeout: () => rej(new Error('超时')) })); // Step 2: 获取群组空间 const org = orgs.find(o => o.name.toLowerCase() === ownerPath.split('/')[0].toLowerCase()); if (!org) throw new Error(`未找到组织 ${ownerPath.split('/')[0]}`); const namespaces = await new Promise((res, rej) => GM_xmlhttpRequest({ method: 'GET', url: `${domain}/oapi/v1/codeup/organizations/${org.id}/namespaces`, headers: { 'x-yunxiao-token': token, 'Content-Type': 'application/json', 'Referer': referer, 'Origin': referer }, onload: r => r.status >= 200 && r.status < 300 ? res(JSON.parse(r.responseText)) : rej(new Error(`群组列表失败 ${r.status}`)), onerror: () => rej(new Error('网络错误')), ontimeout: () => rej(new Error('超时')) })); // Step 3: 匹配群组(webUrl / pathWithNamespace / nameWithNamespace) const targetNs = namespaces.find(ns => { const web = (ns.webUrl || '').replace(/https?:\/\/[^\/]+/, ''); const path = ns.pathWithNamespace || ns.nameWithNamespace || ''; return web.includes(ownerPath) || path.toLowerCase() === ownerPath.toLowerCase(); }); if (!targetNs) { const paths = namespaces.map(n => n.pathWithNamespace || n.name).join(', '); throw new Error(`未找到群组 ${ownerPath}\n可用群组:${paths || '无'}`); } // console.log('匹配群组成功:', targetNs); // console.log(JSON.stringify({ // name: repoName, // path: repoName, // namespaceId: targetNs.id, // readMeType: 'NONE' // })); // Step 4: 正确创建仓库(使用 organizationId + namespaceId) await new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'POST', url: `${domain}/oapi/v1/codeup/organizations/${org.id}/repositories?createParentPath=true`, headers: { 'x-yunxiao-token': token, 'Content-Type': 'application/json', 'Referer': referer, 'Origin': referer }, data: JSON.stringify({ name: repoName, path: repoName, namespaceId: targetNs.id, description: description, }), onload: r => { const data = r.responseText ? JSON.parse(r.responseText) : {}; if (r.status >= 200 && r.status < 300 || data.id || data.result?.id) { console.log('CodeUp 仓库创建成功:', data); resolve(data); } else { reject(new Error(data.message || data.error || `创建失败(${r.status})`)); } }, onerror: () => reject(new Error('网络错误')), ontimeout: () => reject(new Error('请求超时')) }); }); // 模拟成功响应 resp = { ok: true, status: 200 }; data = { id: 'success' }; } // === 统一成功判断 === if ( resp.status >= 200 && resp.status < 400 || data.id || data.full_name || data.html_url || data.name || data.path || (data.message && /already exists|已存在/i.test(data.message)) ) { btn.textContent = '成功'; btn.classList.add('success'); // 自动填回 URL const input = document.querySelector('#url, #project_remote_mirrors_attributes_0_url'); if (input) input.value = info.url; } else { throw new Error(data.message || data.error || `HTTP ${resp.status}`); } } catch (e) { btn.textContent = '失败'; btn.classList.add('error'); console.error('创建仓库失败:', e); alert(`${p.name} 创建失败:${e.message}`); } // 3秒后恢复 setTimeout(() => { btn.disabled = false; btn.textContent = '创建仓库'; btn.className = 'create-repo-btn'; }, 3000); }; // 插入到操作按钮组的最前面 actionTd.insertBefore(btn, actionTd.firstChild); }); } // 创建设置面板 function createSettingsPanel() { const panel = document.createElement('div'); panel.className = 'mirror-helper-container'; panel.id = 'mirror-settings-panel'; let html = `
镜像仓库设置
`; // 为每个平台创建输入框 for (const [key, platform] of Object.entries(platforms)) { html += `

${platform.name}

`; } html += ` `; panel.innerHTML = html; document.body.appendChild(panel); // 添加事件监听 document.getElementById('close-settings').addEventListener('click', () => { panel.remove(); }); document.getElementById('save-settings').addEventListener('click', () => { for (const [key, platform] of Object.entries(platforms)) { const username = document.getElementById(`${key}-username`).value; const token = document.getElementById(`${key}-token`).value; GM_setValue(platform.usernameKey, username); GM_setValue(platform.tokenKey, token); } alert('设置已保存!'); panel.remove(); location.reload(); // 刷新页面以应用新设置 }); document.getElementById('clear-settings').addEventListener('click', () => { if (confirm('确定要清除所有设置吗?')) { for (const platform of Object.values(platforms)) { GM_deleteValue(platform.usernameKey); GM_deleteValue(platform.tokenKey); } alert('设置已清除!'); panel.remove(); location.reload(); } }); } // 创建填充按钮 function createFillButton() { const container = document.createElement('div'); container.className = 'mirror-helper-container'; container.id = 'mirror-fill-container'; let html = `
镜像仓库填充

选择平台

`; container.innerHTML = html; document.body.appendChild(container); // 添加事件监听 document.getElementById('close-fill').addEventListener('click', () => { container.remove(); }); document.getElementById('open-settings').addEventListener('click', () => { container.remove(); createSettingsPanel(); }); document.getElementById('fill-mirror').addEventListener('click', () => { const selectedPlatform = document.getElementById('platform-select').value; fillMirrorForm(selectedPlatform); }); } // 填充表单 function fillMirrorForm(platformKey) { const platform = platforms[platformKey]; const username = GM_getValue(platform.usernameKey, ''); const token = GM_getValue(platform.tokenKey, ''); if (!username || !token) { alert(`请先设置 ${platform.name} 的用户名和Token!`); return; } // 获取当前仓库路径 let [, group, project] = window.location.pathname.split('/'); if (!group || !project) { alert('无法获取仓库路径!'); return; } switch (group) { case 'idev': if (platformKey === 'github') { group = 'idevsig' } break; case 'tiny': if (platformKey === 'github' || platformKey === 'atomgit') { group = 'tinyzen' } break; } // 构建镜像URL const mirrorUrl = `${platform.url}${group}/${project}.git`; // 填充表单 document.getElementById('push_mirror_address').value = mirrorUrl; document.getElementById('push_mirror_username').value = username; document.getElementById('push_mirror_password').value = token; document.getElementById('push_mirror_interval').value = 0; // 勾选"推送提交时同步" const syncCheckbox = document.getElementById('push_mirror_sync_on_commit'); if (syncCheckbox && !syncCheckbox.checked) { syncCheckbox.click(); } // 显示成功消息 const successMsg = document.createElement('div'); successMsg.style.cssText = ` position: fixed; top: 20px; left: 50%; transform: translateX(-50%); background: #4caf50; color: white; padding: 12px 24px; border-radius: 4px; box-shadow: 0 2px 8px rgba(0,0,0,0.2); z-index: 10000; font-size: 14px; `; successMsg.textContent = `已填充 ${platform.name} 镜像仓库信息!`; document.body.appendChild(successMsg); setTimeout(() => { successMsg.remove(); }, 3000); } // 新增一个专门处理「推送镜像」列表中 URL 变成可点击链接的函数 function makePushMirrorUrlsClickable() { // 找到所有推送镜像的 URL 单元格 const urlCells = document.querySelectorAll( 'table.ui.table tbody tr:not([style*="display: none"]) td.tw-break-anywhere' ); urlCells.forEach(cell => { // 避免重复处理 if (cell.querySelector('a')) return; const urlText = cell.textContent.trim(); // 简单验证是否像 Git URL if (urlText && (urlText.startsWith('http://') || urlText.startsWith('https://'))) { const link = document.createElement('a'); link.href = urlText; link.textContent = urlText; link.target = '_blank'; // 新标签页打开 link.rel = 'noopener noreferrer'; // 安全最佳实践 link.style.color = '#0366d6'; // 模仿 Gitea 的蓝色链接 link.style.textDecoration = 'none'; // hover 效果(可选,更像原生链接) link.addEventListener('mouseover', () => { link.style.textDecoration = 'underline'; }); link.addEventListener('mouseout', () => { link.style.textDecoration = 'none'; }); // 清空原文字并替换成链接 cell.innerHTML = ''; cell.appendChild(link); } }); } // 初始化 function init() { // 添加目标仓库列(适用于镜像列表页面) if (document.querySelector('table.ui.table')) { // 延迟执行以确保DOM完全加载 setTimeout(() => { addTargetRepoColumn(); // 监听动态加载的行 const observer = new MutationObserver(() => { addTargetRepoColumn(); }); observer.observe(document.body, { childList: true, subtree: true }); }, 1000); } // 创建填充按钮(适用于设置页面) if (document.querySelector('table.ui.table')) { createFillButton(); makePushMirrorUrlsClickable(); } // 注册菜单命令 GM_registerMenuCommand('镜像仓库设置', createSettingsPanel); } // 页面加载完成后初始化 if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } // 监听页面导航(SPA应用) let currentUrl = location.href; new MutationObserver(() => { if (location.href !== currentUrl) { currentUrl = location.href; setTimeout(init, 500); } }).observe(document, { subtree: true, childList: true }); })();