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.
Files changed (138) hide show
  1. supernote/__init__.py +28 -0
  2. supernote/alembic/__init__.py +0 -0
  3. supernote/alembic/env.py +121 -0
  4. supernote/alembic/script.py.mako +28 -0
  5. supernote/alembic/versions/0543a383957b_initial_schema.py +312 -0
  6. supernote/alembic.ini +149 -0
  7. supernote/cli/__init__.py +1 -0
  8. supernote/cli/admin.py +178 -0
  9. supernote/cli/client.py +434 -0
  10. supernote/cli/main.py +61 -0
  11. supernote/cli/notebook.py +313 -0
  12. supernote/cli/server.py +89 -0
  13. supernote/client/__init__.py +28 -0
  14. supernote/client/admin.py +76 -0
  15. supernote/client/api.py +135 -0
  16. supernote/client/api_model.py +231 -0
  17. supernote/client/auth.py +84 -0
  18. supernote/client/client.py +365 -0
  19. supernote/client/device.py +361 -0
  20. supernote/client/exceptions.py +33 -0
  21. supernote/client/extended.py +20 -0
  22. supernote/client/hashing.py +50 -0
  23. supernote/client/login_client.py +154 -0
  24. supernote/client/schedule.py +149 -0
  25. supernote/client/summary.py +156 -0
  26. supernote/client/web.py +213 -0
  27. supernote/models/__init__.py +14 -0
  28. supernote/models/auth.py +380 -0
  29. supernote/models/base.py +266 -0
  30. supernote/models/equipment.py +276 -0
  31. supernote/models/extended.py +124 -0
  32. supernote/models/file_common.py +95 -0
  33. supernote/models/file_device.py +571 -0
  34. supernote/models/file_web.py +602 -0
  35. supernote/models/schedule.py +663 -0
  36. supernote/models/summary.py +830 -0
  37. supernote/models/system.py +431 -0
  38. supernote/models/user.py +638 -0
  39. supernote/notebook/__init__.py +67 -0
  40. supernote/notebook/color.py +93 -0
  41. supernote/notebook/converter.py +560 -0
  42. supernote/notebook/decoder.py +398 -0
  43. supernote/notebook/exceptions.py +43 -0
  44. supernote/notebook/fileformat.py +463 -0
  45. supernote/notebook/manipulator.py +420 -0
  46. supernote/notebook/parser.py +737 -0
  47. supernote/notebook/utils.py +49 -0
  48. supernote/py.typed +0 -0
  49. supernote/server/__init__.py +12 -0
  50. supernote/server/app.py +428 -0
  51. supernote/server/config.py +265 -0
  52. supernote/server/constants.py +26 -0
  53. supernote/server/db/__init__.py +9 -0
  54. supernote/server/db/base.py +8 -0
  55. supernote/server/db/migrations.py +44 -0
  56. supernote/server/db/models/__init__.py +23 -0
  57. supernote/server/db/models/device.py +17 -0
  58. supernote/server/db/models/file.py +107 -0
  59. supernote/server/db/models/kv.py +17 -0
  60. supernote/server/db/models/login_record.py +22 -0
  61. supernote/server/db/models/note_processing.py +100 -0
  62. supernote/server/db/models/schedule.py +83 -0
  63. supernote/server/db/models/summary.py +122 -0
  64. supernote/server/db/models/user.py +30 -0
  65. supernote/server/db/session.py +88 -0
  66. supernote/server/events.py +63 -0
  67. supernote/server/exceptions.py +153 -0
  68. supernote/server/mcp/__init__.py +1 -0
  69. supernote/server/mcp/models.py +109 -0
  70. supernote/server/mcp/server.py +176 -0
  71. supernote/server/resources/__init__.py +0 -0
  72. supernote/server/resources/prompts/__init__.py +0 -0
  73. supernote/server/resources/prompts/ocr/common/context.md +19 -0
  74. supernote/server/resources/prompts/ocr/common/legend.md +11 -0
  75. supernote/server/resources/prompts/ocr/daily/prompt.md +12 -0
  76. supernote/server/resources/prompts/ocr/default/system.md +14 -0
  77. supernote/server/resources/prompts/ocr/monthly/prompt.md +22 -0
  78. supernote/server/resources/prompts/ocr/weekly/prompt.md +16 -0
  79. supernote/server/resources/prompts/summary/common/instruction.md +8 -0
  80. supernote/server/resources/prompts/summary/daily/prompt.md +6 -0
  81. supernote/server/resources/prompts/summary/default/prompt.md +4 -0
  82. supernote/server/resources/prompts/summary/monthly/prompt.md +6 -0
  83. supernote/server/resources/prompts/summary/weekly/prompt.md +6 -0
  84. supernote/server/routes/admin.py +109 -0
  85. supernote/server/routes/auth.py +337 -0
  86. supernote/server/routes/decorators.py +13 -0
  87. supernote/server/routes/extended.py +139 -0
  88. supernote/server/routes/file_device.py +593 -0
  89. supernote/server/routes/file_web.py +597 -0
  90. supernote/server/routes/oss.py +317 -0
  91. supernote/server/routes/schedule.py +223 -0
  92. supernote/server/routes/summary.py +390 -0
  93. supernote/server/routes/system.py +57 -0
  94. supernote/server/services/__init__.py +15 -0
  95. supernote/server/services/blob.py +214 -0
  96. supernote/server/services/coordination.py +171 -0
  97. supernote/server/services/file.py +970 -0
  98. supernote/server/services/gemini.py +66 -0
  99. supernote/server/services/integrity.py +112 -0
  100. supernote/server/services/processor.py +331 -0
  101. supernote/server/services/processor_modules/__init__.py +145 -0
  102. supernote/server/services/processor_modules/gemini_embedding.py +112 -0
  103. supernote/server/services/processor_modules/gemini_ocr.py +149 -0
  104. supernote/server/services/processor_modules/page_hashing.py +220 -0
  105. supernote/server/services/processor_modules/png_conversion.py +107 -0
  106. supernote/server/services/processor_modules/summary.py +302 -0
  107. supernote/server/services/prompt_loader.py +133 -0
  108. supernote/server/services/schedule.py +161 -0
  109. supernote/server/services/search.py +250 -0
  110. supernote/server/services/summary.py +419 -0
  111. supernote/server/services/user.py +480 -0
  112. supernote/server/services/vfs.py +476 -0
  113. supernote/server/static/favicon.ico +0 -0
  114. supernote/server/static/index.html +186 -0
  115. supernote/server/static/js/api/client.js +463 -0
  116. supernote/server/static/js/components/FileCard.js +55 -0
  117. supernote/server/static/js/components/FileViewer.js +137 -0
  118. supernote/server/static/js/components/LoginCard.js +64 -0
  119. supernote/server/static/js/components/MoveModal.js +74 -0
  120. supernote/server/static/js/components/RenameModal.js +39 -0
  121. supernote/server/static/js/components/SummaryPanel.js +101 -0
  122. supernote/server/static/js/components/SystemPanel.js +158 -0
  123. supernote/server/static/js/composables/useFileSystem.js +116 -0
  124. supernote/server/static/js/main.js +241 -0
  125. supernote/server/static/style.css +14 -0
  126. supernote/server/utils/hashing.py +18 -0
  127. supernote/server/utils/note_content.py +76 -0
  128. supernote/server/utils/paths.py +58 -0
  129. supernote/server/utils/rate_limit.py +52 -0
  130. supernote/server/utils/tasks.py +71 -0
  131. supernote/server/utils/unique_id.py +48 -0
  132. supernote/server/utils/url_signer.py +218 -0
  133. supernote-0.13.6.dist-info/METADATA +183 -0
  134. supernote-0.13.6.dist-info/RECORD +138 -0
  135. supernote-0.13.6.dist-info/WHEEL +5 -0
  136. supernote-0.13.6.dist-info/entry_points.txt +3 -0
  137. supernote-0.13.6.dist-info/licenses/LICENSE +201 -0
  138. supernote-0.13.6.dist-info/top_level.txt +1 -0
