// ==UserScript==
// @name Modules
// @namespace CCAU
// @description Automate course copies
// @match https://*.instructure.com/courses/*/modules
// @version 0.1.0
// @author CIDT
// @grant none
// @license BSD-3-Clause
// ==/UserScript==
"use strict";
(() => {
// out/utils.js
function addButton(name, fn, sel) {
const bar = document.querySelector(sel);
const btn = document.createElement("a");
btn.textContent = name;
btn.classList.add("btn");
btn.setAttribute("tabindex", "0");
btn.addEventListener("click", fn, false);
bar?.insertAdjacentElement("afterbegin", btn);
bar?.insertAdjacentHTML("afterbegin", " ");
}
function clickButton(sel) {
const element = document.querySelector(sel);
const btn = element;
btn?.click();
}
function getChild(element, indices) {
let cur = element;
indices.forEach((i_) => {
const children = cur?.children;
const len = children.length;
const i = i_ >= 0 ? i_ : len + i_;
len > i ? cur = children[i] : null;
});
return cur;
}
function indexOf(name, skip = 0) {
return moduleList().findIndex((m, i) => i >= skip && m.title.toLowerCase() === name.toLowerCase());
}
function lenientIndexOf(name, skip = 0) {
return moduleList().findIndex((m, i) => i >= skip && lenientName(m.title) === lenientName(name));
}
function lenientName(name) {
const ln = name.toLowerCase();
const rgx = /^(week|module|unit) \d{1,2}(?=.?)/;
const matches = ln.match(rgx);
const result = matches ? matches[0] : null;
if (ln.includes("start here")) {
return "START HERE";
}
if (!result) {
return null;
}
return "Week " + result.split(" ")[1];
}
function log(msg) {
console.log("[CCAU] " + msg);
}
function moduleList() {
const sel = ".collapse_module_link";
const mods = Array.from(document.querySelectorAll(sel));
return mods;
}
function openMenu(idx, btnIdx) {
const mods = moduleList();
const hpe = mods[idx].parentElement;
const btn = getChild(hpe, [5, 0, btnIdx]);
btn?.click();
}
function overrideConfirm() {
const orig = window.confirm;
window.confirm = () => true;
return orig;
}
function restoreConfirm(orig) {
window.confirm = orig;
}
// out/date_headers/utils.js
function actOnDates(idc, fn) {
const rows = document.querySelectorAll(".ig-row");
const len = rows.length;
for (let i = 0; i < len; i++) {
const rowItem = rows[i];
const label = getChild(rowItem, [2, 0]);
const btn = getChild(rowItem, idc);
const nm = label?.innerText || "";
const rgx = /^\*?[a-z]{3,12} \d{1,2} - [a-z]{0,12} ?\d{1,2}\*?$/;
if (!rgx.test(nm.toLowerCase())) {
continue;
}
btn?.click();
fn(nm);
}
}
// out/date_headers/del.js
function clickDelete(nm) {
log(`Removing date header: ${nm}`);
const nodes = document.querySelectorAll(".ui-kyle-menu");
const menus = Array.from(nodes).map((e) => e);
const len = menus.length;
for (let i = 0; i < len; i++) {
if (menus[i].getAttribute("aria-hidden") !== "false") {
continue;
}
const miLen = menus[i].children.length;
const btn = getChild(menus[i], [miLen - 1, 0]);
btn?.click();
}
}
function removeOldDates() {
const orig = overrideConfirm();
actOnDates([3, 2, 1, -1, 0], clickDelete);
restoreConfirm(orig);
}
// out/env.js
var CORS_PROXY = "https://api.allorigins.win/get?url=";
var DATA_URL = "https://text.is/ccau_data/raw";
// out/date_headers/modal.js
function createModal(div) {
const container = document.createElement("div");
const content = document.createElement("div");
container.className = "ccau_modal";
container.style.position = "fixed";
container.style.top = "0";
container.style.left = "0";
container.style.width = "100%";
container.style.height = "100%";
container.style.backgroundColor = "rgba(0, 0, 0, 0.5)";
container.style.display = "flex";
container.style.justifyContent = "center";
container.style.alignItems = "center";
container.style.zIndex = "1000";
content.classList.add("ccau_modal_content");
content.classList.add("ui-dialog");
content.classList.add("ui-widget");
content.classList.add("ui-widget-content");
content.classList.add("ui-corner-all");
content.classList.add("ui-dialog-buttons");
content.style.padding = "20px";
content.style.textAlign = "center";
document.body.appendChild(container);
container.appendChild(content);
content.appendChild(div);
return container;
}
function semesterButtons() {
const cached = localStorage.getItem("ccau_data") ?? "{}";
const data = JSON.parse(cached);
const semesters = Object.keys(data["dates"]);
return semesters.map((sem) => {
const button = document.createElement("button");
button.textContent = sem;
button.classList.add("ccau_semester_button");
button.classList.add("btn");
button.style.margin = "5px";
return button;
});
}
function termButtons(semester) {
const data = JSON.parse(localStorage.getItem("ccau_data") || "{}");
const terms = Object.keys(data["ranges"][semester]);
return terms.map((term) => {
const button = document.createElement("button");
button.textContent = term;
button.classList.add("ccau_term_button");
button.classList.add("btn");
button.style.margin = "5px";
return button;
});
}
function replaceButtons(semester) {
const sel = ".ccau_semester_button";
const buttons = Array.from(document.querySelectorAll(sel));
buttons.forEach((button) => button.remove());
const newButtons = termButtons(semester);
const modal = document.querySelector(".ccau_modal_content");
if (!modal) {
throw new Error("Can't add buttons to null modal");
}
newButtons.forEach((button) => modal.appendChild(button));
}
async function showModal() {
const div = document.createElement("div");
const buttons = semesterButtons();
const label = document.createElement("div");
label.textContent = "Which semester is this course?";
div.appendChild(label);
let semester = null;
let term = null;
return new Promise((resolve) => {
const tCallback = (btn) => {
btn.addEventListener("click", () => {
term = btn.textContent;
resolve([semester, term]);
modal.remove();
});
};
const sCallback = (btn) => {
btn.addEventListener("click", () => {
semester = btn.textContent;
replaceButtons(semester || "");
Array.from(document.querySelectorAll(".ccau_term_button")).map((e) => e).forEach(tCallback);
});
div.appendChild(btn);
};
buttons.forEach(sCallback);
const modal = createModal(div);
});
}
// out/date_headers/update.js
function update() {
const day = 1e3 * 60 * 60 * 24;
const now = Date.now();
const last = Number(localStorage.getItem("ccau_data_ts")) ?? 0;
if (now - last < day) {
return;
}
fetch(CORS_PROXY + encodeURIComponent(DATA_URL)).then((response) => response.json()).then((data) => {
localStorage.setItem("ccau_data", data["contents"]);
localStorage.setItem("ccau_data_ts", now.toString());
});
}
function getRawDates(sem) {
const data = JSON.parse(localStorage.getItem("ccau_data") || "{}");
const dates = data["dates"][sem];
if (!dates) {
log(`No dates found for ${sem}`);
return null;
}
return dates;
}
function getDateRange(sem, term) {
const data = JSON.parse(localStorage.getItem("ccau_data") || "{}");
const ret = data["ranges"][sem][term];
if (!ret) {
log(`No range found for ${sem} ${term}`);
return null;
}
return ret;
}
function datesInRange(dates, range) {
return range.split(",").flatMap((r) => {
const nums = r.split("-").map(Number);
const start = nums[0];
const end = nums[1];
return dates.slice(start - 1, end || start);
});
}
function mapToWeeks(dates) {
const dict = {};
for (let i = 0; i < dates.length; i++) {
dict[`Week ${i + 1}`] = dates[i];
}
return dict;
}
async function getDates() {
return new Promise((resolve) => {
update();
showModal().then(async ([sem, term]) => {
if (!sem || !term) {
resolve({});
return;
}
const rawDates = getRawDates(sem);
const range = getDateRange(sem, term);
if (!rawDates || !range) {
resolve({});
return;
}
const dates = datesInRange(rawDates, range);
resolve(mapToWeeks(dates));
});
});
}
// out/date_headers/add.js
function defaultToSubheader() {
const sel = "#add_module_item_select";
const element = document.querySelector(sel);
const select = element;
const options = Array.from(select.options);
options?.forEach((opt) => opt.value = "context_module_sub_header");
}
function publish() {
actOnDates([3, 1, 0], (_) => {
});
}
function setInput(sel, val) {
const element = document.querySelector(sel);
const textBox = element;
textBox.value = val;
}
async function addDates() {
removeOldDates();
defaultToSubheader();
const dates = await getDates();
const mods = moduleList();
const endIdx_ = indexOf("START HERE", 1);
const endIdx = endIdx_ === -1 ? mods.length : endIdx_;
for (let i = 0; i < endIdx; i++) {
const title = mods[i].title;
const name = lenientName(title);
if (!name || !dates[name]) {
log(`No date found for ${name ?? title}`);
continue;
}
openMenu(indexOf(name), 2);
setInput("#sub_header_title", dates[name]);
clickButton(".add_item_button");
}
setTimeout(publish, 1500);
}
function dateButton() {
addButton("Add Dates", addDates, ".header-bar-right__buttons");
}
// out/modules/utils.js
function isEmpty(idx) {
const mods = moduleList();
const mod = mods[idx].parentElement?.parentElement;
return getChild(mod, [2, 0])?.children.length === 0;
}
function getReactHandler(obj) {
const sel = "__reactEventHandler";
const keys = Object.keys(obj);
const key = keys.find((k) => k.startsWith(sel));
return key;
}
// out/modules/del.js
function clickDelete2() {
const sel = ".ui-kyle-menu";
const menus = Array.from(document.querySelectorAll(sel));
const len = menus.length;
for (let i = 0; i < len; i++) {
if (menus[i].getAttribute("aria-hidden") !== "false") {
continue;
}
const menuItem = menus[i];
const btn = getChild(menuItem, [4, 0]);
btn?.click();
}
}
function removeEmpty() {
const orig = overrideConfirm();
const mods = moduleList();
const len = mods.length;
for (let i = 0; i < len - 1; i++) {
if (!isEmpty(i)) {
continue;
}
openMenu(i, 3);
clickDelete2();
}
restoreConfirm(orig);
}
function deleteButton() {
addButton("Remove Empty", removeEmpty, ".header-bar-right__buttons");
}
// out/modules/mov.js
function clickMoveContents() {
const sel = ".ui-kyle-menu";
const menus = Array.from(document.querySelectorAll(sel));
const len = menus.length;
for (let i = 0; i < len; i++) {
if (menus[i].getAttribute("aria-hidden") !== "false") {
continue;
}
const menuItem = menus[i];
const btn = getChild(menuItem, [2, 0]);
btn?.click();
}
}
function selectDestination(name) {
const form = document.querySelector(".move-select-form");
const options = Array.from(form?.options ?? []);
const len = options.length;
if (!form) {
throw new Error("Could not find .move-select-form");
}
for (let i = 0; i < len; i++) {
const opt = options[i];
const handlerName = getReactHandler(form);
const handler = form[handlerName ?? ""];
const fakeObj = { target: { value: opt.value } };
if (opt.text !== name) {
continue;
}
form.selectedIndex = i;
form.value = options[i].value;
handler.onChange(fakeObj);
return true;
}
return false;
}
function moveAll() {
const startIdx = lenientIndexOf("START HERE", 1);
const mods = moduleList();
const len = mods.length;
if (startIdx === -1) {
throw new Error("START HERE not found, add it and reload");
}
for (let i = startIdx; i < len; i++) {
const title = mods[i].title;
const name = lenientName(title);
const idx = indexOf(title, startIdx);
if (!name || isEmpty(i)) {
continue;
}
openMenu(idx, 3);
clickMoveContents();
if (!selectDestination(name)) {
throw new Error(`No destination selected for ${name}`);
}
clickButton("#move-item-tray-submit-button");
}
}
function moveButton() {
addButton("Auto-Move", moveAll, ".header-bar-right__buttons");
}
// out/index.js
function main() {
if (!document.querySelector("#global_nav_accounts_link")) {
throw new Error("Only admins can use this script");
}
dateButton();
deleteButton();
moveButton();
}
main();
})();