Overleaf: Export All Comments

Download every comment in an Overleaf project (author, timestamp, file, line, surrounding text) into one JSON + Markdown file.

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

Advertisement:

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

Advertisement:

// ==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);
})();