2023-02-13 18:39:08 +01:00
import { ChangeDetectorRef , Component , Input , ViewChild } from '@angular/core' ;
import { interval , Subscriber , Subscription } from "rxjs" ;
2022-05-19 11:15:52 +02:00
import { HttpClient , HttpHeaders } from "@angular/common/http" ;
2022-05-24 13:24:54 +02:00
import { AbstractControl , ValidatorFn , Validators } from "@angular/forms" ;
2022-05-19 11:15:52 +02:00
import { Location } from '@angular/common' ;
import { COOKIE } from "../../login/utils/helper.class" ;
import { Router } from "@angular/router" ;
2022-05-23 11:54:34 +02:00
import { properties } from "../../../../environments/environment" ;
2023-02-13 18:39:08 +01:00
import { delay , repeat , startWith , switchMap } from "rxjs/operators" ;
2022-11-11 12:56:58 +01:00
import { StringUtils } from "../string-utils.class" ;
2023-02-13 18:39:08 +01:00
import { HelperFunctions } from "../HelperFunctions.class" ;
2022-05-19 11:15:52 +02:00
declare var UIkit ;
@Component ( {
selector : 'egi-transfer-data' ,
2022-10-17 11:42:01 +02:00
templateUrl : './transferData.component.html' ,
styles : [ `
/*Arrow*/
/ * . s o u r c e : f i r s t - c h i l d : : a f t e r {
content : "" ;
font - size : 20px ;
font - weight : 600 ;
text - align : center ;
padding - bottom : 5 % ;
position : absolute ;
background - image : url ( '/assets/arrow.svg' ) ;
right : - 16 % ;
top : 33 % ;
width : 20 % ;
background - size : contain ;
background - repeat : no - repeat ;
background - position : bottom ;
} * /
` ]
2022-05-19 11:15:52 +02:00
} )
export class EGIDataTransferComponent {
subscriptions = [ ] ;
2023-02-13 18:39:08 +01:00
statusSub : Subscription = null ;
2022-10-14 12:32:11 +02:00
accessToken = null ;
2022-05-19 11:15:52 +02:00
@Input ( ) dois ;
2022-11-11 10:20:02 +01:00
loginURL = properties . eoscDataTransferLoginUrl ;
2022-10-05 15:52:04 +02:00
sourceUrls = [ ] ;
2022-05-19 11:15:52 +02:00
selectedSourceUrl = null ;
2022-11-11 12:56:58 +01:00
destinationUrl = "" ;
destinationAuthUser = "" ;
destinationAuthPass = "" ;
2022-10-05 15:52:04 +02:00
destinationPath = "" ;
2023-01-05 18:14:26 +01:00
destinationOptions = null ; //properties.eoscDataTransferDestinations.map(dest => {return {"label": dest.destination, "value": dest}});
2023-01-04 12:27:49 +01:00
selectedDestination : { kind : string , destination : string , description : string , authType : 'token' | 'password' | 'keys' ,
canBrowse : boolean , transferWith : string } = null ;
2022-10-05 15:52:04 +02:00
folders = { } ;
files = { } ;
2022-05-19 11:15:52 +02:00
downloadElements = null ;
@Input ( ) isOpen = false ;
2023-01-05 18:14:26 +01:00
// @Input() selectedDestinationId = "dcache";
2022-05-25 11:52:00 +02:00
@ViewChild ( 'egiTransferModal' ) egiTransferModal ;
2022-10-05 15:52:04 +02:00
APIURL = properties . eoscDataTransferAPI ;
2023-02-13 18:39:08 +01:00
// status: "loading" | "success" | "errorParser" | "errorUser" | "errorTransfer" | "init" | "canceled" = "init";
status : "unused" | "active" | "succeeded" | "failed" | "canceled" | "errorParser" | "errorUser" | "init" = "init" ;
// unused("unused"),
// active("active"),
// succeeded("succeeded"),
// failed("failed"),
// canceled("canceled");
2022-05-19 11:15:52 +02:00
message ;
2022-05-23 11:54:34 +02:00
doiPrefix = properties . doiURL ;
2022-11-11 12:56:58 +01:00
pathValidators = [ Validators . required , this . pathValidator ( ) /*StringUtils.urlValidator()*/ ] ;
URLValidators = [ Validators . required , StringUtils . urlValidator ( ) ] ;
2022-09-21 16:29:46 +02:00
jobId = null ;
statusMessage = null ;
jobStatus ;
2023-02-13 18:39:08 +01:00
constructor ( private http : HttpClient , private location : Location , private _router : Router , private cdr : ChangeDetectorRef ) {
2022-05-19 11:15:52 +02:00
}
2022-05-25 11:52:00 +02:00
ngAfterViewInit() {
2022-05-19 11:15:52 +02:00
if ( this . isOpen ) {
this . open ( ) ;
}
}
ngOnDestroy() {
this . subscriptions . forEach ( subscription = > {
if ( subscription instanceof Subscriber ) {
subscription . unsubscribe ( ) ;
}
} ) ;
2023-02-13 18:39:08 +01:00
if ( this . statusSub && this . statusSub instanceof Subscriber ) {
this . statusSub . unsubscribe ( ) ;
}
2022-05-19 11:15:52 +02:00
}
open ( ) {
2022-10-14 12:32:11 +02:00
this . accessToken = COOKIE . getCookie ( "EGIAccessToken" ) ;
if ( this . accessToken ) {
2023-01-05 18:14:26 +01:00
// if (this.selectedDestinationId) {
// for (let option of this.destinationOptions) {
// if (this.selectedDestinationId == option.value.destination) {
// this.selectedDestination = option.value;
// }
// }
// } else {
// this.selectedDestination = this.destinationOptions[0].value;
// }
let headers = new HttpHeaders ( { 'Authorization' : 'Bearer ' + this . accessToken } ) ;
this . subscriptions . push ( this . http . get ( this . APIURL + "/storage/types" , { headers : headers } ) . subscribe (
( res : Array < any > ) = > {
this . destinationOptions = res . map ( dest = > { return { "label" : dest . destination , "value" : dest } } ) ;
this . selectedDestination = res [ 0 ] ;
2022-05-23 11:54:34 +02:00
}
2023-01-05 18:14:26 +01:00
) ) ;
2022-10-14 12:32:11 +02:00
for ( let doi of this . dois ) {
this . sourceUrls . push ( this . doiPrefix + doi ) ;
}
try {
this . sourceUrls . sort ( function ( a , b ) {
return Number ( b . split ( "zenodo." ) [ 1 ] ) - Number ( a . split ( "zenodo." ) [ 1 ] ) ;
} ) ;
} catch ( e ) {
2022-10-05 15:52:04 +02:00
}
2022-10-14 12:32:11 +02:00
this . selectedSourceUrl = this . sourceUrls [ 0 ] ;
2022-10-05 15:52:04 +02:00
2022-10-14 12:32:11 +02:00
this . parse ( ) ;
}
2022-05-19 11:15:52 +02:00
this . isOpen = true ;
2022-05-25 11:52:00 +02:00
this . egiTransferModal . cancelButton = false ;
2022-10-17 11:42:01 +02:00
this . egiTransferModal . okButton = true ;
this . egiTransferModal . okButtonText = ">> Transfer" ;
2022-05-25 16:56:00 +02:00
this . egiTransferModal . alertTitle = "EOSC data transfer service [demo]" ;
2022-11-11 10:04:52 +01:00
this . egiTransferModal . stayOpen = true ;
2022-09-21 16:29:46 +02:00
this . init ( ) ;
2023-03-03 14:12:26 +01:00
if ( typeof document !== 'undefined' ) {
this . egiTransferModal . open ( ) ;
}
2022-05-19 11:15:52 +02:00
}
close ( ) {
2022-05-25 11:52:00 +02:00
if ( this . isOpen ) {
this . isOpen = false ;
this . egiTransferModal . cancel ( ) ;
}
2022-05-19 11:15:52 +02:00
// this.downloadElements = [];
2022-09-21 16:29:46 +02:00
this . init ( ) ;
if ( this . _router . url . indexOf ( "&egiTransfer" ) ) {
this . location . go ( this . _router . url . split ( "&egiTransfer" ) [ 0 ] ) ;
}
}
init ( ) {
2022-05-19 11:15:52 +02:00
this . destinationPath = "" ;
2023-01-05 18:14:26 +01:00
// this.selectedDestination = this.destinationOptions[0].value;
2022-05-19 11:15:52 +02:00
this . selectedSourceUrl = this . sourceUrls [ 0 ] ;
this . message = null ;
this . status = "init" ;
2022-09-21 16:29:46 +02:00
this . jobId = null ;
2023-02-13 18:39:08 +01:00
// this.statusMessage = null;
this . statusMessage = "primary" ;
2022-05-19 11:15:52 +02:00
}
checkin ( ) {
2022-05-30 12:56:14 +02:00
window . location . href = this . loginURL + "?redirect=" + encodeURIComponent ( window . location . href + ( window . location . href . indexOf ( "&egiTransfer=t" ) != - 1 ? "" : "&egiTransfer=t" ) ) ;
2022-05-19 11:15:52 +02:00
}
parse ( ) {
2023-02-13 18:39:08 +01:00
this . status = "active" ;
2022-05-23 11:54:34 +02:00
this . message = null ;
this . downloadElements = [ ] ;
2022-11-11 11:28:07 +01:00
let headers = new HttpHeaders ( { 'Authorization' : 'Bearer ' + this . accessToken } ) ;
this . subscriptions . push ( this . http . get ( this . APIURL + "/parser?doi=" + encodeURIComponent ( this . selectedSourceUrl ) , { headers : headers } ) . subscribe (
2022-05-19 11:15:52 +02:00
res = > {
2023-02-13 18:39:08 +01:00
// res['elements'].forEach(element => {
// if(element.downloadUrl && element.name) {
// this.downloadElements.push(element);
// }
// })
2022-05-24 13:24:54 +02:00
this . downloadElements = res [ 'elements' ]
2022-05-23 11:54:34 +02:00
// console.log(this.downloadElements)
2023-02-13 18:39:08 +01:00
this . status = "init" ;
2022-05-19 11:15:52 +02:00
} , error = > {
this . status = "errorParser" ;
2023-03-03 14:12:26 +01:00
this . message = error . error && error . error . id && error . error . id == 'doiNotSupported' ? 'DOI not supported.' : ( error . error && error . error . description && error . error . description ? ( error . error . description + '.' ) : 'Error parsing information.' ) ;
2023-02-13 18:39:08 +01:00
this . statusMessage = "danger" ;
2022-11-11 11:28:07 +01:00
/ * U I k i t . n o t i f i c a t i o n ( t h i s . m e s s a g e , {
2022-05-19 11:15:52 +02:00
status : 'error' ,
2022-05-23 11:54:34 +02:00
timeout : 3000 ,
2022-05-19 11:15:52 +02:00
pos : 'bottom-right'
2022-11-11 11:28:07 +01:00
} ) ; * /
2022-05-19 11:15:52 +02:00
}
) ) ;
}
transfer() {
2022-05-25 11:52:00 +02:00
// console.log(this.selectedDestination)
2023-02-13 18:39:08 +01:00
this . status = "active" ;
this . message = "" ;
2022-10-14 12:32:11 +02:00
let headers = new HttpHeaders ( { 'Authorization' : 'Bearer ' + this . accessToken } ) ;
2023-01-04 12:27:49 +01:00
this . subscriptions . push ( this . http . get ( this . APIURL + "/user/info?dest=" + this . selectedDestination . destination , { headers : headers } ) . subscribe (
2022-05-19 11:15:52 +02:00
res = > {
2022-05-23 11:54:34 +02:00
// console.log(res)
2022-05-19 11:15:52 +02:00
let body = {
"files" : [ ] ,
"params" : {
"priority" : 0 ,
"overwrite" : true ,
"retry" : 3
}
} ;
2022-05-23 11:54:34 +02:00
// console.log(this.selectedDestination)
2022-05-19 11:15:52 +02:00
for ( let element of this . downloadElements ) {
let file = {
2022-05-23 11:54:34 +02:00
"sources" : [ element [ 'downloadUrl' ] ] ,
2022-11-11 12:56:58 +01:00
"destinations" : [ this . destinationUrl + this . destinationPath + element . name ] ,
2022-05-23 11:54:34 +02:00
2022-05-19 11:15:52 +02:00
} ;
2022-05-23 11:54:34 +02:00
//TODO priority? checksum? filesize?
// "filesize": element['size']
2022-05-19 11:15:52 +02:00
body . files . push ( file ) ;
}
2022-10-14 12:32:11 +02:00
let headers = new HttpHeaders ( { 'Authorization' : 'Bearer ' + this . accessToken } ) ;
2023-01-04 12:27:49 +01:00
if ( this . selectedDestination . authType != "token" && this . destinationAuthPass . length > 0 && this . destinationAuthUser . length > 0 ) {
2022-11-11 12:56:58 +01:00
headers = new HttpHeaders ( { 'Authorization' : 'Bearer ' + this . accessToken ,
'Authorization-Storage' : btoa ( this . destinationAuthUser + ':' + this . destinationAuthPass ) } ) ;
}
2022-08-08 11:47:14 +02:00
this . subscriptions . push ( this . http . post ( this . APIURL + "/transfers" , body , { headers : headers } ) . subscribe (
2022-05-19 11:15:52 +02:00
res = > {
2022-05-23 11:54:34 +02:00
// console.log(res)
2023-02-13 18:39:08 +01:00
// UIkit.notification('Data transfer has began! ', {
// status: 'success',
// timeout: 6000,
// pos: 'bottom-right'
// });
2022-09-21 16:29:46 +02:00
this . jobId = res [ 'jobId' ] ;
2023-02-13 18:39:08 +01:00
this . getStatus ( ) ;
this . status = "active" ;
this . statusMessage = "primary" ;
// this.egiTransferModal.okButton = false;
2022-05-19 11:15:52 +02:00
this . message = `
2022-08-08 11:47:14 +02:00
<!-- div class = "uk-text-large uk-margin-bottom" > Data transfer has began ! < / d i v - - >
2023-01-04 12:27:49 +01:00
< div > Transfer of ` + this.downloadElements.length + ` files to ` +this.selectedDestination.description+ ` has began . ` ;
2022-08-08 11:47:14 +02:00
/ * t h i s . m e s s a g e + = ` < d i v c l a s s = " u k - o v e r f l o w - a u t o u k - h e i g h t - x s m a l l " >
2022-05-19 11:15:52 +02:00
< ul >
` ;
// TODO LATER we can call status for each file and see if the transfer has been complete
for ( let element of this . downloadElements ) {
2022-05-23 11:54:34 +02:00
// console.log(element)
// this.message += ` <li> <a href="`+ this.selectedDestination.webpage + this.destinationPath + element.name + `" target="_blank">`+ element.name+ `</a></li> `;
this . message += ` <li> ` + element . name + ` </li> ` ;
2022-05-19 11:15:52 +02:00
}
this . message += `
< / ul >
2022-05-24 13:24:54 +02:00
< / div >
2022-08-08 11:47:14 +02:00
< / div > ` */
this . message += `
2022-05-19 11:15:52 +02:00
< / div > `
2023-02-13 18:39:08 +01:00
this . cdr . detectChanges ( ) ;
HelperFunctions . scrollToId ( "transferAlert" ) ;
// this.getStatus(true)
2022-05-19 11:15:52 +02:00
} , error = > {
2023-02-13 18:39:08 +01:00
this . status = "failed" ;
this . message = "Files could not be transfered." ;
this . statusMessage = "danger" ;
// UIkit.notification("Couldn't transfer files", {
// status: 'error',
// timeout: 6000,
// pos: 'bottom-right'
// });
2022-05-19 11:15:52 +02:00
}
) ) ;
} , error = > {
this . status = "errorUser" ;
2023-02-13 18:39:08 +01:00
this . message = "User cannot be authenticated." ;
this . statusMessage = "danger" ;
// UIkit.notification("User can't be authenticated!", {
// status: 'error',
// timeout: 6000,
// pos: 'bottom-right'
// });
2022-05-19 11:15:52 +02:00
}
) ) ;
}
2022-09-21 16:29:46 +02:00
getStatus ( updateTransferMessage :boolean = false ) {
if ( this . jobId ) {
2022-10-14 12:32:11 +02:00
let headers = new HttpHeaders ( { 'Authorization' : 'Bearer ' + this . accessToken } ) ;
2022-09-21 16:29:46 +02:00
let source = this . http . get ( this . APIURL + "/transfer/" + this . jobId , { headers : headers } ) . pipe ( delay ( 5000 ) ) ;
2023-02-13 18:39:08 +01:00
let source2 = interval ( 5000 ) // request status every 5 secs
. pipe (
startWith ( 2000 ) , // first call after 2 secs
switchMap ( ( ) = > this . http . get ( this . APIURL + "/transfer/" + this . jobId , { headers : headers } ) )
) ;
2022-09-21 16:29:46 +02:00
2023-02-13 18:39:08 +01:00
// this.subscriptions.push(source.pipe(repeat(3)).subscribe(
this . statusSub = source2 . subscribe (
( res : any ) = > {
if ( this . status != res . jobState ) {
this . status = res . jobState ;
this . jobStatus = res ;
this . message = `
<!-- div class = "uk-text-large uk-margin-bottom" > Data transfer has began ! < / d i v - - >
< div > Transfer of ` + this.downloadElements.length + ` files to ` + this.selectedDestination.description + ` has began . ` ;
/ * t h i s . m e s s a g e + = ` < d i v c l a s s = " u k - o v e r f l o w - a u t o u k - h e i g h t - x s m a l l " >
< ul >
` ;
// TODO LATER we can call status for each file and see if the transfer has been complete
for ( let element of this . downloadElements ) {
// console.log(element)
// this.message += ` <li> <a href="`+ this.selectedDestination.webpage + this.destinationPath + element.name + `" target="_blank">`+ element.name+ `</a></li> `;
this . message += ` <li> ` + element . name + ` </li> ` ;
}
this . message += `
< / ul >
< / div >
< / div > ` */
this . message += `
< / div > ` ;
this . statusMessage = "primary" ;
this . statusMessage = res [ 'jobState' ] + ( res [ 'reason' ] ? ( " :" + res [ 'reason' ] ) : "" ) ;
if ( this . status == "succeeded" ) {
this . message = "Transfer successfully completed!" ;
this . statusMessage = "success" ;
this . statusSub . unsubscribe ( ) ;
// UIkit.notification('Transfer successfully completed! ', {
// status: 'success',
// timeout: 6000,
// pos: 'bottom-right'
// });
} else if ( this . status == "failed" ) {
this . message = "Transfer failed." ;
this . statusMessage = "danger" ;
this . statusSub . unsubscribe ( ) ;
// UIkit.notification('Transfer failed', {
// status: 'danger',
// timeout: 6000,
// pos: 'bottom-right'
// });
} else if ( this . status != "active" ) {
this . message = "Transfer completed with status: <b>" + this . status + "</b>." ;
this . statusMessage = "warning" ;
this . statusSub . unsubscribe ( ) ;
// UIkit.notification('Transfer completed with status: '+this.status, {
// status: 'warning',
// timeout: 6000,
// pos: 'bottom-right'
// });
}
}
2022-09-21 16:29:46 +02:00
} , error = > {
2023-02-13 18:39:08 +01:00
this . status = "failed" ;
this . message = "Status of the transfer could not be retrieved." ;
this . statusMessage = "danger" ;
this . statusSub . unsubscribe ( ) ;
// UIkit.notification("Couldn't get status", {
// status: 'error',
// timeout: 6000,
// pos: 'bottom-right'
// });
2022-09-21 16:29:46 +02:00
}
2023-02-13 18:39:08 +01:00
) ;
2022-09-21 16:29:46 +02:00
}
}
cancel ( ) {
if ( this . jobId ) {
2022-10-14 12:32:11 +02:00
let headers = new HttpHeaders ( { 'Authorization' : 'Bearer ' + this . accessToken } ) ;
2022-09-21 16:29:46 +02:00
this . subscriptions . push ( this . http . delete ( this . APIURL + "/transfer/" + this . jobId , { headers : headers } ) . subscribe (
res = > {
this . jobStatus = res ;
this . statusMessage = res [ 'jobState' ] + ( res [ 'reason' ] ? ( " :" + res [ 'reason' ] ) : "" ) ;
this . jobId = null ;
this . status = "canceled" ;
}
) ) ;
}
}
2022-10-05 15:52:04 +02:00
hasBrowse ( ) {
2022-10-14 12:32:11 +02:00
let headers = new HttpHeaders ( { 'Authorization' : 'Bearer ' + this . accessToken } ) ;
2023-01-04 12:27:49 +01:00
this . subscriptions . push ( this . http . get ( this . APIURL + "/storage/info?dest=" + this . selectedDestination . destination + "&seUrl=" + encodeURIComponent ( this . destinationUrl + this . destinationPath ) , { headers : headers } ) . subscribe (
2022-09-21 16:29:46 +02:00
res = > {
console . log ( res ) ;
}
) ) ;
2022-10-05 15:52:04 +02:00
}
getFolder ( folderPath ) {
2022-10-14 12:32:11 +02:00
//TODO is this necessary?
let headers = new HttpHeaders ( { 'Authorization' : 'Bearer ' + this . accessToken } ) ;
2023-01-04 12:27:49 +01:00
this . subscriptions . push ( this . http . get ( this . APIURL + "/storage/folder?dest=" + this . selectedDestination . destination + "&seUrl=" + encodeURIComponent ( this . destinationUrl + folderPath ) , { headers : headers } ) . subscribe (
2022-10-05 15:52:04 +02:00
res = > {
this . folders [ folderPath ] = res ;
this . folders [ folderPath ] [ 'isOpen' ] = true ;
}
) ) ;
}
browseFolder ( folderPath ) {
if ( this . folders [ folderPath ] ) {
this . folders [ folderPath ] . isOpen = ! this . folders [ folderPath ] . isOpen ;
return ;
}
this . getFolder ( folderPath ) ;
2022-10-14 12:32:11 +02:00
let headers = new HttpHeaders ( { 'Authorization' : 'Bearer ' + this . accessToken } ) ;
2023-01-04 12:27:49 +01:00
this . subscriptions . push ( this . http . get ( this . APIURL + "/storage/folder/list?dest=" + this . selectedDestination . destination + "&folderUrl=" + encodeURIComponent ( this . destinationUrl + folderPath ) , { headers : headers } ) . subscribe (
2022-10-05 15:52:04 +02:00
res = > {
this . files [ folderPath ] = res [ 'elements' ] ;
}
) ) ;
}
createFolder ( ) {
2022-10-14 12:32:11 +02:00
let headers = new HttpHeaders ( { 'Authorization' : 'Bearer ' + this . accessToken } ) ;
2023-01-04 12:27:49 +01:00
this . subscriptions . push ( this . http . post ( this . APIURL + "/storage/folder?dest=" + this . selectedDestination . destination + "&seUrl=" +
2022-11-11 12:56:58 +01:00
encodeURIComponent ( this . destinationUrl + this . destinationPath + "test1/" ) , { headers : headers } ) . subscribe (
2022-10-05 15:52:04 +02:00
res = > {
console . log ( res ) ;
}
) ) ;
}
deleteFolder ( ) {
2022-10-14 12:32:11 +02:00
let headers = new HttpHeaders ( { 'Authorization' : 'Bearer ' + this . accessToken } ) ;
2023-01-04 12:27:49 +01:00
this . subscriptions . push ( this . http . delete ( this . APIURL + "/storage/folder?dest=" + this . selectedDestination . destination + "&seUrl=" + encodeURIComponent ( this . destinationUrl + this . destinationPath + "test1/" ) , { headers : headers } ) . subscribe (
2022-10-05 15:52:04 +02:00
res = > {
console . log ( res ) ;
}
) ) ;
2022-09-21 16:29:46 +02:00
}
2022-05-19 11:15:52 +02:00
private parseFilename ( url ) {
let filename = url . split ( "/" ) [ url . split ( "/" ) . length - 1 ] ;
return filename . split ( "?" ) [ 0 ] ;
}
2022-05-25 16:56:00 +02:00
pathValidator ( ) : ValidatorFn {
2022-05-24 13:24:54 +02:00
return ( control : AbstractControl ) : { [ key : string ] : string } | null = > {
2022-05-25 16:56:00 +02:00
if ( ! this . validatePath ( ) ) {
2022-05-24 13:24:54 +02:00
return { 'error' : 'Path should start and end with "/" e.g /path/' } ;
}
return null ;
}
}
2022-05-25 16:56:00 +02:00
validatePath ( ) : boolean {
let exp1 = /^\/([A-z0-9-_+]+\/)*$/g ;
return ( this . destinationPath . length > 0 && this . destinationPath . match ( exp1 ) != null )
}
2023-02-13 18:39:08 +01:00
validateDestinationUrl ( ) : boolean {
return ( this . destinationUrl . length > 0 && StringUtils . isValidUrl ( this . destinationUrl ) ) ;
}
2022-05-19 11:15:52 +02:00
}