@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.
- package/LICENSE +21 -0
- package/README.md +112 -0
- package/bin/cli.js +38 -0
- package/example/README.md +64 -0
- package/example/config/federation.ttl +136 -0
- package/example/config/match-knowledge.ttl +8 -0
- package/example/sources/cityopen/clean.sparql +17 -0
- package/example/sources/cityopen/fetch.js +14 -0
- package/example/sources/cityopen/static/libraries.json +32 -0
- package/example/sources/civichub/clean.sparql +34 -0
- package/example/sources/civichub/fetch.js +14 -0
- package/example/sources/civichub/static/libraries.json +38 -0
- package/package.json +38 -0
- package/src/federate.js +571 -0
- package/src/index.js +6 -0
- package/src/ingest.js +158 -0
- package/src/lift/html.sparql +12 -0
- package/src/lift/json.sparql +12 -0
- package/src/pipeline.js +16 -0
- package/src/utils.js +152 -0
- package/src/webapp.js +41 -0
- package/webapp/index.html +11 -0
- package/webapp/src/About.jsx +24 -0
- package/webapp/src/App.jsx +96 -0
- package/webapp/src/Card.jsx +32 -0
- package/webapp/src/ColumnGraph.jsx +290 -0
- package/webapp/src/Directory.jsx +15 -0
- package/webapp/src/Download.jsx +174 -0
- package/webapp/src/MapGraph.jsx +244 -0
- package/webapp/src/MatchGraph.jsx +137 -0
- package/webapp/src/MergeTables.jsx +61 -0
- package/webapp/src/OrgCard.jsx +126 -0
- package/webapp/src/Pipeline.jsx +41 -0
- package/webapp/src/Query.jsx +165 -0
- package/webapp/src/Sources.jsx +52 -0
- package/webapp/src/instanceData.js +35 -0
- package/webapp/src/loadMap.js +276 -0
- package/webapp/src/loadMatch.js +228 -0
- package/webapp/src/loadMerge.js +93 -0
- package/webapp/src/loadPipeline.js +130 -0
- package/webapp/src/loadSources.js +102 -0
- package/webapp/src/main.jsx +9 -0
- package/webapp/src/mergeOrgs.js +15 -0
- package/webapp/src/sourceMeta.js +81 -0
- package/webapp/src/styles.css +23 -0
- package/webapp/vite.config.js +14 -0
- 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
|
+
}
|