Skip to content

Commit 873324a

Browse files
author
irinaschubert
authored
feat(xmlupload): use custom IRIs created from salsah ARKs for XML upload (DEV-179) (#147)
* add custom IRI and ARK to resource * add creation of custom IRIs from salsah ARKs to XML upload * use https for version 5 uuid * validate salsah ARK before calculating UUID * remove main methods from tests * Update xml_upload.py * add unittest * refactor list from Excel documentation * add documentation for ARK and IRI for XML upload * improve code after review
1 parent 853068d commit 873324a

8 files changed

Lines changed: 128 additions & 21 deletions

File tree

‎docs/dsp-tools-create.md‎

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -317,12 +317,8 @@ Example of a list:
317317

318318
#### Lists from Excel
319319

320-
A list can be directly imported from one or several Excel files. The Excel sheet must have the following format:
321-
322-
![img-list-example.png](assets/images/img-list-example.png)
323-
324-
If there are several languages, a separate Excel file for each language has to be provided. The folder with the Excel
325-
file(s) can be directly referenced inside the list definition by defining it as new list node:
320+
A list can be directly imported from one or several Excel files. The folder with the Excel file(s) can then directly
321+
be referenced inside the list definition by defining it as new list node:
326322

327323
```json
328324
{
@@ -341,12 +337,12 @@ file(s) can be directly referenced inside the list definition by defining it as
341337
}
342338
```
343339

344-
The nodes section must contain the field:
340+
The `nodes` section has to contain the field:
345341

346-
- _folder_: Path to the folder where the Excel files are stored
342+
- _folder_: Path to the folder containing the Excel files
347343

348-
Further details to this functionality can be found
349-
[here](dsp-tools-excel#create-a-list-from-one-or-several-excel-files).
344+
Further information about the expected format of the Excel lists and details to this functionality can be found
345+
[here](./dsp-tools-excel.md#create-a-list-from-one-or-several-excel-files).
350346

351347
The `lists` element is optional. If not used, it should be omitted.
352348

‎docs/dsp-tools-excel.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ For further information about properties, see [here](./dsp-tools-create-ontologi
7676

7777
## Create a list from one or several Excel files
7878

79+
7980
With dsp-tools a JSON list can be created from one or several Excel files. The list can then be inserted into a JSON
8081
ontology and uploaded to a DSP server. The expected structure of the Excel files is described
8182
[here](./dsp-tools-create.md#lists-from-excel). It is possible to create multilingual lists. In this case, a separate

‎docs/dsp-tools-xmlupload.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,9 @@ A `<resource>` element contains all necessary information to create a resource.
179179
- `id`: a unique, arbitrary string providing a unique ID to the resource in order to be referencable by other resources;
180180
the ID is only used during the import process and later replaced by the IRI used internally by DSP (required)
181181
- `permissions`: a reference to a permission set; the permissions will be applied to the created resource (optional)
182+
- `iri`: a custom IRI used when migrating existing resources (optional)
183+
- `ark`: a version 0 ARK used when migrating existing resources from salsah.org to DSP (optional), it is not possible to
184+
use `iri` and `ark` in the same resource. When `ark` is used, it overrides `iri`.
182185

183186
A complete `<resource>` element may look as follows:
184187

‎knora/dsplib/models/resource.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,9 @@ def fromJsonLdObj(self, con: Connection, jsonld_obj: Any) -> 'ResourceInstance':
217217
def toJsonLdObj(self, action: Actions) -> Any:
218218
tmp = {}
219219
if action == Actions.Create:
220+
# if a custom IRI is provided, use it
221+
if self._iri:
222+
tmp['@id'] = self._iri
220223
tmp['@type'] = self.classname
221224
tmp["knora-api:attachedToProject"] = {
222225
"@id": self.project
@@ -335,7 +338,7 @@ def __init__(self,
335338
con: Connection,
336339
projident: str):
337340
self._con = con
338-
if re.match("^[0-9aAbBcCdDeEfF]{4}$", projident):
341+
if re.match("^[0-9a-fA-F]{4}$", projident):
339342
project = Project(con=self._con, shortcode=projident)
340343
elif re.match("^[\\w-]+$", projident):
341344
project = Project(con=self._con, shortname=projident)

‎knora/dsplib/schemas/data.xsd‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,7 +409,9 @@
409409
<xs:attribute name="label" type="xs:string" use="required"/>
410410
<xs:attribute name="restype" type="xs:string" use="required"/>
411411
<xs:attribute name="id" type="xs:ID" use="required"/>
412+
<xs:attribute name="iri" type="xs:string" use="optional"/>
412413
<xs:attribute name="permissions" type="xs:NCName" use="optional"/>
414+
<xs:attribute name="ark" type="xs:string" use="optional"/>
413415
</xs:complexType>
414416

415417
<!-- data type for knora shortcode -->

‎knora/dsplib/utils/xml_upload.py‎

Lines changed: 69 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
"""
22
This module handles the import of XML data into the DSP platform.
33
"""
4+
import base64
45
import json
56
import os
7+
import re
8+
import uuid
69
from datetime import datetime
710
from pathlib import Path
811
from typing import Optional, Union
@@ -229,6 +232,8 @@ class XMLResource:
229232
"""Represents a resource in the XML used for data import"""
230233

231234
_id: str
235+
_iri: Optional[str]
236+
_ark: Optional[str]
232237
_label: str
233238
_restype: str
234239
_permissions: Optional[str]
@@ -242,8 +247,13 @@ def __init__(self, node: etree.Element, default_ontology: Optional[str] = None)
242247
Args:
243248
node: The DOM node to be processed representing a resource (which is a child of the knora element)
244249
default_ontology: The default ontology (given in the attribute default-ontology of the knora element)
250+
251+
Returns:
252+
None
245253
"""
246254
self._id = node.attrib['id']
255+
self._iri = node.attrib.get('iri')
256+
self._ark = node.attrib.get('ark')
247257
self._label = node.attrib['label']
248258
# get the resource type which is in format namespace:resourcetype, p.ex. rosetta:Image
249259
tmp_res_type = node.attrib['restype'].split(':')
@@ -255,11 +265,7 @@ def __init__(self, node: etree.Element, default_ontology: Optional[str] = None)
255265
self._restype = default_ontology + ':' + tmp_res_type[1]
256266
else:
257267
self._restype = 'knora-admin:' + tmp_res_type[0]
258-
permissions_tmp = node.attrib.get("permissions")
259-
if permissions_tmp:
260-
self._permissions = node.attrib['permissions']
261-
else:
262-
self._permissions = None
268+
self._permissions = node.attrib.get("permissions")
263269
self._bitstream = None
264270
self._properties = []
265271
for subnode in node:
@@ -277,6 +283,16 @@ def id(self) -> str:
277283
"""The unique id of the resource"""
278284
return self._id
279285

286+
@property
287+
def iri(self) -> Optional[str]:
288+
"""The custom IRI of the resource"""
289+
return self._iri
290+
291+
@property
292+
def ark(self) -> Optional[str]:
293+
"""The custom ARK of the resource"""
294+
return self._ark
295+
280296
@property
281297
def label(self) -> str:
282298
"""The label of the resource"""
@@ -288,7 +304,7 @@ def restype(self) -> str:
288304
return self._restype
289305

290306
@property
291-
def permissions(self) -> str:
307+
def permissions(self) -> Optional[str]:
292308
"""The reference to the permissions set for this resource"""
293309
return self._permissions
294310

@@ -323,8 +339,7 @@ def get_resptrs(self) -> list[str]:
323339
resptrs.extend(value.resrefs)
324340
return resptrs
325341

326-
def get_propvals(self, resiri_lookup: dict[str, str], permissions_lookup: dict[str, Permissions]) -> dict[
327-
str, Permissions]:
342+
def get_propvals(self, resiri_lookup: dict[str, str], permissions_lookup: dict[str, Permissions]) -> dict[str, Permissions]:
328343
"""
329344
Get a dictionary of the property names and their values belonging to a resource
330345
@@ -371,8 +386,7 @@ def get_propvals(self, resiri_lookup: dict[str, str], permissions_lookup: dict[s
371386
prop_data[prop.name] = vals if len(vals) > 1 else vals[0]
372387
return prop_data
373388

374-
def get_bitstream(self, internal_file_name_bitstream: str, permissions_lookup: dict[str, Permissions]) -> Optional[
375-
dict[str, Union[str, Permissions]]]:
389+
def get_bitstream(self, internal_file_name_bitstream: str, permissions_lookup: dict[str, Permissions]) -> Optional[dict[str, Union[str, Permissions]]]:
376390
"""
377391
Get the bitstream object belonging to the resource
378392
@@ -574,6 +588,46 @@ def validate_xml_against_schema(input_file: str, schema_file: str) -> bool:
574588
return is_valid
575589

576590

591+
def convert_ark_v0_to_resource_iri(ark: str) -> str:
592+
"""
593+
Converts an ARK URL from salsah.org (ARK version 0) of the form ark:/72163/080c-779b9990a0c3f-6e to a DSP resource
594+
IRI of the form http://rdfh.ch/080C/Ef9heHjPWDS7dMR_gGax2Q
595+
596+
This method is needed for the migration of projects from salsah.org to DSP. Resources need to be created with an
597+
existing ARK, so the IRI needs to be extracted from that ARK in order for the ARK URL to be still valid after the
598+
migration.
599+
600+
Args:
601+
ark : an ARK version 0 of the form ark:/72163/080c-779b9990a0c3f-6e, '72163' being the Name Assigning Authority
602+
number, '080c' being the project shortcode, '779b9990a0c3f' being an ID derived from the object's Salsah ID and
603+
'6e' being check digits
604+
605+
Returns:
606+
Resource IRI (str) of the form http://rdfh.ch/080C/Ef9heHjPWDS7dMR_gGax2Q
607+
"""
608+
# create the DaSCH namespace to create version 5 UUIDs
609+
generic_namespace_url = uuid.NAMESPACE_URL
610+
dasch_uuid_ns = uuid.uuid5(generic_namespace_url, "https://dasch.swiss") # cace8b00-717e-50d5-bcb9-486f39d733a2
611+
612+
# get the salsah resource ID from the ARK and convert it to a UUID version 5 (base64 encoded)
613+
if ark.count("-") != 2:
614+
raise BaseError(f"while converting ARK '{ark}'. The ARK seems to be invalid")
615+
project_id, resource_id, _ = ark.split("-")
616+
_, project_id = project_id.rsplit("/", 1)
617+
project_id = project_id.upper()
618+
if not re.match("^[0-9a-fA-F]{4}$", project_id):
619+
raise BaseError(f"while converting ARK '{ark}'. Invalid project shortcode '{project_id}'")
620+
if not re.match("^[0-9A-Za-z]+$", resource_id):
621+
raise BaseError(f"while converting ARK '{ark}'. Invalid Salsah ID '{resource_id}'")
622+
623+
# make a UUID v5 from the namespace created above (which is a UUID itself) and the resource ID and encode it to base64
624+
dsp_uuid = base64.urlsafe_b64encode(uuid.uuid5(dasch_uuid_ns, resource_id).bytes).decode("utf-8")
625+
dsp_uuid = dsp_uuid[:-2]
626+
627+
# use the new UUID to create the resource IRI
628+
return "http://rdfh.ch/" + project_id + "/" + dsp_uuid
629+
630+
577631
def xml_upload(input_file: str, server: str, user: str, password: str, imgdir: str, sipi: str, verbose: bool,
578632
validate_only: bool, incremental: bool) -> None:
579633
"""
@@ -670,6 +724,10 @@ def xml_upload(input_file: str, server: str, user: str, password: str, imgdir: s
670724
if verbose:
671725
resource.print()
672726

727+
resource_iri = resource.iri
728+
if resource.ark:
729+
resource_iri = convert_ark_v0_to_resource_iri(resource.ark)
730+
673731
resource_bitstream = None
674732
if resource.bitstream:
675733
img = sipi.upload_bitstream(os.path.join(imgdir, resource.bitstream.value))
@@ -682,6 +740,7 @@ def xml_upload(input_file: str, server: str, user: str, password: str, imgdir: s
682740
# create a resource instance (ResourceInstance) from the given resource in the XML (XMLResource)
683741
instance: ResourceInstance = res_classes[resource.restype](con=con,
684742
label=resource.label,
743+
iri=resource_iri,
685744
permissions=permissions_tmp,
686745
bitstream=resource_bitstream,
687746
values=resource.get_propvals(res_iri_lookup,

‎test/unittests/BUILD.bazel‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,15 @@ load("@knora_py_deps//:requirements.bzl", "requirement")
66

77
package(default_visibility = ["//visibility:public"])
88

9+
py_test(
10+
name = "test_convert_ark_v0_to_resource_iri",
11+
srcs = ["test_convert_ark_v0_to_resource_iri.py"],
12+
deps = [
13+
"//knora/dsplib/utils:xml_upload",
14+
"//knora/dsplib/models:helpers"
15+
],
16+
)
17+
918
py_test(
1019
name = "test_langstring",
1120
srcs = ["test_langstring.py"],
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
"""Unit tests for ARK v0 conversion"""
2+
3+
import unittest
4+
5+
from knora.dsplib.models.helpers import BaseError
6+
from knora.dsplib.utils.xml_upload import convert_ark_v0_to_resource_iri
7+
8+
9+
class TestARKV02IRI(unittest.TestCase):
10+
11+
def test_convert_ark_v0_to_resource_iri(self):
12+
ark = "ark:/72163/080c-779b9990a0c3f-6e"
13+
iri = convert_ark_v0_to_resource_iri(ark)
14+
self.assertEqual("http://rdfh.ch/080C/Ef9heHjPWDS7dMR_gGax2Q", iri)
15+
16+
with self.assertRaises(BaseError) as err1:
17+
convert_ark_v0_to_resource_iri("ark:/72163/080c-779b999-0a0c3f-6e")
18+
self.assertEqual(err1.exception.message, "while converting ARK 'ark:/72163/080c-779b999-0a0c3f-6e'. The ARK seems to be invalid")
19+
20+
with self.assertRaises(BaseError) as err2:
21+
convert_ark_v0_to_resource_iri("ark:/72163/080X-779b9990a0c3f-6e")
22+
self.assertEqual(err2.exception.message, "while converting ARK 'ark:/72163/080X-779b9990a0c3f-6e'. Invalid project shortcode '080X'")
23+
24+
with self.assertRaises(BaseError) as err3:
25+
convert_ark_v0_to_resource_iri("ark:/72163/080c1-779b9990a0c3f-6e")
26+
self.assertEqual(err3.exception.message, "while converting ARK 'ark:/72163/080c1-779b9990a0c3f-6e'. Invalid project shortcode '080C1'")
27+
28+
with self.assertRaises(BaseError) as err3:
29+
convert_ark_v0_to_resource_iri("ark:/72163/080c-779b99+90a0c3f-6e")
30+
self.assertEqual(err3.exception.message, "while converting ARK 'ark:/72163/080c-779b99+90a0c3f-6e'. Invalid Salsah ID '779b99+90a0c3f'")
31+
32+
33+
if __name__ == '__main__':
34+
unittest.main()

0 commit comments

Comments
 (0)