outlook-mcp/utils/odata-helpers.js

420 lines
14 KiB
JavaScript

/**
* OData helper functions for Microsoft Graph API
*/
const config = require('../config');
const { resolveTimeZone } = require('./timezone-mapper');
/**
* Returns the effective IANA timezone for date math.
* @param {string} [timeZone] - IANA or Windows timezone name (defaults to config.DEFAULT_TIMEZONE)
* @returns {string}
*/
function getEffectiveTimeZone(timeZone = config.DEFAULT_TIMEZONE) {
return resolveTimeZone(timeZone);
}
/**
* Parses a formatted date string from Intl.DateTimeFormat into numeric components.
* @param {Date} date - UTC Date
* @param {string} timeZone - IANA timezone name
* @param {string[]} partsNeeded - parts to include
* @returns {Object}
*/
function formatParts(date, timeZone, partsNeeded = ['year', 'month', 'day']) {
const fmt = new Intl.DateTimeFormat('en-US', {
timeZone,
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
hour12: false
});
const allParts = fmt.formatToParts(date).reduce((acc, p) => {
acc[p.type] = p.value;
return acc;
}, {});
const result = {};
for (const key of partsNeeded) {
result[key] = parseInt(allParts[key], 10);
}
return result;
}
/**
* Returns the local (wall-clock) year/month/day for a UTC Date in the target timezone.
* @param {Date} date - UTC Date
* @param {string} timeZone - IANA timezone name
* @returns {{year:number, month:number, day:number}}
*/
function getLocalDateComponents(date, timeZone) {
if (!timeZone || timeZone === 'UTC') {
return { year: date.getUTCFullYear(), month: date.getUTCMonth() + 1, day: date.getUTCDate() };
}
return formatParts(date, timeZone, ['year', 'month', 'day']);
}
/**
* Computes the offset in milliseconds between UTC and the target timezone at a given UTC instant.
* Positive offset means the timezone is ahead of UTC.
* @param {Date} date - UTC Date
* @param {string} timeZone - IANA timezone name
* @returns {number} offset in ms
*/
function getTimeZoneOffsetMs(date, timeZone) {
if (!timeZone || timeZone === 'UTC') return 0;
const utcParts = {
year: date.getUTCFullYear(),
month: date.getUTCMonth() + 1,
day: date.getUTCDate(),
hour: date.getUTCHours(),
minute: date.getUTCMinutes(),
second: date.getUTCSeconds()
};
const localParts = formatParts(date, timeZone, ['year', 'month', 'day', 'hour', 'minute', 'second']);
const utcMs = Date.UTC(
utcParts.year, utcParts.month - 1, utcParts.day,
utcParts.hour, utcParts.minute, utcParts.second, date.getUTCMilliseconds()
);
const localMs = Date.UTC(
localParts.year, localParts.month - 1, localParts.day,
localParts.hour, localParts.minute, localParts.second, date.getUTCMilliseconds()
);
return localMs - utcMs;
}
/**
* Convert a local date/time expressed in the target timezone into a UTC Date.
*
* @param {number} year - local year
* @param {number} month - local month (1-12)
* @param {number} day - local day
* @param {number} [hours=0] - local hour
* @param {number} [minutes=0] - local minute
* @param {number} [seconds=0] - local second
* @param {number} [ms=0] - local millisecond
* @param {string} [timeZone] - IANA or Windows timezone name (defaults to config.DEFAULT_TIMEZONE)
* @returns {Date} UTC instant
*/
function localToUtc(year, month, day, hours = 0, minutes = 0, seconds = 0, ms = 0, timeZone = config.DEFAULT_TIMEZONE) {
const tz = getEffectiveTimeZone(timeZone);
// Desired wall-clock timestamp in the target timezone, expressed as ms since Unix epoch
// *as if* that wall-clock time were UTC. This gives us a starting guess.
const targetMs = Date.UTC(year, month - 1, day, hours, minutes, seconds, ms);
let guess = new Date(targetMs);
// Converge on the real UTC instant for that wall-clock time (handles DST changes).
// residual = how far the guess's local time is ahead of (+) / behind (-) the target local time.
for (let i = 0; i < 5; i++) {
const offsetMs = getTimeZoneOffsetMs(guess, tz);
const actualLocalMs = guess.getTime() + offsetMs;
const residual = actualLocalMs - targetMs;
if (residual === 0) {
return guess;
}
guess = new Date(guess.getTime() - residual);
}
return guess;
}
/**
* Gets UTC instant for start of the local day containing `date` in the configured timezone.
* @param {Date} date - The date
* @param {string} [timeZone] - IANA or Windows timezone name (defaults to config.DEFAULT_TIMEZONE)
* @returns {Date} - UTC instant of start of day in that timezone
*/
function startOfDay(date, timeZone = config.DEFAULT_TIMEZONE) {
const tz = getEffectiveTimeZone(timeZone);
const { year, month, day } = getLocalDateComponents(date, tz);
return localToUtc(year, month, day, 0, 0, 0, 0, tz);
}
/**
* Gets UTC instant for end of the local day containing `date` in the configured timezone.
* @param {Date} date - The date
* @param {string} [timeZone] - IANA or Windows timezone name (defaults to config.DEFAULT_TIMEZONE)
* @returns {Date} - UTC instant of end of day in that timezone
*/
function endOfDay(date, timeZone = config.DEFAULT_TIMEZONE) {
const tz = getEffectiveTimeZone(timeZone);
const { year, month, day } = getLocalDateComponents(date, tz);
return localToUtc(year, month, day, 23, 59, 59, 999, tz);
}
/**
* Escapes a string for use in OData queries
* @param {string} str - The string to escape
* @returns {string} - The escaped string
*/
function escapeODataString(str) {
if (!str) return str;
// Replace single quotes with double single quotes (OData escaping)
// And remove any special characters that could cause OData syntax errors
str = str.replace(/'/g, "''");
// Escape other potentially problematic characters
str = str.replace(/[\(\)\{\}\[\]\:\;\,\/\?\&\=\+\*\%\$\#\@\!\^]/g, '');
console.error(`Escaped OData string: '${str}'`);
return str;
}
/**
* Builds an OData filter from filter conditions
* @param {Array<string>} conditions - Array of filter conditions
* @returns {string} - Combined OData filter expression
*/
function buildODataFilter(conditions) {
if (!conditions || conditions.length === 0) {
return '';
}
return conditions.join(' and ');
}
/**
* Parses date input (ISO string or relative date)
* @param {string} dateInput - Date string
* @returns {Date} - Parsed date
*/
function parseDate(dateInput) {
if (!dateInput) return null;
const now = new Date();
const today = new Date(now);
// Handle relative dates
const relativeMap = {
'today': today,
'yesterday': new Date(now.getTime() - 24*60*60*1000),
'tomorrow': new Date(now.getTime() + 24*60*60*1000),
'last7days': new Date(now.getTime() - 7*24*60*60*1000),
'last30days': new Date(now.getTime() - 30*24*60*60*1000),
'last90days': new Date(now.getTime() - 90*24*60*60*1000)
};
if (relativeMap[dateInput.toLowerCase()]) {
return relativeMap[dateInput.toLowerCase()];
}
// Handle ISO dates
const parsed = new Date(dateInput);
if (isNaN(parsed.getTime())) {
throw new Error(`Invalid date format: ${dateInput}`);
}
return parsed;
}
/**
* Returns the UTC instant for a date-only local string (YYYY-MM-DD).
* Treats the date as midnight (or end-of-day) in the configured timezone.
* @param {string} dateInput - Date-only string
* @param {boolean} endOfDayFlag - If true, return end of that local day
* @param {string} [timeZone] - IANA or Windows timezone name (defaults to config.DEFAULT_TIMEZONE)
* @returns {Date}
*/
function localDateStringToInstant(dateInput, endOfDayFlag = false, timeZone = config.DEFAULT_TIMEZONE) {
const tz = getEffectiveTimeZone(timeZone);
const [year, month, day] = dateInput.split('-').map(Number);
if (endOfDayFlag) {
return localToUtc(year, month, day, 23, 59, 59, 999, tz);
}
return localToUtc(year, month, day, 0, 0, 0, 0, tz);
}
/**
* Processes predefined date ranges
* @param {string} dateRange - Predefined range
* @param {string} [timeZone] - IANA or Windows timezone name (defaults to config.DEFAULT_TIMEZONE)
* @returns {Object} - Object with from and to dates
*/
function processDateRange(dateRange, timeZone = config.DEFAULT_TIMEZONE) {
if (!dateRange) return null;
const tz = getEffectiveTimeZone(timeZone);
const now = new Date();
const today = new Date(now);
switch (dateRange.toLowerCase()) {
case 'today':
return {
from: startOfDay(today, tz),
to: endOfDay(today, tz)
};
case 'yesterday': {
const { year, month, day } = getLocalDateComponents(today, tz);
const yesterday = subtractLocalDays(year, month, day, 1);
return {
from: localToUtc(yesterday.year, yesterday.month, yesterday.day, 0, 0, 0, 0, tz),
to: localToUtc(yesterday.year, yesterday.month, yesterday.day, 23, 59, 59, 999, tz)
};
}
case 'last7days':
return {
from: startOfDay(new Date(now.getTime() - 7*24*60*60*1000), tz),
to: endOfDay(now, tz)
};
case 'last30days':
return {
from: startOfDay(new Date(now.getTime() - 30*24*60*60*1000), tz),
to: endOfDay(now, tz)
};
case 'last90days':
return {
from: startOfDay(new Date(now.getTime() - 90*24*60*60*1000), tz),
to: endOfDay(now, tz)
};
case 'thisweek': {
const { year, month, day } = getLocalDateComponents(today, tz);
const localMidnight = localToUtc(year, month, day, 0, 0, 0, 0, tz);
const dayOfWeek = localMidnight.getUTCDay();
const startOfWeek = subtractLocalDays(year, month, day, dayOfWeek);
return {
from: localToUtc(startOfWeek.year, startOfWeek.month, startOfWeek.day, 0, 0, 0, 0, tz),
to: endOfDay(today, tz)
};
}
case 'lastweek': {
const { year, month, day } = getLocalDateComponents(today, tz);
const localMidnight = localToUtc(year, month, day, 0, 0, 0, 0, tz);
const dayOfWeek = localMidnight.getUTCDay();
const startOfLastWeek = subtractLocalDays(year, month, day, dayOfWeek + 7);
const endOfLastWeek = subtractLocalDays(year, month, day, dayOfWeek + 1);
return {
from: localToUtc(startOfLastWeek.year, startOfLastWeek.month, startOfLastWeek.day, 0, 0, 0, 0, tz),
to: localToUtc(endOfLastWeek.year, endOfLastWeek.month, endOfLastWeek.day, 23, 59, 59, 999, tz)
};
}
case 'thismonth': {
const { year, month } = getLocalDateComponents(today, tz);
return {
from: localToUtc(year, month, 1, 0, 0, 0, 0, tz),
to: endOfDay(today, tz)
};
}
case 'lastmonth': {
const { year, month } = getLocalDateComponents(today, tz);
const startOfLastMonth = subtractLocalMonths(year, month, 1);
// Last day of the previous month (in UTC calendar arithmetic, which mirrors local Y/M).
const lastDay = new Date(Date.UTC(startOfLastMonth.year, startOfLastMonth.month, 0)).getUTCDate();
return {
from: localToUtc(startOfLastMonth.year, startOfLastMonth.month, 1, 0, 0, 0, 0, tz),
to: localToUtc(startOfLastMonth.year, startOfLastMonth.month, lastDay, 23, 59, 59, 999, tz)
};
}
default:
throw new Error(`Unknown date range: ${dateRange}`);
}
}
/**
* Subtract a number of days from a local calendar date.
* @param {number} year
* @param {number} month
* @param {number} day
* @param {number} days
* @returns {{year:number, month:number, day:number}}
*/
function subtractLocalDays(year, month, day, days) {
const d = new Date(Date.UTC(year, month - 1, day));
d.setUTCDate(d.getUTCDate() - days);
return {
year: d.getUTCFullYear(),
month: d.getUTCMonth() + 1,
day: d.getUTCDate()
};
}
/**
* Subtract a number of months from a local calendar date.
* @param {number} year
* @param {number} month
* @param {number} months
* @returns {{year:number, month:number}}
*/
function subtractLocalMonths(year, month, months) {
let newMonth = month - months;
let newYear = year;
while (newMonth < 1) {
newMonth += 12;
newYear -= 1;
}
while (newMonth > 12) {
newMonth -= 12;
newYear += 1;
}
return { year: newYear, month: newMonth };
}
/**
* Builds date filter conditions for OData queries
* @param {string} dateFrom - Start date
* @param {string} dateTo - End date
* @param {string} dateRange - Predefined range
* @param {string} [timeZone] - IANA or Windows timezone name (defaults to config.DEFAULT_TIMEZONE)
* @returns {Array<string>} - Array of filter conditions
*/
function buildDateFilter(dateFrom, dateTo, dateRange, timeZone = config.DEFAULT_TIMEZONE) {
const conditions = [];
try {
if (dateRange) {
const range = processDateRange(dateRange, timeZone);
if (range) {
conditions.push(`receivedDateTime ge ${range.from.toISOString()}`);
conditions.push(`receivedDateTime le ${range.to.toISOString()}`);
}
} else {
if (dateFrom) {
// Date-only strings are local-day boundaries in the configured timezone
if (dateFrom.length === 10) {
conditions.push(`receivedDateTime ge ${localDateStringToInstant(dateFrom, false, timeZone).toISOString()}`);
} else {
const fromDate = parseDate(dateFrom);
conditions.push(`receivedDateTime ge ${fromDate.toISOString()}`);
}
}
if (dateTo) {
if (dateTo.length === 10) {
conditions.push(`receivedDateTime le ${localDateStringToInstant(dateTo, true, timeZone).toISOString()}`);
} else {
const toDate = parseDate(dateTo);
conditions.push(`receivedDateTime le ${toDate.toISOString()}`);
}
}
}
} catch (error) {
console.error(`Date filter error: ${error.message}`);
// Return empty conditions on error to avoid breaking the query
}
return conditions;
}
module.exports = {
escapeODataString,
buildODataFilter,
parseDate,
processDateRange,
buildDateFilter,
startOfDay,
endOfDay,
localDateStringToInstant,
getEffectiveTimeZone
};