Skip to content

Commit 2b3d4b6

Browse files
authored
#12509 Expose documents resources in GeoNode catalog service (#12549)
Fix #12509 Expose documents resources in GeoNode catalog service
1 parent 2b4083c commit 2b3d4b6

33 files changed

Lines changed: 812 additions & 33 deletions

web/client/api/GeoNode.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ import { getConfigProp } from '../utils/ConfigUtils';
2020
import { resolveApiPresetParams, paramsSerializer, mergePresetParams } from '../utils/GeoNodeUtils';
2121

2222
export const GEONODE_RESOURCE_TYPE_FILTER = 'filter{resource_type.in}';
23+
// default sort applied to the catalog resources request when the caller does not provide one
24+
// (matches the "Most recent" default shown in the catalog toolbar)
25+
export const GEONODE_DEFAULT_SORT = '-date';
2326

2427

2528
export const RESOURCES = 'resources';
@@ -211,7 +214,7 @@ export const getRecords = (url, startPosition, maxRecords, text, options) => {
211214
baseUrl: url,
212215
...(resourceTypes.length && { [GEONODE_RESOURCE_TYPE_FILTER]: resourceTypes }),
213216
...options?.options?.filters,
214-
sort: options?.options?.sort,
217+
sort: options?.options?.sort ?? service?.defaultSort ?? GEONODE_DEFAULT_SORT,
215218
...(service?.apiPresetKey && { apiPresetKey: service.apiPresetKey })
216219
});
217220
};

web/client/api/__tests__/GeoNode-test.js

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,4 +193,32 @@ describe('Test correctness of the GeoNode APIs (mock axios)', () => {
193193
}
194194
});
195195
});
196+
197+
it('getRecords applies the default sort when none is provided', (done) => {
198+
mockAxios.onGet().reply((config) => {
199+
try {
200+
expect(config.params.sort).toEqual(['-date']);
201+
} catch (e) {
202+
done(e);
203+
}
204+
return [200, { resources: [], total: 0, page: 1, page_size: 4, links: {} }];
205+
});
206+
207+
API.getRecords('https://example.com', 1, 4, '', {}).then(() => done()).catch(done);
208+
});
209+
210+
it('getRecords prefers the service defaultSort over the global default', (done) => {
211+
mockAxios.onGet().reply((config) => {
212+
try {
213+
expect(config.params.sort).toEqual(['-popular_count']);
214+
} catch (e) {
215+
done(e);
216+
}
217+
return [200, { resources: [], total: 0, page: 1, page_size: 4, links: {} }];
218+
});
219+
220+
API.getRecords('https://example.com', 1, 4, '', {
221+
options: { service: { defaultSort: '-popular_count' } }
222+
}).then(() => done()).catch(done);
223+
});
196224
});

web/client/api/catalog/GeoNode.js

Lines changed: 155 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,20 @@
77
*/
88

99

10-
import { textSearch as geonodeTextSearch, getDatasetByPk, getResourceByPk } from '../GeoNode';
10+
import { v4 as uuid } from 'uuid';
11+
import isEmpty from 'lodash/isEmpty';
12+
import turfCenter from '@turf/center';
13+
import { textSearch as geonodeTextSearch, getDatasetByPk, getResourceByPk, getDocumentByPk } from '../GeoNode';
1114
import { getLayerTitleTranslations } from '../../utils/LayersUtils';
15+
import { getMessageById } from '../../utils/LocaleUtils';
1216
import {
1317
resourceToLayerConfig,
1418
isDefaultDatasetSubtype,
15-
getTagConfig
19+
getTagConfig,
20+
ResourceTypes,
21+
GEONODE_DOCUMENTS_ROW_VIEWER
1622
} from '../../utils/GeoNodeUtils';
23+
import { getPolygonFromExtent } from '../../utils/CoordinatesUtils';
1724
import { getConfigProp } from '../../utils/ConfigUtils';
1825
import {
1926
preprocess as commonPreprocess,
@@ -92,6 +99,7 @@ export const getCatalogRecords = (records, options) => {
9299
tagFilterType,
93100
creator: record.owner?.username,
94101
identifier: record?.pk,
102+
icon: record.resource_type === ResourceTypes.DOCUMENT ? { glyph: 'document' } : undefined,
95103
isValid: true
96104
};
97105
});
@@ -112,20 +120,158 @@ export const getLayerFromRecord = (record, options, asPromise = false) => {
112120
.then((resource) => resourceToLayerConfig({ ...record, ...resource }, options));
113121
};
114122

