@graphty/graph-io 0.3.19 → 0.3.21

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 (201) hide show
  1. package/README.md +49 -4
  2. package/dist/chunks/{escape-CWExcecC.js → escape-B-tkk_Cl.js} +74 -16
  3. package/dist/chunks/escape-B-tkk_Cl.js.map +1 -0
  4. package/dist/chunks/{writer-DQiKgQJc.js → export-Bh60Dv-n.js} +5 -79
  5. package/dist/chunks/export-Bh60Dv-n.js.map +1 -0
  6. package/dist/chunks/{exporter-CbMZyVt-.js → exporter-BGrsWamJ.js} +7 -6
  7. package/dist/chunks/{exporter-CbMZyVt-.js.map → exporter-BGrsWamJ.js.map} +1 -1
  8. package/dist/chunks/exporter-DIeGJXAZ.js +834 -0
  9. package/dist/chunks/exporter-DIeGJXAZ.js.map +1 -0
  10. package/dist/chunks/{importer-BNFuV1-K.js → importer-BWY2FFc5.js} +28 -7
  11. package/dist/chunks/importer-BWY2FFc5.js.map +1 -0
  12. package/dist/chunks/importer-DD-xv9_Y.js +1471 -0
  13. package/dist/chunks/importer-DD-xv9_Y.js.map +1 -0
  14. package/dist/chunks/{importer-BAP4PrxR.js → importer-DET1tg6s.js} +30 -20
  15. package/dist/chunks/importer-DET1tg6s.js.map +1 -0
  16. package/dist/chunks/{importer-ByPGO-09.js → importer-DEcKhsmQ.js} +2 -2
  17. package/dist/chunks/{importer-ByPGO-09.js.map → importer-DEcKhsmQ.js.map} +1 -1
  18. package/dist/chunks/{importer-BW-Ft2ps.js → importer-DIrbbnAf.js} +6 -5
  19. package/dist/chunks/{importer-BW-Ft2ps.js.map → importer-DIrbbnAf.js.map} +1 -1
  20. package/dist/chunks/importer-D_e7e7LX.js +3055 -0
  21. package/dist/chunks/importer-D_e7e7LX.js.map +1 -0
  22. package/dist/chunks/{importer-Bm1_5vPS.js → importer-eUREhBGj.js} +11 -8
  23. package/dist/chunks/importer-eUREhBGj.js.map +1 -0
  24. package/dist/chunks/{importer-DOepkgnG.js → importer-vi3bSdtw.js} +4 -4
  25. package/dist/chunks/{importer-DOepkgnG.js.map → importer-vi3bSdtw.js.map} +1 -1
  26. package/dist/chunks/{importer-BVGtU1NA.js → importer-zGLo8Dg_.js} +6 -5
  27. package/dist/chunks/{importer-BVGtU1NA.js.map → importer-zGLo8Dg_.js.map} +1 -1
  28. package/dist/chunks/{json-elements-CZY1wiZh.js → json-elements-DDS8N2Dd.js} +2 -2
  29. package/dist/chunks/{json-elements-CZY1wiZh.js.map → json-elements-DDS8N2Dd.js.map} +1 -1
  30. package/dist/chunks/{records-BzNicMsf.js → records-dbRkxwaq.js} +2 -2
  31. package/dist/chunks/{records-BzNicMsf.js.map → records-dbRkxwaq.js.map} +1 -1
  32. package/dist/chunks/{report-BcWboivV.js → report-B1z4WT9e.js} +143 -111
  33. package/dist/chunks/{report-BcWboivV.js.map → report-B1z4WT9e.js.map} +1 -1
  34. package/dist/chunks/{weights-CwISIpCP.js → weights-Dzba96G3.js} +5 -5
  35. package/dist/chunks/{weights-CwISIpCP.js.map → weights-Dzba96G3.js.map} +1 -1
  36. package/dist/chunks/writer-C7flM-Ih.js +77 -0
  37. package/dist/chunks/writer-C7flM-Ih.js.map +1 -0
  38. package/dist/csv.js +6 -5
  39. package/dist/csv.js.map +1 -1
  40. package/dist/cx.js +1 -1
  41. package/dist/cx2.js +2 -2
  42. package/dist/cys.d.ts +1 -0
  43. package/dist/cys.js +6 -0
  44. package/dist/cys.js.map +1 -0
  45. package/dist/dot.js +1 -1
  46. package/dist/gexf.js +5 -4
  47. package/dist/gexf.js.map +1 -1
  48. package/dist/gml.js +12 -14
  49. package/dist/gml.js.map +1 -1
  50. package/dist/graph-io.js +288 -141
  51. package/dist/graph-io.js.map +1 -1
  52. package/dist/graphml.js +1 -1
  53. package/dist/json.js +1 -1
  54. package/dist/neo4j.js +6 -5
  55. package/dist/neo4j.js.map +1 -1
  56. package/dist/obo.js +1 -1
  57. package/dist/pajek.js +1 -1
  58. package/dist/src/common/cell-budget.d.ts +84 -0
  59. package/dist/src/common/cell-budget.d.ts.map +1 -0
  60. package/dist/src/common/cell-budget.js +138 -0
  61. package/dist/src/common/cell-budget.js.map +1 -0
  62. package/dist/src/common/input.d.ts +10 -0
  63. package/dist/src/common/input.d.ts.map +1 -1
  64. package/dist/src/common/input.js +39 -0
  65. package/dist/src/common/input.js.map +1 -1
  66. package/dist/src/common/options.d.ts +6 -0
  67. package/dist/src/common/options.d.ts.map +1 -1
  68. package/dist/src/common/options.js +1 -1
  69. package/dist/src/common/options.js.map +1 -1
  70. package/dist/src/common/xml.d.ts +37 -3
  71. package/dist/src/common/xml.d.ts.map +1 -1
  72. package/dist/src/common/xml.js +86 -6
  73. package/dist/src/common/xml.js.map +1 -1
  74. package/dist/src/common/zip.d.ts +92 -0
  75. package/dist/src/common/zip.d.ts.map +1 -0
  76. package/dist/src/common/zip.js +399 -0
  77. package/dist/src/common/zip.js.map +1 -0
  78. package/dist/src/formats/cx/importer.d.ts.map +1 -1
  79. package/dist/src/formats/cx/importer.js +26 -17
  80. package/dist/src/formats/cx/importer.js.map +1 -1
  81. package/dist/src/formats/cys/constants.d.ts +68 -0
  82. package/dist/src/formats/cys/constants.d.ts.map +1 -0
  83. package/dist/src/formats/cys/constants.js +76 -0
  84. package/dist/src/formats/cys/constants.js.map +1 -0
  85. package/dist/src/formats/cys/importer.d.ts +38 -0
  86. package/dist/src/formats/cys/importer.d.ts.map +1 -0
  87. package/dist/src/formats/cys/importer.js +830 -0
  88. package/dist/src/formats/cys/importer.js.map +1 -0
  89. package/dist/src/formats/cys/index.d.ts +7 -0
  90. package/dist/src/formats/cys/index.d.ts.map +1 -0
  91. package/dist/src/formats/cys/index.js +7 -0
  92. package/dist/src/formats/cys/index.js.map +1 -0
  93. package/dist/src/formats/cys/session.d.ts +132 -0
  94. package/dist/src/formats/cys/session.d.ts.map +1 -0
  95. package/dist/src/formats/cys/session.js +315 -0
  96. package/dist/src/formats/cys/session.js.map +1 -0
  97. package/dist/src/formats/cys/tables.d.ts +90 -0
  98. package/dist/src/formats/cys/tables.d.ts.map +1 -0
  99. package/dist/src/formats/cys/tables.js +293 -0
  100. package/dist/src/formats/cys/tables.js.map +1 -0
  101. package/dist/src/formats/dot/exporter.d.ts +2 -0
  102. package/dist/src/formats/dot/exporter.d.ts.map +1 -1
  103. package/dist/src/formats/dot/exporter.js +12 -0
  104. package/dist/src/formats/dot/exporter.js.map +1 -1
  105. package/dist/src/formats/dot/importer.js +6 -2
  106. package/dist/src/formats/dot/importer.js.map +1 -1
  107. package/dist/src/formats/gexf/importer.d.ts +2 -2
  108. package/dist/src/formats/gexf/importer.d.ts.map +1 -1
  109. package/dist/src/formats/gexf/importer.js +1 -1
  110. package/dist/src/formats/gexf/importer.js.map +1 -1
  111. package/dist/src/formats/gml/exporter.d.ts +0 -8
  112. package/dist/src/formats/gml/exporter.d.ts.map +1 -1
  113. package/dist/src/formats/gml/exporter.js +7 -17
  114. package/dist/src/formats/gml/exporter.js.map +1 -1
  115. package/dist/src/formats/json/exporter.d.ts.map +1 -1
  116. package/dist/src/formats/json/exporter.js +3 -1
  117. package/dist/src/formats/json/exporter.js.map +1 -1
  118. package/dist/src/formats/json/importer.d.ts.map +1 -1
  119. package/dist/src/formats/json/importer.js +3 -2
  120. package/dist/src/formats/json/importer.js.map +1 -1
  121. package/dist/src/formats/xgmml/columns.d.ts +152 -0
  122. package/dist/src/formats/xgmml/columns.d.ts.map +1 -0
  123. package/dist/src/formats/xgmml/columns.js +593 -0
  124. package/dist/src/formats/xgmml/columns.js.map +1 -0
  125. package/dist/src/formats/xgmml/constants.d.ts +196 -0
  126. package/dist/src/formats/xgmml/constants.d.ts.map +1 -0
  127. package/dist/src/formats/xgmml/constants.js +197 -0
  128. package/dist/src/formats/xgmml/constants.js.map +1 -0
  129. package/dist/src/formats/xgmml/document.d.ts +361 -0
  130. package/dist/src/formats/xgmml/document.d.ts.map +1 -0
  131. package/dist/src/formats/xgmml/document.js +695 -0
  132. package/dist/src/formats/xgmml/document.js.map +1 -0
  133. package/dist/src/formats/xgmml/emit.d.ts +341 -0
  134. package/dist/src/formats/xgmml/emit.d.ts.map +1 -0
  135. package/dist/src/formats/xgmml/emit.js +1275 -0
  136. package/dist/src/formats/xgmml/emit.js.map +1 -0
  137. package/dist/src/formats/xgmml/exporter.d.ts +24 -0
  138. package/dist/src/formats/xgmml/exporter.d.ts.map +1 -0
  139. package/dist/src/formats/xgmml/exporter.js +999 -0
  140. package/dist/src/formats/xgmml/exporter.js.map +1 -0
  141. package/dist/src/formats/xgmml/importer.d.ts +58 -0
  142. package/dist/src/formats/xgmml/importer.d.ts.map +1 -0
  143. package/dist/src/formats/xgmml/importer.js +360 -0
  144. package/dist/src/formats/xgmml/importer.js.map +1 -0
  145. package/dist/src/formats/xgmml/index.d.ts +8 -0
  146. package/dist/src/formats/xgmml/index.d.ts.map +1 -0
  147. package/dist/src/formats/xgmml/index.js +8 -0
  148. package/dist/src/formats/xgmml/index.js.map +1 -0
  149. package/dist/src/formats/xgmml/values.d.ts +60 -0
  150. package/dist/src/formats/xgmml/values.d.ts.map +1 -0
  151. package/dist/src/formats/xgmml/values.js +148 -0
  152. package/dist/src/formats/xgmml/values.js.map +1 -0
  153. package/dist/src/index.d.ts +2 -0
  154. package/dist/src/index.d.ts.map +1 -1
  155. package/dist/src/index.js +2 -0
  156. package/dist/src/index.js.map +1 -1
  157. package/dist/src/registry.d.ts +7 -0
  158. package/dist/src/registry.d.ts.map +1 -1
  159. package/dist/src/registry.js +25 -10
  160. package/dist/src/registry.js.map +1 -1
  161. package/dist/src/sniff.d.ts +1 -1
  162. package/dist/src/sniff.d.ts.map +1 -1
  163. package/dist/src/sniff.js +2 -0
  164. package/dist/src/sniff.js.map +1 -1
  165. package/dist/xgmml.d.ts +1 -0
  166. package/dist/xgmml.js +9 -0
  167. package/dist/xgmml.js.map +1 -0
  168. package/package.json +15 -2
  169. package/src/common/cell-budget.ts +170 -0
  170. package/src/common/input.ts +40 -0
  171. package/src/common/options.ts +1 -1
  172. package/src/common/xml.ts +127 -6
  173. package/src/common/zip.ts +472 -0
  174. package/src/formats/cx/importer.ts +27 -19
  175. package/src/formats/cys/constants.ts +99 -0
  176. package/src/formats/cys/importer.ts +1100 -0
  177. package/src/formats/cys/index.ts +7 -0
  178. package/src/formats/cys/session.ts +428 -0
  179. package/src/formats/cys/tables.ts +400 -0
  180. package/src/formats/dot/exporter.ts +17 -0
  181. package/src/formats/dot/importer.ts +6 -2
  182. package/src/formats/gexf/importer.ts +0 -1
  183. package/src/formats/gml/exporter.ts +7 -18
  184. package/src/formats/json/exporter.ts +3 -1
  185. package/src/formats/json/importer.ts +3 -2
  186. package/src/formats/xgmml/columns.ts +747 -0
  187. package/src/formats/xgmml/constants.ts +265 -0
  188. package/src/formats/xgmml/document.ts +975 -0
  189. package/src/formats/xgmml/emit.ts +1563 -0
  190. package/src/formats/xgmml/exporter.ts +1215 -0
  191. package/src/formats/xgmml/importer.ts +491 -0
  192. package/src/formats/xgmml/index.ts +8 -0
  193. package/src/formats/xgmml/values.ts +183 -0
  194. package/src/index.ts +9 -0
  195. package/src/registry.ts +41 -15
  196. package/src/sniff.ts +5 -1
  197. package/dist/chunks/escape-CWExcecC.js.map +0 -1
  198. package/dist/chunks/importer-BAP4PrxR.js.map +0 -1
  199. package/dist/chunks/importer-BNFuV1-K.js.map +0 -1
  200. package/dist/chunks/importer-Bm1_5vPS.js.map +0 -1
  201. package/dist/chunks/writer-DQiKgQJc.js.map +0 -1
