import { CallExpressionShape, Plugin, DurationShape, TimeShape, timeFieldToXML, formatDateToPlatformFormat, type Shape, } from '@servicenow/sdk-build-core' import { NowIdShape } from '../now-id-plugin' import { NowIncludeShape } from '../now-include-plugin' import { ModuleFunctionShape } from '../server-module-plugin' import { toReference, validateServerScriptField } from '../utils' import { dateTimeFieldToXML, convertXMLToDateTime, formatTimeDataToDateTime } from './timeZoneConverter' const DEFAULT_DATE_TIME = '1970-01-01 00:00:00' /** * Calculate the default entered_run_time for 00:00:00 based on timezone * @param timeZone - The IANA timezone identifier * @returns Formatted datetime string representing 00:00:00 in the specified timezone */ function getDefaultRunTime(timeZone: string): string { try { // Floating timezone → return raw epoch if (timeZone === 'floating') { return DEFAULT_DATE_TIME } // Valid timezone → treat reference as LOCAL in that timezone → convert to UTC const utcDate = timeFieldToXML({ hours: 0, minutes: 0, seconds: 0 }, timeZone) return formatDateToPlatformFormat(utcDate) } catch (error) { return DEFAULT_DATE_TIME } } /** * Map day of week numeric values to day names */ const dayOfWeekMap = new Map([ ['1', 'monday'], ['2', 'tuesday'], ['3', 'wednesday'], ['4', 'thursday'], ['5', 'friday'], ['6', 'saturday'], ['7', 'sunday'], ]) const offsetTypeMap = new Map([ ['1', 'past'], ['2', 'future'], ]) /** * Reverse map: offset type names to numeric values (generated from offsetTypeMap) */ const reverseOffsetTypeMap = new Map(Array.from(offsetTypeMap.entries()).map(([num, type]) => [type, num])) /** * Reverse map: day names to numeric values (generated from dayOfWeekMap) */ const reverseDayOfWeekMap = new Map(Array.from(dayOfWeekMap.entries()).map(([num, day]) => [day, num])) /** * Negate a duration object (multiply all values by -1) * @param duration - Duration object with days, hours, minutes, seconds * @returns Negated duration object */ function negateDuration(duration: { days?: number; hours?: number; minutes?: number; seconds?: number }) { return { days: duration.days ? -duration.days : 0, hours: duration.hours ? -duration.hours : 0, minutes: duration.minutes ? -duration.minutes : 0, seconds: duration.seconds ? -duration.seconds : 0, } } /** * Add duration to a Date object in place * @param date - Date object to modify * @param duration - Duration to add (days, hours, minutes, seconds) */ function addDuration(date: Date, duration: { days?: number; hours?: number; minutes?: number; seconds?: number }) { if (duration.days) { date.setDate(date.getDate() + duration.days) } if (duration.hours) { date.setHours(date.getHours() + duration.hours) } if (duration.minutes) { date.setMinutes(date.getMinutes() + duration.minutes) } if (duration.seconds) { date.setSeconds(date.getSeconds() + duration.seconds) } } /** * Add duration to a datetime string without timezone conversion * @param dateTimeStr - Datetime string in format 'YYYY-MM-DD HH:MM:SS' * @param duration - Duration to add (days, hours, minutes, seconds) * @returns New datetime string with duration added */ function addDurationToString( dateTimeStr: string, duration: { days?: number; hours?: number; minutes?: number; seconds?: number } ): string { const parts = dateTimeStr.split(' ') if (parts.length !== 2 || !parts[0] || !parts[1]) { return dateTimeStr } const dateParts = parts[0].split('-').map(Number) const timeParts = parts[1].split(':').map(Number) if (dateParts.length !== 3 || timeParts.length !== 3) { return dateTimeStr } if (dateParts.some(isNaN) || timeParts.some(isNaN)) { return dateTimeStr } const date = new Date( dateParts[0] ?? 0, (dateParts[1] ?? 1) - 1, dateParts[2] ?? 1, timeParts[0] ?? 0, timeParts[1] ?? 0, timeParts[2] ?? 0 ) addDuration(date, duration) const newYear = date.getFullYear() const newMonth = String(date.getMonth() + 1).padStart(2, '0') const newDay = String(date.getDate()).padStart(2, '0') const newHours = String(date.getHours()).padStart(2, '0') const newMinutes = String(date.getMinutes()).padStart(2, '0') const newSeconds = String(date.getSeconds()).padStart(2, '0') return `${newYear}-${newMonth}-${newDay} ${newHours}:${newMinutes}:${newSeconds}` } /** * Calculate the recurring interval in days for different run types * @param frequency - The type of recurring schedule * @returns Number of days in the interval, or undefined if not a fixed-interval type */ function calculateRecurringInterval(frequency: string): number | undefined { const recurrenceIntervals: Record = { daily: 1, weekly: 7, day_and_month_in_year: 365, day_week_month_year: 365, } return recurrenceIntervals[frequency] } /** * Calculate minimum end date for monthly recurring jobs * @param executionStart - Start datetime string * @param maxDrift - Optional max drift duration * @returns Object with minEndStr and intervalDescription */ function calculateMonthlyMinEndDate( executionStart: string, maxDrift?: { days?: number; hours?: number; minutes?: number; seconds?: number } ): { minEndStr: string | undefined; intervalDescription: string | undefined } { const parts = executionStart.split(' ') if (parts.length !== 2 || !parts[0] || !parts[1]) { return { minEndStr: undefined, intervalDescription: undefined } } const [datePart] = parts const dateParts = datePart.split('-').map(Number) if (dateParts.length !== 3) { return { minEndStr: undefined, intervalDescription: undefined } } const [year, month, day] = dateParts const nextDate = new Date(year!, month!, day!) const nextYear = nextDate.getFullYear() const nextMonth = String(nextDate.getMonth() + 1).padStart(2, '0') const nextDay = String(nextDate.getDate()).padStart(2, '0') let minEndStr = `${nextYear}-${nextMonth}-${nextDay} ${parts[1]}` if (maxDrift) { minEndStr = addDurationToString(minEndStr, negateDuration(maxDrift)) } const intervalDescription = `1 month${maxDrift ? ' - maxDrift' : ''}` return { minEndStr, intervalDescription } } /** * Calculate minimum end date for periodically recurring jobs * @param executionStart - Start datetime string * @param executionInterval - Duration for the period * @param maxDrift - Optional max drift duration * @returns Object with minEndStr and intervalDescription */ function calculatePeriodicallyMinEndDate( executionStart: string, executionInterval: { days?: number; hours?: number; minutes?: number; seconds?: number }, maxDrift?: { days?: number; hours?: number; minutes?: number; seconds?: number } ): { minEndStr: string; intervalDescription: string } { let minEndStr = addDurationToString(executionStart, executionInterval) if (maxDrift) { minEndStr = addDurationToString(minEndStr, negateDuration(maxDrift)) } const intervalDescription = `executionInterval${maxDrift ? ' - maxDrift' : ''}` return { minEndStr, intervalDescription } } /** * Calculate minimum end date for fixed-interval recurring jobs (daily, weekly, yearly) * @param executionStart - Start datetime string * @param intervalDays - Number of days in the interval * @param maxDrift - Optional max drift duration * @returns Object with minEndStr and intervalDescription */ function calculateFixedIntervalMinEndDate( executionStart: string, intervalDays: number, maxDrift?: { days?: number; hours?: number; minutes?: number; seconds?: number } ): { minEndStr: string; intervalDescription: string } { let minEndStr = addDurationToString(executionStart, { days: intervalDays }) if (maxDrift) { minEndStr = addDurationToString(minEndStr, negateDuration(maxDrift)) } const intervalDescription = `${intervalDays} day${intervalDays > 1 ? 's' : ''}${maxDrift ? ' - maxDrift' : ''}` return { minEndStr, intervalDescription } } /** * Validate that executionEnd allows at least one execution for recurring jobs * Based on ServiceNow's "Ensure Valid Schedule" business rules * @param executionStart - Start datetime string * @param executionEnd - End datetime string * @param frequency - Type of recurring schedule * @param executionIntervalShape - Shape containing executionInterval duration (for periodically jobs) * @param maxDriftShape - Shape containing maxDrift duration * @returns Object with minEndStr and intervalDescription if validation is needed, undefined otherwise */ function validateRecurringJobSchedule( executionStart: string, executionEnd: string, frequency: string, executionIntervalShape: Shape, maxDriftShape: Shape ): { minEndStr: string | undefined; intervalDescription: string | undefined } | undefined { if (frequency === 'once' || frequency === 'on_demand') { return } const maxDrift = maxDriftShape.is(DurationShape) ? maxDriftShape.as(DurationShape).getDuration() : undefined let minEndStr: string | undefined let intervalDescription: string | undefined if (frequency === 'periodically' && executionIntervalShape.is(DurationShape)) { const duration = executionIntervalShape.as(DurationShape).getDuration() const result = calculatePeriodicallyMinEndDate(executionStart, duration, maxDrift) minEndStr = result.minEndStr intervalDescription = result.intervalDescription } else if (frequency === 'monthly' || frequency === 'week_in_month') { const result = calculateMonthlyMinEndDate(executionStart, maxDrift) minEndStr = result.minEndStr intervalDescription = result.intervalDescription } else { const intervalDays = calculateRecurringInterval(frequency) if (intervalDays) { const result = calculateFixedIntervalMinEndDate(executionStart, intervalDays, maxDrift) minEndStr = result.minEndStr intervalDescription = result.intervalDescription } } if (minEndStr && executionEnd < minEndStr) { return { minEndStr, intervalDescription } } return undefined } export const ScheduledScriptPlugin = Plugin.create({ name: 'ScheduledScriptPlugin', records: { sysauto_script: { async toShape(record, { transform }) { const scriptValue = record.get('script').ifString()?.ifNotEmpty() const script = scriptValue ? await NowIncludeShape.fromRecord(record, record.get('script'), transform) : undefined const timeZone = record.get('time_zone').ifString()?.ifNotEmpty()?.getValue() // If timezone is 'floating' or not set, treat as UTC for consistent round-trip behavior const tz = !timeZone || timeZone === 'floating' ? 'UTC' : timeZone return { success: true, value: new CallExpressionShape({ source: record, callee: 'ScheduledScript', args: [ record.transform(({ $ }) => ({ $id: $.val(NowIdShape.from(record)), name: $, active: $.from('active').toBoolean().def(true), conditional: $.toBoolean().def(false), condition: $.def(''), // Offset configuration offset: $.map((v) => { const stringShape = v.ifString()?.ifNotEmpty() if (!stringShape) { return undefined } return DurationShape.from(record, stringShape) }), offsetType: $.from('offset_type').map((v) => { const value = v.ifString()?.getValue() // Map: 1='past', 2='future', 0=undefined (no offset) if (!value || value === '0') { return undefined } return offsetTypeMap.get(value) }), // Run as configuration runAs: $.from('run_as').def(''), userTimeZone: $.from('run_as_tz').def(''), // Schedule type and timing frequency: $.from('run_type'), dayOfWeek: $.from('run_dayofweek').map((v) => { const val = v.ifString()?.getValue() return val ? dayOfWeekMap.get(val) : undefined }), daysOfWeek: $.from('run_daysofweek').map((v) => { const val = v.ifString()?.getValue() if (!val) { return undefined } // Convert numeric string like '1234' to array of day names const days = val .split('') .map((num) => dayOfWeekMap.get(num)) .filter(Boolean) return days.length > 0 ? days : undefined }), dayOfMonth: $.from('run_dayofmonth').map((v) => { const val = v.ifString()?.ifNotEmpty() return val ? val.toNumber() : undefined }), weekInMonth: $.from('run_weekinmonth').map((v) => { const val = v.ifString()?.ifNotEmpty() return val ? val.toNumber() : undefined }), month: $.from('run_month').map((v) => { const val = v.ifString()?.ifNotEmpty() return val ? val.toNumber() : undefined }), // Time and duration configuration executionTime: $.from('run_time').map((v) => { const stringShape = v.ifString()?.ifNotEmpty() if (!stringShape) { return undefined } // Convert to TimeShape with timezone (tz normalizes empty/floating to UTC) const timeShape = TimeShape.from(record, stringShape, tz) const timeData = timeShape.getTimeData() // Return undefined if Time object would be empty (no hours, minutes, or seconds) if (!timeData.hours && !timeData.minutes && !timeData.seconds) { return undefined } return timeShape }), executionInterval: $.from('run_period').map((v) => { const stringShape = v.ifString()?.ifNotEmpty() if (!stringShape) { return undefined } return DurationShape.from(record, stringShape) }), executionStart: $.from('run_start').map((v) => { const stringShape = v.ifString()?.ifNotEmpty() if (stringShape) { return convertXMLToDateTime(stringShape.getValue(), tz) } return undefined }), executionEnd: $.from('run_end').map((v) => { const stringShape = v.ifString()?.ifNotEmpty() if (stringShape) { return convertXMLToDateTime(stringShape.getValue(), tz) } return undefined }), maxDrift: $.from('max_drift').map((v) => { const stringShape = v.ifString()?.ifNotEmpty() if (!stringShape) { return undefined } return DurationShape.from(record, stringShape) }), // Additional scheduling options repeatEvery: $.from('repeat_every').map((v) => v.ifString()?.isEmpty() || v.isUndefined() ? undefined : v.toNumber().getValue() ), upgradeSafe: $.from('upgrade_safe').toBoolean().def(false), timeZone: $.from('time_zone').map((v) => { const val = v.ifString()?.getValue() return val || undefined }), businessCalendar: $.from('business_calendar').def(''), advanced: $.toBoolean().def(false), // Protection policy protectionPolicy: $.from('sys_policy').map((v) => { const val = v.ifString()?.ifNotEmpty()?.getValue() // Only return valid values: 'read' or 'protected' return val === 'read' || val === 'protected' ? val : undefined }), // Script script: $.val(script), })), ], }), } }, }, }, shapes: [ { shape: CallExpressionShape, fileTypes: ['fluent'], async toRecord(callExpression, { factory, diagnostics, config }) { if (callExpression.getCallee() !== 'ScheduledScript') { return { success: false } } const args = callExpression.getArgument(0).asObject() validateServerScriptField(args.get('script'), diagnostics, config.serverModulesDir) // Validate executionInterval is defined when frequency is 'periodically' const frequency = args.get('frequency').ifString()?.getValue() if (frequency === 'periodically' && args.get('executionInterval').isUndefined()) { diagnostics.error( args.get('frequency'), `executionInterval must be defined when frequency is 'periodically'` ) } // Validate dayOfWeek or daysOfWeek is defined when frequency is 'weekly' if ( frequency === 'weekly' && args.get('daysOfWeek').isUndefined() && args.get('dayOfWeek').isUndefined() ) { diagnostics.error( args.get('frequency'), `dayOfWeek or daysOfWeek must be defined when frequency is 'weekly'` ) } // Validate businessCalendar is not empty when frequency is business calendar related const businessCalendarArg = args.get('businessCalendar') const businessCalendar = toReference(businessCalendarArg) if ( (frequency === 'business_calendar_start' || frequency === 'business_calendar_end') && !businessCalendar ) { diagnostics.error( businessCalendarArg, `businessCalendar cannot be empty when frequency is '${frequency}'. Provide a valid business calendar reference.` ) } // Validate executionStart datetime format const startDate = args.get('executionStart').ifString()?.getValue() if (startDate && !/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(startDate)) { diagnostics.error( args.get('executionStart'), `Invalid datetime format for executionStart: '${startDate}'. Expected format: 'YYYY-MM-DD HH:MM:SS' (e.g., '2024-01-01 00:00:00')` ) } // Validate executionEnd datetime format const endDate = args.get('executionEnd').ifString()?.getValue() if (endDate) { if (!/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(endDate)) { diagnostics.error( args.get('executionEnd'), `Invalid datetime format for executionEnd: '${endDate}'. Expected format: 'YYYY-MM-DD HH:MM:SS' (e.g., '2024-12-31 23:59:59')` ) } // Validate executionStart and executionEnd relationship else if (startDate) { const start = new Date(startDate) const end = new Date(endDate) if (end <= start) { diagnostics.error( args.get('executionEnd'), `executionEnd ('${endDate}') must be after executionStart ('${startDate}')` ) } // Validate recurring job schedule allows at least one execution else { const validationResult = validateRecurringJobSchedule( startDate, endDate, frequency ?? 'daily', args.get('executionInterval'), args.get('maxDrift') ) if (validationResult) { diagnostics.error( args.get('executionEnd'), `executionEnd ('${endDate}') may not allow any executions for recurring job type '${frequency}'. ` + `Minimum executionEnd should be at least '${validationResult.minEndStr}' (executionStart + ${validationResult.intervalDescription})` ) } } } } // Check timezone before transformation // Use 'UTC' as default to match toShape behavior when time_zone is empty const rawTimeZone = args.get('timeZone').ifString()?.getValue() const timeZone = !rawTimeZone || rawTimeZone === 'floating' ? 'UTC' : rawTimeZone // Validate executionTime timezone consistency const executionTimeArg = args.get('executionTime') if (rawTimeZone && rawTimeZone !== 'floating' && executionTimeArg.is(TimeShape)) { const timeShape = executionTimeArg.as(TimeShape) const timeShapeTimeZone = timeShape.getTimeZone() if (!timeShapeTimeZone) { diagnostics.error( executionTimeArg, `executionTime must include timezone when ScheduledScript timeZone is '${timeZone}'. ` + `Use Time({ hours: ..., minutes: ..., }, '${timeZone}' }) instead of Time({ hours: ..., minutes: ... }).` ) } else if (timeShapeTimeZone !== timeZone) { diagnostics.error( executionTimeArg, `executionTime timezone '${timeShapeTimeZone}' does not match ScheduledScript timeZone '${timeZone}'. ` + `They should be the same.` ) } } // Calculate default entered_run_time based on timezone const defaultRunTime = getDefaultRunTime(timeZone ?? '') const record = await factory.createRecord({ source: callExpression, table: 'sysauto_script', explicitId: args.get('$id'), properties: args.transform(({ $ }) => ({ name: $, active: $.from('active').def(true), conditional: $.def(false), condition: $.toCdata(), // Offset configuration offset: $.map((v) => { if (v.isUndefined()) { return undefined } // Convert raw object to DurationShape if needed (supports both Duration() helper and raw { days, hours, minutes, seconds }) const durationShape = v.is(DurationShape) ? v.as(DurationShape) : DurationShape.from(v.getSource(), v.asObject()) return durationShape.toString() }), offset_type: $.from('offsetType').map((v) => { const val = v.ifString()?.getValue() return val ? reverseOffsetTypeMap.get(val) : '0' }), // Run as configuration run_as: $.from('runAs').map(toReference), run_as_tz: $.from('userTimeZone'), // Schedule type and timing run_type: $.from('frequency').def('daily'), run_dayofweek: $.from('dayOfWeek').map((v) => { const val = v.ifString()?.getValue() return val ? reverseDayOfWeekMap.get(val) : '1' }), run_daysofweek: $.from('daysOfWeek').map((v) => { // Convert array of day names to numeric string like '1234' if (v.isArray()) { const nums = v .asArray() .map((item) => { const dayName = item.ifString()?.getValue() return dayName ? reverseDayOfWeekMap.get(dayName) : null }) .filter(Boolean) return nums.length > 0 ? nums.join('') : undefined } return undefined }), run_dayofmonth: $.from('dayOfMonth').def('1'), run_weekinmonth: $.from('weekInMonth'), run_month: $.from('month'), // Time and duration configuration run_time: $.from('executionTime').map((v) => { if (v.isUndefined()) { return defaultRunTime } // Convert raw object to TimeShape if needed (supports both Time() helper and raw { hours, minutes, seconds }) const timeShape = v.is(TimeShape) ? v.as(TimeShape) : TimeShape.from(v.getSource(), v.asObject(), timeZone) // If timezone is floating, return the actual time without UTC conversion if (timeZone === 'floating') { const timeData = timeShape.getTimeData() return formatTimeDataToDateTime(timeData) } return timeShape.toString() }), entered_time: $.from('executionTime').map((v) => { if (v.isUndefined()) { return undefined } // Convert raw object to TimeShape if needed (supports both Time() helper and raw { hours, minutes, seconds }) const timeShape = v.is(TimeShape) ? v.as(TimeShape) : TimeShape.from(v.getSource(), v.asObject(), timeZone) const timeData = timeShape.getTimeData() return formatTimeDataToDateTime(timeData) }), run_period: $.from('executionInterval').map((v) => { if (v.isUndefined()) { return undefined } // Convert raw object to DurationShape if needed (supports both Duration() helper and raw { days, hours, minutes, seconds }) const durationShape = v.is(DurationShape) ? v.as(DurationShape) : DurationShape.from(v.getSource(), v.asObject()) return durationShape.toString() }), run_start: $.from('executionStart').map((v) => { const dateTimeStr = v.ifString()?.getValue() if (dateTimeStr) { return dateTimeFieldToXML(dateTimeStr, timeZone) } return undefined }), run_end: $.from('executionEnd').map((v) => { const dateTimeStr = v.ifString()?.getValue() if (!dateTimeStr) { return undefined } return dateTimeFieldToXML(dateTimeStr, timeZone) }), // Generate from executionStart - okay to add if missing entered_run_start: $.from('executionStart').map((v) => { const dateTimeStr = v.ifString()?.getValue() if (dateTimeStr && timeZone) { return dateTimeStr } return undefined }), entered_run_end: $.from('executionEnd').map((v) => { const dateTimeStr = v.ifString()?.getValue() if (dateTimeStr && timeZone) { return dateTimeStr } return undefined }), max_drift: $.from('maxDrift').map((v) => { if (v.isUndefined()) { return undefined } // Convert raw object to DurationShape if needed (supports both Duration() helper and raw { days, hours, minutes, seconds }) const durationShape = v.is(DurationShape) ? v.as(DurationShape) : DurationShape.from(v.getSource(), v.asObject()) return durationShape.toString() }), // Additional scheduling options repeat_every: $.from('repeatEvery'), upgrade_safe: $.from('upgradeSafe').def(false), time_zone: $.from('timeZone'), business_calendar: $.val(businessCalendar), advanced: $.def(false), // Protection policy sys_policy: $.from('protectionPolicy'), // Script - handle both module functions and strings script: $.map( (v) => v.if(ModuleFunctionShape)?.toString((n) => `${n}({{PARAMS}})`, []) ?? v ).toCdata(), sys_name: $.from('name'), })), }) return { success: true, value: record } }, }, ], })