@graphty/graph-io 0.3.38 → 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 (152) hide show
  1. package/dist/chunks/{choose-CsO24U-5.js → choose-CS3fvGT1.js} +2 -2
  2. package/dist/chunks/{choose-CsO24U-5.js.map → choose-CS3fvGT1.js.map} +1 -1
  3. package/dist/chunks/{constants-BPRiVyBu.js → constants-DqKZT6cH.js} +2 -2
  4. package/dist/chunks/{constants-BPRiVyBu.js.map → constants-DqKZT6cH.js.map} +1 -1
  5. package/dist/chunks/{escape-BRcyzD77.js → escape-BJRLd3vJ.js} +4 -3
  6. package/dist/chunks/escape-BJRLd3vJ.js.map +1 -0
  7. package/dist/chunks/{export-CTUlibzr.js → export-B-aVCwyG.js} +103 -92
  8. package/dist/chunks/export-B-aVCwyG.js.map +1 -0
  9. package/dist/chunks/{exporter-Bf5tZA0X.js → exporter-6ocL_9x_.js} +6 -5
  10. package/dist/chunks/exporter-6ocL_9x_.js.map +1 -0
  11. package/dist/chunks/{exporter-Bn4jdYfd.js → exporter-BVXnV9-9.js} +43 -30
  12. package/dist/chunks/exporter-BVXnV9-9.js.map +1 -0
  13. package/dist/chunks/{exporter-D-qQuRcG.js → exporter-Bcp-l4Bo.js} +6 -6
  14. package/dist/chunks/exporter-Bcp-l4Bo.js.map +1 -0
  15. package/dist/chunks/{exporter-F0vT919P.js → exporter-SFYkDzD0.js} +5 -4
  16. package/dist/chunks/exporter-SFYkDzD0.js.map +1 -0
  17. package/dist/chunks/{importer-Bgb9sgZZ.js → importer-AjwOJrn2.js} +6 -8
  18. package/dist/chunks/importer-AjwOJrn2.js.map +1 -0
  19. package/dist/chunks/{importer-BuzbaFFY.js → importer-BJlGAOht.js} +28 -13
  20. package/dist/chunks/importer-BJlGAOht.js.map +1 -0
  21. package/dist/chunks/{importer-DqMtyYLX.js → importer-BpuS3J8C.js} +12 -14
  22. package/dist/chunks/importer-BpuS3J8C.js.map +1 -0
  23. package/dist/chunks/{importer-CEuUmyPe.js → importer-CPqnIY8m.js} +6 -6
  24. package/dist/chunks/{importer-CEuUmyPe.js.map → importer-CPqnIY8m.js.map} +1 -1
  25. package/dist/chunks/{importer-yWUPR_nU.js → importer-DEp_fYjf.js} +30 -12
  26. package/dist/chunks/importer-DEp_fYjf.js.map +1 -0
  27. package/dist/chunks/{importer-CNMQnHDd.js → importer-D_srHyj6.js} +112 -62
  28. package/dist/chunks/importer-D_srHyj6.js.map +1 -0
  29. package/dist/chunks/{importer-oIUx9u_g.js → importer-DcKB97kW.js} +40 -18
  30. package/dist/chunks/importer-DcKB97kW.js.map +1 -0
  31. package/dist/chunks/{json-elements-AkglPS3m.js → json-elements-DpO6AXIc.js} +7 -5
  32. package/dist/chunks/{json-elements-AkglPS3m.js.map → json-elements-DpO6AXIc.js.map} +1 -1
  33. package/dist/chunks/{ontology-export-DDoG3egY.js → ontology-export-50L9DOV4.js} +2 -2
  34. package/dist/chunks/{ontology-export-DDoG3egY.js.map → ontology-export-50L9DOV4.js.map} +1 -1
  35. package/dist/chunks/{records-ChmWZGuz.js → records-r3SFHz11.js} +137 -96
  36. package/dist/chunks/records-r3SFHz11.js.map +1 -0
  37. package/dist/chunks/{text-Btw3sBe_.js → text-Bjfa1xYL.js} +2 -2
  38. package/dist/chunks/{text-Btw3sBe_.js.map → text-Bjfa1xYL.js.map} +1 -1
  39. package/dist/csv.js +4 -4
  40. package/dist/cx.js +1 -1
  41. package/dist/cx2.js +2 -2
  42. package/dist/cys.js +1 -1
  43. package/dist/dot.js +1 -1
  44. package/dist/gexf.js +6 -4
  45. package/dist/gexf.js.map +1 -1
  46. package/dist/gml.js +8 -6
  47. package/dist/gml.js.map +1 -1
  48. package/dist/graph-io.js +26 -26
  49. package/dist/graphml.js +2 -2
  50. package/dist/json.js +1 -1
  51. package/dist/neo4j.js +3 -3
  52. package/dist/obo.js +1 -1
  53. package/dist/pajek.js +1 -1
  54. package/dist/src/common/direction.js +1 -1
  55. package/dist/src/common/direction.js.map +1 -1
  56. package/dist/src/common/export.d.ts.map +1 -1
  57. package/dist/src/common/export.js +136 -71
  58. package/dist/src/common/export.js.map +1 -1
  59. package/dist/src/common/input.js +1 -1
  60. package/dist/src/common/input.js.map +1 -1
  61. package/dist/src/common/json-elements.d.ts.map +1 -1
  62. package/dist/src/common/json-elements.js +4 -2
  63. package/dist/src/common/json-elements.js.map +1 -1
  64. package/dist/src/common/xml.d.ts.map +1 -1
  65. package/dist/src/common/xml.js +2 -1
  66. package/dist/src/common/xml.js.map +1 -1
  67. package/dist/src/formats/csv/records.d.ts.map +1 -1
  68. package/dist/src/formats/csv/records.js +138 -100
  69. package/dist/src/formats/csv/records.js.map +1 -1
  70. package/dist/src/formats/cx/importer.d.ts.map +1 -1
  71. package/dist/src/formats/cx/importer.js +108 -53
  72. package/dist/src/formats/cx/importer.js.map +1 -1
  73. package/dist/src/formats/cx2/exporter.d.ts.map +1 -1
  74. package/dist/src/formats/cx2/exporter.js +2 -1
  75. package/dist/src/formats/cx2/exporter.js.map +1 -1
  76. package/dist/src/formats/cx2/importer.d.ts.map +1 -1
  77. package/dist/src/formats/cx2/importer.js +28 -6
  78. package/dist/src/formats/cx2/importer.js.map +1 -1
  79. package/dist/src/formats/dot/importer.d.ts +6 -0
  80. package/dist/src/formats/dot/importer.d.ts.map +1 -1
  81. package/dist/src/formats/dot/importer.js +11 -6
  82. package/dist/src/formats/dot/importer.js.map +1 -1
  83. package/dist/src/formats/dot/names.d.ts +5 -0
  84. package/dist/src/formats/dot/names.d.ts.map +1 -1
  85. package/dist/src/formats/dot/names.js +5 -0
  86. package/dist/src/formats/dot/names.js.map +1 -1
  87. package/dist/src/formats/dot/tokenizer.js +9 -9
  88. package/dist/src/formats/dot/tokenizer.js.map +1 -1
  89. package/dist/src/formats/gexf/exporter.d.ts.map +1 -1
  90. package/dist/src/formats/gexf/exporter.js +4 -2
  91. package/dist/src/formats/gexf/exporter.js.map +1 -1
  92. package/dist/src/formats/gml/importer.d.ts +6 -0
  93. package/dist/src/formats/gml/importer.d.ts.map +1 -1
  94. package/dist/src/formats/gml/importer.js +6 -3
  95. package/dist/src/formats/gml/importer.js.map +1 -1
  96. package/dist/src/formats/graphml/exporter.d.ts.map +1 -1
  97. package/dist/src/formats/graphml/exporter.js +2 -1
  98. package/dist/src/formats/graphml/exporter.js.map +1 -1
  99. package/dist/src/formats/graphml/importer.d.ts.map +1 -1
  100. package/dist/src/formats/graphml/importer.js +5 -6
  101. package/dist/src/formats/graphml/importer.js.map +1 -1
  102. package/dist/src/formats/obo/importer.d.ts.map +1 -1
  103. package/dist/src/formats/obo/importer.js +2 -1
  104. package/dist/src/formats/obo/importer.js.map +1 -1
  105. package/dist/src/formats/pajek/exporter.d.ts.map +1 -1
  106. package/dist/src/formats/pajek/exporter.js +4 -5
  107. package/dist/src/formats/pajek/exporter.js.map +1 -1
  108. package/dist/src/formats/xgmml/emit.d.ts +6 -0
  109. package/dist/src/formats/xgmml/emit.d.ts.map +1 -1
  110. package/dist/src/formats/xgmml/emit.js +33 -10
  111. package/dist/src/formats/xgmml/emit.js.map +1 -1
  112. package/dist/src/formats/xgmml/exporter.d.ts.map +1 -1
  113. package/dist/src/formats/xgmml/exporter.js +54 -22
  114. package/dist/src/formats/xgmml/exporter.js.map +1 -1
  115. package/dist/src/sniff.js +1 -1
  116. package/dist/src/sniff.js.map +1 -1
  117. package/dist/xgmml.js +2 -2
  118. package/package.json +2 -2
  119. package/src/common/direction.ts +1 -1
  120. package/src/common/export.ts +190 -97
  121. package/src/common/input.ts +1 -1
  122. package/src/common/json-elements.ts +4 -2
  123. package/src/common/xml.ts +2 -1
  124. package/src/formats/csv/records.ts +143 -99
  125. package/src/formats/cx/importer.ts +140 -58
  126. package/src/formats/cx2/exporter.ts +2 -1
  127. package/src/formats/cx2/importer.ts +34 -10
  128. package/src/formats/dot/importer.ts +35 -4
  129. package/src/formats/dot/names.ts +6 -0
  130. package/src/formats/dot/tokenizer.ts +9 -9
  131. package/src/formats/gexf/exporter.ts +4 -2
  132. package/src/formats/gml/importer.ts +14 -3
  133. package/src/formats/graphml/exporter.ts +2 -3
  134. package/src/formats/graphml/importer.ts +5 -6
  135. package/src/formats/obo/importer.ts +2 -1
  136. package/src/formats/pajek/exporter.ts +6 -4
  137. package/src/formats/xgmml/emit.ts +34 -10
  138. package/src/formats/xgmml/exporter.ts +63 -23
  139. package/src/sniff.ts +1 -1
  140. package/dist/chunks/escape-BRcyzD77.js.map +0 -1
  141. package/dist/chunks/export-CTUlibzr.js.map +0 -1
  142. package/dist/chunks/exporter-Bf5tZA0X.js.map +0 -1
  143. package/dist/chunks/exporter-Bn4jdYfd.js.map +0 -1
  144. package/dist/chunks/exporter-D-qQuRcG.js.map +0 -1
  145. package/dist/chunks/exporter-F0vT919P.js.map +0 -1
  146. package/dist/chunks/importer-Bgb9sgZZ.js.map +0 -1
  147. package/dist/chunks/importer-BuzbaFFY.js.map +0 -1
  148. package/dist/chunks/importer-CNMQnHDd.js.map +0 -1
  149. package/dist/chunks/importer-DqMtyYLX.js.map +0 -1
  150. package/dist/chunks/importer-oIUx9u_g.js.map +0 -1
  151. package/dist/chunks/importer-yWUPR_nU.js.map +0 -1
  152. package/dist/chunks/records-ChmWZGuz.js.map +0 -1
