remarkable-cloud-js
Version:
reMarkable Cloud API for NodeJs
490 lines (425 loc) • 17.7 kB
JavaScript
const fetch = require('node-fetch')
const uuid = require('uuid').v4
const ADMZIP = require('adm-zip');
const fs = require('fs')
const WEBSOCKET = require('ws')
// ---------------------------------------------------------- DATA
// ---- USED AGENT
const user_agent = 'remarkable-cloud-js'
// ---- AUTH
const auth_host = 'https://my.remarkable.com'
const auth_device_ep = '/token/json/2/device/new'
const auth_user_ep = '/token/json/2/user/new'
// ---- DISCOVERY
const discovery_host = 'https://service-manager-production-dot-remarkable-production.appspot.com/service/json/1'
const storage_discovery_ep = '/document-storage?environment=production&group=auth0%7C5a68dc51cb30df1234567890&apiVer=2'
const notifications_discovery_ep = '/notifications?environment=production&group=auth0%7C5a68dc51cb30df1234567890&apiVer=1'
// ---- STORAGE
const docs_ep = '/document-storage/json/2/docs'
const upload_request_ep = '/document-storage/json/2/upload/request'
const update_status_ep = '/document-storage/json/2/upload/update-status'
const delete_ep = '/document-storage/json/2/delete'
// ---- NOTIFICATIONS
const notification_ep = '/notifications/ws/json/1'
// ---------------------------------------------------------- UTILS
async function rm_api({ url, method = 'GET', headers = {}, prop = null, body = null, raw_body = null, expected = 'json' }) {
let options = { method, headers }
headers['User-Agent'] = user_agent
if (body || raw_body) options.body = raw_body ? raw_body : JSON.stringify(body)
let resp = await fetch(url, options)
if (![200, 201, 202, 203, 204].includes(resp.status)) throw resp
if (!expected) return resp
let parsed_resp = await resp[expected]()
if (prop != null) return parsed_resp[prop]
return parsed_resp
}
function create_zip_buffer(zip_map, ID) {
let final_zip_map = Object.fromEntries(Object.entries(zip_map)
.map(([file_path, content]) => [
file_path.replace('{ID}', ID),
Buffer.isBuffer(content) ?
content :
typeof content == 'object' ?
Buffer.from(JSON.stringify(content)) :
Buffer.from(content)
])
)
let zip = new ADMZIP()
Object.entries(final_zip_map).forEach(([file_path, content_buffer]) => zip.addFile(file_path, content_buffer))
return zip.toBuffer()
}
function raw_path_elements(full_path) {
if (!full_path) return full_path
let path_elements = full_path.split('/')
let name = path_elements.pop()
let parent_path = path_elements.join('/')
return { name, parent_path }
}
// ---------------------------------------------------------- MAIN CLASS
class REMARKABLEAPI {
// ---------------------------------- CONSTRUCT
constructor(device_token = null) {
this.device_token = device_token
this.user_token = null
this.storage_host = null
this.notification_host = null
this.notification_ws = null
}
// ---------------------------------- AUTHENTICATION
async register_device(one_time_code, device_desc = REMARKABLEAPI.device_desc.desktop.linux, device_id = uuid()) {
if (!all_device_desc.includes(device_desc))
throw `device description must be of types ${all_device_desc.join(', ')}`
this.device_token = await rm_api({
url: auth_host + auth_device_ep,
method: 'POST',
body: {
code: one_time_code,
deviceDesc: device_desc,
deviceID: device_id
},
expected: 'text'
})
return this.device_token
}
async refresh_token() {
if (!this.device_token) throw 'api must be registered first using "register_device"'
this.user_token = await rm_api({
url: auth_host + auth_user_ep,
method: 'POST',
headers: {
'User-Agent': user_agent,
Authorization: `Bearer ${this.device_token}`
},
expected: 'text'
})
return this.user_token
}
// ---------------------------------- AUTHED API CALL
async api({ url, method = 'GET', headers = {}, prop = null, body = null, raw_body = null, expected = 'json' }) {
if (!this.user_token) throw 'api must be authenticated first using "refresh_token"'
headers['User-Agent'] = user_agent
headers.Authorization = `Bearer ${this.user_token}`
let resp = await rm_api({ url, method, headers, prop, body, raw_body, expected })
return resp
}
async get_storage_host() {
if (!this.storage_host) this.storage_host = 'https://' + await rm_api({ url: discovery_host + storage_discovery_ep, prop: 'Host' })
return this.storage_host
}
async storage_url_maker(endpoint) {
return (await this.get_storage_host()) + endpoint
}
async get_notification_host() {
if (!this.notification_host)
this.notification_host = 'wss://' + await rm_api({ url: discovery_host + notifications_discovery_ep, prop: 'Host' })
return this.notification_host
}
async get_notification_ws() {
if (!this.user_token) throw 'api must be authenticated first using "refresh_token"'
if (!this.notification_ws)
this.notification_ws = new WEBSOCKET((await this.get_notification_host()) + notification_ep, {
headers: {
'User-Agent': user_agent,
'Authorization': `Bearer ${this.user_token}`
}
});
return this.notification_ws
}
// ---------------------------------- API METHOD OVERRIDE
async raw_docs() {
return await this.api({ url: await this.storage_url_maker(docs_ep) })
}
async get_doc(ID, with_blob = true) {
return await this.api({
url: `${await this.storage_url_maker(docs_ep)}?doc=${ID}&withBlob=${JSON.stringify(with_blob)}`,
prop: 0
})
}
async upload_request(doc = null) {
let ID = doc?.ID ?? uuid()
let modification_date = new Date().toISOString()
let sending_document = {
ID,
Version: (doc?.Version ?? 0) + 1,
lastModified: modification_date,
ModifiedClient: modification_date,
}
let resp = await this.api({
url: await this.storage_url_maker(upload_request_ep),
method: 'PUT',
prop: 0,
body: [sending_document]
})
if (!resp.Success) throw REMARKABLEAPI.exception.upload_request_error(resp.Message)
return resp
}
async update_status(doc, changed_doc_data) {
let modification_date = new Date().toISOString()
delete doc._path
let sending_document = {
...doc,
Version: doc.Version + 1,
lastModified: modification_date,
ModifiedClient: modification_date,
...changed_doc_data,
}
let resp = await this.api({
url: await this.storage_url_maker(update_status_ep),
method: 'PUT',
prop: 0,
body: [sending_document]
})
if (!resp.Success) throw REMARKABLEAPI.exception.update_error(resp.Message)
return sending_document
}
async delete(doc) {
let resp = await this.api({
url: await this.storage_url_maker(delete_ep),
method: 'PUT',
prop: 0,
body: [doc]
})
if (!resp.Success) throw REMARKABLEAPI.exception.delete_error(resp.Message)
return resp.Success
}
// ---------------------------------- UTILS
async docs_paths() {
let docs = await this.raw_docs()
let id_map = Object.fromEntries(docs.map(obj => [obj.ID, obj]))
function get_childrens(id) {
return docs.filter(({ Parent }) => Parent == id)
}
function create_paths(id, past_path = '') {
id_map[id]._path = past_path + '/' + id_map[id].VissibleName
get_childrens(id).forEach(({ ID }) => create_paths(ID, id_map[id]._path))
}
docs.filter(({ Parent }) => Parent == '').forEach(({ ID }) => create_paths(ID, ''))
return docs
}
async corrupted_docs() {
return (await this.docs_paths()).filter(({ Parent, _path }) => Parent != 'trash' && _path === undefined)
}
async trashed_docs() {
return (await this.docs_paths()).filter(({ Parent }) => Parent == 'trash')
}
async get_path(path) {
if (path == '') {
return { ID: '' }
} else if (path == 'trash') {
return { ID: 'trash' }
} else if (path == '/') {
return { ID: '' }
}
return (await this.docs_paths()).filter(({ _path }) => _path == path)[0]
}
async get_final_path(path) {
let doc = await this.get_path(path)
if (!doc) throw REMARKABLEAPI.exception.path_not_found(path)
return doc
}
async get_path_content(path) {
return (await this.docs_paths()).filter(({ _path }) => raw_path_elements(_path)?.parent_path == path)
}
async fix_corrupted_docs(move_to_path = 'trash') {
let new_parent = await this.get_path(move_to_path)
if (!new_parent) throw REMARKABLEAPI.exception.path_not_found(move_to_path)
let corrupted_docs = await this.corrupted_docs()
for (let doc of corrupted_docs) {
await this.update_status(doc, { Parent: new_parent.ID })
}
return corrupted_docs
}
async get_ID(id) {
return (await this.docs_paths()).filter(({ ID }) => ID == id)[0]
}
async get_name(name) {
return (await this.docs_paths()).filter(({ VissibleName }) => VissibleName == name)
}
async path_elements(full_path, check_exists = true) {
let path_elements = full_path.split('/')
let name = path_elements.pop()
let parent_path = path_elements.join('/')
if (check_exists) await this.get_final_path(parent_path)
return { name, parent_path }
}
async upload_zip_data(name, parent_path, type, zip_map, doc = null) {
let parent = await this.get_path(parent_path)
if (!parent) throw REMARKABLEAPI.exception.path_not_found(parent_path)
let { ID, BlobURLPut } = await this.upload_request(doc)
let zip_buffer = create_zip_buffer(zip_map, ID)
await this.api({
url: BlobURLPut,
method: 'PUT',
raw_body: zip_buffer,
expected: null
})
let base_document = {
Parent: parent.ID,
Bookmarked: doc?.Bookmarked,
Type: type,
VissibleName: name,
}
return await this.update_status({ ID, Version: doc?.Version ?? 0 }, base_document)
}
// ---------------------------------- DATA RETREIAVAL
async exists(path) {
return (await this.get_path(path)) != undefined
}
async unlink(path) {
let doc = await this.get_final_path(path)
return await this.delete(doc)
}
async move(path, new_parent_path) {
let doc = await this.get_final_path(path)
let new_parent_doc = await this.get_path(new_parent_path)
if (!new_parent_doc) throw REMARKABLEAPI.exception.path_not_found(new_parent_path)
return await this.update_status(doc, { Parent: new_parent_doc.ID })
}
async rename(path, new_name) {
let doc = await this.get_final_path(path)
return await this.update_status(doc, { VissibleName: new_name })
}
async read_zip(path) {
let doc = await this.get_final_path(path)
let { BlobURLGet } = await this.get_doc(doc.ID)
let buffer = await rm_api({
url: BlobURLGet,
expected: 'buffer'
})
let zip = new ADMZIP(buffer)
return Object.fromEntries(zip.getEntries().map(data => {
let raw_data = data.getData()
let parsed_data = raw_data
try {
parsed_data = JSON.parse(raw_data)
} catch (e) {
parsed_data = raw_data
}
let name = data.entryName.replace(doc.ID, '{ID}')
return [name, parsed_data]
}))
}
async write_zip(path, zip_map, type) {
let doc = await this.get_path(path)
let { name, parent_path } = await this.path_elements(path)
return await this.upload_zip_data(
name, parent_path,
type,
zip_map,
doc
)
}
async mkdir(path) {
if (await this.get_path(path)) throw REMARKABLEAPI.exception.path_already_exists_error(path)
return this.write_zip(path, { "{ID}.content": {} }, REMARKABLEAPI.type.collection)
}
async copy(from_path, to_path, recursive = true) {
if (await this.get_path(to_path)) throw REMARKABLEAPI.exception.path_already_exists_error(to_path)
let doc = await this.get_final_path(from_path)
let zip_map = await this.read_zip(from_path)
let new_doc = await this.write_zip(to_path, zip_map, doc.Type)
if (recursive && doc.Type == REMARKABLEAPI.type.collection) {
let sub_docs = await this.get_path_content(from_path)
for (let sub_doc of sub_docs)
await this.copy(sub_doc._path, to_path + '/' + sub_doc.VissibleName)
}
return new_doc
}
async write_file_type(path, local_path, file_type, meta_override, path_reader = fs.readFileSync) {
return await this.write_zip(path, {
'{ID}.content': {
extraMetadata: {},
fileType: file_type,
lastOpenedPage: 0,
lineHeight: -1,
margins: 180,
pageCount: 0,
textScale: 1,
transform: {},
...meta_override
},
'{ID}.pagedata': [],
[`{ID}.${file_type}`]: await path_reader(local_path)
}, REMARKABLEAPI.type.document)
}
async write_file_from_url(path, url, file_type, meta_override) {
return await this.write_file_type(path, url, file_type, meta_override, async (url) =>
await (await fetch(url)).buffer()
)
}
async read_file_type(path, file_type) {
return (await this.read_zip(path))[`{ID}.${file_type}`]
}
async write_pdf(path, pdf_path, metadata = {}) {
return await this.write_file_type(path, pdf_path, 'pdf', metadata)
}
async write_pdf_from_url(path, pdf_url, metadata = {}) {
return await this.write_file_from_url(path, pdf_url, 'pdf', metadata)
}
async read_pdf(path) {
return await this.read_file_type(path, 'pdf')
}
async write_epub(path, epub_path, metadata = {}) {
return await this.write_file_type(path, epub_path, 'epub', { margins: 100, ...metadata })
}
async write_epub_from_url(path, epub_url, metadata = {}) {
return await this.write_file_from_url(path, epub_url, 'epub', { margins: 100, ...metadata })
}
async read_epub(path) {
return await this.read_file_type(path, 'epub')
}
// ---------------------------------- NOTIFICATION API
async subscribe_to_notifications(handler, matching_properties = []) {
let ws = await this.get_notification_ws()
ws.on('message', async (raw_data) => {
let data = JSON.parse(raw_data)
let event = {
...data.message.attributes,
publish_time: data.message.publish_time
}
let send = Object.keys(matching_properties)
.map(prop => matching_properties[prop] == event[prop])
.reduce((a, b) => a && b, true)
if (send) {
if (event.event != REMARKABLEAPI.notification.event.document_deleted)
event.document = await this.get_ID(event.id)
handler(event)
}
});
return true
}
}
// ---------------------------------------------------------- DEVICE DESC
REMARKABLEAPI.device_desc = {
desktop: {
windows: 'desktop-windows',
macos: 'desktop-macos',
linux: 'desktop-linux'
},
mobile: {
android: 'mobile-android',
ios: 'mobile-ios',
},
browser: {
chrome: 'browser-chrome'
}
}
REMARKABLEAPI.type = {
document: 'DocumentType',
collection: 'CollectionType'
}
REMARKABLEAPI.notification = {
event: {
document_added: 'DocAdded',
document_deleted: 'DocDeleted'
}
}
REMARKABLEAPI.exception = {
path_not_found: (path) => `path "${path}" not found.`,
update_error: (error) => `error while updating: "${error}".`,
upload_request_error: (error) => `error while requesting for upload: "${error}".`,
delete_error: (error) => `error while deleting: "${error}".`,
path_already_exists_error: (error) => `path "${error}" already exists.`
}
const all_device_desc = Object.values(REMARKABLEAPI.device_desc).map(sub => Object.values(sub)).flat()
module.exports = REMARKABLEAPI