/ clis / ones / task-helpers.js
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  }