const domain = window.location.origin;
const current_page = window.location.pathname;
function getCurrentCourseId() {
const match = current_page.match(/^\/courses\/(\d+)(?:\/|$)/);
return match ? parseInt(match[1]) : null;
}
function getSidebarLayoutMode() {
if (current_page.match(/^\/courses\/(\d+)(?:\/|$)/)) return "course";
if (isProfilePage()) return "course";
if (current_page === "/courses" || current_page === "/courses/") return "dash";
if (current_page === "/" || current_page === "") return "dash";
return "dash";
}
function isGradesPage() {
return /^\/courses\/\d+\/grades(?:\/|$)/.test(current_page);
}
function isCoursesIndexPage() {
return /^\/courses\/?$/.test(current_page);
}
function isGroupsIndexPage() {
return /^\/groups\/?$/.test(current_page);
}
function isConversationsPage() {
return /^\/conversations(?:\/|$)/.test(current_page);
}
function isAccountsPage() {
return /^\/accounts(?:\/|$)/.test(current_page);
}
function isProfilePage() {
return /^\/profile(?:\/|$)/.test(current_page);
}
function getSubmissionAssignmentLink() {
const match = current_page.match(/^\/courses\/(\d+)\/assignments\/(\d+)\/submissions\/(\d+)(?:\/|$)/);
if (!match) return null;
return `${domain}/courses/${match[1]}/assignments/${match[2]}/`;
}
let submissionPageButtonObserver = null;
let profileLogoutButtonObserver = null;
let newCanvasButtonObserver = null;
function addSubmissionPageButton() {
const assignmentLink = getSubmissionAssignmentLink();
if (!assignmentLink) return;
const content = document.getElementById("content");
if (!content || content.querySelector("#canvasrefined-assignment-return")) return;
makeElement("a", content, {
id: "canvasrefined-assignment-return",
className: "canvasrefined-custom-btn",
href: assignmentLink,
textContent: "Back to Assignment",
style: "display:inline-flex;align-items:center;justify-content:center;align-self:flex-start;margin:0 0 12px 0;padding:10px 14px;text-decoration:none;font-weight:700;",
}, true);
}
function addProfileLogoutPageButton() {
if (!isProfilePage()) return;
const content = document.getElementById("content");
if (!content || content.querySelector("#canvasrefined-profile-logout")) return;
makeElement("a", content, {
id: "canvasrefined-profile-logout",
className: "canvasrefined-custom-btn",
href: `${domain}/logout`,
textContent: "Logout",
style: "display:inline-flex;align-items:center;justify-content:center;align-self:flex-start;margin:0 0 12px 0;padding:10px 14px;text-decoration:none;font-weight:700;",
}, true);
}
function ensureProfileLogoutPageButton() {
if (!isProfilePage()) return false;
const content = document.getElementById("content");
if (!content) return false;
if (content.querySelector("#canvasrefined-profile-logout")) return true;
addProfileLogoutPageButton();
return Boolean(content.querySelector("#canvasrefined-profile-logout"));
}
function watchProfileLogoutPageButton() {
if (!isProfilePage()) return;
if (ensureProfileLogoutPageButton()) return;
if (profileLogoutButtonObserver) return;
profileLogoutButtonObserver = new MutationObserver(() => {
if (ensureProfileLogoutPageButton() && profileLogoutButtonObserver) {
profileLogoutButtonObserver.disconnect();
profileLogoutButtonObserver = null;
}
});
profileLogoutButtonObserver.observe(document.documentElement, { childList: true, subtree: true });
setTimeout(() => {
if (profileLogoutButtonObserver) {
profileLogoutButtonObserver.disconnect();
profileLogoutButtonObserver = null;
}
}, 10000);
}
function ensureSubmissionPageButton() {
const assignmentLink = getSubmissionAssignmentLink();
if (!assignmentLink) return false;
const content = document.getElementById("content");
if (!content) return false;
if (content.querySelector("#canvasrefined-assignment-return")) return true;
addSubmissionPageButton();
return Boolean(content.querySelector("#canvasrefined-assignment-return"));
}
function watchSequenceFooter() {
if (!isAssignmentPage()) return;
if (removeSequenceFooter()) return;
if (sequenceFooterObserver) return;
sequenceFooterObserver = new MutationObserver(() => {
if (removeSequenceFooter() && sequenceFooterObserver) {
sequenceFooterObserver.disconnect();
sequenceFooterObserver = null;
}
});
sequenceFooterObserver.observe(document.documentElement, { childList: true, subtree: true });
setTimeout(() => {
if (sequenceFooterObserver) {
sequenceFooterObserver.disconnect();
sequenceFooterObserver = null;
}
}, 10000);
}
function watchSubmissionPageButton() {
if (!getSubmissionAssignmentLink()) return;
if (ensureSubmissionPageButton()) return;
if (submissionPageButtonObserver) return;
submissionPageButtonObserver = new MutationObserver(() => {
if (ensureSubmissionPageButton() && submissionPageButtonObserver) {
submissionPageButtonObserver.disconnect();
submissionPageButtonObserver = null;
}
});
submissionPageButtonObserver.observe(document.documentElement, { childList: true, subtree: true });
setTimeout(() => {
if (submissionPageButtonObserver) {
submissionPageButtonObserver.disconnect();
submissionPageButtonObserver = null;
}
}, 10000);
}
function removeNewCanvasButton() {
document.querySelectorAll('[data-testid="switch-to-new-dashboard-button"]').forEach(btn => btn.remove());
}
function watchNewCanvasButton() {
if (newCanvasButtonObserver) {
newCanvasButtonObserver.disconnect();
newCanvasButtonObserver = null;
}
if (options.hide_new_canvas !== true) return;
removeNewCanvasButton();
newCanvasButtonObserver = new MutationObserver(() => {
if (options.hide_new_canvas !== true) {
if (newCanvasButtonObserver) {
newCanvasButtonObserver.disconnect();
newCanvasButtonObserver = null;
}
return;
}
removeNewCanvasButton();
});
newCanvasButtonObserver.observe(document.documentElement, { childList: true, subtree: true });
}
async function getActiveCustomBackground() {
const syncOpts = await chrome.storage.sync.get([
"customBackgroundDaily",
"customBackgroundNasaDaily",
"customBackgroundLink",
"customBackgroundScale",
]);
console.log("[CanvasRefined] getActiveCustomBackground:", syncOpts);
if (syncOpts.customBackgroundNasaDaily === true) {
console.log("[CanvasRefined] Using NASA APOD");
return await getNasaDailyBackground();
}
if (syncOpts.customBackgroundDaily === true) {
console.log("[CanvasRefined] Using Wikimedia Featured");
const dailyPreset = await getDailyBackgroundPreset();
if (dailyPreset) {
console.log("[CanvasRefined] Wikimedia URL:", dailyPreset.url);
return {
url: dailyPreset.url,
scale: dailyPreset.scale,
};
}
console.log("[CanvasRefined] Wikimedia returned null");
}
if (syncOpts.customBackgroundLink && syncOpts.customBackgroundLink !== "") {
console.log("[CanvasRefined] Using custom link");
return {
url: syncOpts.customBackgroundLink,
scale: syncOpts.customBackgroundScale || 100,
};
}
console.log("[CanvasRefined] No custom background");
return null;
}
async function getDailyBackgroundPreset() {
const today = new Date();
const dateStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
const cacheKey = `picsum_daily_${dateStr}`;
const cached = await chrome.storage.local.get(cacheKey);
if (cached[cacheKey]) return cached[cacheKey];
const url = `https://picsum.photos/seed/${dateStr}/1920/1080`;
const result = { url, scale: 100 };
await chrome.storage.local.set({ [cacheKey]: result });
return result;
}
async function getNasaDailyBackground() {
try {
return await chrome.runtime.sendMessage({ type: "getNasaBackground" });
} catch (error) {
console.error("[CanvasRefined] Failed to fetch NASA APOD:", error);
return null;
}
}
let nasaInfoOverlayEl = null;
function isDashboardPage() {
return !!document.querySelector("#DashboardCard_Container");
}
function createNasaInfoOverlay() {
if (options.customBackgroundNasaDaily !== true) return;
if (nasaInfoOverlayEl || !isDashboardPage()) return;
const contentMain = document.querySelector("#content.ic-Layout-contentMain, .ic-Layout-contentMain");
if (!contentMain) return;
if (getComputedStyle(contentMain).position === "static") {
contentMain.style.position = "relative";
}
nasaInfoOverlayEl = document.createElement("div");
nasaInfoOverlayEl.id = "canvasrefined-nasa-info-overlay";
nasaInfoOverlayEl.style.cssText = "position:absolute;right:24px;bottom:24px;z-index:9999;";
nasaInfoOverlayEl.innerHTML = `
`;
const icon = nasaInfoOverlayEl.querySelector("#nasa-info-icon");
const panel = nasaInfoOverlayEl.querySelector("#nasa-info-panel");
const populatePanel = async () => {
// Search backward up to 7 days for the APOD actually cached, since today's may not be ready.
const date = new Date();
for (let i = 0; i < 7; i++) {
const dateStr = date.toISOString().slice(0, 10);
const cacheKey = `nasa_apod_${dateStr}`;
const cached = await chrome.storage.local.get(cacheKey);
if (!cached[cacheKey]) {
date.setDate(date.getDate() - 1);
continue;
}
const metadataKey = `nasa_apod_meta_${dateStr}`;
const metadata = await chrome.storage.local.get(metadataKey);
const meta = metadata[metadataKey];
if (!meta) return false;
document.getElementById("nasa-info-title").textContent = meta.title || "";
document.getElementById("nasa-info-date").textContent = `Date: ${meta.date}`;
document.getElementById("nasa-info-credit").textContent = meta.copyright ? `Credit: ${meta.copyright}` : "";
document.getElementById("nasa-info-explanation").textContent = meta.explanation || "No description available.";
return true;
}
return false;
};
let pinned = false;
const showPanel = async () => {
if (pinned) return;
if (await populatePanel()) panel.style.display = "block";
};
const hidePanel = () => {
if (pinned) return;
panel.style.display = "none";
};
const togglePanel = async () => {
if (pinned) {
pinned = false;
panel.style.display = "none";
} else {
pinned = true;
if (await populatePanel()) panel.style.display = "block";
}
};
icon.addEventListener("mouseenter", showPanel);
icon.addEventListener("mouseleave", hidePanel);
panel.addEventListener("mouseenter", showPanel);
panel.addEventListener("mouseleave", hidePanel);
icon.addEventListener("click", togglePanel);
contentMain.appendChild(nasaInfoOverlayEl);
}
function removeNasaInfoOverlay() {
if (nasaInfoOverlayEl) {
nasaInfoOverlayEl.remove();
nasaInfoOverlayEl = null;
}
}
function getSidebarStateMode(mode = getSidebarLayoutMode()) {
return mode === "course" ? "course" : "dashboard";
}
function getSidebarStateKey(mode = getSidebarLayoutMode()) {
return `better_sidebar_expanded_${getSidebarStateMode(mode)}`;
}
async function getSidebarExpandedState(mode = getSidebarLayoutMode()) {
const key = getSidebarStateKey(mode);
const result = await chrome.storage.local.get(key);
return result[key] ?? false;
}
function setSidebarExpandedState(mode, expanded) {
chrome.storage.local.set({ [getSidebarStateKey(mode)]: expanded });
}
let assignments = null;
let grades = null;
let announcements = [];
let completed = [];
let assignmentsDue = [];
let options = {};
let timeCheck = null;
let reminderCheck = null;
let betterSidebarLoading = false;
let dashboardReadyTimer = null;
let sidebarBadgeObserver = null;
let sidebarBadgeSyncTimer = null;
let sidebarBadgeWatchRetries = 0;
/*
Start
*/
/*
Todo Reminders
*/
const canvas_svg = ` `;
async function insertReminders(reminders) {
const toAdd = [];
const storage = await chrome.storage.sync.get("reminders");
// overrides = if theres a item that needs to update, but already exists
let overrides = false;
for (const insert of reminders) {
let found = false;
for (let i = 0; i < storage["reminders"].length; i++) {
// check if item was recently submitted
if (insert.c === -1 && insert.h === storage["reminders"][i].h) {
overrides = true;
storage["reminders"][i] = insert;
} else if (insert.h === storage["reminders"][i].h) {
found = true;
}
}
if (found === false) toAdd.push(insert);
}
if (toAdd.length > 0 || overrides === true) chrome.storage.sync.set({ "reminders": [...storage["reminders"], ...toAdd] });
}
async function hideReminder(href) {
const storage = await chrome.storage.sync.get("reminders");
for (let i = 0; i < storage["reminders"].length; i++) {
if (storage["reminders"][i]["h"] === href) {
storage["reminders"][i]["c"]++;
chrome.storage.sync.set({ "reminders": storage["reminders"] });
break;
}
}
}
function createReminder(reminder, location) {
const remaining = getRelativeDate(new Date(reminder.d));
const wrapper = makeElement("div", location, { "className": "canvasrefined-reminder-wrapper" });
const container = makeElement("div", wrapper, { "className": "canvasrefined-reminder-container" });
const svg = makeElement("div", container, { "innerHTML": canvas_svg });
const content = makeElement("a", container, { "className": "canvasrefined-reminder-content", "href": reminder.h, "target": "_blank" });
const title = makeElement("h2", content, { "className": "canvasrefined-reminder-title", "textContent": reminder.t });
const due = makeElement("p", content, { "className": "canvasrefined-reminder-due", "textContent": `Assignment due in ${remaining.time}` });
const hidebtn = makeElement("btn", wrapper, { "className": "canvasrefined-reminder-hide", "textContent": "x" });
hidebtn.addEventListener("click", () => {
hideReminder(reminder.h);
wrapper.remove();
});
return container;
}
async function reminderWatch() {
const sync = await chrome.storage.sync.get("remind");
if (sync["remind"] !== true) {
if (document.getElementById("canvasrefined-reminders")) document.getElementById("canvasrefined-reminders").style.display = "none";
return;
}
const container = document.getElementById("canvasrefined-reminders") || makeElement("div", document.body, { "id": "canvasrefined-reminders" });
container.style.display = "flex";
container.textContent = "";
const alertPeriod = 1000 * 60 * 60 * 6; // 6 hours
const alertPeriod2 = 1000 * 60 * 60 * 2; // 2 hours
const storage = await chrome.storage.sync.get(["reminders", "reminder_count"]);
const now = (new Date()).getTime();
storage["reminders"].forEach((reminder, index) => {
if (reminder.d < now) {
storage["reminders"].splice(index, 1);
} else if ((reminder.c == 0 && reminder.d < now + alertPeriod) || (reminder.c == 1 && reminder.d < now + alertPeriod2)) {
createReminder(reminder, container);
}
});
chrome.storage.sync.set({ "reminders": storage["reminders"] });
}
function updateReminders() {
if (!assignments || typeof assignments.then !== "function") return;
const fiveDays = 1000 * 60 * 60 * 24 * 5;
const now = (new Date()).getTime();
const list = [];
assignments.then(data => {
data.forEach(item => {
const due = (new Date(item.plannable_date)).getTime();
if (item.plannable_type === "announcement") return;
if (due < now) return;
if (due > now + fiveDays * 2) return;
// { due, title, href, hide count }
// hide count of -1 indicates the item has a submission
list.push({ "d": due, "t": item.plannable.title, "h": domain + item.html_url, "c": item?.submissions?.submitted || false ? -1 : 0 });
});
insertReminders(list);
});
}
function showExampleReminder() {
const location = document.getElementById("canvasrefined-reminders") || makeElement("div", document.body, { "id": "canvasrefined-reminders" });
if (options.remind !== true) {
location.remove();
return;
}
location.textContent = "";
const example = createReminder({ "d": new Date(), "t": "This is an example reminder", }, location);
example.querySelector(".canvasrefined-reminder-due").textContent = "This notification will pop up in other pages to remind you of incomplete assignments that are due in less than 6 hours." /*It will notify again at 2 hours if the 'Remind 2x' option is on."*/;
}
isDomainCanvasPage();
function isDomainCanvasPage() {
chrome.storage.sync.get(['custom_domain', 'dark_mode', 'dark_preset', 'device_dark', 'remind'], result => {
options = result;
if (result.custom_domain.length && result.custom_domain[0] !== "") {
for (let i = 0; i < result.custom_domain.length; i++) {
if (domain.includes(result.custom_domain[i])) {
startExtension();
return;
}
}
// if the code reaches this point, its not a canvas page so run the reminders
setTimeout(reminderWatch, 2000);
setInterval(reminderWatch, 60000);
// turn the reminders on/off if the option is changed
chrome.storage.onChanged.addListener((changes) => {
Object.keys(changes).forEach(key => {
if (key === "remind") reminderWatch();
})
})
} else {
setupCustomURL();
}
});
}
function startExtension() {
// Remove footer robustly - run first so a crash below can't block it
const removeFooter = () => {
const footer = document.querySelector('footer#footer.ic-app-footer, footer#footer');
if (footer) footer.remove();
};
removeFooter();
const footerObserver = new MutationObserver(() => {
removeFooter();
});
footerObserver.observe(document.documentElement, { childList: true, subtree: true });
toggleDarkMode();
chrome.storage.sync.get(["better_sidebar", "sidebar_scale"], result => {
options = { ...options, ...result };
ensureBetterSidebar();
});
chrome.storage.sync.get(null, result => {
options = { ...options, ...result };
toggleAutoDarkMode();
// toggleScheduledReminders();
getApiData();
checkDashboardReady();
loadCustomFont();
applyAestheticChanges();
watchNewCanvasButton();
changeFavicon();
updateReminders();
applyCustomBackground();
ensureBetterSidebar();
watchSequenceFooter();
watchSubmissionPageButton();
watchProfileLogoutPageButton();
setTimeout(() => runDarkModeFixer(false), 800);
setTimeout(() => runDarkModeFixer(false), 4500);
});
chrome.runtime.onMessage.addListener(recieveMessage);
chrome.storage.onChanged.addListener(applyOptionsChanges);
console.log("Canvas Refined - running");
}
function applyOptionsChanges(changes) {
let rewrite = {};
Object.keys(changes).forEach(key => {
rewrite[key] = changes[key].newValue;
});
options = { ...options, ...rewrite };
// when an option is updated it will call the necessary functions again
// so any changes made in the menu no longer require a refresh to apply
Object.keys(changes).forEach(key => {
console.log(key + " changed");
switch (key) {
case "dark_mode":
case "dark_preset":
case "device_dark":
toggleDarkMode();
break;
case "auto_dark":
case "auto_dark_start":
case "auto_dark_end":
toggleAutoDarkMode();
break;
case "gradient_cards":
changeGradientCards();
break;
case "dashboard_notes":
case "dashboard_notes_text":
case "dashboard_notes_mode":
loadDashboardNotes();
break;
case "dashboard_grades":
case "grade_hover":
if (!grades) getGrades();
insertGrades();
break;
case "assignments_due":
case "num_assignments":
if (!assignments) getAssignments();
if (
document.querySelectorAll(".canvasrefined-card-assignment")
.length === 0
)
setupCardAssignments();
loadCardAssignments();
break;
case "custom_assignments":
case "assignment_date_format":
case "card_overdues":
case "relative_dues":
cardAssignments = preloadAssignmentEls();
loadCardAssignments();
break;
case "equal_height_cards":
// Stretch or reset card heights in place instead of rebuilding rows.
equalizeCardHeights();
break;
case "custom_cards":
case "custom_cards_2":
case "custom_cards_3":
customizeCards();
break;
case "todo_hr24":
case "todo_separate_scrollbar":
case "num_todo_items":
case "hover_preview":
// case "todo_overdues":
case "todo_hide_feedback":
case "todo_full_height":
case "custom_cards_3":
moreAnnouncementCount = 0;
moreAssignmentCount = 0;
// loadBetterTodo();
clearTodoList();
createTodoSections(document.querySelector("#canvasrefined-todo-list"));
break;
case "gpa_calc":
case "gpa_calc_prepend":
case "gpa_calc_weighted":
case "gpa_calc_cumulative":
if (!grades) getGrades();
setupGPACalc();
break;
case "gpa_calc_bounds":
calculateGPA2();
break;
case "custom_font":
loadCustomFont();
break;
case "remlogo":
case "disable_color_overlay":
case "condensed_cards":
case "hide_feedback":
case "full_width":
case "center_cards":
case "custom_styles":
applyAestheticChanges();
break;
case "hide_new_canvas":
watchNewCanvasButton();
break;
case "customBackgroundScale":
applyCustomBackground();
break;
case "customBackgroundDaily":
applyCustomBackground();
removeNasaInfoOverlay();
break;
case "customBackgroundNasaDaily":
applyCustomBackground();
if (options.customBackgroundNasaDaily === true) {
createNasaInfoOverlay();
} else {
removeNasaInfoOverlay();
}
break;
case "fitImageToScreen":
applyCustomBackground();
break;
case "remind":
showExampleReminder();
break;
case "imageSize":
case "cardRoundness":
case "cardSpacing":
case "cardWidth":
case "cardHeight":
case "customCardStyles":
applyAestheticChanges();
break;
case "customBackgroundLink":
applyCustomBackground();
break;
case "better_todo":
if (options.better_todo) {
setupBetterTodo();
} else {
window.location.reload();
}
break;
case "todo_progress_rings": {
// toggle progress rings immediately
const placeholder = document.getElementById("better-todo-progress-placeholder");
if (!placeholder) break;
if (progressRingsEnabled()) {
if (typeof assignments?.then === 'function') {
assignments.then(data => {
const courseId = getCurrentCourseId();
const scopedData = courseId
? data.filter(item => {
const itemCourseId = parseInt(item.course_id || item.context_id || item?.plannable?.course_id);
return itemCourseId === courseId;
})
: data;
renderProgressRings(placeholder, scopedData);
});
}
} else {
placeholder.innerHTML = "";
}
break;
}
case "better_sidebar":
if (options.better_sidebar) {
ensureBetterSidebar();
} else {
resetBetterSidebarLayout();
}
break;
case "sidebar_scale": {
const existingSidebar = document.getElementById("better-sidebar-container");
if (existingSidebar) {
const expander = existingSidebar.querySelector(".better-sidebar-expander");
updateSidebar(existingSidebar.dataset.expanded === "true", existingSidebar, expander);
}
break;
}
}
});
}
function resetBetterSidebarLayout() {
document.getElementById("header")?.style.removeProperty("display");
document.querySelector(".ic-Layout-wrapper")?.style.removeProperty("margin-left");
document.querySelector("#main")?.style.removeProperty("margin-left");
document.querySelector(".ic-app-nav-toggle-and-crumbs")?.style.removeProperty("display");
document.getElementById("not_right_side")?.style.removeProperty("display");
document.getElementById("not_right_side")?.style.removeProperty("flex");
document.getElementById("not_right_side")?.style.removeProperty("min-width");
document.getElementById("right-side-wrapper")?.style.removeProperty("flex");
document.getElementById("right-side-wrapper")?.style.removeProperty("width");
document.getElementById("right-side-wrapper")?.style.removeProperty("max-width");
document.querySelector(".ic-Layout-contentWrapper")?.style.removeProperty("display");
document.querySelector(".ic-Layout-contentWrapper")?.style.removeProperty("align-items");
document.querySelector(".ic-Layout-contentWrapper")?.style.removeProperty("min-width");
document.querySelector(".ic-Layout-contentMain")?.style.removeProperty("flex");
document.querySelector(".ic-Layout-contentMain")?.style.removeProperty("min-width");
document.querySelector(".ic-Layout-contentMain")?.style.removeProperty("margin");
document.querySelector(".ic-Layout-contentMain")?.style.removeProperty("padding");
document.querySelector(".ic-Layout-contentMain")?.style.removeProperty("background");
document.querySelector(".ic-Layout-contentMain")?.style.removeProperty("backdrop-filter");
document.querySelector(".ic-Layout-contentMain")?.style.removeProperty("-webkit-backdrop-filter");
document.getElementById("left-side")?.style.removeProperty("display");
document.getElementById("left-side")?.style.removeProperty("padding-top");
document.getElementById("left-side")?.style.removeProperty("padding-left");
document.getElementById("section-tabs")?.style.removeProperty("padding-top");
document.getElementById("better-sidebar-container")?.remove();
clearBetterSidebarLayoutFix();
if (sidebarBadgeObserver) { sidebarBadgeObserver.disconnect(); sidebarBadgeObserver = null; }
if (sidebarBadgeSyncTimer) { clearTimeout(sidebarBadgeSyncTimer); sidebarBadgeSyncTimer = null; }
sidebarBadgeWatchRetries = 0;
}
function ensureBetterSidebar() {
if (!options.better_sidebar) return;
if (document.querySelector("#better-sidebar-container")) return;
if (!document.querySelector("#wrapper") || !document.querySelector(".ic-Layout-contentWrapper")) return;
setupBetterSidebar(getSidebarLayoutMode());
}
async function applyCustomBackground() {
// let style = document.querySelector("#DashboardCard_Container")
let style = document.querySelector("#canvasrefined-background") || document.createElement('style');
style.id = "canvasrefined-background";
const activeBackground = await getActiveCustomBackground();
console.log("[CanvasRefined] activeBackground:", activeBackground);
if (!activeBackground) {
if (style.isConnected) style.remove();
return;
}
const backgroundScale = Number(activeBackground.scale) || 100;
const backgroundUrl = JSON.stringify(activeBackground.url);
const fitToScreen = options.fitImageToScreen === true;
console.log("[CanvasRefined] Applying background:", activeBackground.url, "fitToScreen:", fitToScreen);
style.textContent = `
#wrapper {
background-image: url(${backgroundUrl}) !important;
background-repeat: no-repeat !important;
background-position: center center !important;
background-attachment: fixed !important;
}
@media (orientation: landscape) {
#wrapper { background-size: ${fitToScreen ? 'cover' : backgroundScale + '% auto'} !important; }
}
@media (orientation: portrait) {
#wrapper { background-size: cover !important; }
}
.ic-Dashboard-header__layout {
background: none !important;
/* backdrop-filter: blur(10px) !important; */
border-radius: 5px;
padding-left: 20px !important;
}
#dashboard_header_container {
margin-left: -35px !important;
margin-right: -35px !important;
box-sizing: border-box !important;
background-color: color-mix(in srgb, var(--bcbackground-0), transparent 20%) !important;
border: 1px solid color-mix(in srgb, var(--bcborders) 60%, transparent) !important;
border-radius: 10px !important;
position: sticky !important;
top: 0 !important;
z-index: 1000 !important;
backdrop-filter: blur(8px) saturate(120%) !important;
-webkit-backdrop-filter: blur(8px) saturate(120%) !important;
}
#right-side-wrapper {
/* backdrop-filter: blur(10px) !important; */
background-color: color-mix(in srgb, var(--bcbackground-0), transparent 35%);
border-radius: 5px;
}
.header-bar {
background: none !important;
padding: 0 !important;
border: none !important;
}
.item-group-condensed,
.item-group-container {
background: transparent !important;
/* backdrop-filter: blur(14px) saturate(120%) !important;
-webkit-backdrop-filter: blur(14px) saturate(120%) !important; */
border-radius: 12px !important;
border: 1px solid color-mix(in srgb, var(--bcborders) 75%, transparent) !important;
/* box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12) !important; */
}
#context_modules_sortable_container {
border: none !important;
background: none !important;
padding: 0 !important;
/* backdrop-filter: blur(0) !important; */
}
.item-group-condensed .ig-header,
.item-group-condensed .ig-row,
.item-group-container .ig-header,
.item-group-container .ig-row,
.item-group-condensed .header,
.item-group-container .header {
background: transparent !important;
}
.item-group-condensed .ig-header.header,
.item-group-container .ig-header.header {
background: none !important;
border: none !important;
border-radius: 0 !important;
}
#assignments.ui-tabs-panel {
background-color: color-mix(in srgb, var(--bcbackground-0), transparent 35%) !important;
border-radius: 5px !important;
}
#assignments {
padding-top: 0px !important;
padding-bottom: 0px !important;
padding-left: 10px !important;
padding-right: 10px !important;
}
${isCoursesIndexPage() ? `
#content {
margin: 36px 48px 48px !important;
padding: 10px !important;
background-color: color-mix(in srgb, var(--bcbackground-0), transparent 35%) !important;
border-radius: 5px !important;
box-sizing: border-box !important;
}
` : ""}
${isGroupsIndexPage() ? `
#content {
margin: 36px 48px 48px !important;
padding: 10px !important;
background-color: color-mix(in srgb, var(--bcbackground-0), transparent 35%) !important;
border-radius: 5px !important;
box-sizing: border-box !important;
}
` : ""}
${isConversationsPage() ? `
.css-1nh4pc4-view-flexItem {
background-color: color-mix(in srgb, var(--bcbackground-0), transparent 35%) !important;
border-radius: 5px !important;
box-sizing: border-box !important;
}
.css-1nh4pc4-view-flexItem svg,
.css-1nh4pc4-view-flexItem svg * {
fill: currentColor !important;
stroke: currentColor !important;
color: var(--bctext-0) !important;
}
` : ""}
.item-group-condensed .ig-row.ig-published.no-estimated-duration {
color: var(--bctext-1) !important;
border: 1px solid color-mix(in srgb, var(--bcborders) 60%, transparent) !important;
border-radius: 0 !important;
padding: 10px 12px !important;
}
.item-group-condensed .context_module_item,
.item-group-container .context_module_item {
background: transparent !important;
/* backdrop-filter: blur(10px) saturate(115%) !important;
-webkit-backdrop-filter: blur(10px) saturate(115%) !important; */
}
.item-group-condensed .context_module_item:hover,
.item-group-container .context_module_item:hover,
.item-group-condensed .context_module_item.context_module_item_hover,
.item-group-container .context_module_item.context_module_item_hover {
background: transparent !important;
border-radius: 10px !important;
}
.item-group-container {
background: transparent !important;
border-radius: 12px !important;
border: 1px solid color-mix(in srgb, var(--bcborders) 75%, transparent) !important;
}
.ig-header {
/* backdrop-filter: blur(10px) !important; */
}
.item-group-condensed.context_module,
.item-group-condensed.context_module_item,
.item-group-condensed[class~="context_module"] {
margin-bottom: 10px !important;
padding-top: 0 !important;
padding-bottom: 0 !important;
}
.item-group-condensed .ig-header.header,
.item-group-container .ig-header.header {
padding-top: 0 !important;
}
/* Apply backdrop blur only to module panels, not to all headers */
.item-group-condensed.context_module,
.item-group-condensed.context_module_item,
.item-group-condensed.context_module:hover,
.item-group-condensed.context_module_item:hover,
.item-group-condensed.context_module.context_module_item_hover,
.item-group-condensed.context_module_item.context_module_item_hover {
backdrop-filter: blur(5px) !important;
-webkit-backdrop-filter: blur(5px) !important;
}
.canvasrefined-gpa-card,
.canvasrefined-gpa,
.ic-DashboardCard {
background: var(--bcbackground-0) !important;
}
tr.student_assignment.assignment_graded.editable > * {
border:none!important
}`;
// TODO: liquid glass?
document.documentElement.appendChild(style);
}
function applyBetterSidebarLayoutFix() {
let style = document.querySelector("#canvasrefined-sidebar-layout-fix") || document.createElement("style");
style.id = "canvasrefined-sidebar-layout-fix";
style.textContent = `
#wrapper,
.ic-Layout-wrapper,
#main {
margin-left: 0 !important;
}
`;
document.documentElement.appendChild(style);
}
function clearBetterSidebarLayoutFix() {
let style = document.querySelector("#canvasrefined-sidebar-layout-fix");
if (style) style.remove();
}
let insertTimer;
function resetTimer() {
clearTimeout(insertTimer);
insertTimer = setTimeout(() => {
if (document.querySelectorAll(".ic-DashboardCard__link").length > 0) {
loadCardAssignments();
loadBetterTodo();
} else {
resetTimer();
}
}, 1);
}
function checkDashboardReady() {
const callback = (mutationList) => {
for (const mutation of mutationList) {
if (mutation.type !== "childList") continue;
if (current_page == "/" || current_page == "" || current_page.match(/^\/courses\/(\d+)(?:\/|$)/)) {
if (dashboardReadyTimer) continue;
const dashboardCards = document.querySelector("#DashboardCard_Container");
if (dashboardCards) {
}
dashboardReadyTimer = setTimeout(() => {
dashboardReadyTimer = null;
const c = document.querySelector("#DashboardCard_Container");
if (c) {
let cards = document.querySelectorAll(".ic-DashboardCard");
changeGradientCards();
setupCardAssignments();
loadCardAssignments();
customizeCards(cards);
insertGrades();
loadDashboardNotes();
setupGPACalc();
showUpdateMsg();
createNasaInfoOverlay();
}
const rightSide = document.querySelector("#right-side");
if (rightSide && !rightSide.querySelector(".canvasrefined-todosidebar")) {
setupBetterTodo();
setupBetterSidebar(getSidebarLayoutMode());
}
if (options.better_sidebar) {
ensureBetterSidebar();
}
}, 0);
} else {
console.log("I am outside", current_page);
if (options.better_sidebar) {
ensureBetterSidebar();
}
}
}
};
const observer = new MutationObserver(callback);
observer.observe(document.querySelector('html'), { childList: true, subtree: true });
}
function recieveMessage(request, sender, sendResponse) {
switch (request.message) {
case ("getCards"):
if (options["card_method_dashboard"] === true) {
getCardsFromDashboard();
} else {
getCards();
}
sendResponse(true);
break;
case ("setcolors"): changeColorPreset(request.options); sendResponse(true); break;
case ("getcolors"): sendResponse(getCardColors()); break;
case ("inspect"): sendResponse(inspectDarkMode(true)); break;
case ("fixdm"): sendResponse(runDarkModeFixer(true)); break;
case ("updateBackground"): applyCustomBackground(); sendResponse(true); break;
default: sendResponse(true);
}
}
function hexToRgb(hex) {
let match = (/#(.{2})(.{2})(.{2})/).exec(hex);
if (match) {
return { "r": parseInt(match[1], 16), "g": parseInt(match[2], 16), "b": parseInt(match[3], 16) };
}
}
function inspectDarkMode(withOutput = false) {
let output = "";
let bgcount = 0, textcount = 0, time = performance.now();
let bg0 = hexToRgb(options.dark_preset["background-0"]);
let bg1 = hexToRgb(options.dark_preset["background-1"]);
let txt = hexToRgb(options.dark_preset["text-0"]);
let bdr = hexToRgb(options.dark_preset["borders"]);
let lnk = hexToRgb(options.dark_preset["links"]);
document.querySelectorAll("*").forEach(el => {
let style = getComputedStyle(el);
let bgcolor = style.getPropertyValue("background").match(/rgb\((?\d*)\, ?(?\d*)\, ?(?\d*)\) none/);
let selector = "class=." + el.className + ",id=#" + el.id;
if (bgcolor) {
const r = parseInt(bgcolor.groups["r"]);
const g = parseInt(bgcolor.groups["g"]);
const b = parseInt(bgcolor.groups["b"]);
if (r > 245 && g > 245 && b > 245 && !(r === bg0.r && g === bg0.g && b === bg0.b) && !(r === lnk.r && g === lnk.g && b === lnk.b)) {
el.style.cssText = (";background:" + options.dark_preset["background-0"] + "!important;color" + options.dark_preset["text-0"] + "!important;") + el.style.cssText;
if (withOutput === true) output += selector + "{background: background-0, color: text-0}\n";
bgcount++;
} else if (r > 225 && r < 245 && g > 225 && g < 245 && b > 225 && b < 245 && !(r === bg1.r && g === bg1.g && b === bg1.b) && !(r === lnk.r && g === lnk.g && b === lnk.b)) {
el.style.cssText = (";background:" + options.dark_preset["background-1"] + "!important;color" + options.dark_preset["text-0"] + "!important;") + el.style.cssText;
if (withOutput === true) output += selector + "{background: background-1, color: text-0}";
bgcount++;
}
}
let bordercolor = style.getPropertyValue("border-color").match(/rgb\((?\d*)\, ?(?\d*)\, ?(?\d*)/);
if (bordercolor) {
const r = parseInt(bordercolor.groups["r"]);
const g = parseInt(bordercolor.groups["g"]);
const b = parseInt(bordercolor.groups["b"]);
if (r > 195 && g > 195 && b > 195 && !(r === bdr.r && g === bdr.g && b === bdr.b) && !(r === lnk.r && g === lnk.g && b === lnk.b)) {
el.style.cssText = "border-color:" + options.dark_preset["borders"] + "!important;" + el.style.cssText;
if (withOutput === true) output += selector + "{border: borders}";
}
}
let text = style.getPropertyValue("color").match(/rgb\((?\d*)\, ?(?\d*)\, ?(?\d*)/);
if (text) {
const r = parseInt(text.groups["r"]);
const g = parseInt(text.groups["g"]);
const b = parseInt(text.groups["b"]);
if (r <= 70 && g <= 70 && b <= 70 && !(r === txt.r && g === txt.g && b === txt.b)) {
el.style.cssText = "color:" + options.dark_preset["text-0"] + "!important;" + el.style.cssText;
if (withOutput === true) output += selector + "{text: text-0}";
textcount++;
}
}
});
console.log("done fixing dark mode - time:", performance.now() - time, "total backgrounds changed: ", bgcount, ", total colors changed: ", textcount);
return { "selectors": output === "" ? "no gaps determined" : output, "time": performance.now() - time };
}
function getCardColors() {
let cards = document.querySelectorAll(".ic-DashboardCard__header");
let colors = [];
cards.forEach(card => {
let rgbColor = card.querySelector(".ic-DashboardCard__header_hero").style.backgroundColor;
colors.push({ "href": card.querySelector(".ic-DashboardCard__link").href, "color": rgbToHex(rgbColor) });
});
colors.sort((a, b) => a.href > b.href ? 1 : -1);
colors = colors.map(x => x.color);
return colors;
}
function getCardsFromDashboard() {
console.log("getting cards from dashboard")
const dashboard_cards = document.querySelectorAll(".ic-DashboardCard");
chrome.storage.sync.get(["custom_cards", "custom_cards_2", "custom_cards_3"], storage => {
let cards = storage["custom_cards"] || {};
let cards_2 = storage["custom_cards_2"] || {};
let cards_3 = storage["custom_cards_3"] || {};
let newCards = false;
let count = 0;
try {
dashboard_cards.forEach(card => {
const id = card.querySelector(".ic-DashboardCard__link").href.split("courses/")[1];
if (count >= (options["card_limit"] || 25)) return;
if (!cards[id]) {
newCards = true;
cards[id] = { "default": card.querySelector(".ic-DashboardCard__header-subtitle").textContent.substring(0, 20), "name": "", "code": "", "img": "", "hidden": false, "weight": "regular", "credits": 1, "eid": 100000 - count, "gr": null };
let links = [];
for (let i = 0; i < 4; i++) {
links.push({ "path": "default", "is_default": true });
}
cards_2[id] = { "links": links };
cards_3[id] = { "url": domain };
}
count++;
});
// there shouldn't be 0 cards
if (count === 0) return;
//delete cards that aren't on the dashboard anymore
Object.keys(cards).forEach(key => {
let found = false;
// ignore cards that are not for the current url
if (cards_3[key] && cards_3[key].url !== domain) {
found = true;
} else {
dashboard_cards.forEach(card => {
const id = card.querySelector(".ic-DashboardCard__link").href.split("courses/")[1];
if (parseInt(key) === parseInt(id)) found = true;
});
}
if (found === false) {
console.log("Deleting " + key);
cards[key] && delete cards[key];
cards_2[key] && delete cards_2[key];
cards_3[key] && delete cards_3[key];
newCards = true;
}
});
} catch (e) {
console.log("Error getting dashboard cards\n", e);
logError(e);
} finally {
if(newCards !== true) return;
console.log(newCards ? "new cards found" : "");
chrome.storage.sync.set({ "custom_cards": cards, "custom_cards_2": cards_2, "custom_cards_3": cards_3 });
}
});
}
async function getCards(api = null) {
let dashboard_cards = api ? api : await getData(`${domain}/api/v1/courses?${/*enrollment_state=active&*/""}per_page=100`);
chrome.storage.sync.get(["custom_cards", "custom_cards_2", "custom_cards_3"], storage => {
let cards = storage["custom_cards"] || {};
let cards_2 = storage["custom_cards_2"] || {};
let cards_3 = storage["custom_cards_3"] || {};
let newCards = false;
let count = 0;
// sort cards by enrollment id (i think the higher the id, the more recent it is)
if (options["card_method_date"] === true) {
dashboard_cards.sort((a, b) => (b?.created_at) > (a?.created_at) ? 1 : -1);
} else {
dashboard_cards.sort((a, b) => (b?.enrollment_term_id || 0) - (a?.enrollment_term_id || 0));
}
try {
dashboard_cards.forEach(card => {
if (!card.course_code || count >= (options["card_limit"] || 25)) return;
let id = card.id;
if (!cards || !cards[id]) {
newCards = true;
cards[id] = { "default": card.course_code.substring(0, 20), "name": "", "code": "", "img": "", "hidden": false, "weight": "regular", "credits": 1, "eid": card.enrollment_term_id || 0, "gr": null };
} else if (cards && cards[id]) {
newCards = true;
cards[id].default = card.course_code.substring(0, 20);
cards[id].eid = card.enrollment_term_id || 0;
if (!cards[id].code) cards[id].code = "";
}
if (!cards_2 || !cards_2[id]) {
newCards = true;
let links = [];
for (let i = 0; i < 4; i++) {
links.push({ "path": "default", "is_default": true });
}
cards_2[id] = { "links": links };
}
if (!cards_3 || !cards_3[id]) {
newCards = true;
cards_3[id] = { "url": domain };
}
count++;
});
//delete cards that aren't on the dashboard anymore
Object.keys(cards).forEach(key => {
let found = false;
// ignore cards that are not for the current url
if (cards_3[key] && cards_3[key].url !== domain) {
found = true;
} else {
dashboard_cards.forEach(card => {
if (parseInt(key) === card.id) found = true;
});
}
if (found === false) {
console.log("Deleting " + key + " from custom_cards...", cards[key]);
cards[key] && delete cards[key];
cards_2[key] && delete cards_2[key];
cards_3[key] && delete cards_3[key];
newCards = true;
}
});
} catch (e) {
console.log(e);
} finally {
return chrome.storage.sync.set(newCards ? { "custom_cards": cards, "custom_cards_2": cards_2, "custom_cards_3": cards_3 } : {});
}
});
}
/*
Better todo list
*/
function convertToDueDate(dueAt) {
final = "due ";
let date = new Date(dueAt);
final += date.toLocaleString("en-US", { month: "short", day: "numeric" });
final += " at " + date.toLocaleString("en-US", { hour: "numeric", minute: "numeric", hour12: !options.todo_hr24 });
return final;
}
function updateIndicator(element) {
const indicator = document.getElementById("better-todo-indicator");
indicator.style.width = `${element.offsetWidth*2}px`;
indicator.style.left = `${element.offsetLeft - (element.offsetWidth * .5)}px`;
const buttons = ["announcement", "assignments", "completed"];
buttons.forEach(button => {
const btn = document.getElementById(`better-todo-${button}`);
if (btn == element) {
btn.firstElementChild.style.opacity = "1";
// btn.style.filter = "none";
}
else {
btn.firstElementChild.style.opacity = ".5";
// btn.style.filter = "grayscale(100%)";
}
})
}
// better todo html
betterTodoFilter = "tasks";
// null = show every class; a string courseId = only that class's tasks.
let betterTodoProgressFilter = null;
let domContainers = {};
// true when `courseId` is the dimmed-out class because another class is selected.
function progressFilterDim(courseId) {
return betterTodoProgressFilter != null && String(courseId) !== String(betterTodoProgressFilter);
}
// Make an element filter the todo list to one class on click. A no-op on
// course pages (where only one class is in scope anyway); toggles off when the
// active class is clicked again.
function attachProgressFilterClick(el, courseId) {
if (getCurrentCourseId() != null) { el.style.cursor = ''; el.onclick = null; return; }
el.style.cursor = 'pointer';
el.onclick = () => {
betterTodoProgressFilter = (String(betterTodoProgressFilter) === String(courseId)) ? null : String(courseId);
const loc = document.querySelector("#canvasrefined-todo-list");
if (loc) { clearTodoList(); createTodoSections(loc); }
};
}
function formatDateForInput(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
function formatTimeForInput(date) {
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
return `${hours}:${minutes}`;
}
/* Progress display mode, stored in options.todo_progress_rings.
Modes: "none", "rings", "rainbow", "lines", "line".
Legacy booleans normalize: true/undefined -> rings, false -> none. */
function getProgressRingMode() {
const v = options.todo_progress_rings;
if (v === false) return "none";
if (v === true || v === undefined || v === null) return "rings";
const allowed = ["none", "rings", "rainbow", "lines", "line"];
if (allowed.includes(v)) return v;
// migrate unreleased string values from the prior experimental set
if (v === "per_course") return "rings";
if (v === "combined" || v === "segmented") return "line";
return "rings";
}
function progressRingsEnabled() {
return getProgressRingMode() !== "none";
}
function courseRingColor(courseId, idx) {
return options.custom_cards_3?.[String(courseId)]?.color
|| options.custom_cards_3?.[courseId]?.color
|| `hsl(${(idx * 60) % 360} 70% 50%)`;
}
function courseRingLabel(courseId) {
const card = options.custom_cards?.[String(courseId)] || options.custom_cards?.[courseId];
return card?.default || `Course ${courseId}`;
}
// Mode "rings": concentric rings, one per course, each filled by completion.
function renderProgressRingsMode(wrapper, shown, totalAll, completedAll, percent) {
const containerWidth = wrapper.clientWidth || 240;
const size = Math.min(280, Math.floor(containerWidth * 0.99));
const cx = size / 2;
const cy = size / 2;
const padding = 2;
const outerRadius = Math.floor((size / 2) - padding);
let svg = wrapper.querySelector('svg.canvasrefined-progress-svg');
if (!svg) {
svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('class', 'canvasrefined-progress-svg');
svg.style.display = 'block';
wrapper.appendChild(svg);
}
svg.setAttribute('width', String(size));
svg.setAttribute('height', String(size));
svg.setAttribute('viewBox', `0 0 ${size} ${size}`);
let overlay = wrapper.querySelector('.canvasrefined-progress-overlay');
if (!overlay) {
overlay = document.createElement('div');
overlay.className = 'canvasrefined-progress-overlay';
overlay.style.cssText = 'position:absolute;left:0;top:0;width:100%;height:100%;display:flex;align-items:center;justify-content:center;pointer-events:none;';
const textWrap = document.createElement('div');
textWrap.style.cssText = 'text-align:center;color:var(--bctext-0);';
textWrap.innerHTML = `
`;
overlay.appendChild(textWrap);
wrapper.appendChild(overlay);
}
overlay.querySelector('.canvasrefined-progress-percent').textContent = `${percent}%`;
overlay.querySelector('.canvasrefined-progress-count').textContent = `${completedAll}/${totalAll} done`;
const stroke = 8;
const gap = 4;
const decrement = stroke + gap;
const ringCount = shown.length;
const startRadius = outerRadius - stroke / 2;
const minCenterRadius = 28;
const requiredSpace = (ringCount - 1) * decrement + stroke / 2 + minCenterRadius;
let adjustFactor = 1;
if (requiredSpace > startRadius) {
adjustFactor = (startRadius - minCenterRadius - stroke / 2) / Math.max(1, (ringCount - 1) * decrement);
}
shown.forEach((entry, idx) => {
const radius = startRadius - idx * Math.max(1, Math.floor(decrement * adjustFactor));
if (radius <= 0) return;
const circumference = 2 * Math.PI * radius;
const prog = entry.total === 0 ? 0 : entry.completed / entry.total;
const color = courseRingColor(entry.courseId, idx);
let bg = svg.querySelector(`circle[data-idx='${idx}'][data-role='bg']`);
if (!bg) {
bg = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
bg.setAttribute('data-idx', String(idx));
bg.setAttribute('data-role', 'bg');
bg.classList.add('canvasrefined-ring-bg');
svg.appendChild(bg);
}
bg.setAttribute('cx', String(cx));
bg.setAttribute('cy', String(cy));
bg.setAttribute('r', String(radius));
bg.setAttribute('stroke', color);
bg.setAttribute('stroke-opacity', '0.25');
bg.setAttribute('stroke-width', String(stroke));
bg.setAttribute('fill', 'none');
bg.removeAttribute('stroke-dasharray');
bg.removeAttribute('stroke-dashoffset');
bg.removeAttribute('transform');
let fg = svg.querySelector(`circle[data-idx='${idx}'][data-role='fg']`);
const dasharrayVal = circumference.toFixed(3);
const target = (circumference * (1 - prog)).toFixed(3);
if (!fg) {
fg = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
fg.setAttribute('data-idx', String(idx));
fg.setAttribute('data-role', 'fg');
fg.classList.add('canvasrefined-progress-ring');
fg.setAttribute('stroke-linecap', 'round');
fg.setAttribute('transform', `rotate(-90 ${cx} ${cy})`);
fg.setAttribute('stroke-dasharray', dasharrayVal);
fg.setAttribute('stroke-dashoffset', dasharrayVal); // start empty
fg.style.transition = 'stroke-dashoffset .8s cubic-bezier(.2,.9,.2,1), opacity .3s ease';
svg.appendChild(fg);
}
fg.setAttribute('cx', String(cx));
fg.setAttribute('cy', String(cy));
fg.setAttribute('r', String(radius));
fg.setAttribute('stroke', color);
fg.setAttribute('stroke-width', String(stroke));
fg.setAttribute('fill', 'none');
fg.setAttribute('stroke-dasharray', dasharrayVal);
const dim = progressFilterDim(entry.courseId);
bg.style.transition = 'opacity .3s ease';
bg.style.opacity = dim ? '0.25' : '';
fg.style.opacity = dim ? '0.3' : '';
requestAnimationFrame(() => requestAnimationFrame(() => fg.setAttribute('stroke-dashoffset', target)));
// transparent hit band on top so the whole ring is easy to click;
// width tracks the ring spacing so adjacent bands don't overlap.
const step = Math.max(1, Math.floor(decrement * adjustFactor));
let hit = svg.querySelector(`circle[data-idx='${idx}'][data-role='hit']`);
if (!hit) {
hit = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
hit.setAttribute('data-idx', String(idx));
hit.setAttribute('data-role', 'hit');
hit.setAttribute('fill', 'none');
hit.setAttribute('stroke', 'transparent');
hit.setAttribute('pointer-events', 'stroke');
svg.appendChild(hit);
}
hit.setAttribute('cx', String(cx));
hit.setAttribute('cy', String(cy));
hit.setAttribute('r', String(radius));
hit.setAttribute('stroke-width', String(Math.max(stroke, step)));
attachProgressFilterClick(hit, entry.courseId);
hit.onmouseenter = () => { bg.style.opacity = '0.8'; fg.style.opacity = '0.8'; };
hit.onmouseleave = () => {
const d = progressFilterDim(entry.courseId);
bg.style.opacity = d ? '0.25' : '';
fg.style.opacity = d ? '0.3' : '';
};
});
const maxIdx = shown.length - 1;
svg.querySelectorAll('circle').forEach(c => {
const idx = parseInt(c.getAttribute('data-idx'));
if (Number.isNaN(idx) || idx > maxIdx) c.remove();
});
}
// Mode "rainbow": like "rings" (one arc per class) but condensed into a top
// half-circle and colored with a rainbow palette instead of course colors.
function renderProgressRainbow(wrapper, shown, totalAll, completedAll, percent) {
const containerWidth = wrapper.clientWidth || 240;
const size = Math.min(280, Math.floor(containerWidth * 0.99));
const cx = size / 2;
const stroke = 8;
const gap = 4;
const decrement = stroke + gap;
const ringCount = shown.length;
const pad = 2;
const outerRadius = Math.max(20, Math.floor(size / 2) - stroke / 2 - pad);
// diameter line; arcs bulge UPWARD from here (into y < baseY)
const baseY = outerRadius + stroke / 2 + pad;
const svgHeight = Math.ceil(baseY + stroke / 2 + 2);
let svg = wrapper.querySelector('svg.canvasrefined-progress-svg');
if (!svg) {
svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('class', 'canvasrefined-progress-svg');
svg.style.display = 'block';
wrapper.appendChild(svg);
}
svg.setAttribute('width', String(size));
svg.setAttribute('height', String(svgHeight));
svg.setAttribute('viewBox', `0 0 ${size} ${svgHeight}`);
// shrink spacing if too many classes would overflow the inner radius
const minInnerRadius = 14;
const requiredSpace = (ringCount - 1) * decrement;
let adjustFactor = 1;
if (requiredSpace > outerRadius - minInnerRadius) {
adjustFactor = (outerRadius - minInnerRadius) / Math.max(1, (ringCount - 1) * decrement);
}
shown.forEach((entry, idx) => {
const radius = outerRadius - idx * Math.max(1, Math.floor(decrement * adjustFactor));
if (radius <= 0) return;
// sweep-flag 1 => arc bulges UPWARD (top semicircle), drawn left -> right
const arcPath = `M ${cx - radius} ${baseY} A ${radius} ${radius} 0 0 1 ${cx + radius} ${baseY}`;
const prog = entry.total === 0 ? 0 : entry.completed / entry.total;
const color = `hsl(${Math.round((idx * 360) / Math.max(1, ringCount))},70%,55%)`;
// track: full semicircle, faded class color
let track = svg.querySelector(`path[data-idx='${idx}'][data-role='bg']`);
if (!track) {
track = document.createElementNS('http://www.w3.org/2000/svg', 'path');
track.setAttribute('data-idx', String(idx));
track.setAttribute('data-role', 'bg');
track.setAttribute('fill', 'none');
track.setAttribute('stroke-linecap', 'round');
svg.appendChild(track);
}
track.setAttribute('d', arcPath);
track.setAttribute('stroke', color);
track.setAttribute('stroke-opacity', '0.25');
track.setAttribute('stroke-width', String(stroke));
track.removeAttribute('stroke-dasharray');
track.removeAttribute('stroke-dashoffset');
// progress arc: completed portion drawn from the left, via pathLength=100
let progArc = svg.querySelector(`path[data-idx='${idx}'][data-role='fg']`);
const targetOff = (100 * (1 - prog)).toFixed(3);
if (!progArc) {
progArc = document.createElementNS('http://www.w3.org/2000/svg', 'path');
progArc.setAttribute('data-idx', String(idx));
progArc.setAttribute('data-role', 'fg');
progArc.setAttribute('fill', 'none');
progArc.setAttribute('stroke-linecap', 'round');
progArc.setAttribute('pathLength', '100');
progArc.setAttribute('stroke-dasharray', '100 100');
progArc.setAttribute('stroke-dashoffset', '100'); // start empty
progArc.style.transition = 'stroke-dashoffset .8s cubic-bezier(.2,.9,.2,1), opacity .3s ease';
svg.appendChild(progArc);
}
progArc.setAttribute('d', arcPath);
progArc.setAttribute('stroke', color);
progArc.setAttribute('stroke-width', String(stroke));
progArc.setAttribute('stroke-dasharray', '100 100');
const dim = progressFilterDim(entry.courseId);
track.style.transition = 'opacity .3s ease';
track.style.opacity = dim ? '0.25' : '';
progArc.style.opacity = dim ? '0.3' : '';
requestAnimationFrame(() => requestAnimationFrame(() => progArc.setAttribute('stroke-dashoffset', targetOff)));
// transparent hit arc on top so the whole arc is easy to click.
let hit = svg.querySelector(`path[data-idx='${idx}'][data-role='hit']`);
if (!hit) {
hit = document.createElementNS('http://www.w3.org/2000/svg', 'path');
hit.setAttribute('data-idx', String(idx));
hit.setAttribute('data-role', 'hit');
hit.setAttribute('fill', 'none');
hit.setAttribute('stroke', 'transparent');
hit.setAttribute('stroke-linecap', 'round');
hit.setAttribute('pointer-events', 'stroke');
svg.appendChild(hit);
}
hit.setAttribute('d', arcPath);
hit.setAttribute('stroke-width', String(stroke + 6));
attachProgressFilterClick(hit, entry.courseId);
hit.onmouseenter = () => { track.style.opacity = '0.8'; progArc.style.opacity = '0.8'; };
hit.onmouseleave = () => {
const d = progressFilterDim(entry.courseId);
track.style.opacity = d ? '0.25' : '';
progArc.style.opacity = d ? '0.3' : '';
};
});
// drop arcs for classes no longer shown
const maxIdx = shown.length - 1;
svg.querySelectorAll('path').forEach(p => {
const idx = parseInt(p.getAttribute('data-idx'));
if (Number.isNaN(idx) || idx > maxIdx) p.remove();
});
// Overlay the percent/count text INSIDE the rainbow's semicircle hole
// (the empty bowl bounded by the INNERMOST arc) instead of beneath it or
// up among the arcs. The SVG is centered in the wrapper, so an absolutely-
// positioned overlay covering the wrapper with flex centering aligns the
// text to the SVG's horizontal center. Vertically we target the centroid of
// the innermost semicircle (a touch above the diameter line) so the text
// sits in the arc-free pocket near the bottom rather than near the apexes
// of the inner arcs, where it would overlap the rainbow strokes.
const step = Math.max(1, Math.floor(decrement * adjustFactor));
const innerRadius = ringCount > 0 ? Math.max(1, outerRadius - (ringCount - 1) * step) : outerRadius;
const holeCenterY = baseY - (4 * innerRadius) / (3 * Math.PI) + 6;
const nudge = Math.round(holeCenterY - svgHeight / 2);
let overlay = wrapper.querySelector('.canvasrefined-progress-overlay');
if (!overlay) {
overlay = document.createElement('div');
overlay.className = 'canvasrefined-progress-overlay';
overlay.style.cssText = `position:absolute;left:0;top:0;width:100%;height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;pointer-events:none;transform:translateY(${nudge}px);`;
overlay.innerHTML = `
`;
wrapper.appendChild(overlay);
} else {
overlay.style.transform = `translateY(${nudge}px)`;
}
overlay.querySelector('.canvasrefined-progress-percent').textContent = `${percent}%`;
overlay.querySelector('.canvasrefined-progress-count').textContent = `${completedAll}/${totalAll} done`;
}
// Mode "lines": one horizontal bar per course, each with its own %.
function renderProgressLines(wrapper, shown) {
let list = wrapper.querySelector('.canvasrefined-progress-lines');
if (!list) {
list = document.createElement('div');
list.className = 'canvasrefined-progress-lines';
list.style.cssText = 'display:flex;flex-direction:column;gap:8px;width:100%;box-sizing:border-box;';
wrapper.appendChild(list);
}
while (list.children.length > shown.length) list.lastChild.remove();
shown.forEach((entry, idx) => {
const prog = entry.total === 0 ? 0 : entry.completed / entry.total;
const pct = Math.round(prog * 100);
const color = courseRingColor(entry.courseId, idx);
let row = list.children[idx];
if (!row) {
row = document.createElement('div');
row.className = 'canvasrefined-progress-line';
row.style.cssText = 'display:flex;flex-direction:column;gap:3px;width:100%;';
row.innerHTML = `
`;
list.appendChild(row);
}
const labelEl = row.querySelector('.cr-pl-label');
labelEl.textContent = courseRingLabel(entry.courseId);
labelEl.style.color = color;
row.querySelector('.cr-pl-pct').textContent = `${pct}% (${entry.completed}/${entry.total})`;
const track = row.children[1];
track.style.background = `color-mix(in srgb, ${color} 25%, transparent)`;
const fill = row.querySelector('.cr-pl-fill');
fill.style.background = color;
if (!fill.dataset.init) {
fill.dataset.init = '1';
requestAnimationFrame(() => requestAnimationFrame(() => fill.style.width = `${pct}%`));
} else {
fill.style.width = `${pct}%`;
}
row.style.transition = 'opacity .3s ease';
row.style.opacity = progressFilterDim(entry.courseId) ? '0.4' : '';
attachProgressFilterClick(row, entry.courseId);
row.onmouseenter = () => { row.style.opacity = '0.8'; };
row.onmouseleave = () => { row.style.opacity = progressFilterDim(entry.courseId) ? '0.4' : ''; };
});
}
// Mode "line": one horizontal bar where each class's COMPLETED portion is packed
// to the left (full course color) and its UNCOMPLETED portion to the right
// (faded course color), with no gaps between segments. Overall % shown above.
function renderProgressOneLine(wrapper, shown, totalAll, completedAll, percent) {
let box = wrapper.querySelector('.canvasrefined-progress-oneline');
if (!box) {
box = document.createElement('div');
box.className = 'canvasrefined-progress-oneline';
box.style.cssText = 'display:flex;flex-direction:column;gap:5px;width:100%;box-sizing:border-box;';
wrapper.appendChild(box);
}
let head = box.querySelector('.cr-ol-head');
if (!head) {
head = document.createElement('div');
head.className = 'cr-ol-head';
head.style.cssText = 'display:flex;justify-content:space-between;align-items:center;font-size:12px;color:var(--bctext-0);';
head.innerHTML = `Overall `;
box.appendChild(head);
}
box.querySelector('.cr-ol-pct').textContent = `${percent}% (${completedAll}/${totalAll})`;
// Clicking the "Overall" header clears the active class filter.
if (getCurrentCourseId() == null) {
head.style.cursor = betterTodoProgressFilter != null ? 'pointer' : '';
head.onclick = () => {
if (betterTodoProgressFilter == null) return;
betterTodoProgressFilter = null;
const loc = document.querySelector("#canvasrefined-todo-list");
if (loc) { clearTodoList(); createTodoSections(loc); }
};
} else {
head.style.cursor = '';
head.onclick = null;
}
let bar = box.querySelector('.cr-ol-bar');
if (!bar) {
bar = document.createElement('div');
bar.className = 'cr-ol-bar';
// position:relative so absolutely-positioned segments anchor to it
bar.style.cssText = 'position:relative;width:100%;height:14px;border-radius:999px;overflow:hidden;background:var(--bcbackground-1);';
box.appendChild(bar);
}
// Build the segment list left -> right: ALL completed blocks first (each
// class's full course color), then ALL remaining blocks (each class's faded
// course color). This keeps every completed segment on the left and every
// remaining segment on the right. Consecutive segments share edges, so
// there are no gaps between them.
const segs = [];
let left = 0;
// First pass: completed blocks for every class (left side).
shown.forEach((entry, idx) => {
const color = courseRingColor(entry.courseId, idx);
const doneW = totalAll === 0 ? 0 : (entry.completed / totalAll) * 100;
if (doneW > 0) { segs.push({ left, w: doneW, bg: color, courseId: entry.courseId }); left += doneW; } // completed: full color
});
// Second pass: remaining blocks for every class (right side).
shown.forEach((entry, idx) => {
const color = courseRingColor(entry.courseId, idx);
const remW = totalAll === 0 ? 0 : ((entry.total - entry.completed) / totalAll) * 100;
if (remW > 0) { segs.push({ left, w: remW, bg: `color-mix(in srgb, ${color} 25%, transparent)`, courseId: entry.courseId }); left += remW; } // remaining: faded
});
while (bar.children.length > segs.length) bar.lastChild.remove();
segs.forEach((seg, idx) => {
let el = bar.children[idx];
if (!el) {
el = document.createElement('div');
el.className = 'cr-ol-seg';
el.style.cssText = 'position:absolute;top:0;height:100%;transition:left .6s ease,width .6s ease,background .3s ease,opacity .3s ease;';
bar.appendChild(el);
}
el.style.left = `${seg.left}%`;
el.style.width = `${seg.w}%`;
el.style.background = seg.bg;
el.style.opacity = progressFilterDim(seg.courseId) ? '0.35' : '';
attachProgressFilterClick(el, seg.courseId);
el.onmouseenter = () => { el.style.opacity = '0.8'; };
el.onmouseleave = () => { el.style.opacity = progressFilterDim(seg.courseId) ? '0.35' : ''; };
});
}
function renderProgressRings(container, scopedData) {
const mode = getProgressRingMode();
if (mode === "none") { container.innerHTML = ""; return; }
const allAssignments = scopedData.filter(item => (item.plannable_type == "assignment" || item.plannable_type == "planner_note"));
const groups = {};
allAssignments.forEach(item => {
const cid = String(item.course_id || item.context_id || item.plannable?.course_id || "personal");
groups[cid] = groups[cid] || [];
groups[cid].push(item);
});
const entries = Object.keys(groups).map(cid => {
const arr = groups[cid];
const completed = arr.filter(it => (it.submissions?.submitted || it.planner_override?.marked_complete)).length;
return { courseId: cid, total: arr.length, completed };
}).filter(e => e.total > 0);
if (!entries.length) { container.innerHTML = ""; return; }
// sort by total desc and limit to 6 courses
entries.sort((a, b) => b.total - a.total);
const shown = entries.slice(0, 6);
const totalAll = shown.reduce((s, e) => s + e.total, 0);
const completedAll = shown.reduce((s, e) => s + e.completed, 0);
const percent = totalAll === 0 ? 0 : Math.round((completedAll / totalAll) * 100);
// wrapper reused across renders; clear on mode switch so each mode rebuilds fresh DOM
let wrapper = container.querySelector('.canvasrefined-progress-wrapper');
if (!wrapper) {
wrapper = document.createElement('div');
wrapper.className = 'canvasrefined-progress-wrapper';
wrapper.style.cssText = 'display:flex;flex-direction:column;align-items:center;position:relative;width:100%;box-sizing:border-box;';
container.appendChild(wrapper);
}
if (wrapper.dataset.mode !== mode) {
wrapper.innerHTML = '';
wrapper.dataset.mode = mode;
}
if (mode === "rings") {
renderProgressRingsMode(wrapper, shown, totalAll, completedAll, percent);
} else if (mode === "rainbow") {
renderProgressRainbow(wrapper, shown, totalAll, completedAll, percent);
} else if (mode === "lines") {
renderProgressLines(wrapper, shown);
} else if (mode === "line") {
renderProgressOneLine(wrapper, shown, totalAll, completedAll, percent);
}
}
function buildPlannerNotePayload(form) {
const title = form.querySelector("#better-todo-new-task-title")?.value?.trim();
const details = form.querySelector("#better-todo-new-task-details")?.value?.trim();
const courseIdRaw = form.querySelector("#better-todo-new-task-course")?.value;
const dateValue = form.querySelector("#better-todo-new-task-date")?.value;
const timeValue = form.querySelector("#better-todo-new-task-time")?.value;
const link = form.querySelector("#better-todo-new-task-link")?.value?.trim() || "";
if (!title) {
throw new Error("Task title is required.");
}
if (!dateValue || !timeValue) {
throw new Error("Please choose both a date and time.");
}
const localDateTime = new Date(`${dateValue}T${timeValue}:00`);
if (Number.isNaN(localDateTime.getTime())) {
throw new Error("Invalid task date.");
}
return {
title,
details,
link,
courseId: courseIdRaw ? parseInt(courseIdRaw) : null,
// Canvas accepts local timestamp strings more reliably than UTC ISO strings for planner notes.
todoDate: `${dateValue}T${timeValue}:00`,
};
}
async function createCanvasPlannerNote(payload) {
const csrfToken = CSRFtoken();
const plannerNote = {
title: payload.title,
todo_date: payload.todoDate,
};
if (payload.details) plannerNote.details = payload.details;
if (payload.courseId) plannerNote.course_id = payload.courseId;
const attempts = [
{
headers: {
"content-type": "application/json",
"accept": "application/json",
"X-CSRF-Token": csrfToken,
},
body: JSON.stringify({ planner_note: plannerNote }),
},
{
headers: {
"content-type": "application/json",
"accept": "application/json",
"X-CSRF-Token": csrfToken,
},
body: JSON.stringify(plannerNote),
},
{
headers: {
"content-type": "application/x-www-form-urlencoded; charset=UTF-8",
"accept": "application/json",
"X-CSRF-Token": csrfToken,
},
body: (() => {
const formBody = new URLSearchParams();
formBody.set("planner_note[title]", plannerNote.title);
formBody.set("planner_note[todo_date]", plannerNote.todo_date);
if (plannerNote.details) formBody.set("planner_note[details]", plannerNote.details);
if (plannerNote.course_id) formBody.set("planner_note[course_id]", plannerNote.course_id);
return formBody.toString();
})(),
},
];
let lastError = "Canvas rejected task creation.";
for (const attempt of attempts) {
const response = await fetch(domain + "/api/v1/planner_notes", {
method: "POST",
headers: attempt.headers,
body: attempt.body,
});
if (response.status === 200 || response.status === 201) {
return response.json();
}
try {
const errData = await response.json();
if (errData?.errors?.length) {
lastError = errData.errors.join(" ");
} else if (errData?.message) {
lastError = errData.message;
}
} catch (_) {
// Keep prior error text when body is not JSON.
}
}
throw new Error(lastError || "Canvas rejected task creation.");
}
/* Custom task links: Canvas planner notes have no link field, so store a
user link in sync storage keyed by the note id. */
function getCustomTaskLinks() {
return (options && options.custom_task_links) || {};
}
function getCustomTaskLinkId(item) {
return item?.plannable_id ?? item?.plannable?.id ?? null;
}
function normalizeTaskLink(link) {
if (!link) return "";
link = String(link).trim();
if (!link) return "";
if (/^https?:\/\//i.test(link)) return link;
if (link.startsWith("//")) return "https:" + link;
return domain + (link.startsWith("/") ? link : "/" + link);
}
function customTaskHref(item) {
const id = getCustomTaskLinkId(item);
const links = getCustomTaskLinks();
if (id != null && links[String(id)]) {
return normalizeTaskLink(links[String(id)]);
}
const courseId = item?.course_id || item?.plannable?.course_id || item?.context_id;
if (courseId) return `${domain}/courses/${courseId}`;
return `${domain}/`;
}
function saveCustomTaskLink(noteId, link) {
if (noteId == null) return;
const links = { ...getCustomTaskLinks() };
const key = String(noteId);
if (link && String(link).trim()) {
links[key] = String(link).trim();
} else {
delete links[key];
}
options = { ...options, custom_task_links: links };
chrome.storage.sync.set({ custom_task_links: links });
}
function deleteCustomTaskLink(noteId) {
if (noteId == null) return;
const links = { ...getCustomTaskLinks() };
delete links[String(noteId)];
options = { ...options, custom_task_links: links };
chrome.storage.sync.set({ custom_task_links: links });
}
async function updateCanvasPlannerNote(id, payload) {
if (!id) throw new Error("Missing task id.");
const csrfToken = CSRFtoken();
// Canvas's planner_notes update endpoint permits FLAT parameters
// (title, details, course_id, todo_date) — NOT nested under planner_note.
// Sending nested params is silently ignored, so note.update({}) runs and
// returns 200 with the unchanged note, making edits appear to "not save".
// Always include details (even empty) so the field can be cleared.
const plannerNote = {
title: payload.title,
todo_date: payload.todoDate,
details: payload.details || "",
// Sending course_id as empty string disassociates the note from its course.
course_id: payload.courseId || "",
};
const attempts = [
{
headers: {
"content-type": "application/json",
"accept": "application/json",
"X-CSRF-Token": csrfToken,
},
body: JSON.stringify(plannerNote),
},
{
headers: {
"content-type": "application/x-www-form-urlencoded; charset=UTF-8",
"accept": "application/json",
"X-CSRF-Token": csrfToken,
},
body: (() => {
const formBody = new URLSearchParams();
formBody.set("title", plannerNote.title);
formBody.set("todo_date", plannerNote.todo_date);
formBody.set("details", plannerNote.details);
formBody.set("course_id", plannerNote.course_id);
return formBody.toString();
})(),
},
];
let lastError = "Canvas rejected task update.";
for (const attempt of attempts) {
const response = await fetch(`${domain}/api/v1/planner_notes/${id}`, {
method: "PUT",
headers: attempt.headers,
body: attempt.body,
});
if (response.status === 200 || response.status === 201) {
return response.json();
}
try {
const errData = await response.json();
if (errData?.errors?.length) {
lastError = errData.errors.join(" ");
} else if (errData?.message) {
lastError = errData.message;
}
} catch (_) {
// Keep prior error text when body is not JSON.
}
}
throw new Error(lastError || "Canvas rejected task update.");
}
async function deleteCanvasPlannerNote(id) {
if (!id) throw new Error("Missing task id.");
const csrfToken = CSRFtoken();
const response = await fetch(`${domain}/api/v1/planner_notes/${id}`, {
method: "DELETE",
headers: {
"content-type": "application/json",
"accept": "application/json",
"X-CSRF-Token": csrfToken,
},
});
if (response.status === 200 || response.status === 201 || response.status === 204) {
return true;
}
let lastError = "Canvas rejected task deletion.";
try {
const errData = await response.json();
if (errData?.errors?.length) lastError = errData.errors.join(" ");
else if (errData?.message) lastError = errData.message;
} catch (_) { /* ignore */ }
throw new Error(lastError);
}
/* Scroll a task form field into view, leaving room below for the native date/time picker. */
function scrollTodoIntoView(el, smooth = true) {
if (!el) return;
const sidebar = document.getElementById("right-side-wrapper");
const style = sidebar ? getComputedStyle(sidebar) : null;
const scrollsSidebar = sidebar &&
(style.overflowY === "auto" || style.overflowY === "scroll") &&
sidebar.scrollHeight > sidebar.clientHeight;
const behavior = smooth ? "smooth" : "auto";
if (scrollsSidebar) {
const rect = el.getBoundingClientRect();
const sRect = sidebar.getBoundingClientRect();
const elTop = rect.top - sRect.top + sidebar.scrollTop;
// Keep the element near the top so the picker below it stays on screen.
const target = elTop - (sidebar.clientHeight - rect.height) * 0.3;
sidebar.scrollTo({ top: Math.max(0, target), behavior });
} else {
el.scrollIntoView({ block: "center", behavior });
}
}
function fillTaskCourseOptions(courseSelect) {
const cards = options.custom_cards || {};
const courseColors = options.custom_cards_3 || {};
const currentCourseId = getCurrentCourseId();
// Hidden courses should not be offered as a target for custom tasks.
const entries = Object.entries(cards)
.filter(([, card]) => card?.hidden !== true)
.map(([id, card]) => ({
id,
label: card?.default || `Course ${id}`,
color:
courseColors?.[String(id)]?.color ??
courseColors?.[id]?.color ??
"#c7cdd1",
}))
.sort((a, b) => a.label.localeCompare(b.label));
courseSelect.innerHTML = 'Personal task ';
courseSelect.options[0].dataset.color = "#c7cdd1";
entries.forEach(entry => {
const option = makeElement("option", courseSelect, {
value: entry.id,
textContent: entry.label,
});
option.dataset.color = entry.color;
option.style.color = entry.color;
if (currentCourseId && String(currentCourseId) === String(entry.id)) {
option.selected = true;
}
});
}
function updateTaskCourseSelectColor(courseSelect) {
const selectedOption = courseSelect?.options?.[courseSelect.selectedIndex];
const color = selectedOption?.dataset?.color || "#c7cdd1";
courseSelect.style.borderLeft = `4px solid ${color}`;
courseSelect.style.paddingLeft = "8px";
}
function ensureTodoTaskMenu(location, feedbackElement) {
let actionsRow = location.querySelector("#better-todo-actions-row");
if (!actionsRow) {
actionsRow = makeElement("div", location, {
id: "better-todo-actions-row",
style: "display:flex;flex-direction:column;gap:8px;margin-top:14px;",
});
const addTaskButton = makeElement("button", actionsRow, {
id: "better-todo-add-task-btn",
className: "canvasrefined-custom-btn",
textContent: "+ Add Task",
style: "width:100%;padding:6px 8px;cursor:pointer;",
});
const menu = makeElement("div", actionsRow, {
id: "better-todo-add-task-menu",
className: "canvasrefined-add-assignment",
});
menu.innerHTML = `
`;
const today = new Date();
menu.querySelector("#better-todo-new-task-date").value = formatDateForInput(today);
menu.querySelector("#better-todo-new-task-time").value = formatTimeForInput(today);
const courseSelect = menu.querySelector("#better-todo-new-task-course");
fillTaskCourseOptions(courseSelect);
updateTaskCourseSelectColor(courseSelect);
courseSelect.addEventListener("change", () => updateTaskCourseSelectColor(courseSelect));
addTaskButton.addEventListener("click", () => {
const willOpen = !menu.classList.contains("canvasrefined-custom-open");
menu.classList.toggle("canvasrefined-custom-open");
if (willOpen) {
resetTaskFormToCreate(menu);
// Scroll the form up so the picker stays on screen.
scrollTodoIntoView(menu, true);
}
});
const submitTask = async () => {
const status = menu.querySelector("#better-todo-add-task-status");
const submitButton = menu.querySelector("#better-todo-add-task-submit");
const deleteButton = menu.querySelector("#better-todo-add-task-delete");
status.textContent = "";
submitButton.disabled = true;
if (deleteButton) deleteButton.disabled = true;
try {
const payload = buildPlannerNotePayload(menu);
const editingId = menu.dataset.editingId || null;
if (editingId) {
await updateCanvasPlannerNote(editingId, payload);
saveCustomTaskLink(editingId, payload.link);
status.textContent = "Task updated.";
} else {
const created = await createCanvasPlannerNote(payload);
const newId = created?.id;
if (newId != null) saveCustomTaskLink(newId, payload.link);
status.textContent = "Task created.";
}
status.style.color = "#198754";
resetTaskFormToCreate(menu);
menu.classList.remove("canvasrefined-custom-open");
getAssignments();
clearTodoList();
createTodoSections(location);
} catch (e) {
status.textContent = e?.message || "Could not save task.";
status.style.color = "#db3754";
} finally {
submitButton.disabled = false;
if (deleteButton) deleteButton.disabled = false;
}
};
menu.querySelector("#better-todo-add-task-submit").addEventListener("click", submitTask);
// Close (×) button: dismiss the form without creating/editing a task.
menu.querySelector("#better-todo-add-task-close")?.addEventListener("click", () => {
resetTaskFormToCreate(menu);
menu.classList.remove("canvasrefined-custom-open");
});
// Reposition the field on focus so the picker opens on screen.
["#better-todo-new-task-date", "#better-todo-new-task-time"].forEach((sel) => {
const input = menu.querySelector(sel);
input?.addEventListener("focus", () => scrollTodoIntoView(input, false));
});
menu.querySelector("#better-todo-add-task-delete").addEventListener("click", async () => {
const editingId = menu.dataset.editingId || null;
if (!editingId) return;
if (!confirm("Delete this custom task? This cannot be undone.")) return;
const status = menu.querySelector("#better-todo-add-task-status");
const submitButton = menu.querySelector("#better-todo-add-task-submit");
const deleteButton = menu.querySelector("#better-todo-add-task-delete");
status.textContent = "";
submitButton.disabled = true;
deleteButton.disabled = true;
try {
await deleteCanvasPlannerNote(editingId);
deleteCustomTaskLink(editingId);
resetTaskFormToCreate(menu);
menu.classList.remove("canvasrefined-custom-open");
getAssignments();
clearTodoList();
createTodoSections(location);
} catch (e) {
status.textContent = e?.message || "Could not delete task.";
status.style.color = "#db3754";
} finally {
submitButton.disabled = false;
deleteButton.disabled = false;
}
});
}
if (feedbackElement) {
if (actionsRow.nextSibling !== feedbackElement) {
location.insertBefore(actionsRow, feedbackElement);
}
} else if (actionsRow.parentElement !== location) {
location.append(actionsRow);
}
}
// Reset the shared add/edit task form back to a blank "create" state.
function resetTaskFormToCreate(menu) {
if (!menu) return;
menu.querySelector("#better-todo-new-task-title").value = "";
menu.querySelector("#better-todo-new-task-details").value = "";
menu.querySelector("#better-todo-new-task-link").value = "";
const courseSelect = menu.querySelector("#better-todo-new-task-course");
if (courseSelect) {
fillTaskCourseOptions(courseSelect);
updateTaskCourseSelectColor(courseSelect);
}
const now = new Date();
menu.querySelector("#better-todo-new-task-date").value = formatDateForInput(now);
menu.querySelector("#better-todo-new-task-time").value = formatTimeForInput(now);
const status = menu.querySelector("#better-todo-add-task-status");
if (status) { status.textContent = ""; status.style.color = ""; }
const del = menu.querySelector("#better-todo-add-task-delete");
if (del) del.style.display = "none";
const submit = menu.querySelector("#better-todo-add-task-submit");
if (submit) submit.textContent = "Create";
delete menu.dataset.editingId;
}
// Open the shared form pre-filled with a custom task for editing or deletion.
function openTaskForEdit(item) {
const location = document.getElementById("canvasrefined-todo-list");
if (!location) return;
const feedbackElement = location.querySelector(".recent_feedback");
ensureTodoTaskMenu(location, feedbackElement);
const menu = document.getElementById("better-todo-add-task-menu");
if (!menu) return;
const noteId = getCustomTaskLinkId(item);
menu.querySelector("#better-todo-new-task-title").value = item?.plannable?.title || "";
menu.querySelector("#better-todo-new-task-details").value = item?.plannable?.details || "";
const courseSelect = menu.querySelector("#better-todo-new-task-course");
const cid = item?.course_id || item?.plannable?.course_id || "";
if (courseSelect) courseSelect.value = cid ? String(cid) : "";
updateTaskCourseSelectColor(courseSelect);
const dateObj = new Date(item?.plannable_date || item?.plannable?.todo_date || Date.now());
if (!Number.isNaN(dateObj.getTime())) {
menu.querySelector("#better-todo-new-task-date").value = formatDateForInput(dateObj);
menu.querySelector("#better-todo-new-task-time").value = formatTimeForInput(dateObj);
}
const links = getCustomTaskLinks();
menu.querySelector("#better-todo-new-task-link").value = (noteId != null && links[String(noteId)]) || "";
menu.dataset.editingId = noteId != null ? String(noteId) : "";
const del = menu.querySelector("#better-todo-add-task-delete");
if (del) del.style.display = "";
menu.querySelector("#better-todo-add-task-submit").textContent = "Save";
const status = menu.querySelector("#better-todo-add-task-status");
if (status) { status.textContent = ""; status.style.color = ""; }
menu.classList.add("canvasrefined-custom-open");
scrollTodoIntoView(menu, true);
}
async function createTodoSections(location) {
if (!location.querySelector("#better-todo-header")) {
let header = makeElement("div", location, { id: "better-todo-header" });
header.style = "display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--bcbackground-1);padding-bottom:-2px;";
let today = new Date();
today.setHours(0,0,0,0);
const todayString = today.toLocaleDateString("en-US", { weekday: "long", month: "short", day: "numeric" });
header.innerHTML = `
Tasks
${todayString}
`;
// placeholder for progress rings above the tab/filter control
makeElement("div", location, { id: "better-todo-progress-placeholder", style: "display:flex;justify-content:center;margin-top:8px;" });
let filterControl = makeElement("div", location, { "id": "better-todo-filter" });
filterControl.innerHTML = `
`;
setTimeout(() => updateIndicator(document.getElementById("better-todo-assignments")), 10);
document.getElementById("better-todo-announcement").addEventListener("click", (e) => {
betterTodoFilter = "announcements";
moreAnnouncementCount = 0;
updateIndicator(e.currentTarget);
clearTodoList();
createTodoSections(location);
});
document.getElementById("better-todo-assignments").addEventListener("click", (e) => {
betterTodoFilter = "tasks";
moreAssignmentCount = 0;
updateIndicator(e.currentTarget);
clearTodoList();
createTodoSections(location);
});
document.getElementById("better-todo-completed").addEventListener("click", (e) => {
betterTodoFilter = "completed";
moreCompletedCount = 0;
updateIndicator(e.currentTarget);
clearTodoList();
createTodoSections(location);
});
let mainSection = makeElement("div", location, {
id: "better-todo-main",
});
mainSection.style = "display:flex;flex-direction:column;";
}
let mainSection = location.querySelector("#better-todo-main");
assignments.then(data => {
const courseId = getCurrentCourseId();
const scopedData = courseId
? data.filter(item => {
const itemCourseId = parseInt(item.course_id || item.context_id || item?.plannable?.course_id);
return itemCourseId === courseId;
})
: data;
// Clicking a color in the progress display filters the list to that
// one class. The filter only makes sense where multiple classes show
// (dashboard/profile), so it is cleared on course pages.
if (courseId) betterTodoProgressFilter = null;
const displayData = (betterTodoProgressFilter == null)
? scopedData
: scopedData.filter(item => {
const cid = String(item.course_id || item.context_id || item.plannable?.course_id || "personal");
return cid === String(betterTodoProgressFilter);
});
announcements = displayData.filter(item => item.plannable_type == "announcement");
assignmentsDue = displayData.filter(item => (item.plannable_type == "assignment" || item.plannable_type == "planner_note") && !item.submissions?.submitted && !item.planner_override?.marked_complete);
completed = displayData.filter(item => (item.plannable_type == "assignment" || item.plannable_type == "planner_note") && (item.submissions?.submitted || item.planner_override?.marked_complete));
// console.log("assignments", assignmentsDue);
// console.log("announcements", announcements);
// console.log("completed", completed);
if (document.getElementById("better-todo-announcement-badge")) {
document.getElementById("better-todo-announcement-badge").remove();
}
let isAnnoucementBadge = 0;
announcements.forEach(item => {
if (item.plannable.read_state == "unread") {
isAnnoucementBadge++;
return;
}
})
if (isAnnoucementBadge > 0) {
makeElement("div", document.getElementById("better-todo-announcement"), {
id: "better-todo-announcement-badge",
style: "background-color:#ff0000;width:15px;height:15px;border-radius:50%;font-size:12px;position:absolute;top:-7px;left:16px;display:flex;justify-content:center;align-items:center;", // TODO: theme compatibility
innerHTML: `${isAnnoucementBadge} `
})
}
domContainers = {};
const groupKeys = ["-1", "0", "1", "2", "3", "4", "5", "6", "7", "14", "21", "30", "Later", "New", "Seen", "Ungraded", "Graded"];
for (const key of groupKeys) {
let wrapper = makeElement("div", mainSection, {
style: "display:none;margin-top:10px;",
className: "better-todo-dueheader",
});
let label = "";
if (key == "Later") label = "Due Later ";
if (key == "-1") label = "Overdue ";
else if (key == "0") label = "Due Today ";
else if (key == "1") label = "Due Tomorrow ";
else if (key >= 2 && key < 7) label = "Due " + key + " days ";
else if (key >= 7 && key < 30) label = "Due " + key/7 + " weeks ";
else if (key == "30") label = "Due 1 month ";
else label = "" + key + " ";
makeElement("div", wrapper, {
innerHTML: "" + label + " ",
style: "display:flex;flex-direction:column;gap:10px;font-size:12px;color:var(--bctext-0);"
})
let listContainer = makeElement("div", wrapper, { className: "todo-group-list" });
listContainer.style = "display:flex;flex-direction:column;gap:10px;";
domContainers[key] = { wrapper, listContainer };
}
if (betterTodoFilter == "tasks") {
populateAssignments();
}
if (betterTodoFilter == "announcements") {
populateAnnouncements();
}
if (betterTodoFilter == "completed") {
populateAssignments(true);
}
const feedbackElement = location.querySelector(".recent_feedback");
// populate progress rings placeholder (respect user toggle)
const progressPlaceholder = document.getElementById("better-todo-progress-placeholder");
if (progressPlaceholder) {
if (progressRingsEnabled()) {
renderProgressRings(progressPlaceholder, scopedData);
} else {
progressPlaceholder.innerHTML = "";
}
}
// Only show the Add Task control on the Assignments (tasks) tab.
if (betterTodoFilter === "tasks") {
ensureTodoTaskMenu(location, feedbackElement);
} else {
const existing = location.querySelector("#better-todo-actions-row");
if (existing) existing.remove();
}
if (feedbackElement) {
if (options.todo_hide_feedback == true) {
feedbackElement.style.display = "none";
} else {
feedbackElement.style.display = "block";
}
}
const sidebar = document.getElementById("right-side-wrapper");
ensureRightSideWrapperScrollbarHidden();
sidebar.style.setProperty("scrollbar-width", "none");
sidebar.style.setProperty("-ms-overflow-style", "none");
if (options.todo_full_height) {
sidebar.style.minHeight = "100vh";
} else {
sidebar.style.minHeight = "";
}
if (options.todo_separate_scrollbar) {
sidebar.style.position = "sticky";
sidebar.style.top = "0";
sidebar.style.height = "100vh";
sidebar.style.overflowY = "auto";
} else {
sidebar.style.position = "";
sidebar.style.top = "";
sidebar.style.height = "";
sidebar.style.overflowY = "";
// maybe invisible scrollbar?
}
});
}
function ensureRightSideWrapperScrollbarHidden() {
let style = document.getElementById("canvasrefined-hide-right-sidebar-scrollbar") || document.createElement("style");
style.id = "canvasrefined-hide-right-sidebar-scrollbar";
style.textContent = `
#right-side-wrapper {
scrollbar-width: none !important;
-ms-overflow-style: none !important;
}
#right-side-wrapper::-webkit-scrollbar {
width: 0 !important;
height: 0 !important;
display: none !important;
}
`;
document.head.append(style);
}
function clearTodoList() {
const seeMoreBtn = document.getElementById("better-todo-see-more");
if (seeMoreBtn) {
seeMoreBtn.remove();
}
document.getElementById("better-todo-main").querySelectorAll(".todo-group-list").forEach(list => {
list.innerHTML = "";
});
document.querySelectorAll(".better-todo-dueheader").forEach(header => {
header.remove();
});
}
function populateAssignments(iscompleted = false) {
const today = new Date();
today.setHours(0,0,0,0);
let assignments = (iscompleted ? completed : assignmentsDue).slice();
if (iscompleted) {
assignments.sort((a, b) => {
const aIsGraded = Boolean(a.submissions?.graded);
const bIsGraded = Boolean(b.submissions?.graded);
if (aIsGraded !== bIsGraded) {
return aIsGraded - bIsGraded;
}
return new Date(b.plannable_date) - new Date(a.plannable_date);
});
}
let assignmentCount = 0;
const maxElements = options.num_todo_items;
assignments.forEach((item) => {
let dueGroup = -1;
if (!iscompleted) {
let dueDate = new Date(item.plannable_date);
dueDate.setHours(0,0,0,0);
const diffDays = Math.round((dueDate - today) / (1000 * 60 * 60 * 24));
if (diffDays < 0) {dueGroup = -1;}
else if (diffDays <= 1) { dueGroup = diffDays.toString(); }
else if (diffDays <= 7) { dueGroup = diffDays.toString(); }
else if (diffDays <= 14) {dueGroup = 14;}
else if (diffDays <= 21) {dueGroup = 21;}
else if (diffDays <= 30) {dueGroup = 30;}
else {dueGroup = "Later"};
} else {
dueGroup = item.submissions?.graded ? "Graded" : "Ungraded";
}
let assignment
const targetContainer = domContainers[dueGroup];
assignmentCount++;
let isHidden = assignmentCount > maxElements;
if (targetContainer) {
if (!isHidden) {
targetContainer.wrapper.style.display = "block";
targetContainer.wrapper.setAttribute("data-has-visible", "true");
}
else {
if (!targetContainer.wrapper.hasAttribute("data-has-visible")) {
targetContainer.wrapper.classList.add(
"better-todo-hidden-wrapper",
);
}
}
// targetContainer.wrapper.style.display = "block";
assignment = makeElement("div", targetContainer.listContainer, {
class: "better-todo-assignment",
});
if (isHidden) {
assignment.style.display = "none";
assignment.classList.add("better-todo-hidden-assignment");
}
}
const courseColor =
options.custom_cards_3?.[String(item.course_id)]?.color ??
options.custom_cards_3?.[item.course_id]?.color ??
options.custom_cards_3?.[item.plannable.course_id]?.color ??
"#cccccc";
const isCustomTask = item.plannable_type == "planner_note" || item.planner_override?.custom === true;
const taskHref = isCustomTask ? customTaskHref(item) : (domain + item.html_url);
const editButtonSvg = isCustomTask
? ` `
: "";
const iconSize = isCustomTask ? 26 : 20;
const iconLeftOffset = isCustomTask ? 2 : 5;
const taskIcon = isCustomTask
? `
`
: `
`;
assignment.style.overflowX = "hidden";
assignment.innerHTML = `
`;
assignment.querySelector(".better-todo-assignment-checkmark").addEventListener("click", () => {
console.log("marking ", item.plannable.title, " as complete");
markAs(item, assignment.firstElementChild);
});
const editBtn = assignment.querySelector(".better-todo-assignment-edit");
if (editBtn) {
editBtn.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
openTaskForEdit(item);
});
}
});
if (document.getElementById("better-todo-see-more")) {
document.getElementById("better-todo-see-more").remove();
}
if (assignmentCount > maxElements) {
let isExpanded = false;
let seeMoreButton = makeElement("button", document.getElementById("better-todo-main"), {
textContent: `View More (${assignmentCount - maxElements})`,
className: "canvasrefined-custom-btn",
id: "better-todo-see-more",
style: "width:100%;margin-top:15px;cursor:pointer;"
})
seeMoreButton.addEventListener("click", () => {
if (!isExpanded) {
document.querySelectorAll(".better-todo-hidden-assignment").forEach(element => element.style.display = "block");
document.querySelectorAll(".better-todo-hidden-wrapper").forEach(element => element.style.display = "block");
seeMoreButton.textContent = "View Less";
} else {
document.querySelectorAll(".better-todo-hidden-assignment").forEach(element => element.style.display = "none");
document.querySelectorAll(".better-todo-hidden-wrapper").forEach(element => element.style.display = "none");
seeMoreButton.textContent = `View More (${assignmentCount - maxElements})`;
}
isExpanded = !isExpanded;
})
}
}
function populateAnnouncements() {
const today = new Date();
today.setHours(0,0,0,0);
announcements.forEach((item) => {
let dueGroup = item.plannable.read_state == "read" ? "Seen" : "New";
let announcement;
// console.log(domContainers)
const targetContainer = domContainers[dueGroup];
if (targetContainer) {
targetContainer.wrapper.style.display = "block";
announcement = makeElement("div", targetContainer.listContainer, {
class: "better-todo-announcement",
});
}
const courseColor =
options.custom_cards_3?.[String(item.course_id)]?.color ??
options.custom_cards_3?.[item.course_id]?.color ??
options.custom_cards_3?.[item.plannable.course_id]?.color ??
"#cccccc";
let filter = "";
if (item.plannable.read_state == "read") {
filter = "filter: grayscale(40%);"
}
announcement.innerHTML = `
`;
});
}
function createConfettiBurst(targetElement, opts = {}) {
try {
if (options.todo_confetti === false) return;
const count = opts.count || 48;
const colors = opts.colors || ['#ff4d4f', '#ffc107', '#28a745', '#17a2b8', '#6f42c1', '#ff6b6b', '#ff8a65', '#ffd54f'];
const rect = targetElement.getBoundingClientRect();
const container = document.createElement('div');
container.className = 'canvasrefined-confetti-container';
container.style.position = 'fixed';
container.style.left = '0';
container.style.top = '0';
container.style.pointerEvents = 'none';
container.style.overflow = 'visible';
container.style.zIndex = '2147483647';
document.body.appendChild(container);
const originX = rect.left + rect.width / 2;
const originY = rect.top + rect.height * 0.35;
const particles = [];
for (let i = 0; i < count; i++) {
const el = document.createElement('div');
el.className = 'canvasrefined-confetti';
const w = 4 + Math.floor(Math.random() * 7); // smaller pieces
const h = Math.max(3, Math.floor(w * (0.4 + Math.random() * 0.8)));
el.style.position = 'absolute';
el.style.width = w + 'px';
el.style.height = h + 'px';
el.style.background = colors[Math.floor(Math.random() * colors.length)];
el.style.left = (originX - w / 2) + 'px';
el.style.top = (originY - h / 2) + 'px';
el.style.opacity = '1';
el.style.borderRadius = Math.random() > 0.75 ? '50%' : '2px';
el.style.boxShadow = '0 2px 6px rgba(0,0,0,0.18)';
el.style.transformOrigin = 'center center';
el.style.willChange = 'transform, opacity';
container.appendChild(el);
const duration = 850 + Math.floor(Math.random() * 500);
const delay = Math.floor(Math.random() * 90);
const spread = opts.spread || 110;
const horizontalBias = (Math.random() - 0.5) * 2;
// Arc stays lower and wider than the old cone-shaped burst.
const endX = originX + horizontalBias * (spread * (0.7 + Math.random() * 0.6));
const endY = originY - (16 + Math.random() * 34);
const ctrlX = originX + horizontalBias * (spread * 0.25) + (Math.random() - 0.5) * 14;
const ctrlY = originY - (30 + Math.random() * 55);
particles.push({
el,
delay,
duration,
originX,
originY,
ctrlX,
ctrlY,
endX,
endY,
rotate: (Math.random() * 260) - 130,
scale: 0.8 + Math.random() * 0.5,
});
}
const startTime = performance.now();
let rafId = null;
const animate = now => {
let active = false;
for (let i = particles.length - 1; i >= 0; i--) {
const particle = particles[i];
const elapsed = now - startTime - particle.delay;
if (elapsed < 0) {
active = true;
continue;
}
const progress = Math.min(1, elapsed / particle.duration);
const eased = 1 - Math.pow(1 - progress, 3);
const x = (1 - eased) * (1 - eased) * particle.originX + 2 * (1 - eased) * eased * particle.ctrlX + eased * eased * particle.endX;
const y = (1 - eased) * (1 - eased) * particle.originY + 2 * (1 - eased) * eased * particle.ctrlY + eased * eased * particle.endY;
particle.el.style.transform = `translate(${Math.round(x - particle.originX)}px, ${Math.round(y - particle.originY)}px) rotate(${particle.rotate * eased}deg) scale(${particle.scale * (1 - eased * 0.15)})`;
particle.el.style.opacity = String(1 - progress);
if (progress < 1) {
active = true;
} else {
particle.el.remove();
particles.splice(i, 1);
}
}
if (active) {
rafId = requestAnimationFrame(animate);
} else {
try { container.remove(); } catch (e) { /* ignore */ }
if (rafId) cancelAnimationFrame(rafId);
}
};
rafId = requestAnimationFrame(animate);
// cleanup container after animations
setTimeout(() => {
try { container.remove(); } catch (e) { /* ignore */ }
}, 2400);
} catch (e) {
console.error('confetti error', e);
}
}
function markAs(item, element) {
const csrfToken = CSRFtoken();
const completeState = item.planner_override ? !item.planner_override.marked_complete : true;
fetch(domain + "/api/v1/planner/overrides" + (item.planner_override ? "/" + item.planner_override.id : ""), {
method: item.planner_override ? "PUT" : "POST",
headers: {
"content-type":"application/json",
"accept":"application/json",
"X-CSRF-Token": csrfToken
},
body: JSON.stringify({
id: item.planner_override ? item.planner_override.id : null,
marked_complete: completeState,
plannable_id: item.plannable_id,
plannable_type: item.plannable_type
})
})
.then(resp => {
if (resp.status == 200 || resp.status == 201 || resp.status == 204) {
console.log("marked as complete");
item.planner_override = item.planner_override || {};
item.planner_override.marked_complete = completeState;
element.style.transform = "translate(100%)";
element.style.opacity = "0";
// fire confetti only when marking complete (not when unmarking)
if (completeState) {
try { createConfettiBurst(element); } catch (e) { console.error('confetti trigger error', e); }
}
// update progress rings immediately so they animate while the item slides/fades
const progressPlaceholder = document.getElementById("better-todo-progress-placeholder");
if (progressPlaceholder && typeof assignments?.then === 'function' && progressRingsEnabled()) {
assignments.then(data => {
const courseId = getCurrentCourseId();
const scopedData = courseId
? data.map(d => Object.assign({}, d)) // shallow copy
.filter(d => {
const itemCourseId = parseInt(d.course_id || d.context_id || d?.plannable?.course_id);
return itemCourseId === courseId;
})
: data.map(d => Object.assign({}, d));
// reflect the updated state for this item in the snapshot
for (let i = 0; i < scopedData.length; i++) {
if (scopedData[i].plannable_id === item.plannable_id && scopedData[i].plannable_type === item.plannable_type) {
scopedData[i].planner_override = scopedData[i].planner_override || {};
scopedData[i].planner_override.marked_complete = item.planner_override.marked_complete;
break;
}
}
renderProgressRings(progressPlaceholder, scopedData);
});
}
setTimeout(() => {
clearTodoList();
createTodoSections(document.querySelector("#canvasrefined-todo-list"));
}, 400);
}
})
.catch(err => console.error("error marking as complete", err));
}
function createTodoViewMore(location, type) {
let viewMoreButton = makeElement("button", location, { "className": "canvasrefined-custom-btn canvasrefined-viewmore-btn", "textContent": "View More" });
//viewMoreButton.classList.add("canvasrefined-viewmore-btn");
const showMoreCount = 3;
viewMoreButton.addEventListener("click", function (e) {
if (type === "announcement") {
moreAnnouncementCount += showMoreCount;
} else {
moreAssignmentCount += showMoreCount;
}
loadBetterTodo();
});
}
// better todo init
function setupBetterTodo() {
if (options.better_todo !== true || isGradesPage()) return;
if (document.querySelector('#canvasrefined-todo-list')) return;
let list = document.querySelector("#right-side");
if (!list) return;
//if (!list || list.childElementCount === 0 || list.children[0].id === "canvasrefined-todo-list") return;
try {
/* save the feedback to append it later */
const feedback = list.querySelector(".events_list.recent_feedback");
list.textContent = "";
list = makeElement("div", list, { "className": "canvasrefined-todosidebar","id": "canvasrefined-todo-list"});
createTodoSections(list);
if (feedback) list.append(feedback);
} catch (e) {
logError(e);
}
}
function getSidebarScale() {
const rawScale = parseInt(options.sidebar_scale || 100);
if (isNaN(rawScale)) return 1;
return Math.max(0.7, Math.min(1.5, rawScale / 100));
}
function applySidebarScaleStyles(sidebarList) {
const scale = getSidebarScale();
sidebarList.style.setProperty("--bc-sidebar-icon-size", `${Math.round(20 * scale)}px`);
sidebarList.style.setProperty("--bc-sidebar-btn-height", `${Math.round(30 * scale)}px`);
sidebarList.style.setProperty("--bc-sidebar-btn-gap", `${Math.round(8 * scale)}px`);
sidebarList.style.setProperty("--bc-sidebar-label-size", `${Math.round(14 * scale)}px`);
}
async function setupBetterSidebar(mode = getSidebarLayoutMode()) {
if (!options.better_sidebar) return;
if (document.querySelector('#better-sidebar-container')) return;
let wrapper = document.querySelector("#wrapper");
if (!wrapper || betterSidebarLoading) return;
betterSidebarLoading = true;
try {
const layoutMode = mode === "course" || mode === "dash" ? mode : getSidebarLayoutMode();
let expanded = await getSidebarExpandedState(layoutMode);
const outerWrapper = document.getElementById("main");
outerWrapper?.style.setProperty("display", "flex", "important");
// document.getElementById("not_right_side").style.setProperty("display", "none", "important");
const leftSide = document.getElementById("left-side");
leftSide?.style.setProperty("opacity", "1");
leftSide?.style.setProperty("position", "static");
const mainWrapper = document.querySelector(".ic-Layout-contentWrapper");
if (!mainWrapper) return;
applyBetterSidebarLayoutFix();
mainWrapper.style.display = "flex";
mainWrapper.style.alignItems = "stretch";
mainWrapper.style.minWidth = "0";
const contentMain = document.querySelector(".ic-Layout-contentMain");
contentMain?.style.setProperty("flex", "1 1 auto");
contentMain?.style.setProperty("min-width", "0");
const notRightSide = document.getElementById("not_right_side");
if (notRightSide && isAccountsPage()) {
notRightSide.style.setProperty("width", "100%");
notRightSide.style.setProperty("max-width", "100%");
notRightSide.style.setProperty("min-width", "0");
}
if (layoutMode === "course" && leftSide) {
const rightSideWrapper = document.getElementById("right-side-wrapper");
const sectionTabs = document.getElementById("section-tabs");
leftSide.style.setProperty("padding-top", "0", "important");
leftSide.style.setProperty("padding-left", "0", "important");
if (sectionTabs) {
if (getCurrentCourseId() !== null || isProfilePage()) {
sectionTabs.style.setProperty("padding-top", "40px", "important");
} else {
sectionTabs.style.removeProperty("padding-top");
}
}
leftSide.style.flex = "0 0 250px";
leftSide.style.width = "250px";
leftSide.style.maxWidth = "250px";
if (notRightSide) {
notRightSide.style.display = "flex";
notRightSide.style.flex = "1 1 auto";
notRightSide.style.minWidth = "0";
}
if (rightSideWrapper) {
rightSideWrapper.style.flex = "0 0 280px";
rightSideWrapper.style.width = "280px";
rightSideWrapper.style.maxWidth = "280px";
}
contentMain?.style.setProperty("margin", "26px 38px 38px", "important");
contentMain?.style.setProperty("padding", "10px", "important");
contentMain?.style.setProperty("border-radius", "10px", "important");
contentMain?.style.setProperty("background", "color-mix(in srgb, var(--bcbackground-0) 45%, transparent)", "important");
contentMain?.style.setProperty("backdrop-filter", "blur(5px)", "important");
contentMain?.style.setProperty("-webkit-backdrop-filter", "blur(5px)", "important");
}
const sidebarParent = layoutMode === "course" && leftSide ? leftSide : mainWrapper;
if (layoutMode === "course" && leftSide) {
leftSide.style.display = "flex";
leftSide.style.flexDirection = "row";
leftSide.style.alignItems = "stretch";
leftSide.style.minWidth = "0";
leftSide.style.gap = "0";
}
document.querySelector(".ic-app-nav-toggle-and-crumbs")?.style.setProperty("display", "none");
if (layoutMode !== "course") {
document.getElementById("left-side")?.style.removeProperty("display");
}
if (layoutMode == "dash") {
document.getElementById("header")?.style.setProperty("display", "none");
}
else if (layoutMode == "course") {
document.getElementById("header")?.style.setProperty("display", "none");
}
let sidebarList = makeElement("div", sidebarParent, { id: "better-sidebar-container",
style: `display:flex;flex-direction:column;width:50px;justify-content:center;align-items:center;box-sizing:border-box;position:relative;background-color:var(--bcsidebar);height:100vh;position:sticky;top:0;left:0;`
}, true);
let sidebarContent = makeElement("div", sidebarList, {
style: "display:flex;flex-direction:column;gap:20px;width:100%;flex:1;justify-content:flex-start;align-items:center;margin:40px;"
});
applySidebarScaleStyles(sidebarList);
let expander = makeElement("div", sidebarList, {
className: "better-sidebar-expander",
style: "display:flex;flex-direction:column;gap:0px;margin-top:auto;width:100%;justify-content:center;align-items:center;cursor:pointer;",
});
expander.innerHTML = `
`
sidebarList.dataset.expanded = expanded ? "true" : "false";
updateSidebar(expanded, sidebarList, expander);
setSidebarExpandedState(layoutMode, expanded);
requestAnimationFrame(() => {
populateSidebarFromNav(sidebarContent);
updateSidebar(expanded, sidebarList, expander);
watchSidebarBadges();
});
expander.addEventListener("click", () => {
expanded = !expanded;
sidebarList.dataset.expanded = expanded ? "true" : "false";
setSidebarExpandedState(layoutMode, expanded);
updateSidebar(expanded, sidebarList, expander);
})
} catch (e) {
logError(e);
} finally {
betterSidebarLoading = false;
}
}
function createSidebarButton(text, url, parent, icon) {
let button = makeElement("a", parent, {
style: "width:40%;height:var(--bc-sidebar-btn-height,30px);cursor:pointer;text-align:center;text-decoration:none;display:inline-flex;justify-content:center;align-items:center;gap:var(--bc-sidebar-btn-gap,8px);color:var(--bcsidebar-text) !important;font-weight:bold;position:relative;",
className: "canvasrefined-custom-btn better-sidebar-btn",
href: url,
});
button.innerHTML = `${icon ? `${icon}` : ``}`;
return button;
}
function getNavBadgeCount(item) {
const badge = item.querySelector(".menu-item__badge");
if (!badge) return 0;
const badgeText = badge.querySelector('[aria-hidden="true"]')?.textContent?.trim() || badge.textContent?.trim() || "";
const count = parseInt(badgeText, 10);
return Number.isFinite(count) && count > 0 ? count : 0;
}
function addSidebarButtonBadge(button, count) {
if (!button) return;
// Always clear any existing badge first so a drop to 0 unread removes it.
button.querySelector(".better-sidebar-badge")?.remove();
if (!count) return;
makeElement("div", button, {
className: "better-sidebar-badge",
style: "position:absolute;top:-6px;right:-6px;min-width:16px;height:16px;padding:0 4px;border-radius:999px;background-color:#ff0000;color:white;font-size:11px;line-height:16px;display:flex;justify-content:center;align-items:center;box-sizing:border-box;pointer-events:none;",
textContent: String(count),
});
}
// Re-read the global nav badge for each better-sidebar button and update its dot.
// Canvas loads the unread counts (Inbox/announcements) asynchronously after the
// page renders, so the dot captured at build time is often missing or stale.
function syncSidebarBadges() {
document.querySelectorAll(".better-sidebar-btn").forEach(button => {
const navId = button.dataset.navItemId;
if (!navId) return;
const navItem = document.getElementById(navId);
if (!navItem) return;
addSidebarButtonBadge(button, getNavBadgeCount(navItem));
});
}
function scheduleSidebarBadgeSync() {
if (sidebarBadgeSyncTimer) clearTimeout(sidebarBadgeSyncTimer);
sidebarBadgeSyncTimer = setTimeout(() => {
sidebarBadgeSyncTimer = null;
syncSidebarBadges();
}, 100);
}
// Watch the global nav for badge changes (late load, new mail, read/unread)
// and keep the better-sidebar dots in sync.
function watchSidebarBadges() {
const navMenu = document.getElementById("menu");
if (!navMenu) {
if (sidebarBadgeWatchRetries++ < 20) setTimeout(watchSidebarBadges, 500);
return;
}
sidebarBadgeWatchRetries = 0;
if (sidebarBadgeObserver) sidebarBadgeObserver.disconnect();
sidebarBadgeObserver = new MutationObserver(scheduleSidebarBadgeSync);
sidebarBadgeObserver.observe(navMenu, { childList: true, subtree: true, attributes: true, attributeFilter: ["class", "style"] });
scheduleSidebarBadgeSync();
}
function populateSidebarFromNav(sidebarContent) {
const excludeIds = ["global_nav_help_link", "global_nav_history_link"];
const customIcons = {
"global_nav_profile_link": ` `,
"global_nav_dashboard_link": ` `,
"global_nav_conversations_link": ` `,
"global_nav_calendar_link": ` `,
"global_nav_courses_link": ` `,
"global_nav_groups_link": ` `,
"globalNavExternalTool-69": ` `,
};
const navMenu = document.getElementById("menu");
let hasDashboardButton = false;
if (navMenu) {
const menuItems = navMenu.querySelectorAll("a[id^='global_nav'], .globalNavExternalTool a");
menuItems.forEach(item => {
const itemId = item.id;
if (excludeIds.includes(itemId)) return;
const href = item.getAttribute("href");
let textEl = item.querySelector(".menu-item__text");
let text = textEl?.textContent?.trim();
// If text not found, try other sources
if (!text) {
text = item.getAttribute("aria-label")?.trim() ||
item.getAttribute("title")?.trim() ||
item.textContent?.trim();
}
if (!text || !href) return;
let icon = customIcons[itemId] || "";
if (!icon) {
const svg = item.querySelector("svg");
if (svg) {
icon = svg.outerHTML;
// Detect and scale down large viewBox SVGs
const viewBoxMatch = icon.match(/viewBox="([^"]+)"/);
if (viewBoxMatch) {
const [, viewBox] = viewBoxMatch;
const parts = viewBox.split(/\s+/);
const width = parseFloat(parts[2]);
const height = parseFloat(parts[3]);
// If viewBox is large, add fixed size to scale it down
if (width > 32 || height > 32) {
// Check if svg already has a style attribute
if (icon.includes('style="')) {
// Append to existing style
icon = icon.replace(/style="([^"]*)"/, `style="$1 width:20px;height:20px;flex-shrink:0;fill:var(--bcsidebar-text);stroke:var(--bcsidebar-text);"`);
} else {
// Add new style attribute
icon = icon.replace(" label.style.display = expanded ? "block" : "none");
const buttons = document.querySelectorAll(".better-sidebar-btn");
buttons.forEach(label => label.style.width = expanded ? "80%" : "40%");
sidebarList.querySelectorAll(".better-sidebar-btn svg").forEach(svg => {
svg.style.width = "var(--bc-sidebar-icon-size,20px)";
svg.style.height = "var(--bc-sidebar-icon-size,20px)";
});
// Expand (or restore) the entire left-side column when the sidebar toggles
const leftSide = document.getElementById("left-side");
if (leftSide) {
// on first run store the original width (prefer computed) and inline flex/maxWidth
if (!leftSide.dataset.bcOrigWidth) {
const computed = getComputedStyle(leftSide).width || "";
leftSide.dataset.bcOrigWidth = leftSide.style.width || "";
leftSide.dataset.bcOrigFlex = leftSide.style.flex || "";
leftSide.dataset.bcOrigMaxWidth = leftSide.style.maxWidth || "";
leftSide.dataset.bcOrigWidthPx = parseFloat(computed) || 0;
}
const origPx = parseFloat(leftSide.dataset.bcOrigWidthPx || 0);
const delta = expandedWidth - collapsedWidth;
if (expanded) {
if (origPx > 0) {
const newWidth = Math.round(origPx + delta);
leftSide.style.flex = `0 0 ${newWidth}px`;
leftSide.style.width = `${newWidth}px`;
leftSide.style.maxWidth = `${newWidth}px`;
} else {
leftSide.style.flex = `0 0 ${expandedWidth}px`;
leftSide.style.width = `${expandedWidth}px`;
leftSide.style.maxWidth = `${expandedWidth}px`;
}
} else {
// restore original inline values if present, otherwise remove the properties
if (leftSide.dataset.bcOrigWidth !== "") leftSide.style.width = leftSide.dataset.bcOrigWidth; else leftSide.style.removeProperty('width');
if (leftSide.dataset.bcOrigFlex !== "") leftSide.style.flex = leftSide.dataset.bcOrigFlex; else leftSide.style.removeProperty('flex');
if (leftSide.dataset.bcOrigMaxWidth !== "") leftSide.style.maxWidth = leftSide.dataset.bcOrigMaxWidth; else leftSide.style.removeProperty('max-width');
}
}
const courseLinksTitle = document.getElementById("better-course-links-title");
if (courseLinksTitle) {
courseLinksTitle.style.display = expanded ? "block" : "none";
// Also hide separator when collapsed
const separator = courseLinksTitle.nextElementSibling;
if (separator) separator.style.display = expanded ? "block" : "none";
const container = document.getElementById("better-course-links");
if (container) {
container.style.opacity = expanded ? "1" : "0.6";
container.style.gap = expanded ? "12px" : "8px";
}
}
}
let delay;
let moreAssignmentCount = 0;
let moreAnnouncementCount = 0;
let filter = "todo";
async function loadBetterTodo() {
if (options.better_todo !== true || isGradesPage()) return;
try {
await getColors();
const discussion_svg = ' ';
const quiz_svg = ' ';
const announcement_svg = ' ';
const assignment_svg = ' ';
const x_svg = ' ';
const check_svg = ' ';
const tag_svg = ' ';
// end of SVGs
const maxAssignmentCount = parseInt(options.num_todo_items) + moreAssignmentCount;
const maxAnnouncementCount = parseInt(options.num_todo_items) + moreAnnouncementCount;
const hr24 = options.todo_hr24;
const now = new Date();
//const csrfToken = CSRFtoken();
let todoAnnouncements = document.querySelector("#canvasrefined-announcement-list");
let todoAssignments = document.querySelector("#canvasrefined-todo-list");
let assignmentsToInsert = [];
let announcementsToInsert = [];
assignments.then(data => {
chrome.storage.sync.get(options.custom_assignments_overflow, storage => {
//assignmentData = assignmentData === null ? data : assignmentData;
let items = combineAssignments(data);
items.forEach((item, index) => {
let date = new Date(item.plannable_date);
let itemState = options.assignment_states[item.plannable_id];
let svg;
switch (item.plannable_type) {
case "assignment": svg = assignment_svg; break;
case "discussion_topic": svg = discussion_svg; break;
case "quiz": svg = quiz_svg; break;
case "announcement": svg = announcement_svg; break;
default: return;
}
// if (item.plannable_type === "announcement") {
//if (announcementsToInsert.length >= maxAnnouncementCount + 1) return;
if (item.plannable_type !== "announcement") {
// leaving one extra assignment in the array to indicate there are more and the "view more" button should be created
if (assignmentsToInsert.length >= maxAssignmentCount + 1) return;
if (filter === "todo" && options.hide_completed === true && item.submissions.submitted === true) return;
if (filter === "todo" && ((options.todo_overdues !== true && now >= date) || (options.todo_overdues === true && item.submissions.submitted === true))) return;
if (filter === "done" && now <= date && !(itemState?.["rem"] === true || item?.submissions?.submitted === true)) return;
//if (item.plannable_type !== "assignment" && item.plannable_type !== "quiz" && item.plannable_type !== "discussion_topic") return;
}
if (filter === "todo" && ((itemState && itemState["rem"] === true) || (item.planner_override && item.planner_override.marked_complete === true))) return;
let listItemContainer = document.createElement("div");
listItemContainer.classList.add("canvasrefined-todo-container");
listItemContainer.innerHTML = '
';
listItemContainer.querySelector(".canvasrefined-todo-item").href = item.html_url;
listItemContainer.dataset.id = item.plannable_id;
listItemContainer.querySelector('.canvasrefined-todo-icon').innerHTML += svg;
let listItem = listItemContainer.querySelector(".canvasrefined-todo-item");
const courseColor =
options.custom_cards_3?.[String(item.course_id)]?.color ??
options.custom_cards_3?.[item.course_id]?.color ??
options.custom_cards_3?.[item.plannable?.course_id]?.color ??
"#cccccc";
if (itemState?.["lbl"] && itemState["lbl"] !== "") {
makeElement("span", listItem.querySelector(".canvasrefined-todo-item-header"), { "className": "canvasrefined-todo-label", "textContent": itemState["lbl"] });
}
if (itemState?.["crs"] === true) {
listItemContainer.querySelector(".canvasrefined-todo-item").style.textDecoration = "line-through";
}
let title = makeElement("a", listItem.querySelector(".canvasrefined-todo-item-header"), { "className": "canvasrefined-todoitem-title", "textContent": item.plannable.title });
if (options.todo_hide_feedback === true) title.style = "color:" + courseColor + "!important;";
let course = makeElement("p", listItem, { "className": "canvasrefined-todoitem-course", "textContent": item.context_name });
course.style.color = courseColor;
let format = formatTodoDate(date, item.submissions, hr24);
let todoDate = makeElement("p", listItem, { "className": "canvasrefined-todoitem-date", "textContent": format.date });
if (format.dueSoon) todoDate.classList.add("canvasrefined-due-soon");
if (options.hover_preview === true) {
const customItem = item.planner_override && item.planner_override.custom && item.planner_override.custom === true;
listItem.addEventListener("mouseover", () => {
listItem.classList.add("canvasrefined-todo-hover");
let preview = listItemContainer.querySelector(".canvasrefined-hover-preview");
let previewTitle = preview.querySelector(".canvasrefined-preview-title");
let previewText = preview.querySelector(".canvasrefined-preview-text");
clearTimeout(delay);
delay = setTimeout(async () => {
if (listItem.classList.contains("canvasrefined-todo-hover")) {
previewTitle.textContent = item.plannable.title;
// custom assignment
if (customItem) {
previewText.textContent = "Custom assignment";
} else {
console.log(item);
let found = false;
let searchCount = 1;
while (searchCount < 5 && found === false) {
for (let i = 0; i < announcements.length; i++) {
if (announcements[i].id === item.plannable_id) {
found = true;
if (previewText.textContent === "") {
let description = item.plannable_type === "announcement" ? announcements[i].message : announcements[i].description;
previewText.textContent = description === "" ? "No details given" : description.replace(/<\/?[^>]+(>|$)/g, " ");
}
break;
}
}
if (found === false) {
let apiLink = domain + "/api/v1/";
if (item.plannable_type === "assignment") {
apiLink += `courses/${item.course_id}/assignments/${item.plannable_id}`;
} else if (item.plannable_type === "announcement") {
apiLink += `announcements?context_codes[]=course_${item.course_id}&per_page=3&page=${searchCount}`;
}
let data = await getData(apiLink);
item.plannable_type === "announcement" ? announcements.push(...data) : announcements.push(data);
searchCount++;
}
}
if (found === false) {
previewText.textContent = "Couldn't load preview";
}
}
preview.style.display = "block";
}
}, 250);
});
listItem.addEventListener("mouseleave", () => {
listItem.classList.remove("canvasrefined-todo-hover");
listItemContainer.querySelector(".canvasrefined-hover-preview").style.display = "none";
});
}
const actions = listItemContainer.querySelector(".canvasrefined-todo-actions");
let clickOutActions = (e) => {
if (e.target.className.includes("canvasrefined")) return;
document.body.removeEventListener("click", clickOutActions);
actions.style.display = "none";
}
listItemContainer.querySelector(".canvasrefined-todo-actions-btn").addEventListener("click", () => {
actions.style.display = "block";
setTimeout(() => {
document.body.addEventListener("click", clickOutActions);
}, 100);
});
let removeBtn = makeElement("div", actions, { "className": "canvasrefined-todo-action", "textContent": "Remove" });
removeBtn.innerHTML += x_svg;
const dueAt = new Date(item.plannable_date).getTime();
let crossOffBtn = makeElement("div", actions, { "className": "canvasrefined-todo-action", "textContent": "Cross off" });
crossOffBtn.innerHTML += check_svg;
crossOffBtn.addEventListener("click", () => {
setAssignmentState(item.plannable_id, { "crs": listItemContainer.querySelector(".canvasrefined-todo-item").style.textDecoration === "line-through" ? false : true, "expire": dueAt });
});
let label = makeElement("span", actions, { "className": "canvasrefined-todo-action-tag", "textContent": "Label:" });
label.innerHTML += tag_svg;
let labelInput = makeElement("input", actions, { "className": "canvasrefined-todo-input", "type": "text", "placeholder": "Label", "value": itemState && itemState["lbl"] ? itemState["lbl"] : "" });
labelInput.addEventListener("change", (e) => {
setAssignmentState(item.plannable_id, { "lbl": e.target.value, "expire": dueAt });
});
removeBtn.addEventListener('click', function () {
setAssignmentState(item.plannable_id, { "rem": filter === "todo", "expire": dueAt });
if (item.planner_override && item.planner_override.custom && item.planner_override.custom === true) {
// set item as complete locally
chrome.storage.sync.get("custom_assignments_overflow", overflow => {
chrome.storage.sync.get(overflow["custom_assignments_overflow"], storage => {
overflow["custom_assignments_overflow"].forEach(overflow => {
for (let i = 0; i < storage[overflow].length; i++) {
if (storage[overflow][i].plannable_id === item.plannable_id) {
storage[overflow].splice(i, 1);
chrome.storage.sync.set({ [overflow]: storage[overflow] }).then(() => {
});
break;
}
}
});
});
});
}
});
if (item.plannable_type === "announcement") {
announcementsToInsert.push(listItemContainer);
} else {
assignmentsToInsert.push(listItemContainer);
if (item.submissions && item.submissions.submitted) {
listItemContainer.classList.add("canvasrefined-todo-item-completed");
}
}
});
// appending assignments all at once
todoAssignments.textContent = "";
if (assignmentsToInsert.length > 0) {
let i;
for (i = 0; i < (assignmentsToInsert.length > maxAssignmentCount ? maxAssignmentCount : assignmentsToInsert.length); i++) {
todoAssignments.append(assignmentsToInsert[i]);
}
if (i !== assignmentsToInsert.length) createTodoViewMore(todoAssignments, "assignment");
} else {
makeElement("p", todoAssignments, { "className": "canvasrefined-none-due", "textContent": "None" });
}
// appending announcements all at once
todoAnnouncements.textContent = "";
if (announcementsToInsert.length > 0) {
let i;
for (i = announcementsToInsert.length - 1; i >= (announcementsToInsert.length - maxAnnouncementCount < 0 ? 0 : announcementsToInsert.length - maxAnnouncementCount); i--) {
todoAnnouncements.append(announcementsToInsert[i]);
}
if (i !== -1) createTodoViewMore(todoAnnouncements, "announcement");
} else {
makeElement("p", todoAnnouncements, { "className": "canvasrefined-none-due", "textContent": "None" });
}
cleanCustomAssignments();
});
});
} catch (e) {
logError(e);
}
}
/*
Card color palettes
*/
let changeColorInterval = null;
let colorChanges = [];
async function changeColorPreset(colors) {
if (colors.length === 0) return;
// reset everything
//let res = await getData(`${domain}/api/v1/users/self/colors`);
clearInterval(changeColorInterval);
const csrfToken = CSRFtoken();
const delay = 250;
previous = []
colorChanges = [];
// sort cards
let cards = document.querySelectorAll(".ic-DashboardCard__header");
let sortedCards = [];
cards.forEach(card => {
sortedCards.push({ "href": card.querySelector(".ic-DashboardCard__link").href, "el": card });
});
sortedCards.sort((a, b) => a.href > b.href ? 1 : -1);
// push each color change into a queue
try {
sortedCards.forEach((card, i) => {
let previousColor = rgbToHex(card.el.querySelector(".ic-DashboardCard__header_hero").style.backgroundColor);
previous.push(previousColor);
// Object.keys(res.custom_colors).forEach(item => {
//let item_id = item.split("_")[1];
let course_id = card.href.split("courses/")[1];
//if (card.href.includes(item_id)) {
let cnum = i % colors.length;
let changeCardColor = () => {
fetch(domain + "/api/v1/users/self/colors/courses_" + course_id,
{
method: "PUT",
headers: {
"content-type": "application/json",
'accept': 'application/json',
'X-CSRF-Token': csrfToken,
},
body: JSON.stringify({ "hexcode": colors[cnum] })
}).then(() => {
card.el.querySelector(".ic-DashboardCard__header_hero").style.backgroundColor = colors[cnum];
card.el.querySelector(".ic-DashboardCard__header-title span").style.color = colors[cnum];
card.el.querySelector(".ic-DashboardCard__header-button-bg").style.backgroundColor = colors[cnum];
});
}
colorChanges.push(changeCardColor);
card.el.querySelector(".ic-DashboardCard__header_hero").style.backgroundColor = colors[cnum];
card.el.querySelector(".ic-DashboardCard__header-title span").style.color = colors[cnum];
card.el.querySelector(".ic-DashboardCard__header-button-bg").style.backgroundColor = colors[cnum];
//}
// });
});
} catch (e) {
logError(e);
colorChanges = [];
}
changeGradientCards();
// go through the queue until empty
changeColorInterval = setInterval(() => {
if (colorChanges.length > 0) {
let current = colorChanges.shift();
current();
} else {
clearInterval(changeColorInterval);
}
}, delay);
// set colors to revert back to
chrome.storage.local.get("previous_colors", local => {
const now = Date.now();
if (local["previous_colors"] === null || now >= local["previous_colors"].expire) {
chrome.storage.local.set({ "previous_colors": { "colors": previous, "expire": now + 86400000 } });
}
});
}
/*
Dark mode
*/
// Light-mode fallbacks for the --bc* variables, always emitted so extension UI renders in light mode; dark mode overrides below.
const BC_LIGHT_DEFAULTS = {
"background-0": "#ffffff",
"background-1": "#c7c7c7",
"background-2": "#d9d9d9",
"borders": "#808080",
"links": "#418df1",
"sidebar": "#e3e3e3",
"sidebar-text": "#000000",
"text-0": "#000000",
"text-1": "#050505",
"text-2": "#4f4f4f"
};
function generateDarkModeCSS() {
// Always-on light-mode defaults so var(--bc*) resolves in light mode too.
let css = ":root{\n";
Object.keys(BC_LIGHT_DEFAULTS).forEach((key) => {
css += " --bc" + key + ": " + BC_LIGHT_DEFAULTS[key] + ";\n";
});
css += "}\n\n";
const darkOn = options.dark_mode === true || options.device_dark === true;
if (!darkOn) return css;
let darkBlock = ":root{\n";
if (options.dark_preset) {
Object.keys(options.dark_preset).forEach((key) => {
darkBlock += " --bc" + key + ": " + options.dark_preset[key] + ";\n";
});
}
darkBlock += "}\n\n";
darkBlock += DARKMODE_CSS;
if (options.device_dark === true) {
css += "@media (prefers-color-scheme: dark) {\n" + darkBlock + "\n}";
} else {
css += darkBlock;
}
return css;
}
let darkStyleInserted = false;
function toggleDarkMode() {
const css = generateDarkModeCSS();
const darkOn = options.dark_mode === true || options.device_dark === true;
if (!darkStyleInserted) {
let style = document.createElement('style');
style.textContent = css;
document.documentElement.append(style);
style.id = 'darkcss';
style.className = darkOn ? "canvasrefined-darkmode-enabled" : "";
darkStyleInserted = true;
} else {
let style = document.querySelector("#darkcss");
style.textContent = css;
style.className = darkOn ? "canvasrefined-darkmode-enabled" : "";
}
runiframeChecker();
}
function runDarkModeFixer(override = false) {
if (options.dark_mode !== true) return { "path": "canvasrefined-darkmode_off", "time": "" };
if (override === false && !options["dark_mode_fix"].includes(window.location.pathname)) return { "path": "canvasrefined-none", "time": "" };
let output = inspectDarkMode();
return { "path": window.location.pathname, "time": output.time };
}
function autoDarkModeCheck() {
let date = new Date();
let currentHour = date.getHours();
let currentMinute = date.getMinutes();
let status = false;
if (options.auto_dark === false) return;
let startHour = parseInt(options.auto_dark_start["hour"]);
let startMinute = parseInt(options.auto_dark_start["minute"]);
let endHour = parseInt(options.auto_dark_end["hour"]);
let endMinute = parseInt(options.auto_dark_end["minute"]);
if (currentHour === startHour) {
status = currentMinute >= startMinute;
} else if (currentHour === endHour) {
status = currentMinute <= endMinute;
} else if (startHour > endHour) {
status = currentHour > startHour || currentHour < endHour;
} else if (startHour < endHour) {
status = currentHour > startHour && currentHour < endHour;
}
if (options.auto_dark === true) {
options.dark_mode = status;
chrome.storage.sync.set({ "dark_mode": status }, toggleDarkMode);
}
}
// }
function toggleAutoDarkMode() {
clearInterval(timeCheck);
if (options.auto_dark && options.auto_dark === false) return;
autoDarkModeCheck();
timeCheck = setInterval(autoDarkModeCheck, 60000);
}
let iframeObserver;
function runiframeChecker() {
if (current_page === "/" || current_page === "") return;
if (options.dark_mode !== true) {
if (iframeObserver) iframeObserver.disconnect();
document.querySelectorAll('iframe').forEach((frame) => {
if (frame.contentDocument && frame.contentDocument.documentElement && frame.contentDocument.documentElement.querySelector('#darkcss')) {
frame.contentDocument.documentElement.querySelector('#darkcss').textContent = '';
frame.contentDocument.body.classList.remove("canvasrefined--darkmode--enabled");
}
});
return;
}
const callback = (mutationList) => {
for (const mutation of mutationList) {
if (mutation.type === 'childList' && mutation.addedNodes.length > 0 && mutation.addedNodes[0].nodeName == "IFRAME") {
const frame = mutation.addedNodes[0];
const new_style_element = document.createElement("style");
new_style_element.textContent = generateDarkModeCSS();
new_style_element.id = "darkcss";
frame.contentDocument.body.classList.add("canvasrefined--darkmode--enabled");
frame.contentDocument.documentElement.prepend(new_style_element);
}
}
};
iframeObserver = new MutationObserver(callback);
iframeObserver.observe(document.querySelector('html'), { childList: true, subtree: true });
}
/*
Dashboard grades
*/
function insertGrades() {
if (options.dashboard_grades === true) {
grades.then(data => {
try {
let cards = document.querySelectorAll('.ic-DashboardCard');
if (cards.length === 0 || cards[0].querySelectorAll(".ic-DashboardCard__link").length === 0) return;
for (let i = 0; i < cards.length; i++) {
let course_id = parseInt(cards[i].querySelector(".ic-DashboardCard__link").href.split("courses/")[1]);
data.forEach(grade => {
if (course_id === grade.id) {
let gradepercent = grade.enrollments[0].has_grading_periods === true ? grade.enrollments[0].current_period_computed_current_score : grade.enrollments[0].computed_current_score;
//let gradepercent = grade.enrollments[0].computed_current_score;
let percent = (gradepercent || "--") + "%";
let gradeContainer = cards[i].querySelector(".canvasrefined-card-grade") || makeElement("a", cards[i].querySelector(".ic-DashboardCard__header"), { "className": "canvasrefined-card-grade", "textContent": percent });
if (options.grade_hover === true) {
gradeContainer.classList.add("canvasrefined-hover-only");
} else {
gradeContainer.classList.remove("canvasrefined-hover-only");
}
gradeContainer.setAttribute("href", `${domain}/courses/${course_id}/grades`);
gradeContainer.style.display = "block";
}
});
}
} catch (e) {
logError(e);
}
});
} else {
document.querySelectorAll('.canvasrefined-card-grade').forEach(grade => {
grade.style.display = "none";
});
}
}
/*
Card assignments
*/
function createCardAssignment(assignment) {
let assignmentContainer = document.createElement("div");
assignmentContainer.className = "canvasrefined-assignment-container";
let assignmentName = makeElement("a", assignmentContainer, { "className": "canvasrefined-assignment-link", "textContent": assignment.plannable.title, "href": assignment.html_url });
let assignmentDueAt = makeElement("span", assignmentContainer, { "className": "canvasrefined-assignment-dueat", "textContent": formatCardDue(new Date(assignment.plannable_date)) });
if (assignment.overdue === true) assignmentDueAt.classList.add("canvasrefined-assignment-overdue");
if (assignment?.submissions?.submitted === true) {
assignmentContainer.classList.add("canvasrefined-completed");
} else {
if (options.assignment_states[assignment.plannable_id]?.["crs"] === true) {
assignmentContainer.classList.add("canvasrefined-completed");
}
}
assignmentDueAt.addEventListener('mouseup', function () {
assignmentContainer.classList.toggle("canvasrefined-completed");
const status = assignmentContainer.classList.contains("canvasrefined-completed");
setAssignmentState(assignment.plannable_id, { "crs": status, "expire": assignment.plannable_date });
});
return assignmentContainer;
}
let cardAssignments;
/* Equal Height Cards: stretch each card's assignment area to match the tallest
one, using min-height so cards can still grow. */
let equalHeightResizeTimer = null;
function equalizeCardHeights() {
const cards = document.querySelectorAll(".ic-DashboardCard");
if (cards.length === 0) return;
const enabled = options.equal_height_cards === true && options.assignments_due === true;
// Clear prior min-height so we can measure fresh or fully reset.
cards.forEach(card => {
const area = card.querySelector(".canvasrefined-card-assignment");
if (area) area.style.removeProperty("min-height");
});
if (!enabled) return;
// Stretch each assignment area to the tallest one.
let maxHeight = 0;
cards.forEach(card => {
const area = card.querySelector(".canvasrefined-card-assignment");
if (area) maxHeight = Math.max(maxHeight, area.offsetHeight);
});
if (maxHeight > 0) {
cards.forEach(card => {
const area = card.querySelector(".canvasrefined-card-assignment");
if (area) area.style.minHeight = maxHeight + "px";
});
}
}
window.addEventListener("resize", () => {
if (equalHeightResizeTimer) clearTimeout(equalHeightResizeTimer);
equalHeightResizeTimer = setTimeout(equalizeCardHeights, 150);
});
function preloadAssignmentEls() {
return new Promise((resolve, reject) => {
let assignmentEls = {};
const now = new Date();
assignments.then((data) => {
data = combineAssignments(data);
data.forEach(item => {
let due = new Date(item.plannable_date);
item.overdue = now >= due;
let o = {
"submitted": item.submissions && item.submissions.submitted === true,
"override": item.planner_override && item.planner_override.marked_complete,
"type": item.plannable_type,
"due": due,
"el": createCardAssignment(item)
}
if (assignmentEls[item.course_id]) {
assignmentEls[item.course_id].push(o);
} else {
assignmentEls[item.course_id] = [o];
}
});
resolve(assignmentEls);
});
});
}
function loadCardAssignments() {
if (options.assignments_due !== true) {
document.querySelectorAll(".canvasrefined-card-assignment").forEach(card => {
card.style.display = "none";
});
equalizeCardHeights();
return;
}
setupCardAssignments();
cardAssignments.then(els => {
try {
let cards = document.querySelectorAll('.ic-DashboardCard');
if (cards.length === 0) return;
const now = new Date();
cards.forEach(card => {
let count = 0;
let link = card.querySelector(".ic-DashboardCard__link");
if (!link) return;
let course_id = link.href.split("courses/")[1];
let cardContainer = card.querySelector('.canvasrefined-card-container');
if (!cardContainer) return;
cardContainer.textContent = "";
if (cardContainer.parentElement) {
cardContainer.parentElement.style.display = "block";
}
if (els[course_id]) {
els[course_id].forEach(assignment => {
if (count >= options.num_assignments) return;
if (options.hide_completed_cards === true && assignment.submitted === true) return;
if ((options.card_overdues !== true && now >= assignment.due) || (options.card_overdues === true && assignment.submitted === true)) return;
if (assignment.type !== "assignment" && assignment.type !== "quiz" && assignment.type !== "discussion_topic") return;
if (assignment.override === true) return;
//assignment.el.querySelector(".canvasrefined-assignment-dueat").textContent = formatCardDue(assignment.due);
cardContainer.appendChild(assignment.el);
count++;
});
}
if (count === 0) {
let assignmentContainer = makeElement("div", cardContainer, { "className": "canvasrefined-assignment-container" });
let assignmentDivLink = makeElement("a", assignmentContainer, { "className": "canvasrefined-assignment-link", "textContent": "None" });
}
});
// Wait one frame so the browser lays out the freshly appended
// assignment rows before measuring/equalizing card heights.
requestAnimationFrame(equalizeCardHeights);
} catch (e) {
logError(e);
}
});
}
function setupCardAssignments() {
if (options.assignments_due !== true) return;
try {
let containersCount = document.querySelectorAll('.canvasrefined-card-container').length;
if (document.querySelectorAll('.ic-DashboardCard').length > 0 && containersCount > 0) return;
let cards = document.querySelectorAll('.ic-DashboardCard');
cards.forEach(card => {
let assignmentContainer = card.querySelector(".canvasrefined-card-assignment") || makeElement("div", card, { "className": "canvasrefined-card-assignment" });
let assignmentsDueHeader = card.querySelector(".canvasrefined-card-header-container") || makeElement("div", assignmentContainer, { "className": "canvasrefined-card-header-container" });
let assignmentsDueLabel = card.querySelector(".canvasrefined-card-header") || makeElement("h3", assignmentsDueHeader, { "className": "canvasrefined-card-header", "textContent": chrome.i18n.getMessage("due") });
let cardContainer = card.querySelector(".canvasrefined-card-container") || makeElement("div", assignmentContainer, { "className": "canvasrefined-card-container" });
let skeletonText = card.querySelector(".canvasrefined-skeleton-text") || makeElement("div", cardContainer, { "className": "canvasrefined-skeleton-text" });
});
} catch (e) {
logError(e);
}
}
/*
Card customization
*/
function getCardId(card) {
let link = card.querySelector(".ic-DashboardCard__link");
if (!link) return -1;
let href = link.href;
if (!href || !href.includes("courses/")) return -1;
let id = href.split("courses/")[1];
if (!id) return -1;
// no ~
if (!id.includes("~")) return id;
// has ~ but dashboard card method is used
if (options["custom_cards"][id]) return id;
// weird case, some canvases replace consecutive 0s with a ~ in the id
// but the number of 0s isn't consistent between schools
id = id.split("~");
let re = new RegExp(`${id[0]}0+${id[1]}`);
for (const c of Object.keys(options["custom_cards"])) {
if (c.match(re)) return c;
}
return -1;
}
function customizeCards(c = null) {
if (!options.custom_cards) return;
try {
let cards = c ? c : document.querySelectorAll('.ic-DashboardCard');
if (cards.length && cards.length > 0 && cards[0].querySelectorAll(".ic-DashboardCard__link").length === 0) return;
cards.forEach(card => {
const id = getCardId(card);
let cardOptions = options["custom_cards"][id] || null;
let cardOptions_2 = options["custom_cards_2"][id] || null;
if (!cardOptions) return;
// hide card
card.style.display = cardOptions.hidden === true ? "none" : "inline-block";
// card image
if (cardOptions.img === "none") {
let currentImg = card.querySelector(".ic-DashboardCard__header_image");
if (currentImg) {
card.querySelector(".ic-DashboardCard__header_hero").style.opacity = 1;
}
} else if (cardOptions.img !== "") {
let topColor = card.querySelector(".ic-DashboardCard__header_hero");
let container = card.querySelector(".ic-DashboardCard__header_image") || makeElement("div", card, { "className": "ic-DashboardCard__header_image" });
card.querySelector(".ic-DashboardCard__header").prepend(container);
container.appendChild(topColor);
container.style.backgroundImage = "url(\"" + cardOptions.img + "\")";
topColor.style.opacity = .5;
}
// card name
if (cardOptions.name !== "") {
card.querySelector(".ic-DashboardCard__header-title > span").textContent = cardOptions.name;
}
// card code
if (cardOptions.code !== "") {
card.querySelector(".ic-DashboardCard__header-subtitle").textContent = cardOptions.code;
}
// card links
let links = card.querySelectorAll(".ic-DashboardCard__action");
for (let i = links.length; i < 4; i++) {
makeElement("a", card.querySelector(".ic-DashboardCard__action-container"), { "className": "ic-DashboardCard__action" });
}
links = card.querySelectorAll(".ic-DashboardCard__action");
for (let i = 0; i < 4; i++) {
let img = links[i].querySelector(".canvasrefined-link-image") || makeElement("img", links[i], { "className": "canvasrefined-link-image" });
links[i].style.display = "inherit";
if (cardOptions_2.links[i].path === "none") {
links[i].style.display = "none";
} else if (cardOptions_2.links[i].is_default === false) {
links[i].href = cardOptions_2.links[i].path;
img.src = getCustomLinkImage(cardOptions_2.links[i].path);
if (links[i].querySelector(".ic-DashboardCard__action-layout")) links[i].querySelector(".ic-DashboardCard__action-layout").style.display = "none";
img.style.display = "block";
} else {
if (links[i].querySelector(".ic-DashboardCard__action-layout")) links[i].querySelector(".ic-DashboardCard__action-layout").style.display = "inherit";
img.style.display = "none";
}
img.addEventListener("error", () => {
img.src = "https://www.instructure.com/favicon.ico";
})
}
});
} catch (e) {
logError(e);
}
}
function getCustomLinkImage(path) {
if (path.includes("webassign.net")) {
return "https://www.cengage.com/favicon.ico";
} else if (path.includes("docs.google")) {
return "https://ssl.gstatic.com/docs/documents/images/kix-favicon7.ico";
} else {
let url = { "hostname": "instructure.com/" };
try {
url = new URL(path);
} catch (e) {
logError(e);
}
return "https://" + url.hostname + "/favicon.ico";;
}
}
/*
GPA calculator
*/
function calculateGPA2() {
let qualityPoints = 0, numCredits = 0, weightedQualityPoints = 0, cumulativePoints = 0, cumulativeCredits = 0;
document.querySelectorAll('.canvasrefined-gpa-course').forEach(course => {
const weight = course.querySelector('.canvasrefined-course-weight').value;
const credits = parseFloat(course.querySelector('.canvasrefined-course-credit').value);
const grade = parseFloat(course.querySelector('.canvasrefined-course-percent').value);
if (weight === "dnc" || !credits || !grade) return;
let letter = "--";
let gpa;
if (grade >= options.gpa_calc_bounds["A+"].cutoff) {
gpa = options.gpa_calc_bounds["A+"].gpa;
letter = "A+";
} else if (grade >= options.gpa_calc_bounds["A"].cutoff) {
gpa = options.gpa_calc_bounds["A"].gpa;
letter = "A";
} else if (grade >= options.gpa_calc_bounds["A-"].cutoff) {
gpa = options.gpa_calc_bounds["A-"].gpa;
letter = "A-";
} else if (grade >= options.gpa_calc_bounds["B+"].cutoff) {
gpa = options.gpa_calc_bounds["B+"].gpa;
letter = "B+";
} else if (grade >= options.gpa_calc_bounds["B"].cutoff) {
gpa = options.gpa_calc_bounds["B"].gpa;
letter = "B";
} else if (grade >= options.gpa_calc_bounds["B-"].cutoff) {
gpa = options.gpa_calc_bounds["B-"].gpa;
letter = "B-"
} else if (grade >= options.gpa_calc_bounds["C+"].cutoff) {
gpa = options.gpa_calc_bounds["C+"].gpa;
letter = "C+";
} else if (grade >= options.gpa_calc_bounds["C"].cutoff) {
gpa = options.gpa_calc_bounds["C"].gpa;
letter = "C";
} else if (grade >= options.gpa_calc_bounds["C-"].cutoff) {
gpa = options.gpa_calc_bounds["C-"].gpa;
letter = "C-";
} else if (grade >= options.gpa_calc_bounds["D+"].cutoff) {
gpa = options.gpa_calc_bounds["D+"].gpa;
letter = "D+";
} else if (grade >= options.gpa_calc_bounds["D"].cutoff) {
gpa = options.gpa_calc_bounds["D"].gpa;
letter = "D";
} else if (grade >= options.gpa_calc_bounds["D-"].cutoff) {
gpa = options.gpa_calc_bounds["D-"].gpa;
letter = "D-";
} else {
letter = "F";
gpa = options.gpa_calc_bounds["F"].gpa;
}
course.querySelector(".canvasrefined-gpa-letter-grade").textContent = letter;
let weightMultiplier = 0;
if (weight === "ap") {
weightMultiplier = 1;
} else if (weight === "honors") {
weightMultiplier = .5;
}
qualityPoints += gpa * credits;
weightedQualityPoints += (gpa + weightMultiplier) * credits;
numCredits += credits;
});
document.querySelector("#canvasrefined-gpa-unweighted").textContent = (qualityPoints / numCredits).toFixed(2);
document.querySelector("#canvasrefined-gpa-weighted").textContent = (weightedQualityPoints / numCredits).toFixed(2);
const cGPA = document.querySelector("#canvasrefined-cumulative-gpa");
const g = parseFloat(cGPA.querySelector(".canvasrefined-course-percent").value);
const c = parseInt(cGPA.querySelector(".canvasrefined-course-credit").value);
document.querySelector("#canvasrefined-gpa-cumulative").textContent = (((options.gpa_calc_weighted === true ? weightedQualityPoints : qualityPoints) + (g * c)) / (numCredits + c)).toFixed(2);
}
function changeGPASettings(course_id, update) {
calculateGPA2();
chrome.storage.sync.get(["custom_cards", "cumulative_gpa"], storage => {
if (course_id === "cumulative") {
chrome.storage.sync.set({ "cumulative_gpa": { ...storage["cumulative_gpa"], ...update } });
} else {
chrome.storage.sync.set({ "custom_cards": { ...storage["custom_cards"], [course_id]: { ...storage["custom_cards"][course_id], ...update } } });
}
});
}
function createGPACalcCourse(location, course) {
let customs;
if (course.access_restricted_by_date === true) {
return null;
} if (course.id === "cumulative") {
customs = options["cumulative_gpa"];
} else if (options.custom_cards && options.custom_cards[course.id]) {
customs = options.custom_cards[course.id];
} else {
return;
customs = { "name": course.name, "hidden": false, "weight": "regular", "credits": 1, "gr": null };
}
if (customs.hidden === true) return;
let courseContainer = makeElement("div", location, { "className": course.id === "cumulative" ? "canvasrefined-gpa-cumulative" : "canvasrefined-gpa-course", "innerHTML": '
' });
let courseName = makeElement("p", courseContainer, { "className": "canvasrefined-gpa-name", "textContent": customs.name === "" ? course.course_code : customs.name });
let changerContainer = makeElement("div", courseContainer, { "className": "canvasrefined-gpa-percent-container" });
let credits = makeElement("div", courseContainer, { "className": "canvasrefined-course-credits", "innerHTML": 'cr ' });
let creditsChanger = credits.querySelector(".canvasrefined-course-credit");
creditsChanger.value = customs.credits;
let changer = makeElement("input", changerContainer, { "className": "canvasrefined-course-percent" });
let percent = makeElement("span", changerContainer, { "className": "canvasrefined-course-percent-sign", "textContent": course.id === "cumulative" ? "/4" : "%" });
let courseGrade = course?.enrollments[0].has_grading_periods === true ? course.enrollments[0].current_period_computed_current_score : course.enrollments[0].computed_current_score;
if (customs["gr"] !== null) {
changer.value = customs["gr"];
} else if (courseGrade) {
changer.value = courseGrade;
} else {
changer.value = "--";
}
if (course.id !== "cumulative") {
let weightSelections = makeElement("form", courseContainer, { "className": "canvasrefined-course-weights" });
weightSelections.innerHTML = 'Do not count Regular/College Honors AP/IB ';
let weightChanger = weightSelections.querySelector(".canvasrefined-course-weight");
weightChanger.value = changer.value === "--" ? "dnc" : customs.weight;
weightChanger.addEventListener('change', () => changeGPASettings(course.id, { "weight": weightSelections.querySelector(".canvasrefined-course-weight").value }));
let useCustomGr = makeElement("input", courseContainer, { "className": "canvasrefined-course-customgr", "type": "checkbox", "checked": customs.gr !== null ? true : false });
let useCustomGrLabel = makeElement("span", courseContainer, { "className": "canvasrefined-course-customgr-label", "textContent": "Save custom grade" });
useCustomGr.addEventListener("input", () => {
if (options["custom_cards"][course.id]) {
if (options["custom_cards"][course.id]["gr"] !== undefined && options["custom_cards"][course.id]["gr"] !== null) {
changer.value = courseGrade;
changeGPASettings(course.id, { "gr": null });
} else {
changeGPASettings(course.id, { "gr": changer.value });
}
}
});
}
changer.addEventListener('input', (e) => {
if (course.id === "cumulative" || (options["custom_cards"][course.id]["gr"] !== undefined && options["custom_cards"][course.id]["gr"] !== null)) {
changeGPASettings(course.id, { "gr": e.target.value });
} else {
calculateGPA2();
}
});
credits.querySelector(".canvasrefined-course-credit").addEventListener('input', () => changeGPASettings(course.id, { "credits": credits.querySelector(".canvasrefined-course-credit").value }));
return courseContainer;
}
function setupGPACalc() {
if (current_page !== "/" && current_page !== "") return;
try {
grades?.then(result => {
const sortableContainer = document.querySelector(".ic-DashboardCard__box__container");
const dashboardContainer = sortableContainer || document.querySelector("#DashboardCard_Container");
if (!dashboardContainer) return;
let container2 = document.querySelector(".canvasrefined-gpa-card");
let container = document.querySelector(".canvasrefined-gpa");
const alreadyRendered = container2?.dataset?.canvasrefinedGpaRendered === "true" && container?.dataset?.canvasrefinedGpaRendered === "true";
if (!container2) {
container2 = document.createElement("div");
container2.className = "canvasrefined-gpa-card";
}
if (!container) {
container = document.createElement("div");
container.className = "canvasrefined-gpa";
}
container2.style.display = options.gpa_calc === true ? "inline-block" : "none";
if (!alreadyRendered) {
container2.innerHTML = ``;
let editBtn = makeElement("button", container2, { "className": "canvasrefined-gpa-edit-btn", "textContent": "Edit Calculator" });
container.innerHTML = '';
if (options.gpa_calc_prepend === true) {
dashboardContainer.prepend(container2);
dashboardContainer.prepend(container);
} else {
dashboardContainer.appendChild(container2);
dashboardContainer.appendChild(container);
}
let location = document.querySelector(".canvasrefined-gpa-courses");
if (!location) return;
let cumulative = createGPACalcCourse(location, { "id": "cumulative", "enrollments": [{ "has_grading_periods": true, "current_period_computed_current_score": 0 }] });
cumulative.id = "canvasrefined-cumulative-gpa";
result.forEach(course => createGPACalcCourse(location, course));
container.style.display = "none";
editBtn.addEventListener("click", () => {
if (container.style.display === "none") {
container.style.display = "inline-block";
editBtn.textContent = "Close Calculator";
} else {
container.style.display = "none";
editBtn.textContent = "Edit Calculator";
}
});
container2.dataset.canvasrefinedGpaRendered = "true";
container.dataset.canvasrefinedGpaRendered = "true";
} else {
const weighted = container2.querySelector("#canvasrefined-gpa-weighted")?.parentElement;
const cumulative = container2.querySelector("#canvasrefined-gpa-cumulative")?.parentElement;
if (weighted) weighted.style.display = options.gpa_calc_weighted ? "block" : "none";
if (cumulative) cumulative.style.display = options.gpa_calc_cumulative ? "block" : "none";
const shouldPrepend = options.gpa_calc_prepend === true;
if (shouldPrepend) {
if (dashboardContainer.children[0] !== container || dashboardContainer.children[1] !== container2) {
dashboardContainer.insertBefore(container, dashboardContainer.firstChild);
dashboardContainer.insertBefore(container2, container.nextSibling);
}
} else {
if (dashboardContainer.lastElementChild !== container || container2.nextElementSibling !== container) {
dashboardContainer.appendChild(container2);
dashboardContainer.appendChild(container);
}
}
}
try {
if (sortableContainer && window.jQuery && window.jQuery.fn && window.jQuery.fn.sortable) {
window.jQuery(sortableContainer).sortable('refresh');
}
} catch (e) {}
calculateGPA2();
});
} catch (e) {
logError(e);
}
}
/*
Dashboard notes
*/
let dashboardNotesTimer;
function delayDashboardNotesStorage(text) {
clearTimeout(dashboardNotesTimer);
dashboardNotesTimer = setTimeout(() => {
chrome.storage.sync.set({ dashboard_notes_text: text });
}, 250);
}
/* Fallback Markdown renderer used only if js/markdown.js failed to load. */
function crRenderMarkdownFallback(src) {
if (src == null) return "";
const escapeHtml = (s) => String(s).replace(/&/g, "&").replace(/ /g, ">").replace(/"/g, """).replace(/'/g, "'");
const sanitizeUrl = (u) => {
const v = String(u == null ? "" : u).trim();
if (!v) return "";
if (/^(https?:|mailto:|ftp:|tel:)/i.test(v)) return v;
if (/^(javascript:|vbscript:|file:|data:)/i.test(v)) return "#";
if (/^[#/?]/.test(v)) return v;
if (/^[a-z][a-z0-9+.-]*:/i.test(v)) return "#";
return v;
};
let text = String(src).replace(/\r\n/g, "\n").replace(/\r/g, "\n");
const out = [];
const lines = text.split("\n");
let i = 0;
let taskSeq = 0; // ordinal of rendered task items, stable across code-block stashing
const inline = (t) => {
let h = escapeHtml(t);
h = h.replace(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)/g, (m, alt, url, title) =>
` `);
h = h.replace(/\[([^\]]+)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)/g, (m, txt, url, title) =>
`${txt} `);
h = h.replace(/`([^`\n]+)`/g, (m, c) => `${c}`);
h = h.replace(/\*\*([^*]+?)\*\*/g, "$1 ");
h = h.replace(/~~([^~]+?)~~/g, "$1");
h = h.replace(/(^|[^*])\*([^*]+?)\*(?!\*)/g, "$1$2 ");
return h;
};
while (i < lines.length) {
const line = lines[i];
if (/^\s*$/.test(line)) { i++; continue; }
const h = line.match(/^(#{1,6})\s+(.*)$/);
if (h) { const l = h[1].length; out.push(`${inline(h[2])} `); i++; continue; }
if (/^\s*(-{3,}|\*{3,}|_{3,})\s*$/.test(line)) { out.push(" "); i++; continue; }
if (/^>\s?/.test(line)) {
const q = []; while (i < lines.length && /^>\s?/.test(lines[i])) { q.push(inline(lines[i].replace(/^>\s?/, ""))); i++; }
out.push(`${q.join(" ")} `); continue;
}
if (/^\s*[-*+]\s+/.test(line)) {
const items = []; while (i < lines.length) { const m = lines[i].match(/^\s*[-*+]\s+(.*)$/); if (!m) break; const tk = m[1].match(/^\[([ xX])\]\s+(.*)$/); if (tk) { items.push(` ${inline(tk[2])} `); } else { items.push(`${inline(m[1])} `); } i++; } out.push(``); continue;
}
if (/^\s*\d+\.\s+/.test(line)) {
const items = []; while (i < lines.length) { const m = lines[i].match(/^\s*\d+\.\s+(.*)$/); if (!m) break; items.push(`${inline(m[1])} `); i++; } out.push(`${items.join("")} `); continue;
}
const para = [line]; i++; while (i < lines.length && !/^\s*$/.test(lines[i]) && !/^(#{1,6}\s|\s*[-*+]\s|\s*\d+\.\s|>)/.test(lines[i])) { para.push(lines[i]); i++; }
out.push(`${para.map(inline).join(" ")}
`);
}
return out.join("\n");
}
function renderDashboardNotesPreview(preview, text) {
if (!preview) return;
// Skip identical re-renders to avoid a self-sustaining observer loop.
if (preview._crLastText === text) return;
preview._crLastText = text;
const renderer = (typeof window.renderMarkdown === "function") ? window.renderMarkdown : crRenderMarkdownFallback;
preview.innerHTML = renderer(text);
}
/* Insert/wrap Markdown formatting at the selection and fire an input event. */
function notesApplyFormat(editor, action) {
if (!editor) return;
const start = editor.selectionStart;
const end = editor.selectionEnd;
const value = editor.value;
const fire = () => {
editor.dispatchEvent(new Event("input", { bubbles: true }));
editor.focus();
};
const wrap = (before, after, placeholder) => {
const had = end > start;
const sel = had ? value.slice(start, end) : (placeholder || "");
editor.setRangeText(before + sel + after, start, end, "end");
editor.selectionStart = start + before.length;
editor.selectionEnd = start + before.length + sel.length;
fire();
};
// Range covering every line touched by the selection.
const lineBlock = () => {
const ls = start === 0 ? 0 : value.lastIndexOf("\n", start - 1) + 1;
let le = value.indexOf("\n", end);
if (le === -1) le = value.length;
return { ls, le, block: value.slice(ls, le) };
};
const togglePrefix = (prefix) => {
const { ls, le, block } = lineBlock();
const lines = block.split("\n");
const allHave = lines.every((l) => l.startsWith(prefix));
const newBlock = lines.map((l) => allHave ? l.slice(prefix.length) : prefix + l).join("\n");
editor.setRangeText(newBlock, ls, le, "end");
editor.selectionStart = ls;
editor.selectionEnd = ls + newBlock.length;
fire();
};
switch (action) {
case "bold": wrap("**", "**", "bold"); break;
case "italic": wrap("*", "*", "italic"); break;
case "strike": wrap("~~", "~~", "strikethrough"); break;
case "code": wrap("`", "`", "code"); break;
case "h1": togglePrefix("# "); break;
case "h2": togglePrefix("## "); break;
case "list": togglePrefix("- "); break;
case "numbered": togglePrefix("1. "); break;
case "quote": togglePrefix("> "); break;
case "task": {
const { ls, le, block } = lineBlock();
const marker = block.match(/^-\s*\[([ xX])\]\s+/);
const bullet = block.match(/^[-*+]\s+/);
let newBlock;
if (marker) {
newBlock = block.slice(marker[0].length);
} else if (bullet) {
newBlock = "- [ ] " + block.slice(bullet[0].length);
} else {
newBlock = "- [ ] " + block;
}
editor.setRangeText(newBlock, ls, le, "end");
editor.selectionStart = ls;
editor.selectionEnd = ls + newBlock.length;
fire();
break;
}
case "link": {
const had = end > start;
const sel = had ? value.slice(start, end) : "text";
editor.setRangeText("[" + sel + "](url)", start, end, "end");
const urlStart = start + 1 + sel.length + 2; // after "]("
editor.selectionStart = urlStart;
editor.selectionEnd = urlStart + 3; // select "url"
fire();
break;
}
case "hr": {
const lead = start > 0 && value[start - 1] !== "\n" ? "\n" : "";
editor.setRangeText(lead + "---\n", start, start, "end");
fire();
break;
}
case "codeblock": {
const had = end > start;
const sel = had ? value.slice(start, end) : "code";
const lead = start > 0 && value[start - 1] !== "\n" ? "\n" : "";
editor.setRangeText(lead + "```\n" + sel + "\n```", start, end, "end");
fire();
break;
}
}
}
/* Toggle the Nth rendered Markdown task checkbox and re-render, using a task
ordinal that skips fenced code blocks. */
function toggleDashboardNoteTask(editor, taskIndex) {
if (!editor) return;
const value = editor.value || "";
const lines = value.split("\n");
let inFence = false;
let count = 0;
for (let idx = 0; idx < lines.length; idx++) {
const line = lines[idx];
if (/^\s*```/.test(line)) { inFence = !inFence; continue; }
if (inFence) continue;
const m = line.match(/^(\s*[-*+]\s+)\[([ xX])\](\s+)/);
if (!m) continue;
if (count === taskIndex) {
const newMark = m[2] === " " ? "x" : " ";
lines[idx] = m[1] + "[" + newMark + "]" + m[3] + line.slice(m[0].length);
editor.value = lines.join("\n");
editor.dispatchEvent(new Event("input", { bubbles: true }));
const notes = editor.closest(".canvasrefined-dashboard-notes");
const rendered = notes ? notes.querySelector(".canvasrefined-notes-rendered") : null;
if (rendered) renderDashboardNotesPreview(rendered, editor.value);
return;
}
count++;
}
}
const DASHBOARD_NOTES_HTML = `
B
I
S
</>
H1
H2
•
1.
☐
“
Link
—
```
`;
function wireDashboardNotes(notes) {
const editor = notes.querySelector(".canvasrefined-notes-editor");
const rendered = notes.querySelector(".canvasrefined-notes-rendered");
editor.value = options.dashboard_notes_text || "";
renderDashboardNotesPreview(rendered, editor.value);
const enterEdit = () => {
if (notes.classList.contains("is-editing")) return;
notes.classList.add("is-editing");
editor.focus();
const len = editor.value.length;
editor.setSelectionRange(len, len);
};
const exitEdit = () => {
notes.classList.remove("is-editing");
renderDashboardNotesPreview(rendered, editor.value);
};
rendered.addEventListener("click", (e) => {
// Clicking a checkbox toggles the task; clicking the text edits.
const t = e.target;
if (t && t.nodeType === 1 && t.tagName === "INPUT" && t.type === "checkbox" && t.closest && t.closest("li.cr-task")) {
e.stopPropagation();
const li = t.closest("li.cr-task");
const taskIndex = parseInt(li.dataset.crTask, 10);
if (!Number.isNaN(taskIndex)) toggleDashboardNoteTask(editor, taskIndex);
return;
}
enterEdit();
});
rendered.addEventListener("focus", enterEdit);
editor.addEventListener("blur", exitEdit);
editor.addEventListener("input", function () {
options.dashboard_notes_text = this.value;
delayDashboardNotesStorage(this.value);
});
// preventDefault on the toolbar keeps focus in the editor across misclicks.
const toolbar = notes.querySelector(".canvasrefined-notes-toolbar");
if (toolbar) toolbar.addEventListener("mousedown", e => e.preventDefault());
notes.querySelectorAll(".cr-fmt").forEach(btn => {
btn.addEventListener("click", () => {
if (!notes.classList.contains("is-editing")) enterEdit();
notesApplyFormat(editor, btn.dataset.action);
});
});
editor.addEventListener("keydown", function (e) {
if (e.key === "Escape") { e.preventDefault(); editor.blur(); return; } // Esc: render
const mod = e.ctrlKey || e.metaKey;
if (!mod) return;
const k = e.key.toLowerCase();
if (k === "b") { e.preventDefault(); notesApplyFormat(editor, "bold"); }
else if (k === "i") { e.preventDefault(); notesApplyFormat(editor, "italic"); }
else if (k === "enter") { e.preventDefault(); editor.blur(); } // Ctrl/Cmd+Enter: render
});
}
function loadDashboardNotes() {
const container = document.querySelector("#DashboardCard_Container");
if (options.dashboard_notes === true) {
if (!container) return;
let notes = document.querySelector('.canvasrefined-dashboard-notes');
// Rebuild older (split edit/preview) markup into the new single-surface layout.
if (notes && !notes.querySelector(".canvasrefined-notes-surface")) {
notes.remove();
notes = null;
}
if (!notes) {
notes = document.createElement("div");
notes.classList.add("canvasrefined-dashboard-notes");
notes.innerHTML = DASHBOARD_NOTES_HTML;
// Mount as a full-width sibling above the card grid. Prepending inside the
// DashboardCard_Container makes the notes a masonry/grid cell (narrow & broken).
const parent = container.parentNode;
if (parent) parent.insertBefore(notes, container);
else container.prepend(notes);
wireDashboardNotes(notes);
} else {
notes.style.display = "";
const editor = notes.querySelector(".canvasrefined-notes-editor");
const rendered = notes.querySelector(".canvasrefined-notes-rendered");
// Only sync and render in view mode; the textarea is the source of truth while editing.
if (!notes.classList.contains("is-editing")) {
if (editor && editor.value !== (options.dashboard_notes_text || "")) {
editor.value = options.dashboard_notes_text || "";
}
renderDashboardNotesPreview(rendered, editor ? editor.value : "");
}
}
} else {
let notes = document.querySelector('.canvasrefined-dashboard-notes');
if (notes) notes.style.display = "none";
}
}
/*
Custom font
*/
function loadCustomFont() {
let link = document.querySelector("#custom_font_link");
let style = document.querySelector("#custom_font");
let load = () => {
if (options.custom_font.link !== "") {
document.head.appendChild(style);
link.href = `https://fonts.googleapis.com/css2?family=${options.custom_font.link}&display=swap`;
link.rel = "stylesheet";
document.head.appendChild(link);
}
style.textContent = options.custom_font.link === "" ? "" : `*, input, a, button, h1, h2, h3, h4, h5, h6, p, span {font-family: ${options.custom_font.family}!important}`;
}
let createEls = () => {
link = document.createElement("link");
link.id = "custom_font_link";
style = document.createElement("style");
style.id = "custom_font";
load();
}
if (link && style) {
load();
} else if (options.custom_font.link !== "") {
if (document.readyState !== 'loading') {
createEls();
} else {
document.addEventListener("DOMContentLoaded", () => {
createEls();
});
}
}
}
/*
Smaller features
*/
function applyAestheticChanges() {
let style = document.querySelector("#canvasrefined-aesthetics") || document.createElement('style');
style.id = "canvasrefined-aesthetics";
style.textContent = "";
if (options.condensed_cards === true) style.textContent += ".ic-DashboardCard__header_hero {height:60px!important}.ic-DashboardCard__header-subtitle, .ic-DashboardCard__header-term{display:none}";
if (options.remlogo === true) style.textContent += ".ic-app-header__logomark-container{display:none}";
if (options.disable_color_overlay === true) style.textContent += ".ic-DashboardCard__header_hero{opacity: 0!important} .ic-DashboardCard__header-button-bg{opacity: 1!important}";
if (options.hide_feedback === true) style.textContent += ".recent_feedback {display: none}";
if (options.full_width === true) style.textContent += "#wrapper,.ic-Layout-wrapper{max-width:100%!important}";
if (options.center_cards === true) style.textContent += ".ic-DashboardCard__box__container{display:flex!important;flex-wrap:wrap!important;justify-content:center!important;align-items:flex-start!important}";
if (options.customCardStyles === true) {
if (options.imageSize !== undefined && options.imageSize !== 100) style.textContent += `.ic-DashboardCard__header_image {transform: scale(${options.imageSize / 100})!important; }`;
if (options.cardRoundness !== undefined && options.cardRoundness !== 5) style.textContent += `.ic-DashboardCard {border-radius: ${options.cardRoundness}px!important;}`;
if (options.cardSpacing !== undefined && options.cardSpacing !== 0) style.textContent += `.ic-DashboardCard {margin-right: ${options.cardSpacing / 2}px!important; margin-bottom: ${options.cardSpacing / 2}px!important;}`;
if (options.cardWidth !== undefined && options.cardWidth !== 262) style.textContent += `.ic-DashboardCard {width: ${options.cardWidth}px!important;}`;
if (options.cardHeight !== undefined && options.cardHeight !== 250) style.textContent += `.ic-DashboardCard {height: ${options.cardHeight}px!important;}`;
}
style.textContent += ".ic-app-nav-toggle-and-crumbs{display:none!important}";
if (options.custom_styles !== "") style.textContent += options.custom_styles;
document.documentElement.appendChild(style);
}
function changeGradientCards() {
if (options.gradient_cards === true) {
let cardheads = document.querySelectorAll('.ic-DashboardCard__header_hero');
// Create the style once; re-appending triggers the MutationObserver and re-runs this function.
let cardcss = document.querySelector("#gradientcss");
if (!cardcss) {
cardcss = document.createElement('style');
cardcss.id = "gradientcss";
document.documentElement.appendChild(cardcss);
}
// Build CSS into a string and only touch the DOM if it changed.
let css = "";
for (let i = 0; i < cardheads.length; i++) {
let colorone = cardheads[i].style.backgroundColor.split(',');
let [r, g, b] = [parseInt(colorone[0].split('(')[1]), parseInt(colorone[1]), parseInt(colorone[2])];
let [h, s, l] = [rgbToHsl(r, g, b)[0], rgbToHsl(r, g, b)[1], rgbToHsl(r, g, b)[2]];
let degree = ((h % 60) / 60) >= .66 ? 30 : ((h % 60) / 60) <= .33 ? -30 : 15;
let newh = h > 300 ? (360 - (h + 65)) + (65 + degree) : h + 65 + degree;
css += ".ic-DashboardCard:nth-of-type(" + (i + 1) + ") .ic-DashboardCard__header_hero{background: linear-gradient(115deg, hsl(" + h + "deg," + s + "%," + l + "%) 5%, hsl(" + newh + "deg," + s + "%," + l + "%) 100%)!important}";
}
if (cardcss.textContent !== css) {
cardcss.textContent = css;
}
} else {
let cardcss = document.querySelector("#gradientcss");
if (cardcss && cardcss.textContent !== "") {
cardcss.textContent = "";
}
}
}
function showUpdateMsg() {
// dont run if not on dashboard
const el = document.getElementById("announcementWrapper");
if (!el) return;
// option off or div already created
let div = document.getElementById("canvasrefined-update-msg");
if (options.show_updates !== true || options.update_msg === "") {
if (div) div.style.display = "none";
return;
} else if (div) {
div.style.display = "flex";
return;
}
// first creation
div = makeElement("div", el, { "id": "canvasrefined-update-msg" });
makeElement("p", div, { "textContent": options.update_msg });
const close = makeElement("button", div, { "id": "canvasrefined-update-close", "textContent": "Close" });
close.addEventListener("click", () => {
readUpdate();
div.remove();
});
}
function readUpdate() {
chrome.storage.sync.set({ "update_msg": "" });
}
/*
Other functions
*/
function combineAssignments(data) {
let combined = data;
try {
options.custom_assignments_overflow.forEach(overflow => {
combined = combined.concat(options[overflow]);
});
} catch (e) {
logError(e);
}
return combined.sort((a, b) => new Date(a.plannable_date).getTime() - new Date(b.plannable_date).getTime());
}
function cleanCustomAssignments() {
chrome.storage.sync.get("custom_assignments_overflow", overflows => {
chrome.storage.sync.get(overflows["custom_assignments_overflow"], storage => {
const now = new Date();
overflows["custom_assignments_overflow"].forEach(overflow => {
let changed = false;
for (let i = 0; i < storage[overflow].length; i++) {
let assignmentDate = new Date(storage[overflow][i].plannable_date);
if (!assignmentDate.getTime() || assignmentDate < now) {
storage[overflow].splice(i, 1);
changed = true;
}
}
if (changed) chrome.storage.sync.set({ [overflow]: storage[overflow] });
});
});
});
}
function setupCustomURL() {
//let test = getData(`${domain}/api/v1/dashboard/dashboard_cards?include[]=concluded&include[]=term`);
let test = getData(`${domain}/api/v1/courses?${/*enrollment_state=active&*/""}per_page=100`);
test.then(res => {
if (res.length) {
getCards(res).then(() => {
setTimeout(() => {
console.log("Canvas Refined - setting custom domain to " + domain);
chrome.storage.sync.set({ custom_domain: [domain] }).then(location.reload());
}, 100);
});
} else {
console.log("Canvas Refined - this url doesn't seem to be a canvas url (1)");
}
}).catch(err => {
console.log("Canvas Refined - this url doesn't seem to be a canvas url (2)");
});
}
function getGrades() {
if (options.gpa_calc === true || options.dashboard_grades === true) {
grades = getData(`${domain}/api/v1/courses?${/*enrollment_state=active&*/""}include[]=concluded&include[]=total_scores&include[]=computed_current_score&include[]=current_grading_period_scores&per_page=100`);
}
}
function getColors() {
if (options.tab_icons || options.better_todo || options.better_sidebar) {
return getData(`${domain}/api/v1/users/self/colors`).then(data => {
let cards = options.custom_cards_3;
Object.keys(cards).forEach(key => {
cards[key] = { ...cards[key], "color": data["custom_colors"]["course_" + key] ? data["custom_colors"]["course_" + key] : null };
});
chrome.storage.sync.set({ "custom_cards_3": cards });
return cards;
});
}
}
function changeFavicon() {
if (options.tab_icons !== true) return;
let match = current_page.match(/courses\/(?\d*)/);
if (match && match.groups.id && options.custom_cards_3[match.groups.id]?.color) {
document.querySelector('link[rel="icon"').href = `data:image/svg+xml;utf8, `;
}
}
function getAssignments() {
if (options.assignments_due === true || options.better_todo === true) {
let weekAgo = new Date(new Date() - 604800000);
//let weekAgo = new Date(new Date() - (604800000 * 10));
assignments = getData(`${domain}/api/v1/planner/items?start_date=${weekAgo.toISOString()}&per_page=75`);
cardAssignments = preloadAssignmentEls();
}
}
function getApiData() {
if (current_page === "/" || current_page === "" || options.better_todo || options.better_sidebar) {
getAssignments();
getGrades();
getColors();
}
}
function makeElement(element, location, options, prepend = false) {
let creation = document.createElement(element);
Object.keys(options).forEach(key => {
creation[key] = options[key];
});
if (prepend) {
location.insertBefore(creation, location.firstChild);
} else {
location.appendChild(creation);
}
return creation
}
async function getData(url) {
let response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
}
});
let data = await response.json();
// Deep-clone via JSON to unwrap Firefox Xray objects so nested props are mutable.
try {
return JSON.parse(JSON.stringify(data));
} catch (_) {
return data;
}
}
function rgbToHex(rgb) {
try {
let pat = /^rgb\(\s*(-?\d+)\s*,\s*(-?\d+)\s*,\s*(-?\d+)\s*\)$/;
let exec = pat.exec(rgb);
return "#" + parseInt(exec[1]).toString(16).padStart(2, "0") + parseInt(exec[2]).toString(16).padStart(2, "0") + parseInt(exec[3]).toString(16).padStart(2, "0");
} catch (e) {
console.warn(e);
}
}
function rgbToHsl(r, g, b) {
r /= 255, g /= 255, b /= 255;
var max = Math.max(r, g, b),
min = Math.min(r, g, b);
var h, s, l = (max + min) / 2;
if (max == min) {
h = s = 0;
} else {
var d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r:
h = (g - b) / d + (g < b ? 6 : 0); break;
case g:
h = (b - r) / d + 2; break;
case b:
h = (r - g) / d + 4; break;
}
h /= 6;
}
return [h * 360, s * 100, l * 100];
}
function getRelativeDate(date, short = false) {
let now = new Date();
let timeSince = (now.getTime() - date.getTime()) / 60000;
let time = "min";
timeSince = Math.abs(timeSince);
if (timeSince >= 60) {
timeSince /= 60;
time = short ? "h" : "hour";
if (timeSince >= 24) {
timeSince /= 24;
time = short ? "d" : "day";
if (timeSince >= 7) {
timeSince /= 7;
time = short ? "w" : "week";
}
}
}
timeSince = Math.round(timeSince);
let relative = timeSince + (short ? "" : " ") + time + (timeSince > 1 && !short ? "s" : "");
return { time: relative, ms: now.getTime() - date.getTime() };
}
const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
function formatTodoDate(date, submissions, hr24) {
let { time, ms } = getRelativeDate(date);
let fromNow = ms < 0 ? "in " + time : time + " ago";
let dueSoon = false;
if (submissions && submissions.submitted === false && ms >= -21600000) {
dueSoon = true;
}
return { "dueSoon": dueSoon, "date": months[date.getMonth()] + " " + date.getDate() + " at " + (date.getHours() - (hr24 ? "" : date.getHours() > 12 ? 12 : 0)) + ":" + (date.getMinutes() < 10 ? "0" : "") + date.getMinutes() + (hr24 ? "" : date.getHours() >= 12 ? "pm" : "am") + " (" + fromNow + ")" };
}
function formatCardDue(date) {
let due = new Date(date);
if (options.relative_dues === true) {
let relative = getRelativeDate(due, true);
return relative.ms > 0 ? relative.time + " ago" : "in " + relative.time;
}
return options.assignment_date_format ? (due.getDate()) + "/" + (due.getMonth() + 1) : (due.getMonth() + 1) + "/" + (due.getDate());
}
function logError(e) {
chrome.storage.local.get("errors", storage => {
if (storage.errors.length > 20) {
storage["errors"] = [];
}
chrome.storage.local.set({ "errors": storage["errors"].concat(e.stack) });
console.log(e.stack);
console.log(storage["errors"].concat(e.stack));
})
}
const CSRFtoken = function () {
return decodeURIComponent((document.cookie.match('(^|;) *_csrf_token=([^;]*)') || '')[2])
}