Current File : /home/jeconsul/public_html/wp-content/plugins/suremails/src/utils/utils.js
import { __ } from '@wordpress/i18n';
import clsx from 'clsx';
import { format as format_date } from 'date-fns';
import { twMerge } from 'tailwind-merge';
import DOMPurify from 'dompurify';

/**
 * Formats a given date string based on the provided options.
 *
 * @param {string}  dateString       - The date string to format.
 * @param {Object}  options          - Formatting options to customize the output.
 * @param {boolean} [options.day]    - Whether to include the day in the output.
 * @param {boolean} [options.month]  - Whether to include the month in the output.
 * @param {boolean} [options.year]   - Whether to include the year in the output.
 * @param {boolean} [options.hour]   - Whether to include the hour in the output.
 * @param {boolean} [options.minute] - Whether to include the minute in the output.
 * @param {boolean} [options.hour12] - Whether to use a 12-hour clock format.
 * @return {string} - The formatted date string or a fallback if the input is invalid.
 */
export const formatDate = ( dateString, options = {} ) => {
	if ( ! dateString || isNaN( new Date( dateString ).getTime() ) ) {
		return __( 'No Date', 'suremails' );
	}

	const optionMap = {
		day: '2-digit',
		month: 'short',
		year: 'numeric',
		hour: '2-digit',
		minute: '2-digit',
		hour12: true, // Note: hour12 is a boolean directly
	};

	const formattingOptions = Object.keys( optionMap ).reduce( ( acc, key ) => {
		if ( options[ key ] === true ) {
			acc[ key ] = optionMap[ key ];
		} else if ( options[ key ] === false ) {
		} else if ( options[ key ] !== undefined ) {
			acc[ key ] = options[ key ];
		}
		return acc;
	}, {} );

	return new Intl.DateTimeFormat( 'en-US', formattingOptions ).format(
		new Date( dateString )
	);
};

/**
 *
 * @return {string} - The formatted date string.
 */

export const getDatePlaceholder = () => {
	const currentDate = new Date();
	const pastDate = new Date();
	pastDate.setDate( currentDate.getDate() - 30 ); // Set to 30 days ago

	const formattedPastDate = formatDate( pastDate, 'MM/dd/yyyy' );
	const formattedCurrentDate = formatDate( currentDate, 'MM/dd/yyyy' );

	return `${ formattedPastDate } - ${ formattedCurrentDate }`;
};

/**
 *
 * @return {string} - The formatted date string.
 */

export const getLastNDays = ( days ) => {
	if ( isNaN( days ) ) {
		return {
			from: '',
			to: '',
		};
	}
	const currentDate = new Date();
	const pastDate = new Date();
	pastDate.setDate( currentDate.getDate() - days ); // Set to 30 days ago

	const formattedPastDate = formatDate( pastDate, 'MM/dd/yyyy' );
	const formattedCurrentDate = formatDate( currentDate, 'MM/dd/yyyy' );

	return {
		from: formattedPastDate,
		to: formattedCurrentDate,
	};
};

/**
 * Returns selected date in string format
 *
 * @param {*} selectedDates
 * @return {string} - Formatted string.
 */
export const getSelectedDate = ( selectedDates ) => {
	if ( ! selectedDates.from ) {
		return '';
	}
	if ( ! selectedDates.to ) {
		return `${ format( selectedDates.from, 'MM/dd/yyyy' ) }`;
	}
	return `${ format( selectedDates.from, 'MM/dd/yyyy' ) } - ${ format(
		selectedDates.to,
		'MM/dd/yyyy'
	) }`;
};

/**
 * Utility function to sort an array of objects based on a specified key.
 *
 * @param {Array}  data      - The array of objects to sort.
 * @param {string} key       - The key in the objects to sort by.
 * @param {string} direction - Sort direction: 'asc' for ascending, 'desc' for descending.
 * @return {Array} - The sorted array of objects.
 */
