supernote 0.13.6__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.
- supernote/__init__.py +28 -0
- supernote/alembic/__init__.py +0 -0
- supernote/alembic/env.py +121 -0
- supernote/alembic/script.py.mako +28 -0
- supernote/alembic/versions/0543a383957b_initial_schema.py +312 -0
- supernote/alembic.ini +149 -0
- supernote/cli/__init__.py +1 -0
- supernote/cli/admin.py +178 -0
- supernote/cli/client.py +434 -0
- supernote/cli/main.py +61 -0
- supernote/cli/notebook.py +313 -0
- supernote/cli/server.py +89 -0
- supernote/client/__init__.py +28 -0
- supernote/client/admin.py +76 -0
- supernote/client/api.py +135 -0
- supernote/client/api_model.py +231 -0
- supernote/client/auth.py +84 -0
- supernote/client/client.py +365 -0
- supernote/client/device.py +361 -0
- supernote/client/exceptions.py +33 -0
- supernote/client/extended.py +20 -0
- supernote/client/hashing.py +50 -0
- supernote/client/login_client.py +154 -0
- supernote/client/schedule.py +149 -0
- supernote/client/summary.py +156 -0
- supernote/client/web.py +213 -0
- supernote/models/__init__.py +14 -0
- supernote/models/auth.py +380 -0
- supernote/models/base.py +266 -0
- supernote/models/equipment.py +276 -0
- supernote/models/extended.py +124 -0
- supernote/models/file_common.py +95 -0
- supernote/models/file_device.py +571 -0
- supernote/models/file_web.py +602 -0
- supernote/models/schedule.py +663 -0
- supernote/models/summary.py +830 -0
- supernote/models/system.py +431 -0
- supernote/models/user.py +638 -0
- supernote/notebook/__init__.py +67 -0
- supernote/notebook/color.py +93 -0
- supernote/notebook/converter.py +560 -0
- supernote/notebook/decoder.py +398 -0
- supernote/notebook/exceptions.py +43 -0
- supernote/notebook/fileformat.py +463 -0
- supernote/notebook/manipulator.py +420 -0
- supernote/notebook/parser.py +737 -0
- supernote/notebook/utils.py +49 -0
- supernote/py.typed +0 -0
- supernote/server/__init__.py +12 -0
- supernote/server/app.py +428 -0
- supernote/server/config.py +265 -0
- supernote/server/constants.py +26 -0
- supernote/server/db/__init__.py +9 -0
- supernote/server/db/base.py +8 -0
- supernote/server/db/migrations.py +44 -0
- supernote/server/db/models/__init__.py +23 -0
- supernote/server/db/models/device.py +17 -0
- supernote/server/db/models/file.py +107 -0
- supernote/server/db/models/kv.py +17 -0
- supernote/server/db/models/login_record.py +22 -0
- supernote/server/db/models/note_processing.py +100 -0
- supernote/server/db/models/schedule.py +83 -0
- supernote/server/db/models/summary.py +122 -0
- supernote/server/db/models/user.py +30 -0
- supernote/server/db/session.py +88 -0
- supernote/server/events.py +63 -0
- supernote/server/exceptions.py +153 -0
- supernote/server/mcp/__init__.py +1 -0
- supernote/server/mcp/models.py +109 -0
- supernote/server/mcp/server.py +176 -0
- supernote/server/resources/__init__.py +0 -0
- supernote/server/resources/prompts/__init__.py +0 -0
- supernote/server/resources/prompts/ocr/common/context.md +19 -0
- supernote/server/resources/prompts/ocr/common/legend.md +11 -0
- supernote/server/resources/prompts/ocr/daily/prompt.md +12 -0
- supernote/server/resources/prompts/ocr/default/system.md +14 -0
- supernote/server/resources/prompts/ocr/monthly/prompt.md +22 -0
- supernote/server/resources/prompts/ocr/weekly/prompt.md +16 -0
- supernote/server/resources/prompts/summary/common/instruction.md +8 -0
- supernote/server/resources/prompts/summary/daily/prompt.md +6 -0
- supernote/server/resources/prompts/summary/default/prompt.md +4 -0
- supernote/server/resources/prompts/summary/monthly/prompt.md +6 -0
- supernote/server/resources/prompts/summary/weekly/prompt.md +6 -0
- supernote/server/routes/admin.py +109 -0
- supernote/server/routes/auth.py +337 -0
- supernote/server/routes/decorators.py +13 -0
- supernote/server/routes/extended.py +139 -0
- supernote/server/routes/file_device.py +593 -0
- supernote/server/routes/file_web.py +597 -0
- supernote/server/routes/oss.py +317 -0
- supernote/server/routes/schedule.py +223 -0
- supernote/server/routes/summary.py +390 -0
- supernote/server/routes/system.py +57 -0
- supernote/server/services/__init__.py +15 -0
- supernote/server/services/blob.py +214 -0
- supernote/server/services/coordination.py +171 -0
- supernote/server/services/file.py +970 -0
- supernote/server/services/gemini.py +66 -0
- supernote/server/services/integrity.py +112 -0
- supernote/server/services/processor.py +331 -0
- supernote/server/services/processor_modules/__init__.py +145 -0
- supernote/server/services/processor_modules/gemini_embedding.py +112 -0
- supernote/server/services/processor_modules/gemini_ocr.py +149 -0
- supernote/server/services/processor_modules/page_hashing.py +220 -0
- supernote/server/services/processor_modules/png_conversion.py +107 -0
- supernote/server/services/processor_modules/summary.py +302 -0
- supernote/server/services/prompt_loader.py +133 -0
- supernote/server/services/schedule.py +161 -0
- supernote/server/services/search.py +250 -0
- supernote/server/services/summary.py +419 -0
- supernote/server/services/user.py +480 -0
- supernote/server/services/vfs.py +476 -0
- supernote/server/static/favicon.ico +0 -0
- supernote/server/static/index.html +186 -0
- supernote/server/static/js/api/client.js +463 -0
- supernote/server/static/js/components/FileCard.js +55 -0
- supernote/server/static/js/components/FileViewer.js +137 -0
- supernote/server/static/js/components/LoginCard.js +64 -0
- supernote/server/static/js/components/MoveModal.js +74 -0
- supernote/server/static/js/components/RenameModal.js +39 -0
- supernote/server/static/js/components/SummaryPanel.js +101 -0
- supernote/server/static/js/components/SystemPanel.js +158 -0
- supernote/server/static/js/composables/useFileSystem.js +116 -0
- supernote/server/static/js/main.js +241 -0
- supernote/server/static/style.css +14 -0
- supernote/server/utils/hashing.py +18 -0
- supernote/server/utils/note_content.py +76 -0
- supernote/server/utils/paths.py +58 -0
- supernote/server/utils/rate_limit.py +52 -0
- supernote/server/utils/tasks.py +71 -0
- supernote/server/utils/unique_id.py +48 -0
- supernote/server/utils/url_signer.py +218 -0
- supernote-0.13.6.dist-info/METADATA +183 -0
- supernote-0.13.6.dist-info/RECORD +138 -0
- supernote-0.13.6.dist-info/WHEEL +5 -0
- supernote-0.13.6.dist-info/entry_points.txt +3 -0
- supernote-0.13.6.dist-info/licenses/LICENSE +201 -0
- supernote-0.13.6.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import logging
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from supernote.models.file_common import FileUploadApplyLocalVO
|
|
6
|
+
from supernote.models.file_device import (
|
|
7
|
+
CapacityLocalDTO,
|
|
8
|
+
CapacityLocalVO,
|
|
9
|
+
CreateFolderLocalDTO,
|
|
10
|
+
CreateFolderLocalVO,
|
|
11
|
+
DeleteFolderLocalDTO,
|
|
12
|
+
DeleteFolderLocalVO,
|
|
13
|
+
FileCopyLocalDTO,
|
|
14
|
+
FileCopyLocalVO,
|
|
15
|
+
FileDownloadLocalDTO,
|
|
16
|
+
FileDownloadLocalVO,
|
|
17
|
+
FileMoveLocalDTO,
|
|
18
|
+
FileMoveLocalVO,
|
|
19
|
+
FileQueryByPathLocalDTO,
|
|
20
|
+
FileQueryByPathLocalVO,
|
|
21
|
+
FileQueryLocalDTO,
|
|
22
|
+
FileQueryLocalVO,
|
|
23
|
+
FileUploadApplyLocalDTO,
|
|
24
|
+
FileUploadFinishLocalDTO,
|
|
25
|
+
FileUploadFinishLocalVO,
|
|
26
|
+
ListFolderLocalDTO,
|
|
27
|
+
ListFolderLocalVO,
|
|
28
|
+
ListFolderV2DTO,
|
|
29
|
+
PdfDTO,
|
|
30
|
+
PdfVO,
|
|
31
|
+
PngDTO,
|
|
32
|
+
PngVO,
|
|
33
|
+
SynchronousEndLocalDTO,
|
|
34
|
+
SynchronousEndLocalVO,
|
|
35
|
+
SynchronousStartLocalDTO,
|
|
36
|
+
SynchronousStartLocalVO,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
from .client import Client
|
|
40
|
+
|
|
41
|
+
_LOGGER = logging.getLogger(__name__)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class DeviceClient:
|
|
45
|
+
"""Client for Device (V2/V3) APIs."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, client: Client) -> None:
|
|
48
|
+
"""Initialize the DeviceClient."""
|
|
49
|
+
self._client = client
|
|
50
|
+
|
|
51
|
+
async def create_folder(
|
|
52
|
+
self, path: str, equipment_no: str, autorename: bool = False
|
|
53
|
+
) -> CreateFolderLocalVO:
|
|
54
|
+
"""Create a folder (V2)."""
|
|
55
|
+
dto = CreateFolderLocalDTO(
|
|
56
|
+
path=path, equipment_no=equipment_no, autorename=autorename
|
|
57
|
+
)
|
|
58
|
+
return await self._client.post_json(
|
|
59
|
+
"/api/file/2/files/create_folder_v2",
|
|
60
|
+
CreateFolderLocalVO,
|
|
61
|
+
json=dto.to_dict(),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
async def list_folder(
|
|
65
|
+
self,
|
|
66
|
+
path: str | None = None,
|
|
67
|
+
folder_id: int | None = None,
|
|
68
|
+
equipment_no: str | None = None,
|
|
69
|
+
recursive: bool = False,
|
|
70
|
+
) -> ListFolderLocalVO:
|
|
71
|
+
"""List folder contents.
|
|
72
|
+
|
|
73
|
+
This supports both V2 and V3 APIs. You can either specify path or folder_id.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
path: Path to list contents of.
|
|
77
|
+
folder_id: ID of folder to list contents of.
|
|
78
|
+
equipment_no: Equipment number.
|
|
79
|
+
recursive: Whether to list recursively.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
ListFolderLocalVO
|
|
83
|
+
"""
|
|
84
|
+
if path is not None:
|
|
85
|
+
dto_v2 = ListFolderV2DTO(
|
|
86
|
+
path=path, equipment_no=equipment_no or "WEB", recursive=recursive
|
|
87
|
+
)
|
|
88
|
+
return await self._client.post_json(
|
|
89
|
+
"/api/file/2/files/list_folder",
|
|
90
|
+
ListFolderLocalVO,
|
|
91
|
+
json=dto_v2.to_dict(),
|
|
92
|
+
)
|
|
93
|
+
if folder_id is not None:
|
|
94
|
+
# List folder contents using v3/device API
|
|
95
|
+
dto_v3 = ListFolderLocalDTO(
|
|
96
|
+
id=folder_id,
|
|
97
|
+
equipment_no=equipment_no or "WEB",
|
|
98
|
+
recursive=recursive,
|
|
99
|
+
)
|
|
100
|
+
return await self._client.post_json(
|
|
101
|
+
"/api/file/3/files/list_folder_v3",
|
|
102
|
+
ListFolderLocalVO,
|
|
103
|
+
json=dto_v3.to_dict(),
|
|
104
|
+
)
|
|
105
|
+
raise ValueError("path or folder_id must be specified")
|
|
106
|
+
|
|
107
|
+
async def delete(self, id: int, equipment_no: str) -> DeleteFolderLocalVO:
|
|
108
|
+
"""Delete a folder or file (V3)."""
|
|
109
|
+
dto = DeleteFolderLocalDTO(id=id, equipment_no=equipment_no)
|
|
110
|
+
return await self._client.post_json(
|
|
111
|
+
"/api/file/3/files/delete_folder_v3",
|
|
112
|
+
DeleteFolderLocalVO,
|
|
113
|
+
json=dto.to_dict(),
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
async def delete_by_path(
|
|
117
|
+
self, path: str, equipment_no: str | None = None
|
|
118
|
+
) -> DeleteFolderLocalVO:
|
|
119
|
+
"""Delete a folder or file by path (V3)."""
|
|
120
|
+
if equipment_no is None:
|
|
121
|
+
equipment_no = "WEB"
|
|
122
|
+
info = await self.query_by_path(path, equipment_no)
|
|
123
|
+
if not info.entries_vo:
|
|
124
|
+
raise FileNotFoundError(f"File or folder not found: {path}")
|
|
125
|
+
return await self.delete(int(info.entries_vo.id), equipment_no)
|
|
126
|
+
|
|
127
|
+
async def move(
|
|
128
|
+
self, id: int, to_path: str, equipment_no: str, autorename: bool = False
|
|
129
|
+
) -> FileMoveLocalVO:
|
|
130
|
+
"""Move a folder or file (V3)."""
|
|
131
|
+
dto = FileMoveLocalDTO(
|
|
132
|
+
id=id, to_path=to_path, equipment_no=equipment_no, autorename=autorename
|
|
133
|
+
)
|
|
134
|
+
return await self._client.post_json(
|
|
135
|
+
"/api/file/3/files/move_v3", FileMoveLocalVO, json=dto.to_dict()
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
async def copy(
|
|
139
|
+
self, id: int, to_path: str, equipment_no: str, autorename: bool = False
|
|
140
|
+
) -> FileCopyLocalVO:
|
|
141
|
+
"""Copy a folder or file (V3)."""
|
|
142
|
+
dto = FileCopyLocalDTO(
|
|
143
|
+
id=id, to_path=to_path, equipment_no=equipment_no, autorename=autorename
|
|
144
|
+
)
|
|
145
|
+
return await self._client.post_json(
|
|
146
|
+
"/api/file/3/files/copy_v3", FileCopyLocalVO, json=dto.to_dict()
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
async def upload_content(
|
|
150
|
+
self,
|
|
151
|
+
path: str,
|
|
152
|
+
content: bytes,
|
|
153
|
+
equipment_no: str | None = None,
|
|
154
|
+
chunk_size: int = 5 * 1024 * 1024,
|
|
155
|
+
) -> FileUploadFinishLocalVO:
|
|
156
|
+
"""Create a file (convenience method wrapping upload flow).
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
path: Full cloud path (e.g. /Folder/file.txt)
|
|
160
|
+
content: File content (string or bytes)
|
|
161
|
+
equipment_no: Equipment number
|
|
162
|
+
chunk_size: Upload chunk size/part size
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
FileUploadFinishLocalVO containing file upload finish response
|
|
166
|
+
"""
|
|
167
|
+
if equipment_no is None:
|
|
168
|
+
equipment_no = "WEB"
|
|
169
|
+
filename = Path(path).name
|
|
170
|
+
size = len(content)
|
|
171
|
+
|
|
172
|
+
# First upload the upload url
|
|
173
|
+
_LOGGER.debug("Initiating upload for file %s", path)
|
|
174
|
+
apply = await self.upload_apply(filename, path, size, equipment_no)
|
|
175
|
+
|
|
176
|
+
md5 = hashlib.md5(content).hexdigest()
|
|
177
|
+
|
|
178
|
+
await self._client._upload_to_oss(
|
|
179
|
+
content,
|
|
180
|
+
filename,
|
|
181
|
+
apply.full_upload_url,
|
|
182
|
+
apply.part_upload_url,
|
|
183
|
+
chunk_size,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
_LOGGER.debug("Finishing upload for file %s", path)
|
|
187
|
+
|
|
188
|
+
parent = str(Path(path).parent)
|
|
189
|
+
if parent == ".":
|
|
190
|
+
parent = ""
|
|
191
|
+
parent_path_str = parent.strip("/")
|
|
192
|
+
|
|
193
|
+
return await self.upload_finish(
|
|
194
|
+
file_name=filename,
|
|
195
|
+
path=parent_path_str,
|
|
196
|
+
content_hash=md5,
|
|
197
|
+
equipment_no=equipment_no,
|
|
198
|
+
inner_name=apply.inner_name,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
async def upload_apply(
|
|
202
|
+
self, file_name: str, path: str, size: int, equipment_no: str
|
|
203
|
+
) -> FileUploadApplyLocalVO:
|
|
204
|
+
"""Apply for file upload."""
|
|
205
|
+
dto = FileUploadApplyLocalDTO(
|
|
206
|
+
file_name=file_name, path=path, size=str(size), equipment_no=equipment_no
|
|
207
|
+
)
|
|
208
|
+
return await self._client.post_json(
|
|
209
|
+
"/api/file/3/files/upload/apply", FileUploadApplyLocalVO, json=dto.to_dict()
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
async def upload_finish(
|
|
213
|
+
self,
|
|
214
|
+
file_name: str,
|
|
215
|
+
path: str,
|
|
216
|
+
content_hash: str,
|
|
217
|
+
equipment_no: str,
|
|
218
|
+
inner_name: str | None = None,
|
|
219
|
+
) -> FileUploadFinishLocalVO:
|
|
220
|
+
"""Finish file upload."""
|
|
221
|
+
dto = FileUploadFinishLocalDTO(
|
|
222
|
+
file_name=file_name,
|
|
223
|
+
path=path,
|
|
224
|
+
content_hash=content_hash,
|
|
225
|
+
equipment_no=equipment_no,
|
|
226
|
+
inner_name=inner_name,
|
|
227
|
+
)
|
|
228
|
+
return await self._client.post_json(
|
|
229
|
+
"/api/file/2/files/upload/finish",
|
|
230
|
+
FileUploadFinishLocalVO,
|
|
231
|
+
json=dto.to_dict(),
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
async def download_content(
|
|
235
|
+
self,
|
|
236
|
+
path: str | None = None,
|
|
237
|
+
file_id: int | None = None,
|
|
238
|
+
equipment_no: str = "",
|
|
239
|
+
offset: int = 0,
|
|
240
|
+
length: int = -1,
|
|
241
|
+
) -> bytes:
|
|
242
|
+
"""Download file content, optionally with range.
|
|
243
|
+
|
|
244
|
+
Args:
|
|
245
|
+
path: File path (e.g. /Folder/file.pdf) (optional if file_id provided)
|
|
246
|
+
file_id: File ID (optional if path provided)
|
|
247
|
+
equipment_no: Equipment number
|
|
248
|
+
offset: Start byte offset
|
|
249
|
+
length: Number of bytes to read (-1 for until end)
|
|
250
|
+
|
|
251
|
+
Returns:
|
|
252
|
+
File content bytes
|
|
253
|
+
"""
|
|
254
|
+
if equipment_no == "":
|
|
255
|
+
equipment_no = "WEB"
|
|
256
|
+
|
|
257
|
+
if file_id is None:
|
|
258
|
+
if path is None:
|
|
259
|
+
raise ValueError("Either path or file_id must be provided")
|
|
260
|
+
# Resolve path to ID
|
|
261
|
+
info = await self.query_by_path(path, equipment_no)
|
|
262
|
+
if not info.entries_vo:
|
|
263
|
+
raise FileNotFoundError(f"File not found: {path}")
|
|
264
|
+
# The API returns id as str, we need int for download_v3
|
|
265
|
+
file_id = int(info.entries_vo.id)
|
|
266
|
+
|
|
267
|
+
download_info = await self.download_v3(file_id, equipment_no)
|
|
268
|
+
url = download_info.url
|
|
269
|
+
|
|
270
|
+
headers = {}
|
|
271
|
+
if offset > 0 or length != -1:
|
|
272
|
+
end = ""
|
|
273
|
+
if length != -1:
|
|
274
|
+
end = str(offset + length - 1)
|
|
275
|
+
headers["Range"] = f"bytes={offset}-{end}"
|
|
276
|
+
|
|
277
|
+
return await self._client.get_content(url, headers=headers)
|
|
278
|
+
|
|
279
|
+
async def download_v3(self, file_id: int, equipment_no: str) -> FileDownloadLocalVO:
|
|
280
|
+
"""Get download URL (V3)."""
|
|
281
|
+
dto = FileDownloadLocalDTO(id=file_id, equipment_no=equipment_no)
|
|
282
|
+
return await self._client.post_json(
|
|
283
|
+
"/api/file/3/files/download_v3", FileDownloadLocalVO, json=dto.to_dict()
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
async def get_capacity(self, equipment_no: str = "") -> CapacityLocalVO:
|
|
287
|
+
"""Get storage capacity (Device)."""
|
|
288
|
+
dto = CapacityLocalDTO(equipment_no=equipment_no)
|
|
289
|
+
return await self._client.post_json(
|
|
290
|
+
"/api/file/2/users/get_space_usage", CapacityLocalVO, json=dto.to_dict()
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
async def query_by_path(
|
|
294
|
+
self, path: str, equipment_no: str
|
|
295
|
+
) -> FileQueryByPathLocalVO:
|
|
296
|
+
"""Query file info by path (V3)."""
|
|
297
|
+
dto = FileQueryByPathLocalDTO(path=path, equipment_no=equipment_no)
|
|
298
|
+
return await self._client.post_json(
|
|
299
|
+
"/api/file/3/files/query/by/path_v3",
|
|
300
|
+
FileQueryByPathLocalVO,
|
|
301
|
+
json=dto.to_dict(),
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
async def query_by_id(self, file_id: int, equipment_no: str) -> FileQueryLocalVO:
|
|
305
|
+
"""Query file info by ID (V3)."""
|
|
306
|
+
dto = FileQueryLocalDTO(id=str(file_id), equipment_no=equipment_no)
|
|
307
|
+
return await self._client.post_json(
|
|
308
|
+
"/api/file/3/files/query_v3", FileQueryLocalVO, json=dto.to_dict()
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
async def sync_start(self, equipment_no: str) -> SynchronousStartLocalVO:
|
|
312
|
+
"""Start sync session."""
|
|
313
|
+
dto = SynchronousStartLocalDTO(equipment_no=equipment_no)
|
|
314
|
+
return await self._client.post_json(
|
|
315
|
+
"/api/file/2/files/synchronous/start",
|
|
316
|
+
SynchronousStartLocalVO,
|
|
317
|
+
json=dto.to_dict(),
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
async def sync_end(self, equipment_no: str) -> SynchronousEndLocalVO:
|
|
321
|
+
"""End sync session."""
|
|
322
|
+
dto = SynchronousEndLocalDTO(equipment_no=equipment_no)
|
|
323
|
+
return await self._client.post_json(
|
|
324
|
+
"/api/file/2/files/synchronous/end",
|
|
325
|
+
SynchronousEndLocalVO,
|
|
326
|
+
json=dto.to_dict(),
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
async def note_to_png(self, file_id: int) -> PngVO:
|
|
330
|
+
"""Convert a note to PNG (Device API)."""
|
|
331
|
+
dto = PngDTO(id=file_id)
|
|
332
|
+
return await self._client.post_json(
|
|
333
|
+
"/api/file/note/to/png", PngVO, json=dto.to_dict()
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
async def note_to_pdf(
|
|
337
|
+
self, file_id: int, page_no_list: list[int] | None = None
|
|
338
|
+
) -> PdfVO:
|
|
339
|
+
"""Convert a note to PDF (Device API)."""
|
|
340
|
+
dto = PdfDTO(id=file_id, page_no_list=page_no_list or [])
|
|
341
|
+
return await self._client.post_json(
|
|
342
|
+
"/api/file/note/to/pdf", PdfVO, json=dto.to_dict()
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
async def get_note_png_pages(self, file_id: int) -> list[bytes]:
|
|
346
|
+
"""Convenience method to get PNG content for all pages of a note."""
|
|
347
|
+
conversion = await self.note_to_png(file_id)
|
|
348
|
+
pages = []
|
|
349
|
+
for page_vo in conversion.png_page_vo_list:
|
|
350
|
+
content = await self._client.get_content(page_vo.url)
|
|
351
|
+
pages.append(content)
|
|
352
|
+
return pages
|
|
353
|
+
|
|
354
|
+
async def get_note_pdf(
|
|
355
|
+
self, file_id: int, page_no_list: list[int] | None = None
|
|
356
|
+
) -> bytes:
|
|
357
|
+
"""Convenience method to get PDF content for a note."""
|
|
358
|
+
conversion = await self.note_to_pdf(file_id, page_no_list)
|
|
359
|
+
if not conversion.url:
|
|
360
|
+
raise ValueError("Conversion failed: no URL returned")
|
|
361
|
+
return await self._client.get_content(conversion.url)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Exceptions for supernote cloud."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class SupernoteException(Exception):
|
|
5
|
+
"""Base exception for supernote cloud."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SmsVerificationRequired(SupernoteException):
|
|
9
|
+
"""Exception raised when SMS verification is required."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, message: str, timestamp: str):
|
|
12
|
+
super().__init__(message)
|
|
13
|
+
self.timestamp = timestamp
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ApiException(SupernoteException):
|
|
17
|
+
"""API exception."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ForbiddenException(ApiException):
|
|
21
|
+
"""API exception."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class UnauthorizedException(ApiException):
|
|
25
|
+
"""Authentication exception."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class NotFoundException(ApiException):
|
|
29
|
+
"""Resource not found (404)."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class BadRequestException(ApiException):
|
|
33
|
+
"""Bad request (400)."""
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Client for Extended (Web) APIs."""
|
|
2
|
+
|
|
3
|
+
from supernote.models.extended import WebSummaryListRequestDTO, WebSummaryListVO
|
|
4
|
+
|
|
5
|
+
from . import Client
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ExtendedClient:
|
|
9
|
+
"""Client for Server Extension APIs."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, client: Client):
|
|
12
|
+
"""Initialize the extended client."""
|
|
13
|
+
self._client = client
|
|
14
|
+
|
|
15
|
+
async def list_summaries(self, file_id: int) -> WebSummaryListVO:
|
|
16
|
+
"""List summaries for a file (Extension)."""
|
|
17
|
+
dto = WebSummaryListRequestDTO(file_id=file_id)
|
|
18
|
+
return await self._client.post_json(
|
|
19
|
+
"/api/extended/file/summary/list", WebSummaryListVO, json=dto.to_dict()
|
|
20
|
+
)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Module for hashing utilities."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import logging
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _sha256_string(s: str) -> str:
|
|
10
|
+
"""Return SHA256 hex digest of a string."""
|
|
11
|
+
return hashlib.sha256(s.encode("utf-8")).hexdigest()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _md5_string(s: str) -> str:
|
|
15
|
+
"""Return MD5 hex digest of a string."""
|
|
16
|
+
return hashlib.md5(s.encode("utf-8")).hexdigest()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def hash_with_salt(content: str, salt: str) -> str:
|
|
20
|
+
"""Hash content with salt using SHA256."""
|
|
21
|
+
return _sha256_string(f"{content}{salt}")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def hash_password(password_text: str, rc: str) -> str:
|
|
25
|
+
"""Encode password using MD5 and SHA256."""
|
|
26
|
+
md5 = _md5_string(password_text)
|
|
27
|
+
return hash_with_salt(md5, rc)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_token_salt(token: str) -> str:
|
|
31
|
+
"""Extract real key (salt) from token.
|
|
32
|
+
|
|
33
|
+
This is the dynamic key selection mechanism used during the SMS authentication
|
|
34
|
+
flow. It takes the last character of the token and interprets it as an integer
|
|
35
|
+
index n. It then splits the token string by hyphens - and returns the n-th
|
|
36
|
+
component from the split parts.
|
|
37
|
+
"""
|
|
38
|
+
if not token:
|
|
39
|
+
raise ValueError("Token cannot be empty")
|
|
40
|
+
last_char = token[-1]
|
|
41
|
+
try:
|
|
42
|
+
index = int(last_char)
|
|
43
|
+
except ValueError:
|
|
44
|
+
raise ValueError(f"Last character of token must be an integer: {token}")
|
|
45
|
+
parts = token.split("-")
|
|
46
|
+
if 0 <= index < len(parts):
|
|
47
|
+
return parts[index]
|
|
48
|
+
raise ValueError(
|
|
49
|
+
f"Invalid token format (index out of bounds, index={index}, token={token})"
|
|
50
|
+
)
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Library for authenticating with the Supernote (Private) Cloud Server."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import TypeVar
|
|
5
|
+
|
|
6
|
+
from mashumaro.mixins.json import DataClassJSONMixin
|
|
7
|
+
|
|
8
|
+
from supernote.models.auth import (
|
|
9
|
+
LoginDTO,
|
|
10
|
+
LoginMethod,
|
|
11
|
+
LoginVO,
|
|
12
|
+
QueryTokenDTO,
|
|
13
|
+
QueryTokenVO,
|
|
14
|
+
RandomCodeDTO,
|
|
15
|
+
RandomCodeVO,
|
|
16
|
+
SendSmsDTO,
|
|
17
|
+
SendSmsVO,
|
|
18
|
+
SmsLoginDTO,
|
|
19
|
+
SmsLoginVO,
|
|
20
|
+
UserPreAuthRequest,
|
|
21
|
+
UserPreAuthResponse,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
from .client import Client
|
|
25
|
+
from .exceptions import ApiException, SmsVerificationRequired
|
|
26
|
+
from .hashing import get_token_salt, hash_password, hash_with_salt
|
|
27
|
+
|
|
28
|
+
_LOGGER = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
_T = TypeVar("_T", bound=DataClassJSONMixin)
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"LoginClient",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class LoginClient:
|
|
39
|
+
"""A client library for logging in."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, client: Client):
|
|
42
|
+
"""Initialize the client."""
|
|
43
|
+
self._client = client
|
|
44
|
+
|
|
45
|
+
async def login(self, email: str, password: str) -> str:
|
|
46
|
+
"""Log in and return an access token."""
|
|
47
|
+
await self._token()
|
|
48
|
+
random_code_response = await self._get_random_code(email)
|
|
49
|
+
encoded_password = hash_password(password, random_code_response.random_code)
|
|
50
|
+
access_token_response = await self._get_access_token(
|
|
51
|
+
email, encoded_password, random_code_response.timestamp
|
|
52
|
+
)
|
|
53
|
+
return access_token_response.token
|
|
54
|
+
|
|
55
|
+
async def login_equipment(
|
|
56
|
+
self, email: str, password: str, equipment_no: str
|
|
57
|
+
) -> LoginVO:
|
|
58
|
+
"""Log in via equipment endpoint and return full login response."""
|
|
59
|
+
await self._token()
|
|
60
|
+
random_code_response = await self._get_random_code(email)
|
|
61
|
+
encoded_password = hash_password(password, random_code_response.random_code)
|
|
62
|
+
|
|
63
|
+
payload = LoginDTO(
|
|
64
|
+
account=email,
|
|
65
|
+
password=encoded_password,
|
|
66
|
+
login_method=LoginMethod.PHONE if email.isdigit() else LoginMethod.EMAIL,
|
|
67
|
+
timestamp=random_code_response.timestamp,
|
|
68
|
+
equipment_no=equipment_no,
|
|
69
|
+
).to_dict()
|
|
70
|
+
|
|
71
|
+
return await self._client.post_json(
|
|
72
|
+
"/api/official/user/account/login/equipment", LoginVO, json=payload
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
async def sms_login(self, telephone: str, code: str, timestamp: str) -> str:
|
|
76
|
+
"""Log in via SMS code."""
|
|
77
|
+
# Always get a fresh CSRF token for the SMS login request
|
|
78
|
+
await self._client._get_csrf_token()
|
|
79
|
+
|
|
80
|
+
payload = SmsLoginDTO(
|
|
81
|
+
telephone=telephone,
|
|
82
|
+
timestamp=timestamp,
|
|
83
|
+
valid_code=code,
|
|
84
|
+
valid_code_key=f"1-{telephone}_validCode",
|
|
85
|
+
).to_dict()
|
|
86
|
+
|
|
87
|
+
response = await self._client.post_json(
|
|
88
|
+
"/api/official/user/sms/login", SmsLoginVO, json=payload
|
|
89
|
+
)
|
|
90
|
+
return response.token
|
|
91
|
+
|
|
92
|
+
async def request_sms_code(self, telephone: str, country_code: int = 1) -> None:
|
|
93
|
+
"""Request an SMS verification code."""
|
|
94
|
+
# Pre-authentication step to obtain a token
|
|
95
|
+
account_with_code = f"{country_code}{telephone}"
|
|
96
|
+
pre_auth_payload = UserPreAuthRequest(account=account_with_code).to_dict()
|
|
97
|
+
|
|
98
|
+
# Always get a fresh CSRF token
|
|
99
|
+
await self._client._get_csrf_token()
|
|
100
|
+
|
|
101
|
+
pre_auth_response = await self._client.post_json(
|
|
102
|
+
"/api/user/validcode/pre-auth", UserPreAuthResponse, json=pre_auth_payload
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
# Calculate signature needed to send the random code over SMS
|
|
106
|
+
token_salt = get_token_salt(pre_auth_response.token)
|
|
107
|
+
sign = hash_with_salt(account_with_code, token_salt)
|
|
108
|
+
|
|
109
|
+
# Obtain a random code timestamp and send SMS
|
|
110
|
+
random_code_resp = await self._get_random_code(telephone)
|
|
111
|
+
timestamp = random_code_resp.timestamp
|
|
112
|
+
|
|
113
|
+
sms_payload = SendSmsDTO(
|
|
114
|
+
telephone=telephone,
|
|
115
|
+
timestamp=timestamp,
|
|
116
|
+
token=pre_auth_response.token,
|
|
117
|
+
sign=sign,
|
|
118
|
+
nationcode=country_code,
|
|
119
|
+
).to_dict()
|
|
120
|
+
await self._client.post_json(
|
|
121
|
+
"/api/user/sms/validcode/send", SendSmsVO, json=sms_payload
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
async def _token(self) -> None:
|
|
125
|
+
"""Get a random code."""
|
|
126
|
+
await self._client.post_json(
|
|
127
|
+
"/api/user/query/token", QueryTokenVO, json=QueryTokenDTO().to_dict()
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
async def _get_random_code(self, email: str) -> RandomCodeVO:
|
|
131
|
+
"""Get a random code."""
|
|
132
|
+
payload = RandomCodeDTO(account=email).to_dict()
|
|
133
|
+
return await self._client.post_json(
|
|
134
|
+
"/api/official/user/query/random/code", RandomCodeVO, json=payload
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
async def _get_access_token(
|
|
138
|
+
self, email: str, encoded_password: str, random_code_timestamp: str
|
|
139
|
+
) -> LoginVO:
|
|
140
|
+
"""Get an access token."""
|
|
141
|
+
payload = LoginDTO(
|
|
142
|
+
account=email,
|
|
143
|
+
password=encoded_password,
|
|
144
|
+
login_method=LoginMethod.PHONE if email.isdigit() else LoginMethod.EMAIL,
|
|
145
|
+
timestamp=random_code_timestamp,
|
|
146
|
+
).to_dict()
|
|
147
|
+
try:
|
|
148
|
+
return await self._client.post_json(
|
|
149
|
+
"/api/official/user/account/login/new", LoginVO, json=payload
|
|
150
|
+
)
|
|
151
|
+
except ApiException as err:
|
|
152
|
+
if "verification code" in str(err):
|
|
153
|
+
raise SmsVerificationRequired(str(err), random_code_timestamp) from err
|
|
154
|
+
raise
|