diff --git a/i18n/i18n.schema.json b/i18n/i18n.schema.json index 5f5e306e..8b3c1eda 100644 --- a/i18n/i18n.schema.json +++ b/i18n/i18n.schema.json @@ -263,18 +263,63 @@ "$ref": "#/$defs/Group", "additionalProperties": false, "properties": { + "add_criterion.label": { "$ref": "#/$defs/Value" }, + "add_criterion.title": { "$ref": "#/$defs/Value" }, "add_group.label": { "$ref": "#/$defs/Value" }, "add_group.title": { "$ref": "#/$defs/Value" }, "add_selected.label": { "$ref": "#/$defs/Value" }, "add_selected.title": { "$ref": "#/$defs/Value" }, + "apply_criteria.label": { "$ref": "#/$defs/Value" }, + "apply_criteria.title": { "$ref": "#/$defs/Value" }, + "cancel_criteria.label": { "$ref": "#/$defs/Value" }, + "cancel_criteria.title": { "$ref": "#/$defs/Value" }, + "clear_criteria.label": { "$ref": "#/$defs/Value" }, + "clear_criteria.title": { "$ref": "#/$defs/Value" }, + "criteria_all_of_add.label": { "$ref": "#/$defs/Value" }, + "criteria_all_of_add.title": { "$ref": "#/$defs/Value" }, + "criteria_all_of_joiner": { "$ref": "#/$defs/Value" }, + "criteria_any_of_add.label": { "$ref": "#/$defs/Value" }, + "criteria_any_of_add.title": { "$ref": "#/$defs/Value" }, + "criteria_any_of_joiner": { "$ref": "#/$defs/Value" }, + "criteria_from_values": { "$ref": "#/$defs/Value" }, + "criteria_from_values.any-entity": { "$ref": "#/$defs/Value" }, + "criteria_from_values.many-entities": { "$ref": "#/$defs/Value" }, + "criteria_from_values.header": { "$ref": "#/$defs/Value" }, + "criteria_from_values.joiner": { "$ref": "#/$defs/Value" }, + "criteria_from_values.label": { "$ref": "#/$defs/Value" }, + "criteria_has_property": { "$ref": "#/$defs/Value" }, + "criteria_has_property.any-property": { "$ref": "#/$defs/Value" }, + "criteria_has_property.header": { "$ref": "#/$defs/Value" }, + "criteria_has_property.label": { "$ref": "#/$defs/Value" }, "criteria_has_type": { "$ref": "#/$defs/Value" }, + "criteria_has_type.any_type": { "$ref": "#/$defs/Value" }, + "criteria_has_type.header": { "$ref": "#/$defs/Value" }, + "criteria_has_type.label": { "$ref": "#/$defs/Value" }, "criteria_connected": { "$ref": "#/$defs/Value" }, - "criteria_connected_via": { "$ref": "#/$defs/Value" }, + "criteria_connected.any-link": { "$ref": "#/$defs/Value" }, + "criteria_connected.direction_any.title": { "$ref": "#/$defs/Value" }, + "criteria_connected.direction_in.title": { "$ref": "#/$defs/Value" }, + "criteria_connected.direction_out.title": { "$ref": "#/$defs/Value" }, + "criteria_connected.header": { "$ref": "#/$defs/Value" }, + "criteria_connected.label": { "$ref": "#/$defs/Value" }, + "criteria_connected.other-part": { "$ref": "#/$defs/Value" }, "criteria_connected_to_source": { "$ref": "#/$defs/Value" }, "criteria_connected_to_target": { "$ref": "#/$defs/Value" }, + "criteria_connected_via": { "$ref": "#/$defs/Value" }, + "criteria_placeholder_any": { "$ref": "#/$defs/Value" }, + "criteria_placeholder_unknown": { "$ref": "#/$defs/Value" }, + "criteria_where_caption": { "$ref": "#/$defs/Value" }, + "edit_criteria.label": { "$ref": "#/$defs/Value" }, + "edit_criteria.title": { "$ref": "#/$defs/Value" }, + "error.required_at_least_one_pattern": { "$ref": "#/$defs/Value" }, + "error.required_element_type": { "$ref": "#/$defs/Value" }, + "error.required_property_type": { "$ref": "#/$defs/Value" }, "input.placeholder": { "$ref": "#/$defs/Value" }, "place_elements.command": { "$ref": "#/$defs/Value" }, "query_progress.title": { "$ref": "#/$defs/Value" }, + "remove_criterion.title": { "$ref": "#/$defs/Value" }, + "search_by.label": { "$ref": "#/$defs/Value" }, + "search_by.title": { "$ref": "#/$defs/Value" }, "show_more_results.label": { "$ref": "#/$defs/Value" }, "show_more_results.title": { "$ref": "#/$defs/Value" } } diff --git a/i18n/translations/en.reactodia-translation.json b/i18n/translations/en.reactodia-translation.json index 62047439..d48f921a 100644 --- a/i18n/translations/en.reactodia-translation.json +++ b/i18n/translations/en.reactodia-translation.json @@ -180,18 +180,63 @@ "show_only_creatable": "Show only constructible" }, "search_entities": { + "add_criterion.label": "add", + "add_criterion.title": "Add search condition", "add_group.label": "Add as group", "add_group.title": "Place selected entities on the canvas as a group", "add_selected.label": "Add selected", "add_selected.title": "Place selected entities on the canvas", + "apply_criteria.label": "apply", + "apply_criteria.title": "Apply search conditions", + "cancel_criteria.label": "cancel", + "cancel_criteria.title": "Cancel editing search conditions", + "clear_criteria.label": "clear", + "clear_criteria.title": "Clear search conditions", + "criteria_all_of_add.label": "and", + "criteria_all_of_add.title": "Add \"and\" search condition", + "criteria_all_of_joiner": "and", + "criteria_any_of_add.label": "or", + "criteria_any_of_add.title": "Add \"or\" search condition", + "criteria_any_of_joiner": "or", + "criteria_from_values": "One of {{values}}", + "criteria_from_values.any-entity": "any entity", + "criteria_from_values.many-entities": "one of {{count}} entities", + "criteria_from_values.header": "One of entities", + "criteria_from_values.joiner": " or ", + "criteria_from_values.label": "one of entities", + "criteria_has_property": "Has {{propertyType}} property values", + "criteria_has_property.any-property": "any", + "criteria_has_property.header": "Has property", + "criteria_has_property.label": "has property", "criteria_has_type": "Has type {{entityType}}", + "criteria_has_type.any_type": "any", + "criteria_has_type.header": "Has type", + "criteria_has_type.label": "has type", "criteria_connected": "Connected to {{entity}}", - "criteria_connected_via": "Connected to {{entity}} through {{relationType}}", - "criteria_connected_to_source": "Connected to {{entity}} through {{relationType}} as {{sourceIcon}}\u00A0source", - "criteria_connected_to_target": "Connected to {{entity}} through {{relationType}} as {{targetIcon}}\u00A0target", + "criteria_connected.any-link": "any link", + "criteria_connected.direction_any.title": "Connected to source or target", + "criteria_connected.direction_in.title": "Connected to target", + "criteria_connected.direction_out.title": "Connected to source", + "criteria_connected.header": "Connected to {{entity}} by", + "criteria_connected.label": "connected to", + "criteria_connected.other-part": "entity", + "criteria_connected_to_source": "Connected by {{relationType}} to {{entity}} {{sourceIcon}}\u00A0target", + "criteria_connected_to_target": "Connected by {{relationType}} to {{entity}} {{targetIcon}}\u00A0source", + "criteria_connected_via": "Connected by {{relationType}} to {{entity}}", + "criteria_placeholder_any": "any entity", + "criteria_placeholder_unknown": "Unknown pattern type", + "criteria_where_caption": "where\u00A0{{part}}:", + "edit_criteria.label": "edit", + "edit_criteria.title": "Edit search conditions", + "error.required_at_least_one_pattern": "Should have at least one sub-pattern", + "error.required_element_type": "Element type is required", + "error.required_property_type": "Property type is required", "input.placeholder": null, "place_elements.command": "Add selected elements", "query_progress.title": "Querying for entities", + "remove_criterion.title": "Remove condition", + "search_by.label": "search by", + "search_by.title": null, "show_more_results.label": "Show more", "show_more_results.title": "Load more results" }, diff --git a/package-lock.json b/package-lock.json index d3674c4a..57250eb2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,20 +1,21 @@ { "name": "@reactodia/workspace", - "version": "0.35.1", + "version": "0.35.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@reactodia/workspace", - "version": "0.35.1", + "version": "0.35.2", "license": "LGPL-2.1-or-later", "dependencies": { + "@floating-ui/react-dom": "^2.1.9", "@reactodia/hashmap": "^0.2.1", "@reactodia/worker-proxy": "^0.1.0", "clsx": "^2.1.1", "d3-color": "^3.1.0", "file-saver": "^2.0.5", - "n3": "^1.26.0" + "n3": "^2.13.10" }, "devDependencies": { "@comunica/query-sparql-rdfjs-lite": "^5.2.3", @@ -22,7 +23,7 @@ "@rdfjs/types": "^2.0.1", "@types/d3-color": "^3.1.3", "@types/file-saver": "^2.0.7", - "@types/n3": "^1.26.1", + "@types/n3": "^1.26.4", "@types/react": "^18.3.27", "@types/react-dom": "^18.3.7", "@types/use-sync-external-store": "^1.5.0", @@ -3551,6 +3552,20 @@ "dev": true, "license": "ISC" }, + "node_modules/@comunica/actor-rdf-parse-n3/node_modules/n3": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/n3/-/n3-1.26.0.tgz", + "integrity": "sha512-SQknS0ua90rN+3RHuk8BeIqeYyqIH/+ecViZxX08jR4j6MugqWRjtONl3uANG/crWXnOM2WIqBJtjIhVYFha+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^6.0.3", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">=12.0" + } + }, "node_modules/@comunica/actor-rdf-parse-rdfxml": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/@comunica/actor-rdf-parse-rdfxml/-/actor-rdf-parse-rdfxml-4.5.0.tgz", @@ -5192,6 +5207,44 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz", + "integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.8.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT" + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -6190,9 +6243,9 @@ "license": "MIT" }, "node_modules/@types/n3": { - "version": "1.26.1", - "resolved": "https://registry.npmjs.org/@types/n3/-/n3-1.26.1.tgz", - "integrity": "sha512-TilYHzpU6ecXVJAbV+6o17Z8ZkWLWx6ZJD3IluaU4RiGHxqjU2or9fopxFHS6iXS6qcl5Mg1K3wSx9L8xxJaJQ==", + "version": "1.26.4", + "resolved": "https://registry.npmjs.org/@types/n3/-/n3-1.26.4.tgz", + "integrity": "sha512-PFEJWfs9mL7yLgjHpmVI/BfpZZPrsaDkUN8YiQXJoz8MkBsCYXys0DSo0r20y3+zqQupbUqxAYb8fFe8kbCbTQ==", "dev": true, "license": "MIT", "dependencies": { @@ -9414,7 +9467,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -9954,7 +10006,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dev": true, "license": "MIT", "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" @@ -10121,9 +10172,9 @@ "license": "MIT" }, "node_modules/n3": { - "version": "1.26.0", - "resolved": "https://registry.npmjs.org/n3/-/n3-1.26.0.tgz", - "integrity": "sha512-SQknS0ua90rN+3RHuk8BeIqeYyqIH/+ecViZxX08jR4j6MugqWRjtONl3uANG/crWXnOM2WIqBJtjIhVYFha+w==", + "version": "2.13.10", + "resolved": "https://registry.npmjs.org/n3/-/n3-2.13.10.tgz", + "integrity": "sha512-GunqYLhMwtrLbpSYpE+7sZyH8A7/7KCpgL4KAGD4RDn0rAiZGxLkyskdAlZGVyJcpwlsjeHJRlFFSUJKHpeOjA==", "license": "MIT", "dependencies": { "buffer": "^6.0.3", @@ -11181,7 +11232,6 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", - "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.1.0" @@ -11194,7 +11244,6 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", - "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.1.0", @@ -11550,7 +11599,6 @@ "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", - "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.1.0" @@ -11629,6 +11677,20 @@ "n3": "^1.16.3" } }, + "node_modules/shaclc-parse/node_modules/n3": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/n3/-/n3-1.26.0.tgz", + "integrity": "sha512-SQknS0ua90rN+3RHuk8BeIqeYyqIH/+ecViZxX08jR4j6MugqWRjtONl3uANG/crWXnOM2WIqBJtjIhVYFha+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^6.0.3", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">=12.0" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", diff --git a/package.json b/package.json index 87cb915c..b957d7f8 100644 --- a/package.json +++ b/package.json @@ -39,12 +39,13 @@ "clean": "rimraf dist" }, "dependencies": { + "@floating-ui/react-dom": "^2.1.9", "@reactodia/hashmap": "^0.2.1", "@reactodia/worker-proxy": "^0.1.0", "clsx": "^2.1.1", "d3-color": "^3.1.0", "file-saver": "^2.0.5", - "n3": "^1.26.0" + "n3": "^2.13.10" }, "peerDependencies": { "react": "^17.0.2 || ^18 || ^19", @@ -56,7 +57,7 @@ "@rdfjs/types": "^2.0.1", "@types/d3-color": "^3.1.3", "@types/file-saver": "^2.0.7", - "@types/n3": "^1.26.1", + "@types/n3": "^1.26.4", "@types/react": "^18.3.27", "@types/react-dom": "^18.3.7", "@types/use-sync-external-store": "^1.5.0", diff --git a/src/coreUtils/hooks.ts b/src/coreUtils/hooks.ts index 9ef64a3c..b37d6fa1 100644 --- a/src/coreUtils/hooks.ts +++ b/src/coreUtils/hooks.ts @@ -164,7 +164,22 @@ export interface UseAsyncResult { * @category Hooks */ export function useAsync(params: { + /** + * Input dependecies to load a value, compared with shallow array comparison. + */ input: I; + /** + * Callback to asynchronously load a value. + * + * Does not need to have stable identity, the latest provided callback + * will be used on load. To reload a value on a callback change, it can + * be added to the `input`. + * + * @param input provided input + * @param options load operation options + * @returns a promise with load result or `undefined` to cancel load operation + * while keeping the last result (`data` or `error`) + */ load: (input: I, options: { signal: AbortSignal }) => Promise | undefined; }): UseAsyncResult { const {input, load} = params; @@ -192,7 +207,10 @@ export function useAsync(params: { } ); } else { - setResult({data: undefined, status: 'completed', error: undefined}); + setResult(previous => ({ + ...previous, + status: previous.status === 'loading' ? 'completed' : previous.status, + })); } return () => controller.abort(); }, input); @@ -206,3 +224,26 @@ export function useLatest(value: T): { readonly current: T } { }); return ref; } + +export function useCombinedRef( + refA: React.Ref | undefined, + refB: React.Ref | undefined +): React.RefCallback { + return React.useCallback((value: T) => { + if (refA) { + if (typeof refA === 'function') { + refA(value); + } else { + (refA as React.MutableRefObject).current = value; + } + } + + if (refB) { + if (typeof refB === 'function') { + refB(value); + } else { + (refB as React.MutableRefObject).current = value; + } + } + }, [refA, refB]); +} diff --git a/src/data/composite/composite.ts b/src/data/composite/composite.ts index 0f4a4703..6a9a0cb6 100644 --- a/src/data/composite/composite.ts +++ b/src/data/composite/composite.ts @@ -8,15 +8,18 @@ import { } from '../model'; import { CompositeResponse, + mergeConnectedLinkStats, + mergeElementInfo, + mergeElementTypes, + mergeIndexedElementTypes, + mergeIndexedLinkTypes, + mergeIndexedPropertyTypes, mergeKnownElementTypes, mergeKnownLinkTypes, - mergeElementTypes, - mergePropertyTypes, - mergeLinkTypes, - mergeElementInfo, mergeLinksInfo, - mergeConnectedLinkStats, + mergeLinkTypes, mergeLookup, + mergePropertyTypes, } from './mergeUtils'; /** @@ -114,25 +117,49 @@ export class CompositeDataProvider implements DataProvider { return this.requestWithMerge(p => p.knownLinkTypes(params), mergeKnownLinkTypes); } + lookupElementTypes(params: { + text: string; + limit?: number; + signal?: AbortSignal; + }): Promise { + return this.requestWithMerge(p => p.lookupElementTypes(params), mergeElementTypes); + } + + lookupPropertyTypes(params: { + text: string; + limit?: number; + signal?: AbortSignal; + }): Promise { + return this.requestWithMerge(p => p.lookupPropertyTypes(params), mergePropertyTypes); + } + + lookupLinkTypes(params: { + text: string; + limit?: number; + signal?: AbortSignal; + }): Promise { + return this.requestWithMerge(p => p.lookupLinkTypes(params), mergeLinkTypes); + } + elementTypes(params: { classIds: ReadonlyArray; signal?: AbortSignal; }): Promise> { - return this.requestWithMerge(p => p.elementTypes(params), mergeElementTypes); + return this.requestWithMerge(p => p.elementTypes(params), mergeIndexedElementTypes); } propertyTypes(params: { propertyIds: ReadonlyArray; signal?: AbortSignal; }): Promise> { - return this.requestWithMerge(p => p.propertyTypes(params), mergePropertyTypes); + return this.requestWithMerge(p => p.propertyTypes(params), mergeIndexedPropertyTypes); } linkTypes(params: { linkTypeIds: ReadonlyArray; signal?: AbortSignal; }): Promise> { - return this.requestWithMerge(p => p.linkTypes(params), mergeLinkTypes); + return this.requestWithMerge(p => p.linkTypes(params), mergeIndexedLinkTypes); } elements(params: { diff --git a/src/data/composite/mergeUtils.ts b/src/data/composite/mergeUtils.ts index f7eefcdb..dc6e46be 100644 --- a/src/data/composite/mergeUtils.ts +++ b/src/data/composite/mergeUtils.ts @@ -42,13 +42,31 @@ export function mergeKnownLinkTypes(responses: CompositeResponse[] +): ElementTypeModel[] { + return mergeArrayResponses(responses, mergeClassModel); +} + +export function mergeIndexedElementTypes( + responses: CompositeResponse>[] +): Map { + return mergeMapResponses(responses, mergeClassModel); +} + export function mergePropertyTypes( + responses: CompositeResponse[] +): PropertyTypeModel[] { + return mergeArrayResponses(responses, mergePropertyType); +} + +export function mergeIndexedPropertyTypes( responses: CompositeResponse>[], ): Map { - return mergeMapResponses(responses, mergePropertyModel); + return mergeMapResponses(responses, mergePropertyType); } -function mergePropertyModel(a: PropertyTypeModel, b: PropertyTypeModel): PropertyTypeModel { +function mergePropertyType(a: PropertyTypeModel, b: PropertyTypeModel): PropertyTypeModel { return { ...a, ...b, @@ -56,13 +74,13 @@ function mergePropertyModel(a: PropertyTypeModel, b: PropertyTypeModel): Propert }; } -export function mergeElementTypes( - responses: CompositeResponse>[] -): Map { - return mergeMapResponses(responses, mergeClassModel); +export function mergeLinkTypes( + responses: CompositeResponse[] +): LinkTypeModel[] { + return mergeArrayResponses(responses, mergeLinkType); } -export function mergeLinkTypes( +export function mergeIndexedLinkTypes( responses: CompositeResponse>[] ): Map { return mergeMapResponses(responses, mergeLinkType); @@ -79,20 +97,6 @@ function mergeLinkType(a: LinkTypeModel, b: LinkTypeModel): LinkTypeModel { }; } -function mergeMapResponses( - responses: Iterable>>, - mergeItems: (a: V, b: V) => V -): Map { - const result = new Map(); - for (const [response] of responses) { - for (const [key, model] of response) { - const existing = result.get(key); - result.set(key, existing ? mergeItems(existing, model) : model); - } - } - return result; -} - export function mergeElementInfo( responses: CompositeResponse>[], originProperty: PropertyTypeIri @@ -297,3 +301,31 @@ function mergeCounts(a: number | undefined, b: number | undefined): number | und } return (a ?? 0) + (b ?? 0); } + +function mergeArrayResponses( + responses: Iterable>, + mergeItems: (a: T, b: T) => T +): T[] { + const result = new Map(); + for (const [response] of responses) { + for (const model of response) { + const existing = result.get(model.id); + result.set(model.id, existing ? mergeItems(existing, model) : model); + } + } + return Array.from(result.values()); +} + +function mergeMapResponses( + responses: Iterable>>, + mergeItems: (a: V, b: V) => V +): Map { + const result = new Map(); + for (const [response] of responses) { + for (const [key, model] of response) { + const existing = result.get(key); + result.set(key, existing ? mergeItems(existing, model) : model); + } + } + return result; +} diff --git a/src/data/dataProvider.ts b/src/data/dataProvider.ts index 2ac0a4e0..116ace86 100644 --- a/src/data/dataProvider.ts +++ b/src/data/dataProvider.ts @@ -36,6 +36,66 @@ export interface DataProvider { signal?: AbortSignal; }): Promise; + /** + * Gets known element types filtered by a keyword. + */ + lookupElementTypes(params: { + /** + * Filter by a text lookup. + */ + text: string; + /** + * Limit number of element types returned. + * + * Default value depends on the provider implementation. + */ + limit?: number; + /** + * Cancellation signal. + */ + signal?: AbortSignal; + }): Promise; + + /** + * Gets known property types filtered by a keyword. + */ + lookupPropertyTypes(params: { + /** + * Filter by a text lookup. + */ + text: string; + /** + * Limit number of property types returned. + * + * Default value depends on the provider implementation. + */ + limit?: number; + /** + * Cancellation signal. + */ + signal?: AbortSignal; + }): Promise; + + /** + * Gets known link types filtered by a keyword. + */ + lookupLinkTypes(params: { + /** + * Filter by a text lookup. + */ + text: string; + /** + * Limit number of link types returned. + * + * Default value depends on the provider implementation. + */ + limit?: number; + /** + * Cancellation signal. + */ + signal?: AbortSignal; + }): Promise; + /** * Gets the data for the specified element types. */ @@ -139,12 +199,10 @@ export interface DataProvider { }): Promise; /** - * Looks up elements with different filters: - * - by an element type via `elementTypeId`; - * - by a connected element via `refElementId`, `refElementLinkId` and `linkDirection`; - * - by a text lookup via `text`; + * Finds entities in a data graph by specified lookup filters. * - * Filters can be combined to produce an intersection of the results. + * Support and validity for the provided lookup pattern can be checked + * with {@link DataProvider.supportsPattern}. */ lookup(params: DataProviderLookupParams): Promise; } @@ -179,45 +237,65 @@ export interface DataProviderLinkCount { /** * Parameters for {@link DataProvider.lookup} operation. * + * Specified filters are be combined to produce an intersection of the results, + * same as nesting them in a {@link LookupPatternAllOf} pattern. + * + * All lookup filters can be combined into a single pattern + * with {@link lookupPatternOf}. + * * @category Data * @see {@link DataProvider.lookup} */ export interface DataProviderLookupParams { /** * Filter by an element type. + * + * Same as {@link LookupPatternHasType}. */ elementTypeId?: ElementTypeIri; /** * Filter by a text lookup. + * + * Same as {@link LookupPatternWithText}. */ text?: string; /** * Filter by having a connected element with specified IRI. + * + * Same as {@link LookupPatternConnectedTo} (together with + * {@link refElementLinkId} and {@link linkDirection}). */ refElementId?: ElementIri; /** * Filter by connection link type. * - * Only applicable when `refElementId` is set. + * Same as {@link LookupPatternConnectedTo} (together with + * {@link refElementId} and {@link linkDirection}). */ refElementLinkId?: LinkTypeIri; /** * Reference element link type direction ('in' | 'out'). - * - * Only when `refElementLinkId` is set. + * + * Same as {@link LookupPatternConnectedTo} (together with + * {@link refElementId} and {@link refElementLinkId}). */ linkDirection?: 'in' | 'out'; + /** + * Filter by a lookup pattern. + */ + pattern?: LookupPattern; + /** * Limit number of elements returned. * * Pass `null` to explicitly disable the limit. * - * Default depends on the provider implementation. + * Default value depends on the provider implementation. */ limit?: number | null; @@ -227,6 +305,166 @@ export interface DataProviderLookupParams { signal?: AbortSignal; } +/** + * Describes a lookup pattern to find entities in a data graph. + * + * @see {@link DataProvider.lookup} + */ +export type LookupPattern = + | LookupPatternAllOf + | LookupPatternAnyOf + | LookupPatternWithText + | LookupPatternHasType + | LookupPatternConnectedTo + | LookupPatternFromValues + | LookupPatternHasProperty; + +/** + * Lookup pattern that filters entities by requiring all of the nested + * {@link patterns} to match. + */ +export interface LookupPatternAllOf { + /** + * Lookup pattern type. + */ + readonly type: 'allOf'; + /** + * Patterns list, all of which matching are required for this pattern to match. + * + * Empty list is considered to be an invalid pattern. + */ + readonly patterns: readonly LookupPattern[]; +} + +/** + * Lookup pattern that filters entities by requiring any of the nested + * {@link patterns} to match. + */ +export interface LookupPatternAnyOf { + /** + * Lookup pattern type. + */ + readonly type: 'anyOf'; + /** + * Patterns list, any of which matching is enough for this pattern to match. + * + * Empty list is considered to be an invalid pattern. + */ + readonly patterns: readonly LookupPattern[]; +} + +/** + * Lookup pattern that filters entities by an element type. + */ +export interface LookupPatternHasType { + /** + * Lookup pattern type. + */ + readonly type: 'hasType'; + /** + * Required element type for an entity. + */ + readonly elementType: ElementTypeIri; +} + +/** + * Lookup pattern that filters entities that connected to another entity + * matching an {@link other} pattern, optinally restricted by specific + * {@link linkType link type} and/or {@link linkDirection direction}. + * + * If provided as a top-level pattern (including as part of {@link LookupPatternAllOf} + * or {@link LookupPatternAnyOf}), provides {@link DataProviderLookupItem.inLinks} and + * {@link DataProviderLookupItem.outLinks} on the result items. + */ +export interface LookupPatternConnectedTo { + /** + * Lookup pattern type. + */ + readonly type: 'connectedTo'; + /** + * Required link type between {@link other} and the entity. + * + * If not specified, any link type is allowed. + */ + readonly linkType?: LinkTypeIri; + /** + * Required link direction between {@link other} and the entity: + * - `out`: `other` → entity + * - `in`: `other` ← entity + * + * If not specified, both directions are allowed. + */ + readonly linkDirection?: 'in' | 'out'; + /** + * Required link pattern to match connected entities. + * + * If not specified, any connected entity is allowed. + */ + readonly other?: LookupPattern; +} + +/** + * Lookup pattern that filters entities being in the fixed set of + * the matching {@link values}. + */ +export interface LookupPatternFromValues { + /** + * Lookup pattern type. + */ + readonly type: 'fromValues'; + /** + * Set of allowed entity IRIs. + * + * If empty, the pattern should never match. + */ + readonly values: readonly ElementIri[]; +} + +/** + * Lookup pattern that filters entities by having a property value + * with specified {@link propertyType property type}. + * + * The pattern can optionally match only when the property has at least + * one value from the provided set of values. + */ +export interface LookupPatternHasProperty { + /** + * Lookup pattern type. + */ + readonly type: 'hasProperty'; + /** + * Required property type with at least one value for the entity. + */ + readonly propertyType: PropertyTypeIri; + /** + * Set of matching property values: if non-empty, the pattern should match + * only if the property has at least one values from this set. + */ + readonly values?: ReadonlyArray; +} + +/** + * Lookup pattern that filters entities by having a matching text + * in its labels. + * + * If provided as a top-level pattern, depending on how close {@link text} matches + * entity label by provider-specific score may infuence the result item position + * in the {@link DataProvider.lookup} results. + */ +export interface LookupPatternWithText { + /** + * Lookup pattern type. + */ + readonly type: 'withText'; + /** + * Text token to match in an entity label. + * + * The exact text matching behavior is provider-specific + * (e.g. how partial word matches are handled, etc). + */ + readonly text: string; +} + /** * Describes an element with information on which link types and directions * are used to connect it to other elements. @@ -240,19 +478,68 @@ export interface DataProviderLookupItem { */ readonly element: ElementModel; /** - * Link types for connections in the form: `refElementId` ← `element` - * ({@link DataProviderLookupParams.refElementId refElementId}). + * Link types for connections in the form: `entity` → `target` + * ({@link LookupPatternConnectedTo.target}). * - * Only applicable if {@link DataProviderLookupParams.refElementId refElementId} + * Only applicable if {@link LookupPatternConnectedTo} or equivalent filter * is specified in {@link DataProvider.lookup}. */ readonly inLinks: ReadonlySet; /** - * Link types for connections in the form: `refElementId` → `element` - * ({@link DataProviderLookupParams.refElementId refElementId}). + * Link types for connections in the form: `entity` ← `target` + * ({@link LookupPatternConnectedTo.target}). * - * Only applicable if {@link DataProviderLookupParams.refElementId refElementId} + * Only applicable if {@link LookupPatternConnectedTo} or equivalent filter * is specified in {@link DataProvider.lookup}. */ readonly outLinks: ReadonlySet; } + +/** + * Composes a a single lookup pattern from filters specified in + * {@link DataProviderLookupParams params} for {@link DataProvider.lookup}. + */ +export function lookupPatternOf( + params: Omit +): LookupPattern { + const { + elementTypeId, refElementId, refElementLinkId, linkDirection, text, pattern, + } = params; + + const patterns: LookupPattern[] = []; + + if (elementTypeId) { + patterns.push({ + type: 'hasType', + elementType: elementTypeId, + }); + } + + if (refElementId || refElementLinkId || linkDirection) { + patterns.push({ + type: 'connectedTo', + linkType: refElementLinkId, + linkDirection: linkDirection, + other: refElementId ? { + type: 'fromValues', + values: [refElementId], + } : undefined, + }); + } + + if (text) { + patterns.push({ + type: 'withText', + text, + }); + } + + if (pattern) { + patterns.push(pattern); + } + + return patterns.length === 1 ? patterns[0] : { + type: 'allOf', + patterns, + }; +} diff --git a/src/data/decorated/decoratedDataProvider.ts b/src/data/decorated/decoratedDataProvider.ts index f7b3c5e8..4dd6b20c 100644 --- a/src/data/decorated/decoratedDataProvider.ts +++ b/src/data/decorated/decoratedDataProvider.ts @@ -3,6 +3,7 @@ import { delay } from '../../coreUtils/async'; import type * as Rdf from '../rdf/rdfModel'; import { DataProvider, DataProviderLinkCount, DataProviderLookupParams, DataProviderLookupItem, + LookupPattern, } from '../dataProvider'; import { ElementTypeModel, ElementTypeGraph, LinkTypeModel, ElementModel, LinkModel, @@ -33,6 +34,9 @@ export interface DecoratedDataProviderOptions { export type DecoratedMethodName = | 'knownElementTypes' | 'knownLinkTypes' + | 'lookupElementTypes' + | 'lookupPropertyTypes' + | 'lookupLinkTypes' | 'elementTypes' | 'propertyTypes' | 'linkTypes' @@ -93,6 +97,30 @@ export class DecoratedDataProvider implements DataProvider { return this.decorate('knownLinkTypes', [params]); } + lookupElementTypes(params: { + text: string; + limit?: number; + signal?: AbortSignal; + }): Promise { + return this.decorate('lookupElementTypes', [params]); + } + + lookupPropertyTypes(params: { + text: string; + limit?: number; + signal?: AbortSignal; + }): Promise { + return this.decorate('lookupPropertyTypes', [params]); + } + + lookupLinkTypes(params: { + text: string; + limit?: number; + signal?: AbortSignal; + }): Promise { + return this.decorate('lookupLinkTypes', [params]); + } + elementTypes(params: { classIds: ReadonlyArray; signal?: AbortSignal; diff --git a/src/data/decorated/emptyDataProvider.ts b/src/data/decorated/emptyDataProvider.ts index dff8f346..03235540 100644 --- a/src/data/decorated/emptyDataProvider.ts +++ b/src/data/decorated/emptyDataProvider.ts @@ -1,6 +1,6 @@ import { - ElementTypeGraph, LinkTypeModel, ElementTypeIri, ElementTypeModel, PropertyTypeIri, PropertyTypeModel, - LinkTypeIri, ElementIri, ElementModel, LinkModel, + ElementTypeGraph, LinkTypeModel, ElementTypeIri, ElementTypeModel, PropertyTypeIri, + PropertyTypeModel, LinkTypeIri, ElementIri, ElementModel, LinkModel, } from '../model'; import { DataProvider, DataProviderLinkCount, DataProviderLookupParams, DataProviderLookupItem, @@ -32,6 +32,30 @@ export class EmptyDataProvider implements DataProvider { return Promise.resolve([]); } + lookupElementTypes(params: { + text: string; + limit?: number; + signal?: AbortSignal; + }): Promise { + return Promise.resolve([]); + } + + lookupPropertyTypes(params: { + text: string; + limit?: number; + signal?: AbortSignal; + }): Promise { + return Promise.resolve([]); + } + + lookupLinkTypes(params: { + text: string; + limit?: number; + signal?: AbortSignal; + }): Promise { + return Promise.resolve([]); + } + elementTypes(params: { classIds: readonly ElementTypeIri[]; signal?: AbortSignal | undefined; diff --git a/src/data/indexedDb/indexedDbCachedProvider.ts b/src/data/indexedDb/indexedDbCachedProvider.ts index b5fc7c97..43abf474 100644 --- a/src/data/indexedDb/indexedDbCachedProvider.ts +++ b/src/data/indexedDb/indexedDbCachedProvider.ts @@ -1,15 +1,18 @@ import { AsyncLock } from '../../coreUtils/async'; import { multimapAdd } from '../../coreUtils/collections'; -import type * as Rdf from '../rdf/rdfModel'; +import * as Rdf from '../rdf/rdfModel'; import { ElementTypeGraph, LinkTypeModel, ElementTypeIri, ElementTypeModel, PropertyTypeIri, PropertyTypeModel, LinkTypeIri, ElementIri, ElementModel, LinkModel, } from '../model'; import { DataProvider, DataProviderLinkCount, DataProviderLookupParams, DataProviderLookupItem, + LookupPattern, lookupPatternOf, } from '../dataProvider'; -import { AdjacencyRange, AdjacencyBlock, subtractAdjacencyBlocks, hashAdjacencyRange } from './adjacencyBlocks'; +import { + AdjacencyRange, AdjacencyBlock, subtractAdjacencyBlocks, hashAdjacencyRange, +} from './adjacencyBlocks'; import { Sha256 } from './sha256'; /** @@ -62,6 +65,9 @@ export interface IndexedDbCachedProviderOptions { const enum ObjectStore { knownElementTypes = 'knownElementTypes', knownLinkTypes = 'knownLinkTypes', + lookupElementTypes = 'lookupElementTypes', + lookupPropertyTypes = 'lookupPropertyTypes', + lookupLinkTypes = 'lookupLinkTypes', elementTypes = 'elementTypes', linkTypes = 'linkTypes', propertyTypes = 'propertyTypes', @@ -96,6 +102,15 @@ interface KnownLinkTypesRecord { readonly value: LinkTypeModel[]; } +type LookupTypesKey = [pattern: string, limit: string]; +const LOOKUP_TYPES_KEY_PROPERTIES: Array> = + ['pattern', 'limit']; +interface LookupTypesRecord { + readonly pattern: string; + readonly limit: string; + readonly result: T[]; +} + type LinkRecordKey = [ElementIri, ElementIri]; interface LinkRecord extends LinkModel { __id: number; @@ -119,22 +134,10 @@ interface ConnectedLinkStatsRecord { readonly stats: DataProviderLinkCount[]; } -type LookupLinkDirectionKey = 'in' | 'out' | ''; -type LookupKey = [ElementTypeIri, ElementIri, LinkTypeIri, LookupLinkDirectionKey, string, string]; -const LOOKUP_KEY_PROPERTIES: Array = [ - 'elementType', - 'element', - 'link', - 'direction', - 'text', - 'limit', -]; +type LookupKey = [pattern: string, limit: string]; +const LOOKUP_KEY_PROPERTIES: Array = ['pattern', 'limit']; interface LookupRecord { - readonly elementType: ElementTypeIri; - readonly element: ElementIri; - readonly link: LinkTypeIri; - readonly direction: LookupLinkDirectionKey; - readonly text: string; + readonly pattern: string; readonly limit: string; readonly result: DataProviderLookupItem[]; } @@ -146,7 +149,7 @@ interface LookupRecord { * @category Data */ export class IndexedDbCachedProvider implements DataProvider { - static readonly DB_VERSION = 4; + static readonly DB_VERSION = 5; private readonly hasher = new Sha256(); @@ -212,6 +215,21 @@ export class IndexedDbCachedProvider implements DataProvider { keyPath: 'id', }); } + if (!db.objectStoreNames.contains(ObjectStore.lookupElementTypes)) { + db.createObjectStore(ObjectStore.lookupElementTypes, { + keyPath: LOOKUP_TYPES_KEY_PROPERTIES, + }); + } + if (!db.objectStoreNames.contains(ObjectStore.lookupLinkTypes)) { + db.createObjectStore(ObjectStore.lookupLinkTypes, { + keyPath: LOOKUP_TYPES_KEY_PROPERTIES, + }); + } + if (!db.objectStoreNames.contains(ObjectStore.lookupPropertyTypes)) { + db.createObjectStore(ObjectStore.lookupPropertyTypes, { + keyPath: LOOKUP_TYPES_KEY_PROPERTIES, + }); + } if (!db.objectStoreNames.contains(ObjectStore.elementTypes)) { const keyPath: keyof ElementTypeModel = 'id'; db.createObjectStore(ObjectStore.elementTypes, {keyPath}); @@ -316,6 +334,77 @@ export class IndexedDbCachedProvider implements DataProvider { return result.value; } + async lookupElementTypes(params: { + text: string; + limit?: number; + signal?: AbortSignal | undefined; + }): Promise { + return this.lookupTypes( + params, + ObjectStore.lookupElementTypes, + () => this.baseProvider.lookupElementTypes(params) + ); + } + + async lookupPropertyTypes(params: { + text: string; + limit?: number; + signal?: AbortSignal | undefined; + }): Promise { + return this.lookupTypes( + params, + ObjectStore.lookupPropertyTypes, + () => this.baseProvider.lookupPropertyTypes(params) + ); + } + + async lookupLinkTypes(params: { + text: string; + limit?: number; + signal?: AbortSignal | undefined; + }): Promise { + return this.lookupTypes( + params, + ObjectStore.lookupLinkTypes, + () => this.baseProvider.lookupLinkTypes(params) + ); + } + + private async lookupTypes( + params: { + text: string; + limit?: number; + signal?: AbortSignal | undefined; + }, + store: ObjectStore, + lookupItems: () => Promise + ): Promise { + if (!this.cacheTextLookups) { + return lookupItems(); + } + const key: LookupTypesKey = [ + JSON.stringify(serializeTextLookup(params.text)), + ( + params.limit === undefined ? '' : + params.limit === null ? 'null' : + String(params.limit) + ) + ]; + const db = await this.openDb(); + const record = await fetchSingleWithDbCache( + db, + store, + key, + async ([pattern, limit]): Promise> => ({ + pattern, + limit, + result: await lookupItems(), + }) + ); + rehydrateLabels(record.result, this.factory); + return record.result; + } + async elementTypes(params: { classIds: readonly ElementTypeIri[]; signal?: AbortSignal | undefined; @@ -747,15 +836,12 @@ export class IndexedDbCachedProvider implements DataProvider { } async lookup(params: DataProviderLookupParams): Promise { - if (!this.cacheTextLookups && params.text !== undefined) { + const fullPattern = lookupPatternOf(params); + if (!this.cacheTextLookups && hasTextPatternOrFilter(fullPattern)) { return this.baseProvider.lookup(params); } const key: LookupKey = [ - params.elementTypeId ?? '', - params.refElementId ?? '', - params.refElementLinkId ?? '', - params.linkDirection ?? '', - params.text ?? '', + JSON.stringify(serializePattern(fullPattern)), ( params.limit === undefined ? '' : params.limit === null ? 'null' : @@ -767,19 +853,8 @@ export class IndexedDbCachedProvider implements DataProvider { db, ObjectStore.lookup, key, - async ([ - elementType, - element, - link, - direction, - text, - limit, - ]): Promise => ({ - elementType, - element, - link, - direction, - text, + async ([pattern, limit]): Promise => ({ + pattern, limit, result: await this.baseProvider.lookup(params), }) @@ -1123,3 +1198,65 @@ function rehydrateTerm( return term; } } + +function hasTextPatternOrFilter(pattern: LookupPattern): boolean { + switch (pattern.type) { + case 'allOf': + case 'anyOf': { + return pattern.patterns.some(hasTextPatternOrFilter); + } + case 'withText': { + return true; + } + case 'hasProperty': { + // return pattern.filter !== undefined; + return false; + } + default: { + return false; + } + } +} + +type SExprLike = readonly (string | null | SExprLike)[]; + +function serializeTextLookup(text: string): SExprLike { + return ['withText', text]; +} + +function serializePattern(pattern: LookupPattern): SExprLike { + switch (pattern.type) { + case 'allOf': + case 'anyOf': { + return [pattern.type, pattern.patterns.map(serializePattern)]; + } + case 'hasType': { + return [pattern.type, pattern.elementType]; + } + case 'connectedTo': { + return [ + pattern.type, + pattern.linkType ?? null, + pattern.linkDirection ?? null, + pattern.other ? serializePattern(pattern.other) : null, + ]; + } + case 'withText': { + return [pattern.type, pattern.text]; + } + case 'fromValues': { + return [pattern.type, pattern.values]; + } + case 'hasProperty': { + return [ + pattern.type, + pattern.propertyType, + (pattern.values ?? []).map(Rdf.termToString), + /* serializeFilter(pattern.filter) */ null + ]; + } + default: { + return []; + } + } +} diff --git a/src/data/rdf/datasetLookup.ts b/src/data/rdf/datasetLookup.ts new file mode 100644 index 00000000..21477a0a --- /dev/null +++ b/src/data/rdf/datasetLookup.ts @@ -0,0 +1,277 @@ +import { HashMap } from '@reactodia/hashmap'; + +import { LookupPattern } from '../dataProvider'; +import { ElementIri, ElementTypeIri, LinkTypeIri, PropertyTypeIri } from '../model'; +import { makeCaseInsensitiveFilter } from '../utils'; + +import { MemoryDataset } from './memoryDataset'; +import * as Rdf from './rdfModel'; + +interface MatchItem { + readonly term: Rdf.NamedNode | Rdf.BlankNode; + outLinks?: Set; + inLinks?: Set; +} + +export interface MatchPatternContext { + readonly dataset: MemoryDataset; + readonly factory: Rdf.DataFactory; + readonly typePredicate: Rdf.NamedNode; + readonly labelPredicate: Rdf.NamedNode | null; +} + +export function matchPattern( + pattern: LookupPattern, + context: MatchPatternContext +): HashMap { + return joinWithPattern(undefined, pattern, context); +} + +function joinWithPattern( + previous: HashMap | undefined, + pattern: LookupPattern, + context: MatchPatternContext +): HashMap { + switch (pattern.type) { + case 'allOf': { + if (pattern.patterns.length === 0) { + return makeItemMap(); + } + let items = previous; + for (const p of pattern.patterns) { + items = joinWithPattern(items, p, context); + } + return items ?? makeItemMap(); + } + case 'anyOf': { + const items = makeItemMap(); + for (const p of pattern.patterns) { + const next = joinWithPattern(previous, p, context); + for (const m of next.values()) { + const item = items.get(m.term); + items.set(m.term, item ? mergeItem(item, m) : m); + } + } + return items; + } + case 'withText': { + const items = makeItemMap(); + if (context.labelPredicate) { + const textFilter = makeCaseInsensitiveFilter(pattern.text); + const iterable = previous + ? iterateWithAllSubjects(context.dataset, context.labelPredicate, previous.values()) + : context.dataset.iterateMatches(null, context.labelPredicate, null); + for (const t of iterable) { + if ( + isResourceTerm(t.subject) && + t.object.termType === 'Literal' && + textFilter(t.object.value) && + !items.has(t.subject) + ) { + items.set(t.subject, {term: t.subject}); + } + } + } + return items; + } + case 'hasType': { + const typeTerm = decodeTerm(pattern.elementType, context.factory); + const items = makeItemMap(); + if (previous) { + for (const item of previous.values()) { + if (context.dataset.hasMatches(item.term, context.typePredicate, typeTerm)) { + items.set(item.term, item); + } + } + } else { + for (const t of context.dataset.iterateMatches(null, context.typePredicate, typeTerm)) { + if (isResourceTerm(t.subject) && !items.has(t.subject)) { + items.set(t.subject, {term: t.subject}); + } + } + } + return items; + } + case 'connectedTo': { + const targets = pattern.other + ? Array.from(joinWithPattern(undefined, pattern.other, context).keys()) + : [null]; + const linkTerm = pattern.linkType ? decodeTerm(pattern.linkType, context.factory) : null; + const items = makeItemMap(); + if (!pattern.linkDirection || pattern.linkDirection === 'out') { + for (const valueTerm of targets) { + for (const t of context.dataset.iterateMatches(valueTerm, linkTerm, null)) { + if ( + t.predicate.termType === 'NamedNode' && + isResourceTerm(t.object) && + (!previous || previous.has(t.object)) + ) { + const term = t.object; + let item = items.get(term); + if (!item) { + item = {term}; + items.set(term, item); + } + const predicate: LinkTypeIri = encodeTerm(t.predicate); + if (!item.outLinks) { + item.outLinks = new Set(); + } + item.outLinks.add(predicate); + } + } + } + } + if (!pattern.linkDirection || pattern.linkDirection === 'in') { + for (const valueTerm of targets) { + for (const t of context.dataset.iterateMatches(null, linkTerm, valueTerm)) { + if ( + t.predicate.termType === 'NamedNode' && + isResourceTerm(t.subject) && + (!previous || previous.has(t.subject)) + ) { + const term = t.subject; + let item = items.get(term); + if (!item) { + item = {term}; + items.set(term, item); + } + const predicate: LinkTypeIri = encodeTerm(t.predicate); + if (!item.inLinks) { + item.inLinks = new Set(); + } + item.inLinks.add(predicate); + } + } + } + } + return items; + } + case 'fromValues': { + const items = makeItemMap(); + for (const value of pattern.values) { + const valueTerm = decodeTerm(value, context.factory); + if (!previous || previous.has(valueTerm)) { + items.set(valueTerm, {term: valueTerm}); + } + } + return items; + } + case 'hasProperty': { + const providedTerms = pattern.values ?? []; + const propertyTerm = decodeTerm(pattern.propertyType, context.factory); + const items = makeItemMap(); + if (propertyTerm.termType === 'BlankNode') { + return items; + } + const iterable = ( + previous ? iterateWithAllSubjects(context.dataset, propertyTerm, previous.values()) : + providedTerms.length === 0 ? context.dataset.iterateMatches(null, propertyTerm, null) : + iterateWithAllObjects(context.dataset, propertyTerm, providedTerms) + ); + for (const t of iterable) { + if ( + isResourceTerm(t.subject) && + (!previous || providedTerms.find(value => Rdf.equalTerms(t.object, value))) + ) { + items.set(t.subject, {term: t.subject}); + } + } + return items; + } + } +}; + +function makeItemMap() { + return new HashMap( + Rdf.hashTerm, Rdf.equalTerms + ); +} +function mergeItem(a: MatchItem, b: MatchItem): MatchItem { + if (!a.inLinks && !a.outLinks) { + return b; + } else if (!b.inLinks && !b.outLinks) { + return a; + } else { + let inLinks: Set | undefined; + if (a.inLinks && b.inLinks) { + inLinks = new Set(a.inLinks); + for (const link of b.inLinks) { + inLinks.add(link); + } + } else { + inLinks = a.inLinks ?? b.inLinks; + } + + let outLinks: Set | undefined; + if (a.outLinks && b.outLinks) { + outLinks = new Set(a.outLinks); + for (const link of b.outLinks) { + outLinks.add(link); + } + } else { + outLinks = a.outLinks ?? b.outLinks; + } + + return { + term: a.term, + inLinks, + outLinks, + }; + } +} + +function* iterateWithAllSubjects( + dataset: MemoryDataset, + predicate: Rdf.NamedNode, + items: Iterable +): IterableIterator { + for (const item of items) { + yield* dataset.iterateMatches(item.term, predicate, null); + } +} + +function* iterateWithAllObjects( + dataset: MemoryDataset, + predicate: Rdf.NamedNode, + objects: readonly Rdf.Quad['object'][] +): IterableIterator { + for (const object of objects) { + yield* dataset.iterateMatches(null, predicate, object); + } +} + +export function isResourceTerm(term: Rdf.Term): term is Rdf.NamedNode | Rdf.BlankNode { + switch (term.termType) { + case 'NamedNode': + case 'BlankNode': + return true; + default: + return false; + } +} + +const BLANK_PREFIX = 'urn:reactodia:blank:rdf:'; + +export function encodeTerm(term: Rdf.NamedNode | Rdf.BlankNode): string { + switch (term.termType) { + case 'NamedNode': + return term.value; + case 'BlankNode': + return BLANK_PREFIX + term.value; + default: + throw new Error( + `Unexpected term type to encode: ${(term as Rdf.Term).termType}` + ); + } +} + +export function decodeTerm( + iri: ElementIri | ElementTypeIri | LinkTypeIri | PropertyTypeIri, + factory: Rdf.DataFactory +): Rdf.NamedNode | Rdf.BlankNode { + if (iri.startsWith(BLANK_PREFIX)) { + return factory.blankNode(iri.substring(BLANK_PREFIX.length)); + } else { + return factory.namedNode(iri); + } +} diff --git a/src/data/rdf/rdfDataProvider.ts b/src/data/rdf/rdfDataProvider.ts index dee40117..a45a5024 100644 --- a/src/data/rdf/rdfDataProvider.ts +++ b/src/data/rdf/rdfDataProvider.ts @@ -1,4 +1,4 @@ -import { HashMap, HashSet } from '@reactodia/hashmap'; +import { HashSet } from '@reactodia/hashmap'; import { ElementTypeGraph, ElementTypeModel, LinkTypeModel, ElementModel, LinkModel, PropertyTypeModel, @@ -7,9 +7,11 @@ import { } from '../model'; import { DataProvider, DataProviderLinkCount, DataProviderLookupParams, DataProviderLookupItem, + lookupPatternOf, } from '../dataProvider'; import { makeCaseInsensitiveFilter } from '../utils'; +import { matchPattern, encodeTerm, decodeTerm, isResourceTerm } from './datasetLookup'; import { MemoryDataset, IndexQuadBy, indexedDataset } from './memoryDataset'; import * as Rdf from './rdfModel'; import { owl, rdf, rdfs, schema } from './vocabulary'; @@ -73,16 +75,31 @@ export interface RdfDataProviderOptions { * **Default**: * ```json * [ - * "http://www.w3.org/2002/07/owl#ObjectProperty", * "http://www.w3.org/1999/02/22-rdf-syntax-ns#Property" * ] * ``` */ + readonly propertyOrLinkBaseTypes?: ReadonlyArray; + /** + * **Default**: + * ```json + * [ + * "http://www.w3.org/2002/07/owl#DatatypeProperty" + * ] + * ``` + */ + readonly propertyTypeBaseTypes?: ReadonlyArray; + /** + * **Default**: + * ```json + * [ + * "http://www.w3.org/2002/07/owl#ObjectProperty" + * ] + * ``` + */ readonly linkTypeBaseTypes?: ReadonlyArray; } -const BLANK_PREFIX = 'urn:reactodia:blank:rdf:'; - /** * Provides graph data from in-memory [RDF/JS-compatible](https://rdf.js.org/data-model-spec/) * graph dataset. @@ -101,10 +118,16 @@ export class RdfDataProvider implements DataProvider { private readonly datatypePredicates: ReadonlySet; private readonly elementTypeBaseTypes: ReadonlyArray; private readonly elementSubtypePredicate: Rdf.NamedNode | null; + private readonly propertyOrLinkBaseTypes: ReadonlyArray; + private readonly propertyTypeBaseTypes: ReadonlyArray; private readonly linkTypeBaseTypes: ReadonlyArray; private readonly EMPTY_LINKS: ReadonlySet = new Set(); + private readonly elementCount = new Map(); + private readonly linkCount = new Map(); + private readonly propertyCount = new Map(); + constructor(options: RdfDataProviderOptions = {}) { this.factory = options.factory ?? Rdf.DefaultDataFactory; this.dataset = indexedDataset( @@ -124,7 +147,11 @@ export class RdfDataProvider implements DataProvider { .map(iri => this.factory.namedNode(iri)); this.elementSubtypePredicate = options.elementSubtypePredicate === null ? null : this.factory.namedNode(options.elementSubtypePredicate ?? rdfs.subClassOf); - this.linkTypeBaseTypes = (options.linkTypeBaseTypes ?? [owl.ObjectProperty, rdf.Property]) + this.propertyOrLinkBaseTypes = (options.propertyOrLinkBaseTypes ?? [rdf.Property]) + .map(iri => this.factory.namedNode(iri)); + this.propertyTypeBaseTypes = (options.propertyTypeBaseTypes ?? [owl.DatatypeProperty]) + .map(iri => this.factory.namedNode(iri)); + this.linkTypeBaseTypes = (options.linkTypeBaseTypes ?? [owl.ObjectProperty]) .map(iri => this.factory.namedNode(iri)); } @@ -133,16 +160,40 @@ export class RdfDataProvider implements DataProvider { * to be able to query it via this provider's methods. */ addGraph(quads: Iterable): void { - if (this.acceptBlankNodes) { - this.dataset.addAll(quads); - } else { - for (const q of quads) { - if (!( - q.subject.termType === 'BlankNode' || - q.object.termType === 'BlankNode' || - q.graph.termType === 'BlankNode' - )) { - this.dataset.add(q); + for (const q of quads) { + if (this.dataset.has(q)) { + continue; + } else if (this.acceptBlankNodes || !( + q.subject.termType === 'BlankNode' || + q.object.termType === 'BlankNode' || + q.graph.termType === 'BlankNode' + )) { + this.dataset.add(q); + + if (q.predicate.termType === 'NamedNode') { + if ( + q.predicate.value === this.typePredicate.value && + q.object.termType === 'NamedNode' + ) { + const count = this.elementCount.get(q.object.value) ?? 0; + this.elementCount.set(q.object.value, count + 1); + } else { + if ( + q.object.termType === 'NamedNode' && + !this.datatypePredicates.has(q.predicate.value) + ) { + const count = this.linkCount.get(q.predicate.value) ?? 0; + this.linkCount.set(q.predicate.value, count + 1); + } else if ( + q.object.termType === 'Literal' || ( + this.datatypePredicates.has(q.predicate.value) && + q.object.termType === 'NamedNode' + ) + ) { + const count = this.propertyCount.get(q.predicate.value) ?? 0; + this.propertyCount.set(q.predicate.value, count + 1); + } + } } } } @@ -153,6 +204,9 @@ export class RdfDataProvider implements DataProvider { */ clear(): void { this.dataset.clear(); + this.elementCount.clear(); + this.linkCount.clear(); + this.propertyCount.clear(); } /** @@ -182,7 +236,7 @@ export class RdfDataProvider implements DataProvider { knownElementTypes(params: { signal?: AbortSignal; }): Promise { - const typeCounts = this.computeTypeCounts(); + const typeCounts = new Map(this.elementCount); for (const baseType of this.elementTypeBaseTypes) { for (const t of this.dataset.iterateMatches(null, this.typePredicate, baseType)) { if (isResourceTerm(t.subject)) { @@ -239,13 +293,15 @@ export class RdfDataProvider implements DataProvider { knownLinkTypes(params: { signal?: AbortSignal; }): Promise { - const linkCounts = this.computeLinkCounts(); - for (const baseType of this.linkTypeBaseTypes) { - for (const t of this.dataset.iterateMatches(null, this.typePredicate, baseType)) { - if (isResourceTerm(t.subject)) { - const linkTypeId: LinkTypeIri = this.encodeTerm(t.subject); - if (!linkCounts.has(linkTypeId)) { - linkCounts.set(linkTypeId, 0); + const linkCounts = new Map(this.linkCount); + for (const baseTypes of [this.propertyOrLinkBaseTypes, this.linkTypeBaseTypes]) { + for (const baseType of baseTypes) { + for (const t of this.dataset.iterateMatches(null, this.typePredicate, baseType)) { + if (isResourceTerm(t.subject)) { + const linkTypeId: LinkTypeIri = this.encodeTerm(t.subject); + if (!linkCounts.has(linkTypeId)) { + linkCounts.set(linkTypeId, 0); + } } } } @@ -264,6 +320,146 @@ export class RdfDataProvider implements DataProvider { return Promise.resolve(Array.from(models.values())); } + lookupElementTypes(params: { + text: string; + limit?: number; + signal?: AbortSignal; + }): Promise { + const {text, limit} = params; + return Promise.resolve(this.lookupSchemaItems({ + implicitValues: this.elementCount.keys(), + includeTypes: this.elementTypeBaseTypes, + excludeTypes: [], + allowBlanks: true, + createItem: (idTerm, label): ElementTypeModel => { + const id = this.encodeTerm(idTerm); + const count = this.elementCount.get(id); + return {id, label, count}; + }, + text, + limit, + })); + } + + lookupPropertyTypes(params: { + text: string; + limit?: number; + signal?: AbortSignal; + }): Promise { + const {text, limit} = params; + return Promise.resolve(this.lookupSchemaItems({ + implicitValues: this.propertyCount.keys(), + includeTypes: [ + ...this.propertyOrLinkBaseTypes, + ...this.propertyTypeBaseTypes, + ], + excludeTypes: this.linkTypeBaseTypes, + createItem: (id, label): PropertyTypeModel => ({ + id: this.encodeTerm(id), + label, + }), + text, + limit, + })); + } + + lookupLinkTypes(params: { + text: string; + limit?: number; + signal?: AbortSignal; + }): Promise { + const {text, limit} = params; + return Promise.resolve(this.lookupSchemaItems({ + implicitValues: this.linkCount.keys(), + includeTypes: [ + ...this.propertyOrLinkBaseTypes, + ...this.linkTypeBaseTypes, + ], + excludeTypes: this.propertyTypeBaseTypes, + createItem: (idTerm, label): LinkTypeModel => { + const id = this.encodeTerm(idTerm); + const count = this.linkCount.get(id); + return {id, label, count}; + }, + text, + limit, + })); + } + + private lookupSchemaItems(params: { + implicitValues: Iterable; + includeTypes: readonly Rdf.NamedNode[], + excludeTypes: readonly Rdf.NamedNode[], + allowBlanks?: boolean, + createItem: (id: Rdf.NamedNode | Rdf.BlankNode, label: Rdf.Literal[]) => T, + text: string; + limit?: number; + }): T[] { + const {implicitValues, includeTypes, excludeTypes, allowBlanks, createItem, text} = params; + const limit = typeof params.limit === 'number' ? params.limit : Number.POSITIVE_INFINITY; + const seenIds = new HashSet(Rdf.hashTerm, Rdf.equalTerms); + const items: T[] = []; + const textFilter = makeCaseInsensitiveFilter(text); + + const tryAdd = (id: Rdf.NamedNode | Rdf.BlankNode) => { + let matches = false; + if (id.termType === 'NamedNode') { + const localName = Rdf.getLocalName(id.value); + if (localName !== undefined && textFilter(localName)) { + matches = true; + } + } + + const label = this.labelPredicate + ? findLiterals(this.dataset, id, this.labelPredicate) + : []; + if (label.some(v => textFilter(v.value))) { + matches = true; + } + + if (matches) { + items.push(createItem(id, label)); + } + }; + + for (const implicitValue of implicitValues) { + if (items.length >= limit) { + break; + } + + const id = this.decodeTerm(implicitValue); + if (!seenIds.has(id)) { + tryAdd(id); + } + } + + lookup: for (const baseType of includeTypes) { + const includeMatches = this.dataset.iterateMatches(null, this.typePredicate, baseType); + nextId: for (const {subject: id} of includeMatches) { + if (items.length >= limit) { + break lookup; + } + if ( + !(id.termType === 'NamedNode' || (allowBlanks && id.termType === 'BlankNode')) || + seenIds.has(id) + ) { + continue nextId; + } + + seenIds.add(id); + + for (const excludedType of excludeTypes) { + if (this.dataset.hasMatches(id, this.typePredicate, excludedType)) { + continue nextId; + } + } + + tryAdd(id); + } + } + return items; + } + elementTypes(params: { classIds: ReadonlyArray; signal?: AbortSignal; @@ -312,7 +508,6 @@ export class RdfDataProvider implements DataProvider { signal?: AbortSignal; }): Promise> { const {linkTypeIds} = params; - const linkCounts = this.computeLinkCounts(linkTypeIds); const models = new Map(); for (const linkTypeId of linkTypeIds) { const linkTypeIri = this.decodeTerm(linkTypeId); @@ -321,7 +516,7 @@ export class RdfDataProvider implements DataProvider { label: this.labelPredicate ? findLiterals(this.dataset, linkTypeIri, this.labelPredicate) : [], - count: linkCounts.get(linkTypeId) ?? 0, + count: this.linkCount.get(linkTypeId) ?? 0, }; models.set(linkTypeId, model); } @@ -438,84 +633,13 @@ export class RdfDataProvider implements DataProvider { } lookup(params: DataProviderLookupParams): Promise { - interface ResultItem { - readonly term: Rdf.NamedNode | Rdf.BlankNode; - outLinks?: Set; - inLinks?: Set; - } - - const items = new HashMap( - Rdf.hashTerm, Rdf.equalTerms - ); - let requiredTextFilter = params.text ? makeCaseInsensitiveFilter(params.text) : undefined; - - if (params.refElementId) { - const refElementIri = this.decodeTerm(params.refElementId); - const refLinkIri = params.refElementLinkId - ? this.decodeTerm(params.refElementLinkId) : null; - if (!params.linkDirection || params.linkDirection === 'out') { - for (const t of this.dataset.iterateMatches(refElementIri, refLinkIri, null)) { - if (t.predicate.termType === 'NamedNode' && isResourceTerm(t.object)) { - const term = t.object; - let item = items.get(term); - if (!item) { - item = {term}; - items.set(term, item); - } - const predicate: LinkTypeIri = this.encodeTerm(t.predicate); - if (!item.outLinks) { - item.outLinks = new Set(); - } - item.outLinks.add(predicate); - } - } - } - if (!params.linkDirection || params.linkDirection === 'in') { - for (const t of this.dataset.iterateMatches(null, refLinkIri, refElementIri)) { - if (t.predicate.termType === 'NamedNode' && isResourceTerm(t.subject)) { - const term = t.subject; - let item = items.get(term); - if (!item) { - item = {term}; - items.set(term, item); - } - const predicate: LinkTypeIri = this.encodeTerm(t.predicate); - if (!item.inLinks) { - item.inLinks = new Set(); - } - item.inLinks.add(predicate); - } - } - } - // join with filtered by type - if (params.elementTypeId) { - const typeTerm = this.decodeTerm(params.elementTypeId); - for (const item of Array.from(items.values())) { - if (!this.dataset.hasMatches(item.term, this.typePredicate, typeTerm)) { - items.delete(item.term); - } - } - } - } else if (params.elementTypeId) { - const typeTerm = this.decodeTerm(params.elementTypeId); - for (const t of this.dataset.iterateMatches(null, this.typePredicate, typeTerm)) { - if (isResourceTerm(t.subject) && !items.has(t.subject)) { - items.set(t.subject, {term: t.subject}); - } - } - } else if (requiredTextFilter && this.labelPredicate) { - for (const t of this.dataset.iterateMatches(null, this.labelPredicate, null)) { - if ( - isResourceTerm(t.subject) && - t.object.termType === 'Literal' && - requiredTextFilter(t.object.value) && - !items.has(t.subject) - ) { - items.set(t.subject, {term: t.subject}); - } - } - requiredTextFilter = undefined; - } + const fullPattern = lookupPatternOf(params); + const items = matchPattern(fullPattern, { + factory: this.factory, + dataset: this.dataset, + typePredicate: this.typePredicate, + labelPredicate: this.labelPredicate, + }); const linkedElements: DataProviderLookupItem[] = []; const limit = typeof params.limit === 'number' ? params.limit : Number.POSITIVE_INFINITY; @@ -528,18 +652,6 @@ export class RdfDataProvider implements DataProvider { if (this.labelPredicate) { const labels = findLiterals(this.dataset, item.term, this.labelPredicate); - if (requiredTextFilter) { - let foundMatch = false; - for (const label of labels) { - if (requiredTextFilter(label.value)) { - foundMatch = true; - break; - } - } - if (!foundMatch) { - continue; - } - } properties = { ...properties, [this.labelPredicate.value]: labels, @@ -555,7 +667,7 @@ export class RdfDataProvider implements DataProvider { }; } } - + const model: ElementModel = { id: this.encodeTerm(item.term), types: findTypes(this.dataset, item.term, this.typePredicate), @@ -570,43 +682,6 @@ export class RdfDataProvider implements DataProvider { return Promise.resolve(linkedElements); } - - private computeTypeCounts(): Map { - const instanceCounts = new Map(); - for (const t of this.dataset.iterateMatches(null, this.typePredicate, null)) { - if (isResourceTerm(t.object)) { - const elementTypeId: ElementTypeIri = this.encodeTerm(t.object); - instanceCounts.set(elementTypeId, (instanceCounts.get(elementTypeId) ?? 0) + 1); - } - } - return instanceCounts; - } - - private computeLinkCounts( - onlyLinkTypes?: ReadonlyArray - ): Map { - const linkTypeSet = onlyLinkTypes ? new Set(onlyLinkTypes) : undefined; - const linkStats = new Map(); - for (const t of this.dataset) { - if (t.predicate.termType === 'NamedNode') { - const linkTypeId: LinkTypeIri = this.encodeTerm(t.predicate); - if (!linkTypeSet || linkTypeSet.has(linkTypeId)) { - linkStats.set(linkTypeId, (linkStats.get(linkTypeId) ?? 0) + 1); - } - } - } - return linkStats; - } -} - -function isResourceTerm(term: Rdf.Term): term is Rdf.NamedNode | Rdf.BlankNode { - switch (term.termType) { - case 'NamedNode': - case 'BlankNode': - return true; - default: - return false; - } } function findFirstIriOrLiteral( @@ -682,26 +757,3 @@ function findProperties( return properties; } -function encodeTerm(term: Rdf.NamedNode | Rdf.BlankNode): string { - switch (term.termType) { - case 'NamedNode': - return term.value; - case 'BlankNode': - return BLANK_PREFIX + term.value; - default: - throw new Error( - `Unexpected term type to encode: ${(term as Rdf.Term).termType}` - ); - } -} - -function decodeTerm( - iri: ElementIri | ElementTypeIri | LinkTypeIri | PropertyTypeIri, - factory: Rdf.DataFactory -): Rdf.NamedNode | Rdf.BlankNode { - if (iri.startsWith(BLANK_PREFIX)) { - return factory.blankNode(iri.substring(BLANK_PREFIX.length)); - } else { - return factory.namedNode(iri); - } -} diff --git a/src/data/rdf/rdfModel.ts b/src/data/rdf/rdfModel.ts index 4b9b38c6..35d21c4a 100644 --- a/src/data/rdf/rdfModel.ts +++ b/src/data/rdf/rdfModel.ts @@ -12,7 +12,7 @@ export type DefaultGraph = RdfJs.DefaultGraph; export type BaseQuad = RdfJs.BaseQuad; export type Quad = RdfJs.Quad; -export type Term = NamedNode | BlankNode | Literal | Variable | DefaultGraph | BaseQuad; +export type Term = RdfJs.Term; export type DataFactory = RdfJs.DataFactory; export const DefaultDataFactory: RdfJs.DataFactory = N3.DataFactory; diff --git a/src/data/sparql/responseHandler.ts b/src/data/sparql/responseHandler.ts index d50fe151..b72e7c5d 100644 --- a/src/data/sparql/responseHandler.ts +++ b/src/data/sparql/responseHandler.ts @@ -10,6 +10,7 @@ import { } from '../model'; import type { DataProviderLinkCount, DataProviderLookupItem } from '../dataProvider'; import { LinkConfiguration, PropertyConfiguration } from './sparqlDataProviderSettings'; +import { isDirectLink } from './sparqlGenerator'; import { ClassBinding, ElementBinding, LinkBinding, ElementImageBinding, LinkCountBinding, LinkTypeBinding, ConnectedLinkTypeBinding, PropertyBinding, ElementTypeBinding, FilterBinding, @@ -458,7 +459,7 @@ export function getFilteredData( multimapAdd(resultTypes, iri, binding.classAll.value); } - if (!openWorldLinks && binding.link && binding.direction) { + if (binding.link && binding.direction) { const predicates = ( binding.direction.value === 'in' ? inPredicates : binding.direction.value === 'out' ? outPredicates : @@ -542,18 +543,6 @@ function* translateLinkPredicates( } } -export function isDirectLink(link: LinkConfiguration) { - // link configuration is path-based if includes any variables - const pathBased = /[?$][a-zA-Z]+\b/.test(link.path); - return !pathBased; -} - -export function isDirectProperty(property: PropertyConfiguration) { - // property configuration is path-based if includes any variables - const pathBased = /[?$][a-zA-Z]+\b/.test(property.path); - return !pathBased; -} - function typeMatchesDomain( config: { readonly domain?: ReadonlyArray }, types: ReadonlySet | undefined diff --git a/src/data/sparql/sparqlDataProvider.ts b/src/data/sparql/sparqlDataProvider.ts index 7ee9b73b..2f490df3 100644 --- a/src/data/sparql/sparqlDataProvider.ts +++ b/src/data/sparql/sparqlDataProvider.ts @@ -1,4 +1,3 @@ -import { multimapArrayAdd } from '../../coreUtils/collections'; import * as Rdf from '../rdf/rdfModel'; import { rdfs, schema } from '../rdf/vocabulary'; import { @@ -29,16 +28,17 @@ import { getLinkStatistics, getPropertyTypes, triplesToElementBinding, - isDirectLink, - isDirectProperty, } from './responseHandler'; import { ClassBinding, ElementBinding, ElementTypeBinding, LinkBinding, PropertyBinding, FilterBinding, LinkCountBinding, LinkTypeBinding, ConnectedLinkTypeBinding, ElementImageBinding, } from './sparqlModels'; import { - SparqlDataProviderSettings, OwlStatsSettings, LinkConfiguration, PropertyConfiguration, + SparqlDataProviderSettings, OwlStatsSettings, FullTextSearchSettings, } from './sparqlDataProviderSettings'; +import { + SparqlGenerator, escapeIri, formatValueClauseContent, resolveTemplate, +} from './sparqlGenerator'; import { SparqlQuerySource, HttpSparqlQuerySource, HttpSparqlQuerySourceOptions, } from './sparqlQuerySource'; @@ -177,19 +177,14 @@ export interface SparqlProviderChunkOptions { */ export class SparqlDataProvider implements DataProvider { readonly factory: Rdf.DataFactory; + private readonly source: SparqlQuerySource; private readonly options: SparqlDataProviderOptions; private readonly settings: SparqlDataProviderSettings; private readonly acceptBlankNodes = false; private readonly chunkMeasure: ChunkMeasure; - - private linkByPredicate = new Map(); - private linkById = new Map(); - private openWorldLinks: boolean; - - private propertyByPredicate = new Map(); - private openWorldProperties: boolean; - + + private readonly generator: SparqlGenerator; private readonly labelPredicate: PropertyTypeIri; private readonly imagePredicate: PropertyTypeIri; @@ -246,21 +241,9 @@ export class SparqlDataProvider implements DataProvider { }; } - for (const link of settings.linkConfigurations) { - this.linkById.set(link.id, link); - const predicate = isDirectLink(link) ? link.path : link.id; - multimapArrayAdd(this.linkByPredicate, predicate, link); - } - this.openWorldLinks = settings.linkConfigurations.length === 0 || - Boolean(settings.openWorldLinks); - - for (const property of settings.propertyConfigurations) { - const predicate = isDirectProperty(property) ? property.path : property.id; - multimapArrayAdd(this.propertyByPredicate, predicate, property); - } - this.openWorldProperties = settings.propertyConfigurations.length === 0 || - Boolean(settings.openWorldProperties); - + this.generator = new SparqlGenerator(factory, settings, { + acceptBlankNodes: this.acceptBlankNodes, + }); this.labelPredicate = prepareLabelPredicate ?? rdfs.label; this.imagePredicate = prepareImagePredicate ?? schema.thumbnailUrl; } @@ -281,14 +264,14 @@ export class SparqlDataProvider implements DataProvider { signal?: AbortSignal; }): Promise { const {signal} = params; - const {defaultPrefix, schemaLabelProperty, filterOnlyLanguages, classTreeQuery} = this.settings; + const {defaultPrefix, schemaLabelProperty, classTreeQuery} = this.settings; if (!classTreeQuery) { return {elementTypes: [], subtypeOf: []}; } const query = defaultPrefix + resolveTemplate(classTreeQuery, { schemaLabelProperty, - labelLanguageFilter: formatLanguageFilter('?label', filterOnlyLanguages), + labelLanguageFilter: this.generator.formatLanguageFilter('?label'), }); const response = await this.executeSparqlSelect(query, {signal}); const classTree = getClassTree(response.results.bindings); @@ -300,21 +283,131 @@ export class SparqlDataProvider implements DataProvider { return classTree; } + async knownLinkTypes(params: { + signal?: AbortSignal; + }): Promise { + const {signal} = params; + const { + defaultPrefix, schemaLabelProperty, linkTypesQuery, linkTypesPattern, + } = this.settings; + if (!linkTypesQuery) { + return []; + } + + const query = defaultPrefix + resolveTemplate(linkTypesQuery, { + linkTypesPattern, + schemaLabelProperty, + labelLanguageFilter: this.generator.formatLanguageFilter('?label'), + }); + const response = await this.executeSparqlSelect(query, {signal}); + const linkTypes = getLinkTypes(response.results.bindings); + + if (this.options.prepareLabels) { + await attachLabels(Array.from(linkTypes.values()), this.options.prepareLabels, signal); + } + + return Array.from(linkTypes.values()); + } + + async lookupElementTypes(params: { + text: string; + limit?: number; + signal?: AbortSignal; + }): Promise { + const {classLookupQuery, classLookupSettings} = this.settings; + return this.lookupSchemaItems( + classLookupQuery, + classLookupSettings, + this.factory.variable!('class'), + (bindings: ClassBinding[]) => getClassTree(bindings).elementTypes, + params + ); + } + + async lookupPropertyTypes(params: { + text: string; + limit?: number; + signal?: AbortSignal; + }): Promise { + const {propertyTypesLookupQuery, propertyTypesLookupSettings} = this.settings; + return this.lookupSchemaItems( + propertyTypesLookupQuery, + propertyTypesLookupSettings, + this.factory.variable!('property'), + (bindings: PropertyBinding[]) => Array.from( + getPropertyTypes(bindings).values() + ), + params + ); + } + + async lookupLinkTypes(params: { + text: string; + limit?: number; + signal?: AbortSignal; + }): Promise { + const {linkTypesLookupQuery, linkTypesLookupSettings} = this.settings; + return this.lookupSchemaItems( + linkTypesLookupQuery, + linkTypesLookupSettings, + this.factory.variable!('link'), + (bindings: LinkTypeBinding[]) => Array.from( + getLinkTypes(bindings).values() + ), + params + ); + } + + private async lookupSchemaItems( + lookupQuery: string | undefined, + lookupSettings: FullTextSearchSettings | undefined, + itemTerm: Rdf.Variable, + itemsFromResponse: (bindings: Binding[]) => Item[], + params: { + text: string; + limit?: number; + signal?: AbortSignal; + } + ): Promise { + const {text, limit, signal} = params; + const {schemaTextSearch} = this.settings; + + const textSearchSettings = lookupSettings ?? schemaTextSearch; + if (!(lookupQuery && textSearchSettings)) { + return []; + } + + const query = this.generator.generateLookupSchemaQuery({ + lookupQuery, + textSearchSettings, + itemTerm, + text, + limit, + }); + const response = await this.executeSparqlSelect(query, {signal}); + const items = Array.from(itemsFromResponse(response.results.bindings).values()); + + if (this.options.prepareLabels) { + await attachLabels(items, this.options.prepareLabels, signal); + } + + return items; + } + async propertyTypes(params: { propertyIds: ReadonlyArray; signal?: AbortSignal; }): Promise> { const {propertyIds, signal} = params; - const {defaultPrefix, schemaLabelProperty, filterOnlyLanguages, propertyInfoQuery} = this.settings; + const {defaultPrefix, schemaLabelProperty, propertyInfoQuery} = this.settings; const properties = new Map(); if (propertyInfoQuery) { await this.queryChunked(propertyIds, async batch => { - const ids = batch.map(escapeIri).map(id => ` ( ${id} )`).join(' '); const query = defaultPrefix + resolveTemplate(propertyInfoQuery, { - ids, + ids: formatValueClauseContent(batch), schemaLabelProperty, - labelLanguageFilter: formatLanguageFilter('?label', filterOnlyLanguages), + labelLanguageFilter: this.generator.formatLanguageFilter('?label'), }); const response = await this.executeSparqlSelect(query, {signal}); collectPropertyTypes(response.results.bindings, properties); @@ -337,16 +430,15 @@ export class SparqlDataProvider implements DataProvider { signal?: AbortSignal; }): Promise> { const {classIds, signal} = params; - const {defaultPrefix, schemaLabelProperty, filterOnlyLanguages, classInfoQuery} = this.settings; + const {defaultPrefix, schemaLabelProperty, classInfoQuery} = this.settings; const classes = new Map(); if (classInfoQuery) { await this.queryChunked(classIds, async batch => { - const ids = batch.map(escapeIri).map(id => ` ( ${id} )`).join(' '); const query = defaultPrefix + resolveTemplate(classInfoQuery, { - ids, + ids: formatValueClauseContent(batch), schemaLabelProperty, - labelLanguageFilter: formatLanguageFilter('?label', filterOnlyLanguages), + labelLanguageFilter: this.generator.formatLanguageFilter('?label'), }); const response = await this.executeSparqlSelect(query, {signal}); collectClassInfo(response.results.bindings, classes); @@ -369,7 +461,7 @@ export class SparqlDataProvider implements DataProvider { signal?: AbortSignal; }): Promise> { const {linkTypeIds, signal} = params; - const {defaultPrefix, schemaLabelProperty, filterOnlyLanguages, linkTypesInfoQuery} = this.settings; + const {defaultPrefix, schemaLabelProperty, linkTypesInfoQuery} = this.settings; const linkTypes = new Map(); if (linkTypesInfoQuery) { @@ -378,7 +470,7 @@ export class SparqlDataProvider implements DataProvider { const query = defaultPrefix + resolveTemplate(linkTypesInfoQuery, { ids, schemaLabelProperty, - labelLanguageFilter: formatLanguageFilter('?label', filterOnlyLanguages), + labelLanguageFilter: this.generator.formatLanguageFilter('?label'), }); const response = await this.executeSparqlSelect(query, {signal}); collectLinkTypes(response.results.bindings, linkTypes); @@ -396,32 +488,6 @@ export class SparqlDataProvider implements DataProvider { return linkTypes; } - async knownLinkTypes(params: { - signal?: AbortSignal; - }): Promise { - const {signal} = params; - const { - defaultPrefix, schemaLabelProperty, filterOnlyLanguages, linkTypesQuery, linkTypesPattern, - } = this.settings; - if (!linkTypesQuery) { - return []; - } - - const query = defaultPrefix + resolveTemplate(linkTypesQuery, { - linkTypesPattern, - schemaLabelProperty, - labelLanguageFilter: formatLanguageFilter('?label', filterOnlyLanguages), - }); - const response = await this.executeSparqlSelect(query, {signal}); - const linkTypes = getLinkTypes(response.results.bindings); - - if (this.options.prepareLabels) { - await attachLabels(Array.from(linkTypes.values()), this.options.prepareLabels, signal); - } - - return Array.from(linkTypes.values()); - } - async elements(params: { elementIds: ReadonlyArray; signal?: AbortSignal; @@ -432,13 +498,18 @@ export class SparqlDataProvider implements DataProvider { if (elementIds.length > 0) { await this.queryChunked(elementIds, async batch => { const ids = batch.map(escapeIri).map(id => ` (${id})`).join(' '); - const {defaultPrefix, dataLabelProperty, filterOnlyLanguages, elementInfoQuery} = this.settings; + const {defaultPrefix, dataLabelProperty, elementInfoQuery} = this.settings; const query = defaultPrefix + resolveTemplate(elementInfoQuery, { ids, dataLabelProperty, - labelLanguageFilter: formatLanguageFilter('?label', filterOnlyLanguages), - valueLanguageFilter: formatLanguageFilter('?propValue', filterOnlyLanguages), - propertyConfigurations: this.formatPropertyInfo(), + labelLanguageFilter: this.generator.formatLanguageFilter('?label'), + valueLanguageFilter: this.generator.formatLanguageFilter('?propValue'), + propertyConfigurations: this.generator.formatPropertyUnion({ + entityTerm: this.factory.variable!('inst'), + valueTerm: this.factory.variable!('propValue'), + predicateTerm: this.factory.variable!('propType'), + withProjection: true, + }), }); const response = await this.executeSparqlConstruct(query); for (const triple of response) { @@ -456,9 +527,9 @@ export class SparqlDataProvider implements DataProvider { const elementModels = getElementsInfo( bindings, await types, - this.propertyByPredicate, + this.generator.propertyByPredicate, this.labelPredicate, - this.openWorldProperties + this.generator.openWorldProperties ); if (this.options.prepareLabels) { @@ -518,10 +589,10 @@ export class SparqlDataProvider implements DataProvider { signal?: AbortSignal; }): Promise { const {primary, secondary, linkTypeIds, signal} = params; - const {filterOnlyLanguages, linksInfoQuery} = this.settings; + const {linksInfoQuery} = this.settings; - const propLanguageFilter = formatLanguageFilter('?propValue', filterOnlyLanguages); - const linkConfigurations = this.formatLinkLinks(); + const propLanguageFilter = this.generator.formatLanguageFilter('?propValue'); + const linkConfigurations = this.generator.formatLinkLinks(); let bindings: Promise>; if (primary.length > 0 && secondary.length > 0) { @@ -545,7 +616,7 @@ export class SparqlDataProvider implements DataProvider { } let elementsForTypes: Set | undefined; - if (this.linkByPredicate.size > 0) { + if (this.generator.linkByPredicate.size > 0) { // Optimization for common case without link configurations elementsForTypes = new Set(primary); for (const iri of secondary) { @@ -561,8 +632,8 @@ export class SparqlDataProvider implements DataProvider { let linksInfo = getLinksInfo( await bindings, await types, - this.linkByPredicate, - this.openWorldLinks + this.generator.linkByPredicate, + this.generator.openWorldLinks ); if (linkTypeIds) { const allowedLinkTypes = new Set(linkTypeIds); @@ -641,70 +712,34 @@ export class SparqlDataProvider implements DataProvider { signal?: AbortSignal; }): Promise { const {elementId, inexactCount, signal} = params; - const {defaultPrefix, linkTypesOfQuery, linkTypesStatisticsQuery, filterTypePattern} = this.settings; - - const bindDirection = /\?direction\b/.test(linkTypesOfQuery); - - const elementIri = escapeIri(elementId); - const forAll = this.formatLinkUnion( - elementId, undefined, undefined, '?outObject', '?inObject', bindDirection - ); - if (forAll.usePredicatePart) { - forAll.unionParts.push( - `{ ${elementIri} ?link ?outObject ${bindDirection ? 'BIND("out" AS ?direction)' : ''} }` - ); - forAll.unionParts.push( - `{ ?inObject ?link ${elementIri} ${bindDirection ? 'BIND("in" AS ?direction)' : ''} }` - ); - } - - const query = defaultPrefix + resolveTemplate(linkTypesOfQuery, { - elementIri, - linkConfigurations: forAll.unionParts.join('\nUNION\n'), + + const elementTerm = this.factory.namedNode(elementId); + const targetOut = this.factory.variable!('outObject'); + const targetIn = this.factory.variable!('inObject'); + const {query, bindDirection} = this.generator.generateLinkTypesOfQuery({ + elementTerm, + targetIn, + targetOut, }); - const linkTypeResponse = await this.executeSparqlSelect(query, {signal}); + const linkTypeResponse = await this.executeSparqlSelect( + query, + {signal} + ); const connectedLinkTypes = getConnectedLinkTypes( linkTypeResponse.results.bindings, - this.linkByPredicate, - this.openWorldLinks + this.generator.linkByPredicate, + this.generator.openWorldLinks ); - const navigateElementFilterOut = this.acceptBlankNodes - ? 'FILTER (IsIri(?outObject) || IsBlank(?outObject))' - : 'FILTER IsIri(?outObject)'; - // Specifically use isBlank() as opposed to isIri() for filtering to avoid - // a full scan on some endpoints (e.g. Virtuoso) - const navigateElementFilterIn = this.acceptBlankNodes - ? '' - : 'FILTER(!isBlank(?inObject))'; + const { + navigateElementFilterIn, + navigateElementFilterOut, + } = this.generator.formatLinkStatsNavigateFilters(); const foundLinkStats: DataProviderLinkCount[] = []; await Promise.all(connectedLinkTypes.map(async ({linkType, hasInLink, hasOutLink}) => { - const linkConfig = this.linkById.get(linkType); - let linkConfigurationOut: string; - let linkConfigurationIn: string; - - if (!linkConfig || isDirectLink(linkConfig)) { - const predicate = escapeIri( - linkConfig && isDirectLink(linkConfig) ? linkConfig.path : linkType - ); - linkConfigurationOut = `${elementIri} ${predicate} ?outObject`; - linkConfigurationIn = `?inObject ${predicate} ${elementIri}`; - } else { - linkConfigurationOut = this.formatLinkPath(linkConfig.path, elementIri, '?outObject'); - linkConfigurationIn = this.formatLinkPath(linkConfig.path, '?inObject', elementIri); - } - - if (linkConfig && linkConfig.domain && linkConfig.domain.length > 0) { - const commaSeparatedDomains = linkConfig.domain.map(escapeIri).join(', '); - const restrictionOut = filterTypePattern.replace(/[?$]inst\b/g, elementIri); - const restrictionIn = filterTypePattern.replace(/[?$]inst\b/g, '?inObject'); - linkConfigurationOut += ` { ${restrictionOut} FILTER(?class IN (${commaSeparatedDomains})) }`; - linkConfigurationIn += ` { ${restrictionIn} FILTER(?class IN (${commaSeparatedDomains})) }`; - } - - if (!linkTypesStatisticsQuery || (inexactCount && bindDirection)) { + if (!this.settings.linkTypesStatisticsQuery || (inexactCount && bindDirection)) { foundLinkStats.push({ id: linkType, inCount: hasInLink ? 1 : 0, @@ -712,15 +747,18 @@ export class SparqlDataProvider implements DataProvider { inexact: true, }); } else { - const statsQuery = defaultPrefix + resolveTemplate(linkTypesStatisticsQuery, { - linkId: escapeIri(linkType), - elementIri, - linkConfigurationOut, - linkConfigurationIn, - navigateElementFilterOut, + const statsQuery = this.generator.generateLinkStatsQuery({ + elementTerm, + targetIn, + targetOut, + linkType, navigateElementFilterIn, + navigateElementFilterOut, }); - const statsResponse = await this.executeSparqlSelect(statsQuery, {signal}); + const statsResponse = await this.executeSparqlSelect( + statsQuery, + {signal} + ); const linkStats = getLinkStatistics(statsResponse.results.bindings); if (linkStats) { foundLinkStats.push(linkStats); @@ -744,15 +782,18 @@ export class SparqlDataProvider implements DataProvider { signal ); - const filterQuery = this.createFilterQuery(params); - const response = await this.executeSparqlSelect(filterQuery, {signal}); + const filterQuery = this.generator.generateLookupQuery(params); + const response = await this.executeSparqlSelect( + filterQuery, + {signal} + ); const linkedElements = getFilteredData( response.results.bindings, await types, - this.linkByPredicate, + this.generator.linkByPredicate, this.labelPredicate, - this.openWorldLinks + this.generator.openWorldLinks ); if (this.options.prepareLabels) { @@ -768,81 +809,6 @@ export class SparqlDataProvider implements DataProvider { return linkedElements; } - private createFilterQuery(params: DataProviderLookupParams): string { - if (!params.refElementId && params.refElementLinkId) { - throw new Error('Cannot execute refElementLink filter without refElement'); - } - - let outerProjection = '?inst ?class ?label'; - let innerProjection = '?inst'; - - let filterByRefElementLink = ''; - let queryTypes = ''; - if (params.refElementId) { - outerProjection += ' ?link ?direction'; - innerProjection += ' ?link ?direction'; - filterByRefElementLink = this.createRefQueryPart({ - elementId: params.refElementId, - linkId: params.refElementLinkId, - direction: params.linkDirection, - }); - - if (this.settings.linkConfigurations.length > 0) { - outerProjection += ' ?classAll'; - queryTypes = this.settings.filterTypePattern.replace(/[?$]class\b/g, '?classAll'); - } - } - - let filterByType = ''; - if (params.elementTypeId) { - const elementTypeIri = escapeIri(params.elementTypeId); - filterByType = this.settings.filterTypePattern.replace(/[?$]class\b/g, elementTypeIri); - } - - const { - defaultPrefix, dataLabelProperty, lookupQuery, filterOnlyLanguages, - filterElementInfoPattern, fullTextSearch, - } = this.settings; - - const queryElementInfo = resolveTemplate(filterElementInfoPattern, { - dataLabelProperty, - labelLanguageFilter: formatLanguageFilter('?label', filterOnlyLanguages), - }); - - let filterByText = ''; - let orderBy = ''; - if (params.text) { - innerProjection += ' ?score'; - if (this.settings.fullTextSearch.extractLabel) { - filterByText += sparqlExtractLabel('?inst', '?extractedLabel'); - } - filterByText = resolveTemplate(fullTextSearch.queryPattern, {text: params.text, dataLabelProperty}); - if (filterByText) { - orderBy = 'ORDER BY DESC(?score)'; - } - } - - let limitPart = ''; - if (typeof params.limit === 'number') { - limitPart = `LIMIT ${params.limit}`; - } - - const completePrefix = defaultPrefix + '\n' + fullTextSearch.prefix + '\n'; - return completePrefix + resolveTemplate(lookupQuery, { - innerProjection, - outerProjection, - filterInnerPrelude: this.settings.filterInnerPrelude, - filterByType, - filterByRefElementLink, - filterByText, - filterAdditionalRestriction: this.settings.filterAdditionalRestriction, - orderBy, - limit: limitPart, - queryTypes, - queryElementInfo, - }); - } - /** * Executes arbitrary SPARQL SELECT query and returns the result tuples. */ @@ -876,148 +842,6 @@ export class SparqlDataProvider implements DataProvider { }); } - protected createRefQueryPart(params: { elementId: ElementIri; linkId?: LinkTypeIri; direction?: 'in' | 'out' }) { - const {elementId, linkId, direction} = params; - - const {unionParts, usePredicatePart} = this.formatLinkUnion( - elementId, linkId, direction, '?inst', '?inst', true - ); - - if (usePredicatePart) { - const refElementIRI = escapeIri(params.elementId); - let refLinkType: string | undefined; - if (linkId) { - const link = this.linkById.get(linkId); - refLinkType = link && isDirectLink(link) ? escapeIri(link.path) : escapeIri(linkId); - } - - const linkPattern = refLinkType || '?link'; - const bindType = refLinkType ? `BIND(${refLinkType} as ?link)` : ''; - // Specifically use isBlank() as opposed to isIri() when possible for filtering - // to avoid a full scan on some endpoints (e.g. Virtuoso) - const outFilter = this.acceptBlankNodes - ? 'FILTER(isIri(?inst) || isBlank(?inst))' - : 'FILTER(isIri(?inst))'; - const inFilter = this.acceptBlankNodes - ? '' - : 'FILTER(!isBlank(?inst))'; - - if (!direction || direction === 'out') { - unionParts.push(`{ ${refElementIRI} ${linkPattern} ?inst BIND("out" as ?direction) ${bindType} ${outFilter} }`); - } - if (!direction || direction === 'in') { - unionParts.push(`{ ?inst ${linkPattern} ${refElementIRI} BIND("in" as ?direction) ${bindType} ${inFilter} }`); - } - } - - let resultPattern = unionParts.length === 0 ? 'FILTER(false)' : unionParts.join('\nUNION\n'); - - const useAllLinksPattern = !linkId && this.settings.filterRefElementLinkPattern.length > 0; - if (useAllLinksPattern) { - resultPattern += `\n${this.settings.filterRefElementLinkPattern}`; - } - - return resultPattern; - } - - private formatLinkUnion( - refElementIri: ElementIri, - linkIri: LinkTypeIri | undefined, - direction: 'in' | 'out' | undefined, - outElementVar: string, - inElementVar: string, - bindDirection: boolean - ) { - const {linkConfigurations} = this.settings; - const fixedIri = escapeIri(refElementIri); - - const unionParts: string[] = []; - let hasDirectLink = false; - - for (const link of linkConfigurations) { - if (linkIri && link.id !== linkIri) { continue; } - if (isDirectLink(link)) { - hasDirectLink = true; - } else { - const linkType = escapeIri(link.id); - if (!direction || direction === 'out') { - const path = this.formatLinkPath(link.path, fixedIri, outElementVar); - const boundedDirection = bindDirection ? 'BIND("out" as ?direction) ' : ''; - unionParts.push( - `{ ${path} BIND(${linkType} as ?link) ${boundedDirection}}` - ); - } - if (!direction || direction === 'in') { - const path = this.formatLinkPath(link.path, inElementVar, fixedIri); - const boundedDirection = bindDirection ? 'BIND("in" as ?direction) ' : ''; - unionParts.push( - `{ ${path} BIND(${linkType} as ?link) ${boundedDirection}}` - ); - } - } - } - - const usePredicatePart = this.openWorldLinks || hasDirectLink; - return {unionParts, usePredicatePart}; - } - - private formatLinkLinks(): string { - const unionParts: string[] = []; - let hasDirectLink = false; - for (const link of this.settings.linkConfigurations) { - if (isDirectLink(link)) { - hasDirectLink = true; - } else { - const linkType = escapeIri(link.id); - unionParts.push( - `${this.formatLinkPath(link.path, '?source', '?target')} BIND(${linkType} as ?type)` - ); - } - } - - const usePredicatePart = this.openWorldLinks || hasDirectLink; - if (usePredicatePart) { - unionParts.push('?source ?type ?target'); - } - - return ( - unionParts.length === 0 ? '' : - unionParts.length === 1 ? unionParts[0] : - '{ ' + unionParts.join(' }\nUNION\n{ ') + ' }' - ); - } - - private formatLinkPath(path: string, source: string, target: string): string { - return path.replace(/[?$]source\b/g, source).replace(/[?$]target\b/g, target); - } - - private formatPropertyInfo(): string { - const unionParts: string[] = []; - let hasDirectProperty = false; - for (const property of this.settings.propertyConfigurations) { - if (isDirectProperty(property)) { - hasDirectProperty = true; - } else { - const propType = escapeIri(property.id); - const formatted = this.formatPropertyPath(property.path, '?inst', '?propValue'); - unionParts.push( - `{ ${formatted} BIND(${propType} as ?propType) }` - ); - } - } - - const usePredicatePart = this.openWorldProperties || hasDirectProperty; - if (usePredicatePart) { - unionParts.push('{ ?inst ?propType ?propValue }'); - } - - return unionParts.join('\nUNION\n'); - } - - private formatPropertyPath(path: string, subject: string, value: string): string { - return path.replace(/[?$]inst\b/g, subject).replace(/[?$]value\b/g, value); - } - private async querySingleElementTypes( element: ElementIri | undefined, signal: AbortSignal | undefined @@ -1125,52 +949,3 @@ function prepareElementImages( } }); } - -function resolveTemplate(template: string, values: { [key: string]: string | undefined }) { - let result = template; - for (const replaceKey in values) { - if (!Object.prototype.hasOwnProperty.call(values, replaceKey)) { continue; } - const replaceValue = values[replaceKey] || ''; - result = result.replace(new RegExp('\\${' + replaceKey + '}', 'g'), replaceValue); - } - return result; -} - -function formatLanguageFilter( - variable: string, - languages: ReadonlyArray | undefined -): string { - if (!languages) { - return ''; - } - const parts = [`FILTER(!isLiteral(${variable}) || lang(${variable}) = ""`]; - for (const language of languages) { - parts.push(` || lang(${variable}) = "${language}"`); - } - parts.push(')'); - return parts.join(''); -} - -function sparqlExtractLabel(subject: string, label: string): string { - return ` - BIND ( str( ${subject} ) as ?uriStr) - BIND ( strafter(?uriStr, "#") as ?label3) - BIND ( strafter(strafter(?uriStr, "//"), "/") as ?label6) - BIND ( strafter(?label6, "/") as ?label5) - BIND ( strafter(?label5, "/") as ?label4) - BIND (if (?label3 != "", ?label3, - if (?label4 != "", ?label4, - if (?label5 != "", ?label5, ?label6))) as ${label}) - `; -} - -function formatValueClauseContent(iris: Iterable): string { - return Array.from(iris, iri => `(${escapeIri(iri)})`).join(' '); -} - -function escapeIri(iri: string) { - if (typeof iri !== 'string') { - throw new Error(`Cannot escape IRI of type "${typeof iri}"`); - } - return `<${iri}>`; -} diff --git a/src/data/sparql/sparqlDataProviderSettings.ts b/src/data/sparql/sparqlDataProviderSettings.ts index c4be0a66..999dd6a7 100644 --- a/src/data/sparql/sparqlDataProviderSettings.ts +++ b/src/data/sparql/sparqlDataProviderSettings.ts @@ -25,10 +25,11 @@ export interface SparqlDataProviderSettings { filterOnlyLanguages?: ReadonlyArray; /** - * SELECT query to retrieve class tree. + * SELECT query to get full element type (class) tree. * * Parametrized variables: - * - `${schemaLabelProperty}` `schemaLabelProperty` property from the settings + * - `${schemaLabelProperty}` {@link schemaLabelProperty} property from the settings + * - `${labelLanguageFilter}` label filter based on `filterOnlyLanguages` * * Expected output bindings: * - `?class` @@ -38,6 +39,30 @@ export interface SparqlDataProviderSettings { */ classTreeQuery?: string; + /** + * SELECT query to lookup element types (classes) filtered by a keyword. + * + * Parametrized variables: + * - `${schemaLabelProperty}` {@link schemaLabelProperty} property from the settings + * - `${labelLanguageFilter}` label filter based on `filterOnlyLanguages` + * - `${filterByText}` from {@link classTreeLookupSettings} + * - `${limit}` (when `limit` is provided) + * + * Expected output bindings: + * - `?class` + * - `?label` (optional) + * - `?score` element lookup score (higher ordered first) + * (for order only; when `text` is provided) + */ + classLookupQuery?: string; + + /** + * Settings for element types lookup by a text keyword. + * + * If not specified, {@link schemaLookupSettings} is used by default. + */ + classLookupSettings?: FullTextSearchSettings; + /** * SELECT query to retrieve data for each class in a set. * @@ -53,6 +78,42 @@ export interface SparqlDataProviderSettings { */ classInfoQuery?: string; + /** + * SELECT query to lookup property types filtered by a keyword. + * + * Parametrized variables: + * - `${schemaLabelProperty}` {@link schemaLabelProperty} property from the settings + * - `${labelLanguageFilter}` label filter based on `filterOnlyLanguages` + * - `${filterByText}` from {@link propertyTypesLookupSettings} + * - `${limit}` (when `limit` is provided) + * + * Expected output bindings: + * - `?property` + * - `?label` (optional) + */ + propertyTypesLookupQuery?: string; + + /** + * Settings for property types lookup by a text keyword. + * + * If not specified, {@link schemaLookupSettings} is used by default. + */ + propertyTypesLookupSettings?: FullTextSearchSettings; + + /** + * SELECT query to retrieve data for each datatype property in a set. + * + * Parametrized variables: + * - `${ids}` VALUES clause content with datatype property IRIs + * - `${schemaLabelProperty}` `schemaLabelProperty` property from the settings + * - `${labelLanguageFilter}` label filter based on `filterOnlyLanguages` + * + * Expected output bindings: + * - `?property` + * - `?label` (optional) + */ + propertyInfoQuery?: string; + /** * SELECT query to retrieve initial link types. * @@ -76,33 +137,41 @@ export interface SparqlDataProviderSettings { linkTypesPattern?: string; /** - * SELECT query to retrieve data for each link type in a set. + * SELECT query to lookup link types filtered by a keyword. * * Parametrized variables: - * - `${ids}` VALUES clause content with link type IRIs - * - `${schemaLabelProperty}` `schemaLabelProperty` property from the settings + * - `${schemaLabelProperty}` {@link schemaLabelProperty} property from the settings * - `${labelLanguageFilter}` label filter based on `filterOnlyLanguages` + * - `${filterByText}` from {@link linkTypesLookupSettings} + * - `${limit}` (when `limit` is provided) * * Expected output bindings: * - `?link` * - `?label` (optional) - * - `?instcount` (optional) */ - linkTypesInfoQuery?: string; + linkTypesLookupQuery?: string; /** - * SELECT query to retrieve data for each datatype property in a set. + * Settings for link types lookup by a text keyword. + * + * If not specified, {@link schemaLookupSettings} is used by default. + */ + linkTypesLookupSettings?: FullTextSearchSettings; + + /** + * SELECT query to retrieve data for each link type in a set. * * Parametrized variables: - * - `${ids}` VALUES clause content with datatype property IRIs + * - `${ids}` VALUES clause content with link type IRIs * - `${schemaLabelProperty}` `schemaLabelProperty` property from the settings * - `${labelLanguageFilter}` label filter based on `filterOnlyLanguages` * * Expected output bindings: - * - `?property` + * - `?link` * - `?label` (optional) + * - `?instcount` (optional) */ - propertyInfoQuery?: string; + linkTypesInfoQuery?: string; /** * CONSTRUCT query to retrieve data for each element (types, labels, properties). @@ -196,12 +265,7 @@ export interface SparqlDataProviderSettings { * - `${outerProjection}` * - `${innerProjection}` * - `${filterInnerPrelude}` from {@link filterInnerPrelude} - * - `${filterByType}` from {@link filterTypePattern} - * (when {@link DataProviderLookupParams.elementTypeId} is provided) - * - `${filterByRefElementLink}` from {@link filterRefElementLinkPattern} - * (when {@link DataProviderLookupParams.refElementId} is provided) - * - `${filterByText}` from {@link fullTextSearch} - * (when {@link DataProviderLookupParams.text} is provided) + * - `${filterByPattern}` generated from {@link DataProviderLookupParams.pattern} * - `${filterAdditionalRestriction}` from {@link filterAdditionalRestriction} * - `${orderBy}` (when {@link DataProviderLookupParams.text} is provided) * - `${limit}` (when {@link DataProviderLookupParams.limit} is provided) @@ -210,16 +274,25 @@ export interface SparqlDataProviderSettings { * {@link linkConfigurations} is non-empty) * - `${queryElementInfo}` from {@link filterElementInfoPattern} * + * If `${filterByPattern}` not found in the query, the generated pattern + * is prepended to `${filterAdditionalRestriction}` instead. + * + * For compatibility, the following deprecated variables are replaced by + * an empty pattern: + * - `${filterByType}` + * - `${filterByRefElementLink}` + * - `${filterByText}` + * * Expected bindings: * - `?inst` element IRI * - `?class` element type * - `?label` element label * - `?link` connected via link type - * (when {@link DataProviderLookupParams.refElementId} is provided) + * (when {@link ConnectedToLookupPattern} is provided at top level) * - `?direction` connected via link direction (`"in"` or `"out"`) - * (when {@link DataProviderLookupParams.refElementId} is provided) + * (when {@link ConnectedToLookupPattern} is provided at top level) * - `?score` element lookup score (higher ordered first) - * (for order only; when {@link DataProviderLookupParams.text} is provided) + * (for order only; when {@link WithTextLookupPattern} is provided at top level) */ lookupQuery: string; @@ -269,10 +342,15 @@ export interface SparqlDataProviderSettings { filterAdditionalRestriction: string; /** - * Lookup by text settings. + * Lookup entities by text settings. */ fullTextSearch: FullTextSearchSettings; + /** + * Default settings for schema items (element/property/link types) lookup by text. + */ + schemaTextSearch?: FullTextSearchSettings; + /** * "Virtual" links configurations to translate a SPARQL pattern as a link. */ @@ -317,9 +395,10 @@ export interface FullTextSearchSettings { * Parametrized variables: * - `${text}` text token * - `${dataLabelProperty}` `dataLabelProperty` property from the settings + * - `${schemaLabelProperty}` `schemaLabelProperty` property from the settings * * Expected bindings: - * - `?inst` link type + * - `?inst` found item IRI * - `?score` numerical score for ordering search results by relevance * - `?extractedLabel` (optional; if `extractLabel` is enabled) */ @@ -442,6 +521,8 @@ export const RdfSettings: SparqlDataProviderSettings = { classTreeQuery: '', + classLookupQuery: '', + classInfoQuery: `SELECT ?class ?label ?instcount WHERE { VALUES(?class) {\${ids}} @@ -452,6 +533,17 @@ export const RdfSettings: SparqlDataProviderSettings = { BIND("" as ?instcount) }`, + propertyTypesLookupQuery: '', + + propertyInfoQuery: +`SELECT ?property ?label WHERE { + VALUES(?property) {\${ids}} + OPTIONAL { + ?property \${schemaLabelProperty} ?label + \${labelLanguageFilter} + } +}`, + linkTypesQuery: `SELECT DISTINCT ?link ?instcount ?label WHERE { \${linkTypesPattern} @@ -463,6 +555,8 @@ export const RdfSettings: SparqlDataProviderSettings = { linkTypesPattern: '', + linkTypesLookupQuery: '', + linkTypesInfoQuery: `SELECT ?link ?label WHERE { VALUES(?link) {\${ids}} @@ -472,15 +566,6 @@ export const RdfSettings: SparqlDataProviderSettings = { } }`, - propertyInfoQuery: -`SELECT ?property ?label WHERE { - VALUES(?property) {\${ids}} - OPTIONAL { - ?property \${schemaLabelProperty} ?label - \${labelLanguageFilter} - } -}`, - elementInfoQuery: '', imageQueryPattern: '', @@ -492,9 +577,7 @@ export const RdfSettings: SparqlDataProviderSettings = { \${filterInnerPrelude} { SELECT DISTINCT \${innerProjection} WHERE { - \${filterByType} - \${filterByRefElementLink} - \${filterByText} + \${filterByPattern} \${filterAdditionalRestriction} } \${orderBy} \${limit} } @@ -508,6 +591,45 @@ export const RdfSettings: SparqlDataProviderSettings = { filterElementInfoPattern: '', }; +// Wikidata does not distinguish property/link types +const WikidataPropertyLookup: FullTextSearchSettings = { + prefix: 'PREFIX bds: \n', + queryPattern: ` + SERVICE wikibase:mwapi { + bd:serviceParam wikibase:endpoint "www.wikidata.org"; + wikibase:api "EntitySearch"; + mwapi:search "\${text}"; + mwapi:language "en"; + mwapi:type "property". + ?inst wikibase:apiOutputItem mwapi:item. + ?num wikibase:apiOrdinal true. + } + BIND(IF( + STRLEN(STR(?inst)) > 33, + 0-(SUBSTR(STR(?inst), 33)), + -10000 + ) as ?score) + `, +}; +const WikidataEntityLookup: FullTextSearchSettings = { + prefix: 'PREFIX bds: \n', + queryPattern: ` + SERVICE wikibase:mwapi { + bd:serviceParam wikibase:endpoint "www.wikidata.org"; + wikibase:api "EntitySearch"; + mwapi:search "\${text}"; + mwapi:language "en". + ?inst wikibase:apiOutputItem mwapi:item. + ?num wikibase:apiOrdinal true. + } + BIND(IF( + STRLEN(STR(?inst)) > 33, + 0-(SUBSTR(STR(?inst), 33)), + -10000 + ) as ?score) + `, +}; + const WikidataSettingsOverride: Partial = { defaultPrefix: `PREFIX rdfs: @@ -534,11 +656,39 @@ const WikidataSettingsOverride: Partial = { } `, + classLookupQuery: ` + SELECT ?class ?label WHERE { + \${filterByText} + ?class (wdt:P31 / wdt:P31) wd:Q137429456 . + ?class \${schemaLabelProperty} ?label. + \${labelLanguageFilter} + } ORDER BY ?score \${limit} + `, + classLookupSettings: WikidataEntityLookup, + + propertyTypesLookupQuery: ` + SELECT ?property ?label WHERE { + \${filterByText} + ?property \${schemaLabelProperty} ?label. + \${labelLanguageFilter} + } ORDER BY ?score \${limit} + `, + propertyTypesLookupSettings: WikidataPropertyLookup, + linkTypesQuery: '', linkTypesPattern: ` ?link wdt:P279* wd:Q18616576. BIND(0 as ?instcount) `, + + linkTypesLookupQuery: ` + SELECT ?link ?label WHERE { + \${filterByText} + ?link \${schemaLabelProperty} ?label. + \${labelLanguageFilter} + } ORDER BY ?score \${limit} + `, + linkTypesLookupSettings: WikidataPropertyLookup, linkTypesInfoQuery: `SELECT ?link ?label WHERE { @@ -629,24 +779,7 @@ const WikidataSettingsOverride: Partial = { \${labelLanguageFilter} } `, - fullTextSearch: { - prefix: 'PREFIX bds: \n', - queryPattern: ` - SERVICE wikibase:mwapi { - bd:serviceParam wikibase:endpoint "www.wikidata.org"; - wikibase:api "EntitySearch"; - mwapi:search "\${text}"; - mwapi:language "en". - ?inst wikibase:apiOutputItem mwapi:item. - ?num wikibase:apiOrdinal true. - } - BIND(IF( - STRLEN(STR(?inst)) > 33, - 0-(SUBSTR(STR(?inst), 33)), - -10000 - ) as ?score) - `, - }, + fullTextSearch: WikidataEntityLookup, }; /** @@ -671,17 +804,21 @@ const OwlRdfsSettingsOverride: Partial = { `?inst \${dataLabelProperty} ?search1 FILTER regex(COALESCE(str(?search1)), "\${text}", "i") BIND(if(bound(?search1), 0, 1) as ?score) -`, - extractLabel: true, + `, + }, + schemaTextSearch: { + prefix: '', + queryPattern: + `?inst \${schemaLabelProperty} ?search1 + FILTER regex(COALESCE(str(?search1)), "\${text}", "i") + BIND(if(bound(?search1), 0, 1) as ?score) + `, }, classTreeQuery: ` - SELECT ?class ?label ?parent - WHERE { - { - ?class a rdfs:Class - } UNION { - ?class a owl:Class - } + SELECT ?class ?label ?parent WHERE { + { ?class a rdfs:Class } + UNION + { ?class a owl:Class } FILTER ISIRI(?class) OPTIONAL { ?class rdfs:label ?label @@ -690,14 +827,45 @@ const OwlRdfsSettingsOverride: Partial = { OPTIONAL {?class rdfs:subClassOf ?parent. FILTER ISIRI(?parent)} } `, - - // todo: think more, maybe add a limit here? + classLookupQuery: ` + SELECT ?class ?label WHERE { + { ?class a rdfs:Class } + UNION + { ?class a owl:Class } + FILTER ISIRI(?class) + \${filterByText} + ?class \${schemaLabelProperty} ?label. + \${labelLanguageFilter} + } ORDER BY ?score \${limit} + `, + propertyTypesLookupQuery: ` + SELECT ?property ?label WHERE { + { ?property a rdf:Property } + UNION + { ?property a owl:DatatypeProperty } + FILTER NOT EXISTS { ?property a owl:ObjectProperty } + \${filterByText} + ?property \${schemaLabelProperty} ?label. + \${labelLanguageFilter} + } ORDER BY ?score \${limit} + `, linkTypesPattern: ` { ?link a rdf:Property } UNION { ?link a owl:ObjectProperty } BIND('' as ?instcount) `, + linkTypesLookupQuery: ` + SELECT ?link ?label WHERE { + { ?link a rdf:Property } + UNION + { ?link a owl:ObjectProperty } + FILTER NOT EXISTS { ?link a owl:DatatypeProperty } + \${filterByText} + ?link \${schemaLabelProperty} ?label. + \${labelLanguageFilter} + } ORDER BY ?score \${limit} + `, elementInfoQuery: ` CONSTRUCT { ?inst ?class . diff --git a/src/data/sparql/sparqlGenerator.ts b/src/data/sparql/sparqlGenerator.ts new file mode 100644 index 00000000..aaf69f61 --- /dev/null +++ b/src/data/sparql/sparqlGenerator.ts @@ -0,0 +1,806 @@ +import { HashSet } from '@reactodia/hashmap'; + +import { multimapArrayAdd } from '../../coreUtils/collections'; + +import * as Rdf from '../rdf/rdfModel'; + +import { LinkTypeIri, PropertyTypeIri } from '../model'; +import { + DataProviderLookupParams, LookupPattern, LookupPatternHasType, LookupPatternConnectedTo, + LookupPatternHasProperty, LookupPatternWithText, lookupPatternOf, +} from '../dataProvider'; + +import { + SparqlDataProviderSettings, FullTextSearchSettings, LinkConfiguration, PropertyConfiguration, +} from './sparqlDataProviderSettings'; + +interface SparqlGeneratorOptions { + readonly acceptBlankNodes: boolean; +} + +export class SparqlGenerator { + private readonly acceptBlankNodes: boolean; + + readonly linkByPredicate = new Map(); + private linkById = new Map(); + readonly openWorldLinks: boolean; + + readonly propertyByPredicate = new Map(); + readonly openWorldProperties: boolean; + + constructor( + private readonly factory: Rdf.DataFactory, + private readonly settings: SparqlDataProviderSettings, + options: SparqlGeneratorOptions + ) { + const {acceptBlankNodes} = options; + this.acceptBlankNodes = acceptBlankNodes; + + for (const link of settings.linkConfigurations) { + this.linkById.set(link.id, link); + const predicate = isDirectLink(link) ? link.path : link.id; + multimapArrayAdd(this.linkByPredicate, predicate, link); + } + this.openWorldLinks = settings.linkConfigurations.length === 0 || + Boolean(settings.openWorldLinks); + + for (const property of settings.propertyConfigurations) { + const predicate = isDirectProperty(property) ? property.path : property.id; + multimapArrayAdd(this.propertyByPredicate, predicate, property); + } + this.openWorldProperties = settings.propertyConfigurations.length === 0 || + Boolean(settings.openWorldProperties); + } + + formatLanguageFilter(variable: string): string { + return formatLanguageFilter(variable, this.settings.filterOnlyLanguages); + } + + generateLookupSchemaQuery(params: { + lookupQuery: string; + textSearchSettings: FullTextSearchSettings; + itemTerm: Rdf.Variable; + text: string; + limit?: number; + }): string { + const {lookupQuery, textSearchSettings, itemTerm, text, limit} = params; + const {defaultPrefix, dataLabelProperty, schemaLabelProperty} = this.settings; + + let filterByText = ''; + if (textSearchSettings.extractLabel) { + filterByText += sparqlExtractLabel(escapeTerm(itemTerm), '?extractedLabel'); + } + filterByText += resolveTemplate(textSearchSettings.queryPattern, { + text: text, + dataLabelProperty, + schemaLabelProperty, + }); + const prefix = defaultPrefix + textSearchSettings.prefix; + const query = prefix + resolveTemplate(lookupQuery, { + schemaLabelProperty, + labelLanguageFilter: this.formatLanguageFilter('?label'), + filterByText: filterByText.replace(/[?$]inst\b/g, escapeTerm(itemTerm)), + limit: formatLimit(limit), + }); + return query; + } + + generateLinkTypesOfQuery(params: { + elementTerm: Rdf.NamedNode; + targetIn: Rdf.Variable; + targetOut: Rdf.Variable; + }): { query: string; bindDirection: boolean } { + const {elementTerm, targetIn, targetOut} = params; + const {defaultPrefix, linkTypesOfQuery} = this.settings; + + const bindDirection = /\?direction\b/.test(linkTypesOfQuery); + + const forAll = this.formatLinkUnion({ + sourceTerm: elementTerm, + targetIn: targetOut, + targetOut: targetIn, + withProjection: true, + }); + if (forAll.usePredicatePart) { + const bindOut = bindDirection ? 'BIND("out" AS ?direction)' : ''; + const bindIn = bindDirection ? 'BIND("in" AS ?direction)' : ''; + forAll.unionParts.push( + `{ ${escapeTerm(elementTerm)} ?link ${escapeTerm(targetOut)} ${bindOut} }` + ); + forAll.unionParts.push( + `{ ${escapeTerm(targetIn)} ?link ${escapeTerm(elementTerm)} ${bindIn} }` + ); + } + + const query = defaultPrefix + resolveTemplate(linkTypesOfQuery, { + elementIri: escapeTerm(elementTerm), + linkConfigurations: forAll.unionParts.join('\nUNION\n'), + }); + return {query, bindDirection}; + } + + generateLinkStatsQuery(params: { + elementTerm: Rdf.NamedNode; + targetIn: Rdf.Variable; + targetOut: Rdf.Variable; + linkType: LinkTypeIri; + navigateElementFilterOut: string; + navigateElementFilterIn: string; + }): string { + const { + elementTerm, targetIn, targetOut, linkType, + navigateElementFilterOut, navigateElementFilterIn, + } = params; + const {defaultPrefix, filterTypePattern, linkTypesStatisticsQuery} = this.settings; + + const linkConfig = this.linkById.get(linkType); + let linkConfigurationOut: string; + let linkConfigurationIn: string; + + if (!linkConfig || isDirectLink(linkConfig)) { + const predicate = escapeIri( + linkConfig && isDirectLink(linkConfig) ? linkConfig.path : linkType + ); + linkConfigurationOut = `${escapeTerm(elementTerm)} ${predicate} ${escapeTerm(targetOut)}`; + linkConfigurationIn = `${escapeTerm(targetIn)} ${predicate} ${escapeTerm(elementTerm)}`; + } else { + linkConfigurationOut = this.formatLinkPath(linkConfig.path, elementTerm, targetOut); + linkConfigurationIn = this.formatLinkPath(linkConfig.path, targetIn, elementTerm); + } + + if (linkConfig && linkConfig.domain && linkConfig.domain.length > 0) { + const commaSeparatedDomains = linkConfig.domain.map(escapeIri).join(', '); + const restrictionOut = filterTypePattern.replace(/[?$]inst\b/g, escapeTerm(elementTerm)); + const restrictionIn = filterTypePattern.replace(/[?$]inst\b/g, escapeTerm(targetIn)); + linkConfigurationOut += ` { ${restrictionOut} FILTER(?class IN (${commaSeparatedDomains})) }`; + linkConfigurationIn += ` { ${restrictionIn} FILTER(?class IN (${commaSeparatedDomains})) }`; + } + + const query = defaultPrefix + resolveTemplate(linkTypesStatisticsQuery, { + linkId: escapeIri(linkType), + elementIri: escapeTerm(elementTerm), + linkConfigurationOut, + linkConfigurationIn, + navigateElementFilterOut, + navigateElementFilterIn, + }); + return query; + } + + formatLinkStatsNavigateFilters() { + const navigateElementFilterOut = this.acceptBlankNodes + ? 'FILTER (IsIri(?outObject) || IsBlank(?outObject))' + : 'FILTER IsIri(?outObject)'; + // Specifically use isBlank() as opposed to isIri() for filtering to avoid + // a full scan on some endpoints (e.g. Virtuoso) + const navigateElementFilterIn = this.acceptBlankNodes + ? '' + : 'FILTER(!isBlank(?inObject))'; + return {navigateElementFilterIn, navigateElementFilterOut}; + } + + supportsLookupPatterns(): boolean { + const {lookupQuery} = this.settings; + return /\${filterByPattern}/.test(lookupQuery); + } + + generateLookupQuery(params: DataProviderLookupParams): string { + const { + defaultPrefix, dataLabelProperty, lookupQuery, filterAdditionalRestriction, + filterOnlyLanguages, filterElementInfoPattern, fullTextSearch, + } = this.settings; + + let outerProjection = '?inst ?class ?label'; + let innerProjection = '?inst'; + + const entityTerm = this.factory.variable!('inst'); + const pattern = lookupPatternOf(params); + + const valueSets = new Map>(); + const projections = new Set<'link' | 'direction' | 'score'>(); + + const generateParams = { + entityTerm, + path: '', + valueSets, + projections, + withProjection: true, + } as const; + + const generatedPattern = this.generatePattern(pattern, generateParams); + const hasFilterByPattern = /\${filterByPattern}/.test(lookupQuery); + + let prependedValueSets = ''; + for (const [variableName, setTerms] of valueSets) { + const valueSetContent = formatValueClauseContent(setTerms); + prependedValueSets += `VALUES(?${variableName}) { ${valueSetContent} }\n`; + } + + let queryTypes = ''; + if (projections.has('link')) { + outerProjection += ' ?link'; + innerProjection += ' ?link'; + if (this.settings.linkConfigurations.length > 0) { + outerProjection += ' ?classAll'; + queryTypes = this.settings.filterTypePattern.replace(/[?$]class\b/g, '?classAll'); + } + } + + if (projections.has('direction')) { + outerProjection += ' ?direction'; + innerProjection += ' ?direction'; + } + + let orderBy = ''; + if (projections.has('score')) { + innerProjection += ' ?score'; + orderBy = 'ORDER BY DESC(?score)'; + } + + const queryElementInfo = resolveTemplate(filterElementInfoPattern, { + dataLabelProperty, + labelLanguageFilter: formatLanguageFilter('?label', filterOnlyLanguages), + }); + + const limitPart = formatLimit(params.limit); + + const filterByPattern = prependedValueSets + generatedPattern; + + const completePrefix = defaultPrefix + '\n' + fullTextSearch.prefix + '\n'; + return completePrefix + resolveTemplate(lookupQuery, { + innerProjection, + outerProjection, + filterInnerPrelude: this.settings.filterInnerPrelude, + filterByType: '', + filterByRefElementLink: '', + filterByText: '', + filterByPattern, + filterAdditionalRestriction: hasFilterByPattern + ? filterAdditionalRestriction + : `${filterByPattern}\n${filterAdditionalRestriction}`, + orderBy, + limit: limitPart, + queryTypes, + queryElementInfo, + }); + } + + private generatePattern( + pattern: LookupPattern, + params: { + path: string; + entityTerm: Rdf.Variable; + valueSets: Map>; + projections: Set<'link' | 'direction' | 'score'>; + withProjection?: boolean; + } + ): string { + const {path, entityTerm, valueSets, projections, withProjection} = params; + switch (pattern.type) { + case 'allOf': + case 'anyOf': { + if (pattern.patterns.length === 0) { + return pattern.type === 'allOf' ? 'FILTER(false)' : ''; + } + const allValues = new Map>(); + const parts = pattern.patterns.map((branch, index) => { + const branchValues = new Map>(); + const generatedBranch = this.generatePattern(branch, { + path: `${path}_${index}`, + entityTerm, + valueSets: branchValues, + projections, + withProjection, + }); + for (const [setName, setValues] of branchValues) { + const existing = allValues.get(setName); + if (existing) { + if (pattern.type === 'allOf') { + keepOnlyIntersection(existing, setValues); + + } else { + // Union with existing + for (const item of setValues) { + existing.add(item); + } + } + } else { + allValues.set(setName, setValues); + } + } + return generatedBranch; + }); + + for (const [setName, setValues] of allValues) { + if (valueSets.has(setName)) { + throw new Error('Reactodia: nested patterns cannot shadow value sets'); + } + valueSets.set(setName, setValues); + } + + return '{\n' + parts.join(pattern.type === 'allOf' ? '\n' : '}\nUNION\n{') + '\n}'; + } + case 'hasType': { + const part = replaceVariables(this.settings.filterTypePattern, varName => { + if (varName === 'inst') { + return entityTerm; + } else if (varName === 'class') { + return this.factory.namedNode(pattern.elementType); + } else { + return this.factory.variable!(`${varName}${path}`); + } + }); + return part; + } + case 'connectedTo': { + const parts: string[] = []; + const targetTerm = + pattern.other?.type === 'fromValues' && pattern.other.values.length === 1 + ? this.factory.namedNode(pattern.other.values[0]) + : this.factory.variable!(`connectedTo_other${path}`); + parts.push( + this.generateConnectedToPattern(pattern, { + sourceTerm: entityTerm, + targetTerm, + predicateTerm: withProjection + ? this.factory.variable!('link') + : this.factory.variable!(`connectedTo_link${path}`), + withProjection, + }) + ); + if (withProjection) { + projections.add('link'); + projections.add('direction'); + } + if (pattern.other && targetTerm.termType === 'Variable') { + parts.push( + this.generatePattern(pattern.other, { + path: `${path}_other`, + entityTerm: targetTerm, + valueSets, + projections, + }) + ); + } + return parts.join('\n'); + } + case 'fromValues': { + const valueSet = new HashSet( + Rdf.hashTerm, + Rdf.equalTerms, + pattern.values.map(v => this.factory.namedNode(v)) + ); + valueSets.set(entityTerm.value, valueSet); + return ''; + } + case 'hasProperty': { + const parts: string[] = []; + const valueTerm = this.factory.variable!(`hasProperty_value${path}`); + parts.push( + this.generateHasPropertyPattern(pattern, { + entityTerm, + valueTerm, + predicateTerm: this.factory.variable!(`connectedTo_propType${path}`), + }) + ); + if (pattern.values && pattern.values.length > 0) { + const valueSet = new HashSet(Rdf.hashTerm, Rdf.equalTerms, pattern.values); + valueSets.set(valueTerm.value, valueSet); + } + return parts.join('\n'); + } + case 'withText': { + const scoreTerm = withProjection + ? this.factory.variable!('score') + : this.factory.variable!(`withText_score${path}`); + if (withProjection) { + projections.add('score'); + } + return this.generateWithTextPattern(pattern, { + entityTerm, + labelTerm: this.factory.variable!(`withText_label${path}`), + scoreTerm, + }); + } + default: { + throw new Error( + `Reactodia: unknown lookup pattern type "${(pattern as LookupPattern).type}"` + ); + } + } + } + + private generateConnectedToPattern( + pattern: LookupPatternConnectedTo, + params: { + sourceTerm: Rdf.Variable; + targetTerm: Rdf.Variable | Rdf.NamedNode; + predicateTerm: Rdf.Variable; + withProjection?: boolean; + } + ): string { + const {sourceTerm, targetTerm, predicateTerm, withProjection} = params; + + const {linkType, linkDirection} = pattern; + const {unionParts, usePredicatePart} = this.formatLinkUnion({ + sourceTerm: targetTerm, + targetIn: sourceTerm, + targetOut: sourceTerm, + linkType, + linkDirection, + withProjection + }); + + if (usePredicatePart) { + const source = escapeTerm(sourceTerm); + const target = escapeTerm(targetTerm); + let refLinkType: string | undefined; + if (linkType) { + const link = this.linkById.get(linkType); + refLinkType = link && isDirectLink(link) + ? escapeIri(link.path) + : escapeIri(linkType); + } + + const linkPattern = refLinkType || escapeTerm(predicateTerm); + const bindType = refLinkType ? `BIND(${refLinkType} as ${escapeTerm(predicateTerm)})` : ''; + // Specifically use isBlank() as opposed to isIri() when possible for filtering + // to avoid a full scan on some endpoints (e.g. Virtuoso) + const outFilter = this.acceptBlankNodes + ? `FILTER(isIri(${source}) || isBlank(${source}))` + : `FILTER(isIri(${source}))`; + const inFilter = this.acceptBlankNodes + ? '' + : `FILTER(!isBlank(${source}))`; + + if (!linkDirection || linkDirection === 'out') { + const binds = withProjection ? `BIND("out" as ?direction) ${bindType}` : ''; + unionParts.push(`{ ${target} ${linkPattern} ${source} ${binds} ${outFilter} }`); + } + if (!linkDirection || linkDirection === 'in') { + const binds = withProjection ? `BIND("in" as ?direction) ${bindType}` : ''; + unionParts.push(`{ ${source} ${linkPattern} ${target} ${binds} ${inFilter} }`); + } + } + + let resultPattern = unionParts.length === 0 ? 'FILTER(false)' : unionParts.join('\nUNION\n'); + + const useAllLinksPattern = !linkType && this.settings.filterRefElementLinkPattern.length > 0; + if (useAllLinksPattern) { + resultPattern += `\n${this.settings.filterRefElementLinkPattern}`; + } + + return resultPattern; + } + + private formatLinkUnion(params: { + sourceTerm: Rdf.Variable | Rdf.NamedNode; + targetIn: Rdf.Variable; + targetOut: Rdf.Variable; + linkType?: LinkTypeIri; + linkDirection?: 'in' | 'out'; + withProjection?: boolean; + }) { + const {sourceTerm, targetIn, targetOut, linkType, linkDirection, withProjection} = params; + const {linkConfigurations} = this.settings; + + const unionParts: string[] = []; + let hasDirectLink = false; + + for (const link of linkConfigurations) { + if (linkType && link.id !== linkType) { + continue; + } + + if (isDirectLink(link)) { + hasDirectLink = true; + } else { + const linkType = escapeIri(link.id); + if (!linkDirection || linkDirection === 'out') { + const path = this.formatLinkPath(link.path, sourceTerm, targetOut); + const binds = withProjection + ? `BIND(${linkType} as ?link) BIND("out" as ?direction)` : ''; + unionParts.push(`{ ${path} ${binds} }`); + } + if (!linkDirection || linkDirection === 'in') { + const path = this.formatLinkPath(link.path, targetIn, sourceTerm); + const binds = withProjection + ? `BIND(${linkType} as ?link) BIND("in" as ?direction)` : ''; + unionParts.push(`{ ${path} ${binds} }`); + } + } + } + + const usePredicatePart = this.openWorldLinks || hasDirectLink; + return {unionParts, usePredicatePart}; + } + + formatLinkLinks(): string { + const sourceTerm = this.factory.variable!('source'); + const targetTerm = this.factory.variable!('target'); + + const unionParts: string[] = []; + let hasDirectLink = false; + for (const link of this.settings.linkConfigurations) { + if (isDirectLink(link)) { + hasDirectLink = true; + } else { + const linkType = escapeIri(link.id); + unionParts.push( + `${this.formatLinkPath(link.path, sourceTerm, targetTerm)} BIND(${linkType} as ?type)` + ); + } + } + + const usePredicatePart = this.openWorldLinks || hasDirectLink; + if (usePredicatePart) { + unionParts.push(`${escapeTerm(sourceTerm)} ?type ${escapeTerm(targetTerm)}`); + } + + return ( + unionParts.length === 0 ? '' : + unionParts.length === 1 ? unionParts[0] : + '{ ' + unionParts.join(' }\nUNION\n{ ') + ' }' + ); + } + + private formatLinkPath( + path: string, + source: Rdf.Variable | Rdf.NamedNode, + target: Rdf.Variable | Rdf.NamedNode + ): string { + return replaceVariables(path, varName => { + if (varName === 'source') { + return source; + } else if (varName === 'target') { + return target; + } + return undefined; + }); + } + + private generateHasPropertyPattern( + pattern: LookupPatternHasProperty, + params: { + entityTerm: Rdf.Variable; + valueTerm: Rdf.Variable; + predicateTerm: Rdf.Variable; + withProjection?: boolean; + } + ): string { + const {entityTerm, valueTerm, predicateTerm, withProjection} = params; + return this.formatPropertyUnion({ + entityTerm, + valueTerm, + predicateTerm, + propertyType: pattern.propertyType, + filterByValueType: true, + withProjection, + }); + } + + formatPropertyUnion(params: { + entityTerm: Rdf.Variable; + valueTerm: Rdf.Variable; + predicateTerm: Rdf.Variable; + propertyType?: PropertyTypeIri; + filterByValueType?: boolean; + withProjection?: boolean; + }): string { + const { + entityTerm, valueTerm, predicateTerm, propertyType, filterByValueType, withProjection, + } = params; + + const unionParts: string[] = []; + let hasDirectProperty = false; + for (const property of this.settings.propertyConfigurations) { + if (propertyType && property.id !== propertyType) { + continue; + } + + if (isDirectProperty(property)) { + hasDirectProperty = true; + } else { + const propType = escapeIri(property.id); + const formatted = this.formatPropertyPath(property.path, entityTerm, valueTerm); + const binds = withProjection + ? `BIND(${propType} as ${escapeTerm(predicateTerm)})` + : ''; + unionParts.push(`{ ${formatted} ${binds} }`); + } + } + + const usePredicatePart = this.openWorldProperties || hasDirectProperty; + if (usePredicatePart) { + const entity = escapeTerm(entityTerm); + const predicate = escapeTerm(predicateTerm); + const value = escapeTerm(valueTerm); + + if (propertyType) { + const binds = withProjection + ? `BIND(${escapeIri(propertyType)} as ${predicate})` + : ''; + unionParts.push(`{ ${entity} ${escapeIri(propertyType)} ${value} ${binds} }`); + } else { + let filter = ''; + if (filterByValueType) { + const directPredicates = new Set(); + for (const property of this.settings.propertyConfigurations) { + if (isDirectProperty(property)) { + directPredicates.add(property.path); + } + } + const commaSeparatedPredicates = Array.from(directPredicates, escapeIri).join(', '); + filter = commaSeparatedPredicates + ? `FILTER(isLiteral(${value}) || ${predicate} IN (${commaSeparatedPredicates}))` + : `FILTER(isLiteral(${value}))`; + } + unionParts.push(`{ ${entity} ${predicate} ${value} ${filter} }`); + } + } + + return unionParts.join('\nUNION\n'); + } + + private formatPropertyPath(path: string, subject: Rdf.Variable, value: Rdf.Variable): string { + return replaceVariables(path, varName => { + if (varName === 'inst') { + return subject; + } else if (varName === 'value') { + return value; + } + return undefined; + }); + } + + private generateWithTextPattern( + pattern: LookupPatternWithText, + params: { + entityTerm: Rdf.Variable; + labelTerm: Rdf.Variable; + scoreTerm: Rdf.Variable; + } + ) { + const {entityTerm, labelTerm, scoreTerm} = params; + const parts: string[] = []; + if (this.settings.fullTextSearch.extractLabel) { + parts.push(sparqlExtractLabel(escapeTerm(entityTerm), escapeTerm(labelTerm))); + } + + const template = replaceVariables(this.settings.fullTextSearch.queryPattern, varName => { + if (varName === 'extractedLabel') { + return labelTerm; + } else if (varName === 'score') { + return scoreTerm; + } + return undefined; + }); + parts.push( + resolveTemplate(template, { + text: pattern.text, + dataLabelProperty: this.settings.dataLabelProperty, + schemaLabelProperty: this.settings.schemaLabelProperty, + }) + ); + return parts.join('\n'); + } +} + +export function isDirectLink(link: LinkConfiguration) { + // link configuration is path-based if includes any variables + const pathBased = /[?$][a-zA-Z]+\b/.test(link.path); + return !pathBased; +} + +export function isDirectProperty(property: PropertyConfiguration) { + // property configuration is path-based if includes any variables + const pathBased = /[?$][a-zA-Z]+\b/.test(property.path); + return !pathBased; +} + +export function resolveTemplate(template: string, values: { [key: string]: string | undefined }) { + let result = template; + for (const replaceKey in values) { + if (!Object.prototype.hasOwnProperty.call(values, replaceKey)) { continue; } + const replaceValue = values[replaceKey] || ''; + result = result.replace(new RegExp('\\${' + replaceKey + '}', 'g'), replaceValue); + } + return result; +} + +export function replaceVariables( + pattern: string, + replacer: (varName: string) => Rdf.Variable | Rdf.NamedNode | undefined +): string { + return pattern.replace(/[?$](.+?)\b/g, (original: string, varName: string) => { + const replaceWith = replacer(varName); + return replaceWith ? escapeTerm(replaceWith) : original; + }); +} + +function formatLanguageFilter( + variable: string, + languages: ReadonlyArray | undefined +): string { + if (!languages) { + return ''; + } + const parts = [`FILTER(!isLiteral(${variable}) || lang(${variable}) = ""`]; + for (const language of languages) { + parts.push(` || lang(${variable}) = "${language}"`); + } + parts.push(')'); + return parts.join(''); +} + +function sparqlExtractLabel(subject: string, label: string): string { + return ` + BIND(str(${subject}) as ?uriStr) + BIND(strafter(?uriStr, "#") as ?label3) + BIND(strafter(strafter(?uriStr, "//"), "/") as ?label6) + BIND(strafter(?label6, "/") as ?label5) + BIND(strafter(?label5, "/") as ?label4) + BIND(if (?label3 != "", ?label3, + if (?label4 != "", ?label4, + if (?label5 != "", ?label5, ?label6))) as ${label}) + `; +} + +export function formatValueClauseContent( + terms: Iterable +): string { + return Array.from(terms, term => { + const escaped = typeof term === 'string' ? escapeIri(term) : escapeTerm(term); + return `(${escaped})`; + }).join(' '); +} + +function formatLimit(limit: number | null | undefined): string { + return typeof limit === 'number' ? `LIMIT ${limit}` : ''; +} + +export function escapeTerm(term: Rdf.Term) { + return Rdf.termToString(term); +} + +export function escapeIri(iri: string) { + if (typeof iri !== 'string') { + throw new Error(`Cannot escape IRI of type "${typeof iri}"`); + } + return escapeTerm(Rdf.DefaultDataFactory.namedNode(iri)); +} + +function findPattern( + root: LookupPattern, + matches: (p: LookupPattern) => p is T +): T | undefined { + if (matches(root)) { + return root; + } else if (root.type === 'allOf') { + for (const pattern of root.patterns) { + const found = findPattern(pattern, matches); + if (found) { + return found; + } + } + } + return undefined; +} + +interface BasicSet extends Iterable { + has(item: T): boolean; + add(item: T): this; + delete(item: T): boolean; +} + +function keepOnlyIntersection(baseSet: BasicSet, otherSet: BasicSet): void { + const toRemove: T[] = []; + for (const item of baseSet) { + if (!otherSet.has(item)) { + toRemove.push(item); + } + } + for (const removed of toRemove) { + baseSet.delete(removed); + } +} diff --git a/src/diagram/spinner.tsx b/src/diagram/spinner.tsx index 69a92d08..fe8ab82b 100644 --- a/src/diagram/spinner.tsx +++ b/src/diagram/spinner.tsx @@ -66,6 +66,10 @@ export function Spinner(props: SpinnerProps) { * @see {@link Spinner} */ export function HtmlSpinner(props: { + /** + * Additional CSS class for the component. + */ + className?: string; /** * SVG canvas width to render spinner on. */ @@ -82,10 +86,10 @@ export function HtmlSpinner(props: { */ errorOccurred?: boolean; }) { - const {width, height, errorOccurred} = props; + const {className, width, height, errorOccurred} = props; const size = Math.min(width, height); return ( - + { return useAsync({ input: [locale, assetIri], - load: ([locale, assetIri], {signal}) => { + load: async ([locale, assetIri], {signal}) => { return assetIri === undefined ? undefined : locale.resolveAssetUrl(assetIri, {signal}); }, }); diff --git a/src/editor/withFetchStatus.tsx b/src/editor/withFetchStatus.tsx index 06a2c4ba..e1a2d3b5 100644 --- a/src/editor/withFetchStatus.tsx +++ b/src/editor/withFetchStatus.tsx @@ -28,12 +28,6 @@ export interface WithFetchStatusProps { children: React.ReactElement<{ className?: string }>; } -enum Status { - None = 0, - Loading = 1, - Error = 2, -} - const CLASS_NAME = 'reactodia-fetch-status'; /** @@ -52,31 +46,59 @@ const CLASS_NAME = 'reactodia-fetch-status'; export function WithFetchStatus(props: WithFetchStatusProps) { const {type, target, children} = props; + const status = useFetchStatus({type, target}); + + if (status === undefined) { + return children; + } else { + return React.cloneElement(children, { + className: cx(children.props.className, ( + status === 'loading' ? `${CLASS_NAME}--loading` : + status === 'error' ? `${CLASS_NAME}--error` : + undefined + )), + }); + } +} + +export function useFetchStatus(params: { + /** + * Fetch operation type. + */ + type: T; + /** + * Fetch operation target. + */ + target: FetchOperationTypeToTarget[T] | undefined; +}): 'loading' | 'error' | undefined { + const {type, target} = params; const {model} = useWorkspace(); - const [status, setStatus] = React.useState( + const [status, setStatus] = React.useState<'loading' | 'error' | undefined>( // Initialize with error status if needed to avoid layout jumps - model.getOperationFailReason(type, target) ? Status.Error : Status.None + target !== undefined && model.getOperationFailReason(type, target) + ? 'error' : undefined ); React.useEffect(() => { const checkOperations = (fail?: FetchOperationFail) => { setStatus(() => { - if (fail && isOperationMatch(fail.operation, type, target)) { - return Status.Error; - } + if (target) { + if (fail && isOperationMatch(fail.operation, type, target)) { + return 'error'; + } - for (const operation of model.operations) { - if (isOperationMatch(operation, type, target)) { - return Status.Loading; + for (const operation of model.operations) { + if (isOperationMatch(operation, type, target)) { + return 'loading'; + } } - } - if (model.getOperationFailReason(type, target)) { - return Status.Error; + if (model.getOperationFailReason(type, target)) { + return 'error'; + } } - - return Status.None; + return undefined; }); }; @@ -90,17 +112,7 @@ export function WithFetchStatus(props: WithF return () => model.events.off('changeOperations', listener); }, [type, target]); - if (status === Status.None) { - return children; - } else { - return React.cloneElement(children, { - className: cx(children.props.className, ( - status === Status.Loading ? `${CLASS_NAME}--loading` : - status === Status.Error ? `${CLASS_NAME}--error` : - undefined - )), - }); - } + return status; } function isOperationMatch( diff --git a/src/widgets/classTree/classTreeResults.tsx b/src/widgets/classTree/classTreeResults.tsx index 08122b79..a31ba53a 100644 --- a/src/widgets/classTree/classTreeResults.tsx +++ b/src/widgets/classTree/classTreeResults.tsx @@ -4,13 +4,12 @@ import * as React from 'react'; import { useTranslation } from '../../coreUtils/i18n'; import { ElementTypeIri, ElementTypeModel } from '../../data/model'; -import { useWorkspace } from '../../workspace/workspaceContext'; import { AccessibleTree, type TreeModel, type TreeRenderItem, TreeState, type TreeUpPath, TreeFocusableProps, treePathToDown, } from '../utility/accessibleTree'; -import { highlightSubstring } from '../utility/listElementView'; +import { SchemaTypeView } from '../utility/schemaTypeView'; export interface ClassTreeResultsProps extends ClassTreeProvidedContext { nodes: ReadonlyArray; @@ -129,7 +128,7 @@ export function ClassTreeResults(props: ClassTreeResultsProps) { export interface TreeNode { readonly iri: ElementTypeIri - readonly data: ElementTypeModel | undefined; + readonly data: ElementTypeModel; readonly label: string; readonly derived: ReadonlyArray; } @@ -157,7 +156,6 @@ function Leaf(props: { searchText, creatableClasses, onClickCreate, onDragCreate, draggableItems, onExpand, onSelect, } = useClassTreeContext(); - const {getElementTypeStyle} = useWorkspace(); const t = useTranslation(); const toggleClass = ( @@ -166,20 +164,6 @@ function Leaf(props: { `${CLASS_NAME}__toggle-collapsed` ); - const typeStyle = getElementTypeStyle([node.iri]); - const providedStyle = { - '--reactodia-element-style-color': typeStyle.color, - } as React.CSSProperties; - - const bodyClass = cx( - `${CLASS_NAME}__body`, - selected ? `${CLASS_NAME}__body--selected` : undefined - ); - - const label = highlightSubstring( - node.label, searchText, {className: `${CLASS_NAME}__highlighted-term`} - ); - const onDragStart = (e: React.DragEvent) => { // sets the drag data to support drag-n-drop in Firefox // see https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API/Drag_operations for more details @@ -189,15 +173,16 @@ function Leaf(props: { }; return ( -
+
onExpand(path)} /> - { e.preventDefault(); onSelect(node, path); @@ -209,29 +194,12 @@ function Leaf(props: { } }} draggable={draggableItems}> -
- {typeStyle.icon ? ( - - ) : ( -
- )} -
- {label} {node.data?.count ? ( {node.data.count} ) : null} -
+ {creatableClasses.get(node.iri) ? (
); diff --git a/src/widgets/editorForms/editRelationForm.tsx b/src/widgets/editorForms/editRelationForm.tsx index 64f0fe8c..a641765b 100644 --- a/src/widgets/editorForms/editRelationForm.tsx +++ b/src/widgets/editorForms/editRelationForm.tsx @@ -402,7 +402,7 @@ function useRelationMetadata( ): readonly [shape: RelationMetadata | undefined, error?: unknown] { const {data, status, error} = useAsync({ input: [metadataProvider, link.linkTypeId, linkSource, linkTarget], - load: ([provider, _typeId, source, target], {signal}) => { + load: async ([provider, _typeId, source, target], {signal}) => { if (provider) { return Promise.all([ provider.canModifyRelation(link, source, target, {signal}), diff --git a/src/widgets/editorForms/elementTypeSelector.tsx b/src/widgets/editorForms/elementTypeSelector.tsx index 64ea3f43..15a5676c 100644 --- a/src/widgets/editorForms/elementTypeSelector.tsx +++ b/src/widgets/editorForms/elementTypeSelector.tsx @@ -17,14 +17,11 @@ import type { MetadataCreatedEntity } from '../../data/metadataProvider'; import { HtmlSpinner } from '../../diagram/spinner'; import { type DataDiagramModel, getAllPresentEntities } from '../../editor/dataDiagramModel'; -import { EntityElement } from '../../editor/dataElements'; import { subscribeElementTypes } from '../../editor/observedElement'; -import { ListElementView } from '../utility/listElementView'; import { NoSearchResults } from '../utility/noSearchResults'; import { SearchInput, SearchInputStore, useSearchInputStore } from '../utility/searchInput'; import { SearchResults } from '../utility/searchResults'; -import { createRequest } from '../instancesSearch'; import { type WorkspaceContext, useWorkspace } from '../../workspace/workspaceContext'; @@ -159,11 +156,10 @@ export class ElementTypeSelectorInner extends React.Component - - + }} + slotInput={} + /> { searchStore.value.length > 0 ? (
readonly ElementModel[] + ) => void; +}) { + const {dataProvider, selectedStatus, selected, setSelected} = props; + + const suggest = React.useCallback(async (text: string, options: { signal: AbortSignal }) => { + const {signal} = options; + const items = await dataProvider.lookup({text, signal}); + return items.map(item => item.element); + }, [dataProvider]); + + const selectedSet = React.useMemo(() => new Set(selected.map(v => v.id)), [selected]); + return ( + + {selected.map(item => + + + + + ) : ( + <> + + + + )} +
+ ) : null} + +
+ ); +} + +function searchPatternFrom(criteria: SearchCriteria): SearchPattern | undefined { + const { + elementType, refElement, refElementLink, linkDirection, pattern, + } = criteria; + + const patterns: SearchPattern[] = []; + + if (elementType) { + patterns.push(new SearchPatternHasType({elementType})); + } + + if (refElement || refElementLink || linkDirection) { + patterns.push(new SearchPatternConnectedTo({ + linkType: refElementLink, + linkDirection, + other: refElement + ? new SearchPatternFromValues({values: [refElement]}) + : undefined, + })); + } + + if (pattern) { + patterns.push(pattern); + } + + return ( + patterns.length === 0 ? undefined : + patterns.length === 1 ? patterns[0] : + new SearchPatternAllOf({patterns}) + ); +} diff --git a/src/widgets/instancesSearch.tsx b/src/widgets/instancesSearch/instancesSearch.tsx similarity index 69% rename from src/widgets/instancesSearch.tsx rename to src/widgets/instancesSearch/instancesSearch.tsx index 9cbdf759..aa5eceea 100644 --- a/src/widgets/instancesSearch.tsx +++ b/src/widgets/instancesSearch/instancesSearch.tsx @@ -1,31 +1,37 @@ import cx from 'clsx'; import * as React from 'react'; -import { EventObserver } from '../coreUtils/events'; -import { Debouncer } from '../coreUtils/scheduler'; -import { useTranslation, type Translation, TranslatedText } from '../coreUtils/i18n'; +import { EventObserver } from '../../coreUtils/events'; +import { Debouncer } from '../../coreUtils/scheduler'; +import { useTranslation, type Translation, TranslatedText } from '../../coreUtils/i18n'; -import { ElementModel, ElementIri, ElementTypeIri, LinkTypeIri } from '../data/model'; -import { DataProviderLookupParams, DataProviderLookupItem } from '../data/dataProvider'; +import { ElementModel, ElementIri } from '../../data/model'; +import { DataProviderLookupParams, DataProviderLookupItem } from '../../data/dataProvider'; -import type { CanvasApi } from '../diagram/canvasApi'; -import { placeElementsAroundTarget } from '../diagram/commands'; -import { Element, VoidElement } from '../diagram/elements'; -import { Vector, boundsOf } from '../diagram/geometry'; +import type { CanvasApi } from '../../diagram/canvasApi'; +import { placeElementsAroundTarget } from '../../diagram/commands'; +import { Element, VoidElement } from '../../diagram/elements'; +import { Vector, boundsOf } from '../../diagram/geometry'; -import { - DataGraphStructure, requestElementData, restoreLinksBetweenElements, -} from '../editor/dataDiagramModel'; -import { EntityElement, EntityGroup, iterateEntitiesOf } from '../editor/dataElements'; +import { requestElementData, restoreLinksBetweenElements } from '../../editor/dataDiagramModel'; +import { EntityElement, EntityGroup } from '../../editor/dataElements'; + +import { WorkspaceContext, WorkspaceEventKey, useWorkspace } from '../../workspace/workspaceContext'; +import { InstancesSearchTopic } from '../../workspace/commandBusTopic'; -import { WorkspaceContext, WorkspaceEventKey, useWorkspace } from '../workspace/workspaceContext'; -import { InstancesSearchTopic } from '../workspace/commandBusTopic'; +import { ErrorMessage } from '../utility/errorMessage'; +import { NoSearchResults } from '../utility/noSearchResults'; +import { ProgressBar, ProgressState } from '../utility/progressBar'; +import { SearchInputStore, useSearchInputStore } from '../utility/searchInput'; +import { SearchResults } from '../utility/searchResults'; -import { InlineEntity } from './utility/inlineEntity'; -import { NoSearchResults } from './utility/noSearchResults'; -import { ProgressBar, ProgressState } from './utility/progressBar'; -import { SearchInput, SearchInputStore, useSearchInputStore } from './utility/searchInput'; -import { SearchResults } from './utility/searchResults'; +import { SearchCriteria, InstanceCriteria } from './instanceCriteria'; +import { + type SearchCriteriaOption, SearchCriterionAllOf, SearchCriterionAnyOf, + SearchCriterionHasType, SearchCriterionConnectedTo, SearchCriterionFromValues, + SearchCriterionHasProperty, +} from './searchCriterion'; +import { SearchPatternGenerateContext, SearchPatternType } from './searchPattern'; /** * Props for {@link InstancesSearch} component. @@ -59,6 +65,34 @@ export interface InstancesSearchProps { * @default 3 */ minSearchTermLength?: number; + /** + * Supported search criteria options to display and edit corresponding + * {@link SearchPattern search patterns}. + * + * Built-in criteria options are always included to display or edit their + * {@link SearchCriteriaOption.type pattern types}, however these options + * can also be overriden by including options for these pattern types instead. + * + * **Built-in criteria options**: + * - {@link SearchCriterionAllOf} + * - {@link SearchCriterionAnyOf} + * - {@link SearchCriterionHasType} + * - {@link SearchCriterionConnectedTo} + * - {@link SearchCriterionFromValues} + * - {@link SearchCriterionHasProperty} + */ + criteriaOptions?: readonly SearchCriteriaOption[]; + /** + * The order in which to display the built-in and extra search criteria options + * when adding a new search pattern from the UI. + * + * If specified, only options with corresponding types will be available + * for creation from the menu. + * + * **Default** order is built-in options then additional ones provided + * with {@link creatableOptions}. + */ + addCriteriaOrder?: readonly SearchPatternType[]; /** * Handler for the search criteria changes. */ @@ -99,37 +133,14 @@ export interface InstancesSearchCommands { }; } -/** - * A filter criteria for the entity lookup from a {@link DataProvider}. - * - * @see {@link DataProviderLookupParams} - */ -export interface SearchCriteria { - /** - * Filter by a text lookup. - */ - readonly text?: string; - /** - * Filter by an element type. - */ - readonly elementType?: ElementTypeIri; - /** - * Filter by having a connected element with specified IRI. - */ - readonly refElement?: ElementIri; - /** - * Filter by connection link type. - * - * Only applicable when {@link refElement} is set. - */ - readonly refElementLink?: LinkTypeIri; - /** - * Reference element link type direction ('in' | 'out'). - * - * Only when {@link refElementLink} is set. - */ - readonly linkDirection?: 'in' | 'out'; -} +const BuiltInCriterionOptions: readonly SearchCriteriaOption[] = [ + SearchCriterionAllOf, + SearchCriterionAnyOf, + SearchCriterionHasType, + SearchCriterionConnectedTo, + SearchCriterionFromValues, + SearchCriterionHasProperty, +]; /** * Component to search for entities by various filter criteria @@ -142,6 +153,8 @@ export function InstancesSearch(props: InstancesSearchProps) { searchStore, searchTimeout = 'explicit', minSearchTermLength = 3, + criteriaOptions, + addCriteriaOrder, } = props; const uncontrolledSearch = useSearchInputStore({ initialValue: '', @@ -151,11 +164,32 @@ export function InstancesSearch(props: InstancesSearchProps) { const effectiveSearchStore = searchStore ?? uncontrolledSearch; const workspace = useWorkspace(); const t = useTranslation(); + + const [allCriterions, creatableCriterions] = React.useMemo(() => { + const all = new Map(); + for (const option of BuiltInCriterionOptions) { + all.set(option.type, option); + } + if (criteriaOptions) { + for (const option of criteriaOptions) { + all.set(option.type, option); + } + } + const allValues = Array.from(all.values()); + const creatable = addCriteriaOrder + ? addCriteriaOrder.map(type => all.get(type)) + .filter((option): option is SearchCriteriaOption => Boolean(option)) + : allValues; + return [allValues, creatable]; + }, [criteriaOptions, addCriteriaOrder]); + return ( @@ -166,6 +200,8 @@ interface InstancesSearchInnerProps extends InstancesSearchProps { isControlled: boolean; searchStore: SearchInputStore; minSearchTermLength: number; + allCriterions: readonly SearchCriteriaOption[]; + creatableCriterions: readonly SearchCriteriaOption[]; workspace: WorkspaceContext; translation: Translation; } @@ -174,7 +210,7 @@ interface State { readonly criteria: SearchCriteria; readonly querying?: boolean; readonly resultId: number; - readonly error?: any; + readonly error?: unknown; readonly items?: ReadonlyArray; readonly selection: ReadonlySet; readonly moreItemsAvailable?: boolean; @@ -309,7 +345,8 @@ class InstancesSearchInner extends React.Component -
- {this.renderCriteria()} - {isControlled ? null : ( - + this.setState( + {criteria}, + () => this.props.onChangeCriteria?.(this.state.criteria) )} -
+ searchStore={isControlled ? undefined : searchStore} + allCriterions={allCriterions} + creatableCriterions={creatableCriterions} + /> - {queryError ? ( -
- {queryError} -
- ) : null} + {this.state.error ? : null} {/* specify resultId as key to reset scroll position when loaded new search results */}
{ - const {workspace: {model}, translation: t} = this.props; - const {criteria} = this.state; - const criterions: React.ReactElement[] = []; - - if (criteria.elementType) { - const elementTypeInfo = model.getElementType(criteria.elementType); - const elementTypeLabel = t.formatLabel( - elementTypeInfo?.data?.label, - criteria.elementType, - model.language - ); - criterions.push( -
- {this.renderRemoveCriterionButtons(() => this.setState( - { - criteria: {...criteria, elementType: undefined}, - }, - () => this.props.onChangeCriteria?.(this.state.criteria) - ))} - {t.template('search_entities.criteria_has_type', { - entityType: ( - - {elementTypeLabel} - - ) - })} -
- ); - } else if (criteria.refElement) { - const refElementData = findEntityData(model, criteria.refElement) - ?? EntityElement.placeholderData(criteria.refElement); - - let linkTypeLabel: string | undefined; - if (criteria.refElementLink) { - const linkTypeData = model.getLinkType(criteria.refElementLink); - linkTypeLabel = t.formatLabel( - linkTypeData?.data?.label, - criteria.refElementLink, - model.language - ); - } - - const entity = ; - const relationType = criteria.refElementLink ? ( - - {linkTypeLabel} - - ) : undefined; - const sourceIcon = ; - const targetIcon = ; - - criterions.push( -
- {this.renderRemoveCriterionButtons(() => this.setState( - { - criteria: {...criteria, refElement: undefined, refElementLink: undefined}, - }, - () => this.props.onChangeCriteria?.(this.state.criteria) - ))} - {!criteria.refElementLink ? ( - t.template('search_entities.criteria_connected', { - entity, relationType, sourceIcon, targetIcon, - }) - ) : criteria.linkDirection === 'in' ? ( - t.template('search_entities.criteria_connected_to_source', { - entity, relationType, sourceIcon, targetIcon, - }) - ) : criteria.linkDirection == 'out' ? ( - t.template('search_entities.criteria_connected_to_target', { - entity, relationType, sourceIcon, targetIcon, - }) - ) : ( - t.template('search_entities.criteria_connected_via', { - entity, relationType, sourceIcon, targetIcon, - }) - )} -
- ); - } - - return
{criterions}
; - } - - private renderRemoveCriterionButtons(onClick: () => void) { - return
- -
; - } - private submitCriteriaUpdate(term: string, options: { triggerChange: boolean }): void { const {onChangeCriteria} = this.props; this.setState( @@ -538,7 +470,13 @@ class InstancesSearchInner extends React.Component { + typeKind: Kind; + selected: SchemaType[Kind] | undefined; + setSelected: (item: SchemaType[Kind] | undefined) => void; +} + +export function SchemaTypeSelect( + props: SchemaTypeSelectProps +) { + const {typeKind, selected, setSelected} = props; + const t = useTranslation(); + const {model} = useWorkspace(); + const {dataProvider} = model; + + const language = useObservedProperty(model.events, 'changeLanguage', () => model.language); + + const typeStatus = useFetchStatus({type: typeKind, target: selected?.id}); + + const suggest = React.useCallback(async (text: string, options: { signal: AbortSignal }) => { + const {signal} = options; + switch (typeKind) { + case 'elementType': { + return dataProvider.lookupElementTypes({text, signal}); + } + case 'propertyType': { + return dataProvider.lookupPropertyTypes({text, signal}); + } + case 'linkType': { + return dataProvider.lookupLinkTypes({text, signal}); + } + } + }, [typeKind, dataProvider]) as (text: string) => Promise; + + const suggestDefault = React.useCallback( + async ( + options: { signal: AbortSignal } + ): Promise => { + const {signal} = options; + switch (typeKind) { + case 'elementType': { + const {elementTypes} = await dataProvider.knownElementTypes({signal}); + return elementTypes; + } + case 'propertyType': { + const propertyTypeIds = getPresentPropertyTypes(model); + const propertyTypes = await dataProvider.propertyTypes({ + propertyIds: Array.from(propertyTypeIds), + signal, + }); + return Array.from(propertyTypes.values()); + } + case 'linkType': { + const linkTypeIds = getPresentLinkTypes(model); + const linkTypes = await dataProvider.linkTypes({ + linkTypeIds: Array.from(linkTypeIds), + signal, + }); + return Array.from(linkTypes.values()); + } + } + }, + [typeKind, model, dataProvider] + ) as () => Promise; + + const comparator = React.useMemo( + () => makeSchemaTypesComparator(t, language), + [t, language] + ); + + return ( + +
+ ) : null; + const option = allOptions.find(option => pattern instanceof option.type) + ?? PlaceholderUnknownOption; + const optionProps: SearchCriteriaRenderProps = { + pattern, + setPattern, + inEditMode: editing, + parent, + controls, + }; + + return option.renderCriterion(optionProps); +} + +export function AddCriterion(props: { + onAdd: (pattern: SearchPattern) => void; + title?: string; + children: React.ReactNode; +}) { + const {onAdd, title, children} = props; + const t = useTranslation(); + const {creatableOptions} = React.useContext(SearchCriterionContext); + return ( + + {children} + + }> + {creatableOptions.map(option => option.label ? ( + { + onAdd(option.createPattern()); + }}> + {typeof option.label === 'string' + ? option.label + : option.label.resolve(t)} + + ) : null)} + + ); +} + +function AddCriterionButton(props: { + title: string; + children: React.ReactNode; +}) { + const {title, children} = props; + const {expanded, setExpanded} = useDropdownMenu(); + return ( + + ); +} + +/** + * Utility component to render criterion from {@link SearchCriteriaOption} + * in the edit mode with controls and pattern validation errors. + * + * @category Components + */ +export function SearchCriterionBlock(props: { + /** + * Target pattern to display validation errors for. + */ + target: SearchPattern; + /** + * Criterion header to describe the pattern type. + */ + header: React.ReactNode; + /** + * Criterion controls provided with {@link SearchCriterionProps.controls}. + */ + controls: React.ReactNode; + /** + * Criterion editing UI, e.g. inputs, {@link SearchCriterionWhereGroup}, etc. + */ + children?: React.ReactNode; +}) { + const {target, header, controls, children} = props; + const t = useTranslation(); + const {errors} = React.useContext(SearchCriterionContext); + const targetErrors = errors.get(target); + return ( +
+
+ {header} +
+
+ {controls} +
+
+ {children} +
+ {targetErrors ? ( +
    + {targetErrors.map((error, index) => ( +
  • + {typeof error.message === 'string' + ? error.message + : error.message.resolve(t)} +
  • + ))} +
+ ) : null} +
+ ); +} + +/** + * Utility component to display a "where" definition-list + * when rendering a criterion from {@link SearchCriteriaOption}. + * + * @category Components + * @see {@link SearchCriterionWhere} + */ +export function SearchCriterionWhereGroup(props: { + /** + * Definition list content, e.g. {@link SearchCriterionWhere} items. + */ + children: React.ReactElement; +}) { + const {children} = props; + return ( +
+ {children} +
+ ); +} + +/** + * Utility component to display a "where" definition-item + * when rendering a criterion from {@link SearchCriteriaOption}. + * + * @category Components + * @see {@link SearchCriterionWhereGroup} + */ +export function SearchCriterionWhere(props: { + /** + * Definition label, e.g. "target" or "sub-pattern" to display. + */ + part: string; + /** + * Definition content. + */ + children: React.ReactNode; +}) { + const {part, children} = props; + const t = useTranslation(); + return ( + <> +
+ {t.template('search_entities.criteria_where_caption', { + part: {part}, + })} +
+
+ {children} +
+ + ); +} + +function PlaceholderAnyCriterion(props: { + setPattern: (pattern: SearchPattern) => void; + editing: boolean; + topLevel?: boolean; +}) { + const {setPattern, editing, topLevel} = props; + const t = useTranslation(); + + if (!editing) { + return topLevel ? null : ( +
+ {t.text('search_entities.criteria_placeholder_any')} +
+ ); + } + + return ( + + {t.text('search_entities.add_criterion.label')} + + ); +} + +class SearchPatternUnknown extends SearchPattern> { + generate(): LookupPattern | SearchPatternError { + throw new Error('Unknown pattern type'); + } + + static readonly fromJSONType = 'unknown'; + + static fromJSON(): SearchPatternUnknown { + return new SearchPatternUnknown({}); + } + + toJSON(): SerializedSearchPattern { + return {'@type': 'unknown'}; + } +} + +SearchPattern.define(SearchPatternUnknown); + +const PlaceholderUnknownOption: SearchCriteriaOption = { + type: SearchPatternUnknown, + createPattern: () => new SearchPatternUnknown({}), + renderCriterion: props => , +}; + +function PlaceholderUnknownCriterion( + props: SearchCriteriaRenderProps +) { + const {pattern, controls} = props; + const t = useTranslation(); + return ( + + {t.text('search_entities.criteria_placeholder_unknown')} +
+ } + /> + ); +} + +/** + * Search criteria option corresponding to {@link LookupPatternAllOf}. + */ +export const SearchCriterionAllOf: SearchCriteriaOption = { + type: SearchPatternAllOf, + createPattern: () => new SearchPatternAllOf({patterns: []}), + renderCriterion: props => , +}; + +/** + * Search criteria option corresponding to {@link LookupPatternAnyOf}. + */ +export const SearchCriterionAnyOf: SearchCriteriaOption = { + type: SearchPatternAnyOf, + createPattern: () => new SearchPatternAnyOf({patterns: []}), + renderCriterion: props => , +}; + +function AnyAllOfCriterion

( + props: SearchCriteriaRenderProps

+) { + const {pattern, setPattern, inEditMode, parent} = props; + const t = useTranslation(); + const className = parent + ? `${CLASS_NAME}__logical-group` + : `${CLASS_NAME}__logical-group-root`; + return ( +

+ {pattern.state.patterns.map((child, index) => ( + + {index > 0 ? ( +
+ {( + pattern instanceof SearchPatternAllOf ? t.text( + 'search_entities.criteria_all_of_joiner' + ) : pattern instanceof SearchPatternAnyOf ? t.text( + 'search_entities.criteria_any_of_joiner' + ) : '-' + )} +
+ ) : null} + { + const nextPatterns = [...pattern.state.patterns]; + if (next) { + if (( + next instanceof SearchPatternAllOf && + pattern instanceof SearchPatternAllOf + ) || ( + next instanceof SearchPatternAnyOf && + pattern instanceof SearchPatternAnyOf + )) { + nextPatterns.splice(index, 1, ...next.state.patterns); + } else { + nextPatterns.splice(index, 1, next); + } + } else { + nextPatterns.splice(index, 1); + } + + setPattern( + nextPatterns.length === 0 ? undefined : + nextPatterns.length === 1 ? nextPatterns[0] : + pattern instanceof SearchPatternAllOf ? new SearchPatternAllOf({ + patterns: nextPatterns, + }) : + pattern instanceof SearchPatternAnyOf ? new SearchPatternAnyOf({ + patterns: nextPatterns, + }) : + undefined + ); + }} + parent={pattern} + /> +
+ ))} + {inEditMode && pattern.state.patterns.length === 0 ? ( + { + setPattern( + pattern instanceof SearchPatternAllOf ? new SearchPatternAllOf({ + patterns: [added], + }) : + pattern instanceof SearchPatternAnyOf ? new SearchPatternAnyOf({ + patterns: [added], + }) : + undefined + ); + }} + title={ + pattern instanceof SearchPatternAllOf ? t.textOptional( + 'search_entities.criteria_all_of_add.title' + ) : pattern instanceof SearchPatternAnyOf ? t.textOptional( + 'search_entities.criteria_any_of_add.title' + ) : undefined + }> + {( + pattern instanceof SearchPatternAllOf ? t.text( + 'search_entities.criteria_all_of_add.label' + ) : pattern instanceof SearchPatternAnyOf ? t.text( + 'search_entities.criteria_any_of_add.label' + ) : t.text('search_entities.add_criterion.label') + )} + + ) : null} +
+ ); +} + +/** + * Search criteria option corresponding to {@link LookupPatternHasType}. + */ +export const SearchCriterionHasType: SearchCriteriaOption = { + type: SearchPatternHasType, + label: TranslatedText.text('search_entities.criteria_has_type.label'), + createPattern: () => new SearchPatternHasType({elementType: ''}), + renderCriterion: props => , +}; + +function HasTypeCriterion( + props: SearchCriteriaRenderProps +) { + const {pattern, setPattern, inEditMode, controls} = props; + const t = useTranslation(); + + const elementType = useSchemaType('elementType', pattern.state.elementType); + + let header: React.ReactNode; + let where: React.ReactNode | null = null; + if (!inEditMode) { + const typeView = elementType ? ( + + ) : ( +
+ {t.text('search_entities.criteria_has_type.any_type')} +
+ ); + + header = ( + + {t.template('search_entities.criteria_has_type', { + entityType: typeView, + })} + + ); + } else { + header =
{t.text('search_entities.criteria_has_type.header')}
; + where = ( + setPattern(new SearchPatternHasType({ + ...pattern.state, + elementType: type ? type.id : '', + }))} + /> + ); + } + + return ( + + {where} + + ); +} + +/** + * Search criteria option corresponding to {@link LookupPatternConnectedTo}. + */ +export const SearchCriterionConnectedTo: SearchCriteriaOption = { + type: SearchPatternConnectedTo, + label: TranslatedText.text('search_entities.criteria_connected.label'), + createPattern: () => new SearchPatternConnectedTo({}), + renderCriterion: props => , +}; + +function ConnectedToCriterion( + props: SearchCriteriaRenderProps +) { + const {pattern, setPattern, inEditMode, controls} = props; + const t = useTranslation(); + + const {linkDirection, other} = pattern.state; + + const linkType = useSchemaType('linkType', pattern.state.linkType); + + let inlineEntity = false; + let header: React.ReactNode; + let where: React.ReactNode = null; + + if (!inEditMode) { + let entity: React.ReactElement; + if (!other || other instanceof SearchPatternFromValues) { + inlineEntity = true; + entity = ; + } else { + entity = ( + + {t.text('search_entities.criteria_connected.other-part')} + + ); + } + + const relationType = linkType ? ( + + ) : ( + + {t.text('search_entities.criteria_connected.any-link')} + + ); + + const sourceIcon = ; + const targetIcon = ; + + header = ( + + {!linkType && !linkDirection ? ( + t.template('search_entities.criteria_connected', { + entity, relationType, sourceIcon, targetIcon, + }) + ) : linkDirection === 'in' ? ( + t.template('search_entities.criteria_connected_to_source', { + entity, relationType, sourceIcon, targetIcon, + }) + ) : linkDirection == 'out' ? ( + t.template('search_entities.criteria_connected_to_target', { + entity, relationType, sourceIcon, targetIcon, + }) + ) : ( + t.template('search_entities.criteria_connected_via', { + entity, relationType, sourceIcon, targetIcon, + }) + )} + + ); + } else { + header = ( +
+ {t.template('search_entities.criteria_connected.header', { + entity: ( + + {t.text('search_entities.criteria_connected.other-part')} + + ), + })} +
+ ); + where = ( +
+ setPattern(new SearchPatternConnectedTo({ + ...pattern.state, + linkType: next?.id, + }))} + /> + setPattern(new SearchPatternConnectedTo({ + ...pattern.state, + linkDirection: next, + }))} + /> +
+ ); + } + + return ( + + {where} + {inlineEntity ? null : ( + + + setPattern(new SearchPatternConnectedTo({ + ...pattern.state, + other, + }))} + parent={pattern} + /> + + + )} + + ); +} + +export function LinkDirectionSelect(props: { + selected: LinkDirection | undefined; + setSelected: (direction: LinkDirection | undefined) => void; +}) { + const {selected, setSelected} = props; + const t = useTranslation(); + return ( +
+
+ ); +} + +/** + * Search criteria option corresponding to {@link LookupPatternFromValues}. + */ +export const SearchCriterionFromValues: SearchCriteriaOption = { + type: SearchPatternFromValues, + label: TranslatedText.text('search_entities.criteria_from_values.label'), + createPattern: () => new SearchPatternFromValues({values: []}), + renderCriterion: props => , +}; + +function FromValuesCriterion( + props: SearchCriteriaRenderProps +) { + const {pattern, setPattern, inEditMode, controls} = props; + const t = useTranslation(); + + let header: React.ReactNode; + let where: React.ReactNode = null; + if (!inEditMode) { + header = ( + + {t.template('search_entities.criteria_from_values', { + values: , + })} + + ); + } else { + header =
{t.text('search_entities.criteria_from_values.header')}
; + where = ; + } + + return ( + + {where} + + ); +} + +function FromValuesList(props: { + values: readonly ElementIri[] +}) { + const {values} = props; + const t = useTranslation(); + const {model} = useWorkspace(); + const providedEntities = useProvidedEntities(model.dataProvider, values); + if (values.length === 0) { + return {t.text('search_entities.criteria_from_values.any-entity')}; + } else if (values.length <= 10) { + return ( + + {values.map((iri, index) => { + const data = providedEntities.data.get(iri) + ?? EntityElement.placeholderData(iri); + return ( + + {index === 0 ? null : ( + + {t.text('search_entities.criteria_from_values.joiner')} + + )} + + + ); + })} + + ); + } else { + return ( + + {t.text('search_entities.criteria_from_values.many-entities', { + count: values.length, + })} + + ); + } +} + +function FromValuesSelector(props: Pick< + SearchCriteriaRenderProps, + 'pattern' | 'setPattern' +>) { + const {pattern, setPattern} = props; + const {model} = useWorkspace(); + const providedEntities = useProvidedEntities( + model.dataProvider, + pattern.state.values + ); + const selected = Array.from(providedEntities.data.values()); + return ( + { + const nextValues = update(selected); + setPattern(new SearchPatternFromValues({ + ...pattern.state, + values: nextValues.map(v => v.id), + })); + }} + /> + ); +} + +/** + * Search criteria option corresponding to {@link LookupPatternHasProperty}. + */ +export const SearchCriterionHasProperty: SearchCriteriaOption = { + type: SearchPatternHasProperty, + label: TranslatedText.text('search_entities.criteria_has_property.label'), + createPattern: () => new SearchPatternHasProperty({propertyType: ''}), + renderCriterion: props => , +}; + +function HasPropertyCriterion( + props: SearchCriteriaRenderProps +) { + const {pattern, setPattern, inEditMode, controls} = props; + const t = useTranslation(); + + const propertyType = useSchemaType('propertyType', pattern.state.propertyType); + + let header: React.ReactNode; + let where: React.ReactNode = null; + + if (!inEditMode) { + const propertyView = propertyType ? ( + + ) : ( +
+ {t.text('search_entities.criteria_has_property.any-property')} +
+ ); + + header = ( + + {t.template('search_entities.criteria_has_property', { + propertyType: propertyView, + })} + + ); + } else { + header =
{t.text('search_entities.criteria_has_property.header')}
; + where = ( + { + setPattern(new SearchPatternHasProperty({ + ...pattern.state, + propertyType: next?.id ?? '', + })); + }} + /> + ); + } + + return ( + + {where} + + ); +} diff --git a/src/widgets/instancesSearch/searchPattern.ts b/src/widgets/instancesSearch/searchPattern.ts new file mode 100644 index 00000000..672a47e6 --- /dev/null +++ b/src/widgets/instancesSearch/searchPattern.ts @@ -0,0 +1,532 @@ +import { TranslatedText } from '../../coreUtils/i18n'; +import { ElementIri, ElementTypeIri, LinkTypeIri, PropertyTypeIri } from '../../data/model'; +import { LookupPattern } from '../../data/dataProvider'; +import type * as Rdf from '../../data/rdf/rdfModel'; + +/** + * Abstract search pattern type for search criteria in + * {@link SearchSectionEntities} and {@link InstancesSearch}. + * + * Compared to {@link LookupPattern}, a search pattern represents + * displayed or edited state for a {@link SearchCriterionOption}. + * This state can be converted into {@link LookupPattern} with + * validation, serialized to/deserialized from a plain JSON value. + * + * **Example**: + * ```ts + * class FromNamedSet extends SearchPattern<{ + * readonly setIri: Rdf.NamedNode; + * }> { + * generate( + * context: SearchPatternGenerateContext + * ): LookupPattern | SearchPatternError { + * // ... + * } + * + * static readonly fromJSONType = 'fromNamedSet'; + * static fromJSON( + * raw: SerializedFromNamedSet, + * context: + * ): FromNamedSet { + * const {setIri} = raw; + * return new FromNamedSet({ + * setIri: context.factory.namedNode(setIri), + * }); + * } + * + * toJSON( + * context: SearchPatternToJsonContext + * ): SerializedFromNamedSet { + * const {setIri} = this.state; + * return { + * '@type': 'fromNamedSet', + * setIri: setIri.value, + * }; + * } + * } + * + * SearchPattern.define(FromNamedSet); + * + * interface SerializedFromNamedSet { + * setIri: string; + * } + * ``` + * + * @see {@link SearchPattern.define} + */ +export abstract class SearchPattern { + constructor( + /** + * Search pattern state. + */ + readonly state: S + ) {} + + /** + * Generates a valid {@link LookupPattern} from the pattern or + * {@link SearchPatternError} if it's incomplete or contradictory. + */ + abstract generate( + context: SearchPatternGenerateContext + ): LookupPattern | SearchPatternError; + + /** + * Serializes the pattern to a plain JSON-stringifiable value. + */ + abstract toJSON( + context: SearchPatternToJsonContext + ): SerializedSearchPattern; + + /** + * Utility function to define {@link SearchPattern search pattern} type + * and assert its contract on serialization. + */ + static define(type: SearchPatternType): void { + if (typeof type.fromJSONType !== 'string') { + throw new Error('Missing or invalid "fromJSONType" on a search pattern type'); + } else if (typeof type.fromJSON !== 'function') { + throw new Error('Missing or invalid "fromJSON" on a search pattern type'); + } + } + + /** + * Utility function to generate lookup patterns from an array of search patterns, + * returing all collected errors instead if any error is produced. + */ + static generateMany( + patterns: readonly SearchPattern[], + context: SearchPatternGenerateContext + ): LookupPattern[] | SearchPatternError { + const results: LookupPattern[] = []; + const errors: SearchPatternErrorItem[] = []; + let hasError = false; + for (const pattern of patterns) { + const result = pattern.generate(context); + if (result.type === 'error') { + hasError = true; + for (const error of result.errors) { + errors.push(error); + } + } else { + results.push(result); + } + } + return hasError ? {type: 'error', errors} : results; + } +} + +/** + * Describes a serializable {@link SearchPattern search pattern type}. + */ +export interface SearchPatternType

