rmapi-js 0.0.1
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.
- package/LICENSE +21 -0
- package/README.md +110 -0
- package/bundle/rmapi-js.cjs.min.js +4 -0
- package/bundle/rmapi-js.esm.min.js +4 -0
- package/bundle/rmapi-js.iife.min.js +4 -0
- package/bundle/rmapi.cjs.min.js +4 -0
- package/bundle/rmapi.esm.min.js +4 -0
- package/bundle/rmapi.iife.min.js +4 -0
- package/dist/index.d.ts +404 -0
- package/dist/index.js +535 -0
- package/dist/utils.d.ts +6 -0
- package/dist/utils.js +24 -0
- package/dist/validate.d.ts +3 -0
- package/dist/validate.js +8 -0
- package/package.json +141 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Create and interact with reMarkable cloud.
|
|
3
|
+
*
|
|
4
|
+
* @example
|
|
5
|
+
* A simple fetch
|
|
6
|
+
* ```ts
|
|
7
|
+
* import { register, remarkable } from "rmapi-js";
|
|
8
|
+
*
|
|
9
|
+
* const code = "..." // eight letter code from https://my.remarkable.com/device/desktop/connect
|
|
10
|
+
* const token = await register(code)
|
|
11
|
+
* // persist token
|
|
12
|
+
* const api = await remarkable(token);
|
|
13
|
+
* const [root] = await api.getRootHash();
|
|
14
|
+
* const fileEntries = await api.getEntries(root);
|
|
15
|
+
* for (const entry of fileEntries) {
|
|
16
|
+
* const children = await api.getEntries(entry.hash);
|
|
17
|
+
* for (const { hash, documentId } of children) {
|
|
18
|
+
* if (documentId.endsWith(".metadata")) {
|
|
19
|
+
* const meta = api.getMetadata(hash);
|
|
20
|
+
* // get metadata for entry
|
|
21
|
+
* console.log(meta);
|
|
22
|
+
* }
|
|
23
|
+
* }
|
|
24
|
+
* }
|
|
25
|
+
* ```
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* A simple upload
|
|
29
|
+
* ```ts
|
|
30
|
+
* import { remarkable } from "rmapi-js";
|
|
31
|
+
*
|
|
32
|
+
* const api = await remarkable(...);
|
|
33
|
+
* await api.putEpub("document name", epubBuffer);
|
|
34
|
+
* ```
|
|
35
|
+
*
|
|
36
|
+
* @packageDocumentation
|
|
37
|
+
*/
|
|
38
|
+
import { v4 as uuid4 } from "uuid";
|
|
39
|
+
import { concatBuffers, fromHex, toHex } from "./utils";
|
|
40
|
+
import { validate } from "./validate";
|
|
41
|
+
const SCHEMA_VERSION = "3";
|
|
42
|
+
const AUTH_URL = "https://webapp-production-dot-remarkable-production.appspot.com";
|
|
43
|
+
const BLOB_URL = "https://rm-blob-storage-prod.appspot.com";
|
|
44
|
+
const GENERATION_HEADER = "x-goog-generation";
|
|
45
|
+
const GENERATION_RACE_HEADER = "x-goog-if-generation-match";
|
|
46
|
+
/** tool options */
|
|
47
|
+
export const builtinTools = [
|
|
48
|
+
"Ballpoint",
|
|
49
|
+
"Ballpointv2",
|
|
50
|
+
"Brush",
|
|
51
|
+
"Calligraphy",
|
|
52
|
+
"ClearPage",
|
|
53
|
+
"EraseSection",
|
|
54
|
+
"Eraser",
|
|
55
|
+
"Fineliner",
|
|
56
|
+
"Finelinerv2",
|
|
57
|
+
"Highlighter",
|
|
58
|
+
"Highlighterv2",
|
|
59
|
+
"Marker",
|
|
60
|
+
"Markerv2",
|
|
61
|
+
"Paintbrush",
|
|
62
|
+
"Paintbrushv2",
|
|
63
|
+
"Pencilv2",
|
|
64
|
+
"SharpPencil",
|
|
65
|
+
"SharpPencilv2",
|
|
66
|
+
"SolidPen",
|
|
67
|
+
"ZoomTool",
|
|
68
|
+
];
|
|
69
|
+
/** font name options */
|
|
70
|
+
export const builtinFontNames = [
|
|
71
|
+
"Maison Neue",
|
|
72
|
+
"EB Garamond",
|
|
73
|
+
"Noto Sans",
|
|
74
|
+
"Noto Serif",
|
|
75
|
+
"Noto Mono",
|
|
76
|
+
"Noto Sans UI",
|
|
77
|
+
];
|
|
78
|
+
/** text scale options */
|
|
79
|
+
export const builtinTextScales = {
|
|
80
|
+
/** the smallest */
|
|
81
|
+
xs: 0.7,
|
|
82
|
+
/** small */
|
|
83
|
+
sm: 0.8,
|
|
84
|
+
/** medium / default */
|
|
85
|
+
md: 1.0,
|
|
86
|
+
/** large */
|
|
87
|
+
lg: 1.2,
|
|
88
|
+
/** extra large */
|
|
89
|
+
xl: 1.5,
|
|
90
|
+
/** double extra large */
|
|
91
|
+
xx: 2.0,
|
|
92
|
+
};
|
|
93
|
+
/** margin options */
|
|
94
|
+
export const builtinMargins = {
|
|
95
|
+
/** small */
|
|
96
|
+
sm: 50,
|
|
97
|
+
/** medium */
|
|
98
|
+
md: 125,
|
|
99
|
+
/** default for read on remarkable */
|
|
100
|
+
rr: 180,
|
|
101
|
+
/** large */
|
|
102
|
+
lg: 200,
|
|
103
|
+
};
|
|
104
|
+
/** line height options */
|
|
105
|
+
export const builtinLineHeights = {
|
|
106
|
+
/** default */
|
|
107
|
+
df: -1,
|
|
108
|
+
/** normal */
|
|
109
|
+
md: 100,
|
|
110
|
+
/** half */
|
|
111
|
+
lg: 150,
|
|
112
|
+
/** double */
|
|
113
|
+
xl: 200,
|
|
114
|
+
};
|
|
115
|
+
const urlResponseSchema = {
|
|
116
|
+
properties: {
|
|
117
|
+
relative_path: { type: "string" },
|
|
118
|
+
url: { type: "string" },
|
|
119
|
+
expires: { type: "timestamp" },
|
|
120
|
+
method: { enum: ["POST", "GET", "PUT", "DELETE"] },
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
const commonProperties = {
|
|
124
|
+
visibleName: { type: "string" },
|
|
125
|
+
parent: { type: "string" },
|
|
126
|
+
lastModified: { type: "string" },
|
|
127
|
+
version: { type: "int32" },
|
|
128
|
+
synced: { type: "boolean" },
|
|
129
|
+
};
|
|
130
|
+
const commonOptionalProperties = {
|
|
131
|
+
pinned: { type: "boolean" },
|
|
132
|
+
modified: { type: "boolean" },
|
|
133
|
+
deleted: { type: "boolean" },
|
|
134
|
+
metadatamodified: { type: "boolean" },
|
|
135
|
+
};
|
|
136
|
+
const metadataSchema = {
|
|
137
|
+
discriminator: "type",
|
|
138
|
+
mapping: {
|
|
139
|
+
CollectionType: {
|
|
140
|
+
properties: commonProperties,
|
|
141
|
+
optionalProperties: commonOptionalProperties,
|
|
142
|
+
},
|
|
143
|
+
DocumentType: {
|
|
144
|
+
properties: commonProperties,
|
|
145
|
+
optionalProperties: {
|
|
146
|
+
...commonOptionalProperties,
|
|
147
|
+
lastOpened: { type: "string" },
|
|
148
|
+
lastOpenedPage: { type: "int32" },
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
/** an error that results from a failed request */
|
|
154
|
+
export class ResponseError extends Error {
|
|
155
|
+
/** the response status number */
|
|
156
|
+
status;
|
|
157
|
+
/** the response status text */
|
|
158
|
+
statusText;
|
|
159
|
+
constructor(status, statusText, message) {
|
|
160
|
+
super(message);
|
|
161
|
+
this.status = status;
|
|
162
|
+
this.statusText = statusText;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* an error that results from trying yp update the wrong generation.
|
|
167
|
+
*
|
|
168
|
+
* If we try to update the root hash of files, but the generation has changed
|
|
169
|
+
* relative to the one we're updating from, this will fail.
|
|
170
|
+
*/
|
|
171
|
+
export class GenerationError extends Error {
|
|
172
|
+
constructor() {
|
|
173
|
+
super("Generation preconditions failed. This means the current state is out of date with the cloud and needs to be re-synced.");
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* register a device and get the token needed to access the api
|
|
178
|
+
*
|
|
179
|
+
* Have users go to `https://my.remarkable.com/device/desktop/connect` and pass
|
|
180
|
+
* the resulting code into this function to get a device token. Persist that
|
|
181
|
+
* token to use the api.
|
|
182
|
+
*
|
|
183
|
+
* @param code - the eight letter code a user got from `https://my.remarkable.com/device/desktop/connect`.
|
|
184
|
+
* @returns the device token necessary for creating an api instace. These never expire so persist as long as necessary.
|
|
185
|
+
*/
|
|
186
|
+
export async function register(code, { deviceDesc = "desktop-linux", uuid = uuid4(), authUrl = AUTH_URL, fetch = globalThis.fetch, } = {}) {
|
|
187
|
+
if (code.length !== 8) {
|
|
188
|
+
throw new Error(`code should be length 8, but was ${code.length}`);
|
|
189
|
+
}
|
|
190
|
+
const resp = await fetch(`${authUrl}/token/json/2/device/new`, {
|
|
191
|
+
method: "POST",
|
|
192
|
+
headers: {
|
|
193
|
+
Authorization: "Bearer",
|
|
194
|
+
},
|
|
195
|
+
body: JSON.stringify({
|
|
196
|
+
code,
|
|
197
|
+
deviceDesc,
|
|
198
|
+
deviceID: uuid,
|
|
199
|
+
}),
|
|
200
|
+
});
|
|
201
|
+
if (!resp.ok) {
|
|
202
|
+
throw new ResponseError(resp.status, resp.statusText, "couldn't register api");
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
return await resp.text();
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
/** format an entry */
|
|
209
|
+
function formatEntry({ hash, type, documentId, subfiles, size, }) {
|
|
210
|
+
return `${hash}:${type}:${documentId}:${subfiles}:${size}\n`;
|
|
211
|
+
}
|
|
212
|
+
/** parse an entry */
|
|
213
|
+
function parseEntry(line) {
|
|
214
|
+
const [hash, type, documentId, subfiles, size] = line.split(":");
|
|
215
|
+
if (hash === undefined ||
|
|
216
|
+
type === undefined ||
|
|
217
|
+
documentId === undefined ||
|
|
218
|
+
subfiles === undefined ||
|
|
219
|
+
size === undefined) {
|
|
220
|
+
throw new Error(`entries line didn't contain five fields: '${line}'`);
|
|
221
|
+
}
|
|
222
|
+
if (type === "80000000") {
|
|
223
|
+
if (size !== "0") {
|
|
224
|
+
throw new Error(`collection type entry had nonzero size: ${size}`);
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
return {
|
|
228
|
+
hash,
|
|
229
|
+
type,
|
|
230
|
+
documentId,
|
|
231
|
+
subfiles: parseInt(subfiles),
|
|
232
|
+
size: 0n,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
else if (type === "0") {
|
|
237
|
+
if (subfiles !== "0") {
|
|
238
|
+
throw new Error(`file type entry had nonzero number of subfiles: ${subfiles}`);
|
|
239
|
+
}
|
|
240
|
+
else {
|
|
241
|
+
return {
|
|
242
|
+
hash,
|
|
243
|
+
type,
|
|
244
|
+
documentId,
|
|
245
|
+
subfiles: 0,
|
|
246
|
+
size: BigInt(size),
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
else {
|
|
251
|
+
throw new Error(`entries line contained invalid type: ${type}`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
/** the implementation of that api */
|
|
255
|
+
class Remarkable {
|
|
256
|
+
userToken;
|
|
257
|
+
fetch;
|
|
258
|
+
cache;
|
|
259
|
+
subtle;
|
|
260
|
+
blobUrl;
|
|
261
|
+
constructor(userToken, fetch, cache, subtle, blobUrl) {
|
|
262
|
+
this.userToken = userToken;
|
|
263
|
+
this.fetch = fetch;
|
|
264
|
+
this.cache = cache;
|
|
265
|
+
this.subtle = subtle;
|
|
266
|
+
this.blobUrl = blobUrl;
|
|
267
|
+
}
|
|
268
|
+
/** make an authorized request to remarkable */
|
|
269
|
+
async authedFetch(url, body, method = "POST") {
|
|
270
|
+
const resp = await this.fetch(url, {
|
|
271
|
+
method,
|
|
272
|
+
headers: {
|
|
273
|
+
Authorization: `Bearer ${this.userToken}`,
|
|
274
|
+
},
|
|
275
|
+
body: body && JSON.stringify(body),
|
|
276
|
+
});
|
|
277
|
+
if (!resp.ok) {
|
|
278
|
+
throw new ResponseError(resp.status, resp.statusText, "failed reMarkable request");
|
|
279
|
+
}
|
|
280
|
+
else {
|
|
281
|
+
return resp;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
/** make a signed request to the cloud */
|
|
285
|
+
async signedFetch({ url, method }, body, headers) {
|
|
286
|
+
const resp = await this.fetch(url, {
|
|
287
|
+
method,
|
|
288
|
+
body,
|
|
289
|
+
headers,
|
|
290
|
+
});
|
|
291
|
+
if (!resp.ok) {
|
|
292
|
+
const msg = await resp.text();
|
|
293
|
+
throw new ResponseError(resp.status, resp.statusText, msg);
|
|
294
|
+
}
|
|
295
|
+
else {
|
|
296
|
+
return resp;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
/** get the details for how to make a signed request to remarkable cloud */
|
|
300
|
+
async getUrl(relativePath, gen) {
|
|
301
|
+
const key = gen === undefined ? "downloads" : "uploads";
|
|
302
|
+
const generation = gen === null || gen === undefined ? undefined : `${gen}`;
|
|
303
|
+
const resp = await this.authedFetch(`${this.blobUrl}/api/v1/signed-urls/${key}`, {
|
|
304
|
+
http_method: generation === undefined ? "GET" : "PUT",
|
|
305
|
+
relative_path: relativePath,
|
|
306
|
+
generation,
|
|
307
|
+
});
|
|
308
|
+
const raw = await resp.text();
|
|
309
|
+
const res = JSON.parse(raw);
|
|
310
|
+
validate(urlResponseSchema, res);
|
|
311
|
+
return res;
|
|
312
|
+
}
|
|
313
|
+
/** sends a signal to the server that a sync is complete and other devices should update */
|
|
314
|
+
async syncComplete() {
|
|
315
|
+
await this.authedFetch(`${this.blobUrl}/api/v1/sync-complete`);
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* get the root hash and the current generation
|
|
319
|
+
*/
|
|
320
|
+
async getRootHash() {
|
|
321
|
+
const signed = await this.getUrl("root");
|
|
322
|
+
const resp = await this.signedFetch(signed);
|
|
323
|
+
const generation = resp.headers.get(GENERATION_HEADER);
|
|
324
|
+
if (!generation) {
|
|
325
|
+
throw new Error("no generation header in root hash");
|
|
326
|
+
}
|
|
327
|
+
return [await resp.text(), BigInt(generation)];
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* write the root hash, incrementing from the current generation
|
|
331
|
+
*/
|
|
332
|
+
async putRootHash(hash, generation) {
|
|
333
|
+
const signed = await this.getUrl("root", generation);
|
|
334
|
+
let resp;
|
|
335
|
+
try {
|
|
336
|
+
resp = await this.signedFetch(signed, hash, {
|
|
337
|
+
[GENERATION_RACE_HEADER]: `${generation}`,
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
catch (ex) {
|
|
341
|
+
if (ex instanceof ResponseError && ex.status === 412) {
|
|
342
|
+
throw new GenerationError();
|
|
343
|
+
}
|
|
344
|
+
else {
|
|
345
|
+
throw ex;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
const gen = resp.headers.get(GENERATION_HEADER);
|
|
349
|
+
if (!gen) {
|
|
350
|
+
throw new Error("no generation header in root hash");
|
|
351
|
+
}
|
|
352
|
+
return BigInt(gen);
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* get text content associated with hash
|
|
356
|
+
*/
|
|
357
|
+
async getBuffer(hash) {
|
|
358
|
+
const signed = await this.getUrl(hash);
|
|
359
|
+
const resp = await this.signedFetch(signed);
|
|
360
|
+
return await resp.arrayBuffer();
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* get text content associated with hash
|
|
364
|
+
*/
|
|
365
|
+
async getText(hash) {
|
|
366
|
+
const cached = this.cache && (await this.cache.get(hash));
|
|
367
|
+
if (cached) {
|
|
368
|
+
return cached;
|
|
369
|
+
}
|
|
370
|
+
else {
|
|
371
|
+
const signed = await this.getUrl(hash);
|
|
372
|
+
const resp = await this.signedFetch(signed);
|
|
373
|
+
const raw = await resp.text();
|
|
374
|
+
this.cache && (await this.cache.set(hash, raw));
|
|
375
|
+
return raw;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* get metadata from hash
|
|
380
|
+
*/
|
|
381
|
+
async getMetadata(hash) {
|
|
382
|
+
const raw = await this.getText(hash);
|
|
383
|
+
const parsed = JSON.parse(raw);
|
|
384
|
+
validate(metadataSchema, parsed);
|
|
385
|
+
return parsed;
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* get entries from a collection hash
|
|
389
|
+
*/
|
|
390
|
+
async getEntries(hash) {
|
|
391
|
+
const raw = await this.getText(hash);
|
|
392
|
+
// slice for trailing new line
|
|
393
|
+
const [schema, ...lines] = raw.slice(0, -1).split("\n");
|
|
394
|
+
if (schema !== SCHEMA_VERSION) {
|
|
395
|
+
throw new Error(`got unexpected schema version: ${schema}`);
|
|
396
|
+
}
|
|
397
|
+
return lines.map(parseEntry);
|
|
398
|
+
}
|
|
399
|
+
/** upload data to hash */
|
|
400
|
+
async putHash(hash, body) {
|
|
401
|
+
const signed = await this.getUrl(hash, null);
|
|
402
|
+
await this.signedFetch(signed, body);
|
|
403
|
+
}
|
|
404
|
+
/** put a reference to a set of entries into the cloud */
|
|
405
|
+
async putEntries(documentId, entries) {
|
|
406
|
+
// hash of a collection is the hash of all hashes in documentId order
|
|
407
|
+
const enc = new TextEncoder();
|
|
408
|
+
entries.sort((a, b) => a.documentId.localeCompare(b.documentId));
|
|
409
|
+
const hashes = concatBuffers(entries.map((ent) => fromHex(ent.hash)));
|
|
410
|
+
const digest = await this.subtle.digest("SHA-256", hashes);
|
|
411
|
+
const hash = toHex(digest);
|
|
412
|
+
const entryContents = entries.map(formatEntry).join("");
|
|
413
|
+
const contents = `${SCHEMA_VERSION}\n${entryContents}`;
|
|
414
|
+
const buffer = enc.encode(contents);
|
|
415
|
+
await this.putHash(hash, buffer);
|
|
416
|
+
this.cache && (await this.cache.set(hash, contents));
|
|
417
|
+
return {
|
|
418
|
+
hash,
|
|
419
|
+
type: "80000000",
|
|
420
|
+
documentId,
|
|
421
|
+
subfiles: entries.length,
|
|
422
|
+
size: 0n,
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
/** put a raw buffer in the cloud */
|
|
426
|
+
async putBuffer(documentId, buffer) {
|
|
427
|
+
const digest = await this.subtle.digest("SHA-256", buffer);
|
|
428
|
+
const hash = toHex(digest);
|
|
429
|
+
await this.putHash(hash, buffer);
|
|
430
|
+
return {
|
|
431
|
+
hash,
|
|
432
|
+
type: "0",
|
|
433
|
+
documentId,
|
|
434
|
+
subfiles: 0,
|
|
435
|
+
size: BigInt(buffer.length),
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
/** put cached text in the cloud */
|
|
439
|
+
async putText(documentId, contents) {
|
|
440
|
+
const enc = new TextEncoder();
|
|
441
|
+
const entry = await this.putBuffer(documentId, enc.encode(contents));
|
|
442
|
+
this.cache && (await this.cache.set(entry.hash, contents));
|
|
443
|
+
return entry;
|
|
444
|
+
}
|
|
445
|
+
/** upload an epub */
|
|
446
|
+
async putEpub(visibleName, buffer, { parent = "", margins = 125, orientation, textAlignment, textScale = 1, lineHeight = -1, fontName = "", cover = "visited", lastTool, notify = true, retries = 3, } = {}) {
|
|
447
|
+
const documentId = uuid4();
|
|
448
|
+
const lastModified = `${new Date().valueOf()}`;
|
|
449
|
+
const entryPromises = [];
|
|
450
|
+
// upload main document
|
|
451
|
+
entryPromises.push(this.putBuffer(`${documentId}.epub`, buffer));
|
|
452
|
+
// upload metadata
|
|
453
|
+
const metadata = {
|
|
454
|
+
type: "DocumentType",
|
|
455
|
+
visibleName,
|
|
456
|
+
version: 0,
|
|
457
|
+
parent,
|
|
458
|
+
synced: true,
|
|
459
|
+
lastModified,
|
|
460
|
+
};
|
|
461
|
+
entryPromises.push(this.putText(`${documentId}.metadata`, JSON.stringify(metadata)));
|
|
462
|
+
// upload content file
|
|
463
|
+
const content = {
|
|
464
|
+
dummyDocument: false,
|
|
465
|
+
extraMetadata: {
|
|
466
|
+
LastTool: lastTool,
|
|
467
|
+
},
|
|
468
|
+
fileType: "epub",
|
|
469
|
+
pageCount: 0,
|
|
470
|
+
lastOpenedPage: 0,
|
|
471
|
+
lineHeight: typeof lineHeight === "string"
|
|
472
|
+
? builtinLineHeights[lineHeight]
|
|
473
|
+
: lineHeight,
|
|
474
|
+
margins: typeof margins === "string" ? builtinMargins[margins] : margins,
|
|
475
|
+
textScale: typeof textScale === "string"
|
|
476
|
+
? builtinTextScales[textScale]
|
|
477
|
+
: textScale,
|
|
478
|
+
pages: [],
|
|
479
|
+
coverPageNumber: cover === "first" ? 0 : -1,
|
|
480
|
+
formatVersion: 1,
|
|
481
|
+
orientation,
|
|
482
|
+
textAlignment,
|
|
483
|
+
fontName,
|
|
484
|
+
};
|
|
485
|
+
entryPromises.push(this.putText(`${documentId}.content`, JSON.stringify(content)));
|
|
486
|
+
// NOTE we technically get the entries a bit earlier, so could upload this
|
|
487
|
+
// before all contents are uploaded, but this also saves us from uploading
|
|
488
|
+
// the contents entry before all have uploaded successfully
|
|
489
|
+
const entries = await Promise.all(entryPromises);
|
|
490
|
+
const entry = await this.putEntries(documentId, entries);
|
|
491
|
+
// sync root hash
|
|
492
|
+
// if server undergoes update, this will fail, and we'll need to start
|
|
493
|
+
// again, up to `retries`.
|
|
494
|
+
for (;; --retries) {
|
|
495
|
+
try {
|
|
496
|
+
const [root, gen] = await this.getRootHash();
|
|
497
|
+
const rootEntries = await this.getEntries(root);
|
|
498
|
+
rootEntries.push(entry);
|
|
499
|
+
const { hash } = await this.putEntries("", rootEntries);
|
|
500
|
+
await this.putRootHash(hash, gen);
|
|
501
|
+
break;
|
|
502
|
+
}
|
|
503
|
+
catch (ex) {
|
|
504
|
+
if (retries <= 0 || !(ex instanceof GenerationError)) {
|
|
505
|
+
throw ex;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
if (notify) {
|
|
510
|
+
await this.syncComplete();
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* create an instance of the api
|
|
516
|
+
*
|
|
517
|
+
* This gets a temporary authentication token with the device token. If
|
|
518
|
+
* requests start failing, simply recreate the api instance.
|
|
519
|
+
*
|
|
520
|
+
* @param deviceToken - the device token proving this api instance is registered. Create one with {@link register}.
|
|
521
|
+
* @returns an api instance
|
|
522
|
+
*/
|
|
523
|
+
export async function remarkable(deviceToken, { fetch = globalThis.fetch, cache, subtle = globalThis.crypto?.subtle, authUrl = AUTH_URL, blobUrl = BLOB_URL, } = {}) {
|
|
524
|
+
const resp = await fetch(`${authUrl}/token/json/2/user/new`, {
|
|
525
|
+
method: "POST",
|
|
526
|
+
headers: {
|
|
527
|
+
Authorization: `Bearer ${deviceToken}`,
|
|
528
|
+
},
|
|
529
|
+
});
|
|
530
|
+
if (!resp.ok) {
|
|
531
|
+
throw new Error(`couldn't fetch auth token: ${resp.statusText}`);
|
|
532
|
+
}
|
|
533
|
+
const userToken = await resp.text();
|
|
534
|
+
return new Remarkable(userToken, fetch, cache, subtle, blobUrl);
|
|
535
|
+
}
|
package/dist/utils.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** convert a buffer to hex */
|
|
2
|
+
export declare function toHex(buffer: ArrayBuffer): string;
|
|
3
|
+
/** convert a hex string to a buffer */
|
|
4
|
+
export declare function fromHex(hex: string): Uint8Array;
|
|
5
|
+
/** concat buffers */
|
|
6
|
+
export declare function concatBuffers(buffers: Uint8Array[]): Uint8Array;
|
package/dist/utils.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** convert a buffer to hex */
|
|
2
|
+
export function toHex(buffer) {
|
|
3
|
+
return [...new Uint8Array(buffer)]
|
|
4
|
+
.map((x) => x.toString(16).padStart(2, "0"))
|
|
5
|
+
.join("");
|
|
6
|
+
}
|
|
7
|
+
/** convert a hex string to a buffer */
|
|
8
|
+
export function fromHex(hex) {
|
|
9
|
+
return new Uint8Array((hex.match(/../g) ?? []).map((h) => parseInt(h, 16)));
|
|
10
|
+
}
|
|
11
|
+
/** concat buffers */
|
|
12
|
+
export function concatBuffers(buffers) {
|
|
13
|
+
let length = 0;
|
|
14
|
+
for (const buff of buffers) {
|
|
15
|
+
length += buff.length;
|
|
16
|
+
}
|
|
17
|
+
const result = new Uint8Array(length);
|
|
18
|
+
let offset = 0;
|
|
19
|
+
for (const buff of buffers) {
|
|
20
|
+
result.set(buff, offset);
|
|
21
|
+
offset += buff.length;
|
|
22
|
+
}
|
|
23
|
+
return result;
|
|
24
|
+
}
|
package/dist/validate.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { validate as jtdValidate } from "jtd";
|
|
2
|
+
export function validate(schema, obj) {
|
|
3
|
+
const errors = jtdValidate(schema, obj);
|
|
4
|
+
if (errors.length) {
|
|
5
|
+
// TODO better errors
|
|
6
|
+
throw new Error(`couldn't validate schema: ${JSON.stringify(obj)} didn't match schema ${JSON.stringify(schema)}`);
|
|
7
|
+
}
|
|
8
|
+
}
|