rmapi-js 11.1.2 → 12.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/dist/index.d.ts +154 -91
- package/dist/index.js +756 -178
- package/dist/raw.d.ts +97 -65
- package/dist/raw.js +276 -23
- package/dist/rm5.d.ts +111 -0
- package/dist/rm5.js +181 -0
- package/dist/rm6.d.ts +334 -0
- package/dist/rm6.js +728 -0
- package/dist/rmapi-js.esm.min.js +8 -8
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* // list all items (documents and collections)
|
|
20
20
|
* const [first, ...rest] = await api.listItems();
|
|
21
21
|
* // rename first item
|
|
22
|
-
* const entry = await api.rename(first
|
|
22
|
+
* const entry = await api.rename(first, "new name");
|
|
23
23
|
* ```
|
|
24
24
|
*
|
|
25
25
|
* @example
|
|
@@ -56,12 +56,77 @@ import { v4 as uuid4 } from "uuid";
|
|
|
56
56
|
import { z } from "zod";
|
|
57
57
|
import { HashNotFoundError, ValidationError } from "./error.js";
|
|
58
58
|
import { LruCache } from "./lru.js";
|
|
59
|
-
import { RawRemarkable, } from "./raw.js";
|
|
59
|
+
import { parseMetadata, RawRemarkable, } from "./raw.js";
|
|
60
60
|
export { deviceScreens, } from "./devices.js";
|
|
61
61
|
export { HashNotFoundError, ValidationError } from "./error.js";
|
|
62
|
+
export { decodeBrush, rmColors } from "./rm5.js";
|
|
63
|
+
export { crdtKey, END_MARKER, parseRmScene, ROOT_ID } from "./rm6.js";
|
|
62
64
|
const AUTH_HOST = "https://webapp-prod.cloud.remarkable.engineering";
|
|
63
65
|
const RAW_HOST = "https://eu.tectonic.remarkable.com";
|
|
64
66
|
const UPLOAD_HOST = "https://internal.cloud.remarkable.com";
|
|
67
|
+
/** the parent id of the root directory */
|
|
68
|
+
const ROOT_ID = "";
|
|
69
|
+
/** the parent id of the trash */
|
|
70
|
+
const TRASH_ID = "trash";
|
|
71
|
+
/** the id of the root entry list */
|
|
72
|
+
const ROOT_LIST = "root";
|
|
73
|
+
/** the file name of the root entry index */
|
|
74
|
+
const ROOT_SCHEMA = `${ROOT_LIST}.docSchema`;
|
|
75
|
+
/** base backoff in milliseconds for transient request retries */
|
|
76
|
+
const TRANSIENT_BASE_MS = 200;
|
|
77
|
+
/** base backoff in milliseconds for generation-conflict retries */
|
|
78
|
+
const GENERATION_BASE_MS = 25;
|
|
79
|
+
/** resolve after a number of milliseconds */
|
|
80
|
+
function sleep(ms) {
|
|
81
|
+
return new Promise((resolve) => {
|
|
82
|
+
const timer = setTimeout(resolve, ms);
|
|
83
|
+
// don't let a pending backoff keep a node process alive
|
|
84
|
+
timer.unref?.();
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
/** exponential backoff with full jitter, capped at 30 seconds */
|
|
88
|
+
function backoffMs(attempt, baseMs) {
|
|
89
|
+
const capped = Math.min(baseMs * 2 ** attempt, 30_000);
|
|
90
|
+
return Math.random() * capped;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* a mutex acquired by async iteration
|
|
94
|
+
*
|
|
95
|
+
* `for await (const _lock of mutex) { ... }` runs the body with the lock held
|
|
96
|
+
* and releases it when the body exits — by return, throw, or break — the way a
|
|
97
|
+
* `using` block would. Waiters are served in acquisition order.
|
|
98
|
+
*/
|
|
99
|
+
class Mutex {
|
|
100
|
+
#tail = Promise.resolve();
|
|
101
|
+
async *[Symbol.asyncIterator]() {
|
|
102
|
+
const previous = this.#tail;
|
|
103
|
+
let release;
|
|
104
|
+
this.#tail = new Promise((resolve) => {
|
|
105
|
+
release = resolve;
|
|
106
|
+
});
|
|
107
|
+
await previous;
|
|
108
|
+
try {
|
|
109
|
+
yield;
|
|
110
|
+
}
|
|
111
|
+
finally {
|
|
112
|
+
release();
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/** the ordered page ids of a document's content, from cPages or the legacy list */
|
|
117
|
+
function pageOrder(content) {
|
|
118
|
+
if ("cPages" in content && content.cPages) {
|
|
119
|
+
return content.cPages.pages
|
|
120
|
+
.filter((page) => page.deleted === undefined)
|
|
121
|
+
.map((page) => page.id);
|
|
122
|
+
}
|
|
123
|
+
else if ("pages" in content && content.pages) {
|
|
124
|
+
return content.pages;
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
return [];
|
|
128
|
+
}
|
|
129
|
+
}
|
|
65
130
|
// The section has all the types that are stored in the remarkable cloud.
|
|
66
131
|
const idReg = /^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}||trash)$/;
|
|
67
132
|
/** An error that gets thrown when the backend while trying to update
|
|
@@ -122,24 +187,51 @@ export async function register(code, { deviceDesc = "browser-chrome", uuid = uui
|
|
|
122
187
|
return await resp.text();
|
|
123
188
|
}
|
|
124
189
|
}
|
|
125
|
-
/**
|
|
190
|
+
/**
|
|
191
|
+
* the api for accessing remarkable functions
|
|
192
|
+
*
|
|
193
|
+
* There are roughly two types of functions.
|
|
194
|
+
* - high-level api functions that provide simple access with a single round
|
|
195
|
+
* trip based on the web api
|
|
196
|
+
* - low-level wrapped functions that take more round trips, but provide more
|
|
197
|
+
* control and may be faster since they can be cached.
|
|
198
|
+
*
|
|
199
|
+
* Most of these functions validate the return values so that typescript is
|
|
200
|
+
* accurate. However, sometimes those return values are more strict than the
|
|
201
|
+
* "true" underlying types. If this happens, please [submit a an
|
|
202
|
+
* issue](https://github.com/erikbrinkman/rmapi-js/issues). In the mean time,
|
|
203
|
+
* you should be able to use the low level api to work around any restrictive
|
|
204
|
+
* validation.
|
|
205
|
+
*/
|
|
126
206
|
class Remarkable {
|
|
127
207
|
#sessionToken;
|
|
128
208
|
/** the same cache that underlies the raw api, allowing us to modify it */
|
|
129
209
|
#cache;
|
|
210
|
+
/** scoped access to the raw low-level api */
|
|
130
211
|
raw;
|
|
212
|
+
#maxGenerationRetries;
|
|
213
|
+
#maxTransientRetries;
|
|
131
214
|
#lastHashGen;
|
|
132
215
|
#schemaVersion;
|
|
133
|
-
|
|
216
|
+
/** serializes root updates on this instance so they don't self-conflict */
|
|
217
|
+
#rootMutex = new Mutex();
|
|
218
|
+
constructor(sessionToken, rawHost, uploadHost, cache, maxGenerationRetries, maxTransientRetries) {
|
|
134
219
|
this.#sessionToken = sessionToken;
|
|
135
220
|
this.#cache = cache;
|
|
221
|
+
this.#maxGenerationRetries = maxGenerationRetries;
|
|
222
|
+
this.#maxTransientRetries = maxTransientRetries;
|
|
136
223
|
this.raw = new RawRemarkable((method, url, { body, headers } = {}) => this.#authedFetch(url, { method, body, headers }), cache, rawHost, uploadHost);
|
|
137
224
|
}
|
|
138
225
|
async #getRootHash(refresh = false) {
|
|
139
226
|
if (refresh || this.#lastHashGen === undefined) {
|
|
140
227
|
const [hash, generation, schemaVersion] = await this.raw.getRootHash();
|
|
141
|
-
|
|
142
|
-
|
|
228
|
+
// a slow older fetch can resolve after a newer write; only accept it if
|
|
229
|
+
// it doesn't regress the cached generation past a committed root
|
|
230
|
+
if (this.#lastHashGen === undefined ||
|
|
231
|
+
generation >= this.#lastHashGen[1]) {
|
|
232
|
+
this.#lastHashGen = [hash, generation];
|
|
233
|
+
this.#schemaVersion = schemaVersion;
|
|
234
|
+
}
|
|
143
235
|
}
|
|
144
236
|
return [...this.#lastHashGen, this.#schemaVersion];
|
|
145
237
|
}
|
|
@@ -156,42 +248,119 @@ class Remarkable {
|
|
|
156
248
|
throw ex;
|
|
157
249
|
}
|
|
158
250
|
}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
251
|
+
/**
|
|
252
|
+
* run a root-mutating operation, retrying on generation conflicts
|
|
253
|
+
*
|
|
254
|
+
* On a {@link GenerationError | `GenerationError`} the cached generation was
|
|
255
|
+
* already invalidated by {@link #putRootHash}, so re-running `op` re-reads the
|
|
256
|
+
* latest root and re-applies the change. Callers must resolve any random ids
|
|
257
|
+
* before this so retries reuse the same (cached) blobs.
|
|
258
|
+
*/
|
|
259
|
+
async #withRetry(op) {
|
|
260
|
+
// hold the root lock across the whole read-merge-write so concurrent
|
|
261
|
+
// mutators serialize instead of sharing a generation and forcing each
|
|
262
|
+
// other into avoidable conflicts
|
|
263
|
+
for await (const _lock of this.#rootMutex) {
|
|
264
|
+
for (let attempt = 0;; attempt++) {
|
|
265
|
+
try {
|
|
266
|
+
return await op();
|
|
267
|
+
}
|
|
268
|
+
catch (ex) {
|
|
269
|
+
if (ex instanceof GenerationError &&
|
|
270
|
+
attempt < this.#maxGenerationRetries) {
|
|
271
|
+
await sleep(backoffMs(attempt, GENERATION_BASE_MS));
|
|
272
|
+
}
|
|
273
|
+
else {
|
|
274
|
+
throw ex;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
// the mutex yields exactly once, so the loop always returns or throws
|
|
280
|
+
throw new Error("unreachable");
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* splice an already-uploaded item entry into the root
|
|
284
|
+
*
|
|
285
|
+
* The entry and all its blobs must already be uploaded; only the
|
|
286
|
+
* generation-dependent root merge is retried, so a conflict re-reads the
|
|
287
|
+
* latest root and re-appends the (stable) entry without re-uploading blobs.
|
|
288
|
+
*/
|
|
289
|
+
async #commit(entry) {
|
|
290
|
+
await this.#withRetry(async () => {
|
|
291
|
+
const [rootHash, generation] = await this.#getRootHash();
|
|
292
|
+
const { entries } = await this.raw.getEntries({
|
|
293
|
+
id: ROOT_SCHEMA,
|
|
294
|
+
hash: rootHash,
|
|
295
|
+
});
|
|
296
|
+
entries.push(entry);
|
|
297
|
+
const [rootEntry, uploadRoot] = await this.raw.putEntries(ROOT_LIST, entries, 4);
|
|
298
|
+
await uploadRoot;
|
|
299
|
+
await this.#putRootHash(rootEntry.hash, generation);
|
|
168
300
|
});
|
|
169
|
-
|
|
301
|
+
}
|
|
302
|
+
async #authedFetch(url, { body, method = "POST", headers = {}, }) {
|
|
303
|
+
// the root PUT is a compare-and-set; retrying a lost-but-applied response
|
|
304
|
+
// would resurface as a false generation conflict and be double-applied by
|
|
305
|
+
// #withRetry, so never transient-retry it (GETs and content-addressed file
|
|
306
|
+
// PUTs are idempotent and safe to retry)
|
|
307
|
+
const transientRetries = method === "PUT" && url.endsWith("/sync/v3/root")
|
|
308
|
+
? 0
|
|
309
|
+
: this.#maxTransientRetries;
|
|
310
|
+
for (let attempt = 0;; attempt++) {
|
|
311
|
+
let resp;
|
|
312
|
+
try {
|
|
313
|
+
resp = await fetch(url, {
|
|
314
|
+
method,
|
|
315
|
+
headers: {
|
|
316
|
+
Authorization: `Bearer ${this.#sessionToken}`,
|
|
317
|
+
...headers,
|
|
318
|
+
},
|
|
319
|
+
// fetch works correctly with uint8 arrays, but is not hinted correctly
|
|
320
|
+
body: body,
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
catch (ex) {
|
|
324
|
+
// a network-level failure, retry if we have attempts left
|
|
325
|
+
if (attempt < transientRetries) {
|
|
326
|
+
await sleep(backoffMs(attempt, TRANSIENT_BASE_MS));
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
throw ex;
|
|
330
|
+
}
|
|
331
|
+
if (resp.ok) {
|
|
332
|
+
return resp;
|
|
333
|
+
}
|
|
170
334
|
const msg = await resp.text();
|
|
171
335
|
if (msg === '{"message":"precondition failed"}\n') {
|
|
336
|
+
// a generation conflict; handled by #withRetry at the high level
|
|
172
337
|
throw new GenerationError();
|
|
173
338
|
}
|
|
339
|
+
else if ((resp.status >= 500 || resp.status === 429) &&
|
|
340
|
+
attempt < transientRetries) {
|
|
341
|
+
await sleep(backoffMs(attempt, TRANSIENT_BASE_MS));
|
|
342
|
+
}
|
|
174
343
|
else {
|
|
175
344
|
throw new ResponseError(resp.status, resp.statusText, `failed reMarkable request: ${msg}`);
|
|
176
345
|
}
|
|
177
346
|
}
|
|
178
|
-
else {
|
|
179
|
-
return resp;
|
|
180
|
-
}
|
|
181
347
|
}
|
|
182
348
|
async #convertEntry({ hash, id }) {
|
|
183
|
-
const { entries } = await this.raw.getEntries(
|
|
349
|
+
const { entries } = await this.raw.getEntries({
|
|
350
|
+
id: `${id}.docSchema`,
|
|
351
|
+
hash,
|
|
352
|
+
});
|
|
184
353
|
const metaEnt = entries.find((ent) => ent.id.endsWith(".metadata"));
|
|
185
354
|
const contentEnt = entries.find((ent) => ent.id.endsWith(".content"));
|
|
186
355
|
if (metaEnt === undefined) {
|
|
187
356
|
throw new Error(`couldn't find metadata for hash ${hash}`);
|
|
188
357
|
}
|
|
189
358
|
const [{ visibleName, lastModified, pinned, parent, lastOpened, createdTime, new: isNew, source, }, content,] = await Promise.all([
|
|
190
|
-
this.raw.getMetadata(metaEnt
|
|
359
|
+
this.raw.getMetadata(metaEnt),
|
|
191
360
|
// collections don't always have content, since content only lists tags
|
|
192
361
|
contentEnt === undefined
|
|
193
362
|
? Promise.resolve({ fileType: undefined, tags: undefined })
|
|
194
|
-
: this.raw.getContent(contentEnt
|
|
363
|
+
: this.raw.getContent(contentEnt),
|
|
195
364
|
]);
|
|
196
365
|
if ("templateVersion" in content) {
|
|
197
366
|
return {
|
|
@@ -234,66 +403,269 @@ class Remarkable {
|
|
|
234
403
|
};
|
|
235
404
|
}
|
|
236
405
|
}
|
|
237
|
-
/**
|
|
406
|
+
/**
|
|
407
|
+
* list all items
|
|
408
|
+
*
|
|
409
|
+
* Items include both collections and documents. Documents that are in folders
|
|
410
|
+
* will have their parent set to something other than "" or "trash", but
|
|
411
|
+
* everything will be returned by this function.
|
|
412
|
+
*
|
|
413
|
+
* @example
|
|
414
|
+
* ```ts
|
|
415
|
+
* await api.listItems();
|
|
416
|
+
* ```
|
|
417
|
+
*
|
|
418
|
+
* @remarks
|
|
419
|
+
* This is now backed by the low level api, and you may notice some
|
|
420
|
+
* performance degradation if not taking advantage of the cache.
|
|
421
|
+
*
|
|
422
|
+
* @param refresh - if true, refresh the root hash before listing
|
|
423
|
+
* @returns a list of all items with some metadata
|
|
424
|
+
*/
|
|
238
425
|
async listItems(refresh = false) {
|
|
239
426
|
const ids = await this.listIds(refresh);
|
|
240
427
|
return await Promise.all(ids.map((id) => this.#convertEntry(id)));
|
|
241
428
|
}
|
|
429
|
+
/**
|
|
430
|
+
* similar to {@link listItems | `listItems`} but backed by the low level api
|
|
431
|
+
*
|
|
432
|
+
* @param refresh - if true, refresh the root hash before listing
|
|
433
|
+
*/
|
|
242
434
|
async listIds(refresh = false) {
|
|
243
435
|
const [hash] = await this.#getRootHash(refresh);
|
|
244
|
-
const { entries } = await this.raw.getEntries(
|
|
436
|
+
const { entries } = await this.raw.getEntries({ id: ROOT_SCHEMA, hash });
|
|
245
437
|
return entries.map(({ id, hash }) => ({ id, hash }));
|
|
246
438
|
}
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
439
|
+
/**
|
|
440
|
+
* get the content metadata for an item
|
|
441
|
+
*
|
|
442
|
+
* @remarks
|
|
443
|
+
* If this fails validation and you still want to get the content, you can use
|
|
444
|
+
* the low-level api to get the raw text of the `.content` file in the
|
|
445
|
+
* `RawEntry` for this hash.
|
|
446
|
+
*
|
|
447
|
+
* @param ref - a reference to the item (e.g. from `listItems` or `listIds`)
|
|
448
|
+
* @returns the content
|
|
449
|
+
*/
|
|
450
|
+
async getContent({ id, hash }) {
|
|
451
|
+
const { entries } = await this.raw.getEntries({
|
|
452
|
+
id: `${id}.docSchema`,
|
|
453
|
+
hash,
|
|
454
|
+
});
|
|
455
|
+
const cont = entries.find((e) => e.id.endsWith(".content"));
|
|
250
456
|
if (cont === undefined) {
|
|
251
457
|
throw new Error(`couldn't find contents for hash ${hash}`);
|
|
252
458
|
}
|
|
253
459
|
else {
|
|
254
|
-
return await this.raw.getContent(cont
|
|
460
|
+
return await this.raw.getContent(cont);
|
|
255
461
|
}
|
|
256
462
|
}
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
463
|
+
/**
|
|
464
|
+
* get the metadata for an item
|
|
465
|
+
*
|
|
466
|
+
* @remarks
|
|
467
|
+
* If this fails validation and you still want to get the content, you can use
|
|
468
|
+
* the low-level api to get the raw text of the `.metadata` file in the
|
|
469
|
+
* `RawEntry` for this hash.
|
|
470
|
+
*
|
|
471
|
+
* @param ref - a reference to the item (e.g. from `listItems` or `listIds`)
|
|
472
|
+
* @returns the metadata
|
|
473
|
+
*/
|
|
474
|
+
async getMetadata({ id, hash }) {
|
|
475
|
+
const { entries } = await this.raw.getEntries({
|
|
476
|
+
id: `${id}.docSchema`,
|
|
477
|
+
hash,
|
|
478
|
+
});
|
|
479
|
+
const meta = entries.find((e) => e.id.endsWith(".metadata"));
|
|
260
480
|
if (meta === undefined) {
|
|
261
481
|
throw new Error(`couldn't find metadata for hash ${hash}`);
|
|
262
482
|
}
|
|
263
483
|
else {
|
|
264
|
-
return await this.raw.getMetadata(meta
|
|
484
|
+
return await this.raw.getMetadata(meta);
|
|
265
485
|
}
|
|
266
486
|
}
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
487
|
+
/**
|
|
488
|
+
* get the pdf associated with a document
|
|
489
|
+
*
|
|
490
|
+
* This returns the raw input pdf, not the rendered pdf with any markup.
|
|
491
|
+
*
|
|
492
|
+
* @param ref - a reference to the document (e.g. from `listItems`)
|
|
493
|
+
* @returns the pdf bytes
|
|
494
|
+
*/
|
|
495
|
+
async getPdf({ id, hash }) {
|
|
496
|
+
const { entries } = await this.raw.getEntries({
|
|
497
|
+
id: `${id}.docSchema`,
|
|
498
|
+
hash,
|
|
499
|
+
});
|
|
500
|
+
const pdf = entries.find((e) => e.id.endsWith(".pdf"));
|
|
270
501
|
if (pdf === undefined) {
|
|
271
502
|
throw new Error(`couldn't find pdf for hash ${hash}`);
|
|
272
503
|
}
|
|
273
504
|
else {
|
|
274
|
-
return await this.raw.getHash(pdf
|
|
505
|
+
return await this.raw.getHash(pdf);
|
|
275
506
|
}
|
|
276
507
|
}
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
508
|
+
/**
|
|
509
|
+
* get the epub associated with a document
|
|
510
|
+
*
|
|
511
|
+
* This returns the raw input epub if a document was created from an epub.
|
|
512
|
+
*
|
|
513
|
+
* @param ref - a reference to the document (e.g. from `listItems`)
|
|
514
|
+
* @returns the epub bytes
|
|
515
|
+
*/
|
|
516
|
+
async getEpub({ id, hash }) {
|
|
517
|
+
const { entries } = await this.raw.getEntries({
|
|
518
|
+
id: `${id}.docSchema`,
|
|
519
|
+
hash,
|
|
520
|
+
});
|
|
521
|
+
const epub = entries.find((e) => e.id.endsWith(".epub"));
|
|
280
522
|
if (epub === undefined) {
|
|
281
523
|
throw new Error(`couldn't find epub for hash ${hash}`);
|
|
282
524
|
}
|
|
283
525
|
else {
|
|
284
|
-
return await this.raw.getHash(epub
|
|
526
|
+
return await this.raw.getHash(epub);
|
|
285
527
|
}
|
|
286
528
|
}
|
|
287
|
-
|
|
288
|
-
|
|
529
|
+
/**
|
|
530
|
+
* get a single page's parsed reMarkable lines (`.rm`) drawing
|
|
531
|
+
*
|
|
532
|
+
* @param ref - a reference to the document (e.g. from `listItems`)
|
|
533
|
+
* @param pageId - the id of the page, from the document's `.content` page
|
|
534
|
+
* list (see {@link getRmPages | `getRmPages`} for every page)
|
|
535
|
+
* @returns the parsed page, or `undefined` if the page exists but has no
|
|
536
|
+
* `.rm` drawing (a page you haven't drawn on has no `.rm` file)
|
|
537
|
+
* @throws if `pageId` is not a page of the document
|
|
538
|
+
*/
|
|
539
|
+
async getRmPage(ref, pageId) {
|
|
540
|
+
const { id, hash } = ref;
|
|
541
|
+
const { entries } = await this.raw.getEntries({
|
|
542
|
+
id: `${id}.docSchema`,
|
|
543
|
+
hash,
|
|
544
|
+
});
|
|
545
|
+
const content = await this.getContent(ref);
|
|
546
|
+
if (!pageOrder(content).includes(pageId)) {
|
|
547
|
+
throw new Error(`document ${id} has no page ${pageId}`);
|
|
548
|
+
}
|
|
549
|
+
const entry = entries.find((ent) => ent.id === `${id}/${pageId}.rm`);
|
|
550
|
+
if (entry === undefined) {
|
|
551
|
+
return undefined;
|
|
552
|
+
}
|
|
553
|
+
else {
|
|
554
|
+
return await this.raw.getRm(entry);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* get every drawn page of a document, parsed, keyed by page id
|
|
559
|
+
*
|
|
560
|
+
* Returns a map from page id to its parsed {@link RmPage | `RmPage`},
|
|
561
|
+
* iterating in the page order given by the document's `.content`. Pages with
|
|
562
|
+
* no drawing (and soft-deleted pages) are omitted. Version 3, 5, and 6 pages
|
|
563
|
+
* are all supported.
|
|
564
|
+
*
|
|
565
|
+
* @param ref - a reference to the document (e.g. from `listItems`)
|
|
566
|
+
* @returns the drawn pages, keyed by page id, in document order
|
|
567
|
+
*/
|
|
568
|
+
async getRmPages(ref) {
|
|
569
|
+
const { id, hash } = ref;
|
|
570
|
+
const { entries } = await this.raw.getEntries({
|
|
571
|
+
id: `${id}.docSchema`,
|
|
572
|
+
hash,
|
|
573
|
+
});
|
|
574
|
+
const content = await this.getContent(ref);
|
|
575
|
+
const byName = new Map(entries.map((entry) => [entry.id, entry]));
|
|
576
|
+
const drawn = pageOrder(content)
|
|
577
|
+
.map((pageId) => [pageId, byName.get(`${id}/${pageId}.rm`)])
|
|
578
|
+
.filter((pair) => pair[1] !== undefined);
|
|
579
|
+
const parsed = await Promise.all(drawn.map(([, entry]) => this.raw.getRm(entry)));
|
|
580
|
+
return new Map(drawn.map(([pageId], index) => [pageId, parsed[index]]));
|
|
581
|
+
}
|
|
582
|
+
/**
|
|
583
|
+
* get a document's entire contents as a zip archive
|
|
584
|
+
*
|
|
585
|
+
* This gets every file associated with a document and puts them into a zip
|
|
586
|
+
* archive.
|
|
587
|
+
*
|
|
588
|
+
* @remarks
|
|
589
|
+
* This is an experimental feature. The resulting archive round-trips back
|
|
590
|
+
* through {@link putDocumentArchive | `putDocumentArchive`}.
|
|
591
|
+
*
|
|
592
|
+
* @param ref - a reference to the document (e.g. from `listItems`)
|
|
593
|
+
*/
|
|
594
|
+
async getDocumentArchive({ id, hash }) {
|
|
595
|
+
const { entries } = await this.raw.getEntries({
|
|
596
|
+
id: `${id}.docSchema`,
|
|
597
|
+
hash,
|
|
598
|
+
});
|
|
289
599
|
const zip = new JSZip();
|
|
290
600
|
for (const entry of entries) {
|
|
291
601
|
// TODO if this is .metadata we might want to assert type === "DocumentType"
|
|
292
|
-
zip.file(entry.id, this.raw.getHash(entry
|
|
602
|
+
zip.file(entry.id, this.raw.getHash(entry));
|
|
293
603
|
}
|
|
294
604
|
return zip.generateAsync({ type: "uint8array" });
|
|
295
605
|
}
|
|
296
|
-
|
|
606
|
+
/**
|
|
607
|
+
* upload a document archive produced by {@link getDocumentArchive | `getDocumentArchive`}
|
|
608
|
+
*
|
|
609
|
+
* This explodes the zip archive back into its constituent files, uploads each
|
|
610
|
+
* as a blob, and commits a new document into the root.
|
|
611
|
+
*
|
|
612
|
+
* @remarks
|
|
613
|
+
* This is an experimental feature. By default a fresh document id is generated
|
|
614
|
+
* so re-uploading to the same account doesn't collide with the original; pass
|
|
615
|
+
* {@link PutDocumentOptions.id | `id`} to keep the original id. Like the other
|
|
616
|
+
* low-level puts, this may throw a {@link GenerationError | `GenerationError`}
|
|
617
|
+
* if the generation is stale, requiring a retry.
|
|
618
|
+
*
|
|
619
|
+
* @param buffer - the archive bytes, as returned by `getDocumentArchive`
|
|
620
|
+
* @param options - overrides for parent, visible name, and id
|
|
621
|
+
*/
|
|
622
|
+
async putDocumentArchive(buffer, { refresh = false, parent, visibleName, id: keepId, } = {}) {
|
|
623
|
+
if (parent !== undefined && parent && !idReg.test(parent)) {
|
|
624
|
+
throw new ValidationError(parent, idReg, "parent must be a valid document id");
|
|
625
|
+
}
|
|
626
|
+
const zip = await JSZip.loadAsync(buffer);
|
|
627
|
+
const paths = Object.keys(zip.files).filter((path) => !zip.files[path].dir);
|
|
628
|
+
const metaPath = paths.find((path) => path.endsWith(".metadata"));
|
|
629
|
+
if (metaPath === undefined) {
|
|
630
|
+
throw new Error("archive did not contain a .metadata file");
|
|
631
|
+
}
|
|
632
|
+
const oldId = metaPath.slice(0, -9);
|
|
633
|
+
if (oldId.includes("/")) {
|
|
634
|
+
throw new Error(`unexpected nested .metadata path '${metaPath}'`);
|
|
635
|
+
}
|
|
636
|
+
const newId = keepId ?? uuid4();
|
|
637
|
+
// rewrite the old document id prefix on every archived file to the new id,
|
|
638
|
+
// patching the .metadata as we pass it (parent/name/lastModified). the
|
|
639
|
+
// blobs don't depend on the generation, so upload the rewritten files and
|
|
640
|
+
// the document index once, then let #commit retry only the root merge
|
|
641
|
+
const enc = new TextEncoder();
|
|
642
|
+
const dec = new TextDecoder();
|
|
643
|
+
const lastModified = Date.now().toFixed();
|
|
644
|
+
const [, , schemaVersion] = await this.#getRootHash(refresh);
|
|
645
|
+
const fileUploads = await Promise.all(paths.map(async (path) => {
|
|
646
|
+
if (!path.startsWith(oldId)) {
|
|
647
|
+
throw new Error(`archived file '${path}' did not start with '${oldId}'`);
|
|
648
|
+
}
|
|
649
|
+
const newPath = `${newId}${path.slice(oldId.length)}`;
|
|
650
|
+
let bytes = await zip.files[path].async("uint8array");
|
|
651
|
+
if (path === metaPath) {
|
|
652
|
+
const meta = parseMetadata(dec.decode(bytes));
|
|
653
|
+
if (parent !== undefined)
|
|
654
|
+
meta.parent = parent;
|
|
655
|
+
if (visibleName !== undefined)
|
|
656
|
+
meta.visibleName = visibleName;
|
|
657
|
+
meta.lastModified = lastModified;
|
|
658
|
+
bytes = enc.encode(JSON.stringify(meta));
|
|
659
|
+
}
|
|
660
|
+
return this.raw.putFile(newPath, bytes);
|
|
661
|
+
}));
|
|
662
|
+
const fileEntries = fileUploads.map(([entry]) => entry);
|
|
663
|
+
const [docEntry, uploadDoc] = await this.raw.putEntries(newId, fileEntries, schemaVersion);
|
|
664
|
+
await Promise.all([...fileUploads.map(([, upload]) => upload), uploadDoc]);
|
|
665
|
+
await this.#commit(docEntry);
|
|
666
|
+
return { id: newId, hash: docEntry.hash };
|
|
667
|
+
}
|
|
668
|
+
async #putFile(visibleName, fileType, buffer, { refresh, parent = ROOT_ID, pinned = false, zoomMode = "bestFit", viewBackgroundFilter, textScale = 1, textAlignment = "justify", fontName = "", coverPageNumber = -1, authors, title, publicationDate, publisher, extraMetadata = {}, lineHeight = -1, margins = 125, orientation = "portrait", tags, customZoomScale, customZoomCenterX, customZoomCenterY, customZoomPageWidth, customZoomPageHeight, customZoomOrientation, }) {
|
|
297
669
|
if (parent && !idReg.test(parent)) {
|
|
298
670
|
throw new ValidationError(parent, idReg, "parent must be a valid document id");
|
|
299
671
|
}
|
|
@@ -339,46 +711,94 @@ class Remarkable {
|
|
|
339
711
|
redirectionPageMap: [0],
|
|
340
712
|
sizeInBytes: buffer.length.toFixed(),
|
|
341
713
|
};
|
|
342
|
-
//
|
|
343
|
-
|
|
714
|
+
// the schema version is needed to encode the document index; the blobs
|
|
715
|
+
// themselves don't depend on the generation, so upload them once and let
|
|
716
|
+
// #commit retry only the root merge
|
|
717
|
+
const [, , schemaVersion] = await this.#getRootHash(refresh);
|
|
718
|
+
const [[contentEntry, uploadContent], [metadataEntry, uploadMetadata], [pagedataEntry, uploadPagedata], [fileEntry, uploadFile],] = await Promise.all([
|
|
344
719
|
this.raw.putContent(`${id}.content`, content),
|
|
345
720
|
this.raw.putMetadata(`${id}.metadata`, metadata),
|
|
346
721
|
this.raw.putText(`${id}.pagedata`, "\n"),
|
|
347
722
|
this.raw.putFile(`${id}.${fileType}`, buffer),
|
|
348
|
-
this.#getRootHash(refresh),
|
|
349
|
-
]);
|
|
350
|
-
// now fetch root entries and upload this file entry
|
|
351
|
-
const [[collectionEntry, uploadCollection], { entries: rootEntries }] = await Promise.all([
|
|
352
|
-
this.raw.putEntries(id, [contentEntry, metadataEntry, pagedataEntry, fileEntry], schemaVersion),
|
|
353
|
-
this.raw.getEntries("root.docSchema", rootHash),
|
|
354
723
|
]);
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
//
|
|
724
|
+
const [collectionEntry, uploadCollection] = await this.raw.putEntries(id, [contentEntry, metadataEntry, pagedataEntry, fileEntry], schemaVersion);
|
|
725
|
+
// TODO we could return a full entry here, but we should probably decide
|
|
726
|
+
// what that should be, e.g. we could return more fields than the standard
|
|
727
|
+
// entry. Same for putFolder
|
|
728
|
+
// TODO we should also decide if the api should take hashes or ids...
|
|
359
729
|
await Promise.all([
|
|
360
730
|
uploadContent,
|
|
361
731
|
uploadMetadata,
|
|
362
732
|
uploadPagedata,
|
|
363
733
|
uploadFile,
|
|
364
734
|
uploadCollection,
|
|
365
|
-
uploadRoot,
|
|
366
735
|
]);
|
|
367
|
-
|
|
368
|
-
// what that should be, e.g. we could return more fields than the standard
|
|
369
|
-
// entry. Same for putFolder
|
|
370
|
-
// TODO we should also decide if the api should take hashes or ids...
|
|
371
|
-
await this.#putRootHash(rootEntry.hash, generation);
|
|
736
|
+
await this.#commit(collectionEntry);
|
|
372
737
|
return { id, hash: collectionEntry.hash };
|
|
373
738
|
}
|
|
739
|
+
/**
|
|
740
|
+
* use the low-level api to add a pdf document
|
|
741
|
+
*
|
|
742
|
+
* Since this uses the low-level api, it provides more options than
|
|
743
|
+
* {@link uploadPdf | `uploadPdf`}, but is a little more finicky. Notably, it
|
|
744
|
+
* may throw a {@link GenerationError | `GenerationError`} if the generation
|
|
745
|
+
* doesn't match the current server generation, requiring you to retry until
|
|
746
|
+
* it works.
|
|
747
|
+
*
|
|
748
|
+
* @remarks
|
|
749
|
+
* When `zoomMode` is `"customFit"` the `customZoom*` fields describe the view,
|
|
750
|
+
* all in the source page's device pixels: `customZoomPageWidth` and
|
|
751
|
+
* `customZoomPageHeight` are the page dimensions scaled by the device dpi
|
|
752
|
+
* (`pagePt * dpi / 72`, see {@link deviceScreens | `deviceScreens`}), and the
|
|
753
|
+
* centers are in those pixels.
|
|
754
|
+
*
|
|
755
|
+
* The view always has the device's aspect ratio — you control its height and
|
|
756
|
+
* position, not its shape. `customZoomScale = screenHeight / viewHeight` in
|
|
757
|
+
* device pixels (`screenHeight` fixed per model, see {@link deviceScreens |
|
|
758
|
+
* `deviceScreens`}), normalized to 1:1 native pixels: at `1` the view is
|
|
759
|
+
* screen-tall, showing `screenHeight / customZoomPageHeight` of the page.
|
|
760
|
+
*
|
|
761
|
+
* `customZoomCenterX` offsets the center of the view horizontally from the
|
|
762
|
+
* page center, and `customZoomCenterY` is the absolute distance of the center
|
|
763
|
+
* down from the top of the page; the view's width follows from its height and
|
|
764
|
+
* the device aspect ratio.
|
|
765
|
+
*
|
|
766
|
+
* The fields are a single document-wide setting, but `customZoomCenterY` is
|
|
767
|
+
* applied against each page's own rendered height. On a page rendered taller
|
|
768
|
+
* than `customZoomPageHeight` that distance is a smaller fraction of the page,
|
|
769
|
+
* so the view sits higher and cuts off the bottom; on a shorter page it sits
|
|
770
|
+
* lower and cuts off the top. `customZoomScale` (a ratio) and
|
|
771
|
+
* `customZoomCenterX` (an offset from center) do not shift with page size.
|
|
772
|
+
*
|
|
773
|
+
* @param visibleName - the name to display on the reMarkable
|
|
774
|
+
* @param buffer - the raw pdf
|
|
775
|
+
* @param opts - put options
|
|
776
|
+
* @throws GenerationError if the generation doesn't match the current server generation
|
|
777
|
+
* @returns the entry for the newly inserted document
|
|
778
|
+
*/
|
|
374
779
|
async putPdf(visibleName, buffer, opts = {}) {
|
|
375
780
|
return await this.#putFile(visibleName, "pdf", buffer, opts);
|
|
376
781
|
}
|
|
782
|
+
/**
|
|
783
|
+
* use the low-level api to add an epub document
|
|
784
|
+
*
|
|
785
|
+
* Since this uses the low-level api, it provides more options than
|
|
786
|
+
* {@link uploadEpub | `uploadEpub`}, but is a little more finicky. Notably, it
|
|
787
|
+
* may throw a {@link GenerationError | `GenerationError`} if the generation
|
|
788
|
+
* doesn't match the current server generation, requiring you to retry until
|
|
789
|
+
* it works.
|
|
790
|
+
*
|
|
791
|
+
* @param visibleName - the name to display on the reMarkable
|
|
792
|
+
* @param buffer - the raw epub
|
|
793
|
+
* @param opts - put options
|
|
794
|
+
* @throws GenerationError if the generation doesn't match the current server generation
|
|
795
|
+
* @returns the entry for the newly inserted document
|
|
796
|
+
*/
|
|
377
797
|
async putEpub(visibleName, buffer, opts = {}) {
|
|
378
798
|
return await this.#putFile(visibleName, "epub", buffer, opts);
|
|
379
799
|
}
|
|
380
800
|
/** create a folder */
|
|
381
|
-
async putFolder(visibleName, { parent =
|
|
801
|
+
async putFolder(visibleName, { parent = ROOT_ID } = {}, refresh = false) {
|
|
382
802
|
if (parent && !idReg.test(parent)) {
|
|
383
803
|
throw new ValidationError(parent, idReg, "parent must be a valid document id");
|
|
384
804
|
}
|
|
@@ -395,52 +815,68 @@ class Remarkable {
|
|
|
395
815
|
type: "CollectionType",
|
|
396
816
|
visibleName,
|
|
397
817
|
};
|
|
398
|
-
// upload
|
|
399
|
-
|
|
818
|
+
// the blobs don't depend on the generation, so upload them once and let
|
|
819
|
+
// #commit retry only the root merge
|
|
820
|
+
const [, , schemaVersion] = await this.#getRootHash(refresh);
|
|
821
|
+
const [[contentEntry, uploadContent], [metadataEntry, uploadMetadata]] = await Promise.all([
|
|
400
822
|
this.raw.putContent(`${id}.content`, content),
|
|
401
823
|
this.raw.putMetadata(`${id}.metadata`, metadata),
|
|
402
|
-
this.#getRootHash(refresh),
|
|
403
824
|
]);
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
this.raw.getEntries("root.docSchema", rootHash),
|
|
408
|
-
]);
|
|
409
|
-
// now upload a new root entry
|
|
410
|
-
rootEntries.push(collectionEntry);
|
|
411
|
-
const [rootEntry, uploadRoot] = await this.raw.putEntries("root", rootEntries, 4);
|
|
412
|
-
// before updating the root hash, first upload everything
|
|
413
|
-
await Promise.all([
|
|
414
|
-
uploadContent,
|
|
415
|
-
uploadMetadata,
|
|
416
|
-
uploadCollection,
|
|
417
|
-
uploadRoot,
|
|
418
|
-
]);
|
|
419
|
-
// put root hash and return
|
|
420
|
-
await this.#putRootHash(rootEntry.hash, generation);
|
|
825
|
+
const [collectionEntry, uploadCollection] = await this.raw.putEntries(id, [contentEntry, metadataEntry], schemaVersion);
|
|
826
|
+
await Promise.all([uploadContent, uploadMetadata, uploadCollection]);
|
|
827
|
+
await this.#commit(collectionEntry);
|
|
421
828
|
return { id, hash: collectionEntry.hash };
|
|
422
829
|
}
|
|
423
|
-
/**
|
|
830
|
+
/**
|
|
831
|
+
* upload an epub
|
|
832
|
+
*
|
|
833
|
+
* @example
|
|
834
|
+
* ```ts
|
|
835
|
+
* await api.uploadEpub("My EPub", ...);
|
|
836
|
+
* ```
|
|
837
|
+
*
|
|
838
|
+
* @remarks
|
|
839
|
+
* this uses a simpler api that works even with schema version 4.
|
|
840
|
+
*
|
|
841
|
+
* @param visibleName - the name to show for the uploaded epub
|
|
842
|
+
* @param buffer - the epub contents
|
|
843
|
+
*/
|
|
424
844
|
async uploadEpub(visibleName, buffer) {
|
|
425
845
|
return await this.raw.uploadFile(visibleName, buffer, "application/epub+zip");
|
|
426
846
|
}
|
|
427
|
-
/**
|
|
847
|
+
/**
|
|
848
|
+
* upload a pdf
|
|
849
|
+
*
|
|
850
|
+
* @example
|
|
851
|
+
* ```ts
|
|
852
|
+
* await api.uploadPdf("My PDF", ...);
|
|
853
|
+
* ```
|
|
854
|
+
*
|
|
855
|
+
* @remarks
|
|
856
|
+
* this uses a simpler api that works even with schema version 4.
|
|
857
|
+
*
|
|
858
|
+
* @param visibleName - the name to show for the uploaded epub
|
|
859
|
+
* @param buffer - the epub contents
|
|
860
|
+
*/
|
|
428
861
|
async uploadPdf(visibleName, buffer) {
|
|
429
862
|
return await this.raw.uploadFile(visibleName, buffer, "application/pdf");
|
|
430
863
|
}
|
|
431
|
-
/**
|
|
864
|
+
/** create a folder using the simple api */
|
|
432
865
|
async uploadFolder(visibleName) {
|
|
433
866
|
return await this.raw.uploadFile(visibleName, new Uint8Array(0), "folder");
|
|
434
867
|
}
|
|
435
868
|
/** edit just a content entry */
|
|
436
869
|
async #editContentRaw(id, hash, update, schemaVersion) {
|
|
437
|
-
const { entries } = await this.raw.getEntries(
|
|
870
|
+
const { entries } = await this.raw.getEntries({
|
|
871
|
+
id: `${id}.docSchema`,
|
|
872
|
+
hash,
|
|
873
|
+
});
|
|
438
874
|
const contInd = entries.findIndex((ent) => ent.id.endsWith(".content"));
|
|
439
875
|
const contEntry = entries[contInd];
|
|
440
876
|
if (contEntry === undefined) {
|
|
441
877
|
throw new Error("internal error: couldn't find content in entry hash");
|
|
442
878
|
}
|
|
443
|
-
const cont = await this.raw.getContent(contEntry
|
|
879
|
+
const cont = await this.raw.getContent(contEntry);
|
|
444
880
|
Object.assign(cont, update);
|
|
445
881
|
const [newContEntry, uploadCont] = await this.raw.putContent(contEntry.id, cont);
|
|
446
882
|
entries[contInd] = newContEntry;
|
|
@@ -450,47 +886,90 @@ class Remarkable {
|
|
|
450
886
|
}
|
|
451
887
|
/** fully sync a content edit */
|
|
452
888
|
async #editContent(hash, update, expectedType, refresh) {
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
889
|
+
return await this.#withRetry(async () => {
|
|
890
|
+
const [rootHash, generation, schemaVersion] = await this.#getRootHash(refresh);
|
|
891
|
+
const { entries } = await this.raw.getEntries({
|
|
892
|
+
id: ROOT_SCHEMA,
|
|
893
|
+
hash: rootHash,
|
|
894
|
+
});
|
|
895
|
+
const hashInd = entries.findIndex((ent) => ent.hash === hash);
|
|
896
|
+
const hashEnt = entries[hashInd];
|
|
897
|
+
if (hashEnt === undefined) {
|
|
898
|
+
throw new HashNotFoundError(hash);
|
|
899
|
+
}
|
|
900
|
+
const [[newEnt, uploadEnt], meta] = await Promise.all([
|
|
901
|
+
this.#editContentRaw(hashEnt.id, hash, update, schemaVersion),
|
|
902
|
+
this.getMetadata(hashEnt),
|
|
903
|
+
]);
|
|
904
|
+
if (meta.type !== expectedType) {
|
|
905
|
+
throw new Error(`expected type ${expectedType} but got ${meta.type} for hash ${hash}`);
|
|
906
|
+
}
|
|
907
|
+
entries[hashInd] = newEnt;
|
|
908
|
+
const [rootEntry, uploadRoot] = await this.raw.putEntries(ROOT_LIST, entries, 4);
|
|
909
|
+
await Promise.all([uploadEnt, uploadRoot]);
|
|
910
|
+
await this.#putRootHash(rootEntry.hash, generation);
|
|
911
|
+
return { id: hashEnt.id, hash: newEnt.hash };
|
|
912
|
+
});
|
|
472
913
|
}
|
|
473
|
-
/**
|
|
474
|
-
|
|
475
|
-
|
|
914
|
+
/**
|
|
915
|
+
* update content metadata for a document
|
|
916
|
+
*
|
|
917
|
+
* @example
|
|
918
|
+
* ```ts
|
|
919
|
+
* const next = await api.updateDocument(doc, { textAlignment: "left" });
|
|
920
|
+
* ```
|
|
921
|
+
*
|
|
922
|
+
* @param ref - a reference to the file to update
|
|
923
|
+
* @param content - the fields of content to update
|
|
924
|
+
* @returns a reference to the updated entry, with its new hash
|
|
925
|
+
*/
|
|
926
|
+
async updateDocument(ref, content, refresh = false) {
|
|
927
|
+
return await this.#editContent(ref.hash, content, "DocumentType", refresh);
|
|
476
928
|
}
|
|
477
|
-
/**
|
|
478
|
-
|
|
479
|
-
|
|
929
|
+
/**
|
|
930
|
+
* update content metadata for a collection
|
|
931
|
+
*
|
|
932
|
+
* @example
|
|
933
|
+
* ```ts
|
|
934
|
+
* const next = await api.updateCollection(dir, { textAlignment: "left" });
|
|
935
|
+
* ```
|
|
936
|
+
*
|
|
937
|
+
* @param ref - a reference to the collection to update
|
|
938
|
+
* @param content - the fields of content to update
|
|
939
|
+
* @returns a reference to the updated entry, with its new hash
|
|
940
|
+
*/
|
|
941
|
+
async updateCollection(ref, content, refresh = false) {
|
|
942
|
+
return await this.#editContent(ref.hash, content, "CollectionType", refresh);
|
|
480
943
|
}
|
|
481
|
-
/**
|
|
482
|
-
|
|
483
|
-
|
|
944
|
+
/**
|
|
945
|
+
* update content metadata for a template
|
|
946
|
+
*
|
|
947
|
+
* @example
|
|
948
|
+
* ```ts
|
|
949
|
+
* const next = await api.updateTemplate(tmpl, { textAlignment: "left" });
|
|
950
|
+
* ```
|
|
951
|
+
*
|
|
952
|
+
* @param ref - a reference to the template to update
|
|
953
|
+
* @param content - the fields of content to update
|
|
954
|
+
* @returns a reference to the updated entry, with its new hash
|
|
955
|
+
*/
|
|
956
|
+
async updateTemplate(ref, content, refresh = false) {
|
|
957
|
+
return await this.#editContent(ref.hash, content, "TemplateType", refresh);
|
|
484
958
|
}
|
|
485
959
|
async #editMetaRaw(id, hash, update, schemaVersion) {
|
|
486
|
-
const { entries } = await this.raw.getEntries(
|
|
960
|
+
const { entries } = await this.raw.getEntries({
|
|
961
|
+
id: `${id}.docSchema`,
|
|
962
|
+
hash,
|
|
963
|
+
});
|
|
487
964
|
const metaInd = entries.findIndex((ent) => ent.id.endsWith(".metadata"));
|
|
488
965
|
const metaEntry = entries[metaInd];
|
|
489
966
|
if (metaEntry === undefined) {
|
|
490
967
|
throw new Error("internal error: couldn't find metadata in entry hash");
|
|
491
968
|
}
|
|
492
|
-
const meta = await this.raw.getMetadata(metaEntry
|
|
969
|
+
const meta = await this.raw.getMetadata(metaEntry);
|
|
493
970
|
Object.assign(meta, update);
|
|
971
|
+
meta.version = (meta.version ?? 0) + 1;
|
|
972
|
+
meta.metadatamodified = true;
|
|
494
973
|
const [newMetaEntry, uploadMeta] = await this.raw.putMetadata(metaEntry.id, meta);
|
|
495
974
|
entries[metaInd] = newMetaEntry;
|
|
496
975
|
const [result, uploadEntries] = await this.raw.putEntries(id, entries, schemaVersion);
|
|
@@ -498,74 +977,165 @@ class Remarkable {
|
|
|
498
977
|
return [result, upload];
|
|
499
978
|
}
|
|
500
979
|
async #editMeta(hash, update, refresh = false) {
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
980
|
+
return await this.#withRetry(async () => {
|
|
981
|
+
const [rootHash, generation, schemaVersion] = await this.#getRootHash(refresh);
|
|
982
|
+
const { entries } = await this.raw.getEntries({
|
|
983
|
+
id: ROOT_SCHEMA,
|
|
984
|
+
hash: rootHash,
|
|
985
|
+
});
|
|
986
|
+
const hashInd = entries.findIndex((ent) => ent.hash === hash);
|
|
987
|
+
const hashEnt = entries[hashInd];
|
|
988
|
+
if (hashEnt === undefined) {
|
|
989
|
+
throw new HashNotFoundError(hash);
|
|
990
|
+
}
|
|
991
|
+
const [newEnt, uploadEnt] = await this.#editMetaRaw(hashEnt.id, hash, update, schemaVersion);
|
|
992
|
+
entries[hashInd] = newEnt;
|
|
993
|
+
const [rootEntry, uploadRoot] = await this.raw.putEntries(ROOT_LIST, entries, 4);
|
|
994
|
+
await Promise.all([uploadEnt, uploadRoot]);
|
|
995
|
+
await this.#putRootHash(rootEntry.hash, generation);
|
|
996
|
+
return { id: hashEnt.id, hash: newEnt.hash };
|
|
997
|
+
});
|
|
998
|
+
}
|
|
999
|
+
/**
|
|
1000
|
+
* move an entry
|
|
1001
|
+
*
|
|
1002
|
+
* @example
|
|
1003
|
+
* ```ts
|
|
1004
|
+
* const next = await api.move(doc, dir.id);
|
|
1005
|
+
* ```
|
|
1006
|
+
*
|
|
1007
|
+
* @param ref - a reference to the entry to move
|
|
1008
|
+
* @param parent - the id of the directory to move the entry to, "" (root) and "trash" are special parents
|
|
1009
|
+
* @returns a reference to the moved entry, with its new hash
|
|
1010
|
+
*/
|
|
1011
|
+
async move(ref, parent, refresh = false) {
|
|
517
1012
|
if (!idReg.test(parent)) {
|
|
518
1013
|
throw new ValidationError(parent, idReg, "parent must be a valid document id");
|
|
519
1014
|
}
|
|
520
|
-
return await this.#editMeta(hash, { parent }, refresh);
|
|
1015
|
+
return await this.#editMeta(ref.hash, { parent }, refresh);
|
|
521
1016
|
}
|
|
522
|
-
/**
|
|
523
|
-
|
|
524
|
-
|
|
1017
|
+
/**
|
|
1018
|
+
* delete an entry
|
|
1019
|
+
*
|
|
1020
|
+
* @example
|
|
1021
|
+
* ```ts
|
|
1022
|
+
* await api.delete(file);
|
|
1023
|
+
* ```
|
|
1024
|
+
* @param ref - a reference to the entry to delete
|
|
1025
|
+
* @returns a reference to the deleted entry, with its new hash
|
|
1026
|
+
*/
|
|
1027
|
+
async delete(ref, refresh = false) {
|
|
1028
|
+
return await this.move(ref, TRASH_ID, refresh);
|
|
525
1029
|
}
|
|
526
|
-
/**
|
|
527
|
-
|
|
528
|
-
|
|
1030
|
+
/**
|
|
1031
|
+
* rename an entry
|
|
1032
|
+
*
|
|
1033
|
+
* @example
|
|
1034
|
+
* ```ts
|
|
1035
|
+
* const next = await api.rename(file, "new name");
|
|
1036
|
+
* ```
|
|
1037
|
+
* @param ref - a reference to the entry to rename
|
|
1038
|
+
* @param visibleName - the new name to assign
|
|
1039
|
+
* @returns a reference to the renamed entry, with its new hash
|
|
1040
|
+
*/
|
|
1041
|
+
async rename(ref, visibleName, refresh = false) {
|
|
1042
|
+
return await this.#editMeta(ref.hash, { visibleName }, refresh);
|
|
529
1043
|
}
|
|
530
|
-
/**
|
|
531
|
-
|
|
532
|
-
|
|
1044
|
+
/**
|
|
1045
|
+
* star or unstar an entry
|
|
1046
|
+
*
|
|
1047
|
+
* @example
|
|
1048
|
+
* ```ts
|
|
1049
|
+
* const next = await api.star(file, true);
|
|
1050
|
+
* ```
|
|
1051
|
+
* @param ref - a reference to the entry to star
|
|
1052
|
+
* @param starred - whether the entry should be starred or not
|
|
1053
|
+
* @returns a reference to the updated entry, with its new hash
|
|
1054
|
+
*/
|
|
1055
|
+
async star(ref, starred, refresh = false) {
|
|
1056
|
+
return await this.#editMeta(ref.hash, { pinned: starred }, refresh);
|
|
533
1057
|
}
|
|
534
|
-
/**
|
|
535
|
-
|
|
1058
|
+
/**
|
|
1059
|
+
* move many entries
|
|
1060
|
+
*
|
|
1061
|
+
* @example
|
|
1062
|
+
* ```ts
|
|
1063
|
+
* const next = await api.bulkMove([file], dir.id);
|
|
1064
|
+
* ```
|
|
1065
|
+
*
|
|
1066
|
+
* @param refs - references to the entries to move
|
|
1067
|
+
* @param parent - the directory id to move the entries to, "" (root) and "trash" are special ids
|
|
1068
|
+
* @returns references to the moved entries, each with its new hash
|
|
1069
|
+
*/
|
|
1070
|
+
async bulkMove(refs, parent, refresh = false) {
|
|
536
1071
|
if (!idReg.test(parent)) {
|
|
537
1072
|
throw new ValidationError(parent, idReg, "parent must be a valid document id");
|
|
538
1073
|
}
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
const
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
uploads
|
|
554
|
-
result
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
1074
|
+
return await this.#withRetry(async () => {
|
|
1075
|
+
const [rootHash, generation, schemaVersion] = await this.#getRootHash(refresh);
|
|
1076
|
+
const { entries } = await this.raw.getEntries({
|
|
1077
|
+
id: ROOT_SCHEMA,
|
|
1078
|
+
hash: rootHash,
|
|
1079
|
+
});
|
|
1080
|
+
const hashSet = new Set(refs.map((ref) => ref.hash));
|
|
1081
|
+
const toUpdate = [];
|
|
1082
|
+
const newEntries = [];
|
|
1083
|
+
for (const entry of entries) {
|
|
1084
|
+
const part = hashSet.has(entry.hash) ? toUpdate : newEntries;
|
|
1085
|
+
part.push(entry);
|
|
1086
|
+
}
|
|
1087
|
+
const resolved = await Promise.all(toUpdate.map(({ id, hash }) => this.#editMetaRaw(id, hash, { parent }, schemaVersion)));
|
|
1088
|
+
const uploads = [];
|
|
1089
|
+
const result = [];
|
|
1090
|
+
for (const [i, [newEnt, upload]] of resolved.entries()) {
|
|
1091
|
+
newEntries.push(newEnt);
|
|
1092
|
+
uploads.push(upload);
|
|
1093
|
+
result.push({ id: toUpdate[i].id, hash: newEnt.hash });
|
|
1094
|
+
}
|
|
1095
|
+
const [rootEntry, uploadRoot] = await this.raw.putEntries(ROOT_LIST, newEntries, 4);
|
|
1096
|
+
await Promise.all([Promise.all(uploads), uploadRoot]);
|
|
1097
|
+
await this.#putRootHash(rootEntry.hash, generation);
|
|
1098
|
+
return result;
|
|
1099
|
+
});
|
|
1100
|
+
}
|
|
1101
|
+
/**
|
|
1102
|
+
* delete many entries
|
|
1103
|
+
*
|
|
1104
|
+
* @example
|
|
1105
|
+
* ```ts
|
|
1106
|
+
* await api.bulkDelete([file]);
|
|
1107
|
+
* ```
|
|
1108
|
+
*
|
|
1109
|
+
* @param refs - references to the entries to delete
|
|
1110
|
+
* @returns references to the deleted entries, each with its new hash
|
|
1111
|
+
*/
|
|
1112
|
+
async bulkDelete(refs, refresh = false) {
|
|
1113
|
+
return await this.bulkMove(refs, TRASH_ID, refresh);
|
|
1114
|
+
}
|
|
1115
|
+
/**
|
|
1116
|
+
* get the current cache value as a string
|
|
1117
|
+
*
|
|
1118
|
+
* You can use this to warm start a new instance of
|
|
1119
|
+
* {@link remarkable | `remarkable`} with any previously cached results.
|
|
1120
|
+
*/
|
|
566
1121
|
dumpCache() {
|
|
567
1122
|
return this.raw.dumpCache();
|
|
568
1123
|
}
|
|
1124
|
+
/**
|
|
1125
|
+
* prune the cache so that it contains only reachable hashes
|
|
1126
|
+
*
|
|
1127
|
+
* The cache is append only, so it can grow without bound, even as hashes
|
|
1128
|
+
* become unreachable. In the future, this may have better cache management to
|
|
1129
|
+
* track this in real time, but for now, you can call this method, to keep it
|
|
1130
|
+
* from growing continuously.
|
|
1131
|
+
*
|
|
1132
|
+
* @remarks
|
|
1133
|
+
* This won't necessarily reduce the cache size. In order to see if
|
|
1134
|
+
* hashes are reachable we first have to search through all existing entry
|
|
1135
|
+
* lists.
|
|
1136
|
+
*
|
|
1137
|
+
* @param refresh - whether to refresh the root hash before pruning
|
|
1138
|
+
*/
|
|
569
1139
|
async pruneCache(refresh) {
|
|
570
1140
|
const [rootHash] = await this.#getRootHash(refresh);
|
|
571
1141
|
// start by assuming every cached hash is unreachable, then keep the ones we reach
|
|
@@ -575,7 +1145,7 @@ class Remarkable {
|
|
|
575
1145
|
// should only go one step) to track all hashes encountered
|
|
576
1146
|
// NOTE that we could increase the cache in this process, or it's possible
|
|
577
1147
|
// for other calls to increase the cache with misc values.
|
|
578
|
-
const base = await this.raw.getEntries(
|
|
1148
|
+
const base = await this.raw.getEntries({ id: ROOT_SCHEMA, hash: rootHash });
|
|
579
1149
|
let entries = [base.entries];
|
|
580
1150
|
let nextEntries = [];
|
|
581
1151
|
while (entries.length) {
|
|
@@ -583,7 +1153,7 @@ class Remarkable {
|
|
|
583
1153
|
for (const { hash, subfiles, id } of entryList) {
|
|
584
1154
|
toDelete.delete(hash);
|
|
585
1155
|
if (subfiles > 0) {
|
|
586
|
-
nextEntries.push(this.raw.getEntries(`${id}.docSchema`, hash));
|
|
1156
|
+
nextEntries.push(this.raw.getEntries({ id: `${id}.docSchema`, hash }));
|
|
587
1157
|
}
|
|
588
1158
|
}
|
|
589
1159
|
}
|
|
@@ -595,6 +1165,12 @@ class Remarkable {
|
|
|
595
1165
|
this.#cache.delete(key);
|
|
596
1166
|
}
|
|
597
1167
|
}
|
|
1168
|
+
/**
|
|
1169
|
+
* completely delete the cache
|
|
1170
|
+
*
|
|
1171
|
+
* If the cache is causing memory issues, you can clear it, but this will hurt
|
|
1172
|
+
* performance.
|
|
1173
|
+
*/
|
|
598
1174
|
clearCache() {
|
|
599
1175
|
this.raw.clearCache();
|
|
600
1176
|
}
|
|
@@ -628,7 +1204,7 @@ export async function auth(deviceToken, { authHost = AUTH_HOST } = {}) {
|
|
|
628
1204
|
* @param sessionToken - the session token used for authorization
|
|
629
1205
|
* @returns an api instance
|
|
630
1206
|
*/
|
|
631
|
-
export function session(sessionToken, { rawHost = RAW_HOST, uploadHost = UPLOAD_HOST, cache, maxCacheSize = Infinity, } = {}) {
|
|
1207
|
+
export function session(sessionToken, { rawHost = RAW_HOST, uploadHost = UPLOAD_HOST, cache, maxCacheSize = Infinity, maxGenerationRetries = 10, maxTransientRetries = 3, } = {}) {
|
|
632
1208
|
const initCache = JSON.parse(cache ?? "{}");
|
|
633
1209
|
const parsedCache = cached.safeParse(initCache);
|
|
634
1210
|
if (parsedCache.success) {
|
|
@@ -636,7 +1212,7 @@ export function session(sessionToken, { rawHost = RAW_HOST, uploadHost = UPLOAD_
|
|
|
636
1212
|
const cacheMap = maxCacheSize === Infinity
|
|
637
1213
|
? new Map(entries)
|
|
638
1214
|
: new LruCache(maxCacheSize, entries);
|
|
639
|
-
return new Remarkable(sessionToken, rawHost, uploadHost, cacheMap);
|
|
1215
|
+
return new Remarkable(sessionToken, rawHost, uploadHost, cacheMap, maxGenerationRetries, maxTransientRetries);
|
|
640
1216
|
}
|
|
641
1217
|
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.");
|
|
642
1218
|
}
|
|
@@ -651,12 +1227,14 @@ export function session(sessionToken, { rawHost = RAW_HOST, uploadHost = UPLOAD_
|
|
|
651
1227
|
* @returns an api instance
|
|
652
1228
|
*/
|
|
653
1229
|
export async function remarkable(deviceToken, options = {}) {
|
|
654
|
-
const { authHost, rawHost, uploadHost, cache, maxCacheSize } = options ?? {};
|
|
1230
|
+
const { authHost, rawHost, uploadHost, cache, maxCacheSize, maxGenerationRetries, maxTransientRetries, } = options ?? {};
|
|
655
1231
|
const sessionToken = await auth(deviceToken, { authHost });
|
|
656
1232
|
return session(sessionToken, {
|
|
657
1233
|
rawHost,
|
|
658
1234
|
uploadHost,
|
|
659
1235
|
cache,
|
|
660
1236
|
maxCacheSize,
|
|
1237
|
+
maxGenerationRetries,
|
|
1238
|
+
maxTransientRetries,
|
|
661
1239
|
});
|
|
662
1240
|
}
|