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.
Files changed (150) hide show
  1. package/README.md +7 -0
  2. package/core/activate.js +27 -0
  3. package/core/adopt.js +71 -0
  4. package/core/auth-core.js +73 -0
  5. package/core/auth-fetch.js +154 -0
  6. package/core/component-mount.js +110 -0
  7. package/core/defaults.js +48 -0
  8. package/core/define.js +15 -0
  9. package/core/display-target.js +166 -0
  10. package/core/edit-placements.js +28 -0
  11. package/core/editor-self.js +127 -0
  12. package/core/editor.js +162 -0
  13. package/core/events.js +27 -0
  14. package/core/extension-points.js +189 -0
  15. package/core/form-utils.js +210 -0
  16. package/core/from-query.js +138 -0
  17. package/core/from-rdf.js +52 -0
  18. package/core/here.js +33 -0
  19. package/core/include-core.js +73 -0
  20. package/core/inrupt-global.js +18 -0
  21. package/core/menu-consumer.js +41 -0
  22. package/core/menu-rdf.js +154 -0
  23. package/core/pod-ops.js +392 -0
  24. package/core/pod-registry.js +82 -0
  25. package/core/popup-proxy.js +255 -0
  26. package/core/rdf-core.js +280 -0
  27. package/core/rdf-render.js +136 -0
  28. package/core/rdf-utils.js +411 -0
  29. package/core/rdf.js +154 -0
  30. package/core/services.js +106 -0
  31. package/core/shape-to-form.js +741 -0
  32. package/core/sparql-safety.js +20 -0
  33. package/core/utils.js +196 -0
  34. package/dist/importmap-cdn.json +49 -0
  35. package/dist/importmap-local.json +49 -0
  36. package/dist/sol-loader.manifest.json +140 -0
  37. package/dist/vendor/@comunica-query-sparql.js +137851 -0
  38. package/dist/vendor/@inrupt-solid-client-authn-browser.js +7503 -0
  39. package/dist/vendor/dompurify.js +1476 -0
  40. package/dist/vendor/ical.js.js +9739 -0
  41. package/dist/vendor/marked.js +85 -0
  42. package/dist/vendor/n3.js +14670 -0
  43. package/dist/vendor/rdf-validate-shacl.js +6970 -0
  44. package/dist/vendor/rdflib.js +35172 -0
  45. package/dist/vendor/solid-logic.js +6819 -0
  46. package/dist/vendor/solid-ui.js +21945 -0
  47. package/node/sol-form.js +133 -0
  48. package/node/sol-include.js +55 -0
  49. package/node/sol-login.js +632 -0
  50. package/node/sol-menu.js +639 -0
  51. package/node/sol-query.js +116 -0
  52. package/package.json +133 -0
  53. package/web/menu-from-rdf.js +23 -0
  54. package/web/scripts/prefs.js +25 -0
  55. package/web/sol-accordion.js +114 -0
  56. package/web/sol-basic.js +50 -0
  57. package/web/sol-breadcrumb.js +131 -0
  58. package/web/sol-button.js +244 -0
  59. package/web/sol-calendar.js +465 -0
  60. package/web/sol-default.js +118 -0
  61. package/web/sol-dropdown-button.js +222 -0
  62. package/web/sol-feed.js +1336 -0
  63. package/web/sol-form.js +949 -0
  64. package/web/sol-full.js +43 -0
  65. package/web/sol-gallery.js +303 -0
  66. package/web/sol-include.js +246 -0
  67. package/web/sol-live-edit.js +415 -0
  68. package/web/sol-login.js +856 -0
  69. package/web/sol-menu.js +593 -0
  70. package/web/sol-modal.js +377 -0
  71. package/web/sol-pod-extras.js +17 -0
  72. package/web/sol-pod-ops.js +680 -0
  73. package/web/sol-pod.js +1039 -0
  74. package/web/sol-query.js +546 -0
  75. package/web/sol-rolodex.js +95 -0
  76. package/web/sol-search.js +402 -0
  77. package/web/sol-settings.js +199 -0
  78. package/web/sol-solidos.js +93 -0
  79. package/web/sol-tabs.js +445 -0
  80. package/web/sol-time.js +194 -0
  81. package/web/sol-tree-edit.js +492 -0
  82. package/web/sol-wac.js +456 -0
  83. package/web/sol-weather.js +337 -0
  84. package/web/sol-window.js +142 -0
  85. package/web/styles/buttons-css.js +108 -0
  86. package/web/styles/help.css +242 -0
  87. package/web/styles/root.css +112 -0
  88. package/web/styles/sol-accordion-css.js +97 -0
  89. package/web/styles/sol-calendar-css.js +154 -0
  90. package/web/styles/sol-feed-css.js +475 -0
  91. package/web/styles/sol-form-css.js +471 -0
  92. package/web/styles/sol-gallery-css.js +181 -0
  93. package/web/styles/sol-include-css.js +95 -0
  94. package/web/styles/sol-live-edit-css.js +84 -0
  95. package/web/styles/sol-live-edit.css +101 -0
  96. package/web/styles/sol-login-css.js +116 -0
  97. package/web/styles/sol-menu-css.js +145 -0
  98. package/web/styles/sol-modal-css.js +134 -0
  99. package/web/styles/sol-pod-css.js +187 -0
  100. package/web/styles/sol-pod-modal-css.js +203 -0
  101. package/web/styles/sol-query-css.js +140 -0
  102. package/web/styles/sol-query-help.css +267 -0
  103. package/web/styles/sol-query-one-pager.css +67 -0
  104. package/web/styles/sol-search-css.js +157 -0
  105. package/web/styles/sol-solidos-css.js +7 -0
  106. package/web/styles/sol-tabs-css.js +114 -0
  107. package/web/styles/sol-time-css.js +30 -0
  108. package/web/styles/sol-wac-css.js +73 -0
  109. package/web/styles/sol-weather-css.js +59 -0
  110. package/web/styles/solid-logo.svg +9 -0
  111. package/web/styles/view-accordion-css.js +66 -0
  112. package/web/styles/view-anchorlist-css.js +22 -0
  113. package/web/styles/view-autocomplete-css.js +59 -0
  114. package/web/styles/view-rolodex-css.js +102 -0
  115. package/web/styles/view-select-css.js +21 -0
  116. package/web/utils/calendar-fetch.js +388 -0
  117. package/web/utils/code-mirror-editor.js +82 -0
  118. package/web/utils/commons-fetch.js +108 -0
  119. package/web/utils/feed-edit.js +159 -0
  120. package/web/utils/feed-edit.smoke.mjs +74 -0
  121. package/web/utils/feed-fetch.js +573 -0
  122. package/web/utils/live-edit-help/csv.js +64 -0
  123. package/web/utils/live-edit-help/graphviz.js +41 -0
  124. package/web/utils/live-edit-help/jsonld.js +55 -0
  125. package/web/utils/live-edit-help/markdown.js +52 -0
  126. package/web/utils/live-edit-help/mermaid.js +48 -0
  127. package/web/utils/live-edit-help/turtle.js +85 -0
  128. package/web/utils/rdf-config.js +125 -0
  129. package/web/utils/renderers/csv.js +124 -0
  130. package/web/utils/renderers/d3-force.js +82 -0
  131. package/web/utils/renderers/graphviz.js +13 -0
  132. package/web/utils/renderers/html.js +10 -0
  133. package/web/utils/renderers/jsonld.js +63 -0
  134. package/web/utils/renderers/markdown.js +19 -0
  135. package/web/utils/renderers/mermaid.js +54 -0
  136. package/web/utils/renderers/turtle.js +51 -0
  137. package/web/utils/sol-query-triple-patterns.js +151 -0
  138. package/web/utils/sol-query-ui.js +250 -0
  139. package/web/utils/sol-query-views.js +32 -0
  140. package/web/views/_helpers.js +34 -0
  141. package/web/views/accordion.js +133 -0
  142. package/web/views/anchorlist.js +59 -0
  143. package/web/views/auto-complete.js +183 -0
  144. package/web/views/dl.js +38 -0
  145. package/web/views/list.js +19 -0
  146. package/web/views/menu.js +56 -0
  147. package/web/views/rolodex.js +126 -0
  148. package/web/views/select.js +79 -0
  149. package/web/views/table.js +73 -0
  150. package/web/views/tabs.js +57 -0
