@tuanhv/op-cli 0.1.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/NOTICE +15 -0
- package/README.md +108 -0
- package/dist/bin.js +8 -0
- package/dist/commands/doctor.js +135 -0
- package/dist/commands/meta.js +283 -0
- package/dist/commands/project.js +645 -0
- package/dist/commands/time.js +555 -0
- package/dist/commands/version.js +47 -0
- package/dist/commands/wp.js +1586 -0
- package/dist/context/metadata.js +528 -0
- package/dist/context/profile.js +218 -0
- package/dist/context/resolve.js +96 -0
- package/dist/core/define.js +155 -0
- package/dist/core/duration.js +151 -0
- package/dist/core/errors.js +102 -0
- package/dist/core/filters.js +133 -0
- package/dist/core/hal.js +114 -0
- package/dist/core/http.js +119 -0
- package/dist/core/paginate.js +55 -0
- package/dist/core/usage.js +58 -0
- package/dist/io/node-io.js +89 -0
- package/dist/output/table.js +52 -0
- package/dist/run.js +300 -0
- package/package.json +53 -0
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { halRefId } from "../core/hal.js";
|
|
5
|
+
import { apiGet, apiPost } from "../core/http.js";
|
|
6
|
+
import { halElements } from "../core/paginate.js";
|
|
7
|
+
import { OpCliError } from "../core/errors.js";
|
|
8
|
+
import { ENV_PROFILE_NAME } from "./profile.js";
|
|
9
|
+
function cacheRoot(env) {
|
|
10
|
+
const root = env.OP_CLI_CACHE_DIR ??
|
|
11
|
+
(env.HOME !== undefined ? join(env.HOME, ".cache", "op-cli") : undefined);
|
|
12
|
+
if (root === undefined) {
|
|
13
|
+
throw new OpCliError("INTERNAL_ERROR");
|
|
14
|
+
}
|
|
15
|
+
return root;
|
|
16
|
+
}
|
|
17
|
+
export function metadataPath(env, profile) {
|
|
18
|
+
const key = profile.name === ENV_PROFILE_NAME
|
|
19
|
+
? `env-${createHash("sha1").update(profile.instanceUrl).digest("hex")}`
|
|
20
|
+
: profile.name;
|
|
21
|
+
return join(cacheRoot(env), key, "metadata.json");
|
|
22
|
+
}
|
|
23
|
+
export async function readStoredMetadata(env, profile) {
|
|
24
|
+
try {
|
|
25
|
+
return JSON.parse(await readFile(metadataPath(env, profile), "utf8"));
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
async function persistMetadata(env, profile, metadata) {
|
|
32
|
+
const path = metadataPath(env, profile);
|
|
33
|
+
await mkdir(dirname(path), { recursive: true });
|
|
34
|
+
await writeFile(path, `${JSON.stringify(metadata, null, 2)}\n`);
|
|
35
|
+
}
|
|
36
|
+
function asString(value) {
|
|
37
|
+
return typeof value === "string" ? value : "";
|
|
38
|
+
}
|
|
39
|
+
function asBoolean(value) {
|
|
40
|
+
return value === true;
|
|
41
|
+
}
|
|
42
|
+
function sortByName(entries) {
|
|
43
|
+
return [...entries].sort((a, b) => a.name.localeCompare(b.name));
|
|
44
|
+
}
|
|
45
|
+
function toStoredType(element) {
|
|
46
|
+
return {
|
|
47
|
+
id: Number(element.id),
|
|
48
|
+
name: asString(element.name),
|
|
49
|
+
is_milestone: asBoolean(element.isMilestone),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
function toStoredStatus(element) {
|
|
53
|
+
return {
|
|
54
|
+
id: Number(element.id),
|
|
55
|
+
name: asString(element.name),
|
|
56
|
+
is_closed: asBoolean(element.isClosed),
|
|
57
|
+
is_default: asBoolean(element.isDefault),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
function toStoredPriority(element) {
|
|
61
|
+
return {
|
|
62
|
+
id: Number(element.id),
|
|
63
|
+
name: asString(element.name),
|
|
64
|
+
is_default: asBoolean(element.isDefault),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function hrefId(value) {
|
|
68
|
+
const href = asString(value?.href);
|
|
69
|
+
const match = /(\d+)\/?$/.exec(href);
|
|
70
|
+
return match === null ? 0 : Number(match[1]);
|
|
71
|
+
}
|
|
72
|
+
function principalKind(element) {
|
|
73
|
+
switch (asString(element._type)) {
|
|
74
|
+
case "Group":
|
|
75
|
+
return "Group";
|
|
76
|
+
case "PlaceholderUser":
|
|
77
|
+
case "Placeholder":
|
|
78
|
+
return "Placeholder";
|
|
79
|
+
default:
|
|
80
|
+
return "User";
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function toStoredMember(element) {
|
|
84
|
+
const links = (element._links ?? {});
|
|
85
|
+
const embedded = (element._embedded ?? {});
|
|
86
|
+
const principal = (embedded.principal ?? {});
|
|
87
|
+
const principalLink = (links.principal ?? {});
|
|
88
|
+
const roles = Array.isArray(links.roles) ? links.roles : [];
|
|
89
|
+
return {
|
|
90
|
+
membership_id: Number(element.id),
|
|
91
|
+
user_id: typeof principal.id === "number"
|
|
92
|
+
? principal.id
|
|
93
|
+
: hrefId(principalLink),
|
|
94
|
+
name: asString(principal.name) || asString(principalLink.title),
|
|
95
|
+
type: principalKind(principal),
|
|
96
|
+
roles: roles.map((role) => ({
|
|
97
|
+
id: hrefId(role),
|
|
98
|
+
title: asString(role.title),
|
|
99
|
+
})),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
function toStoredVersion(element) {
|
|
103
|
+
return {
|
|
104
|
+
id: Number(element.id),
|
|
105
|
+
name: asString(element.name),
|
|
106
|
+
status: asString(element.status),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
function toStoredCategory(element) {
|
|
110
|
+
return {
|
|
111
|
+
id: Number(element.id),
|
|
112
|
+
name: asString(element.name),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
// Above this size an allowed-values list is noise in the store, not a
|
|
116
|
+
// usable dropdown; the field keeps working, just without the list.
|
|
117
|
+
const MAX_ALLOWED_VALUES = 50;
|
|
118
|
+
/**
|
|
119
|
+
* The selectable values of one schema property, in either shape an
|
|
120
|
+
* instance uses: full resources under `_embedded.allowedValues`, which is
|
|
121
|
+
* what the create form returns, or bare links under `_links.allowedValues`,
|
|
122
|
+
* which is all the schema endpoint ever carries. Anything without a
|
|
123
|
+
* readable id is dropped rather than resolved to a fabricated one.
|
|
124
|
+
*/
|
|
125
|
+
function allowedOptionsOf(property) {
|
|
126
|
+
const embedded = (property._embedded ?? {});
|
|
127
|
+
const resources = Array.isArray(embedded.allowedValues)
|
|
128
|
+
? embedded.allowedValues
|
|
129
|
+
: [];
|
|
130
|
+
if (resources.length > 0) {
|
|
131
|
+
return resources.flatMap((value) => {
|
|
132
|
+
const option = value;
|
|
133
|
+
const id = Number(option.id);
|
|
134
|
+
if (!Number.isFinite(id)) {
|
|
135
|
+
return [];
|
|
136
|
+
}
|
|
137
|
+
return [{
|
|
138
|
+
id,
|
|
139
|
+
// A CustomOption spells its text "value"; every other resource
|
|
140
|
+
// spells it "name".
|
|
141
|
+
name: asString(option.value ?? option.name),
|
|
142
|
+
}];
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
const links = (property._links ?? {});
|
|
146
|
+
const values = Array.isArray(links.allowedValues)
|
|
147
|
+
? links.allowedValues
|
|
148
|
+
: [];
|
|
149
|
+
return values.flatMap((value) => {
|
|
150
|
+
const link = value;
|
|
151
|
+
const id = halRefId(typeof link.href === "string" ? link.href : null);
|
|
152
|
+
return id === null ? [] : [{ id, name: asString(link.title) }];
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* The allowed values of one custom field as humans read them: the names of
|
|
157
|
+
* a list field's options, or the values the schema listed for any other
|
|
158
|
+
* kind. A vocabulary longer than MAX_ALLOWED_VALUES teaches nothing in a
|
|
159
|
+
* table cell, so it renders as nothing.
|
|
160
|
+
*/
|
|
161
|
+
export function customFieldAllowedNames(field) {
|
|
162
|
+
const names = field.allowed_options === undefined
|
|
163
|
+
? field.allowed_values ?? []
|
|
164
|
+
: field.allowed_options.map((option) => option.name);
|
|
165
|
+
return names.length > MAX_ALLOWED_VALUES ? [] : names;
|
|
166
|
+
}
|
|
167
|
+
function customFieldEntries(schema) {
|
|
168
|
+
const fields = [];
|
|
169
|
+
for (const [key, property] of Object.entries(schema)) {
|
|
170
|
+
if (!/^customField\d+$/.test(key)) {
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
// The schema `type` decides input validation downstream; anything
|
|
174
|
+
// beyond Boolean/User/CustomOption stays unmarked and passes through
|
|
175
|
+
// as text. A multi-valued field spells its kind "[]Kind".
|
|
176
|
+
const kind = asString(property.type).toLowerCase();
|
|
177
|
+
const single = kind.replace(/^\[\]/, "");
|
|
178
|
+
const isUser = kind === "user";
|
|
179
|
+
// A user field's allowed values are the project's members, which
|
|
180
|
+
// meta members already lists; keeping them here would only bloat the
|
|
181
|
+
// store.
|
|
182
|
+
const options = isUser ? [] : allowedOptionsOf(property);
|
|
183
|
+
const isList = single === "customoption";
|
|
184
|
+
fields.push({
|
|
185
|
+
key,
|
|
186
|
+
id: Number(key.slice("customField".length)),
|
|
187
|
+
name: asString(property.name),
|
|
188
|
+
...(property.required === true
|
|
189
|
+
? { is_required: true }
|
|
190
|
+
: {}),
|
|
191
|
+
...(!isList && options.length > 0 && options.length <= MAX_ALLOWED_VALUES
|
|
192
|
+
? { allowed_values: options.map((option) => option.name) }
|
|
193
|
+
: {}),
|
|
194
|
+
...(kind === "boolean" ? { is_boolean: true } : {}),
|
|
195
|
+
...(isUser ? { is_user: true } : {}),
|
|
196
|
+
...(isList ? { is_list: true, allowed_options: options } : {}),
|
|
197
|
+
...(kind.startsWith("[]") ? { is_multi: true } : {}),
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
return fields;
|
|
201
|
+
}
|
|
202
|
+
async function fetchMembers(profile, projectId) {
|
|
203
|
+
const filters = encodeURIComponent(JSON.stringify([{ project: { operator: "=", values: [String(projectId)] } }]));
|
|
204
|
+
const members = await fetchVocabulary(profile, `/api/v3/memberships?filters=${filters}`, toStoredMember);
|
|
205
|
+
return sortByName(members);
|
|
206
|
+
}
|
|
207
|
+
async function fetchVersions(profile, projectId) {
|
|
208
|
+
const versions = await fetchVocabulary(profile, `/api/v3/projects/${String(projectId)}/versions`, toStoredVersion);
|
|
209
|
+
return sortByName(versions);
|
|
210
|
+
}
|
|
211
|
+
async function fetchCategories(profile, projectId) {
|
|
212
|
+
const categories = await fetchVocabulary(profile, `/api/v3/projects/${String(projectId)}/categories`, toStoredCategory);
|
|
213
|
+
return sortByName(categories);
|
|
214
|
+
}
|
|
215
|
+
// The bare schema endpoint builds its values from TimeEntry.new, so its
|
|
216
|
+
// activity list is never populated. Only the create form (form_embedded)
|
|
217
|
+
// fills the activities that are active in the referenced project; its
|
|
218
|
+
// payload carries linked resources under _links, and the embedded schema
|
|
219
|
+
// lists full activity representations under activity/_embedded/allowedValues.
|
|
220
|
+
async function fetchActivities(profile, projectId) {
|
|
221
|
+
const form = (await apiPost(profile.instanceUrl, profile.apiKey, "/api/v3/time_entries/form", { _links: { project: { href: `/api/v3/projects/${String(projectId)}` } } }));
|
|
222
|
+
const formEmbedded = (form._embedded ?? {});
|
|
223
|
+
const schema = (formEmbedded.schema ?? {});
|
|
224
|
+
const activity = (schema.activity ?? {});
|
|
225
|
+
const activityEmbedded = (activity._embedded ?? {});
|
|
226
|
+
const values = Array.isArray(activityEmbedded.allowedValues)
|
|
227
|
+
? activityEmbedded.allowedValues
|
|
228
|
+
: [];
|
|
229
|
+
const activities = values.map((option) => ({
|
|
230
|
+
id: Number(option.id),
|
|
231
|
+
name: asString(option.name),
|
|
232
|
+
is_default: option.default === true,
|
|
233
|
+
}));
|
|
234
|
+
return sortByName(activities);
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* The schema of one project-and-type pair, from the richest source that
|
|
238
|
+
* answers. The bare schema endpoint names every custom field but leaves a
|
|
239
|
+
* list field's allowed values empty; only the create form fills them, so
|
|
240
|
+
* the form is asked first. A caller who may not open a create form still
|
|
241
|
+
* gets every field, just without the options.
|
|
242
|
+
*/
|
|
243
|
+
async function fetchTypeSchema(profile, projectId, typeId) {
|
|
244
|
+
try {
|
|
245
|
+
const form = (await apiPost(profile.instanceUrl, profile.apiKey, `/api/v3/projects/${String(projectId)}/work_packages/form`, { _links: { type: { href: `/api/v3/types/${String(typeId)}` } } }));
|
|
246
|
+
const embedded = (form._embedded ?? {});
|
|
247
|
+
const schema = embedded.schema;
|
|
248
|
+
if (typeof schema === "object" && schema !== null && !Array.isArray(schema)) {
|
|
249
|
+
return schema;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
// The form is an enrichment, never the only source.
|
|
254
|
+
}
|
|
255
|
+
return (await apiGet(profile.instanceUrl, profile.apiKey, `/api/v3/work_packages/schemas/${String(projectId)}-${String(typeId)}`));
|
|
256
|
+
}
|
|
257
|
+
async function fetchCustomFields(profile, projectId) {
|
|
258
|
+
const typeIds = await fetchVocabulary(profile, `/api/v3/projects/${String(projectId)}/types`, (element) => Number(element.id));
|
|
259
|
+
const byType = {};
|
|
260
|
+
for (const typeId of typeIds) {
|
|
261
|
+
const schema = await fetchTypeSchema(profile, projectId, typeId);
|
|
262
|
+
byType[String(typeId)] = sortByName(customFieldEntries(schema));
|
|
263
|
+
}
|
|
264
|
+
return byType;
|
|
265
|
+
}
|
|
266
|
+
async function fetchVocabulary(profile, path, convert) {
|
|
267
|
+
const elements = [];
|
|
268
|
+
for await (const element of halElements((page) => apiGet(profile.instanceUrl, profile.apiKey, page), path)) {
|
|
269
|
+
elements.push(convert(element));
|
|
270
|
+
}
|
|
271
|
+
return elements;
|
|
272
|
+
}
|
|
273
|
+
async function fetchInstanceInfo(profile) {
|
|
274
|
+
let apiVersion = null;
|
|
275
|
+
let coreVersion = null;
|
|
276
|
+
try {
|
|
277
|
+
const root = (await apiGet(profile.instanceUrl, profile.apiKey, "/api/v3/"));
|
|
278
|
+
apiVersion = typeof root.apiVersion === "string" ? root.apiVersion : null;
|
|
279
|
+
coreVersion = typeof root.coreVersion === "string" ? root.coreVersion : null;
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
// The root probe only enriches the store; stock instances may omit it.
|
|
283
|
+
}
|
|
284
|
+
return {
|
|
285
|
+
url: profile.instanceUrl,
|
|
286
|
+
api_version: apiVersion,
|
|
287
|
+
core_version: coreVersion,
|
|
288
|
+
fetched_at: new Date().toISOString(),
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
async function fetchProjectRef(profile, projectId) {
|
|
292
|
+
const project = (await apiGet(profile.instanceUrl, profile.apiKey, `/api/v3/projects/${String(projectId)}`));
|
|
293
|
+
return {
|
|
294
|
+
id: Number(project.id),
|
|
295
|
+
identifier: asString(project.identifier),
|
|
296
|
+
name: asString(project.name),
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
export async function fetchStoredMetadata(profile) {
|
|
300
|
+
const types = sortByName(await fetchVocabulary(profile, "/api/v3/types", toStoredType));
|
|
301
|
+
const statuses = sortByName(await fetchVocabulary(profile, "/api/v3/statuses", toStoredStatus));
|
|
302
|
+
const priorities = sortByName(await fetchVocabulary(profile, "/api/v3/priorities", toStoredPriority));
|
|
303
|
+
const instance = await fetchInstanceInfo(profile);
|
|
304
|
+
const project = profile.project === undefined
|
|
305
|
+
? undefined
|
|
306
|
+
: await fetchProjectRef(profile, profile.project);
|
|
307
|
+
return {
|
|
308
|
+
types,
|
|
309
|
+
statuses,
|
|
310
|
+
priorities,
|
|
311
|
+
instance,
|
|
312
|
+
...(project === undefined ? {} : { project }),
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
export async function loadStoredMetadata(env, profile) {
|
|
316
|
+
const stored = await readStoredMetadata(env, profile);
|
|
317
|
+
if (stored !== undefined) {
|
|
318
|
+
return stored;
|
|
319
|
+
}
|
|
320
|
+
const fetched = await fetchStoredMetadata(profile);
|
|
321
|
+
await persistMetadata(env, profile, fetched);
|
|
322
|
+
return fetched;
|
|
323
|
+
}
|
|
324
|
+
export async function refreshStoredMetadata(env, profile) {
|
|
325
|
+
const previous = await readStoredMetadata(env, profile);
|
|
326
|
+
const next = await fetchStoredMetadata(profile);
|
|
327
|
+
const previousScoped = previous?.projectScoped ?? {};
|
|
328
|
+
const projectScoped = {};
|
|
329
|
+
const dropped = [];
|
|
330
|
+
for (const projectId of Object.keys(previousScoped)) {
|
|
331
|
+
try {
|
|
332
|
+
projectScoped[projectId] = await fetchProjectVocabulary(profile, Number(projectId));
|
|
333
|
+
}
|
|
334
|
+
catch (error) {
|
|
335
|
+
// A project deleted on the instance must not wedge the refresh for
|
|
336
|
+
// good: drop its stored vocabulary, report it, and keep refreshing
|
|
337
|
+
// everything else. Any other failure is still the caller's to see.
|
|
338
|
+
if (!(error instanceof OpCliError && error.code === "NOT_FOUND")) {
|
|
339
|
+
throw error;
|
|
340
|
+
}
|
|
341
|
+
dropped.push({
|
|
342
|
+
section: "project",
|
|
343
|
+
kind: "removed",
|
|
344
|
+
id: Number(projectId),
|
|
345
|
+
detail: `removed cached vocabulary of project ${projectId}`,
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
await persistMetadata(env, profile, {
|
|
350
|
+
...next,
|
|
351
|
+
...(Object.keys(projectScoped).length > 0 ? { projectScoped } : {}),
|
|
352
|
+
});
|
|
353
|
+
const changes = [
|
|
354
|
+
...diffEntries("types", previous?.types, next.types),
|
|
355
|
+
...diffEntries("statuses", previous?.statuses, next.statuses),
|
|
356
|
+
...diffEntries("priorities", previous?.priorities, next.priorities),
|
|
357
|
+
...diffProject(previous?.project, next.project),
|
|
358
|
+
...dropped,
|
|
359
|
+
...diffInstance(previous?.instance, next.instance),
|
|
360
|
+
];
|
|
361
|
+
for (const projectId of Object.keys(projectScoped)) {
|
|
362
|
+
changes.push(...diffVocabulary(previousScoped[projectId], projectScoped[projectId]));
|
|
363
|
+
}
|
|
364
|
+
return changes;
|
|
365
|
+
}
|
|
366
|
+
export async function fetchProjectVocabulary(profile, projectId) {
|
|
367
|
+
return {
|
|
368
|
+
project_id: projectId,
|
|
369
|
+
fetched_at: new Date().toISOString(),
|
|
370
|
+
members: await fetchMembers(profile, projectId),
|
|
371
|
+
versions: await fetchVersions(profile, projectId),
|
|
372
|
+
categories: await fetchCategories(profile, projectId),
|
|
373
|
+
activities: await fetchActivities(profile, projectId),
|
|
374
|
+
custom_fields: await fetchCustomFields(profile, projectId),
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
export async function loadProjectVocabulary(env, profile) {
|
|
378
|
+
if (profile.project === undefined) {
|
|
379
|
+
throw new OpCliError("USAGE_ERROR");
|
|
380
|
+
}
|
|
381
|
+
return loadProjectVocabularyById(env, profile, profile.project);
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* The same cache-and-fetch flow for an explicit project id, used when the
|
|
385
|
+
* project is not the profile default but a resource's own (a time entry
|
|
386
|
+
* resolves its activity against the project of its work package).
|
|
387
|
+
*/
|
|
388
|
+
export async function loadProjectVocabularyById(env, profile, projectId) {
|
|
389
|
+
const stored = await readStoredMetadata(env, profile);
|
|
390
|
+
const cached = stored?.projectScoped?.[String(projectId)];
|
|
391
|
+
if (cached !== undefined) {
|
|
392
|
+
return cached;
|
|
393
|
+
}
|
|
394
|
+
const base = stored ?? (await fetchStoredMetadata(profile));
|
|
395
|
+
const vocabulary = await fetchProjectVocabulary(profile, projectId);
|
|
396
|
+
await persistMetadata(env, profile, {
|
|
397
|
+
...base,
|
|
398
|
+
projectScoped: {
|
|
399
|
+
...base.projectScoped,
|
|
400
|
+
[String(projectId)]: vocabulary,
|
|
401
|
+
},
|
|
402
|
+
});
|
|
403
|
+
return vocabulary;
|
|
404
|
+
}
|
|
405
|
+
export async function clearStoredMetadata(env, profile) {
|
|
406
|
+
await rm(metadataPath(env, profile), { force: true });
|
|
407
|
+
}
|
|
408
|
+
function formatValue(value) {
|
|
409
|
+
if (value === null) {
|
|
410
|
+
return "null";
|
|
411
|
+
}
|
|
412
|
+
return String(value);
|
|
413
|
+
}
|
|
414
|
+
function fieldDiffs(before, after) {
|
|
415
|
+
const beforeRecord = before;
|
|
416
|
+
const afterRecord = after;
|
|
417
|
+
const keys = new Set([
|
|
418
|
+
...Object.keys(beforeRecord),
|
|
419
|
+
...Object.keys(afterRecord),
|
|
420
|
+
]);
|
|
421
|
+
// Arrays (roles, allowed values) arrive as fresh objects on every fetch;
|
|
422
|
+
// compare them by value or every refresh would flag them as changed.
|
|
423
|
+
const sameValue = (before, after) => Array.isArray(before) || Array.isArray(after)
|
|
424
|
+
? JSON.stringify(before) === JSON.stringify(after)
|
|
425
|
+
: Object.is(before, after);
|
|
426
|
+
const diffs = [];
|
|
427
|
+
for (const key of keys) {
|
|
428
|
+
if (!sameValue(beforeRecord[key], afterRecord[key])) {
|
|
429
|
+
diffs.push(`${key}: ${formatValue(beforeRecord[key])} -> ${formatValue(afterRecord[key])}`);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
return diffs.sort();
|
|
433
|
+
}
|
|
434
|
+
function diffEntries(section, before, after) {
|
|
435
|
+
const changes = [];
|
|
436
|
+
const beforeById = new Map((before ?? []).map((entry) => [entry.id, entry]));
|
|
437
|
+
const afterIds = new Set(after.map((entry) => entry.id));
|
|
438
|
+
for (const entry of after) {
|
|
439
|
+
const previous = beforeById.get(entry.id);
|
|
440
|
+
if (previous === undefined) {
|
|
441
|
+
changes.push({
|
|
442
|
+
section,
|
|
443
|
+
kind: "added",
|
|
444
|
+
id: entry.id,
|
|
445
|
+
detail: `added ${entry.name} (${String(entry.id)})`,
|
|
446
|
+
});
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
const diffs = fieldDiffs(previous, entry);
|
|
450
|
+
if (diffs.length > 0) {
|
|
451
|
+
changes.push({
|
|
452
|
+
section,
|
|
453
|
+
kind: "changed",
|
|
454
|
+
id: entry.id,
|
|
455
|
+
detail: `${diffs.join("; ")} (${String(entry.id)})`,
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
for (const entry of before ?? []) {
|
|
460
|
+
if (!afterIds.has(entry.id)) {
|
|
461
|
+
changes.push({
|
|
462
|
+
section,
|
|
463
|
+
kind: "removed",
|
|
464
|
+
id: entry.id,
|
|
465
|
+
detail: `removed ${entry.name} (${String(entry.id)})`,
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
return changes;
|
|
470
|
+
}
|
|
471
|
+
function diffProject(before, after) {
|
|
472
|
+
if (before === undefined && after !== undefined) {
|
|
473
|
+
return [{
|
|
474
|
+
section: "project",
|
|
475
|
+
kind: "added",
|
|
476
|
+
id: after.id,
|
|
477
|
+
detail: `added ${after.name} (${String(after.id)})`,
|
|
478
|
+
}];
|
|
479
|
+
}
|
|
480
|
+
if (before !== undefined && after === undefined) {
|
|
481
|
+
return [{
|
|
482
|
+
section: "project",
|
|
483
|
+
kind: "removed",
|
|
484
|
+
id: before.id,
|
|
485
|
+
detail: `removed ${before.name} (${String(before.id)})`,
|
|
486
|
+
}];
|
|
487
|
+
}
|
|
488
|
+
if (before === undefined || after === undefined) {
|
|
489
|
+
return [];
|
|
490
|
+
}
|
|
491
|
+
const diffs = fieldDiffs(before, after);
|
|
492
|
+
return diffs.length === 0
|
|
493
|
+
? []
|
|
494
|
+
: [{ section: "project", kind: "changed", id: after.id, detail: diffs.join("; ") }];
|
|
495
|
+
}
|
|
496
|
+
function diffInstance(before, after) {
|
|
497
|
+
if (before === undefined) {
|
|
498
|
+
return [];
|
|
499
|
+
}
|
|
500
|
+
const comparable = (info) => ({
|
|
501
|
+
api_version: info.api_version,
|
|
502
|
+
core_version: info.core_version,
|
|
503
|
+
});
|
|
504
|
+
const diffs = fieldDiffs(comparable(before), comparable(after));
|
|
505
|
+
return diffs.length === 0
|
|
506
|
+
? []
|
|
507
|
+
: [{ section: "instance", kind: "changed", id: null, detail: diffs.join("; ") }];
|
|
508
|
+
}
|
|
509
|
+
function diffVocabulary(before, after) {
|
|
510
|
+
if (after === undefined) {
|
|
511
|
+
return [];
|
|
512
|
+
}
|
|
513
|
+
// diffEntries keys on id; members identify by their membership id.
|
|
514
|
+
const memberEntry = (member) => ({
|
|
515
|
+
id: member.membership_id,
|
|
516
|
+
name: member.name,
|
|
517
|
+
roles: member.roles,
|
|
518
|
+
});
|
|
519
|
+
const fields = (vocabulary) => Object.values(vocabulary?.custom_fields ?? {}).flat();
|
|
520
|
+
return [
|
|
521
|
+
...diffEntries("members", before?.members.map(memberEntry), after.members.map(memberEntry)),
|
|
522
|
+
...diffEntries("versions", before?.versions, after.versions),
|
|
523
|
+
...diffEntries("categories", before?.categories, after.categories),
|
|
524
|
+
...diffEntries("activities", before?.activities, after.activities),
|
|
525
|
+
...diffEntries("fields", fields(before), fields(after)),
|
|
526
|
+
];
|
|
527
|
+
}
|
|
528
|
+
//# sourceMappingURL=metadata.js.map
|