rmapi-js 5.0.0 → 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,173 +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
- /* 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
- };
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
+ }
144
62
  const commonProperties = {
145
- visibleName: { type: "string" },
63
+ id: string(),
64
+ hash: string(),
65
+ visibleName: string(),
66
+ lastModified: string(),
67
+ pinned: boolean(),
146
68
  };
147
69
  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
- },
70
+ parent: string(),
71
+ tags: elements(properties({
72
+ name: string(),
73
+ timestamp: float64(),
74
+ })),
207
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
+ });
208
93
  /** an error that results from a failed request */
209
94
  export class ResponseError extends Error {
210
95
  /** the response status number */
@@ -217,15 +102,16 @@ export class ResponseError extends Error {
217
102
  this.statusText = statusText;
218
103
  }
219
104
  }
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.");
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;
229
115
  }
230
116
  }
231
117
  /**
@@ -260,67 +146,15 @@ export async function register(code, { deviceDesc = "browser-chrome", uuid = uui
260
146
  return await resp.text();
261
147
  }
262
148
  }
263
- /** format an entry */
264
- export function formatEntry({ hash, type, documentId, subfiles, size, }) {
265
- return `${hash}:${type}:${documentId}:${subfiles}:${size}\n`;
266
- }
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 {
279
- hash,
280
- type,
281
- documentId,
282
- subfiles: parseInt(subfiles),
283
- size: BigInt(size),
284
- };
285
- }
286
- else if (type === "0") {
287
- if (subfiles !== "0") {
288
- throw new Error(`file type entry had nonzero number of subfiles: ${subfiles}`);
289
- }
290
- else {
291
- return {
292
- hash,
293
- type,
294
- documentId,
295
- subfiles: 0,
296
- size: BigInt(size),
297
- };
298
- }
299
- }
300
- else {
301
- throw new Error(`entries line contained invalid type: ${type}`);
302
- }
303
- }
304
149
  /** the implementation of that api */
305
150
  class Remarkable {
306
151
  #userToken;
307
152
  #fetch;
308
- #subtle;
309
153
  #syncHost;
310
- // caches
311
- #cacheLimitBytes;
312
- #cache = new Map();
313
- #rootCache = null;
314
- constructor(userToken, fetch, subtle, syncHost, cacheLimitBytes, initCache) {
154
+ constructor(userToken, fetch, syncHost) {
315
155
  this.#userToken = userToken;
316
156
  this.#fetch = fetch;
317
- this.#subtle = subtle;
318
157
  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));
323
- }
324
158
  }
325
159
  /** make an authorized request to remarkable */
326
160
  async #authedFetch(url, { body, method = "POST", headers = {}, }) {
@@ -340,495 +174,117 @@ class Remarkable {
340
174
  return resp;
341
175
  }
342
176
  }
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,
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}`, {
353
192
  body,
354
- headers,
193
+ method,
194
+ headers: {
195
+ "content-type": contentType,
196
+ "rm-meta": fromByteArray(encMeta),
197
+ "rm-source": "WebLibrary",
198
+ },
355
199
  });
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
200
  const raw = await resp.text();
373
- const res = JSON.parse(raw);
374
- validate(urlResponseSchema, res);
375
- return res;
376
- }
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];
412
- }
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
- }
432
- }
433
- const genStr = resp.headers.get(GENERATION_HEADER);
434
- if (!genStr) {
435
- throw new Error("no generation header in root hash");
436
- }
437
- const gen = BigInt(genStr);
438
- this.#rootCache = Promise.resolve([hash, gen]);
439
- return gen;
440
- }
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
- }
460
- }
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;
201
+ return JSON.parse(raw);
480
202
  }
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);
203
+ /** list all files */
204
+ async listFiles({ verify = true } = {}) {
205
+ const res = await this.#fileRequest();
206
+ return verification(res, entries, verify);
488
207
  }
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
- }
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;
505
- }
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;
513
- }
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}`);
519
- }
520
- return lines.map(parseEntry);
521
- }
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
- }
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");
534
212
  }
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;
549
- }
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
553
- 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
- };
569
- }
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
- };
582
- }
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);
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);
596
220
  }
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);
221
+ /** create a folder */
222
+ async createFolder(visibleName, { parent = "", verify = true } = {}) {
223
+ return await this.#uploadFile(parent, visibleName, new ArrayBuffer(0), "folder", verify);
643
224
  }
644
225
  /** 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);
226
+ async uploadEpub(visibleName, buffer, { parent = "", verify = true } = {}) {
227
+ return await this.#uploadFile(parent, visibleName, buffer, "application/epub+zip", verify);
670
228
  }
671
229
  /** 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);
691
- }
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,
698
- });
230
+ async uploadPdf(visibleName, buffer, { parent = "", verify = true } = {}) {
231
+ return await this.#uploadFile(parent, visibleName, buffer, "application/pdf", verify);
699
232
  }
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
- }
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");
712
236
  }
713
- else {
714
- return false;
715
- }
716
- }
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);
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);
723
244
  }
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
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");
731
249
  }
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}`);
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");
745
266
  }
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
267
  }
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
- },
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",
784
275
  });
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;
276
+ return verification(res, hashesEntry, verify);
792
277
  }
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
- },
804
- });
805
- const raw = await resp.text();
806
- const res = JSON.parse(raw);
807
- validate(uploadEntrySchema, res, verify);
808
- return res;
809
- }
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]));
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");
823
282
  }
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);
829
- }
830
- }
831
- 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);
832
288
  }
833
289
  }
834
290
  /**
@@ -841,10 +297,7 @@ class Remarkable {
841
297
  * registered. Create one with {@link register}.
842
298
  * @returns an api instance
843
299
  */
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
- }
300
+ export async function remarkable(deviceToken, { fetch = globalThis.fetch, authHost = AUTH_HOST, syncHost = SYNC_HOST, } = {}) {
848
301
  const resp = await fetch(`${authHost}/token/json/2/user/new`, {
849
302
  method: "POST",
850
303
  headers: {
@@ -855,5 +308,5 @@ export async function remarkable(deviceToken, { fetch = globalThis.fetch, subtle
855
308
  throw new Error(`couldn't fetch auth token: ${resp.statusText}`);
856
309
  }
857
310
  const userToken = await resp.text();
858
- return new Remarkable(userToken, fetch, subtle, syncHost, cacheLimitBytes, initCache);
311
+ return new Remarkable(userToken, fetch, syncHost);
859
312
  }