overleaf-codex 0.1.0-rc.1

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.

Potentially problematic release.


This version of overleaf-codex might be problematic. Click here for more details.

Files changed (155) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE.md +25 -0
  3. package/README.md +217 -0
  4. package/assets/olcx-mark.svg +22 -0
  5. package/dist/auth/projectAuth.d.ts +19 -0
  6. package/dist/auth/projectAuth.js +163 -0
  7. package/dist/auth/projectAuth.js.map +1 -0
  8. package/dist/auth/redact.d.ts +3 -0
  9. package/dist/auth/redact.js +7 -0
  10. package/dist/auth/redact.js.map +1 -0
  11. package/dist/auth/types.d.ts +10 -0
  12. package/dist/auth/types.js +4 -0
  13. package/dist/auth/types.js.map +1 -0
  14. package/dist/backend/index.d.ts +6 -0
  15. package/dist/backend/index.js +2 -0
  16. package/dist/backend/index.js.map +1 -0
  17. package/dist/backend/olcli/client.d.ts +329 -0
  18. package/dist/backend/olcli/client.js +1757 -0
  19. package/dist/backend/olcli/client.js.map +1 -0
  20. package/dist/backend/olcli/index.d.ts +2 -0
  21. package/dist/backend/olcli/index.js +2 -0
  22. package/dist/backend/olcli/index.js.map +1 -0
  23. package/dist/backend/overleafBackend.d.ts +41 -0
  24. package/dist/backend/overleafBackend.js +200 -0
  25. package/dist/backend/overleafBackend.js.map +1 -0
  26. package/dist/backend/types.d.ts +73 -0
  27. package/dist/backend/types.js +2 -0
  28. package/dist/backend/types.js.map +1 -0
  29. package/dist/cli-behavior.d.ts +14 -0
  30. package/dist/cli-behavior.js +59 -0
  31. package/dist/cli-behavior.js.map +1 -0
  32. package/dist/cli.d.ts +30 -0
  33. package/dist/cli.js +441 -0
  34. package/dist/cli.js.map +1 -0
  35. package/dist/commands/auth.d.ts +21 -0
  36. package/dist/commands/auth.js +104 -0
  37. package/dist/commands/auth.js.map +1 -0
  38. package/dist/commands/compile.d.ts +7 -0
  39. package/dist/commands/compile.js +73 -0
  40. package/dist/commands/compile.js.map +1 -0
  41. package/dist/commands/doctor.d.ts +11 -0
  42. package/dist/commands/doctor.js +9 -0
  43. package/dist/commands/doctor.js.map +1 -0
  44. package/dist/commands/endpoint.d.ts +23 -0
  45. package/dist/commands/endpoint.js +69 -0
  46. package/dist/commands/endpoint.js.map +1 -0
  47. package/dist/commands/init.d.ts +14 -0
  48. package/dist/commands/init.js +48 -0
  49. package/dist/commands/init.js.map +1 -0
  50. package/dist/commands/status.d.ts +4 -0
  51. package/dist/commands/status.js +5 -0
  52. package/dist/commands/status.js.map +1 -0
  53. package/dist/commands/sync.d.ts +26 -0
  54. package/dist/commands/sync.js +139 -0
  55. package/dist/commands/sync.js.map +1 -0
  56. package/dist/commands/watch.d.ts +28 -0
  57. package/dist/commands/watch.js +124 -0
  58. package/dist/commands/watch.js.map +1 -0
  59. package/dist/compile/compileFlow.d.ts +32 -0
  60. package/dist/compile/compileFlow.js +290 -0
  61. package/dist/compile/compileFlow.js.map +1 -0
  62. package/dist/compile/pdfOutput.d.ts +12 -0
  63. package/dist/compile/pdfOutput.js +64 -0
  64. package/dist/compile/pdfOutput.js.map +1 -0
  65. package/dist/config/ignoreRules.d.ts +5 -0
  66. package/dist/config/ignoreRules.js +53 -0
  67. package/dist/config/ignoreRules.js.map +1 -0
  68. package/dist/config/overleafProject.d.ts +9 -0
  69. package/dist/config/overleafProject.js +61 -0
  70. package/dist/config/overleafProject.js.map +1 -0
  71. package/dist/config/projectConfig.d.ts +6 -0
  72. package/dist/config/projectConfig.js +180 -0
  73. package/dist/config/projectConfig.js.map +1 -0
  74. package/dist/config/projectRoot.d.ts +1 -0
  75. package/dist/config/projectRoot.js +36 -0
  76. package/dist/config/projectRoot.js.map +1 -0
  77. package/dist/config/types.d.ts +50 -0
  78. package/dist/config/types.js +34 -0
  79. package/dist/config/types.js.map +1 -0
  80. package/dist/config/vscode.d.ts +10 -0
  81. package/dist/config/vscode.js +134 -0
  82. package/dist/config/vscode.js.map +1 -0
  83. package/dist/diagnostics/doctor.d.ts +8 -0
  84. package/dist/diagnostics/doctor.js +209 -0
  85. package/dist/diagnostics/doctor.js.map +1 -0
  86. package/dist/diagnostics/status.d.ts +6 -0
  87. package/dist/diagnostics/status.js +110 -0
  88. package/dist/diagnostics/status.js.map +1 -0
  89. package/dist/diagnostics/types.d.ts +33 -0
  90. package/dist/diagnostics/types.js +2 -0
  91. package/dist/diagnostics/types.js.map +1 -0
  92. package/dist/endpoint/overleafEndpoint.d.ts +36 -0
  93. package/dist/endpoint/overleafEndpoint.js +105 -0
  94. package/dist/endpoint/overleafEndpoint.js.map +1 -0
  95. package/dist/errors.d.ts +32 -0
  96. package/dist/errors.js +53 -0
  97. package/dist/errors.js.map +1 -0
  98. package/dist/sync/apply.d.ts +14 -0
  99. package/dist/sync/apply.js +92 -0
  100. package/dist/sync/apply.js.map +1 -0
  101. package/dist/sync/conflicts.d.ts +7 -0
  102. package/dist/sync/conflicts.js +59 -0
  103. package/dist/sync/conflicts.js.map +1 -0
  104. package/dist/sync/ignore.d.ts +5 -0
  105. package/dist/sync/ignore.js +74 -0
  106. package/dist/sync/ignore.js.map +1 -0
  107. package/dist/sync/plan.d.ts +3 -0
  108. package/dist/sync/plan.js +197 -0
  109. package/dist/sync/plan.js.map +1 -0
  110. package/dist/sync/snapshot.d.ts +13 -0
  111. package/dist/sync/snapshot.js +82 -0
  112. package/dist/sync/snapshot.js.map +1 -0
  113. package/dist/sync/state.d.ts +16 -0
  114. package/dist/sync/state.js +214 -0
  115. package/dist/sync/state.js.map +1 -0
  116. package/dist/sync/types.d.ts +113 -0
  117. package/dist/sync/types.js +4 -0
  118. package/dist/sync/types.js.map +1 -0
  119. package/dist/testing/fakeBackend.d.ts +27 -0
  120. package/dist/testing/fakeBackend.js +213 -0
  121. package/dist/testing/fakeBackend.js.map +1 -0
  122. package/dist/watch/queue.d.ts +2 -0
  123. package/dist/watch/queue.js +91 -0
  124. package/dist/watch/queue.js.map +1 -0
  125. package/dist/watch/types.d.ts +52 -0
  126. package/dist/watch/types.js +2 -0
  127. package/dist/watch/types.js.map +1 -0
  128. package/dist/watch/watcher.d.ts +6 -0
  129. package/dist/watch/watcher.js +58 -0
  130. package/dist/watch/watcher.js.map +1 -0
  131. package/dist/watch/workflow.d.ts +30 -0
  132. package/dist/watch/workflow.js +62 -0
  133. package/dist/watch/workflow.js.map +1 -0
  134. package/docs/architecture.md +603 -0
  135. package/docs/auth.md +65 -0
  136. package/docs/cli-behavior.md +95 -0
  137. package/docs/compile.md +51 -0
  138. package/docs/design.md +82 -0
  139. package/docs/endpoint.md +84 -0
  140. package/docs/npm-packaging.md +148 -0
  141. package/docs/quickdev-queue-audit.md +193 -0
  142. package/docs/release-gates.md +119 -0
  143. package/docs/release-notes-v1.md +97 -0
  144. package/docs/security.md +61 -0
  145. package/docs/sync-state.md +305 -0
  146. package/docs/sync.md +50 -0
  147. package/docs/troubleshooting.md +124 -0
  148. package/docs/usage.md +184 -0
  149. package/examples/minimal-paper/.olcx/auth.local.example.json +7 -0
  150. package/examples/minimal-paper/.olcx/config.json +23 -0
  151. package/examples/minimal-paper/README.md +88 -0
  152. package/examples/minimal-paper/main.tex +23 -0
  153. package/package.json +66 -0
  154. package/src/backend/olcli/LICENSE +21 -0
  155. package/src/backend/olcli/README.md +26 -0
