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