Repository: CyberismoCom/cyberismo
Pull request #657: INTDEV-803 Replace clingraph
Author: login: ghost
Created: 2025-05-12T10:22:24Z | Merged: 2025-05-30T08:37:39Z

This will get rid of clingraph by replacing it with a cyberismo report. Report is built in to the source. Also includes some fixes to the error handling of c-api, gcc < 13 fall and a basic readme for the native module


Diff:
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 752d57ca4..f280704a3 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -273,6 +273,9 @@ importers:
       mime-types:
         specifier: ^3.0.1
         version: 3.0.1
+      node-graphviz:
+        specifier: ^0.1.1
+        version: 0.1.1
       pino:
         specifier: ^9.6.0
         version: 9.6.0
@@ -3168,6 +3171,9 @@ packages:
   node-addon-api@7.1.1:
     resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
 
+  node-graphviz@0.1.1:
+    resolution: {integrity: sha512-riY8/pFGSD1ipmyzqCwuN2M6W02ELfuLDjhJvTrQCUS/15tyU8ExkC96mlQrNLBK8Ws0z8PdH+ChBT6DuPFWWA==}
+
   node-gyp-build@4.8.4:
     resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==}
     hasBin: true
@@ -7497,6 +7503,8 @@ snapshots:
 
   node-addon-api@7.1.1: {}
 
+  node-graphviz@0.1.1: {}
+
   node-gyp-build@4.8.4: {}
 
   node-gyp@11.2.0:
