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