export const sortData = ( data, key, direction = 'asc' ) => {
	if ( ! Array.isArray( data ) || ! key ) {
		return data; // Return data as is if invalid
	}

	const sortedData = [ ...data ].sort( ( a, b ) => {
		const valueA =
			new Date( a[ key ] ) instanceof Date &&
			! isNaN( new Date( a[ key ] ).getTime() )
				? new Date( a[ key ] )
				: a[ key ];
		const valueB =
			new Date( b[ key ] ) instanceof Date &&
			! isNaN( new Date( b[ key ] ).getTime() )
				? new Date( b[ key ] )
				: b[ key ];

		if ( valueA < valueB ) {
			return direction === 'asc' ? -1 : 1;
		}
		if ( valueA > valueB ) {
			return direction === 'asc' ? 1 : -1;
		}
		return 0;
	} );

	return sortedData;
};

/**
 * Formats a given date string based on the provided options.
 * If no options are provided, it defaults to 'yyyy-MM-dd' format.
 *
 * @param {string|Date} date                      - The date string or Date object to format.
 * @param {string}      [dateFormat='yyyy-MM-dd'] - The date format string for `date-fns`.
 * @return {string} - The formatted date string or a fallback if the input is invalid.
 */
export const format = ( date, dateFormat = 'yyyy-MM-dd' ) => {
	try {
		if ( ! date || isNaN( new Date( date ).getTime() ) ) {
			throw new Error( __( 'Invalid Date', 'suremails' ) );
		}
		return format_date( new Date( date ), dateFormat );
	} catch ( error ) {
		return __( 'No Date', 'suremails' );
	}
};

/**
 * Parses headers provided either as a raw string or an array of strings into a structured object.
 *
 * Each header line is expected to be in the format "Header-Name: header value".
 * This function handles:
 *   - Numerical prefixes (e.g., "0: From: ...")
 *   - Multiple header values for the same header name.
 *
 * @param {string | string[]} headersInput - The raw headers string or an array of header strings.
 * @return {Object} - An object where the keys are normalized header names and the values are arrays of header values.
 */
export const parseHeaders = ( headersInput ) => {
	const headers = {};

	let headerLines = [];
	if ( Array.isArray( headersInput ) ) {
		headerLines = headersInput;
	} else if ( typeof headersInput === 'string' ) {
		headerLines = headersInput.split( /\r?\n/ );
	} else {
		return headers;
	}

	headerLines.forEach( ( line ) => {
		if ( ! line.trim() ) {
			return;
		}

		// Remove a leading numerical prefix if present (e.g., "0: From: ...").
		const prefixMatch = line.match( /^\d+:\s*(.*)$/ );
		const cleanedLine = prefixMatch ? prefixMatch[ 1 ] : line;

		// Find the first colon (:) that separates the header name from its value.
		const separatorIndex = cleanedLine.indexOf( ':' );
		if ( separatorIndex === -1 ) {
			return;
		}

		// Extract and trim the header name.
		const name = cleanedLine.slice( 0, separatorIndex ).trim();
		if ( ! name ) {
			return;
		}

		const value = cleanedLine.slice( separatorIndex + 1 ).trim();

		const normalizedName = normalizeHeaderName( name );

		// Initialize the header's value array if it doesn't exist.
		if ( ! headers[ normalizedName ] ) {
			headers[ normalizedName ] = [];
		}

		headers[ normalizedName ].push( value );
	} );

	return headers;
};

/**
 * Normalizes header names to a standard format.
 *
 * E.g., 'reply-to' => 'Reply-To'
 *
 * @param {string} name - The header name to normalize.
 * @return {string} - The normalized header name.
 */
const normalizeHeaderName = ( name ) => {
	return name
		.split( '-' )
		.map(
			( word ) =>
				word.charAt( 0 ).toUpperCase() + word.slice( 1 ).toLowerCase()
		)
		.join( '-' );
};

/**
 * Utility function to merge Tailwind CSS and conditional class names.
 *
 * @param {...any} args
 * @return {string} - The concatenated class string.
 */
export const cn = ( ...args ) => twMerge( clsx( ...args ) );

