crossref-local 0.4.0__py3-none-any.whl → 0.5.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.
Files changed (50) hide show
  1. crossref_local/__init__.py +24 -10
  2. crossref_local/_aio/__init__.py +30 -0
  3. crossref_local/_aio/_impl.py +238 -0
  4. crossref_local/_cache/__init__.py +15 -0
  5. crossref_local/{cache_export.py → _cache/export.py} +27 -10
  6. crossref_local/_cache/utils.py +93 -0
  7. crossref_local/_cli/__init__.py +9 -0
  8. crossref_local/_cli/cli.py +389 -0
  9. crossref_local/_cli/mcp.py +351 -0
  10. crossref_local/_cli/mcp_server.py +457 -0
  11. crossref_local/_cli/search.py +199 -0
  12. crossref_local/_core/__init__.py +62 -0
  13. crossref_local/{api.py → _core/api.py} +26 -5
  14. crossref_local/{citations.py → _core/citations.py} +55 -26
  15. crossref_local/{config.py → _core/config.py} +40 -22
  16. crossref_local/{db.py → _core/db.py} +32 -26
  17. crossref_local/_core/export.py +344 -0
  18. crossref_local/{fts.py → _core/fts.py} +37 -14
  19. crossref_local/{models.py → _core/models.py} +120 -6
  20. crossref_local/_remote/__init__.py +56 -0
  21. crossref_local/_remote/base.py +378 -0
  22. crossref_local/_remote/collections.py +175 -0
  23. crossref_local/_server/__init__.py +140 -0
  24. crossref_local/_server/middleware.py +25 -0
  25. crossref_local/_server/models.py +143 -0
  26. crossref_local/_server/routes_citations.py +98 -0
  27. crossref_local/_server/routes_collections.py +282 -0
  28. crossref_local/_server/routes_compat.py +102 -0
  29. crossref_local/_server/routes_works.py +178 -0
  30. crossref_local/_server/server.py +19 -0
  31. crossref_local/aio.py +30 -206
  32. crossref_local/cache.py +100 -100
  33. crossref_local/cli.py +5 -515
  34. crossref_local/jobs.py +169 -0
  35. crossref_local/mcp_server.py +5 -410
  36. crossref_local/remote.py +5 -266
  37. crossref_local/server.py +5 -349
  38. {crossref_local-0.4.0.dist-info → crossref_local-0.5.1.dist-info}/METADATA +36 -11
  39. crossref_local-0.5.1.dist-info/RECORD +49 -0
  40. {crossref_local-0.4.0.dist-info → crossref_local-0.5.1.dist-info}/entry_points.txt +1 -1
  41. crossref_local/cli_mcp.py +0 -275
  42. crossref_local-0.4.0.dist-info/RECORD +0 -27
  43. /crossref_local/{cache_viz.py → _cache/viz.py} +0 -0
  44. /crossref_local/{cli_cache.py → _cli/cache.py} +0 -0
  45. /crossref_local/{cli_completion.py → _cli/completion.py} +0 -0
  46. /crossref_local/{cli_main.py → _cli/main.py} +0 -0
  47. /crossref_local/{impact_factor → _impact_factor}/__init__.py +0 -0
  48. /crossref_local/{impact_factor → _impact_factor}/calculator.py +0 -0
  49. /crossref_local/{impact_factor → _impact_factor}/journal_lookup.py +0 -0
  50. {crossref_local-0.4.0.dist-info → crossref_local-0.5.1.dist-info}/WHEEL +0 -0
