Coverage for changes_metadata_manager / zenodo_upload.py: 75%

537 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-07-17 16:43 +0000

1# SPDX-FileCopyrightText: 2025-2026 Arcangelo Massari <arcangelo.massari@unibo.it> 

2# 

3# SPDX-License-Identifier: ISC 

4 

5import argparse 

6import json 

7import os 

8import re 

9import signal 

10import tempfile 

11import time 

12import unicodedata 

13import zipfile 

14from collections import defaultdict 

15from collections.abc import Mapping 

16from concurrent.futures import ProcessPoolExecutor, as_completed 

17from contextlib import contextmanager 

18from datetime import date 

19from pathlib import Path 

20 

21import requests 

22import yaml 

23from rdflib import Graph, URIRef 

24from rich.progress import ( 

25 Progress, 

26 SpinnerColumn, 

27 TextColumn, 

28 BarColumn, 

29 MofNCompleteColumn, 

30 TimeElapsedColumn, 

31 TimeRemainingColumn, 

32) 

33 

34from piccione.upload.on_zenodo import ( 

35 get_headers, 

36 main as piccione_upload, 

37 publish_draft as piccione_publish_draft, 

38) 

39 

40from changes_metadata_manager.folder_metadata_builder import ( 

41 BASE_URI, 

42 FOLDER_TO_ID, 

43 KG_PATH, 

44 SKIP_FOLDERS, 

45 STAGE_STEPS, 

46 extract_id_from_folder_name, 

47 load_kg, 

48 scan_folder_structure, 

49) 

50from changes_metadata_manager.zenodo_metadata import LICENSE_URI_TO_ZENODO 

51 

52 

53class LiteralBlockDumper(yaml.SafeDumper): 

54 pass 

55 

56 

57def _literal_str_representer(dumper: yaml.SafeDumper, data): 

58 if "\n" in data: 

59 return dumper.represent_scalar("tag:yaml.org,2002:str", data, style="|") 

60 return dumper.represent_scalar("tag:yaml.org,2002:str", data) 

61 

62 

63LiteralBlockDumper.add_representer(str, _literal_str_representer) 

64 

65CREATORS_LOOKUP_PATH = Path(__file__).parent.parent / "data" / "creators_lookup.yaml" 

66 

67 

68def slugify(text: str) -> str: 

69 text = unicodedata.normalize("NFKD", text) 

70 text = text.encode("ascii", "ignore").decode("ascii") 

71 text = re.sub(r"[^\w\s-]", "", text.lower()) 

72 return re.sub(r"[-\s]+", "-", text).strip("-") 

73 

74 

75STEP_TO_STAGE = { 

76 "00": "raw", 

77 "01": "rawp", 

78 "02": "dcho", 

79 "03": "dchoo", 

80 "04": "dchoo", 

81 "05": "dchoo", 

82 "06": "dchoo", 

83} 

84 

85STAGE_DEFINING_STEP: dict[str, str] = { 

86 "raw": "00", 

87 "rawp": "01", 

88 "dcho": "02", 

89 "dchoo": "03", 

90} 

91 

92CRM = "http://www.cidoc-crm.org/cidoc-crm/" 

93CRMDIG = "http://www.cidoc-crm.org/extensions/crmdig/" 

94AAT = "http://vocab.getty.edu/aat/" 

95 

96P70I = URIRef(f"{CRM}P70i_is_documented_in") 

97P3_HAS_NOTE = URIRef(f"{CRM}P3_has_note") 

98P14_CARRIED_OUT_BY = URIRef(f"{CRM}P14_carried_out_by") 

99P1_IS_IDENTIFIED_BY = URIRef(f"{CRM}P1_is_identified_by") 

100P190_HAS_SYMBOLIC_CONTENT = URIRef(f"{CRM}P190_has_symbolic_content") 

101P74_HAS_RESIDENCE = URIRef(f"{CRM}P74_has_current_or_former_residence") 

102P32_USED_GENERAL_TECHNIQUE = URIRef(f"{CRM}P32_used_general_technique") 

103P16_USED_SPECIFIC_OBJECT = URIRef(f"{CRM}P16_used_specific_object") 

104E21_PERSON = URIRef(f"{CRM}E21_Person") 

105RDF_TYPE = URIRef("http://www.w3.org/1999/02/22-rdf-syntax-ns#type") 

106L23_USED_SOFTWARE = URIRef(f"{CRMDIG}L23_used_software_or_firmware") 

107 

108AAT_TECHNIQUE_LABELS: dict[str, str] = { 

109 f"{AAT}300266792": "digital photography", 

110 f"{AAT}300429747": "optical scanning", 

111} 

112 

113SLUG_LABELS: dict[str, str] = { 

114 "3df_zephyr": "3DF Zephyr", 

115 "adobe_photoshop_2023": "Adobe Photoshop 2023", 

116 "agisoft_metashape": "Agisoft Metashape", 

117 "artec_eva": "Artec Eva", 

118 "artec_leo": "Artec Leo", 

119 "artec_spider": "Artec Spider", 

120 "artec_studio_14": "Artec Studio 14", 

121 "artec_studio_15": "Artec Studio 15", 

122 "artec_studio_16": "Artec Studio 16", 

123 "artec_studio_19": "Artec Studio 19", 

124 "aton": "ATON", 

125 "blender": "Blender", 

126 "canon_eos_6d": "Canon EOS 6D", 

127 "chad-ap": "CHAD-AP", 

128 "cloudcompare": "CloudCompare", 

129 "gestaltor": "Gestaltor", 

130 "gimp": "GIMP", 

131 "heritrace": "HERITRACE", 

132 "instalod": "InstaLOD", 

133 "instant_meshes": "Instant Meshes", 

134 "lente_24-70_f2_8_l": "Canon EF 24-70mm f/2.8L", 

135 "meshlab": "MeshLab", 

136 "metashape": "Agisoft Metashape", 

137 "microscopio_digitale_bresciani": "Microscopio Digitale Bresciani", 

138 "modo": "Modo", 

139 "morph-kgc": "Morph-KGC", 

140 "nextcloud": "Nextcloud", 

141 "nikkor_35mm": "Nikkor 35mm", 

142 "nikkor_50mm": "Nikkor 50mm", 

143 "nikon_d3300": "Nikon D3300", 

144 "nikon_d5200": "Nikon D5200", 

145 "nikon_d5600": "Nikon D5600", 

146 "nikon_d7200": "Nikon D7200", 

147 "nikon_d750": "Nikon D750", 

148 "panasonic_dmc-lx100": "Panasonic DMC-LX100", 

149 "sony_alpha_6100": "Sony Alpha 6100", 

150 "sony_alpha_7_i": "Sony Alpha 7 I", 

151 "substance_3d_painter": "Substance 3D Painter", 

152 "substance_painter": "Substance Painter", 

153 "zbrush": "ZBrush", 

154} 

