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
package/web/sol-form.js
ADDED
|
@@ -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><sol-form></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;
|