diff --git a/cypress/e2e/ingestion/files-ui.cy.ts b/cypress/e2e/ingestion/files-ui.cy.ts index 780c6dd7..2bb1d291 100644 --- a/cypress/e2e/ingestion/files-ui.cy.ts +++ b/cypress/e2e/ingestion/files-ui.cy.ts @@ -76,6 +76,45 @@ describe('File Handling and Processing', () => { cy.get(`[id="file-${nodeFile}-field-2"]`).should('have.value', 'seq'); }); + it('renders quote-bearing file names as text and keeps file controls functional', () => { + const unsafeFileName = 'Nodes" autofocus onfocus="window.__mtInjected=true".csv'; + const csvContents = '_id,seq\nA,AAAA\nB,CCCC\n'; + + cy.get('#fileDropRef').then(($input) => { + const input = $input.get(0) as HTMLInputElement; + const win = input.ownerDocument.defaultView as Window & typeof globalThis & { __mtInjected?: boolean }; + const data = new win.DataTransfer(); + const file = new win.File([csvContents], unsafeFileName, { type: 'text/csv' }); + + win.__mtInjected = false; + data.items.add(file); + input.files = data.files; + input.dispatchEvent(new win.Event('change', { bubbles: true })); + }); + + cy.contains('#file-table .file-table-row', unsafeFileName, { timeout: 20000 }) + .as('unsafeFileRow') + .should('be.visible'); + cy.get('@unsafeFileRow') + .find('.file-name > span.p-1') + .should('have.text', unsafeFileName); + cy.get('@unsafeFileRow').find('[autofocus]').should('not.exist'); + cy.get('@unsafeFileRow').find('[onfocus]').should('not.exist'); + cy.get('@unsafeFileRow').find('input[type="radio"]').should('have.length', 7); + cy.window().its('__mtInjected').should('equal', false); + + cy.get('@unsafeFileRow').find('input[data-type="node"]').should('be.checked'); + cy.get('@unsafeFileRow').find('input[data-type="link"]').click({ force: true }); + cy.get('@unsafeFileRow').find('select').eq(0).select('seq', { force: true }); + + cy.window().then((win: any) => { + const file = win.commonService.session.files.find((sessionFile: any) => sessionFile.name === unsafeFileName); + expect(file.format).to.equal('link'); + expect(file.field1).to.equal('seq'); + expect(file.field2).to.equal('seq'); + }); + }); + it('uploads via the welcome overlay input and launches without hitting the error boundary', () => { cy.attach_files('#fileDropRef', [nodeFile, linkFile]); diff --git a/cypress/e2e/ingestion/graphml-import-export.cy.ts b/cypress/e2e/ingestion/graphml-import-export.cy.ts new file mode 100644 index 00000000..add3e4a5 --- /dev/null +++ b/cypress/e2e/ingestion/graphml-import-export.cy.ts @@ -0,0 +1,523 @@ +/// + +import { byTestId, testIds } from '../../support/selectors'; +import { installSaveAsCaptureHook } from '../../support/journey-helpers'; + +describe('GraphML import/export', () => { + const graphMLA = 'GraphML_Provenance_A.graphml'; + const graphMLB = 'GraphML_Provenance_B.graphml'; + const unsupportedGraphML = 'GraphML_Unsupported_Features.graphml'; + const staticGEXF = 'GEXF_Static_Network.gexf'; + const optionalGEXF = 'GEXF_Optional_Metadata.gexf'; + const staticXGMML = 'XGMML_Static_Network.xgmml'; + const staticCX2 = 'CX2_Static_Network.cx2'; + const staticDOT = 'DOT_Static_Network.dot'; + const staticGML = 'GML_Static_Network.gml'; + + beforeEach(() => { + cy.visit('/?skipEula=1&skipDemoSession=1'); + cy.get('#fileDropRef', { timeout: 15000 }).should('exist'); + }); + + it('imports GraphML files with filename-scoped edge provenance and exports GraphML', () => { + cy.attach_files('#fileDropRef', [graphMLA, graphMLB], ['application/graphml+xml', 'application/graphml+xml']); + + [graphMLA, graphMLB].forEach((fileName) => { + cy.contains('#file-table .file-table-row', fileName, { timeout: 20000 }) + .find('input[data-type="network"]') + .should('be.checked'); + }); + + cy.get('#launch').should('not.be.disabled').click({ force: true }); + cy.window({ timeout: 30000 }) + .its('commonService.session.network.isFullyLoaded') + .should('be.true'); + + cy.window().then((win: any) => { + const session = win.commonService.session; + expect(session.data.nodes, 'imported nodes').to.have.length(6); + expect(session.data.links, 'imported links').to.have.length(5); + expect(session.data.nodeFields).to.include.members([ + 'MicrobeTrace Networks', + 'GraphML Graph ID', + 'GraphML File', + 'GraphML Node ID', + ]); + expect(session.data.linkFields).to.include.members([ + 'Has Distance', + 'Distance Origin', + 'GraphML Edge ID', + ]); + expect(session.data.nodeFields).not.to.include('graphml_graph_id'); + expect(session.data.linkFields).not.to.include('graphml_edge_id'); + + const expectedOriginCounts = { + [`${graphMLA}-Contact.csv`]: 1, + [`${graphMLA}-Distance.csv`]: 1, + [`${graphMLB}-Contact.csv`]: 1, + [`${graphMLB}-Distance.csv`]: 1, + 'Duo-Link': 1, + }; + + expect(win.commonService.createLinkColorMap()).to.deep.equal(expectedOriginCounts); + + const contactOnlyLink = session.data.links.find((link: any) => + link.source === 'A1' && link.target === 'A2', + ); + expect(contactOnlyLink.hasDistance).to.equal(false); + expect(contactOnlyLink['Has Distance']).to.equal(false); + expect(contactOnlyLink.origin).to.deep.equal([`${graphMLA}-Contact.csv`]); + expect(contactOnlyLink.distanceOrigin).to.be.oneOf([undefined, null]); + expect(contactOnlyLink.graphml_edge_origin).to.equal('Contact.csv'); + + const duoLink = session.data.links.find((link: any) => + link.source === 'A1' && link.target === 'A3', + ); + expect(duoLink.origin).to.deep.equal([`${graphMLA}-Contact.csv`, `${graphMLA}-Distance.csv`]); + expect(duoLink._originAll).to.deep.equal([`${graphMLA}-Contact.csv`, `${graphMLA}-Distance.csv`]); + expect(duoLink.distanceOrigin).to.equal(`${graphMLA}-Distance.csv`); + expect(duoLink.distanceOrigins).to.deep.equal([`${graphMLA}-Distance.csv`]); + expect(duoLink['GraphML Edge ID']).to.equal(duoLink.graphml_edge_id); + expect(duoLink['Distance Origin']).to.equal(`${graphMLA}-Distance.csv`); + }); + + installSaveAsCaptureHook(); + cy.get(byTestId(testIds.appFileMenuButton)).click({ force: true }); + cy.get('[data-testid="app-file-menu-export-graphml"]').click({ force: true }); + + cy.window({ timeout: 30000 }).should((win: any) => { + const captured = (win.__mtCapturedDownloads || []) + .filter((download: any) => download.fileName === 'microbetrace.graphml'); + expect(captured.length, 'captured GraphML export').to.be.greaterThan(0); + + const dataUrl = captured[captured.length - 1].dataUrl; + const xml = atob(String(dataUrl).split(',').pop() || ''); + expect(xml).to.contain(''); + expect(xml).to.contain(' { + cy.attach_file('#fileDropRef', unsupportedGraphML, 'application/graphml+xml'); + + cy.contains('#file-table .file-table-row', unsupportedGraphML, { timeout: 20000 }) + .find('input[data-type="network"]') + .should('be.checked'); + + cy.get('#launch').should('not.be.disabled').click({ force: true }); + + cy.contains('.p-dialog-title', 'Network Import Warnings', { timeout: 30000 }) + .should('be.visible') + .parents('.p-dialog') + .as('graphMLWarningDialog'); + + cy.get('@graphMLWarningDialog') + .should('contain.text', `${unsupportedGraphML}: Nested GraphML graph elements were ignored.`) + .and('contain.text', `${unsupportedGraphML}: Nested graph under node "U1" was ignored.`) + .and('contain.text', `${unsupportedGraphML}: 1 GraphML port element(s) were ignored.`); + + cy.get('@graphMLWarningDialog') + .contains('button', 'Confirm') + .should('be.visible') + .click({ force: true }); + + cy.contains('.p-dialog-title', 'Network Import Warnings').should('not.exist'); + + cy.window({ timeout: 30000 }) + .its('commonService.session.network.isFullyLoaded') + .should('be.true'); + + cy.window().then((win: any) => { + const links = win.commonService.session.data.links; + expect(links, 'imported links').to.have.length(1); + expect(links[0].origin).to.deep.equal([`${unsupportedGraphML}-Unsupported.csv`]); + }); + }); + + it('imports static GEXF topology, typed attributes, direction, and weights as Network files', () => { + cy.attach_file('#fileDropRef', staticGEXF, 'application/gexf+xml'); + + cy.contains('#file-table .file-table-row', staticGEXF, { timeout: 20000 }) + .find('input[data-type="network"]') + .should('be.checked'); + + cy.get('#launch').should('not.be.disabled').click({ force: true }); + cy.window({ timeout: 30000 }) + .its('commonService.session.network.isFullyLoaded') + .should('be.true'); + + cy.window().then((win: any) => { + const session = win.commonService.session; + expect(session.data.nodes, 'imported GEXF nodes').to.have.length(3); + expect(session.data.links, 'imported GEXF links').to.have.length(2); + + const metadata = session.files.find((file: any) => file.name === staticGEXF); + expect(metadata.format).to.equal('network'); + + const beta = session.data.nodes.find((node: any) => node._id === 'G2'); + expect(beta.label).to.equal('Beta'); + expect(beta.sample_type).to.equal('contact'); + expect(beta.viral_load).to.equal(3.75); + expect(beta.active).to.equal(false); + expect(beta.gexf_node_id).to.equal('G2'); + expect(beta.gexf_file).to.equal(staticGEXF); + + const directedLink = session.data.links.find((link: any) => + link.source === 'G1' && link.target === 'G2', + ); + expect(directedLink.directed).to.equal(true); + expect(directedLink.weight).to.equal(4.5); + expect(directedLink.distance).to.equal(4.5); + expect(directedLink.hasDistance).to.equal(true); + expect(directedLink.distanceOrigin).to.equal(staticGEXF); + expect(directedLink.kind).to.equal('contact'); + expect(directedLink.confirmed).to.equal(true); + expect(directedLink.gexf_edge_id).to.equal('e1'); + + const defaultUndirectedLink = session.data.links.find((link: any) => + link.source === 'G2' && link.target === 'G3', + ); + expect(defaultUndirectedLink.directed).to.equal(false); + expect(defaultUndirectedLink.distance).to.equal(7); + expect(defaultUndirectedLink.confirmed).to.equal(false); + }); + }); + + it('warns for optional GEXF dynamic, hierarchy, and visualization metadata while preserving fields', () => { + cy.attach_file('#fileDropRef', optionalGEXF, 'application/gexf+xml'); + + cy.contains('#file-table .file-table-row', optionalGEXF, { timeout: 20000 }) + .find('input[data-type="network"]') + .should('be.checked'); + + cy.get('#launch').should('not.be.disabled').click({ force: true }); + + cy.contains('.p-dialog-title', 'Network Import Warnings', { timeout: 30000 }) + .should('be.visible') + .parents('.p-dialog') + .as('networkWarningDialog'); + + cy.get('@networkWarningDialog') + .should('contain.text', `${optionalGEXF}: GEXF dynamic timing metadata was imported as data fields`) + .and('contain.text', `${optionalGEXF}: GEXF visualization metadata was imported as data fields`) + .and('contain.text', `${optionalGEXF}: GEXF hierarchy metadata was imported as data fields`); + + cy.get('@networkWarningDialog') + .contains('button', 'Confirm') + .should('be.visible') + .click({ force: true }); + + cy.window({ timeout: 30000 }) + .its('commonService.session.network.isFullyLoaded') + .should('be.true'); + + cy.window().then((win: any) => { + const session = win.commonService.session; + expect(session.data.nodes, 'imported GEXF metadata nodes').to.have.length(3); + expect(session.data.links, 'imported GEXF metadata links').to.have.length(1); + + const child = session.data.nodes.find((node: any) => node._id === 'Child'); + expect(child.gexf_parent_id).to.equal('Parent'); + expect(child.gexf_start).to.equal('2026-01-01'); + expect(child.gexf_end).to.equal('2026-02-01'); + expect(child.gexf_spells).to.deep.equal([{ start: '2026-01-01', end: '2026-02-01' }]); + expect(child.gexf_dynamic_attvalues[0]).to.include({ + field: 'state', + value: 'infectious', + start: '2026-01-05', + end: '2026-01-20', + }); + expect(child.gexf_viz_x).to.equal(10); + expect(child.gexf_viz_y).to.equal(20); + expect(child.gexf_viz_size).to.equal(3.5); + expect(child.gexf_viz_shape).to.equal('diamond'); + + const link = session.data.links[0]; + expect(link.directed).to.equal(true); + expect(link.source).to.equal('Child'); + expect(link.target).to.equal('Random'); + expect(link.gexf_start).to.equal('2026-01-10'); + expect(link.gexf_spells).to.deep.equal([{ start: '2026-01-10', end: '2026-01-30' }]); + }); + }); + + it('imports XGMML topology, attributes, direction, coordinates, and weights as Network files', () => { + cy.attach_file('#fileDropRef', staticXGMML, 'application/xgmml+xml'); + + cy.contains('#file-table .file-table-row', staticXGMML, { timeout: 20000 }) + .find('input[data-type="network"]') + .should('be.checked'); + + cy.get('#launch').should('not.be.disabled').click({ force: true }); + cy.window({ timeout: 30000 }) + .its('commonService.session.network.isFullyLoaded') + .should('be.true'); + + cy.window().then((win: any) => { + const session = win.commonService.session; + expect(session.data.nodes, 'imported XGMML nodes').to.have.length(3); + expect(session.data.links, 'imported XGMML links').to.have.length(2); + + const metadata = session.files.find((file: any) => file.name === staticXGMML); + expect(metadata.format).to.equal('network'); + + const alpha = session.data.nodes.find((node: any) => node._id === 'Alpha'); + expect(alpha.label).to.equal('Alpha'); + expect(alpha.sample_type).to.equal('case'); + expect(alpha.viral_load).to.equal(2.5); + expect(alpha.active).to.equal(true); + expect(alpha.xgmml_node_id).to.equal('1'); + expect(alpha.xgmml_graph_id).to.equal('ContactNetwork'); + expect(alpha.xgmml_graph_label).to.equal('XGMML fixture'); + expect(alpha.xgmml_graph_description).to.equal('Static XGMML import fixture'); + + const beta = session.data.nodes.find((node: any) => node._id === 'Beta'); + expect(beta.sample_type).to.equal('contact'); + expect(beta.active).to.equal(false); + expect(beta.xgmml_x).to.equal(320); + expect(beta.xgmml_y).to.equal(210); + expect(beta.xgmml_graphics_fill).to.equal('#3366ff'); + + const contactLink = session.data.links.find((link: any) => + link.source === 'Alpha' && link.target === 'Beta', + ); + expect(contactLink.directed).to.equal(false); + expect(contactLink.label).to.equal('Alpha contact Beta'); + expect(contactLink.interaction).to.equal('contact'); + expect(contactLink.confirmed).to.equal(true); + expect(contactLink.weight).to.equal(4.5); + expect(contactLink.distance).to.equal(4.5); + expect(contactLink.hasDistance).to.equal(true); + expect(contactLink.origin).to.deep.equal([`${staticXGMML}-Contact.csv`]); + expect(contactLink.distanceOrigin).to.equal(`${staticXGMML}-Contact.csv`); + expect(contactLink.xgmml_edge_origin).to.equal('Contact.csv'); + expect(contactLink.xgmml_edge_id).to.equal('100'); + + const weightedLink = session.data.links.find((link: any) => + link.source === 'Beta' && link.target === 'Gamma', + ); + expect(weightedLink.directed).to.equal(true); + expect(weightedLink.interaction).to.equal('genetic'); + expect(weightedLink.confirmed).to.equal(false); + expect(weightedLink.weight).to.equal(7); + expect(weightedLink.distance).to.equal(7); + expect(weightedLink.origin).to.deep.equal([staticXGMML]); + expect(weightedLink.distanceOrigin).to.equal(staticXGMML); + }); + }); + + it('imports CX2 topology, aliases, defaults, fragments, coordinates, and provenance as Network files', () => { + cy.attach_file('#fileDropRef', staticCX2, 'application/json'); + + cy.contains('#file-table .file-table-row', staticCX2, { timeout: 20000 }) + .find('input[data-type="network"]') + .should('be.checked'); + + cy.get('#launch').should('not.be.disabled').click({ force: true }); + + cy.contains('.p-dialog-title', 'Network Import Warnings', { timeout: 30000 }) + .should('be.visible') + .parents('.p-dialog') + .as('networkWarningDialog'); + + cy.get('@networkWarningDialog') + .should('contain.text', `${staticCX2}: CX2 fragmented aspects were concatenated in file order.`); + + cy.get('@networkWarningDialog') + .contains('button', 'Confirm') + .should('be.visible') + .click({ force: true }); + + cy.window({ timeout: 30000 }) + .its('commonService.session.network.isFullyLoaded') + .should('be.true'); + + cy.window().then((win: any) => { + const session = win.commonService.session; + expect(session.data.nodes, 'imported CX2 nodes').to.have.length(3); + expect(session.data.links, 'imported CX2 links').to.have.length(2); + + const metadata = session.files.find((file: any) => file.name === staticCX2); + expect(metadata.format).to.equal('network'); + + const beta = session.data.nodes.find((node: any) => node._id === 'Beta'); + expect(beta.cx2_node_id).to.equal('2'); + expect(beta.sample_type).to.equal('contact'); + expect(beta.viral_load).to.equal(3.75); + expect(beta.active).to.equal(false); + expect(beta.cx2_x).to.equal(320); + expect(beta.cx2_y).to.equal(210); + expect(beta.cx2_bypass_NODE_BACKGROUND_COLOR).to.equal('#3366ff'); + + const gamma = session.data.nodes.find((node: any) => node._id === 'Gamma'); + expect(gamma.viral_load).to.equal(0); + expect(gamma.active).to.equal(true); + + const contactLink = session.data.links.find((link: any) => + link.source === 'Alpha' && link.target === 'Beta', + ); + expect(contactLink.cx2_edge_id).to.equal('100'); + expect(contactLink.kind).to.equal('contact'); + expect(contactLink.confirmed).to.equal(true); + expect(contactLink.distance).to.equal(4.5); + expect(contactLink.hasDistance).to.equal(true); + expect(contactLink.origin).to.deep.equal([`${staticCX2}-Contact.csv`]); + expect(contactLink.distanceOrigin).to.equal(`${staticCX2}-Contact.csv`); + + const distanceLink = session.data.links.find((link: any) => + link.source === 'Beta' && link.target === 'Gamma', + ); + expect(distanceLink.cx2_edge_id).to.equal('101'); + expect(distanceLink.distance).to.equal(7); + expect(distanceLink.origin).to.deep.equal([staticCX2]); + expect(distanceLink.distanceOrigin).to.equal(staticCX2); + }); + }); + + it('imports DOT topology, attributes, direction, strict edges, and subgraph shorthand as Network files', () => { + cy.attach_file('#fileDropRef', staticDOT, 'text/vnd.graphviz'); + + cy.contains('#file-table .file-table-row', staticDOT, { timeout: 20000 }) + .find('input[data-type="network"]') + .should('be.checked'); + + cy.get('#launch').should('not.be.disabled').click({ force: true }); + cy.window({ timeout: 30000 }) + .its('commonService.session.network.isFullyLoaded') + .should('be.true'); + + cy.window().then((win: any) => { + const session = win.commonService.session; + expect(session.data.nodes, 'imported DOT nodes').to.have.length(5); + expect(session.data.links, 'imported DOT links').to.have.length(4); + + const metadata = session.files.find((file: any) => file.name === staticDOT); + expect(metadata.format).to.equal('network'); + + const alpha = session.data.nodes.find((node: any) => node._id === 'Alpha'); + expect(alpha.label).to.equal('Case Alpha'); + expect(alpha.sample_type).to.equal('case'); + expect(alpha.viral_load).to.equal(2.5); + expect(alpha.active).to.equal(true); + expect(alpha.dot_graph_id).to.equal('ContactNetwork'); + expect(alpha.dot_graph_label).to.equal('DOT fixture'); + + const beta = session.data.nodes.find((node: any) => node._id === 'Beta'); + expect(beta.sample_type).to.equal('contact'); + expect(beta.active).to.equal(false); + + const gamma = session.data.nodes.find((node: any) => node._id === 'Gamma'); + expect(gamma.sample_type).to.equal('case'); + expect(gamma.active).to.equal(true); + + const delta = session.data.nodes.find((node: any) => node._id === 'Delta'); + expect(delta.dot_subgraphs).to.deep.equal(['cluster_followup']); + + const strictLink = session.data.links.find((link: any) => + link.source === 'Alpha' && link.target === 'Beta', + ); + expect(strictLink.directed).to.equal(true); + expect(strictLink.distance).to.equal(5); + expect(strictLink.hasDistance).to.equal(true); + expect(strictLink.origin).to.deep.equal([`${staticDOT}-Contact.csv`]); + expect(strictLink.distanceOrigin).to.equal(`${staticDOT}-Contact.csv`); + expect(strictLink.note).to.equal('strict replacement'); + expect(strictLink.dot_edge_origin).to.equal('Contact.csv'); + + const weightedLink = session.data.links.find((link: any) => + link.source === 'Beta' && link.target === 'Gamma', + ); + expect(weightedLink.kind).to.equal('genetic'); + expect(weightedLink.weight).to.equal(7); + expect(weightedLink.distance).to.equal(7); + expect(weightedLink.origin).to.deep.equal([staticDOT]); + + const shorthandTargets = session.data.links + .filter((link: any) => link.source === 'Beta' && ['Delta', 'Epsilon'].includes(link.target)) + .map((link: any) => link.target) + .sort(); + expect(shorthandTargets).to.deep.equal(['Delta', 'Epsilon']); + }); + }); + + it('imports GML topology, attributes, direction, and weights as Network files', () => { + cy.attach_file('#fileDropRef', staticGML, 'text/plain'); + + cy.contains('#file-table .file-table-row', staticGML, { timeout: 20000 }) + .find('input[data-type="network"]') + .should('be.checked'); + + cy.get('#launch').should('not.be.disabled').click({ force: true }); + cy.window({ timeout: 30000 }) + .its('commonService.session.network.isFullyLoaded') + .should('be.true'); + + cy.window().then((win: any) => { + const session = win.commonService.session; + expect(session.data.nodes, 'imported GML nodes').to.have.length(3); + expect(session.data.links, 'imported GML links').to.have.length(2); + + const metadata = session.files.find((file: any) => file.name === staticGML); + expect(metadata.format).to.equal('network'); + expect(session.data.linkFields).to.include('label'); + + const alpha = session.data.nodes.find((node: any) => node._id === 'Alpha'); + expect(alpha.label).to.equal('Alpha'); + expect(alpha.sample_type).to.equal('case'); + expect(alpha.viral_load).to.equal(2.5); + expect(alpha.status).to.equal('active'); + expect(alpha.gml_node_id).to.equal('1'); + expect(alpha.gml_graph_id).to.equal('ContactNetwork'); + expect(alpha.gml_graph_comment).to.equal('Static GML import fixture'); + expect(alpha.gml_graph_IsPlanar).to.equal(1); + + const contactLink = session.data.links.find((link: any) => + link.source === 'Alpha' && link.target === 'Beta', + ); + expect(contactLink.directed).to.equal(true); + expect(contactLink.label).to.equal('knows'); + expect(contactLink.type).to.equal('contact'); + expect(contactLink.confirmed).to.equal(true); + expect(contactLink.distance).to.equal(4.5); + expect(contactLink.hasDistance).to.equal(true); + expect(contactLink.origin).to.deep.equal([`${staticGML}-Contact.csv`]); + expect(contactLink.distanceOrigin).to.equal(`${staticGML}-Contact.csv`); + expect(contactLink.gml_edge_origin).to.equal('Contact.csv'); + + const weightedLink = session.data.links.find((link: any) => + link.source === 'Beta' && link.target === 'Gamma', + ); + expect(weightedLink.directed).to.equal(true); + expect(weightedLink.type).to.equal('genetic'); + expect(weightedLink.confirmed).to.equal(false); + expect(weightedLink.weight).to.equal(7); + expect(weightedLink.distance).to.equal(7); + expect(weightedLink.origin).to.deep.equal([staticGML]); + expect(weightedLink.distanceOrigin).to.equal(staticGML); + + const twoD = win.commonService.visuals.twoD; + expect(twoD, '2D view').to.exist; + }); + + cy.window({ timeout: 30000 }) + .its('commonService.visuals.twoD.cy') + .should('exist'); + + cy.window().then((win: any) => { + const twoD = win.commonService.visuals.twoD; + const cyInstance = twoD.cy; + const contactEdges = cyInstance.edges().filter((edge: any) => + edge.data('source') === 'Alpha' && edge.data('target') === 'Beta', + ); + expect(contactEdges.length, 'GML contact edge rendered in Cytoscape').to.be.greaterThan(0); + const contactEdge = contactEdges[0]; + expect(contactEdge.data('label')).to.equal(''); + + twoD.onLinkLabelVariableChange('label'); + expect(contactEdge.data('label')).to.equal('knows'); + }); + }); +}); diff --git a/cypress/fixtures/CX2_Static_Network.cx2 b/cypress/fixtures/CX2_Static_Network.cx2 new file mode 100644 index 00000000..bc7809cf --- /dev/null +++ b/cypress/fixtures/CX2_Static_Network.cx2 @@ -0,0 +1,73 @@ +[ + { + "CXVersion": "2.0", + "hasFragments": true + }, + { + "metaData": [ + { "name": "attributeDeclarations", "elementCount": 1 }, + { "name": "networkAttributes", "elementCount": 1 }, + { "name": "nodes", "elementCount": 3 }, + { "name": "edges", "elementCount": 2 } + ] + }, + { + "attributeDeclarations": [ + { + "networkAttributes": { + "name": { "d": "string" }, + "description": { "d": "string" } + }, + "nodes": { + "name": { "d": "string" }, + "sample_type": { "d": "string", "a": "st" }, + "viral_load": { "d": "double", "a": "vl", "v": 0 }, + "active": { "d": "boolean", "v": true } + }, + "edges": { + "kind": { "d": "string", "a": "k" }, + "distance": { "d": "double", "a": "d" }, + "confirmed": { "d": "boolean", "a": "c" }, + "origin": { "d": "list_of_string", "a": "o" } + } + } + ] + }, + { + "networkAttributes": [ + { + "name": "CX2 Fixture", + "description": "Static CX2 import fixture" + } + ] + }, + { + "nodes": [ + { "id": 1, "x": 150, "y": 75, "v": { "name": "Alpha", "st": "case", "vl": 2.5 } }, + { "id": 2, "x": 320, "y": 210, "v": { "name": "Beta", "st": "contact", "vl": 3.75, "active": false } }, + { "id": 3, "x": 500, "y": 300, "v": { "name": "Gamma", "st": "case" } } + ] + }, + { + "edges": [ + { "id": 100, "s": 1, "t": 2, "v": { "k": "contact", "d": 4.5, "c": true, "o": ["Contact.csv"] } } + ] + }, + { + "edges": [ + { "id": 101, "s": 2, "t": 3, "v": { "k": "distance", "d": 7, "c": false } } + ] + }, + { + "nodeBypasses": [ + { "id": 2, "v": { "NODE_BACKGROUND_COLOR": "#3366ff" } } + ] + }, + { + "status": [ + { + "success": true + } + ] + } +] diff --git a/cypress/fixtures/DOT_Static_Network.dot b/cypress/fixtures/DOT_Static_Network.dot new file mode 100644 index 00000000..f3a5f0aa --- /dev/null +++ b/cypress/fixtures/DOT_Static_Network.dot @@ -0,0 +1,21 @@ +// Static DOT import fixture +strict digraph ContactNetwork { + graph [label="DOT fixture", created="2026-07-01"]; + node [sample_type="case", active=true]; + edge [kind="contact"]; + + Alpha [label="Case Alpha", viral_load=2.5]; + Beta [sample_type="contact", active=false]; + Gamma; + + Alpha -> Beta [distance=4.5, confirmed=true, origin="Contact.csv"]; + Beta -> Gamma [weight=7, kind="genetic"]; + Alpha -> Beta [distance=5, note="strict replacement"]; + + subgraph cluster_followup { + Delta [sample_type="contact"]; + Epsilon; + } + + Beta -> { Delta Epsilon } [distance=2]; +} diff --git a/cypress/fixtures/GEXF_Optional_Metadata.gexf b/cypress/fixtures/GEXF_Optional_Metadata.gexf new file mode 100644 index 00000000..9af0fb31 --- /dev/null +++ b/cypress/fixtures/GEXF_Optional_Metadata.gexf @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/cypress/fixtures/GEXF_Static_Network.gexf b/cypress/fixtures/GEXF_Static_Network.gexf new file mode 100644 index 00000000..97895d22 --- /dev/null +++ b/cypress/fixtures/GEXF_Static_Network.gexf @@ -0,0 +1,57 @@ + + + + MicrobeTrace Cypress + Static GEXF import fixture + + + + + + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/cypress/fixtures/GML_Static_Network.gml b/cypress/fixtures/GML_Static_Network.gml new file mode 100644 index 00000000..a3b49be5 --- /dev/null +++ b/cypress/fixtures/GML_Static_Network.gml @@ -0,0 +1,44 @@ +# Static GML import fixture +graph [ + id "ContactNetwork" + comment "Static GML import fixture" + directed 1 + IsPlanar 1 + + node [ + id 1 + label "Alpha" + sample_type "case" + viral_load 2.5 + status "active" + ] + node [ + id 2 + label "Beta" + sample_type "contact" + viral_load 3.75 + status "monitored" + ] + node [ + id 3 + label "Gamma" + sample_type "case" + ] + + edge [ + source 1 + target 2 + label "knows" + type "contact" + distance 4.5 + confirmed true + origin "Contact.csv" + ] + edge [ + source 2 + target 3 + type "genetic" + weight 7 + confirmed false + ] +] diff --git a/cypress/fixtures/GraphML_Provenance_A.graphml b/cypress/fixtures/GraphML_Provenance_A.graphml new file mode 100644 index 00000000..a2638279 --- /dev/null +++ b/cypress/fixtures/GraphML_Provenance_A.graphml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + A1 + ["Nodes_A.csv"] + + + A2 + ["Nodes_A.csv"] + + + A3 + ["Nodes_A.csv"] + + + ["Contact.csv"] + ["Contact.csv"] + 0 + false + + + ["Distance.csv"] + ["Distance.csv"] + 5 + true + Distance.csv + ["Distance.csv"] + + + ["Contact.csv","Distance.csv"] + ["Contact.csv","Distance.csv"] + 6 + true + Distance.csv + ["Distance.csv"] + + + diff --git a/cypress/fixtures/GraphML_Provenance_B.graphml b/cypress/fixtures/GraphML_Provenance_B.graphml new file mode 100644 index 00000000..d97aeeb0 --- /dev/null +++ b/cypress/fixtures/GraphML_Provenance_B.graphml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + B1 + ["Nodes_B.csv"] + + + B2 + ["Nodes_B.csv"] + + + B3 + ["Nodes_B.csv"] + + + ["Contact.csv"] + ["Contact.csv"] + 0 + false + + + ["Distance.csv"] + ["Distance.csv"] + 5 + true + Distance.csv + ["Distance.csv"] + + + diff --git a/cypress/fixtures/GraphML_Unsupported_Features.graphml b/cypress/fixtures/GraphML_Unsupported_Features.graphml new file mode 100644 index 00000000..6857e0c6 --- /dev/null +++ b/cypress/fixtures/GraphML_Unsupported_Features.graphml @@ -0,0 +1,22 @@ + + + + + + + + U1 + + + + + + + U2 + + + ["Unsupported.csv"] + false + + + diff --git a/cypress/fixtures/XGMML_Static_Network.xgmml b/cypress/fixtures/XGMML_Static_Network.xgmml new file mode 100644 index 00000000..4eee8f06 --- /dev/null +++ b/cypress/fixtures/XGMML_Static_Network.xgmml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index 61c5f967..aa58fccb 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -5,7 +5,7 @@ import { byTestId, testIds } from './selectors'; type FileLoadOptions = { name: string; - datatype: 'link' | 'node' | 'matrix' | 'fasta' | 'newick' | 'MT/other'; + datatype: 'link' | 'node' | 'matrix' | 'fasta' | 'newick' | 'network' | 'MT/other'; field1?: string; field2?: string; field3?: string; @@ -44,6 +44,12 @@ export const getMimeTypeFromFilename = (name: string): string => { if (ext === 'csv') return 'text/csv'; if (ext === 'json' || ext === 'microbetrace' || ext === 'style') return 'application/json'; + if (ext === 'graphml') return 'application/graphml+xml'; + if (ext === 'gexf') return 'application/gexf+xml'; + if (ext === 'xgmml') return 'application/xgmml+xml'; + if (ext === 'cx' || ext === 'cx2') return 'application/json'; + if (ext === 'dot' || ext === 'gv') return 'text/vnd.graphviz'; + if (ext === 'gml') return 'text/plain'; if (ext === 'fasta' || ext === 'fas' || ext === 'fa' || ext === 'nwk' || ext === 'newick') { return 'text/plain'; } diff --git a/src/app/contactTraceCommonServices/common.service.ts b/src/app/contactTraceCommonServices/common.service.ts index 5caa8983..7f8f8a1d 100644 --- a/src/app/contactTraceCommonServices/common.service.ts +++ b/src/app/contactTraceCommonServices/common.service.ts @@ -4347,17 +4347,66 @@ align(params): Promise { * @param {string} title */ titleize(title: string): string { - const small = title.toLowerCase().replace(/_/g, " "); - if (small == "null") return "(Empty)"; - if (small == "id" || small == " id") return "ID"; - if (small == "tn93") return "TN93"; - if (small == "snps") return "SNPs"; - if (small == "2d network") return "2D Network"; - if (small == "3d network") return "3D Network"; - if (small == "geo map") return "Map"; - if (small == "nn") return "Nearest Neighbor"; - return title; - return small.replace(/(?:^|\s|-)\S/g, c => c.toUpperCase()); + const raw = `${title ?? ''}`; + const trimmed = raw.trim(); + const small = trimmed.toLowerCase().replace(/[_-]+/g, ' ').replace(/\s+/g, ' ').trim(); + const exactTitles: { [key: string]: string } = { + 'null': '(Empty)', + 'id': 'ID', + 'tn93': 'TN93', + 'snps': 'SNPs', + '2d network': '2D Network', + '3d network': '3D Network', + 'geo map': 'Map', + 'nn': 'Nearest Neighbor', + 'seq': 'Sequence', + 'hasdistance': 'Has Distance', + 'distanceorigin': 'Distance Origin', + 'distanceorigins': 'Distance Origins' + }; + + if (exactTitles[small]) return exactTitles[small]; + + // Preserve free-form labels that users already wrote with spaces/case. + if (!/[_-]/.test(trimmed)) { + return raw; + } + + // Network import provenance fields use raw snake_case keys internally; + // keep those keys stable while showing readable labels in the UI. + const words: { [key: string]: string } = { + 'id': 'ID', + 'ids': 'IDs', + 'mt': 'MicrobeTrace', + 'nn': 'Nearest Neighbor', + 'seq': 'Sequence', + 'snps': 'SNPs', + 'tn93': 'TN93', + 'graphml': 'GraphML', + 'graphmlgraph': 'GraphML Graph', + 'gexf': 'GEXF', + 'xgmml': 'XGMML', + 'cx2': 'CX2', + 'cyjs': 'Cytoscape JSON', + 'cx': 'CX', + 'dot': 'DOT', + 'gml': 'GML', + 'x': 'X', + 'y': 'Y', + 'vx': 'VX', + 'vy': 'VY' + }; + const readable = trimmed + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .replace(/[_-]+/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + + return readable + .split(' ') + .filter(part => part.length > 0) + .map(part => words[part.toLowerCase()] || part.charAt(0).toUpperCase() + part.slice(1)) + .join(' '); }; /** diff --git a/src/app/contactTraceCommonServices/graphml.service.ts b/src/app/contactTraceCommonServices/graphml.service.ts new file mode 100644 index 00000000..65e34fd6 --- /dev/null +++ b/src/app/contactTraceCommonServices/graphml.service.ts @@ -0,0 +1,3827 @@ +import { Injectable } from '@angular/core'; + +export interface GraphMLExportOptions { + graphId?: string; + generatedAt?: Date | string; +} + +export interface GraphMLExportResult { + contents: string; + nodeCount: number; + linkCount: number; + networkCount: number; +} + +export interface GraphMLImportOptions { + sourceName?: string; +} + +export type NetworkXmlFormat = 'graphml' | 'gexf' | 'xgmml' | 'cx2' | 'dot' | 'gml'; + +export interface GraphMLImportResult { + nodes: Record[]; + links: Record[]; + nodeFields: string[]; + linkFields: string[]; + graphIds: string[]; + warnings: string[]; +} + +export interface NetworkXmlImportResult extends GraphMLImportResult { + format: NetworkXmlFormat; + formatLabel: string; +} + +type GraphMLAttributeType = 'boolean' | 'int' | 'long' | 'double' | 'string'; +type GraphMLKeyDomain = 'all' | 'graph' | 'node' | 'edge'; +type GEXFAttributeClass = 'all' | 'graph' | 'node' | 'edge'; +type CX2AttributeType = GraphMLAttributeType + | 'integer' + | 'list_of_string' + | 'list_of_long' + | 'list_of_integer' + | 'list_of_double' + | 'list_of_boolean'; + +type CX2AspectDomain = 'networkAttributes' | 'nodes' | 'edges'; + +interface GraphMLKey { + id: string; + name: string; + type: GraphMLAttributeType; + for: 'graph' | 'node' | 'edge'; +} + +interface GraphMLParsedKey { + id: string; + name: string; + type: GraphMLAttributeType; + for: GraphMLKeyDomain; + defaultValue?: any; +} + +interface GEXFParsedAttribute { + id: string; + name: string; + type: GraphMLAttributeType; + className: GEXFAttributeClass; + mode: string; + defaultValue?: any; +} + +interface CX2ParsedAttribute { + name: string; + type: CX2AttributeType; + alias?: string; + defaultValue?: any; +} + +interface CX2ParsedDeclarations { + attributesByName: Map; + nameByAlias: Map; +} + +interface CX2ParsedNode { + cxNodeId: string; + dataRecord: Record; + originalId: string; + rawNode: Record; +} + +interface GraphMLNodeEntry { + graphmlId: string; + originalId: string; + record: Record; +} + +interface GraphMLEdgeEntry { + graphmlId: string; + sourceGraphmlId: string; + targetGraphmlId: string; + directed: boolean; + record: Record; +} + +type DOTTokenType = 'id' | 'edgeop' | 'symbol' | 'eof'; + +interface DOTToken { + type: DOTTokenType; + value: string; + position: number; + quoted?: boolean; +} + +interface DOTNodeId { + id: string; + port?: string; + compass?: string; +} + +interface DOTOperand { + nodes: DOTNodeId[]; + subgraphId?: string; +} + +interface DOTParseContext { + graphAttrs: Record; + nodeDefaults: Record; + edgeDefaults: Record; + subgraphs: string[]; + scopeNodeIds: Set; +} + +type GMLTokenType = 'value' | 'symbol' | 'eof'; + +interface GMLToken { + type: GMLTokenType; + value: string; + position: number; + quoted?: boolean; +} + +const GRAPHML_NAMESPACE = 'http://graphml.graphdrawing.org/xmlns'; +const XML_SCHEMA_INSTANCE_NAMESPACE = 'http://www.w3.org/2001/XMLSchema-instance'; +const GRAPHML_SCHEMA_LOCATION = 'http://graphml.graphdrawing.org/xmlns http://graphml.graphdrawing.org/xmlns/1.0/graphml.xsd'; + +@Injectable({ + providedIn: 'root' +}) +export class GraphMLService { + looksLikeGraphML(contents: any): boolean { + return typeof contents === 'string' && this.getLeadingXmlElementLocalName(contents) === 'graphml'; + } + + looksLikeGEXF(contents: any): boolean { + return typeof contents === 'string' && this.getLeadingXmlElementLocalName(contents) === 'gexf'; + } + + looksLikeXGMML(contents: any): boolean { + if (typeof contents !== 'string') { + return false; + } + + return this.getLeadingXmlElementLocalName(contents) === 'graph'; + } + + looksLikeCX2(contents: any): boolean { + const data = this.parseJSONIfPossible(contents); + const descriptor = Array.isArray(data) ? data[0] : null; + const version = descriptor && typeof descriptor === 'object' + ? String(descriptor.CXVersion ?? '') + : ''; + + return /^2(?:\.|$)/.test(version.trim()); + } + + looksLikeGML(contents: any): boolean { + if (typeof contents !== 'string') { + return false; + } + + const stripped = this.stripLeadingGMLTrivia(contents); + if (/^(?:strict\s+)?digraph\b/i.test(stripped) || /^(?:strict\s+)?graph\b(?!\s*\[)/i.test(stripped)) { + return false; + } + + return /(?:^|\s)graph\s*\[/i.test(stripped); + } + + looksLikeDOT(contents: any): boolean { + if (typeof contents !== 'string') { + return false; + } + + return /^(?:strict\s+)?(?:graph|digraph)\b/i.test(this.stripLeadingDOTTrivia(contents)); + } + + looksLikeNetworkDocument(contents: any): boolean { + return this.looksLikeGraphML(contents) || this.looksLikeGEXF(contents) || this.looksLikeXGMML(contents) || this.looksLikeCX2(contents) || this.looksLikeGML(contents) || this.looksLikeDOT(contents); + } + + looksLikeNetworkXml(contents: any): boolean { + return this.looksLikeGraphML(contents) || this.looksLikeGEXF(contents) || this.looksLikeXGMML(contents); + } + + private getLeadingXmlElementLocalName(contents: string): string | null { + let index = this.skipXmlWhitespace(contents, 0); + + while (index < contents.length) { + if (contents.startsWith('', index + 2); + if (declarationEnd < 0) { + return null; + } + index = this.skipXmlWhitespace(contents, declarationEnd + 2); + continue; + } + + if (contents.startsWith('', index + 4); + if (commentEnd < 0) { + return null; + } + index = this.skipXmlWhitespace(contents, commentEnd + 3); + continue; + } + + if (this.startsWithIgnoreCase(contents, '', index + 9); + if (doctypeEnd < 0) { + return null; + } + index = this.skipXmlWhitespace(contents, doctypeEnd + 1); + continue; + } + + break; + } + + if (contents[index] !== '<') { + return null; + } + + index = this.skipXmlWhitespace(contents, index + 1); + const nameStart = index; + while (index < contents.length && this.isSimpleXmlNameChar(contents.charCodeAt(index))) { + index++; + } + if (index === nameStart) { + return null; + } + + const delimiterCode = contents.charCodeAt(index); + if (contents[index] !== '>' && !this.isXmlWhitespace(delimiterCode)) { + return null; + } + + const qualifiedName = contents.slice(nameStart, index); + const localNameStart = qualifiedName.lastIndexOf(':') + 1; + return qualifiedName.slice(localNameStart).toLowerCase(); + } + + private skipXmlWhitespace(contents: string, index: number): number { + while (index < contents.length && this.isXmlWhitespace(contents.charCodeAt(index))) { + index++; + } + return index; + } + + private isXmlWhitespace(charCode: number): boolean { + return charCode === 9 + || charCode === 10 + || charCode === 11 + || charCode === 12 + || charCode === 13 + || charCode === 32 + || charCode === 160 + || charCode === 0xFEFF; + } + + private isSimpleXmlNameChar(charCode: number): boolean { + return (charCode >= 65 && charCode <= 90) + || (charCode >= 97 && charCode <= 122) + || (charCode >= 48 && charCode <= 57) + || charCode === 45 + || charCode === 46 + || charCode === 58 + || charCode === 95; + } + + private startsWithIgnoreCase(value: string, search: string, position: number): boolean { + return value.slice(position, position + search.length).toLowerCase() === search; + } + + importNetworkDocument(contents: any, options: GraphMLImportOptions = {}): NetworkXmlImportResult { + if (this.looksLikeGraphML(contents)) { + return this.importGraphML(contents, options); + } + + if (this.looksLikeGEXF(contents)) { + return this.importGEXF(contents, options); + } + + if (this.looksLikeXGMML(contents)) { + return this.importXGMML(contents, options); + } + + if (this.looksLikeCX2(contents)) { + return this.importCX2(contents, options); + } + + if (this.looksLikeGML(contents)) { + return this.importGML(contents, options); + } + + if (this.looksLikeDOT(contents)) { + return this.importDOT(contents, options); + } + + throw new Error('The selected file is not a supported network document.'); + } + + importNetworkXml(contents: any, options: GraphMLImportOptions = {}): NetworkXmlImportResult { + return this.importNetworkDocument(contents, options); + } + + importGraphML(contents: string, options: GraphMLImportOptions = {}): NetworkXmlImportResult { + const sourceName = options.sourceName || 'GraphML Import'; + const doc = new DOMParser().parseFromString(contents, 'application/xml'); + const parseError = this.getFirstElementByLocalName(doc, 'parsererror'); + + if (parseError) { + const message = (parseError.textContent || 'Unable to parse GraphML XML.').trim(); + throw new Error(message); + } + + const graphmlElement = doc.documentElement; + if (!graphmlElement || this.getLocalName(graphmlElement) !== 'graphml') { + throw new Error('The selected file is not a GraphML document.'); + } + + const keys = this.parseGraphMLKeys(graphmlElement); + const topLevelGraphs = this.getChildElements(graphmlElement, 'graph'); + const allGraphElements = this.getElementsByLocalName(graphmlElement, 'graph'); + const warnings: string[] = []; + + if (topLevelGraphs.length === 0) { + throw new Error('GraphML file does not contain a top-level graph element.'); + } + + if (allGraphElements.length > topLevelGraphs.length) { + warnings.push('Nested GraphML graph elements were ignored.'); + } + + const nodes: Record[] = []; + const links: Record[] = []; + const graphIds: string[] = []; + let skippedHyperedges = 0; + let skippedPorts = 0; + + topLevelGraphs.forEach((graphElement, graphIndex) => { + const graphId = this.normalizeIdValue(graphElement.getAttribute('id') || `graph_${graphIndex + 1}`); + const graphOrigin = topLevelGraphs.length > 1 ? graphId : sourceName; + const edgeDefault = String(graphElement.getAttribute('edgedefault') || 'undirected').toLowerCase(); + const graphRecord = this.parseDataRecord(graphElement, keys, 'graph'); + // MicrobeTrace stores fields on nodes/links, so graph-scoped data is + // copied onto each record with a prefix instead of being dropped. + const graphData = this.prefixGraphData(graphRecord); + + graphIds.push(graphId); + + if (!graphElement.hasAttribute('edgedefault')) { + warnings.push(`Graph "${graphId}" has no edgedefault; imported as undirected.`); + } + + const graphNodes = this.getChildElements(graphElement, 'node'); + const graphEdges = this.getChildElements(graphElement, 'edge'); + const graphmlNodeIdToMicrobeTraceId = new Map(); + skippedHyperedges += this.getChildElements(graphElement, 'hyperedge').length; + + graphNodes.forEach(nodeElement => { + skippedPorts += this.getChildElements(nodeElement, 'port').length; + + const graphmlNodeId = this.normalizeIdValue(nodeElement.getAttribute('id')); + const dataRecord = this.parseDataRecord(nodeElement, keys, 'node'); + const originalId = this.normalizeIdValue(dataRecord._id || graphmlNodeId); + const nodeRecord: Record = { + ...graphData, + ...dataRecord, + _id: originalId, + id: originalId, + graphml_node_id: graphmlNodeId, + graphml_graph_id: graphId, + graphml_file: sourceName + }; + + this.applyGraphOrigin(nodeRecord, graphOrigin, topLevelGraphs.length > 1); + this.addNetworkSummary(nodeRecord, true); + nodes.push(nodeRecord); + graphmlNodeIdToMicrobeTraceId.set(graphmlNodeId, originalId); + + if (this.getChildElements(nodeElement, 'graph').length > 0) { + warnings.push(`Nested graph under node "${graphmlNodeId}" was ignored.`); + } + }); + + graphEdges.forEach((edgeElement, edgeIndex) => { + const sourceGraphmlId = this.normalizeIdValue(edgeElement.getAttribute('source')); + const targetGraphmlId = this.normalizeIdValue(edgeElement.getAttribute('target')); + const source = graphmlNodeIdToMicrobeTraceId.get(sourceGraphmlId) || sourceGraphmlId; + const target = graphmlNodeIdToMicrobeTraceId.get(targetGraphmlId) || targetGraphmlId; + const dataRecord = this.parseDataRecord(edgeElement, keys, 'edge'); + const directedAttribute = edgeElement.getAttribute('directed'); + const directed = directedAttribute === null + ? edgeDefault === 'directed' + : String(directedAttribute).toLowerCase() === 'true'; + const linkRecord: Record = { + ...graphData, + ...dataRecord, + source, + target, + directed, + graphml_edge_id: edgeElement.getAttribute('id') || `edge_${graphIndex + 1}_${edgeIndex + 1}`, + graphml_source_id: sourceGraphmlId, + graphml_target_id: targetGraphmlId, + graphml_graph_id: graphId, + graphml_file: sourceName + }; + + this.applyGraphMLEdgeOrigins(linkRecord, sourceName, graphOrigin, topLevelGraphs.length > 1); + this.normalizeImportedDistance(linkRecord, this.getDistanceOriginFallback(linkRecord, sourceName)); + this.addNetworkSummary(linkRecord, true); + links.push(linkRecord); + + if (!graphmlNodeIdToMicrobeTraceId.has(sourceGraphmlId)) { + nodes.push(this.buildGeneratedEndpointNode(source, sourceGraphmlId, graphId, sourceName, graphOrigin, topLevelGraphs.length > 1, graphData)); + graphmlNodeIdToMicrobeTraceId.set(sourceGraphmlId, source); + warnings.push(`Edge source "${sourceGraphmlId}" had no node declaration; a node was generated.`); + } + + if (!graphmlNodeIdToMicrobeTraceId.has(targetGraphmlId)) { + nodes.push(this.buildGeneratedEndpointNode(target, targetGraphmlId, graphId, sourceName, graphOrigin, topLevelGraphs.length > 1, graphData)); + graphmlNodeIdToMicrobeTraceId.set(targetGraphmlId, target); + warnings.push(`Edge target "${targetGraphmlId}" had no node declaration; a node was generated.`); + } + }); + }); + + if (skippedHyperedges > 0) { + warnings.push(`${skippedHyperedges} GraphML hyperedge element(s) were ignored.`); + } + + if (skippedPorts > 0) { + warnings.push(`${skippedPorts} GraphML port element(s) were ignored.`); + } + + return { + format: 'graphml', + formatLabel: 'GraphML', + nodes, + links, + nodeFields: this.collectImportFields(nodes, ['index', '_id', 'id', 'origin', 'mt_networks', 'graphml_graph_id', 'graphml_file', 'graphml_node_id']), + linkFields: this.collectImportFields(links, ['index', 'source', 'target', 'distance', 'visible', 'cluster', 'origin', 'nn', 'directed', 'hasDistance', 'distanceOrigin', 'mt_networks', 'graphml_graph_id', 'graphml_file', 'graphml_edge_id']), + graphIds, + warnings + }; + } + + importGEXF(contents: string, options: GraphMLImportOptions = {}): NetworkXmlImportResult { + const sourceName = options.sourceName || 'GEXF Import'; + const doc = new DOMParser().parseFromString(contents, 'application/xml'); + const parseError = this.getFirstElementByLocalName(doc, 'parsererror'); + + if (parseError) { + const message = (parseError.textContent || 'Unable to parse GEXF XML.').trim(); + throw new Error(message); + } + + const gexfElement = doc.documentElement; + if (!gexfElement || this.getLocalName(gexfElement) !== 'gexf') { + throw new Error('The selected file is not a GEXF document.'); + } + + const topLevelGraphs = this.getChildElements(gexfElement, 'graph'); + const warnings: string[] = []; + + if (topLevelGraphs.length === 0) { + throw new Error('GEXF file does not contain a graph element.'); + } + + if (topLevelGraphs.length > 1) { + warnings.push('GEXF file contains multiple top-level graph elements; all were imported as separate networks.'); + } + + const nodes: Record[] = []; + const links: Record[] = []; + const graphIds: string[] = []; + const featureWarnings = new Set(); + // GEXF can repeat the same optional feature on many elements; warn once + // per feature while still preserving the source metadata as fields. + const warnFeature = (key: string, message: string) => { + if (!featureWarnings.has(key)) { + featureWarnings.add(key); + warnings.push(message); + } + }; + + topLevelGraphs.forEach((graphElement, graphIndex) => { + const graphId = this.normalizeIdValue(graphElement.getAttribute('id') || `graph_${graphIndex + 1}`); + const graphOrigin = topLevelGraphs.length > 1 ? graphId : sourceName; + const edgeDefault = String(graphElement.getAttribute('defaultedgetype') || 'undirected').toLowerCase(); + const attributes = this.parseGEXFAttributes(graphElement); + const graphRecord = { + ...this.parseGEXFMetaRecord(gexfElement), + mode: graphElement.getAttribute('mode') || 'static', + defaultedgetype: edgeDefault, + timeformat: graphElement.getAttribute('timeformat') || '', + timerepresentation: graphElement.getAttribute('timerepresentation') || '', + ...this.parseGEXFDataRecord(graphElement, attributes, 'graph') + }; + const graphData = this.prefixGEXFGraphData(graphRecord); + const graphNodeIdToMicrobeTraceId = new Map(); + + graphIds.push(graphId); + + if (!graphElement.hasAttribute('defaultedgetype')) { + warnings.push(`GEXF graph "${graphId}" has no defaultedgetype; imported as undirected.`); + } + + if ( + String(graphElement.getAttribute('mode') || '').toLowerCase() === 'dynamic' + || graphElement.hasAttribute('timeformat') + || graphElement.hasAttribute('timerepresentation') + ) { + // Dynamic GEXF timing is data-preserved, not wired into timeline behavior. + warnFeature('dynamic', 'GEXF dynamic timing metadata was imported as data fields; MicrobeTrace timeline behavior was not applied.'); + } + + const gexfNodes = this.collectGEXFNodeElements(graphElement); + gexfNodes.forEach(({ element: nodeElement, parentId }) => { + const gexfNodeId = this.normalizeIdValue(nodeElement.getAttribute('id')); + const dataRecord = this.parseGEXFDataRecord(nodeElement, attributes, 'node'); + const label = this.normalizeOptionalValue(nodeElement.getAttribute('label')); + const originalId = this.normalizeIdValue(dataRecord._id || gexfNodeId); + const hierarchyParentId = this.normalizeOptionalValue(nodeElement.getAttribute('pid') || parentId); + const nodeRecord: Record = { + ...graphData, + ...dataRecord, + _id: originalId, + id: originalId, + gexf_node_id: gexfNodeId, + gexf_graph_id: graphId, + gexf_file: sourceName + }; + + if (label && nodeRecord.label === undefined) { + nodeRecord.label = label; + } + if (label) { + nodeRecord.gexf_node_label = label; + } + if (hierarchyParentId) { + nodeRecord.gexf_parent_id = hierarchyParentId; + warnFeature('hierarchy', 'GEXF hierarchy metadata was imported as data fields; MicrobeTrace hierarchy layout behavior was not applied.'); + } + if (this.addGEXFTemporalMetadata(nodeRecord, nodeElement) || nodeRecord.gexf_dynamic_attvalues) { + warnFeature('dynamic', 'GEXF dynamic timing metadata was imported as data fields; MicrobeTrace timeline behavior was not applied.'); + } + if (this.addGEXFVizMetadata(nodeRecord, nodeElement)) { + warnFeature('viz', 'GEXF visualization metadata was imported as data fields; MicrobeTrace styling behavior was not applied.'); + } + if (this.getChildElements(nodeElement, 'nodes').length > 0) { + warnFeature('hierarchy', 'GEXF hierarchy metadata was imported as data fields; MicrobeTrace hierarchy layout behavior was not applied.'); + } + + this.applyGraphOrigin(nodeRecord, graphOrigin, topLevelGraphs.length > 1); + this.addNetworkSummary(nodeRecord, true); + nodes.push(nodeRecord); + graphNodeIdToMicrobeTraceId.set(gexfNodeId, originalId); + }); + + const gexfEdges = this.collectGEXFEdgeElements(graphElement); + gexfEdges.forEach((edgeElement, edgeIndex) => { + const sourceGexfId = this.normalizeIdValue(edgeElement.getAttribute('source')); + const targetGexfId = this.normalizeIdValue(edgeElement.getAttribute('target')); + const source = graphNodeIdToMicrobeTraceId.get(sourceGexfId) || sourceGexfId; + const target = graphNodeIdToMicrobeTraceId.get(targetGexfId) || targetGexfId; + const dataRecord = this.parseGEXFDataRecord(edgeElement, attributes, 'edge'); + const label = this.normalizeOptionalValue(edgeElement.getAttribute('label')); + const weight = this.toFiniteNumber(edgeElement.getAttribute('weight')); + const linkRecord: Record = { + ...graphData, + ...dataRecord, + source, + target, + directed: this.isGEXFEdgeDirected(edgeElement, edgeDefault), + gexf_edge_id: edgeElement.getAttribute('id') || `edge_${graphIndex + 1}_${edgeIndex + 1}`, + gexf_source_id: sourceGexfId, + gexf_target_id: targetGexfId, + gexf_graph_id: graphId, + gexf_file: sourceName + }; + + if (label && linkRecord.label === undefined) { + linkRecord.label = label; + } + if (weight !== null && linkRecord.weight === undefined) { + linkRecord.weight = weight; + } + if (this.addGEXFTemporalMetadata(linkRecord, edgeElement) || linkRecord.gexf_dynamic_attvalues) { + warnFeature('dynamic', 'GEXF dynamic timing metadata was imported as data fields; MicrobeTrace timeline behavior was not applied.'); + } + if (this.addGEXFVizMetadata(linkRecord, edgeElement)) { + warnFeature('viz', 'GEXF visualization metadata was imported as data fields; MicrobeTrace styling behavior was not applied.'); + } + + this.applyGEXFEdgeOrigins(linkRecord, sourceName, graphOrigin, topLevelGraphs.length > 1); + this.normalizeImportedDistance(linkRecord, this.getDistanceOriginFallback(linkRecord, sourceName)); + this.addNetworkSummary(linkRecord, true); + links.push(linkRecord); + + if (!graphNodeIdToMicrobeTraceId.has(sourceGexfId)) { + nodes.push(this.buildGeneratedGEXFEndpointNode(source, sourceGexfId, graphId, sourceName, graphOrigin, topLevelGraphs.length > 1, graphData)); + graphNodeIdToMicrobeTraceId.set(sourceGexfId, source); + warnings.push(`GEXF edge source "${sourceGexfId}" had no node declaration; a node was generated.`); + } + + if (!graphNodeIdToMicrobeTraceId.has(targetGexfId)) { + nodes.push(this.buildGeneratedGEXFEndpointNode(target, targetGexfId, graphId, sourceName, graphOrigin, topLevelGraphs.length > 1, graphData)); + graphNodeIdToMicrobeTraceId.set(targetGexfId, target); + warnings.push(`GEXF edge target "${targetGexfId}" had no node declaration; a node was generated.`); + } + }); + }); + + return { + format: 'gexf', + formatLabel: 'GEXF', + nodes, + links, + nodeFields: this.collectImportFields(nodes, ['index', '_id', 'id', 'label', 'origin', 'mt_networks', 'gexf_graph_id', 'gexf_file', 'gexf_node_id', 'gexf_node_label', 'gexf_parent_id']), + linkFields: this.collectImportFields(links, ['index', 'source', 'target', 'distance', 'weight', 'visible', 'cluster', 'origin', 'nn', 'directed', 'hasDistance', 'distanceOrigin', 'mt_networks', 'gexf_graph_id', 'gexf_file', 'gexf_edge_id']), + graphIds, + warnings + }; + } + + importXGMML(contents: string, options: GraphMLImportOptions = {}): NetworkXmlImportResult { + const sourceName = options.sourceName || 'XGMML Import'; + const doc = new DOMParser().parseFromString(contents, 'application/xml'); + const parseError = this.getFirstElementByLocalName(doc, 'parsererror'); + + if (parseError) { + const message = (parseError.textContent || 'Unable to parse XGMML XML.').trim(); + throw new Error(message); + } + + const graphElement = doc.documentElement; + if (!graphElement || this.getLocalName(graphElement) !== 'graph') { + throw new Error('The selected file is not an XGMML document.'); + } + + const warnings: string[] = []; + const nestedGraphCount = this.getElementsByLocalName(graphElement, 'graph').length; + if (nestedGraphCount > 0) { + warnings.push('Nested XGMML graph elements were ignored.'); + } + + const graphId = this.normalizeIdValue( + graphElement.getAttribute('id') + || graphElement.getAttribute('label') + || sourceName + ); + const graphDirected = this.parseXGMMLBooleanValue(graphElement.getAttribute('directed')) === true; + const graphRecord = { + ...this.copyXGMMLAttributes(graphElement, ['id', 'directed']), + ...this.parseXGMMLAttRecord(graphElement) + }; + const graphData = this.prefixXGMMLGraphData(graphRecord); + const graphOrigin = sourceName; + const nodeElements = this.getChildElements(graphElement, 'node'); + const edgeElements = this.getChildElements(graphElement, 'edge'); + // XGMML often uses numeric node ids plus human labels; parse first so + // we can choose stable MicrobeTrace ids from unique labels/names. + const parsedNodes = nodeElements.map((nodeElement, nodeIndex) => { + const xgmmlNodeId = this.normalizeIdValue(nodeElement.getAttribute('id') || `node_${nodeIndex + 1}`); + const dataRecord = { + ...this.copyXGMMLAttributes(nodeElement, ['id']), + ...this.parseXGMMLAttRecord(nodeElement) + }; + this.preserveReservedFields(dataRecord, ['id'], 'xgmml_attribute'); + this.applyXGMMLGraphics(dataRecord, nodeElement, 'node'); + + return { + xgmmlNodeId, + dataRecord, + originalId: '' + }; + }); + const nameCounts = this.countRecordValues(parsedNodes.map(node => node.dataRecord), 'name'); + const sharedNameCounts = this.countRecordValues(parsedNodes.map(node => node.dataRecord), 'shared name'); + const labelCounts = this.countRecordValues(parsedNodes.map(node => node.dataRecord), 'label'); + const nodes: Record[] = []; + const links: Record[] = []; + const xgmmlNodeIdToMicrobeTraceId = new Map(); + + if (!graphElement.hasAttribute('directed')) { + warnings.push(`XGMML graph "${graphId}" has no directed flag; imported as undirected.`); + } + + parsedNodes.forEach(parsedNode => { + parsedNode.originalId = this.selectXGMMLNodeOriginalId( + parsedNode.dataRecord, + parsedNode.xgmmlNodeId, + labelCounts, + nameCounts, + sharedNameCounts + ); + + const nodeRecord: Record = { + ...graphData, + ...parsedNode.dataRecord, + _id: parsedNode.originalId, + id: parsedNode.originalId, + xgmml_node_id: parsedNode.xgmmlNodeId, + xgmml_graph_id: graphId, + xgmml_file: sourceName + }; + + if (nodeRecord.label !== undefined) { + nodeRecord.xgmml_node_label = nodeRecord.label; + } + + this.applyGraphOrigin(nodeRecord, graphOrigin, false); + this.addNetworkSummary(nodeRecord, true); + nodes.push(nodeRecord); + xgmmlNodeIdToMicrobeTraceId.set(parsedNode.xgmmlNodeId, parsedNode.originalId); + }); + + edgeElements.forEach((edgeElement, edgeIndex) => { + const sourceXgmmlId = this.normalizeIdValue(edgeElement.getAttribute('source')); + const targetXgmmlId = this.normalizeIdValue(edgeElement.getAttribute('target')); + const source = xgmmlNodeIdToMicrobeTraceId.get(sourceXgmmlId) || sourceXgmmlId; + const target = xgmmlNodeIdToMicrobeTraceId.get(targetXgmmlId) || targetXgmmlId; + const dataRecord = { + ...this.copyXGMMLAttributes(edgeElement, ['id', 'source', 'target', 'directed']), + ...this.parseXGMMLAttRecord(edgeElement) + }; + this.preserveReservedFields(dataRecord, ['id', 'source', 'target', 'directed'], 'xgmml_attribute'); + this.applyXGMMLGraphics(dataRecord, edgeElement, 'edge'); + + const edgeDirected = this.parseXGMMLBooleanValue(edgeElement.getAttribute('directed')); + const weight = this.toFiniteNumber(edgeElement.getAttribute('weight') ?? dataRecord.weight); + const linkRecord: Record = { + ...graphData, + ...dataRecord, + source, + target, + directed: edgeDirected === null ? graphDirected : edgeDirected, + xgmml_edge_id: edgeElement.getAttribute('id') || dataRecord.xgmml_attribute_id || `edge_1_${edgeIndex + 1}`, + xgmml_source_id: sourceXgmmlId, + xgmml_target_id: targetXgmmlId, + xgmml_graph_id: graphId, + xgmml_file: sourceName + }; + + if (linkRecord.label !== undefined) { + linkRecord.xgmml_edge_label = linkRecord.label; + } + if (weight !== null) { + linkRecord.weight = weight; + } + + this.applyXGMMLEdgeOrigins(linkRecord, sourceName, graphOrigin, false); + this.normalizeImportedDistance(linkRecord, this.getDistanceOriginFallback(linkRecord, sourceName)); + this.addNetworkSummary(linkRecord, true); + links.push(linkRecord); + + if (!xgmmlNodeIdToMicrobeTraceId.has(sourceXgmmlId)) { + nodes.push(this.buildGeneratedXGMMLEndpointNode(source, sourceXgmmlId, graphId, sourceName, graphData)); + xgmmlNodeIdToMicrobeTraceId.set(sourceXgmmlId, source); + warnings.push(`XGMML edge source "${sourceXgmmlId}" had no node declaration; a node was generated.`); + } + + if (!xgmmlNodeIdToMicrobeTraceId.has(targetXgmmlId)) { + nodes.push(this.buildGeneratedXGMMLEndpointNode(target, targetXgmmlId, graphId, sourceName, graphData)); + xgmmlNodeIdToMicrobeTraceId.set(targetXgmmlId, target); + warnings.push(`XGMML edge target "${targetXgmmlId}" had no node declaration; a node was generated.`); + } + }); + + return { + format: 'xgmml', + formatLabel: 'XGMML', + nodes, + links, + nodeFields: this.collectImportFields(nodes, ['index', '_id', 'id', 'label', 'name', 'origin', 'mt_networks', 'x', 'y', 'xgmml_graph_id', 'xgmml_file', 'xgmml_node_id', 'xgmml_node_label']), + linkFields: this.collectImportFields(links, ['index', 'source', 'target', 'distance', 'weight', 'label', 'visible', 'cluster', 'origin', 'nn', 'directed', 'hasDistance', 'distanceOrigin', 'mt_networks', 'xgmml_graph_id', 'xgmml_file', 'xgmml_edge_id']), + graphIds: [graphId], + warnings + }; + } + + importCX2(contents: any, options: GraphMLImportOptions = {}): NetworkXmlImportResult { + const sourceName = options.sourceName || 'CX2 Import'; + const data = typeof contents === 'string' + ? this.parseRequiredJSON(contents, 'Unable to parse CX2 JSON.') + : contents; + + if (!Array.isArray(data)) { + throw new Error('The selected file is not a CX2 document.'); + } + + const descriptor = data[0]; + const cxVersion = descriptor && typeof descriptor === 'object' + ? String(descriptor.CXVersion ?? '') + : ''; + + if (!/^2(?:\.|$)/.test(cxVersion.trim())) { + throw new Error('The selected Cytoscape Exchange document is not CX2.'); + } + + const warnings: string[] = []; + const warnOnce = this.createWarningCollector(warnings); + const aspects = this.collectCX2Aspects(data, warnings); + const statusEntries = aspects.status || []; + + if (statusEntries.length === 0) { + warnings.push('CX2 status aspect is missing.'); + } else { + if (statusEntries.length > 1) { + warnings.push('CX2 file contains multiple status entries; the last entry was used.'); + } + + const status = statusEntries[statusEntries.length - 1]; + if (status && status.success === false) { + throw new Error(status.error || 'CX2 status reported an unsuccessful network generation.'); + } + if (status && status.success === true && status.error) { + warnings.push(`CX2 status warning: ${status.error}`); + } + } + + if (descriptor?.hasFragments === true) { + warnings.push('CX2 fragmented aspects were concatenated in file order.'); + } + + // CX2 keeps schema details in declarations; aliases/defaults must be + // resolved before node and edge value blocks are interpreted. + const declarations = this.parseCX2AttributeDeclarations(aspects.attributeDeclarations || []); + const networkRecord = this.parseCX2NetworkAttributes(aspects.networkAttributes || [], declarations.networkAttributes); + const graphId = this.normalizeIdValue(networkRecord.name || sourceName); + const graphData = this.prefixCX2NetworkData({ + CXVersion: cxVersion, + hasFragments: descriptor?.hasFragments === true, + ...networkRecord + }); + const cxNodes = (aspects.nodes || []).filter((node): node is Record => !!node && typeof node === 'object'); + const cxEdges = (aspects.edges || []).filter((edge): edge is Record => !!edge && typeof edge === 'object'); + const nodeBypasses = this.parseCX2Bypasses(aspects.nodeBypasses || []); + const edgeBypasses = this.parseCX2Bypasses(aspects.edgeBypasses || []); + + if ((aspects.visualProperties || []).length > 0) { + warnOnce('visualProperties', 'CX2 visualProperties were not applied to MicrobeTrace styling.'); + } + if ((aspects.visualEditorProperties || []).length > 0) { + warnOnce('visualEditorProperties', 'CX2 visualEditorProperties were not applied to MicrobeTrace styling.'); + } + + const parsedNodes = this.parseCX2Nodes(cxNodes, declarations.nodes, nodeBypasses); + const nodes: Record[] = []; + const links: Record[] = []; + const cxNodeIdToMicrobeTraceId = new Map(); + + parsedNodes.forEach(parsedNode => { + const nodeRecord: Record = { + ...graphData, + ...parsedNode.dataRecord, + _id: parsedNode.originalId, + id: parsedNode.originalId, + cx2_node_id: parsedNode.cxNodeId, + cx2_graph_id: graphId, + cx2_file: sourceName + }; + + this.applyCX2NodeCoordinates(nodeRecord, parsedNode.rawNode); + + if (nodeRecord.name !== undefined && nodeRecord.label === undefined) { + nodeRecord.label = nodeRecord.name; + } + if (nodeRecord.name !== undefined) { + nodeRecord.cx2_node_name = nodeRecord.name; + } + + this.applyGraphOrigin(nodeRecord, sourceName, false); + this.addNetworkSummary(nodeRecord, true); + nodes.push(nodeRecord); + cxNodeIdToMicrobeTraceId.set(parsedNode.cxNodeId, parsedNode.originalId); + }); + + cxEdges.forEach((edge, edgeIndex) => { + const cxEdgeId = this.normalizeIdValue(edge.id ?? `edge_${edgeIndex + 1}`); + const cxSourceId = this.normalizeIdValue(edge.s); + const cxTargetId = this.normalizeIdValue(edge.t); + const source = cxNodeIdToMicrobeTraceId.get(cxSourceId) || cxSourceId; + const target = cxNodeIdToMicrobeTraceId.get(cxTargetId) || cxTargetId; + const dataRecord = this.parseCX2DataRecord(edge.v, declarations.edges); + this.preserveReservedFields(dataRecord, ['id', 'source', 'target'], 'cx2_attribute'); + this.applyCX2BypassRecord(dataRecord, edgeBypasses.get(cxEdgeId), 'cx2_bypass'); + + const linkRecord: Record = { + ...graphData, + ...dataRecord, + source, + target, + directed: typeof dataRecord.directed === 'boolean' ? dataRecord.directed : false, + cx2_edge_id: cxEdgeId, + cx2_source_id: cxSourceId, + cx2_target_id: cxTargetId, + cx2_graph_id: graphId, + cx2_file: sourceName + }; + + this.applyCX2EdgeOrigins(linkRecord, sourceName, graphId, false); + this.normalizeImportedDistance(linkRecord, this.getDistanceOriginFallback(linkRecord, sourceName)); + this.addNetworkSummary(linkRecord, true); + links.push(linkRecord); + + if (!cxNodeIdToMicrobeTraceId.has(cxSourceId)) { + nodes.push(this.buildGeneratedCX2EndpointNode(source, cxSourceId, graphId, sourceName, graphData)); + cxNodeIdToMicrobeTraceId.set(cxSourceId, source); + warnings.push(`CX2 edge source "${cxSourceId}" had no node declaration; a node was generated.`); + } + + if (!cxNodeIdToMicrobeTraceId.has(cxTargetId)) { + nodes.push(this.buildGeneratedCX2EndpointNode(target, cxTargetId, graphId, sourceName, graphData)); + cxNodeIdToMicrobeTraceId.set(cxTargetId, target); + warnings.push(`CX2 edge target "${cxTargetId}" had no node declaration; a node was generated.`); + } + }); + + const unsupportedAspectNames = Object.keys(aspects) + .filter(name => ![ + 'metaData', + 'attributeDeclarations', + 'networkAttributes', + 'nodes', + 'edges', + 'visualProperties', + 'nodeBypasses', + 'edgeBypasses', + 'visualEditorProperties', + 'status' + ].includes(name)); + if (unsupportedAspectNames.length > 0) { + warnings.push(`CX2 opaque aspect(s) were ignored: ${unsupportedAspectNames.join(', ')}.`); + } + + return { + format: 'cx2', + formatLabel: 'CX2', + nodes, + links, + nodeFields: this.collectImportFields(nodes, ['index', '_id', 'id', 'label', 'name', 'origin', 'mt_networks', 'x', 'y', 'cx2_graph_id', 'cx2_file', 'cx2_node_id', 'cx2_node_name']), + linkFields: this.collectImportFields(links, ['index', 'source', 'target', 'distance', 'weight', 'visible', 'cluster', 'origin', 'nn', 'directed', 'hasDistance', 'distanceOrigin', 'mt_networks', 'cx2_graph_id', 'cx2_file', 'cx2_edge_id']), + graphIds: [graphId], + warnings + }; + } + + importGML(contents: string, options: GraphMLImportOptions = {}): NetworkXmlImportResult { + const sourceName = options.sourceName || 'GML Import'; + const documentRecord = this.parseGMLDocument(contents); + const graphRecords = this.asGMLRecordArray(documentRecord.graph); + const warnings: string[] = []; + + if (graphRecords.length === 0) { + throw new Error('GML file does not contain a graph block.'); + } + + if (graphRecords.length > 1) { + warnings.push('GML file contains multiple top-level graph blocks; all were imported as separate networks.'); + } + + const nodes: Record[] = []; + const links: Record[] = []; + const graphIds: string[] = []; + + graphRecords.forEach((graphRecord, graphIndex) => { + const graphAttrs = this.copyGMLAttributes(graphRecord, ['node', 'edge']); + const graphId = this.normalizeIdValue(graphAttrs.id || graphAttrs.name || graphAttrs.label || `graph_${graphIndex + 1}`); + const graphOrigin = graphRecords.length > 1 ? graphId : sourceName; + const graphData = this.prefixGMLGraphData(graphAttrs); + const graphDirected = this.parseGMLDirectedValue(graphRecord.directed) === true; + const graphNodes = this.asGMLRecordArray(graphRecord.node); + const graphEdges = this.asGMLRecordArray(graphRecord.edge); + const gmlNodeIdToMicrobeTraceId = new Map(); + + graphIds.push(graphId); + + const labelCounts = this.countGMLAttributeValues(graphNodes, 'label'); + const nameCounts = this.countGMLAttributeValues(graphNodes, 'name'); + + graphNodes.forEach((nodeRecordRaw, nodeIndex) => { + const gmlNodeId = this.normalizeIdValue(nodeRecordRaw.id ?? `node_${graphIndex + 1}_${nodeIndex + 1}`); + const dataRecord = this.copyGMLAttributes(nodeRecordRaw, ['id']); + const originalId = this.selectGMLNodeOriginalId(dataRecord, gmlNodeId, labelCounts, nameCounts); + const nodeRecord: Record = { + ...graphData, + ...dataRecord, + _id: originalId, + id: originalId, + gml_node_id: gmlNodeId, + gml_graph_id: graphId, + gml_file: sourceName + }; + + this.applyGraphOrigin(nodeRecord, graphOrigin, graphRecords.length > 1); + this.addNetworkSummary(nodeRecord, true); + nodes.push(nodeRecord); + gmlNodeIdToMicrobeTraceId.set(gmlNodeId, originalId); + }); + + graphEdges.forEach((edgeRecordRaw, edgeIndex) => { + const sourceGmlId = this.normalizeIdValue(edgeRecordRaw.source); + const targetGmlId = this.normalizeIdValue(edgeRecordRaw.target); + const source = gmlNodeIdToMicrobeTraceId.get(sourceGmlId) || sourceGmlId; + const target = gmlNodeIdToMicrobeTraceId.get(targetGmlId) || targetGmlId; + const dataRecord = this.copyGMLAttributes(edgeRecordRaw, ['id', 'source', 'target', 'directed']); + const edgeDirected = this.parseGMLDirectedValue(edgeRecordRaw.directed); + const linkRecord: Record = { + ...graphData, + ...dataRecord, + source, + target, + directed: edgeDirected === null ? graphDirected : edgeDirected, + gml_edge_id: this.normalizeIdValue(edgeRecordRaw.id ?? `edge_${graphIndex + 1}_${edgeIndex + 1}`), + gml_source_id: sourceGmlId, + gml_target_id: targetGmlId, + gml_graph_id: graphId, + gml_file: sourceName + }; + + this.applyGMLEdgeOrigins(linkRecord, sourceName, graphOrigin, graphRecords.length > 1); + this.normalizeImportedDistance(linkRecord, this.getDistanceOriginFallback(linkRecord, sourceName)); + this.addNetworkSummary(linkRecord, true); + links.push(linkRecord); + + if (!gmlNodeIdToMicrobeTraceId.has(sourceGmlId)) { + nodes.push(this.buildGeneratedGMLEndpointNode(source, sourceGmlId, graphId, sourceName, graphOrigin, graphRecords.length > 1, graphData)); + gmlNodeIdToMicrobeTraceId.set(sourceGmlId, source); + warnings.push(`GML edge source "${sourceGmlId}" had no node declaration; a node was generated.`); + } + + if (!gmlNodeIdToMicrobeTraceId.has(targetGmlId)) { + nodes.push(this.buildGeneratedGMLEndpointNode(target, targetGmlId, graphId, sourceName, graphOrigin, graphRecords.length > 1, graphData)); + gmlNodeIdToMicrobeTraceId.set(targetGmlId, target); + warnings.push(`GML edge target "${targetGmlId}" had no node declaration; a node was generated.`); + } + }); + }); + + return { + format: 'gml', + formatLabel: 'GML', + nodes, + links, + nodeFields: this.collectImportFields(nodes, ['index', '_id', 'id', 'label', 'name', 'origin', 'mt_networks', 'gml_graph_id', 'gml_file', 'gml_node_id']), + linkFields: this.collectImportFields(links, ['index', 'source', 'target', 'distance', 'weight', 'visible', 'cluster', 'origin', 'nn', 'directed', 'hasDistance', 'distanceOrigin', 'mt_networks', 'gml_graph_id', 'gml_file', 'gml_edge_id']), + graphIds, + warnings + }; + } + + importDOT(contents: string, options: GraphMLImportOptions = {}): NetworkXmlImportResult { + const sourceName = options.sourceName || 'DOT Import'; + const tokens = this.tokenizeDOT(contents); + const nodesById = new Map>(); + const links: Record[] = []; + const warnings: string[] = []; + const warnOnce = this.createWarningCollector(warnings); + let index = 0; + let edgeSequence = 1; + let subgraphSequence = 1; + let graphId = this.normalizeIdValue(sourceName); + let isStrict = false; + let isDirectedGraph = false; + // DOT "strict" graphs replace duplicate edges rather than adding them. + const strictEdges = new Map>(); + + const peek = (offset = 0): DOTToken => tokens[Math.min(index + offset, tokens.length - 1)]; + const consume = (): DOTToken => tokens[index++]; + const isKeyword = (token: DOTToken, keyword: string): boolean => + token.type === 'id' && token.quoted !== true && token.value.toLowerCase() === keyword.toLowerCase(); + const peekKeyword = (keyword: string, offset = 0): boolean => isKeyword(peek(offset), keyword); + const matchKeyword = (keyword: string): boolean => { + if (peekKeyword(keyword)) { + consume(); + return true; + } + return false; + }; + const matchSymbol = (symbol: string): boolean => { + if (peek().type === 'symbol' && peek().value === symbol) { + consume(); + return true; + } + return false; + }; + const expectSymbol = (symbol: string): void => { + if (!matchSymbol(symbol)) { + this.throwDOTParseError(`Expected "${symbol}"`, peek()); + } + }; + const parseId = (): string => { + const token = peek(); + if (token.type !== 'id') { + this.throwDOTParseError('Expected an identifier', token); + } + + let value = consume().value; + // DOT allows adjacent quoted strings joined with + for a single id/label. + while (matchSymbol('+')) { + const nextToken = peek(); + if (nextToken.type !== 'id') { + this.throwDOTParseError('Expected an identifier after "+"', nextToken); + } + value += consume().value; + } + return value; + }; + const parseNodeIdTail = (id: string): DOTNodeId => { + const nodeId: DOTNodeId = { id }; + if (!matchSymbol(':')) { + return nodeId; + } + + nodeId.port = parseId(); + if (matchSymbol(':')) { + nodeId.compass = parseId(); + } + return nodeId; + }; + const parseNodeId = (): DOTNodeId => parseNodeIdTail(parseId()); + const parseAttrList = (): Record => { + const attrs: Record = {}; + + while (matchSymbol('[')) { + while (peek().type !== 'eof' && !(peek().type === 'symbol' && peek().value === ']')) { + if (matchSymbol(';') || matchSymbol(',')) { + continue; + } + + const name = parseId(); + if (matchSymbol('=')) { + attrs[name] = this.parseDOTAttributeValue(parseId(), name); + } else { + attrs[name] = true; + } + + matchSymbol(';') || matchSymbol(','); + } + expectSymbol(']'); + } + + return attrs; + }; + const cloneContext = (context: DOTParseContext, subgraphId?: string): DOTParseContext => ({ + graphAttrs: { ...context.graphAttrs }, + nodeDefaults: { ...context.nodeDefaults }, + edgeDefaults: { ...context.edgeDefaults }, + subgraphs: subgraphId ? context.subgraphs.concat([subgraphId]) : context.subgraphs.slice(), + scopeNodeIds: new Set() + }); + const addDOTSubgraphs = (record: Record, subgraphs: string[]): void => { + if (subgraphs.length === 0) { + return; + } + + const existing = Array.isArray(record.dot_subgraphs) + ? record.dot_subgraphs + : this.normalizeOrigins(record.dot_subgraphs); + record.dot_subgraphs = this.uniqStrings(existing.concat(subgraphs)); + }; + const addOrUpdateNode = ( + node: DOTNodeId, + attrs: Record, + context: DOTParseContext, + explicit: boolean + ): Record => { + const id = this.normalizeIdValue(node.id); + const dataRecord = { ...attrs }; + this.preserveReservedFields(dataRecord, ['id'], 'dot_attribute'); + + let nodeRecord = nodesById.get(id); + if (!nodeRecord) { + nodeRecord = { + ...this.prefixDOTGraphData(context.graphAttrs), + ...dataRecord, + _id: id, + id, + dot_node_id: id, + dot_graph_id: graphId, + dot_file: sourceName + }; + if (!explicit) { + nodeRecord.dot_implicit_node = true; + } + this.applyGraphOrigin(nodeRecord, sourceName, false); + nodesById.set(id, nodeRecord); + } else if (explicit) { + Object.assign(nodeRecord, this.prefixDOTGraphData(context.graphAttrs), dataRecord); + if (nodeRecord.dot_implicit_node === true) { + delete nodeRecord.dot_implicit_node; + } + } + + addDOTSubgraphs(nodeRecord, context.subgraphs); + this.addNetworkSummary(nodeRecord, true); + context.scopeNodeIds.add(id); + return nodeRecord; + }; + const strictEdgeKey = (source: string, target: string, directed: boolean): string => { + if (directed) { + return `${source}->${target}`; + } + + return [source, target].sort((a, b) => a.localeCompare(b)).join('--'); + }; + const createEdge = ( + sourceNode: DOTNodeId, + targetNode: DOTNodeId, + attrs: Record, + context: DOTParseContext, + edgeop: string + ): void => { + const source = this.normalizeIdValue(sourceNode.id); + const target = this.normalizeIdValue(targetNode.id); + const directed = edgeop === '->'; + + if (directed && !isDirectedGraph) { + warnOnce('directed-edgeop-in-graph', 'DOT directed edge operator "->" appeared in a graph; imported as directed.'); + } + if (!directed && isDirectedGraph) { + warnOnce('undirected-edgeop-in-digraph', 'DOT undirected edge operator "--" appeared in a digraph; imported as undirected.'); + } + + addOrUpdateNode(sourceNode, context.nodeDefaults, context, false); + addOrUpdateNode(targetNode, context.nodeDefaults, context, false); + + const dataRecord = { ...attrs }; + this.preserveReservedFields(dataRecord, ['id', 'source', 'target', 'directed'], 'dot_attribute'); + const key = strictEdgeKey(source, target, directed); + let linkRecord = isStrict ? strictEdges.get(key) : undefined; + + if (linkRecord) { + Object.assign(linkRecord, dataRecord); + if (sourceNode.port) linkRecord.dot_source_port = sourceNode.port; + if (sourceNode.compass) linkRecord.dot_source_compass = sourceNode.compass; + if (targetNode.port) linkRecord.dot_target_port = targetNode.port; + if (targetNode.compass) linkRecord.dot_target_compass = targetNode.compass; + return; + } + + linkRecord = { + ...this.prefixDOTGraphData(context.graphAttrs), + ...dataRecord, + source, + target, + directed, + dot_edge_id: dataRecord.dot_attribute_id || `edge_${edgeSequence++}`, + dot_source_id: sourceNode.id, + dot_target_id: targetNode.id, + dot_graph_id: graphId, + dot_file: sourceName + }; + + if (sourceNode.port) linkRecord.dot_source_port = sourceNode.port; + if (sourceNode.compass) linkRecord.dot_source_compass = sourceNode.compass; + if (targetNode.port) linkRecord.dot_target_port = targetNode.port; + if (targetNode.compass) linkRecord.dot_target_compass = targetNode.compass; + + addDOTSubgraphs(linkRecord, context.subgraphs); + links.push(linkRecord); + if (isStrict) { + strictEdges.set(key, linkRecord); + } + }; + const parseOperand = (context: DOTParseContext): DOTOperand => { + if (peekKeyword('subgraph') || (peek().type === 'symbol' && peek().value === '{')) { + return parseSubgraph(context); + } + + return { nodes: [parseNodeId()] }; + }; + const parseEdgeStatement = (left: DOTOperand, context: DOTParseContext): void => { + const operands = [left]; + const edgeops: string[] = []; + + while (peek().type === 'edgeop') { + edgeops.push(consume().value); + operands.push(parseOperand(context)); + } + + const attrs = { + ...context.edgeDefaults, + ...parseAttrList() + }; + + edgeops.forEach((edgeop, edgeIndex) => { + const sources = operands[edgeIndex].nodes; + const targets = operands[edgeIndex + 1].nodes; + if (sources.length === 0 || targets.length === 0) { + warnOnce('empty-subgraph-edge', 'DOT edge statement used an empty subgraph operand; no edge was created for that operand.'); + return; + } + + // DOT subgraph operands expand into the cross-product of their + // member nodes, matching Graphviz edge statement semantics. + sources.forEach(source => targets.forEach(target => createEdge(source, target, attrs, context, edgeop))); + }); + }; + const parseSubgraph = (context: DOTParseContext): DOTOperand => { + let subgraphId = ''; + if (matchKeyword('subgraph')) { + if (peek().type === 'id') { + subgraphId = this.normalizeIdValue(parseId()); + } + } + + if (!subgraphId && peek().type === 'symbol' && peek().value !== '{') { + this.throwDOTParseError('Expected "{" after subgraph declaration', peek()); + } + + expectSymbol('{'); + const generatedSubgraphId = subgraphId || `subgraph_${subgraphSequence++}`; + const subContext = cloneContext(context, subgraphId || undefined); + parseStmtList(subContext); + expectSymbol('}'); + + const nodes = Array.from(subContext.scopeNodeIds).map(id => ({ id })); + nodes.forEach(node => context.scopeNodeIds.add(node.id)); + return { + nodes, + subgraphId: subgraphId || generatedSubgraphId + }; + }; + const parseStatement = (context: DOTParseContext): void => { + if (matchSymbol(';') || matchSymbol(',')) { + return; + } + + if (peekKeyword('subgraph') || (peek().type === 'symbol' && peek().value === '{')) { + const operand = parseSubgraph(context); + if (peek().type === 'edgeop') { + parseEdgeStatement(operand, context); + } + return; + } + + if ( + (peekKeyword('graph') || peekKeyword('node') || peekKeyword('edge')) + && peek(1).type === 'symbol' + && peek(1).value === '[' + ) { + const domain = consume().value.toLowerCase(); + const attrs = parseAttrList(); + if (domain === 'graph') { + Object.assign(context.graphAttrs, attrs); + } else if (domain === 'node') { + Object.assign(context.nodeDefaults, attrs); + } else { + Object.assign(context.edgeDefaults, attrs); + } + return; + } + + const firstId = parseId(); + if (matchSymbol('=')) { + context.graphAttrs[firstId] = this.parseDOTAttributeValue(parseId(), firstId); + return; + } + + const node = parseNodeIdTail(firstId); + const operand = { nodes: [node] }; + if (peek().type === 'edgeop') { + parseEdgeStatement(operand, context); + return; + } + + addOrUpdateNode(node, { + ...context.nodeDefaults, + ...parseAttrList() + }, context, true); + }; + const parseStmtList = (context: DOTParseContext): void => { + while (peek().type !== 'eof' && !(peek().type === 'symbol' && peek().value === '}')) { + parseStatement(context); + matchSymbol(';') || matchSymbol(','); + } + }; + + isStrict = matchKeyword('strict'); + if (matchKeyword('digraph')) { + isDirectedGraph = true; + } else if (matchKeyword('graph')) { + isDirectedGraph = false; + } else { + this.throwDOTParseError('DOT file must start with graph or digraph', peek()); + } + + if (peek().type === 'id') { + graphId = this.normalizeIdValue(parseId()); + } + + const rootContext: DOTParseContext = { + graphAttrs: { + kind: isDirectedGraph ? 'digraph' : 'graph', + strict: isStrict + }, + nodeDefaults: {}, + edgeDefaults: {}, + subgraphs: [], + scopeNodeIds: new Set() + }; + + expectSymbol('{'); + parseStmtList(rootContext); + expectSymbol('}'); + + if (peek().type !== 'eof') { + this.throwDOTParseError('Unexpected content after DOT graph', peek()); + } + + const nodes = Array.from(nodesById.values()); + nodes.forEach(node => this.addNetworkSummary(node, true)); + links.forEach(link => { + this.applyDOTEdgeOrigins(link, sourceName, graphId, false); + this.normalizeImportedDistance(link, this.getDistanceOriginFallback(link, sourceName)); + this.addNetworkSummary(link, true); + }); + + return { + format: 'dot', + formatLabel: 'DOT', + nodes, + links, + nodeFields: this.collectImportFields(nodes, ['index', '_id', 'id', 'label', 'origin', 'mt_networks', 'dot_graph_id', 'dot_file', 'dot_node_id', 'dot_subgraphs']), + linkFields: this.collectImportFields(links, ['index', 'source', 'target', 'distance', 'weight', 'visible', 'cluster', 'origin', 'nn', 'directed', 'hasDistance', 'distanceOrigin', 'mt_networks', 'dot_graph_id', 'dot_file', 'dot_edge_id']), + graphIds: [graphId], + warnings + }; + } + + exportSession(session: any, options: GraphMLExportOptions = {}): GraphMLExportResult { + const data = session?.data ?? {}; + const nodes = Array.isArray(data.nodes) ? data.nodes : []; + const links = Array.isArray(data.links) ? data.links : []; + const generatedAt = options.generatedAt ?? new Date(); + const exportedAt = generatedAt instanceof Date ? generatedAt.toISOString() : String(generatedAt); + + const { nodeEntries, nodeIdByOriginalId } = this.buildNodeEntries(nodes); + const edgeEntries = this.buildEdgeEntries(links, nodeEntries, nodeIdByOriginalId); + const networkNames = this.collectNetworkNames(nodeEntries, edgeEntries); + const graphRecord = { + mt_exporter: 'MicrobeTrace', + mt_graphml_export_version: 1, + mt_exported_at: exportedAt, + mt_node_count: nodeEntries.length, + mt_edge_count: edgeEntries.length, + mt_network_count: networkNames.length, + mt_networks: networkNames.join('; '), + default_distance_metric: session?.style?.widgets?.['default-distance-metric'] ?? '', + link_threshold: session?.style?.widgets?.['link-threshold'] ?? '' + }; + + const graphKeys = this.buildKeys([graphRecord], ['graph'], 'graph'); + const nodeKeys = this.buildKeys(nodeEntries.map(entry => entry.record), data.nodeFields, 'node'); + const edgeKeys = this.buildKeys(edgeEntries.map(entry => entry.record), data.linkFields, 'edge'); + const keys = graphKeys.concat(nodeKeys, edgeKeys); + + const lines: string[] = [ + '', + `` + ]; + + keys.forEach(key => lines.push(this.renderKey(key))); + lines.push(` `); + this.renderDataElements(graphRecord, graphKeys, 4).forEach(line => lines.push(line)); + nodeEntries.forEach(entry => { + lines.push(` `); + this.renderDataElements(entry.record, nodeKeys, 6).forEach(line => lines.push(line)); + lines.push(' '); + }); + edgeEntries.forEach(entry => { + lines.push(` `); + this.renderDataElements(entry.record, edgeKeys, 6).forEach(line => lines.push(line)); + lines.push(' '); + }); + lines.push(' '); + lines.push(''); + + return { + contents: lines.join('\n') + '\n', + nodeCount: nodeEntries.length, + linkCount: edgeEntries.length, + networkCount: networkNames.length + }; + } + + private stripLeadingGMLTrivia(contents: string): string { + let index = 0; + + while (index < contents.length) { + const char = contents[index]; + const next = contents[index + 1]; + + if (/\s/.test(char)) { + index++; + continue; + } + + if (char === '#') { + while (index < contents.length && contents[index] !== '\n') { + index++; + } + continue; + } + + if (char === '/' && next === '/') { + index += 2; + while (index < contents.length && contents[index] !== '\n') { + index++; + } + continue; + } + + if (char === '/' && next === '*') { + index += 2; + while (index < contents.length && !(contents[index] === '*' && contents[index + 1] === '/')) { + index++; + } + index = Math.min(index + 2, contents.length); + continue; + } + + break; + } + + return contents.slice(index); + } + + private tokenizeGML(contents: string): GMLToken[] { + const tokens: GMLToken[] = []; + let index = 0; + const symbolChars = new Set(['[', ']']); + const push = (type: GMLTokenType, value: string, position: number, quoted = false) => { + tokens.push({ type, value, position, quoted }); + }; + + while (index < contents.length) { + const char = contents[index]; + const next = contents[index + 1]; + + if (/\s/.test(char)) { + index++; + continue; + } + + if (char === '#') { + while (index < contents.length && contents[index] !== '\n') { + index++; + } + continue; + } + + if (char === '/' && next === '/') { + index += 2; + while (index < contents.length && contents[index] !== '\n') { + index++; + } + continue; + } + + if (char === '/' && next === '*') { + const position = index; + index += 2; + while (index < contents.length && !(contents[index] === '*' && contents[index + 1] === '/')) { + index++; + } + if (index >= contents.length) { + this.throwGMLParseError('Unterminated block comment', { type: 'eof', value: '', position }); + } + index += 2; + continue; + } + + if (symbolChars.has(char)) { + push('symbol', char, index); + index++; + continue; + } + + if (char === '"' || char === "'") { + const quote = char; + const position = index; + let value = ''; + let closed = false; + index++; + + while (index < contents.length) { + const current = contents[index]; + const escaped = contents[index + 1]; + + if (current === quote) { + index++; + closed = true; + break; + } + + if (current === '\\') { + if (escaped === 'n') value += '\n'; + else if (escaped === 'r') value += '\r'; + else if (escaped === 't') value += '\t'; + else value += escaped ?? ''; + index += 2; + continue; + } + + value += current; + index++; + } + + if (!closed) { + this.throwGMLParseError('Unterminated quoted string', { type: 'eof', value: '', position }); + } + + push('value', value, position, true); + continue; + } + + const position = index; + while (index < contents.length) { + const current = contents[index]; + const following = contents[index + 1]; + if ( + /\s/.test(current) + || symbolChars.has(current) + || current === '#' + || (current === '/' && (following === '/' || following === '*')) + ) { + break; + } + index++; + } + + if (index === position) { + this.throwGMLParseError(`Unexpected character "${char}"`, { type: 'symbol', value: char, position }); + } + + push('value', contents.slice(position, index), position); + } + + tokens.push({ type: 'eof', value: '', position: contents.length }); + return tokens; + } + + private parseGMLDocument(contents: string): Record { + const tokens = this.tokenizeGML(contents); + let index = 0; + const peek = (): GMLToken => tokens[Math.min(index, tokens.length - 1)]; + const consume = (): GMLToken => tokens[index++]; + const matchSymbol = (symbol: string): boolean => { + if (peek().type === 'symbol' && peek().value === symbol) { + consume(); + return true; + } + return false; + }; + const expectSymbol = (symbol: string): void => { + if (!matchSymbol(symbol)) { + this.throwGMLParseError(`Expected "${symbol}"`, peek()); + } + }; + const parseValue = (): any => { + if (matchSymbol('[')) { + const nestedRecord = parseRecord(true); + expectSymbol(']'); + return nestedRecord; + } + + const token = peek(); + if (token.type !== 'value') { + this.throwGMLParseError('Expected a value', token); + } + + consume(); + return this.parseGMLScalar(token); + }; + const parseRecord = (untilCloseBracket: boolean): Record => { + const record: Record = {}; + + while (peek().type !== 'eof' && !(untilCloseBracket && peek().type === 'symbol' && peek().value === ']')) { + const keyToken = peek(); + if (keyToken.type !== 'value') { + this.throwGMLParseError('Expected a key', keyToken); + } + const key = consume().value; + const value = parseValue(); + this.addGMLRecordValue(record, key, value); + } + + return record; + }; + + // GML is a recursive key/value format; duplicate keys are preserved as + // arrays so repeated node/edge blocks and attributes are not lost. + const documentRecord = parseRecord(false); + if (peek().type !== 'eof') { + this.throwGMLParseError('Unexpected content after GML document', peek()); + } + + return documentRecord; + } + + private parseGMLScalar(token: GMLToken): any { + if (token.quoted) { + return token.value; + } + + const value = String(token.value ?? '').trim(); + if (/^(true|false)$/i.test(value)) { + return value.toLowerCase() === 'true'; + } + + if (/^-?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i.test(value)) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : value; + } + + return value; + } + + private addGMLRecordValue(record: Record, key: string, value: any): void { + if (!Object.prototype.hasOwnProperty.call(record, key)) { + record[key] = value; + return; + } + + record[key] = Array.isArray(record[key]) + ? record[key].concat([value]) + : [record[key], value]; + } + + private throwGMLParseError(message: string, token: GMLToken): never { + throw new Error(`Unable to parse GML file. ${message} at character ${token.position}.`); + } + + private asGMLRecordArray(value: any): Record[] { + const values = Array.isArray(value) + ? value + : (value === undefined || value === null ? [] : [value]); + + return values.filter((entry): entry is Record => + !!entry && typeof entry === 'object' && !Array.isArray(entry) + ); + } + + private copyGMLAttributes(record: Record, excludedFields: string[]): Record { + const excluded = new Set(excludedFields); + const output: Record = {}; + + Object.keys(record || {}).forEach(key => { + if (!excluded.has(key)) { + output[key] = record[key]; + } + }); + + return output; + } + + private countGMLAttributeValues(records: Record[], field: string): Map { + const counts = new Map(); + + records.forEach(record => { + const value = this.normalizeOptionalValue(record[field]); + if (value) { + counts.set(value, (counts.get(value) || 0) + 1); + } + }); + + return counts; + } + + private selectGMLNodeOriginalId( + dataRecord: Record, + gmlNodeId: string, + labelCounts: Map, + nameCounts: Map + ): string { + const explicitId = this.normalizeOptionalValue(dataRecord._id); + if (explicitId) { + return this.normalizeIdValue(explicitId); + } + + const label = this.normalizeOptionalValue(dataRecord.label); + if (label && labelCounts.get(label) === 1) { + return this.normalizeIdValue(label); + } + + const name = this.normalizeOptionalValue(dataRecord.name); + if (name && nameCounts.get(name) === 1) { + return this.normalizeIdValue(name); + } + + return gmlNodeId; + } + + private parseGMLDirectedValue(value: any): boolean | null { + if (value === undefined || value === null || value === '') { + return null; + } + + if (typeof value === 'boolean') { + return value; + } + + if (typeof value === 'number') { + return value !== 0; + } + + const normalized = String(value).trim().toLowerCase(); + if (normalized === 'true' || normalized === '1' || normalized === 'directed') { + return true; + } + if (normalized === 'false' || normalized === '0' || normalized === 'undirected') { + return false; + } + + return null; + } + + private prefixGMLGraphData(graphRecord: Record): Record { + const prefixed: Record = {}; + Object.keys(graphRecord).forEach(key => { + if (graphRecord[key] !== undefined && graphRecord[key] !== '') { + prefixed[`gml_graph_${key}`] = graphRecord[key]; + } + }); + return prefixed; + } + + private applyGMLEdgeOrigins( + record: Record, + sourceName: string, + graphOrigin: string, + forceGraphOrigin: boolean + ): void { + const edgeOrigins = this.normalizeOrigins(record.origin); + const allEdgeOrigins = this.normalizeOrigins(record._originAll); + const fallbackOrigins = [forceGraphOrigin ? graphOrigin : sourceName]; + const visibleOrigins = edgeOrigins.length > 0 + ? edgeOrigins + : (allEdgeOrigins.length > 0 ? allEdgeOrigins : fallbackOrigins); + const canonicalOrigins = allEdgeOrigins.length > 0 ? allEdgeOrigins : visibleOrigins; + + if (edgeOrigins.length > 0) { + record.gml_edge_origin = edgeOrigins.join('; '); + } + + if (allEdgeOrigins.length > 0) { + record.gml_edge_origin_all = allEdgeOrigins.join('; '); + } + + record.origin = this.prefixGraphMLImportedOrigins(visibleOrigins, sourceName); + record._originAll = this.prefixGraphMLImportedOrigins(canonicalOrigins, sourceName); + + const distanceOrigins = this.normalizeOrigins(record.distanceOrigins); + if (distanceOrigins.length > 0) { + record.gml_edge_distance_origins = distanceOrigins.join('; '); + record.distanceOrigins = this.prefixGraphMLImportedOrigins(distanceOrigins, sourceName); + } + + const distanceOrigin = String(record.distanceOrigin ?? '').trim(); + if (distanceOrigin.length > 0) { + record.gml_edge_distance_origin = distanceOrigin; + record.distanceOrigin = this.prefixGraphMLImportedOrigin(distanceOrigin, sourceName); + } + } + + private buildGeneratedGMLEndpointNode( + id: string, + gmlNodeId: string, + graphId: string, + sourceName: string, + graphOrigin: string, + forceGraphOrigin: boolean, + graphData: Record + ): Record { + const node = { + ...graphData, + _id: id, + id, + gml_node_id: gmlNodeId, + gml_graph_id: graphId, + gml_file: sourceName, + mt_generated_endpoint: true + }; + + this.applyGraphOrigin(node, graphOrigin, forceGraphOrigin); + this.addNetworkSummary(node, true); + return node; + } + + private stripLeadingDOTTrivia(contents: string): string { + let index = 0; + let lineStart = true; + + while (index < contents.length) { + const char = contents[index]; + const next = contents[index + 1]; + + if (/\s/.test(char)) { + lineStart = char === '\n' || char === '\r' ? true : lineStart; + index++; + continue; + } + + if (lineStart && char === '#') { + while (index < contents.length && contents[index] !== '\n') { + index++; + } + lineStart = true; + continue; + } + + if (char === '/' && next === '/') { + index += 2; + while (index < contents.length && contents[index] !== '\n') { + index++; + } + lineStart = true; + continue; + } + + if (char === '/' && next === '*') { + index += 2; + while (index < contents.length && !(contents[index] === '*' && contents[index + 1] === '/')) { + if (contents[index] === '\n' || contents[index] === '\r') { + lineStart = true; + } + index++; + } + index = Math.min(index + 2, contents.length); + continue; + } + + break; + } + + return contents.slice(index); + } + + private tokenizeDOT(contents: string): DOTToken[] { + const tokens: DOTToken[] = []; + let index = 0; + let lineStart = true; + const symbolChars = new Set(['{', '}', '[', ']', ';', ',', '=', ':', '+']); + + const push = (type: DOTTokenType, value: string, position: number, quoted = false) => { + tokens.push({ type, value, position, quoted }); + lineStart = false; + }; + + while (index < contents.length) { + const char = contents[index]; + const next = contents[index + 1]; + + if (/\s/.test(char)) { + if (char === '\n' || char === '\r') { + lineStart = true; + } + index++; + continue; + } + + if (lineStart && char === '#') { + while (index < contents.length && contents[index] !== '\n') { + index++; + } + lineStart = true; + continue; + } + + if (char === '/' && next === '/') { + index += 2; + while (index < contents.length && contents[index] !== '\n') { + index++; + } + lineStart = true; + continue; + } + + if (char === '/' && next === '*') { + index += 2; + while (index < contents.length && !(contents[index] === '*' && contents[index + 1] === '/')) { + if (contents[index] === '\n' || contents[index] === '\r') { + lineStart = true; + } + index++; + } + if (index >= contents.length) { + this.throwDOTParseError('Unterminated block comment', { type: 'eof', value: '', position: contents.length }); + } + index += 2; + continue; + } + + if ((char === '-' && next === '>') || (char === '-' && next === '-')) { + push('edgeop', char + next, index); + index += 2; + continue; + } + + if (symbolChars.has(char)) { + push('symbol', char, index); + index++; + continue; + } + + if (char === '"') { + const position = index; + let value = ''; + index++; + + while (index < contents.length) { + const current = contents[index]; + const escaped = contents[index + 1]; + + if (current === '"') { + index++; + push('id', value, position, true); + value = ''; + break; + } + + if (current === '\\') { + if (escaped === '\r' && contents[index + 2] === '\n') { + index += 3; + continue; + } + if (escaped === '\n' || escaped === '\r') { + index += 2; + continue; + } + if (escaped === 'n') value += '\n'; + else if (escaped === 'r') value += '\r'; + else if (escaped === 't') value += '\t'; + else value += escaped ?? ''; + index += 2; + continue; + } + + value += current; + index++; + } + + if (value.length > 0 || index >= contents.length) { + this.throwDOTParseError('Unterminated quoted string', { type: 'eof', value: '', position }); + } + continue; + } + + if (char === '<') { + const position = index; + let depth = 0; + let inQuote: string | null = null; + + while (index < contents.length) { + const current = contents[index]; + if (inQuote) { + if (current === inQuote && contents[index - 1] !== '\\') { + inQuote = null; + } + index++; + continue; + } + + if (current === '"' || current === "'") { + inQuote = current; + index++; + continue; + } + + if (current === '<') { + depth++; + } else if (current === '>') { + depth--; + if (depth === 0) { + index++; + push('id', contents.slice(position, index), position, true); + break; + } + } + index++; + } + + if (depth !== 0) { + this.throwDOTParseError('Unterminated HTML-like string', { type: 'eof', value: '', position }); + } + continue; + } + + const position = index; + while (index < contents.length) { + const current = contents[index]; + const following = contents[index + 1]; + if ( + /\s/.test(current) + || symbolChars.has(current) + || (current === '-' && (following === '>' || following === '-')) + || (current === '/' && (following === '/' || following === '*')) + ) { + break; + } + index++; + } + + if (index === position) { + this.throwDOTParseError(`Unexpected character "${char}"`, { type: 'symbol', value: char, position }); + } + + push('id', contents.slice(position, index), position); + } + + tokens.push({ type: 'eof', value: '', position: contents.length }); + return tokens; + } + + private parseDOTAttributeValue(rawValue: string, fieldName: string): any { + const value = String(rawValue ?? '').trim(); + const field = String(fieldName ?? '').trim(); + + if ((field === 'origin' || field === '_originAll' || field === 'distanceOrigins') && value.includes(';')) { + return value.split(';').map(part => part.trim()).filter(part => part.length > 0); + } + + if (/^\s*[\[{]/.test(value)) { + try { + // MicrobeTrace GraphML exports arrays/objects as JSON inside data tags. + return JSON.parse(value); + } catch { + return value; + } + } + + if (/^(true|false)$/i.test(value)) { + return value.toLowerCase() === 'true'; + } + + if (/^-?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i.test(value)) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : value; + } + + return value; + } + + private prefixDOTGraphData(graphRecord: Record): Record { + const prefixed: Record = {}; + Object.keys(graphRecord).forEach(key => { + if (graphRecord[key] !== undefined && graphRecord[key] !== '') { + prefixed[`dot_graph_${key}`] = graphRecord[key]; + } + }); + return prefixed; + } + + private applyDOTEdgeOrigins( + record: Record, + sourceName: string, + graphOrigin: string, + forceGraphOrigin: boolean + ): void { + const edgeOrigins = this.normalizeOrigins(record.origin); + const allEdgeOrigins = this.normalizeOrigins(record._originAll); + const fallbackOrigins = [forceGraphOrigin ? graphOrigin : sourceName]; + const visibleOrigins = edgeOrigins.length > 0 + ? edgeOrigins + : (allEdgeOrigins.length > 0 ? allEdgeOrigins : fallbackOrigins); + const canonicalOrigins = allEdgeOrigins.length > 0 ? allEdgeOrigins : visibleOrigins; + + if (edgeOrigins.length > 0) { + record.dot_edge_origin = edgeOrigins.join('; '); + } + + if (allEdgeOrigins.length > 0) { + record.dot_edge_origin_all = allEdgeOrigins.join('; '); + } + + record.origin = this.prefixGraphMLImportedOrigins(visibleOrigins, sourceName); + record._originAll = this.prefixGraphMLImportedOrigins(canonicalOrigins, sourceName); + + const distanceOrigins = this.normalizeOrigins(record.distanceOrigins); + if (distanceOrigins.length > 0) { + record.dot_edge_distance_origins = distanceOrigins.join('; '); + record.distanceOrigins = this.prefixGraphMLImportedOrigins(distanceOrigins, sourceName); + } + + const distanceOrigin = String(record.distanceOrigin ?? '').trim(); + if (distanceOrigin.length > 0) { + record.dot_edge_distance_origin = distanceOrigin; + record.distanceOrigin = this.prefixGraphMLImportedOrigin(distanceOrigin, sourceName); + } + } + + private throwDOTParseError(message: string, token: DOTToken): never { + throw new Error(`Unable to parse DOT file. ${message} at character ${token.position}.`); + } + + private parseJSONIfPossible(contents: any): any { + if (typeof contents !== 'string') { + return contents; + } + + const trimmed = contents.trim(); + if (!trimmed || !/^[\[{]/.test(trimmed)) { + return null; + } + + try { + return JSON.parse(trimmed); + } catch { + return null; + } + } + + private parseRequiredJSON(contents: any, errorMessage: string): any { + if (typeof contents !== 'string') { + return contents; + } + + try { + return JSON.parse(contents); + } catch (error: any) { + throw new Error(`${errorMessage} ${error?.message || error}`); + } + } + + private createWarningCollector(warnings: string[]): (key: string, message: string) => void { + const emitted = new Set(); + return (key: string, message: string) => { + if (!emitted.has(key)) { + emitted.add(key); + warnings.push(message); + } + }; + } + + private collectCX2Aspects(data: any[], warnings: string[]): Record { + const aspects: Record = {}; + + data.slice(1).forEach((block, blockIndex) => { + if (!block || typeof block !== 'object' || Array.isArray(block)) { + warnings.push(`CX2 block ${blockIndex + 2} was ignored because it is not an object.`); + return; + } + + Object.keys(block).forEach(aspectName => { + const aspectValue = block[aspectName]; + if (!Array.isArray(aspectValue)) { + warnings.push(`CX2 aspect "${aspectName}" was ignored because its value is not an array.`); + return; + } + + // Fragmented CX2 files may repeat an aspect across blocks; keep + // file order so later parsing sees the same concatenated stream. + aspects[aspectName] = aspects[aspectName] || []; + aspects[aspectName].push(...aspectValue); + }); + }); + + return aspects; + } + + private parseCX2AttributeDeclarations(entries: any[]): Record { + const declarations = { + networkAttributes: this.createEmptyCX2Declarations(), + nodes: this.createEmptyCX2Declarations(), + edges: this.createEmptyCX2Declarations() + }; + + entries + .filter(entry => !!entry && typeof entry === 'object') + .forEach(entry => { + (['networkAttributes', 'nodes', 'edges'] as CX2AspectDomain[]).forEach(domain => { + const domainDeclarations = entry[domain]; + if (!domainDeclarations || typeof domainDeclarations !== 'object') { + return; + } + + Object.keys(domainDeclarations).forEach(name => { + const rawDeclaration = domainDeclarations[name] || {}; + const type = this.normalizeCX2AttributeType(rawDeclaration.d); + const parsedDeclaration: CX2ParsedAttribute = { + name, + type, + alias: typeof rawDeclaration.a === 'string' ? rawDeclaration.a : undefined, + defaultValue: Object.prototype.hasOwnProperty.call(rawDeclaration, 'v') + ? this.parseCX2Value(rawDeclaration.v, type, name) + : undefined + }; + + declarations[domain].attributesByName.set(name, parsedDeclaration); + if (parsedDeclaration.alias) { + declarations[domain].nameByAlias.set(parsedDeclaration.alias, name); + } + }); + }); + }); + + return declarations; + } + + private createEmptyCX2Declarations(): CX2ParsedDeclarations { + return { + attributesByName: new Map(), + nameByAlias: new Map() + }; + } + + private normalizeCX2AttributeType(rawType: any): CX2AttributeType { + const type = String(rawType || 'string').toLowerCase(); + if (type === 'boolean') return 'boolean'; + if (type === 'integer' || type === 'int') return 'integer'; + if (type === 'long') return 'long'; + if (type === 'float' || type === 'double') return 'double'; + if (type === 'list_of_boolean') return 'list_of_boolean'; + if (type === 'list_of_integer' || type === 'list_of_int') return 'list_of_integer'; + if (type === 'list_of_long') return 'list_of_long'; + if (type === 'list_of_float' || type === 'list_of_double') return 'list_of_double'; + if (type === 'list_of_string') return 'list_of_string'; + return 'string'; + } + + private parseCX2NetworkAttributes(entries: any[], declarations: CX2ParsedDeclarations): Record { + if (entries.length === 0) { + return {}; + } + + return this.parseCX2DataRecord(entries[entries.length - 1], declarations); + } + + private parseCX2DataRecord(rawRecord: any, declarations: CX2ParsedDeclarations): Record { + const record: Record = {}; + + declarations.attributesByName.forEach(attribute => { + if (attribute.defaultValue !== undefined) { + record[attribute.name] = attribute.defaultValue; + } + }); + + if (!rawRecord || typeof rawRecord !== 'object' || Array.isArray(rawRecord)) { + return record; + } + + Object.keys(rawRecord).forEach(rawName => { + const name = declarations.nameByAlias.get(rawName) || rawName; + const declaration = declarations.attributesByName.get(name) || declarations.attributesByName.get(rawName); + // CX2 value blocks can use aliases, but MicrobeTrace fields should use + // the declared canonical names. + record[name] = this.parseCX2Value(rawRecord[rawName], declaration?.type || 'string', name); + }); + + return record; + } + + private parseCX2Value(rawValue: any, type: CX2AttributeType, fieldName: string): any { + if (rawValue === null || rawValue === undefined) { + return rawValue; + } + + if (type.startsWith('list_of_')) { + const itemType = type.replace('list_of_', '') as CX2AttributeType; + let values = rawValue; + + if (typeof rawValue === 'string') { + const trimmed = rawValue.trim(); + try { + // Some CX2 producers serialize list values as JSON strings. + const parsed = JSON.parse(trimmed); + values = Array.isArray(parsed) ? parsed : [parsed]; + } catch { + values = trimmed.length > 0 ? trimmed.split(',').map(part => part.trim()) : []; + } + } + + return (Array.isArray(values) ? values : [values]) + .map(value => this.parseCX2Value(value, itemType, fieldName)); + } + + if (type === 'boolean') { + if (typeof rawValue === 'boolean') { + return rawValue; + } + const value = String(rawValue).trim().toLowerCase(); + return value === 'true' || value === '1'; + } + + if (type === 'integer' || type === 'int' || type === 'long') { + const parsed = typeof rawValue === 'number' ? rawValue : parseInt(String(rawValue).trim(), 10); + return Number.isFinite(parsed) ? parsed : rawValue; + } + + if (type === 'double') { + const parsed = typeof rawValue === 'number' ? rawValue : parseFloat(String(rawValue).trim()); + return Number.isFinite(parsed) ? parsed : rawValue; + } + + if (typeof rawValue === 'string') { + return this.parseGraphMLValue(rawValue, 'string', fieldName); + } + + return rawValue; + } + + private prefixCX2NetworkData(networkRecord: Record): Record { + const prefixed: Record = {}; + Object.keys(networkRecord).forEach(key => { + if (networkRecord[key] !== undefined && networkRecord[key] !== '') { + prefixed[`cx2_network_${key}`] = networkRecord[key]; + } + }); + return prefixed; + } + + private parseCX2Nodes( + cxNodes: Record[], + declarations: CX2ParsedDeclarations, + nodeBypasses: Map> + ): CX2ParsedNode[] { + const parsedNodes = cxNodes.map((rawNode, index) => { + const cxNodeId = this.normalizeIdValue(rawNode.id ?? `node_${index + 1}`); + const dataRecord = this.parseCX2DataRecord(rawNode.v, declarations); + this.preserveReservedFields(dataRecord, ['id'], 'cx2_attribute'); + this.applyCX2BypassRecord(dataRecord, nodeBypasses.get(cxNodeId), 'cx2_bypass'); + + return { + cxNodeId, + dataRecord, + originalId: '', + rawNode + }; + }); + + const nameCounts = new Map(); + parsedNodes.forEach(parsedNode => { + const name = this.normalizeOptionalValue(parsedNode.dataRecord.name); + if (name) { + nameCounts.set(name, (nameCounts.get(name) || 0) + 1); + } + }); + + parsedNodes.forEach(parsedNode => { + const explicitId = this.normalizeOptionalValue(parsedNode.dataRecord._id); + const name = this.normalizeOptionalValue(parsedNode.dataRecord.name); + parsedNode.originalId = this.normalizeIdValue( + explicitId || (name && nameCounts.get(name) === 1 ? name : parsedNode.cxNodeId) + ); + }); + + return parsedNodes; + } + + private parseCX2Bypasses(entries: any[]): Map> { + const byId = new Map>(); + + entries + .filter(entry => !!entry && typeof entry === 'object') + .forEach(entry => { + const id = this.normalizeIdValue(entry.id); + const values = entry.v && typeof entry.v === 'object' && !Array.isArray(entry.v) + ? entry.v + : {}; + byId.set(id, values); + }); + + return byId; + } + + private applyCX2BypassRecord(record: Record, bypass: Record | undefined, prefix: string): void { + if (!bypass) { + return; + } + + Object.keys(bypass).forEach(key => { + record[`${prefix}_${key}`] = bypass[key]; + }); + } + + private applyCX2NodeCoordinates(record: Record, rawNode: Record): void { + (['x', 'y', 'z'] as const).forEach(axis => { + const coordinate = this.toFiniteNumber(rawNode[axis]); + if (coordinate === null) { + return; + } + + if (record[axis] !== undefined) { + record[`cx2_attribute_${axis}`] = record[axis]; + } + + record[axis] = coordinate; + record[`cx2_${axis}`] = coordinate; + }); + } + + private preserveReservedFields(record: Record, reservedFields: string[], prefix: string): void { + reservedFields.forEach(field => { + if (record[field] !== undefined) { + record[`${prefix}_${field}`] = record[field]; + delete record[field]; + } + }); + } + + private applyCX2EdgeOrigins( + record: Record, + sourceName: string, + graphOrigin: string, + forceGraphOrigin: boolean + ): void { + const edgeOrigins = this.normalizeOrigins(record.origin); + const allEdgeOrigins = this.normalizeOrigins(record._originAll); + const fallbackOrigins = [forceGraphOrigin ? graphOrigin : sourceName]; + const visibleOrigins = edgeOrigins.length > 0 + ? edgeOrigins + : (allEdgeOrigins.length > 0 ? allEdgeOrigins : fallbackOrigins); + const canonicalOrigins = allEdgeOrigins.length > 0 ? allEdgeOrigins : visibleOrigins; + + if (edgeOrigins.length > 0) { + record.cx2_edge_origin = edgeOrigins.join('; '); + } + + if (allEdgeOrigins.length > 0) { + record.cx2_edge_origin_all = allEdgeOrigins.join('; '); + } + + record.origin = this.prefixGraphMLImportedOrigins(visibleOrigins, sourceName); + record._originAll = this.prefixGraphMLImportedOrigins(canonicalOrigins, sourceName); + + const distanceOrigins = this.normalizeOrigins(record.distanceOrigins); + if (distanceOrigins.length > 0) { + record.cx2_edge_distance_origins = distanceOrigins.join('; '); + record.distanceOrigins = this.prefixGraphMLImportedOrigins(distanceOrigins, sourceName); + } + + const distanceOrigin = String(record.distanceOrigin ?? '').trim(); + if (distanceOrigin.length > 0) { + record.cx2_edge_distance_origin = distanceOrigin; + record.distanceOrigin = this.prefixGraphMLImportedOrigin(distanceOrigin, sourceName); + } + } + + private buildGeneratedCX2EndpointNode( + id: string, + cxNodeId: string, + graphId: string, + sourceName: string, + graphData: Record + ): Record { + const node = { + ...graphData, + _id: id, + id, + cx2_node_id: cxNodeId, + cx2_graph_id: graphId, + cx2_file: sourceName, + mt_generated_endpoint: true + }; + + this.applyGraphOrigin(node, sourceName, false); + this.addNetworkSummary(node, true); + return node; + } + + private parseGEXFAttributes(graphElement: Element): Map { + const attributes = new Map(); + + this.getChildElements(graphElement, 'attributes').forEach((attributesElement) => { + const rawClass = String(attributesElement.getAttribute('class') || 'all').toLowerCase(); + const className: GEXFAttributeClass = rawClass === 'graph' + || rawClass === 'node' + || rawClass === 'edge' + ? rawClass + : 'all'; + const mode = String(attributesElement.getAttribute('mode') || 'static').toLowerCase(); + + this.getChildElements(attributesElement, 'attribute').forEach((attributeElement, index) => { + const id = attributeElement.getAttribute('id') || `${className}_${index + 1}`; + const name = attributeElement.getAttribute('title') || id; + const type = this.normalizeGEXFAttributeType(attributeElement.getAttribute('type')); + const defaultElement = this.getChildElements(attributeElement, 'default')[0]; + + attributes.set(id, { + id, + name, + type, + className, + mode, + defaultValue: defaultElement ? this.parseGraphMLValue(defaultElement.textContent || '', type, name) : undefined + }); + }); + }); + + return attributes; + } + + private normalizeGEXFAttributeType(rawType: any): GraphMLAttributeType { + const type = String(rawType || 'string').toLowerCase(); + if (type === 'boolean') { + return 'boolean'; + } + if (type === 'integer' || type === 'int') { + return 'int'; + } + if (type === 'long') { + return 'long'; + } + if (type === 'float' || type === 'double') { + return 'double'; + } + return 'string'; + } + + private parseGEXFDataRecord( + element: Element, + attributes: Map, + domain: GEXFAttributeClass + ): Record { + const record: Record = {}; + + attributes.forEach(attribute => { + if ((attribute.className === domain || attribute.className === 'all') && attribute.defaultValue !== undefined) { + record[attribute.name] = attribute.defaultValue; + } + }); + + this.getChildElements(element, 'attvalues').forEach(attvaluesElement => { + this.getChildElements(attvaluesElement, 'attvalue').forEach(attvalueElement => { + const attributeId = attvalueElement.getAttribute('for') || ''; + const attribute = attributes.get(attributeId); + const name = attribute?.name || attributeId; + + if (!name) { + return; + } + + const rawValue = attvalueElement.getAttribute('value') ?? (attvalueElement.textContent || ''); + const value = this.parseGraphMLValue(rawValue, attribute?.type || 'string', name); + record[name] = value; + + const temporalRecord = this.extractGEXFTemporalRecord(attvalueElement); + if (temporalRecord) { + record.gexf_dynamic_attvalues = Array.isArray(record.gexf_dynamic_attvalues) + ? record.gexf_dynamic_attvalues + : []; + record.gexf_dynamic_attvalues.push({ + field: name, + value, + ...temporalRecord + }); + } + }); + }); + + return record; + } + + private parseGEXFMetaRecord(gexfElement: Element): Record { + const metaElement = this.getChildElements(gexfElement, 'meta')[0]; + const record: Record = {}; + + if (!metaElement) { + return record; + } + + Array.from(metaElement.attributes || []).forEach(attribute => { + record[`meta_${attribute.name}`] = attribute.value; + }); + + this.getChildElements(metaElement).forEach(child => { + const name = this.getLocalName(child); + const value = (child.textContent || '').trim(); + if (name && value) { + record[`meta_${name}`] = value; + } + }); + + return record; + } + + private prefixGEXFGraphData(graphRecord: Record): Record { + const prefixed: Record = {}; + Object.keys(graphRecord).forEach(key => { + if (graphRecord[key] !== undefined && graphRecord[key] !== '') { + prefixed[`gexf_graph_${key}`] = graphRecord[key]; + } + }); + return prefixed; + } + + private collectGEXFNodeElements(graphElement: Element): Array<{ element: Element; parentId?: string }> { + const nodes: Array<{ element: Element; parentId?: string }> = []; + const visitNodesContainer = (nodesElement: Element, parentId?: string) => { + this.getChildElements(nodesElement, 'node').forEach(nodeElement => { + nodes.push({ element: nodeElement, parentId }); + const nodeId = this.normalizeIdValue(nodeElement.getAttribute('id')); + this.getChildElements(nodeElement, 'nodes').forEach(childNodesElement => { + visitNodesContainer(childNodesElement, nodeId); + }); + }); + }; + + this.getChildElements(graphElement, 'nodes').forEach(nodesElement => visitNodesContainer(nodesElement)); + return nodes; + } + + private collectGEXFEdgeElements(graphElement: Element): Element[] { + return this.getChildElements(graphElement, 'edges') + .reduce((edges, edgesElement) => edges.concat(this.getChildElements(edgesElement, 'edge')), [] as Element[]); + } + + private addGEXFTemporalMetadata(record: Record, element: Element): boolean { + let hasTemporalMetadata = false; + const temporalRecord = this.extractGEXFTemporalRecord(element); + if (temporalRecord) { + Object.keys(temporalRecord).forEach(key => { + record[`gexf_${key}`] = temporalRecord[key]; + }); + hasTemporalMetadata = true; + } + + const spells = this.getChildElements(element, 'spells') + .reduce((items, spellsElement) => items.concat( + this.getChildElements(spellsElement, 'spell') + .map(spellElement => this.extractGEXFTemporalRecord(spellElement)) + .filter((spell): spell is Record => !!spell) + ), [] as Record[]); + + if (spells.length > 0) { + record.gexf_spells = spells; + hasTemporalMetadata = true; + } + + return hasTemporalMetadata; + } + + private extractGEXFTemporalRecord(element: Element): Record | null { + const record: Record = {}; + ['start', 'end', 'startopen', 'endopen', 'timestamp'].forEach(attributeName => { + const value = this.normalizeOptionalValue(element.getAttribute(attributeName)); + if (value) { + record[attributeName] = value; + } + }); + return Object.keys(record).length > 0 ? record : null; + } + + private addGEXFVizMetadata(record: Record, element: Element): boolean { + let hasVizMetadata = false; + const colorElement = this.getChildElements(element, 'color')[0]; + const positionElement = this.getChildElements(element, 'position')[0]; + const sizeElement = this.getChildElements(element, 'size')[0]; + const shapeElement = this.getChildElements(element, 'shape')[0]; + + if (colorElement) { + const hex = this.normalizeOptionalValue(colorElement.getAttribute('hex')); + const r = this.toFiniteNumber(colorElement.getAttribute('r')); + const g = this.toFiniteNumber(colorElement.getAttribute('g')); + const b = this.toFiniteNumber(colorElement.getAttribute('b')); + const a = this.toFiniteNumber(colorElement.getAttribute('a')); + + if (hex) { + record.gexf_viz_color = hex; + } + if (r !== null) record.gexf_viz_color_r = r; + if (g !== null) record.gexf_viz_color_g = g; + if (b !== null) record.gexf_viz_color_b = b; + if (a !== null) record.gexf_viz_color_a = a; + hasVizMetadata = true; + } + + if (positionElement) { + const x = this.toFiniteNumber(positionElement.getAttribute('x')); + const y = this.toFiniteNumber(positionElement.getAttribute('y')); + const z = this.toFiniteNumber(positionElement.getAttribute('z')); + if (x !== null) record.gexf_viz_x = x; + if (y !== null) record.gexf_viz_y = y; + if (z !== null) record.gexf_viz_z = z; + hasVizMetadata = true; + } + + if (sizeElement) { + const size = this.toFiniteNumber(sizeElement.getAttribute('value')); + if (size !== null) { + record.gexf_viz_size = size; + } + hasVizMetadata = true; + } + + if (shapeElement) { + const shape = this.normalizeOptionalValue(shapeElement.getAttribute('value')); + if (shape) { + record.gexf_viz_shape = shape; + } + hasVizMetadata = true; + } + + return hasVizMetadata; + } + + private isGEXFEdgeDirected(edgeElement: Element, edgeDefault: string): boolean { + const explicitType = String(edgeElement.getAttribute('type') ?? edgeElement.getAttribute('directed') ?? '').toLowerCase(); + if (explicitType === 'directed' || explicitType === 'mutual' || explicitType === 'true') { + return true; + } + if (explicitType === 'undirected' || explicitType === 'false') { + return false; + } + return edgeDefault === 'directed' || edgeDefault === 'mutual'; + } + + private applyGEXFEdgeOrigins( + record: Record, + sourceName: string, + graphOrigin: string, + forceGraphOrigin: boolean + ): void { + const edgeOrigins = this.normalizeOrigins(record.origin); + const allEdgeOrigins = this.normalizeOrigins(record._originAll); + const fallbackOrigins = [forceGraphOrigin ? graphOrigin : sourceName]; + const visibleOrigins = edgeOrigins.length > 0 + ? edgeOrigins + : (allEdgeOrigins.length > 0 ? allEdgeOrigins : fallbackOrigins); + const canonicalOrigins = allEdgeOrigins.length > 0 ? allEdgeOrigins : visibleOrigins; + + if (edgeOrigins.length > 0) { + record.gexf_edge_origin = edgeOrigins.join('; '); + } + + if (allEdgeOrigins.length > 0) { + record.gexf_edge_origin_all = allEdgeOrigins.join('; '); + } + + record.origin = this.prefixGraphMLImportedOrigins(visibleOrigins, sourceName); + record._originAll = this.prefixGraphMLImportedOrigins(canonicalOrigins, sourceName); + + const distanceOrigins = this.normalizeOrigins(record.distanceOrigins); + if (distanceOrigins.length > 0) { + record.gexf_edge_distance_origins = distanceOrigins.join('; '); + record.distanceOrigins = this.prefixGraphMLImportedOrigins(distanceOrigins, sourceName); + } + + const distanceOrigin = String(record.distanceOrigin ?? '').trim(); + if (distanceOrigin.length > 0) { + record.gexf_edge_distance_origin = distanceOrigin; + record.distanceOrigin = this.prefixGraphMLImportedOrigin(distanceOrigin, sourceName); + } + } + + private buildGeneratedGEXFEndpointNode( + id: string, + gexfNodeId: string, + graphId: string, + sourceName: string, + graphOrigin: string, + forceGraphOrigin: boolean, + graphData: Record + ): Record { + const node = { + ...graphData, + _id: id, + id, + gexf_node_id: gexfNodeId, + gexf_graph_id: graphId, + gexf_file: sourceName, + mt_generated_endpoint: true + }; + + this.applyGraphOrigin(node, graphOrigin, forceGraphOrigin); + this.addNetworkSummary(node, true); + return node; + } + + private copyXGMMLAttributes(element: Element, excludedFields: string[]): Record { + const record: Record = {}; + const excluded = new Set(excludedFields.map(field => field.toLowerCase())); + + Array.from(element.attributes || []).forEach(attribute => { + if (attribute.name === 'xmlns' || attribute.name.startsWith('xmlns:')) { + return; + } + + const name = this.getXGMMLAttributeName(attribute); + if (!name || excluded.has(name.toLowerCase()) || excluded.has(attribute.name.toLowerCase())) { + return; + } + + record[name] = this.parseXGMMLValue(attribute.value, 'string', name); + }); + + return record; + } + + private parseXGMMLAttRecord(element: Element): Record { + const record: Record = {}; + + this.getChildElements(element, 'att').forEach(attElement => { + const name = this.normalizeOptionalValue( + attElement.getAttribute('name') + || attElement.getAttribute('label') + || attElement.getAttribute('id') + ); + + if (!name) { + return; + } + + this.addXGMMLRecordValue(record, name, this.parseXGMMLAttValue(attElement, name)); + }); + + return record; + } + + private parseXGMMLAttValue(attElement: Element, fieldName: string): any { + const rawType = attElement.getAttribute('type') || 'string'; + const type = String(rawType).toLowerCase(); + const childAttributes = this.getChildElements(attElement, 'att'); + const hasValueAttribute = attElement.hasAttribute('value'); + + if ((type === 'list' || type.endsWith('list') || type.startsWith('list_')) && childAttributes.length > 0) { + return childAttributes.map(child => { + const childName = this.normalizeOptionalValue( + child.getAttribute('name') + || child.getAttribute('label') + || child.getAttribute('id') + ); + const childValue = this.parseXGMMLAttValue(child, childName || fieldName); + return childName ? { name: childName, value: childValue } : childValue; + }); + } + + if (!hasValueAttribute && childAttributes.length > 0) { + return childAttributes.map(child => { + const childName = this.normalizeOptionalValue( + child.getAttribute('name') + || child.getAttribute('label') + || child.getAttribute('id') + ); + const childValue = this.parseXGMMLAttValue(child, childName || fieldName); + return childName ? { name: childName, value: childValue } : childValue; + }); + } + + const rawValue = hasValueAttribute + ? attElement.getAttribute('value') + : (attElement.textContent || ''); + + return this.parseXGMMLValue(rawValue, rawType, fieldName); + } + + private parseXGMMLValue(rawValue: any, rawType: any, fieldName: string): any { + const type = String(rawType || 'string').toLowerCase(); + const value = String(rawValue ?? '').trim(); + + if (type === 'boolean' || type === 'bool') { + const parsed = this.parseXGMMLBooleanValue(value); + return parsed === null ? value : parsed; + } + + if (type === 'integer' || type === 'int' || type === 'long') { + const parsed = parseInt(value, 10); + return Number.isFinite(parsed) ? parsed : value; + } + + if (type === 'real' || type === 'float' || type === 'double' || type === 'number') { + const parsed = parseFloat(value); + return Number.isFinite(parsed) ? parsed : value; + } + + return this.parseGraphMLValue(value, 'string', fieldName); + } + + private addXGMMLRecordValue(record: Record, key: string, value: any): void { + if (!Object.prototype.hasOwnProperty.call(record, key)) { + record[key] = value; + return; + } + + if (Array.isArray(record[key])) { + record[key].push(value); + return; + } + + record[key] = [record[key], value]; + } + + private countRecordValues(records: Record[], field: string): Map { + const counts = new Map(); + records.forEach(record => { + const value = this.normalizeOptionalValue(record[field]); + if (value) { + counts.set(value, (counts.get(value) || 0) + 1); + } + }); + return counts; + } + + private selectXGMMLNodeOriginalId( + dataRecord: Record, + xgmmlNodeId: string, + labelCounts: Map, + nameCounts: Map, + sharedNameCounts: Map + ): string { + const explicitId = this.normalizeOptionalValue(dataRecord._id); + const name = this.normalizeOptionalValue(dataRecord.name); + const sharedName = this.normalizeOptionalValue(dataRecord['shared name']); + const label = this.normalizeOptionalValue(dataRecord.label); + + // Prefer human-readable IDs only when they are unique; otherwise keep + // the XGMML id to avoid merging distinct nodes. + return this.normalizeIdValue( + explicitId + || (name && nameCounts.get(name) === 1 ? name : '') + || (sharedName && sharedNameCounts.get(sharedName) === 1 ? sharedName : '') + || (label && labelCounts.get(label) === 1 ? label : '') + || xgmmlNodeId + ); + } + + private parseXGMMLBooleanValue(value: any): boolean | null { + const normalized = String(value ?? '').trim().toLowerCase(); + if (!normalized) { + return null; + } + if (normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'directed') { + return true; + } + if (normalized === 'false' || normalized === '0' || normalized === 'no' || normalized === 'undirected') { + return false; + } + return null; + } + + private prefixXGMMLGraphData(graphRecord: Record): Record { + const prefixed: Record = {}; + Object.keys(graphRecord).forEach(key => { + if (graphRecord[key] !== undefined && graphRecord[key] !== '') { + prefixed[`xgmml_graph_${key}`] = graphRecord[key]; + } + }); + return prefixed; + } + + private applyXGMMLGraphics(record: Record, element: Element, domain: 'node' | 'edge'): void { + this.getChildElements(element, 'graphics').forEach((graphicsElement, graphicsIndex) => { + const suffix = graphicsIndex === 0 ? '' : `_${graphicsIndex + 1}`; + + Array.from(graphicsElement.attributes || []).forEach(attribute => { + const name = this.getXGMMLAttributeName(attribute); + if (!name) { + return; + } + + const numberValue = this.toFiniteNumber(attribute.value); + const value = numberValue === null ? attribute.value : numberValue; + record[`xgmml_graphics${suffix}_${name}`] = value; + + if (domain === 'node' && (name === 'x' || name === 'y' || name === 'z') && numberValue !== null) { + // Node coordinates are both preserved as XGMML metadata and + // copied to x/y/z so existing MicrobeTrace layouts can use them. + if (record[name] !== undefined) { + record[`xgmml_attribute_${name}`] = record[name]; + } + record[name] = numberValue; + record[`xgmml_${name}`] = numberValue; + } + }); + }); + } + + private applyXGMMLEdgeOrigins( + record: Record, + sourceName: string, + graphOrigin: string, + forceGraphOrigin: boolean + ): void { + const edgeOrigins = this.normalizeOrigins(record.origin); + const allEdgeOrigins = this.normalizeOrigins(record._originAll); + const fallbackOrigins = [forceGraphOrigin ? graphOrigin : sourceName]; + const visibleOrigins = edgeOrigins.length > 0 + ? edgeOrigins + : (allEdgeOrigins.length > 0 ? allEdgeOrigins : fallbackOrigins); + const canonicalOrigins = allEdgeOrigins.length > 0 ? allEdgeOrigins : visibleOrigins; + + if (edgeOrigins.length > 0) { + record.xgmml_edge_origin = edgeOrigins.join('; '); + } + + if (allEdgeOrigins.length > 0) { + record.xgmml_edge_origin_all = allEdgeOrigins.join('; '); + } + + record.origin = this.prefixGraphMLImportedOrigins(visibleOrigins, sourceName); + record._originAll = this.prefixGraphMLImportedOrigins(canonicalOrigins, sourceName); + + const distanceOrigins = this.normalizeOrigins(record.distanceOrigins); + if (distanceOrigins.length > 0) { + record.xgmml_edge_distance_origins = distanceOrigins.join('; '); + record.distanceOrigins = this.prefixGraphMLImportedOrigins(distanceOrigins, sourceName); + } + + const distanceOrigin = String(record.distanceOrigin ?? '').trim(); + if (distanceOrigin.length > 0) { + record.xgmml_edge_distance_origin = distanceOrigin; + record.distanceOrigin = this.prefixGraphMLImportedOrigin(distanceOrigin, sourceName); + } + } + + private buildGeneratedXGMMLEndpointNode( + id: string, + xgmmlNodeId: string, + graphId: string, + sourceName: string, + graphData: Record + ): Record { + const node = { + ...graphData, + _id: id, + id, + xgmml_node_id: xgmmlNodeId, + xgmml_graph_id: graphId, + xgmml_file: sourceName, + mt_generated_endpoint: true + }; + + this.applyGraphOrigin(node, sourceName, false); + this.addNetworkSummary(node, true); + return node; + } + + private normalizeOptionalValue(value: any): string { + return String(value ?? '').trim(); + } + + private parseGraphMLKeys(graphmlElement: Element): Map { + const keys = new Map(); + this.getChildElements(graphmlElement, 'key').forEach((keyElement, index) => { + const id = keyElement.getAttribute('id') || `key_${index + 1}`; + const rawDomain = String(keyElement.getAttribute('for') || 'all').toLowerCase(); + const domain = rawDomain === 'graph' || rawDomain === 'node' || rawDomain === 'edge' ? rawDomain : 'all'; + const rawType = String(keyElement.getAttribute('attr.type') || 'string').toLowerCase(); + const type: GraphMLAttributeType = rawType === 'boolean' + || rawType === 'int' + || rawType === 'long' + || rawType === 'float' + || rawType === 'double' + || rawType === 'string' + ? (rawType === 'float' ? 'double' : rawType) + : 'string'; + const name = keyElement.getAttribute('attr.name') || id; + const defaultElement = this.getChildElements(keyElement, 'default')[0]; + + keys.set(id, { + id, + name, + type, + for: domain, + defaultValue: defaultElement ? this.parseGraphMLValue(defaultElement.textContent || '', type, name) : undefined + }); + }); + + return keys; + } + + private parseDataRecord(element: Element, keys: Map, domain: GraphMLKeyDomain): Record { + const record: Record = {}; + + keys.forEach(key => { + if ((key.for === domain || key.for === 'all') && key.defaultValue !== undefined) { + record[key.name] = key.defaultValue; + } + }); + + this.getChildElements(element, 'data').forEach(dataElement => { + const keyId = dataElement.getAttribute('key') || ''; + const key = keys.get(keyId); + const name = key?.name || keyId; + + if (!name) { + return; + } + + record[name] = this.parseGraphMLValue(dataElement.textContent || '', key?.type || 'string', name); + }); + + return record; + } + + private parseGraphMLValue(rawValue: string, type: GraphMLAttributeType, fieldName: string): any { + const value = rawValue.trim(); + + if (type === 'boolean') { + return value.toLowerCase() === 'true' || value === '1'; + } + + if (type === 'int' || type === 'long') { + const parsed = parseInt(value, 10); + return Number.isFinite(parsed) ? parsed : value; + } + + if (type === 'double') { + const parsed = parseFloat(value); + return Number.isFinite(parsed) ? parsed : value; + } + + if (/^\s*[\[{]/.test(value)) { + try { + return JSON.parse(value); + } catch { + return value; + } + } + + if ((fieldName === 'origin' || fieldName === '_originAll' || fieldName === 'distanceOrigins') && value.includes(';')) { + return value.split(';').map(part => part.trim()).filter(part => part.length > 0); + } + + return value; + } + + private prefixGraphData(graphRecord: Record): Record { + const prefixed: Record = {}; + Object.keys(graphRecord).forEach(key => { + prefixed[`graphml_graph_${key}`] = graphRecord[key]; + }); + return prefixed; + } + + private applyGraphOrigin(record: Record, graphOrigin: string, forceGraphOrigin: boolean): void { + const existingOrigins = this.normalizeOrigins(record.origin ?? record._originAll); + record.origin = forceGraphOrigin + ? this.uniqStrings(existingOrigins.concat([graphOrigin])) + : (existingOrigins.length > 0 ? existingOrigins : [graphOrigin]); + } + + private applyGraphMLEdgeOrigins( + record: Record, + sourceName: string, + graphOrigin: string, + forceGraphOrigin: boolean + ): void { + const edgeOrigins = this.normalizeOrigins(record.origin); + const allEdgeOrigins = this.normalizeOrigins(record._originAll); + const fallbackOrigins = [forceGraphOrigin ? graphOrigin : sourceName]; + const visibleOrigins = edgeOrigins.length > 0 + ? edgeOrigins + : (allEdgeOrigins.length > 0 ? allEdgeOrigins : fallbackOrigins); + const canonicalOrigins = allEdgeOrigins.length > 0 ? allEdgeOrigins : visibleOrigins; + + // Keep original edge-origin fields for audit while using filename-scoped + // origins in MicrobeTrace to avoid collisions across imported files. + if (edgeOrigins.length > 0) { + record.graphml_edge_origin = edgeOrigins.join('; '); + } + + if (allEdgeOrigins.length > 0) { + record.graphml_edge_origin_all = allEdgeOrigins.join('; '); + } + + record.origin = this.prefixGraphMLImportedOrigins(visibleOrigins, sourceName); + record._originAll = this.prefixGraphMLImportedOrigins(canonicalOrigins, sourceName); + + const distanceOrigins = this.normalizeOrigins(record.distanceOrigins); + if (distanceOrigins.length > 0) { + record.graphml_edge_distance_origins = distanceOrigins.join('; '); + record.distanceOrigins = this.prefixGraphMLImportedOrigins(distanceOrigins, sourceName); + } + + const distanceOrigin = String(record.distanceOrigin ?? '').trim(); + if (distanceOrigin.length > 0) { + record.graphml_edge_distance_origin = distanceOrigin; + record.distanceOrigin = this.prefixGraphMLImportedOrigin(distanceOrigin, sourceName); + } + } + + private prefixGraphMLImportedOrigins(origins: string[], sourceName: string): string[] { + return this.uniqStrings(origins.map(origin => this.prefixGraphMLImportedOrigin(origin, sourceName))); + } + + private prefixGraphMLImportedOrigin(origin: string, sourceName: string): string { + const normalizedSourceName = this.normalizeIdValue(sourceName); + const normalizedOrigin = this.normalizeIdValue(origin); + + if (normalizedOrigin === normalizedSourceName || normalizedOrigin.startsWith(`${normalizedSourceName}-`)) { + return normalizedOrigin; + } + + return `${normalizedSourceName}-${normalizedOrigin}`; + } + + private getDistanceOriginFallback(linkRecord: Record, sourceName: string): string { + return this.normalizeOrigins(linkRecord.origin)[0] || this.normalizeIdValue(sourceName); + } + + private buildGeneratedEndpointNode( + id: string, + graphmlNodeId: string, + graphId: string, + sourceName: string, + graphOrigin: string, + forceGraphOrigin: boolean, + graphData: Record + ): Record { + const node = { + ...graphData, + _id: id, + id, + graphml_node_id: graphmlNodeId, + graphml_graph_id: graphId, + graphml_file: sourceName, + mt_generated_endpoint: true + }; + + this.applyGraphOrigin(node, graphOrigin, forceGraphOrigin); + this.addNetworkSummary(node, true); + return node; + } + + private normalizeImportedDistance(linkRecord: Record, distanceOrigin: string): void { + const distanceFields = [ + 'distance', + 'Distance', + 'length', + 'Length', + 'weight', + 'Weight', + 'snps', + 'SNPs', + 'tn93', + 'TN93', + 'genetic_distance', + 'mean_genetic_distance' + ]; + + const distanceField = distanceFields.find(field => this.toFiniteNumber(linkRecord[field]) !== null); + const distance = distanceField ? this.toFiniteNumber(linkRecord[distanceField]) : null; + const explicitHasDistance = typeof linkRecord.hasDistance === 'boolean' ? linkRecord.hasDistance : null; + + if (explicitHasDistance === false) { + linkRecord.distance = distance ?? this.toFiniteNumber(linkRecord.distance) ?? 0; + linkRecord.hasDistance = false; + delete linkRecord.distanceOrigins; + delete linkRecord.distanceOrigin; + return; + } + + if (explicitHasDistance === true) { + linkRecord.distance = distance ?? this.toFiniteNumber(linkRecord.distance) ?? 0; + linkRecord.hasDistance = true; + this.ensureDistanceOrigin(linkRecord, distanceOrigin); + return; + } + + if (distance !== null) { + linkRecord.distance = distance; + linkRecord.hasDistance = true; + this.ensureDistanceOrigin(linkRecord, distanceOrigin); + return; + } + + linkRecord.distance = this.toFiniteNumber(linkRecord.distance) ?? 0; + linkRecord.hasDistance = false; + } + + private ensureDistanceOrigin(linkRecord: Record, fallbackOrigin: string): void { + const distanceOrigins = this.normalizeOrigins(linkRecord.distanceOrigins); + if (distanceOrigins.length > 0) { + linkRecord.distanceOrigins = distanceOrigins; + if (!distanceOrigins.includes(linkRecord.distanceOrigin)) { + linkRecord.distanceOrigin = distanceOrigins[0]; + } + return; + } + + if (typeof linkRecord.distanceOrigin === 'string' && linkRecord.distanceOrigin.trim().length > 0) { + linkRecord.distanceOrigin = linkRecord.distanceOrigin.trim(); + linkRecord.distanceOrigins = [linkRecord.distanceOrigin]; + return; + } + + linkRecord.distanceOrigin = fallbackOrigin; + linkRecord.distanceOrigins = [fallbackOrigin]; + } + + private toFiniteNumber(value: any): number | null { + if (value === null || value === undefined || value === '') { + return null; + } + + const parsed = typeof value === 'number' ? value : Number(value); + return Number.isFinite(parsed) ? parsed : null; + } + + private buildNodeEntries(nodes: any[]): { nodeEntries: GraphMLNodeEntry[]; nodeIdByOriginalId: Map } { + const usedGraphmlIds = new Set(); + const nodeIdByOriginalId = new Map(); + const nodeEntries: GraphMLNodeEntry[] = []; + + nodes.forEach((node, index) => { + const originalId = this.getNodeId(node, index); + const graphmlId = this.toGraphMLId(originalId, 'n', index, usedGraphmlIds); + nodeIdByOriginalId.set(originalId, graphmlId); + nodeEntries.push({ + graphmlId, + originalId, + record: this.prepareNodeRecord(node, originalId) + }); + }); + + return { nodeEntries, nodeIdByOriginalId }; + } + + private buildEdgeEntries( + links: any[], + nodeEntries: GraphMLNodeEntry[], + nodeIdByOriginalId: Map + ): GraphMLEdgeEntry[] { + const usedEdgeIds = new Set(); + + return links.map((link, index) => { + const sourceId = this.getEndpointId(link?.source, `missing_source_${index}`); + const targetId = this.getEndpointId(link?.target, `missing_target_${index}`); + const sourceGraphmlId = this.ensureEndpointNode(sourceId, nodeEntries, nodeIdByOriginalId); + const targetGraphmlId = this.ensureEndpointNode(targetId, nodeEntries, nodeIdByOriginalId); + const edgeSourceId = link?.id ?? `${sourceId}-${targetId}`; + + return { + graphmlId: this.toGraphMLId(edgeSourceId, 'e', index, usedEdgeIds), + sourceGraphmlId, + targetGraphmlId, + directed: link?.directed === true, + record: this.prepareEdgeRecord(link, sourceId, targetId) + }; + }); + } + + private ensureEndpointNode( + originalId: string, + nodeEntries: GraphMLNodeEntry[], + nodeIdByOriginalId: Map + ): string { + const existingGraphmlId = nodeIdByOriginalId.get(originalId); + if (existingGraphmlId) { + return existingGraphmlId; + } + + const usedGraphmlIds = new Set(nodeEntries.map(entry => entry.graphmlId)); + const graphmlId = this.toGraphMLId(originalId, 'n', nodeEntries.length, usedGraphmlIds); + const record = this.prepareNodeRecord({ + _id: originalId, + id: originalId, + mt_generated_endpoint: true + }, originalId); + + nodeEntries.push({ graphmlId, originalId, record }); + nodeIdByOriginalId.set(originalId, graphmlId); + + return graphmlId; + } + + private prepareNodeRecord(node: any, originalId: string): Record { + const record = this.copyPlainRecord(node); + record._id = originalId; + record.id = originalId; + this.addNetworkSummary(record); + return record; + } + + private prepareEdgeRecord(link: any, sourceId: string, targetId: string): Record { + const record = this.copyPlainRecord(link); + record.source = sourceId; + record.target = targetId; + record.directed = link?.directed === true; + this.addNetworkSummary(record); + return record; + } + + private copyPlainRecord(record: any): Record { + const output: Record = {}; + + if (!record || typeof record !== 'object') { + return output; + } + + Object.keys(record).forEach(key => { + const value = record[key]; + if (typeof value !== 'function' && value !== undefined) { + output[key] = value; + } + }); + + return output; + } + + private addNetworkSummary(record: Record, force = false): void { + if (!force && Object.prototype.hasOwnProperty.call(record, 'mt_networks')) { + return; + } + + const origins = this.normalizeOrigins(record.origin ?? record._originAll); + if (origins.length > 0) { + record.mt_networks = origins.join('; '); + } + } + + private getNodeId(node: any, index: number): string { + return this.normalizeIdValue(node?._id ?? node?.id ?? `node_${index}`); + } + + private getEndpointId(endpoint: any, fallback: string): string { + if (endpoint && typeof endpoint === 'object') { + return this.normalizeIdValue(endpoint._id ?? endpoint.id ?? fallback); + } + + return this.normalizeIdValue(endpoint ?? fallback); + } + + private normalizeIdValue(value: any): string { + const normalized = String(value ?? '').trim(); + return normalized.length > 0 ? normalized : 'unknown'; + } + + private getLocalName(element: Element): string { + return element.localName || element.nodeName.replace(/^.*:/, ''); + } + + private getXGMMLAttributeName(attribute: Attr): string { + return attribute.localName || attribute.name.replace(/^.*:/, ''); + } + + private getChildElements(element: Element | Document, localName?: string): Element[] { + return Array.from(element.childNodes) + .filter((node): node is Element => node.nodeType === Node.ELEMENT_NODE) + .filter(child => !localName || this.getLocalName(child) === localName); + } + + private getElementsByLocalName(element: Element | Document, localName: string): Element[] { + const out: Element[] = []; + const visit = (parent: Element | Document) => { + this.getChildElements(parent).forEach(child => { + if (this.getLocalName(child) === localName) { + out.push(child); + } + visit(child); + }); + }; + + visit(element); + return out; + } + + private getFirstElementByLocalName(element: Element | Document, localName: string): Element | null { + return this.getElementsByLocalName(element, localName)[0] || null; + } + + private toGraphMLId(value: any, prefix: string, index: number, usedIds: Set): string { + const rawId = String(value ?? '').trim(); + let graphmlId = rawId + .replace(/[^A-Za-z0-9_.-]+/g, '_') + .replace(/_+/g, '_') + .replace(/^_+|_+$/g, ''); + + if (!graphmlId) { + graphmlId = `${prefix}${index}`; + } + + if (!/^[A-Za-z_]/.test(graphmlId)) { + graphmlId = `${prefix}_${graphmlId}`; + } + + let uniqueId = graphmlId; + let suffix = 2; + while (usedIds.has(uniqueId)) { + uniqueId = `${graphmlId}_${suffix}`; + suffix++; + } + + usedIds.add(uniqueId); + return uniqueId; + } + + private buildKeys(records: Array>, preferredFields: any, keyFor: 'graph' | 'node' | 'edge'): GraphMLKey[] { + const fields = this.collectFields(records, preferredFields); + + return fields.map((field, index) => ({ + id: `${keyFor}_${index}`, + name: field, + type: this.inferType(records.map(record => record[field])), + for: keyFor + })); + } + + private collectFields(records: Array>, preferredFields: any): string[] { + const fields: string[] = []; + const addField = (field: any) => { + const normalizedField = String(field ?? '').trim(); + if (!normalizedField || fields.includes(normalizedField)) { + return; + } + + const hasValue = records.some(record => record[normalizedField] !== undefined && record[normalizedField] !== null); + if (hasValue) { + fields.push(normalizedField); + } + }; + + (Array.isArray(preferredFields) ? preferredFields : []).forEach(addField); + records.forEach(record => Object.keys(record).forEach(addField)); + + return fields; + } + + private collectImportFields(records: Array>, preferredFields: any): string[] { + const rawFields = this.collectFields(records, preferredFields); + const rawFieldSet = new Set(rawFields); + const readableFields: string[] = []; + + rawFields.forEach(field => { + const readableField = this.getReadableImportFieldName(field); + const canUseAlias = readableField !== field + && !rawFieldSet.has(readableField) + && !readableFields.includes(readableField) + && !this.recordsHaveField(records, readableField); + + if (!canUseAlias) { + readableFields.push(field); + return; + } + + // Keep raw parser keys on the records, but expose stable human-readable aliases to the views. + records.forEach(record => { + if (record[field] !== undefined && record[field] !== null) { + record[readableField] = record[field]; + } + }); + readableFields.push(readableField); + }); + + return readableFields; + } + + private recordsHaveField(records: Array>, field: string): boolean { + return records.some(record => Object.prototype.hasOwnProperty.call(record, field)); + } + + private getReadableImportFieldName(field: string): string { + const normalizedField = String(field ?? '').trim(); + if (!normalizedField) { + return normalizedField; + } + + const fieldNameOverrides: Record = { + '_id': '_id', + 'nn': 'nn', + 'id': 'id', + 'x': 'x', + 'y': 'y', + 'z': 'z' + }; + if (Object.prototype.hasOwnProperty.call(fieldNameOverrides, normalizedField)) { + return fieldNameOverrides[normalizedField]; + } + + const readableText = normalizedField + .replace(/^_+/, '') + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .replace(/[_-]+/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + + if (!readableText || readableText === normalizedField) { + return normalizedField; + } + + return readableText.split(' ') + .map(part => this.formatImportFieldToken(part)) + .join(' '); + } + + private formatImportFieldToken(part: string): string { + const exactNames: Record = { + 'attvalue': 'Attribute Value', + 'attvalues': 'Attribute Values', + 'attr': 'Attribute', + 'attrs': 'Attributes', + 'cx2': 'CX2', + 'csv': 'CSV', + 'dna': 'DNA', + 'dot': 'DOT', + 'gexf': 'GEXF', + 'gml': 'GML', + 'graphml': 'GraphML', + 'hiv': 'HIV', + 'id': 'ID', + 'ids': 'IDs', + 'json': 'JSON', + 'mt': 'MicrobeTrace', + 'rna': 'RNA', + 'snp': 'SNP', + 'uri': 'URI', + 'url': 'URL', + 'viz': 'Visualization', + 'xml': 'XML', + 'xgmml': 'XGMML' + }; + const lowerPart = part.toLowerCase(); + + if (Object.prototype.hasOwnProperty.call(exactNames, lowerPart)) { + return exactNames[lowerPart]; + } + + if (/^\d+$/.test(part)) { + return part; + } + + return lowerPart.charAt(0).toUpperCase() + lowerPart.slice(1); + } + + private inferType(values: any[]): GraphMLAttributeType { + const presentValues = values.filter(value => value !== undefined && value !== null); + if (presentValues.length === 0) { + return 'string'; + } + + if (presentValues.every(value => typeof value === 'boolean')) { + return 'boolean'; + } + + if (presentValues.every(value => typeof value === 'number' && Number.isFinite(value))) { + if (presentValues.every(value => Number.isInteger(value))) { + const isInt = presentValues.every(value => value >= -2147483648 && value <= 2147483647); + return isInt ? 'int' : 'long'; + } + + return 'double'; + } + + return 'string'; + } + + private renderKey(key: GraphMLKey): string { + return ` `; + } + + private renderDataElements(record: Record, keys: GraphMLKey[], indentSize: number): string[] { + const indent = ' '.repeat(indentSize); + return keys + .filter(key => record[key.name] !== undefined && record[key.name] !== null) + .map(key => `${indent}${this.escapeText(this.stringifyValue(record[key.name]))}`); + } + + private stringifyValue(value: any): string { + if (value === undefined || value === null) { + return ''; + } + + if (typeof value === 'string') { + return value; + } + + if (typeof value === 'number' || typeof value === 'boolean') { + return String(value); + } + + try { + return JSON.stringify(value); + } catch { + return String(value); + } + } + + private collectNetworkNames(nodeEntries: GraphMLNodeEntry[], edgeEntries: GraphMLEdgeEntry[]): string[] { + const networks = new Set(); + nodeEntries.forEach(entry => this.normalizeOrigins(entry.record.origin ?? entry.record._originAll).forEach(origin => networks.add(origin))); + edgeEntries.forEach(entry => this.normalizeOrigins(entry.record.origin ?? entry.record._originAll).forEach(origin => networks.add(origin))); + return Array.from(networks).sort((a, b) => a.localeCompare(b)); + } + + private normalizeOrigins(value: any): string[] { + const values = Array.isArray(value) + ? value + : (value === undefined || value === null || value === '' ? [] : [value]); + + return this.uniqStrings(values + .map(origin => String(origin ?? '').trim()) + .filter(origin => origin.length > 0)); + } + + private uniqStrings(values: string[]): string[] { + return values.filter((value, index, list) => list.indexOf(value) === index); + } + + private escapeText(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>'); + } + + private escapeAttribute(value: string): string { + return this.escapeText(value) + .replace(/"/g, '"') + .replace(/'/g, '''); + } +} diff --git a/src/app/filesComponent/files-plugin.component.ts b/src/app/filesComponent/files-plugin.component.ts index 336878e3..95663332 100644 --- a/src/app/filesComponent/files-plugin.component.ts +++ b/src/app/filesComponent/files-plugin.component.ts @@ -19,6 +19,7 @@ import { relativeTimeThreshold } from 'moment'; import { EmbedHandoffService } from '@app/embed/embed-handoff.service'; import { EmbedLaunchOptionsV1, ImportedEmbedFile } from '@app/embed/embed-handoff.types'; import { WorkerComputeService } from '@app/contactTraceCommonServices/worker-compute.service'; +import { GraphMLService } from '@app/contactTraceCommonServices/graphml.service'; // import { ComponentContainer } from 'golden-layout'; // import { ConsoleReporter } from 'jasmine'; @@ -114,6 +115,7 @@ export class FilesComponent extends BaseComponentDirective implements OnInit { public title: string; public id: string; + private pendingNetworkImportWarnings: string[] = []; private destroy$ = new Subject(); private loadViewSubscription?: Subscription; @@ -127,7 +129,8 @@ export class FilesComponent extends BaseComponentDirective implements OnInit { private store: CommonStoreService, private embedHandoffService: EmbedHandoffService, private ngZone: NgZone, - private workerComputeService: WorkerComputeService + private workerComputeService: WorkerComputeService, + private graphMLService: GraphMLService ) { super(elRef.nativeElement); @@ -1000,6 +1003,43 @@ export class FilesComponent extends BaseComponentDirective implements OnInit { this.store.setLoadingMessageUpdated(msg); } + + showNetworkImportWarnings(fileName: string, warnings: string[]) { + if (!warnings.length) { + return; + } + + this.pendingNetworkImportWarnings.push(...warnings.map(warning => `${fileName}: ${warning}`)); + } + + showGraphMLWarnings(fileName: string, warnings: string[]) { + this.showNetworkImportWarnings(fileName, warnings); + } + + flushNetworkImportWarnings() { + if (!this.pendingNetworkImportWarnings.length) { + return; + } + + const warnings = [...this.pendingNetworkImportWarnings]; + this.pendingNetworkImportWarnings = []; + + const microbeTrace = this.commonService.visuals.microbeTrace; + if (microbeTrace?.openNetworkImportWarnings) { + microbeTrace.openNetworkImportWarnings(warnings); + return; + } + if (microbeTrace?.openGraphMLImportWarnings) { + microbeTrace.openGraphMLImportWarnings(warnings); + return; + } + + warnings.forEach(warning => this.showMessage(warning)); + } + + flushGraphMLWarnings() { + this.flushNetworkImportWarnings(); + } /** @@ -1072,6 +1112,7 @@ export class FilesComponent extends BaseComponentDirective implements OnInit { this.commonService.session.messages = []; this.messages = []; + this.pendingNetworkImportWarnings = []; if (this.commonService.debugMode) { console.log('session files', this.commonService.session.files); @@ -1113,7 +1154,7 @@ export class FilesComponent extends BaseComponentDirective implements OnInit { const check = nFiles > 0; // sorts files based on hierarchy - const hierarchy = ['auspice', 'newick', 'matrix', 'link', 'node', 'fasta']; + const hierarchy = ['auspice', 'newick', 'matrix', 'network', 'graphml', 'link', 'node', 'fasta']; this.commonService.session.files.sort((a, b) => hierarchy.indexOf(a.format) - hierarchy.indexOf(b.format)); @@ -1250,6 +1291,54 @@ export class FilesComponent extends BaseComponentDirective implements OnInit { if (fileNum === nFiles) this.processData(loadGeneration); }); + } else if (file.format === 'network' || file.format === 'graphml') { + + this.showMessage(`Parsing ${file.name} as Network...`); + try { + const networkDocument = this.graphMLService.importNetworkDocument(file.contents, { sourceName: file.name }); + let newNodes = 0; + let newLinks = 0; + + networkDocument.nodeFields.forEach(field => { + if (!this.commonService.includes(this.commonService.session.data.nodeFields, field)) { + this.commonService.session.data.nodeFields.push(field); + } + }); + networkDocument.linkFields.forEach(field => { + if (!this.commonService.includes(this.commonService.session.data.linkFields, field)) { + this.commonService.session.data.linkFields.push(field); + } + }); + + networkDocument.nodes.forEach(node => { + newNodes += this.commonService.addNode(node, check); + }); + networkDocument.links.forEach(link => { + newLinks += this.commonService.addLink(link, check); + }); + + this.showNetworkImportWarnings(file.name, networkDocument.warnings); + + console.log('Network Parse time:', (Date.now() - start).toLocaleString(), 'ms'); + this.commonService.recordPerformanceTiming('ingestion', networkDocument.format === 'graphml' ? 'parseAndMergeGraphML' : 'parseAndMergeNetworkDocument', start, { + file: file.name, + format: networkDocument.format, + graphs: networkDocument.graphIds.length, + newNodes, + totalNodes: networkDocument.nodes.length, + newLinks, + totalLinks: networkDocument.links.length, + warnings: networkDocument.warnings.length + }); + this.showMessage(` - Parsed ${newNodes} New, ${networkDocument.nodes.length} Total Nodes from ${networkDocument.formatLabel}.`); + this.showMessage(` - Parsed ${newLinks} New, ${networkDocument.links.length} Total Links from ${networkDocument.graphIds.length} ${networkDocument.formatLabel} Graph${networkDocument.graphIds.length === 1 ? '' : 's'}.`); + if (fileNum === nFiles) this.processData(loadGeneration); + } catch (error: any) { + console.error('Network parse error:', error); + this.showMessage(` - Error processing Network file: ${error?.message || error}`); + this.commonService.session.network.isFullyLoaded = false; + } + } else if (file.format === 'link') { this.showMessage(`Parsing ${file.name} as Link List...`); @@ -1872,6 +1961,8 @@ export class FilesComponent extends BaseComponentDirective implements OnInit { return; } + this.flushNetworkImportWarnings(); + let nodes = this.commonService.session.data.nodes; if(this.commonService.debugMode) { console.log(nodes); @@ -2147,9 +2238,20 @@ export class FilesComponent extends BaseComponentDirective implements OnInit { reader.onerror = () => reject(reader.error); reader.onloadend = (out) => { try { - const output = JSON.parse(out.target['result'] as string); + const rawContents = out.target['result'] as string; + const output = JSON.parse(rawContents); console.log(output); - if (output.meta && output.tree) { + if (this.graphMLService.looksLikeCX2(output)) { + const cxFile = { + contents: rawContents, + name: fileName, + extension: extension, + type: rawfile.type || 'application/json', + format: 'network' + }; + this.commonService.session.files.push(cxFile); + this.addToTable(cxFile); + } else if (output.meta && output.tree) { const auspiceFile = { contents: output, name: fileName, extension: extension, format: 'auspice', datatype: 'auspice'}; this.commonService.session.files.push(auspiceFile); this.addToTable(auspiceFile); @@ -2200,9 +2302,12 @@ export class FilesComponent extends BaseComponentDirective implements OnInit { private inferTabularFileFormat( file: any, headers: any[] = [], - hints: { isFasta?: boolean; isNewick?: boolean; isAuspice?: boolean } = {} + hints: { isFasta?: boolean; isNewick?: boolean; isAuspice?: boolean; isNetworkDocument?: boolean; isNetworkXml?: boolean } = {} ): string { const explicitFormat = String(file?.format ?? '').toLowerCase(); + if (explicitFormat === 'network' || explicitFormat === 'graphml') { + return 'network'; + } const knownFormats = ['link', 'node', 'matrix', 'fasta', 'newick', 'auspice']; if (knownFormats.includes(explicitFormat)) { return explicitFormat; @@ -2211,6 +2316,9 @@ export class FilesComponent extends BaseComponentDirective implements OnInit { if (hints.isAuspice) { return 'auspice'; } + if (hints.isNetworkDocument || hints.isNetworkXml) { + return 'network'; + } if (hints.isFasta) { return 'fasta'; } @@ -2274,10 +2382,14 @@ export class FilesComponent extends BaseComponentDirective implements OnInit { const extension = file.extension ? file.extension : this.commonService.filterXSS(file.name).split('.').pop().toLowerCase(); const isFasta = extension.indexOf('fas') > -1; const isNewick = extension.indexOf('nwk') > -1 || extension.indexOf('newick') > -1; + // Some network formats are uploaded as generic XML/JSON/text, so detect by + // extension first and then by document shape. + const isNetworkDocument = ['graphml', 'gexf', 'xgmml', 'cx', 'cx2', 'dot', 'gv', 'gml'].includes(extension) + || this.graphMLService.looksLikeNetworkDocument(file.contents); const isXL = (extension === 'xlsx' || extension === 'xls'); const isJSON = (extension === 'json'); const isAuspice = (extension === 'json' && file.contents.meta && file.contents.tree); - const tableFormatHints = { isFasta, isNewick, isAuspice }; + const tableFormatHints = { isFasta, isNewick, isAuspice, isNetworkDocument }; if (isXL) { try { const workbook = XLSX.read(file.contents, { type: 'array', cellDates: true, dateNF: 'mm/dd/yyyy' }); @@ -2295,6 +2407,33 @@ export class FilesComponent extends BaseComponentDirective implements OnInit { addTableTile([file.field1, file.field2, file.field3], this); return; } + } else if (isNetworkDocument) { + try { + const networkDocument = this.graphMLService.importNetworkDocument(file.contents, { sourceName: file.name }); + file.format = 'network'; + const detectedFormat = addTableTile(['network_format', '_id', 'source', 'target', 'distance', 'origin'], this); + this.nodeIds = this.nodeIds.filter(x => x.fileName !== file.name); + this.edgeIds = this.edgeIds.filter(x => x.fileName !== file.name); + this.nodeIds.push({ + fileName: file.name, + ids: networkDocument.nodes.map(node => '' + node._id) + }); + this.edgeIds.push({ + fileName: file.name, + ids: networkDocument.links.map(link => ({ + source: '' + link.source, + target: '' + link.target + })) + }); + networkDocument.warnings.forEach(warning => console.warn(`Network ${file.name}: ${warning}`)); + this.nodeEdgeCheck(); + if(this.commonService.debugMode) { + console.log('addToTable Network detected format: ', detectedFormat, networkDocument); + } + } catch (error) { + console.error('Unable to read Network file: ', file.name, error); + addTableTile(['network_format', '_id', 'source', 'target'], this); + } } else if (isJSON) { let data = []; console.log('This is a JSON file'); @@ -2368,8 +2507,12 @@ export class FilesComponent extends BaseComponentDirective implements OnInit { const showsColumnMapping = detectedFormat === 'node' || detectedFormat === 'link'; const root = $('
').data('filename', file.name); const fnamerow = $('
'); - $('
') - .append($('').on('click', () => { + const fileNameCell = $('
').addClass('file-name col'); + const deleteLink = $('') + .attr('href', 'javascript:void(0);') + .addClass('far flaticon-delete-1 align-middle p-1') + .attr('title', 'Remove this file') + .on('click', () => { const fileIndex = parentContext.commonService.session.files.findIndex(f => f.name === file.name); if (fileIndex >= 0) { parentContext.commonService.session.files.splice(fileIndex, 1); @@ -2382,53 +2525,87 @@ export class FilesComponent extends BaseComponentDirective implements OnInit { } root.slideUp(() => root.remove()); parentContext.refreshTemplateState(); - })) - .append($(``).on('click', () => { + }); + const downloadLink = $('') + .attr('href', 'javascript:void(0);') + .addClass('far flaticon-download-1 align-middle p-1') + .attr('title', parentContext.isFileContentsEmpty(file) ? 'Unable to resave this file' : 'Resave this file') + .on('click', () => { if (parentContext.isFileContentsEmpty(file)) { alert('Unable to resave this file.'); } else { saveAs(new Blob([file.contents], { type: file.type || 'text' }), file.name); } - })) - .append('' + file.name + '') - .append(` -
- - - - - - -
`).appendTo(fnamerow); + }); + if (parentContext.isFileContentsEmpty(file)) { + downloadLink.css('color', 'gray'); + } + const fileNameText = $('') + .addClass('p-1') + .text(file.name); + const formatButtons = $('
') + .addClass('btn-group btn-group-toggle btn-group-sm float-right') + .attr('data-toggle', 'buttons'); + const formatOptions = [ + { type: 'link', label: 'Link' }, + { type: 'node', label: 'Node' }, + { type: 'matrix', label: 'Matrix' }, + { type: 'fasta', label: 'FASTA' }, + { type: 'newick', label: 'Newick' }, + { type: 'auspice', label: 'Auspice' }, + { type: 'network', label: 'Network' }, + ]; + formatOptions.forEach(({ type, label }) => { + const formatLabel = $('') + .addClass(`btn btn-light${detectedFormat === type ? ' active' : ''}`); + const formatInput = $('') + .attr('type', 'radio') + .attr('name', `options-${file.name}`) + .attr('autocomplete', 'off') + .data('type', type) + .attr('data-type', type) + .prop('checked', detectedFormat === type); + formatLabel + .append(formatInput) + .append($('').text(label)); + formatButtons.append(formatLabel); + }); + fileNameCell + .append(deleteLink) + .append(downloadLink) + .append(fileNameText) + .append(formatButtons) + .appendTo(fnamerow); fnamerow.appendTo(root); const optionsrow = $('
'); - const options = '' + headers.map(h => ``).join('\n'); - optionsrow.append(` - - - `); + const buildFieldSelect = (fieldNumber: number) => { + const select = $('') + .attr('id', `file-${file.name}-field-${fieldNumber}`) + .addClass('form-control form-control-sm'); + select.append($('').val('None').text('None')); + headers.forEach(h => { + select.append($('').val(h).text(parentContext.commonService.titleize(h))); + }); + return select; + }; + const addMappingControl = (fieldNumber: number, labelText: string) => { + const container = $('
') + .addClass('col-4') + .data('file', file.name) + .attr('data-file', file.name); + if (!showsColumnMapping) { + container.css('display', 'none'); + } + const selectId = `file-${file.name}-field-${fieldNumber}`; + container + .append($('').attr('for', selectId).text(labelText)) + .append(buildFieldSelect(fieldNumber)); + optionsrow.append(container); + }; + addMappingControl(1, isNode ? 'ID' : 'Source'); + addMappingControl(2, isNode ? 'Sequence' : 'Target'); + addMappingControl(3, 'Distance'); optionsrow.appendTo(root); diff --git a/src/app/microbe-trace-next-plugin.component.html b/src/app/microbe-trace-next-plugin.component.html index 861baf93..7938d209 100644 --- a/src/app/microbe-trace-next-plugin.component.html +++ b/src/app/microbe-trace-next-plugin.component.html @@ -19,6 +19,7 @@ + diff --git a/src/app/microbe-trace-next-plugin.component.ts b/src/app/microbe-trace-next-plugin.component.ts index 7498da2a..a41e48da 100644 --- a/src/app/microbe-trace-next-plugin.component.ts +++ b/src/app/microbe-trace-next-plugin.component.ts @@ -18,6 +18,7 @@ import JSZip from 'jszip'; import html2canvas from 'html2canvas'; import { CommonStoreService } from './contactTraceCommonServices/common-store.services'; import { ExportService, ExportOptions } from './contactTraceCommonServices/export.service'; +import { GraphMLService } from './contactTraceCommonServices/graphml.service'; import { sanitizeExportRows } from './contactTraceCommonServices/export-sanitization'; import * as XLSX from 'xlsx'; import { buildDate, commitHash } from "src/environments/version"; @@ -361,6 +362,7 @@ export class MicrobeTraceNextHomeComponent extends AppComponentBase implements A private el: ElementRef, private store: CommonStoreService, private exportService: ExportService, + private graphMLService: GraphMLService, private embedHandoffService: EmbedHandoffService ) { @@ -1846,6 +1848,30 @@ export class MicrobeTraceNextHomeComponent extends AppComponentBase implements A }, ); } + openNetworkImportWarnings(warnings: string[]) { + if (!warnings.length) { + return; + } + + this.confirmationService.confirm({ + header: 'Network Import Warnings', + message: `Some Network file features are not supported by MicrobeTrace and were ignored or imported as data fields. + +${warnings.join('\n')}`, + closable: false, + closeOnEscape: false, + icon: 'pi pi-exclamation-triangle', + rejectVisible: false, + acceptButtonProps: { + label: 'Confirm', + }, + }); + } + + openGraphMLImportWarnings(warnings: string[]) { + this.openNetworkImportWarnings(warnings); + } + onPruneWithTypesChanged(newValue: string) { if (this.userConfirmedNN == false && this.SelectedPruneWithTypesVariable == "Nearest Neighbor") { if (this.commonService.session.data.links.filter(l => l.origin.length > 1 && Array.isArray(l.origin)).length>0) { @@ -4147,6 +4173,12 @@ export class MicrobeTraceNextHomeComponent extends AppComponentBase implements A } } + ExportGraphML() { + const graphMLExport = this.graphMLService.exportSession(this.commonService.session); + const blob = new Blob([graphMLExport.contents], { type: 'application/graphml+xml;charset=utf-8' }); + this.saveGeneratedFile(blob, 'microbetrace.graphml'); + } + updateExportResolution() { const visualWrapper = this.visualWrapperRef.nativeElement; console.log(visualWrapper) diff --git a/src/app/visualizationComponents/TwoDComponent/twoD-plugin.component.ts b/src/app/visualizationComponents/TwoDComponent/twoD-plugin.component.ts index ae89e174..bc46f028 100644 --- a/src/app/visualizationComponents/TwoDComponent/twoD-plugin.component.ts +++ b/src/app/visualizationComponents/TwoDComponent/twoD-plugin.component.ts @@ -664,6 +664,7 @@ export class TwoDComponent extends BaseComponentDirective implements OnInit, Mic source: link.source, target: link.target, lineSelectedColor: this.widgets['selected-color'], + mtRawLinkLabel: this.getRawLinkLabel(link), label: this.getLinkLabel(link).text, // Existing link label lineColor: this.getLinkColor({origin: originItem}).color, // Default to black if not specified lineOpacity: this.getLinkColor({origin: originItem}).opacity, // Default to fully opaque if not specified @@ -680,6 +681,7 @@ export class TwoDComponent extends BaseComponentDirective implements OnInit, Mic source: link.source, target: link.target, lineSelectedColor: this.widgets['selected-color'], + mtRawLinkLabel: this.getRawLinkLabel(link), label: this.getLinkLabel(link).text, // Existing link label lineColor: this.getLinkColor(link).color, // Default to black if not specified lineOpacity: this.getLinkColor(link).opacity, // Default to fully opaque if not specified @@ -3609,6 +3611,9 @@ export class TwoDComponent extends BaseComponentDirective implements OnInit, Mic } else if (labelVariable == 'target_index') { // currently doesn't work; previous link.source and link.target were object now they are just a string of the id return { text: link['target'] } //return link['target']['index'] + } else if (labelVariable == 'label') { + const labelValue = this.getRawLinkLabel(link); + return { text: labelValue === undefined || labelValue === null ? '' : `${labelValue}` }; } else if (labelVariable != 'distance') { return { text: `${link[labelVariable]}` || '' }; } @@ -3639,6 +3644,10 @@ export class TwoDComponent extends BaseComponentDirective implements OnInit, Mic } } + private getRawLinkLabel(link: any): any { + return link?.mtRawLinkLabel ?? link?.label; + } + /** * Calls setNodeLabelSize to update label-size and redraw labels