@aloth/olcli 0.3.1 → 0.4.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.
package/dist/client.js CHANGED
@@ -519,7 +519,11 @@ export class OverleafClient {
519
519
  if (data.status !== 'success') {
520
520
  throw new Error(`Compilation failed: ${data.status}`);
521
521
  }
522
- const pdfFile = data.outputFiles?.find((f) => f.type === 'pdf');
522
+ // Match by path 'output.pdf' — Overleaf's CLSI always names the main
523
+ // compile output 'output.pdf'. Matching only on type === 'pdf' can pick up
524
+ // figure PDFs or *-eps-converted-to.pdf intermediates. See #26.
525
+ const pdfFile = data.outputFiles?.find((f) => f.path === 'output.pdf')
526
+ || data.outputFiles?.find((f) => f.type === 'pdf');
523
527
  if (!pdfFile) {
524
528
  throw new Error('No PDF output found');
525
529
  }
@@ -604,6 +608,225 @@ export class OverleafClient {
604
608
  }
605
609
  return packets;
606
610
  }
611
+ encodeSocketIoEvent(id, name, args) {
612
+ return `5:${id}+::${JSON.stringify({ name, args })}`;
613
+ }
614
+ parseSocketIoAck(packet, id) {
615
+ const match = packet.match(/^6:::(\d+)(.*)$/);
616
+ if (!match || Number.parseInt(match[1], 10) !== id) {
617
+ return null;
618
+ }
619
+ let payload = match[2] || '';
620
+ if (payload.startsWith('+')) {
621
+ payload = payload.slice(1);
622
+ }
623
+ if (!payload)
624
+ return [];
625
+ const args = JSON.parse(payload);
626
+ return Array.isArray(args) ? args : [args];
627
+ }
628
+ decodeOverleafUtf8(text) {
629
+ return Buffer.from(text, 'binary').toString('utf-8');
630
+ }
631
+ generateCommentThreadId() {
632
+ const timestamp = Math.floor(Date.now() / 1000).toString(16).padStart(8, '0');
633
+ const machine = Math.floor(Math.random() * 0xffffff).toString(16).padStart(6, '0');
634
+ const pid = Math.floor(Math.random() * 0x7fff).toString(16).padStart(4, '0');
635
+ return `${timestamp}${machine}${pid}000001`;
636
+ }
637
+ positionToLineColumn(content, position) {
638
+ const prefix = content.slice(0, position);
639
+ const lines = prefix.split('\n');
640
+ return {
641
+ line: lines.length,
642
+ column: lines[lines.length - 1].length + 1
643
+ };
644
+ }
645
+ buildCommentContext(content, line, contextLines = 0) {
646
+ if (contextLines <= 0)
647
+ return undefined;
648
+ const lines = content.split('\n');
649
+ const lineIndex = line - 1;
650
+ const beforeStart = Math.max(0, lineIndex - contextLines);
651
+ const afterEnd = Math.min(lines.length, lineIndex + contextLines + 1);
652
+ return {
653
+ startLine: beforeStart + 1,
654
+ endLine: afterEnd,
655
+ before: lines.slice(beforeStart, lineIndex),
656
+ line: lines[lineIndex] || '',
657
+ after: lines.slice(lineIndex + 1, afterEnd)
658
+ };
659
+ }
660
+ collectProjectDocs(projectInfo) {
661
+ const docs = [];
662
+ function walk(folder, folderPath) {
663
+ for (const doc of folder.docs || []) {
664
+ docs.push({
665
+ id: doc._id,
666
+ path: folderPath ? `${folderPath}/${doc.name}` : doc.name
667
+ });
668
+ }
669
+ for (const child of folder.folders || []) {
670
+ const childPath = folderPath ? `${folderPath}/${child.name}` : child.name;
671
+ walk(child, childPath);
672
+ }
673
+ }
674
+ for (const folder of projectInfo.rootFolder || []) {
675
+ walk(folder, '');
676
+ }
677
+ return docs;
678
+ }
679
+ async openProjectSocket(projectId) {
680
+ const handshakeUrl = `${this.baseUrl}/socket.io/1/?projectId=${encodeURIComponent(projectId)}&t=${Date.now()}`;
681
+ const handshakeResponse = await this.httpRequest(handshakeUrl, {
682
+ headers: { 'Cookie': this.getCookieHeader(), 'User-Agent': USER_AGENT },
683
+ expect: 'text',
684
+ timeoutMs: 5000
685
+ });
686
+ if (!handshakeResponse.ok) {
687
+ throw new Error(`Failed to open project socket: ${handshakeResponse.status}`);
688
+ }
689
+ this.applySetCookieHeaders(handshakeResponse.headers['set-cookie']);
690
+ const sid = handshakeResponse.body.trim().split(':')[0];
691
+ if (!sid) {
692
+ throw new Error('Failed to open project socket: missing session id');
693
+ }
694
+ const session = {
695
+ sid,
696
+ projectId,
697
+ pollUrl: () => `${this.baseUrl}/socket.io/1/xhr-polling/${sid}?projectId=${encodeURIComponent(projectId)}&t=${Date.now()}`
698
+ };
699
+ for (let attempt = 0; attempt < 8; attempt++) {
700
+ const packets = await this.pollProjectSocket(session);
701
+ if (packets.some(packet => {
702
+ if (!packet.startsWith('5:::'))
703
+ return false;
704
+ try {
705
+ return JSON.parse(packet.slice(4))?.name === 'joinProjectResponse';
706
+ }
707
+ catch {
708
+ return false;
709
+ }
710
+ })) {
711
+ return session;
712
+ }
713
+ }
714
+ throw new Error('Project socket did not return joinProjectResponse');
715
+ }
716
+ async pollProjectSocket(session) {
717
+ const response = await this.httpRequest(session.pollUrl(), {
718
+ headers: { 'Cookie': this.getCookieHeader(), 'User-Agent': USER_AGENT },
719
+ expect: 'text',
720
+ timeoutMs: 7000
721
+ });
722
+ if (!response.ok) {
723
+ throw new Error(`Socket poll failed: ${response.status}`);
724
+ }
725
+ this.applySetCookieHeaders(response.headers['set-cookie']);
726
+ const packets = this.decodeSocketIoPayload(response.body);
727
+ for (const packet of packets) {
728
+ if (packet.startsWith('2::')) {
729
+ const heartbeatResponse = await this.httpRequest(session.pollUrl(), {
730
+ method: 'POST',
731
+ headers: {
732
+ 'Cookie': this.getCookieHeader(),
733
+ 'User-Agent': USER_AGENT,
734
+ 'Content-Type': 'text/plain;charset=UTF-8'
735
+ },
736
+ body: '2::',
737
+ expect: 'text',
738
+ timeoutMs: 5000
739
+ });
740
+ this.applySetCookieHeaders(heartbeatResponse.headers['set-cookie']);
741
+ }
742
+ }
743
+ return packets;
744
+ }
745
+ async postProjectSocketPacket(session, packet) {
746
+ const response = await this.httpRequest(session.pollUrl(), {
747
+ method: 'POST',
748
+ headers: {
749
+ 'Cookie': this.getCookieHeader(),
750
+ 'User-Agent': USER_AGENT,
751
+ 'Content-Type': 'text/plain;charset=UTF-8'
752
+ },
753
+ body: packet,
754
+ expect: 'text',
755
+ timeoutMs: 5000
756
+ });
757
+ if (!response.ok) {
758
+ throw new Error(`Socket post failed: ${response.status}`);
759
+ }
760
+ this.applySetCookieHeaders(response.headers['set-cookie']);
761
+ }
762
+ async socketRpc(session, name, args) {
763
+ const id = Math.floor(Math.random() * 0x7fffffff);
764
+ await this.postProjectSocketPacket(session, this.encodeSocketIoEvent(id, name, args));
765
+ for (let attempt = 0; attempt < 10; attempt++) {
766
+ const packets = await this.pollProjectSocket(session);
767
+ for (const packet of packets) {
768
+ const ackArgs = this.parseSocketIoAck(packet, id);
769
+ if (ackArgs) {
770
+ const [error, ...result] = ackArgs;
771
+ if (error) {
772
+ const message = typeof error === 'string' ? error : error.message || JSON.stringify(error);
773
+ throw new Error(`${name} failed: ${message}`);
774
+ }
775
+ return result;
776
+ }
777
+ }
778
+ }
779
+ throw new Error(`${name} did not return an acknowledgement`);
780
+ }
781
+ async closeProjectSocket(session) {
782
+ try {
783
+ await this.postProjectSocketPacket(session, '0::');
784
+ }
785
+ catch {
786
+ // Best-effort socket cleanup only.
787
+ }
788
+ }
789
+ normalizeJoinedDocument(docId, args) {
790
+ const [lines, version, _updates, ranges, type = 'sharejs-text-ot'] = args;
791
+ if (type === 'history-ot') {
792
+ const content = typeof lines?.content === 'string' ? lines.content : '';
793
+ return {
794
+ docId,
795
+ lines: content.split('\n'),
796
+ content,
797
+ version,
798
+ ranges: lines,
799
+ type
800
+ };
801
+ }
802
+ const decodedLines = Array.isArray(lines)
803
+ ? lines.map((line) => this.decodeOverleafUtf8(line))
804
+ : [];
805
+ const decodedRanges = ranges || {};
806
+ for (const comment of decodedRanges.comments || []) {
807
+ if (comment?.op?.c) {
808
+ comment.op.c = this.decodeOverleafUtf8(comment.op.c);
809
+ }
810
+ }
811
+ return {
812
+ docId,
813
+ lines: decodedLines,
814
+ content: decodedLines.join('\n'),
815
+ version,
816
+ ranges: decodedRanges,
817
+ type
818
+ };
819
+ }
820
+ async joinDocument(session, docId) {
821
+ const args = await this.socketRpc(session, 'joinDoc', [
822
+ docId,
823
+ {
824
+ encodeRanges: true,
825
+ supportsHistoryOT: true
826
+ }
827
+ ]);
828
+ return this.normalizeJoinedDocument(docId, args);
829
+ }
607
830
  /**
608
831
  * Extract root folder ID from a Socket.IO event packet (joinProjectResponse).
609
832
  */
