Download every comment in an Overleaf project (author, timestamp, file, line, surrounding text) into one JSON + Markdown file.
// ==UserScript==
// @name Overleaf: Export All Comments
// @namespace https://github.com/yourname/overleaf-export-comments
// @version 1.3.0
// @description Download every comment in an Overleaf project (author, timestamp, file, line, surrounding text) into one JSON + Markdown file.
// @match https://www.overleaf.com/project/*
// @run-at document-idle
// @grant none
// @license MIT
// ==/UserScript==
(function () {
'use strict';
const log = (...a) => console.log('[OL-Comments]', ...a);
/* ---------- helpers ---------- */
function getProjectId() {
const m = location.pathname.match(/\/project\/([a-f0-9]+)/);
return m ? m[1] : null;
}
async function fetchJSON(url) {
const r = await fetch(url, { credentials: 'include', headers: { Accept: 'application/json' } });
if (!r.ok) throw new Error(`${url} -> ${r.status}`);
return r.json();
}
// Walk React fibers to find the project state (rootFolder + members + _id).
function findProjectState() {
const candidates = document.querySelectorAll('[class*="ide"], body');
for (const node of candidates) {
const fk = Object.keys(node).find(k => k.startsWith('__reactFiber'));
if (!fk) continue;
let fiber = node[fk];
while (fiber && fiber.return) fiber = fiber.return;
const queue = [fiber];
while (queue.length) {
const n = queue.shift();
if (!n) continue;
let s = n.memoizedState;
let h = 0;
while (s && h < 30) {
const ms = s.memoizedState;
if (ms && typeof ms === 'object' && !Array.isArray(ms)) {
const k = Object.keys(ms);
if (k.includes('rootFolder') && k.includes('_id') && k.includes('members')) return ms;
}
s = s.next; h++;
}
if (n.child) queue.push(n.child);
if (n.sibling) queue.push(n.sibling);
}
}
return null;
}
function buildDocMap(project) {
const map = {};
function walk(folder, path) {
if (!folder) return;
(folder.docs || []).forEach(d => { map[d._id] = path + '/' + d.name; });
(folder.fileRefs || []).forEach(f => { map[f._id] = path + '/' + f.name; });
(folder.folders || []).forEach(f => walk(f, path + '/' + f.name));
}
const rf = Array.isArray(project.rootFolder) ? project.rootFolder[0] : project.rootFolder;
walk(rf, '');
return map;
}
function buildUserMap(project) {
const map = {};
const add = u => { if (u && u._id) map[u._id] = u; };
add(project.owner);
(project.members || []).forEach(add);
(project.invites || []).forEach(add);
return map;
}
async function getDocText(projectId, docId) {
const r = await fetch(`/project/${projectId}/doc/${docId}/download`, { credentials: 'include' });
return r.ok ? r.text() : '';
}
function offsetToLineCol(text, offset) {
if (!text) return { line: null, col: null };
let line = 1, col = 1;
for (let i = 0; i < Math.min(offset, text.length); i++) {
if (text.charCodeAt(i) === 10) { line++; col = 1; } else col++;
}
return { line, col };
}
function getLineText(text, offset) {
if (!text || offset == null) return null;
const start = text.lastIndexOf('\n', Math.max(0, offset - 1)) + 1;
let end = text.indexOf('\n', offset);
if (end === -1) end = text.length;
return text.slice(start, end);
}
function getContext(text, offset, before = 120, after = 120) {
if (!text || offset == null) return { before: null, after: null };
return {
before: text.slice(Math.max(0, offset - before), offset),
after: text.slice(offset, offset + after),
};
}
const fmtTs = ts => { try { return new Date(ts).toISOString(); } catch { return String(ts); } };
function downloadBlob(name, mime, content) {
const blob = new Blob([content], { type: mime });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = name;
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
/* Walk all history chunks; return Set of pathnames that ever held comment ranges. */
async function getCandidateFiles(projectId) {
const seen = new Set();
let url = `/project/${projectId}/latest/history`;
for (let i = 0; i < 50 && url; i++) {
const r = await fetch(url, { credentials: 'include' });
if (!r.ok) break;
const d = await r.json();
const chunk = d.chunk;
if (!chunk) break;
const files = chunk.history?.snapshot?.files || {};
for (const [name, f] of Object.entries(files)) {
if (f.rangesHash) seen.add(name);
}
const startV = chunk.startVersion ?? 0;
if (startV <= 0) break;
url = `/project/${projectId}/version/${startV - 1}/history`;
}
return seen;
}
/* ---------- main export ---------- */
async function exportAll() {
const projectId = getProjectId();
if (!projectId) { alert('Not on a project page'); return; }
const project = findProjectState();
const docMap = project ? buildDocMap(project) : {};
const userMap = project ? buildUserMap(project) : {};
// path (without leading slash) -> docId, for resolving history snapshot names.
const pathToId = {};
for (const [id, path] of Object.entries(docMap)) pathToId[path.replace(/^\//, '')] = id;
const [threads, ranges, candidatesSet] = await Promise.all([
fetchJSON(`/project/${projectId}/threads`),
fetchJSON(`/project/${projectId}/ranges`),
getCandidateFiles(projectId).catch(() => new Set()),
]);
const candidates = [...candidatesSet];
const fallbackFile = candidates.length === 1 ? '/' + candidates[0] : null;
const fallbackDocId = fallbackFile ? pathToId[candidates[0]] || null : null;
// threadId -> { docId, p, quoted }
const threadLoc = {};
const docsNeeded = new Set();
for (const d of ranges) {
for (const c of (d.ranges?.comments || [])) {
threadLoc[c.op.t] = { docId: d.id, p: c.op.p, quoted: c.op.c || '' };
docsNeeded.add(d.id);
}
}
if (fallbackDocId) docsNeeded.add(fallbackDocId);
// Pre-fetch text for every doc we need.
const docText = {};
await Promise.all([...docsNeeded].map(async id => {
try { docText[id] = await getDocText(projectId, id); } catch { docText[id] = ''; }
}));
// Assemble final list.
const out = [];
for (const [threadId, t] of Object.entries(threads)) {
const loc = threadLoc[threadId];
let file, docId, offset, line, col, quoted, lineText, ctx, orphan = false;
if (loc) {
docId = loc.docId;
file = docMap[docId] || docId;
offset = loc.p;
quoted = loc.quoted || null;
({ line, col } = offsetToLineCol(docText[docId] || '', offset || 0));
lineText = getLineText(docText[docId] || '', offset);
ctx = getContext(docText[docId] || '', offset);
} else if (fallbackFile) {
docId = fallbackDocId;
file = fallbackFile + ' (anchor lost — original position unknown)';
offset = null; line = null; col = null; quoted = null;
lineText = null; ctx = { before: null, after: null };
orphan = true;
} else {
file = '(unknown — anchor lost; possible files: ' + candidates.join(', ') + ')';
docId = null; offset = null; line = null; col = null; quoted = null;
lineText = null; ctx = { before: null, after: null };
orphan = true;
}
const messages = (t.messages || []).map(m => ({
id: m.id,
author: m.user
? `${m.user.first_name || ''} ${m.user.last_name || ''}`.trim()
: (userMap[m.user_id]?.email || m.user_id),
email: m.user?.email || userMap[m.user_id]?.email || null,
timestamp: fmtTs(m.timestamp),
content: m.content,
}));
out.push({
thread_id: threadId,
file, doc_id: docId,
offset, line, col,
quoted,
line_text: lineText,
context_before: ctx.before,
context_after: ctx.after,
orphan,
resolved: !!t.resolved,
resolved_at: t.resolved_at ? fmtTs(t.resolved_at) : null,
resolved_by: t.resolved_by_user
? `${t.resolved_by_user.first_name || ''} ${t.resolved_by_user.last_name || ''}`.trim()
: null,
messages,
});
}
out.sort((a, b) =>
(a.file || '').localeCompare(b.file || '') ||
(a.line || 0) - (b.line || 0));
/* ---- write JSON ---- */
downloadBlob(
`overleaf-comments-${projectId}.json`,
'application/json',
JSON.stringify({
projectId,
exportedAt: new Date().toISOString(),
count: out.length,
threads: out,
}, null, 2),
);
/* ---- write Markdown ---- */
const md = [];
md.push(`# Overleaf comments — \`${projectId}\``);
md.push(`Exported ${new Date().toISOString()} · ${out.length} threads\n`);
let curFile = null;
for (const t of out) {
if (t.file !== curFile) { curFile = t.file; md.push(`\n## ${curFile}\n`); }
const head = t.orphan
? `### Orphan comment (anchor lost)${t.resolved ? ' — ✅ resolved' : ''}`
: `### Line ${t.line ?? '?'} (offset ${t.offset ?? '?'})${t.resolved ? ' — ✅ resolved' : ''}`;
md.push(head);
if (t.line_text) md.push('```\n' + t.line_text + '\n```');
if (t.context_after) {
const before = (t.context_before || '').slice(-40);
const after = (t.context_after || '').slice(0, 80);
md.push(`> …${(before + '⟦HERE⟧' + after).replace(/\n/g, ' ')}…`);
}
if (t.quoted) md.push(`Highlighted: \`${t.quoted.replace(/`/g, '\\`')}\``);
for (const m of t.messages) {
md.push(`- **${m.author}** _(${m.timestamp})_: ${m.content.replace(/\n/g, '\n ')}`);
}
if (t.resolved) md.push(`*Resolved by ${t.resolved_by || '?'} at ${t.resolved_at}*`);
md.push('');
}
downloadBlob(`overleaf-comments-${projectId}.md`, 'text/markdown', md.join('\n'));
alert(`Exported ${out.length} comment threads.`);
}
/* ---------- toolbar mount (top-right, icon-only, native style) ---------- */
function mountButton() {
const actions = document.querySelector('.ide-redesign-toolbar-actions');
if (!actions) return false;
if (document.getElementById('ol-export-comments-btn')) return true;
const wrap = document.createElement('div');
wrap.className = 'ide-redesign-toolbar-button-container';
const btn = document.createElement('button');
btn.id = 'ol-export-comments-btn';
btn.type = 'button';
btn.title = 'Export all comments';
btn.setAttribute('aria-label', 'Export all comments');
btn.className =
'd-inline-grid ide-redesign-toolbar-button-subdued ' +
'ide-redesign-toolbar-button-icon icon-button btn btn-primary';
const icon = document.createElement('span');
icon.className = 'material-symbols icon-small';
icon.textContent = 'download';
btn.appendChild(icon);
btn.onclick = () => {
if (btn.disabled) return;
btn.disabled = true;
icon.textContent = 'progress_activity';
exportAll()
.catch(e => { console.error(e); alert('Export failed: ' + e.message); })
.finally(() => { btn.disabled = false; icon.textContent = 'download'; });
};
wrap.appendChild(btn);
actions.insertBefore(wrap, actions.firstChild); // top-right, before History/Layout/Share
return true;
}
// Re-mount if Overleaf re-renders the toolbar.
new MutationObserver(() => mountButton())
.observe(document.documentElement, { childList: true, subtree: true });
const tries = setInterval(() => { if (mountButton()) clearInterval(tries); }, 500);
})();