Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
108 changes: 64 additions & 44 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2049,61 +2049,58 @@ export class OverleafClient {
return response.body as Record<string, { messages: CommentMessage[]; resolved?: boolean; resolved_at?: string; resolved_by_user_id?: string }>;
}

/**
* 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<JoinedDocument[]> {
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<ProjectComment[]> {
const status = options.status || 'all';
const contextLines = options.contextLines || 0;
const projectInfo = await this.getProjectInfo(projectId);
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,
Expand All @@ -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
Expand Down
38 changes: 38 additions & 0 deletions test/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<object>();
const joinedBy = new Map<string, object>();
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']]);
});