ui-nyla/src/utils/applyFrontendFormat.ts
2026-04-21 23:49:50 +02:00

181 lines
7 KiB
TypeScript

// Copyright (c) 2026 Patrick Motsch
// All rights reserved.
//
// Central frontend formatter for backend ``frontend_format`` / ``frontend_format_labels``
// hints (see gateway/modules/shared/attributeUtils.py). Applied by FormGeneratorTable
// for numeric, int and binary cells. Pure function so it can be unit-tested in isolation.
//
// Format string syntax (Excel-inspired, stays simple on purpose):
// <ALIGN>:<PATTERN>
// ALIGN ∈ { L, M, R } -- left / middle / right alignment hint
// PATTERN may contain literal text wrapped in @...@ (e.g. "@CHF@ #'###.00")
//
// Numeric patterns:
// - "#'###.00" Swiss thousands separator + 2 decimals 1'444'555.67
// - "0.000" Force 3 decimals, no thousands separator 4.556
// - "0" Integer, no decimals 12
// - "b" Auto-scale Byte units (B/KB/MB/GB/TB) 12.3 MB
// - "@CHF@ #'###.00" → "CHF 1'234.50" (literal text via @...@)
//
// Binary (boolean) values use ``frontendFormatLabels`` as a 3-tuple
// [trueLabel, neutralLabel, falseLabel]. ``neutralLabel`` is rendered for
// ``null``/``undefined`` -- pass "" or "-" if you want to hide it.
export type RenderAlign = 'left' | 'right' | 'center';
export interface AppliedFormat {
/** Display string ready for the cell. */
text: string;
/** Alignment hint for the cell, if the format specified one. */
align?: RenderAlign;
}
const _ALIGN_MAP: Record<string, RenderAlign> = {
L: 'left',
M: 'center',
R: 'right',
};
const _BYTE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
/**
* Split "ALIGN:PATTERN" into its parts. Returns ``[alignChar, pattern]``,
* with ``alignChar`` being ``""`` if no align prefix is present.
*/
function _splitAlign(format: string): [string, string] {
if (format.length >= 2 && format[1] === ':' && _ALIGN_MAP[format[0]] !== undefined) {
return [format[0], format.slice(2)];
}
return ['', format];
}
/**
* Extract the literal-text segment ``@…@`` if present, returning
* ``[prefix, numericPattern, suffix]``. The literal segment is dropped from
* the numeric pattern so the rest can be parsed as a number format. Only the
* first literal block is recognised (good enough for ``@CHF@ #'###.00`` and
* ``#'###.00 @CHF@`` cases).
*/
function _extractLiteral(pattern: string): { prefix: string; numericPattern: string; suffix: string } {
const match = pattern.match(/^([^@]*)@([^@]*)@(.*)$/);
if (!match) {
return { prefix: '', numericPattern: pattern, suffix: '' };
}
const [, before, literal, after] = match;
if (!after.trim() && before.trim()) {
return { prefix: '', numericPattern: before.trim(), suffix: literal };
}
return { prefix: literal, numericPattern: after.trim() || before.trim(), suffix: '' };
}
/**
* Format ``value`` as bytes auto-scaled to the largest unit ``< 1024``.
* Locale-formatted to one decimal for KB+, integer for raw B.
*/
function _formatBytes(value: number, locale: string): string {
const sign = value < 0 ? '-' : '';
let abs = Math.abs(value);
let unitIdx = 0;
while (abs >= 1024 && unitIdx < _BYTE_UNITS.length - 1) {
abs /= 1024;
unitIdx += 1;
}
const decimals = unitIdx === 0 ? 0 : 1;
const formatted = abs.toLocaleString(locale, {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
});
return `${sign}${formatted} ${_BYTE_UNITS[unitIdx]}`;
}
/**
* Format a numeric value following ``pattern``. Supported patterns:
* - "b" byte units
* - "#'###.00" thousands separator + N decimals (digits after the dot)
* - "0.000" N decimals, no thousands separator
* - "0" integer
* Falls back to ``toLocaleString`` for unknown patterns so we never break the cell.
*/
function _formatNumeric(value: number, pattern: string, locale: string): string {
if (!pattern) return value.toLocaleString(locale);
if (pattern === 'b' || pattern === 'B') return _formatBytes(value, locale);
const decimalsMatch = pattern.match(/[.,](0+)\s*$/);
const decimals = decimalsMatch ? decimalsMatch[1].length : 0;
const useThousands = pattern.includes("'") || pattern.includes('#');
return value.toLocaleString(locale, {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
useGrouping: useThousands,
});
}
/**
* Apply backend render hints to an arbitrary value.
*
* - ``type === 'binary'`` (or boolean value) renders the i18n-resolved label
* tuple from ``formatLabels``.
* - Numeric/int values are formatted by ``_formatNumeric`` according to
* the ``ALIGN:PATTERN`` format string.
* - If ``format`` is empty, the value is rendered with ``toLocaleString`` for
* numbers and ``String(value)`` for everything else (no format == no change).
*/
export function applyFrontendFormat(
value: unknown,
format: string | undefined,
formatLabels: string[] | undefined,
type: string | undefined,
locale: string = 'de-CH',
): AppliedFormat {
const [alignChar, pattern] = format ? _splitAlign(format) : ['', ''];
const align = _ALIGN_MAP[alignChar];
// Boolean / binary rendering with i18n-resolved labels
if (type === 'binary' || type === 'boolean' || typeof value === 'boolean') {
if (value === null || value === undefined) {
const neutral = formatLabels && formatLabels.length >= 2 ? formatLabels[1] : '-';
return { text: neutral, align };
}
if (formatLabels && formatLabels.length >= 1) {
const trueLabel = formatLabels[0] ?? '';
const falseLabel = formatLabels[2] ?? formatLabels[formatLabels.length - 1] ?? '';
return { text: value ? trueLabel : falseLabel, align };
}
return { text: value ? '✓' : '✗', align };
}
if (value === null || value === undefined) {
return { text: '-', align };
}
const numeric = typeof value === 'number'
? value
: (typeof value === 'string' && value.trim() !== '' && !isNaN(Number(value))
? Number(value)
: NaN);
if (Number.isFinite(numeric) && (type === 'number' || type === 'float' || type === 'integer' || type === 'int' || pattern || typeof value === 'number')) {
if (!pattern) {
return { text: numeric.toLocaleString(locale), align };
}
const { prefix, numericPattern, suffix } = _extractLiteral(pattern);
const numStr = _formatNumeric(numeric, numericPattern, locale);
const text = `${prefix ? `${prefix} ` : ''}${numStr}${suffix ? ` ${suffix}` : ''}`.trim();
return { text, align };
}
return { text: String(value), align };
}
/**
* Convenience: returns just the formatted string. Use this when you only
* need the text (e.g. CSV export) and the alignment is irrelevant.
*/
export function applyFrontendFormatText(
value: unknown,
format: string | undefined,
formatLabels: string[] | undefined,
type: string | undefined,
locale: string = 'de-CH',
): string {
return applyFrontendFormat(value, format, formatLabels, type, locale).text;
}