1/* CTSFW Shared Events Modal (patched for UX & branding) */ 2(() => { 3 const qs = (s, r = document) => r.querySelector(s); 4 const qsa = (s, r = document) => Array.from(r.querySelectorAll(s)); 5 const calendarIcon = (name) => { 6 const template = qs(`[data-ctsfw-modal-icon="${name}"]`); 7 const icon = template && template.content ? template.content.firstElementChild : null; 8 return icon ? icon.cloneNode(true) : null; 9 }; 10 11 const getShell = () => ({ 12 modal: qs('[data-ctsfw-modal]'), 13 overlay: qs('[data-ctsfw-modal-overlay]'), 14 close: qs('[data-ctsfw-modal-close]'), 15 header: qs('[data-ctsfw-modal-header]'), 16 title: qs('[data-ctsfw-modal-title]'), 17 list: qs('[data-ctsfw-modal-events]') 18 }); 19 20 // ---- time helpers (unchanged) ---- 21 const toISO = (v) => { if (!v) return ''; const d = new Date(v); return isNaN(d) ? '' : d.toISOString(); }; 22 const toYMD = (iso) => { 23 const raw = String(iso || ''); 24 const match = raw.match(/^(\d{4})-(\d{2})-(\d{2})/); 25 if (match) return match.slice(1).join(''); 26 const d = new Date(raw); 27 return isNaN(d) ? '' : [d.getUTCFullYear(), String(d.getUTCMonth() + 1).padStart(2, '0'), String(d.getUTCDate()).padStart(2, '0')].join(''); 28 }; 29 const fmtZ = (iso) => { const d = new Date(iso); if (isNaN(d)) return ''; const p = n => (n < 10 ? '0' : '') + n; return d.getUTCFullYear() + p(d.getUTCMonth() + 1) + p(d.getUTCDate()) + 'T' + p(d.getUTCHours()) + p(d.getUTCMinutes()) + p(d.getUTCSeconds()) + 'Z'; }; 30 const isoTime = (iso) => (String(iso || '').match(/T(\d{2}):(\d{2})(?::(\d{2}))?/) || []).slice(1).map(Number); 31 const nextYMD = (ymd) => { const d = new Date(`${ymd.slice(0, 4)}-${ymd.slice(4, 6)}-${ymd.slice(6, 8)}T00:00:00Z`); d.setUTCDate(d.getUTCDate() + 1); return toYMD(d.toISOString()); }; 32 const isAllDay = (sISO, eISO) => { 33 const [sh, sm, ss = 0] = isoTime(sISO); 34 if (sh !== 0 || sm !== 0 || ss !== 0) return false; 35 if (!eISO) return true; 36 const [eh, em] = isoTime(eISO); 37 return (eh === 23 && em === 59) || (eh === 0 && em === 0); 38 }; 39 40 const escICS = (s = '') => String(s) 41 .replace(/\\/g, '\\\\') 42 .replace(/\r\n|\r|\n/g, '\\n') 43 .replace(/([,;])/g, '\\$1'); 44 const safeHref = (value, fallback = '#') => { 45 try { 46 const url = new URL(String(value || ''), window.location.href); 47 return ['https:', 'http:', 'data:'].includes(url.protocol) ? url.href : fallback; 48 } catch (_) { return fallback; } 49 }; 50 51 const buildGCalUrl = ({ title = '', url = '', startISO = '', endISO = '', location = '', desc = '' }) => { 52 let u = 'https://calendar.google.com/calendar/render?action=TEMPLATE'; 53 u += '&text=' + encodeURIComponent(title || 'Event'); 54 const details = [url, desc].filter(Boolean).join('\n\n'); 55 if (details) u += '&details=' + encodeURIComponent(details); 56 if (location) u += '&location=' + encodeURIComponent(location); 57 58 if (startISO) { 59 if (isAllDay(startISO, endISO)) { 60 const s = toYMD(startISO); 61 let e = endISO ? toYMD(endISO) : ''; 62 if (!e || e === s) e = nextYMD(s); 63 u += '&dates=' + encodeURIComponent(`${s}/${e}`); 64 } else { 65 const s = fmtZ(startISO); 66 const e = fmtZ(endISO || startISO); 67 if (s && e) u += '&dates=' + encodeURIComponent(`${s}/${e}`); 68 } 69 } 70 return u; 71 }; 72 73 const buildICSDataUrl = ({ title = '', url = '', startISO = '', endISO = '', location = '', desc = '' }) => { 74 const nowISO = new Date().toISOString(); 75 const uid = (Math.random().toString(36).slice(2)) + '@ctsfw'; 76 const lines = [ 77 'BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//CTFW//Event//EN', 'CALSCALE:GREGORIAN', 'METHOD:PUBLISH', 78 'BEGIN:VEVENT', 'UID:' + uid, 'DTSTAMP:' + fmtZ(nowISO) 79 ]; 80 if (startISO) { 81 if (isAllDay(startISO, endISO)) { 82 const s = toYMD(startISO); 83 let e = endISO ? toYMD(endISO) : ''; 84 if (!e || e === s) e = nextYMD(s); 85 lines.push('DTSTART;VALUE=DATE:' + s, 'DTEND;VALUE=DATE:' + e); 86 } else { 87 const s = fmtZ(startISO), e = fmtZ(endISO || startISO); 88 if (s) lines.push('DTSTART:' + s); 89 if (e) lines.push('DTEND:' + e); 90 } 91 } 92 lines.push('SUMMARY:' + escICS(title || 'Event')); 93 if (location) lines.push('LOCATION:' + escICS(location)); 94 if (desc) lines.push('DESCRIPTION:' + escICS(desc)); 95 if (url) lines.push('URL:' + escICS(url)); 96 lines.push('END:VEVENT', 'END:VCALENDAR'); 97 return 'data:text/calendar;charset=utf-8,' + encodeURIComponent(lines.join('\r\n')); 98 }; 99 100 // ---------- Render + open/close ---------- 101 function ensureChrome() { 102 const { modal, overlay, title, header, close } = getShell(); 103 if (!modal || !overlay) return; 104 105 // Title: ensure exists + correct text & classes 106 let t = title || (header && header.querySelector('[data-ctsfw-modal-title]')); 107 if (!t) { 108 // create a header/title if not present 109 const hdr = header || document.createElement('div'); 110 hdr.setAttribute('data-ctsfw-modal-header', ''); 111 if (!header) modal.prepend(hdr); 112 113 t = document.createElement('h2'); 114 t.setAttribute('data-ctsfw-modal-title', ''); 115 hdr.appendChild(t); 116 } 117 t.textContent = 'Add Event(s) to Calendar'; 118 t.className = 'ctsfw-modal__title'; 119 120 // Close button: inject if missing 121 let x = close; 122 if (!x) { 123 x = document.createElement('button'); 124 x.type = 'button'; 125 x.setAttribute('data-ctsfw-modal-close', ''); 126 x.setAttribute('aria-label', 'Close'); 127 x.className = 'ctsfw-modal-close'; // styled in CSS below 128 x.textContent = 'Ã'; 129 modal.prepend(x); 130 } 131 } 132 133 function hydrateItems(listEl, items) { 134 listEl.innerHTML = '';
135 items.forEach((evt) => { 136 const title = evt.title ?? ''; 137 const startISO = evt.startISO ?? ''; 138 const endISO = evt.endISO ?? ''; 139 const location = evt.location ?? ''; 140 const url = evt.url ?? ''; 141 const desc = evt.desc ?? ''; 142 143 const gcalUrl = evt.gcalUrl || buildGCalUrl({ title, url, startISO, endISO, location, desc }); 144 const icsUrl = evt.icsUrl || buildICSDataUrl({ title, url, startISO, endISO, location, desc }); 145 146 const li = document.createElement('li'); 147 const row = document.createElement('div'); 148 row.className = 'ctsfw-event-row'; 149 const info = document.createElement('div'); 150 info.className = 'ctsfw-event-info'; 151 const titleEl = document.createElement('div'); 152 titleEl.className = 'ctsfw-event-title'; 153 titleEl.textContent = title; 154 const meta = document.createElement('div'); 155 meta.className = 'ctsfw-event-meta'; 156 meta.textContent = `${startISO ? new Date(startISO).toLocaleString() : ''}${location ? ' · ' + location : ''}`; 157 info.append(titleEl, meta); 158 const links = document.createElement('div'); 159 links.className = 'ctsfw-cal-links'; 160 const google = document.createElement('a'); 161 google.className = 'ctsfw-cal-link ctsfw-cal-link--google'; 162 google.href = safeHref(gcalUrl); 163 google.target = '_blank'; 164 google.rel = 'noopener'; 165 google.setAttribute('aria-label', 'Add to Google Calendar'); 166 google.title = 'Add to Google Calendar'; 167 const googleIcon = calendarIcon('google'); 168 if (googleIcon) google.appendChild(googleIcon); else google.textContent = 'Google'; 169 const apple = document.createElement('a'); 170 apple.className = 'ctsfw-cal-link ctsfw-cal-link--apple'; 171 apple.href = safeHref(icsUrl); 172 apple.setAttribute('download', 'event.ics'); 173 apple.setAttribute('aria-label', 'Download Apple Calendar (.ics)'); 174 apple.title = 'Download Apple Calendar (.ics)'; 175 const appleIcon = calendarIcon('apple'); 176 if (appleIcon) apple.appendChild(appleIcon); else apple.textContent = 'Apple'; 177 links.append(google, apple); 178 row.append(info, links); 179 li.appendChild(row); 180 listEl.appendChild(li); 181 }); 182 } 183 184 function doOpen(items) { 185 const { modal, overlay, list } = getShell(); 186 if (!modal || !overlay || !list) { console.warn('[CTSFWMODAL] Modal shell missing.'); return; } 187 ensureChrome(); 188 hydrateItems(list, items); 189 modal.removeAttribute('hidden'); 190 overlay.removeAttribute('hidden'); 191 modal.setAttribute('data-open', 'true'); 192 modal.removeAttribute('aria-hidden'); 193 // focus close for a11y if present 194 const { close } = getShell(); 195 if (close) try { close.focus({ preventScroll: true }); } catch (_) { } 196 } 197 198 function doClose() { 199 const { modal, overlay, list } = getShell(); 200 if (!modal || !overlay || !list) return; 201 modal.removeAttribute('data-open'); 202 modal.setAttribute('aria-hidden', 'true'); 203 modal.setAttribute('hidden', ''); 204 overlay.setAttribute('hidden', ''); 205 list.innerHTML = ''; 206 } 207 208 window.CTSFWMODAL = { open: doOpen, close: doClose }; 209 210 // Close hooks 211 document.addEventListener('click', (e) => { 212 const { modal, overlay, close } = getShell(); 213 if (!modal) return; 214 if (overlay && e.target === overlay) { e.preventDefault(); doClose(); } 215 if (close && e.target.closest('[data-ctsfw-modal-close]') === close) { e.preventDefault(); doClose(); } 216 }); 217 document.addEventListener('keydown', (e) => { if (e.key === 'Escape') doClose(); }); 218 219 // Delegation for triggers 220 document.addEventListener('click', (e) => { 221 const trigger = e.target.closest('[data-ctsfw-addtocalendar]'); 222 if (!trigger) return; 223 e.preventDefault(); 224 225 const json = trigger.getAttribute('data-events'); 226 if (json) { 227 try { 228 const arr = JSON.parse(json); 229 if (Array.isArray(arr) && arr.length) { 230 const norm = arr.map(o => ({ 231 title: o.title ?? '', 232 url: o.url ?? '', 233 startISO: toISO(o.startISO || o.start || ''), 234 endISO: toISO(o.endISO || o.end || ''), 235 location: o.location ?? '', 236 desc: o.desc ?? '' 237 })); 238 return doOpen(norm); 239 } 240 } catch (_) { /* fall through */ } 241 } 242 243 const sel = trigger.getAttribute('data-target'); 244 if (!sel) { console.warn('[CTSFWMODAL] Trigger missing data-events or data-target.'); return; } 245 246 const nodes = qsa(sel); 247 if (!nodes.length) { console.warn('[CTSFWMODAL] No nodes matched for selector:', sel); return; } 248 249 const items = nodes.flatMap(n => { 250 const occurrenceData = n.getAttribute('data-occurrences'); 251 if (occurrenceData) { 252 try { 253 const occurrences = JSON.parse(occurrenceData); 254 if (Array.isArray(occurrences)) { 255 return occurrences.map(o => ({ 256 title: o.title ?? '', 257 url: o.url ?? '', 258 startISO: toISO(o.startISO || o.start || ''), 259 endISO: toISO(o.endISO || o.end || ''), 260 location: o.location ?? '', 261 desc: o.desc ?? '' 262 })); 263 } 264 } catch (_) { /* use the parent event fallback below */ } 265 } 266 const title = n.getAttribute('data-title') || n.querySelector('[itemprop="name"]')?.textContent?.trim() || ''; 267 const url = n.getAttribute('data-url') || n.getAttribute('data-href') || n.querySelector('a')?.href || ''; 268 const start = n.getAttribute('data-start') || n.querySelector('[data-start]')?.getAttribute('data-start') || ''; 269 const end = n.getAttribute('data-end') || n.querySelector('[data-end]')?.getAttribute('data-end') || ''; 270 const loc = n.getAttribute('data-location') || n.querySelector('[data-location]')?.textContent?.trim() || ''; 271 const desc = n.getAttribute('data-desc') || n.querySelector('[data-desc]')?.textContent?.trim() || ''; 272 const o = { title, url, startISO: toISO(start), endISO: toISO(end), location: loc, desc };
273 const g = n.getAttribute('data-gcal') || n.querySelector('[data-gcal]')?.getAttribute?.('href'); 274 const i = n.getAttribute('data-ics') || n.querySelector('[data-ics]')?.getAttribute?.('href'); 275 if (g) o.gcalUrl = g; if (i) o.icsUrl = i; 276 return [o]; 277 }); 278 279 doOpen(items); 280 }); 281})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.