rmapi-js 14.0.0 → 14.1.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 +14 -0
- package/dist/index.d.ts +74 -0
- package/dist/index.js +159 -1
- package/dist/raw.d.ts +15 -1
- package/dist/raw.js +49 -33
- package/dist/rmapi-js.esm.min.js +53 -44
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -67,6 +67,20 @@ const [entry] = await api.listItems()
|
|
|
67
67
|
const buffer = await api.getEpub(entry)
|
|
68
68
|
```
|
|
69
69
|
|
|
70
|
+
To react to changes as they happen, `listen` yields a notification each time a
|
|
71
|
+
device finishes syncing. It names the device, not what changed, so use it as a
|
|
72
|
+
cue to re-read whatever you care about. Compare against `deviceId` to skip your
|
|
73
|
+
own syncs. reMarkable authorizes the socket's handshake with a header, and a
|
|
74
|
+
browser's `WebSocket` takes only a url, so this needs node or bun.
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
for await (const { attributes } of api.listen()) {
|
|
78
|
+
if (attributes.sourceDeviceID !== api.deviceId) {
|
|
79
|
+
const fileEntries = await api.listItems(true);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
70
84
|
### Gotchas
|
|
71
85
|
|
|
72
86
|
By default, all calls try to do their best to verify that the input and output
|
package/dist/index.d.ts
CHANGED
|
@@ -185,6 +185,41 @@ export interface PutOptions {
|
|
|
185
185
|
*/
|
|
186
186
|
refresh?: boolean;
|
|
187
187
|
}
|
|
188
|
+
/** what a sync notification says happened */
|
|
189
|
+
export interface SyncEventAttributes {
|
|
190
|
+
/** the kind of event, always a sync completion here */
|
|
191
|
+
event: "SyncComplete";
|
|
192
|
+
/**
|
|
193
|
+
* the device that synced
|
|
194
|
+
*
|
|
195
|
+
* Tablets report their serial, api clients the id they registered with, so
|
|
196
|
+
* compare it against {@link RemarkableApi.deviceId | `deviceId`} to spot your
|
|
197
|
+
* own syncs.
|
|
198
|
+
*/
|
|
199
|
+
sourceDeviceID: string;
|
|
200
|
+
/** the account that synced, as an auth0 id */
|
|
201
|
+
auth0UserID: string;
|
|
202
|
+
/** the same value as `auth0UserID` in every event we've seen */
|
|
203
|
+
userID?: string;
|
|
204
|
+
/** the notification family, always "SyncEventNotification" so far */
|
|
205
|
+
eventType?: string;
|
|
206
|
+
/** the format version, always "1" so far */
|
|
207
|
+
eventVersion?: string;
|
|
208
|
+
/** the organization of a managed account, otherwise empty */
|
|
209
|
+
orgID?: string;
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* a sync notification pushed over the notification socket
|
|
213
|
+
*
|
|
214
|
+
* Events carry a little more, but nothing worth depending on, so the rest is
|
|
215
|
+
* passed through untyped rather than validated.
|
|
216
|
+
*/
|
|
217
|
+
export interface SyncEvent {
|
|
218
|
+
/** what happened */
|
|
219
|
+
attributes: SyncEventAttributes;
|
|
220
|
+
/** the id of this notification */
|
|
221
|
+
messageid?: string;
|
|
222
|
+
}
|
|
188
223
|
/**
|
|
189
224
|
* the api for accessing remarkable functions
|
|
190
225
|
*
|
|
@@ -206,6 +241,13 @@ declare class Remarkable {
|
|
|
206
241
|
/** scoped access to the raw low-level api */
|
|
207
242
|
readonly raw: RawRemarkable;
|
|
208
243
|
constructor(sessionToken: string, rawHost: string, uploadHost: string, cache: Map<string, Uint8Array | null>, maxGenerationRetries: number, maxTransientRetries: number, maxCachedBytes: number);
|
|
244
|
+
/**
|
|
245
|
+
* the id this api is registered under
|
|
246
|
+
*
|
|
247
|
+
* This is the uuid passed to {@link register | `register`}, which reMarkable
|
|
248
|
+
* stamps on everything this client does. Tablets use their serial instead.
|
|
249
|
+
*/
|
|
250
|
+
get deviceId(): string;
|
|
209
251
|
/**
|
|
210
252
|
* list all items
|
|
211
253
|
*
|
|
@@ -674,6 +716,31 @@ declare class Remarkable {
|
|
|
674
716
|
* @returns references to the deleted entries, each with its new hash
|
|
675
717
|
*/
|
|
676
718
|
bulkDelete(refs: readonly ItemRef[], refresh?: boolean): Promise<ItemRef[]>;
|
|
719
|
+
/**
|
|
720
|
+
* listen for sync notifications
|
|
721
|
+
*
|
|
722
|
+
* reMarkable sends one every time a device finishes syncing. It names the
|
|
723
|
+
* device, not what changed, so use it as a cue to re-read.
|
|
724
|
+
*
|
|
725
|
+
* The socket is reopened when the server drops it, which happens every few
|
|
726
|
+
* minutes. Leaving the loop closes it. Session tokens expire after a few
|
|
727
|
+
* hours, and this throws once reconnecting with an expired one fails.
|
|
728
|
+
*
|
|
729
|
+
* reMarkable authorizes the handshake with a header, which node and bun can
|
|
730
|
+
* attach but a browser's `WebSocket` can't, so this is server side only.
|
|
731
|
+
*
|
|
732
|
+
* @example
|
|
733
|
+
* ```ts
|
|
734
|
+
* for await (const { attributes } of api.listen()) {
|
|
735
|
+
* if (attributes.sourceDeviceID !== api.deviceId) {
|
|
736
|
+
* const entries = await api.listItems(true);
|
|
737
|
+
* }
|
|
738
|
+
* }
|
|
739
|
+
* ```
|
|
740
|
+
*
|
|
741
|
+
* @returns the notifications, in the order they arrive
|
|
742
|
+
*/
|
|
743
|
+
listen(): AsyncGenerator<SyncEvent, void, undefined>;
|
|
677
744
|
/**
|
|
678
745
|
* get the current cache value as a string
|
|
679
746
|
*
|
|
@@ -720,6 +787,10 @@ export interface RemarkableSessionOptions {
|
|
|
720
787
|
/**
|
|
721
788
|
* the base url for making upload requests
|
|
722
789
|
*
|
|
790
|
+
* @deprecated uploads now go to the same server as everything else, so this
|
|
791
|
+
* will be removed and `rawHost` will cover both. Point `rawHost` at the
|
|
792
|
+
* backend you want instead.
|
|
793
|
+
*
|
|
723
794
|
* @defaultValue "https://internal.cloud.remarkable.com"
|
|
724
795
|
*/
|
|
725
796
|
uploadHost?: string;
|
|
@@ -793,6 +864,9 @@ export declare function auth(deviceToken: string, { authHost }?: AuthOptions): P
|
|
|
793
864
|
* If requests start failing, simply recreate the api instance with a freshly
|
|
794
865
|
* fetched session token.
|
|
795
866
|
*
|
|
867
|
+
* The device id is read out of the token, so this throws if it isn't one
|
|
868
|
+
* reMarkable minted.
|
|
869
|
+
*
|
|
796
870
|
* @param sessionToken - the session token used for authorization
|
|
797
871
|
* @returns an api instance
|
|
798
872
|
*/
|
package/dist/index.js
CHANGED
|
@@ -237,6 +237,45 @@ export async function register(code, { deviceDesc = "browser-chrome", uuid = uui
|
|
|
237
237
|
return await resp.text();
|
|
238
238
|
}
|
|
239
239
|
}
|
|
240
|
+
const tokenClaims = z
|
|
241
|
+
.object({ "device-id": z.string() })
|
|
242
|
+
.loose();
|
|
243
|
+
function tokenDeviceId(token) {
|
|
244
|
+
const [, payload] = token.split(".", 2);
|
|
245
|
+
if (payload === undefined) {
|
|
246
|
+
throw new Error("token wasn't a jwt, so it had no device id");
|
|
247
|
+
}
|
|
248
|
+
const decoded = Uint8Array.fromBase64(payload, {
|
|
249
|
+
alphabet: "base64url",
|
|
250
|
+
lastChunkHandling: "loose",
|
|
251
|
+
});
|
|
252
|
+
const json = new TextDecoder().decode(decoded);
|
|
253
|
+
const parsed = tokenClaims.parse(JSON.parse(json));
|
|
254
|
+
return parsed["device-id"];
|
|
255
|
+
}
|
|
256
|
+
const notification = z
|
|
257
|
+
.object({
|
|
258
|
+
message: z
|
|
259
|
+
.object({ attributes: z.object({ event: z.string() }).loose() })
|
|
260
|
+
.loose(),
|
|
261
|
+
})
|
|
262
|
+
.loose();
|
|
263
|
+
const syncEvent = z
|
|
264
|
+
.object({
|
|
265
|
+
attributes: z
|
|
266
|
+
.object({
|
|
267
|
+
event: z.literal("SyncComplete"),
|
|
268
|
+
sourceDeviceID: z.string(),
|
|
269
|
+
auth0UserID: z.string(),
|
|
270
|
+
userID: z.string().optional(),
|
|
271
|
+
eventType: z.string().optional(),
|
|
272
|
+
eventVersion: z.string().optional(),
|
|
273
|
+
orgID: z.string().optional(),
|
|
274
|
+
})
|
|
275
|
+
.loose(),
|
|
276
|
+
messageid: z.string().optional(),
|
|
277
|
+
})
|
|
278
|
+
.loose();
|
|
240
279
|
/**
|
|
241
280
|
* the api for accessing remarkable functions
|
|
242
281
|
*
|
|
@@ -259,6 +298,7 @@ class Remarkable {
|
|
|
259
298
|
#cache;
|
|
260
299
|
/** scoped access to the raw low-level api */
|
|
261
300
|
raw;
|
|
301
|
+
#deviceId;
|
|
262
302
|
#maxGenerationRetries;
|
|
263
303
|
#maxTransientRetries;
|
|
264
304
|
#lastHashGen;
|
|
@@ -267,10 +307,24 @@ class Remarkable {
|
|
|
267
307
|
#rootMutex = new Mutex();
|
|
268
308
|
constructor(sessionToken, rawHost, uploadHost, cache, maxGenerationRetries, maxTransientRetries, maxCachedBytes) {
|
|
269
309
|
this.#sessionToken = sessionToken;
|
|
310
|
+
this.#deviceId = tokenDeviceId(sessionToken);
|
|
270
311
|
this.#cache = cache;
|
|
271
312
|
this.#maxGenerationRetries = maxGenerationRetries;
|
|
272
313
|
this.#maxTransientRetries = maxTransientRetries;
|
|
273
|
-
this.raw = new RawRemarkable((method, url, { body, headers } = {}) => this.#authedFetch(url, { method, body, headers }),
|
|
314
|
+
this.raw = new RawRemarkable((method, url, { body, headers } = {}) => this.#authedFetch(url, { method, body, headers }), (url) =>
|
|
315
|
+
// node and bun both take headers here, the dom types don't know about them
|
|
316
|
+
new WebSocket(url, {
|
|
317
|
+
headers: { Authorization: `Bearer ${this.#sessionToken}` },
|
|
318
|
+
}), cache, rawHost, uploadHost, maxCachedBytes);
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* the id this api is registered under
|
|
322
|
+
*
|
|
323
|
+
* This is the uuid passed to {@link register | `register`}, which reMarkable
|
|
324
|
+
* stamps on everything this client does. Tablets use their serial instead.
|
|
325
|
+
*/
|
|
326
|
+
get deviceId() {
|
|
327
|
+
return this.#deviceId;
|
|
274
328
|
}
|
|
275
329
|
async #getRootHash(refresh = false) {
|
|
276
330
|
if (refresh || this.#lastHashGen === undefined) {
|
|
@@ -1577,6 +1631,107 @@ class Remarkable {
|
|
|
1577
1631
|
async bulkDelete(refs, refresh = false) {
|
|
1578
1632
|
return await this.bulkMove(refs, TRASH_ID, refresh);
|
|
1579
1633
|
}
|
|
1634
|
+
/**
|
|
1635
|
+
* listen for sync notifications
|
|
1636
|
+
*
|
|
1637
|
+
* reMarkable sends one every time a device finishes syncing. It names the
|
|
1638
|
+
* device, not what changed, so use it as a cue to re-read.
|
|
1639
|
+
*
|
|
1640
|
+
* The socket is reopened when the server drops it, which happens every few
|
|
1641
|
+
* minutes. Leaving the loop closes it. Session tokens expire after a few
|
|
1642
|
+
* hours, and this throws once reconnecting with an expired one fails.
|
|
1643
|
+
*
|
|
1644
|
+
* reMarkable authorizes the handshake with a header, which node and bun can
|
|
1645
|
+
* attach but a browser's `WebSocket` can't, so this is server side only.
|
|
1646
|
+
*
|
|
1647
|
+
* @example
|
|
1648
|
+
* ```ts
|
|
1649
|
+
* for await (const { attributes } of api.listen()) {
|
|
1650
|
+
* if (attributes.sourceDeviceID !== api.deviceId) {
|
|
1651
|
+
* const entries = await api.listItems(true);
|
|
1652
|
+
* }
|
|
1653
|
+
* }
|
|
1654
|
+
* ```
|
|
1655
|
+
*
|
|
1656
|
+
* @returns the notifications, in the order they arrive
|
|
1657
|
+
*/
|
|
1658
|
+
listen() {
|
|
1659
|
+
const stop = new AbortController();
|
|
1660
|
+
const events = this.#listen(stop.signal);
|
|
1661
|
+
const { return: finish } = events;
|
|
1662
|
+
// an async generator only acts on return once it reaches a yield, so
|
|
1663
|
+
// stopping has to unblock the loop and let it end on its own
|
|
1664
|
+
events.return = async (value) => {
|
|
1665
|
+
stop.abort();
|
|
1666
|
+
return await finish.call(events, value);
|
|
1667
|
+
};
|
|
1668
|
+
return events;
|
|
1669
|
+
}
|
|
1670
|
+
async *#listen(signal) {
|
|
1671
|
+
for (let failures = 0;;) {
|
|
1672
|
+
const socket = this.raw.notifications();
|
|
1673
|
+
const closeSocket = () => {
|
|
1674
|
+
socket.close();
|
|
1675
|
+
};
|
|
1676
|
+
signal.addEventListener("abort", closeSocket, { once: true });
|
|
1677
|
+
const queued = [];
|
|
1678
|
+
let arrived = () => { };
|
|
1679
|
+
// tells a failed handshake, likely an expired token, from the server's
|
|
1680
|
+
// routine drop of a healthy socket
|
|
1681
|
+
let opened = false;
|
|
1682
|
+
let done = false;
|
|
1683
|
+
socket.addEventListener("open", () => {
|
|
1684
|
+
opened = true;
|
|
1685
|
+
});
|
|
1686
|
+
socket.addEventListener("message", ({ data }) => {
|
|
1687
|
+
queued.push(data);
|
|
1688
|
+
arrived();
|
|
1689
|
+
});
|
|
1690
|
+
socket.addEventListener("error", () => {
|
|
1691
|
+
done = true;
|
|
1692
|
+
arrived();
|
|
1693
|
+
});
|
|
1694
|
+
socket.addEventListener("close", () => {
|
|
1695
|
+
done = true;
|
|
1696
|
+
arrived();
|
|
1697
|
+
});
|
|
1698
|
+
try {
|
|
1699
|
+
while (!done || queued.length) {
|
|
1700
|
+
const raw = queued.shift();
|
|
1701
|
+
if (raw === undefined) {
|
|
1702
|
+
await new Promise((res) => {
|
|
1703
|
+
arrived = res;
|
|
1704
|
+
});
|
|
1705
|
+
}
|
|
1706
|
+
else {
|
|
1707
|
+
const { message } = notification.parse(JSON.parse(raw));
|
|
1708
|
+
// screenshare and passcode events share the socket, and their
|
|
1709
|
+
// attributes are shaped differently
|
|
1710
|
+
if (message.attributes.event === "SyncComplete") {
|
|
1711
|
+
yield syncEvent.parse(message);
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
finally {
|
|
1717
|
+
signal.removeEventListener("abort", closeSocket);
|
|
1718
|
+
socket.close();
|
|
1719
|
+
}
|
|
1720
|
+
if (signal.aborted) {
|
|
1721
|
+
return;
|
|
1722
|
+
}
|
|
1723
|
+
else if (opened) {
|
|
1724
|
+
failures = 0;
|
|
1725
|
+
}
|
|
1726
|
+
else if (failures < this.#maxTransientRetries) {
|
|
1727
|
+
failures++;
|
|
1728
|
+
}
|
|
1729
|
+
else {
|
|
1730
|
+
throw new Error("couldn't open the reMarkable notification socket; the session token may have expired");
|
|
1731
|
+
}
|
|
1732
|
+
await sleep(backoffMs(failures, TRANSIENT_BASE_MS));
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1580
1735
|
/**
|
|
1581
1736
|
* get the current cache value as a string
|
|
1582
1737
|
*
|
|
@@ -1705,6 +1860,9 @@ export async function auth(deviceToken, { authHost = AUTH_HOST } = {}) {
|
|
|
1705
1860
|
* If requests start failing, simply recreate the api instance with a freshly
|
|
1706
1861
|
* fetched session token.
|
|
1707
1862
|
*
|
|
1863
|
+
* The device id is read out of the token, so this throws if it isn't one
|
|
1864
|
+
* reMarkable minted.
|
|
1865
|
+
*
|
|
1708
1866
|
* @param sessionToken - the session token used for authorization
|
|
1709
1867
|
* @returns an api instance
|
|
1710
1868
|
*/
|
package/dist/raw.d.ts
CHANGED
|
@@ -511,6 +511,7 @@ type AuthedFetch = (method: RequestMethod, url: string, init?: {
|
|
|
511
511
|
body?: string | Uint8Array;
|
|
512
512
|
headers?: Record<string, string>;
|
|
513
513
|
}) => Promise<Response>;
|
|
514
|
+
type AuthedSocket = (url: string) => WebSocket;
|
|
514
515
|
/**
|
|
515
516
|
* access to the low-level reMarkable api
|
|
516
517
|
*
|
|
@@ -576,7 +577,7 @@ type AuthedFetch = (method: RequestMethod, url: string, init?: {
|
|
|
576
577
|
*/
|
|
577
578
|
export declare class RawRemarkable {
|
|
578
579
|
#private;
|
|
579
|
-
constructor(authedFetch: AuthedFetch, cache: Map<string, Uint8Array | null>, rawHost: string, uploadHost: string, maxCachedBytes: number);
|
|
580
|
+
constructor(authedFetch: AuthedFetch, authedSocket: AuthedSocket, cache: Map<string, Uint8Array | null>, rawHost: string, uploadHost: string, maxCachedBytes: number);
|
|
580
581
|
/**
|
|
581
582
|
* gets the root hash and the current generation
|
|
582
583
|
*
|
|
@@ -778,6 +779,19 @@ export declare class RawRemarkable {
|
|
|
778
779
|
* @returns a simple entry with the id and hash of the uploaded file
|
|
779
780
|
*/
|
|
780
781
|
uploadFile(visibleName: string, bytes: Uint8Array, mime: UploadMimeType): Promise<ItemRef>;
|
|
782
|
+
/**
|
|
783
|
+
* open the websocket reMarkable pushes notifications to
|
|
784
|
+
*
|
|
785
|
+
* The server drops the socket every few minutes, so anything long lived has
|
|
786
|
+
* to reopen it. {@link RemarkableApi.listen | `listen`} does that, and parses
|
|
787
|
+
* the sync notifications out of what arrives.
|
|
788
|
+
*
|
|
789
|
+
* reMarkable authorizes the handshake with a header, which node and bun can
|
|
790
|
+
* attach but a browser's `WebSocket` can't, so this is server side only.
|
|
791
|
+
*
|
|
792
|
+
* @returns the connecting socket
|
|
793
|
+
*/
|
|
794
|
+
notifications(): WebSocket;
|
|
781
795
|
/**
|
|
782
796
|
* dump the current cache to a string to preserve between session
|
|
783
797
|
*
|
package/dist/raw.js
CHANGED
|
@@ -66,14 +66,14 @@ const tag = z
|
|
|
66
66
|
name: z.string(),
|
|
67
67
|
timestamp: z.number(),
|
|
68
68
|
})
|
|
69
|
-
.
|
|
69
|
+
.loose();
|
|
70
70
|
const pageTag = z
|
|
71
71
|
.object({
|
|
72
72
|
name: z.string(),
|
|
73
73
|
pageId: z.string(),
|
|
74
74
|
timestamp: z.number(),
|
|
75
75
|
})
|
|
76
|
-
.
|
|
76
|
+
.loose();
|
|
77
77
|
const documentMetadata = z
|
|
78
78
|
.object({
|
|
79
79
|
authors: z.array(z.string()).optional(),
|
|
@@ -81,53 +81,51 @@ const documentMetadata = z
|
|
|
81
81
|
publicationDate: z.string().optional(),
|
|
82
82
|
publisher: z.string().optional(),
|
|
83
83
|
})
|
|
84
|
-
.
|
|
84
|
+
.loose();
|
|
85
85
|
const cPagePage = z
|
|
86
86
|
.object({
|
|
87
87
|
id: z.string(),
|
|
88
|
-
idx: z.object({ timestamp: z.string(), value: z.string() }).
|
|
88
|
+
idx: z.object({ timestamp: z.string(), value: z.string() }).loose(),
|
|
89
89
|
template: z
|
|
90
90
|
.object({ timestamp: z.string(), value: z.string() })
|
|
91
|
-
.
|
|
91
|
+
.loose()
|
|
92
92
|
.optional(),
|
|
93
93
|
redir: z
|
|
94
94
|
.object({ timestamp: z.string(), value: z.number().int() })
|
|
95
|
-
.
|
|
95
|
+
.loose()
|
|
96
96
|
.optional(),
|
|
97
97
|
scrollTime: z
|
|
98
98
|
.object({
|
|
99
99
|
timestamp: z.string(),
|
|
100
|
-
value: z.
|
|
100
|
+
value: z.iso.datetime({ offset: true }),
|
|
101
101
|
})
|
|
102
|
-
.
|
|
102
|
+
.loose()
|
|
103
103
|
.optional(),
|
|
104
104
|
verticalScroll: z
|
|
105
105
|
.object({ timestamp: z.string(), value: z.number() })
|
|
106
|
-
.
|
|
106
|
+
.loose()
|
|
107
107
|
.optional(),
|
|
108
108
|
deleted: z
|
|
109
109
|
.object({ timestamp: z.string(), value: z.number().int() })
|
|
110
|
-
.
|
|
110
|
+
.loose()
|
|
111
111
|
.optional(),
|
|
112
112
|
modifed: z.string().optional(),
|
|
113
113
|
})
|
|
114
|
-
.
|
|
114
|
+
.loose();
|
|
115
115
|
const cPages = z
|
|
116
116
|
.object({
|
|
117
|
-
lastOpened: z
|
|
118
|
-
.object({ timestamp: z.string(), value: z.string() })
|
|
119
|
-
.passthrough(),
|
|
117
|
+
lastOpened: z.object({ timestamp: z.string(), value: z.string() }).loose(),
|
|
120
118
|
original: z
|
|
121
119
|
.object({ timestamp: z.string(), value: z.number().int() })
|
|
122
|
-
.
|
|
120
|
+
.loose(),
|
|
123
121
|
pages: z.array(cPagePage),
|
|
124
122
|
uuids: z
|
|
125
123
|
.array(z
|
|
126
124
|
.object({ first: z.string(), second: z.number().int().nonnegative() })
|
|
127
|
-
.
|
|
125
|
+
.loose())
|
|
128
126
|
.nullable(),
|
|
129
127
|
})
|
|
130
|
-
.
|
|
128
|
+
.loose();
|
|
131
129
|
const collectionContent = z
|
|
132
130
|
.object({
|
|
133
131
|
tags: z.array(tag).optional(),
|
|
@@ -162,7 +160,7 @@ const documentContentOptional = {
|
|
|
162
160
|
formatVersion: z.number().int().nonnegative().optional(),
|
|
163
161
|
keyboardMetadata: z
|
|
164
162
|
.object({ count: z.number().int().nonnegative(), timestamp: z.number() })
|
|
165
|
-
.
|
|
163
|
+
.loose()
|
|
166
164
|
.optional(),
|
|
167
165
|
lastOpenedPage: z.number().int().optional(),
|
|
168
166
|
margins: z.number().int().nonnegative().optional(),
|
|
@@ -183,7 +181,7 @@ const documentContentOptional = {
|
|
|
183
181
|
m32: z.number().optional(),
|
|
184
182
|
m33: z.number().optional(),
|
|
185
183
|
})
|
|
186
|
-
.
|
|
184
|
+
.loose()
|
|
187
185
|
.optional(),
|
|
188
186
|
viewBackgroundFilter: z.enum(["off", "fullpage"]).optional(),
|
|
189
187
|
zoomMode: z
|
|
@@ -192,13 +190,13 @@ const documentContentOptional = {
|
|
|
192
190
|
};
|
|
193
191
|
const commonDocumentContent = z
|
|
194
192
|
.object({ ...documentContentRequired, ...documentContentOptional })
|
|
195
|
-
.
|
|
193
|
+
.loose();
|
|
196
194
|
const documentContent = commonDocumentContent
|
|
197
195
|
.extend({ tags: z.array(tag).optional() })
|
|
198
|
-
.
|
|
196
|
+
.loose();
|
|
199
197
|
const legacyDocumentContent = commonDocumentContent
|
|
200
198
|
.extend({ tags: z.array(z.string()).optional() })
|
|
201
|
-
.
|
|
199
|
+
.loose();
|
|
202
200
|
const templateContent = z
|
|
203
201
|
.object({
|
|
204
202
|
id: z.string().optional(),
|
|
@@ -216,7 +214,7 @@ const templateContent = z
|
|
|
216
214
|
items: z.array(z.unknown()),
|
|
217
215
|
formatVersion: z.number().int().nonnegative().optional(),
|
|
218
216
|
})
|
|
219
|
-
.
|
|
217
|
+
.loose();
|
|
220
218
|
// content payloads aren't discriminable (legacy/modern differ only by tags
|
|
221
219
|
// element type), so this is an ordered union: the first matching variant wins
|
|
222
220
|
const content = z.union([
|
|
@@ -242,17 +240,17 @@ const highlightsFile = z
|
|
|
242
240
|
width: z.number(),
|
|
243
241
|
height: z.number(),
|
|
244
242
|
})
|
|
245
|
-
.
|
|
243
|
+
.loose()),
|
|
246
244
|
})
|
|
247
|
-
.
|
|
245
|
+
.loose())),
|
|
248
246
|
})
|
|
249
|
-
.
|
|
247
|
+
.loose();
|
|
250
248
|
const pageMetadataReg = /\/[^/]+-metadata\.json$/;
|
|
251
249
|
const pageMetadata = z
|
|
252
250
|
.object({
|
|
253
|
-
layers: z.array(z.object({ name: z.string() }).
|
|
251
|
+
layers: z.array(z.object({ name: z.string() }).loose()),
|
|
254
252
|
})
|
|
255
|
-
.
|
|
253
|
+
.loose();
|
|
256
254
|
const metadata = z
|
|
257
255
|
.object({
|
|
258
256
|
lastModified: z.string().optional(),
|
|
@@ -271,7 +269,7 @@ const metadata = z
|
|
|
271
269
|
new: z.boolean().optional(),
|
|
272
270
|
source: z.string().optional(),
|
|
273
271
|
})
|
|
274
|
-
.
|
|
272
|
+
.loose();
|
|
275
273
|
/** parse and validate the json text of a `.metadata` file */
|
|
276
274
|
export function parseMetadata(text) {
|
|
277
275
|
const loaded = JSON.parse(text);
|
|
@@ -282,20 +280,20 @@ const updatedRootHash = z
|
|
|
282
280
|
hash: z.string(),
|
|
283
281
|
generation: z.number(),
|
|
284
282
|
})
|
|
285
|
-
.
|
|
283
|
+
.loose();
|
|
286
284
|
const rootHash = z
|
|
287
285
|
.object({
|
|
288
286
|
hash: z.string(),
|
|
289
287
|
generation: z.number(),
|
|
290
288
|
schemaVersion: z.number().int().nonnegative(),
|
|
291
289
|
})
|
|
292
|
-
.
|
|
290
|
+
.loose();
|
|
293
291
|
const nativeItemRef = z
|
|
294
292
|
.object({
|
|
295
293
|
docID: z.string(),
|
|
296
294
|
hash: z.string(),
|
|
297
295
|
})
|
|
298
|
-
.
|
|
296
|
+
.loose();
|
|
299
297
|
async function digest(buff) {
|
|
300
298
|
const digest = await crypto.subtle.digest("SHA-256",
|
|
301
299
|
// NOTE this is type hinted wrong, but it does work correctly on a uint8 view
|
|
@@ -389,6 +387,7 @@ function parseRawEntryLine(line) {
|
|
|
389
387
|
*/
|
|
390
388
|
export class RawRemarkable {
|
|
391
389
|
#authedFetch;
|
|
390
|
+
#authedSocket;
|
|
392
391
|
#rawHost;
|
|
393
392
|
#uploadHost;
|
|
394
393
|
/**
|
|
@@ -402,8 +401,9 @@ export class RawRemarkable {
|
|
|
402
401
|
*/
|
|
403
402
|
#cache;
|
|
404
403
|
#maxCachedBytes;
|
|
405
|
-
constructor(authedFetch, cache, rawHost, uploadHost, maxCachedBytes) {
|
|
404
|
+
constructor(authedFetch, authedSocket, cache, rawHost, uploadHost, maxCachedBytes) {
|
|
406
405
|
this.#authedFetch = authedFetch;
|
|
406
|
+
this.#authedSocket = authedSocket;
|
|
407
407
|
this.#cache = cache;
|
|
408
408
|
this.#rawHost = rawHost;
|
|
409
409
|
this.#uploadHost = uploadHost;
|
|
@@ -867,6 +867,22 @@ export class RawRemarkable {
|
|
|
867
867
|
const { docID, hash } = nativeItemRef.parse(loaded);
|
|
868
868
|
return { id: docID, hash };
|
|
869
869
|
}
|
|
870
|
+
/**
|
|
871
|
+
* open the websocket reMarkable pushes notifications to
|
|
872
|
+
*
|
|
873
|
+
* The server drops the socket every few minutes, so anything long lived has
|
|
874
|
+
* to reopen it. {@link RemarkableApi.listen | `listen`} does that, and parses
|
|
875
|
+
* the sync notifications out of what arrives.
|
|
876
|
+
*
|
|
877
|
+
* reMarkable authorizes the handshake with a header, which node and bun can
|
|
878
|
+
* attach but a browser's `WebSocket` can't, so this is server side only.
|
|
879
|
+
*
|
|
880
|
+
* @returns the connecting socket
|
|
881
|
+
*/
|
|
882
|
+
notifications() {
|
|
883
|
+
const host = this.#rawHost.replace(/^http/, "ws");
|
|
884
|
+
return this.#authedSocket(`${host}/notifications/ws/json/1`);
|
|
885
|
+
}
|
|
870
886
|
/**
|
|
871
887
|
* dump the current cache to a string to preserve between session
|
|
872
888
|
*
|