rmapi-js 8.1.1 → 8.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -51,25 +51,20 @@
51
51
  *
52
52
  * @packageDocumentation
53
53
  */
54
- import { fromByteArray } from "base64-js";
55
- import CRC32C from "crc-32/crc32c";
56
54
  import JSZip from "jszip";
57
- import { boolean, elements, empty, enumeration, float64, int32, nullable, properties, string, timestamp, uint32, uint8, values, } from "jtd-ts";
55
+ import { nullable, string, values } from "jtd-ts";
58
56
  import { v4 as uuid4 } from "uuid";
57
+ import { HashNotFoundError, ValidationError } from "./error";
59
58
  import { LruCache } from "./lru";
59
+ import { RawRemarkable, } from "./raw";
60
+ export { HashNotFoundError, ValidationError } from "./error";
60
61
  const AUTH_HOST = "https://webapp-prod.cloud.remarkable.engineering";
61
62
  const RAW_HOST = "https://eu.tectonic.remarkable.com";
63
+ // ------------ //
64
+ // Request Info //
65
+ // ------------ //
66
+ // The section has all the types that are stored in the remarkable cloud.
62
67
  const idReg = /^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}||trash)$/;
63
- const hashReg = /^[0-9a-f]{64}$/;
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
68
  /** An error that gets thrown when the backend while trying to update
74
69
  *
75
70
  * IF you encounter this error, you likely just need to try th request again. If
@@ -96,27 +91,6 @@ export class ResponseError extends Error {
96
91
  this.statusText = statusText;
97
92
  }
98
93
  }
99
- /** an error that results from a failed request */
100
- export class ValidationError extends Error {
101
- /** the response status number */
102
- field;
103
- /** the response status text */
104
- regex;
105
- constructor(field, regex, message) {
106
- super(message);
107
- this.field = field;
108
- this.regex = regex;
109
- }
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
- }
120
94
  /**
121
95
  * register a device and get the token needed to access the api
122
96
  *
@@ -149,418 +123,6 @@ export async function register(code, { deviceDesc = "browser-chrome", uuid = uui
149
123
  return await resp.text();
150
124
  }
151
125
  }
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 templateContent = properties({
205
- name: string(),
206
- author: string(),
207
- iconData: string(),
208
- categories: elements(string()),
209
- labels: elements(string()),
210
- orientation: enumeration("portrait", "landscape"),
211
- templateVersion: string(),
212
- formatVersion: uint8(),
213
- supportedScreens: elements(enumeration("rm2", "rmPP")),
214
- constants: elements(values(int32())),
215
- items: elements(empty()),
216
- });
217
- const documentContent = properties({
218
- coverPageNumber: int32(),
219
- documentMetadata,
220
- extraMetadata: values(string()),
221
- fileType: enumeration("epub", "notebook", "pdf"),
222
- fontName: string(),
223
- formatVersion: uint8(),
224
- lineHeight: int32(),
225
- orientation: enumeration("portrait", "landscape"),
226
- pageCount: uint32(),
227
- sizeInBytes: string(),
228
- textAlignment: enumeration("justify", "left"),
229
- textScale: float64(),
230
- }, {
231
- cPages,
232
- customZoomCenterX: float64(),
233
- customZoomCenterY: float64(),
234
- customZoomOrientation: enumeration("portrait", "landscape"),
235
- customZoomPageHeight: float64(),
236
- customZoomPageWidth: float64(),
237
- customZoomScale: float64(),
238
- dummyDocument: boolean(),
239
- keyboardMetadata: properties({
240
- count: uint32(),
241
- timestamp: float64(),
242
- }, undefined, true),
243
- lastOpenedPage: uint32(),
244
- margins: uint32(),
245
- originalPageCount: int32(),
246
- pages: elements(string()),
247
- pageTags: elements(pageTag),
248
- redirectionPageMap: elements(int32()),
249
- tags: elements(tag),
250
- transform: properties({
251
- m11: float64(),
252
- m12: float64(),
253
- m13: float64(),
254
- m21: float64(),
255
- m22: float64(),
256
- m23: float64(),
257
- m31: float64(),
258
- m32: float64(),
259
- m33: float64(),
260
- }, undefined, true),
261
- // eslint-disable-next-line spellcheck/spell-checker
262
- viewBackgroundFilter: enumeration("off", "fullpage"),
263
- zoomMode: enumeration("bestFit", "customFit", "fitToHeight", "fitToWidth"),
264
- }, true);
265
- const metadata = properties({
266
- lastModified: string(),
267
- parent: string(),
268
- pinned: boolean(),
269
- type: enumeration("DocumentType", "CollectionType", "TemplateType"),
270
- visibleName: string(),
271
- }, {
272
- lastOpened: string(),
273
- lastOpenedPage: uint32(),
274
- createdTime: string(),
275
- deleted: boolean(),
276
- metadatamodified: boolean(),
277
- modified: boolean(),
278
- synced: boolean(),
279
- version: uint8(),
280
- }, true);
281
- const updatedRootHash = properties({
282
- hash: string(),
283
- generation: float64(),
284
- }, undefined, true);
285
- const rootHash = properties({
286
- hash: string(),
287
- generation: float64(),
288
- schemaVersion: uint8(),
289
- }, undefined, true);
290
- async function digest(buff) {
291
- const digest = await crypto.subtle.digest("SHA-256", buff);
292
- return [...new Uint8Array(digest)]
293
- .map((x) => x.toString(16).padStart(2, "0"))
294
- .join("");
295
- }
296
- class RawRemarkable {
297
- #authedFetch;
298
- #rawHost;
299
- /**
300
- * a cache of all hashes we know exist
301
- *
302
- * The backend is a readonly file system of hashes to content. After a hash has
303
- * been read or written successfully, we know it exists, and potentially it's
304
- * contents. We don't want to cache large binary files, but we can cache the
305
- * small text based metadata files. For binary files we write null, so we know
306
- * not to write a a cached value again, but we'll still need to read it.
307
- */
308
- #cache;
309
- constructor(authedFetch, cache, rawHost) {
310
- this.#authedFetch = authedFetch;
311
- this.#cache = cache;
312
- this.#rawHost = rawHost;
313
- }
314
- /** make an authorized request to remarkable */
315
- async getRootHash() {
316
- const res = await this.#authedFetch("GET", `${this.#rawHost}/sync/v4/root`);
317
- const raw = await res.text();
318
- const loaded = JSON.parse(raw);
319
- if (!rootHash.guardAssert(loaded))
320
- throw Error("invalid root hash");
321
- const { hash, generation, schemaVersion } = loaded;
322
- if (schemaVersion !== 3) {
323
- throw new Error(`schema version ${schemaVersion} not supported`);
324
- }
325
- else if (!Number.isSafeInteger(generation)) {
326
- throw new Error(`generation ${generation} was not a safe integer; please file a bug report`);
327
- }
328
- else {
329
- return [hash, generation];
330
- }
331
- }
332
- async #getHash(hash) {
333
- if (!hashReg.test(hash)) {
334
- throw new ValidationError(hash, hashReg, "hash was not a valid hash");
335
- }
336
- const resp = await this.#authedFetch("GET", `${this.#rawHost}/sync/v3/files/${hash}`);
337
- // TODO switch to `.bytes()`.
338
- const raw = await resp.arrayBuffer();
339
- return new Uint8Array(raw);
340
- }
341
- async getHash(hash) {
342
- const cached = this.#cache.get(hash);
343
- if (cached != null) {
344
- const enc = new TextEncoder();
345
- return enc.encode(cached);
346
- }
347
- else {
348
- const res = await this.#getHash(hash);
349
- // mark that we know hash exists
350
- const cacheVal = this.#cache.get(hash);
351
- if (cacheVal === undefined) {
352
- this.#cache.set(hash, null);
353
- }
354
- return res;
355
- }
356
- }
357
- async getText(hash) {
358
- const cached = this.#cache.get(hash);
359
- if (cached != null) {
360
- return cached;
361
- }
362
- else {
363
- // NOTE two simultaneous requests will fetch twice
364
- const raw = await this.#getHash(hash);
365
- const dec = new TextDecoder();
366
- const res = dec.decode(raw);
367
- this.#cache.set(hash, res);
368
- return res;
369
- }
370
- }
371
- async getEntries(hash) {
372
- const rawFile = await this.getText(hash);
373
- const [version, ...rest] = rawFile.slice(0, -1).split("\n");
374
- if (version != "3") {
375
- throw new Error(`schema version ${version} not supported`);
376
- }
377
- else {
378
- return rest.map((line) => {
379
- const [hash, type, id, subfiles, size] = line.split(":");
380
- if (hash === undefined ||
381
- type === undefined ||
382
- id === undefined ||
383
- subfiles === undefined ||
384
- size === undefined) {
385
- throw new Error(`line '${line}' was not formatted correctly`);
386
- }
387
- else if (type === "80000000") {
388
- return {
389
- hash,
390
- type: 80000000,
391
- id,
392
- subfiles: parseInt(subfiles),
393
- size: parseInt(size),
394
- };
395
- }
396
- else if (type === "0" && subfiles === "0") {
397
- return {
398
- hash,
399
- type: 0,
400
- id,
401
- subfiles: 0,
402
- size: parseInt(size),
403
- };
404
- }
405
- else {
406
- throw new Error(`line '${line}' was not formatted correctly`);
407
- }
408
- });
409
- }
410
- }
411
- async getContent(hash) {
412
- const raw = await this.getText(hash);
413
- const loaded = JSON.parse(raw);
414
- // jtd can't verify non-discriminated unions, in this case, we have fileType
415
- // defined or not. As a result, we try each, and concatenate the errors at the end
416
- const errors = [];
417
- for (const [name, valid] of [
418
- ["collection", collectionContent],
419
- ["template", templateContent],
420
- ["document", documentContent],
421
- ]) {
422
- try {
423
- if (valid.guardAssert(loaded))
424
- return loaded;
425
- }
426
- catch (ex) {
427
- const msg = ex instanceof Error ? ex.message : "unknown error type";
428
- errors.push(`Couldn't validate as ${name} because:\n${msg}`);
429
- }
430
- }
431
- const joined = errors.join("\n\nor\n\n");
432
- throw new Error(`invalid content: ${joined}`);
433
- }
434
- async getMetadata(hash) {
435
- const raw = await this.getText(hash);
436
- const loaded = JSON.parse(raw);
437
- if (!metadata.guardAssert(loaded))
438
- throw Error("invalid metadata");
439
- return loaded;
440
- }
441
- async putRootHash(hash, generation, broadcast = true) {
442
- if (!Number.isSafeInteger(generation)) {
443
- throw new Error(`generation ${generation} was not a safe integer`);
444
- }
445
- else if (!hashReg.test(hash)) {
446
- throw new ValidationError(hash, hashReg, "rootHash was not a valid hash");
447
- }
448
- const body = JSON.stringify({
449
- hash,
450
- generation,
451
- broadcast,
452
- });
453
- const resp = await this.#authedFetch("PUT", `${this.#rawHost}/sync/v3/root`, { body });
454
- const raw = await resp.text();
455
- const loaded = JSON.parse(raw);
456
- if (!updatedRootHash.guardAssert(loaded))
457
- throw Error("invalid root hash");
458
- const { hash: newHash, generation: newGen } = loaded;
459
- if (Number.isSafeInteger(newGen)) {
460
- return [newHash, newGen];
461
- }
462
- else {
463
- throw new Error(`new generation ${newGen} was not a safe integer; please file a bug report`);
464
- }
465
- }
466
- async #putFile(hash, fileName, bytes) {
467
- // if the hash is already in the cache, writing is pointless
468
- if (!this.#cache.has(hash)) {
469
- const crc = CRC32C.buf(bytes, 0);
470
- const buff = new ArrayBuffer(4);
471
- new DataView(buff).setInt32(0, crc, false);
472
- const crcHash = fromByteArray(new Uint8Array(buff));
473
- await this.#authedFetch("PUT", `${this.#rawHost}/sync/v3/files/${hash}`, {
474
- body: bytes,
475
- headers: {
476
- "rm-filename": fileName,
477
- // eslint-disable-next-line spellcheck/spell-checker
478
- "x-goog-hash": `crc32c=${crcHash}`,
479
- },
480
- });
481
- // mark that we know this hash exists
482
- const cacheVal = this.#cache.get(hash);
483
- if (cacheVal === undefined) {
484
- this.#cache.set(hash, null);
485
- }
486
- }
487
- }
488
- async putFile(id, bytes) {
489
- const hash = await digest(bytes);
490
- const res = {
491
- id,
492
- hash,
493
- type: 0,
494
- subfiles: 0,
495
- size: bytes.length,
496
- };
497
- return [res, this.#putFile(hash, id, bytes)];
498
- }
499
- async putText(id, text) {
500
- const enc = new TextEncoder();
501
- const bytes = enc.encode(text);
502
- const [ent, upload] = await this.putFile(id, bytes);
503
- return [
504
- ent,
505
- upload.then(() => {
506
- // on success, write to cache
507
- this.#cache.set(ent.hash, text);
508
- }),
509
- ];
510
- }
511
- async putContent(id, content) {
512
- if (!id.endsWith(".content")) {
513
- throw new Error(`id ${id} did not end with '.content'`);
514
- }
515
- else {
516
- return await this.putText(id, JSON.stringify(content));
517
- }
518
- }
519
- async putMetadata(id, metadata) {
520
- if (!id.endsWith(".metadata")) {
521
- throw new Error(`id ${id} did not end with '.metadata'`);
522
- }
523
- else {
524
- return await this.putText(id, JSON.stringify(metadata));
525
- }
526
- }
527
- async putEntries(id, entries) {
528
- // NOTE collections have a special hash function, the hash of their
529
- // contents, so this needs to be different
530
- entries.sort((a, b) => a.id.localeCompare(b.id));
531
- const hashBuff = new Uint8Array(entries.length * 32);
532
- for (const [start, { hash }] of entries.entries()) {
533
- for (const [i, byte] of (hash.match(/../g) ?? []).entries()) {
534
- hashBuff[start * 32 + i] = parseInt(byte, 16);
535
- }
536
- }
537
- const hash = await digest(hashBuff);
538
- const size = entries.reduce((acc, ent) => acc + ent.size, 0);
539
- const records = ["3\n"];
540
- for (const { hash, type, id, subfiles, size } of entries) {
541
- records.push(`${hash}:${type}:${id}:${subfiles}:${size}\n`);
542
- }
543
- const res = {
544
- id,
545
- hash,
546
- type: 80000000,
547
- subfiles: entries.length,
548
- size,
549
- };
550
- const enc = new TextEncoder();
551
- return [
552
- res,
553
- // NOTE when monitoring requests, this had the extension .docSchema appended, but I'm not entirely sure why
554
- this.#putFile(hash, `${id}.docSchema`, enc.encode(records.join(""))),
555
- ];
556
- }
557
- dumpCache() {
558
- return JSON.stringify(Object.fromEntries(this.#cache));
559
- }
560
- clearCache() {
561
- this.#cache.clear();
562
- }
563
- }
564
126
  /** the implementation of that api */
565
127
  class Remarkable {
566
128
  #userToken;
@@ -861,6 +423,56 @@ class Remarkable {
861
423
  async uploadPdf(visibleName, buffer, opts = {}) {
862
424
  return await this.putPdf(visibleName, buffer, opts);
863
425
  }
426
+ /** edit just a content entry */
427
+ async #editContentRaw(id, hash, update) {
428
+ const entries = await this.raw.getEntries(hash);
429
+ const contInd = entries.findIndex((ent) => ent.id.endsWith(".content"));
430
+ const contEntry = entries[contInd];
431
+ if (contEntry === undefined) {
432
+ throw new Error("internal error: couldn't find content in entry hash");
433
+ }
434
+ const cont = await this.raw.getContent(contEntry.hash);
435
+ Object.assign(cont, update);
436
+ const [newContEntry, uploadCont] = await this.raw.putContent(contEntry.id, cont);
437
+ entries[contInd] = newContEntry;
438
+ const [result, uploadEntries] = await this.raw.putEntries(id, entries);
439
+ const upload = Promise.all([uploadCont, uploadEntries]);
440
+ return [result, upload];
441
+ }
442
+ /** fully sync a content edit */
443
+ async #editContent(hash, update, expectedType, refresh) {
444
+ const [rootHash, generation] = await this.#getRootHash(refresh);
445
+ const entries = await this.raw.getEntries(rootHash);
446
+ const hashInd = entries.findIndex((ent) => ent.hash === hash);
447
+ const hashEnt = entries[hashInd];
448
+ if (hashEnt === undefined) {
449
+ throw new HashNotFoundError(hash);
450
+ }
451
+ const [[newEnt, uploadEnt], meta] = await Promise.all([
452
+ this.#editContentRaw(hashEnt.id, hash, update),
453
+ this.getMetadata(hash),
454
+ ]);
455
+ if (meta.type !== expectedType) {
456
+ throw new Error(`expected type ${expectedType} but got ${meta.type} for hash ${hash}`);
457
+ }
458
+ entries[hashInd] = newEnt;
459
+ const [rootEntry, uploadRoot] = await this.raw.putEntries("root", entries);
460
+ await Promise.all([uploadEnt, uploadRoot]);
461
+ await this.#putRootHash(rootEntry.hash, generation);
462
+ return { hash: newEnt.hash };
463
+ }
464
+ /** update document content */
465
+ async updateDocument(hash, content, refresh = false) {
466
+ return await this.#editContent(hash, content, "DocumentType", refresh);
467
+ }
468
+ /** update collection content */
469
+ async updateCollection(hash, content, refresh = false) {
470
+ return await this.#editContent(hash, content, "CollectionType", refresh);
471
+ }
472
+ /** update template content */
473
+ async updateTemplate(hash, content, refresh = false) {
474
+ return await this.#editContent(hash, content, "TemplateType", refresh);
475
+ }
864
476
  async #editMetaRaw(id, hash, update) {
865
477
  const entries = await this.raw.getEntries(hash);
866
478
  const metaInd = entries.findIndex((ent) => ent.id.endsWith(".metadata"));
@@ -872,8 +484,8 @@ class Remarkable {
872
484
  Object.assign(meta, update);
873
485
  const [newMetaEntry, uploadMeta] = await this.raw.putMetadata(metaEntry.id, meta);
874
486
  entries[metaInd] = newMetaEntry;
875
- const [result, uploadentries] = await this.raw.putEntries(id, entries);
876
- const upload = Promise.all([uploadMeta, uploadentries]).then(() => { });
487
+ const [result, uploadEntries] = await this.raw.putEntries(id, entries);
488
+ const upload = Promise.all([uploadMeta, uploadEntries]);
877
489
  return [result, upload];
878
490
  }
879
491
  async #editMeta(hash, update, refresh = false) {
@@ -906,6 +518,10 @@ class Remarkable {
906
518
  async rename(hash, visibleName, refresh = false) {
907
519
  return await this.#editMeta(hash, { visibleName }, refresh);
908
520
  }
521
+ /** stared */
522
+ async stared(hash, stared, refresh = false) {
523
+ return await this.#editMeta(hash, { pinned: stared }, refresh);
524
+ }
909
525
  /** move many hashes */
910
526
  async bulkMove(hashes, parent, refresh = false) {
911
527
  if (!idReg.test(parent)) {
@@ -929,8 +545,7 @@ class Remarkable {
929
545
  result[toUpdate[i].hash] = newEnt.hash;
930
546
  }
931
547
  const [rootEntry, uploadRoot] = await this.raw.putEntries("root", newEntries);
932
- uploads.push(uploadRoot);
933
- await Promise.all(uploads);
548
+ await Promise.all([Promise.all(uploads), uploadRoot]);
934
549
  await this.#putRootHash(rootEntry.hash, generation);
935
550
  return { hashes: result };
936
551
  }