/**
 * Generates a range of page numbers and ellipses for pagination.
 *
 * @param {number} currentPage  - The current active page.
 * @param {number} totalPages   - The total number of pages.
 * @param {number} siblingCount - Number of pages to show on each side of the current page.
 * @return {Array} An array containing page numbers and 'ellipsis' strings.
 */
export const getPaginationRange = (
	currentPage,
	totalPages,
	siblingCount = 1
) => {
	// Calculate common values
	const siblingFactor = siblingCount * 2; // Sibling count multiplied by 2
	const totalPageNumbers = siblingFactor + 5; // Total numbers including ellipses and edges

	if ( totalPageNumbers >= totalPages ) {
		// If all pages can fit within the range
		return Array.from( { length: totalPages }, ( _, i ) => i + 1 );
	}

	// Calculate indices
	const leftSiblingIndex = Math.max( currentPage - siblingCount, 1 ); // Left sibling index
	const rightSiblingIndex = Math.min(
		currentPage + siblingCount,
		totalPages
	);

	const showLeftEllipsis = leftSiblingIndex > 2;
	const showRightEllipsis = rightSiblingIndex < totalPages - 1;

	// Constants for the first and last pages
	const firstPage = 1;
	const lastPage = totalPages;

	const pages = [];

	if ( ! showLeftEllipsis && showRightEllipsis ) {
		// Calculate range for the left side
		const leftItemCount = 3 + siblingFactor; // Number of items on the left
		const leftRange = Array.from(
			{ length: leftItemCount },
			( _, i ) => i + 1
		);
		pages.push( ...leftRange, 'ellipsis', lastPage );
	} else if ( showLeftEllipsis && ! showRightEllipsis ) {
		// Calculate range for the right side
		const rightItemCount = 3 + siblingFactor; // Number of items on the right
		const rightRange = Array.from(
			{ length: rightItemCount },
			( _, i ) => totalPages - rightItemCount + i + 1
		);
		pages.push( firstPage, 'ellipsis', ...rightRange );
	} else if ( showLeftEllipsis && showRightEllipsis ) {
		// Calculate middle range
		const middleRange = Array.from(
			{ length: siblingFactor + 1 },
			( _, i ) => currentPage - siblingCount + i
		);
		pages.push(
			firstPage,
			'ellipsis',
			...middleRange,
			'ellipsis',
			lastPage
		);
	}

	return pages;
};

/**
 * Get the label for the log status
 *
 * @param {string} status   - The status of the log
 * @param {Array}  response - Array of response objects.
 * @return {string} - The label for the status
 */
export const getStatusLabel = ( status, response ) => {
	const simulated = isResponseSimulated( response );

	if ( simulated ) {
		return __( 'Simulated', 'suremails' );
	}
	switch ( status ) {
		case 'sent':
			return __( 'Successful', 'suremails' );
		case 'failed':
			return __( 'Failed', 'suremails' );
		case 'pending':
			return __( 'In Progress', 'suremails' );
		case 'blocked':
			return __( 'Blocked', 'suremails' );
		default:
			return __( 'Unknown', 'suremails' );
	}
};

/**
 * Get the variant for the log status badge
 *
 * @param {string} status   - The status of the log
 * @param {Array}  response - Array of response objects.
 * @return {string} - The variant for the badge
 */
export const getStatusVariant = ( status, response ) => {
	const simulated = isResponseSimulated( response );

	if ( simulated ) {
		return 'yellow';
	}
	switch ( status ) {
		case 'sent':
			return 'green';
		case 'failed':
			return 'red';
		case 'pending':
			return 'yellow';
		case 'blocked':
			return 'red';
		default:
			return 'gray'; // Fallback color for unknown statuses
	}
};

/**
 * Determines if the response indicates a simulated log.
 *
 * It finds the response element with the highest "retry" value and returns its "simulated" flag.
 *
 * @param {Array} response - Array of response objects.
 * @return {boolean} - True if the element with the highest retry has simulated set to true, false otherwise.
 */
const isResponseSimulated = ( response ) => {
	if ( ! Array.isArray( response ) || response.length === 0 ) {
		return false;
	}
	// Find the response object with the maximum "retry" value.
	const maxRetryEntry = response.reduce( ( prev, curr ) =>
		curr.retry >= prev.retry ? curr : prev
	);
	return maxRetryEntry.simulated;
};

