diff --git a/_locales/en/messages.json b/_locales/en/messages.json
index b8239fe..99c03d3 100644
--- a/_locales/en/messages.json
+++ b/_locales/en/messages.json
@@ -24,7 +24,19 @@
"message": "Dark Mode"
},
"enter_url": {
- "message": "Your Canvas url:"
+ "message": "Your Canvas URLs:"
+ },
+ "add_canvas": {
+ "message": "+ Add canvas"
+ },
+ "no_auto_detect": {
+ "message": "Don't auto detect canvases"
+ },
+ "add_to_list": {
+ "message": "Add to list"
+ },
+ "no_thanks": {
+ "message": "No thanks"
},
"card_grades": {
"message": "Card Grades"
@@ -326,4 +338,4 @@
"grade_analytics_zones": {
"message": "Colored grade zones"
}
-}
\ No newline at end of file
+}
diff --git a/css/content.css b/css/content.css
index 7abc507..9e43bdd 100644
--- a/css/content.css
+++ b/css/content.css
@@ -158,6 +158,20 @@
.canvasrefined-todo-item-header {max-height:80px;overflow:hidden;}
.canvasrefined-todo-svg {fill:#2d3b45!important}
#canvasrefined-update-msg {margin-top: 5px; display: flex; justify-content: space-between; font-size:15px;background: linear-gradient(115deg, rgb(255, 53, 88, .9), rgb(255, 131, 73, .9), rgb(255, 103, 187, .9)); color: #fff; border-radius:4px; padding: 20px;white-space: pre-line;}
+
+/* Detected-canvas prompt — centered modal with backdrop so it can't be missed */
+#canvasrefined-domain-prompt-backdrop {position: fixed!important;inset: 0!important;width: 100%!important;height: 100%!important;background: rgb(0 0 0 / 60%)!important;z-index: 2147483647!important;display: flex!important;align-items: center!important;justify-content: center!important;}
+#canvasrefined-domain-prompt {background: #fff!important;border-radius: 14px!important;padding: 24px 28px!important;max-width: 460px!important;width: 90%!important;box-sizing: border-box!important;box-shadow: 0 12px 48px rgb(0 0 0 / 45%)!important;animation: .25s ease canvasrefined-pop;}
+@keyframes canvasrefined-pop {0% {transform: scale(.9);opacity: 0} 100% {transform: scale(1);opacity: 1}}
+#canvasrefined-domain-prompt h3 {margin: 0 0 10px!important;font-size: 19px!important;font-weight: 700!important;color: #2d3b45!important;font-family: inherit!important;}
+#canvasrefined-domain-prompt p {margin: 0 0 18px!important;font-size: 14px!important;color: #4a5460!important;line-height: 1.5!important;font-family: inherit!important;}
+.canvasrefined-domain-host {font-weight: 700!important;color: #2d3b45!important;word-break: break-all!important;}
+.canvasrefined-domain-prompt-actions {display: flex!important;gap: 10px!important;}
+.canvasrefined-domain-prompt-actions button {flex: 1!important;border: none!important;border-radius: 8px!important;padding: 11px 16px!important;font-size: 14px!important;font-weight: 600!important;cursor: pointer!important;font-family: inherit!important;}
+.canvasrefined-domain-yes {background: #2d3b45!important;color: #fff!important;}
+.canvasrefined-domain-yes:hover {background: #3b4d61!important;}
+.canvasrefined-domain-no {background: #eceef0!important;color: #2d3b45!important;}
+.canvasrefined-domain-no:hover {background: #dfe2e5!important;}
#canvasrefined-update-msg p {margin: 0;color:#fff!important;}
#canvasrefined-update-close { background: #2f2f2f59; padding: 3px 6px; border-radius: 3px; height:100%; border:none; color: #fff!important;}
#canvasrefined-reminders {animation: .4s ease slide; margin:20px!important;position: fixed!important; bottom: 0!important;right: 0;z-index:10000000000!important;flex-direction: column!important; gap:10px!important;}
diff --git a/css/popup.css b/css/popup.css
index de94fb4..952a86d 100644
--- a/css/popup.css
+++ b/css/popup.css
@@ -34,6 +34,15 @@ a.option-container:hover {background: #333}
.options-left {margin-right: 8px;}
.options-left, .options-right {width: 50%;}
.customDomain {font-weight:600}
+
+/* Canvas URL list — one text box per school */
+#custom-domain-list {display: flex;flex-direction: column;gap: 6px;margin-top: 8px}
+.domain-row {display: flex;gap: 6px;align-items: center}
+.domain-row input {flex: 1;min-width: 0;box-sizing: border-box;font-size: 12px;background: var(--inputbg);border: none;padding: 8px;color: #e2e2e2;border-radius: 7px;font-family: inherit}
+.domain-row .domain-remove {flex: none;width: 24px;height: 31px;padding: 0;border: none;border-radius: 6px;background: #2f2f2f;color: #a7a7a7;font-size: 13px;line-height: 1;cursor: pointer}
+.domain-row .domain-remove:hover {background: #3d3d3d;color: #fff}
+#add-domain-btn {margin-top: 8px;font-size: 12px;padding: 5px 12px}
+.domain-toggles {margin-top: 10px;display: flex;flex-direction: column}
.changer {margin:0;margin-bottom: 0px;width:100%}
.option-background {color: #fff}
.color-changer { font-family: inherit;font-size: 20px;background: none;border: none;color: #000;height: 30px;width:100%;padding:10px 0px 10px 10px;}
diff --git a/html/popup.html b/html/popup.html
index 78ba84f..b83a6eb 100644
--- a/html/popup.html
+++ b/html/popup.html
@@ -440,9 +440,15 @@
-
Your Canvas URL:
-
(Enter multiple with a comma)
-
+
Your Canvas URLs:
+
+
+
+
+
+
+
+
diff --git a/js/background.js b/js/background.js
index 6e86982..9af703a 100644
--- a/js/background.js
+++ b/js/background.js
@@ -32,6 +32,8 @@ chrome.runtime.onInstalled.addListener(function () {
"auto_dark_end": { "hour": "08", "minute": "00" },
"num_assignments": 4,
"custom_domain": [""],
+ "custom_domain_denied": [],
+ "auto_detect_disabled": false,
"assignments_done": [],
"dashboard_grades": true,
"assignment_date_format": false,
diff --git a/js/content.js b/js/content.js
index 433cb90..f46196a 100644
--- a/js/content.js
+++ b/js/content.js
@@ -757,31 +757,131 @@ function showExampleReminder() {
}
+// Canvas detection: if the domain is in the user's list, start normally.
+// Otherwise probe it (unless detection was declined for it); an empty list
+// auto-adds the first canvas, and with canvases already in the list the user
+// is asked once per domain.
isDomainCanvasPage();
function isDomainCanvasPage() {
- chrome.storage.sync.get(['custom_domain', 'dark_mode', 'dark_preset', 'device_dark', 'remind'], result => {
+ chrome.storage.sync.get(['custom_domain', 'custom_domain_denied', 'auto_detect_disabled', '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();
+ const domains = (result.custom_domain || []).filter(d => d && d !== "");
+ const denied = result.custom_domain_denied || [];
+ if (domains.some(d => domain.includes(d))) {
+ startExtension();
+ return;
}
+
+ // not in the list — no prompting/probing if the user turned auto detect
+ // off, or already said no here; just run reminders
+ if (result.auto_detect_disabled === true || denied.some(d => domain.includes(d))) {
+ startReminderMode();
+ return;
+ }
+
+ detectCanvasPage().then(({ isCanvas, courses }) => {
+ if (!isCanvas) {
+ startReminderMode();
+ return;
+ }
+ if (domains.length === 0) {
+ // first canvas ever — add it automatically (original first-run
+ // behavior) and reload so the extension starts
+ addCanvasDomain(courses).then(() => location.reload());
+ } else {
+ // at least one canvas already in the list: ask the first time
+ // this page is seen. Saying no means we never ask again here.
+ promptAddCanvasDomain(courses);
+ }
+ });
+ });
+}
+
+function startReminderMode() {
+ 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();
+ })
+ })
+}
+
+// The courses API only answers on real Canvas installs. Resolves { isCanvas, courses }.
+async function detectCanvasPage() {
+ try {
+ const courses = await getData(`${domain}/api/v1/courses?${/*enrollment_state=active&*/""}per_page=100`);
+ if (Array.isArray(courses) && courses.length) {
+ return { isCanvas: true, courses };
+ }
+ console.log("Canvas Refined - this url doesn't seem to be a canvas url (1)");
+ return { isCanvas: false, courses: null };
+ } catch (err) {
+ console.log("Canvas Refined - this url doesn't seem to be a canvas url (2)");
+ return { isCanvas: false, courses: null };
+ }
+}
+
+// Register the domain in the canvas list. `courses` (from detectCanvasPage) is
+// passed to getCards so this school's courses are indexed before the reload,
+// exactly like the original first-run flow did.
+function addCanvasDomain(courses) {
+ return getCards(courses).then(() => new Promise(resolve => {
+ setTimeout(() => {
+ console.log("Canvas Refined - setting custom domain to " + domain);
+ chrome.storage.sync.get(["custom_domain"], storage => {
+ const domains = (storage.custom_domain || []).filter(d => d && d !== "");
+ domains.push(domain);
+ // the domain was just accepted — make sure it isn't on the
+ // "don't ask again" list
+ chrome.storage.sync.get(["custom_domain_denied"], deniedStorage => {
+ const denied = (deniedStorage.custom_domain_denied || []).filter(d => !domain.includes(d));
+ chrome.storage.sync.set({ custom_domain: domains, custom_domain_denied: denied }).then(resolve);
+ });
+ });
+ }, 100);
+ }));
+}
+
+function promptAddCanvasDomain(courses) {
+ // content scripts run at document_start, so the body may not exist yet
+ const show = () => showCanvasPrompt(courses);
+ if (document.body) show();
+ else document.addEventListener("DOMContentLoaded", show, { once: true });
+}
+
+function showCanvasPrompt(courses) {
+ if (document.getElementById("canvasrefined-domain-prompt-backdrop")) return;
+
+ const host = domain.replace(/^https?:\/\//, "");
+ // modal with a backdrop so the ask is impossible to miss
+ const backdrop = makeElement("div", document.body, { "id": "canvasrefined-domain-prompt-backdrop" });
+ const prompt = makeElement("div", backdrop, { "id": "canvasrefined-domain-prompt" });
+ makeElement("h3", prompt, { "textContent": "New Canvas detected" });
+ const text = makeElement("p", prompt, {});
+ text.append("Canvas Refined detected a Canvas page at ");
+ makeElement("span", text, { "className": "canvasrefined-domain-host", "textContent": host });
+ text.append(". Add it to your canvas list?");
+ const actions = makeElement("div", prompt, { "className": "canvasrefined-domain-prompt-actions" });
+ const yes = makeElement("button", actions, { "textContent": chrome.i18n.getMessage("add_to_list") || "Add to list", "className": "canvasrefined-domain-yes" });
+ const no = makeElement("button", actions, { "textContent": chrome.i18n.getMessage("no_thanks") || "No thanks", "className": "canvasrefined-domain-no" });
+
+ const dismiss = () => backdrop.remove();
+
+ yes.addEventListener("click", () => {
+ dismiss();
+ addCanvasDomain(courses).then(() => location.reload());
+ });
+ no.addEventListener("click", () => {
+ dismiss();
+ chrome.storage.sync.get(["custom_domain_denied"], storage => {
+ const denied = storage.custom_domain_denied || [];
+ if (!denied.some(d => domain.includes(d))) denied.push(domain);
+ chrome.storage.sync.set({ custom_domain_denied: denied });
+ });
+ startReminderMode();
});
}
@@ -7634,25 +7734,6 @@ function cleanCustomAssignments() {
});
}
-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`);
diff --git a/js/popup.js b/js/popup.js
index 7cbe587..9ee1415 100644
--- a/js/popup.js
+++ b/js/popup.js
@@ -29,6 +29,7 @@ const syncedSubOptions = [
"todo_show_scores",
"grade_hover",
"card_letter",
+ "auto_detect_disabled",
// "hide_completed",
"num_todo_items",
"hover_preview",
@@ -111,6 +112,8 @@ const defaultOptions = {
"auto_dark_end": { "hour": "08", "minute": "00" },
"num_assignments": 4,
"custom_domain": [""],
+ "custom_domain_denied": [],
+ "auto_detect_disabled": false,
"assignments_done": [],
"dashboard_grades": false,
"assignment_date_format": false,
@@ -876,8 +879,8 @@ function setupFeatureSearch(menu) {
add({ key: "sub:" + keyIdOf(sub), text, el: sub, action: () => goToMainElement(sub) });
});
- // Home: custom Canvas URL
- const customDomain = document.querySelector("#customDomain");
+ // Home: custom Canvas URLs
+ const customDomain = document.querySelector(".customDomain");
if (customDomain && !shouldSkip(customDomain)) {
const wrap = customDomain.closest(".customDomain");
const label = wrap ? wrap.querySelector("[data-i18n='enter_url']") : null;
@@ -1094,6 +1097,7 @@ function setup() {
"todo_show_scores",
"grade_hover",
"card_letter",
+ "auto_detect_disabled",
// "hide_completed",
"hover_preview",
"customBackgroundDaily",
@@ -1491,28 +1495,94 @@ function setup() {
}
updateStorageUsage();
- // activate custom url input
- document.querySelector('#customDomain').addEventListener('input', function () {
- let domains = this.value.split(",");
- domains.forEach((domain, index) => {
- let val = domain.replace(" ", "");
- if (val === "") return;
- //if (!val.includes("https://") && !val.includes("http://")) val = "https://" + val;
- try {
- let url = new URL(val);
- domains[index] = url.hostname;
+ // Canvas URL list — each school gets its own text box
+ const domainListEl = document.querySelector("#custom-domain-list");
+ const addDomainBtn = document.querySelector("#add-domain-btn");
+
+ // Normalize a single entry: a full URL becomes its hostname, anything else
+ // is kept as typed. Idempotent — rows store bare hostnames
+ // ("canvas.school.edu"), which fail new URL() without a scheme, so they're
+ // validated as hostnames instead of re-flagged as invalid on every save.
+ function sanitizeDomainEntry(val, showAlert = true) {
+ let trimmed = val.trim().replace(/ /g, "");
+ if (trimmed === "") return "";
+ try {
+ const host = new URL(trimmed).hostname;
+ if (host) {
clearAlert();
- } catch (e) {
- domains[index] = val;
- displayAlert(true, "The URL you entered appears to be invalid, so it might not work.");
+ return host;
+ }
+ } catch (e) { /* not a full URL — may still be a bare hostname */ }
+ // strip scheme/path if new URL couldn't parse it, then validate as a hostname
+ const host = trimmed.replace(/^https?:\/\//i, "").replace(/\/.*$/, "");
+ if (host && /^[a-z0-9][a-z0-9.-]*$/i.test(host)) {
+ clearAlert();
+ return host;
+ }
+ if (showAlert) displayAlert(true, "The URL you entered appears to be invalid, so it might not work.");
+ return trimmed;
+ }
+
+ function saveDomainList() {
+ const domains = [];
+ domainListEl.querySelectorAll(".domain-row input").forEach(input => {
+ const val = sanitizeDomainEntry(input.value);
+ if (val !== "") domains.push(val);
+ });
+ // keep the legacy [""]-when-empty shape the rest of the code expects
+ chrome.storage.sync.set({ custom_domain: domains.length ? domains : [""] });
+ }
+
+ function makeDomainRow(value) {
+ const row = document.createElement("div");
+ row.className = "domain-row";
+ const input = document.createElement("input");
+ input.type = "text";
+ input.placeholder = "canvas.myschool.edu";
+ input.value = value || "";
+ input.spellcheck = false;
+ input.addEventListener("input", saveDomainList);
+ input.addEventListener("blur", () => {
+ // normalize what's shown and drop rows left empty
+ if (input.value.trim() === "") {
+ if (domainListEl.querySelectorAll(".domain-row").length > 1) row.remove();
+ } else {
+ input.value = sanitizeDomainEntry(input.value);
+ }
+ saveDomainList();
+ });
+ const remove = document.createElement("button");
+ remove.className = "domain-remove";
+ remove.type = "button";
+ remove.textContent = "\u2715";
+ remove.title = "Remove";
+ remove.addEventListener("click", () => {
+ const val = sanitizeDomainEntry(input.value, false);
+ row.remove();
+ saveDomainList();
+ // clear any past "no thanks" for this canvas so the detection
+ // prompt can ask again if it's visited later
+ if (val) {
+ chrome.storage.sync.get(["custom_domain_denied"], storage => {
+ const denied = (storage.custom_domain_denied || []).filter(d => d !== val);
+ chrome.storage.sync.set({ custom_domain_denied: denied });
+ });
}
});
- chrome.storage.sync.set({ custom_domain: domains });
+ row.appendChild(input);
+ row.appendChild(remove);
+ return row;
+ }
+
+ chrome.storage.sync.get(["custom_domain"], storage => {
+ const domains = (storage.custom_domain || []).filter(d => d && d.trim() !== "");
+ domains.forEach(d => domainListEl.appendChild(makeDomainRow(d)));
});
- // setup custom url
- chrome.storage.sync.get(["custom_domain"], storage => {
- document.querySelector("#customDomain").value = storage.custom_domain ? storage.custom_domain : "";
+ addDomainBtn.addEventListener("click", () => {
+ domainListEl.appendChild(makeDomainRow(""));
+ const rows = domainListEl.querySelectorAll(".domain-row input");
+ rows[rows.length - 1].focus();
});
// activate import input box