Modules

Automate course copies

  1. // ==UserScript==
  2. // @name Modules
  3. // @namespace CCAU
  4. // @description Automate course copies
  5. // @match https://*.instructure.com/courses/*/modules
  6. // @version 0.1.0
  7. // @author CIDT
  8. // @grant none
  9. // @license BSD-3-Clause
  10. // ==/UserScript==
  11. "use strict";
  12. (() => {
  13. // out/utils.js
  14. function addButton(name, fn, sel) {
  15. const bar = document.querySelector(sel);
  16. const btn = document.createElement("a");
  17. btn.textContent = name;
  18. btn.classList.add("btn");
  19. btn.setAttribute("tabindex", "0");
  20. btn.addEventListener("click", fn, false);
  21. bar?.insertAdjacentElement("afterbegin", btn);
  22. bar?.insertAdjacentHTML("afterbegin", " ");
  23. }
  24. function clickButton(sel) {
  25. const element = document.querySelector(sel);
  26. const btn = element;
  27. btn?.click();
  28. }
  29. function getChild(element, indices) {
  30. let cur = element;
  31. indices.forEach((i_) => {
  32. const children = cur?.children;
  33. const len = children.length;
  34. const i = i_ >= 0 ? i_ : len + i_;
  35. len > i ? cur = children[i] : null;
  36. });
  37. return cur;
  38. }
  39. function indexOf(name, skip = 0) {
  40. return moduleList().findIndex((m, i) => i >= skip && m.title.toLowerCase() === name.toLowerCase());
  41. }
  42. function lenientIndexOf(name, skip = 0) {
  43. return moduleList().findIndex((m, i) => i >= skip && lenientName(m.title) === lenientName(name));
  44. }
  45. function lenientName(name) {
  46. const ln = name.toLowerCase();
  47. const rgx = /^(week|module|unit) \d{1,2}(?=.?)/;
  48. const matches = ln.match(rgx);
  49. const result = matches ? matches[0] : null;
  50. if (ln.includes("start here")) {
  51. return "START HERE";
  52. }
  53. if (!result) {
  54. return null;
  55. }
  56. return "Week " + result.split(" ")[1];
  57. }
  58. function log(msg) {
  59. console.log("[CCAU] " + msg);
  60. }
  61. function moduleList() {
  62. const sel = ".collapse_module_link";
  63. const mods = Array.from(document.querySelectorAll(sel));
  64. return mods;
  65. }
  66. function openMenu(idx, btnIdx) {
  67. const mods = moduleList();
  68. const hpe = mods[idx].parentElement;
  69. const btn = getChild(hpe, [5, 0, btnIdx]);
  70. btn?.click();
  71. }
  72. function overrideConfirm() {
  73. const orig = window.confirm;
  74. window.confirm = () => true;
  75. return orig;
  76. }
  77. function restoreConfirm(orig) {
  78. window.confirm = orig;
  79. }
  80.  
  81. // out/date_headers/utils.js
  82. function actOnDates(idc, fn) {
  83. const rows = document.querySelectorAll(".ig-row");
  84. const len = rows.length;
  85. for (let i = 0; i < len; i++) {
  86. const rowItem = rows[i];
  87. const label = getChild(rowItem, [2, 0]);
  88. const btn = getChild(rowItem, idc);
  89. const nm = label?.innerText || "";
  90. const rgx = /^\*?[a-z]{3,12} \d{1,2} - [a-z]{0,12} ?\d{1,2}\*?$/;
  91. if (!rgx.test(nm.toLowerCase())) {
  92. continue;
  93. }
  94. btn?.click();
  95. fn(nm);
  96. }
  97. }
  98.  
  99. // out/date_headers/del.js
  100. function clickDelete(nm) {
  101. log(`Removing date header: ${nm}`);
  102. const nodes = document.querySelectorAll(".ui-kyle-menu");
  103. const menus = Array.from(nodes).map((e) => e);
  104. const len = menus.length;
  105. for (let i = 0; i < len; i++) {
  106. if (menus[i].getAttribute("aria-hidden") !== "false") {
  107. continue;
  108. }
  109. const miLen = menus[i].children.length;
  110. const btn = getChild(menus[i], [miLen - 1, 0]);
  111. btn?.click();
  112. }
  113. }
  114. function removeOldDates() {
  115. const orig = overrideConfirm();
  116. actOnDates([3, 2, 1, -1, 0], clickDelete);
  117. restoreConfirm(orig);
  118. }
  119.  
  120. // out/env.js
  121. var CORS_PROXY = "https://api.allorigins.win/get?url=";
  122. var DATA_URL = "https://text.is/ccau_data/raw";
  123.  
  124. // out/date_headers/modal.js
  125. function createModal(div) {
  126. const container = document.createElement("div");
  127. const content = document.createElement("div");
  128. container.className = "ccau_modal";
  129. container.style.position = "fixed";
  130. container.style.top = "0";
  131. container.style.left = "0";
  132. container.style.width = "100%";
  133. container.style.height = "100%";
  134. container.style.backgroundColor = "rgba(0, 0, 0, 0.5)";
  135. container.style.display = "flex";
  136. container.style.justifyContent = "center";
  137. container.style.alignItems = "center";
  138. container.style.zIndex = "1000";
  139. content.classList.add("ccau_modal_content");
  140. content.classList.add("ui-dialog");
  141. content.classList.add("ui-widget");
  142. content.classList.add("ui-widget-content");
  143. content.classList.add("ui-corner-all");
  144. content.classList.add("ui-dialog-buttons");
  145. content.style.padding = "20px";
  146. content.style.textAlign = "center";
  147. document.body.appendChild(container);
  148. container.appendChild(content);
  149. content.appendChild(div);
  150. return container;
  151. }
  152. function semesterButtons() {
  153. const cached = localStorage.getItem("ccau_data") ?? "{}";
  154. const data = JSON.parse(cached);
  155. const semesters = Object.keys(data["dates"]);
  156. return semesters.map((sem) => {
  157. const button = document.createElement("button");
  158. button.textContent = sem;
  159. button.classList.add("ccau_semester_button");
  160. button.classList.add("btn");
  161. button.style.margin = "5px";
  162. return button;
  163. });
  164. }
  165. function termButtons(semester) {
  166. const data = JSON.parse(localStorage.getItem("ccau_data") || "{}");
  167. const terms = Object.keys(data["ranges"][semester]);
  168. return terms.map((term) => {
  169. const button = document.createElement("button");
  170. button.textContent = term;
  171. button.classList.add("ccau_term_button");
  172. button.classList.add("btn");
  173. button.style.margin = "5px";
  174. return button;
  175. });
  176. }
  177. function replaceButtons(semester) {
  178. const sel = ".ccau_semester_button";
  179. const buttons = Array.from(document.querySelectorAll(sel));
  180. buttons.forEach((button) => button.remove());
  181. const newButtons = termButtons(semester);
  182. const modal = document.querySelector(".ccau_modal_content");
  183. if (!modal) {
  184. throw new Error("Can't add buttons to null modal");
  185. }
  186. newButtons.forEach((button) => modal.appendChild(button));
  187. }
  188. async function showModal() {
  189. const div = document.createElement("div");
  190. const buttons = semesterButtons();
  191. const label = document.createElement("div");
  192. label.textContent = "Which semester is this course?";
  193. div.appendChild(label);
  194. let semester = null;
  195. let term = null;
  196. return new Promise((resolve) => {
  197. const tCallback = (btn) => {
  198. btn.addEventListener("click", () => {
  199. term = btn.textContent;
  200. resolve([semester, term]);
  201. modal.remove();
  202. });
  203. };
  204. const sCallback = (btn) => {
  205. btn.addEventListener("click", () => {
  206. semester = btn.textContent;
  207. replaceButtons(semester || "");
  208. Array.from(document.querySelectorAll(".ccau_term_button")).map((e) => e).forEach(tCallback);
  209. });
  210. div.appendChild(btn);
  211. };
  212. buttons.forEach(sCallback);
  213. const modal = createModal(div);
  214. });
  215. }
  216.  
  217. // out/date_headers/update.js
  218. function update() {
  219. const day = 1e3 * 60 * 60 * 24;
  220. const now = Date.now();
  221. const last = Number(localStorage.getItem("ccau_data_ts")) ?? 0;
  222. if (now - last < day) {
  223. return;
  224. }
  225. fetch(CORS_PROXY + encodeURIComponent(DATA_URL)).then((response) => response.json()).then((data) => {
  226. localStorage.setItem("ccau_data", data["contents"]);
  227. localStorage.setItem("ccau_data_ts", now.toString());
  228. });
  229. }
  230. function getRawDates(sem) {
  231. const data = JSON.parse(localStorage.getItem("ccau_data") || "{}");
  232. const dates = data["dates"][sem];
  233. if (!dates) {
  234. log(`No dates found for ${sem}`);
  235. return null;
  236. }
  237. return dates;
  238. }
  239. function getDateRange(sem, term) {
  240. const data = JSON.parse(localStorage.getItem("ccau_data") || "{}");
  241. const ret = data["ranges"][sem][term];
  242. if (!ret) {
  243. log(`No range found for ${sem} ${term}`);
  244. return null;
  245. }
  246. return ret;
  247. }
  248. function datesInRange(dates, range) {
  249. return range.split(",").flatMap((r) => {
  250. const nums = r.split("-").map(Number);
  251. const start = nums[0];
  252. const end = nums[1];
  253. return dates.slice(start - 1, end || start);
  254. });
  255. }
  256. function mapToWeeks(dates) {
  257. const dict = {};
  258. for (let i = 0; i < dates.length; i++) {
  259. dict[`Week ${i + 1}`] = dates[i];
  260. }
  261. return dict;
  262. }
  263. async function getDates() {
  264. return new Promise((resolve) => {
  265. update();
  266. showModal().then(async ([sem, term]) => {
  267. if (!sem || !term) {
  268. resolve({});
  269. return;
  270. }
  271. const rawDates = getRawDates(sem);
  272. const range = getDateRange(sem, term);
  273. if (!rawDates || !range) {
  274. resolve({});
  275. return;
  276. }
  277. const dates = datesInRange(rawDates, range);
  278. resolve(mapToWeeks(dates));
  279. });
  280. });
  281. }
  282.  
  283. // out/date_headers/add.js
  284. function defaultToSubheader() {
  285. const sel = "#add_module_item_select";
  286. const element = document.querySelector(sel);
  287. const select = element;
  288. const options = Array.from(select.options);
  289. options?.forEach((opt) => opt.value = "context_module_sub_header");
  290. }
  291. function publish() {
  292. actOnDates([3, 1, 0], (_) => {
  293. });
  294. }
  295. function setInput(sel, val) {
  296. const element = document.querySelector(sel);
  297. const textBox = element;
  298. textBox.value = val;
  299. }
  300. async function addDates() {
  301. removeOldDates();
  302. defaultToSubheader();
  303. const dates = await getDates();
  304. const mods = moduleList();
  305. const endIdx_ = indexOf("START HERE", 1);
  306. const endIdx = endIdx_ === -1 ? mods.length : endIdx_;
  307. for (let i = 0; i < endIdx; i++) {
  308. const title = mods[i].title;
  309. const name = lenientName(title);
  310. if (!name || !dates[name]) {
  311. log(`No date found for ${name ?? title}`);
  312. continue;
  313. }
  314. openMenu(indexOf(name), 2);
  315. setInput("#sub_header_title", dates[name]);
  316. clickButton(".add_item_button");
  317. }
  318. setTimeout(publish, 1500);
  319. }
  320. function dateButton() {
  321. addButton("Add Dates", addDates, ".header-bar-right__buttons");
  322. }
  323.  
  324. // out/modules/utils.js
  325. function isEmpty(idx) {
  326. const mods = moduleList();
  327. const mod = mods[idx].parentElement?.parentElement;
  328. return getChild(mod, [2, 0])?.children.length === 0;
  329. }
  330. function getReactHandler(obj) {
  331. const sel = "__reactEventHandler";
  332. const keys = Object.keys(obj);
  333. const key = keys.find((k) => k.startsWith(sel));
  334. return key;
  335. }
  336.  
  337. // out/modules/del.js
  338. function clickDelete2() {
  339. const sel = ".ui-kyle-menu";
  340. const menus = Array.from(document.querySelectorAll(sel));
  341. const len = menus.length;
  342. for (let i = 0; i < len; i++) {
  343. if (menus[i].getAttribute("aria-hidden") !== "false") {
  344. continue;
  345. }
  346. const menuItem = menus[i];
  347. const btn = getChild(menuItem, [4, 0]);
  348. btn?.click();
  349. }
  350. }
  351. function removeEmpty() {
  352. const orig = overrideConfirm();
  353. const mods = moduleList();
  354. const len = mods.length;
  355. for (let i = 0; i < len - 1; i++) {
  356. if (!isEmpty(i)) {
  357. continue;
  358. }
  359. openMenu(i, 3);
  360. clickDelete2();
  361. }
  362. restoreConfirm(orig);
  363. }
  364. function deleteButton() {
  365. addButton("Remove Empty", removeEmpty, ".header-bar-right__buttons");
  366. }
  367.  
  368. // out/modules/mov.js
  369. function clickMoveContents() {
  370. const sel = ".ui-kyle-menu";
  371. const menus = Array.from(document.querySelectorAll(sel));
  372. const len = menus.length;
  373. for (let i = 0; i < len; i++) {
  374. if (menus[i].getAttribute("aria-hidden") !== "false") {
  375. continue;
  376. }
  377. const menuItem = menus[i];
  378. const btn = getChild(menuItem, [2, 0]);
  379. btn?.click();
  380. }
  381. }
  382. function selectDestination(name) {
  383. const form = document.querySelector(".move-select-form");
  384. const options = Array.from(form?.options ?? []);
  385. const len = options.length;
  386. if (!form) {
  387. throw new Error("Could not find .move-select-form");
  388. }
  389. for (let i = 0; i < len; i++) {
  390. const opt = options[i];
  391. const handlerName = getReactHandler(form);
  392. const handler = form[handlerName ?? ""];
  393. const fakeObj = { target: { value: opt.value } };
  394. if (opt.text !== name) {
  395. continue;
  396. }
  397. form.selectedIndex = i;
  398. form.value = options[i].value;
  399. handler.onChange(fakeObj);
  400. return true;
  401. }
  402. return false;
  403. }
  404. function moveAll() {
  405. const startIdx = lenientIndexOf("START HERE", 1);
  406. const mods = moduleList();
  407. const len = mods.length;
  408. if (startIdx === -1) {
  409. throw new Error("START HERE not found, add it and reload");
  410. }
  411. for (let i = startIdx; i < len; i++) {
  412. const title = mods[i].title;
  413. const name = lenientName(title);
  414. const idx = indexOf(title, startIdx);
  415. if (!name || isEmpty(i)) {
  416. continue;
  417. }
  418. openMenu(idx, 3);
  419. clickMoveContents();
  420. if (!selectDestination(name)) {
  421. throw new Error(`No destination selected for ${name}`);
  422. }
  423. clickButton("#move-item-tray-submit-button");
  424. }
  425. }
  426. function moveButton() {
  427. addButton("Auto-Move", moveAll, ".header-bar-right__buttons");
  428. }
  429.  
  430. // out/index.js
  431. function main() {
  432. if (!document.querySelector("#global_nav_accounts_link")) {
  433. throw new Error("Only admins can use this script");
  434. }
  435. dateButton();
  436. deleteButton();
  437. moveButton();
  438. }
  439. main();
  440. })();