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
@@ -1,4 +1,6 @@
1
1
  import { readFile } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { isAbsolute, resolve } from 'node:path';
2
4
  import { atomicWriteFile } from './atomic-file.mjs';
3
5
  import { reminderConfigPath } from './paths.mjs';
4
6
 
@@ -6,13 +8,15 @@ export const REMINDER_TARGETS = Object.freeze(['ro', 'jira', 'both']);
6
8
  export const WEEKDAY_NUMBERS = Object.freeze([1, 2, 3, 4, 5]);
7
9
  export const DEFAULT_REMINDER_TIME = '17:00';
8
10
  export const DEFAULT_REMINDER_TARGET = 'both';
11
+ export const MAX_REMINDER_REPOS = 20;
9
12
 
10
13
  export function defaultReminderConfig() {
11
14
  return {
12
15
  enabled: false,
13
16
  time: DEFAULT_REMINDER_TIME,
14
17
  target: DEFAULT_REMINDER_TARGET,
15
- days: [...WEEKDAY_NUMBERS]
18
+ days: [...WEEKDAY_NUMBERS],
19
+ repos: []
16
20
  };
17
21
  }
18
22
 
@@ -46,10 +50,42 @@ export function normalizeReminderConfig(config = {}) {
46
50
  enabled: Boolean(config.enabled),
47
51
  time,
48
52
  target,
49
- days
53
+ days,
54
+ repos: normalizeReminderRepos(config.repos ?? defaults.repos)
50
55
  };
51
56
  }
52
57
 
