rmapi-js 5.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,10 +4,11 @@
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
- * A simple fetch
11
+ * A simple rename
11
12
  * ```ts
12
13
  * import { register, remarkable } from "rmapi-js";
13
14
  *
@@ -15,17 +16,10 @@
15
16
  * const token = await register(code)
16
17
  * // persist token
17
18
  * const api = await remarkable(token);
18
- * const rootEntries = await api.getEntries();
19
- * for (const entry of rootEntries) {
20
- * const children = await api.getEntries(entry.hash);
21
- * for (const { hash, documentId } of children) {
22
- * if (documentId.endsWith(".metadata")) {
23
- * const meta = api.getMetadata(hash);
24
- * // get metadata for entry
25
- * console.log(meta);
26
- * }
27
- * }
28
- * }
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");
29
23
  * ```
30
24
  *
31
25
  * @example
@@ -38,173 +32,91 @@
38
32
  * await api.create(entry);
39
33
  * ```
40
34
  *
35
+ * @remarks
36
+ *
37
+ * The cloud api is essentially a collection of entries. Each entry has an id,
38
+ * which is a uuid4 and a hash, which indicates it's current state, and changes
39
+ * as the item mutates, where the id is constant. Most mutable operations take
40
+ * the initial hash so that merge conflicts can be resolved. Each entry has a
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)
52
+ *
41
53
  * @packageDocumentation
42
54
  */
43
55
  import { fromByteArray } from "base64-js";
44
- import stringify from "json-stable-stringify";
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";
45
59
  import { v4 as uuid4 } from "uuid";
46
- import { concatBuffers, fromHex, toHex } from "./utils";
47
- import { validate } from "./validate";
48
- const SCHEMA_VERSION = "3";
60
+ import { LruCache } from "./lru";
49
61
  const AUTH_HOST = "https://webapp-prod.cloud.remarkable.engineering";
50
- const SYNC_HOST = "https://internal.cloud.remarkable.com";
51
- const GENERATION_HEADER = "x-goog-generation";
52
- const GENERATION_RACE_HEADER = "x-goog-if-generation-match";
53
- const CONTENT_LENGTH_RANGE_HEADER = "x-goog-content-length-range";
54
- /** tool options */
55
- /* eslint-disable spellcheck/spell-checker */
56
- export const builtinTools = [
57
- "Ballpoint",
58
- "Ballpointv2",
59
- "Brush",
60
- "Calligraphy",
61
- "ClearPage",
62
- "EraseSection",
63
- "Eraser",
64
- "Fineliner",
65
- "Finelinerv2",
66
- "Highlighter",
67
- "Highlighterv2",
68
- "Marker",
69
- "Markerv2",
70
- "Paintbrush",
71
- "Paintbrushv2",
72
- "Pencilv2",
73
- "SharpPencil",
74
- "SharpPencilv2",
75
- "SolidPen",
76
- "ZoomTool",
77
- ];
78
- /* eslint-enable spellcheck/spell-checker */
79
- /** font name options */
80
- /* eslint-disable spellcheck/spell-checker */
81
- export const builtinFontNames = [
82
- "Maison Neue",
83
- "EB Garamond",
84
- "Noto Sans",
85
- "Noto Serif",
86
- "Noto Mono",
87
- "Noto Sans UI",
88
- ];
89
- /* eslint-enable spellcheck/spell-checker */
90
- /** text scale options */
91
- export const builtinTextScales = {
92
- /** the smallest */
93
- xs: 0.7,
94
- /** small */
95
- sm: 0.8,
96
- /** medium / default */
97
- md: 1.0,
98
- /** large */
99
- lg: 1.2,
100
- /** extra large */
101
- xl: 1.5,
102
- /** double extra large */
103
- xx: 2.0,
104
- };
105
- /** margin options */
106
- export const builtinMargins = {
107
- /** small */
108
- sm: 50,
109
- /** medium */
110
- md: 125,
111
- /** default for read on remarkable */
112
- rr: 180,
113
- /** large */
114
- lg: 200,
115
- };
116
- /** line height options */
117
- export const builtinLineHeights = {
118
- /** default */
119
- df: -1,
120
- /** normal */
121
- md: 100,
122
- /** half */
123
- lg: 150,
124
- /** double */
125
- xl: 200,
126
- };
127
- const uploadEntrySchema = {
128
- properties: {
129
- docID: { type: "string" },
130
- hash: { type: "string" },
131
- },
132
- };
133
- const urlResponseSchema = {
134
- properties: {
135
- relative_path: { type: "string" },
136
- url: { type: "string" },
137
- expires: { type: "timestamp" },
138
- method: { enum: ["POST", "GET", "PUT", "DELETE"] },
139
- },
140
- optionalProperties: {
141
- maxuploadsize_bytes: { type: "float64" },
142
- },
143
- };
62
+ const RAW_HOST = "https://eu.tectonic.remarkable.com";
63
+ const SYNC_HOST = "https://web.eu.tectonic.remarkable.com";
64
+ const idReg = /^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}||trash)$/;
65
+ const hashReg = /^[0-9a-f]{64}$/;
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);
144
75
  const commonProperties = {
145
- visibleName: { type: "string" },
76
+ id: string(),
77
+ hash: string(),
78
+ visibleName: string(),
79
+ lastModified: string(),
80
+ pinned: boolean(),
146
81
  };
147
82
  const commonOptionalProperties = {
148
- lastModified: { type: "string" },
149
- version: { type: "int32" },
150
- pinned: { type: "boolean" },
151
- synced: { type: "boolean" },
152
- modified: { type: "boolean" },
153
- deleted: { type: "boolean" },
154
- metadatamodified: { type: "boolean" },
155
- parent: { type: "string" },
156
- };
157
- const metadataSchema = {
158
- discriminator: "type",
159
- mapping: {
160
- CollectionType: {
161
- properties: commonProperties,
162
- optionalProperties: commonOptionalProperties,
163
- additionalProperties: true,
164
- },
165
- DocumentType: {
166
- properties: commonProperties,
167
- optionalProperties: {
168
- ...commonOptionalProperties,
169
- lastOpened: { type: "string" },
170
- lastOpenedPage: { type: "int32" },
171
- createdTime: { type: "string" },
172
- },
173
- additionalProperties: true,
174
- },
175
- },
176
- };
177
- const baseMetadataProperties = {
178
- id: { type: "string" },
179
- hash: { type: "string" },
180
- };
181
- const metadataEntrySchema = {
182
- discriminator: "type",
183
- mapping: {
184
- CollectionType: {
185
- properties: {
186
- ...commonProperties,
187
- ...baseMetadataProperties,
188
- },
189
- optionalProperties: commonOptionalProperties,
190
- additionalProperties: true,
191
- },
192
- DocumentType: {
193
- properties: {
194
- ...commonProperties,
195
- ...baseMetadataProperties,
196
- fileType: { enum: ["notebook", "epub", "pdf", ""] },
197
- },
198
- optionalProperties: {
199
- ...commonOptionalProperties,
200
- lastOpened: { type: "string" },
201
- lastOpenedPage: { type: "int32" },
202
- createdTime: { type: "string" },
203
- },
204
- additionalProperties: true,
205
- },
206
- },
83
+ parent: string(),
84
+ tags: elements(properties({
85
+ name: string(),
86
+ timestamp: float64(),
87
+ }, undefined, true)),
207
88
  };
89
+ const entry = discriminator("type", {
90
+ CollectionType: properties(commonProperties, commonOptionalProperties, true),
91
+ DocumentType: properties({
92
+ ...commonProperties,
93
+ lastOpened: string(),
94
+ fileType: enumeration("epub", "pdf", "notebook"),
95
+ }, commonOptionalProperties, true),
96
+ });
97
+ const entries = elements(entry);
98
+ const uploadEntry = properties({
99
+ docID: string(),
100
+ hash: string(),
101
+ }, undefined, true);
102
+ const hashEntry = properties({ hash: string() }, undefined, true);
103
+ const hashesEntry = properties({
104
+ hashes: values(string()),
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
+ }
208
120
  /** an error that results from a failed request */
209
121
  export class ResponseError extends Error {
210
122
  /** the response status number */
@@ -217,15 +129,16 @@ export class ResponseError extends Error {
217
129
  this.statusText = statusText;
218
130
  }
219
131
  }
220
- /**
221
- * an error that results from trying yp update the wrong generation.
222
- *
223
- * If we try to update the root hash of files, but the generation has changed
224
- * relative to the one we're updating from, this will fail.
225
- */
226
- export class GenerationError extends Error {
227
- constructor() {
228
- super("Generation preconditions failed. This means the current state is out of date with the cloud and needs to be re-synced.");
132
+ /** an error that results from a failed request */
133
+ export class ValidationError extends Error {
134
+ /** the response status number */
135
+ field;
136
+ /** the response status text */
137
+ regex;
138
+ constructor(field, regex, message) {
139
+ super(message);
140
+ this.field = field;
141
+ this.regex = regex;
229
142
  }
230
143
  }
231
144
  /**
@@ -238,7 +151,7 @@ export class GenerationError extends Error {
238
151
  * @param code - the eight letter code a user got from `https://my.remarkable.com/device/browser/connect`.