@@ -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. */
@@ -97,6 +97,7 @@ import {
97
97
  PARENT_COLUMN,
98
98
  PIN_ATTRIBUTE,
99
99
  POS_ATTRIBUTE,
100
+ POSITION_COLUMN,
100
101
  SOURCE_PORT_COLUMN,
101
102
  TARGET_PORT_COLUMN,
102
103
  } from "./names.js";
@@ -122,6 +123,12 @@ export interface DotImportOptions extends GraphChoiceOptions, CommonImportOption
122
123
  * @defaultValue true
123
124
  */
124
125
  positions?: boolean | undefined;
126
+ /**
127
+ * With `positions`, also keep `pos` as the text the file wrote; the position column is then
128
+ * named `graphty.position`, so a reader gets both.
129
+ * @defaultValue false
130
+ */
131
+ keepPositionSource?: boolean | undefined;
125
132
  }
126
133
 
127
134
  /**
@@ -323,7 +330,7 @@ interface Scope {
323
330
  */
324
331
  export const dotImporter: GraphImporter<DotImportOptions> = Object.freeze({
325
332
  format: DOT_FORMAT,
326
- options: Object.freeze(["mismatchedEdgeOperator", "positions"]),
333
+ options: Object.freeze(["keepPositionSource", "mismatchedEdgeOperator", "positions"]),
327
334
  extensions: EXTENSIONS,
328
335
  mimeTypes: MIME_TYPES,
329
336
 
@@ -376,7 +383,15 @@ export const dotImporter: GraphImporter<DotImportOptions> = Object.freeze({
376
383
  const reports = { current: report };
377
384
  const lexer = dotLexer(text, reports);
378
385
  const first = guard(report, () => lexer.next());
379
- const parser = new DotParser(lexer, sink, report, resolved, mismatch, options?.positions !== false);
386
+ const parser = new DotParser(
387
+ lexer,
388
+ sink,
389
+ report,
390
+ resolved,
391
+ mismatch,
392
+ options?.positions !== false,
393
+ options?.keepPositionSource === true,
394
+ );
380
395
  parser.headCharset = head.charset;
381
396
  const trailing = parseGraph(parser, report, first);
382
397
  if (trailing.kind !== "eof") {
@@ -429,7 +444,15 @@ export const dotImporter: GraphImporter<DotImportOptions> = Object.freeze({
429
444
  reports.current = report;
430
445
  const sink = sinkFor(done.length);
431
446
  reportSinkOptions(sink, options, report);
432
- const parser = new DotParser(lexer, sink, report, resolved, mismatch, options?.positions !== false);
447
+ const parser = new DotParser(
448
+ lexer,
449
+ sink,
450
+ report,
451
+ resolved,
452
+ mismatch,
453
+ options?.positions !== false,
454
+ options?.keepPositionSource === true,
455
+ );
433
456
  parser.headCharset = head.charset;
434
457
  token = parseGraph(parser, report, token);
435
458
  throwIfAborted(resolved.signal);
@@ -686,6 +709,8 @@ class DotParser {
686
709
  private readonly mismatch: "operator" | "header" | "error";
687
710
  private readonly positions: boolean;
688
711
 
712
+ private readonly keepPositionSource: boolean;
713
+
689
714
  private readonly ids: IdCoercer;
690
715
 
691
716
  private readonly resolver: DirectionResolver;
@@ -758,6 +783,7 @@ class DotParser {
758
783
  * @param options - the resolved common options
759
784
  * @param mismatch - the resolved mismatchedEdgeOperator option
760
785
  * @param positions - whether `pos` goes to the position column
786
+ * @param keepPositionSource - whether `pos` also stays as text
761
787
  */
762
788
  constructor(
763
789
  lexer: DotTokenizer,
@@ -766,6 +792,7 @@ class DotParser {
766
792
  options: ResolvedImportOptions,
767
793
  mismatch: "operator" | "header" | "error",
768
794
  positions: boolean,
795
+ keepPositionSource: boolean,
769
796
  ) {
770
797
  this.lexer = lexer;
771
798
  this.sink = sink;
@@ -773,6 +800,7 @@ class DotParser {
773
800
  this.options = options;
774
801
  this.mismatch = mismatch;
775
802
  this.positions = positions;
803
+ this.keepPositionSource = keepPositionSource;
776
804
  this.ids = new IdCoercer(options.ids);
777
805
  this.resolver = new DirectionResolver(sink, report, options.onMixedDirection);
778
806
  }
@@ -1586,6 +1614,9 @@ class DotParser {
1586
1614
  this.sink.setNodeValue(this.nodeLabel(), index, value);
1587
1615
  } else if (name === POS_ATTRIBUTE && this.positions) {
1588
1616
  this.setPosition(index, value, line, element);
1617
+ if (this.keepPositionSource) {
1618
+ this.textWriter("node", name).write(index, value);
1619
+ }
1589
1620
  } else {
1590
1621
  this.textWriter("node", name).write(index, value);
1591
1622
  }
@@ -1766,7 +1797,7 @@ class DotParser {
1766
1797
  private positionColumn(dims: 2 | 3): ColumnHandle {
1767
1798
  if (this.positionHandle === INVALID_INDEX) {
1768
1799
  this.positionHandle = this.declare("node", {
1769
- name: POS_ATTRIBUTE,
1800
+ name: this.keepPositionSource ? POSITION_COLUMN : POS_ATTRIBUTE,
1770
1801
  dtype: "f64",
1771
1802
  components: 3,
1772
1803
  nullable: true,
@@ -54,6 +54,12 @@ export const LABEL_ATTRIBUTE = "label";
54
54
  */
55
55
  export const POS_ATTRIBUTE = "pos";
56
56
 
57
+ /**
58
+ * The node position column when the import keeps `pos` as text too (the `keepPositionSource` option).
59
+ * @category Plugin helpers
60
+ */
61
+ export const POSITION_COLUMN = "graphty.position";
62
+
57
63
  /**
58
64
  * The edge attribute cgraph uses as an edge's identity within its endpoints, mapped to the edge id role.
59
65
  * @category Plugin helpers
@@ -148,15 +148,15 @@ export class DotTokenizer {
148
148
  const { line } = this;
149
149
  const c = text.charCodeAt(start);
150
150
  switch (c) {
151
- case 0x7b: // {
152
- case 0x7d: // }
153
- case 0x5b: // [
154
- case 0x5d: // ]
155
- case 0x3b: // ;
156
- case 0x2c: // ,
157
- case 0x3d: // =
158
- case 0x3a: // :
159
- case 0x2b: // +
151
+ case 0x7b: // "{"
152
+ case 0x7d: // "}"
153
+ case 0x5b: // "["
154
+ case 0x5d: // "]"
155
+ case 0x3b: // ";"
156
+ case 0x2c: // ","
157
+ case 0x3d: // "="
158
+ case 0x3a: // ":"
159
+ case 0x2b: // "+"
160
160
  this.pos++;
161
161
  return { kind: "punct", text: text[start], quoted: false, html: false, line };
162
162
  case 0x2d: {
@@ -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
  }
@@ -97,6 +97,12 @@ export interface GmlImportOptions extends GraphChoiceOptions, CommonImportOption
97
97
  * @defaultValue true
98
98
  */
99
99
  positions?: boolean | undefined;
100
+ /**
101
+ * With `positions`, also keep the whole `graphics` record, x / y / z included, as the JSON
102
+ * attribute named `graphics`, so a reader gets both the position and the record as written.
103
+ * @defaultValue false
104
+ */
105
+ keepPositionSource?: boolean | undefined;
100
106
  /**
101
107
  * Store a text attribute whose values repeat a lot (fewer distinct values than half the rows)
102
108
  * as a dictionary column, which uses less memory and reads the same. Such a column reports
@@ -378,6 +384,8 @@ class GmlImport {
378
384
 
379
385
  private readonly positions: boolean;
380
386
 
387
+ private readonly keepPositionSource: boolean;
388
+
381
389
  private readonly dictionaries: boolean;
382
390
 
383
391
  private readonly coercer: IdCoercer;
@@ -470,6 +478,7 @@ class GmlImport {
470
478
  this.report = report;
471
479
  this.options = options;
472
480
  this.positions = format?.positions ?? true;
481
+ this.keepPositionSource = format?.keepPositionSource ?? false;
473
482
  this.dictionaries = format?.dictionaries ?? true;
474
483
  this.coercer = new IdCoercer(options.ids);
475
484
  }
@@ -755,7 +764,8 @@ class GmlImport {
755
764
  }
756
765
  const positioned = x && y;
757
766
  entry.position ||= positioned;
758
- entry.rest ||= positioned ? other : true;
767
+ // with keepPositionSource the whole record, x / y / z included, goes to the json column too
768
+ entry.rest ||= positioned ? other || this.keepPositionSource : true;
759
769
  }
760
770
 
761
771
  // ============================================================ pass 2: the push
@@ -1541,7 +1551,8 @@ class GmlImport {
1541
1551
  const positionPlan = plan.position;
1542
1552
  let rest: unknown = first;
1543
1553
  if (isRecord(first) && positionPlan !== null) {
1544
- rest = this.writePosition(positionPlan, index, first, where);
1554
+ const left = this.writePosition(positionPlan, index, this.keepPositionSource ? { ...first } : first, where);
1555
+ rest = this.keepPositionSource ? first : left;
1545
1556
  } else if (!isRecord(first)) {
1546
1557
  this.graphicsWarning("scalar", `node ${where.element}'s graphics is not a record`, where);
1547
1558
  }
@@ -1924,7 +1935,7 @@ function sniffGml(head: Uint8Array): number {
1924
1935
  */
1925
1936
  export const gmlImporter: GraphImporter<GmlImportOptions> = Object.freeze({
1926
1937
  format: "gml",
1927
- options: Object.freeze(["dictionaries", "positions"]),
1938
+ options: Object.freeze(["dictionaries", "keepPositionSource", "positions"]),
1928
1939
  extensions: Object.freeze([".gml"]),
1929
1940
  mimeTypes: Object.freeze(["text/x-gml", "text/plain"]),
1930
1941
  sniff: sniffGml,
@@ -1418,9 +1418,8 @@ function* writeNodeSubtree(
1418
1418
  }
1419
1419
  if (hasChildren(u)) {
1420
1420
  parts.splice(open, 0, ">");
1421
- parts.push(
1422
- `\n${pad}${step}<graph id="${escapeXmlAttribute(`${String(ids.idAt(u))}:`)}" edgedefault="${plan.edgedefault}">`,
1423
- );
1421
+ const graphId = `${String(ids.idAt(u))}:`;
1422
+ parts.push(`\n${pad}${step}<graph id="${escapeXmlAttribute(graphId)}" edgedefault="${plan.edgedefault}">`);
1424
1423
  return parts.join("");
1425
1424
  }
1426
1425
  if (parts.length === open) {
@@ -512,16 +512,14 @@ class GraphmlReader implements XmlHandler {
512
512
  const parent = this.ctx.at(-1) ?? null;
513
513
  switch (parent) {
514
514
  case Ctx.Capture:
515
+ case Ctx.Data:
516
+ case Ctx.Default:
515
517
  this.captureStart(name, attrs);
516
518
  return;
517
519
  case Ctx.Skip:
518
520
  case Ctx.Desc:
519
521
  this.ctx.push(Ctx.Skip);
520
522
  return;
521
- case Ctx.Data:
522
- case Ctx.Default:
523
- this.captureStart(name, attrs);
524
- return;
525
523
  case Ctx.Dropped:
526
524
  this.dropStart(localName(name));
527
525
  return;
@@ -2464,9 +2462,10 @@ class GraphmlReader implements XmlHandler {
2464
2462
  */
2465
2463
  private expandStar(hyperedge: HyperedgeState, where: Where): void {
2466
2464
  this.hyperedgeCount++;
2467
- let hubId = hyperedge.id ?? `hyperedge${this.hyperedgeCount}`;
2465
+ const baseId = hyperedge.id ?? `hyperedge${this.hyperedgeCount}`;
2466
+ let hubId = baseId;
2468
2467
  for (let n = 2; this.sink.indexOf(hubId) !== INVALID_INDEX; n++) {
2469
- hubId = `${hyperedge.id ?? `hyperedge${this.hyperedgeCount}`}#${n}`;
2468
+ hubId = `${baseId}#${n}`;
2470
2469
  }
2471
2470
  const hub = this.sink.addNode(hubId);
2472
2471
  this.declared.add(hub);
@@ -1429,7 +1429,8 @@ class OboReader {
1429
1429
  return;
1430
1430
  }
1431
1431
  const map = (record.values.get("obo.qualifiers") as Record<string, unknown[]> | undefined) ?? {};
1432
- (map[tag] ??= []).push({ value, qualifiers });
1432
+ map[tag] ??= [];
1433
+ map[tag].push({ value, qualifiers });
1433
1434
  record.values.set("obo.qualifiers", map);
1434
1435
  }
1435
1436