dspace-api-toolkit 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dspace_api/__init__.py +75 -0
- dspace_api/_base.py +407 -0
- dspace_api/_containers.py +217 -0
- dspace_api/_version.py +4 -0
- dspace_api/auth.py +467 -0
- dspace_api/authorizations.py +486 -0
- dspace_api/bundles.py +1193 -0
- dspace_api/collections.py +1069 -0
- dspace_api/communities.py +363 -0
- dspace_api/config.py +254 -0
- dspace_api/discovery.py +920 -0
- dspace_api/entity_types.py +127 -0
- dspace_api/epersons.py +647 -0
- dspace_api/exceptions.py +110 -0
- dspace_api/groups.py +599 -0
- dspace_api/items.py +549 -0
- dspace_api/metadata.py +742 -0
- dspace_api/permissions.py +1052 -0
- dspace_api/projection.py +292 -0
- dspace_api/relationships.py +694 -0
- dspace_api/session.py +538 -0
- dspace_api/site.py +98 -0
- dspace_api/utils.py +519 -0
- dspace_api/versions.py +292 -0
- dspace_api/vocabulary.py +365 -0
- dspace_api/workflow.py +893 -0
- dspace_api/workspaces.py +1404 -0
- dspace_api_toolkit-0.1.1.dist-info/METADATA +211 -0
- dspace_api_toolkit-0.1.1.dist-info/RECORD +32 -0
- dspace_api_toolkit-0.1.1.dist-info/WHEEL +5 -0
- dspace_api_toolkit-0.1.1.dist-info/licenses/LICENSE +28 -0
- dspace_api_toolkit-0.1.1.dist-info/top_level.txt +1 -0
dspace_api/discovery.py
ADDED
|
@@ -0,0 +1,920 @@
|
|
|
1
|
+
# -*- coding: UTF-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
Discovery (search, facets) and browse for DSpace API (DSpace 7+ / DSpace-CRIS)
|
|
4
|
+
|
|
5
|
+
GET /discover/search search configuration: filters, operators, sort options
|
|
6
|
+
GET /discover/search/objects search (query, dsoType, scope, configuration, f.<filter>, sort)
|
|
7
|
+
GET /discover/search/facets facets with their top values for a query
|
|
8
|
+
GET /discover/facets facet names of a configuration
|
|
9
|
+
GET /discover/facets/{name} values of one facet (paged; prefix, sort=count|index)
|
|
10
|
+
GET /discover/browses[/{index}] browse indexes
|
|
11
|
+
GET /discover/browses/search/byFields browse index configured for metadata fields
|
|
12
|
+
GET /discover/browses/{index}/entries 1st level of a metadata browse (authors, subjects, ...)
|
|
13
|
+
GET /discover/browses/{index}/items items of a flat browse, or of one entry
|
|
14
|
+
|
|
15
|
+
d = client.discovery
|
|
16
|
+
for item in d.iter_search("dc.title:climate*", dso_type="item", scope=coll,
|
|
17
|
+
filters={"author": "Doe, Jane", "dateIssued": ("[2020 TO 2026]", "equals")}):
|
|
18
|
+
...
|
|
19
|
+
d.facet_counts("subject", scope=coll) # {"Climate": 12, ...} - all values, paged
|
|
20
|
+
for entry in d.iter_browse_entries("author"): # every author with item counts
|
|
21
|
+
items = list(d.iter_browse_items("author", value=entry["value"]))
|
|
22
|
+
|
|
23
|
+
Filters use the contract syntax f.<name>=<value>,<operator>; names and
|
|
24
|
+
operators come from get_search_config() (operators: equals, notequals,
|
|
25
|
+
authority, notauthority, contains, notcontains, query).
|
|
26
|
+
|
|
27
|
+
The methods further down (search_objects, find_by_*, get_all_results, ...)
|
|
28
|
+
are the original simple helpers and keep their behaviour.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
import logging
|
|
32
|
+
from typing import Any, Dict, Iterable, Iterator, List, Optional, Sequence, Tuple, Union
|
|
33
|
+
|
|
34
|
+
from ._base import BaseManager
|
|
35
|
+
from .config import Endpoints, with_all_languages
|
|
36
|
+
from .exceptions import handle_response_error
|
|
37
|
+
from .projection import EmbedArg, ProjectionArg
|
|
38
|
+
|
|
39
|
+
# Logger for the module
|
|
40
|
+
logger = logging.getLogger(__name__)
|
|
41
|
+
|
|
42
|
+
# Discovery API endpoints and related constants
|
|
43
|
+
DISCOVER = "/discover"
|
|
44
|
+
OPERATORS = ("equals", "notequals", "authority", "notauthority", "contains", "notcontains", "query")
|
|
45
|
+
DSO_TYPES = ("all", "item", "community", "collection")
|
|
46
|
+
|
|
47
|
+
# {"author": "Doe"} / {"author": ("Doe", "contains")} / [("author", "Doe", "authority"), ...]
|
|
48
|
+
FilterArg = Union[None, Dict[str, Any], Sequence[Tuple[str, ...]]]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def filter_params(filters: FilterArg) -> List[Tuple[str, str]]:
|
|
52
|
+
"""
|
|
53
|
+
Discovery filters as query parameters f.<name>=<value>,<operator>.
|
|
54
|
+
|
|
55
|
+
{"author": "Doe, Jane"} -> f.author=Doe, Jane,equals
|
|
56
|
+
{"subject": ["a", "b"]} -> two f.subject parameters (AND)
|
|
57
|
+
{"title": ("rain", "contains")} -> f.title=rain,contains
|
|
58
|
+
[("author", "1234-uuid", "authority")] -> f.author=1234-uuid,authority
|
|
59
|
+
{"dateIssued": "[2020 TO 2026],equals"} -> passed through (operator already given)
|
|
60
|
+
|
|
61
|
+
Parameters:
|
|
62
|
+
filters (FilterArg): The filters to convert into query parameters.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
List[Tuple[str, str]]: A list of query parameter tuples.
|
|
66
|
+
"""
|
|
67
|
+
if not filters:
|
|
68
|
+
return []
|
|
69
|
+
items: List[Tuple[str, Any]] = list(filters.items()) if isinstance(filters, dict) else \
|
|
70
|
+
[(f[0], f[1:] if len(f) > 2 else f[1]) for f in filters]
|
|
71
|
+
params = []
|
|
72
|
+
for name, value in items:
|
|
73
|
+
values = value if isinstance(value, list) else [value]
|
|
74
|
+
for v in values:
|
|
75
|
+
if isinstance(v, tuple):
|
|
76
|
+
text, operator = v[0], (v[1] if len(v) > 1 else "equals")
|
|
77
|
+
else:
|
|
78
|
+
text, operator = v, None
|
|
79
|
+
head, _, tail = str(v).rpartition(",")
|
|
80
|
+
if head and tail in OPERATORS:
|
|
81
|
+
text, operator = head, tail
|
|
82
|
+
operator = operator or "equals"
|
|
83
|
+
if operator not in OPERATORS:
|
|
84
|
+
raise ValueError(f"Unknown filter operator {operator!r}; use one of {OPERATORS}")
|
|
85
|
+
params.append((f"f.{name}", f"{text},{operator}"))
|
|
86
|
+
return params
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class DiscoveryManager(BaseManager):
|
|
90
|
+
"""Manages DSpace discovery search, facets and browse indexes"""
|
|
91
|
+
|
|
92
|
+
def __init__(self, auth):
|
|
93
|
+
"""Initialize the DiscoveryManager with the given authentication."""
|
|
94
|
+
super().__init__(auth)
|
|
95
|
+
|
|
96
|
+
# ==================================================================
|
|
97
|
+
# Search (BaseManager based: retries, paging, projection / embed)
|
|
98
|
+
# ==================================================================
|
|
99
|
+
|
|
100
|
+
def _search_params(self, query=None, dso_type=None, scope=None, configuration=None, filters: FilterArg = None,
|
|
101
|
+
sort=None) -> List[Tuple[str, Any]]:
|
|
102
|
+
"""Convert search parameters into query parameters.
|
|
103
|
+
|
|
104
|
+
Parameters:
|
|
105
|
+
query (Optional[str]): The search query.
|
|
106
|
+
dso_type (Optional[str]): The type of DSpace object (e.g., "item", "collection").
|
|
107
|
+
scope (Optional[str]): The scope of the search.
|
|
108
|
+
configuration (Optional[str]): The search configuration to use.
|
|
109
|
+
filters (FilterArg, optional): The filters to apply. Defaults to None.
|
|
110
|
+
sort (Optional[str]): The sort order.
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
List[Tuple[str, Any]]: A list of query parameter tuples.
|
|
114
|
+
"""
|
|
115
|
+
if dso_type and str(dso_type).lower() not in DSO_TYPES:
|
|
116
|
+
raise ValueError(f"dso_type must be one of {DSO_TYPES}")
|
|
117
|
+
params = [(k, v) for k, v in (("query", query), ("dsoType", dso_type and str(dso_type).lower()),
|
|
118
|
+
("scope", scope), ("configuration", configuration), ("sort", sort)) if v]
|
|
119
|
+
return params + filter_params(filters)
|
|
120
|
+
|
|
121
|
+
def get_search_config(self, scope: Optional[str] = None, configuration: Optional[str] = None) -> Dict[str, Any]:
|
|
122
|
+
"""
|
|
123
|
+
Get the discovery search configuration.
|
|
124
|
+
|
|
125
|
+
Parameters:
|
|
126
|
+
scope (Optional[str], optional): The scope of the search. Defaults to None.
|
|
127
|
+
configuration (Optional[str], optional): The search configuration to use. Defaults to None.
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
Dict[str, Any]: The search configuration, including filters and sort options.
|
|
131
|
+
|
|
132
|
+
Example response:
|
|
133
|
+
{"filters": [{"filter", "type", "hasFacets", "operators"}], "sortOptions": [...], ...}"""
|
|
134
|
+
return self._get_json(self._url(DISCOVER, "/search"), "Get discovery search configuration",
|
|
135
|
+
params=self._search_params(scope=scope, configuration=configuration))
|
|
136
|
+
|
|
137
|
+
def get_search_filters(self, scope: Optional[str] = None, configuration: Optional[str] = None) -> List[str]:
|
|
138
|
+
"""Filter names usable in filters={...}.
|
|
139
|
+
|
|
140
|
+
Parameters:
|
|
141
|
+
scope (Optional[str], optional): The scope of the search. Defaults to None.
|
|
142
|
+
configuration (Optional[str], optional): The search configuration to use. Defaults to None.
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
List[str]: A list of filter names.
|
|
146
|
+
"""
|
|
147
|
+
return [f.get("filter") for f in self.get_search_config(scope, configuration).get("filters") or []]
|
|
148
|
+
|
|
149
|
+
def get_sort_options(self, scope: Optional[str] = None, configuration: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
150
|
+
"""Get the available sort options for the given scope and configuration.
|
|
151
|
+
|
|
152
|
+
Parameters:
|
|
153
|
+
scope (Optional[str], optional): The scope of the search. Defaults to None.
|
|
154
|
+
configuration (Optional[str], optional): The search configuration to use. Defaults to None.
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
List[Dict[str, Any]]: A list of sort option data.
|
|
158
|
+
"""
|
|
159
|
+
return self.get_search_config(scope, configuration).get("sortOptions") or []
|
|
160
|
+
|
|
161
|
+
def search(self, query: Optional[str] = None, dso_type: Optional[str] = None, scope: Optional[str] = None,
|
|
162
|
+
configuration: Optional[str] = None, filters: FilterArg = None, sort: Optional[str] = None,
|
|
163
|
+
page: int = 0, size: int = 20, all_languages: Optional[bool] = None,
|
|
164
|
+
projection: ProjectionArg = None, embed: EmbedArg = None) -> Dict[str, Any]:
|
|
165
|
+
"""One page of /discover/search/objects (raw response, incl. facets).
|
|
166
|
+
|
|
167
|
+
Parameters:
|
|
168
|
+
query (Optional[str], optional): The search query. Defaults to None.
|
|
169
|
+
dso_type (Optional[str], optional): The type of DSpace object (e.g., "item", "collection"). Defaults to None.
|
|
170
|
+
scope (Optional[str], optional): The scope of the search. Defaults to None.
|
|
171
|
+
configuration (Optional[str], optional): The search configuration to use. Defaults to None.
|
|
172
|
+
filters (FilterArg, optional): The filters to apply. Defaults to None.
|
|
173
|
+
sort (Optional[str], optional): The sort order. Defaults to None.
|
|
174
|
+
page (int, optional): The page number to retrieve. Defaults to 0.
|
|
175
|
+
size (int, optional): The number of results per page. Defaults to 20.
|
|
176
|
+
all_languages (Optional[bool], optional): Whether to include all languages. Defaults to None.
|
|
177
|
+
projection (ProjectionArg, optional): Projection to apply. Defaults to None.
|
|
178
|
+
embed (EmbedArg, optional): Embed options. Defaults to None.
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
Dict[str, Any]: The raw search response.
|
|
182
|
+
"""
|
|
183
|
+
params = self._search_params(query, dso_type, scope, configuration, filters, sort)
|
|
184
|
+
params += [("page", page), ("size", size)]
|
|
185
|
+
return self._get_json(self._url(DISCOVER, "/search/objects"), "Discovery search", params=params,
|
|
186
|
+
all_languages=all_languages, projection=projection, embed=embed)
|
|
187
|
+
|
|
188
|
+
@staticmethod
|
|
189
|
+
def search_result(response: Dict[str, Any]) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]:
|
|
190
|
+
"""Get the search result from a /discover/search/objects response.
|
|
191
|
+
|
|
192
|
+
Parameters:
|
|
193
|
+
response (Dict[str, Any]): The raw search response.
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
Tuple[List[Dict[str, Any]], Dict[str, Any]]: A tuple containing the list of objects and the page information.
|
|
197
|
+
|
|
198
|
+
(objects, page) of a /discover/search/objects response."""
|
|
199
|
+
result = (response.get("_embedded") or {}).get("searchResult") or {}
|
|
200
|
+
return (result.get("_embedded") or {}).get("objects") or [], result.get("page") or {}
|
|
201
|
+
|
|
202
|
+
def iter_search(self, query: Optional[str] = None, dso_type: Optional[str] = None, scope: Optional[str] = None,
|
|
203
|
+
configuration: Optional[str] = None, filters: FilterArg = None, sort: Optional[str] = None,
|
|
204
|
+
page_size: int = 100, max_results: Optional[int] = None, with_hits: bool = False,
|
|
205
|
+
all_languages: Optional[bool] = None, projection: ProjectionArg = None,
|
|
206
|
+
embed: EmbedArg = None) -> Iterator[Dict[str, Any]]:
|
|
207
|
+
"""
|
|
208
|
+
Every matching object across all pages (a generator). Yields the
|
|
209
|
+
indexableObject; with_hits=True yields the search result wrappers
|
|
210
|
+
(indexableObject in _embedded, hitHighlights) instead.
|
|
211
|
+
|
|
212
|
+
Parameters:
|
|
213
|
+
query (Optional[str], optional): Search query. Defaults to None.
|
|
214
|
+
dso_type (Optional[str], optional): The type of DSpace object to search for. Defaults to None.
|
|
215
|
+
scope (Optional[str], optional): The scope of the search. Defaults to None.
|
|
216
|
+
configuration (Optional[str], optional): The search configuration to use. Defaults to None.
|
|
217
|
+
filters (FilterArg, optional): Filters to apply. Defaults to None.
|
|
218
|
+
sort (Optional[str], optional): Sort order. Defaults to None.
|
|
219
|
+
page_size (int, optional): Number of results per page. Defaults to 100.
|
|
220
|
+
max_results (Optional[int], optional): Maximum number of results to yield. Defaults to None.
|
|
221
|
+
with_hits (bool, optional): Whether to yield search result wrappers instead of indexable objects. Defaults to False.
|
|
222
|
+
all_languages (Optional[bool], optional): Whether to include all languages. Defaults to None.
|
|
223
|
+
projection (ProjectionArg, optional): Projection to apply. Defaults to None.
|
|
224
|
+
embed (EmbedArg, optional): Embed options. Defaults to None.
|
|
225
|
+
|
|
226
|
+
Yields:
|
|
227
|
+
Dict[str, Any]: Each matching object or search result wrapper, depending on with_hits.
|
|
228
|
+
"""
|
|
229
|
+
page, yielded, previous = 0, 0, None
|
|
230
|
+
while True:
|
|
231
|
+
response = self.search(query, dso_type, scope, configuration, filters, sort, page, page_size,
|
|
232
|
+
all_languages, projection, embed)
|
|
233
|
+
objects, info = self.search_result(response)
|
|
234
|
+
if objects and objects == previous:
|
|
235
|
+
logger.warning("Discovery returned the same page twice - stopping (server ignores paging?)")
|
|
236
|
+
return
|
|
237
|
+
previous = objects
|
|
238
|
+
for obj in objects:
|
|
239
|
+
found = obj if with_hits else (obj.get("_embedded") or {}).get("indexableObject")
|
|
240
|
+
if not found:
|
|
241
|
+
continue
|
|
242
|
+
yield found
|
|
243
|
+
yielded += 1
|
|
244
|
+
if max_results is not None and yielded >= max_results:
|
|
245
|
+
return
|
|
246
|
+
total_pages = info.get("totalPages")
|
|
247
|
+
if not objects or (total_pages is not None and page + 1 >= total_pages) or \
|
|
248
|
+
(total_pages is None and len(objects) < page_size):
|
|
249
|
+
return
|
|
250
|
+
page += 1
|
|
251
|
+
|
|
252
|
+
def count(self, query: Optional[str] = None, dso_type: Optional[str] = None, scope: Optional[str] = None,
|
|
253
|
+
configuration: Optional[str] = None, filters: FilterArg = None) -> int:
|
|
254
|
+
"""Returns the total number of matches for the given query and filters.
|
|
255
|
+
|
|
256
|
+
Parameters:
|
|
257
|
+
query (Optional[str], optional): Search query. Defaults to None.
|
|
258
|
+
dso_type (Optional[str], optional): The type of DSpace object to search for. Defaults to None.
|
|
259
|
+
scope (Optional[str], optional): The scope of the search. Defaults to None.
|
|
260
|
+
configuration (Optional[str], optional): The search configuration to use. Defaults to None.
|
|
261
|
+
filters (FilterArg, optional): Filters to apply. Defaults to None.
|
|
262
|
+
|
|
263
|
+
Returns:
|
|
264
|
+
int: The total number of matches for the given query and filters.
|
|
265
|
+
"""
|
|
266
|
+
_, info = self.search_result(self.search(query, dso_type, scope, configuration, filters, size=1))
|
|
267
|
+
return int(info.get("totalElements") or 0)
|
|
268
|
+
|
|
269
|
+
# ------------------------------------------------------------------
|
|
270
|
+
# Facets
|
|
271
|
+
# ------------------------------------------------------------------
|
|
272
|
+
|
|
273
|
+
def search_facets(self, query: Optional[str] = None, dso_type: Optional[str] = None,
|
|
274
|
+
scope: Optional[str] = None, configuration: Optional[str] = None,
|
|
275
|
+
filters: FilterArg = None, names: Optional[Iterable[str]] = None) -> List[Dict[str, Any]]:
|
|
276
|
+
"""
|
|
277
|
+
Facets for a query, each with its first page of values in
|
|
278
|
+
facet["_embedded"]["values"] ([{"label", "count", "authorityKey"?}]).
|
|
279
|
+
names: keep only these facets.
|
|
280
|
+
|
|
281
|
+
Parameters:
|
|
282
|
+
query (Optional[str], optional): Search query. Defaults to None.
|
|
283
|
+
dso_type (Optional[str], optional): The type of DSpace object to search for. Defaults to None.
|
|
284
|
+
scope (Optional[str], optional): The scope of the search. Defaults to None.
|
|
285
|
+
configuration (Optional[str], optional): The search configuration to use. Defaults to None.
|
|
286
|
+
filters (FilterArg, optional): Filters to apply. Defaults to None.
|
|
287
|
+
names (Optional[Iterable[str]], optional): Facet names to keep. Defaults to None.
|
|
288
|
+
|
|
289
|
+
Returns:
|
|
290
|
+
List[Dict[str, Any]]: The list of facets with their first page of values.
|
|
291
|
+
"""
|
|
292
|
+
params = self._search_params(query, dso_type, scope, configuration, filters)
|
|
293
|
+
data = self._get_json(self._url(DISCOVER, "/search/facets"), "Discovery facets", params=params)
|
|
294
|
+
facets = (data.get("_embedded") or {}).get("facets") or []
|
|
295
|
+
wanted = set(names) if names else None
|
|
296
|
+
return [f for f in facets if wanted is None or f.get("name") in wanted]
|
|
297
|
+
|
|
298
|
+
def list_facets(self, scope: Optional[str] = None, configuration: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
299
|
+
"""Facets configured for a scope / configuration (name, facetType, facetLimit, ...), without values.
|
|
300
|
+
|
|
301
|
+
Parameters:
|
|
302
|
+
scope (Optional[str], optional): The scope of the search. Defaults to None.
|
|
303
|
+
configuration (Optional[str], optional): The search configuration to use. Defaults to None.
|
|
304
|
+
|
|
305
|
+
Returns:
|
|
306
|
+
List[Dict[str, Any]]: The list of configured facets without values.
|
|
307
|
+
"""
|
|
308
|
+
data = self._get_json(self._url(DISCOVER, "/facets"), "List facets",
|
|
309
|
+
params=self._search_params(scope=scope, configuration=configuration))
|
|
310
|
+
return (data.get("_embedded") or {}).get("facets") or []
|
|
311
|
+
|
|
312
|
+
def get_facet(self, name: str, query: Optional[str] = None, scope: Optional[str] = None,
|
|
313
|
+
configuration: Optional[str] = None, filters: FilterArg = None, dso_type: Optional[str] = None,
|
|
314
|
+
prefix: Optional[str] = None, sort: Optional[str] = None, page: int = 0,
|
|
315
|
+
size: int = 50) -> Dict[str, Any]:
|
|
316
|
+
"""
|
|
317
|
+
One page of a facet's values. sort: "count" (default: most frequent
|
|
318
|
+
first) or "index" (alphabetical), optionally ",asc|desc".
|
|
319
|
+
prefix: only values starting with it (DSpace extension used by the UI).
|
|
320
|
+
|
|
321
|
+
Parameters:
|
|
322
|
+
name (str): The name of the facet.
|
|
323
|
+
query (Optional[str], optional): Search query. Defaults to None.
|
|
324
|
+
scope (Optional[str], optional): The scope of the search. Defaults to None.
|
|
325
|
+
configuration (Optional[str], optional): The search configuration to use. Defaults to None.
|
|
326
|
+
filters (FilterArg, optional): Filters to apply. Defaults to None.
|
|
327
|
+
dso_type (Optional[str], optional): The type of DSpace object to search for. Defaults to None.
|
|
328
|
+
prefix (Optional[str], optional): Only values starting with this prefix. Defaults to None.
|
|
329
|
+
sort (Optional[str], optional): Sort order, "count" or "index", optionally with ",asc|desc". Defaults to None.
|
|
330
|
+
page (int, optional): The page number to retrieve. Defaults to 0.
|
|
331
|
+
size (int, optional): The number of values per page. Defaults to 50.
|
|
332
|
+
|
|
333
|
+
Returns:
|
|
334
|
+
Dict[str, Any]: The facet with its values for the specified page.
|
|
335
|
+
"""
|
|
336
|
+
params = self._search_params(query, dso_type, scope, configuration, filters, sort)
|
|
337
|
+
if prefix:
|
|
338
|
+
params.append(("prefix", prefix))
|
|
339
|
+
params += [("page", page), ("size", size)]
|
|
340
|
+
return self._get_json(self._url(DISCOVER, f"/facets/{name}"), f"Get facet {name}", params=params)
|
|
341
|
+
|
|
342
|
+
def iter_facet_values(self, name: str, page_size: int = 100, max_results: Optional[int] = None,
|
|
343
|
+
**kwargs) -> Iterator[Dict[str, Any]]:
|
|
344
|
+
"""
|
|
345
|
+
All values of a facet ({"label", "count", "authorityKey"?, ...}).
|
|
346
|
+
Facet pages carry no totalPages - paging follows hasMore / the next link.
|
|
347
|
+
kwargs: query, scope, configuration, filters, dso_type, prefix, sort.
|
|
348
|
+
|
|
349
|
+
Parameters:
|
|
350
|
+
name (str): The name of the facet.
|
|
351
|
+
page_size (int, optional): The number of values per page. Defaults to 100.
|
|
352
|
+
max_results (Optional[int], optional): The maximum number of results to yield. Defaults to None.
|
|
353
|
+
**kwargs: Additional arguments passed to `get_facet` (query, scope, configuration, filters, dso_type, prefix, sort).
|
|
354
|
+
|
|
355
|
+
Yields:
|
|
356
|
+
Dict[str, Any]: Each value of the facet.
|
|
357
|
+
"""
|
|
358
|
+
page, yielded, previous = 0, 0, None
|
|
359
|
+
while True:
|
|
360
|
+
data = self.get_facet(name, page=page, size=page_size, **kwargs)
|
|
361
|
+
values = (data.get("_embedded") or {}).get("values") or []
|
|
362
|
+
if values and values == previous:
|
|
363
|
+
logger.warning("Facet %s returned the same page twice - stopping", name)
|
|
364
|
+
return
|
|
365
|
+
previous = values
|
|
366
|
+
for value in values:
|
|
367
|
+
yield value
|
|
368
|
+
yielded += 1
|
|
369
|
+
if max_results is not None and yielded >= max_results:
|
|
370
|
+
return
|
|
371
|
+
has_next = data.get("hasMore") or "next" in (data.get("_links") or {})
|
|
372
|
+
if not values or not has_next:
|
|
373
|
+
return
|
|
374
|
+
page += 1
|
|
375
|
+
|
|
376
|
+
def facet_counts(self, name: str, **kwargs) -> Dict[str, int]:
|
|
377
|
+
"""Facet counts for a given facet.
|
|
378
|
+
|
|
379
|
+
{label: count} for every value of a facet.
|
|
380
|
+
|
|
381
|
+
Parameters:
|
|
382
|
+
name (str): The name of the facet.
|
|
383
|
+
**kwargs: Additional arguments passed to `iter_facet_values` (query, scope, configuration, filters, dso_type, prefix, sort).
|
|
384
|
+
|
|
385
|
+
Returns:
|
|
386
|
+
Dict[str, int]: A dictionary mapping facet labels to their counts.
|
|
387
|
+
"""
|
|
388
|
+
return {v.get("label"): int(v.get("count") or 0) for v in self.iter_facet_values(name, **kwargs)}
|
|
389
|
+
|
|
390
|
+
# ------------------------------------------------------------------
|
|
391
|
+
# Browse
|
|
392
|
+
# ------------------------------------------------------------------
|
|
393
|
+
|
|
394
|
+
def get_browse_indexes(self) -> List[Dict[str, Any]]:
|
|
395
|
+
"""
|
|
396
|
+
Get the list of browse indexes.
|
|
397
|
+
|
|
398
|
+
Returns:
|
|
399
|
+
List[Dict[str, Any]]: A list of browse indexes, each represented as a dictionary.
|
|
400
|
+
|
|
401
|
+
[{"id": "author", "browseType": "valueList"|"flatBrowse"|"hierarchicalBrowse", ...}]"""
|
|
402
|
+
return list(self._iter_embedded(self._url(DISCOVER, "/browses"), "browses", "List browse indexes"))
|
|
403
|
+
|
|
404
|
+
def get_browse_index(self, index: str) -> Dict[str, Any]:
|
|
405
|
+
"""
|
|
406
|
+
Get the configuration of a specific browse index by its ID.
|
|
407
|
+
|
|
408
|
+
Parameters:
|
|
409
|
+
index (str): The ID of the browse index.
|
|
410
|
+
|
|
411
|
+
Returns:
|
|
412
|
+
Dict[str, Any]: The browse index configuration.
|
|
413
|
+
"""
|
|
414
|
+
return self._get_json(self._url(DISCOVER, f"/browses/{index}"), f"Get browse index {index}")
|
|
415
|
+
|
|
416
|
+
def find_browse_index(self, fields: Union[str, Iterable[str]]) -> Optional[Dict[str, Any]]:
|
|
417
|
+
"""
|
|
418
|
+
Browse index configured for metadata field(s), e.g. "dc.contributor.author"; None if there is none.
|
|
419
|
+
|
|
420
|
+
Parameters:
|
|
421
|
+
fields (Union[str, Iterable[str]]): The metadata field(s) to search for.
|
|
422
|
+
|
|
423
|
+
Returns:
|
|
424
|
+
Optional[Dict[str, Any]]: The browse index configuration if found, otherwise None.
|
|
425
|
+
"""
|
|
426
|
+
fields = [fields] if isinstance(fields, str) else list(fields)
|
|
427
|
+
if not fields:
|
|
428
|
+
raise ValueError("At least one metadata field is required")
|
|
429
|
+
return self._get_optional(self._url(DISCOVER, "/browses/search/byFields"), "Find browse index by fields",
|
|
430
|
+
params=[("fields", f) for f in fields])
|
|
431
|
+
|
|
432
|
+
def _browse_page(self, url: str, key: str, operation: str, params: List[Tuple[str, Any]],
|
|
433
|
+
starts_with: Optional[str], page_size: int, max_results: Optional[int],
|
|
434
|
+
all_languages: Optional[bool] = None, embed: EmbedArg = None) -> Iterator[Dict[str, Any]]:
|
|
435
|
+
"""
|
|
436
|
+
Internal helper to iterate over browse pages.
|
|
437
|
+
|
|
438
|
+
Parameters:
|
|
439
|
+
url (str): The URL to fetch the browse page from.
|
|
440
|
+
key (str): The key to extract from the embedded response.
|
|
441
|
+
operation (str): The operation description for logging or error messages.
|
|
442
|
+
params (List[Tuple[str, Any]]): The query parameters for the request.
|
|
443
|
+
starts_with (Optional[str]): The prefix to start browsing from.
|
|
444
|
+
page_size (int): The number of items per page.
|
|
445
|
+
max_results (Optional[int]): The maximum number of results to yield.
|
|
446
|
+
all_languages (Optional[bool]): Whether to include all languages.
|
|
447
|
+
embed (EmbedArg): The embed argument for the request.
|
|
448
|
+
|
|
449
|
+
Yields:
|
|
450
|
+
Dict[str, Any]: Each browse entry.
|
|
451
|
+
"""
|
|
452
|
+
if starts_with:
|
|
453
|
+
# DSpace answers 422 when page and startsWith are combined: one page from the prefix on
|
|
454
|
+
size = max_results or page_size
|
|
455
|
+
data = self._get_json(url, operation, params=params + [("startsWith", starts_with), ("size", size)],
|
|
456
|
+
all_languages=all_languages, embed=embed)
|
|
457
|
+
yield from ((data.get("_embedded") or {}).get(key) or [])[:size]
|
|
458
|
+
return
|
|
459
|
+
yield from self._iter_embedded(url, key, operation, params, page_size=page_size, max_results=max_results,
|
|
460
|
+
all_languages=all_languages, embed=embed)
|
|
461
|
+
|
|
462
|
+
def iter_browse_entries(self, index: str, scope: Optional[str] = None, sort: Optional[str] = None,
|
|
463
|
+
starts_with: Optional[str] = None, page_size: int = 100,
|
|
464
|
+
max_results: Optional[int] = None) -> Iterator[Dict[str, Any]]:
|
|
465
|
+
"""
|
|
466
|
+
1st level of a metadata browse ("valueList"): {"value", "authority", "count", ...}.
|
|
467
|
+
starts_with: jump to the entries starting at this prefix (one page).
|
|
468
|
+
sort: "default,asc" / "default,desc".
|
|
469
|
+
|
|
470
|
+
Parameters:
|
|
471
|
+
index (str): The ID of the browse index.
|
|
472
|
+
scope (Optional[str]): The scope of the browse.
|
|
473
|
+
sort (Optional[str]): The sort order, e.g., "default,asc" or "default,desc".
|
|
474
|
+
starts_with (Optional[str]): The prefix to start browsing from.
|
|
475
|
+
page_size (int): The number of items per page.
|
|
476
|
+
max_results (Optional[int]): The maximum number of results to yield.
|
|
477
|
+
|
|
478
|
+
Yields:
|
|
479
|
+
Dict[str, Any]: Each browse entry.
|
|
480
|
+
"""
|
|
481
|
+
params = [(k, v) for k, v in (("scope", scope), ("sort", sort)) if v]
|
|
482
|
+
return self._browse_page(self._url(DISCOVER, f"/browses/{index}/entries"), "browseEntries",
|
|
483
|
+
f"Browse entries of {index}", params, starts_with, page_size, max_results)
|
|
484
|
+
|
|
485
|
+
def iter_browse_items(self, index: str, value: Optional[str] = None, authority: Optional[str] = None,
|
|
486
|
+
scope: Optional[str] = None, sort: Optional[str] = None,
|
|
487
|
+
starts_with: Optional[str] = None, page_size: int = 100,
|
|
488
|
+
max_results: Optional[int] = None, all_languages: Optional[bool] = None,
|
|
489
|
+
embed: EmbedArg = None) -> Iterator[Dict[str, Any]]:
|
|
490
|
+
"""
|
|
491
|
+
Items of a flat browse (e.g. "title", "dateissued"), or - for a
|
|
492
|
+
metadata browse - of one entry, given by value or authority (not both).
|
|
493
|
+
sort: "<sortOption>,asc|desc" from get_browse_index()["sortOptions"].
|
|
494
|
+
|
|
495
|
+
Parameters:
|
|
496
|
+
index (str): The ID of the browse index.
|
|
497
|
+
value (Optional[str]): The value to filter by.
|
|
498
|
+
authority (Optional[str]): The authority to filter by.
|
|
499
|
+
scope (Optional[str]): The scope of the browse.
|
|
500
|
+
sort (Optional[str]): The sort order, e.g., "default,asc" or "default,desc".
|
|
501
|
+
starts_with (Optional[str]): The prefix to start browsing from.
|
|
502
|
+
page_size (int): The number of items per page.
|
|
503
|
+
max_results (Optional[int]): The maximum number of results to yield.
|
|
504
|
+
all_languages (Optional[bool]): Whether to include all languages.
|
|
505
|
+
embed (EmbedArg): The embed argument for the request.
|
|
506
|
+
|
|
507
|
+
Yields:
|
|
508
|
+
Dict[str, Any]: Each browse item.
|
|
509
|
+
"""
|
|
510
|
+
if value is not None and authority is not None:
|
|
511
|
+
raise ValueError("Pass value or authority, not both")
|
|
512
|
+
params = [(k, v) for k, v in (("filterValue", value), ("filterAuthority", authority),
|
|
513
|
+
("scope", scope), ("sort", sort)) if v is not None]
|
|
514
|
+
return self._browse_page(self._url(DISCOVER, f"/browses/{index}/items"), "items", f"Browse items of {index}",
|
|
515
|
+
params, starts_with, page_size, max_results, all_languages, embed)
|
|
516
|
+
|
|
517
|
+
def iter_entry_items(self, entry: Dict[str, Any], page_size: int = 100, max_results: Optional[int] = None,
|
|
518
|
+
all_languages: Optional[bool] = None) -> Iterator[Dict[str, Any]]:
|
|
519
|
+
"""Items of a browse entry by following its "items" link (as the contract recommends).
|
|
520
|
+
|
|
521
|
+
Parameters:
|
|
522
|
+
entry (Dict[str, Any]): The browse entry containing the "items" link.
|
|
523
|
+
page_size (int): The number of items per page.
|
|
524
|
+
max_results (Optional[int]): The maximum number of results to yield.
|
|
525
|
+
all_languages (Optional[bool]): Whether to include all languages.
|
|
526
|
+
|
|
527
|
+
Yields:
|
|
528
|
+
Dict[str, Any]: Each browse item.
|
|
529
|
+
"""
|
|
530
|
+
href = ((entry.get("_links") or {}).get("items") or {}).get("href")
|
|
531
|
+
if not href:
|
|
532
|
+
raise ValueError("Browse entry has no items link")
|
|
533
|
+
return self._iter_embedded(self.resolve_href(href), "items", "Browse entry items",
|
|
534
|
+
page_size=page_size, max_results=max_results, all_languages=all_languages)
|
|
535
|
+
|
|
536
|
+
# ==================================================================
|
|
537
|
+
# Original helpers
|
|
538
|
+
# ==================================================================
|
|
539
|
+
|
|
540
|
+
def get_total_and_objects(self, results):
|
|
541
|
+
"""Return (total_elements, objects) from a discovery response with DSpace's nested structure.
|
|
542
|
+
|
|
543
|
+
Parameters:
|
|
544
|
+
results (dict): The discovery response.
|
|
545
|
+
|
|
546
|
+
Returns:
|
|
547
|
+
Tuple[int, List[dict]]: Total elements and list of objects.
|
|
548
|
+
"""
|
|
549
|
+
objects = (results.get('_embedded', {})
|
|
550
|
+
.get('searchResult', {})
|
|
551
|
+
.get('_embedded', {})
|
|
552
|
+
.get('objects', []))
|
|
553
|
+
total_elements = (results.get('_embedded', {})
|
|
554
|
+
.get('searchResult', {})
|
|
555
|
+
.get('page', {})
|
|
556
|
+
.get('totalElements', len(objects)))
|
|
557
|
+
return total_elements, objects
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def search_objects(self, query, page=0, size=10, sort="score,DESC",
|
|
561
|
+
projection="preventMetadataSecurity", scope=None,
|
|
562
|
+
filters=None, all_languages=None):
|
|
563
|
+
"""
|
|
564
|
+
Perform a discovery search query
|
|
565
|
+
|
|
566
|
+
Parameters:
|
|
567
|
+
query: Search query string
|
|
568
|
+
page: Page number (0-based)
|
|
569
|
+
size: Number of results per page
|
|
570
|
+
sort: Sort criteria (default: "score,DESC")
|
|
571
|
+
projection: Projection type
|
|
572
|
+
scope: Optional scope UUID (collection, community)
|
|
573
|
+
filters: Optional list of filter queries
|
|
574
|
+
all_languages: add projection=allLanguages (combined with `projection`);
|
|
575
|
+
None = client setting
|
|
576
|
+
|
|
577
|
+
Returns:
|
|
578
|
+
dict: Search results
|
|
579
|
+
"""
|
|
580
|
+
logger.info("Running discovery query: %s", query)
|
|
581
|
+
|
|
582
|
+
url = f"{self.config.dspace_url}{Endpoints.DISCOVER_SEARCH}"
|
|
583
|
+
|
|
584
|
+
params = {
|
|
585
|
+
"query": query,
|
|
586
|
+
"sort": sort,
|
|
587
|
+
"page": page,
|
|
588
|
+
"size": size,
|
|
589
|
+
"projection": projection
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
if scope:
|
|
593
|
+
params["scope"] = scope
|
|
594
|
+
|
|
595
|
+
if filters:
|
|
596
|
+
for i, filter_query in enumerate(filters):
|
|
597
|
+
params[f"f.{i}"] = filter_query
|
|
598
|
+
|
|
599
|
+
params = with_all_languages(params, self.config.use_all_languages(all_languages))
|
|
600
|
+
|
|
601
|
+
try:
|
|
602
|
+
response = self.session.get(
|
|
603
|
+
url,
|
|
604
|
+
headers=self.config.headers,
|
|
605
|
+
params=params,
|
|
606
|
+
timeout=self.config.timeout
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
if response.status_code == 200:
|
|
610
|
+
logger.info("Search completed successfully, found results")
|
|
611
|
+
return response.json()
|
|
612
|
+
handle_response_error(response, "Discovery search")
|
|
613
|
+
|
|
614
|
+
except Exception as e:
|
|
615
|
+
logger.error(f"Discovery search failed: {e}", exc_info=True)
|
|
616
|
+
raise
|
|
617
|
+
|
|
618
|
+
def find_by_metadata(self, field, value, entity_type=None, exact_match=True):
|
|
619
|
+
"""
|
|
620
|
+
Search for objects by a specific metadata field
|
|
621
|
+
|
|
622
|
+
Parameters:
|
|
623
|
+
field: Metadata field name (e.g., "dc.title", "dc.creator")
|
|
624
|
+
value: Value to search for
|
|
625
|
+
entity_type: Optional entity type filter
|
|
626
|
+
exact_match: Whether to search for exact matches
|
|
627
|
+
|
|
628
|
+
Returns:
|
|
629
|
+
dict: Search results
|
|
630
|
+
"""
|
|
631
|
+
if exact_match:
|
|
632
|
+
query = f'{field}:"{value}"'
|
|
633
|
+
else:
|
|
634
|
+
query = f'{field}:{value}'
|
|
635
|
+
|
|
636
|
+
if entity_type:
|
|
637
|
+
query += f' AND entityType:"{entity_type}"'
|
|
638
|
+
|
|
639
|
+
return self.search_objects(query)
|
|
640
|
+
|
|
641
|
+
def find_by_title(self, title, entity_type=None, exact_match=True):
|
|
642
|
+
"""
|
|
643
|
+
Search for objects by title
|
|
644
|
+
|
|
645
|
+
Parameters:
|
|
646
|
+
title: Title to search for
|
|
647
|
+
entity_type: Optional entity type filter
|
|
648
|
+
exact_match: Whether to search for exact matches
|
|
649
|
+
|
|
650
|
+
Returns:
|
|
651
|
+
dict: Search results
|
|
652
|
+
"""
|
|
653
|
+
return self.find_by_metadata("dc.title", title, entity_type, exact_match)
|
|
654
|
+
|
|
655
|
+
def find_by_author(self, author_name, entity_type="Publication", exact_match=False):
|
|
656
|
+
"""
|
|
657
|
+
Search for publications by author name
|
|
658
|
+
|
|
659
|
+
Parameters:
|
|
660
|
+
author_name: Author name to search for
|
|
661
|
+
entity_type: Entity type (default: "Publication")
|
|
662
|
+
exact_match: Whether to search for exact matches
|
|
663
|
+
|
|
664
|
+
Returns:
|
|
665
|
+
dict: Search results
|
|
666
|
+
"""
|
|
667
|
+
return self.find_by_metadata("dc.contributor.author", author_name, entity_type, exact_match)
|
|
668
|
+
|
|
669
|
+
def find_persons(self, name, exact_match=False):
|
|
670
|
+
"""
|
|
671
|
+
Search for Person entities
|
|
672
|
+
|
|
673
|
+
Parameters:
|
|
674
|
+
name: Person name to search for
|
|
675
|
+
exact_match: Whether to search for exact matches
|
|
676
|
+
|
|
677
|
+
Returns:
|
|
678
|
+
dict: Search results
|
|
679
|
+
"""
|
|
680
|
+
# Try both family name and given name fields
|
|
681
|
+
if exact_match:
|
|
682
|
+
query = f'(person.familyName:"{name}" OR person.givenName:"{name}" OR dc.title:"{name}") AND entityType:"Person"'
|
|
683
|
+
else:
|
|
684
|
+
query = f'(person.familyName:{name} OR person.givenName:{name} OR dc.title:{name}) AND entityType:"Person"'
|
|
685
|
+
|
|
686
|
+
return self.search_objects(query)
|
|
687
|
+
|
|
688
|
+
def find_by_handle(self, handle):
|
|
689
|
+
"""
|
|
690
|
+
Find an object by its handle
|
|
691
|
+
|
|
692
|
+
Parameters:
|
|
693
|
+
handle: DSpace handle (e.g., "123456789/123")
|
|
694
|
+
|
|
695
|
+
Returns:
|
|
696
|
+
dict: Search results
|
|
697
|
+
"""
|
|
698
|
+
query = f'handle:"{handle}"'
|
|
699
|
+
return self.search_objects(query, size=1)
|
|
700
|
+
|
|
701
|
+
def find_by_uuid(self, uuid):
|
|
702
|
+
"""
|
|
703
|
+
Find an object by its UUID
|
|
704
|
+
|
|
705
|
+
Parameters:
|
|
706
|
+
uuid: Object UUID
|
|
707
|
+
|
|
708
|
+
Returns:
|
|
709
|
+
dict: Search results
|
|
710
|
+
"""
|
|
711
|
+
query = f'uuid:"{uuid}"'
|
|
712
|
+
return self.search_objects(query, size=1)
|
|
713
|
+
|
|
714
|
+
def find_recent_items(self, entity_type=None, days=30, size=20):
|
|
715
|
+
"""
|
|
716
|
+
Find recently added items
|
|
717
|
+
|
|
718
|
+
Parameters:
|
|
719
|
+
entity_type: Optional entity type filter
|
|
720
|
+
days: Number of days back to search
|
|
721
|
+
size: Number of results to return
|
|
722
|
+
|
|
723
|
+
Returns:
|
|
724
|
+
dict: Search results
|
|
725
|
+
"""
|
|
726
|
+
query = "*:*"
|
|
727
|
+
if entity_type:
|
|
728
|
+
query += f' AND entityType:"{entity_type}"'
|
|
729
|
+
|
|
730
|
+
# Sort by last modified or date accessioned
|
|
731
|
+
sort = "lastModified,DESC"
|
|
732
|
+
|
|
733
|
+
return self.search_objects(query, size=size, sort=sort)
|
|
734
|
+
|
|
735
|
+
def search_in_collection(self, collection_uuid, query="*:*", **kwargs):
|
|
736
|
+
"""
|
|
737
|
+
Search within a specific collection
|
|
738
|
+
|
|
739
|
+
Parameters:
|
|
740
|
+
collection_uuid: UUID of the collection to search in
|
|
741
|
+
query: Search query (default: "*:*" for all items)
|
|
742
|
+
**kwargs: Additional search parameters
|
|
743
|
+
|
|
744
|
+
Returns:
|
|
745
|
+
dict: Search results
|
|
746
|
+
"""
|
|
747
|
+
return self.search_objects(query, scope=collection_uuid, **kwargs)
|
|
748
|
+
|
|
749
|
+
def search_in_community(self, community_uuid, query="*:*", **kwargs):
|
|
750
|
+
"""
|
|
751
|
+
Search within a specific community
|
|
752
|
+
|
|
753
|
+
Parameters:
|
|
754
|
+
community_uuid: UUID of the community to search in
|
|
755
|
+
query: Search query (default: "*:*" for all items)
|
|
756
|
+
**kwargs: Additional search parameters
|
|
757
|
+
|
|
758
|
+
Returns:
|
|
759
|
+
dict: Search results
|
|
760
|
+
"""
|
|
761
|
+
return self.search_objects(query, scope=community_uuid, **kwargs)
|
|
762
|
+
|
|
763
|
+
def get_facets(self, query="*:*", facet_fields=None, scope=None, configuration=None, filters: FilterArg = None):
|
|
764
|
+
"""
|
|
765
|
+
Facets for a query: {"_embedded": {"facets": [...]}} from
|
|
766
|
+
/discover/search/facets, limited to facet_fields (facet names such as
|
|
767
|
+
"author", "subject", "dateIssued") when given. See search_facets().
|
|
768
|
+
|
|
769
|
+
Parameters:
|
|
770
|
+
query (str): Search query (default: "*:*" for all items)
|
|
771
|
+
facet_fields (list, optional): List of facet field names to include
|
|
772
|
+
scope (str, optional): Scope for the search (collection or community UUID)
|
|
773
|
+
configuration (str, optional): Search configuration
|
|
774
|
+
filters (FilterArg, optional): Filters to apply
|
|
775
|
+
|
|
776
|
+
Returns:
|
|
777
|
+
dict: Faceted search results
|
|
778
|
+
"""
|
|
779
|
+
facets = self.search_facets(query=query, scope=scope, configuration=configuration, filters=filters,
|
|
780
|
+
names=facet_fields)
|
|
781
|
+
return {"_embedded": {"facets": facets}}
|
|
782
|
+
|
|
783
|
+
def parse_search_results(self, results):
|
|
784
|
+
"""
|
|
785
|
+
Parse search results into a more convenient format
|
|
786
|
+
|
|
787
|
+
Parameters:
|
|
788
|
+
results (dict): Raw search results from DSpace API
|
|
789
|
+
|
|
790
|
+
Returns:
|
|
791
|
+
list: List of parsed objects
|
|
792
|
+
"""
|
|
793
|
+
objects = results.get("_embedded", {}).get("searchResult", {}).get("_embedded", {}).get("objects", [])
|
|
794
|
+
parsed_objects = []
|
|
795
|
+
|
|
796
|
+
for obj in objects:
|
|
797
|
+
embedded = obj.get("_embedded", {})
|
|
798
|
+
item = embedded.get("indexableObject", {})
|
|
799
|
+
|
|
800
|
+
parsed = {
|
|
801
|
+
"uuid": item.get("uuid"),
|
|
802
|
+
"name": item.get("name"),
|
|
803
|
+
"handle": item.get("handle"),
|
|
804
|
+
"type": item.get("type"),
|
|
805
|
+
"entityType": item.get("entityType"),
|
|
806
|
+
"lastModified": item.get("lastModified"),
|
|
807
|
+
"metadata": {}
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
# Extract common metadata fields
|
|
811
|
+
metadata = item.get("metadata", {})
|
|
812
|
+
common_fields = [
|
|
813
|
+
"dc.title", "dc.creator", "dc.contributor.author", "dc.date.issued",
|
|
814
|
+
"dc.type", "dc.description", "dc.subject", "dc.identifier.uri",
|
|
815
|
+
"person.familyName", "person.givenName", "person.identifier.gnd"
|
|
816
|
+
]
|
|
817
|
+
|
|
818
|
+
for field in common_fields:
|
|
819
|
+
if field in metadata:
|
|
820
|
+
values = metadata[field]
|
|
821
|
+
if values:
|
|
822
|
+
parsed["metadata"][field] = [v.get("value") for v in values if v.get("value")]
|
|
823
|
+
|
|
824
|
+
# Add title as separate field for convenience
|
|
825
|
+
if "dc.title" in parsed["metadata"] and parsed["metadata"]["dc.title"]:
|
|
826
|
+
parsed["title"] = parsed["metadata"]["dc.title"][0]
|
|
827
|
+
|
|
828
|
+
# Add author/creator as separate field
|
|
829
|
+
author_fields = ["dc.contributor.author", "dc.creator"]
|
|
830
|
+
for field in author_fields:
|
|
831
|
+
if field in parsed["metadata"] and parsed["metadata"][field]:
|
|
832
|
+
parsed["author"] = parsed["metadata"][field][0]
|
|
833
|
+
break
|
|
834
|
+
|
|
835
|
+
# Add person name for Person entities
|
|
836
|
+
if parsed.get("entityType") == "Person":
|
|
837
|
+
family_name = parsed["metadata"].get("person.familyName", [""])[0]
|
|
838
|
+
given_name = parsed["metadata"].get("person.givenName", [""])[0]
|
|
839
|
+
if family_name or given_name:
|
|
840
|
+
parsed["person_name"] = f"{family_name}, {given_name}".strip(", ")
|
|
841
|
+
|
|
842
|
+
parsed_objects.append(parsed)
|
|
843
|
+
|
|
844
|
+
return parsed_objects
|
|
845
|
+
|
|
846
|
+
def count_results(self, query):
|
|
847
|
+
"""
|
|
848
|
+
Get the total count of results for a query without retrieving all results
|
|
849
|
+
|
|
850
|
+
Parameters:
|
|
851
|
+
query (str): The search query to count results for.
|
|
852
|
+
|
|
853
|
+
Returns:
|
|
854
|
+
int: Total number of results matching the query.
|
|
855
|
+
"""
|
|
856
|
+
results = self.search_objects(query, size=1)
|
|
857
|
+
total, _ = self.get_total_and_objects(results)
|
|
858
|
+
return total
|
|
859
|
+
|
|
860
|
+
|
|
861
|
+
def get_all_results(self, query: str, max_results: int | None = None, size: int = 20,
|
|
862
|
+
all_languages: bool | None = None) -> list[dict]:
|
|
863
|
+
"""
|
|
864
|
+
Fetch all discovery results across pages.
|
|
865
|
+
- Supports 'page' metadata both at root and under _embedded.searchResult.page
|
|
866
|
+
- Stops before requesting an empty, beyond-last page (so tests expecting 2 calls pass)
|
|
867
|
+
|
|
868
|
+
Parameters:
|
|
869
|
+
query (str): The search query to fetch results for.
|
|
870
|
+
max_results (int | None, optional): Maximum number of results to fetch. Defaults to None.
|
|
871
|
+
size (int, optional): Number of results per page. Defaults to 20.
|
|
872
|
+
all_languages (bool | None, optional): Whether to include all languages. Defaults to None.
|
|
873
|
+
|
|
874
|
+
Returns:
|
|
875
|
+
list[dict]: List of discovery result objects.
|
|
876
|
+
"""
|
|
877
|
+
results: list[dict] = []
|
|
878
|
+
page = 0
|
|
879
|
+
|
|
880
|
+
def _extract_page_info(res: dict) -> tuple[int | None, int | None]:
|
|
881
|
+
"""
|
|
882
|
+
Extract page number and total pages from the response.
|
|
883
|
+
|
|
884
|
+
Parameters:
|
|
885
|
+
res (dict): The raw search response from DSpace API.
|
|
886
|
+
|
|
887
|
+
Returns:
|
|
888
|
+
tuple[int | None, int | None]: Current page number and total pages, if available.
|
|
889
|
+
"""
|
|
890
|
+
page_block = res.get("page") or res.get("_embedded", {}).get("searchResult", {}).get("page") or {}
|
|
891
|
+
number = page_block.get("number")
|
|
892
|
+
total_pages = page_block.get("totalPages")
|
|
893
|
+
return number, total_pages
|
|
894
|
+
|
|
895
|
+
while True:
|
|
896
|
+
res = self.search_objects(query, page=page, size=size, all_languages=all_languages)
|
|
897
|
+
# Use helper to get the list of search objects
|
|
898
|
+
_total, objects = self.get_total_and_objects(res)
|
|
899
|
+
|
|
900
|
+
# Unwrap indexableObject and collect
|
|
901
|
+
for obj in (objects or []):
|
|
902
|
+
item = obj.get("_embedded", {}).get("indexableObject", {})
|
|
903
|
+
if item:
|
|
904
|
+
results.append(item)
|
|
905
|
+
if max_results is not None and len(results) >= max_results:
|
|
906
|
+
return results[:max_results]
|
|
907
|
+
|
|
908
|
+
# Pagination decision
|
|
909
|
+
number, total_pages = _extract_page_info(res)
|
|
910
|
+
|
|
911
|
+
# If we have valid page metadata and we're on the last page, stop without extra fetch
|
|
912
|
+
if number is not None and total_pages is not None and number >= (total_pages - 1):
|
|
913
|
+
break
|
|
914
|
+
|
|
915
|
+
# Otherwise, advance to next page; if there were no objects, also stop to avoid a loop
|
|
916
|
+
if not objects:
|
|
917
|
+
break
|
|
918
|
+
page += 1
|
|
919
|
+
|
|
920
|
+
return results
|