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