├── .c8rc.json
├── .editorconfig
├── .eslintrc.cjs
├── .gitattributes
├── .github
└── workflows
│ ├── build.yml
│ └── publish.yml
├── .gitignore
├── .husky
└── pre-commit
├── .mocharc.json
├── .vscode
└── launch.json
├── LICENSE
├── README.md
├── README.skel
├── esbuild.config.cjs
├── jsdoc2md.json
├── package-lock.json
├── package.json
├── src
├── api.ts
├── index.ts
└── sync.ts
├── test
├── api.mock.ts
├── filelist.test.ts
├── login.test.ts
└── sync.test.ts
├── tsconfig.build.json
└── tsconfig.json
/.c8rc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@istanbuljs/nyc-config-typescript",
3 | "include": ["src/**"],
4 | "reporter": ["text", "lcov"],
5 | "report-dir": "coverage",
6 |
7 | "all": true
8 | }
9 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | insert_final_newline = false
5 | indent_style = space
6 | indent_size = 2
7 | charset = utf-8
8 | max_line_length = 100
9 | trim_trailing_whitespace = true
--------------------------------------------------------------------------------
/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | "env": {
3 | "es2021": true,
4 | "node": true
5 | },
6 | "extends": [
7 | "eslint:recommended",
8 | "plugin:@typescript-eslint/recommended"
9 | ],
10 | "overrides": [
11 | ],
12 | "parser": "@typescript-eslint/parser",
13 | "parserOptions": {
14 | "ecmaVersion": "latest",
15 | "sourceType": "module"
16 | },
17 | "plugins": [
18 | "@typescript-eslint"
19 | ],
20 | "rules": {
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | * text eol=lf
2 |
3 | *.png binary
4 | *.jpg binary
5 | *.jpeg binary
6 | *.gif binary
7 | *.ico binary
8 |
--------------------------------------------------------------------------------
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
1 | name: Build and test
2 | on:
3 | push:
4 | branches:
5 | - main
6 | jobs:
7 | build:
8 | runs-on: ubuntu-latest
9 | steps:
10 | - uses: actions/checkout@v3
11 | - run: |
12 | npm ci
13 | npm run build
14 | npm test
15 |
--------------------------------------------------------------------------------
/.github/workflows/publish.yml:
--------------------------------------------------------------------------------
1 | name: Publish
2 | on:
3 | release:
4 | types: [published]
5 | jobs:
6 | publish:
7 | runs-on: ubuntu-latest
8 | steps:
9 | - uses: actions/checkout@v3
10 | - uses: actions/setup-node@v3
11 | with:
12 | node-version: node
13 | registry-url: "https://registry.npmjs.org"
14 | - run: npm ci
15 | - run: npm run build
16 | - run: npm test
17 | - run: npm publish
18 | env:
19 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
20 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | lib
3 | coverage
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 | . "$(dirname -- "$0")/_/husky.sh"
3 |
4 | npm test
5 |
--------------------------------------------------------------------------------
/.mocharc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extension": ["ts"],
3 | "spec": "test/**/*.test.ts",
4 | "node-option": ["loader=ts-node/esm", "no-warnings"]
5 | }
6 |
--------------------------------------------------------------------------------
/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | // Use IntelliSense to learn about possible attributes.
3 | // Hover to view descriptions of existing attributes.
4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
5 | "version": "0.2.0",
6 | "configurations": [
7 | {
8 | "type": "node",
9 | "request": "launch",
10 | "name": "Mocha Tests",
11 | "program": "${workspaceRoot}/node_modules/mocha/bin/mocha",
12 | "args": [
13 | "--inspect-brk",
14 | "${workspaceFolder}/test/**/*.test.ts"
15 | ],
16 | "internalConsoleOptions": "openOnSessionStart"
17 | }
18 | ]
19 | }
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 Ade Bateman
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Supernote Cloud Storage API
2 |
3 | [](https://github.com/adrianba/supernote-cloud-api/actions/workflows/build.yml) [](https://www.npmjs.com/package/supernote-cloud-api) 
4 |
5 | (Unofficial) API for accessing the cloud storage for Supernote tablets. This library was created by
6 | observing the network calls from the Supernote web app and may stop working if Supernote modifies
7 | the cloud API.
8 |
9 | ## Modules
10 |
11 |
12 | - supernote-cloud-api
13 |
14 |
15 |
16 | ## Functions
17 |
18 |
19 | - login(email, password) ⇒
Promise.<string>
20 | Login to SuperNote Cloud API.
21 | - fileList(token, directoryId) ⇒
Promise.<FileInfo>
22 | Return contents of folder.
23 | - fileUrl(token, id) ⇒
Promise.<string>
24 | Obtain URL to contents of file.
25 | - syncFiles(token, localPath) ⇒
Promise.<void>
26 | Sync files from cloud to local file system.
27 |
28 |
29 |
30 |
31 | ## supernote-cloud-api
32 |
33 |
34 | ## login(email, password) ⇒ Promise.<string>
35 | Login to SuperNote Cloud API.
36 |
37 | **Kind**: global function
38 | **Returns**: Promise.<string>
- Access token to access storage
39 |
40 | | Param | Type | Description |
41 | | --- | --- | --- |
42 | | email | string
| User e-mail address
|
43 | | password | string
| User password
|
44 |
45 |
46 |
47 | ## fileList(token, directoryId) ⇒ Promise.<FileInfo>
48 | Return contents of folder.
49 |
50 | **Kind**: global function
51 | **Returns**: Promise.<FileInfo>
- List of files and folders.
52 |
53 | | Param | Type | Description |
54 | | --- | --- | --- |
55 | | token | string
| Access token from login()
|
56 | | directoryId | string
| Identifier of folder to list (default is root folder)
|
57 |
58 |
59 |
60 | ## fileUrl(token, id) ⇒ Promise.<string>
61 | Obtain URL to contents of file.
62 |
63 | **Kind**: global function
64 | **Returns**: Promise.<string>
- URL of file
65 |
66 | | Param | Type | Description |
67 | | --- | --- | --- |
68 | | token | string
| Access token from login()
|
69 | | id | string
| Identifier of file
|
70 |
71 |
72 |
73 | ## syncFiles(token, localPath) ⇒ Promise.<void>
74 | Sync files from cloud to local file system.
75 |
76 | **Kind**: global function
77 |
78 | | Param | Type | Description |
79 | | --- | --- | --- |
80 | | token | string
| Access token from login()
|
81 | | localPath | string
| Local file path to sync to
|
82 |
83 |
--------------------------------------------------------------------------------
/README.skel:
--------------------------------------------------------------------------------
1 | # Supernote Cloud Storage API
2 |
3 | [](https://github.com/adrianba/supernote-cloud-api/actions/workflows/build.yml) [](https://www.npmjs.com/package/supernote-cloud-api) 
4 |
5 | (Unofficial) API for accessing the cloud storage for Supernote tablets. This library was created by
6 | observing the network calls from the Supernote web app and may stop working if Supernote modifies
7 | the cloud API.
8 |
9 | {{>main}}
10 |
--------------------------------------------------------------------------------
/esbuild.config.cjs:
--------------------------------------------------------------------------------
1 | const esbuild = require("esbuild");
2 |
3 | // Automatically exclude all node_modules from the bundled version
4 | const { nodeExternalsPlugin } = require("esbuild-node-externals");
5 |
6 | // Find all .ts files - alternative is to just include ./src/index.ts and
7 | // esbuild will recursively find all dependencies from that single file
8 | //const glob = require("glob");
9 | //const entryPoints = glob.sync("./src/**/*.ts");
10 | const entryPoints = ["./src/index.ts"];
11 |
12 | const config = {
13 | entryPoints,
14 | outfile: "./lib/index.js",
15 | bundle: true,
16 | minify: false,
17 | platform: "node",
18 | sourcemap: true,
19 | target: "node18",
20 | format: "esm",
21 | tsconfig: "./tsconfig.build.json",
22 | plugins: [nodeExternalsPlugin()],
23 | };
24 |
25 | esbuild.build(config).catch(() => process.exit(1));
26 |
--------------------------------------------------------------------------------
/jsdoc2md.json:
--------------------------------------------------------------------------------
1 | {
2 | "source": {
3 | "includePattern": ".+\\.ts(doc|x)?$",
4 | "excludePattern": ".+\\.(test|spec).ts"
5 | },
6 | "plugins": ["plugins/markdown", "node_modules/jsdoc-babel"],
7 | "babel": {
8 | "extensions": ["ts", "tsx"],
9 | "ignore": ["**/*.(test|spec).ts"],
10 | "babelrc": false,
11 | "presets": [["@babel/preset-env", { "targets": { "node": true } }], "@babel/preset-typescript"],
12 | "plugins": ["@babel/proposal-class-properties", "@babel/proposal-object-rest-spread"]
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "supernote-cloud-api",
3 | "version": "1.0.13",
4 | "description": "Access SuperNote Cloud Storage",
5 | "main": "./lib/index.js",
6 | "files": [
7 | "./lib"
8 | ],
9 | "type": "module",
10 | "scripts": {
11 | "prebuild": "rimraf lib",
12 | "build": "tsc -p tsconfig.build.json && node ./esbuild.config.cjs",
13 | "builddoc": "jsdoc2md --files ./src/*.ts --configure ./jsdoc2md.json --template README.skel > README.md",
14 | "lint": "eslint --fix 'src/**/*.ts'",
15 | "test": "mocha",
16 | "test:cov": "c8 mocha",
17 | "prepare": "is-ci || husky install",
18 | "postversion": "git push --follow-tags"
19 | },
20 | "repository": {
21 | "type": "git",
22 | "url": "git+https://github.com/adrianba/supernote-cloud-api.git"
23 | },
24 | "keywords": [
25 | "SuperNote",
26 | "Cloud API",
27 | "A5X",
28 | "A6X"
29 | ],
30 | "author": "Ade Bateman",
31 | "license": "MIT",
32 | "bugs": {
33 | "url": "https://github.com/adrianba/supernote-cloud-api/issues"
34 | },
35 | "homepage": "https://github.com/adrianba/supernote-cloud-api#readme",
36 | "devDependencies": {
37 | "@babel/cli": "^7.19.3",
38 | "@babel/core": "^7.20.2",
39 | "@babel/preset-env": "^7.20.2",
40 | "@babel/preset-typescript": "^7.18.6",
41 | "@types/chai": "^4.3.4",
42 | "@types/js-md5": "^0.4.3",
43 | "@types/mocha": "^10.0.0",
44 | "@types/mock-fs": "^4.13.1",
45 | "@types/node": "^18.11.9",
46 | "@types/sha.js": "^2.4.0",
47 | "@typescript-eslint/eslint-plugin": "^5.44.0",
48 | "@typescript-eslint/parser": "^5.44.0",
49 | "c8": "^7.12.0",
50 | "chai": "^4.3.7",
51 | "esbuild": "^0.15.15",
52 | "esbuild-node-externals": "^1.5.0",
53 | "eslint": "^8.28.0",
54 | "husky": "^8.0.2",
55 | "is-ci": "^3.0.1",
56 | "jsdoc-babel": "^0.5.0",
57 | "jsdoc-to-markdown": "^7.1.1",
58 | "mocha": "^10.1.0",
59 | "mock-fs": "^5.2.0",
60 | "nock": "^13.2.9",
61 | "rimraf": "^3.0.2",
62 | "ts-node": "^10.9.1",
63 | "typescript": "^4.9.3"
64 | },
65 | "typings": "./lib/index.d.ts",
66 | "dependencies": {
67 | "js-md5": "^0.7.3",
68 | "node-fetch": "^3.3.0",
69 | "sha.js": "^2.4.11"
70 | },
71 | "publishConfig": {
72 | "registry": "https://registry.npmjs.org",
73 | "access": "public"
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/src/api.ts:
--------------------------------------------------------------------------------
1 | import fetch from "node-fetch";
2 | import md5 from "js-md5";
3 | import shajs from "sha.js";
4 |
5 | function sha256(s: string) {
6 | return shajs('sha256').update(s).digest('hex');
7 | }
8 |
9 | /**
10 | * Login to SuperNote Cloud API.
11 | * @async
12 | * @param {string} email User e-mail address
13 | * @param {string} password User password
14 | * @return {Promise} Access token to access storage
15 | */
16 | export async function login(email: string, password: string): Promise {
17 | const { randomCode, timestamp } = await getRandomCode(email);
18 | return await getAccessToken(email, password, randomCode, timestamp);
19 | }
20 |
21 | /**
22 | * Details of a file or folder.
23 | * @property {string} id - Identifier
24 | * @property {string} directoryId - Folder identifier containing this item
25 | * @property {string} fileName - Name of the file
26 | * @property {number} size - Size of the file, or 0 for folder
27 | * @property {string} md5 - MD5 checksum of file, or "" for folder
28 | * @property {string} isFolder - "Y" for folder, or "N" for file
29 | * @property {number} createTime - Number representing create time
30 | * @property {number} updateTime - Number representing last updated time
31 | */
32 | export type FileInfo = {
33 | "id": string;
34 | "directoryId": string;
35 | "fileName": string;
36 | "size": number;
37 | "md5": string;
38 | "isFolder": string;
39 | "createTime": number;
40 | "updateTime": number;
41 | };
42 |
43 | /**
44 | * Return contents of folder.
45 | * @async
46 | * @param {string} token Access token from login()
47 | * @param {string?} directoryId Identifier of folder to list (default is root folder)
48 | * @return {Promise} List of files and folders.
49 | */
50 | export async function fileList(token: string, directoryId?: string): Promise {
51 | const payload = {
52 | directoryId: directoryId || 0,
53 | pageNo: 1,
54 | pageSize: 100,
55 | order: "time",
56 | sequence: "desc",
57 | };
58 | const data = await postJson("https://cloud.supernote.com/api/file/list/query", payload, token);
59 | return data.userFileVOList as FileInfo[];
60 | }
61 |
62 | /**
63 | * Obtain URL to contents of file.
64 | * @async
65 | * @param {string} token Access token from login()
66 | * @param {string} id Identifier of file
67 | * @return {Promise} URL of file
68 | */
69 | export async function fileUrl(token: string, id: string): Promise {
70 | const payload = {
71 | id,
72 | type: 0,
73 | };
74 | const data = await postJson("https://cloud.supernote.com/api/file/download/url", payload, token);
75 | return data.url;
76 | }
77 |
78 | async function getRandomCode(email: string): Promise<{ randomCode: string; timestamp: number }> {
79 | const payload = { countryCode: "93", account: email };
80 | const data = await postJson(
81 | "https://cloud.supernote.com/api/official/user/query/random/code",
82 | payload
83 | );
84 | return { randomCode: data.randomCode, timestamp: data.timestamp };
85 | }
86 |
87 | async function getAccessToken(
88 | email: string,
89 | password: string,
90 | randomCode: string,
91 | timestamp: number
92 | ): Promise {
93 | const pd = sha256(md5(password) + randomCode);
94 | const payload = {
95 | countryCode: "93",
96 | account: email,
97 | password: pd,
98 | browser: "Chrome107",
99 | equipment: "1",
100 | loginMethod: "1",
101 | timestamp: timestamp,
102 | language: "en",
103 | };
104 |
105 | const data = await postJson(
106 | "https://cloud.supernote.com/api/official/user/account/login/new",
107 | payload
108 | );
109 | return data.token;
110 | }
111 |
112 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
113 | async function postJson(url: string, payload: any, token?: string): Promise {
114 | const headers: HeadersInit = {
115 | "Content-Type": "application/json",
116 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36"
117 | };
118 | if (token) {
119 | headers["x-access-token"] = token;
120 | }
121 | const response = await fetch(url, {
122 | method: "post",
123 | body: JSON.stringify(payload),
124 | headers,
125 | });
126 | return await response.json();
127 | }
128 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @module supernote-cloud-api
3 | */
4 |
5 | import { login, fileList, fileUrl } from "./api.js";
6 | import { syncFiles } from "./sync.js";
7 |
8 | /**
9 | * Details of a file or folder.
10 | * @property {string} id - Identifier
11 | * @property {string} directoryId - Folder identifier containing this item
12 | * @property {string} fileName - Name of the file
13 | * @property {number} size - Size of the file, or 0 for folder
14 | * @property {string} md5 - MD5 checksum of file, or "" for folder
15 | * @property {string} isFolder - "Y" for folder, or "N" for file
16 | * @property {number} createTime - Number representing create time
17 | * @property {number} updateTime - Number representing last updated time
18 | */
19 | export type { FileInfo } from "./api";
20 |
21 | export default {
22 | /**
23 | * Login to SuperNote Cloud API.
24 | * @async
25 | * @param {string} email User e-mail address
26 | * @param {string} password User password
27 | * @return {Promise} Access token to access storage
28 | */
29 | login,
30 |
31 | /**
32 | * Return contents of folder.
33 | * @async
34 | * @param {string} token Access token from login()
35 | * @param {string?} directoryId Identifier of folder to list (default is root folder)
36 | * @return {Promise} List of files and folders.
37 | */
38 | fileList,
39 |
40 | /**
41 | * Obtain URL to contents of file.
42 | * @async
43 | * @param {string} token Access token from login()
44 | * @param {string} id Identifier of file
45 | * @return {Promise} URL of file
46 | */
47 | fileUrl,
48 |
49 | /**
50 | * Sync files from cloud to local file system.
51 | * @async
52 | * @param {string} token Access token from login()
53 | * @param {string} localPath Local file path to sync to
54 | * @returns {Promise}
55 | */
56 | syncFiles
57 | };
--------------------------------------------------------------------------------
/src/sync.ts:
--------------------------------------------------------------------------------
1 | import * as supernote from "./api.js";
2 | import path from "node:path";
3 | import fs from "node:fs/promises";
4 | import fetch from "node-fetch";
5 | import md5 from "js-md5";
6 |
7 | /**
8 | * Sync files from cloud to local file system.
9 | * @async
10 | * @param {string} token Access token from login()
11 | * @param {string} localPath Local file path to sync to
12 | * @returns {Promise}
13 | */
14 | export async function syncFiles(token: string, localPath: string) {
15 | await syncSupernoteDirectory(token, localPath);
16 | }
17 |
18 | async function syncSupernoteDirectory(token: string, localPath: string, directoryId?: string) {
19 | const contents = await supernote.fileList(token, directoryId);
20 | for (const item of contents) {
21 | const itemPath = path.join(localPath, item.fileName);
22 | if (item.isFolder == "Y") {
23 | // Recurse folder
24 | console.log(`Recursing into ${itemPath}`);
25 | await syncSupernoteDirectory(token, itemPath, item.id);
26 | } else {
27 | await syncSupernoteFile(token, itemPath, item);
28 | }
29 | }
30 | }
31 |
32 | async function syncSupernoteFile(token: string, localPath: string, item: supernote.FileInfo) {
33 | console.log(`Checking: ${localPath}`);
34 |
35 | // Get details of localPath
36 | let localInfo;
37 |
38 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
39 | try { localInfo = await fs.stat(localPath); } catch (e: any) {
40 | /* c8 ignore next */
41 | if (e.code !== 'ENOENT') throw e;
42 | console.log(` Not found: ${localPath}`);
43 | }
44 |
45 | // If newer than item, then do nothing
46 | if (localInfo) {
47 | if (!localInfo.isFile()) throw (`Not a file: ${localPath}`);
48 | const lastUpdated = new Date(item.updateTime);
49 | console.log(` -- Local time: ${localInfo.mtime}`);
50 | console.log(` -- Remote time: ${lastUpdated}`);
51 | if (localInfo.mtime > lastUpdated) {
52 | console.log(` Ignoring file (newer): ${localPath}`);
53 | return;
54 | }
55 | // Compute md5 of localPath
56 | console.log(` Cloud MD5: ${item.md5}`);
57 | const localMd5 = await computeFileMd5(localPath);
58 | console.log(` Local MD5: ${localMd5}`);
59 | if (localMd5 == item.md5) {
60 | console.log(` Ignoring file (MD5 matches): ${localPath}`);
61 | return;
62 | }
63 | }
64 |
65 | // If different to item.md5 then download
66 | console.log(` Downloading: ${localPath}`);
67 | const url = await supernote.fileUrl(token, item.id);
68 | await downloadFile(url, localPath, item.updateTime);
69 | }
70 |
71 | // Compute md5 of file
72 | async function computeFileMd5(filePath: string) {
73 | const hash = md5.create();
74 |
75 | const fd = await fs.open(filePath, "r");
76 | try {
77 | let done = false;
78 | while (!done) {
79 | const data = await fd.read();
80 | done = !data || data.bytesRead === 0;
81 | if (!done) hash.update(data.buffer.subarray(0, data.bytesRead));
82 | }
83 | } finally {
84 | await fd.close();
85 | }
86 |
87 | return hash.hex();
88 | }
89 |
90 | async function downloadFile(url: string, filePath: string, updateTime: number) {
91 | // Make sure folder exists
92 | const folder = path.dirname(filePath);
93 | try { await fs.mkdir(folder, { recursive: true }) }
94 | /* c8 ignore next */
95 | catch { /* swallow error */ }
96 |
97 | const res = await fetch(url);
98 | if (!res.ok) {
99 | console.log(`Error downloading [${res.status}: ${res.statusText}]: ${url}`);
100 | return;
101 | }
102 |
103 | const fd = await fs.open(filePath, "w");
104 | try {
105 | const fileStream = fd.createWriteStream({ autoClose: true });
106 | await new Promise((resolve, reject) => {
107 | if (res.body) {
108 | res.body.pipe(fileStream);
109 | res.body.on("error", reject);
110 | fileStream.on("finish", resolve);
111 | /* c8 ignore next 3 */
112 | } else {
113 | reject("Missing body");
114 | }
115 | });
116 | } finally {
117 | await fd.close();
118 | }
119 |
120 | const update = new Date(updateTime);
121 | console.log(` Update time: ${update}`);
122 | await fs.utimes(filePath, update, update);
123 | }
124 |
125 | export default {
126 | syncFiles
127 | };
--------------------------------------------------------------------------------
/test/api.mock.ts:
--------------------------------------------------------------------------------
1 | import nock from "nock";
2 | import md5 from "js-md5";
3 |
4 | export function createScope(token: string) {
5 | return nock("https://cloud.supernote.com", {
6 | reqheaders: {
7 | 'x-access-token': token
8 | }
9 | });
10 | }
11 |
12 | export type MockFileListItem = {
13 | name: string;
14 | isFolder: boolean;
15 | data?: Buffer;
16 | updateTime?: number
17 | }
18 |
19 | export function queueFileList(scope: nock.Scope, directoryId: string, itemList: MockFileListItem[]) {
20 | let count = 0;
21 | scope
22 | .post("/api/file/list/query")
23 | .reply(200, {
24 | "success": true,
25 | "errorCode": null,
26 | "errorMsg": null,
27 | "total": itemList.length,
28 | "userFileVOList": itemList.map(item => ({
29 | "id": (++count).toString(),
30 | "directoryId": directoryId,
31 | "fileName": item.name,
32 | "size": (!item.isFolder && item.data) ? item.data.length : 0,
33 | "md5": (!item.isFolder && item.data) ? buffer2md5(item.data) : "",
34 | "isFolder": item.isFolder ? "Y" : "N",
35 | "createTime": item.updateTime ?? 0,
36 | "updateTime": item.updateTime ?? 0
37 | }
38 | ))
39 | });
40 |
41 | }
42 |
43 | function buffer2md5(buf: Buffer) {
44 | let hash = md5.create();
45 | hash.update(buf);
46 | return hash.hex();
47 | }
48 |
49 | export function queueFileDownload(scope: nock.Scope, host: string, item: MockFileListItem) {
50 | scope
51 | .post("/api/file/download/url")
52 | .reply(200, {
53 | url: host + item.name
54 | });
55 | }
--------------------------------------------------------------------------------
/test/filelist.test.ts:
--------------------------------------------------------------------------------
1 | import nock from "nock";
2 | import { assert } from "chai";
3 | import supernote from "../src/index.js";
4 | import { createScope, queueFileList } from "./api.mock.js";
5 |
6 | describe("fileList tests", function () {
7 | it("root", async function () {
8 | const token = "__token__"
9 | const scope = createScope(token);
10 |
11 | queueFileList(scope, "0", [
12 | {
13 | name: "Note",
14 | isFolder: true,
15 | updateTime: 1658265084000
16 | },
17 | {
18 | name: "Document.pdf",
19 | isFolder: false,
20 | data: Buffer.from("abcdefg", "utf8"),
21 | updateTime: 1658265084000
22 | }
23 | ]);
24 |
25 | let list = await supernote.fileList(token);
26 |
27 | assert.equal(list.length, 2);
28 | assert.equal(list[0].isFolder, "Y");
29 | assert.equal(list[1].isFolder, "N");
30 | assert.equal(list[0].fileName, "Note");
31 | assert.equal(list[1].fileName, "Document.pdf");
32 | scope.done();
33 | });
34 |
35 | it("subfolder", async function () {
36 | const token = "__token__";
37 | const scope = createScope(token);
38 |
39 | const folder = "666666666666666666";
40 | queueFileList(scope, folder, [
41 | {
42 | name: "Note",
43 | isFolder: true,
44 | updateTime: 1658265084000
45 | },
46 | {
47 | name: "Document.pdf",
48 | isFolder: false,
49 | data: Buffer.from("abcdefg", "utf8"),
50 | updateTime: 1658265084000
51 | }
52 | ]);
53 |
54 | let list = await supernote.fileList(token, folder);
55 |
56 | assert.equal(list.length, 2);
57 | assert.equal(list[0].isFolder, "Y");
58 | assert.equal(list[1].isFolder, "N");
59 | assert.equal(list[0].directoryId, folder);
60 | assert.equal(list[1].directoryId, folder);
61 | scope.done();
62 | });
63 |
64 | beforeEach(() => {
65 | nock.disableNetConnect();
66 | });
67 |
68 | afterEach(() => {
69 | nock.cleanAll();
70 | nock.enableNetConnect();
71 | });
72 | });
73 |
--------------------------------------------------------------------------------
/test/login.test.ts:
--------------------------------------------------------------------------------
1 | import { assert } from "chai";
2 | import supernote from "../src/index.js";
3 | import nock from "nock";
4 |
5 | describe("login tests", function () {
6 | it("success", async function () {
7 | const email = "email@example.com"
8 | const scope = nock("https://cloud.supernote.com")
9 | .post("/api/official/user/query/random/code", (body) =>
10 | body.countryCode == 93 && body.account == email
11 | )
12 | .reply(200, {
13 | "success": true,
14 | "errorCode": null,
15 | "errorMsg": null,
16 | "randomCode": "12345678",
17 | "timestamp": 1234567890123
18 | })
19 | .post("/api/official/user/account/login/new", (body) =>
20 | body.countryCode == 93 && body.account == email && body.password == "4d7b223baa98f7cbaf870e309a1335e4b538bb56e3a4604575917fdd45c3ec0e"
21 | )
22 | .reply(200, {
23 | "success": true,
24 | "errorCode": null,
25 | "errorMsg": null,
26 | "token": "__token__",
27 | "counts": null,
28 | "userName": null,
29 | "avatarsUrl": null,
30 | "lastUpdateTime": null,
31 | "isBind": "Y",
32 | "isBindEquipment": null,
33 | "soldOutCount": 0
34 | });
35 | let token = await supernote.login(email, "00000000");
36 | assert.equal(token, "__token__");
37 | scope.done();
38 | });
39 |
40 | it("invalid", async function () {
41 | const email = "email@example.com"
42 | const scope = nock("https://cloud.supernote.com")
43 | .post("/api/official/user/query/random/code")
44 | .reply(200, {
45 | "success": true,
46 | "errorCode": null,
47 | "errorMsg": null,
48 | "randomCode": "12345678",
49 | "timestamp": 1234567890123
50 | })
51 | .post("/api/official/user/account/login/new")
52 | .reply(200, {
53 | "success": false,
54 | "errorCode": null,
55 | "errorMsg": null,
56 | "token": null,
57 | "counts": null,
58 | "userName": null,
59 | "avatarsUrl": null,
60 | "lastUpdateTime": null,
61 | "isBind": null,
62 | "isBindEquipment": null,
63 | "soldOutCount": 0
64 | });
65 | let token = await supernote.login(email, "00000000");
66 | assert.equal(token, null);
67 | scope.done();
68 | });
69 |
70 | beforeEach(() => {
71 | nock.disableNetConnect();
72 | });
73 |
74 | afterEach(() => {
75 | nock.cleanAll();
76 | nock.enableNetConnect();
77 | });
78 | });
79 |
--------------------------------------------------------------------------------
/test/sync.test.ts:
--------------------------------------------------------------------------------
1 | import mockfs from "mock-fs";
2 | import nock from "nock";
3 | import { assert } from "chai";
4 | import supernote from "../src/index.js";
5 | import { createScope, queueFileList, MockFileListItem, queueFileDownload } from "./api.mock.js";
6 |
7 | describe("syncFiles tests", function () {
8 | it("iterate into folder", async () => {
9 | const token = "__token__"
10 | const scope = createScope(token);
11 |
12 | queueFileList(scope, "0", [
13 | {
14 | name: "Note",
15 | isFolder: true,
16 | updateTime: 1658265084000
17 | }
18 | ]);
19 | queueFileList(scope, "1", []);
20 | mockfs({});
21 |
22 | await supernote.syncFiles(token, ".");
23 |
24 | assert(scope.isDone(), "network requests incomplete");
25 | });
26 |
27 | it("check matching root file", async () => {
28 | const token = "__token__"
29 | const scope = createScope(token);
30 |
31 | let fileItem: MockFileListItem =
32 | {
33 | name: "data.txt",
34 | isFolder: false,
35 | data: Buffer.from("hello", "utf8"),
36 | updateTime: 1658265084000
37 | };
38 | queueFileList(scope, "0", [fileItem]);
39 | mockfs({
40 | [fileItem.name]: mockfs.file({
41 | content: fileItem.data,
42 | mtime: new Date(fileItem.updateTime ?? 0)
43 | })
44 | });
45 |
46 | await supernote.syncFiles(token, ".");
47 |
48 | assert(scope.isDone(), "network requests incomplete");
49 | });
50 |
51 | it("check matching subfolder file", async () => {
52 | const token = "__token__"
53 | const scope = createScope(token);
54 |
55 | queueFileList(scope, "0", [
56 | {
57 | name: "Note",
58 | isFolder: true,
59 | updateTime: 1658265084000
60 | }
61 | ]);
62 | let fileItem: MockFileListItem =
63 | {
64 | name: "data.txt",
65 | isFolder: false,
66 | data: Buffer.from("hello", "utf8"),
67 | updateTime: 1658265084000
68 | };
69 | queueFileList(scope, "1", [fileItem]);
70 | mockfs({
71 | "Note": {
72 | [fileItem.name]: mockfs.file({
73 | content: fileItem.data,
74 | mtime: new Date(fileItem.updateTime ?? 0)
75 | })
76 | }
77 | });
78 |
79 | await supernote.syncFiles(token, ".");
80 |
81 | assert(scope.isDone(), "network requests incomplete");
82 | });
83 |
84 | it("check newer file", async () => {
85 | const token = "__token__"
86 | const scope = createScope(token);
87 |
88 | let fileItem: MockFileListItem =
89 | {
90 | name: "data.txt",
91 | isFolder: false,
92 | data: Buffer.from("hello", "utf8"),
93 | updateTime: 1658265084000
94 | };
95 | queueFileList(scope, "0", [fileItem]);
96 | mockfs({
97 | [fileItem.name]: mockfs.file({
98 | content: Buffer.from("hello2", "utf8"),
99 | mtime: new Date((fileItem.updateTime ?? 0) + 10000)
100 | })
101 | });
102 |
103 | await supernote.syncFiles(token, ".");
104 |
105 | assert(scope.isDone(), "network requests incomplete");
106 | });
107 |
108 | it("download new file", async () => {
109 | const token = "__token__"
110 | const scope = createScope(token);
111 |
112 | let fileItem: MockFileListItem =
113 | {
114 | name: "data.txt",
115 | isFolder: false,
116 | data: Buffer.from("hello", "utf8"),
117 | updateTime: 1658265084000
118 | };
119 | queueFileList(scope, "0", [fileItem]);
120 | mockfs({});
121 |
122 | const fileHost = "https://storage/";
123 | queueFileDownload(scope, fileHost, fileItem);
124 | const downloadScope = nock(fileHost).get("/" + fileItem.name).reply(200, fileItem.data);
125 |
126 | await supernote.syncFiles(token, ".");
127 |
128 | assert(scope.isDone(), "network requests incomplete");
129 | assert(downloadScope.isDone(), "download network requests incomplete");
130 | });
131 |
132 | it("failed download", async () => {
133 | const token = "__token__"
134 | const scope = createScope(token);
135 |
136 | let fileItem: MockFileListItem =
137 | {
138 | name: "data.txt",
139 | isFolder: false,
140 | data: Buffer.from("hello", "utf8"),
141 | updateTime: 1658265084000
142 | };
143 | queueFileList(scope, "0", [fileItem]);
144 | mockfs({});
145 |
146 | const fileHost = "https://storage/";
147 | queueFileDownload(scope, fileHost, fileItem);
148 | const downloadScope = nock(fileHost).get("/" + fileItem.name).reply(404, "Not found");
149 |
150 | await supernote.syncFiles(token, ".");
151 |
152 | assert(scope.isDone(), "network requests incomplete");
153 | assert(downloadScope.isDone(), "download network requests incomplete");
154 | });
155 |
156 | it("folder instead of download", async () => {
157 | const token = "__token__"
158 | const scope = createScope(token);
159 |
160 | let fileItem: MockFileListItem =
161 | {
162 | name: "data.txt",
163 | isFolder: false,
164 | data: Buffer.from("hello", "utf8"),
165 | updateTime: 1658265084000
166 | };
167 | queueFileList(scope, "0", [fileItem]);
168 | mockfs({
169 | "data.txt": {}
170 | });
171 |
172 | try {
173 | await supernote.syncFiles(token, ".");
174 | } catch (e) {
175 | assert.equal(e, "Not a file: data.txt");
176 | }
177 |
178 | assert(scope.isDone(), "network requests incomplete");
179 | });
180 |
181 | beforeEach(() => {
182 | nock.disableNetConnect();
183 | });
184 |
185 | afterEach(() => {
186 | nock.cleanAll();
187 | nock.enableNetConnect();
188 | mockfs.restore();
189 | });
190 | });
191 |
--------------------------------------------------------------------------------
/tsconfig.build.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es2020",
4 | "module": "esnext",
5 | "moduleResolution": "node",
6 | "esModuleInterop": true,
7 | "strict": true,
8 | "declaration": true,
9 | "outDir": "lib",
10 | "rootDir": "src",
11 | "emitDeclarationOnly": true
12 | },
13 | "include": [
14 | "src"
15 | ],
16 | "exclude": [
17 | "node_modules",
18 | "test"
19 | ]
20 | }
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es2020",
4 | "module": "esnext",
5 | "moduleResolution": "node",
6 | "esModuleInterop": true,
7 | "strict": true
8 | },
9 | "include": ["src", "test"],
10 | "exclude": ["node_modules"]
11 | }
12 |
--------------------------------------------------------------------------------