@cyberismo/data-handler 0.0.19 → 0.0.21

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 (50) hide show
  1. package/dist/command-handler.js +5 -1
  2. package/dist/command-handler.js.map +1 -1
  3. package/dist/commands/create.js +16 -5
  4. package/dist/commands/create.js.map +1 -1
  5. package/dist/macros/include/index.js +19 -2
  6. package/dist/macros/include/index.js.map +1 -1
  7. package/dist/macros/include/types.d.ts +21 -12
  8. package/dist/macros/index.js +1 -0
  9. package/dist/macros/index.js.map +1 -1
  10. package/dist/resources/create-defaults.js +1 -1
  11. package/dist/resources/create-defaults.js.map +1 -1
  12. package/dist/resources/field-type-resource.js +10 -3
  13. package/dist/resources/field-type-resource.js.map +1 -1
  14. package/dist/resources/folder-resource.js +20 -8
  15. package/dist/resources/folder-resource.js.map +1 -1
  16. package/dist/resources/workflow-resource.d.ts +6 -0
  17. package/dist/resources/workflow-resource.js +28 -12
  18. package/dist/resources/workflow-resource.js.map +1 -1
  19. package/dist/svg/percentage.js +7 -3
  20. package/dist/svg/percentage.js.map +1 -1
  21. package/dist/svg/scoreCard.js +10 -4
  22. package/dist/svg/scoreCard.js.map +1 -1
  23. package/dist/types/queries.d.ts +1 -1
  24. package/dist/utils/csv.d.ts +8 -0
  25. package/dist/utils/csv.js +11 -0
  26. package/dist/utils/csv.js.map +1 -1
  27. package/dist/utils/json.d.ts +17 -10
  28. package/dist/utils/json.js +21 -12
  29. package/dist/utils/json.js.map +1 -1
  30. package/dist/utils/report.js +4 -0
  31. package/dist/utils/report.js.map +1 -1
  32. package/dist/utils/resource-utils.js +1 -0
  33. package/dist/utils/resource-utils.js.map +1 -1
  34. package/package.json +10 -10
  35. package/src/command-handler.ts +4 -1
  36. package/src/commands/create.ts +15 -5
  37. package/src/macros/include/index.ts +22 -2
  38. package/src/macros/include/types.ts +21 -12
  39. package/src/macros/index.ts +1 -0
  40. package/src/resources/create-defaults.ts +1 -1
  41. package/src/resources/field-type-resource.ts +13 -6
  42. package/src/resources/folder-resource.ts +21 -7
  43. package/src/resources/workflow-resource.ts +44 -15
  44. package/src/svg/percentage.ts +7 -3
  45. package/src/svg/scoreCard.ts +10 -4
  46. package/src/types/queries.ts +1 -1
  47. package/src/utils/csv.ts +12 -0
  48. package/src/utils/json.ts +23 -13
  49. package/src/utils/report.ts +4 -0
  50. package/src/utils/resource-utils.ts +1 -0
@@ -12,21 +12,30 @@
12
12
  License along with this program. If not, see <https://www.gnu.org/licenses/>.
13
13
  */
14
14
 
15
+ /**
16
+ * Include macro options.
17
+ * @param cardKey Card key of the card being included
18
+ * @param levelOffset A positive number will increase the level of headings.
19
+ * A negative value will decrease the level of headings in the included content.
20
+ * @param title Determines behaviour with the title that is in card metadata
21
+ * include --> includes the title
22
+ * exclude --> excludes the title
23
+ * only --> includes title but does not import content
24
+ * @param whitespace Whether to trim leading and trailing whitespace
25
+ * @param escape Type of escaping to apply to the included content
26
+ * json --> escapes for JSON strings
27
+ * csv --> escapes for CSV fields
28
+ */
15
29
  export interface IncludeMacroOptions {
16
- /**
17
- * Card key of the card being included
18
- */
19
30
  cardKey: string;
20
- /**
21
- * A positive number wil increase the level of headings and a negative alue will the level of headings in the included content
22
- */
23
31
  levelOffset?: string;
24
- /**
25
- * Determines behaviour with the title that is in card metadata
26
- * include --> includes the title
27
- * exclude --> excludes the title
28
- * only --> includes title but does not import content
29
- */
30
32
  title?: 'include' | 'exclude' | 'only';
31
33
  pageTitles?: 'normal' | 'discrete';
34
+ /**
35
+ * Whether to trim initial and final whitespace and newlines from the output.
36
+ * Useful for generating non-AsciiDoc content such as JSON.
37
+ * Default is 'keep'.
38
+ */
39
+ whitespace?: 'keep' | 'trim';
40
+ escape?: 'json' | 'csv';
32
41
  }
