rmapi-js 6.0.0 → 7.0.0
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 +1 -1
- package/README.md +28 -23
- package/dist/index.d.ts +720 -67
- package/dist/index.js +700 -65
- package/dist/lru.d.ts +8 -0
- package/dist/lru.js +64 -0
- package/dist/rmapi-js.esm.min.js +21 -1
- package/package.json +16 -14
package/dist/index.js
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* After getting a device token with the {@link register | `register`} method,
|
|
5
5
|
* persist it and create api instances using {@link remarkable | `remarkable`}.
|
|
6
6
|
* Outside of registration, all relevant methods are in
|
|
7
|
-
* {@link RemarkableApi | `RemarkableApi`}
|
|
7
|
+
* {@link RemarkableApi | `RemarkableApi`}, or it's interior
|
|
8
|
+
* {@link RawRemarkableApi | `RawRemarkableApi`} (for lower level functions).
|
|
8
9
|
*
|
|
9
10
|
* @example
|
|
10
11
|
* A simple rename
|
|
@@ -15,9 +16,10 @@
|
|
|
15
16
|
* const token = await register(code)
|
|
16
17
|
* // persist token
|
|
17
18
|
* const api = await remarkable(token);
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
19
|
+
* // list all items (documents and collections)
|
|
20
|
+
* const [first, ...rest] = api.listItems();
|
|
21
|
+
* // rename first item
|
|
22
|
+
* const entry = api.rename(first.hash, "new name");
|
|
21
23
|
* ```
|
|
22
24
|
*
|
|
23
25
|
* @example
|
|
@@ -36,29 +38,40 @@
|
|
|
36
38
|
* which is a uuid4 and a hash, which indicates it's current state, and changes
|
|
37
39
|
* as the item mutates, where the id is constant. Most mutable operations take
|
|
38
40
|
* the initial hash so that merge conflicts can be resolved. Each entry has a
|
|
39
|
-
* number of properties, but a key is the `parent`, which represents
|
|
40
|
-
* in the file structure. This will be another document id, or one of
|
|
41
|
-
* special ids, "" (the empty string) for the root directory, or "trash" for
|
|
42
|
-
* trash.
|
|
41
|
+
* number of properties, but a key property is the `parent`, which represents
|
|
42
|
+
* its parent in the file structure. This will be another document id, or one of
|
|
43
|
+
* two special ids, "" (the empty string) for the root directory, or "trash" for
|
|
44
|
+
* the trash.
|
|
45
|
+
*
|
|
46
|
+
* Detailed information about the low-level storage an apis can be found in
|
|
47
|
+
* {@link RawRemarkableApi | `RawRemarkableApi`}.
|
|
48
|
+
*
|
|
49
|
+
* Additionally, this entire api was reverse engineered, so some things are only
|
|
50
|
+
* `[speculative]`, or entirely `[unknown]`. If something breaks, please
|
|
51
|
+
* [file an issue!](https://github.com/erikbrinkman/rmapi-js/issues)
|
|
43
52
|
*
|
|
44
53
|
* @packageDocumentation
|
|
45
54
|
*/
|
|
46
55
|
import { fromByteArray } from "base64-js";
|
|
47
|
-
import
|
|
56
|
+
import CRC32C from "crc-32/crc32c";
|
|
57
|
+
import JSZip from "jszip";
|
|
58
|
+
import { boolean, discriminator, elements, enumeration, float64, int32, nullable, properties, string, timestamp, uint32, uint8, values, } from "jtd-ts";
|
|
48
59
|
import { v4 as uuid4 } from "uuid";
|
|
60
|
+
import { LruCache } from "./lru";
|
|
49
61
|
const AUTH_HOST = "https://webapp-prod.cloud.remarkable.engineering";
|
|
62
|
+
const RAW_HOST = "https://eu.tectonic.remarkable.com";
|
|
50
63
|
const SYNC_HOST = "https://web.eu.tectonic.remarkable.com";
|
|
51
64
|
const idReg = /^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}||trash)$/;
|
|
52
65
|
const hashReg = /^[0-9a-f]{64}$/;
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
}
|
|
66
|
+
const tag = properties({
|
|
67
|
+
name: string(),
|
|
68
|
+
timestamp: float64(),
|
|
69
|
+
}, undefined, true);
|
|
70
|
+
const pageTag = properties({
|
|
71
|
+
name: string(),
|
|
72
|
+
pageId: string(),
|
|
73
|
+
timestamp: float64(),
|
|
74
|
+
}, undefined, true);
|
|
62
75
|
const commonProperties = {
|
|
63
76
|
id: string(),
|
|
64
77
|
hash: string(),
|
|
@@ -71,7 +84,7 @@ const commonOptionalProperties = {
|
|
|
71
84
|
tags: elements(properties({
|
|
72
85
|
name: string(),
|
|
73
86
|
timestamp: float64(),
|
|
74
|
-
})),
|
|
87
|
+
}, undefined, true)),
|
|
75
88
|
};
|
|
76
89
|
const entry = discriminator("type", {
|
|
77
90
|
CollectionType: properties(commonProperties, commonOptionalProperties, true),
|
|
@@ -85,11 +98,25 @@ const entries = elements(entry);
|
|
|
85
98
|
const uploadEntry = properties({
|
|
86
99
|
docID: string(),
|
|
87
100
|
hash: string(),
|
|
88
|
-
});
|
|
89
|
-
const hashEntry = properties({ hash: string() });
|
|
101
|
+
}, undefined, true);
|
|
102
|
+
const hashEntry = properties({ hash: string() }, undefined, true);
|
|
90
103
|
const hashesEntry = properties({
|
|
91
104
|
hashes: values(string()),
|
|
92
|
-
});
|
|
105
|
+
}, undefined, true);
|
|
106
|
+
/** An error that gets thrown when the backend while trying to update
|
|
107
|
+
*
|
|
108
|
+
* IF you encounter this error, you likely just need to try th request again. If
|
|
109
|
+
* you're trying to do several high-level `put` operations simultaneously,
|
|
110
|
+
* you'll likely encounter this error. You should either try to do them
|
|
111
|
+
* serially, or call the low level api directly to do one generation update.
|
|
112
|
+
*
|
|
113
|
+
* @see {@link RawRemarkableApi | `RawRemarkableApi`}
|
|
114
|
+
*/
|
|
115
|
+
export class GenerationError extends Error {
|
|
116
|
+
constructor() {
|
|
117
|
+
super("root generation was stale; try put again");
|
|
118
|
+
}
|
|
119
|
+
}
|
|
93
120
|
/** an error that results from a failed request */
|
|
94
121
|
export class ResponseError extends Error {
|
|
95
122
|
/** the response status number */
|
|
@@ -124,7 +151,7 @@ export class ValidationError extends Error {
|
|
|
124
151
|
* @param code - the eight letter code a user got from `https://my.remarkable.com/device/browser/connect`.
|
|
125
152
|
* @returns the device token necessary for creating an api instace. These never expire so persist as long as necessary.
|
|
126
153
|
*/
|
|
127
|
-
export async function register(code, { deviceDesc = "browser-chrome", uuid = uuid4(), authHost = AUTH_HOST,
|
|
154
|
+
export async function register(code, { deviceDesc = "browser-chrome", uuid = uuid4(), authHost = AUTH_HOST, } = {}) {
|
|
128
155
|
if (code.length !== 8) {
|
|
129
156
|
throw new Error(`code should be length 8, but was ${code.length}`);
|
|
130
157
|
}
|
|
@@ -146,19 +173,433 @@ export async function register(code, { deviceDesc = "browser-chrome", uuid = uui
|
|
|
146
173
|
return await resp.text();
|
|
147
174
|
}
|
|
148
175
|
}
|
|
176
|
+
const documentMetadata = properties(undefined, {
|
|
177
|
+
authors: elements(string()),
|
|
178
|
+
title: string(),
|
|
179
|
+
publicationDate: string(),
|
|
180
|
+
publisher: string(),
|
|
181
|
+
}, true);
|
|
182
|
+
const cPagePage = properties({
|
|
183
|
+
id: string(),
|
|
184
|
+
idx: properties({
|
|
185
|
+
timestamp: string(),
|
|
186
|
+
value: string(),
|
|
187
|
+
}, undefined, true),
|
|
188
|
+
}, {
|
|
189
|
+
template: properties({
|
|
190
|
+
timestamp: string(),
|
|
191
|
+
value: string(),
|
|
192
|
+
}, undefined, true),
|
|
193
|
+
redir: properties({
|
|
194
|
+
timestamp: string(),
|
|
195
|
+
value: int32(),
|
|
196
|
+
}, undefined, true),
|
|
197
|
+
scrollTime: properties({
|
|
198
|
+
timestamp: string(),
|
|
199
|
+
value: timestamp(),
|
|
200
|
+
}, undefined, true),
|
|
201
|
+
verticalScroll: properties({
|
|
202
|
+
timestamp: string(),
|
|
203
|
+
value: float64(),
|
|
204
|
+
}, undefined, true),
|
|
205
|
+
deleted: properties({
|
|
206
|
+
timestamp: string(),
|
|
207
|
+
value: int32(),
|
|
208
|
+
}, undefined, true),
|
|
209
|
+
}, true);
|
|
210
|
+
const cPages = properties({
|
|
211
|
+
lastOpened: properties({
|
|
212
|
+
timestamp: string(),
|
|
213
|
+
value: string(),
|
|
214
|
+
}, undefined, true),
|
|
215
|
+
original: properties({
|
|
216
|
+
timestamp: string(),
|
|
217
|
+
value: int32(),
|
|
218
|
+
}, undefined, true),
|
|
219
|
+
pages: elements(cPagePage),
|
|
220
|
+
uuids: elements(properties({
|
|
221
|
+
first: string(),
|
|
222
|
+
second: uint32(),
|
|
223
|
+
}, undefined, true)),
|
|
224
|
+
}, undefined, true);
|
|
225
|
+
const collectionContent = properties(undefined, {
|
|
226
|
+
tags: elements(tag),
|
|
227
|
+
});
|
|
228
|
+
const documentContent = properties({
|
|
229
|
+
coverPageNumber: int32(),
|
|
230
|
+
documentMetadata,
|
|
231
|
+
extraMetadata: values(string()),
|
|
232
|
+
fileType: enumeration("epub", "notebook", "pdf"),
|
|
233
|
+
fontName: string(),
|
|
234
|
+
formatVersion: uint8(),
|
|
235
|
+
lineHeight: int32(),
|
|
236
|
+
margins: uint32(),
|
|
237
|
+
orientation: enumeration("portrait", "landscape"),
|
|
238
|
+
pageCount: uint32(),
|
|
239
|
+
sizeInBytes: string(),
|
|
240
|
+
textAlignment: enumeration("justify", "left"),
|
|
241
|
+
textScale: float64(),
|
|
242
|
+
}, {
|
|
243
|
+
cPages,
|
|
244
|
+
customZoomCenterX: float64(),
|
|
245
|
+
customZoomCenterY: float64(),
|
|
246
|
+
customZoomOrientation: enumeration("portrait", "landscape"),
|
|
247
|
+
customZoomPageHeight: float64(),
|
|
248
|
+
customZoomPageWidth: float64(),
|
|
249
|
+
customZoomScale: float64(),
|
|
250
|
+
dummyDocument: boolean(),
|
|
251
|
+
keyboardMetadata: properties({
|
|
252
|
+
count: uint32(),
|
|
253
|
+
timestamp: float64(),
|
|
254
|
+
}, undefined, true),
|
|
255
|
+
lastOpenedPage: uint32(),
|
|
256
|
+
originalPageCount: int32(),
|
|
257
|
+
pages: elements(string()),
|
|
258
|
+
pageTags: elements(pageTag),
|
|
259
|
+
redirectionPageMap: elements(int32()),
|
|
260
|
+
tags: elements(tag),
|
|
261
|
+
transform: properties({
|
|
262
|
+
m11: float64(),
|
|
263
|
+
m12: float64(),
|
|
264
|
+
m13: float64(),
|
|
265
|
+
m21: float64(),
|
|
266
|
+
m22: float64(),
|
|
267
|
+
m23: float64(),
|
|
268
|
+
m31: float64(),
|
|
269
|
+
m32: float64(),
|
|
270
|
+
m33: float64(),
|
|
271
|
+
}, undefined, true),
|
|
272
|
+
// eslint-disable-next-line spellcheck/spell-checker
|
|
273
|
+
viewBackgroundFilter: enumeration("off", "fullpage"),
|
|
274
|
+
zoomMode: enumeration("bestFit", "customFit", "fitToHeight", "fitToWidth"),
|
|
275
|
+
}, true);
|
|
276
|
+
const metadata = properties({
|
|
277
|
+
lastModified: string(),
|
|
278
|
+
parent: string(),
|
|
279
|
+
pinned: boolean(),
|
|
280
|
+
type: enumeration("DocumentType", "CollectionType"),
|
|
281
|
+
visibleName: string(),
|
|
282
|
+
}, {
|
|
283
|
+
lastOpened: string(),
|
|
284
|
+
lastOpenedPage: uint32(),
|
|
285
|
+
createdTime: string(),
|
|
286
|
+
deleted: boolean(),
|
|
287
|
+
metadatamodified: boolean(),
|
|
288
|
+
modified: boolean(),
|
|
289
|
+
synced: boolean(),
|
|
290
|
+
version: uint8(),
|
|
291
|
+
}, true);
|
|
292
|
+
const updatedRootHash = properties({
|
|
293
|
+
hash: string(),
|
|
294
|
+
generation: float64(),
|
|
295
|
+
}, undefined, true);
|
|
296
|
+
const rootHash = properties({
|
|
297
|
+
hash: string(),
|
|
298
|
+
generation: float64(),
|
|
299
|
+
schemaVersion: uint8(),
|
|
300
|
+
}, undefined, true);
|
|
301
|
+
async function digest(buff) {
|
|
302
|
+
const digest = await crypto.subtle.digest("SHA-256", buff);
|
|
303
|
+
return [...new Uint8Array(digest)]
|
|
304
|
+
.map((x) => x.toString(16).padStart(2, "0"))
|
|
305
|
+
.join("");
|
|
306
|
+
}
|
|
307
|
+
class RawRemarkable {
|
|
308
|
+
#authedFetch;
|
|
309
|
+
#rawHost;
|
|
310
|
+
/**
|
|
311
|
+
* a cache of all hashes we know exist
|
|
312
|
+
*
|
|
313
|
+
* The backend is a readonly file system of hashes to content. After a hash has
|
|
314
|
+
* been read or written successfully, we know it exists, and potentially it's
|
|
315
|
+
* contents. We don't want to cache large binary files, but we can cache the
|
|
316
|
+
* small text based metadata files. For binary files we write null, so we know
|
|
317
|
+
* not to write a a cached value again, but we'll still need to read it.
|
|
318
|
+
*/
|
|
319
|
+
#cache;
|
|
320
|
+
constructor(authedFetch, cache, rawHost) {
|
|
321
|
+
this.#authedFetch = authedFetch;
|
|
322
|
+
this.#cache = cache;
|
|
323
|
+
this.#rawHost = rawHost;
|
|
324
|
+
}
|
|
325
|
+
/** make an authorized request to remarkable */
|
|
326
|
+
async getRootHash() {
|
|
327
|
+
const res = await this.#authedFetch("GET", `${this.#rawHost}/sync/v4/root`);
|
|
328
|
+
const raw = await res.text();
|
|
329
|
+
const loaded = JSON.parse(raw);
|
|
330
|
+
if (!rootHash.guardAssert(loaded))
|
|
331
|
+
throw Error("invalid root hash");
|
|
332
|
+
const { hash, generation, schemaVersion } = loaded;
|
|
333
|
+
if (schemaVersion !== 3) {
|
|
334
|
+
throw new Error(`schema version ${schemaVersion} not supported`);
|
|
335
|
+
}
|
|
336
|
+
else if (!Number.isSafeInteger(generation)) {
|
|
337
|
+
throw new Error(`generation ${generation} was not a safe integer; please file a bug report`);
|
|
338
|
+
}
|
|
339
|
+
else {
|
|
340
|
+
return [hash, generation];
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
async #getHash(hash) {
|
|
344
|
+
if (!hashReg.test(hash)) {
|
|
345
|
+
throw new ValidationError(hash, hashReg, "hash was not a valid hash");
|
|
346
|
+
}
|
|
347
|
+
const resp = await this.#authedFetch("GET", `${this.#rawHost}/sync/v3/files/${hash}`);
|
|
348
|
+
// TODO switch to `.bytes()`.
|
|
349
|
+
const raw = await resp.arrayBuffer();
|
|
350
|
+
return new Uint8Array(raw);
|
|
351
|
+
}
|
|
352
|
+
async getHash(hash) {
|
|
353
|
+
const cached = this.#cache.get(hash);
|
|
354
|
+
if (cached != null) {
|
|
355
|
+
const enc = new TextEncoder();
|
|
356
|
+
return enc.encode(cached);
|
|
357
|
+
}
|
|
358
|
+
else {
|
|
359
|
+
const res = await this.#getHash(hash);
|
|
360
|
+
// mark that we know hash exists
|
|
361
|
+
const cacheVal = this.#cache.get(hash);
|
|
362
|
+
if (cacheVal === undefined) {
|
|
363
|
+
this.#cache.set(hash, null);
|
|
364
|
+
}
|
|
365
|
+
return res;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
async getText(hash) {
|
|
369
|
+
const cached = this.#cache.get(hash);
|
|
370
|
+
if (cached != null) {
|
|
371
|
+
return cached;
|
|
372
|
+
}
|
|
373
|
+
else {
|
|
374
|
+
// NOTE two simultaneous requests will fetch twice
|
|
375
|
+
const raw = await this.#getHash(hash);
|
|
376
|
+
const dec = new TextDecoder();
|
|
377
|
+
const res = dec.decode(raw);
|
|
378
|
+
this.#cache.set(hash, res);
|
|
379
|
+
return res;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
async getEntries(hash) {
|
|
383
|
+
const rawFile = await this.getText(hash);
|
|
384
|
+
const [version, ...rest] = rawFile.slice(0, -1).split("\n");
|
|
385
|
+
if (version != "3") {
|
|
386
|
+
throw new Error(`schema version ${version} not supported`);
|
|
387
|
+
}
|
|
388
|
+
else {
|
|
389
|
+
return rest.map((line) => {
|
|
390
|
+
const [hash, type, id, subfiles, size] = line.split(":");
|
|
391
|
+
if (hash === undefined ||
|
|
392
|
+
type === undefined ||
|
|
393
|
+
id === undefined ||
|
|
394
|
+
subfiles === undefined ||
|
|
395
|
+
size === undefined) {
|
|
396
|
+
throw new Error(`line '${line}' was not formatted correctly`);
|
|
397
|
+
}
|
|
398
|
+
else if (type === "80000000") {
|
|
399
|
+
return {
|
|
400
|
+
hash,
|
|
401
|
+
type: 80000000,
|
|
402
|
+
id,
|
|
403
|
+
subfiles: parseInt(subfiles),
|
|
404
|
+
size: parseInt(size),
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
else if (type === "0" && subfiles === "0") {
|
|
408
|
+
return {
|
|
409
|
+
hash,
|
|
410
|
+
type: 0,
|
|
411
|
+
id,
|
|
412
|
+
subfiles: 0,
|
|
413
|
+
size: parseInt(size),
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
else {
|
|
417
|
+
throw new Error(`line '${line}' was not formatted correctly`);
|
|
418
|
+
}
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
async getContent(hash) {
|
|
423
|
+
const raw = await this.getText(hash);
|
|
424
|
+
const loaded = JSON.parse(raw);
|
|
425
|
+
// jtd can't verify non-discriminated unions, in this case, we have fileType
|
|
426
|
+
// defined or not. As a result, we only do a normal guard for the presence
|
|
427
|
+
// of tags (e.g. empty content or only specify tags). Otherwise we'll throw
|
|
428
|
+
// the full error for the richer content.
|
|
429
|
+
if (collectionContent.guard(loaded)) {
|
|
430
|
+
return loaded;
|
|
431
|
+
}
|
|
432
|
+
else if (documentContent.guardAssert(loaded)) {
|
|
433
|
+
return loaded;
|
|
434
|
+
}
|
|
435
|
+
else {
|
|
436
|
+
throw Error("invalid content");
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
async getMetadata(hash) {
|
|
440
|
+
const raw = await this.getText(hash);
|
|
441
|
+
const loaded = JSON.parse(raw);
|
|
442
|
+
if (!metadata.guardAssert(loaded))
|
|
443
|
+
throw Error("invalid metadata");
|
|
444
|
+
return loaded;
|
|
445
|
+
}
|
|
446
|
+
async putRootHash(hash, generation, broadcast = true) {
|
|
447
|
+
if (!Number.isSafeInteger(generation)) {
|
|
448
|
+
throw new Error(`generation ${generation} was not a safe integer`);
|
|
449
|
+
}
|
|
450
|
+
else if (!hashReg.test(hash)) {
|
|
451
|
+
throw new ValidationError(hash, hashReg, "rootHash was not a valid hash");
|
|
452
|
+
}
|
|
453
|
+
const body = JSON.stringify({
|
|
454
|
+
hash,
|
|
455
|
+
generation,
|
|
456
|
+
broadcast,
|
|
457
|
+
});
|
|
458
|
+
const resp = await this.#authedFetch("PUT", `${this.#rawHost}/sync/v3/root`, { body });
|
|
459
|
+
const raw = await resp.text();
|
|
460
|
+
const loaded = JSON.parse(raw);
|
|
461
|
+
if (!updatedRootHash.guardAssert(loaded))
|
|
462
|
+
throw Error("invalid root hash");
|
|
463
|
+
const { hash: newHash, generation: newGen } = loaded;
|
|
464
|
+
if (Number.isSafeInteger(newGen)) {
|
|
465
|
+
return [newHash, newGen];
|
|
466
|
+
}
|
|
467
|
+
else {
|
|
468
|
+
throw new Error(`new generation ${newGen} was not a safe integer; please file a bug report`);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
async #putFile(hash, fileName, bytes) {
|
|
472
|
+
// if the hash is already in the cache, writing is pointless
|
|
473
|
+
if (!this.#cache.has(hash)) {
|
|
474
|
+
const crc = CRC32C.buf(bytes, 0);
|
|
475
|
+
const buff = new ArrayBuffer(4);
|
|
476
|
+
new DataView(buff).setInt32(0, crc, false);
|
|
477
|
+
const crcHash = fromByteArray(new Uint8Array(buff));
|
|
478
|
+
await this.#authedFetch("PUT", `${this.#rawHost}/sync/v3/files/${hash}`, {
|
|
479
|
+
body: bytes,
|
|
480
|
+
headers: {
|
|
481
|
+
"rm-filename": fileName,
|
|
482
|
+
// eslint-disable-next-line spellcheck/spell-checker
|
|
483
|
+
"x-goog-hash": `crc32c=${crcHash}`,
|
|
484
|
+
},
|
|
485
|
+
});
|
|
486
|
+
// mark that we know this hash exists
|
|
487
|
+
const cacheVal = this.#cache.get(hash);
|
|
488
|
+
if (cacheVal === undefined) {
|
|
489
|
+
this.#cache.set(hash, null);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
async putFile(id, bytes) {
|
|
494
|
+
const hash = await digest(bytes);
|
|
495
|
+
const res = {
|
|
496
|
+
id,
|
|
497
|
+
hash,
|
|
498
|
+
type: 0,
|
|
499
|
+
subfiles: 0,
|
|
500
|
+
size: bytes.length,
|
|
501
|
+
};
|
|
502
|
+
return [res, this.#putFile(hash, id, bytes)];
|
|
503
|
+
}
|
|
504
|
+
async putText(id, text) {
|
|
505
|
+
const enc = new TextEncoder();
|
|
506
|
+
const bytes = enc.encode(text);
|
|
507
|
+
const [ent, upload] = await this.putFile(id, bytes);
|
|
508
|
+
return [
|
|
509
|
+
ent,
|
|
510
|
+
upload.then(() => {
|
|
511
|
+
// on success, write to cache
|
|
512
|
+
this.#cache.set(ent.hash, text);
|
|
513
|
+
}),
|
|
514
|
+
];
|
|
515
|
+
}
|
|
516
|
+
async putContent(id, content) {
|
|
517
|
+
if (!id.endsWith(".content")) {
|
|
518
|
+
throw new Error(`id ${id} did not end with '.content'`);
|
|
519
|
+
}
|
|
520
|
+
else {
|
|
521
|
+
return await this.putText(id, JSON.stringify(content));
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
async putMetadata(id, metadata) {
|
|
525
|
+
if (!id.endsWith(".metadata")) {
|
|
526
|
+
throw new Error(`id ${id} did not end with '.metadata'`);
|
|
527
|
+
}
|
|
528
|
+
else {
|
|
529
|
+
return await this.putText(id, JSON.stringify(metadata));
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
async putEntries(id, entries) {
|
|
533
|
+
// NOTE collections have a special hash function, the hash of their
|
|
534
|
+
// contents, so this needs to be different
|
|
535
|
+
entries.sort((a, b) => a.id.localeCompare(b.id));
|
|
536
|
+
const hashBuff = new Uint8Array(entries.length * 32);
|
|
537
|
+
for (const [start, { hash }] of entries.entries()) {
|
|
538
|
+
for (const [i, byte] of (hash.match(/../g) ?? []).entries()) {
|
|
539
|
+
hashBuff[start * 32 + i] = parseInt(byte, 16);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
const hash = await digest(hashBuff);
|
|
543
|
+
const size = entries.reduce((acc, ent) => acc + ent.size, 0);
|
|
544
|
+
const records = ["3\n"];
|
|
545
|
+
for (const { hash, type, id, subfiles, size } of entries) {
|
|
546
|
+
records.push(`${hash}:${type}:${id}:${subfiles}:${size}\n`);
|
|
547
|
+
}
|
|
548
|
+
const res = {
|
|
549
|
+
id,
|
|
550
|
+
hash,
|
|
551
|
+
type: 80000000,
|
|
552
|
+
subfiles: entries.length,
|
|
553
|
+
size,
|
|
554
|
+
};
|
|
555
|
+
const enc = new TextEncoder();
|
|
556
|
+
return [
|
|
557
|
+
res,
|
|
558
|
+
// NOTE when monitoring requests, this had the extension .docSchema appended, but I'm not entirely sure why
|
|
559
|
+
this.#putFile(hash, `${id}.docSchema`, enc.encode(records.join(""))),
|
|
560
|
+
];
|
|
561
|
+
}
|
|
562
|
+
dumpCache() {
|
|
563
|
+
return JSON.stringify(Object.fromEntries(this.#cache));
|
|
564
|
+
}
|
|
565
|
+
clearCache() {
|
|
566
|
+
this.#cache.clear();
|
|
567
|
+
}
|
|
568
|
+
}
|
|
149
569
|
/** the implementation of that api */
|
|
150
570
|
class Remarkable {
|
|
151
571
|
#userToken;
|
|
152
|
-
#fetch;
|
|
153
572
|
#syncHost;
|
|
154
|
-
|
|
573
|
+
/** the same cache that underlies the raw api, allowing us to modify it */
|
|
574
|
+
#cache;
|
|
575
|
+
raw;
|
|
576
|
+
#lastHashGen;
|
|
577
|
+
constructor(userToken, syncHost, rawHost, cache) {
|
|
155
578
|
this.#userToken = userToken;
|
|
156
|
-
this.#fetch = fetch;
|
|
157
579
|
this.#syncHost = syncHost;
|
|
580
|
+
this.#cache = cache;
|
|
581
|
+
this.raw = new RawRemarkable((method, url, { body, headers } = {}) => this.#authedFetch(url, { method, body, headers }), cache, rawHost);
|
|
582
|
+
}
|
|
583
|
+
async #getRootHash(refresh = false) {
|
|
584
|
+
if (refresh || this.#lastHashGen === undefined) {
|
|
585
|
+
this.#lastHashGen = await this.raw.getRootHash();
|
|
586
|
+
}
|
|
587
|
+
return this.#lastHashGen;
|
|
588
|
+
}
|
|
589
|
+
async #putRootHash(hash, generation) {
|
|
590
|
+
try {
|
|
591
|
+
this.#lastHashGen = await this.raw.putRootHash(hash, generation);
|
|
592
|
+
}
|
|
593
|
+
catch (ex) {
|
|
594
|
+
// if we hit a generation error, invalidate our cached generation
|
|
595
|
+
if (ex instanceof GenerationError) {
|
|
596
|
+
this.#lastHashGen = undefined;
|
|
597
|
+
}
|
|
598
|
+
throw ex;
|
|
599
|
+
}
|
|
158
600
|
}
|
|
159
|
-
/** make an authorized request to remarkable */
|
|
160
601
|
async #authedFetch(url, { body, method = "POST", headers = {}, }) {
|
|
161
|
-
const resp = await
|
|
602
|
+
const resp = await fetch(url, {
|
|
162
603
|
method,
|
|
163
604
|
headers: {
|
|
164
605
|
Authorization: `Bearer ${this.#userToken}`,
|
|
@@ -168,7 +609,12 @@ class Remarkable {
|
|
|
168
609
|
});
|
|
169
610
|
if (!resp.ok) {
|
|
170
611
|
const msg = await resp.text();
|
|
171
|
-
|
|
612
|
+
if (msg === '{"message":"precondition failed"}\n') {
|
|
613
|
+
throw new GenerationError();
|
|
614
|
+
}
|
|
615
|
+
else {
|
|
616
|
+
throw new ResponseError(resp.status, resp.statusText, `failed reMarkable request: ${msg}`);
|
|
617
|
+
}
|
|
172
618
|
}
|
|
173
619
|
else {
|
|
174
620
|
return resp;
|
|
@@ -200,14 +646,149 @@ class Remarkable {
|
|
|
200
646
|
const raw = await resp.text();
|
|
201
647
|
return JSON.parse(raw);
|
|
202
648
|
}
|
|
203
|
-
/** list all
|
|
204
|
-
async
|
|
649
|
+
/** list all items */
|
|
650
|
+
async listItems() {
|
|
205
651
|
const res = await this.#fileRequest();
|
|
206
|
-
|
|
652
|
+
if (!entries.guardAssert(res))
|
|
653
|
+
throw Error("invalid entries");
|
|
654
|
+
return res;
|
|
655
|
+
}
|
|
656
|
+
async listIds(refresh = false) {
|
|
657
|
+
const [hash] = await this.#getRootHash(refresh);
|
|
658
|
+
const entries = await this.raw.getEntries(hash);
|
|
659
|
+
return entries.map(({ id, hash }) => ({ id, hash }));
|
|
660
|
+
}
|
|
661
|
+
async getContent(hash) {
|
|
662
|
+
const entries = await this.raw.getEntries(hash);
|
|
663
|
+
const [cont] = entries.filter((e) => e.id.endsWith(".content"));
|
|
664
|
+
if (cont === undefined) {
|
|
665
|
+
throw new Error(`couldn't find contents for hash ${hash}`);
|
|
666
|
+
}
|
|
667
|
+
else {
|
|
668
|
+
return await this.raw.getContent(cont.hash);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
async getMetadata(hash) {
|
|
672
|
+
const entries = await this.raw.getEntries(hash);
|
|
673
|
+
const [meta] = entries.filter((e) => e.id.endsWith(".metadata"));
|
|
674
|
+
if (meta === undefined) {
|
|
675
|
+
throw new Error(`couldn't find metadata for hash ${hash}`);
|
|
676
|
+
}
|
|
677
|
+
else {
|
|
678
|
+
return await this.raw.getMetadata(meta.hash);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
async getPdf(hash) {
|
|
682
|
+
const entries = await this.raw.getEntries(hash);
|
|
683
|
+
const [pdf] = entries.filter((e) => e.id.endsWith(".pdf"));
|
|
684
|
+
if (pdf === undefined) {
|
|
685
|
+
throw new Error(`couldn't find pdf for hash ${hash}`);
|
|
686
|
+
}
|
|
687
|
+
else {
|
|
688
|
+
return await this.raw.getHash(pdf.hash);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
async getEpub(hash) {
|
|
692
|
+
const entries = await this.raw.getEntries(hash);
|
|
693
|
+
const [epub] = entries.filter((e) => e.id.endsWith(".epub"));
|
|
694
|
+
if (epub === undefined) {
|
|
695
|
+
throw new Error(`couldn't find epub for hash ${hash}`);
|
|
696
|
+
}
|
|
697
|
+
else {
|
|
698
|
+
return await this.raw.getHash(epub.hash);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
async getDocument(hash) {
|
|
702
|
+
const entries = await this.raw.getEntries(hash);
|
|
703
|
+
const zip = new JSZip();
|
|
704
|
+
for (const entry of entries) {
|
|
705
|
+
// TODO if this is .metadata we might want to assert type === "DocumentType"
|
|
706
|
+
zip.file(entry.id, this.raw.getHash(entry.hash));
|
|
707
|
+
}
|
|
708
|
+
return zip.generateAsync({ type: "uint8array" });
|
|
709
|
+
}
|
|
710
|
+
async #putFile(visibleName, fileType, buffer, { refresh, parent = "", pinned = false, zoomMode = "bestFit", viewBackgroundFilter, textScale = 1, textAlignment = "justify", fontName = "", coverPageNumber = -1, authors, title, publicationDate, publisher, extraMetadata = {}, lineHeight = -1, margins = 125, orientation = "portrait", tags, }) {
|
|
711
|
+
if (parent && !idReg.test(parent)) {
|
|
712
|
+
throw new ValidationError(parent, idReg, "parent must be a valid document id");
|
|
713
|
+
}
|
|
714
|
+
const id = uuid4();
|
|
715
|
+
const now = new Date();
|
|
716
|
+
const enc = new TextEncoder();
|
|
717
|
+
// upload raw files, and get root hash
|
|
718
|
+
const [[contentEntry, uploadContent], [metadataEntry, uploadMetadata], [pagedataEntry, uploadPagedata], [fileEntry, uploadFile], [rootHash, generation],] = await Promise.all([
|
|
719
|
+
this.raw.putContent(`${id}.content`, {
|
|
720
|
+
coverPageNumber,
|
|
721
|
+
documentMetadata: { authors, title, publicationDate, publisher },
|
|
722
|
+
extraMetadata,
|
|
723
|
+
lineHeight,
|
|
724
|
+
margins,
|
|
725
|
+
orientation,
|
|
726
|
+
fileType,
|
|
727
|
+
formatVersion: 1,
|
|
728
|
+
tags: tags?.map((name) => ({ name, timestamp: +now })) ?? [],
|
|
729
|
+
fontName,
|
|
730
|
+
textAlignment,
|
|
731
|
+
textScale,
|
|
732
|
+
zoomMode,
|
|
733
|
+
viewBackgroundFilter,
|
|
734
|
+
// NOTE for some reason we need to "fake" the number of pages at 1, and
|
|
735
|
+
// create "valid" output for that
|
|
736
|
+
originalPageCount: 1,
|
|
737
|
+
pageCount: 1,
|
|
738
|
+
pageTags: [],
|
|
739
|
+
pages: [uuid4()],
|
|
740
|
+
redirectionPageMap: [0],
|
|
741
|
+
sizeInBytes: buffer.length.toFixed(),
|
|
742
|
+
}),
|
|
743
|
+
this.raw.putMetadata(`${id}.metadata`, {
|
|
744
|
+
parent,
|
|
745
|
+
pinned,
|
|
746
|
+
lastModified: (+now).toFixed(),
|
|
747
|
+
createdTime: (+now).toFixed(),
|
|
748
|
+
type: "DocumentType",
|
|
749
|
+
visibleName,
|
|
750
|
+
lastOpened: "0",
|
|
751
|
+
lastOpenedPage: 0,
|
|
752
|
+
}),
|
|
753
|
+
// eslint-disable-next-line spellcheck/spell-checker
|
|
754
|
+
this.raw.putFile(`${id}.pagedata`, enc.encode("\n")),
|
|
755
|
+
this.raw.putFile(`${id}.${fileType}`, buffer),
|
|
756
|
+
this.#getRootHash(refresh),
|
|
757
|
+
]);
|
|
758
|
+
// now fetch root entries and upload this file entry
|
|
759
|
+
const [[collectionEntry, uploadCollection], rootEntries] = await Promise.all([
|
|
760
|
+
this.raw.putEntries(id, [
|
|
761
|
+
contentEntry,
|
|
762
|
+
metadataEntry,
|
|
763
|
+
pagedataEntry,
|
|
764
|
+
fileEntry,
|
|
765
|
+
]),
|
|
766
|
+
this.raw.getEntries(rootHash),
|
|
767
|
+
]);
|
|
768
|
+
// now upload a new root entry
|
|
769
|
+
rootEntries.push(collectionEntry);
|
|
770
|
+
const [rootEntry, uploadRoot] = await this.raw.putEntries("root", rootEntries);
|
|
771
|
+
// before updating the root hash, first upload everything
|
|
772
|
+
await Promise.all([
|
|
773
|
+
uploadContent,
|
|
774
|
+
uploadMetadata,
|
|
775
|
+
uploadPagedata,
|
|
776
|
+
uploadFile,
|
|
777
|
+
uploadCollection,
|
|
778
|
+
uploadRoot,
|
|
779
|
+
]);
|
|
780
|
+
await this.#putRootHash(rootEntry.hash, generation);
|
|
781
|
+
return { id, hash: collectionEntry.hash };
|
|
782
|
+
}
|
|
783
|
+
async putPdf(visibleName, buffer, opts = {}) {
|
|
784
|
+
return await this.#putFile(visibleName, "pdf", buffer, opts);
|
|
785
|
+
}
|
|
786
|
+
async putEpub(visibleName, buffer, opts = {}) {
|
|
787
|
+
return await this.#putFile(visibleName, "epub", buffer, opts);
|
|
207
788
|
}
|
|
208
789
|
/** upload a file */
|
|
209
|
-
async #uploadFile(parent, visibleName, buffer, contentType
|
|
210
|
-
if (
|
|
790
|
+
async #uploadFile(parent, visibleName, buffer, contentType) {
|
|
791
|
+
if (!idReg.test(parent)) {
|
|
211
792
|
throw new ValidationError(parent, idReg, "parent must be a valid document id");
|
|
212
793
|
}
|
|
213
794
|
const res = await this.#fileRequest({
|
|
@@ -216,22 +797,26 @@ class Remarkable {
|
|
|
216
797
|
contentType,
|
|
217
798
|
body: buffer,
|
|
218
799
|
});
|
|
219
|
-
|
|
800
|
+
this.#lastHashGen = undefined; // clear the hash gen since this will change it
|
|
801
|
+
if (!uploadEntry.guardAssert(res))
|
|
802
|
+
throw Error("invalid upload entry");
|
|
803
|
+
const { hash, docID: id } = res;
|
|
804
|
+
return { hash, id };
|
|
220
805
|
}
|
|
221
806
|
/** create a folder */
|
|
222
|
-
async createFolder(visibleName, { parent = ""
|
|
223
|
-
return await this.#uploadFile(parent, visibleName, new
|
|
807
|
+
async createFolder(visibleName, { parent = "" } = {}) {
|
|
808
|
+
return await this.#uploadFile(parent, visibleName, new Uint8Array(0), "folder");
|
|
224
809
|
}
|
|
225
810
|
/** upload an epub */
|
|
226
|
-
async uploadEpub(visibleName, buffer, { parent = ""
|
|
227
|
-
return await this.#uploadFile(parent, visibleName, buffer, "application/epub+zip"
|
|
811
|
+
async uploadEpub(visibleName, buffer, { parent = "" } = {}) {
|
|
812
|
+
return await this.#uploadFile(parent, visibleName, buffer, "application/epub+zip");
|
|
228
813
|
}
|
|
229
814
|
/** upload a pdf */
|
|
230
|
-
async uploadPdf(visibleName, buffer, { parent = ""
|
|
231
|
-
return await this.#uploadFile(parent, visibleName, buffer, "application/pdf"
|
|
815
|
+
async uploadPdf(visibleName, buffer, { parent = "" } = {}) {
|
|
816
|
+
return await this.#uploadFile(parent, visibleName, buffer, "application/pdf");
|
|
232
817
|
}
|
|
233
|
-
async #modify(hash, properties
|
|
234
|
-
if (
|
|
818
|
+
async #modify(hash, properties) {
|
|
819
|
+
if (!hashReg.test(hash)) {
|
|
235
820
|
throw new ValidationError(hash, hashReg, "hash to modify was not a valid hash");
|
|
236
821
|
}
|
|
237
822
|
// this does not allow setting pinned, although I don't know why
|
|
@@ -240,30 +825,31 @@ class Remarkable {
|
|
|
240
825
|
body: JSON.stringify(properties),
|
|
241
826
|
method: "PATCH",
|
|
242
827
|
});
|
|
243
|
-
|
|
828
|
+
this.#lastHashGen = undefined; // clear the hash gen since this will change it
|
|
829
|
+
if (!hashEntry.guardAssert(res))
|
|
830
|
+
throw Error("invalid hash entry");
|
|
831
|
+
return res;
|
|
244
832
|
}
|
|
245
833
|
/** move an entry */
|
|
246
|
-
async move(hash, parent
|
|
247
|
-
if (
|
|
834
|
+
async move(hash, parent) {
|
|
835
|
+
if (!idReg.test(parent)) {
|
|
248
836
|
throw new ValidationError(parent, idReg, "parent must be a valid document id");
|
|
249
837
|
}
|
|
250
|
-
return await this.#modify(hash, { parent }
|
|
838
|
+
return await this.#modify(hash, { parent });
|
|
251
839
|
}
|
|
252
840
|
/** delete an entry */
|
|
253
|
-
async delete(hash
|
|
254
|
-
return await this.move(hash, "trash"
|
|
841
|
+
async delete(hash) {
|
|
842
|
+
return await this.move(hash, "trash");
|
|
255
843
|
}
|
|
256
844
|
/** rename an entry */
|
|
257
|
-
async rename(hash, visibleName
|
|
258
|
-
return await this.#modify(hash, { file_name: visibleName }
|
|
845
|
+
async rename(hash, visibleName) {
|
|
846
|
+
return await this.#modify(hash, { file_name: visibleName });
|
|
259
847
|
}
|
|
260
848
|
/** bulk modify hashes */
|
|
261
|
-
async #bulkModify(hashes, properties
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
throw new ValidationError(hashes.join(", "), hashReg, "hashes to modify were not a valid hashes");
|
|
266
|
-
}
|
|
849
|
+
async #bulkModify(hashes, properties) {
|
|
850
|
+
const invalidHashes = hashes.filter((hash) => !hashReg.test(hash));
|
|
851
|
+
if (invalidHashes.length) {
|
|
852
|
+
throw new ValidationError(hashes.join(", "), hashReg, "hashes to modify were not a valid hashes");
|
|
267
853
|
}
|
|
268
854
|
// this does not allow setting pinned, although I don't know why
|
|
269
855
|
const res = await this.#fileRequest({
|
|
@@ -273,20 +859,59 @@ class Remarkable {
|
|
|
273
859
|
}),
|
|
274
860
|
method: "PATCH",
|
|
275
861
|
});
|
|
276
|
-
|
|
862
|
+
this.#lastHashGen = undefined; // clear the hash gen since this will change it
|
|
863
|
+
if (!hashesEntry.guardAssert(res))
|
|
864
|
+
throw Error("invalid hashes entry");
|
|
865
|
+
return res;
|
|
277
866
|
}
|
|
278
867
|
/** move many hashes */
|
|
279
|
-
async bulkMove(hashes, parent
|
|
280
|
-
if (
|
|
868
|
+
async bulkMove(hashes, parent) {
|
|
869
|
+
if (!idReg.test(parent)) {
|
|
281
870
|
throw new ValidationError(parent, idReg, "parent must be a valid document id");
|
|
282
871
|
}
|
|
283
|
-
return await this.#bulkModify(hashes, { parent }
|
|
872
|
+
return await this.#bulkModify(hashes, { parent });
|
|
284
873
|
}
|
|
285
874
|
/** delete many hashes */
|
|
286
|
-
async bulkDelete(hashes
|
|
287
|
-
return await this.bulkMove(hashes, "trash"
|
|
875
|
+
async bulkDelete(hashes) {
|
|
876
|
+
return await this.bulkMove(hashes, "trash");
|
|
877
|
+
}
|
|
878
|
+
// TODO ostensibly we could implement a bulk rename but idk why
|
|
879
|
+
/** dump the raw cache */
|
|
880
|
+
dumpCache() {
|
|
881
|
+
return this.raw.dumpCache();
|
|
882
|
+
}
|
|
883
|
+
async pruneCache(refresh) {
|
|
884
|
+
const [rootHash] = await this.#getRootHash(refresh);
|
|
885
|
+
// the keys to delete, we'll drop every key we can currently reach
|
|
886
|
+
const toDelete = new Set(this.#cache.keys());
|
|
887
|
+
// bfs through entries (to semi-optimize promise waiting, although this
|
|
888
|
+
// should only go one step) to track all hashes encountered
|
|
889
|
+
// NOTE that we could increase the cache in this process, or it's possible
|
|
890
|
+
// for other calls to increase the cache with misc values.
|
|
891
|
+
let entries = [await this.raw.getEntries(rootHash)];
|
|
892
|
+
let nextEntries = [];
|
|
893
|
+
while (entries.length) {
|
|
894
|
+
for (const entryList of entries) {
|
|
895
|
+
for (const { hash, type } of entryList) {
|
|
896
|
+
toDelete.add(hash);
|
|
897
|
+
if (type === 80000000) {
|
|
898
|
+
nextEntries.push(this.raw.getEntries(hash));
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
entries = await Promise.all(nextEntries);
|
|
903
|
+
nextEntries = [];
|
|
904
|
+
}
|
|
905
|
+
for (const key of toDelete) {
|
|
906
|
+
this.#cache.delete(key);
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
// finally remove any values we had in the cache initially, but couldn't reach
|
|
910
|
+
clearCache() {
|
|
911
|
+
this.raw.clearCache();
|
|
288
912
|
}
|
|
289
913
|
}
|
|
914
|
+
const cached = values(nullable(string()));
|
|
290
915
|
/**
|
|
291
916
|
* create an instance of the api
|
|
292
917
|
*
|
|
@@ -297,7 +922,7 @@ class Remarkable {
|
|
|
297
922
|
* registered. Create one with {@link register}.
|
|
298
923
|
* @returns an api instance
|
|
299
924
|
*/
|
|
300
|
-
export async function remarkable(deviceToken, {
|
|
925
|
+
export async function remarkable(deviceToken, { authHost = AUTH_HOST, syncHost = SYNC_HOST, rawHost = RAW_HOST, cache, maxCacheSize = Infinity, } = {}) {
|
|
301
926
|
const resp = await fetch(`${authHost}/token/json/2/user/new`, {
|
|
302
927
|
method: "POST",
|
|
303
928
|
headers: {
|
|
@@ -308,5 +933,15 @@ export async function remarkable(deviceToken, { fetch = globalThis.fetch, authHo
|
|
|
308
933
|
throw new Error(`couldn't fetch auth token: ${resp.statusText}`);
|
|
309
934
|
}
|
|
310
935
|
const userToken = await resp.text();
|
|
311
|
-
|
|
936
|
+
const initCache = JSON.parse(cache ?? "{}");
|
|
937
|
+
if (cached.guard(initCache)) {
|
|
938
|
+
const entries = Object.entries(initCache);
|
|
939
|
+
const cache = maxCacheSize === Infinity
|
|
940
|
+
? new Map(entries)
|
|
941
|
+
: new LruCache(maxCacheSize, entries);
|
|
942
|
+
return new Remarkable(userToken, syncHost, rawHost, cache);
|
|
943
|
+
}
|
|
944
|
+
else {
|
|
945
|
+
throw new Error("cache was not a valid cache (json string mapping); your cache must be corrupted somehow. Either initialize remarkable without a cache, or fix its format.");
|
|
946
|
+
}
|
|
312
947
|
}
|