rmapi-js 8.1.0 → 8.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/error.d.ts +14 -0
- package/dist/error.js +21 -0
- package/dist/index.d.ts +50 -526
- package/dist/index.js +65 -450
- package/dist/raw.d.ts +538 -0
- package/dist/raw.js +426 -0
- package/dist/rmapi-js.esm.min.js +12 -12
- package/package.json +12 -12
package/dist/raw.js
ADDED
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
import { fromByteArray } from "base64-js";
|
|
2
|
+
import CRC32C from "crc-32/crc32c";
|
|
3
|
+
import { boolean, elements, empty, enumeration, float64, int32, properties, string, timestamp, uint32, uint8, values, } from "jtd-ts";
|
|
4
|
+
import { ValidationError } from "./error.js";
|
|
5
|
+
const hashReg = /^[0-9a-f]{64}$/;
|
|
6
|
+
const tag = properties({
|
|
7
|
+
name: string(),
|
|
8
|
+
timestamp: float64(),
|
|
9
|
+
}, undefined, true);
|
|
10
|
+
const pageTag = properties({
|
|
11
|
+
name: string(),
|
|
12
|
+
pageId: string(),
|
|
13
|
+
timestamp: float64(),
|
|
14
|
+
}, undefined, true);
|
|
15
|
+
const documentMetadata = properties(undefined, {
|
|
16
|
+
authors: elements(string()),
|
|
17
|
+
title: string(),
|
|
18
|
+
publicationDate: string(),
|
|
19
|
+
publisher: string(),
|
|
20
|
+
}, true);
|
|
21
|
+
const cPagePage = properties({
|
|
22
|
+
id: string(),
|
|
23
|
+
idx: properties({
|
|
24
|
+
timestamp: string(),
|
|
25
|
+
value: string(),
|
|
26
|
+
}, undefined, true),
|
|
27
|
+
}, {
|
|
28
|
+
template: properties({
|
|
29
|
+
timestamp: string(),
|
|
30
|
+
value: string(),
|
|
31
|
+
}, undefined, true),
|
|
32
|
+
redir: properties({
|
|
33
|
+
timestamp: string(),
|
|
34
|
+
value: int32(),
|
|
35
|
+
}, undefined, true),
|
|
36
|
+
scrollTime: properties({
|
|
37
|
+
timestamp: string(),
|
|
38
|
+
value: timestamp(),
|
|
39
|
+
}, undefined, true),
|
|
40
|
+
verticalScroll: properties({
|
|
41
|
+
timestamp: string(),
|
|
42
|
+
value: float64(),
|
|
43
|
+
}, undefined, true),
|
|
44
|
+
deleted: properties({
|
|
45
|
+
timestamp: string(),
|
|
46
|
+
value: int32(),
|
|
47
|
+
}, undefined, true),
|
|
48
|
+
}, true);
|
|
49
|
+
const cPages = properties({
|
|
50
|
+
lastOpened: properties({
|
|
51
|
+
timestamp: string(),
|
|
52
|
+
value: string(),
|
|
53
|
+
}, undefined, true),
|
|
54
|
+
original: properties({
|
|
55
|
+
timestamp: string(),
|
|
56
|
+
value: int32(),
|
|
57
|
+
}, undefined, true),
|
|
58
|
+
pages: elements(cPagePage),
|
|
59
|
+
uuids: elements(properties({
|
|
60
|
+
first: string(),
|
|
61
|
+
second: uint32(),
|
|
62
|
+
}, undefined, true)),
|
|
63
|
+
}, undefined, true);
|
|
64
|
+
const collectionContent = properties(undefined, {
|
|
65
|
+
tags: elements(tag),
|
|
66
|
+
});
|
|
67
|
+
const documentContent = properties({
|
|
68
|
+
coverPageNumber: int32(),
|
|
69
|
+
documentMetadata,
|
|
70
|
+
extraMetadata: values(string()),
|
|
71
|
+
fileType: enumeration("epub", "notebook", "pdf"),
|
|
72
|
+
fontName: string(),
|
|
73
|
+
formatVersion: uint8(),
|
|
74
|
+
lineHeight: int32(),
|
|
75
|
+
orientation: enumeration("portrait", "landscape"),
|
|
76
|
+
pageCount: uint32(),
|
|
77
|
+
sizeInBytes: string(),
|
|
78
|
+
textAlignment: enumeration("justify", "left"),
|
|
79
|
+
textScale: float64(),
|
|
80
|
+
}, {
|
|
81
|
+
cPages,
|
|
82
|
+
customZoomCenterX: float64(),
|
|
83
|
+
customZoomCenterY: float64(),
|
|
84
|
+
customZoomOrientation: enumeration("portrait", "landscape"),
|
|
85
|
+
customZoomPageHeight: float64(),
|
|
86
|
+
customZoomPageWidth: float64(),
|
|
87
|
+
customZoomScale: float64(),
|
|
88
|
+
dummyDocument: boolean(),
|
|
89
|
+
keyboardMetadata: properties({
|
|
90
|
+
count: uint32(),
|
|
91
|
+
timestamp: float64(),
|
|
92
|
+
}, undefined, true),
|
|
93
|
+
lastOpenedPage: uint32(),
|
|
94
|
+
margins: uint32(),
|
|
95
|
+
originalPageCount: int32(),
|
|
96
|
+
pages: elements(string()),
|
|
97
|
+
pageTags: elements(pageTag),
|
|
98
|
+
redirectionPageMap: elements(int32()),
|
|
99
|
+
tags: elements(tag),
|
|
100
|
+
transform: properties({
|
|
101
|
+
m11: float64(),
|
|
102
|
+
m12: float64(),
|
|
103
|
+
m13: float64(),
|
|
104
|
+
m21: float64(),
|
|
105
|
+
m22: float64(),
|
|
106
|
+
m23: float64(),
|
|
107
|
+
m31: float64(),
|
|
108
|
+
m32: float64(),
|
|
109
|
+
m33: float64(),
|
|
110
|
+
}, undefined, true),
|
|
111
|
+
// eslint-disable-next-line spellcheck/spell-checker
|
|
112
|
+
viewBackgroundFilter: enumeration("off", "fullpage"),
|
|
113
|
+
zoomMode: enumeration("bestFit", "customFit", "fitToHeight", "fitToWidth"),
|
|
114
|
+
}, true);
|
|
115
|
+
const templateContent = properties({
|
|
116
|
+
name: string(),
|
|
117
|
+
author: string(),
|
|
118
|
+
iconData: string(),
|
|
119
|
+
categories: elements(string()),
|
|
120
|
+
labels: elements(string()),
|
|
121
|
+
orientation: enumeration("portrait", "landscape"),
|
|
122
|
+
templateVersion: string(),
|
|
123
|
+
formatVersion: uint8(),
|
|
124
|
+
supportedScreens: elements(enumeration("rm2", "rmPP")),
|
|
125
|
+
constants: elements(values(int32())),
|
|
126
|
+
items: elements(empty()),
|
|
127
|
+
});
|
|
128
|
+
const metadata = properties({
|
|
129
|
+
lastModified: string(),
|
|
130
|
+
parent: string(),
|
|
131
|
+
pinned: boolean(),
|
|
132
|
+
type: enumeration("DocumentType", "CollectionType", "TemplateType"),
|
|
133
|
+
visibleName: string(),
|
|
134
|
+
}, {
|
|
135
|
+
lastOpened: string(),
|
|
136
|
+
lastOpenedPage: uint32(),
|
|
137
|
+
createdTime: string(),
|
|
138
|
+
deleted: boolean(),
|
|
139
|
+
metadatamodified: boolean(),
|
|
140
|
+
modified: boolean(),
|
|
141
|
+
synced: boolean(),
|
|
142
|
+
version: uint8(),
|
|
143
|
+
}, true);
|
|
144
|
+
const updatedRootHash = properties({
|
|
145
|
+
hash: string(),
|
|
146
|
+
generation: float64(),
|
|
147
|
+
}, undefined, true);
|
|
148
|
+
const rootHash = properties({
|
|
149
|
+
hash: string(),
|
|
150
|
+
generation: float64(),
|
|
151
|
+
schemaVersion: uint8(),
|
|
152
|
+
}, undefined, true);
|
|
153
|
+
async function digest(buff) {
|
|
154
|
+
const digest = await crypto.subtle.digest("SHA-256", buff);
|
|
155
|
+
return [...new Uint8Array(digest)]
|
|
156
|
+
.map((x) => x.toString(16).padStart(2, "0"))
|
|
157
|
+
.join("");
|
|
158
|
+
}
|
|
159
|
+
export class RawRemarkable {
|
|
160
|
+
#authedFetch;
|
|
161
|
+
#rawHost;
|
|
162
|
+
/**
|
|
163
|
+
* a cache of all hashes we know exist
|
|
164
|
+
*
|
|
165
|
+
* The backend is a readonly file system of hashes to content. After a hash has
|
|
166
|
+
* been read or written successfully, we know it exists, and potentially it's
|
|
167
|
+
* contents. We don't want to cache large binary files, but we can cache the
|
|
168
|
+
* small text based metadata files. For binary files we write null, so we know
|
|
169
|
+
* not to write a a cached value again, but we'll still need to read it.
|
|
170
|
+
*/
|
|
171
|
+
#cache;
|
|
172
|
+
constructor(authedFetch, cache, rawHost) {
|
|
173
|
+
this.#authedFetch = authedFetch;
|
|
174
|
+
this.#cache = cache;
|
|
175
|
+
this.#rawHost = rawHost;
|
|
176
|
+
}
|
|
177
|
+
/** make an authorized request to remarkable */
|
|
178
|
+
async getRootHash() {
|
|
179
|
+
const res = await this.#authedFetch("GET", `${this.#rawHost}/sync/v4/root`);
|
|
180
|
+
const raw = await res.text();
|
|
181
|
+
const loaded = JSON.parse(raw);
|
|
182
|
+
if (!rootHash.guardAssert(loaded))
|
|
183
|
+
throw Error("invalid root hash");
|
|
184
|
+
const { hash, generation, schemaVersion } = loaded;
|
|
185
|
+
if (schemaVersion !== 3) {
|
|
186
|
+
throw new Error(`schema version ${schemaVersion} not supported`);
|
|
187
|
+
}
|
|
188
|
+
else if (!Number.isSafeInteger(generation)) {
|
|
189
|
+
throw new Error(`generation ${generation} was not a safe integer; please file a bug report`);
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
return [hash, generation];
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
async #getHash(hash) {
|
|
196
|
+
if (!hashReg.test(hash)) {
|
|
197
|
+
throw new ValidationError(hash, hashReg, "hash was not a valid hash");
|
|
198
|
+
}
|
|
199
|
+
const resp = await this.#authedFetch("GET", `${this.#rawHost}/sync/v3/files/${hash}`);
|
|
200
|
+
// TODO switch to `.bytes()`.
|
|
201
|
+
const raw = await resp.arrayBuffer();
|
|
202
|
+
return new Uint8Array(raw);
|
|
203
|
+
}
|
|
204
|
+
async getHash(hash) {
|
|
205
|
+
const cached = this.#cache.get(hash);
|
|
206
|
+
if (cached != null) {
|
|
207
|
+
const enc = new TextEncoder();
|
|
208
|
+
return enc.encode(cached);
|
|
209
|
+
}
|
|
210
|
+
else {
|
|
211
|
+
const res = await this.#getHash(hash);
|
|
212
|
+
// mark that we know hash exists
|
|
213
|
+
const cacheVal = this.#cache.get(hash);
|
|
214
|
+
if (cacheVal === undefined) {
|
|
215
|
+
this.#cache.set(hash, null);
|
|
216
|
+
}
|
|
217
|
+
return res;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
async getText(hash) {
|
|
221
|
+
const cached = this.#cache.get(hash);
|
|
222
|
+
if (cached != null) {
|
|
223
|
+
return cached;
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
// NOTE two simultaneous requests will fetch twice
|
|
227
|
+
const raw = await this.#getHash(hash);
|
|
228
|
+
const dec = new TextDecoder();
|
|
229
|
+
const res = dec.decode(raw);
|
|
230
|
+
this.#cache.set(hash, res);
|
|
231
|
+
return res;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
async getEntries(hash) {
|
|
235
|
+
const rawFile = await this.getText(hash);
|
|
236
|
+
const [version, ...rest] = rawFile.slice(0, -1).split("\n");
|
|
237
|
+
if (version != "3") {
|
|
238
|
+
throw new Error(`schema version ${version} not supported`);
|
|
239
|
+
}
|
|
240
|
+
else {
|
|
241
|
+
return rest.map((line) => {
|
|
242
|
+
const [hash, type, id, subfiles, size] = line.split(":");
|
|
243
|
+
if (hash === undefined ||
|
|
244
|
+
type === undefined ||
|
|
245
|
+
id === undefined ||
|
|
246
|
+
subfiles === undefined ||
|
|
247
|
+
size === undefined) {
|
|
248
|
+
throw new Error(`line '${line}' was not formatted correctly`);
|
|
249
|
+
}
|
|
250
|
+
else if (type === "80000000") {
|
|
251
|
+
return {
|
|
252
|
+
hash,
|
|
253
|
+
type: 80000000,
|
|
254
|
+
id,
|
|
255
|
+
subfiles: parseInt(subfiles),
|
|
256
|
+
size: parseInt(size),
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
else if (type === "0" && subfiles === "0") {
|
|
260
|
+
return {
|
|
261
|
+
hash,
|
|
262
|
+
type: 0,
|
|
263
|
+
id,
|
|
264
|
+
subfiles: 0,
|
|
265
|
+
size: parseInt(size),
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
else {
|
|
269
|
+
throw new Error(`line '${line}' was not formatted correctly`);
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
async getContent(hash) {
|
|
275
|
+
const raw = await this.getText(hash);
|
|
276
|
+
const loaded = JSON.parse(raw);
|
|
277
|
+
// jtd can't verify non-discriminated unions, in this case, we have fileType
|
|
278
|
+
// defined or not. As a result, we try each, and concatenate the errors at the end
|
|
279
|
+
const errors = [];
|
|
280
|
+
for (const [name, valid] of [
|
|
281
|
+
["collection", collectionContent],
|
|
282
|
+
["template", templateContent],
|
|
283
|
+
["document", documentContent],
|
|
284
|
+
]) {
|
|
285
|
+
try {
|
|
286
|
+
if (valid.guardAssert(loaded))
|
|
287
|
+
return loaded;
|
|
288
|
+
}
|
|
289
|
+
catch (ex) {
|
|
290
|
+
const msg = ex instanceof Error ? ex.message : "unknown error type";
|
|
291
|
+
errors.push(`Couldn't validate as ${name} because:\n${msg}`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
const joined = errors.join("\n\nor\n\n");
|
|
295
|
+
throw new Error(`invalid content: ${joined}`);
|
|
296
|
+
}
|
|
297
|
+
async getMetadata(hash) {
|
|
298
|
+
const raw = await this.getText(hash);
|
|
299
|
+
const loaded = JSON.parse(raw);
|
|
300
|
+
if (!metadata.guardAssert(loaded))
|
|
301
|
+
throw Error("invalid metadata");
|
|
302
|
+
return loaded;
|
|
303
|
+
}
|
|
304
|
+
async putRootHash(hash, generation, broadcast = true) {
|
|
305
|
+
if (!Number.isSafeInteger(generation)) {
|
|
306
|
+
throw new Error(`generation ${generation} was not a safe integer`);
|
|
307
|
+
}
|
|
308
|
+
else if (!hashReg.test(hash)) {
|
|
309
|
+
throw new ValidationError(hash, hashReg, "rootHash was not a valid hash");
|
|
310
|
+
}
|
|
311
|
+
const body = JSON.stringify({
|
|
312
|
+
hash,
|
|
313
|
+
generation,
|
|
314
|
+
broadcast,
|
|
315
|
+
});
|
|
316
|
+
const resp = await this.#authedFetch("PUT", `${this.#rawHost}/sync/v3/root`, { body });
|
|
317
|
+
const raw = await resp.text();
|
|
318
|
+
const loaded = JSON.parse(raw);
|
|
319
|
+
if (!updatedRootHash.guardAssert(loaded))
|
|
320
|
+
throw Error("invalid root hash");
|
|
321
|
+
const { hash: newHash, generation: newGen } = loaded;
|
|
322
|
+
if (Number.isSafeInteger(newGen)) {
|
|
323
|
+
return [newHash, newGen];
|
|
324
|
+
}
|
|
325
|
+
else {
|
|
326
|
+
throw new Error(`new generation ${newGen} was not a safe integer; please file a bug report`);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
async #putFile(hash, fileName, bytes) {
|
|
330
|
+
// if the hash is already in the cache, writing is pointless
|
|
331
|
+
if (!this.#cache.has(hash)) {
|
|
332
|
+
const crc = CRC32C.buf(bytes, 0);
|
|
333
|
+
const buff = new ArrayBuffer(4);
|
|
334
|
+
new DataView(buff).setInt32(0, crc, false);
|
|
335
|
+
const crcHash = fromByteArray(new Uint8Array(buff));
|
|
336
|
+
await this.#authedFetch("PUT", `${this.#rawHost}/sync/v3/files/${hash}`, {
|
|
337
|
+
body: bytes,
|
|
338
|
+
headers: {
|
|
339
|
+
"rm-filename": fileName,
|
|
340
|
+
// eslint-disable-next-line spellcheck/spell-checker
|
|
341
|
+
"x-goog-hash": `crc32c=${crcHash}`,
|
|
342
|
+
},
|
|
343
|
+
});
|
|
344
|
+
// mark that we know this hash exists
|
|
345
|
+
const cacheVal = this.#cache.get(hash);
|
|
346
|
+
if (cacheVal === undefined) {
|
|
347
|
+
this.#cache.set(hash, null);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
async putFile(id, bytes) {
|
|
352
|
+
const hash = await digest(bytes);
|
|
353
|
+
const res = {
|
|
354
|
+
id,
|
|
355
|
+
hash,
|
|
356
|
+
type: 0,
|
|
357
|
+
subfiles: 0,
|
|
358
|
+
size: bytes.length,
|
|
359
|
+
};
|
|
360
|
+
return [res, this.#putFile(hash, id, bytes)];
|
|
361
|
+
}
|
|
362
|
+
async putText(id, text) {
|
|
363
|
+
const enc = new TextEncoder();
|
|
364
|
+
const bytes = enc.encode(text);
|
|
365
|
+
const [ent, upload] = await this.putFile(id, bytes);
|
|
366
|
+
return [
|
|
367
|
+
ent,
|
|
368
|
+
upload.then(() => {
|
|
369
|
+
// on success, write to cache
|
|
370
|
+
this.#cache.set(ent.hash, text);
|
|
371
|
+
}),
|
|
372
|
+
];
|
|
373
|
+
}
|
|
374
|
+
async putContent(id, content) {
|
|
375
|
+
if (!id.endsWith(".content")) {
|
|
376
|
+
throw new Error(`id ${id} did not end with '.content'`);
|
|
377
|
+
}
|
|
378
|
+
else {
|
|
379
|
+
return await this.putText(id, JSON.stringify(content));
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
async putMetadata(id, metadata) {
|
|
383
|
+
if (!id.endsWith(".metadata")) {
|
|
384
|
+
throw new Error(`id ${id} did not end with '.metadata'`);
|
|
385
|
+
}
|
|
386
|
+
else {
|
|
387
|
+
return await this.putText(id, JSON.stringify(metadata));
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
async putEntries(id, entries) {
|
|
391
|
+
// NOTE collections have a special hash function, the hash of their
|
|
392
|
+
// contents, so this needs to be different
|
|
393
|
+
entries.sort((a, b) => a.id.localeCompare(b.id));
|
|
394
|
+
const hashBuff = new Uint8Array(entries.length * 32);
|
|
395
|
+
for (const [start, { hash }] of entries.entries()) {
|
|
396
|
+
for (const [i, byte] of (hash.match(/../g) ?? []).entries()) {
|
|
397
|
+
hashBuff[start * 32 + i] = parseInt(byte, 16);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
const hash = await digest(hashBuff);
|
|
401
|
+
const size = entries.reduce((acc, ent) => acc + ent.size, 0);
|
|
402
|
+
const records = ["3\n"];
|
|
403
|
+
for (const { hash, type, id, subfiles, size } of entries) {
|
|
404
|
+
records.push(`${hash}:${type}:${id}:${subfiles}:${size}\n`);
|
|
405
|
+
}
|
|
406
|
+
const res = {
|
|
407
|
+
id,
|
|
408
|
+
hash,
|
|
409
|
+
type: 80000000,
|
|
410
|
+
subfiles: entries.length,
|
|
411
|
+
size,
|
|
412
|
+
};
|
|
413
|
+
const enc = new TextEncoder();
|
|
414
|
+
return [
|
|
415
|
+
res,
|
|
416
|
+
// NOTE when monitoring requests, this had the extension .docSchema appended, but I'm not entirely sure why
|
|
417
|
+
this.#putFile(hash, `${id}.docSchema`, enc.encode(records.join(""))),
|
|
418
|
+
];
|
|
419
|
+
}
|
|
420
|
+
dumpCache() {
|
|
421
|
+
return JSON.stringify(Object.fromEntries(this.#cache));
|
|
422
|
+
}
|
|
423
|
+
clearCache() {
|
|
424
|
+
this.#cache.clear();
|
|
425
|
+
}
|
|
426
|
+
}
|