rmapi-js 11.1.2 → 12.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/raw.js CHANGED
@@ -1,8 +1,39 @@
1
1
  import CRC32C from "crc-32/crc32c";
2
2
  import { z } from "zod";
3
3
  import { ValidationError } from "./error.js";
4
+ import { HEADER_LENGTH, parseV5, serializeRm, VERSION_PREFIX, } from "./rm5.js";
5
+ import { parseRmScene } from "./rm6.js";
4
6
  import { concatArrays } from "./utils.js";
5
7
  const hashReg = /^[0-9a-f]{64}$/;
8
+ /**
9
+ * parse the bytes of a reMarkable `.rm` page file
10
+ *
11
+ * Dispatches on the header version: versions 3/5 parse to a flat
12
+ * {@link RmPageV5 | `RmPageV5`}; version 6 parses to an
13
+ * {@link RmScene | `RmScene`}. Throws for an unknown version or malformed data.
14
+ *
15
+ * @param data - the raw `.rm` file bytes
16
+ * @returns the parsed page
17
+ */
18
+ export function parseRm(data) {
19
+ if (data.length < HEADER_LENGTH) {
20
+ throw new Error("data is too short to be a reMarkable .lines file");
21
+ }
22
+ const header = new TextDecoder().decode(data.subarray(0, HEADER_LENGTH));
23
+ if (!header.startsWith(VERSION_PREFIX)) {
24
+ throw new Error(`unrecognized .lines header: ${JSON.stringify(header)}`);
25
+ }
26
+ const versionChar = header.charAt(VERSION_PREFIX.length);
27
+ if (versionChar === "6") {
28
+ return parseRmScene(data);
29
+ }
30
+ else if (versionChar === "3" || versionChar === "5") {
31
+ return parseV5(data, versionChar === "3" ? 3 : 5);
32
+ }
33
+ else {
34
+ throw new Error(`unsupported .lines version '${versionChar}'`);
35
+ }
36
+ }
6
37
  const tag = z
7
38
  .object({
8
39
  name: z.string(),
@@ -51,6 +82,7 @@ const cPagePage = z
51
82
  .object({ timestamp: z.string(), value: z.number().int() })
52
83
  .passthrough()
53
84
  .optional(),
85
+ modifed: z.string().optional(),
54
86
  })
55
87
  .passthrough();
56
88
  const cPages = z
@@ -183,6 +215,11 @@ const metadata = z
183
215
  source: z.string().optional(),
184
216
  })
185
217
  .passthrough();
218
+ /** parse and validate the json text of a `.metadata` file */
219
+ export function parseMetadata(text) {
220
+ const loaded = JSON.parse(text);
221
+ return metadata.parse(loaded);
222
+ }
186
223
  const updatedRootHash = z
187
224
  .object({
188
225
  hash: z.string(),
@@ -196,7 +233,7 @@ const rootHash = z
196
233
  schemaVersion: z.number().int().nonnegative(),
197
234
  })
198
235
  .passthrough();