239
152
  * @returns the device token necessary for creating an api instace. These never expire so persist as long as necessary.
240
153
  */
241
- 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, } = {}) {
242
155
  if (code.length !== 8) {
243
156
  throw new Error(`code should be length 8, but was ${code.length}`);
244
157
  }
@@ -260,71 +173,433 @@ export async function register(code, { deviceDesc = "browser-chrome", uuid = uui
260
173
  return await resp.text();
261
174
  }
262
175
  }
263
- /** format an entry */
264
- export function formatEntry({ hash, type, documentId, subfiles, size, }) {
265
- return `${hash}:${type}:${documentId}:${subfiles}:${size}\n`;
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("");
266
306
  }
267
- /** parse an entry */
268
- export function parseEntry(line) {
269
- const [hash, type, documentId, subfiles, size] = line.split(":");
270
- if (hash === undefined ||
271
- type === undefined ||
272
- documentId === undefined ||
273
- subfiles === undefined ||
274
- size === undefined) {
275
- throw new Error(`entries line didn't contain five fields: '${line}'`);
276
- }
277
- if (type === "80000000") {
278
- return {
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,
279
497
  hash,
280
- type,
281
- documentId,
282
- subfiles: parseInt(subfiles),
283
- size: BigInt(size),
498
+ type: 0,
499
+ subfiles: 0,
500
+ size: bytes.length,
284
501
  };
502
+ return [res, this.#putFile(hash, id, bytes)];
285
503
  }
286
- else if (type === "0") {
287
- if (subfiles !== "0") {
288
- throw new Error(`file type entry had nonzero number of subfiles: ${subfiles}`);
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'`);
289
519
  }
290
520
  else {
291
- return {
292
- hash,
293
- type,
294
- documentId,
295
- subfiles: 0,
296
- size: BigInt(size),
297
- };
521
+ return await this.putText(id, JSON.stringify(content));
298
522
  }
299
523
  }
300
- else {
301
- throw new Error(`entries line contained invalid type: ${type}`);
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();
302
567
  }
303
568
  }
304
569
  /** the implementation of that api */
305
570
  class Remarkable {
306
571
  #userToken;
307
- #fetch;
308
- #subtle;
309
572
  #syncHost;
310
- // caches
311
- #cacheLimitBytes;
312
- #cache = new Map();
313
- #rootCache = null;
314
- constructor(userToken, fetch, subtle, syncHost, cacheLimitBytes, initCache) {
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) {
315
578
  this.#userToken = userToken;
316
- this.#fetch = fetch;
317
- this.#subtle = subtle;
318
579
  this.#syncHost = syncHost;
319
- this.#cacheLimitBytes = cacheLimitBytes;
320
- // set cache
321
- for (const [hash, val] of initCache) {
322
- this.#cache.set(hash, Promise.resolve(val));
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;
323
599
  }
324
600
  }
325
- /** make an authorized request to remarkable */
326
601
  async #authedFetch(url, { body, method = "POST", headers = {}, }) {
327
- const resp = await this.#fetch(url, {
602
+ const resp = await fetch(url, {
328
603
  method,
329
604
  headers: {
330
605
  Authorization: `Bearer ${this.#userToken}`,
@@ -334,503 +609,309 @@ class Remarkable {
334
609
  });
335
610
  if (!resp.ok) {
336
611
  const msg = await resp.text();
337
- 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
+ }
338
618
  }
339
619
  else {
340
620
  return resp;
341
621
  }
342
622
  }
343
- /** make a signed request to the cloud */
344
- async #signedFetch({ url, method, maxuploadsize_bytes }, body, add_headers = {}) {
345
- const headers = maxuploadsize_bytes
346
- ? {
347
- ...add_headers,
348
- [CONTENT_LENGTH_RANGE_HEADER]: `0,${maxuploadsize_bytes}`,
349
- }
350
- : add_headers;
351
- const resp = await this.#fetch(url, {
352
- method,
623
+ /** a generic request to the new files api
624
+ *
625
+ * @param meta - remarkable metadata to set, often json formatted or empty
626
+ * @param method - the http method to use
627
+ * @param contentType - the http content type to set
628
+ * @param body - body content, often raw bytes or json
629
+ * @param hash - the hash of a specific file to target
630
+ */
631
+ async #fileRequest({ meta = "", method = "GET",
632
+ // eslint-disable-next-line spellcheck/spell-checker
633
+ contentType = "text/plain;charset=UTF-8", body, hash, } = {}) {
634
+ const encoder = new TextEncoder();
635
+ const encMeta = encoder.encode(meta);
636
+ const suffix = hash === undefined ? "" : `/${hash}`;
637
+ const resp = await this.#authedFetch(`${this.#syncHost}/doc/v2/files${suffix}`, {
353
638
  body,
354
- headers,
639
+ method,
640
+ headers: {
641
+ "content-type": contentType,
642
+ "rm-meta": fromByteArray(encMeta),
643
+ "rm-source": "WebLibrary",
644
+ },
355
645
  });
