@aloth/olcli 0.1.7 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +136 -52
- package/dist/cli.js.map +1 -1
- package/dist/client.d.ts +9 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +318 -132
- package/dist/client.js.map +1 -1
- package/package.json +1 -1
package/dist/client.js
CHANGED
|
@@ -8,6 +8,8 @@ import * as cheerio from 'cheerio';
|
|
|
8
8
|
import { readFileSync } from 'node:fs';
|
|
9
9
|
import { dirname, join } from 'node:path';
|
|
10
10
|
import { fileURLToPath } from 'node:url';
|
|
11
|
+
import * as https from 'node:https';
|
|
12
|
+
import * as http from 'node:http';
|
|
11
13
|
// Read version from package.json
|
|
12
14
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
13
15
|
const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8'));
|
|
@@ -48,24 +50,20 @@ export class OverleafClient {
|
|
|
48
50
|
[cookieName]: sessionCookie
|
|
49
51
|
};
|
|
50
52
|
// Fetch CSRF token from project page
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
53
|
+
const initialHeaders = {
|
|
54
|
+
'Cookie': Object.entries(cookies).map(([k, v]) => `${k}=${v}`).join('; '),
|
|
55
|
+
'User-Agent': USER_AGENT
|
|
56
|
+
};
|
|
57
|
+
const bootstrapClient = new OverleafClient({ cookies, csrf: 'bootstrap', baseUrl });
|
|
58
|
+
const response = await bootstrapClient.httpRequest(`${baseUrl}/project`, {
|
|
59
|
+
headers: initialHeaders,
|
|
60
|
+
expect: 'text'
|
|
56
61
|
});
|
|
57
62
|
if (!response.ok) {
|
|
58
|
-
throw new Error(`Failed to fetch projects page: ${response.status}
|
|
59
|
-
}
|
|
60
|
-
// Capture any new cookies from response
|
|
61
|
-
const setCookieHeaders = response.headers.getSetCookie?.() || [];
|
|
62
|
-
for (const setCookie of setCookieHeaders) {
|
|
63
|
-
const match = setCookie.match(/^([^=]+)=([^;]+)/);
|
|
64
|
-
if (match) {
|
|
65
|
-
cookies[match[1]] = match[2];
|
|
66
|
-
}
|
|
63
|
+
throw new Error(`Failed to fetch projects page: ${response.status}`);
|
|
67
64
|
}
|
|
68
|
-
|
|
65
|
+
bootstrapClient.applySetCookieHeaders(response.headers['set-cookie']);
|
|
66
|
+
const html = response.body;
|
|
69
67
|
const $ = cheerio.load(html);
|
|
70
68
|
// Try multiple methods to find CSRF token (based on PR #66, #82)
|
|
71
69
|
let csrf;
|
|
@@ -90,30 +88,13 @@ export class OverleafClient {
|
|
|
90
88
|
if (!csrf) {
|
|
91
89
|
throw new Error('Could not find CSRF token. Session may have expired.');
|
|
92
90
|
}
|
|
93
|
-
|
|
91
|
+
// Update cookies if the bootstrap request added anything
|
|
92
|
+
const updatedCookies = bootstrapClient.cookies;
|
|
93
|
+
return new OverleafClient({ cookies: updatedCookies, csrf, baseUrl });
|
|
94
94
|
}
|
|
95
95
|
getCookieHeader() {
|
|
96
96
|
return Object.entries(this.cookies).map(([k, v]) => `${k}=${v}`).join('; ');
|
|
97
97
|
}
|
|
98
|
-
applySetCookieHeaders(headers) {
|
|
99
|
-
const setCookieHeaders = headers.getSetCookie?.() || [];
|
|
100
|
-
for (const setCookie of setCookieHeaders) {
|
|
101
|
-
const match = setCookie.match(/^([^=]+)=([^;]+)/);
|
|
102
|
-
if (match) {
|
|
103
|
-
this.cookies[match[1]] = match[2];
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
async fetchWithTimeout(url, init, timeoutMs) {
|
|
108
|
-
const controller = new AbortController();
|
|
109
|
-
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
110
|
-
try {
|
|
111
|
-
return await fetch(url, { ...init, signal: controller.signal });
|
|
112
|
-
}
|
|
113
|
-
finally {
|
|
114
|
-
clearTimeout(timeout);
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
98
|
getHeaders(includeContentType = false) {
|
|
118
99
|
const headers = {
|
|
119
100
|
'Cookie': this.getCookieHeader(),
|
|
@@ -125,17 +106,110 @@ export class OverleafClient {
|
|
|
125
106
|
}
|
|
126
107
|
return headers;
|
|
127
108
|
}
|
|
109
|
+
normalizeHeaders(headers) {
|
|
110
|
+
const normalized = {};
|
|
111
|
+
if (!headers)
|
|
112
|
+
return normalized;
|
|
113
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
114
|
+
if (typeof value === 'string') {
|
|
115
|
+
normalized[key] = value;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return normalized;
|
|
119
|
+
}
|
|
120
|
+
applySetCookieHeaders(setCookie) {
|
|
121
|
+
if (!setCookie)
|
|
122
|
+
return;
|
|
123
|
+
for (const setCookieHeader of setCookie) {
|
|
124
|
+
const match = setCookieHeader.match(/^([^=]+)=([^;]+)/);
|
|
125
|
+
if (match) {
|
|
126
|
+
this.cookies[match[1]] = match[2];
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
async httpRequest(url, options = {}) {
|
|
131
|
+
const method = options.method || 'GET';
|
|
132
|
+
const timeoutMs = options.timeoutMs ?? 10000;
|
|
133
|
+
const maxRedirects = options.maxRedirects ?? 5;
|
|
134
|
+
const expect = options.expect ?? 'text';
|
|
135
|
+
// Normalize FormData bodies into a multipart Buffer + headers using Node's
|
|
136
|
+
// built-in Web Fetch primitives. Keeps every code path on httpRequest
|
|
137
|
+
// (no fetch() reintroduction) while properly serializing multipart uploads.
|
|
138
|
+
let bodyBuffer;
|
|
139
|
+
let extraHeaders = {};
|
|
140
|
+
if (options.body instanceof FormData) {
|
|
141
|
+
const req = new Request('http://x/', { method: 'POST', body: options.body });
|
|
142
|
+
const arrayBuf = await req.arrayBuffer();
|
|
143
|
+
bodyBuffer = Buffer.from(arrayBuf);
|
|
144
|
+
const ct = req.headers.get('content-type');
|
|
145
|
+
if (ct)
|
|
146
|
+
extraHeaders['Content-Type'] = ct;
|
|
147
|
+
extraHeaders['Content-Length'] = String(bodyBuffer.length);
|
|
148
|
+
}
|
|
149
|
+
else if (options.body !== undefined) {
|
|
150
|
+
bodyBuffer = options.body;
|
|
151
|
+
}
|
|
152
|
+
const doRequest = (reqUrl, redirectsLeft) => {
|
|
153
|
+
return new Promise((resolve, reject) => {
|
|
154
|
+
const parsedUrl = new URL(reqUrl);
|
|
155
|
+
const transport = parsedUrl.protocol === 'https:' ? https : http;
|
|
156
|
+
const headers = this.normalizeHeaders({ ...extraHeaders, ...options.headers });
|
|
157
|
+
const req = transport.request(reqUrl, { method, headers }, (res) => {
|
|
158
|
+
const status = res.statusCode || 0;
|
|
159
|
+
const resHeaders = res.headers;
|
|
160
|
+
if (status >= 300 && status < 400 && res.headers.location && redirectsLeft > 0) {
|
|
161
|
+
const redirectUrl = new URL(res.headers.location, reqUrl).toString();
|
|
162
|
+
res.resume();
|
|
163
|
+
doRequest(redirectUrl, redirectsLeft - 1).then(resolve, reject);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
const chunks = [];
|
|
167
|
+
res.on('data', (chunk) => chunks.push(chunk));
|
|
168
|
+
res.on('end', () => {
|
|
169
|
+
const buffer = Buffer.concat(chunks);
|
|
170
|
+
let body = buffer;
|
|
171
|
+
if (expect === 'text') {
|
|
172
|
+
body = buffer.toString('utf-8');
|
|
173
|
+
}
|
|
174
|
+
else if (expect === 'json') {
|
|
175
|
+
try {
|
|
176
|
+
body = JSON.parse(buffer.toString('utf-8'));
|
|
177
|
+
}
|
|
178
|
+
catch (e) {
|
|
179
|
+
return reject(new Error(`Failed to parse JSON response from ${reqUrl}`));
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
resolve({ status, ok: status >= 200 && status < 300, headers: resHeaders, body });
|
|
183
|
+
});
|
|
184
|
+
res.on('error', reject);
|
|
185
|
+
});
|
|
186
|
+
req.on('error', reject);
|
|
187
|
+
if (timeoutMs) {
|
|
188
|
+
req.setTimeout(timeoutMs, () => {
|
|
189
|
+
req.destroy(new Error(`Request timeout after ${timeoutMs}ms`));
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
if (bodyBuffer !== undefined) {
|
|
193
|
+
req.write(bodyBuffer);
|
|
194
|
+
}
|
|
195
|
+
req.end();
|
|
196
|
+
});
|
|
197
|
+
};
|
|
198
|
+
return doRequest(url, maxRedirects);
|
|
199
|
+
}
|
|
128
200
|
/**
|
|
129
201
|
* Get all projects (not archived, not trashed)
|
|
130
202
|
*/
|
|
131
203
|
async listProjects() {
|
|
132
|
-
const response = await
|
|
133
|
-
headers: this.getHeaders()
|
|
204
|
+
const response = await this.httpRequest(this.projectUrl(), {
|
|
205
|
+
headers: this.getHeaders(),
|
|
206
|
+
expect: 'text'
|
|
134
207
|
});
|
|
135
208
|
if (!response.ok) {
|
|
136
209
|
throw new Error(`Failed to fetch projects: ${response.status}`);
|
|
137
210
|
}
|
|
138
|
-
|
|
211
|
+
this.applySetCookieHeaders(response.headers['set-cookie']);
|
|
212
|
+
const html = response.body;
|
|
139
213
|
const $ = cheerio.load(html);
|
|
140
214
|
// Try new Overleaf structure first (PR #82)
|
|
141
215
|
let projectsData = [];
|
|
@@ -212,13 +286,15 @@ export class OverleafClient {
|
|
|
212
286
|
* Get detailed project info including file tree
|
|
213
287
|
*/
|
|
214
288
|
async getProjectInfo(projectId) {
|
|
215
|
-
const response = await
|
|
216
|
-
headers: this.getHeaders()
|
|
289
|
+
const response = await this.httpRequest(`${this.projectUrl()}/${projectId}`, {
|
|
290
|
+
headers: this.getHeaders(),
|
|
291
|
+
expect: 'text'
|
|
217
292
|
});
|
|
218
293
|
if (!response.ok) {
|
|
219
294
|
throw new Error(`Failed to fetch project info: ${response.status}`);
|
|
220
295
|
}
|
|
221
|
-
|
|
296
|
+
this.applySetCookieHeaders(response.headers['set-cookie']);
|
|
297
|
+
const html = response.body;
|
|
222
298
|
const $ = cheerio.load(html);
|
|
223
299
|
// Look for project data in meta tags
|
|
224
300
|
let projectInfo;
|
|
@@ -248,11 +324,104 @@ export class OverleafClient {
|
|
|
248
324
|
}
|
|
249
325
|
}
|
|
250
326
|
}
|
|
327
|
+
// Fallback: Overleaf no longer ships the project tree in meta tags.
|
|
328
|
+
// Use the Socket.IO joinProjectResponse payload (same source used for
|
|
329
|
+
// root folder discovery) to retrieve the full project info.
|
|
330
|
+
if (!projectInfo) {
|
|
331
|
+
const socketProject = await this.getProjectFromSocket(projectId);
|
|
332
|
+
if (socketProject) {
|
|
333
|
+
projectInfo = socketProject;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
251
336
|
if (!projectInfo) {
|
|
252
337
|
throw new Error('Could not parse project info');
|
|
253
338
|
}
|
|
254
339
|
return projectInfo;
|
|
255
340
|
}
|
|
341
|
+
/**
|
|
342
|
+
* Fetch the full project object via the collaboration socket.
|
|
343
|
+
* Returns the `project` field of the joinProjectResponse, which contains
|
|
344
|
+
* the rootFolder tree and other metadata that used to live in ol-project.
|
|
345
|
+
*/
|
|
346
|
+
async getProjectFromSocket(projectId) {
|
|
347
|
+
let sid = null;
|
|
348
|
+
try {
|
|
349
|
+
const handshakeUrl = `${this.baseUrl}/socket.io/1/?projectId=${encodeURIComponent(projectId)}&t=${Date.now()}`;
|
|
350
|
+
const handshakeResponse = await this.httpRequest(handshakeUrl, {
|
|
351
|
+
headers: { 'Cookie': this.getCookieHeader(), 'User-Agent': USER_AGENT },
|
|
352
|
+
expect: 'text',
|
|
353
|
+
timeoutMs: 5000
|
|
354
|
+
});
|
|
355
|
+
if (!handshakeResponse.ok)
|
|
356
|
+
return null;
|
|
357
|
+
this.applySetCookieHeaders(handshakeResponse.headers['set-cookie']);
|
|
358
|
+
const handshakeBody = handshakeResponse.body.trim();
|
|
359
|
+
sid = handshakeBody.split(':')[0];
|
|
360
|
+
if (!sid)
|
|
361
|
+
return null;
|
|
362
|
+
const buildPollUrl = () => `${this.baseUrl}/socket.io/1/xhr-polling/${sid}?projectId=${encodeURIComponent(projectId)}&t=${Date.now()}`;
|
|
363
|
+
for (let attempt = 0; attempt < 6; attempt++) {
|
|
364
|
+
const pollResponse = await this.httpRequest(buildPollUrl(), {
|
|
365
|
+
headers: { 'Cookie': this.getCookieHeader(), 'User-Agent': USER_AGENT },
|
|
366
|
+
expect: 'text',
|
|
367
|
+
timeoutMs: 5000
|
|
368
|
+
});
|
|
369
|
+
if (!pollResponse.ok)
|
|
370
|
+
return null;
|
|
371
|
+
this.applySetCookieHeaders(pollResponse.headers['set-cookie']);
|
|
372
|
+
const packets = this.decodeSocketIoPayload(pollResponse.body);
|
|
373
|
+
for (const packet of packets) {
|
|
374
|
+
if (packet.startsWith('5:::')) {
|
|
375
|
+
try {
|
|
376
|
+
const payload = JSON.parse(packet.slice(4));
|
|
377
|
+
if (payload?.name === 'joinProjectResponse' && payload?.args?.[0]?.project) {
|
|
378
|
+
return payload.args[0].project;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
catch { /* ignore */ }
|
|
382
|
+
}
|
|
383
|
+
if (packet.startsWith('2::')) {
|
|
384
|
+
const heartbeatResponse = await this.httpRequest(buildPollUrl(), {
|
|
385
|
+
method: 'POST',
|
|
386
|
+
headers: {
|
|
387
|
+
'Cookie': this.getCookieHeader(),
|
|
388
|
+
'User-Agent': USER_AGENT,
|
|
389
|
+
'Content-Type': 'text/plain;charset=UTF-8'
|
|
390
|
+
},
|
|
391
|
+
body: '2::',
|
|
392
|
+
expect: 'text',
|
|
393
|
+
timeoutMs: 5000
|
|
394
|
+
});
|
|
395
|
+
this.applySetCookieHeaders(heartbeatResponse.headers['set-cookie']);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
catch {
|
|
401
|
+
// fall through
|
|
402
|
+
}
|
|
403
|
+
finally {
|
|
404
|
+
if (sid) {
|
|
405
|
+
try {
|
|
406
|
+
const disconnectUrl = `${this.baseUrl}/socket.io/1/xhr-polling/${sid}?projectId=${encodeURIComponent(projectId)}&t=${Date.now()}`;
|
|
407
|
+
const disconnectResponse = await this.httpRequest(disconnectUrl, {
|
|
408
|
+
method: 'POST',
|
|
409
|
+
headers: {
|
|
410
|
+
'Cookie': this.getCookieHeader(),
|
|
411
|
+
'User-Agent': USER_AGENT,
|
|
412
|
+
'Content-Type': 'text/plain;charset=UTF-8'
|
|
413
|
+
},
|
|
414
|
+
body: '0::',
|
|
415
|
+
expect: 'text',
|
|
416
|
+
timeoutMs: 5000
|
|
417
|
+
});
|
|
418
|
+
this.applySetCookieHeaders(disconnectResponse.headers['set-cookie']);
|
|
419
|
+
}
|
|
420
|
+
catch { /* ignore */ }
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
return null;
|
|
424
|
+
}
|
|
256
425
|
/**
|
|
257
426
|
* Download a URL as a Buffer using Node.js http/https modules.
|
|
258
427
|
*
|
|
@@ -261,34 +430,15 @@ export class OverleafClient {
|
|
|
261
430
|
* project names). See: https://github.com/aloth/olcli/issues/2
|
|
262
431
|
*/
|
|
263
432
|
async downloadBuffer(url) {
|
|
264
|
-
const
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
// Follow redirects
|
|
274
|
-
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
275
|
-
const redirectUrl = new URL(res.headers.location, reqUrl).toString();
|
|
276
|
-
doRequest(redirectUrl).then(resolve, reject);
|
|
277
|
-
return;
|
|
278
|
-
}
|
|
279
|
-
if (res.statusCode && res.statusCode >= 400) {
|
|
280
|
-
reject(new Error(`Download failed: ${res.statusCode}`));
|
|
281
|
-
return;
|
|
282
|
-
}
|
|
283
|
-
const chunks = [];
|
|
284
|
-
res.on('data', (chunk) => chunks.push(chunk));
|
|
285
|
-
res.on('end', () => resolve(Buffer.concat(chunks)));
|
|
286
|
-
res.on('error', reject);
|
|
287
|
-
});
|
|
288
|
-
req.on('error', reject);
|
|
289
|
-
});
|
|
290
|
-
};
|
|
291
|
-
return doRequest(url);
|
|
433
|
+
const response = await this.httpRequest(url, {
|
|
434
|
+
headers: this.getHeaders(),
|
|
435
|
+
expect: 'buffer'
|
|
436
|
+
});
|
|
437
|
+
if (!response.ok) {
|
|
438
|
+
throw new Error(`Download failed: ${response.status}`);
|
|
439
|
+
}
|
|
440
|
+
this.applySetCookieHeaders(response.headers['set-cookie']);
|
|
441
|
+
return response.body;
|
|
292
442
|
}
|
|
293
443
|
/**
|
|
294
444
|
* Download project as zip
|
|
@@ -303,7 +453,7 @@ export class OverleafClient {
|
|
|
303
453
|
* Compile project and get PDF
|
|
304
454
|
*/
|
|
305
455
|
async compileProject(projectId) {
|
|
306
|
-
const response = await
|
|
456
|
+
const response = await this.httpRequest(this.compileUrl(projectId), {
|
|
307
457
|
method: 'POST',
|
|
308
458
|
headers: this.getHeaders(true),
|
|
309
459
|
body: JSON.stringify({
|
|
@@ -311,12 +461,14 @@ export class OverleafClient {
|
|
|
311
461
|
draft: false,
|
|
312
462
|
check: 'silent',
|
|
313
463
|
incrementalCompilesEnabled: true
|
|
314
|
-
})
|
|
464
|
+
}),
|
|
465
|
+
expect: 'json'
|
|
315
466
|
});
|
|
316
467
|
if (!response.ok) {
|
|
317
468
|
throw new Error(`Failed to compile project: ${response.status}`);
|
|
318
469
|
}
|
|
319
|
-
|
|
470
|
+
this.applySetCookieHeaders(response.headers['set-cookie']);
|
|
471
|
+
const data = response.body;
|
|
320
472
|
if (data.status !== 'success') {
|
|
321
473
|
throw new Error(`Compilation failed: ${data.status}`);
|
|
322
474
|
}
|
|
@@ -340,13 +492,14 @@ export class OverleafClient {
|
|
|
340
492
|
* Create a folder in a project
|
|
341
493
|
*/
|
|
342
494
|
async createFolder(projectId, parentFolderId, name) {
|
|
343
|
-
const response = await
|
|
495
|
+
const response = await this.httpRequest(this.folderUrl(projectId), {
|
|
344
496
|
method: 'POST',
|
|
345
497
|
headers: this.getHeaders(true),
|
|
346
498
|
body: JSON.stringify({
|
|
347
499
|
parent_folder_id: parentFolderId,
|
|
348
500
|
name
|
|
349
|
-
})
|
|
501
|
+
}),
|
|
502
|
+
expect: 'json'
|
|
350
503
|
});
|
|
351
504
|
if (response.status === 400) {
|
|
352
505
|
// Folder already exists
|
|
@@ -355,7 +508,8 @@ export class OverleafClient {
|
|
|
355
508
|
if (!response.ok) {
|
|
356
509
|
throw new Error(`Failed to create folder: ${response.status}`);
|
|
357
510
|
}
|
|
358
|
-
|
|
511
|
+
this.applySetCookieHeaders(response.headers['set-cookie']);
|
|
512
|
+
const data = response.body;
|
|
359
513
|
return data._id;
|
|
360
514
|
}
|
|
361
515
|
/**
|
|
@@ -454,16 +608,18 @@ export class OverleafClient {
|
|
|
454
608
|
let sid = null;
|
|
455
609
|
try {
|
|
456
610
|
const handshakeUrl = `${this.baseUrl}/socket.io/1/?projectId=${encodeURIComponent(projectId)}&t=${Date.now()}`;
|
|
457
|
-
const handshakeResponse = await this.
|
|
611
|
+
const handshakeResponse = await this.httpRequest(handshakeUrl, {
|
|
458
612
|
headers: {
|
|
459
613
|
'Cookie': this.getCookieHeader(),
|
|
460
614
|
'User-Agent': USER_AGENT
|
|
461
|
-
}
|
|
462
|
-
|
|
615
|
+
},
|
|
616
|
+
expect: 'text',
|
|
617
|
+
timeoutMs: 5000
|
|
618
|
+
});
|
|
463
619
|
if (!handshakeResponse.ok)
|
|
464
620
|
return null;
|
|
465
|
-
this.applySetCookieHeaders(handshakeResponse.headers);
|
|
466
|
-
const handshakeBody =
|
|
621
|
+
this.applySetCookieHeaders(handshakeResponse.headers['set-cookie']);
|
|
622
|
+
const handshakeBody = handshakeResponse.body.trim();
|
|
467
623
|
sid = handshakeBody.split(':')[0];
|
|
468
624
|
if (!sid)
|
|
469
625
|
return null;
|
|
@@ -471,16 +627,18 @@ export class OverleafClient {
|
|
|
471
627
|
let discoveredRootFolderId = null;
|
|
472
628
|
// poll a few frames, first is usually connect ack, next includes joinProjectResponse
|
|
473
629
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
474
|
-
const pollResponse = await this.
|
|
630
|
+
const pollResponse = await this.httpRequest(buildPollUrl(), {
|
|
475
631
|
headers: {
|
|
476
632
|
'Cookie': this.getCookieHeader(),
|
|
477
633
|
'User-Agent': USER_AGENT
|
|
478
|
-
}
|
|
479
|
-
|
|
634
|
+
},
|
|
635
|
+
expect: 'text',
|
|
636
|
+
timeoutMs: 5000
|
|
637
|
+
});
|
|
480
638
|
if (!pollResponse.ok)
|
|
481
639
|
return null;
|
|
482
|
-
this.applySetCookieHeaders(pollResponse.headers);
|
|
483
|
-
const payload =
|
|
640
|
+
this.applySetCookieHeaders(pollResponse.headers['set-cookie']);
|
|
641
|
+
const payload = pollResponse.body;
|
|
484
642
|
const packets = this.decodeSocketIoPayload(payload);
|
|
485
643
|
for (const packet of packets) {
|
|
486
644
|
const rootFolderId = this.extractRootFolderIdFromSocketPacket(packet);
|
|
@@ -490,16 +648,18 @@ export class OverleafClient {
|
|
|
490
648
|
}
|
|
491
649
|
if (packet.startsWith('2::')) {
|
|
492
650
|
//reply to heartbeat to keep polling transport alive
|
|
493
|
-
const heartbeatResponse = await this.
|
|
651
|
+
const heartbeatResponse = await this.httpRequest(buildPollUrl(), {
|
|
494
652
|
method: 'POST',
|
|
495
653
|
headers: {
|
|
496
654
|
'Cookie': this.getCookieHeader(),
|
|
497
655
|
'User-Agent': USER_AGENT,
|
|
498
656
|
'Content-Type': 'text/plain;charset=UTF-8'
|
|
499
657
|
},
|
|
500
|
-
body: '2::'
|
|
501
|
-
|
|
502
|
-
|
|
658
|
+
body: '2::',
|
|
659
|
+
expect: 'text',
|
|
660
|
+
timeoutMs: 5000
|
|
661
|
+
});
|
|
662
|
+
this.applySetCookieHeaders(heartbeatResponse.headers['set-cookie']);
|
|
503
663
|
}
|
|
504
664
|
}
|
|
505
665
|
if (discoveredRootFolderId) {
|
|
@@ -514,16 +674,18 @@ export class OverleafClient {
|
|
|
514
674
|
if (sid) {
|
|
515
675
|
try {
|
|
516
676
|
const disconnectUrl = `${this.baseUrl}/socket.io/1/xhr-polling/${sid}?projectId=${encodeURIComponent(projectId)}&t=${Date.now()}`;
|
|
517
|
-
const disconnectResponse = await this.
|
|
677
|
+
const disconnectResponse = await this.httpRequest(disconnectUrl, {
|
|
518
678
|
method: 'POST',
|
|
519
679
|
headers: {
|
|
520
680
|
'Cookie': this.getCookieHeader(),
|
|
521
681
|
'User-Agent': USER_AGENT,
|
|
522
682
|
'Content-Type': 'text/plain;charset=UTF-8'
|
|
523
683
|
},
|
|
524
|
-
body: '0::'
|
|
525
|
-
|
|
526
|
-
|
|
684
|
+
body: '0::',
|
|
685
|
+
expect: 'text',
|
|
686
|
+
timeoutMs: 5000
|
|
687
|
+
});
|
|
688
|
+
this.applySetCookieHeaders(disconnectResponse.headers['set-cookie']);
|
|
527
689
|
}
|
|
528
690
|
catch {
|
|
529
691
|
// Ignore cleanup failures.
|
|
@@ -540,47 +702,53 @@ export class OverleafClient {
|
|
|
540
702
|
let sid = null;
|
|
541
703
|
try {
|
|
542
704
|
const handshakeUrl = `${this.baseUrl}/socket.io/1/?projectId=${encodeURIComponent(projectId)}&t=${Date.now()}`;
|
|
543
|
-
const handshakeResponse = await this.
|
|
705
|
+
const handshakeResponse = await this.httpRequest(handshakeUrl, {
|
|
544
706
|
headers: {
|
|
545
707
|
'Cookie': this.getCookieHeader(),
|
|
546
708
|
'User-Agent': USER_AGENT
|
|
547
|
-
}
|
|
548
|
-
|
|
709
|
+
},
|
|
710
|
+
expect: 'text',
|
|
711
|
+
timeoutMs: 5000
|
|
712
|
+
});
|
|
549
713
|
if (!handshakeResponse.ok)
|
|
550
714
|
return null;
|
|
551
|
-
this.applySetCookieHeaders(handshakeResponse.headers);
|
|
552
|
-
const handshakeBody =
|
|
715
|
+
this.applySetCookieHeaders(handshakeResponse.headers['set-cookie']);
|
|
716
|
+
const handshakeBody = handshakeResponse.body.trim();
|
|
553
717
|
sid = handshakeBody.split(':')[0];
|
|
554
718
|
if (!sid)
|
|
555
719
|
return null;
|
|
556
720
|
const buildPollUrl = () => `${this.baseUrl}/socket.io/1/xhr-polling/${sid}?projectId=${encodeURIComponent(projectId)}&t=${Date.now()}`;
|
|
557
721
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
558
|
-
const pollResponse = await this.
|
|
722
|
+
const pollResponse = await this.httpRequest(buildPollUrl(), {
|
|
559
723
|
headers: {
|
|
560
724
|
'Cookie': this.getCookieHeader(),
|
|
561
725
|
'User-Agent': USER_AGENT
|
|
562
|
-
}
|
|
563
|
-
|
|
726
|
+
},
|
|
727
|
+
expect: 'text',
|
|
728
|
+
timeoutMs: 5000
|
|
729
|
+
});
|
|
564
730
|
if (!pollResponse.ok)
|
|
565
731
|
return null;
|
|
566
|
-
this.applySetCookieHeaders(pollResponse.headers);
|
|
567
|
-
const payload =
|
|
732
|
+
this.applySetCookieHeaders(pollResponse.headers['set-cookie']);
|
|
733
|
+
const payload = pollResponse.body;
|
|
568
734
|
const packets = this.decodeSocketIoPayload(payload);
|
|
569
735
|
for (const packet of packets) {
|
|
570
736
|
const folderTree = this.extractFolderTreeFromSocketPacket(packet);
|
|
571
737
|
if (folderTree)
|
|
572
738
|
return folderTree;
|
|
573
739
|
if (packet.startsWith('2::')) {
|
|
574
|
-
const heartbeatResponse = await this.
|
|
740
|
+
const heartbeatResponse = await this.httpRequest(buildPollUrl(), {
|
|
575
741
|
method: 'POST',
|
|
576
742
|
headers: {
|
|
577
743
|
'Cookie': this.getCookieHeader(),
|
|
578
744
|
'User-Agent': USER_AGENT,
|
|
579
745
|
'Content-Type': 'text/plain;charset=UTF-8'
|
|
580
746
|
},
|
|
581
|
-
body: '2::'
|
|
582
|
-
|
|
583
|
-
|
|
747
|
+
body: '2::',
|
|
748
|
+
expect: 'text',
|
|
749
|
+
timeoutMs: 5000
|
|
750
|
+
});
|
|
751
|
+
this.applySetCookieHeaders(heartbeatResponse.headers['set-cookie']);
|
|
584
752
|
}
|
|
585
753
|
}
|
|
586
754
|
}
|
|
@@ -592,15 +760,17 @@ export class OverleafClient {
|
|
|
592
760
|
if (sid) {
|
|
593
761
|
try {
|
|
594
762
|
const disconnectUrl = `${this.baseUrl}/socket.io/1/xhr-polling/${sid}?projectId=${encodeURIComponent(projectId)}&t=${Date.now()}`;
|
|
595
|
-
await this.
|
|
763
|
+
await this.httpRequest(disconnectUrl, {
|
|
596
764
|
method: 'POST',
|
|
597
765
|
headers: {
|
|
598
766
|
'Cookie': this.getCookieHeader(),
|
|
599
767
|
'User-Agent': USER_AGENT,
|
|
600
768
|
'Content-Type': 'text/plain;charset=UTF-8'
|
|
601
769
|
},
|
|
602
|
-
body: '0::'
|
|
603
|
-
|
|
770
|
+
body: '0::',
|
|
771
|
+
expect: 'text',
|
|
772
|
+
timeoutMs: 5000
|
|
773
|
+
});
|
|
604
774
|
}
|
|
605
775
|
catch {
|
|
606
776
|
// Ignore cleanup failures.
|
|
@@ -704,16 +874,21 @@ export class OverleafClient {
|
|
|
704
874
|
formData.append('name', testFileName);
|
|
705
875
|
formData.append('type', 'text/plain');
|
|
706
876
|
formData.append('qqfile', new Blob(['probe']), testFileName);
|
|
707
|
-
const response = await
|
|
877
|
+
const response = await this.httpRequest(`${this.uploadUrl(projectId)}?folder_id=${folderId}`, {
|
|
708
878
|
method: 'POST',
|
|
709
879
|
headers: {
|
|
710
880
|
'Cookie': this.getCookieHeader(),
|
|
711
881
|
'User-Agent': USER_AGENT,
|
|
712
882
|
'X-Csrf-Token': this.csrf
|
|
713
883
|
},
|
|
714
|
-
body: formData
|
|
884
|
+
body: formData,
|
|
885
|
+
expect: 'json'
|
|
715
886
|
});
|
|
716
|
-
|
|
887
|
+
if (!response.ok) {
|
|
888
|
+
continue;
|
|
889
|
+
}
|
|
890
|
+
this.applySetCookieHeaders(response.headers['set-cookie']);
|
|
891
|
+
const data = response.body;
|
|
717
892
|
if (data.success !== false && data.entity_id) {
|
|
718
893
|
// Success! Delete the probe file and return this folder ID
|
|
719
894
|
try {
|
|
@@ -771,17 +946,18 @@ export class OverleafClient {
|
|
|
771
946
|
formData.append('name', baseName);
|
|
772
947
|
formData.append('type', mimeType);
|
|
773
948
|
formData.append('qqfile', new Blob([content]), baseName);
|
|
774
|
-
const response = await
|
|
949
|
+
const response = await this.httpRequest(`${this.uploadUrl(projectId)}?folder_id=${encodeURIComponent(fid)}`, {
|
|
775
950
|
method: 'POST',
|
|
776
951
|
headers: {
|
|
777
952
|
'Cookie': this.getCookieHeader(),
|
|
778
953
|
'User-Agent': USER_AGENT,
|
|
779
954
|
'X-Csrf-Token': this.csrf
|
|
780
955
|
},
|
|
781
|
-
body: formData
|
|
956
|
+
body: formData,
|
|
957
|
+
expect: 'text'
|
|
782
958
|
});
|
|
783
959
|
if (!response.ok) {
|
|
784
|
-
const text =
|
|
960
|
+
const text = response.body;
|
|
785
961
|
// Overleaf returns folder_not_found as HTTP 422 JSON.
|
|
786
962
|
// Parse the body first so caller can trigger folder probing fallback.
|
|
787
963
|
try {
|
|
@@ -795,7 +971,8 @@ export class OverleafClient {
|
|
|
795
971
|
}
|
|
796
972
|
return { success: false, error: `${response.status} - ${text}` };
|
|
797
973
|
}
|
|
798
|
-
|
|
974
|
+
this.applySetCookieHeaders(response.headers['set-cookie']);
|
|
975
|
+
const data = JSON.parse(response.body);
|
|
799
976
|
if (data.success === false && data.error === 'folder_not_found') {
|
|
800
977
|
return { success: false, error: 'folder_not_found' };
|
|
801
978
|
}
|
|
@@ -837,25 +1014,29 @@ export class OverleafClient {
|
|
|
837
1014
|
*/
|
|
838
1015
|
async deleteEntity(projectId, entityId, entityType) {
|
|
839
1016
|
const url = this.deleteUrl(projectId, entityType, entityId);
|
|
840
|
-
const response = await
|
|
1017
|
+
const response = await this.httpRequest(url, {
|
|
841
1018
|
method: 'DELETE',
|
|
842
|
-
headers: this.getHeaders()
|
|
1019
|
+
headers: this.getHeaders(),
|
|
1020
|
+
expect: 'text'
|
|
843
1021
|
});
|
|
844
1022
|
if (!response.ok) {
|
|
845
1023
|
throw new Error(`Failed to delete entity: ${response.status}`);
|
|
846
1024
|
}
|
|
1025
|
+
this.applySetCookieHeaders(response.headers['set-cookie']);
|
|
847
1026
|
}
|
|
848
1027
|
/**
|
|
849
1028
|
* Get list of entities (files/docs) with paths
|
|
850
1029
|
*/
|
|
851
1030
|
async getEntities(projectId) {
|
|
852
|
-
const response = await
|
|
853
|
-
headers: this.getHeaders()
|
|
1031
|
+
const response = await this.httpRequest(`${this.baseUrl}/project/${projectId}/entities`, {
|
|
1032
|
+
headers: this.getHeaders(),
|
|
1033
|
+
expect: 'json'
|
|
854
1034
|
});
|
|
855
1035
|
if (!response.ok) {
|
|
856
1036
|
throw new Error(`Failed to get entities: ${response.status}`);
|
|
857
1037
|
}
|
|
858
|
-
|
|
1038
|
+
this.applySetCookieHeaders(response.headers['set-cookie']);
|
|
1039
|
+
const data = response.body;
|
|
859
1040
|
return data.entities || [];
|
|
860
1041
|
}
|
|
861
1042
|
/**
|
|
@@ -901,35 +1082,38 @@ export class OverleafClient {
|
|
|
901
1082
|
*/
|
|
902
1083
|
async downloadFile(projectId, fileId, fileType) {
|
|
903
1084
|
const endpoint = fileType === 'doc' ? 'doc' : 'file';
|
|
904
|
-
const response = await
|
|
905
|
-
headers: this.getHeaders()
|
|
1085
|
+
const response = await this.httpRequest(`${this.baseUrl}/project/${projectId}/${endpoint}/${fileId}`, {
|
|
1086
|
+
headers: this.getHeaders(),
|
|
1087
|
+
expect: fileType === 'doc' ? 'json' : 'buffer'
|
|
906
1088
|
});
|
|
907
1089
|
if (!response.ok) {
|
|
908
1090
|
throw new Error(`Failed to download file: ${response.status}`);
|
|
909
1091
|
}
|
|
1092
|
+
this.applySetCookieHeaders(response.headers['set-cookie']);
|
|
910
1093
|
if (fileType === 'doc') {
|
|
911
1094
|
// Docs return JSON with lines array
|
|
912
|
-
const data =
|
|
1095
|
+
const data = response.body;
|
|
913
1096
|
const content = (data.lines || []).join('\n');
|
|
914
1097
|
return Buffer.from(content, 'utf-8');
|
|
915
1098
|
}
|
|
916
1099
|
else {
|
|
917
|
-
|
|
918
|
-
return Buffer.from(arrayBuffer);
|
|
1100
|
+
return response.body;
|
|
919
1101
|
}
|
|
920
1102
|
}
|
|
921
1103
|
/**
|
|
922
1104
|
* Rename a file, doc, or folder
|
|
923
1105
|
*/
|
|
924
1106
|
async renameEntity(projectId, entityId, entityType, newName) {
|
|
925
|
-
const response = await
|
|
1107
|
+
const response = await this.httpRequest(`${this.baseUrl}/project/${projectId}/${entityType}/${entityId}/rename`, {
|
|
926
1108
|
method: 'POST',
|
|
927
1109
|
headers: this.getHeaders(true),
|
|
928
|
-
body: JSON.stringify({ name: newName })
|
|
1110
|
+
body: JSON.stringify({ name: newName }),
|
|
1111
|
+
expect: 'text'
|
|
929
1112
|
});
|
|
930
1113
|
if (!response.ok) {
|
|
931
1114
|
throw new Error(`Failed to rename entity: ${response.status}`);
|
|
932
1115
|
}
|
|
1116
|
+
this.applySetCookieHeaders(response.headers['set-cookie']);
|
|
933
1117
|
}
|
|
934
1118
|
/**
|
|
935
1119
|
* Delete a file by path
|
|
@@ -988,7 +1172,7 @@ export class OverleafClient {
|
|
|
988
1172
|
* Compile project and get all output files
|
|
989
1173
|
*/
|
|
990
1174
|
async compileWithOutputs(projectId) {
|
|
991
|
-
const response = await
|
|
1175
|
+
const response = await this.httpRequest(this.compileUrl(projectId), {
|
|
992
1176
|
method: 'POST',
|
|
993
1177
|
headers: this.getHeaders(true),
|
|
994
1178
|
body: JSON.stringify({
|
|
@@ -996,12 +1180,14 @@ export class OverleafClient {
|
|
|
996
1180
|
draft: false,
|
|
997
1181
|
check: 'silent',
|
|
998
1182
|
incrementalCompilesEnabled: true
|
|
999
|
-
})
|
|
1183
|
+
}),
|
|
1184
|
+
expect: 'json'
|
|
1000
1185
|
});
|
|
1001
1186
|
if (!response.ok) {
|
|
1002
1187
|
throw new Error(`Failed to compile project: ${response.status}`);
|
|
1003
1188
|
}
|
|
1004
|
-
|
|
1189
|
+
this.applySetCookieHeaders(response.headers['set-cookie']);
|
|
1190
|
+
const data = response.body;
|
|
1005
1191
|
const pdfFile = data.outputFiles?.find((f) => f.type === 'pdf');
|
|
1006
1192
|
return {
|
|
1007
1193
|
status: data.status,
|