overleaf-comments-export

A Tampermonkey userscript that syncs Overleaf review comments into your LaTeX source as \olc macros, so they end up in git alongside the text they refer to. Overleaf keeps review comments in its own database, not in the .tex; this pulls them into the document — one macro per comment/reply, anchored right after the highlighted span, with the author, timestamp, highlighted text and comment body — and does it idempotently, so re-running only inserts new comments and updates changed ones rather than duplicating.

Why a userscript (and @grant none)

Comment text comes from a credentialed same-origin call (GET /project/<id>/threads) and comment positions live in the editor's in-memory CodeMirror 6 state (the ranges field: op:{c,p,t} = highlighted text, char offset, thread id). Both are only reachable from inside the page, so the script runs with @grant none (page context, no sandbox) to touch the app's live editor objects and issue the credentialed fetch. It never scrapes the DOM for content — CM6 only renders the visible slice, so the in-memory state is the single source of truth.

Install

  1. Install Tampermonkey (or a compatible userscript manager).
  2. Create a new script and paste the contents of overleaf-comments-export.user.js, or open the raw file and let Tampermonkey offer to install it.
  3. Open a project on https://www.overleaf.com/project/* with the code editor visible.
  4. A Comment sync dropdown appears in the editor's top navigation, next to Help.

Use

The dropdown has two items:

Before writing anything, the script logs every generated \olc line and a summary (inserts / replaces / skips / unanchored) to the console, then asks for confirmation.

Preamble: define \olc

Add a definition of the four-argument macro to your preamble so the document compiles. The macro takes four brace arguments (\olc{date}{author}{highlight}{comment}), so it binds to a plain \providecommand{\olc}[4]{…} with no packages. The default below needs no packages and always compiles — it renders each comment inline as a bold attribution:

