logwork-cli 1.1.0 → 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 (70) hide show
  1. package/README.md +35 -9
  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 +157 -19
  8. package/commands/tui.mjs +1 -0
  9. package/docs/advanced.md +12 -2
  10. package/docs/mcp-setup.md +57 -15
  11. package/docs/operations-guide.md +37 -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-parser.mjs +23 -0
  25. package/lib/batch-workflow.mjs +201 -2
  26. package/lib/commit-workflow.mjs +278 -30
  27. package/lib/config.mjs +7 -0
  28. package/lib/diagnostics.mjs +53 -4
  29. package/lib/jira-api.mjs +25 -3
  30. package/lib/jira-workflow.mjs +36 -6
  31. package/lib/keychain.mjs +19 -12
  32. package/lib/logwork-config.mjs +64 -13
  33. package/lib/manual-apply-state.mjs +15 -0
  34. package/lib/manual-commands.mjs +2 -2
  35. package/lib/manual-ink-app.mjs +108 -1281
  36. package/lib/manual-ink-draft-actions.mjs +227 -0
  37. package/lib/manual-ink-interaction.mjs +306 -0
  38. package/lib/manual-ink-session.mjs +41 -0
  39. package/lib/manual-ink-task-editor.mjs +338 -0
  40. package/lib/manual-ink-wizard.mjs +520 -0
  41. package/lib/manual-mcp.mjs +27 -15
  42. package/lib/manual-repl.mjs +39 -3
  43. package/lib/mcp-server.mjs +63 -21
  44. package/lib/node-runtime.mjs +115 -0
  45. package/lib/npm-command.mjs +29 -0
  46. package/lib/paths.mjs +11 -1
  47. package/lib/project-mapping-workflow.mjs +30 -7
  48. package/lib/query-workflow.mjs +0 -1
  49. package/lib/reconciliation-workflow.mjs +1 -1
  50. package/lib/release-doctor.mjs +4 -2
  51. package/lib/reminder-config.mjs +38 -2
  52. package/lib/reminder-scheduler.mjs +54 -3
  53. package/lib/reminder-service.mjs +28 -3
  54. package/lib/reminder-workflow.mjs +118 -22
  55. package/lib/ro-api-diagnostics.mjs +109 -0
  56. package/lib/ro-api-entries.mjs +359 -0
  57. package/lib/ro-api-fields.mjs +290 -0
  58. package/lib/ro-api-http.mjs +68 -0
  59. package/lib/ro-api-projects.mjs +137 -0
  60. package/lib/ro-api-tasks.mjs +50 -0
  61. package/lib/ro-api-timesheet.mjs +413 -0
  62. package/lib/ro-delete-workflow.mjs +5 -8
  63. package/lib/ro-edit-workflow.mjs +66 -8
  64. package/lib/ro-entry-reader.mjs +49 -6
  65. package/lib/ro-resubmit-workflow.mjs +35 -21
  66. package/lib/terminal-auth-prompts.mjs +7 -4
  67. package/lib/ui.mjs +80 -59
  68. package/lib/update-service.mjs +5 -2
  69. package/package.json +11 -4
  70. package/skills/logwork/SKILL.md +6 -4