356
- if (!resp.ok) {
357
- const msg = await resp.text();
358
- throw new ResponseError(resp.status, resp.statusText, msg);
359
- }
360
- else {
361
- return resp;
362
- }
363
- }
364
- /** get the details for how to make a signed request to remarkable cloud */
365
- async #getUrl(relativePath, gen, rootHash) {
366
- const key = gen === undefined ? "downloads" : "uploads";
367
- // NOTE this is done manually to serialize the bigints appropriately
368
- const body = rootHash && gen !== null && gen !== undefined
369
- ? `{ "http_method": "PUT", "relative_path": "${relativePath}", "root_schema": "${rootHash}", "generation": ${gen} }`
370
- : JSON.stringify({ http_method: "GET", relative_path: relativePath });
371
- const resp = await this.#authedFetch(`${this.#syncHost}/sync/v2/signed-urls/${key}`, { body });
372
646
  const raw = await resp.text();
373
- const res = JSON.parse(raw);
374
- validate(urlResponseSchema, res);
647
+ return JSON.parse(raw);
648
+ }
649
+ /** list all items */
650
+ async listItems() {
651
+ const res = await this.#fileRequest();
652
+ if (!entries.guardAssert(res))
653
+ throw Error("invalid entries");
375
654
  return res;
376
655
  }