/**
 * A utility class to manipulate shadow DOM elements.
 */
export class ShadowDOM {
	element = null;
	shadowRoot = null;
	mode = 'open';

	/**
	 * Constructor for the ShadowDOM class.
	 *
	 * @param {HTMLElement} element - The element to attach the shadow DOM to.
	 * @param {string}      mode    - The mode of the shadow root: 'open' or 'closed'.
	 */
	constructor( element, mode = 'open' ) {
		this.element = element;
		this.mode = mode;
		this.shadowRoot = this.attachShadow( mode );
	}

	/**
	 * Update the element to attach the shadow DOM to.
	 *
	 * @param {HTMLElement} element - The element to attach the shadow DOM to.
	 */
	updateElement( element ) {
		this.element = element;
	}

	/**
	 * Check if the element has a shadow root.
	 *
	 * @return {boolean} - Whether the element has a shadow root.
	 */
	hasShadowRoot() {
		if ( ! this.element ) {
			return false;
		}
		if ( this.mode === 'closed' ) {
			return this.shadowRoot !== null;
		}
		return this.element.shadowRoot !== null;
	}

	/**
	 * Attach a shadow root to the element.
	 *
	 * @param {string} mode - The mode of the shadow root: 'open' or 'closed'.
	 * @return {ShadowRoot} - The shadow root.
	 */
	attachShadow( mode = 'open' ) {
		if ( this.hasShadowRoot() ) {
			return this.element.shadowRoot;
		}
		return this.element.attachShadow( { mode } );
	}

	/**
	 * Append a child to the shadow DOM.
	 *
	 * @param {HTMLElement} child - The child element to append.
	 * @return {HTMLElement} - The appended child element.
	 */
	appendChild( child ) {
		if ( ! this.hasShadowRoot() ) {
			return;
		}
		if ( this.mode === 'closed' ) {
			return this.shadowRoot.appendChild( child );
		}
		return this.element.shadowRoot.appendChild( child );
	}

	/**
	 * Check if the shadow DOM has child nodes.
	 *
	 * @return {boolean} - Whether the shadow DOM has child nodes.
	 */
	hasChildNodes() {
		if ( ! this.hasShadowRoot() ) {
			return false;
		}
		if ( this.mode === 'closed' ) {
			return this.shadowRoot.hasChildNodes();
		}
		return this.element.shadowRoot.hasChildNodes();
	}

	/**
	 * Set the inner HTML of the shadow DOM.
	 *
	 * @param {string} content - The content to set as the inner HTML.
	 */
	innerHTML( content ) {
		if ( ! this.hasShadowRoot() ) {
			return;
		}
		if ( this.mode === 'closed' ) {
			this.shadowRoot.innerHTML = content;
			return;
		}
		this.element.shadowRoot.innerHTML = content;
	}
}

/**
 * Check if the string contains an HTML tag
 *
 * @param {string} str - The string to check
 * @return {boolean} - Whether the string contains an HTML tag
 */
export const containsHtmlTag = ( str ) => {
	return /<[^>]*>/.test( str );
};

/**
 * Converts newlines to <br/> tags
 *
 * @param {string}  str      - The string to convert
 * @param {boolean} is_xhtml - Whether to use XHTML compatible tags
 * @return {string} - The converted string
 */
const nl2br = ( str, is_xhtml = false ) => {
	if ( typeof str === 'undefined' || str === null ) {
		return '';
	}
	const breakTag = is_xhtml ? '<br />' : '<br>';
	return ( str + '' ).replace(
		/([^>\r\n]?)(\r\n|\n\r|\r|\n)/g,
		'$1' + breakTag + '$2'
	);
};

/**
 * Converts plain text to HTML with proper formatting.
 * - Converts newlines to <br/> tags
 * - Converts URLs to clickable links
 * - Sanitizes output to prevent XSS
 *
 * @param {string} text - The plain text to convert
 * @return {string} - Sanitized HTML string
 */