@@ -249,6 +249,7 @@ export async function evaluateMacros(
249
249
  try {
250
250
  const compiled = handlebars.compile(preprocessRawBlocks(result), {
251
251
  strict: true,
252
+ ignoreStandalone: true,
252
253
  });
253
254
  result = compiled({ cardKey: context.cardKey });
254
255
 
@@ -115,7 +115,7 @@ export abstract class DefaultContent {
115
115
  displayName: '',
116
116
  dataType: dataType,
117
117
  } as FieldType;
118
- if (dataType === 'enum') {
118
+ if (dataType === 'enum' || dataType === 'list') {
119
119
  value.enumValues = [{ enumValue: 'value1' }, { enumValue: 'value2' }];
120
120
  }
121
121
  return value;
@@ -188,13 +188,16 @@ export class FieldTypeResource extends FileResource<FieldType> {
188
188
  );
189
189
  }
190
190
  const newValue = (op as ChangeOperation<EnumDefinition>).to;
191
- const foundTo = values.find(
192
- (item) => (item as EnumDefinition).enumValue === newValue.enumValue,
193
- );
194
- if (foundTo) {
195
- throw new Error(
196
- `Cannot perform operation on 'enumValues'. Enum with value '${(op.to as EnumDefinition).enumValue}' already exists`,
191
+ // Only check for duplicates if the enumValue itself is being changed
192
+ if (newValue.enumValue !== targetValue.enumValue) {
193
+ const foundTo = values.find(
194
+ (item) => (item as EnumDefinition).enumValue === newValue.enumValue,
197
195
  );
196
+ if (foundTo) {
197
+ throw new Error(
198
+ `Cannot perform operation on 'enumValues'. Enum with value '${(op.to as EnumDefinition).enumValue}' already exists`,
199
+ );
200
+ }
198
201
  }
199
202
  }
200
203
  // Return the whole object; caller can just provide 'enumValue'.
@@ -431,6 +434,10 @@ export class FieldTypeResource extends FileResource<FieldType> {
431
434
  }
432
435
  content.dataType = super.handleScalar(op) as DataType;
433
436
  } else if (key === 'enumValues') {
437
+ // Initialize enumValues array if it doesn't exist
438
+ if (!content.enumValues) {
439
+ content.enumValues = [];
440
+ }
434
441
  if (op.name === 'add' || op.name === 'change' || op.name === 'remove') {
435
442
  const existingValue = this.enumValueExists<EnumDefinition>(
436
443
  op as Operation<EnumDefinition>,
@@ -19,6 +19,7 @@ import { isContentKey } from '../interfaces/resource-interfaces.js';
19
19
  import {
20
20
  filename,
21
21
  contentPropertyName,
22
+ ALL_FILE_MAPPINGS,
22
23
  } from '../interfaces/folder-content-interfaces.js';
23
24
  import { formatJson } from '../utils/json.js';
24
25
  import { VALID_FOLDER_RESOURCE_FILES } from '../utils/constants.js';
@@ -95,7 +96,7 @@ export abstract class FolderResource<
95
96
  for (const [fileName, fileContent] of contentFiles.entries()) {
96
97
  const key = contentPropertyName(fileName);
97
98
  if (key) {
98
- const isJson = key === 'schema';
99
+ const isJson = key === ALL_FILE_MAPPINGS['parameterSchema.json'];
99
100
  content[key] = isJson ? JSON.parse(fileContent) : fileContent;
100
101
  }
101
102
  }
@@ -143,15 +144,28 @@ export abstract class FolderResource<
143
144
  throw new Error(`File '${fileName}' is not allowed to be updated`);
144
145
  }
145
146
 
146
- await writeFileSafe(filePath, changedContent, { flag: 'w' });
147
+ // TODO: Updates should either use valid strings or allow for objects
148
+ const key = contentPropertyName(fileName);
149
+ const isJson = key === ALL_FILE_MAPPINGS['parameterSchema.json'];
150
+ let parsedContent: unknown = changedContent;
151
+ if (isJson) {
152
+ try {
153
+ parsedContent = JSON.parse(changedContent);
154
+ } catch (error) {
155
+ const message =
156
+ error instanceof Error ? error.message : 'Unknown error';
157
+ throw new Error(`Invalid JSON content for '${key}' update: ${message}`);
158
+ }
159
+ }
160
+ const contentToWrite = isJson
161
+ ? formatJson(parsedContent as object)
162
+ : changedContent;
163
+
164
+ await writeFileSafe(filePath, contentToWrite, { flag: 'w' });
147
165
 
148
166
  // Update this resource's content
149
- const key = contentPropertyName(fileName);
150
167
  if (key) {
151
- const isJson = key === 'schema';
152
- (this.resourceContent as Record<string, unknown>)[key] = isJson
153
- ? JSON.parse(changedContent)
154
- : changedContent;
168
+ (this.resourceContent as Record<string, unknown>)[key] = parsedContent;
155
169
  }
156
170
  }
157
171
 
@@ -78,16 +78,11 @@ export class WorkflowResource extends FileResource<Workflow> {
78
78
  }
79
79
 
80
80
  // Handle change of workflow state.
81
- private async handleStateChange(op: ChangeOperation<WorkflowState>) {
82
- const content = structuredClone(this.content);
81
+ private async handleStateChange(
82
+ content: Workflow,
83
+ op: ChangeOperation<WorkflowState>,
84
+ ) {
83
85
  const stateName = this.targetName(op) as string;
84
- // Check that state can be changed to
85
- content.transitions = content.transitions.filter(
86
- (t) => t.toState !== stateName,
87
- );
88
- content.transitions.forEach((t) => {
89
- t.fromState = t.fromState.filter((state) => state !== stateName);
90
- });
91
86
  // validate that new state contains 'name' and 'category'
92
87
  if (op.to.name === undefined || op.to.category === undefined) {
93
88
  throw new Error(
@@ -95,16 +90,26 @@ export class WorkflowResource extends FileResource<Workflow> {
95
90
  Updated state must have 'name' and 'category' properties.`,
96
91
  );
97
92
  }
98
- // Update all cards that use this state.
93
+ // Rename transitions to use the new state name
99
94
  const toStateName = op.to.name;
100
-
95
+ content.transitions.forEach((t) => {
96
+ if (t.toState === stateName) {
97
+ t.toState = toStateName;
98
+ }
99
+ t.fromState = t.fromState.map((state) =>
100
+ state === stateName ? toStateName : state,
101
+ );
102
+ });
103
+ // Update all cards that use this state.
101
104
  await this.updateCardStates(stateName, toStateName);
102
105
  }
103
106
 
104
107
  // Handle removal of workflow state.
105
108
  // State can be removed with or without replacement.
106
- private async handleStateRemoval(op: RemoveOperation<WorkflowState>) {
107
- const content = structuredClone(this.content);
109
+ private async handleStateRemoval(
110
+ content: Workflow,
111
+ op: RemoveOperation<WorkflowState>,
112
+ ) {
108
113
  const stateName = this.targetName(op) as string;
109
114
 
110
115
  // If there is no replacement value, remove all transitions "to" and "from" this state.
@@ -232,6 +237,28 @@ export class WorkflowResource extends FileResource<Workflow> {
232
237
  return super.create(newContent);
233
238
  }
234
239
 
240
+ /**
241
+ * Validates the content of the workflow resource.
242
+ * @param content Content to be validated.
243
+ * @throws if content is invalid.
244
+ */
245
+ public async validate(content?: Workflow) {
246
+ // Base class run basic schema checks
247
+ await super.validate(content);
248
+
249
+ const workflowContent = content ?? this.content;
250
+
251
+ const newCardTransitions = workflowContent.transitions.filter(
252
+ (t) => t.fromState.includes('') || t.fromState.length === 0,
253
+ );
254
+
255
+ if (newCardTransitions.length !== 1) {
256
+ throw new Error(
257
+ `Workflow '${workflowContent.name}' must have exactly one transition from "New Card" (empty fromState), found ${newCardTransitions.length}.`,
258
+ );
259
+ }
260
+ }
261
+
235
262
  /**
236
263
  * Renames the object and the file.
237
264
  * @param newName New name for the resource.
@@ -329,11 +356,13 @@ export class WorkflowResource extends FileResource<Workflow> {
329
356
  removeOp = {
330
357
  name: 'remove',
331
358
  target: toBeRemovedState as WorkflowState,
359
+ replacementValue: (op as RemoveOperation<unknown>)
360
+ .replacementValue as WorkflowState,
332
361
  };
333
362
  } else {
334
363
  removeOp = op as RemoveOperation<WorkflowState>;
335
364
  }
336
- await this.handleStateRemoval(removeOp);
365
+ await this.handleStateRemoval(content, removeOp);
337
366
  } else if (key === 'states' && op.name === 'change') {
338
367
  // If workflow state is renamed, replace all transitions "to" and "from" the old state with new state.
339
368
  let changeOp: ChangeOperation<WorkflowState>;
@@ -349,7 +378,7 @@ export class WorkflowResource extends FileResource<Workflow> {
349
378
  } else {
350
379
  changeOp = op as ChangeOperation<WorkflowState>;
351
380
  }
352
- await this.handleStateChange(changeOp);
381
+ await this.handleStateChange(content, changeOp);
353
382
  }
354
383
 
355
384
  await super.postUpdate(content, updateKey, op);
@@ -71,10 +71,14 @@ export function percentage(options: PercentageOptions): string {
71
71
  const dynamicSVGHeight = donutYOffset + SIZE / 2 + R + 20;
72
72
  return `
73
73
  <svg width="${SVG_WIDTH}" height="${dynamicSVGHeight}" viewBox="0 0 ${SVG_WIDTH} ${dynamicSVGHeight}" xmlns="http://www.w3.org/2000/svg">
74
+ <style>
75
+ .percentage-text { fill: var(--joy-palette-text-primary, #333); }
76
+ .percentage-legend { fill: var(--joy-palette-text-secondary, #666); }
77
+ </style>
74
78
  <title>${title}</title>
75
79
 
76
80
  <!-- Visible Title (wrapped) -->
77
- <text x="${SVG_WIDTH / 2}" y="${TITLE_Y}" text-anchor="middle" font-size="${TITLE_FONT_SIZE}" font-weight="bold">
81
+ <text class="percentage-text" x="${SVG_WIDTH / 2}" y="${TITLE_Y}" text-anchor="middle" font-size="${TITLE_FONT_SIZE}" font-weight="bold">
78
82
  ${titleLines.map((line, i) => `<tspan x='${SVG_WIDTH / 2}' dy='${i === 0 ? 0 : LINE_SPACING}em'>${line}</tspan>`).join('')}
79
83
  </text>
80
84
 
@@ -90,8 +94,8 @@ export function percentage(options: PercentageOptions): string {
90
94
  transform="rotate(-90 ${SVG_WIDTH / 2} ${donutCenterY})" />
91
95
 
92
96
  <!-- Numbers -->
93
- <text x="${SVG_WIDTH / 2}" y="${donutCenterY - 8}" text-anchor="middle" font-size="${VALUE_FONT_SIZE}" font-weight="bold">${value}%</text>
94
- <text x="${SVG_WIDTH / 2}" y="${donutCenterY + 20}" text-anchor="middle" font-size="${LEGEND_FONT_SIZE}">${legend}</text>
97
+ <text class="percentage-text" x="${SVG_WIDTH / 2}" y="${donutCenterY - 8}" text-anchor="middle" font-size="${VALUE_FONT_SIZE}" font-weight="bold">${value}%</text>
98
+ <text class="percentage-legend" x="${SVG_WIDTH / 2}" y="${donutCenterY + 20}" text-anchor="middle" font-size="${LEGEND_FONT_SIZE}">${legend}</text>
95
99
  </svg>
96
100
  `;
97
101
  }
@@ -76,11 +76,17 @@ export function scoreCard(options: ScoreCardOptions): string {
76
76
  const height = Math.ceil(textBlockHeight + PADDING * 2);
77
77
 
78
78
  const svgContent = `<svg class="card" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg">
79
- <rect rx="8" ry="8" fill="#fff" stroke="#dfe4ea" stroke-width="3" width="${width}" height="${height}"/>
79
+ <style>
80
+ .scorecard-bg { fill: var(--joy-palette-background-surface, #fff); stroke: var(--joy-palette-divider, #dfe4ea); }
81
+ .scorecard-title { fill: var(--joy-palette-text-primary, #001829); }
82
+ .scorecard-value { fill: var(--joy-palette-text-primary, #333); }
83
+ .scorecard-caption { fill: var(--joy-palette-text-tertiary, #999); }
84
+ </style>
85
+ <rect class="scorecard-bg" rx="8" ry="8" stroke-width="3" width="${width}" height="${height}"/>
80
86
  <g text-anchor="middle">
81
- <text class="title" x="${width / 2}" y="${TITLE_SIZE / 2 + PADDING}" font-size="${TITLE_SIZE}" font-weight="400" fill="#001829" dominant-baseline="middle">${title}</text>
82
- <text class="value" x="${width / 2}" y="${titleHeight + VALUE_SIZE / 2 + PADDING}" font-size="${VALUE_SIZE}" font-weight="700" fill="#333" dominant-baseline="middle">${value}<tspan class="unit" font-size="${UNIT_SIZE}" font-weight="400" dx="${UNIT_OFFSET}">${unit}</tspan></text>
83
- <text class="caption" x="${width / 2}" y="${titleHeight + valueHeight + LINE_GAP_CAPTION + CAPTION_SIZE / 2 + PADDING}" font-size="${CAPTION_SIZE}" font-weight="400" fill="#999" dominant-baseline="middle">${legend}</text>
87
+ <text class="scorecard-title" x="${width / 2}" y="${TITLE_SIZE / 2 + PADDING}" font-size="${TITLE_SIZE}" font-weight="400" dominant-baseline="middle">${title}</text>
88
+ <text class="scorecard-value" x="${width / 2}" y="${titleHeight + VALUE_SIZE / 2 + PADDING}" font-size="${VALUE_SIZE}" font-weight="700" dominant-baseline="middle">${value}<tspan class="unit" font-size="${UNIT_SIZE}" font-weight="400" dx="${UNIT_OFFSET}">${unit}</tspan></text>
89
+ <text class="scorecard-caption" x="${width / 2}" y="${titleHeight + valueHeight + LINE_GAP_CAPTION + CAPTION_SIZE / 2 + PADDING}" font-size="${CAPTION_SIZE}" font-weight="400" dominant-baseline="middle">${legend}</text>
84
90
  </g>
85
91
  </svg>`;
86
92
 
@@ -94,7 +94,7 @@ interface CardQueryResult extends BaseResult {
94
94
  cardType: string;
95
95
  cardTypeDisplayName: string;
96
96
  workflowState: string;
97
- lastUpdated: string;
97
+ lastUpdated?: string;
98
98
  fields: CardQueryField[];
99
99
  labels: string[];
100
100
  links: CalculationLink[];
package/src/utils/csv.ts CHANGED
@@ -14,6 +14,18 @@ import { readFile } from 'node:fs/promises';
14
14
  import { parse } from 'csv-parse/sync';
15
15
  import type { CSVRowRaw } from '../interfaces/project-interfaces.js';
16
16
 
17
+ /**
18
+ * Escapes a string for use as a CSV field.
19
+ * Escapes double quotes by doubling them. The caller is responsible for
20
+ * wrapping the result in quotes.
21
+ * @param str The string to escape
22
+ * @returns The escaped string (without surrounding quotes)
23
+ */
24
+ export function escapeCsvField(str: string): string {
25
+ // Escape double quotes by doubling them
26
+ return str.replace(/"/g, '""');
27
+ }
28
+
17
29
  /**
18
30
  * Reads a CSV file and returns its content as an array of objects.
19
31
  * @param file Path to the CSV file.
package/src/utils/json.ts CHANGED
@@ -15,6 +15,29 @@
15
15
  import { readFileSync } from 'node:fs';
16
16
  import { type FileHandle, readFile, writeFile } from 'node:fs/promises';
17
17
 
18
+ /**
19
+ * Escapes a string for use as a JSON string value.
20
+ * Escapes double quotes, backslashes, and control characters.
21
+ * @param content The string to escape
22
+ * @returns The escaped string suitable for use in JSON
23
+ */
24
+ export function escapeJsonString(content: string): string {
25
+ return JSON.stringify(content).slice(1, -1);
26
+ }
27
+
28
+ /**
29
+ * Format an object with JSON.stringify
30
+ *
31
+ * The purpose of this function is to format the JSON output in a centralised function
32
+ * so that the format can be controlled in a single location.
33
+ *
34
+ * @param json JSON object to format.
35
+ * @returns Formatted JSON string
36
+ */
37
+ export function formatJson(json: object) {
38
+ return JSON.stringify(json, trimReplacer, 4);
39
+ }
40
+
18
41
  /**
19
42
  * Handles reading of a JSON file.
20
43
  * @param file file name (and path) to read.
@@ -79,19 +102,6 @@ export function trimReplacer(_: string, value: unknown) {
79
102
  return value;
80
103
  }
81
104
 
82
- /**
83
- * Format an object with JSON.stringify
84
- *
85
- * The purpose of this function is to format the JSON output in a centralised function
86
- * so that the format can be controlled in a single location.
87
- *
88
- * @param json JSON object to format.
89
- * @returns Formatted JSON string
90
- */
91
- export function formatJson(json: object) {
92
- return JSON.stringify(json, trimReplacer, 4);
93
- }
94
-
95
105
  /**
96
106
  * Writes and formats a JSON file.
97
107
  * @param filename file name (and path) to write.
@@ -14,6 +14,8 @@ import type { CalculationEngine } from '../containers/project/calculation-engine
14
14
  import { registerEmptyMacros } from '../macros/index.js';
15
15
  import type { Context } from '../interfaces/project-interfaces.js';
16
16
  import { resourceName } from './resource-utils.js';
17
+ import { escapeJsonString } from './json.js';
18
+ import { escapeCsvField } from './csv.js';
17
19
 
18
20
  /**
19
21
  * Formats a value from a logic program for use to graphviz
@@ -125,6 +127,8 @@ export async function generateReportContent(
125
127
  }
126
128
  handlebars.registerHelper('isCustomField', isCustomField);
127
129
  handlebars.registerHelper('formatValue', formatValue);
130
+ handlebars.registerHelper('jsonEscape', escapeJsonString);
131
+ handlebars.registerHelper('csvEscape', escapeCsvField);
128
132
 
129
133
  return handlebars.compile(contentTemplate)({
130
134
  ...options,
@@ -30,6 +30,7 @@ const IDENTIFIER_INDEX = 2;
30
30
  // Valid resource name has three parts
31
31
  const RESOURCE_NAME_PARTS = 3;
32
32
  const RESOURCE_FOLDER_TYPES = [
33
+ 'calculations',
33
34
  'graphModels',
34
35
  'graphViews',
35
36
  'reports',