task-helpers.js
1 /** 2 * ONES filters/peek 响应解析(tasks / my-tasks 共用) 3 */ 4 import { CliError } from '@jackwener/opencli/errors'; 5 /** ONES task 里 field_values 常为 [{ field_uuid, value }, ...] */ 6 function pickTitleFromFieldValuesArray(fv) { 7 if (!Array.isArray(fv)) 8 return ''; 9 for (const item of fv) { 10 if (!item || typeof item !== 'object') 11 continue; 12 const row = item; 13 const fu = String(row.field_uuid ?? ''); 14 if (!fu.startsWith('field')) 15 continue; 16 const v = row.value; 17 if (typeof v === 'string' && v.trim()) 18 return v.trim(); 19 if (Array.isArray(v) && v.length && typeof v[0] === 'string' && v[0].trim()) 20 return v[0].trim(); 21 } 22 return ''; 23 } 24 export function pickTaskTitle(e) { 25 for (const k of ['summary', 'name', 'title', 'subject']) { 26 const v = e[k]; 27 if (typeof v === 'string' && v.trim()) 28 return v.trim(); 29 } 30 const fromArr = pickTitleFromFieldValuesArray(e.field_values); 31 if (fromArr) 32 return fromArr; 33 const fv = e.field_values; 34 if (fv && typeof fv === 'object' && !Array.isArray(fv)) { 35 const o = fv; 36 for (const k of ['field001', 'field002', 'field003']) { 37 const v = o[k]; 38 if (typeof v === 'string' && v.trim()) 39 return v.trim(); 40 } 41 } 42 return ''; 43 } 44 /** 表格里标题别撑爆终端 */ 45 export function ellipsizeCell(s, max = 64) { 46 const t = s.trim(); 47 if (t.length <= max) 48 return t; 49 return `${t.slice(0, max - 1)}…`; 50 } 51 /** 辅助列:长 uuid 缩略,完整值见 -f json */ 52 export function briefUuid(id, head = 6, tail = 4) { 53 if (!id) 54 return ''; 55 if (id.length <= head + tail + 1) 56 return id; 57 return `${id.slice(0, head)}…${id.slice(-tail)}`; 58 } 59 export function formatStamp(v) { 60 if (v == null || v === '') 61 return ''; 62 const n = Number(v); 63 if (Number.isNaN(n)) 64 return String(v); 65 const ms = n > 1e14 ? Math.floor(n / 1000) : n > 1e12 ? n : n * 1000; 66 try { 67 return new Date(ms).toISOString().replace('T', ' ').slice(0, 19); 68 } 69 catch { 70 return String(v); 71 } 72 } 73 export function flattenPeekGroups(parsed, limit) { 74 if (!Array.isArray(parsed.groups)) { 75 throw new CliError('FETCH_ERROR', 'Unexpected filters/peek response (missing groups)', 'Try -f json; check team UUID and API version.'); 76 } 77 const groups = parsed.groups; 78 const rows = []; 79 for (const g of groups) { 80 const entries = Array.isArray(g.entries) ? g.entries : []; 81 for (const e of entries) { 82 rows.push(e); 83 if (rows.length >= limit) 84 break; 85 } 86 if (rows.length >= limit) 87 break; 88 } 89 return rows.slice(0, limit); 90 } 91 function fieldArrayFirstString(fv, fieldUuid) { 92 if (!Array.isArray(fv)) 93 return ''; 94 for (const item of fv) { 95 if (!item || typeof item !== 'object') 96 continue; 97 const row = item; 98 if (String(row.field_uuid ?? '') !== fieldUuid) 99 continue; 100 const v = row.value; 101 if (typeof v === 'string') 102 return v; 103 if (Array.isArray(v) && v[0] != null) 104 return String(v[0]); 105 } 106 return ''; 107 } 108 function fvRecord(e) { 109 const fv = e.field_values; 110 return fv && typeof fv === 'object' && !Array.isArray(fv) ? fv : null; 111 } 112 /** 工作项状态 uuid(用于查 task_statuses 得中文名) */ 113 export function getTaskStatusRawId(e) { 114 const fv = e.field_values; 115 const fvObj = fvRecord(e); 116 if (typeof e.status_uuid === 'string') 117 return e.status_uuid; 118 return fieldArrayFirstString(fv, 'field016') || (fvObj ? String(fvObj.field016 ?? '') : ''); 119 } 120 /** 项目 uuid */ 121 export function getTaskProjectRawId(e) { 122 const fv = e.field_values; 123 const fvObj = fvRecord(e); 124 if (typeof e.project_uuid === 'string') 125 return e.project_uuid; 126 return fieldArrayFirstString(fv, 'field006') || (fvObj ? String(fvObj.field006 ?? '') : ''); 127 } 128 /** 129 * Project API 里 assess/total/remaining_manhour 多为**定点整数**(与 Web 上「小时」不一致); 130 * 常见换算:raw / 1e5 ≈ 小时。若你方实例不同,可设 `ONES_MANHOUR_SCALE`(默认 100000)。 131 */ 132 export function onesManhourScale() { 133 const raw = Number(process.env.ONES_MANHOUR_SCALE?.trim()); 134 if (Number.isFinite(raw) && raw > 0) 135 return raw; 136 return 1e5; 137 } 138 /** 界面/h 小数 → API 内 manhour 整数(与列表「工时」列同一刻度) */ 139 export function hoursToOnesManhourRaw(hours) { 140 if (!Number.isFinite(hours) || hours <= 0) 141 return 0; 142 return Math.max(1, Math.round(hours * onesManhourScale())); 143 } 144 function formatHoursShort(hours) { 145 if (!Number.isFinite(hours)) 146 return ''; 147 const snapped = Math.round(hours * 1e6) / 1e6; 148 const near = Math.round(snapped); 149 if (Math.abs(snapped - near) < 1e-5) 150 return `${near}h`; 151 const t = Math.round(snapped * 10) / 10; 152 return Number.isInteger(t) ? `${t}h` : `${t.toFixed(1)}h`; 153 } 154 function formatManhourSegment(label, v) { 155 if (v == null || v === '') 156 return null; 157 const n = Number(v); 158 if (!Number.isFinite(n)) 159 return null; 160 const hours = n / onesManhourScale(); 161 return `${label}${formatHoursShort(hours)}`; 162 } 163 export function formatTaskManhourSummary(e) { 164 const parts = []; 165 const a = formatManhourSegment('估', e.assess_manhour); 166 const t = formatManhourSegment('登', e.total_manhour); 167 const r = formatManhourSegment('余', e.remaining_manhour); 168 if (a) 169 parts.push(a); 170 if (t) 171 parts.push(t); 172 if (r) 173 parts.push(r); 174 return parts.length ? parts.join(' ') : '—'; 175 } 176 export function mapTaskEntry(e, labels) { 177 const statusId = getTaskStatusRawId(e); 178 const projectId = getTaskProjectRawId(e); 179 const fullUuid = String(e.uuid ?? ''); 180 const title = ellipsizeCell(pickTaskTitle(e)); 181 const briefIfLong = (s) => (s.length > 14 ? briefUuid(s) : s); 182 const statusLabel = labels?.statusByUuid?.get(statusId) ?? briefIfLong(statusId); 183 const projectLabel = labels?.projectByUuid?.get(projectId) ?? briefIfLong(projectId); 184 return { 185 title, 186 status: ellipsizeCell(statusLabel, 20), 187 project: ellipsizeCell(projectLabel, 40), 188 uuid: fullUuid, 189 updated: formatStamp(e.server_update_stamp), 190 工时: ellipsizeCell(formatTaskManhourSummary(e), 36), 191 }; 192 } 193 export function defaultPeekBody(query) { 194 return { 195 with_boards: false, 196 boards: null, 197 query, 198 group_by: '', 199 sort: [{ create_time: { order: 'desc' } }], 200 include_subtasks: false, 201 include_status_uuid: true, 202 include_issue_type: false, 203 include_project_uuid: true, 204 is_show_derive: false, 205 }; 206 } 207 export function parsePeekLimit(value, fallback) { 208 const parsed = Number.parseInt(String(value ?? ''), 10); 209 if (!Number.isFinite(parsed) || parsed <= 0) 210 return fallback; 211 return Math.max(1, Math.min(500, parsed)); 212 }