@alizarin/napi 0.2.1-alpha.83

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.
package/src/lib.rs ADDED
@@ -0,0 +1,611 @@
1
+ mod instance_wrapper_napi;
2
+
3
+ use std::collections::HashMap;
4
+ use std::sync::OnceLock;
5
+
6
+ use napi::bindgen_prelude::*;
7
+ use napi_derive::napi;
8
+
9
+ use alizarin_core::extension_type_registry::ExtensionTypeRegistry;
10
+ use alizarin_core::graph_mutator::MutatorOptions;
11
+ use alizarin_core::skos::SkosCollection;
12
+ use alizarin_core::type_serialization::SerializationContext;
13
+ use alizarin_core::{
14
+ build_graph_from_model_csvs, build_resources_from_business_csv, wrap_business_data,
15
+ PrebuildLoader, StaticGraph, StaticResource, StaticResourceRegistry,
16
+ };
17
+
18
+ // ============================================================================
19
+ // Extension registry (shared across all calls)
20
+ // ============================================================================
21
+
22
+ fn extension_registry() -> &'static ExtensionTypeRegistry {
23
+ static REGISTRY: OnceLock<ExtensionTypeRegistry> = OnceLock::new();
24
+ REGISTRY.get_or_init(|| {
25
+ let mut registry = ExtensionTypeRegistry::new();
26
+ registry.register(
27
+ alizarin_clm_core::DATATYPE_NAME,
28
+ alizarin_clm_core::create_reference_handler(),
29
+ );
30
+ registry.register(
31
+ alizarin_filelist_core::DATATYPE_NAME,
32
+ alizarin_filelist_core::create_filelist_handler(),
33
+ );
34
+ registry
35
+ })
36
+ }
37
+
38
+ // ============================================================================
39
+ // Error conversion
40
+ // ============================================================================
41
+
42
+ fn loader_err(e: alizarin_core::LoaderError) -> napi::Error {
43
+ napi::Error::from_reason(e.to_string())
44
+ }
45
+
46
+ // ============================================================================
47
+ // NapiPrebuildLoader
48
+ // ============================================================================
49
+
50
+ #[napi]
51
+ pub struct NapiPrebuildLoader {
52
+ inner: PrebuildLoader,
53
+ }
54
+
55
+ #[napi]
56
+ impl NapiPrebuildLoader {
57
+ #[napi(constructor)]
58
+ pub fn new(path: String) -> Result<Self> {
59
+ let inner = PrebuildLoader::new(&path).map_err(loader_err)?;
60
+ Ok(NapiPrebuildLoader { inner })
61
+ }
62
+
63
+ /// Load a single graph from a JSON file path (relative to prebuild root or absolute).
64
+ #[napi]
65
+ pub fn load_graph(&self, path: String) -> Result<NapiStaticGraph> {
66
+ let graph = self.inner.load_graph(&path).map_err(loader_err)?;
67
+ Ok(NapiStaticGraph { inner: graph })
68
+ }
69
+
70
+ /// Load all graphs from prebuild/graphs/resource_models/
71
+ #[napi]
72
+ pub fn load_all_graphs(&self) -> Result<Vec<NapiStaticGraph>> {
73
+ let graphs = self.inner.load_all_graphs().map_err(loader_err)?;
74
+ Ok(graphs
75
+ .into_iter()
76
+ .map(|g| NapiStaticGraph { inner: g })
77
+ .collect())
78
+ }
79
+
80
+ /// Load full resources (with tiles) from a single business data file,
81
+ /// filtered by graph ID.
82
+ #[napi]
83
+ pub fn load_full_resources_from_file(
84
+ &self,
85
+ path: String,
86
+ graph_id: String,
87
+ ) -> Result<serde_json::Value> {
88
+ let resources = self
89
+ .inner
90
+ .load_full_resources_from_file(std::path::Path::new(&path), &graph_id)
91
+ .map_err(loader_err)?;
92
+ serde_json::to_value(&resources).map_err(|e| napi::Error::from_reason(e.to_string()))
93
+ }
94
+
95
+ /// Find all business data JSON files in the prebuild directory.
96
+ #[napi]
97
+ pub fn find_business_data_files(&self) -> Result<Vec<String>> {
98
+ let files = self.inner.find_business_data_files().map_err(loader_err)?;
99
+ Ok(files.into_iter().map(|p| p.display().to_string()).collect())
100
+ }
101
+
102
+ /// Get prebuild directory info.
103
+ #[napi]
104
+ pub fn get_info(&self) -> Result<serde_json::Value> {
105
+ let info = self.inner.get_info().map_err(loader_err)?;
106
+ Ok(serde_json::json!({
107
+ "path": info.path.display().to_string(),
108
+ "hasGraphs": info.has_graphs,
109
+ "hasBusinessData": info.has_business_data,
110
+ "hasReferenceData": info.has_reference_data,
111
+ "hasIndexTemplates": info.has_index_templates,
112
+ "graphFiles": info.graph_files.iter().map(|p| p.display().to_string()).collect::<Vec<_>>(),
113
+ }))
114
+ }
115
+
116
+ /// Count resources for a given graph ID.
117
+ #[napi]
118
+ pub fn count_resources_for_graph(&self, graph_id: String) -> Result<u32> {
119
+ let count = self
120
+ .inner
121
+ .count_resources_for_graph(&graph_id)
122
+ .map_err(loader_err)?;
123
+ Ok(count as u32)
124
+ }
125
+ }
126
+
127
+ // ============================================================================
128
+ // NapiPrebuildExporter
129
+ // ============================================================================
130
+
131
+ fn exporter_err(e: alizarin_core::ExportError) -> napi::Error {
132
+ napi::Error::from_reason(e.to_string())
133
+ }
134
+
135
+ #[napi]
136
+ #[derive(Default)]
137
+ pub struct NapiPrebuildExporter {}
138
+
139
+ #[napi]
140
+ impl NapiPrebuildExporter {
141
+ #[napi(constructor)]
142
+ pub fn new() -> Self {
143
+ NapiPrebuildExporter {}
144
+ }
145
+
146
+ /// Export registered graphs to a directory.
147
+ ///
148
+ /// Classifies graphs as resource_models or branches based on `isresource`,
149
+ /// writes as `{"graph": [graph_data]}` JSON files with sorted keys.
150
+ #[napi]
151
+ pub fn export_graphs(&self, graph_ids: Vec<String>, out_dir: String) -> Result<Vec<String>> {
152
+ let data = alizarin_core::export_graphs(&graph_ids).map_err(exporter_err)?;
153
+ let export_data = alizarin_core::PrebuildExportData {
154
+ graph_files: data,
155
+ ..Default::default()
156
+ };
157
+ alizarin_core::write_to_directory(&export_data, std::path::Path::new(&out_dir))
158
+ .map_err(exporter_err)
159
+ }
160
+
161
+ /// Export all registered graphs to a directory.
162
+ #[napi]
163
+ pub fn export_all_graphs(&self, out_dir: String) -> Result<Vec<String>> {
164
+ let data = alizarin_core::export_all_graphs().map_err(exporter_err)?;
165
+ let export_data = alizarin_core::PrebuildExportData {
166
+ graph_files: data,
167
+ ..Default::default()
168
+ };
169
+ alizarin_core::write_to_directory(&export_data, std::path::Path::new(&out_dir))
170
+ .map_err(exporter_err)
171
+ }
172
+
173
+ /// Build complete export data as JSON (without writing to filesystem).
174
+ ///
175
+ /// Returns an object with `files` array of `{relativePath, content}` entries.
176
+ #[napi]
177
+ pub fn build_export_data(
178
+ &self,
179
+ graph_ids: Option<Vec<String>>,
180
+ base_uri: String,
181
+ ) -> Result<serde_json::Value> {
182
+ let ids = graph_ids.as_deref();
183
+ let data =
184
+ alizarin_core::build_prebuild_export(ids, None, &base_uri).map_err(exporter_err)?;
185
+
186
+ let files: Vec<serde_json::Value> = data
187
+ .all_files()
188
+ .iter()
189
+ .map(|f| {
190
+ serde_json::json!({
191
+ "relativePath": f.relative_path,
192
+ "content": f.content,
193
+ })
194
+ })
195
+ .collect();
196
+
197
+ Ok(serde_json::json!({
198
+ "files": files,
199
+ "graphFileCount": data.graph_files.len(),
200
+ "referenceDataFileCount": data.reference_data_files.len(),
201
+ }))
202
+ }
203
+
204
+ /// Get IDs of all registered graphs.
205
+ #[napi]
206
+ pub fn get_registered_graph_ids(&self) -> Vec<String> {
207
+ alizarin_core::get_registered_graph_ids()
208
+ }
209
+ }
210
+
211
+ // ============================================================================
212
+ // NapiStaticGraph
213
+ // ============================================================================
214
+
215
+ #[napi]
216
+ pub struct NapiStaticGraph {
217
+ inner: StaticGraph,
218
+ }
219
+
220
+ impl NapiStaticGraph {
221
+ pub(crate) fn inner_ref(&self) -> &StaticGraph {
222
+ &self.inner
223
+ }
224
+ }
225
+
226
+ #[napi]
227
+ impl NapiStaticGraph {
228
+ /// Parse a graph from a JSON string (the file content, not a file path).
229
+ #[napi(factory)]
230
+ pub fn from_json_string(json_str: String) -> Result<Self> {
231
+ let graph = StaticGraph::from_json_string(&json_str).map_err(napi::Error::from_reason)?;
232
+ Ok(NapiStaticGraph { inner: graph })
233
+ }
234
+
235
+ #[napi(getter)]
236
+ pub fn graph_id(&self) -> String {
237
+ self.inner.graphid.clone()
238
+ }
239
+
240
+ #[napi(getter)]
241
+ pub fn name(&self) -> serde_json::Value {
242
+ serde_json::to_value(&self.inner.name).unwrap_or(serde_json::Value::Null)
243
+ }
244
+
245
+ /// Register this graph in the global registry so NapiResourceInstanceWrapper can use it.
246
+ #[napi]
247
+ pub fn register(&self) {
248
+ alizarin_core::register_graph_owned(self.inner.clone());
249
+ }
250
+ }
251
+
252
+ // ============================================================================
253
+ // NapiStaticResourceRegistry
254
+ // ============================================================================
255
+
256
+ #[napi]
257
+ pub struct NapiStaticResourceRegistry {
258
+ inner: StaticResourceRegistry,
259
+ }
260
+
261
+ impl Default for NapiStaticResourceRegistry {
262
+ fn default() -> Self {
263
+ Self::new()
264
+ }
265
+ }
266
+
267
+ #[napi]
268
+ impl NapiStaticResourceRegistry {
269
+ #[napi(constructor)]
270
+ pub fn new() -> Self {
271
+ NapiStaticResourceRegistry {
272
+ inner: StaticResourceRegistry::new(),
273
+ }
274
+ }
275
+
276
+ /// Insert full resources (with tiles) from a JSON array of StaticResource.
277
+ #[napi]
278
+ pub fn merge_from_resources_json(
279
+ &mut self,
280
+ resources_json: String,
281
+ store_full: Option<bool>,
282
+ ) -> Result<()> {
283
+ let resources: Vec<StaticResource> = serde_json::from_str(&resources_json)
284
+ .map_err(|e| napi::Error::from_reason(e.to_string()))?;
285
+ self.inner
286
+ .merge_from_resources(&resources, store_full.unwrap_or(true), true);
287
+ Ok(())
288
+ }
289
+
290
+ /// Load a business_data file from raw bytes (Buffer), parse entirely in Rust,
291
+ /// merge into this registry, and return lightweight refs.
292
+ ///
293
+ /// Equivalent to the WASM `loadFromBusinessDataBytes` but runs natively —
294
+ /// no WASM linear memory limit, and panics produce real stack traces.
295
+ #[napi]
296
+ pub fn load_from_business_data_bytes(
297
+ &mut self,
298
+ bytes: Buffer,
299
+ store_full: Option<bool>,
300
+ include_caches: Option<bool>,
301
+ ) -> Result<Vec<serde_json::Value>> {
302
+ let resources = alizarin_core::parse_business_data_bytes(&bytes).map_err(|e| {
303
+ napi::Error::from_reason(format!("Failed to parse business data: {}", e))
304
+ })?;
305
+
306
+ let store_full = store_full.unwrap_or(true);
307
+ let include_caches = include_caches.unwrap_or(true);
308
+
309
+ let refs: Vec<serde_json::Value> = resources
310
+ .iter()
311
+ .map(|r| {
312
+ let is_public = r
313
+ .scopes
314
+ .as_ref()
315
+ .and_then(|s| s.as_array())
316
+ .map(|arr| arr.iter().any(|v| v.as_str() == Some("public")))
317
+ .unwrap_or(false);
318
+ serde_json::json!({
319
+ "resourceinstanceid": r.resourceinstance.resourceinstanceid,
320
+ "graph_id": r.resourceinstance.graph_id,
321
+ "isPublic": is_public,
322
+ })
323
+ })
324
+ .collect();
325
+
326
+ self.inner
327
+ .merge_from_resources(&resources, store_full, include_caches);
328
+
329
+ Ok(refs)
330
+ }
331
+
332
+ /// Insert full resources from a business_data JSON file string.
333
+ #[napi]
334
+ pub fn merge_from_business_data_json(
335
+ &mut self,
336
+ business_data_json: String,
337
+ store_full: Option<bool>,
338
+ ) -> Result<()> {
339
+ // Parse the business_data wrapper to extract resources
340
+ let file: serde_json::Value = serde_json::from_str(&business_data_json)
341
+ .map_err(|e| napi::Error::from_reason(e.to_string()))?;
342
+
343
+ let resources_val = file
344
+ .get("business_data")
345
+ .and_then(|bd| bd.get("resources"))
346
+ .ok_or_else(|| {
347
+ napi::Error::from_reason("JSON missing business_data.resources".to_string())
348
+ })?;
349
+
350
+ let resources: Vec<StaticResource> = serde_json::from_value(resources_val.clone())
351
+ .map_err(|e| napi::Error::from_reason(format!("Failed to parse resources: {}", e)))?;
352
+
353
+ self.inner
354
+ .merge_from_resources(&resources, store_full.unwrap_or(true), true);
355
+ Ok(())
356
+ }
357
+
358
+ /// Build an inverted index: visibility value -> [resource IDs].
359
+ #[napi]
360
+ pub fn get_value_to_resources_index(
361
+ &self,
362
+ graph: &NapiStaticGraph,
363
+ node_identifier: String,
364
+ flatten_localized: Option<bool>,
365
+ ) -> Result<HashMap<String, Vec<String>>> {
366
+ let ctx = SerializationContext {
367
+ extension_registry: Some(extension_registry()),
368
+ ..SerializationContext::empty()
369
+ };
370
+ self.inner
371
+ .get_value_to_resources_index_with_context(
372
+ &graph.inner,
373
+ &node_identifier,
374
+ flatten_localized.unwrap_or(true),
375
+ Some(&ctx),
376
+ )
377
+ .map_err(napi::Error::from_reason)
378
+ }
379
+
380
+ /// Extract values from one node in tiles where another node matches a filter.
381
+ ///
382
+ /// Both nodes must be in the same nodegroup. Returns raw JSON values from
383
+ /// the extract node for each tile where the filter node's display value
384
+ /// contains any of the filter values.
385
+ #[napi]
386
+ pub fn get_filtered_tile_values(
387
+ &self,
388
+ graph: &NapiStaticGraph,
389
+ filter_node: String,
390
+ filter_values: Vec<String>,
391
+ extract_node: String,
392
+ flatten_localized: Option<bool>,
393
+ required_scope: Option<String>,
394
+ ) -> Result<serde_json::Value> {
395
+ let filter_refs: Vec<&str> = filter_values.iter().map(|s| s.as_str()).collect();
396
+ let ctx = SerializationContext {
397
+ extension_registry: Some(extension_registry()),
398
+ ..SerializationContext::empty()
399
+ };
400
+ let results = self
401
+ .inner
402
+ .get_filtered_tile_values(
403
+ &graph.inner,
404
+ &filter_node,
405
+ &filter_refs,
406
+ &extract_node,
407
+ flatten_localized.unwrap_or(true),
408
+ Some(&ctx),
409
+ required_scope.as_deref(),
410
+ )
411
+ .map_err(napi::Error::from_reason)?;
412
+ serde_json::to_value(&results).map_err(|e| napi::Error::from_reason(e.to_string()))
413
+ }
414
+
415
+ /// Build a forward index: resource ID -> [node values].
416
+ #[napi]
417
+ pub fn get_node_values_index(
418
+ &self,
419
+ graph: &NapiStaticGraph,
420
+ node_identifier: String,
421
+ ) -> Result<serde_json::Value> {
422
+ let index = self
423
+ .inner
424
+ .get_node_values_index(&graph.inner, &node_identifier)
425
+ .map_err(napi::Error::from_reason)?;
426
+ serde_json::to_value(&index).map_err(|e| napi::Error::from_reason(e.to_string()))
427
+ }
428
+
429
+ #[napi(getter)]
430
+ pub fn length(&self) -> u32 {
431
+ self.inner.len() as u32
432
+ }
433
+
434
+ #[napi]
435
+ pub fn contains(&self, resource_id: String) -> bool {
436
+ self.inner.contains(&resource_id)
437
+ }
438
+
439
+ #[napi]
440
+ pub fn has_full(&self, resource_id: String) -> bool {
441
+ self.inner.has_full(&resource_id)
442
+ }
443
+
444
+ /// Get the full resource (with tiles) as a JSON object, or null if only summary stored.
445
+ #[napi]
446
+ pub fn get_full(&self, resource_id: String) -> Result<Option<serde_json::Value>> {
447
+ match self.inner.get_full(&resource_id) {
448
+ Some(r) => serde_json::to_value(r)
449
+ .map(Some)
450
+ .map_err(|e| napi::Error::from_reason(format!("Serialization failed: {}", e))),
451
+ None => Ok(None),
452
+ }
453
+ }
454
+
455
+ /// Get a summary for a resource (works for both summary and full entries), or null if unknown.
456
+ #[napi]
457
+ pub fn get_summary(&self, resource_id: String) -> Result<Option<serde_json::Value>> {
458
+ match self.inner.get_summary(&resource_id) {
459
+ Some(s) => serde_json::to_value(&s)
460
+ .map(Some)
461
+ .map_err(|e| napi::Error::from_reason(format!("Serialization failed: {}", e))),
462
+ None => Ok(None),
463
+ }
464
+ }
465
+ }
466
+
467
+ // ============================================================================
468
+ // CSV model and business data loading
469
+ // ============================================================================
470
+
471
+ fn csv_err(e: alizarin_core::CsvModelError) -> napi::Error {
472
+ let msgs: Vec<String> = e
473
+ .diagnostics
474
+ .iter()
475
+ .map(|d| {
476
+ if let Some(line) = d.line {
477
+ format!("{:?}: {}:{}: {}", d.level, d.file, line, d.message)
478
+ } else {
479
+ format!("{:?}: {}: {}", d.level, d.file, d.message)
480
+ }
481
+ })
482
+ .collect();
483
+ napi::Error::from_reason(msgs.join("\n"))
484
+ }
485
+
486
+ /// Build a StaticGraph from model CSVs (graph.csv, nodes.csv, optional collections.csv).
487
+ ///
488
+ /// Returns the graph as a JSON string. The `rdm_namespace` is used for
489
+ /// deterministic ID generation (typically a UUID or URL).
490
+ #[napi]
491
+ pub fn build_graph_from_csvs(
492
+ graph_csv: String,
493
+ nodes_csv: String,
494
+ collections_csv: Option<String>,
495
+ rdm_namespace: String,
496
+ ) -> Result<serde_json::Value> {
497
+ let (graph, collections) = build_graph_from_model_csvs(
498
+ &graph_csv,
499
+ &nodes_csv,
500
+ collections_csv.as_deref(),
501
+ &rdm_namespace,
502
+ MutatorOptions::default(),
503
+ )
504
+ .map_err(csv_err)?;
505
+
506
+ let graph_json =
507
+ serde_json::to_value(&graph).map_err(|e| napi::Error::from_reason(e.to_string()))?;
508
+ let collections_json =
509
+ serde_json::to_value(&collections).map_err(|e| napi::Error::from_reason(e.to_string()))?;
510
+
511
+ Ok(serde_json::json!({
512
+ "graph": graph_json,
513
+ "collections": collections_json,
514
+ }))
515
+ }
516
+
517
+ /// Build StaticResources from a business data CSV, given a graph JSON string
518
+ /// and collections JSON string (as returned by `buildGraphFromCsvs`).
519
+ ///
520
+ /// Returns the resources wrapped in the `{ business_data: { resources: [...] } }`
521
+ /// format expected by PrebuildLoader.
522
+ #[napi]
523
+ pub fn build_business_data_from_csv(
524
+ csv_data: String,
525
+ graph_json: String,
526
+ collections_json: String,
527
+ ) -> Result<serde_json::Value> {
528
+ let graph: StaticGraph = serde_json::from_str(&graph_json)
529
+ .map_err(|e| napi::Error::from_reason(format!("Invalid graph JSON: {e}")))?;
530
+ let collections: Vec<SkosCollection> = serde_json::from_str(&collections_json)
531
+ .map_err(|e| napi::Error::from_reason(format!("Invalid collections JSON: {e}")))?;
532
+
533
+ let resources =
534
+ build_resources_from_business_csv(&csv_data, &graph, &collections, Default::default())
535
+ .map_err(csv_err)?;
536
+
537
+ Ok(wrap_business_data(&resources))
538
+ }
539
+
540
+ // ============================================================================
541
+ // Extension handler direct access
542
+ // ============================================================================
543
+
544
+ /// Coerce a value using the registered extension handler for the given datatype.
545
+ ///
546
+ /// Returns `{ tileData, displayValue }` or null if no handler is registered.
547
+ #[napi(js_name = "extensionCoerce")]
548
+ pub fn extension_coerce(
549
+ datatype: String,
550
+ value: serde_json::Value,
551
+ config: Option<serde_json::Value>,
552
+ ) -> Result<Option<serde_json::Value>> {
553
+ let registry = extension_registry();
554
+ match registry.coerce(&datatype, &value, config.as_ref()) {
555
+ Ok(Some(result)) => {
556
+ let output = serde_json::json!({
557
+ "tileData": result.tile_data,
558
+ "displayValue": result.display_value,
559
+ });
560
+ Ok(Some(output))
561
+ }
562
+ Ok(None) => Ok(None),
563
+ Err(e) => Err(napi::Error::from_reason(e.message)),
564
+ }
565
+ }
566
+
567
+ /// Render a display string for tile data using the extension handler.
568
+ ///
569
+ /// Returns the display string, or null if no handler or no display.
570
+ #[napi(js_name = "extensionRenderDisplay")]
571
+ pub fn extension_render_display(
572
+ datatype: String,
573
+ tile_data: serde_json::Value,
574
+ language: String,
575
+ ) -> Result<Option<String>> {
576
+ let registry = extension_registry();
577
+ registry
578
+ .render_display(&datatype, &tile_data, &language)
579
+ .map_err(|e| napi::Error::from_reason(e.message))
580
+ }
581
+
582
+ /// Resolve markers in tile data using the extension handler.
583
+ ///
584
+ /// Returns the resolved tile data value.
585
+ #[napi(js_name = "extensionResolveMarkers")]
586
+ pub fn extension_resolve_markers(
587
+ datatype: String,
588
+ tile_data: serde_json::Value,
589
+ language: String,
590
+ ) -> Result<serde_json::Value> {
591
+ let registry = extension_registry();
592
+ registry
593
+ .resolve_markers(&datatype, &tile_data, &language)
594
+ .map_err(|e| napi::Error::from_reason(e.message))
595
+ }
596
+
597
+ /// Check if an extension handler is registered for the given datatype.
598
+ #[napi(js_name = "hasExtensionHandler")]
599
+ pub fn has_extension_handler(datatype: String) -> bool {
600
+ extension_registry().has(&datatype)
601
+ }
602
+
603
+ /// List all registered extension handler datatypes.
604
+ #[napi(js_name = "getRegisteredExtensionHandlers")]
605
+ pub fn get_registered_extension_handlers() -> Vec<String> {
606
+ extension_registry()
607
+ .list()
608
+ .into_iter()
609
+ .map(|s| s.to_string())
610
+ .collect()
611
+ }