leafrelay 0.2.8 → 0.2.9
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 +30 -4
- package/README.zh-CN.md +30 -4
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +45 -49
- package/dist/cli.js.map +1 -1
- package/dist/daemon.d.ts +1 -0
- package/dist/daemon.js +866 -934
- package/dist/daemon.js.map +1 -1
- package/dist/dist-JyfvbgjD.js +2923 -0
- package/dist/dist-JyfvbgjD.js.map +1 -0
- package/dist/index.d.ts +15 -16
- package/dist/index.js +1 -8
- package/dist/serve-CQnCOCAM.js +6823 -0
- package/dist/serve-CQnCOCAM.js.map +1 -0
- package/package.json +10 -10
- package/dist/chunk-3TTRDNHP.js +0 -3333
- package/dist/chunk-3TTRDNHP.js.map +0 -1
- package/dist/chunk-4EHEWM7N.js +0 -7393
- package/dist/chunk-4EHEWM7N.js.map +0 -1
- package/dist/chunk-5WRI5ZAA.js +0 -31
- package/dist/chunk-5WRI5ZAA.js.map +0 -1
- package/dist/index.js.map +0 -1
|
@@ -0,0 +1,2923 @@
|
|
|
1
|
+
import { Blob as Blob$1 } from "node:buffer";
|
|
2
|
+
import mime from "mime";
|
|
3
|
+
import { EventEmitter } from "node:events";
|
|
4
|
+
import { v4 } from "uuid";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import { chmod, lstat, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { dirname, join, posix, relative, resolve, sep } from "node:path";
|
|
9
|
+
import { watch } from "chokidar";
|
|
10
|
+
import { minimatch } from "minimatch";
|
|
11
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
12
|
+
import DiffMatchPatch from "diff-match-patch";
|
|
13
|
+
import { diff3Merge } from "node-diff3";
|
|
14
|
+
// ../core/dist/index.js
|
|
15
|
+
const PACKET_TYPES = [
|
|
16
|
+
"disconnect",
|
|
17
|
+
"connect",
|
|
18
|
+
"heartbeat",
|
|
19
|
+
"message",
|
|
20
|
+
"json",
|
|
21
|
+
"event",
|
|
22
|
+
"ack",
|
|
23
|
+
"error",
|
|
24
|
+
"noop"
|
|
25
|
+
];
|
|
26
|
+
function encodePacket(packet) {
|
|
27
|
+
const type = PACKET_TYPES.indexOf(packet.type ?? "noop");
|
|
28
|
+
const id = packet.id ?? "";
|
|
29
|
+
let data;
|
|
30
|
+
switch (packet.type) {
|
|
31
|
+
case "event":
|
|
32
|
+
data = JSON.stringify({
|
|
33
|
+
name: packet.name,
|
|
34
|
+
args: packet.args ?? []
|
|
35
|
+
});
|
|
36
|
+
break;
|
|
37
|
+
case "ack":
|
|
38
|
+
data = `${packet.ackId ?? ""}${packet.args?.length ? `+${JSON.stringify(packet.args)}` : ""}`;
|
|
39
|
+
break;
|
|
40
|
+
case "message":
|
|
41
|
+
data = String(packet.data ?? "");
|
|
42
|
+
break;
|
|
43
|
+
case "json": data = JSON.stringify(packet.data);
|
|
44
|
+
}
|
|
45
|
+
const fields = [
|
|
46
|
+
String(type),
|
|
47
|
+
`${id}${packet.ack === "data" ? "+" : ""}`,
|
|
48
|
+
packet.endpoint ?? ""
|
|
49
|
+
];
|
|
50
|
+
if (data !== void 0) fields.push(data);
|
|
51
|
+
return fields.join(":");
|
|
52
|
+
}
|
|
53
|
+
function decodePacket(value) {
|
|
54
|
+
const match = value.match(/([^:]+):([0-9]+)?(\+)?:([^:]+)?:?([\s\S]*)?/);
|
|
55
|
+
if (!match) return {};
|
|
56
|
+
const packet = {
|
|
57
|
+
type: PACKET_TYPES[Number(match[1])],
|
|
58
|
+
endpoint: match[4] || ""
|
|
59
|
+
};
|
|
60
|
+
if (match[2]) {
|
|
61
|
+
packet.id = match[2];
|
|
62
|
+
packet.ack = match[3] ? "data" : true;
|
|
63
|
+
}
|
|
64
|
+
const data = match[5] || "";
|
|
65
|
+
switch (packet.type) {
|
|
66
|
+
case "event": {
|
|
67
|
+
const event = JSON.parse(data);
|
|
68
|
+
packet.name = event.name;
|
|
69
|
+
packet.args = event.args ?? [];
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
case "ack": {
|
|
73
|
+
const ack = data.match(/^([0-9]+)(\+)?([\s\S]*)/);
|
|
74
|
+
if (ack) {
|
|
75
|
+
packet.ackId = ack[1];
|
|
76
|
+
packet.args = ack[3] ? JSON.parse(ack[3]) : [];
|
|
77
|
+
}
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
case "error": {
|
|
81
|
+
const [reason, advice] = data.split("+");
|
|
82
|
+
packet.reason = [
|
|
83
|
+
"transport not supported",
|
|
84
|
+
"client not handshaken",
|
|
85
|
+
"unauthorized"
|
|
86
|
+
][Number(reason)] ?? "";
|
|
87
|
+
packet.advice = ["reconnect"][Number(advice)] ?? "";
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
case "json":
|
|
91
|
+
packet.data = JSON.parse(data);
|
|
92
|
+
break;
|
|
93
|
+
case "message": packet.data = data;
|
|
94
|
+
}
|
|
95
|
+
return packet;
|
|
96
|
+
}
|
|
97
|
+
function decodePayload(value) {
|
|
98
|
+
if (!value.startsWith("�")) return [decodePacket(value)];
|
|
99
|
+
const packets = [];
|
|
100
|
+
let offset = 1;
|
|
101
|
+
while (offset < value.length) {
|
|
102
|
+
const separator = value.indexOf("�", offset);
|
|
103
|
+
if (separator === -1) break;
|
|
104
|
+
const length = Number(value.slice(offset, separator));
|
|
105
|
+
const start = separator + 1;
|
|
106
|
+
packets.push(decodePacket(value.slice(start, start + length)));
|
|
107
|
+
offset = start + length + 1;
|
|
108
|
+
}
|
|
109
|
+
return packets;
|
|
110
|
+
}
|
|
111
|
+
function mergeCookies(current, additions) {
|
|
112
|
+
const cookies = /* @__PURE__ */ new Map();
|
|
113
|
+
for (const value of [current, ...additions.map((cookie) => cookie.split(";", 1)[0])]) for (const part of value.split(";")) {
|
|
114
|
+
const separator = part.indexOf("=");
|
|
115
|
+
if (separator === -1) continue;
|
|
116
|
+
cookies.set(part.slice(0, separator).trim(), part.slice(separator + 1).trim());
|
|
117
|
+
}
|
|
118
|
+
return [...cookies].map(([name, value]) => `${name}=${value}`).join("; ");
|
|
119
|
+
}
|
|
120
|
+
var OverleafRealtimeSocket = class {
|
|
121
|
+
options;
|
|
122
|
+
events = new EventEmitter();
|
|
123
|
+
endpoint;
|
|
124
|
+
origin;
|
|
125
|
+
cookie;
|
|
126
|
+
socket;
|
|
127
|
+
connected = false;
|
|
128
|
+
projectJoined = false;
|
|
129
|
+
explicitlyDisconnected = false;
|
|
130
|
+
reconnectEnabled;
|
|
131
|
+
reconnectAttempts = 0;
|
|
132
|
+
reconnectTimer;
|
|
133
|
+
inactivityTimer;
|
|
134
|
+
connectionAbort;
|
|
135
|
+
closeTimeoutMs = 6e4;
|
|
136
|
+
acknowledgementId = 0;
|
|
137
|
+
acknowledgements = /* @__PURE__ */ new Map();
|
|
138
|
+
io = { reconnect: (enabled) => {
|
|
139
|
+
this.reconnectEnabled = enabled;
|
|
140
|
+
if (!enabled && this.reconnectTimer) {
|
|
141
|
+
clearTimeout(this.reconnectTimer);
|
|
142
|
+
this.reconnectTimer = void 0;
|
|
143
|
+
}
|
|
144
|
+
} };
|
|
145
|
+
constructor(url, options) {
|
|
146
|
+
this.options = options;
|
|
147
|
+
this.endpoint = new URL(url);
|
|
148
|
+
this.origin = options.origin;
|
|
149
|
+
this.cookie = options.cookie;
|
|
150
|
+
this.reconnectEnabled = options.reconnect ?? true;
|
|
151
|
+
this.connect();
|
|
152
|
+
}
|
|
153
|
+
on(event, handler) {
|
|
154
|
+
this.events.on(event, handler);
|
|
155
|
+
return this;
|
|
156
|
+
}
|
|
157
|
+
once(event, handler) {
|
|
158
|
+
this.events.once(event, handler);
|
|
159
|
+
return this;
|
|
160
|
+
}
|
|
161
|
+
removeListener(event, handler) {
|
|
162
|
+
this.events.removeListener(event, handler);
|
|
163
|
+
return this;
|
|
164
|
+
}
|
|
165
|
+
removeAllListeners(event) {
|
|
166
|
+
this.events.removeAllListeners(event);
|
|
167
|
+
return this;
|
|
168
|
+
}
|
|
169
|
+
emit(event, ...args) {
|
|
170
|
+
const callback = typeof args.at(-1) === "function" ? args.pop() : void 0;
|
|
171
|
+
const id = callback ? String(++this.acknowledgementId) : void 0;
|
|
172
|
+
if (id && callback) this.acknowledgements.set(id, callback);
|
|
173
|
+
const packet = encodePacket({
|
|
174
|
+
type: "event",
|
|
175
|
+
id,
|
|
176
|
+
ack: callback ? "data" : void 0,
|
|
177
|
+
name: event,
|
|
178
|
+
args
|
|
179
|
+
});
|
|
180
|
+
if (!this.sendApplicationEvent(packet) && callback) {
|
|
181
|
+
if (id) this.acknowledgements.delete(id);
|
|
182
|
+
queueMicrotask(() => callback(/* @__PURE__ */ new Error("The Overleaf project session is not connected.")));
|
|
183
|
+
}
|
|
184
|
+
return this;
|
|
185
|
+
}
|
|
186
|
+
markProjectJoined() {
|
|
187
|
+
this.projectJoined = true;
|
|
188
|
+
}
|
|
189
|
+
disconnect() {
|
|
190
|
+
this.explicitlyDisconnected = true;
|
|
191
|
+
this.reconnectEnabled = false;
|
|
192
|
+
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
|
193
|
+
if (this.connected) this.send(encodePacket({ type: "disconnect" }));
|
|
194
|
+
this.closeSocket("booted");
|
|
195
|
+
return this;
|
|
196
|
+
}
|
|
197
|
+
async connect() {
|
|
198
|
+
if (this.explicitlyDisconnected) return;
|
|
199
|
+
const controller = new AbortController();
|
|
200
|
+
this.connectionAbort?.abort();
|
|
201
|
+
this.connectionAbort = controller;
|
|
202
|
+
try {
|
|
203
|
+
this.projectJoined = false;
|
|
204
|
+
const handshakeUrl = new URL("/socket.io/1/", this.endpoint.origin);
|
|
205
|
+
for (const [name, value] of this.endpoint.searchParams) handshakeUrl.searchParams.set(name, value);
|
|
206
|
+
handshakeUrl.searchParams.set("t", String(Date.now()));
|
|
207
|
+
const response = await (this.options.fetch ?? fetch)(handshakeUrl, {
|
|
208
|
+
headers: {
|
|
209
|
+
origin: this.origin,
|
|
210
|
+
cookie: this.cookie
|
|
211
|
+
},
|
|
212
|
+
redirect: "manual",
|
|
213
|
+
signal: AbortSignal.any([controller.signal, AbortSignal.timeout(15e3)])
|
|
214
|
+
});
|
|
215
|
+
if (!response.ok) throw new Error(`Socket.IO handshake failed: HTTP ${response.status} ${await response.text()}`);
|
|
216
|
+
const setCookies = response.headers.getSetCookie?.() ?? [];
|
|
217
|
+
this.cookie = mergeCookies(this.cookie, setCookies);
|
|
218
|
+
const [sessionId, heartbeatTimeout, closeTimeout, transports] = (await response.text()).split(":");
|
|
219
|
+
if (!sessionId || !transports?.split(",").includes("websocket")) throw new Error("Overleaf did not offer a Socket.IO WebSocket transport.");
|
|
220
|
+
this.closeTimeoutMs = Math.max(Number(heartbeatTimeout), Number(closeTimeout), 10) * 1e3;
|
|
221
|
+
const websocketUrl = new URL(`/socket.io/1/websocket/${sessionId}`, this.endpoint.origin);
|
|
222
|
+
websocketUrl.protocol = websocketUrl.protocol === "https:" ? "wss:" : "ws:";
|
|
223
|
+
websocketUrl.search = this.endpoint.search;
|
|
224
|
+
if (controller.signal.aborted) return;
|
|
225
|
+
const socket = new WebSocket(websocketUrl, { headers: {
|
|
226
|
+
origin: this.origin,
|
|
227
|
+
cookie: this.cookie
|
|
228
|
+
} });
|
|
229
|
+
this.socket = socket;
|
|
230
|
+
socket.addEventListener("open", () => {
|
|
231
|
+
if (this.socket !== socket) return;
|
|
232
|
+
this.resetInactivityTimer();
|
|
233
|
+
this.events.emit("connecting", "websocket");
|
|
234
|
+
});
|
|
235
|
+
socket.addEventListener("message", (event) => {
|
|
236
|
+
if (this.socket === socket) this.receive(event.data, socket);
|
|
237
|
+
});
|
|
238
|
+
socket.addEventListener("error", (error) => {
|
|
239
|
+
if (this.socket === socket) this.events.emit("error", error);
|
|
240
|
+
});
|
|
241
|
+
socket.addEventListener("close", (event) => {
|
|
242
|
+
if (this.socket === socket) this.handleClose(event);
|
|
243
|
+
});
|
|
244
|
+
} catch (error) {
|
|
245
|
+
if (controller.signal.aborted) return;
|
|
246
|
+
this.events.emit("connect_failed", error);
|
|
247
|
+
this.events.emit("error", error);
|
|
248
|
+
this.scheduleReconnect();
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
async receive(data, socket) {
|
|
252
|
+
this.resetInactivityTimer();
|
|
253
|
+
const value = typeof data === "string" ? data : data instanceof Blob ? await data.text() : Buffer.from(data).toString("utf8");
|
|
254
|
+
if (this.socket !== socket) return;
|
|
255
|
+
for (const packet of decodePayload(value)) this.handlePacket(packet);
|
|
256
|
+
}
|
|
257
|
+
handlePacket(packet) {
|
|
258
|
+
switch (packet.type) {
|
|
259
|
+
case "connect": {
|
|
260
|
+
const wasReconnecting = this.reconnectAttempts > 0;
|
|
261
|
+
this.connected = true;
|
|
262
|
+
this.events.emit("connect");
|
|
263
|
+
if (wasReconnecting) {
|
|
264
|
+
this.events.emit("reconnect", "websocket", this.reconnectAttempts);
|
|
265
|
+
this.reconnectAttempts = 0;
|
|
266
|
+
}
|
|
267
|
+
break;
|
|
268
|
+
}
|
|
269
|
+
case "heartbeat":
|
|
270
|
+
this.send(encodePacket({ type: "heartbeat" }));
|
|
271
|
+
break;
|
|
272
|
+
case "event": {
|
|
273
|
+
const args = [...packet.args ?? []];
|
|
274
|
+
if (packet.ack === "data" && packet.id) args.push((...acknowledgement) => {
|
|
275
|
+
this.send(encodePacket({
|
|
276
|
+
type: "ack",
|
|
277
|
+
ackId: packet.id,
|
|
278
|
+
args: acknowledgement
|
|
279
|
+
}));
|
|
280
|
+
});
|
|
281
|
+
this.events.emit(packet.name ?? "", ...args);
|
|
282
|
+
break;
|
|
283
|
+
}
|
|
284
|
+
case "ack": {
|
|
285
|
+
const callback = packet.ackId && this.acknowledgements.get(packet.ackId);
|
|
286
|
+
if (callback && packet.ackId) {
|
|
287
|
+
this.acknowledgements.delete(packet.ackId);
|
|
288
|
+
callback(...packet.args ?? []);
|
|
289
|
+
}
|
|
290
|
+
break;
|
|
291
|
+
}
|
|
292
|
+
case "disconnect":
|
|
293
|
+
this.closeSocket("booted");
|
|
294
|
+
break;
|
|
295
|
+
case "error":
|
|
296
|
+
this.events.emit("error", packet.reason || "Socket.IO protocol error");
|
|
297
|
+
if (packet.advice === "reconnect") this.closeSocket("server error");
|
|
298
|
+
break;
|
|
299
|
+
case "message":
|
|
300
|
+
case "json": this.events.emit("message", packet.data);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
send(packet) {
|
|
304
|
+
if (this.connected && this.socket?.readyState === WebSocket.OPEN) this.socket.send(packet);
|
|
305
|
+
}
|
|
306
|
+
sendApplicationEvent(packet) {
|
|
307
|
+
if (!this.connected || !this.projectJoined || this.socket?.readyState !== WebSocket.OPEN) return false;
|
|
308
|
+
this.socket.send(packet);
|
|
309
|
+
return true;
|
|
310
|
+
}
|
|
311
|
+
resetInactivityTimer() {
|
|
312
|
+
if (this.inactivityTimer) clearTimeout(this.inactivityTimer);
|
|
313
|
+
this.inactivityTimer = setTimeout(() => this.closeSocket("timeout"), this.closeTimeoutMs);
|
|
314
|
+
}
|
|
315
|
+
handleClose(event) {
|
|
316
|
+
const wasConnected = this.connected;
|
|
317
|
+
this.socket = void 0;
|
|
318
|
+
this.connected = false;
|
|
319
|
+
this.projectJoined = false;
|
|
320
|
+
if (this.inactivityTimer) clearTimeout(this.inactivityTimer);
|
|
321
|
+
if (wasConnected) {
|
|
322
|
+
const detail = event.reason ? `: ${event.reason}` : "";
|
|
323
|
+
const reason = `transport close (code ${event.code})${detail}`;
|
|
324
|
+
this.failAcknowledgements(reason);
|
|
325
|
+
this.events.emit("disconnect", reason);
|
|
326
|
+
}
|
|
327
|
+
this.scheduleReconnect();
|
|
328
|
+
}
|
|
329
|
+
closeSocket(reason) {
|
|
330
|
+
this.connectionAbort?.abort();
|
|
331
|
+
this.connectionAbort = void 0;
|
|
332
|
+
const wasConnected = this.connected;
|
|
333
|
+
this.connected = false;
|
|
334
|
+
this.projectJoined = false;
|
|
335
|
+
if (this.inactivityTimer) clearTimeout(this.inactivityTimer);
|
|
336
|
+
const socket = this.socket;
|
|
337
|
+
this.socket = void 0;
|
|
338
|
+
socket?.close();
|
|
339
|
+
if (wasConnected) {
|
|
340
|
+
this.failAcknowledgements(reason);
|
|
341
|
+
this.events.emit("disconnect", reason);
|
|
342
|
+
}
|
|
343
|
+
if (!this.explicitlyDisconnected) this.scheduleReconnect();
|
|
344
|
+
}
|
|
345
|
+
failAcknowledgements(reason) {
|
|
346
|
+
const error = /* @__PURE__ */ new Error(`Overleaf realtime connection closed: ${reason}`);
|
|
347
|
+
for (const callback of this.acknowledgements.values()) callback(error);
|
|
348
|
+
this.acknowledgements.clear();
|
|
349
|
+
}
|
|
350
|
+
scheduleReconnect() {
|
|
351
|
+
if (!this.reconnectEnabled || this.explicitlyDisconnected || this.reconnectTimer) return;
|
|
352
|
+
const maxAttempts = this.options.maxReconnectionAttempts ?? Infinity;
|
|
353
|
+
if (this.reconnectAttempts >= maxAttempts) {
|
|
354
|
+
this.events.emit("reconnect_failed");
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
this.reconnectAttempts += 1;
|
|
358
|
+
const initial = this.options.reconnectionDelay ?? 1e3;
|
|
359
|
+
const limit = this.options.reconnectionLimit ?? 16e3;
|
|
360
|
+
const delay = Math.min(initial * 2 ** (this.reconnectAttempts - 1), limit);
|
|
361
|
+
this.events.emit("reconnecting", delay, this.reconnectAttempts);
|
|
362
|
+
this.reconnectTimer = setTimeout(() => {
|
|
363
|
+
this.reconnectTimer = void 0;
|
|
364
|
+
this.connect();
|
|
365
|
+
}, delay);
|
|
366
|
+
}
|
|
367
|
+
};
|
|
368
|
+
let sink;
|
|
369
|
+
function setLogSink(nextSink) {
|
|
370
|
+
sink = nextSink;
|
|
371
|
+
}
|
|
372
|
+
function write(level, args) {
|
|
373
|
+
if (sink !== void 0) {
|
|
374
|
+
sink(level, args);
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
(level === "error" ? console.error : level === "warn" ? console.warn : console.log)(...args);
|
|
378
|
+
}
|
|
379
|
+
function log(...args) {
|
|
380
|
+
write("info", args);
|
|
381
|
+
}
|
|
382
|
+
function error(...args) {
|
|
383
|
+
write("error", args);
|
|
384
|
+
}
|
|
385
|
+
function retryAfterMilliseconds(response, now) {
|
|
386
|
+
const retryAfter = response.headers.get("retry-after");
|
|
387
|
+
if (retryAfter) {
|
|
388
|
+
const seconds = Number(retryAfter);
|
|
389
|
+
if (Number.isFinite(seconds)) return Math.max(1e3, seconds * 1e3);
|
|
390
|
+
const retryAt = Date.parse(retryAfter);
|
|
391
|
+
if (Number.isFinite(retryAt)) return Math.max(1e3, retryAt - now);
|
|
392
|
+
}
|
|
393
|
+
return 5e3;
|
|
394
|
+
}
|
|
395
|
+
var HttpRequestScheduler = class {
|
|
396
|
+
options;
|
|
397
|
+
chain = Promise.resolve();
|
|
398
|
+
nextRequestAt = 0;
|
|
399
|
+
historyNextRequestAt = 0;
|
|
400
|
+
rateLimitedUntil = 0;
|
|
401
|
+
minimumIntervalMs;
|
|
402
|
+
historyIntervalMs;
|
|
403
|
+
fetchImplementation;
|
|
404
|
+
now;
|
|
405
|
+
wait;
|
|
406
|
+
constructor(options = {}) {
|
|
407
|
+
this.options = options;
|
|
408
|
+
this.minimumIntervalMs = options.minimumIntervalMs ?? 300;
|
|
409
|
+
this.historyIntervalMs = options.historyIntervalMs ?? 3e3;
|
|
410
|
+
this.fetchImplementation = options.fetch ?? fetch;
|
|
411
|
+
this.now = options.now ?? Date.now;
|
|
412
|
+
this.wait = options.wait ?? ((milliseconds) => new Promise((resolveWait) => setTimeout(resolveWait, milliseconds)));
|
|
413
|
+
}
|
|
414
|
+
fetch(input, init, timeoutMs = this.options.requestTimeoutMs ?? 6e4) {
|
|
415
|
+
const url = input instanceof Request ? input.url : String(input);
|
|
416
|
+
const history = url.includes("/updates?") || url.includes("/filetree/diff?") || url.includes("/diff?");
|
|
417
|
+
return this.enqueue(async () => {
|
|
418
|
+
const now = this.now();
|
|
419
|
+
const readyAt = Math.max(this.nextRequestAt, this.rateLimitedUntil, history ? this.historyNextRequestAt : 0);
|
|
420
|
+
if (readyAt > now) await this.wait(readyAt - now);
|
|
421
|
+
const requestAt = this.now();
|
|
422
|
+
this.nextRequestAt = requestAt + this.minimumIntervalMs;
|
|
423
|
+
if (history) this.historyNextRequestAt = requestAt + this.historyIntervalMs;
|
|
424
|
+
const signals = [
|
|
425
|
+
AbortSignal.timeout(timeoutMs),
|
|
426
|
+
init?.signal,
|
|
427
|
+
input instanceof Request ? input.signal : void 0
|
|
428
|
+
].filter((signal) => Boolean(signal));
|
|
429
|
+
let response;
|
|
430
|
+
try {
|
|
431
|
+
response = await this.fetchImplementation(input, {
|
|
432
|
+
...init,
|
|
433
|
+
signal: AbortSignal.any(signals)
|
|
434
|
+
});
|
|
435
|
+
} catch (error$1) {
|
|
436
|
+
const destination = new URL(url);
|
|
437
|
+
error("HTTP request failed", {
|
|
438
|
+
method: init?.method ?? (input instanceof Request ? input.method : "GET"),
|
|
439
|
+
url: `${destination.origin}${destination.pathname}`,
|
|
440
|
+
error: error$1
|
|
441
|
+
});
|
|
442
|
+
throw error$1;
|
|
443
|
+
}
|
|
444
|
+
if (response.status === 429) {
|
|
445
|
+
this.rateLimitedUntil = Math.max(this.rateLimitedUntil, this.now() + retryAfterMilliseconds(response, this.now()));
|
|
446
|
+
this.options.onRateLimit?.(this.rateLimitedUntil);
|
|
447
|
+
log(`HTTP request scheduler entered rate-limit cooldown until ${new Date(this.rateLimitedUntil).toISOString()}.`);
|
|
448
|
+
}
|
|
449
|
+
return response;
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
enqueue(operation) {
|
|
453
|
+
const result = this.chain.then(operation);
|
|
454
|
+
this.chain = result.then(() => void 0, () => void 0);
|
|
455
|
+
return result;
|
|
456
|
+
}
|
|
457
|
+
};
|
|
458
|
+
const sharedHttpRequestScheduler = new HttpRequestScheduler();
|
|
459
|
+
/** Extract set-cookie headers from an undici/Response object. */
|
|
460
|
+
function getSetCookie(res) {
|
|
461
|
+
if (typeof res.headers?.getSetCookie === "function") return res.headers.getSetCookie();
|
|
462
|
+
const raw = res.headers?.raw?.()?.["set-cookie"];
|
|
463
|
+
if (raw) return raw;
|
|
464
|
+
return [];
|
|
465
|
+
}
|
|
466
|
+
var BaseAPI = class {
|
|
467
|
+
scheduler;
|
|
468
|
+
url;
|
|
469
|
+
identity;
|
|
470
|
+
retryAfterMs(response, attempt) {
|
|
471
|
+
const retryAfter = response.headers?.get?.("retry-after");
|
|
472
|
+
if (retryAfter !== void 0 && retryAfter !== null) {
|
|
473
|
+
const seconds = Number(retryAfter);
|
|
474
|
+
if (Number.isFinite(seconds)) return Math.max(1e3, seconds * 1e3);
|
|
475
|
+
const retryAt = Date.parse(retryAfter);
|
|
476
|
+
if (Number.isFinite(retryAt)) return Math.max(1e3, retryAt - Date.now());
|
|
477
|
+
}
|
|
478
|
+
return 5e3 * Math.pow(2, attempt);
|
|
479
|
+
}
|
|
480
|
+
constructor(url, scheduler = sharedHttpRequestScheduler) {
|
|
481
|
+
this.scheduler = scheduler;
|
|
482
|
+
this.url = url;
|
|
483
|
+
}
|
|
484
|
+
async getCsrfToken() {
|
|
485
|
+
const res = await this.scheduler.fetch(this.url + "login", {
|
|
486
|
+
method: "GET",
|
|
487
|
+
redirect: "manual"
|
|
488
|
+
});
|
|
489
|
+
const match = (await res.text()).match(/<input.*name="_csrf".*value="([^"]*)">/);
|
|
490
|
+
if (!match) throw new Error("Failed to get CSRF token.");
|
|
491
|
+
else return {
|
|
492
|
+
csrfToken: match[1],
|
|
493
|
+
cookies: getSetCookie(res)[0]?.split(";")[0] ?? ""
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
async getUserId(cookies) {
|
|
497
|
+
const body = await (await this.scheduler.fetch(this.url + "project", {
|
|
498
|
+
method: "GET",
|
|
499
|
+
redirect: "manual",
|
|
500
|
+
headers: {
|
|
501
|
+
Connection: "keep-alive",
|
|
502
|
+
Cookie: cookies
|
|
503
|
+
}
|
|
504
|
+
})).text();
|
|
505
|
+
const userIDMatch = body.match(/<meta\s+name="ol-user_id"\s+content="([^"]*)">/);
|
|
506
|
+
const userEmailMatch = body.match(/<meta\s+name="ol-usersEmail"\s+content="([^"]*)">/);
|
|
507
|
+
const csrfTokenMatch = body.match(/<meta\s+name="ol-csrfToken"\s+content="([^"]*)">/);
|
|
508
|
+
if (userIDMatch !== null && csrfTokenMatch !== null) {
|
|
509
|
+
const userId = userIDMatch[1];
|
|
510
|
+
const csrfToken = csrfTokenMatch[1];
|
|
511
|
+
return {
|
|
512
|
+
userId,
|
|
513
|
+
userEmail: userEmailMatch ? userEmailMatch[1] : "",
|
|
514
|
+
csrfToken
|
|
515
|
+
};
|
|
516
|
+
} else return;
|
|
517
|
+
}
|
|
518
|
+
_initSocketV0(identity, query) {
|
|
519
|
+
return new OverleafRealtimeSocket(new URL(this.url).origin + (query ?? ""), {
|
|
520
|
+
origin: new URL(this.url).origin,
|
|
521
|
+
cookie: identity.cookies,
|
|
522
|
+
reconnect: true,
|
|
523
|
+
reconnectionDelay: 1e3,
|
|
524
|
+
reconnectionLimit: 16e3,
|
|
525
|
+
maxReconnectionAttempts: 10,
|
|
526
|
+
fetch: (input, init) => this.scheduler.fetch(input, init)
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
async passportLogin(email, password) {
|
|
530
|
+
const identity = await this.getCsrfToken();
|
|
531
|
+
const res = await this.scheduler.fetch(this.url + "login", {
|
|
532
|
+
method: "POST",
|
|
533
|
+
redirect: "manual",
|
|
534
|
+
headers: {
|
|
535
|
+
Accept: "*/*",
|
|
536
|
+
"Accept-Encoding": "gzip, deflate, br",
|
|
537
|
+
Connection: "keep-alive",
|
|
538
|
+
"Content-Type": "application/json",
|
|
539
|
+
Cookie: identity.cookies,
|
|
540
|
+
"X-Csrf-Token": identity.csrfToken
|
|
541
|
+
},
|
|
542
|
+
body: JSON.stringify({
|
|
543
|
+
_csrf: identity.csrfToken,
|
|
544
|
+
email,
|
|
545
|
+
password
|
|
546
|
+
})
|
|
547
|
+
});
|
|
548
|
+
if (res.status === 302) {
|
|
549
|
+
const redirect = (await res.text()).match(/Found. Redirecting to (.*)/)[1];
|
|
550
|
+
if (redirect === "/project") {
|
|
551
|
+
const cookies = getSetCookie(res)[0] ?? "";
|
|
552
|
+
return await this.cookiesLogin(cookies);
|
|
553
|
+
} else return {
|
|
554
|
+
type: "error",
|
|
555
|
+
message: `Redirecting to /${redirect}`
|
|
556
|
+
};
|
|
557
|
+
} else if (res.status === 200) return {
|
|
558
|
+
type: "error",
|
|
559
|
+
message: (await res.json()).message.message
|
|
560
|
+
};
|
|
561
|
+
else if (res.status === 401) return {
|
|
562
|
+
type: "error",
|
|
563
|
+
message: (await res.json()).message.text
|
|
564
|
+
};
|
|
565
|
+
else return {
|
|
566
|
+
type: "error",
|
|
567
|
+
message: `${res.status}: ` + await res.text()
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
async cookiesLogin(cookies) {
|
|
571
|
+
const res = await this.getUserId(cookies);
|
|
572
|
+
if (res) {
|
|
573
|
+
const { userId, userEmail, csrfToken } = res;
|
|
574
|
+
const identity = await this.updateCookies({
|
|
575
|
+
cookies,
|
|
576
|
+
csrfToken
|
|
577
|
+
});
|
|
578
|
+
return {
|
|
579
|
+
type: "success",
|
|
580
|
+
userInfo: {
|
|
581
|
+
userId,
|
|
582
|
+
userEmail
|
|
583
|
+
},
|
|
584
|
+
identity
|
|
585
|
+
};
|
|
586
|
+
} else return {
|
|
587
|
+
type: "error",
|
|
588
|
+
message: "Failed to get User ID."
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
async updateCookies(identity) {
|
|
592
|
+
const cookies = getSetCookie(await this.scheduler.fetch(this.url + "socket.io/socket.io.js", {
|
|
593
|
+
method: "GET",
|
|
594
|
+
redirect: "manual",
|
|
595
|
+
headers: {
|
|
596
|
+
Connection: "keep-alive",
|
|
597
|
+
Cookie: identity.cookies
|
|
598
|
+
}
|
|
599
|
+
}))[0]?.split(";")[0];
|
|
600
|
+
if (cookies) identity.cookies = `${identity.cookies}; ${cookies}`;
|
|
601
|
+
return identity;
|
|
602
|
+
}
|
|
603
|
+
setIdentity(identity) {
|
|
604
|
+
this.identity = identity;
|
|
605
|
+
return this;
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* Check if an HTTP error is transient (worth retrying).
|
|
609
|
+
* Retries on: 5xx server errors, network errors (fetch failures), and 429 rate limiting.
|
|
610
|
+
*/
|
|
611
|
+
isTransientError(statusCode, errorMessage) {
|
|
612
|
+
if (statusCode === void 0) return true;
|
|
613
|
+
if (statusCode >= 500 || statusCode === 429) return true;
|
|
614
|
+
if (errorMessage && (errorMessage.includes("ECONNRESET") || errorMessage.includes("ETIMEDOUT") || errorMessage.includes("ECONNREFUSED") || errorMessage.includes("ENOTFOUND") || errorMessage.includes("socket hang up"))) return true;
|
|
615
|
+
return false;
|
|
616
|
+
}
|
|
617
|
+
async request(type, route, body, callback, extraHeaders, timeoutMs) {
|
|
618
|
+
if (this.identity === void 0) return Promise.reject();
|
|
619
|
+
const MAX_HTTP_RETRIES = 2;
|
|
620
|
+
let lastError = {};
|
|
621
|
+
for (let attempt = 0; attempt <= MAX_HTTP_RETRIES; attempt++) try {
|
|
622
|
+
let res = void 0;
|
|
623
|
+
switch (type) {
|
|
624
|
+
case "GET":
|
|
625
|
+
res = await this.scheduler.fetch(this.url + route, {
|
|
626
|
+
method: "GET",
|
|
627
|
+
redirect: "manual",
|
|
628
|
+
headers: {
|
|
629
|
+
Connection: "keep-alive",
|
|
630
|
+
Cookie: this.identity.cookies,
|
|
631
|
+
...extraHeaders
|
|
632
|
+
}
|
|
633
|
+
}, timeoutMs);
|
|
634
|
+
break;
|
|
635
|
+
case "POST":
|
|
636
|
+
const content_type = body instanceof FormData ? void 0 : { "Content-Type": "application/json" };
|
|
637
|
+
const raw_body = body instanceof FormData ? body : JSON.stringify({
|
|
638
|
+
_csrf: this.identity.csrfToken,
|
|
639
|
+
...body
|
|
640
|
+
});
|
|
641
|
+
res = await this.scheduler.fetch(this.url + route, {
|
|
642
|
+
method: "POST",
|
|
643
|
+
redirect: "manual",
|
|
644
|
+
headers: {
|
|
645
|
+
Connection: "keep-alive",
|
|
646
|
+
Cookie: this.identity.cookies,
|
|
647
|
+
...content_type,
|
|
648
|
+
...extraHeaders
|
|
649
|
+
},
|
|
650
|
+
body: raw_body
|
|
651
|
+
}, timeoutMs);
|
|
652
|
+
break;
|
|
653
|
+
case "PUT": break;
|
|
654
|
+
case "DELETE": res = await this.scheduler.fetch(this.url + route, {
|
|
655
|
+
method: "DELETE",
|
|
656
|
+
redirect: "manual",
|
|
657
|
+
headers: {
|
|
658
|
+
Connection: "keep-alive",
|
|
659
|
+
Cookie: this.identity.cookies,
|
|
660
|
+
"X-Csrf-Token": this.identity.csrfToken,
|
|
661
|
+
...extraHeaders
|
|
662
|
+
}
|
|
663
|
+
}, timeoutMs);
|
|
664
|
+
}
|
|
665
|
+
if (res && (res.status === 200 || res.status === 204)) {
|
|
666
|
+
const _res = res.status === 200 ? await res.text() : void 0;
|
|
667
|
+
return {
|
|
668
|
+
type: "success",
|
|
669
|
+
...callback && callback(_res)
|
|
670
|
+
};
|
|
671
|
+
} else if (res && this.isTransientError(res.status) && attempt < MAX_HTTP_RETRIES) {
|
|
672
|
+
const delayMs = res.status === 429 ? this.retryAfterMs(res, attempt) : Math.min(1e3 * Math.pow(2, attempt), 4e3);
|
|
673
|
+
log(`HTTP ${res.status} on ${route}, retrying in ${delayMs}ms (attempt ${attempt + 1}/${MAX_HTTP_RETRIES})`);
|
|
674
|
+
lastError = {
|
|
675
|
+
statusCode: res.status,
|
|
676
|
+
message: await res.text().catch(() => "")
|
|
677
|
+
};
|
|
678
|
+
await new Promise((r) => setTimeout(r, delayMs));
|
|
679
|
+
continue;
|
|
680
|
+
} else {
|
|
681
|
+
const resOrFallback = res || {
|
|
682
|
+
status: "undefined",
|
|
683
|
+
text: async () => ""
|
|
684
|
+
};
|
|
685
|
+
let errorBody = "";
|
|
686
|
+
try {
|
|
687
|
+
errorBody = await resOrFallback.text();
|
|
688
|
+
} catch {
|
|
689
|
+
errorBody = "";
|
|
690
|
+
}
|
|
691
|
+
return {
|
|
692
|
+
type: "error",
|
|
693
|
+
statusCode: typeof resOrFallback.status === "number" ? resOrFallback.status : void 0,
|
|
694
|
+
message: `${resOrFallback.status}: ${errorBody}`
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
} catch (err) {
|
|
698
|
+
const errMsg = err?.message || String(err);
|
|
699
|
+
if (this.isTransientError(void 0, errMsg) && attempt < MAX_HTTP_RETRIES) {
|
|
700
|
+
const delayMs = Math.min(1e3 * Math.pow(2, attempt), 4e3);
|
|
701
|
+
log(`HTTP fetch error on ${route}: ${errMsg}, retrying in ${delayMs}ms (attempt ${attempt + 1}/${MAX_HTTP_RETRIES})`);
|
|
702
|
+
await new Promise((r) => setTimeout(r, delayMs));
|
|
703
|
+
continue;
|
|
704
|
+
}
|
|
705
|
+
return {
|
|
706
|
+
type: "error",
|
|
707
|
+
statusCode: void 0,
|
|
708
|
+
message: errMsg
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
return {
|
|
712
|
+
type: "error",
|
|
713
|
+
statusCode: lastError.statusCode,
|
|
714
|
+
message: lastError.message || `Request failed after 3 attempts`
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
async download(route) {
|
|
718
|
+
if (this.identity === void 0) return Promise.reject();
|
|
719
|
+
let content = [];
|
|
720
|
+
while (true) {
|
|
721
|
+
const res = await this.scheduler.fetch(this.url + route, {
|
|
722
|
+
method: "GET",
|
|
723
|
+
redirect: "manual",
|
|
724
|
+
headers: {
|
|
725
|
+
Connection: "keep-alive",
|
|
726
|
+
Cookie: this.identity.cookies
|
|
727
|
+
}
|
|
728
|
+
});
|
|
729
|
+
if (res.status === 200) {
|
|
730
|
+
content.push(Buffer.from(await res.arrayBuffer()));
|
|
731
|
+
break;
|
|
732
|
+
} else if (res.status === 206) content.push(Buffer.from(await res.arrayBuffer()));
|
|
733
|
+
else break;
|
|
734
|
+
}
|
|
735
|
+
return Buffer.concat(content);
|
|
736
|
+
}
|
|
737
|
+
async logout(identity) {
|
|
738
|
+
this.setIdentity(identity);
|
|
739
|
+
return this.request("POST", "logout");
|
|
740
|
+
}
|
|
741
|
+
async userProjectsJson(identity) {
|
|
742
|
+
this.setIdentity(identity);
|
|
743
|
+
return this.request("GET", "user/projects", void 0, (res) => {
|
|
744
|
+
const projects = JSON.parse(res).projects;
|
|
745
|
+
projects.forEach((project) => {
|
|
746
|
+
project.id = project._id;
|
|
747
|
+
delete project._id;
|
|
748
|
+
});
|
|
749
|
+
return { projects };
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
async getProjectsJson(identity) {
|
|
753
|
+
this.setIdentity(identity);
|
|
754
|
+
return this.request("POST", "api/project", {}, (res) => {
|
|
755
|
+
return { projects: JSON.parse(res).projects };
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
async projectEntitiesJson(identity, projectId) {
|
|
759
|
+
this.setIdentity(identity);
|
|
760
|
+
return this.request("GET", `project/${projectId}/entities`, void 0, (res) => {
|
|
761
|
+
return { entities: JSON.parse(res).entities };
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
async newProject(identity, projectName, template) {
|
|
765
|
+
this.setIdentity(identity);
|
|
766
|
+
return this.request("POST", "project/new", {
|
|
767
|
+
projectName,
|
|
768
|
+
template
|
|
769
|
+
}, (res) => {
|
|
770
|
+
return { message: JSON.parse(res).project_id };
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
async cloneProject(identity, projectId, projectName) {
|
|
774
|
+
this.setIdentity(identity);
|
|
775
|
+
return this.request("POST", `project/${projectId}/clone`, { projectName }, (res) => {
|
|
776
|
+
return { message: JSON.parse(res).project_id };
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
async renameProject(identity, projectId, newProjectName) {
|
|
780
|
+
this.setIdentity(identity);
|
|
781
|
+
return this.request("POST", `project/${projectId}/rename`, { newProjectName });
|
|
782
|
+
}
|
|
783
|
+
async deleteProject(identity, projectId) {
|
|
784
|
+
this.setIdentity(identity);
|
|
785
|
+
return this.request("DELETE", `project/${projectId}`);
|
|
786
|
+
}
|
|
787
|
+
async archiveProject(identity, projectId) {
|
|
788
|
+
this.setIdentity(identity);
|
|
789
|
+
return this.request("POST", `project/${projectId}/archive`, void 0, void 0, { "X-Csrf-Token": identity.csrfToken });
|
|
790
|
+
}
|
|
791
|
+
async unarchiveProject(identity, projectId) {
|
|
792
|
+
this.setIdentity(identity);
|
|
793
|
+
return this.request("DELETE", `project/${projectId}/archive`);
|
|
794
|
+
}
|
|
795
|
+
async trashProject(identity, projectId) {
|
|
796
|
+
this.setIdentity(identity);
|
|
797
|
+
return this.request("POST", `project/${projectId}/trash`, void 0, void 0, { "X-Csrf-Token": identity.csrfToken });
|
|
798
|
+
}
|
|
799
|
+
async untrashProject(identity, projectId) {
|
|
800
|
+
this.setIdentity(identity);
|
|
801
|
+
return this.request("DELETE", `project/${projectId}/trash`);
|
|
802
|
+
}
|
|
803
|
+
async getFile(identity, projectId, fileId) {
|
|
804
|
+
this.setIdentity(identity);
|
|
805
|
+
const content = await this.download(`project/${projectId}/file/${fileId}`);
|
|
806
|
+
return {
|
|
807
|
+
type: "success",
|
|
808
|
+
content: new Uint8Array(content)
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
async addDoc(identity, projectId, parentFolderId, filename) {
|
|
812
|
+
this.setIdentity(identity);
|
|
813
|
+
return this.request("POST", `project/${projectId}/doc`, {
|
|
814
|
+
parent_folder_id: parentFolderId,
|
|
815
|
+
name: filename
|
|
816
|
+
}, (res) => {
|
|
817
|
+
const { _id } = JSON.parse(res);
|
|
818
|
+
return { entity: {
|
|
819
|
+
_type: "doc",
|
|
820
|
+
_id,
|
|
821
|
+
name: filename
|
|
822
|
+
} };
|
|
823
|
+
}, { "X-Csrf-Token": identity.csrfToken });
|
|
824
|
+
}
|
|
825
|
+
async uploadFile(identity, projectId, parentFolderId, filename, fileContent) {
|
|
826
|
+
const formData = new FormData();
|
|
827
|
+
const mimeType = mime.getType(filename);
|
|
828
|
+
formData.append("targetFolderId", parentFolderId);
|
|
829
|
+
formData.append("name", filename);
|
|
830
|
+
formData.append("type", mimeType ? mimeType : "text/plain");
|
|
831
|
+
formData.append("qqfile", new Blob$1([Buffer.from(fileContent)], { type: mimeType ? mimeType : "application/octet-stream" }), filename);
|
|
832
|
+
this.setIdentity(identity);
|
|
833
|
+
return this.request("POST", `project/${projectId}/upload?folder_id=${parentFolderId}`, formData, (res) => {
|
|
834
|
+
const { entity_id, entity_type } = JSON.parse(res);
|
|
835
|
+
return { entity: {
|
|
836
|
+
_type: entity_type,
|
|
837
|
+
_id: entity_id,
|
|
838
|
+
name: filename
|
|
839
|
+
} };
|
|
840
|
+
}, { "X-Csrf-Token": identity.csrfToken });
|
|
841
|
+
}
|
|
842
|
+
async uploadProject(identity, filename, fileContent) {
|
|
843
|
+
const uuid = v4();
|
|
844
|
+
const formData = new FormData();
|
|
845
|
+
formData.append("qqfile", new Blob$1([Buffer.from(fileContent)], { type: "application/zip" }), filename);
|
|
846
|
+
this.setIdentity(identity);
|
|
847
|
+
return this.request("POST", `project/new/upload?_csrf=${identity.csrfToken}&qquuid=${uuid}&qqfilename=${filename}&qqtotalfilesize=${fileContent.length}`, formData, (res) => {
|
|
848
|
+
return { message: JSON.parse(res).project_id };
|
|
849
|
+
});
|
|
850
|
+
}
|
|
851
|
+
async addFolder(identity, projectId, folderName, parentFolderId) {
|
|
852
|
+
const body = {
|
|
853
|
+
name: folderName,
|
|
854
|
+
parent_folder_id: parentFolderId
|
|
855
|
+
};
|
|
856
|
+
this.setIdentity(identity);
|
|
857
|
+
return this.request("POST", `project/${projectId}/folder`, body, (res) => {
|
|
858
|
+
return { entity: JSON.parse(res) };
|
|
859
|
+
}, { "X-Csrf-Token": identity.csrfToken });
|
|
860
|
+
}
|
|
861
|
+
async deleteEntity(identity, projectId, fileType, fileId) {
|
|
862
|
+
this.setIdentity(identity);
|
|
863
|
+
return this.request("DELETE", `project/${projectId}/${fileType}/${fileId}`);
|
|
864
|
+
}
|
|
865
|
+
async deleteAuxFiles(identity, projectId) {
|
|
866
|
+
this.setIdentity(identity);
|
|
867
|
+
return this.request("DELETE", `project/${projectId}/output`);
|
|
868
|
+
}
|
|
869
|
+
async renameEntity(identity, projectId, entityType, entityId, name) {
|
|
870
|
+
this.setIdentity(identity);
|
|
871
|
+
return this.request("POST", `project/${projectId}/${entityType}/${entityId}/rename`, { name }, void 0, { "X-Csrf-Token": identity.csrfToken });
|
|
872
|
+
}
|
|
873
|
+
async moveEntity(identity, projectId, entityType, entityId, newParentFolderId) {
|
|
874
|
+
this.setIdentity(identity);
|
|
875
|
+
return this.request("POST", `project/${projectId}/${entityType}/${entityId}/move`, { folder_id: newParentFolderId }, void 0, { "X-Csrf-Token": identity.csrfToken });
|
|
876
|
+
}
|
|
877
|
+
async compile(identity, projectId, rootResourcePath, draft = false, stopOnFirstError = false) {
|
|
878
|
+
const body = {
|
|
879
|
+
check: "silent",
|
|
880
|
+
draft,
|
|
881
|
+
incrementalCompilesEnabled: true,
|
|
882
|
+
rootResourcePath,
|
|
883
|
+
stopOnFirstError
|
|
884
|
+
};
|
|
885
|
+
this.setIdentity(identity);
|
|
886
|
+
return this.request("POST", `project/${projectId}/compile?auto_compile=true`, body, (res) => {
|
|
887
|
+
return { compile: JSON.parse(res) };
|
|
888
|
+
}, { "X-Csrf-Token": identity.csrfToken }, 3e5);
|
|
889
|
+
}
|
|
890
|
+
async stopCompile(identity, projectId) {
|
|
891
|
+
this.setIdentity(identity);
|
|
892
|
+
return this.request("POST", `project/${projectId}/compile/stop`, void 0, void 0, { "X-Csrf-Token": identity.csrfToken });
|
|
893
|
+
}
|
|
894
|
+
async indexAll(identity, projectId) {
|
|
895
|
+
this.setIdentity(identity);
|
|
896
|
+
return this.request("POST", `project/${projectId}/references/indexAll`, { shouldBroadcast: false }, void 0);
|
|
897
|
+
}
|
|
898
|
+
async getMetadata(identity, projectId) {
|
|
899
|
+
this.setIdentity(identity);
|
|
900
|
+
return this.request("GET", `project/${projectId}/metadata`, void 0, (res) => {
|
|
901
|
+
return { meta: JSON.parse(res) };
|
|
902
|
+
});
|
|
903
|
+
}
|
|
904
|
+
async proxyRequestToSpellingApi(identity, language, userId, words) {
|
|
905
|
+
const body = {
|
|
906
|
+
language,
|
|
907
|
+
skipLearnedWords: true,
|
|
908
|
+
token: userId,
|
|
909
|
+
words
|
|
910
|
+
};
|
|
911
|
+
this.setIdentity(identity);
|
|
912
|
+
return this.request("POST", "spelling/check", body, (res) => {
|
|
913
|
+
return { misspellings: JSON.parse(res).misspellings };
|
|
914
|
+
});
|
|
915
|
+
}
|
|
916
|
+
async spellingControllerLearn(identity, userId, word) {
|
|
917
|
+
const body = {
|
|
918
|
+
token: userId,
|
|
919
|
+
word
|
|
920
|
+
};
|
|
921
|
+
this.setIdentity(identity);
|
|
922
|
+
return this.request("POST", "spelling/learn", body);
|
|
923
|
+
}
|
|
924
|
+
async spellingControllerUnlearn(identity, word) {
|
|
925
|
+
this.setIdentity(identity);
|
|
926
|
+
return this.request("POST", "spelling/unlearn", { word }, void 0, { "X-Csrf-Token": identity.csrfToken });
|
|
927
|
+
}
|
|
928
|
+
async getProjectSettings(identity, projectId) {
|
|
929
|
+
this.setIdentity(identity);
|
|
930
|
+
return this.request("GET", `project/${projectId}`, void 0, (res) => {
|
|
931
|
+
const body = res || "";
|
|
932
|
+
const learnedWordsMatch = /<meta\s+name="ol-learnedWords"\s+data-type="json"\s+content="(\[.*?\])">/.exec(body);
|
|
933
|
+
const learnedWords = learnedWordsMatch !== null ? JSON.parse(learnedWordsMatch[1].replace(/"/g, "\"")) : [];
|
|
934
|
+
const languagesMatch = /<meta\s+name="ol-languages"\s+data-type="json"\s+content="(\[.*?\])">/.exec(body);
|
|
935
|
+
const languages = languagesMatch !== null ? JSON.parse(languagesMatch[1].replace(/"/g, "\"")) : [];
|
|
936
|
+
if (languages.length) languages.unshift({
|
|
937
|
+
name: "Off",
|
|
938
|
+
code: ""
|
|
939
|
+
});
|
|
940
|
+
return { settings: {
|
|
941
|
+
learnedWords,
|
|
942
|
+
languages,
|
|
943
|
+
compilers: [
|
|
944
|
+
{
|
|
945
|
+
code: "pdflatex",
|
|
946
|
+
name: "pdfLaTex"
|
|
947
|
+
},
|
|
948
|
+
{
|
|
949
|
+
code: "latex",
|
|
950
|
+
name: "LaTex"
|
|
951
|
+
},
|
|
952
|
+
{
|
|
953
|
+
code: "xelatex",
|
|
954
|
+
name: "XeLaTex"
|
|
955
|
+
},
|
|
956
|
+
{
|
|
957
|
+
code: "lualatex",
|
|
958
|
+
name: "LuaLaTex"
|
|
959
|
+
}
|
|
960
|
+
]
|
|
961
|
+
} };
|
|
962
|
+
});
|
|
963
|
+
}
|
|
964
|
+
async updateProjectSettings(identity, projectId, setting) {
|
|
965
|
+
this.setIdentity(identity);
|
|
966
|
+
return this.request("POST", `project/${projectId}/settings`, setting);
|
|
967
|
+
}
|
|
968
|
+
async getFileFromClsi(identity, url, compileGroup, clsiServerId, pdfDownloadDomain) {
|
|
969
|
+
if (pdfDownloadDomain && clsiServerId) {
|
|
970
|
+
const cdnUrl = `${pdfDownloadDomain.replace(/\/+$/, "")}/${url.replace(/^\/+/g, "")}?compileGroup=${encodeURIComponent(compileGroup)}&clsiserverid=${encodeURIComponent(clsiServerId)}&enable_pdf_caching=true`;
|
|
971
|
+
const content = await this._downloadAbsolute(cdnUrl, false);
|
|
972
|
+
return {
|
|
973
|
+
type: "success",
|
|
974
|
+
content: new Uint8Array(content)
|
|
975
|
+
};
|
|
976
|
+
}
|
|
977
|
+
url = url.replace(/^\/+/g, "");
|
|
978
|
+
this.setIdentity(identity);
|
|
979
|
+
const content = await this.download(url);
|
|
980
|
+
return {
|
|
981
|
+
type: "success",
|
|
982
|
+
content: new Uint8Array(content)
|
|
983
|
+
};
|
|
984
|
+
}
|
|
985
|
+
/** Download from an absolute URL, optionally including web frontend cookies. */
|
|
986
|
+
async _downloadAbsolute(absoluteUrl, includeCookies) {
|
|
987
|
+
const headers = { Connection: "keep-alive" };
|
|
988
|
+
if (includeCookies && this.identity) headers["Cookie"] = this.identity.cookies;
|
|
989
|
+
let content = [];
|
|
990
|
+
while (true) {
|
|
991
|
+
const res = await this.scheduler.fetch(absoluteUrl, {
|
|
992
|
+
method: "GET",
|
|
993
|
+
redirect: "manual",
|
|
994
|
+
headers
|
|
995
|
+
});
|
|
996
|
+
if (res.status === 200) {
|
|
997
|
+
content.push(Buffer.from(await res.arrayBuffer()));
|
|
998
|
+
break;
|
|
999
|
+
} else if (res.status === 206) content.push(Buffer.from(await res.arrayBuffer()));
|
|
1000
|
+
else break;
|
|
1001
|
+
}
|
|
1002
|
+
return Buffer.concat(content);
|
|
1003
|
+
}
|
|
1004
|
+
async proxySyncPdf(identity, projectId, page, h, v, buildId) {
|
|
1005
|
+
this.setIdentity(identity);
|
|
1006
|
+
const request = `project/${projectId}/sync/pdf?page=${page}&h=${h.toFixed(2)}&v=${v.toFixed(2)}&editorId=${v4()}&buildId=${buildId}`;
|
|
1007
|
+
return this.request("GET", request, void 0, (res) => {
|
|
1008
|
+
return { syncPdf: JSON.parse(res).code[0] };
|
|
1009
|
+
});
|
|
1010
|
+
}
|
|
1011
|
+
async proxySyncCode(identity, projectId, file, line, column, buildId) {
|
|
1012
|
+
this.setIdentity(identity);
|
|
1013
|
+
return this.request("GET", `project/${projectId}/sync/code?file=${file}&line=${line}&column=${column}&editorId=${v4()}&buildId=${buildId}`, void 0, (res) => {
|
|
1014
|
+
return { syncCode: JSON.parse(res).pdf };
|
|
1015
|
+
});
|
|
1016
|
+
}
|
|
1017
|
+
async getAllTags(identity) {
|
|
1018
|
+
this.setIdentity(identity);
|
|
1019
|
+
return this.request("GET", "tag", void 0, (res) => {
|
|
1020
|
+
return { tags: JSON.parse(res) };
|
|
1021
|
+
});
|
|
1022
|
+
}
|
|
1023
|
+
async createTag(identity, name) {
|
|
1024
|
+
this.setIdentity(identity);
|
|
1025
|
+
return this.request("POST", "tag", { name }, (res) => {
|
|
1026
|
+
return { tags: JSON.parse(res) };
|
|
1027
|
+
});
|
|
1028
|
+
}
|
|
1029
|
+
async renameTag(identity, tagId, name) {
|
|
1030
|
+
this.setIdentity(identity);
|
|
1031
|
+
return this.request("POST", `tag/${tagId}/rename`, { name });
|
|
1032
|
+
}
|
|
1033
|
+
async deleteTag(identity, tagId) {
|
|
1034
|
+
this.setIdentity(identity);
|
|
1035
|
+
return this.request("DELETE", `tag/${tagId}`);
|
|
1036
|
+
}
|
|
1037
|
+
async addProjectToTag(identity, tagId, projectId) {
|
|
1038
|
+
this.setIdentity(identity);
|
|
1039
|
+
return this.request("POST", `tag/${tagId}/project/${projectId}`);
|
|
1040
|
+
}
|
|
1041
|
+
async removeProjectFromTag(identity, tagId, projectId) {
|
|
1042
|
+
this.setIdentity(identity);
|
|
1043
|
+
return this.request("DELETE", `tag/${tagId}/project/${projectId}`);
|
|
1044
|
+
}
|
|
1045
|
+
async proxyToHistoryApiAndGetUpdates(identity, projectId, before) {
|
|
1046
|
+
const beforeQuery = before ? `&before=${before}` : "";
|
|
1047
|
+
this.setIdentity(identity);
|
|
1048
|
+
return this.request("GET", `project/${projectId}/updates?min_count=10${beforeQuery}`, void 0, (res) => {
|
|
1049
|
+
return { updates: JSON.parse(res) };
|
|
1050
|
+
});
|
|
1051
|
+
}
|
|
1052
|
+
async proxyToHistoryApiAndGetFileDiff(identity, projectId, pathname, from, to) {
|
|
1053
|
+
this.setIdentity(identity);
|
|
1054
|
+
return this.request("GET", `project/${projectId}/diff?pathname=${pathname}&from=${from}&to=${to}`, void 0, (res) => {
|
|
1055
|
+
return { diff: JSON.parse(res) };
|
|
1056
|
+
});
|
|
1057
|
+
}
|
|
1058
|
+
async proxyToHistoryApiAndGetFileTreeDiff(identity, projectId, from, to) {
|
|
1059
|
+
this.setIdentity(identity);
|
|
1060
|
+
return this.request("GET", `project/${projectId}/filetree/diff?from=${from}&to=${to}`, void 0, (res) => {
|
|
1061
|
+
return { treeDiff: JSON.parse(res) };
|
|
1062
|
+
});
|
|
1063
|
+
}
|
|
1064
|
+
async downloadZipOfVersion(identity, projectId, version) {
|
|
1065
|
+
this.setIdentity(identity);
|
|
1066
|
+
const content = await this.download(`project/${projectId}/version/${version}/zip`);
|
|
1067
|
+
return {
|
|
1068
|
+
type: "success",
|
|
1069
|
+
content: new Uint8Array(content)
|
|
1070
|
+
};
|
|
1071
|
+
}
|
|
1072
|
+
async getLabels(identity, projectId) {
|
|
1073
|
+
this.setIdentity(identity);
|
|
1074
|
+
return this.request("GET", `project/${projectId}/labels`, void 0, (res) => {
|
|
1075
|
+
return { labels: JSON.parse(res) };
|
|
1076
|
+
});
|
|
1077
|
+
}
|
|
1078
|
+
async createLabel(identity, projectId, comment, version) {
|
|
1079
|
+
this.setIdentity(identity);
|
|
1080
|
+
return this.request("POST", `project/${projectId}/labels`, {
|
|
1081
|
+
comment,
|
|
1082
|
+
version
|
|
1083
|
+
}, (res) => {
|
|
1084
|
+
return { labels: [JSON.parse(res)] };
|
|
1085
|
+
});
|
|
1086
|
+
}
|
|
1087
|
+
async deleteLabel(identity, projectId, labelId) {
|
|
1088
|
+
this.setIdentity(identity);
|
|
1089
|
+
return this.request("DELETE", `project/${projectId}/labels/${labelId}`);
|
|
1090
|
+
}
|
|
1091
|
+
async getMessages(identity, projectId, limit = 50) {
|
|
1092
|
+
this.setIdentity(identity);
|
|
1093
|
+
return this.request("GET", `project/${projectId}/messages?limit=${limit}`, void 0, (res) => {
|
|
1094
|
+
return { messages: JSON.parse(res) };
|
|
1095
|
+
}, { "X-Csrf-Token": identity.csrfToken });
|
|
1096
|
+
}
|
|
1097
|
+
async sendMessage(identity, projectId, client_id, content) {
|
|
1098
|
+
this.setIdentity(identity);
|
|
1099
|
+
return this.request("POST", `project/${projectId}/messages`, {
|
|
1100
|
+
client_id,
|
|
1101
|
+
content
|
|
1102
|
+
}, void 0, { "X-Csrf-Token": identity.csrfToken });
|
|
1103
|
+
}
|
|
1104
|
+
};
|
|
1105
|
+
var ExtendedBaseAPI = class extends BaseAPI {
|
|
1106
|
+
async refreshLinkedFile(identity, project_id, file_id) {
|
|
1107
|
+
this.setIdentity(identity);
|
|
1108
|
+
return await this.request("POST", `project/${project_id}/linked_file/${file_id}/refresh`, { shouldReindexReferences: false }, (res) => {
|
|
1109
|
+
return { message: JSON.parse(res).new_file_id };
|
|
1110
|
+
}, { "X-Csrf-Token": identity.csrfToken });
|
|
1111
|
+
}
|
|
1112
|
+
async createLinkedFile(identity, project_id, parent_folder_id, name, provider, data) {
|
|
1113
|
+
this.setIdentity(identity);
|
|
1114
|
+
return await this.request("POST", `project/${project_id}/linked_file`, {
|
|
1115
|
+
name,
|
|
1116
|
+
parent_folder_id,
|
|
1117
|
+
provider,
|
|
1118
|
+
data
|
|
1119
|
+
}, (res) => {
|
|
1120
|
+
return { message: JSON.parse(res).new_file_id };
|
|
1121
|
+
}, { "X-Csrf-Token": identity.csrfToken });
|
|
1122
|
+
}
|
|
1123
|
+
};
|
|
1124
|
+
const SOCKET_OPERATION_TIMEOUT_MS = 15e3;
|
|
1125
|
+
function decodePackedUtf8(text) {
|
|
1126
|
+
return Buffer.from(text, "latin1").toString("utf-8");
|
|
1127
|
+
}
|
|
1128
|
+
var SocketIOAPI = class SocketIOAPI {
|
|
1129
|
+
url;
|
|
1130
|
+
api;
|
|
1131
|
+
identity;
|
|
1132
|
+
projectId;
|
|
1133
|
+
static alternativeSocketFactory;
|
|
1134
|
+
scheme;
|
|
1135
|
+
record;
|
|
1136
|
+
joinedProject;
|
|
1137
|
+
pendingProjectJoin;
|
|
1138
|
+
_handlers = [];
|
|
1139
|
+
connectedPublicId;
|
|
1140
|
+
socket;
|
|
1141
|
+
emit;
|
|
1142
|
+
/** Track the scheme used when the socket was last initialized */
|
|
1143
|
+
_socketInitScheme;
|
|
1144
|
+
constructor(url, api, identity, projectId) {
|
|
1145
|
+
this.url = url;
|
|
1146
|
+
this.api = api;
|
|
1147
|
+
this.identity = identity;
|
|
1148
|
+
this.projectId = projectId;
|
|
1149
|
+
this.scheme = "realtime";
|
|
1150
|
+
this.init();
|
|
1151
|
+
}
|
|
1152
|
+
static setAlternativeSocketFactory(factory) {
|
|
1153
|
+
SocketIOAPI.alternativeSocketFactory = factory;
|
|
1154
|
+
}
|
|
1155
|
+
init() {
|
|
1156
|
+
if (this.socket) try {
|
|
1157
|
+
if (typeof this.socket.removeAllListeners === "function") this.socket.removeAllListeners();
|
|
1158
|
+
if (typeof this.socket.disconnect === "function") this.socket.disconnect();
|
|
1159
|
+
} catch {}
|
|
1160
|
+
switch (this.scheme) {
|
|
1161
|
+
case "Alt":
|
|
1162
|
+
if (!SocketIOAPI.alternativeSocketFactory) throw new Error("The alternative Overleaf connection is not available in this runtime.");
|
|
1163
|
+
this.socket = SocketIOAPI.alternativeSocketFactory(this.url, this.api, this.identity, this.projectId, this.record);
|
|
1164
|
+
break;
|
|
1165
|
+
case "realtime":
|
|
1166
|
+
this.record = void 0;
|
|
1167
|
+
this.joinedProject = void 0;
|
|
1168
|
+
this.resetProjectJoin(/* @__PURE__ */ new Error("The realtime connection was replaced."));
|
|
1169
|
+
const query = `?projectId=${this.projectId}&t=${Date.now()}`;
|
|
1170
|
+
this.socket = this.api._initSocketV0(this.identity, query);
|
|
1171
|
+
}
|
|
1172
|
+
this.socket.emit[promisify.custom] = (event, ...args) => {
|
|
1173
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
1174
|
+
setTimeout(() => {
|
|
1175
|
+
reject("timeout");
|
|
1176
|
+
}, SOCKET_OPERATION_TIMEOUT_MS);
|
|
1177
|
+
});
|
|
1178
|
+
const waitPromise = new Promise((resolve, reject) => {
|
|
1179
|
+
this.socket.emit(event, ...args, (err, ...data) => {
|
|
1180
|
+
if (err) reject(err);
|
|
1181
|
+
else resolve(data);
|
|
1182
|
+
});
|
|
1183
|
+
});
|
|
1184
|
+
return Promise.race([waitPromise, timeoutPromise]);
|
|
1185
|
+
};
|
|
1186
|
+
this.emit = promisify(this.socket.emit).bind(this.socket);
|
|
1187
|
+
this.initInternalHandlers();
|
|
1188
|
+
this.resumeEventHandlers(this._handlers);
|
|
1189
|
+
this._socketInitScheme = this.scheme;
|
|
1190
|
+
}
|
|
1191
|
+
/** Returns true if the socket needs re-initialization (scheme changed, or socket was never init'd) */
|
|
1192
|
+
get needsReinit() {
|
|
1193
|
+
return this._socketInitScheme !== this.scheme || !this.socket;
|
|
1194
|
+
}
|
|
1195
|
+
get connectionScheme() {
|
|
1196
|
+
return this.scheme;
|
|
1197
|
+
}
|
|
1198
|
+
initInternalHandlers() {
|
|
1199
|
+
this.socket.on("connect", () => {
|
|
1200
|
+
log("SocketIOAPI: connected", {
|
|
1201
|
+
scheme: this.scheme,
|
|
1202
|
+
projectId: this.projectId
|
|
1203
|
+
});
|
|
1204
|
+
});
|
|
1205
|
+
this.socket.on("connect_failed", (connectionError) => {
|
|
1206
|
+
log("SocketIOAPI: connect_failed", {
|
|
1207
|
+
scheme: this.scheme,
|
|
1208
|
+
projectId: this.projectId,
|
|
1209
|
+
error: connectionError
|
|
1210
|
+
});
|
|
1211
|
+
});
|
|
1212
|
+
this.socket.on("disconnect", (reason) => {
|
|
1213
|
+
log("SocketIOAPI: disconnect event", {
|
|
1214
|
+
scheme: this.scheme,
|
|
1215
|
+
projectId: this.projectId,
|
|
1216
|
+
reason
|
|
1217
|
+
});
|
|
1218
|
+
if (this.scheme === "realtime") {
|
|
1219
|
+
this.joinedProject = void 0;
|
|
1220
|
+
this.connectedPublicId = void 0;
|
|
1221
|
+
this.resetProjectJoin(/* @__PURE__ */ new Error(`Overleaf realtime connection closed: ${String(reason ?? "unknown")}`));
|
|
1222
|
+
}
|
|
1223
|
+
});
|
|
1224
|
+
this.socket.on("reconnecting", (delay, attempt) => {
|
|
1225
|
+
log("SocketIOAPI: reconnecting", {
|
|
1226
|
+
scheme: this.scheme,
|
|
1227
|
+
projectId: this.projectId,
|
|
1228
|
+
delay,
|
|
1229
|
+
attempt
|
|
1230
|
+
});
|
|
1231
|
+
});
|
|
1232
|
+
this.socket.on("reconnect", (transport, attempts) => {
|
|
1233
|
+
log("SocketIOAPI: reconnected", {
|
|
1234
|
+
scheme: this.scheme,
|
|
1235
|
+
projectId: this.projectId,
|
|
1236
|
+
transport,
|
|
1237
|
+
attempts
|
|
1238
|
+
});
|
|
1239
|
+
});
|
|
1240
|
+
this.socket.on("reconnect_failed", (error) => {
|
|
1241
|
+
log("SocketIOAPI: reconnect_failed", {
|
|
1242
|
+
scheme: this.scheme,
|
|
1243
|
+
projectId: this.projectId,
|
|
1244
|
+
error
|
|
1245
|
+
});
|
|
1246
|
+
});
|
|
1247
|
+
this.socket.on("forceDisconnect", (message, delay = 10) => {
|
|
1248
|
+
log("SocketIOAPI: forceDisconnect", {
|
|
1249
|
+
message,
|
|
1250
|
+
delay,
|
|
1251
|
+
projectId: this.projectId
|
|
1252
|
+
});
|
|
1253
|
+
});
|
|
1254
|
+
this.socket.on("connectionRejected", (err) => {
|
|
1255
|
+
log("SocketIOAPI: connectionRejected", {
|
|
1256
|
+
scheme: this.scheme,
|
|
1257
|
+
projectId: this.projectId,
|
|
1258
|
+
error: err
|
|
1259
|
+
});
|
|
1260
|
+
if (this.socket.io && typeof this.socket.io.reconnect === "function") this.socket.io.reconnect(false);
|
|
1261
|
+
});
|
|
1262
|
+
this.socket.on("error", (err) => {
|
|
1263
|
+
error(`Overleaf connection error: ${err?.message || String(err)}`);
|
|
1264
|
+
});
|
|
1265
|
+
if (this.scheme === "realtime") {
|
|
1266
|
+
this.pendingProjectJoin ??= this.createProjectJoin();
|
|
1267
|
+
this.socket.on("joinProjectResponse", (res) => {
|
|
1268
|
+
const publicId = res.publicId;
|
|
1269
|
+
const project = res.project;
|
|
1270
|
+
this.socket.markProjectJoined?.();
|
|
1271
|
+
log("SocketIOAPI: project joined", {
|
|
1272
|
+
scheme: this.scheme,
|
|
1273
|
+
projectId: this.projectId,
|
|
1274
|
+
publicId
|
|
1275
|
+
});
|
|
1276
|
+
this.connectedPublicId = publicId;
|
|
1277
|
+
this.joinedProject = project;
|
|
1278
|
+
this.pendingProjectJoin?.resolve(project);
|
|
1279
|
+
for (const handler of this._handlers) {
|
|
1280
|
+
handler.onProjectJoined?.(project);
|
|
1281
|
+
handler.onConnectionAccepted?.(publicId);
|
|
1282
|
+
}
|
|
1283
|
+
});
|
|
1284
|
+
this.socket.on("connectionRejected", (err) => {
|
|
1285
|
+
this.pendingProjectJoin?.reject(err?.message || err);
|
|
1286
|
+
});
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
createProjectJoin() {
|
|
1290
|
+
let resolve;
|
|
1291
|
+
let reject;
|
|
1292
|
+
const promise = new Promise((resolveProject, rejectProject) => {
|
|
1293
|
+
resolve = resolveProject;
|
|
1294
|
+
reject = rejectProject;
|
|
1295
|
+
});
|
|
1296
|
+
promise.catch(() => void 0);
|
|
1297
|
+
return {
|
|
1298
|
+
promise,
|
|
1299
|
+
resolve,
|
|
1300
|
+
reject
|
|
1301
|
+
};
|
|
1302
|
+
}
|
|
1303
|
+
resetProjectJoin(reason) {
|
|
1304
|
+
if (reason !== void 0) this.pendingProjectJoin?.reject(reason);
|
|
1305
|
+
this.pendingProjectJoin = this.createProjectJoin();
|
|
1306
|
+
}
|
|
1307
|
+
waitForRealtimeProject() {
|
|
1308
|
+
return this.joinedProject ? Promise.resolve(this.joinedProject) : this.pendingProjectJoin.promise;
|
|
1309
|
+
}
|
|
1310
|
+
async emitProjectEvent(event, ...args) {
|
|
1311
|
+
if (this.scheme === "realtime") await this.waitForRealtimeProject();
|
|
1312
|
+
return this.emit(event, ...args);
|
|
1313
|
+
}
|
|
1314
|
+
disconnect() {
|
|
1315
|
+
this.socket.disconnect();
|
|
1316
|
+
}
|
|
1317
|
+
get handlers() {
|
|
1318
|
+
return this._handlers;
|
|
1319
|
+
}
|
|
1320
|
+
get isUsingAlternativeConnectionScheme() {
|
|
1321
|
+
return this.scheme === "Alt";
|
|
1322
|
+
}
|
|
1323
|
+
toggleAlternativeConnectionScheme(url, updatedRecord) {
|
|
1324
|
+
this.scheme = this.scheme === "Alt" ? "realtime" : "Alt";
|
|
1325
|
+
if (updatedRecord) {
|
|
1326
|
+
this.url = url;
|
|
1327
|
+
this.record = Promise.resolve(updatedRecord);
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
resumeEventHandlers(handlers) {
|
|
1331
|
+
this._handlers = [];
|
|
1332
|
+
handlers.forEach((handler) => {
|
|
1333
|
+
this.updateEventHandlers(handler);
|
|
1334
|
+
});
|
|
1335
|
+
}
|
|
1336
|
+
updateEventHandlers(handlers) {
|
|
1337
|
+
this._handlers.push(handlers);
|
|
1338
|
+
Object.values(handlers).forEach((handler) => {
|
|
1339
|
+
switch (handler) {
|
|
1340
|
+
case handlers.onFileCreated:
|
|
1341
|
+
this.socket.on("reciveNewDoc", (parentFolderId, doc) => {
|
|
1342
|
+
handler(parentFolderId, "doc", doc);
|
|
1343
|
+
});
|
|
1344
|
+
this.socket.on("reciveNewFile", (parentFolderId, file) => {
|
|
1345
|
+
handler(parentFolderId, "file", file);
|
|
1346
|
+
});
|
|
1347
|
+
this.socket.on("reciveNewFolder", (parentFolderId, folder) => {
|
|
1348
|
+
handler(parentFolderId, "folder", folder);
|
|
1349
|
+
});
|
|
1350
|
+
break;
|
|
1351
|
+
case handlers.onFileRenamed:
|
|
1352
|
+
this.socket.on("reciveEntityRename", (entityId, newName) => {
|
|
1353
|
+
handler(entityId, newName);
|
|
1354
|
+
});
|
|
1355
|
+
break;
|
|
1356
|
+
case handlers.onFileRemoved:
|
|
1357
|
+
this.socket.on("removeEntity", (entityId) => {
|
|
1358
|
+
handler(entityId);
|
|
1359
|
+
});
|
|
1360
|
+
break;
|
|
1361
|
+
case handlers.onFileMoved:
|
|
1362
|
+
this.socket.on("reciveEntityMove", (entityId, folderId) => {
|
|
1363
|
+
handler(entityId, folderId);
|
|
1364
|
+
});
|
|
1365
|
+
break;
|
|
1366
|
+
case handlers.onFileChanged:
|
|
1367
|
+
this.socket.on("otUpdateApplied", (update) => {
|
|
1368
|
+
handler(update);
|
|
1369
|
+
});
|
|
1370
|
+
break;
|
|
1371
|
+
case handlers.onDisconnected:
|
|
1372
|
+
this.socket.on("disconnect", (reason) => {
|
|
1373
|
+
handler(reason);
|
|
1374
|
+
});
|
|
1375
|
+
break;
|
|
1376
|
+
case handlers.onProjectJoined: break;
|
|
1377
|
+
case handlers.onConnectionAccepted:
|
|
1378
|
+
this.socket.on("connectionAccepted", (_, publicId) => {
|
|
1379
|
+
handler(publicId);
|
|
1380
|
+
});
|
|
1381
|
+
if (this.connectedPublicId !== void 0) queueMicrotask(() => handler(this.connectedPublicId));
|
|
1382
|
+
break;
|
|
1383
|
+
case handlers.onClientUpdated:
|
|
1384
|
+
this.socket.on("clientTracking.clientUpdated", (user) => {
|
|
1385
|
+
handler(user);
|
|
1386
|
+
});
|
|
1387
|
+
break;
|
|
1388
|
+
case handlers.onClientDisconnected:
|
|
1389
|
+
this.socket.on("clientTracking.clientDisconnected", (id) => {
|
|
1390
|
+
handler(id);
|
|
1391
|
+
});
|
|
1392
|
+
break;
|
|
1393
|
+
case handlers.onReceivedMessage:
|
|
1394
|
+
this.socket.on("new-chat-message", (message) => {
|
|
1395
|
+
handler(message);
|
|
1396
|
+
});
|
|
1397
|
+
break;
|
|
1398
|
+
case handlers.onSpellCheckLanguageUpdated:
|
|
1399
|
+
this.socket.on("spellCheckLanguageUpdated", (language) => {
|
|
1400
|
+
handler(language);
|
|
1401
|
+
});
|
|
1402
|
+
break;
|
|
1403
|
+
case handlers.onCompilerUpdated:
|
|
1404
|
+
this.socket.on("compilerUpdated", (compiler) => {
|
|
1405
|
+
handler(compiler);
|
|
1406
|
+
});
|
|
1407
|
+
break;
|
|
1408
|
+
case handlers.onRootDocUpdated: this.socket.on("rootDocUpdated", (rootDocId) => {
|
|
1409
|
+
handler(rootDocId);
|
|
1410
|
+
});
|
|
1411
|
+
}
|
|
1412
|
+
});
|
|
1413
|
+
}
|
|
1414
|
+
get unSyncFileChanges() {
|
|
1415
|
+
return this.scheme === "Alt" ? this.socket.unSyncedChanges : 0;
|
|
1416
|
+
}
|
|
1417
|
+
async syncFileChanges() {
|
|
1418
|
+
if (this.scheme === "Alt") return await this.socket.uploadToVFS();
|
|
1419
|
+
}
|
|
1420
|
+
/**
|
|
1421
|
+
* Reference: services/web/frontend/js/ide/connection/ConnectionManager.js#L427
|
|
1422
|
+
* @param {string} projectId - The project id.
|
|
1423
|
+
* @returns {Promise}
|
|
1424
|
+
*/
|
|
1425
|
+
async joinProject(project_id) {
|
|
1426
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
1427
|
+
setTimeout(() => {
|
|
1428
|
+
reject("timeout");
|
|
1429
|
+
}, SOCKET_OPERATION_TIMEOUT_MS);
|
|
1430
|
+
});
|
|
1431
|
+
switch (this.scheme) {
|
|
1432
|
+
case "Alt": return Promise.race([this.emit("joinProject", { project_id }).then((returns) => returns[0]), timeoutPromise]);
|
|
1433
|
+
case "realtime": return Promise.race([this.waitForRealtimeProject(), timeoutPromise]);
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
/**
|
|
1437
|
+
* Reference: services/web/frontend/js/ide/editor/Document.js#L500
|
|
1438
|
+
* @param {string} docId - The document id.
|
|
1439
|
+
* @returns {Promise}
|
|
1440
|
+
*/
|
|
1441
|
+
async joinDoc(docId) {
|
|
1442
|
+
return this.emitProjectEvent("joinDoc", docId, { encodeRanges: true }).then((returns) => {
|
|
1443
|
+
const [docLinesAscii, version, updates, ranges] = returns;
|
|
1444
|
+
return {
|
|
1445
|
+
docLines: docLinesAscii.map((line) => decodePackedUtf8(line)),
|
|
1446
|
+
version,
|
|
1447
|
+
updates,
|
|
1448
|
+
ranges
|
|
1449
|
+
};
|
|
1450
|
+
});
|
|
1451
|
+
}
|
|
1452
|
+
/**
|
|
1453
|
+
* Reference: services/web/frontend/js/ide/editor/Document.js#L591
|
|
1454
|
+
* @param {string} docId - The document id.
|
|
1455
|
+
* @returns {Promise}
|
|
1456
|
+
*/
|
|
1457
|
+
async leaveDoc(docId) {
|
|
1458
|
+
return this.emitProjectEvent("leaveDoc", docId).then(() => {});
|
|
1459
|
+
}
|
|
1460
|
+
/**
|
|
1461
|
+
* Reference: services/web/frontend/js/ide/editor/ShareJsDocs.js#L78
|
|
1462
|
+
* @param {string} docId - The document id.
|
|
1463
|
+
* @param {any} update - The changes.
|
|
1464
|
+
* @returns {Promise}
|
|
1465
|
+
*/
|
|
1466
|
+
async applyOtUpdate(docId, update) {
|
|
1467
|
+
try {
|
|
1468
|
+
await this.emitProjectEvent("applyOtUpdate", docId, update);
|
|
1469
|
+
} catch (error) {
|
|
1470
|
+
let detail;
|
|
1471
|
+
if (error instanceof Error) detail = error.message;
|
|
1472
|
+
else if (typeof error === "string") detail = error;
|
|
1473
|
+
else try {
|
|
1474
|
+
detail = JSON.stringify(error);
|
|
1475
|
+
} catch {
|
|
1476
|
+
detail = String(error);
|
|
1477
|
+
}
|
|
1478
|
+
throw new Error(`Overleaf document update rejected: ${detail}`);
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
/**
|
|
1482
|
+
* Reference: services/web/frontend/js/ide/online-users/OnlineUserManager.js#L42
|
|
1483
|
+
* @returns {Promise}
|
|
1484
|
+
*/
|
|
1485
|
+
async getConnectedUsers() {
|
|
1486
|
+
return this.emitProjectEvent("clientTracking.getConnectedUsers").then((returns) => {
|
|
1487
|
+
const [connectedUsers] = returns;
|
|
1488
|
+
return connectedUsers;
|
|
1489
|
+
});
|
|
1490
|
+
}
|
|
1491
|
+
/**
|
|
1492
|
+
* Reference: services/web/frontend/js/ide/online-users/OnlineUserManager.js#L150
|
|
1493
|
+
* @param {string} docId - The document id.
|
|
1494
|
+
* @returns {Promise}
|
|
1495
|
+
*/
|
|
1496
|
+
async updatePosition(doc_id, row, column) {
|
|
1497
|
+
return this.emitProjectEvent("clientTracking.updatePosition", {
|
|
1498
|
+
row,
|
|
1499
|
+
column,
|
|
1500
|
+
doc_id
|
|
1501
|
+
}).then(() => {});
|
|
1502
|
+
}
|
|
1503
|
+
};
|
|
1504
|
+
function configPath(environment = process.env) {
|
|
1505
|
+
return environment.LEAFRELAY_CONFIG ? resolve(environment.LEAFRELAY_CONFIG) : join(environment.LEAFRELAY_HOME ? resolve(environment.LEAFRELAY_HOME) : join(homedir(), ".leafrelay"), "config.json");
|
|
1506
|
+
}
|
|
1507
|
+
function normalizeServerUrl(value) {
|
|
1508
|
+
const url = new URL(value.includes("://") ? value : `https://${value}`);
|
|
1509
|
+
url.pathname = "/";
|
|
1510
|
+
url.search = "";
|
|
1511
|
+
url.hash = "";
|
|
1512
|
+
return url.href;
|
|
1513
|
+
}
|
|
1514
|
+
function serverKey(value) {
|
|
1515
|
+
return new URL(normalizeServerUrl(value)).host;
|
|
1516
|
+
}
|
|
1517
|
+
async function readConfig(path = configPath()) {
|
|
1518
|
+
try {
|
|
1519
|
+
const parsed = JSON.parse(await readFile(path, "utf8"));
|
|
1520
|
+
if (parsed.version !== 1 || typeof parsed.servers !== "object" || parsed.servers === null) throw new Error(`Unsupported LeafRelay configuration in ${path}`);
|
|
1521
|
+
return parsed;
|
|
1522
|
+
} catch (error) {
|
|
1523
|
+
if (error.code === "ENOENT") return {
|
|
1524
|
+
version: 1,
|
|
1525
|
+
servers: {},
|
|
1526
|
+
knownServers: {}
|
|
1527
|
+
};
|
|
1528
|
+
throw error;
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
async function writeConfig(config, path = configPath()) {
|
|
1532
|
+
await mkdir(dirname(path), {
|
|
1533
|
+
recursive: true,
|
|
1534
|
+
mode: 448
|
|
1535
|
+
});
|
|
1536
|
+
await writeFile(path, `${JSON.stringify(config, null, 2)}\n`, {
|
|
1537
|
+
encoding: "utf8",
|
|
1538
|
+
mode: 384
|
|
1539
|
+
});
|
|
1540
|
+
await chmod(path, 384);
|
|
1541
|
+
}
|
|
1542
|
+
async function saveServerSession(session, path = configPath()) {
|
|
1543
|
+
const config = await readConfig(path);
|
|
1544
|
+
const key = serverKey(session.url);
|
|
1545
|
+
config.servers[key] = session;
|
|
1546
|
+
config.knownServers ??= {};
|
|
1547
|
+
config.knownServers[key] ??= {
|
|
1548
|
+
name: key,
|
|
1549
|
+
url: session.url
|
|
1550
|
+
};
|
|
1551
|
+
await writeConfig(config, path);
|
|
1552
|
+
}
|
|
1553
|
+
async function saveKnownServer(name, url, path = configPath()) {
|
|
1554
|
+
const config = await readConfig(path);
|
|
1555
|
+
const key = serverKey(url);
|
|
1556
|
+
config.knownServers ??= {};
|
|
1557
|
+
config.knownServers[key] = {
|
|
1558
|
+
name,
|
|
1559
|
+
url: normalizeServerUrl(url)
|
|
1560
|
+
};
|
|
1561
|
+
await writeConfig(config, path);
|
|
1562
|
+
}
|
|
1563
|
+
async function removeKnownServer(server, path = configPath()) {
|
|
1564
|
+
const config = await readConfig(path);
|
|
1565
|
+
const key = serverKey(server);
|
|
1566
|
+
delete config.servers[key];
|
|
1567
|
+
delete config.knownServers?.[key];
|
|
1568
|
+
await writeConfig(config, path);
|
|
1569
|
+
}
|
|
1570
|
+
async function removeServerSession(server, path = configPath()) {
|
|
1571
|
+
const config = await readConfig(path);
|
|
1572
|
+
delete config.servers[serverKey(server)];
|
|
1573
|
+
await writeConfig(config, path);
|
|
1574
|
+
}
|
|
1575
|
+
async function getServerSession(server, environment = process.env, path = configPath(environment)) {
|
|
1576
|
+
const key = serverKey(server);
|
|
1577
|
+
const stored = (await readConfig(path)).servers[key];
|
|
1578
|
+
const cookie = environment.LEAFRELAY_COOKIE;
|
|
1579
|
+
if (!cookie) return stored;
|
|
1580
|
+
return {
|
|
1581
|
+
url: stored?.url ?? normalizeServerUrl(server),
|
|
1582
|
+
userId: stored?.userId ?? "",
|
|
1583
|
+
userEmail: stored?.userEmail ?? "",
|
|
1584
|
+
identity: {
|
|
1585
|
+
cookies: cookie,
|
|
1586
|
+
csrfToken: stored?.identity.csrfToken ?? ""
|
|
1587
|
+
},
|
|
1588
|
+
updatedAt: stored?.updatedAt ?? (/* @__PURE__ */ new Date(0)).toISOString()
|
|
1589
|
+
};
|
|
1590
|
+
}
|
|
1591
|
+
function projectPath(root, path) {
|
|
1592
|
+
const target = resolve(root, path.replace(/^\/+/, ""));
|
|
1593
|
+
const relativePath = relative(root, target);
|
|
1594
|
+
if (relativePath.startsWith("..") || relativePath === "..") throw new Error(`Path escapes the local project: ${path}`);
|
|
1595
|
+
return target;
|
|
1596
|
+
}
|
|
1597
|
+
function syncPath(root, path) {
|
|
1598
|
+
return `/${relative(root, path).split(sep).join("/")}`;
|
|
1599
|
+
}
|
|
1600
|
+
var NodeReplicaFileSystem = class {
|
|
1601
|
+
root;
|
|
1602
|
+
ignore;
|
|
1603
|
+
watcher;
|
|
1604
|
+
constructor(root, ignore) {
|
|
1605
|
+
this.root = root;
|
|
1606
|
+
this.ignore = ignore;
|
|
1607
|
+
}
|
|
1608
|
+
async listFiles() {
|
|
1609
|
+
const files = [];
|
|
1610
|
+
const visit = async (directory) => {
|
|
1611
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
1612
|
+
const absolute = resolve(directory, entry.name);
|
|
1613
|
+
const path = syncPath(this.root, absolute);
|
|
1614
|
+
if (this.ignore(path) || entry.name.startsWith(".") || entry.isSymbolicLink()) continue;
|
|
1615
|
+
if (entry.isDirectory()) await visit(absolute);
|
|
1616
|
+
else if (entry.isFile()) files.push(path);
|
|
1617
|
+
}
|
|
1618
|
+
};
|
|
1619
|
+
await visit(this.root);
|
|
1620
|
+
return files.sort();
|
|
1621
|
+
}
|
|
1622
|
+
async read(path) {
|
|
1623
|
+
const target = projectPath(this.root, path);
|
|
1624
|
+
try {
|
|
1625
|
+
const stat = await lstat(target);
|
|
1626
|
+
if (!stat.isFile() || stat.isSymbolicLink()) return;
|
|
1627
|
+
return new Uint8Array(await readFile(target));
|
|
1628
|
+
} catch (error) {
|
|
1629
|
+
if (error.code === "ENOENT") return;
|
|
1630
|
+
throw error;
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
async write(path, content) {
|
|
1634
|
+
const target = projectPath(this.root, path);
|
|
1635
|
+
await mkdir(dirname(target), { recursive: true });
|
|
1636
|
+
await writeFile(target, content);
|
|
1637
|
+
}
|
|
1638
|
+
async remove(path) {
|
|
1639
|
+
await rm(projectPath(this.root, path), {
|
|
1640
|
+
force: true,
|
|
1641
|
+
recursive: true
|
|
1642
|
+
});
|
|
1643
|
+
}
|
|
1644
|
+
async watch(onChange) {
|
|
1645
|
+
this.watcher = watch(this.root, {
|
|
1646
|
+
ignoreInitial: true,
|
|
1647
|
+
followSymlinks: false,
|
|
1648
|
+
ignored: (path) => {
|
|
1649
|
+
const relativePath = relative(this.root, path);
|
|
1650
|
+
if (!relativePath) return false;
|
|
1651
|
+
const normalized = `/${relativePath.split(sep).join("/")}`;
|
|
1652
|
+
return this.ignore(normalized) || normalized.split("/").some((part) => part.startsWith("."));
|
|
1653
|
+
},
|
|
1654
|
+
awaitWriteFinish: {
|
|
1655
|
+
stabilityThreshold: 200,
|
|
1656
|
+
pollInterval: 50
|
|
1657
|
+
}
|
|
1658
|
+
});
|
|
1659
|
+
const handler = (path) => onChange(syncPath(this.root, path));
|
|
1660
|
+
this.watcher.on("add", handler).on("change", handler).on("unlink", handler);
|
|
1661
|
+
await new Promise((resolveReady, reject) => {
|
|
1662
|
+
this.watcher.once("ready", resolveReady).once("error", reject);
|
|
1663
|
+
});
|
|
1664
|
+
return async () => {
|
|
1665
|
+
await this.watcher?.close();
|
|
1666
|
+
};
|
|
1667
|
+
}
|
|
1668
|
+
};
|
|
1669
|
+
function parseProjectUri(value) {
|
|
1670
|
+
const uri = new URL(value);
|
|
1671
|
+
const decodedQuery = decodeURIComponent(uri.search.slice(1));
|
|
1672
|
+
const query = new URLSearchParams(decodedQuery);
|
|
1673
|
+
const projectId = query.get("project");
|
|
1674
|
+
if (!projectId) throw new Error("The project URI in .overleaf/settings.json does not contain a project ID.");
|
|
1675
|
+
return {
|
|
1676
|
+
serverName: uri.host,
|
|
1677
|
+
projectId,
|
|
1678
|
+
projectName: decodeURIComponent(uri.pathname.split("/").filter(Boolean)[0] ?? ""),
|
|
1679
|
+
userId: query.get("user") ?? void 0
|
|
1680
|
+
};
|
|
1681
|
+
}
|
|
1682
|
+
async function readProjectSettings(directory) {
|
|
1683
|
+
const settingsPath = resolve(directory, ".overleaf", "settings.json");
|
|
1684
|
+
const raw = JSON.parse(await readFile(settingsPath, "utf8"));
|
|
1685
|
+
if (typeof raw.uri !== "string") throw new Error(`${settingsPath} does not contain a valid project URI.`);
|
|
1686
|
+
const parsed = parseProjectUri(raw.uri);
|
|
1687
|
+
const serverName = typeof raw.serverName === "string" ? raw.serverName : parsed.serverName;
|
|
1688
|
+
const localReplica = raw.localReplica;
|
|
1689
|
+
return {
|
|
1690
|
+
uri: raw.uri,
|
|
1691
|
+
serverName,
|
|
1692
|
+
serverUrl: typeof raw.serverUrl === "string" ? raw.serverUrl : `https://${serverName}/`,
|
|
1693
|
+
projectId: parsed.projectId,
|
|
1694
|
+
projectName: typeof raw.projectName === "string" ? raw.projectName : parsed.projectName,
|
|
1695
|
+
userId: parsed.userId,
|
|
1696
|
+
localReplica: { settings: localReplica?.settings ?? {} }
|
|
1697
|
+
};
|
|
1698
|
+
}
|
|
1699
|
+
const DEFAULT_IGNORE_PATTERNS = [
|
|
1700
|
+
"**/.*",
|
|
1701
|
+
"**/.*/**",
|
|
1702
|
+
"**/*.aux",
|
|
1703
|
+
"**/__latexindent*",
|
|
1704
|
+
"**/*.bbl",
|
|
1705
|
+
"**/*.bcf",
|
|
1706
|
+
"**/*.blg",
|
|
1707
|
+
"**/*.fdb_latexmk",
|
|
1708
|
+
"**/*.fls",
|
|
1709
|
+
"**/*.lof",
|
|
1710
|
+
"**/*.log",
|
|
1711
|
+
"**/*.lot",
|
|
1712
|
+
"**/*.out",
|
|
1713
|
+
"**/*.run.xml",
|
|
1714
|
+
"**/*.synctex(busy)",
|
|
1715
|
+
"**/*.synctex.gz",
|
|
1716
|
+
"**/*.toc",
|
|
1717
|
+
"**/*.xdv",
|
|
1718
|
+
"**/main.pdf",
|
|
1719
|
+
"**/output.pdf"
|
|
1720
|
+
];
|
|
1721
|
+
function createIgnoreMatcher(patterns) {
|
|
1722
|
+
const configured = Array.isArray(patterns) ? patterns.filter((pattern) => typeof pattern === "string") : [];
|
|
1723
|
+
const allPatterns = [...DEFAULT_IGNORE_PATTERNS, ...configured];
|
|
1724
|
+
return (path) => {
|
|
1725
|
+
const normalized = path.replace(/^\/+/, "");
|
|
1726
|
+
return normalized.split("/").some((part) => part.startsWith(".")) || allPatterns.some((pattern) => minimatch(normalized, pattern, { dot: true }));
|
|
1727
|
+
};
|
|
1728
|
+
}
|
|
1729
|
+
function normalizePath$1(path) {
|
|
1730
|
+
const normalized = `/${posix.normalize(path).replace(/^\/+/, "")}`;
|
|
1731
|
+
if (normalized.includes("/../") || normalized.endsWith("/..")) throw new Error(`Invalid project path: ${path}`);
|
|
1732
|
+
return normalized;
|
|
1733
|
+
}
|
|
1734
|
+
function textContent(content) {
|
|
1735
|
+
if (content.includes(0)) return;
|
|
1736
|
+
try {
|
|
1737
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(content);
|
|
1738
|
+
} catch {
|
|
1739
|
+
return;
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
var RemoteProject = class {
|
|
1743
|
+
url;
|
|
1744
|
+
projectId;
|
|
1745
|
+
cookies;
|
|
1746
|
+
api;
|
|
1747
|
+
identity;
|
|
1748
|
+
socket;
|
|
1749
|
+
project;
|
|
1750
|
+
entries = /* @__PURE__ */ new Map();
|
|
1751
|
+
idToPath = /* @__PURE__ */ new Map();
|
|
1752
|
+
collaboratorNames = /* @__PURE__ */ new Map();
|
|
1753
|
+
changeHandler;
|
|
1754
|
+
reconnectHandler;
|
|
1755
|
+
externalTransport;
|
|
1756
|
+
constructor(url, projectId, cookies, transport) {
|
|
1757
|
+
this.url = url;
|
|
1758
|
+
this.projectId = projectId;
|
|
1759
|
+
this.cookies = cookies;
|
|
1760
|
+
this.externalTransport = transport;
|
|
1761
|
+
this.api = transport?.api ?? new BaseAPI(url);
|
|
1762
|
+
}
|
|
1763
|
+
async connect() {
|
|
1764
|
+
if (this.externalTransport) {
|
|
1765
|
+
this.identity = this.externalTransport.identity;
|
|
1766
|
+
this.socket = this.externalTransport.socket;
|
|
1767
|
+
} else {
|
|
1768
|
+
const login = await this.api.cookiesLogin(this.cookies);
|
|
1769
|
+
if (login.type !== "success" || !login.identity) throw new Error(login.message || `Authentication to ${this.url} failed.`);
|
|
1770
|
+
this.identity = login.identity;
|
|
1771
|
+
this.socket = new SocketIOAPI(this.url, this.api, this.identity, this.projectId);
|
|
1772
|
+
}
|
|
1773
|
+
this.project = await this.socket.joinProject(this.projectId);
|
|
1774
|
+
this.indexCollaborators();
|
|
1775
|
+
this.reindex();
|
|
1776
|
+
this.registerEvents();
|
|
1777
|
+
}
|
|
1778
|
+
disconnect() {
|
|
1779
|
+
if (!this.externalTransport) this.socket?.disconnect();
|
|
1780
|
+
}
|
|
1781
|
+
onChange(handler) {
|
|
1782
|
+
this.changeHandler = handler;
|
|
1783
|
+
return () => {
|
|
1784
|
+
if (this.changeHandler === handler) this.changeHandler = void 0;
|
|
1785
|
+
};
|
|
1786
|
+
}
|
|
1787
|
+
onReconnect(handler) {
|
|
1788
|
+
this.reconnectHandler = handler;
|
|
1789
|
+
return () => {
|
|
1790
|
+
if (this.reconnectHandler === handler) this.reconnectHandler = void 0;
|
|
1791
|
+
};
|
|
1792
|
+
}
|
|
1793
|
+
listEntries() {
|
|
1794
|
+
return [...this.entries.values()];
|
|
1795
|
+
}
|
|
1796
|
+
entry(path) {
|
|
1797
|
+
return this.entries.get(normalizePath$1(path));
|
|
1798
|
+
}
|
|
1799
|
+
async read(path) {
|
|
1800
|
+
const entry = this.requireEntry(path);
|
|
1801
|
+
if (entry.type === "doc") {
|
|
1802
|
+
const result = await this.socket.joinDoc(entry.entity._id);
|
|
1803
|
+
return new TextEncoder().encode(result.docLines.join("\n"));
|
|
1804
|
+
}
|
|
1805
|
+
if (entry.type === "file") {
|
|
1806
|
+
const result = await this.api.getFile(this.identity, this.projectId, entry.entity._id);
|
|
1807
|
+
if (result.type === "success" && result.content) return result.content;
|
|
1808
|
+
throw new Error(`Could not download ${path}.`);
|
|
1809
|
+
}
|
|
1810
|
+
throw new Error(`${path} is not a file.`);
|
|
1811
|
+
}
|
|
1812
|
+
async mkdir(path) {
|
|
1813
|
+
const normalized = normalizePath$1(path);
|
|
1814
|
+
if (this.entries.has(normalized)) return;
|
|
1815
|
+
const { parent, name } = await this.ensureParent(normalized);
|
|
1816
|
+
const result = await this.api.addFolder(this.identity, this.projectId, name, parent._id);
|
|
1817
|
+
if (result.type !== "success" || !result.entity) throw new Error(result.message || `Could not create ${normalized}.`);
|
|
1818
|
+
const folder = result.entity;
|
|
1819
|
+
folder._type = "folder";
|
|
1820
|
+
folder.docs ??= [];
|
|
1821
|
+
folder.fileRefs ??= [];
|
|
1822
|
+
folder.folders ??= [];
|
|
1823
|
+
parent.folders.push(folder);
|
|
1824
|
+
this.reindex();
|
|
1825
|
+
}
|
|
1826
|
+
async write(path, content) {
|
|
1827
|
+
const normalized = normalizePath$1(path);
|
|
1828
|
+
const existing = this.entries.get(normalized);
|
|
1829
|
+
if (existing?.type === "folder") throw new Error(`${normalized} is a directory.`);
|
|
1830
|
+
if (existing?.type === "doc") {
|
|
1831
|
+
await this.writeDocument(existing.entity, content);
|
|
1832
|
+
return;
|
|
1833
|
+
}
|
|
1834
|
+
if (existing) await this.remove(normalized);
|
|
1835
|
+
const { parent, name } = await this.ensureParent(normalized);
|
|
1836
|
+
if (textContent(content) !== void 0) {
|
|
1837
|
+
const created = await this.api.addDoc(this.identity, this.projectId, parent._id, name);
|
|
1838
|
+
if (created.type !== "success" || !created.entity) throw new Error(created.message || `Could not create ${normalized}.`);
|
|
1839
|
+
const entity = created.entity;
|
|
1840
|
+
entity._type = "doc";
|
|
1841
|
+
parent.docs.push(entity);
|
|
1842
|
+
this.reindex();
|
|
1843
|
+
if (content.length !== 0) await this.writeDocument(entity, content);
|
|
1844
|
+
return;
|
|
1845
|
+
}
|
|
1846
|
+
const uploaded = await this.api.uploadFile(this.identity, this.projectId, parent._id, name, content);
|
|
1847
|
+
if (uploaded.type !== "success" || !uploaded.entity) throw new Error(uploaded.message || `Could not upload ${normalized}.`);
|
|
1848
|
+
const entity = uploaded.entity;
|
|
1849
|
+
entity._type = "file";
|
|
1850
|
+
parent.fileRefs.push(entity);
|
|
1851
|
+
this.reindex();
|
|
1852
|
+
}
|
|
1853
|
+
async remove(path) {
|
|
1854
|
+
const normalized = normalizePath$1(path);
|
|
1855
|
+
const entry = this.requireEntry(normalized);
|
|
1856
|
+
if (!entry.parent) throw new Error("The project root cannot be removed.");
|
|
1857
|
+
const result = await this.api.deleteEntity(this.identity, this.projectId, entry.type, entry.entity._id);
|
|
1858
|
+
if (result.type !== "success") throw new Error(result.message || `Could not remove ${normalized}.`);
|
|
1859
|
+
this.removeFromParent(entry);
|
|
1860
|
+
this.reindex();
|
|
1861
|
+
}
|
|
1862
|
+
async getCurrentVersion() {
|
|
1863
|
+
const response = await this.api.proxyToHistoryApiAndGetUpdates(this.identity, this.projectId);
|
|
1864
|
+
const version = response.updates?.updates.at(0)?.toV ?? 0;
|
|
1865
|
+
if (response.type !== "success" || !Number.isInteger(version)) throw new Error(response.message || "Could not determine the current Overleaf project version.");
|
|
1866
|
+
return version;
|
|
1867
|
+
}
|
|
1868
|
+
async getFileTreeDiff(from, to) {
|
|
1869
|
+
if (from >= to) return { diff: [] };
|
|
1870
|
+
const response = await this.api.proxyToHistoryApiAndGetFileTreeDiff(this.identity, this.projectId, from, to);
|
|
1871
|
+
if (response.type === "success") return response.treeDiff;
|
|
1872
|
+
if (response.statusCode === 404) return;
|
|
1873
|
+
throw new Error(response.message || "Could not load the Overleaf file-tree history.");
|
|
1874
|
+
}
|
|
1875
|
+
async writeDocument(entity, content) {
|
|
1876
|
+
const text = textContent(content);
|
|
1877
|
+
if (text === void 0) {
|
|
1878
|
+
const path = this.idToPath.get(entity._id);
|
|
1879
|
+
await this.remove(path);
|
|
1880
|
+
await this.write(path, content);
|
|
1881
|
+
return;
|
|
1882
|
+
}
|
|
1883
|
+
const remote = await this.socket.joinDoc(entity._id);
|
|
1884
|
+
const previous = remote.docLines.join("\n");
|
|
1885
|
+
if (previous === text) return;
|
|
1886
|
+
let position = 0;
|
|
1887
|
+
const operations = [];
|
|
1888
|
+
for (const part of new DiffMatchPatch().diff_main(previous, text)) {
|
|
1889
|
+
const start = position;
|
|
1890
|
+
if (part[0] !== -1) position += part[1].length;
|
|
1891
|
+
if (part[0] === 1) operations.push({
|
|
1892
|
+
p: start,
|
|
1893
|
+
i: part[1]
|
|
1894
|
+
});
|
|
1895
|
+
if (part[0] === -1) operations.push({
|
|
1896
|
+
p: start,
|
|
1897
|
+
d: part[1]
|
|
1898
|
+
});
|
|
1899
|
+
}
|
|
1900
|
+
const update = {
|
|
1901
|
+
doc: entity._id,
|
|
1902
|
+
v: remote.version,
|
|
1903
|
+
hash: createHash("sha1").update(`blob ${text.length}\x00${text}`).digest("hex"),
|
|
1904
|
+
op: operations
|
|
1905
|
+
};
|
|
1906
|
+
await this.socket.applyOtUpdate(entity._id, update);
|
|
1907
|
+
}
|
|
1908
|
+
async ensureParent(path) {
|
|
1909
|
+
const name = posix.basename(path);
|
|
1910
|
+
const parentPath = posix.dirname(path);
|
|
1911
|
+
if (parentPath !== "/" && !this.entries.has(parentPath)) await this.mkdir(parentPath);
|
|
1912
|
+
const parentEntry = parentPath === "/" ? this.entries.get("/") : this.entries.get(parentPath);
|
|
1913
|
+
if (!parentEntry || parentEntry.type !== "folder") throw new Error(`Parent directory ${parentPath} does not exist.`);
|
|
1914
|
+
return {
|
|
1915
|
+
parent: parentEntry.entity,
|
|
1916
|
+
name
|
|
1917
|
+
};
|
|
1918
|
+
}
|
|
1919
|
+
requireEntry(path) {
|
|
1920
|
+
const normalized = normalizePath$1(path);
|
|
1921
|
+
const entry = this.entries.get(normalized);
|
|
1922
|
+
if (!entry) throw new Error(`Remote path not found: ${normalized}`);
|
|
1923
|
+
return entry;
|
|
1924
|
+
}
|
|
1925
|
+
reindex() {
|
|
1926
|
+
this.entries.clear();
|
|
1927
|
+
this.idToPath.clear();
|
|
1928
|
+
const root = this.project.rootFolder[0];
|
|
1929
|
+
root._type = "folder";
|
|
1930
|
+
this.indexFolder(root, "/");
|
|
1931
|
+
}
|
|
1932
|
+
indexFolder(folder, path, parent) {
|
|
1933
|
+
this.entries.set(path, {
|
|
1934
|
+
path,
|
|
1935
|
+
type: "folder",
|
|
1936
|
+
entity: folder,
|
|
1937
|
+
parent
|
|
1938
|
+
});
|
|
1939
|
+
this.idToPath.set(folder._id, path);
|
|
1940
|
+
for (const child of folder.folders ?? []) {
|
|
1941
|
+
child._type = "folder";
|
|
1942
|
+
this.indexFolder(child, normalizePath$1(posix.join(path, child.name)), folder);
|
|
1943
|
+
}
|
|
1944
|
+
for (const child of folder.docs ?? []) {
|
|
1945
|
+
child._type = "doc";
|
|
1946
|
+
const childPath = normalizePath$1(posix.join(path, child.name));
|
|
1947
|
+
this.entries.set(childPath, {
|
|
1948
|
+
path: childPath,
|
|
1949
|
+
type: "doc",
|
|
1950
|
+
entity: child,
|
|
1951
|
+
parent: folder
|
|
1952
|
+
});
|
|
1953
|
+
this.idToPath.set(child._id, childPath);
|
|
1954
|
+
}
|
|
1955
|
+
for (const child of folder.fileRefs ?? []) {
|
|
1956
|
+
child._type = "file";
|
|
1957
|
+
const childPath = normalizePath$1(posix.join(path, child.name));
|
|
1958
|
+
this.entries.set(childPath, {
|
|
1959
|
+
path: childPath,
|
|
1960
|
+
type: "file",
|
|
1961
|
+
entity: child,
|
|
1962
|
+
parent: folder
|
|
1963
|
+
});
|
|
1964
|
+
this.idToPath.set(child._id, childPath);
|
|
1965
|
+
}
|
|
1966
|
+
}
|
|
1967
|
+
folderById(id) {
|
|
1968
|
+
const path = this.idToPath.get(id);
|
|
1969
|
+
const entry = path ? this.entries.get(path) : void 0;
|
|
1970
|
+
return entry?.type === "folder" ? entry.entity : void 0;
|
|
1971
|
+
}
|
|
1972
|
+
removeFromParent(entry) {
|
|
1973
|
+
if (!entry.parent) return;
|
|
1974
|
+
const collection = entry.type === "folder" ? entry.parent.folders : entry.type === "doc" ? entry.parent.docs : entry.parent.fileRefs;
|
|
1975
|
+
const index = collection.findIndex((item) => item._id === entry.entity._id);
|
|
1976
|
+
if (index !== -1) collection.splice(index, 1);
|
|
1977
|
+
}
|
|
1978
|
+
emitChange(path, previousPath, actor) {
|
|
1979
|
+
this.changeHandler?.({
|
|
1980
|
+
path: normalizePath$1(path),
|
|
1981
|
+
previousPath,
|
|
1982
|
+
actor
|
|
1983
|
+
});
|
|
1984
|
+
}
|
|
1985
|
+
indexCollaborators() {
|
|
1986
|
+
for (const member of [this.project.owner, ...this.project.members]) {
|
|
1987
|
+
if (!member?._id) continue;
|
|
1988
|
+
const name = [member.first_name, member.last_name].filter(Boolean).join(" ").trim();
|
|
1989
|
+
if (name) this.collaboratorNames.set(member._id, name);
|
|
1990
|
+
}
|
|
1991
|
+
}
|
|
1992
|
+
actor(userId) {
|
|
1993
|
+
if (!userId) return;
|
|
1994
|
+
const name = this.collaboratorNames.get(userId);
|
|
1995
|
+
return name ? `${name} (Overleaf)` : `Overleaf user ${userId}`;
|
|
1996
|
+
}
|
|
1997
|
+
registerEvents() {
|
|
1998
|
+
this.socket.updateEventHandlers({
|
|
1999
|
+
onProjectJoined: (project) => {
|
|
2000
|
+
this.project = project;
|
|
2001
|
+
this.indexCollaborators();
|
|
2002
|
+
this.reindex();
|
|
2003
|
+
this.reconnectHandler?.();
|
|
2004
|
+
},
|
|
2005
|
+
onClientUpdated: (user) => {
|
|
2006
|
+
if (user.user_id && user.name) this.collaboratorNames.set(user.user_id, user.name);
|
|
2007
|
+
},
|
|
2008
|
+
onFileCreated: (parentId, type, entity) => {
|
|
2009
|
+
const parent = this.folderById(parentId);
|
|
2010
|
+
if (!parent || type === "outputs") return;
|
|
2011
|
+
if (this.idToPath.has(entity._id)) return;
|
|
2012
|
+
entity._type = type;
|
|
2013
|
+
if (type === "folder") {
|
|
2014
|
+
const folder = entity;
|
|
2015
|
+
folder.docs ??= [];
|
|
2016
|
+
folder.fileRefs ??= [];
|
|
2017
|
+
folder.folders ??= [];
|
|
2018
|
+
parent.folders.push(folder);
|
|
2019
|
+
} else if (type === "doc") parent.docs.push(entity);
|
|
2020
|
+
else parent.fileRefs.push(entity);
|
|
2021
|
+
const parentPath = this.idToPath.get(parentId) ?? "/";
|
|
2022
|
+
this.reindex();
|
|
2023
|
+
this.emitChange(posix.join(parentPath, entity.name));
|
|
2024
|
+
},
|
|
2025
|
+
onFileRenamed: (entityId, newName) => {
|
|
2026
|
+
const previousPath = this.idToPath.get(entityId);
|
|
2027
|
+
if (!previousPath) return;
|
|
2028
|
+
this.entries.get(previousPath).entity.name = newName;
|
|
2029
|
+
this.reindex();
|
|
2030
|
+
this.emitChange(this.idToPath.get(entityId), previousPath);
|
|
2031
|
+
},
|
|
2032
|
+
onFileRemoved: (entityId) => {
|
|
2033
|
+
const previousPath = this.idToPath.get(entityId);
|
|
2034
|
+
if (!previousPath) return;
|
|
2035
|
+
const entry = this.entries.get(previousPath);
|
|
2036
|
+
this.removeFromParent(entry);
|
|
2037
|
+
this.reindex();
|
|
2038
|
+
this.emitChange(previousPath);
|
|
2039
|
+
},
|
|
2040
|
+
onFileMoved: (entityId, parentId) => {
|
|
2041
|
+
const previousPath = this.idToPath.get(entityId);
|
|
2042
|
+
const nextParent = this.folderById(parentId);
|
|
2043
|
+
if (!previousPath || !nextParent) return;
|
|
2044
|
+
const entry = this.entries.get(previousPath);
|
|
2045
|
+
this.removeFromParent(entry);
|
|
2046
|
+
if (entry.type === "folder") nextParent.folders.push(entry.entity);
|
|
2047
|
+
else if (entry.type === "doc") nextParent.docs.push(entry.entity);
|
|
2048
|
+
else nextParent.fileRefs.push(entry.entity);
|
|
2049
|
+
this.reindex();
|
|
2050
|
+
this.emitChange(this.idToPath.get(entityId), previousPath);
|
|
2051
|
+
},
|
|
2052
|
+
onFileChanged: (update) => {
|
|
2053
|
+
const path = this.idToPath.get(update.doc);
|
|
2054
|
+
if (path) this.emitChange(path, void 0, this.actor(update.meta?.user_id));
|
|
2055
|
+
}
|
|
2056
|
+
});
|
|
2057
|
+
}
|
|
2058
|
+
};
|
|
2059
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
2060
|
+
function decode(content) {
|
|
2061
|
+
if (content === void 0) return "";
|
|
2062
|
+
if (content.includes(0)) return;
|
|
2063
|
+
try {
|
|
2064
|
+
return decoder.decode(content);
|
|
2065
|
+
} catch {
|
|
2066
|
+
return;
|
|
2067
|
+
}
|
|
2068
|
+
}
|
|
2069
|
+
function lineStarts(content) {
|
|
2070
|
+
const starts = [0];
|
|
2071
|
+
for (let index = 0; index < content.length; index++) if (content[index] === "\n" && index + 1 < content.length) starts.push(index + 1);
|
|
2072
|
+
return starts;
|
|
2073
|
+
}
|
|
2074
|
+
function lineAt(starts, offset) {
|
|
2075
|
+
let low = 0;
|
|
2076
|
+
let high = starts.length;
|
|
2077
|
+
while (low < high) {
|
|
2078
|
+
const middle = Math.floor((low + high) / 2);
|
|
2079
|
+
if (starts[middle] <= offset) low = middle + 1;
|
|
2080
|
+
else high = middle;
|
|
2081
|
+
}
|
|
2082
|
+
return Math.max(1, low);
|
|
2083
|
+
}
|
|
2084
|
+
function toLineRange(content, start, end) {
|
|
2085
|
+
const starts = lineStarts(content);
|
|
2086
|
+
const safeStart = Math.min(start, Math.max(0, content.length - 1));
|
|
2087
|
+
const safeEnd = Math.min(Math.max(start, end - 1), Math.max(0, content.length - 1));
|
|
2088
|
+
return {
|
|
2089
|
+
start: lineAt(starts, safeStart),
|
|
2090
|
+
end: lineAt(starts, safeEnd)
|
|
2091
|
+
};
|
|
2092
|
+
}
|
|
2093
|
+
function mergeRanges(ranges) {
|
|
2094
|
+
const merged = [];
|
|
2095
|
+
for (const range of ranges.sort((a, b) => a.start - b.start)) {
|
|
2096
|
+
const previous = merged.at(-1);
|
|
2097
|
+
if (previous && range.start <= previous.end + 1) previous.end = Math.max(previous.end, range.end);
|
|
2098
|
+
else merged.push({ ...range });
|
|
2099
|
+
}
|
|
2100
|
+
return merged;
|
|
2101
|
+
}
|
|
2102
|
+
function formatRanges(ranges) {
|
|
2103
|
+
const shown = ranges.slice(0, 3).map((range) => range.start === range.end ? String(range.start) : `${range.start}-${range.end}`);
|
|
2104
|
+
return `${shown.join(", ")}${ranges.length > shown.length ? ` (+${ranges.length - shown.length} more)` : ""}`;
|
|
2105
|
+
}
|
|
2106
|
+
function describeContentChange(before, after) {
|
|
2107
|
+
if (before && after && Buffer.from(before).equals(Buffer.from(after))) return;
|
|
2108
|
+
const oldText = decode(before);
|
|
2109
|
+
const newText = decode(after);
|
|
2110
|
+
if (oldText === void 0 || newText === void 0) return `binary bytes ${before?.byteLength ?? 0} -> ${after?.byteLength ?? 0}`;
|
|
2111
|
+
if (oldText.length === 0 && newText.length === 0) return;
|
|
2112
|
+
if (oldText.length === 0) {
|
|
2113
|
+
const count = lineStarts(newText).length;
|
|
2114
|
+
return `added lines ${count === 1 ? "1" : `1-${count}`}`;
|
|
2115
|
+
}
|
|
2116
|
+
if (newText.length === 0) {
|
|
2117
|
+
const count = lineStarts(oldText).length;
|
|
2118
|
+
return `removed lines ${count === 1 ? "1" : `1-${count}`}`;
|
|
2119
|
+
}
|
|
2120
|
+
const changes = [];
|
|
2121
|
+
let oldOffset = 0;
|
|
2122
|
+
let newOffset = 0;
|
|
2123
|
+
let current;
|
|
2124
|
+
const finish = () => {
|
|
2125
|
+
if (current) {
|
|
2126
|
+
changes.push(current);
|
|
2127
|
+
current = void 0;
|
|
2128
|
+
}
|
|
2129
|
+
};
|
|
2130
|
+
for (const [operation, value] of new DiffMatchPatch().diff_main(oldText, newText)) {
|
|
2131
|
+
if (operation === 0) {
|
|
2132
|
+
finish();
|
|
2133
|
+
oldOffset += value.length;
|
|
2134
|
+
newOffset += value.length;
|
|
2135
|
+
continue;
|
|
2136
|
+
}
|
|
2137
|
+
current ??= {
|
|
2138
|
+
oldStart: oldOffset,
|
|
2139
|
+
oldEnd: oldOffset,
|
|
2140
|
+
newStart: newOffset,
|
|
2141
|
+
newEnd: newOffset
|
|
2142
|
+
};
|
|
2143
|
+
if (operation === -1) oldOffset += value.length;
|
|
2144
|
+
if (operation === 1) newOffset += value.length;
|
|
2145
|
+
current.oldEnd = oldOffset;
|
|
2146
|
+
current.newEnd = newOffset;
|
|
2147
|
+
}
|
|
2148
|
+
finish();
|
|
2149
|
+
const ranges = mergeRanges(changes.map((change) => change.newEnd > change.newStart ? toLineRange(newText, change.newStart, change.newEnd) : toLineRange(oldText, change.oldStart, change.oldEnd)));
|
|
2150
|
+
return ranges.length === 0 ? void 0 : `lines ${formatRanges(ranges)}`;
|
|
2151
|
+
}
|
|
2152
|
+
const textDecoder = new TextDecoder("utf-8", { fatal: true });
|
|
2153
|
+
const textEncoder = new TextEncoder();
|
|
2154
|
+
function decodeText(content) {
|
|
2155
|
+
if (content.includes(0)) return;
|
|
2156
|
+
try {
|
|
2157
|
+
return textDecoder.decode(content);
|
|
2158
|
+
} catch {
|
|
2159
|
+
return;
|
|
2160
|
+
}
|
|
2161
|
+
}
|
|
2162
|
+
function splitLines(content) {
|
|
2163
|
+
return content.match(/[^\n]*\n|[^\n]+$/g) ?? [];
|
|
2164
|
+
}
|
|
2165
|
+
function encodeTextBase(content) {
|
|
2166
|
+
return decodeText(content);
|
|
2167
|
+
}
|
|
2168
|
+
function mergeText(baseContent, localContent, remoteContent) {
|
|
2169
|
+
const base = decodeText(baseContent);
|
|
2170
|
+
const local = decodeText(localContent);
|
|
2171
|
+
const remote = decodeText(remoteContent);
|
|
2172
|
+
if (base === void 0 || local === void 0 || remote === void 0) return { status: "binary" };
|
|
2173
|
+
const regions = diff3Merge(splitLines(local), splitLines(base), splitLines(remote), { excludeFalseConflicts: true });
|
|
2174
|
+
if (regions.some((region) => region.conflict !== void 0)) return { status: "conflict" };
|
|
2175
|
+
return {
|
|
2176
|
+
status: "merged",
|
|
2177
|
+
content: textEncoder.encode(regions.flatMap((region) => region.ok ?? []).join(""))
|
|
2178
|
+
};
|
|
2179
|
+
}
|
|
2180
|
+
function sha256(content) {
|
|
2181
|
+
return createHash("sha256").update(content).digest("hex");
|
|
2182
|
+
}
|
|
2183
|
+
function createSyncState(projectUri, remoteVersion, initialized = true) {
|
|
2184
|
+
return {
|
|
2185
|
+
schemaVersion: 1,
|
|
2186
|
+
projectUri,
|
|
2187
|
+
remoteVersion,
|
|
2188
|
+
initialized,
|
|
2189
|
+
files: {},
|
|
2190
|
+
textBases: {},
|
|
2191
|
+
conflicts: {}
|
|
2192
|
+
};
|
|
2193
|
+
}
|
|
2194
|
+
function updateSyncCheckpoint(state, path, content) {
|
|
2195
|
+
state.files[path] = sha256(content);
|
|
2196
|
+
const textBase = encodeTextBase(content);
|
|
2197
|
+
if (textBase === void 0) delete state.textBases[path];
|
|
2198
|
+
else state.textBases[path] = textBase;
|
|
2199
|
+
}
|
|
2200
|
+
function removeSyncCheckpoint(state, path) {
|
|
2201
|
+
const checkpointPaths = /* @__PURE__ */ new Set([...Object.keys(state.files), ...Object.keys(state.conflicts)]);
|
|
2202
|
+
for (const checkpointPath of checkpointPaths) if (checkpointPath === path || checkpointPath.startsWith(`${path}/`)) {
|
|
2203
|
+
delete state.files[checkpointPath];
|
|
2204
|
+
delete state.textBases[checkpointPath];
|
|
2205
|
+
delete state.conflicts[checkpointPath];
|
|
2206
|
+
}
|
|
2207
|
+
}
|
|
2208
|
+
function readTextBase(state, path) {
|
|
2209
|
+
const textBase = state?.textBases[path];
|
|
2210
|
+
return textBase === void 0 ? void 0 : new TextEncoder().encode(textBase);
|
|
2211
|
+
}
|
|
2212
|
+
function normalizePath(path) {
|
|
2213
|
+
return `/${posix.normalize(path).replace(/^\/+/, "")}`;
|
|
2214
|
+
}
|
|
2215
|
+
function isRemoteFile(entry) {
|
|
2216
|
+
return entry?.type === "doc" || entry?.type === "file";
|
|
2217
|
+
}
|
|
2218
|
+
var SyncEngine = class {
|
|
2219
|
+
options;
|
|
2220
|
+
state;
|
|
2221
|
+
queue = Promise.resolve();
|
|
2222
|
+
conflicts = /* @__PURE__ */ new Set();
|
|
2223
|
+
stopLocalWatch;
|
|
2224
|
+
stopRemoteChange;
|
|
2225
|
+
stopRemoteReconnect;
|
|
2226
|
+
eventTimers = /* @__PURE__ */ new Map();
|
|
2227
|
+
stopped = false;
|
|
2228
|
+
failedPaths = /* @__PURE__ */ new Map();
|
|
2229
|
+
retryTimer;
|
|
2230
|
+
retryDelayMs = 3e4;
|
|
2231
|
+
pendingRemoteVersion;
|
|
2232
|
+
pendingInitialization = false;
|
|
2233
|
+
get syncError() {
|
|
2234
|
+
if (this.failedPaths.size === 0) return;
|
|
2235
|
+
const [path, failure] = this.failedPaths.entries().next().value;
|
|
2236
|
+
return `${this.failedPaths.size} path(s) could not synchronize, including ${path}: ${failure.message}. Other paths remain active; failed paths will retry automatically.`;
|
|
2237
|
+
}
|
|
2238
|
+
constructor(options) {
|
|
2239
|
+
this.options = options;
|
|
2240
|
+
}
|
|
2241
|
+
async start() {
|
|
2242
|
+
this.stopped = false;
|
|
2243
|
+
const startup = this.enqueue(() => this.initialize());
|
|
2244
|
+
try {
|
|
2245
|
+
await startup;
|
|
2246
|
+
} catch (error) {
|
|
2247
|
+
await this.stop();
|
|
2248
|
+
throw error;
|
|
2249
|
+
}
|
|
2250
|
+
}
|
|
2251
|
+
async initialize() {
|
|
2252
|
+
this.state = await this.options.stateStore.load();
|
|
2253
|
+
this.stopLocalWatch = await this.options.local.watch((path) => this.schedule(path, "local"));
|
|
2254
|
+
this.stopRemoteChange = this.options.remote.onChange((change) => {
|
|
2255
|
+
if (this.stopped) return;
|
|
2256
|
+
if (change.previousPath) this.schedule(change.previousPath, "remote", change.actor);
|
|
2257
|
+
this.schedule(change.path, "remote", change.actor);
|
|
2258
|
+
});
|
|
2259
|
+
this.stopRemoteReconnect = this.options.remote.onReconnect?.(() => {
|
|
2260
|
+
if (this.stopped) return;
|
|
2261
|
+
this.queue = this.queue.then(() => this.reconcileAfterReconnect()).catch((error) => {
|
|
2262
|
+
this.options.log(`Failed to reconcile after reconnecting: ${error instanceof Error ? error.message : String(error)}`);
|
|
2263
|
+
});
|
|
2264
|
+
});
|
|
2265
|
+
const currentVersion = await this.options.remote.getCurrentVersion();
|
|
2266
|
+
const localFiles = await this.options.local.listFiles();
|
|
2267
|
+
const remoteFiles = this.remoteFilePaths();
|
|
2268
|
+
this.options.log(`Checking replica at Overleaf version ${currentVersion}: ${localFiles.length} local file(s), ${remoteFiles.length} remote file(s).`);
|
|
2269
|
+
if (!this.state || !this.state.initialized) await this.initializeState(currentVersion, localFiles, remoteFiles);
|
|
2270
|
+
else {
|
|
2271
|
+
this.restoreConflicts();
|
|
2272
|
+
await this.startupReconcile(currentVersion, localFiles, remoteFiles);
|
|
2273
|
+
}
|
|
2274
|
+
this.options.log(this.syncError ? `Synchronization active with pending retries. ${this.syncError}` : `Synchronization active at Overleaf version ${this.state.remoteVersion}.`);
|
|
2275
|
+
}
|
|
2276
|
+
async stop() {
|
|
2277
|
+
this.stopped = true;
|
|
2278
|
+
if (this.retryTimer) {
|
|
2279
|
+
clearTimeout(this.retryTimer);
|
|
2280
|
+
this.retryTimer = void 0;
|
|
2281
|
+
}
|
|
2282
|
+
this.stopRemoteChange?.();
|
|
2283
|
+
this.stopRemoteChange = void 0;
|
|
2284
|
+
this.stopRemoteReconnect?.();
|
|
2285
|
+
this.stopRemoteReconnect = void 0;
|
|
2286
|
+
for (const pending of this.eventTimers.values()) clearTimeout(pending.timer);
|
|
2287
|
+
this.eventTimers.clear();
|
|
2288
|
+
await this.stopLocalWatch?.();
|
|
2289
|
+
this.stopLocalWatch = void 0;
|
|
2290
|
+
await this.queue;
|
|
2291
|
+
}
|
|
2292
|
+
getConflicts() {
|
|
2293
|
+
return [...this.conflicts].sort();
|
|
2294
|
+
}
|
|
2295
|
+
async resolveConflict(path, winner) {
|
|
2296
|
+
const normalized = normalizePath(path);
|
|
2297
|
+
if (!this.conflicts.has(normalized)) throw new Error(`No unresolved conflict exists for ${normalized}.`);
|
|
2298
|
+
await this.enqueue(async () => {
|
|
2299
|
+
const remoteEntry = this.options.remote.entry(normalized);
|
|
2300
|
+
if (winner === "local") {
|
|
2301
|
+
const localContent = await this.options.local.read(normalized);
|
|
2302
|
+
if (localContent) {
|
|
2303
|
+
await this.options.remote.write(normalized, localContent);
|
|
2304
|
+
const synchronized = await this.options.remote.read(normalized);
|
|
2305
|
+
if (sha256(synchronized) !== sha256(localContent)) await this.options.local.write(normalized, synchronized);
|
|
2306
|
+
updateSyncCheckpoint(this.state, normalized, synchronized);
|
|
2307
|
+
} else {
|
|
2308
|
+
if (remoteEntry) await this.options.remote.remove(normalized);
|
|
2309
|
+
removeSyncCheckpoint(this.state, normalized);
|
|
2310
|
+
}
|
|
2311
|
+
} else if (isRemoteFile(remoteEntry)) {
|
|
2312
|
+
const remoteContent = await this.options.remote.read(normalized);
|
|
2313
|
+
await this.options.local.write(normalized, remoteContent);
|
|
2314
|
+
updateSyncCheckpoint(this.state, normalized, remoteContent);
|
|
2315
|
+
} else {
|
|
2316
|
+
await this.options.local.remove(normalized);
|
|
2317
|
+
removeSyncCheckpoint(this.state, normalized);
|
|
2318
|
+
}
|
|
2319
|
+
this.conflicts.delete(normalized);
|
|
2320
|
+
delete this.state.conflicts[normalized];
|
|
2321
|
+
await this.options.stateStore.save(this.state);
|
|
2322
|
+
this.options.log(`Resolved ${normalized} using ${winner === "local" ? "local" : "Overleaf"} content.`);
|
|
2323
|
+
});
|
|
2324
|
+
}
|
|
2325
|
+
async retry(path) {
|
|
2326
|
+
const normalized = normalizePath(path);
|
|
2327
|
+
const reason = this.state.conflicts[normalized];
|
|
2328
|
+
this.conflicts.delete(normalized);
|
|
2329
|
+
delete this.state.conflicts[normalized];
|
|
2330
|
+
try {
|
|
2331
|
+
await this.enqueue(() => this.reconcilePath(normalized, "local"));
|
|
2332
|
+
} catch (error) {
|
|
2333
|
+
if (reason !== void 0) {
|
|
2334
|
+
this.conflicts.add(normalized);
|
|
2335
|
+
this.state.conflicts[normalized] = reason;
|
|
2336
|
+
await this.options.stateStore.save(this.state);
|
|
2337
|
+
}
|
|
2338
|
+
throw error;
|
|
2339
|
+
}
|
|
2340
|
+
}
|
|
2341
|
+
async initializeState(currentVersion, localFiles, remoteFiles) {
|
|
2342
|
+
if (!this.state) {
|
|
2343
|
+
this.state = createSyncState(this.options.projectUri, currentVersion, false);
|
|
2344
|
+
await this.options.stateStore.save(this.state);
|
|
2345
|
+
}
|
|
2346
|
+
this.restoreConflicts();
|
|
2347
|
+
this.pendingRemoteVersion = currentVersion;
|
|
2348
|
+
this.pendingInitialization = true;
|
|
2349
|
+
const paths = /* @__PURE__ */ new Set([
|
|
2350
|
+
...localFiles,
|
|
2351
|
+
...remoteFiles,
|
|
2352
|
+
...Object.keys(this.state.files)
|
|
2353
|
+
]);
|
|
2354
|
+
for (const path of [...paths].sort()) {
|
|
2355
|
+
const normalized = normalizePath(path);
|
|
2356
|
+
if (this.conflicts.has(normalized)) continue;
|
|
2357
|
+
try {
|
|
2358
|
+
if (this.state.files[normalized] !== void 0) await this.synchronizePath(normalized, "local");
|
|
2359
|
+
else await this.initializePath(normalized);
|
|
2360
|
+
} catch (error) {
|
|
2361
|
+
this.recordFailure(normalized, "local", error);
|
|
2362
|
+
}
|
|
2363
|
+
}
|
|
2364
|
+
await this.finishRemoteBatch();
|
|
2365
|
+
if (this.conflicts.size !== 0) this.options.log(`Synchronization remains active; ${this.conflicts.size} conflicting path(s) are paused.`);
|
|
2366
|
+
}
|
|
2367
|
+
async initializePath(path) {
|
|
2368
|
+
const remoteEntry = this.options.remote.entry(path);
|
|
2369
|
+
if (remoteEntry?.type === "folder") return;
|
|
2370
|
+
const localContent = await this.options.local.read(path);
|
|
2371
|
+
const remoteContent = isRemoteFile(remoteEntry) ? await this.options.remote.read(path) : void 0;
|
|
2372
|
+
if ((localContent && sha256(localContent)) === (remoteContent && sha256(remoteContent))) {
|
|
2373
|
+
if (localContent) {
|
|
2374
|
+
updateSyncCheckpoint(this.state, path, localContent);
|
|
2375
|
+
await this.options.stateStore.save(this.state);
|
|
2376
|
+
}
|
|
2377
|
+
return;
|
|
2378
|
+
}
|
|
2379
|
+
if (!localContent && remoteContent) {
|
|
2380
|
+
await this.options.local.write(path, remoteContent);
|
|
2381
|
+
updateSyncCheckpoint(this.state, path, remoteContent);
|
|
2382
|
+
await this.options.stateStore.save(this.state);
|
|
2383
|
+
this.logChange("pull", "add", path, void 0, remoteContent);
|
|
2384
|
+
return;
|
|
2385
|
+
}
|
|
2386
|
+
await this.recordConflict(path, remoteContent ? "Local and Overleaf content differ, but no common checkpoint exists." : "The local path has no matching Overleaf file and no common checkpoint exists.");
|
|
2387
|
+
}
|
|
2388
|
+
restoreConflicts() {
|
|
2389
|
+
for (const [path, reason] of Object.entries(this.state.conflicts)) {
|
|
2390
|
+
if (this.conflicts.has(path)) continue;
|
|
2391
|
+
this.conflicts.add(path);
|
|
2392
|
+
this.options.onConflict(path, reason);
|
|
2393
|
+
}
|
|
2394
|
+
}
|
|
2395
|
+
async recordConflict(path, reason) {
|
|
2396
|
+
this.conflicts.add(path);
|
|
2397
|
+
this.state.conflicts[path] = reason;
|
|
2398
|
+
await this.options.stateStore.save(this.state);
|
|
2399
|
+
this.options.onConflict(path, reason);
|
|
2400
|
+
}
|
|
2401
|
+
async startupReconcile(currentVersion, localFiles, remoteFiles) {
|
|
2402
|
+
const state = this.state;
|
|
2403
|
+
if (state.remoteVersion > currentVersion) throw new Error("Synchronization paused: the local checkpoint is newer than the Overleaf project history.");
|
|
2404
|
+
const localHashes = /* @__PURE__ */ new Map();
|
|
2405
|
+
for (const path of localFiles) {
|
|
2406
|
+
const content = await this.options.local.read(path);
|
|
2407
|
+
if (content) localHashes.set(path, sha256(content));
|
|
2408
|
+
}
|
|
2409
|
+
const localChanged = /* @__PURE__ */ new Set();
|
|
2410
|
+
for (const path of /* @__PURE__ */ new Set([...Object.keys(state.files), ...localFiles])) if (state.files[path] !== localHashes.get(path)) localChanged.add(path);
|
|
2411
|
+
let remoteDiff = { diff: [] };
|
|
2412
|
+
if (state.remoteVersion < currentVersion) {
|
|
2413
|
+
remoteDiff = await this.options.remote.getFileTreeDiff(state.remoteVersion, currentVersion);
|
|
2414
|
+
if (!remoteDiff) throw new Error("Synchronization paused: Overleaf history is unavailable, so remote changes cannot be distinguished safely.");
|
|
2415
|
+
}
|
|
2416
|
+
const candidates = /* @__PURE__ */ new Set([...localChanged, ...this.failedPaths.keys()]);
|
|
2417
|
+
const knownFiles = /* @__PURE__ */ new Set([
|
|
2418
|
+
...Object.keys(state.files),
|
|
2419
|
+
...localFiles,
|
|
2420
|
+
...remoteFiles
|
|
2421
|
+
]);
|
|
2422
|
+
for (const path of [...localFiles, ...remoteFiles]) if (state.files[path] === void 0) candidates.add(path);
|
|
2423
|
+
for (const change of remoteDiff.diff) {
|
|
2424
|
+
if (!change.operation) continue;
|
|
2425
|
+
const paths = [change.pathname, change.newPathname].filter((path) => Boolean(path));
|
|
2426
|
+
for (const changedPath of paths.map(normalizePath)) {
|
|
2427
|
+
let matched = false;
|
|
2428
|
+
for (const filePath of knownFiles) if (filePath === changedPath || filePath.startsWith(`${changedPath}/`)) {
|
|
2429
|
+
candidates.add(filePath);
|
|
2430
|
+
matched = true;
|
|
2431
|
+
}
|
|
2432
|
+
if (!matched) candidates.add(changedPath);
|
|
2433
|
+
}
|
|
2434
|
+
}
|
|
2435
|
+
this.pendingRemoteVersion = currentVersion;
|
|
2436
|
+
this.options.log(`Reconciling ${candidates.size} changed path(s).`);
|
|
2437
|
+
for (const path of [...candidates].sort()) {
|
|
2438
|
+
if (this.stopped) return;
|
|
2439
|
+
try {
|
|
2440
|
+
await this.synchronizePath(path, localChanged.has(path) ? "local" : "remote");
|
|
2441
|
+
} catch (error) {
|
|
2442
|
+
this.recordFailure(path, localChanged.has(path) ? "local" : "remote", error);
|
|
2443
|
+
}
|
|
2444
|
+
}
|
|
2445
|
+
await this.finishRemoteBatch();
|
|
2446
|
+
if (this.conflicts.size !== 0) this.options.log(`Synchronization remains active; ${this.conflicts.size} conflicting path(s) are paused.`);
|
|
2447
|
+
}
|
|
2448
|
+
async reconcileAfterReconnect() {
|
|
2449
|
+
if (this.stopped) return;
|
|
2450
|
+
const currentVersion = await this.options.remote.getCurrentVersion();
|
|
2451
|
+
await this.startupReconcile(currentVersion, await this.options.local.listFiles(), this.remoteFilePaths());
|
|
2452
|
+
this.options.log(this.syncError ? `Realtime connection restored; ${this.syncError}` : `Realtime connection restored at Overleaf version ${currentVersion}.`);
|
|
2453
|
+
}
|
|
2454
|
+
schedule(path, source, actor) {
|
|
2455
|
+
const normalized = normalizePath(path);
|
|
2456
|
+
if (this.stopped || this.shouldIgnore(normalized) || this.conflicts.has(normalized)) return;
|
|
2457
|
+
const previous = this.eventTimers.get(normalized);
|
|
2458
|
+
if (previous) clearTimeout(previous.timer);
|
|
2459
|
+
const actors = previous?.actors ?? /* @__PURE__ */ new Set();
|
|
2460
|
+
let unknownRemoteActor = previous?.unknownRemoteActor ?? false;
|
|
2461
|
+
if (source === "remote") {
|
|
2462
|
+
if (actor) actors.add(actor);
|
|
2463
|
+
else unknownRemoteActor = true;
|
|
2464
|
+
}
|
|
2465
|
+
const pending = {
|
|
2466
|
+
source: previous?.source === "local" || source === "local" ? "local" : "remote",
|
|
2467
|
+
actors,
|
|
2468
|
+
unknownRemoteActor,
|
|
2469
|
+
timer: setTimeout(() => {
|
|
2470
|
+
this.eventTimers.delete(normalized);
|
|
2471
|
+
if (this.stopped) return;
|
|
2472
|
+
const remoteActor = pending.unknownRemoteActor ? void 0 : pending.actors.size === 1 ? [...pending.actors][0] : pending.actors.size > 1 ? "multiple Overleaf collaborators" : void 0;
|
|
2473
|
+
this.queue = this.queue.then(() => this.synchronizePath(normalized, pending.source, remoteActor)).catch((error) => {
|
|
2474
|
+
this.recordFailure(normalized, pending.source, error, remoteActor);
|
|
2475
|
+
});
|
|
2476
|
+
}, 250)
|
|
2477
|
+
};
|
|
2478
|
+
this.eventTimers.set(normalized, pending);
|
|
2479
|
+
}
|
|
2480
|
+
async synchronizePath(path, source, actor) {
|
|
2481
|
+
if (this.pendingInitialization && this.state.files[path] === void 0) await this.initializePath(path);
|
|
2482
|
+
else await this.reconcilePath(path, source, actor);
|
|
2483
|
+
if (this.failedPaths.delete(path)) this.options.onSyncError?.(this.syncError);
|
|
2484
|
+
}
|
|
2485
|
+
recordFailure(path, source, error, actor) {
|
|
2486
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2487
|
+
this.failedPaths.set(path, {
|
|
2488
|
+
source,
|
|
2489
|
+
actor,
|
|
2490
|
+
message
|
|
2491
|
+
});
|
|
2492
|
+
this.options.log(`Failed to synchronize ${path}: ${message}; retrying without stopping other paths.`);
|
|
2493
|
+
this.options.onSyncError?.(this.syncError);
|
|
2494
|
+
this.scheduleRetry();
|
|
2495
|
+
}
|
|
2496
|
+
scheduleRetry() {
|
|
2497
|
+
if (this.stopped || this.retryTimer || this.failedPaths.size === 0) return;
|
|
2498
|
+
this.retryTimer = setTimeout(() => {
|
|
2499
|
+
this.retryTimer = void 0;
|
|
2500
|
+
this.enqueue(async () => {
|
|
2501
|
+
if (this.stopped) return;
|
|
2502
|
+
for (const [path, failure] of Array.from(this.failedPaths)) {
|
|
2503
|
+
if (this.stopped) return;
|
|
2504
|
+
try {
|
|
2505
|
+
await this.synchronizePath(path, failure.source, failure.actor);
|
|
2506
|
+
this.options.log(`Recovered synchronization for ${path}.`);
|
|
2507
|
+
} catch (error) {
|
|
2508
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2509
|
+
this.failedPaths.set(path, {
|
|
2510
|
+
...failure,
|
|
2511
|
+
message
|
|
2512
|
+
});
|
|
2513
|
+
this.options.log(`Retry failed for ${path}: ${message}`);
|
|
2514
|
+
}
|
|
2515
|
+
}
|
|
2516
|
+
await this.finishRemoteBatch();
|
|
2517
|
+
}).catch((error) => this.options.log(`Could not checkpoint retry batch: ${String(error)}`)).finally(() => {
|
|
2518
|
+
this.retryDelayMs = this.failedPaths.size ? Math.min(this.retryDelayMs * 2, 12e4) : 3e4;
|
|
2519
|
+
this.scheduleRetry();
|
|
2520
|
+
});
|
|
2521
|
+
}, this.retryDelayMs);
|
|
2522
|
+
}
|
|
2523
|
+
async finishRemoteBatch() {
|
|
2524
|
+
if (this.stopped || this.failedPaths.size || this.pendingRemoteVersion === void 0) return;
|
|
2525
|
+
this.state.remoteVersion = this.pendingRemoteVersion;
|
|
2526
|
+
if (this.pendingInitialization) this.state.initialized = true;
|
|
2527
|
+
await this.options.stateStore.save(this.state);
|
|
2528
|
+
this.pendingRemoteVersion = void 0;
|
|
2529
|
+
this.pendingInitialization = false;
|
|
2530
|
+
}
|
|
2531
|
+
enqueue(operation) {
|
|
2532
|
+
const result = this.queue.then(operation);
|
|
2533
|
+
this.queue = result.then(() => void 0, () => void 0);
|
|
2534
|
+
return result;
|
|
2535
|
+
}
|
|
2536
|
+
async reconcilePath(path, source, actor) {
|
|
2537
|
+
const normalized = normalizePath(path);
|
|
2538
|
+
if (this.stopped || this.shouldIgnore(normalized) || this.conflicts.has(normalized)) return;
|
|
2539
|
+
const remoteEntry = this.options.remote.entry(normalized);
|
|
2540
|
+
if (remoteEntry?.type === "folder") return;
|
|
2541
|
+
const localContent = await this.options.local.read(normalized);
|
|
2542
|
+
const remoteContent = isRemoteFile(remoteEntry) ? await this.options.remote.read(normalized) : void 0;
|
|
2543
|
+
const baseHash = this.state.files[normalized];
|
|
2544
|
+
const baseContent = readTextBase(this.state, normalized);
|
|
2545
|
+
const localHash = localContent && sha256(localContent);
|
|
2546
|
+
const remoteHash = remoteContent && sha256(remoteContent);
|
|
2547
|
+
if (localHash === remoteHash) {
|
|
2548
|
+
if (localHash !== baseHash) {
|
|
2549
|
+
if (localContent) updateSyncCheckpoint(this.state, normalized, localContent);
|
|
2550
|
+
else removeSyncCheckpoint(this.state, normalized);
|
|
2551
|
+
await this.options.stateStore.save(this.state);
|
|
2552
|
+
}
|
|
2553
|
+
return;
|
|
2554
|
+
}
|
|
2555
|
+
const localChanged = localHash !== baseHash;
|
|
2556
|
+
const remoteChanged = remoteHash !== baseHash;
|
|
2557
|
+
if (localChanged && remoteChanged) {
|
|
2558
|
+
if (baseContent && localContent && remoteContent) {
|
|
2559
|
+
const merged = mergeText(baseContent, localContent, remoteContent);
|
|
2560
|
+
if (merged.status === "merged" && merged.content) {
|
|
2561
|
+
await this.options.local.write(normalized, merged.content);
|
|
2562
|
+
await this.options.remote.write(normalized, merged.content);
|
|
2563
|
+
updateSyncCheckpoint(this.state, normalized, merged.content);
|
|
2564
|
+
await this.options.stateStore.save(this.state);
|
|
2565
|
+
this.logChange("merge", "update", normalized, baseContent, merged.content, actor);
|
|
2566
|
+
return;
|
|
2567
|
+
}
|
|
2568
|
+
}
|
|
2569
|
+
await this.recordConflict(normalized, "Both local and Overleaf content changed since the last synchronized checkpoint.");
|
|
2570
|
+
return;
|
|
2571
|
+
}
|
|
2572
|
+
if (source === "remote" && localChanged && !remoteChanged) return;
|
|
2573
|
+
if (localChanged || !remoteChanged && source === "local") {
|
|
2574
|
+
let pushedContent = localContent;
|
|
2575
|
+
if (!localContent) {
|
|
2576
|
+
if (remoteEntry) await this.options.remote.remove(normalized);
|
|
2577
|
+
removeSyncCheckpoint(this.state, normalized);
|
|
2578
|
+
} else {
|
|
2579
|
+
await this.options.remote.write(normalized, localContent);
|
|
2580
|
+
const synchronized = await this.options.remote.read(normalized);
|
|
2581
|
+
if (sha256(synchronized) !== sha256(localContent)) await this.options.local.write(normalized, synchronized);
|
|
2582
|
+
pushedContent = synchronized;
|
|
2583
|
+
updateSyncCheckpoint(this.state, normalized, synchronized);
|
|
2584
|
+
}
|
|
2585
|
+
await this.options.stateStore.save(this.state);
|
|
2586
|
+
if (!localContent && remoteEntry) this.logChange("push", "delete", normalized, baseContent ?? remoteContent, void 0, actor);
|
|
2587
|
+
else if (localContent) this.logChange("push", remoteEntry ? "update" : "add", normalized, baseContent ?? remoteContent, pushedContent, actor);
|
|
2588
|
+
return;
|
|
2589
|
+
}
|
|
2590
|
+
if (!remoteContent) {
|
|
2591
|
+
if (localContent) await this.options.local.remove(normalized);
|
|
2592
|
+
removeSyncCheckpoint(this.state, normalized);
|
|
2593
|
+
} else {
|
|
2594
|
+
await this.options.local.write(normalized, remoteContent);
|
|
2595
|
+
updateSyncCheckpoint(this.state, normalized, remoteContent);
|
|
2596
|
+
}
|
|
2597
|
+
await this.options.stateStore.save(this.state);
|
|
2598
|
+
if (!remoteContent && localContent) this.logChange("pull", "delete", normalized, baseContent ?? localContent, void 0, actor);
|
|
2599
|
+
else if (remoteContent) this.logChange("pull", localContent ? "update" : "add", normalized, baseContent ?? localContent, remoteContent, actor);
|
|
2600
|
+
}
|
|
2601
|
+
logChange(direction, operation, path, before, after, actor) {
|
|
2602
|
+
const summary = describeContentChange(before, after);
|
|
2603
|
+
const source = direction === "push" ? "local filesystem" : direction === "pull" ? actor ?? "Overleaf" : `local filesystem + ${actor ?? "Overleaf"}`;
|
|
2604
|
+
this.options.log([
|
|
2605
|
+
`[${direction}] ${operation} ${JSON.stringify(path)}`,
|
|
2606
|
+
summary,
|
|
2607
|
+
`source=${source}`
|
|
2608
|
+
].filter(Boolean).join("; "));
|
|
2609
|
+
}
|
|
2610
|
+
remoteFilePaths() {
|
|
2611
|
+
return this.options.remote.listEntries().filter((entry) => isRemoteFile(entry) && !this.shouldIgnore(entry.path)).map((entry) => entry.path);
|
|
2612
|
+
}
|
|
2613
|
+
shouldIgnore(path) {
|
|
2614
|
+
return this.options.ignore(path) || path.split("/").some((part) => part.startsWith("."));
|
|
2615
|
+
}
|
|
2616
|
+
};
|
|
2617
|
+
const HASH_PATTERN = /^[a-f0-9]{64}$/;
|
|
2618
|
+
const encoder = new TextEncoder();
|
|
2619
|
+
function json(value) {
|
|
2620
|
+
return `${JSON.stringify(value, null, 2)}\n`;
|
|
2621
|
+
}
|
|
2622
|
+
function pathKey(path) {
|
|
2623
|
+
return sha256(encoder.encode(path));
|
|
2624
|
+
}
|
|
2625
|
+
async function readJson(path) {
|
|
2626
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
2627
|
+
}
|
|
2628
|
+
async function listFiles(directory) {
|
|
2629
|
+
const result = [];
|
|
2630
|
+
const visit = async (current) => {
|
|
2631
|
+
let entries;
|
|
2632
|
+
try {
|
|
2633
|
+
entries = await readdir(current, { withFileTypes: true });
|
|
2634
|
+
} catch (error) {
|
|
2635
|
+
if (error.code === "ENOENT") return;
|
|
2636
|
+
throw error;
|
|
2637
|
+
}
|
|
2638
|
+
for (const entry of entries) {
|
|
2639
|
+
const path = join(current, entry.name);
|
|
2640
|
+
if (entry.isDirectory()) await visit(path);
|
|
2641
|
+
else if (entry.isFile()) result.push(path);
|
|
2642
|
+
}
|
|
2643
|
+
};
|
|
2644
|
+
await visit(directory);
|
|
2645
|
+
return result;
|
|
2646
|
+
}
|
|
2647
|
+
async function atomicWrite(path, content) {
|
|
2648
|
+
await mkdir(dirname(path), { recursive: true });
|
|
2649
|
+
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
2650
|
+
try {
|
|
2651
|
+
await writeFile(temporary, content);
|
|
2652
|
+
await rename(temporary, path);
|
|
2653
|
+
} finally {
|
|
2654
|
+
await rm(temporary, { force: true });
|
|
2655
|
+
}
|
|
2656
|
+
}
|
|
2657
|
+
function parseFormat(value, projectUri) {
|
|
2658
|
+
if (typeof value !== "object" || value === null) return false;
|
|
2659
|
+
const format = value;
|
|
2660
|
+
return format.schemaVersion === 1 && format.projectUri === projectUri;
|
|
2661
|
+
}
|
|
2662
|
+
function parseCursor(value) {
|
|
2663
|
+
if (typeof value !== "object" || value === null) return false;
|
|
2664
|
+
const cursor = value;
|
|
2665
|
+
return Number.isInteger(cursor.remoteVersion) && cursor.remoteVersion >= 0 && typeof cursor.initialized === "boolean";
|
|
2666
|
+
}
|
|
2667
|
+
function parsePathRecord(value) {
|
|
2668
|
+
if (typeof value !== "object" || value === null) return false;
|
|
2669
|
+
const record = value;
|
|
2670
|
+
if (typeof record.path !== "string" || !record.path.startsWith("/")) return false;
|
|
2671
|
+
if (record.hash !== void 0 && (typeof record.hash !== "string" || !HASH_PATTERN.test(record.hash))) return false;
|
|
2672
|
+
if (record.baseHash !== void 0 && (typeof record.baseHash !== "string" || !HASH_PATTERN.test(record.baseHash) || record.hash !== record.baseHash)) return false;
|
|
2673
|
+
if (record.conflict !== void 0 && typeof record.conflict !== "string") return false;
|
|
2674
|
+
return record.hash !== void 0 || record.conflict !== void 0;
|
|
2675
|
+
}
|
|
2676
|
+
function createRecord(state, path) {
|
|
2677
|
+
const hash = state.files[path];
|
|
2678
|
+
const textBase = state.textBases[path];
|
|
2679
|
+
const conflict = state.conflicts[path];
|
|
2680
|
+
if (hash === void 0 && conflict === void 0) return;
|
|
2681
|
+
return {
|
|
2682
|
+
path,
|
|
2683
|
+
...hash === void 0 ? {} : { hash },
|
|
2684
|
+
...textBase === void 0 ? {} : { baseHash: hash },
|
|
2685
|
+
...conflict === void 0 ? {} : { conflict }
|
|
2686
|
+
};
|
|
2687
|
+
}
|
|
2688
|
+
var FileSyncStateStore = class {
|
|
2689
|
+
projectUri;
|
|
2690
|
+
legacyPath;
|
|
2691
|
+
syncDirectory;
|
|
2692
|
+
formatPath;
|
|
2693
|
+
cursorPath;
|
|
2694
|
+
pathsDirectory;
|
|
2695
|
+
basesDirectory;
|
|
2696
|
+
persistedRecords = /* @__PURE__ */ new Map();
|
|
2697
|
+
persistedCursor;
|
|
2698
|
+
invalidRecordPaths = /* @__PURE__ */ new Set();
|
|
2699
|
+
initialized = false;
|
|
2700
|
+
constructor(directory, projectUri) {
|
|
2701
|
+
this.projectUri = projectUri;
|
|
2702
|
+
const overleafDirectory = resolve(directory, ".overleaf");
|
|
2703
|
+
this.legacyPath = join(overleafDirectory, "sync-state.json");
|
|
2704
|
+
this.syncDirectory = join(overleafDirectory, "sync");
|
|
2705
|
+
this.formatPath = join(this.syncDirectory, "format.json");
|
|
2706
|
+
this.cursorPath = join(this.syncDirectory, "cursor.json");
|
|
2707
|
+
this.pathsDirectory = join(this.syncDirectory, "paths");
|
|
2708
|
+
this.basesDirectory = join(this.syncDirectory, "bases");
|
|
2709
|
+
}
|
|
2710
|
+
async load() {
|
|
2711
|
+
this.invalidRecordPaths.clear();
|
|
2712
|
+
try {
|
|
2713
|
+
const format = await readJson(this.formatPath);
|
|
2714
|
+
const cursor = await readJson(this.cursorPath);
|
|
2715
|
+
if (!parseFormat(format, this.projectUri) || !parseCursor(cursor)) return;
|
|
2716
|
+
const state = {
|
|
2717
|
+
schemaVersion: 1,
|
|
2718
|
+
projectUri: this.projectUri,
|
|
2719
|
+
remoteVersion: cursor.remoteVersion,
|
|
2720
|
+
initialized: cursor.initialized,
|
|
2721
|
+
files: {},
|
|
2722
|
+
textBases: {},
|
|
2723
|
+
conflicts: {}
|
|
2724
|
+
};
|
|
2725
|
+
const records = /* @__PURE__ */ new Map();
|
|
2726
|
+
const referencedBases = /* @__PURE__ */ new Set();
|
|
2727
|
+
let stateIncomplete = false;
|
|
2728
|
+
for (const recordPath of await listFiles(this.pathsDirectory)) {
|
|
2729
|
+
const record = await this.loadRecord(recordPath);
|
|
2730
|
+
if (!record) {
|
|
2731
|
+
if (recordPath.endsWith(".json")) {
|
|
2732
|
+
this.invalidRecordPaths.add(recordPath);
|
|
2733
|
+
stateIncomplete = true;
|
|
2734
|
+
} else await rm(recordPath, { force: true });
|
|
2735
|
+
continue;
|
|
2736
|
+
}
|
|
2737
|
+
if (record.hash !== void 0) state.files[record.path] = record.hash;
|
|
2738
|
+
if (record.baseHash !== void 0) {
|
|
2739
|
+
let textBase;
|
|
2740
|
+
try {
|
|
2741
|
+
textBase = await readFile(this.basePath(record.baseHash), "utf8");
|
|
2742
|
+
} catch (error) {
|
|
2743
|
+
if (error.code === "ENOENT") {
|
|
2744
|
+
this.invalidRecordPaths.add(recordPath);
|
|
2745
|
+
stateIncomplete = true;
|
|
2746
|
+
delete state.files[record.path];
|
|
2747
|
+
continue;
|
|
2748
|
+
}
|
|
2749
|
+
throw error;
|
|
2750
|
+
}
|
|
2751
|
+
if (sha256(encoder.encode(textBase)) !== record.baseHash) {
|
|
2752
|
+
this.invalidRecordPaths.add(recordPath);
|
|
2753
|
+
stateIncomplete = true;
|
|
2754
|
+
delete state.files[record.path];
|
|
2755
|
+
continue;
|
|
2756
|
+
}
|
|
2757
|
+
state.textBases[record.path] = textBase;
|
|
2758
|
+
referencedBases.add(record.baseHash);
|
|
2759
|
+
}
|
|
2760
|
+
if (record.conflict !== void 0) state.conflicts[record.path] = record.conflict;
|
|
2761
|
+
records.set(record.path, json(record));
|
|
2762
|
+
}
|
|
2763
|
+
if (stateIncomplete) state.initialized = false;
|
|
2764
|
+
await this.removeOrphanBases(referencedBases);
|
|
2765
|
+
this.persistedRecords = records;
|
|
2766
|
+
this.persistedCursor = json(cursor);
|
|
2767
|
+
this.initialized = true;
|
|
2768
|
+
return state;
|
|
2769
|
+
} catch (error) {
|
|
2770
|
+
if (error.code === "ENOENT" || error instanceof SyntaxError) return;
|
|
2771
|
+
throw error;
|
|
2772
|
+
}
|
|
2773
|
+
}
|
|
2774
|
+
async save(state) {
|
|
2775
|
+
if (state.projectUri !== this.projectUri || state.schemaVersion !== 1) throw new Error("Cannot persist synchronization state for a different project or schema.");
|
|
2776
|
+
if (!this.initialized) await this.initializeStorage();
|
|
2777
|
+
const paths = /* @__PURE__ */ new Set([
|
|
2778
|
+
...this.persistedRecords.keys(),
|
|
2779
|
+
...Object.keys(state.files),
|
|
2780
|
+
...Object.keys(state.conflicts)
|
|
2781
|
+
]);
|
|
2782
|
+
const obsoleteBases = /* @__PURE__ */ new Set();
|
|
2783
|
+
for (const path of [...paths].sort()) {
|
|
2784
|
+
const record = createRecord(state, path);
|
|
2785
|
+
const serialized = record && json(record);
|
|
2786
|
+
const previous = this.persistedRecords.get(path);
|
|
2787
|
+
if (serialized === previous) continue;
|
|
2788
|
+
if (previous) {
|
|
2789
|
+
const previousRecord = JSON.parse(previous);
|
|
2790
|
+
if (previousRecord.baseHash) obsoleteBases.add(previousRecord.baseHash);
|
|
2791
|
+
}
|
|
2792
|
+
if (!record || !serialized) {
|
|
2793
|
+
await rm(this.recordPath(path), { force: true });
|
|
2794
|
+
this.persistedRecords.delete(path);
|
|
2795
|
+
continue;
|
|
2796
|
+
}
|
|
2797
|
+
if (record.baseHash) {
|
|
2798
|
+
const textBase = state.textBases[path];
|
|
2799
|
+
if (textBase === void 0 || sha256(encoder.encode(textBase)) !== record.baseHash) throw new Error(`Text checkpoint does not match its content hash: ${path}`);
|
|
2800
|
+
await this.writeBase(record.baseHash, textBase);
|
|
2801
|
+
}
|
|
2802
|
+
const destination = this.recordPath(path);
|
|
2803
|
+
await atomicWrite(destination, serialized);
|
|
2804
|
+
this.invalidRecordPaths.delete(destination);
|
|
2805
|
+
this.persistedRecords.set(path, serialized);
|
|
2806
|
+
}
|
|
2807
|
+
const cursor = json({
|
|
2808
|
+
remoteVersion: state.remoteVersion,
|
|
2809
|
+
initialized: state.initialized
|
|
2810
|
+
});
|
|
2811
|
+
if (cursor !== this.persistedCursor) {
|
|
2812
|
+
await atomicWrite(this.cursorPath, cursor);
|
|
2813
|
+
this.persistedCursor = cursor;
|
|
2814
|
+
}
|
|
2815
|
+
if (state.initialized) {
|
|
2816
|
+
await Promise.all([...this.invalidRecordPaths].map((path) => rm(path, { force: true })));
|
|
2817
|
+
this.invalidRecordPaths.clear();
|
|
2818
|
+
}
|
|
2819
|
+
await rm(this.legacyPath, { force: true });
|
|
2820
|
+
await this.removeObsoleteBases(state, obsoleteBases);
|
|
2821
|
+
}
|
|
2822
|
+
async initializeStorage() {
|
|
2823
|
+
await rm(this.syncDirectory, {
|
|
2824
|
+
recursive: true,
|
|
2825
|
+
force: true
|
|
2826
|
+
});
|
|
2827
|
+
await mkdir(this.pathsDirectory, { recursive: true });
|
|
2828
|
+
await mkdir(this.basesDirectory, { recursive: true });
|
|
2829
|
+
await atomicWrite(this.formatPath, json({
|
|
2830
|
+
schemaVersion: 1,
|
|
2831
|
+
projectUri: this.projectUri
|
|
2832
|
+
}));
|
|
2833
|
+
this.persistedRecords.clear();
|
|
2834
|
+
this.persistedCursor = void 0;
|
|
2835
|
+
this.invalidRecordPaths.clear();
|
|
2836
|
+
this.initialized = true;
|
|
2837
|
+
}
|
|
2838
|
+
recordPath(path) {
|
|
2839
|
+
const key = pathKey(path);
|
|
2840
|
+
return join(this.pathsDirectory, key.slice(0, 2), `${key}.json`);
|
|
2841
|
+
}
|
|
2842
|
+
async loadRecord(recordPath) {
|
|
2843
|
+
if (!recordPath.endsWith(".json")) return;
|
|
2844
|
+
try {
|
|
2845
|
+
const record = await readJson(recordPath);
|
|
2846
|
+
if (!parsePathRecord(record) || recordPath !== this.recordPath(record.path)) return;
|
|
2847
|
+
return record;
|
|
2848
|
+
} catch (error) {
|
|
2849
|
+
if (error.code === "ENOENT" || error instanceof SyntaxError) return;
|
|
2850
|
+
throw error;
|
|
2851
|
+
}
|
|
2852
|
+
}
|
|
2853
|
+
basePath(hash) {
|
|
2854
|
+
return join(this.basesDirectory, hash.slice(0, 2), `${hash}.txt`);
|
|
2855
|
+
}
|
|
2856
|
+
async writeBase(hash, content) {
|
|
2857
|
+
const path = this.basePath(hash);
|
|
2858
|
+
try {
|
|
2859
|
+
if (sha256(encoder.encode(await readFile(path, "utf8"))) === hash) return;
|
|
2860
|
+
} catch (error) {
|
|
2861
|
+
if (error.code !== "ENOENT") throw error;
|
|
2862
|
+
}
|
|
2863
|
+
await atomicWrite(path, content);
|
|
2864
|
+
}
|
|
2865
|
+
async removeObsoleteBases(state, candidates) {
|
|
2866
|
+
if (candidates.size === 0) return;
|
|
2867
|
+
const referenced = /* @__PURE__ */ new Set();
|
|
2868
|
+
for (const [path, textBase] of Object.entries(state.textBases)) if (textBase !== void 0 && state.files[path] !== void 0) referenced.add(state.files[path]);
|
|
2869
|
+
for (const hash of candidates) if (!referenced.has(hash)) await rm(this.basePath(hash), { force: true });
|
|
2870
|
+
}
|
|
2871
|
+
async removeOrphanBases(referencedHashes) {
|
|
2872
|
+
const referencedPaths = new Set([...referencedHashes].map((hash) => this.basePath(hash)));
|
|
2873
|
+
for (const path of await listFiles(this.basesDirectory)) if (!referencedPaths.has(path)) await rm(path, { force: true });
|
|
2874
|
+
}
|
|
2875
|
+
};
|
|
2876
|
+
async function startServe(directory = process.cwd(), options = {}) {
|
|
2877
|
+
const root = resolve(directory);
|
|
2878
|
+
const settings = await readProjectSettings(root);
|
|
2879
|
+
const session = await getServerSession(settings.serverName);
|
|
2880
|
+
if (!session) throw new Error(`No login is stored for ${settings.serverName}. Run: leafrelay login ${settings.serverName}`);
|
|
2881
|
+
const cookie = options.cookie || process.env.LEAFRELAY_COOKIE || session.identity.cookies;
|
|
2882
|
+
const remote = options.remote ?? new RemoteProject(session.url || settings.serverUrl, settings.projectId, cookie);
|
|
2883
|
+
await remote.connect();
|
|
2884
|
+
const configuredPatterns = settings.localReplica.settings["ignore-patterns"];
|
|
2885
|
+
const ignore = createIgnoreMatcher(configuredPatterns);
|
|
2886
|
+
const local = new NodeReplicaFileSystem(root, ignore);
|
|
2887
|
+
const engine = new SyncEngine({
|
|
2888
|
+
projectUri: settings.uri,
|
|
2889
|
+
local,
|
|
2890
|
+
remote,
|
|
2891
|
+
stateStore: new FileSyncStateStore(root, settings.uri),
|
|
2892
|
+
ignore,
|
|
2893
|
+
onSyncError: options.onSyncError,
|
|
2894
|
+
log: options.log ?? log,
|
|
2895
|
+
onConflict: options.onConflict ?? ((path, reason) => {
|
|
2896
|
+
(options.log ?? log)(`Conflict paused for ${path}: ${reason}`);
|
|
2897
|
+
})
|
|
2898
|
+
});
|
|
2899
|
+
try {
|
|
2900
|
+
await engine.start();
|
|
2901
|
+
} catch (error) {
|
|
2902
|
+
remote.disconnect();
|
|
2903
|
+
throw error;
|
|
2904
|
+
}
|
|
2905
|
+
(options.log ?? log)(`LeafRelay is serving ${root} (${settings.projectName}).`);
|
|
2906
|
+
return {
|
|
2907
|
+
root,
|
|
2908
|
+
serverName: settings.serverName,
|
|
2909
|
+
projectId: settings.projectId,
|
|
2910
|
+
projectName: settings.projectName,
|
|
2911
|
+
syncError: () => engine.syncError,
|
|
2912
|
+
conflicts: () => engine.getConflicts(),
|
|
2913
|
+
resolveConflict: (path, winner) => engine.resolveConflict(path, winner),
|
|
2914
|
+
retry: (path) => engine.retry(path),
|
|
2915
|
+
stop: async () => {
|
|
2916
|
+
await engine.stop();
|
|
2917
|
+
remote.disconnect();
|
|
2918
|
+
}
|
|
2919
|
+
};
|
|
2920
|
+
}
|
|
2921
|
+
export { configPath as a, readConfig as c, removeServerSession as d, saveKnownServer as f, startServe as g, setLogSink as h, SocketIOAPI as i, readProjectSettings as l, serverKey as m, HttpRequestScheduler as n, getServerSession as o, saveServerSession as p, RemoteProject as r, normalizeServerUrl as s, ExtendedBaseAPI as t, removeKnownServer as u };
|
|
2922
|
+
|
|
2923
|
+
//# sourceMappingURL=dist-JyfvbgjD.js.map
|