155 

156 

157def _format_slug(slug: str) -> str: 

158 return SLUG_LABELS[slug] 

159 

160 

161def load_creators_lookup(path: Path) -> dict[str, dict]: 

162 with open(path) as f: 

163 data = yaml.safe_load(f) 

164 return { 

165 creator["name_in_rdf"]: { 

166 "family_name": creator["family_name"], 

167 "given_name": creator["given_name"], 

168 "affiliation": creator["affiliation"], 

169 "orcid": creator["orcid"], 

170 } 

171 for creator in data["creators"] 

172 } 

173 

174 

175def _format_creator(creator_data: dict, role: str) -> dict: 

176 return { 

177 "person_or_org": { 

178 "type": "personal", 

179 "family_name": creator_data["family_name"], 

180 "given_name": creator_data["given_name"], 

181 "identifiers": [{"scheme": "orcid", "identifier": creator_data["orcid"]}], 

182 }, 

183 "role": {"id": role}, 

184 "affiliations": [{"name": creator_data["affiliation"]}], 

185 } 

186 

187 

188METADATA_STEP = "05" 

189 

190 

191def _extract_actor_names(graph: Graph, act_uri: URIRef) -> set[str]: 

192 names = set() 

193 for _, _, actor_uri in graph.triples((act_uri, P14_CARRIED_OUT_BY, None)): 

194 assert (actor_uri, RDF_TYPE, E21_PERSON) in graph 

195 for _, _, apl_uri in graph.triples((actor_uri, P1_IS_IDENTIFIED_BY, None)): 

196 for _, _, name in graph.triples((apl_uri, P190_HAS_SYMBOLIC_CONTENT, None)): 

197 names.add(str(name)) 

198 return names 

199 

200 

201def extract_authors_for_entity_stage( 

202 graph: Graph, entity_ids: list[str], stage: str 

203) -> set[str]: 

204 steps = [s for s in STAGE_STEPS[stage] if s != METADATA_STEP] 

205 authors: set[str] = set() 

206 for entity_id in entity_ids: 

207 for step in steps: 

208 authors |= _extract_actor_names( 

209 graph, URIRef(f"{BASE_URI}/act/{entity_id}/{step}/1") 

210 ) 

211 return authors 

212 

213 

214def extract_metadata_authors(graph: Graph, entity_ids: list[str]) -> set[str]: 

215 authors: set[str] = set() 

216 for entity_id in entity_ids: 

217 authors |= _extract_actor_names( 

218 graph, URIRef(f"{BASE_URI}/act/{entity_id}/05/1") 

219 ) 

220 return authors 

221 

222 

223def _build_creators( 

224 author_names: set[str], role: str, creators_lookup: dict[str, dict] 

225) -> list[dict]: 

226 missing_names = sorted(author_names.difference(creators_lookup)) 

227 if missing_names: 

228 raise ValueError(f"Creators missing from lookup: {', '.join(missing_names)}") 

229 return [ 

230 _format_creator(creators_lookup[name], role) for name in sorted(author_names) 

231 ] 

232 

233 

234def build_creators_for_entity_stage( 

235 graph: Graph, entity_ids: list[str], stage: str, creators_lookup: dict[str, dict] 

236) -> list[dict]: 

237 author_names = extract_authors_for_entity_stage(graph, entity_ids, stage) 

238 return _build_creators(author_names, "researcher", creators_lookup) 

239 

240 

241def build_metadata_creators( 

242 graph: Graph, entity_ids: list[str], creators_lookup: dict[str, dict] 

243) -> list[dict]: 

244 author_names = extract_metadata_authors(graph, entity_ids) 

245 return _build_creators(author_names, "datacurator", creators_lookup) 

246 

247 

248def merge_creators( 

249 digitization_creators: list[dict], metadata_creators: list[dict] 

250) -> list[dict]: 

251 seen_orcids: set[str] = set() 

252 merged: list[dict] = [] 

253 for creator in digitization_creators: 

254 orcid = creator["person_or_org"]["identifiers"][0]["identifier"] 

255 seen_orcids.add(orcid) 

256 merged.append(creator) 

257 for creator in metadata_creators: 

258 orcid = creator["person_or_org"]["identifiers"][0]["identifier"] 

259 if orcid not in seen_orcids: 

260 seen_orcids.add(orcid) 

261 merged.append(creator) 

262 return merged 

263 

264 

265def extract_licensed_entity_stages(graph: Graph) -> set[tuple[str, str]]: 

266 pattern = re.compile(rf"^{re.escape(BASE_URI)}/lic/([^/]+)/(\d{{2}})/1$") 

267 licensed = set() 

268 for s, p, o in graph.triples((None, P70I, None)): 

269 match = pattern.match(str(s)) 

270 if match: 

271 entity_id, step = match.groups() 

272 stage = STEP_TO_STAGE.get(step) 

273 if stage: 

274 licensed.add((entity_id, stage)) 

275 return licensed 

276 

277 

278def group_folders_by_entity(structure: dict) -> dict[str, list[tuple[str, str, dict]]]: 

279 groups = defaultdict(list) 

280 for sala_name, sala_items in structure["structure"].items(): 

281 for folder_name, subfolders in sala_items.items(): 

282 if folder_name in SKIP_FOLDERS: 

283 continue 