377
- /**
378
- * get the root hash and the current generation
379
- */
380
- async getRootHash({ cache = true } = {}) {
381
- if (cache) {
382
- while (this.#rootCache) {
383
- try {
384
- const [hash, gen] = await this.#rootCache;
385
- return [hash, gen];
386
- }
387
- catch {
388
- // noop
389
- }
390
- }
391
- }
392
- const prom = (async () => {
393
- try {
394
- const signed = await this.#getUrl("root");
395
- const resp = await this.#signedFetch(signed);
396
- const generation = resp.headers.get(GENERATION_HEADER);
397
- if (!generation) {
398
- throw new Error("no generation header in root hash");
399
- }
400
- else {
401
- return [await resp.text(), BigInt(generation)];
402
- }
403
- }
404
- catch (ex) {
405
- this.#rootCache = null;
406
- throw ex;
407
- }
408
- })();
409
- this.#rootCache = prom;
410
- const [hash, gen] = await prom;
411
- return [hash, gen];
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 }));
412
660
  }
413
- /**
414
- * write the root hash, incrementing from the current generation
415
- */
416
- async putRootHash(hash, generation) {
417
- const signed = await this.#getUrl("root", generation, hash);
418
- let resp;
419
- try {
420
- resp = await this.#signedFetch(signed, hash, {
421
- [GENERATION_RACE_HEADER]: `${generation}`,
422
- });
423
- }
424
- catch (ex) {
425
- if (ex instanceof ResponseError && ex.status === 412) {
426
- this.#rootCache = null;
427
- throw new GenerationError();
428
- }
429
- else {
430
- throw ex;
431
- }
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}`);
432
666
  }
433
- const genStr = resp.headers.get(GENERATION_HEADER);
434
- if (!genStr) {
435
- throw new Error("no generation header in root hash");
667
+ else {
668
+ return await this.raw.getContent(cont.hash);
436
669
  }
437
- const gen = BigInt(genStr);
438
- this.#rootCache = Promise.resolve([hash, gen]);
439
- return gen;
440
670
  }
441
- /**
442
- * get content associated with hash
443
- */
444
- async #getHash(hash) {
445
- let cached = this.#cache.get(hash);
446
- while (cached) {
447
- try {
448
- const val = await cached;
449
- if (val) {
450
- return val;
451
- }
452
- else {
453
- cached = undefined; // break
454
- }
455
- }
456
- catch {
457
- // try again if promise rejected
458
- cached = this.#cache.get(hash);
459
- }
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);
460
679
  }
461
- const prom = (async () => {
462
- const signed = await this.#getUrl(hash);
463
- const resp = await this.#signedFetch(signed);
464
- return await resp.arrayBuffer();
465
- })();
466
- // set cache with appropriate promise that cleans up on rejection
467
- this.#cache.set(hash, prom.then((buff) => (buff.byteLength < this.#cacheLimitBytes ? buff : null), (ex) => {
468
- this.#cache.delete(hash);
469
- throw ex;
470
- }));
471
- return await prom;
472
- }
473
- /**
474
- * get text content associated with hash
475
- */
476
- async getBuffer(hash) {
477
- const buff = await this.#getHash(hash);
478
- // copy if it is long enough to be cached to preven mutations
479
- return buff.byteLength < this.#cacheLimitBytes ? buff.slice(0) : buff;
480
- }
481
- /**
482
- * get text content associated with hash
483
- */
484
- async getText(hash) {
485
- const buff = await this.#getHash(hash);
486
- const decoder = new TextDecoder();
487
- return decoder.decode(buff);
488
- }
489
- /**
490
- * get json content associated with hash
491
- */
492
- async getJson(hash) {
493
- const str = await this.getText(hash);
494
- return JSON.parse(str);
495
680
  }
496
- /**
497
- * get metadata from hash
498
- *
499
- * Call with `verify: false` to disable checking the response.
500
- */
501
- async getMetadata(hash, { verify = true } = {}) {
502
- const raw = await this.getJson(hash);
503
- validate(metadataSchema, raw, verify);
504
- return raw;
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
+ }
505
690
  }
506
- /**
507
- * get entries from a collection hash
508
- */
509
- async getEntries(hash) {
510
- if (hash === undefined) {
511
- const [newHash] = await this.getRootHash({ cache: true });
512
- hash = newHash;
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}`);
513
696
  }
