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/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
- * const [first, ...rest] = api.listfiles();
19
- * // rename first file
20
- * const api.rename(first.hash, "new name");
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 its parent
40
- * in the file structure. This will be another document id, or one of two
41
- * special ids, "" (the empty string) for the root directory, or "trash" for the
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 { boolean, discriminator, elements, enumeration, float64, properties, string, values, } from "jtd-ts";
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
- /** simple verification wrapper that allows for bypassing */
54
- function verification(res, schema, verify) {
55
- if (!verify || schema.guard(res)) {
56
- return res;
57
- }
58
- else {
59
- throw new Error(`couldn't validate schema: ${JSON.stringify(res)} didn't match schema ${JSON.stringify(schema.schema())}`);
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, fetch = globalThis.fetch, } = {}) {
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
- constructor(userToken, fetch, syncHost) {
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 this.#fetch(url, {
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
- throw new ResponseError(resp.status, resp.statusText, `failed reMarkable request: ${msg}`);
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 files */
204
- async listFiles({ verify = true } = {}) {
649
+ /** list all items */
650
+ async listItems() {
205
651
  const res = await this.#fileRequest();
206
- return verification(res, entries, verify);
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, verify) {
210
- if (verify && !idReg.test(parent)) {
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
- return verification(res, uploadEntry, verify);
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 = "", verify = true } = {}) {
223
- return await this.#uploadFile(parent, visibleName, new ArrayBuffer(0), "folder", verify);
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 = "", verify = true } = {}) {
227
- return await this.#uploadFile(parent, visibleName, buffer, "application/epub+zip", verify);
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 = "", verify = true } = {}) {
231
- return await this.#uploadFile(parent, visibleName, buffer, "application/pdf", verify);
815
+ async uploadPdf(visibleName, buffer, { parent = "" } = {}) {
816
+ return await this.#uploadFile(parent, visibleName, buffer, "application/pdf");
232
817
  }
233
- async #modify(hash, properties, verify) {
234
- if (verify && !hashReg.test(hash)) {
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
- return verification(res, hashEntry, verify);
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, { verify = true } = {}) {
247
- if (verify && !idReg.test(parent)) {
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 }, verify);
838
+ return await this.#modify(hash, { parent });
251
839
  }
252
840
  /** delete an entry */
253
- async delete(hash, opts = {}) {
254
- return await this.move(hash, "trash", opts);
841
+ async delete(hash) {
842
+ return await this.move(hash, "trash");
255
843
  }
256
844
  /** rename an entry */
257
- async rename(hash, visibleName, { verify = true } = {}) {
258
- return await this.#modify(hash, { file_name: visibleName }, verify);
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, verify) {
262
- if (verify) {
263
- const invalidHashes = hashes.filter((hash) => !hashReg.test(hash));
264
- if (invalidHashes.length) {
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
- return verification(res, hashesEntry, verify);
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, { verify = true } = {}) {
280
- if (verify && !idReg.test(parent)) {
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 }, verify);
872
+ return await this.#bulkModify(hashes, { parent });
284
873
  }
285
874
  /** delete many hashes */
286
- async bulkDelete(hashes, opts = {}) {
287
- return await this.bulkMove(hashes, "trash", opts);
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, { fetch = globalThis.fetch, authHost = AUTH_HOST, syncHost = SYNC_HOST, } = {}) {
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
- return new Remarkable(userToken, fetch, syncHost);
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
  }