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/__init__.py +372 -0
- matelab/_generated/__init__.py +1 -0
- matelab/_generated/models.py +3553 -0
- matelab/_transport.py +481 -0
- matelab/client.py +191 -0
- matelab/cloud_drive.py +789 -0
- matelab/errors.py +38 -0
- matelab/groups.py +78 -0
- matelab/literature.py +1243 -0
- matelab/models.py +27 -0
- matelab/notebooks.py +467 -0
- matelab/py.typed +1 -0
- matelab/records.py +2511 -0
- matelab/streaming.py +82 -0
- matelab/templates.py +737 -0
- matelab/uploads.py +247 -0
- matelab/users.py +77 -0
- matelab_python_sdk-0.1.0a1.dist-info/METADATA +538 -0
- matelab_python_sdk-0.1.0a1.dist-info/RECORD +22 -0
- matelab_python_sdk-0.1.0a1.dist-info/WHEEL +4 -0
- matelab_python_sdk-0.1.0a1.dist-info/licenses/LICENSE +202 -0
- matelab_python_sdk-0.1.0a1.dist-info/licenses/NOTICE +2 -0
matelab/records.py
ADDED
|
@@ -0,0 +1,2511 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import json
|
|
3
|
+
import re
|
|
4
|
+
from collections.abc import Mapping, Sequence
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from html.parser import HTMLParser
|
|
7
|
+
from types import MappingProxyType
|
|
8
|
+
from typing import IO, Literal, cast
|
|
9
|
+
from urllib.parse import parse_qs, unquote
|
|
10
|
+
|
|
11
|
+
from pydantic import ValidationError
|
|
12
|
+
|
|
13
|
+
from matelab._generated.models import AddRelationRequest as WireAddRelationRequest
|
|
14
|
+
from matelab._generated.models import BasicSuccessResponse as WireBasicSuccessResponse
|
|
15
|
+
from matelab._generated.models import Code as WireSuccessCode
|
|
16
|
+
from matelab._generated.models import CommentListResponse as WireCommentListResponse
|
|
17
|
+
from matelab._generated.models import CopyRecordRequest as WireCopyRecordRequest
|
|
18
|
+
from matelab._generated.models import CopyRecordResponse as WireCopyRecordResponse
|
|
19
|
+
from matelab._generated.models import CreateRecordRequest as WireCreateRecordRequest
|
|
20
|
+
from matelab._generated.models import Default as WirePageDefault
|
|
21
|
+
from matelab._generated.models import DeletedRecordPageResponse as WireDeletedRecordPageResponse
|
|
22
|
+
from matelab._generated.models import DeleteRecordCommentRequest as WireDeleteRecordCommentRequest
|
|
23
|
+
from matelab._generated.models import DeleteRecordsRequest as WireDeleteRecordsRequest
|
|
24
|
+
from matelab._generated.models import DeleteRelationRequest as WireDeleteRelationRequest
|
|
25
|
+
from matelab._generated.models import DownloadRecordAttachmentParametersQuery as WireDownloadRecordAttachmentQuery
|
|
26
|
+
from matelab._generated.models import (
|
|
27
|
+
DownloadRecordCommentAttachmentParametersQuery as WireDownloadRecordCommentAttachmentQuery,
|
|
28
|
+
)
|
|
29
|
+
from matelab._generated.models import ExportRecordsRequest as WireExportRecordsRequest
|
|
30
|
+
from matelab._generated.models import ExportRecordsResponse as WireExportRecordsResponse
|
|
31
|
+
from matelab._generated.models import ForwardableSuccessResponse as WireForwardableSuccessResponse
|
|
32
|
+
from matelab._generated.models import ImportRecordsRequest as WireImportRecordsRequest
|
|
33
|
+
from matelab._generated.models import ImportRecordsResponse as WireImportRecordsResponse
|
|
34
|
+
from matelab._generated.models import ItemViewResponse as WireItemViewResponse
|
|
35
|
+
from matelab._generated.models import ListDeletedRecordPageParametersQuery as WireDeletedRecordPageQuery
|
|
36
|
+
from matelab._generated.models import ListNotebookRecordPageParametersQuery as WireRecordPageQuery
|
|
37
|
+
from matelab._generated.models import ListRecordCommentsParametersQuery as WireRecordCommentsQuery
|
|
38
|
+
from matelab._generated.models import ListRecordRelationsParametersQuery as WireRecordRelationsQuery
|
|
39
|
+
from matelab._generated.models import ListRecordsRequest as WireListRecordsRequest
|
|
40
|
+
from matelab._generated.models import OrderDesc as WireOrderDesc
|
|
41
|
+
from matelab._generated.models import PublicRecordListRequest as WirePublicRecordListRequest
|
|
42
|
+
from matelab._generated.models import PublicRecordListResponse as WirePublicRecordListResponse
|
|
43
|
+
from matelab._generated.models import ReadRecordParametersQuery as WireReadRecordQuery
|
|
44
|
+
from matelab._generated.models import RecordComment as WireRecordComment
|
|
45
|
+
from matelab._generated.models import RecordListItem as WireRecordListItem
|
|
46
|
+
from matelab._generated.models import RecordListResponse as WireRecordListResponse
|
|
47
|
+
from matelab._generated.models import RecordPageResponse as WireRecordPageResponse
|
|
48
|
+
from matelab._generated.models import RecordSubtype as WireRecordSubtype
|
|
49
|
+
from matelab._generated.models import RelationListResponse as WireRelationListResponse
|
|
50
|
+
from matelab._generated.models import RestoreRecordsRequest as WireRestoreRecordsRequest
|
|
51
|
+
from matelab._generated.models import SaveRecordCommentRequest as WireSaveRecordCommentRequest
|
|
52
|
+
from matelab._generated.models import SearchRecordsRequest as WireSearchRecordsRequest
|
|
53
|
+
from matelab._generated.models import SearchRecordsResponse as WireSearchRecordsResponse
|
|
54
|
+
from matelab._generated.models import UpdateRecordRequest as WireUpdateRecordRequest
|
|
55
|
+
from matelab._generated.models import UploadAttachmentRequest as WireUploadAttachmentRequest
|
|
56
|
+
from matelab._generated.models import UploadAttachmentResponse as WireUploadAttachmentResponse
|
|
57
|
+
from matelab._generated.models import UploadRecordCommentAttachmentRequest as WireUploadRecordCommentAttachmentRequest
|
|
58
|
+
from matelab._generated.models import UploadRecordCommentAttachmentResponse as WireUploadRecordCommentAttachmentResponse
|
|
59
|
+
from matelab._transport import Encoding, Operation, SessionTransport, StreamOperation, multipart_fields
|
|
60
|
+
from matelab.errors import MatelabError, MatelabProtocolError, MatelabUsageError, MatelabVerificationError
|
|
61
|
+
from matelab.notebooks import NotebookRef, PublicNotebookRef
|
|
62
|
+
from matelab.streaming import ByteRange, DownloadStream
|
|
63
|
+
from matelab.templates import TemplateRef
|
|
64
|
+
from matelab.uploads import UploadBindingRef
|
|
65
|
+
from matelab.users import UserRef
|
|
66
|
+
|
|
67
|
+
_LIST_RECORDS = Operation(
|
|
68
|
+
method="POST",
|
|
69
|
+
path="/eln_api/items",
|
|
70
|
+
encoding=Encoding.JSON,
|
|
71
|
+
response_model=WireRecordListResponse,
|
|
72
|
+
success_codes=frozenset({0, 10}),
|
|
73
|
+
retry_on_access_expired=True,
|
|
74
|
+
)
|
|
75
|
+
_CREATE_BLANK_RECORD = Operation(
|
|
76
|
+
method="POST",
|
|
77
|
+
path="/eln_api/create",
|
|
78
|
+
encoding=Encoding.JSON,
|
|
79
|
+
response_model=WireBasicSuccessResponse,
|
|
80
|
+
success_codes=frozenset({0}),
|
|
81
|
+
)
|
|
82
|
+
_IMPORT_RECORDS = Operation(
|
|
83
|
+
method="POST",
|
|
84
|
+
path="/eln_api/import",
|
|
85
|
+
encoding=Encoding.JSON,
|
|
86
|
+
response_model=WireImportRecordsResponse,
|
|
87
|
+
success_codes=frozenset({0}),
|
|
88
|
+
)
|
|
89
|
+
_DELETE_RECORDS = Operation(
|
|
90
|
+
method="POST",
|
|
91
|
+
path="/eln_api/delete",
|
|
92
|
+
encoding=Encoding.JSON,
|
|
93
|
+
response_model=WireBasicSuccessResponse,
|
|
94
|
+
success_codes=frozenset({0}),
|
|
95
|
+
)
|
|
96
|
+
_UPDATE_RECORD = Operation(
|
|
97
|
+
method="POST",
|
|
98
|
+
path="/eln_api/update",
|
|
99
|
+
encoding=Encoding.JSON,
|
|
100
|
+
response_model=WireBasicSuccessResponse,
|
|
101
|
+
success_codes=frozenset({0}),
|
|
102
|
+
)
|
|
103
|
+
_UPLOAD_RECORD_ATTACHMENT = Operation(
|
|
104
|
+
method="POST",
|
|
105
|
+
path="/eln_api/upload",
|
|
106
|
+
encoding=Encoding.MULTIPART,
|
|
107
|
+
response_model=WireUploadAttachmentResponse,
|
|
108
|
+
success_codes=frozenset({0}),
|
|
109
|
+
)
|
|
110
|
+
_READ_RECORD = Operation(
|
|
111
|
+
method="GET",
|
|
112
|
+
path="/eln_items/item_view",
|
|
113
|
+
encoding=Encoding.QUERY,
|
|
114
|
+
response_model=WireItemViewResponse,
|
|
115
|
+
success_codes=frozenset({0}),
|
|
116
|
+
retry_on_access_expired=True,
|
|
117
|
+
)
|
|
118
|
+
_EXPORT_RECORDS = Operation(
|
|
119
|
+
method="POST",
|
|
120
|
+
path="/eln_api/export",
|
|
121
|
+
encoding=Encoding.JSON,
|
|
122
|
+
response_model=WireExportRecordsResponse,
|
|
123
|
+
success_codes=frozenset({0, 10}),
|
|
124
|
+
retry_on_access_expired=True,
|
|
125
|
+
)
|
|
126
|
+
_SEARCH_RECORDS = Operation(
|
|
127
|
+
method="POST",
|
|
128
|
+
path="/eln_api/search",
|
|
129
|
+
encoding=Encoding.JSON,
|
|
130
|
+
response_model=WireSearchRecordsResponse,
|
|
131
|
+
success_codes=frozenset({0, 10}),
|
|
132
|
+
retry_on_access_expired=True,
|
|
133
|
+
)
|
|
134
|
+
_LIST_PUBLIC_RECORDS = Operation(
|
|
135
|
+
method="POST",
|
|
136
|
+
path="/eln_api/public_items",
|
|
137
|
+
encoding=Encoding.JSON,
|
|
138
|
+
response_model=WirePublicRecordListResponse,
|
|
139
|
+
success_codes=frozenset({0}),
|
|
140
|
+
retry_on_access_expired=True,
|
|
141
|
+
)
|
|
142
|
+
_LIST_RECORD_PAGE = Operation(
|
|
143
|
+
method="GET",
|
|
144
|
+
path="/eln_items/items",
|
|
145
|
+
encoding=Encoding.QUERY,
|
|
146
|
+
response_model=WireRecordPageResponse,
|
|
147
|
+
success_codes=frozenset({0}),
|
|
148
|
+
retry_on_access_expired=True,
|
|
149
|
+
)
|
|
150
|
+
_LIST_DELETED_RECORDS = Operation(
|
|
151
|
+
method="GET",
|
|
152
|
+
path="/eln_items/items_recycle",
|
|
153
|
+
encoding=Encoding.QUERY,
|
|
154
|
+
response_model=WireDeletedRecordPageResponse,
|
|
155
|
+
success_codes=frozenset({0, 10}),
|
|
156
|
+
retry_on_access_expired=True,
|
|
157
|
+
)
|
|
158
|
+
_LIST_RELATIONS = Operation(
|
|
159
|
+
method="GET",
|
|
160
|
+
path="/eln_items/related_list",
|
|
161
|
+
encoding=Encoding.QUERY,
|
|
162
|
+
response_model=WireRelationListResponse,
|
|
163
|
+
success_codes=frozenset({0}),
|
|
164
|
+
retry_on_access_expired=True,
|
|
165
|
+
)
|
|
166
|
+
_ADD_RELATION = Operation(
|
|
167
|
+
method="POST",
|
|
168
|
+
path="/eln_items/related_add",
|
|
169
|
+
encoding=Encoding.JSON,
|
|
170
|
+
response_model=WireForwardableSuccessResponse,
|
|
171
|
+
success_codes=frozenset({0, 10}),
|
|
172
|
+
)
|
|
173
|
+
_DELETE_RELATION = Operation(
|
|
174
|
+
method="POST",
|
|
175
|
+
path="/eln_items/related_del",
|
|
176
|
+
encoding=Encoding.JSON,
|
|
177
|
+
response_model=WireForwardableSuccessResponse,
|
|
178
|
+
success_codes=frozenset({0, 10}),
|
|
179
|
+
)
|
|
180
|
+
_RESTORE_RECORDS = Operation(
|
|
181
|
+
method="POST",
|
|
182
|
+
path="/eln_items/restore",
|
|
183
|
+
encoding=Encoding.JSON,
|
|
184
|
+
response_model=WireBasicSuccessResponse,
|
|
185
|
+
success_codes=frozenset({0}),
|
|
186
|
+
)
|
|
187
|
+
_COPY_RECORD = Operation(
|
|
188
|
+
method="POST",
|
|
189
|
+
path="/eln_items/copy",
|
|
190
|
+
encoding=Encoding.JSON,
|
|
191
|
+
response_model=WireCopyRecordResponse,
|
|
192
|
+
success_codes=frozenset({0, 10}),
|
|
193
|
+
)
|
|
194
|
+
_LIST_COMMENTS = Operation(
|
|
195
|
+
method="GET",
|
|
196
|
+
path="/eln_items/mores",
|
|
197
|
+
encoding=Encoding.QUERY,
|
|
198
|
+
response_model=WireCommentListResponse,
|
|
199
|
+
success_codes=frozenset({0}),
|
|
200
|
+
retry_on_access_expired=True,
|
|
201
|
+
)
|
|
202
|
+
_SAVE_COMMENT = Operation(
|
|
203
|
+
method="POST",
|
|
204
|
+
path="/eln_items/more",
|
|
205
|
+
encoding=Encoding.MULTIPART,
|
|
206
|
+
response_model=WireForwardableSuccessResponse,
|
|
207
|
+
success_codes=frozenset({0, 10}),
|
|
208
|
+
)
|
|
209
|
+
_UPLOAD_COMMENT_ATTACHMENT = Operation(
|
|
210
|
+
method="POST",
|
|
211
|
+
path="/eln_items/more_upload",
|
|
212
|
+
encoding=Encoding.MULTIPART,
|
|
213
|
+
response_model=WireUploadRecordCommentAttachmentResponse,
|
|
214
|
+
success_codes=frozenset({0}),
|
|
215
|
+
)
|
|
216
|
+
_DELETE_COMMENT = Operation(
|
|
217
|
+
method="POST",
|
|
218
|
+
path="/eln_items/more_del",
|
|
219
|
+
encoding=Encoding.MULTIPART,
|
|
220
|
+
response_model=WireForwardableSuccessResponse,
|
|
221
|
+
success_codes=frozenset({0, 10}),
|
|
222
|
+
)
|
|
223
|
+
_DOWNLOAD_RECORD_ATTACHMENT = StreamOperation(method="GET", path="/eln_url/eln", retry_on_access_expired=True)
|
|
224
|
+
_DOWNLOAD_COMMENT_ATTACHMENT = StreamOperation(method="GET", path="/eln_url/eln_more", retry_on_access_expired=True)
|
|
225
|
+
_SEARCH_RESERVED_FIELDS = frozenset({"id", "eln_id", "uid", "title", "eln_name"})
|
|
226
|
+
_ELNURL_PATTERN = re.compile(r"^elnurl://h=([a-f0-9]{64})&s=([0-9]+)&f=(.+)$", re.IGNORECASE)
|
|
227
|
+
_ATTACHMENT_MARKER_PATTERN = re.compile(r"matelab-attachment:([A-Za-z0-9_.-]{1,64})")
|
|
228
|
+
_NATIVE_ATTACHMENT_PATTERN = re.compile(r"#(?:hash|file|base64)\{", re.IGNORECASE)
|
|
229
|
+
_COMMENT_TEMP_URL_PATTERN = re.compile(r"https?://[^\"']+/eln_url/eln_temp\?", re.IGNORECASE)
|
|
230
|
+
_DATABASE_UPDATE_MESSAGES = frozenset({"数据库数据更新成功", "Database data update successful"})
|
|
231
|
+
_COLLABORATION_UPDATE_MESSAGES = frozenset({"协同编辑数据更新完成", "Collaboration data update completed"})
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@dataclass(frozen=True, slots=True)
|
|
235
|
+
class RecordRef:
|
|
236
|
+
"""Record identity with distinct database and wire identifiers."""
|
|
237
|
+
|
|
238
|
+
record_database_id: int
|
|
239
|
+
record_uid: str
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@dataclass(frozen=True, slots=True)
|
|
243
|
+
class RecordLocator:
|
|
244
|
+
notebook: NotebookRef
|
|
245
|
+
record: RecordRef
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@dataclass(frozen=True, slots=True)
|
|
249
|
+
class DeletedRecordRef:
|
|
250
|
+
record_database_id: int
|
|
251
|
+
record_uid: str
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
@dataclass(frozen=True, slots=True)
|
|
255
|
+
class RecordVersionRef:
|
|
256
|
+
"""Historical version identity derived from an authorized current record read."""
|
|
257
|
+
|
|
258
|
+
notebook_id: int
|
|
259
|
+
record_database_id: int
|
|
260
|
+
record_uid: str
|
|
261
|
+
version_id: int
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
@dataclass(frozen=True, slots=True)
|
|
265
|
+
class RecordSummary:
|
|
266
|
+
ref: RecordRef
|
|
267
|
+
title: str
|
|
268
|
+
summary: str | None
|
|
269
|
+
created_at: str
|
|
270
|
+
modified_at: str
|
|
271
|
+
keywords: tuple[str, ...]
|
|
272
|
+
template_id: int | None
|
|
273
|
+
subtype_id: int | None
|
|
274
|
+
encrypted: bool
|
|
275
|
+
audit_count: int | None
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
@dataclass(frozen=True, slots=True)
|
|
279
|
+
class RecordSubtypeSummary:
|
|
280
|
+
subtype_id: int
|
|
281
|
+
title: str
|
|
282
|
+
parent_subtype_id: int
|
|
283
|
+
record_count: int | None
|
|
284
|
+
child_subtype_count: int | None
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
@dataclass(frozen=True, slots=True)
|
|
288
|
+
class RecordCollection:
|
|
289
|
+
records: tuple[RecordSummary, ...]
|
|
290
|
+
subtypes: tuple[RecordSubtypeSummary, ...]
|
|
291
|
+
trash_count: int
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
@dataclass(frozen=True, slots=True)
|
|
295
|
+
class BlankRecordCreateResult:
|
|
296
|
+
notebook: NotebookRef
|
|
297
|
+
title: str
|
|
298
|
+
requested_record_uid: str | None
|
|
299
|
+
observed: RecordSummary | None
|
|
300
|
+
confirmation: Literal[
|
|
301
|
+
"observed_by_requested_uid", "not_observed_by_requested_uid", "provider_generated_identity_not_returned"
|
|
302
|
+
]
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
@dataclass(frozen=True, slots=True)
|
|
306
|
+
class RecordImportTemplate:
|
|
307
|
+
template: TemplateRef
|
|
308
|
+
title: str
|
|
309
|
+
owner: UserRef | None = None
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
@dataclass(frozen=True, slots=True)
|
|
313
|
+
class RecordImportItem:
|
|
314
|
+
record_uid: str
|
|
315
|
+
title: str
|
|
316
|
+
data: Mapping[str, object]
|
|
317
|
+
keyword: str | None = None
|
|
318
|
+
path: tuple[str, ...] | None = None
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
@dataclass(frozen=True, slots=True)
|
|
322
|
+
class RecordImportResult:
|
|
323
|
+
notebook: NotebookRef
|
|
324
|
+
template: RecordImportTemplate
|
|
325
|
+
requested_record_uids: tuple[str, ...]
|
|
326
|
+
created_record_database_ids: tuple[int, ...]
|
|
327
|
+
provider_notebook_id: int
|
|
328
|
+
per_item_mapping: None = None
|
|
329
|
+
identity_count_status: Literal["one_id_per_item", "fewer_ids_than_items", "more_ids_than_items"] = "one_id_per_item"
|
|
330
|
+
atomicity: Literal["provider_unspecified"] = "provider_unspecified"
|
|
331
|
+
field_persistence: Literal["provider_not_verified"] = "provider_not_verified"
|
|
332
|
+
known_limitations: tuple[Literal["PVD-023"], Literal["PVD-024"]] = ("PVD-023", "PVD-024")
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
@dataclass(frozen=True, slots=True)
|
|
336
|
+
class RecordCopyResult:
|
|
337
|
+
source: RecordLocator
|
|
338
|
+
target_notebook: NotebookRef
|
|
339
|
+
record_database_id: int
|
|
340
|
+
record: RecordRef | None
|
|
341
|
+
observed: RecordSummary | None
|
|
342
|
+
forwarded: bool
|
|
343
|
+
confirmation: Literal["observed_by_returned_id", "returned_id_not_observed"]
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
@dataclass(frozen=True, slots=True)
|
|
347
|
+
class RecordDeleteResult:
|
|
348
|
+
notebook: NotebookRef
|
|
349
|
+
requested: tuple[RecordRef, ...]
|
|
350
|
+
no_longer_active: tuple[RecordRef, ...]
|
|
351
|
+
still_active: tuple[RecordRef, ...]
|
|
352
|
+
observed_in_recycle_bin: tuple[DeletedRecordRef, ...]
|
|
353
|
+
unresolved: tuple[RecordRef, ...]
|
|
354
|
+
operation_semantics: Literal["move_to_recycle_bin"] = "move_to_recycle_bin"
|
|
355
|
+
observation_scope: Literal["active_list_and_first_256_deleted_rows"] = "active_list_and_first_256_deleted_rows"
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
@dataclass(frozen=True, slots=True)
|
|
359
|
+
class RecordRestoreResult:
|
|
360
|
+
notebook: NotebookRef
|
|
361
|
+
requested: tuple[DeletedRecordRef, ...]
|
|
362
|
+
observed_active: tuple[RecordRef, ...]
|
|
363
|
+
still_deleted: tuple[DeletedRecordRef, ...]
|
|
364
|
+
unresolved: tuple[DeletedRecordRef, ...]
|
|
365
|
+
provider_per_item_results_available: Literal[False] = False
|
|
366
|
+
observation_scope: Literal["active_list_and_first_256_deleted_rows"] = "active_list_and_first_256_deleted_rows"
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
@dataclass(frozen=True, slots=True)
|
|
370
|
+
class ExportedRecord:
|
|
371
|
+
ref: RecordRef
|
|
372
|
+
notebook_title: str | None
|
|
373
|
+
title: str
|
|
374
|
+
summary: str | None
|
|
375
|
+
data: tuple[object, ...]
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
@dataclass(frozen=True, slots=True)
|
|
379
|
+
class RecordExport:
|
|
380
|
+
records: tuple[ExportedRecord, ...]
|
|
381
|
+
forwarded: bool
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
@dataclass(frozen=True, slots=True)
|
|
385
|
+
class RecordFieldExtraction:
|
|
386
|
+
alias: str
|
|
387
|
+
path: tuple[str | int, ...]
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
@dataclass(frozen=True, slots=True)
|
|
391
|
+
class RecordSearchMatch:
|
|
392
|
+
ref: RecordRef
|
|
393
|
+
notebook_id: int
|
|
394
|
+
notebook_title: str
|
|
395
|
+
title: str
|
|
396
|
+
extracted: Mapping[str, object]
|
|
397
|
+
missing_extractions: tuple[str, ...]
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
@dataclass(frozen=True, slots=True)
|
|
401
|
+
class RecordSearchResult:
|
|
402
|
+
matches: tuple[RecordSearchMatch, ...]
|
|
403
|
+
forwarded: bool
|
|
404
|
+
ordering: Literal["provider_unspecified"] = "provider_unspecified"
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
@dataclass(frozen=True, slots=True)
|
|
408
|
+
class PublicRecordRef:
|
|
409
|
+
public_entry_id: int
|
|
410
|
+
source_notebook_id: int
|
|
411
|
+
source_record_database_id: int
|
|
412
|
+
record_uid: str | None
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
@dataclass(frozen=True, slots=True)
|
|
416
|
+
class PublicRecordSummary:
|
|
417
|
+
ref: PublicRecordRef
|
|
418
|
+
title: str | None
|
|
419
|
+
summary: str | None
|
|
420
|
+
locked: bool
|
|
421
|
+
source_notebook_title: str | None
|
|
422
|
+
owner: UserRef | None
|
|
423
|
+
owner_name: str | None
|
|
424
|
+
owner_email: str | None
|
|
425
|
+
owned_by_caller: bool
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
@dataclass(frozen=True, slots=True)
|
|
429
|
+
class PublicRecordCollection:
|
|
430
|
+
notebook: PublicNotebookRef
|
|
431
|
+
records: tuple[PublicRecordSummary, ...]
|
|
432
|
+
ordering: Literal["provider_unspecified"] = "provider_unspecified"
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
@dataclass(frozen=True, slots=True)
|
|
436
|
+
class RecordPagePermissions:
|
|
437
|
+
editable: bool
|
|
438
|
+
can_create: bool
|
|
439
|
+
can_delete: bool
|
|
440
|
+
can_manage_subtypes: bool
|
|
441
|
+
can_read_audit: bool
|
|
442
|
+
can_manage_users: bool
|
|
443
|
+
can_sign: bool
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
@dataclass(frozen=True, slots=True)
|
|
447
|
+
class RecordPageContext:
|
|
448
|
+
notebook: NotebookRef
|
|
449
|
+
notebook_title: str | None
|
|
450
|
+
owner_name: str | None
|
|
451
|
+
template_id: int | None
|
|
452
|
+
data_server: str
|
|
453
|
+
permissions: RecordPagePermissions
|
|
454
|
+
effective_page_size: int
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
@dataclass(frozen=True, slots=True)
|
|
458
|
+
class RecordPage:
|
|
459
|
+
records: tuple[RecordSummary, ...]
|
|
460
|
+
subtypes: tuple[RecordSubtypeSummary, ...]
|
|
461
|
+
context: RecordPageContext
|
|
462
|
+
page: int
|
|
463
|
+
total_count: int
|
|
464
|
+
matching_record_database_ids: tuple[int, ...]
|
|
465
|
+
trash_count: int
|
|
466
|
+
has_more: bool
|
|
467
|
+
keywords: tuple[object, ...]
|
|
468
|
+
has_more_basis: Literal["derived_from_matching_ids"] = "derived_from_matching_ids"
|
|
469
|
+
ordering: Literal["provider_configured_unstable"] = "provider_configured_unstable"
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
@dataclass(frozen=True, slots=True)
|
|
473
|
+
class DeletedRecordSummary:
|
|
474
|
+
ref: DeletedRecordRef
|
|
475
|
+
title: str
|
|
476
|
+
summary: str | None
|
|
477
|
+
created_at: str
|
|
478
|
+
modified_at: str
|
|
479
|
+
deleted_at: str | None
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
@dataclass(frozen=True, slots=True)
|
|
483
|
+
class DeletedRecordPage:
|
|
484
|
+
notebook: NotebookRef
|
|
485
|
+
records: tuple[DeletedRecordSummary, ...]
|
|
486
|
+
page: int
|
|
487
|
+
requested_page_size: int
|
|
488
|
+
total_count: int
|
|
489
|
+
has_more: bool
|
|
490
|
+
forwarded: bool
|
|
491
|
+
has_more_basis: Literal["derived_from_total"] = "derived_from_total"
|
|
492
|
+
ordering: Literal["provider_unspecified"] = "provider_unspecified"
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
@dataclass(frozen=True, slots=True)
|
|
496
|
+
class RecordCommentRef:
|
|
497
|
+
comment_id: int
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
@dataclass(frozen=True, slots=True)
|
|
501
|
+
class RecordAttachmentLocation:
|
|
502
|
+
"""One observed attachment occurrence in canonical record content."""
|
|
503
|
+
|
|
504
|
+
kind: Literal["form_file", "table_file", "files_module", "images_module", "richtext"]
|
|
505
|
+
module: str
|
|
506
|
+
field: str | None
|
|
507
|
+
position: int
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
@dataclass(frozen=True, slots=True)
|
|
511
|
+
class RecordAttachmentRef:
|
|
512
|
+
"""Attachment metadata observed in an authorized record read."""
|
|
513
|
+
|
|
514
|
+
source: RecordLocator
|
|
515
|
+
sha256: str = field(repr=False)
|
|
516
|
+
filename: str
|
|
517
|
+
size: int
|
|
518
|
+
observed_version: RecordVersionRef | None = None
|
|
519
|
+
location: RecordAttachmentLocation | None = None
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
@dataclass(frozen=True, slots=True)
|
|
523
|
+
class StagedRecordAttachment:
|
|
524
|
+
"""A completed record upload that has not yet been bound by a record update."""
|
|
525
|
+
|
|
526
|
+
source: RecordLocator
|
|
527
|
+
temporary_file_id: int
|
|
528
|
+
binding: UploadBindingRef
|
|
529
|
+
filename: str
|
|
530
|
+
size: int
|
|
531
|
+
sha256: str = field(repr=False)
|
|
532
|
+
binding_state: Literal["staged_not_bound"] = "staged_not_bound"
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
@dataclass(frozen=True, slots=True)
|
|
536
|
+
class RecordValueUpdate:
|
|
537
|
+
path: tuple[str | int, ...]
|
|
538
|
+
value: object
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
@dataclass(frozen=True, slots=True)
|
|
542
|
+
class RecordValueDeletion:
|
|
543
|
+
path: tuple[str | int, ...]
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
@dataclass(frozen=True, slots=True)
|
|
547
|
+
class RecordModuleCreation:
|
|
548
|
+
name: str
|
|
549
|
+
module_type: Literal["form", "table", "files", "richtext", "code"]
|
|
550
|
+
data: str | None = None
|
|
551
|
+
language: (
|
|
552
|
+
Literal["json", "python", "javascript", "html", "css", "xml", "php", "java", "cpp", "sql", "latex", "other"]
|
|
553
|
+
| None
|
|
554
|
+
) = None
|
|
555
|
+
style: Literal["ternary", "echarts"] | None = None
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
@dataclass(frozen=True, slots=True)
|
|
559
|
+
class RecordModuleDeletion:
|
|
560
|
+
name: str
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
@dataclass(frozen=True, slots=True)
|
|
564
|
+
class RecordValueAddition:
|
|
565
|
+
module: str
|
|
566
|
+
value: object = None
|
|
567
|
+
name: str | None = None
|
|
568
|
+
value_type: Literal["text", "richtext", "multiline", "number", "date", "time", "file", "bool"] | None = None
|
|
569
|
+
row: Literal[0, -1] | None = None
|
|
570
|
+
path: tuple[str, ...] | None = None
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
@dataclass(frozen=True, slots=True)
|
|
574
|
+
class RecordFormAttachmentRemoval:
|
|
575
|
+
attachment: RecordAttachmentRef
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
@dataclass(frozen=True, slots=True)
|
|
579
|
+
class RecordTableAttachmentReplacement:
|
|
580
|
+
existing: RecordAttachmentRef
|
|
581
|
+
replacement: StagedRecordAttachment
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
@dataclass(frozen=True, slots=True)
|
|
585
|
+
class RecordFilesAttachmentAppend:
|
|
586
|
+
module: str
|
|
587
|
+
attachment: StagedRecordAttachment
|
|
588
|
+
caption: str | None = None
|
|
589
|
+
path: tuple[str, ...] | None = None
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
@dataclass(frozen=True, slots=True)
|
|
593
|
+
class RecordFilesAttachmentReplacement:
|
|
594
|
+
existing: RecordAttachmentRef
|
|
595
|
+
replacement: StagedRecordAttachment
|
|
596
|
+
caption: str | None = None
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
@dataclass(frozen=True, slots=True)
|
|
600
|
+
class RecordFilesAttachmentRemoval:
|
|
601
|
+
attachment: RecordAttachmentRef
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def _empty_record_attachment_bindings() -> Mapping[str, StagedRecordAttachment]:
|
|
605
|
+
return {}
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
@dataclass(frozen=True, slots=True)
|
|
609
|
+
class RecordRichTextUpdate:
|
|
610
|
+
module: str
|
|
611
|
+
html: str
|
|
612
|
+
attachments: Mapping[str, StagedRecordAttachment] = field(default_factory=_empty_record_attachment_bindings)
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
@dataclass(frozen=True, slots=True)
|
|
616
|
+
class RecordPatch:
|
|
617
|
+
value_updates: tuple[RecordValueUpdate, ...] = ()
|
|
618
|
+
value_deletions: tuple[RecordValueDeletion, ...] = ()
|
|
619
|
+
module_creations: tuple[RecordModuleCreation, ...] = ()
|
|
620
|
+
module_deletions: tuple[RecordModuleDeletion, ...] = ()
|
|
621
|
+
value_additions: tuple[RecordValueAddition, ...] = ()
|
|
622
|
+
attachment_changes: tuple[
|
|
623
|
+
RecordFormAttachmentRemoval
|
|
624
|
+
| RecordTableAttachmentReplacement
|
|
625
|
+
| RecordFilesAttachmentAppend
|
|
626
|
+
| RecordFilesAttachmentReplacement
|
|
627
|
+
| RecordFilesAttachmentRemoval,
|
|
628
|
+
...,
|
|
629
|
+
] = ()
|
|
630
|
+
rich_text_updates: tuple[RecordRichTextUpdate, ...] = ()
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
@dataclass(frozen=True, slots=True)
|
|
634
|
+
class RecordVersionSummary:
|
|
635
|
+
ref: RecordVersionRef
|
|
636
|
+
locked_at_ms: int
|
|
637
|
+
modified_at_ms: int
|
|
638
|
+
locked: bool
|
|
639
|
+
author_userid: int
|
|
640
|
+
author_name: str | None
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
@dataclass(frozen=True, slots=True)
|
|
644
|
+
class RecordSignature:
|
|
645
|
+
signer_userid: int
|
|
646
|
+
username: str | None
|
|
647
|
+
photo_signature: str | None
|
|
648
|
+
signed_at: str | None
|
|
649
|
+
timestamp_result: str
|
|
650
|
+
user_result: str
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
@dataclass(frozen=True, slots=True)
|
|
654
|
+
class Record:
|
|
655
|
+
ref: RecordRef
|
|
656
|
+
notebook: NotebookRef
|
|
657
|
+
selected_version: RecordVersionRef | None
|
|
658
|
+
selected_record_uid: str | None
|
|
659
|
+
title: str | None
|
|
660
|
+
keywords: tuple[str, ...] | None
|
|
661
|
+
modules: tuple[dict[str, object], ...]
|
|
662
|
+
template_id: int | None
|
|
663
|
+
modified_at_ms: int
|
|
664
|
+
author_name: str | None
|
|
665
|
+
versions: tuple[RecordVersionSummary, ...]
|
|
666
|
+
current_version: RecordVersionRef | None
|
|
667
|
+
locked_at_ms: int
|
|
668
|
+
locked: bool
|
|
669
|
+
signatures: tuple[RecordSignature, ...]
|
|
670
|
+
signed: bool
|
|
671
|
+
owner: bool
|
|
672
|
+
editable: bool
|
|
673
|
+
notebook_title: str | None
|
|
674
|
+
data_server: str
|
|
675
|
+
notebook_keywords: tuple[object, ...]
|
|
676
|
+
pdf_enabled: bool
|
|
677
|
+
template_render_metadata: str
|
|
678
|
+
template_title: str
|
|
679
|
+
template_intro: str
|
|
680
|
+
attachments: tuple[RecordAttachmentRef, ...]
|
|
681
|
+
content_sha256: str
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
@dataclass(frozen=True, slots=True)
|
|
685
|
+
class RecordUpdateResult:
|
|
686
|
+
source: RecordLocator
|
|
687
|
+
before: Record
|
|
688
|
+
observed_after: Record | None
|
|
689
|
+
persistence: Literal["persisted", "pending_browser_save", "provider_acknowledged_unclassified"]
|
|
690
|
+
confirmation: Literal["observed_changed", "observed_unchanged", "pending_browser_save_not_read_back"]
|
|
691
|
+
requested_operation_count: int
|
|
692
|
+
provider_message: str
|
|
693
|
+
atomicity: Literal["provider_unspecified"] = "provider_unspecified"
|
|
694
|
+
concurrency_control: Literal["client_read_precondition_not_provider_cas"] = (
|
|
695
|
+
"client_read_precondition_not_provider_cas"
|
|
696
|
+
)
|
|
697
|
+
retryable: Literal[False] = False
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
@dataclass(frozen=True, slots=True)
|
|
701
|
+
class RecordCommentAttachmentRef:
|
|
702
|
+
"""Attachment metadata observed in an authorized comment read.
|
|
703
|
+
|
|
704
|
+
This reference preserves its record/comment context for correct request
|
|
705
|
+
construction. It is not a Provider-side authorization credential: current
|
|
706
|
+
Providers do not verify that the file belongs to that context (PVD-038).
|
|
707
|
+
"""
|
|
708
|
+
|
|
709
|
+
source: RecordLocator
|
|
710
|
+
comment: RecordCommentRef
|
|
711
|
+
sha256: str = field(repr=False)
|
|
712
|
+
filename: str
|
|
713
|
+
size: int
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
@dataclass(frozen=True, slots=True)
|
|
717
|
+
class StagedRecordCommentAttachment:
|
|
718
|
+
source: RecordLocator
|
|
719
|
+
binding: UploadBindingRef
|
|
720
|
+
temporary_file_id: int
|
|
721
|
+
filename: str
|
|
722
|
+
size: int
|
|
723
|
+
sha256: str = field(repr=False)
|
|
724
|
+
abort_supported: Literal[False] = False
|
|
725
|
+
binding_user_verified_on_save: Literal[False] = False
|
|
726
|
+
_temporary_url: str = field(repr=False, compare=False, default="")
|
|
727
|
+
_canonical_reference: str = field(repr=False, compare=False, default="")
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def _empty_comment_attachment_bindings() -> Mapping[str, StagedRecordCommentAttachment]:
|
|
731
|
+
return {}
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
@dataclass(frozen=True, slots=True)
|
|
735
|
+
class RecordCommentDraft:
|
|
736
|
+
html: str
|
|
737
|
+
attachments: Mapping[str, StagedRecordCommentAttachment] = field(default_factory=_empty_comment_attachment_bindings)
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
@dataclass(frozen=True, slots=True)
|
|
741
|
+
class RecordComment:
|
|
742
|
+
source: RecordLocator
|
|
743
|
+
ref: RecordCommentRef
|
|
744
|
+
modified_at: str
|
|
745
|
+
body: str
|
|
746
|
+
author_name: str | None
|
|
747
|
+
owned_by_caller: bool
|
|
748
|
+
attachments: tuple[RecordCommentAttachmentRef, ...]
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
@dataclass(frozen=True, slots=True)
|
|
752
|
+
class RecordComments:
|
|
753
|
+
source: RecordLocator
|
|
754
|
+
comments: tuple[RecordComment, ...]
|
|
755
|
+
ordering: Literal["provider_unspecified"] = "provider_unspecified"
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
@dataclass(frozen=True, slots=True)
|
|
759
|
+
class RecordCommentSaveResult:
|
|
760
|
+
source: RecordLocator
|
|
761
|
+
action: Literal["create", "update"]
|
|
762
|
+
requested_comment: RecordCommentRef | None
|
|
763
|
+
observed: tuple[RecordComment, ...]
|
|
764
|
+
confirmation: Literal[
|
|
765
|
+
"unique_created_comment_observed",
|
|
766
|
+
"created_comment_identity_ambiguous",
|
|
767
|
+
"created_comment_not_observed",
|
|
768
|
+
"updated_comment_observed_matching",
|
|
769
|
+
"updated_comment_observed_mismatched",
|
|
770
|
+
"updated_comment_not_observed",
|
|
771
|
+
]
|
|
772
|
+
identity_source: Literal["readback", "unknown"]
|
|
773
|
+
forwarded: bool
|
|
774
|
+
provider_returned_identity: Literal[False] = False
|
|
775
|
+
retryable: Literal[False] = False
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
@dataclass(frozen=True, slots=True)
|
|
779
|
+
class RecordCommentDeleteResult:
|
|
780
|
+
comment: RecordCommentRef
|
|
781
|
+
source: RecordLocator
|
|
782
|
+
confirmation: Literal["not_observed_after_delete", "still_observed_after_delete"]
|
|
783
|
+
forwarded: bool
|
|
784
|
+
affected_row_count: None = None
|
|
785
|
+
retryable: Literal[False] = False
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
@dataclass(frozen=True, slots=True)
|
|
789
|
+
class RelatedRecordIdentity:
|
|
790
|
+
declared_notebook_id: int | None
|
|
791
|
+
resolved_notebook_id: int | None
|
|
792
|
+
record_database_id: int
|
|
793
|
+
record_uid: str | None
|
|
794
|
+
notebook_title: str | None
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
@dataclass(frozen=True, slots=True)
|
|
798
|
+
class RecordRelationRef:
|
|
799
|
+
relation_id: int
|
|
800
|
+
source: RecordLocator
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
@dataclass(frozen=True, slots=True)
|
|
804
|
+
class RecordRelation:
|
|
805
|
+
ref: RecordRelationRef
|
|
806
|
+
target: RelatedRecordIdentity
|
|
807
|
+
title: str | None
|
|
808
|
+
summary: str | None
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
@dataclass(frozen=True, slots=True)
|
|
812
|
+
class RecordRelations:
|
|
813
|
+
relations: tuple[RecordRelation, ...]
|
|
814
|
+
legacy_comments: tuple[RecordComment, ...]
|
|
815
|
+
editable: bool
|
|
816
|
+
ordering: Literal["provider_unspecified"] = "provider_unspecified"
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
@dataclass(frozen=True, slots=True)
|
|
820
|
+
class RecordRelationAddResult:
|
|
821
|
+
source: RecordLocator
|
|
822
|
+
target: RecordLocator
|
|
823
|
+
observed: tuple[RecordRelation, ...]
|
|
824
|
+
confirmation: Literal["observed_after_add", "not_observed_after_add"]
|
|
825
|
+
forwarded: bool
|
|
826
|
+
provider_returned_identity: Literal[False] = False
|
|
827
|
+
preflight: Literal["client_verified_non_atomic"] = "client_verified_non_atomic"
|
|
828
|
+
retryable: Literal[False] = False
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
@dataclass(frozen=True, slots=True)
|
|
832
|
+
class RecordRelationDeleteResult:
|
|
833
|
+
requested: RecordRelation
|
|
834
|
+
confirmation: Literal["not_observed_after_delete", "still_observed_after_delete"]
|
|
835
|
+
other_relations_missing_after_write: tuple[RecordRelationRef, ...]
|
|
836
|
+
forwarded: bool
|
|
837
|
+
affected_row_count: None = None
|
|
838
|
+
preflight: Literal["client_verified_non_atomic"] = "client_verified_non_atomic"
|
|
839
|
+
retryable: Literal[False] = False
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
@dataclass(frozen=True, slots=True)
|
|
843
|
+
class _ParsedAttachment:
|
|
844
|
+
sha256: str
|
|
845
|
+
filename: str
|
|
846
|
+
size: int
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
class _RichTextAttachmentParser(HTMLParser):
|
|
850
|
+
def __init__(self) -> None:
|
|
851
|
+
super().__init__(convert_charrefs=True)
|
|
852
|
+
self.attachments: list[_ParsedAttachment] = []
|
|
853
|
+
|
|
854
|
+
def handle_starttag( # pyright: ignore[reportImplicitOverride]
|
|
855
|
+
self, tag: str, attrs: list[tuple[str, str | None]]
|
|
856
|
+
) -> None:
|
|
857
|
+
target_attribute = "src" if tag.casefold() == "img" else "href" if tag.casefold() == "a" else None
|
|
858
|
+
if target_attribute is None:
|
|
859
|
+
return
|
|
860
|
+
value = next((value for name, value in attrs if name.casefold() == target_attribute), None)
|
|
861
|
+
if value is None:
|
|
862
|
+
return
|
|
863
|
+
attachment = _parse_elnurl(value)
|
|
864
|
+
if attachment is not None:
|
|
865
|
+
self.attachments.append(attachment)
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
def _parse_elnurl(value: str) -> _ParsedAttachment | None:
|
|
869
|
+
match = _ELNURL_PATTERN.fullmatch(value)
|
|
870
|
+
if match is None:
|
|
871
|
+
return None
|
|
872
|
+
filename = unquote(match.group(3))
|
|
873
|
+
if not filename:
|
|
874
|
+
return None
|
|
875
|
+
return _ParsedAttachment(sha256=match.group(1).lower(), filename=filename, size=int(match.group(2)))
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
def _rich_text_attachments(value: str) -> tuple[_ParsedAttachment, ...]:
|
|
879
|
+
parser = _RichTextAttachmentParser()
|
|
880
|
+
parser.feed(value)
|
|
881
|
+
parser.close()
|
|
882
|
+
return tuple(parser.attachments)
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
def _nested_record_attachments(value: object) -> tuple[_ParsedAttachment, ...]:
|
|
886
|
+
attachments: list[_ParsedAttachment] = []
|
|
887
|
+
|
|
888
|
+
def visit(item: object) -> None:
|
|
889
|
+
if isinstance(item, str):
|
|
890
|
+
attachments.extend(_rich_text_attachments(item))
|
|
891
|
+
return
|
|
892
|
+
if isinstance(item, list):
|
|
893
|
+
for child in cast(list[object], item):
|
|
894
|
+
visit(child)
|
|
895
|
+
return
|
|
896
|
+
if not isinstance(item, dict):
|
|
897
|
+
return
|
|
898
|
+
mapping = cast(dict[str, object], item)
|
|
899
|
+
sha256 = mapping.get("hash")
|
|
900
|
+
filename = mapping.get("filename")
|
|
901
|
+
size = mapping.get("size")
|
|
902
|
+
if (
|
|
903
|
+
isinstance(sha256, str)
|
|
904
|
+
and _ELNURL_PATTERN.fullmatch(f"elnurl://h={sha256}&s=0&f=x") is not None
|
|
905
|
+
and isinstance(filename, str)
|
|
906
|
+
and filename
|
|
907
|
+
and type(size) is int
|
|
908
|
+
and size >= 0
|
|
909
|
+
):
|
|
910
|
+
attachments.append(_ParsedAttachment(sha256=sha256.lower(), filename=filename, size=size))
|
|
911
|
+
for child in mapping.values():
|
|
912
|
+
visit(child)
|
|
913
|
+
|
|
914
|
+
visit(value)
|
|
915
|
+
return tuple(attachments)
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
def _content_sha256(modules: Sequence[Mapping[str, object]]) -> str:
|
|
919
|
+
encoded = json.dumps(list(modules), ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode()
|
|
920
|
+
return hashlib.sha256(encoded).hexdigest()
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
def _contains_native_attachment_reference(value: object) -> bool:
|
|
924
|
+
if isinstance(value, str):
|
|
925
|
+
return _NATIVE_ATTACHMENT_PATTERN.search(value) is not None
|
|
926
|
+
if isinstance(value, Mapping):
|
|
927
|
+
return any(
|
|
928
|
+
_contains_native_attachment_reference(item) for item in cast(Mapping[object, object], value).values()
|
|
929
|
+
)
|
|
930
|
+
if isinstance(value, (list, tuple)):
|
|
931
|
+
return any(
|
|
932
|
+
_contains_native_attachment_reference(item) for item in cast(list[object] | tuple[object, ...], value)
|
|
933
|
+
)
|
|
934
|
+
return False
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
def _record_attachment_refs(
|
|
938
|
+
modules: Sequence[Mapping[str, object]], *, source: RecordLocator, version: RecordVersionRef | None
|
|
939
|
+
) -> tuple[RecordAttachmentRef, ...]:
|
|
940
|
+
located: list[tuple[_ParsedAttachment, RecordAttachmentLocation]] = []
|
|
941
|
+
|
|
942
|
+
def add_structured(
|
|
943
|
+
value: object,
|
|
944
|
+
*,
|
|
945
|
+
kind: Literal["form_file", "table_file", "files_module", "images_module"],
|
|
946
|
+
module: str,
|
|
947
|
+
field_name: str | None,
|
|
948
|
+
position: int,
|
|
949
|
+
) -> None:
|
|
950
|
+
if not isinstance(value, dict):
|
|
951
|
+
return
|
|
952
|
+
mapping = cast(dict[str, object], value)
|
|
953
|
+
sha256 = mapping.get("hash")
|
|
954
|
+
filename = mapping.get("filename")
|
|
955
|
+
size = mapping.get("size")
|
|
956
|
+
if (
|
|
957
|
+
isinstance(sha256, str)
|
|
958
|
+
and re.fullmatch(r"[0-9a-f]{64}", sha256) is not None
|
|
959
|
+
and isinstance(filename, str)
|
|
960
|
+
and filename
|
|
961
|
+
and type(size) is int
|
|
962
|
+
and size >= 0
|
|
963
|
+
):
|
|
964
|
+
located.append(
|
|
965
|
+
(
|
|
966
|
+
_ParsedAttachment(sha256=sha256, filename=filename, size=size),
|
|
967
|
+
RecordAttachmentLocation(kind=kind, module=module, field=field_name, position=position),
|
|
968
|
+
)
|
|
969
|
+
)
|
|
970
|
+
|
|
971
|
+
for module in modules:
|
|
972
|
+
module_name = module.get("name")
|
|
973
|
+
module_type = module.get("type")
|
|
974
|
+
if not isinstance(module_name, str) or not module_name or not isinstance(module_type, str):
|
|
975
|
+
continue
|
|
976
|
+
values = module.get("data")
|
|
977
|
+
if module_type in {"form", "table"} and isinstance(values, list):
|
|
978
|
+
kind: Literal["form_file", "table_file"] = "form_file" if module_type == "form" else "table_file"
|
|
979
|
+
for raw_field in cast(list[object], values):
|
|
980
|
+
if not isinstance(raw_field, dict):
|
|
981
|
+
continue
|
|
982
|
+
field_mapping = cast(dict[str, object], raw_field)
|
|
983
|
+
field_name = field_mapping.get("name")
|
|
984
|
+
field_values = field_mapping.get("data")
|
|
985
|
+
if (
|
|
986
|
+
field_mapping.get("type") != "file"
|
|
987
|
+
or not isinstance(field_name, str)
|
|
988
|
+
or not field_name
|
|
989
|
+
or not isinstance(field_values, list)
|
|
990
|
+
):
|
|
991
|
+
continue
|
|
992
|
+
for position, value in enumerate(cast(list[object], field_values)):
|
|
993
|
+
add_structured(value, kind=kind, module=module_name, field_name=field_name, position=position)
|
|
994
|
+
elif module_type in {"files", "images"} and isinstance(values, list):
|
|
995
|
+
file_kind: Literal["files_module", "images_module"] = (
|
|
996
|
+
"files_module" if module_type == "files" else "images_module"
|
|
997
|
+
)
|
|
998
|
+
for position, value in enumerate(cast(list[object], values)):
|
|
999
|
+
add_structured(value, kind=file_kind, module=module_name, field_name=None, position=position)
|
|
1000
|
+
elif module_type == "richtext" and isinstance(values, str):
|
|
1001
|
+
for position, attachment in enumerate(_rich_text_attachments(values)):
|
|
1002
|
+
located.append(
|
|
1003
|
+
(
|
|
1004
|
+
attachment,
|
|
1005
|
+
RecordAttachmentLocation(kind="richtext", module=module_name, field=None, position=position),
|
|
1006
|
+
)
|
|
1007
|
+
)
|
|
1008
|
+
|
|
1009
|
+
known_values = {(attachment.sha256, attachment.filename, attachment.size) for attachment, _ in located}
|
|
1010
|
+
unlocated = [
|
|
1011
|
+
attachment
|
|
1012
|
+
for attachment in _nested_record_attachments(list(modules))
|
|
1013
|
+
if (attachment.sha256, attachment.filename, attachment.size) not in known_values
|
|
1014
|
+
]
|
|
1015
|
+
return tuple(
|
|
1016
|
+
RecordAttachmentRef(
|
|
1017
|
+
source=source,
|
|
1018
|
+
sha256=attachment.sha256,
|
|
1019
|
+
filename=attachment.filename,
|
|
1020
|
+
size=attachment.size,
|
|
1021
|
+
observed_version=version,
|
|
1022
|
+
location=location,
|
|
1023
|
+
)
|
|
1024
|
+
for attachment, location in located
|
|
1025
|
+
) + tuple(
|
|
1026
|
+
RecordAttachmentRef(
|
|
1027
|
+
source=source,
|
|
1028
|
+
sha256=attachment.sha256,
|
|
1029
|
+
filename=attachment.filename,
|
|
1030
|
+
size=attachment.size,
|
|
1031
|
+
observed_version=version,
|
|
1032
|
+
)
|
|
1033
|
+
for attachment in unlocated
|
|
1034
|
+
)
|
|
1035
|
+
|
|
1036
|
+
|
|
1037
|
+
class Records:
|
|
1038
|
+
"""Record-oriented Matelab operations."""
|
|
1039
|
+
|
|
1040
|
+
def __init__(self, transport: SessionTransport) -> None:
|
|
1041
|
+
self._transport: SessionTransport = transport
|
|
1042
|
+
|
|
1043
|
+
async def list(
|
|
1044
|
+
self,
|
|
1045
|
+
*,
|
|
1046
|
+
notebook: NotebookRef,
|
|
1047
|
+
date_start: str | None = None,
|
|
1048
|
+
date_end: str | None = None,
|
|
1049
|
+
keywords: Sequence[str] | None = None,
|
|
1050
|
+
content: str | None = None,
|
|
1051
|
+
) -> RecordCollection:
|
|
1052
|
+
if notebook.title is None:
|
|
1053
|
+
raise MatelabUsageError("Cannot list records for a notebook whose Provider title is null.")
|
|
1054
|
+
try:
|
|
1055
|
+
request = WireListRecordsRequest(
|
|
1056
|
+
eln=notebook.title,
|
|
1057
|
+
user=notebook.owner_userid if notebook.scope == "shared" else None,
|
|
1058
|
+
date_start=date_start if date_start is not None else "",
|
|
1059
|
+
date_end=date_end if date_end is not None else "",
|
|
1060
|
+
keywords=list(keywords) if keywords is not None else [],
|
|
1061
|
+
content=content if content is not None else "",
|
|
1062
|
+
)
|
|
1063
|
+
except ValidationError:
|
|
1064
|
+
raise MatelabUsageError("Record-list filters do not satisfy the Integration Contract.") from None
|
|
1065
|
+
response = await self._transport.request(
|
|
1066
|
+
_LIST_RECORDS, payload=request.model_dump(mode="json", exclude_defaults=True, exclude_none=True)
|
|
1067
|
+
)
|
|
1068
|
+
return RecordCollection(
|
|
1069
|
+
records=tuple(self._record_summary(item) for item in response.items),
|
|
1070
|
+
subtypes=tuple(self._subtype_summary(item) for item in response.subtypes),
|
|
1071
|
+
trash_count=response.trash_num,
|
|
1072
|
+
)
|
|
1073
|
+
|
|
1074
|
+
async def create_blank(
|
|
1075
|
+
self,
|
|
1076
|
+
*,
|
|
1077
|
+
notebook: NotebookRef,
|
|
1078
|
+
title: str,
|
|
1079
|
+
summary: str | None = None,
|
|
1080
|
+
record_uid: str | None = None,
|
|
1081
|
+
path: Sequence[str] | None = None,
|
|
1082
|
+
) -> BlankRecordCreateResult:
|
|
1083
|
+
notebook_title = self._notebook_title(notebook)
|
|
1084
|
+
requested_uid = record_uid or None
|
|
1085
|
+
try:
|
|
1086
|
+
request = WireCreateRecordRequest(
|
|
1087
|
+
eln=notebook_title,
|
|
1088
|
+
title=title,
|
|
1089
|
+
describe=summary,
|
|
1090
|
+
uid=requested_uid,
|
|
1091
|
+
user=notebook.owner_userid if notebook.scope == "shared" else None,
|
|
1092
|
+
path=list(path) if path is not None else None,
|
|
1093
|
+
)
|
|
1094
|
+
except ValidationError:
|
|
1095
|
+
raise MatelabUsageError("Blank-record input does not satisfy the Integration Contract.") from None
|
|
1096
|
+
_ = await self._transport.request(_CREATE_BLANK_RECORD, payload=request.model_dump(mode="json"))
|
|
1097
|
+
if requested_uid is None:
|
|
1098
|
+
return BlankRecordCreateResult(
|
|
1099
|
+
notebook=notebook,
|
|
1100
|
+
title=title,
|
|
1101
|
+
requested_record_uid=None,
|
|
1102
|
+
observed=None,
|
|
1103
|
+
confirmation="provider_generated_identity_not_returned",
|
|
1104
|
+
)
|
|
1105
|
+
after = await self._verified_list(notebook, "blank-record creation")
|
|
1106
|
+
observed = next((item for item in after.records if item.ref.record_uid == requested_uid), None)
|
|
1107
|
+
return BlankRecordCreateResult(
|
|
1108
|
+
notebook=notebook,
|
|
1109
|
+
title=title,
|
|
1110
|
+
requested_record_uid=requested_uid,
|
|
1111
|
+
observed=observed,
|
|
1112
|
+
confirmation="observed_by_requested_uid" if observed is not None else "not_observed_by_requested_uid",
|
|
1113
|
+
)
|
|
1114
|
+
|
|
1115
|
+
async def import_dataset(
|
|
1116
|
+
self, *, notebook: NotebookRef, template: RecordImportTemplate, items: Sequence[RecordImportItem]
|
|
1117
|
+
) -> RecordImportResult:
|
|
1118
|
+
notebook_title = self._notebook_title(notebook)
|
|
1119
|
+
selected = tuple(items)
|
|
1120
|
+
if template.template.template_id < 1 or not selected or len(selected) > 100:
|
|
1121
|
+
raise MatelabUsageError("Record import requires a valid template and one through one hundred items.")
|
|
1122
|
+
if len({item.record_uid for item in selected}) != len(selected):
|
|
1123
|
+
raise MatelabUsageError("Imported record UIDs must be distinct within the batch.")
|
|
1124
|
+
payload: dict[str, object] = {
|
|
1125
|
+
"eln": notebook_title,
|
|
1126
|
+
"template": template.title,
|
|
1127
|
+
"dataset": [
|
|
1128
|
+
{
|
|
1129
|
+
"uid": item.record_uid,
|
|
1130
|
+
"title": item.title,
|
|
1131
|
+
"data": dict(item.data),
|
|
1132
|
+
"keyword": item.keyword,
|
|
1133
|
+
"path": list(item.path) if item.path is not None else None,
|
|
1134
|
+
}
|
|
1135
|
+
for item in selected
|
|
1136
|
+
],
|
|
1137
|
+
"user": notebook.owner_userid if notebook.scope == "shared" else None,
|
|
1138
|
+
"template_user": template.owner.userid if template.owner is not None else None,
|
|
1139
|
+
}
|
|
1140
|
+
try:
|
|
1141
|
+
request = WireImportRecordsRequest.model_validate(payload)
|
|
1142
|
+
_ = json.dumps(request.model_dump(mode="json"), ensure_ascii=False)
|
|
1143
|
+
except (ValidationError, TypeError, ValueError):
|
|
1144
|
+
raise MatelabUsageError("Record import dataset does not satisfy the Integration Contract.") from None
|
|
1145
|
+
response = await self._transport.request(_IMPORT_RECORDS, payload=request.model_dump(mode="json"))
|
|
1146
|
+
if response.eln_id != notebook.notebook_id:
|
|
1147
|
+
raise MatelabProtocolError("Matelab returned a different target notebook for the record import.")
|
|
1148
|
+
return RecordImportResult(
|
|
1149
|
+
notebook=notebook,
|
|
1150
|
+
template=template,
|
|
1151
|
+
requested_record_uids=tuple(item.record_uid for item in selected),
|
|
1152
|
+
created_record_database_ids=tuple(item.root for item in response.id),
|
|
1153
|
+
provider_notebook_id=response.eln_id,
|
|
1154
|
+
identity_count_status=(
|
|
1155
|
+
"one_id_per_item"
|
|
1156
|
+
if len(response.id) == len(selected)
|
|
1157
|
+
else "fewer_ids_than_items"
|
|
1158
|
+
if len(response.id) < len(selected)
|
|
1159
|
+
else "more_ids_than_items"
|
|
1160
|
+
),
|
|
1161
|
+
)
|
|
1162
|
+
|
|
1163
|
+
async def copy(
|
|
1164
|
+
self,
|
|
1165
|
+
source: RecordLocator,
|
|
1166
|
+
*,
|
|
1167
|
+
title: str,
|
|
1168
|
+
target_notebook: NotebookRef | None = None,
|
|
1169
|
+
record_uid: str | None = None,
|
|
1170
|
+
) -> RecordCopyResult:
|
|
1171
|
+
target = target_notebook or source.notebook
|
|
1172
|
+
_ = self._notebook_title(target)
|
|
1173
|
+
try:
|
|
1174
|
+
request = WireCopyRecordRequest(
|
|
1175
|
+
eln=source.notebook.notebook_id,
|
|
1176
|
+
eln_to=0 if target.notebook_id == source.notebook.notebook_id else target.notebook_id,
|
|
1177
|
+
id=source.record.record_database_id,
|
|
1178
|
+
uid=record_uid,
|
|
1179
|
+
title=title,
|
|
1180
|
+
)
|
|
1181
|
+
except ValidationError:
|
|
1182
|
+
raise MatelabUsageError("Record copy input does not satisfy the Integration Contract.") from None
|
|
1183
|
+
response = await self._transport.request(_COPY_RECORD, payload=request.model_dump(mode="json"))
|
|
1184
|
+
after = await self._verified_list(target, "record copy")
|
|
1185
|
+
observed = next((item for item in after.records if item.ref.record_database_id == response.id), None)
|
|
1186
|
+
if observed is not None and record_uid and observed.ref.record_uid != record_uid:
|
|
1187
|
+
raise MatelabProtocolError("Matelab returned a different UID for the copied record.")
|
|
1188
|
+
return RecordCopyResult(
|
|
1189
|
+
source=source,
|
|
1190
|
+
target_notebook=target,
|
|
1191
|
+
record_database_id=response.id,
|
|
1192
|
+
record=observed.ref if observed is not None else None,
|
|
1193
|
+
observed=observed,
|
|
1194
|
+
forwarded=response.code is WireSuccessCode.int_10,
|
|
1195
|
+
confirmation="observed_by_returned_id" if observed is not None else "returned_id_not_observed",
|
|
1196
|
+
)
|
|
1197
|
+
|
|
1198
|
+
async def delete(self, *, notebook: NotebookRef, records: Sequence[RecordRef]) -> RecordDeleteResult:
|
|
1199
|
+
_ = self._notebook_title(notebook)
|
|
1200
|
+
selected = tuple(records)
|
|
1201
|
+
uids = tuple(record.record_uid for record in selected)
|
|
1202
|
+
if not selected or len(uids) != len(set(uids)):
|
|
1203
|
+
raise MatelabUsageError("Record deletion requires a non-empty set of distinct record UIDs.")
|
|
1204
|
+
try:
|
|
1205
|
+
request = WireDeleteRecordsRequest.model_validate(
|
|
1206
|
+
{
|
|
1207
|
+
"eln": notebook.title,
|
|
1208
|
+
"uids": list(uids),
|
|
1209
|
+
"user": notebook.owner_userid if notebook.scope == "shared" else None,
|
|
1210
|
+
}
|
|
1211
|
+
)
|
|
1212
|
+
except ValidationError:
|
|
1213
|
+
raise MatelabUsageError("Record deletion input does not satisfy the Integration Contract.") from None
|
|
1214
|
+
_ = await self._transport.request(_DELETE_RECORDS, payload=request.model_dump(mode="json"))
|
|
1215
|
+
active, deleted = await self._verified_lifecycle_state(notebook, "record deletion")
|
|
1216
|
+
active_uids = {item.ref.record_uid for item in active.records}
|
|
1217
|
+
deleted_by_identity = {(item.ref.record_database_id, item.ref.record_uid): item.ref for item in deleted.records}
|
|
1218
|
+
still_active = tuple(record for record in selected if record.record_uid in active_uids)
|
|
1219
|
+
no_longer_active = tuple(record for record in selected if record.record_uid not in active_uids)
|
|
1220
|
+
observed_deleted = tuple(
|
|
1221
|
+
deleted_by_identity[(record.record_database_id, record.record_uid)]
|
|
1222
|
+
for record in selected
|
|
1223
|
+
if (record.record_database_id, record.record_uid) in deleted_by_identity
|
|
1224
|
+
)
|
|
1225
|
+
unresolved = tuple(
|
|
1226
|
+
record
|
|
1227
|
+
for record in no_longer_active
|
|
1228
|
+
if (record.record_database_id, record.record_uid) not in deleted_by_identity
|
|
1229
|
+
)
|
|
1230
|
+
return RecordDeleteResult(
|
|
1231
|
+
notebook=notebook,
|
|
1232
|
+
requested=selected,
|
|
1233
|
+
no_longer_active=no_longer_active,
|
|
1234
|
+
still_active=still_active,
|
|
1235
|
+
observed_in_recycle_bin=observed_deleted,
|
|
1236
|
+
unresolved=unresolved,
|
|
1237
|
+
)
|
|
1238
|
+
|
|
1239
|
+
async def restore(self, *, notebook: NotebookRef, records: Sequence[DeletedRecordRef]) -> RecordRestoreResult:
|
|
1240
|
+
_ = self._notebook_title(notebook)
|
|
1241
|
+
selected = tuple(records)
|
|
1242
|
+
ids = tuple(record.record_database_id for record in selected)
|
|
1243
|
+
if not selected or any(record_id < 1 for record_id in ids) or len(ids) != len(set(ids)):
|
|
1244
|
+
raise MatelabUsageError("Record restoration requires distinct positive deleted-record identities.")
|
|
1245
|
+
try:
|
|
1246
|
+
request = WireRestoreRecordsRequest(eln=notebook.notebook_id, ids=json.dumps(ids, separators=(",", ":")))
|
|
1247
|
+
except ValidationError:
|
|
1248
|
+
raise MatelabUsageError("Record restore input does not satisfy the Integration Contract.") from None
|
|
1249
|
+
_ = await self._transport.request(_RESTORE_RECORDS, payload=request.model_dump(mode="json"))
|
|
1250
|
+
active, deleted = await self._verified_lifecycle_state(notebook, "record restoration")
|
|
1251
|
+
active_by_identity = {(item.ref.record_database_id, item.ref.record_uid): item.ref for item in active.records}
|
|
1252
|
+
deleted_identities = {(item.ref.record_database_id, item.ref.record_uid) for item in deleted.records}
|
|
1253
|
+
observed_active = tuple(
|
|
1254
|
+
active_by_identity[(item.record_database_id, item.record_uid)]
|
|
1255
|
+
for item in selected
|
|
1256
|
+
if (item.record_database_id, item.record_uid) in active_by_identity
|
|
1257
|
+
)
|
|
1258
|
+
still_deleted = tuple(
|
|
1259
|
+
item for item in selected if (item.record_database_id, item.record_uid) in deleted_identities
|
|
1260
|
+
)
|
|
1261
|
+
observed_identities = {(item.record_database_id, item.record_uid) for item in observed_active}
|
|
1262
|
+
unresolved = tuple(
|
|
1263
|
+
item
|
|
1264
|
+
for item in selected
|
|
1265
|
+
if (item.record_database_id, item.record_uid) not in observed_identities
|
|
1266
|
+
and (item.record_database_id, item.record_uid) not in deleted_identities
|
|
1267
|
+
)
|
|
1268
|
+
return RecordRestoreResult(
|
|
1269
|
+
notebook=notebook,
|
|
1270
|
+
requested=selected,
|
|
1271
|
+
observed_active=observed_active,
|
|
1272
|
+
still_deleted=still_deleted,
|
|
1273
|
+
unresolved=unresolved,
|
|
1274
|
+
)
|
|
1275
|
+
|
|
1276
|
+
async def upload_attachment(
|
|
1277
|
+
self,
|
|
1278
|
+
source: RecordLocator,
|
|
1279
|
+
*,
|
|
1280
|
+
filename: str,
|
|
1281
|
+
content: IO[bytes] | bytes,
|
|
1282
|
+
size: int,
|
|
1283
|
+
sha256: str,
|
|
1284
|
+
binding: UploadBindingRef | None = None,
|
|
1285
|
+
content_type: str = "application/octet-stream",
|
|
1286
|
+
) -> StagedRecordAttachment:
|
|
1287
|
+
notebook_title = self._notebook_title(source.notebook)
|
|
1288
|
+
selected_binding = binding or UploadBindingRef.new()
|
|
1289
|
+
self._validate_upload_metadata(
|
|
1290
|
+
filename=filename, content=content, size=size, sha256=sha256, content_type=content_type
|
|
1291
|
+
)
|
|
1292
|
+
try:
|
|
1293
|
+
request = WireUploadAttachmentRequest.model_validate(
|
|
1294
|
+
{
|
|
1295
|
+
"eln": notebook_title,
|
|
1296
|
+
"user": source.notebook.owner_userid if source.notebook.scope == "shared" else None,
|
|
1297
|
+
"uid": source.record.record_uid,
|
|
1298
|
+
"name": selected_binding.uid,
|
|
1299
|
+
"hash": sha256,
|
|
1300
|
+
"last": 1,
|
|
1301
|
+
"file": b"validation-placeholder",
|
|
1302
|
+
}
|
|
1303
|
+
)
|
|
1304
|
+
except ValidationError:
|
|
1305
|
+
raise MatelabUsageError("Record attachment upload does not satisfy the Integration Contract.") from None
|
|
1306
|
+
response = await self._transport.request(
|
|
1307
|
+
_UPLOAD_RECORD_ATTACHMENT,
|
|
1308
|
+
payload=request.model_dump(mode="json", exclude={"file"}, exclude_none=True),
|
|
1309
|
+
files={"file": (filename, content, content_type)},
|
|
1310
|
+
)
|
|
1311
|
+
if (
|
|
1312
|
+
response.hash.root != sha256
|
|
1313
|
+
or response.size != size
|
|
1314
|
+
or response.filename != filename
|
|
1315
|
+
or response.name != selected_binding.uid
|
|
1316
|
+
):
|
|
1317
|
+
raise MatelabProtocolError(
|
|
1318
|
+
"Matelab staged the record attachment but returned different upload metadata; "
|
|
1319
|
+
+ "the temporary upload may remain."
|
|
1320
|
+
)
|
|
1321
|
+
return StagedRecordAttachment(
|
|
1322
|
+
source=source,
|
|
1323
|
+
temporary_file_id=response.id,
|
|
1324
|
+
binding=selected_binding,
|
|
1325
|
+
filename=response.filename,
|
|
1326
|
+
size=response.size,
|
|
1327
|
+
sha256=response.hash.root,
|
|
1328
|
+
)
|
|
1329
|
+
|
|
1330
|
+
async def update(
|
|
1331
|
+
self,
|
|
1332
|
+
source: RecordLocator,
|
|
1333
|
+
patch: RecordPatch,
|
|
1334
|
+
*,
|
|
1335
|
+
expected_content_sha256: str | None = None,
|
|
1336
|
+
password: str | None = None,
|
|
1337
|
+
) -> RecordUpdateResult:
|
|
1338
|
+
before = await self.read(notebook=source.notebook, record=source.record)
|
|
1339
|
+
if expected_content_sha256 is not None:
|
|
1340
|
+
if re.fullmatch(r"[0-9a-f]{64}", expected_content_sha256) is None:
|
|
1341
|
+
raise MatelabUsageError("Expected record content fingerprint must be a lowercase SHA-256.")
|
|
1342
|
+
if before.content_sha256 != expected_content_sha256:
|
|
1343
|
+
raise MatelabUsageError(
|
|
1344
|
+
"Record content changed before the update; this client-side precondition is not Provider CAS."
|
|
1345
|
+
)
|
|
1346
|
+
mutation_payload, operation_count = self._record_patch_payload(source, before, patch)
|
|
1347
|
+
try:
|
|
1348
|
+
request = WireUpdateRecordRequest.model_validate(
|
|
1349
|
+
{
|
|
1350
|
+
"eln": self._notebook_title(source.notebook),
|
|
1351
|
+
"uid": source.record.record_uid,
|
|
1352
|
+
"user": source.notebook.owner_userid if source.notebook.scope == "shared" else None,
|
|
1353
|
+
"password": password,
|
|
1354
|
+
**mutation_payload,
|
|
1355
|
+
}
|
|
1356
|
+
)
|
|
1357
|
+
_ = json.dumps(request.model_dump(mode="json", by_alias=True), ensure_ascii=False)
|
|
1358
|
+
except (ValidationError, TypeError, ValueError):
|
|
1359
|
+
raise MatelabUsageError("Record patch does not satisfy the Integration Contract.") from None
|
|
1360
|
+
response = await self._transport.request(
|
|
1361
|
+
_UPDATE_RECORD, payload=request.model_dump(mode="json", by_alias=True, exclude_none=True)
|
|
1362
|
+
)
|
|
1363
|
+
message = response.msg.strip()
|
|
1364
|
+
if message in _COLLABORATION_UPDATE_MESSAGES:
|
|
1365
|
+
return RecordUpdateResult(
|
|
1366
|
+
source=source,
|
|
1367
|
+
before=before,
|
|
1368
|
+
observed_after=None,
|
|
1369
|
+
persistence="pending_browser_save",
|
|
1370
|
+
confirmation="pending_browser_save_not_read_back",
|
|
1371
|
+
requested_operation_count=operation_count,
|
|
1372
|
+
provider_message=response.msg,
|
|
1373
|
+
)
|
|
1374
|
+
after = await self._verified_read(source, "record update")
|
|
1375
|
+
return RecordUpdateResult(
|
|
1376
|
+
source=source,
|
|
1377
|
+
before=before,
|
|
1378
|
+
observed_after=after,
|
|
1379
|
+
persistence="persisted" if message in _DATABASE_UPDATE_MESSAGES else "provider_acknowledged_unclassified",
|
|
1380
|
+
confirmation="observed_changed" if after.content_sha256 != before.content_sha256 else "observed_unchanged",
|
|
1381
|
+
requested_operation_count=operation_count,
|
|
1382
|
+
provider_message=response.msg,
|
|
1383
|
+
)
|
|
1384
|
+
|
|
1385
|
+
async def export(self, records: Sequence[RecordLocator]) -> RecordExport:
|
|
1386
|
+
try:
|
|
1387
|
+
request = WireExportRecordsRequest.model_validate(
|
|
1388
|
+
{
|
|
1389
|
+
"uids": [
|
|
1390
|
+
{"uid": record.record.record_uid, "eln": self._notebook_selector(record.notebook)}
|
|
1391
|
+
for record in records
|
|
1392
|
+
]
|
|
1393
|
+
}
|
|
1394
|
+
)
|
|
1395
|
+
except ValidationError:
|
|
1396
|
+
raise MatelabUsageError("Record export selection does not satisfy the Integration Contract.") from None
|
|
1397
|
+
response = await self._transport.request(_EXPORT_RECORDS, payload=request.model_dump(mode="json"))
|
|
1398
|
+
return RecordExport(
|
|
1399
|
+
records=tuple(
|
|
1400
|
+
ExportedRecord(
|
|
1401
|
+
ref=RecordRef(record_database_id=item.id, record_uid=item.uid),
|
|
1402
|
+
notebook_title=item.eln_name,
|
|
1403
|
+
title=item.title,
|
|
1404
|
+
summary=item.comm,
|
|
1405
|
+
data=tuple(cast(list[object], item.data)),
|
|
1406
|
+
)
|
|
1407
|
+
for item in response.dataset
|
|
1408
|
+
),
|
|
1409
|
+
forwarded=response.code is WireSuccessCode.int_10,
|
|
1410
|
+
)
|
|
1411
|
+
|
|
1412
|
+
async def search(
|
|
1413
|
+
self,
|
|
1414
|
+
*,
|
|
1415
|
+
extractions: Sequence[RecordFieldExtraction],
|
|
1416
|
+
notebooks: Sequence[NotebookRef] | None = None,
|
|
1417
|
+
date_start: str | None = None,
|
|
1418
|
+
date_end: str | None = None,
|
|
1419
|
+
keywords: Sequence[str] | None = None,
|
|
1420
|
+
) -> RecordSearchResult:
|
|
1421
|
+
aliases = tuple(extraction.alias for extraction in extractions)
|
|
1422
|
+
if len(aliases) != len(set(aliases)) or any(alias in _SEARCH_RESERVED_FIELDS for alias in aliases):
|
|
1423
|
+
raise MatelabUsageError("Record extraction aliases must be unique and cannot replace identity fields.")
|
|
1424
|
+
try:
|
|
1425
|
+
request = WireSearchRecordsRequest.model_validate(
|
|
1426
|
+
{
|
|
1427
|
+
"eln": [self._notebook_selector(notebook) for notebook in notebooks or ()],
|
|
1428
|
+
"requests": [
|
|
1429
|
+
{"uid": extraction.alias, "path": list(extraction.path)} for extraction in extractions
|
|
1430
|
+
],
|
|
1431
|
+
"date_start": date_start,
|
|
1432
|
+
"date_end": date_end,
|
|
1433
|
+
"keywords": list(keywords) if keywords is not None else None,
|
|
1434
|
+
}
|
|
1435
|
+
)
|
|
1436
|
+
except ValidationError:
|
|
1437
|
+
raise MatelabUsageError("Record search input does not satisfy the Integration Contract.") from None
|
|
1438
|
+
response = await self._transport.request(
|
|
1439
|
+
_SEARCH_RECORDS, payload=request.model_dump(mode="json", exclude_defaults=True, exclude_none=True)
|
|
1440
|
+
)
|
|
1441
|
+
matches: list[RecordSearchMatch] = []
|
|
1442
|
+
for item in response.data:
|
|
1443
|
+
extra = cast(dict[str, object], item.model_extra or {})
|
|
1444
|
+
extracted = {alias: extra[alias] for alias in aliases if alias in extra}
|
|
1445
|
+
matches.append(
|
|
1446
|
+
RecordSearchMatch(
|
|
1447
|
+
ref=RecordRef(record_database_id=item.id, record_uid=item.uid),
|
|
1448
|
+
notebook_id=item.eln_id,
|
|
1449
|
+
notebook_title=item.eln_name,
|
|
1450
|
+
title=item.title,
|
|
1451
|
+
extracted=MappingProxyType(extracted),
|
|
1452
|
+
missing_extractions=tuple(alias for alias in aliases if alias not in extra),
|
|
1453
|
+
)
|
|
1454
|
+
)
|
|
1455
|
+
return RecordSearchResult(matches=tuple(matches), forwarded=response.code is WireSuccessCode.int_10)
|
|
1456
|
+
|
|
1457
|
+
async def list_public(self, notebook: PublicNotebookRef) -> PublicRecordCollection:
|
|
1458
|
+
try:
|
|
1459
|
+
request = WirePublicRecordListRequest(eln=notebook.title)
|
|
1460
|
+
except ValidationError:
|
|
1461
|
+
raise MatelabUsageError("Public notebook selector does not satisfy the Integration Contract.") from None
|
|
1462
|
+
response = await self._transport.request(_LIST_PUBLIC_RECORDS, payload=request.model_dump(mode="json"))
|
|
1463
|
+
return PublicRecordCollection(
|
|
1464
|
+
notebook=notebook,
|
|
1465
|
+
records=tuple(
|
|
1466
|
+
PublicRecordSummary(
|
|
1467
|
+
ref=PublicRecordRef(
|
|
1468
|
+
public_entry_id=item.id,
|
|
1469
|
+
source_notebook_id=item.eln_id,
|
|
1470
|
+
source_record_database_id=item.item_id,
|
|
1471
|
+
record_uid=item.uid,
|
|
1472
|
+
),
|
|
1473
|
+
title=item.title,
|
|
1474
|
+
summary=item.comm,
|
|
1475
|
+
locked=item.locked,
|
|
1476
|
+
source_notebook_title=item.eln_name,
|
|
1477
|
+
owner=UserRef(userid=item.userid) if item.userid is not None and item.userid > 0 else None,
|
|
1478
|
+
owner_name=item.username,
|
|
1479
|
+
owner_email=item.email,
|
|
1480
|
+
owned_by_caller=item.owner,
|
|
1481
|
+
)
|
|
1482
|
+
for item in response.items
|
|
1483
|
+
),
|
|
1484
|
+
)
|
|
1485
|
+
|
|
1486
|
+
async def page(
|
|
1487
|
+
self,
|
|
1488
|
+
notebook: NotebookRef,
|
|
1489
|
+
*,
|
|
1490
|
+
page: int = 1,
|
|
1491
|
+
subtype_id: int = -1,
|
|
1492
|
+
date_start: str = "",
|
|
1493
|
+
date_end: str = "",
|
|
1494
|
+
content: str = "",
|
|
1495
|
+
keywords: Sequence[str] = (),
|
|
1496
|
+
) -> RecordPage:
|
|
1497
|
+
encoded_keywords = json.dumps(list(keywords), ensure_ascii=False, separators=(",", ":")) if keywords else ""
|
|
1498
|
+
try:
|
|
1499
|
+
request = WireRecordPageQuery(
|
|
1500
|
+
eln=notebook.notebook_id,
|
|
1501
|
+
page=page,
|
|
1502
|
+
page_size=0,
|
|
1503
|
+
subtype=subtype_id,
|
|
1504
|
+
default=WirePageDefault.integer_1,
|
|
1505
|
+
order_desc=WireOrderDesc.integer_1,
|
|
1506
|
+
ds=date_start,
|
|
1507
|
+
de=date_end,
|
|
1508
|
+
content=content,
|
|
1509
|
+
keywords=encoded_keywords,
|
|
1510
|
+
)
|
|
1511
|
+
except ValidationError:
|
|
1512
|
+
raise MatelabUsageError("Record-page filters do not satisfy the Integration Contract.") from None
|
|
1513
|
+
response = await self._transport.request(_LIST_RECORD_PAGE, payload=request.model_dump(mode="json"))
|
|
1514
|
+
if response.eln.id != notebook.notebook_id:
|
|
1515
|
+
raise MatelabProtocolError("Matelab returned a different notebook for the record page.")
|
|
1516
|
+
matching_ids = tuple(item.root for item in response.ids)
|
|
1517
|
+
effective_page_size = response.eln.page_size
|
|
1518
|
+
return RecordPage(
|
|
1519
|
+
records=tuple(self._record_summary(item) for item in response.items),
|
|
1520
|
+
subtypes=tuple(self._subtype_summary(item) for item in response.subtypes),
|
|
1521
|
+
context=RecordPageContext(
|
|
1522
|
+
notebook=notebook,
|
|
1523
|
+
notebook_title=response.eln.eln_text,
|
|
1524
|
+
owner_name=response.eln.username,
|
|
1525
|
+
template_id=response.eln.template_id,
|
|
1526
|
+
data_server=response.eln.server,
|
|
1527
|
+
permissions=RecordPagePermissions(
|
|
1528
|
+
editable=response.eln.editable,
|
|
1529
|
+
can_create=response.eln.create,
|
|
1530
|
+
can_delete=response.eln.delete,
|
|
1531
|
+
can_manage_subtypes=response.eln.subtype,
|
|
1532
|
+
can_read_audit=response.eln.readlog,
|
|
1533
|
+
can_manage_users=response.eln.user,
|
|
1534
|
+
can_sign=response.eln.sign,
|
|
1535
|
+
),
|
|
1536
|
+
effective_page_size=effective_page_size,
|
|
1537
|
+
),
|
|
1538
|
+
page=response.page,
|
|
1539
|
+
total_count=len(matching_ids),
|
|
1540
|
+
matching_record_database_ids=matching_ids,
|
|
1541
|
+
trash_count=response.trash_num,
|
|
1542
|
+
has_more=response.page * effective_page_size < len(matching_ids),
|
|
1543
|
+
keywords=tuple(cast(list[object], response.keywords)),
|
|
1544
|
+
)
|
|
1545
|
+
|
|
1546
|
+
async def recycle_bin(self, notebook: NotebookRef, *, page: int = 1, page_size: int = 24) -> DeletedRecordPage:
|
|
1547
|
+
if not 10 <= page_size <= 256:
|
|
1548
|
+
raise MatelabUsageError("Recycle-bin page size must be between 10 and 256.")
|
|
1549
|
+
try:
|
|
1550
|
+
request = WireDeletedRecordPageQuery(eln=notebook.notebook_id, page=page, page_size=page_size)
|
|
1551
|
+
except ValidationError:
|
|
1552
|
+
raise MatelabUsageError("Recycle-bin page input does not satisfy the Integration Contract.") from None
|
|
1553
|
+
response = await self._transport.request(_LIST_DELETED_RECORDS, payload=request.model_dump(mode="json"))
|
|
1554
|
+
if response.eln.id != notebook.notebook_id:
|
|
1555
|
+
raise MatelabProtocolError("Matelab returned a different notebook for the recycle bin.")
|
|
1556
|
+
return DeletedRecordPage(
|
|
1557
|
+
notebook=notebook,
|
|
1558
|
+
records=tuple(
|
|
1559
|
+
DeletedRecordSummary(
|
|
1560
|
+
ref=DeletedRecordRef(record_database_id=item.id, record_uid=item.sn),
|
|
1561
|
+
title=item.title,
|
|
1562
|
+
summary=item.comm,
|
|
1563
|
+
created_at=item.datetime_create,
|
|
1564
|
+
modified_at=item.datetime_modify,
|
|
1565
|
+
deleted_at=item.deleted_dtime,
|
|
1566
|
+
)
|
|
1567
|
+
for item in response.items
|
|
1568
|
+
),
|
|
1569
|
+
page=response.page,
|
|
1570
|
+
requested_page_size=request.page_size,
|
|
1571
|
+
total_count=response.count,
|
|
1572
|
+
has_more=response.page * request.page_size < response.count,
|
|
1573
|
+
forwarded=response.code is WireSuccessCode.int_10,
|
|
1574
|
+
)
|
|
1575
|
+
|
|
1576
|
+
async def relations(self, *, notebook: NotebookRef, record: RecordRef) -> RecordRelations:
|
|
1577
|
+
try:
|
|
1578
|
+
request = WireRecordRelationsQuery(eln=notebook.notebook_id, item_id=record.record_database_id)
|
|
1579
|
+
except ValidationError:
|
|
1580
|
+
raise MatelabUsageError("Record relation source does not satisfy the Integration Contract.") from None
|
|
1581
|
+
response = await self._transport.request(_LIST_RELATIONS, payload=request.model_dump(mode="json"))
|
|
1582
|
+
source = RecordLocator(notebook=notebook, record=record)
|
|
1583
|
+
return RecordRelations(
|
|
1584
|
+
relations=tuple(
|
|
1585
|
+
RecordRelation(
|
|
1586
|
+
ref=RecordRelationRef(relation_id=item.id, source=source),
|
|
1587
|
+
target=RelatedRecordIdentity(
|
|
1588
|
+
declared_notebook_id=item.related_eln,
|
|
1589
|
+
resolved_notebook_id=item.eln_id,
|
|
1590
|
+
record_database_id=item.related_id,
|
|
1591
|
+
record_uid=item.uid,
|
|
1592
|
+
notebook_title=item.eln,
|
|
1593
|
+
),
|
|
1594
|
+
title=item.title,
|
|
1595
|
+
summary=item.comm,
|
|
1596
|
+
)
|
|
1597
|
+
for item in response.items
|
|
1598
|
+
),
|
|
1599
|
+
legacy_comments=tuple(self._comment(item, source=source) for item in response.more),
|
|
1600
|
+
editable=response.editable,
|
|
1601
|
+
)
|
|
1602
|
+
|
|
1603
|
+
async def add_relation(self, *, source: RecordLocator, target: RecordLocator) -> RecordRelationAddResult:
|
|
1604
|
+
source_record = await self.read(notebook=source.notebook, record=source.record)
|
|
1605
|
+
source_relations = await self.relations(notebook=source.notebook, record=source.record)
|
|
1606
|
+
if not source_relations.editable:
|
|
1607
|
+
raise MatelabUsageError("The selected source record is not currently editable.")
|
|
1608
|
+
target_record = (
|
|
1609
|
+
source_record if target == source else await self.read(notebook=target.notebook, record=target.record)
|
|
1610
|
+
)
|
|
1611
|
+
if source_record.data_server != target_record.data_server:
|
|
1612
|
+
raise MatelabUsageError(
|
|
1613
|
+
"Record relation endpoints resolve to different data servers; Provider validation is insufficient."
|
|
1614
|
+
)
|
|
1615
|
+
try:
|
|
1616
|
+
request = WireAddRelationRequest(
|
|
1617
|
+
eln_id=source.notebook.notebook_id,
|
|
1618
|
+
item_id=source.record.record_database_id,
|
|
1619
|
+
related_eln=target.notebook.notebook_id,
|
|
1620
|
+
related_id=target.record.record_database_id,
|
|
1621
|
+
)
|
|
1622
|
+
except ValidationError:
|
|
1623
|
+
raise MatelabUsageError("Record relation endpoints do not satisfy the Integration Contract.") from None
|
|
1624
|
+
response = await self._transport.request(_ADD_RELATION, payload=request.model_dump(mode="json"))
|
|
1625
|
+
after = await self._verified_relations(source, "record relation addition")
|
|
1626
|
+
observed = tuple(
|
|
1627
|
+
relation
|
|
1628
|
+
for relation in after.relations
|
|
1629
|
+
if relation.target.declared_notebook_id == target.notebook.notebook_id
|
|
1630
|
+
and relation.target.resolved_notebook_id == target.notebook.notebook_id
|
|
1631
|
+
and relation.target.record_database_id == target.record.record_database_id
|
|
1632
|
+
and relation.target.record_uid == target.record.record_uid
|
|
1633
|
+
)
|
|
1634
|
+
return RecordRelationAddResult(
|
|
1635
|
+
source=source,
|
|
1636
|
+
target=target,
|
|
1637
|
+
observed=observed,
|
|
1638
|
+
confirmation="observed_after_add" if observed else "not_observed_after_add",
|
|
1639
|
+
forwarded=response.code is WireSuccessCode.int_10,
|
|
1640
|
+
)
|
|
1641
|
+
|
|
1642
|
+
async def delete_relation(self, relation: RecordRelation) -> RecordRelationDeleteResult:
|
|
1643
|
+
source = relation.ref.source
|
|
1644
|
+
before = await self.relations(notebook=source.notebook, record=source.record)
|
|
1645
|
+
if not before.editable:
|
|
1646
|
+
raise MatelabUsageError("The selected source record is not currently editable.")
|
|
1647
|
+
current = next((item for item in before.relations if item.ref == relation.ref), None)
|
|
1648
|
+
if current is None or current.target != relation.target:
|
|
1649
|
+
raise MatelabUsageError("The selected relation is no longer present with the observed target identity.")
|
|
1650
|
+
colliding_targets = tuple(
|
|
1651
|
+
item
|
|
1652
|
+
for item in before.relations
|
|
1653
|
+
if item.ref != relation.ref
|
|
1654
|
+
and item.target.record_database_id == relation.target.record_database_id
|
|
1655
|
+
and item.target.declared_notebook_id != relation.target.declared_notebook_id
|
|
1656
|
+
)
|
|
1657
|
+
if colliding_targets:
|
|
1658
|
+
raise MatelabUsageError(
|
|
1659
|
+
"Relation deletion is unsafe because the Provider ignores target notebook identity (PVD-020)."
|
|
1660
|
+
)
|
|
1661
|
+
try:
|
|
1662
|
+
request = WireDeleteRelationRequest(
|
|
1663
|
+
eln=source.notebook.notebook_id,
|
|
1664
|
+
item_id=source.record.record_database_id,
|
|
1665
|
+
related_id=relation.target.record_database_id,
|
|
1666
|
+
)
|
|
1667
|
+
except ValidationError:
|
|
1668
|
+
raise MatelabUsageError("Record relation identity does not satisfy the Integration Contract.") from None
|
|
1669
|
+
response = await self._transport.request(_DELETE_RELATION, payload=request.model_dump(mode="json"))
|
|
1670
|
+
after = await self._verified_relations(source, "record relation deletion")
|
|
1671
|
+
remaining_refs = {item.ref for item in after.relations}
|
|
1672
|
+
other_missing = tuple(
|
|
1673
|
+
item.ref for item in before.relations if item.ref != relation.ref and item.ref not in remaining_refs
|
|
1674
|
+
)
|
|
1675
|
+
return RecordRelationDeleteResult(
|
|
1676
|
+
requested=relation,
|
|
1677
|
+
confirmation=(
|
|
1678
|
+
"still_observed_after_delete" if relation.ref in remaining_refs else "not_observed_after_delete"
|
|
1679
|
+
),
|
|
1680
|
+
other_relations_missing_after_write=other_missing,
|
|
1681
|
+
forwarded=response.code is WireSuccessCode.int_10,
|
|
1682
|
+
)
|
|
1683
|
+
|
|
1684
|
+
async def comments(self, *, notebook: NotebookRef, record: RecordRef) -> RecordComments:
|
|
1685
|
+
try:
|
|
1686
|
+
request = WireRecordCommentsQuery(eln=notebook.notebook_id, item_id=record.record_database_id)
|
|
1687
|
+
except ValidationError:
|
|
1688
|
+
raise MatelabUsageError("Record comment source does not satisfy the Integration Contract.") from None
|
|
1689
|
+
response = await self._transport.request(_LIST_COMMENTS, payload=request.model_dump(mode="json"))
|
|
1690
|
+
source = RecordLocator(notebook=notebook, record=record)
|
|
1691
|
+
return RecordComments(
|
|
1692
|
+
source=source, comments=tuple(self._comment(item, source=source) for item in response.mores)
|
|
1693
|
+
)
|
|
1694
|
+
|
|
1695
|
+
async def upload_comment_attachment(
|
|
1696
|
+
self,
|
|
1697
|
+
source: RecordLocator,
|
|
1698
|
+
*,
|
|
1699
|
+
filename: str,
|
|
1700
|
+
content: IO[bytes] | bytes,
|
|
1701
|
+
size: int,
|
|
1702
|
+
sha256: str,
|
|
1703
|
+
binding: UploadBindingRef | None = None,
|
|
1704
|
+
content_type: str = "application/octet-stream",
|
|
1705
|
+
) -> StagedRecordCommentAttachment:
|
|
1706
|
+
selected_binding = binding or UploadBindingRef.new()
|
|
1707
|
+
self._validate_upload_metadata(
|
|
1708
|
+
filename=filename, content=content, size=size, sha256=sha256, content_type=content_type
|
|
1709
|
+
)
|
|
1710
|
+
try:
|
|
1711
|
+
request = WireUploadRecordCommentAttachmentRequest.model_validate(
|
|
1712
|
+
{
|
|
1713
|
+
"uid": selected_binding.uid,
|
|
1714
|
+
"eln": source.notebook.notebook_id,
|
|
1715
|
+
"i": source.record.record_database_id,
|
|
1716
|
+
"upload": b"validation-placeholder",
|
|
1717
|
+
}
|
|
1718
|
+
)
|
|
1719
|
+
except ValidationError:
|
|
1720
|
+
raise MatelabUsageError(
|
|
1721
|
+
"Record-comment attachment upload does not satisfy the Integration Contract."
|
|
1722
|
+
) from None
|
|
1723
|
+
response = await self._transport.request(
|
|
1724
|
+
_UPLOAD_COMMENT_ATTACHMENT,
|
|
1725
|
+
payload=request.model_dump(mode="json", exclude={"upload"}),
|
|
1726
|
+
files={"upload": (filename, content, content_type)},
|
|
1727
|
+
)
|
|
1728
|
+
returned_notebook_id, temporary_id, returned_sha256, returned_size, returned_filename, canonical_reference = (
|
|
1729
|
+
self._parse_comment_upload_query(response.query)
|
|
1730
|
+
)
|
|
1731
|
+
if (
|
|
1732
|
+
returned_notebook_id != source.notebook.notebook_id
|
|
1733
|
+
or response.filename != filename
|
|
1734
|
+
or returned_filename != filename
|
|
1735
|
+
or returned_sha256 != sha256
|
|
1736
|
+
or returned_size != size
|
|
1737
|
+
):
|
|
1738
|
+
raise MatelabProtocolError(
|
|
1739
|
+
"Matelab staged the comment attachment but returned different upload metadata; "
|
|
1740
|
+
+ "the temporary upload cannot be aborted through this Contract."
|
|
1741
|
+
)
|
|
1742
|
+
return StagedRecordCommentAttachment(
|
|
1743
|
+
source=source,
|
|
1744
|
+
binding=selected_binding,
|
|
1745
|
+
temporary_file_id=temporary_id,
|
|
1746
|
+
filename=filename,
|
|
1747
|
+
size=size,
|
|
1748
|
+
sha256=sha256,
|
|
1749
|
+
_temporary_url=self._transport.absolute_url(response.path, response.query),
|
|
1750
|
+
_canonical_reference=canonical_reference,
|
|
1751
|
+
)
|
|
1752
|
+
|
|
1753
|
+
async def create_comment(self, source: RecordLocator, draft: RecordCommentDraft) -> RecordCommentSaveResult:
|
|
1754
|
+
before = await self.comments(notebook=source.notebook, record=source.record)
|
|
1755
|
+
wire_html, canonical_html, binding_uid = self._render_comment_draft(source, draft)
|
|
1756
|
+
response = await self._save_comment_request(
|
|
1757
|
+
source=source, comment_id=0, html=wire_html, binding_uid=binding_uid
|
|
1758
|
+
)
|
|
1759
|
+
after = await self._verified_comments(source, "record comment creation")
|
|
1760
|
+
previous_ids = {comment.ref.comment_id for comment in before.comments}
|
|
1761
|
+
observed = tuple(
|
|
1762
|
+
comment
|
|
1763
|
+
for comment in after.comments
|
|
1764
|
+
if comment.ref.comment_id not in previous_ids and comment.owned_by_caller and comment.body == canonical_html
|
|
1765
|
+
)
|
|
1766
|
+
if len(observed) == 1:
|
|
1767
|
+
confirmation: Literal[
|
|
1768
|
+
"unique_created_comment_observed", "created_comment_identity_ambiguous", "created_comment_not_observed"
|
|
1769
|
+
] = "unique_created_comment_observed"
|
|
1770
|
+
elif observed:
|
|
1771
|
+
confirmation = "created_comment_identity_ambiguous"
|
|
1772
|
+
else:
|
|
1773
|
+
confirmation = "created_comment_not_observed"
|
|
1774
|
+
return RecordCommentSaveResult(
|
|
1775
|
+
source=source,
|
|
1776
|
+
action="create",
|
|
1777
|
+
requested_comment=None,
|
|
1778
|
+
observed=observed,
|
|
1779
|
+
confirmation=confirmation,
|
|
1780
|
+
identity_source="readback" if len(observed) == 1 else "unknown",
|
|
1781
|
+
forwarded=response.code is WireSuccessCode.int_10,
|
|
1782
|
+
)
|
|
1783
|
+
|
|
1784
|
+
async def update_comment(self, comment: RecordComment, draft: RecordCommentDraft) -> RecordCommentSaveResult:
|
|
1785
|
+
source = comment.source
|
|
1786
|
+
before = await self.comments(notebook=source.notebook, record=source.record)
|
|
1787
|
+
current = next((item for item in before.comments if item.ref == comment.ref), None)
|
|
1788
|
+
if current is None or not current.owned_by_caller:
|
|
1789
|
+
raise MatelabUsageError("Only a currently observed caller-owned comment can be updated.")
|
|
1790
|
+
wire_html, canonical_html, binding_uid = self._render_comment_draft(source, draft)
|
|
1791
|
+
response = await self._save_comment_request(
|
|
1792
|
+
source=source, comment_id=comment.ref.comment_id, html=wire_html, binding_uid=binding_uid
|
|
1793
|
+
)
|
|
1794
|
+
after = await self._verified_comments(source, "record comment update")
|
|
1795
|
+
observed = tuple(item for item in after.comments if item.ref == comment.ref)
|
|
1796
|
+
confirmation: Literal[
|
|
1797
|
+
"updated_comment_observed_matching", "updated_comment_observed_mismatched", "updated_comment_not_observed"
|
|
1798
|
+
]
|
|
1799
|
+
if not observed:
|
|
1800
|
+
confirmation = "updated_comment_not_observed"
|
|
1801
|
+
elif observed[0].body == canonical_html:
|
|
1802
|
+
confirmation = "updated_comment_observed_matching"
|
|
1803
|
+
else:
|
|
1804
|
+
confirmation = "updated_comment_observed_mismatched"
|
|
1805
|
+
return RecordCommentSaveResult(
|
|
1806
|
+
source=source,
|
|
1807
|
+
action="update",
|
|
1808
|
+
requested_comment=comment.ref,
|
|
1809
|
+
observed=observed,
|
|
1810
|
+
confirmation=confirmation,
|
|
1811
|
+
identity_source="readback" if observed else "unknown",
|
|
1812
|
+
forwarded=response.code is WireSuccessCode.int_10,
|
|
1813
|
+
)
|
|
1814
|
+
|
|
1815
|
+
async def delete_comment(self, comment: RecordComment) -> RecordCommentDeleteResult:
|
|
1816
|
+
source = comment.source
|
|
1817
|
+
before = await self.comments(notebook=source.notebook, record=source.record)
|
|
1818
|
+
current = next((item for item in before.comments if item.ref == comment.ref), None)
|
|
1819
|
+
if current is None or not current.owned_by_caller:
|
|
1820
|
+
raise MatelabUsageError("Only a currently observed caller-owned comment can be deleted.")
|
|
1821
|
+
try:
|
|
1822
|
+
request = WireDeleteRecordCommentRequest(
|
|
1823
|
+
eln=source.notebook.notebook_id, item_id=source.record.record_database_id, id=comment.ref.comment_id
|
|
1824
|
+
)
|
|
1825
|
+
except ValidationError:
|
|
1826
|
+
raise MatelabUsageError("Record comment identity does not satisfy the Integration Contract.") from None
|
|
1827
|
+
response = await self._transport.request(
|
|
1828
|
+
_DELETE_COMMENT, files=multipart_fields(request.model_dump(mode="json"))
|
|
1829
|
+
)
|
|
1830
|
+
after = await self._verified_comments(source, "record comment deletion")
|
|
1831
|
+
still_observed = any(item.ref == comment.ref for item in after.comments)
|
|
1832
|
+
return RecordCommentDeleteResult(
|
|
1833
|
+
comment=comment.ref,
|
|
1834
|
+
source=source,
|
|
1835
|
+
confirmation="still_observed_after_delete" if still_observed else "not_observed_after_delete",
|
|
1836
|
+
forwarded=response.code is WireSuccessCode.int_10,
|
|
1837
|
+
)
|
|
1838
|
+
|
|
1839
|
+
async def download_attachment(
|
|
1840
|
+
self, attachment: RecordAttachmentRef, *, byte_range: ByteRange | None = None
|
|
1841
|
+
) -> DownloadStream:
|
|
1842
|
+
try:
|
|
1843
|
+
request = WireDownloadRecordAttachmentQuery.model_validate(
|
|
1844
|
+
{
|
|
1845
|
+
"eln": attachment.source.notebook.notebook_id,
|
|
1846
|
+
"i": attachment.source.record.record_database_id,
|
|
1847
|
+
"u": attachment.source.record.record_uid,
|
|
1848
|
+
"h": attachment.sha256,
|
|
1849
|
+
"f": attachment.filename,
|
|
1850
|
+
"s": attachment.size,
|
|
1851
|
+
"c": 0,
|
|
1852
|
+
"thumb": 0,
|
|
1853
|
+
}
|
|
1854
|
+
)
|
|
1855
|
+
except ValidationError:
|
|
1856
|
+
raise MatelabUsageError("Record attachment reference does not satisfy the Integration Contract.") from None
|
|
1857
|
+
return await self._transport.stream(
|
|
1858
|
+
_DOWNLOAD_RECORD_ATTACHMENT,
|
|
1859
|
+
payload=request.model_dump(mode="json"),
|
|
1860
|
+
range_header=byte_range.to_header() if byte_range is not None else None,
|
|
1861
|
+
)
|
|
1862
|
+
|
|
1863
|
+
async def download_comment_attachment(
|
|
1864
|
+
self, attachment: RecordCommentAttachmentRef, *, byte_range: ByteRange | None = None
|
|
1865
|
+
) -> DownloadStream:
|
|
1866
|
+
"""Download an observed comment attachment despite Provider issue PVD-038.
|
|
1867
|
+
|
|
1868
|
+
The SDK preserves the source notebook, record, and comment association,
|
|
1869
|
+
but the current Provider only authorizes the supplied notebook and does
|
|
1870
|
+
not independently verify that the attachment belongs to it.
|
|
1871
|
+
"""
|
|
1872
|
+
try:
|
|
1873
|
+
request = WireDownloadRecordCommentAttachmentQuery.model_validate(
|
|
1874
|
+
{
|
|
1875
|
+
"eln": attachment.source.notebook.notebook_id,
|
|
1876
|
+
"i": attachment.comment.comment_id,
|
|
1877
|
+
"h": attachment.sha256,
|
|
1878
|
+
"s": attachment.size,
|
|
1879
|
+
"f": attachment.filename,
|
|
1880
|
+
}
|
|
1881
|
+
)
|
|
1882
|
+
except ValidationError:
|
|
1883
|
+
raise MatelabUsageError(
|
|
1884
|
+
"Record-comment attachment reference does not satisfy the Integration Contract."
|
|
1885
|
+
) from None
|
|
1886
|
+
return await self._transport.stream(
|
|
1887
|
+
_DOWNLOAD_COMMENT_ATTACHMENT,
|
|
1888
|
+
payload=request.model_dump(mode="json"),
|
|
1889
|
+
range_header=byte_range.to_header() if byte_range is not None else None,
|
|
1890
|
+
)
|
|
1891
|
+
|
|
1892
|
+
async def read(
|
|
1893
|
+
self, *, notebook: NotebookRef, record: RecordRef, version: RecordVersionRef | None = None
|
|
1894
|
+
) -> Record:
|
|
1895
|
+
if version is not None and (
|
|
1896
|
+
version.notebook_id != notebook.notebook_id
|
|
1897
|
+
or version.record_database_id != record.record_database_id
|
|
1898
|
+
or version.record_uid != record.record_uid
|
|
1899
|
+
):
|
|
1900
|
+
raise MatelabUsageError("Record version ref does not belong to the selected notebook and record.")
|
|
1901
|
+
if version is not None:
|
|
1902
|
+
current = await self.read(notebook=notebook, record=record)
|
|
1903
|
+
if version not in (item.ref for item in current.versions):
|
|
1904
|
+
raise MatelabUsageError("Record version is no longer present in the current modify_log.")
|
|
1905
|
+
try:
|
|
1906
|
+
request = WireReadRecordQuery(
|
|
1907
|
+
eln=notebook.notebook_id,
|
|
1908
|
+
id=record.record_database_id,
|
|
1909
|
+
version=version.version_id if version is not None else 0,
|
|
1910
|
+
)
|
|
1911
|
+
except ValidationError:
|
|
1912
|
+
raise MatelabUsageError("Record identity does not satisfy the Integration Contract.") from None
|
|
1913
|
+
response = await self._transport.request(
|
|
1914
|
+
_READ_RECORD, payload=request.model_dump(mode="json", exclude_defaults=True, exclude_none=True)
|
|
1915
|
+
)
|
|
1916
|
+
if response.eln_id != notebook.notebook_id or response.item_id != record.record_database_id:
|
|
1917
|
+
raise MatelabProtocolError("Matelab returned a different notebook or record identity.")
|
|
1918
|
+
if version is None and response.uid != record.record_uid:
|
|
1919
|
+
raise MatelabProtocolError("Matelab returned a different record UID.")
|
|
1920
|
+
|
|
1921
|
+
versions = tuple(
|
|
1922
|
+
RecordVersionSummary(
|
|
1923
|
+
ref=RecordVersionRef(
|
|
1924
|
+
notebook_id=notebook.notebook_id,
|
|
1925
|
+
record_database_id=record.record_database_id,
|
|
1926
|
+
record_uid=record.record_uid,
|
|
1927
|
+
version_id=item.id,
|
|
1928
|
+
),
|
|
1929
|
+
locked_at_ms=item.datetime_locked,
|
|
1930
|
+
modified_at_ms=item.datetime_modify,
|
|
1931
|
+
locked=bool(item.locked),
|
|
1932
|
+
author_userid=item.userid,
|
|
1933
|
+
author_name=item.username,
|
|
1934
|
+
)
|
|
1935
|
+
for item in response.modify_log
|
|
1936
|
+
)
|
|
1937
|
+
current_version = next((item.ref for item in versions if item.ref.version_id == response.current_version), None)
|
|
1938
|
+
modules = tuple(cast(dict[str, object], item.model_dump(mode="json")) for item in response.modules)
|
|
1939
|
+
source = RecordLocator(notebook=notebook, record=record)
|
|
1940
|
+
return Record(
|
|
1941
|
+
ref=record,
|
|
1942
|
+
notebook=notebook,
|
|
1943
|
+
selected_version=version,
|
|
1944
|
+
selected_record_uid=response.uid,
|
|
1945
|
+
title=response.title,
|
|
1946
|
+
keywords=tuple(response.keywords) if response.keywords is not None else None,
|
|
1947
|
+
modules=modules,
|
|
1948
|
+
template_id=response.template_id,
|
|
1949
|
+
modified_at_ms=response.datetime_modify,
|
|
1950
|
+
author_name=response.username,
|
|
1951
|
+
versions=versions,
|
|
1952
|
+
current_version=current_version,
|
|
1953
|
+
locked_at_ms=response.datetime_locked,
|
|
1954
|
+
locked=response.locked,
|
|
1955
|
+
signatures=tuple(
|
|
1956
|
+
RecordSignature(
|
|
1957
|
+
signer_userid=item.userid,
|
|
1958
|
+
username=item.username,
|
|
1959
|
+
photo_signature=item.photo_sign,
|
|
1960
|
+
signed_at=item.datetime_sign,
|
|
1961
|
+
timestamp_result=item.timestamp_result,
|
|
1962
|
+
user_result=item.user_result,
|
|
1963
|
+
)
|
|
1964
|
+
for item in response.signs
|
|
1965
|
+
),
|
|
1966
|
+
signed=response.signed,
|
|
1967
|
+
owner=response.owner,
|
|
1968
|
+
editable=response.editable,
|
|
1969
|
+
notebook_title=response.eln_text,
|
|
1970
|
+
data_server=response.eln_server,
|
|
1971
|
+
notebook_keywords=tuple(cast(list[object], response.keyword_eln)),
|
|
1972
|
+
pdf_enabled=response.pdf_enable,
|
|
1973
|
+
template_render_metadata=response.template_show,
|
|
1974
|
+
template_title=response.template_text,
|
|
1975
|
+
template_intro=response.template_intro,
|
|
1976
|
+
attachments=_record_attachment_refs(modules, source=source, version=version),
|
|
1977
|
+
content_sha256=_content_sha256(modules),
|
|
1978
|
+
)
|
|
1979
|
+
|
|
1980
|
+
def _record_patch_payload(
|
|
1981
|
+
self, source: RecordLocator, before: Record, patch: RecordPatch
|
|
1982
|
+
) -> tuple[dict[str, object], int]:
|
|
1983
|
+
modules: dict[str, Mapping[str, object]] = {}
|
|
1984
|
+
for module in before.modules:
|
|
1985
|
+
name = module.get("name")
|
|
1986
|
+
if not isinstance(name, str) or not name or name in modules:
|
|
1987
|
+
raise MatelabUsageError("Current record modules do not have unique non-empty names.")
|
|
1988
|
+
modules[name] = module
|
|
1989
|
+
|
|
1990
|
+
modify: list[dict[str, object]] = []
|
|
1991
|
+
delete_values: list[dict[str, object]] = []
|
|
1992
|
+
create_modules: list[dict[str, object]] = []
|
|
1993
|
+
delete_modules: list[dict[str, object]] = []
|
|
1994
|
+
add_values: list[dict[str, object]] = []
|
|
1995
|
+
claimed_paths: set[tuple[str | int, ...]] = set()
|
|
1996
|
+
used_staged: set[tuple[int, str]] = set()
|
|
1997
|
+
|
|
1998
|
+
def claim(path: tuple[str | int, ...]) -> None:
|
|
1999
|
+
if path in claimed_paths:
|
|
2000
|
+
raise MatelabUsageError("A record patch cannot mutate the same path more than once.")
|
|
2001
|
+
claimed_paths.add(path)
|
|
2002
|
+
|
|
2003
|
+
for update in patch.value_updates:
|
|
2004
|
+
self._validate_nonattachment_path(update.path, modules)
|
|
2005
|
+
if update.value is None:
|
|
2006
|
+
raise MatelabUsageError("Record value replacement cannot use null; use a deletion operation.")
|
|
2007
|
+
if _contains_native_attachment_reference(update.value):
|
|
2008
|
+
raise MatelabUsageError("Provider-native attachment references require a staged attachment object.")
|
|
2009
|
+
claim(update.path)
|
|
2010
|
+
modify.append({"path": list(update.path), "data": update.value})
|
|
2011
|
+
|
|
2012
|
+
for deletion in patch.value_deletions:
|
|
2013
|
+
self._validate_nonattachment_path(deletion.path, modules)
|
|
2014
|
+
claim(deletion.path)
|
|
2015
|
+
delete_values.append({"path": list(deletion.path)})
|
|
2016
|
+
|
|
2017
|
+
created_types: dict[str, str] = {}
|
|
2018
|
+
for creation in patch.module_creations:
|
|
2019
|
+
if creation.name in modules or creation.name in created_types:
|
|
2020
|
+
raise MatelabUsageError("A record patch cannot create an existing or duplicate module name.")
|
|
2021
|
+
if creation.module_type == "code" and creation.language is None:
|
|
2022
|
+
raise MatelabUsageError("A new code module requires a language.")
|
|
2023
|
+
if creation.data is not None and _ATTACHMENT_MARKER_PATTERN.search(creation.data):
|
|
2024
|
+
raise MatelabUsageError(
|
|
2025
|
+
"New rich-text attachments must use a dedicated rich-text update after the module exists."
|
|
2026
|
+
)
|
|
2027
|
+
if creation.data is not None and _contains_native_attachment_reference(creation.data):
|
|
2028
|
+
raise MatelabUsageError("Provider-native attachment references require a staged attachment object.")
|
|
2029
|
+
created_types[creation.name] = creation.module_type
|
|
2030
|
+
create_modules.append(
|
|
2031
|
+
{
|
|
2032
|
+
"name": creation.name,
|
|
2033
|
+
"type": creation.module_type,
|
|
2034
|
+
"data": creation.data,
|
|
2035
|
+
"language": creation.language,
|
|
2036
|
+
"style": creation.style,
|
|
2037
|
+
}
|
|
2038
|
+
)
|
|
2039
|
+
|
|
2040
|
+
deleted_names: set[str] = set()
|
|
2041
|
+
for deletion in patch.module_deletions:
|
|
2042
|
+
if deletion.name in deleted_names or deletion.name not in modules:
|
|
2043
|
+
raise MatelabUsageError("A record patch can delete each existing module at most once.")
|
|
2044
|
+
if any(
|
|
2045
|
+
attachment.location is not None and attachment.location.module == deletion.name
|
|
2046
|
+
for attachment in before.attachments
|
|
2047
|
+
):
|
|
2048
|
+
raise MatelabUsageError(
|
|
2049
|
+
"A module containing attachments cannot be deleted through the safe record patch interface."
|
|
2050
|
+
)
|
|
2051
|
+
deleted_names.add(deletion.name)
|
|
2052
|
+
delete_modules.append({"name": deletion.name})
|
|
2053
|
+
|
|
2054
|
+
for addition in patch.value_additions:
|
|
2055
|
+
module_type = self._module_type(addition.module, modules, created_types)
|
|
2056
|
+
if module_type in {"files", "images", "richtext"} or addition.value_type == "file":
|
|
2057
|
+
raise MatelabUsageError("Attachment-bearing additions require a dedicated attachment operation.")
|
|
2058
|
+
if (
|
|
2059
|
+
addition.name is not None
|
|
2060
|
+
and module_type in {"form", "table"}
|
|
2061
|
+
and self._module_field_type(modules.get(addition.module), addition.name) == "file"
|
|
2062
|
+
):
|
|
2063
|
+
raise MatelabUsageError("File fields require a dedicated attachment operation.")
|
|
2064
|
+
if _contains_native_attachment_reference(addition.value):
|
|
2065
|
+
raise MatelabUsageError("Provider-native attachment references require a staged attachment object.")
|
|
2066
|
+
add_values.append(
|
|
2067
|
+
{
|
|
2068
|
+
"module": addition.module,
|
|
2069
|
+
"name": addition.name,
|
|
2070
|
+
"type": addition.value_type,
|
|
2071
|
+
"row": addition.row,
|
|
2072
|
+
"data": addition.value,
|
|
2073
|
+
"path": list(addition.path) if addition.path is not None else None,
|
|
2074
|
+
}
|
|
2075
|
+
)
|
|
2076
|
+
|
|
2077
|
+
for change in patch.attachment_changes:
|
|
2078
|
+
if isinstance(change, RecordFormAttachmentRemoval):
|
|
2079
|
+
current = self._current_attachment(source, before, change.attachment)
|
|
2080
|
+
location = current.location
|
|
2081
|
+
if location is None or location.kind != "form_file" or location.field is None:
|
|
2082
|
+
raise MatelabUsageError("Form attachment removal requires an observed form-file occurrence.")
|
|
2083
|
+
if (
|
|
2084
|
+
sum(
|
|
2085
|
+
isinstance(other, RecordFormAttachmentRemoval)
|
|
2086
|
+
and other.attachment.location is not None
|
|
2087
|
+
and other.attachment.location.module == location.module
|
|
2088
|
+
and other.attachment.location.field == location.field
|
|
2089
|
+
for other in patch.attachment_changes
|
|
2090
|
+
)
|
|
2091
|
+
> 1
|
|
2092
|
+
):
|
|
2093
|
+
raise MatelabUsageError(
|
|
2094
|
+
"Remove multiple form-file occurrences in separate read/patch cycles to avoid index shifts."
|
|
2095
|
+
)
|
|
2096
|
+
path = (location.module, location.field, location.position)
|
|
2097
|
+
claim(path)
|
|
2098
|
+
delete_values.append({"path": list(path)})
|
|
2099
|
+
continue
|
|
2100
|
+
|
|
2101
|
+
if isinstance(change, RecordTableAttachmentReplacement):
|
|
2102
|
+
current = self._current_attachment(source, before, change.existing)
|
|
2103
|
+
location = current.location
|
|
2104
|
+
if location is None or location.kind != "table_file" or location.field is None:
|
|
2105
|
+
raise MatelabUsageError("Table attachment replacement requires an observed table-file occurrence.")
|
|
2106
|
+
if any(
|
|
2107
|
+
deletion.path
|
|
2108
|
+
and deletion.path[0] == location.module
|
|
2109
|
+
and isinstance(deletion.path[1] if len(deletion.path) > 1 else None, int)
|
|
2110
|
+
for deletion in patch.value_deletions
|
|
2111
|
+
):
|
|
2112
|
+
raise MatelabUsageError(
|
|
2113
|
+
"Table-row deletion and table-file replacement require separate read/patch cycles."
|
|
2114
|
+
)
|
|
2115
|
+
self._claim_staged_attachment(source, change.replacement, used_staged)
|
|
2116
|
+
path = (location.module, location.field, location.position)
|
|
2117
|
+
claim(path)
|
|
2118
|
+
modify.append({"path": list(path), "data": self._native_attachment_ref(change.replacement)})
|
|
2119
|
+
continue
|
|
2120
|
+
|
|
2121
|
+
if isinstance(change, RecordFilesAttachmentAppend):
|
|
2122
|
+
if self._module_type(change.module, modules, created_types) != "files":
|
|
2123
|
+
raise MatelabUsageError("Files attachment append requires a canonical files module.")
|
|
2124
|
+
self._claim_staged_attachment(source, change.attachment, used_staged)
|
|
2125
|
+
add_values.append(
|
|
2126
|
+
{
|
|
2127
|
+
"module": change.module,
|
|
2128
|
+
"data": {"file": self._native_attachment_ref(change.attachment), "text": change.caption},
|
|
2129
|
+
"path": list(change.path) if change.path is not None else None,
|
|
2130
|
+
}
|
|
2131
|
+
)
|
|
2132
|
+
continue
|
|
2133
|
+
|
|
2134
|
+
if isinstance(change, RecordFilesAttachmentReplacement):
|
|
2135
|
+
current = self._current_attachment(source, before, change.existing)
|
|
2136
|
+
location = current.location
|
|
2137
|
+
if location is None or location.kind != "files_module":
|
|
2138
|
+
raise MatelabUsageError(
|
|
2139
|
+
"Files attachment replacement requires an observed canonical files-module occurrence."
|
|
2140
|
+
)
|
|
2141
|
+
if any(
|
|
2142
|
+
isinstance(other, RecordFilesAttachmentRemoval)
|
|
2143
|
+
and other.attachment.location is not None
|
|
2144
|
+
and other.attachment.location.module == location.module
|
|
2145
|
+
for other in patch.attachment_changes
|
|
2146
|
+
):
|
|
2147
|
+
raise MatelabUsageError(
|
|
2148
|
+
"Files removal and positional replacement require separate read/patch cycles."
|
|
2149
|
+
)
|
|
2150
|
+
self._claim_staged_attachment(source, change.replacement, used_staged)
|
|
2151
|
+
path = (location.module, location.position)
|
|
2152
|
+
claim(path)
|
|
2153
|
+
modify.append(
|
|
2154
|
+
{
|
|
2155
|
+
"path": list(path),
|
|
2156
|
+
"data": {"file": self._native_attachment_ref(change.replacement), "text": change.caption},
|
|
2157
|
+
}
|
|
2158
|
+
)
|
|
2159
|
+
continue
|
|
2160
|
+
|
|
2161
|
+
current = self._current_attachment(source, before, change.attachment)
|
|
2162
|
+
location = current.location
|
|
2163
|
+
if location is None or location.kind not in {"files_module", "images_module"}:
|
|
2164
|
+
raise MatelabUsageError("Files attachment removal requires an observed files/images-module occurrence.")
|
|
2165
|
+
same_hash = tuple(
|
|
2166
|
+
attachment
|
|
2167
|
+
for attachment in before.attachments
|
|
2168
|
+
if attachment.location is not None
|
|
2169
|
+
and attachment.location.kind == location.kind
|
|
2170
|
+
and attachment.location.module == location.module
|
|
2171
|
+
and attachment.sha256 == current.sha256
|
|
2172
|
+
)
|
|
2173
|
+
if len(same_hash) != 1:
|
|
2174
|
+
raise MatelabUsageError(
|
|
2175
|
+
"The Provider deletes files by hash, so a duplicate-hash occurrence is unsafe (PVD-016)."
|
|
2176
|
+
)
|
|
2177
|
+
path = (location.module, current.sha256)
|
|
2178
|
+
claim(path)
|
|
2179
|
+
delete_values.append({"path": list(path)})
|
|
2180
|
+
|
|
2181
|
+
for rich_text in patch.rich_text_updates:
|
|
2182
|
+
module = modules.get(rich_text.module)
|
|
2183
|
+
if module is None or module.get("type") != "richtext":
|
|
2184
|
+
raise MatelabUsageError("Rich-text attachment updates require an existing richtext module.")
|
|
2185
|
+
path = (rich_text.module,)
|
|
2186
|
+
claim(path)
|
|
2187
|
+
modify.append({"path": list(path), "data": self._render_record_rich_text(source, rich_text, used_staged)})
|
|
2188
|
+
|
|
2189
|
+
operation_count = len(modify) + len(delete_values) + len(create_modules) + len(delete_modules) + len(add_values)
|
|
2190
|
+
if operation_count == 0:
|
|
2191
|
+
raise MatelabUsageError("Record patch must contain at least one mutation.")
|
|
2192
|
+
payload: dict[str, object] = {}
|
|
2193
|
+
if modify:
|
|
2194
|
+
payload["modify"] = modify
|
|
2195
|
+
if delete_values:
|
|
2196
|
+
payload["del"] = delete_values
|
|
2197
|
+
if create_modules:
|
|
2198
|
+
payload["addModule"] = create_modules
|
|
2199
|
+
if delete_modules:
|
|
2200
|
+
payload["delModule"] = delete_modules
|
|
2201
|
+
if add_values:
|
|
2202
|
+
payload["add"] = add_values
|
|
2203
|
+
return payload, operation_count
|
|
2204
|
+
|
|
2205
|
+
@staticmethod
|
|
2206
|
+
def _module_type(name: str, modules: Mapping[str, Mapping[str, object]], created_types: Mapping[str, str]) -> str:
|
|
2207
|
+
if name in created_types:
|
|
2208
|
+
return created_types[name]
|
|
2209
|
+
module = modules.get(name)
|
|
2210
|
+
module_type = module.get("type") if module is not None else None
|
|
2211
|
+
if not isinstance(module_type, str):
|
|
2212
|
+
raise MatelabUsageError("Record patch targets a module that is not currently present.")
|
|
2213
|
+
return module_type
|
|
2214
|
+
|
|
2215
|
+
@staticmethod
|
|
2216
|
+
def _module_field_type(module: Mapping[str, object] | None, field_name: str) -> str | None:
|
|
2217
|
+
if module is None or not isinstance(raw_fields := module.get("data"), list):
|
|
2218
|
+
return None
|
|
2219
|
+
for raw_field in cast(list[object], raw_fields):
|
|
2220
|
+
if not isinstance(raw_field, dict):
|
|
2221
|
+
continue
|
|
2222
|
+
field_mapping = cast(dict[str, object], raw_field)
|
|
2223
|
+
if field_mapping.get("name") == field_name and isinstance(field_mapping.get("type"), str):
|
|
2224
|
+
return cast(str, field_mapping["type"])
|
|
2225
|
+
return None
|
|
2226
|
+
|
|
2227
|
+
@classmethod
|
|
2228
|
+
def _validate_nonattachment_path(
|
|
2229
|
+
cls, path: tuple[str | int, ...], modules: Mapping[str, Mapping[str, object]]
|
|
2230
|
+
) -> None:
|
|
2231
|
+
if not path or not isinstance(path[0], str) or not path[0]:
|
|
2232
|
+
raise MatelabUsageError("Record patch paths must begin with an exact module name.")
|
|
2233
|
+
module = modules.get(path[0])
|
|
2234
|
+
if module is None:
|
|
2235
|
+
raise MatelabUsageError("Record patch path targets a module that is not currently present.")
|
|
2236
|
+
module_type = module.get("type")
|
|
2237
|
+
if module_type in {"files", "images", "richtext"}:
|
|
2238
|
+
raise MatelabUsageError("Attachment-bearing modules require a dedicated mutation type.")
|
|
2239
|
+
if (
|
|
2240
|
+
module_type in {"form", "table"}
|
|
2241
|
+
and len(path) >= 2
|
|
2242
|
+
and isinstance(path[1], str)
|
|
2243
|
+
and cls._module_field_type(module, path[1]) == "file"
|
|
2244
|
+
):
|
|
2245
|
+
raise MatelabUsageError("File fields require a dedicated attachment mutation type.")
|
|
2246
|
+
|
|
2247
|
+
@staticmethod
|
|
2248
|
+
def _current_attachment(
|
|
2249
|
+
source: RecordLocator, before: Record, requested: RecordAttachmentRef
|
|
2250
|
+
) -> RecordAttachmentRef:
|
|
2251
|
+
if requested.source != source or requested.observed_version is not None or requested.location is None:
|
|
2252
|
+
raise MatelabUsageError("Attachment mutation requires a current occurrence observed on the target record.")
|
|
2253
|
+
current = next(
|
|
2254
|
+
(
|
|
2255
|
+
attachment
|
|
2256
|
+
for attachment in before.attachments
|
|
2257
|
+
if attachment.location == requested.location
|
|
2258
|
+
and attachment.sha256 == requested.sha256
|
|
2259
|
+
and attachment.filename == requested.filename
|
|
2260
|
+
and attachment.size == requested.size
|
|
2261
|
+
),
|
|
2262
|
+
None,
|
|
2263
|
+
)
|
|
2264
|
+
if current is None:
|
|
2265
|
+
raise MatelabUsageError("The selected attachment occurrence changed before the mutation.")
|
|
2266
|
+
return current
|
|
2267
|
+
|
|
2268
|
+
@staticmethod
|
|
2269
|
+
def _claim_staged_attachment(
|
|
2270
|
+
source: RecordLocator, attachment: StagedRecordAttachment, used: set[tuple[int, str]]
|
|
2271
|
+
) -> None:
|
|
2272
|
+
if attachment.source != source:
|
|
2273
|
+
raise MatelabUsageError("Staged record attachment belongs to a different record.")
|
|
2274
|
+
if (
|
|
2275
|
+
attachment.temporary_file_id < 1
|
|
2276
|
+
or not attachment.binding.uid
|
|
2277
|
+
or len(attachment.binding.uid) > 45
|
|
2278
|
+
or not attachment.filename
|
|
2279
|
+
or "\r" in attachment.filename
|
|
2280
|
+
or "\n" in attachment.filename
|
|
2281
|
+
or attachment.size < 0
|
|
2282
|
+
or re.fullmatch(r"[0-9a-f]{64}", attachment.sha256) is None
|
|
2283
|
+
):
|
|
2284
|
+
raise MatelabUsageError("Staged record attachment metadata is invalid.")
|
|
2285
|
+
identity = (attachment.temporary_file_id, attachment.binding.uid)
|
|
2286
|
+
if identity in used:
|
|
2287
|
+
raise MatelabUsageError("One staged record attachment can be consumed only once in a patch.")
|
|
2288
|
+
used.add(identity)
|
|
2289
|
+
|
|
2290
|
+
@staticmethod
|
|
2291
|
+
def _native_attachment_ref(attachment: StagedRecordAttachment) -> str:
|
|
2292
|
+
if "\r" in attachment.filename or "\n" in attachment.filename:
|
|
2293
|
+
raise MatelabUsageError("Attachment filename cannot contain line breaks.")
|
|
2294
|
+
return f"#hash{{{attachment.filename}, {attachment.sha256}}}"
|
|
2295
|
+
|
|
2296
|
+
def _render_record_rich_text(
|
|
2297
|
+
self, source: RecordLocator, update: RecordRichTextUpdate, used_staged: set[tuple[int, str]]
|
|
2298
|
+
) -> str:
|
|
2299
|
+
attachments = dict(update.attachments)
|
|
2300
|
+
if _contains_native_attachment_reference(update.html):
|
|
2301
|
+
raise MatelabUsageError("Provider-native attachment references require a staged attachment object.")
|
|
2302
|
+
markers = set(_ATTACHMENT_MARKER_PATTERN.findall(update.html))
|
|
2303
|
+
if markers != set(attachments):
|
|
2304
|
+
raise MatelabUsageError("Rich-text attachment names must exactly match matelab-attachment:<name> markers.")
|
|
2305
|
+
rendered = update.html
|
|
2306
|
+
for name, attachment in attachments.items():
|
|
2307
|
+
self._claim_staged_attachment(source, attachment, used_staged)
|
|
2308
|
+
rendered = rendered.replace(f"matelab-attachment:{name}", self._native_attachment_ref(attachment))
|
|
2309
|
+
if _ATTACHMENT_MARKER_PATTERN.search(rendered):
|
|
2310
|
+
raise MatelabUsageError("Rich-text content contains an unresolved attachment marker.")
|
|
2311
|
+
return rendered
|
|
2312
|
+
|
|
2313
|
+
@staticmethod
|
|
2314
|
+
def _validate_upload_metadata(
|
|
2315
|
+
*, filename: str, content: IO[bytes] | bytes, size: int, sha256: str, content_type: str
|
|
2316
|
+
) -> None:
|
|
2317
|
+
if (
|
|
2318
|
+
not filename
|
|
2319
|
+
or len(filename) > 1000
|
|
2320
|
+
or "\r" in filename
|
|
2321
|
+
or "\n" in filename
|
|
2322
|
+
or type(size) is not int
|
|
2323
|
+
or size < 0
|
|
2324
|
+
or re.fullmatch(r"[0-9a-f]{64}", sha256) is None
|
|
2325
|
+
or not content_type
|
|
2326
|
+
or "\r" in content_type
|
|
2327
|
+
or "\n" in content_type
|
|
2328
|
+
):
|
|
2329
|
+
raise MatelabUsageError("Attachment metadata does not satisfy the Integration Contract.")
|
|
2330
|
+
if isinstance(content, bytes) and (len(content) != size or hashlib.sha256(content).hexdigest() != sha256):
|
|
2331
|
+
raise MatelabUsageError("Attachment bytes do not match the declared size and SHA-256.")
|
|
2332
|
+
|
|
2333
|
+
@staticmethod
|
|
2334
|
+
def _parse_comment_upload_query(query: str) -> tuple[int, int, str, int, str, str]:
|
|
2335
|
+
try:
|
|
2336
|
+
parsed = parse_qs(query, keep_blank_values=True, strict_parsing=True)
|
|
2337
|
+
except ValueError:
|
|
2338
|
+
raise MatelabProtocolError("Matelab returned an invalid comment attachment reference.") from None
|
|
2339
|
+
if any(len(values) != 1 for values in parsed.values()) or not {"eln", "id", "h", "s", "f"} <= parsed.keys():
|
|
2340
|
+
raise MatelabProtocolError("Matelab returned an invalid comment attachment reference.")
|
|
2341
|
+
try:
|
|
2342
|
+
notebook_id = int(parsed["eln"][0])
|
|
2343
|
+
temporary_id = int(parsed["id"][0])
|
|
2344
|
+
size = int(parsed["s"][0])
|
|
2345
|
+
except ValueError:
|
|
2346
|
+
raise MatelabProtocolError("Matelab returned an invalid comment attachment reference.") from None
|
|
2347
|
+
sha256 = parsed["h"][0]
|
|
2348
|
+
filename = parsed["f"][0]
|
|
2349
|
+
if (
|
|
2350
|
+
notebook_id < 1
|
|
2351
|
+
or temporary_id < 1
|
|
2352
|
+
or size < 0
|
|
2353
|
+
or re.fullmatch(r"[0-9a-f]{64}", sha256) is None
|
|
2354
|
+
or not filename
|
|
2355
|
+
):
|
|
2356
|
+
raise MatelabProtocolError("Matelab returned an invalid comment attachment reference.")
|
|
2357
|
+
raw_filename = next((part.partition("=")[2] for part in query.split("&") if part.partition("=")[0] == "f"), "")
|
|
2358
|
+
canonical_reference = f"elnurl://h={sha256}&s={size}&f={raw_filename}"
|
|
2359
|
+
return notebook_id, temporary_id, sha256, size, filename, canonical_reference
|
|
2360
|
+
|
|
2361
|
+
def _render_comment_draft(self, source: RecordLocator, draft: RecordCommentDraft) -> tuple[str, str, str]:
|
|
2362
|
+
attachments = dict(draft.attachments)
|
|
2363
|
+
if _COMMENT_TEMP_URL_PATTERN.search(draft.html):
|
|
2364
|
+
raise MatelabUsageError("Temporary comment URLs require a staged attachment object.")
|
|
2365
|
+
markers = set(_ATTACHMENT_MARKER_PATTERN.findall(draft.html))
|
|
2366
|
+
if markers != set(attachments):
|
|
2367
|
+
raise MatelabUsageError("Comment attachment names must exactly match matelab-attachment:<name> markers.")
|
|
2368
|
+
binding_uids = {attachment.binding.uid for attachment in attachments.values()}
|
|
2369
|
+
if len(binding_uids) > 1:
|
|
2370
|
+
raise MatelabUsageError("All attachments in one comment save must use the same upload binding.")
|
|
2371
|
+
wire_html = draft.html
|
|
2372
|
+
canonical_html = draft.html
|
|
2373
|
+
for name, attachment in attachments.items():
|
|
2374
|
+
if (
|
|
2375
|
+
attachment.source != source
|
|
2376
|
+
or attachment.temporary_file_id < 1
|
|
2377
|
+
or not attachment.binding.uid
|
|
2378
|
+
or len(attachment.binding.uid) > 45
|
|
2379
|
+
or not attachment._temporary_url # pyright: ignore[reportPrivateUsage]
|
|
2380
|
+
or not attachment._canonical_reference # pyright: ignore[reportPrivateUsage]
|
|
2381
|
+
or attachment.size < 0
|
|
2382
|
+
or re.fullmatch(r"[0-9a-f]{64}", attachment.sha256) is None
|
|
2383
|
+
):
|
|
2384
|
+
raise MatelabUsageError("Staged comment attachment belongs to a different record.")
|
|
2385
|
+
marker = f"matelab-attachment:{name}"
|
|
2386
|
+
wire_html = wire_html.replace(
|
|
2387
|
+
marker,
|
|
2388
|
+
attachment._temporary_url, # pyright: ignore[reportPrivateUsage]
|
|
2389
|
+
)
|
|
2390
|
+
canonical_html = canonical_html.replace(
|
|
2391
|
+
marker,
|
|
2392
|
+
attachment._canonical_reference, # pyright: ignore[reportPrivateUsage]
|
|
2393
|
+
)
|
|
2394
|
+
if _ATTACHMENT_MARKER_PATTERN.search(wire_html):
|
|
2395
|
+
raise MatelabUsageError("Comment content contains an unresolved attachment marker.")
|
|
2396
|
+
return wire_html.strip(), canonical_html.strip(), next(iter(binding_uids), "")
|
|
2397
|
+
|
|
2398
|
+
async def _save_comment_request(
|
|
2399
|
+
self, *, source: RecordLocator, comment_id: int, html: str, binding_uid: str
|
|
2400
|
+
) -> WireForwardableSuccessResponse:
|
|
2401
|
+
try:
|
|
2402
|
+
request = WireSaveRecordCommentRequest(
|
|
2403
|
+
id=comment_id,
|
|
2404
|
+
eln=source.notebook.notebook_id,
|
|
2405
|
+
item_id=source.record.record_database_id,
|
|
2406
|
+
more=html,
|
|
2407
|
+
uid=binding_uid,
|
|
2408
|
+
)
|
|
2409
|
+
except ValidationError:
|
|
2410
|
+
raise MatelabUsageError("Record comment does not satisfy the Integration Contract.") from None
|
|
2411
|
+
return await self._transport.request(_SAVE_COMMENT, files=multipart_fields(request.model_dump(mode="json")))
|
|
2412
|
+
|
|
2413
|
+
async def _verified_read(self, source: RecordLocator, action: str) -> Record:
|
|
2414
|
+
try:
|
|
2415
|
+
return await self.read(notebook=source.notebook, record=source.record)
|
|
2416
|
+
except MatelabError as exc:
|
|
2417
|
+
raise MatelabVerificationError(
|
|
2418
|
+
f"Matelab acknowledged the {action}, but readback failed; the mutation may have persisted."
|
|
2419
|
+
) from exc
|
|
2420
|
+
|
|
2421
|
+
async def _verified_relations(self, source: RecordLocator, action: str) -> RecordRelations:
|
|
2422
|
+
try:
|
|
2423
|
+
return await self.relations(notebook=source.notebook, record=source.record)
|
|
2424
|
+
except MatelabError as exc:
|
|
2425
|
+
raise MatelabVerificationError(
|
|
2426
|
+
f"Matelab acknowledged the {action}, but readback failed; the mutation may have persisted."
|
|
2427
|
+
) from exc
|
|
2428
|
+
|
|
2429
|
+
async def _verified_comments(self, source: RecordLocator, action: str) -> RecordComments:
|
|
2430
|
+
try:
|
|
2431
|
+
return await self.comments(notebook=source.notebook, record=source.record)
|
|
2432
|
+
except MatelabError as exc:
|
|
2433
|
+
raise MatelabVerificationError(
|
|
2434
|
+
f"Matelab acknowledged the {action}, but readback failed; the mutation may have persisted."
|
|
2435
|
+
) from exc
|
|
2436
|
+
|
|
2437
|
+
@staticmethod
|
|
2438
|
+
def _notebook_selector(notebook: NotebookRef) -> str | dict[str, object]:
|
|
2439
|
+
title = Records._notebook_title(notebook)
|
|
2440
|
+
if notebook.scope == "shared":
|
|
2441
|
+
return {"title": title, "user": notebook.owner_userid}
|
|
2442
|
+
return title
|
|
2443
|
+
|
|
2444
|
+
@staticmethod
|
|
2445
|
+
def _notebook_title(notebook: NotebookRef) -> str:
|
|
2446
|
+
if notebook.title is None:
|
|
2447
|
+
raise MatelabUsageError("Notebook selector cannot use a null Provider title.")
|
|
2448
|
+
return notebook.title
|
|
2449
|
+
|
|
2450
|
+
async def _verified_list(self, notebook: NotebookRef, action: str) -> RecordCollection:
|
|
2451
|
+
try:
|
|
2452
|
+
return await self.list(notebook=notebook)
|
|
2453
|
+
except MatelabError as exc:
|
|
2454
|
+
raise MatelabVerificationError(
|
|
2455
|
+
f"Matelab acknowledged the {action}, but readback failed; the mutation may have persisted."
|
|
2456
|
+
) from exc
|
|
2457
|
+
|
|
2458
|
+
async def _verified_lifecycle_state(
|
|
2459
|
+
self, notebook: NotebookRef, action: str
|
|
2460
|
+
) -> tuple[RecordCollection, DeletedRecordPage]:
|
|
2461
|
+
try:
|
|
2462
|
+
return (await self.list(notebook=notebook), await self.recycle_bin(notebook, page=1, page_size=256))
|
|
2463
|
+
except MatelabError as exc:
|
|
2464
|
+
raise MatelabVerificationError(
|
|
2465
|
+
f"Matelab acknowledged the {action}, but readback failed; the mutation may have persisted."
|
|
2466
|
+
) from exc
|
|
2467
|
+
|
|
2468
|
+
@staticmethod
|
|
2469
|
+
def _record_summary(item: WireRecordListItem) -> RecordSummary:
|
|
2470
|
+
return RecordSummary(
|
|
2471
|
+
ref=RecordRef(record_database_id=item.id, record_uid=item.sn),
|
|
2472
|
+
title=item.title,
|
|
2473
|
+
summary=item.comm,
|
|
2474
|
+
created_at=item.datetime_create,
|
|
2475
|
+
modified_at=item.datetime_modify,
|
|
2476
|
+
keywords=tuple(item.keywords),
|
|
2477
|
+
template_id=item.template_id,
|
|
2478
|
+
subtype_id=item.subtype,
|
|
2479
|
+
encrypted=bool(item.encrypt),
|
|
2480
|
+
audit_count=item.log,
|
|
2481
|
+
)
|
|
2482
|
+
|
|
2483
|
+
@staticmethod
|
|
2484
|
+
def _subtype_summary(wire: WireRecordSubtype) -> RecordSubtypeSummary:
|
|
2485
|
+
return RecordSubtypeSummary(
|
|
2486
|
+
subtype_id=wire.id,
|
|
2487
|
+
title=wire.showtext,
|
|
2488
|
+
parent_subtype_id=wire.parent,
|
|
2489
|
+
record_count=wire.items_num,
|
|
2490
|
+
child_subtype_count=wire.subtype_num,
|
|
2491
|
+
)
|
|
2492
|
+
|
|
2493
|
+
@staticmethod
|
|
2494
|
+
def _comment(wire: WireRecordComment, *, source: RecordLocator) -> RecordComment:
|
|
2495
|
+
ref = RecordCommentRef(comment_id=wire.id)
|
|
2496
|
+
attachments = tuple(
|
|
2497
|
+
RecordCommentAttachmentRef(
|
|
2498
|
+
source=source, comment=ref, sha256=attachment.sha256, filename=attachment.filename, size=attachment.size
|
|
2499
|
+
)
|
|
2500
|
+
for attachment in _rich_text_attachments(wire.data)
|
|
2501
|
+
if attachment.size >= 1 and len(attachment.filename) <= 1000
|
|
2502
|
+
)
|
|
2503
|
+
return RecordComment(
|
|
2504
|
+
source=source,
|
|
2505
|
+
ref=ref,
|
|
2506
|
+
modified_at=wire.datetime,
|
|
2507
|
+
body=wire.data,
|
|
2508
|
+
author_name=wire.username,
|
|
2509
|
+
owned_by_caller=wire.owner,
|
|
2510
|
+
attachments=attachments,
|
|
2511
|
+
)
|