@aloth/olcli 0.1.6 → 0.1.8
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 +49 -0
- package/dist/cli.js +58 -8
- package/dist/cli.js.map +1 -1
- package/dist/client.d.ts +32 -5
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +385 -141
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts +4 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +18 -3
- package/dist/config.js.map +1 -1
- package/package.json +1 -1
package/dist/client.js
CHANGED
|
@@ -8,50 +8,62 @@ 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'));
|
|
14
16
|
const USER_AGENT = `olcli/${pkg.version}`;
|
|
15
|
-
const
|
|
16
|
-
const PROJECT_URL = `${BASE_URL}/project`;
|
|
17
|
-
const DOWNLOAD_URL = `${BASE_URL}/project/{id}/download/zip`;
|
|
18
|
-
const UPLOAD_URL = `${BASE_URL}/project/{id}/upload`;
|
|
19
|
-
const FOLDER_URL = `${BASE_URL}/project/{id}/folder`;
|
|
20
|
-
const DELETE_URL = `${BASE_URL}/project/{projectId}/{type}/{entityId}`;
|
|
21
|
-
const COMPILE_URL = `${BASE_URL}/project/{id}/compile?enable_pdf_caching=true`;
|
|
17
|
+
const DEFAULT_BASE_URL = 'https://www.overleaf.com';
|
|
22
18
|
export class OverleafClient {
|
|
23
19
|
cookies;
|
|
24
20
|
csrf;
|
|
21
|
+
baseUrl;
|
|
25
22
|
constructor(credentials) {
|
|
26
23
|
this.cookies = credentials.cookies;
|
|
27
24
|
this.csrf = credentials.csrf;
|
|
25
|
+
this.baseUrl = credentials.baseUrl || DEFAULT_BASE_URL;
|
|
26
|
+
}
|
|
27
|
+
projectUrl() {
|
|
28
|
+
return `${this.baseUrl}/project`;
|
|
29
|
+
}
|
|
30
|
+
downloadUrl(projectId) {
|
|
31
|
+
return `${this.baseUrl}/project/${projectId}/download/zip`;
|
|
32
|
+
}
|
|
33
|
+
uploadUrl(projectId) {
|
|
34
|
+
return `${this.baseUrl}/project/${projectId}/upload`;
|
|
35
|
+
}
|
|
36
|
+
folderUrl(projectId) {
|
|
37
|
+
return `${this.baseUrl}/project/${projectId}/folder`;
|
|
38
|
+
}
|
|
39
|
+
deleteUrl(projectId, entityType, entityId) {
|
|
40
|
+
return `${this.baseUrl}/project/${projectId}/${entityType}/${entityId}`;
|
|
41
|
+
}
|
|
42
|
+
compileUrl(projectId) {
|
|
43
|
+
return `${this.baseUrl}/project/${projectId}/compile?enable_pdf_caching=true`;
|
|
28
44
|
}
|
|
29
45
|
/**
|
|
30
46
|
* Create client from session cookie string
|
|
31
47
|
*/
|
|
32
|
-
static async fromSessionCookie(sessionCookie) {
|
|
48
|
+
static async fromSessionCookie(sessionCookie, baseUrl = DEFAULT_BASE_URL, cookieName = 'overleaf_session2') {
|
|
33
49
|
const cookies = {
|
|
34
|
-
|
|
50
|
+
[cookieName]: sessionCookie
|
|
35
51
|
};
|
|
36
52
|
// Fetch CSRF token from project page
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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'
|
|
42
61
|
});
|
|
43
62
|
if (!response.ok) {
|
|
44
|
-
throw new Error(`Failed to fetch projects page: ${response.status}
|
|
45
|
-
}
|
|
46
|
-
// Capture any new cookies from response
|
|
47
|
-
const setCookieHeaders = response.headers.getSetCookie?.() || [];
|
|
48
|
-
for (const setCookie of setCookieHeaders) {
|
|
49
|
-
const match = setCookie.match(/^([^=]+)=([^;]+)/);
|
|
50
|
-
if (match) {
|
|
51
|
-
cookies[match[1]] = match[2];
|
|
52
|
-
}
|
|
63
|
+
throw new Error(`Failed to fetch projects page: ${response.status}`);
|
|
53
64
|
}
|
|
54
|
-
|
|
65
|
+
bootstrapClient.applySetCookieHeaders(response.headers['set-cookie']);
|
|
66
|
+
const html = response.body;
|
|
55
67
|
const $ = cheerio.load(html);
|
|
56
68
|
// Try multiple methods to find CSRF token (based on PR #66, #82)
|
|
57
69
|
let csrf;
|
|
@@ -76,30 +88,13 @@ export class OverleafClient {
|
|
|
76
88
|
if (!csrf) {
|
|
77
89
|
throw new Error('Could not find CSRF token. Session may have expired.');
|
|
78
90
|
}
|
|
79
|
-
|
|
91
|
+
// Update cookies if the bootstrap request added anything
|
|
92
|
+
const updatedCookies = bootstrapClient.cookies;
|
|
93
|
+
return new OverleafClient({ cookies: updatedCookies, csrf, baseUrl });
|
|
80
94
|
}
|
|
81
95
|
getCookieHeader() {
|
|
82
96
|
return Object.entries(this.cookies).map(([k, v]) => `${k}=${v}`).join('; ');
|
|
83
97
|
}
|
|
84
|
-
applySetCookieHeaders(headers) {
|
|
85
|
-
const setCookieHeaders = headers.getSetCookie?.() || [];
|
|
86
|
-
for (const setCookie of setCookieHeaders) {
|
|
87
|
-
const match = setCookie.match(/^([^=]+)=([^;]+)/);
|
|
88
|
-
if (match) {
|
|
89
|
-
this.cookies[match[1]] = match[2];
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
async fetchWithTimeout(url, init, timeoutMs) {
|
|
94
|
-
const controller = new AbortController();
|
|
95
|
-
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
96
|
-
try {
|
|
97
|
-
return await fetch(url, { ...init, signal: controller.signal });
|
|
98
|
-
}
|
|
99
|
-
finally {
|
|
100
|
-
clearTimeout(timeout);
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
98
|
getHeaders(includeContentType = false) {
|
|
104
99
|
const headers = {
|
|
105
100
|
'Cookie': this.getCookieHeader(),
|
|
@@ -111,17 +106,93 @@ export class OverleafClient {
|
|
|
111
106
|
}
|
|
112
107
|
return headers;
|
|
113
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
|
+
const doRequest = (reqUrl, redirectsLeft) => {
|
|
136
|
+
return new Promise((resolve, reject) => {
|
|
137
|
+
const parsedUrl = new URL(reqUrl);
|
|
138
|
+
const transport = parsedUrl.protocol === 'https:' ? https : http;
|
|
139
|
+
const headers = this.normalizeHeaders(options.headers);
|
|
140
|
+
const req = transport.request(reqUrl, { method, headers }, (res) => {
|
|
141
|
+
const status = res.statusCode || 0;
|
|
142
|
+
const resHeaders = res.headers;
|
|
143
|
+
if (status >= 300 && status < 400 && res.headers.location && redirectsLeft > 0) {
|
|
144
|
+
const redirectUrl = new URL(res.headers.location, reqUrl).toString();
|
|
145
|
+
res.resume();
|
|
146
|
+
doRequest(redirectUrl, redirectsLeft - 1).then(resolve, reject);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const chunks = [];
|
|
150
|
+
res.on('data', (chunk) => chunks.push(chunk));
|
|
151
|
+
res.on('end', () => {
|
|
152
|
+
const buffer = Buffer.concat(chunks);
|
|
153
|
+
let body = buffer;
|
|
154
|
+
if (expect === 'text') {
|
|
155
|
+
body = buffer.toString('utf-8');
|
|
156
|
+
}
|
|
157
|
+
else if (expect === 'json') {
|
|
158
|
+
try {
|
|
159
|
+
body = JSON.parse(buffer.toString('utf-8'));
|
|
160
|
+
}
|
|
161
|
+
catch (e) {
|
|
162
|
+
return reject(new Error(`Failed to parse JSON response from ${reqUrl}`));
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
resolve({ status, ok: status >= 200 && status < 300, headers: resHeaders, body });
|
|
166
|
+
});
|
|
167
|
+
res.on('error', reject);
|
|
168
|
+
});
|
|
169
|
+
req.on('error', reject);
|
|
170
|
+
if (timeoutMs) {
|
|
171
|
+
req.setTimeout(timeoutMs, () => {
|
|
172
|
+
req.destroy(new Error(`Request timeout after ${timeoutMs}ms`));
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
if (options.body) {
|
|
176
|
+
req.write(options.body);
|
|
177
|
+
}
|
|
178
|
+
req.end();
|
|
179
|
+
});
|
|
180
|
+
};
|
|
181
|
+
return doRequest(url, maxRedirects);
|
|
182
|
+
}
|
|
114
183
|
/**
|
|
115
184
|
* Get all projects (not archived, not trashed)
|
|
116
185
|
*/
|
|
117
186
|
async listProjects() {
|
|
118
|
-
const response = await
|
|
119
|
-
headers: this.getHeaders()
|
|
187
|
+
const response = await this.httpRequest(this.projectUrl(), {
|
|
188
|
+
headers: this.getHeaders(),
|
|
189
|
+
expect: 'text'
|
|
120
190
|
});
|
|
121
191
|
if (!response.ok) {
|
|
122
192
|
throw new Error(`Failed to fetch projects: ${response.status}`);
|
|
123
193
|
}
|
|
124
|
-
|
|
194
|
+
this.applySetCookieHeaders(response.headers['set-cookie']);
|
|
195
|
+
const html = response.body;
|
|
125
196
|
const $ = cheerio.load(html);
|
|
126
197
|
// Try new Overleaf structure first (PR #82)
|
|
127
198
|
let projectsData = [];
|
|
@@ -198,13 +269,15 @@ export class OverleafClient {
|
|
|
198
269
|
* Get detailed project info including file tree
|
|
199
270
|
*/
|
|
200
271
|
async getProjectInfo(projectId) {
|
|
201
|
-
const response = await
|
|
202
|
-
headers: this.getHeaders()
|
|
272
|
+
const response = await this.httpRequest(`${this.projectUrl()}/${projectId}`, {
|
|
273
|
+
headers: this.getHeaders(),
|
|
274
|
+
expect: 'text'
|
|
203
275
|
});
|
|
204
276
|
if (!response.ok) {
|
|
205
277
|
throw new Error(`Failed to fetch project info: ${response.status}`);
|
|
206
278
|
}
|
|
207
|
-
|
|
279
|
+
this.applySetCookieHeaders(response.headers['set-cookie']);
|
|
280
|
+
const html = response.body;
|
|
208
281
|
const $ = cheerio.load(html);
|
|
209
282
|
// Look for project data in meta tags
|
|
210
283
|
let projectInfo;
|
|
@@ -247,34 +320,15 @@ export class OverleafClient {
|
|
|
247
320
|
* project names). See: https://github.com/aloth/olcli/issues/2
|
|
248
321
|
*/
|
|
249
322
|
async downloadBuffer(url) {
|
|
250
|
-
const
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
// Follow redirects
|
|
260
|
-
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
261
|
-
const redirectUrl = new URL(res.headers.location, reqUrl).toString();
|
|
262
|
-
doRequest(redirectUrl).then(resolve, reject);
|
|
263
|
-
return;
|
|
264
|
-
}
|
|
265
|
-
if (res.statusCode && res.statusCode >= 400) {
|
|
266
|
-
reject(new Error(`Download failed: ${res.statusCode}`));
|
|
267
|
-
return;
|
|
268
|
-
}
|
|
269
|
-
const chunks = [];
|
|
270
|
-
res.on('data', (chunk) => chunks.push(chunk));
|
|
271
|
-
res.on('end', () => resolve(Buffer.concat(chunks)));
|
|
272
|
-
res.on('error', reject);
|
|
273
|
-
});
|
|
274
|
-
req.on('error', reject);
|
|
275
|
-
});
|
|
276
|
-
};
|
|
277
|
-
return doRequest(url);
|
|
323
|
+
const response = await this.httpRequest(url, {
|
|
324
|
+
headers: this.getHeaders(),
|
|
325
|
+
expect: 'buffer'
|
|
326
|
+
});
|
|
327
|
+
if (!response.ok) {
|
|
328
|
+
throw new Error(`Download failed: ${response.status}`);
|
|
329
|
+
}
|
|
330
|
+
this.applySetCookieHeaders(response.headers['set-cookie']);
|
|
331
|
+
return response.body;
|
|
278
332
|
}
|
|
279
333
|
/**
|
|
280
334
|
* Download project as zip
|
|
@@ -283,14 +337,13 @@ export class OverleafClient {
|
|
|
283
337
|
* Content-Disposition headers. See: https://github.com/aloth/olcli/issues/2
|
|
284
338
|
*/
|
|
285
339
|
async downloadProject(projectId) {
|
|
286
|
-
|
|
287
|
-
return this.downloadBuffer(url);
|
|
340
|
+
return this.downloadBuffer(this.downloadUrl(projectId));
|
|
288
341
|
}
|
|
289
342
|
/**
|
|
290
343
|
* Compile project and get PDF
|
|
291
344
|
*/
|
|
292
345
|
async compileProject(projectId) {
|
|
293
|
-
const response = await
|
|
346
|
+
const response = await this.httpRequest(this.compileUrl(projectId), {
|
|
294
347
|
method: 'POST',
|
|
295
348
|
headers: this.getHeaders(true),
|
|
296
349
|
body: JSON.stringify({
|
|
@@ -298,12 +351,14 @@ export class OverleafClient {
|
|
|
298
351
|
draft: false,
|
|
299
352
|
check: 'silent',
|
|
300
353
|
incrementalCompilesEnabled: true
|
|
301
|
-
})
|
|
354
|
+
}),
|
|
355
|
+
expect: 'json'
|
|
302
356
|
});
|
|
303
357
|
if (!response.ok) {
|
|
304
358
|
throw new Error(`Failed to compile project: ${response.status}`);
|
|
305
359
|
}
|
|
306
|
-
|
|
360
|
+
this.applySetCookieHeaders(response.headers['set-cookie']);
|
|
361
|
+
const data = response.body;
|
|
307
362
|
if (data.status !== 'success') {
|
|
308
363
|
throw new Error(`Compilation failed: ${data.status}`);
|
|
309
364
|
}
|
|
@@ -312,7 +367,7 @@ export class OverleafClient {
|
|
|
312
367
|
throw new Error('No PDF output found');
|
|
313
368
|
}
|
|
314
369
|
return {
|
|
315
|
-
pdfUrl: `${
|
|
370
|
+
pdfUrl: `${this.baseUrl}${pdfFile.url}`,
|
|
316
371
|
logs: data.compileGroup ? [`Compile group: ${data.compileGroup}`] : []
|
|
317
372
|
};
|
|
318
373
|
}
|
|
@@ -327,13 +382,14 @@ export class OverleafClient {
|
|
|
327
382
|
* Create a folder in a project
|
|
328
383
|
*/
|
|
329
384
|
async createFolder(projectId, parentFolderId, name) {
|
|
330
|
-
const response = await
|
|
385
|
+
const response = await this.httpRequest(this.folderUrl(projectId), {
|
|
331
386
|
method: 'POST',
|
|
332
387
|
headers: this.getHeaders(true),
|
|
333
388
|
body: JSON.stringify({
|
|
334
389
|
parent_folder_id: parentFolderId,
|
|
335
390
|
name
|
|
336
|
-
})
|
|
391
|
+
}),
|
|
392
|
+
expect: 'json'
|
|
337
393
|
});
|
|
338
394
|
if (response.status === 400) {
|
|
339
395
|
// Folder already exists
|
|
@@ -342,7 +398,8 @@ export class OverleafClient {
|
|
|
342
398
|
if (!response.ok) {
|
|
343
399
|
throw new Error(`Failed to create folder: ${response.status}`);
|
|
344
400
|
}
|
|
345
|
-
|
|
401
|
+
this.applySetCookieHeaders(response.headers['set-cookie']);
|
|
402
|
+
const data = response.body;
|
|
346
403
|
return data._id;
|
|
347
404
|
}
|
|
348
405
|
/**
|
|
@@ -404,6 +461,35 @@ export class OverleafClient {
|
|
|
404
461
|
return null;
|
|
405
462
|
}
|
|
406
463
|
}
|
|
464
|
+
/**
|
|
465
|
+
* Extract full folder tree from a Socket.IO joinProjectResponse packet.
|
|
466
|
+
* Returns a map of folder path -> folder ID, e.g. { '': rootId, 'figures': figuresId }
|
|
467
|
+
*/
|
|
468
|
+
extractFolderTreeFromSocketPacket(packet) {
|
|
469
|
+
if (!packet.startsWith('5:::'))
|
|
470
|
+
return null;
|
|
471
|
+
try {
|
|
472
|
+
const payload = JSON.parse(packet.slice(4));
|
|
473
|
+
if (payload?.name !== 'joinProjectResponse')
|
|
474
|
+
return null;
|
|
475
|
+
const rootFolder = payload?.args?.[0]?.project?.rootFolder?.[0];
|
|
476
|
+
if (!rootFolder?._id)
|
|
477
|
+
return null;
|
|
478
|
+
const folderMap = {};
|
|
479
|
+
function walkFolders(folder, currentPath) {
|
|
480
|
+
folderMap[currentPath] = folder._id;
|
|
481
|
+
for (const sub of folder.folders || []) {
|
|
482
|
+
const subPath = currentPath ? `${currentPath}/${sub.name}` : sub.name;
|
|
483
|
+
walkFolders(sub, subPath);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
walkFolders(rootFolder, '');
|
|
487
|
+
return folderMap;
|
|
488
|
+
}
|
|
489
|
+
catch {
|
|
490
|
+
return null;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
407
493
|
/**
|
|
408
494
|
* main problem to resolve root folder ID from Overleaf's collaboration join payload
|
|
409
495
|
* authoritative for projects where ObjectID arithmetic does not apply
|
|
@@ -411,34 +497,38 @@ export class OverleafClient {
|
|
|
411
497
|
async getRootFolderIdFromSocket(projectId) {
|
|
412
498
|
let sid = null;
|
|
413
499
|
try {
|
|
414
|
-
const handshakeUrl = `${
|
|
415
|
-
const handshakeResponse = await this.
|
|
500
|
+
const handshakeUrl = `${this.baseUrl}/socket.io/1/?projectId=${encodeURIComponent(projectId)}&t=${Date.now()}`;
|
|
501
|
+
const handshakeResponse = await this.httpRequest(handshakeUrl, {
|
|
416
502
|
headers: {
|
|
417
503
|
'Cookie': this.getCookieHeader(),
|
|
418
504
|
'User-Agent': USER_AGENT
|
|
419
|
-
}
|
|
420
|
-
|
|
505
|
+
},
|
|
506
|
+
expect: 'text',
|
|
507
|
+
timeoutMs: 5000
|
|
508
|
+
});
|
|
421
509
|
if (!handshakeResponse.ok)
|
|
422
510
|
return null;
|
|
423
|
-
this.applySetCookieHeaders(handshakeResponse.headers);
|
|
424
|
-
const handshakeBody =
|
|
511
|
+
this.applySetCookieHeaders(handshakeResponse.headers['set-cookie']);
|
|
512
|
+
const handshakeBody = handshakeResponse.body.trim();
|
|
425
513
|
sid = handshakeBody.split(':')[0];
|
|
426
514
|
if (!sid)
|
|
427
515
|
return null;
|
|
428
|
-
const buildPollUrl = () => `${
|
|
516
|
+
const buildPollUrl = () => `${this.baseUrl}/socket.io/1/xhr-polling/${sid}?projectId=${encodeURIComponent(projectId)}&t=${Date.now()}`;
|
|
429
517
|
let discoveredRootFolderId = null;
|
|
430
518
|
// poll a few frames, first is usually connect ack, next includes joinProjectResponse
|
|
431
519
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
432
|
-
const pollResponse = await this.
|
|
520
|
+
const pollResponse = await this.httpRequest(buildPollUrl(), {
|
|
433
521
|
headers: {
|
|
434
522
|
'Cookie': this.getCookieHeader(),
|
|
435
523
|
'User-Agent': USER_AGENT
|
|
436
|
-
}
|
|
437
|
-
|
|
524
|
+
},
|
|
525
|
+
expect: 'text',
|
|
526
|
+
timeoutMs: 5000
|
|
527
|
+
});
|
|
438
528
|
if (!pollResponse.ok)
|
|
439
529
|
return null;
|
|
440
|
-
this.applySetCookieHeaders(pollResponse.headers);
|
|
441
|
-
const payload =
|
|
530
|
+
this.applySetCookieHeaders(pollResponse.headers['set-cookie']);
|
|
531
|
+
const payload = pollResponse.body;
|
|
442
532
|
const packets = this.decodeSocketIoPayload(payload);
|
|
443
533
|
for (const packet of packets) {
|
|
444
534
|
const rootFolderId = this.extractRootFolderIdFromSocketPacket(packet);
|
|
@@ -448,16 +538,18 @@ export class OverleafClient {
|
|
|
448
538
|
}
|
|
449
539
|
if (packet.startsWith('2::')) {
|
|
450
540
|
//reply to heartbeat to keep polling transport alive
|
|
451
|
-
const heartbeatResponse = await this.
|
|
541
|
+
const heartbeatResponse = await this.httpRequest(buildPollUrl(), {
|
|
452
542
|
method: 'POST',
|
|
453
543
|
headers: {
|
|
454
544
|
'Cookie': this.getCookieHeader(),
|
|
455
545
|
'User-Agent': USER_AGENT,
|
|
456
546
|
'Content-Type': 'text/plain;charset=UTF-8'
|
|
457
547
|
},
|
|
458
|
-
body: '2::'
|
|
459
|
-
|
|
460
|
-
|
|
548
|
+
body: '2::',
|
|
549
|
+
expect: 'text',
|
|
550
|
+
timeoutMs: 5000
|
|
551
|
+
});
|
|
552
|
+
this.applySetCookieHeaders(heartbeatResponse.headers['set-cookie']);
|
|
461
553
|
}
|
|
462
554
|
}
|
|
463
555
|
if (discoveredRootFolderId) {
|
|
@@ -471,17 +563,19 @@ export class OverleafClient {
|
|
|
471
563
|
finally {
|
|
472
564
|
if (sid) {
|
|
473
565
|
try {
|
|
474
|
-
const disconnectUrl = `${
|
|
475
|
-
const disconnectResponse = await this.
|
|
566
|
+
const disconnectUrl = `${this.baseUrl}/socket.io/1/xhr-polling/${sid}?projectId=${encodeURIComponent(projectId)}&t=${Date.now()}`;
|
|
567
|
+
const disconnectResponse = await this.httpRequest(disconnectUrl, {
|
|
476
568
|
method: 'POST',
|
|
477
569
|
headers: {
|
|
478
570
|
'Cookie': this.getCookieHeader(),
|
|
479
571
|
'User-Agent': USER_AGENT,
|
|
480
572
|
'Content-Type': 'text/plain;charset=UTF-8'
|
|
481
573
|
},
|
|
482
|
-
body: '0::'
|
|
483
|
-
|
|
484
|
-
|
|
574
|
+
body: '0::',
|
|
575
|
+
expect: 'text',
|
|
576
|
+
timeoutMs: 5000
|
|
577
|
+
});
|
|
578
|
+
this.applySetCookieHeaders(disconnectResponse.headers['set-cookie']);
|
|
485
579
|
}
|
|
486
580
|
catch {
|
|
487
581
|
// Ignore cleanup failures.
|
|
@@ -490,6 +584,134 @@ export class OverleafClient {
|
|
|
490
584
|
}
|
|
491
585
|
return null;
|
|
492
586
|
}
|
|
587
|
+
/**
|
|
588
|
+
* Get full folder tree for a project via Socket.IO.
|
|
589
|
+
* Returns a map of folder path -> folder ID, e.g. { '': rootId, 'figures': figuresId }
|
|
590
|
+
*/
|
|
591
|
+
async getFolderTreeFromSocket(projectId) {
|
|
592
|
+
let sid = null;
|
|
593
|
+
try {
|
|
594
|
+
const handshakeUrl = `${this.baseUrl}/socket.io/1/?projectId=${encodeURIComponent(projectId)}&t=${Date.now()}`;
|
|
595
|
+
const handshakeResponse = await this.httpRequest(handshakeUrl, {
|
|
596
|
+
headers: {
|
|
597
|
+
'Cookie': this.getCookieHeader(),
|
|
598
|
+
'User-Agent': USER_AGENT
|
|
599
|
+
},
|
|
600
|
+
expect: 'text',
|
|
601
|
+
timeoutMs: 5000
|
|
602
|
+
});
|
|
603
|
+
if (!handshakeResponse.ok)
|
|
604
|
+
return null;
|
|
605
|
+
this.applySetCookieHeaders(handshakeResponse.headers['set-cookie']);
|
|
606
|
+
const handshakeBody = handshakeResponse.body.trim();
|
|
607
|
+
sid = handshakeBody.split(':')[0];
|
|
608
|
+
if (!sid)
|
|
609
|
+
return null;
|
|
610
|
+
const buildPollUrl = () => `${this.baseUrl}/socket.io/1/xhr-polling/${sid}?projectId=${encodeURIComponent(projectId)}&t=${Date.now()}`;
|
|
611
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
612
|
+
const pollResponse = await this.httpRequest(buildPollUrl(), {
|
|
613
|
+
headers: {
|
|
614
|
+
'Cookie': this.getCookieHeader(),
|
|
615
|
+
'User-Agent': USER_AGENT
|
|
616
|
+
},
|
|
617
|
+
expect: 'text',
|
|
618
|
+
timeoutMs: 5000
|
|
619
|
+
});
|
|
620
|
+
if (!pollResponse.ok)
|
|
621
|
+
return null;
|
|
622
|
+
this.applySetCookieHeaders(pollResponse.headers['set-cookie']);
|
|
623
|
+
const payload = pollResponse.body;
|
|
624
|
+
const packets = this.decodeSocketIoPayload(payload);
|
|
625
|
+
for (const packet of packets) {
|
|
626
|
+
const folderTree = this.extractFolderTreeFromSocketPacket(packet);
|
|
627
|
+
if (folderTree)
|
|
628
|
+
return folderTree;
|
|
629
|
+
if (packet.startsWith('2::')) {
|
|
630
|
+
const heartbeatResponse = await this.httpRequest(buildPollUrl(), {
|
|
631
|
+
method: 'POST',
|
|
632
|
+
headers: {
|
|
633
|
+
'Cookie': this.getCookieHeader(),
|
|
634
|
+
'User-Agent': USER_AGENT,
|
|
635
|
+
'Content-Type': 'text/plain;charset=UTF-8'
|
|
636
|
+
},
|
|
637
|
+
body: '2::',
|
|
638
|
+
expect: 'text',
|
|
639
|
+
timeoutMs: 5000
|
|
640
|
+
});
|
|
641
|
+
this.applySetCookieHeaders(heartbeatResponse.headers['set-cookie']);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
catch {
|
|
647
|
+
// Fall back
|
|
648
|
+
}
|
|
649
|
+
finally {
|
|
650
|
+
if (sid) {
|
|
651
|
+
try {
|
|
652
|
+
const disconnectUrl = `${this.baseUrl}/socket.io/1/xhr-polling/${sid}?projectId=${encodeURIComponent(projectId)}&t=${Date.now()}`;
|
|
653
|
+
await this.httpRequest(disconnectUrl, {
|
|
654
|
+
method: 'POST',
|
|
655
|
+
headers: {
|
|
656
|
+
'Cookie': this.getCookieHeader(),
|
|
657
|
+
'User-Agent': USER_AGENT,
|
|
658
|
+
'Content-Type': 'text/plain;charset=UTF-8'
|
|
659
|
+
},
|
|
660
|
+
body: '0::',
|
|
661
|
+
expect: 'text',
|
|
662
|
+
timeoutMs: 5000
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
catch {
|
|
666
|
+
// Ignore cleanup failures.
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
return null;
|
|
671
|
+
}
|
|
672
|
+
/**
|
|
673
|
+
* Resolve a folder path to a folder ID, creating missing folders as needed.
|
|
674
|
+
* folderTree is a map of path -> ID (fetched once per push session).
|
|
675
|
+
* folderPath is e.g. 'figures' or 'a/b/c'.
|
|
676
|
+
*/
|
|
677
|
+
async resolveFolderId(projectId, folderTree, folderPath) {
|
|
678
|
+
if (!folderPath || folderPath === '')
|
|
679
|
+
return folderTree[''];
|
|
680
|
+
if (folderTree[folderPath])
|
|
681
|
+
return folderTree[folderPath];
|
|
682
|
+
// Create each missing segment
|
|
683
|
+
const segments = folderPath.split('/');
|
|
684
|
+
let currentPath = '';
|
|
685
|
+
for (const segment of segments) {
|
|
686
|
+
const parentPath = currentPath;
|
|
687
|
+
currentPath = currentPath ? `${currentPath}/${segment}` : segment;
|
|
688
|
+
if (folderTree[currentPath])
|
|
689
|
+
continue;
|
|
690
|
+
const parentId = folderTree[parentPath];
|
|
691
|
+
if (!parentId)
|
|
692
|
+
throw new Error(`Cannot resolve parent folder for: ${currentPath}`);
|
|
693
|
+
try {
|
|
694
|
+
const newId = await this.createFolder(projectId, parentId, segment);
|
|
695
|
+
folderTree[currentPath] = newId;
|
|
696
|
+
}
|
|
697
|
+
catch (e) {
|
|
698
|
+
if (e.message === 'Folder already exists') {
|
|
699
|
+
// Folder exists but we don't have its ID - re-fetch tree
|
|
700
|
+
const freshTree = await this.getFolderTreeFromSocket(projectId);
|
|
701
|
+
if (freshTree?.[currentPath]) {
|
|
702
|
+
folderTree[currentPath] = freshTree[currentPath];
|
|
703
|
+
}
|
|
704
|
+
else {
|
|
705
|
+
throw new Error(`Folder '${currentPath}' exists but could not resolve its ID`);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
else {
|
|
709
|
+
throw e;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
return folderTree[folderPath];
|
|
714
|
+
}
|
|
493
715
|
/**
|
|
494
716
|
* Get root folder ID for a project (tries multiple methods)
|
|
495
717
|
*/
|
|
@@ -542,16 +764,21 @@ export class OverleafClient {
|
|
|
542
764
|
formData.append('name', testFileName);
|
|
543
765
|
formData.append('type', 'text/plain');
|
|
544
766
|
formData.append('qqfile', new Blob(['probe']), testFileName);
|
|
545
|
-
const response = await
|
|
767
|
+
const response = await this.httpRequest(`${this.uploadUrl(projectId)}?folder_id=${folderId}`, {
|
|
546
768
|
method: 'POST',
|
|
547
769
|
headers: {
|
|
548
770
|
'Cookie': this.getCookieHeader(),
|
|
549
771
|
'User-Agent': USER_AGENT,
|
|
550
772
|
'X-Csrf-Token': this.csrf
|
|
551
773
|
},
|
|
552
|
-
body: formData
|
|
774
|
+
body: formData,
|
|
775
|
+
expect: 'json'
|
|
553
776
|
});
|
|
554
|
-
|
|
777
|
+
if (!response.ok) {
|
|
778
|
+
continue;
|
|
779
|
+
}
|
|
780
|
+
this.applySetCookieHeaders(response.headers['set-cookie']);
|
|
781
|
+
const data = response.body;
|
|
555
782
|
if (data.success !== false && data.entity_id) {
|
|
556
783
|
// Success! Delete the probe file and return this folder ID
|
|
557
784
|
try {
|
|
@@ -570,13 +797,22 @@ export class OverleafClient {
|
|
|
570
797
|
return null;
|
|
571
798
|
}
|
|
572
799
|
/**
|
|
573
|
-
* Upload a file to a project
|
|
800
|
+
* Upload a file to a project.
|
|
801
|
+
* If folderTree is provided and fileName contains a path (e.g. 'figures/img.png'),
|
|
802
|
+
* the file will be uploaded into the correct subfolder, creating it if needed.
|
|
574
803
|
*/
|
|
575
|
-
async uploadFile(projectId, folderId, fileName, content) {
|
|
576
|
-
//
|
|
577
|
-
let targetFolderId = folderId || await this.getRootFolderId(projectId);
|
|
578
|
-
// Extract just the filename without path (PR #73 fix)
|
|
804
|
+
async uploadFile(projectId, folderId, fileName, content, folderTree) {
|
|
805
|
+
// Extract just the filename without path
|
|
579
806
|
const baseName = fileName.split('/').pop() || fileName;
|
|
807
|
+
// Resolve target folder: if fileName has a directory part and we have a folderTree, use it
|
|
808
|
+
const dirPart = fileName.includes('/') ? fileName.split('/').slice(0, -1).join('/') : '';
|
|
809
|
+
let targetFolderId;
|
|
810
|
+
if (dirPart && folderTree) {
|
|
811
|
+
targetFolderId = await this.resolveFolderId(projectId, folderTree, dirPart);
|
|
812
|
+
}
|
|
813
|
+
else {
|
|
814
|
+
targetFolderId = folderId || await this.getRootFolderId(projectId);
|
|
815
|
+
}
|
|
580
816
|
// Determine MIME type
|
|
581
817
|
const ext = baseName.split('.').pop()?.toLowerCase() || '';
|
|
582
818
|
const mimeTypes = {
|
|
@@ -600,17 +836,18 @@ export class OverleafClient {
|
|
|
600
836
|
formData.append('name', baseName);
|
|
601
837
|
formData.append('type', mimeType);
|
|
602
838
|
formData.append('qqfile', new Blob([content]), baseName);
|
|
603
|
-
const response = await
|
|
839
|
+
const response = await this.httpRequest(`${this.uploadUrl(projectId)}?folder_id=${encodeURIComponent(fid)}`, {
|
|
604
840
|
method: 'POST',
|
|
605
841
|
headers: {
|
|
606
842
|
'Cookie': this.getCookieHeader(),
|
|
607
843
|
'User-Agent': USER_AGENT,
|
|
608
844
|
'X-Csrf-Token': this.csrf
|
|
609
845
|
},
|
|
610
|
-
body: formData
|
|
846
|
+
body: formData,
|
|
847
|
+
expect: 'text'
|
|
611
848
|
});
|
|
612
849
|
if (!response.ok) {
|
|
613
|
-
const text =
|
|
850
|
+
const text = response.body;
|
|
614
851
|
// Overleaf returns folder_not_found as HTTP 422 JSON.
|
|
615
852
|
// Parse the body first so caller can trigger folder probing fallback.
|
|
616
853
|
try {
|
|
@@ -624,7 +861,8 @@ export class OverleafClient {
|
|
|
624
861
|
}
|
|
625
862
|
return { success: false, error: `${response.status} - ${text}` };
|
|
626
863
|
}
|
|
627
|
-
|
|
864
|
+
this.applySetCookieHeaders(response.headers['set-cookie']);
|
|
865
|
+
const data = JSON.parse(response.body);
|
|
628
866
|
if (data.success === false && data.error === 'folder_not_found') {
|
|
629
867
|
return { success: false, error: 'folder_not_found' };
|
|
630
868
|
}
|
|
@@ -665,29 +903,30 @@ export class OverleafClient {
|
|
|
665
903
|
* Delete a file or folder
|
|
666
904
|
*/
|
|
667
905
|
async deleteEntity(projectId, entityId, entityType) {
|
|
668
|
-
const url =
|
|
669
|
-
|
|
670
|
-
.replace('{type}', entityType)
|
|
671
|
-
.replace('{entityId}', entityId);
|
|
672
|
-
const response = await fetch(url, {
|
|
906
|
+
const url = this.deleteUrl(projectId, entityType, entityId);
|
|
907
|
+
const response = await this.httpRequest(url, {
|
|
673
908
|
method: 'DELETE',
|
|
674
|
-
headers: this.getHeaders()
|
|
909
|
+
headers: this.getHeaders(),
|
|
910
|
+
expect: 'text'
|
|
675
911
|
});
|
|
676
912
|
if (!response.ok) {
|
|
677
913
|
throw new Error(`Failed to delete entity: ${response.status}`);
|
|
678
914
|
}
|
|
915
|
+
this.applySetCookieHeaders(response.headers['set-cookie']);
|
|
679
916
|
}
|
|
680
917
|
/**
|
|
681
918
|
* Get list of entities (files/docs) with paths
|
|
682
919
|
*/
|
|
683
920
|
async getEntities(projectId) {
|
|
684
|
-
const response = await
|
|
685
|
-
headers: this.getHeaders()
|
|
921
|
+
const response = await this.httpRequest(`${this.baseUrl}/project/${projectId}/entities`, {
|
|
922
|
+
headers: this.getHeaders(),
|
|
923
|
+
expect: 'json'
|
|
686
924
|
});
|
|
687
925
|
if (!response.ok) {
|
|
688
926
|
throw new Error(`Failed to get entities: ${response.status}`);
|
|
689
927
|
}
|
|
690
|
-
|
|
928
|
+
this.applySetCookieHeaders(response.headers['set-cookie']);
|
|
929
|
+
const data = response.body;
|
|
691
930
|
return data.entities || [];
|
|
692
931
|
}
|
|
693
932
|
/**
|
|
@@ -733,35 +972,38 @@ export class OverleafClient {
|
|
|
733
972
|
*/
|
|
734
973
|
async downloadFile(projectId, fileId, fileType) {
|
|
735
974
|
const endpoint = fileType === 'doc' ? 'doc' : 'file';
|
|
736
|
-
const response = await
|
|
737
|
-
headers: this.getHeaders()
|
|
975
|
+
const response = await this.httpRequest(`${this.baseUrl}/project/${projectId}/${endpoint}/${fileId}`, {
|
|
976
|
+
headers: this.getHeaders(),
|
|
977
|
+
expect: fileType === 'doc' ? 'json' : 'buffer'
|
|
738
978
|
});
|
|
739
979
|
if (!response.ok) {
|
|
740
980
|
throw new Error(`Failed to download file: ${response.status}`);
|
|
741
981
|
}
|
|
982
|
+
this.applySetCookieHeaders(response.headers['set-cookie']);
|
|
742
983
|
if (fileType === 'doc') {
|
|
743
984
|
// Docs return JSON with lines array
|
|
744
|
-
const data =
|
|
985
|
+
const data = response.body;
|
|
745
986
|
const content = (data.lines || []).join('\n');
|
|
746
987
|
return Buffer.from(content, 'utf-8');
|
|
747
988
|
}
|
|
748
989
|
else {
|
|
749
|
-
|
|
750
|
-
return Buffer.from(arrayBuffer);
|
|
990
|
+
return response.body;
|
|
751
991
|
}
|
|
752
992
|
}
|
|
753
993
|
/**
|
|
754
994
|
* Rename a file, doc, or folder
|
|
755
995
|
*/
|
|
756
996
|
async renameEntity(projectId, entityId, entityType, newName) {
|
|
757
|
-
const response = await
|
|
997
|
+
const response = await this.httpRequest(`${this.baseUrl}/project/${projectId}/${entityType}/${entityId}/rename`, {
|
|
758
998
|
method: 'POST',
|
|
759
999
|
headers: this.getHeaders(true),
|
|
760
|
-
body: JSON.stringify({ name: newName })
|
|
1000
|
+
body: JSON.stringify({ name: newName }),
|
|
1001
|
+
expect: 'text'
|
|
761
1002
|
});
|
|
762
1003
|
if (!response.ok) {
|
|
763
1004
|
throw new Error(`Failed to rename entity: ${response.status}`);
|
|
764
1005
|
}
|
|
1006
|
+
this.applySetCookieHeaders(response.headers['set-cookie']);
|
|
765
1007
|
}
|
|
766
1008
|
/**
|
|
767
1009
|
* Delete a file by path
|
|
@@ -820,7 +1062,7 @@ export class OverleafClient {
|
|
|
820
1062
|
* Compile project and get all output files
|
|
821
1063
|
*/
|
|
822
1064
|
async compileWithOutputs(projectId) {
|
|
823
|
-
const response = await
|
|
1065
|
+
const response = await this.httpRequest(this.compileUrl(projectId), {
|
|
824
1066
|
method: 'POST',
|
|
825
1067
|
headers: this.getHeaders(true),
|
|
826
1068
|
body: JSON.stringify({
|
|
@@ -828,20 +1070,22 @@ export class OverleafClient {
|
|
|
828
1070
|
draft: false,
|
|
829
1071
|
check: 'silent',
|
|
830
1072
|
incrementalCompilesEnabled: true
|
|
831
|
-
})
|
|
1073
|
+
}),
|
|
1074
|
+
expect: 'json'
|
|
832
1075
|
});
|
|
833
1076
|
if (!response.ok) {
|
|
834
1077
|
throw new Error(`Failed to compile project: ${response.status}`);
|
|
835
1078
|
}
|
|
836
|
-
|
|
1079
|
+
this.applySetCookieHeaders(response.headers['set-cookie']);
|
|
1080
|
+
const data = response.body;
|
|
837
1081
|
const pdfFile = data.outputFiles?.find((f) => f.type === 'pdf');
|
|
838
1082
|
return {
|
|
839
1083
|
status: data.status,
|
|
840
|
-
pdfUrl: pdfFile ? `${
|
|
1084
|
+
pdfUrl: pdfFile ? `${this.baseUrl}${pdfFile.url}` : undefined,
|
|
841
1085
|
outputFiles: (data.outputFiles || []).map((f) => ({
|
|
842
1086
|
path: f.path,
|
|
843
1087
|
type: f.type,
|
|
844
|
-
url: `${
|
|
1088
|
+
url: `${this.baseUrl}${f.url}`
|
|
845
1089
|
}))
|
|
846
1090
|
};
|
|
847
1091
|
}
|