Greasy Fork is available in English.
Groups annotation comments by rubric item on Gradescope
// ==UserScript==
// @name Gradescope Comment Viewer
// @namespace https://www.gradescope.com
// @version 1.2
// @description Groups annotation comments by rubric item on Gradescope
// @author Justin Lyon
// @license MIT
// @match https://www.gradescope.com/courses/*/questions/*/submissions/*
// @grant none
// @run-at document-idle
// ==/UserScript==
(function () {
"use strict";
const path = window.location.pathname;
const match = path.match(
/\/courses\/(\d+)\/questions\/(\d+)\/submissions\/(\d+)/
);
if (!match) return;
const [, courseId, questionId] = match;
const baseUrl = `https://www.gradescope.com/courses/${courseId}/questions/${questionId}`;
// --- Constants ---
const MIN_WIDTH = 280;
const MIN_HEIGHT = 200;
const RESIZE_EDGE = 12;
// --- Extract rubric data from the page's embedded JSON ---
function extractPageData() {
const raw = document.documentElement.innerHTML;
const tmp = document.createElement("textarea");
tmp.innerHTML = raw;
const html = tmp.value;
const rubricItems = [];
const rubricGroups = [];
function extractArray(source, key) {
const marker = '"' + key + '":[';
const start = source.indexOf(marker);
if (start === -1) return null;
const arrStart = start + marker.length - 1;
let depth = 0;
for (let i = arrStart; i < source.length; i++) {
if (source[i] === "[") depth++;
else if (source[i] === "]") {
depth--;
if (depth === 0) return source.slice(arrStart, i + 1);
}
}
return null;
}
try {
const itemsJson = extractArray(html, "rubric_items");
if (itemsJson) rubricItems.push(...JSON.parse(itemsJson));
} catch (_) { }
try {
const groupsJson = extractArray(html, "rubric_item_groups");
if (groupsJson) rubricGroups.push(...JSON.parse(groupsJson));
} catch (_) { }
return { rubricItems, rubricGroups };
}
// --- Fetch annotation comments ---
async function fetchComments() {
const csrfMeta = document.querySelector('meta[name="csrf-token"]');
const headers = {
Accept: "application/json",
"X-Requested-With": "XMLHttpRequest",
};
if (csrfMeta) headers["X-CSRF-Token"] = csrfMeta.content;
const resp = await fetch(`${baseUrl}/submissions/annotation_comments`, {
headers,
credentials: "same-origin",
});
if (!resp.ok) throw new Error(`Failed to fetch comments: ${resp.status}`);
return resp.json();
}
// --- Build grouped data structure ---
function groupComments(comments, rubricItems, rubricGroups) {
const groupMap = new Map();
for (const g of rubricGroups) {
groupMap.set(g.id, { ...g, items: [] });
}
const itemMap = new Map();
for (const item of rubricItems) {
const entry = { ...item, comments: [] };
itemMap.set(item.id, entry);
const group = groupMap.get(item.group_id);
if (group) group.items.push(entry);
}
const ungrouped = [];
for (const value of Object.values(comments)) {
const [text, linkableId, linkableType] = value;
if (linkableType === "RubricItem" && itemMap.has(linkableId)) {
itemMap.get(linkableId).comments.push(text);
} else if (
linkableType === "RubricItemGroup" &&
groupMap.has(linkableId)
) {
const group = groupMap.get(linkableId);
if (!group.groupComments) group.groupComments = [];
group.groupComments.push(text);
} else {
ungrouped.push(text);
}
}
const sortedGroups = [...groupMap.values()].sort(
(a, b) => a.position - b.position
);
for (const g of sortedGroups) {
g.items.sort((a, b) => a.position - b.position);
}
return { groups: sortedGroups, ungrouped };
}
// --- Dragging ---
function makeDraggable(panel, handle) {
let dx = 0,
dy = 0,
startX = 0,
startY = 0;
handle.style.cursor = "grab";
handle.addEventListener("mousedown", (e) => {
if (e.target.closest("button, input")) return;
e.preventDefault();
startX = e.clientX;
startY = e.clientY;
handle.style.cursor = "grabbing";
const onMove = (e2) => {
dx = e2.clientX - startX;
dy = e2.clientY - startY;
startX = e2.clientX;
startY = e2.clientY;
panel.style.top = panel.offsetTop + dy + "px";
panel.style.left = panel.offsetLeft + dx + "px";
};
const onUp = () => {
handle.style.cursor = "grab";
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
});
}
// --- Resizing from any edge ---
function makeResizable(panel) {
const EDGE = RESIZE_EDGE;
let resizing = null;
function getEdge(e) {
const r = panel.getBoundingClientRect();
const x = e.clientX, y = e.clientY;
const edges = {
top: y - r.top < EDGE,
bottom: r.bottom - y < EDGE,
left: x - r.left < EDGE,
right: r.right - x < EDGE,
};
return edges;
}
function getCursor(edges) {
if ((edges.top && edges.left) || (edges.bottom && edges.right)) return "nwse-resize";
if ((edges.top && edges.right) || (edges.bottom && edges.left)) return "nesw-resize";
if (edges.top || edges.bottom) return "ns-resize";
if (edges.left || edges.right) return "ew-resize";
return "";
}
panel.addEventListener("mousemove", (e) => {
if (resizing) return;
const edges = getEdge(e);
const cursor = getCursor(edges);
panel.style.cursor = cursor || "";
});
panel.addEventListener("mousedown", (e) => {
const edges = getEdge(e);
if (!edges.top && !edges.bottom && !edges.left && !edges.right) return;
e.preventDefault();
e.stopPropagation();
const startX = e.clientX, startY = e.clientY;
const rect = panel.getBoundingClientRect();
resizing = { edges, startX, startY, startW: rect.width, startH: rect.height, startTop: rect.top, startLeft: rect.left };
const onMove = (e2) => {
const dx = e2.clientX - resizing.startX;
const dy = e2.clientY - resizing.startY;
if (resizing.edges.right) {
panel.style.width = Math.max(MIN_WIDTH, resizing.startW + dx) + "px";
}
if (resizing.edges.left) {
const newW = Math.max(MIN_WIDTH, resizing.startW - dx);
panel.style.width = newW + "px";
panel.style.left = resizing.startLeft + (resizing.startW - newW) + "px";
}
if (resizing.edges.bottom) {
panel.style.height = Math.max(MIN_HEIGHT, resizing.startH + dy) + "px";
panel.style.maxHeight = "none";
}
if (resizing.edges.top) {
const newH = Math.max(MIN_HEIGHT, resizing.startH - dy);
panel.style.height = newH + "px";
panel.style.maxHeight = "none";
panel.style.top = resizing.startTop + (resizing.startH - newH) + "px";
}
};
const onUp = () => {
resizing = null;
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
});
}
// --- Search with auto-expand ---
function setupSearch(panel, content) {
const searchBox = document.createElement("div");
searchBox.className = "gs-cv-search";
searchBox.innerHTML =
'<input type="text" class="gs-cv-search-input" placeholder="Search comments..."><button class="gs-cv-search-clear">×</button>';
const input = searchBox.querySelector("input");
const clearBtn = searchBox.querySelector(".gs-cv-search-clear");
clearBtn.onclick = () => {
input.value = "";
input.dispatchEvent(new Event("input"));
input.focus();
};
// Ctrl+F inside panel focuses search
panel.addEventListener("keydown", (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === "f") {
e.preventDefault();
e.stopPropagation();
input.focus();
input.select();
}
});
function highlightText(el, q) {
// Store original text if not already stored
if (!el.dataset.originalText) el.dataset.originalText = el.textContent;
const text = el.dataset.originalText;
if (!q) { el.textContent = text; return false; }
const lower = text.toLowerCase();
const idx = lower.indexOf(q);
if (idx === -1) { el.textContent = text; return false; }
el.innerHTML = '';
let pos = 0;
let searchFrom = 0;
while (true) {
const i = lower.indexOf(q, searchFrom);
if (i === -1) break;
if (i > pos) el.appendChild(document.createTextNode(text.slice(pos, i)));
const mark = document.createElement('mark');
mark.className = 'gs-cv-mark';
mark.textContent = text.slice(i, i + q.length);
el.appendChild(mark);
pos = i + q.length;
searchFrom = pos;
}
if (pos < text.length) el.appendChild(document.createTextNode(text.slice(pos)));
return true;
}
let searchTimeout;
input.addEventListener("input", () => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(doSearch, 120);
});
function doSearch() {
const q = input.value.trim().toLowerCase();
const allComments = content.querySelectorAll(".gs-cv-comment");
const allItemHeaders = content.querySelectorAll(".gs-cv-item-header");
const allGroupHeaders = content.querySelectorAll(".gs-cv-group-header");
const allItems = content.querySelectorAll(".gs-cv-item");
const allGroups = content.querySelectorAll(".gs-cv-group");
if (!q) {
// Clear: restore all text, show everything
allComments.forEach((c) => { highlightText(c, ''); c.style.display = ""; });
allItemHeaders.forEach((h) => highlightText(h, ''));
allGroupHeaders.forEach((h) => highlightText(h, ''));
allItems.forEach((i) => (i.style.display = ""));
allGroups.forEach((g) => (g.style.display = ""));
return;
}
// Highlight and track matches in comments
allComments.forEach((c) => {
const matched = highlightText(c, q);
c.style.display = matched ? "" : "none";
});
// Highlight item headers and check if they or their comments match
allItems.forEach((itemEl) => {
const headerEl = itemEl.querySelector(".gs-cv-item-header");
const headerMatch = headerEl ? highlightText(headerEl, q) : false;
const hasVisibleComment = itemEl.querySelector('.gs-cv-comment:not([style*="display: none"])');
if (headerMatch) {
// Header matches: show all its comments too
itemEl.style.display = "";
itemEl.querySelectorAll(".gs-cv-comment").forEach((c) => { c.style.display = ""; highlightText(c, q); });
const itemBody = itemEl.querySelector(".gs-cv-item-body");
if (itemBody) itemBody.classList.remove("gs-cv-collapsed");
} else if (hasVisibleComment) {
itemEl.style.display = "";
const itemBody = itemEl.querySelector(".gs-cv-item-body");
if (itemBody) itemBody.classList.remove("gs-cv-collapsed");
} else {
itemEl.style.display = "none";
}
});
// Highlight group headers and check if they or children match
allGroups.forEach((groupEl) => {
const headerEl = groupEl.querySelector(".gs-cv-group-header");
const headerMatch = headerEl ? highlightText(headerEl, q) : false;
const hasVisibleChild = groupEl.querySelector('.gs-cv-item:not([style*="display: none"]), .gs-cv-comment:not([style*="display: none"])');
if (headerMatch) {
// Group header matches: show group and all children
groupEl.style.display = "";
groupEl.querySelectorAll(".gs-cv-item").forEach((i) => (i.style.display = ""));
groupEl.querySelectorAll(".gs-cv-comment").forEach((c) => { c.style.display = ""; highlightText(c, q); });
groupEl.querySelectorAll(".gs-cv-item-header").forEach((h) => highlightText(h, q));
const groupBody = groupEl.querySelector(".gs-cv-group-body");
if (groupBody) groupBody.classList.remove("gs-cv-collapsed");
groupEl.querySelectorAll(".gs-cv-item-body").forEach((b) => b.classList.remove("gs-cv-collapsed"));
} else if (hasVisibleChild) {
groupEl.style.display = "";
const groupBody = groupEl.querySelector(".gs-cv-group-body");
if (groupBody) groupBody.classList.remove("gs-cv-collapsed");
} else {
groupEl.style.display = "none";
}
});
}
return searchBox;
}
// --- Render the panel ---
function renderPanel(grouped) {
const panel = document.createElement("div");
panel.id = "gs-comment-viewer";
panel.tabIndex = 0; // so it can receive keyboard events
// Header
const header = document.createElement("div");
header.className = "gs-cv-header";
const titleRow = document.createElement("div");
titleRow.className = "gs-cv-title-row";
const title = document.createElement("span");
title.className = "gs-cv-title";
title.textContent = "Comments";
titleRow.appendChild(title);
const btnGroup = document.createElement("div");
btnGroup.className = "gs-cv-btn-group";
const refreshBtn = document.createElement("button");
refreshBtn.className = "gs-cv-btn gs-cv-refresh";
refreshBtn.textContent = "Refresh";
refreshBtn.title = "Reload comments";
btnGroup.appendChild(refreshBtn);
const expandAllBtn = document.createElement("button");
expandAllBtn.className = "gs-cv-btn";
expandAllBtn.textContent = "Expand All";
expandAllBtn.title = "Expand all sections";
btnGroup.appendChild(expandAllBtn);
const collapseAllBtn = document.createElement("button");
collapseAllBtn.className = "gs-cv-btn";
collapseAllBtn.textContent = "Collapse All";
collapseAllBtn.title = "Collapse all sections";
btnGroup.appendChild(collapseAllBtn);
const minimizeBtn = document.createElement("button");
minimizeBtn.className = "gs-cv-btn gs-cv-minimize";
minimizeBtn.innerHTML = "−"; // minus sign
minimizeBtn.title = "Minimize";
btnGroup.appendChild(minimizeBtn);
const closeBtn = document.createElement("button");
closeBtn.className = "gs-cv-btn gs-cv-close";
closeBtn.innerHTML = "×";
closeBtn.title = "Close";
btnGroup.appendChild(closeBtn);
titleRow.appendChild(btnGroup);
header.appendChild(titleRow);
panel.appendChild(header);
// Content wrapper (search + scrollable content)
const contentWrap = document.createElement("div");
contentWrap.className = "gs-cv-content-wrap";
const content = document.createElement("div");
content.className = "gs-cv-content";
// Search bar
const searchBox = setupSearch(panel, content);
contentWrap.appendChild(searchBox);
// Track collapse state: key -> boolean (true = collapsed)
const collapseState = new Map();
function saveCollapseState() {
content.querySelectorAll(".gs-cv-group").forEach((g) => {
const key = "g:" + g.querySelector(".gs-cv-group-header")?.dataset.key;
const body = g.querySelector(".gs-cv-group-body");
if (body) collapseState.set(key, body.classList.contains("gs-cv-collapsed"));
});
content.querySelectorAll(".gs-cv-item").forEach((i) => {
const key = "i:" + i.querySelector(".gs-cv-item-header")?.dataset.key;
const body = i.querySelector(".gs-cv-item-body");
if (body) collapseState.set(key, body.classList.contains("gs-cv-collapsed"));
});
}
function buildContent(grouped) {
saveCollapseState();
content.innerHTML = "";
let total = 0;
function buildGroup(description, commentsList, items) {
const commentCount =
(commentsList?.length || 0) +
(items || []).reduce((s, i) => s + i.comments.length, 0);
if (commentCount === 0) return null;
const groupKey = "g:" + description;
const groupEl = document.createElement("div");
groupEl.className = "gs-cv-group";
const groupHeader = document.createElement("div");
groupHeader.className = "gs-cv-group-header";
groupHeader.dataset.key = description;
groupHeader.textContent = `${description} (${commentCount})`;
groupHeader.onclick = () =>
groupBody.classList.toggle("gs-cv-collapsed");
groupEl.appendChild(groupHeader);
const groupBody = document.createElement("div");
groupBody.className = "gs-cv-group-body";
// Restore state: if known, use saved; if new section, expand (not collapsed)
if (collapseState.has(groupKey)) {
if (collapseState.get(groupKey)) groupBody.classList.add("gs-cv-collapsed");
}
if (commentsList?.length) {
for (const c of commentsList) {
groupBody.appendChild(makeCommentEl(c));
total++;
}
}
if (items) {
for (const item of items) {
if (item.comments.length === 0) continue;
const itemKey = "i:" + item.description;
const itemEl = document.createElement("div");
itemEl.className = "gs-cv-item";
const itemHeader = document.createElement("div");
itemHeader.className = "gs-cv-item-header";
itemHeader.dataset.key = item.description;
itemHeader.textContent = `${item.description} (${item.comments.length})`;
itemHeader.onclick = (e) => {
e.stopPropagation();
itemBody.classList.toggle("gs-cv-collapsed");
};
itemEl.appendChild(itemHeader);
const itemBody = document.createElement("div");
itemBody.className = "gs-cv-item-body";
if (collapseState.has(itemKey)) {
if (collapseState.get(itemKey)) itemBody.classList.add("gs-cv-collapsed");
}
for (const c of item.comments) {
itemBody.appendChild(makeCommentEl(c));
total++;
}
itemEl.appendChild(itemBody);
groupBody.appendChild(itemEl);
}
}
groupEl.appendChild(groupBody);
return groupEl;
}
for (const group of grouped.groups) {
const el = buildGroup(
group.description,
group.groupComments,
group.items
);
if (el) content.appendChild(el);
}
if (grouped.ungrouped.length > 0) {
const el = buildGroup("Unlinked Comments", grouped.ungrouped, null);
if (el) content.appendChild(el);
}
return total;
}
let totalComments = buildContent(grouped);
contentWrap.appendChild(content);
panel.appendChild(contentWrap);
// --- Refresh logic ---
let refreshing = false;
async function refreshComments() {
if (refreshing) return;
refreshing = true;
refreshBtn.textContent = "...";
try {
const comments = await fetchComments();
const { rubricItems, rubricGroups } = extractPageData();
const newGrouped = groupComments(comments, rubricItems, rubricGroups);
totalComments = buildContent(newGrouped);
toggleBtn.textContent = `Comments (${totalComments})`;
// Re-trigger search if active
const searchInput = panel.querySelector(".gs-cv-search-input");
if (searchInput && searchInput.value.trim()) {
searchInput.dispatchEvent(new Event("input"));
}
console.log("[GS-CV] Refreshed:", totalComments, "comments");
} catch (err) {
console.error("[GS-CV] Refresh error:", err);
}
refreshBtn.textContent = "Refresh";
refreshing = false;
}
refreshBtn.onclick = refreshComments;
// --- Expand/Collapse All ---
expandAllBtn.onclick = () => {
content
.querySelectorAll(".gs-cv-collapsed")
.forEach((el) => el.classList.remove("gs-cv-collapsed"));
};
collapseAllBtn.onclick = () => {
content
.querySelectorAll(".gs-cv-group-body, .gs-cv-item-body")
.forEach((el) => el.classList.add("gs-cv-collapsed"));
};
// --- Minimize (collapse to just the header bar) ---
let minimized = false;
minimizeBtn.onclick = () => {
minimized = !minimized;
contentWrap.style.display = minimized ? "none" : "";
panel.querySelector(".gs-cv-resize-grip").style.display = minimized
? "none"
: "";
minimizeBtn.innerHTML = minimized ? "+" : "−";
minimizeBtn.title = minimized ? "Restore" : "Minimize";
if (minimized) {
panel.style.height = "auto";
panel.style.maxHeight = "none";
} else {
panel.style.height = "";
panel.style.maxHeight = "";
}
};
// --- Close ---
const toggleBtn = document.createElement("button");
toggleBtn.id = "gs-cv-toggle";
toggleBtn.textContent = `Comments (${totalComments})`;
toggleBtn.onclick = () => {
panel.style.display = "flex";
toggleBtn.style.display = "none";
};
closeBtn.onclick = () => {
panel.style.display = "none";
toggleBtn.style.display = "block";
};
// --- Styles ---
if (!document.getElementById("gs-cv-styles")) {
const style = document.createElement("style");
style.id = "gs-cv-styles";
style.textContent = `
#gs-comment-viewer {
position: fixed; top: 60px; left: 72px; width: 400px; height: calc(100vh - 200px);
background: #fff; border: 1px solid #ccc; border-radius: 8px;
box-shadow: 0 4px 20px rgba(0,0,0,0.15); z-index: 99999;
display: flex; flex-direction: column; font-family: -apple-system, BlinkMacSystemFont, sans-serif;
font-size: 14px; color: #333; overflow: hidden;
}
.gs-cv-header {
padding: 8px 12px; border-bottom: 1px solid #eee; background: #f8f9fa;
border-radius: 8px 8px 0 0; flex-shrink: 0;
}
.gs-cv-title-row {
display: flex; justify-content: space-between; align-items: center;
}
.gs-cv-title { font-weight: 600; font-size: 15px; margin-right: 4px; }
.gs-cv-btn-group { display: flex; gap: 4px; }
.gs-cv-btn {
background: none; border: 1px solid #ddd; border-radius: 4px;
padding: 2px 8px; font-size: 12px; cursor: pointer; color: #555;
}
.gs-cv-btn:hover { background: #e8e8e8; color: #000; }
.gs-cv-minimize, .gs-cv-close { font-size: 16px; padding: 0 6px; font-weight: bold; }
.gs-cv-content-wrap { display: flex; flex-direction: column; flex: 1; min-height: 0; }
.gs-cv-search { padding: 6px 8px; border-bottom: 1px solid #eee; flex-shrink: 0; position: relative; }
.gs-cv-search-input {
width: 100%; box-sizing: border-box; padding: 6px 28px 6px 10px;
border: 1px solid #ddd; border-radius: 4px; font-size: 13px; outline: none;
}
.gs-cv-search-input:focus { border-color: #4a90d9; box-shadow: 0 0 0 2px rgba(74,144,217,0.2); }
.gs-cv-search-clear {
position: absolute; right: 14px; top: 50%; transform: translateY(-50%);
background: none; border: none; font-size: 16px; color: #999; cursor: pointer;
padding: 0 4px; line-height: 1;
}
.gs-cv-search-clear:hover { color: #333; }
.gs-cv-content { overflow-y: auto; padding: 8px; flex: 1; }
.gs-cv-group { margin-bottom: 6px; border: 1px solid #e8e8e8; border-radius: 6px; overflow: hidden; }
.gs-cv-group-header {
padding: 9px 12px; background: #f0f4f8; font-weight: 600; font-size: 14px;
cursor: pointer; user-select: none;
}
.gs-cv-group-header:hover { background: #e4eaf0; }
.gs-cv-group-body { padding: 4px 8px; }
.gs-cv-group-body.gs-cv-collapsed { display: none; }
.gs-cv-item { margin: 4px 0; }
.gs-cv-item-header {
padding: 6px 10px; background: #fafafa; font-weight: 500; font-size: 14px;
color: #555; border-left: 3px solid #4a90d9; margin-bottom: 2px;
cursor: pointer; user-select: none;
}
.gs-cv-item-header:hover { background: #f0f0f0; }
.gs-cv-item-body { }
.gs-cv-item-body.gs-cv-collapsed { display: none; }
.gs-cv-comment {
padding: 8px 12px; margin: 3px 0; background: #fffef5; border-radius: 4px;
border: 1px solid #f0eedc; font-size: 14px; line-height: 1.5;
cursor: pointer; transition: background 0.1s; white-space: pre-wrap;
}
.gs-cv-comment:hover { background: #fff9d4; }
.gs-cv-comment.gs-cv-copied { background: #e6ffe6; border-color: #b3e6b3; }
.gs-cv-mark { background: #ffe066; color: #000; padding: 0 1px; border-radius: 2px; }
#gs-cv-toggle {
position: fixed; top: 60px; left: 72px; z-index: 99998;
background: #4a90d9; color: #fff; border: none; border-radius: 6px;
padding: 8px 14px; font-size: 14px; font-weight: 600; cursor: pointer;
box-shadow: 0 2px 8px rgba(0,0,0,0.15); display: none;
}
#gs-cv-toggle:hover { background: #3a7bc8; }
.gs-cv-resize-grip {
position: absolute; bottom: 0; right: 0; width: 16px; height: 16px;
cursor: nwse-resize;
background: linear-gradient(135deg, transparent 50%, #ccc 50%, transparent 55%, #ccc 65%, transparent 65%);
}
`;
document.head.appendChild(style);
}
const grip = document.createElement("div");
grip.className = "gs-cv-resize-grip";
panel.appendChild(grip);
makeDraggable(panel, header);
makeResizable(panel);
document.body.appendChild(panel);
document.body.appendChild(toggleBtn);
return { refresh: refreshComments };
}
function makeCommentEl(text) {
const el = document.createElement("div");
el.className = "gs-cv-comment";
el.textContent = text;
el.title = "Click to copy";
el.addEventListener("click", () => {
navigator.clipboard.writeText(text).then(() => {
el.classList.add("gs-cv-copied");
setTimeout(() => el.classList.remove("gs-cv-copied"), 600);
});
});
return el;
}
// --- Intercept fetch to detect comment mutations ---
function interceptFetch(onCommentChange) {
const origFetch = window.fetch;
window.fetch = function (url, opts) {
const result = origFetch.apply(this, arguments);
const method = (opts?.method || "GET").toUpperCase();
if (
method !== "GET" &&
typeof url === "string" &&
(url.includes("/annotation") || url.includes("/comments"))
) {
result.then(() => {
// Small delay to let the server process
setTimeout(onCommentChange, 500);
}).catch(() => { });
}
return result;
};
// Also intercept XMLHttpRequest for older code paths
const origOpen = XMLHttpRequest.prototype.open;
const origSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (method, url) {
this._gscvMethod = method;
this._gscvUrl = url;
return origOpen.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function () {
if (
this._gscvMethod &&
this._gscvMethod.toUpperCase() !== "GET" &&
typeof this._gscvUrl === "string" &&
(this._gscvUrl.includes("/annotation") ||
this._gscvUrl.includes("/comments"))
) {
this.addEventListener("load", () => {
setTimeout(onCommentChange, 500);
});
}
return origSend.apply(this, arguments);
};
}
// --- Main ---
async function init() {
console.log("[GS-CV] Script running on:", path);
const { rubricItems, rubricGroups } = extractPageData();
console.log(
"[GS-CV] Found rubric items:",
rubricItems.length,
"groups:",
rubricGroups.length
);
if (rubricItems.length === 0) return;
try {
const comments = await fetchComments();
console.log("[GS-CV] Fetched comments:", Object.keys(comments).length);
const grouped = groupComments(comments, rubricItems, rubricGroups);
const { refresh } = renderPanel(grouped);
console.log("[GS-CV] Panel rendered");
// Auto-refresh when comments are created/updated/deleted
interceptFetch(refresh);
} catch (err) {
console.error("[GS-CV] Error:", err);
const errBtn = document.createElement("button");
errBtn.id = "gs-cv-toggle";
errBtn.textContent = "Comments (error loading)";
errBtn.style.display = "block";
errBtn.style.background = "#d9534f";
errBtn.title = err.message;
errBtn.onclick = () => { errBtn.remove(); init(); };
document.body.appendChild(errBtn);
}
}
init();
})();