514
- const raw = await this.getText(hash);
515
- // slice for trailing new line
516
- const [schema, ...lines] = raw.slice(0, -1).split("\n");
517
- if (schema !== SCHEMA_VERSION) {
518
- throw new Error(`got unexpected schema version: ${schema}`);
697
+ else {
698
+ return await this.raw.getHash(epub.hash);
519
699
  }
520
- return lines.map(parseEntry);
521
700
  }
522
- /** upload data to hash */
523
- async #putHash(hash, body) {
524
- // try cached version
525
- const cached = this.#cache.get(hash);
526
- if (cached) {
527
- try {
528
- await cached;
529
- return;
530
- }
531
- catch {
532
- // noop
533
- }
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));
534
707
  }
535
- // if missing or rejected then put for real
536
- const prom = (async () => {
537
- try {
538
- const signed = await this.#getUrl(hash, null);
539
- await this.#signedFetch(signed, body);
540
- return body.byteLength < this.#cacheLimitBytes ? body : null;
541
- }
542
- catch (ex) {
543
- this.#cache.delete(hash);
544
- throw ex;
545
- }
546
- })();
547
- this.#cache.set(hash, prom);
548
- await prom;
708
+ return zip.generateAsync({ type: "uint8array" });
549
709
  }
550
- /** put a reference to a set of entries into the cloud */
551
- async putEntries(documentId, entries) {
552
- // hash of a collection is the hash of all hashes in documentId order
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();
553
716
  const enc = new TextEncoder();
554
- entries.sort((a, b) => a.documentId.localeCompare(b.documentId));
555
- const hashes = concatBuffers(entries.map((ent) => fromHex(ent.hash)));
556
- const digest = await this.#subtle.digest("SHA-256", hashes);
557
- const hash = toHex(new Uint8Array(digest));
558
- const entryContents = entries.map(formatEntry).join("");
559
- const contents = `${SCHEMA_VERSION}\n${entryContents}`;
560
- const buffer = enc.encode(contents).buffer;
561
- await this.#putHash(hash, buffer);
562
- return {
563
- hash,
564
- type: "80000000",
565
- documentId,
566
- subfiles: entries.length,
567
- size: 0n,
568
- };
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);
569
788
  }
570
- /** put a raw buffer in the cloud */
571
- async putBuffer(documentId, buffer) {
572
- const digest = await this.#subtle.digest("SHA-256", buffer);
573
- const hash = toHex(new Uint8Array(digest));
574
- await this.#putHash(hash, buffer);
575
- return {
576
- hash,
577
- type: "0",
578
- documentId,
579
- subfiles: 0,
580
- size: BigInt(buffer.byteLength),
581
- };
789
+ /** upload a file */
790
+ async #uploadFile(parent, visibleName, buffer, contentType) {
791
+ if (!idReg.test(parent)) {
792
+ throw new ValidationError(parent, idReg, "parent must be a valid document id");
793
+ }
794
+ const res = await this.#fileRequest({
795
+ meta: JSON.stringify({ parent, file_name: visibleName }),
796
+ method: "POST",
797
+ contentType,
798
+ body: buffer,
799
+ });
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 };
582
805
  }
