rmapi-js 11.1.2 → 12.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/dist/index.js CHANGED
@@ -19,7 +19,7 @@
19
19
  * // list all items (documents and collections)
20
20
  * const [first, ...rest] = await api.listItems();
21
21
  * // rename first item
22
- * const entry = await api.rename(first.hash, "new name");
22
+ * const entry = await api.rename(first, "new name");
23
23
  * ```
24
24
  *
25
25
  * @example
@@ -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
@@ -122,24 +187,51 @@ export async function register(code, { deviceDesc = "browser-chrome", uuid = uui
122
187
  return await resp.text();
123
188
  }
124
189
  }
125
- /** the implementation of that api */
190
+ /**
191
+ * the api for accessing remarkable functions
192
+ *
193
+ * There are roughly two types of functions.
194
+ * - high-level api functions that provide simple access with a single round
195
+ * trip based on the web api
196
+ * - low-level wrapped functions that take more round trips, but provide more
197
+ * control and may be faster since they can be cached.
198
+ *
199
+ * Most of these functions validate the return values so that typescript is
200
+ * accurate. However, sometimes those return values are more strict than the
201
+ * "true" underlying types. If this happens, please [submit a an
202
+ * issue](https://github.com/erikbrinkman/rmapi-js/issues). In the mean time,
203
+ * you should be able to use the low level api to work around any restrictive
204
+ * validation.
205
+ */
126
206
  class Remarkable {
127
207
  #sessionToken;
128
208
  /** the same cache that underlies the raw api, allowing us to modify it */
129
209
  #cache;
210
+ /** scoped access to the raw low-level api */
130
211
  raw;
212
+ #maxGenerationRetries;
213
+ #maxTransientRetries;
131
214
  #lastHashGen;
132
215
  #schemaVersion;
133
- constructor(sessionToken, rawHost, uploadHost, cache) {
216
+ /** serializes root updates on this instance so they don't self-conflict */
217
+ #rootMutex = new Mutex();
218
+ constructor(sessionToken, rawHost, uploadHost, cache, maxGenerationRetries, maxTransientRetries) {
134
219
  this.#sessionToken = sessionToken;
135
220
  this.#cache = cache;
221
+ this.#maxGenerationRetries = maxGenerationRetries;
222
+ this.#maxTransientRetries = maxTransientRetries;
136
223
  this.raw = new RawRemarkable((method, url, { body, headers } = {}) => this.#authedFetch(url, { method, body, headers }), cache, rawHost, uploadHost);
137
224
  }
138
225
  async #getRootHash(refresh = false) {
139
226
  if (refresh || this.#lastHashGen === undefined) {
140
227
  const [hash, generation, schemaVersion] = await this.raw.getRootHash();
141
- this.#lastHashGen = [hash, generation];
142
- this.#schemaVersion = schemaVersion;
228
+ // a slow older fetch can resolve after a newer write; only accept it if
229
+ // it doesn't regress the cached generation past a committed root
230
+ if (this.#lastHashGen === undefined ||
231
+ generation >= this.#lastHashGen[1]) {
232
+ this.#lastHashGen = [hash, generation];
233
+ this.#schemaVersion = schemaVersion;
234
+ }
143
235
  }
144
236
  return [...this.#lastHashGen, this.#schemaVersion];
145
237
  }
@@ -156,42 +248,119 @@ class Remarkable {
156
248
  throw ex;
157
249
  }
158
250
  }
159
- async #authedFetch(url, { body, method = "POST", headers = {}, }) {
160
- const resp = await fetch(url, {
161
- method,
162
- headers: {
163
- Authorization: `Bearer ${this.#sessionToken}`,
164
- ...headers,
165
- },
166
- // fetch works correctly with uint8 arrays, but is not hinted correctly
167
- body: body,
251
+ /**
252
+ * run a root-mutating operation, retrying on generation conflicts
253
+ *
254
+ * On a {@link GenerationError | `GenerationError`} the cached generation was
255
+ * already invalidated by {@link #putRootHash}, so re-running `op` re-reads the
256
+ * latest root and re-applies the change. Callers must resolve any random ids
257
+ * before this so retries reuse the same (cached) blobs.
258
+ */
259
+ async #withRetry(op) {
260
+ // hold the root lock across the whole read-merge-write so concurrent
261
+ // mutators serialize instead of sharing a generation and forcing each
262
+ // other into avoidable conflicts
263
+ for await (const _lock of this.#rootMutex) {
264
+ for (let attempt = 0;; attempt++) {
265
+ try {
266
+ return await op();
267
+ }
268
+ catch (ex) {
269
+ if (ex instanceof GenerationError &&
270
+ attempt < this.#maxGenerationRetries) {
271
+ await sleep(backoffMs(attempt, GENERATION_BASE_MS));
272
+ }
273
+ else {
274
+ throw ex;
275
+ }
276
+ }
277
+ }
278
+ }
279
+ // the mutex yields exactly once, so the loop always returns or throws
280
+ throw new Error("unreachable");
281
+ }
282
+ /**
283
+ * splice an already-uploaded item entry into the root
284
+ *
285
+ * The entry and all its blobs must already be uploaded; only the
286
+ * generation-dependent root merge is retried, so a conflict re-reads the
287
+ * latest root and re-appends the (stable) entry without re-uploading blobs.
288
+ */
289
+ async #commit(entry) {
290
+ await this.#withRetry(async () => {
291
+ const [rootHash, generation] = await this.#getRootHash();
292
+ const { entries } = await this.raw.getEntries({
293
+ id: ROOT_SCHEMA,
294
+ hash: rootHash,
295
+ });
296
+ entries.push(entry);
297
+ const [rootEntry, uploadRoot] = await this.raw.putEntries(ROOT_LIST, entries, 4);
298
+ await uploadRoot;
299
+ await this.#putRootHash(rootEntry.hash, generation);
168
300
  });
