// ==UserScript== // @name Knowt Flashcards - Printable Double-Sided // @namespace https://github.com/nousresearch // @version 1.2.0 // @description Extract flashcards from Knowt and generate a printable double-sided layout with terms on front and definitions/images on back. // @author Hermes Agent // @match https://knowt.com/flashcards/* // @icon https://knowt.com/favicon.ico // @grant GM_addStyle // @run-at document-idle // @downloadURL https://update.greasyfork.org/scripts/584409/Knowt%20Flashcards%20-%20Printable%20Double-Sided.user.js // @updateURL https://update.greasyfork.org/scripts/584409/Knowt%20Flashcards%20-%20Printable%20Double-Sided.meta.js // ==/UserScript== // Source: https://github.com/thepick/userscripts (function () { "use strict"; var DEBUG = true; function log() { if (!DEBUG) return; var args = Array.prototype.slice.call(arguments); args.unshift("[Knowt Print]"); console.log.apply(console, args); } // ------------------------------------------------------------ // Floating button styles // ------------------------------------------------------------ GM_addStyle(` #kt-print-btn { position: fixed !important; bottom: 24px !important; right: 24px !important; z-index: 2147483647 !important; padding: 12px 20px !important; background: #4F46E5 !important; color: #fff !important; border: none !important; border-radius: 8px !important; font-size: 15px !important; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif !important; font-weight: 700 !important; cursor: pointer !important; box-shadow: 0 4px 14px rgba(79,70,229,0.4) !important; transition: transform 0.15s, box-shadow 0.15s !important; display: flex !important; align-items: center !important; gap: 8px !important; opacity: 1 !important; pointer-events: auto !important; } #kt-print-btn:hover { transform: translateY(-2px) !important; box-shadow: 0 6px 20px rgba(79,70,229,0.5) !important; } #kt-print-btn:active { transform: translateY(0) !important; } #kt-print-btn:disabled { opacity: 0.6 !important; cursor: not-allowed !important; transform: none !important; } `); // ------------------------------------------------------------ // Text helpers // ------------------------------------------------------------ function getCleanText(el) { if (!el) return ""; return String(el.textContent || "") .replace(/\s+/g, " ") .trim(); } function escapeHTML(str) { var div = document.createElement("div"); div.textContent = String(str || ""); return div.innerHTML; } function escapeAttr(str) { return String(str || "") .replace(/&/g, "&") .replace(/"/g, """) .replace(//g, ">"); } // ------------------------------------------------------------ // DOM scraping // ------------------------------------------------------------ // Knowt virtualizes its editor: scrolling replaces the mounted cards. // Use row identity and explicit sides, never visibility or text deduplication. function findAllCardContainers() { return Array.from(document.querySelectorAll('[id^="flashcardCard-"][data-card-index]')); } function extractCardData(container) { function side(name) { var editor = container.querySelector('[data-testid="flashcard-' + name + '-editor"]'); if (!editor) throw new Error("A card editor has not rendered yet. Please try again."); var area = editor; // The image is a sibling of the text column within this side's row. while (area.parentElement && area.parentElement !== container && area.parentElement.querySelectorAll('[data-testid$="-editor"]').length === 1) { area = area.parentElement; } var img = area.querySelector('img'); return {text: getCleanText(editor), image: img ? img.currentSrc || img.src || "" : ""}; } var term = side("term"); var definition = side("definition"); return { id: container.id, index: Number(container.dataset.cardIndex), term: term.text, definition: definition.text, termImageUrl: term.image, imageUrl: definition.image }; } async function collectAllCards(btn) { var list = document.querySelector('[data-testid="virtuoso-item-list"]'); if (!list) throw new Error("Open the flashcard set's Edit page, then try Print Flashcards again."); var viewport = list.closest('[data-viewport-type]'); var scroller = viewport && viewport.dataset.viewportType === "window" ? document.scrollingElement : list.closest('[data-virtuoso-scroller]'); if (!scroller) throw new Error("Could not find the flashcard scroll area."); var originalTop = scroller.scrollTop; var originalBehavior = scroller.style.scrollBehavior; var route = location.href; var cards = new Map(); var pause = function () { return new Promise(function (resolve) { setTimeout(resolve, 300); }); }; var observer = new MutationObserver(capture); function capture() { findAllCardContainers().forEach(function (row) { if (!row.querySelector('[data-testid="flashcard-term-editor"]') || !row.querySelector('[data-testid="flashcard-definition-editor"]')) return; var card = extractCardData(row); if (Number.isInteger(card.index) && card.index >= 0) cards.set(card.index, card); }); } scroller.style.scrollBehavior = "auto"; observer.observe(list, {childList: true, subtree: true, attributes: true, characterData: true}); try { scroller.scrollTop = 0; var stableBottom = 0; var previousSize = 0; var previousHeight = 0; var deadline = Date.now() + 180000; while (stableBottom < 5) { if (Date.now() > deadline) throw new Error("Collection timed out. Try again after the page finishes loading."); if (location.href !== route || !list.isConnected) throw new Error("The set changed during collection. Please try again."); await pause(); capture(); btn.textContent = "Collecting " + cards.size + " cards..."; var height = scroller.scrollHeight; var atBottom = scroller.scrollTop + scroller.clientHeight >= height - 2; stableBottom = atBottom && cards.size === previousSize && height === previousHeight ? stableBottom + 1 : 0; previousSize = cards.size; previousHeight = height; if (!atBottom) scroller.scrollTop += Math.max(100, Math.floor(scroller.clientHeight * 0.65)); } var result = Array.from(cards.values()).sort(function (a, b) { return a.index - b.index; }); if (!result.length) throw new Error("No cards were found. Open the set's Edit page and try again."); var missing = []; for (var i = 0; i <= result[result.length - 1].index; i++) { if (!cards.has(i)) missing.push(i + 1); } // Remaining virtual padding means the final rows have not mounted yet. var remaining = parseFloat(getComputedStyle(list).paddingBottom) || 0; if (missing.length || remaining > 2) { throw new Error("The set did not fully render. " + (missing.length ? "Missing card numbers: " + missing.join(", ") + ". " : "") + "Please try again and leave the page open while it scrolls."); } log("Collected", result.length, "cards in set order"); return result; } finally { observer.disconnect(); scroller.scrollTop = originalTop; scroller.style.scrollBehavior = originalBehavior; } } // ------------------------------------------------------------ // Print HTML // ------------------------------------------------------------ function getBackIndex(frontIndex, columns) { var row = Math.floor(frontIndex / columns); var col = frontIndex % columns; return row * columns + (columns - 1 - col); } function buildCardCellHTML(card, pageCardNumber, side, columns) { var html = ""; if (side === "front") { html += '
Best option: Click "Print all duplex", then choose double-sided printing and "Flip on long edge" in your printer dialog.
Manual option: Print fronts only first. Put the paper back in the printer, then print backs only.
The back pages are mirrored left-to-right so the definitions line up with the matching terms after duplex printing.
Important print settings: Paper size A4, scale 100, margins none, pages per sheet 1, headers and footers off.