Skip to content

Commit b674bf6

Browse files
author
senyaaa
committed
feat: UI support for zip content previewing
* New entrypoint for zip enhanced previewer * Two new routes for preview and download container items * Zip enhanced previewer * Decorator to pass container item to views * view function to preview specific container item and download it * JS and HTML files
1 parent 0ec0c02 commit b674bf6

File tree

12 files changed

+476
-2
lines changed

12 files changed

+476
-2
lines changed

invenio_app_rdm/config.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -804,7 +804,9 @@ def files_rest_permission_factory(obj, action):
804804
"record_detail": "/records/<pid_value>",
805805
"record_export": "/records/<pid_value>/export/<export_format>",
806806
"record_file_preview": "/records/<pid_value>/preview/<path:filename>",
807+
"record_container_item_preview": "/records/<pid_value>/preview/<path:filename>/container/<path:path>",
807808
"record_file_download": "/records/<pid_value>/files/<path:filename>",
809+
"record_container_item_download": "/records/<pid_value>/files/<path:filename>/container/<path:path>",
808810
"record_thumbnail": "/records/<pid_value>/thumb<int:size>",
809811
"record_media_file_download": "/records/<pid_value>/media-files/<path:filename>",
810812
"record_from_pid": "/<any({schemes}):pid_scheme>/<path:pid_value>",
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
# -*- coding: utf-8 -*-
2+
#
3+
# This file is part of Invenio.
4+
# Copyright (C) 2025 CESNET i.a.l.e.
5+
#
6+
# Invenio is free software; you can redistribute it and/or modify it
7+
# under the terms of the MIT License; see LICENSE file for more details.
8+
9+
"""Simple ZIP archive previewer."""
10+
11+
from flask import render_template
12+
from invenio_access.permissions import system_identity
13+
from invenio_base import invenio_url_for
14+
from invenio_previewer.proxies import current_previewer
15+
from invenio_previewer.views import is_container_item_previewable
16+
17+
from invenio_app_rdm.records_ui.views.records import ContainerItemPreview
18+
19+
previewable_extensions = ["zip"]
20+
21+
22+
def create_container_item_preview_link(record_id, container_filename, item_path):
23+
"""Create preview link for a container item."""
24+
values = {
25+
"pid_value": record_id,
26+
"filename": container_filename, # specific .zip file
27+
"path": item_path,
28+
}
29+
return invenio_url_for(
30+
"invenio_app_rdm_records.record_container_item_preview", **values
31+
)
32+
33+
34+
def convert_zip_list_container(tree, record_id, container_filename):
35+
"""Convert structure returned by files.list_container(...).to_dict()."""
36+
37+
def convert_node(key, node, counter):
38+
"""Convert one node (file or folder)."""
39+
converted = {
40+
"name": key,
41+
"type": "item", # previewer later decides if it's folder
42+
"id": f"item{next(counter)}",
43+
"children": {},
44+
}
45+
46+
# Copy metadata fields if they exist
47+
for field in ("size", "compressed_size", "mime_type", "crc", "links"):
48+
if field in node:
49+
converted[field] = node[field]
50+
51+
# Case 1: File
52+
if node.get("type") == "file":
53+
# create preview link
54+
container_item_extension = node.get("key", "").split(".")[-1].lower()
55+
if is_container_item_previewable(container_item_extension):
56+
converted["links"].update(
57+
{
58+
"preview": create_container_item_preview_link(
59+
record_id, container_filename, node["id"]
60+
)
61+
}
62+
)
63+
return converted
64+
65+
# Case 2: Folder
66+
children = node.get("children", {})
67+
for child_key, child_node in children.items():
68+
converted["children"][child_key] = convert_node(
69+
child_key, child_node, counter
70+
)
71+
72+
return converted
73+
74+
# ID counter for "item0", "item1", ...
75+
def counter_gen():
76+
i = 0
77+
while True:
78+
yield i
79+
i += 1
80+
81+
counter = counter_gen()
82+
83+
# Root folder
84+
root = {"type": "folder", "id": -1, "children": {}}
85+
86+
# Convert children of root
87+
for key, child in tree.get("children", {}).items():
88+
root["children"][key] = convert_node(key, child, counter)
89+
90+
return root
91+
92+
93+
def children_to_list(node):
94+
"""Organize children structure."""
95+
if node["type"] == "item" and len(node["children"]) == 0:
96+
del node["children"]
97+
else:
98+
node["type"] = "folder"
99+
node["children"] = list(node["children"].values())
100+
node["children"].sort(key=lambda x: x["name"])
101+
node["children"] = map(children_to_list, node["children"])
102+
return node
103+
104+
105+
def can_preview(file):
106+
"""Return True if filetype can be previewed."""
107+
return (
108+
file.is_local()
109+
and file.has_extensions(".zip")
110+
and not isinstance(file, ContainerItemPreview) # we are top level file
111+
)
112+
113+
114+
def preview(file):
115+
"""Return the appropriate template and pass the file and an embed flag."""
116+
from invenio_rdm_records.proxies import current_rdm_records_service
117+
118+
tree_raw = current_rdm_records_service.files.list_container(
119+
system_identity, file.record["id"], file.filename
120+
).to_dict()
121+
122+
converted_tree = convert_zip_list_container(
123+
tree_raw, file.record["id"], file.filename
124+
)
125+
tree_list = children_to_list(converted_tree)["children"]
126+
return render_template(
127+
"invenio_previewer/previewable_zip.html",
128+
file=file,
129+
tree=tree_list,
130+
limit_reached=False,
131+
error=None,
132+
js_bundles=current_previewer.js_bundles + ["previewable-zip.js"],
133+
css_bundles=current_previewer.css_bundles + ["zip_css.css"],
134+
)