diff --git a/resources/graphvizReport/index.adoc.hbs b/resources/graphvizReport/index.adoc.hbs
new file mode 100644
index 000000000..2261ba51f
--- /dev/null
+++ b/resources/graphvizReport/index.adoc.hbs
@@ -0,0 +1,34 @@
+{{#*inline "subgraph"}}
+subgraph cluster_{{this.key}} {
+  graph [{{#each attrs}}{{this.attr}}={{{formatAttributeValue this.value}}} {{/each}}]
+  {{#each edges}}
+    "{{this.[source]}}" -> "{{this.destination}}" [{{#each attrs}}{{this.attr}}={{{formatAttributeValue this.value}}} {{/each}}]
+  {{/each}}
+  {{#each children}}
+    {{#if this.isNode}}
+      "{{this.cardKey}}" [{{#each attrs}}{{this.attr}}={{{formatAttributeValue this.value}}} {{/each}}]
+    {{/if}}
+    {{#if this.isGraph}}
+      {{> subgraph}}
+    {{/if}}
+  {{/each}}
+}
+{{/inline}}
+{{#each results}}
+  {{#each digraphs}}
+    digraph {{this.key}} {
+      graph [{{#each attrs}}{{this.attr}}={{{formatAttributeValue this.value}}} {{/each}}]
+      {{#each edges}}
+        "{{this.[source]}}" -> "{{this.destination}}" [{{#each attrs}}{{this.attr}}={{{formatAttributeValue this.value}}} {{/each}}]
+      {{/each}}
+      {{#each children}}
+        {{#if this.isNode}}
+          "{{this.cardKey}}" [{{#each attrs}}{{this.attr}}={{{formatAttributeValue this.value}}} {{/each}}]
+        {{/if}}
+        {{#if this.isGraph}}
+          {{> subgraph}}
+        {{/if}}
+      {{/each}}
+    }
+  {{/each}}
+{{/each}}
\ No newline at end of file
diff --git a/resources/graphvizReport/query.lp.hbs b/resources/graphvizReport/query.lp.hbs
new file mode 100644
index 000000000..218f0fd87
--- /dev/null
+++ b/resources/graphvizReport/query.lp.hbs
@@ -0,0 +1,141 @@
+% model
+% note: not escaped
+{{{model}}}
+
+% include the view:
+{{{view}}}
+
+selectAll.
+
+result(result).
+
+% We need to format the label attributes without double quotes,
+% because labels already use HTML < and >, but we'll enclose other
+% attributes in double quotes, so here is a helper term to identify label attributes:
+labelAttribute("label").
+labelAttribute(label).
+
+% top-level graph
+childResult(result, Graph, "digraphs") :-
+    graph(Graph).
+
+childResult(Graph, ((graph, Graph), Field), "attrs") :-
+    graph(Graph),
+    attr(graph, Graph, Field, _).
+
+field(((graph, Graph), Field), "attr", Field) :-
+    graph(Graph),
+    attr(graph, Graph, Field, _).
+
+field(((graph, Graph), Field), "value", Value) :-
+    graph(Graph),
+    attr(graph, Graph, Field, Value),
+    labelAttribute(Field).
+
+field(((graph, Graph), Field), "value", Value) :-
+    graph(Graph),
+    attr(graph, Graph, Field, Value),
+    not labelAttribute(Field).
+
+% subgraphs
+childResult(Parent, Graph, "children") :-
+    graph(Graph, Parent).
+
+field(Graph, "isGraph", "void") :-
+    childResult(_, Graph, "children"),
+    graph(Graph, _).
+
+childResult(Graph, ((graph, Graph), Field), "attrs") :-
+    graph(Graph, _),
+    attr(graph, Graph, Field, _).
+
+field(((graph, Graph), Field), "attr", Field) :-
+    graph(Graph, _),
+    attr(graph, Graph, Field, _).
+
+field(((graph, Graph), Field), "value", Value) :-
+    graph(Graph, _),
+    attr(graph, Graph, Field, Value),
+    labelAttribute(Field).
+
+field(((graph, Graph), Field), "value", Value) :-
+    graph(Graph, _),
+    attr(graph, Graph, Field, Value),
+    not labelAttribute(Field).
+
+% nodes
+
+% TODO: For some reason, we need to use (node, Node) instead of Node, or
+% the solving will not succeed
+
+field((node, Node), "rank", Rank) :- field(Node, "rank", Rank), node(Node, _).
+
+field((node, Node), "isNode", "void") :-
+    childResult(_, (node, Node), "children"),
+    node(Node, _).
+
+childResult(Graph, (node, Node), "children") :-
+    childResult(_, Graph, _),
+    node(Node, Graph).
+
+field((node, Node), "cardKey", Node) :-
+    childResult(_, Graph, _),
+    node(Node, Graph).
+
+childResult((node, Node), ((node, Node), Field), "attrs") :-
+    node(Node, Graph),
+    attr(node, Node, Field, _).
+
+field(((node, Node), Field), "attr", Field) :-
+    node(Node, Graph),
+    attr(node, Node, Field, _).
+
+field(((node, Node), Field), "value", Value) :-
+    node(Node, Graph),
+    attr(node, Node, Field, Value),
+    labelAttribute(Field).
+
+field(((node, Node), Field), "value", Value) :-
+    node(Node, Graph),
+    attr(node, Node, Field, Value),
+    not labelAttribute(Field).
+
+graph(default) :-
+    node(_).
+
+graph(default) :-
+    edge(_).
+
+node(X, default) :-
+    node(X).
+
+edge((XR, YR), default) :-
+    edge((XR, YR)).
+
+edge((XR, YR, ""), Graph) :-
+    edge((XR, YR), Graph).
+
+% edges
+childResult(Graph, (edge, (XR, YR, Description)), "edges") :-
+    childResult(_, Graph, _),
+    edge((XR, YR, Description), Graph).
+
+childResult((edge, (XR, YR, Description)), ((edge, (XR, YR, Description)), Field), "attrs") :-
+    childResult(_, (edge, (XR, YR, Description)), _),
+    attr(edge, (XR, YR, Description), Field, _).
+
+field(((edge, (XR, YR, Description)), Field), "attr", Field) :-
+    childResult(_, (edge, (XR, YR, Description)), _),
+    attr(edge, (XR, YR, Description), Field, Value).
+
+field(((edge, (XR, YR, Description)), Field), "value", Value) :-
+    childResult(_, (edge, (XR, YR, Description)), _),
+    attr(edge, (XR, YR, Description), Field, Value).
+
+field((edge, (XR, YR, Description)), "source", XR) :-
+    edge((XR, YR, Description), _).
+
+field((edge, (XR, YR, Description)), "destination", YR) :-
+    edge((XR, YR, Description), _).
+
+order(N, Collection, 1, "rank", "ASC") :- resultLevel(_, N, Collection).
\ No newline at end of file
diff --git a/tools/backend/src/routes/cards.ts b/tools/backend/src/routes/cards.ts
index 58e43d710..180d72458 100644
--- a/tools/backend/src/routes/cards.ts
+++ b/tools/backend/src/routes/cards.ts
@@ -97,11 +97,15 @@ async function getCardDetails(
 
   let asciidocContent = '';
   try {
-    asciidocContent = await evaluateMacros(cardDetailsResponse.content || '', {
-      mode: 'inject',
-      projectPath: commands.project.basePath || '',
-      cardKey: key,
-    });
+    asciidocContent = await evaluateMacros(
+      cardDetailsResponse.content || '',
+      {
+        mode: 'inject',
+        projectPath: commands.project.basePath || '',
+        cardKey: key,
+      },
+      commands.calculateCmd,
+    );
   } catch (error) {
     asciidocContent = `Macro error: ${error instanceof Error ? error.message : 'Unknown error'}\n\n${asciidocContent}`;
   }
@@ -563,11 +567,15 @@ router.post('/:key/parse', async (c) => {
   try {
     let asciidocContent = '';
     try {
-      asciidocContent = await evaluateMacros(content, {
-        mode: 'inject',
-        projectPath: commands.project.basePath || '',
-        cardKey: key,
-      });
+      asciidocContent = await evaluateMacros(
+        content,
+        {
+          mode: 'inject',
+          projectPath: commands.project.basePath || '',
+          cardKey: key,
+        },
+        commands.calculateCmd,
+      );
     } catch (error) {
       asciidocContent = `Macro error: ${error instanceof Error ? error.message : 'Unknown error'}\n\n${content}`;
     }
diff --git a/tools/data-handler/package.json b/tools/data-handler/package.json
index 5d29447af..c43a89711 100644
--- a/tools/data-handler/package.json
+++ b/tools/data-handler/package.json
@@ -53,6 +53,7 @@
     "json-schema": "^0.4.0",
     "jsonschema": "^1.5.0",
     "mime-types": "^3.0.1",
+    "node-graphviz": "^0.1.1",
     "pino": "^9.6.0",
     "tslib": "^2.6.2",
     "write-json-file": "^6.0.0"
diff --git a/tools/data-handler/src/command-handler.ts b/tools/data-handler/src/command-handler.ts
index 2220441ed..a2161567f 100644
--- a/tools/data-handler/src/command-handler.ts
+++ b/tools/data-handler/src/command-handler.ts
@@ -509,9 +509,9 @@ export class Commands {
   // Runs a given logic program along with the query-language
   private async runLogicProgram(filePath: string): Promise<requestStatus> {
     try {
-      const res = await this.commands?.calculateCmd.runLogicProgram({
-        query: readFileSync(filePath, 'utf-8'),
-      });
+      const res = await this.commands?.calculateCmd.runLogicProgram(
+        readFileSync(filePath, 'utf-8'),
+      );
       return {
         statusCode: 200,
         payload: res,
diff --git a/tools/data-handler/src/command-manager.ts b/tools/data-handler/src/command-manager.ts
index f026bdbd1..eb1977a07 100644
--- a/tools/data-handler/src/command-manager.ts
+++ b/tools/data-handler/src/command-manager.ts
@@ -50,8 +50,8 @@ export class CommandManager {
     this.project = new Project(path);
     this.validateCmd = Validate.getInstance();
 
-    this.showCmd = new Show(this.project);
     this.calculateCmd = new Calculate(this.project);
+    this.showCmd = new Show(this.project, this.calculateCmd);
     this.createCmd = new Create(this.project, this.calculateCmd);
     this.editCmd = new Edit(this.project, this.calculateCmd);
     this.exportCmd = new Export(this.project, this.calculateCmd, this.showCmd);
diff --git a/tools/data-handler/src/commands/calculate.ts b/tools/data-handler/src/commands/calculate.ts
index 4892c0b2e..b2281e4d0 100644
--- a/tools/data-handler/src/commands/calculate.ts
+++ b/tools/data-handler/src/commands/calculate.ts
@@ -14,10 +14,10 @@
 // node
 import { basename, join, resolve } from 'node:path';
 import { fileURLToPath } from 'node:url';
-import { readFile, rm } from 'node:fs/promises';
+import { readFile } from 'node:fs/promises';
 import { readFileSync } from 'node:fs';
-import { spawnSync } from 'node:child_process';
 import { sanitizeSvgBase64 } from '../utils/sanitize-svg.js';
+import { graphviz } from 'node-graphviz';
 
 import type {
   BaseResult,
@@ -45,7 +45,6 @@ import {
   createWorkflowFacts,
 } from '../utils/clingo-facts.js';
 import { CardMetadataUpdater } from '../card-metadata-updater.js';
-import { generateRandomString } from '../utils/random.js';
 import type {
   CardType,
   FieldType,
@@ -55,23 +54,39 @@ import type {
   Workflow,
 } from '../interfaces/resource-interfaces.js';
 import { solve, setBaseProgram } from '@cyberismocom/node-clingo';
+import { generateReportContent } from '../utils/report.js';
 
 /**
  * This will done in a different way when build for npmjs is done
  */
 
-const commonFolderLocation = join(
+const commonResourcesLocation = join(
   fileURLToPath(import.meta.url),
-  '../../../../../resources/calculations/common',
+  '../../../../../resources',
 );
 
-const baseProgramPath = join(commonFolderLocation, 'base.lp');
-const queryLanguagePath = join(commonFolderLocation, 'queryLanguage.lp');
+const baseProgramPath = join(
+  commonResourcesLocation,
+  '/calculations/common/base.lp',
+);
+const queryLanguagePath = join(
+  commonResourcesLocation,
+  '/calculations/common/queryLanguage.lp',
+);
 
 // Read base and query language files
 const baseContent = readFileSync(baseProgramPath, 'utf-8');
 const queryLanguageContent = readFileSync(queryLanguagePath, 'utf-8');
 
+const reportQueryTemplate = readFileSync(
+  join(commonResourcesLocation, '/graphvizReport/query.lp.hbs'),
+  'utf-8',
+);
+const reportContentTemplate = readFileSync(
+  join(commonResourcesLocation, '/graphvizReport/index.adoc.hbs'),
+  'utf-8',
+);
+
 // Define names for the base programs
 const BASE_PROGRAM_KEY = 'base';
 const QUERY_LANGUAGE_KEY = 'queryLanguage';
@@ -80,7 +95,7 @@ const QUERY_LANGUAGE_KEY = 'queryLanguage';
 export class Calculate {
   private static mutex = new Mutex();
 
-  private pythonBinary: string = 'python';
+  private pythonBinary: string = 'python3';
   private queryCache = new Map<string, string>();
   private static commonFolderLocation: string = join(
     fileURLToPath(import.meta.url),
@@ -341,31 +356,18 @@ export class Calculate {
     return pathExists(location) ? location : null;
   }
 
-  //
-  private async run(
-    data: {
-      query?: string;
-    },
-    argMode: 'graph' | 'query' = 'query',
-  ): Promise<string[]> {
-    if (!data.query) {
-      throw new Error('Must provide query to run a clingo program');
-    }
-
+  private async run(query: string): Promise<string[]> {
     const res = await Calculate.mutex.runExclusive(async () => {
       // For queries, use both base and queryLanguage
-      const basePrograms =
-        argMode === 'query'
-          ? [BASE_PROGRAM_KEY, QUERY_LANGUAGE_KEY]
-          : BASE_PROGRAM_KEY;
+      const basePrograms = [BASE_PROGRAM_KEY, QUERY_LANGUAGE_KEY];
 
       // Then solve with the program - need to pass the program as parameter
-      return solve(data.query as string, basePrograms);
+      return solve(query, basePrograms);
     });
 
     logger.trace(
       {
-        query: data.query,
+        query,
       },
       `Ran Clingo solve command`,
     );
@@ -538,70 +540,28 @@ export class Calculate {
    * @param timeout Maximum amount of milliseconds clingraph is allowed to run
    * @returns a base64 encoded image as a string
    */
-  public async runGraph(
-    data: { query?: string; file?: string },
-    timeout?: number,
-  ) {
-    const clingoOutput = await this.run(data, 'graph');
-
-    // unlikely we ever get a collision
-    const randomId = generateRandomString(36, 20);
-
-    const clingGraphArgs = [
-      '--out=render',
-      '--format=svg',
-      '--type=digraph',
-      `--name-format=${randomId}`,
-      `--dir=${this.project.paths.tempFolder}`,
-    ];
-
-    // python is only used for windows
-    const pythonArgs = [
-      '-c',
-      `from clingraph import main; import sys; sys.argv = ["sys.argv[0]", ${clingGraphArgs
-        .map((arg) => `'${arg.replace(/\\/g, '\\\\')}'`)
-        .join(',')}]; sys.exit(main())`,
-    ];
-
-    const clingraph = spawnSync(this.pythonBinary, pythonArgs, {
-      encoding: 'utf8',
-      // below looks like it could be replaced by just joining dots, but
-      // it breaks it because elements contain newlines, which must be
-      // replaced by dots
-      input: clingoOutput.join('\n').replaceAll('\n', '.') + '.',
-      timeout,
-      maxBuffer: 1024 * 1024 * 100,
+  public async runGraph(model: string, view: string) {
+    // Let's run the clingraph query
+    const result = await generateReportContent({
+      calculate: this,
+      contentTemplate: reportContentTemplate,
+      queryTemplate: reportQueryTemplate,
+      options: {
+        model: model,
+        view: view,
+      },
+      graph: true,
     });
-
-    if (clingraph.status !== 0) {
-      throw new Error(`Graph: Failed to run clingraph ${clingraph.stderr}`);
-    }
-
-    const filePath = join(this.project.paths.tempFolder, randomId);
-
-    let fileData;
-    try {
-      fileData = await readFile(filePath + '.svg');
-    } catch (e) {
-      throw new Error(
-        `Graph: Failed to read image file after generating graph: ${e}`,
-      );
-    } finally {
-      await rm(filePath, { force: true });
-      await rm(filePath + '.svg', { force: true });
-    }
-
-    // Sanitizing SVG before returning it
-    return sanitizeSvgBase64(fileData);
+    return sanitizeSvgBase64(await graphviz.dot(result, 'svg'));
   }
 
   /**
    * Runs a logic program using clingo.
-   * @param filePath Path to a query file to be run in relation to current working directory
+   * @param query Logic program to be run
    * @returns parsed program output
    */
-  public async runLogicProgram(data: { query?: string; file?: string }) {
-    const clingoOutput = await this.run(data);
+  public async runLogicProgram(query: string) {
+    const clingoOutput = await this.run(query);
 
     return this.parseClingoResult(clingoOutput);
   }
@@ -623,12 +583,11 @@ export class Calculate {
       content = compiled(options);
     }
 
-    const clingoOutput = await this.run(
-      {
-        query: content,
-      },
-      'query',
-    );
+    if (!content) {
+      throw new Error(`Query file ${queryName} not found`);
+    }
+
+    const clingoOutput = await this.run(content);
 
     const result = await this.parseClingoResult(clingoOutput);
 
diff --git a/tools/data-handler/src/commands/export.ts b/tools/data-handler/src/commands/export.ts
index fd0c09649..9641f3192 100644
--- a/tools/data-handler/src/commands/export.ts
+++ b/tools/data-handler/src/commands/export.ts
@@ -206,6 +206,7 @@ export class Export {
           projectPath,
           cardKey: card.key,
         },
+        this.calculateCmd,
       );
     } catch (error) {
       asciiDocContent = `Macro error: ${error instanceof Error ? error.message : 'Unknown error'}\n\n${asciiDocContent}`;
diff --git a/tools/data-handler/src/commands/show.ts b/tools/data-handler/src/commands/show.ts
index 5a660a391..e9261ebba 100644
--- a/tools/data-handler/src/commands/show.ts
+++ b/tools/data-handler/src/commands/show.ts
@@ -43,6 +43,7 @@ import { UserPreferences } from '../utils/user-preferences.js';
 
 import ReportMacro from '../macros/report/index.js';
 import TaskQueue from '../macros/task-queue.js';
+import type { Calculate } from './calculate.js';
 
 /**
  * Show command.
@@ -52,7 +53,10 @@ export class Show {
     string,
     (from?: ResourcesFrom) => Promise<Resource[]>
   >;
-  constructor(private project: Project) {
+  constructor(
+    private project: Project,
+    private calculate: Calculate,
+  ) {
     this.resourceFunction = new Map([
       ['cardTypes', this.project.cardTypes.bind(this.project)],
       ['fieldTypes', this.project.fieldTypes.bind(this.project)],
@@ -351,7 +355,7 @@ export class Show {
       throw new Error(`Report '${reportName}' does not exist`);
     }
 
-    const reportMacro = new ReportMacro(new TaskQueue());
+    const reportMacro = new ReportMacro(new TaskQueue(), this.calculate);
     const result = await reportMacro.handleInject(
       {
         projectPath: this.project.basePath,
diff --git a/tools/data-handler/src/commands/validate.ts b/tools/data-handler/src/commands/validate.ts
index ca7352ead..3685307e0 100644
--- a/tools/data-handler/src/commands/validate.ts
+++ b/tools/data-handler/src/commands/validate.ts
@@ -52,7 +52,7 @@ const SHORT_TEXT_MAX_LENGTH = 80;
 
 import * as EmailValidator from 'email-validator';
 import { evaluateMacros } from '../macros/index.js';
-
+import { Calculate } from './calculate.js';
 const baseDir = dirname(fileURLToPath(import.meta.url));
 const subFoldersToValidate = ['.cards', 'cardRoot'];
 
@@ -547,6 +547,7 @@ export class Validate {
       } else {
         const errorMsg: string[] = [];
         const project = new Project(projectPath);
+        const calculate = new Calculate(project);
 
         // Then, validate that each 'contentSchema' children as well.
         const result = await this.readAndValidateContentFiles(
@@ -590,11 +591,15 @@ export class Validate {
 
           // Validate macros in content
           if (card.content) {
-            await evaluateMacros(card.content, {
-              mode: 'validate',
-              projectPath,
-              cardKey: card.key,
-            });
+            await evaluateMacros(
+              card.content,
+              {
+                mode: 'validate',
+                projectPath,
+                cardKey: card.key,
+              },
+              calculate,
+            );
           }
         }
         if (errorMsg.length) {
diff --git a/tools/data-handler/src/macros/graph/index.ts b/tools/data-handler/src/macros/graph/index.ts
index 457212ed8..04021b44e 100644
--- a/tools/data-handler/src/macros/graph/index.ts
+++ b/tools/data-handler/src/macros/graph/index.ts
@@ -110,9 +110,7 @@ class ReportMacro extends BaseMacro {
     const view = handlebars.compile(viewContent)(handlebarsContext);
 
     const modelContent = await readFile(modelLocation, { encoding: 'utf-8' });
-    const result = await calculate.runGraph({
-      query: view + '\n' + modelContent,
-    });
+    const result = await calculate.runGraph(modelContent, view);
 
     if (typeof result !== 'string') {
       throw new Error(
diff --git a/tools/data-handler/src/macros/index.ts b/tools/data-handler/src/macros/index.ts
index def71a821..d7893c249 100644
--- a/tools/data-handler/src/macros/index.ts
+++ b/tools/data-handler/src/macros/index.ts
@@ -28,21 +28,45 @@ import type {
 } from '../interfaces/macros.js';
 import type BaseMacro from './base-macro.js';
 import TaskQueue from './task-queue.js';
-
+import type { Calculate } from '../commands/index.js';
 const CURLY_LEFT = '&#123;';
 const CURLY_RIGHT = '&#125;';
 
-export interface MacroConstructor {
-  new (tasks: TaskQueue): BaseMacro; // Constructor signature
+/**
+ * Constructor for all macros except report macros
+ */
+export interface SimpleMacroConstructor {
+  new (tasks: TaskQueue): BaseMacro;
+}
+
+/**
+ * Constructor for report macros
+ */
+export interface ReportMacroConstructor {
+  new (tasks: TaskQueue, calculate: Calculate): BaseMacro;
 }
 
-export const macros: { [K in MacroName]: MacroConstructor } = {
+/**
+ * Constructor for all macros
+ */
+export type MacroConstructor = SimpleMacroConstructor | ReportMacroConstructor;
+
+export const macros: {
+  [K in MacroName]: MacroConstructor;
+} = {
   createCards,
   graph,
   report,
   scoreCard,
 };
 
+/**
+ * Validates the content inside a macro
+ * @param macro - The macro to validate the content of
+ * @param data - The data to validate
+ * @param validator - The validator to use
+ * @returns The validated data
+ */
 export function validateMacroContent<T>(
   macro: MacroMetadata,
   data: unknown,
@@ -74,11 +98,12 @@ export function registerMacros(
   instance: typeof Handlebars,
   context: MacroGenerationContext,
   tasks: TaskQueue,
+  calculate: Calculate,
 ) {
   const macroInstances: BaseMacro[] = [];
   for (const macro of Object.keys(macros) as MacroName[]) {
     const MacroClass = macros[macro];
-    const macroInstance = new MacroClass(tasks);
+    const macroInstance = new MacroClass(tasks, calculate);
     instance.registerHelper(macro, function (this: unknown, options) {
       if (
         this != null &&
@@ -134,11 +159,12 @@ export function registerEmptyMacros(instance: typeof Handlebars) {
 export async function evaluateMacros(
   content: string,
   context: MacroGenerationContext,
+  calculate: Calculate,
   maxTries: number = 10,
 ) {
   const handlebars = Handlebars.create();
   const tasks = new TaskQueue();
-  registerMacros(handlebars, context, tasks);
+  registerMacros(handlebars, context, tasks, calculate);
   let result = content;
   while (maxTries-- > 0) {
     tasks.reset();
diff --git a/tools/data-handler/src/macros/report/index.ts b/tools/data-handler/src/macros/report/index.ts
index 9db7642e5..10723ac64 100644
--- a/tools/data-handler/src/macros/report/index.ts
+++ b/tools/data-handler/src/macros/report/index.ts
@@ -10,26 +10,30 @@
     License along with this program.  If not, see <https://www.gnu.org/licenses/>.
 */
 
-import { registerEmptyMacros, validateMacroContent } from '../index.js';
+import { validateMacroContent } from '../index.js';
 import type { MacroOptions } from '../index.js';
+
 import type { MacroGenerationContext } from '../../interfaces/macros.js';
 import macroMetadata from './metadata.js';
 import { Project } from '../../containers/project.js';
-import { Calculate } from '../../commands/index.js';
-import Handlebars from 'handlebars';
+import type { Calculate } from '../../commands/index.js';
 import BaseMacro from '../base-macro.js';
 import { validateJson } from '../../utils/validate.js';
 import type TaskQueue from '../task-queue.js';
 import { ReportResource } from '../../resources/report-resource.js';
 import { resourceName } from '../../utils/resource-utils.js';
+import { generateReportContent } from '../../utils/report.js';
 
 export interface ReportOptions extends MacroOptions {
   name: string;
 }
 
 class ReportMacro extends BaseMacro {
-  constructor(tasksQueue: TaskQueue) {
-    super(macroMetadata, tasksQueue);
+  constructor(
+    tasks: TaskQueue,
+    private readonly calculate: Calculate,
+  ) {
+    super(macroMetadata, tasks);
   }
   handleValidate = (input: unknown) => {
     this.validate(input);
@@ -45,7 +49,7 @@ class ReportMacro extends BaseMacro {
     const resource = new ReportResource(project, resourceName(options.name));
     const report = await resource.show();
 
-    if (!report) throw new Error(`Report ${options} does not exist`);
+    if (!report) throw new Error(`Report ${options.name} does not exist`);
 
     if (report.schema) {
       validateJson(options, {
@@ -53,30 +57,14 @@ class ReportMacro extends BaseMacro {
       });
     }
 
-    const handlebarsContext = {
-      cardKey: context.cardKey,
-      ...options,
-    };
-
-    const handlebars = Handlebars.create();
-
-    const template = handlebars.compile(report.queryTemplate, {
-      strict: true,
-    });
-
-    const calculate = new Calculate(project);
-    const result = await calculate.runLogicProgram({
-      query: template(handlebarsContext),
-    });
-    if (result.error) {
-      throw new Error(result.error);
-    }
-    // register empty macros so that other macros aren't touched yet
-    registerEmptyMacros(handlebars);
-
-    return handlebars.compile(report.contentTemplate)({
-      ...handlebarsContext,
-      ...result,
+    return generateReportContent({
+      calculate: this.calculate,
+      contentTemplate: report.contentTemplate,
+      queryTemplate: report.queryTemplate,
+      options: {
+        cardKey: context.cardKey,
+        ...options,
+      },
     });
   };
 
diff --git a/tools/data-handler/src/utils/report.ts b/tools/data-handler/src/utils/report.ts
new file mode 100644
index 000000000..2e1849f59
--- /dev/null
+++ b/tools/data-handler/src/utils/report.ts
@@ -0,0 +1,86 @@
+/**
+    Cyberismo
+    Copyright © Cyberismo Ltd and contributors 2024
+
+    This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License version 3 as published by the Free Software Foundation.
+
+    This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+    You should have received a copy of the GNU Affero General Public
+    License along with this program.  If not, see <https://www.gnu.org/licenses/>.
+*/
+import Handlebars from 'handlebars';
+import type { Calculate } from '../commands/index.js';
+import { registerEmptyMacros } from '../macros/index.js';
+
+/**
+ * Formats a value from a logic program for use to graphviz
+ * @param value - The value to format
+ * @returns The formatted value
+ */
+export function formatAttributeValue(value?: string) {
+  if (!value) {
+    return '';
+  }
+  // value is an html-like string
+  if (value.length > 1 && value.startsWith('<') && value.endsWith('>')) {
+    return value;
+  }
+  // value is a normal string and needs to be wrapped in quotes
+  return `"${value}"`;
+}
+
+/**
+ * Parameters for the core generation function
+ */
+interface GenerateReportContentParams {
+  calculate: Calculate;
+  contentTemplate: string;
+  queryTemplate: string;
+  options: Record<string, string>;
+  graph?: boolean;
+}
+
+/**
+ * Generates report content based on a report definition, project context, and options,
+ * by utilizing the ReportRunner class.
+ *
+ * @param params - The parameters for report generation.
+ * @returns The rendered report content as a string.
+ * @throws Error if the report definition is invalid, schema validation fails, or logic program execution fails.
+ */
+export async function generateReportContent(
+  params: GenerateReportContentParams,
+): Promise<string> {
+  const {
+    calculate,
+    contentTemplate,
+    queryTemplate,
+    graph,
+    options, // Destructure options
+  } = params;
+
+  const handlebars = Handlebars.create();
+
+  // Compile and execute the query template
+  const template = handlebars.compile(queryTemplate, {
+    strict: true,
+  });
+
+  const result = await calculate.runLogicProgram(template(options));
+
+  if (result.error) {
+    throw new Error(result.error);
+  }
+  // register empty macros so that other macros aren't touched yet
+  registerEmptyMacros(handlebars);
+
+  if (graph) {
+    handlebars.registerHelper('formatAttributeValue', formatAttributeValue);
+  }
+
+  return handlebars.compile(contentTemplate)({
+    ...options,
+    ...result,
+  });
+}
diff --git a/tools/data-handler/src/utils/sanitize-svg.ts b/tools/data-handler/src/utils/sanitize-svg.ts
index 4272c1a60..d089ae07f 100644
--- a/tools/data-handler/src/utils/sanitize-svg.ts
+++ b/tools/data-handler/src/utils/sanitize-svg.ts
@@ -25,22 +25,17 @@ const removeSvgWidthAndHeight = (node: Element) => {
 };
 
 /**
- * Sanitize an SVG Buffer and return a base64-encoded string
- * @param buffer - SVG content as a Buffer
+ * Sanitize an SVG string and return a base64-encoded string
+ * @param svg - SVG content as a string
  * @returns base64-encoded sanitized SVG string
  */
-export function sanitizeSvgBase64(buffer: Buffer): string {
+export function sanitizeSvgBase64(svg: string): string {
   const DOMPurify = createDOMPurify(window as unknown as WindowLike);
 
-  const dirty = buffer.toString('utf-8');
-
   DOMPurify.setConfig({ USE_PROFILES: { svg: true } });
   DOMPurify.addHook('afterSanitizeAttributes', removeSvgWidthAndHeight);
 
-  let cleaned = DOMPurify.sanitize(dirty);
-
-  // Remove link titles, quick fix for Clingraph/Graphviz generated titles for links that are quite strange
-  cleaned = cleaned.replace(/\s*xlink:title=(["']).*?\1/g, '');
+  const cleaned = DOMPurify.sanitize(svg);
 
   return Buffer.from(cleaned, 'utf-8').toString('base64');
 }
diff --git a/tools/data-handler/test/calculate.test.ts b/tools/data-handler/test/calculate.test.ts
index 84fcde162..abd007bf3 100644
--- a/tools/data-handler/test/calculate.test.ts
+++ b/tools/data-handler/test/calculate.test.ts
@@ -85,98 +85,85 @@ describe('calculate', () => {
     // load file
     const file = join(baseDir, '../../../resources/calculations/test/model.lp');
     const content = readFileSync(file, 'utf8');
-    const res = await calculate.runGraph({
-      query: 'viewAll.\n' + content,
-    });
+    const res = await calculate.runGraph(content, 'viewAll(3).');
 
     expect(res).to.not.equal('');
   }).timeout(20000);
   describe('python functions', () => {
     it('concatenate a string, a number and a constant', async () => {
-      const res = await calculate.runLogicProgram({
-        query: 'result(@concatenate("string", 1234, constant)).',
-      });
+      const res = await calculate.runLogicProgram(
+        'result(@concatenate("string", 1234, constant)).',
+      );
       expect(res.results[0].key).to.equal('string1234constant');
     });
     it('concatenate without parameters', async () => {
-      const res = await calculate.runLogicProgram({
-        query: 'result(@concatenate()).',
-      });
+      const res = await calculate.runLogicProgram('result(@concatenate()).');
       expect(res.results[0].key).to.equal('');
     });
     it('concatenate with 1 parameter', async () => {
-      const res = await calculate.runLogicProgram({
-        query: 'result(@concatenate("parameter")).',
-      });
+      const res = await calculate.runLogicProgram(
+        'result(@concatenate("parameter")).',
+      );
       expect(res.results[0].key).to.equal('parameter');
     });
     it('concatenate with 2 parameters', async () => {
-      const res = await calculate.runLogicProgram({
-        query: 'result(@concatenate("one", "two")).',
-      });
+      const res = await calculate.runLogicProgram(
+        'result(@concatenate("one", "two")).',
+      );
       expect(res.results[0].key).to.equal('onetwo');
     });
     it('calculate daysSince 2024-01-01', async () => {
-      const res = await calculate.runLogicProgram({
-        query: 'result(@daysSince("2024-01-01")).',
-      });
+      const res = await calculate.runLogicProgram(
+        'result(@daysSince("2024-01-01")).',
+      );
       expect(Number(res.results[0].key)).greaterThan(365);
     });
     it('calculate daysSince "2022-01-15T17:08:50.716Z"', async () => {
-      const res = await calculate.runLogicProgram({
-        query: 'result(@daysSince("2022-01-01T17:08:50.716Z")).',
-      });
+      const res = await calculate.runLogicProgram(
+        'result(@daysSince("2022-01-01T17:08:50.716Z")).',
+      );
       expect(Number(res.results[0].key)).greaterThan(1000);
     });
     it('daysSince of an invalid date should be zero', async () => {
-      const res = await calculate.runLogicProgram({
-        query: 'result(@daysSince("23232323")).',
-      });
+      const res = await calculate.runLogicProgram(
+        'result(@daysSince("23232323")).',
+      );
       expect(Number(res.results[0].key)).to.equal(0);
     });
     it('daysSince of a number date should be zero', async () => {
-      const res = await calculate.runLogicProgram({
-        query: 'result(@daysSince(1)).',
-      });
+      const res = await calculate.runLogicProgram('result(@daysSince(1)).');
       expect(Number(res.results[0].key)).to.equal(0);
     });
     it('the length of today() should be 10', async () => {
-      const res = await calculate.runLogicProgram({
-        query: 'result(@today()).',
-      });
+      const res = await calculate.runLogicProgram('result(@today()).');
       expect(res.results[0].key.length).to.equal(10);
     });
     it('wrapping a short string should yield the string itself', async () => {
-      const res = await calculate.runLogicProgram({
-        query: 'result(@wrap("A short string")).',
-      });
+      const res = await calculate.runLogicProgram(
+        'result(@wrap("A short string")).',
+      );
       expect(res.results[0].key).to.equal('A short string');
     });
     it('wrapping a long string', async () => {
-      const res = await calculate.runLogicProgram({
-        query:
-          'result(@wrap("This is a long string that would be too long as a title of a node in a graph")).',
-      });
+      const res = await calculate.runLogicProgram(
+        'result(@wrap("This is a long string that would be too long as a title of a node in a graph")).',
+      );
       expect(res.results[0].key).to.equal(
         'This is a long string that<br/>would be too long as a<br/>title of a node in a graph',
       );
     });
     it('wrapping an empty string', async () => {
-      const res = await calculate.runLogicProgram({
-        query: 'result(@wrap("")).',
-      });
+      const res = await calculate.runLogicProgram('result(@wrap("")).');
       expect(res.results[0].key).to.equal('');
     });
     it('wrapping an integer', async () => {
-      const res = await calculate.runLogicProgram({
-        query: 'result(@wrap(5)).',
-      });
+      const res = await calculate.runLogicProgram('result(@wrap(5)).');
       expect(res.results[0].key).to.equal('');
     });
     it('wrapping a string with &', async () => {
-      const res = await calculate.runLogicProgram({
-        query: 'result(@wrap("this & that")).',
-      });
+      const res = await calculate.runLogicProgram(
+        'result(@wrap("this & that")).',
+      );
       expect(res.results[0].key).to.equal('this &amp; that');
     });
   });
diff --git a/tools/node-clingo/README.md b/tools/node-clingo/README.md
new file mode 100644
index 000000000..cc28b42a2
--- /dev/null
+++ b/tools/node-clingo/README.md
@@ -0,0 +1,136 @@
+# @cyberismocom/node-clingo
+
+Node.js native bindings for the [Clingo](https://potassco.org/clingo/) answer set solver (ASP).
+
+---
+
+## Installation
+
+To install, run `pnpm install`:
+
+- On install, a script attempts to download the correct prebuilt binary for your platform from GitHub Releases.
+- If no prebuild is available, it will attempt a local build (requires Clingo and build tools).
+
+---
+
+## Usage
+
+```js
+import { solve, setBaseProgram, clearBaseProgram } from '@cyberismocom/node-clingo';
+
+// Solve a simple logic program
+const result = await solve('a. b. c(1). c(2).');
+console.log(result.answers); // [ 'a b c(1) c(2)' ]
+
+// Set a base program (persisted across solves)
+setBaseProgram('base(1).');
+const result2 = await solve('derived :- base(X).');
+console.log(result2.answers); // [ 'base(1) derived' ]
+
+// Named base programs
+setBaseProgram('type(query).', 'query');
+setBaseProgram('type(graph).', 'graph');
+const result3 = await solve('valid :- type(query).', 'query');
+
+// Combine multiple base programs
+setBaseProgram('color(red).', 'colors');
+setBaseProgram('shape(circle).', 'shapes');
+setBaseProgram('size(large).', 'sizes');
+const result4 = await solve('valid :- color(X), shape(Y), size(Z).', [
+  'colors', 'shapes', 'sizes',
+]);
+
+// Clear base programs
+default: clearBaseProgram(); // clears all
+clearBaseProgram('query'); // clears named
+```
+
+---
+
+## API
+
+### `async solve(program: string, basePrograms?: string | string[]): Promise<{ answers: string[], executionTime: number }>`
+
+Solves a logic program, optionally combining with one or more base programs. Returns all answer sets and execution time (μs).
+
+### `setBaseProgram(program: string, key?: string)`
+
+Stores a base program under a key (default: 'default'). Used in subsequent solves.
+
+### `clearBaseProgram(key?: string)`
+
+Removes a named base program, or all if no key is given.
+
+---
+
+## Prebuilds & Supported Platforms
+
+Prebuilt binaries are provided for:
+
+- **Linux x64** (glibc & musl/Alpine)
+- **Linux arm64** (glibc & musl/Alpine)
+- **macOS x64**
+- **macOS arm64**
+- **Windows x64**
+
+Prebuilds are downloaded automatically on install. If a prebuild is not available, a local build is attempted (requires Clingo and build tools).
+
+### Prebuild details
+
+- Prebuilds are stored in `prebuilds/{platform}-{arch}/`.
+- Linux glibc and musl (Alpine) builds are distinguished by `.glibc.node` and `.musl.node` suffixes.
+- See `.github/workflows/prebuild.yml` for the full CI build matrix and packaging logic.
+
+---
+
+## Building Locally
+
+If you need to build from source (e.g., for unsupported platforms):
+
+### Prerequisites
+
+- Node.js 22
+- Clingo (must be installed and available in your system path)
+- C++20 compiler (GCC 14+ recommended), should also support older versions
+- Python 3, make, and build tools
+
+### Tips for building
+
+**Windows**:
+Make sure you installed the build tools when you installed nodeJS.
+Install clingo using conda with the environment.yml available at the root of this repo
+
+```
+conda env create -f environment.yml
+
+```
+
+**MacOS**:
+XCode tools should contain all requirements. Install clingo using homebrew:
+`brew install clingo`
+
+**Linux**:
+sudo apt install build-essentials make python3 gringo
+
+### Build steps
+
+```sh
+pnpm install
+pnpm run build-prebuildify
+```
+
+#### Alpine/musl builds
+
+See `alpine.Dockerfile` for the musl/Alpine build process (used in CI for static builds).
+
+---
+
+## CI/CD & Prebuild Workflow
+
+- See `.github/workflows/prebuild.yml` for the full automation:
+  - Detects version changes
+  - Builds prebuilds for all platforms (including musl via Docker)
+  - Uploads prebuilds as GitHub release assets
+  - Installs prebuilds automatically on `pnpm install` via `scripts/download-prebuild.js`
+
+---
diff --git a/tools/node-clingo/package.json b/tools/node-clingo/package.json
index 2919d494c..cb5370ed4 100644
--- a/tools/node-clingo/package.json
+++ b/tools/node-clingo/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@cyberismocom/node-clingo",
-  "version": "1.0.1",
+  "version": "1.0.2",
   "description": "Node.js bindings for Clingo answer set solver",
   "scripts": {
     "install": "node scripts/download-prebuild.js || node-gyp-build",
diff --git a/tools/node-clingo/src/binding.cc b/tools/node-clingo/src/binding.cc
index e49005f7d..0a2f11c97 100644
--- a/tools/node-clingo/src/binding.cc
+++ b/tools/node-clingo/src/binding.cc
@@ -229,16 +229,20 @@ Napi::Value SetBaseProgram(const Napi::CallbackInfo &info)
     }
     catch (const Napi::Error &e)
     {
-        // Let Napi errors propagate as they are
-        throw e;
+        e.ThrowAsJavaScriptException();
+        return env.Undefined();
     }
     catch (const std::exception &e)
     {
-        throw Napi::Error::New(env, e.what());
+        const auto error = Napi::Error::New(env, e.what());
+        error.ThrowAsJavaScriptException();
+        return env.Undefined();
     }
     catch (...)
     {
-        throw Napi::Error::New(env, "Unknown error occurred");
+        const auto error = Napi::Error::New(env, "Unknown error occurred");
+        error.ThrowAsJavaScriptException();
+        return env.Undefined();
     }
 }
 
@@ -269,15 +273,20 @@ Napi::Value ClearBaseProgram(const Napi::CallbackInfo &info)
     }
     catch (const Napi::Error &e)
     {
-        throw e;
+        e.ThrowAsJavaScriptException();
+        return env.Undefined();
     }
     catch (const std::exception &e)
     {
-        throw Napi::Error::New(env, e.what());
+        const auto error = Napi::Error::New(env, e.what());
+        error.ThrowAsJavaScriptException();
+        return env.Undefined();
     }
     catch (...)
     {
-        throw Napi::Error::New(env, "Unknown error occurred");
+        const auto error = Napi::Error::New(env, "Unknown error occurred");
+        error.ThrowAsJavaScriptException();
+        return env.Undefined();
     }
 }
 
@@ -299,15 +308,20 @@ Napi::Value ClearAllBasePrograms(const Napi::CallbackInfo &info)
     }
     catch (const Napi::Error &e)
     {
-        throw e;
+        e.ThrowAsJavaScriptException();
+        return env.Undefined();
     }
     catch (const std::exception &e)
     {
-        throw Napi::Error::New(env, e.what());
+        const auto error = Napi::Error::New(env, e.what());
+        error.ThrowAsJavaScriptException();
+        return env.Undefined();
     }
     catch (...)
     {
-        throw Napi::Error::New(env, "Unknown error occurred");
+        const auto error = Napi::Error::New(env, "Unknown error occurred");
+        error.ThrowAsJavaScriptException();
+        return env.Undefined();
     }
 }
 
@@ -448,16 +462,20 @@ Napi::Value Solve(const Napi::CallbackInfo &info)
     }
     catch (const Napi::Error &e)
     {
-        // Let Napi errors propagate as they are
-        throw e;
+        e.ThrowAsJavaScriptException();
+        return env.Undefined();
     }
     catch (const std::exception &e)
     {
-        throw Napi::Error::New(env, e.what());
+        const auto error = Napi::Error::New(env, e.what());
+        error.ThrowAsJavaScriptException();
+        return env.Undefined();
     }
     catch (...)
     {
-        throw Napi::Error::New(env, "Unknown error occurred");
+        const auto error = Napi::Error::New(env, "Unknown error occurred");
+        error.ThrowAsJavaScriptException();
+        return env.Undefined();
     }
 }
 
diff --git a/tools/node-clingo/src/function_handlers.cc b/tools/node-clingo/src/function_handlers.cc
index 6e3d4f95f..c14a87a6e 100644
--- a/tools/node-clingo/src/function_handlers.cc
+++ b/tools/node-clingo/src/function_handlers.cc
@@ -17,12 +17,18 @@
 #include <iostream>
 #include <sstream>
 
-#if !defined(__clang__)
-#include <format>
+#if defined(__clang__) || __GNUC__ < 13
+#define USE_FORMAT_FALLBACK 1
 #else
+#define USE_FORMAT_FALLBACK 0
+#endif
+
+#if USE_FORMAT_FALLBACK
 #include <ctime>
 #include <time.h>
 #include <iomanip>
+#else
+#include <format>
 #endif
 
 namespace node_clingo
@@ -133,17 +139,16 @@ namespace node_clingo
 
 // clang used on mac does not support
 // current_zone and zoned_time
-#if !defined(__clang__)
-        const auto now_point = std::chrono::system_clock::now();
-        const auto current_zone = std::chrono::current_zone();
-        const std::chrono::zoned_time zt{current_zone, now_point};
-        std::string today_str = std::format("{:%Y-%m-%d}", zt);
-#else
+#if USE_FORMAT_FALLBACK
         time_t now = time(nullptr);
-        std::string today_str;
         std::stringstream ss;
         ss << std::put_time(std::localtime(&now), "%Y-%m-%d");
-        today_str = ss.str();
+        const auto today_str = ss.str();
+#else
+        const auto now_point = std::chrono::system_clock::now();
+        const auto current_zone = std::chrono::current_zone();
+        const std::chrono::zoned_time zt{current_zone, now_point};
+        const auto today_str = std::format("{:%Y-%m-%d}", zt);
 #endif
 
         clingo_symbol_t sym;
diff --git a/tools/node-clingo/test/solve.test.ts b/tools/node-clingo/test/solve.test.ts
index da200e665..2b3ab10eb 100644
--- a/tools/node-clingo/test/solve.test.ts
+++ b/tools/node-clingo/test/solve.test.ts
@@ -99,4 +99,7 @@ describe('Clingo solver', () => {
       expect(result.answers[0]).toBe('a');
     });
   });
+  it('should handle errors', async () => {
+    await expect(solve('not a program', 'non_existent')).rejects.toThrow();
+  });
 });


Reviews:
user: login: Fuzzbender
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T09:27:07Z
user: login: Fuzzbender
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T09:29:06Z
user: login: CyberJML
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T10:15:07Z
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-13T10:28:23Z
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-14T05:53:25Z
user: login: Fuzzbender
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T08:46:48Z
user: login: Fuzzbender
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T08:49:18Z
user: login: Fuzzbender
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T08:59:47Z
user: login: Fuzzbender
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T09:00:32Z
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-14T09:20:12Z
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-14T09:28:46Z
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-14T09:29:02Z
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-14T09:29:18Z
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-14T09:31:22Z
user: login: henryhaverinen
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T20:12:50Z
user: login: henryhaverinen
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T20:17:21Z
user: login: Fuzzbender
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T08:04:48Z
user: login: Fuzzbender
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T08:08:54Z
user: login: Fuzzbender
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T08:14:35Z
user: login: Fuzzbender
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T08:20:03Z
user: login: Fuzzbender
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T08:23:53Z
user: login: Fuzzbender
body: Some minor editorial comments. Looks good to me.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T08:26:39Z
user: login: henryhaverinen
body: Let's not integrate yet, but only once we have made sure that the related change in secure development essentials has been adopted
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T09:36:36Z
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-15T11:46:00Z
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-16T09:40:35Z
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-16T10:08:12Z
user: login: Fuzzbender
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T06:01:15Z

Review comments:
diff_hunk: @@ -229,16 +229,20 @@ Napi::Value SetBaseProgram(const Napi::CallbackInfo &info)
     }
     catch (const Napi::Error &e)
     {
-        // Let Napi errors propagate as they are
-        throw e;
+        e.ThrowAsJavaScriptException();
+        return env.Undefined();
     }
     catch (const std::exception &e)
     {
-        throw Napi::Error::New(env, e.what());
+        auto error = Napi::Error::New(env, e.what());
path: tools/node-clingo/src/binding.cc
user: login: Fuzzbender
body: Add `const` --> `const auto error = ...`
(and similarly for other places.
created_at: 2025-05-13T09:27:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -133,17 +139,17 @@ namespace node_clingo
 
 // clang used on mac does not support
 // current_zone and zoned_time
-#if !defined(__clang__)
-        const auto now_point = std::chrono::system_clock::now();
-        const auto current_zone = std::chrono::current_zone();
-        const std::chrono::zoned_time zt{current_zone, now_point};
-        std::string today_str = std::format("{:%Y-%m-%d}", zt);
-#else
+#if USE_FORMAT_FALLBACK
         time_t now = time(nullptr);
         std::string today_str;
         std::stringstream ss;
         ss << std::put_time(std::localtime(&now), "%Y-%m-%d");
         today_str = ss.str();
+#else
+        const auto now_point = std::chrono::system_clock::now();
+        const auto current_zone = std::chrono::current_zone();
+        const std::chrono::zoned_time zt{current_zone, now_point};
+        std::string today_str = std::format("{:%Y-%m-%d}", zt);
path: tools/node-clingo/src/function_handlers.cc
user: login: Fuzzbender
body: I think this could also be `const auto`
created_at: 2025-05-13T09:29:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -53,6 +53,7 @@
     "json-schema": "^0.4.0",
     "jsonschema": "^1.5.0",
     "mime-types": "^3.0.1",
+    "node-graphviz": "^0.1.1",
path: tools/data-handler/package.json
user: login: CyberJML
body: is there any alternatives? This is 3 years old publish and possibly not going to update anymore?
created_at: 2025-05-13T10:15:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -53,6 +53,7 @@
     "json-schema": "^0.4.0",
     "jsonschema": "^1.5.0",
     "mime-types": "^3.0.1",
+    "node-graphviz": "^0.1.1",
path: tools/data-handler/package.json
body: I did not find any wasm or native alternatives. The package we use is based on https://hpcc-systems.github.io/hpcc-js-wasm/getting-started.html . However, that package includes a ton of other deps. Graphviz itself does not update often: https://github.com/graphp/graphviz . But you're right, that package likely won't be updated. So we'd probably need to create our own graphviz webassembly build, if that happens. However, I feel like there's no reason to do that unless we run into issues
created_at: 2025-05-13T10:28:22Z
author_association: NONE
diff_hunk: @@ -133,17 +139,17 @@ namespace node_clingo
 
 // clang used on mac does not support
 // current_zone and zoned_time
-#if !defined(__clang__)
-        const auto now_point = std::chrono::system_clock::now();
-        const auto current_zone = std::chrono::current_zone();
-        const std::chrono::zoned_time zt{current_zone, now_point};
-        std::string today_str = std::format("{:%Y-%m-%d}", zt);
-#else
+#if USE_FORMAT_FALLBACK
         time_t now = time(nullptr);
         std::string today_str;
         std::stringstream ss;
         ss << std::put_time(std::localtime(&now), "%Y-%m-%d");
         today_str = ss.str();
+#else
+        const auto now_point = std::chrono::system_clock::now();
+        const auto current_zone = std::chrono::current_zone();
+        const std::chrono::zoned_time zt{current_zone, now_point};
+        std::string today_str = std::format("{:%Y-%m-%d}", zt);
path: tools/node-clingo/src/function_handlers.cc
body: Not possible
created_at: 2025-05-14T05:53:25Z
author_association: NONE
diff_hunk: @@ -133,17 +139,17 @@ namespace node_clingo
 
 // clang used on mac does not support
 // current_zone and zoned_time
-#if !defined(__clang__)
-        const auto now_point = std::chrono::system_clock::now();
-        const auto current_zone = std::chrono::current_zone();
-        const std::chrono::zoned_time zt{current_zone, now_point};
-        std::string today_str = std::format("{:%Y-%m-%d}", zt);
-#else
+#if USE_FORMAT_FALLBACK
         time_t now = time(nullptr);
         std::string today_str;
         std::stringstream ss;
         ss << std::put_time(std::localtime(&now), "%Y-%m-%d");
         today_str = ss.str();
+#else
+        const auto now_point = std::chrono::system_clock::now();
+        const auto current_zone = std::chrono::current_zone();
+        const std::chrono::zoned_time zt{current_zone, now_point};
+        std::string today_str = std::format("{:%Y-%m-%d}", zt);
path: tools/node-clingo/src/function_handlers.cc
user: login: Fuzzbender
body: Why?
created_at: 2025-05-14T08:46:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -133,17 +139,17 @@ namespace node_clingo
 
 // clang used on mac does not support
 // current_zone and zoned_time
-#if !defined(__clang__)
-        const auto now_point = std::chrono::system_clock::now();
-        const auto current_zone = std::chrono::current_zone();
-        const std::chrono::zoned_time zt{current_zone, now_point};
-        std::string today_str = std::format("{:%Y-%m-%d}", zt);
-#else
+#if USE_FORMAT_FALLBACK
         time_t now = time(nullptr);
         std::string today_str;
         std::stringstream ss;
         ss << std::put_time(std::localtime(&now), "%Y-%m-%d");
         today_str = ss.str();
path: tools/node-clingo/src/function_handlers.cc
user: login: Fuzzbender
body: Even though this hasn't changed; could this be: 
```
std::string today_str = ss.str();
```
And potentially `const` and potentially `auto` instead of provided type.

created_at: 2025-05-14T08:49:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,34 @@
+{{#*inline "subgraph"}}
+subgraph cluster{{this.key}} {
+  graph [{{#each attrs}}{{this.attr}}="{{{this.value}}}" {{/each}}]
+{{#each edges}}
+  "{{this.[source]}}" -> "{{this.destination}}" [{{#each attrs}}{{this.attr}}="{{{this.value}}}" {{/each}}]
+{{/each}}
+{{#each children}}
+{{#if this.isNode}}
+  "{{this.cardKey}}" [{{#each attrs}}{{this.attr}}="{{{this.value}}}" {{/each}}]
+{{/if}}
+{{#if this.isGraph}}
+  {{> subgraph}}
+{{/if}}
+{{/each}}
+}
+{{/inline}}
+{{#each results}}
path: resources/graphvizReport/index.adoc.hbs
user: login: Fuzzbender
body: General comment on handlebars code. 

Shouldn't it be still indented if there are nested statements?
Ie.  each-within-each should be:
```
{{#each results}}
  {{#each digraphs}}
  ...
  {{/each}}
  ...
{{/each}}
```

It makes reading the HB-code a bit easier.
created_at: 2025-05-14T08:59:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,141 @@
+% model
+% note: not escapted
path: resources/graphvizReport/model.lp
user: login: Fuzzbender
body: typo --> escaped
created_at: 2025-05-14T09:00:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -133,17 +139,17 @@ namespace node_clingo
 
 // clang used on mac does not support
 // current_zone and zoned_time
-#if !defined(__clang__)
-        const auto now_point = std::chrono::system_clock::now();
-        const auto current_zone = std::chrono::current_zone();
-        const std::chrono::zoned_time zt{current_zone, now_point};
-        std::string today_str = std::format("{:%Y-%m-%d}", zt);
-#else
+#if USE_FORMAT_FALLBACK
         time_t now = time(nullptr);
         std::string today_str;
         std::stringstream ss;
         ss << std::put_time(std::localtime(&now), "%Y-%m-%d");
         today_str = ss.str();
+#else
+        const auto now_point = std::chrono::system_clock::now();
+        const auto current_zone = std::chrono::current_zone();
+        const std::chrono::zoned_time zt{current_zone, now_point};
+        std::string today_str = std::format("{:%Y-%m-%d}", zt);
path: tools/node-clingo/src/function_handlers.cc
body: I thought you meant, zoned_time, but yeah the string could be const auto
created_at: 2025-05-14T09:20:11Z
author_association: NONE
diff_hunk: @@ -133,17 +139,17 @@ namespace node_clingo
 
 // clang used on mac does not support
 // current_zone and zoned_time
-#if !defined(__clang__)
-        const auto now_point = std::chrono::system_clock::now();
-        const auto current_zone = std::chrono::current_zone();
-        const std::chrono::zoned_time zt{current_zone, now_point};
-        std::string today_str = std::format("{:%Y-%m-%d}", zt);
-#else
+#if USE_FORMAT_FALLBACK
         time_t now = time(nullptr);
         std::string today_str;
         std::stringstream ss;
         ss << std::put_time(std::localtime(&now), "%Y-%m-%d");
         today_str = ss.str();
path: tools/node-clingo/src/function_handlers.cc
body: Done
created_at: 2025-05-14T09:28:45Z
author_association: NONE
diff_hunk: @@ -133,17 +139,17 @@ namespace node_clingo
 
 // clang used on mac does not support
 // current_zone and zoned_time
-#if !defined(__clang__)
-        const auto now_point = std::chrono::system_clock::now();
-        const auto current_zone = std::chrono::current_zone();
-        const std::chrono::zoned_time zt{current_zone, now_point};
-        std::string today_str = std::format("{:%Y-%m-%d}", zt);
-#else
+#if USE_FORMAT_FALLBACK
         time_t now = time(nullptr);
         std::string today_str;
         std::stringstream ss;
         ss << std::put_time(std::localtime(&now), "%Y-%m-%d");
         today_str = ss.str();
+#else
+        const auto now_point = std::chrono::system_clock::now();
+        const auto current_zone = std::chrono::current_zone();
+        const std::chrono::zoned_time zt{current_zone, now_point};
+        std::string today_str = std::format("{:%Y-%m-%d}", zt);
path: tools/node-clingo/src/function_handlers.cc
body: Changed it
created_at: 2025-05-14T09:29:01Z
author_association: NONE
diff_hunk: @@ -0,0 +1,141 @@
+% model
+% note: not escapted
path: resources/graphvizReport/model.lp
body: Done
created_at: 2025-05-14T09:29:18Z
author_association: NONE
diff_hunk: @@ -0,0 +1,34 @@
+{{#*inline "subgraph"}}
+subgraph cluster{{this.key}} {
+  graph [{{#each attrs}}{{this.attr}}="{{{this.value}}}" {{/each}}]
+{{#each edges}}
+  "{{this.[source]}}" -> "{{this.destination}}" [{{#each attrs}}{{this.attr}}="{{{this.value}}}" {{/each}}]
+{{/each}}
+{{#each children}}
+{{#if this.isNode}}
+  "{{this.cardKey}}" [{{#each attrs}}{{this.attr}}="{{{this.value}}}" {{/each}}]
+{{/if}}
+{{#if this.isGraph}}
+  {{> subgraph}}
+{{/if}}
+{{/each}}
+}
+{{/inline}}
+{{#each results}}
path: resources/graphvizReport/index.adoc.hbs
body: They probably were done like that, because then there would be extra whitespace. But yes it would be more readable, I'll add indentation
created_at: 2025-05-14T09:31:21Z
author_association: NONE
diff_hunk: @@ -25,19 +25,17 @@ const removeSvgWidthAndHeight = (node: Element) => {
 };
 
 /**
- * Sanitize an SVG Buffer and return a base64-encoded string
- * @param buffer - SVG content as a Buffer
+ * Sanitize an SVG string and return a base64-encoded string
+ * @param svg - SVG content as a string
  * @returns base64-encoded sanitized SVG string
  */
-export function sanitizeSvgBase64(buffer: Buffer): string {
+export function sanitizeSvgBase64(svg: string): string {
   const DOMPurify = createDOMPurify(window as unknown as WindowLike);
 
-  const dirty = buffer.toString('utf-8');
-
   DOMPurify.setConfig({ USE_PROFILES: { svg: true } });
   DOMPurify.addHook('afterSanitizeAttributes', removeSvgWidthAndHeight);
 
-  let cleaned = DOMPurify.sanitize(dirty);
+  let cleaned = DOMPurify.sanitize(svg);
 
   // Remove link titles, quick fix for Clingraph/Graphviz generated titles for links that are quite strange
   cleaned = cleaned.replace(/\s*xlink:title=(["']).*?\1/g, '');
path: tools/data-handler/src/utils/sanitize-svg.ts
user: login: henryhaverinen
body: I wonder if this workaround is still needed when we're not using Clingraph?
created_at: 2025-05-14T20:12:50Z
author_association: CONTRIBUTOR
path: resources/graphvizReport/model.lp
user: login: henryhaverinen
body: Since this is really a report, the name of this file could be query.lp or query.lp.hbs, similarly to regular reports. 
created_at: 2025-05-14T20:17:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -50,8 +50,8 @@ export class CommandManager {
     this.project = new Project(path);
     this.validateCmd = Validate.getInstance();
 
-    this.showCmd = new Show(this.project);
     this.calculateCmd = new Calculate(this.project);
+    this.showCmd = new Show(this.project, this.calculateCmd);
path: tools/data-handler/src/command-manager.ts
user: login: Fuzzbender
body: Just an idea for the future, to avoid problems passing commands to each others. No need to make any changes in this PR; just something to think about...

It might make sense to separate "calculation engine" and "calculation command". And the `project` would own "calculation engine". This would avoid passing command instances to other commands and only pass `project` to them [Yes, there's also `showCmd` and `createCmd` being passed in few cases, but `calculateCmd` is basically passed to all commands now]
created_at: 2025-05-15T08:04:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -342,30 +357,18 @@ export class Calculate {
   }
 
   //
-  private async run(
-    data: {
-      query?: string;
-    },
-    argMode: 'graph' | 'query' = 'query',
-  ): Promise<string[]> {
-    if (!data.query) {
-      throw new Error('Must provide query to run a clingo program');
-    }
-
+  private async run(query: string): Promise<string[]> {
     const res = await Calculate.mutex.runExclusive(async () => {
       // For queries, use both base and queryLanguage
-      const basePrograms =
-        argMode === 'query'
-          ? [BASE_PROGRAM_KEY, QUERY_LANGUAGE_KEY]
-          : BASE_PROGRAM_KEY;
+      const basePrograms = [BASE_PROGRAM_KEY, QUERY_LANGUAGE_KEY];
 
       // Then solve with the program - need to pass the program as parameter
-      return solve(data.query as string, basePrograms);
+      return solve(query as string, basePrograms);
path: tools/data-handler/src/commands/calculate.ts
user: login: Fuzzbender
body: `query` is already a `string`. Is the `as string` needed here?
created_at: 2025-05-15T08:08:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -538,70 +541,28 @@ export class Calculate {
    * @param timeout Maximum amount of milliseconds clingraph is allowed to run
    * @returns a base64 encoded image as a string
    */
-  public async runGraph(
-    data: { query?: string; file?: string },
-    timeout?: number,
-  ) {
-    const clingoOutput = await this.run(data, 'graph');
-
-    // unlikely we ever get a collision
-    const randomId = generateRandomString(36, 20);
-
-    const clingGraphArgs = [
-      '--out=render',
-      '--format=svg',
-      '--type=digraph',
-      `--name-format=${randomId}`,
-      `--dir=${this.project.paths.tempFolder}`,
-    ];
-
-    // python is only used for windows
-    const pythonArgs = [
-      '-c',
-      `from clingraph import main; import sys; sys.argv = ["sys.argv[0]", ${clingGraphArgs
-        .map((arg) => `'${arg.replace(/\\/g, '\\\\')}'`)
-        .join(',')}]; sys.exit(main())`,
-    ];
-
-    const clingraph = spawnSync(this.pythonBinary, pythonArgs, {
-      encoding: 'utf8',
-      // below looks like it could be replaced by just joining dots, but
-      // it breaks it because elements contain newlines, which must be
-      // replaced by dots
-      input: clingoOutput.join('\n').replaceAll('\n', '.') + '.',
-      timeout,
-      maxBuffer: 1024 * 1024 * 100,
+  public async runGraph(model: string, view: string) {
+    // Let's run the clingraph query
+    const result = await generateReportContent({
+      calculate: this,
+      contentTemplate: reportContentTemplate,
+      queryTemplate: reportQueryTemplate,
+      options: {
+        model: model,
+        view: view,
+      },
+      graph: true,
     });
-
-    if (clingraph.status !== 0) {
-      throw new Error(`Graph: Failed to run clingraph ${clingraph.stderr}`);
-    }
-
-    const filePath = join(this.project.paths.tempFolder, randomId);
-
-    let fileData;
-    try {
-      fileData = await readFile(filePath + '.svg');
-    } catch (e) {
-      throw new Error(
-        `Graph: Failed to read image file after generating graph: ${e}`,
-      );
-    } finally {
-      await rm(filePath, { force: true });
-      await rm(filePath + '.svg', { force: true });
-    }
-
-    // Sanitizing SVG before returning it
-    return sanitizeSvgBase64(fileData);
+    return sanitizeSvgBase64(await graphviz.dot(result, 'svg'));
   }
 
   /**
    * Runs a logic program using clingo.
    * @param filePath Path to a query file to be run in relation to current working directory
path: tools/data-handler/src/commands/calculate.ts
user: login: Fuzzbender
body: The name of the param is wrong (and was already earlier)
created_at: 2025-05-15T08:14:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -28,15 +28,23 @@ import type {
 } from '../interfaces/macros.js';
 import type BaseMacro from './base-macro.js';
 import TaskQueue from './task-queue.js';
-
+import type { Calculate } from '../commands/index.js';
 const CURLY_LEFT = '&#123;';
 const CURLY_RIGHT = '&#125;';
 
-export interface MacroConstructor {
+export interface SimpleMacroConstructor {
path: tools/data-handler/src/macros/index.ts
user: login: Fuzzbender
body: Add comments to the exported interfaces what they are and what they are for
created_at: 2025-05-15T08:20:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,79 @@
+/**
+    Cyberismo
+    Copyright © Cyberismo Ltd and contributors 2024
+
+    This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License version 3 as published by the Free Software Foundation.
+
+    This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+    You should have received a copy of the GNU Affero General Public
+    License along with this program.  If not, see <https://www.gnu.org/licenses/>.
+*/
+import Handlebars from 'handlebars';
+import type { Calculate } from '../commands/index.js';
+import { registerEmptyMacros } from '../macros/index.js';
+
+// Parameters for the core generation function
+interface GenerateReportContentParams {
+  calculate: Calculate;
+  contentTemplate: string;
+  queryTemplate: string;
+  options: Record<string, string>;
+  graph?: boolean;
+}
+
+/**
+ * Generates report content based on a report definition, project context, and options,
+ * by utilizing the ReportRunner class.
+ *
+ * @param params - The parameters for report generation.
+ * @returns The rendered report content as a string.
+ * @throws Error if the report definition is invalid, schema validation fails, or logic program execution fails.
+ */
+export async function generateReportContent(
+  params: GenerateReportContentParams,
+): Promise<string> {
+  const {
+    calculate,
+    contentTemplate,
+    queryTemplate,
+    graph,
+    options, // Destructure options
+  } = params;
+
+  const handlebars = Handlebars.create();
+
+  // Compile and execute the query template
+  const template = handlebars.compile(queryTemplate, {
+    strict: true,
+  });
+
+  const result = await calculate.runLogicProgram(template(options));
+
+  if (result.error) {
+    throw new Error(result.error);
+  }
+  // register empty macros so that other macros aren't touched yet
+  registerEmptyMacros(handlebars);
+
+  if (graph) {
+    handlebars.registerHelper('formatAttributeValue', formatAttributeValue);
+  }
+
+  return handlebars.compile(contentTemplate)({
+    ...options,
+    ...result,
+  });
+}
+
+export function formatAttributeValue(value?: string) {
path: tools/data-handler/src/utils/report.ts
user: login: Fuzzbender
body: Add comment block to this exported function. 
Functions should be in alphabetical order in the file.
created_at: 2025-05-15T08:23:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -25,19 +25,17 @@ const removeSvgWidthAndHeight = (node: Element) => {
 };
 
 /**
- * Sanitize an SVG Buffer and return a base64-encoded string
- * @param buffer - SVG content as a Buffer
+ * Sanitize an SVG string and return a base64-encoded string
+ * @param svg - SVG content as a string
  * @returns base64-encoded sanitized SVG string
  */
-export function sanitizeSvgBase64(buffer: Buffer): string {
+export function sanitizeSvgBase64(svg: string): string {
   const DOMPurify = createDOMPurify(window as unknown as WindowLike);
 
-  const dirty = buffer.toString('utf-8');
-
   DOMPurify.setConfig({ USE_PROFILES: { svg: true } });
   DOMPurify.addHook('afterSanitizeAttributes', removeSvgWidthAndHeight);
 
-  let cleaned = DOMPurify.sanitize(dirty);
+  let cleaned = DOMPurify.sanitize(svg);
 
   // Remove link titles, quick fix for Clingraph/Graphviz generated titles for links that are quite strange
   cleaned = cleaned.replace(/\s*xlink:title=(["']).*?\1/g, '');
path: tools/data-handler/src/utils/sanitize-svg.ts
body: the width and height is still there, but I did not find any xlink:title 's from the generated svg. @CyberJML did you take a look at https://www.npmjs.com/package/sanitize-html ? Seems like very similar to dompurify, but with it we could get rid of jsdom, which is a large portion of our node_modules.
created_at: 2025-05-15T11:46:00Z
author_association: NONE
path: resources/graphvizReport/model.lp
body: Yes
created_at: 2025-05-16T09:40:34Z
author_association: NONE
diff_hunk: @@ -50,8 +50,8 @@ export class CommandManager {
     this.project = new Project(path);
     this.validateCmd = Validate.getInstance();
 
-    this.showCmd = new Show(this.project);
     this.calculateCmd = new Calculate(this.project);
+    this.showCmd = new Show(this.project, this.calculateCmd);
path: tools/data-handler/src/command-manager.ts
body: Yes the current calculateCmd is kind of a calculate engine that is also used as a command. Since we have this class based approach, a proper IoC container would be nice, because now I feel like we have to avoid dependencies as much as possible, because we have to pass them manually. For example, since the report macro now requires CalculateCmd, it is passed to all macros. If some other macro needs validate, then we need to again change the constructor for all macros or make a separate function / class just for creating a macro class
created_at: 2025-05-16T10:08:11Z
author_association: NONE
diff_hunk: @@ -50,8 +50,8 @@ export class CommandManager {
     this.project = new Project(path);
     this.validateCmd = Validate.getInstance();
 
-    this.showCmd = new Show(this.project);
     this.calculateCmd = new Calculate(this.project);
+    this.showCmd = new Show(this.project, this.calculateCmd);
path: tools/data-handler/src/command-manager.ts
user: login: Fuzzbender
body: I'll create a ticket about it. While at it; validation command should be split to "command" and "validation engine" parts (similar reasons as Calculation). 
created_at: 2025-05-21T06:01:15Z
author_association: CONTRIBUTOR

PR comments:
user: login: ghost
created_at: 2025-05-12T13:39:39Z
body: Feel free to review code, all functionality that remains to be done should happen in the logic programs
author_association: NONE
user: login: henryhaverinen
created_at: 2025-05-15T09:32:21Z
body: The branch seems to work great and I think the performane is a bit better as well.

The only observation I have is that the titles in some places are aligned too much to the left, like this in the card demo_7s6siekw of cyberismo-demo:

<img width="245" alt="image" src="https://github.com/user-attachments/assets/52547571-5b0a-4f94-82c5-ed9c0a5eb71d" />

author_association: CONTRIBUTOR