geonetwork-ui 2.10.3-dev.4154db07d → 2.11.0-dev.0ddf1324b

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/fesm2022/geonetwork-ui.mjs +1661 -986
  2. package/fesm2022/geonetwork-ui.mjs.map +1 -1
  3. package/index.d.ts +298 -128
  4. package/index.d.ts.map +1 -1
  5. package/package.json +9 -8
  6. package/src/libs/api/metadata-converter/src/lib/fixtures/generic.records.ts +5 -5
  7. package/src/libs/api/metadata-converter/src/lib/fixtures/metawal.records.ts +5 -5
  8. package/src/libs/api/metadata-converter/src/lib/gn4/gn4.field.mapper.ts +43 -13
  9. package/src/libs/api/metadata-converter/src/lib/gn4/types/elasticsearch.model.ts +13 -1
  10. package/src/libs/api/metadata-converter/src/lib/iso19115-3/write-parts.ts +2 -2
  11. package/src/libs/api/metadata-converter/src/lib/iso19139/read-parts.ts +9 -5
  12. package/src/libs/api/metadata-converter/src/lib/iso19139/utils/association-type.mapper.ts +12 -0
  13. package/src/libs/api/metadata-converter/src/lib/iso19139/write-parts.ts +2 -2
  14. package/src/libs/api/repository/src/lib/gn4/elasticsearch/elasticsearch.service.ts +130 -37
  15. package/src/libs/api/repository/src/lib/gn4/gn4-repository.ts +40 -16
  16. package/src/libs/common/domain/src/lib/model/record/metadata.model.ts +40 -2
  17. package/src/libs/common/domain/src/lib/model/search/filter.model.ts +20 -1
  18. package/src/libs/common/domain/src/lib/model/search/sort-by.model.ts +1 -5
  19. package/src/libs/common/domain/src/lib/repository/records-repository.interface.ts +2 -2
  20. package/src/libs/feature/dataviz/src/lib/chart-view/chart-view.component.ts +3 -2
  21. package/src/libs/feature/dataviz/src/lib/geo-table-view/geo-table-view.component.ts +2 -1
  22. package/src/libs/feature/dataviz/src/lib/service/data.service.ts +22 -22
  23. package/src/libs/feature/editor/src/lib/components/record-form/form-field/form-field-associated-records/form-field-associated-records.component.html +33 -0
  24. package/src/libs/feature/editor/src/lib/components/record-form/form-field/form-field-associated-records/form-field-associated-records.component.ts +94 -0
  25. package/src/libs/feature/editor/src/lib/components/record-form/form-field/form-field-temporal-extents/form-field-temporal-extents.component.html +16 -12
  26. package/src/libs/feature/editor/src/lib/components/record-form/form-field/form-field.component.html +6 -0
  27. package/src/libs/feature/editor/src/lib/components/record-form/form-field/form-field.component.ts +6 -0
  28. package/src/libs/feature/editor/src/lib/fields.config.ts +5 -0
  29. package/src/libs/feature/map/src/index.ts +0 -1
  30. package/src/libs/feature/map/src/lib/add-layer-from-file/add-layer-from-file.component.html +1 -0
  31. package/src/libs/feature/map/src/lib/feature-map.module.ts +1 -8
  32. package/src/libs/feature/notifications/src/lib/notifications.service.ts +6 -4
  33. package/src/libs/feature/record/src/lib/state/mdview.actions.ts +4 -8
  34. package/src/libs/feature/record/src/lib/state/mdview.effects.ts +7 -16
  35. package/src/libs/feature/record/src/lib/state/mdview.facade.ts +1 -3
  36. package/src/libs/feature/record/src/lib/state/mdview.reducer.ts +4 -9
  37. package/src/libs/feature/record/src/lib/state/mdview.selectors.ts +2 -7
  38. package/src/libs/feature/router/src/lib/default/router.service.ts +1 -1
  39. package/src/libs/feature/router/src/lib/default/state/query-params.utils.ts +1 -1
  40. package/src/libs/feature/search/src/index.ts +2 -0
  41. package/src/libs/feature/search/src/lib/filter-dropdown/filter-dropdown.component.html +12 -4
  42. package/src/libs/feature/search/src/lib/filter-dropdown/filter-dropdown.component.ts +58 -19
  43. package/src/libs/feature/{map/src/lib → search/src/lib/geocoding}/geocoding.service.ts +5 -1
  44. package/src/libs/feature/search/src/lib/location-search/location-search.component.html +8 -0
  45. package/src/libs/feature/search/src/lib/location-search/location-search.component.ts +40 -0
  46. package/src/libs/feature/search/src/lib/search-filters-summary-item/search-filters-summary-item.component.ts +18 -8
  47. package/src/libs/feature/search/src/lib/sort-by/sort-by.component.ts +1 -1
  48. package/src/libs/feature/search/src/lib/utils/service/fields.service.ts +9 -1
  49. package/src/libs/feature/search/src/lib/utils/service/fields.ts +61 -2
  50. package/src/libs/ui/dataviz/src/lib/data-table/data-table.component.html +6 -7
  51. package/src/libs/ui/dataviz/src/lib/data-table/data-table.component.ts +31 -19
  52. package/src/libs/ui/dataviz/src/lib/data-table/data-table.fixtures.ts +2 -2
  53. package/src/libs/ui/elements/src/lib/service-capabilities/service-capabilities.component.ts +2 -1
  54. package/src/libs/ui/inputs/src/index.ts +2 -0
  55. package/src/libs/ui/inputs/src/lib/autocomplete/autocomplete.component.html +10 -1
  56. package/src/libs/ui/inputs/src/lib/autocomplete/autocomplete.component.ts +8 -0
  57. package/src/libs/ui/inputs/src/lib/date-adapter.providers.ts +30 -0
  58. package/src/libs/ui/inputs/src/lib/date-picker/date-picker.component.ts +3 -24
  59. package/src/libs/ui/inputs/src/lib/date-range-dropdown/date-range-dropdown.component.css +37 -3
  60. package/src/libs/ui/inputs/src/lib/date-range-dropdown/date-range-dropdown.component.html +135 -16
  61. package/src/libs/ui/inputs/src/lib/date-range-dropdown/date-range-dropdown.component.ts +166 -29
  62. package/src/libs/ui/inputs/src/lib/date-range-picker/date-range-picker.component.ts +3 -7
  63. package/src/libs/ui/inputs/src/lib/drag-and-drop-file-input/drag-and-drop-file-input.component.css +1 -0
  64. package/src/libs/ui/inputs/src/lib/drag-and-drop-file-input/drag-and-drop-file-input.component.html +20 -7
  65. package/src/libs/ui/inputs/src/lib/drag-and-drop-file-input/drag-and-drop-file-input.component.ts +46 -5
  66. package/src/libs/ui/inputs/src/lib/dropdown-multiselect/dropdown-multiselect.component.html +7 -4
  67. package/src/libs/ui/inputs/src/lib/dropdown-multiselect/dropdown-multiselect.component.ts +12 -9
  68. package/src/libs/ui/inputs/src/lib/dropdown-multiselect/dropdown-multiselect.model.ts +2 -2
  69. package/src/libs/ui/inputs/src/lib/spatial-extent-dropdown/spatial-extent-dropdown.component.css +0 -0
  70. package/src/libs/ui/inputs/src/lib/spatial-extent-dropdown/spatial-extent-dropdown.component.html +111 -0
  71. package/src/libs/ui/inputs/src/lib/spatial-extent-dropdown/spatial-extent-dropdown.component.ts +215 -0
  72. package/src/libs/util/app-config/src/lib/app-config.ts +3 -0
  73. package/src/libs/util/app-config/src/lib/model.ts +1 -0
  74. package/src/libs/util/data-fetcher/src/lib/data-fetcher.ts +19 -22
  75. package/src/libs/util/data-fetcher/src/lib/engine/duckdb.ts +185 -0
  76. package/src/libs/util/data-fetcher/src/lib/engine/results.ts +63 -0
  77. package/src/libs/util/data-fetcher/src/lib/{sql-utils.ts → engine/sql-utils.ts} +28 -13
  78. package/src/libs/util/data-fetcher/src/lib/model.ts +2 -1
  79. package/src/libs/util/data-fetcher/src/lib/readers/base-file.ts +53 -38
  80. package/src/libs/util/data-fetcher/src/lib/readers/base.ts +11 -0
  81. package/src/libs/util/data-fetcher/src/lib/readers/csv.ts +9 -47
  82. package/src/libs/util/data-fetcher/src/lib/readers/excel.ts +27 -27
  83. package/src/libs/util/data-fetcher/src/lib/readers/geojson.ts +5 -24
  84. package/src/libs/util/data-fetcher/src/lib/readers/gml.ts +5 -49
  85. package/src/libs/util/data-fetcher/src/lib/readers/json.ts +5 -23
  86. package/src/libs/util/data-fetcher/src/lib/readers/wfs.ts +184 -128
  87. package/src/libs/util/data-fetcher/src/lib/utils.ts +0 -143
  88. package/src/libs/util/shared/src/index.ts +1 -0
  89. package/src/libs/util/shared/src/lib/autofocus.directive.ts +26 -0
  90. package/src/libs/util/shared/src/lib/gn-ui-version.ts +1 -1
  91. package/src/libs/util/shared/src/lib/links/link-utils.ts +19 -10
  92. package/src/libs/util/shared/src/lib/services/date.service.ts +3 -3
  93. package/src/libs/util/shared/src/lib/utils/file.ts +15 -0
  94. package/src/libs/util/shared/src/lib/utils/geojson.ts +8 -0
  95. package/src/libs/util/shared/src/lib/utils/index.ts +1 -0
  96. package/tailwind.base.css +5 -0
  97. package/translations/de.json +30 -4
  98. package/translations/en.json +29 -3
  99. package/translations/es.json +27 -1
  100. package/translations/fr.json +30 -4
  101. package/translations/it.json +28 -2
  102. package/translations/nl.json +27 -1
  103. package/translations/pt.json +27 -1
  104. package/translations/sk.json +28 -2
  105. package/src/libs/feature/map/src/lib/geocoding/geocoding.component.html +0 -41
  106. package/src/libs/feature/map/src/lib/geocoding/geocoding.component.ts +0 -112
  107. package/src/libs/util/data-fetcher/src/lib/readers/base-cache.ts +0 -14
  108. /package/src/libs/feature/{map/src/lib/geocoding/geocoding.component.css → editor/src/lib/components/record-form/form-field/form-field-associated-records/form-field-associated-records.component.css} +0 -0