@@ -0,0 +1,231 @@
1
+ """Model classes for the Supernote Service API."""
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+ from mashumaro import field_options
6
+ from mashumaro.config import BaseConfig
7
+ from mashumaro.mixins.json import DataClassJSONMixin
8
+
9
+ COUNTRY_CODE = 1
10
+ BROWSER = "Chrome142"
11
+ EQUIPMENT = 1
12
+ LANGUAGE = "en"
13
+
14
+
15
+ @dataclass
16
+ class BaseResponse(DataClassJSONMixin):
17
+ """Base response class."""
18
+
19
+ success: bool = True
20
+ error_code: str = field(metadata=field_options(alias="errorCode"), default="")
21
+ error_msg: str = field(metadata=field_options(alias="errorMsg"), default="")
22
+
23
+
24
+ @dataclass
25
+ class QueryUserRequest(DataClassJSONMixin):
26
+ """Request to query user."""
27
+
28
+ account: str
29
+ country_code: int = field(
30
+ metadata=field_options(alias="countryCode"), default=COUNTRY_CODE
31
+ )
32
+
33
+ class Config(BaseConfig):
34
+ serialize_by_alias = True
35
+
36
+
37
+ @dataclass(kw_only=True)
38
+ class QueryUserResponse(BaseResponse):
39
+ """Response from query user call."""
40
+
41
+ user_id: str = field(metadata=field_options(alias="userId"))
42
+ user_name: str = field(metadata=field_options(alias="userName"))
43
+ birthday: str = field(metadata=field_options(alias="birthday"))
44
+ country_code: str = field(
45
+ metadata=field_options(alias="countryCode"), default=str(COUNTRY_CODE)
46
+ )
47
+ telephone: str = field(metadata=field_options(alias="telephone"), default="")
48
+ sex: str = ""
49
+ file_server: str = field(metadata=field_options(alias="fileServer"), default="")
50
+
51
+
52
+ @dataclass
53
+ class TokenRequest(DataClassJSONMixin):
54
+ """Request to token endpoint."""
55
+
56
+ class Config(BaseConfig):
57
+ serialize_by_alias = True
58
+
59
+
60
+ @dataclass
61
+ class TokenResponse(BaseResponse):
62
+ """Response from token endpoint."""
63
+
64
+
65
+ @dataclass
66
+ class UserRandomCodeRequest(DataClassJSONMixin):
67
+ """Request to get a random code."""
68
+
69
+ account: str
70
+ country_code: int = field(
71
+ metadata=field_options(alias="countryCode"), default=COUNTRY_CODE
72
+ )
73
+
74
+ class Config(BaseConfig):
75
+ serialize_by_alias = True
76
+
77
+
78
+ @dataclass
79
+ class UserRandomCodeResponse(BaseResponse):
80
+ """Response from login."""
81
+
82
+ random_code: str = field(metadata=field_options(alias="randomCode"), default="")
83
+ timestamp: str = ""
84
+
85
+
86
+ @dataclass
87
+ class UserLoginRequest(DataClassJSONMixin):
88
+ """Request to login."""
89
+
90
+ account: str
91
+ password: str
92
+ login_method: int = field(metadata=field_options(alias="loginMethod"))
93
+ timestamp: str
94
+ language: str = LANGUAGE
95
+ country_code: int = field(
96
+ metadata=field_options(alias="countryCode"), default=COUNTRY_CODE
97
+ )
98
+ browser: str = BROWSER
99
+ equipment: int = EQUIPMENT
100
+
101
+ class Config(BaseConfig):
102
+ serialize_by_alias = True
103
+
104
+
105
+ @dataclass(kw_only=True)
106
+ class UserLoginResponse(BaseResponse):
107
+ """Response from access token call."""
108
+
109
+ token: str
110
+
111
+
112
+ @dataclass
113
+ class UserSmsLoginRequest(DataClassJSONMixin):
114
+ """Request to login via sms."""
115
+
116
+ telephone: str
117
+ timestamp: str
118
+ valid_code: str = field(metadata=field_options(alias="validCode"))
119
+ # String like "1-{telephone}_validCode"
120
+ valid_code_key: str = field(metadata=field_options(alias="validCodeKey"))
121
+
122
+ country_code: int = field(
123
+ metadata=field_options(alias="countryCode"), default=COUNTRY_CODE
124
+ )
125
+ browser: str = BROWSER
126
+ equipment: int = EQUIPMENT
127
+
128
+ class Config(BaseConfig):
129
+ serialize_by_alias = True
130
+
131
+
132
+ @dataclass
133
+ class UserPreAuthRequest(DataClassJSONMixin):
134
+ """Request for pre-auth."""
135
+
136
+ account: str
137
+
138
+
139
+ @dataclass
140
+ class UserPreAuthResponse(BaseResponse):
141
+ """Response from pre-auth."""
142
+
143
+ token: str = ""
144
+
145
+
146
+ @dataclass
147
+ class UserSendSmsRequest(DataClassJSONMixin):
148
+ """Request to send SMS code."""
149
+
150
+ telephone: str
151
+ timestamp: str
152
+ token: str
153
+ sign: str
154
+ nationcode: int = field(
155
+ metadata=field_options(alias="nationcode"), default=COUNTRY_CODE
156
+ )
157
+
158
+ class Config(BaseConfig):
159
+ serialize_by_alias = True
160
+
161
+
162
+ @dataclass
163
+ class UserSendSmsResponse(BaseResponse):
164
+ """Response from send SMS."""
165
+
166
+ valid_code_key: str = field(
167
+ metadata=field_options(alias="validCodeKey"), default=""
168
+ )
169
+
170
+
171
+ @dataclass(kw_only=True)
172
+ class UserSmsLoginResponse(BaseResponse):
173
+ """Response from access token call."""
174
+
175
+ token: str
176
+
177
+
178
+ @dataclass(kw_only=True)
179
+ class File(DataClassJSONMixin):
180
+ """Representation of a file."""
181
+
182
+ id: int
183
+ directory_id: int = field(metadata=field_options(alias="directoryId"))
184
+ file_name: str = field(metadata=field_options(alias="fileName"))
185
+ size: int = 0
186
+ md5: str = ""
187
+ is_folder: str = field(metadata=field_options(alias="isFolder")) # "Y" or "N"
188
+ create_time: int = field(metadata=field_options(alias="createTime"))
189
+ update_time: int = field(metadata=field_options(alias="updateTime"))
190
+
191
+
192
+ @dataclass
193
+ class FileListRequest(DataClassJSONMixin):
194
+ """Request for file list."""
195
+
196
+ directory_id: int = field(metadata=field_options(alias="directoryId"))
197
+ page_no: int = field(metadata=field_options(alias="pageNo"))
198
+ page_size: int = field(metadata=field_options(alias="pageSize"), default=20)
199
+ order: str = "time"
200
+ sequence: str = "desc"
201
+
202
+ class Config(BaseConfig):
203
+ serialize_by_alias = True
204
+
205
+
206
+ @dataclass(kw_only=True)
207
+ class FileListResponse(BaseResponse):
208
+ """Response from file list call."""
209
+
210
+ total: int
211
+ size: int
212
+ pages: int
213
+ file_list: list[File] = field(metadata=field_options(alias="userFileVOList"))
214
+
215
+
216
+ @dataclass
217
+ class GetFileDownloadUrlRequest(DataClassJSONMixin):
218
+ """Request for file download."""
219
+
220
+ file_id: int = field(metadata=field_options(alias="id"))
221
+ file_type: int = field(metadata=field_options(alias="type"), default=0)
222
+
223
+ class Config(BaseConfig):
224
+ serialize_by_alias = True
225
+
226
+
227
+ @dataclass(kw_only=True)
228
+ class GetFileDownloadUrlResponse(BaseResponse):
229
+ """Response from file download call."""
230
+
231
+ url: str
@@ -0,0 +1,84 @@
1
+ """Library for authentication."""
2
+
3
+ import logging
4
+ import os
5
+ import pickle
6
+ from abc import ABC, abstractmethod
7
+
8
+ _LOGGER = logging.getLogger(__name__)
9
+
10
+
11
+ class AbstractAuth(ABC):
12
+ """Authentication library."""
13
+
14
+ @abstractmethod
15
+ async def async_get_access_token(self) -> str:
16
+ """Return a valid access token."""
17
+
18
+
19
+ class ConstantAuth(AbstractAuth):
20
+ """Authentication library."""
21
+
22
+ def __init__(self, access_token: str):
23
+ """Initialize the auth."""
24
+ self._access_token = access_token
25
+
26
+ async def async_get_access_token(self) -> str:
27
+ """Return a valid access token."""
28
+ return self._access_token
29
+
30
+ @property
31
+ def token(self) -> str:
32
+ """Return the access token."""
33
+ return self._access_token
34
+
35
+
36
+ class FileCacheAuth(AbstractAuth):
37
+ """Authentication library that caches token in a file."""
38
+
39
+ def __init__(self, cache_path: str):
40
+ """Initialize the auth."""
41
+ self._cache_path = cache_path
42
+ self._access_token: str | None = None
43
+ self._host: str | None = None
44
+ try:
45
+ self._load_from_cache()
46
+ except ValueError as err:
47
+ _LOGGER.info(f"No cached credentials found at {self._cache_path} ({err})")
48
+
49
+ async def async_get_access_token(self) -> str:
50
+ """Return a valid access token."""
51
+ if self._access_token:
52
+ return self._access_token
53
+ raise ValueError(f"No access token found in cache: {self._cache_path}")
54
+
55
+ def get_host(self) -> str | None:
56
+ """Return the cached host URL."""
57
+ return self._host
58
+
59
+ def _load_from_cache(self) -> None:
60
+ if not os.path.exists(self._cache_path):
61
+ raise ValueError("Cache file does not exist")
62
+ try:
63
+ with open(self._cache_path, "rb") as f:
64
+ data = pickle.load(f)
65
+ except Exception as err:
66
+ raise ValueError(f"Failed to load token from cache: {err}")
67
+ if not isinstance(data, dict):
68
+ raise ValueError("Cache file is not a dictionary")
69
+ self._access_token = data["access_token"]
70
+ if "host" not in data:
71
+ raise ValueError("Cache file is missing host")
72
+ self._host = data["host"]
73
+
74
+ def save_credentials(self, token: str, host: str) -> None:
75
+ """Save access token and host to cache."""
76
+ self._access_token = token
77
+ self._host = host
78
+
79
+ # Ensure directory exists
80
+ os.makedirs(os.path.dirname(self._cache_path), exist_ok=True)
81
+
82
+ with open(self._cache_path, "wb") as f:
83
+ data = {"access_token": token, "host": host}
84
+ pickle.dump(data, f)
@@ -0,0 +1,365 @@
1
+ """Library for accessing backups in Supenote Cloud."""
2
+
3
+ import hashlib
4
+ import logging
5
+ import uuid
6
+ from typing import Any, Type, TypeVar
7
+
8
+ import aiohttp
9
+ from aiohttp import FormData
10
+ from aiohttp.client_exceptions import ClientError
11
+
12
+ from supernote.models.base import BaseResponse
13
+ from supernote.models.system import FileChunkParams, FileChunkVO, UploadFileVO
14
+
15
+ from .auth import AbstractAuth
16
+ from .exceptions import (
17
+ ApiException,
18
+ BadRequestException,
19
+ ForbiddenException,
20
+ NotFoundException,
21
+ UnauthorizedException,
22
+ )
23
+
24
+ _LOGGER = logging.getLogger(__name__)
25
+
26
+ __all__ = [
27
+ "Client",
28
+ ]
29
+
30
+
31
+ _T = TypeVar("_T", bound=BaseResponse)
32
+
33
+ CLOUD_API_URL = "https://cloud.supernote.com"
34
+ HEADERS = {
35
+ "Content-Type": "application/json",
36
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36",
37
+ }
38
+ ACCESS_TOKEN = "x-access-token"
39
+ XSRF_COOKIE = "XSRF-TOKEN"
40
+ XSRF_HEADER = "X-XSRF-TOKEN"
41
+
42
+
43
+ def _create_headers(host: str | None = None) -> dict[str, Any]:
44
+ headers = {
45
+ **HEADERS,
46
+ }
47
+ if host:
48
+ headers["Referer"] = host
49
+ headers["Origin"] = host
50
+ return headers
51
+
52
+
53
+ class Client:
54
+ """Library that makes authenticated HTTP requests."""
55
+
56
+ def __init__(
57
+ self,
58
+ websession: aiohttp.ClientSession,
59
+ host: str | None = None,
60
+ auth: AbstractAuth | None = None,
61
+ ):
62
+ """Initialize the auth."""
63
+ self._websession = websession
64
+ self._host = host or CLOUD_API_URL
65
+ self._auth = auth
66
+ self._xsrf_token: str | None = None
67
+
68
+ @property
69
+ def host(self) -> str:
70
+ """Return the host URL."""
71
+ return self._host
72
+
73
+ def with_auth(self, auth: AbstractAuth) -> "Client":
74
+ """Return a new client with the given authentication credentials."""
75
+ return Client(self._websession, host=self._host, auth=auth)
76
+
77
+ def get_auth(self) -> AbstractAuth | None:
78
+ """Return the current authentication credentials."""
79
+ return self._auth
80
+
81
+ def _url(self, url: str) -> str:
82
+ if not (url.startswith("http://") or url.startswith("https://")):
83
+ if self._host.endswith("/"):
84
+ if url.startswith("/"):
85
+ url = url[1:]
86
+ elif not url.startswith("/"):
87
+ url = f"/{url}"
88
+ url = f"{self._host}{url}"
89
+ return url
90
+
91
+ async def request(
92
+ self,
93
+ method: str,
94
+ url: str,
95
+ headers: dict[str, Any] | None = None,
96
+ **kwargs: Any,
97
+ ) -> aiohttp.ClientResponse:
98
+ """Make a request."""
99
+ if headers is None:
100
+ headers = _create_headers(self._host)
101
+ # Always get a fresh CSRF token
102
+ self._xsrf_token = await self._get_csrf_token()
103
+ headers[XSRF_HEADER] = self._xsrf_token
104
+
105
+ if self._auth and ACCESS_TOKEN not in headers:
106
+ access_token = await self._auth.async_get_access_token()
107
+ headers[ACCESS_TOKEN] = access_token
108
+ url = self._url(url)
109
+ _LOGGER.debug(
110
+ "request[%s]=%s %s %s",
111
+ method,
112
+ url,
113
+ kwargs.get("params"),
114
+ headers,
115
+ )
116
+ if method != "get" and "json" in kwargs:
117
+ _LOGGER.debug("request[post json]=%s", kwargs["json"])
118
+ response = await self._websession.request(
119
+ method, url, **kwargs, headers=headers
120
+ )
121
+ return response
122
+
123
+ async def get(self, url: str, **kwargs: Any) -> aiohttp.ClientResponse:
124
+ """Make a get request."""
125
+ try:
126
+ resp = await self.request("get", url, **kwargs)
127
+ except ClientError as err:
128
+ raise ApiException(f"Error connecting to API: {err}") from err
129
+ return await self._raise_for_status(resp)
130
+
131
+ async def get_json(
132
+ self,
133
+ url: str,
134
+ data_cls: Type[_T],
135
+ **kwargs: Any,
136
+ ) -> _T:
137
+ """Make a get request and return json response."""
138
+ resp = await self.get(url, **kwargs)
139
+ try:
140
+ result = await resp.text()
141
+ except ClientError as err:
142
+ raise ApiException("Server returned malformed response") from err
143
+ _LOGGER.debug("response=%s", result)
144
+ try:
145
+ data_response = data_cls.from_json(result)
146
+ except (LookupError, ValueError) as err:
147
+ raise ApiException(f"Server return malformed response: {result}") from err
148
+ if not data_response.success:
149
+ raise ApiException(data_response.error_msg)
150
+ return data_response
151
+
152
+ async def post(self, url: str, **kwargs: Any) -> aiohttp.ClientResponse:
153
+ """Make a post request."""
154
+ try:
155
+ resp = await self.request("post", url, **kwargs)
156
+ except ClientError as err:
157
+ raise ApiException(f"Error connecting to API: {err}") from err
158
+ return await self._raise_for_status(resp)
159
+
160
+ async def put(self, url: str, **kwargs: Any) -> aiohttp.ClientResponse:
161
+ """Make a put request."""
162
+ try:
163
+ resp = await self.request("put", url, **kwargs)
164
+ except ClientError as err:
165
+ raise ApiException(f"Error connecting to API: {err}") from err
166
+ return await self._raise_for_status(resp)
167
+
168
+ async def get_content(self, url: str, **kwargs: Any) -> bytes:
169
+ """Make a get request and return bytes."""
170
+ resp = await self.get(url, **kwargs)
171
+ try:
172
+ return await resp.read()
173
+ except ClientError as err:
174
+ raise ApiException(f"Error reading response: {err}") from err
175
+
176
+ async def post_json(self, url: str, data_cls: Type[_T], **kwargs: Any) -> _T:
177
+ """Make a post request and return a json response."""
178
+ resp = await self.post(url, **kwargs)
179
+ try:
180
+ result = await resp.text()
181
+ except ClientError as err:
182
+ raise ApiException("Server returned malformed response") from err
183
+ try:
184
+ data_response = data_cls.from_json(result)
185
+ except (LookupError, ValueError) as err:
186
+ raise ApiException(
187
+ f"Server return malformed response type {data_cls.__name__}: {result}"
188
+ ) from err
189
+ if not data_response.success:
190
+ raise ApiException(data_response.error_msg)
191
+ return data_response
192
+
193
+ async def put_json(self, url: str, data_cls: Type[_T], **kwargs: Any) -> _T:
194
+ """Make a put request and return a json response."""
195
+ resp = await self.put(url, **kwargs)
196
+ try:
197
+ result = await resp.text()
198
+ except ClientError as err:
199
+ raise ApiException("Server returned malformed response") from err
200
+ try:
201
+ data_response = data_cls.from_json(result)
202
+ except (LookupError, ValueError) as err:
203
+ raise ApiException(
204
+ f"Server return malformed response type {data_cls.__name__}: {result}"
205
+ ) from err
206
+ if not data_response.success:
207
+ raise ApiException(data_response.error_msg)
208
+ return data_response
209
+
210
+ async def _get_csrf_token(self) -> str:
211
+ """Get the CSRF token."""
212
+ url = self._url("/api/csrf")
213
+ _LOGGER.debug("CSRF request[get]=%s %s", url, HEADERS)
214
+ resp = await self._websession.request("get", url, headers=HEADERS)
215
+ try:
216
+ await resp.text()
217
+ except ClientError as err:
218
+ raise ApiException("Server returned malformed response") from err
219
+ # Can be added back later for debugging
220
+ # _LOGGER.debug("CSRF response headers=%s", resp.headers)
221
+ token = resp.headers.get(XSRF_HEADER)
222
+ if token is None:
223
+ raise ApiException("Failed to get CSRF token from header")
224
+ return token
225
+
226
+ @classmethod
227
+ async def _raise_for_status(
228
+ cls, resp: aiohttp.ClientResponse
229
+ ) -> aiohttp.ClientResponse:
230
+ """Raise exceptions on failure methods."""
231
+ error_detail = await cls._error_detail(resp)
232
+ try:
233
+ resp.raise_for_status()
234
+ except aiohttp.ClientResponseError as err:
235
+ if err.status == 401:
236
+ raise UnauthorizedException(
237
+ f"Unauthorized response from API ({err.status}): {error_detail}"
238
+ ) from err
239
+ if err.status == 403:
240
+ raise ForbiddenException(
241
+ f"Forbidden response from API ({err.status}): {error_detail}"
242
+ ) from err
243
+ if err.status == 404:
244
+ raise NotFoundException(
245
+ f"Not found response from API ({err.status}): {error_detail}"
246
+ ) from err
247
+ if err.status == 400:
248
+ raise BadRequestException(
249
+ f"Bad request response from API ({err.status}): {error_detail}"
250
+ ) from err
251
+ error_message = f"Error response from API ({err.status}): {error_detail}"
252
+ raise ApiException(error_message) from err
253
+ except aiohttp.ClientError as err:
254
+ raise ApiException(f"Error from API: {err}") from err
255
+ return resp
256
+
257
+ @classmethod
258
+ async def _error_detail(cls, resp: aiohttp.ClientResponse) -> str | None:
259
+ """Returns an error message string from the APi response."""
260
+ if resp.status < 400:
261
+ return None
262
+ try:
263
+ result = await resp.text()
264
+ except ClientError:
265
+ return None
266
+ return result
267
+
268
+ async def _upload_to_oss(
269
+ self,
270
+ content: bytes,
271
+ filename: str,
272
+ full_upload_url: str | None,
273
+ part_upload_url: str | None,
274
+ chunk_size: int = 5 * 1024 * 1024,
275
+ ) -> None:
276
+ """Upload content to OSS (support single or multi-part)."""
277
+ size = len(content)
278
+
279
+ if size < chunk_size or part_upload_url is None:
280
+ if full_upload_url is None:
281
+ raise ValueError("No upload URL available")
282
+
283
+ # Compute MD5 of content for verification
284
+ content_md5 = hashlib.md5(content).hexdigest()
285
+
286
+ _LOGGER.debug(
287
+ "Uploading file %s in one chunk (MD5: %s)", filename, content_md5
288
+ )
289
+ data = FormData()
290
+ data.add_field("file", content, filename=filename)
291
+ # Pass empty dict to headers to avoid default application/json Content-Type
292
+ try:
293
+ resp = await self.request(
294
+ "post",
295
+ full_upload_url,
296
+ data=data,
297
+ headers={},
298
+ )
299
+ except ClientError as err:
300
+ raise ApiException("Failed to upload file") from err
301
+ # Parse the UploadFileVO response
302
+ try:
303
+ result = await resp.text()
304
+ except ClientError:
305
+ raise ApiException("Server returned malformed response")
306
+ _LOGGER.debug("Upload response: %s", result)
307
+ try:
308
+ upload_vo = UploadFileVO.from_json(result)
309
+ except (LookupError, ValueError) as err:
310
+ raise ApiException(
311
+ f"Server returned malformed upload response: {result}"
312
+ ) from err
313
+ if not upload_vo.success:
314
+ raise ApiException(f"Upload failed: {upload_vo.error_msg}")
315
+
316
+ # Verify MD5 matches
317
+ if upload_vo.md5 != content_md5:
318
+ raise ApiException(
319
+ f"MD5 mismatch: client={content_md5}, server={upload_vo.md5}"
320
+ )
321
+ return
322
+
323
+ upload_id = uuid.uuid4().hex
324
+ # Break into chunks
325
+ chunks = [content[i : i + chunk_size] for i in range(0, size, chunk_size)]
326
+ for i, chunk in enumerate(chunks):
327
+ chunk_md5 = hashlib.md5(chunk).hexdigest()
328
+ _LOGGER.debug(f"Uploading chunk {i + 1} of {size} ({len(chunk)} bytes)")
329
+ data = FormData()
330
+ data.add_field("file", chunk, filename=filename)
331
+ params = FileChunkParams(
332
+ upload_id=upload_id,
333
+ part_number=i + 1,
334
+ total_chunks=len(chunks),
335
+ )
336
+ try:
337
+ resp = await self.request(
338
+ "post",
339
+ part_upload_url,
340
+ data=data,
341
+ params={k: v for k, v in params.to_dict().items() if v is not None},
342
+ headers={},
343
+ )
344
+ except ApiException as err:
345
+ raise ApiException(f"Chunk upload failed: {err}")
346
+ try:
347
+ result = await resp.text()
348
+ except ClientError as err:
349
+ raise ApiException("Failed to get chunk response") from err
350
+ try:
351
+ _LOGGER.debug("Chunk response: %s", result)
352
+ chunk_vo = FileChunkVO.from_json(result)
353
+ except (LookupError, ValueError) as err:
354
+ raise ApiException(
355
+ f"Server returned malformed chunk response: {result}"
356
+ ) from err
357
+
358
+ if not chunk_vo.success:
359
+ raise ApiException(f"Chunk upload failed: {chunk_vo.error_msg}")
360
+
361
+ # Verify MD5 matches
362
+ if chunk_vo.chunk_md5 != chunk_md5:
363
+ raise ApiException(
364
+ f"Chunk {i + 1} MD5 mismatch: client={chunk_md5}, server={chunk_vo.chunk_md5}"
365
+ )