@graphty/graph-io 0.3.9 → 0.3.11

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 (125) hide show
  1. package/README.md +29 -5
  2. package/dist/chunks/{escape-BjmIaFFo.js → escape-D1f9-cwf.js} +4 -4
  3. package/dist/chunks/{escape-BjmIaFFo.js.map → escape-D1f9-cwf.js.map} +1 -1
  4. package/dist/chunks/{importer-BddBW3sf.js → importer-9PxhqH4v.js} +336 -92
  5. package/dist/chunks/importer-9PxhqH4v.js.map +1 -0
  6. package/dist/chunks/{importer-DfI8POXb.js → importer-B8lsjFWx.js} +6 -5
  7. package/dist/chunks/importer-B8lsjFWx.js.map +1 -0
  8. package/dist/chunks/{importer-BtHFICnf.js → importer-C3HcYWAn.js} +5 -3
  9. package/dist/chunks/importer-C3HcYWAn.js.map +1 -0
  10. package/dist/chunks/{importer-BOxwmef_.js → importer-C7mnGdr_.js} +125 -8
  11. package/dist/chunks/importer-C7mnGdr_.js.map +1 -0
  12. package/dist/chunks/{records-BKSMowhR.js → records-IHsCfv7s.js} +20 -7
  13. package/dist/chunks/records-IHsCfv7s.js.map +1 -0
  14. package/dist/chunks/{writer-BdMak4_J.js → writer-BtWpUaiH.js} +4 -4
  15. package/dist/chunks/{writer-BdMak4_J.js.map → writer-BtWpUaiH.js.map} +1 -1
  16. package/dist/csv.js +239 -18
  17. package/dist/csv.js.map +1 -1
  18. package/dist/dot.js +1 -1
  19. package/dist/gexf.js +72 -33
  20. package/dist/gexf.js.map +1 -1
  21. package/dist/gml.js +44 -23
  22. package/dist/gml.js.map +1 -1
  23. package/dist/graph-io.js +11 -11
  24. package/dist/graphml.js +1 -1
  25. package/dist/json.js +1 -1
  26. package/dist/neo4j.js +5 -4
  27. package/dist/neo4j.js.map +1 -1
  28. package/dist/pajek.js +1 -1
  29. package/dist/src/common/export.js +1 -1
  30. package/dist/src/common/export.js.map +1 -1
  31. package/dist/src/formats/csv/exporter.d.ts +15 -4
  32. package/dist/src/formats/csv/exporter.d.ts.map +1 -1
  33. package/dist/src/formats/csv/exporter.js +125 -6
  34. package/dist/src/formats/csv/exporter.js.map +1 -1
  35. package/dist/src/formats/csv/importer.d.ts +36 -4
  36. package/dist/src/formats/csv/importer.d.ts.map +1 -1
  37. package/dist/src/formats/csv/importer.js +169 -8
  38. package/dist/src/formats/csv/importer.js.map +1 -1
  39. package/dist/src/formats/csv/records.d.ts +14 -1
  40. package/dist/src/formats/csv/records.d.ts.map +1 -1
  41. package/dist/src/formats/csv/records.js +28 -7
  42. package/dist/src/formats/csv/records.js.map +1 -1
  43. package/dist/src/formats/dot/exporter.d.ts.map +1 -1
  44. package/dist/src/formats/dot/exporter.js +2 -0
  45. package/dist/src/formats/dot/exporter.js.map +1 -1
  46. package/dist/src/formats/gexf/exporter.d.ts.map +1 -1
  47. package/dist/src/formats/gexf/exporter.js +36 -10
  48. package/dist/src/formats/gexf/exporter.js.map +1 -1
  49. package/dist/src/formats/gexf/importer.d.ts +9 -4
  50. package/dist/src/formats/gexf/importer.d.ts.map +1 -1
  51. package/dist/src/formats/gexf/importer.js +44 -21
  52. package/dist/src/formats/gexf/importer.js.map +1 -1
  53. package/dist/src/formats/gexf/index.d.ts +1 -1
  54. package/dist/src/formats/gexf/index.d.ts.map +1 -1
  55. package/dist/src/formats/gexf/index.js +1 -1
  56. package/dist/src/formats/gexf/index.js.map +1 -1
  57. package/dist/src/formats/gexf/schema.d.ts +2 -0
  58. package/dist/src/formats/gexf/schema.d.ts.map +1 -1
  59. package/dist/src/formats/gexf/schema.js +18 -0
  60. package/dist/src/formats/gexf/schema.js.map +1 -1
  61. package/dist/src/formats/gml/exporter.d.ts.map +1 -1
  62. package/dist/src/formats/gml/exporter.js +1 -0
  63. package/dist/src/formats/gml/exporter.js.map +1 -1
  64. package/dist/src/formats/gml/importer.d.ts +7 -4
  65. package/dist/src/formats/gml/importer.d.ts.map +1 -1
  66. package/dist/src/formats/gml/importer.js +32 -19
  67. package/dist/src/formats/gml/importer.js.map +1 -1
  68. package/dist/src/formats/gml/index.d.ts +3 -1
  69. package/dist/src/formats/gml/index.d.ts.map +1 -1
  70. package/dist/src/formats/gml/index.js +4 -2
  71. package/dist/src/formats/gml/index.js.map +1 -1
  72. package/dist/src/formats/graphml/constants.d.ts +2 -0
  73. package/dist/src/formats/graphml/constants.d.ts.map +1 -1
  74. package/dist/src/formats/graphml/constants.js +2 -0
  75. package/dist/src/formats/graphml/constants.js.map +1 -1
  76. package/dist/src/formats/graphml/exporter.d.ts.map +1 -1
  77. package/dist/src/formats/graphml/exporter.js +14 -2
  78. package/dist/src/formats/graphml/exporter.js.map +1 -1
  79. package/dist/src/formats/graphml/importer.d.ts +2 -1
  80. package/dist/src/formats/graphml/importer.d.ts.map +1 -1
  81. package/dist/src/formats/graphml/importer.js +91 -27
  82. package/dist/src/formats/graphml/importer.js.map +1 -1
  83. package/dist/src/formats/graphml/yfiles.d.ts +62 -0
  84. package/dist/src/formats/graphml/yfiles.d.ts.map +1 -0
  85. package/dist/src/formats/graphml/yfiles.js +269 -0
  86. package/dist/src/formats/graphml/yfiles.js.map +1 -0
  87. package/dist/src/formats/json/exporter.d.ts.map +1 -1
  88. package/dist/src/formats/json/exporter.js +9 -1
  89. package/dist/src/formats/json/exporter.js.map +1 -1
  90. package/dist/src/formats/json/importer.d.ts +14 -0
  91. package/dist/src/formats/json/importer.d.ts.map +1 -1
  92. package/dist/src/formats/json/importer.js +165 -4
  93. package/dist/src/formats/json/importer.js.map +1 -1
  94. package/dist/src/formats/neo4j/exporter.d.ts.map +1 -1
  95. package/dist/src/formats/neo4j/exporter.js +1 -0
  96. package/dist/src/formats/neo4j/exporter.js.map +1 -1
  97. package/dist/src/formats/pajek/exporter.d.ts.map +1 -1
  98. package/dist/src/formats/pajek/exporter.js +3 -2
  99. package/dist/src/formats/pajek/exporter.js.map +1 -1
  100. package/package.json +2 -2
  101. package/src/common/export.ts +1 -1
  102. package/src/formats/csv/exporter.ts +140 -10
  103. package/src/formats/csv/importer.ts +207 -18
  104. package/src/formats/csv/records.ts +41 -6
  105. package/src/formats/dot/exporter.ts +2 -0
  106. package/src/formats/gexf/exporter.ts +40 -11
  107. package/src/formats/gexf/importer.ts +47 -25
  108. package/src/formats/gexf/index.ts +1 -1
  109. package/src/formats/gexf/schema.ts +18 -0
  110. package/src/formats/gml/exporter.ts +1 -0
  111. package/src/formats/gml/importer.ts +47 -24
  112. package/src/formats/gml/index.ts +4 -1
  113. package/src/formats/graphml/constants.ts +2 -0
  114. package/src/formats/graphml/exporter.ts +21 -2
  115. package/src/formats/graphml/importer.ts +99 -28
  116. package/src/formats/graphml/yfiles.ts +292 -0
  117. package/src/formats/json/exporter.ts +9 -1
  118. package/src/formats/json/importer.ts +202 -4
  119. package/src/formats/neo4j/exporter.ts +1 -0
  120. package/src/formats/pajek/exporter.ts +3 -1
  121. package/dist/chunks/importer-BOxwmef_.js.map +0 -1
  122. package/dist/chunks/importer-BddBW3sf.js.map +0 -1
  123. package/dist/chunks/importer-BtHFICnf.js.map +0 -1
  124. package/dist/chunks/importer-DfI8POXb.js.map +0 -1
  125. package/dist/chunks/records-BKSMowhR.js.map +0 -1
