diff --git a/apps/metadata-editor-e2e/src/e2e/import.cy.ts b/apps/metadata-editor-e2e/src/e2e/import.cy.ts
new file mode 100644
index 0000000000..0c73348dc2
--- /dev/null
+++ b/apps/metadata-editor-e2e/src/e2e/import.cy.ts
@@ -0,0 +1,79 @@
+// eslint-disable-next-line @nx/enforce-module-boundaries
+import { simpleDatasetRecordAsXmlFixture } from '@geonetwork-ui/common/fixtures'
+
+describe('import', () => {
+ beforeEach(() => {
+ cy.login('admin', 'admin', false)
+ cy.visit('/catalog/search')
+ })
+
+ describe('import a record', () => {
+ beforeEach(() => {
+ // Open the import overlay
+ cy.get('[data-test="import-record"]').click()
+ })
+
+ it('should show the import menu overlay', () => {
+ cy.get('gn-ui-import-record').should('be.visible')
+ cy.get('[data-test="importMenuMainSection"]').should('be.visible')
+ })
+
+ describe('import by URL section', () => {
+ beforeEach(() => {
+ cy.get('[data-test="importFromUrlButton"]').click()
+ })
+
+ it('should show the import by URL section', () => {
+ cy.get('[data-test="importMenuImportExternalFileSection"]').should(
+ 'be.visible'
+ )
+ })
+
+ it('should show the import by URL section', () => {
+ cy.get('[data-test="importMenuImportExternalFileSection"]').should(
+ 'be.visible'
+ )
+ })
+
+ it('should import a record', () => {
+ cy.get('[data-test="importMenuImportExternalFileSection"]')
+ .find('gn-ui-url-input')
+ .type('http://www.marvelous-record/xml/download')
+
+ cy.intercept(
+ {
+ method: 'GET',
+ url: /\/xml\/download$/,
+ },
+ {
+ statusCode: 200,
+ body: simpleDatasetRecordAsXmlFixture(),
+ }
+ ).as('importUrlRequest')
+
+ cy.get('gn-ui-url-input').find('gn-ui-button').find('button').click()
+
+ // Check that the record is correctly displayed
+ cy.get('gn-ui-record-form').should('be.visible')
+
+ cy.get('gn-ui-record-form')
+ .find('gn-ui-form-field')
+ .eq(0)
+ .find('input')
+ .invoke('val')
+ .should('contain', 'Copy')
+ })
+
+ it('should be able to navigate back to the main section', () => {
+ cy.get(
+ '[data-test="importMenuImportExternalFileSectionBackButton"]'
+ ).click()
+
+ cy.get('[data-test="importMenuMainSection"]').should('be.visible')
+ cy.get('[data-test="importMenuImportExternalFileSection"]').should(
+ 'not.exist'
+ )
+ })
+ })
+ })
+})
diff --git a/apps/metadata-editor/src/app/records/records-list.component.ts b/apps/metadata-editor/src/app/records/records-list.component.ts
index 5ef8dc82b3..c930da2a02 100644
--- a/apps/metadata-editor/src/app/records/records-list.component.ts
+++ b/apps/metadata-editor/src/app/records/records-list.component.ts
@@ -59,6 +59,7 @@ export class RecordsListComponent {
paginate(page: number) {
this.searchService.setPage(page)
}
+
createRecord() {
this.router.navigate(['/create'])
}
diff --git a/apps/metadata-editor/src/app/records/search-records/search-records-list.component.html b/apps/metadata-editor/src/app/records/search-records/search-records-list.component.html
index becee4f003..930a7b542c 100644
--- a/apps/metadata-editor/src/app/records/search-records/search-records-list.component.html
+++ b/apps/metadata-editor/src/app/records/search-records/search-records-list.component.html
@@ -31,6 +31,26 @@
dashboard.results.listResources
+
+ dashboard.importRecord
+ keyboard_arrow_down
+ keyboard_arrow_up
+
+
+
+
Promise.resolve(true))
}
describe('SearchRecordsComponent', () => {
@@ -81,9 +82,11 @@ describe('SearchRecordsComponent', () => {
let fixture: ComponentFixture
let router: Router
let searchService: SearchService
+ let searchFacade: SearchFacade
beforeEach(() => {
TestBed.configureTestingModule({
+ schemas: [CUSTOM_ELEMENTS_SCHEMA],
providers: [
importProvidersFrom(TranslateModule.forRoot()),
{
@@ -115,6 +118,8 @@ describe('SearchRecordsComponent', () => {
fixture = TestBed.createComponent(SearchRecordsComponent)
router = TestBed.inject(Router)
searchService = TestBed.inject(SearchService)
+ searchFacade = TestBed.inject(SearchFacade)
+
component = fixture.componentInstance
fixture.detectChanges()
})
@@ -123,6 +128,54 @@ describe('SearchRecordsComponent', () => {
expect(component).toBeTruthy()
})
+ it('should map search filters to searchText$', (done) => {
+ component.searchText$.subscribe((text) => {
+ expect(text).toBe('hello world')
+ done()
+ })
+ })
+
+ describe('when clicking createRecord', () => {
+ beforeEach(() => {
+ component.createRecord()
+ })
+
+ it('navigates to the create record page', () => {
+ expect(router.navigate).toHaveBeenCalledWith(['/create'])
+ })
+ })
+
+ describe('when importing a record', () => {
+ beforeEach(() => {
+ component.importRecord()
+ })
+
+ it('sets isImportMenuOpen to true', () => {
+ expect(component.isImportMenuOpen).toBe(true)
+ })
+ })
+
+ describe('when closing the import menu', () => {
+ let overlaySpy: any
+
+ beforeEach(() => {
+ overlaySpy = {
+ dispose: jest.fn(),
+ }
+ component['overlayRef'] = overlaySpy
+
+ component.closeImportMenu()
+ })
+
+ it('sets isImportMenuOpen to false', () => {
+ expect(component.isImportMenuOpen).toBe(false)
+ })
+
+ it('disposes the overlay', () => {
+ expect(overlaySpy.dispose).toHaveBeenCalled()
+ })
+ })
+
describe('when search results', () => {
let table, pagination
beforeEach(() => {
@@ -141,6 +194,7 @@ describe('SearchRecordsComponent', () => {
expect(pagination.currentPage).toEqual(currentPage)
expect(pagination.totalPages).toEqual(totalPages)
})
+
describe('when click on a record', () => {
const uniqueIdentifier = 123
const singleRecord = {
@@ -154,6 +208,7 @@ describe('SearchRecordsComponent', () => {
expect(router.navigate).toHaveBeenCalledWith(['/edit', 123])
})
})
+
describe('when asking for record duplication', () => {
const uniqueIdentifier = 123
const singleRecord = {
@@ -167,6 +222,7 @@ describe('SearchRecordsComponent', () => {
expect(router.navigate).toHaveBeenCalledWith(['/duplicate', 123])
})
})
+
describe('when click on pagination', () => {
beforeEach(() => {
pagination.newCurrentPageEvent.emit(3)
diff --git a/apps/metadata-editor/src/app/records/search-records/search-records-list.component.ts b/apps/metadata-editor/src/app/records/search-records/search-records-list.component.ts
index a6a0d5af3d..2335f548d5 100644
--- a/apps/metadata-editor/src/app/records/search-records/search-records-list.component.ts
+++ b/apps/metadata-editor/src/app/records/search-records/search-records-list.component.ts
@@ -1,5 +1,12 @@
import { CommonModule } from '@angular/common'
-import { Component } from '@angular/core'
+import {
+ ChangeDetectorRef,
+ Component,
+ ElementRef,
+ TemplateRef,
+ ViewChild,
+ ViewContainerRef,
+} from '@angular/core'
import {
ResultsTableContainerComponent,
SearchFacade,
@@ -14,6 +21,14 @@ import { Observable } from 'rxjs'
import { UiElementsModule } from '@geonetwork-ui/ui/elements'
import { UiInputsModule } from '@geonetwork-ui/ui/inputs'
import { MatIconModule } from '@angular/material/icon'
+import {
+ CdkConnectedOverlay,
+ CdkOverlayOrigin,
+ Overlay,
+ OverlayRef,
+} from '@angular/cdk/overlay'
+import { TemplatePortal } from '@angular/cdk/portal'
+import { ImportRecordComponent } from '@geonetwork-ui/feature/editor'
@Component({
selector: 'md-editor-search-records-list',
@@ -28,32 +43,88 @@ import { MatIconModule } from '@angular/material/icon'
UiElementsModule,
UiInputsModule,
MatIconModule,
+ ImportRecordComponent,
+ CdkOverlayOrigin,
+ CdkConnectedOverlay,
],
})
export class SearchRecordsComponent {
+ @ViewChild('importRecordButton', { read: ElementRef })
+ private importRecordButton!: ElementRef
+ @ViewChild('template') template!: TemplateRef
+ private overlayRef!: OverlayRef
+
searchText$: Observable =
this.searchFacade.searchFilters$.pipe(
map((filters) => ('any' in filters ? (filters['any'] as string) : null))
)
+ isImportMenuOpen = false
+
constructor(
private router: Router,
public searchFacade: SearchFacade,
- public searchService: SearchService
+ public searchService: SearchService,
+ private overlay: Overlay,
+ private viewContainerRef: ViewContainerRef,
+ private cdr: ChangeDetectorRef
) {
this.searchFacade.setPageSize(15)
this.searchFacade.resetSearch()
}
editRecord(record: CatalogRecord) {
- this.router.navigate(['/edit', record.uniqueIdentifier])
+ this.router
+ .navigate(['/edit', record.uniqueIdentifier])
+ .catch((err) => console.error(err))
}
duplicateRecord(record: CatalogRecord) {
- this.router.navigate(['/duplicate', record.uniqueIdentifier])
+ this.router
+ .navigate(['/duplicate', record.uniqueIdentifier])
+ .catch((err) => console.error(err))
}
createRecord() {
- this.router.navigate(['/create'])
+ this.router.navigate(['/create']).catch((err) => console.error(err))
+ }
+
+ importRecord() {
+ this.isImportMenuOpen = true
+
+ const positionStrategy = this.overlay
+ .position()
+ .flexibleConnectedTo(this.importRecordButton)
+ .withPositions([
+ {
+ originX: 'end',
+ originY: 'bottom',
+ overlayX: 'end',
+ overlayY: 'top',
+ },
+ ])
+
+ this.overlayRef = this.overlay.create({
+ hasBackdrop: true,
+ backdropClass: 'cdk-overlay-transparent-backdrop',
+ positionStrategy: positionStrategy,
+ scrollStrategy: this.overlay.scrollStrategies.reposition(),
+ })
+
+ const portal = new TemplatePortal(this.template, this.viewContainerRef)
+
+ this.overlayRef.attach(portal)
+
+ this.overlayRef.backdropClick().subscribe(() => {
+ this.closeImportMenu()
+ })
+ }
+
+ closeImportMenu() {
+ if (this.overlayRef) {
+ this.isImportMenuOpen = false
+ this.overlayRef.dispose()
+ this.cdr.markForCheck()
+ }
}
}
diff --git a/apps/metadata-editor/src/styles.css b/apps/metadata-editor/src/styles.css
index 29fec93421..6352aa4946 100644
--- a/apps/metadata-editor/src/styles.css
+++ b/apps/metadata-editor/src/styles.css
@@ -22,6 +22,10 @@ body {
@apply text-2xl px-9 py-3;
}
+.cdk-overlay-transparent-backdrop {
+ @apply bg-transparent;
+}
+
.mat-mdc-button-base {
line-height: normal;
}
diff --git a/libs/api/metadata-converter/src/index.ts b/libs/api/metadata-converter/src/index.ts
index 169d8dfcb6..3ce8f4d066 100644
--- a/libs/api/metadata-converter/src/index.ts
+++ b/libs/api/metadata-converter/src/index.ts
@@ -2,3 +2,4 @@ export * from './lib/iso19139'
export * from './lib/iso19115-3'
export * from './lib/find-converter'
export * from './lib/gn4'
+export * from './lib/xml-utils'
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 1d7957feb9..4a4815c547 100644
--- a/libs/api/metadata-converter/src/lib/xml-utils.spec.ts
+++ b/libs/api/metadata-converter/src/lib/xml-utils.spec.ts
@@ -1,11 +1,14 @@
import { XmlElement } from '@rgrove/parse-xml'
import {
+ assertValidXml,
+ createDocument,
getRootElement,
parseXmlString,
readText,
renameElements,
xmlToString,
} from './xml-utils'
+import { simpleDatasetRecordAsXmlFixture } from '@geonetwork-ui/common/fixtures'
describe('xml utils', () => {
describe('xmlToString', () => {
@@ -162,4 +165,47 @@ world
expect(readText()(null)).toEqual(null)
})
})
+
+ describe('createDocument', () => {
+ it('succeeds when custom namespaces are present inside the tree', () => {
+ const originalDoc = parseXmlString(`
+
+
+
+
+
+
+ EPSG:2154
+
+
+
+
+
+`)
+ const rootEl = getRootElement(originalDoc)
+ const newDoc = createDocument(rootEl)
+ expect(newDoc).toBeTruthy()
+ })
+ })
+
+ describe('parseAndValidateXml', () => {
+ it('should parse valid XML without errors', () => {
+ const validXml = simpleDatasetRecordAsXmlFixture()
+ const xmlDoc = assertValidXml(validXml)
+
+ expect(xmlDoc).toBeDefined()
+ expect(xmlDoc.querySelector('mdb\\:MD_Metadata')).toBeTruthy()
+ expect(
+ xmlDoc.querySelector('gco\\:CharacterString')?.textContent
+ ).toContain('my-dataset-001')
+ })
+
+ it('should throw an error for invalid XML', () => {
+ const invalidXml = `Invalid XML assertValidXml(invalidXml)).toThrow(
+ new Error('File is not a valid XML.')
+ )
+ })
+ })
})
diff --git a/libs/api/metadata-converter/src/lib/xml-utils.ts b/libs/api/metadata-converter/src/lib/xml-utils.ts
index 1031a709a8..1e2b70f563 100644
--- a/libs/api/metadata-converter/src/lib/xml-utils.ts
+++ b/libs/api/metadata-converter/src/lib/xml-utils.ts
@@ -36,7 +36,8 @@ export function createDocument(rootEl: XmlElement): XmlDocument {
if (namespace === 'xmlns' || namespace === null) return
if (rootEl.attributes[`xmlns:${namespace}`]) return
if (!NAMESPACES[namespace]) {
- throw new Error(`No known URI for namespace ${namespace}`)
+ // the namespace is unknown but it might still be declared correctly: ignore it
+ return
}
rootEl.attributes[`xmlns:${namespace}`] = NAMESPACES[namespace]
}
@@ -456,3 +457,21 @@ export function renameElements(
doReplace(rootElement)
return rootElement
}
+
+/**
+ * This function use the DOMParser to check if the given xmlString is a valid XML file or throw an error
+ * (Generated by chatGPT)
+ * @param xmlString
+ */
+export function assertValidXml(xmlString: string): Document {
+ const parser = new DOMParser()
+ const xmlDoc = parser.parseFromString(xmlString, 'application/xml')
+ const parserError = xmlDoc.querySelector('parsererror')
+
+ if (parserError) {
+ console.error(parserError)
+ throw new Error('File is not a valid XML.')
+ }
+
+ return xmlDoc
+}
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 60de0e6766..84b0d73c15 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 {
} from '@geonetwork-ui/data-access/gn4'
import { firstValueFrom, lastValueFrom, of, throwError } from 'rxjs'
import { ElasticsearchService } from './elasticsearch'
-import { TestBed } from '@angular/core/testing'
+import { fakeAsync, TestBed, tick } from '@angular/core/testing'
import {
EsSearchResponse,
Gn4Converter,
@@ -22,6 +22,10 @@ import {
import { CatalogRecord } from '@geonetwork-ui/common/domain/model/record'
import { map } from 'rxjs/operators'
import { HttpErrorResponse } from '@angular/common/http'
+import {
+ HttpClientTestingModule,
+ HttpTestingController,
+} from '@angular/common/http/testing'
class Gn4MetadataMapperMock {
readRecords = jest.fn((records) =>
@@ -84,6 +88,7 @@ class RecordsApiServiceMock {
},
})
)
+ deleteRecord = jest.fn(() => of({}))
}
describe('Gn4Repository', () => {
@@ -91,9 +96,11 @@ describe('Gn4Repository', () => {
let gn4Helper: ElasticsearchService
let gn4SearchApi: SearchApiService
let gn4RecordsApi: RecordsApiService
+ let httpTestingController: HttpTestingController
beforeEach(() => {
TestBed.configureTestingModule({
+ imports: [HttpClientTestingModule],
providers: [
Gn4Repository,
{
@@ -118,7 +125,14 @@ describe('Gn4Repository', () => {
gn4Helper = TestBed.inject(ElasticsearchService)
gn4SearchApi = TestBed.inject(SearchApiService)
gn4RecordsApi = TestBed.inject(RecordsApiService)
+ httpTestingController = TestBed.inject(HttpTestingController)
+ })
+
+ afterEach(() => {
+ // Verify that no other requests are outstanding
+ httpTestingController.verify()
})
+
it('creates', () => {
expect(repository).toBeTruthy()
})
@@ -536,4 +550,153 @@ describe('Gn4Repository', () => {
])
})
})
+
+ describe('importRecordFromExternalFileUrlAsDraft', () => {
+ const recordDownloadUrl = 'https://example.com/record/xml'
+ const mockXml = simpleDatasetRecordAsXmlFixture()
+ let tempId: string
+
+ it('should fetch the external record and save it as a draft', fakeAsync(() => {
+ repository
+ .importRecordFromExternalFileUrlAsDraft(recordDownloadUrl)
+ .subscribe((id) => {
+ tempId = id
+
+ expect(tempId).toMatch(/^TEMP-ID-\d+$/)
+ })
+
+ const req = httpTestingController.expectOne(recordDownloadUrl)
+
+ expect(req.request.headers.get('Accept')).toEqual(
+ 'text/xml,application/xml'
+ )
+ expect(req.request.method).toEqual('GET')
+
+ req.flush(mockXml)
+
+ tick()
+ }))
+
+ it('should handle an error response when fetching the external record', fakeAsync(() => {
+ let errorResponse: any
+
+ repository
+ .importRecordFromExternalFileUrlAsDraft(recordDownloadUrl)
+ .subscribe({
+ error: (error) => {
+ errorResponse = error
+ },
+ })
+
+ const req = httpTestingController.expectOne(recordDownloadUrl)
+
+ req.flush('Error fetching record', {
+ status: 404,
+ statusText: 'Not Found',
+ })
+
+ tick()
+
+ expect(errorResponse).toBeDefined()
+ expect(errorResponse.status).toBe(404)
+ expect(errorResponse.statusText).toBe('Not Found')
+ }))
+ })
+
+ describe('deleteRecord', () => {
+ it('calls the API to delete the record by unique identifier', fakeAsync(() => {
+ repository.deleteRecord('1234-5678').subscribe()
+
+ // Simulate async passage of time
+ tick()
+
+ // Ensure the API method was called correctly
+ expect(gn4RecordsApi.deleteRecord).toHaveBeenCalledWith('1234-5678')
+ }))
+ })
+
+ describe('saveRecordAsDraft', () => {
+ beforeEach(async () => {
+ await lastValueFrom(
+ repository.saveRecordAsDraft({
+ ...simpleDatasetRecordFixture(),
+ uniqueIdentifier: 'DRAFT-123',
+ })
+ )
+ })
+
+ it('saves the record to localStorage with the correct key', () => {
+ const hasDraft = repository.recordHasDraft('DRAFT-123')
+ expect(hasDraft).toBe(true)
+ })
+
+ it('emits a draft changed notification', async () => {
+ const draftsChangedSpy = jest.spyOn(repository._draftsChanged, 'next')
+
+ await lastValueFrom(
+ repository.saveRecordAsDraft({
+ ...simpleDatasetRecordFixture(),
+ uniqueIdentifier: 'DRAFT-456',
+ })
+ )
+
+ expect(draftsChangedSpy).toHaveBeenCalled()
+ })
+ })
+
+ describe('generateTemporaryId', () => {
+ it('generates a temporary ID with the correct prefix', () => {
+ const tempId = repository.generateTemporaryId()
+ expect(tempId).toMatch(/^TEMP-ID-\d+$/)
+ })
+ })
+
+ describe('clearRecordDraft', () => {
+ beforeEach(async () => {
+ await lastValueFrom(
+ repository.saveRecordAsDraft({
+ ...simpleDatasetRecordFixture(),
+ uniqueIdentifier: 'DRAFT-123',
+ })
+ )
+ repository.clearRecordDraft('DRAFT-123')
+ })
+
+ it('removes the draft from localStorage', () => {
+ const hasDraft = repository.recordHasDraft('DRAFT-123')
+ expect(hasDraft).toBe(false)
+ })
+
+ it('emits a draft changed notification after clearing', () => {
+ const draftsChangedSpy = jest.spyOn(repository._draftsChanged, 'next')
+ repository.clearRecordDraft('DRAFT-123')
+ expect(draftsChangedSpy).toHaveBeenCalled()
+ })
+ })
+
+ describe('recordHasDraft', () => {
+ it('returns true if the draft exists', async () => {
+ await lastValueFrom(
+ repository.saveRecordAsDraft({
+ ...simpleDatasetRecordFixture(),
+ uniqueIdentifier: 'DRAFT-123',
+ })
+ )
+ expect(repository.recordHasDraft('DRAFT-123')).toBe(true)
+ })
+
+ it('returns false if the draft does not exist', () => {
+ expect(repository.recordHasDraft('NON_EXISTENT_DRAFT')).toBe(false)
+ })
+ })
+
+ describe('isRecordNotYetSaved', () => {
+ it('returns true if the record has a temporary ID', () => {
+ expect(repository.isRecordNotYetSaved('TEMP-ID-12345')).toBe(true)
+ })
+
+ it('returns false if the record does not have a temporary ID', () => {
+ expect(repository.isRecordNotYetSaved('1234-5678')).toBe(false)
+ })
+ })
})
diff --git a/libs/api/repository/src/lib/gn4/gn4-repository.ts b/libs/api/repository/src/lib/gn4/gn4-repository.ts
index a31688b218..e9eecc4d85 100644
--- a/libs/api/repository/src/lib/gn4/gn4-repository.ts
+++ b/libs/api/repository/src/lib/gn4/gn4-repository.ts
@@ -6,6 +6,7 @@ import {
import { ElasticsearchService } from './elasticsearch'
import {
combineLatest,
+ exhaustMap,
from,
Observable,
of,
@@ -25,22 +26,32 @@ import {
} from '@geonetwork-ui/common/domain/model/search'
import { catchError, map, tap } from 'rxjs/operators'
import {
+ assertValidXml,
findConverterForDocument,
Gn4Converter,
Gn4SearchResults,
Iso19139Converter,
} from '@geonetwork-ui/api/metadata-converter'
import { CatalogRecord } from '@geonetwork-ui/common/domain/model/record'
-import { HttpErrorResponse } from '@angular/common/http'
+import {
+ HttpClient,
+ HttpErrorResponse,
+ HttpHeaders,
+} from '@angular/common/http'
const TEMPORARY_ID_PREFIX = 'TEMP-ID-'
+export const LOCAL_STORAGE_RECORD_PREFIX = 'me-record-draft-'
+
+export type RecordAsXml = string
+
@Injectable()
export class Gn4Repository implements RecordsRepositoryInterface {
_draftsChanged = new Subject()
draftsChanged$ = this._draftsChanged.asObservable()
constructor(
+ private httpClient: HttpClient,
private gn4SearchApi: SearchApiService,
private gn4SearchHelper: ElasticsearchService,
private gn4Mapper: Gn4Converter,
@@ -140,6 +151,7 @@ export class Gn4Repository implements RecordsRepositoryInterface {
)
)
}
+
aggregate(params: AggregationsParams): Observable {
// if aggregations are empty, return an empty object right away
if (Object.keys(params).length === 0) return of({})
@@ -184,44 +196,12 @@ export class Gn4Repository implements RecordsRepositoryInterface {
)
}
- /**
- * Returns null if the record is not found
- */
- private loadRecordAsXml(uniqueIdentifier: string): Observable {
- return this.gn4RecordsApi
- .getRecordAs(
- uniqueIdentifier,
- undefined,
- false,
- undefined,
- undefined,
- undefined,
- 'application/xml',
- 'response',
- undefined,
- { httpHeaderAccept: 'text/xml,application/xml' as 'application/xml' } // this is to make sure that the response is parsed as text
- )
- .pipe(
- map((response) => response.body),
- catchError((error: HttpErrorResponse) =>
- error.status === 404 ? of(null) : throwError(() => error)
- )
- )
- }
-
- private getLocalStorageKeyForRecord(uniqueIdentifier: string) {
- return `geonetwork-ui-draft-${uniqueIdentifier}`
- }
-
openRecordForEdition(
uniqueIdentifier: string
): Observable<[CatalogRecord, string, boolean] | null> {
- const draft$ = of(
- window.localStorage.getItem(
- this.getLocalStorageKeyForRecord(uniqueIdentifier)
- )
- )
- const recordAsXml$ = this.loadRecordAsXml(uniqueIdentifier)
+ const draft$ = of(this.getRecordFromLocalStorage(uniqueIdentifier))
+ const recordAsXml$ = this.getRecordAsXml(uniqueIdentifier)
+
return combineLatest([draft$, recordAsXml$]).pipe(
switchMap(([draft, recordAsXml]) => {
const xml = draft ?? recordAsXml
@@ -239,34 +219,27 @@ export class Gn4Repository implements RecordsRepositoryInterface {
openRecordForDuplication(
uniqueIdentifier: string
): Observable<[CatalogRecord, string, false] | null> {
- return this.loadRecordAsXml(uniqueIdentifier).pipe(
- switchMap(async (recordAsXml) => {
- const converter = findConverterForDocument(recordAsXml)
- const record = await converter.readRecord(recordAsXml)
+ return this.getRecordAsXml(uniqueIdentifier).pipe(
+ switchMap(async (fetchedRecordAsXml) => {
+ const converter = findConverterForDocument(fetchedRecordAsXml)
+ const record = await converter.readRecord(fetchedRecordAsXml)
+
record.uniqueIdentifier = `${TEMPORARY_ID_PREFIX}${Date.now()}`
record.title = `${record.title} (Copy)`
- const xml = await converter.writeRecord(record, recordAsXml)
- window.localStorage.setItem(
- this.getLocalStorageKeyForRecord(record.uniqueIdentifier),
- xml
+
+ const recordAsXml = await converter.writeRecord(
+ record,
+ fetchedRecordAsXml
)
+
+ this.saveRecordToLocalStorage(recordAsXml, record.uniqueIdentifier)
this._draftsChanged.next()
- return [record, xml, false] as [CatalogRecord, string, false]
+
+ return [record, recordAsXml, false] as [CatalogRecord, string, false]
})
)
}
- private serializeRecordToXml(
- record: CatalogRecord,
- referenceRecordSource?: string
- ): Observable {
- // if there's a reference record, use that standard; otherwise, use iso19139
- const converter = referenceRecordSource
- ? findConverterForDocument(referenceRecordSource)
- : new Iso19139Converter()
- return from(converter.writeRecord(record, referenceRecordSource))
- }
-
saveRecord(
record: CatalogRecord,
referenceRecordSource?: string
@@ -301,6 +274,34 @@ export class Gn4Repository implements RecordsRepositoryInterface {
)
}
+ importRecordFromExternalFileUrlAsDraft(
+ recordDownloadUrl: string
+ ): Observable {
+ return this.getExternalRecordAsXml(recordDownloadUrl).pipe(
+ exhaustMap(async (fetchedRecordAsXml: string) => {
+ const converter = findConverterForDocument(fetchedRecordAsXml)
+ const record = await converter.readRecord(fetchedRecordAsXml)
+ const tempId = this.generateTemporaryId()
+
+ record.title = `${record.title} (Copy)`
+ record.uniqueIdentifier = tempId
+
+ const recordAsXml = await converter.writeRecord(
+ record,
+ fetchedRecordAsXml
+ )
+
+ this.saveRecordToLocalStorage(recordAsXml, record.uniqueIdentifier)
+ this._draftsChanged.next()
+
+ return tempId
+ }),
+ catchError((error: HttpErrorResponse) => {
+ return throwError(() => error)
+ })
+ )
+ }
+
deleteRecord(uniqueIdentifier: string): Observable {
return this.gn4RecordsApi.deleteRecord(uniqueIdentifier)
}
@@ -315,28 +316,19 @@ export class Gn4Repository implements RecordsRepositoryInterface {
): Observable {
return this.serializeRecordToXml(record, referenceRecordSource).pipe(
tap((recordXml) => {
- window.localStorage.setItem(
- this.getLocalStorageKeyForRecord(record.uniqueIdentifier),
- recordXml
- )
+ this.saveRecordToLocalStorage(recordXml, record.uniqueIdentifier)
this._draftsChanged.next()
})
)
}
clearRecordDraft(uniqueIdentifier: string): void {
- window.localStorage.removeItem(
- this.getLocalStorageKeyForRecord(uniqueIdentifier)
- )
+ this.removeRecordFromLocalStorage(uniqueIdentifier)
this._draftsChanged.next()
}
recordHasDraft(uniqueIdentifier: string): boolean {
- return (
- window.localStorage.getItem(
- this.getLocalStorageKeyForRecord(uniqueIdentifier)
- ) !== null
- )
+ return this.getRecordFromLocalStorage(uniqueIdentifier) !== null
}
isRecordNotYetSaved(uniqueIdentifier: string): boolean {
@@ -347,7 +339,7 @@ export class Gn4Repository implements RecordsRepositoryInterface {
getAllDrafts(): Observable {
const items = { ...window.localStorage }
const drafts = Object.keys(items)
- .filter((key) => key.startsWith('geonetwork-ui-draft-'))
+ .filter((key) => key.startsWith(LOCAL_STORAGE_RECORD_PREFIX))
.map((key) => window.localStorage.getItem(key))
.filter((draft) => draft !== null)
return from(
@@ -356,4 +348,99 @@ export class Gn4Repository implements RecordsRepositoryInterface {
)
)
}
+
+ /**
+ * Retrieves a record in XML format from the API using a unique identifier.
+ *
+ * @param {string} uniqueIdentifier - The unique identifier of the record to be retrieved.
+ * @returns {Observable} - An Observable that emits the XML content of the record as a string, or `null` if the record is not found.
+ */
+ private getRecordAsXml(uniqueIdentifier: string): Observable {
+ return this.gn4RecordsApi
+ .getRecordAs(
+ uniqueIdentifier,
+ undefined,
+ false,
+ undefined,
+ undefined,
+ undefined,
+ 'application/xml',
+ 'response',
+ undefined,
+ { httpHeaderAccept: 'text/xml,application/xml' as 'application/xml' } // this is to make sure that the response is parsed as text
+ )
+ .pipe(
+ map((response) => response.body),
+ catchError((error: HttpErrorResponse) =>
+ error.status === 404 ? of(null) : throwError(() => error)
+ )
+ )
+ }
+
+ /**
+ * Serializes a given catalog record into XML format, optionally using a specific reference record source.
+ *
+ * @param {CatalogRecord} record - The catalog record to be serialized into XML.
+ * @param {string} [referenceRecordSource] - An optional reference record source that determines the standard for serialization. If not provided, the ISO19139 standard is used.
+ * @returns {Observable} - An Observable that emits the serialized XML string of the catalog record.
+ */
+ private serializeRecordToXml(
+ record: CatalogRecord,
+ referenceRecordSource?: string
+ ): Observable {
+ // if there's a reference record, use that standard; otherwise, use iso19139
+ const converter = referenceRecordSource
+ ? findConverterForDocument(referenceRecordSource)
+ : new Iso19139Converter()
+ return from(converter.writeRecord(record, referenceRecordSource))
+ }
+
+ /**
+ * Retrieves an external record in XML format from a given URL.
+ *
+ * @param {string} recordDownloadUrl - The URL from which the external record in XML format will be downloaded.
+ * @returns {Observable} - An Observable that emits the XML content of the external record as a string.
+ */
+ private getExternalRecordAsXml(
+ recordDownloadUrl: string
+ ): Observable {
+ let headers = new HttpHeaders()
+ const responseType_ = 'text'
+ headers = headers.set('Accept', 'text/xml,application/xml')
+
+ return this.httpClient
+ .get(recordDownloadUrl, {
+ responseType: responseType_,
+ headers: headers,
+ observe: 'body',
+ })
+ .pipe(
+ map((recordAsXmlFile) => {
+ assertValidXml(recordAsXmlFile)
+
+ return recordAsXmlFile
+ })
+ )
+ }
+
+ private getLocalStorageKeyForRecord(recordId: string): string {
+ return `${LOCAL_STORAGE_RECORD_PREFIX}${recordId}`
+ }
+
+ private saveRecordToLocalStorage(recordAsXml: RecordAsXml, recordId: string) {
+ window.localStorage.setItem(
+ this.getLocalStorageKeyForRecord(recordId),
+ recordAsXml
+ )
+ }
+
+ private getRecordFromLocalStorage(recordId: string): RecordAsXml {
+ return window.localStorage.getItem(
+ this.getLocalStorageKeyForRecord(recordId)
+ )
+ }
+
+ private removeRecordFromLocalStorage(recordId: string): void {
+ window.localStorage.removeItem(this.getLocalStorageKeyForRecord(recordId))
+ }
}
diff --git a/libs/common/domain/src/lib/repository/records-repository.interface.ts b/libs/common/domain/src/lib/repository/records-repository.interface.ts
index e254465afe..aa27108a6c 100644
--- a/libs/common/domain/src/lib/repository/records-repository.interface.ts
+++ b/libs/common/domain/src/lib/repository/records-repository.interface.ts
@@ -52,6 +52,16 @@ export abstract class RecordsRepositoryInterface {
referenceRecordSource?: string
): Observable
+ /**
+ * Try to import the external record from given url. If it suceed, then it will save the record as draft and return its temporary id.
+ *
+ * @param recordDownloadUrl
+ * @returns Observable
+ */
+ abstract importRecordFromExternalFileUrlAsDraft(
+ recordDownloadUrl: string
+ ): Observable
+
/**
* @param uniqueIdentifier
* @returns Observable Returns when record is deleted
diff --git a/libs/feature/editor/src/index.ts b/libs/feature/editor/src/index.ts
index 5e6dd85211..0f0b938728 100644
--- a/libs/feature/editor/src/index.ts
+++ b/libs/feature/editor/src/index.ts
@@ -5,6 +5,7 @@ export * from './lib/+state/editor.reducer'
export * from './lib/+state/editor.actions'
export * from './lib/feature-editor.module'
export * from './lib/services/editor.service'
+export * from './lib/components/import-record/import-record.component'
export * from './lib/components/record-form/record-form.component'
export * from './lib/components/wizard/wizard.component'
export * from './lib/components/wizard-field/wizard-field.component'
diff --git a/libs/feature/editor/src/lib/components/import-record/import-record.component.css b/libs/feature/editor/src/lib/components/import-record/import-record.component.css
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/libs/feature/editor/src/lib/components/import-record/import-record.component.html b/libs/feature/editor/src/lib/components/import-record/import-record.component.html
new file mode 100644
index 0000000000..1ec816e118
--- /dev/null
+++ b/libs/feature/editor/src/lib/components/import-record/import-record.component.html
@@ -0,0 +1,43 @@
+
+
+
+
+ -
+
+ {{ menuItem.icon }} {{ menuItem.label }}
+
+
+
+
+
+
+
+
+ arrow_back
+
+ {{ externalImportBackLabel }}
+
+
+
+
+
diff --git a/libs/feature/editor/src/lib/components/import-record/import-record.component.spec.ts b/libs/feature/editor/src/lib/components/import-record/import-record.component.spec.ts
new file mode 100644
index 0000000000..f6e073745f
--- /dev/null
+++ b/libs/feature/editor/src/lib/components/import-record/import-record.component.spec.ts
@@ -0,0 +1,138 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing'
+import { ImportRecordComponent } from './import-record.component'
+import { ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core'
+import { Router } from '@angular/router'
+import { TranslateModule, TranslateService } from '@ngx-translate/core'
+import { NotificationsService } from '@geonetwork-ui/feature/notifications'
+import { RecordsRepositoryInterface } from '@geonetwork-ui/common/domain/repository/records-repository.interface'
+import { of, throwError } from 'rxjs'
+
+class MockRouter {
+ navigate = jest.fn().mockReturnValue(Promise.resolve(true))
+}
+
+class MockTranslateService {
+ instant = jest.fn((key: string) => key)
+}
+
+class MockNotificationsService {
+ showNotification = jest.fn()
+}
+
+class MockRecordsRepository {
+ importRecordFromExternalFileUrlAsDraft = jest.fn(() => of(''))
+}
+
+class MockChangeDetectorRef {
+ markForCheck = jest.fn()
+}
+
+describe('ImportRecordComponent', () => {
+ let component: ImportRecordComponent
+ let fixture: ComponentFixture
+ let mockRouter: MockRouter
+ let mockTranslateService: MockTranslateService
+ let mockNotificationsService: MockNotificationsService
+ let mockRecordsRepository: MockRecordsRepository
+ let mockChangeDetectorRef: Partial
+
+ beforeEach(async () => {
+ mockRouter = new MockRouter()
+ mockTranslateService = new MockTranslateService()
+ mockNotificationsService = new MockNotificationsService()
+ mockRecordsRepository = new MockRecordsRepository()
+ mockChangeDetectorRef = new MockChangeDetectorRef()
+
+ await TestBed.configureTestingModule({
+ imports: [ImportRecordComponent, TranslateModule.forRoot()],
+ providers: [
+ { provide: Router, useValue: mockRouter },
+ { provide: TranslateService, useClass: MockTranslateService },
+ { provide: NotificationsService, useValue: mockNotificationsService },
+ {
+ provide: RecordsRepositoryInterface,
+ useValue: mockRecordsRepository,
+ },
+ { provide: ChangeDetectorRef, useValue: mockChangeDetectorRef },
+
+ ChangeDetectorRef,
+ ],
+ })
+ .overrideComponent(ImportRecordComponent, {
+ set: {
+ changeDetection: ChangeDetectionStrategy.Default,
+ },
+ })
+ .compileComponents()
+
+ fixture = TestBed.createComponent(ImportRecordComponent)
+ component = fixture.componentInstance
+ mockChangeDetectorRef = TestBed.inject(ChangeDetectorRef)
+ fixture.detectChanges()
+ })
+
+ it('should create the component', () => {
+ expect(component).toBeTruthy()
+ })
+
+ it('should successfully import record and navigate on success', () => {
+ const mockUrl = 'https://example.com/file'
+ const mockRecordTempId = '12345'
+ mockRecordsRepository.importRecordFromExternalFileUrlAsDraft.mockReturnValue(
+ of(mockRecordTempId)
+ )
+
+ const closeMenuSpy = jest.spyOn(component.closeImportMenu, 'next')
+
+ component.importRecord(mockUrl)
+
+ expect(
+ mockRecordsRepository.importRecordFromExternalFileUrlAsDraft
+ ).toHaveBeenCalledWith(mockUrl)
+
+ expect(mockNotificationsService.showNotification).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: 'success',
+ title: 'editor.record.importFromExternalFile.success.title',
+ }),
+ 2500
+ )
+
+ expect(mockRouter.navigate).toHaveBeenCalledWith([
+ '/edit',
+ mockRecordTempId,
+ ])
+ expect(closeMenuSpy).toHaveBeenCalled()
+ })
+
+ it('should handle error when importRecord fails', () => {
+ const mockUrl = 'https://example.com/file'
+ const mockError = 'Import failed'
+ mockRecordsRepository.importRecordFromExternalFileUrlAsDraft.mockReturnValue(
+ throwError(() => mockError)
+ )
+
+ component.importRecord(mockUrl)
+
+ expect(
+ mockRecordsRepository.importRecordFromExternalFileUrlAsDraft
+ ).toHaveBeenCalledWith(mockUrl)
+
+ expect(mockNotificationsService.showNotification).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: 'error',
+ title: 'editor.record.importFromExternalFile.failure.title',
+ text: `editor.record.importFromExternalFile.failure.body ${mockError}`,
+ }),
+ 2500
+ )
+
+ expect(component.isRecordImportInProgress).toBe(false)
+ })
+
+ it('should emit closeImportMenu when closing the menu', () => {
+ const closeMenuSpy = jest.spyOn(component.closeImportMenu, 'next')
+ component.closeImportMenu.next()
+ expect(closeMenuSpy).toHaveBeenCalled()
+ })
+})
diff --git a/libs/feature/editor/src/lib/components/import-record/import-record.component.stories.ts b/libs/feature/editor/src/lib/components/import-record/import-record.component.stories.ts
new file mode 100644
index 0000000000..1681f7aacb
--- /dev/null
+++ b/libs/feature/editor/src/lib/components/import-record/import-record.component.stories.ts
@@ -0,0 +1,60 @@
+import {
+ applicationConfig,
+ componentWrapperDecorator,
+ Meta,
+ moduleMetadata,
+ StoryObj,
+} from '@storybook/angular'
+import { ImportRecordComponent } from './import-record.component'
+import { BrowserAnimationsModule } from '@angular/platform-browser/animations'
+import { importProvidersFrom } from '@angular/core'
+import { MatIconModule } from '@angular/material/icon'
+import { CommonModule } from '@angular/common'
+import { ButtonComponent, UrlInputComponent } from '@geonetwork-ui/ui/inputs'
+import { ThumbnailComponent } from '@geonetwork-ui/ui/elements'
+import { UtilI18nModule } from '@geonetwork-ui/util/i18n'
+import { TranslateModule } from '@ngx-translate/core'
+import { RecordsRepositoryInterface } from '@geonetwork-ui/common/domain/repository/records-repository.interface'
+import { of } from 'rxjs'
+
+class MockRecordsRepository {
+ importRecordFromExternalFileUrlAsDraft(url: string) {
+ return of('mockedRecordTempId')
+ }
+}
+
+export default {
+ title: 'Elements/ImportRecordComponent',
+ component: ImportRecordComponent,
+ decorators: [
+ moduleMetadata({
+ imports: [
+ CommonModule,
+ MatIconModule,
+ ButtonComponent,
+ ThumbnailComponent,
+ UrlInputComponent,
+ ],
+ providers: [
+ {
+ provide: RecordsRepositoryInterface,
+ useClass: MockRecordsRepository,
+ },
+ ],
+ }),
+ applicationConfig({
+ providers: [
+ importProvidersFrom(BrowserAnimationsModule),
+ importProvidersFrom(UtilI18nModule),
+ importProvidersFrom(TranslateModule.forRoot()),
+ ],
+ }),
+ componentWrapperDecorator(
+ (story) => `${story}
`
+ ),
+ ],
+} as Meta
+
+export const Primary: StoryObj = {
+ args: {},
+}
diff --git a/libs/feature/editor/src/lib/components/import-record/import-record.component.ts b/libs/feature/editor/src/lib/components/import-record/import-record.component.ts
new file mode 100644
index 0000000000..d4d1cb847b
--- /dev/null
+++ b/libs/feature/editor/src/lib/components/import-record/import-record.component.ts
@@ -0,0 +1,131 @@
+import {
+ ChangeDetectionStrategy,
+ ChangeDetectorRef,
+ Component,
+ EventEmitter,
+ Output,
+} from '@angular/core'
+import { MatIconModule } from '@angular/material/icon'
+import { CommonModule } from '@angular/common'
+import { ButtonComponent, UrlInputComponent } from '@geonetwork-ui/ui/inputs'
+import { ThumbnailComponent } from '@geonetwork-ui/ui/elements'
+import { TranslateService } from '@ngx-translate/core'
+import { NotificationsService } from '@geonetwork-ui/feature/notifications'
+import { RecordsRepositoryInterface } from '@geonetwork-ui/common/domain/repository/records-repository.interface'
+import { Router } from '@angular/router'
+
+interface ImportMenuItems {
+ label: string
+ icon: string
+ action: () => any
+ dataTest: string
+}
+
+type ImportMenuPage = 'mainMenu' | 'importExternalFile'
+
+@Component({
+ selector: 'gn-ui-import-record',
+ templateUrl: './import-record.component.html',
+ styleUrls: ['./import-record.component.css'],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ standalone: true,
+ imports: [
+ CommonModule,
+ MatIconModule,
+ ButtonComponent,
+ ThumbnailComponent,
+ UrlInputComponent,
+ ],
+})
+export class ImportRecordComponent {
+ @Output() closeImportMenu = new EventEmitter()
+
+ importMenuItems: ImportMenuItems[] = [
+ {
+ label: this.translateService.instant('dashboard.importRecord.useModel'),
+ icon: 'highlight',
+ action: () => null,
+ dataTest: 'useAModelButton',
+ },
+ {
+ label: this.translateService.instant(
+ 'dashboard.importRecord.importExternal'
+ ),
+ icon: 'cloud_download',
+ action: this.displayImportExternal.bind(this),
+ dataTest: 'importFromUrlButton',
+ },
+ ]
+
+ isRecordImportInProgress = false
+
+ sectionDisplayed: ImportMenuPage = 'mainMenu'
+
+ externalImportBackLabel = this.translateService.instant(
+ 'dashboard.importRecord.importExternalLabel'
+ )
+
+ constructor(
+ private router: Router,
+ private translateService: TranslateService,
+ private cdr: ChangeDetectorRef,
+ private notificationsService: NotificationsService,
+ private recordsRepository: RecordsRepositoryInterface
+ ) {}
+
+ displayMainMenu() {
+ this.sectionDisplayed = 'mainMenu'
+ this.cdr.markForCheck()
+ }
+
+ displayImportExternal() {
+ this.sectionDisplayed = 'importExternalFile'
+ this.cdr.markForCheck()
+ }
+
+ importRecord(url: string) {
+ this.isRecordImportInProgress = true
+
+ this.recordsRepository
+ .importRecordFromExternalFileUrlAsDraft(url)
+ .subscribe({
+ next: (recordTempId) => {
+ if (recordTempId) {
+ this.notificationsService.showNotification(
+ {
+ type: 'success',
+ title: this.translateService.instant(
+ 'editor.record.importFromExternalFile.success.title'
+ ),
+ text: `${this.translateService.instant(
+ 'editor.record.importFromExternalFile.success.body'
+ )}`,
+ },
+ 2500
+ )
+
+ this.router
+ .navigate(['/edit', recordTempId])
+ .catch((err) => console.error(err))
+ }
+ this.closeImportMenu.next()
+ },
+ error: (error) => {
+ this.notificationsService.showNotification(
+ {
+ type: 'error',
+ title: this.translateService.instant(
+ 'editor.record.importFromExternalFile.failure.title'
+ ),
+ text: `${this.translateService.instant(
+ 'editor.record.importFromExternalFile.failure.body'
+ )} ${error}`,
+ },
+ 2500
+ )
+ this.isRecordImportInProgress = false
+ this.cdr.markForCheck()
+ },
+ })
+ }
+}
diff --git a/libs/ui/inputs/src/index.ts b/libs/ui/inputs/src/index.ts
index 173648678f..2fd16aa78e 100644
--- a/libs/ui/inputs/src/index.ts
+++ b/libs/ui/inputs/src/index.ts
@@ -19,6 +19,7 @@ export * from './lib/star-toggle/star-toggle.component'
export * from './lib/text-area/text-area.component'
export * from './lib/text-input/text-input.component'
export * from './lib/ui-inputs.module'
+export * from './lib/url-input/url-input.component'
export * from './lib/viewport-intersector/viewport-intersector.component'
export * from './lib/previous-next-buttons/previous-next-buttons.component'
export * from './lib/switch-toggle/switch-toggle.component'
diff --git a/tools/e2e/commands.ts b/tools/e2e/commands.ts
index 736b225890..9aa0962614 100644
--- a/tools/e2e/commands.ts
+++ b/tools/e2e/commands.ts
@@ -144,7 +144,7 @@ Cypress.Commands.add('clearRecordDrafts', () => {
cy.window().then((window) => {
const items = { ...window.localStorage }
const draftKeys = Object.keys(items).filter((key) =>
- key.startsWith('geonetwork-ui-draft-')
+ key.startsWith('me-record-draft-')
)
draftKeys.forEach((key) => window.localStorage.removeItem(key))
cy.log(`Cleared ${draftKeys.length} draft(s).`)
diff --git a/translations/de.json b/translations/de.json
index 4787c1cbca..687d1a9e01 100644
--- a/translations/de.json
+++ b/translations/de.json
@@ -24,6 +24,10 @@
"dashboard.catalog.discussion": "Diskussion",
"dashboard.catalog.thesaurus": "Thesaurus",
"dashboard.createRecord": "Neuer Eintrag",
+ "dashboard.importRecord": "",
+ "dashboard.importRecord.importExternal": "",
+ "dashboard.importRecord.importExternalLabel": "",
+ "dashboard.importRecord.useModel": "",
"dashboard.labels.catalog": "Katalog",
"dashboard.labels.mySpace": "Mein Bereich",
"dashboard.records.all": "Katalog",
@@ -250,6 +254,10 @@
"editor.record.form.temporalExtents.date": "Datum",
"editor.record.form.temporalExtents.range": "Datumsbereich",
"editor.record.form.updateFrequency.planned": "Die Daten sollten regelmäßig aktualisiert werden.",
+ "editor.record.importFromExternalFile.failure.body": "",
+ "editor.record.importFromExternalFile.failure.title": "",
+ "editor.record.importFromExternalFile.success.body": "",
+ "editor.record.importFromExternalFile.success.title": "",
"editor.record.loadError.body": "Der Datensatz konnte nicht geladen werden:",
"editor.record.loadError.closeMessage": "Verstanden",
"editor.record.loadError.title": "Fehler beim Laden des Datensatzes",
diff --git a/translations/en.json b/translations/en.json
index 5ce2162159..147283cfc8 100644
--- a/translations/en.json
+++ b/translations/en.json
@@ -24,6 +24,10 @@
"dashboard.catalog.discussion": "Discussion",
"dashboard.catalog.thesaurus": "Thesaurus",
"dashboard.createRecord": "New record",
+ "dashboard.importRecord": "Import",
+ "dashboard.importRecord.importExternal": "Import an external file",
+ "dashboard.importRecord.importExternalLabel": "External file URL",
+ "dashboard.importRecord.useModel": "Use a model",
"dashboard.labels.catalog": "Catalog",
"dashboard.labels.mySpace": "My space",
"dashboard.records.all": "Metadata records",
@@ -250,6 +254,10 @@
"editor.record.form.temporalExtents.date": "Date",
"editor.record.form.temporalExtents.range": "Date range",
"editor.record.form.updateFrequency.planned": "The data should be updated regularly.",
+ "editor.record.importFromExternalFile.failure.body": "Failure",
+ "editor.record.importFromExternalFile.failure.title": "The import of the record has failed: ",
+ "editor.record.importFromExternalFile.success.body": "Import succesful",
+ "editor.record.importFromExternalFile.success.title": "The record has been succefuly imported.",
"editor.record.loadError.body": "The record could not be loaded:",
"editor.record.loadError.closeMessage": "Understood",
"editor.record.loadError.title": "Error loading record",
diff --git a/translations/es.json b/translations/es.json
index cb9c7a2ef0..4cba414505 100644
--- a/translations/es.json
+++ b/translations/es.json
@@ -24,6 +24,10 @@
"dashboard.catalog.discussion": "",
"dashboard.catalog.thesaurus": "",
"dashboard.createRecord": "",
+ "dashboard.importRecord": "",
+ "dashboard.importRecord.importExternal": "",
+ "dashboard.importRecord.importExternalLabel": "",
+ "dashboard.importRecord.useModel": "",
"dashboard.labels.catalog": "Catálogo",
"dashboard.labels.mySpace": "Mi espacio",
"dashboard.records.all": "Catálogo",
@@ -250,6 +254,10 @@
"editor.record.form.temporalExtents.date": "",
"editor.record.form.temporalExtents.range": "",
"editor.record.form.updateFrequency.planned": "",
+ "editor.record.importFromExternalFile.failure.body": "",
+ "editor.record.importFromExternalFile.failure.title": "",
+ "editor.record.importFromExternalFile.success.body": "",
+ "editor.record.importFromExternalFile.success.title": "",
"editor.record.loadError.body": "",
"editor.record.loadError.closeMessage": "",
"editor.record.loadError.title": "",
diff --git a/translations/fr.json b/translations/fr.json
index 80868136e8..24c069d9b0 100644
--- a/translations/fr.json
+++ b/translations/fr.json
@@ -24,6 +24,10 @@
"dashboard.catalog.discussion": "Discussions",
"dashboard.catalog.thesaurus": "Thesaurus",
"dashboard.createRecord": "Nouvel enregistrement",
+ "dashboard.importRecord": "Importer",
+ "dashboard.importRecord.importExternal": "Importer une fiche externe",
+ "dashboard.importRecord.importExternalLabel": "URL de la fiche externe",
+ "dashboard.importRecord.useModel": "Utiliser un modele",
"dashboard.labels.catalog": "Catalogue",
"dashboard.labels.mySpace": "Mon espace",
"dashboard.records.all": "Catalogue",
@@ -250,6 +254,10 @@
"editor.record.form.temporalExtents.date": "Date concernée",
"editor.record.form.temporalExtents.range": "Période concernée",
"editor.record.form.updateFrequency.planned": "Ces données doivent être mise à jour régulièrement.",
+ "editor.record.importFromExternalFile.failure.body": "Une erreur est survenue pendant l'import de la fiche: ",
+ "editor.record.importFromExternalFile.failure.title": "Erreur",
+ "editor.record.importFromExternalFile.success.body": "L'import de la fiche de metadonnée à été realisée avec succès.",
+ "editor.record.importFromExternalFile.success.title": "Import reussi",
"editor.record.loadError.body": "La fiche n'a pas pu être chargée :",
"editor.record.loadError.closeMessage": "Compris",
"editor.record.loadError.title": "Erreur lors du chargement",
diff --git a/translations/it.json b/translations/it.json
index 7d048701dd..21c37eb722 100644
--- a/translations/it.json
+++ b/translations/it.json
@@ -24,6 +24,10 @@
"dashboard.catalog.discussion": "",
"dashboard.catalog.thesaurus": "",
"dashboard.createRecord": "Crea un record",
+ "dashboard.importRecord": "",
+ "dashboard.importRecord.importExternal": "",
+ "dashboard.importRecord.importExternalLabel": "",
+ "dashboard.importRecord.useModel": "",
"dashboard.labels.catalog": "Catalogo",
"dashboard.labels.mySpace": "Il mio spazio",
"dashboard.records.all": "Catalogo",
@@ -250,6 +254,10 @@
"editor.record.form.temporalExtents.date": "",
"editor.record.form.temporalExtents.range": "",
"editor.record.form.updateFrequency.planned": "",
+ "editor.record.importFromExternalFile.failure.body": "",
+ "editor.record.importFromExternalFile.failure.title": "",
+ "editor.record.importFromExternalFile.success.body": "",
+ "editor.record.importFromExternalFile.success.title": "",
"editor.record.loadError.body": "",
"editor.record.loadError.closeMessage": "",
"editor.record.loadError.title": "",
diff --git a/translations/nl.json b/translations/nl.json
index a0db9c7932..9e773cd3d1 100644
--- a/translations/nl.json
+++ b/translations/nl.json
@@ -24,6 +24,10 @@
"dashboard.catalog.discussion": "",
"dashboard.catalog.thesaurus": "",
"dashboard.createRecord": "",
+ "dashboard.importRecord": "",
+ "dashboard.importRecord.importExternal": "",
+ "dashboard.importRecord.importExternalLabel": "",
+ "dashboard.importRecord.useModel": "",
"dashboard.labels.catalog": "Catalogus",
"dashboard.labels.mySpace": "Mijn ruimte",
"dashboard.records.all": "Catalogus",
@@ -250,6 +254,10 @@
"editor.record.form.temporalExtents.date": "",
"editor.record.form.temporalExtents.range": "",
"editor.record.form.updateFrequency.planned": "",
+ "editor.record.importFromExternalFile.failure.body": "",
+ "editor.record.importFromExternalFile.failure.title": "",
+ "editor.record.importFromExternalFile.success.body": "",
+ "editor.record.importFromExternalFile.success.title": "",
"editor.record.loadError.body": "",
"editor.record.loadError.closeMessage": "",
"editor.record.loadError.title": "",
diff --git a/translations/pt.json b/translations/pt.json
index 28f3337e93..9f29a32d97 100644
--- a/translations/pt.json
+++ b/translations/pt.json
@@ -24,6 +24,10 @@
"dashboard.catalog.discussion": "",
"dashboard.catalog.thesaurus": "",
"dashboard.createRecord": "",
+ "dashboard.importRecord": "",
+ "dashboard.importRecord.importExternal": "",
+ "dashboard.importRecord.importExternalLabel": "",
+ "dashboard.importRecord.useModel": "",
"dashboard.labels.catalog": "Catálogo",
"dashboard.labels.mySpace": "Meu espaço",
"dashboard.records.all": "Catálogo",
@@ -250,6 +254,10 @@
"editor.record.form.temporalExtents.date": "",
"editor.record.form.temporalExtents.range": "",
"editor.record.form.updateFrequency.planned": "",
+ "editor.record.importFromExternalFile.failure.body": "",
+ "editor.record.importFromExternalFile.failure.title": "",
+ "editor.record.importFromExternalFile.success.body": "",
+ "editor.record.importFromExternalFile.success.title": "",
"editor.record.loadError.body": "",
"editor.record.loadError.closeMessage": "",
"editor.record.loadError.title": "",
diff --git a/translations/sk.json b/translations/sk.json
index 7de804bd25..57e57cd4b5 100644
--- a/translations/sk.json
+++ b/translations/sk.json
@@ -24,6 +24,10 @@
"dashboard.catalog.discussion": "",
"dashboard.catalog.thesaurus": "",
"dashboard.createRecord": "",
+ "dashboard.importRecord": "",
+ "dashboard.importRecord.importExternal": "",
+ "dashboard.importRecord.importExternalLabel": "",
+ "dashboard.importRecord.useModel": "",
"dashboard.labels.catalog": "Katalóg",
"dashboard.labels.mySpace": "Môj priestor",
"dashboard.records.all": "Katalóg",
@@ -250,6 +254,10 @@
"editor.record.form.temporalExtents.date": "",
"editor.record.form.temporalExtents.range": "",
"editor.record.form.updateFrequency.planned": "",
+ "editor.record.importFromExternalFile.failure.body": "",
+ "editor.record.importFromExternalFile.failure.title": "",
+ "editor.record.importFromExternalFile.success.body": "",
+ "editor.record.importFromExternalFile.success.title": "",
"editor.record.loadError.body": "",
"editor.record.loadError.closeMessage": "",
"editor.record.loadError.title": "",