import { displayCellValue, type CellValue } from "@/lib/cellValue"; import dayjs, { Dayjs } from "dayjs"; import customParseFormat from "dayjs/plugin/customParseFormat"; import utc from "dayjs/plugin/utc"; import timezone from "dayjs/plugin/timezone"; dayjs.extend(customParseFormat); dayjs.extend(utc); dayjs.extend(timezone); export type DateTimeFormatterUnit = "seconds" | "milliseconds" | "auto"; const DEFAULT_DATETIME_PATTERN = "YYYY-MM-DD HH:mm:ss"; export const DateTimePatterns = ["HH:mm:ss", "HH:mm:ss.SSS", "YYYY-MM-DD HH:mm:ss", "YYYY-MM-DD HH:mm:ss.SSS", "YYYY/MM/DD HH:mm:ss", "YYYY/MM/DD HH:mm:ss.SSS", "YYYY-MM-DDTHH:mm:ssZ", "YYYY-MM-DDTHH:mm:ss.SSSZ", "YYYY/MM/DDTHH:mm:ssZ", "YYYY/MM/DDTHH:mm:ss.SSSZ"]; const STRICT_LOCAL_DATETIME_INPUT_PATTERNS = ["YYYY-MM-DD", "YYYY/MM/DD", "YYYY-MM-DD HH:mm:ss", "YYYY-MM-DD HH:mm:ss.SSS", "YYYY/MM/DD HH:mm:ss", "YYYY/MM/DD HH:mm:ss.SSS", "YYYY-MM-DDTHH:mm:ss", "YYYY-MM-DDTHH:mm:ss.SSS", "YYYY/MM/DDTHH:mm:ss", "YYYY/MM/DDTHH:mm:ss.SSS"]; const ISO_OFFSET_DATETIME_PATTERN = /^(\d{4})([-/])(\d{2})\2(\d{2})T(\d{2}):(\d{2}):(\d{2})(\.\d{1,3})?(Z|[+-]\d{2}:\d{2})$/; export interface CustomColumnFormatterConfig { id: string; name: string; template: string; } export type ColumnFormatterConfig = { kind: "datetime"; unit: DateTimeFormatterUnit; pattern: string } | { kind: "json-path"; path: string } | { kind: "mask"; prefix: number; suffix: number } | { kind: "custom-template"; template: string } | { kind: "custom-ref"; formatterId: string }; export interface ColumnFormatterKeyParts { connectionId: string; database?: string; schema?: string; tableName: string; column: string; } export function buildColumnFormatterKey(parts: ColumnFormatterKeyParts): string { return [parts.connectionId, parts.database ?? "", parts.schema ?? "", parts.tableName, parts.column].join("::"); } export function normalizeColumnFormatter(value: unknown): ColumnFormatterConfig | undefined { if (!value || typeof value !== "object") return undefined; const config = value as Record; if (config.kind === "datetime") { return config.unit === "seconds" || config.unit === "milliseconds" || config.unit === "auto" ? { kind: "datetime", unit: config.unit, pattern: normalizeDateTimePattern(config.pattern), } : undefined; } if (config.kind === "json-path") { return typeof config.path === "string" && isSupportedJsonPath(config.path) ? { kind: "json-path", path: config.path } : undefined; } if (config.kind === "mask") { if (!Number.isInteger(config.prefix) || !Number.isInteger(config.suffix)) return undefined; if ((config.prefix as number) < 0 || (config.suffix as number) < 0) return undefined; return { kind: "mask", prefix: config.prefix as number, suffix: config.suffix as number }; } if (config.kind === "custom-template") { return typeof config.template === "string" && config.template.trim() ? { kind: "custom-template", template: config.template.slice(0, 500) } : undefined; } if (config.kind === "custom-ref") { return typeof config.formatterId === "string" && config.formatterId.trim() ? { kind: "custom-ref", formatterId: config.formatterId } : undefined; } return undefined; } export function normalizeCustomColumnFormatter(value: unknown): CustomColumnFormatterConfig | undefined { if (!value || typeof value !== "object") return undefined; const config = value as Record; if (typeof config.id !== "string" || !config.id.trim()) return undefined; if (typeof config.name !== "string" || !config.name.trim()) return undefined; if (typeof config.template !== "string" || !config.template.trim()) return undefined; return { id: config.id.trim(), name: config.name.trim().slice(0, 80), template: config.template.slice(0, 500), }; } export function resolveColumnFormatter(formatter: ColumnFormatterConfig | undefined, customFormatters: Record): ColumnFormatterConfig | undefined { if (!formatter) return undefined; if (formatter.kind !== "custom-ref") return formatter; const customFormatter = customFormatters[formatter.formatterId]; return customFormatter ? { kind: "custom-template", template: customFormatter.template } : undefined; } export function applyColumnFormatter(value: CellValue, formatter: ColumnFormatterConfig | undefined): string { if (!formatter) return displayCellValue(value); try { if (formatter.kind === "datetime") return formatDateTime(value, formatter.unit, formatter.pattern); if (formatter.kind === "json-path") return formatJsonPath(value, formatter.path); if (formatter.kind === "mask") return formatMask(value, formatter); if (formatter.kind === "custom-template") return formatCustomTemplate(value, formatter.template); return displayCellValue(value); } catch { return displayCellValue(value); } } function formatDateTime(value: CellValue, unit: DateTimeFormatterUnit, pattern: string): string { if (value === null) return displayCellValue(value); if (typeof value === "boolean") return value ? "true" : "false"; if (typeof value === "object") return JSON.stringify(value); const parsed: Dayjs | undefined = resolveDateTimeValue(value, unit); return parsed ? parsed.format(pattern || DEFAULT_DATETIME_PATTERN) : displayCellValue(value); } function resolveDateTimeValue(value: string | number, unit: DateTimeFormatterUnit) { if (typeof value === "string") { const trimmed = value.trim(); if (!trimmed) { return undefined; } const timestamp = parseTimestampMilliseconds(trimmed, unit); if (timestamp !== undefined) { const parsedTimestamp = dayjs(timestamp); return parsedTimestamp.isValid() ? parsedTimestamp : undefined; } if (isIntegerString(trimmed)) return undefined; return parseStrictDateTimeString(trimmed); } const timestamp = parseTimestampMilliseconds(value, unit); if (timestamp === undefined) return undefined; const parsedTimestamp = dayjs(timestamp); return parsedTimestamp.isValid() ? parsedTimestamp : undefined; } function normalizeDateTimePattern(pattern: unknown): string { return typeof pattern === "string" && pattern.trim() ? pattern : DEFAULT_DATETIME_PATTERN; } function parseStrictDateTimeString(value: string): Dayjs | undefined { const parsedIsoOffset = parseIsoOffsetDateTimeString(value); if (parsedIsoOffset) return parsedIsoOffset; for (const pattern of STRICT_LOCAL_DATETIME_INPUT_PATTERNS) { // Day.js non-strict parsing normalizes overflow dates such as 2022-01-33. // Keep cell text strict so invalid values fall back unchanged. const parsed = dayjs(value, pattern, true); if (parsed.isValid()) return parsed; } return undefined; } function parseIsoOffsetDateTimeString(value: string): Dayjs | undefined { const match = value.match(ISO_OFFSET_DATETIME_PATTERN); if (!match) return undefined; const [, yearText, , monthText, dayText, hourText, minuteText, secondText, fractionText = "", zoneText] = match; if (!isValidDateTimeParts(yearText, monthText, dayText, hourText, minuteText, secondText)) return undefined; if (!isValidOffset(zoneText)) return undefined; const normalized = `${yearText}-${monthText}-${dayText}T${hourText}:${minuteText}:${secondText}${fractionText}${zoneText}`; const parsed = dayjs(normalized); return parsed.isValid() ? parsed : undefined; } function isValidDateTimeParts(yearText: string, monthText: string, dayText: string, hourText: string, minuteText: string, secondText: string): boolean { const year = Number(yearText); const month = Number(monthText); const day = Number(dayText); const hour = Number(hourText); const minute = Number(minuteText); const second = Number(secondText); if (!Number.isInteger(year) || year < 1) return false; if (!Number.isInteger(month) || month < 1 || month > 12) return false; const daysInMonth = new Date(Date.UTC(year, month, 0)).getUTCDate(); if (!Number.isInteger(day) || day < 1 || day > daysInMonth) return false; return hour >= 0 && hour <= 23 && minute >= 0 && minute <= 59 && second >= 0 && second <= 59; } function isValidOffset(offset: string): boolean { if (offset === "Z") return true; const match = offset.match(/^([+-])(\d{2}):(\d{2})$/); if (!match) return false; const hour = Number(match[2]); const minute = Number(match[3]); return hour >= 0 && hour <= 23 && minute >= 0 && minute <= 59; } function parseTimestampMilliseconds(value: string | number, unit: DateTimeFormatterUnit): number | undefined { const numeric = typeof value === "string" ? Number(value) : value; if (!Number.isInteger(numeric)) { return undefined; } if (unit === "seconds" || unit === "milliseconds") { return convertToMilliseconds(numeric, unit); } return isAutoTimestampValue(value, numeric) ? convertToMilliseconds(numeric, unit) : undefined; } function convertToMilliseconds(value: number, unit: DateTimeFormatterUnit): number { return unit === "seconds" || (unit === "auto" && Math.abs(value) < 100_000_000_000) ? value * 1000 : value; } function isAutoTimestampValue(originalValue: string | number, numericValue: number): boolean { if (!Number.isInteger(numericValue)) { return false; } const digits = getTimestampDigits(originalValue); if (digits !== 10 && digits !== 13) { return false; } const yearFromTimestamp = dayjs(digits === 10 ? numericValue * 1000 : numericValue).year(); return yearFromTimestamp >= 1970 && yearFromTimestamp <= 2100; } function getTimestampDigits(value: string | number): number | undefined { if (typeof value === "string") { const trimmed = value.trim(); if (!isIntegerString(trimmed)) return undefined; return trimmed.startsWith("-") ? trimmed.length - 1 : trimmed.length; } else if (!Number.isInteger(value)) { return undefined; } else { return Math.abs(value).toString().length; } } function isIntegerString(value: string): boolean { return /^-?\d+$/.test(value); } function formatJsonPath(value: CellValue, path: string): string { if (value === null) return displayCellValue(value); if (typeof value !== "string") return displayCellValue(value); const parsed = JSON.parse(value); const tokens = parseJsonPath(path); let current: unknown = parsed; for (const token of tokens) { if (current == null) return ""; if (typeof token === "number") { if (!Array.isArray(current)) return ""; current = current[token]; } else { if (typeof current !== "object" || Array.isArray(current)) return ""; current = (current as Record)[token]; } } if (current === undefined) return ""; if (current === null) return "NULL"; if (typeof current === "object") return JSON.stringify(current); return String(current); } function formatMask(value: CellValue, formatter: Extract): string { if (value === null) return displayCellValue(value); const text = displayCellValue(value); const visibleCount = formatter.prefix + formatter.suffix; if (text.length <= visibleCount) return "*".repeat(text.length); return `${text.slice(0, formatter.prefix)}${"*".repeat(text.length - visibleCount)}${text.slice(text.length - formatter.suffix)}`; } function formatCustomTemplate(value: CellValue, template: string): string { const text = displayCellValue(value); return template.replaceAll("${value}", text).replaceAll("${upper}", text.toUpperCase()).replaceAll("${lower}", text.toLowerCase()).replaceAll("${length}", String(text.length)); } function isSupportedJsonPath(path: string): boolean { if (!path.startsWith("$")) return false; try { parseJsonPath(path); return true; } catch { return false; } } function parseJsonPath(path: string): Array { const tokens: Array = []; let index = 1; while (index < path.length) { if (path[index] === ".") { const match = path.slice(index + 1).match(/^[A-Za-z_$][\w$]*/); if (!match) throw new Error("Invalid JSON path"); tokens.push(match[0]); index += match[0].length + 1; continue; } if (path[index] === "[") { const match = path.slice(index).match(/^\[(\d+)\]/); if (!match) throw new Error("Invalid JSON path"); tokens.push(Number(match[1])); index += match[0].length; continue; } throw new Error("Invalid JSON path"); } return tokens; }