diff --git a/apps/metadata-converter/src/app/app.component.html b/apps/metadata-converter/src/app/app.component.html index d72f4bb9eb..0a13090598 100644 --- a/apps/metadata-converter/src/app/app.component.html +++ b/apps/metadata-converter/src/app/app.component.html @@ -6,7 +6,7 @@

@@ -20,7 +20,7 @@

placeholder="Upload a record from your computer" /> { const text = evt.target.result as string - this.statusComponent.referenceIso19139 = text - this.statusComponent.recordIso19139 = text + this.statusComponent.referenceMetadata = text + this.statusComponent.currentMetadata = text } reader.onerror = () => this.statusComponent.errorReadingFile() } @@ -47,8 +47,8 @@ export class AppComponent { fetch(url) .then((resp) => resp.text()) .then((text) => { - this.statusComponent.referenceIso19139 = text - this.statusComponent.recordIso19139 = text + this.statusComponent.referenceMetadata = text + this.statusComponent.currentMetadata = text }) .catch((e) => this.statusComponent.errorLoadingFile(e.message)) } diff --git a/apps/metadata-converter/src/app/components/record-form/record-form.component.html b/apps/metadata-converter/src/app/components/record-form/record-form.component.html index 0cc2e89645..e3447599a4 100644 --- a/apps/metadata-converter/src/app/components/record-form/record-form.component.html +++ b/apps/metadata-converter/src/app/components/record-form/record-form.component.html @@ -68,72 +68,88 @@ role: 'unspecified' }" > - - - - - - - - - - - - - - -

-

+

+ + +

+ + + + + + + + + + + + + + + +

+ +

+

+ + +

-

- - -

