@graphty/graph-io 0.3.39 → 0.3.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/dist/chunks/{escape-BpYMQZVa.js → escape-BJRLd3vJ.js} +3 -2
  2. package/dist/chunks/escape-BJRLd3vJ.js.map +1 -0
  3. package/dist/chunks/{exporter-BqKs1Q12.js → exporter-6ocL_9x_.js} +5 -4
  4. package/dist/chunks/exporter-6ocL_9x_.js.map +1 -0
  5. package/dist/chunks/{exporter-DXqCPNrI.js → exporter-BVXnV9-9.js} +42 -29
  6. package/dist/chunks/exporter-BVXnV9-9.js.map +1 -0
  7. package/dist/chunks/{exporter-CNJZF_BN.js → exporter-Bcp-l4Bo.js} +2 -2
  8. package/dist/chunks/{exporter-CNJZF_BN.js.map → exporter-Bcp-l4Bo.js.map} +1 -1
  9. package/dist/chunks/{importer-CqgYq4Ld.js → importer-AjwOJrn2.js} +2 -2
  10. package/dist/chunks/{importer-CqgYq4Ld.js.map → importer-AjwOJrn2.js.map} +1 -1
  11. package/dist/chunks/{importer-BfJ1jnNr.js → importer-BJlGAOht.js} +27 -12
  12. package/dist/chunks/importer-BJlGAOht.js.map +1 -0
  13. package/dist/chunks/{importer-PVXkeoaS.js → importer-BpuS3J8C.js} +8 -8
  14. package/dist/chunks/importer-BpuS3J8C.js.map +1 -0
  15. package/dist/chunks/{importer-CSt-gaYE.js → importer-CPqnIY8m.js} +4 -4
  16. package/dist/chunks/{importer-CSt-gaYE.js.map → importer-CPqnIY8m.js.map} +1 -1
  17. package/dist/chunks/{importer-B7Ir269L.js → importer-DEp_fYjf.js} +28 -10
  18. package/dist/chunks/importer-DEp_fYjf.js.map +1 -0
  19. package/dist/chunks/{importer-DvFznt5-.js → importer-D_srHyj6.js} +111 -61
  20. package/dist/chunks/importer-D_srHyj6.js.map +1 -0
  21. package/dist/chunks/{importer--tQQsY4W.js → importer-DcKB97kW.js} +2 -2
  22. package/dist/chunks/{importer--tQQsY4W.js.map → importer-DcKB97kW.js.map} +1 -1
  23. package/dist/chunks/{json-elements-CXs4z1iL.js → json-elements-DpO6AXIc.js} +5 -3
  24. package/dist/chunks/{json-elements-CXs4z1iL.js.map → json-elements-DpO6AXIc.js.map} +1 -1
  25. package/dist/chunks/{records-C9jd7yLD.js → records-r3SFHz11.js} +136 -95
  26. package/dist/chunks/records-r3SFHz11.js.map +1 -0
  27. package/dist/csv.js +2 -2
  28. package/dist/cx.js +1 -1
  29. package/dist/cx2.js +2 -2
  30. package/dist/cys.js +1 -1
  31. package/dist/dot.js +1 -1
  32. package/dist/gexf.js +5 -3
  33. package/dist/gexf.js.map +1 -1
  34. package/dist/gml.js +1 -1
  35. package/dist/graph-io.js +19 -19
  36. package/dist/graphml.js +1 -1
  37. package/dist/json.js +1 -1
  38. package/dist/neo4j.js +2 -2
  39. package/dist/pajek.js +1 -1
  40. package/dist/src/common/json-elements.d.ts.map +1 -1
  41. package/dist/src/common/json-elements.js +4 -2
  42. package/dist/src/common/json-elements.js.map +1 -1
  43. package/dist/src/common/xml.d.ts.map +1 -1
  44. package/dist/src/common/xml.js +2 -1
  45. package/dist/src/common/xml.js.map +1 -1
  46. package/dist/src/formats/csv/records.d.ts.map +1 -1
  47. package/dist/src/formats/csv/records.js +138 -100
  48. package/dist/src/formats/csv/records.js.map +1 -1
  49. package/dist/src/formats/cx/importer.d.ts.map +1 -1
  50. package/dist/src/formats/cx/importer.js +108 -53
  51. package/dist/src/formats/cx/importer.js.map +1 -1
  52. package/dist/src/formats/cx2/exporter.d.ts.map +1 -1
  53. package/dist/src/formats/cx2/exporter.js +2 -1
  54. package/dist/src/formats/cx2/exporter.js.map +1 -1
  55. package/dist/src/formats/cx2/importer.d.ts.map +1 -1
  56. package/dist/src/formats/cx2/importer.js +28 -6
  57. package/dist/src/formats/cx2/importer.js.map +1 -1
  58. package/dist/src/formats/gexf/exporter.d.ts.map +1 -1
  59. package/dist/src/formats/gexf/exporter.js +4 -2
  60. package/dist/src/formats/gexf/exporter.js.map +1 -1
  61. package/dist/src/formats/graphml/exporter.d.ts.map +1 -1
  62. package/dist/src/formats/graphml/exporter.js +2 -1
  63. package/dist/src/formats/graphml/exporter.js.map +1 -1
  64. package/dist/src/formats/graphml/importer.d.ts.map +1 -1
  65. package/dist/src/formats/graphml/importer.js +3 -2
  66. package/dist/src/formats/graphml/importer.js.map +1 -1
  67. package/dist/src/formats/xgmml/emit.d.ts +6 -0
  68. package/dist/src/formats/xgmml/emit.d.ts.map +1 -1
  69. package/dist/src/formats/xgmml/emit.js +33 -10
  70. package/dist/src/formats/xgmml/emit.js.map +1 -1
  71. package/dist/src/formats/xgmml/exporter.d.ts.map +1 -1
  72. package/dist/src/formats/xgmml/exporter.js +54 -22
  73. package/dist/src/formats/xgmml/exporter.js.map +1 -1
  74. package/dist/xgmml.js +2 -2
  75. package/package.json +2 -2
  76. package/src/common/json-elements.ts +4 -2
  77. package/src/common/xml.ts +2 -1
  78. package/src/formats/csv/records.ts +143 -99
  79. package/src/formats/cx/importer.ts +140 -58
  80. package/src/formats/cx2/exporter.ts +2 -1
  81. package/src/formats/cx2/importer.ts +34 -10
  82. package/src/formats/gexf/exporter.ts +4 -2
  83. package/src/formats/graphml/exporter.ts +2 -3
  84. package/src/formats/graphml/importer.ts +3 -2
  85. package/src/formats/xgmml/emit.ts +34 -10
  86. package/src/formats/xgmml/exporter.ts +63 -23
  87. package/dist/chunks/escape-BpYMQZVa.js.map +0 -1
  88. package/dist/chunks/exporter-BqKs1Q12.js.map +0 -1
  89. package/dist/chunks/exporter-DXqCPNrI.js.map +0 -1
  90. package/dist/chunks/importer-B7Ir269L.js.map +0 -1
  91. package/dist/chunks/importer-BfJ1jnNr.js.map +0 -1
  92. package/dist/chunks/importer-DvFznt5-.js.map +0 -1
  93. package/dist/chunks/importer-PVXkeoaS.js.map +0 -1
  94. package/dist/chunks/records-C9jd7yLD.js.map +0 -1
