@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/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 BASE_URL = 'https://www.overleaf.com';
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
- 'overleaf_session2': sessionCookie
50
+ [cookieName]: sessionCookie
35
51
  };
36
52
  // Fetch CSRF token from project page
37
- const response = await fetch(PROJECT_URL, {
38
- headers: {
39
- 'Cookie': Object.entries(cookies).map(([k, v]) => `${k}=${v}`).join('; '),
40
- 'User-Agent': USER_AGENT
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} ${response.statusText}`);
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
- const html = await response.text();
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
- return new OverleafClient({ cookies, csrf });
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 fetch(PROJECT_URL, {
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
- const html = await response.text();
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 fetch(`${PROJECT_URL}/${projectId}`, {
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
- const html = await response.text();
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 { default: https } = await import('node:https');
251
- const { default: http } = await import('node:http');
252
- const doRequest = (reqUrl) => {
253
- return new Promise((resolve, reject) => {
254
- const parsedUrl = new URL(reqUrl);
255
- const transport = parsedUrl.protocol === 'https:' ? https : http;
256
- const req = transport.get(reqUrl, {
257
- headers: this.getHeaders(),
258
- }, (res) => {
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
- const url = DOWNLOAD_URL.replace('{id}', projectId);
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 fetch(COMPILE_URL.replace('{id}', projectId), {
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
- const data = await response.json();
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: `${BASE_URL}${pdfFile.url}`,
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 fetch(FOLDER_URL.replace('{id}', projectId), {
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
- const data = await response.json();
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 = `${BASE_URL}/socket.io/1/?projectId=${encodeURIComponent(projectId)}&t=${Date.now()}`;
415
- const handshakeResponse = await this.fetchWithTimeout(handshakeUrl, {
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
- }, 5000);
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 = (await handshakeResponse.text()).trim();
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 = () => `${BASE_URL}/socket.io/1/xhr-polling/${sid}?projectId=${encodeURIComponent(projectId)}&t=${Date.now()}`;
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.fetchWithTimeout(buildPollUrl(), {
520
+ const pollResponse = await this.httpRequest(buildPollUrl(), {
433
521
  headers: {
434
522
  'Cookie': this.getCookieHeader(),
435
523
  'User-Agent': USER_AGENT
436
- }
437
- }, 5000);
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 = await pollResponse.text();
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.fetchWithTimeout(buildPollUrl(), {
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
- }, 5000);
460
- this.applySetCookieHeaders(heartbeatResponse.headers);
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 = `${BASE_URL}/socket.io/1/xhr-polling/${sid}?projectId=${encodeURIComponent(projectId)}&t=${Date.now()}`;
475
- const disconnectResponse = await this.fetchWithTimeout(disconnectUrl, {
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
- }, 5000);
484
- this.applySetCookieHeaders(disconnectResponse.headers);
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 fetch(`${UPLOAD_URL.replace('{id}', projectId)}?folder_id=${folderId}`, {
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
- const data = await response.json();
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
- // If no folder ID provided, get the root folder
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 fetch(`${UPLOAD_URL.replace('{id}', projectId)}?folder_id=${encodeURIComponent(fid)}`, {
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 = await response.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
- const data = await response.json();
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 = DELETE_URL
669
- .replace('{projectId}', projectId)
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 fetch(`${BASE_URL}/project/${projectId}/entities`, {
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
- const data = await response.json();
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 fetch(`${BASE_URL}/project/${projectId}/${endpoint}/${fileId}`, {
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 = await response.json();
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
- const arrayBuffer = await response.arrayBuffer();
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 fetch(`${BASE_URL}/project/${projectId}/${entityType}/${entityId}/rename`, {
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 fetch(COMPILE_URL.replace('{id}', projectId), {
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
- const data = await response.json();
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 ? `${BASE_URL}${pdfFile.url}` : undefined,
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: `${BASE_URL}${f.url}`
1088
+ url: `${this.baseUrl}${f.url}`
845
1089
  }))
846
1090
  };
847
1091
  }