ones-fetch 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,19 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(npm install:*)",
5
+ "Bash(node:*)",
6
+ "Bash(timeout 3 npm start)",
7
+ "Bash(ls:*)",
8
+ "Bash(python process_icon.py)",
9
+ "Bash(python -c \"from PIL import Image; img = Image.open\\('public/icon.png'\\); print\\(f'尺寸: {img.size}, 模式: {img.mode}'\\)\")",
10
+ "Bash(git add:*)",
11
+ "Bash(git reset:*)",
12
+ "Bash(git mv:*)",
13
+ "Bash(gh --version)",
14
+ "Bash(gh repo:*)",
15
+ "Bash(npm whoami:*)",
16
+ "Bash(npm login:*)"
17
+ ]
18
+ }
19
+ }
package/README.md ADDED
@@ -0,0 +1,149 @@
1
+ # ONES 子任务采集工具
2
+
3
+ 一个本地 Web 工具,用浏览器界面批量解析 ONES 任务链接或任务编号,并递归抓取所有子任务。
4
+
5
+ ## 快速安装(推荐)
6
+
7
+ 策划电脑上已有 Node.js,执行一条命令即可:
8
+
9
+ ```bash
10
+ npx ones-fetch
11
+ ```
12
+
13
+ 这会自动:
14
+ - 安装依赖(约 30MB)
15
+ - 在桌面创建快捷方式
16
+ - 完成后双击桌面图标即可使用
17
+
18
+ ---
19
+
20
+ ## 环境要求
21
+
22
+ - Node.js 18+
23
+ - Chrome 或 Edge 浏览器(系统已安装)
24
+ - 可访问的 ONES 租户 URL
25
+
26
+ ## 手动安装
27
+
28
+ ```bash
29
+ git clone <repo-url>
30
+ cd ones-fetch
31
+ npm install
32
+ ```
33
+
34
+ ---
35
+
36
+ ## 使用方法
37
+
38
+ ### 方式 1:双击启动(推荐给非技术用户)
39
+
40
+ **Windows 用户:**
41
+ - 双击 `启动 ONES 采集工具.vbs`
42
+ - 首次运行会自动安装依赖
43
+ - 浏览器会自动打开工具页面
44
+
45
+ ### 方式 2:命令行启动
46
+
47
+ ```bash
48
+ npm start
49
+ # 或开发模式(自动重启)
50
+ npm run dev
51
+ ```
52
+
53
+ 启动后会自动打开浏览器访问 `http://localhost:3000`。
54
+
55
+ ### 使用流程
56
+
57
+ 1. **首次使用 - 连接 ONES**
58
+ - 页面会提示"连接 ONES"
59
+ - 点击后会打开浏览器登录页
60
+ - 登录完成后,系统会自动捕获认证信息和 team-id
61
+
62
+ 2. **粘贴任务文本**
63
+ - 支持粘贴完整 ONES 任务链接
64
+ - 也支持直接粘贴 `#90182|#90183` 这种编号文本
65
+ - 页面会自动提取任务编号并递归抓取所有子任务
66
+
67
+ 3. **查看结果**
68
+ - 任务列表会显示所有子任务
69
+ - 包含任务编号、标题、状态、负责人、截止日期等信息
70
+
71
+ ---
72
+
73
+ ## 技术说明
74
+
75
+ ### 认证机制
76
+
77
+ - 首次登录时,系统会启动浏览器窗口
78
+ - 自动从页面 URL 提取 team-id(格式:`/team/{uuid}/...`)
79
+ - 认证信息保存在 `~/.ones-fetch/credentials.json`(权限 600)
80
+ - 令牌过期时会提示重新登录
81
+
82
+ ### Web API
83
+
84
+ 服务提供以下接口:
85
+
86
+ | 方法 | 路径 | 说明 |
87
+ |---|---|---|
88
+ | `GET` | `/` | Web UI 首页 |
89
+ | `GET` | `/api/auth/status` | 当前认证状态 |
90
+ | `POST` | `/api/auth/login` | 启动浏览器登录采集 |
91
+ | `POST` | `/api/crawl` | 批量采集接口 |
92
+
93
+ **`/api/crawl` 请求格式:**
94
+
95
+ ```json
96
+ {
97
+ "taskIds": ["90182", "90183", "HM6gttraKPOVnrdy"],
98
+ "baseUrl": "https://your-team.ones.cn",
99
+ "teamId": "<team-uuid>"
100
+ }
101
+ ```
102
+
103
+ **响应格式:**
104
+
105
+ ```json
106
+ {
107
+ "roots": ["<uuid1>", "<uuid2>"],
108
+ "tasks": [
109
+ { "uuid": "...", "root_uuid": "<uuid1>", "number": "...", "summary": "...", ... },
110
+ ...
111
+ ]
112
+ }
113
+ ```
114
+
115
+ 错误时返回 `401`(需要登录或令牌过期)或 `500`(采集失败)。
116
+
117
+ ---
118
+
119
+ ## 环境变量
120
+
121
+ 可选的环境变量:
122
+
123
+ | 变量 | 说明 |
124
+ |---|---|
125
+ | `PORT` | 服务端口(默认 3000) |
126
+ | `ONES_BASE_URL` | ONES 基础 URL |
127
+ | `ONES_TEAM_ID` | 团队 UUID(通常自动检测) |
128
+
129
+ ---
130
+
131
+ ## 本地凭证文件
132
+
133
+ | 路径 | 用途 |
134
+ |---|---|
135
+ | `~/.ones-fetch/credentials.json` | 登录时保存的认证令牌(权限 600) |
136
+
137
+ ---
138
+
139
+ ## 项目结构
140
+
141
+ ```
142
+ ones-fetch/
143
+ ├── src/
144
+ │ ├── server.mjs # HTTP 服务器和任务爬取逻辑
145
+ │ └── auth.mjs # 浏览器登录和凭证管理
146
+ ├── public/
147
+ │ └── index.html # Web UI 界面
148
+ └── package.json
149
+ ```
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { fileURLToPath } from 'node:url';
4
+ import { dirname, join } from 'node:path';
5
+ import { homedir, platform } from 'node:os';
6
+ import { writeFile, mkdir } from 'node:fs/promises';
7
+ import { exec } from 'node:child_process';
8
+ import { promisify } from 'node:util';
9
+
10
+ const execAsync = promisify(exec);
11
+ const __dirname = dirname(fileURLToPath(import.meta.url));
12
+ const projectRoot = join(__dirname, '..');
13
+
14
+ async function createWindowsShortcut() {
15
+ const desktop = join(homedir(), 'Desktop');
16
+ const shortcutPath = join(desktop, 'ONES 采集工具.lnk');
17
+ const iconPath = join(projectRoot, 'public', 'icon.png');
18
+ const vbsLauncher = join(projectRoot, 'public', 'launcher.vbs');
19
+
20
+ // 创建 PowerShell 脚本来生成快捷方式
21
+ const psScript = `
22
+ $WshShell = New-Object -ComObject WScript.Shell
23
+ $Shortcut = $WshShell.CreateShortcut("${shortcutPath.replace(/\\/g, '\\\\')}")
24
+ $Shortcut.TargetPath = "wscript.exe"
25
+ $Shortcut.Arguments = '"${vbsLauncher.replace(/\\/g, '\\\\')}"'
26
+ $Shortcut.WorkingDirectory = "${projectRoot.replace(/\\/g, '\\\\')}"
27
+ $Shortcut.IconLocation = "${iconPath.replace(/\\/g, '\\\\')}"
28
+ $Shortcut.Description = "ONES 任务采集工具"
29
+ $Shortcut.Save()
30
+ `;
31
+
32
+ const tempPs1 = join(projectRoot, 'temp-create-shortcut.ps1');
33
+ await writeFile(tempPs1, psScript, 'utf8');
34
+
35
+ try {
36
+ await execAsync(`powershell -ExecutionPolicy Bypass -File "${tempPs1}"`);
37
+ console.log(`✓ 桌面快捷方式已创建: ${shortcutPath}`);
38
+ } finally {
39
+ // 清理临时文件
40
+ try {
41
+ await execAsync(`del "${tempPs1}"`);
42
+ } catch {}
43
+ }
44
+ }
45
+
46
+ async function createMacShortcut() {
47
+ const desktop = join(homedir(), 'Desktop');
48
+ const appPath = join(desktop, 'ONES 采集工具.command');
49
+
50
+ const scriptContent = `#!/bin/bash
51
+ cd "${projectRoot}"
52
+ node src/server.mjs &
53
+ sleep 2
54
+ open http://localhost:3000
55
+ `;
56
+
57
+ await writeFile(appPath, scriptContent, 'utf8');
58
+ await execAsync(`chmod +x "${appPath}"`);
59
+ console.log(`✓ 桌面快捷方式已创建: ${appPath}`);
60
+ }
61
+
62
+ async function createLinuxShortcut() {
63
+ const desktop = join(homedir(), 'Desktop');
64
+ const desktopFile = join(desktop, 'ones-fetch.desktop');
65
+
66
+ const content = `[Desktop Entry]
67
+ Version=1.0
68
+ Type=Application
69
+ Name=ONES 采集工具
70
+ Exec=bash -c "cd ${projectRoot} && node src/server.mjs & sleep 2 && xdg-open http://localhost:3000"
71
+ Icon=utilities-terminal
72
+ Terminal=false
73
+ Categories=Utility;
74
+ `;
75
+
76
+ await writeFile(desktopFile, content, 'utf8');
77
+ await execAsync(`chmod +x "${desktopFile}"`);
78
+ console.log(`✓ 桌面快捷方式已创建: ${desktopFile}`);
79
+ }
80
+
81
+ async function main() {
82
+ console.log('ONES Fetch 安装程序\n');
83
+
84
+ // 安装依赖
85
+ console.log('正在安装依赖...');
86
+ try {
87
+ await execAsync('npm install', { cwd: projectRoot });
88
+ console.log('✓ 依赖安装完成\n');
89
+ } catch (err) {
90
+ console.error('✗ 依赖安装失败:', err.message);
91
+ process.exit(1);
92
+ }
93
+
94
+ // 创建桌面快捷方式
95
+ console.log('正在创建桌面快捷方式...');
96
+ try {
97
+ const os = platform();
98
+ if (os === 'win32') {
99
+ await createWindowsShortcut();
100
+ } else if (os === 'darwin') {
101
+ await createMacShortcut();
102
+ } else {
103
+ await createLinuxShortcut();
104
+ }
105
+ } catch (err) {
106
+ console.error('✗ 快捷方式创建失败:', err.message);
107
+ process.exit(1);
108
+ }
109
+
110
+ console.log('\n✓ 安装完成!');
111
+ console.log('\n使用方法:');
112
+ console.log(' 1. 双击桌面上的 "ONES 采集工具" 图标');
113
+ console.log(' 2. 浏览器会自动打开工具页面');
114
+ console.log('\n或者在命令行运行:');
115
+ console.log(` cd ${projectRoot}`);
116
+ console.log(' npm start');
117
+ }
118
+
119
+ main().catch(console.error);
package/package.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "ones-fetch",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "description": "ONES Fetch — Web app for recursive ONES subtask crawling",
6
+ "bin": {
7
+ "ones-fetch": "./bin/install.mjs"
8
+ },
9
+ "scripts": {
10
+ "start": "node src/server.mjs",
11
+ "dev": "node --watch src/server.mjs"
12
+ },
13
+ "dependencies": {
14
+ "playwright-core": "^1.58.2"
15
+ },
16
+ "keywords": ["ones", "task", "crawler", "project-management"],
17
+ "author": "",
18
+ "license": "MIT"
19
+ }
Binary file
@@ -0,0 +1,385 @@
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>ONES 任务解析器</title>
7
+ <style>
8
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
9
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f5f7fa; color: #1a1a2e; min-height: 100vh; }
10
+ .container { max-width: 1100px; margin: 0 auto; padding: 24px 16px; }
11
+ h1 { font-size: 1.5rem; font-weight: 700; margin-bottom: 4px; color: #1a1a2e; }
12
+ .subtitle { font-size: 0.875rem; color: #6b7280; margin-bottom: 20px; }
13
+ .input-card { background: #fff; border-radius: 10px; padding: 20px; box-shadow: 0 1px 4px rgba(0,0,0,.08); margin-bottom: 16px; }
14
+ textarea { width: 100%; height: 140px; border: 1px solid #d1d5db; border-radius: 6px; padding: 10px 12px; font-size: 0.875rem; font-family: inherit; resize: vertical; outline: none; transition: border-color .2s; }
15
+ textarea:focus { border-color: #6366f1; }
16
+ .row { display: flex; gap: 10px; align-items: center; margin-top: 12px; flex-wrap: wrap; }
17
+ .hint { font-size: 0.8125rem; color: #6b7280; }
18
+ button { cursor: pointer; border: none; border-radius: 6px; padding: 8px 18px; font-size: 0.875rem; font-weight: 500; transition: opacity .15s, background .15s; }
19
+ button:disabled { opacity: .5; cursor: not-allowed; }
20
+ .btn-primary { background: #6366f1; color: #fff; }
21
+ .btn-primary:hover:not(:disabled) { background: #4f46e5; }
22
+ .btn-secondary { background: #e5e7eb; color: #374151; }
23
+ .btn-secondary:hover:not(:disabled) { background: #d1d5db; }
24
+ .btn-success { background: #10b981; color: #fff; }
25
+ .btn-success:hover:not(:disabled) { background: #059669; }
26
+ .btn-link { background: #f59e0b; color: #fff; }
27
+ .btn-link:hover:not(:disabled) { background: #d97706; }
28
+ .status { font-size: 0.8125rem; color: #6b7280; }
29
+ .status.error { color: #ef4444; }
30
+ .preview-chips { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 10px; }
31
+ .chip { background: #ede9fe; color: #5b21b6; font-size: 0.75rem; padding: 3px 10px; border-radius: 999px; font-family: monospace; }
32
+ .toolbar { display: flex; gap: 8px; align-items: center; margin-bottom: 12px; flex-wrap: wrap; }
33
+ .results-card { background: #fff; border-radius: 10px; box-shadow: 0 1px 4px rgba(0,0,0,.08); overflow: hidden; }
34
+ .table-wrap { overflow-x: auto; }
35
+ table { width: 100%; border-collapse: collapse; font-size: 0.8125rem; }
36
+ th { background: #f9fafb; text-align: left; padding: 10px 14px; font-weight: 600; color: #6b7280; border-bottom: 1px solid #e5e7eb; white-space: nowrap; }
37
+ td { padding: 9px 14px; border-bottom: 1px solid #f3f4f6; vertical-align: middle; }
38
+ tr:last-child td { border-bottom: none; }
39
+ tr:hover td { background: #fafafa; }
40
+ .indent { display: inline-block; }
41
+ .toggle-btn { cursor: pointer; background: none; border: none; padding: 0 4px 0 0; font-size: 0.75rem; color: #9ca3af; line-height: 1; vertical-align: middle; }
42
+ .toggle-btn:hover { color: #6366f1; }
43
+ .num-cell { font-weight: 600; color: #4f46e5; white-space: nowrap; }
44
+ .num-link { color: inherit; text-decoration: none; }
45
+ .num-link:hover { text-decoration: underline; }
46
+ .tag { display: inline-block; font-size: 0.7rem; padding: 2px 7px; border-radius: 4px; background: #dbeafe; color: #1d4ed8; }
47
+ .empty { text-align: center; color: #9ca3af; padding: 40px; font-size: 0.875rem; }
48
+ .loading { text-align: center; color: #6b7280; padding: 40px; }
49
+ .spinner { display: inline-block; width: 18px; height: 18px; border: 2px solid #e5e7eb; border-top-color: #6366f1; border-radius: 50%; animation: spin .7s linear infinite; vertical-align: middle; margin-right: 6px; }
50
+ @keyframes spin { to { transform: rotate(360deg); } }
51
+ .copied { background: #10b981 !important; }
52
+ </style>
53
+ </head>
54
+ <body>
55
+ <div class="container">
56
+ <h1>ONES 任务解析器</h1>
57
+ <p class="subtitle">粘贴包含 ONES 任务链接或 #任务编号 的文本,自动提取并递归获取所有子任务</p>
58
+
59
+ <div class="input-card">
60
+ <textarea id="input" placeholder="粘贴任务文本,例如:&#10;#80487 【9.12】去掉封印英雄相关的内容&#10;https://ones.kingamer.cn/project/#/team/EPySA1xa/task/Dw3sXNfzJOJ73Gfg"></textarea>
61
+ <div class="row">
62
+ <button class="btn-primary" id="parseBtn" onclick="parse()">解 析</button>
63
+ <button class="btn-secondary" onclick="clearAll()">清 空</button>
64
+ <button class="btn-link" id="authBtn" onclick="connectOnes()" style="display:none">连接 ONES</button>
65
+ <span class="status" id="status"></span>
66
+ </div>
67
+ <div class="hint" id="authHint"></div>
68
+ <div class="preview-chips" id="chips"></div>
69
+ </div>
70
+
71
+ <div id="resultsSection" style="display:none">
72
+ <div class="toolbar">
73
+ <button class="btn-success" id="copyBtn" onclick="copyAllNumbers()">复制所有编号</button>
74
+ <button class="btn-secondary" onclick="expandAll()">展开全部</button>
75
+ <button class="btn-secondary" onclick="collapseAll()">折叠全部</button>
76
+ <span class="status" id="countLabel"></span>
77
+ </div>
78
+ <div class="results-card">
79
+ <div class="table-wrap">
80
+ <table>
81
+ <thead>
82
+ <tr>
83
+ <th>编号</th>
84
+ <th>标题</th>
85
+ <th>状态</th>
86
+ <th>负责人</th>
87
+ <th>截止日期</th>
88
+ </tr>
89
+ </thead>
90
+ <tbody id="tbody"></tbody>
91
+ </table>
92
+ </div>
93
+ </div>
94
+ </div>
95
+ </div>
96
+
97
+ <script>
98
+ let allTasks = [];
99
+ let collapsed = new Set();
100
+ let authPollingTimer = null;
101
+ let authContext = { baseUrl: '', teamId: '' };
102
+
103
+ function extractContext(text) {
104
+ const match = text.match(/(https?:\/\/[^/\s]+)\/project\/#\/team\/([^/\s]+)\/task\/([A-Za-z0-9]{10,})/);
105
+ if (!match) return { baseUrl: '', teamId: '' };
106
+ return { baseUrl: match[1], teamId: match[2] };
107
+ }
108
+
109
+ function extractTaskIds(text) {
110
+ const uuids = [];
111
+ const numbers = [];
112
+
113
+ for (const match of text.matchAll(/\/task\/([A-Za-z0-9]{10,})/g)) {
114
+ uuids.push(match[1]);
115
+ }
116
+
117
+ const textWithoutUrls = text.replace(/https?:\/\/[^\s]+/g, '');
118
+ for (const match of textWithoutUrls.matchAll(/#(\d+)/g)) {
119
+ numbers.push(match[1]);
120
+ }
121
+
122
+ const seen = new Set();
123
+ return [...uuids, ...numbers].filter(id => {
124
+ if (seen.has(id)) return false;
125
+ seen.add(id);
126
+ return true;
127
+ });
128
+ }
129
+
130
+ function setStatus(msg, isError = false) {
131
+ const el = document.getElementById('status');
132
+ el.textContent = msg;
133
+ el.className = 'status' + (isError ? ' error' : '');
134
+ }
135
+
136
+ function setAuthHint(msg) {
137
+ document.getElementById('authHint').textContent = msg || '';
138
+ }
139
+
140
+ function updateAuthContext(next) {
141
+ authContext = {
142
+ baseUrl: next?.baseUrl || authContext.baseUrl || '',
143
+ teamId: next?.teamId || authContext.teamId || '',
144
+ };
145
+ }
146
+
147
+ function showAuthButton(show, label = '连接 ONES') {
148
+ const btn = document.getElementById('authBtn');
149
+ btn.style.display = show ? '' : 'none';
150
+ btn.textContent = label;
151
+ btn.disabled = false;
152
+ }
153
+
154
+ function renderChips(taskIds) {
155
+ const el = document.getElementById('chips');
156
+ if (taskIds.length === 0) { el.innerHTML = ''; return; }
157
+ el.innerHTML = taskIds.map(id => `<span class="chip">${id}</span>`).join('');
158
+ }
159
+
160
+ async function parse() {
161
+ const text = document.getElementById('input').value.trim();
162
+ if (!text) { setStatus('请先粘贴文本', true); return; }
163
+ const taskIds = extractTaskIds(text);
164
+ const context = extractContext(text);
165
+ updateAuthContext(context);
166
+ if (taskIds.length === 0) { setStatus('未找到任务 ID(支持 /task/UUID 链接或 #数字编号)', true); return; }
167
+ renderChips(taskIds);
168
+ setStatus(`已提取 ${taskIds.length} 个任务 ID,正在抓取...`);
169
+ setAuthHint(context.baseUrl ? `已识别站点:${context.baseUrl}${context.teamId ? `,团队:${context.teamId}` : ''}` : '');
170
+ document.getElementById('parseBtn').disabled = true;
171
+ document.getElementById('tbody').innerHTML = '<tr><td colspan="5" class="loading"><span class="spinner"></span>加载中...</td></tr>';
172
+ document.getElementById('resultsSection').style.display = '';
173
+
174
+ try {
175
+ const resp = await fetch('/api/crawl', {
176
+ method: 'POST',
177
+ headers: { 'Content-Type': 'application/json' },
178
+ body: JSON.stringify({ taskIds, baseUrl: authContext.baseUrl || context.baseUrl || undefined, teamId: authContext.teamId || context.teamId || undefined }),
179
+ });
180
+ const data = await resp.json();
181
+ if (!resp.ok) {
182
+ if (data.error === 'auth_required' || data.error === 'token_expired') {
183
+ updateAuthContext(data);
184
+ showAuthButton(true, data.error === 'token_expired' ? '重新连接 ONES' : '连接 ONES');
185
+ setAuthHint(`将打开一个浏览器窗口用于登录 ONES,完成后会自动重试。${authContext.baseUrl ? ` 当前站点:${authContext.baseUrl}` : ''}`);
186
+ }
187
+ const msg = data.error === 'token_expired'
188
+ ? '登录状态已过期,请点击“连接 ONES”重新登录'
189
+ : data.error === 'auth_required'
190
+ ? '当前还没有可用的 ONES 凭据,请先连接 ONES'
191
+ : (data.detail ?? data.error ?? '抓取失败');
192
+ setStatus(msg, true);
193
+ document.getElementById('tbody').innerHTML = `<tr><td colspan="5" class="empty">${msg}</td></tr>`;
194
+ return;
195
+ }
196
+ showAuthButton(false);
197
+ updateAuthContext(data);
198
+ allTasks = data.tasks ?? [];
199
+ collapsed = new Set();
200
+ renderTable();
201
+ setStatus(`共获取 ${allTasks.length} 条任务`);
202
+ document.getElementById('countLabel').textContent = `共 ${allTasks.length} 条`;
203
+ } catch (e) {
204
+ setStatus('网络错误:' + e.message, true);
205
+ } finally {
206
+ document.getElementById('parseBtn').disabled = false;
207
+ }
208
+ }
209
+
210
+ async function connectOnes() {
211
+ const text = document.getElementById('input').value;
212
+ const context = extractContext(text);
213
+ updateAuthContext(context);
214
+ const btn = document.getElementById('authBtn');
215
+
216
+ if (!authContext.baseUrl) {
217
+ setStatus('当前没有可用的 ONES 站点地址,请先粘贴任务链接或配置 ONES_BASE_URL', true);
218
+ return;
219
+ }
220
+
221
+ btn.disabled = true;
222
+ btn.textContent = '正在打开登录窗口...';
223
+ setStatus('正在打开 ONES 登录窗口,请在弹出的浏览器里完成登录');
224
+
225
+ const resp = await fetch('/api/auth/login', {
226
+ method: 'POST',
227
+ headers: { 'Content-Type': 'application/json' },
228
+ body: JSON.stringify({ baseUrl: authContext.baseUrl, teamId: authContext.teamId || undefined }),
229
+ });
230
+ const data = await resp.json();
231
+ if (!resp.ok) {
232
+ btn.disabled = false;
233
+ btn.textContent = '连接 ONES';
234
+ setStatus(data.detail ?? data.error ?? '无法启动登录流程', true);
235
+ return;
236
+ }
237
+
238
+ updateAuthContext(data);
239
+ setAuthHint(`已打开 ${authContext.baseUrl} 的登录窗口,登录完成后将自动重试解析。`);
240
+ startAuthPolling();
241
+ }
242
+
243
+ function startAuthPolling() {
244
+ if (authPollingTimer) window.clearInterval(authPollingTimer);
245
+ authPollingTimer = window.setInterval(async () => {
246
+ const resp = await fetch('/api/auth/status');
247
+ const data = await resp.json();
248
+ if (data.status === 'pending') {
249
+ showAuthButton(true, '等待登录完成...');
250
+ document.getElementById('authBtn').disabled = true;
251
+ return;
252
+ }
253
+
254
+ window.clearInterval(authPollingTimer);
255
+ authPollingTimer = null;
256
+
257
+ if (data.status === 'authenticated') {
258
+ updateAuthContext(data);
259
+ showAuthButton(false);
260
+ setStatus('ONES 已连接,正在重新解析...');
261
+ setAuthHint(`当前站点:${authContext.baseUrl}${authContext.teamId ? `,团队:${authContext.teamId}` : ''}`);
262
+ await parse();
263
+ return;
264
+ }
265
+
266
+ showAuthButton(true, '连接 ONES');
267
+ setStatus(data.error === 'LOGIN_TIMEOUT' ? '登录超时,请重试' : '登录未完成,请重试', true);
268
+ }, 1500);
269
+ }
270
+
271
+ function buildTree(tasks) {
272
+ const byUuid = new Map(tasks.map(t => [t.uuid, { ...t, children: [] }]));
273
+ const roots = [];
274
+ for (const node of byUuid.values()) {
275
+ if (node.parent_uuid && byUuid.has(node.parent_uuid)) {
276
+ byUuid.get(node.parent_uuid).children.push(node);
277
+ } else {
278
+ roots.push(node);
279
+ }
280
+ }
281
+ return roots;
282
+ }
283
+
284
+ function flattenTree(nodes, depth = 0) {
285
+ const result = [];
286
+ for (const node of nodes) {
287
+ result.push({ ...node, _depth: depth });
288
+ if (!collapsed.has(node.uuid) && node.children.length > 0) {
289
+ result.push(...flattenTree(node.children, depth + 1));
290
+ }
291
+ }
292
+ return result;
293
+ }
294
+
295
+ function renderTable() {
296
+ const roots = buildTree(allTasks);
297
+ const flat = flattenTree(roots);
298
+
299
+ const rows = flat.map(t => {
300
+ const hasChildren = t.children.length > 0;
301
+ const isCollapsed = collapsed.has(t.uuid);
302
+ const toggleIcon = hasChildren ? `<button class="toggle-btn" onclick="toggleRow('${t.uuid}')">${isCollapsed ? '▶' : '▼'}</button>` : '<span style="display:inline-block;width:18px"></span>';
303
+ const indent = `<span class="indent" style="width:${t._depth * 20}px"></span>`;
304
+ const taskUrl = authContext.baseUrl && authContext.teamId
305
+ ? `${authContext.baseUrl}/project/#/team/${authContext.teamId}/task/${t.uuid}`
306
+ : '';
307
+ const numberCell = taskUrl
308
+ ? `<a class="num-link" href="${escAttr(taskUrl)}" target="_blank" rel="noreferrer">#${esc(t.number ?? '')}</a>`
309
+ : `#${esc(t.number ?? '')}`;
310
+ return `<tr data-uuid="${t.uuid}">
311
+ <td class="num-cell">${numberCell}</td>
312
+ <td>${indent}${toggleIcon}${esc(t.summary ?? '')}</td>
313
+ <td>${t.status_name ? `<span class="tag">${esc(t.status_name)}</span>` : ''}</td>
314
+ <td>${esc(t.assign_name ?? '')}</td>
315
+ <td>${esc(t.deadline ?? '')}</td>
316
+ </tr>`;
317
+ }).join('');
318
+
319
+ document.getElementById('tbody').innerHTML = rows || '<tr><td colspan="5" class="empty">无结果</td></tr>';
320
+ }
321
+
322
+ function esc(str) {
323
+ return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
324
+ }
325
+
326
+ function escAttr(str) {
327
+ return String(str).replace(/&/g, '&amp;').replace(/"/g, '&quot;');
328
+ }
329
+
330
+ function toggleRow(uuid) {
331
+ if (collapsed.has(uuid)) collapsed.delete(uuid);
332
+ else collapsed.add(uuid);
333
+ renderTable();
334
+ }
335
+
336
+ function expandAll() {
337
+ collapsed.clear();
338
+ renderTable();
339
+ }
340
+
341
+ function collapseAll() {
342
+ for (const t of allTasks) {
343
+ const node = allTasks.find(x => x.parent_uuid === t.uuid);
344
+ if (node) collapsed.add(t.uuid);
345
+ }
346
+ renderTable();
347
+ }
348
+
349
+ function copyAllNumbers() {
350
+ const numbers = allTasks.map(t => `#${t.number}`).join('|');
351
+ navigator.clipboard.writeText(numbers).then(() => {
352
+ const btn = document.getElementById('copyBtn');
353
+ btn.textContent = '已复制 ✓';
354
+ btn.classList.add('copied');
355
+ setTimeout(() => {
356
+ btn.textContent = '复制所有编号';
357
+ btn.classList.remove('copied');
358
+ }, 2000);
359
+ });
360
+ }
361
+
362
+ function clearAll() {
363
+ document.getElementById('input').value = '';
364
+ document.getElementById('chips').innerHTML = '';
365
+ document.getElementById('status').textContent = '';
366
+ setAuthHint('');
367
+ showAuthButton(false);
368
+ authContext = { baseUrl: '', teamId: '' };
369
+ document.getElementById('resultsSection').style.display = 'none';
370
+ allTasks = [];
371
+ }
372
+
373
+ // Auto-extract chips on input
374
+ document.getElementById('input').addEventListener('input', () => {
375
+ const text = document.getElementById('input').value;
376
+ const taskIds = extractTaskIds(text);
377
+ const context = extractContext(text);
378
+ updateAuthContext(context);
379
+ renderChips(taskIds);
380
+ setStatus(taskIds.length ? `检测到 ${taskIds.length} 个任务 ID` : '');
381
+ setAuthHint(authContext.baseUrl ? `已识别站点:${authContext.baseUrl}${authContext.teamId ? `,团队:${authContext.teamId}` : ''}` : '');
382
+ });
383
+ </script>
384
+ </body>
385
+ </html>
@@ -0,0 +1,34 @@
1
+ Set WshShell = CreateObject("WScript.Shell")
2
+ Set fso = CreateObject("Scripting.FileSystemObject")
3
+
4
+ ' 获取脚本所在目录
5
+ scriptDir = fso.GetParentFolderName(WScript.ScriptFullName)
6
+
7
+ ' 检查 Node.js 是否安装
8
+ On Error Resume Next
9
+ WshShell.Run "node --version", 0, True
10
+ If Err.Number <> 0 Then
11
+ MsgBox "未检测到 Node.js,请先安装 Node.js 18 或更高版本。" & vbCrLf & vbCrLf & "下载地址:https://nodejs.org/", vbCritical, "ONES 采集工具"
12
+ WScript.Quit
13
+ End If
14
+ On Error Goto 0
15
+
16
+ ' 检查是否已安装依赖
17
+ If Not fso.FolderExists(scriptDir & "\node_modules") Then
18
+ result = MsgBox("首次运行需要安装依赖(约 30MB),是否继续?", vbYesNo + vbQuestion, "ONES 采集工具")
19
+ If result = vbNo Then
20
+ WScript.Quit
21
+ End If
22
+
23
+ ' 显示安装窗口
24
+ WshShell.Run "cmd /c cd /d """ & scriptDir & """ && npm install && pause", 1, True
25
+ End If
26
+
27
+ ' 启动服务器(后台运行)
28
+ WshShell.Run "cmd /c cd /d """ & scriptDir & """ && node src/server.mjs", 0, False
29
+
30
+ ' 等待服务器启动
31
+ WScript.Sleep 2000
32
+
33
+ ' 打开浏览器
34
+ WshShell.Run "http://localhost:3000", 1
package/src/auth.mjs ADDED
@@ -0,0 +1,181 @@
1
+ // auth.mjs — browser login for ones-fetch web mode
2
+ // Credentials stored at ~/.ones-fetch/credentials.json (mode 600 on non-Windows)
3
+
4
+ import os from "node:os";
5
+ import fsp from "node:fs/promises";
6
+ import path from "node:path";
7
+ import { chromium } from "playwright-core";
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Paths
11
+ // ---------------------------------------------------------------------------
12
+
13
+ export function getCredentialsPath() {
14
+ return path.join(os.homedir(), ".ones-fetch", "credentials.json");
15
+ }
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Read / write credentials
19
+ // ---------------------------------------------------------------------------
20
+
21
+ export async function readCredentials() {
22
+ const file = getCredentialsPath();
23
+ try {
24
+ const raw = await fsp.readFile(file, "utf8");
25
+ return JSON.parse(raw);
26
+ } catch {
27
+ return {};
28
+ }
29
+ }
30
+
31
+ export async function writeCredentials(obj) {
32
+ const file = getCredentialsPath();
33
+ await fsp.mkdir(path.dirname(file), { recursive: true });
34
+ await fsp.writeFile(file, JSON.stringify(obj, null, 2), "utf8");
35
+ if (process.platform !== "win32") {
36
+ await fsp.chmod(file, 0o600);
37
+ }
38
+ }
39
+
40
+ function getLoginUrl(baseUrl) {
41
+ return `${baseUrl}/project/#/3rd_party_connect/ldap/login?path=/auth/third_login&ones_from=${encodeURIComponent(baseUrl + "/project/#/workspace")}`;
42
+ }
43
+
44
+ async function extractSessionFromPage(page) {
45
+ return page.evaluate(() => {
46
+ const fromStorage = (key) => window.localStorage.getItem(key) || window.sessionStorage.getItem(key);
47
+
48
+ let authToken = fromStorage("ones-auth-token") || fromStorage("authToken") || fromStorage("token");
49
+ let userId = fromStorage("ones-user-id") || fromStorage("userId") || fromStorage("uid");
50
+
51
+ if (!authToken || !userId) {
52
+ const cookies = document.cookie.split(";").map((item) => item.trim()).filter(Boolean);
53
+ for (const cookie of cookies) {
54
+ const eqIndex = cookie.indexOf("=");
55
+ if (eqIndex === -1) continue;
56
+ const key = cookie.slice(0, eqIndex);
57
+ const value = decodeURIComponent(cookie.slice(eqIndex + 1));
58
+ if (!authToken && key.includes("ones-auth-token")) authToken = value;
59
+ if (!userId && key.includes("ones-user-id")) userId = value;
60
+ }
61
+ }
62
+
63
+ // Extract team-id from URL: /team/{uuid}/...
64
+ let teamId = null;
65
+ const teamMatch = window.location.href.match(/\/team\/([a-zA-Z0-9]{16,})/);
66
+ if (teamMatch) teamId = teamMatch[1];
67
+
68
+ return authToken && userId ? { authToken, userId, teamId } : null;
69
+ });
70
+ }
71
+
72
+ export async function runBrowserLoginCapture({ baseUrl, timeoutMs = 300000, verbose = false }) {
73
+ const browser = await chromium.launch({ headless: false });
74
+ const context = await browser.newContext();
75
+ const page = await context.newPage();
76
+ const loginUrl = getLoginUrl(baseUrl);
77
+
78
+ let resolved = false;
79
+
80
+ return new Promise(async (resolve, reject) => {
81
+ const finish = async (result, error) => {
82
+ if (resolved) return;
83
+ try {
84
+ if (error) throw error;
85
+ const credentialsToSave = { ...result, baseUrl };
86
+ await writeCredentials(credentialsToSave);
87
+ resolved = true;
88
+ resolve(credentialsToSave);
89
+ } catch (innerError) {
90
+ resolved = true;
91
+ reject(innerError);
92
+ } finally {
93
+ clearTimeout(timeout);
94
+ clearInterval(poller);
95
+ page.removeListener("response", onResponse);
96
+ page.removeListener("framenavigated", onNavigation);
97
+ try {
98
+ await browser.close();
99
+ } catch {
100
+ // Ignore browser close failures during auth teardown.
101
+ }
102
+ }
103
+ };
104
+
105
+ const tryPageSession = async () => {
106
+ let session = null;
107
+ try {
108
+ session = await extractSessionFromPage(page);
109
+ } catch {
110
+ // Cross-page transitions can temporarily break page evaluation.
111
+ }
112
+ if (session?.authToken && session?.userId) {
113
+ await finish(session, null);
114
+ }
115
+ };
116
+
117
+ const onResponse = async (res) => {
118
+ if (!res.url().includes("/sso/login") && !res.url().includes("/auth/login")) return;
119
+ try {
120
+ const headers = res.headers();
121
+ const authToken = headers["ones-auth-token"] ?? null;
122
+ const userId = headers["ones-user-id"] ?? null;
123
+ if (authToken && userId) {
124
+ // Try to extract teamId from current page URL
125
+ let teamId = null;
126
+ try {
127
+ const currentUrl = page.url();
128
+ const teamMatch = currentUrl.match(/\/team\/([a-zA-Z0-9]{16,})/);
129
+ if (teamMatch) teamId = teamMatch[1];
130
+ } catch { /* ignore */ }
131
+ await finish({ authToken, userId, teamId }, null);
132
+ return;
133
+ }
134
+ } catch {
135
+ // Ignore response parsing issues and fall back to page polling.
136
+ }
137
+ await tryPageSession();
138
+ };
139
+
140
+ const onNavigation = () => {
141
+ void tryPageSession();
142
+ };
143
+
144
+ const timeout = setTimeout(() => {
145
+ void finish(null, new Error("LOGIN_TIMEOUT"));
146
+ }, timeoutMs);
147
+
148
+ const poller = setInterval(() => {
149
+ void tryPageSession();
150
+ }, 1000);
151
+
152
+ page.on("response", onResponse);
153
+ page.on("framenavigated", onNavigation);
154
+
155
+ try {
156
+ if (verbose) process.stderr.write(`Opening browser login at ${loginUrl}\n`);
157
+ await page.goto(loginUrl, { waitUntil: "domcontentloaded", timeout: 60000 });
158
+ await tryPageSession();
159
+ } catch (error) {
160
+ await finish(null, error instanceof Error ? error : new Error(String(error)));
161
+ }
162
+ });
163
+ }
164
+
165
+ // ---------------------------------------------------------------------------
166
+ // loadAuth — read credentials for use in server
167
+ // ---------------------------------------------------------------------------
168
+
169
+ export async function loadAuth(cliBaseUrl) {
170
+ const creds = await readCredentials();
171
+ const authToken = creds.authToken;
172
+ const userId = creds.userId;
173
+ const baseUrl = cliBaseUrl ?? creds.baseUrl;
174
+ const teamId = creds.teamId;
175
+
176
+ if (!authToken || !userId || !baseUrl) {
177
+ throw new Error("Not authenticated");
178
+ }
179
+
180
+ return { authToken, userId, baseUrl, teamId };
181
+ }
package/src/server.mjs ADDED
@@ -0,0 +1,395 @@
1
+ #!/usr/bin/env node
2
+ import http from 'node:http';
3
+ import { readFile } from 'node:fs/promises';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { join, dirname } from 'node:path';
6
+ import { exec } from 'node:child_process';
7
+
8
+ const __dirname = dirname(fileURLToPath(import.meta.url));
9
+ const PUBLIC_DIR = join(__dirname, '..', 'public');
10
+ const PORT = process.env.PORT ?? 3000;
11
+
12
+ function openBrowser(url) {
13
+ const start = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
14
+ exec(`${start} ${url}`);
15
+ }
16
+
17
+ const authFlow = {
18
+ status: 'idle',
19
+ error: null,
20
+ startedAt: 0,
21
+ baseUrl: '',
22
+ teamId: '',
23
+ };
24
+
25
+ function normalizeBaseUrl(url) {
26
+ return String(url || '').replace(/\/+$/, '');
27
+ }
28
+
29
+ async function readJsonBody(req, res) {
30
+ let body = '';
31
+ for await (const chunk of req) body += chunk;
32
+ try {
33
+ return body ? JSON.parse(body) : {};
34
+ } catch {
35
+ res.writeHead(400, { 'Content-Type': 'application/json' });
36
+ res.end(JSON.stringify({ error: 'invalid_json' }));
37
+ return null;
38
+ }
39
+ }
40
+
41
+ function sendJson(res, statusCode, payload) {
42
+ res.writeHead(statusCode, { 'Content-Type': 'application/json' });
43
+ res.end(JSON.stringify(payload));
44
+ }
45
+
46
+ async function resolveRuntimeContext(overrides = {}) {
47
+ const { loadAuth } = await import('./auth.mjs');
48
+
49
+ const overrideBaseUrl = normalizeBaseUrl(overrides.baseUrl);
50
+ const envBaseUrl = normalizeBaseUrl(process.env.ONES_BASE_URL);
51
+ const requestedBaseUrl = overrideBaseUrl || envBaseUrl;
52
+
53
+ let auth = null;
54
+ try {
55
+ auth = await loadAuth();
56
+ } catch {
57
+ auth = null;
58
+ }
59
+
60
+ const authBaseUrl = normalizeBaseUrl(auth?.baseUrl);
61
+ if (requestedBaseUrl && authBaseUrl && requestedBaseUrl !== authBaseUrl) {
62
+ auth = null;
63
+ }
64
+
65
+ const baseUrl = requestedBaseUrl || authBaseUrl || '';
66
+ const teamId = overrides.teamId ?? auth?.teamId ?? process.env.ONES_TEAM_ID ?? '';
67
+
68
+ return {
69
+ authToken: auth?.authToken ?? null,
70
+ userId: auth?.userId ?? null,
71
+ baseUrl,
72
+ teamId,
73
+ };
74
+ }
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // Task crawling logic (inlined from ones-subtasks-cli.mjs)
78
+ // ---------------------------------------------------------------------------
79
+
80
+ async function resolveTaskNumber(baseUrl, teamId, taskNumber, authToken, userId) {
81
+ const url = `${baseUrl}/project/api/project/team/${teamId}/items/graphql?t=resolve-by-number`;
82
+ const body = JSON.stringify({
83
+ query: `{ tasks(filter: { number_equal: ${taskNumber} }) { uuid number } }`,
84
+ variables: {},
85
+ });
86
+ const res = await fetch(url, {
87
+ method: "POST",
88
+ headers: {
89
+ "accept": "application/json, text/plain, */*",
90
+ "content-type": "application/json;charset=UTF-8",
91
+ "ones-auth-token": authToken,
92
+ "ones-user-id": userId,
93
+ "referer": `${baseUrl}/project/`,
94
+ },
95
+ body,
96
+ });
97
+ if (res.status === 401) throw new Error("TOKEN_EXPIRED");
98
+ if (!res.ok) throw new Error(`GraphQL resolve → ${res.status}`);
99
+ const json = await res.json();
100
+ if (json?.errors?.length > 0) {
101
+ const msg = json.errors[0].message ?? String(json.errors[0]);
102
+ throw new Error(`GraphQL query failed for task number "${taskNumber}": ${msg}`);
103
+ }
104
+ const tasks = json?.data?.tasks ?? [];
105
+ if (tasks.length === 0) throw new Error(`No task found with number ${taskNumber}`);
106
+ return tasks[0].uuid;
107
+ }
108
+
109
+ async function fetchTaskInfo(baseUrl, teamId, taskUuid, authToken, userId) {
110
+ const url = `${baseUrl}/project/api/project/team/${teamId}/task/${taskUuid}/info`;
111
+ const res = await fetch(url, {
112
+ headers: {
113
+ "accept": "application/json, text/plain, */*",
114
+ "ones-auth-token": authToken,
115
+ "ones-user-id": userId,
116
+ "referer": `${baseUrl}/project/`,
117
+ },
118
+ });
119
+ if (res.status === 401) throw new Error("TOKEN_EXPIRED");
120
+ if (!res.ok) throw new Error(`GET ${url} → ${res.status}`);
121
+ return res.json();
122
+ }
123
+
124
+ function chunk(list, size) {
125
+ const chunks = [];
126
+ for (let i = 0; i < list.length; i += size) chunks.push(list.slice(i, i + size));
127
+ return chunks;
128
+ }
129
+
130
+ function getImportantFieldValue(task, fieldName) {
131
+ const match = (task.importantField ?? []).find((field) => field.name === fieldName);
132
+ return match?.value ?? null;
133
+ }
134
+
135
+ async function enrichTasks(baseUrl, teamId, tasks, authToken, userId) {
136
+ const details = new Map();
137
+
138
+ for (const uuidChunk of chunk([...new Set(tasks.map((task) => task.uuid))], 200)) {
139
+ const res = await fetch(`${baseUrl}/project/api/project/team/${teamId}/items/graphql?t=task-enrich`, {
140
+ method: "POST",
141
+ headers: {
142
+ accept: "application/json, text/plain, */*",
143
+ "content-type": "application/json;charset=UTF-8",
144
+ "ones-auth-token": authToken,
145
+ "ones-user-id": userId,
146
+ referer: `${baseUrl}/project/`,
147
+ },
148
+ body: JSON.stringify({
149
+ query: `query TaskEnrich($uuids: [String!]) {
150
+ tasks(filter: { uuid_in: $uuids }) {
151
+ uuid
152
+ number
153
+ name
154
+ deadline(unit: ONESDATE)
155
+ status {
156
+ uuid
157
+ name
158
+ category
159
+ }
160
+ parent {
161
+ uuid
162
+ }
163
+ project {
164
+ uuid
165
+ }
166
+ importantField {
167
+ name
168
+ value
169
+ fieldUUID
170
+ }
171
+ }
172
+ }`,
173
+ variables: {
174
+ uuids: uuidChunk,
175
+ },
176
+ }),
177
+ });
178
+
179
+ if (res.status === 401) throw new Error("TOKEN_EXPIRED");
180
+ if (!res.ok) throw new Error(`GraphQL enrich → ${res.status}`);
181
+
182
+ const json = await res.json();
183
+ const enrichedTasks = json?.data?.tasks ?? [];
184
+ for (const enriched of enrichedTasks) {
185
+ details.set(enriched.uuid, {
186
+ summary: enriched.name,
187
+ status_uuid: enriched.status?.uuid ?? null,
188
+ status_name: enriched.status?.name ?? null,
189
+ assign_name: getImportantFieldValue(enriched, "负责人"),
190
+ deadline: enriched.deadline ?? null,
191
+ parent_uuid: enriched.parent?.uuid || null,
192
+ project_uuid: enriched.project?.uuid ?? null,
193
+ });
194
+ }
195
+ }
196
+
197
+ return tasks.map((task) => {
198
+ const enriched = details.get(task.uuid);
199
+ if (!enriched) return task;
200
+ return {
201
+ ...task,
202
+ summary: enriched.summary ?? task.summary,
203
+ status_uuid: enriched.status_uuid ?? task.status_uuid,
204
+ status_name: enriched.status_name ?? task.status_name,
205
+ assign_name: enriched.assign_name ?? task.assign_name,
206
+ deadline: enriched.deadline ?? task.deadline,
207
+ parent_uuid: enriched.parent_uuid ?? task.parent_uuid,
208
+ project_uuid: enriched.project_uuid ?? task.project_uuid,
209
+ };
210
+ });
211
+ }
212
+
213
+ function extractTask(data) {
214
+ const status = typeof data.status === "object" && data.status ? data.status : null;
215
+ return {
216
+ uuid: data.uuid,
217
+ number: data.number,
218
+ summary: data.summary ?? data.name,
219
+ status_uuid: data.status_uuid ?? status?.uuid ?? "",
220
+ status_name: status?.name ?? null,
221
+ assign: typeof data.assign === "string" ? data.assign : data.assign?.uuid ?? "",
222
+ assign_name: typeof data.assign === "object" && data.assign ? data.assign.name : null,
223
+ priority: data.priority,
224
+ deadline: typeof data.deadline === "number" ? new Date(data.deadline * 1000).toISOString().slice(0, 10) : data.deadline ?? null,
225
+ parent_uuid: data.parent_uuid || data.parent?.uuid || null,
226
+ project_uuid: data.project_uuid || data.project?.uuid || null,
227
+ };
228
+ }
229
+
230
+ async function crawlTask(baseUrl, teamId, taskUuid, authToken, userId, depth, maxDepth, seen) {
231
+ if (depth > maxDepth || seen.has(taskUuid)) return [];
232
+ seen.add(taskUuid);
233
+
234
+ const data = await fetchTaskInfo(baseUrl, teamId, taskUuid, authToken, userId);
235
+ const task = extractTask(data);
236
+ const results = [task];
237
+
238
+ const subtasks = data.subtasks ?? data.subTasks ?? [];
239
+ for (const sub of subtasks) {
240
+ const children = await crawlTask(baseUrl, teamId, sub.uuid, authToken, userId, depth + 1, maxDepth, seen);
241
+ results.push(...children);
242
+ }
243
+ return results;
244
+ }
245
+
246
+ // ---------------------------------------------------------------------------
247
+ // HTTP handlers
248
+ // ---------------------------------------------------------------------------
249
+
250
+ async function handleAuthStatus(_req, res) {
251
+ if (authFlow.status === 'pending') {
252
+ return sendJson(res, 200, {
253
+ status: 'pending',
254
+ error: authFlow.error,
255
+ baseUrl: authFlow.baseUrl,
256
+ teamId: authFlow.teamId,
257
+ });
258
+ }
259
+
260
+ const context = await resolveRuntimeContext();
261
+ if (context.authToken && context.userId && context.baseUrl) {
262
+ return sendJson(res, 200, {
263
+ status: 'authenticated',
264
+ baseUrl: context.baseUrl,
265
+ teamId: context.teamId,
266
+ });
267
+ }
268
+
269
+ return sendJson(res, 200, {
270
+ status: 'unauthenticated',
271
+ error: authFlow.error,
272
+ baseUrl: context.baseUrl,
273
+ teamId: context.teamId,
274
+ });
275
+ }
276
+
277
+ async function handleAuthLogin(req, res) {
278
+ const body = await readJsonBody(req, res);
279
+ if (!body) return;
280
+
281
+ const context = await resolveRuntimeContext({ baseUrl: body.baseUrl, teamId: body.teamId });
282
+ if (!context.baseUrl) {
283
+ return sendJson(res, 400, { error: 'missing_base_url', detail: 'Need ONES base URL to open the login window.' });
284
+ }
285
+
286
+ if (authFlow.status === 'pending') {
287
+ return sendJson(res, 200, { status: 'pending', baseUrl: authFlow.baseUrl });
288
+ }
289
+
290
+ authFlow.status = 'pending';
291
+ authFlow.error = null;
292
+ authFlow.startedAt = Date.now();
293
+ authFlow.baseUrl = context.baseUrl;
294
+ authFlow.teamId = context.teamId;
295
+
296
+ void (async () => {
297
+ try {
298
+ const { runBrowserLoginCapture } = await import('./auth.mjs');
299
+ await runBrowserLoginCapture({ baseUrl: context.baseUrl });
300
+ authFlow.status = 'idle';
301
+ authFlow.error = null;
302
+ authFlow.baseUrl = '';
303
+ authFlow.teamId = '';
304
+ } catch (error) {
305
+ authFlow.status = 'idle';
306
+ authFlow.error = error?.message ?? String(error);
307
+ authFlow.baseUrl = context.baseUrl;
308
+ authFlow.teamId = context.teamId;
309
+ }
310
+ })();
311
+
312
+ return sendJson(res, 200, { status: 'pending', baseUrl: context.baseUrl });
313
+ }
314
+
315
+ async function handleCrawl(req, res) {
316
+ const body = await readJsonBody(req, res);
317
+ if (!body) return;
318
+
319
+ const { taskIds, baseUrl: requestBaseUrl, teamId: requestTeamId } = body;
320
+ if (!Array.isArray(taskIds) || taskIds.length === 0) {
321
+ return sendJson(res, 400, { error: 'taskIds must be a non-empty array' });
322
+ }
323
+
324
+ try {
325
+ const { authToken, userId, baseUrl, teamId } = await resolveRuntimeContext({
326
+ baseUrl: requestBaseUrl,
327
+ teamId: requestTeamId,
328
+ });
329
+
330
+ if (!authToken || !userId || !baseUrl) {
331
+ return sendJson(res, 401, {
332
+ error: 'auth_required',
333
+ detail: 'Connect ONES first to let the local service capture credentials.',
334
+ baseUrl,
335
+ teamId,
336
+ });
337
+ }
338
+
339
+ if (!teamId) {
340
+ return sendJson(res, 500, { error: 'missing_config', detail: 'team-id not configured. Please login to auto-detect it.' });
341
+ }
342
+
343
+ const seen = new Set();
344
+ const roots = [];
345
+ const allTasks = [];
346
+
347
+ for (const rawId of taskIds) {
348
+ let rootUuid;
349
+ if (/^\d+$/.test(rawId)) {
350
+ rootUuid = await resolveTaskNumber(baseUrl, teamId, rawId, authToken, userId);
351
+ } else {
352
+ rootUuid = rawId;
353
+ }
354
+ roots.push(rootUuid);
355
+ const tasks = await crawlTask(baseUrl, teamId, rootUuid, authToken, userId, 0, 10, seen);
356
+ for (const t of tasks) allTasks.push({ ...t, root_uuid: rootUuid });
357
+ }
358
+
359
+ const enrichedTasks = await enrichTasks(baseUrl, teamId, allTasks, authToken, userId).catch(() => allTasks);
360
+
361
+ return sendJson(res, 200, { roots, tasks: enrichedTasks, baseUrl, teamId });
362
+ } catch (err) {
363
+ const tokenExpired = err.message?.includes('401') || err.message?.includes('Token expired') || err.message?.includes('TOKEN_EXPIRED');
364
+ return sendJson(res, tokenExpired ? 401 : 500, { error: tokenExpired ? 'token_expired' : 'crawl_failed', detail: err.message });
365
+ }
366
+ }
367
+
368
+ const server = http.createServer(async (req, res) => {
369
+ if (req.method === 'GET' && req.url === '/') {
370
+ try {
371
+ const html = await readFile(join(PUBLIC_DIR, 'index.html'), 'utf8');
372
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
373
+ res.end(html);
374
+ } catch {
375
+ res.writeHead(404); res.end('index.html not found');
376
+ }
377
+ return;
378
+ }
379
+ if (req.method === 'GET' && req.url === '/api/auth/status') {
380
+ return handleAuthStatus(req, res);
381
+ }
382
+ if (req.method === 'POST' && req.url === '/api/auth/login') {
383
+ return handleAuthLogin(req, res);
384
+ }
385
+ if (req.method === 'POST' && req.url === '/api/crawl') {
386
+ return handleCrawl(req, res);
387
+ }
388
+ res.writeHead(404); res.end('Not found');
389
+ });
390
+
391
+ server.listen(PORT, () => {
392
+ const url = `http://localhost:${PORT}`;
393
+ process.stdout.write(`ONES Fetch Web UI → ${url}\n`);
394
+ openBrowser(url);
395
+ });