rmapi-js 11.1.2 → 11.2.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/dist/index.d.ts +104 -13
- package/dist/index.js +343 -124
- package/dist/raw.d.ts +61 -10
- package/dist/raw.js +62 -13
- package/dist/rm5.d.ts +111 -0
- package/dist/rm5.js +181 -0
- package/dist/rm6.d.ts +334 -0
- package/dist/rm6.js +728 -0
- package/dist/rmapi-js.esm.min.js +8 -8
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -56,12 +56,77 @@ import { v4 as uuid4 } from "uuid";
|
|
|
56
56
|
import { z } from "zod";
|
|
57
57
|
import { HashNotFoundError, ValidationError } from "./error.js";
|
|
58
58
|
import { LruCache } from "./lru.js";
|
|
59
|
-
import { RawRemarkable, } from "./raw.js";
|
|
59
|
+
import { parseMetadata, RawRemarkable, } from "./raw.js";
|
|
60
60
|
export { deviceScreens, } from "./devices.js";
|
|
61
61
|
export { HashNotFoundError, ValidationError } from "./error.js";
|
|
62
|
+
export { decodeBrush, rmColors } from "./rm5.js";
|
|
63
|
+
export { crdtKey, END_MARKER, parseRmScene, ROOT_ID } from "./rm6.js";
|
|
62
64
|
const AUTH_HOST = "https://webapp-prod.cloud.remarkable.engineering";
|
|
63
65
|
const RAW_HOST = "https://eu.tectonic.remarkable.com";
|
|
64
66
|
const UPLOAD_HOST = "https://internal.cloud.remarkable.com";
|
|
67
|
+
/** the parent id of the root directory */
|
|
68
|
+
const ROOT_ID = "";
|
|
69
|
+
/** the parent id of the trash */
|
|
70
|
+
const TRASH_ID = "trash";
|
|
71
|
+
/** the id of the root entry list */
|
|
72
|
+
const ROOT_LIST = "root";
|
|
73
|
+
/** the file name of the root entry index */
|
|
74
|
+
const ROOT_SCHEMA = `${ROOT_LIST}.docSchema`;
|
|
75
|
+
/** base backoff in milliseconds for transient request retries */
|
|
76
|
+
const TRANSIENT_BASE_MS = 200;
|
|
77
|
+
/** base backoff in milliseconds for generation-conflict retries */
|
|
78
|
+
const GENERATION_BASE_MS = 25;
|
|
79
|
+
/** resolve after a number of milliseconds */
|
|
80
|
+
function sleep(ms) {
|
|
81
|
+
return new Promise((resolve) => {
|
|
82
|
+
const timer = setTimeout(resolve, ms);
|
|
83
|
+
// don't let a pending backoff keep a node process alive
|
|
84
|
+
timer.unref?.();
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
/** exponential backoff with full jitter, capped at 30 seconds */
|
|
88
|
+
function backoffMs(attempt, baseMs) {
|
|
89
|
+
const capped = Math.min(baseMs * 2 ** attempt, 30_000);
|
|
90
|
+
return Math.random() * capped;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* a mutex acquired by async iteration
|
|
94
|
+
*
|
|
95
|
+
* `for await (const _lock of mutex) { ... }` runs the body with the lock held
|
|
96
|
+
* and releases it when the body exits — by return, throw, or break — the way a
|
|
97
|
+
* `using` block would. Waiters are served in acquisition order.
|
|
98
|
+
*/
|
|
99
|
+
class Mutex {
|
|
100
|
+
#tail = Promise.resolve();
|
|
101
|
+
async *[Symbol.asyncIterator]() {
|
|
102
|
+
const previous = this.#tail;
|
|
103
|
+
let release;
|
|
104
|
+
this.#tail = new Promise((resolve) => {
|
|
105
|
+
release = resolve;
|
|
106
|
+
});
|
|
107
|
+
await previous;
|
|
108
|
+
try {
|
|
109
|
+
yield;
|
|
110
|
+
}
|
|
111
|
+
finally {
|
|
112
|
+
release();
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/** the ordered page ids of a document's content, from cPages or the legacy list */
|
|
117
|
+
function pageOrder(content) {
|
|
118
|
+
if ("cPages" in content && content.cPages) {
|
|
119
|
+
return content.cPages.pages
|
|
120
|
+
.filter((page) => page.deleted === undefined)
|
|
121
|
+
.map((page) => page.id);
|
|
122
|
+
}
|
|
123
|
+
else if ("pages" in content && content.pages) {
|
|
124
|
+
return content.pages;
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
return [];
|
|
128
|
+
}
|
|
129
|
+
}
|
|
65
130
|
// The section has all the types that are stored in the remarkable cloud.
|
|
66
131
|
const idReg = /^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}||trash)$/;
|
|
67
132
|
/** An error that gets thrown when the backend while trying to update
|
|
@@ -128,18 +193,29 @@ class Remarkable {
|
|
|
128
193
|
/** the same cache that underlies the raw api, allowing us to modify it */
|
|
129
194
|
#cache;
|
|
130
195
|
raw;
|
|
196
|
+
#maxGenerationRetries;
|
|
197
|
+
#maxTransientRetries;
|
|
131
198
|
#lastHashGen;
|
|
132
199
|
#schemaVersion;
|
|
133
|
-
|
|
200
|
+
/** serializes root updates on this instance so they don't self-conflict */
|
|
201
|
+
#rootMutex = new Mutex();
|
|
202
|
+
constructor(sessionToken, rawHost, uploadHost, cache, maxGenerationRetries, maxTransientRetries) {
|
|
134
203
|
this.#sessionToken = sessionToken;
|
|
135
204
|
this.#cache = cache;
|
|
205
|
+
this.#maxGenerationRetries = maxGenerationRetries;
|
|
206
|
+
this.#maxTransientRetries = maxTransientRetries;
|
|
136
207
|
this.raw = new RawRemarkable((method, url, { body, headers } = {}) => this.#authedFetch(url, { method, body, headers }), cache, rawHost, uploadHost);
|
|
137
208
|
}
|
|
138
209
|
async #getRootHash(refresh = false) {
|
|
139
210
|
if (refresh || this.#lastHashGen === undefined) {
|
|
140
211
|
const [hash, generation, schemaVersion] = await this.raw.getRootHash();
|
|
141
|
-
|
|
142
|
-
|
|
212
|
+
// a slow older fetch can resolve after a newer write; only accept it if
|
|
213
|
+
// it doesn't regress the cached generation past a committed root
|
|
214
|
+
if (this.#lastHashGen === undefined ||
|
|
215
|
+
generation >= this.#lastHashGen[1]) {
|
|
216
|
+
this.#lastHashGen = [hash, generation];
|
|
217
|
+
this.#schemaVersion = schemaVersion;
|
|
218
|
+
}
|
|
143
219
|
}
|
|
144
220
|
return [...this.#lastHashGen, this.#schemaVersion];
|
|
145
221
|
}
|
|
@@ -156,28 +232,99 @@ class Remarkable {
|
|
|
156
232
|
throw ex;
|
|
157
233
|
}
|
|
158
234
|
}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
235
|
+
/**
|
|
236
|
+
* run a root-mutating operation, retrying on generation conflicts
|
|
237
|
+
*
|
|
238
|
+
* On a {@link GenerationError | `GenerationError`} the cached generation was
|
|
239
|
+
* already invalidated by {@link #putRootHash}, so re-running `op` re-reads the
|
|
240
|
+
* latest root and re-applies the change. Callers must resolve any random ids
|
|
241
|
+
* before this so retries reuse the same (cached) blobs.
|
|
242
|
+
*/
|
|
243
|
+
async #withRetry(op) {
|
|
244
|
+
// hold the root lock across the whole read-merge-write so concurrent
|
|
245
|
+
// mutators serialize instead of sharing a generation and forcing each
|
|
246
|
+
// other into avoidable conflicts
|
|
247
|
+
for await (const _lock of this.#rootMutex) {
|
|
248
|
+
for (let attempt = 0;; attempt++) {
|
|
249
|
+
try {
|
|
250
|
+
return await op();
|
|
251
|
+
}
|
|
252
|
+
catch (ex) {
|
|
253
|
+
if (ex instanceof GenerationError &&
|
|
254
|
+
attempt < this.#maxGenerationRetries) {
|
|
255
|
+
await sleep(backoffMs(attempt, GENERATION_BASE_MS));
|
|
256
|
+
}
|
|
257
|
+
else {
|
|
258
|
+
throw ex;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
// the mutex yields exactly once, so the loop always returns or throws
|
|
264
|
+
throw new Error("unreachable");
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* splice an already-uploaded item entry into the root
|
|
268
|
+
*
|
|
269
|
+
* The entry and all its blobs must already be uploaded; only the
|
|
270
|
+
* generation-dependent root merge is retried, so a conflict re-reads the
|
|
271
|
+
* latest root and re-appends the (stable) entry without re-uploading blobs.
|
|
272
|
+
*/
|
|
273
|
+
async #commit(entry) {
|
|
274
|
+
await this.#withRetry(async () => {
|
|
275
|
+
const [rootHash, generation] = await this.#getRootHash();
|
|
276
|
+
const { entries } = await this.raw.getEntries(ROOT_SCHEMA, rootHash);
|
|
277
|
+
entries.push(entry);
|
|
278
|
+
const [rootEntry, uploadRoot] = await this.raw.putEntries(ROOT_LIST, entries, 4);
|
|
279
|
+
await uploadRoot;
|
|
280
|
+
await this.#putRootHash(rootEntry.hash, generation);
|
|
168
281
|
});
|
|
169
|
-
|
|
282
|
+
}
|
|
283
|
+
async #authedFetch(url, { body, method = "POST", headers = {}, }) {
|
|
284
|
+
// the root PUT is a compare-and-set; retrying a lost-but-applied response
|
|
285
|
+
// would resurface as a false generation conflict and be double-applied by
|
|
286
|
+
// #withRetry, so never transient-retry it (GETs and content-addressed file
|
|
287
|
+
// PUTs are idempotent and safe to retry)
|
|
288
|
+
const transientRetries = method === "PUT" && url.endsWith("/sync/v3/root")
|
|
289
|
+
? 0
|
|
290
|
+
: this.#maxTransientRetries;
|
|
291
|
+
for (let attempt = 0;; attempt++) {
|
|
292
|
+
let resp;
|
|
293
|
+
try {
|
|
294
|
+
resp = await fetch(url, {
|
|
295
|
+
method,
|
|
296
|
+
headers: {
|
|
297
|
+
Authorization: `Bearer ${this.#sessionToken}`,
|
|
298
|
+
...headers,
|
|
299
|
+
},
|
|
300
|
+
// fetch works correctly with uint8 arrays, but is not hinted correctly
|
|
301
|
+
body: body,
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
catch (ex) {
|
|
305
|
+
// a network-level failure, retry if we have attempts left
|
|
306
|
+
if (attempt < transientRetries) {
|
|
307
|
+
await sleep(backoffMs(attempt, TRANSIENT_BASE_MS));
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
throw ex;
|
|
311
|
+
}
|
|
312
|
+
if (resp.ok) {
|
|
313
|
+
return resp;
|
|
314
|
+
}
|
|
170
315
|
const msg = await resp.text();
|
|
171
316
|
if (msg === '{"message":"precondition failed"}\n') {
|
|
317
|
+
// a generation conflict; handled by #withRetry at the high level
|
|
172
318
|
throw new GenerationError();
|
|
173
319
|
}
|
|
320
|
+
else if ((resp.status >= 500 || resp.status === 429) &&
|
|
321
|
+
attempt < transientRetries) {
|
|
322
|
+
await sleep(backoffMs(attempt, TRANSIENT_BASE_MS));
|
|
323
|
+
}
|
|
174
324
|
else {
|
|
175
325
|
throw new ResponseError(resp.status, resp.statusText, `failed reMarkable request: ${msg}`);
|
|
176
326
|
}
|
|
177
327
|
}
|
|
178
|
-
else {
|
|
179
|
-
return resp;
|
|
180
|
-
}
|
|
181
328
|
}
|
|
182
329
|
async #convertEntry({ hash, id }) {
|
|
183
330
|
const { entries } = await this.raw.getEntries(`${id}.docSchema`, hash);
|
|
@@ -241,12 +388,12 @@ class Remarkable {
|
|
|
241
388
|
}
|
|
242
389
|
async listIds(refresh = false) {
|
|
243
390
|
const [hash] = await this.#getRootHash(refresh);
|
|
244
|
-
const { entries } = await this.raw.getEntries(
|
|
391
|
+
const { entries } = await this.raw.getEntries(ROOT_SCHEMA, hash);
|
|
245
392
|
return entries.map(({ id, hash }) => ({ id, hash }));
|
|
246
393
|
}
|
|
247
394
|
async getContent(id, hash) {
|
|
248
395
|
const { entries } = await this.raw.getEntries(`${id}.docSchema`, hash);
|
|
249
|
-
const
|
|
396
|
+
const cont = entries.find((e) => e.id.endsWith(".content"));
|
|
250
397
|
if (cont === undefined) {
|
|
251
398
|
throw new Error(`couldn't find contents for hash ${hash}`);
|
|
252
399
|
}
|
|
@@ -256,7 +403,7 @@ class Remarkable {
|
|
|
256
403
|
}
|
|
257
404
|
async getMetadata(id, hash) {
|
|
258
405
|
const { entries } = await this.raw.getEntries(`${id}.docSchema`, hash);
|
|
259
|
-
const
|
|
406
|
+
const meta = entries.find((e) => e.id.endsWith(".metadata"));
|
|
260
407
|
if (meta === undefined) {
|
|
261
408
|
throw new Error(`couldn't find metadata for hash ${hash}`);
|
|
262
409
|
}
|
|
@@ -266,7 +413,7 @@ class Remarkable {
|
|
|
266
413
|
}
|
|
267
414
|
async getPdf(id, hash) {
|
|
268
415
|
const { entries } = await this.raw.getEntries(`${id}.docSchema`, hash);
|
|
269
|
-
const
|
|
416
|
+
const pdf = entries.find((e) => e.id.endsWith(".pdf"));
|
|
270
417
|
if (pdf === undefined) {
|
|
271
418
|
throw new Error(`couldn't find pdf for hash ${hash}`);
|
|
272
419
|
}
|
|
@@ -276,7 +423,7 @@ class Remarkable {
|
|
|
276
423
|
}
|
|
277
424
|
async getEpub(id, hash) {
|
|
278
425
|
const { entries } = await this.raw.getEntries(`${id}.docSchema`, hash);
|
|
279
|
-
const
|
|
426
|
+
const epub = entries.find((e) => e.id.endsWith(".epub"));
|
|
280
427
|
if (epub === undefined) {
|
|
281
428
|
throw new Error(`couldn't find epub for hash ${hash}`);
|
|
282
429
|
}
|
|
@@ -284,7 +431,31 @@ class Remarkable {
|
|
|
284
431
|
return await this.raw.getHash(epub.id, epub.hash);
|
|
285
432
|
}
|
|
286
433
|
}
|
|
287
|
-
async
|
|
434
|
+
async getRmPage(id, hash, pageId) {
|
|
435
|
+
const { entries } = await this.raw.getEntries(`${id}.docSchema`, hash);
|
|
436
|
+
const content = await this.getContent(id, hash);
|
|
437
|
+
if (!pageOrder(content).includes(pageId)) {
|
|
438
|
+
throw new Error(`document ${id} has no page ${pageId}`);
|
|
439
|
+
}
|
|
440
|
+
const entry = entries.find((ent) => ent.id === `${id}/${pageId}.rm`);
|
|
441
|
+
if (entry === undefined) {
|
|
442
|
+
return undefined;
|
|
443
|
+
}
|
|
444
|
+
else {
|
|
445
|
+
return await this.raw.getRm(entry.id, entry.hash);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
async getRmPages(id, hash) {
|
|
449
|
+
const { entries } = await this.raw.getEntries(`${id}.docSchema`, hash);
|
|
450
|
+
const content = await this.getContent(id, hash);
|
|
451
|
+
const byName = new Map(entries.map((entry) => [entry.id, entry]));
|
|
452
|
+
const drawn = pageOrder(content)
|
|
453
|
+
.map((pageId) => [pageId, byName.get(`${id}/${pageId}.rm`)])
|
|
454
|
+
.filter((pair) => pair[1] !== undefined);
|
|
455
|
+
const parsed = await Promise.all(drawn.map(([, entry]) => this.raw.getRm(entry.id, entry.hash)));
|
|
456
|
+
return new Map(drawn.map(([pageId], index) => [pageId, parsed[index]]));
|
|
457
|
+
}
|
|
458
|
+
async getDocumentArchive(id, hash) {
|
|
288
459
|
const { entries } = await this.raw.getEntries(`${id}.docSchema`, hash);
|
|
289
460
|
const zip = new JSZip();
|
|
290
461
|
for (const entry of entries) {
|
|
@@ -293,7 +464,61 @@ class Remarkable {
|
|
|
293
464
|
}
|
|
294
465
|
return zip.generateAsync({ type: "uint8array" });
|
|
295
466
|
}
|
|
296
|
-
async
|
|
467
|
+
async putDocumentArchive(buffer, { refresh = false, parent, visibleName, id: keepId, } = {}) {
|
|
468
|
+
if (parent !== undefined && parent && !idReg.test(parent)) {
|
|
469
|
+
throw new ValidationError(parent, idReg, "parent must be a valid document id");
|
|
470
|
+
}
|
|
471
|
+
const zip = await JSZip.loadAsync(buffer);
|
|
472
|
+
const paths = Object.keys(zip.files).filter((path) => !zip.files[path].dir);
|
|
473
|
+
const metaPath = paths.find((path) => path.endsWith(".metadata"));
|
|
474
|
+
if (metaPath === undefined) {
|
|
475
|
+
throw new Error("archive did not contain a .metadata file");
|
|
476
|
+
}
|
|
477
|
+
const oldId = metaPath.slice(0, -9);
|
|
478
|
+
if (oldId.includes("/")) {
|
|
479
|
+
throw new Error(`unexpected nested .metadata path '${metaPath}'`);
|
|
480
|
+
}
|
|
481
|
+
const newId = keepId ?? uuid4();
|
|
482
|
+
// rewrite the old document id prefix on every archived file to the new id,
|
|
483
|
+
// patching the .metadata as we pass it (parent/name/lastModified). the
|
|
484
|
+
// blobs don't depend on the generation, so upload the rewritten files and
|
|
485
|
+
// the document index once, then let #commit retry only the root merge
|
|
486
|
+
const enc = new TextEncoder();
|
|
487
|
+
const dec = new TextDecoder();
|
|
488
|
+
const lastModified = Date.now().toFixed();
|
|
489
|
+
const [, , schemaVersion] = await this.#getRootHash(refresh);
|
|
490
|
+
const fileUploads = await Promise.all(paths.map(async (path) => {
|
|
491
|
+
if (!path.startsWith(oldId)) {
|
|
492
|
+
throw new Error(`archived file '${path}' did not start with '${oldId}'`);
|
|
493
|
+
}
|
|
494
|
+
const newPath = `${newId}${path.slice(oldId.length)}`;
|
|
495
|
+
let bytes = await zip.files[path].async("uint8array");
|
|
496
|
+
if (path === metaPath) {
|
|
497
|
+
const meta = parseMetadata(dec.decode(bytes));
|
|
498
|
+
if (parent !== undefined)
|
|
499
|
+
meta.parent = parent;
|
|
500
|
+
if (visibleName !== undefined)
|
|
501
|
+
meta.visibleName = visibleName;
|
|
502
|
+
meta.lastModified = lastModified;
|
|
503
|
+
bytes = enc.encode(JSON.stringify(meta));
|
|
504
|
+
}
|
|
505
|
+
return this.raw.putFile(newPath, bytes);
|
|
506
|
+
}));
|
|
507
|
+
const fileEntries = fileUploads.map(([entry]) => entry);
|
|
508
|
+
const [docEntry, uploadDoc] = await this.raw.putEntries(newId, fileEntries, schemaVersion);
|
|
509
|
+
await Promise.all([...fileUploads.map(([, upload]) => upload), uploadDoc]);
|
|
510
|
+
await this.#commit(docEntry);
|
|
511
|
+
return { id: newId, hash: docEntry.hash };
|
|
512
|
+
}
|
|
513
|
+
/** @deprecated renamed; use {@link getDocumentArchive | `getDocumentArchive`} */
|
|
514
|
+
async getDocument(id, hash) {
|
|
515
|
+
return await this.getDocumentArchive(id, hash);
|
|
516
|
+
}
|
|
517
|
+
/** @deprecated renamed; use {@link putDocumentArchive | `putDocumentArchive`} */
|
|
518
|
+
async putDocument(buffer, options) {
|
|
519
|
+
return await this.putDocumentArchive(buffer, options);
|
|
520
|
+
}
|
|
521
|
+
async #putFile(visibleName, fileType, buffer, { refresh, parent = ROOT_ID, pinned = false, zoomMode = "bestFit", viewBackgroundFilter, textScale = 1, textAlignment = "justify", fontName = "", coverPageNumber = -1, authors, title, publicationDate, publisher, extraMetadata = {}, lineHeight = -1, margins = 125, orientation = "portrait", tags, customZoomScale, customZoomCenterX, customZoomCenterY, customZoomPageWidth, customZoomPageHeight, customZoomOrientation, }) {
|
|
297
522
|
if (parent && !idReg.test(parent)) {
|
|
298
523
|
throw new ValidationError(parent, idReg, "parent must be a valid document id");
|
|
299
524
|
}
|
|
@@ -339,36 +564,29 @@ class Remarkable {
|
|
|
339
564
|
redirectionPageMap: [0],
|
|
340
565
|
sizeInBytes: buffer.length.toFixed(),
|
|
341
566
|
};
|
|
342
|
-
//
|
|
343
|
-
|
|
567
|
+
// the schema version is needed to encode the document index; the blobs
|
|
568
|
+
// themselves don't depend on the generation, so upload them once and let
|
|
569
|
+
// #commit retry only the root merge
|
|
570
|
+
const [, , schemaVersion] = await this.#getRootHash(refresh);
|
|
571
|
+
const [[contentEntry, uploadContent], [metadataEntry, uploadMetadata], [pagedataEntry, uploadPagedata], [fileEntry, uploadFile],] = await Promise.all([
|
|
344
572
|
this.raw.putContent(`${id}.content`, content),
|
|
345
573
|
this.raw.putMetadata(`${id}.metadata`, metadata),
|
|
346
574
|
this.raw.putText(`${id}.pagedata`, "\n"),
|
|
347
575
|
this.raw.putFile(`${id}.${fileType}`, buffer),
|
|
348
|
-
this.#getRootHash(refresh),
|
|
349
|
-
]);
|
|
350
|
-
// now fetch root entries and upload this file entry
|
|
351
|
-
const [[collectionEntry, uploadCollection], { entries: rootEntries }] = await Promise.all([
|
|
352
|
-
this.raw.putEntries(id, [contentEntry, metadataEntry, pagedataEntry, fileEntry], schemaVersion),
|
|
353
|
-
this.raw.getEntries("root.docSchema", rootHash),
|
|
354
576
|
]);
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
//
|
|
577
|
+
const [collectionEntry, uploadCollection] = await this.raw.putEntries(id, [contentEntry, metadataEntry, pagedataEntry, fileEntry], schemaVersion);
|
|
578
|
+
// TODO we could return a full entry here, but we should probably decide
|
|
579
|
+
// what that should be, e.g. we could return more fields than the standard
|
|
580
|
+
// entry. Same for putFolder
|
|
581
|
+
// TODO we should also decide if the api should take hashes or ids...
|
|
359
582
|
await Promise.all([
|
|
360
583
|
uploadContent,
|
|
361
584
|
uploadMetadata,
|
|
362
585
|
uploadPagedata,
|
|
363
586
|
uploadFile,
|
|
364
587
|
uploadCollection,
|
|
365
|
-
uploadRoot,
|
|
366
588
|
]);
|
|
367
|
-
|
|
368
|
-
// what that should be, e.g. we could return more fields than the standard
|
|
369
|
-
// entry. Same for putFolder
|
|
370
|
-
// TODO we should also decide if the api should take hashes or ids...
|
|
371
|
-
await this.#putRootHash(rootEntry.hash, generation);
|
|
589
|
+
await this.#commit(collectionEntry);
|
|
372
590
|
return { id, hash: collectionEntry.hash };
|
|
373
591
|
}
|
|
374
592
|
async putPdf(visibleName, buffer, opts = {}) {
|
|
@@ -378,7 +596,7 @@ class Remarkable {
|
|
|
378
596
|
return await this.#putFile(visibleName, "epub", buffer, opts);
|
|
379
597
|
}
|
|
380
598
|
/** create a folder */
|
|
381
|
-
async putFolder(visibleName, { parent =
|
|
599
|
+
async putFolder(visibleName, { parent = ROOT_ID } = {}, refresh = false) {
|
|
382
600
|
if (parent && !idReg.test(parent)) {
|
|
383
601
|
throw new ValidationError(parent, idReg, "parent must be a valid document id");
|
|
384
602
|
}
|
|
@@ -395,29 +613,16 @@ class Remarkable {
|
|
|
395
613
|
type: "CollectionType",
|
|
396
614
|
visibleName,
|
|
397
615
|
};
|
|
398
|
-
// upload
|
|
399
|
-
|
|
616
|
+
// the blobs don't depend on the generation, so upload them once and let
|
|
617
|
+
// #commit retry only the root merge
|
|
618
|
+
const [, , schemaVersion] = await this.#getRootHash(refresh);
|
|
619
|
+
const [[contentEntry, uploadContent], [metadataEntry, uploadMetadata]] = await Promise.all([
|
|
400
620
|
this.raw.putContent(`${id}.content`, content),
|
|
401
621
|
this.raw.putMetadata(`${id}.metadata`, metadata),
|
|
402
|
-
this.#getRootHash(refresh),
|
|
403
|
-
]);
|
|
404
|
-
// now fetch root entries and upload this file entry
|
|
405
|
-
const [[collectionEntry, uploadCollection], { entries: rootEntries }] = await Promise.all([
|
|
406
|
-
this.raw.putEntries(id, [contentEntry, metadataEntry], schemaVersion),
|
|
407
|
-
this.raw.getEntries("root.docSchema", rootHash),
|
|
408
|
-
]);
|
|
409
|
-
// now upload a new root entry
|
|
410
|
-
rootEntries.push(collectionEntry);
|
|
411
|
-
const [rootEntry, uploadRoot] = await this.raw.putEntries("root", rootEntries, 4);
|
|
412
|
-
// before updating the root hash, first upload everything
|
|
413
|
-
await Promise.all([
|
|
414
|
-
uploadContent,
|
|
415
|
-
uploadMetadata,
|
|
416
|
-
uploadCollection,
|
|
417
|
-
uploadRoot,
|
|
418
622
|
]);
|
|
419
|
-
|
|
420
|
-
await
|
|
623
|
+
const [collectionEntry, uploadCollection] = await this.raw.putEntries(id, [contentEntry, metadataEntry], schemaVersion);
|
|
624
|
+
await Promise.all([uploadContent, uploadMetadata, uploadCollection]);
|
|
625
|
+
await this.#commit(collectionEntry);
|
|
421
626
|
return { id, hash: collectionEntry.hash };
|
|
422
627
|
}
|
|
423
628
|
/** upload an epub */
|
|
@@ -450,25 +655,27 @@ class Remarkable {
|
|
|
450
655
|
}
|
|
451
656
|
/** fully sync a content edit */
|
|
452
657
|
async #editContent(hash, update, expectedType, refresh) {
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
658
|
+
return await this.#withRetry(async () => {
|
|
659
|
+
const [rootHash, generation, schemaVersion] = await this.#getRootHash(refresh);
|
|
660
|
+
const { entries } = await this.raw.getEntries(ROOT_SCHEMA, rootHash);
|
|
661
|
+
const hashInd = entries.findIndex((ent) => ent.hash === hash);
|
|
662
|
+
const hashEnt = entries[hashInd];
|
|
663
|
+
if (hashEnt === undefined) {
|
|
664
|
+
throw new HashNotFoundError(hash);
|
|
665
|
+
}
|
|
666
|
+
const [[newEnt, uploadEnt], meta] = await Promise.all([
|
|
667
|
+
this.#editContentRaw(hashEnt.id, hash, update, schemaVersion),
|
|
668
|
+
this.getMetadata(hashEnt.id, hash),
|
|
669
|
+
]);
|
|
670
|
+
if (meta.type !== expectedType) {
|
|
671
|
+
throw new Error(`expected type ${expectedType} but got ${meta.type} for hash ${hash}`);
|
|
672
|
+
}
|
|
673
|
+
entries[hashInd] = newEnt;
|
|
674
|
+
const [rootEntry, uploadRoot] = await this.raw.putEntries(ROOT_LIST, entries, 4);
|
|
675
|
+
await Promise.all([uploadEnt, uploadRoot]);
|
|
676
|
+
await this.#putRootHash(rootEntry.hash, generation);
|
|
677
|
+
return { hash: newEnt.hash };
|
|
678
|
+
});
|
|
472
679
|
}
|
|
473
680
|
/** update document content */
|
|
474
681
|
async updateDocument(hash, content, refresh = false) {
|
|
@@ -491,6 +698,8 @@ class Remarkable {
|
|
|
491
698
|
}
|
|
492
699
|
const meta = await this.raw.getMetadata(metaEntry.id, metaEntry.hash);
|
|
493
700
|
Object.assign(meta, update);
|
|
701
|
+
meta.version = (meta.version ?? 0) + 1;
|
|
702
|
+
meta.metadatamodified = true;
|
|
494
703
|
const [newMetaEntry, uploadMeta] = await this.raw.putMetadata(metaEntry.id, meta);
|
|
495
704
|
entries[metaInd] = newMetaEntry;
|
|
496
705
|
const [result, uploadEntries] = await this.raw.putEntries(id, entries, schemaVersion);
|
|
@@ -498,19 +707,21 @@ class Remarkable {
|
|
|
498
707
|
return [result, upload];
|
|
499
708
|
}
|
|
500
709
|
async #editMeta(hash, update, refresh = false) {
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
710
|
+
return await this.#withRetry(async () => {
|
|
711
|
+
const [rootHash, generation, schemaVersion] = await this.#getRootHash(refresh);
|
|
712
|
+
const { entries } = await this.raw.getEntries(ROOT_SCHEMA, rootHash);
|
|
713
|
+
const hashInd = entries.findIndex((ent) => ent.hash === hash);
|
|
714
|
+
const hashEnt = entries[hashInd];
|
|
715
|
+
if (hashEnt === undefined) {
|
|
716
|
+
throw new HashNotFoundError(hash);
|
|
717
|
+
}
|
|
718
|
+
const [newEnt, uploadEnt] = await this.#editMetaRaw(hashEnt.id, hash, update, schemaVersion);
|
|
719
|
+
entries[hashInd] = newEnt;
|
|
720
|
+
const [rootEntry, uploadRoot] = await this.raw.putEntries(ROOT_LIST, entries, 4);
|
|
721
|
+
await Promise.all([uploadEnt, uploadRoot]);
|
|
722
|
+
await this.#putRootHash(rootEntry.hash, generation);
|
|
723
|
+
return { hash: newEnt.hash };
|
|
724
|
+
});
|
|
514
725
|
}
|
|
515
726
|
/** move an entry */
|
|
516
727
|
async move(hash, parent, refresh = false) {
|
|
@@ -521,46 +732,52 @@ class Remarkable {
|
|
|
521
732
|
}
|
|
522
733
|
/** delete an entry */
|
|
523
734
|
async delete(hash, refresh = false) {
|
|
524
|
-
return await this.move(hash,
|
|
735
|
+
return await this.move(hash, TRASH_ID, refresh);
|
|
525
736
|
}
|
|
526
737
|
/** rename an entry */
|
|
527
738
|
async rename(hash, visibleName, refresh = false) {
|
|
528
739
|
return await this.#editMeta(hash, { visibleName }, refresh);
|
|
529
740
|
}
|
|
530
|
-
/**
|
|
741
|
+
/** star or unstar an entry */
|
|
742
|
+
async star(hash, starred, refresh = false) {
|
|
743
|
+
return await this.#editMeta(hash, { pinned: starred }, refresh);
|
|
744
|
+
}
|
|
745
|
+
/** @deprecated misspelling; use {@link star | `star`} */
|
|
531
746
|
async stared(hash, stared, refresh = false) {
|
|
532
|
-
return await this
|
|
747
|
+
return await this.star(hash, stared, refresh);
|
|
533
748
|
}
|
|
534
749
|
/** move many hashes */
|
|
535
750
|
async bulkMove(hashes, parent, refresh = false) {
|
|
536
751
|
if (!idReg.test(parent)) {
|
|
537
752
|
throw new ValidationError(parent, idReg, "parent must be a valid document id");
|
|
538
753
|
}
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
const
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
754
|
+
return await this.#withRetry(async () => {
|
|
755
|
+
const [rootHash, generation, schemaVersion] = await this.#getRootHash(refresh);
|
|
756
|
+
const { entries } = await this.raw.getEntries(ROOT_SCHEMA, rootHash);
|
|
757
|
+
const hashSet = new Set(hashes);
|
|
758
|
+
const toUpdate = [];
|
|
759
|
+
const newEntries = [];
|
|
760
|
+
for (const entry of entries) {
|
|
761
|
+
const part = hashSet.has(entry.hash) ? toUpdate : newEntries;
|
|
762
|
+
part.push(entry);
|
|
763
|
+
}
|
|
764
|
+
const resolved = await Promise.all(toUpdate.map(({ id, hash }) => this.#editMetaRaw(id, hash, { parent }, schemaVersion)));
|
|
765
|
+
const uploads = [];
|
|
766
|
+
const result = {};
|
|
767
|
+
for (const [i, [newEnt, upload]] of resolved.entries()) {
|
|
768
|
+
newEntries.push(newEnt);
|
|
769
|
+
uploads.push(upload);
|
|
770
|
+
result[toUpdate[i].hash] = newEnt.hash;
|
|
771
|
+
}
|
|
772
|
+
const [rootEntry, uploadRoot] = await this.raw.putEntries(ROOT_LIST, newEntries, 4);
|
|
773
|
+
await Promise.all([Promise.all(uploads), uploadRoot]);
|
|
774
|
+
await this.#putRootHash(rootEntry.hash, generation);
|
|
775
|
+
return { hashes: result };
|
|
776
|
+
});
|
|
560
777
|
}
|
|
561
778
|
/** delete many hashes */
|
|
562
779
|
async bulkDelete(hashes, refresh = false) {
|
|
563
|
-
return await this.bulkMove(hashes,
|
|
780
|
+
return await this.bulkMove(hashes, TRASH_ID, refresh);
|
|
564
781
|
}
|
|
565
782
|
/** dump the raw cache */
|
|
566
783
|
dumpCache() {
|
|
@@ -575,7 +792,7 @@ class Remarkable {
|
|
|
575
792
|
// should only go one step) to track all hashes encountered
|
|
576
793
|
// NOTE that we could increase the cache in this process, or it's possible
|
|
577
794
|
// for other calls to increase the cache with misc values.
|
|
578
|
-
const base = await this.raw.getEntries(
|
|
795
|
+
const base = await this.raw.getEntries(ROOT_SCHEMA, rootHash);
|
|
579
796
|
let entries = [base.entries];
|
|
580
797
|
let nextEntries = [];
|
|
581
798
|
while (entries.length) {
|
|
@@ -628,7 +845,7 @@ export async function auth(deviceToken, { authHost = AUTH_HOST } = {}) {
|
|
|
628
845
|
* @param sessionToken - the session token used for authorization
|
|
629
846
|
* @returns an api instance
|
|
630
847
|
*/
|
|
631
|
-
export function session(sessionToken, { rawHost = RAW_HOST, uploadHost = UPLOAD_HOST, cache, maxCacheSize = Infinity, } = {}) {
|
|
848
|
+
export function session(sessionToken, { rawHost = RAW_HOST, uploadHost = UPLOAD_HOST, cache, maxCacheSize = Infinity, maxGenerationRetries = 10, maxTransientRetries = 3, } = {}) {
|
|
632
849
|
const initCache = JSON.parse(cache ?? "{}");
|
|
633
850
|
const parsedCache = cached.safeParse(initCache);
|
|
634
851
|
if (parsedCache.success) {
|
|
@@ -636,7 +853,7 @@ export function session(sessionToken, { rawHost = RAW_HOST, uploadHost = UPLOAD_
|
|
|
636
853
|
const cacheMap = maxCacheSize === Infinity
|
|
637
854
|
? new Map(entries)
|
|
638
855
|
: new LruCache(maxCacheSize, entries);
|
|
639
|
-
return new Remarkable(sessionToken, rawHost, uploadHost, cacheMap);
|
|
856
|
+
return new Remarkable(sessionToken, rawHost, uploadHost, cacheMap, maxGenerationRetries, maxTransientRetries);
|
|
640
857
|
}
|
|
641
858
|
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.");
|
|
642
859
|
}
|
|
@@ -651,12 +868,14 @@ export function session(sessionToken, { rawHost = RAW_HOST, uploadHost = UPLOAD_
|
|
|
651
868
|
* @returns an api instance
|
|
652
869
|
*/
|
|
653
870
|
export async function remarkable(deviceToken, options = {}) {
|
|
654
|
-
const { authHost, rawHost, uploadHost, cache, maxCacheSize } = options ?? {};
|
|
871
|
+
const { authHost, rawHost, uploadHost, cache, maxCacheSize, maxGenerationRetries, maxTransientRetries, } = options ?? {};
|
|
655
872
|
const sessionToken = await auth(deviceToken, { authHost });
|
|
656
873
|
return session(sessionToken, {
|
|
657
874
|
rawHost,
|
|
658
875
|
uploadHost,
|
|
659
876
|
cache,
|
|
660
877
|
maxCacheSize,
|
|
878
|
+
maxGenerationRetries,
|
|
879
|
+
maxTransientRetries,
|
|
661
880
|
});
|
|
662
881
|
}
|