diff --git a/Examples/Example-Table/Example-TableDateTimeConditionsFormats.ps1 b/Examples/Example-Table/Example-TableDateTimeConditionsFormats.ps1 new file mode 100644 index 00000000..81a9f3c9 --- /dev/null +++ b/Examples/Example-Table/Example-TableDateTimeConditionsFormats.ps1 @@ -0,0 +1,31 @@ +Import-Module .\PSWriteHTML.psd1 -Force + +# Demonstrates date comparisons with formats commonly used in PowerShell/.NET output, +# and verifies both HTML and JavaScript DataStore modes. + +$DateDeleteCheck = Get-Date -Year 2025 -Month 1 -Day 5 -Hour 0 -Minute 0 -Second 0 + +$AdminsDisabled = @( + [pscustomobject]@{ User = 'Before threshold'; RefreshTokenDate = $DateDeleteCheck.AddDays(-1) } + [pscustomobject]@{ User = 'Equal to threshold'; RefreshTokenDate = $DateDeleteCheck } + [pscustomobject]@{ User = 'After threshold'; RefreshTokenDate = $DateDeleteCheck.AddDays(1) } + [pscustomobject]@{ User = 'Empty / null'; RefreshTokenDate = $null } +) + +New-HTML { + New-HTMLSection -HeaderText 'HTML DataStore (dd/MM/yyyy)' { + New-HTMLTableOption -DataStore HTML -DateTimeFormat 'dd/MM/yyyy HH:mm:ss' + New-HTMLTable -DataTable $AdminsDisabled -HideFooter -DisablePaging -DateTimeSortingFormat 'DD/MM/YYYY HH:mm:ss' { + New-HTMLTableHeader -Title 'HTML: Highlight RefreshTokenDate < threshold' + New-HTMLTableCondition -Name 'RefreshTokenDate' -ComparisonType 'date' -Operator lt -Value $DateDeleteCheck -BackgroundColor Red -Color Black -FailBackgroundColor LightGreen -FailColor Black -DateTimeFormat 'dd/MM/YYYY HH:mm:ss' + } + } + + New-HTMLSection -HeaderText 'JavaScript DataStore (dd/MM/yyyy)' { + New-HTMLTableOption -DataStore JavaScript -DateTimeFormat 'dd/MM/yyyy HH:mm:ss' + New-HTMLTable -DataTable $AdminsDisabled -HideFooter -DisablePaging -DateTimeSortingFormat 'DD/MM/YYYY HH:mm:ss' { + New-HTMLTableHeader -Title 'JavaScript: Highlight RefreshTokenDate < threshold' + New-HTMLTableCondition -Name 'RefreshTokenDate' -ComparisonType 'date' -Operator lt -Value $DateDeleteCheck -BackgroundColor Red -Color Black -FailBackgroundColor LightGreen -FailColor Black -DateTimeFormat 'dd/MM/YYYY HH:mm:ss' + } + } +} -ShowHTML -FilePath $PSScriptRoot\Example-TableDateTimeConditionsFormats.html -Online diff --git a/PSWriteHTML.psd1 b/PSWriteHTML.psd1 index bdaa2b88..2ed82de8 100644 --- a/PSWriteHTML.psd1 +++ b/PSWriteHTML.psd1 @@ -8,19 +8,20 @@ Description = 'PSWriteHTML is PowerShell Module to generate beautiful HTML reports, pages, emails without any knowledge of HTML, CSS or JavaScript. To get started basics PowerShell knowledge is required.' FunctionsToExport = @('Add-HTML', 'Add-HTMLScript', 'Add-HTMLStyle', 'ConvertTo-CascadingStyleSheets', 'Email', 'EmailAttachment', 'EmailBCC', 'EmailBody', 'EmailCC', 'EmailFrom', 'EmailHeader', 'EmailLayout', 'EmailLayoutColumn', 'EmailLayoutRow', 'EmailListItem', 'EmailOptions', 'EmailReplyTo', 'EmailServer', 'EmailSubject', 'EmailTo', 'Enable-HTMLFeature', 'New-AccordionItem', 'New-CalendarEvent', 'New-CarouselSlide', 'New-ChartAxisX', 'New-ChartAxisY', 'New-ChartBar', 'New-ChartBarOptions', 'New-ChartDataLabel', 'New-ChartDesign', 'New-ChartDonut', 'New-ChartEvent', 'New-ChartGrid', 'New-ChartLegend', 'New-ChartLine', 'New-ChartMarker', 'New-ChartPie', 'New-ChartRadial', 'New-ChartRadialOptions', 'New-ChartSpark', 'New-ChartTheme', 'New-ChartTimeLine', 'New-ChartToolbar', 'New-ChartToolTip', 'New-DiagramEvent', 'New-DiagramLink', 'New-DiagramNode', 'New-DiagramOptionsInteraction', 'New-DiagramOptionsLayout', 'New-DiagramOptionsLinks', 'New-DiagramOptionsManipulation', 'New-DiagramOptionsNodes', 'New-DiagramOptionsPhysics', 'New-GageSector', 'New-HierarchicalTreeNode', 'New-HTML', 'New-HTMLAccordion', 'New-HTMLAnchor', 'New-HTMLCalendar', 'New-HTMLCarousel', 'New-HTMLCarouselStyle', 'New-HTMLChart', 'New-HTMLCodeBlock', 'New-HTMLContainer', 'New-HTMLDate', 'New-HTMLDiagram', 'New-HTMLFontIcon', 'New-HTMLFooter', 'New-HTMLFrame', 'New-HTMLGage', 'New-HTMLHeader', 'New-HTMLHeading', 'New-HTMLHierarchicalTree', 'New-HTMLHorizontalLine', 'New-HTMLImage', 'New-HTMLInfoCard', 'New-HTMLList', 'New-HTMLListItem', 'New-HTMLLogo', 'New-HTMLMain', 'New-HTMLMap', 'New-HTMLMarkdown', 'New-HTMLMermeidChart', 'New-HTMLNav', 'New-HTMLNavFloat', 'New-HTMLNavTop', 'New-HTMLOrgChart', 'New-HTMLPage', 'New-HTMLPanel', 'New-HTMLPanelStyle', 'New-HTMLQRCode', 'New-HTMLSection', 'New-HTMLSectionScrolling', 'New-HTMLSectionScrollingItem', 'New-HTMLSectionStyle', 'New-HTMLSpanStyle', 'New-HTMLStatus', 'New-HTMLStatusItem', 'New-HTMLSummary', 'New-HTMLSummaryItem', 'New-HTMLSummaryItemData', 'New-HTMLTab', 'New-HTMLTable', 'New-HTMLTableOption', 'New-HTMLTableStyle', 'New-HTMLTabPanel', 'New-HTMLTabPanelColor', 'New-HTMLTabStyle', 'New-HTMLTag', 'New-HTMLText', 'New-HTMLTextBox', 'New-HTMLTimeline', 'New-HTMLTimelineItem', 'New-HTMLToast', 'New-HTMLTree', 'New-HTMLTreeChildCounter', 'New-HTMLTreeNode', 'New-HTMLWinBox', 'New-HTMLWizard', 'New-HTMLWizardColor', 'New-HTMLWizardStep', 'New-MapArea', 'New-MapLegendOption', 'New-MapLegendSlice', 'New-MapPlot', 'New-NavFloatWidget', 'New-NavFloatWidgetItem', 'New-NavItem', 'New-NavLink', 'New-NavTopMenu', 'New-OrgChartNode', 'New-TableAlphabetSearch', 'New-TableButtonColumnVisibility', 'New-TableButtonCopy', 'New-TableButtonCSV', 'New-TableButtonExcel', 'New-TableButtonPageLength', 'New-TableButtonPDF', 'New-TableButtonPrint', 'New-TableButtonSearchBuilder', 'New-TableColumnOption', 'New-TableCondition', 'New-TableConditionGroup', 'New-TableContent', 'New-TableEvent', 'New-TableHeader', 'New-TableLanguage', 'New-TablePercentageBar', 'New-TablePercentageBarCondition', 'New-TableReplace', 'New-TableRowGrouping', 'Out-HtmlView', 'Save-HTML') GUID = 'a7bdf640-f5cb-4acf-9de0-365b322d245c' - ModuleVersion = '1.39.0' + ModuleVersion = '1.40.0' PowerShellVersion = '5.1' PrivateData = @{ PSData = @{ - IconUri = 'https://evotec.xyz/wp-content/uploads/2018/12/PSWriteHTML.png' - ProjectUri = 'https://github.com/EvotecIT/PSWriteHTML' - Tags = @('HTML', 'WWW', 'JavaScript', 'CSS', 'Reports', 'Reporting', 'Windows', 'MacOS', 'Linux') + IconUri = 'https://evotec.xyz/wp-content/uploads/2018/12/PSWriteHTML.png' + ProjectUri = 'https://github.com/EvotecIT/PSWriteHTML' + RequireLicenseAcceptance = $false + Tags = @('HTML', 'WWW', 'JavaScript', 'CSS', 'Reports', 'Reporting', 'Windows', 'MacOS', 'Linux') } } RequiredModules = @(@{ Guid = 'ee272aa8-baaa-4edf-9f45-b6d6f7d844fe' ModuleName = 'PSSharedGoods' - ModuleVersion = '0.0.310' + ModuleVersion = '0.0.312' }) RootModule = 'PSWriteHTML.psm1' } \ No newline at end of file diff --git a/Private/Parameters.Configuration.ps1 b/Private/Parameters.Configuration.ps1 index 366004e8..c49d6895 100644 --- a/Private/Parameters.Configuration.ps1 +++ b/Private/Parameters.Configuration.ps1 @@ -962,7 +962,7 @@ Comment = 'DataTables Conditions' Header = @{ JsLink = @( - "https://cdn.jsdelivr.net/npm/@evotecit/htmlextensions@0.1.2/dist/datatables.columnHighlighter.js" + "https://cdn.jsdelivr.net/npm/@evotecit/htmlextensions@0.1.12/dist/datatables.columnHighlighter.js" ) JS = @( "$PSScriptRoot\..\Resources\JS\dataTables.columnHighlighter.js" @@ -2202,4 +2202,4 @@ # 'Mermaid' # ) # Import-Module "C:\Support\GitHub\PSWriteHTML.Helper\PSWriteHTML.Helper.psd1" -Force -# Save-HTMLResource -Configuration $Configuration -Keys $Keys -PathToSave 'C:\Support\GitHub\PSWriteHTML\Resources\CSS' -Verbose \ No newline at end of file +# Save-HTMLResource -Configuration $Configuration -Keys $Keys -PathToSave 'C:\Support\GitHub\PSWriteHTML\Resources\CSS' -Verbose diff --git a/Resources/JS/dataTables.columnHighlighter.js b/Resources/JS/dataTables.columnHighlighter.js index 4e5d6679..1ebe35b0 100644 --- a/Resources/JS/dataTables.columnHighlighter.js +++ b/Resources/JS/dataTables.columnHighlighter.js @@ -1,18 +1,287 @@ -// DataTables Column Highlighter (standalone) -// Combines conditional engine and responsive child-row + visible cell styling -// Usage: -// $('#table').DataTable({ -// columnHighlighter: { rules: [ /* see README.md */ ] } -// }); -/* - DataTables Column Highlighter v1.0.0 - (c) 2025 EvotecIT | MIT - https://github.com/EvotecIT/HTMLExtensions +/*! + HTMLExtensions v0.1.12 — DataTables ColumnHighlighter & ToggleView + (c) 2011–2025 Przemyslaw Klys @ Evotec + https://htmlextensions.evotec.xyz | MIT License | Build: 2025-12-14T18:31:19.713Z */ + (function(){ if (typeof window === 'undefined') { return; } function isEmptyOrSpaces(str) { return !str || str.trim() === ''; } + // Normalize header/column names for tolerant matching (case/spacing/punctuation) + function normalizeName(input) { + try { return ('' + input).toLowerCase().replace(/[\s\u00A0\u202F\-_]/g, '').replace(/[^a-z0-9]/gi, ''); } catch(_) { return ''+input; } + } + + // Parse numbers accepting both "." and "," decimal separators (and typical thousands separators) + function parseLocaleNumber(input) { + if (typeof input === 'number') return input; + var s = ('' + input).trim(); + if (!s) return undefined; + // remove regular/narrow/nb spaces used as group separators + s = s.replace(/[\s\u00A0\u202F]/g, ''); + var hasComma = s.indexOf(',') !== -1; + var hasDot = s.indexOf('.') !== -1; + if (hasComma && hasDot) { + // The last separator is the decimal; the other is group separator + if (s.lastIndexOf(',') > s.lastIndexOf('.')) { + // comma decimal, dot thousands + s = s.replace(/\./g, ''); + s = s.replace(',', '.'); + } else { + // dot decimal, comma thousands + s = s.replace(/,/g, ''); + } + } else if (hasComma && !hasDot) { + // Only comma present -> treat as decimal separator + s = s.replace(',', '.'); + } else { + // Only dot or neither -> unchanged + } + var n = Number(s); + return isNaN(n) ? undefined : n; + } + + function isValidDate(d) { + return d instanceof Date && !isNaN(d.valueOf()); + } + + function uniqPush(arr, value) { + if (!value) return; + if (arr.indexOf(value) === -1) arr.push(value); + } + + // Converts common .NET / PowerShell date format tokens to Moment-compatible tokens. + // Also converts .NET quoted literals ('...') into Moment literals ([...]) so letters don't get interpreted as tokens. + function dotNetToMomentFormat(fmt) { + if (!fmt || typeof fmt !== 'string') return fmt; + var out = ''; + var i = 0; + while (i < fmt.length) { + var ch = fmt[i]; + + // .NET literal: '...' + if (ch === "'") { + var j = i + 1; + var lit = ''; + while (j < fmt.length) { + if (fmt[j] === "'") { + // Escaped single quote: '' + if (j + 1 < fmt.length && fmt[j + 1] === "'") { + lit += "'"; + j += 2; + continue; + } + break; + } + lit += fmt[j]; + j++; + } + // consume closing quote if present + if (j < fmt.length && fmt[j] === "'") j++; + out += '[' + lit + ']'; + i = j; + continue; + } + + // .NET escape: \x (treat x as literal) + if (ch === '\\') { + if (i + 1 < fmt.length) { + out += '[' + fmt[i + 1] + ']'; + i += 2; + } else { + i++; + } + continue; + } + + // Token runs (same letter repeated) + if (/[A-Za-z]/.test(ch)) { + var k = i + 1; + while (k < fmt.length && fmt[k] === ch) k++; + var token = fmt.slice(i, k); + var c = token.charAt(0); + var len = token.length; + + if (c === 'y') { + out += (len <= 2) ? 'YY' : 'YYYY'; + } else if (c === 'd') { + // dd = day-of-month in .NET, but dd = weekday in Moment; map only 1-2 to day-of-month. + out += (len <= 2) ? (len === 2 ? 'DD' : 'D') : token; + } else if (c === 'f' || c === 'F') { + out += (len === 1) ? 'S' : (len === 2 ? 'SS' : 'SSS'); + } else if (c === 't') { + // AM/PM designator + out += 'A'; + } else if (c === 'K') { + out += 'Z'; + } else if (c === 'z') { + out += 'Z'; + } else { + out += token; + } + + i = k; + continue; + } + + out += ch; + i++; + } + return out; + } + + function buildDateFormatCandidates(fmt) { + var candidates = []; + if (!fmt || typeof fmt !== 'string') return candidates; + var base = fmt.trim(); + if (!base) return candidates; + + uniqPush(candidates, base); + var normalized = dotNetToMomentFormat(base); + if (normalized && normalized !== base) uniqPush(candidates, normalized); + + // Common user typos: dd -> DD, yyyy -> YYYY (even when already "moment-like") + var quickFix = base + .replace(/(^|[^d])dd([^d]|$)/g, '$1DD$2') + .replace(/(^|[^d])d([^d]|$)/g, '$1D$2') + .replace(/(^|[^y])yyyy([^y]|$)/g, '$1YYYY$2') + .replace(/(^|[^y])yyy([^y]|$)/g, '$1YYYY$2') + .replace(/(^|[^y])yy([^y]|$)/g, '$1YY$2') + .replace(/tt/g, 'A') + .replace(/f{3}/g, 'SSS') + .replace(/f{2}/g, 'SS') + .replace(/f/g, 'S'); + if (quickFix && quickFix !== base) uniqPush(candidates, quickFix); + + var normalizedFix = dotNetToMomentFormat(quickFix); + if (normalizedFix && candidates.indexOf(normalizedFix) === -1) uniqPush(candidates, normalizedFix); + + return candidates; + } + + function hasTimezoneOffset(s) { + if (!s) return false; + // ISO Z or numeric offsets like +02:00 / -0500 + return /[zZ]$/.test(s) || /[+-]\d{2}:?\d{2}$/.test(s); + } + + function detectDateOrder(fmt) { + if (!fmt || typeof fmt !== 'string') return null; + var normalized = dotNetToMomentFormat(fmt); + // Remove moment literals ([...]) so we don't match tokens inside literal text + try { normalized = normalized.replace(/\[[^\]]*\]/g, ''); } catch (_) { /* noop */ } + + var idxY = normalized.search(/Y{2,4}/); + var idxM = normalized.search(/M{1,4}/); + var idxD = normalized.search(/D{1,2}/); + if (idxY === -1 || idxM === -1 || idxD === -1) return null; + + var arr = [{ t: 'Y', i: idxY }, { t: 'M', i: idxM }, { t: 'D', i: idxD }]; + arr.sort(function (a, b) { return a.i - b.i; }); + return [arr[0].t, arr[1].t, arr[2].t]; + } + + function parseDateByFormatHint(value, fmt) { + if (!value) return undefined; + var s = ('' + value).trim(); + if (!s) return undefined; + + // If we have timezone information, native parsing is usually best (and respects the offset) + if (hasTimezoneOffset(s)) { + var dIso = new Date(s); + if (isValidDate(dIso)) return dIso; + } + + var numsRaw = s.match(/\d+/g); + if (!numsRaw || numsRaw.length < 3) return undefined; + var nums = numsRaw.map(function (x) { return parseInt(x, 10); }); + + var order = detectDateOrder(fmt); + if (!order) { + // Best-effort inference when format is missing or unrecognizable + if (numsRaw[0] && numsRaw[0].length === 4) order = ['Y', 'M', 'D']; + else if (nums[0] > 12) order = ['D', 'M', 'Y']; + else if (nums[1] > 12) order = ['M', 'D', 'Y']; + else order = null; + } + if (!order) return undefined; + + var map = {}; + map[order[0]] = nums[0]; + map[order[1]] = nums[1]; + map[order[2]] = nums[2]; + + var year = map.Y, month = map.M, day = map.D; + if (typeof year !== 'number' || typeof month !== 'number' || typeof day !== 'number') return undefined; + + // Two-digit year handling (match Moment's pivot-ish behavior) + if (year < 100) { + year = (year >= 68) ? (1900 + year) : (2000 + year); + } + + var hour = nums.length > 3 ? nums[3] : 0; + var minute = nums.length > 4 ? nums[4] : 0; + var second = nums.length > 5 ? nums[5] : 0; + var ms = 0; + if (nums.length > 6) { + // Use the first 3 digits of the next group as milliseconds (covers 3/6/7 digit fractions from .NET) + var msStr = '' + numsRaw[6]; + ms = parseInt(msStr.substring(0, 3), 10); + if (isNaN(ms)) ms = 0; + } + + // Handle 12h clocks with AM/PM + var normalized = dotNetToMomentFormat(fmt || ''); + var uses12h = normalized && normalized.indexOf('h') !== -1 && normalized.indexOf('H') === -1; + var hasMeridiem = normalized && normalized.indexOf('A') !== -1; + if (uses12h || hasMeridiem) { + var upper = s.toUpperCase(); + if (upper.indexOf('PM') !== -1 && hour < 12) hour += 12; + if (upper.indexOf('AM') !== -1 && hour === 12) hour = 0; + } + + var d = new Date(year, month - 1, day, hour, minute, second, ms); + return isValidDate(d) ? d : undefined; + } + + function parseDateValue(value, fmt) { + if (value === null || value === undefined) return undefined; + + if (value instanceof Date) return value; + if (typeof value === 'number') { + var dn = new Date(value); + return isValidDate(dn) ? dn : undefined; + } + + var s = ('' + value).trim(); + if (!s) return undefined; + + // Prefer Moment.js if available (strict parsing + format candidates) + if (typeof moment !== 'undefined') { + var candidates = buildDateFormatCandidates(fmt); + var m; + if (candidates.length > 0) { + m = moment(s, candidates, true); + if (!m.isValid()) m = moment(s, candidates, false); + } else { + m = moment(s); + } + if (m && typeof m.isValid === 'function' && m.isValid()) { + return m.toDate(); + } + } + + // When we have a format hint, try our lightweight parser first to avoid locale-dependent native parsing. + if (fmt) { + var dh = parseDateByFormatHint(s, fmt); + if (isValidDate(dh)) return dh; + } + + // Fallback to native Date parsing (works well for ISO strings) + var d = new Date(s); + return isValidDate(d) ? d : undefined; + } function dataTablesCheckCondition(condition, data) { var columnName = condition['columnName']; @@ -46,17 +315,14 @@ if (Array.isArray(conditionValue)) { var tmp = []; for (var i = 0; i < conditionValue.length; i++) { - if (!isEmptyOrSpaces(String(conditionValue[i]))) { tmp.push(Number(conditionValue[i])); } - else { tmp.push(undefined); } + var parsed = parseLocaleNumber(conditionValue[i]); + tmp.push(parsed); } conditionValue = tmp; - if (!isEmptyOrSpaces(String(columnValue))) { columnValue = Number(columnValue); } - else { columnValue = undefined; } + columnValue = parseLocaleNumber(columnValue); } else { - if (!isEmptyOrSpaces(String(conditionValue))) { conditionValue = Number(conditionValue); } - else { conditionValue = undefined; } - if (!isEmptyOrSpaces(String(columnValue))) { columnValue = Number(columnValue); } - else { columnValue = undefined; } + conditionValue = parseLocaleNumber(conditionValue); + columnValue = parseLocaleNumber(columnValue); } } else if (condition['type'] == 'date') { if (Array.isArray(condition['valueDate'])) { @@ -70,8 +336,8 @@ var vd = condition['valueDate']; conditionValue = new Date(vd.year, vd.month - 1, vd.day, vd.hours, vd.minutes, vd.seconds); } - var momentConversion = (typeof moment !== 'undefined') ? moment(columnValue, condition['dateTimeFormat']) : columnValue; - columnValue = new Date(momentConversion); + var parsed = parseDateValue(columnValue, condition['dateTimeFormat']); + columnValue = parsed ? parsed : new Date(NaN); } var left, right; if (reverseCondition) { left = conditionValue; right = columnValue; } else { left = columnValue; right = conditionValue; } if (operator == 'eq') { return left == right; } @@ -91,16 +357,78 @@ function getHeaderNames(tableId) { try { - var names = []; var $ths = jQuery('#' + tableId + ' thead th'); - $ths.each(function(){ names.push(jQuery(this).text().trim()); }); return names; + // Prefer cached headers captured from DataTables API during init (avoids title rows / multi-headers). + try { + if (window.DataTablesColumnHighlighter && window.DataTablesColumnHighlighter.configurations) { + var cached = window.DataTablesColumnHighlighter.configurations[tableId]; + if (cached && Array.isArray(cached.headers) && cached.headers.length > 0) { + return cached.headers; + } + } + } catch (_) { /* noop */ } + + var $table = jQuery('#' + tableId); + + // If table is already initialized, DataTables knows the correct header cells for columns. + try { + if (jQuery.fn && jQuery.fn.dataTable && jQuery.fn.dataTable.isDataTable($table)) { + var api = $table.DataTable(); + if (api && api.columns && api.columns().header) { + var headerNodes = api.columns().header().toArray(); + var namesApi = []; + for (var i = 0; i < headerNodes.length; i++) { + namesApi.push(jQuery(headerNodes[i]).text().trim()); + } + if (namesApi.length > 0) return namesApi; + } + } + } catch (_) { /* noop */ } + + // Fallback: pick the header row with the most non-empty header cells. + // This ignores PSWriteHTML-style title rows (