583
- /** put text in the cloud */
584
- async putText(documentId, contents) {
585
- const enc = new TextEncoder();
586
- const encoded = enc.encode(contents).buffer;
587
- return await this.putBuffer(documentId, encoded);
588
- }
589
- /** put json in the cloud */
590
- async putJson(documentId, contents) {
591
- return await this.putText(documentId, stringify(contents));
592
- }
593
- /** put metadata into the cloud */
594
- async putMetadata(documentId, metadata) {
595
- return await this.putJson(`${documentId}.metadata`, metadata);
596
- }
597
- /** put a new collection (folder) */
598
- async putCollection(visibleName, parent = "") {
599
- const documentId = uuid4();
600
- const lastModified = `${new Date().valueOf()}`;
601
- const entryPromises = [];
602
- // upload metadata
603
- const metadata = {
604
- type: "CollectionType",
605
- visibleName,
606
- version: 0,
607
- parent,
608
- synced: true,
609
- lastModified,
610
- };
611
- entryPromises.push(this.putMetadata(documentId, metadata));
612
- entryPromises.push(this.putText(`${documentId}.content`, "{}"));
613
- const entries = await Promise.all(entryPromises);
614
- return await this.putEntries(documentId, entries);
615
- }
616
- /** upload a content file */
617
- async #putContent(visibleName, buffer, fileType, parent, content) {
618
- /* istanbul ignore if */
619
- if (content.fileType !== fileType) {
620
- throw new Error(`internal error: fileTypes don't match: ${fileType}, ${content.fileType}`);
621
- }
622
- const documentId = uuid4();
623
- const lastModified = `${new Date().valueOf()}`;
624
- const entryPromises = [];
625
- // upload main document
626
- entryPromises.push(this.putBuffer(`${documentId}.${fileType}`, buffer));
627
- // upload metadata
628
- const metadata = {
629
- type: "DocumentType",
630
- visibleName,
631
- version: 0,
632
- parent,
633
- synced: true,
634
- lastModified,
635
- };
636
- entryPromises.push(this.putMetadata(documentId, metadata));
637
- entryPromises.push(this.putText(`${documentId}.content`, JSON.stringify(content)));
638
- // NOTE we technically get the entries a bit earlier, so could upload this
639
- // before all contents are uploaded, but this also saves us from uploading
640
- // the contents entry before all have uploaded successfully
641
- const entries = await Promise.all(entryPromises);
642
- return await this.putEntries(documentId, entries);
806
+ /** create a folder */
807
+ async createFolder(visibleName, { parent = "" } = {}) {
808
+ return await this.#uploadFile(parent, visibleName, new Uint8Array(0), "folder");
643
809
  }
644
810
  /** upload an epub */
645
- async putEpub(visibleName, buffer, { parent = "", margins = 125, orientation, textAlignment, textScale = 1, lineHeight = -1, fontName = "", cover = "visited", lastTool, } = {}) {
646
- // upload content file
647
- const content = {
648
- dummyDocument: false,
649
- extraMetadata: {
650
- LastTool: lastTool,
651
- },
652
- fileType: "epub",
653
- pageCount: 0,
654
- lastOpenedPage: 0,
655
- lineHeight: typeof lineHeight === "string"
656
- ? builtinLineHeights[lineHeight]
657
- : lineHeight,
658
- margins: typeof margins === "string" ? builtinMargins[margins] : margins,
659
- textScale: typeof textScale === "string"
660
- ? builtinTextScales[textScale]
661
- : textScale,
662
- pages: [],
663
- coverPageNumber: cover === "first" ? 0 : -1,
664
- formatVersion: 1,
665
- orientation,
666
- textAlignment,
667
- fontName,
668
- };
669
- return await this.#putContent(visibleName, buffer, "epub", parent, content);
811
+ async uploadEpub(visibleName, buffer, { parent = "" } = {}) {
812
+ return await this.#uploadFile(parent, visibleName, buffer, "application/epub+zip");
670
813
  }
671
814
  /** upload a pdf */
672
- async putPdf(visibleName, buffer, { parent = "", orientation, cover = "first", lastTool } = {}) {
673
- // upload content file
674
- const content = {
675
- dummyDocument: false,
676
- extraMetadata: {
677
- LastTool: lastTool,
678
- },
679
- fileType: "pdf",
680
- pageCount: 0,
681
- lastOpenedPage: 0,
682
- lineHeight: -1,
683
- margins: 125,
684
- textScale: 1,
685
- pages: [],
686
- coverPageNumber: cover === "first" ? 0 : -1,
687
- formatVersion: 1,
688
- orientation,
689
- };
690
- return await this.#putContent(visibleName, buffer, "pdf", parent, content);
815
+ async uploadPdf(visibleName, buffer, { parent = "" } = {}) {
816
+ return await this.#uploadFile(parent, visibleName, buffer, "application/pdf");
691
817
  }
692
- /** send sync complete request */
693
- async syncComplete(generation) {
694
- // NOTE this is done manually to properly serialize the bigint
695
- const body = `{ "generation": ${generation} }`;
696
- await this.#authedFetch(`${this.#syncHost}/sync/v2/sync-complete`, {
697
- body,
818
+ async #modify(hash, properties) {
819
+ if (!hashReg.test(hash)) {
820
+ throw new ValidationError(hash, hashReg, "hash to modify was not a valid hash");
821
+ }
822
+ // this does not allow setting pinned, although I don't know why
823
+ const res = await this.#fileRequest({
824
+ hash,
825
+ body: JSON.stringify(properties),
826
+ method: "PATCH",
698
827
  });
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;
699
832
  }
