diff --git a/package-lock.json b/package-lock.json index 9dd724f..8056393 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "firefox-extension-webhook-trigger", + "name": "browser-extension-webhook-trigger", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "firefox-extension-webhook-trigger", + "name": "browser-extension-webhook-trigger", "version": "1.0.0", "license": "ISC", "devDependencies": { diff --git a/popup/popup.js b/popup/popup.js index 9e26f02..323286c 100644 --- a/popup/popup.js +++ b/popup/popup.js @@ -158,6 +158,10 @@ document if (webhook && webhook.customPayload) { try { // Create variable replacements map + const now = new Date(); +// Create a new Date object with local time values but in UTC, to easily extract local date/time parts. +const nowLocal = new Date(now.getTime() - now.getTimezoneOffset() * 60000); + const replacements = { "{{tab.title}}": activeTab.title, "{{tab.url}}": currentUrl, @@ -172,30 +176,54 @@ document "{{platform.arch}}": platformInfo.arch || "unknown", "{{platform.os}}": platformInfo.os || "unknown", "{{platform.version}}": platformInfo.version, - "{{triggeredAt}}": new Date().toISOString(), - "{{identifier}}": webhook.identifier || "" + "{{identifier}}": webhook.identifier || "", + // Legacy variable + "{{triggeredAt}}": now.toISOString(), + // New DateTime variables (UTC) + "{{now.iso}}": now.toISOString(), + "{{now.date}}": now.toISOString().slice(0, 10), + "{{now.time}}": now.toISOString().slice(11, 19), + "{{now.unix}}": Math.floor(now.getTime() / 1000), + "{{now.unix_ms}}": now.getTime(), + "{{now.year}}": now.getUTCFullYear(), + "{{now.month}}": now.getUTCMonth() + 1, +"{{now.local.iso}}": (() => { + const offsetMinutes = now.getTimezoneOffset(); + const sign = offsetMinutes > 0 ? "-" : "+"; + const hours = ("0" + Math.floor(Math.abs(offsetMinutes / 60))).slice(-2); + const minutes = ("0" + Math.abs(offsetMinutes % 60)).slice(-2); + return nowLocal.toISOString().slice(0, -1) + sign + hours + ":" + minutes; +})(), + "{{now.hour}}": now.getUTCHours(), + "{{now.minute}}": now.getUTCMinutes(), + "{{now.second}}": now.getUTCSeconds(), + "{{now.millisecond}}": now.getUTCMilliseconds(), + // New DateTime variables (local) + "{{now.local.iso}}": nowLocal.toISOString().slice(0, -1) + (now.getTimezoneOffset() > 0 ? "-" : "+") + ("0" + Math.abs(now.getTimezoneOffset() / 60)).slice(-2) + ":" + ("0" + Math.abs(now.getTimezoneOffset() % 60)).slice(-2), + "{{now.local.date}}": nowLocal.toISOString().slice(0, 10), + "{{now.local.time}}": nowLocal.toISOString().slice(11, 19), }; - // Replace placeholders in custom payload - let customPayloadStr = webhook.customPayload; - Object.entries(replacements).forEach(([placeholder, value]) => { - // Handle different types of values - // For string values in JSON, we need to handle them differently based on context - // If the placeholder is inside quotes in the JSON, we should not add quotes again - const isPlaceholderInQuotes = customPayloadStr.match(new RegExp(`"[^"]*${placeholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[^"]*"`, 'g')); - - const replaceValue = typeof value === 'string' - ? (isPlaceholderInQuotes ? value.replace(/"/g, '\\"') : `"${value.replace(/"/g, '\\"')}"`) - : (value === undefined ? 'null' : JSON.stringify(value)); + // Parse the custom payload as JSON + let customPayload = JSON.parse(webhook.customPayload); - customPayloadStr = customPayloadStr.replace( - new RegExp(placeholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), - replaceValue - ); - }); + // Recursively replace placeholders + const replacePlaceholders = (obj) => { + for (const key in obj) { + if (obj.hasOwnProperty(key)) { + if (typeof obj[key] === 'string') { + const placeholder = obj[key]; + if (replacements.hasOwnProperty(placeholder)) { + obj[key] = replacements[placeholder]; + } + } else if (typeof obj[key] === 'object' && obj[key] !== null) { + replacePlaceholders(obj[key]); + } + } + } + }; - // Parse the resulting JSON - const customPayload = JSON.parse(customPayloadStr); + replacePlaceholders(customPayload); // Use the custom payload instead of the default one payload = customPayload; diff --git a/tests/popup.test.js b/tests/popup.test.js index a2934f1..e9ba5d7 100644 --- a/tests/popup.test.js +++ b/tests/popup.test.js @@ -69,8 +69,25 @@ describe('popup script', () => { expect(fetchMock.mock.calls[0][0]).toBe('https://hook.test'); }); - test('uses custom payload when available', async () => { - const customPayload = '{"message": "Custom message with {{tab.title}}"}'; + test('uses custom payload with all datetime variables', async () => { + const customPayload = JSON.stringify({ + "triggeredAt": "{{triggeredAt}}", + "now.iso": "{{now.iso}}", + "now.date": "{{now.date}}", + "now.time": "{{now.time}}", + "now.unix": "{{now.unix}}", + "now.unix_ms": "{{now.unix_ms}}", + "now.year": "{{now.year}}", + "now.month": "{{now.month}}", + "now.day": "{{now.day}}", + "now.hour": "{{now.hour}}", + "now.minute": "{{now.minute}}", + "now.second": "{{now.second}}", + "now.millisecond": "{{now.millisecond}}", + "now.local.iso": "{{now.local.iso}}", + "now.local.date": "{{now.local.date}}", + "now.local.time": "{{now.local.time}}", + }); const hook = { id: '1', label: 'Send', @@ -91,6 +108,18 @@ describe('popup script', () => { status: 'complete' }]); + // Mock Date + const mockDate = new Date('2025-08-07T10:20:30.123Z'); + const OriginalDate = global.Date; + global.Date = jest.fn((...args) => { + if (args.length) { + return new OriginalDate(...args); + } + return mockDate; + }); + global.Date.now = jest.fn(() => mockDate.getTime()); + global.Date.toISOString = jest.fn(() => mockDate.toISOString()); + require('../popup/popup.js'); document.dispatchEvent(new dom.window.Event('DOMContentLoaded')); await new Promise(setImmediate); @@ -102,10 +131,32 @@ describe('popup script', () => { expect(fetchMock).toHaveBeenCalled(); - // Check that the custom payload was used with the placeholder replaced const fetchOptions = fetchMock.mock.calls[0][1]; const sentPayload = JSON.parse(fetchOptions.body); - expect(sentPayload).toEqual({ message: 'Custom message with Test Page' }); + + const expectedPayload = { + "triggeredAt": "2025-08-07T10:20:30.123Z", + "now.iso": "2025-08-07T10:20:30.123Z", + "now.date": "2025-08-07", + "now.time": "10:20:30", + "now.unix": Math.floor(mockDate.getTime() / 1000), + "now.unix_ms": mockDate.getTime(), + "now.year": 2025, + "now.month": 8, + "now.day": 7, + "now.hour": 10, + "now.minute": 20, + "now.second": 30, + "now.millisecond": 123, + "now.local.iso": "2025-08-07T10:20:30.123+00:00", + "now.local.date": "2025-08-07", + "now.local.time": "10:20:30" + }; + + expect(sentPayload).toEqual(expectedPayload); + + // Restore Date mock + global.Date = OriginalDate; }); test('filters webhooks based on urlFilter', async () => {