Instantly share code, notes, and snippets.
Last active
November 28, 2025 00:06
-
Star
0
(0)
You must be signed in to star a gist -
Fork
0
(0)
You must be signed in to fork a gist
-
-
Save mark05e/fe76e1f27e15477983e9ea1844aff254 to your computer and use it in GitHub Desktop.
Adds a button to TPL event pages to quickly add the event details to Google Calendar.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name TPL Event Google Calendar Button | |
| // @namespace http://tampermonkey.net/ | |
| // @version 1.0 | |
| // @description Adds a button to TPL event pages to quickly add the event details to Google Calendar. | |
| // @author mark05e & Gemini | |
| // @match https://www.torontopubliclibrary.ca/detail.jsp?*Entt=RDMEVT* | |
| // @run-at document-idle | |
| // @updateURL https://gist.github.com/mark05e/fe76e1f27e15477983e9ea1844aff254/raw | |
| // ==/UserScript== | |
| const BASE_URL = "http://www.torontopubliclibrary.ca"; | |
| /** | |
| * Helper function to convert a relative path to an absolute URL. | |
| * @param {string} relativePath - The path to convert. | |
| * @returns {string} The full absolute URL. | |
| */ | |
| function toAbsoluteUrl(relativePath) { | |
| if (!relativePath || relativePath.startsWith('http')) { | |
| return relativePath; // Already absolute or null | |
| } | |
| // Ensure no double slashes | |
| const cleanedPath = relativePath.startsWith('/') ? relativePath.substring(1) : relativePath; | |
| return `${BASE_URL}/${cleanedPath}`; | |
| } | |
| // --- Extraction Functions --- | |
| function extractTitle() { | |
| const titleElement = document.querySelector('h1.title'); | |
| return titleElement ? titleElement.textContent.trim() : "Title not found"; | |
| } | |
| function extractDateTime() { | |
| const container = document.querySelector(".event-date-time p"); | |
| if (!container) return "Date/Time not found"; | |
| // Step 1: Normalize the HTML to text with clear line breaks | |
| let text = container.innerHTML | |
| .replace(/<br\s*\/?>/gi, "\n") // Convert <br> to newlines | |
| .replace(/<[^>]+>/g, "") // Remove any remaining tags | |
| .replace(/\s+/g, " ") // Normalize whitespace | |
| .trim(); | |
| // Example normalized text: | |
| // "Mon. Dec. 01, 2025 6:00 p.m. - 8:00 p.m. 120 mins" | |
| // Step 2: Extract the date (weekday + month + day + year) | |
| const dateRegex = | |
| /([A-Z][a-z]{2}\.\s+[A-Z][a-z]{2,4}\.\s+\d{1,2},\s+\d{4})/; | |
| // Step 3: Extract start/end times | |
| const timeRegex = | |
| /(\d{1,2}:\d{2}\s*p\.m\.)\s*-\s*(\d{1,2}:\d{2}\s*p\.m\.)/i; | |
| const dateMatch = text.match(dateRegex); | |
| const timeMatch = text.match(timeRegex); | |
| if (!dateMatch || !timeMatch) { | |
| return { | |
| fullText: "Date-Time not found (format mismatch)", | |
| datePart: null, | |
| startTime: null, | |
| endTime: null | |
| }; | |
| } | |
| const date = dateMatch[1].trim(); | |
| const startTime = timeMatch[1].trim(); | |
| const endTime = timeMatch[2].trim(); | |
| return { | |
| fullText: `${date} ${startTime} - ${endTime}`, | |
| datePart: date, | |
| startTime, | |
| endTime | |
| }; | |
| } | |
| /** | |
| * Converts a date/time string (e.g., "Mon. Dec. 01, 2025" and "6:00 p.m.") to Google Calendar's YYYYMMDDTHHMMSS format. | |
| * Assumes North American standard time formatting. | |
| */ | |
| function formatToGoogleCalendarTime(dateStr, timeStr) { | |
| if (!dateStr || !timeStr) return null; | |
| // Mapping month abbreviations to numbers (without periods for robustness) | |
| const monthMap = { | |
| 'Jan': 0, 'Feb': 1, 'Mar': 2, 'Apr': 3, 'May': 4, 'Jun': 5, | |
| 'Jul': 6, 'Aug': 7, 'Sep': 8, 'Oct': 9, 'Nov': 10, 'Dec': 11 | |
| }; | |
| // Extract parts: Day, Month, Date, Year | |
| // Regex captures the month name without the trailing period, e.g., 'Dec' from 'Mon. Dec. 01, 2025' | |
| const dateParts = dateStr.match(/\w+\.\s*(\w+)\.?\s*(\d+),\s*(\d{4})/); | |
| if (!dateParts) return null; | |
| // Fix: Use the captured month name group (index 1) and trim periods/spaces | |
| const monthName = dateParts[1].replace('.', '').trim(); | |
| const dayOfMonth = parseInt(dateParts[2], 10); | |
| const year = parseInt(dateParts[3], 10); | |
| const month = monthMap[monthName] !== undefined ? monthMap[monthName] : null; | |
| if (month === null) return null; | |
| // Extract time parts: HH, MM, AM/PM | |
| const timeParts = timeStr.match(/(\d{1,2}):(\d{2})\s*(a\.m\.|p\.m\.)/i); | |
| if (!timeParts) return null; | |
| let hours = parseInt(timeParts[1], 10); | |
| const minutes = parseInt(timeParts[2], 10); | |
| const ampm = timeParts[3].toLowerCase(); | |
| // Convert to 24-hour format | |
| if (ampm === 'p.m.' && hours !== 12) { | |
| hours += 12; | |
| } else if (ampm === 'a.m.' && hours === 12) { | |
| hours = 0; // Midnight | |
| } | |
| // Create Date object (Local time) | |
| // Note: Date constructor with year, month, day is local time | |
| const date = new Date(year, month, dayOfMonth, hours, minutes, 0); | |
| // Format to YYYYMMDDTHHMMSS (Google prefers this without time zone for all-day or simple events) | |
| const y = date.getFullYear(); | |
| const m = String(date.getMonth() + 1).padStart(2, '0'); | |
| const d = String(date.getDate()).padStart(2, '0'); | |
| const h = String(date.getHours()).padStart(2, '0'); | |
| const min = String(date.getMinutes()).padStart(2, '0'); | |
| // Seconds are omitted | |
| return `${y}${m}${d}T${h}${min}00`; // Always padding seconds with 00 | |
| } | |
| function generateGoogleCalendarUrl(eventData) { | |
| const dateTimes = eventData.DateTime; | |
| const startFormatted = formatToGoogleCalendarTime(dateTimes.datePart, dateTimes.startTime); | |
| const endFormatted = formatToGoogleCalendarTime(dateTimes.datePart, dateTimes.endTime); | |
| if (!startFormatted || !endFormatted) { | |
| console.error("Cannot generate Calendar URL: Date/Time formatting failed."); | |
| return '#'; | |
| } | |
| // Get the current page URL to link back to the full details | |
| const sourceUrl = window.location.href; | |
| // Google Calendar URL structure: | |
| const baseURL = "https://calendar.google.com/calendar/render?action=TEMPLATE"; | |
| const datesParam = `&dates=${startFormatted}/${endFormatted}`; | |
| const textParam = `&text=${encodeURIComponent(eventData.Title)}`; | |
| // --- MODIFIED LOGIC START --- | |
| let detailsHeader = ""; | |
| // 1. Add Registration Link (if available) | |
| if (eventData.Links && eventData.Links.url) { | |
| detailsHeader += `REGISTER HERE:\n${eventData.Links.url}\n\n`; | |
| } | |
| // 2. Add Full Details Link (always available) | |
| detailsHeader += `FULL DETAILS:\n${sourceUrl}\n\n`; | |
| // 3. Construct the description string: Header + Description body | |
| let fullDescriptionText = detailsHeader + eventData.Description; | |
| // 4. Add Series link (if available) at the end | |
| if (eventData.Series && eventData.Series.length > 0) { | |
| // If the description ends cleanly, add two newlines before series info | |
| fullDescriptionText += '\n\n' + eventData.Series.map(series => | |
| `PART OF SERIES: ${series.name}\n${series.url}` | |
| ).join('\n\n'); | |
| } | |
| // DEBUG: Show the text that contains the newlines (\n\n) before encoding. | |
| console.log('DEBUG: Description Text (before URL encoding):', JSON.stringify(fullDescriptionText)); | |
| // 5. Encode the result (This will convert \n to %0A) | |
| const detailsParam = `&details=${encodeURIComponent(fullDescriptionText)}`; | |
| // DEBUG: Show the final encoded string in the URL parameter. | |
| console.log('DEBUG: Encoded Details Parameter (full query part):', detailsParam); | |
| // --- MODIFIED LOGIC END --- | |
| const locationParam = `&location=${encodeURIComponent(eventData.Location.name + ', ' + eventData.Location.specificRoom)}`; | |
| return `${baseURL}${datesParam}${textParam}${detailsParam}${locationParam}`; | |
| } | |
| function extractLocation() { | |
| const branchLink = document.querySelector('.event-location a.branch-link'); | |
| // Extract the lab name. We look for the text node containing "Elearning Lab" | |
| let lab = 'Elearning Lab'; | |
| const branchContainer = document.querySelector('.event-location p'); | |
| if (branchContainer) { | |
| const textNode = Array.from(branchContainer.childNodes).find(node => | |
| node.nodeType === 3 && node.textContent.trim().includes('Elearning Lab') | |
| ); | |
| if (textNode) { | |
| // Extract the specific room name from the messy text content | |
| lab = textNode.textContent.trim().replace('Elearning Lab', '').trim() === '' | |
| ? 'Elearning Lab' | |
| : textNode.textContent.trim().replace(/\s+/g, ' '); // General cleanup | |
| } | |
| } | |
| const library = branchLink ? branchLink.textContent.trim() : 'Library not found'; | |
| const libraryLinkRelative = branchLink ? branchLink.getAttribute('href') : null; | |
| return { | |
| url: toAbsoluteUrl(libraryLinkRelative), | |
| name: library, | |
| specificRoom: lab | |
| }; | |
| } | |
| function extractLinks() { | |
| const linkElement = document.querySelector('.record-detail a[href*="eventbrite.ca"]'); | |
| if (linkElement) { | |
| return { | |
| name: linkElement.textContent.trim(), | |
| url: linkElement.getAttribute('href') // Already absolute | |
| }; | |
| } | |
| return { | |
| name: "Register Here (Link not found)", | |
| url: null | |
| }; | |
| } | |
| function extractSeries() { | |
| const seriesLinks = document.querySelectorAll('.part-of a'); | |
| if (seriesLinks.length === 0) { | |
| return []; | |
| } | |
| const seriesData = Array.from(seriesLinks).map(a => ({ | |
| name: a.textContent.trim(), | |
| url: toAbsoluteUrl(a.getAttribute('href')) | |
| })); | |
| return seriesData; | |
| } | |
| function extractDescription() { | |
| const descriptionContainer = document.getElementById('event-description'); | |
| if (!descriptionContainer) { | |
| return "Description not found"; | |
| } | |
| // Get all <p> elements | |
| const paragraphs = descriptionContainer.querySelectorAll('p'); | |
| // CASE 1: There are <p> tags — process them for inline <br> formatting | |
| if (paragraphs.length > 0) { | |
| return Array.from(paragraphs) | |
| .map(p => { | |
| let html = p.innerHTML; | |
| // Convert <br> into newlines | |
| // Replaces <br>, <br/>, <br > etc. with double newlines for separation | |
| html = html.replace(/<br\s*\/?>/gi, '\n\n'); | |
| // Remove any leftover HTML tags (e.g., <strong>, <em>, <a>) | |
| html = html.replace(/<[^>]+>/g, ''); | |
| // Cleanup extra newlines, ensuring only double newlines for paragraph breaks | |
| html = html.trim().replace(/\n{3,}/g, '\n\n'); | |
| return html; | |
| }) | |
| .join('\n\n'); // Separate text from different <p> elements with double newlines | |
| } | |
| // CASE 2: No <p> tags — fallback logic to process raw content with line breaks | |
| let content = descriptionContainer.innerHTML; | |
| // Replace multiple spaces with a single space | |
| content = content.replace(/\s+/g, ' '); | |
| // Convert <br> tags to double newlines | |
| content = content.replace(/<br\s*\/?>/gi, '\n\n'); | |
| // Remove any leftover HTML tags | |
| content = content.replace(/<[^>]+>/g, ''); | |
| // Clean up | |
| content = content.trim().replace(/\n{3,}/g, '\n\n'); | |
| return content || "Description not found (No content)"; | |
| } | |
| // --- Initialization and Rendering --- | |
| (function() { | |
| // Wrap the main execution in a slight delay to ensure the DOM is fully ready | |
| setTimeout(() => { | |
| // 1. Create the "Add to Calendar" button element | |
| const calendarButton = document.createElement('a'); | |
| // Use the 'cursor-pointer' utility for better UX | |
| calendarButton.href = '#'; | |
| calendarButton.target = '_blank'; | |
| calendarButton.className = 'inline-flex items-center space-x-2 px-4 py-2 bg-indigo-600 text-white font-semibold rounded-full shadow-lg hover:bg-indigo-700 transition duration-150 transform hover:scale-[1.01] text-sm mt-4 cursor-pointer'; | |
| calendarButton.innerHTML = ` | |
| <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" width="20" height="20"> | |
| <path fill-rule="evenodd" d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" clip-rule="evenodd" /> | |
| </svg> | |
| <span>Add to Google Calendar</span> | |
| `; | |
| // 2. Add click handler to extract data, generate URL, and navigate | |
| calendarButton.addEventListener('click', (e) => { | |
| e.preventDefault(); // Prevent default link behavior | |
| // Data Extraction is now done on click | |
| const eventData = { | |
| Title: extractTitle(), | |
| DateTime: extractDateTime(), | |
| Location: extractLocation(), | |
| Description: extractDescription(), | |
| Links: extractLinks(), | |
| Series: extractSeries() | |
| }; | |
| // Generate URL on click | |
| const calendarUrl = generateGoogleCalendarUrl(eventData); | |
| console.log('DEBUG: Calendar button clicked. Generated URL:', calendarUrl); | |
| // Open the new tab if a valid URL was generated | |
| if (calendarUrl && calendarUrl !== '#') { | |
| window.open(calendarUrl, '_blank'); | |
| } else { | |
| console.error("Calendar URL generation failed on click."); | |
| } | |
| }); | |
| // 3. Inject the button | |
| const dateTimeContainer = document.querySelector('.event-date-time'); | |
| if (dateTimeContainer) { | |
| // Inject button | |
| dateTimeContainer.appendChild(calendarButton); | |
| // Add utility classes to ensure the button displays below the text | |
| dateTimeContainer.classList.add('flex', 'flex-col', 'items-start'); | |
| } else { | |
| console.error("DOM Error: Could not find injection target '.event-date-time'."); | |
| } | |
| }, 50); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment