sol-components 2.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/README.md +7 -0
- package/core/activate.js +27 -0
- package/core/adopt.js +71 -0
- package/core/auth-core.js +73 -0
- package/core/auth-fetch.js +154 -0
- package/core/component-mount.js +110 -0
- package/core/defaults.js +48 -0
- package/core/define.js +15 -0
- package/core/display-target.js +166 -0
- package/core/edit-placements.js +28 -0
- package/core/editor-self.js +127 -0
- package/core/editor.js +162 -0
- package/core/events.js +27 -0
- package/core/extension-points.js +189 -0
- package/core/form-utils.js +210 -0
- package/core/from-query.js +138 -0
- package/core/from-rdf.js +52 -0
- package/core/here.js +33 -0
- package/core/include-core.js +73 -0
- package/core/inrupt-global.js +18 -0
- package/core/menu-consumer.js +41 -0
- package/core/menu-rdf.js +154 -0
- package/core/pod-ops.js +392 -0
- package/core/pod-registry.js +82 -0
- package/core/popup-proxy.js +255 -0
- package/core/rdf-core.js +280 -0
- package/core/rdf-render.js +136 -0
- package/core/rdf-utils.js +411 -0
- package/core/rdf.js +154 -0
- package/core/services.js +106 -0
- package/core/shape-to-form.js +741 -0
- package/core/sparql-safety.js +20 -0
- package/core/utils.js +196 -0
- package/dist/importmap-cdn.json +49 -0
- package/dist/importmap-local.json +49 -0
- package/dist/sol-loader.manifest.json +140 -0
- package/dist/vendor/@comunica-query-sparql.js +137851 -0
- package/dist/vendor/@inrupt-solid-client-authn-browser.js +7503 -0
- package/dist/vendor/dompurify.js +1476 -0
- package/dist/vendor/ical.js.js +9739 -0
- package/dist/vendor/marked.js +85 -0
- package/dist/vendor/n3.js +14670 -0
- package/dist/vendor/rdf-validate-shacl.js +6970 -0
- package/dist/vendor/rdflib.js +35172 -0
- package/dist/vendor/solid-logic.js +6819 -0
- package/dist/vendor/solid-ui.js +21945 -0
- package/node/sol-form.js +133 -0
- package/node/sol-include.js +55 -0
- package/node/sol-login.js +632 -0
- package/node/sol-menu.js +639 -0
- package/node/sol-query.js +116 -0
- package/package.json +133 -0
- package/web/menu-from-rdf.js +23 -0
- package/web/scripts/prefs.js +25 -0
- package/web/sol-accordion.js +114 -0
- package/web/sol-basic.js +50 -0
- package/web/sol-breadcrumb.js +131 -0
- package/web/sol-button.js +244 -0
- package/web/sol-calendar.js +465 -0
- package/web/sol-default.js +118 -0
- package/web/sol-dropdown-button.js +222 -0
- package/web/sol-feed.js +1336 -0
- package/web/sol-form.js +949 -0
- package/web/sol-full.js +43 -0
- package/web/sol-gallery.js +303 -0
- package/web/sol-include.js +246 -0
- package/web/sol-live-edit.js +415 -0
- package/web/sol-login.js +856 -0
- package/web/sol-menu.js +593 -0
- package/web/sol-modal.js +377 -0
- package/web/sol-pod-extras.js +17 -0
- package/web/sol-pod-ops.js +680 -0
- package/web/sol-pod.js +1039 -0
- package/web/sol-query.js +546 -0
- package/web/sol-rolodex.js +95 -0
- package/web/sol-search.js +402 -0
- package/web/sol-settings.js +199 -0
- package/web/sol-solidos.js +93 -0
- package/web/sol-tabs.js +445 -0
- package/web/sol-time.js +194 -0
- package/web/sol-tree-edit.js +492 -0
- package/web/sol-wac.js +456 -0
- package/web/sol-weather.js +337 -0
- package/web/sol-window.js +142 -0
- package/web/styles/buttons-css.js +108 -0
- package/web/styles/help.css +242 -0
- package/web/styles/root.css +112 -0
- package/web/styles/sol-accordion-css.js +97 -0
- package/web/styles/sol-calendar-css.js +154 -0
- package/web/styles/sol-feed-css.js +475 -0
- package/web/styles/sol-form-css.js +471 -0
- package/web/styles/sol-gallery-css.js +181 -0
- package/web/styles/sol-include-css.js +95 -0
- package/web/styles/sol-live-edit-css.js +84 -0
- package/web/styles/sol-live-edit.css +101 -0
- package/web/styles/sol-login-css.js +116 -0
- package/web/styles/sol-menu-css.js +145 -0
- package/web/styles/sol-modal-css.js +134 -0
- package/web/styles/sol-pod-css.js +187 -0
- package/web/styles/sol-pod-modal-css.js +203 -0
- package/web/styles/sol-query-css.js +140 -0
- package/web/styles/sol-query-help.css +267 -0
- package/web/styles/sol-query-one-pager.css +67 -0
- package/web/styles/sol-search-css.js +157 -0
- package/web/styles/sol-solidos-css.js +7 -0
- package/web/styles/sol-tabs-css.js +114 -0
- package/web/styles/sol-time-css.js +30 -0
- package/web/styles/sol-wac-css.js +73 -0
- package/web/styles/sol-weather-css.js +59 -0
- package/web/styles/solid-logo.svg +9 -0
- package/web/styles/view-accordion-css.js +66 -0
- package/web/styles/view-anchorlist-css.js +22 -0
- package/web/styles/view-autocomplete-css.js +59 -0
- package/web/styles/view-rolodex-css.js +102 -0
- package/web/styles/view-select-css.js +21 -0
- package/web/utils/calendar-fetch.js +388 -0
- package/web/utils/code-mirror-editor.js +82 -0
- package/web/utils/commons-fetch.js +108 -0
- package/web/utils/feed-edit.js +159 -0
- package/web/utils/feed-edit.smoke.mjs +74 -0
- package/web/utils/feed-fetch.js +573 -0
- package/web/utils/live-edit-help/csv.js +64 -0
- package/web/utils/live-edit-help/graphviz.js +41 -0
- package/web/utils/live-edit-help/jsonld.js +55 -0
- package/web/utils/live-edit-help/markdown.js +52 -0
- package/web/utils/live-edit-help/mermaid.js +48 -0
- package/web/utils/live-edit-help/turtle.js +85 -0
- package/web/utils/rdf-config.js +125 -0
- package/web/utils/renderers/csv.js +124 -0
- package/web/utils/renderers/d3-force.js +82 -0
- package/web/utils/renderers/graphviz.js +13 -0
- package/web/utils/renderers/html.js +10 -0
- package/web/utils/renderers/jsonld.js +63 -0
- package/web/utils/renderers/markdown.js +19 -0
- package/web/utils/renderers/mermaid.js +54 -0
- package/web/utils/renderers/turtle.js +51 -0
- package/web/utils/sol-query-triple-patterns.js +151 -0
- package/web/utils/sol-query-ui.js +250 -0
- package/web/utils/sol-query-views.js +32 -0
- package/web/views/_helpers.js +34 -0
- package/web/views/accordion.js +133 -0
- package/web/views/anchorlist.js +59 -0
- package/web/views/auto-complete.js +183 -0
- package/web/views/dl.js +38 -0
- package/web/views/list.js +19 -0
- package/web/views/menu.js +56 -0
- package/web/views/rolodex.js +126 -0
- package/web/views/select.js +79 -0
- package/web/views/table.js +73 -0
- package/web/views/tabs.js +57 -0
|
@@ -0,0 +1,741 @@
|
|
|
1
|
+
// shape-to-form — turn a SHACL shape into an editable form.
|
|
2
|
+
//
|
|
3
|
+
// Pure functions; no DOM ownership beyond the rendering layer, which
|
|
4
|
+
// delegates to solid-ui's form widgets. The intended consumer pattern is:
|
|
5
|
+
//
|
|
6
|
+
// const { targets, properties } = parseShape(shapeText, shapeUri);
|
|
7
|
+
// const subjects = findSubjects(store, targets, dataDoc);
|
|
8
|
+
// const cleanup = renderRecordForm(container, store, subjects[0], properties, {
|
|
9
|
+
// doc, onChange: (subj) => { /* persist however the host wants */ },
|
|
10
|
+
// });
|
|
11
|
+
//
|
|
12
|
+
// Rendering goes through solid-ui's `window.UI.widgets.fieldFunction`
|
|
13
|
+
// for consistency with every other form on the page (sol-form's legacy
|
|
14
|
+
// form-driven path, the menu editor when it used menu-form.ttl, etc.).
|
|
15
|
+
// shape-to-form builds a synthetic ui:Form node in the data store per
|
|
16
|
+
// render, hands it to solid-ui, and collects a cleanup function that
|
|
17
|
+
// removes the synthesized triples on teardown.
|
|
18
|
+
//
|
|
19
|
+
// Mapping (SHACL → ui:* field type):
|
|
20
|
+
// sh:nodeKind sh:IRI → ui:NamedNodeURIField
|
|
21
|
+
// sh:datatype xsd:integer → ui:IntegerField
|
|
22
|
+
// sh:datatype xsd:decimal → ui:DecimalField
|
|
23
|
+
// sh:datatype xsd:boolean → ui:BooleanField
|
|
24
|
+
// sh:datatype xsd:date → ui:DateField
|
|
25
|
+
// sh:datatype xsd:dateTime → ui:DateTimeField
|
|
26
|
+
// sh:datatype xsd:anyURI → ui:NamedNodeURIField
|
|
27
|
+
// sh:datatype xsd:string / fallback → ui:SingleLineTextField
|
|
28
|
+
//
|
|
29
|
+
// sh:in (IRIs with rdfs:label) → ui:Choice + ui:from pointing at a
|
|
30
|
+
// synthesized rdfs:Class whose
|
|
31
|
+
// instances are the listed URIs;
|
|
32
|
+
// labels propagate via the rdfs:label
|
|
33
|
+
// already declared in the shape.
|
|
34
|
+
// sh:in (literals) → ui:SingleLineTextField fallback —
|
|
35
|
+
// solid-ui's Choice doesn't model
|
|
36
|
+
// literal-instance enums. Authors who
|
|
37
|
+
// need a dropdown should declare the
|
|
38
|
+
// options as URIs with rdfs:label.
|
|
39
|
+
//
|
|
40
|
+
// sh:maxCount > 1 / unbounded → wrapped in ui:Multiple, with the
|
|
41
|
+
// ui:* field above as ui:part.
|
|
42
|
+
// sh:name → ui:label
|
|
43
|
+
// sh:minCount 1 → ui:required true
|
|
44
|
+
//
|
|
45
|
+
// Read-only mode (`opts.readOnly`) is wired via `store.updater.editable`
|
|
46
|
+
// — solid-ui's fields respect that flag and render as non-editable.
|
|
47
|
+
|
|
48
|
+
import { rdf } from './rdf.js';
|
|
49
|
+
|
|
50
|
+
const SH = 'http://www.w3.org/ns/shacl#';
|
|
51
|
+
const RDF_NS = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#';
|
|
52
|
+
const RDFS_NS = 'http://www.w3.org/2000/01/rdf-schema#';
|
|
53
|
+
const UI = 'http://www.w3.org/ns/ui#';
|
|
54
|
+
const XSD = 'http://www.w3.org/2001/XMLSchema#';
|
|
55
|
+
const OWL = 'http://www.w3.org/2002/07/owl#';
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Parse a SHACL document into a normalized descriptor list.
|
|
59
|
+
* Pure / sync. Throws if the SHACL fails to parse.
|
|
60
|
+
*
|
|
61
|
+
* @param {string} shapeText raw turtle of the SHACL document
|
|
62
|
+
* @param {string} baseUri base URI used to resolve relative refs in the doc
|
|
63
|
+
* @returns {{ targets: Targets, properties: ShapeProp[] }}
|
|
64
|
+
*/
|
|
65
|
+
export async function parseShape(shapeText, baseUri, ctx = {}) {
|
|
66
|
+
const abs = baseUri
|
|
67
|
+
? new URL(baseUri, typeof document !== 'undefined' ? document.baseURI : 'file:///').href
|
|
68
|
+
: '';
|
|
69
|
+
const shapeStore = rdf.graph();
|
|
70
|
+
rdf.parse(shapeText, shapeStore, abs, 'text/turtle');
|
|
71
|
+
await followOwlImports(shapeStore, abs);
|
|
72
|
+
|
|
73
|
+
// Shape selection (in priority order):
|
|
74
|
+
// 1. If ctx supplies subject + dataStore: prefer the shape whose
|
|
75
|
+
// sh:targetClass matches one of the subject's rdf:type values.
|
|
76
|
+
// This is the "outer shape applies to user data" case (e.g.,
|
|
77
|
+
// schema:ItemList → SearchEnginesShape).
|
|
78
|
+
// 2. The file/topic wrapper pattern: file-shape uses sh:node to
|
|
79
|
+
// point at a topic-shape carrying the actual property
|
|
80
|
+
// constraints (e.g., DataKitchenSettingsFile → settings topic
|
|
81
|
+
// shape via foaf:primaryTopic). Prefer the sh:node-referenced
|
|
82
|
+
// shape — the file-shape's only property is the walker.
|
|
83
|
+
// 3. Fallback: first NodeShape in the file.
|
|
84
|
+
const allShapes = shapeStore.each(null,
|
|
85
|
+
rdf.sym(RDF_NS + 'type'),
|
|
86
|
+
rdf.sym(SH + 'NodeShape'));
|
|
87
|
+
if (!allShapes.length) {
|
|
88
|
+
return { targets: { nodes: [], classes: [], subjectsOf: [] }, properties: [] };
|
|
89
|
+
}
|
|
90
|
+
let nodeShape = null;
|
|
91
|
+
if (ctx.subject && ctx.dataStore) {
|
|
92
|
+
const subjectTypes = ctx.dataStore.each(ctx.subject, rdf.sym(RDF_NS + 'type'));
|
|
93
|
+
if (subjectTypes.length) {
|
|
94
|
+
nodeShape = allShapes.find(s => {
|
|
95
|
+
const tcs = shapeStore.each(s, rdf.sym(SH + 'targetClass'));
|
|
96
|
+
return tcs.some(tc => subjectTypes.some(t => t.value === tc.value));
|
|
97
|
+
}) || null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
nodeShape ||=
|
|
101
|
+
allShapes.find(s => shapeStore.any(null, rdf.sym(SH + 'node'), s)) ||
|
|
102
|
+
allShapes[0];
|
|
103
|
+
|
|
104
|
+
const targets = {
|
|
105
|
+
nodes: shapeStore.each(nodeShape, rdf.sym(SH + 'targetNode')),
|
|
106
|
+
classes: shapeStore.each(nodeShape, rdf.sym(SH + 'targetClass')),
|
|
107
|
+
subjectsOf: shapeStore.each(nodeShape, rdf.sym(SH + 'targetSubjectsOf')),
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const properties = [];
|
|
111
|
+
for (const prop of shapeStore.each(nodeShape, rdf.sym(SH + 'property'))) {
|
|
112
|
+
const desc = readShapeProperty(shapeStore, prop);
|
|
113
|
+
if (desc) properties.push(desc);
|
|
114
|
+
}
|
|
115
|
+
return { targets, properties };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Follow owl:imports declarations in the shape store, fetching each
|
|
119
|
+
// referenced TTL and parsing it into BOTH the shape store (so
|
|
120
|
+
// shape-to-form's own lookups like sh:class → narrower options work)
|
|
121
|
+
// AND the shared singleton store (so solid-ui's Choice handler can
|
|
122
|
+
// enumerate instances of those classes at render time). Cycle-safe
|
|
123
|
+
// via a visited set; failed fetches are warned and skipped.
|
|
124
|
+
async function followOwlImports(store, baseUri) {
|
|
125
|
+
const seen = new Set(baseUri ? [baseUri] : []);
|
|
126
|
+
const objectsOfImports = () =>
|
|
127
|
+
store.statementsMatching(null, rdf.sym(OWL + 'imports'), null).map(st => st.object);
|
|
128
|
+
const queue = objectsOfImports()
|
|
129
|
+
.map(o => new URL(o.value, baseUri || document.baseURI).href)
|
|
130
|
+
.filter(u => !seen.has(u));
|
|
131
|
+
while (queue.length) {
|
|
132
|
+
const url = queue.shift();
|
|
133
|
+
if (seen.has(url)) continue;
|
|
134
|
+
seen.add(url);
|
|
135
|
+
try {
|
|
136
|
+
const resp = await fetch(url);
|
|
137
|
+
if (!resp.ok) { console.warn(`[shape-to-form] owl:imports HTTP ${resp.status}: ${url}`); continue; }
|
|
138
|
+
const text = await resp.text();
|
|
139
|
+
rdf.parse(text, store, url, 'text/turtle');
|
|
140
|
+
try { rdf.parse(text, rdf.store, url, 'text/turtle'); }
|
|
141
|
+
catch (_) { /* shared store may already have these triples; ignore */ }
|
|
142
|
+
const more = objectsOfImports()
|
|
143
|
+
.map(o => new URL(o.value, url).href)
|
|
144
|
+
.filter(u => !seen.has(u));
|
|
145
|
+
queue.push(...more);
|
|
146
|
+
} catch (err) {
|
|
147
|
+
console.warn(`[shape-to-form] owl:imports ${url}: ${err.message}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Read a single sh:property descriptor from a shape store. Exported so
|
|
154
|
+
* components that parse multi-NodeShape files (e.g. sol-tree-edit, which
|
|
155
|
+
* routes one shape per ui:Component / ui:Link / ui:Menu via
|
|
156
|
+
* sh:targetClass) can reuse the same walker without re-implementing it.
|
|
157
|
+
*
|
|
158
|
+
* Recurses into sh:node when present, populating `nestedProperties` so
|
|
159
|
+
* renderers can synthesise a ui:Group / ui:Multiple for nested data
|
|
160
|
+
* shapes (e.g. a list of schema:PropertyValue pairs).
|
|
161
|
+
*/
|
|
162
|
+
export function readShapeProperty(shapeStore, prop) {
|
|
163
|
+
const pathNode = shapeStore.any(prop, rdf.sym(SH + 'path'));
|
|
164
|
+
if (!pathNode) return null;
|
|
165
|
+
|
|
166
|
+
// SHACL property paths. We handle the two common shapes:
|
|
167
|
+
// sh:path <pred> → forward predicate (path = NamedNode)
|
|
168
|
+
// sh:path [ sh:inversePath <pred> ] → inverse predicate (path = blank node)
|
|
169
|
+
// Sequence / alternative / zeroOrMore paths aren't supported yet.
|
|
170
|
+
let path = pathNode;
|
|
171
|
+
let reverse = false;
|
|
172
|
+
if (pathNode.termType !== 'NamedNode') {
|
|
173
|
+
const inv = shapeStore.any(pathNode, rdf.sym(SH + 'inversePath'));
|
|
174
|
+
if (!inv) return null; // complex path we don't understand
|
|
175
|
+
path = inv;
|
|
176
|
+
reverse = true;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const minCount = parseInt(shapeStore.anyValue(prop, rdf.sym(SH + 'minCount')) ?? '0', 10);
|
|
180
|
+
const maxRaw = shapeStore.anyValue(prop, rdf.sym(SH + 'maxCount'));
|
|
181
|
+
const maxCount = maxRaw == null ? Infinity : parseInt(maxRaw, 10);
|
|
182
|
+
const label = shapeStore.anyValue(prop, rdf.sym(SH + 'name')) ?? null;
|
|
183
|
+
const description = shapeStore.anyValue(prop, rdf.sym(SH + 'description')) ?? null;
|
|
184
|
+
|
|
185
|
+
const dt = shapeStore.any(prop, rdf.sym(SH + 'datatype'));
|
|
186
|
+
const datatype = dt ? dt.value : null;
|
|
187
|
+
|
|
188
|
+
const inList = shapeStore.any(prop, rdf.sym(SH + 'in'));
|
|
189
|
+
let enumOpts = null, enumLabels = null;
|
|
190
|
+
if (inList) {
|
|
191
|
+
const items = collectRdfList(shapeStore, inList);
|
|
192
|
+
enumOpts = items.map(n => ({ value: n.value, termType: n.termType }));
|
|
193
|
+
enumLabels = items.map(n => {
|
|
194
|
+
if (n.termType !== 'NamedNode') return n.value;
|
|
195
|
+
const lbl = shapeStore.anyValue(n, rdf.sym(RDFS_NS + 'label'));
|
|
196
|
+
return lbl || n.value;
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const nk = shapeStore.any(prop, rdf.sym(SH + 'nodeKind'));
|
|
201
|
+
const nodeKind = nk ? nk.value : null;
|
|
202
|
+
|
|
203
|
+
// sh:class — values must be instances of this class. shape-to-form
|
|
204
|
+
// emits ui:from on the synthesized ui:Choice, leaving the runtime
|
|
205
|
+
// enumeration to solid-ui (it walks `kb.each(null, rdf:type, X)`).
|
|
206
|
+
const classNode = shapeStore.any(prop, rdf.sym(SH + 'class')) || null;
|
|
207
|
+
|
|
208
|
+
// sh:node — a nested NodeShape validating the values of this path.
|
|
209
|
+
// Each matching value is a blank/named node carrying its own
|
|
210
|
+
// sh:property entries; the renderer turns this into a ui:Group of
|
|
211
|
+
// sub-fields (wrapped in a ui:Multiple when the outer property is
|
|
212
|
+
// multi-valued).
|
|
213
|
+
const nodeShape = shapeStore.any(prop, rdf.sym(SH + 'node'));
|
|
214
|
+
// ui:sortedBy on a container property — names the inner predicate
|
|
215
|
+
// whose integer value orders the rolodex cards. Renderer hides the
|
|
216
|
+
// named inner field and replaces it with ↑/↓ buttons that swap
|
|
217
|
+
// values with the previous / next subject.
|
|
218
|
+
const sortedBy = shapeStore.any(prop, rdf.sym(UI + 'sortedBy')) || null;
|
|
219
|
+
let nestedProperties = null;
|
|
220
|
+
if (nodeShape) {
|
|
221
|
+
nestedProperties = [];
|
|
222
|
+
for (const subProp of shapeStore.each(nodeShape, rdf.sym(SH + 'property'))) {
|
|
223
|
+
const subDesc = readShapeProperty(shapeStore, subProp);
|
|
224
|
+
if (subDesc) nestedProperties.push(subDesc);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const key = localPart(path.value);
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
path, key, datatype, enumOpts, enumLabels, nodeKind, classNode,
|
|
232
|
+
minCount, maxCount, label, description, nestedProperties, reverse,
|
|
233
|
+
sortedBy,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function localPart(uri) {
|
|
238
|
+
const i = Math.max(uri.lastIndexOf('#'), uri.lastIndexOf('/'));
|
|
239
|
+
return i === -1 ? uri : uri.slice(i + 1);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function collectRdfList(store, head) {
|
|
243
|
+
if (!head) return [];
|
|
244
|
+
if (head.termType === 'Collection' && Array.isArray(head.elements)) {
|
|
245
|
+
return head.elements;
|
|
246
|
+
}
|
|
247
|
+
const FIRST = rdf.sym(RDF_NS + 'first');
|
|
248
|
+
const REST = rdf.sym(RDF_NS + 'rest');
|
|
249
|
+
const NIL = RDF_NS + 'nil';
|
|
250
|
+
const out = [];
|
|
251
|
+
let node = head;
|
|
252
|
+
while (node && node.value !== NIL) {
|
|
253
|
+
const first = store.any(node, FIRST);
|
|
254
|
+
if (first) out.push(first);
|
|
255
|
+
node = store.any(node, REST);
|
|
256
|
+
}
|
|
257
|
+
return out;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Resolve a parsed shape's targets against a data graph → list of
|
|
262
|
+
* subjects the shape covers.
|
|
263
|
+
*/
|
|
264
|
+
export function findSubjects(store, targets, baseDoc = null) {
|
|
265
|
+
const seen = new Set();
|
|
266
|
+
const out = [];
|
|
267
|
+
const add = (n) => { if (n && !seen.has(n.value)) { seen.add(n.value); out.push(n); } };
|
|
268
|
+
|
|
269
|
+
for (const node of targets.nodes) add(node);
|
|
270
|
+
for (const cls of targets.classes) {
|
|
271
|
+
for (const s of store.each(null, rdf.sym(RDF_NS + 'type'), cls, baseDoc)) add(s);
|
|
272
|
+
}
|
|
273
|
+
for (const pred of targets.subjectsOf) {
|
|
274
|
+
for (const st of store.statementsMatching(null, pred, null, baseDoc)) add(st.subject);
|
|
275
|
+
}
|
|
276
|
+
return out;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Render an editable record form for one subject. Builds a synthetic
|
|
281
|
+
* ui:Form in the store, hands it to solid-ui's fieldFunction, and
|
|
282
|
+
* returns a cleanup function that removes the synthesized triples and
|
|
283
|
+
* detaches the rendered widget.
|
|
284
|
+
*
|
|
285
|
+
* @param {HTMLElement} container
|
|
286
|
+
* @param {Object} store rdflib graph (typically rdf.store)
|
|
287
|
+
* @param {Object} subject NamedNode being edited
|
|
288
|
+
* @param {ShapeProp[]} properties from parseShape().properties
|
|
289
|
+
* @param {Object} [opts]
|
|
290
|
+
* @param {Object?} [opts.doc] named-graph for the data (NamedNode)
|
|
291
|
+
* @param {Function} [opts.onChange] called with (subject) after every mutation
|
|
292
|
+
* @param {boolean} [opts.readOnly] render via solid-ui's read-only path
|
|
293
|
+
* @returns {Function} cleanup
|
|
294
|
+
*/
|
|
295
|
+
export function renderRecordForm(container, store, subject, properties, opts = {}) {
|
|
296
|
+
const doc = opts.doc ?? null;
|
|
297
|
+
const onChange = typeof opts.onChange === 'function' ? opts.onChange : () => {};
|
|
298
|
+
const readOnly = !!opts.readOnly;
|
|
299
|
+
|
|
300
|
+
const inner = document.createElement('div');
|
|
301
|
+
inner.className = 'sol-form-shape-fields';
|
|
302
|
+
if (readOnly) inner.classList.add('sol-form-shape-readonly');
|
|
303
|
+
container.appendChild(inner);
|
|
304
|
+
|
|
305
|
+
const fieldFunction = window.UI?.widgets?.fieldFunction
|
|
306
|
+
?? window.UI?.widgets?.forms?.fieldFunction;
|
|
307
|
+
if (typeof fieldFunction !== 'function') {
|
|
308
|
+
inner.innerHTML = '<div class="sol-form-error">solid-ui is not loaded — required for shape-driven forms.</div>';
|
|
309
|
+
return () => { if (inner.parentNode === container) container.removeChild(inner); };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Solid-ui's editable flag governs whether fields render as inputs
|
|
313
|
+
// or as read-only text. Save and restore around the render so other
|
|
314
|
+
// forms aren't affected.
|
|
315
|
+
const origEditable = store.updater?.editable;
|
|
316
|
+
if (readOnly && store.updater) store.updater.editable = () => false;
|
|
317
|
+
|
|
318
|
+
// Each render synthesises ui:Form widget triples. We put them in a
|
|
319
|
+
// SEPARATE named graph (formGraph) rather than `doc`, so that a
|
|
320
|
+
// serialization of `doc` (e.g. sol-form's getTurtle) yields just the
|
|
321
|
+
// user's data — never the form metadata. The cleanup still removes
|
|
322
|
+
// them outright on form teardown.
|
|
323
|
+
const formGraph = rdf.sym('about:sol-form-synth#g');
|
|
324
|
+
const synthesized = [];
|
|
325
|
+
const add = (s, p, o, g = formGraph) => {
|
|
326
|
+
store.add(s, p, o, g);
|
|
327
|
+
synthesized.push({ s, p, o, g });
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
// For each descriptor, build a ui:* field node and hand it (or its
|
|
331
|
+
// ui:Multiple wrapper) to solid-ui. The widgets sit one-per-row in
|
|
332
|
+
// the same container; mixing solid-ui-rendered widgets in one list
|
|
333
|
+
// is supported because each fieldFunction call returns an
|
|
334
|
+
// independent DOM subtree.
|
|
335
|
+
for (const desc of properties) {
|
|
336
|
+
const row = document.createElement('div');
|
|
337
|
+
row.className = 'sol-form-shape-key';
|
|
338
|
+
row.dataset.key = desc.key;
|
|
339
|
+
inner.appendChild(row);
|
|
340
|
+
|
|
341
|
+
const cb = (ok /*, msg */) => {
|
|
342
|
+
if (ok) onChange(subject);
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
// Multi-valued primitive (no sh:node, no sh:in) → render rows
|
|
346
|
+
// ourselves. Workaround for a solid-ui basicField limitation: when
|
|
347
|
+
// wrapped in ui:Multiple, solid-ui passes the *value* as `subject`
|
|
348
|
+
// to the inner field, and basicField then does
|
|
349
|
+
// `kb.any(subject, property, …)` which looks for `<value> path ?`
|
|
350
|
+
// — a triple that doesn't exist for primitive multi-values like
|
|
351
|
+
// `<#All> dct:source <url>`. Result: inputs render empty.
|
|
352
|
+
// Persistence still goes through `store.updater.update` (rdflib's
|
|
353
|
+
// PATCH path) per [[feedback-no-reinvent-saves]].
|
|
354
|
+
const isMulti = desc.maxCount === Infinity || desc.maxCount > 1;
|
|
355
|
+
if (isMulti && !desc.nestedProperties && !desc.enumOpts) {
|
|
356
|
+
renderPrimitiveMulti(row, store, subject, desc, doc, cb, readOnly);
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const fieldNode = buildFieldNode(store, desc, synthesized, formGraph);
|
|
361
|
+
if (!fieldNode) {
|
|
362
|
+
row.textContent = '(unrecognised shape for ' + (desc.label || desc.key) + ')';
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
try {
|
|
367
|
+
const renderFn = fieldFunction(document, fieldNode);
|
|
368
|
+
if (typeof renderFn !== 'function') {
|
|
369
|
+
row.textContent = '(no renderer for ' + (desc.label || desc.key) + ')';
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
const widget = renderFn(document, row, {}, subject, fieldNode, doc, cb);
|
|
373
|
+
if (widget && !row.contains(widget)) row.appendChild(widget);
|
|
374
|
+
|
|
375
|
+
// solid-ui's single-select Choice does NOT autosave on change —
|
|
376
|
+
// only its multiSelect path writes back. For sh:class-driven
|
|
377
|
+
// single-cardinality dropdowns we attach our own change handler
|
|
378
|
+
// that replaces the predicate's value with the picked URI and
|
|
379
|
+
// PUTs the result through updater.update.
|
|
380
|
+
if (!desc.nestedProperties) wireSingleSelectAutosave(row, store, subject, desc.path, doc, cb);
|
|
381
|
+
} catch (err) {
|
|
382
|
+
row.textContent = err.message;
|
|
383
|
+
console.error('[shape-to-form]', err);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return () => {
|
|
388
|
+
// Remove every triple we added during this render so the store
|
|
389
|
+
// doesn't accumulate dead ui:* metadata across renders.
|
|
390
|
+
for (const st of synthesized) {
|
|
391
|
+
if (!st) continue;
|
|
392
|
+
for (const match of store.statementsMatching(st.s, st.p, st.o, st.g).slice()) {
|
|
393
|
+
store.remove(match);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
if (readOnly && store.updater && origEditable !== undefined) {
|
|
397
|
+
store.updater.editable = origEditable;
|
|
398
|
+
}
|
|
399
|
+
if (inner.parentNode === container) container.removeChild(inner);
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Render a multi-valued primitive (no sh:node, no sh:in) as a label
|
|
404
|
+
// + one input row per existing value, with ✕ to remove a value and
|
|
405
|
+
// + to add. Each commit PATCHes via `store.updater.update`.
|
|
406
|
+
//
|
|
407
|
+
// Why this exists: solid-ui's basicField, when wrapped in ui:Multiple,
|
|
408
|
+
// is passed each value as `subject` and then queries
|
|
409
|
+
// `kb.any(subject, property, …)` — wrong for primitive multi-values
|
|
410
|
+
// (the value isn't itself the subject of `property`). See
|
|
411
|
+
// [[feedback-dont-invent-what-exists]] — this is the bug-workaround
|
|
412
|
+
// carve-out (surgical, library data path).
|
|
413
|
+
function renderPrimitiveMulti(row, store, subject, desc, doc, cb, readOnly) {
|
|
414
|
+
const label = document.createElement('label');
|
|
415
|
+
label.className = 'sol-form-shape-multi-label';
|
|
416
|
+
label.textContent = desc.label || desc.key;
|
|
417
|
+
row.appendChild(label);
|
|
418
|
+
|
|
419
|
+
const valueBox = document.createElement('div');
|
|
420
|
+
valueBox.className = 'sol-form-shape-multi-value';
|
|
421
|
+
row.appendChild(valueBox);
|
|
422
|
+
|
|
423
|
+
const list = document.createElement('div');
|
|
424
|
+
list.className = 'sol-form-shape-multi-list';
|
|
425
|
+
valueBox.appendChild(list);
|
|
426
|
+
|
|
427
|
+
const isIRI = desc.nodeKind === SH + 'IRI'
|
|
428
|
+
|| desc.nodeKind === SH + 'IRIOrLiteral'
|
|
429
|
+
|| desc.nodeKind === SH + 'BlankNodeOrIRI'
|
|
430
|
+
|| desc.datatype === XSD + 'anyURI';
|
|
431
|
+
const toTerm = (raw) => {
|
|
432
|
+
const s = String(raw).trim();
|
|
433
|
+
if (!s) return null;
|
|
434
|
+
if (isIRI) {
|
|
435
|
+
try { return rdf.sym(s); } catch (_) { return null; }
|
|
436
|
+
}
|
|
437
|
+
return desc.datatype
|
|
438
|
+
? rdf.literal(s, undefined, rdf.sym(desc.datatype))
|
|
439
|
+
: rdf.literal(s);
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
const makeItem = (existingValue) => {
|
|
443
|
+
const item = document.createElement('div');
|
|
444
|
+
item.className = 'sol-form-shape-multi-item';
|
|
445
|
+
const input = document.createElement('input');
|
|
446
|
+
input.type = isIRI ? 'url' : 'text';
|
|
447
|
+
input.value = existingValue ? existingValue.value : '';
|
|
448
|
+
input.disabled = readOnly;
|
|
449
|
+
item.appendChild(input);
|
|
450
|
+
|
|
451
|
+
const del = document.createElement('button');
|
|
452
|
+
del.type = 'button';
|
|
453
|
+
del.className = 'sol-form-shape-multi-del';
|
|
454
|
+
del.setAttribute('aria-label', 'Remove value');
|
|
455
|
+
del.textContent = '✕';
|
|
456
|
+
del.disabled = readOnly;
|
|
457
|
+
item.appendChild(del);
|
|
458
|
+
|
|
459
|
+
let bound = existingValue || null;
|
|
460
|
+
del.addEventListener('click', () => {
|
|
461
|
+
if (!bound) { list.removeChild(item); return; }
|
|
462
|
+
const olds = [rdf.st(subject, desc.path, bound, doc)];
|
|
463
|
+
store.updater.update(olds, [], (_uri, ok) => {
|
|
464
|
+
if (!ok) return;
|
|
465
|
+
list.removeChild(item);
|
|
466
|
+
cb(true);
|
|
467
|
+
});
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
input.addEventListener('change', () => {
|
|
471
|
+
const term = toTerm(input.value);
|
|
472
|
+
if (!term) return;
|
|
473
|
+
if (bound && bound.equals(term)) return;
|
|
474
|
+
const olds = bound ? [rdf.st(subject, desc.path, bound, doc)] : [];
|
|
475
|
+
const news = [rdf.st(subject, desc.path, term, doc)];
|
|
476
|
+
store.updater.update(olds, news, (_uri, ok) => {
|
|
477
|
+
if (!ok) return;
|
|
478
|
+
bound = term;
|
|
479
|
+
cb(true);
|
|
480
|
+
});
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
return item;
|
|
484
|
+
};
|
|
485
|
+
|
|
486
|
+
for (const v of store.each(subject, desc.path, null, doc)) {
|
|
487
|
+
list.appendChild(makeItem(v));
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
if (!readOnly) {
|
|
491
|
+
const add = document.createElement('button');
|
|
492
|
+
add.type = 'button';
|
|
493
|
+
add.className = 'sol-form-shape-multi-add';
|
|
494
|
+
add.textContent = `+ Add ${(desc.label || desc.key).toLowerCase()}`;
|
|
495
|
+
add.addEventListener('click', () => {
|
|
496
|
+
const item = makeItem(null);
|
|
497
|
+
list.appendChild(item);
|
|
498
|
+
item.querySelector('input').focus();
|
|
499
|
+
});
|
|
500
|
+
valueBox.appendChild(add);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Find the picker's <select> inside `row` and attach a change handler
|
|
505
|
+
// that swaps the (subject, predicate, *) triples for a single new one
|
|
506
|
+
// pointing at the picked URI. Skipped for multi-select selects (those
|
|
507
|
+
// go through solid-ui's own update path).
|
|
508
|
+
function wireSingleSelectAutosave(row, store, subject, predicate, doc, cb) {
|
|
509
|
+
// solid-ui's single-select Choice (a) doesn't autosave the chosen value
|
|
510
|
+
// and (b) actively detaches the <select> from its parent on every change
|
|
511
|
+
// (its onChange does `container.removeChild(container.lastChild)` and
|
|
512
|
+
// never re-adds for single-select). We attach our own change handler
|
|
513
|
+
// that re-appends the detached <select> AND PATCHes the new value via
|
|
514
|
+
// store.updater.update — the same path solid-ui's basic fields use.
|
|
515
|
+
row.addEventListener('change', (e) => {
|
|
516
|
+
const sel = e.target;
|
|
517
|
+
if (!sel || sel.tagName !== 'SELECT' || sel.multiple) return;
|
|
518
|
+
const newUri = sel.value;
|
|
519
|
+
if (!newUri || !/^https?:|^urn:|^did:/.test(newUri)) return;
|
|
520
|
+
|
|
521
|
+
// solid-ui already removed sel from its parent (.choiceBox-selectBox).
|
|
522
|
+
// Put it back so the user keeps seeing the dropdown.
|
|
523
|
+
if (!sel.parentNode) {
|
|
524
|
+
const rhs = row.querySelector('.choiceBox-selectBox');
|
|
525
|
+
if (rhs) rhs.appendChild(sel);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if (!store.updater) { cb(false); return; }
|
|
529
|
+
const olds = store.statementsMatching(subject, predicate, null, doc).slice();
|
|
530
|
+
const news = [rdf.st(subject, predicate, rdf.sym(newUri), doc)];
|
|
531
|
+
store.updater.update(olds, news, (_uri, ok) => { cb(!!ok); });
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// Build (and add to the store) the ui:* triples for one descriptor.
|
|
536
|
+
// Returns the form-side node solid-ui should render — that's either the
|
|
537
|
+
// field itself for single-valued, or a wrapping ui:Multiple for
|
|
538
|
+
// multi-valued. Returns null if the descriptor is malformed.
|
|
539
|
+
function buildFieldNode(store, desc, synthesized, doc) {
|
|
540
|
+
if (desc.nestedProperties) {
|
|
541
|
+
return buildNestedFieldNode(store, desc, synthesized, doc);
|
|
542
|
+
}
|
|
543
|
+
const fieldNode = rdf.blankNode();
|
|
544
|
+
const fieldType = uiTypeForDescriptor(desc, store, synthesized, doc, fieldNode);
|
|
545
|
+
if (!fieldType) return null;
|
|
546
|
+
|
|
547
|
+
addTriple(store, synthesized, doc, fieldNode, rdf.sym(RDF_NS + 'type'), rdf.sym(fieldType));
|
|
548
|
+
addTriple(store, synthesized, doc, fieldNode, rdf.sym(UI + 'property'), desc.path);
|
|
549
|
+
if (desc.label) {
|
|
550
|
+
addTriple(store, synthesized, doc, fieldNode, rdf.sym(UI + 'label'),
|
|
551
|
+
rdf.literal(desc.label));
|
|
552
|
+
}
|
|
553
|
+
// ui:required true (solid-ui doesn't surface this visibly today but
|
|
554
|
+
// SHACL min/max are recorded for completeness).
|
|
555
|
+
if (desc.minCount >= 1 && desc.maxCount === 1) {
|
|
556
|
+
addTriple(store, synthesized, doc, fieldNode, rdf.sym(UI + 'required'),
|
|
557
|
+
rdf.literal('true', rdf.sym(XSD + 'boolean')));
|
|
558
|
+
}
|
|
559
|
+
// Description — solid-ui's basic fields don't render a tooltip from
|
|
560
|
+
// this, but rdfs:comment is the conventional slot and any future
|
|
561
|
+
// help-popover would use it.
|
|
562
|
+
if (desc.description) {
|
|
563
|
+
addTriple(store, synthesized, doc, fieldNode, rdf.sym(RDFS_NS + 'comment'),
|
|
564
|
+
rdf.literal(desc.description));
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// Multi-valued handling:
|
|
568
|
+
//
|
|
569
|
+
// • IRI-enum (sh:in with NamedNode options) → keep ONE ui:Choice and
|
|
570
|
+
// mark it ui:multiselect true. Solid-ui's Choice handler reads that
|
|
571
|
+
// flag and renders a single multi-select widget showing every
|
|
572
|
+
// selected option simultaneously — instead of multiple parallel
|
|
573
|
+
// dropdowns that each show the same first-alphabetical option
|
|
574
|
+
// (the "Imperial / Imperial" bug we hit with Multiple-wrap).
|
|
575
|
+
//
|
|
576
|
+
// • Other multi-valued: wrap in ui:Multiple. Solid-ui renders one
|
|
577
|
+
// row per value with +/− chrome and reorder controls for ordered
|
|
578
|
+
// lists.
|
|
579
|
+
const isMulti = desc.maxCount > 1 || (desc.maxCount === Infinity && desc.minCount >= 0);
|
|
580
|
+
if (isMulti) {
|
|
581
|
+
const fieldType = store.anyValue(fieldNode, rdf.sym(RDF_NS + 'type'));
|
|
582
|
+
if (fieldType === UI + 'Choice') {
|
|
583
|
+
addTriple(store, synthesized, doc, fieldNode, rdf.sym(UI + 'multiselect'),
|
|
584
|
+
rdf.literal('true', rdf.sym(XSD + 'boolean')));
|
|
585
|
+
return fieldNode;
|
|
586
|
+
}
|
|
587
|
+
const multi = rdf.blankNode();
|
|
588
|
+
addTriple(store, synthesized, doc, multi, rdf.sym(RDF_NS + 'type'), rdf.sym(UI + 'Multiple'));
|
|
589
|
+
addTriple(store, synthesized, doc, multi, rdf.sym(UI + 'property'), desc.path);
|
|
590
|
+
addTriple(store, synthesized, doc, multi, rdf.sym(UI + 'part'), fieldNode);
|
|
591
|
+
if (desc.label) {
|
|
592
|
+
addTriple(store, synthesized, doc, multi, rdf.sym(UI + 'label'), rdf.literal(desc.label));
|
|
593
|
+
}
|
|
594
|
+
if (desc.reverse) {
|
|
595
|
+
// SHACL sh:inversePath → solid-ui reads ui:reverse to flip its
|
|
596
|
+
// own kb.each() direction (and to emit inverse triples on add).
|
|
597
|
+
addTriple(store, synthesized, doc, multi, rdf.sym(UI + 'reverse'),
|
|
598
|
+
rdf.literal('true', rdf.sym(XSD + 'boolean')));
|
|
599
|
+
}
|
|
600
|
+
return multi;
|
|
601
|
+
}
|
|
602
|
+
return fieldNode;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// sh:node nested shape → ui:Group of sub-fields (wrapped in ui:Multiple
|
|
606
|
+
// when the outer property is multi-valued, matching menu-form.ttl's
|
|
607
|
+
// ui:attribute → :attrForm pattern). Each sub-property is built via the
|
|
608
|
+
// regular buildFieldNode so nesting can chain arbitrarily deep.
|
|
609
|
+
function buildNestedFieldNode(store, desc, synthesized, doc) {
|
|
610
|
+
const groupNode = rdf.blankNode();
|
|
611
|
+
addTriple(store, synthesized, doc, groupNode,
|
|
612
|
+
rdf.sym(RDF_NS + 'type'), rdf.sym(UI + 'Group'));
|
|
613
|
+
|
|
614
|
+
const subNodes = [];
|
|
615
|
+
for (const sub of desc.nestedProperties) {
|
|
616
|
+
const subNode = buildFieldNode(store, sub, synthesized, doc);
|
|
617
|
+
if (subNode) subNodes.push(subNode);
|
|
618
|
+
}
|
|
619
|
+
const list = synthesizeRdfList(store, synthesized, doc, subNodes);
|
|
620
|
+
addTriple(store, synthesized, doc, groupNode,
|
|
621
|
+
rdf.sym(UI + 'parts'), list);
|
|
622
|
+
|
|
623
|
+
const isMulti = desc.maxCount > 1 || desc.maxCount === Infinity;
|
|
624
|
+
if (!isMulti) {
|
|
625
|
+
if (desc.label) {
|
|
626
|
+
addTriple(store, synthesized, doc, groupNode,
|
|
627
|
+
rdf.sym(UI + 'label'), rdf.literal(desc.label));
|
|
628
|
+
}
|
|
629
|
+
return groupNode;
|
|
630
|
+
}
|
|
631
|
+
const multi = rdf.blankNode();
|
|
632
|
+
addTriple(store, synthesized, doc, multi, rdf.sym(RDF_NS + 'type'), rdf.sym(UI + 'Multiple'));
|
|
633
|
+
addTriple(store, synthesized, doc, multi, rdf.sym(UI + 'property'), desc.path);
|
|
634
|
+
addTriple(store, synthesized, doc, multi, rdf.sym(UI + 'part'), groupNode);
|
|
635
|
+
if (desc.label) {
|
|
636
|
+
addTriple(store, synthesized, doc, multi, rdf.sym(UI + 'label'), rdf.literal(desc.label));
|
|
637
|
+
}
|
|
638
|
+
if (desc.reverse) {
|
|
639
|
+
addTriple(store, synthesized, doc, multi, rdf.sym(UI + 'reverse'),
|
|
640
|
+
rdf.literal('true', rdf.sym(XSD + 'boolean')));
|
|
641
|
+
}
|
|
642
|
+
return multi;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// Build an rdflib Collection holding `nodes`. Returned as a single
|
|
646
|
+
// Collection term so solid-ui's Group handler — which reads
|
|
647
|
+
// `parts.elements` directly — finds the populated array. Cons-cell
|
|
648
|
+
// triples never enter the store; on teardown the Collection just gets
|
|
649
|
+
// garbage-collected with the synthesized parent triple.
|
|
650
|
+
function synthesizeRdfList(store, synthesized, doc, nodes) {
|
|
651
|
+
if (nodes.length === 0) return rdf.sym(RDF_NS + 'nil');
|
|
652
|
+
return new rdf.Collection(nodes);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function uiTypeForDescriptor(desc, store, synthesized, doc, fieldNode) {
|
|
656
|
+
// sh:class — reuse the existing class as the ui:Choice source.
|
|
657
|
+
// Solid-ui enumerates instances at render time, so the option list
|
|
658
|
+
// stays live as new instances are added to the data store.
|
|
659
|
+
if (desc.classNode) {
|
|
660
|
+
addTriple(store, synthesized, doc, fieldNode, rdf.sym(UI + 'from'), desc.classNode);
|
|
661
|
+
return UI + 'Choice';
|
|
662
|
+
}
|
|
663
|
+
// sh:in with IRI options → ui:Choice + synthesized class.
|
|
664
|
+
if (desc.enumOpts && desc.enumOpts.length > 0 && desc.enumOpts[0].termType === 'NamedNode') {
|
|
665
|
+
const choiceClass = synthesizeEnumClass(store, synthesized, doc, desc);
|
|
666
|
+
addTriple(store, synthesized, doc, fieldNode, rdf.sym(UI + 'from'), choiceClass);
|
|
667
|
+
return UI + 'Choice';
|
|
668
|
+
}
|
|
669
|
+
// sh:in with literal options: no faithful ui:* mapping. Fall back to
|
|
670
|
+
// a text field — solid-ui renders it; users type the value. (Adding
|
|
671
|
+
// a class-with-instances mapping would change the stored RDF kind to
|
|
672
|
+
// a URI, which we don't want here.)
|
|
673
|
+
if (desc.enumOpts && desc.enumOpts.length > 0) {
|
|
674
|
+
return UI + 'SingleLineTextField';
|
|
675
|
+
}
|
|
676
|
+
// IRI-valued single field.
|
|
677
|
+
if (isIriKind(desc)) return UI + 'NamedNodeURIField';
|
|
678
|
+
|
|
679
|
+
switch (desc.datatype) {
|
|
680
|
+
case XSD + 'integer': return UI + 'IntegerField';
|
|
681
|
+
case XSD + 'decimal':
|
|
682
|
+
case XSD + 'double':
|
|
683
|
+
case XSD + 'float': return UI + 'DecimalField';
|
|
684
|
+
case XSD + 'boolean': return UI + 'BooleanField';
|
|
685
|
+
case XSD + 'date': return UI + 'DateField';
|
|
686
|
+
case XSD + 'dateTime':return UI + 'DateTimeField';
|
|
687
|
+
case XSD + 'anyURI': return UI + 'NamedNodeURIField';
|
|
688
|
+
case XSD + 'string':
|
|
689
|
+
default: return UI + 'SingleLineTextField';
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function isIriKind(desc) {
|
|
694
|
+
return desc.nodeKind === SH + 'IRI' ||
|
|
695
|
+
desc.nodeKind === SH + 'IRIOrLiteral' ||
|
|
696
|
+
desc.nodeKind === SH + 'BlankNodeOrIRI';
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// Build a unique rdfs:Class and declare each enum URI as an instance of
|
|
700
|
+
// it, propagating rdfs:label from the shape so solid-ui's Choice shows
|
|
701
|
+
// human-friendly text in the dropdown.
|
|
702
|
+
function synthesizeEnumClass(store, synthesized, doc, desc) {
|
|
703
|
+
const cls = rdf.blankNode();
|
|
704
|
+
addTriple(store, synthesized, doc, cls, rdf.sym(RDF_NS + 'type'), rdf.sym(RDFS_NS + 'Class'));
|
|
705
|
+
for (let i = 0; i < desc.enumOpts.length; i++) {
|
|
706
|
+
const opt = desc.enumOpts[i];
|
|
707
|
+
const node = rdf.sym(opt.value);
|
|
708
|
+
addTriple(store, synthesized, doc, node, rdf.sym(RDF_NS + 'type'), cls);
|
|
709
|
+
const label = desc.enumLabels?.[i];
|
|
710
|
+
if (label && label !== opt.value) {
|
|
711
|
+
addTriple(store, synthesized, doc, node, rdf.sym(RDFS_NS + 'label'), rdf.literal(label));
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
return cls;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function addTriple(store, synthesized, g, s, p, o) {
|
|
718
|
+
store.add(s, p, o, g);
|
|
719
|
+
synthesized.push({ s, p, o, g });
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* @typedef {Object} Targets
|
|
724
|
+
* @property {Array} nodes sh:targetNode values
|
|
725
|
+
* @property {Array} classes sh:targetClass values
|
|
726
|
+
* @property {Array} subjectsOf sh:targetSubjectsOf values
|
|
727
|
+
*/
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* @typedef {Object} ShapeProp
|
|
731
|
+
* @property {Object} path NamedNode — sh:path (the real predicate)
|
|
732
|
+
* @property {string} key local part of the path URI (display key)
|
|
733
|
+
* @property {?string} datatype xsd: URI string, or null
|
|
734
|
+
* @property {?Array} enumOpts [{value, termType}, ...] from sh:in, or null
|
|
735
|
+
* @property {?string[]} enumLabels per-option rdfs:label (NamedNode opts), or null
|
|
736
|
+
* @property {?string} nodeKind sh:nodeKind URI string, or null
|
|
737
|
+
* @property {number} minCount sh:minCount (default 0)
|
|
738
|
+
* @property {number} maxCount sh:maxCount (default Infinity)
|
|
739
|
+
* @property {?string} label sh:name
|
|
740
|
+
* @property {?string} description sh:description
|
|
741
|
+
*/
|