199
- const nativeSimpleEntry = z
236
+ const nativeItemRef = z
200
237
  .object({
201
238
  docID: z.string(),
202
239
  hash: z.string(),
@@ -230,6 +267,65 @@ function parseRawEntryLine(line) {
230
267
  throw new Error(`line '${line}' was not formatted correctly`);
231
268
  }
232
269
  }
270
+ /**
271
+ * access to the low-level reMarkable api
272
+ *
273
+ * This class gives more granualar access to the reMarkable cloud, but is more
274
+ * dangerous.
275
+ *
276
+ * ## Overview
277
+ *
278
+ * reMarkable uses an immutable file system, where each file is referenced by
279
+ * the 32 byte sha256 hash of its contents. Each file also has an id used to
280
+ * keep track of updates, so to "update" a file, you upload a new file, and
281
+ * change the hash associated with it's id.
282
+ *
283
+ * Each "item" (a document or a collection) is actually a list of files.
284
+ * The whole reMarkable state is then a list of these lists. Finally, the hash
285
+ * of that list is called the rootHash. To update anything, you have to update
286
+ * the root hash to point to a new list of updated items.
287
+ *
288
+ * This can be dangerous, as corrupting the root hash can destroy all of your
289
+ * files. It is therefore highly recommended to save your current root hash
290
+ * ({@link getRootHash | `getRootHash`}) before using this api to attempt file
291
+ * writes, so you can recover a previous "snapshot" should anything go wrong.
292
+ *
293
+ * ## Items
294
+ *
295
+ * Each item is a collection of individual files. Using
296
+ * {@link getEntries | `getEntries`} on the root hash will give you a list
297
+ * entries that correspond to items. Using `getEntries` on any of those items
298
+ * will get you the files that make up that item.
299
+ *
300
+ * The documented files are:
301
+ * - `<docid>.pdf` - a raw pdf document
302
+ * - `<docid>.epub` - a raw epub document
303
+ * - `<docid>.content` - a json file roughly describing document properties (see {@link DocumentContent | `DocumentContent`})
304
+ * - `<docid>.metadata` - metadata about the document (see {@link Metadata | `Metadata`})
305
+ * - `<docid>.pagedata` - a text file where each line is the template of that page
306
+ * - `<docid>/<pageid>.rm` - [speculative] raw remarkable vectors, text, etc
307
+ * - `<docid>/<pageid>-metadata.json` - [speculative] metadata about the individual page
308
+ * - `<docid>.highlights/<pageid>.json` - [speculative] highlights on the page
309
+ *
310
+ * Some items will have both a `.pdf` and `.epub` file, likely due to preparing
311
+ * for export. Collections only have `.content` and `.metadata` files, with
312
+ * `.content` only containing tags.
313
+ *
314
+ * ## Caching
315
+ *
316
+ * Since everything is tied to the hash of it's contents, we can agressively
317
+ * cache results. We assume that text contents are "small" and so fully cache
318
+ * them, where as binary files we treat as large and only store that we know
319
+ * they exist to prevent future writes.
320
+ *
321
+ * By default, this only persists as long as the api instance is alive. However,
322
+ * for performance reasons, you should call {@link dumpCache | `dumpCache`} to
323
+ * persist the cache between sessions.
324
+ *
325
+ * @remarks
326
+ *
327
+ * Generally all hashes are 64 character hex strings, and all ids are uuid4.
328
+ */
233
329
  export class RawRemarkable {
234
330
  #authedFetch;
235
331
  #rawHost;
@@ -251,6 +347,14 @@ export class RawRemarkable {
251
347
  this.#uploadHost = uploadHost;
252
348
  }
253
349
  /** make an authorized request to remarkable */
350
+ /**
351
+ * gets the root hash and the current generation
352
+ *
353
+ * When calling `putRootHash`, you should pass the generation you got from
354
+ * this call. That way you tell reMarkable you're updating the previous state.
355
+ *
356
+ * @returns the root hash and the current generation
357
+ */
254
358
  async getRootHash() {
255
359
  const res = await this.#authedFetch("GET", `${this.#rawHost}/sync/v4/root`);
256
360
  const raw = await res.text();
@@ -275,7 +379,17 @@ export class RawRemarkable {
275
379
  const raw = await resp.arrayBuffer();
276
380
  return new Uint8Array(raw);
277
381
  }
278
- async getHash(fileName, hash) {
382
+ /**
383
+ * get the raw binary data associated with a hash
384
+ *
385
+ * @param ref - a reference to the stored file. Its `id` is the logical file
386
+ * name (`<id>.<ext>` for files, or `<id>.docSchema` / `"root.docSchema"`
387
+ * for entry indexes), which reMarkable validates against the rm-filename
388
+ * header. Sub-entries from {@link getEntries | `getEntries`} can be passed
389
+ * directly.
390
+ * @returns the data
391
+ */
392
+ async getHash({ id: fileName, hash }) {
279
393
  const cached = this.#cache.get(hash);
280
394
  if (cached != null) {
281
395
  const enc = new TextEncoder();
@@ -291,7 +405,16 @@ export class RawRemarkable {
291
405
  return res;
292
406
  }
293
407
  }
294
- async getText(fileName, hash) {
408
+ /**
409
+ * get raw text data associated with a hash
410
+ *
411
+ * We assume text data are small, and so cache the entire text. If you want to
412
+ * avoid this, use {@link getHash | `getHash`} combined with a TextDecoder.
413
+ *
414
+ * @param ref - a reference to the stored file (see {@link getHash})
415
+ * @returns the text
416
+ */
417
+ async getText({ id: fileName, hash }) {
295
418
  const cached = this.#cache.get(hash);
296
419
  if (cached != null) {
297
420
  return cached;
@@ -305,8 +428,18 @@ export class RawRemarkable {
305
428
  return res;
306
429
  }
307
430
  }
308
- async getEntries(fileName, hash) {
309
- const rawFile = await this.getText(fileName, hash);
431
+ /**
432
+ * get the entries associated with a list hash
433
+ *
434
+ * A list hash is the root hash, or any hash with the type 80000000. NOTE
435
+ * these are hashed differently than files.
436
+ *
437
+ * @param ref - a reference whose `id` is `"root.docSchema"` for the root, or
438
+ * `"<id>.docSchema"` for a sub-document's entry index
439
+ * @returns the entries
440
+ */
441
+ async getEntries(ref) {
442
+ const rawFile = await this.getText(ref);
310
443
  const [version, ...rest] = rawFile.slice(0, -1).split("\n");
311
444
  if (version === "3") {
312
445
  return { entries: rest.map(parseRawEntryLine) };
@@ -334,16 +467,77 @@ export class RawRemarkable {
334
467
  throw new Error(`schema version ${version} not supported`);
335
468
  }
336
469
  }
337
- async getContent(fileName, hash) {
338
- const raw = await this.getText(fileName, hash);
470
+ /**
471
+ * get the parsed and validated `Content` of a content hash
472
+ *
473
+ * Use {@link getText | `getText`} combined with `JSON.parse` to bypass
474
+ * validation
475
+ *
476
+ * @param ref - a reference to the stored file, typically `"<id>.content"`
477
+ * @returns the content
478
+ */
479
+ async getContent(ref) {
480
+ const raw = await this.getText(ref);
339
481
  const loaded = JSON.parse(raw);
340
482
  return content.parse(loaded);
341
483
  }
342
- async getMetadata(fileName, hash) {
343
- const raw = await this.getText(fileName, hash);
484
+ /**
485
+ * get the parsed and validated `Metadata` of a metadata hash
486
+ *
487
+ * Use {@link getText | `getText`} combined with `JSON.parse` to bypass
488
+ * validation
489
+ *
490
+ * @param ref - a reference to the stored file, typically `"<id>.metadata"`
491
+ * @returns the metadata
492
+ */
493
+ async getMetadata(ref) {
494
+ const raw = await this.getText(ref);
344
495
  const loaded = JSON.parse(raw);
345
496
  return metadata.parse(loaded);
346
497
  }
498
+ /**
499
+ * get the parsed reMarkable lines (`.rm`) drawing of a page hash
500
+ *
501
+ * @param ref - a reference to the stored file, typically `"<id>/<pageid>.rm"`
502
+ * @returns the parsed page
503
+ */
504
+ async getRm(ref) {
505
+ const bytes = await this.getHash(ref);
506
+ return parseRm(bytes);
507
+ }
508
+ /**
509
+ * the same as {@link putFile | `putFile`} but rendering an `RmPage` to `.rm`
510
+ * bytes
511
+ *
512
+ * Only version 3 and 5 pages can be rendered; version 6 pages are read-only.
513
+ */
514
+ async putRm(fileName, page) {
515
+ if (!fileName.endsWith(".rm")) {
516
+ throw new Error(`fileName ${fileName} did not end with '.rm'`);
517
+ }
518
+ else {
519
+ return await this.putFile(fileName, serializeRm(page));
520
+ }
521
+ }
522
+ /**
523
+ * update the current root hash
524
+ *
525
+ * This will fail if generation doesn't match the current server generation.
526
+ * This ensures that you are updating what you expect. IF you get a
527
+ * {@link GenerationError | `GenerationError`}, that indicates that the server
528
+ * was updated after you last got the generation. You should call
529
+ * {@link getRootHash | `getRootHash`} and then recompute the changes you want
530
+ * from the new root hash. If you ignore the update hash value and just call
531
+ * `putRootHash` again, you will overwrite the changes made by the other
532
+ * update.
533
+ *
534
+ * @param hash - the new root hash
535
+ * @param generation - the generation of the current root hash
536
+ * @param broadcast - [unknown] an option in the request
537
+ *
538
+ * @throws GenerationError if the generation doesn't match the current server generation
539
+ * @returns the new root hash and the new generation
540
+ */
347
541
  async putRootHash(hash, generation, broadcast = true) {
348
542
  if (!Number.isSafeInteger(generation)) {
349
543
  throw new Error(`generation ${generation} was not a safe integer`);
@@ -388,21 +582,36 @@ export class RawRemarkable {
388
582
  }
389
583
  }
390
584
  }
391
- async putFile(id, bytes) {
585
+ /**
586
+ * put a raw onto the server
587
+ *
588
+ * This returns the new expeced entry of the file you uploaded, and a promise
589
+ * to finish the upload successful. By splitting these two operations you can
590
+ * start using the uploaded entry while file finishes uploading.
591
+ *
592
+ * NOTE: This won't update the state of the reMarkable until this entry is
593
+ * incorporated into the root hash.
594
+ *
595
+ * @param fileName - the file name to upload (e.g. `<id>.pdf`)
596
+ * @param bytes - the bytes to upload
597
+ * @returns the new entry and a promise to finish the upload
598
+ */
599
+ async putFile(fileName, bytes) {
392
600
  const hash = await digest(bytes);
393
601
  const res = {
394
- id,
602
+ id: fileName,
395
603
  hash,
396
604
  type: 0,
397
605
  subfiles: 0,
398
606
  size: bytes.length,
399
607
  };
400
- return [res, this.#putFile(id, hash, bytes)];
608
+ return [res, this.#putFile(fileName, hash, bytes)];
401
609
  }
402
- async putText(id, text) {
610
+ /** the same as {@link putFile | `putFile`} but with caching for text */
611
+ async putText(fileName, text) {
403
612
  const enc = new TextEncoder();
404
613
  const bytes = enc.encode(text);
405
- const [ent, upload] = await this.putFile(id, bytes);
614
+ const [ent, upload] = await this.putFile(fileName, bytes);
406
615
  return [
407
616
  ent,
408
617
  upload.then(() => {
@@ -411,22 +620,47 @@ export class RawRemarkable {
411
620
  }),
412
621
  ];
413
622
  }
414
- async putContent(id, content) {
415
- if (!id.endsWith(".content")) {
416
- throw new Error(`id ${id} did not end with '.content'`);
623
+ /** the same as {@link putText | `putText`} but with extra validation for Content */
624
+ async putContent(fileName, content) {
625
+ if (!fileName.endsWith(".content")) {
626
+ throw new Error(`fileName ${fileName} did not end with '.content'`);
417
627
  }
418
628
  else {
419
- return await this.putText(id, JSON.stringify(content));
629
+ return await this.putText(fileName, JSON.stringify(content));
420
630
  }
421
631
  }
422
- async putMetadata(id, metadata) {
423
- if (!id.endsWith(".metadata")) {
424
- throw new Error(`id ${id} did not end with '.metadata'`);
632
+ /** the same as {@link putText | `putText`} but with extra validation for Metadata */
633
+ async putMetadata(fileName, metadata) {
634
+ if (!fileName.endsWith(".metadata")) {
635
+ throw new Error(`fileName ${fileName} did not end with '.metadata'`);
425
636
  }
426
637
  else {
427
- return await this.putText(id, JSON.stringify(metadata));
638
+ return await this.putText(fileName, JSON.stringify(metadata));
428
639
  }
429
640
  }
641
+ /**
642
+ * put a set of entries to make an entry list file
643
+ *
644
+ * To fully upload an item:
645
+ * 1. upload all the constituent files and metadata
646
+ * 2. call this with all of the entries
647
+ * 3. append this entry to the root entry and call this again to update this root list
648
+ * 4. put the new root hash
649
+ *
650
+ * NOTE: reMarkable currently rejects newly written schema 3 root indexes
651
+ * with a 400 "Software must be updated" error, even for accounts that still
652
+ * report schema 3, so the root list should always be written as schema 4. A
653
+ * warning is logged if a schema 3 root index is written.
654
+ *
655
+ * @param id - the id of the list to upload - this should be the item id if
656
+ * uploading an item list, or "root" if uploading a new root list. Note the
657
+ * asymmetry with {@link getEntries | `getEntries`}: `getEntries` takes the
658
+ * full `"<id>.docSchema"` file name, whereas `putEntries` takes the bare id
659
+ * and appends `.docSchema` (and special-cases `"root"`) itself.
660
+ * @param entries - the entries to upload
661
+ *
662
+ * @returns the new list entry and a promise to finish the upload
663
+ */
430
664
  async putEntries(id, entries, schemaVersion) {
431
665
  if (id === "root" && schemaVersion === 3) {
432
666
  console.warn('writing a schema 3 root index, which reMarkable rejects with a 400 "Software must be updated" error; write the root index with schema version 4 instead');
@@ -471,6 +705,19 @@ export class RawRemarkable {
471
705
  };
472
706
  return [res, this.#putFile(`${id}.docSchema`, hash, entryBuff)];
473
707
  }
708
+ /**
709
+ * upload a file to the reMarkable cloud using the simple api
710
+ *
711
+ * This api is the same as used by the native reMarkable extension and works
712
+ * even if the backend schema version is version 4. Setting mime to "folder"
713
+ * allows folder creation.
714
+ *
715
+ * @param visibleName - the name of the file as it should appear on the reMarkable
716
+ * @param bytes - the bytes of the file to upload
717
+ * @param mime - the mime type of the file to upload
718
+
719
+ * @returns a simple entry with the id and hash of the uploaded file
720
+ */
474
721
  async uploadFile(visibleName, bytes, mime) {
475
722
  const enc = new TextEncoder();
476
723
  const meta = enc
@@ -485,12 +732,18 @@ export class RawRemarkable {
485
732
  },
486
733
  });
487
734
  const loaded = (await resp.json());
488
- const { docID, hash } = nativeSimpleEntry.parse(loaded);
735
+ const { docID, hash } = nativeItemRef.parse(loaded);
489
736
  return { id: docID, hash };
490
737
  }
738
+ /**
739
+ * dump the current cache to a string to preserve between session
740
+ *
741
+ * @returns a serialized version of the cache to pass to a new api instance
742
+ */
491
743
  dumpCache() {
492
744
  return JSON.stringify(Object.fromEntries(this.#cache));
493
745
  }
746
+ /** completely clear the cache */
494
747
  clearCache() {
495
748
  this.#cache.clear();
496
749
  }
package/dist/rm5.d.ts ADDED
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Parse and render the flat (version 3 and 5) reMarkable `.rm` page format.
3
+ *
4
+ * A `.rm` file is the vector drawing for a single notebook page. The version 3
5
+ * and 5 formats are a flat, little-endian struct of layers, each holding strokes
6
+ * ("lines"), each holding sampled points (they differ only by one extra
7
+ * per-stroke field in version 5). {@link parseV5 | `parseV5`} reads them into an
8
+ * {@link RmPageV5 | `RmPageV5`} and {@link serializeRm | `serializeRm`} renders
9
+ * it back to byte-exact bytes.
10
+ *
11
+ * The newer version 6 "scene tree" format lives in `./rm6.js`; the version
12
+ * dispatch that picks between them (`parseRm`) lives in `./raw.js`.
13
+ *
14
+ * @packageDocumentation
15
+ */
16
+ /** the flat reMarkable `.lines` file versions read into an {@link RmPageV5} */
17
+ export type RmVersion = 3 | 5;
18
+ /** a single sampled point along a stroke */
19
+ export interface RmPoint {
20
+ /** the horizontal position in device pixels (see the page type for the origin) */
21
+ x: number;
22
+ /** the vertical position in device pixels (see the page type for the origin) */
23
+ y: number;
24
+ /** the pen speed at this point */
25
+ speed: number;
26
+ /** the pen tilt/heading in radians */
27
+ direction: number;
28
+ /** the stroke width at this point */
29
+ width: number;
30
+ /** the pen pressure, from 0 to 1 */
31
+ pressure: number;
32
+ }
33
+ /**
34
+ * a single stroke (called a "line") within a layer
35
+ *
36
+ * `brushType` and `color` are kept as raw integers because both are overloaded
37
+ * across firmware versions (there are two pen-code families, and the color
38
+ * field is reinterpreted as a palette index when colored annotations are
39
+ * enabled), so the right meaning can only be resolved with the source firmware
40
+ * in mind.
41
+ */
42
+ export interface RmLine {
43
+ /** the raw brush/pen type code */
44
+ brushType: number;
45
+ /** the raw color code */
46
+ color: number;
47
+ /** a per-stroke padding field, typically 0 */
48
+ padding?: number;
49
+ /** the base brush size */
50
+ brushBaseSize: number;
51
+ /** [unknown] a per-stroke field only present in version 5 */
52
+ unknown?: number;
53
+ /** the sampled points making up the stroke, in order */
54
+ points: RmPoint[];
55
+ }
56
+ /** a drawing layer, an ordered set of strokes */
57
+ export interface RmLayer {
58
+ /** the strokes on this layer, in order */
59
+ lines: RmLine[];
60
+ }
61
+ /**
62
+ * a parsed version 3 or 5 page
63
+ *
64
+ * Coordinates use a top-left origin: `x` in `[0, width]`, `y` in `[0, height]`,
65
+ * in device pixels. Only these pages can be re-rendered (via `raw.putRm`).
66
+ */
67
+ export interface RmPageV5 {
68
+ /** the file format version */
69
+ version: 3 | 5;
70
+ /** the drawing layers, back to front */
71
+ layers: RmLayer[];
72
+ }
73
+ /** a decoded pen/tool for a stroke */
74
+ export type RmBrush = "brush" | "pencil" | "ballpoint" | "marker" | "fineliner" | "highlighter" | "eraser" | "mechanicalPencil" | "eraseArea" | "calligraphy" | "shader";
75
+ /**
76
+ * decode a raw {@link RmLine.brushType | `brushType`} code to a pen name
77
+ *
78
+ * Best-effort and advisory: the reMarkable pen codes come in two firmware
79
+ * families and new ones appear over time, so an unrecognized code returns
80
+ * `undefined`. The raw `brushType` int stays authoritative.
81
+ */
82
+ export declare function decodeBrush(code: number): RmBrush | undefined;
83
+ /**
84
+ * the default (monochrome) reMarkable palette, by raw {@link RmLine.color | `color`}
85
+ *
86
+ * Advisory only: when "colored annotations" are enabled or on version 6, the
87
+ * `color` field is reinterpreted, so resolve against the source firmware.
88
+ */
89
+ export declare const rmColors: Readonly<Record<number, string>>;
90
+ /** the length of the fixed `.lines` header, in bytes */
91
+ export declare const HEADER_LENGTH = 43;
92
+ /** the ascii prefix of the header, immediately followed by the version digit */
93
+ export declare const VERSION_PREFIX = "reMarkable .lines file, version=";
94
+ /**
95
+ * parse a version 3 or 5 (flat struct) `.rm` page
96
+ *
97
+ * Named for version 5, the common case, but the two formats differ only by the
98
+ * extra {@link RmLine.unknown | `unknown`} field version 5 adds per stroke.
99
+ */
100
+ export declare function parseV5(data: Uint8Array, version: 3 | 5): RmPageV5;
101
+ /**
102
+ * render a page back into reMarkable `.rm` file bytes
103
+ *
104
+ * The inverse of {@link parseV5 | `parseV5`}: `serializeRm(parseV5(bytes, v))`
105
+ * reproduces the original bytes for version 3 and 5 files. A missing `padding`
106
+ * or (version 5) `unknown` field is written as `0`.
107
+ *
108
+ * @param page - the page to render
109
+ * @returns the `.rm` file bytes
110
+ */
111
+ export declare function serializeRm(page: RmPageV5): Uint8Array;