Skip to content

Instantly share code, notes, and snippets.

@mark05e
Last active November 28, 2025 00:06
Show Gist options
  • Select an option

  • Save mark05e/fe76e1f27e15477983e9ea1844aff254 to your computer and use it in GitHub Desktop.

Select an option

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.
// ==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