invenio_app_rdm/records_ui/views/__init__.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@
4848
from .records import (
4949
draft_not_found_error,
5050
not_found_error,
51+
record_container_item_download,
52+
record_container_item_preview,
5153
record_detail,
5254
record_export,
5355
record_file_download,
@@ -114,13 +116,25 @@ def create_blueprint(app):
114116
default_view_func=record_file_preview,
115117
)
116118
)
119+
blueprint.add_url_rule(
120+
**create_url_rule(
121+
routes["record_container_item_preview"],
122+
default_view_func=record_container_item_preview,
123+
)
124+
)
117125

118126
blueprint.add_url_rule(
119127
**create_url_rule(
120128
routes["record_file_download"],
121129
default_view_func=record_file_download,
122130
)
123131
)
132+
blueprint.add_url_rule(
133+
**create_url_rule(
134+
routes["record_container_item_download"],
135+
default_view_func=record_container_item_download,
136+
)
137+
)
124138
blueprint.add_url_rule(
125139
**create_url_rule(
126140
routes["record_thumbnail"],

invenio_app_rdm/records_ui/views/decorators.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
# Copyright (C) 2019-2025 CERN.
44
# Copyright (C) 2019-2025 Northwestern University.
55
# Copyright (C) 2021 TU Wien.
6+
# Copyright (C) 2025 CESNET i.a.l.e.
67
#
78
# Invenio App RDM is free software; you can redistribute it and/or modify it
89
# under the terms of the MIT License; see LICENSE file for more details.
@@ -249,6 +250,48 @@ def view(**kwargs):
249250
return decorator
250251

251252

253+
def pass_container_item():
254+
"""Decorator to pass a extracted file from container (e.g. zip)."""
255+
256+
def decorator(f):
257+
@wraps(f)
258+
def view(**kwargs):
259+
pid_value = kwargs.get("pid_value")
260+
file_key = kwargs.get("filename")
261+
path = kwargs.get("path")
262+
extract_kwargs = {
263+
"id_": pid_value,
264+
"file_key": file_key,
265+
"identity": g.identity,
266+
"path": path,
267+
}
268+
269+
from invenio_records_resources.proxies import current_service_registry
270+
271+
file_service = current_service_registry.get("files")
272+
273+
try:
274+
item = file_service.extract_from_container(**extract_kwargs)
275+
276+
kwargs["container_item"] = item
277+
return f(**kwargs)
278+
279+
except RecordDeletedException:
280+
# Redirect to the record page which has proper tombstone handling
281+
return redirect(
282+
url_for(
283+
"invenio_app_rdm_records.record_detail",
284+
pid_value=pid_value,
285+
),
286+
# Use 302 (temporary) instead of 301 since records can be restored
287+
code=302,
288+
)
289+
290+
return view
291+
292+
return decorator
293+
294+
252295
def pass_file_metadata(f):
253296
"""Decorate a view to pass a file's metadata using the files service."""
254297

0 commit comments

Comments
 (0)