@@ -739,125 +739,169 @@ class RecordScanner {
739
739
  * @returns the position after the last character consumed
740
740
  */
741
741
  scan(chunk: string, from: number): number {
742
- const { quoteCode, delimiterCode } = this;
742
+ const { delimiterCode } = this;
743
743
  const n = chunk.length;
744
744
  let i = from;
745
745
  this.segment = from;
746
746
  while (i < n) {
747
747
  const c = chunk.charCodeAt(i);
748
- if (this.state === COMMENT) {
749
- if (c === LF || c === CR) {
750
- this.comment = this.grow(this.comment, chunk.slice(this.segment, i));
751
- this.reader.leadingComments.push(this.comment);
752
- this.reader.leadingCommentLines.push(this.line);
753
- this.comment = "";
754
- this.state = START;
755
- this.endLine(c);
756
- this.segment = i + 1;
757
- }
748
+ const { state } = this;
749
+ if (state === COMMENT) {
750
+ this.commentChar(chunk, i, c);
758
751
  i++;
759
752
  continue;
760
753
  }
761
- if (this.state === QUOTED) {
762
- // jump to the next quote; the run in between is field text whose line breaks are counted
763
- const q = chunk.indexOf(this.quoteText, i);
764
- const end = q < 0 ? n : q;
765
- if (end > i) {
766
- this.countLineBreaks(chunk, i, end);
767
- }
768
- if (q < 0) {
769
- i = n;
770
- break;
771
- }
772
- this.field = this.grow(this.field, chunk.slice(this.segment, q));
773
- this.state = CLOSING;
774
- this.lastWasCr = false;
775
- i = q + 1;
754
+ if (state === QUOTED) {
755
+ i = this.quotedRun(chunk, i);
776
756
  continue;
777
757
  }
778
758
  const isDelimiter = c === delimiterCode || (this.collapse && c === TAB);
779
- if (this.state === CLOSING) {
780
- if (c === quoteCode) {
781
- this.field = this.grow(this.field, this.quoteText);
782
- this.segment = i + 1;
783
- this.state = QUOTED;
784
- this.lastWasCr = false;
785
- i++;
786
- continue;
787
- }
788
- if (!isDelimiter && c !== LF && c !== CR) {
789
- this.reader.badQuote(this.startLine, this.count + 1);
790
- }
791
- this.state = AFTER_QUOTED;
792
- this.segment = i;
793
- // fall through to the delimiter / line-end handling below without consuming c
794
- }
795
- if (isDelimiter || c === LF || c === CR) {
796
- if (c === LF && this.lastWasCr) {
797
- // the second half of a CRLF already ended the record
798
- this.lastWasCr = false;
799
- this.segment = i + 1;
800
- i++;
801
- continue;
802
- }
803
- if (this.state === UNQUOTED) {
804
- this.field = this.grow(this.field, chunk.slice(this.segment, i));
805
- }
806
- if (isDelimiter && this.collapse && this.state === START) {
807
- // a run of whitespace, or indentation: one separator
808
- this.lastWasCr = false;
809
- this.segment = i + 1;
810
- i++;
811
- continue;
812
- }
813
- if (isDelimiter) {
814
- this.push();
815
- this.state = START;
816
- this.lastWasCr = false;
817
- this.segment = i + 1;
818
- i++;
819
- continue;
820
- }
821
- // a line ending right after a whitespace run in the whitespace dialect ends no field
822
- if (!(this.state === START && (this.count === 0 || this.collapse))) {
823
- this.push();
824
- }
825
- if (this.count > 0) {
826
- this.ready = true;
827
- this.recordLine = this.startLine;
828
- }
829
- this.state = START;
830
- this.endLine(c);
831
- this.segment = i + 1;
832
- i++;
759
+ if (state === CLOSING || isDelimiter || c === LF || c === CR) {
760
+ i = this.boundary(chunk, i, c, isDelimiter);
833
761
  if (this.ready) {
834
762
  return i;
835
763
  }
836
764
  continue;
837
765
  }
766
+ this.fieldChar(c, i);
767
+ i++;
768
+ }
769
+ return i;
770
+ }
771
+
772
+ /**
773
+ * One character of a leading comment line: a line break ends the comment.
774
+ * @param chunk - the chunk
775
+ * @param i - the character's index
776
+ * @param c - the character's code
777
+ */
778
+ private commentChar(chunk: string, i: number, c: number): void {
779
+ if (c !== LF && c !== CR) {
780
+ return;
781
+ }
782
+ this.comment = this.grow(this.comment, chunk.slice(this.segment, i));
783
+ this.reader.leadingComments.push(this.comment);
784
+ this.reader.leadingCommentLines.push(this.line);
785
+ this.comment = "";
786
+ this.state = START;
787
+ this.endLine(c);
788
+ this.segment = i + 1;
789
+ }
790
+
791
+ /**
792
+ * Inside a quoted field: jump to the next quote; the run in between is field text whose line
793
+ * breaks are counted.
794
+ * @param chunk - the chunk
795
+ * @param i - where the run starts
796
+ * @returns the position after the quote, or the chunk end when there is none
797
+ */
798
+ private quotedRun(chunk: string, i: number): number {
799
+ const q = chunk.indexOf(this.quoteText, i);
800
+ const end = q < 0 ? chunk.length : q;
801
+ if (end > i) {
802
+ this.countLineBreaks(chunk, i, end);
803
+ }
804
+ if (q < 0) {
805
+ return chunk.length;
806
+ }
807
+ this.field = this.grow(this.field, chunk.slice(this.segment, q));
808
+ this.state = CLOSING;
809
+ this.lastWasCr = false;
810
+ return q + 1;
811
+ }
812
+
813
+ /**
814
+ * The character after a quote inside a quoted field, a delimiter or a line break: a doubled
815
+ * quote, the end of a field, or the end of a record.
816
+ * @param chunk - the chunk
817
+ * @param i - the character's index
818
+ * @param c - the character's code
819
+ * @param isDelimiter - whether it separates fields
820
+ * @returns the position after the character
821
+ */
822
+ private boundary(chunk: string, i: number, c: number, isDelimiter: boolean): number {
823
+ if (this.state === CLOSING) {
824
+ if (c === this.quoteCode) {
825
+ this.field = this.grow(this.field, this.quoteText);
826
+ this.segment = i + 1;
827
+ this.state = QUOTED;
828
+ this.lastWasCr = false;
829
+ return i + 1;
830
+ }
831
+ if (!isDelimiter && c !== LF && c !== CR) {
832
+ this.reader.badQuote(this.startLine, this.count + 1);
833
+ }
834
+ this.state = AFTER_QUOTED;
835
+ this.segment = i;
836
+ }
837
+ if (c === LF && this.lastWasCr) {
838
+ // the second half of a CRLF already ended the record
838
839
  this.lastWasCr = false;
839
- if (this.state === START) {
840
- if (this.leading && this.count === 0 && this.commentCodes.includes(c)) {
841
- this.state = COMMENT;
842
- this.segment = i;
843
- i++;
844
- continue;
845
- }
846
- this.leading = false;
847
- if (c === quoteCode) {
848
- this.state = QUOTED;
849
- this.fieldQuoted = true;
850
- this.segment = i + 1;
851
- } else {
852
- this.state = UNQUOTED;
853
- this.segment = i;
854
- }
855
- } else if (c === quoteCode && this.state === UNQUOTED && this.reader.strayQuoteLine === 0) {
840
+ this.segment = i + 1;
841
+ return i + 1;
842
+ }
843
+ if (this.state === UNQUOTED) {
844
+ this.field = this.grow(this.field, chunk.slice(this.segment, i));
845
+ }
846
+ if (isDelimiter) {
847
+ // in the whitespace dialect a run of whitespace, or indentation, is one separator
848
+ if (!(this.collapse && this.state === START)) {
849
+ this.push();
850
+ this.state = START;
851
+ }
852
+ this.lastWasCr = false;
853
+ this.segment = i + 1;
854
+ return i + 1;
855
+ }
856
+ this.lineEnd(c);
857
+ this.segment = i + 1;
858
+ return i + 1;
859
+ }
860
+
861
+ /**
862
+ * A line break that ends a record (or a blank line).
863
+ * @param c - the LF or CR
864
+ */
865
+ private lineEnd(c: number): void {
866
+ // a line ending right after a whitespace run in the whitespace dialect ends no field
867
+ if (!(this.state === START && (this.count === 0 || this.collapse))) {
868
+ this.push();
869
+ }
870
+ if (this.count > 0) {
871
+ this.ready = true;
872
+ this.recordLine = this.startLine;
873
+ }
874
+ this.state = START;
875
+ this.endLine(c);
876
+ }
877
+
878
+ /**
879
+ * Any other character outside a quoted field: the start of a field or a comment, or field text.
880
+ * @param c - the character's code
881
+ * @param i - its index
882
+ */
883
+ private fieldChar(c: number, i: number): void {
884
+ this.lastWasCr = false;
885
+ if (this.state !== START) {
886
+ if (c === this.quoteCode && this.state === UNQUOTED && this.reader.strayQuoteLine === 0) {
856
887
  this.reader.strayQuoteLine = this.startLine;
857
888
  }
858
- i++;
889
+ return;
890
+ }
891
+ if (this.leading && this.count === 0 && this.commentCodes.includes(c)) {
892
+ this.state = COMMENT;
893
+ this.segment = i;
894
+ return;
895
+ }
896
+ this.leading = false;
897
+ if (c === this.quoteCode) {
898
+ this.state = QUOTED;
899
+ this.fieldQuoted = true;
900
+ this.segment = i + 1;
901
+ } else {
902
+ this.state = UNQUOTED;
903
+ this.segment = i;
859
904
  }
860
- return i;
861
905
  }
862
906
 
863
907
  /**
@@ -773,12 +773,13 @@ function checkNumberVerification(value: unknown, count: number, report: ImportRe
773
773
  const raw = isRecord(value) ? value.longNumber : undefined;
774
774
  const digits = raw instanceof ExactInteger ? raw.digits : String(raw);
775
775
  if (count > 1 || !NUMBER_VERIFICATION_VALUES.has(digits)) {
776
+ const held = raw === undefined ? null : digits;
776
777
  report.warning(
777
778
  "validation-error",
778
779
  CX_ISSUE.NUMBER_VERIFICATION,
779
780
  count > 1
780
781
  ? "a second numberVerification element; ignored"
781
- : `numberVerification holds ${shown(raw === undefined ? null : digits)}, not 281474976710655`,
782
+ : `numberVerification holds ${shown(held)}, not 281474976710655`,
782
783
  { line, element: "numberVerification" },
783
784
  );
784
785
  }
@@ -993,10 +994,12 @@ async function readDocument(
993
994
  break;
994
995
  }
995
996
  countMember(event.block, null);
997
+ const what =
998
+ keys.length > 1 ? `an object with ${keys.length} keys (${keys.join(", ")})` : shown(value);
996
999
  report.error(
997
1000
  "parse-error",
998
1001
  BAD_ASPECT_BLOCK_CODE,
999
- `a member of the document is ${keys.length > 1 ? `an object with ${keys.length} keys (${keys.join(", ")})` : shown(value)}, not a one-key aspect fragment; skipped`,
1002
+ `a member of the document is ${what}, not a one-key aspect fragment; skipped`,
1000
1003
  { line: event.line, element: keys[0] ?? null },
1001
1004
  );
1002
1005
  break;
@@ -1361,6 +1364,26 @@ function memberCount(members: ReadonlySet<NodeId> | null, total: number): number
1361
1364
 
1362
1365
  // ============================================================ the build
1363
1366
 
1367
+ /** The cyVisualProperties of a graph's view, gathered before they are written. */
1368
+ interface VisualCollection {
1369
+ /** per domain, per property: the [row, text] cells */
1370
+ columns: Record<"node" | "edge", Map<string, [number, string][]>>;
1371
+ /** the network values by property */
1372
+ network: Map<string, string>;
1373
+ defaults: number;
1374
+ mappings: number;
1375
+ dependencies: number;
1376
+ }
1377
+
1378
+ /**
1379
+ * A visual property value as text: a string as it is, anything else as JSON.
1380
+ * @param raw - the value
1381
+ * @returns its text
1382
+ */
1383
+ function visualText(raw: unknown): string {
1384
+ return typeof raw === "string" ? raw : JSON.stringify(raw);
1385
+ }
1386
+
1364
1387
  /** One attribute column of the graph being built. */
1365
1388
  interface AttributeColumn {
1366
1389
  readonly name: string;
@@ -2849,57 +2872,99 @@ class CxReader {
2849
2872
  * applied.
2850
2873
  */
2851
2874
  private readVisualProperties(): void {
2852
- const entries = aspect(this.doc, "cyVisualProperties");
2853
2875
  // Cytoscape's table-cell styles: a visual property aspect graph-io keeps but does not read
2854
2876
  const tableStyles = TABLE_STYLE_ASPECTS.reduce((n, name) => n + (this.doc.kept.get(name)?.length ?? 0), 0);
2855
2877
  // the graph's own view (its first): the one its positions come from; the others stay in meta.extra.cx
2856
2878
  const views = new Set(this.plan.views.slice(0, 1));
2857
- const columns = { node: new Map<string, [number, string][]>(), edge: new Map<string, [number, string][]>() };
2858
- const network = new Map<string, string>();
2859
- let defaults = 0;
2860
- let mappings = 0;
2861
- let dependencies = 0;
2862
- for (const { value } of entries) {
2863
- if (!isRecord(value)) {
2864
- continue;
2865
- }
2866
- const view = value.view === undefined || value.view === null ? null : refId(value.view);
2867
- if (view !== null && views.size > 0 && !views.has(view)) {
2868
- continue;
2879
+ const visual: VisualCollection = {
2880
+ columns: { node: new Map(), edge: new Map() },
2881
+ network: new Map(),
2882
+ defaults: 0,
2883
+ mappings: 0,
2884
+ dependencies: 0,
2885
+ };
2886
+ for (const { value } of aspect(this.doc, "cyVisualProperties")) {
2887
+ if (isRecord(value)) {
2888
+ this.collectVisualEntry(value, views, visual);
2869
2889
  }
2870
- const properties = isRecord(value.properties) ? value.properties : {};
2871
- mappings += isRecord(value.mappings) ? Object.keys(value.mappings).length : 0;
2872
- dependencies += isRecord(value.dependencies) ? Object.keys(value.dependencies).length : 0;
2873
- switch (value.properties_of) {
2874
- case "nodes":
2875
- case "edges": {
2876
- const domain = value.properties_of === "nodes" ? "node" : "edge";
2877
- const target = refId(value.applies_to);
2878
- const rows = domain === "node" ? this.nodeRows : this.edgeRows;
2879
- const row = target === null ? undefined : rows.get(target);
2880
- if (row === undefined) {
2881
- if (target === null || !(domain === "node" ? this.rootNodes : this.rootEdges).has(target)) {
2882
- this.dangle(`${domain} visual property entry`);
2883
- }
2884
- continue;
2885
- }
2886
- for (const [property, raw] of Object.entries(properties)) {
2887
- const list = columns[domain].get(property) ?? [];
2888
- list.push([row, typeof raw === "string" ? raw : JSON.stringify(raw)]);
2889
- columns[domain].set(property, list);
2890
- }
2891
- break;
2890
+ }
2891
+ this.writeBypassColumns(visual.columns);
2892
+ this.writeBypassNetwork(visual.network);
2893
+ this.reportStyleRules(visual, tableStyles);
2894
+ }
2895
+
2896
+ /**
2897
+ * File one cyVisualProperties element of this graph's view into the collection: per-element and
2898
+ * network values by property, defaults, mappings and dependencies counted.
2899
+ * @param value - the element
2900
+ * @param views - the views read (empty: every view)
2901
+ * @param visual - the collection to add to
2902
+ */
2903
+ private collectVisualEntry(
2904
+ value: Record<string, unknown>,
2905
+ views: ReadonlySet<NodeId>,
2906
+ visual: VisualCollection,
2907
+ ): void {
2908
+ const view = value.view === undefined || value.view === null ? null : refId(value.view);
2909
+ if (view !== null && views.size > 0 && !views.has(view)) {
2910
+ return;
2911
+ }
2912
+ const properties = isRecord(value.properties) ? value.properties : {};
2913
+ visual.mappings += isRecord(value.mappings) ? Object.keys(value.mappings).length : 0;
2914
+ visual.dependencies += isRecord(value.dependencies) ? Object.keys(value.dependencies).length : 0;
2915
+ switch (value.properties_of) {
2916
+ case "nodes":
2917
+ this.collectElementProperties("node", value.applies_to, properties, visual.columns);
2918
+ break;
2919
+ case "edges":
2920
+ this.collectElementProperties("edge", value.applies_to, properties, visual.columns);
2921
+ break;
2922
+ case "network":
2923
+ for (const [property, raw] of Object.entries(properties)) {
2924
+ visual.network.set(property, visualText(raw));
2892
2925
  }
2893
- case "network":
2894
- for (const [property, raw] of Object.entries(properties)) {
2895
- network.set(property, typeof raw === "string" ? raw : JSON.stringify(raw));
2896
- }
2897
- break;
2898
- default:
2899
- defaults += Object.keys(properties).length;
2900
- break;
2926
+ break;
2927
+ default:
2928
+ visual.defaults += Object.keys(properties).length;
2929
+ break;
2930
+ }
2931
+ }
2932
+
2933
+ /**
2934
+ * File the visual values of one node or edge under its row; an element not in this graph is
2935
+ * skipped, and reported dangling when it is in no graph of the file.
2936
+ * @param domain - node or edge
2937
+ * @param appliesTo - the element's applies_to
2938
+ * @param properties - its visual properties
2939
+ * @param columns - the per-property cells to add to
2940
+ */
2941
+ private collectElementProperties(
2942
+ domain: "node" | "edge",
2943
+ appliesTo: unknown,
2944
+ properties: Record<string, unknown>,
2945
+ columns: VisualCollection["columns"],
2946
+ ): void {
2947
+ const target = refId(appliesTo);
2948
+ const rows = domain === "node" ? this.nodeRows : this.edgeRows;
2949
+ const row = target === null ? undefined : rows.get(target);
2950
+ if (row === undefined) {
2951
+ if (target === null || !(domain === "node" ? this.rootNodes : this.rootEdges).has(target)) {
2952
+ this.dangle(`${domain} visual property entry`);
2901
2953
  }
2954
+ return;
2902
2955
  }
2956
+ for (const [property, raw] of Object.entries(properties)) {
2957
+ const list = columns[domain].get(property) ?? [];
2958
+ list.push([row, visualText(raw)]);
2959
+ columns[domain].set(property, list);
2960
+ }
2961
+ }
2962
+
2963
+ /**
2964
+ * Write the collected per-element visual values as string columns in the cx.bypass namespace.
2965
+ * @param columns - the per-property cells of each domain
2966
+ */
2967
+ private writeBypassColumns(columns: VisualCollection["columns"]): void {
2903
2968
  for (const domain of ["node", "edge"] as const) {
2904
2969
  for (const [property, cells] of columns[domain]) {
2905
2970
  const handle = declareFresh(
@@ -2922,6 +2987,13 @@ class CxReader {
2922
2987
  }
2923
2988
  }
2924
2989
  }
2990
+ }
2991
+
2992
+ /**
2993
+ * Write the network visual values as graph values in the cx.bypass namespace.
2994
+ * @param network - the value of each property
2995
+ */
2996
+ private writeBypassNetwork(network: ReadonlyMap<string, string>): void {
2925
2997
  for (const [property, text] of network) {
2926
2998
  try {
2927
2999
  this.sink.setGraphValue(property, text, {
@@ -2932,20 +3004,30 @@ class CxReader {
2932
3004
  this.report.recordError(err, { element: property });
2933
3005
  }
2934
3006
  }
2935
- if (defaults + mappings + dependencies + tableStyles > 0) {
2936
- const parts = [
2937
- `cyVisualProperties: ${defaults} default${plural(defaults)}, ${mappings} mapping${plural(mappings)}, ${dependencies} dependenc(ies)`,
2938
- ];
2939
- if (tableStyles > 0) {
2940
- parts.push(`${tableStyles} table style element${plural(tableStyles)}`);
2941
- }
2942
- this.report.warning(
2943
- "unsupported",
2944
- STYLES_NOT_IMPORTED_CODE,
2945
- `the file's style rules are not applied (${parts.join("; ")}); they are kept in meta.extra.cx`,
2946
- { element: "cyVisualProperties" },
2947
- );
3007
+ }
3008
+
3009
+ /**
3010
+ * Warn that the style rules (defaults, mappings, dependencies, table styles) are kept, not applied.
3011
+ * @param visual - the counted rules
3012
+ * @param tableStyles - the number of table style elements
3013
+ */
3014
+ private reportStyleRules(visual: VisualCollection, tableStyles: number): void {
3015
+ const { defaults, mappings, dependencies } = visual;
3016
+ if (defaults + mappings + dependencies + tableStyles === 0) {
3017
+ return;
2948
3018
  }
3019
+ const parts = [
3020
+ `cyVisualProperties: ${defaults} default${plural(defaults)}, ${mappings} mapping${plural(mappings)}, ${dependencies} dependenc(ies)`,
3021
+ ];
3022
+ if (tableStyles > 0) {
3023
+ parts.push(`${tableStyles} table style element${plural(tableStyles)}`);
3024
+ }
3025
+ this.report.warning(
3026
+ "unsupported",
3027
+ STYLES_NOT_IMPORTED_CODE,
3028
+ `the file's style rules are not applied (${parts.join("; ")}); they are kept in meta.extra.cx`,
3029
+ { element: "cyVisualProperties" },
3030
+ );
2949
3031
  }
2950
3032
 
2951
3033
  /**
@@ -304,9 +304,10 @@ function capabilityNotes(
304
304
  continue;
305
305
  }
306
306
  if (gen.code === LOSS.JSON) {
307
+ const what = gen.column === null ? "a column" : `column "${gen.column}"`;
307
308
  note(
308
309
  CX2_LOSS.JSON_AS_STRING,
309
- `${gen.column === null ? "a column" : `column "${gen.column}"`} holds nested values; written as a string attribute holding their JSON text`,
310
+ `${what} holds nested values; written as a string attribute holding their JSON text`,
310
311
  gen.column,
311
312
  gen.count,
312
313
  );
@@ -688,6 +688,25 @@ async function readDocument(
688
688
  return doc;
689
689
  }
690
690
 
691
+ /**
692
+ * Fail the import: the first member of the array is not a descriptor.
693
+ * @param value - the member
694
+ * @param line - its line
695
+ * @param report - the report
696
+ * @returns never: the import fails
697
+ */
698
+ function failNoDescriptor(value: unknown, line: number, report: ImportReportBuilder): never {
699
+ const keys = isRecord(value) ? Object.keys(value) : [];
700
+ const keyList = keys.length > 0 ? ` (keys: ${keys.join(", ")})` : "";
701
+ return report.fail(
702
+ CX2_ISSUE.NO_DESCRIPTOR,
703
+ `the first member of the array is not a descriptor with CXVersion${keyList}`,
704
+ {
705
+ line,
706
+ },
707
+ );
708
+ }
709
+
691
710
  /**
692
711
  * Check the descriptor, the first member of the array.
693
712
  * @param value - the member
@@ -697,12 +716,7 @@ async function readDocument(
697
716
  */
698
717
  function readDescriptor(value: unknown, line: number, doc: Cx2Document, report: ImportReportBuilder): void {
699
718
  if (!isRecord(value) || !("CXVersion" in value)) {
700
- const keys = isRecord(value) ? Object.keys(value) : [];
701
- report.fail(
702
- CX2_ISSUE.NO_DESCRIPTOR,
703
- `the first member of the array is not a descriptor with CXVersion${keys.length > 0 ? ` (keys: ${keys.join(", ")})` : ""}`,
704
- { line },
705
- );
719
+ failNoDescriptor(value, line, report);
706
720
  }
707
721
  const raw = value.CXVersion;
708
722
  const text = typeof raw === "string" || typeof raw === "number" ? String(raw) : "";
@@ -779,10 +793,11 @@ function memberAspect(
779
793
  collect(keys[0], value[keys[0]]);
780
794
  return;
781
795
  }
796
+ const what = keys.length > 1 ? `an object with ${keys.length} keys (${keys.join(", ")})` : shown(value);
782
797
  report.error(
783
798
  "parse-error",
784
799
  BAD_ASPECT_BLOCK_CODE,
785
- `a member of the document is ${keys.length > 1 ? `an object with ${keys.length} keys (${keys.join(", ")})` : shown(value)}, not a one-key aspect block; skipped`,
800
+ `a member of the document is ${what}, not a one-key aspect block; skipped`,
786
801
  { line, element: keys[0] ?? null },
787
802
  );
788
803
  }
@@ -1325,10 +1340,11 @@ class Cx2Reader {
1325
1340
  }
1326
1341
  const other = table.byName.get(alias) ?? table.byAlias.get(alias);
1327
1342
  if (other !== undefined) {
1343
+ const clash = other.name === alias ? "an attribute's name" : `the alias of "${other.name}"`;
1328
1344
  this.report.error(
1329
1345
  "validation-error",
1330
1346
  CX2_ISSUE.ALIAS_CONFLICT,
1331
- `the alias "${alias}" of the ${domain} attribute "${decl.name}" is also ${other.name === alias ? "an attribute's name" : `the alias of "${other.name}"`}; the alias is ignored`,
1347
+ `the alias "${alias}" of the ${domain} attribute "${decl.name}" is also ${clash}; the alias is ignored`,
1332
1348
  { element: decl.name },
1333
1349
  );
1334
1350
  decl.alias = null;
@@ -2259,6 +2275,14 @@ class Cx2Reader {
2259
2275
  }
2260
2276
  }
2261
2277
  }
2278
+ this.checkStatus();
2279
+ structure.checkCounts();
2280
+ }
2281
+
2282
+ /** The status block: present, and exactly one well-formed element. */
2283
+ private checkStatus(): void {
2284
+ const { report } = this;
2285
+ const { structure } = this.doc;
2262
2286
  if (!structure.hasStatus) {
2263
2287
  report.error("validation-error", CX2_ISSUE.NO_STATUS, "the document ends without a status block", {
2264
2288
  element: "status",
@@ -2266,14 +2290,14 @@ class Cx2Reader {
2266
2290
  } else if (!structure.statusWellFormed() || structure.counts.get("status") !== 1) {
2267
2291
  // the specification: exactly one status aspect holding exactly one element
2268
2292
  const count = structure.counts.get("status") ?? 0;
2293
+ const found = count === 1 ? shown(structure.status) : `${count} elements`;
2269
2294
  report.error(
2270
2295
  "validation-error",
2271
2296
  CX2_ISSUE.NO_STATUS,
2272
- `the status is malformed (${count === 1 ? shown(structure.status) : `${count} elements`}): a document ends with one status block holding one object with a boolean success`,
2297
+ `the status is malformed (${found}): a document ends with one status block holding one object with a boolean success`,
2273
2298
  { element: "status" },
2274
2299
  );
2275
2300
  }
2276
- structure.checkCounts();
2277
2301
  }
2278
2302
 
2279
2303
  /** Record the metadata: source format and version, name, description, id type, the kept aspects. */
@@ -1225,7 +1225,8 @@ function lifetimeAttrs(roles: RoleColumns, row: number, plan: ExportPlan): strin
1225
1225
  const text = timeText(column, roles.companions.get(column.meta.name) ?? null, row, timeFormat);
1226
1226
  // GEXF 1.2: `startopen` / `endopen` hold the time of a non-inclusive bound (dynamics.xsd
1227
1227
  // time-type) and replace `start` / `end`
1228
- out += ` ${open ? `${name}open` : name}="${escapeXmlAttribute(text)}"`;
1228
+ const attribute = open ? `${name}open` : name;
1229
+ out += ` ${attribute}="${escapeXmlAttribute(text)}"`;
1229
1230
  };
1230
1231
  if (roles.timestamp?.isSet(row)) {
1231
1232
  if (version === "1.3" && plan.timeRepresentation === "timestamp") {
@@ -1240,7 +1241,8 @@ function lifetimeAttrs(roles: RoleColumns, row: number, plan: ExportPlan): strin
1240
1241
  }
1241
1242
  if (version === "1.3" && roles.timestamps?.isSet(row)) {
1242
1243
  const items = listItems(roles.timestamps, row).map((t) => formatTimeValue(t as number, timeFormat));
1243
- out += ` timestamps="${escapeXmlAttribute(`<[${items.join(", ")}]>`)}"`;
1244
+ const stamps = `<[${items.join(", ")}]>`;
1245
+ out += ` timestamps="${escapeXmlAttribute(stamps)}"`;
1244
1246
  }
1245
1247
  return out;
1246
1248
  }