diff --git a/.changeset/green-tomatoes-care.md b/.changeset/green-tomatoes-care.md new file mode 100644 index 00000000..d3527523 --- /dev/null +++ b/.changeset/green-tomatoes-care.md @@ -0,0 +1,5 @@ +--- +"@hydrofoil/shaperone-core": patch +--- + +Implement complete shape selection based on targets diff --git a/packages/core/models/forms/effects/pushFocusNode.ts b/packages/core/models/forms/effects/pushFocusNode.ts index 5aa02283..fc9435b6 100644 --- a/packages/core/models/forms/effects/pushFocusNode.ts +++ b/packages/core/models/forms/effects/pushFocusNode.ts @@ -2,6 +2,7 @@ import type { PropertyShape } from '@rdfine/shacl' import type { FocusNode } from '../../../index' import type { BaseParams } from '../../index' import type { Store } from '../../../state' +import { matchShapes } from '../../shapes/lib' export function pushFocusNode(store: Store) { const dispatch = store.getDispatch() @@ -20,7 +21,7 @@ export function pushFocusNode(store: Store) { focusNode, editors, shape: property.node, - shapes: shapes.get(form)?.shapes || [], + shapes: matchShapes(shapes.get(form)?.shapes).to(focusNode), shouldEnableEditorChoice: formState.shouldEnableEditorChoice, }) } diff --git a/packages/core/models/forms/effects/shapes/setGraph.ts b/packages/core/models/forms/effects/shapes/setGraph.ts index 30a5953b..c2710747 100644 --- a/packages/core/models/forms/effects/shapes/setGraph.ts +++ b/packages/core/models/forms/effects/shapes/setGraph.ts @@ -1,6 +1,7 @@ import { AnyPointer } from 'clownface' import type { Store } from '../../../../state' import { SetShapesGraphParams } from '../../../shapes/reducers' +import { matchShapes } from '../../../shapes/lib' export default function setGraph(store: Store) { const dispatch = store.getDispatch() @@ -25,7 +26,7 @@ export default function setGraph(store: Store) { form, focusNode, editors, - shapes: shapes.get(form)?.shapes || [], + shapes: matchShapes(shapes.get(form)?.shapes).to(focusNode), shouldEnableEditorChoice: formState.shouldEnableEditorChoice, }) }) diff --git a/packages/core/models/forms/lib/stateBuilder.ts b/packages/core/models/forms/lib/stateBuilder.ts index d35ff6d4..0c56340e 100644 --- a/packages/core/models/forms/lib/stateBuilder.ts +++ b/packages/core/models/forms/lib/stateBuilder.ts @@ -161,7 +161,7 @@ export function initialiseFocusNode(params: InitializeParams, previous: FocusNod const { properties, groups } = initialisePropertyShapes(shape, params, previous) return { - shape: shapes[0], + shape, shapes, focusNode, groups, diff --git a/packages/core/models/forms/reducers/replaceFocusNodes.ts b/packages/core/models/forms/reducers/replaceFocusNodes.ts index 88aa1a52..22f64313 100644 --- a/packages/core/models/forms/reducers/replaceFocusNodes.ts +++ b/packages/core/models/forms/reducers/replaceFocusNodes.ts @@ -2,22 +2,28 @@ import produce from 'immer' import { BaseParams, formStateReducer } from '../../index' import { initialiseFocusNode } from '../lib/stateBuilder' import type { FormState } from '../index' +import { matchShapes } from '../../shapes/lib' type StackAction = {appendToStack?: true} | {replaceStack?: true} export type Params = Parameters[0] & StackAction & BaseParams export const createFocusNodeState = formStateReducer((state: FormState, { focusNode, ...rest }: Params) => produce(state, (draft) => { - draft.focusNodes[focusNode.value] = initialiseFocusNode({ - focusNode, - ...rest, - }, state.focusNodes[focusNode.value]) + let { shapes } = rest if ('appendToStack' in rest && rest.appendToStack) { draft.focusStack.push(focusNode) + shapes = matchShapes(shapes).to(focusNode) } if ('replaceStack' in rest && rest.replaceStack) { draft.focusStack = [focusNode] + shapes = matchShapes(shapes).to(focusNode) } + + draft.focusNodes[focusNode.value] = initialiseFocusNode({ + focusNode, + ...rest, + shapes, + }, state.focusNodes[focusNode.value]) })) diff --git a/packages/core/models/shapes/lib/index.ts b/packages/core/models/shapes/lib/index.ts index 824a4a8c..3d4738e4 100644 --- a/packages/core/models/shapes/lib/index.ts +++ b/packages/core/models/shapes/lib/index.ts @@ -1,20 +1,53 @@ import { Shape } from '@rdfine/shacl' -import { rdf } from '@tpluscode/rdf-ns-builders' +import { rdf, sh, rdfs } from '@tpluscode/rdf-ns-builders' +import TermMap from '@rdf-esm/term-map' import { FocusNode } from '../../../index' -export function matchFor(focusNode: FocusNode) { - return (shape: Shape) => { - const { targetNode, targetClass } = shape +const scores = new TermMap([ + [sh.targetNode, 20], + [sh.targetClass, 10], + [sh.targetObjectsOf, 5], + [sh.targetSubjectsOf, 5], +]) +function toScoring(focusNode: FocusNode) { + return (matched: [Shape, number][], shape: Shape): [Shape, number][] => { + let score = 0 + const { targetNode, targetClass, targetObjectsOf, targetSubjectsOf } = shape if (targetNode.some(targetNode => targetNode.equals(focusNode.term))) { - return true + score = scores.get(sh.targetNode) || 0 } const classIds = targetClass.map(c => c.id) - if (focusNode.has(rdf.type, classIds).values.some(Boolean)) { - return true + if (shape.types.has(rdfs.Class)) { + classIds.push(shape.id) } + if (focusNode.has(rdf.type, classIds).terms.length) { + score = Math.max(score, scores.get(sh.targetClass) || 0) + } + + if (targetSubjectsOf && focusNode.out(targetSubjectsOf.id).terms.length) { + score = Math.max(score, scores.get(sh.targetSubjectsOf) || 0) + } + + if (targetObjectsOf && focusNode.in(targetObjectsOf.id).terms.length) { + score = Math.max(score, scores.get(sh.targetObjectsOf) || 0) + } + + return [ + ...matched, + [shape, score], + ] + } +} - return false +export function matchShapes(shapes: Shape[] = []): { to: (focusNode: FocusNode) => Shape[] } { + return { + to(focusNode: FocusNode) { + return shapes.reduce(toScoring(focusNode), []) + .filter(match => match[1] > 0) + .sort((left, right) => right[1] - left[1]) + .map(([shape]) => shape) + }, } } diff --git a/packages/core/test/models/shapes/lib/index.test.ts b/packages/core/test/models/shapes/lib/index.test.ts new file mode 100644 index 00000000..4edf7df0 --- /dev/null +++ b/packages/core/test/models/shapes/lib/index.test.ts @@ -0,0 +1,98 @@ +import { describe, it } from 'mocha' +import clownface from 'clownface' +import $rdf from 'rdf-ext' +import { rdf, schema, rdfs } from '@tpluscode/rdf-ns-builders' +import { expect } from 'chai' +import { nodeShape } from '../../../util' +import { matchShapes } from '../../../../models/shapes/lib' + +describe('models/shapes/lib', () => { + describe('matchShapes', () => { + const focusNode = clownface({ dataset: $rdf.dataset() }) + .namedNode('john') + .addOut(rdf.type, schema.Person) + .addOut(schema.alumniOf, $rdf.namedNode('UCLA')) + .addIn(schema.parent, $rdf.namedNode('jane')) + + const johnShape = nodeShape({ targetNode: [$rdf.namedNode('john')] }) + const personClassShape = nodeShape({ targetClass: [schema.Person] }) + const alumnusShape = nodeShape({ targetSubjectsOf: schema.alumniOf }) + const parentShape = nodeShape({ targetObjectsOf: schema.parent }) + const implicitPersonTargetShape = nodeShape(schema.Person, { types: [rdfs.Class] }) + + it('prefers sh:targetNode over all other shape targets', () => { + // given + const shapes = [ + personClassShape, + alumnusShape, + johnShape, + parentShape, + ] + + // when + const matched = matchShapes(shapes).to(focusNode) + + // then + expect(matched).to.have.length(4) + expect(matched[0].id).to.deep.eq(johnShape.id) + }) + + it('prefers sh:targetClass over property target shapes', () => { + // given + const shapes = [ + alumnusShape, + personClassShape, + parentShape, + ] + + // when + const matched = matchShapes(shapes).to(focusNode) + + // then + expect(matched).to.have.length(3) + expect(matched[0].id).to.deep.eq(personClassShape.id) + }) + + it('prefers implicit rdf:Class shape over property shapes', () => { + // given + const shapes = [ + alumnusShape, + implicitPersonTargetShape, + parentShape, + ] + + // when + const matched = matchShapes(shapes).to(focusNode) + + // then + expect(matched).to.have.length(3) + expect(matched[0].id).to.deep.eq(implicitPersonTargetShape.id) + }) + + it('matches shape by subject usage in graph', () => { + // given + const shapes = [ + alumnusShape, + ] + + // when + const matched = matchShapes(shapes).to(focusNode) + + // then + expect(matched[0].id).to.deep.eq(alumnusShape.id) + }) + + it('matches shape by object usage in graph', () => { + // given + const shapes = [ + parentShape, + ] + + // when + const matched = matchShapes(shapes).to(focusNode) + + // then + expect(matched[0].id).to.deep.eq(parentShape.id) + }) + }) +}) diff --git a/packages/core/test/util.ts b/packages/core/test/util.ts index 0b78fdb0..d3b3f4fb 100644 --- a/packages/core/test/util.ts +++ b/packages/core/test/util.ts @@ -1,5 +1,5 @@ -import type { PropertyShape } from '@rdfine/shacl' -import { PropertyShapeMixin } from '@rdfine/shacl' +import type { NodeShape, PropertyShape } from '@rdfine/shacl' +import { NodeShapeMixin, PropertyShapeMixin } from '@rdfine/shacl' import RdfResource, { Initializer, ResourceIdentifier } from '@tpluscode/rdfine/RdfResource' import clownface, { GraphPointer } from 'clownface' import * as $rdf from '@rdf-esm/dataset' @@ -29,3 +29,15 @@ export function propertyShape(shape?: GraphPointer | Initial initializer, }) } + +function isTerm(term: any): term is ResourceIdentifier { + return 'termType' in term +} + +export function nodeShape(idOrInit: ResourceIdentifier | Initializer, shape?: Initializer): NodeShape { + const graph = clownface({ dataset: $rdf.dataset() }) + if (isTerm(idOrInit)) { + return new NodeShapeMixin.Class(graph.node(idOrInit), shape) + } + return new NodeShapeMixin.Class(graph.blankNode(), idOrInit) +}