package com.example.export; import java.nio.charset.StandardCharsets; import java.text.Normalizer; import java.text.NumberFormat; import java.util.Arrays; import java.util.List; import java.util.Locale; /** * One widget's data as a downloadable CSV table: the labels a widget is * currently showing plus the numbers behind them, already resolved against the * bundle — this class never sees a translation key, only display text. *

* The dialect is the one German spreadsheet software expects: {@code ;} as the * separator (a decimal comma makes {@code ,} unusable), CRLF line breaks, and a * UTF-8 BOM in {@link #toBytes(Locale)} so Excel reads the umlauts. Numbers are * formatted for the same {@link Locale} the widget renders in, so the file * carries the values the user sees, not their machine representation. */ public record CsvExport(String categoryHeader, String valueHeader, List categories, List values) { private static final String SEPARATOR = ";"; private static final String LINE_BREAK = "\r\n"; /** UTF-8 byte order mark — without it Excel reads the file as ANSI. */ private static final String BOM = ""; public CsvExport { categories = List.copyOf(categories); values = List.copyOf(values); if (categories.size() != values.size()) { throw new IllegalArgumentException( "each value needs a category: %d values, %d categories" .formatted(values.size(), categories.size())); } } /** A header row plus one row per data point, in the widget's own order. */ public String toCsv(Locale locale) { NumberFormat numbers = NumberFormat.getNumberInstance(locale); numbers.setGroupingUsed(false); numbers.setMaximumFractionDigits(2); StringBuilder csv = new StringBuilder(); row(csv, quote(categoryHeader), quote(valueHeader)); for (int i = 0; i < categories.size(); i++) { row(csv, quote(categories.get(i)), numbers.format(values.get(i))); } return csv.toString(); } public byte[] toBytes(Locale locale) { return (BOM + toCsv(locale)).getBytes(StandardCharsets.UTF_8); } private static void row(StringBuilder csv, String... fields) { csv.append(String.join(SEPARATOR, fields)).append(LINE_BREAK); } /** Quotes a field the way RFC 4180 does, doubling embedded quotes. Only * where it is needed, so a plain label stays readable in a text editor. */ private static String quote(String field) { if (field.contains(SEPARATOR) || field.contains("\"") || field.contains("\n") || field.contains("\r")) { return '"' + field.replace("\"", "\"\"") + '"'; } return field; } /** * A file name built from what the user picked the export from — widget * title and reporting period, say — so a downloads folder full of exports * is still readable. Parts are slugified and joined with {@code -}; * {@code "Umsatz-Entwicklung"} and {@code "Halbjahr"} become * {@code umsatz-entwicklung-halbjahr.csv}. */ public static String fileName(String... parts) { String name = Arrays.stream(parts).map(CsvExport::slug) .filter(part -> !part.isEmpty()) .reduce((a, b) -> a + "-" + b) .orElse("export"); return name + ".csv"; } /** Lowercase ASCII, dashes for everything else: umlauts are decomposed and * their accents dropped, {@code ß} spelled out — {@link Normalizer} has no * decomposition for it. */ private static String slug(String text) { String ascii = Normalizer.normalize(text.toLowerCase(Locale.ROOT).replace("ß", "ss"), Normalizer.Form.NFD) .replaceAll("\\p{M}", ""); return ascii.replaceAll("[^a-z0-9]+", "-").replaceAll("^-|-$", ""); } }