export const stringToHtml = ( text ) => {
	if ( ! text ) {
		return '';
	}
	let parsedText = text;

	// Check if string contains an HTML tag
	const hasHtmlTag = containsHtmlTag( text );

	// If the text/string is not HTML, convert it to HTML
	if ( ! hasHtmlTag ) {
		// Convert URLs to clickable links
		const urlRegex = /(https?:\/\/[^\s]+)/g;
		parsedText = parsedText.replace(
			urlRegex,
			'<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>'
		);

		// Convert newlines to <br/> tags
		parsedText = nl2br( parsedText.trim() );
	}

	// For security, override target and rel attributes to links
	DOMPurify.addHook( 'afterSanitizeAttributes', function ( node ) {
		// set all elements owning target to target=_blank and rel=noopener noreferrer
		if ( 'target' in node ) {
			node.setAttribute( 'target', '_blank' );
			node.setAttribute( 'rel', 'noopener noreferrer' );
		}
	} );

	// Sanitize the final HTML
	return DOMPurify.sanitize( parsedText );
};

/**
 * Get the query params from the URL
 *
 * @return {Object} - The query params
 */
export const getQueryParams = () => {
	try {
		const url = new URL( window.location.href );
		return url.searchParams;
	} catch ( error ) {
		return null;
	}
};

/**
 * Check if the query param is in the URL
 *
 * @param {string} param - The query param to check
 * @return {boolean} - Whether the query param is in the URL
 */
export const hasInQueryParams = ( param ) => {
	try {
		const queryParams = getQueryParams();
		return queryParams?.has( param );
	} catch ( error ) {
		return false;
	}
};

/**
 * Remove the query param from the URL and replace the URL
 *
 * @param {string} param - The query param to remove
 * @return {boolean} - Whether the query param was removed
 */
export const removeQueryParam = ( param ) => {
	try {
		const url = new URL( window.location.href );
		const queryParams = url.searchParams;
		queryParams.delete( param );

		// Construct new URL with original path and hash
		const newUrl = `${ url.origin }${ url.pathname }`;
		const searchString = queryParams.toString();
		const finalUrl = searchString
			? `${ newUrl }?${ searchString }`
			: newUrl;

		// Append hash if it exists
		const urlWithHash = url.hash ? `${ finalUrl }${ url.hash }` : finalUrl;

		window.history.replaceState( null, '', urlWithHash );
	} catch ( error ) {
		return false;
	}
};

/**
 * Convert a Connection string from UTC to the browser's local time.
 * Expects a string in the format: "Used {connection_title}, at {utcTimestamp}"
 *
 * @param {string} connectionStr
 */
export const convertUTCConnection = ( connectionStr ) => {
	const lastAtIndex = connectionStr.lastIndexOf( ', at ' );
	if ( lastAtIndex === -1 ) {
		return connectionStr;
	}

	const utcTimestamp = connectionStr.substring( lastAtIndex + 5 ).trim();
	const utcDate = new Date( utcTimestamp + ' UTC' );

	if ( isNaN( utcDate.getTime() ) ) {
		return connectionStr;
	}

	const localTimestamp = utcDate.toLocaleString( 'en-US', {
		month: 'short',
		day: 'numeric',
		year: 'numeric',
		hour: 'numeric',
		minute: '2-digit',
		hour12: true,
	} );

	return (
		connectionStr.substring( 0, lastAtIndex ) + `, at ${ localTimestamp }`
	);
};

export const get_connection_message = ( connection_title, timeStamp ) => {
	const formattedTimeStamp = formatDate( timeStamp, {
		day: true,
		month: true,
		year: true,
		hour: true,
		minute: true,
		hour12: true,
	} );
	return `Used ${ connection_title }, at ${ formattedTimeStamp }`;
};

/**
 * Check if the status is pending or not
 *
 * @param {string} status - The status of the log
 * @return {boolean} - Whether the status is pending
 */
export const get_pending_status = ( status ) => {
	if ( status && status === 'pending' ) {
		return true;
	}
	return false;
};