NYCU EM3 - E3 UI Plus

強化 NYCU E3 全站介面與操作體驗。

Aby zainstalować ten skrypt, wymagana jest instalacje jednego z następujących rozszerzeń: Tampermonkey, Greasemonkey lub Violentmonkey.

You will need to install an extension such as Tampermonkey to install this script.

Aby zainstalować ten skrypt, wymagana jest instalacje jednego z następujących rozszerzeń: Tampermonkey, Violentmonkey.

Aby zainstalować ten skrypt, wymagana będzie instalacja rozszerzenia Tampermonkey lub Userscripts.

You will need to install an extension such as Tampermonkey to install this script.

Aby zainstalować ten skrypt, musisz zainstalować rozszerzenie menedżera skryptów użytkownika.

(Mam już menedżera skryptów użytkownika, pozwól mi to zainstalować!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

Będziesz musiał zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

Będziesz musiał zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

Musisz zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

(Mam już menedżera stylów użytkownika, pozwól mi to zainstalować!)

// ==UserScript==
// @name         NYCU EM3 - E3 UI Plus
// @name:en      NYCU EM3 - E3 Interface Optimization
// @name:zh-CN   NYCU EM3 - E3 介面最佳化
// @name:zh-TW   NYCU EM3 - E3 介面最佳化
// @name:zh      NYCU EM3 - E3 介面最佳化
// @namespace    http://tampermonkey.net/
// @version      1.2.6
// @description  強化 NYCU E3 全站介面與操作體驗。
// @description:en  Improve NYCU E3 full-site UI/UX.
// @description:zh-CN  强化 NYCU E3 全站介面与操作体验。
// @description:zh-TW  強化 NYCU E3 全站介面與操作體驗。
// @description:zh  強化 NYCU E3 全站介面與操作體驗。
// @author       Elvis Mao
// @match        https://e3p.nycu.edu.tw/*
// @icon         https://emtech.cc/static/icons/apple-touch-icon.png
// @license      Apache-2.0
// @homepageURL  https://github.com/Edit-Mr/SSS/tree/main
// @supportURL   https://github.com/Edit-Mr/SSS/issues
// @run-at       document-start
// @grant        GM_addStyle
// ==/UserScript==

(() => {
	"use strict";

	// 嘗試載入 CSS。如果失敗的話代表可能是使用 dev.js 在跑,交給他就好。
	try {
		GM_addStyle("@import url('https://g.elvismao.com/nycu-em3/index.css');@import url('https://g.elvismao.com/nycu-em3/home.css');");
	} catch (err) {
		console.warn("[TM] Failed to load external CSS, maybe you're in dev mode.");
	}

	const path = location.pathname.replace(/\/+$/, "") || "/";
	const isDashboard = location.origin === "https://e3p.nycu.edu.tw" && (path === "/my" || path === "/my/index.php");
	const isEnglish = new URLSearchParams(location.search).get("lang") === "en" || (document.documentElement.lang || "").startsWith("en");

	function boot() {
		try {
			console.log(`[TM] NYCU E3 UI Plus loaded. Dashboard: ${isDashboard}, English: ${isEnglish}`);
			initTheme();
			patchNavbar();
			if (isDashboard) initDashboard();
		} catch (err) {
			console.error("[TM] Error in main execution:", err);
		}
	}

	if (document.readyState === "loading") {
		document.addEventListener("DOMContentLoaded", boot, { once: true });
	} else {
		boot();
	}

	// --- tool functions ---
	function initTheme() {
		const savedTheme = localStorage.getItem("em-theme");

		if (savedTheme === "dark") {
			document.body.classList.add("darkmode");
		} else if (savedTheme === "light") {
			document.body.classList.remove("darkmode");
		} else {
			const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
			if (prefersDark) {
				document.body.classList.add("darkmode");
			}
		}

		window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", e => {
			if (!localStorage.getItem("em-theme")) {
				if (e.matches) {
					document.body.classList.add("darkmode");
				} else {
					document.body.classList.remove("darkmode");
				}
			}
		});
	}

	function toggleTheme() {
		const isDark = document.body.classList.contains("darkmode");

		if (isDark) {
			document.body.classList.remove("darkmode");
			localStorage.setItem("em-theme", "light");
		} else {
			document.body.classList.add("darkmode");
			localStorage.setItem("em-theme", "dark");
		}

		updateThemeToggleIcon(!isDark);
	}

	function updateThemeToggleIcon(isDark) {
		const toggleBtn = document.querySelector("#theme-toggle");
		if (toggleBtn) {
			toggleBtn.innerHTML = isDark ? "☀️" : "🌙";
			toggleBtn.setAttribute("aria-label", isDark ? "切換到亮色模式" : "切換到暗色模式");
		}
	}

	function patchNavbar() {
		const navbar = document.querySelector("nav.navbar.fixed-top");
		if (!navbar) return;

		// 移除空的 primary-navigation 和 page_heading_menu
		navbar.querySelector(".primary-navigation")?.remove();
		navbar.querySelector("ul.navbar-nav.d-none.d-md-flex")?.remove();
		navbar.querySelector(".navbar-toggler")?.remove();

		document.querySelector("nav .navbar-brand").innerHTML = `
			<img src="https://g.elvismao.com/nycu-em3/e3.svg" alt="E3 Logo" style="height:32px; margin-right:8px;">
			<span style="font-weight:700;">NYCU EM3</span>
		`;

		// Add theme toggle button to navbar
		const userNavigation = navbar.querySelector("#usernavigation");
		if (userNavigation) {
			const themeToggleContainer = document.createElement("div");
			themeToggleContainer.className = "popover-region collapsed";

			const isDark = document.body.classList.contains("darkmode");
			themeToggleContainer.innerHTML = `
				<button 
					id="theme-toggle" 
					class="nav-link popover-region-toggle position-relative icon-no-margin" 
					style="background:none; border:none; color:var(--em-text);"
					aria-label="${isDark ? "切換到亮色模式" : "切換到暗色模式"}"
				>
					${isDark ? "☀️" : "🌙"}
				</button>
			`;

			userNavigation.insertBefore(themeToggleContainer, userNavigation.firstChild);
			const toggleBtn = themeToggleContainer.querySelector("#theme-toggle");
			toggleBtn.addEventListener("click", toggleTheme);
		}

		// set favicon
		const favicon = document.querySelector('link[rel="icon"]') || document.createElement("link");
		favicon.rel = "icon";
		favicon.href = "https://g.elvismao.com/nycu-em3/e3.svg";
		document.head.appendChild(favicon);

		// 升級 Gravatar 解析度(頭像 img)
		const avatarImg = navbar.querySelector("#user-menu-toggle img.userpicture");
		if (avatarImg?.src) {
			avatarImg.src = avatarImg.src.replace("s=35", "s=200");
			avatarImg.removeAttribute("width");
			avatarImg.removeAttribute("height");
		}
	}

	function initDashboard() {
		const data = extractDashboardData();
		const app = buildDashboardApp(data);

		document.getElementById("page")?.remove();
		document.getElementById("page-wrapper").appendChild(app);
	}

	function extractDashboardData() {
		const siteLogo = document.querySelector(".navbar-brand img")?.src || document.querySelector(".drawerheader a img")?.src || "";

		const avatarRaw = document.querySelector("#user-menu-toggle img")?.src || document.querySelector(".myprofileitem.picture img")?.src || "";
		// Gravatar 預設 s=35 畫質很差,換成 200
		const avatar = avatarRaw.replace("s=35", "s=200");

		const notificationCount = cleanText(document.querySelector("#nav-notification-popover-container .count-container")?.textContent) || "";

		const lang = cleanText([...document.querySelectorAll("#usernavigation a.nav-link")].map(el => el.textContent).find(t => /\bTW\b/i.test(t))) || "TW";

		const profileName = cleanText(document.querySelector("#inst82730 .myprofileitem.fullname")?.textContent) || "已登入使用者";

		const country = cleanText(document.querySelector("#inst82730 .myprofileitem.country")?.textContent).replace(/^國家:\s*/, "");

		const englishName = cleanText(document.querySelectorAll("#inst82730 .myprofileitem.city")[0]?.textContent).replace(/^英文姓名:\s*/, "");

		const email = cleanText(document.querySelectorAll("#inst82730 .myprofileitem.city")[1]?.textContent).replace(/^電子郵件信箱:\s*/, "");

		const courses = parseCourses();
		const announcements = parseAnnouncements();
		const events = parseEvents();

		// Collect unique terms in DOM order (newest first); courses without a term go under "其他"
		const allTerms = [...new Set(courses.map(c => c.term).filter(Boolean))];
		if (courses.some(c => !c.term)) allTerms.push("其他");
		const currentTerm = allTerms[0] || "";

		return {
			siteLogo,
			avatar,
			notificationCount,
			lang,
			isEnglish,
			profileName,
			country,
			englishName,
			email,
			currentTerm,
			allTerms,
			courses,
			announcements,
			events
		};
	}

	function parseCourses() {
		// 優先抓右側主課程清單;若只有一個學期,再補入左側 sidebar(可能包含舊學期)
		const rightNodes = [...document.querySelectorAll("#layer2_right_current_course_stu a.course-link")];
		const leftNodes = [...document.querySelectorAll("#layer2_right_current_course_left a.course-link")];

		// 右側已有的 href set,用來去重
		const rightHrefs = new Set(rightNodes.map(a => normalizeHref(a.getAttribute("href"))).filter(Boolean));

		// 把左側有、但右側沒有的補進來(舊學期課程)
		const extraNodes = leftNodes.filter(a => !rightHrefs.has(normalizeHref(a.getAttribute("href"))));

		const nodes = [...rightNodes, ...extraNodes];
		const seen = new Set();
		const items = [];

		for (const a of nodes) {
			const href = normalizeHref(a.getAttribute("href"));
			const raw = cleanText(a.textContent);
			if (!href || !raw || seen.has(href)) continue;
			seen.add(href);

			const termMatch = raw.match(/【([^】]+)】/);
			const term = termMatch ? termMatch[1] : "";

			// 去掉【學期】前綴與課號後,剩下「中文名稱 英文名稱」
			const body = raw
				.replace(/^\s*【[^】]+】\s*/, "")
				.replace(/^\d+\s*/, "")
				.trim();

			// 英文部分:從第一個「空白+大寫英文字母」開始到結尾
			const enMatch = body.match(/\s+([A-Z].*)$/);
			const titleEn = enMatch ? enMatch[1].trim() : "";
			const titleZh = enMatch ? body.slice(0, enMatch.index).trim() : body;

			const title = titleZh || raw;

			items.push({ title, titleZh: title, titleEn, href, term });
		}

		return items;
	}

	function parseAnnouncements() {
		const posts = [...document.querySelectorAll("#inst20 .post")];
		const announcements = posts.map(post => {
			const dateLine = cleanText(post.querySelector(".date")?.textContent);
			const courseRaw = cleanText(post.querySelector(".date b")?.textContent);
			const title = cleanText(post.querySelector(".name")?.textContent);
			const info = cleanText(post.querySelector(".info")?.textContent);
			const link = normalizeHref(post.querySelector(".info a")?.getAttribute("href"));

			const rawTime = dateLine.replace(courseRaw, "").trim();
			const formattedTime = formatRelativeDate(rawTime);

			// 格式:1142.515501.離散數學 Discrete Mathematics → 離散數學
			// 先去掉「學期代碼.課號.」前綴,再去掉英文名稱
			const course =
				courseRaw
					.replace(/^\d+\.\d+\./, "") // 去掉 "1142.515501."
					.replace(/\s+[A-Za-z][\s\S]*$/, "") // 去掉英文名稱
					.trim() || courseRaw;

			// 為了排序,保留原始時間用於解析
			const timeMatch = rawTime.match(/(\d{1,2})月\s*(\d{1,2})日,(\d{1,2}):(\d{2})/);
			let sortDate = new Date();
			if (timeMatch) {
				const [, month, day, hour, minute] = timeMatch;
				const currentYear = new Date().getFullYear();
				sortDate = new Date(currentYear, parseInt(month) - 1, parseInt(day), parseInt(hour), parseInt(minute));

				// 如果解析出來的日期比現在晚,則可能是去年
				if (sortDate > new Date()) {
					sortDate = new Date(currentYear - 1, parseInt(month) - 1, parseInt(day), parseInt(hour), parseInt(minute));
				}
			}

			return {
				course,
				time: formattedTime,
				title,
				info,
				href: link || "#",
				sortDate
			};
		});

		// 按日期排序,最新的在前面
		return announcements.sort((a, b) => b.sortDate - a.sortDate);
	}

	function parseEvents() {
		const items = [...document.querySelectorAll('#inst11984 [data-region="event-item"]')];
		return items.slice(0, 4).map(item => {
			const title = cleanText(item.querySelector("h6 a")?.textContent);
			const href = normalizeHref(item.querySelector("h6 a")?.getAttribute("href"));
			const time = cleanText(item.querySelector(".date")?.textContent);
			return { title, href, time };
		});
	}

	function buildDashboardApp(data) {
		const app = document.createElement("div");
		app.className = "em-app";

		app.innerHTML = `
      <main class="em-main">
        <div class="em-col">
          <section class="em-section">
            <div class="em-section-head">
              <h2 class="em-section-title">課程列表</h2>
              ${
								data.allTerms.length > 1
									? `<div class="em-term-dropdown" id="em-term-dropdown">
                      <button class="em-pill em-pill-btn" id="em-term-btn" aria-haspopup="true" aria-expanded="false">
                        ${escapeHTML(data.currentTerm)} <span class="em-pill-caret">▾</span>
                      </button>
                      <ul class="em-term-menu" id="em-term-menu" role="listbox">
                        ${data.allTerms
													.map(t => {
														const val = t === "其他" ? "" : t;
														return `<li class="em-term-option${t === data.currentTerm ? " active" : ""}" role="option" data-term="${escapeAttr(val)}">${escapeHTML(t)}</li>`;
													})
													.join("")}
                      </ul>
                    </div>`
									: data.currentTerm
										? `<div class="em-pill">${escapeHTML(data.currentTerm)}</div>`
										: ""
							}
            </div>
            ${
							data.courses.length
								? `<div class="em-course-grid" id="em-course-grid">
                    ${data.courses
											.map(
												course => `
                          <a class="em-course-chip" href="${escapeAttr(course.href)}" data-term="${escapeAttr(course.term)}"${course.term !== data.currentTerm ? ' style="display:none"' : ""}>
                            ${escapeHTML(data.isEnglish && course.titleEn ? course.titleEn : course.titleZh)}
                          </a>
                        `
											)
											.join("")}
                  </div>`
								: `<div class="em-empty">沒有抓到課程資料</div>`
						}
          </section>

          <section class="em-section">
            <div class="em-section-head">
              <h2 class="em-section-title">已登入使用者</h2>
            </div>
            <div class="em-card em-profile-card">
              <div class="em-profile-pic">
                ${data.avatar ? `<img src="${escapeAttr(data.avatar)}" alt="profile">` : ""}
              </div>
              <div>
                <div class="em-profile-name">${escapeHTML(data.profileName)}</div>
                ${data.country ? `<div class="em-profile-line"><b>國家:</b> ${escapeHTML(data.country)}</div>` : ""}
                ${data.englishName ? `<div class="em-profile-line"><b>英文姓名:</b> ${escapeHTML(data.englishName)}</div>` : ""}
                ${data.email ? `<div class="em-profile-line"><b>電子郵件信箱:</b> ${escapeHTML(data.email)}</div>` : ""}
              </div>
            </div>
          </section>
        </div>

        <div class="em-col">
          <section class="em-section">
            <div class="em-section-head">
              <h2 class="em-section-title">課程公告</h2>
            </div>
            <div class="em-card em-announcements">
              ${
								data.announcements.length
									? data.announcements
											.map(
												item => `
                          <a class="em-announcement" href="${escapeAttr(item.href)}">
                            <div class="em-announcement-meta">
                              <span class="em-announcement-course">${escapeHTML(item.course)}</span>
                              ${item.time ? ` / ${escapeHTML(item.time)}` : ""}
                            </div>
                            <div class="em-announcement-title">${escapeHTML(item.title)}</div>
                            <div class="em-announcement-desc">${escapeHTML(item.info)}</div>
                          </a>
                        `
											)
											.join("")
									: `<div class="em-empty">沒有抓到公告資料</div>`
							}
            </div>
          </section>

          <section class="em-section">
            <div class="em-section-head">
              <h2 class="em-section-title">未來事件</h2>
            </div>
            <div class="em-card em-events">
              ${
								data.events.length
									? data.events
											.map(
												event => `
                          <a class="em-event" href="${escapeAttr(event.href)}">
                            <div class="em-event-icon">🎓</div>
                            <div>
                              <div class="em-event-title">${escapeHTML(event.title)}</div>
                              <div class="em-event-time">${escapeHTML(event.time)}</div>
                            </div>
                          </a>
                        `
											)
											.join("")
									: `<div class="em-empty">沒有抓到未來事件</div>`
							}
            </div>
          </section>
        </div>
      </main>
    `;

		// Term dropdown interaction
		const termBtn = app.querySelector("#em-term-btn");
		const termMenu = app.querySelector("#em-term-menu");
		const courseGrid = app.querySelector("#em-course-grid");
		if (termBtn && termMenu && courseGrid) {
			let activeTerm = data.currentTerm;

			const applyTerm = term => {
				activeTerm = term;

				// Update pill label
				const label = term === "" ? "其他" : term;
				termBtn.childNodes[0].textContent = label + " ";

				// Show/hide chips
				courseGrid.querySelectorAll(".em-course-chip").forEach(chip => {
					const t = chip.dataset.term;
					chip.style.display = t === term ? "" : "none";
				});

				// Update active state in menu
				termMenu.querySelectorAll(".em-term-option").forEach(opt => {
					opt.classList.toggle("active", opt.dataset.term === term);
				});
			};

			termBtn.addEventListener("click", e => {
				e.stopPropagation();
				const open = termMenu.classList.toggle("open");
				termBtn.setAttribute("aria-expanded", String(open));
			});

			termMenu.addEventListener("click", e => {
				const option = e.target.closest(".em-term-option");
				if (!option) return;
				applyTerm(option.dataset.term);
				termMenu.classList.remove("open");
				termBtn.setAttribute("aria-expanded", "false");
			});

			// Close on outside click
			document.addEventListener("click", () => {
				termMenu.classList.remove("open");
				termBtn.setAttribute("aria-expanded", "false");
			});
		}

		return app;
	}

	function normalizeHref(href) {
		if (!href) return "";
		try {
			return new URL(href, location.origin).href;
		} catch {
			return href;
		}
	}

	function cleanText(value) {
		return (value || "")
			.replace(/\u00a0/g, " ")
			.replace(/\s+/g, " ")
			.trim();
	}

	function escapeHTML(str) {
		return String(str).replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
	}

	function formatRelativeDate(dateStr) {
		// 解析原始日期字串 "03月 16日,15:49"
		const match = dateStr.match(/(\d{1,2})月\s*(\d{1,2})日,(\d{1,2}):(\d{2})/);
		if (!match) return dateStr;

		const [, month, day] = match;
		const now = new Date();
		const currentYear = now.getFullYear();

		// 假設是當年的日期,如果解析出來的日期比現在晚,則可能是去年
		let targetDate = new Date(currentYear, parseInt(month) - 1, parseInt(day), 0, 0);
		if (targetDate > now) {
			targetDate = new Date(currentYear - 1, parseInt(month) - 1, parseInt(day), 0, 0);
		}

		const timeDiff = now - targetDate;
		const daysDiff = Math.floor(timeDiff / (1000 * 60 * 60 * 24));
		const weekdays = ["星期日", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六"];
		const weekday = weekdays[targetDate.getDay()];

		// 如果是今天
		if (daysDiff === 0) {
			return `今天`;
		}

		// 如果是昨天
		if (daysDiff === 1) {
			return `昨天 ${weekday}`;
		}

		if (daysDiff < now.getDay()) {
			return weekday;
		}

		if (daysDiff < now.getDay() + 7) {
			return `${month}/${day} 上${weekday}`;
		}

		// 超過兩周,按週計算
		const weeksDiff = Math.floor(daysDiff / 7) + 1;
		if (weeksDiff < 8) {
			return `${weeksDiff} 周前${weekday}`;
		}

		// 超過 8 周,顯示月/日
		return `${month}/${day}`;
	}

	function escapeAttr(str) {
		return escapeHTML(str);
	}
})();