diff --git a/source/UnixTime.popclipext/test.js b/source/UnixTime.popclipext/test.js index 06400a2b..8340c7ca 100644 --- a/source/UnixTime.popclipext/test.js +++ b/source/UnixTime.popclipext/test.js @@ -7,6 +7,7 @@ const testData = new Map([ [["1535438729", 'Europe/London', 'gb'] , "28/08/2018, 07:45:29 BST"], [["1535438729", 'America/New_York', 'zu'] , "2018-08-28 02:45:29 GMT-4"], [["1535438729", 'Pacific/Honolulu', 'zu'] , "2018-08-27 20:45:29 GMT-10"], + [["1762265252513", 'UTC', 'zu'] , "2025-11-04 14:07:32 UTC"], ]); let fail = false; diff --git a/source/UnixTime.popclipext/unixtime.js b/source/UnixTime.popclipext/unixtime.js index 3d90a253..5b1ce283 100644 --- a/source/UnixTime.popclipext/unixtime.js +++ b/source/UnixTime.popclipext/unixtime.js @@ -10,9 +10,62 @@ const defaultLocale = ["zu", "International Standard"]; // Used to unset params if it's chosen, allowing environment defaults to be used. const systemSetting = ["", "System"]; +// Detect if input is a date string or Unix timestamp +const isDateString = (input) => { + const cleaned = input.trim(); + // Negative numbers are always timestamps (Unix epoch can be before 1970) + if (cleaned.startsWith('-') && /^-[\d\s]+$/.test(cleaned)) { + return false; + } + // If it contains date separators or is clearly a date format + if (/[-\/]/.test(cleaned) || /^\+\d+-\d+-\d+/.test(cleaned) || /^\d{4}-\d+-\d+/.test(cleaned)) { + return true; + } + // If it's all digits (possibly with spaces), treat as Unix timestamp + return !/^[\d\s+-]+$/.test(cleaned); +} + +// Parse date string to Date object +const parseDateString = (dateString) => { + // Remove UTC timezone indicator if present + let cleaned = dateString.trim().replace(/\s*UTC\s*$/i, ''); + + // Handle extended year format with + sign (e.g., +057813-12-18 13:41:53) + if (cleaned.startsWith('+')) { + // Remove the + sign for parsing + cleaned = cleaned.substring(1); + } + + // Try to parse the date string + const date = new Date(cleaned); + + // Validate the date + if (isNaN(date.getTime())) { + throw new Error(`Invalid date string: ${dateString}`); + } + + return date; +} + const process = (selection, options) => { - const unixTime = selection.replace(/\s/g, ''); // remove spaces; - const date = convert(unixTime); + const trimmed = selection.trim(); + + let date; + + // Detect if input is a date string or Unix timestamp + if (isDateString(trimmed)) { + // Parse as date string + date = parseDateString(trimmed); + } else { + // Parse as Unix timestamp + const unixTime = trimmed.replace(/\s/g, ''); // remove spaces + date = convert(unixTime); + } + + // Validate date + if (isNaN(date.getTime())) { + throw new Error(`Invalid input: ${trimmed}`); + } const custom_settings = (options.timeZone !== defaultTimezone[0] || options.locale !== defaultLocale[0]) if (custom_settings) { @@ -27,9 +80,115 @@ const process = (selection, options) => { } const convert = (unixTime) => { - const seconds = unixTime; - const milliseconds = 1000; - return new Date(seconds * milliseconds) + // Remove any non-numeric characters except +, -, and spaces + const cleaned = unixTime.replace(/[^\d\s+-]/g, '').replace(/\s/g, ''); + const timestamp = parseFloat(cleaned); + + if (isNaN(timestamp)) { + throw new Error(`Invalid Unix timestamp: ${unixTime}`); + } + + // Unix timestamp standard: seconds since 1970-01-01 (Unix epoch) + // Optimal detection algorithm combining digit count and date range validation + // Based on industry standards (epochconverter.com, unixtimestamp.com) and + // avoiding pitfalls from naive approaches (see Pydantic issue #7940) + + // Handle negative timestamps (before 1970) - always treat as seconds + // Negative timestamps are rarely used in milliseconds in practice + if (timestamp < 0) { + return new Date(timestamp * 1000); + } + + // Calculate digit count (handle edge case for 0) + const digitCount = timestamp === 0 ? 1 : Math.floor(Math.log10(Math.abs(timestamp))) + 1; + + // Try both interpretations for ambiguous cases (11-12 digits: seconds vs milliseconds) + if (digitCount === 11 || digitCount === 12) { + const dateAsSeconds = new Date(timestamp * 1000); + const dateAsMilliseconds = new Date(timestamp); + + // Validate date ranges: reasonable Unix epoch dates (1970-2300) + // This prevents false positives for old timestamps in milliseconds + // Extended to 2300 to handle edge cases like 2286 (max reasonable 10-digit seconds) + const yearAsSeconds = dateAsSeconds.getFullYear(); + const yearAsMilliseconds = dateAsMilliseconds.getFullYear(); + + const isValidYear = (year) => year >= 1970 && year <= 2300; + + const secondsValid = isValidYear(yearAsSeconds); + const millisecondsValid = isValidYear(yearAsMilliseconds); + + if (millisecondsValid && !secondsValid) { + // Only milliseconds gives valid date + return dateAsMilliseconds; + } else if (secondsValid && !millisecondsValid) { + // Only seconds gives valid date + return dateAsSeconds; + } else if (millisecondsValid && secondsValid) { + // Both valid - use magnitude heuristic: + // Threshold: 1e10 (10 billion) - if timestamp is larger, prefer milliseconds + // This handles cases like 10000000000 (10 billion) where both are valid + // but milliseconds interpretation is more likely for 11-12 digit numbers > 10 billion + if (timestamp >= 1e10) { + return dateAsMilliseconds; + } else { + // Prefer seconds (Unix standard) for smaller values + return dateAsSeconds; + } + } + // Neither valid - fall through to digit count logic + // If neither gives valid date, prefer milliseconds for 11-12 digits + // (as they're more likely to be JavaScript timestamps) + if (digitCount === 11 || digitCount === 12) { + return dateAsMilliseconds; + } + } + + // Try both interpretations for ambiguous cases (14-15 digits: milliseconds vs microseconds) + if (digitCount === 14 || digitCount === 15) { + const dateAsMilliseconds = new Date(timestamp); + const dateAsMicroseconds = new Date(timestamp / 1000); + + // Validate date ranges: reasonable Unix epoch dates (1970-2300) + // Extended to 2300 to handle edge cases like 2286 (max reasonable 10-digit seconds) + const yearAsMilliseconds = dateAsMilliseconds.getFullYear(); + const yearAsMicroseconds = dateAsMicroseconds.getFullYear(); + + const isValidYear = (year) => year >= 1970 && year <= 2300; + + const millisecondsValid = isValidYear(yearAsMilliseconds); + const microsecondsValid = isValidYear(yearAsMicroseconds); + + if (millisecondsValid && !microsecondsValid) { + // Only milliseconds gives valid date + return dateAsMilliseconds; + } else if (microsecondsValid && !millisecondsValid) { + // Only microseconds gives valid date + return dateAsMicroseconds; + } else if (millisecondsValid && microsecondsValid) { + // Both valid - prefer milliseconds (more common in practice) + return dateAsMilliseconds; + } + // Neither valid - prefer milliseconds for 14-15 digits + if (digitCount === 14 || digitCount === 15) { + return dateAsMilliseconds; + } + } + + // Clear cases based on digit count (industry standard) + if (digitCount <= 10) { + // Standard Unix timestamp in seconds + return new Date(timestamp * 1000); + } else if (digitCount === 13) { + // Milliseconds (JavaScript Date format) - clear case + return new Date(timestamp); + } else if (digitCount === 16) { + // Microseconds - convert to milliseconds - clear case + return new Date(timestamp / 1000); + } else if (digitCount > 16) { + // Nanoseconds - convert to milliseconds + return new Date(timestamp / 1000000); + } } const standardize = (date, clean = false) => { @@ -58,7 +217,13 @@ const buildFormat = (locales = [], custom_options = {}) => { module.exports = { action(selection, context, options) { - popclip.showText(process(selection.text, options)) + try { + const result = process(selection.text, options); + popclip.copyText(result); + popclip.showText(result); + } catch (error) { + popclip.showText(`Error: ${error.message}`); + } }, icon: "UnixTime.png", options: [