// ==UserScript== // @name ReadWorks Book of Knowledge - Comment Exporter // @namespace https://github.com/nousresearch // @version 1.6.0 // @description Extract all student BOK comments with names from ReadWorks assignments. Export in multiple formats including AI-collage-ready text and A4 landscape print medley with density control (1,2,4,6,8,10 per page), article colors, and student interleaving. // @author Hermes Agent // @match https://www.readworks.org/* // @match https://*.readworks.org/* // @icon https://www.readworks.org/favicon.ico // @grant GM_setClipboard // @grant GM_addStyle // @grant GM_getValue // @grant GM_setValue // @run-at document-idle // @downloadURL https://update.greasyfork.org/scripts/584410/ReadWorks%20Book%20of%20Knowledge%20-%20Comment%20Exporter.user.js // @updateURL https://update.greasyfork.org/scripts/584410/ReadWorks%20Book%20of%20Knowledge%20-%20Comment%20Exporter.meta.js // ==/UserScript== // Source: https://github.com/thepick/userscripts /* -------------------------------------------- ReadWorks Book of Knowledge Comment Exporter v1.6.0 - Landscape orientation with adjusted grid layouts -------------------------------------------- */ (function () { "use strict"; const DEBUG = true; function log(...args) { if (DEBUG) console.log("[BOK Exporter]", ...args); } // -- Panel Styles ---------------------------- GM_addStyle(` #rw-bok-export-panel { position: fixed; bottom: 24px; right: 24px; z-index: 99999; background: #1e1e2e; color: #cdd6f4; border: 1px solid #45475a; border-radius: 12px; padding: 16px 20px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 13px; box-shadow: 0 8px 32px rgba(0,0,0,0.35); min-width: 290px; max-width: 380px; transition: all 0.2s ease; } #rw-bok-export-panel.minimized { min-width: auto; padding: 10px 14px; } #rw-bok-export-panel h3 { margin: 0 0 12px 0; font-size: 15px; font-weight: 700; color: #cba6f7; display: flex; align-items: center; gap: 8px; cursor: pointer; user-select: none; } #rw-bok-export-panel h3 .count-badge { background: #a6e3a1; color: #1e1e2e; border-radius: 10px; padding: 1px 8px; font-size: 12px; font-weight: 700; margin-left: auto; } #rw-bok-export-panel .rw-bok-body { display: flex; flex-direction: column; gap: 8px; } #rw-bok-export-panel .rw-section-label { font-size: 10px; text-transform: uppercase; letter-spacing: 1px; color: #6c7086; margin: 4px 0 0 0; padding-top: 8px; border-top: 1px solid #45475a; } #rw-bok-export-panel .rw-section-label:first-child { border-top: none; padding-top: 0; margin-top: 0; } #rw-bok-export-panel .rw-subject-row { display: flex; gap: 6px; align-items: center; } #rw-bok-export-panel .rw-subject-row input { flex: 1; padding: 6px 8px; border: 1px solid #45475a; border-radius: 6px; background: #313244; color: #cdd6f4; font-size: 12px; font-family: inherit; } #rw-bok-export-panel .rw-subject-row input::placeholder { color: #6c7086; } #rw-bok-export-panel .rw-density-row { display: flex; gap: 5px; align-items: center; } #rw-bok-export-panel .rw-density-row span { font-size: 11px; color: #a6adc8; white-space: nowrap; } #rw-bok-export-panel .rw-density-row button.density-btn { flex: 1; padding: 6px 0; font-size: 12px; text-align: center; border-radius: 6px; border: 1px solid #45475a; background: #313244; color: #cdd6f4; cursor: pointer; transition: all 0.15s; } #rw-bok-export-panel .rw-density-row button.density-btn.active { background: #5B8C85; border-color: #5B8C85; color: #fff; font-weight: 700; } #rw-bok-export-panel .rw-density-row button.density-btn:hover { background: #45475a; } #rw-bok-export-panel .rw-density-row button.density-btn.active:hover { background: #4a7a74; } #rw-bok-export-panel .rw-palette-row { display: flex; gap: 5px; align-items: center; } #rw-bok-export-panel .rw-palette-row span { font-size: 11px; color: #a6adc8; white-space: nowrap; } #rw-bok-export-panel .rw-palette-row button.palette-btn { flex: 1; padding: 6px 4px; font-size: 10px; text-align: center; border-radius: 6px; border: 1px solid #45475a; background: #313244; color: #cdd6f4; cursor: pointer; transition: all 0.15s; white-space: nowrap; overflow: hidden; } #rw-bok-export-panel .rw-palette-row button.palette-btn:hover { background: #45475a; } #rw-bok-export-panel .rw-palette-row button.palette-btn.active { border-color: #cba6f7; color: #cba6f7; font-weight: 700; } #rw-bok-export-panel .rw-palette-row button.palette-btn .swatch-dots { display: flex; gap: 2px; justify-content: center; margin-bottom: 2px; } #rw-bok-export-panel .rw-palette-row button.palette-btn .swatch-dot { width: 8px; height: 8px; border-radius: 50%; } #rw-bok-export-panel button { display: block; width: 100%; padding: 8px 12px; border: 1px solid #45475a; border-radius: 6px; background: #313244; color: #cdd6f4; cursor: pointer; font-size: 12px; text-align: left; transition: background 0.15s; } #rw-bok-export-panel button:hover { background: #45475a; } #rw-bok-export-panel button.primary { background: #cba6f7; color: #1e1e2e; border-color: #cba6f7; font-weight: 600; } #rw-bok-export-panel button.primary:hover { background: #b4befe; border-color: #b4befe; } #rw-bok-export-panel button.medley-btn { background: #a6e3a1; color: #1e1e2e; border-color: #a6e3a1; font-weight: 700; font-size: 13px; } #rw-bok-export-panel button.medley-btn:hover { background: #94e2d5; border-color: #94e2d5; } #rw-bok-export-panel button.debug-btn { background: #f38ba8; color: #1e1e2e; border-color: #f38ba8; font-weight: 600; } #rw-bok-export-panel .status-msg { font-size: 11px; color: #a6e3a1; min-height: 16px; margin-top: 4px; opacity: 0; transition: opacity 0.2s; } #rw-bok-export-panel .status-msg.show { opacity: 1; } #rw-bok-export-panel .toggle-icon { font-size: 14px; transition: transform 0.2s; } #rw-bok-export-panel.minimized .toggle-icon { transform: rotate(-90deg); } @media print { #rw-bok-export-panel { display: none !important; } } `); // -- Extract entries (now includes article title) - function extractEntries() { const entries = []; const bokEntries = document.querySelectorAll(".bok-entry"); bokEntries.forEach((entry) => { const nameEl = entry.querySelector(".student-name, [class*='student-name']"); const contentEl = entry.querySelector(".bok-submission-content, [class*='submission-content']"); const articleEl = entry.querySelector(".article-title"); if (nameEl && contentEl) { let name = nameEl.textContent.trim().replace(/\s+said:\s*$/i, ""); const comment = contentEl.textContent.trim(); const article = articleEl ? articleEl.textContent.trim() : ""; if (name && comment) { entries.push({ name, comment, article }); } } }); // Fallback: try old selectors if .bok-entry didn't work if (entries.length === 0) { const nameEls = document.querySelectorAll(".student-name, [class*='student-name']"); nameEls.forEach((nameEl) => { let parent = nameEl.parentElement; for (let i = 0; i < 5 && parent; i++) { const contentEl = parent.querySelector(".bok-submission-content, [class*='submission-content']"); if (contentEl) { const articleEl = parent.querySelector(".article-title"); entries.push({ name: nameEl.textContent.trim().replace(/\s+said:\s*$/i, ""), comment: contentEl.textContent.trim(), article: articleEl ? articleEl.textContent.trim() : "", }); break; } parent = parent.parentElement; } }); } // Deduplicate by name + comment + article const seen = new Set(); return entries.filter((e) => { const key = `${e.name}|||${e.comment}|||${e.article}`; if (seen.has(key)) return false; seen.add(key); return true; }); } // -- Interleave entries: minimize same student per page - function interleaveEntries(entries, perPage) { if (entries.length === 0) return []; // Group by student name const groups = {}; entries.forEach((e) => { if (!groups[e.name]) groups[e.name] = []; groups[e.name].push(e); }); const names = Object.keys(groups).sort(); const totalEntries = entries.length; const totalPages = Math.ceil(totalEntries / perPage); // Track which names have appeared on each page so far const usedOnPage = []; for (let p = 0; p < totalPages; p++) { usedOnPage.push(new Set()); } // Working copy: remaining entries per student const remaining = names.map((name) => ({ name, count: groups[name].length, entries: [...groups[name]], // shallow clone - we'll shift() from this })); const result = []; // Fill each page slot by slot for (let page = 0; page < totalPages; page++) { const slotsNeeded = Math.min(perPage, totalEntries - result.length); for (let slot = 0; slot < slotsNeeded; slot++) { // Sort: students with most remaining entries first (stable tie-break by name) remaining.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name)); // First choice: most remaining entries, NOT yet on this page let chosen = remaining.find( (c) => c.count > 0 && !usedOnPage[page].has(c.name) ); // Fallback: any student with remaining entries (repeat unavoidable) if (!chosen) { chosen = remaining.find((c) => c.count > 0); } if (chosen) { result.push(chosen.entries.shift()); chosen.count--; usedOnPage[page].add(chosen.name); } } } return result; } // -- Debug ----------------------------------- function debugPageStructure() { log("=== PAGE DEBUG ==="); log("URL:", window.location.href); const entries = extractEntries(); log(`Entries: ${entries.length}`); const articles = new Set(entries.map((e) => e.article).filter(Boolean)); log(`Unique articles: ${[...articles].join(", ")}`); const names = new Set(entries.map((e) => e.name)); log(`Unique students: ${[...names].join(", ")}`); entries.slice(0, 3).forEach((e, i) => { log(` [${i}] ${e.name} | ${e.article} | "${e.comment.substring(0, 60)}..."`); }); log("=== END DEBUG ==="); } // -- Scrape opened assignment / student-summary title -------------------- function scrapeSubject() { function cleanTitleText(el) { if (!el) return ""; const clone = el.cloneNode(true); // Remove things that are not part of the title. const junkSelectors = [ "button", "i", "svg", "img", ".assignment-date", "[class*='date']", ".entry-count", "[class*='entry-count']", ".student-name", "[class*='student-name']", ".bok-submission-content", "[class*='submission-content']" ]; junkSelectors.forEach(function (sel) { const nodes = clone.querySelectorAll(sel); nodes.forEach(function (node) { node.remove(); }); }); let txt = clone.textContent || ""; txt = txt .replace(/\s+/g, " ") .replace(/\b[A-Z][a-z]{2}\s+\d{1,2}\s*-\s*[A-Z][a-z]{2}\s+\d{1,2},\s*\d{4}\b/g, "") .replace(/\b[A-Z][a-z]{2}\s+\d{1,2},\s*\d{4}\b/g, "") .replace(/\b\d+\s+entries?\b/gi, "") .trim(); return txt; } function isGoodTitle(txt) { if (!txt) return false; if (txt.length < 2) return false; if (/readworks|book of knowledge|assignment|student reflections/i.test(txt)) return false; if (/^[A-Z][a-z]{2}\s+\d{1,2}/.test(txt)) return false; return true; } // 1. Best match: the expanded/open assignment section that contains student summaries. const openPanels = Array.from(document.querySelectorAll( "section.bok-panel.expanded, section[class*='bok-panel'][class*='expanded'], section[aria-expanded='true']" )); for (let i = 0; i < openPanels.length; i++) { const panel = openPanels[i]; // Make sure this is a student-summary panel, not just any open panel. if (!panel.querySelector(".bok-entry, [class*='bok-entry'], .bok-submission-content, [class*='submission-content']")) { continue; } const header = panel.querySelector( ".bok-assignment-header, [id^='bok-assignment-header'], [class*='assignment-header']" ); if (header) { const titleCandidates = header.querySelectorAll( "h1, h2, h3, h4, .product-title, .assignment-title, [class*='title']" ); for (let j = 0; j < titleCandidates.length; j++) { const txt = cleanTitleText(titleCandidates[j]); if (isGoodTitle(txt)) return txt; } const headerTxt = cleanTitleText(header); if (isGoodTitle(headerTxt)) return headerTxt; } } // 2. Fallback: find an expanded accordion button, then use its matching header. const expandedButtons = Array.from(document.querySelectorAll( "button[aria-expanded='true'][aria-controls]" )); for (let i = 0; i < expandedButtons.length; i++) { const btn = expandedButtons[i]; const header = btn.closest( ".bok-assignment-header, [id^='bok-assignment-header'], [class*='assignment-header'], header" ); if (header) { const titleCandidates = header.querySelectorAll( "h1, h2, h3, h4, .product-title, .assignment-title, [class*='title']" ); for (let j = 0; j < titleCandidates.length; j++) { const txt = cleanTitleText(titleCandidates[j]); if (isGoodTitle(txt)) return txt; } const headerTxt = cleanTitleText(header); if (isGoodTitle(headerTxt)) return headerTxt; } } // 3. Older ReadWorks article-title fallback. const oldSelectors = [ "#content h3.product-title", "main h3.product-title", "h3.product-title", ".assignment-title", "[class*='assignment-title']", ".reading-title", "[class*='reading-title']", ".page-title", "[class*='page-title']", "h1" ]; for (let i = 0; i < oldSelectors.length; i++) { const el = document.querySelector(oldSelectors[i]); const txt = cleanTitleText(el); if (isGoodTitle(txt)) return txt; } return ""; } // -- Scrape assignment date ------------------ function scrapeAssignmentDate() { const dateEl = document.querySelector(".assignment-date"); if (!dateEl) return ""; // Clone so we can safely remove child elements const clone = dateEl.cloneNode(true); // Remove the .entry-count span if present const countSpan = clone.querySelector(".entry-count"); if (countSpan) countSpan.remove(); return clone.textContent.trim(); } // -- Escape HTML ----------------------------- function esc(s) { return String(s).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"); } // -- Article color palettes (ink-efficient, 10 colors each) - const ARTICLE_PALETTES = [ { // 0 - Earthy Warm name: "Earthy Warm", colors: [ { border: "#B86B3D", bg: "#FDF6F0" }, // terracotta { border: "#5B8C85", bg: "#F2F7F6" }, // sage { border: "#8B5E83", bg: "#F8F3F7" }, // muted plum { border: "#C4843B", bg: "#FDF8F2" }, // warm amber { border: "#4A7C96", bg: "#F2F6F8" }, // slate blue { border: "#9B6B4F", bg: "#F9F5F1" }, // cocoa { border: "#6B8E6B", bg: "#F4F7F3" }, // olive { border: "#A0525C", bg: "#FBF5F5" }, // dusty rose { border: "#5C7A6B", bg: "#F3F7F5" }, // pine { border: "#8B6D4A", bg: "#F9F6F2" }, // warm brown ], }, { // 1 - Ocean Cool name: "Ocean Cool", colors: [ { border: "#3A7D8C", bg: "#F2F7F8" }, // deep teal { border: "#C07060", bg: "#FDF6F4" }, // coral { border: "#6B7DB3", bg: "#F5F5FA" }, // periwinkle { border: "#3D5A80", bg: "#F3F5F9" }, // navy { border: "#6BAA9E", bg: "#F3F8F7" }, // seafoam { border: "#C4A882", bg: "#F9F6F2" }, // sand { border: "#7B9EB3", bg: "#F4F7F9" }, // sky steel { border: "#8E6B7B", bg: "#F9F5F7" }, // mauve { border: "#5A8F8F", bg: "#F2F7F6" }, // deep aqua { border: "#A09080", bg: "#F8F6F4" }, // driftwood ], }, { // 2 - Garden Fresh name: "Garden Fresh", colors: [ { border: "#6B9E5A", bg: "#F4F8F2" }, // leaf green { border: "#9B8EC4", bg: "#F7F5FA" }, // lavender { border: "#C4B85A", bg: "#FCFAF3" }, // butter yellow { border: "#6B9EC4", bg: "#F3F7FA" }, // sky blue { border: "#C47B8E", bg: "#FCF5F7" }, // rose { border: "#8EC4A0", bg: "#F4F9F6" }, // mint { border: "#C49B6B", bg: "#FBF7F2" }, // honey { border: "#8EB3C4", bg: "#F4F8FA" }, // powder blue { border: "#B38EC4", bg: "#F8F5FA" }, // lilac { border: "#9EC47B", bg: "#F6F9F3" }, // celery ], }, { // 3 - Sunset Glow name: "Sunset Glow", colors: [ { border: "#C49B7B", bg: "#FCF7F3" }, // peach { border: "#C47B8E", bg: "#FCF5F7" }, // dusty rose { border: "#C4A85A", bg: "#FCFAF3" }, // goldenrod { border: "#A08EC4", bg: "#F7F5FA" }, // lilac { border: "#9B9B9B", bg: "#F6F6F6" }, // warm grey { border: "#C48B5A", bg: "#FCF6F2" }, // soft orange { border: "#C49494", bg: "#FBF6F6" }, // blush { border: "#A0A8C4", bg: "#F6F7FA" }, // lavender grey { border: "#C4B08B", bg: "#FBF8F4" }, // wheat { border: "#8BA8C4", bg: "#F4F7FA" }, // cornflower ], }, ]; function getArticleColorMap(articles, paletteIndex) { const palette = ARTICLE_PALETTES[paletteIndex || 0].colors; const map = {}; articles.forEach((article, i) => { map[article] = palette[i % palette.length]; }); return map; } function readPaletteIndex() { const activeBtn = document.querySelector("#rw-bok-export-panel .palette-btn.active"); return activeBtn ? parseInt(activeBtn.dataset.palette) : 0; } // =============================================== // MEDLEY HTML GENERATOR // =============================================== function generateMedleyHTML(rawEntries, subject, perPage, paletteIndex) { paletteIndex = paletteIndex || 0; // Interleave first, then build const entries = interleaveEntries(rawEntries, perPage); // Build article color map const articles = [...new Set(entries.map((e) => e.article).filter(Boolean))]; const colorMap = getArticleColorMap(articles, paletteIndex); const scrapedDate = scrapeAssignmentDate(); const today = scrapedDate || new Date().toLocaleDateString("en-GB", { weekday: "long", year: "numeric", month: "long", day: "numeric", }); const subjectDisplay = subject || "This Week's Reading"; // -- Layout config by density --------------- const layoutConfig = { 1: { nameSize: "30pt", baseQuoteSize: 18, articleSize: "13pt", bubblePad: "10mm 10mm", cardPad: "12mm 12mm", maxWidth: "250mm", bubbleMaxW: "220mm", innerMinH: "100mm" }, 2: { nameSize: "22pt", baseQuoteSize: 14, articleSize: "11pt", bubblePad: "5mm 8mm", cardPad: "6mm 10mm", maxWidth: "100%", bubbleMaxW: "100%", innerMinH: "0" }, 4: { nameSize: "15pt", baseQuoteSize: 11, articleSize: "9pt", bubblePad: "4mm 6mm", cardPad: "4mm 6mm", maxWidth: "100%", bubbleMaxW: "100%", innerMinH: "0" }, 6: { nameSize: "12pt", baseQuoteSize: 10, articleSize: "8pt", bubblePad: "3mm 5mm", cardPad: "3mm 4mm", maxWidth: "100%", bubbleMaxW: "100%", innerMinH: "0" }, 8: { nameSize: "11pt", baseQuoteSize: 9, articleSize: "7pt", bubblePad: "2mm 4mm", cardPad: "2mm 3mm", maxWidth: "100%", bubbleMaxW: "100%", innerMinH: "0" }, 10: { nameSize: "10pt", baseQuoteSize: 8, articleSize: "7pt", bubblePad: "2mm 3mm", cardPad: "2mm 3mm", maxWidth: "100%", bubbleMaxW: "100%", innerMinH: "0" }, }; const cfg = layoutConfig[perPage] || layoutConfig[4]; // -- Dynamic font sizing based on comment length - function calcQuoteSize(comment, basePt) { const len = comment.length; let scale; if (len <= 60) scale = 1.25; // short -> bigger else if (len <= 180) scale = 1.08; // medium-short -> slightly bigger else if (len <= 400) scale = 1.0; // medium -> standard else if (len <= 700) scale = 0.85; // long -> smaller else scale = 0.72; // very long -> much smaller return (basePt * scale).toFixed(1) + "pt"; } // -- Build quote card HTML ------------------ function quoteCardHTML(entry, i) { const palette = entry.article && colorMap[entry.article] ? colorMap[entry.article] : { border: "#B86B3D", bg: "#FDF6F0" }; const articleLine = entry.article ? `