181 lines
7 KiB
TypeScript
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;
|
|
}
|