@@ -0,0 +1,25 @@
1
+ """Request middleware for CrossRef Local API."""
2
+
3
+ from fastapi import Request
4
+ from starlette.middleware.base import BaseHTTPMiddleware
5
+
6
+
7
+ class UserContextMiddleware(BaseHTTPMiddleware):
8
+ """Extract X-User-ID header for multi-tenant collection scoping.
9
+
10
+ When requests come through scitex-cloud gateway, it passes the
11
+ authenticated user's ID via X-User-ID header. This middleware
12
+ extracts it and makes it available via request.state.user_id.
13
+
14
+ Usage in endpoints:
15
+ @app.get("/collections")
16
+ def list_collections(request: Request):
17
+ user_id = request.state.user_id # None for local, set for cloud
18
+ ...
19
+ """
20
+
21
+ async def dispatch(self, request: Request, call_next):
22
+ # Extract user ID from header (passed by scitex-cloud gateway)
23
+ request.state.user_id = request.headers.get("X-User-ID")
24
+ response = await call_next(request)
25
+ return response
@@ -0,0 +1,143 @@
1
+ """Pydantic models for API responses."""
2
+
3
+ from typing import Optional, List
4
+ from pydantic import BaseModel
5
+
6
+ from .. import __version__
7
+
8
+
9
+ class WorkResponse(BaseModel):
10
+ """Work metadata response."""
11
+
12
+ doi: str
13
+ title: Optional[str] = None
14
+ authors: List[str] = []
15
+ year: Optional[int] = None
16
+ journal: Optional[str] = None
17
+ issn: Optional[str] = None
18
+ volume: Optional[str] = None
19
+ issue: Optional[str] = None
20
+ page: Optional[str] = None
21
+ abstract: Optional[str] = None
22
+ citation_count: Optional[int] = None
23
+ impact_factor: Optional[float] = None
24
+ impact_factor_source: Optional[str] = None
25
+
26
+
27
+ class LimitInfoResponse(BaseModel):
28
+ """Information about result limiting."""
29
+
30
+ requested: int
31
+ returned: int
32
+ total_available: int
33
+ capped: bool = False
34
+ capped_reason: Optional[str] = None
35
+ stage: str = "crossref-local"
36
+
37
+
38
+ class SearchResponse(BaseModel):
39
+ """Search results response."""
40
+
41
+ query: str
42
+ total: int
43
+ returned: int
44
+ elapsed_ms: float
45
+ results: List[WorkResponse]
46
+ limit_info: Optional[LimitInfoResponse] = None
47
+
48
+
49
+ class InfoResponse(BaseModel):
50
+ """Database info response."""
51
+
52
+ name: str = "CrossRef Local API"
53
+ version: str = __version__
54
+ status: str = "running"
55
+ mode: str = "local"
56
+ total_papers: int
57
+ fts_indexed: int
58
+ citations: int
59
+ database_path: str
60
+
61
+
62
+ class BatchRequest(BaseModel):
63
+ """Batch DOI lookup request."""
64
+
65
+ dois: List[str]
66
+
67
+
68
+ class BatchResponse(BaseModel):
69
+ """Batch DOI lookup response."""
70
+
71
+ requested: int
72
+ found: int
73
+ results: List[WorkResponse]
74
+
75
+
76
+ # Citation models
77
+ class CitingResponse(BaseModel):
78
+ """Papers citing a DOI."""
79
+
80
+ doi: str
81
+ citing_count: int
82
+ papers: List[str]
83
+
84
+
85
+ class CitedResponse(BaseModel):
86
+ """Papers cited by a DOI."""
87
+
88
+ doi: str
89
+ cited_count: int
90
+ papers: List[str]
91
+
92
+
93
+ class CitationCountResponse(BaseModel):
94
+ """Citation count for a DOI."""
95
+
96
+ doi: str
97
+ citation_count: int
98
+
99
+
100
+ class CitationNetworkResponse(BaseModel):
101
+ """Citation network graph."""
102
+
103
+ center_doi: str
104
+ depth: int
105
+ total_nodes: int
106
+ total_edges: int
107
+ nodes: List[dict]
108
+ edges: List[dict]
109
+
110
+
111
+ # Collection models
112
+ class CollectionCreateRequest(BaseModel):
113
+ """Create collection request."""
114
+
115
+ name: str
116
+ query: Optional[str] = None
117
+ dois: Optional[List[str]] = None
118
+ limit: int = 1000
119
+
120
+
121
+ class CollectionInfo(BaseModel):
122
+ """Collection information."""
123
+
124
+ name: str
125
+ path: str
126
+ size_bytes: int
127
+ size_mb: float
128
+ paper_count: int
129
+ created_at: str
130
+ query: Optional[str] = None
131
+
132
+
133
+ class CollectionQueryRequest(BaseModel):
134
+ """Query collection request."""
135
+
136
+ fields: Optional[List[str]] = None
137
+ include_abstract: bool = False
138
+ include_references: bool = False
139
+ include_citations: bool = False
140
+ year_min: Optional[int] = None
141
+ year_max: Optional[int] = None
142
+ journal: Optional[str] = None
143
+ limit: Optional[int] = None
@@ -0,0 +1,98 @@
1
+ """Citation network endpoints."""
2
+
3
+ from fastapi import APIRouter, Query
4
+
5
+ from .._core.citations import get_citing, get_cited, get_citation_count, CitationNetwork
6
+ from .models import (
7
+ CitingResponse,
8
+ CitedResponse,
9
+ CitationCountResponse,
10
+ CitationNetworkResponse,
11
+ )
12
+
13
+ router = APIRouter(prefix="/citations", tags=["citations"])
14
+
15
+
16
+ @router.get("/{doi:path}/citing", response_model=CitingResponse)
17
+ def get_citing_papers(
18
+ doi: str,
19
+ limit: int = Query(100, ge=1, le=1000, description="Max papers to return"),
20
+ ):
21
+ """
22
+ Get papers that cite this DOI.
23
+
24
+ Examples:
25
+ /citations/10.1038/nature12373/citing
26
+ /citations/10.1038/nature12373/citing?limit=50
27
+ """
28
+ citing_dois = get_citing(doi, limit=limit)
29
+ return CitingResponse(
30
+ doi=doi,
31
+ citing_count=len(citing_dois),
32
+ papers=citing_dois,
33
+ )
34
+
35
+
36
+ @router.get("/{doi:path}/cited", response_model=CitedResponse)
37
+ def get_cited_papers(
38
+ doi: str,
39
+ limit: int = Query(100, ge=1, le=1000, description="Max papers to return"),
40
+ ):
41
+ """
42
+ Get papers cited by this DOI (references).
43
+
44
+ Examples:
45
+ /citations/10.1038/nature12373/cited
46
+ /citations/10.1038/nature12373/cited?limit=50
47
+ """
48
+ cited_dois = get_cited(doi, limit=limit)
49
+ return CitedResponse(
50
+ doi=doi,
51
+ cited_count=len(cited_dois),
52
+ papers=cited_dois,
53
+ )
54
+
55
+
56
+ @router.get("/{doi:path}/count", response_model=CitationCountResponse)
57
+ def get_citation_count_endpoint(doi: str):
58
+ """
59
+ Get citation count for a DOI.
60
+
61
+ Examples:
62
+ /citations/10.1038/nature12373/count
63
+ """
64
+ count = get_citation_count(doi)
65
+ return CitationCountResponse(doi=doi, citation_count=count)
66
+
67
+
68
+ @router.get("/{doi:path}/network", response_model=CitationNetworkResponse)
69
+ def get_citation_network(
70
+ doi: str,
71
+ depth: int = Query(1, ge=1, le=3, description="Network depth (1-3)"),
72
+ max_citing: int = Query(25, ge=1, le=100, description="Max citing per node"),
73
+ max_cited: int = Query(25, ge=1, le=100, description="Max cited per node"),
74
+ ):
75
+ """
76
+ Get citation network graph for a DOI.
77
+
78
+ Returns nodes (papers) and edges (citation relationships).
79
+
80
+ Examples:
81
+ /citations/10.1038/nature12373/network
82
+ /citations/10.1038/nature12373/network?depth=2&max_citing=50
83
+ """
84
+ network = CitationNetwork(
85
+ doi,
86
+ depth=depth,
87
+ max_citing=max_citing,
88
+ max_cited=max_cited,
89
+ )
90
+ data = network.to_dict()
91
+ return CitationNetworkResponse(
92
+ center_doi=data["center_doi"],
93
+ depth=data["depth"],
94
+ total_nodes=data["stats"]["total_nodes"],
95
+ total_edges=data["stats"]["total_edges"],
96
+ nodes=data["nodes"],
97
+ edges=data["edges"],
98
+ )
@@ -0,0 +1,282 @@
1
+ """Collection management endpoints with file download support."""
2
+
3
+ import tempfile
4
+ from typing import Optional
5
+
6
+ from fastapi import APIRouter, Query, HTTPException, Request
7
+ from fastapi.responses import FileResponse
8
+
9
+ from .. import cache
10
+ from .._cache.utils import sanitize_name
11
+ from .models import CollectionCreateRequest, CollectionInfo
12
+
13
+
14
+ # Allowed fields for field filtering (whitelist)
15
+ ALLOWED_FIELDS = {
16
+ "doi",
17
+ "title",
18
+ "authors",
19
+ "year",
20
+ "journal",
21
+ "volume",
22
+ "issue",
23
+ "page",
24
+ "abstract",
25
+ "citation_count",
26
+ "references",
27
+ "issn",
28
+ "publisher",
29
+ }
30
+
31
+ # Maximum limits
32
+ MAX_LIMIT = 10000
33
+ MAX_DOIS = 1000
34
+
35
+ router = APIRouter(prefix="/collections", tags=["collections"])
36
+
37
+
38
+ def _get_user_id(request: Request) -> Optional[str]:
39
+ """Get user ID from request state (set by middleware)."""
40
+ return getattr(request.state, "user_id", None)
41
+
42
+
43
+ @router.get("")
44
+ def list_collections(request: Request):
45
+ """
46
+ List all collections.
47
+
48
+ For cloud API (with X-User-ID header), returns only user's collections.
49
+ For local API, returns all collections.
50
+ """
51
+ user_id = _get_user_id(request)
52
+ caches = cache.list_caches(user_id=user_id)
53
+ return {
54
+ "count": len(caches),
55
+ "collections": [c.to_dict() for c in caches],
56
+ }
57
+
58
+
59
+ @router.post("", response_model=CollectionInfo)
60
+ def create_collection(request: Request, body: CollectionCreateRequest):
61
+ """
62
+ Create a new collection from search query or DOI list.
63
+
64
+ Request body:
65
+ {"name": "epilepsy", "query": "epilepsy seizure", "limit": 500}
66
+ or
67
+ {"name": "my_papers", "dois": ["10.1038/...", "10.1016/..."]}
68
+ """
69
+ user_id = _get_user_id(request)
70
+
71
+ # Validate collection name
72
+ try:
73
+ sanitize_name(body.name)
74
+ except ValueError as e:
75
+ raise HTTPException(status_code=400, detail=str(e))
76
+
77
+ if not body.query and not body.dois:
78
+ raise HTTPException(
79
+ status_code=400,
80
+ detail="Must provide 'query' or 'dois'",
81
+ )
82
+
83
+ # Validate limits
84
+ if body.limit > MAX_LIMIT:
85
+ raise HTTPException(
86
+ status_code=400,
87
+ detail=f"Limit exceeds maximum ({MAX_LIMIT})",
88
+ )
89
+
90
+ if body.dois and len(body.dois) > MAX_DOIS:
91
+ raise HTTPException(
92
+ status_code=400,
93
+ detail=f"Too many DOIs ({len(body.dois)}). Maximum: {MAX_DOIS}",
94
+ )
95
+
96
+ try:
97
+ info = cache.create(
98
+ body.name,
99
+ query=body.query,
100
+ dois=body.dois,
101
+ limit=body.limit,
102
+ user_id=user_id,
103
+ )
104
+ return CollectionInfo(**info.to_dict())
105
+ except ValueError as e:
106
+ raise HTTPException(status_code=400, detail=str(e))
107
+ except Exception as e:
108
+ raise HTTPException(status_code=500, detail=str(e))
109
+
110
+
111
+ @router.get("/{name}")
112
+ def query_collection(
113
+ name: str,
114
+ request: Request,
115
+ fields: Optional[str] = Query(None, description="Comma-separated field list"),
116
+ include_abstract: bool = Query(False, description="Include abstracts"),
117
+ include_references: bool = Query(False, description="Include references"),
118
+ include_citations: bool = Query(False, description="Include citation counts"),
119
+ year_min: Optional[int] = Query(None, description="Filter by min year"),
120
+ year_max: Optional[int] = Query(None, description="Filter by max year"),
121
+ journal: Optional[str] = Query(None, description="Filter by journal"),
122
+ limit: Optional[int] = Query(None, description="Max results"),
123
+ ):
124
+ """
125
+ Query a collection with field filtering.
126
+
127
+ Returns minimal data to reduce response size.
128
+ Use 'fields' parameter to specify exactly which fields to return.
129
+
130
+ Examples:
131
+ /collections/epilepsy?fields=doi,title,year
132
+ /collections/epilepsy?year_min=2020&include_citations=true
133
+ """
134
+ user_id = _get_user_id(request)
135
+
136
+ # Validate collection name
137
+ try:
138
+ sanitize_name(name)
139
+ except ValueError as e:
140
+ raise HTTPException(status_code=400, detail=str(e))
141
+
142
+ if not cache.exists(name, user_id=user_id):
143
+ raise HTTPException(status_code=404, detail=f"Collection not found: {name}")
144
+
145
+ # Validate and filter fields
146
+ field_list = None
147
+ if fields:
148
+ field_list = [f.strip() for f in fields.split(",")]
149
+ invalid_fields = set(field_list) - ALLOWED_FIELDS
150
+ if invalid_fields:
151
+ raise HTTPException(
152
+ status_code=400,
153
+ detail=f"Invalid fields: {invalid_fields}. Allowed: {ALLOWED_FIELDS}",
154
+ )
155
+
156
+ papers = cache.query(
157
+ name,
158
+ fields=field_list,
159
+ include_abstract=include_abstract,
160
+ include_references=include_references,
161
+ include_citations=include_citations,
162
+ year_min=year_min,
163
+ year_max=year_max,
164
+ journal=journal,
165
+ limit=limit,
166
+ user_id=user_id,
167
+ )
168
+
169
+ return {
170
+ "name": name,
171
+ "count": len(papers),
172
+ "papers": papers,
173
+ }
174
+
175
+
176
+ @router.get("/{name}/stats")
177
+ def collection_stats(name: str, request: Request):
178
+ """
179
+ Get collection statistics.
180
+
181
+ Returns year distribution, top journals, citation stats.
182
+ """
183
+ user_id = _get_user_id(request)
184
+
185
+ try:
186
+ sanitize_name(name)
187
+ except ValueError as e:
188
+ raise HTTPException(status_code=400, detail=str(e))
189
+
190
+ if not cache.exists(name, user_id=user_id):
191
+ raise HTTPException(status_code=404, detail=f"Collection not found: {name}")
192
+
193
+ stats = cache.stats(name, user_id=user_id)
194
+ return {"name": name, **stats}
195
+
196
+
197
+ @router.get("/{name}/download")
198
+ def download_collection(
199
+ name: str,
200
+ request: Request,
201
+ format: str = Query("json", description="Export format: json, csv, bibtex, dois"),
202
+ fields: Optional[str] = Query(None, description="Fields to include (json/csv)"),
203
+ ):
204
+ """
205
+ Download collection as a file.
206
+
207
+ Supports multiple formats:
208
+ - json: Full JSON with all fields or specified fields
209
+ - csv: CSV format with specified fields
210
+ - bibtex: BibTeX format for bibliography
211
+ - dois: Plain text list of DOIs
212
+
213
+ Examples:
214
+ /collections/epilepsy/download?format=json
215
+ /collections/epilepsy/download?format=bibtex
216
+ /collections/epilepsy/download?format=csv&fields=doi,title,year
217
+ """
218
+ user_id = _get_user_id(request)
219
+
220
+ try:
221
+ sanitize_name(name)
222
+ except ValueError as e:
223
+ raise HTTPException(status_code=400, detail=str(e))
224
+
225
+ if not cache.exists(name, user_id=user_id):
226
+ raise HTTPException(status_code=404, detail=f"Collection not found: {name}")
227
+
228
+ # Determine file extension and media type
229
+ format_info = {
230
+ "json": ("application/json", ".json"),
231
+ "csv": ("text/csv", ".csv"),
232
+ "bibtex": ("application/x-bibtex", ".bib"),
233
+ "dois": ("text/plain", ".txt"),
234
+ }
235
+
236
+ if format not in format_info:
237
+ raise HTTPException(
238
+ status_code=400,
239
+ detail=f"Unsupported format: {format}. Use: json, csv, bibtex, dois",
240
+ )
241
+
242
+ media_type, ext = format_info[format]
243
+ filename = f"{name}{ext}"
244
+
245
+ # Export to temporary file
246
+ with tempfile.NamedTemporaryFile(mode="w", suffix=ext, delete=False) as tmp:
247
+ field_list = fields.split(",") if fields else None
248
+ cache.export(
249
+ name,
250
+ tmp.name,
251
+ format=format,
252
+ fields=field_list,
253
+ user_id=user_id,
254
+ )
255
+ tmp_path = tmp.name
256
+
257
+ return FileResponse(
258
+ tmp_path,
259
+ media_type=media_type,
260
+ filename=filename,
261
+ headers={"Content-Disposition": f'attachment; filename="{filename}"'},
262
+ )
263
+
264
+
265
+ @router.delete("/{name}")
266
+ def delete_collection(name: str, request: Request):
267
+ """
268
+ Delete a collection.
269
+ """
270
+ user_id = _get_user_id(request)
271
+
272
+ try:
273
+ sanitize_name(name)
274
+ except ValueError as e:
275
+ raise HTTPException(status_code=400, detail=str(e))
276
+
277
+ if not cache.exists(name, user_id=user_id):
278
+ raise HTTPException(status_code=404, detail=f"Collection not found: {name}")
279
+
280
+ deleted = cache.delete(name, user_id=user_id)
281
+
282
+ return {"deleted": deleted, "name": name}
@@ -0,0 +1,102 @@
1
+ """Backwards-compatible legacy API endpoints."""
2
+
3
+ from typing import Optional
4
+
5
+ from fastapi import APIRouter, HTTPException
6
+
7
+ from .._core import fts
8
+ from .._core.db import get_db
9
+ from .._core.models import Work
10
+ from .models import WorkResponse
11
+ from .routes_works import get_work
12
+
13
+ router = APIRouter(prefix="/api", tags=["legacy"])
14
+
15
+
16
+ @router.get("/search/")
17
+ def api_search_compat(
18
+ title: Optional[str] = None,
19
+ q: Optional[str] = None,
20
+ doi: Optional[str] = None,
21
+ limit: int = 10,
22
+ ):
23
+ """Backwards-compatible search endpoint."""
24
+ query = title or q
25
+
26
+ if doi:
27
+ # DOI lookup
28
+ try:
29
+ work = get_work(doi)
30
+ return {
31
+ "query": {"doi": doi},
32
+ "results": [work.model_dump()],
33
+ "total": 1,
34
+ "returned": 1,
35
+ }
36
+ except HTTPException:
37
+ return {"query": {"doi": doi}, "results": [], "total": 0, "returned": 0}
38
+
39
+ if not query:
40
+ raise HTTPException(
41
+ status_code=400, detail="Specify q, title, or doi parameter"
42
+ )
43
+
44
+ # Call fts.search directly (not the endpoint function)
45
+ results = fts.search(query, limit=limit, offset=0)
46
+ return {
47
+ "query": {
48
+ "title": query,
49
+ "doi": None,
50
+ "year": None,
51
+ "authors": None,
52
+ "limit": limit,
53
+ },
54
+ "results": [
55
+ WorkResponse(
56
+ doi=w.doi,
57
+ title=w.title,
58
+ authors=w.authors,
59
+ year=w.year,
60
+ journal=w.journal,
61
+ issn=w.issn,
62
+ volume=w.volume,
63
+ issue=w.issue,
64
+ page=w.page,
65
+ abstract=w.abstract,
66
+ citation_count=w.citation_count,
67
+ ).model_dump()
68
+ for w in results.works
69
+ ],
70
+ "total": results.total,
71
+ "returned": len(results.works),
72
+ }
73
+
74
+
75
+ @router.get("/stats/")
76
+ def api_stats_compat():
77
+ """Backwards-compatible stats endpoint."""
78
+ db = get_db()
79
+
80
+ row = db.fetchone("SELECT COUNT(*) as count FROM works")
81
+ work_count = row["count"] if row else 0
82
+
83
+ # Get table names
84
+ tables = []
85
+ for row in db.fetchall("SELECT name FROM sqlite_master WHERE type='table'"):
86
+ tables.append(row["name"])
87
+
88
+ # Get index names
89
+ indices = []
90
+ for row in db.fetchall("SELECT name FROM sqlite_master WHERE type='index'"):
91
+ if row["name"]:
92
+ indices.append(row["name"])
93
+
94
+ return {
95
+ "total_papers": work_count,
96
+ "database_size_mb": None,
97
+ "year_range": None,
98
+ "total_journals": 0,
99
+ "total_citations": None,
100
+ "tables": tables,
101
+ "indices": indices,
102
+ }