├── .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 | [![Build and test badge](https://github.com/adrianba/supernote-cloud-api/actions/workflows/build.yml/badge.svg)](https://github.com/adrianba/supernote-cloud-api/actions/workflows/build.yml) [![npm version badge](https://img.shields.io/npm/v/supernote-cloud-api)](https://www.npmjs.com/package/supernote-cloud-api) ![license badge](https://img.shields.io/github/license/adrianba/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 | [![Build and test badge](https://github.com/adrianba/supernote-cloud-api/actions/workflows/build.yml/badge.svg)](https://github.com/adrianba/supernote-cloud-api/actions/workflows/build.yml) [![npm version badge](https://img.shields.io/npm/v/supernote-cloud-api)](https://www.npmjs.com/package/supernote-cloud-api) ![license badge](https://img.shields.io/github/license/adrianba/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 | --------------------------------------------------------------------------------