284 entity_id = extract_id_from_folder_name(folder_name) 

285 if entity_id in FOLDER_TO_ID.values(): 

286 group_id = entity_id 

287 else: 

288 group_id = entity_id.rstrip("abcdefghijklmnopqrstuvwxyz") 

289 groups[group_id].append((sala_name, folder_name, subfolders)) 

290 return dict(groups) 

291 

292 

293STAGES = ("raw", "rawp", "dcho", "dchoo") 

294 

295 

296def _extract_license_from_meta(stage_dir: Path, stage: str) -> str | None: 

297 g = Graph() 

298 g.parse(stage_dir / "meta.ttl", format="turtle") 

299 expected_step = STAGE_DEFINING_STEP[stage] 

300 for s, _, o in g.triples((None, P70I, None)): 

301 s_str = str(s) 

302 if "/lic/" in s_str: 

303 step_match = re.search(r"/(\d{2})/\d+$", s_str) 

304 if step_match and step_match.group(1) == expected_step: 

305 return LICENSE_URI_TO_ZENODO.get(str(o)) 

306 return None 

307 

308 

309def create_stage_zip( 

310 entity_id: str, 

311 stage: str, 

312 folders: list[tuple[str, str, dict]], 

313 root: Path, 

314 output_dir: Path, 

315 title: str, 

316) -> tuple[Path, str | None] | None: 

317 stage_dirs: list[tuple[str, str, Path]] = [] 

318 license_id: str | None = None 

319 for sala_name, folder_name, stages_dict in folders: 

320 stage_name_in_folder = None 

321 for name in stages_dict: 

322 if name.lower() == stage: 

323 stage_name_in_folder = name 

324 break 

325 if stage_name_in_folder is None: 

326 continue 

327 stage_dir = root / sala_name / folder_name / stage_name_in_folder 

328 stage_dirs.append((folder_name, stage_name_in_folder, stage_dir)) 

329 folder_license = _extract_license_from_meta(stage_dir, stage) 

330 if folder_license: 

331 license_id = folder_license 

332 if not stage_dirs: 

333 return None 

334 sala_slug = slugify(folders[0][0]) 

335 title_slug = slugify(title) 

336 zip_path = output_dir / f"{sala_slug}-{title_slug}-{entity_id}-{stage}.zip" 

337 with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf: 

338 for folder_name, stage_name_in_folder, stage_dir in stage_dirs: 

339 for file_path in stage_dir.rglob("*"): 

340 if not file_path.is_file(): 

341 continue 

342 if license_id or file_path.name in ("meta.ttl", "prov.trig"): 

343 rel = file_path.relative_to(stage_dir) 

344 arc_name = f"{folder_name}/{stage_name_in_folder}/{rel}" 

345 zf.write(file_path, arc_name) 

346 return zip_path, license_id 

347 

348 

349def _get_label(graph: Graph, uri: URIRef) -> str | None: 

350 for _, _, apl_uri in graph.triples((uri, P1_IS_IDENTIFIED_BY, None)): 

351 for _, _, name in graph.triples((apl_uri, P190_HAS_SYMBOLIC_CONTENT, None)): 

352 return str(name) 

353 return None 

354 

355 

356def extract_keeper_info( 

357 graph: Graph, entity_ids: list[str] 

358) -> tuple[str | None, str | None]: 

359 for entity_id in entity_ids: 

360 custody_uri = URIRef(f"{BASE_URI}/act/{entity_id}/ob08/1") 

361 for _, _, keeper_uri in graph.triples((custody_uri, P14_CARRIED_OUT_BY, None)): 

362 assert isinstance(keeper_uri, URIRef) 

363 keeper_name = _get_label(graph, keeper_uri) 

364 location_name = None 

365 for _, _, place_uri in graph.triples((keeper_uri, P74_HAS_RESIDENCE, None)): 

366 assert isinstance(place_uri, URIRef) 

367 location_name = _get_label(graph, place_uri) 

368 return keeper_name, location_name 

369 return None, None 

370 

371 

372def extract_entity_title(graph: Graph, entity_ids: list[str]) -> str: 

373 for entity_id in entity_ids: 

374 item_uri = URIRef(f"{BASE_URI}/itm/{entity_id}/ob00/1") 

375 for _, _, note_value in graph.triples((item_uri, P3_HAS_NOTE, None)): 

376 note = str(note_value) 

377 return re.split(r"\n|\\n", note)[0].strip() 

378 return f"Entity {entity_ids[0]}" 

379 

380 

381def extract_acquisition_technique(graph: Graph, entity_ids: list[str]) -> str | None: 

382 for entity_id in entity_ids: 

383 act_uri = URIRef(f"{BASE_URI}/act/{entity_id}/00/1") 

384 for _, _, technique_uri in graph.triples( 

385 (act_uri, P32_USED_GENERAL_TECHNIQUE, None) 

386 ): 

387 return AAT_TECHNIQUE_LABELS[str(technique_uri)] 

388 return None 

389 

390 

391def extract_devices(graph: Graph, entity_ids: list[str]) -> list[str]: 

392 devices: set[str] = set() 

393 for entity_id in entity_ids: 

394 act_uri = URIRef(f"{BASE_URI}/act/{entity_id}/00/1") 

395 for _, _, obj_uri in graph.triples((act_uri, P16_USED_SPECIFIC_OBJECT, None)): 

396 uri_str = str(obj_uri) 

397 if "/dev/" in uri_str: 

398 slug = uri_str.split("/dev/")[1].split("/")[0] 

399 devices.add(_format_slug(slug)) 

400 return sorted(devices) 

401 

402 

403def extract_software_for_stage( 

404 graph: Graph, entity_ids: list[str], stage: str 

405) -> list[str]: 

406 steps = [s for s in STAGE_STEPS[stage] if s != METADATA_STEP] 

407 software: set[str] = set() 

408 for entity_id in entity_ids: 

409 for step in steps: 

410 act_uri = URIRef(f"{BASE_URI}/act/{entity_id}/{step}/1") 

411 for _, _, sfw_uri in graph.triples((act_uri, L23_USED_SOFTWARE, None)): 

