language updates! (& slight UI changes to popup)

This commit is contained in:
Guy Sandler 2026-10-19 16:58:53 -07:00
parent d61907c2c6
commit f709b1dd6c
17 changed files with 22936 additions and 4837 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -116,18 +116,20 @@ h2 {margin-top: 20px;font-weight:600}
#advanced-settings, #custom-font-btn, #customize-dark-btn, #card-colors-btn {width: 100%;} #advanced-settings, #custom-font-btn, #customize-dark-btn, #card-colors-btn {width: 100%;}
.back-btn {width: 120px;height:100%;margin-top:0} .back-btn {width: 120px;height:100%;margin-top:0}
.big-button {border: none;border-radius: 7px;background: var(--containerbg);color: #fff;font-family: inherit;padding: 12px 10px;font-weight:600} .big-button {border: none;border-radius: 7px;background: var(--containerbg);color: #fff;font-family: inherit;padding: 12px 10px;font-weight:600}
.social-buttons {display: flex;gap: 8px;margin-top: 8px;} .social-footer {display: flex;flex-direction: column;align-items: center;gap: 6px;margin-top: 16px;padding-top: 14px;border-top: 1px solid #ffffff0f;}
.social-footer .social-buttons {display: flex;gap: 8px;width: 100%;max-width: 300px;margin-top: 0;}
.social-buttons a {flex: 1;box-sizing: border-box;} .social-buttons a {flex: 1;box-sizing: border-box;}
.discord-button {display: flex;align-items: center;justify-content: center;gap: 8px;padding: 12px 10px;border: none;border-radius: 7px;background: #5865F2;color: #fff;font-family: inherit;font-size: 14px;font-weight: 600;text-decoration: none;transition: .15s background} .discord-button {display: flex;align-items: center;justify-content: center;gap: 7px;padding: 9px 10px;border: none;border-radius: 8px;background: #5865F2;color: #fff;font-family: inherit;font-size: 13px;font-weight: 600;text-decoration: none;transition: .15s background}
.discord-button:hover {background: #4752c4;cursor: pointer} .discord-button:hover {background: #4752c4;cursor: pointer}
.discord-button:active {filter: brightness(85%)} .discord-button:active {filter: brightness(85%)}
.github-button {display: flex;align-items: center;justify-content: center;gap: 8px;padding: 12px 10px;border: none;border-radius: 7px;background: #30363d;color: #fff;font-family: inherit;font-size: 14px;font-weight: 600;text-decoration: none;transition: .15s background} .github-button {display: flex;align-items: center;justify-content: center;gap: 7px;padding: 9px 10px;border: none;border-radius: 8px;background: #30363d;color: #fff;font-family: inherit;font-size: 13px;font-weight: 600;text-decoration: none;transition: .15s background}
.github-button:hover {background: #21262d;cursor: pointer} .github-button:hover {background: #21262d;cursor: pointer}
.github-button:active {filter: brightness(85%)} .github-button:active {filter: brightness(85%)}
.discord-button svg, .github-button svg {flex-shrink: 0} .discord-button svg, .github-button svg {flex-shrink: 0}
.social-toggle {text-align: center;font-size: 12px;color: #9a9a9a;margin-top: 6px;cursor: pointer;user-select: none;transition: .15s color;text-decoration: none} .social-toggle {text-align: center;font-size: 12px;color: #9a9a9a;cursor: pointer;user-select: none;transition: .15s color;text-decoration: underline;text-underline-offset: 3px;padding: 4px 10px;border-radius: 6px;}
.social-toggle:hover {color: #e2e2e2} .social-toggle:hover {color: #e2e2e2;background: #ffffff0a;}
.social-buttons.hidden {display: none} .social-buttons.hidden {display: none}
.social-footer:has(.social-buttons.hidden) {padding-top: 8px;border-top-color: transparent;margin-top: 8px;}
#setSingleColor, #setGradientColor {padding: 8px 20px;margin-top:5px;} #setSingleColor, #setGradientColor {padding: 8px 20px;margin-top:5px;}
.tab-header {display: flex;justify-content: space-between;gap:20px;align-items:center} .tab-header {display: flex;justify-content: space-between;gap:20px;align-items:center}
.custom-font-flex {display: flex; align-items: center;font-size: 11px;} .custom-font-flex {display: flex; align-items: center;font-size: 11px;}

View File

@ -1,57 +1,57 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<title>Canvas Refined</title> <title>Canvas Refined</title>
<link href="../css/options.css" rel="stylesheet"> <link href="../css/options.css" rel="stylesheet">
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Rubik&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Rubik&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Wix+Madefor+Text:wght@400;500;600;700&display=swap" <link href="https://fonts.googleapis.com/css2?family=Wix+Madefor+Text:wght@400;500;600;700&display=swap"
rel="stylesheet"> rel="stylesheet">
</head> </head>
<body> <body>
<div class="main"> <div class="main">
<div class="main-left"> <div class="main-left">
<div class="container"> <div class="container">
<div class="welcome"> <div class="welcome">
<h1>Welcome to <span class="canvasrefined">Canvas Refined!</span></h1> <h1><span data-i18n="options_welcome">Welcome to</span> <span class="canvasrefined">Canvas Refined!</span></h1>
<div id="refresh-note"> <div id="refresh-note">
<p style="font-weight:600;">Refresh your Canvas page to setup</p> <p style="font-weight:600;" data-i18n="options_refresh_note">Refresh your Canvas page to setup</p>
<span id="finger2">&#128072;</span> <span id="finger2">&#128072;</span>
</div> </div>
</div> </div>
<div class="steps"> <div class="steps">
<p>How to access this menu later:</p> <p data-i18n="options_how_to_access">How to access this menu later:</p>
<li class="steps-item"> <li class="steps-item">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" class="puzzle-icon icon"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" class="puzzle-icon icon">
<path <path
d="M345.14,480H256V434.29a31.3,31.3,0,0,0-9.59-22.65c-7.67-7.56-18.83-11.81-30.57-11.64a44.38,44.38,0,0,0-28.45,10.67c-5.2,4.6-11.39,12.56-11.39,24.42V480H87.62A55.68,55.68,0,0,1,32,424.38V336H77.71c9.16,0,18.07-3.92,25.09-11A42.06,42.06,0,0,0,115,295.08C114.7,273.89,97.26,256,76.91,256H32V166.66a53.77,53.77,0,0,1,16.53-39A55.88,55.88,0,0,1,87.62,112h63.24V97.52A65.53,65.53,0,0,1,217.54,32c35.49.62,64.36,30.38,64.36,66.33V112h63.24A54.28,54.28,0,0,1,400,166.86V230.1h13.66c36.58,0,66.34,29,66.34,64.64,0,36.61-29.39,66.4-65.52,66.4H400v63.24C400,455.05,375.39,480,345.14,480Z" /> d="M345.14,480H256V434.29a31.3,31.3,0,0,0-9.59-22.65c-7.67-7.56-18.83-11.81-30.57-11.64a44.38,44.38,0,0,0-28.45,10.67c-5.2,4.6-11.39,12.56-11.39,24.42V480H87.62A55.68,55.68,0,0,1,32,424.38V336H77.71c9.16,0,18.07-3.92,25.09-11A42.06,42.06,0,0,0,115,295.08C114.7,273.89,97.26,256,76.91,256H32V166.66a53.77,53.77,0,0,1,16.53-39A55.88,55.88,0,0,1,87.62,112h63.24V97.52A65.53,65.53,0,0,1,217.54,32c35.49.62,64.36,30.38,64.36,66.33V112h63.24A54.28,54.28,0,0,1,400,166.86V230.1h13.66c36.58,0,66.34,29,66.34,64.64,0,36.61-29.39,66.4-65.52,66.4H400v63.24C400,455.05,375.39,480,345.14,480Z" />
</svg> </svg>
<span>Click on this puzzle icon at the top right</span> <span data-i18n="options_click_puzzle">Click on this puzzle icon at the top right</span>
</li> </li>
<li class="steps-item"><img src="../icon/icon-48.png" class="icon">Click on Canvas Refined</li> <li class="steps-item"><img src="../icon/icon-48.png" class="icon"><span data-i18n="options_click_extension">Click on Canvas Refined</span></li>
<li class="steps-item"> <li class="steps-item">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" class="pin-icon icon"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" class="pin-icon icon">
<path <path
d="M4.146.146A.5.5 0 0 1 4.5 0h7a.5.5 0 0 1 .5.5c0 .68-.342 1.174-.646 1.479-.126.125-.25.224-.354.298v4.431l.078.048c.203.127.476.314.751.555C12.36 7.775 13 8.527 13 9.5a.5.5 0 0 1-.5.5h-4v4.5c0 .276-.224 1.5-.5 1.5s-.5-1.224-.5-1.5V10h-4a.5.5 0 0 1-.5-.5c0-.973.64-1.725 1.17-2.189A5.921 5.921 0 0 1 5 6.708V2.277a2.77 2.77 0 0 1-.354-.298C4.342 1.674 4 1.179 4 .5a.5.5 0 0 1 .146-.354z" /> d="M4.146.146A.5.5 0 0 1 4.5 0h7a.5.5 0 0 1 .5.5c0 .68-.342 1.174-.646 1.479-.126.125-.25.224-.354.298v4.431l.078.048c.203.127.476.314.751.555C12.36 7.775 13 8.527 13 9.5a.5.5 0 0 1-.5.5h-4v4.5c0 .276-.224 1.5-.5 1.5s-.5-1.224-.5-1.5V10h-4a.5.5 0 0 1-.5-.5c0-.973.64-1.725 1.17-2.189A5.921 5.921 0 0 1 5 6.708V2.277a2.77 2.77 0 0 1-.354-.298C4.342 1.674 4 1.179 4 .5a.5.5 0 0 1 .146-.354z" />
</svg> </svg>
<span>You can also pin it for easier access</span> <span data-i18n="options_pin">You can also pin it for easier access</span>
</li> </li>
</div> </div>
<div class="contact-info"> <div class="contact-info">
<p style="margin-top: 5px">My email is <span style="font-weight:700">sandlerguy5@gmail.com</span> if you <p style="margin-top: 5px" id="options-contact"></p>
have any issues/suggestions - any feedback is appreciated!</p>
</div>
</div> </div>
</div> </div>
</div> <iframe class="menu-frame" src="/html/popup.html"></iframe>
<iframe class="menu-frame" src="/html/popup.html"></iframe> </div>
</div> <script src="../js/options.js"></script>
</body> </body>
</html> </html>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

48
js/options.js Normal file
View File

@ -0,0 +1,48 @@
// Options page i18n: honours the language picked in the popup, like content.js.
const LANGUAGE_STORAGE_KEY = "selected_locale";
let overrideMessages = null;
async function loadLanguageOverride() {
try {
const stored = await chrome.storage.local.get(LANGUAGE_STORAGE_KEY);
const locale = stored[LANGUAGE_STORAGE_KEY];
if (!locale || locale === "auto") { overrideMessages = null; return; }
const res = await fetch(chrome.runtime.getURL(`_locales/${locale}/messages.json`));
if (!res.ok) throw new Error("fetch failed");
overrideMessages = await res.json();
} catch (e) {
overrideMessages = null;
}
}
function getMsg(key, fallback) {
if (overrideMessages && overrideMessages[key] && overrideMessages[key].message) {
return overrideMessages[key].message;
}
const m = chrome.i18n.getMessage(key);
return m || (fallback !== undefined ? fallback : "");
}
function applyI18n() {
document.querySelectorAll("[data-i18n]").forEach(el => {
const msg = getMsg(el.dataset.i18n);
if (msg) el.textContent = msg;
});
const contact = document.getElementById("options-contact");
if (contact) {
const msg = getMsg("options_contact", "My email is $EMAIL$ if you have any issues/suggestions - any feedback is appreciated!");
const email = "sandlerguy5@gmail.com";
const parts = msg.split("$EMAIL$");
contact.textContent = "";
contact.append(parts[0]);
const strong = document.createElement("span");
strong.style.fontWeight = "700";
strong.textContent = email;
contact.append(strong, parts[1] || "");
}
}
(async () => {
await loadLanguageOverride();
applyI18n();
})();

View File

@ -81,6 +81,106 @@ const syncedSubOptions = [
"card_blur", "card_blur",
]; ];
const localSwitches = []; const localSwitches = [];
const LANGUAGES = {
"auto": "Auto (browser)",
"en": "English",
"de": "Deutsch",
"es": "Español",
"fr": "Français",
"it": "Italiano",
"ja": "日本語",
"pt_PT": "Português",
"ru": "Русский",
"sv": "Svenska",
"zh_CN": "中文 (简体)",
"zh_TW": "中文 (繁體)"
};
const LANGUAGE_STORAGE_KEY = "selected_locale";
let overrideMessages = null;
async function loadOverrideMessages(locale) {
if (!locale || locale === "auto") { overrideMessages = null; return; }
try {
const url = chrome.runtime.getURL(`_locales/${locale}/messages.json`);
const res = await fetch(url);
if (!res.ok) throw new Error("fetch failed");
overrideMessages = await res.json();
// monkey-patch chrome.i18n so any later getMsg respects override
if (!chrome.i18n._origGetMessage) {
chrome.i18n._origGetMessage = chrome.i18n.getMessage.bind(chrome.i18n);
chrome.i18n.getMessage = (key) => getMsg(key) || chrome.i18n._origGetMessage(key) || "";
}
} catch (e) {
console.warn("Failed to load locale", locale, e);
overrideMessages = null;
}
}
function getMsg(key, fallback) {
if (overrideMessages && overrideMessages[key] && overrideMessages[key].message) {
return overrideMessages[key].message;
}
const orig = chrome.i18n._origGetMessage || chrome.i18n.getMessage.bind(chrome.i18n);
const m = orig(key);
if (m) return m;
return fallback !== undefined ? fallback : "";
}
function applyI18nOverride() {
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.dataset.i18n;
const msg = getMsg(key);
if (msg) el.textContent = msg;
});
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
const msg = getMsg(el.dataset.i18nPlaceholder);
if (msg) el.placeholder = msg;
});
document.querySelectorAll('[data-i18n-title]').forEach(el => {
const msg = getMsg(el.dataset.i18nTitle);
if (msg) el.title = msg;
});
document.querySelectorAll('[data-i18n-aria-label]').forEach(el => {
const msg = getMsg(el.dataset.i18nAriaLabel);
if (msg) el.setAttribute("aria-label", msg);
});
}
async function initLanguageSelector() {
const sel = document.getElementById("language-select");
if (!sel) return;
// populate
for (const [code, label] of Object.entries(LANGUAGES)) {
const opt = document.createElement("option");
opt.value = code;
opt.textContent = getMsg(code === "auto" ? "language_auto" : code, label) || label;
// keep native label for non-auto, but allow translation for auto
if (code !== "auto") opt.textContent = label;
else opt.textContent = getMsg("language_auto") || label;
sel.appendChild(opt);
}
const stored = await chrome.storage.local.get(LANGUAGE_STORAGE_KEY);
const current = stored[LANGUAGE_STORAGE_KEY] || "auto";
sel.value = current;
await loadOverrideMessages(current);
if (current !== "auto") applyI18nOverride();
sel.addEventListener("change", async () => {
const next = sel.value;
await chrome.storage.local.set({ [LANGUAGE_STORAGE_KEY]: next });
await loadOverrideMessages(next);
applyI18nOverride();
// also update dynamic celebration labels etc that use getMsg
const label = document.getElementById("todo_celebrations_label");
if (label) {
// trigger rerender via storage event or just reload popup text
// easiest: reload popup
location.reload();
} else {
location.reload();
}
});
}
// Theme export carries visual settings only, never productivity data. // Theme export carries visual settings only, never productivity data.
const exportDarkSchedule = ["auto_dark", "auto_dark_start", "auto_dark_end", "device_dark"]; const exportDarkSchedule = ["auto_dark", "auto_dark_start", "auto_dark_end", "device_dark"];
@ -325,7 +425,7 @@ document.addEventListener("DOMContentLoaded", () => {
const KEY = "hide_socials"; const KEY = "hide_socials";
const render = (hidden) => { const render = (hidden) => {
buttons.classList.toggle("hidden", hidden); buttons.classList.toggle("hidden", hidden);
toggle.textContent = chrome.i18n.getMessage(hidden ? "show_links" : "hide_links"); toggle.textContent = getMsg(hidden ? "show_links" : "hide_links") || (hidden ? "Show links" : "Hide links");
}; };
chrome.storage.local.get(KEY, (res) => render(!!res[KEY])); chrome.storage.local.get(KEY, (res) => render(!!res[KEY]));
const flip = () => chrome.storage.local.get(KEY, (res) => { const flip = () => chrome.storage.local.get(KEY, (res) => {
@ -412,11 +512,11 @@ function setupProgressRingsSelect(initial) {
// `todo_celebrations`: multi-select effect ids, one picked at random per // `todo_celebrations`: multi-select effect ids, one picked at random per
// completion. `todo_celebration_amount` scales intensity. // completion. `todo_celebration_amount` scales intensity.
const CELEBRATION_TYPES = [ const CELEBRATION_TYPES = [
{ id: "confetti", label: "Confetti" }, { id: "confetti", label: getMsg("celebration_confetti") || "Confetti" },
{ id: "fireworks", label: "Fireworks" }, { id: "fireworks", label: getMsg("celebration_fireworks") || "Fireworks" },
{ id: "sparkles", label: "Sparkle burst" }, { id: "sparkles", label: getMsg("celebration_sparkles") || "Sparkle burst" },
{ id: "hearts", label: "Rising hearts" }, { id: "hearts", label: getMsg("celebration_hearts") || "Rising hearts" },
{ id: "rocket", label: "Rocket" }, { id: "rocket", label: getMsg("celebration_rocket") || "Rocket" },
]; ];
// Accepts the legacy todo_confetti boolean and drops unknown ids. // Accepts the legacy todo_confetti boolean and drops unknown ids.
@ -455,9 +555,9 @@ function setupCelebrationsSelect(initial) {
}); });
}; };
const updateLabel = () => { const updateLabel = () => {
if (selected.length === 0) label.textContent = "None"; if (selected.length === 0) label.textContent = getMsg("celebration_none") || "None";
else if (selected.length === 1) label.textContent = CELEBRATION_TYPES.find(t => t.id === selected[0])?.label || "None"; else if (selected.length === 1) label.textContent = CELEBRATION_TYPES.find(t => t.id === selected[0]) ? (getMsg("celebration_" + selected[0]) || CELEBRATION_TYPES.find(t => t.id === selected[0]).label) : (getMsg("celebration_none") || "None");
else label.textContent = `${selected.length} selected`; else label.textContent = (getMsg("selected_count") || "$COUNT$ selected").replace("$COUNT$", selected.length);
}; };
// Legacy key: the special handler only passes the new one. // Legacy key: the special handler only passes the new one.
@ -504,7 +604,7 @@ function setupCelebrationPreview() {
btn.addEventListener("click", async () => { btn.addEventListener("click", async () => {
const res = await sendFromPopup("previewCelebration"); const res = await sendFromPopup("previewCelebration");
if (!res || res.ok !== true) { if (!res || res.ok !== true) {
displayAlert(true, "Open a Canvas page to preview celebrations."); displayAlert(true, getMsg("open_canvas_preview") || "Open a Canvas page to preview celebrations.");
} }
}); });
} }
@ -803,11 +903,11 @@ function updateCardGridPreview() {
const warning = document.getElementById("card-grid-warning"); const warning = document.getElementById("card-grid-warning");
if (warning) { if (warning) {
if (s.card_grid_flex === true) { if (s.card_grid_flex === true) {
warning.textContent = "Flexible grid is on - cards wrap to fit narrow windows instead of being cut off."; warning.textContent = getMsg("flexible_grid_on") || "Flexible grid is on - cards wrap to fit narrow windows instead of being cut off.";
warning.style.display = "block"; warning.style.display = "block";
} else if (active > total) { } else if (active > total) {
const gpaNote = s.gpa_calc === true ? " (including the GPA calculator)" : ""; const gpaNote = s.gpa_calc === true ? getMsg("grid_includes_gpa", " (including the GPA calculator)") : "";
warning.textContent = `\u26A0 Your grid holds ${total} card${total === 1 ? "" : "s"} but you have ${active} card${active === 1 ? "" : "s"}${gpaNote} - some cards may be cut off.`; warning.textContent = "\u26A0 " + (getMsg("grid_warning") || "Your grid holds $TOTAL$ card$TOTAL_S$ but you have $ACTIVE$ card$ACTIVE_S$$GPA_NOTE$ - some cards may be cut off.").replace("$TOTAL$", total).replace("$TOTAL_S$", total === 1 ? "" : "s").replace("$ACTIVE$", active).replace("$ACTIVE_S$", active === 1 ? "" : "s").replace("$GPA_NOTE$", gpaNote);
warning.style.display = "block"; warning.style.display = "block";
} else { } else {
warning.style.display = "none"; warning.style.display = "none";
@ -984,7 +1084,7 @@ function parseWidgetOrderEntries(raw) {
function widgetDisplayName(id) { function widgetDisplayName(id) {
const info = WIDGET_LIST.find(w => w.id === id); const info = WIDGET_LIST.find(w => w.id === id);
const msg = info && chrome.i18n.getMessage(info.shortKey || info.nameKey); const msg = info && getMsg(info.shortKey || info.nameKey);
if (msg) return msg; if (msg) return msg;
return id.replace(/_/g, " ").replace(/\b\w/g, c => c.toUpperCase()); return id.replace(/_/g, " ").replace(/\b\w/g, c => c.toUpperCase());
} }
@ -1516,7 +1616,7 @@ function setupFeatureSearch(menu) {
if (!matches.length) { if (!matches.length) {
const empty = document.createElement("div"); const empty = document.createElement("div");
empty.className = "search-result empty"; empty.className = "search-result empty";
empty.textContent = "No features found"; empty.textContent = getMsg("no_features") || "No features found";
resultsEl.appendChild(empty); resultsEl.appendChild(empty);
resultsEl.classList.add("open"); resultsEl.classList.add("open");
return; return;
@ -1593,8 +1693,8 @@ function setupFeatureSearch(menu) {
}); });
} }
function setup() { async function setup() {
await initLanguageSelector();
const menu = { const menu = {
switches: syncedSwitches, switches: syncedSwitches,
checkboxes: [ checkboxes: [
@ -1994,18 +2094,7 @@ function setup() {
}); });
}); });
document.querySelectorAll('[data-i18n]').forEach(text => { applyI18nOverride();
const msg = chrome.i18n.getMessage(text.dataset.i18n);
if (msg) text.innerText = msg;
});
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
const msg = chrome.i18n.getMessage(el.dataset.i18nPlaceholder);
if (msg) el.placeholder = msg;
});
document.querySelectorAll('[data-i18n-title]').forEach(el => {
const msg = chrome.i18n.getMessage(el.dataset.i18nTitle);
if (msg) el.title = msg;
});
setupFeatureSearch(menu); setupFeatureSearch(menu);
@ -2022,7 +2111,7 @@ function setup() {
} else if (output.time < 250) { } else if (output.time < 250) {
rating = "ok"; rating = "ok";
} }
document.getElementById("fix-dm-output").textContent = "Fix took " + Math.round(output.time) + "ms (rating: " + rating + ")"; document.getElementById("fix-dm-output").textContent = (getMsg("fix_took") || "Fix took $TIME$ms (rating: $RATING$)").replace("$TIME$", Math.round(output.time)).replace("$RATING$", rating);
chrome.storage.sync.get("dark_mode_fix", sync => { chrome.storage.sync.get("dark_mode_fix", sync => {
if (sync["dark_mode_fix"].includes(output.path)) return; if (sync["dark_mode_fix"].includes(output.path)) return;
sync["dark_mode_fix"].push(output.path); sync["dark_mode_fix"].push(output.path);
@ -2046,12 +2135,12 @@ function setup() {
// Bumps the fake-data seed; every open Canvas tab re-shuffles without a reload. // Bumps the fake-data seed; every open Canvas tab re-shuffles without a reload.
document.querySelector("#anon-reroll-btn").addEventListener("click", () => { document.querySelector("#anon-reroll-btn").addEventListener("click", () => {
chrome.storage.sync.set({ "anon_grade_seed": Math.floor(Math.random() * 1000000) }); chrome.storage.sync.set({ "anon_grade_seed": Math.floor(Math.random() * 1000000) });
displayAlert(false, "Fake classes rerolled."); displayAlert(false, getMsg("fake_rerolled") || "Fake classes rerolled.");
}); });
// Key must stay in sync with PLANNER_CACHE_KEY in content.js. // Key must stay in sync with PLANNER_CACHE_KEY in content.js.
document.querySelector("#planner-cache-clear-btn").addEventListener("click", async () => { document.querySelector("#planner-cache-clear-btn").addEventListener("click", async () => {
displayAlert(false, "Planner cache cleared."); displayAlert(false, getMsg("planner_cleared") || "Planner cache cleared.");
try { try {
await chrome.storage.local.remove("planner_cache_v1"); await chrome.storage.local.remove("planner_cache_v1");
} catch (e) { /* nothing stored yet */ } } catch (e) { /* nothing stored yet */ }
@ -2087,15 +2176,15 @@ function setup() {
}; };
const syncBytes = await storageBytesInUse(chrome.storage.sync); const syncBytes = await storageBytesInUse(chrome.storage.sync);
set("#storage-usage-sync", syncBytes == null ? "unknown" : formatStorageBytes(syncBytes) + " of 100 KB quota"); set("#storage-usage-sync", syncBytes == null ? getMsg("storage_unknown", "unknown") : formatStorageBytes(syncBytes) + getMsg("storage_quota_100kb", " of 100 KB quota"));
const cacheBytes = await storageBytesInUse(chrome.storage.local, "planner_cache_v1"); const cacheBytes = await storageBytesInUse(chrome.storage.local, "planner_cache_v1");
let cacheText = "empty"; let cacheText = getMsg("storage_empty", "empty");
if (cacheBytes) { if (cacheBytes) {
try { try {
const result = await chrome.storage.local.get("planner_cache_v1"); const result = await chrome.storage.local.get("planner_cache_v1");
const count = result?.["planner_cache_v1"]?.items?.length; const count = result?.["planner_cache_v1"]?.items?.length;
cacheText = formatStorageBytes(cacheBytes) + (typeof count === "number" ? ` (${count} items)` : ""); cacheText = formatStorageBytes(cacheBytes) + (typeof count === "number" ? getMsg("storage_items", " ($COUNT$ items)").replace("$COUNT$", count) : "");
} catch (e) { } catch (e) {
cacheText = formatStorageBytes(cacheBytes); cacheText = formatStorageBytes(cacheBytes);
} }
@ -2104,7 +2193,7 @@ function setup() {
const localBytes = await storageBytesInUse(chrome.storage.local); const localBytes = await storageBytesInUse(chrome.storage.local);
if (localBytes == null) { if (localBytes == null) {
set("#storage-usage-local", "unknown"); set("#storage-usage-local", getMsg("storage_unknown", "unknown"));
} else { } else {
const other = cacheBytes != null ? Math.max(localBytes - cacheBytes, 0) : null; const other = cacheBytes != null ? Math.max(localBytes - cacheBytes, 0) : null;
set("#storage-usage-local", other == null ? formatStorageBytes(localBytes) : formatStorageBytes(other)); set("#storage-usage-local", other == null ? formatStorageBytes(localBytes) : formatStorageBytes(other));
@ -2139,7 +2228,7 @@ function setup() {
clearAlert(); clearAlert();
return host; return host;
} }
if (showAlert) displayAlert(true, "The URL you entered appears to be invalid, so it might not work."); if (showAlert) displayAlert(true, getMsg("invalid_url") || "The URL you entered appears to be invalid, so it might not work.");
return trimmed; return trimmed;
} }
@ -2157,7 +2246,7 @@ function setup() {
row.className = "domain-row"; row.className = "domain-row";
const input = document.createElement("input"); const input = document.createElement("input");
input.type = "text"; input.type = "text";
input.placeholder = "canvas.myschool.edu"; input.placeholder = getMsg("placeholder_example") || "canvas.myschool.edu";
input.value = value || ""; input.value = value || "";
input.spellcheck = false; input.spellcheck = false;
input.addEventListener("input", saveDomainList); input.addEventListener("input", saveDomainList);
@ -2173,7 +2262,7 @@ function setup() {
remove.className = "domain-remove"; remove.className = "domain-remove";
remove.type = "button"; remove.type = "button";
remove.textContent = "\u2715"; remove.textContent = "\u2715";
remove.title = "Remove"; remove.title = getMsg("remove_label") || "Remove";
remove.addEventListener("click", () => { remove.addEventListener("click", () => {
const val = sanitizeDomainEntry(input.value, false); const val = sanitizeDomainEntry(input.value, false);
row.remove(); row.remove();
@ -2210,8 +2299,8 @@ function setup() {
const output = document.querySelector("#export-output"); const output = document.querySelector("#export-output");
const btn = document.querySelector("#export-copy"); const btn = document.querySelector("#export-copy");
const text = output.value; const text = output.value;
if (!text) { displayAlert(true, "Nothing to copy — select what to export first."); return; } if (!text) { displayAlert(true, getMsg("nothing_to_copy") || "Nothing to copy — select what to export first."); return; }
const flash = () => { const old = btn.textContent; btn.textContent = "Copied!"; setTimeout(() => { btn.textContent = old; }, 1200); }; const flash = () => { const old = btn.textContent; btn.textContent = getMsg("copied") || "Copied!"; setTimeout(() => { btn.textContent = old; }, 1200); };
try { try {
await navigator.clipboard.writeText(text); await navigator.clipboard.writeText(text);
flash(); flash();
@ -2405,10 +2494,10 @@ function setup() {
const drawer = document.getElementById("submit-drawer"); const drawer = document.getElementById("submit-drawer");
if (drawer.style.display === "none") { if (drawer.style.display === "none") {
drawer.style.display = "block"; drawer.style.display = "block";
e.target.textContent = "Hide"; e.target.textContent = getMsg("hide") || "Hide";
} else { } else {
drawer.style.display = "none"; drawer.style.display = "none";
e.target.textContent = "Show"; e.target.textContent = getMsg("show") || "Show";
} }
}); });
@ -2734,22 +2823,35 @@ function displayThemeSearchList(themesToShow, pageDir = 0) {
function getRelativeDate(date, short = false) { function getRelativeDate(date, short = false) {
let now = new Date(); let now = new Date();
let timeSince = (now.getTime() - date.getTime()) / 60000; let timeSince = (now.getTime() - date.getTime()) / 60000;
let time = "min"; let unitKey = short ? "time_min_short" : "time_min";
let unitFallback = "min";
let pluralKey = null;
let pluralFallback = "";
timeSince = Math.abs(timeSince); timeSince = Math.abs(timeSince);
if (timeSince >= 60) { if (timeSince >= 60) {
timeSince /= 60; timeSince /= 60;
time = short ? "h" : "hour"; unitKey = short ? "time_hour_short" : "time_hour";
unitFallback = short ? "h" : "hour";
pluralKey = short ? null : "time_hour_plural";
pluralFallback = "hours";
if (timeSince >= 24) { if (timeSince >= 24) {
timeSince /= 24; timeSince /= 24;
time = short ? "d" : "day"; unitKey = short ? "time_day_short" : "time_day";
unitFallback = short ? "d" : "day";
pluralKey = short ? null : "time_day_plural";
pluralFallback = "days";
if (timeSince >= 7) { if (timeSince >= 7) {
timeSince /= 7; timeSince /= 7;
time = short ? "w" : "week"; unitKey = short ? "time_week_short" : "time_week";
unitFallback = short ? "w" : "week";
pluralKey = short ? null : "time_week_plural";
pluralFallback = "weeks";
} }
} }
} }
timeSince = Math.round(timeSince); timeSince = Math.round(timeSince);
let relative = timeSince + (short ? "" : " ") + time + (timeSince > 1 && !short ? "s" : ""); const unit = getMsg(timeSince > 1 && pluralKey ? pluralKey : unitKey, timeSince > 1 && pluralKey ? pluralFallback : unitFallback);
let relative = short ? timeSince + unit : timeSince + " " + unit;
return { time: relative, ms: now.getTime() - date.getTime() }; return { time: relative, ms: now.getTime() - date.getTime() };
} }
@ -2760,8 +2862,8 @@ function displaySavedThemes() {
Object.keys(local["saved_themes"]).forEach((key, index) => { Object.keys(local["saved_themes"]).forEach((key, index) => {
const created = new Date(parseInt(key)); const created = new Date(parseInt(key));
let btn = makeElement("div", target, { "className": "saved-theme" }); let btn = makeElement("div", target, { "className": "saved-theme" });
let title = makeElement("p", btn, { "className": "theme-button-title", "textContent": `Theme ${index + 1}`}); let title = makeElement("p", btn, { "className": "theme-button-title", "textContent": (getMsg("theme_number") || "Theme $NUM$").replace("$NUM$", index + 1)});
let date = makeElement("p", btn, { "className": "theme-button-creator", "textContent": `${getRelativeDate(created).time} ago` }); let date = makeElement("p", btn, { "className": "theme-button-creator", "textContent": (getMsg("relative_ago", "$TIME$ ago")).replace("$TIME$", getRelativeDate(created).time) });
let remove = makeElement("div", btn, { "className": "theme-button-remove", "textContent": "x" }); let remove = makeElement("div", btn, { "className": "theme-button-remove", "textContent": "x" });
btn.style.backgroundImage = `linear-gradient(rgba(0, 0, 0, 0.44), rgba(0, 0, 0, 0.44)), url(${local["saved_themes"][key]["custom_cards"]?.[0] || ""})`; btn.style.backgroundImage = `linear-gradient(rgba(0, 0, 0, 0.44), rgba(0, 0, 0, 0.44)), url(${local["saved_themes"][key]["custom_cards"]?.[0] || ""})`;
btn.addEventListener("click", () => { btn.addEventListener("click", () => {
@ -2840,7 +2942,7 @@ function updateCards(key, value) {
chrome.storage.sync.get(["custom_cards"], result => { chrome.storage.sync.get(["custom_cards"], result => {
chrome.storage.sync.set({ "custom_cards": { ...result["custom_cards"], [key]: { ...result["custom_cards"][key], ...value } } }, () => { chrome.storage.sync.set({ "custom_cards": { ...result["custom_cards"], [key]: { ...result["custom_cards"][key], ...value } } }, () => {
if (chrome.runtime.lastError) { if (chrome.runtime.lastError) {
displayAlert(true, "The data you're entering is exceeding the storage limit, so it won't save. Try using shorter links, and make sure to press \"copy image address\" and not \"copy image\" for links."); displayAlert(true, getMsg("storage_limit_error", "The data you're entering is exceeding the storage limit, so it won't save. Try using shorter links, and make sure to press \"copy image address\" and not \"copy image\" for links."));
} }
}) })
}); });
@ -2866,7 +2968,7 @@ function displayCustomFont() {
const popularFonts = ["Arimo", "Barriecito", "Barlow", "Caveat", "Cinzel", "Comfortaa", "Corben", "DM Sans", "Expletus Sans", "Gluten", "Happy Monkey", "Inconsolata", "Inria Sans", "Jost", "Kanit", "Karla", "Kode Mono", "Lobster", "Lora", "Madimi One", "Mali", "Montserrat", "Nanum Myeongjo", "Open Sans", "Oswald", "Permanent Marker", "Playfair Display", "Poetsen One", "Poppins", "Quicksand", "Rakkas", "Redacted Script", "Roboto Mono", "Rubik", "Silkscreen", "Sixtyfour", "Syne Mono", "Tektur", "Texturina", "Ysabeau Infant", "Yuji Syuku"]; const popularFonts = ["Arimo", "Barriecito", "Barlow", "Caveat", "Cinzel", "Comfortaa", "Corben", "DM Sans", "Expletus Sans", "Gluten", "Happy Monkey", "Inconsolata", "Inria Sans", "Jost", "Kanit", "Karla", "Kode Mono", "Lobster", "Lora", "Madimi One", "Mali", "Montserrat", "Nanum Myeongjo", "Open Sans", "Oswald", "Permanent Marker", "Playfair Display", "Poetsen One", "Poppins", "Quicksand", "Rakkas", "Redacted Script", "Roboto Mono", "Rubik", "Silkscreen", "Sixtyfour", "Syne Mono", "Tektur", "Texturina", "Ysabeau Infant", "Yuji Syuku"];
let quickFonts = document.querySelector("#quick-fonts"); let quickFonts = document.querySelector("#quick-fonts");
quickFonts.textContent = ""; quickFonts.textContent = "";
let noFont = makeElement("button", quickFonts, { "className": "customization-button", "textContent": "None" }); let noFont = makeElement("button", quickFonts, { "className": "customization-button", "textContent": getMsg("todo_progress_none") || "None" });
noFont.addEventListener("click", () => { noFont.addEventListener("click", () => {
chrome.storage.sync.set({ "custom_font": { "link": "", "family": "" } }); chrome.storage.sync.set({ "custom_font": { "link": "", "family": "" } });
link.value = ""; link.value = "";
@ -2903,7 +3005,7 @@ function setCustomImage(key, val) {
if (val !== "" && val !== "none") { if (val !== "" && val !== "none") {
let test = new Image(); let test = new Image();
test.onerror = () => { test.onerror = () => {
displayAlert(true, "It seems that the image link you provided isn't working. Make sure to right click on any images you want to use and select \"copy image address\" to get the correct link."); displayAlert(true, getMsg("image_link_error", "It seems that the image link you provided isn't working. Make sure to right click on any images you want to use and select \"copy image address\" to get the correct link."));
updateCards(key, { "img": val }); updateCards(key, { "img": val });
} }
test.onload = clearAlert; test.onload = clearAlert;
@ -2949,7 +3051,7 @@ async function displayAdvancedCards() {
}); });
if (Object.keys(allCards).length === 0) { if (Object.keys(allCards).length === 0) {
cardGrid.innerHTML = '<p style="text-align: center; color: #999; padding: 20px;">No course cards found. Visit your Canvas dashboard to load courses.</p>'; cardGrid.innerHTML = '<p style="text-align: center; color: #999; padding: 20px;">' + (getMsg("no_course_cards") || "No course cards found. Visit your Canvas dashboard to load courses.") + '</p>';
return; return;
} }
@ -2998,7 +3100,7 @@ function createCourseButton(courseId, courseData) {
tile.className = "course-card-tile"; tile.className = "course-card-tile";
tile.classList.toggle("card-hidden", courseData.hidden === true); tile.classList.toggle("card-hidden", courseData.hidden === true);
tile.appendChild(button); tile.appendChild(button);
makeElement("button", tile, { className: "course-card-hide-toggle", title: "Hide/show on dashboard", innerHTML: CARD_EYE_SVG }).addEventListener("click", () => { makeElement("button", tile, { className: "course-card-hide-toggle", title: getMsg("card_hide_toggle", "Hide/show on dashboard"), innerHTML: CARD_EYE_SVG }).addEventListener("click", () => {
const hidden = courseData.hidden !== true; const hidden = courseData.hidden !== true;
courseData.hidden = courseData.hide = hidden; courseData.hidden = courseData.hide = hidden;
updateCards(courseId, { hidden, hide: hidden }); updateCards(courseId, { hidden, hide: hidden });
@ -3122,7 +3224,7 @@ function saveCardChanges(courseId) {
updateCards(courseId, updates); updateCards(courseId, updates);
} }
displayAlert(false, "Card settings saved successfully!"); displayAlert(false, getMsg("card_settings_saved", "Card settings saved successfully!"));
hideCardEditMenu(); hideCardEditMenu();
@ -3134,7 +3236,7 @@ function saveCardChanges(courseId) {
function resetCardToDefault(courseId) { function resetCardToDefault(courseId) {
updateCards(courseId, { name: "", code: "", img: "", hidden: false, hide: false, hide_todo: false }); updateCards(courseId, { name: "", code: "", img: "", hidden: false, hide: false, hide_todo: false });
displayAlert(false, "Card reset to default settings!"); displayAlert(false, getMsg("card_reset_default", "Card reset to default settings!"));
setTimeout(() => { setTimeout(() => {
displayAdvancedCards(); displayAdvancedCards();