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