@@ -1222,6 +1445,247 @@ export class OverleafClient {
1222
1445
  }
1223
1446
  throw new Error(`File not found in archive: ${path}`);
1224
1447
  }
1448
+ async getCommentThreads(projectId) {
1449
+ const response = await this.httpRequest(`${this.baseUrl}/project/${projectId}/threads`, {
1450
+ headers: this.getHeaders(),
1451
+ expect: 'json'
1452
+ });
1453
+ if (!response.ok) {
1454
+ throw new Error(`Failed to fetch comment threads: ${response.status}`);
1455
+ }
1456
+ this.applySetCookieHeaders(response.headers['set-cookie']);
1457
+ return response.body;
1458
+ }
1459
+ async listComments(projectId, options = {}) {
1460
+ const status = options.status || 'all';
1461
+ const contextLines = options.contextLines || 0;
1462
+ const projectInfo = await this.getProjectInfo(projectId);
1463
+ const docs = this.collectProjectDocs(projectInfo);
1464
+ const threads = await this.getCommentThreads(projectId);
1465
+ const comments = [];
1466
+ const session = await this.openProjectSocket(projectId);
1467
+ try {
1468
+ for (const doc of docs) {
1469
+ const joinedDoc = await this.joinDocument(session, doc.id);
1470
+ if (joinedDoc.type === 'history-ot') {
1471
+ for (const comment of joinedDoc.ranges.comments || []) {
1472
+ const ranges = comment.ranges || [];
1473
+ const firstRange = ranges[0];
1474
+ if (!firstRange)
1475
+ continue;
1476
+ const selectedText = ranges
1477
+ .map((range) => joinedDoc.content.slice(range.pos, range.pos + range.length))
1478
+ .join('');
1479
+ const location = this.positionToLineColumn(joinedDoc.content, firstRange.pos);
1480
+ const thread = threads[comment.id] || { messages: [] };
1481
+ const resolved = Boolean(comment.resolved || thread.resolved);
1482
+ comments.push({
1483
+ threadId: comment.id,
1484
+ docId: doc.id,
1485
+ path: doc.path,
1486
+ position: firstRange.pos,
1487
+ line: location.line,
1488
+ column: location.column,
1489
+ selectedText,
1490
+ resolved,
1491
+ messages: thread.messages || [],
1492
+ context: this.buildCommentContext(joinedDoc.content, location.line, contextLines)
1493
+ });
1494
+ }
1495
+ continue;
1496
+ }
1497
+ for (const comment of joinedDoc.ranges.comments || []) {
1498
+ const op = comment.op || {};
1499
+ const threadId = op.t || comment.id;
1500
+ if (!threadId || typeof op.p !== 'number')
1501
+ continue;
1502
+ const selectedText = typeof op.c === 'string'
1503
+ ? op.c
1504
+ : joinedDoc.content.slice(op.p, op.p + (op.c?.length || 0));
1505
+ const location = this.positionToLineColumn(joinedDoc.content, op.p);
1506
+ const thread = threads[threadId] || { messages: [] };
1507
+ const resolved = Boolean(comment.resolved || op.resolved || thread.resolved);
1508
+ comments.push({
1509
+ threadId,
1510
+ docId: doc.id,
1511
+ path: doc.path,
1512
+ position: op.p,
1513
+ line: location.line,
1514
+ column: location.column,
1515
+ selectedText,
1516
+ resolved,
1517
+ messages: thread.messages || [],
1518
+ context: this.buildCommentContext(joinedDoc.content, location.line, contextLines)
1519
+ });
1520
+ }
1521
+ }
1522
+ }
1523
+ finally {
1524
+ await this.closeProjectSocket(session);
1525
+ }
1526
+ return comments
1527
+ .filter(comment => {
1528
+ if (status === 'all')
1529
+ return true;
1530
+ return status === 'resolved' ? comment.resolved : !comment.resolved;
1531
+ })
1532
+ .sort((a, b) => a.path.localeCompare(b.path) || a.position - b.position);
1533
+ }
1534
+ async resolveComment(projectId, threadId) {
1535
+ const comment = await this.findComment(projectId, threadId);
1536
+ const response = await this.httpRequest(`${this.baseUrl}/project/${projectId}/doc/${comment.docId}/thread/${threadId}/resolve`, {
1537
+ method: 'POST',
1538
+ headers: this.getHeaders(true),
1539
+ body: '',
1540
+ expect: 'text'
1541
+ });
1542
+ if (!response.ok) {
1543
+ throw new Error(`Failed to resolve comment: ${response.status}`);
1544
+ }
1545
+ this.applySetCookieHeaders(response.headers['set-cookie']);
1546
+ return comment;
1547
+ }
1548
+ async reopenComment(projectId, threadId) {
1549
+ const comment = await this.findComment(projectId, threadId);
1550
+ const response = await this.httpRequest(`${this.baseUrl}/project/${projectId}/doc/${comment.docId}/thread/${threadId}/reopen`, {
1551
+ method: 'POST',
1552
+ headers: this.getHeaders(true),
1553
+ body: '',
1554
+ expect: 'text'
1555
+ });
1556
+ if (!response.ok) {
1557
+ throw new Error(`Failed to reopen comment: ${response.status}`);
1558
+ }
1559
+ this.applySetCookieHeaders(response.headers['set-cookie']);
1560
+ return comment;
1561
+ }
1562
+ async deleteComment(projectId, threadId) {
1563
+ const comment = await this.findComment(projectId, threadId);
1564
+ const response = await this.httpRequest(`${this.baseUrl}/project/${projectId}/doc/${comment.docId}/thread/${threadId}`, {
1565
+ method: 'DELETE',
1566
+ headers: this.getHeaders(true),
1567
+ body: '',
1568
+ expect: 'text'
1569
+ });
1570
+ if (!response.ok) {
1571
+ throw new Error(`Failed to delete comment: ${response.status}`);
1572
+ }
1573
+ this.applySetCookieHeaders(response.headers['set-cookie']);
1574
+ return comment;
1575
+ }
1576
+ async findComment(projectId, threadId) {
1577
+ const comments = await this.listComments(projectId);
1578
+ const comment = comments.find(item => item.threadId === threadId);
1579
+ if (!comment) {
1580
+ throw new Error(`Comment thread not found: ${threadId}`);
1581
+ }
1582
+ return comment;
1583
+ }
1584
+ resolveCommentSelection(doc, options) {
1585
+ if (options.selectedText) {
1586
+ const occurrence = options.occurrence || 1;
1587
+ let fromIndex = 0;
1588
+ let position = -1;
1589
+ for (let index = 0; index < occurrence; index++) {
1590
+ position = doc.content.indexOf(options.selectedText, fromIndex);
1591
+ if (position === -1)
1592
+ break;
1593
+ fromIndex = position + options.selectedText.length;
1594
+ }
1595
+ if (position === -1) {
1596
+ throw new Error(`Selected text not found in ${options.filePath}`);
1597
+ }
1598
+ return { position, selectedText: options.selectedText };
1599
+ }
1600
+ let position = options.position;
1601
+ if (position == null) {
1602
+ if (options.line == null || options.column == null) {
1603
+ throw new Error('Add comment requires either --text, --position, or both --line and --column');
1604
+ }
1605
+ const lines = doc.content.split('\n');
1606
+ if (options.line < 1 || options.line > lines.length) {
1607
+ throw new Error(`Line out of range: ${options.line}`);
1608
+ }
1609
+ if (options.column < 1 || options.column > lines[options.line - 1].length + 1) {
1610
+ throw new Error(`Column out of range: ${options.column}`);
1611
+ }
1612
+ position = lines.slice(0, options.line - 1).reduce((sum, line) => sum + line.length + 1, 0) + options.column - 1;
1613
+ }
1614
+ const length = options.length || 1;
1615
+ if (position < 0 || position + length > doc.content.length) {
1616
+ throw new Error('Comment selection is outside the document');
1617
+ }
1618
+ return {
1619
+ position,
1620
+ selectedText: doc.content.slice(position, position + length)
1621
+ };
1622
+ }
1623
+ async addComment(projectId, options) {
1624
+ const projectInfo = await this.getProjectInfo(projectId);
1625
+ const docs = this.collectProjectDocs(projectInfo);
1626
+ const normalizedPath = options.filePath.replace(/^\//, '');
1627
+ const doc = docs.find(item => item.path === normalizedPath || item.path.replace(/^\//, '') === normalizedPath);
1628
+ if (!doc) {
1629
+ throw new Error(`Doc not found: ${options.filePath}`);
1630
+ }
1631
+ const session = await this.openProjectSocket(projectId);
1632
+ try {
1633
+ const joinedDoc = await this.joinDocument(session, doc.id);
1634
+ const selection = this.resolveCommentSelection(joinedDoc, options);
1635
+ const threadId = this.generateCommentThreadId();
1636
+ await this.postCommentMessage(projectId, threadId, options.content);
1637
+ const op = joinedDoc.type === 'history-ot'
1638
+ ? {
1639
+ commentId: threadId,
1640
+ ranges: [{ pos: selection.position, length: selection.selectedText.length }]
1641
+ }
1642
+ : {
1643
+ c: selection.selectedText,
1644
+ p: selection.position,
1645
+ t: threadId
1646
+ };
1647
+ await this.socketRpc(session, 'applyOtUpdate', [doc.id, {
1648
+ doc: doc.id,
1649
+ op: [op],
1650
+ v: joinedDoc.version
1651
+ }]);
1652
+ const location = this.positionToLineColumn(joinedDoc.content, selection.position);
1653
+ return {
1654
+ threadId,
1655
+ docId: doc.id,
1656
+ path: doc.path,
1657
+ position: selection.position,
1658
+ line: location.line,
1659
+ column: location.column,
1660
+ selectedText: selection.selectedText,
1661
+ resolved: false,
1662
+ messages: []
1663
+ };
1664
+ }
1665
+ finally {
1666
+ await this.closeProjectSocket(session);
1667
+ }
1668
+ }
1669
+ async postCommentMessage(projectId, threadId, content) {
1670
+ const response = await this.httpRequest(`${this.baseUrl}/project/${projectId}/thread/${threadId}/messages`, {
1671
+ method: 'POST',
1672
+ headers: this.getHeaders(true),
1673
+ body: JSON.stringify({ content }),
1674
+ expect: 'text'
1675
+ });
1676
+ if (!response.ok) {
1677
+ throw new Error(`Failed to post comment message: ${response.status}`);
1678
+ }
1679
+ this.applySetCookieHeaders(response.headers['set-cookie']);
1680
+ if (!response.body)
1681
+ return null;
1682
+ try {
1683
+ return JSON.parse(response.body);
1684
+ }
1685
+ catch {
1686
+ return null;
1687
+ }
1688
+ }
1225
1689
  /**
1226
1690
  * Compile project and get all output files
1227
1691
  */
@@ -1242,7 +1706,10 @@ export class OverleafClient {
1242
1706
  }
1243
1707
  this.applySetCookieHeaders(response.headers['set-cookie']);
1244
1708
  const data = response.body;
1245
- const pdfFile = data.outputFiles?.find((f) => f.type === 'pdf');
1709
+ // Prefer 'output.pdf' (the main compile output) over any other PDF.
1710
+ // See #26 — projects with figure PDFs could return the wrong file.
1711
+ const pdfFile = data.outputFiles?.find((f) => f.path === 'output.pdf')
1712
+ || data.outputFiles?.find((f) => f.type === 'pdf');
1246
1713
  // Overleaf's CDN requires ?clsiserverid=<id> for build-output downloads.
1247
1714
  // Without it every output (pdf/log/bbl/...) 404s. See issue #22.
1248
1715
  const qs = data.clsiServerId ? `?clsiserverid=${encodeURIComponent(data.clsiServerId)}` : '';