logwork-cli 1.1.1 → 1.2.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.
Files changed (64) hide show
  1. package/README.md +23 -7
  2. package/RELEASE.md +1 -0
  3. package/commands/commits.mjs +57 -6
  4. package/commands/history.mjs +0 -4
  5. package/commands/quick.mjs +38 -23
  6. package/commands/reminder.mjs +41 -2
  7. package/commands/setup.mjs +50 -16
  8. package/commands/tui.mjs +1 -0
  9. package/docs/advanced.md +12 -2
  10. package/docs/mcp-setup.md +56 -14
  11. package/docs/operations-guide.md +35 -2
  12. package/docs/recipes.md +27 -0
  13. package/docs/security.md +3 -1
  14. package/examples/mcp/antigravity.mcp_config.json +1 -1
  15. package/examples/mcp/codex.config.toml +1 -1
  16. package/examples/mcp/copilot.mcp.json +1 -1
  17. package/lib/api-auth-forms.mjs +393 -0
  18. package/lib/api-auth-http.mjs +184 -0
  19. package/lib/api-auth-support.mjs +101 -0
  20. package/lib/api-auth-tokens.mjs +129 -0
  21. package/lib/api-auth.mjs +39 -813
  22. package/lib/api.mjs +38 -1422
  23. package/lib/apply-ledger.mjs +1 -1
  24. package/lib/batch-workflow.mjs +187 -2
  25. package/lib/commit-workflow.mjs +188 -21
  26. package/lib/config.mjs +7 -0
  27. package/lib/diagnostics.mjs +53 -4
  28. package/lib/jira-api.mjs +25 -3
  29. package/lib/jira-workflow.mjs +36 -6
  30. package/lib/logwork-config.mjs +64 -13
  31. package/lib/manual-apply-state.mjs +15 -0
  32. package/lib/manual-commands.mjs +2 -2
  33. package/lib/manual-ink-app.mjs +108 -1281
  34. package/lib/manual-ink-draft-actions.mjs +227 -0
  35. package/lib/manual-ink-interaction.mjs +306 -0
  36. package/lib/manual-ink-session.mjs +41 -0
  37. package/lib/manual-ink-task-editor.mjs +338 -0
  38. package/lib/manual-ink-wizard.mjs +520 -0
  39. package/lib/manual-mcp.mjs +27 -15
  40. package/lib/manual-repl.mjs +39 -3
  41. package/lib/mcp-server.mjs +51 -20
  42. package/lib/node-runtime.mjs +115 -0
  43. package/lib/paths.mjs +7 -0
  44. package/lib/project-mapping-workflow.mjs +30 -7
  45. package/lib/query-workflow.mjs +0 -1
  46. package/lib/reconciliation-workflow.mjs +1 -1
  47. package/lib/reminder-config.mjs +38 -2
  48. package/lib/reminder-scheduler.mjs +54 -3
  49. package/lib/reminder-service.mjs +28 -3
  50. package/lib/reminder-workflow.mjs +118 -22
  51. package/lib/ro-api-diagnostics.mjs +109 -0
  52. package/lib/ro-api-entries.mjs +359 -0
  53. package/lib/ro-api-fields.mjs +290 -0
  54. package/lib/ro-api-http.mjs +68 -0
  55. package/lib/ro-api-projects.mjs +137 -0
  56. package/lib/ro-api-tasks.mjs +50 -0
  57. package/lib/ro-api-timesheet.mjs +413 -0
  58. package/lib/ro-delete-workflow.mjs +2 -1
  59. package/lib/ro-entry-reader.mjs +11 -6
  60. package/lib/ro-resubmit-workflow.mjs +2 -1
  61. package/lib/terminal-auth-prompts.mjs +7 -4
  62. package/lib/ui.mjs +80 -59
  63. package/package.json +11 -4
  64. package/skills/logwork/SKILL.md +5 -4