58
+ // Git repos whose commits the reminder summarizes. Stored as absolute paths
59
+ // because the scheduled run starts in the helper home, not where the user
60
+ // typed the command. `~` is expanded; relative paths resolve against cwd.
61
+ export function normalizeReminderRepos(value, { cwd = process.cwd(), home = homedir() } = {}) {
62
+ if (value === undefined || value === null) {
63
+ return [];
64
+ }
65
+ if (!Array.isArray(value)) {
66
+ throw new Error('Reminder repos must be a list of paths.');
67
+ }
68
+ const repos = [];
69
+ for (const item of value) {
70
+ const raw = String(item ?? '').trim();
71
+ if (!raw) {
72
+ continue;
73
+ }
74
+ if (/[\0\r\n]/.test(raw)) {
75
+ throw new Error('Reminder repo paths cannot contain control characters.');
76
+ }
77
+ const expanded = raw === '~' || /^~[\\/]/.test(raw) ? `${home}${raw.slice(1)}` : raw;
78
+ const absolute = isAbsolute(expanded) ? resolve(expanded) : resolve(cwd, expanded);
79
+ if (!repos.includes(absolute)) {
80
+ repos.push(absolute);
81
+ }
82
+ }
83
+ if (repos.length > MAX_REMINDER_REPOS) {
84
+ throw new Error(`Reminder repos are limited to ${MAX_REMINDER_REPOS} paths.`);
85
+ }
86
+ return repos;
87
+ }
88
+
53
89
  export function normalizeReminderTime(value) {
54
90
  const time = String(value || '').trim();
55
91
  const match = /^(\d{2}):(\d{2})$/.exec(time);
@@ -119,7 +119,8 @@ export async function getReminderSchedulerStatus({
119
119
  userHome = homedir(),
120
120
  uid = userInfo().uid,
121
121
  run = runCommand,
122
- accessFile = fs.access
122
+ accessFile = fs.access,
123
+ readFile = fs.readFile
123
124
  } = {}) {
124
125
  if (platform === 'darwin') {
125
126
  const plistPath = macosReminderPlistPath(userHome);
@@ -130,7 +131,8 @@ export async function getReminderSchedulerStatus({
130
131
  installed: fileExists && result.code === 0,
131
132
  scheduler: 'launchd',
132
133
  identifier: MACOS_REMINDER_LABEL,
133
- path: plistPath
134
+ path: plistPath,
135
+ nodePath: fileExists ? await readReminderSchedulerNodePath({ platform, userHome, run, readFile }) : null
134
136
  };
135
137
  }
136
138
 
@@ -141,13 +143,53 @@ export async function getReminderSchedulerStatus({
141
143
  installed: result.code === 0,
142
144
  scheduler: 'Task Scheduler',
143
145
  identifier: WINDOWS_REMINDER_TASK_NAME,
144
- path: null
146
+ path: null,
147
+ nodePath: result.code === 0 ? await readReminderSchedulerNodePath({ platform, userHome, run, readFile }) : null
145
148
  };
146
149
  }
147
150
 
148
151
  throw unsupportedReminderPlatform(platform);
149
152
  }
150
153
 
154
+ // The node binary the installed schedule runs: the first ProgramArguments
155
+ // entry of the launchd plist, or the <Command> of the Windows task. Returns
156
+ // null when no schedule is installed or it cannot be read. Read-only.
157
+ export async function readReminderSchedulerNodePath({
158
+ platform = process.platform,
159
+ userHome = homedir(),
160
+ run = runCommand,
161
+ readFile = fs.readFile
162
+ } = {}) {
163
+ try {
164
+ if (platform === 'darwin') {
165
+ const plist = await readFile(macosReminderPlistPath(userHome), 'utf8');
166
+ return parseMacosPlistNodePath(plist);
167
+ }
168
+ if (platform === 'win32') {
169
+ const result = await run('schtasks.exe', ['/Query', '/TN', WINDOWS_REMINDER_TASK_NAME, '/XML']);
170
+ return result.code === 0 ? parseWindowsTaskNodePath(result.stdout) : null;
171
+ }
172
+ } catch {
173
+ // Missing or unreadable schedule: nothing to compare.
174
+ }
175
+ return null;
176
+ }
177
+
178
+ export function parseMacosPlistNodePath(plist) {
179
+ const match = /<key>ProgramArguments<\/key>\s*<array>\s*<string>([^<]*)<\/string>/.exec(String(plist || ''));
180
+ return match ? unescapeXml(match[1]).trim() || null : null;
181
+ }
182
+
183
+ export function parseWindowsTaskNodePath(xml) {
184
+ // schtasks may write the XML as UTF-16; decoded as UTF-8 that leaves NULs.
185
+ const match = /<Command>([^<]*)<\/Command>/i.exec(String(xml || '').replaceAll('\0', ''));
186
+ if (!match) {
187
+ return null;
188
+ }
189
+ // schtasks keeps the quotes /TR put around a path with spaces.
190
+ return unescapeXml(match[1]).trim().replace(/^"(.*)"$/, '$1') || null;
191
+ }
192
+
151
193
  export function buildMacosReminderPlist({ config, nodePath, runnerPath, home }) {
152
194
  const normalized = normalizeReminderConfig({ ...config, enabled: true });
153
195
  const [hour, minute] = normalized.time.split(':').map(Number);
@@ -296,6 +338,15 @@ function escapeXml(value) {
296
338
  .replaceAll("'", '&apos;');
297
339
  }
298
340
 
341
+ function unescapeXml(value) {
342
+ return String(value)
343
+ .replaceAll('&lt;', '<')
344
+ .replaceAll('&gt;', '>')
345
+ .replaceAll('&quot;', '"')
346
+ .replaceAll('&apos;', "'")
347
+ .replaceAll('&amp;', '&');
348
+ }
349
+
299
350
  function unsupportedReminderPlatform(platform) {
300
351
  return new Error(`Reminder scheduling is unsupported on ${platform}. Supported platforms are macOS and Windows.`);
301
352
  }
@@ -1,6 +1,8 @@
1
+ import { formatNodePathIssue, inspectNodePath } from './node-runtime.mjs';
1
2
  import {
2
3
  loadReminderConfig,
3
4
  normalizeReminderConfig,
5
+ normalizeReminderRepos,
4
6
  saveReminderConfig
5
7
  } from './reminder-config.mjs';
6
8
  import {
@@ -11,9 +13,12 @@ import {
11
13
  import { loadReminderState } from './reminder-state.mjs';
12
14
  import { sendTestReminderNotification } from './reminder-workflow.mjs';
13
15
 
16
+ // repos: undefined keeps the configured commit repos; an array (even empty)
17
+ // replaces them.
14
18
  export async function enableLogworkReminder({
15
19
  time,
16
20
  target,
21
+ repos,
17
22
  confirm,
18
23
  loadConfig = loadReminderConfig,
19
24
  saveConfig = saveReminderConfig,
@@ -26,7 +31,8 @@ export async function enableLogworkReminder({
26
31
  ...current,
27
32
  enabled: true,
28
33
  ...(time ? { time } : {}),
29
- ...(target ? { target } : {})
34
+ ...(target ? { target } : {}),
35
+ ...(repos !== undefined ? { repos: normalizeReminderRepos(repos) } : {})
30
36
  });
31
37
 
32
38
  const scheduler = await installScheduler(config);
@@ -41,7 +47,12 @@ export async function enableLogworkReminder({
41
47
  status: 'enabled',
42
48
  config,
43
49
  scheduler,
44
- summary: `Enabled ${config.target} logwork reminder at ${config.time}, Monday-Friday, using ${scheduler.scheduler}.`
50
+ summary: [
51
+ `Enabled ${config.target} logwork reminder at ${config.time}, Monday-Friday, using ${scheduler.scheduler}.`,
52
+ config.repos.length
53
+ ? `Commit summary repos: ${config.repos.join(', ')}.`
54
+ : 'No reminder repos set; add --repo <path> to include today\'s commits (repos on project mappings are also used).'
55
+ ].join('\n')
45
56
  };
46
57
  }
47
58
 
@@ -69,7 +80,8 @@ export async function disableLogworkReminder({
69
80
  export async function getLogworkReminderStatus({
70
81
  loadConfig = loadReminderConfig,
71
82
  loadState = loadReminderState,
72
- schedulerStatus = getReminderSchedulerStatus
83
+ schedulerStatus = getReminderSchedulerStatus,
84
+ inspectNode = inspectNodePath
73
85
  } = {}) {
74
86
  const [config, state, scheduler] = await Promise.all([
75
87
  loadConfig(),
@@ -82,9 +94,21 @@ export async function getLogworkReminderStatus({
82
94
  `Schedule: Monday-Friday at ${config.time}; target ${config.target}.`,
83
95
  `OS scheduler: ${scheduler.scheduler} ${scheduler.installed ? 'installed' : 'not installed'}.`
84
96
  ];
97
+ if (config.repos?.length) {
98
+ lines.push(`Commit summary repos: ${config.repos.join(', ')}.`);
99
+ }
85
100
  if (!consistent) {
86
101
  lines.push('Warning: reminder config and OS scheduler are out of sync; run reminder enable or disable again.');
87
102
  }
103
+ // The schedule stores an absolute node path; nvm or Homebrew can remove it
104
+ // while the schedule still looks installed.
105
+ const nodeCheck = scheduler.nodePath
106
+ ? await inspectNode(scheduler.nodePath).catch(() => ({ status: 'unknown', recordedPath: scheduler.nodePath }))
107
+ : null;
108
+ const nodeIssue = nodeCheck ? formatNodePathIssue('reminder', nodeCheck) : '';
109
+ if (nodeIssue) {
110
+ lines.push(`Warning: ${nodeIssue}`);
111
+ }
88
112
  if (state?.lastRunAt) {
89
113
  lines.push(`Last run: ${state.lastRunAt}, status ${state.lastStatus || 'unknown'}.`);
90
114
  }
@@ -94,6 +118,7 @@ export async function getLogworkReminderStatus({
94
118
  scheduler,
95
119
  state,
96
120
  consistent,
121
+ ...(nodeCheck ? { nodeCheck } : {}),
97
122
  summary: lines.join('\n')
98
123
  };
99
124
  }
@@ -1,26 +1,37 @@
1
1
  import { isAuthRequiredError } from './auth-errors.mjs';
2
2
  import { redactText } from './auth-redaction.mjs';
3
+ import { draftLogworkFromCommits } from './commit-workflow.mjs';
3
4
  import { withFileLock } from './file-lock.mjs';
4
5
  import { searchJiraIssues } from './jira-api.mjs';
5
6
  import {
6
7
  getStoredJiraSession,
7
8
  isJiraAuthRequiredError
8
9
  } from './jira-auth.mjs';
10
+ import { loadLocalConfig } from './logwork-config.mjs';
9
11
  import { sendOsNotification } from './os-notification.mjs';
10
- import { reminderStatePath } from './paths.mjs';
12
+ import { helperHome, reminderStatePath } from './paths.mjs';
11
13
  import { queryLogwork } from './query-workflow.mjs';
12
- import { loadReminderConfig } from './reminder-config.mjs';
14
+ import { loadReminderConfig, normalizeReminderRepos } from './reminder-config.mjs';
13
15
  import {
14
16
  loadReminderState,
15
17
  saveReminderState
16
18
  } from './reminder-state.mjs';
17
19
  import { todayLocalDateISO } from './util.mjs';
18
20
 
21
+ const REMINDER_HEADER = 'Logwork hôm nay chưa hoàn tất';
22
+ const DEFAULT_ACTION_LINE = 'Mở Terminal và chạy: logwork';
23
+ const COMMITS_ACTION_LINE = 'Mở Terminal và chạy: logwork commits today (bản nháp từ commit)';
24
+ const COMMIT_SUMMARY_TIMEOUT_MS = 20_000;
25
+
26
+ // repos: git repos whose commits back up a missing-RO reminder. Without repos,
27
+ // or when reading them fails, the message is the plain RO/Jira one.
19
28
  export async function checkLogworkReminder({
20
29
  target = 'both',
21
30
  date = todayLocalDateISO(),
31
+ repos = [],
22
32
  queryRo = queryResourceOptimiserDay,
23
- queryJira = queryJiraWorklogDay
33
+ queryJira = queryJiraWorklogDay,
34
+ summarizeCommits = summarizeTodayCommits
24
35
  } = {}) {
25
36
  const checks = {};
26
37
  const jobs = [];
@@ -42,15 +53,16 @@ export async function checkLogworkReminder({
42
53
 
43
54
  const problemLines = buildProblemLines(checks);
44
55
  const notify = problemLines.length > 0;
45
- const notification = notify ? {
46
- title: 'Logwork',
47
- message: [
48
- 'Logwork hôm nay chưa hoàn tất',
49
- ...problemLines,
50
- '',
51
- 'Mở Terminal và chạy: logwork'
52
- ].join('\n')
53
- } : null;
56
+ let commits = null;
57
+ if (notify && checks.ro?.status === 'incomplete' && repos?.length) {
58
+ // Commit evidence is a bonus: any failure keeps the plain message.
59
+ try {
60
+ commits = await summarizeCommits({ date, repos });
61
+ } catch (error) {
62
+ commits = { status: 'unavailable', error: redactText(error.message || String(error)).slice(0, 300) };
63
+ }
64
+ }
65
+ const notification = notify ? buildReminderNotification({ problemLines, commits }) : null;
54
66
 
55
67
  return {
56
68
  date,
@@ -58,6 +70,7 @@ export async function checkLogworkReminder({
58
70
  status: notify ? 'notification_required' : 'complete',
59
71
  notify,
60
72
  checks,
73
+ ...(commits ? { commits } : {}),
61
74
  notification,
62
75
  summary: notify
63
76
  ? `Logwork reminder requires attention: ${problemLines.join(' | ')}`
@@ -117,8 +130,81 @@ export async function queryJiraWorklogDay({
117
130
  };
118
131
  }
119
132
 
133
+ // Counts today's commits in the given repos with the read-only commit draft
134
+ // (no RO query: the reminder already has the RO totals). Throws when no repo
135
+ // could be read, so the caller falls back to the plain message.
136
+ export async function summarizeTodayCommits({
137
+ date,
138
+ repos,
139
+ draft = draftLogworkFromCommits,
140
+ cwd = helperHome(),
141
+ timeoutMs = COMMIT_SUMMARY_TIMEOUT_MS
142
+ } = {}) {
143
+ const result = await withTimeout(
144
+ draft({ from: date, repos, useBookings: false, cwd }),
145
+ timeoutMs,
146
+ 'Reading commits timed out.'
147
+ );
148
+ const repoResults = Array.isArray(result?.repos) ? result.repos : [];
149
+ const readable = repoResults.filter((repo) => !repo.error);
150
+ if (!readable.length) {
151
+ throw new Error('None of the reminder repos could be read.');
152
+ }
153
+ return {
154
+ status: 'ok',
155
+ commitCount: Number(result.commitCount || 0),
156
+ repoCount: readable.filter((repo) => Number(repo.commitCount || 0) > 0).length,
157
+ readRepoCount: readable.length,
158
+ failedRepoCount: repoResults.length - readable.length
159
+ };
160
+ }
161
+
162
+ // Reminder repos plus any `repos` listed on project mappings in the local
163
+ // config. Mapping repos are optional and read defensively.
164
+ export async function collectReminderRepos({
165
+ config,
166
+ loadMappings = loadLocalConfig,
167
+ cwd = helperHome()
168
+ } = {}) {
169
+ const repos = [...(Array.isArray(config?.repos) ? config.repos : [])];
170
+ let mappings = [];
171
+ try {
172
+ mappings = (await loadMappings(cwd))?.projectMappings || [];
173
+ } catch {
174
+ mappings = [];
175
+ }
176
+ for (const mapping of Array.isArray(mappings) ? mappings : []) {
177
+ if (!Array.isArray(mapping?.repos)) {
178
+ continue;
179
+ }
180
+ for (const repo of mapping.repos) {
181
+ try {
182
+ repos.push(...normalizeReminderRepos([repo], { cwd }));
183
+ } catch {
184
+ // Skip a malformed mapping path.
185
+ }
186
+ }
187
+ }
188
+ return [...new Set(repos)];
189
+ }
190
+
191
+ export function buildReminderNotification({ header = REMINDER_HEADER, problemLines = [], commits = null } = {}) {
192
+ const hasCommits = commits?.status === 'ok' && commits.commitCount > 0;
193
+ return {
194
+ title: 'Logwork',
195
+ message: [
196
+ header,
197
+ ...problemLines,
198
+ ...(hasCommits ? [`Hôm nay: ${commits.commitCount} commit trong ${commits.repoCount} repo`] : []),
199
+ '',
200
+ hasCommits ? COMMITS_ACTION_LINE : DEFAULT_ACTION_LINE
201
+ ].join('\n')
202
+ };
203
+ }
204
+
120
205
  export async function runScheduledReminder({
121
206
  loadConfig = loadReminderConfig,
207
+ collectRepos = collectReminderRepos,
122
208
  loadState = loadReminderState,
123
209
  saveState = saveReminderState,
124
210
  check = checkLogworkReminder,
@@ -165,9 +251,11 @@ export async function runScheduledReminder({
165
251
  return result;
166
252
  }
167
253
 
254
+ const repos = await collectRepos({ config }).catch(() => []);
168
255
  const checkResult = await check({
169
256
  target: config.target,
170
- date
257
+ date,
258
+ repos
171
259
  });
172
260
  if (!checkResult.notify) {
173
261
  const result = {
@@ -219,15 +307,13 @@ export async function sendTestReminderNotification({
219
307
  if (!lines.length) {
220
308
  throw new Error('Reminder target must be ro, jira, or both.');
221
309
  }
222
- const notification = {
223
- title: 'Logwork',
224
- message: [
225
- 'Đây là notification thử nghiệm',
226
- ...lines,
227
- '',
228
- 'Mở Terminal và chạy: logwork'
229
- ].join('\n')
230
- };
310
+ // Sample data in the real message format, including the commit line a
311
+ // reminder with repos shows when RO hours are missing.
312
+ const notification = buildReminderNotification({
313
+ header: 'Đây là notification thử nghiệm',
314
+ problemLines: lines,
315
+ commits: target === 'jira' ? null : { status: 'ok', commitCount: 5, repoCount: 2 }
316
+ });
231
317
  const result = await notify(notification);
232
318
  return {
233
319
  status: 'sent',
@@ -292,6 +378,16 @@ function formatHours(value) {
292
378
  return Number(value || 0).toFixed(2).replace(/\.00$/, '').replace(/(\.\d)0$/, '$1');
293
379
  }
294
380
 
381
+ function withTimeout(promise, timeoutMs, message) {
382
+ let timer;
383
+ return Promise.race([
384
+ promise,
385
+ new Promise((_, reject) => {
386
+ timer = setTimeout(() => reject(new Error(message)), timeoutMs);
387
+ })
388
+ ]).finally(() => clearTimeout(timer));
389
+ }
390
+
295
391
  function assertDateOnly(value) {
296
392
  if (!/^\d{4}-\d{2}-\d{2}$/.test(String(value || ''))) {
297
393
  throw new Error('Reminder date must use YYYY-MM-DD format.');
@@ -0,0 +1,109 @@
1
+ // Normalization diagnostics attached (non-enumerable) to normalized RO record lists.
2
+
3
+ const NORMALIZATION_DIAGNOSTICS_KEY = 'normalization';
4
+
5
+ const MAX_DIAGNOSTIC_ITEMS = 25;
6
+
7
+ export function getNormalizationDiagnostics(value) {
8
+ return value?.[NORMALIZATION_DIAGNOSTICS_KEY] || emptyNormalizationDiagnostics();
9
+ }
10
+
11
+ export function createNormalizationDiagnostics(sourceShape = 'unknown') {
12
+ return {
13
+ status: 'ok',
14
+ sourceShape,
15
+ rowsRead: 0,
16
+ rowsAccepted: 0,
17
+ droppedRowCount: 0,
18
+ warningCount: 0,
19
+ warnings: [],
20
+ droppedRows: []
21
+ };
22
+ }
23
+
24
+ function emptyNormalizationDiagnostics() {
25
+ return createNormalizationDiagnostics('unknown');
26
+ }
27
+
28
+ export function attachNormalizationDiagnostics(value, diagnostics) {
29
+ Object.defineProperty(value, NORMALIZATION_DIAGNOSTICS_KEY, {
30
+ value: finalizeNormalizationDiagnostics(diagnostics),
31
+ enumerable: false,
32
+ configurable: true
33
+ });
34
+ return value;
35
+ }
36
+
37
+ function finalizeNormalizationDiagnostics(diagnostics) {
38
+ const warningCount = diagnostics.warnings.length;
39
+ const droppedRowCount = diagnostics.droppedRows.length;
40
+ return {
41
+ ...diagnostics,
42
+ status: warningCount || droppedRowCount ? 'warning' : 'ok',
43
+ warningCount,
44
+ droppedRowCount
45
+ };
46
+ }
47
+
48
+ export function combineNormalizationDiagnostics(...items) {
49
+ const diagnostics = createNormalizationDiagnostics('combined');
50
+ for (const item of items) {
51
+ const current = item?.warnings || item?.droppedRows
52
+ ? item
53
+ : getNormalizationDiagnostics(item);
54
+ if (!current) {
55
+ continue;
56
+ }
57
+ diagnostics.rowsRead += Number(current.rowsRead || 0);
58
+ diagnostics.rowsAccepted += Number(current.rowsAccepted || 0);
59
+ for (const warning of current.warnings || []) {
60
+ pushDiagnostic(diagnostics.warnings, warning);
61
+ }
62
+ for (const dropped of current.droppedRows || []) {
63
+ pushDiagnostic(diagnostics.droppedRows, dropped);
64
+ }
65
+ }
66
+ return finalizeNormalizationDiagnostics(diagnostics);
67
+ }
68
+
69
+ export function addWarning(diagnostics, reason, details = {}) {
70
+ if (!diagnostics) {
71
+ return;
72
+ }
73
+ pushDiagnostic(diagnostics.warnings, compactDiagnostic({
74
+ reason,
75
+ ...details
76
+ }));
77
+ }
78
+
79
+ export function addDroppedRow(diagnostics, reason, details = {}) {
80
+ if (!diagnostics) {
81
+ return;
82
+ }
83
+ pushDiagnostic(diagnostics.droppedRows, compactDiagnostic({
84
+ reason,
85
+ ...details
86
+ }));
87
+ }
88
+
89
+ function pushDiagnostic(items, item) {
90
+ if (items.length < MAX_DIAGNOSTIC_ITEMS) {
91
+ items.push(item);
92
+ }
93
+ }
94
+
95
+ function compactDiagnostic(details) {
96
+ return Object.fromEntries(Object.entries(details).filter(([, value]) => (
97
+ value !== undefined &&
98
+ value !== null &&
99
+ value !== ''
100
+ )));
101
+ }
102
+
103
+ export function diagnosticProjectFields({ projectMemberId, projectId, projectName } = {}) {
104
+ return compactDiagnostic({
105
+ projectMemberId,
106
+ projectId,
107
+ projectName
108
+ });
109
+ }