diff --git a/build.gradle b/build.gradle index 3a334d6..29b70b5 100644 --- a/build.gradle +++ b/build.gradle @@ -1,5 +1,5 @@ plugins { - id "io.freefair.lombok" version "8.4" + id "io.freefair.lombok" version "8.6" id "com.github.ben-manes.versions" version "0.51.0" } @@ -9,7 +9,7 @@ apply plugin: "maven-publish" apply plugin: "signing" group = "nl.colorize" -version = "2024.3" +version = "2024.4" compileJava.options.encoding = "UTF-8" java { @@ -27,8 +27,8 @@ repositories { } dependencies { - api "com.google.guava:guava:33.0.0-jre" - testImplementation "org.junit.jupiter:junit-jupiter:5.10.1" + api "com.google.guava:guava:33.1.0-jre" + testImplementation "org.junit.jupiter:junit-jupiter:5.10.2" testRuntimeOnly "org.junit.platform:junit-platform-launcher" } diff --git a/readme.md b/readme.md index fd1485e..5e154fa 100644 --- a/readme.md +++ b/readme.md @@ -32,13 +32,13 @@ to the dependencies section in `pom.xml`: nl.colorize colorize-java-commons - 2024.3 + 2024.4 The library can also be used in Gradle projects: dependencies { - implementation "nl.colorize:colorize-java-commons:2024.3" + implementation "nl.colorize:colorize-java-commons:2024.4" } Documentation @@ -62,9 +62,6 @@ The following example shows how to define a simple command line interface: @Arg(name = "--input", usage = "Input directory") public File inputDir - @Arg(name = "--output", defaultValue = "/tmp", usage = ""Output directory") - public File outputDir; - @Arg public boolean overwrite; diff --git a/resources/custom-swing-components.properties b/resources/custom-swing-components.properties index b639a4c..2a651f5 100644 --- a/resources/custom-swing-components.properties +++ b/resources/custom-swing-components.properties @@ -25,3 +25,4 @@ PropertyEditor.cancel=Cancel ImageViewer.imageFile=Image file ImageViewer.imageFileSize=File size +ImageViewer.imageSize=Size diff --git a/source/nl/colorize/util/CSVRecord.java b/source/nl/colorize/util/CSVRecord.java deleted file mode 100644 index 786f47e..0000000 --- a/source/nl/colorize/util/CSVRecord.java +++ /dev/null @@ -1,226 +0,0 @@ -//----------------------------------------------------------------------------- -// Colorize Java Commons -// Copyright 2007-2024 Colorize -// Apache license (http://www.apache.org/licenses/LICENSE-2.0) -//----------------------------------------------------------------------------- - -package nl.colorize.util; - -import com.google.common.base.CharMatcher; -import com.google.common.base.Joiner; -import com.google.common.base.Preconditions; -import com.google.common.base.Splitter; -import com.google.common.collect.ImmutableList; -import nl.colorize.util.stats.TupleList; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.stream.Collectors; - -/** - * Immutable record within a CSV file. Cells are separated by a delimiter, - * records are separated by newlines. Cells can be referenced by column index, - * or by column name if the CSV record includes column name information. - * - * @deprecated This class is intended for quick-and-dirty CSV support. - * Applications interacting with CSV files should prefer using - * a full-blown CSV library such as Apache Commons CSV or FastCSV. - */ -@Deprecated -public class CSVRecord { - - private List columns; - private List cells; - - private static final Splitter RECORD_SPLITTER = Splitter.on("\n").omitEmptyStrings(); - private static final Joiner RECORD_JOINER = Joiner.on("\n"); - - private CSVRecord(List columns, List cells) { - Preconditions.checkArgument(!cells.isEmpty(), - "CSV record is empty"); - - Preconditions.checkArgument(columns.isEmpty() || columns.size() == cells.size(), - "Number of columns does not match number of cells: " + columns + " <-> " + cells); - - this.columns = ImmutableList.copyOf(columns); - this.cells = ImmutableList.copyOf(cells); - } - - /** - * Returns the value of the cell with the specified index. This method is - * always available, while accessing cells by column name is only available - * if the CSV containing column information. - * - * @throws IllegalArgumentException if the column index is invalid. - */ - public String get(int index) { - Preconditions.checkArgument(index >= 0 && index < cells.size(), - "Invalid index " + index + ", record has " + cells.size() + " cells"); - - return cells.get(index); - } - - /** - * Returns the value for the cell with the specified column name. - * - * @throws IllegalStateException if no column name information is available. - * @throws IllegalArgumentException if no column with that name exists. - */ - public String get(String column) { - Preconditions.checkState(!columns.isEmpty(), "No column name information available"); - Preconditions.checkArgument(columns.contains(column), "No such column: " + column); - - int columnIndex = columns.indexOf(column); - return cells.get(columnIndex); - } - - public boolean hasColumnNameInformation() { - return !columns.isEmpty(); - } - - /** - * Returns a list of all named columns in this CSV record. The columns will - * be sorted in the same order as they appear in the CSV. If no column name - * information is available, this will return an empty list. - * - * @throws IllegalStateException if no column name information is available. - */ - public List getColumns() { - Preconditions.checkState(!columns.isEmpty(), "No column name information available"); - return ImmutableList.copyOf(columns); - } - - /** - * Returns a list of column values for all cells in this CSV record. Each - * tuple in the list consists of the column name and corresponding cell - * value. The list will be sorted to match the order in which the columns - * appear in the CSV. - * - * @throws IllegalStateException if no column name information is available. - */ - public TupleList getColumnValues() { - Preconditions.checkState(!columns.isEmpty(), "No column name information available"); - return TupleList.combine(columns, cells); - } - - private String toCSV(String delimiter) { - Preconditions.checkArgument(delimiter.length() == 1, - "Invalid CSV delimiter: " + delimiter); - - CharMatcher escaper = CharMatcher.anyOf("\r\n" + delimiter); - - return cells.stream() - .map(cell -> cell == null ? "" : cell) - .map(escaper::removeFrom) - .collect(Collectors.joining(delimiter)); - } - - @Override - public String toString() { - return toCSV(";"); - } - - /** - * Creates an indivudual CSV record from the specified list of cells and - * column headers. - */ - public static CSVRecord create(List columns, List cells) { - return new CSVRecord(columns, cells); - } - - /** - * Creates an indivudual CSV record from the specified list of cells, with - * no column headers. - */ - public static CSVRecord create(List cells) { - return new CSVRecord(Collections.emptyList(), cells); - } - - /** - * Creates an individual CSV record from the specified array of cells. - */ - public static CSVRecord create(String... cells) { - return new CSVRecord(Collections.emptyList(), ImmutableList.copyOf(cells)); - } - - /** - * Parses an individual CSV record, using the specified delimiter. - */ - public static CSVRecord parseRecord(String csv, String delimiter) { - Preconditions.checkArgument(delimiter.length() == 1, - "Invalid CSV delimiter: " + delimiter); - - List cells = Splitter.on(delimiter).splitToList(csv); - return new CSVRecord(Collections.emptyList(), cells); - } - - /** - * Parses a CSV file containing any number of record. Cells within each - * record are split using the specified delimiter. If {@code headers} - * is true, the first record is used as the column header names. If false, - * the first record is considered as a normal record, and no column header - * information will be available. - */ - public static List parseCSV(String csv, String delimiter, boolean headers) { - Preconditions.checkArgument(delimiter.length() == 1, - "Invalid CSV delimiter: " + delimiter); - - if (csv.isEmpty()) { - return Collections.emptyList(); - } - - List records = new ArrayList<>(); - List columns = Collections.emptyList(); - - for (String row : RECORD_SPLITTER.split(csv)) { - List cells = parseRecord(row, delimiter).cells; - if (headers && columns.isEmpty()) { - columns = cells; - } else { - CSVRecord record = new CSVRecord(columns, cells); - records.add(record); - } - } - - return records; - } - - /** - * Serializes a list of records to CSV, using the specified cell delimiter. - * If the list of records is empty this will return an empty string. If - * {@code headers} is true, the header information in the first record will - * be used to include column headers in the generated CSV. If false, no - * header information will be included. - * - * @throws IllegalStateException if {@code headers} is true, but no column - * header information is available for the first record. Also thrown - * when {@code headers} is true, but the records in list do not share - * the same headers. - */ - public static String toCSV(List records, String delimiter, boolean headers) { - if (records.isEmpty()) { - return ""; - } - - List rows = new ArrayList<>(); - - for (CSVRecord record : records) { - if (headers) { - Preconditions.checkState(record.columns.size() > 0, - "Record missing column headers: " + record); - Preconditions.checkState(record.columns.equals(records.get(0).columns), - "Records have inconsistent columns: " + record); - - if (rows.isEmpty()) { - CSVRecord headerRecord = CSVRecord.create(record.columns); - rows.add(headerRecord.toCSV(delimiter)); - } - } - - rows.add(record.toCSV(delimiter)); - } - - return RECORD_JOINER.join(rows); - } -} diff --git a/source/nl/colorize/util/PropertyDeserializer.java b/source/nl/colorize/util/PropertyDeserializer.java index 942e710..12b4766 100644 --- a/source/nl/colorize/util/PropertyDeserializer.java +++ b/source/nl/colorize/util/PropertyDeserializer.java @@ -7,6 +7,7 @@ package nl.colorize.util; import com.google.common.base.Preconditions; +import nl.colorize.util.stats.CSVRecord; import java.io.File; import java.nio.file.Path; @@ -223,4 +224,33 @@ public static PropertyDeserializer fromProperties(Properties properties) { propertyDeserializer.registerPreprocessor(properties::getProperty); return propertyDeserializer; } + + /** + * Returns a {@link PropertyDeserializer} that acts as a live view for + * parsing values from the specified map. + */ + public static PropertyDeserializer fromMap(Map properties) { + PropertyDeserializer propertyDeserializer = new PropertyDeserializer(); + propertyDeserializer.registerPreprocessor(name -> { + Object value = properties.get(name); + return value != null ? value.toString() : null; + }); + return propertyDeserializer; + } + + /** + * Returns a {@link PropertyDeserializer} that acts as a live view for + * parsing values from the specified CSV record. + * + * @throws IllegalStateException if the CSV does not include any column + * information. + */ + public static PropertyDeserializer fromCSV(CSVRecord record) { + Preconditions.checkState(record.hasColumnInformation(), + "CSV does not include column information"); + + PropertyDeserializer propertyDeserializer = new PropertyDeserializer(); + propertyDeserializer.registerPreprocessor(record::get); + return propertyDeserializer; + } } diff --git a/source/nl/colorize/util/Subscribable.java b/source/nl/colorize/util/Subscribable.java index ea9907f..fb55e8f 100644 --- a/source/nl/colorize/util/Subscribable.java +++ b/source/nl/colorize/util/Subscribable.java @@ -24,12 +24,12 @@ * asynchronous) operation, allowing for publish/subscribe workflows. * Subscribers can be notified for events, for errors, or for both. *

- * Such workflows can also be created in other ways, most commonly using - * {@code java.util.concurrent} and its Flow API. However, the Flow API is - * not yet supported on all platforms that are supported by this library, - * making this class a more portable alternative. + * This type of workflow can also be created in other ways, most commonly + * using {@code java.util.concurrent} and its Flow API. However, the Flow + * API is not yet available on all platforms that are supported by this + * library, making this class a more portable alternative. *

- * On platforms that do support concurrency, {@link Subscribable} instances + * On platforms that support concurrency, {@link Subscribable} instances * are thread-safe and can be accessed from multiple threads. * * @param The type of event that can be subscribed to. @@ -43,16 +43,15 @@ public final class Subscribable { private static final Logger LOGGER = LogHelper.getLogger(Subscribable.class); public Subscribable() { - this.subscribers = prepareList(); - this.history = prepareList(); + this.subscribers = new ArrayList<>(); + this.history = new ArrayList<>(); this.completed = false; - } - private List prepareList() { - if (Platform.isTeaVM()) { - return new ArrayList<>(); - } else { - return new CopyOnWriteArrayList<>(); + // This needs to be in a block so this class can be + // compiled and run within TeaVM. + if (!Platform.isTeaVM()) { + subscribers = new CopyOnWriteArrayList<>(); + history = new CopyOnWriteArrayList<>(); } } @@ -62,17 +61,13 @@ private List prepareList() { * completed. */ public void next(T event) { - if (completed) { - return; - } - - for (Subscriber subscriber : subscribers) { - if (subscriber.onEvent != null) { - subscriber.onEvent.accept(event); + if (!completed) { + for (Subscriber subscriber : subscribers) { + subscriber.onEvent(event); } - } - history.add(event); + history.add(event); + } } /** @@ -83,24 +78,13 @@ public void next(T event) { * marked as completed. */ public void nextError(Exception error) { - if (completed) { - return; - } - - boolean handled = false; - - for (Subscriber subscriber : subscribers) { - if (subscriber.onError != null) { - subscriber.onError.accept(error); - handled = true; + if (!completed) { + for (Subscriber subscriber : subscribers) { + subscriber.onError(error); } - } - if (!handled) { - LOGGER.warning("Unhandled error: " + error.getMessage()); + history.add(error); } - - history.add(error); } /** @@ -112,15 +96,13 @@ public void nextError(Exception error) { * completed. */ public void next(Callable operation) { - if (completed) { - return; - } - - try { - T event = operation.call(); - next(event); - } catch (Exception e) { - nextError(e); + if (!completed) { + try { + T event = operation.call(); + next(event); + } catch (Exception e) { + nextError(e); + } } } @@ -177,22 +159,23 @@ public void retry(Callable operation, int attempts, long delay) { } /** - * Registers the specified callback functions as event and error - * subscribers. The subscribers will immediately be notified of previously - * published events and/or errors. The {@code id} parameter can be used to - * identify the subscriber. + * Registers the specified subscriber to receive published events and + * errors. The subscriber will immediately be notified of previously + * published events. * * @return This {@link Subscribable}, for method chaining. */ - public Subscribable subscribe(UUID id, Consumer onEvent, Consumer onError) { - Subscriber subscriber = new Subscriber<>(id, onEvent, onError); + @SuppressWarnings("unchecked") + public Subscribable subscribe(Subscriber subscriber) { + Preconditions.checkNotNull(subscriber, "Null subscriber"); + subscribers.add(subscriber); for (Object historicEvent : history) { - if (onError != null && historicEvent instanceof Exception error) { - onError.accept(error); - } else if (onEvent != null) { - onEvent.accept((T) historicEvent); + if (historicEvent instanceof Exception error) { + subscriber.onError(error); + } else { + subscriber.onEvent((T) historicEvent); } } @@ -201,23 +184,23 @@ public Subscribable subscribe(UUID id, Consumer onEvent, Consumer subscribe(Consumer onEvent, Consumer onError) { - return subscribe(UUID.randomUUID(), onEvent, onError); - } + public Subscribable subscribe(Consumer eventFn, Consumer errorFn) { + return subscribe(new Subscriber() { + @Override + public void onEvent(T event) { + eventFn.accept(event); + } - /** - * Registers the specified callback function as an event subscriber. The - * subscriber will immediately be notified of previously published events. - * - * @return This {@link Subscribable}, for method chaining. - */ - public Subscribable subscribe(Consumer onEvent) { - return subscribe(onEvent, null); + @Override + public void onError(Exception error) { + errorFn.accept(error); + } + }); } /** @@ -227,7 +210,8 @@ public Subscribable subscribe(Consumer onEvent) { * @return This {@link Subscribable}, for method chaining. */ public Subscribable subscribeErrors(Consumer onError) { - return subscribe(null, onError); + Consumer onEvent = event -> {}; + return subscribe(onEvent, onError); } /** @@ -243,12 +227,12 @@ public Subscribable subscribe(Subscribable subscriber) { } /** - * Removes a previously registered subscriber, identifying the subscriber - * by its ID. This method does nothing if none of the current subscribers - * match the ID. + * Removes a previously registered subscriber. If the subscriber is not + * currently registered with this {@link Subscribable}, this method does + * nothing. */ - public void unsubscribe(UUID id) { - subscribers.removeIf(subscriber -> subscriber.id.equals(id)); + public void unsubscribe(Subscriber subscriber) { + subscribers.remove(subscriber); } /** @@ -257,7 +241,13 @@ public void unsubscribe(UUID id) { * might still be published when additional subscribers are registered. */ public void complete() { - completed = true; + if (!completed) { + completed = true; + + for (Subscriber subscriber : subscribers) { + subscriber.onComplete(); + } + } } /** @@ -350,12 +340,4 @@ public static Subscribable runBackground(Callable operation) { return subscribable; } - - /** - * Internal data structure that creates a subscriber objects from callback - * methods. Callback methods can be {@code null} if the subscriber is not - * interested in certain events. - */ - private record Subscriber(UUID id, Consumer onEvent, Consumer onError) { - } } diff --git a/source/nl/colorize/util/Subscriber.java b/source/nl/colorize/util/Subscriber.java new file mode 100644 index 0000000..a09aeaa --- /dev/null +++ b/source/nl/colorize/util/Subscriber.java @@ -0,0 +1,42 @@ +//----------------------------------------------------------------------------- +// Colorize Java Commons +// Copyright 2007-2024 Colorize +// Apache license (http://www.apache.org/licenses/LICENSE-2.0) +//----------------------------------------------------------------------------- + +package nl.colorize.util; + +import java.util.logging.Logger; + +/** + * Interface for subscribing to (possibly asynchronous) events that are + * published by a {@link Subscribable}. + * + * @param The type of event that can be subscribed to. + */ +@FunctionalInterface +public interface Subscriber { + + /** + * Invoked by the {@link Subscribable} when an event has been received. + */ + public void onEvent(T event); + + /** + * Invoked by the {@link Subscribable} when an error has occurred while + * publishing an event. This is an optional method, the default + * implementation will log the error. + */ + default void onError(Exception error) { + Logger logger = LogHelper.getLogger(getClass()); + logger.warning("Sunscriber:"); + } + + /** + * Invoked when the {@link Subscribable} is marked as completed, after + * which this {@link Subscriber} will no longer receive events. This is + * an optional method, the default implementation does nothing. + */ + default void onComplete() { + } +} diff --git a/source/nl/colorize/util/TextUtils.java b/source/nl/colorize/util/TextUtils.java index 8a721d3..86adfe6 100644 --- a/source/nl/colorize/util/TextUtils.java +++ b/source/nl/colorize/util/TextUtils.java @@ -39,10 +39,12 @@ public final class TextUtils { public static final Splitter LINE_SPLITTER = Splitter.on("\n"); public static final Joiner LINE_JOINER = Joiner.on("\n"); + private static final Pattern WORD_SEPARATOR = Pattern.compile("[ _]"); private static final Splitter WORD_SPLITTER = Splitter.on(WORD_SEPARATOR).omitEmptyStrings(); - private static final CharMatcher TEXT_MATCHER = CharMatcher.forPredicate(Character::isLetterOrDigit) + private static final CharMatcher TEXT_MATCHER = CharMatcher + .forPredicate(Character::isLetterOrDigit) .or(CharMatcher.whitespace()); private TextUtils() { diff --git a/source/nl/colorize/util/http/URLLoader.java b/source/nl/colorize/util/http/URLLoader.java index 520b8d8..9ef27e8 100644 --- a/source/nl/colorize/util/http/URLLoader.java +++ b/source/nl/colorize/util/http/URLLoader.java @@ -331,8 +331,8 @@ private URI getFullURL() { * will be thrown. */ public URLResponse send() throws IOException { - boolean force = System.getProperty(FORCE_LEGACY_HTTP_CLIENT_SYSTEM_PROPERTY, "").equals("true"); - boolean classicHttpClient = Platform.isTeaVM() || force; + boolean classicHttpClient = Platform.isTeaVM() || + System.getProperty(FORCE_LEGACY_HTTP_CLIENT_SYSTEM_PROPERTY, "").equals("true"); if (classicHttpClient) { URI fullURL = getFullURL(); diff --git a/source/nl/colorize/util/http/URLResponse.java b/source/nl/colorize/util/http/URLResponse.java index 61c68d5..10293ba 100644 --- a/source/nl/colorize/util/http/URLResponse.java +++ b/source/nl/colorize/util/http/URLResponse.java @@ -22,6 +22,8 @@ import java.util.List; import java.util.Optional; +import static lombok.AccessLevel.PROTECTED; + /** * Represents an HTTP response that is returned after sending an HTTP request * to a URL. Used in conjunction with {@link URLLoader}, which supports/uses @@ -33,7 +35,7 @@ public class URLResponse implements Resource { private int status; private Headers headers; private byte[] body; - @Setter(AccessLevel.PROTECTED) private SSLSession sslSession; + @Setter(PROTECTED) private SSLSession sslSession; public URLResponse(int status, Headers headers, byte[] body) { this.status = status; diff --git a/source/nl/colorize/util/stats/Aggregate.java b/source/nl/colorize/util/stats/Aggregate.java index 6cd5642..01b9728 100644 --- a/source/nl/colorize/util/stats/Aggregate.java +++ b/source/nl/colorize/util/stats/Aggregate.java @@ -87,6 +87,10 @@ public static float percentage(float value, float total) { return value * 100f / total; } + public static float multiplyPercentage(float percentageA, float percentageB) { + return ((percentageA / 100f) * (percentageB / 100f)) * 100f; + } + /** * Calculates the value of the Nth percentile for the specified data set. * This method will interpolate between values if none of the values in diff --git a/source/nl/colorize/util/stats/CSVFormat.java b/source/nl/colorize/util/stats/CSVFormat.java new file mode 100644 index 0000000..7796fcc --- /dev/null +++ b/source/nl/colorize/util/stats/CSVFormat.java @@ -0,0 +1,156 @@ +//----------------------------------------------------------------------------- +// Colorize Java Commons +// Copyright 2007-2024 Colorize +// Apache license (http://www.apache.org/licenses/LICENSE-2.0) +//----------------------------------------------------------------------------- + +package nl.colorize.util.stats; + +import com.google.common.base.CharMatcher; +import com.google.common.base.Preconditions; +import com.google.common.base.Splitter; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +/** + * Describes the format of a CSV file, which is necessary since the CSV file + * format is not fully standardized. Instances of the format can then be used + * to parse CSV files into {@link CSVRecord}s, or to serialize records into + * CSV files. + *

+ * Note: This class is intended for basic CSV support. + * When working with CSV files produced by other applications, prefer using + * the FastCSV library. + */ +public class CSVFormat { + + private boolean headers; + private char delimiter; + private String lineSeparator; + private boolean quotes; + + public static final CSVFormat COMMA = withHeaders(','); + public static final CSVFormat TAB = withHeaders('\t'); + public static final CSVFormat SEMICOLON = withHeaders(';'); + + private CSVFormat(boolean headers, char delimiter, String lineSeparator, boolean quotes) { + this.headers = headers; + this.delimiter = delimiter; + this.lineSeparator = lineSeparator; + this.quotes = quotes; + } + + public CSVFormat withQuotes() { + return new CSVFormat(headers, delimiter, lineSeparator, true); + } + + public CSVFormat withLineSeparator(String lineSeparator) { + return new CSVFormat(headers, delimiter, lineSeparator, quotes); + } + + /** + * Parses the specified CSV file using this {@link CSVFormat}, and returns + * the resulting records. If this CSV format includes header information, + * the first record in the file is assumed to contain the headers. + */ + public List parseCSV(String csv) { + List records = new ArrayList<>(); + List columns = null; + + for (String line : Splitter.on(lineSeparator).omitEmptyStrings().split(csv)) { + List cells = parseLine(line); + + if (headers && columns == null) { + columns = cells; + } else { + records.add(new CSVRecord(columns, cells, this)); + } + } + + return records; + } + + private List parseLine(String line) { + if (quotes) { + return parseQuotedLine(line); + } else { + return Splitter.on(delimiter).splitToList(line); + } + } + + private List parseQuotedLine(String line) { + List cells = new ArrayList<>(); + StringBuilder buffer = new StringBuilder(); + boolean quoting = false; + + for (int i = 0; i < line.length(); i++) { + if (line.charAt(i) == delimiter && !quoting) { + cells.add(buffer.toString()); + buffer.delete(0, buffer.length()); + } else if (line.charAt(i) == '\"') { + quoting = !quoting; + } else { + buffer.append(line.charAt(i)); + } + } + + if (!buffer.isEmpty()) { + cells.add(buffer.toString()); + } + + return cells; + } + + /** + * Serializes the specified record using this CSV format. The serialized + * line will end with a trailing line separator. This method can be used + * for both rows and headers, since CSV files do not differentiate + * between the two apart from their location within the file. + * + * @throws IllegalArgumentException if the record does not contain at + * least one cell. + */ + public String toCSV(List cells) { + Preconditions.checkArgument(!cells.isEmpty(), "Empty CSV record"); + + CharMatcher illegalCharacters = CharMatcher.anyOf("\r\n\"" + delimiter); + + return cells.stream() + .map(illegalCharacters::removeFrom) + .collect(Collectors.joining(String.valueOf(delimiter))) + lineSeparator; + } + + /** + * Serializes the specified record using this CSV format. The serialized + * line will end with a trailing line separator. This method can be used + * for both rows and headers, since CSV files do not differentiate + * between the two apart from their location within the file. + * + * @throws IllegalArgumentException if the record does not contain at + * least one cell. + */ + public String toCSV(String... cells) { + return toCSV(List.of(cells)); + } + + /** + * Serializes the specified record using this CSV format. The serialized + * line will end with a trailing line separator. + * + * @throws IllegalArgumentException if the record does not contain at + * least one cell. + */ + public String toCSV(CSVRecord record) { + return toCSV(record.getCells()); + } + + public static CSVFormat withHeaders(char delimiter) { + return new CSVFormat(true, delimiter, System.lineSeparator(), false); + } + + public static CSVFormat withoutHeaders(char delimiter) { + return new CSVFormat(false, delimiter, System.lineSeparator(), false); + } +} diff --git a/source/nl/colorize/util/stats/CSVRecord.java b/source/nl/colorize/util/stats/CSVRecord.java new file mode 100644 index 0000000..ccb1f1a --- /dev/null +++ b/source/nl/colorize/util/stats/CSVRecord.java @@ -0,0 +1,114 @@ +//----------------------------------------------------------------------------- +// Colorize Java Commons +// Copyright 2007-2024 Colorize +// Apache license (http://www.apache.org/licenses/LICENSE-2.0) +//----------------------------------------------------------------------------- + +package nl.colorize.util.stats; + +import com.google.common.base.Preconditions; +import lombok.Getter; + +import java.util.List; + +/** + * One of the records witin a CSV file. Records are usually parsed from a + * CSV file using {@link CSVFormat}, but can also be created programmatically. + * Fields within the record can be retrieved by index or by column name. + * The latter is only available if the CSV contains column information. + *

+ * Note: This class is intended for basic CSV support. + * When working with CSV files produced by other applications, prefer using + * the FastCSV library. + */ +@Getter +public class CSVRecord { + + private List columns; + private List cells; + private CSVFormat format; + + /** + * Creates a new CSV record from the specified cells. The column + * information is optional: {@code null} indicates that no column + * information is available, meaning that cells can only be retrieved + * by index. + *

+ * {@link CSVRecord} instances are normally by obtained by parsing CSV + * files using {@link CSVFormat}. There is normally no need for + * applications to use this constructor directly. + */ + protected CSVRecord(List columns, List cells, CSVFormat format) { + Preconditions.checkArgument(!cells.isEmpty(), "Empty CSV record"); + Preconditions.checkArgument(columns == null || columns.size() == cells.size(), + "Invalid number of columns"); + + this.columns = columns == null ? null : List.copyOf(columns); + this.cells = List.copyOf(cells); + this.format = format; + } + + /** + * Returns the value of the cell with the specified index. This method is + * always available, even if the CSV file does not include any column + * information. + * + * @throws IllegalArgumentException if the column index is invalid. + */ + public String get(int index) { + Preconditions.checkArgument(index >= 0 && index < cells.size(), + "Invalid column index: " + index); + return cells.get(index); + } + + /** + * Returns true if this CSV record included column information. If false, + * cells can only be accessed by index, not by column name. + */ + public boolean hasColumnInformation() { + return columns != null; + } + + /** + * Returns the value for the cell with the specified column name. + * + * @throws IllegalStateException if no column name information is available. + * @throws IllegalArgumentException if no column with that name exists. + */ + public String get(String column) { + Preconditions.checkState(columns != null, "No column name information available"); + Preconditions.checkArgument(columns.contains(column), "No such column: " + column); + + int columnIndex = columns.indexOf(column); + return cells.get(columnIndex); + } + + /** + * Returns a list of all named columns in this CSV record. The columns will + * be sorted in the same order as they appear in the CSV. + * + * @throws IllegalStateException if no column name information is available. + */ + public List getColumns() { + Preconditions.checkState(columns != null, "No column name information available"); + return columns; + } + + /** + * Returns a list of column values for all cells in this CSV record. Each + * tuple in the list consists of the column name and corresponding cell + * value. The list will be sorted to match the order in which the columns + * appear in the CSV. + * + * @throws IllegalStateException if no column name information is available. + */ + public TupleList getColumnValues() { + Preconditions.checkState(!columns.isEmpty(), "No column name information available"); + return TupleList.combine(columns, cells); + } + + @Override + public String toString() { + return format.toCSV(this); + } +} diff --git a/test/nl/colorize/util/CSVRecordTest.java b/test/nl/colorize/util/CSVRecordTest.java deleted file mode 100644 index 7d48faa..0000000 --- a/test/nl/colorize/util/CSVRecordTest.java +++ /dev/null @@ -1,150 +0,0 @@ -//----------------------------------------------------------------------------- -// Colorize Java Commons -// Copyright 2007-2024 Colorize -// Apache license (http://www.apache.org/licenses/LICENSE-2.0) -//----------------------------------------------------------------------------- - -package nl.colorize.util; - -import org.junit.jupiter.api.Test; - -import java.util.Arrays; -import java.util.Collections; -import java.util.List; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; - -public class CSVRecordTest { - - @Test - public void testParse() { - CSVRecord record = CSVRecord.parseRecord("a;2;3.4;false", ";"); - - assertEquals("a", record.get(0)); - assertEquals("2", record.get(1)); - assertEquals("3.4", record.get(2)); - assertEquals("false", record.get(3)); - } - - @Test - public void testSerialize() { - CSVRecord record = CSVRecord.parseRecord("a;2", ";"); - - assertEquals("a;2", record.toString()); - } - - @Test - public void testSerializeMultiple() { - CSVRecord first = CSVRecord.create(Arrays.asList("a", "2")); - CSVRecord second = CSVRecord.create(Arrays.asList("b", "3")); - - assertEquals("a;2\nb;3", CSVRecord.toCSV(List.of(first, second), ";", false)); - } - - @Test - public void testEscape() { - CSVRecord record = CSVRecord.create(Arrays.asList("a", "b;c", "d\ne")); - - assertEquals("a;bc;de", record.toString()); - } - - @Test - public void testParseMultipleWithHeader() { - List records = CSVRecord.parseCSV("h1;h2\na;2\nb;3", ";", true); - - assertEquals(2, records.size()); - assertEquals("a", records.get(0).get(0)); - assertEquals("b", records.get(1).get(0)); - } - - @Test - void returnEmptyStringIfNoRecords() { - assertEquals("", CSVRecord.toCSV(Collections.emptyList(), ";", false)); - } - - @Test - void getColumnByName() { - String csv = """ - Name;Age - John;38 - """; - - List records = CSVRecord.parseCSV(csv, ";", true); - - assertEquals("John", records.get(0).get("Name")); - assertEquals("38", records.get(0).get("Age")); - } - - @Test - void parseWithoutHeaders() { - String csv = """ - Name;Age - John;38 - Jim;26 - """; - - List records = CSVRecord.parseCSV(csv, ";", false); - - assertEquals(3, records.size()); - assertEquals("Name", records.get(0).get(0)); - assertEquals("John", records.get(1).get(0)); - assertEquals("Jim", records.get(2).get(0)); - } - - @Test - void serializeWithHeaders() { - CSVRecord record = CSVRecord.create(List.of("Name", "Age"), List.of("John", "38")); - String csv = CSVRecord.toCSV(List.of(record), ";", true); - - String expected = """ - Name;Age - John;38"""; - - assertEquals(expected, csv); - } - - @Test - void serializeWithoutHeaders() { - CSVRecord record = CSVRecord.create(List.of("Name", "Age"), List.of("John", "38")); - String csv = CSVRecord.toCSV(List.of(record), ";", false); - - assertEquals("John;38", csv); - } - - @Test - void exceptionIfHeadersAreRequestedButNotAvailable() { - CSVRecord record = CSVRecord.create(List.of("John", "38")); - - assertThrows(IllegalStateException.class, - () -> CSVRecord.toCSV(List.of(record), ";", true)); - } - - @Test - void exceptionForInconsistentHeaders() { - CSVRecord recordA = CSVRecord.create(List.of("Name", "Age"), List.of("John", "38")); - CSVRecord recordB = CSVRecord.create(List.of("Name", "Other"), List.of("Jim", "26")); - - assertThrows(IllegalStateException.class, - () -> CSVRecord.toCSV(List.of(recordA, recordB), ";", true)); - } - - @Test - void getColumns() { - String csv = """ - Name;Age - John;38 - """; - - List records = CSVRecord.parseCSV(csv, ";", true); - - assertEquals(1, records.size()); - assertEquals(List.of("Name", "Age"), records.get(0).getColumns()); - assertEquals("[(Name, John), (Age, 38)]", records.get(0).getColumnValues().toString()); - } - - @Test - void parseEmptyCSV() { - assertEquals(0, CSVRecord.parseCSV("", ";", true).size()); - } -} diff --git a/test/nl/colorize/util/PropertyDeserializerTest.java b/test/nl/colorize/util/PropertyDeserializerTest.java index cb8aa51..ce52b28 100644 --- a/test/nl/colorize/util/PropertyDeserializerTest.java +++ b/test/nl/colorize/util/PropertyDeserializerTest.java @@ -7,6 +7,8 @@ package nl.colorize.util; import com.google.common.base.Splitter; +import nl.colorize.util.stats.CSVFormat; +import nl.colorize.util.stats.CSVRecord; import nl.colorize.util.stats.DateRange; import org.junit.jupiter.api.Test; @@ -16,6 +18,7 @@ import java.time.LocalDateTime; import java.util.Date; import java.util.List; +import java.util.Map; import java.util.Properties; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -138,4 +141,23 @@ void convertDateAndTime() { assertEquals(LocalDate.of(2018, 3, 18), date); assertEquals(LocalDateTime.of(2018, 3, 18, 15, 30), datetime); } + + @Test + void fromMap() { + Map properties = Map.of("a", "b", "c", 2); + PropertyDeserializer propertyDeserializer = PropertyDeserializer.fromMap(properties); + + assertEquals("b", propertyDeserializer.parseString("a", "?")); + assertEquals(2, propertyDeserializer.parseInt("c", -1)); + assertEquals(-1, propertyDeserializer.parseInt("d", -1)); + } + + @Test + void fromCSV() { + CSVRecord record = CSVFormat.SEMICOLON.parseCSV("name;age\njohn;38").getFirst(); + PropertyDeserializer propertyDeserializer = PropertyDeserializer.fromCSV(record); + + assertEquals("john", propertyDeserializer.parseString("name", "")); + assertEquals(38, propertyDeserializer.parseInt("age", -1)); + } } diff --git a/test/nl/colorize/util/SubscribableTest.java b/test/nl/colorize/util/SubscribableTest.java index 4c11d9a..16eddeb 100644 --- a/test/nl/colorize/util/SubscribableTest.java +++ b/test/nl/colorize/util/SubscribableTest.java @@ -11,9 +11,8 @@ import java.util.ArrayList; import java.util.List; -import java.util.UUID; import java.util.concurrent.CopyOnWriteArrayList; -import java.util.function.Consumer; +import java.util.concurrent.atomic.AtomicInteger; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -34,7 +33,7 @@ void subscribe() { void dontReceiveAfterDispose() { List received = new ArrayList<>(); Subscribable subject = new Subscribable<>(); - Consumer subscriber = received::add; + Subscriber subscriber = received::add; subject.subscribe(subscriber); subject.next("a"); @@ -189,16 +188,16 @@ void subscribeOther() { @Test void unsubscribe() { - UUID idA = UUID.randomUUID(); - UUID idB = UUID.randomUUID(); List received = new ArrayList<>(); + Subscriber a = event -> received.add("a" + event); + Subscriber b = event -> received.add("b" + event); Subscribable subject = new Subscribable<>(); - subject.subscribe(idA, event -> received.add("a" + event), null); - subject.subscribe(idB, event -> received.add("b" + event), null); + subject.subscribe(a); + subject.subscribe(b); subject.next("1"); subject.next("2"); - subject.unsubscribe(idA); + subject.unsubscribe(a); subject.next("3"); assertEquals("[a1, b1, a2, b2, b3]", received.toString()); @@ -261,4 +260,45 @@ void retryWithDelay() { assertEquals(1, result.size()); assertEquals(0, errors.size()); } + + @Test + void completedEvent() { + AtomicInteger counter = new AtomicInteger(0); + + Subscribable subject = new Subscribable<>(); + subject.subscribe(new Subscriber<>() { + @Override + public void onEvent(String event) { + } + + @Override + public void onComplete() { + counter.incrementAndGet(); + } + }); + subject.complete(); + + assertEquals(1, counter.get()); + } + + @Test + void doNotSendCompletedEventTwice() { + AtomicInteger counter = new AtomicInteger(0); + + Subscribable subject = new Subscribable<>(); + subject.subscribe(new Subscriber<>() { + @Override + public void onEvent(String event) { + } + + @Override + public void onComplete() { + counter.incrementAndGet(); + } + }); + subject.complete(); + subject.complete(); + + assertEquals(1, counter.get()); + } } diff --git a/test/nl/colorize/util/stats/AggregateTest.java b/test/nl/colorize/util/stats/AggregateTest.java index 080c603..fb1f0fb 100644 --- a/test/nl/colorize/util/stats/AggregateTest.java +++ b/test/nl/colorize/util/stats/AggregateTest.java @@ -86,4 +86,13 @@ void correlation() { assertEquals(-0.90f, Aggregate.correlation(original, differentOrder), EPSILON); assertEquals(0.13f, Aggregate.correlation(original, dissimilar), EPSILON); } + + @Test + void multiplyPercentage() { + assertEquals(0f, Aggregate.multiplyPercentage(0f, 0f), EPSILON); + assertEquals(0f, Aggregate.multiplyPercentage(0f, 50f), EPSILON); + assertEquals(100f, Aggregate.multiplyPercentage(100f, 100f), EPSILON); + assertEquals(50f, Aggregate.multiplyPercentage(100f, 50f), EPSILON); + assertEquals(25f, Aggregate.multiplyPercentage(50f, 50f), EPSILON); + } } diff --git a/test/nl/colorize/util/stats/CSVFormatTest.java b/test/nl/colorize/util/stats/CSVFormatTest.java new file mode 100644 index 0000000..fc750de --- /dev/null +++ b/test/nl/colorize/util/stats/CSVFormatTest.java @@ -0,0 +1,90 @@ +//----------------------------------------------------------------------------- +// Colorize Java Commons +// Copyright 2007-2024 Colorize +// Apache license (http://www.apache.org/licenses/LICENSE-2.0) +//----------------------------------------------------------------------------- + +package nl.colorize.util.stats; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class CSVFormatTest { + + @Test + void parseCSV() { + String csv = """ + name;age + john;38 + jim;40 + """; + + List records = CSVFormat.SEMICOLON.parseCSV(csv); + + assertEquals(2, records.size()); + assertEquals("john", records.get(0).get(0)); + assertEquals("38", records.get(0).get(1)); + assertEquals("jim", records.get(1).get(0)); + assertEquals("40", records.get(1).get(1)); + } + + @Test + void parseWithoutHeaders() { + String csv = """ + john;38 + jim;40 + """; + + List records = CSVFormat.withoutHeaders(';').parseCSV(csv); + + assertEquals(2, records.size()); + assertEquals("john", records.get(0).get(0)); + assertEquals("38", records.get(0).get(1)); + assertEquals("jim", records.get(1).get(0)); + assertEquals("40", records.get(1).get(1)); + } + + @Test + void parseEmptyCSV() { + List records = CSVFormat.withHeaders(';').parseCSV(""); + + assertEquals(0, records.size()); + } + + @Test + void parseHeadersWithoutRows() { + List records = CSVFormat.withHeaders(';').parseCSV("name;age"); + + assertEquals(0, records.size()); + } + + @Test + void parseQuotes() { + String csv = """ + name;age + "jo;hn";38 + jim;"old" + """; + + List records = CSVFormat.SEMICOLON.withQuotes().parseCSV(csv); + + assertEquals(2, records.size()); + assertEquals("jo;hn", records.get(0).get(0)); + assertEquals("38", records.get(0).get(1)); + assertEquals("jim", records.get(1).get(0)); + assertEquals("old", records.get(1).get(1)); + } + + @Test + void serializeRecords() { + assertEquals("john;38\n", CSVFormat.SEMICOLON.toCSV("john", "38")); + } + + @Test + void escapeDelimiters() { + assertEquals("john;38\n", CSVFormat.SEMICOLON.toCSV("jo;hn", "38")); + } +} diff --git a/test/nl/colorize/util/stats/CSVRecordTest.java b/test/nl/colorize/util/stats/CSVRecordTest.java new file mode 100644 index 0000000..aada7de --- /dev/null +++ b/test/nl/colorize/util/stats/CSVRecordTest.java @@ -0,0 +1,73 @@ +//----------------------------------------------------------------------------- +// Colorize Java Commons +// Copyright 2007-2024 Colorize +// Apache license (http://www.apache.org/licenses/LICENSE-2.0) +//----------------------------------------------------------------------------- + +package nl.colorize.util.stats; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class CSVRecordTest { + + @Test + public void testParseMultipleWithHeader() { + List records = CSVFormat.SEMICOLON.parseCSV("h1;h2\na;2\nb;3"); + + assertEquals(2, records.size()); + assertEquals("a", records.get(0).get(0)); + assertEquals("b", records.get(1).get(0)); + } + + @Test + void getColumnByName() { + String csv = """ + Name;Age + John;38 + """; + + List records = CSVFormat.SEMICOLON.parseCSV(csv); + + assertEquals("John", records.get(0).get("Name")); + assertEquals("38", records.get(0).get("Age")); + } + + @Test + void parseWithoutHeaders() { + String csv = """ + Name;Age + John;38 + Jim;26 + """; + + List records = CSVFormat.withoutHeaders(';').parseCSV(csv); + + assertEquals(3, records.size()); + assertEquals("Name", records.get(0).get(0)); + assertEquals("John", records.get(1).get(0)); + assertEquals("Jim", records.get(2).get(0)); + } + + @Test + void getColumns() { + String csv = """ + Name;Age + John;38 + """; + + List records = CSVFormat.SEMICOLON.parseCSV(csv); + + assertEquals(1, records.size()); + assertEquals(List.of("Name", "Age"), records.get(0).getColumns()); + assertEquals("[(Name, John), (Age, 38)]", records.get(0).getColumnValues().toString()); + } + + @Test + void parseEmptyCSV() { + assertEquals(0, CSVFormat.SEMICOLON.parseCSV("").size()); + } +} diff --git a/test/nl/colorize/util/uitest/ImageViewerUIT.java b/test/nl/colorize/util/uitest/ImageViewerUIT.java index 017725e..abe2bf9 100644 --- a/test/nl/colorize/util/uitest/ImageViewerUIT.java +++ b/test/nl/colorize/util/uitest/ImageViewerUIT.java @@ -44,7 +44,7 @@ public class ImageViewerUIT { private ImageViewer imageViewer; private Table imageList; - private static final int IMAGE_CACHE_SIZE = 1100; + private static final int IMAGE_CACHE_SIZE = 1000; public static void main(String[] args) { SwingUtils.initializeSwing();