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.
- package/LICENSE +21 -0
- package/NOTICE.md +25 -0
- package/README.md +217 -0
- package/assets/olcx-mark.svg +22 -0
- package/dist/auth/projectAuth.d.ts +19 -0
- package/dist/auth/projectAuth.js +163 -0
- package/dist/auth/projectAuth.js.map +1 -0
- package/dist/auth/redact.d.ts +3 -0
- package/dist/auth/redact.js +7 -0
- package/dist/auth/redact.js.map +1 -0
- package/dist/auth/types.d.ts +10 -0
- package/dist/auth/types.js +4 -0
- package/dist/auth/types.js.map +1 -0
- package/dist/backend/index.d.ts +6 -0
- package/dist/backend/index.js +2 -0
- package/dist/backend/index.js.map +1 -0
- package/dist/backend/olcli/client.d.ts +329 -0
- package/dist/backend/olcli/client.js +1757 -0
- package/dist/backend/olcli/client.js.map +1 -0
- package/dist/backend/olcli/index.d.ts +2 -0
- package/dist/backend/olcli/index.js +2 -0
- package/dist/backend/olcli/index.js.map +1 -0
- package/dist/backend/overleafBackend.d.ts +41 -0
- package/dist/backend/overleafBackend.js +200 -0
- package/dist/backend/overleafBackend.js.map +1 -0
- package/dist/backend/types.d.ts +73 -0
- package/dist/backend/types.js +2 -0
- package/dist/backend/types.js.map +1 -0
- package/dist/cli-behavior.d.ts +14 -0
- package/dist/cli-behavior.js +59 -0
- package/dist/cli-behavior.js.map +1 -0
- package/dist/cli.d.ts +30 -0
- package/dist/cli.js +441 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/auth.d.ts +21 -0
- package/dist/commands/auth.js +104 -0
- package/dist/commands/auth.js.map +1 -0
- package/dist/commands/compile.d.ts +7 -0
- package/dist/commands/compile.js +73 -0
- package/dist/commands/compile.js.map +1 -0
- package/dist/commands/doctor.d.ts +11 -0
- package/dist/commands/doctor.js +9 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/endpoint.d.ts +23 -0
- package/dist/commands/endpoint.js +69 -0
- package/dist/commands/endpoint.js.map +1 -0
- package/dist/commands/init.d.ts +14 -0
- package/dist/commands/init.js +48 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/status.d.ts +4 -0
- package/dist/commands/status.js +5 -0
- package/dist/commands/status.js.map +1 -0
- package/dist/commands/sync.d.ts +26 -0
- package/dist/commands/sync.js +139 -0
- package/dist/commands/sync.js.map +1 -0
- package/dist/commands/watch.d.ts +28 -0
- package/dist/commands/watch.js +124 -0
- package/dist/commands/watch.js.map +1 -0
- package/dist/compile/compileFlow.d.ts +32 -0
- package/dist/compile/compileFlow.js +290 -0
- package/dist/compile/compileFlow.js.map +1 -0
- package/dist/compile/pdfOutput.d.ts +12 -0
- package/dist/compile/pdfOutput.js +64 -0
- package/dist/compile/pdfOutput.js.map +1 -0
- package/dist/config/ignoreRules.d.ts +5 -0
- package/dist/config/ignoreRules.js +53 -0
- package/dist/config/ignoreRules.js.map +1 -0
- package/dist/config/overleafProject.d.ts +9 -0
- package/dist/config/overleafProject.js +61 -0
- package/dist/config/overleafProject.js.map +1 -0
- package/dist/config/projectConfig.d.ts +6 -0
- package/dist/config/projectConfig.js +180 -0
- package/dist/config/projectConfig.js.map +1 -0
- package/dist/config/projectRoot.d.ts +1 -0
- package/dist/config/projectRoot.js +36 -0
- package/dist/config/projectRoot.js.map +1 -0
- package/dist/config/types.d.ts +50 -0
- package/dist/config/types.js +34 -0
- package/dist/config/types.js.map +1 -0
- package/dist/config/vscode.d.ts +10 -0
- package/dist/config/vscode.js +134 -0
- package/dist/config/vscode.js.map +1 -0
- package/dist/diagnostics/doctor.d.ts +8 -0
- package/dist/diagnostics/doctor.js +209 -0
- package/dist/diagnostics/doctor.js.map +1 -0
- package/dist/diagnostics/status.d.ts +6 -0
- package/dist/diagnostics/status.js +110 -0
- package/dist/diagnostics/status.js.map +1 -0
- package/dist/diagnostics/types.d.ts +33 -0
- package/dist/diagnostics/types.js +2 -0
- package/dist/diagnostics/types.js.map +1 -0
- package/dist/endpoint/overleafEndpoint.d.ts +36 -0
- package/dist/endpoint/overleafEndpoint.js +105 -0
- package/dist/endpoint/overleafEndpoint.js.map +1 -0
- package/dist/errors.d.ts +32 -0
- package/dist/errors.js +53 -0
- package/dist/errors.js.map +1 -0
- package/dist/sync/apply.d.ts +14 -0
- package/dist/sync/apply.js +92 -0
- package/dist/sync/apply.js.map +1 -0
- package/dist/sync/conflicts.d.ts +7 -0
- package/dist/sync/conflicts.js +59 -0
- package/dist/sync/conflicts.js.map +1 -0
- package/dist/sync/ignore.d.ts +5 -0
- package/dist/sync/ignore.js +74 -0
- package/dist/sync/ignore.js.map +1 -0
- package/dist/sync/plan.d.ts +3 -0
- package/dist/sync/plan.js +197 -0
- package/dist/sync/plan.js.map +1 -0
- package/dist/sync/snapshot.d.ts +13 -0
- package/dist/sync/snapshot.js +82 -0
- package/dist/sync/snapshot.js.map +1 -0
- package/dist/sync/state.d.ts +16 -0
- package/dist/sync/state.js +214 -0
- package/dist/sync/state.js.map +1 -0
- package/dist/sync/types.d.ts +113 -0
- package/dist/sync/types.js +4 -0
- package/dist/sync/types.js.map +1 -0
- package/dist/testing/fakeBackend.d.ts +27 -0
- package/dist/testing/fakeBackend.js +213 -0
- package/dist/testing/fakeBackend.js.map +1 -0
- package/dist/watch/queue.d.ts +2 -0
- package/dist/watch/queue.js +91 -0
- package/dist/watch/queue.js.map +1 -0
- package/dist/watch/types.d.ts +52 -0
- package/dist/watch/types.js +2 -0
- package/dist/watch/types.js.map +1 -0
- package/dist/watch/watcher.d.ts +6 -0
- package/dist/watch/watcher.js +58 -0
- package/dist/watch/watcher.js.map +1 -0
- package/dist/watch/workflow.d.ts +30 -0
- package/dist/watch/workflow.js +62 -0
- package/dist/watch/workflow.js.map +1 -0
- package/docs/architecture.md +603 -0
- package/docs/auth.md +65 -0
- package/docs/cli-behavior.md +95 -0
- package/docs/compile.md +51 -0
- package/docs/design.md +82 -0
- package/docs/endpoint.md +84 -0
- package/docs/npm-packaging.md +148 -0
- package/docs/quickdev-queue-audit.md +193 -0
- package/docs/release-gates.md +119 -0
- package/docs/release-notes-v1.md +97 -0
- package/docs/security.md +61 -0
- package/docs/sync-state.md +305 -0
- package/docs/sync.md +50 -0
- package/docs/troubleshooting.md +124 -0
- package/docs/usage.md +184 -0
- package/examples/minimal-paper/.olcx/auth.local.example.json +7 -0
- package/examples/minimal-paper/.olcx/config.json +23 -0
- package/examples/minimal-paper/README.md +88 -0
- package/examples/minimal-paper/main.tex +23 -0
- package/package.json +66 -0
- package/src/backend/olcli/LICENSE +21 -0
- 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
|