diff --git a/CHANGELOG.md b/CHANGELOG.md index ed4cc13..918e6a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to this project will be documented in this file. +## [Unreleased] + +### Changed +- **`olcli comments list` (and `resolve`, `reopen`, `delete`, which look the thread up first) joins docs over four sockets instead of one doc at a time** - the command joined every doc in the project serially at about 0.25 s each, so a 43-doc project took around 14 s. One socket cannot overlap requests, since the long-poll gives an acknowledgment to whichever request polls first and the others time out, so the docs are spread across a small pool of sockets instead + ## [0.13.1] - 2026-10-09 ### Fixed diff --git a/src/client.ts b/src/client.ts index 4dbd3fe..94f6427 100644 --- a/src/client.ts +++ b/src/client.ts @@ -2049,6 +2049,30 @@ export class OverleafClient { return response.body as Record; } + /** + * Join every doc over a small pool of sockets. One socket cannot overlap + * requests: its long-poll hands an ack to whichever request polls first, so + * concurrent joinDoc calls on a shared session time out. + */ + private async joinDocuments(projectId: string, docs: ProjectDoc[], concurrency = 4): Promise { + const joined: JoinedDocument[] = new Array(docs.length); + let next = 0; + + await Promise.all(Array.from({ length: Math.min(concurrency, docs.length) }, async () => { + const session = await this.openProjectSocket(projectId); + try { + while (next < docs.length) { + const index = next++; + joined[index] = await this.joinDocument(session, docs[index].id); + } + } finally { + await this.closeProjectSocket(session); + } + })); + + return joined; + } + async listComments(projectId: string, options: ListCommentsOptions = {}): Promise { const status = options.status || 'all'; const contextLines = options.contextLines || 0; @@ -2056,54 +2080,27 @@ export class OverleafClient { const docs = this.collectProjectDocs(projectInfo); const threads = await this.getCommentThreads(projectId); const comments: ProjectComment[] = []; - const session = await this.openProjectSocket(projectId); + const joinedDocs = await this.joinDocuments(projectId, docs); - try { - for (const doc of docs) { - const joinedDoc = await this.joinDocument(session, doc.id); - - if (joinedDoc.type === 'history-ot') { - for (const comment of joinedDoc.ranges.comments || []) { - const ranges = comment.ranges || []; - const firstRange = ranges[0]; - if (!firstRange) continue; - const selectedText = ranges - .map((range: any) => joinedDoc.content.slice(range.pos, range.pos + range.length)) - .join(''); - const location = this.positionToLineColumn(joinedDoc.content, firstRange.pos); - const thread = threads[comment.id] || { messages: [] }; - const resolved = Boolean(comment.resolved || thread.resolved); - comments.push({ - threadId: comment.id, - docId: doc.id, - path: doc.path, - position: firstRange.pos, - line: location.line, - column: location.column, - selectedText, - resolved, - messages: thread.messages || [], - context: this.buildCommentContext(joinedDoc.content, location.line, contextLines) - }); - } - continue; - } + for (const [index, doc] of docs.entries()) { + const joinedDoc = joinedDocs[index]; + if (joinedDoc.type === 'history-ot') { for (const comment of joinedDoc.ranges.comments || []) { - const op = comment.op || {}; - const threadId = op.t || comment.id; - if (!threadId || typeof op.p !== 'number') continue; - const selectedText = typeof op.c === 'string' - ? op.c - : joinedDoc.content.slice(op.p, op.p + (op.c?.length || 0)); - const location = this.positionToLineColumn(joinedDoc.content, op.p); - const thread = threads[threadId] || { messages: [] }; - const resolved = Boolean(comment.resolved || op.resolved || thread.resolved); + const ranges = comment.ranges || []; + const firstRange = ranges[0]; + if (!firstRange) continue; + const selectedText = ranges + .map((range: any) => joinedDoc.content.slice(range.pos, range.pos + range.length)) + .join(''); + const location = this.positionToLineColumn(joinedDoc.content, firstRange.pos); + const thread = threads[comment.id] || { messages: [] }; + const resolved = Boolean(comment.resolved || thread.resolved); comments.push({ - threadId, + threadId: comment.id, docId: doc.id, path: doc.path, - position: op.p, + position: firstRange.pos, line: location.line, column: location.column, selectedText, @@ -2112,9 +2109,32 @@ export class OverleafClient { context: this.buildCommentContext(joinedDoc.content, location.line, contextLines) }); } + continue; + } + + for (const comment of joinedDoc.ranges.comments || []) { + const op = comment.op || {}; + const threadId = op.t || comment.id; + if (!threadId || typeof op.p !== 'number') continue; + const selectedText = typeof op.c === 'string' + ? op.c + : joinedDoc.content.slice(op.p, op.p + (op.c?.length || 0)); + const location = this.positionToLineColumn(joinedDoc.content, op.p); + const thread = threads[threadId] || { messages: [] }; + const resolved = Boolean(comment.resolved || op.resolved || thread.resolved); + comments.push({ + threadId, + docId: doc.id, + path: doc.path, + position: op.p, + line: location.line, + column: location.column, + selectedText, + resolved, + messages: thread.messages || [], + context: this.buildCommentContext(joinedDoc.content, location.line, contextLines) + }); } - } finally { - await this.closeProjectSocket(session); } return comments diff --git a/test/client.test.ts b/test/client.test.ts index 09b4fdf..27bcdf1 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -115,3 +115,41 @@ test('createProject reports HTTP errors and malformed success responses', async } ); }); + +test('listComments joins docs over several sockets, not one doc at a time on one socket', async () => { + const client: any = new OverleafClient({ cookies: { session: 'secret' }, csrf: 'csrf-token' }); + const docs = Array.from({ length: 10 }, (_, i) => ({ _id: `doc${i}`, name: `f${i}.tex` })); + client.getProjectInfo = async () => ({ rootFolder: [{ docs, folders: [] }] }); + client.getCommentThreads = async () => ({}); + + let open = 0; + const sessions = new Set(); + const joinedBy = new Map(); + client.openProjectSocket = async () => { + const session = {}; + sessions.add(session); + open++; + return session; + }; + client.closeProjectSocket = async () => { open--; }; + client.joinDocument = async (session: object, docId: string) => { + joinedBy.set(docId, session); + await new Promise(resolve => setTimeout(resolve, 5)); + const index = Number(docId.slice(3)); + return { + docId, + content: 'hello world', + lines: ['hello world'], + version: 1, + type: 'sharejs-text-ot', + ranges: index === 7 ? { comments: [{ op: { c: 'world', p: 6, t: 'thread7' } }] } : {} + }; + }; + + const comments = await client.listComments('project'); + + assert.equal(joinedBy.size, 10); + assert(sessions.size > 1 && sessions.size <= 4, `expected 2-4 sockets, got ${sessions.size}`); + assert.equal(open, 0, 'every socket is closed'); + assert.deepEqual(comments.map((c: any) => [c.threadId, c.path]), [['thread7', 'f7.tex']]); +});