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,137 @@
1
+ // Project memberships and per-project day logs from the legacy member-logtime API.
2
+ import { CONFIG } from './config.mjs';
3
+ import { mapLimit, toApiLogDate } from './util.mjs';
4
+ import { ApiError, apiFetch } from './ro-api-http.mjs';
5
+ import {
6
+ addDroppedRow,
7
+ attachNormalizationDiagnostics,
8
+ createNormalizationDiagnostics,
9
+ diagnosticProjectFields,
10
+ getNormalizationDiagnostics
11
+ } from './ro-api-diagnostics.mjs';
12
+ import {
13
+ hasProjectName,
14
+ normalizeDateOnly,
15
+ projectFallbackName,
16
+ readExplicitProjectMemberId,
17
+ readNumber,
18
+ readProjectId,
19
+ readProjectName,
20
+ unwrapRecords
21
+ } from './ro-api-fields.mjs';
22
+
23
+ export async function getProjects(token, userId) {
24
+ const qs = new URLSearchParams({
25
+ f_user_id: String(userId),
26
+ 'f_project.stage_id': '!6'
27
+ });
28
+
29
+ const data = await apiFetch(token, `${CONFIG.projectMembersPath}?${qs}`);
30
+ return unwrapRecords(data).map(normalizeProject).filter((project) => project.projectMemberId);
31
+ }
32
+
33
+ export async function getDayLogs(token, projectId, userId, localDateISO) {
34
+ const qs = new URLSearchParams({
35
+ f_project_id: String(projectId),
36
+ f_user_id: String(userId),
37
+ f_logdate: toApiLogDate(localDateISO)
38
+ });
39
+
40
+ try {
41
+ const data = await apiFetch(token, `${CONFIG.memberLogtimePath}?${qs}`);
42
+ const logs = unwrapRecords(data);
43
+ const entries = normalizeLogtimeEntries(logs, {
44
+ date: localDateISO,
45
+ projectId
46
+ });
47
+ const normalization = getNormalizationDiagnostics(entries);
48
+
49
+ return {
50
+ projectId,
51
+ totalHours: entries.reduce((sum, log) => sum + Number(log.hours || 0), 0),
52
+ entries,
53
+ logs,
54
+ normalization,
55
+ ok: true
56
+ };
57
+ } catch (error) {
58
+ if (error instanceof ApiError && error.status === 401) {
59
+ throw error;
60
+ }
61
+
62
+ return {
63
+ projectId,
64
+ totalHours: null,
65
+ logs: [],
66
+ ok: false,
67
+ error: error.message
68
+ };
69
+ }
70
+ }
71
+
72
+ export function normalizeLogtimeEntries(logs, defaults = {}) {
73
+ const rows = unwrapRecords(logs);
74
+ const diagnostics = createNormalizationDiagnostics('logtime_entries');
75
+ diagnostics.rowsRead = rows.length;
76
+ const entries = [];
77
+
78
+ rows.forEach((log, index) => {
79
+ if (!log || typeof log !== 'object') {
80
+ addDroppedRow(diagnostics, 'invalid_log_entry', { index });
81
+ return;
82
+ }
83
+
84
+ const context = { index, kind: 'logtime_entry' };
85
+ const date = normalizeDateOnly(log.logdate ?? log.log_date ?? log.date) || defaults.date;
86
+ const projectMemberId = readExplicitProjectMemberId(log) ?? defaults.projectMemberId;
87
+ const projectId = readProjectId(log) ?? defaults.projectId;
88
+ const hours = readNumber(log, ['logtimes', 'hours', 'time'], diagnostics, context);
89
+
90
+ if (!date) {
91
+ addDroppedRow(diagnostics, 'missing_date', { ...context, ...diagnosticProjectFields({ projectMemberId, projectId }) });
92
+ return;
93
+ }
94
+
95
+ if (projectMemberId === undefined && projectId === undefined) {
96
+ addDroppedRow(diagnostics, 'missing_project_identity', { ...context, date });
97
+ return;
98
+ }
99
+
100
+ if (hours <= 0) {
101
+ addDroppedRow(diagnostics, 'non_positive_hours', { ...context, date, ...diagnosticProjectFields({ projectMemberId, projectId }) });
102
+ return;
103
+ }
104
+
105
+ diagnostics.rowsAccepted += 1;
106
+ entries.push({
107
+ id: log.id ?? log.logtime_id ?? log.logtimeId,
108
+ date,
109
+ projectMemberId,
110
+ projectId,
111
+ projectName: hasProjectName(log) ? readProjectName(log, readProjectId(log), diagnostics, context) : defaults.projectName,
112
+ hours,
113
+ taskName: log.task_name ?? log.taskName ?? log.name ?? log.title ?? ''
114
+ });
115
+ });
116
+
117
+ return attachNormalizationDiagnostics(entries, diagnostics);
118
+ }
119
+
120
+ export async function getAllDayLogSummaries(token, projects, userId, localDateISO) {
121
+ return mapLimit(projects, CONFIG.dayLogConcurrency, (project) => (
122
+ getDayLogs(token, project.projectId, userId, localDateISO)
123
+ ));
124
+ }
125
+
126
+ function normalizeProject(rec) {
127
+ const projectId = rec.project_id ?? rec.project?.id ?? rec.project?.project_id;
128
+ const projectMemberId = rec.id ?? rec.project_member_id;
129
+
130
+ return {
131
+ projectMemberId,
132
+ projectId,
133
+ projectName: rec.project?.name ?? rec.project_name ?? rec.name ?? projectFallbackName(projectId ?? projectMemberId),
134
+ workloadPercent: rec.workload ?? rec.workload_percent ?? rec.assigned_percent ?? 0,
135
+ raw: rec
136
+ };
137
+ }
@@ -0,0 +1,50 @@
1
+ // Worklog task catalog: project tasks plus shared default tasks.
2
+ import { CONFIG } from './config.mjs';
3
+ import { apiFetch } from './ro-api-http.mjs';
4
+ import { unwrapRecords } from './ro-api-fields.mjs';
5
+ import {
6
+ mergeWorklogTaskLists,
7
+ normalizeWorklogTask
8
+ } from './worklog-task-resolver.mjs';
9
+
10
+ export async function getWorklogTasks(token, { projectId, search } = {}, options = {}) {
11
+ const path = `${CONFIG.logworkTasksPath}${buildLogworkTasksQuery({ projectId, search })}`;
12
+ const data = await apiFetch(token, path, options);
13
+ return unwrapRecords(data).map(normalizeWorklogTask);
14
+ }
15
+
16
+ export async function getDefaultWorklogTasks(token, { projectId, search } = {}, options = {}) {
17
+ const path = `${CONFIG.logworkDefaultTasksPath}${buildLogworkTasksQuery({ projectId, search })}`;
18
+ const data = await apiFetch(token, path, options);
19
+ return unwrapRecords(data).map(normalizeWorklogTask);
20
+ }
21
+
22
+ export async function fetchWorklogTasksForProject(token, projectId, options = {}) {
23
+ const cacheKey = Number(projectId);
24
+ const { cache, ...apiOptions } = options;
25
+ if (cache instanceof Map && cache.has(cacheKey)) {
26
+ return cache.get(cacheKey);
27
+ }
28
+
29
+ const [projectTasks, defaultTasks] = await Promise.all([
30
+ getWorklogTasks(token, { projectId: cacheKey }, apiOptions),
31
+ getDefaultWorklogTasks(token, { projectId: cacheKey }, apiOptions)
32
+ ]);
33
+ const merged = mergeWorklogTaskLists(projectTasks, defaultTasks);
34
+ if (cache instanceof Map) {
35
+ cache.set(cacheKey, merged);
36
+ }
37
+ return merged;
38
+ }
39
+
40
+ function buildLogworkTasksQuery({ projectId, search }) {
41
+ const qs = new URLSearchParams();
42
+ if (projectId !== undefined && projectId !== null && projectId !== '') {
43
+ qs.set('project_id', String(projectId));
44
+ }
45
+ if (search) {
46
+ qs.set('search', String(search));
47
+ }
48
+ const query = qs.toString();
49
+ return query ? `?${query}` : '';
50
+ }
@@ -0,0 +1,413 @@
1
+ // Timesheet range reads: booked and logged hours per day and project.
2
+ import { CONFIG } from './config.mjs';
3
+ import { addLocalDays, toApiLogDate } from './util.mjs';
4
+ import { apiFetch } from './ro-api-http.mjs';
5
+ import {
6
+ addDroppedRow,
7
+ addWarning,
8
+ attachNormalizationDiagnostics,
9
+ createNormalizationDiagnostics,
10
+ diagnosticProjectFields,
11
+ getNormalizationDiagnostics
12
+ } from './ro-api-diagnostics.mjs';
13
+ import {
14
+ coerceHours,
15
+ hasProjectName,
16
+ isDateInRange,
17
+ normalizeDateOnly,
18
+ pickFirst,
19
+ readAssignedHours,
20
+ readDateLike,
21
+ readLoggedHours,
22
+ readNumber,
23
+ readProjectId,
24
+ readProjectMemberId,
25
+ readProjectName
26
+ } from './ro-api-fields.mjs';
27
+
28
+ export async function getTimesheetRange(token, userId, { from, to }) {
29
+ const { records } = await getTimesheetRangeResult(token, userId, { from, to });
30
+ return records;
31
+ }
32
+
33
+ export async function getTimesheetRangeResult(token, userId, { from, to }) {
34
+ const qs = new URLSearchParams({
35
+ f_user_id: String(userId),
36
+ f_from: toApiLogDate(from),
37
+ f_to: toApiLogDate(to),
38
+ f_timesheet_by_week: '0',
39
+ f_time_off: '1'
40
+ });
41
+
42
+ const data = await apiFetch(token, `${CONFIG.timesheetPath}?${qs}`);
43
+ return normalizeTimesheetRangeResult(data, { from, to });
44
+ }
45
+
46
+ export function normalizeTimesheetRange(data, { from, to } = {}) {
47
+ return normalizeTimesheetRangeResult(data, { from, to }).records;
48
+ }
49
+
50
+ export function normalizeTimesheetRangeResult(data, { from, to } = {}) {
51
+ const diagnostics = createNormalizationDiagnostics('unknown');
52
+ const explicitRows = expandProjectTimesheetRows(data, { from, to }, diagnostics);
53
+ const rows = explicitRows.length ? explicitRows : findTimesheetRowsInRange(data, { from, to });
54
+ diagnostics.sourceShape = explicitRows.length ? 'project_timesheet' : 'range_scan';
55
+ if (!rows.length && hasObjectPayload(data)) {
56
+ addWarning(diagnostics, 'unknown_timesheet_shape');
57
+ }
58
+ const records = normalizeTimesheetRecords(rows, diagnostics);
59
+ return {
60
+ records,
61
+ normalization: getNormalizationDiagnostics(records)
62
+ };
63
+ }
64
+
65
+ export async function getBookedProjectsForDate(token, userId, localDateISO) {
66
+ const { records } = await getTimesheetRangeResult(token, userId, {
67
+ from: localDateISO,
68
+ to: addLocalDays(localDateISO, 1)
69
+ });
70
+ return normalizeTimesheetProjects(records, localDateISO);
71
+ }
72
+
73
+ export async function getTodayTimesheetProjects(token, userId, localDateISO) {
74
+ return getBookedProjectsForDate(token, userId, localDateISO);
75
+ }
76
+
77
+ function normalizeTimesheetProjects(rows, localDateISO) {
78
+ const byMember = new Map();
79
+
80
+ for (const row of rows) {
81
+ const projectMemberId = readProjectMemberId(row);
82
+ const projectId = readProjectId(row);
83
+
84
+ if (!projectMemberId && !projectId) {
85
+ continue;
86
+ }
87
+
88
+ const key = String(projectMemberId ?? projectId);
89
+ const projectName = readProjectName(row, projectId ?? projectMemberId);
90
+ const assignedHours = Number(row.bookedHours ?? readAssignedHours(row));
91
+ const loggedHours = Number(row.loggedHours ?? readLoggedHours(row));
92
+ const existing = byMember.get(key);
93
+
94
+ if (existing) {
95
+ existing.assignedHours += assignedHours;
96
+ existing.loggedHours += loggedHours;
97
+ existing.workloadPercent = hoursToAssignPercent(existing.assignedHours);
98
+ existing.raw.push(row);
99
+ continue;
100
+ }
101
+
102
+ byMember.set(key, {
103
+ projectMemberId: projectMemberId ?? row.project_member_id ?? row.id,
104
+ projectId,
105
+ projectName,
106
+ assignedHours,
107
+ loggedHours,
108
+ workloadPercent: hoursToAssignPercent(assignedHours),
109
+ localDateISO,
110
+ raw: [row]
111
+ });
112
+ }
113
+
114
+ return [...byMember.values()].filter((project) => project.assignedHours > 0);
115
+ }
116
+
117
+ function normalizeTimesheetRecords(rows, diagnostics = createNormalizationDiagnostics('range_scan')) {
118
+ const byDayProject = new Map();
119
+ diagnostics.rowsRead = rows.length;
120
+
121
+ rows.forEach((row, index) => {
122
+ const date = readDateLike(row);
123
+ const projectMemberId = readProjectMemberId(row);
124
+ const projectId = readProjectId(row);
125
+ const context = {
126
+ index,
127
+ kind: 'timesheet_row',
128
+ ...diagnosticProjectFields({ projectMemberId, projectId, projectName: hasProjectName(row) ? readProjectName(row, projectId ?? projectMemberId) : undefined })
129
+ };
130
+
131
+ if (!date) {
132
+ addDroppedRow(diagnostics, 'missing_date', context);
133
+ return;
134
+ }
135
+
136
+ if (projectMemberId === undefined && projectId === undefined) {
137
+ addDroppedRow(diagnostics, 'missing_project_identity', { ...context, date });
138
+ return;
139
+ }
140
+
141
+ const key = `${date}:${projectMemberId ?? projectId}`;
142
+ const existing = byDayProject.get(key);
143
+ const bookedHours = readTimesheetHours(row, ['bookedHours'], readAssignedHours, diagnostics, { ...context, date, metric: 'bookedHours' });
144
+ const loggedHours = readTimesheetHours(row, ['loggedHours'], readLoggedHours, diagnostics, { ...context, date, metric: 'loggedHours' });
145
+ const entries = readLogEntries(row, diagnostics, { ...context, date });
146
+ diagnostics.rowsAccepted += 1;
147
+
148
+ if (existing) {
149
+ existing.bookedHours += bookedHours;
150
+ existing.loggedHours += loggedHours;
151
+ existing.entries.push(...entries);
152
+ existing.raw.push(row);
153
+ return;
154
+ }
155
+
156
+ byDayProject.set(key, {
157
+ date,
158
+ projectMemberId,
159
+ projectId,
160
+ projectName: readProjectName(row, projectId ?? projectMemberId, diagnostics, { ...context, date }),
161
+ bookedHours,
162
+ loggedHours,
163
+ entries,
164
+ raw: [row]
165
+ });
166
+ });
167
+
168
+ return attachNormalizationDiagnostics([...byDayProject.values()].map((record) => ({
169
+ ...record,
170
+ bookedHours: Number(record.bookedHours.toFixed(2)),
171
+ loggedHours: Number(record.loggedHours.toFixed(2))
172
+ })), diagnostics);
173
+ }
174
+
175
+ function expandProjectTimesheetRows(data, range, diagnostics = null) {
176
+ const assignments = [];
177
+ collectProjectAssignments(data, assignments, new Set());
178
+ const rows = [];
179
+
180
+ assignments.forEach((assignment, assignmentIndex) => {
181
+ assignment.timesheet.forEach((day, dayIndex) => {
182
+ const date = normalizeDateOnly(day.logdate ?? day.log_date ?? day.date);
183
+ if (!isDateInRange(date, range)) {
184
+ return;
185
+ }
186
+
187
+ const context = {
188
+ index: rows.length,
189
+ assignmentIndex,
190
+ dayIndex,
191
+ kind: 'project_timesheet_row',
192
+ date,
193
+ ...diagnosticProjectFields({
194
+ projectMemberId: assignment.id,
195
+ projectId: assignment.project?.id,
196
+ projectName: assignment.project?.name
197
+ })
198
+ };
199
+
200
+ rows.push({
201
+ date,
202
+ projectMemberId: assignment.id,
203
+ project_member_id: assignment.id,
204
+ projectId: assignment.project?.id,
205
+ project_id: assignment.project?.id,
206
+ projectName: assignment.project?.name,
207
+ project_name: assignment.project?.name,
208
+ bookedHours: readAssignedHours(day, diagnostics, { ...context, metric: 'bookedHours' }),
209
+ loggedHours: readNumber(day, ['logtimes'], diagnostics, { ...context, metric: 'loggedHours' }),
210
+ overtime: readNumber(day, ['overtime'], diagnostics, { ...context, metric: 'overtime' }),
211
+ source: 'timesheet'
212
+ });
213
+ });
214
+ });
215
+
216
+ return rows;
217
+ }
218
+
219
+ function collectProjectAssignments(value, assignments, seen) {
220
+ if (!value || typeof value !== 'object') {
221
+ return;
222
+ }
223
+
224
+ if (seen.has(value)) {
225
+ return;
226
+ }
227
+ seen.add(value);
228
+
229
+ if (Array.isArray(value)) {
230
+ for (const item of value) {
231
+ collectProjectAssignments(item, assignments, seen);
232
+ }
233
+ return;
234
+ }
235
+
236
+ if (
237
+ value.project &&
238
+ typeof value.project === 'object' &&
239
+ Array.isArray(value.timesheet)
240
+ ) {
241
+ assignments.push(value);
242
+ return;
243
+ }
244
+
245
+ for (const [key, child] of Object.entries(value)) {
246
+ if (key === 'overall') {
247
+ continue;
248
+ }
249
+ collectProjectAssignments(child, assignments, seen);
250
+ }
251
+ }
252
+
253
+ function findTimesheetRowsInRange(data, { from, to } = {}) {
254
+ const rows = [];
255
+ collectTimesheetRowsInRange(data, { from, to }, rows, new Set());
256
+ return rows;
257
+ }
258
+
259
+ function collectTimesheetRowsInRange(value, range, rows, seen) {
260
+ if (!value || typeof value !== 'object') {
261
+ return;
262
+ }
263
+
264
+ if (seen.has(value)) {
265
+ return;
266
+ }
267
+ seen.add(value);
268
+
269
+ if (Array.isArray(value)) {
270
+ for (const item of value) {
271
+ collectTimesheetRowsInRange(item, range, rows, seen);
272
+ }
273
+ return;
274
+ }
275
+
276
+ const directDate = readDateLike(value);
277
+ if (isDateInRange(directDate, range) && looksLikeTimesheetRow(value)) {
278
+ rows.push(value);
279
+ } else if (!directDate && looksLikeTimesheetRow(value)) {
280
+ rows.push(value);
281
+ }
282
+
283
+ for (const [key, child] of Object.entries(value)) {
284
+ if (Array.isArray(child)) {
285
+ const keyDate = normalizeDateOnly(key);
286
+ if (isDateInRange(keyDate, range)) {
287
+ for (const item of child) {
288
+ if (item && typeof item === 'object') {
289
+ rows.push({ ...item, date: readDateLike(item) || keyDate });
290
+ }
291
+ }
292
+ continue;
293
+ }
294
+
295
+ for (const item of child) {
296
+ if (item && typeof item === 'object') {
297
+ const itemDate = readDateLike(item);
298
+ if (isDateInRange(itemDate, range) && looksLikeTimesheetRow(item)) {
299
+ rows.push(item);
300
+ continue;
301
+ }
302
+ }
303
+
304
+ collectTimesheetRowsInRange(item, range, rows, seen);
305
+ }
306
+ continue;
307
+ }
308
+
309
+ if (child && typeof child === 'object') {
310
+ const keyDate = normalizeDateOnly(key);
311
+ if (isDateInRange(keyDate, range)) {
312
+ rows.push({ ...child, date: readDateLike(child) || keyDate });
313
+ continue;
314
+ }
315
+
316
+ collectTimesheetRowsInRange(child, range, rows, seen);
317
+ }
318
+ }
319
+ }
320
+
321
+ function looksLikeTimesheetRow(row) {
322
+ return readAssignedHours(row) > 0 || readLoggedHours(row) > 0 || Boolean(pickFirst(row, [
323
+ 'project_member_id',
324
+ 'projectMemberId',
325
+ 'project_id',
326
+ 'projectId'
327
+ ]));
328
+ }
329
+
330
+ function readLogEntries(row, diagnostics = null, context = {}) {
331
+ const rawEntries = pickFirst(row, [
332
+ 'logs',
333
+ 'logtimes_data',
334
+ 'logtimesData',
335
+ 'log_times',
336
+ 'logTimes',
337
+ 'items',
338
+ 'children'
339
+ ]);
340
+
341
+ if (!Array.isArray(rawEntries)) {
342
+ return [];
343
+ }
344
+
345
+ const entries = [];
346
+ rawEntries.forEach((entry, entryIndex) => {
347
+ if (!entry || typeof entry !== 'object') {
348
+ addDroppedRow(diagnostics, 'invalid_log_entry', {
349
+ ...context,
350
+ entryIndex
351
+ });
352
+ return;
353
+ }
354
+
355
+ const entryContext = {
356
+ ...context,
357
+ entryIndex,
358
+ kind: 'embedded_log_entry'
359
+ };
360
+ const date = normalizeDateOnly(entry.logdate ?? entry.log_date ?? entry.date ?? row.logdate ?? row.date);
361
+ const projectMemberId = readProjectMemberId(entry) ?? readProjectMemberId(row);
362
+ const projectId = readProjectId(entry) ?? readProjectId(row);
363
+ const hours = readNumber(entry, ['logtimes', 'hours', 'time'], diagnostics, { ...entryContext, metric: 'hours' });
364
+
365
+ if (!date) {
366
+ addDroppedRow(diagnostics, 'missing_date', { ...entryContext, ...diagnosticProjectFields({ projectMemberId, projectId }) });
367
+ return;
368
+ }
369
+
370
+ if (projectMemberId === undefined && projectId === undefined) {
371
+ addDroppedRow(diagnostics, 'missing_project_identity', { ...entryContext, date });
372
+ return;
373
+ }
374
+
375
+ if (hours <= 0) {
376
+ addDroppedRow(diagnostics, 'non_positive_hours', { ...entryContext, date, ...diagnosticProjectFields({ projectMemberId, projectId }) });
377
+ return;
378
+ }
379
+
380
+ entries.push({
381
+ id: entry.id ?? entry.logtime_id ?? entry.logtimeId,
382
+ date,
383
+ projectMemberId,
384
+ projectId,
385
+ projectName: hasProjectName(entry) ? readProjectName(entry, readProjectId(entry), diagnostics, entryContext) : readProjectName(row, readProjectId(row) ?? readProjectMemberId(row), diagnostics, entryContext),
386
+ hours,
387
+ taskName: entry.task_name ?? entry.taskName ?? entry.name ?? entry.title ?? '',
388
+ raw: entry
389
+ });
390
+ });
391
+
392
+ return entries;
393
+ }
394
+
395
+ function readTimesheetHours(row, directPaths, fallbackReader, diagnostics, context) {
396
+ const directValue = pickFirst(row, directPaths);
397
+ if (directValue !== undefined) {
398
+ return coerceHours(directValue, diagnostics, {
399
+ ...context,
400
+ field: directPaths[0]
401
+ });
402
+ }
403
+
404
+ return fallbackReader(row, diagnostics, context);
405
+ }
406
+
407
+ function hoursToAssignPercent(hours) {
408
+ return Math.round((Number(hours || 0) / CONFIG.standardDayHours) * 100);
409
+ }
410
+
411
+ function hasObjectPayload(data) {
412
+ return Boolean(data && typeof data === 'object' && Object.keys(data).length > 0);
413
+ }
@@ -1,6 +1,7 @@
1
- import { ApiError, deleteLogworkEntry } from './api.mjs';
1
+ import { ApiError } from './ro-api-http.mjs';
2
+ import { deleteLogworkEntry } from './ro-api-entries.mjs';
2
3
  import { createResourceOptimiserSession } from './query-workflow.mjs';
3
- import { createF03LogworkReader } from './ro-entry-reader.mjs';
4
+ import { assertOwnUnlockedRoEntry, createF03LogworkReader, createRoEntryRevisionFingerprint } from './ro-entry-reader.mjs';
4
5
 
5
6
  const DELETABLE_STATUSES = new Set(['submitted', 'approved']);
6
7
 
@@ -11,6 +12,7 @@ export async function previewRoLogworkDelete({
11
12
  const reader = fetchLogwork || createF03LogworkReader();
12
13
  const current = await reader(assertIdentifier(logworkId, 'logworkId'));
13
14
  assertDeletableStatus(current);
15
+ assertOwnUnlockedRoEntry(current, 'delete');
14
16
 
15
17
  const preview = {
16
18
  previewId: `delete_${current.id}`,
@@ -106,10 +108,5 @@ function assertIdentifier(value, label) {
106
108
  }
107
109
 
108
110
  function createLogworkRevisionFingerprint(logwork) {
109
- return JSON.stringify({
110
- id: logwork.id,
111
- hours: logwork.hours,
112
- status: logwork.status,
113
- taskName: logwork.taskName
114
- });
111
+ return createRoEntryRevisionFingerprint(logwork);
115
112
  }