700
- /** try to sync, always succeed */
701
- async #tryPutRootEntries(gen, entries, sync) {
702
- const { hash } = await this.putEntries("", entries);
703
- const nextGen = await this.putRootHash(hash, gen);
704
- if (sync) {
705
- try {
706
- await this.syncComplete(nextGen);
707
- return true;
708
- }
709
- catch {
710
- return false;
711
- }
712
- }
713
- else {
714
- return false;
833
+ /** move an entry */
834
+ async move(hash, parent) {
835
+ if (!idReg.test(parent)) {
836
+ throw new ValidationError(parent, idReg, "parent must be a valid document id");
715
837
  }
838
+ return await this.#modify(hash, { parent });
716
839
  }
717
- /** high level api to create an entry */
718
- async create(entry, { cache = true, sync = true } = {}) {
719
- const [root, gen] = await this.getRootHash({ cache });
720
- const rootEntries = await this.getEntries(root);
721
- rootEntries.push(entry);
722
- return await this.#tryPutRootEntries(gen, rootEntries, sync);
840
+ /** delete an entry */
841
+ async delete(hash) {
842
+ return await this.move(hash, "trash");
723
843
  }
724
- /** high level api to move a document / collection */
725
- async move(documentId, dest, { cache = true, sync = true } = {}) {
726
- const [root, gen] = await this.getRootHash({ cache });
727
- const rootEntries = await this.getEntries(root);
728
- // check if destination is a collection
729
- if (!dest || dest === "trash") {
730
- // fine
731
- }
732
- else {
733
- // TODO some of these could be done in parallel
734
- const entry = rootEntries.find((ent) => ent.documentId === dest);
735
- if (!entry) {
736
- throw new Error(`destination id not found: ${dest}`);
737
- }
738
- else if (entry.type !== "80000000") {
739
- throw new Error(`destination id was a raw file: ${dest}`);
740
- }
741
- const ents = await this.getEntries(entry.hash);
742
- const [meta] = ents.filter((ent) => ent.documentId === `${dest}.metadata`);
743
- if (!meta) {
744
- throw new Error(`destination id didn't have metadata: ${dest}`);
745
- }
746
- const metadata = await this.getMetadata(meta.hash);
747
- if (metadata.type !== "CollectionType") {
748
- throw new Error(`destination id wasn't a collection: ${dest}`);
749
- }
750
- }
751
- // get entry to move from root
752
- const ind = rootEntries.findIndex((ent) => ent.documentId === documentId);
753
- if (ind === -1) {
754
- throw new Error(`document not found: ${documentId}`);
755
- }
756
- const [oldEntry] = rootEntries.splice(ind, 1);
757
- if (oldEntry.type !== "80000000") {
758
- throw new Error(`document was a raw file: ${documentId}`);
759
- }
760
- // get metadata from entry
761
- const docEnts = await this.getEntries(oldEntry.hash);
762
- const metaInd = docEnts.findIndex((ent) => ent.documentId === `${documentId}.metadata`);
763
- if (metaInd === -1) {
764
- throw new Error(`document didn't have metadata: ${documentId}`);
765
- }
766
- const [metaEnt] = docEnts.splice(metaInd, 1);
767
- const metadata = await this.getMetadata(metaEnt.hash);
768
- // update metadata
769
- metadata.parent = dest;
770
- const newMetaEnt = await this.putMetadata(documentId, metadata);
771
- docEnts.push(newMetaEnt);
772
- // update root entries
773
- const newEntry = await this.putEntries(documentId, docEnts);
774
- rootEntries.push(newEntry);
775
- return await this.#tryPutRootEntries(gen, rootEntries, sync);
776
- }
777
- /** get entries and metadata for all files */
778
- async getEntriesMetadata({ verify = true } = {}) {
779
- const resp = await this.#authedFetch(`${this.#syncHost}/doc/v2/files`, {
780
- method: "GET",
781
- headers: {
782
- "rm-source": "RoR-Browser",
783
- },
784
- });
785
- const raw = await resp.text();
786
- const res = JSON.parse(raw);
787
- const schema = {
788
- elements: metadataEntrySchema,
789
- };
790
- validate(schema, res, verify);
791
- return res;
844
+ /** rename an entry */
845
+ async rename(hash, visibleName) {
846
+ return await this.#modify(hash, { file_name: visibleName });
792
847
  }
793
- /** upload a file */
794
- async #uploadFile(visibleName, buffer, contentType, verify) {
795
- const encoder = new TextEncoder();
796
- const meta = encoder.encode(JSON.stringify({ file_name: visibleName }));
797
- const resp = await this.#authedFetch(`${this.#syncHost}/doc/v2/files`, {
798
- body: buffer,
799
- headers: {
800
- "content-type": contentType,
801
- "rm-meta": fromByteArray(meta),
802
- "rm-source": "RoR-Browser",
803
- },
848
+ /** bulk modify hashes */
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");
853
+ }
854
+ // this does not allow setting pinned, although I don't know why
855
+ const res = await this.#fileRequest({
856
+ body: JSON.stringify({
857
+ updates: properties,
858
+ hashes,
859
+ }),
860
+ method: "PATCH",
804
861
  });
