rmapi-js 6.0.0 → 8.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/LICENSE +1 -1
- package/README.md +29 -23
- package/dist/index.d.ts +737 -67
- package/dist/index.js +810 -145
- package/dist/lru.d.ts +8 -0
- package/dist/lru.js +64 -0
- package/dist/rmapi-js.esm.min.js +21 -1
- package/package.json +16 -14
package/dist/index.js
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* After getting a device token with the {@link register | `register`} method,
|
|
5
5
|
* persist it and create api instances using {@link remarkable | `remarkable`}.
|
|
6
6
|
* Outside of registration, all relevant methods are in
|
|
7
|
-
* {@link RemarkableApi | `RemarkableApi`}
|
|
7
|
+
* {@link RemarkableApi | `RemarkableApi`}, or it's interior
|
|
8
|
+
* {@link RawRemarkableApi | `RawRemarkableApi`} (for lower level functions).
|
|
8
9
|
*
|
|
9
10
|
* @example
|
|
10
11
|
* A simple rename
|
|
@@ -15,9 +16,10 @@
|
|
|
15
16
|
* const token = await register(code)
|
|
16
17
|
* // persist token
|
|
17
18
|
* const api = await remarkable(token);
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
19
|
+
* // list all items (documents and collections)
|
|
20
|
+
* const [first, ...rest] = api.listItems();
|
|
21
|
+
* // rename first item
|
|
22
|
+
* const entry = api.rename(first.hash, "new name");
|
|
21
23
|
* ```
|
|
22
24
|
*
|
|
23
25
|
* @example
|
|
@@ -27,7 +29,6 @@
|
|
|
27
29
|
*
|
|
28
30
|
* const api = await remarkable(...);
|
|
29
31
|
* const entry = await api.putEpub("document name", epubBuffer);
|
|
30
|
-
* await api.create(entry);
|
|
31
32
|
* ```
|
|
32
33
|
*
|
|
33
34
|
* @remarks
|
|
@@ -36,60 +37,53 @@
|
|
|
36
37
|
* which is a uuid4 and a hash, which indicates it's current state, and changes
|
|
37
38
|
* as the item mutates, where the id is constant. Most mutable operations take
|
|
38
39
|
* 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
|
|
40
|
-
* in the file structure. This will be another document id, or one of
|
|
41
|
-
* special ids, "" (the empty string) for the root directory, or "trash" for
|
|
42
|
-
* trash.
|
|
40
|
+
* number of properties, but a key property is the `parent`, which represents
|
|
41
|
+
* its parent in the file structure. This will be another document id, or one of
|
|
42
|
+
* two special ids, "" (the empty string) for the root directory, or "trash" for
|
|
43
|
+
* the trash.
|
|
44
|
+
*
|
|
45
|
+
* Detailed information about the low-level storage an apis can be found in
|
|
46
|
+
* {@link RawRemarkableApi | `RawRemarkableApi`}.
|
|
47
|
+
*
|
|
48
|
+
* Additionally, this entire api was reverse engineered, so some things are only
|
|
49
|
+
* `[speculative]`, or entirely `[unknown]`. If something breaks, please
|
|
50
|
+
* [file an issue!](https://github.com/erikbrinkman/rmapi-js/issues)
|
|
43
51
|
*
|
|
44
52
|
* @packageDocumentation
|
|
45
53
|
*/
|
|
46
54
|
import { fromByteArray } from "base64-js";
|
|
47
|
-
import
|
|
55
|
+
import CRC32C from "crc-32/crc32c";
|
|
56
|
+
import JSZip from "jszip";
|
|
57
|
+
import { boolean, elements, enumeration, float64, int32, nullable, properties, string, timestamp, uint32, uint8, values, } from "jtd-ts";
|
|
48
58
|
import { v4 as uuid4 } from "uuid";
|
|
59
|
+
import { LruCache } from "./lru";
|
|
49
60
|
const AUTH_HOST = "https://webapp-prod.cloud.remarkable.engineering";
|
|
50
|
-
const
|
|
61
|
+
const RAW_HOST = "https://eu.tectonic.remarkable.com";
|
|
51
62
|
const idReg = /^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}||trash)$/;
|
|
52
63
|
const hashReg = /^[0-9a-f]{64}$/;
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
64
|
+
const tag = properties({
|
|
65
|
+
name: string(),
|
|
66
|
+
timestamp: float64(),
|
|
67
|
+
}, undefined, true);
|
|
68
|
+
const pageTag = properties({
|
|
69
|
+
name: string(),
|
|
70
|
+
pageId: string(),
|
|
71
|
+
timestamp: float64(),
|
|
72
|
+
}, undefined, true);
|
|
73
|
+
/** An error that gets thrown when the backend while trying to update
|
|
74
|
+
*
|
|
75
|
+
* IF you encounter this error, you likely just need to try th request again. If
|
|
76
|
+
* you're trying to do several high-level `put` operations simultaneously,
|
|
77
|
+
* you'll likely encounter this error. You should either try to do them
|
|
78
|
+
* serially, or call the low level api directly to do one generation update.
|
|
79
|
+
*
|
|
80
|
+
* @see {@link RawRemarkableApi | `RawRemarkableApi`}
|
|
81
|
+
*/
|
|
82
|
+
export class GenerationError extends Error {
|
|
83
|
+
constructor() {
|
|
84
|
+
super("root generation was stale; try put again");
|
|
60
85
|
}
|
|
61
86
|
}
|
|
62
|
-
const commonProperties = {
|
|
63
|
-
id: string(),
|
|
64
|
-
hash: string(),
|
|
65
|
-
visibleName: string(),
|
|
66
|
-
lastModified: string(),
|
|
67
|
-
pinned: boolean(),
|
|
68
|
-
};
|
|
69
|
-
const commonOptionalProperties = {
|
|
70
|
-
parent: string(),
|
|
71
|
-
tags: elements(properties({
|
|
72
|
-
name: string(),
|
|
73
|
-
timestamp: float64(),
|
|
74
|
-
})),
|
|
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
|
-
});
|
|
93
87
|
/** an error that results from a failed request */
|
|
94
88
|
export class ResponseError extends Error {
|
|
95
89
|
/** the response status number */
|
|
@@ -114,6 +108,15 @@ export class ValidationError extends Error {
|
|
|
114
108
|
this.regex = regex;
|
|
115
109
|
}
|
|
116
110
|
}
|
|
111
|
+
/** an error that results while supplying a hash not found in the entries of the root hash */
|
|
112
|
+
export class HashNotFoundError extends Error {
|
|
113
|
+
/** the hash that couldn't be found */
|
|
114
|
+
hash;
|
|
115
|
+
constructor(hash) {
|
|
116
|
+
super(`'${hash}' not found in the root hash`);
|
|
117
|
+
this.hash = hash;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
117
120
|
/**
|
|
118
121
|
* register a device and get the token needed to access the api
|
|
119
122
|
*
|
|
@@ -124,7 +127,7 @@ export class ValidationError extends Error {
|
|
|
124
127
|
* @param code - the eight letter code a user got from `https://my.remarkable.com/device/browser/connect`.
|
|
125
128
|
* @returns the device token necessary for creating an api instace. These never expire so persist as long as necessary.
|
|
126
129
|
*/
|
|
127
|
-
export async function register(code, { deviceDesc = "browser-chrome", uuid = uuid4(), authHost = AUTH_HOST,
|
|
130
|
+
export async function register(code, { deviceDesc = "browser-chrome", uuid = uuid4(), authHost = AUTH_HOST, } = {}) {
|
|
128
131
|
if (code.length !== 8) {
|
|
129
132
|
throw new Error(`code should be length 8, but was ${code.length}`);
|
|
130
133
|
}
|
|
@@ -146,19 +149,431 @@ export async function register(code, { deviceDesc = "browser-chrome", uuid = uui
|
|
|
146
149
|
return await resp.text();
|
|
147
150
|
}
|
|
148
151
|
}
|
|
152
|
+
const documentMetadata = properties(undefined, {
|
|
153
|
+
authors: elements(string()),
|
|
154
|
+
title: string(),
|
|
155
|
+
publicationDate: string(),
|
|
156
|
+
publisher: string(),
|
|
157
|
+
}, true);
|
|
158
|
+
const cPagePage = properties({
|
|
159
|
+
id: string(),
|
|
160
|
+
idx: properties({
|
|
161
|
+
timestamp: string(),
|
|
162
|
+
value: string(),
|
|
163
|
+
}, undefined, true),
|
|
164
|
+
}, {
|
|
165
|
+
template: properties({
|
|
166
|
+
timestamp: string(),
|
|
167
|
+
value: string(),
|
|
168
|
+
}, undefined, true),
|
|
169
|
+
redir: properties({
|
|
170
|
+
timestamp: string(),
|
|
171
|
+
value: int32(),
|
|
172
|
+
}, undefined, true),
|
|
173
|
+
scrollTime: properties({
|
|
174
|
+
timestamp: string(),
|
|
175
|
+
value: timestamp(),
|
|
176
|
+
}, undefined, true),
|
|
177
|
+
verticalScroll: properties({
|
|
178
|
+
timestamp: string(),
|
|
179
|
+
value: float64(),
|
|
180
|
+
}, undefined, true),
|
|
181
|
+
deleted: properties({
|
|
182
|
+
timestamp: string(),
|
|
183
|
+
value: int32(),
|
|
184
|
+
}, undefined, true),
|
|
185
|
+
}, true);
|
|
186
|
+
const cPages = properties({
|
|
187
|
+
lastOpened: properties({
|
|
188
|
+
timestamp: string(),
|
|
189
|
+
value: string(),
|
|
190
|
+
}, undefined, true),
|
|
191
|
+
original: properties({
|
|
192
|
+
timestamp: string(),
|
|
193
|
+
value: int32(),
|
|
194
|
+
}, undefined, true),
|
|
195
|
+
pages: elements(cPagePage),
|
|
196
|
+
uuids: elements(properties({
|
|
197
|
+
first: string(),
|
|
198
|
+
second: uint32(),
|
|
199
|
+
}, undefined, true)),
|
|
200
|
+
}, undefined, true);
|
|
201
|
+
const collectionContent = properties(undefined, {
|
|
202
|
+
tags: elements(tag),
|
|
203
|
+
});
|
|
204
|
+
const documentContent = properties({
|
|
205
|
+
coverPageNumber: int32(),
|
|
206
|
+
documentMetadata,
|
|
207
|
+
extraMetadata: values(string()),
|
|
208
|
+
fileType: enumeration("epub", "notebook", "pdf"),
|
|
209
|
+
fontName: string(),
|
|
210
|
+
formatVersion: uint8(),
|
|
211
|
+
lineHeight: int32(),
|
|
212
|
+
margins: uint32(),
|
|
213
|
+
orientation: enumeration("portrait", "landscape"),
|
|
214
|
+
pageCount: uint32(),
|
|
215
|
+
sizeInBytes: string(),
|
|
216
|
+
textAlignment: enumeration("justify", "left"),
|
|
217
|
+
textScale: float64(),
|
|
218
|
+
}, {
|
|
219
|
+
cPages,
|
|
220
|
+
customZoomCenterX: float64(),
|
|
221
|
+
customZoomCenterY: float64(),
|
|
222
|
+
customZoomOrientation: enumeration("portrait", "landscape"),
|
|
223
|
+
customZoomPageHeight: float64(),
|
|
224
|
+
customZoomPageWidth: float64(),
|
|
225
|
+
customZoomScale: float64(),
|
|
226
|
+
dummyDocument: boolean(),
|
|
227
|
+
keyboardMetadata: properties({
|
|
228
|
+
count: uint32(),
|
|
229
|
+
timestamp: float64(),
|
|
230
|
+
}, undefined, true),
|
|
231
|
+
lastOpenedPage: uint32(),
|
|
232
|
+
originalPageCount: int32(),
|
|
233
|
+
pages: elements(string()),
|
|
234
|
+
pageTags: elements(pageTag),
|
|
235
|
+
redirectionPageMap: elements(int32()),
|
|
236
|
+
tags: elements(tag),
|
|
237
|
+
transform: properties({
|
|
238
|
+
m11: float64(),
|
|
239
|
+
m12: float64(),
|
|
240
|
+
m13: float64(),
|
|
241
|
+
m21: float64(),
|
|
242
|
+
m22: float64(),
|
|
243
|
+
m23: float64(),
|
|
244
|
+
m31: float64(),
|
|
245
|
+
m32: float64(),
|
|
246
|
+
m33: float64(),
|
|
247
|
+
}, undefined, true),
|
|
248
|
+
// eslint-disable-next-line spellcheck/spell-checker
|
|
249
|
+
viewBackgroundFilter: enumeration("off", "fullpage"),
|
|
250
|
+
zoomMode: enumeration("bestFit", "customFit", "fitToHeight", "fitToWidth"),
|
|
251
|
+
}, true);
|
|
252
|
+
const metadata = properties({
|
|
253
|
+
lastModified: string(),
|
|
254
|
+
parent: string(),
|
|
255
|
+
pinned: boolean(),
|
|
256
|
+
type: enumeration("DocumentType", "CollectionType"),
|
|
257
|
+
visibleName: string(),
|
|
258
|
+
}, {
|
|
259
|
+
lastOpened: string(),
|
|
260
|
+
lastOpenedPage: uint32(),
|
|
261
|
+
createdTime: string(),
|
|
262
|
+
deleted: boolean(),
|
|
263
|
+
metadatamodified: boolean(),
|
|
264
|
+
modified: boolean(),
|
|
265
|
+
synced: boolean(),
|
|
266
|
+
version: uint8(),
|
|
267
|
+
}, true);
|
|
268
|
+
const updatedRootHash = properties({
|
|
269
|
+
hash: string(),
|
|
270
|
+
generation: float64(),
|
|
271
|
+
}, undefined, true);
|
|
272
|
+
const rootHash = properties({
|
|
273
|
+
hash: string(),
|
|
274
|
+
generation: float64(),
|
|
275
|
+
schemaVersion: uint8(),
|
|
276
|
+
}, undefined, true);
|
|
277
|
+
async function digest(buff) {
|
|
278
|
+
const digest = await crypto.subtle.digest("SHA-256", buff);
|
|
279
|
+
return [...new Uint8Array(digest)]
|
|
280
|
+
.map((x) => x.toString(16).padStart(2, "0"))
|
|
281
|
+
.join("");
|
|
282
|
+
}
|
|
283
|
+
class RawRemarkable {
|
|
284
|
+
#authedFetch;
|
|
285
|
+
#rawHost;
|
|
286
|
+
/**
|
|
287
|
+
* a cache of all hashes we know exist
|
|
288
|
+
*
|
|
289
|
+
* The backend is a readonly file system of hashes to content. After a hash has
|
|
290
|
+
* been read or written successfully, we know it exists, and potentially it's
|
|
291
|
+
* contents. We don't want to cache large binary files, but we can cache the
|
|
292
|
+
* small text based metadata files. For binary files we write null, so we know
|
|
293
|
+
* not to write a a cached value again, but we'll still need to read it.
|
|
294
|
+
*/
|
|
295
|
+
#cache;
|
|
296
|
+
constructor(authedFetch, cache, rawHost) {
|
|
297
|
+
this.#authedFetch = authedFetch;
|
|
298
|
+
this.#cache = cache;
|
|
299
|
+
this.#rawHost = rawHost;
|
|
300
|
+
}
|
|
301
|
+
/** make an authorized request to remarkable */
|
|
302
|
+
async getRootHash() {
|
|
303
|
+
const res = await this.#authedFetch("GET", `${this.#rawHost}/sync/v4/root`);
|
|
304
|
+
const raw = await res.text();
|
|
305
|
+
const loaded = JSON.parse(raw);
|
|
306
|
+
if (!rootHash.guardAssert(loaded))
|
|
307
|
+
throw Error("invalid root hash");
|
|
308
|
+
const { hash, generation, schemaVersion } = loaded;
|
|
309
|
+
if (schemaVersion !== 3) {
|
|
310
|
+
throw new Error(`schema version ${schemaVersion} not supported`);
|
|
311
|
+
}
|
|
312
|
+
else if (!Number.isSafeInteger(generation)) {
|
|
313
|
+
throw new Error(`generation ${generation} was not a safe integer; please file a bug report`);
|
|
314
|
+
}
|
|
315
|
+
else {
|
|
316
|
+
return [hash, generation];
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
async #getHash(hash) {
|
|
320
|
+
if (!hashReg.test(hash)) {
|
|
321
|
+
throw new ValidationError(hash, hashReg, "hash was not a valid hash");
|
|
322
|
+
}
|
|
323
|
+
const resp = await this.#authedFetch("GET", `${this.#rawHost}/sync/v3/files/${hash}`);
|
|
324
|
+
// TODO switch to `.bytes()`.
|
|
325
|
+
const raw = await resp.arrayBuffer();
|
|
326
|
+
return new Uint8Array(raw);
|
|
327
|
+
}
|
|
328
|
+
async getHash(hash) {
|
|
329
|
+
const cached = this.#cache.get(hash);
|
|
330
|
+
if (cached != null) {
|
|
331
|
+
const enc = new TextEncoder();
|
|
332
|
+
return enc.encode(cached);
|
|
333
|
+
}
|
|
334
|
+
else {
|
|
335
|
+
const res = await this.#getHash(hash);
|
|
336
|
+
// mark that we know hash exists
|
|
337
|
+
const cacheVal = this.#cache.get(hash);
|
|
338
|
+
if (cacheVal === undefined) {
|
|
339
|
+
this.#cache.set(hash, null);
|
|
340
|
+
}
|
|
341
|
+
return res;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
async getText(hash) {
|
|
345
|
+
const cached = this.#cache.get(hash);
|
|
346
|
+
if (cached != null) {
|
|
347
|
+
return cached;
|
|
348
|
+
}
|
|
349
|
+
else {
|
|
350
|
+
// NOTE two simultaneous requests will fetch twice
|
|
351
|
+
const raw = await this.#getHash(hash);
|
|
352
|
+
const dec = new TextDecoder();
|
|
353
|
+
const res = dec.decode(raw);
|
|
354
|
+
this.#cache.set(hash, res);
|
|
355
|
+
return res;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
async getEntries(hash) {
|
|
359
|
+
const rawFile = await this.getText(hash);
|
|
360
|
+
const [version, ...rest] = rawFile.slice(0, -1).split("\n");
|
|
361
|
+
if (version != "3") {
|
|
362
|
+
throw new Error(`schema version ${version} not supported`);
|
|
363
|
+
}
|
|
364
|
+
else {
|
|
365
|
+
return rest.map((line) => {
|
|
366
|
+
const [hash, type, id, subfiles, size] = line.split(":");
|
|
367
|
+
if (hash === undefined ||
|
|
368
|
+
type === undefined ||
|
|
369
|
+
id === undefined ||
|
|
370
|
+
subfiles === undefined ||
|
|
371
|
+
size === undefined) {
|
|
372
|
+
throw new Error(`line '${line}' was not formatted correctly`);
|
|
373
|
+
}
|
|
374
|
+
else if (type === "80000000") {
|
|
375
|
+
return {
|
|
376
|
+
hash,
|
|
377
|
+
type: 80000000,
|
|
378
|
+
id,
|
|
379
|
+
subfiles: parseInt(subfiles),
|
|
380
|
+
size: parseInt(size),
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
else if (type === "0" && subfiles === "0") {
|
|
384
|
+
return {
|
|
385
|
+
hash,
|
|
386
|
+
type: 0,
|
|
387
|
+
id,
|
|
388
|
+
subfiles: 0,
|
|
389
|
+
size: parseInt(size),
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
else {
|
|
393
|
+
throw new Error(`line '${line}' was not formatted correctly`);
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
async getContent(hash) {
|
|
399
|
+
const raw = await this.getText(hash);
|
|
400
|
+
const loaded = JSON.parse(raw);
|
|
401
|
+
// jtd can't verify non-discriminated unions, in this case, we have fileType
|
|
402
|
+
// defined or not. As a result, we only do a normal guard for the presence
|
|
403
|
+
// of tags (e.g. empty content or only specify tags). Otherwise we'll throw
|
|
404
|
+
// the full error for the richer content.
|
|
405
|
+
if (collectionContent.guard(loaded)) {
|
|
406
|
+
return loaded;
|
|
407
|
+
}
|
|
408
|
+
else if (documentContent.guardAssert(loaded)) {
|
|
409
|
+
return loaded;
|
|
410
|
+
}
|
|
411
|
+
else {
|
|
412
|
+
throw Error("invalid content");
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
async getMetadata(hash) {
|
|
416
|
+
const raw = await this.getText(hash);
|
|
417
|
+
const loaded = JSON.parse(raw);
|
|
418
|
+
if (!metadata.guardAssert(loaded))
|
|
419
|
+
throw Error("invalid metadata");
|
|
420
|
+
return loaded;
|
|
421
|
+
}
|
|
422
|
+
async putRootHash(hash, generation, broadcast = true) {
|
|
423
|
+
if (!Number.isSafeInteger(generation)) {
|
|
424
|
+
throw new Error(`generation ${generation} was not a safe integer`);
|
|
425
|
+
}
|
|
426
|
+
else if (!hashReg.test(hash)) {
|
|
427
|
+
throw new ValidationError(hash, hashReg, "rootHash was not a valid hash");
|
|
428
|
+
}
|
|
429
|
+
const body = JSON.stringify({
|
|
430
|
+
hash,
|
|
431
|
+
generation,
|
|
432
|
+
broadcast,
|
|
433
|
+
});
|
|
434
|
+
const resp = await this.#authedFetch("PUT", `${this.#rawHost}/sync/v3/root`, { body });
|
|
435
|
+
const raw = await resp.text();
|
|
436
|
+
const loaded = JSON.parse(raw);
|
|
437
|
+
if (!updatedRootHash.guardAssert(loaded))
|
|
438
|
+
throw Error("invalid root hash");
|
|
439
|
+
const { hash: newHash, generation: newGen } = loaded;
|
|
440
|
+
if (Number.isSafeInteger(newGen)) {
|
|
441
|
+
return [newHash, newGen];
|
|
442
|
+
}
|
|
443
|
+
else {
|
|
444
|
+
throw new Error(`new generation ${newGen} was not a safe integer; please file a bug report`);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
async #putFile(hash, fileName, bytes) {
|
|
448
|
+
// if the hash is already in the cache, writing is pointless
|
|
449
|
+
if (!this.#cache.has(hash)) {
|
|
450
|
+
const crc = CRC32C.buf(bytes, 0);
|
|
451
|
+
const buff = new ArrayBuffer(4);
|
|
452
|
+
new DataView(buff).setInt32(0, crc, false);
|
|
453
|
+
const crcHash = fromByteArray(new Uint8Array(buff));
|
|
454
|
+
await this.#authedFetch("PUT", `${this.#rawHost}/sync/v3/files/${hash}`, {
|
|
455
|
+
body: bytes,
|
|
456
|
+
headers: {
|
|
457
|
+
"rm-filename": fileName,
|
|
458
|
+
// eslint-disable-next-line spellcheck/spell-checker
|
|
459
|
+
"x-goog-hash": `crc32c=${crcHash}`,
|
|
460
|
+
},
|
|
461
|
+
});
|
|
462
|
+
// mark that we know this hash exists
|
|
463
|
+
const cacheVal = this.#cache.get(hash);
|
|
464
|
+
if (cacheVal === undefined) {
|
|
465
|
+
this.#cache.set(hash, null);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
async putFile(id, bytes) {
|
|
470
|
+
const hash = await digest(bytes);
|
|
471
|
+
const res = {
|
|
472
|
+
id,
|
|
473
|
+
hash,
|
|
474
|
+
type: 0,
|
|
475
|
+
subfiles: 0,
|
|
476
|
+
size: bytes.length,
|
|
477
|
+
};
|
|
478
|
+
return [res, this.#putFile(hash, id, bytes)];
|
|
479
|
+
}
|
|
480
|
+
async putText(id, text) {
|
|
481
|
+
const enc = new TextEncoder();
|
|
482
|
+
const bytes = enc.encode(text);
|
|
483
|
+
const [ent, upload] = await this.putFile(id, bytes);
|
|
484
|
+
return [
|
|
485
|
+
ent,
|
|
486
|
+
upload.then(() => {
|
|
487
|
+
// on success, write to cache
|
|
488
|
+
this.#cache.set(ent.hash, text);
|
|
489
|
+
}),
|
|
490
|
+
];
|
|
491
|
+
}
|
|
492
|
+
async putContent(id, content) {
|
|
493
|
+
if (!id.endsWith(".content")) {
|
|
494
|
+
throw new Error(`id ${id} did not end with '.content'`);
|
|
495
|
+
}
|
|
496
|
+
else {
|
|
497
|
+
return await this.putText(id, JSON.stringify(content));
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
async putMetadata(id, metadata) {
|
|
501
|
+
if (!id.endsWith(".metadata")) {
|
|
502
|
+
throw new Error(`id ${id} did not end with '.metadata'`);
|
|
503
|
+
}
|
|
504
|
+
else {
|
|
505
|
+
return await this.putText(id, JSON.stringify(metadata));
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
async putEntries(id, entries) {
|
|
509
|
+
// NOTE collections have a special hash function, the hash of their
|
|
510
|
+
// contents, so this needs to be different
|
|
511
|
+
entries.sort((a, b) => a.id.localeCompare(b.id));
|
|
512
|
+
const hashBuff = new Uint8Array(entries.length * 32);
|
|
513
|
+
for (const [start, { hash }] of entries.entries()) {
|
|
514
|
+
for (const [i, byte] of (hash.match(/../g) ?? []).entries()) {
|
|
515
|
+
hashBuff[start * 32 + i] = parseInt(byte, 16);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
const hash = await digest(hashBuff);
|
|
519
|
+
const size = entries.reduce((acc, ent) => acc + ent.size, 0);
|
|
520
|
+
const records = ["3\n"];
|
|
521
|
+
for (const { hash, type, id, subfiles, size } of entries) {
|
|
522
|
+
records.push(`${hash}:${type}:${id}:${subfiles}:${size}\n`);
|
|
523
|
+
}
|
|
524
|
+
const res = {
|
|
525
|
+
id,
|
|
526
|
+
hash,
|
|
527
|
+
type: 80000000,
|
|
528
|
+
subfiles: entries.length,
|
|
529
|
+
size,
|
|
530
|
+
};
|
|
531
|
+
const enc = new TextEncoder();
|
|
532
|
+
return [
|
|
533
|
+
res,
|
|
534
|
+
// NOTE when monitoring requests, this had the extension .docSchema appended, but I'm not entirely sure why
|
|
535
|
+
this.#putFile(hash, `${id}.docSchema`, enc.encode(records.join(""))),
|
|
536
|
+
];
|
|
537
|
+
}
|
|
538
|
+
dumpCache() {
|
|
539
|
+
return JSON.stringify(Object.fromEntries(this.#cache));
|
|
540
|
+
}
|
|
541
|
+
clearCache() {
|
|
542
|
+
this.#cache.clear();
|
|
543
|
+
}
|
|
544
|
+
}
|
|
149
545
|
/** the implementation of that api */
|
|
150
546
|
class Remarkable {
|
|
151
547
|
#userToken;
|
|
152
|
-
|
|
153
|
-
#
|
|
154
|
-
|
|
548
|
+
/** the same cache that underlies the raw api, allowing us to modify it */
|
|
549
|
+
#cache;
|
|
550
|
+
raw;
|
|
551
|
+
#lastHashGen;
|
|
552
|
+
constructor(userToken, rawHost, cache) {
|
|
155
553
|
this.#userToken = userToken;
|
|
156
|
-
this.#
|
|
157
|
-
this
|
|
554
|
+
this.#cache = cache;
|
|
555
|
+
this.raw = new RawRemarkable((method, url, { body, headers } = {}) => this.#authedFetch(url, { method, body, headers }), cache, rawHost);
|
|
556
|
+
}
|
|
557
|
+
async #getRootHash(refresh = false) {
|
|
558
|
+
if (refresh || this.#lastHashGen === undefined) {
|
|
559
|
+
this.#lastHashGen = await this.raw.getRootHash();
|
|
560
|
+
}
|
|
561
|
+
return this.#lastHashGen;
|
|
562
|
+
}
|
|
563
|
+
async #putRootHash(hash, generation) {
|
|
564
|
+
try {
|
|
565
|
+
this.#lastHashGen = await this.raw.putRootHash(hash, generation);
|
|
566
|
+
}
|
|
567
|
+
catch (ex) {
|
|
568
|
+
// if we hit a generation error, invalidate our cached generation
|
|
569
|
+
if (ex instanceof GenerationError) {
|
|
570
|
+
this.#lastHashGen = undefined;
|
|
571
|
+
}
|
|
572
|
+
throw ex;
|
|
573
|
+
}
|
|
158
574
|
}
|
|
159
|
-
/** make an authorized request to remarkable */
|
|
160
575
|
async #authedFetch(url, { body, method = "POST", headers = {}, }) {
|
|
161
|
-
const resp = await
|
|
576
|
+
const resp = await fetch(url, {
|
|
162
577
|
method,
|
|
163
578
|
headers: {
|
|
164
579
|
Authorization: `Bearer ${this.#userToken}`,
|
|
@@ -168,125 +583,365 @@ class Remarkable {
|
|
|
168
583
|
});
|
|
169
584
|
if (!resp.ok) {
|
|
170
585
|
const msg = await resp.text();
|
|
171
|
-
|
|
586
|
+
if (msg === '{"message":"precondition failed"}\n') {
|
|
587
|
+
throw new GenerationError();
|
|
588
|
+
}
|
|
589
|
+
else {
|
|
590
|
+
throw new ResponseError(resp.status, resp.statusText, `failed reMarkable request: ${msg}`);
|
|
591
|
+
}
|
|
172
592
|
}
|
|
173
593
|
else {
|
|
174
594
|
return resp;
|
|
175
595
|
}
|
|
176
596
|
}
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
597
|
+
async #convertEntry({ hash, id }) {
|
|
598
|
+
const entries = await this.raw.getEntries(hash);
|
|
599
|
+
const metaEnt = entries.find((ent) => ent.id.endsWith(".metadata"));
|
|
600
|
+
const contentEnt = entries.find((ent) => ent.id.endsWith(".content"));
|
|
601
|
+
if (metaEnt === undefined) {
|
|
602
|
+
throw new Error(`couldn't find metadata for hash ${hash}`);
|
|
603
|
+
}
|
|
604
|
+
else if (contentEnt === undefined) {
|
|
605
|
+
throw new Error(`couldn't find content for hash ${hash}`);
|
|
606
|
+
}
|
|
607
|
+
const [{ visibleName, lastModified, pinned, parent, lastOpened }, content] = await Promise.all([
|
|
608
|
+
this.raw.getMetadata(metaEnt.hash),
|
|
609
|
+
this.raw.getContent(contentEnt.hash),
|
|
610
|
+
]);
|
|
611
|
+
if (content.fileType === undefined) {
|
|
612
|
+
return {
|
|
613
|
+
id,
|
|
614
|
+
hash,
|
|
615
|
+
visibleName,
|
|
616
|
+
lastModified,
|
|
617
|
+
pinned,
|
|
618
|
+
parent,
|
|
619
|
+
tags: content.tags,
|
|
620
|
+
type: "CollectionType",
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
else {
|
|
624
|
+
return {
|
|
625
|
+
id,
|
|
626
|
+
hash,
|
|
627
|
+
visibleName,
|
|
628
|
+
lastModified,
|
|
629
|
+
pinned,
|
|
630
|
+
parent,
|
|
631
|
+
tags: content.tags,
|
|
632
|
+
lastOpened: lastOpened ?? "",
|
|
633
|
+
fileType: content.fileType,
|
|
634
|
+
type: "DocumentType",
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
/** list all items */
|
|
639
|
+
async listItems(refresh = false) {
|
|
640
|
+
const ids = await this.listIds(refresh);
|
|
641
|
+
return await Promise.all(ids.map((id) => this.#convertEntry(id)));
|
|
202
642
|
}
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
const
|
|
206
|
-
return
|
|
643
|
+
async listIds(refresh = false) {
|
|
644
|
+
const [hash] = await this.#getRootHash(refresh);
|
|
645
|
+
const entries = await this.raw.getEntries(hash);
|
|
646
|
+
return entries.map(({ id, hash }) => ({ id, hash }));
|
|
207
647
|
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
648
|
+
async getContent(hash) {
|
|
649
|
+
const entries = await this.raw.getEntries(hash);
|
|
650
|
+
const [cont] = entries.filter((e) => e.id.endsWith(".content"));
|
|
651
|
+
if (cont === undefined) {
|
|
652
|
+
throw new Error(`couldn't find contents for hash ${hash}`);
|
|
653
|
+
}
|
|
654
|
+
else {
|
|
655
|
+
return await this.raw.getContent(cont.hash);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
async getMetadata(hash) {
|
|
659
|
+
const entries = await this.raw.getEntries(hash);
|
|
660
|
+
const [meta] = entries.filter((e) => e.id.endsWith(".metadata"));
|
|
661
|
+
if (meta === undefined) {
|
|
662
|
+
throw new Error(`couldn't find metadata for hash ${hash}`);
|
|
663
|
+
}
|
|
664
|
+
else {
|
|
665
|
+
return await this.raw.getMetadata(meta.hash);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
async getPdf(hash) {
|
|
669
|
+
const entries = await this.raw.getEntries(hash);
|
|
670
|
+
const [pdf] = entries.filter((e) => e.id.endsWith(".pdf"));
|
|
671
|
+
if (pdf === undefined) {
|
|
672
|
+
throw new Error(`couldn't find pdf for hash ${hash}`);
|
|
673
|
+
}
|
|
674
|
+
else {
|
|
675
|
+
return await this.raw.getHash(pdf.hash);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
async getEpub(hash) {
|
|
679
|
+
const entries = await this.raw.getEntries(hash);
|
|
680
|
+
const [epub] = entries.filter((e) => e.id.endsWith(".epub"));
|
|
681
|
+
if (epub === undefined) {
|
|
682
|
+
throw new Error(`couldn't find epub for hash ${hash}`);
|
|
683
|
+
}
|
|
684
|
+
else {
|
|
685
|
+
return await this.raw.getHash(epub.hash);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
async getDocument(hash) {
|
|
689
|
+
const entries = await this.raw.getEntries(hash);
|
|
690
|
+
const zip = new JSZip();
|
|
691
|
+
for (const entry of entries) {
|
|
692
|
+
// TODO if this is .metadata we might want to assert type === "DocumentType"
|
|
693
|
+
zip.file(entry.id, this.raw.getHash(entry.hash));
|
|
694
|
+
}
|
|
695
|
+
return zip.generateAsync({ type: "uint8array" });
|
|
696
|
+
}
|
|
697
|
+
async #putFile(visibleName, fileType, buffer, { refresh, parent = "", pinned = false, zoomMode = "bestFit", viewBackgroundFilter, textScale = 1, textAlignment = "justify", fontName = "", coverPageNumber = -1, authors, title, publicationDate, publisher, extraMetadata = {}, lineHeight = -1, margins = 125, orientation = "portrait", tags, }) {
|
|
698
|
+
if (parent && !idReg.test(parent)) {
|
|
211
699
|
throw new ValidationError(parent, idReg, "parent must be a valid document id");
|
|
212
700
|
}
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
701
|
+
const id = uuid4();
|
|
702
|
+
const now = new Date();
|
|
703
|
+
const metadata = {
|
|
704
|
+
parent,
|
|
705
|
+
pinned,
|
|
706
|
+
lastModified: (+now).toFixed(),
|
|
707
|
+
createdTime: (+now).toFixed(),
|
|
708
|
+
type: "DocumentType",
|
|
709
|
+
visibleName,
|
|
710
|
+
lastOpened: "0",
|
|
711
|
+
lastOpenedPage: 0,
|
|
712
|
+
};
|
|
713
|
+
const content = {
|
|
714
|
+
coverPageNumber,
|
|
715
|
+
documentMetadata: { authors, title, publicationDate, publisher },
|
|
716
|
+
extraMetadata,
|
|
717
|
+
lineHeight,
|
|
718
|
+
margins,
|
|
719
|
+
orientation,
|
|
720
|
+
fileType,
|
|
721
|
+
formatVersion: 1,
|
|
722
|
+
tags: tags?.map((name) => ({ name, timestamp: +now })) ?? [],
|
|
723
|
+
fontName,
|
|
724
|
+
textAlignment,
|
|
725
|
+
textScale,
|
|
726
|
+
zoomMode,
|
|
727
|
+
viewBackgroundFilter,
|
|
728
|
+
// NOTE for some reason we need to "fake" the number of pages at 1, and
|
|
729
|
+
// create "valid" output for that
|
|
730
|
+
originalPageCount: 1,
|
|
731
|
+
pageCount: 1,
|
|
732
|
+
pageTags: [],
|
|
733
|
+
pages: [uuid4()],
|
|
734
|
+
redirectionPageMap: [0],
|
|
735
|
+
sizeInBytes: buffer.length.toFixed(),
|
|
736
|
+
};
|
|
737
|
+
// upload raw files, and get root hash
|
|
738
|
+
const [[contentEntry, uploadContent], [metadataEntry, uploadMetadata], [pagedataEntry, uploadPagedata], [fileEntry, uploadFile], [rootHash, generation],] = await Promise.all([
|
|
739
|
+
this.raw.putContent(`${id}.content`, content),
|
|
740
|
+
this.raw.putMetadata(`${id}.metadata`, metadata),
|
|
741
|
+
// eslint-disable-next-line spellcheck/spell-checker
|
|
742
|
+
this.raw.putText(`${id}.pagedata`, "\n"),
|
|
743
|
+
this.raw.putFile(`${id}.${fileType}`, buffer),
|
|
744
|
+
this.#getRootHash(refresh),
|
|
745
|
+
]);
|
|
746
|
+
// now fetch root entries and upload this file entry
|
|
747
|
+
const [[collectionEntry, uploadCollection], rootEntries] = await Promise.all([
|
|
748
|
+
this.raw.putEntries(id, [
|
|
749
|
+
contentEntry,
|
|
750
|
+
metadataEntry,
|
|
751
|
+
pagedataEntry,
|
|
752
|
+
fileEntry,
|
|
753
|
+
]),
|
|
754
|
+
this.raw.getEntries(rootHash),
|
|
755
|
+
]);
|
|
756
|
+
// now upload a new root entry
|
|
757
|
+
rootEntries.push(collectionEntry);
|
|
758
|
+
const [rootEntry, uploadRoot] = await this.raw.putEntries("root", rootEntries);
|
|
759
|
+
// before updating the root hash, first upload everything
|
|
760
|
+
await Promise.all([
|
|
761
|
+
uploadContent,
|
|
762
|
+
uploadMetadata,
|
|
763
|
+
uploadPagedata,
|
|
764
|
+
uploadFile,
|
|
765
|
+
uploadCollection,
|
|
766
|
+
uploadRoot,
|
|
767
|
+
]);
|
|
768
|
+
// TODO we could return a full entry here, but we should probably decide
|
|
769
|
+
// what that should be, e.g. we could return more fields than the standard
|
|
770
|
+
// entry. Same for putFolder
|
|
771
|
+
// TODO we should also decide if the api should take hashes or ids...
|
|
772
|
+
await this.#putRootHash(rootEntry.hash, generation);
|
|
773
|
+
return { id, hash: collectionEntry.hash };
|
|
774
|
+
}
|
|
775
|
+
async putPdf(visibleName, buffer, opts = {}) {
|
|
776
|
+
return await this.#putFile(visibleName, "pdf", buffer, opts);
|
|
777
|
+
}
|
|
778
|
+
async putEpub(visibleName, buffer, opts = {}) {
|
|
779
|
+
return await this.#putFile(visibleName, "epub", buffer, opts);
|
|
220
780
|
}
|
|
221
781
|
/** create a folder */
|
|
222
|
-
async createFolder(visibleName, { parent = ""
|
|
223
|
-
|
|
782
|
+
async createFolder(visibleName, { parent = "" } = {}, refresh = false) {
|
|
783
|
+
if (parent && !idReg.test(parent)) {
|
|
784
|
+
throw new ValidationError(parent, idReg, "parent must be a valid document id");
|
|
785
|
+
}
|
|
786
|
+
const id = uuid4();
|
|
787
|
+
const now = new Date();
|
|
788
|
+
const content = {
|
|
789
|
+
tags: [],
|
|
790
|
+
};
|
|
791
|
+
const metadata = {
|
|
792
|
+
lastModified: (+now).toFixed(),
|
|
793
|
+
createdTime: (+now).toFixed(),
|
|
794
|
+
parent,
|
|
795
|
+
pinned: false,
|
|
796
|
+
type: "CollectionType",
|
|
797
|
+
visibleName,
|
|
798
|
+
};
|
|
799
|
+
// upload folder contents
|
|
800
|
+
const [[contentEntry, uploadContent], [metadataEntry, uploadMetadata], [rootHash, generation],] = await Promise.all([
|
|
801
|
+
this.raw.putContent(`${id}.content`, content),
|
|
802
|
+
this.raw.putMetadata(`${id}.metadata`, metadata),
|
|
803
|
+
this.#getRootHash(refresh),
|
|
804
|
+
]);
|
|
805
|
+
// now fetch root entries and upload this file entry
|
|
806
|
+
const [[collectionEntry, uploadCollection], rootEntries] = await Promise.all([
|
|
807
|
+
this.raw.putEntries(id, [contentEntry, metadataEntry]),
|
|
808
|
+
this.raw.getEntries(rootHash),
|
|
809
|
+
]);
|
|
810
|
+
// now upload a new root entry
|
|
811
|
+
rootEntries.push(collectionEntry);
|
|
812
|
+
const [rootEntry, uploadRoot] = await this.raw.putEntries("root", rootEntries);
|
|
813
|
+
// before updating the root hash, first upload everything
|
|
814
|
+
await Promise.all([
|
|
815
|
+
uploadContent,
|
|
816
|
+
uploadMetadata,
|
|
817
|
+
uploadCollection,
|
|
818
|
+
uploadRoot,
|
|
819
|
+
]);
|
|
820
|
+
// put root hash and return
|
|
821
|
+
await this.#putRootHash(rootEntry.hash, generation);
|
|
822
|
+
return { id, hash: collectionEntry.hash };
|
|
224
823
|
}
|
|
225
824
|
/** upload an epub */
|
|
226
|
-
async uploadEpub(visibleName, buffer,
|
|
227
|
-
return await this
|
|
825
|
+
async uploadEpub(visibleName, buffer, opts = {}) {
|
|
826
|
+
return await this.putEpub(visibleName, buffer, opts);
|
|
228
827
|
}
|
|
229
828
|
/** upload a pdf */
|
|
230
|
-
async uploadPdf(visibleName, buffer,
|
|
231
|
-
return await this
|
|
829
|
+
async uploadPdf(visibleName, buffer, opts = {}) {
|
|
830
|
+
return await this.putPdf(visibleName, buffer, opts);
|
|
232
831
|
}
|
|
233
|
-
async #
|
|
234
|
-
|
|
235
|
-
|
|
832
|
+
async #editMetaRaw(id, hash, update) {
|
|
833
|
+
const entries = await this.raw.getEntries(hash);
|
|
834
|
+
const metaInd = entries.findIndex((ent) => ent.id.endsWith(".metadata"));
|
|
835
|
+
const metaEntry = entries[metaInd];
|
|
836
|
+
if (metaEntry === undefined) {
|
|
837
|
+
throw new Error("internal error: couldn't find metadata in entry hash");
|
|
236
838
|
}
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
});
|
|
243
|
-
return
|
|
839
|
+
const meta = await this.raw.getMetadata(metaEntry.hash);
|
|
840
|
+
Object.assign(meta, update);
|
|
841
|
+
const [newMetaEntry, uploadMeta] = await this.raw.putMetadata(metaEntry.id, meta);
|
|
842
|
+
entries[metaInd] = newMetaEntry;
|
|
843
|
+
const [result, uploadentries] = await this.raw.putEntries(id, entries);
|
|
844
|
+
const upload = Promise.all([uploadMeta, uploadentries]).then(() => { });
|
|
845
|
+
return [result, upload];
|
|
846
|
+
}
|
|
847
|
+
async #editMeta(hash, update, refresh = false) {
|
|
848
|
+
const [rootHash, generation] = await this.#getRootHash(refresh);
|
|
849
|
+
const entries = await this.raw.getEntries(rootHash);
|
|
850
|
+
const hashInd = entries.findIndex((ent) => ent.hash === hash);
|
|
851
|
+
const hashEnt = entries[hashInd];
|
|
852
|
+
if (hashEnt === undefined) {
|
|
853
|
+
throw new HashNotFoundError(hash);
|
|
854
|
+
}
|
|
855
|
+
const [newEnt, uploadEnt] = await this.#editMetaRaw(hashEnt.id, hash, update);
|
|
856
|
+
entries[hashInd] = newEnt;
|
|
857
|
+
const [rootEntry, uploadRoot] = await this.raw.putEntries("root", entries);
|
|
858
|
+
await Promise.all([uploadEnt, uploadRoot]);
|
|
859
|
+
await this.#putRootHash(rootEntry.hash, generation);
|
|
860
|
+
return { hash: newEnt.hash };
|
|
244
861
|
}
|
|
245
862
|
/** move an entry */
|
|
246
|
-
async move(hash, parent,
|
|
247
|
-
if (
|
|
863
|
+
async move(hash, parent, refresh = false) {
|
|
864
|
+
if (!idReg.test(parent)) {
|
|
248
865
|
throw new ValidationError(parent, idReg, "parent must be a valid document id");
|
|
249
866
|
}
|
|
250
|
-
return await this.#
|
|
867
|
+
return await this.#editMeta(hash, { parent }, refresh);
|
|
251
868
|
}
|
|
252
869
|
/** delete an entry */
|
|
253
|
-
async delete(hash,
|
|
254
|
-
return await this.move(hash, "trash",
|
|
870
|
+
async delete(hash, refresh = false) {
|
|
871
|
+
return await this.move(hash, "trash", refresh);
|
|
255
872
|
}
|
|
256
873
|
/** rename an entry */
|
|
257
|
-
async rename(hash, visibleName,
|
|
258
|
-
return await this.#
|
|
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");
|
|
266
|
-
}
|
|
267
|
-
}
|
|
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",
|
|
275
|
-
});
|
|
276
|
-
return verification(res, hashesEntry, verify);
|
|
874
|
+
async rename(hash, visibleName, refresh = false) {
|
|
875
|
+
return await this.#editMeta(hash, { visibleName }, refresh);
|
|
277
876
|
}
|
|
278
877
|
/** move many hashes */
|
|
279
|
-
async bulkMove(hashes, parent,
|
|
280
|
-
if (
|
|
878
|
+
async bulkMove(hashes, parent, refresh = false) {
|
|
879
|
+
if (!idReg.test(parent)) {
|
|
281
880
|
throw new ValidationError(parent, idReg, "parent must be a valid document id");
|
|
282
881
|
}
|
|
283
|
-
|
|
882
|
+
const [rootHash, generation] = await this.#getRootHash(refresh);
|
|
883
|
+
const entries = await this.raw.getEntries(rootHash);
|
|
884
|
+
const hashSet = new Set(hashes);
|
|
885
|
+
const toUpdate = [];
|
|
886
|
+
const newEntries = [];
|
|
887
|
+
for (const entry of entries) {
|
|
888
|
+
const part = hashSet.has(entry.hash) ? toUpdate : newEntries;
|
|
889
|
+
part.push(entry);
|
|
890
|
+
}
|
|
891
|
+
const resolved = await Promise.all(toUpdate.map(({ id, hash }) => this.#editMetaRaw(id, hash, { parent })));
|
|
892
|
+
const uploads = [];
|
|
893
|
+
const result = {};
|
|
894
|
+
for (const [i, [newEnt, upload]] of resolved.entries()) {
|
|
895
|
+
newEntries.push(newEnt);
|
|
896
|
+
uploads.push(upload);
|
|
897
|
+
result[toUpdate[i].hash] = newEnt.hash;
|
|
898
|
+
}
|
|
899
|
+
const [rootEntry, uploadRoot] = await this.raw.putEntries("root", newEntries);
|
|
900
|
+
uploads.push(uploadRoot);
|
|
901
|
+
await Promise.all(uploads);
|
|
902
|
+
await this.#putRootHash(rootEntry.hash, generation);
|
|
903
|
+
return { hashes: result };
|
|
284
904
|
}
|
|
285
905
|
/** delete many hashes */
|
|
286
|
-
async bulkDelete(hashes,
|
|
287
|
-
return await this.bulkMove(hashes, "trash",
|
|
906
|
+
async bulkDelete(hashes, refresh = false) {
|
|
907
|
+
return await this.bulkMove(hashes, "trash", refresh);
|
|
908
|
+
}
|
|
909
|
+
/** dump the raw cache */
|
|
910
|
+
dumpCache() {
|
|
911
|
+
return this.raw.dumpCache();
|
|
912
|
+
}
|
|
913
|
+
async pruneCache(refresh) {
|
|
914
|
+
const [rootHash] = await this.#getRootHash(refresh);
|
|
915
|
+
// the keys to delete, we'll drop every key we can currently reach
|
|
916
|
+
const toDelete = new Set(this.#cache.keys());
|
|
917
|
+
// bfs through entries (to semi-optimize promise waiting, although this
|
|
918
|
+
// should only go one step) to track all hashes encountered
|
|
919
|
+
// NOTE that we could increase the cache in this process, or it's possible
|
|
920
|
+
// for other calls to increase the cache with misc values.
|
|
921
|
+
let entries = [await this.raw.getEntries(rootHash)];
|
|
922
|
+
let nextEntries = [];
|
|
923
|
+
while (entries.length) {
|
|
924
|
+
for (const entryList of entries) {
|
|
925
|
+
for (const { hash, type } of entryList) {
|
|
926
|
+
toDelete.add(hash);
|
|
927
|
+
if (type === 80000000) {
|
|
928
|
+
nextEntries.push(this.raw.getEntries(hash));
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
entries = await Promise.all(nextEntries);
|
|
933
|
+
nextEntries = [];
|
|
934
|
+
}
|
|
935
|
+
for (const key of toDelete) {
|
|
936
|
+
this.#cache.delete(key);
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
// finally remove any values we had in the cache initially, but couldn't reach
|
|
940
|
+
clearCache() {
|
|
941
|
+
this.raw.clearCache();
|
|
288
942
|
}
|
|
289
943
|
}
|
|
944
|
+
const cached = values(nullable(string()));
|
|
290
945
|
/**
|
|
291
946
|
* create an instance of the api
|
|
292
947
|
*
|
|
@@ -297,7 +952,7 @@ class Remarkable {
|
|
|
297
952
|
* registered. Create one with {@link register}.
|
|
298
953
|
* @returns an api instance
|
|
299
954
|
*/
|
|
300
|
-
export async function remarkable(deviceToken, {
|
|
955
|
+
export async function remarkable(deviceToken, { authHost = AUTH_HOST, rawHost = RAW_HOST, cache, maxCacheSize = Infinity, } = {}) {
|
|
301
956
|
const resp = await fetch(`${authHost}/token/json/2/user/new`, {
|
|
302
957
|
method: "POST",
|
|
303
958
|
headers: {
|
|
@@ -308,5 +963,15 @@ export async function remarkable(deviceToken, { fetch = globalThis.fetch, authHo
|
|
|
308
963
|
throw new Error(`couldn't fetch auth token: ${resp.statusText}`);
|
|
309
964
|
}
|
|
310
965
|
const userToken = await resp.text();
|
|
311
|
-
|
|
966
|
+
const initCache = JSON.parse(cache ?? "{}");
|
|
967
|
+
if (cached.guard(initCache)) {
|
|
968
|
+
const entries = Object.entries(initCache);
|
|
969
|
+
const cache = maxCacheSize === Infinity
|
|
970
|
+
? new Map(entries)
|
|
971
|
+
: new LruCache(maxCacheSize, entries);
|
|
972
|
+
return new Remarkable(userToken, rawHost, cache);
|
|
973
|
+
}
|
|
974
|
+
else {
|
|
975
|
+
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.");
|
|
976
|
+
}
|
|
312
977
|
}
|