412 uri_str = str(sfw_uri) 

413 slug = uri_str.split("/sfw/")[1].split("/")[0] 

414 software.add(_format_slug(slug)) 

415 return sorted(software) 

416 

417 

418STAGE_TITLE_NAMES = { 

419 "raw": "Raw", 

420 "rawp": "Processed raw model", 

421 "dcho": "Digital Cultural Heritage Object", 

422 "dchoo": "Optimized Digital Cultural Heritage Object", 

423} 

424 

425STAGE_DESCRIPTION_NAMES = { 

426 "raw": "Raw acquisition data", 

427 "rawp": "Processed raw model", 

428 "dcho": "Digital Cultural Heritage Object", 

429 "dchoo": "Optimized Digital Cultural Heritage Object", 

430} 

431 

432STAGE_DESCRIPTIONS = { 

433 "raw": "This dataset contains the raw material generated during the acquisition phase.", 

434 "rawp": "This dataset contains the preliminary output from the photogrammetry or scanner software after initial data processing but without any interpolation or geometry corrections.", 

435 "dcho": "This dataset contains the version that includes interpolation, gap filling, and resolution of geometric issues, resulting in a refined and improved model.", 

436 "dchoo": "This dataset contains the version optimised for real-time online interaction.", 

437} 

438 

439PROPAGATED_FIELDS = ( 

440 "zenodo_url", 

441 "access_token", 

442 "user_agent", 

443 "subjects", 

444 "publication_date", 

445 "version", 

446 "community", 

447 "contributors", 

448 "funding", 

449 "references", 

450 "dates", 

451) 

452 

453 

454def extract_license_for_entity_stage( 

455 graph: Graph, entity_id: str, stage: str 

456) -> str | None: 

457 step = STAGE_DEFINING_STEP[stage] 

458 lic_uri = URIRef(f"{BASE_URI}/lic/{entity_id}/{step}/1") 

459 for _, _, license_url in graph.triples((lic_uri, P70I, None)): 

460 return LICENSE_URI_TO_ZENODO.get(str(license_url)) 

461 return None 

462 

463 

464CC0_DISCLAIMER = ( 

465 "No copyright or related rights are claimed in these digital reproductions. " 

466 "The files are released under CC0 1.0 Universal (Public Domain Dedication).\n" 

467 "\n" 

468 "Please note that the original works may qualify as cultural heritage assets " 

469 "under Italian law (D. Lgs. 42/2004). Consequently, although the digital " 

470 "reproductions are released under CC0, certain uses — and in particular " 

471 "commercial uses — may be subject to specific authorisations, restrictions, " 

472 "or fees pursuant to the applicable provisions governing the reproduction " 

473 "and publication of cultural heritage assets. Users are therefore responsible " 

474 "for ensuring compliance with Italian cultural heritage regulations before " 

475 "undertaking any commercial exploitation of the images." 

476) 

477 

478 

479CHAD_AP_URL = "https://w3id.org/dharc/ontology/chad-ap" 

480 

481RESTRICTED_NOTICE = ( 

482 "The digital object files are not included in this dataset " 

483 "because the holding institution did not grant permission for their publication. " 

484 "Only metadata and provenance files are provided." 

485) 

486 

487EXTERNAL_SOURCE_NOTICE = ( 

488 "The digital object files are not included in this dataset because they have " 

489 "been either gathered from an existing platform or provided directly by " 

490 "colleagues, without any formal permission to republish them in their original " 

491 "form. Only metadata and provenance files are provided." 

492) 

493 

494 

495def has_defining_activity(graph: Graph, entity_ids: list[str], stage: str) -> bool: 

496 step = STAGE_DEFINING_STEP[stage] 

497 return any( 

498 any(graph.triples((URIRef(f"{BASE_URI}/act/{entity_id}/{step}/1"), None, None))) 

499 for entity_id in entity_ids 

500 ) 

501 

502 

503def select_missing_files_notice( 

504 graph: Graph, 

505 entity_ids: list[str], 

506 stage: str, 

507 content_license: str | None, 

508) -> str | None: 

509 if content_license is not None: 

510 return None 

511 if has_defining_activity(graph, entity_ids, stage): 

512 return RESTRICTED_NOTICE 

513 return EXTERNAL_SOURCE_NOTICE 

514 

515 

516def build_enhanced_description( 

517 stage: str, 

518 title: str, 

519 keeper_name: str | None = None, 

520 keeper_location: str | None = None, 

521) -> str: 

522 parts = [ 

523 f'{STAGE_DESCRIPTION_NAMES[stage]} of "{title}" from the Aldrovandi Digital Twin.', 

524 ] 

525 if keeper_name: 

526 keeper_line = f"The original object is held by {keeper_name}" 

527 if keeper_location: 

528 keeper_line += f" ({keeper_location})" 

529 keeper_line += "." 

530 parts.append(keeper_line) 

531 parts.append(STAGE_DESCRIPTIONS[stage]) 

532 parts.append( 

533 f'Includes metadata (meta.ttl) and provenance (prov.trig) files following the <a href="{CHAD_AP_URL}">CHAD-AP</a> ontology.', 

534 ) 

535 return " ".join(parts) + "\n" 

536 

537 

538WORKFLOW_DOI_URL = "https://doi.org/10.46298/transformations.14773" 

539 

540 

541def build_methods_description( 

542 graph: Graph, 

543 entity_ids: list[str], 

544 stage: str, 

545) -> str: 

546 parts = [ 

547 f"Acquisition and digitization followed the reproducible workflow documented in " 

548 f'<a href="{WORKFLOW_DOI_URL}">doi:10.46298/transformations.14773</a>.', 

549 ] 

550 technique = extract_acquisition_technique(graph, entity_ids) 

551 devices = extract_devices(graph, entity_ids) 

552 if technique: 

553 line = f"Data was acquired using {technique}" 

554 if devices: 

555 line += f" ({', '.join(devices)})" 

556 line += "." 

557 parts.append(line) 

558 software = extract_software_for_stage(graph, entity_ids, stage) 

559 if software: 