@@ -15,9 +15,11 @@ import {
15
15
  } from './atomic-operations'
16
16
  import { MetadataUrlService } from './metadata-url.service'
17
17
  import { inject, Injectable } from '@angular/core'
18
+ import { getAssociationTypeFromCode } from '../iso19139/utils/association-type.mapper'
18
19
  import { getStatusFromStatusCode } from '../iso19139/utils/status.mapper'
19
20
  import { getUpdateFrequencyFromFrequencyCode } from '../iso19139/utils/update-frequency.mapper'
20
21
  import {
22
+ AssociatedRecord,
21
23
  CatalogRecord,
22
24
  Constraint,
23
25
  DatasetSpatialExtent,
@@ -318,25 +320,16 @@ export class Gn4FieldMapper {
318
320
  output
319
321
  ),
320
322
  related: (output, source) => {
323
+ const related = <SourceWithUnknownProps>selectField(source, 'related')
321
324
  const fcatSource = selectField(
322
- getFirstValue(
323
- selectField(
324
- <SourceWithUnknownProps>selectField(source, 'related'),
325
- 'fcats'
326
- )
327
- ) ?? {},
325
+ getFirstValue(selectField(related, 'fcats')) ?? {},
328
326
  '_source'
329
327
  )
330
328
  const featureCatalogIdentifier: string = selectField(
331
329
  <SourceWithUnknownProps>fcatSource,
332
330
  'uuid'
333
331
  )
334
- const sourceOfLinks = getAsArray(
335
- selectField(
336
- <SourceWithUnknownProps>selectField(source, 'related'),
337
- 'hassources'
338
- )
339
- )
332
+ const sourceOfLinks = getAsArray(selectField(related, 'hassources'))
340
333
  const sourceOfIdentifiers: string[] = sourceOfLinks
341
334
  .filter((link) => link['origin'] === 'catalog')
342
335
  .map((link) => {
@@ -345,13 +338,50 @@ export class Gn4FieldMapper {
345
338
  'uuid'
346
339
  )
347
340
  })
348
- const extraValues: Record<string, string | string[]> = {}
341
+ const siblingLinks = getAsArray(selectField(related, 'siblings'))
342
+ const siblings: AssociatedRecord[] = siblingLinks
343
+ .filter((link) => link['origin'] === 'catalog')
344
+ .map((link) => ({
345
+ uniqueIdentifier: selectField<string>(
346
+ selectField(<SourceWithUnknownProps>link, '_source'),
347
+ 'uuid'
348
+ ),
349
+ associationType: getAssociationTypeFromCode(
350
+ selectField<string>(
351
+ selectField(<SourceWithUnknownProps>link, 'properties'),
352
+ 'associationType'
353
+ )
354
+ ),
355
+ }))
356
+ const associatedLinks = getAsArray(selectField(related, 'associated'))
357
+ const associatedIdentifiers: string[] = associatedLinks
358
+ .filter((link) => link['origin'] === 'catalog')
359
+ .map((link) => {
360
+ return selectField<string>(
361
+ selectField(<SourceWithUnknownProps>link, '_source'),
362
+ 'uuid'
363
+ )
364
+ })
365
+ .filter(
366
+ (uuid) =>
367
+ !siblings.some((sibling) => sibling.uniqueIdentifier === uuid)
368
+ )
369
+ const extraValues: Record<
370
+ string,
371
+ string | string[] | AssociatedRecord[]
372
+ > = {}
349
373
  if (featureCatalogIdentifier) {
350
374
  extraValues.featureCatalogIdentifier = featureCatalogIdentifier
351
375
  }
352
376
  if (sourceOfIdentifiers && sourceOfIdentifiers.length > 0) {
353
377
  extraValues.sourceOfIdentifiers = sourceOfIdentifiers
354
378
  }
379
+ if (associatedIdentifiers && associatedIdentifiers.length > 0) {
380
+ extraValues.associatedIdentifiers = associatedIdentifiers
381
+ }
382
+ if (siblings && siblings.length > 0) {
383
+ extraValues.siblings = siblings
384
+ }
355
385
  return Object.keys(extraValues).length > 0
356
386
  ? this.addExtra(extraValues, output)
357
387
  : output
@@ -2,7 +2,19 @@ export type SortOrder = 'asc' | 'desc'
2
2
  interface SortParam {
3
3
  [key: string]: SortOrder
4
4
  }
5
- export type SortParams = string | SortParam | (string | SortParam)[]
5
+ type EsFieldSort = {
6
+ order: SortOrder
7
+ mode?: 'max' | 'min'
8
+ missing?: '_last'
9
+ nested?: {
10
+ path: string
11
+ }
12
+ }
13
+ export type SortParams =
14
+ | string
15
+ | SortParam
16
+ | { [property: string]: EsFieldSort }
17
+ | (string | SortParam | { [property: string]: EsFieldSort })[]
6
18
 
7
19
  export interface EsRequestAggTerm {
8
20
  field?: string
@@ -587,7 +587,7 @@ export function writeAssociatedRecords(
587
587
  removeChildrenByName('mri:associatedResource'),
588
588
  appendChildren(
589
589
  ...record.associatedRecords
590
- .filter((assoc) => assoc.uuid && assoc.associationType)
590
+ .filter((assoc) => assoc.uniqueIdentifier && assoc.associationType)
591
591
  .map((assoc) =>
592
592
  pipe(
593
593
  createNestedElement(
@@ -608,7 +608,7 @@ export function writeAssociatedRecords(
608
608
  ),
609
609
  pipe(
610
610
  createElement('mri:metadataReference'),
611
- writeAttribute('uuidref', assoc.uuid)
611
+ writeAttribute('uuidref', assoc.uniqueIdentifier)
612
612
  )
613
613
  )
614
614
  )
@@ -51,6 +51,7 @@ import {
51
51
  readText,
52
52
  XmlElement,
53
53
  } from '../xml-utils'
54
+ import { getAssociationTypeFromCode } from './utils/association-type.mapper'
54
55
  import { readGeometry } from './utils/geometry'
55
56
  import { fullNameToParts } from './utils/individual-name'
56
57
  import { getKeywordTypeFromKeywordTypeCode } from './utils/keyword.mapper'
@@ -931,19 +932,22 @@ export function readSourceRecords(rootEl: XmlElement): SourceRecord[] {
931
932
  export function extractAssociatedRecords(
932
933
  containerName: string,
933
934
  aggregateName: string,
934
- readUuid: ChainableFunction<XmlElement, string>
935
+ readIdentifier: ChainableFunction<XmlElement, string>
935
936
  ): ChainableFunction<XmlElement, AssociatedRecord[]> {
936
937
  return pipe(
937
938
  findChildrenElement(containerName, false),
938
939
  mapArray((el) => {
939
940
  const aggregateEl = findChildElement(aggregateName, false)(el)
940
- const uuid = readUuid(aggregateEl)
941
- const associationType = pipe(
941
+ const uniqueIdentifier = readIdentifier(aggregateEl)
942
+ const associationTypeCode = pipe(
942
943
  findNestedElement('gmd:associationType', 'gmd:DS_AssociationTypeCode'),
943
944
  readAttribute('codeListValue')
944
945
  )(aggregateEl)
945
- if (!uuid || !associationType) return null
946
- return { uuid, associationType }
946
+ if (!uniqueIdentifier || !associationTypeCode) return null
947
+ return {
948
+ uniqueIdentifier,
949
+ associationType: getAssociationTypeFromCode(associationTypeCode),
950
+ }
947
951
  }),
948
952
  filterArray((r): r is AssociatedRecord => r !== null)
949
953
  )
@@ -0,0 +1,12 @@
1
+ import {
2
+ AssociationType,
3
+ associationTypeValues,
4
+ } from '../../../../../../../libs/common/domain/src/lib/model/record'
5
+
6
+ export function getAssociationTypeFromCode(
7
+ associationTypeCode: string
8
+ ): AssociationType {
9
+ return associationTypeValues.includes(associationTypeCode as AssociationType)
10
+ ? (associationTypeCode as AssociationType)
11
+ : 'crossReference'
12
+ }
@@ -1276,7 +1276,7 @@ export function writeAssociatedRecords(
1276
1276
  removeChildrenByName('gmd:aggregationInfo'),
1277
1277
  appendChildren(
1278
1278
  ...record.associatedRecords
1279
- .filter((assoc) => assoc.uuid && assoc.associationType)
1279
+ .filter((assoc) => assoc.uniqueIdentifier && assoc.associationType)
1280
1280
  .map((assoc) =>
1281
1281
  pipe(
1282
1282
  createNestedElement(
@@ -1290,7 +1290,7 @@ export function writeAssociatedRecords(
1290
1290
  'gmd:MD_Identifier',
1291
1291
  'gmd:code'
1292
1292
  ),
1293
- writeCharacterString(assoc.uuid)
1293
+ writeCharacterString(assoc.uniqueIdentifier)
1294
1294
  ),
1295
1295
  pipe(
1296
1296
  createNestedElement(
@@ -11,6 +11,7 @@ import {
11
11
  AggregationsParams,
12
12
  FieldFilter,
13
13
  FieldFilters,
14
+ FieldSort,
14
15
  FilterQuery,
15
16
  FiltersAggregationParams,
16
17
  SortByField,
@@ -33,7 +34,12 @@ import {
33
34
  LanguageCode,
34
35
  } from '../../../../../../../libs/common/domain/src/lib/model/record'
35
36
  import { TranslateService } from '@ngx-translate/core'
36
- import { getGeometryBoundingBox } from '../../../../../../../libs/util/shared/src'
37
+ import {
38
+ bboxToPolygon,
39
+ BoundingBox,
40
+ getGeometryBoundingBox,
41
+ isBoundingBox,
42
+ } from '../../../../../../../libs/util/shared/src'
37
43
  import { getLength as getGeodesicLength } from 'ol/sphere.js'
38
44
  import { LineString } from 'ol/geom.js'
39
45
 
@@ -48,7 +54,10 @@ export class ElasticsearchService {
48
54
 
49
55
  // runtime fields are computed using a Painless script
50
56
  // see: https://www.elastic.co/guide/en/elasticsearch/reference/current/runtime-mapping-fields.html
51
- private runtimeFields: Record<string, string> = {}
57
+ private runtimeFields: Record<
58
+ string,
59
+ { script: string; type: 'keyword' | 'date' }
60
+ > = {}
52
61
 
53
62
  // we're using getters in case the defined languages change over time
54
63
  private get metadataLang(): LanguageCode {
@@ -90,8 +99,8 @@ export class ElasticsearchService {
90
99
  const addMapping = (fieldName: string) => {
91
100
  if (!payload.runtime_mappings) payload.runtime_mappings = {}
92
101
  payload.runtime_mappings[fieldName] = {
93
- type: 'keyword',
94
- script: this.runtimeFields[fieldName],
102
+ type: this.runtimeFields[fieldName].type,
103
+ script: this.runtimeFields[fieldName].script,
95
104
  }
96
105
  }
97
106
  const lookForField = (node: unknown) => {
@@ -112,6 +121,14 @@ export class ElasticsearchService {
112
121
  ) {
113
122
  addMapping(runtimeField)
114
123
  }
124
+ if (
125
+ runtimeField in node &&
126
+ typeof node[runtimeField] === 'object' &&
127
+ node[runtimeField] !== null &&
128
+ ('gte' in node[runtimeField] || 'lte' in node[runtimeField])
129
+ ) {
130
+ addMapping(runtimeField)
131
+ }
115
132
  if (
116
133
  'query' in node &&
117
134
  typeof node.query === 'string' &&
@@ -130,8 +147,12 @@ export class ElasticsearchService {
130
147
  return payload
131
148
  }
132
149
 
133
- registerRuntimeField(fieldName: string, expression: string) {
134
- this.runtimeFields[fieldName] = expression
150
+ registerRuntimeField(
151
+ fieldName: string,
152
+ expression: string,
153
+ type: 'keyword' | 'date' = 'keyword'
154
+ ) {
155
+ this.runtimeFields[fieldName] = { script: expression, type }
135
156
  }
136
157
 
137
158
  getMetadataByIdsPayload(uuids: string[]): EsSearchParams {
@@ -141,6 +162,7 @@ export class ElasticsearchService {
141
162
  values: uuids,
142
163
  },
143
164
  },
165
+ size: uuids.length,
144
166
  }
145
167
  }
146
168
 
@@ -199,9 +221,27 @@ export class ElasticsearchService {
199
221
  }
200
222
 
201
223
  private buildPayloadSort(sortBy: SortByField): SortParams {
202
- if (sortBy === null) return undefined
203
- const fields = Array.isArray(sortBy[0]) ? sortBy : [sortBy]
204
- return fields.map((field) => ({ [field[1]]: field[0] }))
224
+ if (!sortBy || sortBy.length === 0) return undefined
225
+ const fields = Array.isArray(sortBy[0])
226
+ ? (sortBy as FieldSort[])
227
+ : [sortBy as FieldSort]
228
+ return fields.map((field) => {
229
+ // Sort by nested array of dates only works with the explicit syntax
230
+ if (field[1].endsWith('.date')) {
231
+ const nestedPath = field[1].slice(0, field[1].lastIndexOf('.date'))
232
+ return {
233
+ [field[1]]: {
234
+ order: field[0] as 'desc' | 'asc',
235
+ mode: field[0] === 'desc' ? 'max' : 'min',
236
+ missing: '_last',
237
+ nested: {
238
+ path: nestedPath,
239
+ },
240
+ },
241
+ }
242
+ }
243
+ return { [field[1]]: field[0] }
244
+ })
205
245
  }
206
246
 
207
247
  private injectLangInQueryStringFields(
@@ -244,8 +284,55 @@ export class ElasticsearchService {
244
284
  return this.metadataLang === 'current'
245
285
  }
246
286
 
247
- private filtersToQuery(
287
+ private findSpatialFilterExtent(
248
288
  filters: FieldFilters | FiltersAggregationParams | string
289
+ ): BoundingBox | undefined {
290
+ if (typeof filters === 'string') {
291
+ return undefined
292
+ }
293
+ return Object.values(filters).find(isBoundingBox)
294
+ }
295
+
296
+ // if a field registered min/max runtime fields, its dates form an interval
297
+ // the record matches if that interval intersects the filter range
298
+ private buildDateRangeQuery(searchField: string, dateRange: DateRange) {
299
+ const minField = `${searchField}Min`
300
+ const maxField = `${searchField}Max`
301
+ if (minField in this.runtimeFields && maxField in this.runtimeFields) {
302
+ const filter = [
303
+ dateRange.start && {
304
+ range: {
305
+ [maxField]: {
306
+ gte: formatDate(dateRange.start),
307
+ format: 'yyyy-MM-dd',
308
+ },
309
+ },
310
+ },
311
+ dateRange.end && {
312
+ range: {
313
+ [minField]: {
314
+ lte: formatDate(dateRange.end),
315
+ format: 'yyyy-MM-dd',
316
+ },
317
+ },
318
+ },
319
+ ].filter(Boolean)
320
+ return { bool: { filter } }
321
+ }
322
+ return {
323
+ range: {
324
+ [searchField]: {
325
+ ...(dateRange.start && { gte: formatDate(dateRange.start) }),
326
+ ...(dateRange.end && { lte: formatDate(dateRange.end) }),
327
+ format: 'yyyy-MM-dd',
328
+ },
329
+ },
330
+ }
331
+ }
332
+
333
+ private filtersToQuery(
334
+ filters: FieldFilters | FiltersAggregationParams | string,
335
+ spatialFilterExtent = this.findSpatialFilterExtent(filters)
249
336
  ): FilterQuery {
250
337
  const addQuote = (key: string) => (/^\/.+\/$/.test(key) ? key : `"${key}"`)
251
338
  const makeQuery = (filter: FieldFilter): string => {
@@ -266,7 +353,9 @@ export class ElasticsearchService {
266
353
  ? filters
267
354
  : Object.keys(filters)
268
355
  .filter((fieldname) => fieldname !== 'gn-ui-crossFieldFilter')
356
+ .filter((fieldname) => !isBoundingBox(filters[fieldname]))
269
357
  .filter((fieldname) => !isDateRange(filters[fieldname]))
358
+ .filter((fieldname) => !Array.isArray(filters[fieldname]))
270
359
  .filter(
271
360
  (fieldname) =>
272
361
  filters[fieldname] &&
@@ -279,37 +368,33 @@ export class ElasticsearchService {
279
368
  if (filters['gn-ui-crossFieldFilter']) {
280
369
  queryString = `${queryString} AND (${filters['gn-ui-crossFieldFilter']})`
281
370
  }
282
- const queryRange = Object.entries(filters)
283
- .filter(([, value]) => isDateRange(value))
284
- .map(([searchField, dateRange]) => {
285
- return {
286
- searchField,
287
- dateRange,
288
- } as {
289
- searchField: string
290
- dateRange: DateRange
291
- }
292
- })[0]
371
+ const queryRanges = Object.entries(filters).filter(([, value]) =>
372
+ isDateRange(value)
373
+ ) as [string, DateRange][]
293
374
  const queryParts = [
294
375
  queryString && {
295
376
  query_string: {
296
377
  query: queryString,
297
378
  },
298
379
  },
299
- queryRange &&
300
- queryRange.dateRange && {
301
- range: {
302
- [queryRange.searchField]: {
303
- ...(queryRange.dateRange.start && {
304
- gte: formatDate(queryRange.dateRange.start),
305
- }),
306
- ...(queryRange.dateRange.end && {
307
- lte: formatDate(queryRange.dateRange.end),
308
- }),
309
- format: 'yyyy-MM-dd',
380
+ ...queryRanges.map(([searchField, dateRange]) =>
381
+ this.buildDateRangeQuery(searchField, dateRange)
382
+ ),
383
+ spatialFilterExtent && {
384
+ geo_shape: {
385
+ geom: {
386
+ shape: {
387
+ type: 'envelope',
388
+ // spatialFilterExtent is [minX, minY, maxX, maxY]; envelope coordinates are [top-left, bottom-right]
389
+ coordinates: [
390
+ [spatialFilterExtent[0], spatialFilterExtent[3]],
391
+ [spatialFilterExtent[2], spatialFilterExtent[1]],
392
+ ],
310
393
  },
394
+ relation: 'intersects',
311
395
  },
312
396
  },
397
+ },
313
398
  ].filter(Boolean)
314
399
  return queryParts.length > 0 ? (queryParts as FilterQuery) : undefined
315
400
  }
@@ -348,7 +433,12 @@ export class ElasticsearchService {
348
433
  },
349
434
  })
350
435
  }
351
- const queryFilters = this.filtersToQuery(fieldSearchFilters)
436
+ // a spatial extent filter takes precedence over the preference geometry for boosting
437
+ const spatialFilterExtent = this.findSpatialFilterExtent(fieldSearchFilters)
438
+ const queryFilters = this.filtersToQuery(
439
+ fieldSearchFilters,
440
+ spatialFilterExtent
441
+ )
352
442
  if (queryFilters) {
353
443
  filter.push(...queryFilters)
354
444
  }
@@ -359,7 +449,10 @@ export class ElasticsearchService {
359
449
  },
360
450
  })
361
451
  }
362
- if (geometry) {
452
+ const boostGeometry = spatialFilterExtent
453
+ ? bboxToPolygon(spatialFilterExtent)
454
+ : geometry
455
+ if (boostGeometry) {
363
456
  // boosts applied using the filter geometry:
364
457
  // * records completely within the geometry receive a boost of 5
365
458
  // * records intersecting the geometry receive a boost of 2
@@ -369,7 +462,7 @@ export class ElasticsearchService {
369
462
  {
370
463
  geo_shape: {
371
464
  geom: {
372
- shape: geometry,
465
+ shape: boostGeometry,
373
466
  relation: 'within',
374
467
  },
375
468
  boost: 5.0,
@@ -378,7 +471,7 @@ export class ElasticsearchService {
378
471
  {
379
472
  geo_shape: {
380
473
  geom: {
381
- shape: geometry,
474
+ shape: boostGeometry,
382
475
  relation: 'intersects',
383
476
  },
384
477
  boost: 2.0,
@@ -389,7 +482,7 @@ export class ElasticsearchService {
389
482
  // this will boost the results variably depending on their distance from the given geometry
390
483
  // note: this takes into account the `location` field of a record; this is generally the center of all spatial extents
391
484
  // combined, and thus the actual size/coverage of the record spatial extent isn't relevant here
392
- const bbox = getGeometryBoundingBox(geometry)
485
+ const bbox = getGeometryBoundingBox(boostGeometry)
393
486
  const center = [(bbox[0] + bbox[2]) / 2, (bbox[1] + bbox[3]) / 2]
394
487
  const northToCenter = new LineString([
395
488
  [center[0], bbox[3]],
@@ -14,10 +14,13 @@ import {
14
14
  } from '../../../../../../libs/api/metadata-converter/src'
15
15
  import { PublicationVersionError } from '../../../../../../libs/common/domain/src/lib/model/error'
16
16
  import {
17
+ AssociatedRecord,
17
18
  CatalogRecord,
18
19
  DatasetFeatureCatalog,
19
20
  DatasetFeatureType,
20
21
  LanguageCode,
22
+ LinkedRecord,
23
+ RecordRelation,
21
24
  } from '../../../../../../libs/common/domain/src/lib/model/record'
22
25
  import {
23
26
  Aggregations,
@@ -145,7 +148,7 @@ export class Gn4Repository implements RecordsRepositoryInterface {
145
148
  return this.gn4SearchApi
146
149
  .search(
147
150
  'bucket',
148
- ['fcats', 'hassources'],
151
+ ['fcats', 'hassources', 'siblings', 'associated'],
149
152
  JSON.stringify(
150
153
  this.gn4SearchHelper.getMetadataByIdsPayload([uniqueIdentifier])
151
154
  )
@@ -249,22 +252,43 @@ export class Gn4Repository implements RecordsRepositoryInterface {
249
252
  )
250
253
  }
251
254
 
252
- getSources(record: CatalogRecord): Observable<CatalogRecord[]> {
253
- const sourcesIdentifiers = record.extras?.['sourcesIdentifiers'] as string[]
254
- if (sourcesIdentifiers && sourcesIdentifiers.length > 0) {
255
- return this.getMultipleRecords(sourcesIdentifiers)
256
- }
257
- return of(null)
258
- }
259
-
260
- getSourceOf(record: CatalogRecord): Observable<CatalogRecord[]> {
261
- const sourceOfIdentifiers = record.extras?.[
262
- 'sourceOfIdentifiers'
263
- ] as string[]
264
- if (sourceOfIdentifiers && sourceOfIdentifiers.length > 0) {
265
- return this.getMultipleRecords(sourceOfIdentifiers)
255
+ getLinkedRecords(record: CatalogRecord): Observable<LinkedRecord[]> {
256
+ const siblings = (record.extras?.['siblings'] ?? []) as AssociatedRecord[]
257
+ const relations: Array<[RecordRelation, string[]]> = [
258
+ ['source', (record.extras?.['sourcesIdentifiers'] ?? []) as string[]],
259
+ ['sourceOf', (record.extras?.['sourceOfIdentifiers'] ?? []) as string[]],
260
+ ['sibling', siblings.map(({ uniqueIdentifier }) => uniqueIdentifier)],
261
+ [
262
+ 'associated',
263
+ (record.extras?.['associatedIdentifiers'] ?? []) as string[],
264
+ ],
265
+ ]
266
+ const requested = relations.filter(
267
+ ([, identifiers]) => identifiers.length > 0
268
+ )
269
+ if (requested.length === 0) {
270
+ return of([])
266
271
  }
267
- return of(null)
272
+ return forkJoin(
273
+ requested.map(([relation, identifiers]) =>
274
+ this.getMultipleRecords(identifiers).pipe(
275
+ map((records) =>
276
+ (records ?? []).map((record) => ({
277
+ record,
278
+ relation,
279
+ associationType:
280
+ relation === 'sibling'
281
+ ? siblings.find(
282
+ ({ uniqueIdentifier }) =>
283
+ uniqueIdentifier === record.uniqueIdentifier
284
+ )?.associationType
285
+ : undefined,
286
+ }))
287
+ ),
288
+ catchError(() => of([]))
289
+ )
290
+ )
291
+ ).pipe(map((groups) => groups.flat()))
268
292
  }
269
293
 
270
294
  aggregate(params: AggregationsParams): Observable<Aggregations> {
@@ -260,12 +260,50 @@ export interface SourceRecord {
260
260
  href?: string
261
261
  }
262
262
 
263
+ marker('domain.record.associationType.crossReference')
264
+ marker('domain.record.associationType.largerWorkCitation')
265
+ marker('domain.record.associationType.partOfSeamlessDatabase')
266
+ marker('domain.record.associationType.stereoMate')
267
+ marker('domain.record.associationType.isComposedOf')
268
+ marker('domain.record.associationType.collectiveTitle')
269
+ marker('domain.record.associationType.series')
270
+ marker('domain.record.associationType.dependency')
271
+ marker('domain.record.associationType.revisionOf')
272
+
273
+ // DS_AssociationTypeCode values of the ISO19115-3 codelist, any unrecognized value
274
+ // being mapped to 'crossReference'
275
+ export const associationTypeValues = [
276
+ 'crossReference',
277
+ 'largerWorkCitation',
278
+ 'partOfSeamlessDatabase',
279
+ 'stereoMate',
280
+ 'isComposedOf',
281
+ 'collectiveTitle',
282
+ 'series',
283
+ 'dependency',
284
+ 'revisionOf',
285
+ ] as const
286
+
287
+ export type AssociationType = (typeof associationTypeValues)[number]
288
+
263
289
  /**
264
290
  * Represents another record associated with this one (e.g. a parent/sibling dataset).
265
291
  */
266
292
  export interface AssociatedRecord {
267
- uuid: string
268
- associationType: string
293
+ uniqueIdentifier: string
294
+ associationType: AssociationType
295
+ }
296
+
297
+ /**
298
+ * Nature of the link between a record and another one; the association type is
299
+ * only known for 'sibling' relations, as it is held by the declaring record.
300
+ */
301
+ export type RecordRelation = 'source' | 'sourceOf' | 'sibling' | 'associated'
302
+
303
+ export interface LinkedRecord {
304
+ record: CatalogRecord
305
+ relation: RecordRelation
306
+ associationType?: AssociationType
269
307
  }
270
308
 
271
309
  export interface DatasetRecord extends BaseRecord {
@@ -6,11 +6,15 @@ export type FieldFilterByRange = {
6
6
  start?: Date
7
7
  end?: Date
8
8
  }
9
+ // matches util/shared's BoundingBox; duplicated here to avoid a circular
10
+ // dependency (util/shared already depends on common/domain)
11
+ export type FieldFilterByBoundingBox = [number, number, number, number]
9
12
 
10
13
  export type FieldFilter =
11
14
  | FieldFilterByExpression
12
15
  | FieldFilterByValues
13
16
  | FieldFilterByRange
17
+ | FieldFilterByBoundingBox
14
18
  export type FieldFilters = Record<FieldName, FieldFilter>
15
19
 
16
20
  export type QueryString = {
@@ -19,4 +23,19 @@ export type QueryString = {
19
23
  export type QueryRange = {
20
24
  range: Record<string, { format: string; gte: string; lte: string }>
21
25
  }
22
- export type FilterQuery = Array<QueryString | QueryRange>
26
+ export type QueryGeoShape = {
27
+ geo_shape: {
28
+ geom: {
29
+ shape: { type: string; coordinates: number[][] }
30
+ relation: string
31
+ }
32
+ }
33
+ }
34
+ export type QueryBool = {
35
+ bool: {
36
+ filter: QueryRange[]
37
+ }
38
+ }
39
+ export type FilterQuery = Array<
40
+ QueryString | QueryRange | QueryGeoShape | QueryBool
41
+ >
@@ -6,9 +6,5 @@ export const SortByEnum: Record<string, SortByField> = {
6
6
  RELEVANCY: ['desc', '_score'],
7
7
  QUALITY_SCORE: ['desc', 'qualityScore'],
8
8
  CHANGE_DATE: ['desc', 'changeDate'],
9
- RESOURCE_DATES: [
10
- ['desc', 'revisionDateForResource'],
11
- ['desc', 'publicationDateForResource'],
12
- ['desc', 'creationDateForResource'],
13
- ],
9
+ RESOURCE_DATE: ['desc', 'resourceDate.date'],
14
10
  }