diff --git a/README.md b/README.md index fd954ad69..e35c44bbe 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,4 @@ + ![Workflow status](https://github.com/geonetwork/geonetwork-ui/actions/workflows/checks.yml/badge.svg)    ![Workflow status](https://github.com/geonetwork/geonetwork-ui/actions/workflows/snyk-security.yml/badge.svg)    ![Workflow status](https://github.com/geonetwork/geonetwork-ui/actions/workflows/artifacts.yml/badge.svg)    [![Coverage Status](https://coveralls.io/repos/github/geonetwork/geonetwork-ui/badge.svg?branch=main)](https://coveralls.io/github/geonetwork/geonetwork-ui?branch=main) # GeoNetwork UI diff --git a/apps/webcomponents/src/app/components/gn-metadata-quality/gn-metadata-quality.component.css b/apps/webcomponents/src/app/components/gn-metadata-quality/gn-metadata-quality.component.css new file mode 100644 index 000000000..9f65673d3 --- /dev/null +++ b/apps/webcomponents/src/app/components/gn-metadata-quality/gn-metadata-quality.component.css @@ -0,0 +1,5 @@ +@import '../../../styles.css'; + +:host { + display: block; +} diff --git a/apps/webcomponents/src/app/components/gn-metadata-quality/gn-metadata-quality.component.html b/apps/webcomponents/src/app/components/gn-metadata-quality/gn-metadata-quality.component.html new file mode 100644 index 000000000..db9313b01 --- /dev/null +++ b/apps/webcomponents/src/app/components/gn-metadata-quality/gn-metadata-quality.component.html @@ -0,0 +1,8 @@ + diff --git a/apps/webcomponents/src/app/components/gn-metadata-quality/gn-metadata-quality.component.ts b/apps/webcomponents/src/app/components/gn-metadata-quality/gn-metadata-quality.component.ts new file mode 100644 index 000000000..6dc64c54a --- /dev/null +++ b/apps/webcomponents/src/app/components/gn-metadata-quality/gn-metadata-quality.component.ts @@ -0,0 +1,90 @@ +import { + ChangeDetectionStrategy, + Component, + Input, + ViewEncapsulation, +} from '@angular/core' +import { BaseComponent, DefaultProviders } from '../base.component' +import { CatalogRecord } from '@geonetwork-ui/common/domain/model/record' +import { ValidatorMapperKeys } from '@geonetwork-ui/util/shared' + +@Component({ + selector: 'wc-gn-metadata-quality', + templateUrl: './gn-metadata-quality.component.html', + styleUrls: ['./gn-metadata-quality.component.css'], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.ShadowDom, + providers: [DefaultProviders], + standalone: false, +}) +export class GnMetadataQualityComponent extends BaseComponent { + @Input() metadata: CatalogRecord | string // Accept both object and JSON string + @Input() smaller = false + @Input() metadataQualityDisplay = true + @Input() popoverDisplay = true + @Input() forceComputeScore = false + + private _propsToValidate?: ValidatorMapperKeys[] | string + public parsedPropsToValidate?: ValidatorMapperKeys[] + + @Input() + set propsToValidate(value: ValidatorMapperKeys[] | string | undefined) { + this._propsToValidate = value + if (!value) { + this.parsedPropsToValidate = undefined + return + } + if (typeof value === 'string') { + try { + const parsed = JSON.parse(value) + this.parsedPropsToValidate = Array.isArray(parsed) ? parsed : undefined + } catch (e) { + console.warn('Failed to parse propsToValidate JSON:', e) + this.parsedPropsToValidate = undefined + } + } else if (Array.isArray(value)) { + this.parsedPropsToValidate = value + } else { + this.parsedPropsToValidate = undefined + } + } + get propsToValidate() { + return this._propsToValidate + } + + get parsedMetadata(): CatalogRecord | null { + if (!this.metadata) return null + if (typeof this.metadata === 'string') { + try { + return JSON.parse(this.metadata) + } catch (e) { + console.error('Failed to parse metadata JSON:', e) + return null + } + } + return this.metadata + } + + get parsedForceComputeScore(): boolean { + if (typeof this.forceComputeScore === 'boolean') return this.forceComputeScore + return this.forceComputeScore === 'true' || this.forceComputeScore === true + } + + get parsedSmaller(): boolean { + if (typeof this.smaller === 'boolean') return this.smaller + return this.smaller === 'true' || this.smaller === true + } + + get parsedMetadataQualityDisplay(): boolean { + if (typeof this.metadataQualityDisplay === 'boolean') + return this.metadataQualityDisplay + return ( + this.metadataQualityDisplay === 'true' || this.metadataQualityDisplay === true + ) + } + + get parsedPopoverDisplay(): boolean { + if (typeof this.popoverDisplay === 'boolean') return this.popoverDisplay + return this.popoverDisplay === 'true' || this.popoverDisplay === true + } +} diff --git a/apps/webcomponents/src/app/components/gn-metadata-quality/gn-metadata-quality.sample.html b/apps/webcomponents/src/app/components/gn-metadata-quality/gn-metadata-quality.sample.html new file mode 100644 index 000000000..22f7a0b6d --- /dev/null +++ b/apps/webcomponents/src/app/components/gn-metadata-quality/gn-metadata-quality.sample.html @@ -0,0 +1,15 @@ + + + diff --git a/apps/webcomponents/src/app/webcomponents.module.ts b/apps/webcomponents/src/app/webcomponents.module.ts index c4f97ab74..475471493 100644 --- a/apps/webcomponents/src/app/webcomponents.module.ts +++ b/apps/webcomponents/src/app/webcomponents.module.ts @@ -44,6 +44,7 @@ import { GnFigureDatasetsComponent } from './components/gn-figure-datasets/gn-fi import { GnMapViewerComponent } from './components/gn-map-viewer/gn-map-viewer.component' import { GnResultsListComponent } from './components/gn-results-list/gn-results-list.component' import { GnSearchInputComponent } from './components/gn-search-input/gn-search-input.component' +import { GnMetadataQualityComponent } from './components/gn-metadata-quality/gn-metadata-quality.component' import { standaloneConfigurationObject } from './configuration' import { StandaloneSearchModule } from './standalone-search.module' import { BrowserModule } from '@angular/platform-browser' @@ -52,9 +53,11 @@ import { RouterModule, TitleStrategy } from '@angular/router' // eslint-disable-next-line @nx/enforce-module-boundaries import { DATAHUB_ROUTER_PROVIDERS } from '@geonetwork-ui/apps/datahub/app.providers.ts' import { NoopTitleStrategy } from './noop-title-strategy.service' +import { MetadataQualityComponent } from '@geonetwork-ui/ui/elements' +type WebComponentConstructor = new (...args: any[]) => BaseComponent | GnDatahubComponent | MetadataQualityComponent; const CUSTOM_ELEMENTS: [ - new (...args) => BaseComponent | GnDatahubComponent, + WebComponentConstructor, string, ][] = [ [GnFacetsComponent, 'gn-facets'], @@ -66,6 +69,8 @@ const CUSTOM_ELEMENTS: [ [GnMapViewerComponent, 'gn-map-viewer'], [GnFigureDatasetsComponent, 'gn-figure-datasets'], [GnDatasetViewMapComponent, 'gn-dataset-view-map'], + [GnMetadataQualityComponent, 'gn-metadata-quality'], + [MetadataQualityComponent, 'gn-ui-metadata-quality'], [GnDatahubComponent, 'gn-datahub'], ] @@ -81,6 +86,7 @@ const CUSTOM_ELEMENTS: [ GnMapViewerComponent, GnFigureDatasetsComponent, GnDatasetViewMapComponent, + GnMetadataQualityComponent, ], imports: [ BrowserModule, @@ -111,6 +117,7 @@ const CUSTOM_ELEMENTS: [ }, } ), + MetadataQualityComponent, ], providers: [ importProvidersFrom( diff --git a/libs/api/metadata-converter/src/lib/dcat-ap/dcat-ap.converter.ts b/libs/api/metadata-converter/src/lib/dcat-ap/dcat-ap.converter.ts index ad19784cc..5ade90296 100644 --- a/libs/api/metadata-converter/src/lib/dcat-ap/dcat-ap.converter.ts +++ b/libs/api/metadata-converter/src/lib/dcat-ap/dcat-ap.converter.ts @@ -56,6 +56,7 @@ export class DcatApConverter extends BaseConverter { spatialExtents: readSpatialExtents, keywords: readKeywords, topics: readTopics, + subtopics: () => undefined, // specific to eCH-0271, not supported in DCAT-AP resourceIdentifiers: () => undefined, recordUpdated: readRecordUpdated, recordCreated: readRecordCreated, @@ -105,6 +106,7 @@ export class DcatApConverter extends BaseConverter { contactsForResource: () => undefined, keywords: () => undefined, topics: () => undefined, + subtopics: () => undefined, licenses: () => undefined, legalConstraints: () => undefined, securityConstraints: () => undefined, diff --git a/libs/api/metadata-converter/src/lib/gn4/gn4.field.mapper.spec.ts b/libs/api/metadata-converter/src/lib/gn4/gn4.field.mapper.spec.ts index 907ccd0dd..728ad811d 100644 --- a/libs/api/metadata-converter/src/lib/gn4/gn4.field.mapper.spec.ts +++ b/libs/api/metadata-converter/src/lib/gn4/gn4.field.mapper.spec.ts @@ -137,6 +137,9 @@ describe('Gn4FieldMapper', () => { const result = mappingFn(output, source) expect(result).toEqual({ title: 'Default title', + extras: { + resourceTitleObject: { default: 'Default title', langfre: 'French title' }, + }, }) }) it('resourceAbstractObject - should return a function that correctly maps the field to fre lang', () => { @@ -153,6 +156,27 @@ describe('Gn4FieldMapper', () => { const result = mappingFn(output, source) expect(result).toEqual({ abstract: 'French abstract', + extras: { + resourceAbstractObject: { default: 'Default abstract', langfre: 'French abstract' }, + }, + }) + }) + it('resourceAltTitleObject - should store alt title array in extras', () => { + const fieldName = 'resourceAltTitleObject' + const mappingFn = service.getMappingFn(fieldName) + const output = {} + const source = { + resourceAltTitleObject: [ + { langfre: 'Titre alternatif FR', langger: 'Alternativer Titel DE' }, + ], + } + const result = mappingFn(output, source) + expect(result).toEqual({ + extras: { + resourceAltTitleObject: [ + { langfre: 'Titre alternatif FR', langger: 'Alternativer Titel DE' }, + ], + }, }) }) it('overview - should return a function that correctly maps the field', () => { @@ -194,7 +218,43 @@ describe('Gn4FieldMapper', () => { }, } const result = mappingFn(output, source) - expect(result).toEqual({ status: 'completed' }) + expect(result).toEqual({ status: 'completed', extras: { cl_statusObject: { key: 'completed', default: 'Finalisé', langfre: 'Finalisé', link: 'http://standards.iso.org/iso/19139/resources/gmxCodelists.xml#MD_ProgressCode' } } }) + }) + it('cl_subTopicCategory - should return a function that maps subtopics', () => { + translateService.currentLang = 'de' + const fieldName = 'cl_subTopicCategory' + const mappingFn = service.getMappingFn(fieldName) + const output = {} + const source = { + cl_subTopicCategory: [ + { default: 'Subtopic 1', langger: 'Unterthema 1' }, + { default: 'Subtopic 2', langger: 'Unterthema 2' }, + ], + } + const result = mappingFn(output, source) + expect(result).toEqual({ + subtopics: ['Unterthema 1', 'Unterthema 2'], + }) + }) + it('MD_LegalConstraintsOtherConstraintsObject - should store in extras and add to legalConstraints', () => { + translateService.currentLang = 'de' + const fieldName = 'MD_LegalConstraintsOtherConstraintsObject' + const mappingFn = service.getMappingFn(fieldName) + const output = {} + const source = { + MD_LegalConstraintsOtherConstraintsObject: [ + { default: 'CC-BY', langger: 'CC-BY' }, + ], + } + const result = mappingFn(output, source) + expect(result).toEqual({ + legalConstraints: [{ text: 'CC-BY' }], + extras: { + MD_LegalConstraintsOtherConstraintsObject: [ + { default: 'CC-BY', langger: 'CC-BY' }, + ], + }, + }) }) it('isHarvested - should return a function that correctly maps the field', () => { const fieldName = 'isHarvested' @@ -216,6 +276,60 @@ describe('Gn4FieldMapper', () => { const result = mappingFn(output, source) expect(result).toEqual({ extras: { edit: true } }) }) + it('linkProtocol - should return a function that stores protocols in extras', () => { + const fieldName = 'linkProtocol' + const mappingFn = service.getMappingFn(fieldName) + const output = {} + const source = { + linkProtocol: ['MAP:Preview', 'OGC:WMS', 'WWW:DOWNLOAD-URL'], + } + const result = mappingFn(output, source) + expect(result).toEqual({ + extras: { + linkProtocol: ['MAP:Preview', 'OGC:WMS', 'WWW:DOWNLOAD-URL'], + }, + }) + }) + it('format - should return a function that stores formats in extras', () => { + const fieldName = 'format' + const mappingFn = service.getMappingFn(fieldName) + const output = {} + const source = { + format: ['ESRI Shapefile (SHP)', 'GeoJSON'], + } + const result = mappingFn(output, source) + expect(result).toEqual({ + extras: { + format: ['ESRI Shapefile (SHP)', 'GeoJSON'], + }, + }) + }) + it('featureTypes - should return a function that stores featureTypes in extras', () => { + const fieldName = 'featureTypes' + const mappingFn = service.getMappingFn(fieldName) + const output = {} + const source = { + featureTypes: [ + { + typeName: 'Bornes parcellaires', + definition: '', + attributeTable: [{ name: 'ID', type: 'string' }], + }, + ], + } + const result = mappingFn(output, source) + expect(result).toEqual({ + extras: { + featureTypes: [ + { + typeName: 'Bornes parcellaires', + definition: '', + attributeTable: [{ name: 'ID', type: 'string' }], + }, + ], + }, + }) + }) it('languages - should return a list of languages even with unsupported ones and without defaultLang', () => { const fieldName = 'otherLanguage' const mappingFn = service.getMappingFn(fieldName) 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 9beeffcc1..a568c3d96 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 @@ -65,28 +65,53 @@ export class Gn4FieldMapper { { qualityScore: selectField(source, 'qualityScore') }, output ), - resourceTitleObject: (output, source) => ({ - ...output, - title: selectFallback( - selectTranslatedField( - source, - 'resourceTitleObject', - this.getLocalizedIndexKey - ), - 'no title' - ), - }), - resourceAbstractObject: (output, source) => ({ - ...output, - abstract: selectFallback( - selectTranslatedField( - source, - 'resourceAbstractObject', - this.getLocalizedIndexKey - ), - 'no title' - ), - }), + resourceTitleObject: (output, source) => { + const titleObject = selectField( + source, + 'resourceTitleObject' + ) + return this.addExtra( + { resourceTitleObject: titleObject }, + { + ...output, + title: selectFallback( + selectTranslatedField( + source, + 'resourceTitleObject', + this.getLocalizedIndexKey + ), + 'no title' + ), + } + ) + }, + resourceAbstractObject: (output, source) => { + const abstractObject = selectField( + source, + 'resourceAbstractObject' + ) + return this.addExtra( + { resourceAbstractObject: abstractObject }, + { + ...output, + abstract: selectFallback( + selectTranslatedField( + source, + 'resourceAbstractObject', + this.getLocalizedIndexKey + ), + 'no abstract' + ), + } + ) + }, + resourceAltTitleObject: (output, source) => { + const altTitleObject = selectField( + source, + 'resourceAltTitleObject' + ) + return this.addExtra({ resourceAltTitleObject: altTitleObject }, output) + }, overview: (output, source) => { const firstOverview = getFirstValue(selectField(source, 'overview')) const description = selectTranslatedValue( @@ -114,12 +139,29 @@ export class Gn4FieldMapper { ), ], }), - cl_status: (output, source) => ({ + cl_subTopicCategory: (output, source) => ({ ...output, - status: getStatusFromStatusCode( - selectField(getFirstValue(selectField(source, 'cl_status')), 'key') - ), + subtopics: [ + ...(output.subtopics || []), + ...getAsArray( + selectField(source, 'cl_subTopicCategory') + ).map((subtopic) => + selectTranslatedValue(subtopic, this.getLocalizedIndexKey) + ), + ], }), + cl_status: (output, source) => { + const statusObject = getFirstValue(selectField(source, 'cl_status')) + return this.addExtra( + { cl_statusObject: statusObject }, + { + ...output, + status: getStatusFromStatusCode( + selectField(statusObject, 'key') + ), + } + ) + }, cl_maintenanceAndUpdateFrequency: (output, source) => ({ ...output, updateFrequency: getUpdateFrequencyFromFrequencyCode( @@ -205,6 +247,16 @@ export class Gn4FieldMapper { onlineResources, } as CatalogRecord }, + linkProtocol: (output, source) => + this.addExtra( + { linkProtocol: getAsArray(selectField(source, 'linkProtocol')) }, + output + ), + format: (output, source) => + this.addExtra( + { format: getAsArray(selectField(source, 'format')) }, + output + ), contact: (output, source) => ({ ...output, contacts: [ @@ -265,14 +317,20 @@ export class Gn4FieldMapper { selectField(source, 'MD_LegalConstraintsUseLimitationObject') ) ), - MD_LegalConstraintsOtherConstraintsObject: (output, source) => - this.constraintField( - 'legal', - output, - getAsArray( - selectField(source, 'MD_LegalConstraintsOtherConstraintsObject') + MD_LegalConstraintsOtherConstraintsObject: (output, source) => { + const otherConstraints = selectField( + source, + 'MD_LegalConstraintsOtherConstraintsObject' + ) + return this.addExtra( + { MD_LegalConstraintsOtherConstraintsObject: otherConstraints }, + this.constraintField( + 'legal', + output, + getAsArray(otherConstraints) ) - ), + ) + }, MD_SecurityConstraintsUseLimitationObject: (output, source) => this.constraintField( 'security', diff --git a/libs/api/metadata-converter/src/lib/iso19139/iso19139.converter.ts b/libs/api/metadata-converter/src/lib/iso19139/iso19139.converter.ts index c6270738e..8b47440a1 100644 --- a/libs/api/metadata-converter/src/lib/iso19139/iso19139.converter.ts +++ b/libs/api/metadata-converter/src/lib/iso19139/iso19139.converter.ts @@ -103,6 +103,7 @@ export class Iso19139Converter extends BaseConverter { contactsForResource: readContactsForResource, keywords: readKeywords, topics: readIsoTopics, + subtopics: () => undefined, // specific to eCH-0271, not supported in ISO19139 licenses: readLicenses, legalConstraints: readLegalConstraints, securityConstraints: readSecurityConstraints, @@ -144,6 +145,7 @@ export class Iso19139Converter extends BaseConverter { contactsForResource: writeContactsForResource, keywords: writeKeywords, topics: writeTopics, + subtopics: () => undefined, // specific to eCH-0271, not supported in ISO19139 licenses: writeLicenses, legalConstraints: writeLegalConstraints, securityConstraints: writeSecurityConstraints, diff --git a/libs/api/repository/src/lib/gn4/elasticsearch/constant.ts b/libs/api/repository/src/lib/gn4/elasticsearch/constant.ts index 875d714f7..9b99cc5d2 100644 --- a/libs/api/repository/src/lib/gn4/elasticsearch/constant.ts +++ b/libs/api/repository/src/lib/gn4/elasticsearch/constant.ts @@ -9,6 +9,8 @@ export const ES_SOURCE_SUMMARY = [ 'logo', 'link', 'linkProtocol', + 'format', + 'featureTypes', 'contactForResource*.organisation*', 'contact*.organisation*', 'contact*.email', 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 c216c80ba..6ab86ef7c 100644 --- a/libs/common/domain/src/lib/model/record/metadata.model.ts +++ b/libs/common/domain/src/lib/model/record/metadata.model.ts @@ -130,6 +130,7 @@ export interface BaseRecord { recordUpdated: Date kind: RecordKind topics: Array // TODO: handle codelists + subtopics: Array // TODO: handle codelists keywords: Array licenses: Array legalConstraints: Array @@ -278,12 +279,14 @@ export type ServiceOnlineResource = (ServiceEndpoint | OnlineLinkResource) & { export interface ServiceRecord extends BaseRecord { kind: 'service' + status: RecordStatus onlineResources: Array spatialExtents: Array } export interface ReuseRecord extends BaseRecord { kind: 'reuse' + status: RecordStatus lineage: string // Explanation of the origin of this record (e.g: how, why)" onlineResources: Array reuseType: ReuseType diff --git a/libs/ui/elements/src/lib/metadata-quality/metadata-quality.component.html b/libs/ui/elements/src/lib/metadata-quality/metadata-quality.component.html index c9a323d46..2d49b1326 100644 --- a/libs/ui/elements/src/lib/metadata-quality/metadata-quality.component.html +++ b/libs/ui/elements/src/lib/metadata-quality/metadata-quality.component.html @@ -3,12 +3,17 @@ class="flex items-center metadata-quality min-w-0" [ngClass]="{ 'text-xs leading-[8px] m-h-[120px]': smaller }" > - +
+ + {{ qualityScoreFraction }} +
@if (popoverDisplay) { value).length + const totalCount = this.items.length + return `${validCount}/${totalCount}` + } + + get qualityScoreLabel(): string { + const score = this.qualityScore + if (score <= 50) { + return this.translateService.instant('record.metadata.quality.score.insufficient') + } else if (score < 70) { + return this.translateService.instant('record.metadata.quality.score.average') + } else if (score < 90) { + return this.translateService.instant('record.metadata.quality.score.good') + } else { + return this.translateService.instant('record.metadata.quality.score.excellent') + } + } + + get qualityScoreColor(): string { + const score = this.qualityScore + if (score <= 50) { + return 'bg-red-500' + } else if (score < 70) { + return 'bg-yellow-500' + } else if (score < 90) { + return 'bg-lime-500' + } else { + return 'bg-green-500' + } + } + initialize() { if (!this.propsToValidate) { this.propsToValidate = getAllKeysValidator() diff --git a/libs/ui/widgets/src/lib/progress-bar/progress-bar.component.html b/libs/ui/widgets/src/lib/progress-bar/progress-bar.component.html index 6cfd35cce..6a19076c4 100644 --- a/libs/ui/widgets/src/lib/progress-bar/progress-bar.component.html +++ b/libs/ui/widgets/src/lib/progress-bar/progress-bar.component.html @@ -7,7 +7,11 @@ text-opacity-100 !text-slate-800" data-cy="progressPercentage" > - {{ progress }}% + @if (customLabel) { + {{ customLabel }} + } @else { + {{ progress }}% + }
0 ? (this.value < 100 ? this.value : 100) : 0 } get color(): ColorScheme { - switch (this.type) { - case 'default': - return { - outerBar: 'bg-gray-200', - innerBar: 'bg-gray-100', - text: 'text-gray-900', - } - case 'primary': - return { - outerBar: 'bg-primary', - innerBar: 'bg-primary-lighter', - text: 'text-white', - } - case 'secondary': - return { - outerBar: 'bg-secondary', - innerBar: 'bg-secondary-lighter', - text: 'text-white', - } - case 'light': - return { - outerBar: 'bg-primary-white', - innerBar: 'bg-primary', - text: 'text-main', - } + const baseColor = (() => { + switch (this.type) { + case 'default': + return { + outerBar: 'bg-gray-200', + innerBar: 'bg-gray-100', + text: 'text-gray-900', + } + case 'primary': + return { + outerBar: 'bg-primary', + innerBar: 'bg-primary-lighter', + text: 'text-white', + } + case 'secondary': + return { + outerBar: 'bg-secondary', + innerBar: 'bg-secondary-lighter', + text: 'text-white', + } + case 'light': + return { + outerBar: 'bg-primary-white', + innerBar: 'bg-primary', + text: 'text-main', + } + } + })() + + // Override innerBar color if barColor is provided + if (this.barColor) { + return { + ...baseColor, + innerBar: this.barColor, + } } + + return baseColor } } diff --git a/libs/util/shared/src/lib/record/quality-score.util.spec.ts b/libs/util/shared/src/lib/record/quality-score.util.spec.ts index da70418e9..acf9e663b 100644 --- a/libs/util/shared/src/lib/record/quality-score.util.spec.ts +++ b/libs/util/shared/src/lib/record/quality-score.util.spec.ts @@ -1,4 +1,4 @@ -import { CatalogRecord } from '@geonetwork-ui/common/domain/model/record' +import { CatalogRecord, DatasetDownloadDistribution, DatasetServiceDistribution, Individual, Keyword } from '@geonetwork-ui/common/domain/model/record' import { ValidatorMapperKeys, @@ -11,23 +11,65 @@ describe('Metadata Validators', () => { kind: 'dataset', title: 'Test title', abstract: 'Test abstract', - keywords: ['keyword1', 'keyword2'], - legalConstraints: [{ type: 'license', text: 'MIT' }], + keywords: [{ label: 'keyword1', type: 'other' }, { label: 'keyword2', type: 'other' }] as Keyword[], + legalConstraints: [{ text: 'MIT' }], contacts: [ { email: 'test@example.com', organization: { name: 'Test Org' }, + role: 'point_of_contact', + firstName: '', + lastName: '', }, - ], + ] as Individual[], + contactsForResource: [ + { + role: 'point_of_contact', + email: 'contact@example.com', + organization: { name: 'Test Org' }, + firstName: '', + lastName: '', + }, + { + role: 'owner', + email: 'owner@example.com', + organization: { name: 'Test Org' }, + firstName: '', + lastName: '', + }, + ] as Individual[], updateFrequency: 'daily', topics: ['environment'], + subtopics: ['subtopic1'], onlineResources: [ { - url: { href: 'http://example.com/capabilities' }, - }, + type: 'download', + url: new URL('http://example.com/data.zip'), + } as DatasetDownloadDistribution, + { + type: 'service', + url: new URL('http://example.com/wms'), + accessServiceProtocol: 'wms', + } as DatasetServiceDistribution, ], extras: { sourcesIdentifiers: '12345', + resourceTitleObject: { langfre: 'Titre français', langger: 'Deutscher Titel' }, + resourceAltTitleObject: [{ langfre: 'Alt titre FR', langger: 'Alt Titel DE' }], + resourceAbstractObject: { + langfre: 'Résumé français', + langger: 'Deutsche Zusammenfassung', + }, + MD_LegalConstraintsOtherConstraintsObject: [{ default: 'CC-BY' }], + cl_statusObject: { key: 'completed' }, + linkProtocol: ['MAP:Preview', 'OGC:WMS'], + featureTypes: [ + { + typeName: 'Feature', + attributeTable: [{ name: 'ID', type: 'string' }], + }, + ], + format: ['ESRI Shapefile (SHP)'], }, } @@ -38,8 +80,10 @@ describe('Metadata Validators', () => { keywords: [], legalConstraints: [], contacts: [], + contactsForResource: [], updateFrequency: undefined, topics: [], + subtopics: [], onlineResources: [], extras: {}, } @@ -49,162 +93,151 @@ describe('Metadata Validators', () => { const result = getAllKeysValidator() expect(result).toEqual([ 'title', + 'titleMultilingual', + 'altTitleMultilingual', 'abstract', + 'abstractMultilingual', 'keywords', 'legalConstraints', + 'legalConstraintsOtherConstraints', 'contacts', + 'contactsPointOfContactEmail', + 'contactsForResourceWithOwner', 'updateFrequency', 'topics', + 'subtopics', 'organisation', 'source', + 'status', + 'linkDownload', + 'linkService', + 'linkMapPreview', + 'featureCatalog', + 'resourceFormat', ]) expect(result.every((key) => typeof key === 'string')).toBe(true) }) }) describe('getQualityValidators', () => { - const propsToValidate: ValidatorMapperKeys[] = [ - 'title', - 'abstract', - 'keywords', - 'legalConstraints', - 'contacts', - 'updateFrequency', - 'topics', - 'organisation', - 'source', - ] - - it('should filter and return only validators applicable to record kind "dataset" with correct validation results', () => { - const result = getQualityValidators( - { ...mockRecord, kind: 'dataset' }, - propsToValidate - ) - - expect(result.length).toBe(8) - expect(result[0].name).toBe('title') - expect(result[1].name).toBe('abstract') - expect(result[2].name).toBe('keywords') - expect(result[3].name).toBe('legalConstraints') - expect(result[4].name).toBe('contacts') - expect(result[5].name).toBe('updateFrequency') - expect(result[6].name).toBe('topics') - expect(result[7].name).toBe('organisation') - expect(result[0].validator()).toBe(true) - expect(result[1].validator()).toBe(true) - expect(result[2].validator()).toBe(true) - expect(result[3].validator()).toBe(true) - expect(result[4].validator()).toBe(true) - expect(result[5].validator()).toBe(true) - expect(result[6].validator()).toBe(true) - expect(result[7].validator()).toBe(true) - - const resultFailedValidation = getQualityValidators( - { ...mockRecordInvalid, kind: 'dataset' }, - propsToValidate - ) - - expect(resultFailedValidation.length).toBe(8) - expect(resultFailedValidation[0].name).toBe('title') - expect(resultFailedValidation[1].name).toBe('abstract') - expect(resultFailedValidation[2].name).toBe('keywords') - expect(resultFailedValidation[3].name).toBe('legalConstraints') - expect(resultFailedValidation[4].name).toBe('contacts') - expect(resultFailedValidation[5].name).toBe('updateFrequency') - expect(resultFailedValidation[6].name).toBe('topics') - expect(resultFailedValidation[7].name).toBe('organisation') - expect(resultFailedValidation[0].validator()).toBe(false) - expect(resultFailedValidation[1].validator()).toBe(false) - expect(resultFailedValidation[2].validator()).toBe(false) - expect(resultFailedValidation[3].validator()).toBe(false) - expect(resultFailedValidation[4].validator()).toBe(false) - expect(resultFailedValidation[5].validator()).toBe(false) - expect(resultFailedValidation[6].validator()).toBe(false) - expect(resultFailedValidation[7].validator()).toBe(false) + describe('for kind "dataset"', () => { + const propsToValidate: ValidatorMapperKeys[] = [ + 'titleMultilingual', + 'altTitleMultilingual', + 'abstractMultilingual', + 'keywords', + 'legalConstraintsOtherConstraints', + 'contactsPointOfContactEmail', + 'contactsForResourceWithOwner', + 'status', + 'linkDownload', + 'linkService', + 'linkMapPreview', + 'subtopics', + 'organisation', + 'featureCatalog', + 'resourceFormat', + ] + + it('should return all applicable validators passing with valid record', () => { + const result = getQualityValidators( + { ...mockRecord, kind: 'dataset' }, + propsToValidate + ) + expect(result.length).toBe(15) + expect(result.map((v) => v.name)).toEqual(propsToValidate) + result.forEach((v) => expect(v.validator()).toBe(true)) + }) + + it('should return all applicable validators failing with invalid record', () => { + const result = getQualityValidators( + { ...mockRecordInvalid, kind: 'dataset' }, + propsToValidate + ) + expect(result.length).toBe(15) + result.forEach((v) => expect(v.validator()).toBe(false)) + }) }) - it('should filter and return only validators applicable to record kind "reuse" with correct validation results', () => { - const result = getQualityValidators( - { ...mockRecord, kind: 'reuse' }, - propsToValidate - ) - - expect(result.length).toBe(8) - expect(result[0].name).toBe('title') - expect(result[1].name).toBe('abstract') - expect(result[2].name).toBe('keywords') - expect(result[3].name).toBe('legalConstraints') - expect(result[4].name).toBe('contacts') - expect(result[5].name).toBe('topics') - expect(result[6].name).toBe('organisation') - expect(result[7].name).toBe('source') - expect(result[0].validator()).toBe(true) - expect(result[1].validator()).toBe(true) - expect(result[2].validator()).toBe(true) - expect(result[3].validator()).toBe(true) - expect(result[4].validator()).toBe(true) - expect(result[5].validator()).toBe(true) - expect(result[6].validator()).toBe(true) - expect(result[7].validator()).toBe(true) - - const resultFailedValidation = getQualityValidators( - { ...mockRecordInvalid, kind: 'reuse' }, - propsToValidate - ) + describe('for kind "reuse"', () => { + const propsToValidate: ValidatorMapperKeys[] = [ + 'titleMultilingual', + 'altTitleMultilingual', + 'abstractMultilingual', + 'keywords', + 'legalConstraintsOtherConstraints', + 'contactsPointOfContactEmail', + 'contactsForResourceWithOwner', + 'status', + 'linkDownload', + 'linkService', + 'linkMapPreview', + 'subtopics', + 'organisation', + 'source', + ] + + it('should return all applicable validators passing with valid record', () => { + const result = getQualityValidators( + { ...mockRecord, kind: 'reuse' }, + propsToValidate + ) + expect(result.length).toBe(14) + expect(result.map((v) => v.name)).toEqual(propsToValidate) + result.forEach((v) => expect(v.validator()).toBe(true)) + }) + + it('should return all applicable validators failing with invalid record', () => { + const result = getQualityValidators( + { ...mockRecordInvalid, kind: 'reuse' }, + propsToValidate + ) + expect(result.length).toBe(14) + result.forEach((v) => expect(v.validator()).toBe(false)) + }) + }) - expect(resultFailedValidation.length).toBe(8) - expect(resultFailedValidation[0].name).toBe('title') - expect(resultFailedValidation[1].name).toBe('abstract') - expect(resultFailedValidation[2].name).toBe('keywords') - expect(resultFailedValidation[3].name).toBe('legalConstraints') - expect(resultFailedValidation[4].name).toBe('contacts') - expect(resultFailedValidation[5].name).toBe('topics') - expect(resultFailedValidation[6].name).toBe('organisation') - expect(resultFailedValidation[7].name).toBe('source') - expect(resultFailedValidation[0].validator()).toBe(false) - expect(resultFailedValidation[1].validator()).toBe(false) - expect(resultFailedValidation[2].validator()).toBe(false) - expect(resultFailedValidation[3].validator()).toBe(false) - expect(resultFailedValidation[4].validator()).toBe(false) - expect(resultFailedValidation[5].validator()).toBe(false) - expect(resultFailedValidation[6].validator()).toBe(false) - expect(resultFailedValidation[7].validator()).toBe(false) + describe('for kind "service"', () => { + const propsToValidate: ValidatorMapperKeys[] = [ + 'titleMultilingual', + 'altTitleMultilingual', + 'abstractMultilingual', + 'keywords', + 'legalConstraintsOtherConstraints', + 'contactsPointOfContactEmail', + 'contactsForResourceWithOwner', + 'status', + 'linkDownload', + 'linkService', + 'linkMapPreview', + ] + + it('should return all applicable validators passing with valid record', () => { + const result = getQualityValidators( + { ...mockRecord, kind: 'service' } as Partial, + propsToValidate + ) + expect(result.length).toBe(11) + expect(result.map((v) => v.name)).toEqual(propsToValidate) + result.forEach((v) => expect(v.validator()).toBe(true)) + }) + + it('should return all applicable validators failing with invalid record', () => { + const result = getQualityValidators( + { ...mockRecordInvalid, kind: 'service' } as Partial, + propsToValidate + ) + expect(result.length).toBe(11) + result.forEach((v) => expect(v.validator()).toBe(false)) + }) }) - it('should filter and return only validators applicable to record kind "service" with correct validation results', () => { + it('should exclude validators not applicable to the record kind', () => { const result = getQualityValidators( - { ...mockRecord, kind: 'service' }, - propsToValidate + { ...mockRecord, kind: 'service' } as Partial, + ['featureCatalog', 'resourceFormat', 'subtopics', 'source'] ) - - expect(result.length).toBe(5) - expect(result[0].name).toBe('title') - expect(result[1].name).toBe('abstract') - expect(result[2].name).toBe('keywords') - expect(result[3].name).toBe('legalConstraints') - expect(result[4].name).toBe('contacts') - expect(result[0].validator()).toBe(true) - expect(result[1].validator()).toBe(true) - expect(result[2].validator()).toBe(true) - expect(result[3].validator()).toBe(true) - expect(result[4].validator()).toBe(true) - - const resultFailedValidation = getQualityValidators( - { ...mockRecordInvalid, kind: 'service' }, - propsToValidate - ) - - expect(resultFailedValidation.length).toBe(5) - expect(resultFailedValidation[0].name).toBe('title') - expect(resultFailedValidation[1].name).toBe('abstract') - expect(resultFailedValidation[2].name).toBe('keywords') - expect(resultFailedValidation[3].name).toBe('legalConstraints') - expect(resultFailedValidation[4].name).toBe('contacts') - expect(resultFailedValidation[0].validator()).toBe(false) - expect(resultFailedValidation[1].validator()).toBe(false) - expect(resultFailedValidation[2].validator()).toBe(false) - expect(resultFailedValidation[3].validator()).toBe(false) - expect(resultFailedValidation[4].validator()).toBe(false) + expect(result.length).toBe(0) }) }) }) diff --git a/libs/util/shared/src/lib/record/quality-score.util.ts b/libs/util/shared/src/lib/record/quality-score.util.ts index 7f43fe7ab..e165270ab 100644 --- a/libs/util/shared/src/lib/record/quality-score.util.ts +++ b/libs/util/shared/src/lib/record/quality-score.util.ts @@ -9,21 +9,145 @@ type TValidatorMapper = { const ValidatorMapper: TValidatorMapper = { title: (record) => !!record?.title, + titleMultilingual: (record) => { + const titleObject = record?.extras?.resourceTitleObject as Record< + string, + unknown + > | undefined + const fre = titleObject?.['langfre'] as string | undefined + const ger = titleObject?.['langger'] as string | undefined + return !!( + fre && + ger && + fre.trim() !== ger.trim() + ) + }, + altTitleMultilingual: (record) => { + const altTitleArray = record?.extras?.resourceAltTitleObject as Array< + Record + > | undefined + return !!( + altTitleArray?.length && + altTitleArray.some((item) => { + const langfre = item?.['langfre'] as string | undefined + const langger = item?.['langger'] as string | undefined + return ( + langfre && + langger && + langfre.trim().length <= 35 && + langger.trim().length <= 35 && + langfre.trim() !== langger.trim() + ) + }) + ) + }, abstract: (record) => !!record?.abstract, - keywords: (record) => (record?.keywords?.length ?? 0) > 0, + abstractMultilingual: (record) => { + const abstractObject = record?.extras?.resourceAbstractObject as Record< + string, + unknown + > | undefined + const fre = abstractObject?.['langfre'] as string | undefined + const ger = abstractObject?.['langger'] as string | undefined + return !!( + fre && + ger && + fre.trim() !== ger.trim() + ) + }, + keywords: (record) => (record?.keywords?.length ?? 0) > 1, legalConstraints: (record) => !!( record?.legalConstraints?.length && record.legalConstraints.some((c) => c?.text?.trim().length > 0) ), + legalConstraintsOtherConstraints: (record) => { + const otherConstraints = record?.extras + ?.MD_LegalConstraintsOtherConstraintsObject as Array> | undefined + return !!( + otherConstraints?.length && + otherConstraints.some((c) => + Object.values(c).some( + (val) => typeof val === 'string' && val.trim().length > 0 + ) + ) + ) + }, contacts: (record) => !!record?.contacts?.[0]?.email && record.contacts[0].email !== 'missing@missing.com', + contactsPointOfContactEmail: (record) => { + const isValidEmail = (email: string) => { + if (!email || typeof email !== 'string') return false + email = email.trim() + if (email.length < 5 || email.length > 254) return false + + const dummyPatterns = + /^(test|example|demo|placeholder|dummy|fake|unknown|noemail|contact@contact)$/i + if (dummyPatterns.test(email)) return false + + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) + } + return !!( + record?.contactsForResource?.length && + record.contactsForResource.some( + (c) => + c?.role === 'point_of_contact' && + c?.email && + isValidEmail(c.email as string) + ) + ) + }, + contactsForResourceWithOwner: (record) => + !!( + record?.contactsForResource?.length && + record.contactsForResource.some((c) => c?.role === 'owner') + ), updateFrequency: (record) => !!record?.updateFrequency && record.updateFrequency !== 'unknown', topics: (record) => (record?.topics?.length ?? 0) > 0, + subtopics: (record) => (record?.subtopics?.length ?? 0) > 0, organisation: (record) => !!record?.contacts?.[0]?.organization?.name, source: (record) => !!record?.extras?.sourcesIdentifiers, + status: (record) => { + const statusObject = record?.extras?.cl_statusObject as Record< + string, + unknown + > | undefined + return !!statusObject + }, + linkDownload: (record) => + !!(record?.onlineResources?.some((r) => r.type === 'download')), + linkService: (record) => + !!(record?.onlineResources?.some((r) => r.type === 'service' || r.type === 'endpoint')), + linkMapPreview: (record) => { + const linkProtocols = record?.extras?.linkProtocol as + | string[] + | undefined + return !!( + linkProtocols?.some( + (p) => + p?.startsWith('MAP:') || p === 'CHTOPO:specialised-geoportal' + ) + ) + }, + featureCatalog: (record) => { + const featureTypes = record?.extras?.['featureTypes'] as + | Array<{ attributeTable?: unknown[]; attributes?: unknown[] }> + | undefined + const hasEmbedded = !!(featureTypes?.some( + (ft) => (ft?.attributeTable?.length ?? ft?.attributes?.length ?? 0) > 0 + )) + const hasExternal = !!record?.extras?.['featureCatalogIdentifier'] + return hasEmbedded || hasExternal + }, + resourceFormat: (record) => { + const formats = record?.extras?.format as string[] | undefined + return !!(formats?.some((f) => typeof f === 'string' && f.trim().length > 0)) + }, } as const export type ValidatorMapperKeys = keyof typeof ValidatorMapper & string @@ -35,23 +159,43 @@ export function getAllKeysValidator() { function getMappersFromKind(kind: RecordKind) { let kindKeys = [] const commonsKeys = [ - 'title', - 'abstract', + 'titleMultilingual', + 'altTitleMultilingual', + 'abstractMultilingual', 'keywords', - 'legalConstraints', - 'contacts', + 'subtopics', + 'organisation', + 'linkDownload', + 'linkService', + 'linkMapPreview', ] switch (kind) { case 'reuse': - kindKeys = ['topics', 'organisation', 'source'] + kindKeys = [ + 'source' + ] break case 'service': - kindKeys = [] + kindKeys = [ + 'featureCatalog', + 'resourceFormat', + 'legalConstraintsOtherConstraints', + 'contactsPointOfContactEmail', + 'contactsForResourceWithOwner', + 'status' + ] break case 'dataset': default: - kindKeys = ['updateFrequency', 'topics', 'organisation'] + kindKeys = [ + 'featureCatalog', + 'resourceFormat', + 'legalConstraintsOtherConstraints', + 'contactsPointOfContactEmail', + 'contactsForResourceWithOwner', + 'status' + ] } return [...commonsKeys, ...kindKeys] diff --git a/translations/de.json b/translations/de.json index b808896ba..05b7745f4 100644 --- a/translations/de.json +++ b/translations/de.json @@ -505,20 +505,49 @@ "record.metadata.quality.contacts.failed": "Kontakt nicht angegeben", "record.metadata.quality.contacts.success": "Kontakt angegeben", "record.metadata.quality.details": "Details", - "record.metadata.quality.keywords.failed": "Schlagwörter nicht angegeben", - "record.metadata.quality.keywords.success": "Schlagwörter angegeben", + "record.metadata.quality.keywords.failed": "Schlagwörter nicht angegeben (mindestens zwei erforderlich)", + "record.metadata.quality.keywords.success": "Schlagwörter angegeben (mindestens zwei erforderlich)", "record.metadata.quality.legalConstraints.failed": "Rechtliche Beschränkungen nicht angegeben", "record.metadata.quality.legalConstraints.success": "Rechtliche Beschränkungen angegeben", + "record.metadata.quality.legalConstraintsOtherConstraints.failed": "Nutzungsbedingungen von opendata.swiss nicht angegeben", + "record.metadata.quality.legalConstraintsOtherConstraints.success": "Nutzungsbedingungen von opendata.swiss angegeben", "record.metadata.quality.organisation.failed": "Organisation nicht angegeben", "record.metadata.quality.organisation.success": "Organisation angegeben", "record.metadata.quality.source.failed": "Keine Quelldatensätze angegeben", "record.metadata.quality.source.success": "Ein Quelldatensatz wird ausgefüllt", "record.metadata.quality.title.failed": "Titel nicht angegeben", "record.metadata.quality.title.success": "Titel angegeben", + "record.metadata.quality.titleMultilingual.failed": "Titel nicht in Französisch und Deutsch vorhanden", + "record.metadata.quality.titleMultilingual.success": "Titel auf Französisch und Deutsch vorhanden", + "record.metadata.quality.abstractMultilingual.failed": "Beschreibung nicht in Französisch und Deutsch vorhanden", + "record.metadata.quality.abstractMultilingual.success": "Beschreibung auf Französisch und Deutsch vorhanden", + "record.metadata.quality.altTitleMultilingual.failed": "Alternativer Titel nicht in Französisch und Deutsch vorhanden (max. 35 Zeichen)", + "record.metadata.quality.altTitleMultilingual.success": "Alternativer Titel auf Französisch und Deutsch vorhanden (max. 35 Zeichen)", + "record.metadata.quality.contactsForResourceWithOwner.failed": "Kein Eigentümer für die Ressource angegeben", + "record.metadata.quality.contactsForResourceWithOwner.success": "Eigentümer für die Ressource angegeben", + "record.metadata.quality.contactsPointOfContactEmail.failed": "Die E-Mail des Ansprechpartners für die Ressource ist ungültig oder nicht angegeben", + "record.metadata.quality.contactsPointOfContactEmail.success": "Die E-Mail des Ansprechpartners für die Ressource ist gültig und angegeben", "record.metadata.quality.topics.failed": "Thema nicht angegeben", - "record.metadata.quality.topics.success": "Thema angegeben", + "record.metadata.quality.topics.success": "Thema angegeben", "record.metadata.quality.subtopics.failed": "Unterthema nicht angegeben", + "record.metadata.quality.subtopics.success": "Unterthema angegeben", + "record.metadata.quality.status.failed": "Status nicht angegeben", + "record.metadata.quality.status.success": "Status angegeben", + "record.metadata.quality.linkDownload.failed": "Kein Download-Link angegeben", + "record.metadata.quality.linkDownload.success": "Ein Download-Link ist angegeben", + "record.metadata.quality.linkService.failed": "Kein Kartendienst-Link angegeben (WMS, WMTS, ESRI:REST...)", + "record.metadata.quality.linkService.success": "Ein Kartendienst-Link ist angegeben (WMS, WMTS, ESRI:REST...)", + "record.metadata.quality.linkMapPreview.failed": "Kein Kartenvorschau-Link angegeben", + "record.metadata.quality.linkMapPreview.success": "Ein Kartenvorschau-Link ist angegeben", + "record.metadata.quality.featureCatalog.failed": "Kein Attributkatalog angegeben", + "record.metadata.quality.featureCatalog.success": "Ein Attributkatalog ist angegeben", + "record.metadata.quality.resourceFormat.failed": "Kein Download-Format angegeben", + "record.metadata.quality.resourceFormat.success": "Ein Download-Format ist angegeben", "record.metadata.quality.updateFrequency.failed": "Aktualisierungsfrequenz nicht angegeben", "record.metadata.quality.updateFrequency.success": "Aktualisierungsfrequenz angegeben", + "record.metadata.quality.score.insufficient": "Unzureichend", + "record.metadata.quality.score.average": "Durchschnittlich", + "record.metadata.quality.score.good": "Gut", + "record.metadata.quality.score.excellent": "Sehr gut", "record.metadata.related": "Entdecken Sie den Katalog", "record.metadata.resourceCreated": "Erstellt", "record.metadata.resourcePublished": "Veröffentlicht", diff --git a/translations/en.json b/translations/en.json index 70292fd08..96298bd32 100644 --- a/translations/en.json +++ b/translations/en.json @@ -500,25 +500,55 @@ "record.metadata.quality": "Metadata Quality", "record.metadata.quality.abstract.failed": "Description is not specified", "record.metadata.quality.abstract.success": "Description is specified", - "record.metadata.quality.capabilities.failed": "Capabilities URL is not specified", - "record.metadata.quality.capabilities.success": "Capabilities URL is specified", + "record.metadata.quality.capabilities.failed": "No Capabilities URL is specified", + "record.metadata.quality.capabilities.success": "A Capabilities URL is specified", "record.metadata.quality.contacts.failed": "Contact is not specified", "record.metadata.quality.contacts.success": "Contact is specified", "record.metadata.quality.details": "Details", - "record.metadata.quality.keywords.failed": "Keywords are not specified", - "record.metadata.quality.keywords.success": "Keywords are specified", + "record.metadata.quality.keywords.failed": "Keywords are not specified (at least two are required)", + "record.metadata.quality.keywords.success": "Keywords are specified (at least two are required)", "record.metadata.quality.legalConstraints.failed": "Legal constraints are not specified", "record.metadata.quality.legalConstraints.success": "Legal constraints are specified", - "record.metadata.quality.organisation.failed": "Organisation is not specified", - "record.metadata.quality.organisation.success": "Organisation is specified", + "record.metadata.quality.legalConstraintsOtherConstraints.failed": "Opendata.swiss usage conditions are not specified", + "record.metadata.quality.legalConstraintsOtherConstraints.success": "Opendata.swiss usage conditions are specified", + "record.metadata.quality.organisation.failed": "Producer is not specified", + "record.metadata.quality.organisation.success": "Producer is specified", "record.metadata.quality.source.failed": "No source dataset is specified", "record.metadata.quality.source.success": "A source dataset is specified", "record.metadata.quality.title.failed": "Title is not specified", "record.metadata.quality.title.success": "Title is specified", - "record.metadata.quality.topics.failed": "Topic is not specified", - "record.metadata.quality.topics.success": "Topic is specified", - "record.metadata.quality.updateFrequency.failed": "Update frequency is not specified", - "record.metadata.quality.updateFrequency.success": "Update frequency is specified", + "record.metadata.quality.titleMultilingual.failed": "Title is not specified in French and German", + "record.metadata.quality.titleMultilingual.success": "Title is specified in French and German", + "record.metadata.quality.abstractMultilingual.failed": "Description is not specified in French and German", + "record.metadata.quality.abstractMultilingual.success": "Description is specified in French and German", + "record.metadata.quality.altTitleMultilingual.failed": "Alternative title is not specified in French and German (max 35 characters)", + "record.metadata.quality.altTitleMultilingual.success": "Alternative title is specified in French and German (max 35 characters)", + "record.metadata.quality.contactsForResourceWithOwner.failed": "No owner is specified for the resource", + "record.metadata.quality.contactsForResourceWithOwner.success": "An owner is specified for the resource", + "record.metadata.quality.contactsPointOfContactEmail.failed": "The contact point email is not valid or not specified for the resource", + "record.metadata.quality.contactsPointOfContactEmail.success": "The contact point email is valid and specified for the resource", + "record.metadata.quality.topics.failed": "The topic is not specified", + "record.metadata.quality.topics.success": "The topic is specified", + "record.metadata.quality.subtopics.failed": "The subtopic is not specified", + "record.metadata.quality.subtopics.success": "The subtopic is specified", + "record.metadata.quality.status.failed": "The status is not specified", + "record.metadata.quality.status.success": "The status is specified", + "record.metadata.quality.linkDownload.failed": "No download link is specified", + "record.metadata.quality.linkDownload.success": "A download link is specified", + "record.metadata.quality.linkService.failed": "No map service link is specified (WMS, WMTS, ESRI:REST...)", + "record.metadata.quality.linkService.success": "A map service link is specified (WMS, WMTS, ESRI:REST...)", + "record.metadata.quality.linkMapPreview.failed": "No map preview link is specified", + "record.metadata.quality.linkMapPreview.success": "A map preview link is specified", + "record.metadata.quality.featureCatalog.failed": "No feature catalog (attribute table) is specified", + "record.metadata.quality.featureCatalog.success": "A feature catalog (attribute table) is specified", + "record.metadata.quality.resourceFormat.failed": "No download format is specified", + "record.metadata.quality.resourceFormat.success": "A download format is specified", + "record.metadata.quality.updateFrequency.failed": "The update frequency is not specified", + "record.metadata.quality.updateFrequency.success": "The update frequency is specified", + "record.metadata.quality.score.insufficient": "Insufficient", + "record.metadata.quality.score.average": "Average", + "record.metadata.quality.score.good": "Good", + "record.metadata.quality.score.excellent": "Excellent", "record.metadata.related": "Explore the catalog", "record.metadata.resourceCreated": "Created", "record.metadata.resourcePublished": "Published", diff --git a/translations/fr.json b/translations/fr.json index 0bf6a1b0e..3b94fa7c4 100644 --- a/translations/fr.json +++ b/translations/fr.json @@ -497,7 +497,7 @@ "record.metadata.producer": "Producteur de la donnée", "record.metadata.publication": "Date de publication", "record.metadata.publications": "{count, plural, =0{ressource} one{ressource} other{ressources}}", - "record.metadata.quality": "Description de la ressource", + "record.metadata.quality": "Qualité de la métadonnée", "record.metadata.quality.abstract.failed": "La description n'est pas renseignée", "record.metadata.quality.abstract.success": "La description est renseignée", "record.metadata.quality.capabilities.failed": "Aucune URL de Capabilities n'est renseignée", @@ -505,20 +505,50 @@ "record.metadata.quality.contacts.failed": "Le contact n'est pas renseigné", "record.metadata.quality.contacts.success": "Le contact est renseigné", "record.metadata.quality.details": "Détails", - "record.metadata.quality.keywords.failed": "Les mots-clés ne sont pas renseignés", - "record.metadata.quality.keywords.success": "Les mots-clés sont renseignés", + "record.metadata.quality.keywords.failed": "Les mots-clés ne sont pas renseignés (au moins deux sont nécessaires)", + "record.metadata.quality.keywords.success": "Les mots-clés sont renseignés (au moins deux sont nécessaires)", "record.metadata.quality.legalConstraints.failed": "Les contraintes légales ne sont pas renseignées", "record.metadata.quality.legalConstraints.success": "Les contraintes légales sont renseignées", + "record.metadata.quality.legalConstraintsOtherConstraints.failed": "Les conditions d'utilisation opendata.swiss ne sont pas renseignées", + "record.metadata.quality.legalConstraintsOtherConstraints.success": "Les conditions d'utilisation opendata.swiss sont renseignées", "record.metadata.quality.organisation.failed": "Le producteur n'est pas renseigné", "record.metadata.quality.organisation.success": "Le producteur est renseigné", "record.metadata.quality.source.failed": "Aucun jeu de données source n'est renseigné", "record.metadata.quality.source.success": "Un jeu de données source est renseigné", "record.metadata.quality.title.failed": "Le titre n'est pas renseigné", "record.metadata.quality.title.success": "Le titre est renseigné", + "record.metadata.quality.titleMultilingual.failed": "Le titre n'est pas renseigné en français et allemand", + "record.metadata.quality.titleMultilingual.success": "Le titre est renseigné en français et allemand", + "record.metadata.quality.abstractMultilingual.failed": "La description n'est pas renseignée en français et allemand", + "record.metadata.quality.abstractMultilingual.success": "La description est renseignée en français et allemand", + "record.metadata.quality.altTitleMultilingual.failed": "Le titre alternatif n'est pas renseigné en français et allemand (max 35 caractères)", + "record.metadata.quality.altTitleMultilingual.success": "Le titre alternatif est renseigné en français et allemand (max 35 caractères)", + "record.metadata.quality.contactsForResourceWithOwner.failed": "Aucun propriétaire n'est renseigné pour la ressource", + "record.metadata.quality.contactsForResourceWithOwner.success": "Un propriétaire est renseigné pour la ressource", + "record.metadata.quality.contactsPointOfContactEmail.failed": "L'email du point de contact n'est pas valide ou n'est pas renseigné pour la ressource", + "record.metadata.quality.contactsPointOfContactEmail.success": "L'email du point de contact est valide et renseigné pour la ressource", "record.metadata.quality.topics.failed": "Le thème n'est pas renseigné", "record.metadata.quality.topics.success": "Le thème est renseigné", + "record.metadata.quality.subtopics.failed": "Le sous-thème n'est pas renseigné", + "record.metadata.quality.subtopics.success": "Le sous-thème est renseigné", + "record.metadata.quality.status.failed": "Le statut n'est pas renseigné", + "record.metadata.quality.status.success": "Le statut est renseigné", + "record.metadata.quality.linkDownload.failed": "Aucun lien de téléchargement n'est renseigné", + "record.metadata.quality.linkDownload.success": "Un lien de téléchargement est renseigné", + "record.metadata.quality.linkService.failed": "Aucun lien vers un service cartographique n'est renseigné (WMS, WMTS, ESRI:REST...)", + "record.metadata.quality.linkService.success": "Un lien vers un service cartographique est renseigné (WMS, WMTS, ESRI:REST...)", + "record.metadata.quality.linkMapPreview.failed": "Aucun lien de prévisualisation cartographique n'est renseigné", + "record.metadata.quality.linkMapPreview.success": "Un lien de prévisualisation cartographique est renseigné", + "record.metadata.quality.featureCatalog.failed": "Aucun catalogue d'attributs n'est renseigné", + "record.metadata.quality.featureCatalog.success": "Un catalogue d'attributs est renseigné", + "record.metadata.quality.resourceFormat.failed": "Aucun format de téléchargement n'est renseigné", + "record.metadata.quality.resourceFormat.success": "Un format de téléchargement est renseigné", "record.metadata.quality.updateFrequency.failed": "La fréquence de mise à jour n'est pas renseignée", "record.metadata.quality.updateFrequency.success": "La fréquence de mise à jour est renseignée", + "record.metadata.quality.score.insufficient": "Insuffisant", + "record.metadata.quality.score.average": "Moyen", + "record.metadata.quality.score.good": "Bon", + "record.metadata.quality.score.excellent": "Très bon", "record.metadata.related": "Explorez le catalogue", "record.metadata.resourceCreated": "Créé", "record.metadata.resourcePublished": "Publié",