169
- if (!resp.ok) {
301
+ }
302
+ async #authedFetch(url, { body, method = "POST", headers = {}, }) {
303
+ // the root PUT is a compare-and-set; retrying a lost-but-applied response
304
+ // would resurface as a false generation conflict and be double-applied by
305
+ // #withRetry, so never transient-retry it (GETs and content-addressed file
306
+ // PUTs are idempotent and safe to retry)
307
+ const transientRetries = method === "PUT" && url.endsWith("/sync/v3/root")
308
+ ? 0
309
+ : this.#maxTransientRetries;
310
+ for (let attempt = 0;; attempt++) {
311
+ let resp;
312
+ try {
313
+ resp = await fetch(url, {
314
+ method,
315
+ headers: {
316
+ Authorization: `Bearer ${this.#sessionToken}`,
317
+ ...headers,
318
+ },
319
+ // fetch works correctly with uint8 arrays, but is not hinted correctly
320
+ body: body,
321
+ });
322
+ }
323
+ catch (ex) {
324
+ // a network-level failure, retry if we have attempts left
325
+ if (attempt < transientRetries) {
326
+ await sleep(backoffMs(attempt, TRANSIENT_BASE_MS));
327
+ continue;
328
+ }
329
+ throw ex;
330
+ }
331
+ if (resp.ok) {
332
+ return resp;
333
+ }
170
334
  const msg = await resp.text();
171
335
  if (msg === '{"message":"precondition failed"}\n') {
336
+ // a generation conflict; handled by #withRetry at the high level
172
337
  throw new GenerationError();
173
338
  }
339
+ else if ((resp.status >= 500 || resp.status === 429) &&
340
+ attempt < transientRetries) {
341
+ await sleep(backoffMs(attempt, TRANSIENT_BASE_MS));
342
+ }
174
343
  else {
175
344
  throw new ResponseError(resp.status, resp.statusText, `failed reMarkable request: ${msg}`);
176
345
  }
177
346
  }
178
- else {
179
- return resp;
180
- }
181
347
  }
182
348
  async #convertEntry({ hash, id }) {
183
- const { entries } = await this.raw.getEntries(`${id}.docSchema`, hash);
349
+ const { entries } = await this.raw.getEntries({
350
+ id: `${id}.docSchema`,
351
+ hash,
352
+ });
184
353
  const metaEnt = entries.find((ent) => ent.id.endsWith(".metadata"));
185
354
  const contentEnt = entries.find((ent) => ent.id.endsWith(".content"));
186
355
  if (metaEnt === undefined) {
187
356
  throw new Error(`couldn't find metadata for hash ${hash}`);
188
357
  }
189
358
  const [{ visibleName, lastModified, pinned, parent, lastOpened, createdTime, new: isNew, source, }, content,] = await Promise.all([
190
- this.raw.getMetadata(metaEnt.id, metaEnt.hash),
359
+ this.raw.getMetadata(metaEnt),
191
360
  // collections don't always have content, since content only lists tags
192
361
  contentEnt === undefined
193
362
  ? Promise.resolve({ fileType: undefined, tags: undefined })
194
- : this.raw.getContent(contentEnt.id, contentEnt.hash),
363
+ : this.raw.getContent(contentEnt),
195
364
  ]);
196
365
  if ("templateVersion" in content) {
197
366
  return {
@@ -234,66 +403,269 @@ class Remarkable {
234
403
  };
235
404
  }
236
405
  }