{ + new (...args: any[]): P; + /** + * Unique identifier string to act as determine a search pattern type + * for a serialized pattern state. + */ + readonly fromJSONType: ReturnType['@type']; + /** + * Deserializes the pattern from a plain JSON-stringifiable value. + */ + fromJSON( + state: ReturnType, + context: SearchPatternFromJsonContext + ): P | undefined; +} + +/** + * A combined error from search pattern {@link SearchPattern.generate generation}. + */ +export interface SearchPatternError { + /** + * Error result type. + */ + readonly type: 'error'; + /** + * All collected generation errors. + */ + readonly errors: readonly SearchPatternErrorItem[]; +} + +/** + * A sigle error related to search pattern {@link SearchPattern.generate generation} + * from a `target` pattern. + */ +export interface SearchPatternErrorItem { + /** + * Target search pattern from which the error originates. + */ + readonly target: SearchPattern; + /** + * Error message. + */ + readonly message: string | TranslatedText; +} + +/** + * Context for {@link SearchPattern.generate}. + */ +export type SearchPatternGenerateContext = Record; + +/** + * Context for {@link SearchPattern.toJSON}. + */ +export interface SearchPatternToJsonContext { + /** + * Serializes a sub-pattern. + */ + toJson: (pattern: SearchPattern) => SerializedSearchPattern; +} + +/** + * Context for {@link SearchPatternType.fromJSON}. + */ +export interface SearchPatternFromJsonContext { + /** + * An [RDF term factory](https://rdf.js.org/data-model-spec/#datafactory-interface) + * to create RDF terms for identifiers and property values. + */ + factory: Rdf.DataFactory; + /** + * Deserializes a sub-pattern. + */ + fromJson: (pattern: SerializedSearchPattern) => SearchPattern; +} + +/** + * Base type for serialized {@link SearchPattern search pattern}. + */ +export interface SerializedSearchPattern { + '@type': string; +} + +/** + * Search pattern corresponding to {@link LookupPatternAllOf}. + * + * @see {@link SearchCriterionAllOf} + */ +export class SearchPatternAllOf extends SearchPattern<{ + readonly patterns: readonly SearchPattern[]; +}> { + generate( + context: SearchPatternGenerateContext + ): LookupPattern | SearchPatternError { + const {patterns} = this.state; + if (patterns.length === 0) { + return { + type: 'error', + errors: [{ + target: this, + message: TranslatedText.text( + 'search_entities.error.required_at_least_one_pattern' + ), + }], + }; + } + const inner = SearchPattern.generateMany(patterns, context); + return Array.isArray(inner) ? {type: 'allOf', patterns: inner} : inner; + } + + static readonly fromJSONType = 'AllOf'; + + static fromJSON( + raw: SerializedAllOf, + context: SearchPatternFromJsonContext + ): SearchPatternAllOf { + const {patterns} = raw; + return new SearchPatternAllOf({ + patterns: patterns.map(p => context.fromJson(p)), + }); + } + + toJSON(context: SearchPatternToJsonContext): SerializedAllOf { + const {patterns} = this.state; + return { + '@type': 'AllOf', + patterns: patterns.map(p => context.toJson(p)), + }; + } +} + +SearchPattern.define(SearchPatternAllOf); + +interface SerializedAllOf { + '@type': 'AllOf'; + patterns: SerializedSearchPattern[]; +} + +/** + * Search pattern corresponding to {@link LookupPatternAnyOf}. + * + * @see {@link SearchCriterionAnyOf} + */ +export class SearchPatternAnyOf extends SearchPattern<{ + readonly patterns: readonly SearchPattern[]; +}> { + generate( + context: SearchPatternGenerateContext + ): LookupPattern | SearchPatternError { + const {patterns} = this.state; + if (patterns.length === 0) { + return { + type: 'error', + errors: [{ + target: this, + message: TranslatedText.text( + 'search_entities.error.required_at_least_one_pattern' + ), + }], + }; + } + const inner = SearchPattern.generateMany(patterns, context); + return Array.isArray(inner) ? {type: 'anyOf', patterns: inner} : inner; + } + + static readonly fromJSONType = 'AnyOf'; + + static fromJSON( + raw: SerializedAnyOf, + context: SearchPatternFromJsonContext + ): SearchPatternAnyOf { + const {patterns} = raw; + return new SearchPatternAnyOf({ + patterns: patterns.map(p => context.fromJson(p)), + }); + } + + toJSON(context: SearchPatternToJsonContext): SerializedAnyOf { + const {patterns} = this.state; + return { + '@type': 'AnyOf', + patterns: patterns.map(p => context.toJson(p)), + }; + } +} + +SearchPattern.define(SearchPatternAnyOf); + +interface SerializedAnyOf { + '@type': 'AnyOf'; + patterns: SerializedSearchPattern[]; +} + +/** + * Search pattern corresponding to {@link LookupPatternHasType}. + * + * @see {@link SearchCriterionHasType} + */ +export class SearchPatternHasType extends SearchPattern<{ + readonly elementType?: ElementTypeIri; +}> { + generate( + context: SearchPatternGenerateContext + ): LookupPattern | SearchPatternError { + const {elementType} = this.state; + if (!elementType) { + return { + type: 'error', + errors: [{ + target: this, + message: TranslatedText.text( + 'search_entities.error.required_element_type' + ), + }], + }; + } + return { + type: 'hasType', + elementType, + }; + } + + static readonly fromJSONType = 'HasType'; + + static fromJSON(raw: SerializedHasType): SearchPatternHasType { + const {elementType} = raw; + return new SearchPatternHasType({elementType}); + } + + toJSON(context: SearchPatternToJsonContext): SerializedHasType { + const {elementType} = this.state; + return { + '@type': 'HasType', + elementType, + }; + } +} + +SearchPattern.define(SearchPatternHasType); + +interface SerializedHasType { + '@type': 'HasType'; + elementType?: ElementTypeIri; +} + +/** + * Search pattern corresponding to {@link LookupPatternConnectedTo}. + * + * @see {@link SearchCriterionConnectedTo} + */ +export class SearchPatternConnectedTo extends SearchPattern<{ + readonly linkType?: LinkTypeIri; + readonly linkDirection?: 'in' | 'out'; + readonly other?: SearchPattern; +}> { + generate( + context: SearchPatternGenerateContext + ): LookupPattern | SearchPatternError { + const {linkType, linkDirection, other} = this.state; + const otherPattern = other?.generate(context); + if (otherPattern?.type === 'error') { + return otherPattern; + } + return { + type: 'connectedTo', + linkType, + linkDirection, + other: otherPattern, + }; + } + + static readonly fromJSONType = 'ConnectedTo'; + + static fromJSON( + raw: SerializedConnectedTo, + context: SearchPatternFromJsonContext + ): SearchPatternConnectedTo { + const {linkType, linkDirection, other} = raw; + return new SearchPatternConnectedTo({ + linkType, + linkDirection, + other: other ? context.fromJson(other) : undefined, + }); + } + + toJSON(context: SearchPatternToJsonContext): SerializedConnectedTo { + const {linkType, linkDirection, other} = this.state; + return { + '@type': 'ConnectedTo', + linkType, + linkDirection, + other: other ? context.toJson(other) : undefined, + }; + } +} + +SearchPattern.define(SearchPatternConnectedTo); + +interface SerializedConnectedTo { + '@type': 'ConnectedTo'; + linkType?: LinkTypeIri; + linkDirection?: 'in' | 'out'; + other?: SerializedSearchPattern; +} + +/** + * Search pattern corresponding to {@link LookupPatternFromValues}. + * + * @see {@link SearchCriterionFromValues} + */ +export class SearchPatternFromValues extends SearchPattern<{ + readonly values: readonly ElementIri[]; +}> { + generate( + context: SearchPatternGenerateContext + ): LookupPattern | SearchPatternError { + const {values} = this.state; + return { + type: 'fromValues', + values, + }; + } + + static readonly fromJSONType = 'FromValues'; + + static fromJSON(raw: SerializedFromValues): SearchPatternFromValues { + const {values} = raw; + return new SearchPatternFromValues({values}); + } + + toJSON(context: SearchPatternToJsonContext): SerializedFromValues { + const {values} = this.state; + return { + '@type': 'FromValues', + values, + }; + } +} + +SearchPattern.define(SearchPatternFromValues); + +interface SerializedFromValues { + '@type': 'FromValues'; + values: readonly ElementIri[]; +} + +/** + * Search pattern corresponding to {@link LookupPatternHasProperty}. + * + * @see {@link SearchCriterionHasProperty} + */ +export class SearchPatternHasProperty extends SearchPattern<{ + readonly propertyType?: PropertyTypeIri; + readonly values?: ReadonlyArray; +}> { + generate( + context: SearchPatternGenerateContext + ): LookupPattern | SearchPatternError { + const {propertyType, values} = this.state; + if (!propertyType) { + return { + type: 'error', + errors: [{ + target: this, + message: TranslatedText.text( + 'search_entities.error.required_property_type' + ), + }], + }; + } + return { + type: 'hasProperty', + propertyType, + values, + }; + } + + static readonly fromJSONType = 'HasProperty'; + + static fromJSON( + raw: SerializedHasProperty, + context: SearchPatternFromJsonContext + ): SearchPatternHasProperty { + const {propertyType, values} = raw; + return new SearchPatternHasProperty({ + propertyType, + values: values?.map(v => context.factory.fromTerm( + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + v as any + ) as Rdf.NamedNode | Rdf.Literal), + }); + } + + toJSON(context: SearchPatternToJsonContext): SerializedHasProperty { + const {propertyType, values} = this.state; + return { + '@type': 'HasProperty', + propertyType, + values, + }; + } +} + +SearchPattern.define(SearchPatternHasProperty); + +interface SerializedHasProperty { + '@type': 'HasProperty'; + propertyType?: PropertyTypeIri; + values?: readonly ( + | Pick + | Pick + )[]; +} diff --git a/src/widgets/unifiedSearch/builtinSearchSections.tsx b/src/widgets/unifiedSearch/builtinSearchSections.tsx index e38de8b5..2e26c172 100644 --- a/src/widgets/unifiedSearch/builtinSearchSections.tsx +++ b/src/widgets/unifiedSearch/builtinSearchSections.tsx @@ -6,7 +6,9 @@ import type { CanvasDropEvent } from '../../diagram/canvasApi'; import type { EntityElement } from '../../editor/dataElements'; import { ClassTree } from '../classTree'; -import { InstancesSearch, SearchCriteria } from '../instancesSearch'; +import { + InstancesSearch, SearchCriteria, SearchCriteriaOption, SearchPatternType, +} from '../instancesSearch'; import { LinkTypesToolbox } from '../linksToolbox'; import { InstancesSearchTopic } from '../../workspace/commandBusTopic'; @@ -88,8 +90,41 @@ export function SearchSectionEntities(props: { * @default 3 */ minSearchTermLength?: number; + /** + * Supported search criteria options to display and edit corresponding + * {@link SearchPattern search patterns}. + * + * Built-in criteria options are always included to display or edit their + * {@link SearchCriterionOption.type pattern types}, however these options + * can be overriden by including options for these pattern types instead. + * + * **Built-in criteria options**: + * - {@link SearchCriterionAllOf} + * - {@link SearchCriterionAnyOf} + * - {@link SearchCriterionHasType} + * - {@link SearchCriterionConnectedTo} + * - {@link SearchCriterionFromValues} + * - {@link SearchCriterionHasProperty} + */ + criteriaOptions?: readonly SearchCriteriaOption[]; + /** + * The order in which to display the built-in and extra search criteria options + * when adding a new search pattern from the UI. + * + * If specified, only options with corresponding types will be available + * for creation from the menu. + * + * **Default** order is built-in options then additional ones provided + * with {@link creatableOptions}. + */ + addCriteriaOrder?: readonly SearchPatternType[]; }) { - const {searchTimeout = 600, minSearchTermLength = 3} = props; + const { + searchTimeout = 600, + minSearchTermLength = 3, + criteriaOptions, + addCriteriaOrder, + } = props; const {getCommandBus} = useWorkspace(); const {shouldRender, setSectionActive, searchStore} = useUnifiedSearchSection({ searchTimeout, @@ -108,6 +143,8 @@ export function SearchSectionEntities(props: { return ( { if (shouldRender) { setSectionActive(true, criteriaAsSearchExtra(criteria)); diff --git a/src/widgets/unifiedSearch/unifiedSearch.tsx b/src/widgets/unifiedSearch/unifiedSearch.tsx index b2144d83..e75dd279 100644 --- a/src/widgets/unifiedSearch/unifiedSearch.tsx +++ b/src/widgets/unifiedSearch/unifiedSearch.tsx @@ -1,6 +1,7 @@ import cx from 'clsx'; import * as React from 'react'; +import { Events, EventSource, EventObserver } from '../../coreUtils/events'; import { useObservedProperty } from '../../coreUtils/hooks'; import type { HotkeyString } from '../../coreUtils/hotkey'; import { useTranslation } from '../../coreUtils/i18n'; @@ -273,12 +274,18 @@ export function UnifiedSearch(props: UnifiedSearchProps) { } }, [hasSearchQuery, setExpanded]); + const [innerSource] = React.useState(() => new EventSource()); + const onContentPositioned = React.useCallback(() => { + innerSource.trigger('positioned', {}); + }, [innerSource]); + return ( ); @@ -366,8 +374,12 @@ class ActiveSection { } } +interface InnerEvents { + positioned: Record; +} + function SearchToggle(props: { - inputRef: React.RefObject, + inputRef: React.RefObject; searchTerm: string; setSearchTerm: (term: string) => void; onSubmit: () => void; @@ -462,10 +474,11 @@ function SearchContent(props: { offsetForMaxSize: Vector; onResize: (size: Size) => void; setMaxSize: (size: Size) => void; + innerEvents: Events; }) { const { visible, sections, activeSectionKey, onActivateSection, - dropUp, size, minSize, offsetForMaxSize, onResize, setMaxSize, + dropUp, size, minSize, offsetForMaxSize, onResize, setMaxSize, innerEvents, } = props; const {canvas} = useCanvas(); @@ -476,52 +489,68 @@ function SearchContent(props: { readonly centerOffsetX: number; }>(undefined); - const updatePanelBounds = () => { - if (!panelRef.current) { - return; - } - const {x: rawX, y: rawY, width, height} = panelRef.current.getBoundingClientRect(); - const x = rawX + window.scrollX; - const y = rawY + window.scrollY; - const [alignmentX] = getParentDockAlignment(panelRef.current); - const viewport = canvas.metrics.getViewportPageRect(); - panelBounds.current = { - bounds: {x, y, width, height}, - // A workaround to have correct resizing behavior when the element - // is aligned to the center without sacrificing performance to avoid - // reading target bounds on every resize (causing a costly reflow) - centeredByX: alignmentX === 'center', - centerOffsetX: Math.max( - x * 2 + width - viewport.x * 2 - viewport.width, 0 - ), - }; - }; - - useViewportResizeHandler(() => { - if (!panelBounds.current) { - updatePanelBounds(); - } - - if (panelBounds.current) { - const {bounds, centeredByX, centerOffsetX} = panelBounds.current; + const handlers = { + updatePanelBounds: () => { + if (!(visible && panelRef.current)) { + return; + } + const {x: rawX, y: rawY, width, height} = panelRef.current.getBoundingClientRect(); + const x = rawX + window.scrollX; + const y = rawY + window.scrollY; + const [alignmentX] = getParentDockAlignment(panelRef.current); const viewport = canvas.metrics.getViewportPageRect(); - const viewportRight = viewport.x + viewport.width; - const viewportBottom = viewport.y + viewport.height; + panelBounds.current = { + bounds: {x, y, width, height}, + // A workaround to have correct resizing behavior when the element + // is aligned to the center without sacrificing performance to avoid + // reading target bounds on every resize (causing a costly reflow) + centeredByX: alignmentX === 'center', + centerOffsetX: Math.max( + x * 2 + width - viewport.x * 2 - viewport.width, 0 + ), + }; + }, + updateMaxSize: () => { + if (!panelBounds.current) { + handlers.updatePanelBounds(); + } + + if (panelBounds.current) { + const {bounds, centeredByX, centerOffsetX} = panelBounds.current; + const viewport = canvas.metrics.getViewportPageRect(); + const viewportRight = viewport.x + viewport.width; + const viewportBottom = viewport.y + viewport.height; - const proposedWidth = centeredByX - ? viewport.width - centerOffsetX - offsetForMaxSize.x - : viewportRight - bounds.x - offsetForMaxSize.x; + const proposedWidth = centeredByX + ? viewport.width - centerOffsetX - offsetForMaxSize.x + : viewportRight - bounds.x - offsetForMaxSize.x; - const proposedHeight = dropUp - ? bounds.y - viewport.y - offsetForMaxSize.y - : viewportBottom - bounds.y - offsetForMaxSize.y; + const proposedHeight = dropUp + ? bounds.y - viewport.y - offsetForMaxSize.y + : viewportBottom - bounds.y - offsetForMaxSize.y; - const maxWidth = Math.max(proposedWidth, minSize.width); - const maxHeight = Math.max(proposedHeight, minSize.height); + const maxWidth = Math.max(proposedWidth, minSize.width); + const maxHeight = Math.max(proposedHeight, minSize.height); - setMaxSize({width: maxWidth, height: maxHeight}); + setMaxSize({width: maxWidth, height: maxHeight}); + } } - }); + } as const; + + useViewportResizeHandler(handlers.updateMaxSize); + + const latestHandlers = React.useRef(handlers); + latestHandlers.current = handlers; + + React.useLayoutEffect(() => { + const listener = new EventObserver(); + listener.listen(innerEvents, 'positioned', () => { + const {updatePanelBounds, updateMaxSize} = latestHandlers.current; + updatePanelBounds(); + updateMaxSize(); + }); + return () => listener.stopListening(); + }, [latestHandlers, innerEvents]); const onDrag = (e: MouseEvent, dx: number, dy: number) => { if (panelBounds.current) { @@ -583,19 +612,19 @@ function SearchContent(props: { ))}

diff --git a/src/widgets/utility/autocomplete.tsx b/src/widgets/utility/autocomplete.tsx new file mode 100644 index 00000000..ee45b9ca --- /dev/null +++ b/src/widgets/utility/autocomplete.tsx @@ -0,0 +1,319 @@ +import cx from 'clsx'; +import * as React from 'react'; + +import { EventObserver } from '../../coreUtils/events'; +import { useAsync, UseAsyncResult, useObservedProperty } from '../../coreUtils/hooks'; +import { useTranslation } from '../../coreUtils/i18n'; +import { HtmlSpinner } from '../../diagram/spinner'; + +import { Dropdown } from './dropdown'; +import { ErrorMessage } from './errorMessage'; +import { + FocusGroup, FocusGroupController, useFocusGroup, +} from './focusGroup'; +import { NoSearchResults } from './noSearchResults'; +import { ProgressBar } from './progressBar'; +import { SearchInput, SearchInputStore, useSearchInputStore } from './searchInput'; + +const CLASS_NAME = 'reactodia-autocomplete'; + +export function Autocomplete(props: { + /** + * Additional CSS class for the component. + */ + className?: string; + inlineContent: React.ReactNode | undefined; + inlineStatus?: 'loading' | 'error'; + suggest: (text: string, options: { signal: AbortSignal }) => Promise; + suggestDefault?: (options: { signal: AbortSignal }) => Promise; + suggestDefaultComparator?: (a: T, b: T) => number; + /** + * Input dropdown expand direction. + * + * @default "down" + */ + direction?: 'down' | 'up'; + /** + * Debounce timeout in milliseconds after input to perform the text search. + * + * @default 200 + */ + searchTimeout?: number; + /** + * Minimum number of characters in the search term to initiate the search. + * + * @default 2 + */ + minSearchTermLength?: number; + /** + * Placeholder text for the input. + * + * @default "Search for..." + */ + placeholder?: string; + /** + * @default "m" + */ + sizeVariant?: 'm' | 's'; + children: (params: { + items: readonly T[]; + highlightText: string; + }) => React.ReactNode; +}) { + const { + className, + inlineContent, + inlineStatus, + suggest, + suggestDefault, + suggestDefaultComparator, + direction, + searchTimeout = 200, + minSearchTermLength = 2, + placeholder, + sizeVariant = 'm', + children, + } = props; + + const [expanded, setExpanded] = React.useState(false); + const searchStore = useSearchInputStore({ + initialValue: '', + submitTimeout: searchTimeout, + allowSubmit: text => text.length >= minSearchTermLength, + }); + const searchTerm = useObservedProperty( + searchStore.events, 'changeValue', () => searchStore.value + ); + + const cancellation = React.useRef(); + const [suggestions, setSuggestions] = React.useState>({ + data: undefined, + status: 'completed', + }); + + React.useEffect(() => { + const listener = new EventObserver(); + listener.listen(searchStore.events, 'executeSearch', ({value}) => { + cancellation.current?.abort(); + cancellation.current = new AbortController(); + const signal = cancellation.current.signal; + setSuggestions(previous => ({data: previous.data, status: 'loading'})); + suggest(value, {signal}).then( + items => { + setSuggestions({data: items, status: 'completed'}); + }, + error => { + setSuggestions(previous => ({data: previous.data, status: 'error'})); + console.error(error); + } + ); + }); + listener.listen(searchStore.events, 'clearSearch', () => { + cancellation.current?.abort(); + setSuggestions({data: undefined, status: 'completed'}); + }); + }, [searchStore, suggest, setSuggestions]); + + React.useEffect(() => { + return () => cancellation.current?.abort(); + }, []); + + const dropdownRef = React.useRef(null); + const toggleInputRef = React.useRef(null); + + const hasSearchText = searchTerm.length > 0; + + const showDefaultSuggestions = suggestDefault && expanded && !hasSearchText; + const defaultSuggestions = useAsync({ + input: [suggestDefault, showDefaultSuggestions], + load: ([loadDefault, shouldLoad], {signal}) => + shouldLoad ? loadDefault?.({signal}) : undefined, + }); + + const onClickOutside = React.useCallback(() => { + if (!hasSearchText) { + setExpanded(false); + } + }, [hasSearchText, setExpanded]); + + React.useLayoutEffect(() => { + const container = dropdownRef.current; + if (container) { + const onFocusout = (e: FocusEvent) => { + if (!( + e.relatedTarget instanceof Node && + container.contains(e.relatedTarget) + )) { + setExpanded(false); + } + }; + container.addEventListener('focusout', onFocusout); + return () => container.removeEventListener('focusout', onFocusout); + } + }, [dropdownRef]); + + const displayedResult = React.useMemo(() => { + if (showDefaultSuggestions) { + return { + ...defaultSuggestions, + data: defaultSuggestions.data + ? [...defaultSuggestions.data].sort(suggestDefaultComparator) + : undefined, + }; + } else { + return suggestions; + } + }, [showDefaultSuggestions, defaultSuggestions, suggestDefaultComparator, suggestions]); + + let content: React.ReactNode; + if (displayedResult.status === 'error') { + content = ; + } else if (displayedResult.data && displayedResult.data.length > 0) { + content = children({ items: displayedResult.data, highlightText: searchTerm }); + } else { + content = ( + + ); + } + + return ( + + {({ref}) => ( + + } + onClick={() => toggleInputRef.current?.focus()}> + + + {content} + + + )} + + ); +} + +function AutocompleteToggle(props: { + inputRef: React.RefObject, + searchStore: SearchInputStore; + hasSearchText: boolean; + inlineContent: React.ReactNode; + inlineStatus: 'loading' | 'error' | undefined; + expanded: boolean; + setExpanded: (expanded: boolean) => void; + placeholder?: string; + sizeVariant: 'm' | 's'; +}) { + const { + inputRef, searchStore, hasSearchText, inlineContent, inlineStatus, + placeholder, sizeVariant, expanded, setExpanded, + } = props; + const t = useTranslation(); + + return ( + , + className: `${CLASS_NAME}__toggle-input`, + placeholder, + onClick: () => setExpanded(true), + onFocus: () => setExpanded(true), + onKeyUp: e => { + switch (e.key) { + case 'Enter': { + if (e.currentTarget.value.length > 0) { + setExpanded(true); + } + break; + } + case 'Escape': { + setExpanded(false); + break; + } + default: { + setExpanded(true); + break; + } + } + } + }} + slotInput={ + <> + {inlineContent} + {inlineStatus ? ( + + ) : null} + + } + slotEnd={ + hasSearchText ? undefined : ( + + ) + } + /> + ); +} + +function AutocompleteItems(props: { + innerRef: (element: HTMLElement | null) => void; + visible: boolean; + dropUp: boolean; + setExpanded: (nextExpanded: boolean) => void; + children: React.ReactNode; +}) { + const {innerRef, visible, dropUp, setExpanded, children} = props; + const controller = useFocusGroup(); + + const onKeyDown = (e: React.KeyboardEvent, controller: FocusGroupController) => { + if (e.key === 'Escape') { + setExpanded(false); + } else { + controller.defaultKeyDown(e); + } + }; + + return ( +
onKeyDown(e, controller)}> + {children} +
+ ); +} diff --git a/src/widgets/utility/dropdown.tsx b/src/widgets/utility/dropdown.tsx index e7120b1c..9c05f2b0 100644 --- a/src/widgets/utility/dropdown.tsx +++ b/src/widgets/utility/dropdown.tsx @@ -1,6 +1,9 @@ +import { autoUpdate, size, useFloating } from '@floating-ui/react-dom'; import cx from 'clsx'; import * as React from 'react'; +import { useCombinedRef } from '../../coreUtils/hooks'; + import { FocusGroup, FocusGroupController, useFocusGroup, useFocusGroupItem, } from './focusGroup'; @@ -15,6 +18,10 @@ export interface DropdownProps extends React.HTMLAttributes { * Additional CSS class for the component. */ className?: string; + /** + * `ref` for the inner DOM element. + */ + innerRef?: React.Ref; /** * Dropdown expand direction. * @@ -39,6 +46,11 @@ export interface DropdownProps extends React.HTMLAttributes { * Handler for clicks outside the dropdown DOM element. */ onClickOutside?: () => void; + /** + * Handler for dropdown content panel position changes + * e.g. when the dropdown becomes expanded. + */ + onContentPositioned?: () => void; } const CLASS_NAME = 'reactodia-dropdown'; @@ -50,16 +62,41 @@ const CLASS_NAME = 'reactodia-dropdown'; */ export function Dropdown(props: DropdownProps) { const { - className, direction = 'down', expanded, toggle, onClickOutside, children, - ...otherProps + className, innerRef, direction = 'down', expanded, toggle, onClickOutside, + onContentPositioned, children, ...otherProps } = props; - const menuRef = React.useRef(null); + + const {refs, floatingStyles, isPositioned} = useFloating({ + open: expanded, + placement: direction === 'up' ? 'top-start' : 'bottom-start', + strategy: 'fixed', + whileElementsMounted: autoUpdate, + middleware: [ + size({ + apply({elements}) { + const referenceBounds = elements.reference.getBoundingClientRect(); + elements.floating.style.setProperty( + '--reactodia-dropdown-toggle-width', + `${referenceBounds.width}px`, + ); + }, + }), + ], + }); + + const passedRef = useCombinedRef(refs.setReference, innerRef); + + React.useEffect(() => { + if (isPositioned) { + onContentPositioned?.(); + } + }, [onContentPositioned, isPositioned]); React.useLayoutEffect(() => { if (onClickOutside && expanded) { const closeMenu = (e: PointerEvent) => { // Auto-close menu on clicks from outside - if (e.target instanceof Node && !menuRef.current?.contains(e.target)) { + if (e.target instanceof Node && !refs.reference.current?.contains(e.target)) { onClickOutside(); } }; @@ -70,7 +107,7 @@ export function Dropdown(props: DropdownProps) { return (
{direction === 'down' ? toggle : null} -
+
{children}
{direction === 'up' ? toggle : null} @@ -102,6 +142,10 @@ export interface DropdownMenuProps extends React.HTMLAttributes { * @default "down" */ direction?: 'down' | 'up'; + /** + * Custom toggle menu button component. + */ + toggle?: React.ReactElement; /** * Title for the toggle menu button. */ @@ -120,7 +164,7 @@ const MENU_CLASS_NAME = 'reactodia-dropdown-menu'; * @category Components */ export function DropdownMenu(props: DropdownMenuProps) { - const {className, direction, title, children, ...otherProps} = props; + const {className, direction, toggle, title, children, ...otherProps} = props; const [expanded, setExpanded] = React.useState(false); const providedContext = React.useMemo( (): DropdownMenuContext => ({expanded, setExpanded}), @@ -144,7 +188,7 @@ export function DropdownMenu(props: DropdownMenuProps) { direction={direction} expanded={expanded} toggle={ - + } onClickOutside={onClickOutside}>
{ @@ -185,7 +232,7 @@ function DropdownMenuToggleButton(props: { title?: string }) { controller.ensureFocusable({reset: true}); } }, [expanded]); - return ( + return component ?? (
+ ); +} + +function tryFormatError(error: unknown): string | undefined { + if (error instanceof Error) { + return error.message || error.name; + } + return undefined; +} diff --git a/src/widgets/utility/inlineEntity.tsx b/src/widgets/utility/inlineEntity.tsx index dd1b4666..25d1b2c1 100644 --- a/src/widgets/utility/inlineEntity.tsx +++ b/src/widgets/utility/inlineEntity.tsx @@ -1,3 +1,4 @@ +import cx from 'clsx'; import * as React from 'react'; import { useTranslation } from '../../coreUtils/i18n'; @@ -11,9 +12,15 @@ import { formatEntityTitle } from '../utility/listElementView'; const CLASS_NAME = 'reactodia-inline-entity'; export function InlineEntity(props: { + className?: string; target: ElementModel; + /** + * @default false + */ + block?: boolean; + children?: React.ReactNode; }) { - const {target} = props; + const {className, target, block, children} = props; const {model, getElementTypeStyle} = useWorkspace(); const t = useTranslation(); const label = model.locale.formatEntityLabel(target, model.language); @@ -21,13 +28,15 @@ export function InlineEntity(props: { const style = { '--reactodia-inline-entity-color': color, } as React.CSSProperties; + const Tag = block ? 'div' : 'span'; return ( - - + {label} - - + + {children} + ); } diff --git a/src/widgets/utility/listElementView.tsx b/src/widgets/utility/listElementView.tsx index 9ec0012e..c3143705 100644 --- a/src/widgets/utility/listElementView.tsx +++ b/src/widgets/utility/listElementView.tsx @@ -93,7 +93,7 @@ export function ListElementView(props: ListElementViewProps) { ref={ref as React.Ref} className={combinedClass} role={otherProps.role ?? 'option'} - draggable={!disabled && Boolean(onDragStart)} + draggable={!disabled && Boolean(onDragStart) ? true : undefined} title={otherProps.title ?? formatEntityTitle(element, model, t)} style={{...otherProps.style, ...providedStyle}} onClick={onItemClick} diff --git a/src/widgets/utility/schemaTypeView.tsx b/src/widgets/utility/schemaTypeView.tsx new file mode 100644 index 00000000..0721bcc2 --- /dev/null +++ b/src/widgets/utility/schemaTypeView.tsx @@ -0,0 +1,99 @@ +import cx from 'clsx'; +import * as React from 'react'; + +import { useEventStore, useSyncStore } from '../../coreUtils/hooks'; +import { useTranslation } from '../../coreUtils/i18n'; +import { + ElementTypeIri, ElementTypeModel, PropertyTypeModel, LinkTypeModel, +} from '../../data/model'; +import { useWorkspace } from '../../workspace/workspaceContext'; + +import { highlightSubstring } from './listElementView'; + +export type SchemaType = { + elementType: ElementTypeModel; + propertyType: PropertyTypeModel; + linkType: LinkTypeModel; +}; + +export interface SchemaTypeViewProps + extends React.HTMLProps +{ + typeKind: Kind; + typeData: SchemaType[Kind]; + label?: string; + highlightText?: string; + selected?: boolean; + sizeVariant?: 'm' | 's'; + inline?: boolean; +} + +const CLASS_NAME = 'reactodia-schema-type-view'; + +export function SchemaTypeView( + props: SchemaTypeViewProps +) { + const { + typeKind, typeData, label, highlightText, selected, + sizeVariant = 'm', + inline = false, + ...restProps + } = props; + const t = useTranslation(); + const {model, getElementTypeStyle} = useWorkspace(); + + const language = useSyncStore( + useEventStore(label === undefined ? undefined : model.events, 'changeLanguage'), + () => model.language + ); + const typeLabel = label ?? t.formatLabel(typeData.label, typeData.id, language); + const highlightedLabel = highlightSubstring( + typeLabel, highlightText, {className: `${CLASS_NAME}__highlighted-term`} + ); + + const typeStyle = getElementTypeStyle([typeData.id as ElementTypeIri]); + const providedStyle = { + '--reactodia-element-style-color': typeStyle.color, + } as React.CSSProperties; + + const typeKindClass = ( + typeKind === 'elementType' ? `${CLASS_NAME}--element-type` : + typeKind === 'propertyType' ? `${CLASS_NAME}--property-type` : + typeKind === 'linkType' ? `${CLASS_NAME}--link-type` : + undefined + ); + + return ( + e.preventDefault())} + style={{...providedStyle, ...restProps.style}} + href={typeData.id}> +
+ {typeStyle.icon ? ( + + ) : ( +
+ )} +
+
+ {highlightedLabel} +
+ + {restProps.children} +
+ ); +} diff --git a/src/widgets/utility/searchInput.tsx b/src/widgets/utility/searchInput.tsx index 0d75dcf7..dcafd953 100644 --- a/src/widgets/utility/searchInput.tsx +++ b/src/widgets/utility/searchInput.tsx @@ -10,6 +10,10 @@ export interface SearchInputProps { className?: string; inputProps?: React.HTMLProps & DataAttributes; store: SearchInputStore; + /** @default "m" */ + sizeVariant?: 'm' | 's'; + slotInput?: React.ReactNode; + slotEnd?: React.ReactNode; children?: React.ReactNode; } @@ -19,7 +23,7 @@ const CLASS_NAME = 'reactodia-search-input'; export function SearchInput(props: SearchInputProps) { const { - className, inputProps = {}, store, children, + className, inputProps = {}, store, sizeVariant = 'm', slotInput, slotEnd, children, } = props; const t = useTranslation(); @@ -30,36 +34,37 @@ export function SearchInput(props: SearchInputProps) {
- store.change({value: e.currentTarget.value, action: 'input'})} - onKeyUp={e => { - if (e.key === 'Enter') { - store.change({value: store.value, action: 'submit'}); - } - }} - /> - {store.value ? ( -
+
+ {slotInput} + store.change({value: e.currentTarget.value, action: 'input'})} + onKeyUp={e => { + if (e.key === 'Enter') { + store.change({value: store.value, action: 'submit'}); + } + inputProps.onKeyUp?.(e); + }} + /> +
+
+ {store.value ? ( -
- ) : null} + ) : slotEnd} +
{mode === 'explicit' ? (