supernote 0.2.0__tar.gz → 0.3.0__tar.gz
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-0.2.0/supernote.egg-info → supernote-0.3.0}/PKG-INFO +4 -2
- {supernote-0.2.0 → supernote-0.3.0}/README.md +1 -1
- {supernote-0.2.0 → supernote-0.3.0}/pyproject.toml +9 -1
- {supernote-0.2.0 → supernote-0.3.0}/supernote/__init__.py +2 -0
- supernote-0.3.0/supernote/cloud/api_model.py +231 -0
- supernote-0.3.0/supernote/cloud/auth.py +67 -0
- supernote-0.3.0/supernote/cloud/client.py +184 -0
- supernote-0.3.0/supernote/cloud/cloud_client.py +48 -0
- supernote-0.3.0/supernote/cloud/exceptions.py +25 -0
- supernote-0.3.0/supernote/cloud/login_client.py +174 -0
- supernote-0.3.0/supernote/cmds/__init__.py +1 -0
- supernote-0.3.0/supernote/cmds/cloud_login_tool.py +247 -0
- {supernote-0.2.0 → supernote-0.3.0}/supernote/cmds/supernote_tool.py +21 -0
- {supernote-0.2.0 → supernote-0.3.0/supernote.egg-info}/PKG-INFO +4 -2
- {supernote-0.2.0 → supernote-0.3.0}/supernote.egg-info/SOURCES.txt +7 -0
- {supernote-0.2.0 → supernote-0.3.0}/supernote.egg-info/requires.txt +2 -0
- supernote-0.2.0/supernote/cmds/__init__.py +0 -0
- {supernote-0.2.0 → supernote-0.3.0}/LICENSE +0 -0
- {supernote-0.2.0 → supernote-0.3.0}/setup.cfg +0 -0
- {supernote-0.2.0 → supernote-0.3.0}/supernote/color.py +0 -0
- {supernote-0.2.0 → supernote-0.3.0}/supernote/converter.py +0 -0
- {supernote-0.2.0 → supernote-0.3.0}/supernote/decoder.py +0 -0
- {supernote-0.2.0 → supernote-0.3.0}/supernote/exceptions.py +0 -0
- {supernote-0.2.0 → supernote-0.3.0}/supernote/fileformat.py +0 -0
- {supernote-0.2.0 → supernote-0.3.0}/supernote/manipulator.py +0 -0
- {supernote-0.2.0 → supernote-0.3.0}/supernote/parser.py +0 -0
- {supernote-0.2.0 → supernote-0.3.0}/supernote/utils.py +0 -0
- {supernote-0.2.0 → supernote-0.3.0}/supernote.egg-info/dependency_links.txt +0 -0
- {supernote-0.2.0 → supernote-0.3.0}/supernote.egg-info/entry_points.txt +0 -0
- {supernote-0.2.0 → supernote-0.3.0}/supernote.egg-info/top_level.txt +0 -0
- {supernote-0.2.0 → supernote-0.3.0}/tests/test_init.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: supernote
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Unofficial python library for parsing Supernote notebooks
|
|
5
5
|
Author-email: jya <jya@wizmy.net>, Allen Porter <allen.porter@gmail.com>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -15,6 +15,8 @@ Requires-Dist: potracer>=0.0.1
|
|
|
15
15
|
Requires-Dist: pypng>=0.0.20
|
|
16
16
|
Requires-Dist: reportlab>=3.6.1
|
|
17
17
|
Requires-Dist: svgwrite>=1.4
|
|
18
|
+
Requires-Dist: aiohttp>=3.13.2
|
|
19
|
+
Requires-Dist: mashumaro>=3.17
|
|
18
20
|
Dynamic: license-file
|
|
19
21
|
|
|
20
22
|
# supernote
|
|
@@ -28,7 +30,7 @@ having a similar dependency limitation.
|
|
|
28
30
|
## Development
|
|
29
31
|
|
|
30
32
|
```
|
|
31
|
-
uv venv
|
|
33
|
+
uv venv --python=3.14
|
|
32
34
|
source .venv/bin/activate
|
|
33
35
|
uv pip install -r requirements_dev.txt
|
|
34
36
|
```
|
|
@@ -4,7 +4,7 @@ requires = ["setuptools>=77.0"]
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "supernote"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
license = "Apache-2.0"
|
|
9
9
|
license-files = ["LICENSE"]
|
|
10
10
|
description = "Unofficial python library for parsing Supernote notebooks"
|
|
@@ -16,6 +16,7 @@ authors = [
|
|
|
16
16
|
requires-python = ">=3.13"
|
|
17
17
|
classifiers = []
|
|
18
18
|
dependencies = [
|
|
19
|
+
# Notebook dependencies
|
|
19
20
|
"colour>=0.1.5",
|
|
20
21
|
"numpy>=1.19.0",
|
|
21
22
|
"Pillow>=7.2.0",
|
|
@@ -23,6 +24,10 @@ dependencies = [
|
|
|
23
24
|
"pypng>=0.0.20",
|
|
24
25
|
"reportlab>=3.6.1",
|
|
25
26
|
"svgwrite>=1.4",
|
|
27
|
+
|
|
28
|
+
# Cloud dependencies
|
|
29
|
+
"aiohttp>=3.13.2",
|
|
30
|
+
"mashumaro>=3.17",
|
|
26
31
|
]
|
|
27
32
|
|
|
28
33
|
[project.urls]
|
|
@@ -61,3 +66,6 @@ disallow_untyped_decorators = true
|
|
|
61
66
|
disallow_untyped_defs = true
|
|
62
67
|
warn_return_any = true
|
|
63
68
|
warn_unreachable = true
|
|
69
|
+
|
|
70
|
+
[tool.pytest.ini_options]
|
|
71
|
+
asyncio_mode = "auto"
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Model classes for the Supernote Cloud API."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
from mashumaro import field_options
|
|
6
|
+
from mashumaro.mixins.json import DataClassJSONMixin
|
|
7
|
+
from mashumaro.config import BaseConfig
|
|
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=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,67 @@
|
|
|
1
|
+
"""Library for authentication."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
import logging
|
|
5
|
+
import pickle
|
|
6
|
+
import os
|
|
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
|
+
|
|
31
|
+
class FileCacheAuth(AbstractAuth):
|
|
32
|
+
"""Authentication library that caches token in a file."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, cache_path: str):
|
|
35
|
+
"""Initialize the auth."""
|
|
36
|
+
self._cache_path = cache_path
|
|
37
|
+
self._access_token = None
|
|
38
|
+
|
|
39
|
+
async def async_get_access_token(self) -> str:
|
|
40
|
+
"""Return a valid access token."""
|
|
41
|
+
if self._access_token:
|
|
42
|
+
return self._access_token
|
|
43
|
+
|
|
44
|
+
if os.path.exists(self._cache_path):
|
|
45
|
+
try:
|
|
46
|
+
with open(self._cache_path, "rb") as f:
|
|
47
|
+
data = pickle.load(f)
|
|
48
|
+
if isinstance(data, dict) and "access_token" in data:
|
|
49
|
+
self._access_token = data["access_token"]
|
|
50
|
+
return self._access_token
|
|
51
|
+
except Exception as err:
|
|
52
|
+
_LOGGER.warning("Failed to load token from cache: %s", err)
|
|
53
|
+
|
|
54
|
+
raise ValueError("No access token found in cache")
|
|
55
|
+
|
|
56
|
+
def save_access_token(self, token: str) -> None:
|
|
57
|
+
"""Save access token to cache."""
|
|
58
|
+
self._access_token = token
|
|
59
|
+
|
|
60
|
+
# Ensure directory exists
|
|
61
|
+
os.makedirs(os.path.dirname(self._cache_path), exist_ok=True)
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
with open(self._cache_path, "wb") as f:
|
|
65
|
+
pickle.dump({"access_token": token}, f)
|
|
66
|
+
except Exception as err:
|
|
67
|
+
_LOGGER.warning("Failed to save token to cache: %s", err)
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Library for accessing backups in Supenote Cloud."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Any, Type, TypeVar
|
|
5
|
+
|
|
6
|
+
import aiohttp
|
|
7
|
+
from aiohttp.client_exceptions import ClientError
|
|
8
|
+
|
|
9
|
+
from .api_model import BaseResponse
|
|
10
|
+
from .exceptions import ApiException, UnauthorizedException, ForbiddenException
|
|
11
|
+
from .auth import AbstractAuth
|
|
12
|
+
|
|
13
|
+
_LOGGER = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
API_URL = "https://cloud.supernote.com/api"
|
|
16
|
+
HEADERS = {
|
|
17
|
+
"Content-Type": "application/json",
|
|
18
|
+
"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",
|
|
19
|
+
"Referer": "https://cloud.supernote.com/",
|
|
20
|
+
"Origin": "https://cloud.supernote.com",
|
|
21
|
+
}
|
|
22
|
+
ACCESS_TOKEN = "x-access-token"
|
|
23
|
+
XSRF_COOKIE = "XSRF-TOKEN"
|
|
24
|
+
XSRF_HEADER = "X-XSRF-TOKEN"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
_T = TypeVar("_T", bound=BaseResponse)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Client:
|
|
31
|
+
"""Library that makes authenticated HTTP requests."""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
websession: aiohttp.ClientSession,
|
|
36
|
+
host: str | None = None,
|
|
37
|
+
auth: AbstractAuth | None = None,
|
|
38
|
+
):
|
|
39
|
+
"""Initialize the auth."""
|
|
40
|
+
self._websession = websession
|
|
41
|
+
self._host = host or API_URL
|
|
42
|
+
self._auth = auth
|
|
43
|
+
self._xsrf_token: str | None = None
|
|
44
|
+
|
|
45
|
+
async def request(
|
|
46
|
+
self,
|
|
47
|
+
method: str,
|
|
48
|
+
url: str,
|
|
49
|
+
headers: dict[str, Any] | None = None,
|
|
50
|
+
**kwargs: Any,
|
|
51
|
+
) -> aiohttp.ClientResponse:
|
|
52
|
+
"""Make a request."""
|
|
53
|
+
if headers is None:
|
|
54
|
+
headers = {
|
|
55
|
+
**HEADERS,
|
|
56
|
+
}
|
|
57
|
+
# Always get a fresh CSRF token
|
|
58
|
+
self._xsrf_token = await self._get_csrf_token()
|
|
59
|
+
headers[XSRF_HEADER] = self._xsrf_token
|
|
60
|
+
|
|
61
|
+
if self._auth and ACCESS_TOKEN not in headers:
|
|
62
|
+
access_token = await self._auth.async_get_access_token()
|
|
63
|
+
headers[ACCESS_TOKEN] = access_token
|
|
64
|
+
if not (url.startswith("http://") or url.startswith("https://")):
|
|
65
|
+
url = f"{self._host}/{url}"
|
|
66
|
+
_LOGGER.debug(
|
|
67
|
+
"request[%s]=%s %s %s",
|
|
68
|
+
method,
|
|
69
|
+
url,
|
|
70
|
+
kwargs.get("params"),
|
|
71
|
+
headers,
|
|
72
|
+
)
|
|
73
|
+
if method != "get" and "json" in kwargs:
|
|
74
|
+
_LOGGER.debug("request[post json]=%s", kwargs["json"])
|
|
75
|
+
response = await self._websession.request(
|
|
76
|
+
method, url, **kwargs, headers=headers
|
|
77
|
+
)
|
|
78
|
+
return response
|
|
79
|
+
|
|
80
|
+
async def get(self, url: str, **kwargs: Any) -> aiohttp.ClientResponse:
|
|
81
|
+
"""Make a get request."""
|
|
82
|
+
try:
|
|
83
|
+
resp = await self.request("get", url, **kwargs)
|
|
84
|
+
except ClientError as err:
|
|
85
|
+
raise ApiException(f"Error connecting to API: {err}") from err
|
|
86
|
+
return await self._raise_for_status(resp)
|
|
87
|
+
|
|
88
|
+
async def get_json(
|
|
89
|
+
self,
|
|
90
|
+
url: str,
|
|
91
|
+
data_cls: Type[_T],
|
|
92
|
+
**kwargs: Any,
|
|
93
|
+
) -> _T:
|
|
94
|
+
"""Make a get request and return json response."""
|
|
95
|
+
resp = await self.get(url, **kwargs)
|
|
96
|
+
try:
|
|
97
|
+
result = await resp.text()
|
|
98
|
+
except ClientError as err:
|
|
99
|
+
raise ApiException("Server returned malformed response") from err
|
|
100
|
+
_LOGGER.debug("response=%s", result)
|
|
101
|
+
try:
|
|
102
|
+
data_response = data_cls.from_json(result)
|
|
103
|
+
except (LookupError, ValueError) as err:
|
|
104
|
+
raise ApiException(f"Server return malformed response: {result}") from err
|
|
105
|
+
if not data_response.success:
|
|
106
|
+
raise ApiException(data_response.error_msg)
|
|
107
|
+
return data_response
|
|
108
|
+
|
|
109
|
+
async def post(self, url: str, **kwargs: Any) -> aiohttp.ClientResponse:
|
|
110
|
+
"""Make a post request."""
|
|
111
|
+
try:
|
|
112
|
+
resp = await self.request("post", url, **kwargs)
|
|
113
|
+
except ClientError as err:
|
|
114
|
+
raise ApiException(f"Error connecting to API: {err}") from err
|
|
115
|
+
return await self._raise_for_status(resp)
|
|
116
|
+
|
|
117
|
+
async def post_json(self, url: str, data_cls: Type[_T], **kwargs: Any) -> _T:
|
|
118
|
+
"""Make a post request and return a json response."""
|
|
119
|
+
resp = await self.post(url, **kwargs)
|
|
120
|
+
try:
|
|
121
|
+
result = await resp.text()
|
|
122
|
+
except ClientError as err:
|
|
123
|
+
raise ApiException("Server returned malformed response") from err
|
|
124
|
+
try:
|
|
125
|
+
data_response = data_cls.from_json(result)
|
|
126
|
+
except (LookupError, ValueError) as err:
|
|
127
|
+
raise ApiException(f"Server return malformed response: {result}") from err
|
|
128
|
+
if not data_response.success:
|
|
129
|
+
raise ApiException(data_response.error_msg)
|
|
130
|
+
return data_response
|
|
131
|
+
|
|
132
|
+
async def _get_csrf_token(self) -> str:
|
|
133
|
+
"""Get the CSRF token."""
|
|
134
|
+
url = f"{self._host}/csrf"
|
|
135
|
+
_LOGGER.debug("CSRF request[get]=%s %s", url, HEADERS)
|
|
136
|
+
resp = await self._websession.request("get", url, headers=HEADERS)
|
|
137
|
+
try:
|
|
138
|
+
result = await resp.text()
|
|
139
|
+
except ClientError as err:
|
|
140
|
+
raise ApiException("Server returned malformed response") from err
|
|
141
|
+
_LOGGER.debug("CSRF response=%s", result)
|
|
142
|
+
_LOGGER.debug("CSRF response headers=%s", resp.headers)
|
|
143
|
+
token = resp.headers.get(XSRF_HEADER)
|
|
144
|
+
if token is None:
|
|
145
|
+
raise ApiException("Failed to get CSRF token from header")
|
|
146
|
+
_LOGGER.debug("CSRF token=%s", token)
|
|
147
|
+
_LOGGER.debug("CSRF response cookies=%s", resp.cookies)
|
|
148
|
+
return token
|
|
149
|
+
|
|
150
|
+
@classmethod
|
|
151
|
+
async def _raise_for_status(
|
|
152
|
+
cls, resp: aiohttp.ClientResponse
|
|
153
|
+
) -> aiohttp.ClientResponse:
|
|
154
|
+
"""Raise exceptions on failure methods."""
|
|
155
|
+
error_detail = await cls._error_detail(resp)
|
|
156
|
+
try:
|
|
157
|
+
resp.raise_for_status()
|
|
158
|
+
except aiohttp.ClientResponseError as err:
|
|
159
|
+
if err.status == 401:
|
|
160
|
+
error_message = (
|
|
161
|
+
f"Unauthorized response from API ({err.status}): {error_detail}"
|
|
162
|
+
)
|
|
163
|
+
raise UnauthorizedException(error_message) from err
|
|
164
|
+
if err.status == 403:
|
|
165
|
+
error_message = (
|
|
166
|
+
f"Forbidden response from API ({err.status}): {error_detail}"
|
|
167
|
+
)
|
|
168
|
+
raise ForbiddenException(error_message) from err
|
|
169
|
+
error_message = f"Error response from API ({err.status}): {error_detail}"
|
|
170
|
+
raise ApiException(error_message) from err
|
|
171
|
+
except aiohttp.ClientError as err:
|
|
172
|
+
raise ApiException(f"Error from API: {err}") from err
|
|
173
|
+
return resp
|
|
174
|
+
|
|
175
|
+
@classmethod
|
|
176
|
+
async def _error_detail(cls, resp: aiohttp.ClientResponse) -> str | None:
|
|
177
|
+
"""Returns an error message string from the APi response."""
|
|
178
|
+
if resp.status < 400:
|
|
179
|
+
return None
|
|
180
|
+
try:
|
|
181
|
+
result = await resp.text()
|
|
182
|
+
except ClientError:
|
|
183
|
+
return None
|
|
184
|
+
return result
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Library for accessing backups in Supenote Cloud."""
|
|
2
|
+
|
|
3
|
+
from .api_model import (
|
|
4
|
+
FileListResponse,
|
|
5
|
+
GetFileDownloadUrlRequest,
|
|
6
|
+
GetFileDownloadUrlResponse,
|
|
7
|
+
FileListRequest,
|
|
8
|
+
QueryUserResponse,
|
|
9
|
+
QueryUserRequest,
|
|
10
|
+
)
|
|
11
|
+
from .client import Client
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SupernoteCloudClient:
|
|
15
|
+
"""A client library for Supernote Cloud."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, client: Client):
|
|
18
|
+
"""Initialize the client."""
|
|
19
|
+
self._client = client
|
|
20
|
+
|
|
21
|
+
async def query_user(self, account: str) -> QueryUserResponse:
|
|
22
|
+
"""Query the user."""
|
|
23
|
+
payload = QueryUserRequest(country_code=1, account=account).to_dict()
|
|
24
|
+
return await self._client.post_json(
|
|
25
|
+
"user/query", QueryUserResponse, json=payload
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
async def file_list(self, directory_id: int = 0) -> FileListResponse:
|
|
29
|
+
"""Return a list of files."""
|
|
30
|
+
payload = FileListRequest(
|
|
31
|
+
directory_id=directory_id,
|
|
32
|
+
page_no=1,
|
|
33
|
+
page_size=100,
|
|
34
|
+
order="time",
|
|
35
|
+
sequence="desc",
|
|
36
|
+
).to_dict()
|
|
37
|
+
return await self._client.post_json(
|
|
38
|
+
"file/list/query", FileListResponse, json=payload
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
async def file_download(self, file_id: int) -> bytes:
|
|
42
|
+
"""Download a file."""
|
|
43
|
+
payload = GetFileDownloadUrlRequest(file_id=file_id, file_type=0).to_dict()
|
|
44
|
+
download_url_response = await self._client.post_json(
|
|
45
|
+
"file/download/url", GetFileDownloadUrlResponse, json=payload
|
|
46
|
+
)
|
|
47
|
+
response = await self._client.get(download_url_response.url)
|
|
48
|
+
return await response.read()
|
|
@@ -0,0 +1,25 @@
|
|
|
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."""
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Library for accessing backups in Supenote Cloud."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import logging
|
|
5
|
+
from typing import TypeVar
|
|
6
|
+
|
|
7
|
+
from mashumaro.mixins.json import DataClassJSONMixin
|
|
8
|
+
|
|
9
|
+
from .api_model import (
|
|
10
|
+
UserLoginRequest,
|
|
11
|
+
UserLoginResponse,
|
|
12
|
+
UserRandomCodeRequest,
|
|
13
|
+
UserRandomCodeResponse,
|
|
14
|
+
UserSmsLoginRequest,
|
|
15
|
+
UserSmsLoginResponse,
|
|
16
|
+
UserPreAuthRequest,
|
|
17
|
+
UserPreAuthResponse,
|
|
18
|
+
UserSendSmsRequest,
|
|
19
|
+
UserSendSmsResponse,
|
|
20
|
+
TokenRequest,
|
|
21
|
+
TokenResponse,
|
|
22
|
+
)
|
|
23
|
+
from .client import Client
|
|
24
|
+
from .exceptions import ApiException, SmsVerificationRequired
|
|
25
|
+
|
|
26
|
+
_LOGGER = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
_T = TypeVar("_T", bound=DataClassJSONMixin)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _sha256_s(s: str) -> str:
|
|
33
|
+
return hashlib.sha256(s.encode("utf-8")).hexdigest()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _md5_s(s: str) -> str:
|
|
37
|
+
return hashlib.md5(s.encode("utf-8")).hexdigest()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _encode_password(password: str, rc: str) -> str:
|
|
41
|
+
return _sha256_s(_md5_s(password) + rc)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _extract_real_key(token: str) -> str:
|
|
45
|
+
"""Extract real key from token as per JS logic."""
|
|
46
|
+
# var t = e.charAt(e.length - 1)
|
|
47
|
+
# var n = parseInt(t)
|
|
48
|
+
# var a = e.split("-")
|
|
49
|
+
# var o = a[n];
|
|
50
|
+
if not token:
|
|
51
|
+
return ""
|
|
52
|
+
last_char = token[-1]
|
|
53
|
+
try:
|
|
54
|
+
index = int(last_char)
|
|
55
|
+
parts = token.split("-")
|
|
56
|
+
if 0 <= index < len(parts):
|
|
57
|
+
return parts[index]
|
|
58
|
+
except ValueError:
|
|
59
|
+
pass
|
|
60
|
+
return ""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class LoginClient:
|
|
64
|
+
"""A client library for logging in."""
|
|
65
|
+
|
|
66
|
+
def __init__(self, client: Client):
|
|
67
|
+
"""Initialize the client."""
|
|
68
|
+
self._client = client
|
|
69
|
+
|
|
70
|
+
async def login(self, email: str, password: str) -> str:
|
|
71
|
+
"""Log in and return an access token."""
|
|
72
|
+
await self._token()
|
|
73
|
+
random_code_response = await self._get_random_code(email)
|
|
74
|
+
encoded_password = _encode_password(password, random_code_response.random_code)
|
|
75
|
+
access_token_response = await self._get_access_token(
|
|
76
|
+
email, encoded_password, random_code_response.timestamp
|
|
77
|
+
)
|
|
78
|
+
return access_token_response.token
|
|
79
|
+
|
|
80
|
+
async def sms_login(self, telephone: str, code: str, timestamp: str) -> str:
|
|
81
|
+
"""Log in via SMS code."""
|
|
82
|
+
# Always get a fresh CSRF token for the SMS login request
|
|
83
|
+
await self._client._get_csrf_token()
|
|
84
|
+
|
|
85
|
+
payload = UserSmsLoginRequest(
|
|
86
|
+
telephone=telephone,
|
|
87
|
+
timestamp=timestamp,
|
|
88
|
+
valid_code=code,
|
|
89
|
+
valid_code_key=f"1-{telephone}_validCode",
|
|
90
|
+
).to_dict()
|
|
91
|
+
|
|
92
|
+
response = await self._client.post_json(
|
|
93
|
+
"official/user/sms/login", UserSmsLoginResponse, json=payload
|
|
94
|
+
)
|
|
95
|
+
return response.token
|
|
96
|
+
|
|
97
|
+
async def request_sms_code(self, telephone: str, country_code: int = 1) -> None:
|
|
98
|
+
"""Request an SMS verification code."""
|
|
99
|
+
# 1. Pre-auth to get token
|
|
100
|
+
# Note: The JS code prefixes the account with the country code for pre-auth
|
|
101
|
+
# account: this.tempAccountInfo.countryCode + this.tempAccountInfo.account
|
|
102
|
+
account_with_code = f"{country_code}{telephone}"
|
|
103
|
+
pre_auth_payload = UserPreAuthRequest(account=account_with_code).to_dict()
|
|
104
|
+
|
|
105
|
+
# Always get a fresh CSRF token
|
|
106
|
+
await self._client._get_csrf_token()
|
|
107
|
+
|
|
108
|
+
pre_auth_response = await self._client.post_json(
|
|
109
|
+
"user/validcode/pre-auth", UserPreAuthResponse, json=pre_auth_payload
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
token = pre_auth_response.token
|
|
113
|
+
|
|
114
|
+
# 2. Extract real key and calculate sign
|
|
115
|
+
# e.sign = e.hash256(n + a) where n is account_with_code and a is real_key
|
|
116
|
+
real_key = _extract_real_key(token)
|
|
117
|
+
sign = _sha256_s(account_with_code + real_key)
|
|
118
|
+
|
|
119
|
+
# 3. Send SMS
|
|
120
|
+
# timestamp is needed here. In the JS it uses e.tempAccountInfo.timestamp
|
|
121
|
+
# We might need to fetch a timestamp first if we don't have one, but let's see.
|
|
122
|
+
# The JS gets timestamp from the initial login failure or a separate query.
|
|
123
|
+
# For now, let's try to get a fresh timestamp/random code first?
|
|
124
|
+
# Actually, the JS flow for "sendVerificationCode" seems to use existing timestamp.
|
|
125
|
+
# But if we are starting fresh, we might need one.
|
|
126
|
+
# Let's assume we can get a fresh random code to get a timestamp.
|
|
127
|
+
random_code_resp = await self._get_random_code(telephone)
|
|
128
|
+
timestamp = random_code_resp.timestamp
|
|
129
|
+
|
|
130
|
+
sms_payload = UserSendSmsRequest(
|
|
131
|
+
telephone=telephone,
|
|
132
|
+
timestamp=timestamp,
|
|
133
|
+
token=token,
|
|
134
|
+
sign=sign,
|
|
135
|
+
nationcode=country_code,
|
|
136
|
+
).to_dict()
|
|
137
|
+
|
|
138
|
+
await self._client.post_json(
|
|
139
|
+
"user/sms/validcode/send", UserSendSmsResponse, json=sms_payload
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
async def _token(self) -> None:
|
|
143
|
+
"""Get a random code."""
|
|
144
|
+
await self._client.post_json(
|
|
145
|
+
"user/query/token",
|
|
146
|
+
TokenResponse,
|
|
147
|
+
json=TokenRequest().to_dict(),
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
async def _get_random_code(self, email: str) -> UserRandomCodeResponse:
|
|
151
|
+
"""Get a random code."""
|
|
152
|
+
payload = UserRandomCodeRequest(account=email).to_dict()
|
|
153
|
+
return await self._client.post_json(
|
|
154
|
+
"official/user/query/random/code", UserRandomCodeResponse, json=payload
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
async def _get_access_token(
|
|
158
|
+
self, email: str, encoded_password: str, random_code_timestamp: str
|
|
159
|
+
) -> UserLoginResponse:
|
|
160
|
+
"""Get an access token."""
|
|
161
|
+
payload = UserLoginRequest(
|
|
162
|
+
account=email,
|
|
163
|
+
password=encoded_password,
|
|
164
|
+
login_method=1,
|
|
165
|
+
timestamp=random_code_timestamp,
|
|
166
|
+
).to_dict()
|
|
167
|
+
try:
|
|
168
|
+
return await self._client.post_json(
|
|
169
|
+
"official/user/account/login/new", UserLoginResponse, json=payload
|
|
170
|
+
)
|
|
171
|
+
except ApiException as err:
|
|
172
|
+
if "verification code" in str(err):
|
|
173
|
+
raise SmsVerificationRequired(str(err), random_code_timestamp) from err
|
|
174
|
+
raise
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Supernote command line tool."""
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Cloud login debugging tool for Supernote Cloud."""
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import logging
|
|
6
|
+
import sys
|
|
7
|
+
import os
|
|
8
|
+
|
|
9
|
+
import aiohttp
|
|
10
|
+
|
|
11
|
+
from supernote.cloud.client import Client
|
|
12
|
+
from supernote.cloud.login_client import LoginClient
|
|
13
|
+
from supernote.cloud.auth import FileCacheAuth
|
|
14
|
+
from supernote.cloud.cloud_client import SupernoteCloudClient
|
|
15
|
+
from supernote.cloud.exceptions import SupernoteException
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
_LOGGER = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def setup_logging(verbose: bool = False) -> None:
|
|
22
|
+
"""Setup logging configuration."""
|
|
23
|
+
level = logging.DEBUG if verbose else logging.INFO
|
|
24
|
+
logging.basicConfig(
|
|
25
|
+
level=level,
|
|
26
|
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
|
27
|
+
stream=sys.stdout,
|
|
28
|
+
)
|
|
29
|
+
# Enable debug logging for cloud modules and aiohttp
|
|
30
|
+
if verbose:
|
|
31
|
+
logging.getLogger("supernote.cloud").setLevel(logging.DEBUG)
|
|
32
|
+
logging.getLogger("supernote.cloud.client").setLevel(logging.DEBUG)
|
|
33
|
+
logging.getLogger("aiohttp").setLevel(logging.DEBUG)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def async_cloud_login(email: str, password: str, verbose: bool = False) -> None:
|
|
37
|
+
"""Perform cloud login with detailed debugging output.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
email: User email/account
|
|
41
|
+
password: User password
|
|
42
|
+
verbose: Enable verbose HTTP logging
|
|
43
|
+
"""
|
|
44
|
+
setup_logging(verbose)
|
|
45
|
+
|
|
46
|
+
print("=" * 80)
|
|
47
|
+
print("Supernote Cloud Login Debugging Tool")
|
|
48
|
+
print("=" * 80)
|
|
49
|
+
print(f"Email: {email}")
|
|
50
|
+
print(
|
|
51
|
+
f"Verbose Mode: {'ENABLED' if verbose else 'DISABLED (use -v or --verbose for detailed logs)'}"
|
|
52
|
+
)
|
|
53
|
+
print("=" * 80)
|
|
54
|
+
print()
|
|
55
|
+
|
|
56
|
+
async with aiohttp.ClientSession() as session:
|
|
57
|
+
try:
|
|
58
|
+
# Step 1: Create client and login
|
|
59
|
+
print("Step 1: Initializing client...")
|
|
60
|
+
client = Client(session)
|
|
61
|
+
login_client = LoginClient(client)
|
|
62
|
+
|
|
63
|
+
print("Step 2: Starting login flow...")
|
|
64
|
+
print(" - This will get CSRF token")
|
|
65
|
+
print(" - Call token endpoint")
|
|
66
|
+
print(" - Get random code")
|
|
67
|
+
print(" - Encode password")
|
|
68
|
+
print(" - Submit login request")
|
|
69
|
+
print()
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
access_token = await login_client.login(email, password)
|
|
73
|
+
except SupernoteException as err:
|
|
74
|
+
# Check if it's an SMS verification requirement
|
|
75
|
+
# We do this by checking the exception type, but since we just added it
|
|
76
|
+
# we need to make sure we import it.
|
|
77
|
+
# For now, let's assume if the message contains "verification code" it is one.
|
|
78
|
+
# But better to use the type.
|
|
79
|
+
from supernote.cloud.exceptions import SmsVerificationRequired
|
|
80
|
+
|
|
81
|
+
if isinstance(err, SmsVerificationRequired):
|
|
82
|
+
print()
|
|
83
|
+
print("!" * 80)
|
|
84
|
+
print("SMS Verification Required")
|
|
85
|
+
print("!" * 80)
|
|
86
|
+
print(f"Message: {err}")
|
|
87
|
+
print("The server has sent an SMS verification code to your phone.")
|
|
88
|
+
print()
|
|
89
|
+
|
|
90
|
+
print("Requesting SMS verification code...")
|
|
91
|
+
await login_client.request_sms_code(email)
|
|
92
|
+
print("SMS code requested successfully.")
|
|
93
|
+
print()
|
|
94
|
+
|
|
95
|
+
code = input("Enter verification code: ").strip()
|
|
96
|
+
print()
|
|
97
|
+
print("Submitting verification code...")
|
|
98
|
+
|
|
99
|
+
access_token = await login_client.sms_login(
|
|
100
|
+
email, code, err.timestamp
|
|
101
|
+
)
|
|
102
|
+
else:
|
|
103
|
+
raise
|
|
104
|
+
|
|
105
|
+
print("✓ Login successful!")
|
|
106
|
+
print(
|
|
107
|
+
f"Access Token: {access_token[:20]}..."
|
|
108
|
+
if len(access_token) > 20
|
|
109
|
+
else access_token
|
|
110
|
+
)
|
|
111
|
+
print()
|
|
112
|
+
|
|
113
|
+
# Save token to cache
|
|
114
|
+
cache_path = os.path.expanduser("~/.cache/supernote.pkl")
|
|
115
|
+
print(f"Saving token to {cache_path}...")
|
|
116
|
+
auth = FileCacheAuth(cache_path)
|
|
117
|
+
auth.save_access_token(access_token)
|
|
118
|
+
print("✓ Token saved!")
|
|
119
|
+
print()
|
|
120
|
+
|
|
121
|
+
# Step 2: Test basic functionality
|
|
122
|
+
print("Step 3: Testing basic functionality...")
|
|
123
|
+
# auth = ConstantAuth(access_token) # No longer needed as we use FileCacheAuth
|
|
124
|
+
authenticated_client = Client(session, auth=auth)
|
|
125
|
+
cloud_client = SupernoteCloudClient(authenticated_client)
|
|
126
|
+
|
|
127
|
+
# Test 1: Query user
|
|
128
|
+
print(" Test 1: Querying user information...")
|
|
129
|
+
try:
|
|
130
|
+
user_response = await cloud_client.query_user(email)
|
|
131
|
+
print(" ✓ User query successful!")
|
|
132
|
+
print(f" - User ID: {user_response.user_id}")
|
|
133
|
+
print(f" - User Name: {user_response.user_name}")
|
|
134
|
+
print(f" - Country Code: {user_response.country_code}")
|
|
135
|
+
if user_response.file_server:
|
|
136
|
+
print(f" - File Server: {user_response.file_server}")
|
|
137
|
+
except SupernoteException as err:
|
|
138
|
+
print(f" ✗ User query failed: {err}")
|
|
139
|
+
print()
|
|
140
|
+
|
|
141
|
+
# Test 2: List files
|
|
142
|
+
print(" Test 2: Listing files in root directory...")
|
|
143
|
+
try:
|
|
144
|
+
file_list_response = await cloud_client.file_list(directory_id=0)
|
|
145
|
+
print(" ✓ File list successful!")
|
|
146
|
+
print(f" - Total files: {file_list_response.total}")
|
|
147
|
+
print(f" - Pages: {file_list_response.pages}")
|
|
148
|
+
print(f" - Files in this page: {file_list_response.size}")
|
|
149
|
+
|
|
150
|
+
if file_list_response.file_list:
|
|
151
|
+
print(" - First few files:")
|
|
152
|
+
for i, file in enumerate(file_list_response.file_list[:5]):
|
|
153
|
+
folder_marker = "📁" if file.is_folder == "Y" else "📄"
|
|
154
|
+
print(f" {folder_marker} {file.file_name} (ID: {file.id})")
|
|
155
|
+
else:
|
|
156
|
+
print(" - No files found")
|
|
157
|
+
except SupernoteException as err:
|
|
158
|
+
print(f" ✗ File list failed: {err}")
|
|
159
|
+
print()
|
|
160
|
+
|
|
161
|
+
print("=" * 80)
|
|
162
|
+
print("All tests completed successfully!")
|
|
163
|
+
print("=" * 80)
|
|
164
|
+
|
|
165
|
+
except SupernoteException as err:
|
|
166
|
+
print()
|
|
167
|
+
print("=" * 80)
|
|
168
|
+
print(f"✗ Error during login flow: {err}")
|
|
169
|
+
print("=" * 80)
|
|
170
|
+
if verbose:
|
|
171
|
+
import traceback
|
|
172
|
+
|
|
173
|
+
traceback.print_exc()
|
|
174
|
+
sys.exit(1)
|
|
175
|
+
except Exception as err:
|
|
176
|
+
print()
|
|
177
|
+
print("=" * 80)
|
|
178
|
+
print(f"✗ Unexpected error: {err}")
|
|
179
|
+
print("=" * 80)
|
|
180
|
+
if verbose:
|
|
181
|
+
import traceback
|
|
182
|
+
|
|
183
|
+
traceback.print_exc()
|
|
184
|
+
sys.exit(1)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def subcommand_cloud_login(args) -> None:
|
|
188
|
+
"""Handler for cloud-login subcommand.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
args: Parsed command line arguments
|
|
192
|
+
"""
|
|
193
|
+
asyncio.run(async_cloud_login(args.email, args.password, args.verbose))
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
async def async_cloud_ls(verbose: bool = False) -> None:
|
|
197
|
+
"""List files in Supernote Cloud using cached credentials.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
verbose: Enable verbose logging
|
|
201
|
+
"""
|
|
202
|
+
setup_logging(verbose)
|
|
203
|
+
|
|
204
|
+
cache_path = os.path.expanduser("~/.cache/supernote.pkl")
|
|
205
|
+
if not os.path.exists(cache_path):
|
|
206
|
+
print(f"Error: No cached credentials found at {cache_path}")
|
|
207
|
+
print("Please run 'supernote-tool cloud-login' first.")
|
|
208
|
+
sys.exit(1)
|
|
209
|
+
|
|
210
|
+
async with aiohttp.ClientSession() as session:
|
|
211
|
+
try:
|
|
212
|
+
auth = FileCacheAuth(cache_path)
|
|
213
|
+
client = Client(session, auth=auth)
|
|
214
|
+
cloud_client = SupernoteCloudClient(client)
|
|
215
|
+
|
|
216
|
+
print("Listing files in root directory...")
|
|
217
|
+
file_list_response = await cloud_client.file_list()
|
|
218
|
+
|
|
219
|
+
print(f"Total files: {file_list_response.total}")
|
|
220
|
+
if file_list_response.file_list:
|
|
221
|
+
for file in file_list_response.file_list:
|
|
222
|
+
folder_marker = "📁" if file.is_folder == "Y" else "📄"
|
|
223
|
+
print(f"{folder_marker} {file.file_name} (ID: {file.id})")
|
|
224
|
+
|
|
225
|
+
except SupernoteException as err:
|
|
226
|
+
print(f"Error: {err}")
|
|
227
|
+
if verbose:
|
|
228
|
+
import traceback
|
|
229
|
+
|
|
230
|
+
traceback.print_exc()
|
|
231
|
+
sys.exit(1)
|
|
232
|
+
except Exception as err:
|
|
233
|
+
print(f"Unexpected error: {err}")
|
|
234
|
+
if verbose:
|
|
235
|
+
import traceback
|
|
236
|
+
|
|
237
|
+
traceback.print_exc()
|
|
238
|
+
sys.exit(1)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def subcommand_cloud_ls(args) -> None:
|
|
242
|
+
"""Handler for cloud-ls subcommand.
|
|
243
|
+
|
|
244
|
+
Args:
|
|
245
|
+
args: Parsed command line arguments
|
|
246
|
+
"""
|
|
247
|
+
asyncio.run(async_cloud_ls(args.verbose))
|
|
@@ -29,6 +29,7 @@ from supernote.converter import (
|
|
|
29
29
|
TextConverter,
|
|
30
30
|
)
|
|
31
31
|
from supernote.converter import VisibilityOverlay
|
|
32
|
+
from supernote.cmds.cloud_login_tool import subcommand_cloud_login, subcommand_cloud_ls
|
|
32
33
|
|
|
33
34
|
|
|
34
35
|
def convert_all(converter, total, file_name, save_func, visibility_overlay):
|
|
@@ -316,6 +317,26 @@ def main():
|
|
|
316
317
|
parser_reconstruct.add_argument("output", type=str, help="output note file")
|
|
317
318
|
parser_reconstruct.set_defaults(handler=subcommand_reconstruct)
|
|
318
319
|
|
|
320
|
+
# 'cloud-login' subcommand
|
|
321
|
+
parser_cloud_login = subparsers.add_parser(
|
|
322
|
+
"cloud-login", help="debug Supernote Cloud login flow"
|
|
323
|
+
)
|
|
324
|
+
parser_cloud_login.add_argument("email", type=str, help="user email/account")
|
|
325
|
+
parser_cloud_login.add_argument("password", type=str, help="user password")
|
|
326
|
+
parser_cloud_login.add_argument(
|
|
327
|
+
"-v", "--verbose", action="store_true", help="Enable verbose logging"
|
|
328
|
+
)
|
|
329
|
+
parser_cloud_login.set_defaults(handler=subcommand_cloud_login)
|
|
330
|
+
|
|
331
|
+
# Cloud ls command
|
|
332
|
+
cloud_ls_parser = subparsers.add_parser(
|
|
333
|
+
"cloud-ls", help="List files in Supernote Cloud"
|
|
334
|
+
)
|
|
335
|
+
cloud_ls_parser.add_argument(
|
|
336
|
+
"-v", "--verbose", action="store_true", help="Enable verbose logging"
|
|
337
|
+
)
|
|
338
|
+
cloud_ls_parser.set_defaults(handler=subcommand_cloud_ls)
|
|
339
|
+
|
|
319
340
|
args = parser.parse_args()
|
|
320
341
|
if hasattr(args, "handler"):
|
|
321
342
|
args.handler(args)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: supernote
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Unofficial python library for parsing Supernote notebooks
|
|
5
5
|
Author-email: jya <jya@wizmy.net>, Allen Porter <allen.porter@gmail.com>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -15,6 +15,8 @@ Requires-Dist: potracer>=0.0.1
|
|
|
15
15
|
Requires-Dist: pypng>=0.0.20
|
|
16
16
|
Requires-Dist: reportlab>=3.6.1
|
|
17
17
|
Requires-Dist: svgwrite>=1.4
|
|
18
|
+
Requires-Dist: aiohttp>=3.13.2
|
|
19
|
+
Requires-Dist: mashumaro>=3.17
|
|
18
20
|
Dynamic: license-file
|
|
19
21
|
|
|
20
22
|
# supernote
|
|
@@ -28,7 +30,7 @@ having a similar dependency limitation.
|
|
|
28
30
|
## Development
|
|
29
31
|
|
|
30
32
|
```
|
|
31
|
-
uv venv
|
|
33
|
+
uv venv --python=3.14
|
|
32
34
|
source .venv/bin/activate
|
|
33
35
|
uv pip install -r requirements_dev.txt
|
|
34
36
|
```
|
|
@@ -16,6 +16,13 @@ supernote.egg-info/dependency_links.txt
|
|
|
16
16
|
supernote.egg-info/entry_points.txt
|
|
17
17
|
supernote.egg-info/requires.txt
|
|
18
18
|
supernote.egg-info/top_level.txt
|
|
19
|
+
supernote/cloud/api_model.py
|
|
20
|
+
supernote/cloud/auth.py
|
|
21
|
+
supernote/cloud/client.py
|
|
22
|
+
supernote/cloud/cloud_client.py
|
|
23
|
+
supernote/cloud/exceptions.py
|
|
24
|
+
supernote/cloud/login_client.py
|
|
19
25
|
supernote/cmds/__init__.py
|
|
26
|
+
supernote/cmds/cloud_login_tool.py
|
|
20
27
|
supernote/cmds/supernote_tool.py
|
|
21
28
|
tests/test_init.py
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|