diff --git a/frontend/src/utils/dateTimeUtils.js b/frontend/src/utils/dateTimeUtils.js index 7993786d..852a39a6 100644 --- a/frontend/src/utils/dateTimeUtils.js +++ b/frontend/src/utils/dateTimeUtils.js @@ -45,6 +45,32 @@ export const toFriendlyDuration = (dateTime, unit) => export const fromNow = (dateTime) => dayjs(dateTime).fromNow(); +export const setTz = (dateTime, timeZone) => dayjs(dateTime).tz(timeZone); + +export const setMonth = (dateTime, value) => dayjs(dateTime).month(value); + +export const setYear = (dateTime, value) => dayjs(dateTime).year(value); + +export const setDay = (dateTime, value) => dayjs(dateTime).date(value); + +export const setHour = (dateTime, value) => dayjs(dateTime).hour(value); + +export const setMinute = (dateTime, value) => dayjs(dateTime).minute(value); + +export const setSecond = (dateTime, value) => dayjs(dateTime).second(value); + +export const getMonth = (dateTime) => dayjs(dateTime).month(); + +export const getYear = (dateTime) => dayjs(dateTime).year(); + +export const getDay = (dateTime) => dayjs(dateTime).date(); + +export const getHour = (dateTime) => dayjs(dateTime).hour(); + +export const getMinute = (dateTime) => dayjs(dateTime).minute(); + +export const getSecond = (dateTime) => dayjs(dateTime).second(); + export const getNowMs = () => Date.now(); export const roundToNearest = (dateTime, minutes) => { @@ -281,3 +307,33 @@ export const getDefaultTimeZone = () => { return 'UTC'; } }; + +export const MONTH_NAMES = [ + 'january', + 'february', + 'march', + 'april', + 'may', + 'june', + 'july', + 'august', + 'september', + 'october', + 'november', + 'december', +]; + +export const MONTH_ABBR = [ + 'jan', + 'feb', + 'mar', + 'apr', + 'may', + 'jun', + 'jul', + 'aug', + 'sep', + 'oct', + 'nov', + 'dec', +]; \ No newline at end of file diff --git a/frontend/src/utils/forms/AccountInfoModalUtils.js b/frontend/src/utils/forms/AccountInfoModalUtils.js new file mode 100644 index 00000000..b9ee6f81 --- /dev/null +++ b/frontend/src/utils/forms/AccountInfoModalUtils.js @@ -0,0 +1,54 @@ +// Helper function to format timestamps +import API from '../../api.js'; + +export const formatTimestamp = (timestamp) => { + if (!timestamp) return 'Unknown'; + try { + const date = + typeof timestamp === 'string' && timestamp.includes('T') + ? new Date(timestamp) // This should handle ISO format properly + : new Date(parseInt(timestamp) * 1000); + + // Convert to user's local time and display with timezone + return date.toLocaleString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + timeZoneName: 'short', + }); + } catch { + return 'Invalid date'; + } +}; + +// Helper function to get time remaining +export const getTimeRemaining = (expTimestamp) => { + if (!expTimestamp) return null; + + try { + const now = new Date(); + const expDate = new Date(parseInt(expTimestamp) * 1000); + const diffMs = expDate - now; + + if (diffMs <= 0) return 'Expired'; + + const MS_PER_HOUR = 1000 * 60 * 60; + const MS_PER_DAY = MS_PER_HOUR * 24; + + const days = Math.floor(diffMs / MS_PER_DAY); + const hours = Math.floor((diffMs % MS_PER_DAY) / MS_PER_HOUR); + + const dayLabel = `${days} ${days === 1 ? 'day' : 'days'}`; + const hourLabel = `${hours} ${hours === 1 ? 'hour' : 'hours'}`; + + return days > 0 ? `${dayLabel} ${hourLabel}` : hourLabel; + } catch { + return 'Unknown'; + } +}; + +export const refreshAccountInfo = (currentProfile) => { + return API.refreshAccountInfo(currentProfile.id); +}; diff --git a/frontend/src/utils/forms/ChannelBatchUtils.js b/frontend/src/utils/forms/ChannelBatchUtils.js new file mode 100644 index 00000000..de44b938 --- /dev/null +++ b/frontend/src/utils/forms/ChannelBatchUtils.js @@ -0,0 +1,186 @@ +import API from '../../api.js'; + +export const getChannelGroupChange = (selectedChannelGroup, channelGroups) => { + if (!selectedChannelGroup || selectedChannelGroup === '-1') return null; + const groupName = channelGroups[selectedChannelGroup]?.name || 'Unknown'; + return `• Channel Group: ${groupName}`; +}; + +export const getLogoChange = (selectedLogoId, channelLogos) => { + if (!selectedLogoId || selectedLogoId === '-1') return null; + if (selectedLogoId === '0') return `• Logo: Use Default`; + const logoName = channelLogos[selectedLogoId]?.name || 'Selected Logo'; + return `• Logo: ${logoName}`; +}; + +export const getStreamProfileChange = (streamProfileId, streamProfiles) => { + if (!streamProfileId || streamProfileId === '-1') return null; + if (streamProfileId === '0') return `• Stream Profile: Use Default`; + const profile = streamProfiles.find( + (p) => `${p.id}` === `${streamProfileId}` + ); + return `• Stream Profile: ${profile?.name || 'Selected Profile'}`; +}; + +export const getUserLevelChange = (userLevel, userLevelLabels) => { + if (!userLevel || userLevel === '-1') return null; + return `• User Level: ${userLevelLabels[userLevel] || userLevel}`; +}; + +export const getMatureContentChange = (isAdult) => { + if (!isAdult || isAdult === '-1') return null; + return `• Mature Content: ${isAdult === 'true' ? 'Yes' : 'No'}`; +}; + +export const getRegexNameChange = (regexFind, regexReplace) => { + if (!regexFind?.trim()) return null; + return `• Name Change: Apply regex find "${regexFind}" replace with "${regexReplace || ''}"`; +}; + +export const getEpgChange = (selectedDummyEpgId, epgs) => { + if (!selectedDummyEpgId) return null; + if (selectedDummyEpgId === 'clear') + return `• EPG: Clear Assignment (use default dummy)`; + const epgName = epgs[selectedDummyEpgId]?.name || 'Selected EPG'; + return `• Dummy EPG: ${epgName}`; +}; + +export const updateChannels = (channelIds, values) => { + return API.updateChannels(channelIds, values); +}; + +export const bulkRegexRenameChannels = ( + channelIds, + regexFind, + regexReplace, + flags +) => { + return API.bulkRegexRenameChannels( + channelIds, + regexFind, + regexReplace ?? '', + flags + ); +}; + +export const batchSetEPG = (associations) => { + return API.batchSetEPG(associations); +}; + +export const getEpgData = () => { + return API.getEPGData(); +}; + +export const setChannelNamesFromEpg = (channelIds) => { + return API.setChannelNamesFromEpg(channelIds); +}; + +export const setChannelLogosFromEpg = (channelIds) => { + return API.setChannelLogosFromEpg(channelIds); +}; + +export const setChannelTvgIdsFromEpg = (channelIds) => { + return API.setChannelTvgIdsFromEpg(channelIds); +}; + +export const computeRegexPreview = ( + channelIds, + nameById, + find, + replace, + limit = 25 +) => { + if (!find) return []; + + let re; + try { + re = new RegExp(find, 'g'); + } catch (error) { + console.error('Invalid regex:', error); + return [{ before: 'Invalid regex', after: '' }]; + } + + // Limit preview to items that exist on the current page + const pageOnlyIds = channelIds.filter((id) => nameById[id] !== undefined); + const items = []; + + for (let i = 0; i < Math.min(pageOnlyIds.length, limit); i++) { + const before = nameById[pageOnlyIds[i]] ?? ''; + const after = before.replace(re, replace ?? ''); + if (before !== after) items.push({ before, after }); + } + + return items; +}; + +export const buildSubmitValues = ( + formValues, + selectedChannelGroup, + selectedLogoId +) => { + const values = { ...formValues }; + + // Handle channel group ID - convert to integer if it exists + if (selectedChannelGroup && selectedChannelGroup !== '-1') { + values.channel_group_id = parseInt(selectedChannelGroup); + } else { + delete values.channel_group_id; + } + + if (selectedLogoId && selectedLogoId !== '-1') { + values.logo_id = selectedLogoId === '0' ? null : parseInt(selectedLogoId); + } + delete values.logo; + // Remove the channel_group field from form values as we use channel_group_id + delete values.channel_group; + + // Handle stream profile ID - convert special values + if (!values.stream_profile_id || values.stream_profile_id === '-1') { + delete values.stream_profile_id; + } else if ( + values.stream_profile_id === '0' || + values.stream_profile_id === 0 + ) { + values.stream_profile_id = null; // Convert "use default" to null + } + + if (values.user_level == '-1') delete values.user_level; + + if (values.is_adult === '-1') { + delete values.is_adult; + } else { + values.is_adult = values.is_adult === 'true'; + } + + return values; +}; + +export const buildEpgAssociations = async ( + selectedDummyEpgId, + channelIds, + epgs, + tvgs +) => { + if (!selectedDummyEpgId) return null; + + if (selectedDummyEpgId === 'clear') { + // Clear EPG assignments + return channelIds.map((id) => ({ channel_id: id, epg_data_id: null })); + } + + // Assign the selected dummy EPG + const selectedEpg = epgs[selectedDummyEpgId]; + if (!selectedEpg?.epg_data_count) return null; + + const epgSourceId = parseInt(selectedDummyEpgId, 10); + // Check if we already have EPG data loaded in the store + let epgData = tvgs.find((data) => data.epg_source === epgSourceId); + + if (!epgData) { + const epgDataList = await getEpgData(); + epgData = epgDataList.find((data) => data.epg_source === epgSourceId); + } + + if (!epgData) return null; + return channelIds.map((id) => ({ channel_id: id, epg_data_id: epgData.id })); +}; \ No newline at end of file diff --git a/frontend/src/utils/forms/ChannelUtils.js b/frontend/src/utils/forms/ChannelUtils.js new file mode 100644 index 00000000..433d6943 --- /dev/null +++ b/frontend/src/utils/forms/ChannelUtils.js @@ -0,0 +1,97 @@ +import API from '../../api.js'; + +export const matchChannelEpg = (channel) => { + return API.matchChannelEpg(channel.id); +}; +export const createLogo = (newLogoData) => { + return API.createLogo(newLogoData); +}; +const setChannelEPG = (channel, values) => { + return API.setChannelEPG(channel.id, values.epg_data_id); +}; +const updateChannel = (values) => { + return API.updateChannel(values); +}; +export const addChannel = (channel) => { + return API.addChannel(channel); +}; +export const requeryChannels = () => { + API.requeryChannels(); +}; + +export const getChannelFormDefaultValues = (channel, channelGroups) => { + return { + name: channel?.name || '', + channel_number: + channel?.channel_number !== null && channel?.channel_number !== undefined + ? channel.channel_number + : '', + channel_group_id: channel?.channel_group_id + ? `${channel.channel_group_id}` + : Object.keys(channelGroups).length > 0 + ? Object.keys(channelGroups)[0] + : '', + stream_profile_id: channel?.stream_profile_id + ? `${channel.stream_profile_id}` + : '0', + tvg_id: channel?.tvg_id || '', + tvc_guide_stationid: channel?.tvc_guide_stationid || '', + epg_data_id: channel?.epg_data_id ?? '', + logo_id: channel?.logo_id ? `${channel.logo_id}` : '', + user_level: `${channel?.user_level ?? '0'}`, + is_adult: channel?.is_adult ?? false, + }; +}; + +export const getFormattedValues = (values) => { + const formattedValues = { ...values }; + + // Convert empty or "0" stream_profile_id to null for the API + if ( + !formattedValues.stream_profile_id || + formattedValues.stream_profile_id === '0' + ) { + formattedValues.stream_profile_id = null; + } + + // Ensure tvg_id is properly included (no empty strings) + formattedValues.tvg_id = formattedValues.tvg_id || null; + + // Ensure tvc_guide_stationid is properly included (no empty strings) + formattedValues.tvc_guide_stationid = + formattedValues.tvc_guide_stationid || null; + + return formattedValues; +}; + +export const handleEpgUpdate = async ( + channel, + values, + formattedValues, + channelStreams +) => { + // If there's an EPG to set, use our enhanced endpoint + if (values.epg_data_id !== (channel.epg_data_id ?? '')) { + // Use the special endpoint to set EPG and trigger refresh + await setChannelEPG(channel, values); + + // Remove epg_data_id from values since we've handled it separately + const { epg_data_id: _epg_data_id, ...otherValues } = formattedValues; + + // Update other channel fields if needed + if (Object.keys(otherValues).length > 0) { + await updateChannel({ + id: channel.id, + ...otherValues, + streams: channelStreams.map((stream) => stream.id), + }); + } + } else { + // No EPG change, regular update + await updateChannel({ + id: channel.id, + ...formattedValues, + streams: channelStreams.map((stream) => stream.id), + }); + } +}; \ No newline at end of file diff --git a/frontend/src/utils/forms/ConnectionUtils.js b/frontend/src/utils/forms/ConnectionUtils.js new file mode 100644 index 00000000..bc791ed2 --- /dev/null +++ b/frontend/src/utils/forms/ConnectionUtils.js @@ -0,0 +1,75 @@ +import API from '../../api.js'; +import { SUBSCRIPTION_EVENTS } from '../../constants.js'; + +export const EVENT_OPTIONS = Object.entries(SUBSCRIPTION_EVENTS).map( + ([value, label]) => ({ + value, + label, + }) +); + +export const updateConnectIntegration = (connection, values, config) => { + return API.updateConnectIntegration(connection.id, { + name: values.name, + type: values.type, + config, + enabled: values.enabled, + }); +}; + +export const createConnectIntegration = (values, config) => { + return API.createConnectIntegration({ + name: values.name, + type: values.type, + config, + enabled: values.enabled, + }); +}; + +export const setConnectSubscriptions = (connection, subs) => { + return API.setConnectSubscriptions(connection.id, subs); +}; + +const buildWebhookConfig = (url, headers) => { + const hdrs = {}; + headers.forEach((h) => { + if (h.key && h.key.trim()) hdrs[h.key] = h.value; + }); + const config = { url }; + if (Object.keys(hdrs).length) config.headers = hdrs; + return config; +}; + +const buildScriptConfig = (scriptPath) => ({ path: scriptPath }); + +export const buildConfig = (values, headers) => + values.type === 'webhook' + ? buildWebhookConfig(values.url, headers) + : buildScriptConfig(values.script_path); + +export const buildSubscriptions = (selectedEvents, payloadTemplates) => + Object.keys(SUBSCRIPTION_EVENTS).map((event) => ({ + event, + enabled: selectedEvents.includes(event), + payload_template: payloadTemplates[event] ?? null, + })); + +export const parseApiError = (error) => { + const body = error?.body; + + if (!body || typeof body !== 'object') { + return { fieldErrors: {}, apiError: error?.message ?? 'Unknown error' }; + } + + const knownFields = ['name', 'type']; + const fieldErrors = Object.fromEntries( + knownFields.filter((f) => body[f]).map((f) => [f, body[f]]) + ); + + const nonField = body.non_field_errors ?? body.detail ?? null; + const apiError = + nonField ?? + (Object.keys(fieldErrors).length === 0 ? JSON.stringify(body) : ''); + + return { fieldErrors, apiError }; +}; \ No newline at end of file diff --git a/frontend/src/utils/forms/CronBuilderUtils.js b/frontend/src/utils/forms/CronBuilderUtils.js new file mode 100644 index 00000000..9cce8f48 --- /dev/null +++ b/frontend/src/utils/forms/CronBuilderUtils.js @@ -0,0 +1,131 @@ +export const PRESETS = [ + { + label: 'Every hour', + value: '0 * * * *', + description: 'At the start of every hour', + }, + { + label: 'Every 6 hours', + value: '0 */6 * * *', + description: 'Every 6 hours starting at midnight', + }, + { + label: 'Every 12 hours', + value: '0 */12 * * *', + description: 'Twice daily at midnight and noon', + }, + { + label: 'Daily at midnight', + value: '0 0 * * *', + description: 'Once per day at 12:00 AM', + }, + { + label: 'Daily at 3 AM', + value: '0 3 * * *', + description: 'Once per day at 3:00 AM', + }, + { + label: 'Daily at noon', + value: '0 12 * * *', + description: 'Once per day at 12:00 PM', + }, + { + label: 'Weekly (Sunday midnight)', + value: '0 0 * * 0', + description: 'Once per week on Sunday', + }, + { + label: 'Weekly (Monday 3 AM)', + value: '0 3 * * 1', + description: 'Once per week on Monday', + }, + { + label: 'Monthly (1st at 2:30 AM)', + value: '30 2 1 * *', + description: 'First day of each month', + }, +]; + +export const DAYS_OF_WEEK = [ + { value: '*', label: 'Every day' }, + { value: '0', label: 'Sunday' }, + { value: '1', label: 'Monday' }, + { value: '2', label: 'Tuesday' }, + { value: '3', label: 'Wednesday' }, + { value: '4', label: 'Thursday' }, + { value: '5', label: 'Friday' }, + { value: '6', label: 'Saturday' }, +]; + +export const FREQUENCY_OPTIONS = [ + { value: 'hourly', label: 'Hourly' }, + { value: 'daily', label: 'Daily' }, + { value: 'weekly', label: 'Weekly' }, + { value: 'monthly', label: 'Monthly' }, +]; + +export const buildCron = (frequency, minute, hour, dayOfWeek, dayOfMonth) => { + switch (frequency) { + case 'hourly': + return `${minute} * * * *`; + case 'daily': + return `${minute} ${hour} * * *`; + case 'weekly': + return `${minute} ${hour} * * ${dayOfWeek === '*' ? '0' : dayOfWeek}`; + case 'monthly': + return `${minute} ${hour} ${dayOfMonth} * *`; + default: + return '* * * * *'; + } +}; + +const parseHour = (hr) => parseInt(hr.replace('*/', '').replace('*', '0')) || 0; + +export const parseCronPreset = (cron) => { + const [min, hr, day, _month, weekday] = cron.split(' '); + const minute = parseInt(min) || 0; + const hour = parseHour(hr); + + if (hr === '*') + return { frequency: 'hourly', minute, hour, dayOfWeek: '*', dayOfMonth: 1 }; + if (weekday !== '*') + return { + frequency: 'weekly', + minute, + hour, + dayOfWeek: weekday, + dayOfMonth: 1, + }; + if (day !== '*') + return { + frequency: 'monthly', + minute, + hour, + dayOfWeek: '*', + dayOfMonth: parseInt(day) || 1, + }; + return { frequency: 'daily', minute, hour, dayOfWeek: '*', dayOfMonth: 1 }; +}; + +export const CRON_FIELDS = [ + { index: 0, label: 'Minute (0-59)', placeholder: '*, 0, */15, 0,15,30,45' }, + { index: 1, label: 'Hour (0-23)', placeholder: '*, 0, 9-17, */6, 2,4,16' }, + { + index: 2, + label: 'Day of Month (1-31)', + placeholder: '*, 1, 1-15, */2, 1,15', + }, + { index: 3, label: 'Month (1-12)', placeholder: '*, 1, 1-6, */3, 6,12' }, + { + index: 4, + label: 'Day of Week (0-6, Sun-Sat)', + placeholder: '*, 0, 1-5, 0,6', + }, +]; + +export const updateCronPart = (cron, index, value) => { + const parts = + cron.split(' ').length >= 5 ? cron.split(' ') : ['*', '*', '*', '*', '*']; + parts[index] = value || '*'; + return parts.join(' '); +}; diff --git a/frontend/src/utils/forms/DummyEpgUtils.js b/frontend/src/utils/forms/DummyEpgUtils.js new file mode 100644 index 00000000..76b3ee64 --- /dev/null +++ b/frontend/src/utils/forms/DummyEpgUtils.js @@ -0,0 +1,266 @@ +import API from '../../api.js'; +import { + format, + getDay, + getHour, + getMinute, + getMonth, + getNow, + getYear, + MONTH_ABBR, + MONTH_NAMES, + setHour, + setMinute, + setSecond, + setTz, +} from '../dateTimeUtils.js'; + +export const getTimezones = () => { + return API.getTimezones(); +}; +export const updateEPG = (values, epg) => { + return API.updateEPG({ ...values, id: epg.id }); +}; +export const addEPG = (values) => { + return API.addEPG(values); +}; + +export const getDummyEpgFormInitialValues = () => { + return { + name: '', + is_active: true, + source_type: 'dummy', + custom_properties: buildCustomProperties({}), + }; +}; + +export const buildCustomProperties = (custom = {}) => ({ + title_pattern: custom.title_pattern || '', + time_pattern: custom.time_pattern || '', + date_pattern: custom.date_pattern || '', + timezone: + custom.timezone || custom.timezone_offset?.toString() || 'US/Eastern', + output_timezone: custom.output_timezone || '', + program_duration: custom.program_duration || 180, + sample_title: custom.sample_title || '', + title_template: custom.title_template || '', + subtitle_template: custom.subtitle_template || '', + description_template: custom.description_template || '', + upcoming_title_template: custom.upcoming_title_template || '', + upcoming_description_template: custom.upcoming_description_template || '', + ended_title_template: custom.ended_title_template || '', + ended_description_template: custom.ended_description_template || '', + fallback_title_template: custom.fallback_title_template || '', + fallback_description_template: custom.fallback_description_template || '', + channel_logo_url: custom.channel_logo_url || '', + program_poster_url: custom.program_poster_url || '', + name_source: custom.name_source || 'channel', + stream_index: custom.stream_index || 1, + category: custom.category || '', + include_date: custom.include_date ?? true, + include_live: custom.include_live ?? false, + include_new: custom.include_new ?? false, +}); + +export const validateCustomTitlePattern = (value) => { + if (!value?.trim()) return 'Title pattern is required'; + try { + new RegExp(value); + return null; + } catch (e) { + return `Invalid regex: ${e.message}`; + } +}; + +export const validateCustomNameSource = (value) => { + if (!value) return 'Name source is required'; + return null; +}; + +export const validateCustomStreamIndex = (values, value) => { + if (values.custom_properties?.name_source === 'stream') { + if (!value || value < 1) { + return 'Stream index must be at least 1'; + } + } + return null; +}; + +export const matchPattern = (pattern, input, errorPrefix) => { + if (!pattern || !input) return { matched: false, groups: {}, error: null }; + try { + const match = input.match(new RegExp(pattern)); + return match + ? { matched: true, groups: match.groups || {}, error: null } + : { matched: false, groups: {}, error: null }; + } catch (e) { + return { + matched: false, + groups: {}, + error: `${errorPrefix}: ${e.message}`, + }; + } +}; + +export const addNormalizedGroups = (groups) => { + const result = { ...groups }; + Object.keys(groups).forEach((key) => { + if (groups[key]) { + result[`${key}_normalize`] = String(groups[key]) + .replace(/[^a-zA-Z0-9\s]/g, '') + .replace(/\s+/g, '') + .toLowerCase(); + } + }); + return result; +}; + +const formatTime12 = (h, m) => { + const period = h < 12 ? 'AM' : 'PM'; + let h12 = h % 12 || 12; + return m > 0 + ? `${h12}:${String(m).padStart(2, '0')} ${period}` + : `${h12} ${period}`; +}; + +const formatTime12Long = (h, m) => { + const period = h < 12 ? 'AM' : 'PM'; + let h12 = h % 12 || 12; + return `${h12}:${String(m).padStart(2, '0')} ${period}`; +}; + +const formatTime24 = (h, m) => + m > 0 + ? `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}` + : `${String(h).padStart(2, '0')}:00`; + +export const buildTimePlaceholders = ( + timeGroups, + dateGroups, + sourceTimezone, + outputTimezone, + programDuration +) => { + if (!timeGroups || !timeGroups.hour) return {}; + + try { + let hour24 = parseInt(timeGroups.hour); + const minute = timeGroups.minute ? parseInt(timeGroups.minute) : 0; + const ampm = timeGroups.ampm?.toLowerCase(); + + if (ampm === 'pm' && hour24 !== 12) hour24 += 12; + else if (ampm === 'am' && hour24 === 12) hour24 = 0; + + let baseDate = setTz(getNow(), sourceTimezone); + + if (dateGroups.month && dateGroups.day) { + const monthValue = dateGroups.month; + let extractedMonth; + if (/^\d+$/.test(monthValue)) { + extractedMonth = parseInt(monthValue); + } else { + const lower = monthValue.toLowerCase(); + const idx = + MONTH_NAMES.indexOf(lower) !== -1 + ? MONTH_NAMES.indexOf(lower) + : MONTH_ABBR.indexOf(lower); + extractedMonth = idx !== -1 ? idx + 1 : getMonth(getNow()) + 1; + } + const extractedDay = parseInt(dateGroups.day); + const extractedYear = dateGroups.year + ? parseInt(dateGroups.year) + : getYear(getNow()); + if ( + !isNaN(extractedMonth) && + !isNaN(extractedDay) && + !isNaN(extractedYear) && + extractedMonth >= 1 && + extractedMonth <= 12 && + extractedDay >= 1 && + extractedDay <= 31 + ) { + baseDate = setTz( + `${extractedYear}-${String(extractedMonth).padStart(2, '0')}-${String(extractedDay).padStart(2, '0')}`, + sourceTimezone + ); + } + } + + let sourceDate = setHour(baseDate, hour24); + sourceDate = setMinute(sourceDate, minute); + sourceDate = setSecond(sourceDate, 0); + const workDate = + outputTimezone && outputTimezone !== sourceTimezone + ? setTz(sourceDate, outputTimezone) + : sourceDate; + + const h24 = getHour(workDate); + const min = getMinute(workDate); + + const endTotalMin = h24 * 60 + min + (programDuration || 180); + const endH24 = Math.floor(endTotalMin / 60) % 24; + const endMin = endTotalMin % 60; + + return { + starttime: formatTime12(h24, min), + starttime_long: formatTime12Long(h24, min), + starttime24: formatTime24(h24, min), + starttime24_long: formatTime24(h24, min), + endtime: formatTime12(endH24, endMin), + endtime_long: formatTime12Long(endH24, endMin), + endtime24: formatTime24(endH24, endMin), + date: format(workDate, 'YYYY-MM-DD'), + month: getMonth(workDate) + 1, + day: getDay(workDate), + year: getYear(workDate), + }; + } catch (e) { + console.error('Error building time placeholders:', e); + } +}; + +const PLAIN_TEMPLATES = [ + { stateKey: 'titleTemplate', resultKey: 'formattedTitle' }, + { stateKey: 'subtitleTemplate', resultKey: 'formattedSubtitle' }, + { stateKey: 'descriptionTemplate', resultKey: 'formattedDescription' }, + { stateKey: 'upcomingTitleTemplate', resultKey: 'formattedUpcomingTitle' }, + { + stateKey: 'upcomingDescriptionTemplate', + resultKey: 'formattedUpcomingDescription', + }, + { stateKey: 'endedTitleTemplate', resultKey: 'formattedEndedTitle' }, + { + stateKey: 'endedDescriptionTemplate', + resultKey: 'formattedEndedDescription', + }, +]; + +const URL_TEMPLATES = [ + { stateKey: 'channelLogoUrl', resultKey: 'formattedChannelLogoUrl' }, + { stateKey: 'programPosterUrl', resultKey: 'formattedProgramPosterUrl' }, +]; + +export const applyTemplates = (templateValues, groups, hasMatch) => { + const result = {}; + if (!hasMatch) return result; + + PLAIN_TEMPLATES.forEach(({ stateKey, resultKey }) => { + if (templateValues[stateKey]) { + result[resultKey] = templateValues[stateKey].replace( + /\{(\w+)\}/g, + (m, k) => groups[k] || m + ); + } + }); + + URL_TEMPLATES.forEach(({ stateKey, resultKey }) => { + if (templateValues[stateKey]) { + result[resultKey] = templateValues[stateKey].replace( + /\{(\w+)\}/g, + (m, k) => (groups[k] ? encodeURIComponent(String(groups[k])) : m) + ); + } + }); + + return result; +}; \ No newline at end of file