@directory-builder/core 0.1.0

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 (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +112 -0
  3. package/bin/cli.js +38 -0
  4. package/example/README.md +64 -0
  5. package/example/config/federation.ttl +136 -0
  6. package/example/config/match-knowledge.ttl +8 -0
  7. package/example/sources/cityopen/clean.sparql +17 -0
  8. package/example/sources/cityopen/fetch.js +14 -0
  9. package/example/sources/cityopen/static/libraries.json +32 -0
  10. package/example/sources/civichub/clean.sparql +34 -0
  11. package/example/sources/civichub/fetch.js +14 -0
  12. package/example/sources/civichub/static/libraries.json +38 -0
  13. package/package.json +38 -0
  14. package/src/federate.js +571 -0
  15. package/src/index.js +6 -0
  16. package/src/ingest.js +158 -0
  17. package/src/lift/html.sparql +12 -0
  18. package/src/lift/json.sparql +12 -0
  19. package/src/pipeline.js +16 -0
  20. package/src/utils.js +152 -0
  21. package/src/webapp.js +41 -0
  22. package/webapp/index.html +11 -0
  23. package/webapp/src/About.jsx +24 -0
  24. package/webapp/src/App.jsx +96 -0
  25. package/webapp/src/Card.jsx +32 -0
  26. package/webapp/src/ColumnGraph.jsx +290 -0
  27. package/webapp/src/Directory.jsx +15 -0
  28. package/webapp/src/Download.jsx +174 -0
  29. package/webapp/src/MapGraph.jsx +244 -0
  30. package/webapp/src/MatchGraph.jsx +137 -0
  31. package/webapp/src/MergeTables.jsx +61 -0
  32. package/webapp/src/OrgCard.jsx +126 -0
  33. package/webapp/src/Pipeline.jsx +41 -0
  34. package/webapp/src/Query.jsx +165 -0
  35. package/webapp/src/Sources.jsx +52 -0
  36. package/webapp/src/instanceData.js +35 -0
  37. package/webapp/src/loadMap.js +276 -0
  38. package/webapp/src/loadMatch.js +228 -0
  39. package/webapp/src/loadMerge.js +93 -0
  40. package/webapp/src/loadPipeline.js +130 -0
  41. package/webapp/src/loadSources.js +102 -0
  42. package/webapp/src/main.jsx +9 -0
  43. package/webapp/src/mergeOrgs.js +15 -0
  44. package/webapp/src/sourceMeta.js +81 -0
  45. package/webapp/src/styles.css +23 -0
  46. package/webapp/vite.config.js +14 -0
  47. package/webapp/vite.js +28 -0
@@ -0,0 +1,290 @@
1
+ // Generic column-layout graph (xyflow): arranges nodes into typed columns with
2
+ // labelled edges. Pure view — no data loading.
3
+ // Reads: props (nodes, edges, columns, colors, …)
4
+ // Does: renders the flow graph; used by Pipeline, Map and Match
5
+
6
+ import { ReactFlow, Background, Controls, MarkerType, Handle, Position, useNodesState, useEdgesState, BaseEdge, EdgeLabelRenderer, getBezierPath } from "@xyflow/react"
7
+ import React, { createContext, useContext, useEffect, useMemo, useState } from "react"
8
+ import "@xyflow/react/dist/style.css"
9
+
10
+ const DEFAULT_COL_SPACING = 260
11
+ const DEFAULT_SIBLING_GAP = 80
12
+ const DEFAULT_NODE_WIDTH = 160
13
+
14
+ function SideNode({ data, style }) {
15
+ const targetPos = data.targetPos ?? Position.Left
16
+ const sourcePos = data.sourcePos ?? Position.Right
17
+ return (
18
+ <div style={style}>
19
+ <Handle type="target" position={targetPos} />
20
+ <div title={data.label} style={{ textAlign: "center", fontWeight: data.props?.length ? 600 : 400, display: "-webkit-box", WebkitLineClamp: 2, WebkitBoxOrient: "vertical", overflow: "hidden", wordBreak: "break-word" }}>{data.label}</div>
21
+ {data.subtitle && (
22
+ <div title={data.subtitle} style={{ textAlign: "center", fontSize: 10, color: "#888", marginTop: 2, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{data.subtitle}</div>
23
+ )}
24
+ {data.props?.length > 0 && (
25
+ <div style={{ marginTop: 6, fontSize: 9, lineHeight: "13px", color: "#888" }}>
26
+ {data.props.map((p, i) => (
27
+ <div key={i} style={{ display: "flex", gap: 4, whiteSpace: "nowrap", overflow: "hidden" }} title={`${p.key}: ${p.value}`}>
28
+ <span>{p.key}:</span>
29
+ <span style={{ overflow: "hidden", textOverflow: "ellipsis" }}>{p.value}</span>
30
+ </div>
31
+ ))}
32
+ </div>
33
+ )}
34
+ <Handle type="source" position={sourcePos} />
35
+ </div>
36
+ )
37
+ }
38
+
39
+ // Shared state so hovering an edge or its label highlights the other.
40
+ const HoveredEdgeContext = createContext({ id: null, set: () => {} })
41
+
42
+ const HOVER_COLOR = "#ff6a00"
43
+
44
+ // Renders `data.value` near the bezier midpoint with a small per-edge offset
45
+ // (so parallel edges don't pile up). `data.bg` tints the label by the
46
+ // transformation "moment" — source-field outgoing vs. transform outgoing.
47
+ function ValueEdge({ id, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, data, markerEnd, style }) {
48
+ const [edgePath, midX, midY] = getBezierPath({ sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition })
49
+ const idx = data.idx ?? 0
50
+ const dx = targetX - sourceX
51
+ const dy = targetY - sourceY
52
+ const len = Math.hypot(dx, dy) || 1
53
+ const tShift = data.centered ? 0 : (((idx % 5) - 2) / 2) * 0.15
54
+ const perp = data.centered ? 0 : ((idx % 3) - 1) * 14
55
+ const labelX = midX + dx * tShift + (-dy / len) * perp
56
+ const labelY = midY + dy * tShift + ( dx / len) * perp
57
+
58
+ const { id: hoveredId, set } = useContext(HoveredEdgeContext)
59
+ const hovered = hoveredId === id
60
+ // Edges attached to a node being dragged are highlighted by the parent
61
+ // (orange stroke); we mirror that highlight on the label here.
62
+ const highlight = hovered || data.attached
63
+ const onIn = () => set(id)
64
+ const onOut = () => set(null)
65
+
66
+ const edgeStyle = hovered ? { ...style, stroke: HOVER_COLOR, strokeWidth: 2 } : style
67
+ const edgeMarker = hovered ? { type: MarkerType.ArrowClosed, color: HOVER_COLOR } : markerEnd
68
+
69
+ return (
70
+ <>
71
+ <g onPointerEnter={onIn} onPointerLeave={onOut} style={{ cursor: "grab" }}>
72
+ <BaseEdge id={id} path={edgePath} markerEnd={edgeMarker} style={edgeStyle} />
73
+ </g>
74
+ <EdgeLabelRenderer>
75
+ <div
76
+ title={data.value}
77
+ onPointerEnter={onIn}
78
+ onPointerLeave={onOut}
79
+ style={{
80
+ position: "absolute",
81
+ transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
82
+ background: data.bg ?? "white",
83
+ border: `1px solid ${highlight ? HOVER_COLOR : "#bbb"}`,
84
+ borderRadius: 3,
85
+ padding: "2px 5px",
86
+ fontSize: 10,
87
+ lineHeight: "12px",
88
+ color: "#444",
89
+ pointerEvents: "auto",
90
+ cursor: "default",
91
+ maxWidth: 150,
92
+ wordBreak: "break-word",
93
+ whiteSpace: "pre-line",
94
+ display: "-webkit-box",
95
+ WebkitLineClamp: 3,
96
+ WebkitBoxOrient: "vertical",
97
+ overflow: "hidden",
98
+ ...(highlight && { zIndex: 1000, boxShadow: "0 4px 14px rgba(0,0,0,0.35)" }),
99
+ }}
100
+ >{data.value}</div>
101
+ </EdgeLabelRenderer>
102
+ </>
103
+ )
104
+ }
105
+
106
+ // Decorative, non-interactive nodes: a per-column title above each lane and a
107
+ // full-height background band behind the "dedup" columns. They live in the flow
108
+ // coordinate space so they pan/zoom in step with the real nodes.
109
+ function HeaderNode({ data, style }) {
110
+ // A title's first line is the heading; any line(s) after a newline (e.g. the
111
+ // schema:Class under a lane name) render smaller and muted.
112
+ const [main, ...rest] = String(data.title).split("\n")
113
+ return (
114
+ <div style={{ ...style, textAlign: "center", fontSize: 14, fontWeight: 400, color: "#555", lineHeight: 1.3, pointerEvents: "none", ...data.hstyle }}>
115
+ {main}
116
+ {rest.length > 0 && <div style={{ fontSize: 11, color: "#888" }}>{rest.join(" ")}</div>}
117
+ </div>
118
+ )
119
+ }
120
+ function BandNode({ style }) { return <div style={{ ...style, pointerEvents: "none" }} /> }
121
+
122
+ const REL_COLOR = "#9333ea"
123
+ const nodeTypes = { sideNode: SideNode, headerNode: HeaderNode, bandNode: BandNode }
124
+ const edgeTypes = { value: ValueEdge }
125
+
126
+ function toFlow({ nodes, edges }, columns, colors, centerColumns, direction, colSpacing, siblingGap, nodeWidth, columnTitles, columnBands, nodeY, columnHeaderStyle) {
127
+ const isVertical = direction === "vertical"
128
+ const centered = new Set(centerColumns ?? [])
129
+ const buckets = Object.fromEntries(columns.map((c) => [c, []]))
130
+ for (const n of nodes) (buckets[n.type] ??= []).push(n)
131
+
132
+ const maxColSize = Math.max(...columns.map((c) => buckets[c]?.length ?? 0))
133
+ // Logical layout in (col-axis, sibling-axis) coords; swapped at the end for vertical mode.
134
+ const positions = new Map()
135
+
136
+ if (nodeY) {
137
+ // Caller supplies the sibling-axis coord per node (e.g. a tree layout);
138
+ // the column still fixes the col-axis coord.
139
+ columns.forEach((col, colIdx) => {
140
+ for (const n of buckets[col] ?? []) positions.set(n.id, { x: colIdx * colSpacing, y: nodeY.get(n.id) ?? 0 })
141
+ })
142
+ } else columns.forEach((col, colIdx) => {
143
+ const x = colIdx * colSpacing
144
+ const colNodes = buckets[col] ?? []
145
+ if (centered.has(col)) {
146
+ // Position each node at the average sibling-axis coord of its incoming neighbours,
147
+ // sorted so we can push later nodes down to avoid overlap.
148
+ const incomingYs = new Map()
149
+ for (const e of edges) {
150
+ if (e.sideInput) continue // side inputs feed a step without pulling its centering
151
+ const fromPos = positions.get(e.from)
152
+ if (!fromPos) continue
153
+ if (!incomingYs.has(e.to)) incomingYs.set(e.to, [])
154
+ incomingYs.get(e.to).push(fromPos.y)
155
+ }
156
+ const ranked = colNodes.map((n) => {
157
+ const ys = incomingYs.get(n.id) ?? []
158
+ const target = ys.length ? ys.reduce((a, b) => a + b, 0) / ys.length : 0
159
+ return { node: n, target }
160
+ }).sort((a, b) => a.target - b.target)
161
+ let lastY = -Infinity
162
+ for (const { node, target } of ranked) {
163
+ const y = Math.max(target, lastY + siblingGap)
164
+ positions.set(node.id, { x, y })
165
+ lastY = y
166
+ }
167
+ } else {
168
+ const yOffset = ((maxColSize - colNodes.length) / 2) * siblingGap
169
+ colNodes.forEach((n, i) => {
170
+ positions.set(n.id, { x, y: yOffset + i * siblingGap })
171
+ })
172
+ }
173
+ })
174
+
175
+ // A side-input source (e.g. the Match step's knowledge graph) is parked one
176
+ // sibling-gap beside the step it feeds — where a sibling of that step would
177
+ // sit — so its edge stays short instead of trailing in from the column edge.
178
+ for (const e of edges) {
179
+ if (!e.sideInput) continue
180
+ const tgt = positions.get(e.to)
181
+ const src = positions.get(e.from)
182
+ if (tgt && src) positions.set(e.from, { x: src.x, y: tgt.y + siblingGap })
183
+ }
184
+
185
+ const targetPos = isVertical ? Position.Top : Position.Left
186
+ const sourcePos = isVertical ? Position.Bottom : Position.Right
187
+
188
+ const flowNodes = []
189
+ for (const n of nodes) {
190
+ const pos = positions.get(n.id)
191
+ if (!pos) continue
192
+ flowNodes.push({
193
+ id: n.id,
194
+ type: "sideNode",
195
+ position: isVertical ? { x: pos.y, y: pos.x } : pos,
196
+ data: { label: n.label, subtitle: n.subtitle, props: n.props, targetPos, sourcePos },
197
+ style: {
198
+ background: n.color ?? colors[n.type] ?? "#eee",
199
+ border: `1px ${n.dashed ? "dashed" : "solid"} ${n.borderColor ?? "#888"}`,
200
+ borderRadius: 4,
201
+ fontSize: 12,
202
+ padding: 6,
203
+ width: nodeWidth,
204
+ },
205
+ })
206
+ }
207
+
208
+ // Column headers + background bands (horizontal layouts only) — decorative
209
+ // nodes spanning the full node range, drawn behind (band) / above (header).
210
+ if (!isVertical && (columnTitles || columnBands)) {
211
+ const ys = [...positions.values()].map((p) => p.y)
212
+ const minY = Math.min(...ys), maxY = Math.max(...ys)
213
+ columns.forEach((col, colIdx) => {
214
+ const x = colIdx * colSpacing
215
+ if (columnBands?.[col]) flowNodes.unshift({
216
+ id: `__band_${col}`, type: "bandNode", position: { x: x - 16, y: minY - 66 },
217
+ draggable: false, selectable: false, zIndex: -1, data: {},
218
+ style: { width: nodeWidth + 32, height: (maxY - minY) + 138, background: columnBands[col], borderRadius: 10 },
219
+ })
220
+ if (columnTitles?.[col]) flowNodes.push({
221
+ id: `__hdr_${col}`, type: "headerNode", position: { x, y: minY - 56 },
222
+ draggable: false, selectable: false, zIndex: 6, data: { title: columnTitles[col], hstyle: columnHeaderStyle?.[col] },
223
+ style: { width: nodeWidth },
224
+ })
225
+ })
226
+ }
227
+
228
+ const flowEdges = edges.map((e, i) => {
229
+ const base = { id: `e-${i}`, source: e.from, target: e.to, markerEnd: { type: MarkerType.ArrowClosed } }
230
+ if (e.value !== undefined) { base.type = "value"; base.data = { value: e.value, idx: i, bg: e.valueBg, centered: e.centered } }
231
+ if (e.rel) {
232
+ base.style = { stroke: REL_COLOR, strokeWidth: 1.5 }
233
+ base.markerEnd = { type: MarkerType.ArrowClosed, color: REL_COLOR }
234
+ base.zIndex = 4
235
+ }
236
+ return base
237
+ })
238
+
239
+ return { flowNodes, flowEdges }
240
+ }
241
+
242
+ export default function ColumnGraph({ nodes, edges, columns, colors, centerColumns, direction = "horizontal", colSpacing = DEFAULT_COL_SPACING, siblingGap = DEFAULT_SIBLING_GAP, nodeWidth = DEFAULT_NODE_WIDTH, columnTitles, columnBands, nodeY, columnHeaderStyle, onNodeClick }) {
243
+ const { flowNodes, flowEdges } = useMemo(() => toFlow({ nodes, edges }, columns, colors, centerColumns, direction, colSpacing, siblingGap, nodeWidth, columnTitles, columnBands, nodeY, columnHeaderStyle), [nodes, edges, columns, colors, centerColumns, direction, colSpacing, siblingGap, nodeWidth, columnTitles, columnBands, nodeY, columnHeaderStyle])
244
+ const [rfNodes, , onNodesChange] = useNodesState(flowNodes)
245
+ const [rfEdges, setRfEdges, onEdgesChange] = useEdgesState(flowEdges)
246
+ const [draggingId, setDraggingId] = useState(null)
247
+ const [hoveredEdge, setHoveredEdge] = useState(null)
248
+ const hoverCtx = useMemo(() => ({ id: hoveredEdge, set: setHoveredEdge }), [hoveredEdge])
249
+ // Sync edges when value labels change (e.g. selecting a different org) so
250
+ // the user keeps any node positions they've dragged.
251
+ useEffect(() => { setRfEdges(flowEdges) }, [flowEdges, setRfEdges])
252
+
253
+ const styledEdges = useMemo(() => rfEdges.map((e) => {
254
+ const attached = e.source === draggingId || e.target === draggingId
255
+ return attached
256
+ ? { ...e, style: { stroke: "#ff6a00", strokeWidth: 2 }, markerEnd: { type: MarkerType.ArrowClosed, color: "#ff6a00" }, zIndex: 1000, data: { ...e.data, attached: true } }
257
+ : e
258
+ }), [rfEdges, draggingId])
259
+
260
+ const onInit = async (instance) => {
261
+ await instance.fitView()
262
+ const { x, zoom } = instance.getViewport()
263
+ const minY = Math.min(...instance.getNodes().map((n) => n.position.y))
264
+ instance.setViewport({ x, y: 20 - minY * zoom, zoom })
265
+ }
266
+
267
+ return (
268
+ <HoveredEdgeContext.Provider value={hoverCtx}>
269
+ {/* Differentiate cursors: pointer on draggable nodes, grab on
270
+ edges (matching the canvas pan), so the open hand doesn't
271
+ show indiscriminately on every hover. */}
272
+ <style>{`.react-flow__node{cursor:pointer!important;}.react-flow__edge{cursor:grab!important;}`}</style>
273
+ <ReactFlow
274
+ nodes={rfNodes}
275
+ edges={styledEdges}
276
+ onNodesChange={onNodesChange}
277
+ onEdgesChange={onEdgesChange}
278
+ onNodeDragStart={(_, n) => setDraggingId(n.id)}
279
+ onNodeDragStop={() => setDraggingId(null)}
280
+ onNodeClick={onNodeClick}
281
+ nodeTypes={nodeTypes}
282
+ edgeTypes={edgeTypes}
283
+ onInit={onInit}
284
+ >
285
+ <Background />
286
+ <Controls showInteractive={false} />
287
+ </ReactFlow>
288
+ </HoveredEdgeContext.Provider>
289
+ )
290
+ }
@@ -0,0 +1,15 @@
1
+ // Consumer-facing directory: one compact card per resolved organisation.
2
+ // Reads: finalOrgs from mergeOrgs.js (← data/pipeline/final.ttl)
3
+ // Does: renders the Directory page (list of compact <OrgCard>)
4
+
5
+ import OrgCard from "./OrgCard.jsx"
6
+ import { finalOrgs } from "./mergeOrgs.js"
7
+ import React from "react"
8
+
9
+ export default function Directory() {
10
+ return (
11
+ <div className="page" style={{ overflowY: "auto", height: "100%" }}>
12
+ {finalOrgs.map((org) => <OrgCard key={org.iri} org={org} compact={true} highlight={false} />)}
13
+ </div>
14
+ )
15
+ }
@@ -0,0 +1,174 @@
1
+ // Download view: choose target fields + format, or an external-schema export.
2
+ // Reads: config/federation.ttl, data/pipeline/final.ttl, and the exporters
3
+ // the federation declares via :hasExporter (instance-owned modules at
4
+ // webapp/exporters/<name>.js, dynamic-imported at runtime like config/data)
5
+ // Does: triggers a browser download (.ttl / .jsonld / .json / .csv, or an
6
+ // exporter's external-schema file)
7
+
8
+ import { datasetToTurtleWriter, storeFromTurtles } from "@foerderfunke/sem-ops-utils/core"
9
+ import { turtleToJsonLdObj } from "@foerderfunke/sem-ops-utils/jsonld"
10
+ import { sparqlSelect } from "@foerderfunke/sem-ops-utils/sparql"
11
+ import { CDP, groupBySubject, localName, objectsOf, parseTtl, PATHS, shrink, subjectsOfType } from "@directory-builder/core/utils"
12
+ import { displayPrefixes, federationTtl, finalTtl } from "./instanceData.js"
13
+ import React, { useState } from "react"
14
+
15
+ const SCHEMA_IDENTIFIER = "http://schema.org/identifier"
16
+
17
+ function readTargetFields() {
18
+ const quads = parseTtl(federationTtl)
19
+ const isTargetField = subjectsOfType(quads, `${CDP}TargetField`)
20
+ const fieldOrder = []
21
+ const seen = new Set()
22
+ const predicateOf = new Map()
23
+ for (const q of quads) {
24
+ if (q.predicate.value === `${CDP}hasTargetField`) {
25
+ if (!seen.has(q.object.value)) { seen.add(q.object.value); fieldOrder.push(q.object.value) }
26
+ } else if (q.predicate.value === `${CDP}targetPredicate`) {
27
+ predicateOf.set(q.subject.value, q.object.value)
28
+ }
29
+ }
30
+ return fieldOrder
31
+ .filter((iri) => isTargetField.has(iri) && predicateOf.has(iri))
32
+ .map((iri) => ({ predicate: predicateOf.get(iri), label: shrink(predicateOf.get(iri), displayPrefixes) }))
33
+ .filter((f) => f.predicate !== SCHEMA_IDENTIFIER)
34
+ }
35
+
36
+ const FINAL_QUADS = parseTtl(finalTtl)
37
+ // Only offer target fields that actually carry data in final.ttl —
38
+ // declared-but-unmapped fields would just download as empty columns.
39
+ const PREDICATES_WITH_DATA = new Set(FINAL_QUADS.map((q) => q.predicate.value))
40
+ const TARGET_FIELDS = readTargetFields().filter((f) => PREDICATES_WITH_DATA.has(f.predicate))
41
+
42
+ const FORMATS = [
43
+ { value: "ttl", label: "Turtle (.ttl)", ext: "ttl", mime: "text/turtle" },
44
+ { value: "jsonld", label: "JSON-LD (.jsonld)", ext: "jsonld", mime: "application/ld+json" },
45
+ { value: "json", label: "JSON (.json)", ext: "json", mime: "application/json" },
46
+ { value: "csv", label: "CSV (.csv)", ext: "csv", mime: "text/csv" },
47
+ ]
48
+
49
+ const csvEscape = (v) => /[",\n\r]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v
50
+
51
+ function buildCsv(quads, fields) {
52
+ const bySubject = groupBySubject(quads)
53
+ const header = ["iri", ...fields.map((f) => f.label)]
54
+ const lines = [header.map(csvEscape).join(",")]
55
+ for (const [s, row] of bySubject) {
56
+ const cells = [s, ...fields.map((f) => (row.get(f.predicate) ?? []).join("; "))]
57
+ lines.push(cells.map(csvEscape).join(","))
58
+ }
59
+ return lines.join("\n") + "\n"
60
+ }
61
+
62
+ function buildJson(quads, fields) {
63
+ const out = []
64
+ for (const [s, row] of groupBySubject(quads)) {
65
+ const obj = { iri: s }
66
+ for (const f of fields) {
67
+ const vals = row.get(f.predicate)
68
+ if (!vals) continue
69
+ obj[f.label] = vals.length === 1 ? vals[0] : vals
70
+ }
71
+ out.push(obj)
72
+ }
73
+ return JSON.stringify(out, null, 2)
74
+ }
75
+
76
+ async function buildFile(selectedFields, format) {
77
+ const allowed = new Set(selectedFields.map((f) => f.predicate))
78
+ const filtered = FINAL_QUADS.filter((q) => allowed.has(q.predicate.value))
79
+ if (format === "csv") return buildCsv(filtered, selectedFields)
80
+ if (format === "json") return buildJson(filtered, selectedFields)
81
+ const ttl = await datasetToTurtleWriter(filtered, displayPrefixes)
82
+ if (format === "ttl") return ttl
83
+ const jsonld = await turtleToJsonLdObj(ttl)
84
+ return JSON.stringify(jsonld, null, 2)
85
+ }
86
+
87
+ function triggerDownload(content, mime, filename) {
88
+ const url = URL.createObjectURL(new Blob([content], { type: mime }))
89
+ const a = document.createElement("a")
90
+ a.href = url
91
+ a.download = filename
92
+ a.click()
93
+ URL.revokeObjectURL(url)
94
+ }
95
+
96
+ // Exporters are instance code, not part of this app: the federation declares
97
+ // them by name (:hasExporter "x" → webapp/exporters/x.js in the instance),
98
+ // and each module exports { label, filename, mime, build }. Bare imports can't
99
+ // resolve in a runtime-loaded module, so build() receives a toolkit instead.
100
+ const TOOLKIT = { sparqlSelect, storeFromTurtles, parseTtl, localName, shrink, groupBySubject }
101
+
102
+ const EXTERNAL_TARGETS = (await Promise.all(
103
+ objectsOf(parseTtl(federationTtl), `${CDP}hasExporter`).map(async (name) => {
104
+ const mod = await import(/* @vite-ignore */ `${import.meta.env.BASE_URL}${PATHS.exporter(name)}`)
105
+ .catch((e) => { console.error(`exporter ${name} failed to load`, e); return null })
106
+ return mod && {
107
+ value: name,
108
+ label: mod.label ?? name,
109
+ filename: mod.filename ?? `${name}.json`,
110
+ mime: mod.mime ?? "application/json",
111
+ build: () => mod.build(finalTtl, TOOLKIT),
112
+ }
113
+ }),
114
+ )).filter(Boolean)
115
+
116
+ export default function Download() {
117
+ const [selected, setSelected] = useState(() => new Set(TARGET_FIELDS.map((f) => f.predicate)))
118
+ const [format, setFormat] = useState("ttl")
119
+ const [externalTarget, setExternalTarget] = useState(EXTERNAL_TARGETS[0]?.value)
120
+
121
+ const toggle = (pred) => {
122
+ const next = new Set(selected)
123
+ if (next.has(pred)) next.delete(pred); else next.add(pred)
124
+ setSelected(next)
125
+ }
126
+
127
+ const onDownload = async () => {
128
+ const fmt = FORMATS.find((f) => f.value === format)
129
+ const fields = TARGET_FIELDS.filter((f) => selected.has(f.predicate))
130
+ const content = await buildFile(fields, format)
131
+ triggerDownload(content, fmt.mime, `final.${fmt.ext}`)
132
+ }
133
+
134
+ const onDownloadExternal = async () => {
135
+ const target = EXTERNAL_TARGETS.find((t) => t.value === externalTarget)
136
+ triggerDownload(await target.build(), target.mime, target.filename)
137
+ }
138
+
139
+ return (
140
+ <div className="page" style={{ fontSize: 14 }}>
141
+ <h3 style={{ margin: "0 0 0.75rem" }}>Federated directory</h3>
142
+ <div style={{ marginBottom: "0.5rem" }}>Fields to include:</div>
143
+ <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))", columnGap: "1rem", rowGap: "0.25rem" }}>
144
+ {TARGET_FIELDS.map((f) => (
145
+ <label key={f.predicate} style={{ display: "inline-flex", alignItems: "center", gap: "0.5rem" }}>
146
+ <input type="checkbox" checked={selected.has(f.predicate)} onChange={() => toggle(f.predicate)} />
147
+ <code>{f.label}</code>
148
+ </label>
149
+ ))}
150
+ </div>
151
+ <div style={{ display: "flex", alignItems: "center", gap: "0.75rem", marginTop: "1rem" }}>
152
+ <label style={{ display: "inline-flex", alignItems: "center", gap: "0.5rem" }}>
153
+ Format:
154
+ <select value={format} onChange={(e) => setFormat(e.target.value)}>
155
+ {FORMATS.map((f) => <option key={f.value} value={f.value}>{f.label}</option>)}
156
+ </select>
157
+ </label>
158
+ <button onClick={onDownload} disabled={selected.size === 0}>Download</button>
159
+ </div>
160
+
161
+ {EXTERNAL_TARGETS.length > 0 && <>
162
+ <hr style={{ margin: "1.5rem 0", border: 0, borderTop: "1px solid #ddd" }} />
163
+
164
+ <h3 style={{ margin: "0 0 0.75rem" }}>Map to other schema</h3>
165
+ <div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
166
+ <select value={externalTarget} onChange={(e) => setExternalTarget(e.target.value)}>
167
+ {EXTERNAL_TARGETS.map((t) => <option key={t.value} value={t.value}>{t.label}</option>)}
168
+ </select>
169
+ <button onClick={onDownloadExternal}>Download</button>
170
+ </div>
171
+ </>}
172
+ </div>
173
+ )
174
+ }