237
- /** list all items */
406
+ /**
407
+ * list all items
408
+ *
409
+ * Items include both collections and documents. Documents that are in folders
410
+ * will have their parent set to something other than "" or "trash", but
411
+ * everything will be returned by this function.
412
+ *
413
+ * @example
414
+ * ```ts
415
+ * await api.listItems();
416
+ * ```
417
+ *
418
+ * @remarks
419
+ * This is now backed by the low level api, and you may notice some
420
+ * performance degradation if not taking advantage of the cache.
421
+ *
422
+ * @param refresh - if true, refresh the root hash before listing
423
+ * @returns a list of all items with some metadata
424
+ */
238
425
  async listItems(refresh = false) {
239
426
  const ids = await this.listIds(refresh);
240
427
  return await Promise.all(ids.map((id) => this.#convertEntry(id)));
241
428
  }
429
+ /**
430
+ * similar to {@link listItems | `listItems`} but backed by the low level api
431
+ *
432
+ * @param refresh - if true, refresh the root hash before listing
433
+ */
242
434
  async listIds(refresh = false) {
243
435
  const [hash] = await this.#getRootHash(refresh);
244
- const { entries } = await this.raw.getEntries("root.docSchema", hash);
436
+ const { entries } = await this.raw.getEntries({ id: ROOT_SCHEMA, hash });
245
437
  return entries.map(({ id, hash }) => ({ id, hash }));
246
438
  }
247
- async getContent(id, hash) {
248
- const { entries } = await this.raw.getEntries(`${id}.docSchema`, hash);
249
- const [cont] = entries.filter((e) => e.id.endsWith(".content"));
439
+ /**
440
+ * get the content metadata for an item
441
+ *
442
+ * @remarks
443
+ * If this fails validation and you still want to get the content, you can use
444
+ * the low-level api to get the raw text of the `.content` file in the
445
+ * `RawEntry` for this hash.
446
+ *
447
+ * @param ref - a reference to the item (e.g. from `listItems` or `listIds`)
448
+ * @returns the content
449
+ */
450
+ async getContent({ id, hash }) {
451
+ const { entries } = await this.raw.getEntries({
452
+ id: `${id}.docSchema`,
453
+ hash,
454
+ });
455
+ const cont = entries.find((e) => e.id.endsWith(".content"));
250
456
  if (cont === undefined) {
251
457
  throw new Error(`couldn't find contents for hash ${hash}`);
252
458
  }
253
459
  else {
254
- return await this.raw.getContent(cont.id, cont.hash);
460
+ return await this.raw.getContent(cont);
255
461
  }
256
462
  }
257
- async getMetadata(id, hash) {
258
- const { entries } = await this.raw.getEntries(`${id}.docSchema`, hash);
259
- const [meta] = entries.filter((e) => e.id.endsWith(".metadata"));
463
+ /**
464
+ * get the metadata for an item
465
+ *
466
+ * @remarks
467
+ * If this fails validation and you still want to get the content, you can use
468
+ * the low-level api to get the raw text of the `.metadata` file in the
469
+ * `RawEntry` for this hash.
470
+ *
471
+ * @param ref - a reference to the item (e.g. from `listItems` or `listIds`)
472
+ * @returns the metadata
473
+ */
474
+ async getMetadata({ id, hash }) {
475
+ const { entries } = await this.raw.getEntries({
476
+ id: `${id}.docSchema`,
477
+ hash,
478
+ });
479
+ const meta = entries.find((e) => e.id.endsWith(".metadata"));
260
480
  if (meta === undefined) {
261
481
  throw new Error(`couldn't find metadata for hash ${hash}`);
262
482
  }
263
483
  else {
264
- return await this.raw.getMetadata(meta.id, meta.hash);
484
+ return await this.raw.getMetadata(meta);
265
485
  }
266
486
  }
267
- async getPdf(id, hash) {
268
- const { entries } = await this.raw.getEntries(`${id}.docSchema`, hash);
269
- const [pdf] = entries.filter((e) => e.id.endsWith(".pdf"));
487
+ /**
488
+ * get the pdf associated with a document
489
+ *
490
+ * This returns the raw input pdf, not the rendered pdf with any markup.
491
+ *
492
+ * @param ref - a reference to the document (e.g. from `listItems`)
493
+ * @returns the pdf bytes
494
+ */
495
+ async getPdf({ id, hash }) {
496
+ const { entries } = await this.raw.getEntries({
497
+ id: `${id}.docSchema`,
498
+ hash,
499
+ });
500
+ const pdf = entries.find((e) => e.id.endsWith(".pdf"));
270
501
  if (pdf === undefined) {
271
502
  throw new Error(`couldn't find pdf for hash ${hash}`);
272
503
  }
273
504
  else {
274
- return await this.raw.getHash(pdf.id, pdf.hash);
505
+ return await this.raw.getHash(pdf);
275
506
  }
276
507
  }
277
- async getEpub(id, hash) {
278
- const { entries } = await this.raw.getEntries(`${id}.docSchema`, hash);
279
- const [epub] = entries.filter((e) => e.id.endsWith(".epub"));
508
+ /**
509
+ * get the epub associated with a document
510
+ *
511
+ * This returns the raw input epub if a document was created from an epub.
512
+ *
513
+ * @param ref - a reference to the document (e.g. from `listItems`)
514
+ * @returns the epub bytes
515
+ */
516
+ async getEpub({ id, hash }) {
517
+ const { entries } = await this.raw.getEntries({
518
+ id: `${id}.docSchema`,
519
+ hash,
520
+ });
521
+ const epub = entries.find((e) => e.id.endsWith(".epub"));
280
522
  if (epub === undefined) {
281
523
  throw new Error(`couldn't find epub for hash ${hash}`);
282
524
  }
283
525
  else {
284
- return await this.raw.getHash(epub.id, epub.hash);
526
+ return await this.raw.getHash(epub);
285
527
  }
286
528
  }
287
- async getDocument(id, hash) {
288
- const { entries } = await this.raw.getEntries(`${id}.docSchema`, hash);
529
+ /**
530
+ * get a single page's parsed reMarkable lines (`.rm`) drawing
531
+ *
532
+ * @param ref - a reference to the document (e.g. from `listItems`)
533
+ * @param pageId - the id of the page, from the document's `.content` page
534
+ * list (see {@link getRmPages | `getRmPages`} for every page)
535
+ * @returns the parsed page, or `undefined` if the page exists but has no
536
+ * `.rm` drawing (a page you haven't drawn on has no `.rm` file)
537
+ * @throws if `pageId` is not a page of the document
538
+ */
539
+ async getRmPage(ref, pageId) {
540
+ const { id, hash } = ref;
541
+ const { entries } = await this.raw.getEntries({
542
+ id: `${id}.docSchema`,
543
+ hash,
544
+ });
545
+ const content = await this.getContent(ref);
546
+ if (!pageOrder(content).includes(pageId)) {
547
+ throw new Error(`document ${id} has no page ${pageId}`);
548
+ }
549
+ const entry = entries.find((ent) => ent.id === `${id}/${pageId}.rm`);
550
+ if (entry === undefined) {
551
+ return undefined;
552
+ }
553
+ else {
554
+ return await this.raw.getRm(entry);
555
+ }
556
+ }
557
+ /**
558
+ * get every drawn page of a document, parsed, keyed by page id
559
+ *
560
+ * Returns a map from page id to its parsed {@link RmPage | `RmPage`},
561
+ * iterating in the page order given by the document's `.content`. Pages with
562
+ * no drawing (and soft-deleted pages) are omitted. Version 3, 5, and 6 pages
563
+ * are all supported.
564
+ *
565
+ * @param ref - a reference to the document (e.g. from `listItems`)
566
+ * @returns the drawn pages, keyed by page id, in document order
567
+ */
568
+ async getRmPages(ref) {
569
+ const { id, hash } = ref;
570
+ const { entries } = await this.raw.getEntries({
571
+ id: `${id}.docSchema`,
572
+ hash,
573
+ });
574
+ const content = await this.getContent(ref);
575
+ const byName = new Map(entries.map((entry) => [entry.id, entry]));
576
+ const drawn = pageOrder(content)
577
+ .map((pageId) => [pageId, byName.get(`${id}/${pageId}.rm`)])
578
+ .filter((pair) => pair[1] !== undefined);
579
+ const parsed = await Promise.all(drawn.map(([, entry]) => this.raw.getRm(entry)));
580
+ return new Map(drawn.map(([pageId], index) => [pageId, parsed[index]]));
581
+ }
582
+ /**
583
+ * get a document's entire contents as a zip archive
584
+ *
585
+ * This gets every file associated with a document and puts them into a zip
586
+ * archive.
587
+ *
588
+ * @remarks
589
+ * This is an experimental feature. The resulting archive round-trips back
590
+ * through {@link putDocumentArchive | `putDocumentArchive`}.
591
+ *
592
+ * @param ref - a reference to the document (e.g. from `listItems`)
593
+ */
594
+ async getDocumentArchive({ id, hash }) {
595
+ const { entries } = await this.raw.getEntries({
596
+ id: `${id}.docSchema`,
597
+ hash,
598
+ });
289
599
  const zip = new JSZip();
290
600
  for (const entry of entries) {
291
601
  // TODO if this is .metadata we might want to assert type === "DocumentType"
292
- zip.file(entry.id, this.raw.getHash(entry.id, entry.hash));
602
+ zip.file(entry.id, this.raw.getHash(entry));
293
603
  }
294
604
  return zip.generateAsync({ type: "uint8array" });
295
605
  }
296
- 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, customZoomScale, customZoomCenterX, customZoomCenterY, customZoomPageWidth, customZoomPageHeight, customZoomOrientation, }) {
606
+ /**
607
+ * upload a document archive produced by {@link getDocumentArchive | `getDocumentArchive`}
608
+ *
609
+ * This explodes the zip archive back into its constituent files, uploads each
610
+ * as a blob, and commits a new document into the root.
611
+ *
612
+ * @remarks
613
+ * This is an experimental feature. By default a fresh document id is generated
614
+ * so re-uploading to the same account doesn't collide with the original; pass
615
+ * {@link PutDocumentOptions.id | `id`} to keep the original id. Like the other
616
+ * low-level puts, this may throw a {@link GenerationError | `GenerationError`}
617
+ * if the generation is stale, requiring a retry.
618
+ *
619
+ * @param buffer - the archive bytes, as returned by `getDocumentArchive`
620
+ * @param options - overrides for parent, visible name, and id
621
+ */
622
+ async putDocumentArchive(buffer, { refresh = false, parent, visibleName, id: keepId, } = {}) {
623
+ if (parent !== undefined && parent && !idReg.test(parent)) {
624
+ throw new ValidationError(parent, idReg, "parent must be a valid document id");
625
+ }
626
+ const zip = await JSZip.loadAsync(buffer);
627
+ const paths = Object.keys(zip.files).filter((path) => !zip.files[path].dir);
628
+ const metaPath = paths.find((path) => path.endsWith(".metadata"));
629
+ if (metaPath === undefined) {
630
+ throw new Error("archive did not contain a .metadata file");
631
+ }
632
+ const oldId = metaPath.slice(0, -9);
633
+ if (oldId.includes("/")) {
634
+ throw new Error(`unexpected nested .metadata path '${metaPath}'`);
635
+ }
636
+ const newId = keepId ?? uuid4();
637
+ // rewrite the old document id prefix on every archived file to the new id,
638
+ // patching the .metadata as we pass it (parent/name/lastModified). the
639
+ // blobs don't depend on the generation, so upload the rewritten files and
640
+ // the document index once, then let #commit retry only the root merge
641
+ const enc = new TextEncoder();
642
+ const dec = new TextDecoder();
643
+ const lastModified = Date.now().toFixed();
644
+ const [, , schemaVersion] = await this.#getRootHash(refresh);
645
+ const fileUploads = await Promise.all(paths.map(async (path) => {
646
+ if (!path.startsWith(oldId)) {
647
+ throw new Error(`archived file '${path}' did not start with '${oldId}'`);
648
+ }
649
+ const newPath = `${newId}${path.slice(oldId.length)}`;
650
+ let bytes = await zip.files[path].async("uint8array");
651
+ if (path === metaPath) {
652
+ const meta = parseMetadata(dec.decode(bytes));
653
+ if (parent !== undefined)
654
+ meta.parent = parent;
655
+ if (visibleName !== undefined)
656
+ meta.visibleName = visibleName;
657
+ meta.lastModified = lastModified;
658
+ bytes = enc.encode(JSON.stringify(meta));
659
+ }
660
+ return this.raw.putFile(newPath, bytes);
661
+ }));
662
+ const fileEntries = fileUploads.map(([entry]) => entry);
663
+ const [docEntry, uploadDoc] = await this.raw.putEntries(newId, fileEntries, schemaVersion);
664
+ await Promise.all([...fileUploads.map(([, upload]) => upload), uploadDoc]);
665
+ await this.#commit(docEntry);
666
+ return { id: newId, hash: docEntry.hash };
667
+ }
668
+ 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
669
  if (parent && !idReg.test(parent)) {
298
670
  throw new ValidationError(parent, idReg, "parent must be a valid document id");
299
671
  }
@@ -339,46 +711,94 @@ class Remarkable {
339
711
  redirectionPageMap: [0],
340
712
  sizeInBytes: buffer.length.toFixed(),
341
713
  };
342
- // upload raw files, and get root hash
343
- const [[contentEntry, uploadContent], [metadataEntry, uploadMetadata], [pagedataEntry, uploadPagedata], [fileEntry, uploadFile], [rootHash, generation, schemaVersion],] = await Promise.all([
714
+ // the schema version is needed to encode the document index; the blobs
715
+ // themselves don't depend on the generation, so upload them once and let
716
+ // #commit retry only the root merge
717
+ const [, , schemaVersion] = await this.#getRootHash(refresh);
718
+ const [[contentEntry, uploadContent], [metadataEntry, uploadMetadata], [pagedataEntry, uploadPagedata], [fileEntry, uploadFile],] = await Promise.all([
344
719
  this.raw.putContent(`${id}.content`, content),
345
720
  this.raw.putMetadata(`${id}.metadata`, metadata),
346
721
  this.raw.putText(`${id}.pagedata`, "\n"),
347
722
  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
723
  ]);
355
- // now upload a new root entry
356
- rootEntries.push(collectionEntry);
357
- const [rootEntry, uploadRoot] = await this.raw.putEntries("root", rootEntries, 4);
358
- // before updating the root hash, first upload everything
724
+ const [collectionEntry, uploadCollection] = await this.raw.putEntries(id, [contentEntry, metadataEntry, pagedataEntry, fileEntry], schemaVersion);
725
+ // TODO we could return a full entry here, but we should probably decide
726
+ // what that should be, e.g. we could return more fields than the standard
727
+ // entry. Same for putFolder
728
+ // TODO we should also decide if the api should take hashes or ids...
359
729
  await Promise.all([
360
730
  uploadContent,
361
731
  uploadMetadata,
362
732
  uploadPagedata,
363
733
  uploadFile,
364
734
  uploadCollection,
365
- uploadRoot,
366
735
  ]);
367
- // TODO we could return a full entry here, but we should probably decide
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);
736
+ await this.#commit(collectionEntry);
372
737
  return { id, hash: collectionEntry.hash };
373
738
  }
739
+ /**
740
+ * use the low-level api to add a pdf document
741
+ *
742
+ * Since this uses the low-level api, it provides more options than
743
+ * {@link uploadPdf | `uploadPdf`}, but is a little more finicky. Notably, it
744
+ * may throw a {@link GenerationError | `GenerationError`} if the generation
745
+ * doesn't match the current server generation, requiring you to retry until
746
+ * it works.
747
+ *
748
+ * @remarks
749
+ * When `zoomMode` is `"customFit"` the `customZoom*` fields describe the view,
750
+ * all in the source page's device pixels: `customZoomPageWidth` and
751
+ * `customZoomPageHeight` are the page dimensions scaled by the device dpi
752
+ * (`pagePt * dpi / 72`, see {@link deviceScreens | `deviceScreens`}), and the
753
+ * centers are in those pixels.
754
+ *
755
+ * The view always has the device's aspect ratio — you control its height and
756
+ * position, not its shape. `customZoomScale = screenHeight / viewHeight` in
757
+ * device pixels (`screenHeight` fixed per model, see {@link deviceScreens |
758
+ * `deviceScreens`}), normalized to 1:1 native pixels: at `1` the view is
759
+ * screen-tall, showing `screenHeight / customZoomPageHeight` of the page.
760
+ *
761
+ * `customZoomCenterX` offsets the center of the view horizontally from the
762
+ * page center, and `customZoomCenterY` is the absolute distance of the center
763
+ * down from the top of the page; the view's width follows from its height and
764
+ * the device aspect ratio.
765
+ *
766
+ * The fields are a single document-wide setting, but `customZoomCenterY` is
767
+ * applied against each page's own rendered height. On a page rendered taller
768
+ * than `customZoomPageHeight` that distance is a smaller fraction of the page,
769
+ * so the view sits higher and cuts off the bottom; on a shorter page it sits
770
+ * lower and cuts off the top. `customZoomScale` (a ratio) and
771
+ * `customZoomCenterX` (an offset from center) do not shift with page size.
772
+ *
773
+ * @param visibleName - the name to display on the reMarkable
774
+ * @param buffer - the raw pdf
775
+ * @param opts - put options
776
+ * @throws GenerationError if the generation doesn't match the current server generation
777
+ * @returns the entry for the newly inserted document
778
+ */
374
779
  async putPdf(visibleName, buffer, opts = {}) {
375
780
  return await this.#putFile(visibleName, "pdf", buffer, opts);
376
781
  }
782
+ /**
783
+ * use the low-level api to add an epub document
784
+ *
785
+ * Since this uses the low-level api, it provides more options than
786
+ * {@link uploadEpub | `uploadEpub`}, but is a little more finicky. Notably, it
787
+ * may throw a {@link GenerationError | `GenerationError`} if the generation
788
+ * doesn't match the current server generation, requiring you to retry until
789
+ * it works.
790
+ *
791
+ * @param visibleName - the name to display on the reMarkable
792
+ * @param buffer - the raw epub
793
+ * @param opts - put options
794
+ * @throws GenerationError if the generation doesn't match the current server generation
795
+ * @returns the entry for the newly inserted document
796
+ */
377
797
  async putEpub(visibleName, buffer, opts = {}) {
378
798
  return await this.#putFile(visibleName, "epub", buffer, opts);
379
799
  }
380
800
  /** create a folder */
381
- async putFolder(visibleName, { parent = "" } = {}, refresh = false) {
801
+ async putFolder(visibleName, { parent = ROOT_ID } = {}, refresh = false) {
382
802
  if (parent && !idReg.test(parent)) {
383
803
  throw new ValidationError(parent, idReg, "parent must be a valid document id");
384
804
  }
@@ -395,52 +815,68 @@ class Remarkable {
395
815
  type: "CollectionType",
396
816
  visibleName,
397
817
  };
398
- // upload folder contents
399
- const [[contentEntry, uploadContent], [metadataEntry, uploadMetadata], [rootHash, generation, schemaVersion],] = await Promise.all([
818
+ // the blobs don't depend on the generation, so upload them once and let
819
+ // #commit retry only the root merge
820
+ const [, , schemaVersion] = await this.#getRootHash(refresh);
821
+ const [[contentEntry, uploadContent], [metadataEntry, uploadMetadata]] = await Promise.all([
400
822
  this.raw.putContent(`${id}.content`, content),
401
823
  this.raw.putMetadata(`${id}.metadata`, metadata),
402
- this.#getRootHash(refresh),
403
824
  ]);
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
- ]);
419
- // put root hash and return
420
- await this.#putRootHash(rootEntry.hash, generation);
825
+ const [collectionEntry, uploadCollection] = await this.raw.putEntries(id, [contentEntry, metadataEntry], schemaVersion);
826
+ await Promise.all([uploadContent, uploadMetadata, uploadCollection]);
827
+ await this.#commit(collectionEntry);
421
828
  return { id, hash: collectionEntry.hash };
422
829
  }
423
- /** upload an epub */
830
+ /**
831
+ * upload an epub
832
+ *
833
+ * @example
834
+ * ```ts
835
+ * await api.uploadEpub("My EPub", ...);
836
+ * ```
837
+ *
838
+ * @remarks
839
+ * this uses a simpler api that works even with schema version 4.
840
+ *
841
+ * @param visibleName - the name to show for the uploaded epub
842
+ * @param buffer - the epub contents
843
+ */
424
844
  async uploadEpub(visibleName, buffer) {
425
845
  return await this.raw.uploadFile(visibleName, buffer, "application/epub+zip");
426
846
  }
427
- /** upload a pdf */
847
+ /**
848
+ * upload a pdf
849
+ *
850
+ * @example
851
+ * ```ts
852
+ * await api.uploadPdf("My PDF", ...);
853
+ * ```
854
+ *
855
+ * @remarks
856
+ * this uses a simpler api that works even with schema version 4.
857
+ *
858
+ * @param visibleName - the name to show for the uploaded epub
859
+ * @param buffer - the epub contents
860
+ */
428
861
  async uploadPdf(visibleName, buffer) {
429
862
  return await this.raw.uploadFile(visibleName, buffer, "application/pdf");
430
863
  }
431
- /** upload a folder */
864
+ /** create a folder using the simple api */
432
865
  async uploadFolder(visibleName) {
433
866
  return await this.raw.uploadFile(visibleName, new Uint8Array(0), "folder");
434
867
  }
435
868
  /** edit just a content entry */
436
869
  async #editContentRaw(id, hash, update, schemaVersion) {
437
- const { entries } = await this.raw.getEntries(`${id}.docSchema`, hash);
870
+ const { entries } = await this.raw.getEntries({
871
+ id: `${id}.docSchema`,
872
+ hash,
873
+ });
438
874
  const contInd = entries.findIndex((ent) => ent.id.endsWith(".content"));
439
875
  const contEntry = entries[contInd];
440
876
  if (contEntry === undefined) {
441
877
  throw new Error("internal error: couldn't find content in entry hash");
442
878
  }
443
- const cont = await this.raw.getContent(contEntry.id, contEntry.hash);
879
+ const cont = await this.raw.getContent(contEntry);
444
880
  Object.assign(cont, update);
445
881
  const [newContEntry, uploadCont] = await this.raw.putContent(contEntry.id, cont);
446
882
  entries[contInd] = newContEntry;
@@ -450,47 +886,90 @@ class Remarkable {
450
886
  }
451
887
  /** fully sync a content edit */
452
888
  async #editContent(hash, update, expectedType, refresh) {
453
- const [rootHash, generation, schemaVersion] = await this.#getRootHash(refresh);
454
- const { entries } = await this.raw.getEntries("root.docSchema", rootHash);
455
- const hashInd = entries.findIndex((ent) => ent.hash === hash);
456
- const hashEnt = entries[hashInd];
457
- if (hashEnt === undefined) {
458
- throw new HashNotFoundError(hash);
459
- }
460
- const [[newEnt, uploadEnt], meta] = await Promise.all([
461
- this.#editContentRaw(hashEnt.id, hash, update, schemaVersion),
462
- this.getMetadata(hashEnt.id, hash),
463
- ]);
464
- if (meta.type !== expectedType) {
465
- throw new Error(`expected type ${expectedType} but got ${meta.type} for hash ${hash}`);
466
- }
467
- entries[hashInd] = newEnt;
468
- const [rootEntry, uploadRoot] = await this.raw.putEntries("root", entries, 4);
469
- await Promise.all([uploadEnt, uploadRoot]);
470
- await this.#putRootHash(rootEntry.hash, generation);
471
- return { hash: newEnt.hash };
889
+ return await this.#withRetry(async () => {
890
+ const [rootHash, generation, schemaVersion] = await this.#getRootHash(refresh);
891
+ const { entries } = await this.raw.getEntries({
892
+ id: ROOT_SCHEMA,
893
+ hash: rootHash,
894
+ });
895
+ const hashInd = entries.findIndex((ent) => ent.hash === hash);
896
+ const hashEnt = entries[hashInd];
897
+ if (hashEnt === undefined) {
898
+ throw new HashNotFoundError(hash);
899
+ }
900
+ const [[newEnt, uploadEnt], meta] = await Promise.all([
901
+ this.#editContentRaw(hashEnt.id, hash, update, schemaVersion),
902
+ this.getMetadata(hashEnt),
903
+ ]);
904
+ if (meta.type !== expectedType) {
905
+ throw new Error(`expected type ${expectedType} but got ${meta.type} for hash ${hash}`);
906
+ }
907
+ entries[hashInd] = newEnt;
908
+ const [rootEntry, uploadRoot] = await this.raw.putEntries(ROOT_LIST, entries, 4);
909
+ await Promise.all([uploadEnt, uploadRoot]);
910
+ await this.#putRootHash(rootEntry.hash, generation);
911
+ return { id: hashEnt.id, hash: newEnt.hash };
912
+ });
472
913
  }
473
- /** update document content */
474
- async updateDocument(hash, content, refresh = false) {
475
- return await this.#editContent(hash, content, "DocumentType", refresh);
914
+ /**
915
+ * update content metadata for a document
916
+ *
917
+ * @example
918
+ * ```ts
919
+ * const next = await api.updateDocument(doc, { textAlignment: "left" });
920
+ * ```
921
+ *
922
+ * @param ref - a reference to the file to update
923
+ * @param content - the fields of content to update
924
+ * @returns a reference to the updated entry, with its new hash
925
+ */
926
+ async updateDocument(ref, content, refresh = false) {
927
+ return await this.#editContent(ref.hash, content, "DocumentType", refresh);
476
928
  }
477
- /** update collection content */
478
- async updateCollection(hash, content, refresh = false) {
479
- return await this.#editContent(hash, content, "CollectionType", refresh);
929
+ /**
930
+ * update content metadata for a collection
931
+ *
932
+ * @example
933
+ * ```ts
934
+ * const next = await api.updateCollection(dir, { textAlignment: "left" });
935
+ * ```
936
+ *
937
+ * @param ref - a reference to the collection to update
938
+ * @param content - the fields of content to update
939
+ * @returns a reference to the updated entry, with its new hash
940
+ */
941
+ async updateCollection(ref, content, refresh = false) {
942
+ return await this.#editContent(ref.hash, content, "CollectionType", refresh);
480
943
  }
481
- /** update template content */
482
- async updateTemplate(hash, content, refresh = false) {
483
- return await this.#editContent(hash, content, "TemplateType", refresh);
944
+ /**
945
+ * update content metadata for a template
946
+ *
947
+ * @example
948
+ * ```ts
949
+ * const next = await api.updateTemplate(tmpl, { textAlignment: "left" });
950
+ * ```
951
+ *
952
+ * @param ref - a reference to the template to update
953
+ * @param content - the fields of content to update
954
+ * @returns a reference to the updated entry, with its new hash
955
+ */
956
+ async updateTemplate(ref, content, refresh = false) {
957
+ return await this.#editContent(ref.hash, content, "TemplateType", refresh);
484
958
  }
485
959
  async #editMetaRaw(id, hash, update, schemaVersion) {
486
- const { entries } = await this.raw.getEntries(`${id}.docSchema`, hash);
960
+ const { entries } = await this.raw.getEntries({
961
+ id: `${id}.docSchema`,
962
+ hash,
963
+ });
487
964
  const metaInd = entries.findIndex((ent) => ent.id.endsWith(".metadata"));
488
965
  const metaEntry = entries[metaInd];
489
966
  if (metaEntry === undefined) {
490
967
  throw new Error("internal error: couldn't find metadata in entry hash");
491
968
  }
492
- const meta = await this.raw.getMetadata(metaEntry.id, metaEntry.hash);
969
+ const meta = await this.raw.getMetadata(metaEntry);
493
970
  Object.assign(meta, update);
971
+ meta.version = (meta.version ?? 0) + 1;
972
+ meta.metadatamodified = true;
494
973
  const [newMetaEntry, uploadMeta] = await this.raw.putMetadata(metaEntry.id, meta);
495
974
  entries[metaInd] = newMetaEntry;
496
975
  const [result, uploadEntries] = await this.raw.putEntries(id, entries, schemaVersion);
@@ -498,74 +977,165 @@ class Remarkable {
498
977
  return [result, upload];
499
978
  }
500
979
  async #editMeta(hash, update, refresh = false) {
501
- const [rootHash, generation, schemaVersion] = await this.#getRootHash(refresh);
502
- const { entries } = await this.raw.getEntries("root.docSchema", rootHash);
503
- const hashInd = entries.findIndex((ent) => ent.hash === hash);
504
- const hashEnt = entries[hashInd];
505
- if (hashEnt === undefined) {
506
- throw new HashNotFoundError(hash);
507
- }
508
- const [newEnt, uploadEnt] = await this.#editMetaRaw(hashEnt.id, hash, update, schemaVersion);
509
- entries[hashInd] = newEnt;
510
- const [rootEntry, uploadRoot] = await this.raw.putEntries("root", entries, 4);
511
- await Promise.all([uploadEnt, uploadRoot]);
512
- await this.#putRootHash(rootEntry.hash, generation);
513
- return { hash: newEnt.hash };
514
- }
515
- /** move an entry */
516
- async move(hash, parent, refresh = false) {
980
+ return await this.#withRetry(async () => {
981
+ const [rootHash, generation, schemaVersion] = await this.#getRootHash(refresh);
982
+ const { entries } = await this.raw.getEntries({
983
+ id: ROOT_SCHEMA,
984
+ hash: rootHash,
985
+ });
986
+ const hashInd = entries.findIndex((ent) => ent.hash === hash);
987
+ const hashEnt = entries[hashInd];
988
+ if (hashEnt === undefined) {
989
+ throw new HashNotFoundError(hash);
990
+ }
991
+ const [newEnt, uploadEnt] = await this.#editMetaRaw(hashEnt.id, hash, update, schemaVersion);
992
+ entries[hashInd] = newEnt;
993
+ const [rootEntry, uploadRoot] = await this.raw.putEntries(ROOT_LIST, entries, 4);
994
+ await Promise.all([uploadEnt, uploadRoot]);
995
+ await this.#putRootHash(rootEntry.hash, generation);
996
+ return { id: hashEnt.id, hash: newEnt.hash };
997
+ });
998
+ }
999
+ /**
1000
+ * move an entry
1001
+ *
1002
+ * @example
1003
+ * ```ts
1004
+ * const next = await api.move(doc, dir.id);
1005
+ * ```
1006
+ *
1007
+ * @param ref - a reference to the entry to move
1008
+ * @param parent - the id of the directory to move the entry to, "" (root) and "trash" are special parents
1009
+ * @returns a reference to the moved entry, with its new hash
1010
+ */
1011
+ async move(ref, parent, refresh = false) {
517
1012
  if (!idReg.test(parent)) {
518
1013
  throw new ValidationError(parent, idReg, "parent must be a valid document id");
519
1014
  }
520
- return await this.#editMeta(hash, { parent }, refresh);
1015
+ return await this.#editMeta(ref.hash, { parent }, refresh);
521
1016
  }
522
- /** delete an entry */
523
- async delete(hash, refresh = false) {
524
- return await this.move(hash, "trash", refresh);
1017
+ /**
1018
+ * delete an entry
1019
+ *
1020
+ * @example
1021
+ * ```ts
1022
+ * await api.delete(file);
1023
+ * ```
1024
+ * @param ref - a reference to the entry to delete
1025
+ * @returns a reference to the deleted entry, with its new hash
1026
+ */
1027
+ async delete(ref, refresh = false) {
1028
+ return await this.move(ref, TRASH_ID, refresh);
525
1029
  }
526
- /** rename an entry */
527
- async rename(hash, visibleName, refresh = false) {
528
- return await this.#editMeta(hash, { visibleName }, refresh);
1030
+ /**
1031
+ * rename an entry
1032
+ *
1033
+ * @example
1034
+ * ```ts
1035
+ * const next = await api.rename(file, "new name");
1036
+ * ```
1037
+ * @param ref - a reference to the entry to rename
1038
+ * @param visibleName - the new name to assign
1039
+ * @returns a reference to the renamed entry, with its new hash
1040
+ */
1041
+ async rename(ref, visibleName, refresh = false) {
1042
+ return await this.#editMeta(ref.hash, { visibleName }, refresh);
529
1043
  }
530
- /** stared */
531
- async stared(hash, stared, refresh = false) {
532
- return await this.#editMeta(hash, { pinned: stared }, refresh);
1044
+ /**
1045
+ * star or unstar an entry
1046
+ *
1047
+ * @example
1048
+ * ```ts
1049
+ * const next = await api.star(file, true);
1050
+ * ```
1051
+ * @param ref - a reference to the entry to star
1052
+ * @param starred - whether the entry should be starred or not
1053
+ * @returns a reference to the updated entry, with its new hash
1054
+ */
1055
+ async star(ref, starred, refresh = false) {
1056
+ return await this.#editMeta(ref.hash, { pinned: starred }, refresh);
533
1057
  }
534
- /** move many hashes */
535
- async bulkMove(hashes, parent, refresh = false) {
1058
+ /**
1059
+ * move many entries
1060
+ *
1061
+ * @example
1062
+ * ```ts
1063
+ * const next = await api.bulkMove([file], dir.id);
1064
+ * ```
1065
+ *
1066
+ * @param refs - references to the entries to move
1067
+ * @param parent - the directory id to move the entries to, "" (root) and "trash" are special ids
1068
+ * @returns references to the moved entries, each with its new hash
1069
+ */
1070
+ async bulkMove(refs, parent, refresh = false) {
536
1071
  if (!idReg.test(parent)) {
537
1072
  throw new ValidationError(parent, idReg, "parent must be a valid document id");
538
1073
  }
539
- const [rootHash, generation, schemaVersion] = await this.#getRootHash(refresh);
540
- const { entries } = await this.raw.getEntries("root.docSchema", rootHash);
541
- const hashSet = new Set(hashes);
542
- const toUpdate = [];
543
- const newEntries = [];
544
- for (const entry of entries) {
545
- const part = hashSet.has(entry.hash) ? toUpdate : newEntries;
546
- part.push(entry);
547
- }
548
- const resolved = await Promise.all(toUpdate.map(({ id, hash }) => this.#editMetaRaw(id, hash, { parent }, schemaVersion)));
549
- const uploads = [];
550
- const result = {};
551
- for (const [i, [newEnt, upload]] of resolved.entries()) {
552
- newEntries.push(newEnt);
553
- uploads.push(upload);
554
- result[toUpdate[i].hash] = newEnt.hash;
555
- }
556
- const [rootEntry, uploadRoot] = await this.raw.putEntries("root", newEntries, 4);
557
- await Promise.all([Promise.all(uploads), uploadRoot]);
558
- await this.#putRootHash(rootEntry.hash, generation);
559
- return { hashes: result };
560
- }
561
- /** delete many hashes */
562
- async bulkDelete(hashes, refresh = false) {
563
- return await this.bulkMove(hashes, "trash", refresh);
564
- }
565
- /** dump the raw cache */
1074
+ return await this.#withRetry(async () => {
1075
+ const [rootHash, generation, schemaVersion] = await this.#getRootHash(refresh);
1076
+ const { entries } = await this.raw.getEntries({
1077
+ id: ROOT_SCHEMA,
1078
+ hash: rootHash,
1079
+ });
1080
+ const hashSet = new Set(refs.map((ref) => ref.hash));
1081
+ const toUpdate = [];
1082
+ const newEntries = [];
1083
+ for (const entry of entries) {
1084
+ const part = hashSet.has(entry.hash) ? toUpdate : newEntries;
1085
+ part.push(entry);
1086
+ }
1087
+ const resolved = await Promise.all(toUpdate.map(({ id, hash }) => this.#editMetaRaw(id, hash, { parent }, schemaVersion)));
1088
+ const uploads = [];
1089
+ const result = [];
1090
+ for (const [i, [newEnt, upload]] of resolved.entries()) {
1091
+ newEntries.push(newEnt);
1092
+ uploads.push(upload);
1093
+ result.push({ id: toUpdate[i].id, hash: newEnt.hash });
1094
+ }
1095
+ const [rootEntry, uploadRoot] = await this.raw.putEntries(ROOT_LIST, newEntries, 4);
1096
+ await Promise.all([Promise.all(uploads), uploadRoot]);
1097
+ await this.#putRootHash(rootEntry.hash, generation);
1098
+ return result;
1099
+ });
1100
+ }
1101
+ /**
1102
+ * delete many entries
1103
+ *
1104
+ * @example
1105
+ * ```ts
1106
+ * await api.bulkDelete([file]);
1107
+ * ```
1108
+ *
1109
+ * @param refs - references to the entries to delete
1110
+ * @returns references to the deleted entries, each with its new hash
1111
+ */
1112
+ async bulkDelete(refs, refresh = false) {
1113
+ return await this.bulkMove(refs, TRASH_ID, refresh);
1114
+ }
1115
+ /**
1116
+ * get the current cache value as a string
1117
+ *
1118
+ * You can use this to warm start a new instance of
1119
+ * {@link remarkable | `remarkable`} with any previously cached results.
1120
+ */
566
1121
  dumpCache() {
567
1122
  return this.raw.dumpCache();
568
1123
  }
1124
+ /**
1125
+ * prune the cache so that it contains only reachable hashes
1126
+ *
1127
+ * The cache is append only, so it can grow without bound, even as hashes
1128
+ * become unreachable. In the future, this may have better cache management to
1129
+ * track this in real time, but for now, you can call this method, to keep it
1130
+ * from growing continuously.
1131
+ *
1132
+ * @remarks
1133
+ * This won't necessarily reduce the cache size. In order to see if
1134
+ * hashes are reachable we first have to search through all existing entry
1135
+ * lists.
1136
+ *
1137
+ * @param refresh - whether to refresh the root hash before pruning
1138
+ */
569
1139
  async pruneCache(refresh) {
570
1140
  const [rootHash] = await this.#getRootHash(refresh);
571
1141
  // start by assuming every cached hash is unreachable, then keep the ones we reach
@@ -575,7 +1145,7 @@ class Remarkable {
575
1145
  // should only go one step) to track all hashes encountered
576
1146
  // NOTE that we could increase the cache in this process, or it's possible
577
1147
  // for other calls to increase the cache with misc values.
578
- const base = await this.raw.getEntries("root.docSchema", rootHash);
1148
+ const base = await this.raw.getEntries({ id: ROOT_SCHEMA, hash: rootHash });
579
1149
  let entries = [base.entries];
580
1150
  let nextEntries = [];
581
1151
  while (entries.length) {
@@ -583,7 +1153,7 @@ class Remarkable {
583
1153
  for (const { hash, subfiles, id } of entryList) {
584
1154
  toDelete.delete(hash);
585
1155
  if (subfiles > 0) {
586
- nextEntries.push(this.raw.getEntries(`${id}.docSchema`, hash));
1156
+ nextEntries.push(this.raw.getEntries({ id: `${id}.docSchema`, hash }));
587
1157
  }
588
1158
  }
589
1159
  }
@@ -595,6 +1165,12 @@ class Remarkable {
595
1165
  this.#cache.delete(key);
596
1166
  }
597
1167
  }
1168
+ /**
1169
+ * completely delete the cache
1170
+ *
1171
+ * If the cache is causing memory issues, you can clear it, but this will hurt
1172
+ * performance.
1173
+ */
598
1174
  clearCache() {
599
1175
  this.raw.clearCache();
600
1176
  }
@@ -628,7 +1204,7 @@ export async function auth(deviceToken, { authHost = AUTH_HOST } = {}) {
628
1204
  * @param sessionToken - the session token used for authorization
629
1205
  * @returns an api instance
630
1206
  */
631
- export function session(sessionToken, { rawHost = RAW_HOST, uploadHost = UPLOAD_HOST, cache, maxCacheSize = Infinity, } = {}) {
1207
+ export function session(sessionToken, { rawHost = RAW_HOST, uploadHost = UPLOAD_HOST, cache, maxCacheSize = Infinity, maxGenerationRetries = 10, maxTransientRetries = 3, } = {}) {
632
1208
  const initCache = JSON.parse(cache ?? "{}");
633
1209
  const parsedCache = cached.safeParse(initCache);
634
1210
  if (parsedCache.success) {
@@ -636,7 +1212,7 @@ export function session(sessionToken, { rawHost = RAW_HOST, uploadHost = UPLOAD_
636
1212
  const cacheMap = maxCacheSize === Infinity
637
1213
  ? new Map(entries)
638
1214
  : new LruCache(maxCacheSize, entries);
639
- return new Remarkable(sessionToken, rawHost, uploadHost, cacheMap);
1215
+ return new Remarkable(sessionToken, rawHost, uploadHost, cacheMap, maxGenerationRetries, maxTransientRetries);
640
1216
  }
641
1217
  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
1218
  }
@@ -651,12 +1227,14 @@ export function session(sessionToken, { rawHost = RAW_HOST, uploadHost = UPLOAD_
651
1227
  * @returns an api instance
652
1228
  */
653
1229
  export async function remarkable(deviceToken, options = {}) {
654
- const { authHost, rawHost, uploadHost, cache, maxCacheSize } = options ?? {};
1230
+ const { authHost, rawHost, uploadHost, cache, maxCacheSize, maxGenerationRetries, maxTransientRetries, } = options ?? {};
655
1231
  const sessionToken = await auth(deviceToken, { authHost });
656
1232
  return session(sessionToken, {
657
1233
  rawHost,
658
1234
  uploadHost,
659
1235
  cache,
660
1236
  maxCacheSize,
1237
+ maxGenerationRetries,
1238
+ maxTransientRetries,
661
1239
  });
662
1240
  }