@@ -10,9 +10,11 @@
10
10
  * forward reference is legal; element lifetimes (`start` / `end` / `timestamp`, `<spells>`, 1.3
11
11
  * `timestamps` / `intervals`, 1.2 `startopen` / `endopen` holding the open bound's time) become
12
12
  * the temporal role columns; dynamic attribute values go into the temporal extension tables of
13
- * design section 5.10; the graph header's `mode`, `timeformat`, `timerepresentation` and `idtype`
14
- * land in the graph meta (`idtype` is informational: Gephi writes `idtype="string"` for every
15
- * file, so ids follow the `ids` rule, canonical by default).
13
+ * design section 5.10, and a spell's open bounds go to the spells.open column; the graph header's
14
+ * `mode`, `timeformat`, `timerepresentation` and `idtype` land in the graph meta, and its
15
+ * `defaultedgetype`, `start`, `end` and `timestamp` as written in `meta.extra.gexf` (`idtype` is
16
+ * informational: Gephi writes `idtype="string"` for every file, so ids follow the `ids` rule,
17
+ * canonical by default).
16
18
  *
17
19
  * Direction follows design section 8.4: `defaultedgetype` (undirected when absent, per the spec and
18
20
  * the `defaultDirected` option) sets the sink's direction once, and every per-edge `type` override,
@@ -136,7 +138,10 @@ export const WEIGHT_IGNORED_CODE = "W_GEXF_WEIGHT_IGNORED";
136
138
  export const VIZ_SKIPPED_CODE = "W_GEXF_VIZ_SKIPPED";
137
139
  /** Issue code: a 1.2 dynamic viz element (its bounds are dropped, the value kept). */
138
140
  export const VIZ_DYNAMIC_CODE = "W_GEXF_VIZ_DYNAMIC_DROPPED";
139
- /** Issue code: `startopen` / `endopen` on a `<spell>` (the spells column has no open bits). */
141
+ /**
142
+ * Issue code: `startopen` / `endopen` on a `<spell>` stored closed. No longer recorded: the open
143
+ * bits of each spell are kept in the spells.open column. Kept so the exported code table is stable.
144
+ */
140
145
  export const SPELL_OPEN_CODE = "W_GEXF_SPELL_OPEN_DROPPED";
141
146
  /** Issue code: a viz element with a value that does not parse (the element is skipped). */
142
147
  export const VIZ_VALUE_CODE = "W_GEXF_VIZ_VALUE";
@@ -153,6 +158,16 @@ export {
153
158
  };
154
159
 
155
160
  const EDGE_TYPES: ReadonlySet<string> = new Set(["directed", "undirected", "mutual"]);
161
+
162
+ /**
163
+ * An edge type as a keyword of {@link EDGE_TYPES}: the schema spells them in lower case, but files
164
+ * in the wild write `Directed`, and the keyword is read whatever its case or surrounding space.
165
+ * @param text - a `defaultedgetype` or edge `type` value
166
+ * @returns the text trimmed and in lower case
167
+ */
168
+ function edgeTypeKeyword(text: string): string {
169
+ return text.trim().toLowerCase();
170
+ }
156
171
  const ABORT_CHECK_INTERVAL = 64;
157
172
 
158
173
  /** The XML attributes the importer reads on `<node>`; any other one is reported once. */
@@ -351,6 +366,7 @@ interface DomainColumns {
351
366
  readonly end: TemporalColumn;
352
367
  readonly timestamp: TemporalColumn;
353
368
  readonly spells: LazyColumn;
369
+ readonly spellsOpen: LazyColumn;
354
370
  readonly timestamps: LazyColumn;
355
371
  readonly open: LazyColumn;
356
372
  readonly color: LazyColumn;
@@ -502,6 +518,9 @@ class GexfReader implements XmlHandler {
502
518
 
503
519
  private spellPairs: number[][] | null = null;
504
520
 
521
+ /** The open bits of each spell in spellPairs, in the same order. */
522
+ private spellOpen: number[] = [];
523
+
505
524
  private spellsWhere: IssueLocation | null = null;
506
525
 
507
526
  private parentIds: NodeId[] | null = null;
@@ -532,6 +551,7 @@ class GexfReader implements XmlHandler {
532
551
  end: new TemporalColumn("node", NODE_DECLS.end),
533
552
  timestamp: new TemporalColumn("node", NODE_DECLS.timestamp),
534
553
  spells: new LazyColumn("node", NODE_DECLS.spells),
554
+ spellsOpen: new LazyColumn("node", NODE_DECLS.spellsOpen),
535
555
  timestamps: new LazyColumn("node", NODE_DECLS.timestamps),
536
556
  open: new LazyColumn("node", NODE_DECLS.open),
537
557
  color: new LazyColumn("node", NODE_DECLS.color),
@@ -543,6 +563,7 @@ class GexfReader implements XmlHandler {
543
563
  end: new TemporalColumn("edge", EDGE_DECLS.end),
544
564
  timestamp: new TemporalColumn("edge", EDGE_DECLS.timestamp),
545
565
  spells: new LazyColumn("edge", EDGE_DECLS.spells),
566
+ spellsOpen: new LazyColumn("edge", EDGE_DECLS.spellsOpen),
546
567
  timestamps: new LazyColumn("edge", EDGE_DECLS.timestamps),
547
568
  open: new LazyColumn("edge", EDGE_DECLS.open),
548
569
  color: new LazyColumn("edge", EDGE_DECLS.color),
@@ -801,8 +822,8 @@ class GexfReader implements XmlHandler {
801
822
  const edgeType = attrs.get("defaultedgetype");
802
823
  if (edgeType === undefined) {
803
824
  this.defaultKind = this.options.defaultDirected ? "directed" : "undirected";
804
- } else if (EDGE_TYPES.has(edgeType)) {
805
- this.defaultKind = edgeType as GexfEdgeType;
825
+ } else if (EDGE_TYPES.has(edgeTypeKeyword(edgeType))) {
826
+ this.defaultKind = edgeTypeKeyword(edgeType) as GexfEdgeType;
806
827
  } else {
807
828
  this.defaultKind = this.options.defaultDirected ? "directed" : "undirected";
808
829
  this.warnHeader("defaultedgetype", edgeType, where);
@@ -846,7 +867,7 @@ class GexfReader implements XmlHandler {
846
867
  }
847
868
  }
848
869
  const extra: Record<string, unknown> = {};
849
- for (const key of ["start", "end", "timestamp"]) {
870
+ for (const key of ["defaultedgetype", "start", "end", "timestamp"]) {
850
871
  const value = attrs.get(key);
851
872
  if (value !== undefined) {
852
873
  extra[key] = value;
@@ -1256,6 +1277,7 @@ class GexfReader implements XmlHandler {
1256
1277
  return;
1257
1278
  case "spells":
1258
1279
  this.spellPairs = [];
1280
+ this.spellOpen = [];
1259
1281
  this.spellsWhere = frame.where;
1260
1282
  this.ctx.push(Ctx.Spells);
1261
1283
  return;
@@ -1408,13 +1430,14 @@ class GexfReader implements XmlHandler {
1408
1430
  return;
1409
1431
  }
1410
1432
  let kind: EdgeKind = this.defaultKind;
1411
- const type = attrs.get("type");
1412
- if (type !== undefined) {
1433
+ const typeText = attrs.get("type");
1434
+ if (typeText !== undefined) {
1435
+ const type = edgeTypeKeyword(typeText);
1413
1436
  if (!EDGE_TYPES.has(type)) {
1414
1437
  report.error(
1415
1438
  "validation-error",
1416
1439
  EDGE_TYPE_CODE,
1417
- `edge type "${type}" is not directed, undirected or mutual`,
1440
+ `edge type "${typeText}" is not directed, undirected or mutual`,
1418
1441
  where,
1419
1442
  );
1420
1443
  report.counts.skippedEdges++;
@@ -1525,6 +1548,7 @@ class GexfReader implements XmlHandler {
1525
1548
  return;
1526
1549
  case "spells":
1527
1550
  this.spellPairs = [];
1551
+ this.spellOpen = [];
1528
1552
  this.spellsWhere = edge.where;
1529
1553
  this.ctx.push(Ctx.Spells);
1530
1554
  return;
@@ -1970,36 +1994,34 @@ class GexfReader implements XmlHandler {
1970
1994
  const where = this.spellsWhere ?? { line };
1971
1995
  try {
1972
1996
  const bounds = this.readBounds(attrs, where);
1973
- if (bounds.open !== 0) {
1974
- this.report.warnOnce(
1975
- "unsupported",
1976
- SPELL_OPEN_CODE,
1977
- "startopen / endopen on a <spell> cannot be kept; the spell is stored closed",
1978
- where,
1979
- );
1980
- }
1981
1997
  this.spellPairs?.push([bounds.start.value, bounds.end.value]);
1998
+ this.spellOpen.push(bounds.open);
1982
1999
  } catch (err) {
1983
2000
  this.report.recordError(err, where);
1984
2001
  }
1985
2002
  }
1986
2003
 
1987
- /** Close `<spells>`: write the pairs into the open element's spells column. */
2004
+ /**
2005
+ * Close `<spells>`: write the pairs into the open element's spells column, and their open bits
2006
+ * into the spells.open column when any spell has an open bound.
2007
+ */
1988
2008
  private finishSpells(): void {
1989
2009
  const pairs = this.spellPairs;
2010
+ const open = this.spellOpen;
1990
2011
  const where = this.spellsWhere ?? undefined;
1991
2012
  this.spellPairs = null;
2013
+ this.spellOpen = [];
1992
2014
  this.spellsWhere = null;
1993
2015
  if (pairs === null) {
1994
2016
  return;
1995
2017
  }
1996
2018
  try {
1997
- if (this.edge !== null) {
1998
- this.edgeColumns.spells.set(this.sink, this.report, this.edge.index, pairs, where);
1999
- } else {
2000
- const frame = this.nodeStack[this.nodeStack.length - 1];
2001
- if (frame.index !== INVALID_INDEX) {
2002
- this.nodeColumns.spells.set(this.sink, this.report, frame.index, pairs, where);
2019
+ const columns = this.edge === null ? this.nodeColumns : this.edgeColumns;
2020
+ const { index } = this.edge ?? this.nodeStack[this.nodeStack.length - 1];
2021
+ if (index !== INVALID_INDEX) {
2022
+ columns.spells.set(this.sink, this.report, index, pairs, where);
2023
+ if (open.some((bits) => bits !== 0)) {
2024
+ columns.spellsOpen.set(this.sink, this.report, index, open, where);
2003
2025
  }
2004
2026
  }
2005
2027
  } catch (err) {
@@ -128,7 +128,7 @@ export const GEXF_ISSUE = Object.freeze({
128
128
  VIZ_SKIPPED: VIZ_SKIPPED_CODE,
129
129
  /** A 1.2 dynamic viz element whose bounds were dropped. */
130
130
  VIZ_DYNAMIC_DROPPED: VIZ_DYNAMIC_CODE,
131
- /** A 1.2 startopen / endopen spell stored closed. */
131
+ /** Deprecated and no longer recorded: open spells are kept in the spells.open column. */
132
132
  SPELL_OPEN_DROPPED: SPELL_OPEN_CODE,
133
133
  /** A viz value that could not be read. */
134
134
  VIZ_VALUE: VIZ_VALUE_CODE,
@@ -56,6 +56,7 @@ export const NODE_COLUMNS = Object.freeze({
56
56
  end: "end",
57
57
  timestamp: "timestamp",
58
58
  spells: "spells",
59
+ spellsOpen: "spells.open",
59
60
  timestamps: "timestamps",
60
61
  open: "open",
61
62
  position: "position",
@@ -74,6 +75,7 @@ const EDGE_COLUMNS = Object.freeze({
74
75
  end: "end",
75
76
  timestamp: "timestamp",
76
77
  spells: "spells",
78
+ spellsOpen: "spells.open",
77
79
  timestamps: "timestamps",
78
80
  open: "open",
79
81
  color: "color",
@@ -138,6 +140,14 @@ export const NODE_DECLS: Readonly<Record<keyof typeof NODE_COLUMNS, ColumnDecl>>
138
140
  origin: XML_ORIGIN,
139
141
  },
140
142
  open: { name: NODE_COLUMNS.open, dtype: "u8", role: "open", nullable: true, origin: XML_ORIGIN },
143
+ spellsOpen: {
144
+ name: NODE_COLUMNS.spellsOpen,
145
+ dtype: "list",
146
+ itemDtype: "u8",
147
+ role: "spellsOpen",
148
+ nullable: true,
149
+ origin: XML_ORIGIN,
150
+ },
141
151
  position: {
142
152
  name: NODE_COLUMNS.position,
143
153
  dtype: "f32",
@@ -187,6 +197,14 @@ export const EDGE_DECLS: Readonly<Record<keyof typeof EDGE_COLUMNS, ColumnDecl>>
187
197
  origin: XML_ORIGIN,
188
198
  },
189
199
  open: { name: EDGE_COLUMNS.open, dtype: "u8", role: "open", nullable: true, origin: XML_ORIGIN },
200
+ spellsOpen: {
201
+ name: EDGE_COLUMNS.spellsOpen,
202
+ dtype: "list",
203
+ itemDtype: "u8",
204
+ role: "spellsOpen",
205
+ nullable: true,
206
+ origin: XML_ORIGIN,
207
+ },
190
208
  color: {
191
209
  name: EDGE_COLUMNS.color,
192
210
  dtype: "f32",
@@ -136,6 +136,7 @@ const SKIPPED_ROLES: ReadonlySet<string> = new Set([
136
136
  "timestamps",
137
137
  "spells",
138
138
  "open",
139
+ "spellsOpen",
139
140
  ]);
140
141
 
141
142
  const NODE_RESERVED: ReadonlySet<string> = new Set(["id"]);
@@ -14,9 +14,10 @@
14
14
  * a `graphics [ x y z ]` node record mapped to the `position` role (note 07 section 9) with the
15
15
  * remaining graphics keys kept as a json column.
16
16
  *
17
- * Node ids are the integer `id` keys (the GML spec type; a non-integer id is a validation error,
18
- * never a silent string) under `nodeIdFrom: "id"`, the `label` under `"label"`, and the node's
19
- * ordinal under `"index"`; `source` / `target` always resolve through the integer ids. A node's
17
+ * Node ids are the `id` keys under `nodeIdFrom: "id"`, the `label` under `"label"`, and the node's
18
+ * ordinal under `"index"`; `source` / `target` always resolve through the `id` keys. The GML spec
19
+ * types ids as integers; a string id (written by NetworkX and Gephi) is accepted under the `ids`
20
+ * rule with one warning per file, and a real or record id is a validation error. A node's
20
21
  * `graphty_originalId` key written by the exporter's `sanitizeIds: "mangle"` restores the
21
22
  * original id under `restoreMangledIds` (the default).
22
23
  */
@@ -96,8 +97,10 @@ export const MISSING_ID_CODE = SHARED_MISSING_ID_CODE;
96
97
  export const MISSING_LABEL_CODE = "E_GML_MISSING_LABEL";
97
98
  /** Issue code: an edge block has no `source` or no `target` key. */
98
99
  export const MISSING_ENDPOINT_CODE = SHARED_MISSING_ENDPOINT_CODE;
99
- /** Issue code: an `id`, `source` or `target` value is not an integer. */
100
+ /** Issue code: an `id`, `source` or `target` value is neither an integer nor a string. */
100
101
  export const ID_TYPE_CODE = "E_GML_ID_TYPE";
102
+ /** Issue code: string `id`, `source` or `target` values, outside the GML spec's integer ids; warned once per file. */
103
+ export const STRING_ID_CODE = "W_GML_STRING_ID";
101
104
  /** Issue code: a node id (or label under `nodeIdFrom: "label"`) is declared twice; later keys overwrite. */
102
105
  export const DUPLICATE_NODE_CODE = SHARED_DUPLICATE_NODE_CODE;
103
106
  /** Issue code: a structural key (`id`, `source`, `target`) appears twice in one block. */
@@ -301,7 +304,7 @@ class GmlImport {
301
304
  private elementsSinceCheck = 0;
302
305
 
303
306
  /** The sink id every file id maps to when they differ (label / index / restored ids), else null. */
304
- private idMap: Map<number, NodeId> | null = null;
307
+ private idMap: Map<NodeId, NodeId> | null = null;
305
308
 
306
309
  /** Node indices declared by a node block of this import, for duplicate detection. */
307
310
  private declared = new Uint32Array(64);
@@ -866,7 +869,9 @@ class GmlImport {
866
869
  if (idTok < 0) {
867
870
  throw new GraphFormatError("E_INVALID_ID", "node has no id", { reason: "missing id" });
868
871
  }
869
- const fileId = this.integerOf(idTok, "id");
872
+ // the file id under the ids rule, so `id 1` and `source "1"` meet in idMap (except under
873
+ // "keep", where the integer 1 and the string "1" are different ids by design)
874
+ const fileId = this.coerceFileId(idTok, this.fileIdOf(idTok, "id"));
870
875
  if (originalTok >= 0) {
871
876
  sinkId = this.restoredId(originalTok);
872
877
  } else {
@@ -883,7 +888,7 @@ class GmlImport {
883
888
  sinkId = ordinal;
884
889
  break;
885
890
  default:
886
- sinkId = this.coerceInteger(idTok, fileId);
891
+ sinkId = fileId;
887
892
  }
888
893
  }
889
894
  if (this.idMap !== null) {
@@ -1019,24 +1024,48 @@ class GmlImport {
1019
1024
  }
1020
1025
 
1021
1026
  /**
1022
- * The integer value of an id / source / target token (the GML grammar's integer ids; a string
1023
- * id is refused, as the malformed corpus pins, although NetworkX would accept it).
1027
+ * The value of an id / source / target token: an integer (the GML spec's type) as a number, a
1028
+ * string (NetworkX and Gephi write them) as its text with one warning per file.
1024
1029
  * @param v - the value token
1025
1030
  * @param key - the key name, for the error
1026
- * @returns the number
1031
+ * @returns the number or the string
1027
1032
  */
1028
- private integerOf(v: number, key: string): number {
1033
+ private fileIdOf(v: number, key: string): number | string {
1029
1034
  const t = this.requireTokens();
1035
+ if (t.kind[v] === TOKEN_STRING) {
1036
+ this.report.warnOnce(
1037
+ "validation-error",
1038
+ STRING_ID_CODE,
1039
+ `${key} is the string "${t.textOf(v)}"; GML ids are integers, string ids are kept under the ids rule`,
1040
+ { line: t.line[v], element: t.stringOf(v) },
1041
+ );
1042
+ return t.stringOf(v);
1043
+ }
1030
1044
  if (t.kind[v] !== TOKEN_INT) {
1031
- throw new GraphFormatError("E_INVALID_ID", `${key} must be an integer, found ${describeValue(t, v)}`, {
1032
- reason: "not an integer",
1033
- key,
1034
- value: t.kind[v] === TOKEN_OPEN ? "[...]" : t.textOf(v),
1035
- });
1045
+ throw new GraphFormatError(
1046
+ "E_INVALID_ID",
1047
+ `${key} must be an integer or a string, found ${describeValue(t, v)}`,
1048
+ {
1049
+ reason: "not an integer",
1050
+ key,
1051
+ value: t.kind[v] === TOKEN_OPEN ? "[...]" : t.textOf(v),
1052
+ },
1053
+ );
1036
1054
  }
1037
1055
  return Number(t.textOf(v));
1038
1056
  }
1039
1057
 
1058
+ /**
1059
+ * Coerce an id / source / target value under the `ids` rule: a string by its text, an integer
1060
+ * through coerceInteger().
1061
+ * @param v - the value token
1062
+ * @param id - its value (fileIdOf())
1063
+ * @returns the id
1064
+ */
1065
+ private coerceFileId(v: number, id: number | string): NodeId {
1066
+ return typeof id === "string" ? this.coercer.text(id) : this.coerceInteger(v, id);
1067
+ }
1068
+
1040
1069
  /**
1041
1070
  * Coerce an integer id token under the `ids` rule: a safe integer passes as a typed number
1042
1071
  * (so `01` is 1 and `-0` is 0), a larger one goes through the text rule (a string under
@@ -1099,14 +1128,8 @@ class GmlImport {
1099
1128
  * @returns the id
1100
1129
  */
1101
1130
  private endpoint(v: number, key: string): NodeId {
1102
- const n = this.integerOf(v, key);
1103
- if (this.idMap !== null) {
1104
- const mapped = this.idMap.get(n);
1105
- if (mapped !== undefined) {
1106
- return mapped;
1107
- }
1108
- }
1109
- return this.coerceInteger(v, n);
1131
+ const id = this.coerceFileId(v, this.fileIdOf(v, key));
1132
+ return this.idMap?.get(id) ?? id;
1110
1133
  }
1111
1134
 
1112
1135
  /**
@@ -45,6 +45,7 @@ import {
45
45
  REPEATED_KEY_CODE,
46
46
  ROLE_TAKEN_CODE,
47
47
  SECOND_GRAPH_CODE,
48
+ STRING_ID_CODE,
48
49
  UNKNOWN_ENTITY_CODE,
49
50
  } from "./importer.js";
50
51
 
@@ -77,8 +78,10 @@ export const GML_ISSUE = Object.freeze({
77
78
  MISSING_LABEL: MISSING_LABEL_CODE,
78
79
  /** An edge without `source` or `target`. */
79
80
  MISSING_ENDPOINT: MISSING_ENDPOINT_CODE,
80
- /** A node id, source or target that is not an integer. */
81
+ /** A node id, source or target that is neither an integer nor a string. */
81
82
  ID_TYPE: ID_TYPE_CODE,
83
+ /** String node ids, sources or targets (outside the spec's integers), kept under the ids rule; warned once. */
84
+ STRING_ID: STRING_ID_CODE,
82
85
  /** A node id declared twice (later keys overwrite). */
83
86
  DUPLICATE_NODE: DUPLICATE_NODE_CODE,
84
87
  /** A structural key repeated in one element. */
@@ -233,6 +233,8 @@ export const GRAPHML_LOSS = Object.freeze({
233
233
  ID_TEXT_TYPE: ID_TEXT_TYPE_CODE,
234
234
  /** A numeric edge id column reads back as string. */
235
235
  EDGE_ID_TEXT: "W_GRAPHML_EDGE_ID_TEXT",
236
+ /** A `yfiles.*` graphics column that no longer matches its yFiles tree: only the tree is written. */
237
+ YFILES_GRAPHICS_STALE: "W_GRAPHML_YFILES_GRAPHICS_STALE",
236
238
  /** A yfiles json value that is not a serialisable tree: export() will throw E_COLUMN_TYPE. */
237
239
  YFILES_TREE: "E_GRAPHML_YFILES_TREE",
238
240
  });
@@ -56,6 +56,7 @@ import {
56
56
  YFILES_NAMESPACE,
57
57
  } from "./constants.js";
58
58
  import { treeProblem, writeXmlTree } from "./tree.js";
59
+ import { isGraphicsColumn, staleGraphicsRows } from "./yfiles.js";
59
60
 
60
61
  /** The format-specific options of the GraphML exporter. */
61
62
  export interface GraphmlExportOptions {
@@ -110,6 +111,7 @@ const DROPPED_ROLES: ReadonlySet<string> = new Set([
110
111
  "timestamps",
111
112
  "spells",
112
113
  "open",
114
+ "spellsOpen",
113
115
  ]);
114
116
 
115
117
  /**
@@ -294,11 +296,16 @@ function graphmlMetaOf(snapshot: GraphSnapshot): GraphmlMeta | null {
294
296
  * @returns the plan
295
297
  */
296
298
  function planExport(snapshot: GraphSnapshot, options: ResolvedExportOptions, format: FormatOptions): Plan {
297
- // the generic json note is replaced by planTable()'s (yfiles trees are kept, other json is text)
299
+ // the generic json note is replaced by planTable()'s (yfiles trees are kept, other json is
300
+ // text); columns mapped from a yfiles tree are never written: planTable() notes the ones
301
+ // that no longer match their tree, the rest lose nothing
302
+ const graphics = new Set(
303
+ [...snapshot.nodes, ...snapshot.edges].filter((c) => isGraphicsColumn(c.meta)).map((c) => c.meta.name),
304
+ );
298
305
  const notes = checkCapabilities(snapshot, CAPABILITIES, options, {
299
306
  roles: SLOT_ROLES,
300
307
  roleNames: ROLE_NAMES,
301
- }).filter((n) => n.code !== LOSS.JSON);
308
+ }).filter((n) => n.code !== LOSS.JSON && (n.column === null || !graphics.has(n.column)));
302
309
  const note: NoteFn = (code, message, column = null, count = null): void => {
303
310
  notes.push(Object.freeze({ code, message, column, count }));
304
311
  };
@@ -649,6 +656,18 @@ function planTable(table: Iterable<Column>, domain: Domain, notes: LossNote[], n
649
656
  for (const column of columns) {
650
657
  const { meta } = column;
651
658
  const { role, name } = meta;
659
+ if (isGraphicsColumn(meta)) {
660
+ const stale = domain === "graph" ? 0 : staleGraphicsRows(column, columns, domain);
661
+ if (stale > 0) {
662
+ note(
663
+ GRAPHML_LOSS.YFILES_GRAPHICS_STALE,
664
+ `${stale} value(s) of ${domain} column "${name}" differ from the yFiles tree it was read from; only the tree is written, so they are lost`,
665
+ name,
666
+ stale,
667
+ );
668
+ }
669
+ continue;
670
+ }
652
671
  if (role === "parents") {
653
672
  note(
654
673
  GRAPHML_LOSS.PARENTS_DROPPED,