@@ -0,0 +1,1757 @@
1
+ /**
2
+ * Adapted from @aloth/olcli v0.5.0 src/client.ts.
3
+ * Source: https://github.com/aloth/olcli
4
+ * Tag: v0.5.0
5
+ * Commit: 524c30b11328a847a9c0bcf4447d2b3468160f8c
6
+ * Copyright (c) 2026 Alexander Loth
7
+ * Licensed under the MIT License. See ./LICENSE.
8
+ *
9
+ * olcx adaptations:
10
+ * - moved into backend-private src/backend/olcli/
11
+ * - removed import-time package.json version lookup
12
+ * - exported a fixed olcx/olcli user agent for built-package imports
13
+ */
14
+ /**
15
+ * Overleaf API Client
16
+ *
17
+ * Provides programmatic access to Overleaf's REST APIs for project
18
+ * management, file operations, and LaTeX compilation.
19
+ */
20
+ import * as cheerio from 'cheerio';
21
+ import * as https from 'node:https';
22
+ import * as http from 'node:http';
23
+ export const USER_AGENT = "olcx/0.1.0 olcli/0.5.0";
24
+ const DEFAULT_BASE_URL = 'https://www.overleaf.com';
25
+ export class OverleafClient {
26
+ cookies;
27
+ csrf;
28
+ baseUrl;
29
+ verbose = false;
30
+ // Cache per-project folder trees so repeated uploads in sync/upload calls
31
+ // don't re-fetch the tree via Socket.IO on every file.
32
+ folderTreeCache = new Map();
33
+ constructor(credentials) {
34
+ this.cookies = credentials.cookies;
35
+ this.csrf = credentials.csrf;
36
+ this.baseUrl = credentials.baseUrl || DEFAULT_BASE_URL;
37
+ }
38
+ /** Enable or disable verbose request/response logging to stderr. */
39
+ setVerbose(v) {
40
+ this.verbose = v;
41
+ }
42
+ /**
43
+ * Resolve (and cache) the folder tree for a project. Falls back to a
44
+ * minimal tree containing only the root folder when the Socket.IO probe
45
+ * fails (e.g. self-hosted Overleaf without that endpoint).
46
+ */
47
+ async getOrLoadFolderTree(projectId) {
48
+ const cached = this.folderTreeCache.get(projectId);
49
+ if (cached)
50
+ return cached;
51
+ let tree = await this.getFolderTreeFromSocket(projectId);
52
+ if (!tree) {
53
+ const rootId = await this.getRootFolderId(projectId);
54
+ tree = { '': rootId };
55
+ }
56
+ this.folderTreeCache.set(projectId, tree);
57
+ return tree;
58
+ }
59
+ /** Drop the cached folder tree for a project (e.g. after rename/delete). */
60
+ invalidateFolderTree(projectId) {
61
+ this.folderTreeCache.delete(projectId);
62
+ }
63
+ projectUrl() {
64
+ return `${this.baseUrl}/project`;
65
+ }
66
+ downloadUrl(projectId) {
67
+ return `${this.baseUrl}/project/${projectId}/download/zip`;
68
+ }
69
+ uploadUrl(projectId) {
70
+ return `${this.baseUrl}/project/${projectId}/upload`;
71
+ }
72
+ folderUrl(projectId) {
73
+ return `${this.baseUrl}/project/${projectId}/folder`;
74
+ }
75
+ deleteUrl(projectId, entityType, entityId) {
76
+ return `${this.baseUrl}/project/${projectId}/${entityType}/${entityId}`;
77
+ }
78
+ compileUrl(projectId) {
79
+ return `${this.baseUrl}/project/${projectId}/compile?enable_pdf_caching=true`;
80
+ }
81
+ /**
82
+ * Create client from session cookie string
83
+ */
84
+ static async fromSessionCookie(sessionCookie, baseUrl = DEFAULT_BASE_URL, cookieName = 'overleaf_session2') {
85
+ const cookies = {
86
+ [cookieName]: sessionCookie
87
+ };
88
+ // Fetch CSRF token from project page
89
+ const initialHeaders = {
90
+ 'Cookie': Object.entries(cookies).map(([k, v]) => `${k}=${v}`).join('; '),
91
+ 'User-Agent': USER_AGENT
92
+ };
93
+ const bootstrapClient = new OverleafClient({ cookies, csrf: 'bootstrap', baseUrl });
94
+ const response = await bootstrapClient.httpRequest(`${baseUrl}/project`, {
95
+ headers: initialHeaders,
96
+ expect: 'text'
97
+ });
98
+ if (!response.ok) {
99
+ throw new Error(`Failed to fetch projects page: ${response.status}`);
100
+ }
101
+ bootstrapClient.applySetCookieHeaders(response.headers['set-cookie']);
102
+ const html = response.body;
103
+ const $ = cheerio.load(html);
104
+ // Try multiple methods to find CSRF token (based on PR #66, #82)
105
+ let csrf;
106
+ // Method 1: ol-csrfToken meta tag
107
+ csrf = $('meta[name="ol-csrfToken"]').attr('content');
108
+ // Method 2: hidden input field
109
+ if (!csrf) {
110
+ csrf = $('input[name="_csrf"]').attr('value');
111
+ }
112
+ // Method 3: Look in script tags for csrfToken
113
+ if (!csrf) {
114
+ const scripts = $('script').toArray();
115
+ for (const script of scripts) {
116
+ const content = $(script).html() || '';
117
+ const match = content.match(/csrfToken["']?\s*[:=]\s*["']([^"']+)["']/);
118
+ if (match) {
119
+ csrf = match[1];
120
+ break;
121
+ }
122
+ }
123
+ }
124
+ if (!csrf) {
125
+ throw new Error('Could not find CSRF token. Session may have expired.');
126
+ }
127
+ // Update cookies if the bootstrap request added anything
128
+ const updatedCookies = bootstrapClient.cookies;
129
+ return new OverleafClient({ cookies: updatedCookies, csrf, baseUrl });
130
+ }
131
+ getCookieHeader() {
132
+ return Object.entries(this.cookies).map(([k, v]) => `${k}=${v}`).join('; ');
133
+ }
134
+ getHeaders(includeContentType = false) {
135
+ const headers = {
136
+ 'Cookie': this.getCookieHeader(),
137
+ 'User-Agent': USER_AGENT,
138
+ 'X-Csrf-Token': this.csrf
139
+ };
140
+ if (includeContentType) {
141
+ headers['Content-Type'] = 'application/json';
142
+ }
143
+ return headers;
144
+ }
145
+ normalizeHeaders(headers) {
146
+ const normalized = {};
147
+ if (!headers)
148
+ return normalized;
149
+ for (const [key, value] of Object.entries(headers)) {
150
+ if (typeof value === 'string') {
151
+ normalized[key] = value;
152
+ }
153
+ }
154
+ return normalized;
155
+ }
156
+ applySetCookieHeaders(setCookie) {
157
+ if (!setCookie)
158
+ return;
159
+ for (const setCookieHeader of setCookie) {
160
+ const match = setCookieHeader.match(/^([^=]+)=([^;]+)/);
161
+ if (match) {
162
+ this.cookies[match[1]] = match[2];
163
+ }
164
+ }
165
+ }
166
+ logVerbose(...args) {
167
+ if (this.verbose) {
168
+ // eslint-disable-next-line no-console
169
+ console.error('[olcli]', ...args);
170
+ }
171
+ }
172
+ async httpRequest(url, options = {}) {
173
+ const method = options.method || 'GET';
174
+ const timeoutMs = options.timeoutMs ?? 10000;
175
+ const maxRedirects = options.maxRedirects ?? 5;
176
+ const expect = options.expect ?? 'text';
177
+ // Normalize FormData bodies into a multipart Buffer + headers using Node's
178
+ // built-in Web Fetch primitives. Keeps every code path on httpRequest
179
+ // (no fetch() reintroduction) while properly serializing multipart uploads.
180
+ let bodyBuffer;
181
+ let extraHeaders = {};
182
+ if (options.body instanceof FormData) {
183
+ const req = new Request('http://x/', { method: 'POST', body: options.body });
184
+ const arrayBuf = await req.arrayBuffer();
185
+ bodyBuffer = Buffer.from(arrayBuf);
186
+ const ct = req.headers.get('content-type');
187
+ if (ct)
188
+ extraHeaders['Content-Type'] = ct;
189
+ extraHeaders['Content-Length'] = String(bodyBuffer.length);
190
+ }
191
+ else if (options.body !== undefined) {
192
+ bodyBuffer = options.body;
193
+ }
194
+ const doRequest = (reqUrl, redirectsLeft) => {
195
+ return new Promise((resolve, reject) => {
196
+ const parsedUrl = new URL(reqUrl);
197
+ const transport = parsedUrl.protocol === 'https:' ? https : http;
198
+ const headers = this.normalizeHeaders({ ...extraHeaders, ...options.headers });
199
+ const req = transport.request(reqUrl, { method, headers }, (res) => {
200
+ const status = res.statusCode || 0;
201
+ const resHeaders = res.headers;
202
+ if (status >= 300 && status < 400 && res.headers.location && redirectsLeft > 0) {
203
+ this.logVerbose(`${method} ${reqUrl} -> ${status} redirect -> ${res.headers.location}`);
204
+ const redirectUrl = new URL(res.headers.location, reqUrl).toString();
205
+ res.resume();
206
+ doRequest(redirectUrl, redirectsLeft - 1).then(resolve, reject);
207
+ return;
208
+ }
209
+ const chunks = [];
210
+ res.on('data', (chunk) => chunks.push(chunk));
211
+ res.on('end', () => {
212
+ const buffer = Buffer.concat(chunks);
213
+ let body = buffer;
214
+ if (expect === 'text') {
215
+ body = buffer.toString('utf-8');
216
+ }
217
+ else if (expect === 'json') {
218
+ try {
219
+ body = JSON.parse(buffer.toString('utf-8'));
220
+ }
221
+ catch (e) {
222
+ this.logVerbose(`${method} ${reqUrl} -> ${status} (invalid JSON, ${buffer.length} bytes)`);
223
+ return reject(new Error(`Failed to parse JSON response from ${reqUrl}`));
224
+ }
225
+ }
226
+ const ok = status >= 200 && status < 300;
227
+ if (this.verbose) {
228
+ const ct = (resHeaders['content-type'] || '');
229
+ let snippet = '';
230
+ if (!ok) {
231
+ const text = expect === 'buffer' ? '' : (typeof body === 'string' ? body : JSON.stringify(body));
232
+ snippet = text ? ` body=${text.slice(0, 200).replace(/\s+/g, ' ')}` : '';
233
+ }
234
+ this.logVerbose(`${method} ${reqUrl} -> ${status} (${buffer.length}B ${ct})${snippet}`);
235
+ }
236
+ resolve({ status, ok, headers: resHeaders, body });
237
+ });
238
+ res.on('error', reject);
239
+ });
240
+ req.on('error', reject);
241
+ if (timeoutMs) {
242
+ req.setTimeout(timeoutMs, () => {
243
+ req.destroy(new Error(`Request timeout after ${timeoutMs}ms`));
244
+ });
245
+ }
246
+ if (bodyBuffer !== undefined) {
247
+ req.write(bodyBuffer);
248
+ }
249
+ req.end();
250
+ });
251
+ };
252
+ return doRequest(url, maxRedirects);
253
+ }
254
+ /**
255
+ * Get all projects (not archived, not trashed)
256
+ */
257
+ async listProjects() {
258
+ const response = await this.httpRequest(this.projectUrl(), {
259
+ headers: this.getHeaders(),
260
+ expect: 'text'
261
+ });
262
+ if (!response.ok) {
263
+ throw new Error(`Failed to fetch projects: ${response.status}`);
264
+ }
265
+ this.applySetCookieHeaders(response.headers['set-cookie']);
266
+ const html = response.body;
267
+ const $ = cheerio.load(html);
268
+ // Try new Overleaf structure first (PR #82)
269
+ let projectsData = [];
270
+ // Method 1: ol-prefetchedProjectsBlob (newest Overleaf)
271
+ const prefetchedBlob = $('meta[name="ol-prefetchedProjectsBlob"]').attr('content');
272
+ if (prefetchedBlob) {
273
+ try {
274
+ const data = JSON.parse(prefetchedBlob);
275
+ projectsData = data.projects || data;
276
+ }
277
+ catch (e) {
278
+ // Try next method
279
+ }
280
+ }
281
+ // Method 2: Meta tag with projects content (PR #73)
282
+ if (projectsData.length === 0) {
283
+ const metas = $('meta[content]').toArray();
284
+ for (const meta of metas) {
285
+ const content = $(meta).attr('content') || '';
286
+ if (content.includes('"projects"')) {
287
+ try {
288
+ const data = JSON.parse(content);
289
+ if (data.projects) {
290
+ projectsData = data.projects;
291
+ break;
292
+ }
293
+ }
294
+ catch (e) {
295
+ // Continue
296
+ }
297
+ }
298
+ }
299
+ }
300
+ // Method 3: ol-projects meta tag (legacy)
301
+ if (projectsData.length === 0) {
302
+ const projectsMeta = $('meta[name="ol-projects"]').attr('content');
303
+ if (projectsMeta) {
304
+ try {
305
+ projectsData = JSON.parse(projectsMeta);
306
+ }
307
+ catch (e) {
308
+ // Continue
309
+ }
310
+ }
311
+ }
312
+ // Filter out archived and trashed
313
+ return projectsData
314
+ .filter((p) => !p.archived && !p.trashed)
315
+ .map((p) => ({
316
+ id: p.id || p._id,
317
+ name: p.name,
318
+ lastUpdated: p.lastUpdated,
319
+ lastUpdatedBy: p.lastUpdatedBy,
320
+ owner: p.owner,
321
+ archived: p.archived,
322
+ trashed: p.trashed
323
+ }));
324
+ }
325
+ /**
326
+ * Get project by name
327
+ */
328
+ async getProject(name) {
329
+ const projects = await this.listProjects();
330
+ return projects.find(p => p.name === name);
331
+ }
332
+ /**
333
+ * Get project by ID
334
+ */
335
+ async getProjectById(id) {
336
+ const projects = await this.listProjects();
337
+ return projects.find(p => p.id === id);
338
+ }
339
+ /**
340
+ * Get detailed project info including file tree
341
+ */
342
+ async getProjectInfo(projectId) {
343
+ const response = await this.httpRequest(`${this.projectUrl()}/${projectId}`, {
344
+ headers: this.getHeaders(),
345
+ expect: 'text'
346
+ });
347
+ if (!response.ok) {
348
+ throw new Error(`Failed to fetch project info: ${response.status}`);
349
+ }
350
+ this.applySetCookieHeaders(response.headers['set-cookie']);
351
+ const html = response.body;
352
+ const $ = cheerio.load(html);
353
+ // Look for project data in meta tags
354
+ let projectInfo;
355
+ // Try ol-project meta tag
356
+ const projectMeta = $('meta[name="ol-project"]').attr('content');
357
+ if (projectMeta) {
358
+ try {
359
+ projectInfo = JSON.parse(projectMeta);
360
+ }
361
+ catch (e) {
362
+ // Continue
363
+ }
364
+ }
365
+ // Try to find in other meta tags
366
+ if (!projectInfo) {
367
+ const metas = $('meta[content]').toArray();
368
+ for (const meta of metas) {
369
+ const content = $(meta).attr('content') || '';
370
+ if (content.includes('rootFolder')) {
371
+ try {
372
+ projectInfo = JSON.parse(content);
373
+ break;
374
+ }
375
+ catch (e) {
376
+ // Continue
377
+ }
378
+ }
379
+ }
380
+ }
381
+ // Fallback: Overleaf no longer ships the project tree in meta tags.
382
+ // Use the Socket.IO joinProjectResponse payload (same source used for
383
+ // root folder discovery) to retrieve the full project info.
384
+ if (!projectInfo) {
385
+ const socketProject = await this.getProjectFromSocket(projectId);
386
+ if (socketProject) {
387
+ projectInfo = socketProject;
388
+ }
389
+ }
390
+ if (!projectInfo) {
391
+ throw new Error('Could not parse project info');
392
+ }
393
+ return projectInfo;
394
+ }
395
+ /**
396
+ * Fetch the full project object via the collaboration socket.
397
+ * Returns the `project` field of the joinProjectResponse, which contains
398
+ * the rootFolder tree and other metadata that used to live in ol-project.
399
+ */
400
+ async getProjectFromSocket(projectId) {
401
+ let sid = null;
402
+ try {
403
+ const handshakeUrl = `${this.baseUrl}/socket.io/1/?projectId=${encodeURIComponent(projectId)}&t=${Date.now()}`;
404
+ const handshakeResponse = await this.httpRequest(handshakeUrl, {
405
+ headers: { 'Cookie': this.getCookieHeader(), 'User-Agent': USER_AGENT },
406
+ expect: 'text',
407
+ timeoutMs: 5000
408
+ });
409
+ if (!handshakeResponse.ok)
410
+ return null;
411
+ this.applySetCookieHeaders(handshakeResponse.headers['set-cookie']);
412
+ const handshakeBody = handshakeResponse.body.trim();
413
+ sid = handshakeBody.split(':')[0];
414
+ if (!sid)
415
+ return null;
416
+ const buildPollUrl = () => `${this.baseUrl}/socket.io/1/xhr-polling/${sid}?projectId=${encodeURIComponent(projectId)}&t=${Date.now()}`;
417
+ for (let attempt = 0; attempt < 6; attempt++) {
418
+ const pollResponse = await this.httpRequest(buildPollUrl(), {
419
+ headers: { 'Cookie': this.getCookieHeader(), 'User-Agent': USER_AGENT },
420
+ expect: 'text',
421
+ timeoutMs: 5000
422
+ });
423
+ if (!pollResponse.ok)
424
+ return null;
425
+ this.applySetCookieHeaders(pollResponse.headers['set-cookie']);
426
+ const packets = this.decodeSocketIoPayload(pollResponse.body);
427
+ for (const packet of packets) {
428
+ if (packet.startsWith('5:::')) {
429
+ try {
430
+ const payload = JSON.parse(packet.slice(4));
431
+ if (payload?.name === 'joinProjectResponse' && payload?.args?.[0]?.project) {
432
+ return payload.args[0].project;
433
+ }
434
+ }
435
+ catch { /* ignore */ }
436
+ }
437
+ if (packet.startsWith('2::')) {
438
+ const heartbeatResponse = await this.httpRequest(buildPollUrl(), {
439
+ method: 'POST',
440
+ headers: {
441
+ 'Cookie': this.getCookieHeader(),
442
+ 'User-Agent': USER_AGENT,
443
+ 'Content-Type': 'text/plain;charset=UTF-8'
444
+ },
445
+ body: '2::',
446
+ expect: 'text',
447
+ timeoutMs: 5000
448
+ });
449
+ this.applySetCookieHeaders(heartbeatResponse.headers['set-cookie']);
450
+ }
451
+ }
452
+ }
453
+ }
454
+ catch {
455
+ // fall through
456
+ }
457
+ finally {
458
+ if (sid) {
459
+ try {
460
+ const disconnectUrl = `${this.baseUrl}/socket.io/1/xhr-polling/${sid}?projectId=${encodeURIComponent(projectId)}&t=${Date.now()}`;
461
+ const disconnectResponse = await this.httpRequest(disconnectUrl, {
462
+ method: 'POST',
463
+ headers: {
464
+ 'Cookie': this.getCookieHeader(),
465
+ 'User-Agent': USER_AGENT,
466
+ 'Content-Type': 'text/plain;charset=UTF-8'
467
+ },
468
+ body: '0::',
469
+ expect: 'text',
470
+ timeoutMs: 5000
471
+ });
472
+ this.applySetCookieHeaders(disconnectResponse.headers['set-cookie']);
473
+ }
474
+ catch { /* ignore */ }
475
+ }
476
+ }
477
+ return null;
478
+ }
479
+ /**
480
+ * Download a URL as a Buffer using Node.js http/https modules.
481
+ *
482
+ * This avoids fetch's strict header validation which rejects non-Latin1
483
+ * characters in response headers (e.g. Content-Disposition with Unicode
484
+ * project names). See: https://github.com/aloth/olcli/issues/2
485
+ */
486
+ async downloadBuffer(url) {
487
+ const response = await this.httpRequest(url, {
488
+ headers: this.getHeaders(),
489
+ expect: 'buffer'
490
+ });
491
+ if (!response.ok) {
492
+ throw new Error(`Download failed: ${response.status}`);
493
+ }
494
+ this.applySetCookieHeaders(response.headers['set-cookie']);
495
+ return response.body;
496
+ }
497
+ /**
498
+ * Download project as zip
499
+ *
500
+ * Uses downloadBuffer to avoid ByteString errors from non-Latin1
501
+ * Content-Disposition headers. See: https://github.com/aloth/olcli/issues/2
502
+ */
503
+ async downloadProject(projectId) {
504
+ return this.downloadBuffer(this.downloadUrl(projectId));
505
+ }
506
+ /**
507
+ * Compile project and get PDF
508
+ */
509
+ async compileProject(projectId, options = {}) {
510
+ const response = await this.httpRequest(this.compileUrl(projectId), {
511
+ method: 'POST',
512
+ headers: this.getHeaders(true),
513
+ body: JSON.stringify({
514
+ rootDoc_id: null,
515
+ draft: options.draft ?? false,
516
+ check: 'silent',
517
+ incrementalCompilesEnabled: true
518
+ }),
519
+ timeoutMs: options.timeoutMs,
520
+ expect: 'json'
521
+ });
522
+ if (!response.ok) {
523
+ throw new Error(`Failed to compile project: ${response.status}`);
524
+ }
525
+ this.applySetCookieHeaders(response.headers['set-cookie']);
526
+ const data = response.body;
527
+ if (data.status !== 'success') {
528
+ throw new Error(`Compilation failed: ${data.status}`);
529
+ }
530
+ // Match by path 'output.pdf' — Overleaf's CLSI always names the main
531
+ // compile output 'output.pdf'. Matching only on type === 'pdf' can pick up
532
+ // figure PDFs or *-eps-converted-to.pdf intermediates. See #26.
533
+ const pdfFile = data.outputFiles?.find((f) => f.path === 'output.pdf')
534
+ || data.outputFiles?.find((f) => f.type === 'pdf');
535
+ if (!pdfFile) {
536
+ throw new Error('No PDF output found');
537
+ }
538
+ // Overleaf's CDN requires ?clsiserverid=<id> for build-output downloads.
539
+ // Without it the build URL 404s. See: https://github.com/aloth/olcli/issues/22
540
+ const qs = data.clsiServerId ? `?clsiserverid=${encodeURIComponent(data.clsiServerId)}` : '';
541
+ return {
542
+ pdfUrl: `${this.baseUrl}${pdfFile.url}${qs}`,
543
+ logs: data.compileGroup ? [`Compile group: ${data.compileGroup}`] : []
544
+ };
545
+ }
546
+ /**
547
+ * Download compiled PDF
548
+ */
549
+ async downloadPdf(projectId) {
550
+ const { pdfUrl } = await this.compileProject(projectId);
551
+ return this.downloadBuffer(pdfUrl);
552
+ }
553
+ /**
554
+ * Create a folder in a project
555
+ */
556
+ async createFolder(projectId, parentFolderId, name) {
557
+ const response = await this.httpRequest(this.folderUrl(projectId), {
558
+ method: 'POST',
559
+ headers: this.getHeaders(true),
560
+ body: JSON.stringify({
561
+ parent_folder_id: parentFolderId,
562
+ name
563
+ }),
564
+ expect: 'json'
565
+ });
566
+ if (response.status === 400) {
567
+ // Folder already exists
568
+ throw new Error('Folder already exists');
569
+ }
570
+ if (!response.ok) {
571
+ throw new Error(`Failed to create folder: ${response.status}`);
572
+ }
573
+ this.applySetCookieHeaders(response.headers['set-cookie']);
574
+ const data = response.body;
575
+ return data._id;
576
+ }
577
+ /**
578
+ * Compute root folder ID from project ID
579
+ * MongoDB ObjectIDs are 24 hex chars. The root folder ID is typically projectId - 1
580
+ */
581
+ computeRootFolderId(projectId) {
582
+ // Parse the last 8 chars as a hex number (the counter portion)
583
+ const prefix = projectId.slice(0, 16);
584
+ const suffix = projectId.slice(16);
585
+ const counter = parseInt(suffix, 16);
586
+ const newCounter = (counter - 1).toString(16).padStart(8, '0');
587
+ return prefix + newCounter;
588
+ }
589
+ /**
590
+ * Decode Socket.IO 0.9 payloads. Frames may be a single packet or \ufffd-length framed packets.
591
+ */
592
+ decodeSocketIoPayload(payload) {
593
+ if (!payload)
594
+ return [];
595
+ if (!payload.startsWith('\ufffd'))
596
+ return [payload];
597
+ const packets = [];
598
+ let i = 0;
599
+ while (i < payload.length) {
600
+ if (payload[i] !== '\ufffd')
601
+ break;
602
+ i += 1;
603
+ let len = '';
604
+ while (i < payload.length && payload[i] !== '\ufffd') {
605
+ len += payload[i];
606
+ i += 1;
607
+ }
608
+ if (i >= payload.length || payload[i] !== '\ufffd')
609
+ break;
610
+ i += 1;
611
+ const packetLen = Number.parseInt(len, 10);
612
+ if (!Number.isFinite(packetLen) || packetLen < 0)
613
+ break;
614
+ packets.push(payload.slice(i, i + packetLen));
615
+ i += packetLen;
616
+ }
617
+ return packets;
618
+ }
619
+ encodeSocketIoEvent(id, name, args) {
620
+ return `5:${id}+::${JSON.stringify({ name, args })}`;
621
+ }
622
+ parseSocketIoAck(packet, id) {
623
+ const match = packet.match(/^6:::(\d+)(.*)$/);
624
+ if (!match || Number.parseInt(match[1], 10) !== id) {
625
+ return null;
626
+ }
627
+ let payload = match[2] || '';
628
+ if (payload.startsWith('+')) {
629
+ payload = payload.slice(1);
630
+ }
631
+ if (!payload)
632
+ return [];
633
+ const args = JSON.parse(payload);
634
+ return Array.isArray(args) ? args : [args];
635
+ }
636
+ decodeOverleafUtf8(text) {
637
+ return Buffer.from(text, 'binary').toString('utf-8');
638
+ }
639
+ generateCommentThreadId() {
640
+ const timestamp = Math.floor(Date.now() / 1000).toString(16).padStart(8, '0');
641
+ const machine = Math.floor(Math.random() * 0xffffff).toString(16).padStart(6, '0');
642
+ const pid = Math.floor(Math.random() * 0x7fff).toString(16).padStart(4, '0');
643
+ return `${timestamp}${machine}${pid}000001`;
644
+ }
645
+ positionToLineColumn(content, position) {
646
+ const prefix = content.slice(0, position);
647
+ const lines = prefix.split('\n');
648
+ return {
649
+ line: lines.length,
650
+ column: lines[lines.length - 1].length + 1
651
+ };
652
+ }
653
+ buildCommentContext(content, line, contextLines = 0) {
654
+ if (contextLines <= 0)
655
+ return undefined;
656
+ const lines = content.split('\n');
657
+ const lineIndex = line - 1;
658
+ const beforeStart = Math.max(0, lineIndex - contextLines);
659
+ const afterEnd = Math.min(lines.length, lineIndex + contextLines + 1);
660
+ return {
661
+ startLine: beforeStart + 1,
662
+ endLine: afterEnd,
663
+ before: lines.slice(beforeStart, lineIndex),
664
+ line: lines[lineIndex] || '',
665
+ after: lines.slice(lineIndex + 1, afterEnd)
666
+ };
667
+ }
668
+ collectProjectDocs(projectInfo) {
669
+ const docs = [];
670
+ function walk(folder, folderPath) {
671
+ for (const doc of folder.docs || []) {
672
+ docs.push({
673
+ id: doc._id,
674
+ path: folderPath ? `${folderPath}/${doc.name}` : doc.name
675
+ });
676
+ }
677
+ for (const child of folder.folders || []) {
678
+ const childPath = folderPath ? `${folderPath}/${child.name}` : child.name;
679
+ walk(child, childPath);
680
+ }
681
+ }
682
+ for (const folder of projectInfo.rootFolder || []) {
683
+ walk(folder, '');
684
+ }
685
+ return docs;
686
+ }
687
+ async openProjectSocket(projectId) {
688
+ const handshakeUrl = `${this.baseUrl}/socket.io/1/?projectId=${encodeURIComponent(projectId)}&t=${Date.now()}`;
689
+ const handshakeResponse = await this.httpRequest(handshakeUrl, {
690
+ headers: { 'Cookie': this.getCookieHeader(), 'User-Agent': USER_AGENT },
691
+ expect: 'text',
692
+ timeoutMs: 5000
693
+ });
694
+ if (!handshakeResponse.ok) {
695
+ throw new Error(`Failed to open project socket: ${handshakeResponse.status}`);
696
+ }
697
+ this.applySetCookieHeaders(handshakeResponse.headers['set-cookie']);
698
+ const sid = handshakeResponse.body.trim().split(':')[0];
699
+ if (!sid) {
700
+ throw new Error('Failed to open project socket: missing session id');
701
+ }
702
+ const session = {
703
+ sid,
704
+ projectId,
705
+ pollUrl: () => `${this.baseUrl}/socket.io/1/xhr-polling/${sid}?projectId=${encodeURIComponent(projectId)}&t=${Date.now()}`
706
+ };
707
+ for (let attempt = 0; attempt < 8; attempt++) {
708
+ const packets = await this.pollProjectSocket(session);
709
+ if (packets.some(packet => {
710
+ if (!packet.startsWith('5:::'))
711
+ return false;
712
+ try {
713
+ return JSON.parse(packet.slice(4))?.name === 'joinProjectResponse';
714
+ }
715
+ catch {
716
+ return false;
717
+ }
718
+ })) {
719
+ return session;
720
+ }
721
+ }
722
+ throw new Error('Project socket did not return joinProjectResponse');
723
+ }
724
+ async pollProjectSocket(session) {
725
+ const response = await this.httpRequest(session.pollUrl(), {
726
+ headers: { 'Cookie': this.getCookieHeader(), 'User-Agent': USER_AGENT },
727
+ expect: 'text',
728
+ timeoutMs: 7000
729
+ });
730
+ if (!response.ok) {
731
+ throw new Error(`Socket poll failed: ${response.status}`);
732
+ }
733
+ this.applySetCookieHeaders(response.headers['set-cookie']);
734
+ const packets = this.decodeSocketIoPayload(response.body);
735
+ for (const packet of packets) {
736
+ if (packet.startsWith('2::')) {
737
+ const heartbeatResponse = await this.httpRequest(session.pollUrl(), {
738
+ method: 'POST',
739
+ headers: {
740
+ 'Cookie': this.getCookieHeader(),
741
+ 'User-Agent': USER_AGENT,
742
+ 'Content-Type': 'text/plain;charset=UTF-8'
743
+ },
744
+ body: '2::',
745
+ expect: 'text',
746
+ timeoutMs: 5000
747
+ });
748
+ this.applySetCookieHeaders(heartbeatResponse.headers['set-cookie']);
749
+ }
750
+ }
751
+ return packets;
752
+ }
753
+ async postProjectSocketPacket(session, packet) {
754
+ const response = await this.httpRequest(session.pollUrl(), {
755
+ method: 'POST',
756
+ headers: {
757
+ 'Cookie': this.getCookieHeader(),
758
+ 'User-Agent': USER_AGENT,
759
+ 'Content-Type': 'text/plain;charset=UTF-8'
760
+ },
761
+ body: packet,
762
+ expect: 'text',
763
+ timeoutMs: 5000
764
+ });
765
+ if (!response.ok) {
766
+ throw new Error(`Socket post failed: ${response.status}`);
767
+ }
768
+ this.applySetCookieHeaders(response.headers['set-cookie']);
769
+ }
770
+ async socketRpc(session, name, args) {
771
+ const id = Math.floor(Math.random() * 0x7fffffff);
772
+ await this.postProjectSocketPacket(session, this.encodeSocketIoEvent(id, name, args));
773
+ for (let attempt = 0; attempt < 10; attempt++) {
774
+ const packets = await this.pollProjectSocket(session);
775
+ for (const packet of packets) {
776
+ const ackArgs = this.parseSocketIoAck(packet, id);
777
+ if (ackArgs) {
778
+ const [error, ...result] = ackArgs;
779
+ if (error) {
780
+ const message = typeof error === 'string' ? error : error.message || JSON.stringify(error);
781
+ throw new Error(`${name} failed: ${message}`);
782
+ }
783
+ return result;
784
+ }
785
+ }
786
+ }
787
+ throw new Error(`${name} did not return an acknowledgement`);
788
+ }
789
+ async closeProjectSocket(session) {
790
+ try {
791
+ await this.postProjectSocketPacket(session, '0::');
792
+ }
793
+ catch {
794
+ // Best-effort socket cleanup only.
795
+ }
796
+ }
797
+ normalizeJoinedDocument(docId, args) {
798
+ const [lines, version, _updates, ranges, type = 'sharejs-text-ot'] = args;
799
+ if (type === 'history-ot') {
800
+ const content = typeof lines?.content === 'string' ? lines.content : '';
801
+ return {
802
+ docId,
803
+ lines: content.split('\n'),
804
+ content,
805
+ version,
806
+ ranges: lines,
807
+ type
808
+ };
809
+ }
810
+ const decodedLines = Array.isArray(lines)
811
+ ? lines.map((line) => this.decodeOverleafUtf8(line))
812
+ : [];
813
+ const decodedRanges = ranges || {};
814
+ for (const comment of decodedRanges.comments || []) {
815
+ if (comment?.op?.c) {
816
+ comment.op.c = this.decodeOverleafUtf8(comment.op.c);
817
+ }
818
+ }
819
+ return {
820
+ docId,
821
+ lines: decodedLines,
822
+ content: decodedLines.join('\n'),
823
+ version,
824
+ ranges: decodedRanges,
825
+ type
826
+ };
827
+ }
828
+ async joinDocument(session, docId) {
829
+ const args = await this.socketRpc(session, 'joinDoc', [
830
+ docId,
831
+ {
832
+ encodeRanges: true,
833
+ supportsHistoryOT: true
834
+ }
835
+ ]);
836
+ return this.normalizeJoinedDocument(docId, args);
837
+ }
838
+ /**
839
+ * Extract root folder ID from a Socket.IO event packet (joinProjectResponse).
840
+ */
841
+ extractRootFolderIdFromSocketPacket(packet) {
842
+ if (!packet.startsWith('5:::'))
843
+ return null;
844
+ try {
845
+ const payload = JSON.parse(packet.slice(4));
846
+ if (payload?.name !== 'joinProjectResponse')
847
+ return null;
848
+ const rootFolderId = payload?.args?.[0]?.project?.rootFolder?.[0]?._id;
849
+ return typeof rootFolderId === 'string' ? rootFolderId : null;
850
+ }
851
+ catch {
852
+ return null;
853
+ }
854
+ }
855
+ /**
856
+ * Extract full folder tree from a Socket.IO joinProjectResponse packet.
857
+ * Returns a map of folder path -> folder ID, e.g. { '': rootId, 'figures': figuresId }
858
+ */
859
+ extractFolderTreeFromSocketPacket(packet) {
860
+ if (!packet.startsWith('5:::'))
861
+ return null;
862
+ try {
863
+ const payload = JSON.parse(packet.slice(4));
864
+ if (payload?.name !== 'joinProjectResponse')
865
+ return null;
866
+ const rootFolder = payload?.args?.[0]?.project?.rootFolder?.[0];
867
+ if (!rootFolder?._id)
868
+ return null;
869
+ const folderMap = {};
870
+ function walkFolders(folder, currentPath) {
871
+ folderMap[currentPath] = folder._id;
872
+ for (const sub of folder.folders || []) {
873
+ const subPath = currentPath ? `${currentPath}/${sub.name}` : sub.name;
874
+ walkFolders(sub, subPath);
875
+ }
876
+ }
877
+ walkFolders(rootFolder, '');
878
+ return folderMap;
879
+ }
880
+ catch {
881
+ return null;
882
+ }
883
+ }
884
+ /**
885
+ * main problem to resolve root folder ID from Overleaf's collaboration join payload
886
+ * authoritative for projects where ObjectID arithmetic does not apply
887
+ */
888
+ async getRootFolderIdFromSocket(projectId) {
889
+ let sid = null;
890
+ try {
891
+ const handshakeUrl = `${this.baseUrl}/socket.io/1/?projectId=${encodeURIComponent(projectId)}&t=${Date.now()}`;
892
+ const handshakeResponse = await this.httpRequest(handshakeUrl, {
893
+ headers: {
894
+ 'Cookie': this.getCookieHeader(),
895
+ 'User-Agent': USER_AGENT
896
+ },
897
+ expect: 'text',
898
+ timeoutMs: 5000
899
+ });
900
+ if (!handshakeResponse.ok)
901
+ return null;
902
+ this.applySetCookieHeaders(handshakeResponse.headers['set-cookie']);
903
+ const handshakeBody = handshakeResponse.body.trim();
904
+ sid = handshakeBody.split(':')[0];
905
+ if (!sid)
906
+ return null;
907
+ const buildPollUrl = () => `${this.baseUrl}/socket.io/1/xhr-polling/${sid}?projectId=${encodeURIComponent(projectId)}&t=${Date.now()}`;
908
+ let discoveredRootFolderId = null;
909
+ // poll a few frames, first is usually connect ack, next includes joinProjectResponse
910
+ for (let attempt = 0; attempt < 3; attempt++) {
911
+ const pollResponse = await this.httpRequest(buildPollUrl(), {
912
+ headers: {
913
+ 'Cookie': this.getCookieHeader(),
914
+ 'User-Agent': USER_AGENT
915
+ },
916
+ expect: 'text',
917
+ timeoutMs: 5000
918
+ });
919
+ if (!pollResponse.ok)
920
+ return null;
921
+ this.applySetCookieHeaders(pollResponse.headers['set-cookie']);
922
+ const payload = pollResponse.body;
923
+ const packets = this.decodeSocketIoPayload(payload);
924
+ for (const packet of packets) {
925
+ const rootFolderId = this.extractRootFolderIdFromSocketPacket(packet);
926
+ if (rootFolderId) {
927
+ discoveredRootFolderId = rootFolderId;
928
+ break;
929
+ }
930
+ if (packet.startsWith('2::')) {
931
+ //reply to heartbeat to keep polling transport alive
932
+ const heartbeatResponse = await this.httpRequest(buildPollUrl(), {
933
+ method: 'POST',
934
+ headers: {
935
+ 'Cookie': this.getCookieHeader(),
936
+ 'User-Agent': USER_AGENT,
937
+ 'Content-Type': 'text/plain;charset=UTF-8'
938
+ },
939
+ body: '2::',
940
+ expect: 'text',
941
+ timeoutMs: 5000
942
+ });
943
+ this.applySetCookieHeaders(heartbeatResponse.headers['set-cookie']);
944
+ }
945
+ }
946
+ if (discoveredRootFolderId) {
947
+ return discoveredRootFolderId;
948
+ }
949
+ }
950
+ }
951
+ catch {
952
+ // Fall back to non-socket methods.
953
+ }
954
+ finally {
955
+ if (sid) {
956
+ try {
957
+ const disconnectUrl = `${this.baseUrl}/socket.io/1/xhr-polling/${sid}?projectId=${encodeURIComponent(projectId)}&t=${Date.now()}`;
958
+ const disconnectResponse = await this.httpRequest(disconnectUrl, {
959
+ method: 'POST',
960
+ headers: {
961
+ 'Cookie': this.getCookieHeader(),
962
+ 'User-Agent': USER_AGENT,
963
+ 'Content-Type': 'text/plain;charset=UTF-8'
964
+ },
965
+ body: '0::',
966
+ expect: 'text',
967
+ timeoutMs: 5000
968
+ });
969
+ this.applySetCookieHeaders(disconnectResponse.headers['set-cookie']);
970
+ }
971
+ catch {
972
+ // Ignore cleanup failures.
973
+ }
974
+ }
975
+ }
976
+ return null;
977
+ }
978
+ /**
979
+ * Get full folder tree for a project via Socket.IO.
980
+ * Returns a map of folder path -> folder ID, e.g. { '': rootId, 'figures': figuresId }
981
+ */
982
+ async getFolderTreeFromSocket(projectId) {
983
+ let sid = null;
984
+ try {
985
+ const handshakeUrl = `${this.baseUrl}/socket.io/1/?projectId=${encodeURIComponent(projectId)}&t=${Date.now()}`;
986
+ const handshakeResponse = await this.httpRequest(handshakeUrl, {
987
+ headers: {
988
+ 'Cookie': this.getCookieHeader(),
989
+ 'User-Agent': USER_AGENT
990
+ },
991
+ expect: 'text',
992
+ timeoutMs: 5000
993
+ });
994
+ if (!handshakeResponse.ok)
995
+ return null;
996
+ this.applySetCookieHeaders(handshakeResponse.headers['set-cookie']);
997
+ const handshakeBody = handshakeResponse.body.trim();
998
+ sid = handshakeBody.split(':')[0];
999
+ if (!sid)
1000
+ return null;
1001
+ const buildPollUrl = () => `${this.baseUrl}/socket.io/1/xhr-polling/${sid}?projectId=${encodeURIComponent(projectId)}&t=${Date.now()}`;
1002
+ for (let attempt = 0; attempt < 3; attempt++) {
1003
+ const pollResponse = await this.httpRequest(buildPollUrl(), {
1004
+ headers: {
1005
+ 'Cookie': this.getCookieHeader(),
1006
+ 'User-Agent': USER_AGENT
1007
+ },
1008
+ expect: 'text',
1009
+ timeoutMs: 5000
1010
+ });
1011
+ if (!pollResponse.ok)
1012
+ return null;
1013
+ this.applySetCookieHeaders(pollResponse.headers['set-cookie']);
1014
+ const payload = pollResponse.body;
1015
+ const packets = this.decodeSocketIoPayload(payload);
1016
+ for (const packet of packets) {
1017
+ const folderTree = this.extractFolderTreeFromSocketPacket(packet);
1018
+ if (folderTree)
1019
+ return folderTree;
1020
+ if (packet.startsWith('2::')) {
1021
+ const heartbeatResponse = await this.httpRequest(buildPollUrl(), {
1022
+ method: 'POST',
1023
+ headers: {
1024
+ 'Cookie': this.getCookieHeader(),
1025
+ 'User-Agent': USER_AGENT,
1026
+ 'Content-Type': 'text/plain;charset=UTF-8'
1027
+ },
1028
+ body: '2::',
1029
+ expect: 'text',
1030
+ timeoutMs: 5000
1031
+ });
1032
+ this.applySetCookieHeaders(heartbeatResponse.headers['set-cookie']);
1033
+ }
1034
+ }
1035
+ }
1036
+ }
1037
+ catch {
1038
+ // Fall back
1039
+ }
1040
+ finally {
1041
+ if (sid) {
1042
+ try {
1043
+ const disconnectUrl = `${this.baseUrl}/socket.io/1/xhr-polling/${sid}?projectId=${encodeURIComponent(projectId)}&t=${Date.now()}`;
1044
+ await this.httpRequest(disconnectUrl, {
1045
+ method: 'POST',
1046
+ headers: {
1047
+ 'Cookie': this.getCookieHeader(),
1048
+ 'User-Agent': USER_AGENT,
1049
+ 'Content-Type': 'text/plain;charset=UTF-8'
1050
+ },
1051
+ body: '0::',
1052
+ expect: 'text',
1053
+ timeoutMs: 5000
1054
+ });
1055
+ }
1056
+ catch {
1057
+ // Ignore cleanup failures.
1058
+ }
1059
+ }
1060
+ }
1061
+ return null;
1062
+ }
1063
+ /**
1064
+ * Resolve a folder path to a folder ID, creating missing folders as needed.
1065
+ * folderTree is a map of path -> ID (fetched once per push session).
1066
+ * folderPath is e.g. 'figures' or 'a/b/c'.
1067
+ */
1068
+ async resolveFolderId(projectId, folderTree, folderPath) {
1069
+ if (!folderPath || folderPath === '')
1070
+ return folderTree[''];
1071
+ if (folderTree[folderPath])
1072
+ return folderTree[folderPath];
1073
+ // Create each missing segment
1074
+ const segments = folderPath.split('/');
1075
+ let currentPath = '';
1076
+ for (const segment of segments) {
1077
+ const parentPath = currentPath;
1078
+ currentPath = currentPath ? `${currentPath}/${segment}` : segment;
1079
+ if (folderTree[currentPath])
1080
+ continue;
1081
+ const parentId = folderTree[parentPath];
1082
+ if (!parentId)
1083
+ throw new Error(`Cannot resolve parent folder for: ${currentPath}`);
1084
+ try {
1085
+ const newId = await this.createFolder(projectId, parentId, segment);
1086
+ folderTree[currentPath] = newId;
1087
+ }
1088
+ catch (e) {
1089
+ if (e.message === 'Folder already exists') {
1090
+ // Folder exists but we don't have its ID - re-fetch tree
1091
+ const freshTree = await this.getFolderTreeFromSocket(projectId);
1092
+ if (freshTree?.[currentPath]) {
1093
+ folderTree[currentPath] = freshTree[currentPath];
1094
+ }
1095
+ else {
1096
+ throw new Error(`Folder '${currentPath}' exists but could not resolve its ID`);
1097
+ }
1098
+ }
1099
+ else {
1100
+ throw e;
1101
+ }
1102
+ }
1103
+ }
1104
+ return folderTree[folderPath];
1105
+ }
1106
+ /**
1107
+ * Get root folder ID for a project (tries multiple methods)
1108
+ */
1109
+ async getRootFolderId(projectId) {
1110
+ // Method 1: Try to get from project page meta tags
1111
+ try {
1112
+ const projectInfo = await this.getProjectInfo(projectId);
1113
+ if (projectInfo.rootFolder?.[0]?._id) {
1114
+ return projectInfo.rootFolder[0]._id;
1115
+ }
1116
+ }
1117
+ catch (e) {
1118
+ // Fall through to computed method
1119
+ }
1120
+ // Method 2: Ask collaboration socket (authoritative project tree)
1121
+ const socketRootFolderId = await this.getRootFolderIdFromSocket(projectId);
1122
+ if (socketRootFolderId) {
1123
+ return socketRootFolderId;
1124
+ }
1125
+ // Method 3: Compute from project ID (projectId - 1)
1126
+ return this.computeRootFolderId(projectId);
1127
+ }
1128
+ /**
1129
+ * Find root folder ID by probing multiple candidates
1130
+ * This handles cases where projectId - 1 doesn't work
1131
+ */
1132
+ async probeRootFolderId(projectId) {
1133
+ const candidates = [];
1134
+ // Method 1: Try projectId - 1 (most common)
1135
+ candidates.push(this.computeRootFolderId(projectId));
1136
+ const prefix = projectId.slice(0, 16);
1137
+ const suffix = parseInt(projectId.slice(16), 16);
1138
+ // Method 2: Try a wide range around the project ID
1139
+ // Some projects have root folder created with different offsets
1140
+ for (let i = 2; i <= 50; i++) {
1141
+ if (suffix - i >= 0) {
1142
+ candidates.push(prefix + (suffix - i).toString(16).padStart(8, '0'));
1143
+ }
1144
+ }
1145
+ for (let i = 1; i <= 50; i++) {
1146
+ candidates.push(prefix + (suffix + i).toString(16).padStart(8, '0'));
1147
+ }
1148
+ // Test each candidate with a minimal probe request
1149
+ for (const folderId of candidates) {
1150
+ try {
1151
+ // Try to create a temp file to probe the folder
1152
+ const testFileName = `.olcli-probe-${Date.now()}.tmp`;
1153
+ const formData = new FormData();
1154
+ formData.append('targetFolderId', folderId);
1155
+ formData.append('name', testFileName);
1156
+ formData.append('type', 'text/plain');
1157
+ formData.append('qqfile', new Blob(['probe']), testFileName);
1158
+ const response = await this.httpRequest(`${this.uploadUrl(projectId)}?folder_id=${folderId}`, {
1159
+ method: 'POST',
1160
+ headers: {
1161
+ 'Cookie': this.getCookieHeader(),
1162
+ 'User-Agent': USER_AGENT,
1163
+ 'X-Csrf-Token': this.csrf
1164
+ },
1165
+ body: formData,
1166
+ expect: 'json'
1167
+ });
1168
+ if (!response.ok) {
1169
+ continue;
1170
+ }
1171
+ this.applySetCookieHeaders(response.headers['set-cookie']);
1172
+ const data = response.body;
1173
+ if (data.success !== false && data.entity_id) {
1174
+ // Success! Delete the probe file and return this folder ID
1175
+ try {
1176
+ await this.deleteEntity(projectId, data.entity_id, 'doc');
1177
+ }
1178
+ catch (e) {
1179
+ // Ignore delete errors for probe file
1180
+ }
1181
+ return folderId;
1182
+ }
1183
+ }
1184
+ catch (e) {
1185
+ // Continue to next candidate
1186
+ }
1187
+ }
1188
+ return null;
1189
+ }
1190
+ /**
1191
+ * Upload a file to a project.
1192
+ * If folderTree is provided and fileName contains a path (e.g. 'figures/img.png'),
1193
+ * the file will be uploaded into the correct subfolder, creating it if needed.
1194
+ */
1195
+ async uploadFile(projectId, folderId, fileName, content, folderTree) {
1196
+ // Extract just the filename without path
1197
+ const baseName = fileName.split('/').pop() || fileName;
1198
+ // Resolve target folder: if fileName has a directory part, place the file there.
1199
+ // Lazy-load + cache the folder tree when caller didn't supply one, so external
1200
+ // callers (and our own `upload`/`sync` paths) don't silently dump files into root.
1201
+ // See: https://github.com/aloth/olcli/issues/22 follow-up + 0.3.1 upload-fix.
1202
+ const dirPart = fileName.includes('/') ? fileName.split('/').slice(0, -1).join('/') : '';
1203
+ let targetFolderId;
1204
+ if (dirPart) {
1205
+ const tree = folderTree || await this.getOrLoadFolderTree(projectId);
1206
+ targetFolderId = await this.resolveFolderId(projectId, tree, dirPart);
1207
+ }
1208
+ else {
1209
+ targetFolderId = folderId || await this.getRootFolderId(projectId);
1210
+ }
1211
+ // Determine MIME type
1212
+ const ext = baseName.split('.').pop()?.toLowerCase() || '';
1213
+ const mimeTypes = {
1214
+ 'tex': 'text/x-tex',
1215
+ 'bib': 'text/x-bibtex',
1216
+ 'cls': 'text/x-tex',
1217
+ 'sty': 'text/x-tex',
1218
+ 'png': 'image/png',
1219
+ 'jpg': 'image/jpeg',
1220
+ 'jpeg': 'image/jpeg',
1221
+ 'gif': 'image/gif',
1222
+ 'pdf': 'application/pdf',
1223
+ 'svg': 'image/svg+xml',
1224
+ 'eps': 'application/postscript'
1225
+ };
1226
+ const mimeType = mimeTypes[ext] || 'application/octet-stream';
1227
+ // Helper function to attempt upload with a specific folder ID
1228
+ const tryUpload = async (fid) => {
1229
+ const formData = new FormData();
1230
+ formData.append('targetFolderId', fid);
1231
+ formData.append('name', baseName);
1232
+ formData.append('type', mimeType);
1233
+ const uploadBytes = new Uint8Array(content.byteLength);
1234
+ uploadBytes.set(content);
1235
+ formData.append('qqfile', new Blob([uploadBytes]), baseName);
1236
+ const response = await this.httpRequest(`${this.uploadUrl(projectId)}?folder_id=${encodeURIComponent(fid)}`, {
1237
+ method: 'POST',
1238
+ headers: {
1239
+ 'Cookie': this.getCookieHeader(),
1240
+ 'User-Agent': USER_AGENT,
1241
+ 'X-Csrf-Token': this.csrf
1242
+ },
1243
+ body: formData,
1244
+ expect: 'text'
1245
+ });
1246
+ if (!response.ok) {
1247
+ const text = response.body;
1248
+ // Overleaf returns folder_not_found as HTTP 422 JSON.
1249
+ // Parse the body first so caller can trigger folder probing fallback.
1250
+ try {
1251
+ const data = JSON.parse(text);
1252
+ if (data?.error === 'folder_not_found') {
1253
+ return { success: false, error: 'folder_not_found' };
1254
+ }
1255
+ }
1256
+ catch (e) {
1257
+ // Ignore non-JSON responses and return generic HTTP error below.
1258
+ }
1259
+ return { success: false, error: `${response.status} - ${text}` };
1260
+ }
1261
+ this.applySetCookieHeaders(response.headers['set-cookie']);
1262
+ const data = JSON.parse(response.body);
1263
+ if (data.success === false && data.error === 'folder_not_found') {
1264
+ return { success: false, error: 'folder_not_found' };
1265
+ }
1266
+ return {
1267
+ success: data.success !== false,
1268
+ entityId: data.entity_id,
1269
+ entityType: data.entity_type
1270
+ };
1271
+ };
1272
+ // First attempt with computed/cached folder ID
1273
+ let result = await tryUpload(targetFolderId);
1274
+ // If cached folder ID is stale, re-resolve root folder ID and retry once.
1275
+ if (!result.success && result.error === 'folder_not_found') {
1276
+ const refreshedRootFolderId = await this.getRootFolderId(projectId);
1277
+ if (refreshedRootFolderId !== targetFolderId) {
1278
+ targetFolderId = refreshedRootFolderId;
1279
+ result = await tryUpload(targetFolderId);
1280
+ }
1281
+ }
1282
+ // If folder is still unresolved, probe for a valid root folder ID
1283
+ if (!result.success && result.error === 'folder_not_found') {
1284
+ const probedFolderId = await this.probeRootFolderId(projectId);
1285
+ if (probedFolderId && probedFolderId !== targetFolderId) {
1286
+ targetFolderId = probedFolderId;
1287
+ result = await tryUpload(targetFolderId);
1288
+ }
1289
+ }
1290
+ if (!result.success) {
1291
+ throw new Error(`Failed to upload file: ${result.error || 'unknown error'}`);
1292
+ }
1293
+ return {
1294
+ success: result.success,
1295
+ entityId: result.entityId,
1296
+ entityType: result.entityType
1297
+ };
1298
+ }
1299
+ /**
1300
+ * Delete a file or folder
1301
+ */
1302
+ async deleteEntity(projectId, entityId, entityType) {
1303
+ const url = this.deleteUrl(projectId, entityType, entityId);
1304
+ const response = await this.httpRequest(url, {
1305
+ method: 'DELETE',
1306
+ headers: this.getHeaders(),
1307
+ expect: 'text'
1308
+ });
1309
+ if (!response.ok) {
1310
+ throw new Error(`Failed to delete entity: ${response.status}`);
1311
+ }
1312
+ this.applySetCookieHeaders(response.headers['set-cookie']);
1313
+ }
1314
+ /**
1315
+ * Get list of entities (files/docs) with paths
1316
+ */
1317
+ async getEntities(projectId) {
1318
+ const response = await this.httpRequest(`${this.baseUrl}/project/${projectId}/entities`, {
1319
+ headers: this.getHeaders(),
1320
+ expect: 'json'
1321
+ });
1322
+ if (!response.ok) {
1323
+ throw new Error(`Failed to get entities: ${response.status}`);
1324
+ }
1325
+ this.applySetCookieHeaders(response.headers['set-cookie']);
1326
+ const data = response.body;
1327
+ return data.entities || [];
1328
+ }
1329
+ /**
1330
+ * Find entity ID by path (searches through project file tree)
1331
+ */
1332
+ async findEntityByPath(projectId, targetPath) {
1333
+ const projectInfo = await this.getProjectInfo(projectId);
1334
+ const normalizedTarget = targetPath.replace(/^\//, '');
1335
+ function searchFolder(folder, currentPath) {
1336
+ // Check docs
1337
+ for (const doc of folder.docs || []) {
1338
+ const docPath = currentPath ? `${currentPath}/${doc.name}` : doc.name;
1339
+ if (docPath === normalizedTarget || doc.name === normalizedTarget) {
1340
+ return { id: doc._id, type: 'doc', name: doc.name };
1341
+ }
1342
+ }
1343
+ // Check files
1344
+ for (const file of folder.fileRefs || []) {
1345
+ const filePath = currentPath ? `${currentPath}/${file.name}` : file.name;
1346
+ if (filePath === normalizedTarget || file.name === normalizedTarget) {
1347
+ return { id: file._id, type: 'file', name: file.name };
1348
+ }
1349
+ }
1350
+ // Check subfolders
1351
+ for (const subfolder of folder.folders || []) {
1352
+ const folderPath = currentPath ? `${currentPath}/${subfolder.name}` : subfolder.name;
1353
+ if (folderPath === normalizedTarget || subfolder.name === normalizedTarget) {
1354
+ return { id: subfolder._id, type: 'folder', name: subfolder.name };
1355
+ }
1356
+ const found = searchFolder(subfolder, folderPath);
1357
+ if (found)
1358
+ return found;
1359
+ }
1360
+ return null;
1361
+ }
1362
+ if (projectInfo.rootFolder?.[0]) {
1363
+ return searchFolder(projectInfo.rootFolder[0], '');
1364
+ }
1365
+ return null;
1366
+ }
1367
+ /**
1368
+ * Download a single file by ID
1369
+ */
1370
+ async downloadFile(projectId, fileId, fileType) {
1371
+ const endpoint = fileType === 'doc' ? 'doc' : 'file';
1372
+ const response = await this.httpRequest(`${this.baseUrl}/project/${projectId}/${endpoint}/${fileId}`, {
1373
+ headers: this.getHeaders(),
1374
+ expect: fileType === 'doc' ? 'json' : 'buffer'
1375
+ });
1376
+ if (!response.ok) {
1377
+ throw new Error(`Failed to download file: ${response.status}`);
1378
+ }
1379
+ this.applySetCookieHeaders(response.headers['set-cookie']);
1380
+ if (fileType === 'doc') {
1381
+ // Docs return JSON with lines array
1382
+ const data = response.body;
1383
+ const content = (data.lines || []).join('\n');
1384
+ return Buffer.from(content, 'utf-8');
1385
+ }
1386
+ else {
1387
+ return response.body;
1388
+ }
1389
+ }
1390
+ /**
1391
+ * Rename a file, doc, or folder
1392
+ */
1393
+ async renameEntity(projectId, entityId, entityType, newName) {
1394
+ const response = await this.httpRequest(`${this.baseUrl}/project/${projectId}/${entityType}/${entityId}/rename`, {
1395
+ method: 'POST',
1396
+ headers: this.getHeaders(true),
1397
+ body: JSON.stringify({ name: newName }),
1398
+ expect: 'text'
1399
+ });
1400
+ if (!response.ok) {
1401
+ throw new Error(`Failed to rename entity: ${response.status}`);
1402
+ }
1403
+ this.applySetCookieHeaders(response.headers['set-cookie']);
1404
+ }
1405
+ /**
1406
+ * Delete a file by path
1407
+ */
1408
+ async deleteByPath(projectId, path) {
1409
+ const entity = await this.findEntityByPath(projectId, path);
1410
+ if (!entity) {
1411
+ throw new Error(`File not found: ${path}`);
1412
+ }
1413
+ await this.deleteEntity(projectId, entity.id, entity.type);
1414
+ }
1415
+ /**
1416
+ * Rename a file by path
1417
+ */
1418
+ async renameByPath(projectId, oldPath, newName) {
1419
+ const entity = await this.findEntityByPath(projectId, oldPath);
1420
+ if (!entity) {
1421
+ throw new Error(`File not found: ${oldPath}`);
1422
+ }
1423
+ await this.renameEntity(projectId, entity.id, entity.type, newName);
1424
+ }
1425
+ /**
1426
+ * Download a file by path (uses zip as fallback if ID not available)
1427
+ */
1428
+ async downloadByPath(projectId, path) {
1429
+ const normalizedPath = path.replace(/^\//, '');
1430
+ // First check if file exists
1431
+ const entities = await this.getEntities(projectId);
1432
+ const entityExists = entities.find(e => e.path.replace(/^\//, '') === normalizedPath ||
1433
+ e.path === `/${normalizedPath}`);
1434
+ if (!entityExists) {
1435
+ throw new Error(`File not found: ${path}`);
1436
+ }
1437
+ // Try to find entity with ID for direct download
1438
+ try {
1439
+ const entity = await this.findEntityByPath(projectId, path);
1440
+ if (entity && entity.type !== 'folder') {
1441
+ if (entity.type === 'doc') {
1442
+ return await this.downloadDocFromSocket(projectId, entity.id);
1443
+ }
1444
+ return await this.downloadFile(projectId, entity.id, entity.type);
1445
+ }
1446
+ }
1447
+ catch (e) {
1448
+ // Fall through to zip method
1449
+ }
1450
+ // Fallback: download zip and extract the file
1451
+ const zipBuffer = await this.downloadProject(projectId);
1452
+ const AdmZip = (await import('adm-zip')).default;
1453
+ const zip = new AdmZip(zipBuffer);
1454
+ for (const entry of zip.getEntries()) {
1455
+ if (entry.entryName === normalizedPath || entry.entryName === path) {
1456
+ return entry.getData();
1457
+ }
1458
+ }
1459
+ throw new Error(`File not found in archive: ${path}`);
1460
+ }
1461
+ async downloadDocFromSocket(projectId, docId) {
1462
+ const session = await this.openProjectSocket(projectId);
1463
+ try {
1464
+ const joinedDoc = await this.joinDocument(session, docId);
1465
+ return Buffer.from(joinedDoc.content, 'utf8');
1466
+ }
1467
+ finally {
1468
+ await this.closeProjectSocket(session);
1469
+ }
1470
+ }
1471
+ async getCommentThreads(projectId) {
1472
+ const response = await this.httpRequest(`${this.baseUrl}/project/${projectId}/threads`, {
1473
+ headers: this.getHeaders(),
1474
+ expect: 'json'
1475
+ });
1476
+ if (!response.ok) {
1477
+ throw new Error(`Failed to fetch comment threads: ${response.status}`);
1478
+ }
1479
+ this.applySetCookieHeaders(response.headers['set-cookie']);
1480
+ return response.body;
1481
+ }
1482
+ async listComments(projectId, options = {}) {
1483
+ const status = options.status || 'all';
1484
+ const contextLines = options.contextLines || 0;
1485
+ const projectInfo = await this.getProjectInfo(projectId);
1486
+ const docs = this.collectProjectDocs(projectInfo);
1487
+ const threads = await this.getCommentThreads(projectId);
1488
+ const comments = [];
1489
+ const session = await this.openProjectSocket(projectId);
1490
+ try {
1491
+ for (const doc of docs) {
1492
+ const joinedDoc = await this.joinDocument(session, doc.id);
1493
+ if (joinedDoc.type === 'history-ot') {
1494
+ for (const comment of joinedDoc.ranges.comments || []) {
1495
+ const ranges = comment.ranges || [];
1496
+ const firstRange = ranges[0];
1497
+ if (!firstRange)
1498
+ continue;
1499
+ const selectedText = ranges
1500
+ .map((range) => joinedDoc.content.slice(range.pos, range.pos + range.length))
1501
+ .join('');
1502
+ const location = this.positionToLineColumn(joinedDoc.content, firstRange.pos);
1503
+ const thread = threads[comment.id] || { messages: [] };
1504
+ const resolved = Boolean(comment.resolved || thread.resolved);
1505
+ comments.push({
1506
+ threadId: comment.id,
1507
+ docId: doc.id,
1508
+ path: doc.path,
1509
+ position: firstRange.pos,
1510
+ line: location.line,
1511
+ column: location.column,
1512
+ selectedText,
1513
+ resolved,
1514
+ messages: thread.messages || [],
1515
+ context: this.buildCommentContext(joinedDoc.content, location.line, contextLines)
1516
+ });
1517
+ }
1518
+ continue;
1519
+ }
1520
+ for (const comment of joinedDoc.ranges.comments || []) {
1521
+ const op = comment.op || {};
1522
+ const threadId = op.t || comment.id;
1523
+ if (!threadId || typeof op.p !== 'number')
1524
+ continue;
1525
+ const selectedText = typeof op.c === 'string'
1526
+ ? op.c
1527
+ : joinedDoc.content.slice(op.p, op.p + (op.c?.length || 0));
1528
+ const location = this.positionToLineColumn(joinedDoc.content, op.p);
1529
+ const thread = threads[threadId] || { messages: [] };
1530
+ const resolved = Boolean(comment.resolved || op.resolved || thread.resolved);
1531
+ comments.push({
1532
+ threadId,
1533
+ docId: doc.id,
1534
+ path: doc.path,
1535
+ position: op.p,
1536
+ line: location.line,
1537
+ column: location.column,
1538
+ selectedText,
1539
+ resolved,
1540
+ messages: thread.messages || [],
1541
+ context: this.buildCommentContext(joinedDoc.content, location.line, contextLines)
1542
+ });
1543
+ }
1544
+ }
1545
+ }
1546
+ finally {
1547
+ await this.closeProjectSocket(session);
1548
+ }
1549
+ return comments
1550
+ .filter(comment => {
1551
+ if (status === 'all')
1552
+ return true;
1553
+ return status === 'resolved' ? comment.resolved : !comment.resolved;
1554
+ })
1555
+ .sort((a, b) => a.path.localeCompare(b.path) || a.position - b.position);
1556
+ }
1557
+ async resolveComment(projectId, threadId) {
1558
+ const comment = await this.findComment(projectId, threadId);
1559
+ const response = await this.httpRequest(`${this.baseUrl}/project/${projectId}/doc/${comment.docId}/thread/${threadId}/resolve`, {
1560
+ method: 'POST',
1561
+ headers: this.getHeaders(true),
1562
+ body: '',
1563
+ expect: 'text'
1564
+ });
1565
+ if (!response.ok) {
1566
+ throw new Error(`Failed to resolve comment: ${response.status}`);
1567
+ }
1568
+ this.applySetCookieHeaders(response.headers['set-cookie']);
1569
+ return comment;
1570
+ }
1571
+ async reopenComment(projectId, threadId) {
1572
+ const comment = await this.findComment(projectId, threadId);
1573
+ const response = await this.httpRequest(`${this.baseUrl}/project/${projectId}/doc/${comment.docId}/thread/${threadId}/reopen`, {
1574
+ method: 'POST',
1575
+ headers: this.getHeaders(true),
1576
+ body: '',
1577
+ expect: 'text'
1578
+ });
1579
+ if (!response.ok) {
1580
+ throw new Error(`Failed to reopen comment: ${response.status}`);
1581
+ }
1582
+ this.applySetCookieHeaders(response.headers['set-cookie']);
1583
+ return comment;
1584
+ }
1585
+ async deleteComment(projectId, threadId) {
1586
+ const comment = await this.findComment(projectId, threadId);
1587
+ const response = await this.httpRequest(`${this.baseUrl}/project/${projectId}/doc/${comment.docId}/thread/${threadId}`, {
1588
+ method: 'DELETE',
1589
+ headers: this.getHeaders(true),
1590
+ body: '',
1591
+ expect: 'text'
1592
+ });
1593
+ if (!response.ok) {
1594
+ throw new Error(`Failed to delete comment: ${response.status}`);
1595
+ }
1596
+ this.applySetCookieHeaders(response.headers['set-cookie']);
1597
+ return comment;
1598
+ }
1599
+ async findComment(projectId, threadId) {
1600
+ const comments = await this.listComments(projectId);
1601
+ const comment = comments.find(item => item.threadId === threadId);
1602
+ if (!comment) {
1603
+ throw new Error(`Comment thread not found: ${threadId}`);
1604
+ }
1605
+ return comment;
1606
+ }
1607
+ resolveCommentSelection(doc, options) {
1608
+ if (options.selectedText) {
1609
+ const occurrence = options.occurrence || 1;
1610
+ let fromIndex = 0;
1611
+ let position = -1;
1612
+ for (let index = 0; index < occurrence; index++) {
1613
+ position = doc.content.indexOf(options.selectedText, fromIndex);
1614
+ if (position === -1)
1615
+ break;
1616
+ fromIndex = position + options.selectedText.length;
1617
+ }
1618
+ if (position === -1) {
1619
+ throw new Error(`Selected text not found in ${options.filePath}`);
1620
+ }
1621
+ return { position, selectedText: options.selectedText };
1622
+ }
1623
+ let position = options.position;
1624
+ if (position == null) {
1625
+ if (options.line == null || options.column == null) {
1626
+ throw new Error('Add comment requires either --text, --position, or both --line and --column');
1627
+ }
1628
+ const lines = doc.content.split('\n');
1629
+ if (options.line < 1 || options.line > lines.length) {
1630
+ throw new Error(`Line out of range: ${options.line}`);
1631
+ }
1632
+ if (options.column < 1 || options.column > lines[options.line - 1].length + 1) {
1633
+ throw new Error(`Column out of range: ${options.column}`);
1634
+ }
1635
+ position = lines.slice(0, options.line - 1).reduce((sum, line) => sum + line.length + 1, 0) + options.column - 1;
1636
+ }
1637
+ const length = options.length || 1;
1638
+ if (position < 0 || position + length > doc.content.length) {
1639
+ throw new Error('Comment selection is outside the document');
1640
+ }
1641
+ return {
1642
+ position,
1643
+ selectedText: doc.content.slice(position, position + length)
1644
+ };
1645
+ }
1646
+ async addComment(projectId, options) {
1647
+ const projectInfo = await this.getProjectInfo(projectId);
1648
+ const docs = this.collectProjectDocs(projectInfo);
1649
+ const normalizedPath = options.filePath.replace(/^\//, '');
1650
+ const doc = docs.find(item => item.path === normalizedPath || item.path.replace(/^\//, '') === normalizedPath);
1651
+ if (!doc) {
1652
+ throw new Error(`Doc not found: ${options.filePath}`);
1653
+ }
1654
+ const session = await this.openProjectSocket(projectId);
1655
+ try {
1656
+ const joinedDoc = await this.joinDocument(session, doc.id);
1657
+ const selection = this.resolveCommentSelection(joinedDoc, options);
1658
+ const threadId = this.generateCommentThreadId();
1659
+ await this.postCommentMessage(projectId, threadId, options.content);
1660
+ const op = joinedDoc.type === 'history-ot'
1661
+ ? {
1662
+ commentId: threadId,
1663
+ ranges: [{ pos: selection.position, length: selection.selectedText.length }]
1664
+ }
1665
+ : {
1666
+ c: selection.selectedText,
1667
+ p: selection.position,
1668
+ t: threadId
1669
+ };
1670
+ await this.socketRpc(session, 'applyOtUpdate', [doc.id, {
1671
+ doc: doc.id,
1672
+ op: [op],
1673
+ v: joinedDoc.version
1674
+ }]);
1675
+ const location = this.positionToLineColumn(joinedDoc.content, selection.position);
1676
+ return {
1677
+ threadId,
1678
+ docId: doc.id,
1679
+ path: doc.path,
1680
+ position: selection.position,
1681
+ line: location.line,
1682
+ column: location.column,
1683
+ selectedText: selection.selectedText,
1684
+ resolved: false,
1685
+ messages: []
1686
+ };
1687
+ }
1688
+ finally {
1689
+ await this.closeProjectSocket(session);
1690
+ }
1691
+ }
1692
+ async postCommentMessage(projectId, threadId, content) {
1693
+ const response = await this.httpRequest(`${this.baseUrl}/project/${projectId}/thread/${threadId}/messages`, {
1694
+ method: 'POST',
1695
+ headers: this.getHeaders(true),
1696
+ body: JSON.stringify({ content }),
1697
+ expect: 'text'
1698
+ });
1699
+ if (!response.ok) {
1700
+ throw new Error(`Failed to post comment message: ${response.status}`);
1701
+ }
1702
+ this.applySetCookieHeaders(response.headers['set-cookie']);
1703
+ if (!response.body)
1704
+ return null;
1705
+ try {
1706
+ return JSON.parse(response.body);
1707
+ }
1708
+ catch {
1709
+ return null;
1710
+ }
1711
+ }
1712
+ /**
1713
+ * Compile project and get all output files
1714
+ */
1715
+ async compileWithOutputs(projectId, options = {}) {
1716
+ const response = await this.httpRequest(this.compileUrl(projectId), {
1717
+ method: 'POST',
1718
+ headers: this.getHeaders(true),
1719
+ body: JSON.stringify({
1720
+ rootDoc_id: null,
1721
+ draft: options.draft ?? false,
1722
+ check: 'silent',
1723
+ incrementalCompilesEnabled: true
1724
+ }),
1725
+ timeoutMs: options.timeoutMs,
1726
+ expect: 'json'
1727
+ });
1728
+ if (!response.ok) {
1729
+ throw new Error(`Failed to compile project: ${response.status}`);
1730
+ }
1731
+ this.applySetCookieHeaders(response.headers['set-cookie']);
1732
+ const data = response.body;
1733
+ // Prefer 'output.pdf' (the main compile output) over any other PDF.
1734
+ // See #26 — projects with figure PDFs could return the wrong file.
1735
+ const pdfFile = data.outputFiles?.find((f) => f.path === 'output.pdf')
1736
+ || data.outputFiles?.find((f) => f.type === 'pdf');
1737
+ // Overleaf's CDN requires ?clsiserverid=<id> for build-output downloads.
1738
+ // Without it every output (pdf/log/bbl/...) 404s. See issue #22.
1739
+ const qs = data.clsiServerId ? `?clsiserverid=${encodeURIComponent(data.clsiServerId)}` : '';
1740
+ return {
1741
+ status: data.status,
1742
+ pdfUrl: pdfFile ? `${this.baseUrl}${pdfFile.url}${qs}` : undefined,
1743
+ outputFiles: (data.outputFiles || []).map((f) => ({
1744
+ path: f.path,
1745
+ type: f.type,
1746
+ url: `${this.baseUrl}${f.url}${qs}`
1747
+ }))
1748
+ };
1749
+ }
1750
+ /**
1751
+ * Download a compile output file (logs, bbl, aux, etc.)
1752
+ */
1753
+ async downloadOutputFile(url) {
1754
+ return this.downloadBuffer(url);
1755
+ }
1756
+ }
1757
+ //# sourceMappingURL=client.js.map