package/lib/api.mjs CHANGED
@@ -1,1422 +1,38 @@
1
- import { CONFIG } from './config.mjs';
2
- import { fetchWithPolicy, redactedExcerpt } from './http.mjs';
3
- import { addLocalDays, mapLimit, safeJsonParse, toApiLogDate } from './util.mjs';
4
- import {
5
- mergeWorklogTaskLists,
6
- normalizeWorklogTask,
7
- resolveWorklogTask
8
- } from './worklog-task-resolver.mjs';
9
-
10
- const LOGWORK_TYPE_OF_WORK = new Set(['create', 'correct', 'improve', 'other']);
11
-
12
- const NORMALIZATION_DIAGNOSTICS_KEY = 'normalization';
13
- const MAX_DIAGNOSTIC_ITEMS = 25;
14
-
15
- export class ApiError extends Error {
16
- constructor(message, { status, path, body } = {}) {
17
- super(message);
18
- this.name = 'ApiError';
19
- this.status = status;
20
- this.path = path;
21
- this.body = body;
22
- }
23
- }
24
-
25
- export async function apiFetch(token, path, options = {}) {
26
- const {
27
- fetchImpl,
28
- retries,
29
- retryDelayMs = CONFIG.httpRetryDelayMs,
30
- timeoutMs = CONFIG.httpTimeoutMs,
31
- cache: _worklogTaskCache,
32
- taskCache: _taskCacheAlias,
33
- ...requestOptions
34
- } = options;
35
- const method = requestOptions.method || 'GET';
36
- const response = await fetchWithPolicy(`${CONFIG.apiBase}${path}`, {
37
- ...requestOptions,
38
- fetchImpl,
39
- timeoutMs,
40
- retryDelayMs,
41
- retries: retries ?? CONFIG.httpReadRetries,
42
- idempotent: isIdempotentMethod(method),
43
- method,
44
- headers: {
45
- Authorization: `Bearer ${token}`,
46
- Accept: 'application/json',
47
- 'Content-Type': 'application/json',
48
- ...requestOptions.headers
49
- }
50
- });
51
-
52
- const text = await response.text();
53
- const data = text ? safeJsonParse(text, text) : null;
54
- const safeBody = redactedExcerpt(text);
55
-
56
- if (response.status === 401) {
57
- throw new ApiError('API request returned 401 Unauthorized.', {
58
- status: response.status,
59
- path,
60
- body: safeBody
61
- });
62
- }
63
-
64
- if (!response.ok) {
65
- throw new ApiError(`API request failed: ${response.status} ${response.statusText} for ${path}. Body: ${safeBody}`, {
66
- status: response.status,
67
- path,
68
- body: safeBody
69
- });
70
- }
71
-
72
- return data;
73
- }
74
-
75
- function isIdempotentMethod(method) {
76
- return ['GET', 'HEAD', 'OPTIONS'].includes(String(method || 'GET').toUpperCase());
77
- }
78
-
79
- export function getNormalizationDiagnostics(value) {
80
- return value?.[NORMALIZATION_DIAGNOSTICS_KEY] || emptyNormalizationDiagnostics();
81
- }
82
-
83
- export async function getProjects(token, userId) {
84
- const qs = new URLSearchParams({
85
- f_user_id: String(userId),
86
- 'f_project.stage_id': '!6'
87
- });
88
-
89
- const data = await apiFetch(token, `${CONFIG.projectMembersPath}?${qs}`);
90
- return unwrapRecords(data).map(normalizeProject).filter((project) => project.projectMemberId);
91
- }
92
-
93
- export async function getDayLogs(token, projectId, userId, localDateISO) {
94
- const qs = new URLSearchParams({
95
- f_project_id: String(projectId),
96
- f_user_id: String(userId),
97
- f_logdate: toApiLogDate(localDateISO)
98
- });
99
-
100
- try {
101
- const data = await apiFetch(token, `${CONFIG.memberLogtimePath}?${qs}`);
102
- const logs = unwrapRecords(data);
103
- const entries = normalizeLogtimeEntries(logs, {
104
- date: localDateISO,
105
- projectId
106
- });
107
- const normalization = getNormalizationDiagnostics(entries);
108
-
109
- return {
110
- projectId,
111
- totalHours: entries.reduce((sum, log) => sum + Number(log.hours || 0), 0),
112
- entries,
113
- logs,
114
- normalization,
115
- ok: true
116
- };
117
- } catch (error) {
118
- if (error instanceof ApiError && error.status === 401) {
119
- throw error;
120
- }
121
-
122
- return {
123
- projectId,
124
- totalHours: null,
125
- logs: [],
126
- ok: false,
127
- error: error.message
128
- };
129
- }
130
- }
131
-
132
- export function normalizeLogtimeEntries(logs, defaults = {}) {
133
- const rows = unwrapRecords(logs);
134
- const diagnostics = createNormalizationDiagnostics('logtime_entries');
135
- diagnostics.rowsRead = rows.length;
136
- const entries = [];
137
-
138
- rows.forEach((log, index) => {
139
- if (!log || typeof log !== 'object') {
140
- addDroppedRow(diagnostics, 'invalid_log_entry', { index });
141
- return;
142
- }
143
-
144
- const context = { index, kind: 'logtime_entry' };
145
- const date = normalizeDateOnly(log.logdate ?? log.log_date ?? log.date) || defaults.date;
146
- const projectMemberId = readExplicitProjectMemberId(log) ?? defaults.projectMemberId;
147
- const projectId = readProjectId(log) ?? defaults.projectId;
148
- const hours = readNumber(log, ['logtimes', 'hours', 'time'], diagnostics, context);
149
-
150
- if (!date) {
151
- addDroppedRow(diagnostics, 'missing_date', { ...context, ...diagnosticProjectFields({ projectMemberId, projectId }) });
152
- return;
153
- }
154
-
155
- if (projectMemberId === undefined && projectId === undefined) {
156
- addDroppedRow(diagnostics, 'missing_project_identity', { ...context, date });
157
- return;
158
- }
159
-
160
- if (hours <= 0) {
161
- addDroppedRow(diagnostics, 'non_positive_hours', { ...context, date, ...diagnosticProjectFields({ projectMemberId, projectId }) });
162
- return;
163
- }
164
-
165
- diagnostics.rowsAccepted += 1;
166
- entries.push({
167
- id: log.id ?? log.logtime_id ?? log.logtimeId,
168
- date,
169
- projectMemberId,
170
- projectId,
171
- projectName: hasProjectName(log) ? readProjectName(log, readProjectId(log), diagnostics, context) : defaults.projectName,
172
- hours,
173
- taskName: log.task_name ?? log.taskName ?? log.name ?? log.title ?? ''
174
- });
175
- });
176
-
177
- return attachNormalizationDiagnostics(entries, diagnostics);
178
- }
179
-
180
- export async function getAllDayLogSummaries(token, projects, userId, localDateISO) {
181
- return mapLimit(projects, CONFIG.dayLogConcurrency, (project) => (
182
- getDayLogs(token, project.projectId, userId, localDateISO)
183
- ));
184
- }
185
-
186
- export async function getTimesheetRange(token, userId, { from, to }) {
187
- const { records } = await getTimesheetRangeResult(token, userId, { from, to });
188
- return records;
189
- }
190
-
191
- export async function getTimesheetRangeResult(token, userId, { from, to }) {
192
- const qs = new URLSearchParams({
193
- f_user_id: String(userId),
194
- f_from: toApiLogDate(from),
195
- f_to: toApiLogDate(to),
196
- f_timesheet_by_week: '0',
197
- f_time_off: '1'
198
- });
199
-
200
- const data = await apiFetch(token, `${CONFIG.timesheetPath}?${qs}`);
201
- return normalizeTimesheetRangeResult(data, { from, to });
202
- }
203
-
204
- export function normalizeTimesheetRange(data, { from, to } = {}) {
205
- return normalizeTimesheetRangeResult(data, { from, to }).records;
206
- }
207
-
208
- export function normalizeTimesheetRangeResult(data, { from, to } = {}) {
209
- const diagnostics = createNormalizationDiagnostics('unknown');
210
- const explicitRows = expandProjectTimesheetRows(data, { from, to }, diagnostics);
211
- const rows = explicitRows.length ? explicitRows : findTimesheetRowsInRange(data, { from, to });
212
- diagnostics.sourceShape = explicitRows.length ? 'project_timesheet' : 'range_scan';
213
- if (!rows.length && hasObjectPayload(data)) {
214
- addWarning(diagnostics, 'unknown_timesheet_shape');
215
- }
216
- const records = normalizeTimesheetRecords(rows, diagnostics);
217
- return {
218
- records,
219
- normalization: getNormalizationDiagnostics(records)
220
- };
221
- }
222
-
223
- export async function getBookedProjectsForDate(token, userId, localDateISO) {
224
- const { records } = await getTimesheetRangeResult(token, userId, {
225
- from: localDateISO,
226
- to: addLocalDays(localDateISO, 1)
227
- });
228
- return normalizeTimesheetProjects(records, localDateISO);
229
- }
230
-
231
- export async function getTodayTimesheetProjects(token, userId, localDateISO) {
232
- return getBookedProjectsForDate(token, userId, localDateISO);
233
- }
234
-
235
- export async function getLogtimeById(token, logtimeId, options = {}) {
236
- const path = `${CONFIG.memberLogtimePath}/${encodeURIComponent(assertLogtimeId(logtimeId, 'logtimeId'))}`;
237
- const data = await apiFetch(token, path, options);
238
- const record = extractLogtimeRecord(data);
239
- if (!record) {
240
- throw new ApiError(`Resource Optimiser did not return logwork ${logtimeId}.`, {
241
- path
242
- });
243
- }
244
- return normalizeEditableLogtime(record);
245
- }
246
-
247
- export async function getWorklogTasks(token, { projectId, search } = {}, options = {}) {
248
- const path = `${CONFIG.logworkTasksPath}${buildLogworkTasksQuery({ projectId, search })}`;
249
- const data = await apiFetch(token, path, options);
250
- return unwrapRecords(data).map(normalizeWorklogTask);
251
- }
252
-
253
- export async function getDefaultWorklogTasks(token, { projectId, search } = {}, options = {}) {
254
- const path = `${CONFIG.logworkDefaultTasksPath}${buildLogworkTasksQuery({ projectId, search })}`;
255
- const data = await apiFetch(token, path, options);
256
- return unwrapRecords(data).map(normalizeWorklogTask);
257
- }
258
-
259
- export async function fetchWorklogTasksForProject(token, projectId, options = {}) {
260
- const cacheKey = Number(projectId);
261
- const { cache, ...apiOptions } = options;
262
- if (cache instanceof Map && cache.has(cacheKey)) {
263
- return cache.get(cacheKey);
264
- }
265
-
266
- const [projectTasks, defaultTasks] = await Promise.all([
267
- getWorklogTasks(token, { projectId: cacheKey }, apiOptions),
268
- getDefaultWorklogTasks(token, { projectId: cacheKey }, apiOptions)
269
- ]);
270
- const merged = mergeWorklogTaskLists(projectTasks, defaultTasks);
271
- if (cache instanceof Map) {
272
- cache.set(cacheKey, merged);
273
- }
274
- return merged;
275
- }
276
-
277
- export async function createLogworkEntry(token, {
278
- worklogTaskId,
279
- projectId,
280
- effortHours,
281
- localDateISO,
282
- typeOfWork = CONFIG.defaultTypeOfWork,
283
- description,
284
- needsProjectId = false
285
- }, options = {}) {
286
- const normalizedTaskId = assertLogtimeId(worklogTaskId, 'worklogTaskId');
287
- const normalizedProjectId = assertPositiveProjectId(projectId);
288
- const normalizedHours = Number(effortHours);
289
- const normalizedTypeOfWork = String(typeOfWork || CONFIG.defaultTypeOfWork).trim();
290
- if (!Number.isFinite(normalizedHours) || normalizedHours <= 0) {
291
- throw new Error('effortHours must be a positive number.');
292
- }
293
- if (!LOGWORK_TYPE_OF_WORK.has(normalizedTypeOfWork)) {
294
- throw new Error(`typeOfWork must be one of: ${[...LOGWORK_TYPE_OF_WORK].join(', ')}.`);
295
- }
296
- if (!/^\d{4}-\d{2}-\d{2}$/.test(String(localDateISO || ''))) {
297
- throw new Error('localDateISO must use YYYY-MM-DD format.');
298
- }
299
-
300
- const path = CONFIG.logworkEntriesPath;
301
- const body = {
302
- worklog_task_id: preserveNumericId(normalizedTaskId),
303
- effort_hours: normalizedHours,
304
- logdate: localDateISO,
305
- type_of_work: normalizedTypeOfWork
306
- };
307
- if (description !== undefined && description !== null && String(description).trim()) {
308
- body.description = String(description).trim();
309
- }
310
- if (needsProjectId) {
311
- body.project_id = preserveNumericId(normalizedProjectId);
312
- }
313
-
314
- if (process.env.LOGWORK_DRY_RUN === '1') {
315
- return {
316
- dryRun: true,
317
- method: 'POST',
318
- path,
319
- body
320
- };
321
- }
322
-
323
- try {
324
- const response = await apiFetch(token, path, {
325
- ...options,
326
- method: 'POST',
327
- body: JSON.stringify(body)
328
- });
329
- const record = extractLogtimeRecord(response);
330
- return {
331
- dryRun: false,
332
- entry: record ? normalizeEditableLogtime(record) : null,
333
- response
334
- };
335
- } catch (error) {
336
- throw mapLogworkEntryApiError(error);
337
- }
338
- }
339
-
340
- export async function getLogworkDay(token, { date, projectId } = {}, options = {}) {
341
- if (!/^\d{4}-\d{2}-\d{2}$/.test(String(date || ''))) {
342
- throw new Error('date must use YYYY-MM-DD format.');
343
- }
344
- const qs = new URLSearchParams({ date });
345
- if (projectId !== undefined && projectId !== null && projectId !== '') {
346
- qs.set('project_id', String(projectId));
347
- }
348
- const data = await apiFetch(token, `${CONFIG.logworkDayPath}?${qs}`, options);
349
- return normalizeLogworkDayPayload(unwrapLogworkObject(data));
350
- }
351
-
352
- export async function getLogworkWeekly(token, { weekStart } = {}, options = {}) {
353
- if (!/^\d{4}-\d{2}-\d{2}$/.test(String(weekStart || ''))) {
354
- throw new Error('weekStart must use YYYY-MM-DD format.');
355
- }
356
- const qs = new URLSearchParams({ week_start: weekStart });
357
- const data = await apiFetch(token, `${CONFIG.logworkWeeklyPath}?${qs}`, options);
358
- return normalizeLogworkWeeklyPayload(unwrapLogworkObject(data));
359
- }
360
-
361
- export async function getLogworkRejected(token, options = {}) {
362
- const data = await apiFetch(token, CONFIG.logworkRejectedPath, options);
363
- return unwrapRecords(data).map(normalizeLogworkF03Entry);
364
- }
365
-
366
- export async function resubmitLogworkEntry(token, entryId, {
367
- effortHours,
368
- typeOfWork = CONFIG.defaultTypeOfWork,
369
- noteToPm,
370
- description
371
- }, options = {}) {
372
- const id = assertLogtimeId(entryId, 'entryId');
373
- const normalizedHours = Number(effortHours);
374
- const normalizedTypeOfWork = String(typeOfWork || CONFIG.defaultTypeOfWork).trim();
375
- if (!Number.isFinite(normalizedHours) || normalizedHours <= 0) {
376
- throw new Error('effortHours must be a positive number.');
377
- }
378
- if (!LOGWORK_TYPE_OF_WORK.has(normalizedTypeOfWork)) {
379
- throw new Error(`typeOfWork must be one of: ${[...LOGWORK_TYPE_OF_WORK].join(', ')}.`);
380
- }
381
-
382
- const path = `${CONFIG.logworkEntriesPath}/${encodeURIComponent(String(id))}/resubmit`;
383
- const body = {
384
- effort_hours: normalizedHours,
385
- type_of_work: normalizedTypeOfWork
386
- };
387
- if (noteToPm !== undefined && String(noteToPm).trim()) {
388
- body.note_to_pm = String(noteToPm).trim();
389
- }
390
- if (description !== undefined && String(description).trim()) {
391
- body.description = String(description).trim();
392
- }
393
-
394
- if (process.env.LOGWORK_DRY_RUN === '1') {
395
- return { dryRun: true, method: 'PATCH', path, body };
396
- }
397
-
398
- try {
399
- const response = await apiFetch(token, path, {
400
- ...options,
401
- method: 'PATCH',
402
- body: JSON.stringify(body)
403
- });
404
- const record = extractLogtimeRecord(response);
405
- return {
406
- dryRun: false,
407
- entry: record ? normalizeLogworkF03Entry(record) : null,
408
- response
409
- };
410
- } catch (error) {
411
- throw mapLogworkEntryApiError(error);
412
- }
413
- }
414
-
415
- export async function deleteLogworkEntry(token, entryId, options = {}) {
416
- const id = assertLogtimeId(entryId, 'entryId');
417
- const path = `${CONFIG.logworkEntriesPath}/${encodeURIComponent(String(id))}`;
418
-
419
- if (process.env.LOGWORK_DRY_RUN === '1') {
420
- return { dryRun: true, method: 'DELETE', path };
421
- }
422
-
423
- try {
424
- const response = await apiFetch(token, path, {
425
- ...options,
426
- method: 'DELETE'
427
- });
428
- return { dryRun: false, response };
429
- } catch (error) {
430
- throw mapLogworkEntryApiError(error);
431
- }
432
- }
433
-
434
- export function normalizeLogworkF03Entry(record = {}) {
435
- const base = normalizeEditableLogtime(record);
436
- return {
437
- ...base,
438
- typeOfWork: record.type_of_work ?? record.typeOfWork ?? '',
439
- worklogTaskId: record.worklog_task_id ?? record.worklogTaskId ?? null,
440
- description: record.description ?? '',
441
- rejectionComment: record.rejection_comment ?? record.rejectionComment ?? '',
442
- noteToPm: record.note_to_pm ?? record.noteToPm ?? ''
443
- };
444
- }
445
-
446
- function normalizeLogworkDayPayload(payload = {}) {
447
- const entries = Array.isArray(payload.entries)
448
- ? payload.entries.map(normalizeLogworkF03Entry)
449
- : [];
450
- return {
451
- date: normalizeDateOnly(payload.date) || '',
452
- entries,
453
- isLocked: Boolean(payload.isLocked ?? payload.is_locked),
454
- isProjectClosed: Boolean(payload.isProjectClosed ?? payload.is_project_closed),
455
- leaveRequests: Array.isArray(payload.leaveRequests) ? payload.leaveRequests : []
456
- };
457
- }
458
-
459
- function normalizeLogworkWeeklyPayload(payload = {}) {
460
- return {
461
- weekStart: normalizeDateOnly(payload.weekStart ?? payload.week_start) || '',
462
- isLocked: Boolean(payload.isLocked ?? payload.is_locked),
463
- projects: Array.isArray(payload.projects) ? payload.projects : [],
464
- missingDays: Array.isArray(payload.missingDays) ? payload.missingDays : [],
465
- weeklyTotals: payload.weeklyTotals ?? payload.weekly_totals ?? null
466
- };
467
- }
468
-
469
- function unwrapLogworkObject(data) {
470
- if (data?.data && typeof data.data === 'object' && !Array.isArray(data.data)) {
471
- return data.data;
472
- }
473
- if (data && typeof data === 'object' && !Array.isArray(data)) {
474
- return data;
475
- }
476
- return {};
477
- }
478
-
479
- export async function addLogtime(token, entry, options = {}) {
480
- const {
481
- projectMemberId: _projectMemberId,
482
- projectId,
483
- logtimes,
484
- taskName,
485
- localDateISO,
486
- typeOfWork,
487
- description,
488
- worklogTaskId,
489
- worklogNeedsProjectId,
490
- taskCache
491
- } = entry || {};
492
-
493
- const normalizedHours = Number(logtimes);
494
- const normalizedTaskName = String(taskName ?? '').trim();
495
- if (!Number.isFinite(normalizedHours) || normalizedHours <= 0) {
496
- throw new Error('logtimes must be a positive number.');
497
- }
498
- if (!normalizedTaskName) {
499
- throw new Error('taskName must not be empty.');
500
- }
501
- if (!/^\d{4}-\d{2}-\d{2}$/.test(String(localDateISO || ''))) {
502
- throw new Error('localDateISO must use YYYY-MM-DD format.');
503
- }
504
-
505
- const normalizedProjectId = assertPositiveProjectId(projectId);
506
- let resolvedTaskId = worklogTaskId;
507
- let needsProjectId = worklogNeedsProjectId;
508
-
509
- if (resolvedTaskId === undefined || resolvedTaskId === null) {
510
- const tasks = await fetchWorklogTasksForProject(token, normalizedProjectId, {
511
- cache: taskCache,
512
- ...options
513
- });
514
- const resolution = resolveWorklogTask({
515
- taskName: normalizedTaskName,
516
- projectId: normalizedProjectId,
517
- tasks
518
- });
519
- resolvedTaskId = resolution.worklogTaskId;
520
- needsProjectId = resolution.needsProjectId;
521
- }
522
-
523
- return createLogworkEntry(token, {
524
- worklogTaskId: resolvedTaskId,
525
- projectId: normalizedProjectId,
526
- effortHours: normalizedHours,
527
- localDateISO,
528
- typeOfWork,
529
- description,
530
- needsProjectId: Boolean(needsProjectId)
531
- }, options);
532
- }
533
-
534
- function buildLogworkTasksQuery({ projectId, search }) {
535
- const qs = new URLSearchParams();
536
- if (projectId !== undefined && projectId !== null && projectId !== '') {
537
- qs.set('project_id', String(projectId));
538
- }
539
- if (search) {
540
- qs.set('search', String(search));
541
- }
542
- const query = qs.toString();
543
- return query ? `?${query}` : '';
544
- }
545
-
546
- function assertPositiveProjectId(projectId, label = 'projectId') {
547
- const normalized = Number(projectId);
548
- if (!Number.isFinite(normalized) || normalized <= 0) {
549
- throw new Error(`${label} must be a positive number.`);
550
- }
551
- return normalized;
552
- }
553
-
554
- function mapLogworkEntryApiError(error) {
555
- if (!(error instanceof ApiError)) {
556
- return error;
557
- }
558
-
559
- const bodyText = String(error.body || '');
560
- const knownMessages = [
561
- ['TASK_OR_PROJECT_NOT_FOUND', 'Worklog task or project was not found. Check project_id and worklog_task_id.'],
562
- ['HOLIDAY_BLOCKED', 'Logwork is blocked on this date (holiday).'],
563
- ['NOT_MEMBER_OF_PROJECT', 'You are not a member of this project.'],
564
- ['INVALID_EFFORT', 'Effort hours must be greater than 0.'],
565
- ['EFFORT_EXCEEDS_24', 'Effort cannot exceed 24 hours per entry.'],
566
- ['ENTRY_NOT_FOUND', 'Logwork entry was not found or is already deleted.'],
567
- ['FORBIDDEN', 'You can only delete or change logwork entries you created.']
568
- ];
569
-
570
- for (const [code, message] of knownMessages) {
571
- if (bodyText.includes(code)) {
572
- return new ApiError(message, {
573
- status: error.status,
574
- path: error.path,
575
- body: error.body
576
- });
577
- }
578
- }
579
-
580
- if (bodyText.includes('Can only delete unprocessed entries')) {
581
- return new ApiError('Only submitted or approved entries can be deleted. Rejected entries must be resubmitted or edited.', {
582
- status: error.status,
583
- path: error.path,
584
- body: error.body
585
- });
586
- }
587
-
588
- if (bodyText.includes('LOCKED') || bodyText.includes('locked')) {
589
- return new ApiError('This log date is locked and cannot be edited.', {
590
- status: error.status,
591
- path: error.path,
592
- body: error.body
593
- });
594
- }
595
-
596
- return error;
597
- }
598
-
599
- export function normalizeEditableLogtime(record = {}) {
600
- const hours = Number(record.logtimes ?? record.hours ?? record.effort_hours ?? record.time);
601
- return {
602
- id: record.id ?? record.logtime_id ?? record.logtimeId,
603
- projectMemberId: record.project_member_id ?? record.projectMemberId,
604
- projectId: record.project_id ?? record.projectId ?? record.project?.id,
605
- projectName: record.project_name ?? record.projectName ?? record.project?.name ?? '',
606
- date: normalizeDateOnly(record.logdate ?? record.log_date ?? record.date),
607
- hours: Number.isFinite(hours) ? hours : 0,
608
- taskName: record.task_name ?? record.taskName ?? record.name ?? record.title ?? '',
609
- createdBy: record.created_by ?? record.createdBy,
610
- updatedBy: record.updated_by ?? record.updatedBy,
611
- updatedDate: record.updated_date ?? record.updatedDate ?? '',
612
- jiraLogworkId: record.jira_logwork_id ?? record.jiraLogworkId ?? null,
613
- referrerUrl: record.referrer_url ?? record.referrerUrl ?? null,
614
- source: record.source ?? '',
615
- status: record.status ?? '',
616
- isLocked: Boolean(record.is_locked ?? record.isLocked),
617
- raw: record
618
- };
619
- }
620
-
621
- function normalizeProject(rec) {
622
- const projectId = rec.project_id ?? rec.project?.id ?? rec.project?.project_id;
623
- const projectMemberId = rec.id ?? rec.project_member_id;
624
-
625
- return {
626
- projectMemberId,
627
- projectId,
628
- projectName: rec.project?.name ?? rec.project_name ?? rec.name ?? projectFallbackName(projectId ?? projectMemberId),
629
- workloadPercent: rec.workload ?? rec.workload_percent ?? rec.assigned_percent ?? 0,
630
- raw: rec
631
- };
632
- }
633
-
634
- function normalizeTimesheetProjects(rows, localDateISO) {
635
- const byMember = new Map();
636
-
637
- for (const row of rows) {
638
- const projectMemberId = readProjectMemberId(row);
639
- const projectId = readProjectId(row);
640
-
641
- if (!projectMemberId && !projectId) {
642
- continue;
643
- }
644
-
645
- const key = String(projectMemberId ?? projectId);
646
- const projectName = readProjectName(row, projectId ?? projectMemberId);
647
- const assignedHours = Number(row.bookedHours ?? readAssignedHours(row));
648
- const loggedHours = Number(row.loggedHours ?? readLoggedHours(row));
649
- const existing = byMember.get(key);
650
-
651
- if (existing) {
652
- existing.assignedHours += assignedHours;
653
- existing.loggedHours += loggedHours;
654
- existing.workloadPercent = hoursToAssignPercent(existing.assignedHours);
655
- existing.raw.push(row);
656
- continue;
657
- }
658
-
659
- byMember.set(key, {
660
- projectMemberId: projectMemberId ?? row.project_member_id ?? row.id,
661
- projectId,
662
- projectName,
663
- assignedHours,
664
- loggedHours,
665
- workloadPercent: hoursToAssignPercent(assignedHours),
666
- localDateISO,
667
- raw: [row]
668
- });
669
- }
670
-
671
- return [...byMember.values()].filter((project) => project.assignedHours > 0);
672
- }
673
-
674
- function normalizeTimesheetRecords(rows, diagnostics = createNormalizationDiagnostics('range_scan')) {
675
- const byDayProject = new Map();
676
- diagnostics.rowsRead = rows.length;
677
-
678
- rows.forEach((row, index) => {
679
- const date = readDateLike(row);
680
- const projectMemberId = readProjectMemberId(row);
681
- const projectId = readProjectId(row);
682
- const context = {
683
- index,
684
- kind: 'timesheet_row',
685
- ...diagnosticProjectFields({ projectMemberId, projectId, projectName: hasProjectName(row) ? readProjectName(row, projectId ?? projectMemberId) : undefined })
686
- };
687
-
688
- if (!date) {
689
- addDroppedRow(diagnostics, 'missing_date', context);
690
- return;
691
- }
692
-
693
- if (projectMemberId === undefined && projectId === undefined) {
694
- addDroppedRow(diagnostics, 'missing_project_identity', { ...context, date });
695
- return;
696
- }
697
-
698
- const key = `${date}:${projectMemberId ?? projectId}`;
699
- const existing = byDayProject.get(key);
700
- const bookedHours = readTimesheetHours(row, ['bookedHours'], readAssignedHours, diagnostics, { ...context, date, metric: 'bookedHours' });
701
- const loggedHours = readTimesheetHours(row, ['loggedHours'], readLoggedHours, diagnostics, { ...context, date, metric: 'loggedHours' });
702
- const entries = readLogEntries(row, diagnostics, { ...context, date });
703
- diagnostics.rowsAccepted += 1;
704
-
705
- if (existing) {
706
- existing.bookedHours += bookedHours;
707
- existing.loggedHours += loggedHours;
708
- existing.entries.push(...entries);
709
- existing.raw.push(row);
710
- return;
711
- }
712
-
713
- byDayProject.set(key, {
714
- date,
715
- projectMemberId,
716
- projectId,
717
- projectName: readProjectName(row, projectId ?? projectMemberId, diagnostics, { ...context, date }),
718
- bookedHours,
719
- loggedHours,
720
- entries,
721
- raw: [row]
722
- });
723
- });
724
-
725
- return attachNormalizationDiagnostics([...byDayProject.values()].map((record) => ({
726
- ...record,
727
- bookedHours: Number(record.bookedHours.toFixed(2)),
728
- loggedHours: Number(record.loggedHours.toFixed(2))
729
- })), diagnostics);
730
- }
731
-
732
- function findTimesheetRowsForDate(data, localDateISO) {
733
- const rows = [];
734
- collectTimesheetRows(data, localDateISO, rows, new Set());
735
- return rows;
736
- }
737
-
738
- function expandProjectTimesheetRows(data, range, diagnostics = null) {
739
- const assignments = [];
740
- collectProjectAssignments(data, assignments, new Set());
741
- const rows = [];
742
-
743
- assignments.forEach((assignment, assignmentIndex) => {
744
- assignment.timesheet.forEach((day, dayIndex) => {
745
- const date = normalizeDateOnly(day.logdate ?? day.log_date ?? day.date);
746
- if (!isDateInRange(date, range)) {
747
- return;
748
- }
749
-
750
- const context = {
751
- index: rows.length,
752
- assignmentIndex,
753
- dayIndex,
754
- kind: 'project_timesheet_row',
755
- date,
756
- ...diagnosticProjectFields({
757
- projectMemberId: assignment.id,
758
- projectId: assignment.project?.id,
759
- projectName: assignment.project?.name
760
- })
761
- };
762
-
763
- rows.push({
764
- date,
765
- projectMemberId: assignment.id,
766
- project_member_id: assignment.id,
767
- projectId: assignment.project?.id,
768
- project_id: assignment.project?.id,
769
- projectName: assignment.project?.name,
770
- project_name: assignment.project?.name,
771
- bookedHours: readAssignedHours(day, diagnostics, { ...context, metric: 'bookedHours' }),
772
- loggedHours: readNumber(day, ['logtimes'], diagnostics, { ...context, metric: 'loggedHours' }),
773
- overtime: readNumber(day, ['overtime'], diagnostics, { ...context, metric: 'overtime' }),
774
- source: 'timesheet'
775
- });
776
- });
777
- });
778
-
779
- return rows;
780
- }
781
-
782
- function collectProjectAssignments(value, assignments, seen) {
783
- if (!value || typeof value !== 'object') {
784
- return;
785
- }
786
-
787
- if (seen.has(value)) {
788
- return;
789
- }
790
- seen.add(value);
791
-
792
- if (Array.isArray(value)) {
793
- for (const item of value) {
794
- collectProjectAssignments(item, assignments, seen);
795
- }
796
- return;
797
- }
798
-
799
- if (
800
- value.project &&
801
- typeof value.project === 'object' &&
802
- Array.isArray(value.timesheet)
803
- ) {
804
- assignments.push(value);
805
- return;
806
- }
807
-
808
- for (const [key, child] of Object.entries(value)) {
809
- if (key === 'overall') {
810
- continue;
811
- }
812
- collectProjectAssignments(child, assignments, seen);
813
- }
814
- }
815
-
816
- function findTimesheetRowsInRange(data, { from, to } = {}) {
817
- const rows = [];
818
- collectTimesheetRowsInRange(data, { from, to }, rows, new Set());
819
- return rows;
820
- }
821
-
822
- function collectTimesheetRowsInRange(value, range, rows, seen) {
823
- if (!value || typeof value !== 'object') {
824
- return;
825
- }
826
-
827
- if (seen.has(value)) {
828
- return;
829
- }
830
- seen.add(value);
831
-
832
- if (Array.isArray(value)) {
833
- for (const item of value) {
834
- collectTimesheetRowsInRange(item, range, rows, seen);
835
- }
836
- return;
837
- }
838
-
839
- const directDate = readDateLike(value);
840
- if (isDateInRange(directDate, range) && looksLikeTimesheetRow(value)) {
841
- rows.push(value);
842
- } else if (!directDate && looksLikeTimesheetRow(value)) {
843
- rows.push(value);
844
- }
845
-
846
- for (const [key, child] of Object.entries(value)) {
847
- if (Array.isArray(child)) {
848
- const keyDate = normalizeDateOnly(key);
849
- if (isDateInRange(keyDate, range)) {
850
- for (const item of child) {
851
- if (item && typeof item === 'object') {
852
- rows.push({ ...item, date: readDateLike(item) || keyDate });
853
- }
854
- }
855
- continue;
856
- }
857
-
858
- for (const item of child) {
859
- if (item && typeof item === 'object') {
860
- const itemDate = readDateLike(item);
861
- if (isDateInRange(itemDate, range) && looksLikeTimesheetRow(item)) {
862
- rows.push(item);
863
- continue;
864
- }
865
- }
866
-
867
- collectTimesheetRowsInRange(item, range, rows, seen);
868
- }
869
- continue;
870
- }
871
-
872
- if (child && typeof child === 'object') {
873
- const keyDate = normalizeDateOnly(key);
874
- if (isDateInRange(keyDate, range)) {
875
- rows.push({ ...child, date: readDateLike(child) || keyDate });
876
- continue;
877
- }
878
-
879
- collectTimesheetRowsInRange(child, range, rows, seen);
880
- }
881
- }
882
- }
883
-
884
- function collectTimesheetRows(value, localDateISO, rows, seen) {
885
- if (!value || typeof value !== 'object') {
886
- return;
887
- }
888
-
889
- if (seen.has(value)) {
890
- return;
891
- }
892
- seen.add(value);
893
-
894
- if (Array.isArray(value)) {
895
- for (const item of value) {
896
- collectTimesheetRows(item, localDateISO, rows, seen);
897
- }
898
- return;
899
- }
900
-
901
- const directDate = readDateLike(value);
902
- if (directDate === localDateISO && looksLikeTimesheetRow(value)) {
903
- rows.push(value);
904
- }
905
-
906
- for (const [key, child] of Object.entries(value)) {
907
- if (Array.isArray(child)) {
908
- if (isDateKey(key, localDateISO)) {
909
- for (const item of child) {
910
- if (item && typeof item === 'object') {
911
- rows.push({ ...value, ...item });
912
- }
913
- }
914
- continue;
915
- }
916
-
917
- for (const item of child) {
918
- if (item && typeof item === 'object' && readDateLike(item) === localDateISO && looksLikeTimesheetRow({ ...value, ...item })) {
919
- rows.push({ ...value, ...item });
920
- continue;
921
- }
922
-
923
- collectTimesheetRows(item, localDateISO, rows, seen);
924
- }
925
- continue;
926
- }
927
-
928
- if (child && typeof child === 'object') {
929
- if (isDateKey(key, localDateISO)) {
930
- rows.push({ ...value, ...child });
931
- continue;
932
- }
933
-
934
- collectTimesheetRows(child, localDateISO, rows, seen);
935
- }
936
- }
937
- }
938
-
939
- function looksLikeTimesheetRow(row) {
940
- return readAssignedHours(row) > 0 || readLoggedHours(row) > 0 || Boolean(pickFirst(row, [
941
- 'project_member_id',
942
- 'projectMemberId',
943
- 'project_id',
944
- 'projectId'
945
- ]));
946
- }
947
-
948
- function readProjectMemberId(row) {
949
- const explicit = readExplicitProjectMemberId(row);
950
-
951
- if (explicit !== undefined) {
952
- return explicit;
953
- }
954
-
955
- if (looksLikeProjectMemberRow(row)) {
956
- return row.id;
957
- }
958
-
959
- return undefined;
960
- }
961
-
962
- function readExplicitProjectMemberId(row) {
963
- return pickFirst(row, [
964
- 'project_member_id',
965
- 'projectMemberId',
966
- 'project_member.id',
967
- 'projectMember.id'
968
- ]);
969
- }
970
-
971
- function readProjectId(row) {
972
- return pickFirst(row, [
973
- 'project_id',
974
- 'projectId',
975
- 'project.id',
976
- 'project.project_id',
977
- 'projectMember.project_id',
978
- 'project_member.project_id'
979
- ]);
980
- }
981
-
982
- function readProjectName(row, fallbackId, diagnostics = null, context = {}) {
983
- const projectName = pickFirst(row, [
984
- 'project.name',
985
- 'project_name',
986
- 'projectName',
987
- 'name',
988
- 'projectMember.project.name',
989
- 'project_member.project.name'
990
- ]);
991
-
992
- if (projectName !== undefined) {
993
- return projectName;
994
- }
995
-
996
- if (fallbackId !== undefined && fallbackId !== null && String(fallbackId).trim() !== '') {
997
- addWarning(diagnostics, 'fallback_project_name', {
998
- ...context,
999
- ...diagnosticProjectFields({ projectMemberId: readProjectMemberId(row), projectId: readProjectId(row) })
1000
- });
1001
- return projectFallbackName(fallbackId);
1002
- }
1003
-
1004
- addWarning(diagnostics, 'fallback_project_name', context);
1005
- return 'Unknown project';
1006
- }
1007
-
1008
- function projectFallbackName(fallbackId) {
1009
- return fallbackId !== undefined && fallbackId !== null && String(fallbackId).trim() !== ''
1010
- ? `Project ${fallbackId}`
1011
- : 'Unknown project';
1012
- }
1013
-
1014
- function hasProjectName(row) {
1015
- return pickFirst(row, [
1016
- 'project.name',
1017
- 'project_name',
1018
- 'projectName',
1019
- 'name',
1020
- 'projectMember.project.name',
1021
- 'project_member.project.name'
1022
- ]) !== undefined;
1023
- }
1024
-
1025
- function looksLikeProjectMemberRow(row) {
1026
- return row && typeof row === 'object' && row.id !== undefined && (
1027
- row.project_id !== undefined ||
1028
- row.projectId !== undefined ||
1029
- row.project !== undefined ||
1030
- row.project_name !== undefined ||
1031
- row.projectName !== undefined ||
1032
- row.assign_hours !== undefined ||
1033
- row.assigned_hours !== undefined ||
1034
- row.booked_hours !== undefined
1035
- );
1036
- }
1037
-
1038
- function readAssignedHours(row, diagnostics = null, context = {}) {
1039
- return readNumber(row, [
1040
- 'assign_hours',
1041
- 'assigned_hours',
1042
- 'assignedHours',
1043
- 'booking_hours',
1044
- 'booked_hours',
1045
- 'bookedHours',
1046
- 'workload_hours',
1047
- 'workloadHours',
1048
- 'assign_percent',
1049
- 'assignPercent',
1050
- 'hours_per_day',
1051
- 'hoursPerDay',
1052
- 'timesheet',
1053
- 'logtime',
1054
- 'logtimes_plan',
1055
- 'planned_hours',
1056
- 'plannedHours',
1057
- 'effort',
1058
- 'man_day'
1059
- ], diagnostics, context);
1060
- }
1061
-
1062
- function readLoggedHours(row, diagnostics = null, context = {}) {
1063
- return readNumber(row, [
1064
- 'logged_hours',
1065
- 'loggedHours',
1066
- 'actual_hours',
1067
- 'actualHours',
1068
- 'logtimes',
1069
- 'log_times',
1070
- 'logTimes',
1071
- 'logged',
1072
- 'total_logtimes',
1073
- 'totalLoggedHours',
1074
- 'total_logged_hours',
1075
- 'spent_hours',
1076
- 'spentHours'
1077
- ], diagnostics, context);
1078
- }
1079
-
1080
- function readLogEntries(row, diagnostics = null, context = {}) {
1081
- const rawEntries = pickFirst(row, [
1082
- 'logs',
1083
- 'logtimes_data',
1084
- 'logtimesData',
1085
- 'log_times',
1086
- 'logTimes',
1087
- 'items',
1088
- 'children'
1089
- ]);
1090
-
1091
- if (!Array.isArray(rawEntries)) {
1092
- return [];
1093
- }
1094
-
1095
- const entries = [];
1096
- rawEntries.forEach((entry, entryIndex) => {
1097
- if (!entry || typeof entry !== 'object') {
1098
- addDroppedRow(diagnostics, 'invalid_log_entry', {
1099
- ...context,
1100
- entryIndex
1101
- });
1102
- return;
1103
- }
1104
-
1105
- const entryContext = {
1106
- ...context,
1107
- entryIndex,
1108
- kind: 'embedded_log_entry'
1109
- };
1110
- const date = normalizeDateOnly(entry.logdate ?? entry.log_date ?? entry.date ?? row.logdate ?? row.date);
1111
- const projectMemberId = readProjectMemberId(entry) ?? readProjectMemberId(row);
1112
- const projectId = readProjectId(entry) ?? readProjectId(row);
1113
- const hours = readNumber(entry, ['logtimes', 'hours', 'time'], diagnostics, { ...entryContext, metric: 'hours' });
1114
-
1115
- if (!date) {
1116
- addDroppedRow(diagnostics, 'missing_date', { ...entryContext, ...diagnosticProjectFields({ projectMemberId, projectId }) });
1117
- return;
1118
- }
1119
-
1120
- if (projectMemberId === undefined && projectId === undefined) {
1121
- addDroppedRow(diagnostics, 'missing_project_identity', { ...entryContext, date });
1122
- return;
1123
- }
1124
-
1125
- if (hours <= 0) {
1126
- addDroppedRow(diagnostics, 'non_positive_hours', { ...entryContext, date, ...diagnosticProjectFields({ projectMemberId, projectId }) });
1127
- return;
1128
- }
1129
-
1130
- entries.push({
1131
- id: entry.id ?? entry.logtime_id ?? entry.logtimeId,
1132
- date,
1133
- projectMemberId,
1134
- projectId,
1135
- projectName: hasProjectName(entry) ? readProjectName(entry, readProjectId(entry), diagnostics, entryContext) : readProjectName(row, readProjectId(row) ?? readProjectMemberId(row), diagnostics, entryContext),
1136
- hours,
1137
- taskName: entry.task_name ?? entry.taskName ?? entry.name ?? entry.title ?? '',
1138
- raw: entry
1139
- });
1140
- });
1141
-
1142
- return entries;
1143
- }
1144
-
1145
- function readTimesheetHours(row, directPaths, fallbackReader, diagnostics, context) {
1146
- const directValue = pickFirst(row, directPaths);
1147
- if (directValue !== undefined) {
1148
- return coerceHours(directValue, diagnostics, {
1149
- ...context,
1150
- field: directPaths[0]
1151
- });
1152
- }
1153
-
1154
- return fallbackReader(row, diagnostics, context);
1155
- }
1156
-
1157
- function readNumber(row, paths, diagnostics = null, context = {}) {
1158
- for (const path of paths) {
1159
- const value = pickFirst(row, [path]);
1160
- if (value !== undefined) {
1161
- const number = Number(value);
1162
- if (Number.isFinite(number) && number > 0) {
1163
- return number;
1164
- }
1165
- if (Number.isFinite(number) && number === 0) {
1166
- continue;
1167
- }
1168
- addWarning(diagnostics, 'invalid_hours', {
1169
- ...context,
1170
- field: path
1171
- });
1172
- }
1173
- }
1174
-
1175
- return 0;
1176
- }
1177
-
1178
- function coerceHours(value, diagnostics = null, context = {}) {
1179
- const number = Number(value);
1180
- if (Number.isFinite(number) && number >= 0) {
1181
- return number;
1182
- }
1183
-
1184
- addWarning(diagnostics, 'invalid_hours', context);
1185
- return 0;
1186
- }
1187
-
1188
- function hoursToAssignPercent(hours) {
1189
- return Math.round((Number(hours || 0) / CONFIG.standardDayHours) * 100);
1190
- }
1191
-
1192
- function readDateLike(row) {
1193
- const value = pickFirst(row, [
1194
- 'logdate',
1195
- 'log_date',
1196
- 'date',
1197
- 'day',
1198
- 'workdate',
1199
- 'work_date',
1200
- 'timesheet_date'
1201
- ]);
1202
-
1203
- return normalizeDateOnly(value);
1204
- }
1205
-
1206
- function normalizeDateOnly(value) {
1207
- if (!value) {
1208
- return '';
1209
- }
1210
-
1211
- const text = String(value);
1212
- const match = text.match(/\d{4}-\d{2}-\d{2}/);
1213
- return match ? match[0] : '';
1214
- }
1215
-
1216
- function isDateKey(key, localDateISO) {
1217
- return normalizeDateOnly(key) === localDateISO;
1218
- }
1219
-
1220
- function isDateInRange(localDateISO, { from, to } = {}) {
1221
- if (!localDateISO) {
1222
- return false;
1223
- }
1224
-
1225
- if (from && localDateISO < from) {
1226
- return false;
1227
- }
1228
-
1229
- if (to && localDateISO >= to) {
1230
- return false;
1231
- }
1232
-
1233
- return true;
1234
- }
1235
-
1236
- function pickFirst(row, paths) {
1237
- for (const path of paths) {
1238
- const value = getPath(row, path);
1239
- if (value !== undefined && value !== null && value !== '') {
1240
- return value;
1241
- }
1242
- }
1243
-
1244
- return undefined;
1245
- }
1246
-
1247
- function getPath(row, path) {
1248
- return path.split('.').reduce((current, part) => {
1249
- if (current === undefined || current === null) {
1250
- return undefined;
1251
- }
1252
-
1253
- return current[part];
1254
- }, row);
1255
- }
1256
-
1257
- function unwrapRecords(data) {
1258
- if (Array.isArray(data)) {
1259
- return data;
1260
- }
1261
-
1262
- if (Array.isArray(data?.data)) {
1263
- return data.data;
1264
- }
1265
-
1266
- if (Array.isArray(data?.data?.data)) {
1267
- return data.data.data;
1268
- }
1269
-
1270
- if (Array.isArray(data?.records)) {
1271
- return data.records;
1272
- }
1273
-
1274
- if (Array.isArray(data?.items)) {
1275
- return data.items;
1276
- }
1277
-
1278
- return [];
1279
- }
1280
-
1281
- function extractLogtimeRecord(data, collectionKey) {
1282
- const candidates = collectionKey
1283
- ? [data?.data?.[collectionKey], data?.[collectionKey]]
1284
- : [data?.data?.data, data?.data, data?.record, data?.item, data];
1285
-
1286
- for (const candidate of candidates) {
1287
- if (Array.isArray(candidate)) {
1288
- const record = candidate.find((item) => item && typeof item === 'object');
1289
- if (record) {
1290
- return record;
1291
- }
1292
- continue;
1293
- }
1294
- if (candidate && typeof candidate === 'object' && (
1295
- candidate.id !== undefined ||
1296
- candidate.logtime_id !== undefined ||
1297
- candidate.logtimeId !== undefined
1298
- )) {
1299
- return candidate;
1300
- }
1301
- }
1302
-
1303
- return null;
1304
- }
1305
-
1306
- function assertLogtimeId(value, name) {
1307
- const normalized = String(value ?? '').trim();
1308
- if (!normalized || !/^[A-Za-z0-9_-]+$/.test(normalized)) {
1309
- throw new Error(`${name} must be a valid Resource Optimiser identifier.`);
1310
- }
1311
- return normalized;
1312
- }
1313
-
1314
- function preserveNumericId(value) {
1315
- return /^\d+$/.test(value) && Number.isSafeInteger(Number(value))
1316
- ? Number(value)
1317
- : value;
1318
- }
1319
-
1320
- function createNormalizationDiagnostics(sourceShape = 'unknown') {
1321
- return {
1322
- status: 'ok',
1323
- sourceShape,
1324
- rowsRead: 0,
1325
- rowsAccepted: 0,
1326
- droppedRowCount: 0,
1327
- warningCount: 0,
1328
- warnings: [],
1329
- droppedRows: []
1330
- };
1331
- }
1332
-
1333
- function emptyNormalizationDiagnostics() {
1334
- return createNormalizationDiagnostics('unknown');
1335
- }
1336
-
1337
- function attachNormalizationDiagnostics(value, diagnostics) {
1338
- Object.defineProperty(value, NORMALIZATION_DIAGNOSTICS_KEY, {
1339
- value: finalizeNormalizationDiagnostics(diagnostics),
1340
- enumerable: false,
1341
- configurable: true
1342
- });
1343
- return value;
1344
- }
1345
-
1346
- function finalizeNormalizationDiagnostics(diagnostics) {
1347
- const warningCount = diagnostics.warnings.length;
1348
- const droppedRowCount = diagnostics.droppedRows.length;
1349
- return {
1350
- ...diagnostics,
1351
- status: warningCount || droppedRowCount ? 'warning' : 'ok',
1352
- warningCount,
1353
- droppedRowCount
1354
- };
1355
- }
1356
-
1357
- export function combineNormalizationDiagnostics(...items) {
1358
- const diagnostics = createNormalizationDiagnostics('combined');
1359
- for (const item of items) {
1360
- const current = item?.warnings || item?.droppedRows
1361
- ? item
1362
- : getNormalizationDiagnostics(item);
1363
- if (!current) {
1364
- continue;
1365
- }
1366
- diagnostics.rowsRead += Number(current.rowsRead || 0);
1367
- diagnostics.rowsAccepted += Number(current.rowsAccepted || 0);
1368
- for (const warning of current.warnings || []) {
1369
- pushDiagnostic(diagnostics.warnings, warning);
1370
- }
1371
- for (const dropped of current.droppedRows || []) {
1372
- pushDiagnostic(diagnostics.droppedRows, dropped);
1373
- }
1374
- }
1375
- return finalizeNormalizationDiagnostics(diagnostics);
1376
- }
1377
-
1378
- function addWarning(diagnostics, reason, details = {}) {
1379
- if (!diagnostics) {
1380
- return;
1381
- }
1382
- pushDiagnostic(diagnostics.warnings, compactDiagnostic({
1383
- reason,
1384
- ...details
1385
- }));
1386
- }
1387
-
1388
- function addDroppedRow(diagnostics, reason, details = {}) {
1389
- if (!diagnostics) {
1390
- return;
1391
- }
1392
- pushDiagnostic(diagnostics.droppedRows, compactDiagnostic({
1393
- reason,
1394
- ...details
1395
- }));
1396
- }
1397
-
1398
- function pushDiagnostic(items, item) {
1399
- if (items.length < MAX_DIAGNOSTIC_ITEMS) {
1400
- items.push(item);
1401
- }
1402
- }
1403
-
1404
- function compactDiagnostic(details) {
1405
- return Object.fromEntries(Object.entries(details).filter(([, value]) => (
1406
- value !== undefined &&
1407
- value !== null &&
1408
- value !== ''
1409
- )));
1410
- }
1411
-
1412
- function diagnosticProjectFields({ projectMemberId, projectId, projectName } = {}) {
1413
- return compactDiagnostic({
1414
- projectMemberId,
1415
- projectId,
1416
- projectName
1417
- });
1418
- }
1419
-
1420
- function hasObjectPayload(data) {
1421
- return Boolean(data && typeof data === 'object' && Object.keys(data).length > 0);
1422
- }
1
+ // Resource Optimiser API facade. The implementation lives in the ro-api-* modules;
2
+ // this module keeps every public name importable from its original path.
3
+ export { ApiError, apiFetch } from './ro-api-http.mjs';
4
+ export {
5
+ combineNormalizationDiagnostics,
6
+ getNormalizationDiagnostics
7
+ } from './ro-api-diagnostics.mjs';
8
+ export {
9
+ getAllDayLogSummaries,
10
+ getDayLogs,
11
+ getProjects,
12
+ normalizeLogtimeEntries
13
+ } from './ro-api-projects.mjs';
14
+ export {
15
+ getBookedProjectsForDate,
16
+ getTimesheetRange,
17
+ getTimesheetRangeResult,
18
+ getTodayTimesheetProjects,
19
+ normalizeTimesheetRange,
20
+ normalizeTimesheetRangeResult
21
+ } from './ro-api-timesheet.mjs';
22
+ export {
23
+ fetchWorklogTasksForProject,
24
+ getDefaultWorklogTasks,
25
+ getWorklogTasks
26
+ } from './ro-api-tasks.mjs';
27
+ export {
28
+ addLogtime,
29
+ createLogworkEntry,
30
+ deleteLogworkEntry,
31
+ getLogtimeById,
32
+ getLogworkDay,
33
+ getLogworkRejected,
34
+ getLogworkWeekly,
35
+ normalizeEditableLogtime,
36
+ normalizeLogworkF03Entry,
37
+ resubmitLogworkEntry
38
+ } from './ro-api-entries.mjs';