560 parts.append(f"Processing software: {', '.join(software)}.") 

561 parts.append( 

562 f'Metadata follows the <a href="{CHAD_AP_URL}">Cultural Heritage Acquisition and ' 

563 f"Digitisation Application Profile (CHAD-AP)</a> based on CIDOC-CRM.", 

564 ) 

565 return "\n\n".join(parts) + "\n" 

566 

567 

568def build_entity_uri(entity_ids: list[str]) -> str: 

569 return f"{BASE_URI}/itm/{entity_ids[0]}/ob00/1" 

570 

571 

572LICENSE_INFO = { 

573 "cc0-1.0": { 

574 "title": "Creative Commons Zero v1.0 Universal", 

575 "link": "https://creativecommons.org/publicdomain/zero/1.0/", 

576 }, 

577 "cc-by-4.0": { 

578 "title": "Creative Commons Attribution 4.0 International", 

579 "link": "https://creativecommons.org/licenses/by/4.0/", 

580 }, 

581 "cc-by-nc-4.0": { 

582 "title": "Creative Commons Attribution Non Commercial 4.0 International", 

583 "link": "https://creativecommons.org/licenses/by-nc/4.0/", 

584 }, 

585 "cc-by-sa-4.0": { 

586 "title": "Creative Commons Attribution Share Alike 4.0 International", 

587 "link": "https://creativecommons.org/licenses/by-sa/4.0/", 

588 }, 

589 "cc-by-nc-sa-4.0": { 

590 "title": "Creative Commons Attribution Non Commercial Share Alike 4.0 International", 

591 "link": "https://creativecommons.org/licenses/by-nc-sa/4.0/", 

592 }, 

593} 

594 

595 

596def build_rights(content_license: str | None) -> list[dict]: 

597 metadata_info = LICENSE_INFO["cc0-1.0"] 

598 rights = [ 

599 { 

600 "title": {"en": f"{metadata_info['title']} (Metadata license)"}, 

601 "description": {"en": "Applies to metadata files: meta.ttl, prov.trig"}, 

602 "link": metadata_info["link"], 

603 } 

604 ] 

605 if content_license and content_license in LICENSE_INFO: 

606 content_info = LICENSE_INFO[content_license] 

607 rights.append( 

608 { 

609 "title": {"en": f"{content_info['title']} (Content license)"}, 

610 "description": { 

611 "en": "Applies to all data files except meta.ttl and prov.trig" 

612 }, 

613 "link": content_info["link"], 

614 } 

615 ) 

616 return rights 

617 

618 

619def generate_zenodo_config( 

620 stage: str, 

621 zip_path: Path, 

622 title: str, 

623 base_config: dict, 

624 creators: list[dict], 

625 methods_description: str, 

626 license: str | None = None, 

627 entity_uri: str | None = None, 

628 keeper_name: str | None = None, 

629 keeper_location: str | None = None, 

630 missing_files_notice: str | None = None, 

631) -> dict: 

632 description = build_enhanced_description(stage, title, keeper_name, keeper_location) 

633 

634 config: dict = { 

635 "title": f"{title} - {STAGE_TITLE_NAMES[stage]} - Aldrovandi Digital Twin", 

636 "description": description, 

637 "resource_type": {"id": "dataset"}, 

638 "publisher": "Zenodo", 

639 "access": {"record": "public", "files": "public"}, 

640 "files": [str(zip_path.absolute())], 

641 "creators": creators, 

642 "publication_date": date.today().isoformat(), 

643 "rights": build_rights(license), 

644 } 

645 

646 additional_descriptions: list[dict] = [ 

647 { 

648 "description": methods_description, 

649 "type": {"id": "methods"}, 

650 }, 

651 { 

652 "description": base_config["notes"], 

653 "type": {"id": "notes"}, 

654 }, 

655 ] 

656 if missing_files_notice is not None: 

657 additional_descriptions.append( 

658 { 

659 "description": missing_files_notice, 

660 "type": {"id": "notes"}, 

661 } 

662 ) 

663 if license == "cc0-1.0": 

664 additional_descriptions.append( 

665 { 

666 "description": CC0_DISCLAIMER, 

667 "type": {"id": "notes"}, 

668 } 

669 ) 

670 config["additional_descriptions"] = additional_descriptions 

671 

672 config["locations"] = { 

673 "features": [ 

674 { 

675 "geometry": { 

676 "type": "Point", 

677 "coordinates": [loc["lon"], loc["lat"]], 

678 }, 

679 "place": loc["place"], 

680 "description": loc["description"], 

681 } 

682 for loc in base_config["locations"] 

683 ] 

684 } 

685 

686 for field in PROPAGATED_FIELDS: 

687 if field in base_config and field not in config: 

688 config[field] = base_config[field] 

689 

690 if "related_identifiers" in base_config: 

691 converted = [] 

692 for ri in base_config["related_identifiers"]: 

693 entry: dict = { 

694 "identifier": ri["identifier"], 

695 "relation_type": {"id": ri["relation"]}, 

696 } 

697 if "resource_type" in ri: 

698 entry["resource_type"] = {"id": ri["resource_type"]} 

699 if "scheme" in ri: 

700 entry["scheme"] = ri["scheme"] 

701 converted.append(entry) 

702 config["related_identifiers"] = converted 

703 

704 if entity_uri: 

705 config["identifiers"] = [{"identifier": entity_uri, "scheme": "url"}] 

706 

707 return config 

708 

709 

710def _get_sub_entity_ids(folders: list[tuple[str, str, dict]]) -> list[str]: 

711 entity_ids: list[str] = [] 

712 for _, folder_name, _ in folders: 

713 entity_id = extract_id_from_folder_name(folder_name) 

714 if entity_id not in entity_ids: 

715 entity_ids.append(entity_id) 

716 return entity_ids 

717 

718 

719_worker_kg: Graph 

720_worker_base_config: dict 

721_worker_creators_lookup: dict 

722 

723 

724def _init_worker(kg_path: Path, base_config: dict, creators_lookup: dict) -> None: 

725 global _worker_kg, _worker_base_config, _worker_creators_lookup 