@@ -0,0 +1,949 @@
1
+ /**
2
+ * <sol-form> — Generic RDF form renderer.
3
+ *
4
+ * Loads a ui:Form definition from a Turtle source URI and renders it via
5
+ * solid-ui's form field system. Form data lives in an rdflib IndexedFormula
6
+ * and is persisted to a Solid Pod through rdflib's UpdateManager.
7
+ *
8
+ * Save behaviour:
9
+ * • Non-ordered forms auto-save on every field change (debounced).
10
+ * • Forms containing a ui:Multiple with ui:ordered true render a Save
11
+ * button and persist via PUT only when clicked.
12
+ * • Save location is derived from the `subject` or `save-to` attribute;
13
+ * if neither is given, the user is prompted inline on first save.
14
+ *
15
+ * Attributes:
16
+ * source — URI of a Turtle file containing a ui:Form definition (required)
17
+ * subject — URI of an existing RDF resource to edit (optional; blank = new)
18
+ * shape — URI of a SHACL shapes file for validation before save (optional)
19
+ * save-to — Pre-filled Pod URL for saving (optional)
20
+ *
21
+ * Events (bubbling, composed):
22
+ * sol-form-change — detail: { subject, ok, message } — every field edit
23
+ * sol-form-save — detail: { subject, turtle, target } — after save
24
+ *
25
+ * @class SolForm
26
+ * @extends HTMLElement
27
+ */
28
+
29
+ import { define } from '../core/define.js';
30
+ import { adopt } from '../core/adopt.js';
31
+ import { rdf } from '../core/rdf.js';
32
+ import { loadRdfStore } from '../core/rdf-utils.js';
33
+ import { UI, RDF, readFormParts, findForm } from '../core/form-utils.js';
34
+ import { parseShape, renderRecordForm, findSubjects } from '../core/shape-to-form.js';
35
+ import { CSS as FORM_CSS, sheet as formSheet } from './styles/sol-form-css.js';
36
+ import { CSS as ROLODEX_CSS, sheet as rolodexSheet } from './styles/view-rolodex-css.js';
37
+
38
+ const AUTOSAVE_DEBOUNCE_MS = 600;
39
+
40
+ // Replace the store's UpdateManager.update with a raw `application/sparql-update`
41
+ // PATCH (DELETE DATA / INSERT DATA from the concrete statement arrays solid-ui
42
+ // and our rolodex pass). rdflib's own PATCH 500s on the Community Solid Server
43
+ // for some documents (large / certain content); a plain sparql-update PATCH is
44
+ // what that server reliably accepts — the same workaround the omp player uses
45
+ // for its library writes. Idempotent; install once per store. `put` (new-doc
46
+ // creation) is left on rdflib. Applied only to editable forms.
47
+ function installRawSparqlUpdate(store) {
48
+ const updater = store?.updater;
49
+ if (!updater || updater._rawPatchInstalled) return;
50
+ updater._rawPatchInstalled = true;
51
+ const nt = (s) => `${s.subject.toNT()} ${s.predicate.toNT()} ${s.object.toNT()} .`;
52
+ updater.update = (deletes = [], inserts = [], cb) => {
53
+ deletes = deletes || []; inserts = inserts || [];
54
+ const any = deletes[0] || inserts[0];
55
+ const doc = any && (any.why || any.graph) ? (any.why || any.graph).value : null;
56
+ if (!doc) { cb && cb(null, false, 'sol-form rawPatch: no target document'); return; }
57
+ const parts = [];
58
+ if (deletes.length) parts.push(`DELETE DATA {\n${deletes.map(nt).join('\n')}\n}`);
59
+ if (inserts.length) parts.push(`INSERT DATA {\n${inserts.map(nt).join('\n')}\n}`);
60
+ const body = parts.join(' ;\n');
61
+ // Prefer the logged-in Solid session's fetch (carries the auth token for
62
+ // writes to a protected pod); fall back to the page fetch (public pods,
63
+ // dev). solid-client-authn-browser is exposed as window.solidClientAuthn.
64
+ const session = globalThis.solidClientAuthn?.getDefaultSession?.();
65
+ const fetchFn = (session?.info?.isLoggedIn && session.fetch.bind(session))
66
+ || globalThis.fetch.bind(globalThis);
67
+ Promise.resolve(fetchFn(doc, {
68
+ method: 'PATCH', headers: { 'Content-Type': 'application/sparql-update' }, body,
69
+ })).then(async res => {
70
+ if (res && res.ok) {
71
+ for (const s of deletes) store.remove(s);
72
+ for (const s of inserts) store.add(s.subject, s.predicate, s.object, s.why || s.graph);
73
+ cb && cb(doc, true);
74
+ } else {
75
+ console.warn('[sol-form] PATCH failed:', res && res.status, 'on', doc);
76
+ cb && cb(doc, false, `HTTP ${res && res.status}`);
77
+ }
78
+ }).catch(e => cb && cb(doc, false, e.message));
79
+ };
80
+ }
81
+
82
+ class SolForm extends HTMLElement {
83
+ constructor() {
84
+ super();
85
+ this.attachShadow({ mode: 'open' });
86
+ this._store = null;
87
+ this._formNode = null;
88
+ this._subject = null;
89
+ this._docNode = null;
90
+ this._docUrl = null;
91
+ this._ordered = false;
92
+ this._rendered = false;
93
+ this._shapeText = null;
94
+ this._saveTimer = null;
95
+ this._pendingSave = false;
96
+ }
97
+
98
+ static get observedAttributes() { return ['source', 'subject', 'shape', 'save-to', 'view']; }
99
+
100
+ attributeChangedCallback(name, oldVal, newVal) {
101
+ if (oldVal === newVal) return;
102
+ if ((name === 'source' || name === 'view') && this._rendered) this._load();
103
+ }
104
+
105
+ connectedCallback() {
106
+ if (this._rendered) return;
107
+ this._initShell();
108
+ this._load();
109
+ }
110
+
111
+ // ── public API ──
112
+
113
+ get store() { return this._store; }
114
+ get subject() { return this._subject; }
115
+
116
+ getTurtle() {
117
+ if (!this._store || !this._docNode) return '';
118
+ return rdf.serialize(this._docNode, this._store, this._docNode.value, 'text/turtle') || '';
119
+ }
120
+
121
+ // ── shell ──
122
+
123
+ _initShell() {
124
+ const root = this.shadowRoot;
125
+ root.innerHTML = `
126
+ <div class="sol-form-body"></div>
127
+ <div class="sol-form-save-bar">
128
+ <div class="sol-form-validation-summary" style="display:none"></div>
129
+ <div class="sol-form-pod-url" style="display:none">
130
+ <label>Save to:
131
+ <input type="url" placeholder="https://you.pod/path/data.ttl" class="sol-form-pod-input">
132
+ </label>
133
+ <button type="button" class="sol-form-btn sol-form-set-loc">Set</button>
134
+ </div>
135
+ <div class="sol-form-actions">
136
+ <button type="button" class="sol-form-btn sol-form-btn-primary sol-form-save-btn" style="display:none">Save</button>
137
+ <span class="sol-form-save-status"></span>
138
+ </div>
139
+ </div>`;
140
+ adopt(root, { sheet: formSheet, css: FORM_CSS });
141
+ this._rendered = true;
142
+
143
+ root.querySelector('.sol-form-set-loc').addEventListener('click', () => this._onSetLocation());
144
+ root.querySelector('.sol-form-save-btn').addEventListener('click', () => this._onSaveClick());
145
+ }
146
+
147
+ _showLocationInput(show) {
148
+ const el = this.shadowRoot.querySelector('.sol-form-pod-url');
149
+ el.style.display = show ? 'flex' : 'none';
150
+ if (show) {
151
+ const input = el.querySelector('.sol-form-pod-input');
152
+ if (!input.value && this.getAttribute('save-to')) input.value = this.getAttribute('save-to');
153
+ input.focus();
154
+ }
155
+ }
156
+
157
+ _showSaveButton(show) {
158
+ this.shadowRoot.querySelector('.sol-form-save-btn').style.display = show ? '' : 'none';
159
+ }
160
+
161
+ // ── loading ──
162
+
163
+ async _load() {
164
+ const source = this.getAttribute('source');
165
+ const shape = this.getAttribute('shape');
166
+ const view = (this.getAttribute('view') || '').toLowerCase();
167
+ if (!source && !shape) return;
168
+
169
+ const body = this.shadowRoot.querySelector('.sol-form-body');
170
+ body.innerHTML = '<div class="sol-form-loading">Loading form…</div>';
171
+ this._clearStatus();
172
+ this._hideValidation();
173
+ clearTimeout(this._saveTimer);
174
+
175
+ // Rolodex view: `source` is a data document, `shape` is the per-item
176
+ // shape. We find every subject the shape targets and render one
177
+ // editable record-form per subject, navigable card-by-card.
178
+ if (view === 'rolodex') {
179
+ try {
180
+ if (!shape) throw new Error('view="rolodex" requires a shape attribute.');
181
+ if (!source) throw new Error('view="rolodex" requires a source data document.');
182
+ await this._renderRolodex(body, source, shape);
183
+ } catch (err) {
184
+ body.innerHTML = `<div class="sol-form-error">${this._esc(err.message)}</div>`;
185
+ console.error('<sol-form view="rolodex"> failed:', err);
186
+ }
187
+ return;
188
+ }
189
+
190
+ try {
191
+ // Form definition (optional in shape-driven mode).
192
+ let formStore = null, formRoot = null;
193
+ if (source) {
194
+ formStore = await loadRdfStore(source);
195
+ formRoot = findForm(formStore, source);
196
+ if (!formRoot) throw new Error('No ui:Form found in ' + source);
197
+ }
198
+
199
+ const subjectAttr = this.getAttribute('subject');
200
+ const saveTo = this.getAttribute('save-to');
201
+ // rdflib requires absolute IRIs; absolutize `subject` against the
202
+ // page so consumers can use relative URLs (matching what `shape`
203
+ // and `source` already do via `new URL(…, document.baseURI)`).
204
+ const subjectUri = subjectAttr
205
+ ? new URL(subjectAttr, document.baseURI).href
206
+ : null;
207
+ let dataStore, subjectNode, docNode, docUrl;
208
+
209
+ if (subjectUri) {
210
+ docUrl = subjectUri.split('#')[0];
211
+ dataStore = this._initStore(docUrl);
212
+ await dataStore.fetcher.load(docUrl);
213
+ subjectNode = rdf.sym(subjectUri);
214
+ docNode = rdf.sym(docUrl);
215
+ } else {
216
+ // Use save-to as the doc URL when given; otherwise a synthetic local
217
+ // base — _docUrl stays null until the user supplies a real location.
218
+ const baseUri = source || shape;
219
+ const baseDoc = saveTo || new URL('_new.ttl', new URL(baseUri, document.baseURI)).href;
220
+ dataStore = this._initStore(baseDoc);
221
+ docNode = rdf.sym(baseDoc);
222
+ subjectNode = rdf.blankNode();
223
+ docUrl = saveTo || null;
224
+ }
225
+
226
+ this._store = dataStore;
227
+ this._formNode = formRoot;
228
+ this._subject = subjectNode;
229
+ this._docNode = docNode;
230
+ this._docUrl = docUrl;
231
+ // Track whether this form is editing an existing-on-server doc
232
+ // (`true` → per-field PATCH via solid-ui already saved everything;
233
+ // a Save-button click just emits the event) vs. authoring a new
234
+ // doc (`false` → PUT once to create, then flip to true).
235
+ this._docExists = !!docUrl;
236
+ this._ordered = formStore ? this._hasOrdering(formStore, formRoot) : false;
237
+
238
+ if (formStore) {
239
+ // Classic form-driven path: parse the ui:Form and hand to solid-ui.
240
+ this._mergeFormDefs(dataStore, formStore);
241
+ if (shape) await this._loadShape(shape);
242
+ this._renderForm(body, dataStore, subjectNode, formRoot, docNode);
243
+ } else {
244
+ // Shape-driven path: no form TTL, the SHACL shape IS the schema.
245
+ // sol-form walks the shape and generates one labelled field per
246
+ // sh:qualifiedValueShape entry (PropertyValue-style settings).
247
+ await this._loadShape(shape);
248
+ await this._renderFromShape(body, dataStore, subjectNode, docNode);
249
+ }
250
+
251
+ this._showSaveButton(this._ordered);
252
+
253
+ } catch (err) {
254
+ body.innerHTML = `<div class="sol-form-error">${this._esc(err.message)}</div>`;
255
+ console.error('<sol-form> load failed:', err);
256
+ }
257
+ }
258
+
259
+ _initStore(docUrl) {
260
+ // Use the shared singleton (solid-logic's when available; otherwise
261
+ // swc's own lazy graph). That's the same graph solid-ui's modules
262
+ // captured at import time — see core/rdf.js. Everything we add here
263
+ // is immediately visible to solid-ui's field renderers.
264
+ const store = rdf.store;
265
+ if (!store.fetcher) store.fetcher = new (rdf.Fetcher)(store);
266
+ if (!store.updater) store.updater = new (rdf.UpdateManager)(store);
267
+
268
+ return store;
269
+ }
270
+
271
+ _mergeFormDefs(dataStore, formStore) {
272
+ const stmts = formStore.statements || formStore.match(null, null, null) || [];
273
+ for (const st of stmts) {
274
+ if (!dataStore.holds(st.subject, st.predicate, st.object, st.why)) {
275
+ dataStore.add(st.subject, st.predicate, st.object, st.why);
276
+ }
277
+ }
278
+ }
279
+
280
+ // Walk the form definition, returning true if any ui:Multiple has
281
+ // ui:ordered true (directly or in a referenced sub-form).
282
+ _hasOrdering(formStore, formRoot) {
283
+ const TYPE = rdf.sym(RDF + 'type');
284
+ const ORDERED = rdf.sym(UI + 'ordered');
285
+ const PART = rdf.sym(UI + 'part');
286
+ const USE = rdf.sym(UI + 'use');
287
+ const CASE = rdf.sym(UI + 'case');
288
+
289
+ const seen = new Set();
290
+ const queue = [formRoot];
291
+ while (queue.length) {
292
+ const node = queue.shift();
293
+ if (!node || !node.value || seen.has(node.value)) continue;
294
+ seen.add(node.value);
295
+
296
+ const t = formStore.any(node, TYPE);
297
+ if (t && t.value === UI + 'Multiple' && formStore.anyValue(node, ORDERED) === 'true') {
298
+ return true;
299
+ }
300
+
301
+ for (const part of readFormParts(formStore, node)) queue.push(part);
302
+ const subPart = formStore.any(node, PART);
303
+ if (subPart) queue.push(subPart);
304
+ for (const c of formStore.each(node, CASE)) {
305
+ const useForm = formStore.any(c, USE);
306
+ if (useForm) queue.push(useForm);
307
+ }
308
+ }
309
+ return false;
310
+ }
311
+
312
+ // ── render via solid-ui ──
313
+
314
+ _renderForm(body, store, subject, form, doc) {
315
+ body.innerHTML = '';
316
+
317
+ // Bundled solid-ui exposes fieldFunction at window.UI.widgets.fieldFunction
318
+ // (flattened). The older API put it at widgets.forms.fieldFunction. Accept
319
+ // either so sol-form works against both shapes.
320
+ const fieldFunction =
321
+ window.UI?.widgets?.fieldFunction ??
322
+ window.UI?.widgets?.forms?.fieldFunction;
323
+ if (typeof fieldFunction !== 'function') {
324
+ body.innerHTML =
325
+ '<div class="sol-form-error">solid-ui is not loaded — <code>&lt;sol-form&gt;</code> requires it for rendering. Add solid-ui to the page.</div>';
326
+ return;
327
+ }
328
+
329
+ // _initStore returned solid-logic's singleton store when available,
330
+ // so solid-ui's captured `kb` already IS `store`. Nothing to swap.
331
+ const renderFn = fieldFunction(document, form);
332
+ if (typeof renderFn !== 'function') {
333
+ body.innerHTML =
334
+ '<div class="sol-form-error">solid-ui could not resolve a renderer for the form root (check the form definition reaches solid-logic).</div>';
335
+ return;
336
+ }
337
+ const widget = renderFn(document, body, {}, subject, form, doc, (ok, msg) => {
338
+ this.dispatchEvent(new CustomEvent('sol-form-change', {
339
+ bubbles: true, composed: true,
340
+ detail: { subject: this._subject, ok, message: msg },
341
+ }));
342
+ if (ok && !this._ordered) this._scheduleAutoSave();
343
+ });
344
+ if (widget && !body.contains(widget)) body.appendChild(widget);
345
+ }
346
+
347
+ // solid-logic shares state across module copies via a Symbol.for-keyed
348
+ // singleton on the global object — same lookup it uses internally.
349
+ // When present, this singleton's .store IS sol-form's data store
350
+ // (see _initStore), so there's no swap/restore dance: every component
351
+ // shares the same graph.
352
+ _solidLogicSingleton() {
353
+ const win = typeof window !== 'undefined' ? window : null;
354
+ if (!win) return null;
355
+ const sym = Symbol.for('solid-logic-singleton');
356
+ return win[sym] || win.SolidLogic || null;
357
+ }
358
+
359
+ // ── shape-driven rendering ──
360
+ //
361
+ // When sol-form is given a `shape` attribute and no `source` form,
362
+ // the SHACL shape IS the schema. The heavy lifting (parsing the
363
+ // SHACL, walking sh:property entries with sh:qualifiedValueShape,
364
+ // building typed inputs, binding them back to the store) lives in
365
+ // `core/shape-to-form.js` so it can be reused by sol-tree-edit,
366
+ // future view-mode renderers, and the standalone shape2form demo.
367
+ //
368
+ // sol-form's job here is just: parse + render + wire the onChange
369
+ // callback to the existing autosave + sol-form-change event flow.
370
+
371
+ async _renderFromShape(body, store, subject, doc) {
372
+ body.innerHTML = '';
373
+ let parsed;
374
+ try {
375
+ parsed = await parseShape(this._shapeText, this.getAttribute('shape') || '',
376
+ { dataStore: store, subject });
377
+ } catch (err) {
378
+ body.innerHTML = `<div class="sol-form-error">Failed to parse shape: ${this._esc(err.message)}</div>`;
379
+ return;
380
+ }
381
+ if (!parsed.properties.length) {
382
+ body.innerHTML = '<div class="sol-form-error">Shape declares no qualified properties — nothing to render.</div>';
383
+ return;
384
+ }
385
+
386
+ // Container pattern: if the selected shape has a multi-valued
387
+ // property with a nested NodeShape (sh:node), the user data is "a
388
+ // collection of records" — render a rolodex of cards keyed off
389
+ // that property, one per linked record, using the inner shape's
390
+ // own properties. Scalar siblings on the outer shape are
391
+ // intentionally ignored here; the rolodex of records is what the
392
+ // user cares about. First match wins.
393
+ const containerProp = parsed.properties.find(p =>
394
+ (p.maxCount === Infinity || p.maxCount > 1) && p.nestedProperties);
395
+ if (containerProp) {
396
+ const subjects = containerProp.reverse
397
+ ? store.each(null, containerProp.path, subject, doc).filter(n => n)
398
+ : store.each(subject, containerProp.path, null, doc).filter(n => n);
399
+ this._buildRolodexCards(body, store, doc, subjects,
400
+ containerProp.nestedProperties, containerProp.sortedBy);
401
+ return;
402
+ }
403
+
404
+ const readOnly = this.hasAttribute('no-edit');
405
+ this._shapeCleanup?.();
406
+ this._shapeCleanup = renderRecordForm(body, store, subject, parsed.properties, {
407
+ doc,
408
+ readOnly,
409
+ onChange: () => {
410
+ // solid-ui's fieldFunction widgets (basic + Choice via our
411
+ // wireSingleSelectAutosave) PATCH via store.updater.update — that
412
+ // IS the save. We don't autosave on top of that; we just emit the
413
+ // events downstream listeners use to refresh.
414
+ this.dispatchEvent(new CustomEvent('sol-form-change', {
415
+ bubbles: true, composed: true,
416
+ detail: { subject: this._subject, ok: true, message: '' },
417
+ }));
418
+ if (!this._ordered) {
419
+ this.dispatchEvent(new CustomEvent('sol-form-save', {
420
+ bubbles: true, composed: true,
421
+ detail: { subject: this._subject, target: this._docUrl },
422
+ }));
423
+ }
424
+ },
425
+ });
426
+ // Hide the save bar entirely when read-only — nothing to save.
427
+ const saveBar = this.shadowRoot.querySelector('.sol-form-save-bar');
428
+ if (saveBar) saveBar.style.display = readOnly ? 'none' : '';
429
+ }
430
+
431
+ // ── rolodex view (one form per matching subject) ──
432
+ //
433
+ // `source` is treated as a data document (not a ui:Form definition).
434
+ // `shape` selects which subjects in that document get a form via its
435
+ // sh:targetClass / sh:targetNode / sh:targetSubjectsOf. Each form is
436
+ // pre-rendered and kept mounted (toggle visibility on nav) so solid-ui
437
+ // widgets keep their state — sol-rolodex's clone-per-flip approach
438
+ // would break live form bindings.
439
+ async _renderRolodex(body, source, shape) {
440
+ await this._loadShape(shape);
441
+ const parsed = await parseShape(this._shapeText, shape || '');
442
+ if (!parsed.properties.length) {
443
+ throw new Error('Shape declares no qualified properties — nothing to render.');
444
+ }
445
+
446
+ const docUrl = new URL(source, document.baseURI).href;
447
+ const dataStore = this._initStore(docUrl);
448
+ // Editable rolodexes write through a raw sparql-update PATCH (rdflib's
449
+ // own PATCH 500s on CSS for some docs). Field edits (solid-ui) and our
450
+ // Add / Remove both go through updater.update, so this one swap covers all.
451
+ if (this.hasAttribute('editable')) installRawSparqlUpdate(dataStore);
452
+ await dataStore.fetcher.load(docUrl);
453
+ const docNode = rdf.sym(docUrl);
454
+
455
+ const subjects = findSubjects(dataStore, parsed.targets, docNode);
456
+
457
+ this._store = dataStore;
458
+ this._docNode = docNode;
459
+ this._docUrl = docUrl;
460
+
461
+ this._buildRolodexCards(body, dataStore, docNode, subjects, parsed.properties, null, {
462
+ lazy: this.hasAttribute('lazy'),
463
+ editable: this.hasAttribute('editable'),
464
+ targets: parsed.targets,
465
+ });
466
+ }
467
+
468
+ // Build the rolodex UI: nav buttons + counter + one pre-rendered card
469
+ // per subject. Used both by view="rolodex" and by the container-pattern
470
+ // detection in _renderFromShape (a shape whose outer property is a
471
+ // multi-valued sh:node onto an inner record shape).
472
+ //
473
+ // When `sortedBy` (NamedNode) is given, cards are sorted by that
474
+ // predicate's integer value on each subject, the matching inner field
475
+ // is hidden, and each card gains ↑/↓ buttons that swap the
476
+ // `sortedBy` value with the previous / next subject (two-statement
477
+ // PATCH via store.updater.update).
478
+ _buildRolodexCards(body, dataStore, docNode, subjects, properties, sortedBy = null, opts = {}) {
479
+ adopt(this.shadowRoot, { sheet: rolodexSheet, css: ROLODEX_CSS });
480
+
481
+ // `lazy` mounts only the active record's form (dispose + rebuild on
482
+ // nav) so a rolodex over hundreds of records stays light; safe because
483
+ // fields autosave (no in-progress state to preserve across a flip).
484
+ // sortedBy reorder needs neighbouring cards mounted, so it forces eager.
485
+ const lazy = !!opts.lazy && !sortedBy;
486
+ const editable = !!opts.editable; // show jump box + Add / Remove
487
+ const targets = opts.targets || {};
488
+ const startIndex = opts.startIndex || 0;
489
+ const RDF_TYPE = rdf.sym('http://www.w3.org/1999/02/22-rdf-syntax-ns#type');
490
+ const lastSeg = u => String(u).replace(/[#/]+$/, '').replace(/^.*[#/]/, '') || u;
491
+
492
+ // Mutable copy — Add / Remove splice this list.
493
+ subjects = [...subjects];
494
+
495
+ const sortKey = (subj) => {
496
+ if (!sortedBy) return 0;
497
+ const v = dataStore.anyValue(subj, sortedBy, null, docNode);
498
+ const n = parseInt(v, 10);
499
+ return Number.isFinite(n) ? n : Number.MAX_SAFE_INTEGER;
500
+ };
501
+ if (sortedBy) subjects.sort((a, b) => sortKey(a) - sortKey(b));
502
+
503
+ // Hide the ordering field from each card — the ↑/↓ buttons own it.
504
+ const displayProps = sortedBy
505
+ ? properties.filter(p => !p.path || p.path.value !== sortedBy.value)
506
+ : properties;
507
+ // Label predicate for the jump box: the first scalar field of the shape.
508
+ const labelPred = (displayProps.find(p => p.path) || {}).path || null;
509
+ const labelOf = (subj) =>
510
+ (labelPred && dataStore.anyValue(subj, labelPred, null, docNode)) || lastSeg(subj.value);
511
+
512
+ this._rolodexCleanups?.forEach(fn => { try { fn(); } catch (_) {} });
513
+ this._rolodexCleanups = [];
514
+
515
+ body.innerHTML = '';
516
+ const wrapper = document.createElement('div');
517
+ wrapper.className = 'sol-view-rolodex';
518
+ wrapper.tabIndex = 0;
519
+ wrapper.style.display = 'block';
520
+ wrapper.style.width = '100%';
521
+
522
+ const nav = document.createElement('div');
523
+ nav.className = 'rolodex-nav';
524
+ const prevBtn = document.createElement('button');
525
+ prevBtn.type = 'button';
526
+ prevBtn.className = 'sol-btn sol-btn-icon rolodex-btn';
527
+ prevBtn.setAttribute('aria-label', 'Previous record');
528
+ prevBtn.textContent = '‹';
529
+ const counter = document.createElement('span');
530
+ counter.className = 'rolodex-counter';
531
+ counter.setAttribute('aria-live', 'polite');
532
+ const nextBtn = document.createElement('button');
533
+ nextBtn.type = 'button';
534
+ nextBtn.className = 'sol-btn sol-btn-icon rolodex-btn';
535
+ nextBtn.setAttribute('aria-label', 'Next record');
536
+ nextBtn.textContent = '›';
537
+ nav.append(prevBtn, counter, nextBtn);
538
+ wrapper.appendChild(nav);
539
+
540
+ // Jump box: a native <datalist> over record labels (in-memory, no query).
541
+ // Picking / typing an exact label pages the rolodex to that record.
542
+ let jumpInput = null, datalist = null;
543
+ if (editable) {
544
+ const jump = document.createElement('div');
545
+ jump.className = 'rolodex-jump';
546
+ jumpInput = document.createElement('input');
547
+ jumpInput.type = 'text';
548
+ jumpInput.className = 'rolodex-jump-input';
549
+ jumpInput.placeholder = 'Type to search ...';
550
+ jumpInput.setAttribute('aria-label', 'Jump to a record');
551
+ const listId = 'rolodex-list-' + Math.random().toString(36).slice(2);
552
+ jumpInput.setAttribute('list', listId);
553
+ datalist = document.createElement('datalist');
554
+ datalist.id = listId;
555
+ jump.append(jumpInput, datalist);
556
+ wrapper.appendChild(jump);
557
+ const tryJump = () => {
558
+ const i = subjects.findIndex(s => labelOf(s) === jumpInput.value);
559
+ if (i >= 0) show(i);
560
+ };
561
+ jumpInput.addEventListener('input', tryJump);
562
+ jumpInput.addEventListener('change', tryJump);
563
+ }
564
+
565
+ const card = document.createElement('div');
566
+ card.className = 'rolodex-card';
567
+ card.style.cursor = 'default';
568
+ wrapper.appendChild(card);
569
+
570
+ // Add / Remove bar.
571
+ let addBtn = null, removeBtn = null;
572
+ if (editable) {
573
+ const bar = document.createElement('div');
574
+ bar.className = 'rolodex-actions';
575
+ bar.style.cssText = 'display:flex;gap:8px;margin-top:10px;';
576
+ addBtn = document.createElement('button');
577
+ addBtn.type = 'button';
578
+ addBtn.className = 'sol-btn rolodex-add';
579
+ addBtn.textContent = 'Add new record';
580
+ removeBtn = document.createElement('button');
581
+ removeBtn.type = 'button';
582
+ removeBtn.className = 'sol-btn rolodex-remove';
583
+ removeBtn.textContent = 'Delete this record';
584
+ removeBtn.style.marginLeft = 'auto';
585
+ bar.append(addBtn, removeBtn);
586
+ wrapper.appendChild(bar);
587
+ }
588
+
589
+ body.appendChild(wrapper);
590
+
591
+ const rebuildList = () => {
592
+ if (!datalist) return;
593
+ datalist.replaceChildren(...subjects.map(s => {
594
+ const o = document.createElement('option');
595
+ o.value = labelOf(s);
596
+ return o;
597
+ }));
598
+ };
599
+ rebuildList();
600
+
601
+ const emitSave = (subj) => this.dispatchEvent(new CustomEvent('sol-form-save', {
602
+ bubbles: true, composed: true, detail: { subject: subj, target: this._docUrl },
603
+ }));
604
+ const onFieldChange = (subj) => {
605
+ this.dispatchEvent(new CustomEvent('sol-form-change', {
606
+ bubbles: true, composed: true, detail: { subject: subj, ok: true, message: '' },
607
+ }));
608
+ rebuildList(); // a label edit changes the jump options
609
+ emitSave(subj);
610
+ };
611
+
612
+ let index = 0;
613
+ let pages = []; // eager only
614
+
615
+ // Render one record's form into `card`, disposing whatever was there.
616
+ const renderInto = (subj) => {
617
+ this._rolodexCleanups.forEach(fn => { try { fn(); } catch (_) {} });
618
+ this._rolodexCleanups = [];
619
+ card.replaceChildren();
620
+ const page = document.createElement('div');
621
+ page.className = 'sol-form-rolodex-page';
622
+ page.dataset.subject = subj.value;
623
+ card.appendChild(page);
624
+ this._rolodexCleanups.push(renderRecordForm(page, dataStore, subj, displayProps, {
625
+ doc: docNode, onChange: () => onFieldChange(subj),
626
+ }));
627
+ };
628
+
629
+ // Flush a pending field edit before disposing its widget.
630
+ const flush = () => { const ae = this.shadowRoot.activeElement; if (ae && ae.blur) ae.blur(); };
631
+
632
+ let show;
633
+ if (lazy) {
634
+ show = (i) => {
635
+ if (!subjects.length) {
636
+ card.replaceChildren();
637
+ counter.textContent = '0 of 0';
638
+ this._subject = null;
639
+ return;
640
+ }
641
+ flush();
642
+ index = ((i % subjects.length) + subjects.length) % subjects.length;
643
+ renderInto(subjects[index]);
644
+ counter.textContent = `${index + 1} of ${subjects.length}`;
645
+ this._subject = subjects[index];
646
+ };
647
+ } else {
648
+ // Eager: pre-render every card and toggle visibility (preserves widget
649
+ // state across flips; required for the sortedBy reorder controls).
650
+ pages = subjects.map(subj => {
651
+ const page = document.createElement('div');
652
+ page.className = 'sol-form-rolodex-page';
653
+ page.dataset.subject = subj.value;
654
+ card.appendChild(page);
655
+ this._rolodexCleanups.push(renderRecordForm(page, dataStore, subj, displayProps, {
656
+ doc: docNode, onChange: () => onFieldChange(subj),
657
+ }));
658
+
659
+ if (sortedBy) {
660
+ const reorder = document.createElement('div');
661
+ reorder.className = 'rolodex-reorder';
662
+ const hint = document.createElement('span');
663
+ hint.className = 'rolodex-reorder-hint';
664
+ hint.textContent = 'Use arrows to change order';
665
+ const upBtn = document.createElement('button');
666
+ upBtn.type = 'button';
667
+ upBtn.className = 'sol-btn sol-btn-icon rolodex-reorder-btn';
668
+ upBtn.setAttribute('aria-label', 'Move up');
669
+ upBtn.textContent = '↑';
670
+ upBtn.addEventListener('click', () => this._swapSortedNeighbor(-1));
671
+ const posSpan = document.createElement('span');
672
+ posSpan.className = 'rolodex-pos';
673
+ posSpan.setAttribute('aria-label', 'Position');
674
+ posSpan.textContent = String(sortKey(subj));
675
+ const downBtn = document.createElement('button');
676
+ downBtn.type = 'button';
677
+ downBtn.className = 'sol-btn sol-btn-icon rolodex-reorder-btn';
678
+ downBtn.setAttribute('aria-label', 'Move down');
679
+ downBtn.textContent = '↓';
680
+ downBtn.addEventListener('click', () => this._swapSortedNeighbor(1));
681
+ reorder.append(hint, upBtn, posSpan, downBtn);
682
+ page.appendChild(reorder);
683
+ }
684
+ return page;
685
+ });
686
+
687
+ show = (i) => {
688
+ if (!pages.length) { counter.textContent = '0 of 0'; this._subject = null; return; }
689
+ index = ((i % pages.length) + pages.length) % pages.length;
690
+ pages.forEach((p, j) => { p.hidden = j !== index; });
691
+ counter.textContent = `${index + 1} of ${pages.length}`;
692
+ this._subject = subjects[index];
693
+ if (sortedBy) {
694
+ const cur = pages[index];
695
+ const up = cur.querySelector('.rolodex-reorder-btn[aria-label="Move up"]');
696
+ const dn = cur.querySelector('.rolodex-reorder-btn[aria-label="Move down"]');
697
+ if (up) up.disabled = index === 0;
698
+ if (dn) dn.disabled = index === pages.length - 1;
699
+ }
700
+ };
701
+
702
+ this._swapSortedNeighbor = (delta) => {
703
+ const i = index;
704
+ const j = i + delta;
705
+ if (j < 0 || j >= subjects.length) return;
706
+ const a = subjects[i], b = subjects[j];
707
+ const litA = dataStore.any(a, sortedBy, null, docNode);
708
+ const litB = dataStore.any(b, sortedBy, null, docNode);
709
+ if (!litA || !litB) return;
710
+ const olds = [rdf.st(a, sortedBy, litA, docNode), rdf.st(b, sortedBy, litB, docNode)];
711
+ const news = [rdf.st(a, sortedBy, litB, docNode), rdf.st(b, sortedBy, litA, docNode)];
712
+ dataStore.updater.update(olds, news, (_uri, ok) => {
713
+ if (!ok) return;
714
+ [subjects[i], subjects[j]] = [subjects[j], subjects[i]];
715
+ [pages[i], pages[j]] = [pages[j], pages[i]];
716
+ card.insertBefore(pages[Math.min(i, j)], pages[Math.max(i, j)]);
717
+ pages.forEach((p, k) => {
718
+ const span = p.querySelector('.rolodex-pos');
719
+ if (span) span.textContent = String(sortKey(subjects[k]));
720
+ });
721
+ show(j);
722
+ emitSave(a);
723
+ });
724
+ };
725
+ }
726
+
727
+ // Re-run the whole build (used by Add / Remove in eager mode, where the
728
+ // pre-rendered `pages` array can't grow / shrink in place).
729
+ const rebuild = (at) => this._buildRolodexCards(
730
+ body, dataStore, docNode, subjects, properties, sortedBy,
731
+ { ...opts, startIndex: at });
732
+
733
+ if (addBtn) addBtn.addEventListener('click', () => {
734
+ const id = 'n' + Date.now().toString(36) + Math.floor(Math.random() * 46656).toString(36);
735
+ const subj = rdf.sym(docNode.value.split('#')[0] + '#' + id);
736
+ const inserts = [];
737
+ for (const c of (targets.classes || [])) inserts.push(rdf.st(subj, RDF_TYPE, c, docNode));
738
+ for (const p of (targets.subjectsOf || [])) {
739
+ const ex = dataStore.any(null, p, null, docNode); // anchor to an existing parent
740
+ if (ex) inserts.push(rdf.st(subj, p, ex, docNode));
741
+ }
742
+ if (!inserts.length) { console.warn('[sol-form] cannot derive a type for the new record'); return; }
743
+ dataStore.updater.update([], inserts, (_u, ok, msg) => {
744
+ if (!ok) { console.warn('[sol-form] add failed:', msg); return; }
745
+ subjects.push(subj);
746
+ rebuildList();
747
+ emitSave(subj);
748
+ if (lazy) show(subjects.length - 1);
749
+ else rebuild(subjects.length - 1);
750
+ });
751
+ });
752
+
753
+ if (removeBtn) removeBtn.addEventListener('click', () => {
754
+ if (!subjects.length) return;
755
+ // Two-step confirm on the button itself (no native dialog).
756
+ if (removeBtn.dataset.armed !== '1') {
757
+ removeBtn.dataset.armed = '1';
758
+ removeBtn.textContent = 'Click again to confirm';
759
+ clearTimeout(this._removeArmTimer);
760
+ this._removeArmTimer = setTimeout(() => {
761
+ removeBtn.dataset.armed = ''; removeBtn.textContent = 'Delete this record';
762
+ }, 3000);
763
+ return;
764
+ }
765
+ removeBtn.dataset.armed = ''; removeBtn.textContent = 'Delete this record';
766
+ const subj = subjects[index];
767
+ const dels = [
768
+ ...dataStore.statementsMatching(subj, null, null, docNode), // its own triples
769
+ ...dataStore.statementsMatching(null, null, subj, docNode), // catalog membership etc.
770
+ ];
771
+ dataStore.updater.update(dels.slice(), [], (_u, ok, msg) => {
772
+ if (!ok) { console.warn('[sol-form] remove failed:', msg); return; }
773
+ const at = subjects.indexOf(subj);
774
+ subjects.splice(at, 1);
775
+ rebuildList();
776
+ emitSave(subj);
777
+ const next = Math.min(at, Math.max(0, subjects.length - 1));
778
+ if (lazy) show(next);
779
+ else rebuild(next);
780
+ });
781
+ });
782
+
783
+ prevBtn.addEventListener('click', () => show(index - 1));
784
+ nextBtn.addEventListener('click', () => show(index + 1));
785
+ wrapper.addEventListener('keydown', e => {
786
+ if (e.target === jumpInput) return; // let the jump box use arrows
787
+ if (e.key === 'ArrowLeft') { e.preventDefault(); show(index - 1); }
788
+ else if (e.key === 'ArrowRight') { e.preventDefault(); show(index + 1); }
789
+ });
790
+
791
+ if (!subjects.length && editable) { card.replaceChildren(); counter.textContent = '0 of 0'; }
792
+ else show(Math.min(startIndex, subjects.length - 1));
793
+
794
+ const saveBar = this.shadowRoot.querySelector('.sol-form-save-bar');
795
+ if (saveBar) saveBar.style.display = 'none';
796
+ }
797
+
798
+ // ── save ──
799
+
800
+ _scheduleAutoSave() {
801
+ clearTimeout(this._saveTimer);
802
+ this._pendingSave = true;
803
+ this._saveTimer = setTimeout(() => this._save().catch(() => {}), AUTOSAVE_DEBOUNCE_MS);
804
+ }
805
+
806
+ // Manual save button (ordered forms).
807
+ _onSaveClick() {
808
+ this._save().catch(() => {});
809
+ }
810
+
811
+ // "Set" button next to the save-location input.
812
+ async _onSetLocation() {
813
+ const input = this.shadowRoot.querySelector('.sol-form-pod-input');
814
+ const url = (input.value || '').trim();
815
+ if (!url) { this._setStatus('err', 'Enter a URL'); return; }
816
+ try { new URL(url); } catch { this._setStatus('err', 'Invalid URL'); return; }
817
+ this._docUrl = url;
818
+ // Re-anchor the doc node so the serialized turtle is rooted at the chosen URL.
819
+ this._docNode = rdf.sym(url);
820
+ this._showLocationInput(false);
821
+ if (this._pendingSave || !this._ordered) await this._save().catch(() => {});
822
+ }
823
+
824
+ async _save() {
825
+ if (this._shapeText) {
826
+ const report = await this._validate();
827
+ this._showValidation(report);
828
+ if (!report.conforms) return;
829
+ }
830
+ if (!this._docUrl) {
831
+ this._showLocationInput(true);
832
+ this._setStatus('', 'Choose a save location');
833
+ return;
834
+ }
835
+
836
+ const btn = this.shadowRoot.querySelector('.sol-form-save-btn');
837
+ if (btn) btn.disabled = true;
838
+
839
+ try {
840
+ // For existing docs, each per-field edit already PATCHed via
841
+ // store.updater.update (solid-ui's basic widgets + our
842
+ // wireSingleSelectAutosave). Nothing left to save — just confirm.
843
+ // For brand-new docs (no on-server state yet), do a one-shot PUT
844
+ // to create the file, then flip the flag so subsequent edits flow
845
+ // through the per-field PATCH path normally.
846
+ if (!this._docExists) {
847
+ const turtle = this.getTurtle();
848
+ if (!turtle) { this._setStatus('err', 'Nothing to save'); return; }
849
+ this._setStatus('', 'Saving…');
850
+ await this._putViaUpdater(turtle);
851
+ this._docExists = true;
852
+ }
853
+ this._pendingSave = false;
854
+ this._setStatus('ok', this._ordered ? 'Saved' : 'Auto-saved');
855
+ this.dispatchEvent(new CustomEvent('sol-form-save', {
856
+ bubbles: true, composed: true,
857
+ detail: { subject: this._subject, target: this._docUrl },
858
+ }));
859
+ } catch (err) {
860
+ this._setStatus('err', err.message || 'Save failed');
861
+ } finally {
862
+ if (btn) btn.disabled = false;
863
+ }
864
+ }
865
+
866
+ // PUT the document via rdflib's UpdateManager.
867
+ _putViaUpdater(turtle) {
868
+ return new Promise((resolve, reject) => {
869
+ const stmts = this._store.statementsMatching(null, null, null, this._docNode);
870
+ this._store.updater.put(this._docNode, stmts, 'text/turtle',
871
+ (uri, ok, errMsg) => ok ? resolve() : reject(new Error(errMsg || 'PUT failed')));
872
+ });
873
+ }
874
+
875
+ // ── SHACL validation ──
876
+
877
+ async _loadShape(shapeUri) {
878
+ try {
879
+ const resp = await fetch(new URL(shapeUri, document.baseURI).href);
880
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
881
+ this._shapeText = await resp.text();
882
+ } catch (err) {
883
+ console.warn('<sol-form> could not load shape:', err);
884
+ this._shapeText = null;
885
+ }
886
+ }
887
+
888
+ async _validate() {
889
+ if (!this._shapeText) return { conforms: true, results: [] };
890
+ try {
891
+ const { Parser, Store } = await import('n3');
892
+ const SHACLValidator = (await import('rdf-validate-shacl')).default;
893
+ const parseToStore = (text, baseIRI) => {
894
+ const parser = new Parser({ baseIRI });
895
+ const s = new Store();
896
+ s.addQuads(parser.parse(text));
897
+ return s;
898
+ };
899
+ const turtle = this.getTurtle();
900
+ if (!turtle) return { conforms: false, results: [{ message: 'No data to validate' }] };
901
+ const shapesStore = parseToStore(this._shapeText, this.getAttribute('shape') || '');
902
+ const dataStore = parseToStore(turtle, this._docNode?.value || '');
903
+ return new SHACLValidator(shapesStore).validate(dataStore);
904
+ } catch (err) {
905
+ console.warn('<sol-form> SHACL validation failed:', err);
906
+ return { conforms: true, results: [] };
907
+ }
908
+ }
909
+
910
+ _showValidation(report) {
911
+ const el = this.shadowRoot.querySelector('.sol-form-validation-summary');
912
+ if (!report || report.conforms) { el.style.display = 'none'; return; }
913
+ const msgs = Array.from(report.results || []).map(r => {
914
+ const path = r.path ? r.path.value.replace(/.*[/#]/, '') : '';
915
+ const msg = (Array.isArray(r.message) ? r.message[0]?.value : r.message?.value) || 'Validation error';
916
+ return path ? `${path}: ${msg}` : msg;
917
+ });
918
+ el.innerHTML = `<strong>Validation errors:</strong><ul>${msgs.map(m => `<li>${this._esc(m)}</li>`).join('')}</ul>`;
919
+ el.style.display = 'block';
920
+ }
921
+
922
+ _hideValidation() {
923
+ const el = this.shadowRoot.querySelector('.sol-form-validation-summary');
924
+ if (el) el.style.display = 'none';
925
+ }
926
+
927
+ // ── small UI helpers ──
928
+
929
+ _setStatus(cls, msg) {
930
+ const el = this.shadowRoot.querySelector('.sol-form-save-status');
931
+ el.className = 'sol-form-save-status ' + cls;
932
+ el.textContent = msg;
933
+ }
934
+
935
+ _clearStatus() {
936
+ const el = this.shadowRoot.querySelector('.sol-form-save-status');
937
+ if (el) { el.className = 'sol-form-save-status'; el.textContent = ''; }
938
+ }
939
+
940
+ _esc(s) {
941
+ const d = document.createElement('div');
942
+ d.textContent = s;
943
+ return d.innerHTML;
944
+ }
945
+ }
946
+
947
+ define('sol-form', SolForm);
948
+ export { SolForm };
949
+ export default SolForm;