Skip to content

Commit

Permalink
geosolutions-it#6886: Configurable CSW filters (geosolutions-it#7220)
Browse files Browse the repository at this point in the history
  • Loading branch information
dsuren1 committed Sep 21, 2021
1 parent c39ee1d commit 68218c3
Show file tree
Hide file tree
Showing 17 changed files with 375 additions and 73 deletions.
15 changes: 15 additions & 0 deletions docs/developer-guide/local-config.md
Expand Up @@ -180,6 +180,21 @@ Set `selectedService` value to one of the ID of the services object ("Demo CSW S
"format": "image/png8", // image format needs to be configured also inside layerOptions
"tileSize": 512 // determine the default tile size for the catalog, valid for WMS and CSW catalogs
},
"filter": { // applicable only for CSW service
"staticFilter": "filter is always applied, even when search text is NOT PRESENT",
"dynamicFilter": "filter is used when search text is PRESENT and is applied in `AND` with staticFilter. The template is used with ${searchText} placeholder to append search string"
}
}
```
CSW service
<br> `filter` - For both static and dynamic filter, input only xml element contained within <ogc:Filter> (i.e. Do not enclose the filter value in <ogc:Filter>)<br>
<br>Example:<br>
```javascript
{
"filter": { // Default filter values
"staticFilter": "<ogc:Or><ogc:PropertyIsEqualTo><ogc:PropertyName>dc:type</ogc:PropertyName><ogc:Literal>dataset</ogc:Literal></ogc:PropertyIsEqualTo><ogc:PropertyIsEqualTo><ogc:PropertyName>dc:type</ogc:PropertyName><ogc:Literal>http://purl.org/dc/dcmitype/Dataset</ogc:Literal></ogc:PropertyIsEqualTo></ogc:Or>",
"dynamicFilter": "<ogc:PropertyIsLike wildCard='%' singleChar='_' escapeChar='\\'><ogc:PropertyName>csw:AnyText</ogc:PropertyName><ogc:Literal>%${searchText}%</ogc:Literal></ogc:PropertyIsLike>"
}
}
```

Expand Down
111 changes: 51 additions & 60 deletions web/client/api/CSW.js
Expand Up @@ -8,7 +8,7 @@

import urlUtil from 'url';

import { head, last } from 'lodash';
import { head, last, template } from 'lodash';
import assign from 'object-assign';

import axios from '../libs/ajax';
Expand All @@ -25,22 +25,8 @@ const parseUrl = (url) => {
}));
};

export const constructXMLBody = (startPosition, maxRecords, searchText) => {
if (!searchText) {
return `<csw:GetRecords xmlns:csw="http://www.opengis.net/cat/csw/2.0.2"
xmlns:ogc="http://www.opengis.net/ogc"
xmlns:gml="http://www.opengis.net/gml"
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:dct="http://purl.org/dc/terms/"
xmlns:gmd="http://www.isotc211.org/2005/gmd"
xmlns:gco="http://www.isotc211.org/2005/gco"
xmlns:gmi="http://www.isotc211.org/2005/gmi"
xmlns:ows="http://www.opengis.net/ows" service="CSW" version="2.0.2" resultType="results" startPosition="${startPosition}" maxRecords="${maxRecords}">
<csw:Query typeNames="csw:Record">
<csw:ElementSetName>full</csw:ElementSetName>
<csw:Constraint version="1.1.0">
<ogc:Filter>
<ogc:Or>
const defaultStaticFilter =
`<ogc:Or>
<ogc:PropertyIsEqualTo>
<ogc:PropertyName>dc:type</ogc:PropertyName>
<ogc:Literal>dataset</ogc:Literal>
Expand All @@ -49,45 +35,50 @@ export const constructXMLBody = (startPosition, maxRecords, searchText) => {
<ogc:PropertyName>dc:type</ogc:PropertyName>
<ogc:Literal>http://purl.org/dc/dcmitype/Dataset</ogc:Literal>
</ogc:PropertyIsEqualTo>
</ogc:Or>
</ogc:Filter>
</csw:Constraint>
</csw:Query>
</csw:GetRecords>`;
}
return `<csw:GetRecords xmlns:csw="http://www.opengis.net/cat/csw/2.0.2"
xmlns:ogc="http://www.opengis.net/ogc"
xmlns:gml="http://www.opengis.net/gml"
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:dct="http://purl.org/dc/terms/"
xmlns:gmd="http://www.isotc211.org/2005/gmd"
xmlns:gco="http://www.isotc211.org/2005/gco"
xmlns:gmi="http://www.isotc211.org/2005/gmi"
xmlns:ows="http://www.opengis.net/ows" service="CSW" version="2.0.2" resultType="results" startPosition="${startPosition}" maxRecords="${maxRecords}">
<csw:Query typeNames="csw:Record">
<csw:ElementSetName>full</csw:ElementSetName>
<csw:Constraint version="1.1.0">
<ogc:Filter>
<ogc:And>
<ogc:PropertyIsLike wildCard="%" singleChar="_" escapeChar="\\">
<ogc:PropertyName>csw:AnyText</ogc:PropertyName>
<ogc:Literal>%${searchText}%</ogc:Literal>
</ogc:PropertyIsLike>
<ogc:Or>
<ogc:PropertyIsEqualTo>
<ogc:PropertyName>dc:type</ogc:PropertyName>
<ogc:Literal>dataset</ogc:Literal>
</ogc:PropertyIsEqualTo>
<ogc:PropertyIsEqualTo>
<ogc:PropertyName>dc:type</ogc:PropertyName>
<ogc:Literal>http://purl.org/dc/dcmitype/Dataset</ogc:Literal>
</ogc:PropertyIsEqualTo>
</ogc:Or>
</ogc:And>
</ogc:Filter>
</csw:Constraint>
</csw:Query>
</csw:GetRecords>`;
</ogc:Or>`;

const defaultDynamicFilter = "<ogc:PropertyIsLike wildCard='%' singleChar='_' escapeChar='\\'>" +
"<ogc:PropertyName>csw:AnyText</ogc:PropertyName> " +
"<ogc:Literal>%${searchText}%</ogc:Literal> " +
"</ogc:PropertyIsLike> ";

export const cswGetRecordsXml = '<csw:GetRecords xmlns:csw="http://www.opengis.net/cat/csw/2.0.2" ' +
'xmlns:ogc="http://www.opengis.net/ogc" ' +
'xmlns:gml="http://www.opengis.net/gml" ' +
'xmlns:dc="http://purl.org/dc/elements/1.1/" ' +
'xmlns:dct="http://purl.org/dc/terms/" ' +
'xmlns:gmd="http://www.isotc211.org/2005/gmd" ' +
'xmlns:gco="http://www.isotc211.org/2005/gco" ' +
'xmlns:gmi="http://www.isotc211.org/2005/gmi" ' +
'xmlns:ows="http://www.opengis.net/ows" service="CSW" version="2.0.2" resultType="results" startPosition="${startPosition}" maxRecords="${maxRecords}"> ' +
'<csw:Query typeNames="csw:Record"> ' +
'<csw:ElementSetName>full</csw:ElementSetName> ' +
'<csw:Constraint version="1.1.0"> ' +
'<ogc:Filter> ' +
'${filterXml} ' +
'</ogc:Filter> ' +
'</csw:Constraint> ' +
'</csw:Query> ' +
'</csw:GetRecords>';

/**
* Construct XML body to get records from the CSW service
* @param {object} [options] the options to pass to withIntersectionObserver enhancer.
* @param {number} startPosition
* @param {number} maxRecords
* @param {string} searchText
* @param {object} filter object holds static and dynamic filter configured for the CSW service
* @param {object} filter.staticFilter filter to fetch all record applied always i.e even when no search text is present
* @param {object} filter.dynamicFilter filter when search text is present and is applied in conjunction with static filter
* @return {string} constructed xml string
*/
export const constructXMLBody = (startPosition, maxRecords, searchText, {filter} = {}) => {
const staticFilter = filter?.staticFilter || defaultStaticFilter;
const dynamicFilter = `<ogc:And>
${template(filter?.dynamicFilter || defaultDynamicFilter)({searchText})}
${staticFilter}
</ogc:And>`;
return template(cswGetRecordsXml)({filterXml: !searchText ? staticFilter : dynamicFilter, startPosition, maxRecords});
};

/**
Expand Down Expand Up @@ -148,7 +139,7 @@ var Api = {
});
});
},
getRecords: function(url, startPosition, maxRecords, filter) {
getRecords: function(url, startPosition, maxRecords, filter, options) {
return new Promise((resolve) => {
require.ensure(['../utils/ogc/CSW', '../utils/ogc/Filter'], () => {
const {CSW, marshaller, unmarshaller } = require('../utils/ogc/CSW');
Expand All @@ -157,7 +148,7 @@ var Api = {
value: CSW.getRecords(startPosition, maxRecords, typeof filter !== "string" && filter)
});
if (!filter || typeof filter === "string") {
body = constructXMLBody(startPosition, maxRecords, filter);
body = constructXMLBody(startPosition, maxRecords, filter, options);
}
resolve(axios.post(parseUrl(url), body, { headers: {
'Content-Type': 'application/xml'
Expand Down Expand Up @@ -270,9 +261,9 @@ var Api = {
});
});
},
textSearch: function(url, startPosition, maxRecords, text) {
textSearch: function(url, startPosition, maxRecords, text, options) {
return new Promise((resolve) => {
resolve(Api.getRecords(url, startPosition, maxRecords, text));
resolve(Api.getRecords(url, startPosition, maxRecords, text, options));
});
},
workspaceSearch: function(url, startPosition, maxRecords, text, workspace) {
Expand Down
15 changes: 15 additions & 0 deletions web/client/api/__tests__/CSW-test.js
Expand Up @@ -156,4 +156,19 @@ describe("constructXMLBody", () => {
const body2 = constructXMLBody(1, 5, null);
expect(body2.indexOf("PropertyIsEqualTo")).toNotBe(-1);
});
it("construct body with custom filter", () => {
const filter = {
staticFilter: "<ogc:Or><ogc:PropertyIsEqualTo><ogc:PropertyName>dc:type</ogc:PropertyName><ogc:Literal>dataset</ogc:Literal></ogc:PropertyIsEqualTo></ogc:Or>",
dynamicFilter: "<ogc:PropertyIsLike wildCard='*' singleChar='_' escapeChar='\\'><ogc:PropertyName>csw:AnyText</ogc:PropertyName><ogc:Literal>${searchText}*</ogc:Literal></ogc:PropertyIsLike>"
};
// With search text
let body = constructXMLBody(1, 5, "text", {filter});

expect(body.indexOf("text*")).toNotBe(-1); // Dynamic filter

// Empty search
body = constructXMLBody(1, 5, null);
expect(body.indexOf("dc:type")).toNotBe(-1); // Static filter
expect(body.indexOf("text*")).toBe(-1); // Dynamic filter
});
});
18 changes: 14 additions & 4 deletions web/client/components/catalog/CompactCatalog.jsx
Expand Up @@ -74,10 +74,20 @@ const PAGE_SIZE = 10;
/*
* retrieves data from a catalog service and converts to props
*/
const loadPage = ({text, catalog = {}}, page = 0) => Rx.Observable
.fromPromise(API[catalog.type].textSearch(catalog.url, page * PAGE_SIZE + (catalog.type === "csw" ? 1 : 0), PAGE_SIZE, text, catalog.type === 'tms' ? {options: {service: catalog}} : {}))
.map((result) => ({ result, records: getCatalogRecords(catalog.type, result || [], { url: catalog && catalog.url, service: catalog })}))
.map(({records, result}) => resToProps({records, result, catalog}));
const loadPage = ({text, catalog = {}}, page = 0) => {
const type = catalog.type;
const _tempOption = {options: {service: catalog}};
let options = {};
if (type === 'csw') {
options = {..._tempOption, filter: catalog.filter};
} else if (type === 'tms') {
options = _tempOption;
}
return Rx.Observable
.fromPromise(API[type].textSearch(catalog.url, page * PAGE_SIZE + (type === "csw" ? 1 : 0), PAGE_SIZE, text, options))
.map((result) => ({ result, records: getCatalogRecords(type, result || [], { url: catalog && catalog.url, service: catalog })}))
.map(({records, result}) => resToProps({records, result, catalog}));
};
const scrollSpyOptions = {querySelector: ".ms2-border-layout-body .ms2-border-layout-content", pageSize: PAGE_SIZE};
/**
* Compat catalog : Reusable catalog component, with infinite scroll.
Expand Down
129 changes: 129 additions & 0 deletions web/client/components/catalog/editor/AdvancedSettings/CSWFilters.jsx
@@ -0,0 +1,129 @@
import React, { useState, useEffect } from "react";
import {
Col,
ControlLabel,
FormGroup,
Glyphicon,
Tooltip,
HelpBlock
} from "react-bootstrap";
import { Controlled as CodeMirror } from "react-codemirror2";
import "codemirror/lib/codemirror.css";
import "codemirror/theme/material.css";
import "codemirror/mode/xml/xml";
import "codemirror/addon/lint/lint";
import "codemirror/addon/display/autorefresh";
import template from "lodash/template";
import isEqual from "lodash/isEqual";
import { cswGetRecordsXml } from "../../../../api/CSW";
import OverlayTrigger from "../../../misc/OverlayTrigger";
import Message from "../../../I18N/Message";

const options = {
mode: "xml",
theme: "material",
lineNumbers: true,
lineWrapping: true,
autoRefresh: true,
indentUnit: 2,
tabSize: 2
};
const FilterInfo = ({ glyph = "info-sign", className = "", tooltip }) => (
<OverlayTrigger placement={"bottom"} overlay={tooltip}>
<Glyphicon
style={{ marginLeft: 4 }}
glyph={glyph}
className={className}
/>
</OverlayTrigger>
);

const tooltip = _type => {
let msgId = `catalog.filter.${_type}.info`;
if (_type === "error") msgId = `catalog.filter.error`;
return (
<Tooltip id={"filter"}>
<Message msgId={msgId} />
</Tooltip>
);
};
const renderError = (
<FilterInfo
tooltip={tooltip("error")}
glyph={"exclamation-mark"}
className={"text-danger"}
/>
);
const renderHelpText = (
<HelpBlock style={{ fontSize: 12 }}>
<Message msgId={`catalog.filter.dynamic.helpText`} />
</HelpBlock>
);

const FilterCode = ({ type, code, setCode, error }) => {
const filterProp = `${type}Filter`;
return (
<FormGroup>
<Col xs={4}>
<ControlLabel>
<Message msgId={`catalog.filter.${type}.label`} />
</ControlLabel>
<FilterInfo tooltip={tooltip(type)} />
{error[type] && renderError}
</Col>
<Col xs={8} style={{ marginBottom: 5 }}>
<CodeMirror
value={code[filterProp]}
options={options}
onBeforeChange={(_, __, value) => {
setCode({ ...code, [filterProp]: value });
}}
/>
{type === 'dynamic' && renderHelpText}
</Col>
</FormGroup>
);
};

export default ({
onChangeServiceProperty = () => {},
filter: { staticFilter, dynamicFilter } = {}
} = {}) => {
const [error, setError] = useState({});
const [code, setCode] = useState({ staticFilter, dynamicFilter });

const cmProps = { code, setCode, error };
const isValid = value => {
const _filter = template(cswGetRecordsXml)({
filterXml: value,
startPosition: 1,
maxRecords: 4
});
return !new DOMParser()
.parseFromString(_filter, "application/xml")
?.getElementsByTagName("parsererror")?.length;
};

useEffect(() => {
const validFt = isValid(code.staticFilter);
validFt &&
!isEqual(code.staticFilter, staticFilter) &&
onChangeServiceProperty("filter.staticFilter", code.staticFilter);
setError({ ...error, "static": !validFt });
}, [code.staticFilter]);

useEffect(() => {
const validFt = isValid(code.dynamicFilter);
validFt &&
!isEqual(code.dynamicFilter, dynamicFilter) &&
onChangeServiceProperty("filter.dynamicFilter", code.dynamicFilter);
setError({ ...error, dynamic: !validFt });
}, [code.dynamicFilter]);

return (
<div className={"catalog-csw-filters"}>
<FilterCode type={"static"} {...cmProps} />
<FilterCode type={"dynamic"} {...cmProps} />
</div>
);
};
Expand Up @@ -12,6 +12,7 @@ import { FormGroup, Checkbox, Col } from "react-bootstrap";

import Message from "../../../I18N/Message";
import InfoPopover from '../../../widgets/widget/InfoPopover';
import CSWFilters from "./CSWFilters";

/**
* Common Advanced settings form, used by WMS/CSW/WMTS
Expand Down Expand Up @@ -96,5 +97,8 @@ export default ({
</Col>
</FormGroup>)}
{children}
{!isNil(service.type) && service.type === "csw" &&
<CSWFilters filter={service?.filter} onChangeServiceProperty={onChangeServiceProperty}/>
}
</div>
);

0 comments on commit 68218c3

Please sign in to comment.