726 _worker_kg = load_kg(kg_path) 

727 _worker_base_config = base_config 

728 _worker_creators_lookup = creators_lookup 

729 

730 

731def _process_entity( 

732 entity_id: str, 

733 folders: list[tuple[str, str, dict]], 

734 root: Path, 

735 zips_dir: Path, 

736 configs_dir: Path, 

737) -> None: 

738 kg = _worker_kg 

739 base_config = _worker_base_config 

740 creators_lookup = _worker_creators_lookup 

741 entity_ids = _get_sub_entity_ids(folders) 

742 title = extract_entity_title(kg, entity_ids) 

743 keeper_name, keeper_location = extract_keeper_info(kg, entity_ids) 

744 sala_slug = slugify(folders[0][0]) 

745 title_slug = slugify(title) 

746 metadata_creators = build_metadata_creators(kg, entity_ids, creators_lookup) 

747 for stage in STAGES: 

748 result = create_stage_zip(entity_id, stage, folders, root, zips_dir, title) 

749 if result is None: 

750 continue 

751 zip_path, license = result 

752 digitization_creators = build_creators_for_entity_stage( 

753 kg, entity_ids, stage, creators_lookup 

754 ) 

755 creators = merge_creators(digitization_creators, metadata_creators) 

756 entity_uri = build_entity_uri(entity_ids) 

757 methods_description = build_methods_description(kg, entity_ids, stage) 

758 missing_files_notice = select_missing_files_notice( 

759 kg, entity_ids, stage, license 

760 ) 

761 config = generate_zenodo_config( 

762 stage, 

763 zip_path, 

764 title, 

765 base_config, 

766 creators, 

767 methods_description, 

768 license, 

769 entity_uri, 

770 keeper_name, 

771 keeper_location, 

772 missing_files_notice, 

773 ) 

774 config_path = configs_dir / f"{sala_slug}-{title_slug}-{entity_id}-{stage}.yaml" 

775 with open(config_path, "w") as f: 

776 yaml.dump( 

777 config, 

778 f, 

779 Dumper=LiteralBlockDumper, 

780 default_flow_style=False, 

781 allow_unicode=True, 

782 sort_keys=False, 

783 ) 

784 

785 

786def prepare_all( 

787 root: Path, 

788 zenodo_base_config_path: Path, 

789 output_dir: Path, 

790 kg_path: Path = KG_PATH, 

791) -> None: 

792 structure = scan_folder_structure(root) 

793 entity_groups = group_folders_by_entity(structure) 

794 

795 with open(zenodo_base_config_path) as f: 

796 base_config = yaml.safe_load(f) 

797 

798 creators_lookup = load_creators_lookup(CREATORS_LOOKUP_PATH) 

799 

800 zips_dir = output_dir / "zips" 

801 configs_dir = output_dir / "configs" 

802 zips_dir.mkdir(parents=True, exist_ok=True) 

803 configs_dir.mkdir(parents=True, exist_ok=True) 

804 

805 with ProcessPoolExecutor( 

806 initializer=_init_worker, 

807 initargs=(kg_path, base_config, creators_lookup), 

808 ) as executor: 

809 futures = { 

810 executor.submit( 

811 _process_entity, entity_id, folders, root, zips_dir, configs_dir 

812 ): entity_id 

813 for entity_id, folders in entity_groups.items() 

814 } 

815 with Progress( 

816 SpinnerColumn(), 

817 TextColumn("[progress.description]{task.description}"), 

818 BarColumn(), 

819 MofNCompleteColumn(), 

820 TimeElapsedColumn(), 

821 TimeRemainingColumn(), 

822 ) as progress: 

823 task = progress.add_task("Creating stage packages", total=len(futures)) 

824 for future in as_completed(futures): 

825 entity_id = futures[future] 

826 future.result() 

827 progress.update(task, description=f"Completed entity {entity_id}") 

828 progress.advance(task) 

829 

830 

831def _extract_doi(record: Mapping[str, object]) -> str: 

832 pids = record.get("pids", {}) 

833 doi_info = pids.get("doi", {}) if isinstance(pids, dict) else {} 

834 return doi_info.get("identifier", "") if isinstance(doi_info, dict) else "" 

835 

836 

837def _extract_record_url(record: Mapping[str, object]) -> str: 

838 links = record["links"] 

839 assert isinstance(links, dict) 

840 return links["self_html"] 

841 

842 

843def _atomic_write_json(path: Path, data: list) -> None: 

844 fd, tmp_path = tempfile.mkstemp(dir=path.parent, suffix=".tmp") 

845 with os.fdopen(fd, "w") as f: 

846 json.dump(data, f, indent=2) 

847 os.replace(tmp_path, path) 

848 

849 

850@contextmanager 

851def _graceful_shutdown(): 

852 stop = [False] 

853 original = signal.getsignal(signal.SIGINT) 

854 

855 def handler(signum, frame): 

856 if stop[0]: 

857 signal.signal(signal.SIGINT, original) 

858 raise KeyboardInterrupt 

859 stop[0] = True 

860 print("Finishing current record, then stopping...") 

861 

862 signal.signal(signal.SIGINT, handler) 

863 try: 

864 yield stop 

865 finally: 

866 signal.signal(signal.SIGINT, original) 

867 

868 

869def upload_all(configs_dir: Path, publish: bool = False) -> None: 

870 config_files = sorted(configs_dir.glob("*.yaml")) 

871 drafts_path = configs_dir.parent / "drafts.json" 

872 

873 drafts: list[dict] = [] 

874 if drafts_path.exists(): 

875 with open(drafts_path) as f: 

876 drafts = json.load(f) 

877 

878 completed_stems = { 

879 Path(d["config_file"]).stem 

880 for d in drafts 

881 if d["status"] in ("uploaded", "published") 

882 } 

883 

884 skipped = 0 

885 failed = 0 

886 uploaded = 0 

887 

888 with ( 

889 _graceful_shutdown() as shutdown, 

890 Progress( 

891 SpinnerColumn(), 

892 TextColumn("[progress.description]{task.description}"), 

893 BarColumn(), 

894 MofNCompleteColumn(), 

895 ) as progress, 

896 ): 