% \olc{date}{author}{highlighted text}{comment}
\providecommand{\olc}[4]{\textbf{Comment by #2 on #1} on ``#3'': #4}

Prefer a margin note? A todonotes variant (add \usepackage[textwidth=3cm]{todonotes}):

\providecommand{\olc}[4]{\todo[color=yellow!40,size=\scriptsize]{\textbf{#2}, #1: #4}}

Per-author colours and display names

On a document with several reviewers the flat version gets hard to skim. This variant keeps a small table of authors and gives each one a colour and a short display name, so Comment by Carlos Rombaldo Junior becomes a blue Comment by Carlos. It also sets each comment as its own small paragraph and stops the annotated line below it from being indented, so the comment reads as a block rather than as part of your prose:

\usepackage{xcolor}
\usepackage{etoolbox}   % for \csdef

% \olcauthor{author exactly as the exporter writes it}{display name}{colour}
\newcommand{\olcauthor}[3]{%
  \csdef{olc@name@\detokenize{#1}}{#2}%
  \csdef{olc@color@\detokenize{#1}}{#3}%
}

\olcauthor{ingolfbecker7}{Ingolf}{purple}
\olcauthor{Ingolf Becker}{Ingolf}{orange}
\olcauthor{Carlos Rombaldo Junior}{Carlos}{blue}

\makeatletter
\newcommand{\olc@lookup}[1]{%
  \ifcsname olc@color@\detokenize{#1}\endcsname
    \edef\olc@col{\csname olc@color@\detokenize{#1}\endcsname}%
    \expandafter\let\expandafter\olc@who\csname olc@name@\detokenize{#1}\endcsname
  \else
    \def\olc@col{black}%
    \def\olc@who{#1}%   unknown author: fall back to the raw name
  \fi}

\providecommand{\olc}[4]{%
  \par\smallskip
  \begingroup
    \olc@lookup{#2}%
    \color{\olc@col}%
    \noindent\textbf{Comment by \olc@who\ on #1} on ``#3'': #4%
  \par
  \endgroup
  \smallskip
  \@afterindentfalse\@afterheading
}
\makeatother

The key must match #2 exactly. The exporter writes the author as Overleaf's first_name last_name (trimmed), falling back to the account's email, then to unknown — it is never a shortened or normalised username. So the key for a reviewer whose account reads Carlos Rombaldo Junior is the whole three-word string, not carlos; an account with only a first name set gives a one-word key like ingolfbecker7. The quickest way to get them right is to run the sync once and copy the second argument out of the \olc lines it writes.

An author with no \olcauthor entry is not an error: they render in black under their raw name, so you can add entries as reviewers appear. Accented names work as long as you type them as literal UTF-8, exactly as the exporter does (\olcauthor{José Álvarez}{José}{olive}), not as Jos\'e. The \detokenize calls are what let the key survive LaTeX-escaping — without them an author string containing an escaped character (most often a _ in an email-fallback name, as a\_b@example.com) breaks the \csname lookup with Missing \endcsname inserted.

The macro is injected on its own line(s), immediately before the line that contains the highlighted span, so it sits next to the text it refers to without interrupting the sentence. The highlighted text is passed as #3 purely for reference; it is truncated to first two [...] last two words when it runs longer than five words, and may be empty for older comments (see below).

How the sync stays idempotent

Each injected line carries a trailing marker:

\olc{13 May, 6:27 pm}{Ingolf Becker}{the highlighted span}{the comment text} %olcsync:<threadId>:<messageId>:<contentHash>

On each run, for every message: no marker → insert; marker present, hash matches → skip; marker present, hash differs (someone edited the comment) → replace that whole line in place. Comments are therefore never duplicated, and the hash covers the pre-escape values so cosmetic LaTeX-escaping changes don't churn it. The marker sits after the macro on its own line — the % only comments out the marker itself, never any of your source.

⚠️ Writing edits the live document — and syncs to collaborators

This is a real edit. The sync injects into the live CodeMirror document via a single CM6 transaction, exactly as if you typed it. That means it propagates to every collaborator on the project in real time, and if track-changes is enabled it will be captured as tracked changes. This is intentional — the goal is to get the comments into the .tex and then into git — but it is deliberate: the script asks for confirmation first, shows you the exact counts, and warns (best-effort) if it detects track-changes is on. Track-changes detection is a DOM sniff and may be wrong; when in doubt, check the review panel yourself before confirming.

Behaviour details

All files (experimental)

There is no project-level positions endpoint — per-file ranges load only when a document is open. "All files" therefore walks the .tex entries in the file tree, opens each in turn, waits for its ranges to load, syncs it (still confirming per file), then restores the originally-open document. It is DOM-driven and fragile, gated behind an extra confirmation, and marked experimental in the code. Get this file right first; treat all-files as best-effort.

Depends on Overleaf internals

This reads Overleaf's in-memory CM6 state and an undocumented endpoint, and injects UI next to their nav. It may break when Overleaf updates. When an anchor or state field can't be found, the script fails loudly to the console ([olc] …) rather than silently doing the wrong thing — check the console first if a menu item seems to do nothing.

Tests

Two layers: fast, hermetic unit tests for the pure core, and a live-site Playwright probe for the browser integration (menu, DOM anchors, CM6 state, /threads).

Unit tests (core)

The pure core (escaping, timestamp/hash, marker parsing, annotation building, edit planning/idempotency) is unit-tested through V8 via mini-racer, driven by the userscript's own test-export hatch. Run deterministically (the timestamp test pins TZ=UTC):

TZ=UTC uv run overleaf-comments-export/tests/test_core.py

Live-site probe (Playwright)

tests/probe_overleaf.py drives a real Overleaf project in a headless browser, injects the userscript in page context (exactly as @grant none does), and checks the things the pure core cannot: that the Comment sync menu injects next to Help, that its submenu opens on click and actually renders, and that the fragile selectors still match the live app — the CM6 ranges state field (found by shape, never a hardcoded index), the /threads payload, and the Help/file-tree anchors. It is named probe_* (not test_*) so pytest never collects it: it hits the network and needs a browser plus a logged-in session.

One-time browser install (the binary is not a pip dependency, so it is fetched separately):

uv run --with playwright playwright install chromium

Authentication — where to put the cookie. Overleaf link-share edits ride on an account, so even an "anyone with the link" project needs a logged-in session; a fresh, cookie-less browser is bounced to /login. Give the probe a session one of two ways, both read from a gitignored tests/.auth/ directory and never printed or committed:

Run it (defaults to headless Chromium against the shared test project):

uv run overleaf-comments-export/tests/probe_overleaf.py            # headless, cookie auth
uv run overleaf-comments-export/tests/probe_overleaf.py --headed   # watch it happen
uv run overleaf-comments-export/tests/probe_overleaf.py --browser firefox --url <project-or-share-url>

Screenshots (editor, menu, open submenu) land in the gitignored tests/test-results/. The probe presents a normal desktop User-Agent and hides navigator.webdriver, because Overleaf bounces the default headless fingerprint to /login.

Next steps

Contents

📁tests/
📄overleaf-comments-export.user.js25.0 KB
📄README.md13.9 KB