@ulb-darmstadt/shacl-form 1.6.1 → 1.6.3
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 +8 -4
- package/dist/exports.d.ts +1 -1
- package/dist/form-bootstrap.js +1 -1
- package/dist/form-default.js +1 -1
- package/dist/form-material.js +1 -359
- package/dist/form-material.js.LICENSE.txt +0 -66
- package/dist/form.d.ts +4 -3
- package/dist/loader.d.ts +1 -0
- package/dist/plugins/leaflet.js +1 -1
- package/dist/plugins/mapbox.js +1 -1
- package/package.json +4 -3
- package/src/config.ts +113 -0
- package/src/constants.ts +25 -0
- package/src/constraints.ts +106 -0
- package/src/exports.ts +6 -0
- package/src/form-bootstrap.ts +12 -0
- package/src/form-default.ts +12 -0
- package/src/form-material.ts +12 -0
- package/src/form.ts +290 -0
- package/src/globals.d.ts +2 -0
- package/src/group.ts +35 -0
- package/src/loader.ts +172 -0
- package/src/node.ts +167 -0
- package/src/plugin.ts +60 -0
- package/src/plugins/file-upload.ts +26 -0
- package/src/plugins/fixed-list.ts +19 -0
- package/src/plugins/leaflet.ts +196 -0
- package/src/plugins/map-util.ts +41 -0
- package/src/plugins/mapbox.ts +157 -0
- package/src/property-template.ts +132 -0
- package/src/property.ts +188 -0
- package/src/serialize.ts +76 -0
- package/src/shacl-engine.d.ts +2 -0
- package/src/styles.css +59 -0
- package/src/theme.ts +132 -0
- package/src/themes/bootstrap.css +6 -0
- package/src/themes/bootstrap.ts +44 -0
- package/src/themes/default.css +4 -0
- package/src/themes/default.ts +240 -0
- package/src/themes/material.css +7 -0
- package/src/themes/material.ts +239 -0
- package/src/util.ts +116 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Point, Polygon } from 'geojson'
|
|
2
|
+
|
|
3
|
+
export type Geometry = Point | Polygon
|
|
4
|
+
|
|
5
|
+
export const worldBounds: [number, number][] = [[-90, -180], [90, 180]]
|
|
6
|
+
|
|
7
|
+
export function wktToGeometry(wkt: string): Geometry | undefined {
|
|
8
|
+
const pointCoords = wkt.match(/^POINT\((.*)\)$/)
|
|
9
|
+
if (pointCoords?.length == 2) {
|
|
10
|
+
const xy = pointCoords[1].split(' ')
|
|
11
|
+
if (xy.length === 2) {
|
|
12
|
+
return { type: 'Point', coordinates: [parseFloat(xy[0]), parseFloat(xy[1])] }
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
const polygonCoords = wkt.match(/^POLYGON[(]{2}(.*)[)]{2}$/)
|
|
16
|
+
if (polygonCoords?.length == 2) {
|
|
17
|
+
const split = polygonCoords[1].split(',')
|
|
18
|
+
if (split.length > 2) {
|
|
19
|
+
const coords: number[][][] = []
|
|
20
|
+
const outer: number[][] = []
|
|
21
|
+
coords.push(outer)
|
|
22
|
+
for (const coord of split) {
|
|
23
|
+
const xy = coord.split(' ')
|
|
24
|
+
if (xy.length === 2) {
|
|
25
|
+
outer.push([parseFloat(xy[0]), parseFloat(xy[1])])
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return { type: 'Polygon', coordinates: coords }
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function geometryToWkt(geometry: Geometry): string {
|
|
34
|
+
if (geometry.type === 'Point') {
|
|
35
|
+
return `POINT(${geometry.coordinates.join(' ')})`
|
|
36
|
+
} else if (geometry.type === 'Polygon') {
|
|
37
|
+
return `POLYGON((${geometry.coordinates[0].map(item => { return item.join(' ') }).join(',')}))`
|
|
38
|
+
} else {
|
|
39
|
+
return ''
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { Term } from '@rdfjs/types'
|
|
2
|
+
import { Plugin, PluginOptions } from '../plugin'
|
|
3
|
+
import { ShaclPropertyTemplate } from '../property-template'
|
|
4
|
+
import { Editor, fieldFactory } from '../theme'
|
|
5
|
+
import { Map, NavigationControl, FullscreenControl, LngLatBounds, LngLatLike } from 'mapbox-gl'
|
|
6
|
+
import MapboxDraw from '@mapbox/mapbox-gl-draw'
|
|
7
|
+
import mapboxGlCss from 'mapbox-gl/dist/mapbox-gl.css'
|
|
8
|
+
import mapboxGlDrawCss from '@mapbox/mapbox-gl-draw/dist/mapbox-gl-draw.css'
|
|
9
|
+
import { Geometry, geometryToWkt, wktToGeometry } from './map-util'
|
|
10
|
+
|
|
11
|
+
const css = `
|
|
12
|
+
#shaclMapDialog .closeButton { position: absolute; right: 0; top: 0; z-index: 1; padding: 6px 8px; cursor: pointer; border: 0; background-color: #FFFA; font-size: 24px; }
|
|
13
|
+
#shaclMapDialog { padding: 0; width:90vw; height: 90vh; margin: auto; }
|
|
14
|
+
#shaclMapDialog::backdrop { background-color: #0007; }
|
|
15
|
+
#shaclMapDialog .closeButton:hover { background-color: #FFF }
|
|
16
|
+
#shaclMapDialog .hint { position: absolute; right: 60px; top: 3px; z-index: 1; padding: 4px 6px; background-color: #FFFA; border-radius: 4px; }
|
|
17
|
+
.mapboxgl-map { min-height: 300px; }
|
|
18
|
+
#shaclMapDialogContainer { width:100%; height: 100% }
|
|
19
|
+
`
|
|
20
|
+
const dialogTemplate = `
|
|
21
|
+
<dialog id="shaclMapDialog" onclick="event.target==this && this.close()">
|
|
22
|
+
<div id="shaclMapDialogContainer"></div>
|
|
23
|
+
<div class="hint">ⓘ Draw a polygon or point, then close dialog</div>
|
|
24
|
+
<button class="closeButton" type="button" onclick="this.parentElement.close()">✕</button>
|
|
25
|
+
</dialog>`
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
export class MapboxPlugin extends Plugin {
|
|
29
|
+
map: Map | undefined
|
|
30
|
+
draw: MapboxDraw | undefined
|
|
31
|
+
currentEditor: Editor | undefined
|
|
32
|
+
apiKey: string
|
|
33
|
+
|
|
34
|
+
constructor(options: PluginOptions, apiKey: string) {
|
|
35
|
+
super(options, mapboxGlCss + '\n' + mapboxGlDrawCss + '\n' + css)
|
|
36
|
+
this.apiKey = apiKey
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
initEditMode(form: HTMLElement): HTMLDialogElement {
|
|
40
|
+
form.insertAdjacentHTML('beforeend', dialogTemplate)
|
|
41
|
+
const container = form.querySelector('#shaclMapDialogContainer') as HTMLElement
|
|
42
|
+
this.map = new Map({
|
|
43
|
+
container: container,
|
|
44
|
+
style: 'mapbox://styles/mapbox/satellite-streets-v11',
|
|
45
|
+
zoom: 5,
|
|
46
|
+
center: { lng: 8.657238961696038, lat: 49.87627570549512 },
|
|
47
|
+
attributionControl: false,
|
|
48
|
+
accessToken: this.apiKey
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
this.draw = new MapboxDraw({
|
|
52
|
+
displayControlsDefault: false,
|
|
53
|
+
controls: { point: true, polygon: true }
|
|
54
|
+
})
|
|
55
|
+
this.map.addControl(new NavigationControl(), 'top-left')
|
|
56
|
+
this.map.addControl(this.draw, 'top-left')
|
|
57
|
+
|
|
58
|
+
this.map.on('idle', () => {
|
|
59
|
+
// this fixes wrong size of canvas
|
|
60
|
+
this.map!.resize()
|
|
61
|
+
})
|
|
62
|
+
// @ts-ignore
|
|
63
|
+
this.map.on('draw.create', () => this.deleteAllButLastDrawing())
|
|
64
|
+
|
|
65
|
+
const dialog = form.querySelector('#shaclMapDialog') as HTMLDialogElement
|
|
66
|
+
dialog.addEventListener('close', () => {
|
|
67
|
+
const scrollY = document.body.style.top
|
|
68
|
+
document.body.style.position = ''
|
|
69
|
+
document.body.style.top = ''
|
|
70
|
+
window.scrollTo(0, parseInt(scrollY || '0') * -1)
|
|
71
|
+
// set wkt in editor
|
|
72
|
+
const data = this.draw!.getAll()
|
|
73
|
+
if (data && data.features.length && this.currentEditor) {
|
|
74
|
+
const geometry = data.features[0].geometry as Geometry
|
|
75
|
+
if (geometry.coordinates?.length) {
|
|
76
|
+
const wkt = geometryToWkt(geometry)
|
|
77
|
+
this.currentEditor.value = wkt
|
|
78
|
+
this.currentEditor.dispatchEvent(new Event('change', { bubbles: true }))
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
return dialog
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
createEditor(template: ShaclPropertyTemplate, value?: Term): HTMLElement {
|
|
86
|
+
let dialog = template.config.form.querySelector('#shaclMapDialog') as HTMLDialogElement
|
|
87
|
+
if (!dialog) {
|
|
88
|
+
dialog = this.initEditMode(template.config.form)
|
|
89
|
+
}
|
|
90
|
+
const button = template.config.theme.createButton('Open map...', false)
|
|
91
|
+
button.style.marginLeft = '5px'
|
|
92
|
+
button.classList.add('open-map-button')
|
|
93
|
+
button.onclick = () => {
|
|
94
|
+
this.currentEditor = instance.querySelector('.editor') as Editor
|
|
95
|
+
this.draw?.deleteAll()
|
|
96
|
+
|
|
97
|
+
const wkt = this.currentEditor.value || ''
|
|
98
|
+
const geometry = wktToGeometry(wkt)
|
|
99
|
+
if (geometry && geometry.coordinates?.length) {
|
|
100
|
+
this.draw?.add(geometry)
|
|
101
|
+
this.fitToGeometry(this.map!, geometry)
|
|
102
|
+
} else {
|
|
103
|
+
this.map?.setZoom(5)
|
|
104
|
+
}
|
|
105
|
+
document.body.style.top = `-${window.scrollY}px`
|
|
106
|
+
document.body.style.position = 'fixed'
|
|
107
|
+
dialog.showModal()
|
|
108
|
+
}
|
|
109
|
+
const instance = fieldFactory(template, value || null)
|
|
110
|
+
instance.appendChild(button)
|
|
111
|
+
return instance
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
createViewer(template: ShaclPropertyTemplate, value: Term): HTMLElement {
|
|
115
|
+
const container = document.createElement('div')
|
|
116
|
+
const geometry = wktToGeometry(value.value)
|
|
117
|
+
if (geometry?.coordinates?.length) {
|
|
118
|
+
// wait for container to be available in DOM
|
|
119
|
+
setTimeout(() => {
|
|
120
|
+
const draw = new MapboxDraw({ displayControlsDefault: false })
|
|
121
|
+
const map = new Map({
|
|
122
|
+
container: container,
|
|
123
|
+
style: 'mapbox://styles/mapbox/satellite-streets-v11',
|
|
124
|
+
zoom: 5,
|
|
125
|
+
attributionControl: false,
|
|
126
|
+
accessToken: this.apiKey
|
|
127
|
+
})
|
|
128
|
+
map.addControl(draw)
|
|
129
|
+
map.addControl(new FullscreenControl())
|
|
130
|
+
draw.add(geometry)
|
|
131
|
+
this.fitToGeometry(map, geometry)
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
return container
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
fitToGeometry(map: Map, geometry: Geometry) {
|
|
138
|
+
if (typeof geometry.coordinates[0] === 'number') {
|
|
139
|
+
// e.g. Point
|
|
140
|
+
map.setCenter(geometry.coordinates as LngLatLike)
|
|
141
|
+
map.setZoom(15)
|
|
142
|
+
} else {
|
|
143
|
+
// e.g. Polygon
|
|
144
|
+
const bounds = geometry.coordinates[0].reduce((bounds, coord) => {
|
|
145
|
+
return bounds.extend(coord as mapboxgl.LngLatLike)
|
|
146
|
+
}, new LngLatBounds(geometry.coordinates[0][0] as mapboxgl.LngLatLike, geometry.coordinates[0][0] as mapboxgl.LngLatLike))
|
|
147
|
+
map.fitBounds(bounds, { padding: 20, animate: false })
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
deleteAllButLastDrawing() {
|
|
152
|
+
const data = this.draw!.getAll()
|
|
153
|
+
for (let i = 0; i < data.features.length - 1; i++) {
|
|
154
|
+
this.draw!.delete(data.features[i].id as string)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { Literal, NamedNode, Quad, DataFactory } from 'n3'
|
|
2
|
+
import { Term } from '@rdfjs/types'
|
|
3
|
+
import { OWL_PREDICATE_IMPORTS, PREFIX_DASH, PREFIX_OA, PREFIX_RDF, PREFIX_SHACL, SHACL_PREDICATE_CLASS, SHACL_PREDICATE_TARGET_CLASS } from './constants'
|
|
4
|
+
import { Config } from './config'
|
|
5
|
+
import { findLabel, removePrefixes } from './util'
|
|
6
|
+
import { ShaclNode } from './node'
|
|
7
|
+
|
|
8
|
+
const mappers: Record<string, (template: ShaclPropertyTemplate, term: Term) => void> = {
|
|
9
|
+
[`${PREFIX_SHACL}name`]: (template, term) => { const literal = term as Literal; if (!template.name || literal.language === template.config.attributes.language) { template.name = literal } },
|
|
10
|
+
[`${PREFIX_SHACL}description`]: (template, term) => { const literal = term as Literal; if (!template.description || literal.language === template.config.attributes.language) { template.description = literal } },
|
|
11
|
+
[`${PREFIX_SHACL}path`]: (template, term) => { template.path = term.value },
|
|
12
|
+
[`${PREFIX_SHACL}node`]: (template, term) => { template.node = term as NamedNode },
|
|
13
|
+
[`${PREFIX_SHACL}datatype`]: (template, term) => { template.datatype = term as NamedNode },
|
|
14
|
+
[`${PREFIX_SHACL}nodeKind`]: (template, term) => { template.nodeKind = term as NamedNode },
|
|
15
|
+
[`${PREFIX_SHACL}minCount`]: (template, term) => { template.minCount = parseInt(term.value) },
|
|
16
|
+
[`${PREFIX_SHACL}maxCount`]: (template, term) => { template.maxCount = parseInt(term.value) },
|
|
17
|
+
[`${PREFIX_SHACL}minLength`]: (template, term) => { template.minLength = parseInt(term.value) },
|
|
18
|
+
[`${PREFIX_SHACL}maxLength`]: (template, term) => { template.maxLength = parseInt(term.value) },
|
|
19
|
+
[`${PREFIX_SHACL}minInclusive`]: (template, term) => { template.minInclusive = parseInt(term.value) },
|
|
20
|
+
[`${PREFIX_SHACL}maxInclusive`]: (template, term) => { template.maxInclusive = parseInt(term.value) },
|
|
21
|
+
[`${PREFIX_SHACL}minExclusive`]: (template, term) => { template.minExclusive = parseInt(term.value) },
|
|
22
|
+
[`${PREFIX_SHACL}maxExclusive`]: (template, term) => { template.maxExclusive = parseInt(term.value) },
|
|
23
|
+
[`${PREFIX_SHACL}pattern`]: (template, term) => { template.pattern = term.value },
|
|
24
|
+
[`${PREFIX_SHACL}order`]: (template, term) => { template.order = parseInt(term.value) },
|
|
25
|
+
[`${PREFIX_DASH}singleLine`]: (template, term) => { template.singleLine = term.value === 'true' },
|
|
26
|
+
[`${PREFIX_OA}styleClass`]: (template, term) => { template.cssClass = term.value },
|
|
27
|
+
[`${PREFIX_SHACL}and`]: (template, term) => { template.shaclAnd = term.value },
|
|
28
|
+
[`${PREFIX_SHACL}in`]: (template, term) => { template.shaclIn = term.value },
|
|
29
|
+
// sh:datatype might be undefined, but sh:languageIn defined. this is undesired. the spec says, that strings without a lang tag are not valid if sh:languageIn is set. but the shacl validator accepts these as valid. to prevent this, we just set the datatype here to 'langString'.
|
|
30
|
+
[`${PREFIX_SHACL}languageIn`]: (template, term) => { template.languageIn = template.config.lists[term.value]; template.datatype = DataFactory.namedNode(PREFIX_RDF + 'langString') },
|
|
31
|
+
[`${PREFIX_SHACL}defaultValue`]: (template, term) => { template.defaultValue = term },
|
|
32
|
+
[`${PREFIX_SHACL}hasValue`]: (template, term) => { template.hasValue = term },
|
|
33
|
+
[OWL_PREDICATE_IMPORTS.id]: (template, term) => { template.owlImports.push(term as NamedNode) },
|
|
34
|
+
[SHACL_PREDICATE_CLASS.id]: (template, term) => {
|
|
35
|
+
template.class = term as NamedNode
|
|
36
|
+
// try to find node shape that has requested target class
|
|
37
|
+
const nodeShapes = template.config.shapesGraph.getSubjects(SHACL_PREDICATE_TARGET_CLASS, term, null)
|
|
38
|
+
if (nodeShapes.length > 0) {
|
|
39
|
+
template.node = nodeShapes[0] as NamedNode
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
[`${PREFIX_SHACL}or`]: (template, term) => {
|
|
43
|
+
const list = template.config.lists[term.value]
|
|
44
|
+
if (list?.length) {
|
|
45
|
+
template.shaclOr = list
|
|
46
|
+
} else {
|
|
47
|
+
console.error('list not found:', term.value, 'existing lists:', template.config.lists)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class ShaclPropertyTemplate {
|
|
53
|
+
parent: ShaclNode
|
|
54
|
+
label = ''
|
|
55
|
+
name: Literal | undefined
|
|
56
|
+
description: Literal | undefined
|
|
57
|
+
path: string | undefined
|
|
58
|
+
node: NamedNode | undefined
|
|
59
|
+
class: NamedNode | undefined
|
|
60
|
+
minCount: number | undefined
|
|
61
|
+
maxCount: number | undefined
|
|
62
|
+
minLength: number | undefined
|
|
63
|
+
maxLength: number | undefined
|
|
64
|
+
minInclusive: number | undefined
|
|
65
|
+
maxInclusive: number | undefined
|
|
66
|
+
minExclusive: number | undefined
|
|
67
|
+
maxExclusive: number | undefined
|
|
68
|
+
singleLine: boolean | undefined
|
|
69
|
+
cssClass: string | undefined
|
|
70
|
+
defaultValue: Term | undefined
|
|
71
|
+
pattern: string | undefined
|
|
72
|
+
order: number | undefined
|
|
73
|
+
nodeKind: NamedNode | undefined
|
|
74
|
+
shaclAnd: string | undefined
|
|
75
|
+
shaclIn: string | undefined
|
|
76
|
+
shaclOr: Term[] | undefined
|
|
77
|
+
languageIn: Term[] | undefined
|
|
78
|
+
datatype: NamedNode | undefined
|
|
79
|
+
hasValue: Term | undefined
|
|
80
|
+
owlImports: NamedNode[] = []
|
|
81
|
+
|
|
82
|
+
config: Config
|
|
83
|
+
extendedShapes: NamedNode[] | undefined
|
|
84
|
+
|
|
85
|
+
constructor(quads: Quad[], parent: ShaclNode, config: Config) {
|
|
86
|
+
this.parent = parent
|
|
87
|
+
this.config = config
|
|
88
|
+
this.merge(quads)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
merge(quads: Quad[]): ShaclPropertyTemplate {
|
|
92
|
+
for (const quad of quads) {
|
|
93
|
+
mappers[quad.predicate.id]?.call(this, this, quad.object)
|
|
94
|
+
}
|
|
95
|
+
// provide best fitting label for UI
|
|
96
|
+
this.label = this.name?.value || findLabel(quads, this.config.languages)
|
|
97
|
+
if (!this.label && !this.shaclAnd) {
|
|
98
|
+
this.label = this.path ? removePrefixes(this.path, this.config.prefixes) : 'unknown'
|
|
99
|
+
}
|
|
100
|
+
// resolve extended shapes
|
|
101
|
+
if (this.node || this.shaclAnd) {
|
|
102
|
+
this.extendedShapes = []
|
|
103
|
+
if (this.node) {
|
|
104
|
+
this.extendedShapes.push(this.node)
|
|
105
|
+
}
|
|
106
|
+
if (this.shaclAnd) {
|
|
107
|
+
const list = this.config.lists[this.shaclAnd]
|
|
108
|
+
if (list?.length) {
|
|
109
|
+
for (const node of list) {
|
|
110
|
+
this.extendedShapes.push(node as NamedNode)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return this
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
clone(): ShaclPropertyTemplate {
|
|
119
|
+
const copy = Object.assign({}, this)
|
|
120
|
+
// arrays are not cloned but referenced, so create them manually
|
|
121
|
+
copy.owlImports = [ ...this.owlImports ]
|
|
122
|
+
if (this.languageIn) {
|
|
123
|
+
copy.languageIn = [ ...this.languageIn ]
|
|
124
|
+
}
|
|
125
|
+
if (this.shaclOr) {
|
|
126
|
+
copy.shaclOr = [ ...this.shaclOr ]
|
|
127
|
+
}
|
|
128
|
+
copy.merge = this.merge.bind(copy)
|
|
129
|
+
copy.clone = this.clone.bind(copy)
|
|
130
|
+
return copy
|
|
131
|
+
}
|
|
132
|
+
}
|
package/src/property.ts
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { BlankNode, DataFactory, NamedNode, Store } from 'n3'
|
|
2
|
+
import { Term } from '@rdfjs/types'
|
|
3
|
+
import { ShaclNode } from './node'
|
|
4
|
+
import { focusFirstInputElement } from './util'
|
|
5
|
+
import { createShaclOrConstraint, resolveShaclOrConstraint } from './constraints'
|
|
6
|
+
import { Config } from './config'
|
|
7
|
+
import { ShaclPropertyTemplate } from './property-template'
|
|
8
|
+
import { Editor, fieldFactory } from './theme'
|
|
9
|
+
import { toRDF } from './serialize'
|
|
10
|
+
import { findPlugin } from './plugin'
|
|
11
|
+
|
|
12
|
+
export class ShaclProperty extends HTMLElement {
|
|
13
|
+
template: ShaclPropertyTemplate
|
|
14
|
+
addButton: HTMLElement | undefined
|
|
15
|
+
|
|
16
|
+
constructor(shaclSubject: BlankNode | NamedNode, parent: ShaclNode, config: Config, valueSubject?: NamedNode | BlankNode) {
|
|
17
|
+
super()
|
|
18
|
+
this.template = new ShaclPropertyTemplate(config.shapesGraph.getQuads(shaclSubject, null, null, null), parent, config)
|
|
19
|
+
|
|
20
|
+
if (this.template.order !== undefined) {
|
|
21
|
+
this.style.order = `${this.template.order}`
|
|
22
|
+
}
|
|
23
|
+
if (this.template.cssClass) {
|
|
24
|
+
this.classList.add(this.template.cssClass)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (config.editMode) {
|
|
28
|
+
this.addButton = document.createElement('a')
|
|
29
|
+
this.addButton.innerText = this.template.label
|
|
30
|
+
this.addButton.title = 'Add ' + this.template.label
|
|
31
|
+
this.addButton.classList.add('control-button', 'add-button')
|
|
32
|
+
this.addButton.addEventListener('click', _ => {
|
|
33
|
+
const instance = this.addPropertyInstance()
|
|
34
|
+
instance.classList.add('fadeIn')
|
|
35
|
+
this.updateControls()
|
|
36
|
+
focusFirstInputElement(instance)
|
|
37
|
+
setTimeout(() => {
|
|
38
|
+
instance.classList.remove('fadeIn')
|
|
39
|
+
}, 200)
|
|
40
|
+
})
|
|
41
|
+
this.appendChild(this.addButton)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// bind existing values
|
|
45
|
+
if (this.template.path) {
|
|
46
|
+
const values = valueSubject ? config.dataGraph.getQuads(valueSubject, this.template.path, null, null) : []
|
|
47
|
+
let valuesContainHasValue = false
|
|
48
|
+
for (const value of values) {
|
|
49
|
+
this.addPropertyInstance(value.object)
|
|
50
|
+
if (this.template.hasValue && value.object.equals(this.template.hasValue)) {
|
|
51
|
+
valuesContainHasValue = true
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (config.editMode && this.template.hasValue && !valuesContainHasValue) {
|
|
55
|
+
// sh:hasValue is defined in shapes graph, but does not exist in data graph, so force it
|
|
56
|
+
this.addPropertyInstance(this.template.hasValue)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (config.editMode) {
|
|
61
|
+
this.addEventListener('change', () => { this.updateControls() })
|
|
62
|
+
this.updateControls()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (this.template.extendedShapes?.length && this.template.config.attributes.collapse !== null && (!this.template.maxCount || this.template.maxCount > 1)) {
|
|
66
|
+
// in view mode, show collapsible only when we have something to show
|
|
67
|
+
if (config.editMode || this.childElementCount > 0) {
|
|
68
|
+
const collapsible = this
|
|
69
|
+
collapsible.classList.add('collapsible')
|
|
70
|
+
if (this.template.config.attributes.collapse === 'open') {
|
|
71
|
+
collapsible.classList.add('open')
|
|
72
|
+
}
|
|
73
|
+
const activator = document.createElement('h1')
|
|
74
|
+
activator.classList.add('activator')
|
|
75
|
+
activator.innerText = this.template.label
|
|
76
|
+
activator.addEventListener('click', () => {
|
|
77
|
+
collapsible.classList.toggle('open')
|
|
78
|
+
})
|
|
79
|
+
this.prepend(activator)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
addPropertyInstance(value?: Term): HTMLElement {
|
|
85
|
+
let instance: HTMLElement
|
|
86
|
+
if (this.template.shaclOr?.length) {
|
|
87
|
+
if (value) {
|
|
88
|
+
instance = createPropertyInstance(resolveShaclOrConstraint(this.template, value), value, true)
|
|
89
|
+
} else {
|
|
90
|
+
instance = createShaclOrConstraint(this.template.shaclOr, this, this.template.config)
|
|
91
|
+
appendRemoveButton(instance, '')
|
|
92
|
+
}
|
|
93
|
+
} else {
|
|
94
|
+
instance = createPropertyInstance(this.template, value)
|
|
95
|
+
}
|
|
96
|
+
if (this.template.config.editMode) {
|
|
97
|
+
this.insertBefore(instance, this.addButton!)
|
|
98
|
+
} else {
|
|
99
|
+
this.appendChild(instance)
|
|
100
|
+
}
|
|
101
|
+
return instance
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
updateControls() {
|
|
105
|
+
let instanceCount = this.querySelectorAll(":scope > .property-instance, :scope > .shacl-or-constraint, :scope > shacl-node").length
|
|
106
|
+
if (instanceCount === 0 && (!this.template.extendedShapes?.length || (this.template.minCount !== undefined && this.template.minCount > 0))) {
|
|
107
|
+
this.addPropertyInstance()
|
|
108
|
+
instanceCount = this.querySelectorAll(":scope > .property-instance, :scope > .shacl-or-constraint, :scope > shacl-node").length
|
|
109
|
+
}
|
|
110
|
+
let mayRemove: boolean
|
|
111
|
+
if (this.template.minCount !== undefined) {
|
|
112
|
+
mayRemove = instanceCount > this.template.minCount
|
|
113
|
+
} else {
|
|
114
|
+
mayRemove = (this.template.extendedShapes && this.template.extendedShapes.length > 0) || instanceCount > 1
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const mayAdd = this.template.maxCount === undefined || instanceCount < this.template.maxCount
|
|
118
|
+
this.classList.toggle('may-remove', mayRemove)
|
|
119
|
+
this.classList.toggle('may-add', mayAdd)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
toRDF(graph: Store, subject: NamedNode | BlankNode) {
|
|
123
|
+
for (const instance of this.querySelectorAll(':scope > .property-instance')) {
|
|
124
|
+
const pathNode = DataFactory.namedNode((instance as HTMLElement).dataset.path!)
|
|
125
|
+
if (instance.firstChild instanceof ShaclNode) {
|
|
126
|
+
const quadCount = graph.size
|
|
127
|
+
const shapeSubject = instance.firstChild.toRDF(graph)
|
|
128
|
+
graph.addQuad(subject, pathNode, shapeSubject)
|
|
129
|
+
} else {
|
|
130
|
+
const editor = instance.querySelector('.editor') as Editor
|
|
131
|
+
const value = toRDF(editor)
|
|
132
|
+
if (value) {
|
|
133
|
+
graph.addQuad(subject, pathNode, value)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function createPropertyInstance(template: ShaclPropertyTemplate, value?: Term, forceRemovable = false): HTMLElement {
|
|
141
|
+
let instance: HTMLElement
|
|
142
|
+
if (template.extendedShapes?.length) {
|
|
143
|
+
instance = document.createElement('div')
|
|
144
|
+
instance.classList.add('property-instance')
|
|
145
|
+
for (const node of template.extendedShapes) {
|
|
146
|
+
instance.appendChild(new ShaclNode(node, template.config, value as NamedNode | BlankNode | undefined, template.parent, template.nodeKind, template.label))
|
|
147
|
+
}
|
|
148
|
+
} else {
|
|
149
|
+
const plugin = findPlugin(template.path, template.datatype?.value)
|
|
150
|
+
if (plugin) {
|
|
151
|
+
if (template.config.editMode) {
|
|
152
|
+
instance = plugin.createEditor(template, value)
|
|
153
|
+
} else {
|
|
154
|
+
instance = plugin.createViewer(template, value!)
|
|
155
|
+
}
|
|
156
|
+
} else {
|
|
157
|
+
instance = fieldFactory(template, value || null)
|
|
158
|
+
}
|
|
159
|
+
instance.classList.add('property-instance')
|
|
160
|
+
}
|
|
161
|
+
if (template.config.editMode) {
|
|
162
|
+
appendRemoveButton(instance, template.label, forceRemovable)
|
|
163
|
+
}
|
|
164
|
+
instance.dataset.path = template.path
|
|
165
|
+
return instance
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function appendRemoveButton(instance: HTMLElement, label: string, forceRemovable = false) {
|
|
169
|
+
const removeButton = document.createElement('a')
|
|
170
|
+
removeButton.innerText = '\u00d7'
|
|
171
|
+
removeButton.classList.add('control-button', 'btn', 'remove-button')
|
|
172
|
+
removeButton.title = 'Remove ' + label
|
|
173
|
+
removeButton.addEventListener('click', _ => {
|
|
174
|
+
instance.classList.remove('fadeIn')
|
|
175
|
+
instance.classList.add('fadeOut')
|
|
176
|
+
setTimeout(() => {
|
|
177
|
+
const parent = instance.parentElement
|
|
178
|
+
instance.remove()
|
|
179
|
+
parent?.dispatchEvent(new Event('change', { bubbles: true, cancelable: true }))
|
|
180
|
+
}, 200)
|
|
181
|
+
})
|
|
182
|
+
if (forceRemovable) {
|
|
183
|
+
removeButton.classList.add('persistent')
|
|
184
|
+
}
|
|
185
|
+
instance.appendChild(removeButton)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
window.customElements.define('shacl-property', ShaclProperty)
|
package/src/serialize.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { DataFactory, NamedNode, Writer, Quad, Literal, Prefixes } from 'n3'
|
|
2
|
+
import { PREFIX_XSD, RDF_PREDICATE_TYPE, PREFIX_SHACL } from './constants'
|
|
3
|
+
import { Editor } from './theme'
|
|
4
|
+
import { NodeObject } from 'jsonld'
|
|
5
|
+
|
|
6
|
+
export function serialize(quads: Quad[], format: string, prefixes?: Prefixes): string {
|
|
7
|
+
if (format === 'application/ld+json') {
|
|
8
|
+
return serializeJsonld(quads)
|
|
9
|
+
} else {
|
|
10
|
+
const writer = new Writer({ format: format, prefixes: prefixes })
|
|
11
|
+
writer.addQuads(quads)
|
|
12
|
+
let serialized = ''
|
|
13
|
+
writer.end((error, result) => {
|
|
14
|
+
if (error) {
|
|
15
|
+
console.error(error)
|
|
16
|
+
}
|
|
17
|
+
serialized = result
|
|
18
|
+
})
|
|
19
|
+
return serialized
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function serializeJsonld(quads: Quad[]): string {
|
|
24
|
+
const triples: NodeObject[] = []
|
|
25
|
+
for (const quad of quads) {
|
|
26
|
+
const triple: NodeObject = { '@id': quad.subject.id }
|
|
27
|
+
|
|
28
|
+
if (quad.predicate === RDF_PREDICATE_TYPE) {
|
|
29
|
+
triple['@type'] = quad.object.id
|
|
30
|
+
} else {
|
|
31
|
+
let object: string | {} = quad.object.value
|
|
32
|
+
if (quad.object instanceof Literal) {
|
|
33
|
+
if (quad.object.language) {
|
|
34
|
+
object = { '@language': quad.object.language, '@value': quad.object.value }
|
|
35
|
+
} else if (quad.object.datatype && quad.object.datatype.value !== `${PREFIX_XSD}#string`) {
|
|
36
|
+
object = { '@type': quad.object.datatype.value, '@value': quad.object.value }
|
|
37
|
+
}
|
|
38
|
+
} else {
|
|
39
|
+
object = { '@id': quad.object.id }
|
|
40
|
+
}
|
|
41
|
+
triple[quad.predicate.value] = object
|
|
42
|
+
}
|
|
43
|
+
triples.push(triple)
|
|
44
|
+
}
|
|
45
|
+
return JSON.stringify(triples)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function toRDF(editor: Editor): Literal | NamedNode | undefined {
|
|
49
|
+
let languageOrDatatype: NamedNode<string> | string | undefined = editor['shaclDatatype']
|
|
50
|
+
let value: number | string = editor.value
|
|
51
|
+
if (value) {
|
|
52
|
+
if (editor.dataset.class || editor.dataset.nodeKind === PREFIX_SHACL + 'IRI') {
|
|
53
|
+
return DataFactory.namedNode(value)
|
|
54
|
+
} else {
|
|
55
|
+
if (editor.dataset.lang) {
|
|
56
|
+
languageOrDatatype = editor.dataset.lang
|
|
57
|
+
}
|
|
58
|
+
else if (editor['type'] === 'number') {
|
|
59
|
+
value = parseFloat(value)
|
|
60
|
+
}
|
|
61
|
+
else if (editor['type'] === 'file' && editor['binaryData']) {
|
|
62
|
+
value = editor['binaryData']
|
|
63
|
+
}
|
|
64
|
+
else if (editor['type'] === 'datetime-local') {
|
|
65
|
+
// if seconds in value are 0, the input field omits them which is then not a valid xsd:dateTime
|
|
66
|
+
value = new Date(value).toISOString().slice(0, 19)
|
|
67
|
+
}
|
|
68
|
+
return DataFactory.literal(value, languageOrDatatype)
|
|
69
|
+
}
|
|
70
|
+
} else if (editor['type'] === 'checkbox' || editor.getAttribute('type') === 'checkbox') {
|
|
71
|
+
// emit boolean 'false' only when required
|
|
72
|
+
if (editor['checked'] || parseInt(editor.dataset.minCount || '0') > 0) {
|
|
73
|
+
return DataFactory.literal(editor['checked'] ? 'true' : 'false', languageOrDatatype)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
package/src/styles.css
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
form { box-sizing: border-box; display:block; --label-width: 8em; --caret-size: 10px; }
|
|
2
|
+
form.mode-edit { padding-left: 1em; }
|
|
3
|
+
form *, form ::after, form ::before { box-sizing: inherit; }
|
|
4
|
+
shacl-node, .shacl-group { display: flex; flex-direction: column; width: 100%; position: relative; }
|
|
5
|
+
shacl-node .control-button { text-decoration: none; cursor: pointer; border: 1px solid transparent; border-radius: 4px; padding: 2px 4px; }
|
|
6
|
+
shacl-node .control-button:hover { border-color: inherit; }
|
|
7
|
+
shacl-node .remove-button { margin-left: 4px; }
|
|
8
|
+
shacl-node .add-button { font-size: 0.8rem; color: #555; margin: 4px 24px 0 0; }
|
|
9
|
+
shacl-node .add-button:before { content: '+'; margin-right: 0.2em; }
|
|
10
|
+
shacl-node .add-button:hover { color: inherit; }
|
|
11
|
+
shacl-node h1 { font-size: 1.1rem; border-bottom: 1px solid; margin-top: 4px; color: #555; }
|
|
12
|
+
shacl-property { display: flex; flex-direction: column; align-items: end; position: relative; }
|
|
13
|
+
shacl-property:not(.may-add) > .add-button { display: none; }
|
|
14
|
+
shacl-property:not(.may-remove) > .property-instance > .remove-button:not(.persistent) { visibility: hidden; }
|
|
15
|
+
shacl-property:not(.may-remove) > .shacl-or-constraint > .remove-button:not(.persistent) { visibility: hidden; }
|
|
16
|
+
.shacl-group { margin-bottom: 1em; padding-bottom: 1em; }
|
|
17
|
+
.mode-view .shacl-group:not(:has(shacl-property)) { display: none; }
|
|
18
|
+
.property-instance, .shacl-or-constraint { display: flex; align-items: flex-start; padding: 4px 0; width: 100%; position: relative; }
|
|
19
|
+
.shacl-or-constraint label { display: inline-block; word-break: break-word; width: var(--label-width); line-height: 1em; padding-top: 0.15em; padding-right: 1em; flex-shrink: 0; position: relative; }
|
|
20
|
+
.property-instance label[title] { cursor: help; text-decoration: underline dashed #AAA; }
|
|
21
|
+
.mode-edit .property-instance label.required::before { color: red; content: '\2736'; font-size: 0.6rem; position: absolute; left: -1.4em; top: 0.15rem; }
|
|
22
|
+
.property-instance.valid::before { position: absolute; left: calc(var(--label-width) - 1em); top: 6px; color: green; content: '\2713'; }
|
|
23
|
+
.editor:not([type='checkbox']), .shacl-or-constraint select { flex-grow: 1; }
|
|
24
|
+
.shacl-or-constraint select { border: 1px solid #DDD; padding: 2px 4px; }
|
|
25
|
+
select { overflow: hidden; text-overflow: ellipsis; }
|
|
26
|
+
textarea.editor { resize: vertical; }
|
|
27
|
+
.lang-chooser { position: absolute; top: 5px; right: 24px; border: 0; background-color: #e9e9ed; padding: 2px 4px; max-width: 40px; width: 40px; box-sizing: content-box; }
|
|
28
|
+
.lang-chooser+.editor { padding-right: 55px; }
|
|
29
|
+
.validation-error { position: absolute; left: calc(var(--label-width) - 1em); top: 6px; color: red; cursor: help; }
|
|
30
|
+
.validation-error::before { content: '\26a0' }
|
|
31
|
+
.validation-error.node { left: -1em; }
|
|
32
|
+
.invalid > .editor { border-color: red !important; }
|
|
33
|
+
.ml-0 { margin-left: 0 !important; }
|
|
34
|
+
.pr-0 { padding-right: 0 !important; }
|
|
35
|
+
.mode-view .property-instance:not(:first-child) > label { visibility: hidden; }
|
|
36
|
+
.mode-view .property-instance label { width: var(--label-width); }
|
|
37
|
+
|
|
38
|
+
.d-flex { display: flex; }
|
|
39
|
+
.lang { opacity: 0.65; font-size: 0.6em; }
|
|
40
|
+
a, a:visited { color: inherit; }
|
|
41
|
+
|
|
42
|
+
.fadeIn, .fadeOut { animation: fadeIn 0.2s ease-out; }
|
|
43
|
+
.fadeOut { animation-direction: reverse; animation-timing-function: ease-out;}
|
|
44
|
+
@keyframes fadeIn {
|
|
45
|
+
0% { opacity: 0; transform: scaleY(0.8); }
|
|
46
|
+
100% { opacity: 1; transform: scaleY(1); }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.collapsible > .activator { display: flex; justify-content: space-between; align-items: center; cursor: pointer; width: 100%; border: 0; padding: 8px 0; transition: 0.2s; }
|
|
50
|
+
.collapsible > .activator:hover, .collapsible.open > .activator { background-color: #F5F5F5; }
|
|
51
|
+
.collapsible > .activator::after { content:''; width: var(--caret-size); height: var(--caret-size); border-style: none solid solid none; border-width: calc(0.3 * var(--caret-size)); transform: rotate(45deg); transition: transform .15s ease-out; margin-right: calc(0.5 * var(--caret-size)); }
|
|
52
|
+
.collapsible.open > .activator::after { transform: rotate(225deg); }
|
|
53
|
+
.collapsible > *:not(.activator) { transition: all 0.2s ease-out; opacity: 1; }
|
|
54
|
+
.collapsible:not(.open) > *:not(.activator) { max-height: 0; padding: 0; opacity: 0; overflow: hidden; }
|
|
55
|
+
.collapsible > .property-instance > shacl-node > h1 { display: none; }
|
|
56
|
+
.collapsible.open > .property-instance:nth-child(odd) { background-color: #F5F5F5; }
|
|
57
|
+
.ref-link { cursor: pointer; }
|
|
58
|
+
.ref-link:hover { text-decoration: underline; }
|
|
59
|
+
.node-id-display { color: #999; font-size: 11px; }
|