897 task = progress.add_task("Uploading to Zenodo", total=len(config_files)) 

898 for config_file in config_files: 

899 if shutdown[0]: 

900 print("\nGraceful shutdown: stopping after last completed record.") 

901 break 

902 if config_file.stem in completed_stems: 

903 skipped += 1 

904 progress.update(task, description=f"Skipped {config_file.stem}") 

905 progress.advance(task) 

906 continue 

907 

908 progress.update(task, description=f"Uploading {config_file.stem}") 

909 try: 

910 record = piccione_upload(str(config_file), publish=publish) 

911 with open(config_file) as f: 

912 config = yaml.safe_load(f) 

913 drafts = [ 

914 d 

915 for d in drafts 

916 if not ( 

917 Path(d["config_file"]).stem == config_file.stem 

918 and d["status"] == "failed" 

919 ) 

920 ] 

921 drafts.append( 

922 { 

923 "draft_id": record["id"], 

924 "config_file": str(config_file), 

925 "title": config["title"], 

926 "zenodo_url": config["zenodo_url"], 

927 "access_token": config["access_token"], 

928 "user_agent": config["user_agent"], 

929 "status": "published" if publish else "uploaded", 

930 "doi": _extract_doi(record), 

931 "record_url": _extract_record_url(record), 

932 } 

933 ) 

934 uploaded += 1 

935 except Exception as exc: 

936 drafts.append( 

937 { 

938 "draft_id": "", 

939 "config_file": str(config_file), 

940 "title": config_file.stem, 

941 "zenodo_url": "", 

942 "access_token": "", 

943 "user_agent": "", 

944 "status": "failed", 

945 "doi": "", 

946 "record_url": "", 

947 "error": str(exc), 

948 } 

949 ) 

950 failed += 1 

951 print(f"\n[FAILED] {config_file.stem}: {exc}") 

952 

953 _atomic_write_json(drafts_path, drafts) 

954 time.sleep(2) 

955 progress.advance(task) 

956 

957 print(f"Drafts saved to {drafts_path}") 

958 print( 

959 f"Summary: {uploaded} uploaded, {skipped} skipped, {failed} failed (of {len(config_files)} total)" 

960 ) 

961 

962 

963def publish_all_drafts(drafts_path: Path) -> None: 

964 with open(drafts_path) as f: 

965 drafts: list[dict] = json.load(f) 

966 

967 publishable = [d for d in drafts if d["status"] in ("uploaded", "publish_failed")] 

968 published = 0 

969 failed = 0 

970 

971 with ( 

972 _graceful_shutdown() as shutdown, 

973 Progress( 

974 SpinnerColumn(), 

975 TextColumn("[progress.description]{task.description}"), 

976 BarColumn(), 

977 MofNCompleteColumn(), 

978 ) as progress, 

979 ): 

980 task = progress.add_task("Publishing drafts", total=len(publishable)) 

981 for draft in publishable: 

982 if shutdown[0]: 

983 print("\nGraceful shutdown: stopping after last completed record.") 

984 break 

985 progress.update(task, description=f"Publishing {draft['title']}") 

986 try: 

987 base_url = draft["zenodo_url"].rstrip("/") 

988 record = piccione_publish_draft( 

989 base_url, 

990 draft["access_token"], 

991 draft["draft_id"], 

992 draft["user_agent"], 

993 ) 

994 draft["status"] = "published" 

995 draft["doi"] = _extract_doi(record) 

996 draft["record_url"] = _extract_record_url(record) 

997 if "error" in draft: 

998 del draft["error"] 

999 published += 1 

1000 except Exception as exc: 

1001 draft["status"] = "publish_failed" 

1002 draft["error"] = str(exc) 

1003 failed += 1 

1004 print(f"\n[FAILED] {draft['title']}: {exc}") 

1005 

1006 _atomic_write_json(drafts_path, drafts) 

1007 time.sleep(2) 

1008 progress.advance(task) 

1009 

1010 skipped = len(drafts) - len(publishable) 

1011 print(f"Summary: {published} published, {skipped} skipped, {failed} failed") 

1012 

1013 

1014def sync_status(drafts_path: Path) -> None: 

1015 with open(drafts_path) as f: 

1016 drafts: list[dict] = json.load(f) 

1017 

1018 updated = 0 

1019 with Progress( 

1020 SpinnerColumn(), 

1021 TextColumn("[progress.description]{task.description}"), 

1022 BarColumn(), 

1023 MofNCompleteColumn(), 

1024 ) as progress: 

1025 task = progress.add_task("Syncing status from Zenodo", total=len(drafts)) 

1026 for draft in drafts: 

1027 progress.update(task, description=f"Querying {draft['draft_id']}") 

1028 headers = get_headers(draft["access_token"], draft["user_agent"]) 

1029 base_url = draft["zenodo_url"].rstrip("/") 

1030 resp = requests.get( 

1031 f"{base_url}/records/{draft['draft_id']}", headers=headers 

1032 ) 

1033 if resp.status_code == 404: 

1034 resp = requests.get( 

1035 f"{base_url}/records/{draft['draft_id']}/draft", headers=headers 

1036 ) 

1037 resp.raise_for_status() 

1038 record = resp.json() 

1039 new_status = record["status"] 

1040 new_doi = record.get("doi", "") 

1041 new_url = record["links"]["self_html"] 

1042 if ( 

1043 draft["status"] != new_status 

1044 or draft["doi"] != new_doi 

1045 or draft["record_url"] != new_url 

1046 ): 

1047 draft["status"] = new_status 

1048 draft["doi"] = new_doi 

1049 draft["record_url"] = new_url 

1050 updated += 1 

1051 time.sleep(0.5) 

1052 progress.advance(task) 

1053 

1054 _atomic_write_json(drafts_path, drafts) 

1055 print(f"Updated {updated} of {len(drafts)} entries") 

1056 

1057 

1058def _normalize_quotes(text: str) -> str: 

1059 return text.replace("‘", "'").replace("’", "'").replace("“", '"').replace("”", '"') 

