From 0e5b2509406e440334e749de03b55d257f01d4c6 Mon Sep 17 00:00:00 2001 From: late4marshmellow Date: Mon, 6 Apr 2026 11:23:17 +0200 Subject: [PATCH 1/3] fix: replace ensureString with normalizeEventText for robust nested payload handling ensureString only handled shallow objects with known keys (val, value, text) and fell back to String() which produced [object Object] for unknown shapes. normalizeEventText handles: - Nested objects recursively with a preferred-key priority list - Arrays (joins with ', ') - Circular reference detection via a seen-set - All render sites: title (previously unguarded), description, location - Uses textContent instead of innerHTML for safety --- CX3_shared.mjs | 63 ++++++++++++++++++++++++++++++-------------------- 1 file changed, 38 insertions(+), 25 deletions(-) diff --git a/CX3_shared.mjs b/CX3_shared.mjs index 1fa6ed7..c140108 100644 --- a/CX3_shared.mjs +++ b/CX3_shared.mjs @@ -41,24 +41,37 @@ const convertVarious2UnixTime = (unknown) => { } /** - * Convert potentially object-based iCal property to string - * Some iCal parsers return objects like {val: "text", params: {...}} instead of plain strings - * @param {any} value - The value to convert (string, object, or other) - * @returns {string} - The string representation - */ -const ensureString = (value) => { - if (!value) return '' + * Normalize unknown event text payload to a displayable string. + * Handles nested objects from some calendar providers (e.g. Outlook via iCloud) + * which may return structured objects instead of plain strings for title, + * description, and location fields. + * @param {any} value + * @param {Set} seen - used internally to detect circular references + * @returns {string} + */ +const normalizeEventText = (value, seen = new Set()) => { + if (value === null || value === undefined || value === false) return '' if (typeof value === 'string') return value + if (typeof value === 'number' || typeof value === 'bigint') return String(value) + if (Array.isArray(value)) { + return value.map((v) => normalizeEventText(v, seen)).filter(Boolean).join(', ') + } if (typeof value === 'object') { - // Try common property names used by iCal parsers - if (value.val !== undefined) return String(value.val) - if (value.value !== undefined) return String(value.value) - if (value.text !== undefined) return String(value.text) - // If it's an object but none of the above, log it for debugging - console.warn('CX3_shared.ensureString: Unexpected object format for event property:', value) - return String(value) + if (seen.has(value)) return '' + seen.add(value) + const preferredKeys = ['value', 'val', 'text', 'plain', 'html', 'label', 'name', 'description', 'location'] + for (const key of preferredKeys) { + if (Object.prototype.hasOwnProperty.call(value, key)) { + return normalizeEventText(value[key], seen) + } + } + if (Object.prototype.hasOwnProperty.call(value, 'params')) return '' + const flattened = Object.values(value).map((v) => normalizeEventText(v, seen)).filter(Boolean) + if (!flattened.length) return '' + if (flattened.length === 1) return flattened[0] + return flattened.join(' | ') } - return String(value) + return '' } /** @@ -151,11 +164,11 @@ const renderEventDefault = (event) => { e.dataset.calendarSeq = event?.calendarSeq ?? 0 event.calendarName ? (e.dataset.calendarName = event.calendarName) : null e.dataset.color = event.color - e.dataset.description = ensureString(event.description) - e.dataset.title = event.title + e.dataset.description = normalizeEventText(event.description) + e.dataset.title = normalizeEventText(event.title) e.dataset.fullDayEvent = event.fullDayEvent e.dataset.geo = event.geo - e.dataset.location = ensureString(event.location) + e.dataset.location = normalizeEventText(event.location) e.dataset.startDate = event.startDate e.dataset.endDate = event.endDate e.dataset.today = event.today @@ -220,7 +233,7 @@ const renderEvent = (event, options) => { const t = document.createElement('span') t.classList.add('title', 'eventTitle') - t.innerHTML = event.title + t.textContent = normalizeEventText(event.title) e.appendChild(t) return e } @@ -240,7 +253,7 @@ const renderEventJournal = (event, { useSymbol, eventTimeOptions, eventDateOptio const title = document.createElement('div') title.classList.add('title') - title.innerHTML = event.title + title.textContent = normalizeEventText(event.title) headline.appendChild(title) e.appendChild(headline) @@ -263,11 +276,11 @@ const renderEventJournal = (event, { useSymbol, eventTimeOptions, eventDateOptio const description = document.createElement('div') description.classList.add('description') - description.innerHTML = ensureString(event.description) + description.textContent = normalizeEventText(event.description) e.appendChild(description) const location = document.createElement('div') location.classList.add('location') - location.innerHTML = ensureString(event.location) + location.textContent = normalizeEventText(event.location) e.appendChild(location) return e @@ -310,16 +323,16 @@ const renderEventAgenda = (event, {useSymbol, eventTimeOptions, locale, useIconi const title = document.createElement('div') title.classList.add('title') - title.innerHTML = event.title + title.textContent = normalizeEventText(event.title) headline.appendChild(title) e.appendChild(headline) const description = document.createElement('div') description.classList.add('description') - description.innerHTML = ensureString(event.description) + description.textContent = normalizeEventText(event.description) e.appendChild(description) const location = document.createElement('div') location.classList.add('location') - location.innerHTML = ensureString(event.location) + location.textContent = normalizeEventText(event.location) e.appendChild(location) return e From 539871192a5b1ef49cf2f7f3fc1450f6cbecc0d0 Mon Sep 17 00:00:00 2001 From: late4marshmellow Date: Mon, 6 Apr 2026 12:25:37 +0200 Subject: [PATCH 2/3] feat: show date/time range for multiday events in renderEventAgenda When showMultidayEventsOnce is true and event.isMultiday: - Fullday multiday: show date range (e.g. 'Apr 3 - Apr 9') using multidayRangeLabelOptions format - Timed multiday: show 'Apr 3 10:00 AM - Apr 9 2:00 PM' combining date and time for each endpoint Uses existing startTime/endTime CSS classes so the module stylesheet separator applies consistently. When showMultidayEventsOnce is false, behaviour is unchanged. --- CX3_shared.mjs | 68 ++++++++++++++++++++++++++++++++++---------------- 1 file changed, 47 insertions(+), 21 deletions(-) diff --git a/CX3_shared.mjs b/CX3_shared.mjs index c140108..1cc7886 100644 --- a/CX3_shared.mjs +++ b/CX3_shared.mjs @@ -293,33 +293,59 @@ const renderEventJournal = (event, { useSymbol, eventTimeOptions, eventDateOptio * @param {Date} tm * @returns HTMLElement event DOM */ -const renderEventAgenda = (event, {useSymbol, eventTimeOptions, locale, useIconify}, tm = new Date())=> { +const renderEventAgenda = (event, {useSymbol, eventTimeOptions, locale, useIconify, showMultidayEventsOnce, multidayRangeLabelOptions}, tm = new Date())=> { const e = renderEventDefault(event) const headline = document.createElement('div') headline.classList.add('headline') renderSymbol(headline, event, { useSymbol, useIconify }) - const time = document.createElement('div') - time.classList.add('period') - - const startTime = document.createElement('div') - const st = new Date(+event.startDate) - startTime.classList.add('time', 'startTime', (st.getDate() === tm.getDate()) ? 'inDay' : 'notInDay') - startTime.innerHTML = new Intl.DateTimeFormat(locale, eventTimeOptions).formatToParts(st).reduce((prev, cur, curIndex) => { - prev = prev + `${cur.value}` - return prev - }, '') - headline.appendChild(startTime) - - const endTime = document.createElement('div') - const et = new Date(+event.endDate) - endTime.classList.add('time', 'endTime', (et.getDate() === tm.getDate()) ? 'inDay' : 'notInDay') - endTime.innerHTML = new Intl.DateTimeFormat(locale, eventTimeOptions).formatToParts(et).reduce((prev, cur, curIndex) => { - prev = prev + `${cur.value}` - return prev - }, '') - headline.appendChild(endTime) + if (showMultidayEventsOnce && event.isMultiday) { + const rangeOptions = multidayRangeLabelOptions ?? { month: 'short', day: 'numeric' } + const dateFmt = new Intl.DateTimeFormat(locale, rangeOptions) + const timeFmt = new Intl.DateTimeFormat(locale, eventTimeOptions) + const st = new Date(+event.startDate) + const et = new Date(+event.endDate) + + const formatParts = (fmt, d, prefix) => fmt.formatToParts(d).reduce((prev, cur, curIndex) => { + return prev + `${cur.value}` + }, '') + + // Use startTime/endTime classes so existing ::after CSS separator applies automatically. + const startDateEl = document.createElement('div') + startDateEl.classList.add('time', 'startTime', 'inDay') + startDateEl.innerHTML = event.isFullday + ? formatParts(dateFmt, st, 'sd') + : formatParts(dateFmt, st, 'sd') + ' ' + formatParts(timeFmt, st, 'st') + headline.appendChild(startDateEl) + + const endDateEl = document.createElement('div') + endDateEl.classList.add('time', 'endTime', 'inDay') + endDateEl.innerHTML = event.isFullday + ? formatParts(dateFmt, et, 'ed') + : formatParts(dateFmt, et, 'ed') + ' ' + formatParts(timeFmt, et, 'et') + headline.appendChild(endDateEl) + } else if (!event.isFullday) { + // Timed single-day events: show start/end times relative to the displayed day. + const startTime = document.createElement('div') + const st = new Date(+event.startDate) + startTime.classList.add('time', 'startTime', (st.getDate() === tm.getDate()) ? 'inDay' : 'notInDay') + startTime.innerHTML = new Intl.DateTimeFormat(locale, eventTimeOptions).formatToParts(st).reduce((prev, cur, curIndex) => { + prev = prev + `${cur.value}` + return prev + }, '') + headline.appendChild(startTime) + + const endTime = document.createElement('div') + const et = new Date(+event.endDate) + endTime.classList.add('time', 'endTime', (et.getDate() === tm.getDate()) ? 'inDay' : 'notInDay') + endTime.innerHTML = new Intl.DateTimeFormat(locale, eventTimeOptions).formatToParts(et).reduce((prev, cur, curIndex) => { + prev = prev + `${cur.value}` + return prev + }, '') + headline.appendChild(endTime) + } + // Fullday single-day events: no time shown (startDate is always midnight). const title = document.createElement('div') title.classList.add('title') From da62c75e02444d7550061145f567431c71345497 Mon Sep 17 00:00:00 2001 From: Kristjan ESPERANTO <35647502+KristjanESPERANTO@users.noreply.github.com> Date: Mon, 6 Apr 2026 13:51:09 +0200 Subject: [PATCH 3/3] fix: remove semantic field names from normalizeEventText preferredKeys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove 'description', 'location', and 'html' from the preferred key list, and add the missing 'title'. 'description' and 'location' are event-level fields, not generic text-container properties. Including them as preferred keys means any object that happens to have a 'description' key would have its description extracted instead of its actual text value — wrong semantics. 'html' would pass raw markup through to textContent, rendering tag literals visibly in the UI. 'title' was the obvious gap: a common iCal object shape is {title: "…"} which previously fell through to the flattening fallback instead of being extracted directly. --- CX3_shared.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CX3_shared.mjs b/CX3_shared.mjs index 1cc7886..d5d893c 100644 --- a/CX3_shared.mjs +++ b/CX3_shared.mjs @@ -59,7 +59,7 @@ const normalizeEventText = (value, seen = new Set()) => { if (typeof value === 'object') { if (seen.has(value)) return '' seen.add(value) - const preferredKeys = ['value', 'val', 'text', 'plain', 'html', 'label', 'name', 'description', 'location'] + const preferredKeys = ['value', 'val', 'text', 'plain', 'label', 'name', 'title'] for (const key of preferredKeys) { if (Object.prototype.hasOwnProperty.call(value, key)) { return normalizeEventText(value[key], seen)