{ + this.newMetadata.emit(output) + const time = Math.round(performance.now() - start) + this.status = `Converting to ISO9139... Done (${time} ms).` + }) + .catch((e) => { + this.status = `Converting to ISO9139... Failed: ${ + e instanceof Error ? e.message : e + }` + console.error(e) + }) + } + @Input() set currentMetadata(value: string) { const start = performance.now() this.status = 'Converting to native format...' - try { - const output = toModel(value) - this.newRecordNative.emit(output) - this.newRecordIso19139.emit(value) - const time = Math.round(performance.now() - start) - this.status = `Converting to native format... Done (${time} ms).` - } catch (e) { - this.status = `Converting to native format... Failed: ${ - e instanceof Error ? e.message : e - }` - console.error(e) - } - } - @Input() referenceIso19139: string + this.xmlToRecord(value) + .then((output) => { + this.newRecordNative.emit(output) + this.newMetadata.emit(value) + const time = Math.round(performance.now() - start) + this.status = `Converting to native format... Done (${time} ms).` + }) + .catch((e) => { + this.status = `Converting to native format... Failed: ${ + e instanceof Error ? e.message : e + }` + console.error(e) + }) + } + @Input() referenceMetadata: string @Output() newRecordNative = new EventEmitter() - @Output() newRecordIso19139 = new EventEmitter() + @Output() newMetadata = new EventEmitter() status = 'Standing by.' @@ -60,4 +63,16 @@ export class StatusComponent { errorReadingFile() { this.status = `Reading file... Failed` } + + private recordToXml(record: CatalogRecord) { + const converter = this.referenceMetadata + ? findConverterForDocument(this.referenceMetadata) + : new Iso191153Converter() + return converter.writeRecord(record, this.referenceMetadata) + } + + private xmlToRecord(metadata: string) { + const converter = findConverterForDocument(metadata) + return converter.readRecord(metadata) + } } diff --git a/libs/api/metadata-converter/src/index.ts b/libs/api/metadata-converter/src/index.ts index 52c8e90120..169d8dfcb6 100644 --- a/libs/api/metadata-converter/src/index.ts +++ b/libs/api/metadata-converter/src/index.ts @@ -1,4 +1,4 @@ -export * from './lib/iso19139/converter' -export * from './lib/gn4/gn4.metadata.mapper' -export * from './lib/gn4/atomic-operations' -export * from './lib/gn4/types' +export * from './lib/iso19139' +export * from './lib/iso19115-3' +export * from './lib/find-converter' +export * from './lib/gn4' diff --git a/libs/api/metadata-converter/src/lib/metadata-base.mapper.ts b/libs/api/metadata-converter/src/lib/base.converter.ts similarity index 83% rename from libs/api/metadata-converter/src/lib/metadata-base.mapper.ts rename to libs/api/metadata-converter/src/lib/base.converter.ts index d8a0939016..7f59c3413b 100644 --- a/libs/api/metadata-converter/src/lib/metadata-base.mapper.ts +++ b/libs/api/metadata-converter/src/lib/base.converter.ts @@ -7,13 +7,13 @@ export class MetadataMapperContext { readonly location? } -export abstract class MetadataBaseMapper { +export abstract class BaseConverter { constructor( protected ctx: MetadataMapperContext = new MetadataMapperContext() ) {} abstract readRecord(document: F): Promise - abstract writeRecord(record: CatalogRecord): Promise + abstract writeRecord(record: CatalogRecord, reference?: F): Promise readRecords(documents: F[]): Promise { return Promise.all(documents.map((doc) => this.readRecord(doc))) } diff --git a/libs/api/metadata-converter/src/lib/find-converter.ts b/libs/api/metadata-converter/src/lib/find-converter.ts new file mode 100644 index 0000000000..acffdcb37e --- /dev/null +++ b/libs/api/metadata-converter/src/lib/find-converter.ts @@ -0,0 +1,16 @@ +import { Iso19139Converter } from './iso19139' +import { BaseConverter } from './base.converter' +import { Iso191153Converter } from './iso19115-3' + +export function findConverterForDocument( + document: string +): BaseConverter { + if (document.indexOf('mdb:MD_Metadata') > 0) { + return new Iso191153Converter() + } else if (document.indexOf('gmd:MD_Metadata') > 0) { + return new Iso19139Converter() + } else { + throw new Error(`No suitable converter found for the following document: +${document.substring(0, 400)}...`) + } +} diff --git a/libs/api/metadata-converter/src/lib/fixtures/generic-dataset+geo2france-plu.iso19139.xml b/libs/api/metadata-converter/src/lib/fixtures/generic-dataset+geo2france-plu.iso19139.xml index 5167595c7b..44095c03d9 100644 --- a/libs/api/metadata-converter/src/lib/fixtures/generic-dataset+geo2france-plu.iso19139.xml +++ b/libs/api/metadata-converter/src/lib/fixtures/generic-dataset+geo2france-plu.iso19139.xml @@ -21,42 +21,6 @@ - - - - Bob TheGreat - - - developer - - - MyOrganization - - - - - - - bob@org.net - - - - - - - https://www.my.org/info - - - - - - - - - - 2022-02-01T15:12:00 @@ -257,6 +221,50 @@ Cette section contient des *caractères internationaux* (ainsi que des "caractè + + + + Bill TheDistributor + + + randomWorker + + + Another Organization + + + + + + + +11234567890 + + + + + + + bill@org2.com + + + 123 rue des moulins, 10808 Montargis, FR + + + + + + + https://www.another.org/docs + + + + + + + + + + @@ -547,6 +555,41 @@ As such, **it is not very interesting at all.** + + + + Bob TheGreat + + + developer + + + MyOrganization + + + + + + + bob@org.net + + + + + + + https://www.my.org/info + + + + + + + + + + diff --git a/libs/api/metadata-converter/src/lib/fixtures/generic-dataset+metawal.iso19115-3.xml b/libs/api/metadata-converter/src/lib/fixtures/generic-dataset+metawal.iso19115-3.xml new file mode 100644 index 0000000000..7b89177e73 --- /dev/null +++ b/libs/api/metadata-converter/src/lib/fixtures/generic-dataset+metawal.iso19115-3.xml @@ -0,0 +1,822 @@ + + + + + + my-dataset-001 + + + urn:uuid + + + + + + + + + + + + + + + + + + + + + + + + + ISO 19115 + + + 2003/Cor 1:2006 + + + + + + + https://metawal.wallonie.be/geonetwork/srv/api/records/2d974612-70b1-4662-a9f4-c43cbe453773 + + + + + + + + + + + + EPSG:31370 + + + Belge 1972 / Belgian Lambert 72 (EPSG:31370) + + + + + + + + + + + + + + A very interesting dataset (un jeu de données très intéressant) + + + + + 2d974612-70b1-4662-a9f4-c43cbe453773 + + + http://geodata.wallonie.be/id/ + + + + + + + 2022-09-01T14:18:19 + + + creation + + + + + + + 2022-12-04T15:12:00 + + + revision + + + + + + + # Introduction +This dataset has been established for testing purposes. + +## Details +This is a section about details. Here is an HTML tag: <img src="http://google.com" />. And [a link](https://google.com). + +## Informations intéressantes +Cette section contient des *caractères internationaux* (ainsi que des "caractères spéciaux"). 'çàü^@/~^& + + + + + + grid + + + + + + + 10000 + + + + + + + + + Région wallonne + + + + + 2.75 + + + 6.50 + + + 49.45 + + + 50.85 + + + + + + + + + P0Y0M10D + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + author + + + + + MyOrganization + + + + + + + bob@org.net + + + + + + + https://www.my.org/info + + + + + + + + + Bob TheGreat + + + developer + + + + + + + + + + + custodian + + + + + Another Organization + + + + + + + john@org2.com + + + + + + + https://www.another.org/docs + + + + + + + + + manager + + + + + + + + + + + + + + + + geonetwork.thesaurus.local + + + + + geonetwork.thesaurus.local + + + + + + + international + + + test + + + _another_keyword_ + + + + + + + + + + + + geonetwork.thesaurus.theme + + + + + geonetwork.thesaurus.theme + + + + + + + test theme + + + + + + + + + + + + geonetwork.thesaurus.place + + + + + geonetwork.thesaurus.place + + + + + + + test place + + + + + + + + + + themeNoThesaurus + + + themeNoThesaurus 2 + + + + + + + + + + temporalNoThesaurus + + + + + agriculture + + + + + + + + Dataset access isn't possible since it does not really exist + + + + + + + + + + Contains sensitive information related to national defense + + + + + + + + + + + + + Licence ODbL mai 2013 (basée sur ODbL 1.0) + + + + + + + Should only be used as a testing tool + + + + + + + Might cause minor annoyance in people + + + + + + + http://my-org.net/one.png + + + An overview + + + + + + + http://my-org.net/two.png + + + + + + + + + + + + + distributor + + + + + Another Organization + + + + + + + bill@org2.com + + + 123 rue des moulins, 10808 Montargis, FR + + + + + + + https://www.another.org/docs + + + + + + + +11234567890 + + + + + + + + + Bill TheDistributor + + + randomWorker + + + + + + + + + + + + + + + x-gis/x-shapefile + + + + + + + + + + + http://my-org.net/download/1.zip + + + Dataset downloaded as a shapefile + + + Direct download + + + WWW:DOWNLOAD + + + + + + + + + + + + + + This record was edited manually to test the conversion processes + +As such, **it is not very interesting at all.** + + + + + + + + + + + + + + pointOfContact + + + + + MyOrganization + + + + + + + bob@org.net + + + + + + + https://www.my.org/info + + + + + + + + + Bob TheGreat + + + developer + + + + + + + + + + + 2022-02-01T15:12:00 + + + revision + + + + + + + 2021-11-15T09:00:00 + + + creation + + + + + + + 2022-01-01T10:00:00 + + + publication + + + + + + + + + + + application/geo+json + + + + + + + + + + + http://my-org.net/download/2.geojson + + + Direct download + + + WWW:DOWNLOAD + + + + + + + + + + + + + + + + + + https://my-org.net/docs/1234.pdf + + + A link to the online documentation in PDF; please forgive the typos. + + + Documentation + + + WWW:LINK + + + + + + + + + + + + + + + + + + https://my-org.net/wfs + + + This WFS service offers direct download capability + + + my:featuretype + + + OGC:WFS + + + + + + + + + + + diff --git a/libs/api/metadata-converter/src/lib/fixtures/generic-dataset.iso19115-3.xml b/libs/api/metadata-converter/src/lib/fixtures/generic-dataset.iso19115-3.xml new file mode 100644 index 0000000000..950f7d5cde --- /dev/null +++ b/libs/api/metadata-converter/src/lib/fixtures/generic-dataset.iso19115-3.xml @@ -0,0 +1,642 @@ + + + + + + my-dataset-001 + + + + + + + dataset + + + + + + + pointOfContact + + + + + MyOrganization + + + + + + + bob@org.net + + + + + + + https://www.my.org/info + + + + + + + + + Bob TheGreat + + + developer + + + + + + + + + + + 2022-02-01T15:12:00 + + + revision + + + + + + + 2021-11-15T09:00:00 + + + creation + + + + + + + 2022-01-01T10:00:00 + + + publication + + + + + + + + + A very interesting dataset (un jeu de données très intéressant) + + + + + 2022-09-01T14:18:19 + + + creation + + + + + + + 2022-12-04T15:12:00 + + + revision + + + + + + + # Introduction +This dataset has been established for testing purposes. + +## Details +This is a section about details. Here is an HTML tag: <img src="http://google.com" />. And [a link](https://google.com). + +## Informations intéressantes +Cette section contient des *caractères internationaux* (ainsi que des "caractères spéciaux"). 'çàü^@/~^& + + + + + author + + + + + MyOrganization + + + + + + + bob@org.net + + + + + + + https://www.my.org/info + + + + + + + + + Bob TheGreat + + + developer + + + + + + + + + + + custodian + + + + + Another Organization + + + + + + + john@org2.com + + + + + + + https://www.another.org/docs + + + + + + + + + manager + + + + + + + + + + + + + + + + geonetwork.thesaurus.local + + + + + geonetwork.thesaurus.local + + + + + + + international + + + test + + + _another_keyword_ + + + + + + + + + + + + geonetwork.thesaurus.theme + + + + + geonetwork.thesaurus.theme + + + + + + + test theme + + + + + + + + + + + + geonetwork.thesaurus.place + + + + + geonetwork.thesaurus.place + + + + + + + test place + + + + + + + + + + themeNoThesaurus + + + themeNoThesaurus 2 + + + + + + + + + + temporalNoThesaurus + + + + + agriculture + + + + + + + + Dataset access isn't possible since it does not really exist + + + + + + + + + + Contains sensitive information related to national defense + + + + + + + + + + + + + Licence ODbL mai 2013 (basée sur ODbL 1.0) + + + + + + + Should only be used as a testing tool + + + + + + + Might cause minor annoyance in people + + + + + onGoing + + + + + P0Y0M10D + + + + + grid + + + + + http://my-org.net/one.png + + + An overview + + + + + + + http://my-org.net/two.png + + + + + + + + + + + + + distributor + + + + + Another Organization + + + + + + + bill@org2.com + + + 123 rue des moulins, 10808 Montargis, FR + + + + + + + https://www.another.org/docs + + + + + + + +11234567890 + + + + + + + + + Bill TheDistributor + + + randomWorker + + + + + + + + + + + + + + + x-gis/x-shapefile + + + + + + + + + + + http://my-org.net/download/1.zip + + + Dataset downloaded as a shapefile + + + Direct download + + + WWW:DOWNLOAD + + + + + + + + + + + + + + + + + + application/geo+json + + + + + + + + + + + http://my-org.net/download/2.geojson + + + Direct download + + + WWW:DOWNLOAD + + + + + + + + + + + + + + + + + + https://my-org.net/docs/1234.pdf + + + A link to the online documentation in PDF; please forgive the typos. + + + Documentation + + + WWW:LINK + + + + + + + + + + + + + + + + + + https://my-org.net/wfs + + + This WFS service offers direct download capability + + + my:featuretype + + + OGC:WFS + + + + + + + + + + + + + + This record was edited manually to test the conversion processes + +As such, **it is not very interesting at all.** + + + + diff --git a/libs/api/metadata-converter/src/lib/fixtures/generic-dataset.iso19139.xml b/libs/api/metadata-converter/src/lib/fixtures/generic-dataset.iso19139.xml index fcffbe3bb0..5cd5e5a7e4 100644 --- a/libs/api/metadata-converter/src/lib/fixtures/generic-dataset.iso19139.xml +++ b/libs/api/metadata-converter/src/lib/fixtures/generic-dataset.iso19139.xml @@ -161,6 +161,50 @@ Cette section contient des *caractères internationaux* (ainsi que des "caractè + + + + Bill TheDistributor + + + randomWorker + + + Another Organization + + + + + + + +11234567890 + + + + + + + bill@org2.com + + + 123 rue des moulins, 10808 Montargis, FR + + + + + + + https://www.another.org/docs + + + + + + + + + + diff --git a/libs/api/metadata-converter/src/lib/fixtures/generic.records.ts b/libs/api/metadata-converter/src/lib/fixtures/generic.records.ts index 4f6d5b838a..54994c3f99 100644 --- a/libs/api/metadata-converter/src/lib/fixtures/generic.records.ts +++ b/libs/api/metadata-converter/src/lib/fixtures/generic.records.ts @@ -10,6 +10,21 @@ export const GENERIC_DATASET_RECORD: DatasetRecord = { description: 'A generic organization', }, contacts: [ + { + email: 'bob@org.net', + role: 'point_of_contact', + organization: { + name: 'MyOrganization', + website: new URL('https://www.my.org/info'), + logoUrl: new URL('https://www.my.org/logo.png'), + description: 'A generic organization', + }, + firstName: 'Bob', + lastName: 'TheGreat', + position: 'developer', + }, + ], + contactsForResource: [ { email: 'bob@org.net', role: 'author', @@ -32,14 +47,26 @@ export const GENERIC_DATASET_RECORD: DatasetRecord = { }, position: 'manager', }, + { + email: 'bill@org2.com', + role: 'distributor', + organization: { + name: 'Another Organization', + website: new URL('https://www.another.org/docs'), + }, + position: 'randomWorker', + address: '123 rue des moulins, 10808 Montargis, FR', + phone: '+11234567890', + lastName: 'TheDistributor', + firstName: 'Bill', + }, ], - contactsForResource: [], status: 'ongoing', - recordCreated: new Date('2022-02-01T15:12:00'), - recordPublished: new Date('2022-02-01T15:12:00'), + recordCreated: new Date('2021-11-15T09:00:00'), + recordPublished: new Date('2022-01-01T10:00:00'), recordUpdated: new Date('2022-02-01T15:12:00'), - datasetCreated: new Date('2022-09-01T14:18:19'), - datasetUpdated: new Date('2022-12-04T15:12:00'), + resourceCreated: new Date('2022-09-01T14:18:19'), + resourceUpdated: new Date('2022-12-04T15:12:00'), title: 'A very interesting dataset (un jeu de données très intéressant)', abstract: `# Introduction This dataset has been established for testing purposes. diff --git a/libs/api/metadata-converter/src/lib/fixtures/geo2france.records.ts b/libs/api/metadata-converter/src/lib/fixtures/geo2france.records.ts index 36dc988458..d85325be99 100644 --- a/libs/api/metadata-converter/src/lib/fixtures/geo2france.records.ts +++ b/libs/api/metadata-converter/src/lib/fixtures/geo2france.records.ts @@ -16,11 +16,18 @@ export const GEO2FRANCE_PLU_DATASET_RECORD: DatasetRecord = { }, }, ], - contactsForResource: [], - recordCreated: new Date('2022-04-15T14:18:19'), - recordPublished: new Date('2022-04-15T14:18:19'), + contactsForResource: [ + { + email: 'sig@agglo-compiegne.fr', + role: 'point_of_contact', + organization: { + name: 'GeoCompiegnois', + }, + }, + ], recordUpdated: new Date('2022-04-15T14:18:19'), - datasetUpdated: new Date('2022-03-29'), + resourcePublished: new Date('2022-05-01'), + resourceUpdated: new Date('2022-03-29'), title: "Plan local d'urbanisme (PLU) dématérialisé - commune d'Avrigny - approbation du 29/03/2022", // data revision: 2022-03-29 ??? diff --git a/libs/api/metadata-converter/src/lib/fixtures/geocat-ch.records.ts b/libs/api/metadata-converter/src/lib/fixtures/geocat-ch.records.ts index 9d3bc7a21f..713751065e 100644 --- a/libs/api/metadata-converter/src/lib/fixtures/geocat-ch.records.ts +++ b/libs/api/metadata-converter/src/lib/fixtures/geocat-ch.records.ts @@ -13,6 +13,17 @@ export const GEOCAT_CH_DATASET_RECORD: DatasetRecord = { { email: 'rolf.giezendanner@are.admin.ch', role: 'point_of_contact', + address: 'Ittigen, 3063, CH', + organization: { + name: 'Bundesamt für Raumentwicklung', + }, + }, + ], + contactsForResource: [ + { + email: 'rolf.giezendanner@are.admin.ch', + role: 'point_of_contact', + address: 'Ittigen, 3063, CH', organization: { name: 'Bundesamt für Raumentwicklung', }, @@ -25,12 +36,9 @@ export const GEOCAT_CH_DATASET_RECORD: DatasetRecord = { }, }, ], - contactsForResource: [], - recordCreated: new Date('2022-02-22T19:40:06'), - recordPublished: new Date('2022-02-22T19:40:06'), recordUpdated: new Date('2022-02-22T19:40:06'), - datasetCreated: new Date('1999-01-01T00:00:00'), - datasetUpdated: new Date('2009-01-01'), + resourceCreated: new Date('1999-01-01T00:00:00'), + resourceUpdated: new Date('2009-01-01'), title: 'Alpenkonvention', abstract: `Perimeter der Alpenkonvention in der Schweiz. Die Alpenkonvention ist ein völkerrechtlicher Vertrag zwischen den acht Alpenländern Deutschland, Frankreich, Italien, Liechtenstein, Monaco, Österreich, Schweiz, Slowenien sowie der Europäischen Union. Das Ziel des Übereinkommens ist der Schutz der Alpen durch eine sektorübergreifende, ganzheitliche und nachhaltige Politik.`, overviews: [], @@ -339,9 +347,25 @@ export const GEOCAT_CH_SERVICE_RECORD: ServiceRecord = { website: new URL('https://www.sg.ch/bauen/geoinformation/datenbezug.html'), }, contacts: [ + { + email: 'geodaten@sg.ch', + role: 'resource_provider', + phone: '+41(0)58 229 31 47', + address: 'St. Gallen, 9001, CH', + organization: { + name: 'Amt für Raumentwicklung und Geoinformation (SG)', + website: new URL( + 'https://www.sg.ch/bauen/geoinformation/datenbezug.html' + ), + }, + }, + ], + contactsForResource: [ { email: 'geodaten@sg.ch', role: 'other', + phone: '+41(0)58 229 31 47', + address: 'St. Gallen, 9001, CH', organization: { name: 'Amt für Raumentwicklung und Geoinformation (SG)', website: new URL( @@ -352,6 +376,8 @@ export const GEOCAT_CH_SERVICE_RECORD: ServiceRecord = { { email: 'geodaten@sg.ch', role: 'publisher', + phone: '+41(0)58 229 31 47', + address: 'St. Gallen, 9001, CH', organization: { name: 'Amt für Raumentwicklung und Geoinformation (SG)', website: new URL( @@ -360,9 +386,9 @@ export const GEOCAT_CH_SERVICE_RECORD: ServiceRecord = { }, }, ], - recordCreated: new Date('2022-03-07T01:15:51+01:00'), - recordPublished: new Date('2022-03-07T01:15:51+01:00'), recordUpdated: new Date('2022-03-07T01:15:51+01:00'), + resourceCreated: new Date('2021-09-15'), + resourceUpdated: new Date('2021-09-17'), title: 'Verkehrsregelungsanlagen (WMS)', abstract: `Diese Karte beinhaltet die Verkehrsregelungsanlagen des Kantons St.Gallen.`, overviews: [ diff --git a/libs/api/metadata-converter/src/lib/fixtures/metawal.iso19115-3.dataset.xml b/libs/api/metadata-converter/src/lib/fixtures/metawal.iso19115-3.dataset.xml index 2b5db674fe..1b1d2824db 100644 --- a/libs/api/metadata-converter/src/lib/fixtures/metawal.iso19115-3.dataset.xml +++ b/libs/api/metadata-converter/src/lib/fixtures/metawal.iso19115-3.dataset.xml @@ -223,13 +223,13 @@ Cette donnée ponctuelle reprend la localisation des passages pour piétons sur l’ensemble des routes régionales du territoire. - Les passages pour piétons constituent un élément spécifique du réseau et font l'objet d'une attention particulière du gestionnaire de la voirie. L’implantation d’un passage piéton est régi par un arrêté ministériel. +Les passages pour piétons constituent un élément spécifique du réseau et font l'objet d'une attention particulière du gestionnaire de la voirie. L’implantation d’un passage piéton est régi par un arrêté ministériel. - Une distinction est faite entre les passages aux abords des écoles et les autres ; les premiers cités étant dotés d’aménagements spécifiques (éclairage, barrières,…). +Une distinction est faite entre les passages aux abords des écoles et les autres ; les premiers cités étant dotés d’aménagements spécifiques (éclairage, barrières,…). - Parmi les données attributaires de chaque élément, sont mentionnés la localisation de l’élément (route , BK, coordonnées XY) ainsi que le District gestionnaire. D’autres informations sont présentes dans la base de données : environnement, vitesse autorisée, aménagements, éléments de signalisation, distances de visibilité, … +Parmi les données attributaires de chaque élément, sont mentionnés la localisation de l’élément (route , BK, coordonnées XY) ainsi que le District gestionnaire. D’autres informations sont présentes dans la base de données : environnement, vitesse autorisée, aménagements, éléments de signalisation, distances de visibilité, … - Toutes ces données sont reprises dans BDR. +Toutes ces données sont reprises dans BDR. En 2002, la Direction de le Sécurité des aménagements de voiries a décidé de mener une analyse approfondie de la sécurité sur les passages piétons. En effet, depuis 1996 (loi du 21/12/2006), le piéton manifestant clairement son intention de traverser au droit d'un passage piéton bénéficie d'une priorité absolue sur les automobilistes. - La première démarche de cette direction a été de créer une base de données. Un relevé de tous les passages a donc été effectué de 2003 à 2005 et un peu moins de 6000 passages ont été répertoriés via une fiche de terrain qui est toujours utilisée actuellement. +La première démarche de cette direction a été de créer une base de données. Un relevé de tous les passages a donc été effectué de 2003 à 2005 et un peu moins de 6000 passages ont été répertoriés via une fiche de terrain qui est toujours utilisée actuellement. - Cette donnée a été intégrée dans la base de données routière (BDR). Les Districts routiers font la mise à jour directement dans cette base de données. +Cette donnée a été intégrée dans la base de données routière (BDR). Les Districts routiers font la mise à jour directement dans cette base de données. - Ces données, intégrées dans la Banque de Données routières (BDR), ont fait l’objet d’une mise à jour massive en 2014-2015. - Depuis, ce sont les Districts routiers qui assurent la tenue à jour de ces informations directement dans la base de données. +Ces données, intégrées dans la Banque de Données routières (BDR), ont fait l’objet d’une mise à jour massive en 2014-2015. +Depuis, ce sont les Districts routiers qui assurent la tenue à jour de ces informations directement dans la base de données. diff --git a/libs/api/metadata-converter/src/lib/fixtures/metawal.records.ts b/libs/api/metadata-converter/src/lib/fixtures/metawal.records.ts new file mode 100644 index 0000000000..576545697c --- /dev/null +++ b/libs/api/metadata-converter/src/lib/fixtures/metawal.records.ts @@ -0,0 +1,580 @@ +import { + DatasetRecord, + ServiceRecord, +} from '@geonetwork-ui/common/domain/model/record' + +export const METAWAL_DATASET_RECORD: DatasetRecord = { + uniqueIdentifier: '2d974612-70b1-4662-a9f4-c43cbe453773', + abstract: `Cette donnée ponctuelle reprend la localisation des passages pour piétons sur l’ensemble des routes régionales du territoire. + +Les passages pour piétons constituent un élément spécifique du réseau et font l'objet d'une attention particulière du gestionnaire de la voirie. L’implantation d’un passage piéton est régi par un arrêté ministériel. + +Une distinction est faite entre les passages aux abords des écoles et les autres ; les premiers cités étant dotés d’aménagements spécifiques (éclairage, barrières,…). + +Parmi les données attributaires de chaque élément, sont mentionnés la localisation de l’élément (route , BK, coordonnées XY) ainsi que le District gestionnaire. D’autres informations sont présentes dans la base de données : environnement, vitesse autorisée, aménagements, éléments de signalisation, distances de visibilité, … + +Toutes ces données sont reprises dans BDR.`, + contacts: [ + { + firstName: 'Frédéric', + lastName: 'Plumier', + position: 'Attaché', + email: 'frederic.plumier@spw.wallonie.be', + address: 'Boulevard du Nord, 8, Namur, 5000, Belgique', + organization: { + name: 'Direction Asset Management (SPW - Mobilité et Infrastructures - Direction Asset Management)', + }, + role: 'point_of_contact', + }, + ], + contactsForResource: [ + { + email: 'helpdesk.carto@spw.wallonie.be', + organization: { + name: "Helpdesk carto du SPW (SPW - Secrétariat général - SPW Digital - Département de la Géomatique - Direction de l'Intégration des géodonnées)", + }, + role: 'point_of_contact', + }, + { + firstName: 'Frédéric', + lastName: 'Plumier', + position: 'Attaché', + email: 'frederic.plumier@spw.wallonie.be', + address: 'Boulevard du Nord, 8, NAMUR, 5000, Belgique', + phone: '+32 (0)81/772760', + organization: { + name: 'Direction Asset Management (SPW - Mobilité et Infrastructures - Direction Asset Management)', + }, + role: 'custodian', + }, + { + email: 'missing@missing.com', + organization: { + name: 'Service public de Wallonie (SPW)', + website: new URL('https://geoportail.wallonie.be'), + }, + role: 'owner', + }, + { + email: 'helpdesk.carto@spw.wallonie.be', + organization: { + name: 'Service public de Wallonie (SPW)', + }, + role: 'distributor', + }, + ], + recordCreated: new Date('2019-04-02T12:34:35'), + recordUpdated: new Date('2022-06-16T05:01:21'), + resourceCreated: new Date('2002-01-01'), + resourceUpdated: new Date('2022-06-16'), + resourcePublished: new Date('2022-06-16'), + distributions: [ + { + description: + "Application de consultation des routes et autoroutes de Wallonie. Cette application est sécurisée et n'est accessible que pour les agents de la DGO1 du SPW.", + name: 'Portail cartographique des routes - Application sécurisée', + type: 'link', + url: new URL('http://geoapps.spw.wallonie.be/portailRoutes/'), + }, + { + description: + "Application sécurisée permettant d'accéder aux rapports pour les passages pour piétons dans la BDR", + name: 'Rapport pour les passages pour piétons dans la BDR - Application sécurisée', + type: 'link', + url: new URL('http://rapport.papiweb.spw.wallonie.be/RapportPaPi/'), + }, + { + description: + 'Portail de la DGO1 - Routes et Bâtiments relatif aux autoroutes et routes de Wallonie', + name: 'Portail autouroutes et routes de Wallonie', + type: 'link', + url: new URL('http://routes.wallonie.be/'), + }, + ], + keywords: [ + { + label: 'Routes', + thesaurus: { + id: 'geonetwork.thesaurus.external.theme.Themes_geoportail_wallon_hierarchy', + name: 'Thèmes du géoportail wallon', + url: new URL( + 'https://metawal.wallonie.be/geonetwork/srv/api/registries/vocabularies/external.theme.Themes_geoportail_wallon_hierarchy' + ), + }, + type: 'theme', + }, + { + label: 'géographie', + thesaurus: { + id: 'geonetwork.thesaurus.external.theme.gemet-theme', + name: 'GEMET themes', + url: new URL( + 'https://metawal.wallonie.be/geonetwork/srv/api/registries/vocabularies/external.theme.gemet-theme' + ), + }, + type: 'theme', + }, + { + label: 'transport', + thesaurus: { + id: 'geonetwork.thesaurus.external.theme.gemet-theme', + name: 'GEMET themes', + url: new URL( + 'https://metawal.wallonie.be/geonetwork/srv/api/registries/vocabularies/external.theme.gemet-theme' + ), + }, + type: 'theme', + }, + { + label: 'infrastructure routière', + thesaurus: { + id: 'geonetwork.thesaurus.external.theme.gemet', + name: 'GEMET', + url: new URL( + 'https://metawal.wallonie.be/geonetwork/srv/api/registries/vocabularies/external.theme.gemet' + ), + }, + type: 'theme', + }, + { + label: 'contrôle de la circulation', + thesaurus: { + id: 'geonetwork.thesaurus.external.theme.gemet', + name: 'GEMET', + url: new URL( + 'https://metawal.wallonie.be/geonetwork/srv/api/registries/vocabularies/external.theme.gemet' + ), + }, + type: 'theme', + }, + { + label: 'réseau routier', + thesaurus: { + id: 'geonetwork.thesaurus.external.theme.gemet', + name: 'GEMET', + url: new URL( + 'https://metawal.wallonie.be/geonetwork/srv/api/registries/vocabularies/external.theme.gemet' + ), + }, + type: 'theme', + }, + { + label: 'route', + thesaurus: { + id: 'geonetwork.thesaurus.external.theme.gemet', + name: 'GEMET', + url: new URL( + 'https://metawal.wallonie.be/geonetwork/srv/api/registries/vocabularies/external.theme.gemet' + ), + }, + type: 'theme', + }, + { + label: 'route', + thesaurus: { + id: 'geonetwork.thesaurus.external.theme.gemet', + name: 'GEMET', + url: new URL( + 'https://metawal.wallonie.be/geonetwork/srv/api/registries/vocabularies/external.theme.gemet' + ), + }, + type: 'theme', + }, + { + label: 'surveillance du trafic', + thesaurus: { + id: 'geonetwork.thesaurus.external.theme.gemet', + name: 'GEMET', + url: new URL( + 'https://metawal.wallonie.be/geonetwork/srv/api/registries/vocabularies/external.theme.gemet' + ), + }, + type: 'theme', + }, + { + label: 'réglementation de la circulation', + thesaurus: { + id: 'geonetwork.thesaurus.external.theme.gemet', + name: 'GEMET', + url: new URL( + 'https://metawal.wallonie.be/geonetwork/srv/api/registries/vocabularies/external.theme.gemet' + ), + }, + type: 'theme', + }, + { + label: 'trafic routier', + thesaurus: { + id: 'geonetwork.thesaurus.external.theme.gemet', + name: 'GEMET', + url: new URL( + 'https://metawal.wallonie.be/geonetwork/srv/api/registries/vocabularies/external.theme.gemet' + ), + }, + type: 'theme', + }, + { + label: 'transport terrestre', + thesaurus: { + id: 'geonetwork.thesaurus.external.theme.gemet', + name: 'GEMET', + url: new URL( + 'https://metawal.wallonie.be/geonetwork/srv/api/registries/vocabularies/external.theme.gemet' + ), + }, + type: 'theme', + }, + { + label: 'véhicule', + thesaurus: { + id: 'geonetwork.thesaurus.external.theme.gemet', + name: 'GEMET', + url: new URL( + 'https://metawal.wallonie.be/geonetwork/srv/api/registries/vocabularies/external.theme.gemet' + ), + }, + type: 'theme', + }, + { + label: 'autoroute', + thesaurus: { + id: 'geonetwork.thesaurus.external.theme.gemet', + name: 'GEMET', + url: new URL( + 'https://metawal.wallonie.be/geonetwork/srv/api/registries/vocabularies/external.theme.gemet' + ), + }, + type: 'theme', + }, + { + label: 'route à grande circulation', + thesaurus: { + id: 'geonetwork.thesaurus.external.theme.gemet', + name: 'GEMET', + url: new URL( + 'https://metawal.wallonie.be/geonetwork/srv/api/registries/vocabularies/external.theme.gemet' + ), + }, + type: 'theme', + }, + { + label: 'transport en commun', + thesaurus: { + id: 'geonetwork.thesaurus.external.theme.gemet', + name: 'GEMET', + url: new URL( + 'https://metawal.wallonie.be/geonetwork/srv/api/registries/vocabularies/external.theme.gemet' + ), + }, + type: 'theme', + }, + { + label: 'BDInfraSIG', + thesaurus: { + id: 'geonetwork.thesaurus.external.theme.infraSIG', + name: 'Mots-clés InfraSIG', + url: new URL( + 'https://metawal.wallonie.be/geonetwork/srv/api/registries/vocabularies/external.theme.infraSIG' + ), + }, + type: 'theme', + }, + { + label: 'passage piéton', + type: 'theme', + }, + { + label: 'voirie régionale', + type: 'theme', + }, + { + label: 'route régionale', + type: 'theme', + }, + { + label: 'voie de communication', + type: 'theme', + }, + { + label: 'code de la rue', + type: 'theme', + }, + { + label: 'aménagement routier', + type: 'theme', + }, + { + label: 'usager faible', + type: 'theme', + }, + { + label: 'piéton', + type: 'theme', + }, + { + label: 'gestion de la circulation', + type: 'theme', + }, + { + label: 'visibilité', + type: 'theme', + }, + { + label: 'code de la route', + type: 'theme', + }, + { + label: 'accotement', + type: 'theme', + }, + { + label: 'marquage', + type: 'theme', + }, + { + label: 'pied', + type: 'theme', + }, + { + label: 'signalisation', + type: 'theme', + }, + { + label: 'Régional', + thesaurus: { + id: 'geonetwork.thesaurus.external.theme.httpinspireeceuropaeumetadatacodelistSpatialScope-SpatialScope', + name: 'Champ géographique', + url: new URL( + 'https://metawal.wallonie.be/geonetwork/srv/api/registries/vocabularies/external.theme.httpinspireeceuropaeumetadatacodelistSpatialScope-SpatialScope' + ), + }, + type: 'theme', + }, + ], + kind: 'dataset', + legalConstraints: [], + licenses: [ + { + text: "Les conditions générales d'accès s’appliquent.", + url: new URL( + 'https://geoportail.wallonie.be/files/documents/ConditionsSPW/DataSPW-CGA.pdf' + ), + }, + { + text: "Les conditions générales d'utilisation s'appliquent.", + url: new URL( + 'https://geoportail.wallonie.be/files/documents/ConditionsSPW/DataSPW-CGU.pdf' + ), + }, + ], + lineage: `En 2002, la Direction de le Sécurité des aménagements de voiries a décidé de mener une analyse approfondie de la sécurité sur les passages piétons. En effet, depuis 1996 (loi du 21/12/2006), le piéton manifestant clairement son intention de traverser au droit d'un passage piéton bénéficie d'une priorité absolue sur les automobilistes. + +La première démarche de cette direction a été de créer une base de données. Un relevé de tous les passages a donc été effectué de 2003 à 2005 et un peu moins de 6000 passages ont été répertoriés via une fiche de terrain qui est toujours utilisée actuellement. + +Cette donnée a été intégrée dans la base de données routière (BDR). Les Districts routiers font la mise à jour directement dans cette base de données. + +Ces données, intégrées dans la Banque de Données routières (BDR), ont fait l’objet d’une mise à jour massive en 2014-2015. +Depuis, ce sont les Districts routiers qui assurent la tenue à jour de ces informations directement dans la base de données.`, + otherConstraints: [], + overviews: [ + { + description: 'PASSAGES_PIETONS', + url: new URL( + 'https://metawal.wallonie.be/geonetwork/srv/api/records/2d974612-70b1-4662-a9f4-c43cbe453773/attachments/PASSAGES_PIETONS.png' + ), + }, + ], + ownerOrganization: { + name: 'Direction Asset Management (SPW - Mobilité et Infrastructures - Direction Asset Management)', + }, + securityConstraints: [], + spatialExtents: [], + spatialRepresentation: 'vector', + status: 'ongoing', + temporalExtents: [], + title: 'Passages pour piéton', + topics: ['transportation'], + updateFrequency: 'continual', + landingPage: new URL( + 'https://metawal.wallonie.be/geonetwork/srv/api/records/2d974612-70b1-4662-a9f4-c43cbe453773' + ), + languages: [], +} + +export const METAWAL_SERVICE_RECORD: ServiceRecord = { + uniqueIdentifier: '6d2b6fdb-f1ea-4d48-8697-a0c05512f1dc', + abstract: + "Ce service permet de visualiser les données du bâti et du parcellaire CADMAP 2016 fournies par l'Administration Générale de la Documentation Patrimoniale (AGDP - tous droits réservés) au Service public de Wallonie.", + contacts: [ + { + email: 'helpdesk.carto@spw.wallonie.be', + organization: { + name: "Direction de l'Intégration des géodonnées (SPW - Secrétariat général - SPW Digital - Département de la Géomatique - Direction de l'Intégration des géodonnées)", + }, + role: 'point_of_contact', + }, + ], + contactsForResource: [ + { + email: 'helpdesk.carto@spw.wallonie.be', + organization: { + name: "Helpdesk carto du SPW (SPW - Secrétariat général - SPW Digital - Département de la Géomatique - Direction de l'Intégration des géodonnées)", + }, + role: 'point_of_contact', + }, + { + email: 'helpdesk.carto@spw.wallonie.be', + organization: { + name: "Direction de l'Intégration des géodonnées (SPW - Secrétariat général - SPW Digital - Département de la Géomatique - Direction de l'Intégration des géodonnées)", + }, + role: 'custodian', + }, + { + email: 'missing@missing.com', + organization: { + name: 'Service public de Wallonie (SPW)', + website: new URL('https://geoportail.wallonie.be/'), + }, + role: 'owner', + }, + { + email: 'helpdesk.carto@spw.wallonie.be', + organization: { + name: 'Service public de Wallonie (SPW)', + }, + role: 'distributor', + }, + ], + keywords: [ + { + label: 'Plans et règlements', + thesaurus: { + id: 'geonetwork.thesaurus.external.theme.Themes_geoportail_wallon_hierarchy', + name: 'Thèmes du géoportail wallon', + url: new URL( + 'https://metawal.wallonie.be/geonetwork/srv/api/registries/vocabularies/external.theme.Themes_geoportail_wallon_hierarchy' + ), + }, + type: 'theme', + }, + { + label: 'Données de base', + thesaurus: { + id: 'geonetwork.thesaurus.external.theme.Themes_geoportail_wallon_hierarchy', + name: 'Thèmes du géoportail wallon', + url: new URL( + 'https://metawal.wallonie.be/geonetwork/srv/api/registries/vocabularies/external.theme.Themes_geoportail_wallon_hierarchy' + ), + }, + type: 'theme', + }, + { + label: 'administration', + thesaurus: { + id: 'geonetwork.thesaurus.external.theme.gemet-theme', + name: 'GEMET themes', + url: new URL( + 'https://metawal.wallonie.be/geonetwork/srv/api/registries/vocabularies/external.theme.gemet-theme' + ), + }, + type: 'theme', + }, + { + label: 'Reporting INSPIRENO', + thesaurus: { + id: 'geonetwork.thesaurus.external.theme.infraSIG', + name: 'Mots-clés InfraSIG', + url: new URL( + 'https://metawal.wallonie.be/geonetwork/srv/api/registries/vocabularies/external.theme.infraSIG' + ), + }, + type: 'theme', + }, + { + label: 'Ministère des Finances', + type: 'theme', + }, + { + label: 'SPF Finances', + type: 'theme', + }, + { + label: 'Cadastre', + type: 'theme', + }, + { + label: 'Administration Générale de la Documentation Patrimoniale', + type: 'theme', + }, + { + label: 'Etat Fédéral', + type: 'theme', + }, + { + label: 'Cadmap', + type: 'theme', + }, + { + label: 'Cadgis', + type: 'theme', + }, + { + label: 'Propriété', + type: 'theme', + }, + { + label: 'Plan cadastral', + type: 'theme', + }, + { + label: 'Fiscal', + type: 'theme', + }, + { + label: 'Zonage', + type: 'theme', + }, + { + label: 'Privé', + type: 'theme', + }, + { + label: 'Parcellaire cadastral', + type: 'theme', + }, + ], + kind: 'service', + legalConstraints: [], + licenses: [ + { + text: "Les conditions d'utilisation du service sont régies par les Conditions d’accès et d’utilisation des services web géographiques de visualisation du Service public de Wallonie consultables à l'adresse https://geoportail.wallonie.be/files/documents/ConditionsSPW/LicServicesSPW.pdf\n\n Elles s'appliquent sans préjudice des conditions d'accès à la donnée décrites dans la fiche de la donnée.", + }, + ], + onlineResources: [ + { + description: + 'Ce service ESRI-REST permet de visualiser la couche de données "Plan parcellaire cadastral - situation 01/01/2016" (uniquement les données du bâti et le parcellaire)', + endpointUrl: new URL( + 'https://geoservices.wallonie.be/arcgis/rest/services/PLAN_REGLEMENT/CADMAP_2016_PARCELLES/MapServer' + ), + protocol: 'esriRest', + type: 'endpoint', + }, + ], + otherConstraints: [], + overviews: [], + ownerOrganization: { + name: "Direction de l'Intégration des géodonnées (SPW - Secrétariat général - SPW Digital - Département de la Géomatique - Direction de l'Intégration des géodonnées)", + }, + recordCreated: new Date('2019-04-02T12:31:58'), + recordUpdated: new Date('2022-02-09T11:31:06.766Z'), + resourcePublished: new Date('2016-12-01'), + securityConstraints: [], + title: + 'Plan parcellaire cadastral - situation au 01/01/2016 - Service de visualisation REST', + topics: [], + languages: [], + landingPage: new URL( + 'https://metawal.wallonie.be/geonetwork/srv/api/records/6d2b6fdb-f1ea-4d48-8697-a0c05512f1dc' + ), +} diff --git a/libs/api/metadata-converter/src/lib/gn4/atomic-operations.ts b/libs/api/metadata-converter/src/lib/gn4/atomic-operations.ts index da3842711b..c3a783cd08 100644 --- a/libs/api/metadata-converter/src/lib/gn4/atomic-operations.ts +++ b/libs/api/metadata-converter/src/lib/gn4/atomic-operations.ts @@ -2,9 +2,9 @@ import { Individual, Organization, } from '@geonetwork-ui/common/domain/model/record' -import { getRoleFromRoleCode } from '../iso19139/codelists/role.mapper' +import { getRoleFromRoleCode } from '../iso19139/utils/role.mapper' import { Thesaurus } from './types' -import { getKeywordTypeFromKeywordTypeCode } from '../iso19139/codelists/keyword.mapper' +import { getKeywordTypeFromKeywordTypeCode } from '../iso19139/utils/keyword.mapper' export type SourceWithUnknownProps = { [key: string]: unknown } diff --git a/libs/api/metadata-converter/src/lib/gn4/gn4.metadata.mapper.spec.ts b/libs/api/metadata-converter/src/lib/gn4/gn4.converter.spec.ts similarity index 99% rename from libs/api/metadata-converter/src/lib/gn4/gn4.metadata.mapper.spec.ts rename to libs/api/metadata-converter/src/lib/gn4/gn4.converter.spec.ts index 88a8ac16ed..24cd15fe1a 100644 --- a/libs/api/metadata-converter/src/lib/gn4/gn4.metadata.mapper.spec.ts +++ b/libs/api/metadata-converter/src/lib/gn4/gn4.converter.spec.ts @@ -3,7 +3,7 @@ import { ES_FIXTURE_FULL_RESPONSE, hitsOnly, } from '@geonetwork-ui/common/fixtures' -import { Gn4MetadataMapper } from './gn4.metadata.mapper' +import { Gn4Converter } from './gn4.converter' import { of } from 'rxjs' import { TestBed } from '@angular/core/testing' import { MetadataUrlService } from './metadata-url.service' @@ -37,8 +37,8 @@ const translateServiceMock = { currentLang: 'de', } -describe('Gn4MetadataMapper', () => { - let service: Gn4MetadataMapper +describe('Gn4Converter', () => { + let service: Gn4Converter beforeAll(() => { window.console.warn = jest.fn() @@ -61,13 +61,13 @@ describe('Gn4MetadataMapper', () => { }) it('should be created', () => { - service = TestBed.inject(Gn4MetadataMapper) + service = TestBed.inject(Gn4Converter) expect(service).toBeTruthy() }) describe('methods', () => { beforeEach(() => { - service = TestBed.inject(Gn4MetadataMapper) + service = TestBed.inject(Gn4Converter) }) describe('#readRecords', () => { it('outputs records', async () => { diff --git a/libs/api/metadata-converter/src/lib/gn4/gn4.metadata.mapper.ts b/libs/api/metadata-converter/src/lib/gn4/gn4.converter.ts similarity index 91% rename from libs/api/metadata-converter/src/lib/gn4/gn4.metadata.mapper.ts rename to libs/api/metadata-converter/src/lib/gn4/gn4.converter.ts index 939f8c7939..c3c0dfd90e 100644 --- a/libs/api/metadata-converter/src/lib/gn4/gn4.metadata.mapper.ts +++ b/libs/api/metadata-converter/src/lib/gn4/gn4.converter.ts @@ -2,14 +2,14 @@ import { Gn4FieldMapper } from './gn4.field.mapper' import { lastValueFrom } from 'rxjs' import { OrganizationsServiceInterface } from '@geonetwork-ui/common/domain/organizations.service.interface' import { CatalogRecord } from '@geonetwork-ui/common/domain/model/record' -import { MetadataBaseMapper } from '../metadata-base.mapper' +import { BaseConverter } from '../base.converter' import { Injectable } from '@angular/core' import { Gn4Record } from './types' @Injectable({ providedIn: 'root', }) -export class Gn4MetadataMapper extends MetadataBaseMapper { +export class Gn4Converter extends BaseConverter { constructor( private fieldMapper: Gn4FieldMapper, private orgsService: OrganizationsServiceInterface diff --git a/libs/api/metadata-converter/src/lib/gn4/gn4.field.mapper.ts b/libs/api/metadata-converter/src/lib/gn4/gn4.field.mapper.ts index a6941f2e1e..625b857d28 100644 --- a/libs/api/metadata-converter/src/lib/gn4/gn4.field.mapper.ts +++ b/libs/api/metadata-converter/src/lib/gn4/gn4.field.mapper.ts @@ -15,8 +15,8 @@ import { } from './atomic-operations' import { MetadataUrlService } from './metadata-url.service' import { Injectable } from '@angular/core' -import { getStatusFromStatusCode } from '../iso19139/codelists/status.mapper' -import { getUpdateFrequencyFromFrequencyCode } from '../iso19139/codelists/update-frequency.mapper' +import { getStatusFromStatusCode } from '../iso19139/utils/status.mapper' +import { getUpdateFrequencyFromFrequencyCode } from '../iso19139/utils/update-frequency.mapper' import { CatalogRecord, Constraint, diff --git a/libs/api/metadata-converter/src/lib/gn4/index.ts b/libs/api/metadata-converter/src/lib/gn4/index.ts new file mode 100644 index 0000000000..fd0b48574c --- /dev/null +++ b/libs/api/metadata-converter/src/lib/gn4/index.ts @@ -0,0 +1,3 @@ +export * from './gn4.converter' +export * from './atomic-operations' +export * from './types' diff --git a/libs/api/metadata-converter/src/lib/iso19115-3/index.ts b/libs/api/metadata-converter/src/lib/iso19115-3/index.ts new file mode 100644 index 0000000000..a92fdb8648 --- /dev/null +++ b/libs/api/metadata-converter/src/lib/iso19115-3/index.ts @@ -0,0 +1 @@ +export * from './iso19115-3.converter' diff --git a/libs/api/metadata-converter/src/lib/iso19115-3/iso19115-3.converter.spec.ts b/libs/api/metadata-converter/src/lib/iso19115-3/iso19115-3.converter.spec.ts new file mode 100644 index 0000000000..2a075bd1c1 --- /dev/null +++ b/libs/api/metadata-converter/src/lib/iso19115-3/iso19115-3.converter.spec.ts @@ -0,0 +1,140 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +import { Iso191153Converter } from './iso19115-3.converter' +import { parseXmlString, xmlToString } from '../xml-utils' +// @ts-ignore +import GENERIC_DATASET from '../fixtures/generic-dataset.iso19115-3.xml' +// @ts-ignore +import GENERIC_DATASET_PLUS_METAWAL_DATASET from '../fixtures/generic-dataset+metawal.iso19115-3.xml' +// @ts-ignore +import METAWAL_DATASET from '../fixtures/metawal.iso19115-3.dataset.xml' +// @ts-ignore +import METAWAL_SERVICE from '../fixtures/metawal.iso19115-3.service.xml' +import { + METAWAL_DATASET_RECORD, + METAWAL_SERVICE_RECORD, +} from '../fixtures/metawal.records' +import { GENERIC_DATASET_RECORD } from '../fixtures/generic.records' + +// this makes the xml go through the same formatting as the converter +function formatXml(xmlString: string) { + return xmlToString(parseXmlString(xmlString)) +} + +describe('ISO19115-3 converter', () => { + let converter: Iso191153Converter + + beforeEach(() => { + converter = new Iso191153Converter() + }) + + describe('from XML to model', () => { + it('produces the corresponding record (metawal dataset)', async () => { + const record = await converter.readRecord(METAWAL_DATASET) + expect(record).toStrictEqual(METAWAL_DATASET_RECORD) + }) + it('produces the corresponding record (metawal service)', async () => { + const record = await converter.readRecord(METAWAL_SERVICE) + expect(record).toStrictEqual(METAWAL_SERVICE_RECORD) + }) + it('produces the corresponding record (generic dataset)', async () => { + const record = await converter.readRecord(GENERIC_DATASET) + // exclude unsupported fields + expect(record).toStrictEqual({ + ...GENERIC_DATASET_RECORD, + ownerOrganization: { + name: GENERIC_DATASET_RECORD.ownerOrganization.name, + website: GENERIC_DATASET_RECORD.ownerOrganization.website, + }, + contacts: GENERIC_DATASET_RECORD.contacts.map((c) => ({ + ...c, + organization: { + name: c.organization.name, + website: c.organization.website, + }, + })), + contactsForResource: GENERIC_DATASET_RECORD.contactsForResource.map( + (c) => ({ + ...c, + organization: { + name: c.organization.name, + website: c.organization.website, + }, + }) + ), + }) + }) + }) + + describe('from model to XML', () => { + it('produces a valid XML document based on a generic record', async () => { + // parse and output xml to guarantee identical formatting + const ref = xmlToString(parseXmlString(GENERIC_DATASET)) + const xml = await converter.writeRecord(GENERIC_DATASET_RECORD) + expect(xml).toStrictEqual(ref) + }) + it('produces a valid XML document by combining a generic record and a third-party XML', async () => { + // parse and output xml to guarantee identical formatting + const ref = xmlToString( + parseXmlString(GENERIC_DATASET_PLUS_METAWAL_DATASET) + ) + const xml = await converter.writeRecord( + GENERIC_DATASET_RECORD, + METAWAL_DATASET + ) + expect(xml).toStrictEqual(ref) + }) + }) + + describe('idempotency', () => { + describe('with a third-party XML record', () => { + describe('when converting to a native record and back to XML', () => { + it('keeps the record unchanged (dataset)', async () => { + const backAndForth = await converter.writeRecord( + await converter.readRecord(METAWAL_DATASET), + METAWAL_DATASET + ) + expect(backAndForth).toStrictEqual(formatXml(METAWAL_DATASET)) + }) + it('keeps the record unchanged (service)', async () => { + const backAndForth = await converter.writeRecord( + await converter.readRecord(METAWAL_SERVICE), + METAWAL_SERVICE + ) + expect(backAndForth).toStrictEqual(formatXml(METAWAL_SERVICE)) + }) + }) + }) + describe('with a native record', () => { + describe('when converting to XML and back', () => { + it('keeps the record unchanged', async () => { + const backAndForth = await converter.readRecord( + await converter.writeRecord(GENERIC_DATASET_RECORD) + ) + expect(backAndForth).toStrictEqual({ + ...GENERIC_DATASET_RECORD, + ownerOrganization: { + name: GENERIC_DATASET_RECORD.ownerOrganization.name, + website: GENERIC_DATASET_RECORD.ownerOrganization.website, + }, + contacts: GENERIC_DATASET_RECORD.contacts.map((c) => ({ + ...c, + organization: { + name: c.organization.name, + website: c.organization.website, + }, + })), + contactsForResource: GENERIC_DATASET_RECORD.contactsForResource.map( + (c) => ({ + ...c, + organization: { + name: c.organization.name, + website: c.organization.website, + }, + }) + ), + }) + }) + }) + }) + }) +}) diff --git a/libs/api/metadata-converter/src/lib/iso19115-3/iso19115-3.converter.ts b/libs/api/metadata-converter/src/lib/iso19115-3/iso19115-3.converter.ts new file mode 100644 index 0000000000..1ce5344057 --- /dev/null +++ b/libs/api/metadata-converter/src/lib/iso19115-3/iso19115-3.converter.ts @@ -0,0 +1,176 @@ +import { Iso19139Converter } from '../iso19139' +import { + readContacts, + readDistributions, + readKind, + readLandingPage, + readLineage, + readOwnerOrganization, + readRecordCreated, + readRecordPublished, + readRecordUpdated, + readResourceContacts, + readUniqueIdentifier, +} from './read-parts' +import { + writeContacts, + writeContactsForResource, + writeDistributions, + writeKind, + writeLandingPage, + writeLineage, + writeOwnerOrganization, + writeRecordCreated, + writeRecordPublished, + writeRecordUpdated, + writeResourceCreated, + writeResourcePublished, + writeResourceUpdated, + writeSpatialRepresentation, + writeStatus, + writeUniqueIdentifier, +} from './write-parts' +import { XmlElement } from '@rgrove/parse-xml' +import { renameElements } from '../xml-utils' +import { CatalogRecord } from '@geonetwork-ui/common/domain/model/record' + +export class Iso191153Converter extends Iso19139Converter { + constructor() { + super() + + this.readers['uniqueIdentifier'] = readUniqueIdentifier + this.readers['kind'] = readKind + this.readers['recordUpdated'] = readRecordUpdated + this.readers['recordCreated'] = readRecordCreated + this.readers['recordPublished'] = readRecordPublished + this.readers['contacts'] = readContacts + this.readers['contactsForResource'] = readResourceContacts + this.readers['ownerOrganization'] = readOwnerOrganization + this.readers['landingPage'] = readLandingPage + this.readers['lineage'] = readLineage + this.readers['distributions'] = readDistributions + + this.writers['uniqueIdentifier'] = writeUniqueIdentifier + this.writers['kind'] = writeKind + this.writers['recordUpdated'] = writeRecordUpdated + this.writers['recordCreated'] = writeRecordCreated + this.writers['recordPublished'] = writeRecordPublished + this.writers['resourceUpdated'] = writeResourceUpdated + this.writers['resourceCreated'] = writeResourceCreated + this.writers['resourcePublished'] = writeResourcePublished + this.writers['contacts'] = writeContacts + this.writers['contactsForResource'] = writeContactsForResource + this.writers['ownerOrganization'] = writeOwnerOrganization + this.writers['landingPage'] = writeLandingPage + this.writers['lineage'] = writeLineage + this.writers['distributions'] = writeDistributions + this.writers['status'] = writeStatus + this.writers['spatialRepresentation'] = writeSpatialRepresentation + } + + beforeDocumentCreation(rootEl: XmlElement) { + renameElements(rootEl, { + gmd: 'mdb', + 'gmd:characterEncoding': 'lan:characterEncoding', + 'gmd:MD_CharacterSetCode': 'lan:MD_CharacterSetCode', + 'gmd:MD_DataIdentification': 'mri:MD_DataIdentification', + 'gmd:citation': 'mri:citation', + 'gmd:abstract': 'mri:abstract', + 'gmd:title': 'cit:title', + 'gmd:CI_Citation': 'cit:CI_Citation', + 'gmx:Anchor': 'gcx:Anchor', + + // languages + 'gmd:PT_Locale': 'lan:PT_Locale', + 'gmd:languageCode': 'lan:languageCode', + 'gmd:LanguageCode': 'lan:LanguageCode', + + // status + 'gmd:status': 'mri:status', + 'gmd:MD_ProgressCode': 'mri:MD_ProgressCode', + + // dates + 'gmd:date': 'cit:date', + 'gmd:CI_Date': 'cit:CI_Date', + 'gmd:dateType': 'cit:dateType', + 'gmd:CI_DateTypeCode': 'cit:CI_DateTypeCode', + + // contacts + 'gmd:CI_Responsibility': 'cit:CI_Responsibility', + 'gmd:role': 'cit:role', + 'gmd:CI_RoleCode': 'cit:CI_RoleCode', + + // keywords + 'gmd:descriptiveKeywords': 'mri:descriptiveKeywords', + 'gmd:MD_Keywords': 'mri:MD_Keywords', + 'gmd:type': 'mri:type', + 'gmd:MD_KeywordTypeCode': 'mri:MD_KeywordTypeCode', + 'gmd:thesaurusName': 'mri:thesaurusName', + 'gmd:keyword': 'mri:keyword', + 'gmd:identifier': 'cit:identifier', + 'gmd:MD_Identifier': 'mcc:MD_Identifier', + 'gmd:code': 'mcc:code', + + // distributions + 'gmd:MD_Distribution': 'mrd:MD_Distribution', + 'gmd:transferOptions': 'mrd:transferOptions', + 'gmd:MD_DigitalTransferOptions': 'mrd:MD_DigitalTransferOptions', + 'gmd:onLine': 'mrd:onLine', + 'gmd:distributionFormat': 'mrd:distributionFormat', + 'gmd:MD_Format': 'mrd:MD_Format', + 'gmd:CI_OnlineResource': 'cit:CI_OnlineResource', + 'gmd:linkage': 'cit:linkage', + 'gmd:name': 'cit:name', + 'gmd:description': 'cit:description', + 'gmd:CI_OnLineFunctionCode': 'cit:CI_OnLineFunctionCode', + 'gmd:function': 'cit:function', + 'gmd:protocol': 'cit:protocol', + + // topic + 'gmd:topicCategory': 'mri:topicCategory', + 'gmd:MD_TopicCategoryCode': 'mri:MD_TopicCategoryCode', + + // update frequency + 'gmd:resourceMaintenance': 'mri:resourceMaintenance', + 'gmd:MD_MaintenanceInformation': 'mmi:MD_MaintenanceInformation', + 'gmd:userDefinedMaintenanceFrequency': + 'mmi:userDefinedMaintenanceFrequency', + 'gts:TM_PeriodDuration': 'gco:TM_PeriodDuration', + + // constraints + 'gmd:resourceConstraints': 'mri:resourceConstraints', + 'gmd:MD_Constraints': 'mco:MD_Constraints', + 'gmd:MD_LegalConstraints': 'mco:MD_LegalConstraints', + 'gmd:MD_SecurityConstraints': 'mco:MD_SecurityConstraints', + 'gmd:useLimitation': 'mco:useLimitation', + 'gmd:useConstraints': 'mco:useConstraints', + 'gmd:accessConstraints': 'mco:accessConstraints', + 'gmd:otherConstraints': 'mco:otherConstraints', + 'gmd:MD_RestrictionCode': 'mco:MD_RestrictionCode', + 'gmd:classification': 'mco:classification', + 'gmd:MD_ClassificationCode': 'mco:MD_ClassificationCode', + + // overviews + 'gmd:graphicOverview': 'mri:graphicOverview', + 'gmd:MD_BrowseGraphic': 'mcc:MD_BrowseGraphic', + 'gmd:fileName': 'mcc:fileName', + 'gmd:fileDescription': 'mcc:fileDescription', + + // no more URL elements + 'gmd:URL': 'gco:CharacterString', + }) + } + + async writeRecord( + record: CatalogRecord, + reference?: string + ): Promise { + let result = await super.writeRecord(record, reference) + // fix gco namespace definition (changes between iso19139 and iso19115-3 + result = result.replace( + '"http://www.isotc211.org/2005/gco"', + '"http://standards.iso.org/iso/19115/-3/gco/1.0"' + ) + return result + } +} diff --git a/libs/api/metadata-converter/src/lib/iso19115-3/read-parts.spec.ts b/libs/api/metadata-converter/src/lib/iso19115-3/read-parts.spec.ts new file mode 100644 index 0000000000..aff60d899d --- /dev/null +++ b/libs/api/metadata-converter/src/lib/iso19115-3/read-parts.spec.ts @@ -0,0 +1,359 @@ +import { getRootElement, parseXmlString } from '../xml-utils' +import { + readContacts, + readOwnerOrganization, + readResourceContacts, +} from './read-parts' +import { + Individual, + Organization, +} from '@geonetwork-ui/common/domain/model/record' + +describe('read parts', () => { + describe('readContacts, readContactsForResource, readOwnerOrganization', () => { + let contacts: Array + let contactsForResource: Array + let ownerOrg: Organization + + describe('all possible types of contacts', () => { + beforeEach(() => { + const withContacts = getRootElement( + parseXmlString(` + + + + + + + + + + Direction Asset Management (SPW - Mobilité et Infrastructures - Direction Asset Management) + + + + + + + Boulevard du Nord, 8 + + + Namur + + + 5000 + + + Belgique + + + frederic.plumier@spw.wallonie.be + + + + + + + + + Frédéric Plumier + + + Attaché + + + + + + + + + + + + + + + + OpenWork Ltd + + + + + + + name@email.org + + + + + + + + + Metadata Bob + + + + + + + + + + + + + + pointOfContact + + + + + MyOrganization + + + + + + + bob@org.net + + + + + + + https://www.my.org/info + + + + + + + + + Bob TheGreat + + + developer + + + + + + + + + + + + + + + + + + + + OpenWork Ltd + + + + + + + info@openwork.nz + + + + + + + + + + + + + + + + + + Byron Cochrane + + + + + + + byron@openwork.nz + + + + + + + + + + + + + + + + + + + + Helpdesk carto du SPW (SPW - Secrétariat général - SPW Digital - Département de la Géomatique - Direction de l'Intégration des géodonnées) + + + + + + + helpdesk.carto@spw.wallonie.be + + + + + + + + + + + + + + + + + + + + + + + + OpenWork Ltd + + + + + + + info@openwork.nz + + + + + + + + + + + + + +`) + ) + contacts = readContacts(withContacts) + contactsForResource = readResourceContacts(withContacts) + ownerOrg = readOwnerOrganization(withContacts) + }) + + it('root contacts are for record', () => { + expect(contacts).toEqual([ + { + firstName: 'Frédéric', + lastName: 'Plumier', + position: 'Attaché', + email: 'frederic.plumier@spw.wallonie.be', + address: 'Boulevard du Nord, 8, Namur, 5000, Belgique', + organization: { + name: 'Direction Asset Management (SPW - Mobilité et Infrastructures - Direction Asset Management)', + }, + role: 'custodian', + }, + { + email: 'name@email.org', + firstName: 'Metadata', + lastName: 'Bob', + organization: { + name: 'OpenWork Ltd', + }, + role: 'owner', + }, + { + email: 'bob@org.net', + firstName: 'Bob', + lastName: 'TheGreat', + organization: { + name: 'MyOrganization', + website: new URL('https://www.my.org/info'), + }, + position: 'developer', + role: 'point_of_contact', + }, + ]) + }) + it('point of contact, citation and distribution are contacts for resource', () => { + expect(contactsForResource).toEqual([ + { + email: 'info@openwork.nz', + organization: { + name: 'OpenWork Ltd', + }, + role: 'author', + }, + { + firstName: 'Byron', + lastName: 'Cochrane', + email: 'byron@openwork.nz', + organization: { + name: 'Missing Organization', + }, + role: 'publisher', + }, + { + email: 'helpdesk.carto@spw.wallonie.be', + organization: { + name: "Helpdesk carto du SPW (SPW - Secrétariat général - SPW Digital - Département de la Géomatique - Direction de l'Intégration des géodonnées)", + }, + role: 'point_of_contact', + }, + { + email: 'info@openwork.nz', + organization: { + name: 'OpenWork Ltd', + }, + role: 'distributor', + }, + ]) + }) + it('owner organization is organization of the first point of contact for the record', () => { + expect(ownerOrg).toEqual({ + name: 'MyOrganization', + website: new URL('https://www.my.org/info'), + }) + }) + }) + }) +}) diff --git a/libs/api/metadata-converter/src/lib/iso19115-3/read-parts.ts b/libs/api/metadata-converter/src/lib/iso19115-3/read-parts.ts new file mode 100644 index 0000000000..7c6efab2ed --- /dev/null +++ b/libs/api/metadata-converter/src/lib/iso19115-3/read-parts.ts @@ -0,0 +1,329 @@ +import { + findChildElement, + findChildrenElement, + findNestedElement, + findNestedElements, + findParent, + readAttribute, + XmlElement, +} from '../xml-utils' +import { + ChainableFunction, + combine, + filterArray, + flattenArray, + getAtIndex, + map, + mapArray, + pipe, +} from '../function-utils' +import { + extractCharacterString, + extractDatasetDistributions, + extractDateTime, + extractRole, + extractUrl, + findIdentification, +} from '../iso19139/read-parts' +import { + DatasetDistribution, + Individual, + Organization, + RecordKind, + Role, +} from '@geonetwork-ui/common/domain/model/record' +import { matchMimeType } from '../common/distribution.mapper' +import { fullNameToParts } from '../iso19139/utils/individual-name' + +export function readKind(rootEl: XmlElement): RecordKind { + return pipe( + findNestedElement( + 'mdb:metadataScope', + 'mdb:MD_MetadataScope', + 'mdb:resourceScope', + 'mcc:MD_ScopeCode' + ), + readAttribute('codeListValue'), + map( + (scopeCode): RecordKind => + scopeCode === 'service' ? 'service' : 'dataset' + ) + )(rootEl) +} + +export function findDistribution() { + return findNestedElement('mdb:distributionInfo', 'mrd:MD_Distribution') +} + +// from cit:CI_Organisation +export function extractOrganization(): ChainableFunction< + XmlElement, + Organization +> { + const getUrl = pipe( + findNestedElements( + 'cit:contactInfo', + 'cit:CI_Contact', + 'cit:onlineResource', + 'cit:CI_OnlineResource', + 'cit:linkage' + ), + getAtIndex(0), + extractUrl() + ) + return pipe( + combine( + pipe(findChildElement('cit:name', false), extractCharacterString()), + getUrl + ), + map(([name, website]) => ({ + name, + ...(website && { website }), + })) + ) +} + +// from cit:CI_Individual or cit:CI_Organisation +export function extractIndividual( + role: Role, + organization?: Organization, + orgContact?: Individual +): ChainableFunction { + const getPosition = pipe( + findChildElement('cit:positionName'), + extractCharacterString() + ) + const getNameParts = pipe( + findChildElement('cit:name'), + extractCharacterString(), + map((fullName) => { + if (!fullName) return [] + return fullNameToParts(fullName) + }) + ) + const getContact = findNestedElement('cit:contactInfo', 'cit:CI_Contact') + const getAddressRoot = pipe( + getContact, + findNestedElement('cit:address', 'cit:CI_Address') + ) + const getAddress = pipe( + getAddressRoot, + combine( + pipe( + findChildElement('cit:deliveryPoint', false), + extractCharacterString() + ), + pipe(findChildElement('cit:city', false), extractCharacterString()), + pipe(findChildElement('cit:postalCode', false), extractCharacterString()), + pipe(findChildElement('cit:country', false), extractCharacterString()) + ), + map((parts) => parts.filter((p) => !!p).join(', ')) + ) + const getPhone = pipe( + getContact, + findNestedElement('cit:phone', 'cit:CI_Telephone', 'cit:number'), + extractCharacterString() + ) + const getEmail = pipe( + getAddressRoot, + findChildElement('cit:electronicMailAddress', false), + extractCharacterString() + ) + const defaultOrg: Organization = { + name: 'Missing Organization', + } + + let defaultIndividual: Partial = {} + if (orgContact) { + defaultIndividual = { + email: orgContact.email, + ...(orgContact.address && { address: orgContact.address }), + ...(orgContact.phone && { phone: orgContact.phone }), + ...(orgContact.position && { position: orgContact.position }), + organization, + } + } + + return pipe( + combine(getPosition, getNameParts, getEmail, getAddress, getPhone), + map(([position, [firstName, lastName], email, address, phone]) => ({ + ...defaultIndividual, + email: email || defaultIndividual.email || 'missing@missing.com', + role, + organization: organization || defaultOrg, + ...(position && { position }), + ...(firstName && { firstName }), + ...(lastName && { lastName }), + ...(address && { address }), + ...(phone && { phone }), + })) + ) +} + +// from cit:CI_Organisation +export function extractOrganizationIndividuals( + role: Role +): ChainableFunction> { + return pipe( + combine( + extractOrganization(), + extractIndividual(role), + findNestedElements('cit:individual', 'cit:CI_Individual') + ), + map(([org, orgContact, els]) => + els.length + ? els.map((el) => extractIndividual(role, org, orgContact)(el)) + : [ + { + email: orgContact.email, + ...(orgContact.address && { address: orgContact.address }), + ...(orgContact.phone && { phone: orgContact.phone }), + ...(orgContact.position && { position: orgContact.position }), + organization: org, + role, + }, + ] + ) + ) +} + +// from cit:CI_Responsibility +export function extractIndividuals(): ChainableFunction< + XmlElement, + Array +> { + const getRole = pipe(findChildElement('cit:role'), extractRole()) + const getIndividuals = pipe( + combine(getRole, findNestedElements('cit:party', 'cit:CI_Individual')), + ([role, els]) => els.map((el) => extractIndividual(role)(el)) + ) + const getOrgIndividuals = pipe( + combine(getRole, findNestedElements('cit:party', 'cit:CI_Organisation')), + map(([role, els]) => + els.map((el) => extractOrganizationIndividuals(role)(el)) + ), + flattenArray() + ) + + return pipe(combine(getIndividuals, getOrgIndividuals), flattenArray()) +} + +export function readUniqueIdentifier(rootEl: XmlElement): string { + return pipe( + findNestedElement( + 'mdb:metadataIdentifier', + 'mcc:MD_Identifier', + 'mcc:code' + ), + extractCharacterString() + )(rootEl) +} + +export function readOwnerOrganization(rootEl: XmlElement): Organization { + const contacts = readContacts(rootEl) + const pointOfContact = contacts.filter( + (c) => c.role === 'point_of_contact' + )[0] + return (pointOfContact || contacts[0]).organization +} + +export function readContacts(rootEl: XmlElement): Individual[] { + return pipe( + findNestedElements('mdb:contact', 'cit:CI_Responsibility'), + mapArray(extractIndividuals()), + flattenArray() + )(rootEl) +} + +export function readResourceContacts(rootEl: XmlElement): Individual[] { + return pipe( + combine( + pipe( + findIdentification(), + findNestedElements( + 'mri:citation', + 'cit:CI_Citation', + 'cit:citedResponsibleParty' + ) + ), + pipe( + findIdentification(), + findChildrenElement('mri:pointOfContact', false) + ), + pipe(findDistribution(), findChildrenElement('mrd:distributorContact')) + ), + flattenArray(), + mapArray(findChildElement('cit:CI_Responsibility', false)), + mapArray(extractIndividuals()), + flattenArray() + )(rootEl) +} + +export function readLandingPage(rootEl: XmlElement): URL { + return pipe( + findNestedElement( + 'mdb:metadataLinkage', + 'cit:CI_OnlineResource', + 'cit:linkage' + ), + extractUrl() + )(rootEl) +} + +export function readLineage(rootEl: XmlElement): string { + return pipe( + findNestedElement('mdb:resourceLineage', 'mrl:LI_Lineage', 'mrl:statement'), + extractCharacterString() + )(rootEl) +} + +function extractDateInfo( + type: 'creation' | 'revision' | 'publication' +): ChainableFunction { + return pipe( + findChildrenElement('mdb:dateInfo', false), + filterArray( + (el) => + pipe( + findChildElement('cit:CI_DateTypeCode'), + readAttribute('codeListValue') + )(el) === type + ), + getAtIndex(0), + findChildElement('cit:date'), + extractDateTime() + ) +} + +export function readRecordUpdated(rootEl: XmlElement): Date { + return extractDateInfo('revision')(rootEl) +} + +export function readRecordCreated(rootEl: XmlElement): Date { + return extractDateInfo('creation')(rootEl) +} + +export function readRecordPublished(rootEl: XmlElement): Date { + return extractDateInfo('publication')(rootEl) +} + +const getMimeType = pipe( + findParent('mrd:MD_Distribution'), + findNestedElement( + 'mrd:distributionFormat', + 'mrd:MD_Format', + 'mrd:formatSpecificationCitation', + 'cit:CI_Citation', + 'cit:title' + ), + extractCharacterString(), + map(matchMimeType) +) + +export function readDistributions(rootEl: XmlElement): DatasetDistribution[] { + return pipe( + findNestedElements('mrd:distributionInfo', 'mrd:MD_Distribution'), + mapArray(extractDatasetDistributions(getMimeType)), + flattenArray() + )(rootEl) +} diff --git a/libs/api/metadata-converter/src/lib/iso19115-3/write-parts.spec.ts b/libs/api/metadata-converter/src/lib/iso19115-3/write-parts.spec.ts new file mode 100644 index 0000000000..a8b411ac9c --- /dev/null +++ b/libs/api/metadata-converter/src/lib/iso19115-3/write-parts.spec.ts @@ -0,0 +1,359 @@ +import { GENERIC_DATASET_RECORD } from '../fixtures/generic.records' +import { writeContactsForResource, writeDistributions } from './write-parts' +import { + createElement, + getRootElement, + parseXmlString, + xmlToString, +} from '../xml-utils' +import { XmlElement } from '@rgrove/parse-xml' +import { DatasetRecord } from '@geonetwork-ui/common/domain/model/record' + +describe('write parts', () => { + let rootEl: XmlElement + let datasetRecord: DatasetRecord + + function rootAsString() { + return xmlToString(rootEl).trim() + } + + beforeEach(() => { + rootEl = createElement('root')() + datasetRecord = { ...GENERIC_DATASET_RECORD } + }) + + describe('writeDistributions', () => { + const distributionShp = GENERIC_DATASET_RECORD.distributions[0] + const distributionLink = GENERIC_DATASET_RECORD.distributions[2] + + it('writes one distributionInfo per link, format in iso19115-3, reuses a distribution info with distributor contact', () => { + datasetRecord = { + ...datasetRecord, + contactsForResource: [ + { + role: 'distributor', + firstName: 'Jim', + email: 'jim@mail.org', + organization: { + name: 'Org', + }, + }, + ], + distributions: [distributionShp, distributionLink], + } + writeContactsForResource(datasetRecord, rootEl) + writeDistributions(datasetRecord, rootEl) + expect(rootAsString()).toEqual(` + + + + + + + + + + + distributor + + + + + Org + + + + + + + jim@mail.org + + + + + + + + + Jim + + + + + + + + + + + + + + + x-gis/x-shapefile + + + + + + + + + + + http://my-org.net/download/1.zip + + + Dataset downloaded as a shapefile + + + Direct download + + + WWW:DOWNLOAD + + + + + + + + + + + + + + + + + + https://my-org.net/docs/1234.pdf + + + A link to the online documentation in PDF; please forgive the typos. + + + Documentation + + + WWW:LINK + + + + + + + + + + +`) + }) + + it('removes existing ones, keeping distributor info if not empty', () => { + // add some distributions first + const sample = parseXmlString(` + + + + + + + + distributor + + + + + Org + + + + + + + jim@mail.org + + + + + + + + + Jim + + + + + + + + + + + + + ESRI Shapefile + + + - + + + + + + + + + https://map.geo.admin.ch/?layers=ch.are.alpenkonvention + + + MAP:Preview + + + Vorschau map.geo.admin.ch + + + Vorschau map.geo.admin.ch + + + Aperçu map.geo.admin.ch + + + Previsione map.geo.admin.ch + + + Preview map.geo.admin.ch + + + + + Vorschau map.geo.admin.ch + + + Vorschau map.geo.admin.ch + + + Aperçu map.geo.admin.ch + + + Previsione map.geo.admin.ch + + + Preview map.geo.admin.ch + + + + + + + + + + + + + + + + + https://my-org.net/wfs + + + This WFS service offers direct download capability + + + my:featuretype + + + OGC:WFS + + + + + + + + + + +`) + rootEl = getRootElement(sample) + writeDistributions( + { + ...datasetRecord, + contactsForResource: [], + distributions: [distributionLink], + }, + rootEl + ) + expect(rootAsString()).toEqual(` + + + + + + + + distributor + + + + + Org + + + + + + + jim@mail.org + + + + + + + + + Jim + + + + + + + + + + + + + + + https://my-org.net/docs/1234.pdf + + + A link to the online documentation in PDF; please forgive the typos. + + + Documentation + + + WWW:LINK + + + + + + + + + + +`) + }) + }) +}) diff --git a/libs/api/metadata-converter/src/lib/iso19115-3/write-parts.ts b/libs/api/metadata-converter/src/lib/iso19115-3/write-parts.ts new file mode 100644 index 0000000000..91c6bf75ad --- /dev/null +++ b/libs/api/metadata-converter/src/lib/iso19115-3/write-parts.ts @@ -0,0 +1,513 @@ +import { + CatalogRecord, + DatasetRecord, + Individual, + ServiceOnlineResource, +} from '@geonetwork-ui/common/domain/model/record' +import { + addAttribute, + allChildrenElement, + appendChildren, + appendChildTree, + createChild, + createElement, + findChildElement, + findChildOrCreate, + findChildrenElement, + findNestedChildOrCreate, + findNestedElement, + findNestedElements, + readAttribute, + removeAllChildren, + removeChildren, + removeChildrenByName, + setTextContent, + XmlElement, +} from '../xml-utils' +import { + fallback, + filterArray, + getAtIndex, + map, + mapArray, + noop, + pipe, +} from '../function-utils' +import { + appendDistribution, + appendKeywords, + createDistributionInfo, + findOrCreateDistribution, + findOrCreateIdentification, + getProgressCode, + getRoleCode, + getServiceEndpointProtocol, + removeKeywords, + writeCharacterString, + writeDateTime, + writeLinkage, +} from '../iso19139/write-parts' +import { findIdentification } from '../iso19139/read-parts' +import { namePartsToFull } from '../iso19139/utils/individual-name' + +export function writeUniqueIdentifier( + record: CatalogRecord, + rootEl: XmlElement +) { + pipe( + findChildOrCreate('mdb:metadataIdentifier'), + findChildOrCreate('mcc:MD_Identifier'), + findChildOrCreate('mcc:code'), + writeCharacterString(record.uniqueIdentifier) + )(rootEl) +} + +export function writeKind(record: CatalogRecord, rootEl: XmlElement) { + pipe( + findNestedChildOrCreate( + 'mdb:metadataScope', + 'mdb:MD_MetadataScope', + 'mdb:resourceScope', + 'mcc:MD_ScopeCode' + ), + addAttribute( + 'codeList', + 'https://standards.iso.org/iso/19115/resources/Codelists/cat/codelists.xml#MD_ScopeCode' + ), + addAttribute('codeListValue', record.kind), + setTextContent(record.kind) + )(rootEl) +} + +function removeRecordDate(type: 'revision' | 'creation' | 'publication') { + return removeChildren( + pipe( + findChildrenElement('mdb:dateInfo', false), + filterArray( + pipe( + findChildElement('cit:CI_DateTypeCode'), + readAttribute('codeListValue'), + map((value) => value === type) + ) + ) + ) + ) +} + +function appendRecordDate( + date: Date, + type: 'revision' | 'creation' | 'publication' +) { + return appendChildren( + pipe( + createElement('mdb:dateInfo'), + createChild('cit:CI_Date'), + appendChildren( + pipe(createElement('cit:date'), writeDateTime(date)), + pipe( + createElement('cit:dateType'), + createChild('cit:CI_DateTypeCode'), + addAttribute( + 'codeList', + 'https://standards.iso.org/iso/19115/resources/Codelists/cat/codelists.xml#CI_DateTypeCode' + ), + addAttribute('codeListValue', type), + setTextContent(type) + ) + ) + ) + ) +} + +export function writeRecordUpdated(record: CatalogRecord, rootEl: XmlElement) { + removeRecordDate('revision')(rootEl) + appendRecordDate(record.recordUpdated, 'revision')(rootEl) +} + +export function writeRecordCreated(record: CatalogRecord, rootEl: XmlElement) { + removeRecordDate('creation')(rootEl) + if (!('recordCreated' in record)) return + appendRecordDate(record.recordCreated, 'creation')(rootEl) +} + +export function writeRecordPublished( + record: CatalogRecord, + rootEl: XmlElement +) { + removeRecordDate('publication')(rootEl) + if (!('recordPublished' in record)) return + appendRecordDate(record.recordPublished, 'publication')(rootEl) +} + +function removeResourceDate(type: 'revision' | 'creation' | 'publication') { + return pipe( + findIdentification(), + findNestedElement('mri:citation', 'cit:CI_Citation'), + removeChildren( + pipe( + findChildrenElement('cit:date', false), + filterArray( + pipe( + findChildElement('cit:CI_DateTypeCode'), + readAttribute('codeListValue'), + map((value) => value === type) + ) + ) + ) + ) + ) +} + +function appendResourceDate( + date: Date, + type: 'revision' | 'creation' | 'publication' +) { + return pipe( + findIdentification(), + findNestedElement('mri:citation', 'cit:CI_Citation'), + appendChildren( + pipe( + createElement('cit:date'), + createChild('cit:CI_Date'), + appendChildren( + pipe(createElement('cit:date'), writeDateTime(date)), + pipe( + createElement('cit:dateType'), + createChild('cit:CI_DateTypeCode'), + addAttribute( + 'codeList', + 'https://standards.iso.org/iso/19115/resources/Codelists/cat/codelists.xml#CI_DateTypeCode' + ), + addAttribute('codeListValue', type), + setTextContent(type) + ) + ) + ) + ) + ) +} + +export function writeResourceUpdated( + record: CatalogRecord, + rootEl: XmlElement +) { + removeResourceDate('revision')(rootEl) + if (!('resourceUpdated' in record)) return + appendResourceDate(record.resourceUpdated, 'revision')(rootEl) +} + +export function writeResourceCreated( + record: CatalogRecord, + rootEl: XmlElement +) { + removeResourceDate('creation')(rootEl) + if (!('resourceCreated' in record)) return + appendResourceDate(record.resourceCreated, 'creation')(rootEl) +} + +export function writeResourcePublished( + record: CatalogRecord, + rootEl: XmlElement +) { + removeResourceDate('publication')(rootEl) + if (!('resourcePublished' in record)) return + appendResourceDate(record.resourcePublished, 'publication')(rootEl) +} + +export function writeOwnerOrganization( + record: CatalogRecord, + rootEl: XmlElement +) { + // if no contact matches the owner org, create an empty one + const ownerContact: Individual = record.contacts.find( + (contact) => contact.organization.name === record.ownerOrganization.name + ) + pipe( + findChildOrCreate('mdb:contact'), + removeAllChildren(), + appendResponsibleParty( + ownerContact + ? { + ...ownerContact, + // owner responsible party is always point of contact + role: 'point_of_contact', + } + : { + organization: record.ownerOrganization, + email: 'missing@missing.com', + role: 'point_of_contact', + } + ) + )(rootEl) +} + +export function appendResponsibleParty(contact: Individual) { + const fullName = namePartsToFull(contact.firstName, contact.lastName) + + const createIndividual = pipe( + createElement('cit:individual'), + createChild('cit:CI_Individual'), + fullName + ? appendChildren( + pipe(createElement('cit:name'), writeCharacterString(fullName)) + ) + : noop, + contact.position + ? appendChildren( + pipe( + createElement('cit:positionName'), + writeCharacterString(contact.position) + ) + ) + : noop + ) + + const createContactInfo = pipe( + createElement('cit:contactInfo'), + createChild('cit:CI_Contact'), + appendChildren( + pipe( + createElement('cit:address'), + createChild('cit:CI_Address'), + appendChildren( + pipe( + createElement('cit:electronicMailAddress'), + writeCharacterString(contact.email) + ) + ), + contact.address + ? appendChildren( + pipe( + createElement('cit:deliveryPoint'), + writeCharacterString(contact.address) + ) + ) + : noop + ) + ), + contact.organization?.website + ? appendChildren( + pipe( + createElement('cit:onlineResource'), + createChild('cit:CI_OnlineResource'), + createChild('cit:linkage'), + writeCharacterString(contact.organization.website.toString()) + ) + ) + : noop, + contact.phone + ? appendChildren( + pipe( + createElement('cit:phone'), + createChild('cit:CI_Telephone'), + createChild('cit:number'), + writeCharacterString(contact.phone) + ) + ) + : noop + ) + + const createRole = pipe( + createElement('cit:role'), + createChild('cit:CI_RoleCode'), + addAttribute( + 'codeList', + 'https://standards.iso.org/iso/19115/resources/Codelists/cat/codelists.xml#CI_RoleCode' + ), + addAttribute('codeListValue', getRoleCode(contact.role)), + setTextContent(getRoleCode(contact.role)) + ) + + const createParty = pipe( + createElement('cit:party'), + createChild('cit:CI_Organisation'), + contact.organization?.name + ? appendChildren( + pipe( + createElement('cit:name'), + writeCharacterString(contact.organization?.name) + ) + ) + : noop, + appendChildren(createContactInfo, createIndividual) + ) + + return appendChildren( + pipe( + createElement('cit:CI_Responsibility'), + appendChildren(createRole, createParty) + ) + ) +} + +export function writeContacts(record: CatalogRecord, rootEl: XmlElement) { + pipe( + removeChildrenByName('mdb:contact'), + appendChildren( + ...record.contacts.map((contact) => + pipe(createElement('gmd:contact'), appendResponsibleParty(contact)) + ) + ) + )(rootEl) +} + +export function writeContactsForResource( + record: CatalogRecord, + rootEl: XmlElement +) { + const withoutDistributors = record.contactsForResource.filter( + (c) => c.role !== 'distributor' + ) + const distributors = record.contactsForResource.filter( + (c) => c.role === 'distributor' + ) + pipe( + findOrCreateIdentification(), + removeChildrenByName('mri:pointOfContact'), + appendChildren( + ...withoutDistributors.map((contact) => + pipe( + createElement('mri:pointOfContact'), + appendResponsibleParty(contact) + ) + ) + ) + )(rootEl) + if (!distributors.length) return + pipe( + findOrCreateDistribution(), + removeChildrenByName('mrd:distributor'), + createChild('mrd:distributor'), + createChild('mrd:MD_Distributor'), + appendChildren( + ...distributors.map((contact) => + pipe( + createElement('mrd:distributorContact'), + appendResponsibleParty(contact) + ) + ) + ) + )(rootEl) +} + +export function writeKeywords(record: CatalogRecord, rootEl: XmlElement) { + pipe( + findOrCreateIdentification(), + removeKeywords(), + appendKeywords(record.keywords) + )(rootEl) +} + +export function writeLandingPage(record: DatasetRecord, rootEl: XmlElement) { + pipe( + findNestedChildOrCreate( + 'mdb:metadataLinkage', + 'cit:CI_OnlineResource', + 'cit:linkage' + ), + writeLinkage(record.landingPage) + )(rootEl) +} + +export function writeLineage(record: DatasetRecord, rootEl: XmlElement) { + pipe( + findNestedChildOrCreate( + 'mdb:resourceLineage', + 'mrl:LI_Lineage', + 'mrl:statement' + ), + writeCharacterString(record.lineage) + )(rootEl) +} + +export function writeStatus(record: DatasetRecord, rootEl: XmlElement) { + const progressCode = getProgressCode(record.status) + pipe( + findOrCreateIdentification(), + findNestedChildOrCreate('mri:status', 'mcc:MD_ProgressCode'), + addAttribute( + 'codeList', + 'https://standards.iso.org/iso/19115/resources/Codelists/cat/codelists.xml#MD_ProgressCode' + ), + addAttribute('codeListValue', progressCode), + setTextContent(progressCode) + )(rootEl) +} + +export function writeSpatialRepresentation( + record: DatasetRecord, + rootEl: XmlElement +) { + pipe( + findOrCreateIdentification(), + findNestedChildOrCreate( + 'mri:spatialRepresentationType', + 'mcc:MD_SpatialRepresentationTypeCode' + ), + addAttribute( + 'codeList', + 'https://standards.iso.org/iso/19115/resources/Codelists/cat/codelists.xml#MD_SpatialRepresentationTypeCode' + ), + addAttribute('codeListValue', record.spatialRepresentation), + setTextContent(record.spatialRepresentation) + )(rootEl) +} + +// this will remove all transfer options and formats from distribution info +// and remove empty distribution info +function removeTransferOptions(rootEl: XmlElement) { + // remove transfer options & formats + pipe( + findNestedElements('mdb:distributionInfo', 'mrd:MD_Distribution'), + mapArray( + pipe( + removeChildren(findChildrenElement('mrd:distributionFormat', false)), + removeChildren(findChildrenElement('mrd:transferOptions', false)) + ) + ) + )(rootEl) + // remove empty distributions + removeChildren( + pipe( + findChildrenElement('mdb:distributionInfo', false), + filterArray( + pipe( + findChildElement('mrd:MD_Distribution'), + allChildrenElement, + map((children) => children.length === 0) + ) + ) + ) + )(rootEl) +} + +function appendDistributionFormat(mimeType: string) { + return appendChildren( + pipe( + createElement('mrd:distributionFormat'), + createChild('mrd:MD_Format'), + createChild('mrd:formatSpecificationCitation'), + createChild('cit:CI_Citation'), + createChild('cit:title'), + writeCharacterString(mimeType) + ) + ) +} + +export function writeDistributions(record: DatasetRecord, rootEl: XmlElement) { + removeTransferOptions(rootEl) + + // for each distribution, either find an existing distribution info or create a new one + record.distributions.forEach((distribution, index) => { + pipe( + fallback( + pipe( + findNestedElements('gmd:distributionInfo', 'gmd:MD_Distribution'), + getAtIndex(index) + ), + appendChildTree(createDistributionInfo()) + ), + appendDistribution(distribution, appendDistributionFormat) + )(rootEl) + }) +} diff --git a/libs/api/metadata-converter/src/lib/iso19139/converter.ts b/libs/api/metadata-converter/src/lib/iso19139/converter.ts deleted file mode 100644 index a555331aa9..0000000000 --- a/libs/api/metadata-converter/src/lib/iso19139/converter.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { - CatalogRecord, - DatasetRecord, - ServiceRecord, -} from '@geonetwork-ui/common/domain/model/record' -import { - createDocument, - createElement, - getRootElement, - parseXmlString, - xmlToString, -} from '../xml-utils' -import { - writeAbstract, - writeContacts, - writeDatasetCreated, - writeDatasetUpdated, - writeDistributions, - writeGraphicOverviews, - writeKeywords, - writeKind, - writeLegalConstraints, - writeLicenses, - writeLineage, - writeOnlineResources, - writeOtherConstraints, - writeOwnerOrganization, - writeRecordUpdated, - writeSecurityConstraints, - writeSpatialRepresentation, - writeStatus, - writeTopics, - writeTitle, - writeUniqueIdentifier, - writeUpdateFrequency, -} from './write-parts' -import { - readAbstract, - readContacts, - readDatasetCreated, - readDatasetUpdated, - readDistributions, - readIsoTopics, - readKeywords, - readKind, - readLegalConstraints, - readLicenses, - readLineage, - readOnlineResources, - readOtherConstraints, - readOverviews, - readOwnerOrganization, - readRecordUpdated, - readRecordPublished, - readSecurityConstraints, - readSpatialExtents, - readSpatialRepresentation, - readStatus, - readTemporalExtents, - readTitle, - readUniqueIdentifier, - readUpdateFrequency, -} from './read-parts' -import { isEqual } from '../convert-utils' - -export function toModel(xml: string): CatalogRecord { - const doc = parseXmlString(xml) - const rootEl = getRootElement(doc) - - const uniqueIdentifier = readUniqueIdentifier(rootEl) - const kind = readKind(rootEl) - const ownerOrganization = readOwnerOrganization(rootEl) - const title = readTitle(rootEl) - const abstract = readAbstract(rootEl) - const contacts = readContacts(rootEl) - const recordUpdated = readRecordUpdated(rootEl) - const recordCreated = recordUpdated - const recordPublished = readRecordPublished(rootEl) - const keywords = readKeywords(rootEl) - const topics = readIsoTopics(rootEl) - const legalConstraints = readLegalConstraints(rootEl) - const otherConstraints = readOtherConstraints(rootEl) - const securityConstraints = readSecurityConstraints(rootEl) - const licenses = readLicenses(rootEl) - const overviews = readOverviews(rootEl) - - if (kind === 'dataset') { - const status = readStatus(rootEl) - const datasetCreated = readDatasetCreated(rootEl) - const datasetUpdated = readDatasetUpdated(rootEl) - const spatialRepresentation = readSpatialRepresentation(rootEl) - const spatialExtents = readSpatialExtents(rootEl) - const temporalExtents = readTemporalExtents(rootEl) - const lineage = readLineage(rootEl) - const distributions = readDistributions(rootEl) - const updateFrequency = readUpdateFrequency(rootEl) - - return { - uniqueIdentifier, - kind, - languages: [], - recordCreated, - recordUpdated, - recordPublished, - status, - title, - abstract, - ownerOrganization, - contacts, - contactsForResource: [], // FIXME: is that really useful?? - keywords, - topics, - licenses, - legalConstraints, - securityConstraints, - otherConstraints, - ...(datasetCreated && { datasetCreated }), - ...(datasetUpdated && { datasetUpdated }), - lineage, - ...(spatialRepresentation && { spatialRepresentation }), - overviews, - spatialExtents, - temporalExtents, - distributions, - updateFrequency, - } as DatasetRecord - } else { - const onlineResources = readOnlineResources(rootEl) - return { - uniqueIdentifier, - kind, - languages: [], - recordCreated, - recordUpdated, - recordPublished, - title, - abstract, - ownerOrganization, - contacts, - keywords, - topics, - licenses, - legalConstraints, - securityConstraints, - otherConstraints, - overviews, - onlineResources, - } as ServiceRecord - } -} - -export function toXml(record: CatalogRecord, originalXml?: string): string { - const originalDoc = originalXml ? parseXmlString(originalXml) : null - const originalRecord = originalXml ? toModel(originalXml) : null - const rootEl = originalDoc - ? getRootElement(originalDoc) - : createElement('gmd:MD_Metadata')() - - function fieldChanged(name: string) { - return originalRecord !== null - ? !isEqual(record[name], originalRecord[name]) - : true - } - - writeUniqueIdentifier(record, rootEl) - writeKind(record, rootEl) - fieldChanged('ownerOrganization') && writeOwnerOrganization(record, rootEl) - fieldChanged('recordUpdated') && writeRecordUpdated(record, rootEl) - writeTitle(record, rootEl) - writeAbstract(record, rootEl) - fieldChanged('contacts') && writeContacts(record, rootEl) - fieldChanged('keywords') && writeKeywords(record, rootEl) - fieldChanged('topics') && writeTopics(record, rootEl) - fieldChanged('legalConstraints') && writeLegalConstraints(record, rootEl) - fieldChanged('securityConstraints') && - writeSecurityConstraints(record, rootEl) - fieldChanged('licenses') && writeLicenses(record, rootEl) - fieldChanged('otherConstraints') && writeOtherConstraints(record, rootEl) - - if (record.kind === 'dataset') { - writeStatus(record, rootEl) - fieldChanged('updateFrequency') && writeUpdateFrequency(record, rootEl) - fieldChanged('datasetCreated') && writeDatasetCreated(record, rootEl) - fieldChanged('datasetUpdated') && writeDatasetUpdated(record, rootEl) - fieldChanged('spatialRepresentation') && - writeSpatialRepresentation(record, rootEl) - fieldChanged('overviews') && writeGraphicOverviews(record, rootEl) - fieldChanged('distributions') && writeDistributions(record, rootEl) - writeLineage(record, rootEl) - } else { - fieldChanged('onlineResources') && writeOnlineResources(record, rootEl) - } - - const newDocument = createDocument(rootEl) - return xmlToString(newDocument) -} diff --git a/libs/api/metadata-converter/src/lib/iso19139/index.ts b/libs/api/metadata-converter/src/lib/iso19139/index.ts new file mode 100644 index 0000000000..db6683ac15 --- /dev/null +++ b/libs/api/metadata-converter/src/lib/iso19139/index.ts @@ -0,0 +1 @@ +export * from './iso19139.converter' diff --git a/libs/api/metadata-converter/src/lib/iso19139/converter.spec.ts b/libs/api/metadata-converter/src/lib/iso19139/iso19139.converter.spec.ts similarity index 57% rename from libs/api/metadata-converter/src/lib/iso19139/converter.spec.ts rename to libs/api/metadata-converter/src/lib/iso19139/iso19139.converter.spec.ts index 611d31f2ca..e16228682e 100644 --- a/libs/api/metadata-converter/src/lib/iso19139/converter.spec.ts +++ b/libs/api/metadata-converter/src/lib/iso19139/iso19139.converter.spec.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/ban-ts-comment */ -import { toModel, toXml } from './converter' +import { Iso19139Converter } from './iso19139.converter' import { parseXmlString, xmlToString } from '../xml-utils' import { GEO2FRANCE_PLU_DATASET_RECORD } from '../fixtures/geo2france.records' import { @@ -24,48 +24,60 @@ function formatXml(xmlString: string) { } describe('ISO19139 converter', () => { + let converter: Iso19139Converter + + beforeEach(() => { + converter = new Iso19139Converter() + }) + describe('from XML to model', () => { - it('produces the corresponding record (geo2france dataset)', () => { - expect(toModel(GEO2FRANCE_PLU_DATASET)).toStrictEqual( - GEO2FRANCE_PLU_DATASET_RECORD - ) + it('produces the corresponding record (geo2france dataset)', async () => { + const record = await converter.readRecord(GEO2FRANCE_PLU_DATASET) + expect(record).toStrictEqual(GEO2FRANCE_PLU_DATASET_RECORD) }) - it('produces the corresponding record (geocat.ch dataset)', () => { - expect(toModel(GEOCAT_CH_DATASET)).toStrictEqual(GEOCAT_CH_DATASET_RECORD) + it('produces the corresponding record (geocat.ch dataset)', async () => { + const record = await converter.readRecord(GEOCAT_CH_DATASET) + expect(record).toStrictEqual(GEOCAT_CH_DATASET_RECORD) }) - it('produces the corresponding record (geocat.ch service)', () => { - expect(toModel(GEOCAT_CH_SERVICE)).toStrictEqual(GEOCAT_CH_SERVICE_RECORD) + it('produces the corresponding record (geocat.ch service)', async () => { + const record = await converter.readRecord(GEOCAT_CH_SERVICE) + expect(record).toStrictEqual(GEOCAT_CH_SERVICE_RECORD) }) }) describe('from model to XML', () => { - it('produces a valid XML document based on a generic record', () => { + it('produces a valid XML document based on a generic record', async () => { + // parse and output xml to guarantee identical formatting const ref = xmlToString(parseXmlString(GENERIC_DATASET)) - expect(toXml(GENERIC_DATASET_RECORD)).toStrictEqual(ref) + const xml = await converter.writeRecord(GENERIC_DATASET_RECORD) + expect(xml).toStrictEqual(ref) }) - it('produces a valid XML document by combining a generic record and a third-party XML', () => { + it('produces a valid XML document by combining a generic record and a third-party XML', async () => { + // parse and output xml to guarantee identical formatting const ref = xmlToString( parseXmlString(GENERIC_DATASET_PLUS_GEO2FRANCE_DATASET) ) - expect( - toXml(GENERIC_DATASET_RECORD, GEO2FRANCE_PLU_DATASET) - ).toStrictEqual(ref) + const xml = await converter.writeRecord( + GENERIC_DATASET_RECORD, + GEO2FRANCE_PLU_DATASET + ) + expect(xml).toStrictEqual(ref) }) }) describe('idempotency', () => { describe('with a third-party XML record', () => { describe('when converting to a native record and back to XML', () => { - it('keeps the record unchanged (dataset)', () => { - const backAndForth = toXml( - toModel(GEO2FRANCE_PLU_DATASET), + it('keeps the record unchanged (dataset)', async () => { + const backAndForth = await converter.writeRecord( + await converter.readRecord(GEO2FRANCE_PLU_DATASET), GEO2FRANCE_PLU_DATASET ) expect(backAndForth).toStrictEqual(formatXml(GEO2FRANCE_PLU_DATASET)) }) - it('keeps the record unchanged (service)', () => { - const backAndForth = toXml( - toModel(GEOCAT_CH_SERVICE), + it('keeps the record unchanged (service)', async () => { + const backAndForth = await converter.writeRecord( + await converter.readRecord(GEOCAT_CH_SERVICE), GEOCAT_CH_SERVICE ) expect(backAndForth).toStrictEqual(formatXml(GEOCAT_CH_SERVICE)) @@ -74,11 +86,15 @@ describe('ISO19139 converter', () => { }) describe('with a native record', () => { describe('when converting to XML and back', () => { - it('keeps the record unchanged', () => { - const backAndForth = toModel(toXml(GENERIC_DATASET_RECORD)) + it('keeps the record unchanged', async () => { + const backAndForth = await converter.readRecord( + await converter.writeRecord(GENERIC_DATASET_RECORD) + ) // unsupported fields need to be filtered out + const { recordPublished, recordCreated, ...withoutDates } = + GENERIC_DATASET_RECORD expect(backAndForth).toStrictEqual({ - ...GENERIC_DATASET_RECORD, + ...withoutDates, ownerOrganization: { name: GENERIC_DATASET_RECORD.ownerOrganization.name, website: GENERIC_DATASET_RECORD.ownerOrganization.website, @@ -90,6 +106,15 @@ describe('ISO19139 converter', () => { website: c.organization.website, }, })), + contactsForResource: GENERIC_DATASET_RECORD.contactsForResource.map( + (c) => ({ + ...c, + organization: { + name: c.organization.name, + website: c.organization.website, + }, + }) + ), }) }) }) diff --git a/libs/api/metadata-converter/src/lib/iso19139/iso19139.converter.ts b/libs/api/metadata-converter/src/lib/iso19139/iso19139.converter.ts new file mode 100644 index 0000000000..8ccfa3e18e --- /dev/null +++ b/libs/api/metadata-converter/src/lib/iso19139/iso19139.converter.ts @@ -0,0 +1,327 @@ +import { + CatalogRecord, + CatalogRecordKeys, + DatasetRecord, + ServiceRecord, +} from '@geonetwork-ui/common/domain/model/record' +import { + createDocument, + createElement, + getRootElement, + parseXmlString, + xmlToString, +} from '../xml-utils' +import { + writeAbstract, + writeContacts, + writeContactsForResource, + writeDistributions, + writeGraphicOverviews, + writeKeywords, + writeKind, + writeLegalConstraints, + writeLicenses, + writeLineage, + writeOnlineResources, + writeOtherConstraints, + writeOwnerOrganization, + writeRecordUpdated, + writeResourceCreated, + writeResourcePublished, + writeResourceUpdated, + writeSecurityConstraints, + writeSpatialRepresentation, + writeStatus, + writeTitle, + writeTopics, + writeUniqueIdentifier, + writeUpdateFrequency, +} from './write-parts' +import { + readAbstract, + readContacts, + readContactsForResource, + readDistributions, + readIsoTopics, + readKeywords, + readKind, + readLegalConstraints, + readLicenses, + readLineage, + readOnlineResources, + readOtherConstraints, + readOverviews, + readOwnerOrganization, + readRecordUpdated, + readResourceCreated, + readResourcePublished, + readResourceUpdated, + readSecurityConstraints, + readSpatialRepresentation, + readStatus, + readTitle, + readUniqueIdentifier, + readUpdateFrequency, +} from './read-parts' +import { isEqual } from '../convert-utils' +import { BaseConverter } from '../base.converter' +import { XmlElement } from '@rgrove/parse-xml' + +export class Iso19139Converter extends BaseConverter { + protected readers: Record< + CatalogRecordKeys, + (rootEl: XmlElement) => unknown + > = { + uniqueIdentifier: readUniqueIdentifier, + kind: readKind, + ownerOrganization: readOwnerOrganization, + recordUpdated: readRecordUpdated, + recordCreated: () => undefined, // not supported in ISO19139 + recordPublished: () => undefined, // not supported in ISO19139 + resourceUpdated: readResourceUpdated, + resourceCreated: readResourceCreated, + resourcePublished: readResourcePublished, + title: readTitle, + abstract: readAbstract, + contacts: readContacts, + contactsForResource: readContactsForResource, + keywords: readKeywords, + topics: readIsoTopics, + licenses: readLicenses, + legalConstraints: readLegalConstraints, + securityConstraints: readSecurityConstraints, + otherConstraints: readOtherConstraints, + status: readStatus, + updateFrequency: readUpdateFrequency, + spatialRepresentation: readSpatialRepresentation, + overviews: readOverviews, + lineage: readLineage, + distributions: readDistributions, + onlineResources: readOnlineResources, + // TODO + spatialExtents: () => [], + temporalExtents: () => [], + extras: () => undefined, + landingPage: () => undefined, + languages: () => [], + } + + protected writers: Record< + CatalogRecordKeys, + (record: CatalogRecord, rootEl: XmlElement) => void + > = { + uniqueIdentifier: writeUniqueIdentifier, + kind: writeKind, + ownerOrganization: writeOwnerOrganization, + recordUpdated: writeRecordUpdated, + recordCreated: () => undefined, // not supported in ISO19139 + recordPublished: () => undefined, // not supported in ISO19139 + resourceUpdated: writeResourceUpdated, + resourceCreated: writeResourceCreated, + resourcePublished: writeResourcePublished, + title: writeTitle, + abstract: writeAbstract, + contacts: writeContacts, + contactsForResource: writeContactsForResource, + keywords: writeKeywords, + topics: writeTopics, + licenses: writeLicenses, + legalConstraints: writeLegalConstraints, + securityConstraints: writeSecurityConstraints, + otherConstraints: writeOtherConstraints, + status: writeStatus, + updateFrequency: writeUpdateFrequency, + spatialRepresentation: writeSpatialRepresentation, + overviews: writeGraphicOverviews, + lineage: writeLineage, + distributions: writeDistributions, + onlineResources: writeOnlineResources, + // TODO + spatialExtents: () => undefined, + temporalExtents: () => undefined, + extras: () => undefined, + landingPage: () => undefined, + languages: () => undefined, + } + + protected beforeDocumentCreation(rootElement: XmlElement) { + // to override + } + + async readRecord(document: string): Promise { + const doc = parseXmlString(document) + const rootEl = getRootElement(doc) + + const uniqueIdentifier = this.readers['uniqueIdentifier'](rootEl) + const kind = this.readers['kind'](rootEl) + const ownerOrganization = this.readers['ownerOrganization'](rootEl) + const title = this.readers['title'](rootEl) + const abstract = this.readers['abstract'](rootEl) + const contacts = this.readers['contacts'](rootEl) + const contactsForResource = this.readers['contactsForResource'](rootEl) + const recordUpdated = this.readers['recordUpdated'](rootEl) + const recordCreated = this.readers['recordCreated'](rootEl) + const recordPublished = this.readers['recordPublished'](rootEl) + const resourceCreated = this.readers['resourceCreated'](rootEl) + const resourceUpdated = this.readers['resourceUpdated'](rootEl) + const resourcePublished = this.readers['resourcePublished'](rootEl) + const keywords = this.readers['keywords'](rootEl) + const topics = this.readers['topics'](rootEl) + const legalConstraints = this.readers['legalConstraints'](rootEl) + const otherConstraints = this.readers['otherConstraints'](rootEl) + const securityConstraints = this.readers['securityConstraints'](rootEl) + const licenses = this.readers['licenses'](rootEl) + const overviews = this.readers['overviews'](rootEl) + const landingPage = this.readers['landingPage'](rootEl) + + if (kind === 'dataset') { + const status = this.readers['status'](rootEl) + const spatialRepresentation = + this.readers['spatialRepresentation'](rootEl) + const spatialExtents = this.readers['spatialExtents'](rootEl) + const temporalExtents = this.readers['temporalExtents'](rootEl) + const lineage = this.readers['lineage'](rootEl) + const distributions = this.readers['distributions'](rootEl) + const updateFrequency = this.readers['updateFrequency'](rootEl) + + return { + uniqueIdentifier, + kind, + languages: [], + ...(recordCreated && { recordCreated }), + ...(recordPublished && { recordPublished }), + recordUpdated, + ...(resourceCreated && { resourceCreated }), + ...(resourceUpdated && { resourceUpdated }), + ...(resourcePublished && { resourcePublished }), + status, + title, + abstract, + ownerOrganization, + contacts, + contactsForResource, + keywords, + topics, + licenses, + legalConstraints, + securityConstraints, + otherConstraints, + lineage, + ...(spatialRepresentation && { spatialRepresentation }), + overviews, + spatialExtents, + temporalExtents, + distributions, + updateFrequency, + ...(landingPage && { landingPage }), + } as DatasetRecord + } else { + const onlineResources = this.readers['onlineResources'](rootEl) + return { + uniqueIdentifier, + kind, + languages: [], + ...(recordCreated && { recordCreated }), + ...(recordPublished && { recordPublished }), + recordUpdated, + ...(resourceCreated && { resourceCreated }), + ...(resourceUpdated && { resourceUpdated }), + ...(resourcePublished && { resourcePublished }), + title, + abstract, + ownerOrganization, + contacts, + contactsForResource, + keywords, + topics, + licenses, + legalConstraints, + securityConstraints, + otherConstraints, + overviews, + onlineResources, + ...(landingPage && { landingPage }), + } as ServiceRecord + } + } + + async writeRecord( + record: CatalogRecord, + reference?: string + ): Promise { + let rootEl: XmlElement + let fieldChanged: (name: string) => boolean + if (reference) { + const originalDoc = parseXmlString(reference) + const originalRecord = await this.readRecord(reference) + rootEl = getRootElement(originalDoc) + + fieldChanged = (name: string) => { + return originalRecord !== null + ? !isEqual(record[name], originalRecord[name]) + : true + } + } else { + rootEl = createElement('gmd:MD_Metadata')() + fieldChanged = () => true + } + + fieldChanged('uniqueIdentifier') && + this.writers['uniqueIdentifier'](record, rootEl) + fieldChanged('kind') && this.writers['kind'](record, rootEl) + + fieldChanged('contacts') && this.writers['contacts'](record, rootEl) + fieldChanged('ownerOrganization') && + this.writers['ownerOrganization'](record, rootEl) + + fieldChanged('recordUpdated') && + this.writers['recordUpdated'](record, rootEl) + fieldChanged('recordCreated') && + this.writers['recordCreated'](record, rootEl) + fieldChanged('recordPublished') && + this.writers['recordPublished'](record, rootEl) + + fieldChanged('title') && this.writers['title'](record, rootEl) + fieldChanged('abstract') && this.writers['abstract'](record, rootEl) + + fieldChanged('resourceCreated') && + this.writers['resourceCreated'](record, rootEl) + fieldChanged('resourcePublished') && + this.writers['resourcePublished'](record, rootEl) + fieldChanged('resourceUpdated') && + this.writers['resourceUpdated'](record, rootEl) + + fieldChanged('contactsForResource') && + this.writers['contactsForResource'](record, rootEl) + + fieldChanged('keywords') && this.writers['keywords'](record, rootEl) + fieldChanged('topics') && this.writers['topics'](record, rootEl) + fieldChanged('legalConstraints') && + this.writers['legalConstraints'](record, rootEl) + fieldChanged('securityConstraints') && + this.writers['securityConstraints'](record, rootEl) + fieldChanged('licenses') && this.writers['licenses'](record, rootEl) + fieldChanged('otherConstraints') && + this.writers['otherConstraints'](record, rootEl) + + if (record.kind === 'dataset') { + fieldChanged('status') && this.writers['status'](record, rootEl) + fieldChanged('updateFrequency') && + this.writers['updateFrequency'](record, rootEl) + fieldChanged('spatialRepresentation') && + this.writers['spatialRepresentation'](record, rootEl) + fieldChanged('overviews') && this.writers['overviews'](record, rootEl) + fieldChanged('distributions') && + this.writers['distributions'](record, rootEl) + fieldChanged('lineage') && this.writers['lineage'](record, rootEl) + } else { + fieldChanged('onlineResources') && + this.writers['onlineResources'](record, rootEl) + } + + this.beforeDocumentCreation(rootEl) + + const newDocument = createDocument(rootEl) + return xmlToString(newDocument) + } +} diff --git a/libs/api/metadata-converter/src/lib/iso19139/read-parts.spec.ts b/libs/api/metadata-converter/src/lib/iso19139/read-parts.spec.ts index 52da80f844..9e30c111e0 100644 --- a/libs/api/metadata-converter/src/lib/iso19139/read-parts.spec.ts +++ b/libs/api/metadata-converter/src/lib/iso19139/read-parts.spec.ts @@ -32,19 +32,13 @@ describe('read parts', () => { it('returns an array of individuals with their organization', () => { expect(readContacts(recordRootEl)).toEqual([ { + address: 'Ittigen, 3063, CH', email: 'rolf.giezendanner@are.admin.ch', organization: { name: 'Bundesamt für Raumentwicklung', }, role: 'point_of_contact', }, - { - email: 'info@are.admin.ch', - organization: { - name: 'Bundesamt für Raumentwicklung', - }, - role: 'owner', - }, ]) }) }) diff --git a/libs/api/metadata-converter/src/lib/iso19139/read-parts.ts b/libs/api/metadata-converter/src/lib/iso19139/read-parts.ts index 831e69b48f..674feff624 100644 --- a/libs/api/metadata-converter/src/lib/iso19139/read-parts.ts +++ b/libs/api/metadata-converter/src/lib/iso19139/read-parts.ts @@ -1,8 +1,6 @@ import { Constraint, DatasetDistribution, - DatasetSpatialExtent, - DatasetTemporalExtent, GraphicOverview, Individual, Keyword, @@ -16,8 +14,8 @@ import { UpdateFrequency, UpdateFrequencyCustom, } from '@geonetwork-ui/common/domain/model/record' -import { getStatusFromStatusCode } from './codelists/status.mapper' -import { getUpdateFrequencyFromFrequencyCode } from './codelists/update-frequency.mapper' +import { getStatusFromStatusCode } from './utils/status.mapper' +import { getUpdateFrequencyFromFrequencyCode } from './utils/update-frequency.mapper' import { findChildElement, findChildrenElement, @@ -39,11 +37,15 @@ import { mapArray, pipe, } from '../function-utils' -import { getRoleFromRoleCode } from './codelists/role.mapper' +import { getRoleFromRoleCode } from './utils/role.mapper' import { matchMimeType, matchProtocol } from '../common/distribution.mapper' -import { getKeywordTypeFromKeywordTypeCode } from './codelists/keyword.mapper' +import { getKeywordTypeFromKeywordTypeCode } from './utils/keyword.mapper' +import { fullNameToParts } from './utils/individual-name' -function extractCharacterString(): ChainableFunction { +export function extractCharacterString(): ChainableFunction< + XmlElement, + string +> { return pipe( fallback( findChildElement('gco:CharacterString', false), @@ -53,7 +55,7 @@ function extractCharacterString(): ChainableFunction { ) } -function extractDateTime(): ChainableFunction { +export function extractDateTime(): ChainableFunction { return pipe( fallback( findChildElement('gco:DateTime', false), @@ -64,7 +66,7 @@ function extractDateTime(): ChainableFunction { ) } -function extractUrl(): ChainableFunction { +export function extractUrl(): ChainableFunction { const getUrl = pipe(findChildElement('gmd:URL', false), readText()) const getCharacterString = pipe( findChildElement('gco:CharacterString', false), @@ -86,12 +88,12 @@ function extractUrl(): ChainableFunction { ) } -function extractMandatoryUrl() { +export function extractMandatoryUrl() { return fallback(extractUrl(), () => new URL('http://missing')) } // from gmd:role -function extractRole(): ChainableFunction { +export function extractRole(): ChainableFunction { return pipe( findChildElement('gmd:CI_RoleCode'), readAttribute('codeListValue'), @@ -100,7 +102,10 @@ function extractRole(): ChainableFunction { } // from gmd:CI_ResponsibleParty -function extractOrganization(): ChainableFunction { +export function extractOrganization(): ChainableFunction< + XmlElement, + Organization +> { const getUrl = pipe( findNestedElements( 'gmd:contactInfo', @@ -128,10 +133,7 @@ function extractOrganization(): ChainableFunction { } // from gmd:CI_ResponsibleParty -function extractIndividuals(): ChainableFunction< - XmlElement, - Array -> { +export function extractIndividual(): ChainableFunction { const getRole = pipe(findChildElement('gmd:role'), extractRole()) const getPosition = pipe( findChildElement('gmd:positionName'), @@ -142,40 +144,70 @@ function extractIndividuals(): ChainableFunction< extractCharacterString(), map((fullName) => { if (!fullName) return [] - const parts = fullName.split(/\s+/) - if (!parts.length) return [fullName, null] - const first = parts.shift() - return [first, parts.join(' ')] + return fullNameToParts(fullName) }) ) const getOrganization = extractOrganization() + const getContactRoot = findNestedElement('gmd:contactInfo', 'gmd:CI_Contact') const getEmail = pipe( + getContactRoot, findChildElement('gmd:electronicMailAddress'), extractCharacterString(), map((email) => (email === null ? 'missing@missing.com' : email)) ) + const getAddress = pipe( + getContactRoot, + findNestedElement('gmd:address', 'gmd:CI_Address'), + combine( + pipe( + findChildElement('gmd:deliveryPoint', false), + extractCharacterString() + ), + pipe(findChildElement('gmd:city', false), extractCharacterString()), + pipe(findChildElement('gmd:postalCode', false), extractCharacterString()), + pipe(findChildElement('gmd:country', false), extractCharacterString()) + ), + map((parts) => parts.filter((p) => !!p).join(', ')) + ) + const getPhone = pipe( + getContactRoot, + findNestedElement('gmd:phone', 'gmd:CI_Telephone', 'gmd:voice'), + extractCharacterString() + ) return pipe( combine( getRole, getPosition, getNameParts, getOrganization, - pipe(findChildrenElement('gmd:contactInfo'), mapArray(getEmail)) + getEmail, + getAddress, + getPhone ), - map(([role, position, [firstName, lastName], organization, emails]) => - emails.map((email) => ({ + map( + ([ + role, + position, + [firstName, lastName], + organization, + email, + address, + phone, + ]) => ({ email, role, organization, ...(position && { position }), ...(firstName && { firstName }), ...(lastName && { lastName }), - })) + ...(address && { address }), + ...(phone && { phone }), + }) ) ) } -function extractStatus(): ChainableFunction { +export function extractStatus(): ChainableFunction { return pipe( findChildElement('gmd:MD_ProgressCode'), readAttribute('codeListValue'), @@ -184,7 +216,7 @@ function extractStatus(): ChainableFunction { } // from gmd:resourceConstraints -function extractLegalConstraints(): ChainableFunction< +export function extractLegalConstraints(): ChainableFunction< XmlElement, Array > { @@ -214,7 +246,7 @@ function extractLegalConstraints(): ChainableFunction< } // from gmd:resourceConstraints -function extractSecurityConstraints(): ChainableFunction< +export function extractSecurityConstraints(): ChainableFunction< XmlElement, Array > { @@ -230,7 +262,7 @@ function extractSecurityConstraints(): ChainableFunction< } // from gmd:resourceConstraints -function extractOtherConstraints(): ChainableFunction< +export function extractOtherConstraints(): ChainableFunction< XmlElement, Array > { @@ -246,7 +278,10 @@ function extractOtherConstraints(): ChainableFunction< } // from gmd:resourceConstraints -function extractLicenses(): ChainableFunction> { +export function extractLicenses(): ChainableFunction< + XmlElement, + Array +> { return pipe( findChildrenElement('gmd:MD_LegalConstraints', false), filterArray( @@ -272,18 +307,20 @@ function extractLicenses(): ChainableFunction> { ) } -// from gmd:MD_Distribution -function extractDatasetDistributions(): ChainableFunction< - XmlElement, - DatasetDistribution[] -> { - const getFormat = pipe( - findParent('gmd:MD_Distribution'), - findNestedElement('gmd:distributionFormat', 'gmd:MD_Format', 'gmd:name'), - extractCharacterString(), - map(matchMimeType) - ) +const getMimeType = pipe( + findParent('gmd:MD_Distribution'), + findNestedElement('gmd:distributionFormat', 'gmd:MD_Format', 'gmd:name'), + extractCharacterString(), + map(matchMimeType) +) +/** + * Extract distributions from a MD_Distribution element + * @param getMimeTypeFn This function starts from a gmd:transferOptions element + */ +export function extractDatasetDistributions( + getMimeTypeFn: ChainableFunction +): ChainableFunction { const getUrl = pipe(findChildElement('gmd:linkage'), extractMandatoryUrl()) const getProtocolStr = pipe( findChildElement('gmd:protocol'), @@ -326,11 +363,11 @@ function extractDatasetDistributions(): ChainableFunction< getUrl, getName, getDescription, - getFormat + getMimeTypeFn ) ), mapArray( - ([isService, isDownload, protocol, url, name, description, format]) => { + ([isService, isDownload, protocol, url, name, description, mimeType]) => { if (isService) { const hasIdentifier = protocol === 'wms' || protocol === 'wfs' return { @@ -342,7 +379,6 @@ function extractDatasetDistributions(): ChainableFunction< ...(description && { description }), } } else if (isDownload) { - const mimeType = format return { type: 'download', url: url, @@ -363,7 +399,7 @@ function extractDatasetDistributions(): ChainableFunction< ) } -function getUpdateFrequencyFromCustomPeriod( +export function getUpdateFrequencyFromCustomPeriod( isoPeriod: string ): UpdateFrequencyCustom { if (!isoPeriod) return null @@ -415,7 +451,7 @@ function getUpdateFrequencyFromCustomPeriod( } // from gmd:MD_MaintenanceInformation -function extractUpdateFrequency(): ChainableFunction< +export function extractUpdateFrequency(): ChainableFunction< XmlElement, UpdateFrequency > { @@ -439,18 +475,24 @@ function extractUpdateFrequency(): ChainableFunction< /** * Looks for srv:SV_ServiceIdentification or gmd:MD_DataIdentification element - * depending on record type + * Will find the first one that exists, not reading the type of the record + * (this allows using this function in other similar schemas) */ -function findIdentification() { - return (rootEl: XmlElement) => { - const kind = readKind(rootEl) - let eltName = 'gmd:MD_DataIdentification' - if (kind === 'service') eltName = 'srv:SV_ServiceIdentification' - return findNestedElement('gmd:identificationInfo', eltName)(rootEl) - } +export function findIdentification() { + return pipe( + findChildElement('gmd:identificationInfo', false), + combine( + findChildElement('gmd:MD_DataIdentification', false), + findChildElement('srv:SV_ServiceIdentification', false) + ), + filterArray((el) => el !== null), + getAtIndex(0) + ) } -function extractCitationDate(type: 'creation' | 'revision' | 'publication') { +export function extractIdentificationDate( + type: 'creation' | 'revision' | 'publication' +) { return pipe( findIdentification(), findNestedElements('gmd:citation', 'gmd:CI_Citation', 'gmd:date'), @@ -473,7 +515,7 @@ function extractCitationDate(type: 'creation' | 'revision' | 'publication') { ) } -function getSpatialRepresentationFromCode( +export function getSpatialRepresentationFromCode( spatialRepresentationCode: string ): SpatialRepresentationType | null { switch (spatialRepresentationCode) { @@ -513,11 +555,19 @@ export function readOwnerOrganization(rootEl: XmlElement): Organization { )(rootEl) } -export function readRecordUpdated(rootEl: XmlElement): Date { - return pipe(findChildElement('gmd:dateStamp'), extractDateTime())(rootEl) +export function readResourceUpdated(rootEl: XmlElement): Date { + return extractIdentificationDate('revision')(rootEl) +} + +export function readResourceCreated(rootEl: XmlElement): Date { + return extractIdentificationDate('creation')(rootEl) } -export function readRecordPublished(rootEl: XmlElement): Date { +export function readResourcePublished(rootEl: XmlElement): Date { + return extractIdentificationDate('publication')(rootEl) +} + +export function readRecordUpdated(rootEl: XmlElement): Date { return pipe(findChildElement('gmd:dateStamp'), extractDateTime())(rootEl) } @@ -537,30 +587,29 @@ export function readAbstract(rootEl: XmlElement): string { )(rootEl) } -export function readDatasetCreated(rootEl: XmlElement): Date { - return extractCitationDate('creation')(rootEl) -} - -export function readDatasetUpdated(rootEl: XmlElement): Date { - return extractCitationDate('revision')(rootEl) +export function readContacts(rootEl: XmlElement): Individual[] { + return pipe( + findChildrenElement('gmd:contact', false), + mapArray(findChildElement('gmd:CI_ResponsibleParty', false)), + mapArray(extractIndividual()) + )(rootEl) } -export function readContacts(rootEl: XmlElement): Individual[] { +export function readContactsForResource(rootEl: XmlElement): Individual[] { return pipe( findIdentification(), combine( - findChildrenElement('gmd:contact'), - findChildrenElement('gmd:pointOfContact') + findChildrenElement('gmd:contact', false), + findChildrenElement('gmd:pointOfContact', false) ), flattenArray(), mapArray(findChildElement('gmd:CI_ResponsibleParty', false)), - mapArray(extractIndividuals()), - flattenArray() + mapArray(extractIndividual()) )(rootEl) } // from gmd:thesaurusName -function readThesaurus(rootEl: XmlElement): KeywordThesaurus { +export function readThesaurus(rootEl: XmlElement): KeywordThesaurus { if (!rootEl) return null const findIdentifier = findNestedElement( @@ -583,7 +632,7 @@ function readThesaurus(rootEl: XmlElement): KeywordThesaurus { } // from gmd:MD_Keywords -function readKeywordGroup(rootEl: XmlElement): Keyword[] { +export function readKeywordGroup(rootEl: XmlElement): Keyword[] { const type = pipe( findChildrenElement('gmd:MD_KeywordTypeCode'), mapArray(readAttribute('codeListValue')), @@ -701,16 +750,6 @@ export function readOverviews(rootEl: XmlElement): GraphicOverview[] { )(rootEl) } -export function readSpatialExtents(rootEl: XmlElement): DatasetSpatialExtent[] { - return [] // TODO -} - -export function readTemporalExtents( - rootEl: XmlElement -): DatasetTemporalExtent[] { - return [] // TODO -} - export function readLineage(rootEl: XmlElement): string { return pipe( findNestedElement( @@ -727,7 +766,7 @@ export function readLineage(rootEl: XmlElement): string { export function readDistributions(rootEl: XmlElement): DatasetDistribution[] { return pipe( findNestedElements('gmd:distributionInfo', 'gmd:MD_Distribution'), - mapArray(extractDatasetDistributions()), + mapArray(extractDatasetDistributions(getMimeType)), flattenArray() )(rootEl) } diff --git a/libs/api/metadata-converter/src/lib/iso19139/utils/individual-name.spec.ts b/libs/api/metadata-converter/src/lib/iso19139/utils/individual-name.spec.ts new file mode 100644 index 0000000000..18fd1b68d9 --- /dev/null +++ b/libs/api/metadata-converter/src/lib/iso19139/utils/individual-name.spec.ts @@ -0,0 +1,23 @@ +import { fullNameToParts, namePartsToFull } from './individual-name' + +describe('individual name utils', () => { + it('fullNameToParts', () => { + expect(fullNameToParts('John Doe')).toEqual(['John', 'Doe']) + expect(fullNameToParts('John')).toEqual(['John', null]) + expect(fullNameToParts(' John Jim Doe Blah ')).toEqual([ + 'John', + 'Jim Doe Blah', + ]) + }) + it('namePartsToFull', () => { + expect(namePartsToFull('John', 'Doe')).toEqual('John Doe') + expect(namePartsToFull('John', null)).toEqual('John') + expect(namePartsToFull(null, 'Doe')).toEqual('Doe') + expect(namePartsToFull(null, null)).toEqual(null) + expect(namePartsToFull('', ' ')).toEqual(null) + expect(namePartsToFull('John', 'Doe Blah')).toEqual('John Doe Blah') + expect(namePartsToFull(' John Jim ', ' Doe Blah ')).toEqual( + 'John Jim Doe Blah' + ) + }) +}) diff --git a/libs/api/metadata-converter/src/lib/iso19139/utils/individual-name.ts b/libs/api/metadata-converter/src/lib/iso19139/utils/individual-name.ts new file mode 100644 index 0000000000..ef3ddb7b1a --- /dev/null +++ b/libs/api/metadata-converter/src/lib/iso19139/utils/individual-name.ts @@ -0,0 +1,20 @@ +/** + * Parts are [firstName, lastName] + * Second part will be null if no separation could be done + * @param fullName + */ +export function fullNameToParts(fullName: string): [string, string | null] { + const parts = fullName.trim().split(/\s+/) + const first = parts.shift() + return [first, parts.join(' ').trim() || null] +} + +export function namePartsToFull( + firstName: string | null, + lastName: string | null +): string | null { + const first = firstName?.trim() + const last = lastName?.trim() + if (!first && !last) return null + return last && first ? `${first} ${last}` : last || first +} diff --git a/libs/api/metadata-converter/src/lib/iso19139/codelists/keyword.mapper.ts b/libs/api/metadata-converter/src/lib/iso19139/utils/keyword.mapper.ts similarity index 100% rename from libs/api/metadata-converter/src/lib/iso19139/codelists/keyword.mapper.ts rename to libs/api/metadata-converter/src/lib/iso19139/utils/keyword.mapper.ts diff --git a/libs/api/metadata-converter/src/lib/iso19139/codelists/role.mapper.ts b/libs/api/metadata-converter/src/lib/iso19139/utils/role.mapper.ts similarity index 100% rename from libs/api/metadata-converter/src/lib/iso19139/codelists/role.mapper.ts rename to libs/api/metadata-converter/src/lib/iso19139/utils/role.mapper.ts diff --git a/libs/api/metadata-converter/src/lib/iso19139/codelists/status.mapper.ts b/libs/api/metadata-converter/src/lib/iso19139/utils/status.mapper.ts similarity index 100% rename from libs/api/metadata-converter/src/lib/iso19139/codelists/status.mapper.ts rename to libs/api/metadata-converter/src/lib/iso19139/utils/status.mapper.ts diff --git a/libs/api/metadata-converter/src/lib/iso19139/codelists/update-frequency.mapper.ts b/libs/api/metadata-converter/src/lib/iso19139/utils/update-frequency.mapper.ts similarity index 100% rename from libs/api/metadata-converter/src/lib/iso19139/codelists/update-frequency.mapper.ts rename to libs/api/metadata-converter/src/lib/iso19139/utils/update-frequency.mapper.ts diff --git a/libs/api/metadata-converter/src/lib/iso19139/write-parts.spec.ts b/libs/api/metadata-converter/src/lib/iso19139/write-parts.spec.ts index 9755306808..12b4cd1009 100644 --- a/libs/api/metadata-converter/src/lib/iso19139/write-parts.spec.ts +++ b/libs/api/metadata-converter/src/lib/iso19139/write-parts.spec.ts @@ -422,6 +422,93 @@ describe('write parts', () => { +`) + }) + + it('correctly adds a thesaurus to an existing keyword', () => { + // add some distributions first + const sample = parseXmlString(` + + + + + + + Usage des sols + + + Agriculture + + + + + + + + +`) + rootEl = getRootElement(sample) + writeKeywords( + { + ...datasetRecord, + keywords: [ + { + label: 'Usage des sols', + type: 'theme', + }, + { + label: 'Agriculture', + type: 'theme', + thesaurus: { + id: 'abcd', + url: new URL('http://abcd.com'), + name: 'A thesaurus', + }, + }, + ], + }, + rootEl + ) + expect(rootAsString()).toEqual(` + + + + + + + + + Usage des sols + + + + + + + + + + + + A thesaurus + + + + + abcd + + + + + + + Agriculture + + + + + `) }) }) diff --git a/libs/api/metadata-converter/src/lib/iso19139/write-parts.ts b/libs/api/metadata-converter/src/lib/iso19139/write-parts.ts index 4e9dfe4f7e..21b54ca1c0 100644 --- a/libs/api/metadata-converter/src/lib/iso19139/write-parts.ts +++ b/libs/api/metadata-converter/src/lib/iso19139/write-parts.ts @@ -36,6 +36,7 @@ import { } from '../xml-utils' import { ChainableFunction, + combine, fallback, filterArray, getAtIndex, @@ -47,8 +48,9 @@ import { } from '../function-utils' import format from 'date-fns/format' import { readKind } from './read-parts' +import { namePartsToFull } from './utils/individual-name' -function writeCharacterString( +export function writeCharacterString( text: string ): ChainableFunction { return tap( @@ -56,7 +58,9 @@ function writeCharacterString( ) } -function writeLinkage(url: URL): ChainableFunction { +export function writeLinkage( + url: URL +): ChainableFunction { return tap( pipe( findNestedChildOrCreate('gmd:linkage', 'gmd:URL'), @@ -65,7 +69,7 @@ function writeLinkage(url: URL): ChainableFunction { ) } -function writeAnchor( +export function writeAnchor( url: URL, text?: string ): ChainableFunction { @@ -78,7 +82,9 @@ function writeAnchor( ) } -function writeDateTime(date: Date): ChainableFunction { +export function writeDateTime( + date: Date +): ChainableFunction { return tap( pipe( findChildOrCreate('gco:DateTime'), @@ -87,7 +93,9 @@ function writeDateTime(date: Date): ChainableFunction { ) } -function writeDate(date: Date): ChainableFunction { +export function writeDate( + date: Date +): ChainableFunction { return tap( pipe( findChildOrCreate('gco:Date'), @@ -96,7 +104,7 @@ function writeDate(date: Date): ChainableFunction { ) } -function getProgressCode(status: RecordStatus): string { +export function getProgressCode(status: RecordStatus): string { switch (status) { case 'completed': return 'completed' @@ -115,7 +123,7 @@ function getProgressCode(status: RecordStatus): string { } } -function getRoleCode(role: Role): string { +export function getRoleCode(role: Role): string { switch (role) { case 'author': return 'author' @@ -164,7 +172,7 @@ function getRoleCode(role: Role): string { } } -function getDistributionProtocol( +export function getDistributionProtocol( distribution: DatasetServiceDistribution ): string { switch (distribution.accessServiceProtocol.toLowerCase()) { @@ -179,7 +187,7 @@ function getDistributionProtocol( } } -function getMaintenanceFrequencyCode( +export function getMaintenanceFrequencyCode( updateFrequency: UpdateFrequencyCode ): string | null { switch (updateFrequency) { @@ -198,7 +206,7 @@ function getMaintenanceFrequencyCode( } } -function getISODuration(updateFrequency: UpdateFrequencyCustom): string { +export function getISODuration(updateFrequency: UpdateFrequencyCustom): string { const duration = { years: 0, months: 0, @@ -227,19 +235,61 @@ function getISODuration(updateFrequency: UpdateFrequencyCustom): string { return `P${duration.years}Y${duration.months}M${duration.days}D${hours}` } -function appendResponsibleParty(contact: Individual) { - const name = - contact.lastName && contact.firstName - ? `${contact.firstName} ${contact.lastName}` - : contact.lastName || contact.firstName || null +export function appendResponsibleParty(contact: Individual) { + const fullName = namePartsToFull(contact.firstName, contact.lastName) + + const createAddress = pipe( + createElement('gmd:address'), + createChild('gmd:CI_Address'), + appendChildren( + pipe( + createElement('gmd:electronicMailAddress'), + writeCharacterString(contact.email) + ) + ), + contact.address + ? appendChildren( + pipe( + createElement('gmd:deliveryPoint'), + writeCharacterString(contact.address) + ) + ) + : noop + ) + + const createContact = pipe( + createElement('gmd:contactInfo'), + createChild('gmd:CI_Contact'), + contact.phone + ? appendChildren( + pipe( + createElement('gmd:phone'), + createChild('gmd:CI_Telephone'), + createChild('gmd:voice'), + writeCharacterString(contact.phone) + ) + ) + : noop, + appendChildren(createAddress), + 'website' in contact.organization + ? appendChildren( + pipe( + createElement('gmd:onlineResource'), + createChild('gmd:CI_OnlineResource'), + writeLinkage(contact.organization.website) + ) + ) + : noop + ) + return appendChildren( pipe( createElement('gmd:CI_ResponsibleParty'), - name + fullName ? appendChildren( pipe( createElement('gmd:individualName'), - writeCharacterString(name) + writeCharacterString(fullName) ) ) : noop, @@ -256,27 +306,7 @@ function appendResponsibleParty(contact: Individual) { createElement('gmd:organisationName'), writeCharacterString(contact.organization.name) ), - pipe( - createElement('gmd:contactInfo'), - createChild('gmd:CI_Contact'), - appendChildren( - pipe( - createElement('gmd:address'), - createChild('gmd:CI_Address'), - createChild('gmd:electronicMailAddress'), - writeCharacterString(contact.email) - ) - ), - 'website' in contact.organization - ? appendChildren( - pipe( - createElement('gmd:onlineResource'), - createChild('gmd:CI_OnlineResource'), - writeLinkage(contact.organization.website) - ) - ) - : noop - ), + createContact, pipe( createElement('gmd:role'), createChild('gmd:CI_RoleCode'), @@ -291,7 +321,7 @@ function appendResponsibleParty(contact: Individual) { ) } -function updateCitationDate( +export function updateCitationDate( date: Date, type: 'revision' | 'creation' | 'publication' ) { @@ -311,8 +341,8 @@ function updateCitationDate( ) } -function appendCitationDate( - date, +export function appendCitationDate( + date: Date, type: 'revision' | 'creation' | 'publication' ) { return appendChildren( @@ -335,12 +365,12 @@ function appendCitationDate( ) } -function removeKeywords() { +export function removeKeywords() { return removeChildren(pipe(findNestedElements('gmd:descriptiveKeywords'))) } // returns a element -function createThesaurus(thesaurus: KeywordThesaurus) { +export function createThesaurus(thesaurus: KeywordThesaurus) { return pipe( createElement('gmd:thesaurusName'), createChild('gmd:CI_Citation'), @@ -365,14 +395,15 @@ function createThesaurus(thesaurus: KeywordThesaurus) { ) } -function appendKeywords(keywords: Keyword[]) { +export function appendKeywords(keywords: Keyword[]) { + // keywords are grouped by thesaurus if they have one, otherwise by type const keywordsByThesaurus: Keyword[][] = keywords.reduce((acc, keyword) => { const thesaurusId = keyword.thesaurus?.id const type = keyword.type let existingGroup = acc.find((group) => - group[0].thesaurus - ? group[0].thesaurus.id === thesaurusId - : group[0].type === type + thesaurusId + ? group[0].thesaurus?.id === thesaurusId + : group[0].type === type && !group[0].thesaurus ) if (!existingGroup) { existingGroup = [] @@ -413,7 +444,7 @@ function appendKeywords(keywords: Keyword[]) { ) } -function createConstraint( +export function createConstraint( constraint: Constraint, type: 'legal' | 'security' | 'other' ) { @@ -471,7 +502,7 @@ function createConstraint( ) } -function removeOtherConstraints() { +export function removeOtherConstraints() { return removeChildren( pipe( findChildrenElement('gmd:resourceConstraints'), @@ -485,7 +516,7 @@ function removeOtherConstraints() { ) } -function removeSecurityConstraints() { +export function removeSecurityConstraints() { return removeChildren( pipe( findChildrenElement('gmd:resourceConstraints'), @@ -499,7 +530,7 @@ function removeSecurityConstraints() { ) } -function removeLegalConstraints() { +export function removeLegalConstraints() { return removeChildren( pipe( findChildrenElement('gmd:resourceConstraints'), @@ -519,7 +550,7 @@ function removeLegalConstraints() { ) } -function removeLicenses() { +export function removeLicenses() { return removeChildren( pipe( findChildrenElement('gmd:resourceConstraints'), @@ -539,7 +570,7 @@ function removeLicenses() { ) } -function createLicense(license: Constraint) { +export function createLicense(license: Constraint) { return pipe( createElement('gmd:resourceConstraints'), createChild('gmd:MD_LegalConstraints'), @@ -572,44 +603,52 @@ function createLicense(license: Constraint) { ) } -function removeDistributions() { +export function removeDistributions() { return pipe(removeChildrenByName('gmd:distributionInfo')) } -function createDistribution(distribution: DatasetDistribution) { - const appendDistributionFormat = - 'mimeType' in distribution - ? appendChildren( - pipe( - createElement('gmd:distributionFormat'), - createChild('gmd:MD_Format'), - appendChildren( - pipe( - createElement('gmd:name'), - writeCharacterString(distribution.mimeType) - ), - pipe( - createElement('gmd:version'), - writeCharacterString('1.0') // hardcoding this as it most likely won't be used but is mandatory - ) - ) - ) +function appendDistributionFormat(mimeType: string) { + return appendChildren( + pipe( + createElement('gmd:distributionFormat'), + createChild('gmd:MD_Format'), + appendChildren( + pipe(createElement('gmd:name'), writeCharacterString(mimeType)), + pipe( + createElement('gmd:version'), + writeCharacterString('1.0') // hardcoding this as it most likely won't be used but is mandatory ) - : noop + ) + ) + ) +} + +export function createDistributionInfo() { + return pipe( + createElement('gmd:distributionInfo'), + createChild('gmd:MD_Distribution') + ) +} - let linkageUrl, name, functionCode, protocol +// apply to MD_Distribution +export function appendDistribution( + distribution: DatasetDistribution, + appendFormatFn: ( + mimeType: string + ) => ChainableFunction +) { + let name: string + let functionCode: string + let protocol: string if (distribution.type === 'service') { - linkageUrl = distribution.url.toString() name = distribution.identifierInService // this is for GeoNetwork to know the layer name functionCode = 'download' protocol = getDistributionProtocol(distribution) } else if (distribution.type === 'download') { - linkageUrl = distribution.url.toString() name = distribution.name functionCode = 'download' protocol = 'WWW:DOWNLOAD' } else { - linkageUrl = distribution.url.toString() name = distribution.name functionCode = 'information' protocol = 'WWW:LINK' @@ -620,7 +659,7 @@ function createDistribution(distribution: DatasetDistribution) { createChild('gmd:MD_DigitalTransferOptions'), createChild('gmd:onLine'), createChild('gmd:CI_OnlineResource'), - writeLinkage(linkageUrl), + writeLinkage(distribution.url), 'description' in distribution ? appendChildren( pipe( @@ -649,9 +688,7 @@ function createDistribution(distribution: DatasetDistribution) { ) ) return pipe( - createElement('gmd:distributionInfo'), - createChild('gmd:MD_Distribution'), - appendDistributionFormat, + 'mimeType' in distribution ? appendFormatFn(distribution.mimeType) : noop, appendTransferOptions ) } @@ -660,7 +697,7 @@ function createDistribution(distribution: DatasetDistribution) { * Looks for srv:SV_ServiceIdentification or gmd:MD_DataIdentification element * depending on record type, create if missing */ -function findOrCreateIdentification() { +export function findOrCreateIdentification() { return (rootEl: XmlElement) => { const kind = readKind(rootEl) let eltName = 'gmd:MD_DataIdentification' @@ -669,7 +706,7 @@ function findOrCreateIdentification() { } } -function findOrCreateDistribution() { +export function findOrCreateDistribution() { return (rootEl: XmlElement) => { return findNestedChildOrCreate( 'gmd:distributionInfo', @@ -763,11 +800,26 @@ export function writeStatus(record: DatasetRecord, rootEl: XmlElement) { } export function writeContacts(record: CatalogRecord, rootEl: XmlElement) { + pipe( + removeChildrenByName('gmd:contact'), + appendChildren( + ...record.contacts.map((contact) => + pipe(createElement('gmd:contact'), appendResponsibleParty(contact)) + ) + ) + )(rootEl) +} + +export function writeContactsForResource( + record: CatalogRecord, + rootEl: XmlElement +) { pipe( findOrCreateIdentification(), removeChildrenByName('gmd:pointOfContact'), + removeChildrenByName('gmd:contact'), appendChildren( - ...record.contacts.map((contact) => + ...record.contactsForResource.map((contact) => pipe( createElement('gmd:pointOfContact'), appendResponsibleParty(contact) @@ -878,26 +930,47 @@ export function writeUpdateFrequency( )(rootEl) } -export function writeDatasetCreated(record: DatasetRecord, rootEl: XmlElement) { - if (!('datasetCreated' in record)) return +export function writeResourceCreated( + record: DatasetRecord, + rootEl: XmlElement +) { + if (!('resourceCreated' in record)) return pipe( findOrCreateIdentification(), findNestedChildOrCreate('gmd:citation', 'gmd:CI_Citation'), fallback( - updateCitationDate(record.datasetCreated, 'creation'), - appendCitationDate(record.datasetCreated, 'creation') + updateCitationDate(record.resourceCreated, 'creation'), + appendCitationDate(record.resourceCreated, 'creation') ) )(rootEl) } -export function writeDatasetUpdated(record: DatasetRecord, rootEl: XmlElement) { - if (!('datasetUpdated' in record)) return +export function writeResourceUpdated( + record: DatasetRecord, + rootEl: XmlElement +) { + if (!('resourceUpdated' in record)) return pipe( findOrCreateIdentification(), findNestedChildOrCreate('gmd:citation', 'gmd:CI_Citation'), fallback( - updateCitationDate(record.datasetUpdated, 'revision'), - appendCitationDate(record.datasetUpdated, 'revision') + updateCitationDate(record.resourceUpdated, 'revision'), + appendCitationDate(record.resourceUpdated, 'revision') + ) + )(rootEl) +} + +export function writeResourcePublished( + record: DatasetRecord, + rootEl: XmlElement +) { + if (!('resourcePublished' in record)) return + pipe( + findOrCreateIdentification(), + findNestedChildOrCreate('gmd:citation', 'gmd:CI_Citation'), + fallback( + updateCitationDate(record.resourcePublished, 'publication'), + appendCitationDate(record.resourcePublished, 'publication') ) )(rootEl) } @@ -955,7 +1028,14 @@ export function writeGraphicOverviews( export function writeDistributions(record: DatasetRecord, rootEl: XmlElement) { pipe( removeDistributions(), - appendChildren(...record.distributions.map(createDistribution)) + appendChildren( + ...record.distributions.map((d) => + pipe( + createDistributionInfo(), + appendDistribution(d, appendDistributionFormat) + ) + ) + ) )(rootEl) } @@ -972,7 +1052,7 @@ export function writeLineage(record: DatasetRecord, rootEl: XmlElement) { )(rootEl) } -function getServiceEndpointProtocol(endpoint: ServiceEndpoint): string { +export function getServiceEndpointProtocol(endpoint: ServiceEndpoint): string { switch (endpoint.protocol.toLowerCase()) { case 'wfs': return 'OGC:WFS' @@ -985,7 +1065,7 @@ function getServiceEndpointProtocol(endpoint: ServiceEndpoint): string { } } -function createOnlineResource(onlineResource: ServiceOnlineResource) { +export function createOnlineResource(onlineResource: ServiceOnlineResource) { let linkageUrl, functionCode, protocol if (onlineResource.type === 'endpoint') { linkageUrl = onlineResource.endpointUrl.toString() diff --git a/libs/api/metadata-converter/src/lib/xml-utils.spec.ts b/libs/api/metadata-converter/src/lib/xml-utils.spec.ts index d306d421d6..cbfb4ec919 100644 --- a/libs/api/metadata-converter/src/lib/xml-utils.spec.ts +++ b/libs/api/metadata-converter/src/lib/xml-utils.spec.ts @@ -1,4 +1,10 @@ -import { parseXmlString, xmlToString } from './xml-utils' +import { + getRootElement, + parseXmlString, + renameElements, + xmlToString, +} from './xml-utils' +import { XmlElement } from '@rgrove/parse-xml' describe('xml utils', () => { describe('xmlToString', () => { @@ -32,4 +38,110 @@ end. expect(xmlToString(doc)).toEqual(input) }) }) + + describe('replaceNamespace', () => { + let rootEl: XmlElement + beforeEach(() => { + rootEl = getRootElement( + parseXmlString(` + + + + + + + + + + + + + + + + + Alpenkonvention + + + Alpenkonvention + + + Convention des Alpes + + + Convenzione delle alpi + + + Alpine Convention + + + Convenziun da las Alps + + + + + + + +`) + ) + rootEl = renameElements(rootEl, { + gmd: 'mdb', + 'gmd:PT_Locale': 'lan:PT_Locale', + 'gmd:languageCode': 'lan:languageCode', + 'gmd:LanguageCode': 'lan:LanguageCode', + 'gmd:characterEncoding': 'lan:characterEncoding', + 'gmd:MD_CharacterSetCode': 'lan:MD_CharacterSetCode', + 'gmd:MD_DataIdentification': 'mri:MD_DataIdentification', + 'gmd:citation': 'mri:citation', + 'gmd:CI_Citation': 'cit:CI_Citation', + 'gmd:title': 'cit:title', + }) + }) + + it('renames elements according to given map', () => { + expect(xmlToString(rootEl)).toEqual(` + + + + + + + + + + + + + + + + + Alpenkonvention + + + Alpenkonvention + + + Convention des Alpes + + + Convenzione delle alpi + + + Alpine Convention + + + Convenziun da las Alps + + + + + + + + +`) + }) + }) }) diff --git a/libs/api/metadata-converter/src/lib/xml-utils.ts b/libs/api/metadata-converter/src/lib/xml-utils.ts index 21bd2dd11b..cd0673a4ef 100644 --- a/libs/api/metadata-converter/src/lib/xml-utils.ts +++ b/libs/api/metadata-converter/src/lib/xml-utils.ts @@ -33,6 +33,7 @@ export function createDocument(rootEl: XmlElement): XmlDocument { function collectNamespaceFromName(name: string) { const namespace = extractNamespace(name) if (namespace === 'xmlns' || namespace === null) return + if (rootEl.attributes[`xmlns:${namespace}`]) return if (!NAMESPACES[namespace]) { throw new Error(`No known URI for namespace ${namespace}`) } @@ -55,11 +56,16 @@ export function createDocument(rootEl: XmlElement): XmlDocument { /** * Will do nothing if no namespace present */ -function stripNamespace(name: string): string { +export function stripNamespace(name: string): string { const colon = name.indexOf(':') return colon > -1 ? name.substring(colon + 1) : name } +export function getNamespace(name: string): string { + const colon = name.indexOf(':') + return colon > -1 ? name.substring(0, colon) : '' +} + function getElementName(element: XmlElement): string { return element.name || '' } @@ -115,7 +121,7 @@ export function allChildrenElement(element: XmlElement): Array { * returns an empty array if no matching element */ export function findNestedElements( - ...elementNames + ...elementNames: string[] ): ChainableFunction> { return (el) => { function lookFor(elNameIndex: number) { @@ -248,6 +254,29 @@ const NAMESPACES = { gsr: 'http://www.isotc211.org/2005/gsr', gmi: 'http://www.isotc211.org/2005/gmi', xlink: 'http://www.w3.org/1999/xlink', + mdb: 'http://standards.iso.org/iso/19115/-3/mdb/2.0', + mdq: 'http://standards.iso.org/iso/19157/-2/mdq/1.0', + msr: 'http://standards.iso.org/iso/19115/-3/msr/2.0', + mrs: 'http://standards.iso.org/iso/19115/-3/mrs/1.0', + mmi: 'http://standards.iso.org/iso/19115/-3/mmi/1.0', + mrl: 'http://standards.iso.org/iso/19115/-3/mrl/2.0', + mdt: 'http://standards.iso.org/iso/19115/-3/mdt/2.0', + mrd: 'http://standards.iso.org/iso/19115/-3/mrd/1.0', + mds: 'http://standards.iso.org/iso/19115/-3/mds/2.0', + mpc: 'http://standards.iso.org/iso/19115/-3/mpc/1.0', + mcc: 'http://standards.iso.org/iso/19115/-3/mcc/1.0', + mac: 'http://standards.iso.org/iso/19115/-3/mac/2.0', + mco: 'http://standards.iso.org/iso/19115/-3/mco/1.0', + mda: 'http://standards.iso.org/iso/19115/-3/mda/1.0', + mex: 'http://standards.iso.org/iso/19115/-3/mex/1.0', + gex: 'http://standards.iso.org/iso/19115/-3/gex/1.0', + gcx: 'http://standards.iso.org/iso/19115/-3/gcx/1.0', + mas: 'http://standards.iso.org/iso/19115/-3/mas/1.0', + mri: 'http://standards.iso.org/iso/19115/-3/mri/1.0', + cit: 'http://standards.iso.org/iso/19115/-3/cit/2.0', + cat: 'http://standards.iso.org/iso/19115/-3/cat/1.0', + lan: 'http://standards.iso.org/iso/19115/-3/lan/1.0', + mrc: 'http://standards.iso.org/iso/19115/-3/mrc/2.0', } /** @@ -268,6 +297,13 @@ export function addAttribute( return element } } +function getTreeRoot(element: XmlElement): XmlElement { + let root = element + while (root.parent instanceof XmlElement) { + root = root.parent + } + return root +} // stays on the parent element // if the given elements are part of a subtree, will add the root of subtree @@ -276,22 +312,26 @@ export function appendChildren( ): ChainableFunction { return (element) => { if (!element) return null - element.children.push( - ...childrenFns - .map((fn) => fn()) - .map((el) => { - let root = el - while (root.parent instanceof XmlElement) { - root = root.parent - } - return root - }) - ) + element.children.push(...childrenFns.map((fn) => fn()).map(getTreeRoot)) element.children.forEach((el) => (el.parent = element)) return element } } +// switch to the tip of the subtree +export function appendChildTree( + childrenFn: ChainableFunction +): ChainableFunction { + return (element) => { + if (!element) return null + const treeTip = childrenFn() + const treeRoot = getTreeRoot(treeTip) + element.children.push(treeRoot) + treeRoot.parent = element + return treeTip + } +} + // switches to the child element export function createChild( childName: string @@ -349,6 +389,7 @@ export function removeAllChildren(): ChainableFunction { } } +// stays on the same element export function removeChildrenByName( name: string ): ChainableFunction { @@ -372,11 +413,38 @@ export function removeChildren( childrenFn: ChainableFunction> ): ChainableFunction { return (element) => { - const children = childrenFn(element) - children.forEach((child) => (child.parent = null)) + const childrenToRemove = childrenFn(element) + childrenToRemove.forEach((child) => (child.parent = null)) element.children = element.children.filter( - (child) => child instanceof XmlElement && children.indexOf(child) === -1 + (child) => + child instanceof XmlElement && childrenToRemove.indexOf(child) === -1 ) return element } } + +/** + * Renames elements in the XML tree according to the map + * Either specify a full element name like 'gmd:MD_Metadata' or simply a namespace like 'gmd' + * @param rootElement + * @param replaceMap + */ +export function renameElements( + rootElement: XmlElement, + replaceMap: Record +) { + function doReplace(element: XmlElement) { + if (element.name in replaceMap) { + element.name = replaceMap[element.name] + } else if (element.name && getNamespace(element.name) in replaceMap) { + element.name = `${ + replaceMap[getNamespace(element.name)] + }:${stripNamespace(element.name)}` + } + if (element.children) { + element.children.forEach(doReplace) + } + } + doReplace(rootElement) + return rootElement +} diff --git a/libs/api/repository/src/lib/gn4/gn4-repository.spec.ts b/libs/api/repository/src/lib/gn4/gn4-repository.spec.ts index 5bdc63a6e8..bbea77b453 100644 --- a/libs/api/repository/src/lib/gn4/gn4-repository.spec.ts +++ b/libs/api/repository/src/lib/gn4/gn4-repository.spec.ts @@ -5,7 +5,7 @@ import { ElasticsearchService } from './elasticsearch' import { TestBed } from '@angular/core/testing' import { EsSearchResponse, - Gn4MetadataMapper, + Gn4Converter, } from '@geonetwork-ui/api/metadata-converter' import { Aggregations, @@ -74,7 +74,7 @@ describe('Gn4Repository', () => { useClass: SearchApiServiceMock, }, { - provide: Gn4MetadataMapper, + provide: Gn4Converter, useClass: Gn4MetadataMapperMock, }, ], diff --git a/libs/api/repository/src/lib/gn4/gn4-repository.ts b/libs/api/repository/src/lib/gn4/gn4-repository.ts index 090c5d5fb6..a31a61aa81 100644 --- a/libs/api/repository/src/lib/gn4/gn4-repository.ts +++ b/libs/api/repository/src/lib/gn4/gn4-repository.ts @@ -14,7 +14,7 @@ import { } from '@geonetwork-ui/common/domain/model/search' import { map } from 'rxjs/operators' import { - Gn4MetadataMapper, + Gn4Converter, Gn4SearchResults, } from '@geonetwork-ui/api/metadata-converter' import { CatalogRecord } from '@geonetwork-ui/common/domain/model/record' @@ -24,7 +24,7 @@ export class Gn4Repository implements RecordsRepositoryInterface { constructor( private gn4SearchApi: SearchApiService, private gn4SearchHelper: ElasticsearchService, - private gn4Mapper: Gn4MetadataMapper + private gn4Mapper: Gn4Converter ) {} search({ diff --git a/libs/common/domain/src/lib/model/record/metadata.model.ts b/libs/common/domain/src/lib/model/record/metadata.model.ts index 182b65a3bb..4ae3267465 100644 --- a/libs/common/domain/src/lib/model/record/metadata.model.ts +++ b/libs/common/domain/src/lib/model/record/metadata.model.ts @@ -83,8 +83,8 @@ export interface BaseRecord { title: string abstract: string recordCreated?: Date - recordUpdated: Date recordPublished?: Date + recordUpdated: Date languages: Array kind: RecordKind topics: Array // TODO: handle codelists @@ -98,6 +98,12 @@ export interface BaseRecord { landingPage?: URL updateFrequency?: UpdateFrequency + // information related to the resource (dataset, service) + contactsForResource: Array + resourceCreated?: Date + resourcePublished?: Date + resourceUpdated?: Date + // to add: canonical url // to add: source catalog (??) // to add: is open data ? @@ -173,10 +179,7 @@ export interface DatasetTemporalExtent { export interface DatasetRecord extends BaseRecord { kind: 'dataset' - contactsForResource: Array status: RecordStatus - datasetCreated?: Date - datasetUpdated?: Date lineage: string // Explanation of the origin of this record (e.g: how, why)" distributions: Array spatialExtents: Array @@ -203,3 +206,5 @@ export interface ServiceRecord extends BaseRecord { } export type CatalogRecord = ServiceRecord | DatasetRecord + +export type CatalogRecordKeys = keyof ServiceRecord | keyof DatasetRecord diff --git a/libs/feature/editor/src/lib/services/editor.service.spec.ts b/libs/feature/editor/src/lib/services/editor.service.spec.ts index d8cf3aed6f..7ac6c6c2cc 100644 --- a/libs/feature/editor/src/lib/services/editor.service.spec.ts +++ b/libs/feature/editor/src/lib/services/editor.service.spec.ts @@ -6,7 +6,6 @@ import { } from '@angular/common/http/testing' import { CatalogRecord } from '@geonetwork-ui/common/domain/model/record' import { DEFAULT_FIELDS } from '../fields.config' -import { firstValueFrom } from 'rxjs' import { DATASET_RECORDS } from '@geonetwork-ui/common/fixtures' const SAMPLE_RECORD: CatalogRecord = DATASET_RECORDS[0] diff --git a/libs/feature/editor/src/lib/services/editor.service.ts b/libs/feature/editor/src/lib/services/editor.service.ts index c2920a3c96..f3f57481c5 100644 --- a/libs/feature/editor/src/lib/services/editor.service.ts +++ b/libs/feature/editor/src/lib/services/editor.service.ts @@ -1,8 +1,11 @@ import { Inject, Injectable, Optional } from '@angular/core' -import { toModel, toXml } from '@geonetwork-ui/api/metadata-converter' +import { + findConverterForDocument, + Iso19139Converter, +} from '@geonetwork-ui/api/metadata-converter' import { Configuration } from '@geonetwork-ui/data-access/gn4' -import { Observable } from 'rxjs' -import { map } from 'rxjs/operators' +import { from, Observable } from 'rxjs' +import { map, switchMap } from 'rxjs/operators' import { HttpClient } from '@angular/common/http' import { CatalogRecord } from '@geonetwork-ui/common/domain/model/record' import { EditorFieldsConfig } from '../models/fields.model' @@ -30,7 +33,11 @@ export class EditorService { Accept: 'application/xml', }, }) - .pipe(map((response) => toModel(response.toString()))) + .pipe( + switchMap((response) => + findConverterForDocument(response).readRecord(response.toString()) + ) + ) } // returns the record as it was when saved @@ -52,17 +59,21 @@ export class EditorService { } // TODO: use the catalog repository instead - return this.http - .put( - `${this.apiUrl}/records?metadataType=METADATA&uuidProcessing=OVERWRITE&transformWith=_none_&publishToAll=on`, - toXml(savedRecord), - { - headers: { - 'Content-Type': 'application/xml', - }, - withCredentials: true, - } - ) - .pipe(map(() => savedRecord)) + // TODO: use converter based on the format of the record before change + return from(new Iso19139Converter().writeRecord(savedRecord)).pipe( + switchMap((recordXml) => + this.http.put( + `${this.apiUrl}/records?metadataType=METADATA&uuidProcessing=OVERWRITE&transformWith=_none_&publishToAll=on`, + recordXml, + { + headers: { + 'Content-Type': 'application/xml', + }, + withCredentials: true, + } + ) + ), + map(() => savedRecord) + ) } }