1060 

1061 

1062def cleanup_duplicates(drafts_path: Path, dry_run: bool = False) -> None: 

1063 with open(drafts_path) as f: 

1064 drafts: list[dict] = json.load(f) 

1065 

1066 known_ids = {d["draft_id"] for d in drafts} 

1067 known_titles = {_normalize_quotes(d["title"]) for d in drafts} 

1068 token = drafts[0]["access_token"] 

1069 base_url = drafts[0]["zenodo_url"].rstrip("/") 

1070 ua = drafts[0]["user_agent"] 

1071 headers = get_headers(token, ua) 

1072 

1073 duplicates: list[dict] = [] 

1074 page = 1 

1075 while True: 

1076 resp = requests.get( 

1077 f"{base_url}/user/records", 

1078 params={ 

1079 "size": 100, 

1080 "page": page, 

1081 }, 

1082 headers=headers, 

1083 ) 

1084 resp.raise_for_status() 

1085 hits = resp.json()["hits"]["hits"] 

1086 if not hits: 

1087 break 

1088 for hit in hits: 

1089 title = hit.get("title", hit.get("metadata", {}).get("title", "")) 

1090 if hit["id"] not in known_ids and _normalize_quotes(title) in known_titles: 

1091 duplicates.append(hit) 

1092 page += 1 

1093 

1094 if not duplicates: 

1095 print("No duplicates found.") 

1096 return 

1097 

1098 draft_dups = [d for d in duplicates if d.get("status") != "published"] 

1099 published_dups = [d for d in duplicates if d.get("status") == "published"] 

1100 

1101 print( 

1102 f"Found {len(duplicates)} duplicate(s): {len(draft_dups)} draft(s), {len(published_dups)} published" 

1103 ) 

1104 

1105 for dup in published_dups: 

1106 print( 

1107 f" [PUBLISHED - cannot delete] id={dup['id']}, doi={dup.get('doi', '')}, title={dup.get('title', '')}" 

1108 ) 

1109 

1110 deleted = 0 

1111 for dup in draft_dups: 

1112 title = dup.get("title", dup.get("metadata", {}).get("title", "")) 

1113 if dry_run: 

1114 print(f" [DRY RUN] Would delete draft id={dup['id']}, title={title}") 

1115 else: 

1116 resp = requests.delete( 

1117 f"{base_url}/records/{dup['id']}/draft", headers=headers 

1118 ) 

1119 if resp.status_code == 204: 

1120 deleted += 1 

1121 print(f" [DELETED] id={dup['id']}, title={title}") 

1122 else: 

1123 print( 

1124 f" [FAILED] id={dup['id']}, status={resp.status_code}, body={resp.text[:200]}" 

1125 ) 

1126 time.sleep(1) 

1127 

1128 if dry_run: 

1129 print(f"Dry run complete. {len(draft_dups)} draft(s) would be deleted.") 

1130 else: 

1131 print(f"Deleted {deleted} of {len(draft_dups)} draft duplicate(s).") 

1132 

1133 

1134def parse_arguments(): # pragma: no cover 

1135 parser = argparse.ArgumentParser(description="Prepare and upload Zenodo packages") 

1136 subparsers = parser.add_subparsers(dest="command", required=True) 

1137 

1138 prepare_parser = subparsers.add_parser( 

1139 "prepare", help="Create zips and YAML configs" 

1140 ) 

1141 prepare_parser.add_argument( 

1142 "root", type=Path, help="Root directory with Sala/Folder/Stage structure" 

1143 ) 

1144 prepare_parser.add_argument( 

1145 "zenodo_config", type=Path, help="Base Zenodo configuration YAML" 

1146 ) 

1147 prepare_parser.add_argument( 

1148 "--output", 

1149 "-o", 

1150 type=Path, 

1151 default=Path("zenodo_output"), 

1152 help="Output directory", 

1153 ) 

1154 upload_parser = subparsers.add_parser("upload", help="Upload to Zenodo") 

1155 upload_parser.add_argument( 

1156 "configs_dir", type=Path, help="Directory containing YAML configs" 

1157 ) 

1158 upload_parser.add_argument( 

1159 "--publish", action="store_true", help="Publish after upload" 

1160 ) 

1161 publish_parser = subparsers.add_parser( 

1162 "publish-drafts", help="Publish previously uploaded drafts" 

1163 ) 

1164 publish_parser.add_argument( 

1165 "drafts_file", type=Path, help="Path to drafts.json from a previous upload" 

1166 ) 

1167 sync_parser = subparsers.add_parser( 

1168 "sync-status", help="Sync drafts.json with actual Zenodo record status" 

1169 ) 

1170 sync_parser.add_argument("drafts_file", type=Path, help="Path to drafts.json") 

1171 cleanup_parser = subparsers.add_parser( 

1172 "cleanup-duplicates", 

1173 help="Find and delete duplicate records not in drafts.json", 

1174 ) 

1175 cleanup_parser.add_argument("drafts_file", type=Path, help="Path to drafts.json") 

1176 cleanup_parser.add_argument( 

1177 "--dry-run", action="store_true", help="Only report duplicates, don't delete" 

1178 ) 

1179 

1180 return parser.parse_args() 

1181 

1182 

1183def main(): # pragma: no cover 

1184 args = parse_arguments() 

1185 if args.command == "prepare": 

1186 prepare_all( 

1187 root=args.root, 

1188 zenodo_base_config_path=args.zenodo_config, 

1189 output_dir=args.output, 

1190 ) 

1191 elif args.command == "upload": 

1192 upload_all(configs_dir=args.configs_dir, publish=args.publish) 

1193 elif args.command == "publish-drafts": 

1194 publish_all_drafts(drafts_path=args.drafts_file) 

1195 elif args.command == "sync-status": 

1196 sync_status(drafts_path=args.drafts_file) 

1197 elif args.command == "cleanup-duplicates": 

1198 cleanup_duplicates(drafts_path=args.drafts_file, dry_run=args.dry_run) 

1199 

1200 

1201if __name__ == "__main__": # pragma: no cover 

1202 main()