@@ -0,0 +1,1100 @@
1
+ /**
2
+ * The Cytoscape session importer (design `design/graph-io/cytoscape-and-obo/design.md` sections
3
+ * 1.4 and 3; `research-session-and-style.md`). A session is a zip of every network of a Cytoscape
4
+ * desktop; the unit of import is the registered subnetwork (3.x) or the network (2.x), one
5
+ * snapshot each: `import()` reads the one `graphIndex` / `graphName` picks (the first by default),
6
+ * `importAll()` every one, and `listGraphs()` lists them from the network files alone.
7
+ *
8
+ * 3.x: the network file gives the topology (shared nodes through `xlink:href`, per-edge direction,
9
+ * groups, nested-network pointers); the subnetwork's CyCSV tables give its columns (LOCAL_ATTRS as
10
+ * they are, the SHARED_ATTRS columns `cytables.xml` joins in, HIDDEN and app tables as hidden
11
+ * columns under their namespace); its first view gives positions (y flipped) and the `graphics`
12
+ * column, further views `position@<n>` columns. 2.x: one full XGMML file per network, with
13
+ * selection and hidden state from `cysession.xml`. Everything is resolved by the XGMML emitter, so
14
+ * both readers share every column rule. Styles are not read (issue #706): W_STYLES_NOT_IMPORTED.
15
+ */
16
+
17
+ import { GraphFormatError, type GraphSink, INVALID_INDEX } from "@graphty/graph-format";
18
+
19
+ import { declareResolved } from "../../common/attributes.js";
20
+ import { IdCoercer } from "../../common/ids.js";
21
+ import { decodeEntryName, readBytes, throwIfAborted } from "../../common/input.js";
22
+ import {
23
+ chooseGraph,
24
+ type ImportFormatDefaults,
25
+ reportSinkOptions,
26
+ reportUnusedOptions,
27
+ type ResolvedImportOptions,
28
+ resolveImportOptions,
29
+ } from "../../common/options.js";
30
+ import { ImportReportBuilder } from "../../common/report.js";
31
+ import { startsLikeZip } from "../../common/zip.js";
32
+ import {
33
+ type CommonImportOptions,
34
+ type GraphChoiceOptions,
35
+ type GraphImporter,
36
+ type GraphListing,
37
+ ImportError,
38
+ type ImportInput,
39
+ type ImportReport,
40
+ } from "../../types.js";
41
+ import { XGMML_ISSUE, XGMML_ORIGIN_NAMESPACE } from "../xgmml/constants.js";
42
+ import {
43
+ type AttRec,
44
+ type EdgeRec,
45
+ type GraphRec,
46
+ isCyTrue,
47
+ type NodeRec,
48
+ type XgmmlDocument,
49
+ } from "../xgmml/document.js";
50
+ import {
51
+ type Dialect,
52
+ dialectOf,
53
+ type EmitExtras,
54
+ membersOf,
55
+ XgmmlEmitter,
56
+ type XgmmlSettings,
57
+ } from "../xgmml/emit.js";
58
+ import { parseXgmml, resolveSettings } from "../xgmml/importer.js";
59
+ import {
60
+ CYS_ISSUE,
61
+ CYTOSCAPE_NAMESPACE,
62
+ DEFAULT_MAX_UNCOMPRESSED,
63
+ EXTENSIONS,
64
+ FORMAT,
65
+ HIDDEN_COLUMN,
66
+ META_KEY,
67
+ MIME_TYPES,
68
+ SELECTED_COLUMN,
69
+ VIEW_POSITION_PREFIX,
70
+ } from "./constants.js";
71
+ import {
72
+ elementsNamed,
73
+ listed,
74
+ type NetworkEntry,
75
+ openSession,
76
+ readXmlTree,
77
+ type Session,
78
+ type SessionEntry,
79
+ type TableEntry,
80
+ urlDecode,
81
+ type XmlNode,
82
+ } from "./session.js";
83
+ import { cellAtt, type CyTable, readCyTable, type VirtualColumn, virtualColumnsOf } from "./tables.js";
84
+
85
+ /** The format-specific options of the session importer. */
86
+ export interface CysImportOptions extends GraphChoiceOptions {
87
+ /** Where Cytoscape's z (a stacking order) goes: the `z` column (default) or the position. */
88
+ zAs?: "column" | "position" | undefined;
89
+ /**
90
+ * The most bytes one import may inflate, in total (default 2 GiB); an entry beyond it, or one
91
+ * whose compression ratio is above 1000:1, is E_TOO_LARGE.
92
+ */
93
+ maxUncompressedBytes?: number | undefined;
94
+ }
95
+
96
+ /** The per-format defaults: SUIDs as written, edges directed unless they say otherwise, no weight. */
97
+ const FORMAT_DEFAULTS: ImportFormatDefaults = {
98
+ ids: "keep",
99
+ defaultDirected: true,
100
+ weightFrom: null,
101
+ addMissingNodes: false,
102
+ };
103
+
104
+ /** The common options the session importer reads. */
105
+ const USED_OPTIONS: ReadonlySet<keyof CommonImportOptions> = new Set<keyof CommonImportOptions>([
106
+ "ids",
107
+ "addMissingNodes",
108
+ "duplicateEdges",
109
+ "selfLoops",
110
+ "onMixedDirection",
111
+ "defaultDirected",
112
+ "weightFrom",
113
+ "weightDtype",
114
+ "long",
115
+ "errorLimit",
116
+ "signal",
117
+ "onProgress",
118
+ ]);
119
+
120
+ /** One network the session holds: a registered subnetwork of a 3.x network file, or a 2.x network. */
121
+ type Choice = Choice3 | Choice2;
122
+
123
+ /** A registered subnetwork of a 3.x network file. */
124
+ interface Choice3 {
125
+ readonly era: "3";
126
+ readonly network: NetworkEntry;
127
+ readonly graphId: string;
128
+ readonly name: string | null;
129
+ readonly nodes: number;
130
+ readonly edges: number;
131
+ }
132
+
133
+ /** A 2.x network and its `cysession.xml` record. */
134
+ interface Choice2 {
135
+ readonly era: "2";
136
+ readonly file: SessionEntry;
137
+ readonly name: string;
138
+ readonly record: XmlNode;
139
+ readonly parent: string | null;
140
+ }
141
+
142
+ /** What every import call sets up. */
143
+ interface Prepared {
144
+ readonly report: ImportReportBuilder;
145
+ readonly common: ResolvedImportOptions;
146
+ /** The options the entries inside are read with: no progress, no encoding override. */
147
+ readonly inner: ResolvedImportOptions;
148
+ readonly zAs: "column" | "position";
149
+ readonly session: Session;
150
+ readonly choices: readonly Choice[];
151
+ /** The network file documents parsed for the listing, by entry name (3.x). */
152
+ readonly docs: Map<string, Parsed>;
153
+ }
154
+
155
+ /** A parsed XGMML entry. */
156
+ interface Parsed {
157
+ readonly doc: XgmmlDocument;
158
+ readonly dialect: Dialect;
159
+ }
160
+
161
+ /**
162
+ * The maxUncompressedBytes option, checked.
163
+ * @param value - the caller's value
164
+ * @returns the byte budget; E_UNSUPPORTED for anything but a positive number
165
+ */
166
+ function budgetOption(value: unknown): number {
167
+ if (value === undefined) {
168
+ return DEFAULT_MAX_UNCOMPRESSED;
169
+ }
170
+ if (typeof value !== "number" || !(value > 0)) {
171
+ throw new GraphFormatError(
172
+ "E_UNSUPPORTED",
173
+ `option maxUncompressedBytes: ${JSON.stringify(value)} is not a positive number`,
174
+ {
175
+ option: "maxUncompressedBytes",
176
+ found: value,
177
+ },
178
+ );
179
+ }
180
+ return value;
181
+ }
182
+
183
+ /**
184
+ * Resolve the options, open the archive and list its networks.
185
+ * @param input - the input
186
+ * @param sink - the sink, or null for listGraphs
187
+ * @param options - the options
188
+ * @returns what the import needs
189
+ */
190
+ async function prepare(
191
+ input: ImportInput,
192
+ sink: GraphSink | null,
193
+ options: (CysImportOptions & CommonImportOptions) | undefined,
194
+ ): Promise<Prepared> {
195
+ const common = resolveImportOptions(options, FORMAT_DEFAULTS);
196
+ const report = new ImportReportBuilder(FORMAT, common.errorLimit);
197
+ if (sink !== null) {
198
+ reportSinkOptions(sink, options, report, true);
199
+ reportUnusedOptions(options, report, USED_OPTIONS);
200
+ }
201
+ const { zAs } = resolveSettings({ zAs: options?.zAs }, false);
202
+ const maxBytes = budgetOption(options?.maxUncompressedBytes);
203
+ const session = await openSession(input, report, common, maxBytes);
204
+ const inner: ResolvedImportOptions = Object.freeze({ ...common, onProgress: null, encoding: null });
205
+ const docs = new Map<string, Parsed>();
206
+ const choices =
207
+ session.layout.era === "3"
208
+ ? await networks3(session, report, inner, docs)
209
+ : await networks2(session, report, inner);
210
+ return { report, common, inner, zAs, session, choices, docs };
211
+ }
212
+
213
+ /**
214
+ * Read an XGMML entry. Its issues are relayed into the import's report with the entry name in
215
+ * the message; a fatal one fails the import.
216
+ * @param session - the session
217
+ * @param entry - the entry
218
+ * @param report - the import's report
219
+ * @param inner - the options entries are read with
220
+ * @returns the document and its dialect
221
+ */
222
+ async function parseEntry(
223
+ session: Session,
224
+ entry: SessionEntry,
225
+ report: ImportReportBuilder,
226
+ inner: ResolvedImportOptions,
227
+ ): Promise<Parsed> {
228
+ const bytes = await session.read(entry);
229
+ const scratch = new ImportReportBuilder(FORMAT, Number.MAX_SAFE_INTEGER);
230
+ try {
231
+ const doc = await parseXgmml(bytes, scratch, inner, undefined);
232
+ const dialect = dialectOf(doc, scratch);
233
+ relay(scratch.issues, report, entry.name);
234
+ return { doc, dialect };
235
+ } catch (err) {
236
+ if (!(err instanceof ImportError)) {
237
+ throw err;
238
+ }
239
+ const { issues } = scratch;
240
+ const fatal = issues.at(-1);
241
+ relay(issues.slice(0, -1), report, entry.name);
242
+ return report.fail(fatal?.code ?? CYS_ISSUE.CORRUPT, `${entry.name}: ${fatal?.message ?? err.message}`, {
243
+ line: fatal?.line ?? null,
244
+ });
245
+ }
246
+ }
247
+
248
+ /**
249
+ * Record issues of an entry in the import's report, the entry name before each message.
250
+ * @param issues - the issues
251
+ * @param report - the import's report
252
+ * @param entry - the entry name
253
+ */
254
+ function relay(issues: ImportReport["issues"], report: ImportReportBuilder, entry: string): void {
255
+ for (const issue of issues) {
256
+ const where = { line: issue.line, element: issue.element };
257
+ if (issue.severity === "error") {
258
+ report.error(issue.category, issue.code, `${entry}: ${issue.message}`, where);
259
+ } else {
260
+ report.warning(issue.category, issue.code, `${entry}: ${issue.message}`, where);
261
+ }
262
+ }
263
+ }
264
+
265
+ // ---------------------------------------------------------------------------------------- 3.x
266
+
267
+ /**
268
+ * The registered subnetworks of a 3.x session, in `network_list.xml` order (else entry order).
269
+ * @param session - the session
270
+ * @param report - the report
271
+ * @param inner - the options entries are read with
272
+ * @param docs - where the parsed network files are kept
273
+ * @returns the networks
274
+ */
275
+ async function networks3(
276
+ session: Session,
277
+ report: ImportReportBuilder,
278
+ inner: ResolvedImportOptions,
279
+ docs: Map<string, Parsed>,
280
+ ): Promise<Choice3[]> {
281
+ const { layout } = session;
282
+ const choices: Choice3[] = [];
283
+ for (const network of layout.networks) {
284
+ const parsed = await parseEntry(session, network, report, inner);
285
+ docs.set(network.name, parsed);
286
+ for (const graph of registeredGraphs(parsed)) {
287
+ const members = membersOf(parsed.doc, graph);
288
+ choices.push({
289
+ era: "3",
290
+ network,
291
+ graphId: graph.id ?? "",
292
+ name: graph.label ?? graph.id,
293
+ nodes: new Set(members.nodes).size,
294
+ edges: members.edges.length,
295
+ });
296
+ }
297
+ }
298
+ const known = new Set(choices.map((c) => c.graphId));
299
+ const strayViews = layout.views.filter((v) => !known.has(v.network)).map((v) => v.name);
300
+ const strayTables = layout.tables.filter(
301
+ (t) =>
302
+ !known.has(t.network) &&
303
+ !layout.networks.some((n) => n.suid === t.network) &&
304
+ !isGroupTable(t, session, docs),
305
+ );
306
+ if (strayViews.length > 0 || strayTables.length > 0) {
307
+ report.warning(
308
+ "validation-error",
309
+ CYS_ISSUE.DANGLING_REFERENCE,
310
+ `${strayViews.length + strayTables.length} view(s) or table(s) belong to a network the session does not hold and were not read: ${listed([...strayViews, ...strayTables.map((t) => t.name)])}`,
311
+ );
312
+ }
313
+ if (layout.networkList === null) {
314
+ return choices;
315
+ }
316
+ const tree = await readXmlTree(await session.read(layout.networkList), layout.networkList.name, report, inner);
317
+ const order = new Map<string, number>();
318
+ for (const node of elementsNamed(tree, "network")) {
319
+ const id = node.attrs.get("id");
320
+ const position = Number(node.attrs.get("order"));
321
+ if (id !== undefined && Number.isFinite(position)) {
322
+ order.set(id, position);
323
+ }
324
+ }
325
+ return choices
326
+ .map((choice, index) => ({ choice, index }))
327
+ .sort(
328
+ (a, b) =>
329
+ (order.get(a.choice.graphId) ?? Infinity) - (order.get(b.choice.graphId) ?? Infinity) ||
330
+ a.index - b.index,
331
+ )
332
+ .map((c) => c.choice);
333
+ }
334
+
335
+ /**
336
+ * Whether a table belongs to a graph declared in some network file (a group's network): those are
337
+ * read with their network, not reported.
338
+ * @param table - the table
339
+ * @param session - the session
340
+ * @param docs - the parsed network files
341
+ * @returns true for a table of a known graph
342
+ */
343
+ function isGroupTable(table: TableEntry, session: Session, docs: ReadonlyMap<string, Parsed>): boolean {
344
+ for (const network of session.layout.networks) {
345
+ if (docs.get(network.name)?.doc.graphs.some((g) => g.id === table.network) === true) {
346
+ return true;
347
+ }
348
+ }
349
+ return false;
350
+ }
351
+
352
+ /**
353
+ * The registered subnetworks of a network file (`cy:registered` true under the root).
354
+ * @param parsed - the network file
355
+ * @returns the graphs
356
+ */
357
+ function registeredGraphs(parsed: Parsed): GraphRec[] {
358
+ return parsed.doc.root.subgraphs.filter((g) => isCyTrue(g.registered));
359
+ }
360
+
361
+ /**
362
+ * Import one registered subnetwork of a 3.x session.
363
+ * @param prepared - the prepared import
364
+ * @param choice - the subnetwork
365
+ * @param sink - the sink
366
+ * @param parsed - its network file, parsed for this import (its records are filled in)
367
+ */
368
+ async function import3(prepared: Prepared, choice: Choice3, sink: GraphSink, parsed: Parsed): Promise<void> {
369
+ const { report, inner, session } = prepared;
370
+ const { layout } = session;
371
+ const { doc, dialect } = parsed;
372
+ const graph = doc.root.subgraphs.find((g) => g.id === choice.graphId) as GraphRec;
373
+ const members = membersOf(doc, graph);
374
+ const nodes = byId(members.nodes);
375
+ const edges = byId(members.edges);
376
+ const cache = new Map<string, Promise<CyTable | null>>();
377
+ const tableOf = (path: string): Promise<CyTable | null> => {
378
+ let pending = cache.get(path);
379
+ if (pending === undefined) {
380
+ const entry = layout.tables.find((t) => t.tablePath === path);
381
+ pending =
382
+ entry === undefined
383
+ ? Promise.resolve(null)
384
+ : session.read(entry).then((bytes) => readCyTable(bytes, path, entry.name, report, inner));
385
+ cache.set(path, pending);
386
+ }
387
+ return pending;
388
+ };
389
+ const virtuals = layout.cytables === null ? [] : await readVirtuals(session, layout.cytables, report, inner);
390
+ // a row of an element the file declares outside this network (a collapsed group's member, a
391
+ // meta-edge) is Cytoscape's bookkeeping, not a stale row
392
+ const declared = new Set<string>([
393
+ ...doc.nodes.flatMap((n) => (n.id === null ? [] : [n.id])),
394
+ ...doc.edges.flatMap((e) => (e.id === null ? [] : [e.id])),
395
+ ]);
396
+ let unmatched = 0;
397
+ for (const entry of layout.tables.filter((t) => t.network === choice.graphId && t.element !== null)) {
398
+ throwIfAborted(inner.signal);
399
+ const table = await tableOf(entry.tablePath);
400
+ if (table === null) {
401
+ continue;
402
+ }
403
+ const local = entry.namespace === "LOCAL_ATTRS";
404
+ const shared = entry.namespace === "SHARED_ATTRS";
405
+ const namespace = local ? null : entry.namespace;
406
+ const hidden = !local && !shared;
407
+ const target = (key: string): AttRec[] | undefined => {
408
+ if (entry.element === "network") {
409
+ return key === choice.graphId ? graph.atts : undefined;
410
+ }
411
+ return (entry.element === "node" ? nodes : edges).get(key)?.atts;
412
+ };
413
+ for (const [key, cells] of table.rows) {
414
+ const atts = target(key);
415
+ if (atts === undefined) {
416
+ if (!declared.has(key)) {
417
+ unmatched++;
418
+ }
419
+ continue;
420
+ }
421
+ const line = table.lines.get(key) ?? 0;
422
+ for (let i = 1; i < table.columns.length && i < cells.length; i++) {
423
+ const att = cellAtt(table.columns[i], cells[i], line, namespace, hidden);
424
+ if (att !== null) {
425
+ atts.push(att);
426
+ }
427
+ }
428
+ }
429
+ for (const virtual of await virtualColumnsOf(table, virtuals, tableOf, report)) {
430
+ // a shared column named like a local one is renamed; the local one keeps the name
431
+ const owned = table.columns.some((c) => c.name === virtual.column.name);
432
+ let ns = namespace;
433
+ if (local) {
434
+ ns = owned ? "SHARED_ATTRS" : null;
435
+ }
436
+ for (const [key, text] of virtual.values) {
437
+ const att = cellAtt(virtual.column, text, table.lines.get(key) ?? 0, ns, hidden);
438
+ if (att !== null) {
439
+ target(key)?.push(att);
440
+ }
441
+ }
442
+ }
443
+ }
444
+ if (unmatched > 0) {
445
+ report.warning(
446
+ "parse-error",
447
+ CYS_ISSUE.TABLE_ROW,
448
+ `${unmatched} table row(s) of network ${choice.graphId} name no node, edge or network of it (stale rows); they are not read`,
449
+ );
450
+ }
451
+ const views = layout.views.filter((v) => v.network === choice.graphId);
452
+ let visualStyle: string | null = null;
453
+ const extraViews: Map<string, [number, number, number]>[] = [];
454
+ for (const [i, view] of views.entries()) {
455
+ const viewDoc = (await parseEntry(session, view, report, inner)).doc;
456
+ if (i === 0) {
457
+ visualStyle = viewDoc.root.attrs.get("cy:visualStyle") ?? null;
458
+ applyView(viewDoc, graph, nodes, edges, report, view.name);
459
+ } else {
460
+ extraViews.push(viewPositions(viewDoc, prepared.zAs));
461
+ }
462
+ }
463
+ const cyMeta = sessionMeta(prepared, {
464
+ collection: doc.root.label ?? choice.network.title,
465
+ network: choice.graphId,
466
+ parentNetwork: parentNetwork(graph, prepared.choices),
467
+ visualStyle,
468
+ });
469
+ const groups: { group: string; members: string[] }[] = [];
470
+ cyMeta.groups = groups;
471
+ const extras: EmitExtras = {
472
+ sourceFormat: FORMAT,
473
+ entry: choice.network.name,
474
+ graphName: localName(graph) ?? choice.name,
475
+ metaExtra: { [META_KEY]: cyMeta },
476
+ groupNodes: new Set(
477
+ members.nodes
478
+ .filter((n) => n.atts.some((a) => a.name === "__isGroup" && isCyTrue(a.value)))
479
+ .flatMap((n) => (n.id === null ? [] : [n.id])),
480
+ ),
481
+ resolvePointer: pointerResolver(prepared, doc),
482
+ onMissingMember: (group, member): void => {
483
+ const record = groups.find((g) => g.group === group);
484
+ if (record === undefined) {
485
+ groups.push({ group, members: [member] });
486
+ } else if (!record.members.includes(member)) {
487
+ record.members.push(member);
488
+ }
489
+ },
490
+ };
491
+ const settings: XgmmlSettings = { labelAliases: false, cytoscapeEscapes: false, zAs: prepared.zAs };
492
+ new XgmmlEmitter(doc, dialect, sink, report, prepared.inner, settings, extras).emit(graph);
493
+ if (groups.length > 0) {
494
+ report.warning(
495
+ "unsupported",
496
+ CYS_ISSUE.COLLAPSED_GROUP,
497
+ `${groups.length} collapsed group(s) hold ${groups.reduce((n, g) => n + g.members.length, 0)} member(s) that are not in the network; they are listed in meta.extra.cytoscape.groups`,
498
+ );
499
+ }
500
+ reportRootOnly(parsed, report, choice.network.name);
501
+ writeViewPositions(
502
+ sink,
503
+ extraViews,
504
+ views.slice(1).map((v) => v.view),
505
+ report,
506
+ prepared.inner,
507
+ );
508
+ }
509
+
510
+ /**
511
+ * The virtual columns of `tables/cytables.xml`.
512
+ * @param session - the session
513
+ * @param entry - the cytables.xml entry
514
+ * @param report - the report
515
+ * @param inner - the options entries are read with
516
+ * @returns the virtual columns in document order
517
+ */
518
+ async function readVirtuals(
519
+ session: Session,
520
+ entry: SessionEntry,
521
+ report: ImportReportBuilder,
522
+ inner: ResolvedImportOptions,
523
+ ): Promise<VirtualColumn[]> {
524
+ const tree = await readXmlTree(await session.read(entry), entry.name, report, inner);
525
+ const out: VirtualColumn[] = [];
526
+ for (const node of elementsNamed(tree, "virtualColumn")) {
527
+ const a = (key: string): string => node.attrs.get(key) ?? "";
528
+ out.push({
529
+ name: a("name"),
530
+ targetTable: a("targetTable"),
531
+ sourceTable: a("sourceTable"),
532
+ sourceColumn: a("sourceColumn"),
533
+ sourceJoinKey: node.attrs.get("sourceJoinKey") ?? "SUID",
534
+ targetJoinKey: node.attrs.get("targetJoinKey") ?? "SUID",
535
+ });
536
+ }
537
+ return out;
538
+ }
539
+
540
+ /**
541
+ * Records by id.
542
+ * @param records - node or edge records
543
+ * @returns the first record of each id
544
+ */
545
+ function byId<T extends NodeRec | EdgeRec>(records: readonly T[]): Map<string, T> {
546
+ const out = new Map<string, T>();
547
+ for (const record of records) {
548
+ if (record.id !== null && !out.has(record.id)) {
549
+ out.set(record.id, record);
550
+ }
551
+ }
552
+ return out;
553
+ }
554
+
555
+ /**
556
+ * Copy a view's coordinates and graphics onto the network's records (by `cy:nodeId` and
557
+ * `cy:edgeId`), and its network graphics onto the graph. A view element naming nothing of the
558
+ * network is counted in one W_DANGLING_REFERENCE.
559
+ * @param view - the view document
560
+ * @param graph - the subnetwork
561
+ * @param nodes - its node records by id
562
+ * @param edges - its edge records by id
563
+ * @param report - the report
564
+ * @param entry - the view entry name
565
+ */
566
+ function applyView(
567
+ view: XgmmlDocument,
568
+ graph: GraphRec,
569
+ nodes: ReadonlyMap<string, NodeRec>,
570
+ edges: ReadonlyMap<string, EdgeRec>,
571
+ report: ImportReportBuilder,
572
+ entry: string,
573
+ ): void {
574
+ let dangling = 0;
575
+ for (const node of view.nodes) {
576
+ const target = node.viewId === null ? undefined : nodes.get(node.viewId);
577
+ if (target === undefined) {
578
+ dangling++;
579
+ continue;
580
+ }
581
+ target.x = node.x;
582
+ target.y = node.y;
583
+ target.z = node.z;
584
+ if (node.graphics !== null) {
585
+ target.graphics = { ...(target.graphics ?? {}), ...node.graphics };
586
+ }
587
+ }
588
+ for (const edge of view.edges) {
589
+ const target = edge.viewId === null ? undefined : edges.get(edge.viewId);
590
+ if (target === undefined) {
591
+ dangling++;
592
+ continue;
593
+ }
594
+ if (edge.graphics !== null) {
595
+ target.graphics = { ...(target.graphics ?? {}), ...edge.graphics };
596
+ }
597
+ }
598
+ if (view.root.graphics !== null) {
599
+ graph.graphics = { ...(graph.graphics ?? {}), ...view.root.graphics };
600
+ }
601
+ if (dangling > 0) {
602
+ report.warning(
603
+ "validation-error",
604
+ CYS_ISSUE.DANGLING_REFERENCE,
605
+ `${entry}: ${dangling} view element(s) name no node or edge of the network; they are not read`,
606
+ );
607
+ }
608
+ }
609
+
610
+ /**
611
+ * The positions of a further view, by model node id: y flipped to y-up, z in the position only
612
+ * under zAs "position".
613
+ * @param view - the view document
614
+ * @param zAs - where z goes
615
+ * @returns the positions
616
+ */
617
+ function viewPositions(view: XgmmlDocument, zAs: "column" | "position"): Map<string, [number, number, number]> {
618
+ const out = new Map<string, [number, number, number]>();
619
+ for (const node of view.nodes) {
620
+ const x = Number(node.x);
621
+ const y = Number(node.y);
622
+ const z = zAs === "position" ? Number(node.z ?? 0) : 0;
623
+ if (node.viewId !== null && node.x !== null && node.y !== null && Number.isFinite(x) && Number.isFinite(y)) {
624
+ out.set(node.viewId, [x, y === 0 ? 0 : -y, Number.isFinite(z) ? z : 0]);
625
+ }
626
+ }
627
+ return out;
628
+ }
629
+
630
+ /**
631
+ * Write each further view's positions as a `position@<n>` column (n from 2), no role.
632
+ * @param sink - the sink
633
+ * @param views - the positions of each further view
634
+ * @param viewIds - each view's SUID
635
+ * @param report - the report
636
+ * @param common - the id rule
637
+ */
638
+ function writeViewPositions(
639
+ sink: GraphSink,
640
+ views: readonly Map<string, [number, number, number]>[],
641
+ viewIds: readonly string[],
642
+ report: ImportReportBuilder,
643
+ common: ResolvedImportOptions,
644
+ ): void {
645
+ const coercer = new IdCoercer(common.ids);
646
+ views.forEach((positions, i) => {
647
+ const name = `${VIEW_POSITION_PREFIX}${i + 2}`;
648
+ const { handle } = declareResolved(
649
+ sink,
650
+ "node",
651
+ {
652
+ name,
653
+ dtype: "f32",
654
+ components: 3,
655
+ nullable: true,
656
+ origin: {
657
+ format: FORMAT,
658
+ id: viewIds[i],
659
+ title: null,
660
+ type: "graphics",
661
+ namespace: XGMML_ORIGIN_NAMESPACE,
662
+ },
663
+ extra: { sourceDims: 2, units: "file" },
664
+ },
665
+ report,
666
+ { element: name },
667
+ );
668
+ for (const [id, xyz] of positions) {
669
+ let index: number;
670
+ try {
671
+ index = sink.indexOf(coercer.text(id));
672
+ } catch {
673
+ continue; // an id the id rule refuses was refused, and recorded, as a node already
674
+ }
675
+ if (index !== INVALID_INDEX) {
676
+ sink.setNodeValue(handle, index, xyz);
677
+ }
678
+ }
679
+ });
680
+ }
681
+
682
+ /**
683
+ * The network's name from its own table (the LOCAL `name` att), else null.
684
+ * @param graph - the subnetwork with its table atts
685
+ * @returns the name
686
+ */
687
+ function localName(graph: GraphRec): string | null {
688
+ const att = graph.atts.find((a) => a.name === "name" && (a.namespace ?? null) === null);
689
+ return att?.value ?? null;
690
+ }
691
+
692
+ /**
693
+ * The name of the network a subnetwork was made from: the hidden `__parentNetwork.SUID` (or the
694
+ * pre-3.4 `Cy2 Parent Network.SUID`), named when the session holds it.
695
+ * @param graph - the subnetwork with its table atts
696
+ * @param choices - the session's networks
697
+ * @returns the parent's name, its SUID when unnamed, or null
698
+ */
699
+ function parentNetwork(graph: GraphRec, choices: readonly Choice[]): string | null {
700
+ const att = graph.atts.find((a) => a.name === "__parentNetwork.SUID" || a.name === "Cy2 Parent Network.SUID");
701
+ if (att?.value === undefined || att.value === null || att.value.length === 0) {
702
+ return null;
703
+ }
704
+ const suid = att.value;
705
+ const named = choices.find((c) => c.era === "3" && c.graphId === suid);
706
+ return named?.name ?? suid;
707
+ }
708
+
709
+ /**
710
+ * The resolver of nested-network pointers into other network files of the session
711
+ * (`207-Set+2.xgmml#223`): the target graph's label. Only the files a pointer names are read.
712
+ * @param prepared - the prepared import
713
+ * @param doc - the network file being read
714
+ * @returns the resolver
715
+ */
716
+ function pointerResolver(prepared: Prepared, doc: XgmmlDocument): (href: string) => string | null {
717
+ const names = new Map<string, string>();
718
+ for (const graph of doc.graphs) {
719
+ const { href } = graph;
720
+ const hash = href?.indexOf("#") ?? -1;
721
+ if (href === null || hash <= 0 || names.has(href)) {
722
+ continue;
723
+ }
724
+ const file = urlDecode(href.slice(0, hash));
725
+ const id = href.slice(hash + 1);
726
+ const network = prepared.session.layout.networks.find(
727
+ (n) => urlDecode(n.path.slice("networks/".length)) === file,
728
+ );
729
+ const parsed = network === undefined ? undefined : prepared.docs.get(network.name);
730
+ const target = parsed?.doc.graphs.find((g) => g.id === id);
731
+ if (target !== undefined) {
732
+ names.set(href, target.label ?? id);
733
+ }
734
+ }
735
+ return (href) => names.get(href) ?? null;
736
+ }
737
+
738
+ /**
739
+ * W_XGMML_ROOT_ONLY_ELEMENTS for elements of a network file no registered subnetwork holds.
740
+ * @param parsed - the network file
741
+ * @param report - the report
742
+ * @param entry - the entry name
743
+ */
744
+ function reportRootOnly(parsed: Parsed, report: ImportReportBuilder, entry: string): void {
745
+ const held = new Set<unknown>();
746
+ for (const graph of registeredGraphs(parsed)) {
747
+ const members = membersOf(parsed.doc, graph);
748
+ members.nodes.forEach((n) => held.add(n));
749
+ members.edges.forEach((e) => held.add(e));
750
+ }
751
+ const nodes = parsed.doc.nodes.filter((n) => !held.has(n)).length;
752
+ const edges = parsed.doc.edges.filter((e) => !held.has(e)).length;
753
+ if (nodes + edges > 0) {
754
+ report.warning(
755
+ "unsupported",
756
+ XGMML_ISSUE.ROOT_ONLY_ELEMENTS,
757
+ `${entry}: ${nodes} node(s) and ${edges} edge(s) belong to no registered network (group meta-edges, collapsed group members) and were not read`,
758
+ );
759
+ }
760
+ }
761
+
762
+ // ---------------------------------------------------------------------------------------- 2.x
763
+
764
+ /**
765
+ * The networks of a 2.x session: every network of `cysession.xml`'s tree except `Network Root`,
766
+ * in the order of their files in the archive.
767
+ * @param session - the session
768
+ * @param report - the report
769
+ * @param inner - the options entries are read with
770
+ * @returns the networks
771
+ */
772
+ async function networks2(
773
+ session: Session,
774
+ report: ImportReportBuilder,
775
+ inner: ResolvedImportOptions,
776
+ ): Promise<Choice2[]> {
777
+ const { layout } = session;
778
+ if (layout.cysession === null) {
779
+ return report.fail(CYS_ISSUE.NOT_SESSION, "the session has no cysession.xml");
780
+ }
781
+ const tree = await readXmlTree(await session.read(layout.cysession), layout.cysession.name, report, inner);
782
+ const documentVersion = tree.attrs.get("documentVersion") ?? "";
783
+ if (documentVersion.startsWith("3")) {
784
+ report.fail(
785
+ CYS_ISSUE.VERSION,
786
+ `cysession.xml has documentVersion ${documentVersion} and no version marker: the 2011 Cytoscape 3.0 pre-release layout, which no released Cytoscape reads`,
787
+ );
788
+ }
789
+ const byDecoded = new Map<string, SessionEntry>();
790
+ for (const [path, entry] of layout.files) {
791
+ byDecoded.set(path, entry);
792
+ byDecoded.set(urlDecode(path), entry);
793
+ }
794
+ const order = [...layout.files.values()];
795
+ const choices: Choice2[] = [];
796
+ const missing: string[] = [];
797
+ const named = new Set<SessionEntry>();
798
+ for (const record of elementsNamed(tree, "network")) {
799
+ const id = record.attrs.get("id") ?? "";
800
+ const filename = record.attrs.get("filename") ?? `${id}.xgmml`;
801
+ if (id === "Network Root") {
802
+ continue;
803
+ }
804
+ const file = byDecoded.get(filename);
805
+ if (file === undefined) {
806
+ missing.push(filename);
807
+ continue;
808
+ }
809
+ named.add(file);
810
+ const parent = record.children.find((c) => c.name === "parent")?.attrs.get("id") ?? null;
811
+ choices.push({
812
+ era: "2",
813
+ file,
814
+ name: id,
815
+ record,
816
+ parent: parent === "Network Root" || parent === "NULL" ? null : parent,
817
+ });
818
+ }
819
+ if (missing.length > 0) {
820
+ report.warning(
821
+ "validation-error",
822
+ CYS_ISSUE.DANGLING_REFERENCE,
823
+ `cysession.xml names ${missing.length} network file(s) the session does not hold: ${listed(missing)}`,
824
+ );
825
+ }
826
+ const unnamed = order.filter((e) => !named.has(e)).map((e) => e.name);
827
+ if (unnamed.length > 0) {
828
+ report.warning(
829
+ "unsupported",
830
+ CYS_ISSUE.ENTRY_SKIPPED,
831
+ `${unnamed.length} network file(s) are not in cysession.xml's network tree and were not read: ${listed(unnamed)}`,
832
+ );
833
+ }
834
+ return choices.sort((a, b) => order.indexOf(a.file) - order.indexOf(b.file));
835
+ }
836
+
837
+ /**
838
+ * Import one network of a 2.x session.
839
+ * @param prepared - the prepared import
840
+ * @param choice - the network
841
+ * @param sink - the sink
842
+ */
843
+ async function import2(prepared: Prepared, choice: Choice2, sink: GraphSink): Promise<void> {
844
+ const { report, inner, session } = prepared;
845
+ const { doc, dialect } = await parseEntry(session, choice.file, report, inner);
846
+ for (const [list, column] of [
847
+ ["selectedNodes", SELECTED_COLUMN],
848
+ ["hiddenNodes", HIDDEN_COLUMN],
849
+ ] as const) {
850
+ mark(doc.nodes, idsIn(choice.record, list), column);
851
+ }
852
+ for (const [list, column] of [
853
+ ["selectedEdges", SELECTED_COLUMN],
854
+ ["hiddenEdges", HIDDEN_COLUMN],
855
+ ] as const) {
856
+ mark(doc.edges, idsIn(choice.record, list), column);
857
+ }
858
+ const cyMeta = sessionMeta(prepared, {
859
+ collection: null,
860
+ network: choice.name,
861
+ parentNetwork: choice.parent,
862
+ visualStyle: choice.record.attrs.get("visualStyle") ?? null,
863
+ });
864
+ const extras: EmitExtras = {
865
+ sourceFormat: FORMAT,
866
+ entry: choice.file.name,
867
+ graphName: choice.name,
868
+ metaExtra: { [META_KEY]: cyMeta },
869
+ };
870
+ const settings: XgmmlSettings = { labelAliases: true, cytoscapeEscapes: true, zAs: prepared.zAs };
871
+ new XgmmlEmitter(doc, dialect, sink, report, inner, settings, extras).emit(null);
872
+ }
873
+
874
+ /**
875
+ * The element ids a `cysession.xml` network record lists under one key (`selectedNodes`, ...).
876
+ * @param record - the network record
877
+ * @param list - the list element's name
878
+ * @returns the ids (2.x node names, edge identifiers)
879
+ */
880
+ function idsIn(record: XmlNode, list: string): Set<string> {
881
+ const out = new Set<string>();
882
+ for (const holder of record.children.filter((c) => c.name === list)) {
883
+ for (const item of holder.children) {
884
+ const id = item.attrs.get("id");
885
+ if (id !== undefined) {
886
+ out.add(id);
887
+ }
888
+ }
889
+ }
890
+ return out;
891
+ }
892
+
893
+ /**
894
+ * Give the records 2.x lists by name (a node's name is its label; an edge is named by its id or
895
+ * label) a true cell in a bool column of the cytoscape namespace.
896
+ * @param records - the node or edge records
897
+ * @param ids - the listed names
898
+ * @param column - the column
899
+ */
900
+ function mark(records: readonly (NodeRec | EdgeRec)[], ids: ReadonlySet<string>, column: string): void {
901
+ if (ids.size === 0) {
902
+ return;
903
+ }
904
+ for (const record of records) {
905
+ if ((record.id !== null && ids.has(record.id)) || (record.label !== null && ids.has(record.label))) {
906
+ record.atts.push({
907
+ name: column,
908
+ type: null,
909
+ cyType: "Boolean",
910
+ elementType: null,
911
+ value: "true",
912
+ hidden: false,
913
+ equation: false,
914
+ children: [],
915
+ extra: {},
916
+ xml: null,
917
+ hasGraph: false,
918
+ text: "",
919
+ line: record.line,
920
+ namespace: CYTOSCAPE_NAMESPACE,
921
+ });
922
+ }
923
+ }
924
+ }
925
+
926
+ // ---------------------------------------------------------------------------------------- shared
927
+
928
+ /** The session facts of `meta.extra.cytoscape`. */
929
+ interface SessionMeta {
930
+ sessionVersion: string;
931
+ collection: string | null;
932
+ network: string;
933
+ parentNetwork: string | null;
934
+ visualStyle: string | null;
935
+ skippedEntries: string[];
936
+ groups?: { group: string; members: string[] }[];
937
+ }
938
+
939
+ /**
940
+ * The session facts of one network, and the once-per-import warnings about what is not read
941
+ * (styles, other entries).
942
+ * @param prepared - the prepared import
943
+ * @param facts - the network's facts
944
+ * @param facts.collection - the root network's name (3.x)
945
+ * @param facts.network - the network's id
946
+ * @param facts.parentNetwork - the network it was made from
947
+ * @param facts.visualStyle - the style its view uses
948
+ * @returns the record
949
+ */
950
+ function sessionMeta(
951
+ prepared: Prepared,
952
+ facts: { collection: string | null; network: string; parentNetwork: string | null; visualStyle: string | null },
953
+ ): SessionMeta {
954
+ const { layout } = prepared.session;
955
+ const { report } = prepared;
956
+ if (layout.styles.length > 0) {
957
+ report.warning(
958
+ "unsupported",
959
+ CYS_ISSUE.STYLES_NOT_IMPORTED,
960
+ `the session's styles (${layout.styles.map((s) => s.path).join(", ")}) are not applied: style import is issue #706`,
961
+ );
962
+ }
963
+ if (layout.skipped.length > 0) {
964
+ report.warning(
965
+ "unsupported",
966
+ CYS_ISSUE.ENTRY_SKIPPED,
967
+ `${layout.skipped.length} entries hold no graph data and were not read (apps, global tables, properties, images): ${listed(layout.skipped)}`,
968
+ );
969
+ }
970
+ return {
971
+ sessionVersion: layout.version,
972
+ ...facts,
973
+ skippedEntries: [...layout.skipped],
974
+ };
975
+ }
976
+
977
+ /**
978
+ * Import one network of a prepared session into a sink.
979
+ * @param prepared - the prepared import
980
+ * @param index - the network
981
+ * @param sink - the sink
982
+ * @returns the report
983
+ */
984
+ async function importChoice(prepared: Prepared, index: number, sink: GraphSink): Promise<ImportReport> {
985
+ const choice = prepared.choices[index];
986
+ if (choice.era === "3") {
987
+ const parsed = prepared.docs.get(choice.network.name) as Parsed;
988
+ await import3(prepared, choice, sink, parsed);
989
+ } else {
990
+ await import2(prepared, choice, sink);
991
+ }
992
+ throwIfAborted(prepared.common.signal);
993
+ return prepared.report.finish();
994
+ }
995
+
996
+ /**
997
+ * Import one network of a session.
998
+ * @param input - the session bytes
999
+ * @param sink - the sink
1000
+ * @param options - format-specific and common options
1001
+ * @returns the report; ImportError when the session cannot be read or the error limit is exceeded
1002
+ */
1003
+ async function importCys(
1004
+ input: ImportInput,
1005
+ sink: GraphSink,
1006
+ options?: CysImportOptions & CommonImportOptions,
1007
+ ): Promise<ImportReport> {
1008
+ const prepared = await prepare(input, sink, options);
1009
+ const index = chooseGraph(
1010
+ prepared.choices.map((c) => c.name),
1011
+ options,
1012
+ prepared.report,
1013
+ );
1014
+ if (prepared.choices.length > 1) {
1015
+ prepared.report.warning(
1016
+ "unsupported",
1017
+ CYS_ISSUE.MULTIPLE_GRAPHS,
1018
+ `the session holds ${prepared.choices.length} networks; ${prepared.choices.length - 1} were not read (use importAll, graphIndex or graphName)`,
1019
+ );
1020
+ }
1021
+ return importChoice(prepared, index, sink);
1022
+ }
1023
+
1024
+ /**
1025
+ * Import every network of a session, each into its own sink with its own report.
1026
+ * @param input - the session bytes
1027
+ * @param sinkFor - a sink per network
1028
+ * @param options - format-specific and common options
1029
+ * @returns one report per network
1030
+ */
1031
+ async function importAllCys(
1032
+ input: ImportInput,
1033
+ sinkFor: (index: number) => GraphSink,
1034
+ options?: CysImportOptions & CommonImportOptions,
1035
+ ): Promise<ImportReport[]> {
1036
+ const bytes = (await readBytes(input, { signal: options?.signal ?? null })) ?? input;
1037
+ const first = await prepare(bytes, null, options);
1038
+ const reports: ImportReport[] = [];
1039
+ for (let i = 0; i < first.choices.length; i++) {
1040
+ const sink = sinkFor(i);
1041
+ // every network gets fresh records and a fresh report
1042
+ const prepared = i === 0 ? first : await prepare(bytes, null, options);
1043
+ reportSinkOptions(sink, options, prepared.report, true);
1044
+ reportUnusedOptions(options, prepared.report, USED_OPTIONS);
1045
+ reports.push(await importChoice(prepared, i, sink));
1046
+ }
1047
+ return reports;
1048
+ }
1049
+
1050
+ /**
1051
+ * List the networks of a session from its network files, without reading tables or views.
1052
+ * @param input - the session bytes
1053
+ * @param options - the options
1054
+ * @returns one listing per network; node and edge counts for 3.x sessions only
1055
+ */
1056
+ async function listCysGraphs(
1057
+ input: ImportInput,
1058
+ options?: CysImportOptions & CommonImportOptions,
1059
+ ): Promise<readonly GraphListing[]> {
1060
+ const prepared = await prepare(input, null, options);
1061
+ return prepared.choices.map((choice, index) =>
1062
+ Object.freeze({
1063
+ index,
1064
+ name: choice.name,
1065
+ nodes: choice.era === "3" ? choice.nodes : null,
1066
+ edges: choice.era === "3" ? choice.edges : null,
1067
+ }),
1068
+ );
1069
+ }
1070
+
1071
+ /** A session's marker or folder name in the head of the archive. */
1072
+ const SESSION_NAME = /CytoscapeSession|cysession\.xml|\d+\.\d+\.\d+\.version/;
1073
+
1074
+ /**
1075
+ * Confidence that a head of bytes is a Cytoscape session: 0.95 for a zip whose head names the
1076
+ * session folder, its version marker or cysession.xml; 0 for any other zip, so an `.xlsx`, a
1077
+ * `.docx` or a zipped GraphML ranks as no format rather than as a broken session. The zip may
1078
+ * start after a short stub (a self-extracting archive) within the head.
1079
+ * @param head - the first bytes
1080
+ * @returns the confidence
1081
+ */
1082
+ export function sniffCys(head: Uint8Array): number {
1083
+ for (let at = 0; at + 4 <= head.byteLength; at++) {
1084
+ if (startsLikeZip(head.subarray(at))) {
1085
+ return SESSION_NAME.test(decodeEntryName(head.subarray(at))) ? 0.95 : 0;
1086
+ }
1087
+ }
1088
+ return 0;
1089
+ }
1090
+
1091
+ /** The Cytoscape session importer. */
1092
+ export const cysImporter: GraphImporter<CysImportOptions> = Object.freeze({
1093
+ format: FORMAT,
1094
+ extensions: EXTENSIONS,
1095
+ mimeTypes: MIME_TYPES,
1096
+ sniff: sniffCys,
1097
+ import: importCys,
1098
+ importAll: importAllCys,
1099
+ listGraphs: listCysGraphs,
1100
+ });