matelab-python-sdk 0.1.0a1__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.
matelab/literature.py ADDED
@@ -0,0 +1,1243 @@
1
+ import json
2
+ import math
3
+ import re
4
+ from collections.abc import Mapping, Sequence
5
+ from dataclasses import dataclass, field
6
+ from enum import StrEnum
7
+ from types import MappingProxyType
8
+ from typing import Literal, TypeAlias, cast
9
+
10
+ from pydantic import ValidationError
11
+
12
+ from matelab._generated.models import BasicSuccessResponse as WireBasicSuccessResponse
13
+ from matelab._generated.models import DeleteLiteratureCommentRequest as WireDeleteLiteratureCommentRequest
14
+ from matelab._generated.models import DeleteLiteraturePdfRequest as WireDeleteLiteraturePdfRequest
15
+ from matelab._generated.models import DeletePersonalLiteratureItemRequest as WireDeletePersonalLiteratureItemRequest
16
+ from matelab._generated.models import DownloadLiteraturePdfParametersQuery as WireDownloadLiteraturePdfQuery
17
+ from matelab._generated.models import ExtractedLiteratureAuthor as WireExtractedLiteratureAuthor
18
+ from matelab._generated.models import ExtractLiteratureMetadataRequest as WireExtractLiteratureMetadataRequest
19
+ from matelab._generated.models import ListLiteratureItemsParametersQuery as WireListLiteratureItemsQuery
20
+ from matelab._generated.models import LiteratureContent1 as WireInvalidLiteratureContent
21
+ from matelab._generated.models import LiteratureContentObject as WireLiteratureContentObject
22
+ from matelab._generated.models import LiteratureDetailResponse as WireLiteratureDetailResponse
23
+ from matelab._generated.models import LiteratureFieldDefinition as WireLiteratureFieldDefinition
24
+ from matelab._generated.models import LiteratureLibrariesResponse as WireLiteratureLibrariesResponse
25
+ from matelab._generated.models import LiteratureLibrary as WireLiteratureLibrary
26
+ from matelab._generated.models import LiteratureListItem as WireLiteratureListItem
27
+ from matelab._generated.models import LiteratureListResponse as WireLiteratureListResponse
28
+ from matelab._generated.models import LiteratureMetadataExtractionResponse as WireLiteratureMetadataExtractionResponse
29
+ from matelab._generated.models import LiteratureTemplateResponse as WireLiteratureTemplateResponse
30
+ from matelab._generated.models import ReadLiteratureDetailParametersQuery as WireReadLiteratureDetailQuery
31
+ from matelab._generated.models import ReplaceLiteraturePdfRequest as WireReplaceLiteraturePdfRequest
32
+ from matelab._generated.models import SaveLiteratureCommentRequest as WireSaveLiteratureCommentRequest
33
+ from matelab._generated.models import SaveLiteratureContent as WireSaveLiteratureContent
34
+ from matelab._generated.models import SaveLiteratureItemRequest as WireSaveLiteratureItemRequest
35
+ from matelab._generated.models import ShareLiteratureItemsRequest as WireShareLiteratureItemsRequest
36
+ from matelab._transport import Encoding, Operation, SessionTransport, StreamOperation, multipart_fields
37
+ from matelab.errors import MatelabError, MatelabProtocolError, MatelabUsageError, MatelabVerificationError
38
+ from matelab.streaming import ByteRange, DownloadStream
39
+ from matelab.uploads import StagedFile, UploadBindingRef
40
+ from matelab.users import UserRef, UserSummary
41
+
42
+ _LIST_LIBRARIES = Operation(
43
+ method="GET",
44
+ path="/recommend_fav/stores",
45
+ encoding=Encoding.NONE,
46
+ response_model=WireLiteratureLibrariesResponse,
47
+ success_codes=frozenset({0}),
48
+ retry_on_access_expired=True,
49
+ )
50
+ _LIST_ITEMS = Operation(
51
+ method="GET",
52
+ path="/recommend_fav/list",
53
+ encoding=Encoding.QUERY,
54
+ response_model=WireLiteratureListResponse,
55
+ success_codes=frozenset({0}),
56
+ retry_on_access_expired=True,
57
+ )
58
+ _READ_ITEM = Operation(
59
+ method="GET",
60
+ path="/recommend_fav/detail",
61
+ encoding=Encoding.QUERY,
62
+ response_model=WireLiteratureDetailResponse,
63
+ success_codes=frozenset({0}),
64
+ retry_on_access_expired=True,
65
+ )
66
+ _SAVE_COMMENT = Operation(
67
+ method="POST",
68
+ path="/recommend_fav/comment",
69
+ encoding=Encoding.MULTIPART,
70
+ response_model=WireBasicSuccessResponse,
71
+ success_codes=frozenset({0}),
72
+ )
73
+ _DELETE_COMMENT = Operation(
74
+ method="POST",
75
+ path="/recommend_fav/comment_del",
76
+ encoding=Encoding.MULTIPART,
77
+ response_model=WireBasicSuccessResponse,
78
+ success_codes=frozenset({0}),
79
+ )
80
+ _SHARE_ITEMS = Operation(
81
+ method="POST",
82
+ path="/recommend_fav/share",
83
+ encoding=Encoding.MULTIPART,
84
+ response_model=WireBasicSuccessResponse,
85
+ success_codes=frozenset({0}),
86
+ )
87
+ _DELETE_PERSONAL_ITEM = Operation(
88
+ method="POST",
89
+ path="/recommend_fav/del",
90
+ encoding=Encoding.MULTIPART,
91
+ response_model=WireBasicSuccessResponse,
92
+ success_codes=frozenset({0}),
93
+ )
94
+ _READ_CREATION_SCHEMA = Operation(
95
+ method="GET",
96
+ path="/recommend_fav/template",
97
+ encoding=Encoding.NONE,
98
+ response_model=WireLiteratureTemplateResponse,
99
+ success_codes=frozenset({0}),
100
+ retry_on_access_expired=True,
101
+ )
102
+ _EXTRACT_METADATA = Operation(
103
+ method="POST",
104
+ path="/actions/pdf_doi",
105
+ encoding=Encoding.MULTIPART,
106
+ response_model=WireLiteratureMetadataExtractionResponse,
107
+ success_codes=frozenset({0}),
108
+ retry_on_access_expired=True,
109
+ )
110
+ _SAVE_ITEM = Operation(
111
+ method="POST",
112
+ path="/recommend_fav/edit",
113
+ encoding=Encoding.MULTIPART,
114
+ response_model=WireBasicSuccessResponse,
115
+ success_codes=frozenset({0}),
116
+ )
117
+ _REPLACE_PDF = Operation(
118
+ method="POST",
119
+ path="/recommend_fav/pdf_add",
120
+ encoding=Encoding.MULTIPART,
121
+ response_model=WireBasicSuccessResponse,
122
+ success_codes=frozenset({0}),
123
+ )
124
+ _DELETE_PDF = Operation(
125
+ method="POST",
126
+ path="/recommend_fav/pdf_del",
127
+ encoding=Encoding.MULTIPART,
128
+ response_model=WireBasicSuccessResponse,
129
+ success_codes=frozenset({0}),
130
+ )
131
+ _DOWNLOAD_PDF = StreamOperation(
132
+ method="GET",
133
+ path="/url_eln/hash",
134
+ retry_on_access_expired=True,
135
+ media_types=frozenset({"application/pdf", "application/octet-stream"}),
136
+ )
137
+ _SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$")
138
+ _COMMENT_ATTACHMENT_MARKER = "matelab-staged-file"
139
+
140
+
141
+ def _empty_mapping() -> Mapping[str, object]:
142
+ return MappingProxyType({})
143
+
144
+
145
+ class LiteratureSource(StrEnum):
146
+ SUBSCRIBED = "subscribed"
147
+ USER_CREATED = "user_created"
148
+ SHARED_COPY = "shared_copy"
149
+
150
+
151
+ @dataclass(frozen=True, slots=True)
152
+ class PersonalLiteratureLibraryRef:
153
+ """The caller's synthetic private library (`store_id=0`)."""
154
+
155
+
156
+ @dataclass(frozen=True, slots=True)
157
+ class SharedLiteratureLibraryRef:
158
+ literature_library_id: int
159
+
160
+
161
+ LiteratureLibraryRef: TypeAlias = PersonalLiteratureLibraryRef | SharedLiteratureLibraryRef
162
+
163
+
164
+ @dataclass(frozen=True, slots=True)
165
+ class PendingLiteratureShareRef:
166
+ """A pending copy identity, not a readable library-item identity."""
167
+
168
+ pending_item_id: int
169
+
170
+
171
+ @dataclass(frozen=True, slots=True)
172
+ class PersonalLiteratureItemRef:
173
+ literature_item_id: int
174
+
175
+
176
+ @dataclass(frozen=True, slots=True)
177
+ class SharedLiteratureItemRef:
178
+ literature_item_id: int
179
+ library: SharedLiteratureLibraryRef
180
+
181
+
182
+ LiteratureItemRef: TypeAlias = PersonalLiteratureItemRef | SharedLiteratureItemRef
183
+
184
+
185
+ @dataclass(frozen=True, slots=True)
186
+ class LiteratureLibrarySummary:
187
+ ref: LiteratureLibraryRef | None
188
+ kind: Literal["personal", "shared", "dangling_membership"]
189
+ name: str | None
190
+ permission_mask: int
191
+ item_count: int
192
+ keywords: tuple[str, ...]
193
+ introduction: str | None
194
+
195
+
196
+ @dataclass(frozen=True, slots=True)
197
+ class PendingLiteratureShareComment:
198
+ comment_id: int
199
+ share: PendingLiteratureShareRef
200
+ content: str
201
+
202
+
203
+ @dataclass(frozen=True, slots=True)
204
+ class PendingLiteratureShare:
205
+ ref: PendingLiteratureShareRef
206
+ title: str | None
207
+ sender_name: str
208
+ introduction: str | None
209
+ keywords: tuple[str, ...]
210
+ comments: tuple[PendingLiteratureShareComment, ...]
211
+
212
+
213
+ @dataclass(frozen=True, slots=True)
214
+ class LiteratureLibraries:
215
+ personal: LiteratureLibrarySummary
216
+ shared: tuple[LiteratureLibrarySummary, ...]
217
+ dangling_memberships: tuple[LiteratureLibrarySummary, ...]
218
+ pending_shares: tuple[PendingLiteratureShare, ...]
219
+ unmatched_pending_comments: tuple[PendingLiteratureShareComment, ...]
220
+ ordering: Literal["provider_unspecified"] = "provider_unspecified"
221
+
222
+
223
+ @dataclass(frozen=True, slots=True)
224
+ class LiteratureCitation:
225
+ year: float | None = None
226
+ volume: str | None = None
227
+ issue: str | None = None
228
+ page: str | None = None
229
+
230
+
231
+ @dataclass(frozen=True, slots=True)
232
+ class LiteratureMetadata:
233
+ """Complete canonical metadata replacement accepted by the Provider template."""
234
+
235
+ title: str
236
+ journal: str | None = None
237
+ citation: LiteratureCitation | None = None
238
+ author: str | None = None
239
+ keywords: tuple[str, ...] | None = ()
240
+ abstract: str | None = ""
241
+ doi: str | None = None
242
+ url: str | None = None
243
+ references: tuple[str, ...] | None = ()
244
+
245
+
246
+ @dataclass(frozen=True, slots=True)
247
+ class LiteratureStoredMetadata:
248
+ """Decoded stored metadata, including source fields a canonical update would drop."""
249
+
250
+ valid_object: bool
251
+ title: str | None
252
+ journal: str | None
253
+ citation: LiteratureCitation | None
254
+ author: str | None
255
+ authors: tuple[str, ...] | None
256
+ keywords: tuple[str, ...] | None
257
+ abstract: str | None
258
+ reference_information: str | None
259
+ doi: str | None
260
+ url: str | None
261
+ references: tuple[str, ...] | None
262
+ extension_fields: Mapping[str, object] = field(default_factory=_empty_mapping)
263
+ fields_dropped_by_canonical_update: tuple[str, ...] = ()
264
+
265
+
266
+ @dataclass(frozen=True, slots=True)
267
+ class LiteraturePdf:
268
+ item: LiteratureItemRef
269
+ sha256: str | None = field(repr=False)
270
+ filename: str | None
271
+ size: int | None
272
+
273
+ @property
274
+ def is_attached(self) -> bool:
275
+ return self.sha256 is not None or self.filename is not None or self.size is not None
276
+
277
+
278
+ @dataclass(frozen=True, slots=True)
279
+ class LiteratureItemSummary:
280
+ ref: LiteratureItemRef
281
+ source: LiteratureSource
282
+ star: int
283
+ title: str
284
+ author: str
285
+ citation_html: str
286
+ abstract: str
287
+ journal: str
288
+ keywords: tuple[str, ...]
289
+ publication_date: str
290
+ matching_author_fragments: tuple[str, ...]
291
+ pdf: LiteraturePdf
292
+
293
+
294
+ @dataclass(frozen=True, slots=True)
295
+ class LiteraturePage:
296
+ library: LiteratureLibraryRef
297
+ items: tuple[LiteratureItemSummary, ...]
298
+ library_keywords: tuple[str, ...]
299
+ library_name: str
300
+ permission_mask: int
301
+ page: int
302
+ requested_page_size: int
303
+ effective_page_size: int
304
+ total_count: int
305
+ has_more: bool
306
+ ordering: Literal["descending_item_id"] = "descending_item_id"
307
+
308
+
309
+ @dataclass(frozen=True, slots=True)
310
+ class LiteratureNestedField:
311
+ field_id: str
312
+ field_type: str
313
+ label: str | tuple[str, str]
314
+ width: int | None
315
+ attributes: Mapping[str, object] = field(default_factory=_empty_mapping)
316
+
317
+
318
+ @dataclass(frozen=True, slots=True)
319
+ class LiteratureField:
320
+ field_id: str
321
+ field_type: str
322
+ label: str | tuple[str, str]
323
+ required: bool | None
324
+ group: str | None
325
+ help_text: str | tuple[str, str] | None
326
+ print_template: str | None
327
+ nested: tuple[LiteratureNestedField, ...]
328
+ visible: bool | None
329
+ width: int | None
330
+ condition: Mapping[str, object] | None
331
+ attributes: Mapping[str, object] = field(default_factory=_empty_mapping)
332
+
333
+
334
+ @dataclass(frozen=True, slots=True)
335
+ class LiteratureCreationSchema:
336
+ fields: tuple[LiteratureField, ...]
337
+ metadata_extraction_available: bool
338
+
339
+
340
+ @dataclass(frozen=True, slots=True)
341
+ class LiteratureCommentRef:
342
+ item: LiteratureItemRef
343
+ comment_id: int
344
+
345
+
346
+ @dataclass(frozen=True, slots=True)
347
+ class LiteratureComment:
348
+ ref: LiteratureCommentRef
349
+ content: str
350
+ author: UserRef
351
+ author_name: str | None
352
+ created_at: str
353
+ owned_by_caller: bool
354
+
355
+
356
+ @dataclass(frozen=True, slots=True)
357
+ class LiteratureDetail:
358
+ ref: LiteratureItemRef
359
+ metadata: LiteratureStoredMetadata
360
+ schema_fields: tuple[LiteratureField, ...]
361
+ library_keywords: tuple[str, ...]
362
+ item_keywords: tuple[str, ...]
363
+ star: int
364
+ comments: tuple[LiteratureComment, ...]
365
+ pdf: LiteraturePdf
366
+ source: LiteratureSource
367
+ permission_mask: int
368
+ library_name: str
369
+ authorization_scope: Literal["provider_authorized_response_pvd_011_not_security_proof"] = (
370
+ "provider_authorized_response_pvd_011_not_security_proof"
371
+ )
372
+
373
+
374
+ @dataclass(frozen=True, slots=True)
375
+ class DoiMetadataSource:
376
+ doi: str
377
+
378
+
379
+ @dataclass(frozen=True, slots=True)
380
+ class StagedPdfMetadataSource:
381
+ staged_pdf: StagedFile
382
+
383
+
384
+ LiteratureMetadataSource: TypeAlias = DoiMetadataSource | StagedPdfMetadataSource
385
+
386
+
387
+ @dataclass(frozen=True, slots=True)
388
+ class ExtractedLiteratureAuthor:
389
+ name: str
390
+ affiliations: tuple[str, ...] | None
391
+ email: str | None
392
+ corresponding: bool | None
393
+ orcid: str | None
394
+ extension_fields: Mapping[str, object] = field(default_factory=_empty_mapping)
395
+
396
+
397
+ @dataclass(frozen=True, slots=True)
398
+ class ExtractedLiteratureMetadata:
399
+ doi: str | None
400
+ url: str | None
401
+ publication_date: str | None
402
+ publisher: str | None
403
+ journal: str | None
404
+ issue: str | None
405
+ volume: str | None
406
+ page: str | None
407
+ title: str | None
408
+ abstract: str | None
409
+ authors: tuple[ExtractedLiteratureAuthor, ...] | None
410
+ keywords: tuple[str, ...] | None
411
+ references: tuple[str, ...] | None
412
+ fundings: tuple[object, ...] | None
413
+ language: str | None
414
+ citation_count: int | None
415
+ extension_fields: Mapping[str, object] = field(default_factory=_empty_mapping)
416
+
417
+
418
+ @dataclass(frozen=True, slots=True)
419
+ class LiteratureCreateResult:
420
+ requested: LiteratureMetadata
421
+ staged_pdf: StagedFile | None
422
+ created_item: None = None
423
+ identity_status: Literal["provider_did_not_return_identity"] = "provider_did_not_return_identity"
424
+ persistence: Literal["provider_acknowledged_not_readback_identifiable"] = (
425
+ "provider_acknowledged_not_readback_identifiable"
426
+ )
427
+ metadata_pdf_atomicity: Literal["not_guaranteed"] = "not_guaranteed"
428
+ automatic_retry: Literal[False] = False
429
+
430
+
431
+ @dataclass(frozen=True, slots=True)
432
+ class LiteratureUpdateResult:
433
+ item: LiteratureItemRef
434
+ requested: LiteratureMetadata
435
+ observed: LiteratureDetail
436
+ confirmation: Literal["observed_matching", "observed_mismatch"]
437
+ source_metadata_loss_authorized: bool
438
+ provider_cas: Literal[False] = False
439
+ automatic_retry: Literal[False] = False
440
+
441
+
442
+ @dataclass(frozen=True, slots=True)
443
+ class LiteraturePdfReplaceResult:
444
+ item: LiteratureItemRef
445
+ staged_pdf: StagedFile
446
+ observed: LiteraturePdf
447
+ confirmation: Literal["observed_matching", "observed_mismatch"]
448
+ metadata_pdf_atomicity: Literal["separate_mutation_not_atomic"] = "separate_mutation_not_atomic"
449
+ automatic_retry: Literal[False] = False
450
+
451
+
452
+ @dataclass(frozen=True, slots=True)
453
+ class LiteraturePdfDeleteResult:
454
+ item: LiteratureItemRef
455
+ observed: LiteraturePdf
456
+ absence_confirmed: bool
457
+ metadata_pdf_atomicity: Literal["separate_mutation_not_atomic"] = "separate_mutation_not_atomic"
458
+ automatic_retry: Literal[False] = False
459
+
460
+
461
+ @dataclass(frozen=True, slots=True)
462
+ class LiteraturePermanentDeleteResult:
463
+ item: PersonalLiteratureItemRef
464
+ absence_confirmed: bool
465
+ verification: Literal["personal_library_snapshot"]
466
+ recoverable: Literal[False] = False
467
+ automatic_retry: Literal[False] = False
468
+
469
+
470
+ @dataclass(frozen=True, slots=True)
471
+ class LiteratureShareResult:
472
+ items: tuple[LiteratureItemRef, ...]
473
+ recipients: tuple[UserRef, ...]
474
+ copied_owner_comment: bool
475
+ per_recipient_results: None = None
476
+ outcome: Literal["provider_acknowledged_unverified_batch"] = "provider_acknowledged_unverified_batch"
477
+ atomicity: Literal["provider_does_not_report_or_guarantee_batch_atomicity"] = (
478
+ "provider_does_not_report_or_guarantee_batch_atomicity"
479
+ )
480
+ automatic_retry: Literal[False] = False
481
+
482
+
483
+ @dataclass(frozen=True, slots=True)
484
+ class LiteratureCommentDraft:
485
+ """Rich text; use one `matelab-staged-file` marker when attaching a staged file."""
486
+
487
+ html: str
488
+ attachment: StagedFile | None = None
489
+
490
+
491
+ @dataclass(frozen=True, slots=True)
492
+ class LiteratureCommentSaveResult:
493
+ item: LiteratureItemRef
494
+ action: Literal["created", "updated"]
495
+ upload_binding: UploadBindingRef
496
+ owner_comments_after: tuple[LiteratureComment, ...]
497
+ observed: LiteratureComment | None
498
+ confirmation: Literal["one_owner_comment_observed", "no_owner_comment_observed", "multiple_owner_comments_observed"]
499
+ binding_authorization: Literal["provider_does_not_verify_uploader"] = "provider_does_not_verify_uploader"
500
+ automatic_retry: Literal[False] = False
501
+
502
+
503
+ @dataclass(frozen=True, slots=True)
504
+ class LiteratureCommentDeleteResult:
505
+ comment: LiteratureCommentRef
506
+ absence_confirmed: bool
507
+ automatic_retry: Literal[False] = False
508
+
509
+
510
+ class Literature:
511
+ """Literature discovery, canonical metadata, sharing, comments, and PDF workflows."""
512
+
513
+ def __init__(self, transport: SessionTransport) -> None:
514
+ self._transport: SessionTransport = transport
515
+
516
+ async def libraries(self) -> LiteratureLibraries:
517
+ response = await self._transport.request(_LIST_LIBRARIES)
518
+ personal_items = [item for item in response.stores if item.id == 0]
519
+ if len(personal_items) != 1:
520
+ raise MatelabProtocolError("Matelab did not return exactly one synthetic personal literature library.")
521
+ personal_wire = personal_items[0]
522
+ personal = self._library(personal_wire, kind="personal")
523
+ shared = tuple(self._library(item, kind="shared") for item in response.stores if item.id not in {0, None})
524
+ dangling = tuple(self._library(item, kind="dangling_membership") for item in response.stores if item.id is None)
525
+ comments = tuple(
526
+ PendingLiteratureShareComment(
527
+ comment_id=item.id, share=PendingLiteratureShareRef(pending_item_id=item.fav_id), content=item.content
528
+ )
529
+ for item in response.share_comments
530
+ )
531
+ comment_by_share: dict[int, list[PendingLiteratureShareComment]] = {}
532
+ for comment in comments:
533
+ comment_by_share.setdefault(comment.share.pending_item_id, []).append(comment)
534
+ shares = tuple(
535
+ PendingLiteratureShare(
536
+ ref=PendingLiteratureShareRef(pending_item_id=item.id),
537
+ title=item.title,
538
+ sender_name=item.username,
539
+ introduction=item.intro,
540
+ keywords=tuple(item.keywords),
541
+ comments=tuple(comment_by_share.get(item.id, ())),
542
+ )
543
+ for item in response.share
544
+ )
545
+ known_share_ids = {item.ref.pending_item_id for item in shares}
546
+ return LiteratureLibraries(
547
+ personal=personal,
548
+ shared=shared,
549
+ dangling_memberships=dangling,
550
+ pending_shares=shares,
551
+ unmatched_pending_comments=tuple(
552
+ comment for comment in comments if comment.share.pending_item_id not in known_share_ids
553
+ ),
554
+ )
555
+
556
+ async def list(
557
+ self,
558
+ library: LiteratureLibraryRef | None = None,
559
+ *,
560
+ page: int = 1,
561
+ page_size: int = 10,
562
+ sources: Sequence[LiteratureSource] = (),
563
+ stars: Sequence[int] = (),
564
+ keywords: Sequence[str] = (),
565
+ search: str = "",
566
+ search_author: bool = False,
567
+ ) -> LiteraturePage:
568
+ selected_library: LiteratureLibraryRef = library or PersonalLiteratureLibraryRef()
569
+ store_id = self._library_id(selected_library)
570
+ source_values = tuple(self._source_wire_value(source) for source in sources)
571
+ star_values = tuple(stars)
572
+ keyword_values = tuple(keywords)
573
+ if (
574
+ len(source_values) != len(set(source_values))
575
+ or any(type(star) is not int or not 1 <= star <= 5 for star in star_values)
576
+ or len(star_values) != len(set(star_values))
577
+ or any(not isinstance(keyword, str) for keyword in cast(tuple[object, ...], keyword_values))
578
+ or len(keyword_values) != len(set(keyword_values))
579
+ or type(search_author) is not bool
580
+ ):
581
+ raise MatelabUsageError("Literature list filters contain invalid or duplicate values.")
582
+ try:
583
+ request = WireListLiteratureItemsQuery.model_validate(
584
+ {
585
+ "page": page,
586
+ "page_size": page_size,
587
+ "store_id": store_id,
588
+ "types": json.dumps(source_values, separators=(",", ":")) if source_values else "",
589
+ "stars": json.dumps(star_values, separators=(",", ":")) if star_values else "",
590
+ "keywords": (
591
+ json.dumps(keyword_values, ensure_ascii=False, separators=(",", ":")) if keyword_values else ""
592
+ ),
593
+ "search": search,
594
+ "search_type": int(search_author),
595
+ }
596
+ )
597
+ except (ValidationError, TypeError, ValueError):
598
+ raise MatelabUsageError("Literature list filters do not satisfy the Integration Contract.") from None
599
+ response = await self._transport.request(_LIST_ITEMS, payload=request.model_dump(mode="json"))
600
+ normalized_page = max(1, request.page)
601
+ effective_page_size = max(request.page_size, 10)
602
+ return LiteraturePage(
603
+ library=selected_library,
604
+ items=tuple(self._summary(item, selected_library) for item in response.items),
605
+ library_keywords=tuple(response.keywords),
606
+ library_name=response.store_name,
607
+ permission_mask=response.power,
608
+ page=normalized_page,
609
+ requested_page_size=request.page_size,
610
+ effective_page_size=effective_page_size,
611
+ total_count=response.count,
612
+ has_more=normalized_page * effective_page_size < response.count,
613
+ )
614
+
615
+ async def read(self, item: LiteratureItemRef) -> LiteratureDetail:
616
+ item_id = self._item_id(item)
617
+ try:
618
+ request = WireReadLiteratureDetailQuery(id=item_id)
619
+ except ValidationError:
620
+ raise MatelabUsageError("Literature item identity does not satisfy the Integration Contract.") from None
621
+ response = await self._transport.request(_READ_ITEM, payload=request.model_dump(mode="json"))
622
+ return self._detail(item, response)
623
+
624
+ async def creation_schema(self) -> LiteratureCreationSchema:
625
+ response = await self._transport.request(_READ_CREATION_SCHEMA)
626
+ return LiteratureCreationSchema(
627
+ fields=tuple(self._field(item) for item in response.template), metadata_extraction_available=response.doipdf
628
+ )
629
+
630
+ async def extract_metadata(self, source: LiteratureMetadataSource) -> ExtractedLiteratureMetadata:
631
+ raw_source = cast(object, source)
632
+ if isinstance(raw_source, DoiMetadataSource):
633
+ payload: dict[str, object] = {"id": 0, "doi": raw_source.doi}
634
+ elif isinstance(raw_source, StagedPdfMetadataSource):
635
+ self._validate_staged_file(raw_source.staged_pdf)
636
+ payload = {"id": raw_source.staged_pdf.temporary_file_id, "doi": ""}
637
+ else:
638
+ raise MatelabUsageError("Literature metadata extraction needs a DOI or a completed staged PDF.")
639
+ try:
640
+ request = WireExtractLiteratureMetadataRequest.model_validate(payload)
641
+ except ValidationError:
642
+ raise MatelabUsageError("Literature metadata source does not satisfy the Integration Contract.") from None
643
+ request_payload = cast(dict[str, object], request.model_dump(mode="json"))
644
+ response = await self._transport.request(_EXTRACT_METADATA, files=multipart_fields(request_payload))
645
+ item = response.doi
646
+ return ExtractedLiteratureMetadata(
647
+ doi=item.DOI,
648
+ url=item.url,
649
+ publication_date=item.date,
650
+ publisher=item.publisher,
651
+ journal=item.publication,
652
+ issue=item.issue,
653
+ volume=item.volume,
654
+ page=item.page,
655
+ title=item.title,
656
+ abstract=item.abstract,
657
+ authors=(
658
+ tuple(self._extracted_author(author) for author in item.authors) if item.authors is not None else None
659
+ ),
660
+ keywords=tuple(item.keywords) if item.keywords is not None else None,
661
+ references=tuple(item.refs) if item.refs is not None else None,
662
+ fundings=tuple(cast(list[object], item.fundings)) if item.fundings is not None else None,
663
+ language=item.language,
664
+ citation_count=item.citation_count,
665
+ extension_fields=MappingProxyType(cast(dict[str, object], dict(item.model_extra or {}))),
666
+ )
667
+
668
+ async def download_pdf(
669
+ self, pdf: LiteraturePdf, *, byte_range: ByteRange | None = None, preview: bool = True
670
+ ) -> DownloadStream:
671
+ if (
672
+ pdf.sha256 is None
673
+ or _SHA256_PATTERN.fullmatch(pdf.sha256) is None
674
+ or type(preview) is not bool
675
+ or self._item_id(pdf.item) < 1
676
+ ):
677
+ raise MatelabUsageError("Literature PDF identity does not satisfy the Integration Contract.")
678
+ try:
679
+ request = WireDownloadLiteraturePdfQuery.model_validate(
680
+ {
681
+ "t": "recommend_fav_pdf",
682
+ "id": self._item_id(pdf.item),
683
+ "h": pdf.sha256,
684
+ "f": pdf.filename or "",
685
+ "c": int(preview),
686
+ "thumb": 0,
687
+ }
688
+ )
689
+ except ValidationError:
690
+ raise MatelabUsageError("Literature PDF identity does not satisfy the Integration Contract.") from None
691
+ return await self._transport.stream(
692
+ _DOWNLOAD_PDF,
693
+ payload=request.model_dump(mode="json"),
694
+ range_header=byte_range.to_header() if byte_range is not None else None,
695
+ )
696
+
697
+ async def create(
698
+ self, metadata: LiteratureMetadata, *, staged_pdf: StagedFile | None = None
699
+ ) -> LiteratureCreateResult:
700
+ if staged_pdf is not None:
701
+ self._validate_staged_file(staged_pdf)
702
+ request = self._save_request(metadata, item=None, staged_pdf=staged_pdf)
703
+ request_payload = cast(dict[str, object], request.model_dump(mode="json"))
704
+ _ = await self._transport.request(_SAVE_ITEM, files=multipart_fields(request_payload))
705
+ return LiteratureCreateResult(requested=metadata, staged_pdf=staged_pdf)
706
+
707
+ async def update(
708
+ self, item: LiteratureItemRef, metadata: LiteratureMetadata, *, allow_source_metadata_loss: bool = False
709
+ ) -> LiteratureUpdateResult:
710
+ if type(cast(object, allow_source_metadata_loss)) is not bool:
711
+ raise MatelabUsageError("Source-metadata-loss acknowledgement must be a boolean.")
712
+ before = await self.read(item)
713
+ if before.metadata.fields_dropped_by_canonical_update and not allow_source_metadata_loss:
714
+ fields = ", ".join(before.metadata.fields_dropped_by_canonical_update)
715
+ message = (
716
+ f"Canonical replacement would drop source metadata ({fields}); "
717
+ + "set allow_source_metadata_loss=True only when that loss is intentional."
718
+ )
719
+ raise MatelabUsageError(message)
720
+ request = self._save_request(metadata, item=item, staged_pdf=None)
721
+ request_payload = cast(dict[str, object], request.model_dump(mode="json"))
722
+ _ = await self._transport.request(_SAVE_ITEM, files=multipart_fields(request_payload))
723
+ observed = await self._verified_read(item, "literature metadata replacement")
724
+ return LiteratureUpdateResult(
725
+ item=item,
726
+ requested=metadata,
727
+ observed=observed,
728
+ confirmation="observed_matching"
729
+ if self._metadata_matches(observed.metadata, metadata)
730
+ else "observed_mismatch",
731
+ source_metadata_loss_authorized=allow_source_metadata_loss,
732
+ )
733
+
734
+ async def replace_pdf(self, item: LiteratureItemRef, staged_pdf: StagedFile) -> LiteraturePdfReplaceResult:
735
+ self._validate_staged_file(staged_pdf)
736
+ try:
737
+ request = WireReplaceLiteraturePdfRequest.model_validate(
738
+ {"id": self._item_id(item), "uid": staged_pdf.binding.uid}
739
+ )
740
+ except ValidationError:
741
+ raise MatelabUsageError("Literature PDF replacement does not satisfy the Integration Contract.") from None
742
+ _ = await self._transport.request(_REPLACE_PDF, files=multipart_fields(request.model_dump(mode="json")))
743
+ observed = (await self._verified_read(item, "literature PDF replacement")).pdf
744
+ matches = (
745
+ observed.sha256 == staged_pdf.sha256
746
+ and observed.filename == staged_pdf.filename
747
+ and observed.size == staged_pdf.size
748
+ )
749
+ return LiteraturePdfReplaceResult(
750
+ item=item,
751
+ staged_pdf=staged_pdf,
752
+ observed=observed,
753
+ confirmation="observed_matching" if matches else "observed_mismatch",
754
+ )
755
+
756
+ async def delete_pdf(self, item: LiteratureItemRef) -> LiteraturePdfDeleteResult:
757
+ try:
758
+ request = WireDeleteLiteraturePdfRequest(id=self._item_id(item))
759
+ except ValidationError:
760
+ raise MatelabUsageError("Literature PDF identity does not satisfy the Integration Contract.") from None
761
+ _ = await self._transport.request(_DELETE_PDF, files=multipart_fields(request.model_dump(mode="json")))
762
+ observed = (await self._verified_read(item, "literature PDF deletion")).pdf
763
+ return LiteraturePdfDeleteResult(item=item, observed=observed, absence_confirmed=not observed.is_attached)
764
+
765
+ async def permanently_delete(self, item: PersonalLiteratureItemRef) -> LiteraturePermanentDeleteResult:
766
+ raw_item = cast(object, item)
767
+ if not isinstance(raw_item, PersonalLiteratureItemRef):
768
+ raise MatelabUsageError(
769
+ "Permanent literature deletion only accepts an item observed in the personal library."
770
+ )
771
+ try:
772
+ request = WireDeletePersonalLiteratureItemRequest(id=self._item_id(item))
773
+ except ValidationError:
774
+ raise MatelabUsageError("Personal literature identity does not satisfy the Integration Contract.") from None
775
+ _ = await self._transport.request(
776
+ _DELETE_PERSONAL_ITEM, files=multipart_fields(request.model_dump(mode="json"))
777
+ )
778
+ absent = await self._verified_personal_absence(item)
779
+ return LiteraturePermanentDeleteResult(
780
+ item=item, absence_confirmed=absent, verification="personal_library_snapshot"
781
+ )
782
+
783
+ async def share(
784
+ self,
785
+ items: Sequence[LiteratureItemSummary],
786
+ recipients: Sequence[UserSummary],
787
+ *,
788
+ introduction: str = "",
789
+ copy_owner_comment: bool = False,
790
+ ) -> LiteratureShareResult:
791
+ selected = tuple(items)
792
+ targets = tuple(recipients)
793
+ item_ids = tuple(self._item_id(item.ref) for item in selected)
794
+ userids = tuple(target.ref.userid for target in targets)
795
+ identity = self._transport.require_session().identity
796
+ if (
797
+ not selected
798
+ or len(item_ids) != len(set(item_ids))
799
+ or not targets
800
+ or any(type(userid) is not int or userid < 1 for userid in userids)
801
+ or len(userids) != len(set(userids))
802
+ or identity is None
803
+ or identity.userid in userids
804
+ or type(copy_owner_comment) is not bool
805
+ ):
806
+ message = (
807
+ "Literature sharing needs distinct list-observed items, distinct search-observed non-self users, "
808
+ + "and a resolved caller identity."
809
+ )
810
+ raise MatelabUsageError(message)
811
+ if copy_owner_comment:
812
+ for selected_item in selected:
813
+ detail = await self.read(selected_item.ref)
814
+ if sum(comment.owned_by_caller for comment in detail.comments) > 1:
815
+ raise MatelabUsageError(
816
+ "The source has multiple caller-owned comments, so comment copying is ambiguous (PVD-035)."
817
+ )
818
+ try:
819
+ request = WireShareLiteratureItemsRequest.model_validate(
820
+ {
821
+ "ids": json.dumps(item_ids, separators=(",", ":")),
822
+ "users": json.dumps(userids, separators=(",", ":")),
823
+ "comm": int(copy_owner_comment),
824
+ "intro": introduction,
825
+ }
826
+ )
827
+ except (ValidationError, TypeError, ValueError):
828
+ raise MatelabUsageError("Literature share input does not satisfy the Integration Contract.") from None
829
+ share_payload = cast(dict[str, object], request.model_dump(mode="json"))
830
+ _ = await self._transport.request(_SHARE_ITEMS, files=multipart_fields(share_payload))
831
+ return LiteratureShareResult(
832
+ items=tuple(item.ref for item in selected),
833
+ recipients=tuple(target.ref for target in targets),
834
+ copied_owner_comment=copy_owner_comment,
835
+ )
836
+
837
+ async def save_comment(self, item: LiteratureItemRef, draft: LiteratureCommentDraft) -> LiteratureCommentSaveResult:
838
+ before = await self.read(item)
839
+ owned_before = tuple(comment for comment in before.comments if comment.owned_by_caller)
840
+ if len(owned_before) > 1:
841
+ raise MatelabUsageError(
842
+ "The caller already owns multiple comments on this item, so create/edit selection is ambiguous (PVD-035)."
843
+ )
844
+ rendered, binding = self._render_comment(draft)
845
+ if owned_before:
846
+ payload: dict[str, object] = {
847
+ "id": owned_before[0].ref.comment_id,
848
+ "fav_id": 0,
849
+ "comment": rendered,
850
+ "uid": binding.uid,
851
+ }
852
+ action: Literal["created", "updated"] = "updated"
853
+ else:
854
+ payload = {"id": 0, "fav_id": self._item_id(item), "comment": rendered, "uid": binding.uid}
855
+ action = "created"
856
+ try:
857
+ request = WireSaveLiteratureCommentRequest.model_validate(payload)
858
+ except ValidationError:
859
+ raise MatelabUsageError("Literature comment does not satisfy the Integration Contract.") from None
860
+ request_payload = cast(dict[str, object], request.model_dump(mode="json"))
861
+ _ = await self._transport.request(_SAVE_COMMENT, files=multipart_fields(request_payload))
862
+ after = await self._verified_read(item, "literature comment save")
863
+ owned_after = tuple(comment for comment in after.comments if comment.owned_by_caller)
864
+ if len(owned_after) == 1:
865
+ confirmation: Literal[
866
+ "one_owner_comment_observed", "no_owner_comment_observed", "multiple_owner_comments_observed"
867
+ ] = "one_owner_comment_observed"
868
+ observed = owned_after[0]
869
+ elif not owned_after:
870
+ confirmation = "no_owner_comment_observed"
871
+ observed = None
872
+ else:
873
+ confirmation = "multiple_owner_comments_observed"
874
+ observed = None
875
+ return LiteratureCommentSaveResult(
876
+ item=item,
877
+ action=action,
878
+ upload_binding=binding,
879
+ owner_comments_after=owned_after,
880
+ observed=observed,
881
+ confirmation=confirmation,
882
+ )
883
+
884
+ async def delete_comment(self, comment: LiteratureComment) -> LiteratureCommentDeleteResult:
885
+ if not comment.owned_by_caller:
886
+ raise MatelabUsageError("Only a caller-owned, detail-observed literature comment can be deleted.")
887
+ try:
888
+ request = WireDeleteLiteratureCommentRequest(id=comment.ref.comment_id)
889
+ except ValidationError:
890
+ raise MatelabUsageError("Literature comment identity does not satisfy the Integration Contract.") from None
891
+ _ = await self._transport.request(_DELETE_COMMENT, files=multipart_fields(request.model_dump(mode="json")))
892
+ after = await self._verified_read(comment.ref.item, "literature comment deletion")
893
+ return LiteratureCommentDeleteResult(
894
+ comment=comment.ref,
895
+ absence_confirmed=all(item.ref.comment_id != comment.ref.comment_id for item in after.comments),
896
+ )
897
+
898
+ async def _verified_read(self, item: LiteratureItemRef, action: str) -> LiteratureDetail:
899
+ try:
900
+ return await self.read(item)
901
+ except MatelabError as exc:
902
+ raise MatelabVerificationError(
903
+ f"Matelab acknowledged the {action}, but readback failed; the mutation may have persisted."
904
+ ) from exc
905
+
906
+ async def _verified_personal_absence(self, item: PersonalLiteratureItemRef) -> bool:
907
+ try:
908
+ first = await self.list(PersonalLiteratureLibraryRef(), page=1, page_size=256)
909
+ if any(summary.ref == item for summary in first.items):
910
+ return False
911
+ for page_number in range(2, math.ceil(first.total_count / first.effective_page_size) + 1):
912
+ page = await self.list(
913
+ PersonalLiteratureLibraryRef(), page=page_number, page_size=first.effective_page_size
914
+ )
915
+ if any(summary.ref == item for summary in page.items):
916
+ return False
917
+ return True
918
+ except MatelabError as exc:
919
+ message = (
920
+ "Matelab acknowledged permanent literature deletion, but personal-library readback failed; "
921
+ + "the deletion may have persisted."
922
+ )
923
+ raise MatelabVerificationError(message) from exc
924
+
925
+ def _save_request(
926
+ self, metadata: LiteratureMetadata, *, item: LiteratureItemRef | None, staged_pdf: StagedFile | None
927
+ ) -> WireSaveLiteratureItemRequest:
928
+ wire_metadata = self._wire_metadata(metadata)
929
+ payload = {
930
+ "id": self._item_id(item) if item is not None else 0,
931
+ "store_id": 0,
932
+ "uid": staged_pdf.binding.uid if staged_pdf is not None else "",
933
+ "info": json.dumps(wire_metadata.model_dump(mode="json"), ensure_ascii=False, separators=(",", ":")),
934
+ }
935
+ try:
936
+ return WireSaveLiteratureItemRequest.model_validate(payload)
937
+ except (ValidationError, TypeError, ValueError):
938
+ raise MatelabUsageError("Literature save input does not satisfy the Integration Contract.") from None
939
+
940
+ @staticmethod
941
+ def _wire_metadata(metadata: LiteratureMetadata) -> WireSaveLiteratureContent:
942
+ raw_metadata = cast(object, metadata)
943
+ if (
944
+ not isinstance(raw_metadata, LiteratureMetadata)
945
+ or any(not isinstance(value, str) for value in cast(tuple[object, ...], raw_metadata.keywords or ()))
946
+ or any(not isinstance(value, str) for value in cast(tuple[object, ...], raw_metadata.references or ()))
947
+ or (
948
+ raw_metadata.citation is not None
949
+ and (
950
+ not isinstance(cast(object, raw_metadata.citation), LiteratureCitation)
951
+ or isinstance(raw_metadata.citation.year, bool)
952
+ )
953
+ )
954
+ ):
955
+ raise MatelabUsageError("Literature metadata contains invalid canonical fields.")
956
+ try:
957
+ return WireSaveLiteratureContent.model_validate(
958
+ {
959
+ "title": metadata.title,
960
+ "journal": metadata.journal,
961
+ "info": (
962
+ {
963
+ "year": metadata.citation.year,
964
+ "volume": metadata.citation.volume,
965
+ "issue": metadata.citation.issue,
966
+ "page": metadata.citation.page,
967
+ }
968
+ if metadata.citation is not None
969
+ else None
970
+ ),
971
+ "author": metadata.author,
972
+ "keywords": list(metadata.keywords) if metadata.keywords is not None else None,
973
+ "abstract": metadata.abstract,
974
+ "doi": metadata.doi,
975
+ "url": metadata.url,
976
+ "reference": list(metadata.references) if metadata.references is not None else None,
977
+ }
978
+ )
979
+ except ValidationError:
980
+ raise MatelabUsageError("Literature metadata does not satisfy the Integration Contract.") from None
981
+
982
+ def _detail(self, item: LiteratureItemRef, response: WireLiteratureDetailResponse) -> LiteratureDetail:
983
+ return LiteratureDetail(
984
+ ref=item,
985
+ metadata=self._stored_metadata(response.content.root),
986
+ schema_fields=tuple(self._field(field) for field in response.template),
987
+ library_keywords=tuple(response.keywords),
988
+ item_keywords=tuple(response.keywords_item),
989
+ star=response.star,
990
+ comments=tuple(
991
+ LiteratureComment(
992
+ ref=LiteratureCommentRef(item=item, comment_id=comment.id),
993
+ content=comment.content,
994
+ author=UserRef(userid=comment.userid),
995
+ author_name=comment.user,
996
+ created_at=comment.dtime,
997
+ owned_by_caller=comment.owner,
998
+ )
999
+ for comment in response.comments
1000
+ ),
1001
+ pdf=LiteraturePdf(item=item, sha256=response.hash, filename=response.filename, size=response.size),
1002
+ source=self._source(response.source),
1003
+ permission_mask=response.power,
1004
+ library_name=response.store_name,
1005
+ )
1006
+
1007
+ @staticmethod
1008
+ def _stored_metadata(
1009
+ content: WireLiteratureContentObject | WireInvalidLiteratureContent,
1010
+ ) -> LiteratureStoredMetadata:
1011
+ if isinstance(content, WireInvalidLiteratureContent):
1012
+ return LiteratureStoredMetadata(
1013
+ valid_object=False,
1014
+ title=None,
1015
+ journal=None,
1016
+ citation=None,
1017
+ author=None,
1018
+ authors=None,
1019
+ keywords=None,
1020
+ abstract=None,
1021
+ reference_information=None,
1022
+ doi=None,
1023
+ url=None,
1024
+ references=None,
1025
+ )
1026
+ extension_fields = cast(dict[str, object], dict(content.model_extra or {}))
1027
+ dropped = set(extension_fields)
1028
+ if content.authors is not None:
1029
+ dropped.add("authors")
1030
+ if content.reference_information is not None:
1031
+ dropped.add("reference_information")
1032
+ return LiteratureStoredMetadata(
1033
+ valid_object=True,
1034
+ title=content.title,
1035
+ journal=content.journal,
1036
+ citation=(
1037
+ LiteratureCitation(
1038
+ year=content.info.year, volume=content.info.volume, issue=content.info.issue, page=content.info.page
1039
+ )
1040
+ if content.info is not None
1041
+ else None
1042
+ ),
1043
+ author=content.author,
1044
+ authors=tuple(content.authors) if content.authors is not None else None,
1045
+ keywords=tuple(content.keywords) if content.keywords is not None else None,
1046
+ abstract=content.abstract,
1047
+ reference_information=content.reference_information,
1048
+ doi=content.doi,
1049
+ url=content.url,
1050
+ references=tuple(content.reference) if content.reference is not None else None,
1051
+ extension_fields=MappingProxyType(extension_fields),
1052
+ fields_dropped_by_canonical_update=tuple(sorted(dropped)),
1053
+ )
1054
+
1055
+ @staticmethod
1056
+ def _metadata_matches(observed: LiteratureStoredMetadata, requested: LiteratureMetadata) -> bool:
1057
+ return (
1058
+ observed.valid_object
1059
+ and observed.title == requested.title
1060
+ and observed.journal == requested.journal
1061
+ and observed.citation == requested.citation
1062
+ and observed.author == requested.author
1063
+ and (observed.keywords or ()) == (requested.keywords or ())
1064
+ and (observed.abstract or "") == (requested.abstract or "")
1065
+ and observed.doi == requested.doi
1066
+ and observed.url == requested.url
1067
+ and (observed.references or ()) == (requested.references or ())
1068
+ )
1069
+
1070
+ def _render_comment(self, draft: LiteratureCommentDraft) -> tuple[str, UploadBindingRef]:
1071
+ raw_draft = cast(object, draft)
1072
+ if not isinstance(raw_draft, LiteratureCommentDraft) or not isinstance(cast(object, raw_draft.html), str):
1073
+ raise MatelabUsageError("Literature comment draft is invalid.")
1074
+ if raw_draft.attachment is None:
1075
+ if _COMMENT_ATTACHMENT_MARKER in raw_draft.html or "url_eln/temp" in raw_draft.html:
1076
+ raise MatelabUsageError("Temporary literature-comment URLs require a completed staged-file object.")
1077
+ return raw_draft.html, UploadBindingRef.new()
1078
+ self._validate_staged_file(raw_draft.attachment)
1079
+ if raw_draft.html.count(_COMMENT_ATTACHMENT_MARKER) != 1 or "url_eln/temp" in raw_draft.html:
1080
+ raise MatelabUsageError(
1081
+ "A literature comment attachment requires exactly one matelab-staged-file marker and no raw temp URL."
1082
+ )
1083
+ return (
1084
+ raw_draft.html.replace(_COMMENT_ATTACHMENT_MARKER, raw_draft.attachment.temporary_url),
1085
+ raw_draft.attachment.binding,
1086
+ )
1087
+
1088
+ @staticmethod
1089
+ def _validate_staged_file(staged: StagedFile) -> None:
1090
+ raw_staged = cast(object, staged)
1091
+ if (
1092
+ not isinstance(raw_staged, StagedFile)
1093
+ or raw_staged.temporary_file_id < 1
1094
+ or not raw_staged.binding.uid
1095
+ or len(raw_staged.binding.uid) > 45
1096
+ or not raw_staged.session.upload_session_id
1097
+ or len(raw_staged.session.upload_session_id) > 45
1098
+ or raw_staged.session.next_offset != raw_staged.size
1099
+ or not raw_staged.filename
1100
+ or "\r" in raw_staged.filename
1101
+ or "\n" in raw_staged.filename
1102
+ or raw_staged.size < 0
1103
+ or _SHA256_PATTERN.fullmatch(raw_staged.sha256) is None
1104
+ or not raw_staged.temporary_url
1105
+ or raw_staged.completion != "provider_acknowledged_complete"
1106
+ or raw_staged.binding_state != "staged_not_bound"
1107
+ ):
1108
+ raise MatelabUsageError("Completed staged-file metadata is invalid.")
1109
+
1110
+ @staticmethod
1111
+ def _library(
1112
+ item: WireLiteratureLibrary, *, kind: Literal["personal", "shared", "dangling_membership"]
1113
+ ) -> LiteratureLibrarySummary:
1114
+ if kind == "personal":
1115
+ ref: LiteratureLibraryRef | None = PersonalLiteratureLibraryRef()
1116
+ elif kind == "shared" and item.id is not None:
1117
+ ref = SharedLiteratureLibraryRef(literature_library_id=item.id)
1118
+ else:
1119
+ ref = None
1120
+ return LiteratureLibrarySummary(
1121
+ ref=ref,
1122
+ kind=kind,
1123
+ name=item.showtext,
1124
+ permission_mask=item.power,
1125
+ item_count=item.num,
1126
+ keywords=tuple(item.keywords),
1127
+ introduction=item.intro,
1128
+ )
1129
+
1130
+ @staticmethod
1131
+ def _summary(item: WireLiteratureListItem, library: LiteratureLibraryRef) -> LiteratureItemSummary:
1132
+ if isinstance(library, PersonalLiteratureLibraryRef):
1133
+ ref: LiteratureItemRef = PersonalLiteratureItemRef(literature_item_id=item.id)
1134
+ else:
1135
+ ref = SharedLiteratureItemRef(literature_item_id=item.id, library=library)
1136
+ pdf = LiteraturePdf(item=ref, sha256=item.hash or None, filename=item.filename or None, size=None)
1137
+ return LiteratureItemSummary(
1138
+ ref=ref,
1139
+ source=Literature._source(item.source),
1140
+ star=item.star,
1141
+ title=item.title,
1142
+ author=item.author,
1143
+ citation_html=item.info,
1144
+ abstract=item.abstract,
1145
+ journal=item.journal,
1146
+ keywords=tuple(item.keywords),
1147
+ publication_date=item.dtime,
1148
+ matching_author_fragments=tuple(item.authors_bold),
1149
+ pdf=pdf,
1150
+ )
1151
+
1152
+ @staticmethod
1153
+ def _field(item: WireLiteratureFieldDefinition) -> LiteratureField:
1154
+ label: str | tuple[str, str]
1155
+ if isinstance(item.name, str):
1156
+ label = item.name
1157
+ else:
1158
+ label = cast(tuple[str, str], tuple(item.name.root))
1159
+ if isinstance(item.comm, str) or item.comm is None:
1160
+ help_text: str | tuple[str, str] | None = item.comm
1161
+ else:
1162
+ help_text = cast(tuple[str, str], tuple(item.comm.root))
1163
+ nested = tuple(
1164
+ LiteratureNestedField(
1165
+ field_id=child.id,
1166
+ field_type=child.type.value,
1167
+ label=(child.name if isinstance(child.name, str) else cast(tuple[str, str], tuple(child.name.root))),
1168
+ width=child.width,
1169
+ attributes=MappingProxyType(cast(dict[str, object], dict(child.model_extra or {}))),
1170
+ )
1171
+ for child in item.template or ()
1172
+ )
1173
+ return LiteratureField(
1174
+ field_id=item.id,
1175
+ field_type=item.type.value,
1176
+ label=label,
1177
+ required=item.required,
1178
+ group=item.group,
1179
+ help_text=help_text,
1180
+ print_template=item.print,
1181
+ nested=nested,
1182
+ visible=item.show,
1183
+ width=item.width,
1184
+ condition=(
1185
+ MappingProxyType(cast(dict[str, object], item.condition.model_dump(mode="json")))
1186
+ if item.condition is not None
1187
+ else None
1188
+ ),
1189
+ attributes=MappingProxyType(cast(dict[str, object], dict(item.model_extra or {}))),
1190
+ )
1191
+
1192
+ @staticmethod
1193
+ def _extracted_author(item: WireExtractedLiteratureAuthor) -> ExtractedLiteratureAuthor:
1194
+ return ExtractedLiteratureAuthor(
1195
+ name=item.name,
1196
+ affiliations=tuple(item.affiliation) if item.affiliation is not None else None,
1197
+ email=item.email,
1198
+ corresponding=item.is_corresponding,
1199
+ orcid=item.orcid,
1200
+ extension_fields=MappingProxyType(cast(dict[str, object], dict(item.model_extra or {}))),
1201
+ )
1202
+
1203
+ @staticmethod
1204
+ def _source(value: object) -> LiteratureSource:
1205
+ source = int(cast(int, value))
1206
+ mapping = {1: LiteratureSource.SUBSCRIBED, 2: LiteratureSource.USER_CREATED, 3: LiteratureSource.SHARED_COPY}
1207
+ try:
1208
+ return mapping[source]
1209
+ except KeyError:
1210
+ raise MatelabProtocolError("Matelab returned an unknown literature source.") from None
1211
+
1212
+ @staticmethod
1213
+ def _source_wire_value(value: LiteratureSource) -> int:
1214
+ try:
1215
+ source = LiteratureSource(value)
1216
+ except (TypeError, ValueError):
1217
+ raise MatelabUsageError("Literature source filter is invalid.") from None
1218
+ return {LiteratureSource.SUBSCRIBED: 1, LiteratureSource.USER_CREATED: 2, LiteratureSource.SHARED_COPY: 3}[
1219
+ source
1220
+ ]
1221
+
1222
+ @staticmethod
1223
+ def _library_id(library: LiteratureLibraryRef) -> int:
1224
+ raw_library = cast(object, library)
1225
+ if isinstance(raw_library, PersonalLiteratureLibraryRef):
1226
+ return 0
1227
+ if (
1228
+ isinstance(raw_library, SharedLiteratureLibraryRef)
1229
+ and type(raw_library.literature_library_id) is int
1230
+ and raw_library.literature_library_id >= 1
1231
+ ):
1232
+ return raw_library.literature_library_id
1233
+ raise MatelabUsageError("Literature library identity is invalid.")
1234
+
1235
+ @staticmethod
1236
+ def _item_id(item: LiteratureItemRef | None) -> int:
1237
+ if isinstance(item, (PersonalLiteratureItemRef, SharedLiteratureItemRef)):
1238
+ value = item.literature_item_id
1239
+ if type(value) is int and value >= 1:
1240
+ if isinstance(item, SharedLiteratureItemRef):
1241
+ _ = Literature._library_id(item.library)
1242
+ return value
1243
+ raise MatelabUsageError("Literature item identity is invalid.")