805
- const raw = await resp.text();
806
- const res = JSON.parse(raw);
807
- validate(uploadEntrySchema, res, 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");
808
865
  return res;
809
866
  }
810
- /** upload an epub */
811
- async uploadEpub(visibleName, buffer, { verify = true } = {}) {
812
- return await this.#uploadFile(visibleName, buffer, "application/epub+zip", verify);
813
- }
814
- /** upload a pdf */
815
- async uploadPdf(visibleName, buffer, { verify = true } = {}) {
816
- // TODO why doesn't this work
817
- return await this.#uploadFile(visibleName, buffer, "application/pdf", verify);
818
- }
819
- async getCache() {
820
- const promises = [];
821
- for (const [hash, prom] of this.#cache) {
822
- promises.push(prom.then((val) => [hash, val], () => [hash, null]));
823
- }
824
- const entries = await Promise.all(promises);
825
- const cache = new Map();
826
- for (const [hash, val] of entries) {
827
- if (val) {
828
- cache.set(hash, val);
867
+ /** move many hashes */
868
+ async bulkMove(hashes, parent) {
869
+ if (!idReg.test(parent)) {
870
+ throw new ValidationError(parent, idReg, "parent must be a valid document id");
871
+ }
872
+ return await this.#bulkModify(hashes, { parent });
873
+ }
874
+ /** delete many hashes */
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
+ }
829
901
  }
902
+ entries = await Promise.all(nextEntries);
903
+ nextEntries = [];
904
+ }
905
+ for (const key of toDelete) {
906
+ this.#cache.delete(key);
830
907
  }
831
- return cache;
908
+ }
909
+ // finally remove any values we had in the cache initially, but couldn't reach
910
+ clearCache() {
911
+ this.raw.clearCache();
832
912
  }
833
913
  }
914
+ const cached = values(nullable(string()));
834
915
  /**
835
916
  * create an instance of the api
836
917
  *
@@ -841,10 +922,7 @@ class Remarkable {
841
922
  * registered. Create one with {@link register}.
842
923
  * @returns an api instance
843
924
  */
844
- export async function remarkable(deviceToken, { fetch = globalThis.fetch, subtle = globalThis.crypto?.subtle, authHost = AUTH_HOST, syncHost = SYNC_HOST, cacheLimitBytes = 1048576, initCache = [], } = {}) {
845
- if (!subtle) {
846
- throw new Error("subtle was missing, try specifying it manually");
847
- }
925
+ export async function remarkable(deviceToken, { authHost = AUTH_HOST, syncHost = SYNC_HOST, rawHost = RAW_HOST, cache, maxCacheSize = Infinity, } = {}) {
848
926
  const resp = await fetch(`${authHost}/token/json/2/user/new`, {
849
927
  method: "POST",
850
928
  headers: {
@@ -855,5 +933,15 @@ export async function remarkable(deviceToken, { fetch = globalThis.fetch, subtle
855
933
  throw new Error(`couldn't fetch auth token: ${resp.statusText}`);
856
934
  }
857
935
  const userToken = await resp.text();
858
- return new Remarkable(userToken, fetch, subtle, syncHost, cacheLimitBytes, initCache);
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
+ }
859
947
  }