NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript== // @name PS Calendar to ICS (iZJU) // @namespace https://github.com/yourname/ps-calendar-to-ics // @version 0.4.1 // @description 将 PeopleSoft「我的每周课程表-列表查看」导出为 ICS 文件(支持中文/英文标签,Asia/Shanghai) // @author You // @match https://scrsprd.zju.edu.cn/psc/CSPRD/EMPLOYEE/HRMS/* // @match file:///* // @run-at document-idle // @grant none // @license MIT // ==/UserScript== (function () { "use strict"; const APP_NAME = "PS Calendar to ICS"; const TZID = "Asia/Shanghai"; // China Standard Time (no DST) const HOST_HINT = "scrsprd.zju.edu.cn"; // 2025-2026学年秋冬学期学术日历 const ACADEMIC_CALENDAR_2025_2026 = { semesterStart: new Date(2025, 8, 15), // 9月15日 semesterEnd: new Date(2026, 0, 10), // 1月10日(包含考试期间) holidays: [ // 中秋节、国庆节放假调休 { start: new Date(2025, 9, 1), end: new Date(2025, 9, 8), name: "中秋节、国庆节放假调休" }, // 元旦放假 { start: new Date(2026, 0, 1), end: new Date(2026, 0, 1), name: "元旦放假" } ], makeupClasses: [ // 9月28日工作日,授10月3日周五课 { date: new Date(2025, 8, 28), originalDay: 5, name: "授10月3日周五课" }, // 5 = Friday // 10月11日工作日,授10月8日周三课 { date: new Date(2025, 9, 11), originalDay: 3, name: "授10月8日周三课" } // 3 = Wednesday ], specialEvents: [ // 新生报到注册 { date: new Date(2025, 7, 22), name: "新生报到注册", type: "allday" }, // 本科生新生始业教育、军训 { start: new Date(2025, 7, 23), end: new Date(2025, 8, 14), name: "本科生新生始业教育、军训", type: "allday" }, // 本科生开学典礼 { date: new Date(2025, 7, 24), name: "本科生开学典礼", type: "allday" }, // UIUC校历课程开始 { date: new Date(2025, 7, 25), name: "UIUC校历课程-课程开始", type: "allday" }, // ZJUI二轮选课开始 { date: new Date(2025, 7, 25), name: "ZJUI二轮选课开始", type: "allday" }, // UIUC校历课程本科生加课截止时间 { date: new Date(2025, 8, 8), name: "UIUC校历课程本科生加课截止时间", type: "allday" }, // 本科生老生报到注册 { date: new Date(2025, 8, 12), name: "本科生老生报到注册", type: "allday" }, // 本科生选课截止 { date: new Date(2025, 8, 19), name: "本科生选课截止", type: "allday" }, // UIUC校历课程本科生退课截止日期 { date: new Date(2025, 9, 17), name: "UIUC校历课程本科生退课截止日期", type: "allday" }, // 秋季校运动会停课 { start: new Date(2025, 9, 24), end: new Date(2025, 9, 26), name: "秋季校运动会停课", type: "no_class" }, // 本科生申请退课截止日期 { date: new Date(2025, 10, 7), name: "本科生申请退课截止日期", type: "allday" }, // 国际校区2025年辞旧迎新活动 { date: new Date(2025, 11, 21), name: "国际校区2025年辞旧迎新活动", type: "allday" }, // 课程结束 { date: new Date(2025, 11, 26), name: "课程结束", type: "allday" }, // 复习与考试 { start: new Date(2025, 11, 29), end: new Date(2025, 11, 30), name: "复习与考试", type: "allday" }, // 浙江大学学生节 { date: new Date(2025, 11, 31), name: "浙江大学学生节", type: "allday" }, // 复习与考试(1月) { start: new Date(2026, 0, 2), end: new Date(2026, 0, 10), name: "复习与考试", type: "allday" } ] }; /** * Main bootstrap function */ function bootstrap() { tryInjectForDocument(window.document); observeForSchedule(window.document); // Handle target iframes const iframeSelector = "iframe.ps_target-iframe"; const iframeList = Array.from(document.querySelectorAll(iframeSelector)); iframeList.forEach((iframe) => attachIframeListener(iframe)); // Observe future iframes const obs = new MutationObserver((mutations) => { for (const m of mutations) { if (m.type === "childList") { m.addedNodes.forEach((node) => { if ( node instanceof HTMLIFrameElement && node.classList.contains("ps_target-iframe") ) { attachIframeListener(node); } }); } } }); obs.observe(document.documentElement || document.body, { childList: true, subtree: true, }); } function attachIframeListener(iframe) { iframe.addEventListener("load", () => { try { const doc = iframe.contentDocument; if (!doc) return; tryInjectForDocument(doc); observeForSchedule(doc); } catch (_) { // ignore cross-origin issues } }); } function observeForSchedule(doc) { const observer = new MutationObserver(() => { if (findScheduleRoot(doc)) { injectExportButton(doc); } }); observer.observe(doc.documentElement || doc.body, { subtree: true, childList: true, attributes: false, }); } function tryInjectForDocument(doc) { if (findScheduleRoot(doc)) { injectExportButton(doc); } } function findScheduleRoot(doc) { // Look for course schedule container based on real HTML structure const selectors = [ 'div[id*="DERIVED_REGFRM1_DESCR20"]', // Course container 'table[id*="CLASS_MTG_VW"]', // Meeting time table 'div[id*="win0divSTDNT_ENRL_SSV2"]', // Original container ]; for (const sel of selectors) { const elements = Array.from(doc.querySelectorAll(sel)); for (const el of elements) { if (isScheduleContainer(el)) { return el; } } } // Fallback: look for elements containing course schedule indicators const fallbackSelectors = [ 'td.PAGROUPDIVIDER', // Course title divider 'th[abbr*="课程号码"]', // Course number header 'th[abbr*="日期和时间"]', // Date and time header ]; for (const sel of fallbackSelectors) { const el = doc.querySelector(sel); if (el) { let parent = el; for (let i = 0; i < 10 && parent; i++) { parent = parent.parentElement; if (parent && isScheduleContainer(parent)) { return parent; } } } } return null; } function isScheduleContainer(el) { const text = cleanText(el.textContent); if (!text) return false; const hasScheduleContent = /课程表|我的课程|课程号码|日期和时间|开始.结束日期|讲师/i.test(text) || /Class Schedule|My Class Schedule|Course|Meeting Times|Days.*Times|Component|Instructor|Laboratory|Lecture/i.test(text); const hasSubstantialContent = text.length > 50; const hasCourseTables = el.querySelectorAll('table[id*="CLASS_MTG_VW"], td.PAGROUPDIVIDER').length > 0; return hasScheduleContent && (hasSubstantialContent || hasCourseTables); } function injectExportButton(doc) { // Avoid duplicate buttons if (doc.querySelector("#ps-ics-export-btn")) return; const scheduleRoot = findScheduleRoot(doc); if (!scheduleRoot) return; // Create export button const btn = doc.createElement("button"); btn.id = "ps-ics-export-btn"; btn.textContent = "导出 ICS"; btn.style.cssText = ` position: fixed; top: 20px; right: 20px; z-index: 10000; padding: 8px 16px; background: #007cba; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 14px; font-family: sans-serif; box-shadow: 0 2px 8px rgba(0,0,0,0.3); `; btn.addEventListener("click", () => { try { console.log(APP_NAME, "开始解析课程表..."); const parsed = parseScheduleFromDocument(doc); console.log(APP_NAME, "解析结果:", parsed); if (!parsed.events || parsed.events.length === 0) { alert("未找到课程信息。请确保您处于\"列表查看\"界面。"); return; } const icsText = buildICS(parsed); const fileName = buildSuggestedFileName(parsed); triggerDownload(icsText, fileName); console.log(APP_NAME, "导出完成,文件名:", fileName); } catch (err) { console.error(APP_NAME, err); alert("导出失败:" + (err && err.message ? err.message : String(err))); } }); // Insert button into the document doc.body.appendChild(btn); console.log(APP_NAME, "导出按钮已注入"); } /** * Parse schedule from document using real HTML structure */ function parseScheduleFromDocument(doc) { const events = []; let termTitle = detectTermTitle(doc); // Find all course containers const courseContainers = Array.from(doc.querySelectorAll('div[id*="DERIVED_REGFRM1_DESCR20"]')); console.log(APP_NAME, `找到 ${courseContainers.length} 个课程容器`); for (const container of courseContainers) { try { const courseEvents = parseCourseContainer(container); events.push(...courseEvents); } catch (err) { console.warn(APP_NAME, "解析课程容器时出错:", err); } } return { termTitle, events }; } function parseCourseContainer(container) { const events = []; // Get course title from PAGROUPDIVIDER const courseTitleElement = container.querySelector('td.PAGROUPDIVIDER'); let currentCourseCode = ""; let currentCourseName = ""; if (courseTitleElement) { const fullTitle = cleanText(courseTitleElement.textContent); // Parse "CS 101 - 计算导论:工程与科学" or "IBMS 7001A - 整合生物医学科学1" format const match = fullTitle.match(/^([A-Z]+\s*\d+[A-Z]*)\s*-\s*(.+)$/); if (match) { currentCourseCode = match[1].trim(); currentCourseName = match[2].trim(); } else { currentCourseName = fullTitle; } } console.log(APP_NAME, `解析课程: ${currentCourseCode} - ${currentCourseName}`); // Find the meeting times table const meetingTable = container.querySelector('table[id*="CLASS_MTG_VW"]'); if (!meetingTable) { console.warn(APP_NAME, "未找到课程时间表"); return events; } // Parse each row of the meeting table const rows = Array.from(meetingTable.querySelectorAll('tr')); let currentComponent = ""; // Track current component for (let i = 1; i < rows.length; i++) { // Skip header row const row = rows[i]; const cells = Array.from(row.querySelectorAll('td')); if (cells.length < 7) continue; try { const classNumber = cleanText(cells[0].textContent); const section = cleanText(cells[1].textContent); const component = cleanText(cells[2].textContent); const dateTime = cleanText(cells[3].textContent); const room = cleanText(cells[4].textContent); const instructor = cleanText(cells[5].textContent); const startEndDate = cleanText(cells[6].textContent); // Update current component if not empty (handles continuation rows) if (component) { currentComponent = component; } // Skip rows without essential information if (!dateTime && !startEndDate) continue; // Parse the event const event = parseScheduleRow({ courseCode: currentCourseCode, courseName: currentCourseName, classNumber, section, component: currentComponent, dateTime, room, instructor, startEndDate }); if (event) { events.push(event); } } catch (err) { console.warn(APP_NAME, "解析课程行时出错:", err); } } return events; } function parseScheduleRow(data) { const { courseCode, courseName, classNumber, section, component, dateTime, room, instructor, startEndDate } = data; // Parse date range const dateRange = parseStartEndDate(startEndDate); if (!dateRange) { console.warn(APP_NAME, "无法解析日期范围:", startEndDate); return null; } // Parse time and days const timeInfo = parseDateTimeInfo(dateTime); if (!timeInfo) { console.warn(APP_NAME, "无法解析时间信息:", dateTime); return null; } // Build event summary using course code + component (calendar-friendly format) let summary = courseCode || "课程"; if (component) { // Use more calendar-friendly format instead of square brackets summary += ` - ${component}`; } if (section) { summary += ` (${section})`; } return { summary, courseCode, courseName, component, section, classNumber, location: room || "", instructor: instructor || "", days: timeInfo.days, startTime: timeInfo.startTime, endTime: timeInfo.endTime, startDate: dateRange.start, endDate: dateRange.end }; } function parseStartEndDate(dateStr) { if (!dateStr) return null; // Parse single date range: "15/09/2025 - 21/09/2025" // Or multiple date ranges: "15/09/2025 - 21/09/2025, 29/09/2025 - 05/10/2025, ..." const dateRangePattern = /(\d{1,2})\/(\d{1,2})\/(\d{4})\s*-\s*(\d{1,2})\/(\d{1,2})\/(\d{4})/g; const matches = Array.from(dateStr.matchAll(dateRangePattern)); if (matches.length === 0) return null; // Parse all date ranges const dateRanges = matches.map(match => { const [, startDay, startMonth, startYear, endDay, endMonth, endYear] = match; return { start: new Date(parseInt(startYear), parseInt(startMonth) - 1, parseInt(startDay)), end: new Date(parseInt(endYear), parseInt(endMonth) - 1, parseInt(endDay)) }; }); // If multiple date ranges, this indicates a biweekly or irregular pattern if (dateRanges.length > 1) { console.log(APP_NAME, `检测到单双周课程模式,共${dateRanges.length}个日期范围`); return { start: dateRanges[0].start, end: dateRanges[dateRanges.length - 1].end, dateRanges: dateRanges, isBiweekly: true }; } // Single date range - use full semester range for regular weekly courses return { start: ACADEMIC_CALENDAR_2025_2026.semesterStart, end: ACADEMIC_CALENDAR_2025_2026.semesterEnd, dateRanges: [dateRanges[0]], isBiweekly: false }; } function parseDateTimeInfo(timeStr) { if (!timeStr) return null; // Parse English format first: "Mo 2:00PM - 3:50PM" or "Mon 14:00 - 15:50" etc. const englishMatch = timeStr.match(/(Su|Mo|Tu|We|Th|Fr|Sa|Sun\.?|Mon\.?|Tue\.?|Tues\.?|Wed\.?|Thu\.?|Thur\.?|Fri\.?|Sat\.?|Sunday|Monday|Tuesday|Wednesday|Thursday|Friday|Saturday)\s+(\d{1,2}):(\d{2})(?:(AM|PM)|)\s*-\s*(\d{1,2}):(\d{2})(?:(AM|PM)|)/); if (englishMatch) { const [, dayAbbr, startHour, startMin, startAmPm, endHour, endMin, endAmPm] = englishMatch; // Convert English day abbreviation to number (0 = Sunday, 1 = Monday, etc.) const dayMap = { 'Su': 0, 'Sun': 0, 'Sun.': 0, 'Sunday': 0, 'Mo': 1, 'Mon': 1, 'Mon.': 1, 'Monday': 1, 'Tu': 2, 'Tue': 2, 'Tue.': 2, 'Tues': 2, 'Tues.': 2, 'Tuesday': 2, 'We': 3, 'Wed': 3, 'Wed.': 3, 'Wednesday': 3, 'Th': 4, 'Thu': 4, 'Thu.': 4, 'Thur': 4, 'Thur.': 4, 'Thursday': 4, 'Fr': 5, 'Fri': 5, 'Fri.': 5, 'Friday': 5, 'Sa': 6, 'Sat': 6, 'Sat.': 6, 'Saturday': 6 }; const dayOfWeek = dayMap[dayAbbr]; if (dayOfWeek === undefined) return null; // Convert to 24-hour format let startHour24 = parseInt(startHour); let endHour24 = parseInt(endHour); // Handle AM/PM if present (12-hour format) if (startAmPm) { if (startAmPm === 'PM' && startHour24 !== 12) startHour24 += 12; if (startAmPm === 'AM' && startHour24 === 12) startHour24 = 0; } if (endAmPm) { if (endAmPm === 'PM' && endHour24 !== 12) endHour24 += 12; if (endAmPm === 'AM' && endHour24 === 12) endHour24 = 0; } // If no AM/PM, assume 24-hour format (already correct) return { days: [dayOfWeek], startTime: { hour: startHour24, minute: parseInt(startMin) }, endTime: { hour: endHour24, minute: parseInt(endMin) } }; } // Parse Chinese format: "星期一 2:00PM - 3:50PM" const chineseMatch = timeStr.match(/星期([一二三四五六日])\s+(\d+):(\d+)(AM|PM)\s*-\s*(\d+):(\d+)(AM|PM)/); if (chineseMatch) { const [, dayChar, startHour, startMin, startAmPm, endHour, endMin, endAmPm] = chineseMatch; // Convert Chinese day to number (0 = Sunday, 1 = Monday, etc.) const dayMap = { '日': 0, '一': 1, '二': 2, '三': 3, '四': 4, '五': 5, '六': 6 }; const dayOfWeek = dayMap[dayChar]; if (dayOfWeek === undefined) return null; // Convert 12-hour to 24-hour format let startHour24 = parseInt(startHour); let endHour24 = parseInt(endHour); if (startAmPm === 'PM' && startHour24 !== 12) startHour24 += 12; if (startAmPm === 'AM' && startHour24 === 12) startHour24 = 0; if (endAmPm === 'PM' && endHour24 !== 12) endHour24 += 12; if (endAmPm === 'AM' && endHour24 === 12) endHour24 = 0; return { days: [dayOfWeek], startTime: { hour: startHour24, minute: parseInt(startMin) }, endTime: { hour: endHour24, minute: parseInt(endMin) } }; } return null; } function detectTermTitle(doc) { const titleCandidates = [ ...Array.from(doc.querySelectorAll("h1, h2, h3, .PATRANSACTIONTITLE, .PTTEXT, .ps_pageheader")), ]; for (const el of titleCandidates) { const t = cleanText(el.textContent); if (!t) continue; if (/学期|学年|Term|Session|课程表|Class Schedule/i.test(t)) { return t; } } const title = (doc.title || "").trim(); if (title) return title; // Detect language and return appropriate default const isEnglish = doc.documentElement.lang === 'en' || doc.querySelector('title')?.textContent?.includes('My Class Schedule') || doc.querySelector('span')?.textContent?.includes('Component'); return isEnglish ? "My Class Schedule" : "我的课程表"; } /** * Academic calendar helper functions */ function isHoliday(date) { for (const holiday of ACADEMIC_CALENDAR_2025_2026.holidays) { if (isDateInRange(date, holiday.start, holiday.end)) { return true; } } return false; } function isMakeupClassDay(date) { for (const makeup of ACADEMIC_CALENDAR_2025_2026.makeupClasses) { if (isSameDate(date, makeup.date)) { return makeup; } } return null; } function isSpecialEvent(date) { for (const event of ACADEMIC_CALENDAR_2025_2026.specialEvents) { if (event.start && event.end) { if (isDateInRange(date, event.start, event.end)) { return event; } } else if (event.date) { if (isSameDate(date, event.date)) { return event; } } } return null; } function shouldSkipDate(date, targetDayOfWeek) { // Skip if it's a holiday if (isHoliday(date)) { return true; } // Skip if it's a special no-class event const specialEvent = isSpecialEvent(date); if (specialEvent && specialEvent.type === 'no_class') { return true; } return false; } /** * Check if event uses weekly recurring pattern or individual date ranges */ function isWeeklyRecurringPattern(event) { // If dateRanges property exists and isBiweekly is true, it's not a weekly recurring pattern return !event.isBiweekly; } /** * Generate individual events for specific date ranges (biweekly courses) */ function generateIndividualEventsForDateRange(event) { const events = []; const targetDayOfWeek = event.days[0]; // Assume single day for biweekly courses for (const dateRange of event.dateRanges) { // Find all occurrences of the target day within this date range let currentDate = new Date(dateRange.start); // Adjust to the first occurrence of the target day while (currentDate.getDay() !== targetDayOfWeek && currentDate <= dateRange.end) { currentDate.setDate(currentDate.getDate() + 1); } // Generate events for all occurrences in this date range while (currentDate <= dateRange.end) { if (!shouldSkipDate(currentDate, targetDayOfWeek)) { events.push({ date: new Date(currentDate), startTime: event.startTime, endTime: event.endTime, summary: event.summary, location: event.location, component: event.component, instructor: event.instructor }); } currentDate.setDate(currentDate.getDate() + 7); // Next week } } return events; } function generateRRuleAndExceptions(event) { // Generate RRULE with EXDATE for holidays and makeup classes const startDate = event.startDate; const endDate = event.endDate; const targetDaysOfWeek = event.days; // Calculate total weeks const totalDays = Math.ceil((endDate - startDate) / (1000 * 60 * 60 * 24)); const totalWeeks = Math.ceil(totalDays / 7); // Find the first occurrence date let firstOccurrence = new Date(startDate); while (!targetDaysOfWeek.includes(firstOccurrence.getDay())) { firstOccurrence.setDate(firstOccurrence.getDate() + 1); } // Generate all basic dates to find exceptions const basicDates = []; const exceptionDates = []; const makeupEvents = []; let currentDate = new Date(firstOccurrence); let weekCount = 0; while (currentDate <= endDate && weekCount < totalWeeks) { const dayOfWeek = currentDate.getDay(); if (targetDaysOfWeek.includes(dayOfWeek)) { basicDates.push(new Date(currentDate)); // Check if this date should be excluded (holiday) if (shouldSkipDate(currentDate, dayOfWeek)) { exceptionDates.push(new Date(currentDate)); } // Move to next week currentDate.setDate(currentDate.getDate() + 7); weekCount++; } else { currentDate.setDate(currentDate.getDate() + 1); } } // Find makeup classes let checkDate = new Date(startDate); while (checkDate <= endDate) { const makeupClass = isMakeupClassDay(checkDate); if (makeupClass && targetDaysOfWeek.includes(makeupClass.originalDay)) { makeupEvents.push({ date: new Date(checkDate), originalDay: makeupClass.originalDay, note: `补${getChineseDayName(makeupClass.originalDay)}的课` }); } checkDate.setDate(checkDate.getDate() + 1); } return { firstOccurrence, weekCount: Math.max(1, weekCount), dayOfWeek: firstOccurrence.getDay(), exceptionDates, makeupEvents }; } function getDayName(dayOfWeek) { const days = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA']; return days[dayOfWeek]; } function getChineseDayName(dayOfWeek) { const names = ['日', '一', '二', '三', '四', '五', '六']; return names[dayOfWeek]; } /** * Build ICS text from parsed data */ function buildICS(parsed) { const lines = []; const now = new Date(); const dtstamp = toUTCStringBasic(now); lines.push("BEGIN:VCALENDAR"); lines.push("PRODID:-//" + APP_NAME + " (iZJU)//EN"); lines.push("VERSION:2.0"); lines.push("CALSCALE:GREGORIAN"); // Add timezone definition lines.push("BEGIN:VTIMEZONE"); lines.push("TZID:" + TZID); lines.push("BEGIN:STANDARD"); lines.push("DTSTART:19700101T000000"); lines.push("TZOFFSETFROM:+0800"); lines.push("TZOFFSETTO:+0800"); lines.push("TZNAME:CST"); lines.push("END:STANDARD"); lines.push("END:VTIMEZONE"); // Group events by unique course to reduce file size // Use base course info without section to avoid duplicates const eventGroups = new Map(); for (const ev of parsed.events) { // Build base summary without section for grouping let baseSummary = ev.courseCode || "课程"; if (ev.component) { baseSummary += ` - ${ev.component}`; } // Don't include section in grouping key to merge similar courses const groupKey = `${baseSummary}|${ev.location}|${ev.instructor}|${ev.days.join(',')}`; if (!eventGroups.has(groupKey)) { eventGroups.set(groupKey, []); } eventGroups.get(groupKey).push(ev); } console.log(APP_NAME, `将 ${parsed.events.length} 个事件分组为 ${eventGroups.size} 组`); // Process each group - use different strategies for regular vs biweekly courses for (const [groupKey, events] of eventGroups) { // Prefer events with section info (more specific) over those without const representativeEvent = events.find(ev => ev.section) || events[0]; if (isWeeklyRecurringPattern(representativeEvent)) { // Regular weekly courses: use RRULE + EXDATE const rruleData = generateRRuleAndExceptions(representativeEvent); console.log(APP_NAME, `常规课程 "${representativeEvent.summary}": ${rruleData.weekCount} 周, ${rruleData.exceptionDates.length} 个例外, ${rruleData.makeupEvents.length} 个补课`); // Create main recurring event const dtStart = combineDateAndTime(rruleData.firstOccurrence, representativeEvent.startTime); const dtEnd = combineDateAndTime(rruleData.firstOccurrence, representativeEvent.endTime); lines.push("BEGIN:VEVENT"); lines.push("UID:" + buildUID(representativeEvent, now, 0)); lines.push("DTSTAMP:" + dtstamp + "Z"); lines.push("DTSTART;TZID=" + TZID + ":" + toLocalStringBasic(dtStart)); lines.push("DTEND;TZID=" + TZID + ":" + toLocalStringBasic(dtEnd)); // Add RRULE const dayName = getDayName(rruleData.dayOfWeek); lines.push(`RRULE:FREQ=WEEKLY;COUNT=${rruleData.weekCount};BYDAY=${dayName}`); // Add EXDATE for holidays if (rruleData.exceptionDates.length > 0) { const exdates = rruleData.exceptionDates .map(date => toLocalStringBasic(combineDateAndTime(date, representativeEvent.startTime))) .join(","); lines.push(`EXDATE;TZID=${TZID}:${exdates}`); } lines.push("SUMMARY:" + escapeICSText(representativeEvent.summary)); if (representativeEvent.location) { lines.push("LOCATION:" + escapeICSText(representativeEvent.location)); } // Compact description let desc = []; if (representativeEvent.component) desc.push(`类型: ${representativeEvent.component}`); if (representativeEvent.instructor) desc.push(`讲师: ${representativeEvent.instructor}`); if (desc.length > 0) { lines.push("DESCRIPTION:" + escapeICSText(desc.join("\n"))); } lines.push("END:VEVENT"); // Add makeup events as separate events for (let i = 0; i < rruleData.makeupEvents.length; i++) { const makeupEvent = rruleData.makeupEvents[i]; const makeupStart = combineDateAndTime(makeupEvent.date, representativeEvent.startTime); const makeupEnd = combineDateAndTime(makeupEvent.date, representativeEvent.endTime); lines.push("BEGIN:VEVENT"); lines.push("UID:" + buildUID(representativeEvent, now, `makeup-${i}`)); lines.push("DTSTAMP:" + dtstamp + "Z"); lines.push("DTSTART;TZID=" + TZID + ":" + toLocalStringBasic(makeupStart)); lines.push("DTEND;TZID=" + TZID + ":" + toLocalStringBasic(makeupEnd)); lines.push("SUMMARY:" + escapeICSText(representativeEvent.summary + " (调课)")); if (representativeEvent.location) { lines.push("LOCATION:" + escapeICSText(representativeEvent.location)); } let makeupDesc = []; if (representativeEvent.component) makeupDesc.push(`类型: ${representativeEvent.component}`); if (representativeEvent.instructor) makeupDesc.push(`讲师: ${representativeEvent.instructor}`); makeupDesc.push(`注: ${makeupEvent.note}`); lines.push("DESCRIPTION:" + escapeICSText(makeupDesc.join("\n"))); lines.push("END:VEVENT"); } } else { // Biweekly courses: generate individual events const individualEvents = generateIndividualEventsForDateRange(representativeEvent); console.log(APP_NAME, `单双周课程 "${representativeEvent.summary}": 生成 ${individualEvents.length} 个独立事件`); for (let i = 0; i < individualEvents.length; i++) { const event = individualEvents[i]; const dtStart = combineDateAndTime(event.date, event.startTime); const dtEnd = combineDateAndTime(event.date, event.endTime); lines.push("BEGIN:VEVENT"); lines.push("UID:" + buildUID(representativeEvent, now, `biweekly-${i}`)); lines.push("DTSTAMP:" + dtstamp + "Z"); lines.push("DTSTART;TZID=" + TZID + ":" + toLocalStringBasic(dtStart)); lines.push("DTEND;TZID=" + TZID + ":" + toLocalStringBasic(dtEnd)); lines.push("SUMMARY:" + escapeICSText(event.summary)); if (event.location) { lines.push("LOCATION:" + escapeICSText(event.location)); } // Add description with course info let desc = []; if (event.component) desc.push(`类型: ${event.component}`); if (event.instructor) desc.push(`讲师: ${event.instructor}`); if (desc.length > 0) { lines.push("DESCRIPTION:" + escapeICSText(desc.join("\n"))); } lines.push("END:VEVENT"); } } } // Add special events (holidays, campus activities) - simplified addSpecialEventsToICS(lines, now, dtstamp); lines.push("END:VCALENDAR"); console.log(APP_NAME, `生成的 ICS 内容长度: ${lines.join('\r\n').length} 字符`); return lines.join("\r\n"); } function addSpecialEventsToICS(lines, now, dtstamp) { // Only add the most important events to keep file small const importantEvents = [ { date: "2025-08-24", name: "本科生开学典礼" }, { date: "2025-09-15", name: "秋冬学期课程开始" }, { date: "2025-12-26", name: "课程结束" }, { date: "2025-12-31", name: "浙江大学学生节" } ]; for (const event of importantEvents) { lines.push("BEGIN:VEVENT"); lines.push("UID:" + event.name.replace(/\s/g, '') + "@ps-calendar"); lines.push("DTSTAMP:" + dtstamp + "Z"); lines.push("DTSTART;VALUE=DATE:" + event.date.replace(/-/g, '')); lines.push("SUMMARY:" + escapeICSText(event.name)); lines.push("TRANSP:TRANSPARENT"); lines.push("END:VEVENT"); } } function buildUID(ev, now, index = 0) { const base = [ ev.courseCode || ev.summary, ev.component || "", ev.section || "", ev.location, ev.instructor, ev.days.join(""), index.toString() ].filter(Boolean).join("-"); const hash = simpleHash(base); const timestamp = Math.floor(now.getTime() / 1000); return `${hash}-${timestamp}@${HOST_HINT}`; } function buildSpecialEventUID(event, now, date = null) { const base = [ event.name, event.type || "", date ? toDateString(date) : toDateString(event.date || event.start) ].filter(Boolean).join("-"); const hash = simpleHash(base); const timestamp = Math.floor(now.getTime() / 1000); return `special-${hash}-${timestamp}@${HOST_HINT}`; } function buildSuggestedFileName(parsed) { const termPart = parsed.termTitle || "课程表"; const now = new Date(); const datePart = `${now.getFullYear()}-${(now.getMonth() + 1).toString().padStart(2, '0')}-${now.getDate().toString().padStart(2, '0')}`; return `iZJU-${termPart}-${datePart}.ics`; } function triggerDownload(content, filename) { const blob = new Blob([content], { type: "text/calendar;charset=utf-8" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = filename; a.click(); URL.revokeObjectURL(url); } // Helper functions function cleanText(text) { return (text || "").replace(/\s+/g, " ").trim(); } function combineDateAndTime(date, time) { const result = new Date(date); if (!time || typeof time !== 'object') { console.error(APP_NAME, "Invalid time object:", time); return result; } const hour = time.hour !== undefined ? time.hour : time.h; const minute = time.minute !== undefined ? time.minute : time.m; if (hour === undefined || minute === undefined || isNaN(hour) || isNaN(minute)) { console.error(APP_NAME, "Invalid time values:", { hour, minute, originalTime: time }); return result; } result.setHours(Number(hour), Number(minute), 0, 0); return result; } function toLocalStringBasic(date) { return date.getFullYear() + (date.getMonth() + 1).toString().padStart(2, '0') + date.getDate().toString().padStart(2, '0') + 'T' + date.getHours().toString().padStart(2, '0') + date.getMinutes().toString().padStart(2, '0') + date.getSeconds().toString().padStart(2, '0'); } function toUTCStringBasic(date) { return date.getUTCFullYear() + (date.getUTCMonth() + 1).toString().padStart(2, '0') + date.getUTCDate().toString().padStart(2, '0') + 'T' + date.getUTCHours().toString().padStart(2, '0') + date.getUTCMinutes().toString().padStart(2, '0') + date.getUTCSeconds().toString().padStart(2, '0'); } function toDateString(date) { return date.getFullYear() + (date.getMonth() + 1).toString().padStart(2, '0') + date.getDate().toString().padStart(2, '0'); } function foldLine(text) { // ICS line folding: max 75 octets per line if (!text) return ""; // Escape special characters for better calendar compatibility text = escapeICSText(text); const maxLen = 73; // Leave room for CRLF if (text.length <= maxLen) return text; const lines = []; let pos = 0; while (pos < text.length) { if (pos === 0) { lines.push(text.substring(pos, pos + maxLen)); pos += maxLen; } else { lines.push(" " + text.substring(pos, pos + maxLen - 1)); pos += maxLen - 1; } } return lines.join("\r\n"); } function escapeICSText(text) { if (!text) return ""; // RFC 5545 compliant text escaping return text .replace(/\\/g, "\\\\") // Escape backslashes first .replace(/,/g, "\\,") // Escape commas .replace(/;/g, "\\;") // Escape semicolons .replace(/\n/g, "\\n") // Escape newlines .replace(/\r/g, ""); // Remove carriage returns } function formatDescription(parts) { if (!parts || parts.length === 0) return ""; return parts.join("\n"); } function simpleHash(str) { let hash = 0; for (let i = 0; i < str.length; i++) { const char = str.charCodeAt(i); hash = ((hash << 5) - hash) + char; hash = hash & hash; // Convert to 32-bit integer } return Math.abs(hash).toString(36); } function isSameDate(date1, date2) { return date1.getFullYear() === date2.getFullYear() && date1.getMonth() === date2.getMonth() && date1.getDate() === date2.getDate(); } function isDateInRange(date, start, end) { return date >= start && date <= end; } // Start the script if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", bootstrap); } else { bootstrap(); } })();