115-
export const getCapabilities = () => {
123+
const calculateBbox = (coordinates) => {
124+
const validCoords = (coordinates || []).filter(coord => coord && coord.length === 2);
125+
if (validCoords.length === 0) {
126+
return null;
127+
}
128+
const lons = validCoords.map(coord => coord[0]);
129+
const lats = validCoords.map(coord => coord[1]);
130+
return {
131+
bounds: {
132+
minx: Math.min(...lons),
133+
miny: Math.min(...lats),
134+
maxx: Math.max(...lons),
135+
maxy: Math.max(...lats)
136+
},
137+
crs: 'EPSG:4326'
138+
};
139+
};
140+
141+
const documentMarkerSymbolizer = (glyph) => [{
142+
kind: 'Icon',
143+
size: 46,
144+
image: { args: [{ color: 'blue', glyph, shape: 'circle' }], name: 'msMarkerIcon' },
145+
anchor: 'bottom',
146+
rotate: 0,
147+
opacity: 1,
148+
symbolizerId: '01',
149+
msBringToFront: false,
150+
msHeightReference: 'none'
151+
}];
152+
153+
const getDocumentsStyle = (locales) => ({
154+
format: 'geostyler',
155+
metadata: { editorType: 'visual' },
156+
body: {
157+
rules: [
158+
{ name: getMessageById(locales, 'catalog.subtypes.video'), ruleId: '01', mandatory: false, filter: ['&&', ['==', 'subtype', 'video']], symbolizers: documentMarkerSymbolizer('video-camera') },
159+
{ name: getMessageById(locales, 'catalog.subtypes.image'), ruleId: '02', mandatory: false, filter: ['&&', ['==', 'subtype', 'image']], symbolizers: documentMarkerSymbolizer('camera') },
160+
{ name: getMessageById(locales, 'catalog.subtypes.file'), ruleId: '03', mandatory: false, filter: ['&&', ['!=', 'subtype', 'image'], ['!=', 'subtype', 'video']], symbolizers: documentMarkerSymbolizer('file') }
161+
]
162+
}
163+
});
164+
165+
/**
166+
* Build a single MapStore vector layer that collects the given GeoNode documents
167+
* as point features (located at the center of each document extent). Documents
168+
* without an extent are skipped.
169+
*/
170+
export const documentsToLayerConfig = (documents = [], options = {}) => {
171+
const baseURL = options?.service?.url;
172+
const locales = options?.locales;
173+
// resilient per document: a failed fetch is skipped, not fatal to the whole layer
174+
return Promise.all(documents.map(doc => getDocumentByPk(baseURL, doc.pk).catch(() => null)))
175+
.then((fullDocs) => {
176+
const features = fullDocs
177+
.map((doc) => {
178+
const extent = doc?.extent?.coords;
179+
const polygon = !isEmpty(extent) ? getPolygonFromExtent(extent) : null;
180+
const center = polygon ? turfCenter(polygon) : null;
181+
if (!center) {
182+
return null;
183+
}
184+
return {
185+
type: 'Feature',
186+
properties: doc,
187+
geometry: {
188+
type: 'Point',
189+
coordinates: center.geometry.coordinates
190+
},
191+
id: doc.pk
192+
};
193+
})
194+
.filter(Boolean);
195+
const bbox = calculateBbox(features.map(feature => feature.geometry.coordinates));
196+
return {
197+
id: uuid(),
198+
type: 'vector',
199+
visibility: true,
200+
name: 'Documents',
201+
title: `${getMessageById(locales, 'catalog.resourceTypes.document')} (${features.length})`,
202+
...(bbox && { bbox }),
203+
features,
204+
style: getDocumentsStyle(locales),
205+
rowViewer: GEONODE_DOCUMENTS_ROW_VIEWER
206+
};
207+
});
208+
};
209+
210+
/**
211+
* Process the whole selected record set into map content (N records -> M layers).
212+
* GeoNode documents collapse into a single vector layer; every other record type
213+
* is converted through getLayerFromRecord. Always resolves with `{ layers, groups }`.
214+
*/
215+
export const processRecords = (records = [], options = {}, locales) => {
216+
const protectedId = options?.service?.protectedId;
217+
const applySecurity = (layer) => layer && protectedId
218+
? { ...layer, security: { type: 'basic', sourceId: protectedId } }
219+
: layer;
220+
const documents = records.filter(record => record.resource_type === ResourceTypes.DOCUMENT);
221+
const others = records.filter(record => record.resource_type !== ResourceTypes.DOCUMENT);
222+
const otherLayersPromise = Promise.all(
223+
// resilient per record: a failed conversion is skipped, not fatal to the batch
224+
others.map(record => getLayerFromRecord(record, options, true).then(applySecurity).catch(() => null))
225+
);
226+
const documentsLayerPromise = documents.length
227+
? documentsToLayerConfig(documents, { ...options, locales }).catch(() => null)
228+
: Promise.resolve(null);
229+
return Promise.all([otherLayersPromise, documentsLayerPromise])
230+
.then(([otherLayers, documentsLayer]) => ({
231+
layers: [...otherLayers, documentsLayer].filter(Boolean),
232+
groups: []
233+
}));
234+
};
235+
236+
export const getCapabilities = ({ service } = {}) => {
237+
238+
const subtypes = [
239+
...(service?.resourceTypes?.includes('dataset') ? [
240+
'vector',
241+
'raster',
242+
'vector_time',
243+
'3dtiles',
244+
'tabular'
245+
] : []),
246+
...(service?.resourceTypes?.includes('document') ? [
247+
'image',
248+
'video',
249+
'audio',
250+
'text',
251+
'archive',
252+
'presentation'
253+
] : [])
254+
];
116255
return {
117256
filterSupport: true,
118257
orderBySupport: true,
119-
getTagFilterKey: (service) => {
120-
const tagFilterType = resolveTagFilterType(service);
258+
getTagFilterKey: (_service) => {
259+
const tagFilterType = resolveTagFilterType(_service);
121260
return getTagConfig(tagFilterType).filterKey;
122261
},
123262
filterFormFields: [
124-
{ id: 'category', type: 'select', order: 5, facet: 'category', label: 'Category', key: 'filter{category.identifier.in}' },
125-
{ id: 'keyword', type: 'select', order: 6, facet: 'keyword', label: 'Keyword', key: 'filter{keywords.slug.in}' },
126-
{ id: 'region', type: 'select', order: 7, facet: 'place', label: 'Region', key: 'filter{regions.code.in}' },
263+
...(subtypes?.length ? [{
264+
id: 'subtype',
265+
labelId: 'catalog.subtypes.label',
266+
type: 'select',
267+
order: 4,
268+
options: subtypes.map((value) => ({ value, labelId: `catalog.subtypes.${value}` }))
269+
}] : []),
270+
{ id: 'category', type: 'select', order: 5, facet: 'category', labelId: 'catalog.filterFields.category', key: 'filter{category.identifier.in}' },
271+
{ id: 'keyword', type: 'select', order: 6, facet: 'keyword', labelId: 'catalog.filterFields.keyword', key: 'filter{keywords.slug.in}' },
272+
{ id: 'region', type: 'select', order: 7, facet: 'place', labelId: 'catalog.filterFields.region', key: 'filter{regions.code.in}' },
127273
{ type: 'date-range', filterKey: 'date', labelId: 'resourcesCatalog.creationFilter' },
128-
{ labelId: 'Extent Filter', type: 'extent' }
274+
{ labelId: 'catalog.filterFields.extent', type: 'extent' }
129275
]
130276
};
131277
};

0 commit comments

Comments
 (0)