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.
- package/README.md +23 -7
- package/RELEASE.md +1 -0
- package/commands/commits.mjs +57 -6
- package/commands/history.mjs +0 -4
- package/commands/quick.mjs +38 -23
- package/commands/reminder.mjs +41 -2
- package/commands/setup.mjs +50 -16
- package/commands/tui.mjs +1 -0
- package/docs/advanced.md +12 -2
- package/docs/mcp-setup.md +56 -14
- package/docs/operations-guide.md +35 -2
- package/docs/recipes.md +27 -0
- package/docs/security.md +3 -1
- package/examples/mcp/antigravity.mcp_config.json +1 -1
- package/examples/mcp/codex.config.toml +1 -1
- package/examples/mcp/copilot.mcp.json +1 -1
- package/lib/api-auth-forms.mjs +393 -0
- package/lib/api-auth-http.mjs +184 -0
- package/lib/api-auth-support.mjs +101 -0
- package/lib/api-auth-tokens.mjs +129 -0
- package/lib/api-auth.mjs +39 -813
- package/lib/api.mjs +38 -1422
- package/lib/apply-ledger.mjs +1 -1
- package/lib/batch-workflow.mjs +187 -2
- package/lib/commit-workflow.mjs +188 -21
- package/lib/config.mjs +7 -0
- package/lib/diagnostics.mjs +53 -4
- package/lib/jira-api.mjs +25 -3
- package/lib/jira-workflow.mjs +36 -6
- package/lib/logwork-config.mjs +64 -13
- package/lib/manual-apply-state.mjs +15 -0
- package/lib/manual-commands.mjs +2 -2
- package/lib/manual-ink-app.mjs +108 -1281
- package/lib/manual-ink-draft-actions.mjs +227 -0
- package/lib/manual-ink-interaction.mjs +306 -0
- package/lib/manual-ink-session.mjs +41 -0
- package/lib/manual-ink-task-editor.mjs +338 -0
- package/lib/manual-ink-wizard.mjs +520 -0
- package/lib/manual-mcp.mjs +27 -15
- package/lib/manual-repl.mjs +39 -3
- package/lib/mcp-server.mjs +51 -20
- package/lib/node-runtime.mjs +115 -0
- package/lib/paths.mjs +7 -0
- package/lib/project-mapping-workflow.mjs +30 -7
- package/lib/query-workflow.mjs +0 -1
- package/lib/reconciliation-workflow.mjs +1 -1
- package/lib/reminder-config.mjs +38 -2
- package/lib/reminder-scheduler.mjs +54 -3
- package/lib/reminder-service.mjs +28 -3
- package/lib/reminder-workflow.mjs +118 -22
- package/lib/ro-api-diagnostics.mjs +109 -0
- package/lib/ro-api-entries.mjs +359 -0
- package/lib/ro-api-fields.mjs +290 -0
- package/lib/ro-api-http.mjs +68 -0
- package/lib/ro-api-projects.mjs +137 -0
- package/lib/ro-api-tasks.mjs +50 -0
- package/lib/ro-api-timesheet.mjs +413 -0
- package/lib/ro-delete-workflow.mjs +2 -1
- package/lib/ro-entry-reader.mjs +11 -6
- package/lib/ro-resubmit-workflow.mjs +2 -1
- package/lib/terminal-auth-prompts.mjs +7 -4
- package/lib/ui.mjs +80 -59
- package/package.json +11 -4
- package/skills/logwork/SKILL.md +5 -4
|
@@ -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("'", ''');
|
|
297
339
|
}
|
|
298
340
|
|
|
341
|
+
function unescapeXml(value) {
|
|
342
|
+
return String(value)
|
|
343
|
+
.replaceAll('<', '<')
|
|
344
|
+
.replaceAll('>', '>')
|
|
345
|
+
.replaceAll('"', '"')
|
|
346
|
+
.replaceAll(''', "'")
|
|
347
|
+
.replaceAll('&', '&');
|
|
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
|
}
|
package/lib/reminder-service.mjs
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
}
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
+
}
|