@@ -0,0 +1,359 @@
1
+ // Logwork entries: reads (legacy GET, /logwork/day, weekly, rejected) and writes
2
+ // (create, resubmit, delete). Writes honor LOGWORK_DRY_RUN.
3
+ import { CONFIG, isDryRun } from './config.mjs';
4
+ import { resolveWorklogTask } from './worklog-task-resolver.mjs';
5
+ import { ApiError, apiFetch } from './ro-api-http.mjs';
6
+ import { fetchWorklogTasksForProject } from './ro-api-tasks.mjs';
7
+ import {
8
+ assertLogtimeId,
9
+ extractLogtimeRecord,
10
+ normalizeDateOnly,
11
+ preserveNumericId,
12
+ unwrapRecords
13
+ } from './ro-api-fields.mjs';
14
+
15
+ const LOGWORK_TYPE_OF_WORK = new Set(['create', 'correct', 'improve', 'other']);
16
+
17
+ export async function getLogtimeById(token, logtimeId, options = {}) {
18
+ const path = `${CONFIG.memberLogtimePath}/${encodeURIComponent(assertLogtimeId(logtimeId, 'logtimeId'))}`;
19
+ const data = await apiFetch(token, path, options);
20
+ const record = extractLogtimeRecord(data);
21
+ if (!record) {
22
+ throw new ApiError(`Resource Optimiser did not return logwork ${logtimeId}.`, {
23
+ path
24
+ });
25
+ }
26
+ return normalizeEditableLogtime(record);
27
+ }
28
+
29
+ export async function createLogworkEntry(token, {
30
+ worklogTaskId,
31
+ projectId,
32
+ effortHours,
33
+ localDateISO,
34
+ typeOfWork = CONFIG.defaultTypeOfWork,
35
+ description,
36
+ needsProjectId = false
37
+ }, options = {}) {
38
+ const normalizedTaskId = assertLogtimeId(worklogTaskId, 'worklogTaskId');
39
+ const normalizedProjectId = assertPositiveProjectId(projectId);
40
+ const normalizedHours = Number(effortHours);
41
+ const normalizedTypeOfWork = String(typeOfWork || CONFIG.defaultTypeOfWork).trim();
42
+ if (!Number.isFinite(normalizedHours) || normalizedHours <= 0) {
43
+ throw new Error('effortHours must be a positive number.');
44
+ }
45
+ if (!LOGWORK_TYPE_OF_WORK.has(normalizedTypeOfWork)) {
46
+ throw new Error(`typeOfWork must be one of: ${[...LOGWORK_TYPE_OF_WORK].join(', ')}.`);
47
+ }
48
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(String(localDateISO || ''))) {
49
+ throw new Error('localDateISO must use YYYY-MM-DD format.');
50
+ }
51
+
52
+ const path = CONFIG.logworkEntriesPath;
53
+ const body = {
54
+ worklog_task_id: preserveNumericId(normalizedTaskId),
55
+ effort_hours: normalizedHours,
56
+ logdate: localDateISO,
57
+ type_of_work: normalizedTypeOfWork
58
+ };
59
+ if (description !== undefined && description !== null && String(description).trim()) {
60
+ body.description = String(description).trim();
61
+ }
62
+ if (needsProjectId) {
63
+ body.project_id = preserveNumericId(normalizedProjectId);
64
+ }
65
+
66
+ if (isDryRun()) {
67
+ return {
68
+ dryRun: true,
69
+ method: 'POST',
70
+ path,
71
+ body
72
+ };
73
+ }
74
+
75
+ try {
76
+ const response = await apiFetch(token, path, {
77
+ ...options,
78
+ method: 'POST',
79
+ body: JSON.stringify(body)
80
+ });
81
+ const record = extractLogtimeRecord(response);
82
+ return {
83
+ dryRun: false,
84
+ entry: record ? normalizeEditableLogtime(record) : null,
85
+ response
86
+ };
87
+ } catch (error) {
88
+ throw mapLogworkEntryApiError(error);
89
+ }
90
+ }
91
+
92
+ export async function getLogworkDay(token, { date, projectId } = {}, options = {}) {
93
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(String(date || ''))) {
94
+ throw new Error('date must use YYYY-MM-DD format.');
95
+ }
96
+ const qs = new URLSearchParams({ date });
97
+ if (projectId !== undefined && projectId !== null && projectId !== '') {
98
+ qs.set('project_id', String(projectId));
99
+ }
100
+ const data = await apiFetch(token, `${CONFIG.logworkDayPath}?${qs}`, options);
101
+ return normalizeLogworkDayPayload(unwrapLogworkObject(data));
102
+ }
103
+
104
+ export async function getLogworkWeekly(token, { weekStart } = {}, options = {}) {
105
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(String(weekStart || ''))) {
106
+ throw new Error('weekStart must use YYYY-MM-DD format.');
107
+ }
108
+ const qs = new URLSearchParams({ week_start: weekStart });
109
+ const data = await apiFetch(token, `${CONFIG.logworkWeeklyPath}?${qs}`, options);
110
+ return normalizeLogworkWeeklyPayload(unwrapLogworkObject(data));
111
+ }
112
+
113
+ export async function getLogworkRejected(token, options = {}) {
114
+ const data = await apiFetch(token, CONFIG.logworkRejectedPath, options);
115
+ return unwrapRecords(data).map(normalizeLogworkF03Entry);
116
+ }
117
+
118
+ export async function resubmitLogworkEntry(token, entryId, {
119
+ effortHours,
120
+ typeOfWork = CONFIG.defaultTypeOfWork,
121
+ noteToPm,
122
+ description
123
+ }, options = {}) {
124
+ const id = assertLogtimeId(entryId, 'entryId');
125
+ const normalizedHours = Number(effortHours);
126
+ const normalizedTypeOfWork = String(typeOfWork || CONFIG.defaultTypeOfWork).trim();
127
+ if (!Number.isFinite(normalizedHours) || normalizedHours <= 0) {
128
+ throw new Error('effortHours must be a positive number.');
129
+ }
130
+ if (!LOGWORK_TYPE_OF_WORK.has(normalizedTypeOfWork)) {
131
+ throw new Error(`typeOfWork must be one of: ${[...LOGWORK_TYPE_OF_WORK].join(', ')}.`);
132
+ }
133
+
134
+ const path = `${CONFIG.logworkEntriesPath}/${encodeURIComponent(String(id))}/resubmit`;
135
+ const body = {
136
+ effort_hours: normalizedHours,
137
+ type_of_work: normalizedTypeOfWork
138
+ };
139
+ if (noteToPm !== undefined && String(noteToPm).trim()) {
140
+ body.note_to_pm = String(noteToPm).trim();
141
+ }
142
+ if (description !== undefined && String(description).trim()) {
143
+ body.description = String(description).trim();
144
+ }
145
+
146
+ if (isDryRun()) {
147
+ return { dryRun: true, method: 'PATCH', path, body };
148
+ }
149
+
150
+ try {
151
+ const response = await apiFetch(token, path, {
152
+ ...options,
153
+ method: 'PATCH',
154
+ body: JSON.stringify(body)
155
+ });
156
+ const record = extractLogtimeRecord(response);
157
+ return {
158
+ dryRun: false,
159
+ entry: record ? normalizeLogworkF03Entry(record) : null,
160
+ response
161
+ };
162
+ } catch (error) {
163
+ throw mapLogworkEntryApiError(error);
164
+ }
165
+ }
166
+
167
+ export async function deleteLogworkEntry(token, entryId, options = {}) {
168
+ const id = assertLogtimeId(entryId, 'entryId');
169
+ const path = `${CONFIG.logworkEntriesPath}/${encodeURIComponent(String(id))}`;
170
+
171
+ if (isDryRun()) {
172
+ return { dryRun: true, method: 'DELETE', path };
173
+ }
174
+
175
+ try {
176
+ const response = await apiFetch(token, path, {
177
+ ...options,
178
+ method: 'DELETE'
179
+ });
180
+ return { dryRun: false, response };
181
+ } catch (error) {
182
+ throw mapLogworkEntryApiError(error);
183
+ }
184
+ }
185
+
186
+ export function normalizeLogworkF03Entry(record = {}) {
187
+ const base = normalizeEditableLogtime(record);
188
+ return {
189
+ ...base,
190
+ typeOfWork: record.type_of_work ?? record.typeOfWork ?? '',
191
+ worklogTaskId: record.worklog_task_id ?? record.worklogTaskId ?? null,
192
+ description: record.description ?? '',
193
+ rejectionComment: record.rejection_comment ?? record.rejectionComment ?? '',
194
+ noteToPm: record.note_to_pm ?? record.noteToPm ?? ''
195
+ };
196
+ }
197
+
198
+ function normalizeLogworkDayPayload(payload = {}) {
199
+ const entries = Array.isArray(payload.entries)
200
+ ? payload.entries.map(normalizeLogworkF03Entry)
201
+ : [];
202
+ return {
203
+ date: normalizeDateOnly(payload.date) || '',
204
+ entries,
205
+ isLocked: Boolean(payload.isLocked ?? payload.is_locked),
206
+ isProjectClosed: Boolean(payload.isProjectClosed ?? payload.is_project_closed),
207
+ leaveRequests: Array.isArray(payload.leaveRequests) ? payload.leaveRequests : []
208
+ };
209
+ }
210
+
211
+ function normalizeLogworkWeeklyPayload(payload = {}) {
212
+ return {
213
+ weekStart: normalizeDateOnly(payload.weekStart ?? payload.week_start) || '',
214
+ isLocked: Boolean(payload.isLocked ?? payload.is_locked),
215
+ projects: Array.isArray(payload.projects) ? payload.projects : [],
216
+ missingDays: Array.isArray(payload.missingDays) ? payload.missingDays : [],
217
+ weeklyTotals: payload.weeklyTotals ?? payload.weekly_totals ?? null
218
+ };
219
+ }
220
+
221
+ function unwrapLogworkObject(data) {
222
+ if (data?.data && typeof data.data === 'object' && !Array.isArray(data.data)) {
223
+ return data.data;
224
+ }
225
+ if (data && typeof data === 'object' && !Array.isArray(data)) {
226
+ return data;
227
+ }
228
+ return {};
229
+ }
230
+
231
+ export async function addLogtime(token, entry, options = {}) {
232
+ const {
233
+ projectMemberId: _projectMemberId,
234
+ projectId,
235
+ logtimes,
236
+ taskName,
237
+ localDateISO,
238
+ typeOfWork,
239
+ description,
240
+ worklogTaskId,
241
+ worklogNeedsProjectId,
242
+ taskCache
243
+ } = entry || {};
244
+
245
+ const normalizedHours = Number(logtimes);
246
+ const normalizedTaskName = String(taskName ?? '').trim();
247
+ if (!Number.isFinite(normalizedHours) || normalizedHours <= 0) {
248
+ throw new Error('logtimes must be a positive number.');
249
+ }
250
+ if (!normalizedTaskName) {
251
+ throw new Error('taskName must not be empty.');
252
+ }
253
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(String(localDateISO || ''))) {
254
+ throw new Error('localDateISO must use YYYY-MM-DD format.');
255
+ }
256
+
257
+ const normalizedProjectId = assertPositiveProjectId(projectId);
258
+ let resolvedTaskId = worklogTaskId;
259
+ let needsProjectId = worklogNeedsProjectId;
260
+
261
+ if (resolvedTaskId === undefined || resolvedTaskId === null) {
262
+ const tasks = await fetchWorklogTasksForProject(token, normalizedProjectId, {
263
+ cache: taskCache,
264
+ ...options
265
+ });
266
+ const resolution = resolveWorklogTask({
267
+ taskName: normalizedTaskName,
268
+ projectId: normalizedProjectId,
269
+ tasks
270
+ });
271
+ resolvedTaskId = resolution.worklogTaskId;
272
+ needsProjectId = resolution.needsProjectId;
273
+ }
274
+
275
+ return createLogworkEntry(token, {
276
+ worklogTaskId: resolvedTaskId,
277
+ projectId: normalizedProjectId,
278
+ effortHours: normalizedHours,
279
+ localDateISO,
280
+ typeOfWork,
281
+ description,
282
+ needsProjectId: Boolean(needsProjectId)
283
+ }, options);
284
+ }
285
+
286
+ function assertPositiveProjectId(projectId, label = 'projectId') {
287
+ const normalized = Number(projectId);
288
+ if (!Number.isFinite(normalized) || normalized <= 0) {
289
+ throw new Error(`${label} must be a positive number.`);
290
+ }
291
+ return normalized;
292
+ }
293
+
294
+ function mapLogworkEntryApiError(error) {
295
+ if (!(error instanceof ApiError)) {
296
+ return error;
297
+ }
298
+
299
+ const bodyText = String(error.body || '');
300
+ const knownMessages = [
301
+ ['TASK_OR_PROJECT_NOT_FOUND', 'Worklog task or project was not found. Check project_id and worklog_task_id.'],
302
+ ['HOLIDAY_BLOCKED', 'Logwork is blocked on this date (holiday).'],
303
+ ['NOT_MEMBER_OF_PROJECT', 'You are not a member of this project.'],
304
+ ['INVALID_EFFORT', 'Effort hours must be greater than 0.'],
305
+ ['EFFORT_EXCEEDS_24', 'Effort cannot exceed 24 hours per entry.'],
306
+ ['ENTRY_NOT_FOUND', 'Logwork entry was not found or is already deleted.'],
307
+ ['FORBIDDEN', 'You can only delete or change logwork entries you created.']
308
+ ];
309
+
310
+ for (const [code, message] of knownMessages) {
311
+ if (bodyText.includes(code)) {
312
+ return new ApiError(message, {
313
+ status: error.status,
314
+ path: error.path,
315
+ body: error.body
316
+ });
317
+ }
318
+ }
319
+
320
+ if (bodyText.includes('Can only delete unprocessed entries')) {
321
+ return new ApiError('Only submitted or approved entries can be deleted. Rejected entries must be resubmitted or edited.', {
322
+ status: error.status,
323
+ path: error.path,
324
+ body: error.body
325
+ });
326
+ }
327
+
328
+ if (bodyText.includes('LOCKED') || bodyText.includes('locked')) {
329
+ return new ApiError('This log date is locked and cannot be edited.', {
330
+ status: error.status,
331
+ path: error.path,
332
+ body: error.body
333
+ });
334
+ }
335
+
336
+ return error;
337
+ }
338
+
339
+ export function normalizeEditableLogtime(record = {}) {
340
+ const hours = Number(record.logtimes ?? record.hours ?? record.effort_hours ?? record.time);
341
+ return {
342
+ id: record.id ?? record.logtime_id ?? record.logtimeId,
343
+ projectMemberId: record.project_member_id ?? record.projectMemberId,
344
+ projectId: record.project_id ?? record.projectId ?? record.project?.id,
345
+ projectName: record.project_name ?? record.projectName ?? record.project?.name ?? '',
346
+ date: normalizeDateOnly(record.logdate ?? record.log_date ?? record.date),
347
+ hours: Number.isFinite(hours) ? hours : 0,
348
+ taskName: record.task_name ?? record.taskName ?? record.name ?? record.title ?? '',
349
+ createdBy: record.created_by ?? record.createdBy,
350
+ updatedBy: record.updated_by ?? record.updatedBy,
351
+ updatedDate: record.updated_date ?? record.updatedDate ?? '',
352
+ jiraLogworkId: record.jira_logwork_id ?? record.jiraLogworkId ?? null,
353
+ referrerUrl: record.referrer_url ?? record.referrerUrl ?? null,
354
+ source: record.source ?? '',
355
+ status: record.status ?? '',
356
+ isLocked: Boolean(record.is_locked ?? record.isLocked),
357
+ raw: record
358
+ };
359
+ }
@@ -0,0 +1,290 @@
1
+ // Readers for loosely shaped Resource Optimiser responses. Each reader tries a list
2
+ // of candidate field paths; extend those lists rather than hard-coding one shape.
3
+ import { addWarning, diagnosticProjectFields } from './ro-api-diagnostics.mjs';
4
+
5
+ export function readProjectMemberId(row) {
6
+ const explicit = readExplicitProjectMemberId(row);
7
+
8
+ if (explicit !== undefined) {
9
+ return explicit;
10
+ }
11
+
12
+ if (looksLikeProjectMemberRow(row)) {
13
+ return row.id;
14
+ }
15
+
16
+ return undefined;
17
+ }
18
+
19
+ export function readExplicitProjectMemberId(row) {
20
+ return pickFirst(row, [
21
+ 'project_member_id',
22
+ 'projectMemberId',
23
+ 'project_member.id',
24
+ 'projectMember.id'
25
+ ]);
26
+ }
27
+
28
+ export function readProjectId(row) {
29
+ return pickFirst(row, [
30
+ 'project_id',
31
+ 'projectId',
32
+ 'project.id',
33
+ 'project.project_id',
34
+ 'projectMember.project_id',
35
+ 'project_member.project_id'
36
+ ]);
37
+ }
38
+
39
+ export function readProjectName(row, fallbackId, diagnostics = null, context = {}) {
40
+ const projectName = pickFirst(row, [
41
+ 'project.name',
42
+ 'project_name',
43
+ 'projectName',
44
+ 'name',
45
+ 'projectMember.project.name',
46
+ 'project_member.project.name'
47
+ ]);
48
+
49
+ if (projectName !== undefined) {
50
+ return projectName;
51
+ }
52
+
53
+ if (fallbackId !== undefined && fallbackId !== null && String(fallbackId).trim() !== '') {
54
+ addWarning(diagnostics, 'fallback_project_name', {
55
+ ...context,
56
+ ...diagnosticProjectFields({ projectMemberId: readProjectMemberId(row), projectId: readProjectId(row) })
57
+ });
58
+ return projectFallbackName(fallbackId);
59
+ }
60
+
61
+ addWarning(diagnostics, 'fallback_project_name', context);
62
+ return 'Unknown project';
63
+ }
64
+
65
+ export function projectFallbackName(fallbackId) {
66
+ return fallbackId !== undefined && fallbackId !== null && String(fallbackId).trim() !== ''
67
+ ? `Project ${fallbackId}`
68
+ : 'Unknown project';
69
+ }
70
+
71
+ export function hasProjectName(row) {
72
+ return pickFirst(row, [
73
+ 'project.name',
74
+ 'project_name',
75
+ 'projectName',
76
+ 'name',
77
+ 'projectMember.project.name',
78
+ 'project_member.project.name'
79
+ ]) !== undefined;
80
+ }
81
+
82
+ function looksLikeProjectMemberRow(row) {
83
+ return row && typeof row === 'object' && row.id !== undefined && (
84
+ row.project_id !== undefined ||
85
+ row.projectId !== undefined ||
86
+ row.project !== undefined ||
87
+ row.project_name !== undefined ||
88
+ row.projectName !== undefined ||
89
+ row.assign_hours !== undefined ||
90
+ row.assigned_hours !== undefined ||
91
+ row.booked_hours !== undefined
92
+ );
93
+ }
94
+
95
+ export function readAssignedHours(row, diagnostics = null, context = {}) {
96
+ return readNumber(row, [
97
+ 'assign_hours',
98
+ 'assigned_hours',
99
+ 'assignedHours',
100
+ 'booking_hours',
101
+ 'booked_hours',
102
+ 'bookedHours',
103
+ 'workload_hours',
104
+ 'workloadHours',
105
+ 'assign_percent',
106
+ 'assignPercent',
107
+ 'hours_per_day',
108
+ 'hoursPerDay',
109
+ 'timesheet',
110
+ 'logtime',
111
+ 'logtimes_plan',
112
+ 'planned_hours',
113
+ 'plannedHours',
114
+ 'effort',
115
+ 'man_day'
116
+ ], diagnostics, context);
117
+ }
118
+
119
+ export function readLoggedHours(row, diagnostics = null, context = {}) {
120
+ return readNumber(row, [
121
+ 'logged_hours',
122
+ 'loggedHours',
123
+ 'actual_hours',
124
+ 'actualHours',
125
+ 'logtimes',
126
+ 'log_times',
127
+ 'logTimes',
128
+ 'logged',
129
+ 'total_logtimes',
130
+ 'totalLoggedHours',
131
+ 'total_logged_hours',
132
+ 'spent_hours',
133
+ 'spentHours'
134
+ ], diagnostics, context);
135
+ }
136
+
137
+ export function readNumber(row, paths, diagnostics = null, context = {}) {
138
+ for (const path of paths) {
139
+ const value = pickFirst(row, [path]);
140
+ if (value !== undefined) {
141
+ const number = Number(value);
142
+ if (Number.isFinite(number) && number > 0) {
143
+ return number;
144
+ }
145
+ if (Number.isFinite(number) && number === 0) {
146
+ continue;
147
+ }
148
+ addWarning(diagnostics, 'invalid_hours', {
149
+ ...context,
150
+ field: path
151
+ });
152
+ }
153
+ }
154
+
155
+ return 0;
156
+ }
157
+
158
+ export function coerceHours(value, diagnostics = null, context = {}) {
159
+ const number = Number(value);
160
+ if (Number.isFinite(number) && number >= 0) {
161
+ return number;
162
+ }
163
+
164
+ addWarning(diagnostics, 'invalid_hours', context);
165
+ return 0;
166
+ }
167
+
168
+ export function readDateLike(row) {
169
+ const value = pickFirst(row, [
170
+ 'logdate',
171
+ 'log_date',
172
+ 'date',
173
+ 'day',
174
+ 'workdate',
175
+ 'work_date',
176
+ 'timesheet_date'
177
+ ]);
178
+
179
+ return normalizeDateOnly(value);
180
+ }
181
+
182
+ export function normalizeDateOnly(value) {
183
+ if (!value) {
184
+ return '';
185
+ }
186
+
187
+ const text = String(value);
188
+ const match = text.match(/\d{4}-\d{2}-\d{2}/);
189
+ return match ? match[0] : '';
190
+ }
191
+
192
+ export function isDateInRange(localDateISO, { from, to } = {}) {
193
+ if (!localDateISO) {
194
+ return false;
195
+ }
196
+
197
+ if (from && localDateISO < from) {
198
+ return false;
199
+ }
200
+
201
+ if (to && localDateISO >= to) {
202
+ return false;
203
+ }
204
+
205
+ return true;
206
+ }
207
+
208
+ export function pickFirst(row, paths) {
209
+ for (const path of paths) {
210
+ const value = getPath(row, path);
211
+ if (value !== undefined && value !== null && value !== '') {
212
+ return value;
213
+ }
214
+ }
215
+
216
+ return undefined;
217
+ }
218
+
219
+ function getPath(row, path) {
220
+ return path.split('.').reduce((current, part) => {
221
+ if (current === undefined || current === null) {
222
+ return undefined;
223
+ }
224
+
225
+ return current[part];
226
+ }, row);
227
+ }
228
+
229
+ export function unwrapRecords(data) {
230
+ if (Array.isArray(data)) {
231
+ return data;
232
+ }
233
+
234
+ if (Array.isArray(data?.data)) {
235
+ return data.data;
236
+ }
237
+
238
+ if (Array.isArray(data?.data?.data)) {
239
+ return data.data.data;
240
+ }
241
+
242
+ if (Array.isArray(data?.records)) {
243
+ return data.records;
244
+ }
245
+
246
+ if (Array.isArray(data?.items)) {
247
+ return data.items;
248
+ }
249
+
250
+ return [];
251
+ }
252
+
253
+ export function extractLogtimeRecord(data, collectionKey) {
254
+ const candidates = collectionKey
255
+ ? [data?.data?.[collectionKey], data?.[collectionKey]]
256
+ : [data?.data?.data, data?.data, data?.record, data?.item, data];
257
+
258
+ for (const candidate of candidates) {
259
+ if (Array.isArray(candidate)) {
260
+ const record = candidate.find((item) => item && typeof item === 'object');
261
+ if (record) {
262
+ return record;
263
+ }
264
+ continue;
265
+ }
266
+ if (candidate && typeof candidate === 'object' && (
267
+ candidate.id !== undefined ||
268
+ candidate.logtime_id !== undefined ||
269
+ candidate.logtimeId !== undefined
270
+ )) {
271
+ return candidate;
272
+ }
273
+ }
274
+
275
+ return null;
276
+ }
277
+
278
+ export function assertLogtimeId(value, name) {
279
+ const normalized = String(value ?? '').trim();
280
+ if (!normalized || !/^[A-Za-z0-9_-]+$/.test(normalized)) {
281
+ throw new Error(`${name} must be a valid Resource Optimiser identifier.`);
282
+ }
283
+ return normalized;
284
+ }
285
+
286
+ export function preserveNumericId(value) {
287
+ return /^\d+$/.test(value) && Number.isSafeInteger(Number(value))
288
+ ? Number(value)
289
+ : value;
290
+ }
@@ -0,0 +1,68 @@
1
+ // Resource Optimiser HTTP plumbing: the authenticated JSON fetch and its error type.
2
+ import { CONFIG } from './config.mjs';
3
+ import { fetchWithPolicy, redactedExcerpt } from './http.mjs';
4
+ import { safeJsonParse } from './util.mjs';
5
+
6
+ export class ApiError extends Error {
7
+ constructor(message, { status, path, body } = {}) {
8
+ super(message);
9
+ this.name = 'ApiError';
10
+ this.status = status;
11
+ this.path = path;
12
+ this.body = body;
13
+ }
14
+ }
15
+
16
+ export async function apiFetch(token, path, options = {}) {
17
+ const {
18
+ fetchImpl,
19
+ retries,
20
+ retryDelayMs = CONFIG.httpRetryDelayMs,
21
+ timeoutMs = CONFIG.httpTimeoutMs,
22
+ cache: _worklogTaskCache,
23
+ taskCache: _taskCacheAlias,
24
+ ...requestOptions
25
+ } = options;
26
+ const method = requestOptions.method || 'GET';
27
+ const response = await fetchWithPolicy(`${CONFIG.apiBase}${path}`, {
28
+ ...requestOptions,
29
+ fetchImpl,
30
+ timeoutMs,
31
+ retryDelayMs,
32
+ retries: retries ?? CONFIG.httpReadRetries,
33
+ idempotent: isIdempotentMethod(method),
34
+ method,
35
+ headers: {
36
+ Authorization: `Bearer ${token}`,
37
+ Accept: 'application/json',
38
+ 'Content-Type': 'application/json',
39
+ ...requestOptions.headers
40
+ }
41
+ });
42
+
43
+ const text = await response.text();
44
+ const data = text ? safeJsonParse(text, text) : null;
45
+ const safeBody = redactedExcerpt(text);
46
+
47
+ if (response.status === 401) {
48
+ throw new ApiError('API request returned 401 Unauthorized.', {
49
+ status: response.status,
50
+ path,
51
+ body: safeBody
52
+ });
53
+ }
54
+
55
+ if (!response.ok) {
56
+ throw new ApiError(`API request failed: ${response.status} ${response.statusText} for ${path}. Body: ${safeBody}`, {
57
+ status: response.status,
58
+ path,
59
+ body: safeBody
60
+ });
61
+ }
62
+
63
+ return data;
64
+ }
65
+
66
+ function isIdempotentMethod(method) {
67
+ return ['GET', 'HEAD', 'OPTIONS'].includes(String(method || 'GET').toUpperCase());
68
+ }