This commit is contained in:
coco
2026-07-03 15:56:07 +08:00
commit caef23209c
5767 changed files with 1004268 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
# binjr-core
[![Maven Central](https://img.shields.io/maven-central/v/eu.binjr/binjr-core.svg?label=Maven%20Central&style=flat-square)](https://search.maven.org/search?q=g:%22eu.binjr%22%20AND%20a:%22binjr-core%22)
This is the main module for the application. It is responsible for :
* The presentation layer and interaction with the end-user.
* The management and persistence of user sessions:
- A single session is organised as a [Workspace](https://github.com/binjr/binjr/tree/master/binjr-core/src/main/java/eu/binjr/core/data/workspace/Workspace.java).
- A workspace can hold an arbitrary number of [Worksheets](https://github.com/binjr/binjr/tree/master/binjr-core/src/main/java/eu/binjr/core/data/workspace/Worksheet.java) and [Sources](https://github.com/binjr/binjr/tree/master/binjr-core/src/main/java/eu/binjr/core/data/workspace/Source.java)
- A worksheet displays data from time-series from any sources on one or more graphs, all sharing a single timeline.
- A source is backed by a [DataAdapter](https://github.com/binjr/binjr/tree/master/binjr-core/src/main/java/eu/binjr/core/data/adapters/DataAdapter.java), a component which handles the actual communication to a data source.
- A workspace can be saved (i.e. serialized to XML and written to a file) by the end-user at any time to be restored later on.
* Loading and instantiating DataAdapters for the various supported data sources, packaged in other modules.
* Exposing the [DataAdapter API](https://github.com/binjr/binjr/tree/master/binjr-core/src/main/java/eu/binjr/core/data/adapters) to other modules
+78
View File
@@ -0,0 +1,78 @@
/*
* Copyright 2018-2024 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
dependencies {
api 'org.apache.logging.log4j:log4j-core:2.22.0'
api 'org.apache.logging.log4j:log4j-jcl:2.22.0'
implementation 'org.apache.logging.log4j:log4j-slf4j-impl:2.22.0'
api 'org.controlsfx:controlsfx:11.2.1'
implementation 'org.gillius:jfxutils:1.0'
api 'com.google.code.gson:gson:2.10.1'
api 'org.apache.commons:commons-csv:1.10.0'
api 'jakarta.xml.bind:jakarta.xml.bind-api:4.0.2'
implementation 'org.glassfish.jaxb:jaxb-runtime:4.0.5'
implementation 'org.bouncycastle:bcpg-jdk18on:1.78.1'
implementation 'org.reflections:reflections:0.9.12'
api 'org.fxmisc.richtext:richtextfx:0.11.0'
api 'org.apache.lucene:lucene-core:9.10.0'
api 'org.apache.lucene:lucene-facet:9.10.0'
implementation 'org.apache.lucene:lucene-queryparser:9.10.0'
implementation 'org.apache.lucene:lucene-analysis-common:9.10.0'
implementation 'com.github.ben-manes.caffeine:caffeine:3.1.8'
// Only to prepare POM for Maven central upload
compileOnlyApi "org.openjfx:javafx-base:${OPENJFX_VERSION}"
compileOnlyApi "org.openjfx:javafx-graphics:${OPENJFX_VERSION}"
compileOnlyApi "org.openjfx:javafx-controls:${OPENJFX_VERSION}"
compileOnlyApi "org.openjfx:javafx-fxml:${OPENJFX_VERSION}"
compileOnlyApi "org.openjfx:javafx-swing:${OPENJFX_VERSION}"
}
compileJava {
doFirst {
options.compilerArgs = [
'--module-path', classpath.asPath,
'--add-modules', 'javafx.controls,javafx.fxml,javafx.swing'
]
}
}
jar {
manifest {
attributes(
'Main-Class': 'eu.binjr.core.Bootstrap',
'Specification-Title': project.name,
'Specification-Version': project.version,
'Implementation-Title': project.name,
'Implementation-Version': project.version,
'SplashScreen-Image': 'images / splashscreen.png',
'Build-Number': BINJR_BUILD_NUMBER
)
}
}
test {
useJUnitPlatform()
}
repositories {
mavenCentral()
}
@@ -0,0 +1,103 @@
/*
* Copyright 2017-2018 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.auth;
/**
* Defines an immutable set of credentials
*
* @author Frederic Thevenet
*/
public class CredentialsEntry {
private final String login;
private final char[] pwd;
private final boolean filled;
/**
* An empty credential set.
* <p>This specific credential set should be used to intend that no credentials where explicitly provided</p>
*/
public static final CredentialsEntry EMPTY = new CredentialsEntry("", new char[0], false);
/**
* A canceled credential set.
* <p>This specific credential set should be used to intend that the query for explicit credentials was initiated but canceled</p>
*/
public static final CredentialsEntry CANCELLED = new CredentialsEntry("", new char[0], true);
/**
* Initializes a new instance of the {@link CredentialsEntry} class
*
* @param login the principle's login
* @param password the principle's password
*/
public CredentialsEntry(String login, char[] password) {
this(login, password, true);
}
/**
* Creates a deep clone of the provided {@link CredentialsEntry} instance.
*
* @param credentials the {@link CredentialsEntry} instance to copy.
* @return a deep clone of the provided {@link CredentialsEntry} instance.
*/
public static CredentialsEntry copyOf(CredentialsEntry credentials) {
return new CredentialsEntry(credentials.login, credentials.pwd, credentials.filled);
}
private CredentialsEntry(String login, char[] password, boolean filled) {
this.login = login;
this.pwd = password;
this.filled = filled;
}
/**
* Returns the principle's password
*
* @return the password
*/
public char[] getPwd() {
return pwd;
}
/**
* Returns the principle's login
*
* @return the principle's login
*/
public String getLogin() {
return login;
}
/**
* Returns true if the current set of credentials have been filled-in, false otherwise.
*
* @return true if the current set of credentials have been filled-in, false otherwise.
*/
public boolean isFilled() {
return filled;
}
/**
* Overwrites the array backing the principle's password property.
*/
public void clearPassword() {
for (int i = 0; i < pwd.length; i++) {
pwd[i] = '#';
}
}
}
@@ -0,0 +1,116 @@
/*
* Copyright 2017-2021 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.auth;
import com.sun.security.auth.module.Krb5LoginModule;
import eu.binjr.common.logging.Logger;
import eu.binjr.core.dialogs.Dialogs;
import javafx.application.Platform;
import javafx.stage.StageStyle;
import javafx.util.Pair;
import org.controlsfx.dialog.LoginDialog;
import javax.security.auth.Subject;
import javax.security.auth.callback.*;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
/**
* An extension of the Krb5LoginModule that displays a JavaFX dialog to obtain credentials from the end-user if need be.
*
* @author Frederic Thevenet
*/
public class JfxKrb5LoginModule extends Krb5LoginModule {
private static final Logger logger = Logger.create(JfxKrb5LoginModule.class);
private CredentialsEntry credentials = CredentialsEntry.EMPTY;
@Override
public void initialize(Subject subject, CallbackHandler callbackHandler, Map<String, ?> sharedState, Map<String, ?> options) {
super.initialize(subject, callbacks -> {
try {
credentials = obtainCredentials(CredentialsEntry.copyOf(credentials));
for (Callback callback : callbacks) {
if (callback instanceof NameCallback nc) {
nc.setName(credentials.getLogin());
} else if (callback instanceof PasswordCallback pc) {
pc.setPassword(credentials.getPwd());
credentials.clearPassword();
} else {
throw new UnsupportedCallbackException(callback, "Unknown Callback");
}
}
} catch (InterruptedException | TimeoutException e) {
logger.error("An exception occurred while retrieving credentials - " + e.getMessage());
if (logger.isDebugEnabled()) {
logger.debug("Stack trace", e);
}
}
}, sharedState, options);
}
private synchronized CredentialsEntry obtainCredentials(CredentialsEntry credentialsEntry) throws InterruptedException, TimeoutException {
if (credentialsEntry.isFilled()) {
return credentialsEntry;
}
AsyncResult<CredentialsEntry> future = new AsyncResult<>();
Platform.runLater(() -> {
LoginDialog dlg = new LoginDialog(null, null);
dlg.setHeaderText("Enter login credentials");
dlg.setTitle("Login");
dlg.initStyle(StageStyle.UTILITY);
Dialogs.setAlwaysOnTop(dlg);
Optional<Pair<String, String>> res = dlg.showAndWait();
if (res.isPresent()) {
CredentialsEntry newCreds = new CredentialsEntry(dlg.getResult().getKey(), dlg.getResult().getValue().toCharArray());
future.put(newCreds);
} else {
future.put(CredentialsEntry.CANCELLED);
}
});
return future.get();
}
private class AsyncResult<F> {
private final CountDownLatch latch = new CountDownLatch(1);
private F value;
public boolean isDone() {
return latch.getCount() == 0;
}
public F get() throws InterruptedException {
latch.await();
return value;
}
public F get(long timeout, TimeUnit unit) throws InterruptedException, TimeoutException {
if (latch.await(timeout, unit)) {
return value;
} else {
throw new TimeoutException();
}
}
private void put(F result) {
value = result;
latch.countDown();
}
}
}
@@ -0,0 +1,39 @@
/*
* Copyright 2017-2018 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.cache;
/**
* Indicates that the implementing class can be cached using {@link LRUMapCapacityBound} or {@link LRUMapSizeBound}
*
* @param <T> The type for cached values.
*/
public interface Cacheable<T> {
/**
* Returns the cached value
*
* @return the cached value/
*/
T getValue();
/**
* Returns the size in bytes of the cached value.
*
* @return The size in bytes of the cached value.
*/
long getSize();
}
@@ -0,0 +1,58 @@
/*
* Copyright 2017-2018 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.cache;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Finite capacity map with a Least Recent Used eviction policy
*
* @param <K> type of keys
* @param <V> type of values
* @author Frederic Thevenet
*/
@Deprecated
public class LRUMapCapacityBound<K, V> extends LinkedHashMap<K, V> {
private int cacheSize;
/**
* Initializes a new instance of the {@link LRUMapCapacityBound} class with the specified capacity
*
* @param capacity the maximum capacity for the {@link LRUMapCapacityBound}
*/
public LRUMapCapacityBound(int capacity) {
super(16, 0.75f, true);
this.cacheSize = capacity;
}
/**
* Initializes a new instance of the {@link LRUMapCapacityBound} class with the specified capacity and initial values
*
* @param capacity the maximum capacity for the {@link LRUMapCapacityBound}
* @param values initial values to populate the {@link LRUMapCapacityBound}
*/
public LRUMapCapacityBound(int capacity, Map<? extends K, ? extends V> values) {
this(capacity);
putAll(values);
}
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
return size() > cacheSize;
}
}
@@ -0,0 +1,61 @@
/*
* Copyright 2017-2018 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.cache;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Map witch evicts the least recently used element when a maximum size of stored element is reached.
*
* <p><i><b>NB: </b>This implementation recalculates the combined size of all stored elements each time a new element is added,
* so it is not well suited to pushing a large number of cached items at a high rate.</i></p>
*
* @param <K> type of keys
* @param <V> type of values
* @author Frederic Thevenet
*/
@Deprecated
public class LRUMapSizeBound<K, V extends Cacheable> extends LinkedHashMap<K, V> {
private long maxSize;
/**
* Initializes a new instance of the {@link LRUMapSizeBound} class with the specified capacity
*
* @param maxSize the maximum capacity for the {@link LRUMapSizeBound}
*/
public LRUMapSizeBound(int maxSize) {
super(16, 0.75f, true);
this.maxSize = maxSize;
}
/**
* Initializes a new instance of the {@link LRUMapSizeBound} class with the specified capacity and initial values
*
* @param maxSize the maximum capacity for the {@link LRUMapSizeBound}
* @param values initial values to populate the {@link LRUMapSizeBound}
*/
public LRUMapSizeBound(int maxSize, Map<? extends K, ? extends V> values) {
this(maxSize);
putAll(values);
}
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
return this.values().stream().map(Cacheable::getSize).reduce(0L, Long::sum) > maxSize;
}
}
@@ -0,0 +1,90 @@
/*
* Copyright 2020 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.colors;
import eu.binjr.common.logging.Logger;
import javafx.scene.paint.Color;
/**
* Utility methods to convert color encodings
*
* @author Frederic Thevenet
*/
public class ColorUtils {
private static final Logger logger = Logger.create(ColorUtils.class);
public static String toRGBAString(Color color) {
return toRGBAString(color, color.getOpacity());
}
public static String toRGBAString(Color color, double alpha) {
if (color == null) {
throw new IllegalArgumentException("Argument color is null");
}
if (alpha > 1.0 || alpha < 0) {
throw new IllegalArgumentException("alpha parameter value is out of bound: " + alpha);
}
StringBuilder sb = new StringBuilder("rgba(")
.append(color.getRed())
.append(",")
.append(color.getGreen())
.append(",")
.append(color.getBlue())
.append(",")
.append(alpha)
.append(")");
logger.debug(() -> "RGBA representation = " + sb.toString());
return sb.toString();
}
public static String toHex(Color color, String defaultHexValue) {
if (defaultHexValue == null) {
throw new IllegalArgumentException("Default color hex value cannot be null");
}
if (color == null) {
return defaultHexValue;
}
return toHex(color);
}
public static String toHex(Color color) {
if (color == null) {
throw new IllegalArgumentException("Argument color is null");
}
return toHex(color, color.getOpacity());
}
public static String toHex(Color color, double alpha) {
if (color == null) {
throw new IllegalArgumentException("Argument color is null");
}
return String.format("#%02x%02x%02x%02x",
Math.round(color.getRed() * 255),
Math.round(color.getGreen() * 255),
Math.round(color.getBlue() * 255),
Math.round(alpha * 255));
}
public static Color copy(Color color){
return Color.color(color.getRed(),
color.getGreen(),
color.getBlue(),
color.getOpacity());
}
}
@@ -0,0 +1,216 @@
package eu.binjr.common.concurrent;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import java.io.Closeable;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.function.Supplier;
/**
* A generic resource manager for safely initializing, sharing and disposing
* singletons amongst multiple consumer objects, on multiple threads.
* <p>
* The resource manager will guarantee that only one instance for each supplied
* key is ever is initialized and returned when required.
* </p>
* <p>
* Reference counting is used to ensure that the actual closing of a resource
* only happens once all registered consumers have released it.
* </p>
*
* @param <T> Resource type
* @author Frederic Thevenet
*/
public class CloseableResourceManager<T extends Closeable> {
private static final Log logger = LogFactory.getLog(CloseableResourceManager.class);
private final Map<String, ResourceHolder<T>> resources = new HashMap<>();
private final ReadWriteLockHelper managerLock = new ReadWriteLockHelper(new ReentrantReadWriteLock());
/**
* Resource holder Resource type
*
* @param <U> Resource type
*/
private final class ResourceHolder<U extends AutoCloseable> {
private AtomicInteger referenceCount = new AtomicInteger(0);
private volatile boolean closed;
private U instance;
/**
* Constructor
*
* @param instance Resource instance
*/
private ResourceHolder(U instance) {
this.instance = instance;
}
/**
* Get Resource instance
*
* @return Resource instance
*/
private U getInstance() {
return instance;
}
/**
* Register resource
*
* @return Reference count
*/
private int register() {
return referenceCount.incrementAndGet();
}
/**
* Release resource
*
* @return Reference count
* @throws Exception exception
*/
private int release() throws Exception {
int refLeft = referenceCount.decrementAndGet();
if (refLeft < 1) {
if (!closed) {
try {
instance.close();
} finally {
closed = true;
}
}
}
return refLeft;
}
}
/**
* Registers a consumer with a resource, identified by the supplied key.
* <p>
* This method must only be called once per consumer (or the release methods
* must be called accordingly), otherwise the resource may never be
* released.
* </p>
*
* @param key the key to identify the resource to register.
* @param resourceFactory a factory method used to build a new instance of the resource
* if one doesn't already exist.
* @return the registered resource
*/
public T acquire(String key, Supplier<T> resourceFactory) {
return managerLock.write().lock(() -> {
var r = resources.computeIfAbsent(key, k -> new ResourceHolder<>(resourceFactory.get()));
r.register();
return r.getInstance();
});
}
/**
* Registers a consumer with a resource, identified by the supplied key.
* <p>
* This method must only be called once per consumer (or the release methods
* must be called accordingly), otherwise the resource may never be
* released.
* </p>
*
* @param key the key to identify the resource to register.
* @param resource the resource to register
* @return Reference count
*/
public int register(String key, T resource) {
return managerLock.write().lock(() -> {
var r = resources.computeIfAbsent(key, k -> new ResourceHolder<>(resource));
return r.register();
});
}
/**
* Releases the resource identified by the supplied key.
* <p>
* If all references to this resource are released, the resource is closed.
* </p>
* <p>
* <b>Warning:</b> Registering a closed resources with the same key will
* cause a new instance to be created.
* </p>
*
* @param key the key to identify the resource to release.
* @return the number of references left for the specified resource.
* @throws Exception if an error occurs while closing the resource.
*/
public int release(String key) throws Exception {
return managerLock.write().lock(() -> {
ResourceHolder<T> r = resources.get(key);
if (r != null) {
try {
return r.release();
} finally {
if (r.closed) {
resources.remove(key);
}
}
} else {
logger.warn("Trying to release a resource that is not registered.");
return -1;
}
});
}
/**
* Forces the release and subsequent closing of the specified resource,
* regardless of how many references to it are still held by other
* consumers.
* <p>
* Use with caution.
* </p>
*
* @param key the key to identify the resource to close.
* @throws Exception if an error occurs while closing the resource.
*/
public void forceReleaseAndClose(String key) throws Exception {
managerLock.write().lock(() -> {
ResourceHolder<T> r = resources.get(key);
if (r != null) {
while (r.release() > 0) {
// Nothing
}
} else {
logger.warn("Trying to close a resource that is not registered.");
}
});
}
/**
* Returns the resource identified by the supplied key.
*
* @param key the key to identify the resource to release.
* @return An {@link Optional} wrapping the resource identified by the supplied key.
*/
public Optional<T> get(String key) {
return managerLock.read().lock(() -> {
ResourceHolder<T> r = resources.get(key);
if (r != null) {
return Optional.of(r.getInstance());
}
return Optional.empty();
});
}
/**
* Returns true if a resource identified by the provided key is registered,
* false otherwise.
*
* @param key the key to identify the resource.
* @return true if a resource identified by the provided key is registered,
* false otherwise.
*/
public boolean isRegistered(String key) {
return managerLock.read().lock(() -> resources.containsKey(key));
}
}
@@ -0,0 +1,306 @@
/*
* Copyright 2017-2018 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.concurrent;
import eu.binjr.common.function.*;
import java.util.Optional;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
/**
* An helper class that wraps a {@link ReadWriteLock} instance and provides methods to streamline usage and avoid common inattention mistakes,
* such as locking one the lock and unlocking the other in the {@code finally} block.
*
* @author Frederic Thevenet
*/
public class ReadWriteLockHelper {
private final ReadWriteLock lock;
private final LockHelper readLockHelper;
private final LockHelper writeLockHelper;
/**
* Creates a new instance of the {@link ReadWriteLockHelper} class that wraps a new instance of {@link ReentrantReadWriteLock}
*/
public ReadWriteLockHelper() {
this(new ReentrantReadWriteLock());
}
/**
* Creates a new instance of the {@link ReadWriteLockHelper} class for the provided lock.
*
* @param lock the {@link ReadWriteLock} instance to wrap.
*/
public ReadWriteLockHelper(ReadWriteLock lock) {
this.lock = lock;
this.readLockHelper = new LockHelper(lock.readLock());
this.writeLockHelper = new LockHelper(lock.writeLock());
}
/**
* Gets the underlying ReadWriteLock instance.
*
* @return the underlying ReadWriteLock instance.
*/
public ReadWriteLock getLock() {
return lock;
}
/**
* Returns an helper providing methods to run lambdas within the context of the read lock.
*
* @return an helper providing methods to run lambdas within the context of the read lock.
*/
public LockHelper read() {
return readLockHelper;
}
/**
* Returns an helper providing methods to run lambdas within the context of the write lock.
*
* @return an helper providing methods to run lambdas within the context of the write lock.
*/
public LockHelper write() {
return writeLockHelper;
}
/**
* An helper providing methods to run lambdas within the context of supplied lock.
*/
public static class LockHelper {
private final Lock lock;
/**
* Constructor
*
* @param lock Lock
*/
private LockHelper(Lock lock) {
this.lock = lock;
}
//region *** lock method overloads ***
/**
* Encapsulated the evaluation of the supplied {@link CheckedSupplier} within the boundaries of the lock.
*
* @param operation the {@link CheckedSupplier} to evaluate.
* @param <R> the return type for the {@link CheckedSupplier}.
* @param <E> the type of the exception thrown by the {@link CheckedSupplier}.
* @return the return of the {@link CheckedSupplier}.
* @throws E the exception thrown by the {@link CheckedSupplier}.
*/
public <R, E extends Exception> R lock(CheckedSupplier<R, E> operation) throws E {
lock.lock();
try {
return operation.get();
} finally {
lock.unlock();
}
}
/**
* Encapsulated the evaluation of the supplied {@link CheckedFunction} within the boundaries of the lock.
*
* @param operation the {@link CheckedFunction} to apply.
* @param t the parameter to pass the {@link CheckedFunction}
* @param <T> the type of he parameter to pass the {@link CheckedFunction}
* @param <R> the return type for the {@link CheckedFunction}.
* @param <E> the type of the exception thrown by the {@link CheckedFunction}.
* @return the result of the {@link CheckedFunction}.
* @throws E the exception thrown by the {@link CheckedFunction}.
*/
public <T, R, E extends Exception> R lock(CheckedFunction<T, R, E> operation, T t) throws E {
lock.lock();
try {
return operation.apply(t);
} finally {
lock.unlock();
}
}
/**
* Encapsulated the evaluation of the supplied {@link CheckedBiFunction} within the boundaries of the lock.
*
* @param operation the {@link CheckedBiFunction} to apply.
* @param t the first parameter to pass the {@link CheckedBiFunction}.
* @param u the second parameter to pass the {@link CheckedBiFunction}.
* @param <T> the type of the first parameter of the {@link CheckedBiFunction}.
* @param <U> the type of the first parameter of the {@link CheckedBiFunction}.
* @param <R> the return type for the {@link CheckedBiFunction}.
* @param <E> the type of the exception thrown by the {@link CheckedBiFunction}.
* @return the result of the {@link CheckedFunction}.
* @throws E the exception thrown by the {@link CheckedBiFunction}.
*/
public <T, U, R, E extends Exception> R lock(CheckedBiFunction<T, U, R, E> operation, T t, U u) throws E {
lock.lock();
try {
return operation.apply(t, u);
} finally {
lock.unlock();
}
}
/**
* Encapsulated the evaluation of the supplied {@link CheckedRunnable} within the boundaries of the lock.
*
* @param operation the {@link CheckedRunnable} to evaluate.
* @param <E> the type of the exception thrown by the {@link CheckedRunnable}.
* @throws E the exception thrown by the {@link CheckedRunnable}.
*/
public <E extends Exception> void lock(CheckedRunnable<E> operation) throws E {
lock.lock();
try {
operation.run();
} finally {
lock.unlock();
}
}
/**
* Encapsulated the evaluation of the supplied {@link CheckedConsumer} within the boundaries of the lock.
*
* @param operation the {@link CheckedConsumer} to evaluate.
* @param t the parameter to pass the {@link CheckedConsumer}
* @param <T> the type of the parameter to pass the {@link CheckedConsumer}
* @param <E> the type of the exception thrown by the {@link CheckedConsumer}.
* @throws E the exception thrown by the {@link CheckedConsumer}.
*/
public <T, E extends Exception> void lock(CheckedConsumer<T, E> operation, T t) throws E {
lock.lock();
try {
operation.accept(t);
} finally {
lock.unlock();
}
}
//endregion
//region *** tryLock method overloads ***
/**
* Tries to acquire the lock and if successful, evaluates the supplied {@link CheckedSupplier} within the boundaries of the lock.
*
* @param operation the {@link CheckedSupplier} to evaluate.
* @param <R> the return type for the {@link CheckedSupplier}.
* @param <E> the type of the exception thrown by the {@link CheckedSupplier}.
* @return An {@link Optional} that contains the return value of the {@link CheckedSupplier} if the lock could be acquired
* @throws E the exception thrown by the {@link CheckedSupplier}.
*/
public <R, E extends Exception> Optional<R> tryLock(CheckedSupplier<R, E> operation) throws E {
if (lock.tryLock()) {
try {
return Optional.ofNullable(operation.get());
} finally {
lock.unlock();
}
}
return Optional.empty();
}
/**
* Tries to acquire the lock and if successful, evaluates the supplied {@link CheckedFunction} within the boundaries of the lock.
*
* @param operation the {@link CheckedFunction} to apply.
* @param t the parameter to pass the {@link CheckedFunction}
* @param <T> the type of he parameter to pass the {@link CheckedFunction}
* @param <R> the return type for the {@link CheckedFunction}.
* @param <E> the type of the exception thrown by the {@link CheckedFunction}.
* @return the result of the {@link CheckedFunction}.
* @throws E the exception thrown by the {@link CheckedFunction}.
*/
public <T, R, E extends Exception> Optional<R> tryLock(CheckedFunction<T, R, E> operation, T t) throws E {
if (lock.tryLock()) {
try {
return Optional.ofNullable(operation.apply(t));
} finally {
lock.unlock();
}
}
return Optional.empty();
}
/**
* Tries to acquire the lock and if successful, evaluates the supplied {@link CheckedBiFunction} within the boundaries of the lock.
*
* @param operation the {@link CheckedBiFunction} to apply.
* @param t the first parameter to pass the {@link CheckedBiFunction}.
* @param u the second parameter to pass the {@link CheckedBiFunction}.
* @param <T> the type of the first parameter of the {@link CheckedBiFunction}.
* @param <U> the type of the first parameter of the {@link CheckedBiFunction}.
* @param <R> the return type for the {@link CheckedBiFunction}.
* @param <E> the type of the exception thrown by the {@link CheckedBiFunction}.
* @return the result of the {@link CheckedFunction}.
* @throws E the exception thrown by the {@link CheckedBiFunction}.
*/
public <T, U, R, E extends Exception> Optional<R> tryLock(CheckedBiFunction<T, U, R, E> operation, T t, U u) throws E {
if (lock.tryLock()) {
try {
return Optional.ofNullable(operation.apply(t, u));
} finally {
lock.unlock();
}
}
return Optional.empty();
}
/**
* Tries to acquire the lock and if successful, evaluates the supplied {@link CheckedRunnable} within the boundaries of the lock.
*
* @param operation the {@link CheckedRunnable} to evaluate.
* @param <E> the type of the exception thrown by the {@link CheckedRunnable}.
* @return true if the lock was acquired, false otherwise.
* @throws E the exception thrown by the {@link CheckedRunnable}.
*/
public <E extends Exception> boolean tryLock(CheckedRunnable<E> operation) throws E {
if (lock.tryLock()) {
try {
operation.run();
} finally {
lock.unlock();
}
return true;
}
return false;
}
/**
* Tries to acquire the lock and if successful, evaluates the supplied {@link CheckedConsumer} within the boundaries of the lock.
*
* @param operation the {@link CheckedConsumer} to evaluate.
* @param t the parameter to pass the {@link CheckedConsumer}
* @param <T> the type of the parameter to pass the {@link CheckedConsumer}
* @param <E> the type of the exception thrown by the {@link CheckedConsumer}.
* @return true if the lock was acquired, false otherwise.
* @throws E the exception thrown by the {@link CheckedConsumer}.
*/
public <T, E extends Exception> boolean tryLock(CheckedConsumer<T, E> operation, T t) throws E {
if (lock.tryLock()) {
try {
operation.accept(t);
} finally {
lock.unlock();
}
return true;
}
return false;
}
//endregion
}
}
@@ -0,0 +1,175 @@
/*
* Copyright 2017-2020 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.diagnostic;
import eu.binjr.core.preferences.AppEnvironment;
import javax.management.JMX;
import javax.management.MBeanServer;
import javax.management.MalformedObjectNameException;
import javax.management.ObjectName;
import java.lang.management.ManagementFactory;
import java.nio.file.Path;
import java.util.function.Supplier;
/**
* Defines methods for a diagnostic command.
*/
public interface DiagnosticCommand {
DiagnosticCommand local = ((Supplier<DiagnosticCommand>) () -> {
try {
MBeanServer server = ManagementFactory.getPlatformMBeanServer();
ObjectName name = new ObjectName("com.sun.management", "type", "DiagnosticCommand");
return JMX.newMBeanProxy(server, name, DiagnosticCommand.class);
} catch (MalformedObjectNameException e) {
throw new AssertionError(e);
}
}).get();
static String dumpVmSystemProperties() throws DiagnosticException {
try {
return local.vmSystemProperties();
} catch (Throwable t) {
throw new DiagnosticException("Failed to invoke diagnostic command VM.System_properties", t);
}
}
static String dumpThreadStacks() throws DiagnosticException {
try {
return local.threadPrint("-l");
} catch (Throwable t) {
throw new DiagnosticException("Failed to invoke diagnostic command Thread.print", t);
}
}
static String dumpClassHistogram() throws DiagnosticException {
try {
return local.gcClassHistogram();
} catch (Throwable t) {
throw new DiagnosticException("Failed to invoke diagnostic command GC.class_histogram", t);
}
}
static String dumpVmFlags() throws DiagnosticException {
try {
return local.vmFlags();
} catch (Throwable t) {
throw new DiagnosticException("Failed to invoke diagnostic command VM.flags", t);
}
}
static String dumpVmCommandLine() throws DiagnosticException {
try {
return local.vmCommandLine();
} catch (Throwable t) {
throw new DiagnosticException("Failed to invoke diagnostic command VM.command_line", t);
}
}
static String getHelp() throws DiagnosticException {
try {
return local.help();
} catch (Throwable t) {
throw new DiagnosticException("Failed to invoke diagnostic command help", t);
}
}
static void dumpHeap(Path path) throws DiagnosticException {
switch (AppEnvironment.getInstance().getRunningJvm()) {
case HOTSPOT:
HotSpotDiagnosticHelper.dumpHeap(path);
break;
case OPENJ9:
case UNSUPPORTED:
default:
throw new UnavailableDiagnosticFeatureException();
}
}
static Path getHeapDumpPath() throws DiagnosticException {
switch (AppEnvironment.getInstance().getRunningJvm()) {
case HOTSPOT:
return HotSpotDiagnosticHelper.getHeapDumpPath();
case OPENJ9:
case UNSUPPORTED:
default:
throw new UnavailableDiagnosticFeatureException();
}
}
static void setHeapDumpPath(Path path) throws DiagnosticException {
switch (AppEnvironment.getInstance().getRunningJvm()) {
case HOTSPOT:
HotSpotDiagnosticHelper.setHeapDumpPath(path);
break;
case OPENJ9:
case UNSUPPORTED:
default:
throw new UnavailableDiagnosticFeatureException();
}
}
static boolean getHeapDumpOnOutOfMemoryError() throws DiagnosticException {
switch (AppEnvironment.getInstance().getRunningJvm()) {
case HOTSPOT:
return HotSpotDiagnosticHelper.getHeapDumpOnOutOfMemoryError();
case OPENJ9:
case UNSUPPORTED:
default:
throw new UnavailableDiagnosticFeatureException();
}
}
static void setHeapDumpOnOutOfMemoryError(boolean value) throws DiagnosticException {
switch (AppEnvironment.getInstance().getRunningJvm()) {
case HOTSPOT:
HotSpotDiagnosticHelper.setHeapDumpOnOutOfMemoryError(value);
break;
case OPENJ9:
case UNSUPPORTED:
default:
throw new UnavailableDiagnosticFeatureException();
}
}
static String dumpVmOptions() throws DiagnosticException {
switch (AppEnvironment.getInstance().getRunningJvm()) {
case HOTSPOT:
return HotSpotDiagnosticHelper.dumpVmOptions();
case OPENJ9:
case UNSUPPORTED:
default:
throw new UnavailableDiagnosticFeatureException();
}
}
String threadPrint(String... args);
String help(String... args);
String vmSystemProperties(String... args);
String gcClassHistogram(String... args);
String vmFlags(String... args);
String vmCommandLine(String... args);
}
@@ -0,0 +1,59 @@
/*
* Copyright 2017-2018 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.diagnostic;
/**
* Signals that an error occurred while invoking a diagnostic command.
*
* @author Frederic Thevenet
*/
public class DiagnosticException extends Exception {
/**
* Creates a new instance of the {@link DiagnosticException} class.
*/
public DiagnosticException() {
super("An error occurred while invoking a diagnostic command.");
}
/**
* Creates a new instance of the {@link DiagnosticException} class with the provided message.
*
* @param message the message of the exception.
*/
public DiagnosticException(String message) {
super(message);
}
/**
* Creates a new instance of the {@link DiagnosticException} class with the provided message and cause {@link Throwable}
*
* @param message the message of the exception.
* @param cause the cause for the exception.
*/
public DiagnosticException(String message, Throwable cause) {
super(message, cause);
}
/**
* Creates a new instance of the {@link DiagnosticException} class with the provided cause {@link Throwable}
*
* @param cause the cause for the exception.
*/
public DiagnosticException(Throwable cause) {
super(cause);
}
}
@@ -0,0 +1,91 @@
/*
* Copyright 2019-2020 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.diagnostic;
import com.sun.management.HotSpotDiagnosticMXBean;
import com.sun.management.VMOption;
import java.lang.management.ManagementFactory;
import java.nio.file.Path;
import java.util.List;
import java.util.stream.Collectors;
/**
* Defines methods for HotSpot specific diagnistic commands.
*/
public interface HotSpotDiagnosticHelper {
HotSpotDiagnosticMXBean HOTSPOT_DIAGNOSTIC = ManagementFactory.getPlatformMXBean(HotSpotDiagnosticMXBean.class);
static String dumpVmOptions() throws DiagnosticException{
return "Hotspot VM Options\n" + listOption()
.stream()
.map(vmOption -> String.format("%s=%s (%s)",
vmOption.getName(),
vmOption.getValue(),
vmOption.getOrigin()))
.collect(Collectors.joining("\n"));
}
static List<VMOption> listOption() throws DiagnosticException {
try {
return HOTSPOT_DIAGNOSTIC.getDiagnosticOptions();
} catch (Throwable t) {
throw new DiagnosticException("Failed to list VMOptions", t);
}
}
static void dumpHeap(Path path) throws DiagnosticException {
try {
HOTSPOT_DIAGNOSTIC.dumpHeap(path.toString(), false);
} catch (Throwable t) {
throw new DiagnosticException("Failed to get VMOption HeapDumpPath", t);
}
}
static Path getHeapDumpPath() throws DiagnosticException {
try {
return Path.of(HOTSPOT_DIAGNOSTIC.getVMOption("HeapDumpPath").getValue());
} catch (Throwable t) {
throw new DiagnosticException("Failed to get VMOption HeapDumpPath", t);
}
}
static void setHeapDumpPath(Path path) throws DiagnosticException {
try {
HOTSPOT_DIAGNOSTIC.setVMOption("HeapDumpPath", path.toString());
} catch (Throwable t) {
throw new DiagnosticException("Failed to set VMOption HeapDumpPath", t);
}
}
static boolean getHeapDumpOnOutOfMemoryError() throws DiagnosticException {
try {
return Boolean.parseBoolean(HOTSPOT_DIAGNOSTIC.getVMOption("HeapDumpOnOutOfMemoryError").getValue());
} catch (Throwable t) {
throw new DiagnosticException("Failed to get VMOption HeapDumpOnOutOfMemoryError", t);
}
}
static void setHeapDumpOnOutOfMemoryError(boolean value) throws DiagnosticException {
try {
HOTSPOT_DIAGNOSTIC.setVMOption("HeapDumpOnOutOfMemoryError", Boolean.toString(value));
} catch (Throwable t) {
throw new DiagnosticException("Failed to set VMOption HeapDumpOnOutOfMemoryError", t);
}
}
}
@@ -0,0 +1,27 @@
/*
* Copyright 2020 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.diagnostic;
/**
* Signals that a diagnostic feature is not available for the current JVM implementation
*/
public class UnavailableDiagnosticFeatureException extends DiagnosticException {
UnavailableDiagnosticFeatureException() {
super("This diagnostic feature is not available for the current JVM implementation " +
System.getProperty("java.vm.name"));
}
}
@@ -0,0 +1,42 @@
/*
* Copyright 2019 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.function;
import java.util.function.BiConsumer;
/**
* A functional interface equivalent to {@link BiConsumer}, whose method
* throws a checked exception.
*
* @param <T> the first type of the input to the operation.
* @param <U> the second type of the input to the operation.
* @param <E> the type of checked exception thrown.
* @author Frederic Thevenet
*/
@FunctionalInterface
public interface CheckedBiConsumer<T, U, E extends Exception> {
/**
* Performs this operation on the given argument.
*
* @param t the first input argument
* @param u the second input argument
* @throws E a checked exception
*/
void accept(T t, U u) throws E;
}
@@ -0,0 +1,42 @@
/*
* Copyright 2017-2018 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.function;
import java.util.function.BiFunction;
/**
* A functional interface equivalent to {@link BiFunction}, whose method
* throws a checked exception.
*
* @param <T> the type of the first input to the function.
* @param <U> the type of the second input to the function.
* @param <R> the type of the result of the function.
* @param <E> the type of checked exception thrown.
* @author Frederic Thevenet
*/
@FunctionalInterface
public interface CheckedBiFunction<T, U, R, E extends Exception> {
/**
* Applies this function to the given argument.
*
* @param t the first function argument
* @param u the second function argument.
* @return the function result
* @throws E a checked exception
*/
R apply(T t, U u) throws E;
}
@@ -0,0 +1,40 @@
/*
* Copyright 2017-2018 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.function;
import java.util.function.Consumer;
/**
* A functional interface equivalent to {@link Consumer}, whose method
* throws a checked exception.
*
* @param <T> the type of the input to the operation.
* @param <E> the type of checked exception thrown.
* @author Frederic Thevenet
*/
@FunctionalInterface
public interface CheckedConsumer<T, E extends Exception> {
/**
* Performs this operation on the given argument.
*
* @param t the input argument
* @throws E a checked exception
*/
void accept(T t) throws E;
}
@@ -0,0 +1,40 @@
/*
* Copyright 2017-2018 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.function;
import java.util.function.Function;
/**
* A functional interface equivalent to {@link Function}, whose method
* throws a checked exception.
*
* @param <T> the type of the input to the function.
* @param <R> the type of the result of the function.
* @param <E> the type of checked exception thrown.
* @author Frederic Thevenet
*/
@FunctionalInterface
public interface CheckedFunction<T, R, E extends Exception> {
/**
* Applies this function to the given argument.
*
* @param t the function argument
* @return the function result
* @throws E a checked exception
*/
R apply(T t) throws E;
}
@@ -0,0 +1,141 @@
/*
* Copyright 2017-2018 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.function;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Supplier;
/**
* A series of methods that wrap functional interfaces that throw checked
* exceptions inside their standard (non throwing) counterparts, so they can be
* used with streams or other classes expecting standards functional interfaces
* and have them acting transparent to the exception thrown within the lambda.
*
* @author Frederic Thevenet
*/
public final class CheckedLambdas {
/**
* Override constructor
*/
private CheckedLambdas() {
}
/**
* Wraps a {@link CheckedConsumer} inside a {@link Consumer} and rethrow the
* original exception.
*
* @param consumer the {@link Consumer} to wrap.
* @param <T> the type for the consumer's parameter.
* @param <E> the type for the checked exception.
* @return a {@link Consumer} instance.
* @throws E the checked exception thrown in the lambda.
*/
public static <T, E extends Exception> Consumer<T> wrap(CheckedConsumer<T, E> consumer) throws E {
return t -> {
try {
consumer.accept(t);
} catch (Exception exception) {
throw throwActualException(exception);
}
};
}
// public static <T, E extends Exception> Predicate<T> wrap(CheckedPredicate<T, E> predicate) throws E {
// return t -> {
// try {
// return predicate.test(t);
// } catch (Exception exception) {
// throw throwActualException(exception);
// }
// };
// }
/**
* Wraps a {@link CheckedRunnable} inside a {@link Runnable} and rethrow the
* original exception.
*
* @param runnable the {@link Runnable} to wrap.
* @param <E> the type for the checked exception.
* @return a {@link Runnable} instance.
* @throws E the checked exception thrown in the lambda.
*/
public static <E extends Exception> Runnable wrap(CheckedRunnable<E> runnable) throws E {
return () -> {
try {
runnable.run();
} catch (Exception exception) {
throw throwActualException(exception);
}
};
}
/**
* Wraps a {@link CheckedSupplier} inside a {@link Supplier} and rethrow the
* original exception.
*
* @param supplier the {@link Supplier} to wrap.
* @param <T> the type for the supplier's parameter.
* @param <E> the type for the checked exception.
* @return a {@link Supplier} instance.
* @throws E the checked exception thrown in the lambda.
*/
public static <T, E extends Exception> Supplier<T> wrap(CheckedSupplier<T, E> supplier) throws E {
return () -> {
try {
return supplier.get();
} catch (Exception exception) {
throw throwActualException(exception);
}
};
}
/**
* Wraps a {@link CheckedFunction} inside a {@link Function} and rethrow the
* original exception.
*
* @param function the {@link Function} to wrap.
* @param <T> the type for the function's parameter.
* @param <R> the return type of the function.
* @param <E> the type for the checked exception.
* @return a {@link Function} instance.
* @throws E the checked exception thrown in the lambda.
*/
public static <T, R, E extends Exception> Function<T, R> wrap(CheckedFunction<T, R, E> function) throws E {
return t -> {
try {
return function.apply(t);
} catch (Exception exception) {
throw throwActualException(exception);
}
};
}
/**
* Forge runtime exception
*
* @param exception exception
* @return Runtime exception
* @throws E Exception
*/
@SuppressWarnings("unchecked")
private static <E extends Exception> RuntimeException throwActualException(Exception exception) throws E {
throw (E) exception;
}
}
@@ -0,0 +1,39 @@
/*
* Copyright 2017-2018 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.function;
/**
* A functional interface equivalent to {@link java.util.function.Predicate}, whose method
* throws a checked exception.
*
* @param <T> the type of the input to the operation.
* @param <E> the type of checked exception thrown.
* @author Frederic Thevenet
*/
@FunctionalInterface
public interface CheckedPredicate<T, E extends Exception> {
/**
* Performs this operation on the given argument.
*
* @param t the input argument
* @return the result of the predicate resolution.
* @throws E a checked exception
*/
boolean test(T t) throws E;
}
@@ -0,0 +1,34 @@
/*
* Copyright 2017-2018 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.function;
/**
* A functional interface equivalent to {@link Runnable}, whose method
* throws a checked exception.
*
* @param <E> the type of checked exception thrown.
* @author Frederic Thevenet
*/
@FunctionalInterface
public interface CheckedRunnable<E extends Exception> {
/**
* Evaluates the lambda.
*
* @throws E a checked exception
*/
void run() throws E;
}
@@ -0,0 +1,39 @@
/*
* Copyright 2017-2018 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.function;
import java.util.function.Supplier;
/**
* A functional interface equivalent to {@link Supplier}, whose method
* throws a checked exception.
*
* @param <T> the type of results supplied by this supplier
* @param <E> the type of checked exception thrown.
* @author Frederic Thevenet
*/
@FunctionalInterface
public interface CheckedSupplier<T, E extends Exception> {
/**
* Gets a result.
*
* @return a result
* @throws E a checked exception
*/
T get() throws E;
}
@@ -0,0 +1,365 @@
/*
* Copyright 2017-2024 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.github;
import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
import eu.binjr.common.io.IOUtils;
import eu.binjr.common.io.ProxyConfiguration;
import eu.binjr.common.io.SSLContextUtils;
import eu.binjr.common.io.SSLCustomContextException;
import eu.binjr.common.logging.Logger;
import eu.binjr.core.preferences.UserPreferences;
import javafx.beans.property.DoubleProperty;
import java.io.Closeable;
import java.io.IOException;
import java.lang.reflect.Type;
import java.net.*;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.*;
/**
* A series of helper methods to wrap some GitHub APIs
* See: https://developer.github.com/v3/
*
* @author Frederic Thevenet
*/
public class GitHubApiHelper implements Closeable {
private static final Logger logger = Logger.create(GitHubApiHelper.class);
public static final String HTTPS_API_GITHUB_COM = "https://api.github.com";
protected final HttpClient httpClient;
private final URI apiEndpoint;
protected String userCredentials;
private static final Gson GSON = new Gson();
private final static Type ghReleaseArrayType = new TypeToken<ArrayList<GithubRelease>>() {
}.getType();
private final static Type ghAssetArrayType = new TypeToken<ArrayList<GithubAsset>>() {
}.getType();
private GitHubApiHelper() {
this(null);
}
private GitHubApiHelper(URI apiEndpoint) {
this(apiEndpoint, null, null, null, false);
}
private GitHubApiHelper(URI apiEndpoint, ProxyConfiguration proxyConfig, String userName, String token, boolean useJvmCacerts) {
this.apiEndpoint = Objects.requireNonNullElseGet(apiEndpoint, () -> URI.create(HTTPS_API_GITHUB_COM));
HttpClient.Builder builder = null;
builder = HttpClient.newBuilder()
.followRedirects(HttpClient.Redirect.NORMAL)
.cookieHandler(new CookieManager(null, CookiePolicy.ACCEPT_ORIGINAL_SERVER));
if (!useJvmCacerts) {
try {
builder.sslContext(SSLContextUtils.withPlatformKeystore());
} catch (SSLCustomContextException e) {
logger.error("Error creating SSL context for GitHub helper:" + e.getMessage());
logger.debug("Stacktrace", e);
}
}
if (proxyConfig != null && proxyConfig.enabled()) {
try {
var proxySelector = ProxySelector.of(InetSocketAddress.createUnresolved(proxyConfig.host(), proxyConfig.port()));
if (proxyConfig.useAuth()) {
builder.authenticator(new Authenticator() {
@Override
protected PasswordAuthentication getPasswordAuthentication() {
if (getRequestorType().equals(RequestorType.PROXY)) {
return new PasswordAuthentication(proxyConfig.login(), proxyConfig.pwd());
} else {
return null;
}
}
});
}
builder.proxy(proxySelector);
} catch (Exception e) {
logger.error("Failed to setup http proxy: " + e.getMessage());
logger.debug(() -> "Stack", e);
}
}
setUserCredentials(userName, token);
httpClient = builder.build();
}
/**
* Initializes a new instance of the {@link GitHubApiHelper} class.
*
* @param apiEndpoint the URI that specifies the API endpoint.
* @return a new instance of the {@link GitHubApiHelper} class.
*/
public static GitHubApiHelper of(URI apiEndpoint) {
return new GitHubApiHelper(apiEndpoint);
}
/**
* Initializes a new instance of the {@link GitHubApiHelper} class.
*
* @param apiEndpoint the URI that specifies the API endpoint.
* @param proxyConfiguration Configuration for http proxy
* @return a new instance of the {@link GitHubApiHelper} class.
*/
public static GitHubApiHelper of(URI apiEndpoint,
ProxyConfiguration proxyConfiguration,
String userName,
String token,
boolean useJvmCacerts) {
return new GitHubApiHelper(apiEndpoint, proxyConfiguration, userName, token, useJvmCacerts);
}
/**
* Initializes a new instance of the {@link GitHubApiHelper} class.
*
* @param apiEndpoint the URI that specifies the API endpoint.
* @param proxyConfiguration Configuration for http proxy
* @return a new instance of the {@link GitHubApiHelper} class.
*/
public static GitHubApiHelper of(URI apiEndpoint,
ProxyConfiguration proxyConfiguration,
String userName,
String token) {
return new GitHubApiHelper(apiEndpoint, proxyConfiguration, userName, token, false);
}
/**
* Initializes a new instance of the {@link GitHubApiHelper} class.
*
* @return a new instance of the {@link GitHubApiHelper} class.
*/
public static GitHubApiHelper createDefault() {
return new GitHubApiHelper();
}
/**
* Returns the latest release from the specified repository.
*
* @param owner the repository's owner
* @param repo the repository's name
* @return An {@link Optional} that contains the latest release if it could be found.
* @throws IOException if an IO error occurs while communicating with GiHub
* @throws URISyntaxException if the crafted URI is incorrect.
*/
public Optional<GithubRelease> getLatestRelease(String owner, String repo) throws IOException, URISyntaxException, InterruptedException {
return getRelease(owner, repo, "latest");
}
/**
* Returns the latest release from the specified repository.
*
* @param slug the repository's slug (owner/name)
* @return An {@link Optional} that contains the latest release if it could be found.
* @throws IOException if an IO error occurs while communicating with GiHub
* @throws URISyntaxException if the crafted URI is incorrect.
*/
public Optional<GithubRelease> getLatestRelease(String slug) throws IOException, URISyntaxException, InterruptedException {
return getRelease(slug, "latest");
}
/**
* Returns a specific release from the specified repository.
*
* @param owner the repository's owner
* @param repo the repository's name
* @param id the id of the release to retrieve
* @return An {@link Optional} that contains the specified release if it could be found.
* @throws IOException if an IO error occurs while communicating with GitHub.
* @throws URISyntaxException if the crafted URI is incorrect.
*/
public Optional<GithubRelease> getRelease(String owner, String repo, String id) throws IOException, URISyntaxException, InterruptedException {
return getRelease(owner + "/" + repo, id);
}
/**
* Returns a specific release from the specified repository.
*
* @param slug the repository's slug (owner/name)
* @param id the id of the release to retrieve
* @return An {@link Optional} that contains the specified release if it could be found.
* @throws IOException if an IO error occurs while communicating with GitHub.
* @throws URISyntaxException if the crafted URI is incorrect.
*/
public Optional<GithubRelease> getRelease(String slug, String id) throws IOException, URISyntaxException, InterruptedException {
var requestUrl = URI.create(apiEndpoint + "/repos/" + slug + "/releases/" + id);
logger.debug(() -> "requestUrl = " + requestUrl);
var httpget = basicAuthGet(requestUrl);
var response = httpClient.send(httpget, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new IOException("Failed to get release from " +
requestUrl +
"(HTTP status: " + response.statusCode() + ")");
}
return Optional.ofNullable(GSON.fromJson(response.body(), GithubRelease.class));
}
/**
* Returns a list of all release from the specified repository.
*
* @param owner the repository's owner
* @param repo the repository's name
* @return a list of all release from the specified repository.
* @throws IOException if an IO error occurs while communicating with GitHub.
* @throws URISyntaxException if the crafted URI is incorrect.
*/
public List<GithubRelease> getAllReleases(String owner, String repo) throws IOException, URISyntaxException, InterruptedException {
return getAllReleases(owner + "/" + repo);
}
/**
* Returns a list of all release from the specified repository.
*
* @param slug the repository's slug (owner/name)
* @return a list of all release from the specified repository.
* @throws IOException if an IO error occurs while communicating with GitHub.
* @throws URISyntaxException if the crafted URI is incorrect.
*/
public List<GithubRelease> getAllReleases(String slug) throws IOException, URISyntaxException, InterruptedException {
var requestUrl = URI.create(apiEndpoint +
"/repos/" + slug + "/releases?per_page=100");
logger.debug(() -> "requestUrl = " + requestUrl);
var httpget = basicAuthGet(requestUrl);
var response = httpClient.send(httpget, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new IOException("Failed to get release list from " +
requestUrl +
"(HTTP status: " + response.statusCode() + ")");
}
return GSON.fromJson(response.body(), ghReleaseArrayType);
}
/**
* Returns a list of all {@link GithubAsset} instances associated to a a {@link GithubRelease} instance.
*
* @param release The {@link GithubRelease} instance to get the assets from.
* @return a list of all {@link GithubAsset} instances associated to a a {@link GithubRelease} instance.
* @throws URISyntaxException if the crafted URI is incorrect.
* @throws IOException if an IO error occurs while communicating with GitHub.
*/
public List<GithubAsset> getAssets(GithubRelease release) throws URISyntaxException, IOException, InterruptedException {
logger.debug(() -> "requestUrl = " + release.getAssetsUrl());
var httpget = basicAuthGet(release.getAssetsUrl().toURI());
var response = httpClient.send(httpget, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new IOException("Failed to download asset list from " +
release.getAssetsUrl() +
"(HTTP status: " + response.statusCode() + ")");
}
return GSON.fromJson(response.body(), ghAssetArrayType);
}
/**
* Download the byteArrayTuple of the specified github asset to a temporary location.
*
* @param asset the asset to download.
* @return the {@link Path} where it was downloaded.
* @throws IOException if an IO error occurs while attempting to download the file.
* @throws URISyntaxException if the crafted URI is incorrect.
*/
public Path downloadAsset(GithubAsset asset) throws IOException, URISyntaxException, InterruptedException {
return downloadAsset(asset, Files.createTempDirectory(UserPreferences.getInstance().temporaryFilesRoot.get(), "binjr-updates_"));
}
/**
* Download the byteArrayTuple of the specified github asset into the specified directory.
*
* @param asset the asset to download.
* @param targetDir the target directory to download the asset to.
* @return the {@link Path} where it was downloaded.
* @throws IOException if an IO error occurs while attempting to download the file.
* @throws URISyntaxException if the crafted URI is incorrect.
*/
public Path downloadAsset(GithubAsset asset, Path targetDir) throws IOException, URISyntaxException, InterruptedException {
return downloadAsset(asset, targetDir, null);
}
/**
* Download the byteArrayTuple of the specified github asset into the specified directory.
*
* @param asset the asset to download.
* @param targetDir the target directory to download the asset to.
* @param progress A {@link DoubleProperty} used to report download progression.
* @return the {@link Path} where it was downloaded.
* @throws IOException if an IO error occurs while attempting to download the file.
* @throws URISyntaxException if the crafted URI is incorrect.
*/
public Path downloadAsset(GithubAsset asset, Path targetDir, DoubleProperty progress) throws IOException, URISyntaxException, InterruptedException {
if (!Files.isDirectory(targetDir)) {
throw new NotDirectoryException(targetDir.toString());
}
var assetSize = asset.getSize();
var get = HttpRequest.newBuilder()
.uri(asset.getBrowserDownloadUrl().toURI())
.build();
Path target = targetDir.resolve(asset.getName());
var response = httpClient.send(get, HttpResponse.BodyHandlers.ofInputStream());
if (response.statusCode() != 200) {
throw new IOException("Failed to download asset from " +
asset.getBrowserDownloadUrl() +
"(HTTP status: " + response.statusCode() + ")");
}
try (var in = response.body()) {
if (progress == null) {
Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING);
} else {
try (var out = Files.newOutputStream(target, StandardOpenOption.CREATE_NEW)) {
IOUtils.copyStreams(in, out, assetSize, progress);
}
}
}
return target;
}
/**
* Set the user credentials for api authentication.
*
* @param username the user name
* @param token a github personal access token.
*/
public void setUserCredentials(String username, String token) {
if (username != null && token != null && !username.trim().isEmpty()) {
this.userCredentials = Base64.getEncoder().encodeToString((username + ":" + token).getBytes(StandardCharsets.UTF_8));
} else {
this.userCredentials = null;
}
}
protected HttpRequest basicAuthGet(URI requestUri) {
var requestBuilder = HttpRequest.newBuilder()
.GET()
.uri(requestUri);
if (userCredentials != null) {
requestBuilder.header("Authorization", "Basic " + userCredentials);
}
return requestBuilder.build();
}
@Override
public void close() throws IOException {
httpClient.close();
}
}
@@ -0,0 +1,158 @@
/*
* Copyright 2019 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.github;
import com.google.gson.annotations.SerializedName;
import java.net.URL;
import java.util.Date;
/**
* A data class that represents a GitHub release asset.
*/
public class GithubAsset {
private URL url;
private int id;
@SerializedName("node_id")
private String nodeId;
private String name;
private String label;
private GithubUser uploader;
@SerializedName("content_type")
private String contentType;
private String state;
private int size;
@SerializedName("download_count")
private int downloadCount;
@SerializedName("created_at")
private Date createdAt;
@SerializedName("updated_at")
private Date updatedAt;
@SerializedName("browser_download_url")
private URL browserDownloadUrl;
public URL getUrl() {
return url;
}
public void setUrl(URL url) {
this.url = url;
}
public int getId() {
return id;
}
public void setId(int id) {
this.id = id;
}
public String getNodeId() {
return nodeId;
}
public void setNodeId(String nodeId) {
this.nodeId = nodeId;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getLabel() {
return label;
}
public void setLabel(String label) {
this.label = label;
}
public GithubUser getUploader() {
return uploader;
}
public void setUploader(GithubUser uploader) {
this.uploader = uploader;
}
public String getContentType() {
return contentType;
}
public void setContentType(String contentType) {
this.contentType = contentType;
}
public String getState() {
return state;
}
public void setState(String state) {
this.state = state;
}
public int getSize() {
return size;
}
public void setSize(int size) {
this.size = size;
}
public int getDownloadCount() {
return downloadCount;
}
public void setDownloadCount(int downloadCount) {
this.downloadCount = downloadCount;
}
public Date getCreatedAt() {
return createdAt;
}
public void setCreatedAt(Date createdAt) {
this.createdAt = createdAt;
}
public Date getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(Date updatedAt) {
this.updatedAt = updatedAt;
}
public URL getBrowserDownloadUrl() {
return browserDownloadUrl;
}
public void setBrowserDownloadUrl(URL browserDownloadUrl) {
this.browserDownloadUrl = browserDownloadUrl;
}
@Override
public String toString() {
return "GithubAsset{" +
"name='" + name + '\'' +
'}';
}
}
@@ -0,0 +1,435 @@
/*
* Copyright 2017-2020 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.github;
import com.google.gson.annotations.SerializedName;
import eu.binjr.common.logging.Logger;
import eu.binjr.common.version.Version;
import java.net.URL;
import java.util.Date;
import java.util.List;
/**
* GitHub release POJO
*
* @author Frederic Thevenet
*/
public class GithubRelease {
private static final Logger logger = Logger.create(GithubRelease.class);
private URL url;
@SerializedName("html_url")
private URL htmlUrl;
@SerializedName("assets_url")
private URL assetsUrl;
@SerializedName("upload_url")
private URL uploadUrl;
@SerializedName("tarball_url")
private URL tarballUrl;
@SerializedName("zipball_url")
private URL zipballUrl;
private long id;
@SerializedName("tag_name")
private String tagName;
@SerializedName("target_commitish")
private String targetCommitish;
private String name;
private String body;
@SerializedName("draft")
private boolean isDraft;
@SerializedName("prerelease")
private boolean isPrerelease;
@SerializedName("created_at")
private Date createdAt;
@SerializedName("published_at")
private Date publishedAt;
private GithubUser author;
@SerializedName("assets")
private List<GithubAsset> assets;
/**
* Returns the url associated to the release.
*
* @return the url associated to the release.
*/
public URL getUrl() {
return url;
}
/**
* Sets the url associated to the release.
*
* @param url the url associated to the release.
* @return this release
*/
public GithubRelease setUrl(URL url) {
this.url = url;
return this;
}
/**
* Returns the url associated to the html page.
*
* @return htmlUrl the url associated to the html page.
*/
public URL getHtmlUrl() {
return htmlUrl;
}
/**
* Sets the url associated to the html page.
*
* @param htmlUrl the url associated to the html page.
* @return this release
*/
public GithubRelease setHtmlUrl(URL htmlUrl) {
this.htmlUrl = htmlUrl;
return this;
}
/**
* Returns the assets url
*
* @return the assets url
*/
public URL getAssetsUrl() {
return assetsUrl;
}
/**
* Sets the assets url
*
* @param assetsUrl the assets url
* @return this release
*/
public GithubRelease setAssetsUrl(URL assetsUrl) {
this.assetsUrl = assetsUrl;
return this;
}
/**
* Returns the upload url
*
* @return the upload url
*/
public URL getUploadUrl() {
return uploadUrl;
}
/**
* Sets the upload url
*
* @param uploadUrl the upload url
* @return this release
*/
public GithubRelease setUploadUrl(URL uploadUrl) {
this.uploadUrl = uploadUrl;
return this;
}
/**
* Retuns the tarball url
*
* @return the tarball url
*/
public URL getTarballUrl() {
return tarballUrl;
}
/**
* Sets the tarball url
*
* @param tarballUrl the tarball url
* @return this release
*/
public GithubRelease setTarballUrl(URL tarballUrl) {
this.tarballUrl = tarballUrl;
return this;
}
/**
* Returns the zip url.
*
* @return zipballUrl the zip url.
*/
public URL getZipballUrl() {
return zipballUrl;
}
/**
* Sets the zip url.
*
* @param zipballUrl the zip url.
* @return this release
*/
public GithubRelease setZipballUrl(URL zipballUrl) {
this.zipballUrl = zipballUrl;
return this;
}
/**
* Returns the id of the release
*
* @return id the id of the release
*/
public long getId() {
return id;
}
/**
* Sets the id of the release
*
* @param id the id of the release
* @return this release
*/
public GithubRelease setId(long id) {
this.id = id;
return this;
}
/**
* Returns the tag name.
*
* @return tagName the tag name.
*/
public String getTagName() {
return tagName;
}
/**
* Sets the tag name.
*
* @param tagName the tag name.
* @return this release
*/
public GithubRelease setTagName(String tagName) {
this.tagName = tagName;
return this;
}
/**
* Returns the target commitish
*
* @return targetCommitish the target commitish
*/
public String getTargetCommitish() {
return targetCommitish;
}
/**
* Sets the target commitish
*
* @param targetCommitish the target commitish
* @return this release
*/
public GithubRelease setTargetCommitish(String targetCommitish) {
this.targetCommitish = targetCommitish;
return this;
}
/**
* Returns the name of the release.
*
* @return name the name of the release.
*/
public String getName() {
return name;
}
/**
* Sets the name of the release.
*
* @param name the name of the release.
* @return this release
*/
public GithubRelease setName(String name) {
this.name = name;
return this;
}
/**
* Returns the body of the release.
*
* @return body the body of the release.
*/
public String getBody() {
return body;
}
/**
* Sets the body of the release.
*
* @param body the body of the release.
* @return this release
*/
public GithubRelease setBody(String body) {
this.body = body;
return this;
}
/**
* Returns true is the release is a draft, false otherwise.
*
* @return isDraft true is the release is a draft, false otherwise.
*/
public boolean isDraft() {
return isDraft;
}
/**
* Sets to true is the release is a draft, false otherwise.
*
* @param isDraft true is the release is a draft, false otherwise.
* @return this release
*/
public GithubRelease setDraft(boolean isDraft) {
this.isDraft = isDraft;
return this;
}
/**
* Returns true if the release is a pre-release, false otherwise.
*
* @return isPrerelease true is the release is a pre-release, false otherwise.
*/
public boolean isPrerelease() {
return isPrerelease;
}
/**
* Sets to true if the release is a pre-release, false otherwise.
*
* @param isPrerelease true if the release is a pre-release, false otherwise.
* @return this release
*/
public GithubRelease setPrerelease(boolean isPrerelease) {
this.isPrerelease = isPrerelease;
return this;
}
/**
* Returns the creation date.
*
* @return createdAt the creation date.
*/
public Date getCreatedAt() {
return createdAt;
}
/**
* Sets the creation date.
*
* @param createdAt the creation date.
* @return this release
*/
public GithubRelease setCreatedAt(Date createdAt) {
this.createdAt = createdAt;
return this;
}
/**
* Returns the publication date.
*
* @return publishedAt the publication date.
*/
public Date getPublishedAt() {
return publishedAt;
}
/**
* Sets the publication date.
*
* @param publishedAt the publication date.
* @return this release
*/
public GithubRelease setPublishedAt(Date publishedAt) {
this.publishedAt = publishedAt;
return this;
}
/**
* Returns the author of the release.
*
* @return author the author of the release.
*/
public GithubUser getAuthor() {
return author;
}
/**
* Sets the author of the release.
*
* @param author the author of the release.
* @return this release
*/
public GithubRelease setAuthor(GithubUser author) {
this.author = author;
return this;
}
/**
* Returns the version of the release.
*
* @return the version of the release.
*/
public Version getVersion() {
if (tagName == null) {
return Version.emptyVersion;
}
try {
return new Version(tagName.replaceAll("^v", ""));
} catch (IllegalArgumentException e) {
logger.error("Could not decode version number from tag: " + tagName, e);
return Version.emptyVersion;
}
}
public List<GithubAsset> getAssets() {
return assets;
}
public void setAssets(List<GithubAsset> assets) {
this.assets = assets;
}
@Override
public String toString() {
final StringBuilder sb = new StringBuilder("GithubRelease{");
sb.append("url='").append(url).append('\'');
sb.append(", htmlUrl='").append(htmlUrl).append('\'');
sb.append(", assetsUrl='").append(assetsUrl).append('\'');
sb.append(", uploadUrl='").append(uploadUrl).append('\'');
sb.append(", tarballUrl='").append(tarballUrl).append('\'');
sb.append(", zipballUrl='").append(zipballUrl).append('\'');
sb.append(", id=").append(id);
sb.append(", tagName='").append(tagName).append('\'');
sb.append(", targetCommitish='").append(targetCommitish).append('\'');
sb.append(", name='").append(name).append('\'');
sb.append(", body='").append(body).append('\'');
sb.append(", isDraft=").append(isDraft);
sb.append(", isPrerelease=").append(isPrerelease);
sb.append(", createdAt=").append(createdAt);
sb.append(", publishedAt=").append(publishedAt);
sb.append(", author=").append(author);
sb.append('}');
return sb.toString();
}
}
@@ -0,0 +1,313 @@
/*
* Copyright 2017-2018 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.github;
import com.google.gson.annotations.SerializedName;
import java.util.Date;
/**
* GitHub user POJO
*
* @author Frederic Thevenet
*/
public class GithubUser {
private boolean hireable;
@SerializedName("created_at")
private Date createdAt;
private int collaborators;
@SerializedName("disk_usage")
private int diskUsage;
private int followers;
private int following;
private int id;
@SerializedName("owned_private_repos")
private int ownedPrivateRepos;
@SerializedName("private_gists")
private int privateGists;
@SerializedName("public_gists")
private int publicGists;
@SerializedName("public_repos")
private int publicRepos;
@SerializedName("total_private_repos")
private int totalPrivateRepos;
@SerializedName("avatar_url")
private String avatarUrl;
private String bio;
private String blog;
private String company;
private String email;
@SerializedName("gravatar_id")
private String gravatarId;
@SerializedName("html_url")
private String htmlUrl;
private String location;
private String login;
private String name;
private String type;
private String url;
public boolean isHireable() {
return hireable;
}
public GithubUser setHireable(boolean hireable) {
this.hireable = hireable;
return this;
}
public Date getCreatedAt() {
return new Date(createdAt.getTime());
}
public GithubUser setCreatedAt(Date createdAt) {
this.createdAt = new Date(createdAt.getTime());
return this;
}
public int getCollaborators() {
return collaborators;
}
public GithubUser setCollaborators(int collaborators) {
this.collaborators = collaborators;
return this;
}
public int getDiskUsage() {
return diskUsage;
}
public GithubUser setDiskUsage(int diskUsage) {
this.diskUsage = diskUsage;
return this;
}
public int getFollowers() {
return followers;
}
public GithubUser setFollowers(int followers) {
this.followers = followers;
return this;
}
public int getFollowing() {
return following;
}
public GithubUser setFollowing(int following) {
this.following = following;
return this;
}
public int getId() {
return id;
}
public GithubUser setId(int id) {
this.id = id;
return this;
}
public int getOwnedPrivateRepos() {
return ownedPrivateRepos;
}
public GithubUser setOwnedPrivateRepos(int ownedPrivateRepos) {
this.ownedPrivateRepos = ownedPrivateRepos;
return this;
}
public int getPrivateGists() {
return privateGists;
}
public GithubUser setPrivateGists(int privateGists) {
this.privateGists = privateGists;
return this;
}
public int getPublicGists() {
return publicGists;
}
public GithubUser setPublicGists(int publicGists) {
this.publicGists = publicGists;
return this;
}
public int getPublicRepos() {
return publicRepos;
}
public GithubUser setPublicRepos(int publicRepos) {
this.publicRepos = publicRepos;
return this;
}
public int getTotalPrivateRepos() {
return totalPrivateRepos;
}
public GithubUser setTotalPrivateRepos(int totalPrivateRepos) {
this.totalPrivateRepos = totalPrivateRepos;
return this;
}
public String getAvatarUrl() {
return avatarUrl;
}
public GithubUser setAvatarUrl(String avatarUrl) {
this.avatarUrl = avatarUrl;
return this;
}
public String getBio() {
return bio;
}
public GithubUser setBio(String bio) {
this.bio = bio;
return this;
}
public String getBlog() {
return blog;
}
public GithubUser setBlog(String blog) {
this.blog = blog;
return this;
}
public String getCompany() {
return company;
}
public GithubUser setCompany(String company) {
this.company = company;
return this;
}
public String getEmail() {
return email;
}
public GithubUser setEmail(String email) {
this.email = email;
return this;
}
@Deprecated
public String getGravatarId() {
return gravatarId;
}
@Deprecated
public GithubUser setGravatarId(String gravatarId) {
this.gravatarId = gravatarId;
return this;
}
public String getHtmlUrl() {
return htmlUrl;
}
public GithubUser setHtmlUrl(String htmlUrl) {
this.htmlUrl = htmlUrl;
return this;
}
public String getLocation() {
return location;
}
public GithubUser setLocation(String location) {
this.location = location;
return this;
}
public String getLogin() {
return login;
}
public GithubUser setLogin(String login) {
this.login = login;
return this;
}
public String getName() {
return name;
}
public GithubUser setName(String name) {
this.name = name;
return this;
}
public String getType() {
return type;
}
public GithubUser setType(String type) {
this.type = type;
return this;
}
public String getUrl() {
return url;
}
public GithubUser setUrl(String url) {
this.url = url;
return this;
}
@Override
public String toString() {
final StringBuilder sb = new StringBuilder("GithubUser{");
sb.append("hireable=").append(hireable);
sb.append(", createdAt=").append(createdAt);
sb.append(", collaborators=").append(collaborators);
sb.append(", diskUsage=").append(diskUsage);
sb.append(", followers=").append(followers);
sb.append(", following=").append(following);
sb.append(", id=").append(id);
sb.append(", ownedPrivateRepos=").append(ownedPrivateRepos);
sb.append(", privateGists=").append(privateGists);
sb.append(", publicGists=").append(publicGists);
sb.append(", publicRepos=").append(publicRepos);
sb.append(", totalPrivateRepos=").append(totalPrivateRepos);
sb.append(", avatarUrl='").append(avatarUrl).append('\'');
sb.append(", bio='").append(bio).append('\'');
sb.append(", blog='").append(blog).append('\'');
sb.append(", company='").append(company).append('\'');
sb.append(", email='").append(email).append('\'');
sb.append(", gravatarId='").append(gravatarId).append('\'');
sb.append(", htmlUrl='").append(htmlUrl).append('\'');
sb.append(", location='").append(location).append('\'');
sb.append(", login='").append(login).append('\'');
sb.append(", name='").append(name).append('\'');
sb.append(", type='").append(type).append('\'');
sb.append(", url='").append(url).append('\'');
sb.append('}');
return sb.toString();
}
}
@@ -0,0 +1,109 @@
/*
* Copyright 2022-2023 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.io;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import javax.crypto.*;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.PBEKeySpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.*;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.KeySpec;
import java.util.Base64;
public class AesHelper {
private static final String AES = "AES";
public static final String AES_GCM_NO_PADDING = "AES/GCM/NoPadding";
private static final String PBKDF_2_WITH_HMAC_SHA_256 = "PBKDF2WithHmacSHA256";
private static final int IV_LEN = 16;
private static final Logger logger = LogManager.getLogger(AesHelper.class);
public static String encrypt(String input, SecretKey key)
throws GeneralSecurityException {
var params = generateIv();
Cipher cipher = Cipher.getInstance(AES_GCM_NO_PADDING);
cipher.init(Cipher.ENCRYPT_MODE, key, params);
byte[] cipherText = cipher.doFinal(input.getBytes());
return Base64.getEncoder().encodeToString(IOUtils.concat(params.getIV(), cipherText));
}
public static String decrypt(String input, SecretKey key) throws GeneralSecurityException {
var b = IOUtils.split(Base64.getDecoder().decode(input), IV_LEN);
var params = new GCMParameterSpec(IV_LEN * 8, b.first());
Cipher cipher = Cipher.getInstance(AES_GCM_NO_PADDING);
cipher.init(Cipher.DECRYPT_MODE, key, params);
byte[] plainText = cipher.doFinal(b.second());
return new String(plainText);
}
public static boolean validateKey(SecretKey key) {
try {
var params = generateIv();
Cipher cipher = Cipher.getInstance(AES_GCM_NO_PADDING);
cipher.init(Cipher.DECRYPT_MODE, key, params);
} catch (NoSuchPaddingException | NoSuchAlgorithmException | InvalidAlgorithmParameterException |
InvalidKeyException e) {
logger.error("Cannot initialize Cipher with provided key:" + e.getMessage());
logger.debug("Stack trace", e);
return false;
}
return true;
}
public static SecretKey getKeyFromEncoded(String encoded) {
byte[] buffer = Base64.getDecoder().decode(encoded);
return new SecretKeySpec(buffer, AES);
}
public static SecretKey generateKey(int n) throws NoSuchAlgorithmException {
KeyGenerator keyGenerator = KeyGenerator.getInstance(AES);
keyGenerator.init(n);
return keyGenerator.generateKey();
}
public static String generateEncodedKey(int n) throws NoSuchAlgorithmException {
return encodeSecretKey(generateKey(n));
}
public static String encodeSecretKey(SecretKey secretKey) {
return Base64.getEncoder().encodeToString(secretKey.getEncoded());
}
public static SecretKey deriveKeyFromPassword(String password, String salt)
throws NoSuchAlgorithmException, InvalidKeySpecException {
return deriveKeyFromPassword(password.toCharArray(), salt.getBytes(StandardCharsets.UTF_8));
}
public static SecretKey deriveKeyFromPassword(char[] password, byte[] salt)
throws NoSuchAlgorithmException, InvalidKeySpecException {
SecretKeyFactory factory = SecretKeyFactory.getInstance(PBKDF_2_WITH_HMAC_SHA_256);
KeySpec spec = new PBEKeySpec(password, salt, 65536, 256);
return new SecretKeySpec(factory.generateSecret(spec).getEncoded(), AES);
}
public static GCMParameterSpec generateIv() {
byte[] iv = new byte[IV_LEN];
new SecureRandom().nextBytes(iv);
return new GCMParameterSpec(IV_LEN * 8, iv);
}
}
@@ -0,0 +1,349 @@
/*
* Copyright 2020-2023 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.io;
import eu.binjr.common.function.CheckedLambdas;
import eu.binjr.common.logging.Logger;
import java.io.Closeable;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.*;
import java.util.function.Predicate;
/**
* Defines methods to help list entries in a file system, and obtain input streams on files it contains.
* Roots for a {@link FileSystemBrowser} can be either folders in the default file system or zipfs compatible files
* (typically .zip and .jar files).
*/
public class FileSystemBrowser implements Closeable {
private static final Logger logger = Logger.create(FileSystemBrowser.class);
private final Path fsRoot;
private final FileSystem fs;
/**
* Initialize a new browser from a single root
*
* @param fsRoot the root path for the browser
* @throws IOException if an error occurs while initializing the browser.
*/
private FileSystemBrowser(Path fsRoot) throws IOException {
this(Objects.requireNonNull(fsRoot).getFileSystem(), fsRoot);
}
/**
* Initialize a new browser from a list of roots and a {@link FileSystem}
* NB: All roots must belong to the same FileSystem
*
* @param fs the FileSystem for the browser
* @param fsRoot a list of root directories to browse
* @throws IOException if an error occurs while initializing the browser.
*/
private FileSystemBrowser(FileSystem fs, Path fsRoot) throws IOException {
Objects.requireNonNull(fsRoot);
this.fs = fs;
this.fsRoot = fsRoot;
}
/**
* Return a new {@link FileSystemBrowser} instance for the provided path.
* Valid path can be either a folder of the default file system or a zip.jar file.
*
* @param path the path to browse.
* @return a new {@link FileSystemBrowser} instance for the provided path
* @throws IOException if an error occurs while initializing the browser.
*/
public static FileSystemBrowser of(Path path) throws IOException {
if (Files.isDirectory(path)) {
return new FileSystemBrowser(path);
}
if (path.toString().toLowerCase(Locale.ROOT).endsWith(".zip") ||
(path.toString().toLowerCase(Locale.ROOT).endsWith(".jar"))) {
var fs = FileSystems.newFileSystem(path, (ClassLoader) null);
for (var root : fs.getRootDirectories()) {
// we only care about the first root in a zip file
return new FileSystemBrowser(fs, root);
}
} else {
// Return an instance that only lists the single file that was passed as root.
return new FileSystemBrowser(path.getParent()){
@Override
public Collection<FileSystemEntry> listEntries(Predicate<Path> filter) throws IOException {
return List.of(new FileSystemEntry(Files.isDirectory(path), path.getFileName(), Files.size(path)));
}
};
}
throw new UnsupportedOperationException("Unsupported operation: path is not a folder nor a zip file");
}
/**
* Return a new {@link FileSystemBrowser} instance for the provided URI and a root path inside the target file system
* Valid URI can point to a zip or a jar file.
*
* @param uri the URI to browse the fs to browse
* @param first the path string or initial part of the path string to browse
* @param more additional strings to be joined to form the path string to browse
* @return a new {@link FileSystemBrowser} instance
* @throws IOException if an error occurs while initializing the browser.
*/
public static FileSystemBrowser of(URI uri, String first, String... more) throws IOException {
return new FileSystemBrowser(FileSystems.newFileSystem(uri, Collections.emptyMap()).getPath(first, more));
}
/**
* Returns an {@link InputStream} for the file system entry identified by the provided path.
* <p>
* <b>NOTE:</b> It is the caller's responsibility to close the returned stream when no longer needed.
* </p>
*
* @param path the path of the file system entry to get a stream for.
* @return an {@link InputStream} for the file system entry identified by the provided path.
* @throws IOException If no entry could be identified in the underlying file system for the provided path.
*/
public InputStream getData(String path) throws IOException {
return Files.newInputStream(getRootDirectory().resolve(path), StandardOpenOption.READ);
}
/**
* Returns a collection of {@link InputStream} for all file system entries matching the provided path predicate.
* <p>
* <b>NOTE:</b> It is the caller's responsibility to close the returned stream when no longer needed.
* </p>
*
* @param filter a predicate that filters the file system entries to return a stream for.
* @return a collection of {@link InputStream} for all file system entries matching the provided path predicate.
* @throws IOException If no entry could be identified in the underlying file system for the provided path.
*/
public Collection<InputStream> getData(Predicate<Path> filter) throws IOException {
var streams = new ArrayList<InputStream>();
Files.walkFileTree(fsRoot, EnumSet.noneOf(FileVisitOption.class), Integer.MAX_VALUE, new SimpleFileVisitor<>() {
@Override
public FileVisitResult visitFile(Path path, BasicFileAttributes attrs) throws IOException {
var p = fsRoot.relativize(path);
if (filter.test(p)) {
streams.add(Files.newInputStream(fsRoot.resolve(p), StandardOpenOption.READ));
}
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult visitFileFailed(Path file, IOException exc) throws IOException {
logger.warn(() -> "An error occurred while accessing file " + file + ": " + exc.getMessage());
logger.debug(() -> "Full stack", exc);
return FileVisitResult.CONTINUE;
}
});
return streams;
}
/**
* Returns a collection of {@link FileSystemEntry} for all paths specified in the target file system
*
* @param paths list of paths to return a {@link FileSystemEntry} for.
* @return a collection of {@link FileSystemEntry}.
* @throws IOException If an error occurs while listing file system entries.
*/
public Collection<FileSystemEntry> listEntries(Collection<Path> paths) throws IOException {
return paths.stream()
.map(fsRoot::resolve)
.filter(Files::exists)
.map(CheckedLambdas.wrap(p -> {
return new FileSystemEntry(Files.isDirectory(p), fsRoot.relativize(p), Files.size(fsRoot.resolve(p)));
}))
.sorted(FileSystemEntry::compareTo)
.toList();
}
/**
* * Returns a {@link FileSystemEntry} instance for the path specified in the target file system
*
* @param path path to return a {@link FileSystemEntry} for.
* @return a {@link FileSystemEntry} instance for the path specified
* @throws IOException If an error occurs while listing file system entries.
*/
public FileSystemEntry getEntry(String path) throws IOException {
var p = getRootDirectory().resolve(path);
return new FileSystemEntry(Files.isDirectory(p), p, Files.size(p));
}
/**
* Returns a collection of {@link FileSystemEntry} for all file system entries matching the provided path predicate.
*
* @param filter a predicate that filters the file system entries to return a {@link FileSystemEntry} for.
* @return a collection of {@link FileSystemEntry} for all file system entries matching the provided path predicate.
* @throws IOException If an error occurs while listing file system entries.
*/
public Collection<FileSystemEntry> listEntries(Predicate<Path> filter) throws IOException {
var subEntries = new ArrayList<FileSystemEntry>();
Files.walkFileTree(fsRoot, EnumSet.noneOf(FileVisitOption.class), Integer.MAX_VALUE, new SimpleFileVisitor<>() {
@Override
public FileVisitResult visitFile(Path path, BasicFileAttributes attrs) throws IOException {
var p = fsRoot.relativize(path);
if (filter.test(p)) {
subEntries.add(new FileSystemEntry(Files.isDirectory(p), p, Files.size(fsRoot.resolve(p))));
}
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult visitFileFailed(Path file, IOException exc) throws IOException {
logger.warn(() -> "An error occurred while accessing file " + file + ": " + exc.getMessage());
logger.debug(() -> "Full stack", exc);
return FileVisitResult.CONTINUE;
}
});
return subEntries.stream().sorted(FileSystemEntry::compareTo).toList();
}
/**
* Returns a collection of {@link FileSystemEntry} for all file system entries matching the provided
* folder and files glob patterns.
*
* @param folderFilters a list of names of folders to inspect for content.
* @param fileExtensionsFilters a list of file extensions patterns to inspect for content.
* @return a collection of {@link FileSystemEntry} for all file system entries matching the provided path predicate.
* @throws IOException If an error occurs while listing file system entries.
*/
public Collection<FileSystemEntry> listEntries(String[] folderFilters, String[] fileExtensionsFilters) throws IOException {
return listEntries(path -> path.getFileName() != null &&
Arrays.stream(folderFilters)
.map(folder -> folder.equalsIgnoreCase("*") || path.startsWith(this.toInternalPath(folder)))
.reduce(Boolean::logicalOr).orElse(false) &&
Arrays.stream(fileExtensionsFilters)
.map(f -> f.equalsIgnoreCase("*") ||
f.equalsIgnoreCase("*.*") ||
path.getFileName().toString().matches(("\\Q" + f + "\\E").replace("*", "\\E.*\\Q").replace("?", "\\E.\\Q")))
.reduce(Boolean::logicalOr).orElse(false));
}
/**
* Returns the file system root directory for this {@link FileSystemBrowser} instance
*
* @return the file system root directory for this {@link FileSystemBrowser} instance
*/
public Path getRootDirectory() {
return fsRoot;
}
/**
* Converts a path string, or a sequence of strings that when joined form a path string, to a {@link Path} valid
* inside the browser's {@link FileSystem}
*
* @param first the path string or initial part of the path string
* @param more additional strings to be joined to form the path string
* @return the resulting {@code Path}
* @throws InvalidPathException If the path string cannot be converted
*/
public Path toInternalPath(String first, String... more) {
return this.fs.getPath(first, more);
}
@Override
public void close() throws IOException {
if (!this.fs.equals(FileSystems.getDefault())) {
this.fs.close();
}
}
/**
* A data class to carry selected properties describing file system entries browsed by {@link FileSystemBrowser}
*/
public static class FileSystemEntry implements Comparable<FileSystemEntry> {
private final boolean directory;
private final Path path;
private final Long size;
public FileSystemEntry(boolean directory, Path path, Long size) {
this.directory = directory;
this.path = path;
this.size = size;
}
public boolean isDirectory() {
return directory;
}
public Path getPath() {
return path;
}
public Long getSize() {
return size;
}
@Override
public int compareTo(FileSystemEntry o) {
var dirComp = Boolean.compare(this.directory, o.directory);
if (dirComp != 0) {
return dirComp;
}
var rootComp = Boolean.compare(isRoot(this.path), isRoot(o.path));
if (rootComp != 0) {
return rootComp;
}
var pathComp = this.path.toString().compareToIgnoreCase(o.path.toString());
if (pathComp != 0) {
return pathComp;
}
var depthComp = pathDepthFromRoot(o.path) - pathDepthFromRoot(this.path);
if (depthComp != 0) {
return depthComp;
}
return Long.compare(this.size, o.size);
}
private boolean isRoot(Path path) {
return path.getParent() == null || path.getParent().toString().equals("/");
}
private int pathDepthFromRoot(Path path) {
int depth = 0;
Path parent = path.getParent();
while (parent != null) {
depth++;
parent = parent.getParent();
}
return depth;
}
@Override
public int hashCode() {
return path.hashCode() + Long.hashCode(size) + Boolean.hashCode(directory);
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj == null || getClass() != obj.getClass()) {
return false;
}
FileSystemEntry fse = (FileSystemEntry) obj;
return path.equals(fse.path) &&
size.equals(fse.size) &&
directory == fse.directory;
}
}
}
@@ -0,0 +1,243 @@
/*
* Copyright 2017-2022 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.io;
import eu.binjr.common.function.CheckedLambdas;
import eu.binjr.common.logging.Logger;
import javafx.beans.property.DoubleProperty;
import java.io.*;
import java.nio.ByteBuffer;
import java.nio.channels.ReadableByteChannel;
import java.nio.channels.WritableByteChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Base64;
import java.util.Collection;
import java.util.Objects;
import java.util.function.BiConsumer;
import java.util.stream.Stream;
/**
* Utility methods to read, write and copy data from and across streams
*
* @author Frederic Thevenet
*/
public class IOUtils {
private static final int DEFAULT_COPY_BUFFER_SIZE = 16384;
private static final int EOF = -1;
private static final Logger logger = Logger.create(IOUtils.class);
public static long copyChannels(ReadableByteChannel input, WritableByteChannel output) throws IOException {
return copyChannels(input, output, DEFAULT_COPY_BUFFER_SIZE);
}
public static long copyChannels(ReadableByteChannel input, WritableByteChannel output, int bufferSize) throws IOException {
Objects.requireNonNull(input, "Argument input must not be null");
Objects.requireNonNull(output, "Argument output must not be null");
ByteBuffer buffer = ByteBuffer.allocateDirect(bufferSize);
long count = 0;
while (input.read(buffer) != -1) {
buffer.flip();
count += output.write(buffer);
buffer.compact();
}
buffer.flip();
while (buffer.hasRemaining()) {
count += output.write(buffer);
}
return count;
}
public static long copyStreams(InputStream input, OutputStream output) throws IOException {
return copyStreams(input, output, DEFAULT_COPY_BUFFER_SIZE);
}
public static long copyStreams(InputStream input, OutputStream output, int bufferSize) throws IOException {
Objects.requireNonNull(input, "Argument input must not be null");
Objects.requireNonNull(output, "Argument output must not be null");
byte[] buffer = new byte[bufferSize];
long count = 0;
int n;
while (EOF != (n = input.read(buffer))) {
output.write(buffer, 0, n);
count += n;
}
return count;
}
public static long copyStreams(InputStream input, OutputStream output,long size, DoubleProperty progress) throws IOException {
Objects.requireNonNull(input, "Argument input must not be null");
Objects.requireNonNull(output, "Argument output must not be null");
if (size == 0){
size = -1;
}
byte[] buffer = new byte[DEFAULT_COPY_BUFFER_SIZE];
long count = 0;
int n;
while (EOF != (n = input.read(buffer))) {
output.write(buffer, 0, n);
count += n;
progress.set(count / (double) size);
}
progress.set(1.0);
return count;
}
public static byte[] readToBuffer(InputStream input) throws IOException {
Objects.requireNonNull(input, "Argument input must not be null");
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
long count = copyStreams(input, baos);
return baos.toByteArray();
}
}
public static long consumeStream(InputStream input) throws IOException {
Objects.requireNonNull(input, "Argument input must not be null");
byte[] buffer = new byte[DEFAULT_COPY_BUFFER_SIZE];
long count = 0;
int n;
while (EOF != (n = input.read(buffer))) {
count += n;
}
return count;
}
public static String readToString(InputStream in) throws IOException {
return new String(readToBuffer(in));
}
public static String readToString(InputStream in, String charset) throws IOException {
return new String(readToBuffer(in), charset);
}
public static <T extends AutoCloseable> void closeAll(Collection<T> collection) {
closeAll(collection.stream());
}
public static <T extends AutoCloseable> void closeAll(Collection<T> collection, BiConsumer<T, Exception> onError) {
closeAll(collection.stream(), onError);
}
public static <T extends AutoCloseable> void closeAll(Stream<T> stream) {
closeAll(stream, (c, e) -> {
logger.error("An error occurred while closing element" + c + ": " + e.getMessage());
logger.debug("Stack Trace:", e);
});
}
public static <T extends AutoCloseable> void closeAll(Stream<T> stream, BiConsumer<T, Exception> onError) {
Objects.requireNonNull(stream, "Argument collection must not be null");
stream.forEach(closeable -> close(closeable, onError));
}
public static <T extends AutoCloseable> void close(T closeable) {
close(closeable, (t, e) -> {
logger.error("An error occurred while closing " + t + ": " + e.getMessage());
logger.debug("Stack Trace:", e);
});
}
public static <T extends AutoCloseable> void close(T closeable, BiConsumer<T, Exception> onError) {
if (closeable != null) {
try {
logger.trace(() -> "Closing: " + closeable);
closeable.close();
} catch (Exception e) {
onError.accept(closeable, e);
}
}
}
public static void copyDirectory(Path from, Path to, CopyOption... options) throws IOException {
try (final Stream<Path> sources = Files.walk(from)) {
sources.forEach(CheckedLambdas.wrap(src -> {
final Path dest = to.resolve(from.relativize(src).toString());
if (Files.isDirectory(src)) {
if (Files.notExists(dest)) {
logger.trace("Creating directory {}", dest);
Files.createDirectories(dest);
}
} else {
logger.trace("Extracting file {} to {}", src, dest);
Files.copy(src, dest, options);
}
}));
}
}
public static void attemptDeleteTempPath(Path path) {
try {
logger.debug("Attempting to delete " + path);
Files.walkFileTree(path, new SimpleFileVisitor<>() {
@Override
public FileVisitResult visitFile(Path path, BasicFileAttributes basicFileAttributes) throws IOException {
try {
Files.delete(path);
logger.trace("Deleted file: " + path);
} catch (IOException e) {
logger.warn("Temporary file " + path + " could not be deleted: " + e.getMessage());
logger.debug("Stack Trace:", e);
}
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult postVisitDirectory(Path directory, IOException ioException) throws IOException {
try {
Files.delete(directory);
logger.trace("Deleted directory:" + path);
} catch (IOException e) {
logger.warn("Temporary folder " + directory + " could not be deleted: " + e.getMessage());
logger.debug("Stack Trace:", e);
}
return FileVisitResult.CONTINUE;
}
});
} catch (IOException e) {
logger.warn("Temporary location " + path + " could not be deleted: " + e.getMessage());
logger.debug("Stack Trace:", e);
}
}
public static byte[] concat(byte[] a, byte[] b) {
byte[] c = new byte[a.length + b.length];
System.arraycopy(a, 0, c, 0, a.length);
System.arraycopy(b, 0, c, a.length, b.length);
return c;
}
public static byteArrayTuple split(byte[] array, int len) {
byte[] first = new byte[len];
byte[] second = new byte[array.length - len];
System.arraycopy(array, 0, first, 0, len);
System.arraycopy(array, len, second, 0, second.length);
return new byteArrayTuple(first, second);
}
public record byteArrayTuple(byte[] first, byte[] second) {
}
public static String sha256(String input) throws NoSuchAlgorithmException {
MessageDigest msdDigest = MessageDigest.getInstance("SHA-256");
msdDigest.update(input.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(msdDigest.digest());
}
}
@@ -0,0 +1,21 @@
/*
* Copyright 2022 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.io;
public record ProxyConfiguration(Boolean enabled, String host, int port, boolean useAuth, String login, char[] pwd) {
}
@@ -0,0 +1,81 @@
/*
* Copyright 2023 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.io;
import eu.binjr.common.logging.Logger;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManager;
import javax.net.ssl.TrustManagerFactory;
import java.io.IOException;
import java.security.*;
import java.security.cert.CertificateException;
import java.util.*;
public class SSLContextUtils {
private static final Logger logger = Logger.create(SSLContextUtils.class);
public enum PlatformKeyStore {
NONE("none"),
WINDOWS_ROOT("Windows-ROOT"),
WINDOWS_MY("Windows-MY"),
MACOS_KEYCHAIN("KeychainStore");
private final String name;
PlatformKeyStore(String name) {
this.name = name;
}
public String getName() {
return name;
}
}
public static SSLContext withPlatformKeystore() throws SSLCustomContextException {
var OS_NAME = System.getProperty("os.name").toLowerCase(Locale.ROOT);
if (OS_NAME.startsWith("windows")) {
return withKeystore(PlatformKeyStore.WINDOWS_ROOT);
}
if (OS_NAME.startsWith("mac")) {
return withKeystore(PlatformKeyStore.MACOS_KEYCHAIN);
}
return withKeystore(PlatformKeyStore.NONE);
}
public static SSLContext withKeystore(PlatformKeyStore keyStore) throws SSLCustomContextException {
try {
var sslContext = SSLContext.getInstance("TLSv1.2");
logger.debug(() -> "Using platform specific keystore: " + keyStore);
TrustManager[] trustManagers = null;
if (keyStore != PlatformKeyStore.NONE) {
var tks = KeyStore.getInstance(keyStore.getName());
tks.load(null, null);
var tmFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
tmFactory.init(tks);
trustManagers = tmFactory.getTrustManagers();
}
sslContext.init(null, trustManagers, null);
return sslContext;
} catch (KeyStoreException | CertificateException | NoSuchAlgorithmException | KeyManagementException |
IOException e) {
throw new SSLCustomContextException("Failed to create custom SSL context: " + e.getMessage(), e);
}
}
}
@@ -0,0 +1,33 @@
/*
* Copyright 2023 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.io;
import java.io.IOException;
public class SSLCustomContextException extends IOException {
public SSLCustomContextException() {
super();
}
public SSLCustomContextException(String message) {
super(message);
}
public SSLCustomContextException(String message, Throwable inner) {
super(message, inner);
}
}
@@ -0,0 +1,362 @@
/*
* Copyright 2017-2021 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.javafx.bindings;
import eu.binjr.common.logging.Logger;
import javafx.beans.InvalidationListener;
import javafx.beans.property.Property;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.collections.ListChangeListener;
import javafx.collections.ObservableList;
import javafx.collections.ObservableSet;
import javafx.collections.SetChangeListener;
import javafx.event.Event;
import javafx.event.EventHandler;
import javafx.event.WeakEventHandler;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.BiConsumer;
/**
* A class that provide methods to centralize and register the attachment of listeners
* and bindings onto {@link javafx.beans.Observable} instances.
* <p>
* This makes it possible to remove all listeners and bindings attached to to
* registered {@link javafx.beans.Observable} instances in a determinist fashion (on invoking {@code close()})
* and helps alleviate the potential for references leaks in some scenarios.
* </p>
*
* @author Frederic Thevenet
*/
@SuppressWarnings("rawtypes")
public class BindingManager implements AutoCloseable {
private static final Logger logger = Logger.create(BindingManager.class);
private final Map<ObservableValue, List<ChangeListener>> changeListeners = Collections.synchronizedMap(new WeakHashMap<>());
private final Map<ObservableValue, List<InvalidationListener>> invalidationListeners = Collections.synchronizedMap(new WeakHashMap<>());
private final Map<ObservableList, List<ListChangeListener>> listChangeListeners = Collections.synchronizedMap(new WeakHashMap<>());
private final Map<ObservableList, List<InvalidationListener>> listInvalidationListeners = Collections.synchronizedMap(new WeakHashMap<>());
private final Map<ObservableSet, List<SetChangeListener>> setChangeListeners = Collections.synchronizedMap(new WeakHashMap<>());
private final Map<ObservableSet, List<InvalidationListener>> setInvalidationListeners= Collections.synchronizedMap(new WeakHashMap<>());
private final Map<Property<?>, ObservableValue> boundProperties = Collections.synchronizedMap(new WeakHashMap<>());
private final Map<Property<?>, Property> bidirectionallyBoundProperties = Collections.synchronizedMap(new WeakHashMap<>());
private final List<EventHandler<?>> registeredHandlers = Collections.synchronizedList(new ArrayList<>());
private final AtomicBoolean closed = new AtomicBoolean(false);
/**
* Binds the specified {@link ObservableValue} onto the specified {@link Property} and registers the resulting binding.
*
* @param property the {@link Property} to bind
* @param binding the {@link ObservableValue} to bind to the {@link Property}
* @param <T> the type of {@link Property}
* @param <U> the type of {@link ObservableValue}
*/
public <T, U extends T> void bind(Property<T> property, ObservableValue<U> binding) {
Objects.requireNonNull(property, "property parameter cannot be null");
Objects.requireNonNull(binding, "binding parameter cannot be null");
logger.trace(() -> "Binding " + binding.toString() + " to " + property.toString());
property.bind(binding);
boundProperties.put(property, binding);
}
/**
* Unbinds all registered bindings
*/
public void unbindAll() {
boundProperties.forEach((property, binding) -> {
logger.trace(() -> "Unbinding property " + property.toString());
property.unbind();
});
boundProperties.clear();
bidirectionallyBoundProperties.forEach((property, binding) -> {
logger.trace(() -> "Unbinding property " + property.toString() + " from " + binding.toString());
property.unbindBidirectional(binding);
});
bidirectionallyBoundProperties.clear();
}
public <T> void bindBidirectional(Property<T> property, Property<T> binding) {
Objects.requireNonNull(property, "property parameter cannot be null");
Objects.requireNonNull(binding, "binding parameter cannot be null");
logger.trace(() -> "Binding " + binding.toString() + " to " + property.toString());
property.bindBidirectional(binding);
bidirectionallyBoundProperties.put(property, binding);
}
public <T> void unbindBidirectionnal(Property<T> property, Property<T> binding) {
Objects.requireNonNull(property, "property parameter cannot be null");
Objects.requireNonNull(binding, "binding parameter cannot be null");
logger.trace(() -> "Unbinding " + binding.toString() + " from " + property.toString());
property.unbindBidirectional(binding);
bidirectionallyBoundProperties.remove(property, binding);
}
/**
* Attach a {@link ChangeListener} to an {@link ObservableValue} and registers the resulting binding.
*
* @param observable the {@link ObservableValue} to attach the listener to.
* @param listener the {@link ChangeListener} to attach
*/
public void attachListener(ObservableValue<?> observable, ChangeListener<?> listener) {
register(observable, listener, changeListeners, ObservableValue::addListener);
}
/**
* Attach a {@link InvalidationListener} to an {@link ObservableValue} and registers the resulting binding.
*
* @param observable the {@link ObservableValue} to attach the listener to.
* @param listener the {@link InvalidationListener} to attach
*/
public void attachListener(ObservableValue<?> observable, InvalidationListener listener) {
register(observable, listener, invalidationListeners, ObservableValue::addListener);
}
/**
* Attach a {@link ListChangeListener} to an {@link ObservableList} and registers the resulting binding.
*
* @param observable the {@link ObservableList} to attach the listener to.
* @param listener the {@link ListChangeListener} to attach
*/
public void attachListener(ObservableList<?> observable, ListChangeListener listener) {
register(observable, listener, listChangeListeners, ObservableList::addListener);
}
public void attachListener(ObservableList<?> observable, InvalidationListener listener) {
register(observable, listener, listInvalidationListeners, ObservableList::addListener);
}
public void attachListener(ObservableSet<?> observable, SetChangeListener listener){
register(observable, listener, setChangeListeners, ObservableSet::addListener);
}
public void attachListener(ObservableSet<?> observable, InvalidationListener listener){
register(observable, listener, setInvalidationListeners, ObservableSet::addListener);
}
public void detachListener(ObservableSet<?> observable, SetChangeListener listener) {
unregister(observable, listener, setChangeListeners, ObservableSet::removeListener);
}
public void detachListener(ObservableSet<?> observable, InvalidationListener listener) {
unregister(observable, listener, setInvalidationListeners, ObservableSet::removeListener);
}
/**
* Remove a specific {@link ChangeListener} from an {@link ObservableValue}.
*
* @param observable the {@link ObservableValue} to remove the listener from.
* @param listener the {@link ChangeListener} to remove
*/
public void detachListener(ObservableValue<?> observable, ChangeListener listener) {
unregister(observable, listener, changeListeners, ObservableValue::removeListener);
}
/**
* Remove a specific {@link InvalidationListener} from an {@link ObservableValue}.
*
* @param observable the {@link ObservableValue} to remove the listener from.
* @param listener the {@link InvalidationListener} to remove
*/
public void detachListener(ObservableValue<?> observable, InvalidationListener listener) {
unregister(observable, listener, invalidationListeners, ObservableValue::removeListener);
}
/**
* Remove a specific {@link ListChangeListener} from an {@link ObservableList}.
*
* @param observable the {@link ObservableList} to remove the listener from.
* @param listener the {@link ListChangeListener} to remove
*/
public void detachListener(ObservableList<?> observable, ListChangeListener<?> listener) {
unregister(observable, listener, listChangeListeners, ObservableList::removeListener);
}
public void detachListener(ObservableList<?> observable, InvalidationListener listener) {
unregister(observable, listener, listInvalidationListeners, ObservableList::removeListener);
}
/**
* Remove <u>all</u> {@link InvalidationListener} from an {@link ObservableValue}.
*
* @param observable the {@link ObservableValue} to remove all listeners from.
*/
public void detachAllInvalidationListeners(ObservableValue<?> observable) {
unregister(observable, invalidationListeners, ObservableValue::removeListener);
}
/**
* Remove <u>all</u> {@link ChangeListener} from an {@link ObservableValue}.
*
* @param observable the {@link ObservableValue} to remove all listeners from.
*/
public void detachAllChangeListeners(ObservableValue<?> observable) {
unregister(observable, changeListeners, ObservableValue::removeListener);
}
/**
* Remove <u>all</u> {@link ListChangeListener} from an {@link ObservableList}.
*
* @param observable the {@link ObservableList} to remove all listeners from.
*/
public void detachAllListChangeListeners(ObservableList<?> observable) {
unregister(observable, listChangeListeners, ObservableList::removeListener);
}
public void detachAllInvalidationListeners(ObservableList<?> observable) {
unregister(observable, listInvalidationListeners, ObservableList::removeListener);
}
@Override
public synchronized void close() {
if (closed.compareAndSet(false, true)) {
try {
unregisterAll(listChangeListeners, ObservableList::removeListener);
unregisterAll(listInvalidationListeners, ObservableList::removeListener);
unregisterAll(invalidationListeners, ObservableValue::removeListener);
unregisterAll(changeListeners, ObservableValue::removeListener);
unregisterAll(setChangeListeners, ObservableSet::removeListener);
unregisterAll(setInvalidationListeners, ObservableSet::removeListener);
unbindAll();
// Release strong refs to registered event handlers, so that their
// weak counterpart may be collected.
registeredHandlers.clear();
} catch (Exception e) {
logger.warn("An error occurred while closing BindingManager instance", e);
}
}
}
public synchronized void suspend() {
visitMap(listChangeListeners, ObservableList::removeListener);
visitMap(listInvalidationListeners, ObservableList::removeListener);
visitMap(invalidationListeners, ObservableValue::removeListener);
visitMap(changeListeners, ObservableValue::removeListener);
visitMap(setChangeListeners, ObservableSet::removeListener);
visitMap(setInvalidationListeners, ObservableSet::removeListener);
boundProperties.keySet().forEach(Property::unbind);
}
public synchronized void resume() {
visitMap(listChangeListeners, ObservableList::addListener);
visitMap(listInvalidationListeners, ObservableList::addListener);
visitMap(invalidationListeners, ObservableValue::addListener);
visitMap(changeListeners, ObservableValue::addListener);
visitMap(setChangeListeners, ObservableSet::addListener);
visitMap(setInvalidationListeners, ObservableSet::addListener);
boundProperties.forEach(Property::bind);
}
public synchronized void suspend(Property<?>... properties) {
for (var p : properties) {
if (!boundProperties.containsKey(p)) {
throw new IllegalArgumentException("Property " + p.getName() + " is not managed by this instance of BindingManager");
}
p.unbind();
}
}
public synchronized void resume(Property<?>... properties) {
for (var p : properties) {
if (!boundProperties.containsKey(p)) {
throw new IllegalArgumentException("Property " + p.getName() + " is not managed by this instance of BindingManager");
}
p.bind(boundProperties.get(p));
}
}
public <T extends Event> WeakEventHandler<T> registerHandler(EventHandler<T> handler) {
// Store strong ref to handler, so it doesn't get collected prematurely.
registeredHandlers.add(handler);
// wrap in WeakEventHandler
return new WeakEventHandler<T>(handler);
}
public <T extends Event> void unregisterHandler(EventHandler<T> handler) {
// Remove strong ref to handler, so it can be collected.
registeredHandlers.remove(handler);
}
private <T, U> void register(T observable, U listener, Map<T, List<U>> map, BiConsumer<T, U> attachAction) {
Objects.requireNonNull(observable, "observable parameter cannot be null");
Objects.requireNonNull(listener, "listener parameter cannot be null");
Objects.requireNonNull(map, "map parameter cannot be null");
Objects.requireNonNull(attachAction, "attachAction parameter cannot be null");
map.computeIfAbsent(observable, p -> new ArrayList<>()).add(listener);
logger.trace(() -> "Attaching listener " + listener.toString() + " to observable " + observable.toString());
attachAction.accept(observable, listener);
}
private <T, U> void unregister(T key, U value, Map<T, List<U>> map, BiConsumer<T, U> unregisterAction) {
Objects.requireNonNull(key, "key parameter cannot be null");
Objects.requireNonNull(value, "value parameter cannot be null");
Objects.requireNonNull(map, "map parameter cannot be null");
Objects.requireNonNull(unregisterAction, "unregisterAction parameter cannot be null");
List<U> listeners = map.get(key);
if (listeners == null) {
logger.debug(() -> "Object " + key.toString() + " is not managed by this BindingManager instance");
return;
}
listeners.stream().filter(l -> l.equals(value)).findFirst().ifPresent(found -> map.get(key).remove(found));
logger.trace(() -> "Unregistering " + value.toString() + " from " + key.toString());
unregisterAction.accept(key, value);
}
private <T, U> void unregister(T key, Map<T, List<U>> map, BiConsumer<T, U> unregisterAction) {
Objects.requireNonNull(key, "key paramater cannot be null");
Objects.requireNonNull(map, "map parameter cannot be null");
Objects.requireNonNull(unregisterAction, "unregisterAction parameter cannot be null");
List<U> l = map.get(key);
if (l == null) {
logger.debug(() -> "Object " + key.toString() + " is not managed by this BindingManager instance");
return;
}
l.forEach(value -> {
logger.trace(() -> "Unregistering " + value.toString() + " from " + key.toString());
unregisterAction.accept(key, value);
});
map.remove(key);
}
private <T, U> void unregisterAll(Map<T, List<U>> map, BiConsumer<T, U> unregisterAction) {
Objects.requireNonNull(map, "map parameter cannot be null");
Objects.requireNonNull(unregisterAction, "unregisterAction parameter cannot be null");
map.forEach((k, vList) -> {
vList.forEach(v -> {
logger.trace(() -> "Unregistering " + v.toString() + " from " + k.toString());
unregisterAction.accept(k, v);
});
});
map.clear();
}
private <T, U> void visitMap(Map<T, List<U>> map, BiConsumer<T, U> action) {
Objects.requireNonNull(map, "map parameter cannot be null");
Objects.requireNonNull(action, "action parameter cannot be null");
map.forEach((observable, listeners) -> listeners.forEach(listener -> {
logger.trace(() -> "visiting key " + listener.toString() + " value " + observable.toString());
action.accept(observable, listener);
}));
}
}
@@ -0,0 +1,30 @@
/*
* Copyright 2017-2021 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.javafx.charts;
import eu.binjr.common.text.BinaryPrefixFormatter;
/**
* An implementation of {@link StableTicksAxis} that divide up large numbers by powers of 2 and apply binary unit prefixes
*
* @author Frederic Thevenet
*/
public class BinaryStableTicksAxis<T extends Number> extends StableTicksAxis<T> {
public BinaryStableTicksAxis() {
super(new BinaryPrefixFormatter(), 2, new double[]{1.0, 2.0, 4.0, 8.0, 16.0});
}
}
@@ -0,0 +1,30 @@
/*
* Copyright 2017-2021 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.javafx.charts;
import eu.binjr.common.text.MetricPrefixFormatter;
/**
* An implementation of {@link StableTicksAxis} that divide up large numbers by powers of 10 and apply metric unit prefixes
*
* @author Frederic Thevenet
*/
public class MetricStableTicksAxis<T extends Number> extends StableTicksAxis<T> {
public MetricStableTicksAxis() {
super(new MetricPrefixFormatter(), 10, new double[]{1.0, 2.5, 5.0});
}
}
@@ -0,0 +1,145 @@
/*
* Copyright 2019-2020 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.javafx.charts;
import javafx.collections.ObservableList;
import javafx.scene.chart.Axis;
import javafx.scene.chart.StackedAreaChart;
import javafx.scene.chart.XYChart;
import java.util.*;
/**
* A {@link StackedAreaChart} that support NaN values in series.
*
* @param <X> Type for the X axis
* @param <Y> Type for the Y axis
*/
public class NaNStackedAreaChart<X, Y> extends StackedAreaChart<X, Y> {
public NaNStackedAreaChart(Axis<X> xAxis, Axis<Y> yAxis) {
super(xAxis, yAxis);
}
public NaNStackedAreaChart(Axis<X> xAxis, Axis<Y> yAxis, ObservableList<Series<X, Y>> data) {
super(xAxis, yAxis, data);
}
/**
* {@inheritDoc}
*/
@Override
protected void updateAxisRange() {
// This override is necessary to update axis range based on cumulative Y value for the
// Y axis instead of the normal way where max value in the data range is used.
final Axis<X> xa = getXAxis();
final Axis<Y> ya = getYAxis();
if (xa.isAutoRanging()) {
List<X> xData = new ArrayList<>();
for (XYChart.Series<X, Y> series : getData()) {
for (XYChart.Data<X, Y> data : series.getData()) {
xData.add(data.getXValue());
}
}
xa.invalidateRange(xData);
}
if (ya.isAutoRanging()) {
double totalMinY = Double.MAX_VALUE;
Iterator<XYChart.Series<X, Y>> seriesIterator = getDisplayedSeriesIterator();
boolean first = true;
NavigableMap<Double, Double> accum = new TreeMap<>();
NavigableMap<Double, Double> prevAccum = new TreeMap<>();
NavigableMap<Double, Double> currentValues = new TreeMap<>();
while (seriesIterator.hasNext()) {
currentValues.clear();
XYChart.Series<X, Y> series = seriesIterator.next();
for (XYChart.Data<X, Y> item : series.getData()) {
if (item != null) {
final double xv = xa.toNumericValue(item.getXValue());
final double yv = Double.isNaN(ya.toNumericValue(item.getYValue())) ? 0.0 : ya.toNumericValue(item.getYValue());
currentValues.put(xv, yv);
if (first) {
// On the first pass, just fill the map
accum.put(xv, yv);
// minimum is applicable only in the first series
totalMinY = Math.min(totalMinY, yv);
} else {
if (prevAccum.containsKey(xv)) {
accum.put(xv, prevAccum.get(xv) + yv);
} else {
// If the point wasn't yet in the previous (accumulated) series
Map.Entry<Double, Double> he = prevAccum.higherEntry(xv);
Map.Entry<Double, Double> le = prevAccum.lowerEntry(xv);
if (he != null && le != null) {
// If there's both point above and below this point, interpolate
accum.put(xv, ((xv - le.getKey()) / (he.getKey() - le.getKey())) *
(le.getValue() + he.getValue()) + yv);
} else if (he != null) {
// The point is before the first point in the previously accumulated series
accum.put(xv, he.getValue() + yv);
} else if (le != null) {
// The point is after the last point in the previously accumulated series
accum.put(xv, le.getValue() + yv);
} else {
// The previously accumulated series is empty
accum.put(xv, yv);
}
}
}
}
}
// Now update all the keys that were in the previous series, but not in the new one
for (Map.Entry<Double, Double> e : prevAccum.entrySet()) {
if (accum.keySet().contains(e.getKey())) {
continue;
}
Double k = e.getKey();
final Double v = e.getValue();
// Look at the values of the current series
Map.Entry<Double, Double> he = currentValues.higherEntry(k);
Map.Entry<Double, Double> le = currentValues.lowerEntry(k);
if (he != null && le != null) {
// Interpolate the for the point from current series and add the accumulated value
accum.put(k, ((k - le.getKey()) / (he.getKey() - le.getKey())) *
(le.getValue() + he.getValue()) + v);
} else if (he != null) {
// There accumulated value is before the first value in the current series
accum.put(k, he.getValue() + v);
} else if (le != null) {
// There accumulated value is after the last value in the current series
accum.put(k, le.getValue() + v);
} else {
// The current series are empty
accum.put(k, v);
}
}
prevAccum.clear();
prevAccum.putAll(accum);
accum.clear();
first = (totalMinY == Double.MAX_VALUE); // If there was already some value in the series, we can consider as
// being past the first series
}
if (totalMinY != Double.MAX_VALUE) ya.invalidateRange(Arrays.asList(ya.toRealValue(totalMinY),
ya.toRealValue(Collections.max(prevAccum.values()))));
}
}
}
@@ -0,0 +1,49 @@
/*
* Copyright 2017-2020 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.javafx.charts;
import eu.binjr.common.logging.Logger;
import eu.binjr.common.logging.Profiler;
import javafx.scene.chart.Axis;
import javafx.scene.chart.StackedAreaChart;
/**
* A {@link StackedAreaChart} that logs the execution time of the {@code layoutPlotChildren} method
*
* @author Frederic Thevenet
*/
public class ProfiledStackedAreaChart<X, Y> extends StackedAreaChart<X, Y> {
private static final Logger logger = Logger.create(ProfiledStackedAreaChart.class);
/**
* Initializes a new instance of the {@link ProfiledStackedAreaChart} class
*
* @param xAxis the x axis of the chart
* @param yAxis the y axis of the chart
*/
public ProfiledStackedAreaChart(Axis<X> xAxis, Axis<Y> yAxis) {
super(xAxis, yAxis);
}
@Override
protected void layoutPlotChildren() {
try (Profiler p = Profiler.start("Plotting MyStackedAreaChart " + this.getTitle(), logger::perf)) {
super.layoutPlotChildren();
}
}
}
@@ -0,0 +1,550 @@
/*
* Copyright 2013 Jason Winnebeck
* Copyright 2019-2022 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.javafx.charts;
import eu.binjr.common.text.PrefixFormatter;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.beans.property.*;
import javafx.beans.value.WritableValue;
import javafx.collections.ObservableList;
import javafx.css.PseudoClass;
import javafx.geometry.Dimension2D;
import javafx.geometry.Side;
import javafx.scene.chart.Axis;
import javafx.scene.chart.ValueAxis;
import javafx.scene.layout.Pane;
import javafx.util.Duration;
import org.gillius.jfxutils.chart.AxisTickFormatter;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
/**
* The StableTicksAxis places tick marks at consistent (axis value rather than graphical) locations.
*
* @author Jason Winnebeck
*/
public class StableTicksAxis<T extends Number> extends ValueAxis<T> {
private T dataMaxValue = (T) Double.valueOf(0);
private T dataMinValue = (T) Double.valueOf(0);
private final double[] dividers;
private final int base;
public static class SelectableRegion extends Pane {
private static final PseudoClass SELECTED_PSEUDO_CLASS = PseudoClass.getPseudoClass("selected");
private final BooleanProperty selected = new BooleanPropertyBase(false) {
public void invalidated() {
pseudoClassStateChanged(SELECTED_PSEUDO_CLASS, get());
}
@Override
public Object getBean() {
return SelectableRegion.this;
}
@Override
public String getName() {
return "selected";
}
};
public boolean isSelected() {
return selected.get();
}
public BooleanProperty selectedProperty() {
return selected;
}
public void setSelected(boolean selected) {
this.selected.set(selected);
}
}
/**
* Possible tick spacing at the 10^1 level. These numbers must be &gt;= 1 and &lt; 10.
*/
private static final int NUM_MINOR_TICKS = 3;
public static final int BTN_WITDTH = 21;
private final SelectableRegion selectionMarker = new SelectableRegion();
private final BooleanProperty selectionMarkerVisible = selectionMarker.visibleProperty();
private final BooleanProperty selected = selectionMarker.selectedProperty(); //new SimpleBooleanProperty(false);
private final Timeline animationTimeline = new Timeline();
private final WritableValue<Double> scaleValue = new WritableValue<>() {
@Override
public Double getValue() {
return getScale();
}
@Override
public void setValue(Double value) {
setScale(value);
}
};
private AxisTickFormatter axisTickFormatter;
private final SimpleDoubleProperty tickSpacing = new SimpleDoubleProperty(20);
private final StringProperty displayUnit = new SimpleStringProperty("");
private List<T> minorTicks;
/**
* Amount of padding to add on the each end of the axis when auto ranging.
*/
private final DoubleProperty autoRangePadding = new SimpleDoubleProperty(0.1);
/**
* If true, when auto-ranging, force 0 to be the min or max end of the range.
*/
private final BooleanProperty forceZeroInRange = new SimpleBooleanProperty(true);
/**
* Initializes a new instance of the {@link StableTicksAxis} class.
*
* @param prefixFormatter the {@link PrefixFormatter} instance to use.
* @param base the numerical base used to determine tick positions.
* @param dividers a list of divider candidates.
*/
public StableTicksAxis(PrefixFormatter prefixFormatter, int base, double[] dividers) {
super();
this.base = base;
this.dividers = dividers != null ? dividers : new double[]{1.0, 2.5, 5.0};
getStyleClass().setAll("axis");
selectionMarker.getStyleClass().addAll("selection-marker", "drop-target");
var states = selectionMarker.getPseudoClassStates();
this.getChildren().add(selectionMarker);
this.axisTickFormatter = new AxisTickFormatter() {
@Override
public void setRange(double v, double v1, double v2) {
}
@Override
public String format(Number number) {
return prefixFormatter.format(number.doubleValue()) + displayUnit.getValue();
}
};
}
/**
* Returns the axis tick formatter.
*
* @return the axis tick formatter.
*/
public AxisTickFormatter getAxisTickFormatter() {
return axisTickFormatter;
}
/**
* Sets the axis tick formatter.
*
* @param axisTickFormatter the axis tick formatter.
*/
public void setAxisTickFormatter(AxisTickFormatter axisTickFormatter) {
this.axisTickFormatter = axisTickFormatter;
}
/**
* Amount of padding to add on the each end of the axis when auto ranging.
*
* @return Amount of padding to add on the each end of the axis when auto ranging.
*/
public double getAutoRangePadding() {
return autoRangePadding.get();
}
/**
* The autoRangePadding property.
*
* @return the autoRangePadding property.
*/
public DoubleProperty autoRangePaddingProperty() {
return autoRangePadding;
}
/**
* Amount of padding to add on the each end of the axis when auto ranging.
*
* @param autoRangePadding Amount of padding to add on the each end of the axis when auto ranging.
*/
public void setAutoRangePadding(double autoRangePadding) {
this.autoRangePadding.set(autoRangePadding);
}
/**
* If true, when auto-ranging, force 0 to be the min or max end of the range.
*
* @return true if force 0 to be the min or max end of the range when auto-ranging, false otherwize.
*/
public boolean isForceZeroInRange() {
return forceZeroInRange.get();
}
/**
* The forceZeroInRange property
*
* @return the forceZeroInRange property
*/
public BooleanProperty forceZeroInRangeProperty() {
return forceZeroInRange;
}
/**
* If true, when auto-ranging, force 0 to be the min or max end of the range.
*
* @param forceZeroInRange set to true, when auto-ranging, to force 0 to be the min or max end of the range.
*/
public void setForceZeroInRange(boolean forceZeroInRange) {
this.forceZeroInRange.set(forceZeroInRange);
}
@Override
protected void layoutChildren() {
super.layoutChildren();
Side side = getSide();
if (side.isVertical()) {
double xShift = side == Side.LEFT ? -10 : 0;
double contentX = this.getLayoutX();
double contentY = this.getLayoutY();
double contentWidth = this.getWidth() + 10;//(xShift * -1);
double contentHeight = this.getHeight();
// this.selectionMarker.setPrefWidth(contentWidth);
this.selectionMarker.setPrefHeight(contentHeight);
selectionMarker.resizeRelocate(
snapPositionX(xShift),
snapPositionY(0),
snapSizeX(contentWidth),
snapSizeY(contentHeight));
selectionMarker.toFront();
}
}
@Override
public void invalidateRange(List<T> data) {
// Calculate min and max value not taking NaN values into account
dataMaxValue = data.stream()
.filter(d -> d != null && !Double.isNaN(d.doubleValue()))
.max(Comparator.comparingDouble(T::doubleValue))
.orElse((T) Double.valueOf(getUpperBound()));
dataMinValue = data.stream()
.filter(d -> d != null && !Double.isNaN(d.doubleValue()))
.min(Comparator.comparingDouble(T::doubleValue))
.orElse((T) Double.valueOf(getLowerBound()));
// Invoke super with an empty list so that requestLayout is called while avoiding iterating the sample list again.
super.invalidateRange(Collections.emptyList());
}
@Override
protected Range autoRange(double minValue, double maxValue, double length, double labelSize) {
// By fthevenet: Override the provided min and max values by those calculated by our override.
minValue = dataMinValue.doubleValue();
maxValue = dataMaxValue.doubleValue();
//By dweil: if the range is very small, display it like a flat line, the scaling doesn't work very well at these
//values. 1e-300 was chosen arbitrarily.
if (Math.abs(minValue - maxValue) < 1e-300) {
//Normally this is the case for all points with the same value
minValue = minValue - 1;
maxValue = maxValue + 1;
} else {
//Add padding
double delta = maxValue - minValue;
double paddedMin = minValue - delta * autoRangePadding.get();
//If we've crossed the 0 line, clamp to 0.
//noinspection FloatingPointEquality
if (Math.signum(paddedMin) != Math.signum(minValue)) {
paddedMin = 0.0;
}
double paddedMax = maxValue + delta * autoRangePadding.get();
//If we've crossed the 0 line, clamp to 0.
//noinspection FloatingPointEquality
if (Math.signum(paddedMax) != Math.signum(maxValue)) {
paddedMax = 0.0;
}
minValue = paddedMin;
maxValue = paddedMax;
}
//Handle forcing zero into the range
if (forceZeroInRange.get()) {
if (minValue < 0 && maxValue < 0) {
maxValue = 0;
minValue -= -minValue * autoRangePadding.get();
} else if (minValue > 0 && maxValue > 0) {
minValue = 0;
maxValue += maxValue * autoRangePadding.get();
}
}
Range ret = getRange(minValue, maxValue);
return ret;
}
private Range getRange(double minValue, double maxValue) {
double length = getLength();
double delta = maxValue - minValue;
double scale = calculateNewScale(length, minValue, maxValue);
int maxTicks = Math.max(1, (int) (length / getTickSpacing()));
Range ret;
ret = new Range(minValue, maxValue, calculateTickSpacing(delta, maxTicks), scale);
return ret;
}
public double calculateTickSpacing(double delta, int maxTicks) {
if (delta == 0.0) {
return 0.0;
}
if (delta <= 0.0) {
throw new IllegalArgumentException("delta must be positive");
}
if (maxTicks < 1) {
throw new IllegalArgumentException("must be at least one tick");
}
int divider = 0;
int factor = (int) (Math.log(delta) / Math.log(base));
double numTicks = delta / (dividers[divider] * Math.pow(base, factor));
//We don't have enough ticks, so increase ticks until we're over the limit, then back off once.
if (numTicks < maxTicks) {
while (numTicks < maxTicks) {
//Move up
--divider;
if (divider < 0) {
--factor;
divider = dividers.length - 1;
}
numTicks = delta / (dividers[divider] * Math.pow(base, factor));
}
//Now back off once unless we hit exactly
//noinspection FloatingPointEquality
if (numTicks != maxTicks) {
++divider;
if (divider >= dividers.length) {
++factor;
divider = 0;
}
}
} else {
//We have too many ticks or exactly max, so decrease until we're just under (or at) the limit.
while (numTicks > maxTicks) {
++divider;
if (divider >= dividers.length) {
++factor;
divider = 0;
}
numTicks = delta / (dividers[divider] * Math.pow(base, factor));
}
}
return dividers[divider] * Math.pow(base, factor);
}
@Override
protected List<T> calculateMinorTickMarks() {
return minorTicks;
}
@Override
protected void setRange(Object range, boolean animate) {
Range rangeVal = (Range) range;
if (animate) {
animationTimeline.stop();
ObservableList<KeyFrame> keyFrames = animationTimeline.getKeyFrames();
keyFrames.setAll(
new KeyFrame(Duration.ZERO,
new KeyValue(currentLowerBound, getLowerBound()),
new KeyValue(scaleValue, getScale())),
new KeyFrame(Duration.millis(750),
new KeyValue(currentLowerBound, rangeVal.low),
new KeyValue(scaleValue, rangeVal.scale)));
animationTimeline.play();
} else {
currentLowerBound.set(rangeVal.low);
setScale(rangeVal.scale);
}
setLowerBound(rangeVal.low);
setUpperBound(rangeVal.high);
axisTickFormatter.setRange(rangeVal.low, rangeVal.high, rangeVal.tickSpacing);
}
@Override
protected Range getRange() {
Range ret = getRange(getLowerBound(), getUpperBound());
return ret;
}
@Override
protected List<T> calculateTickValues(double length, Object range) {
Range rangeVal = (Range) range;
//Use floor so we start generating ticks before the axis starts -- this is really only relevant
//because of the minor ticks before the first visible major tick. We'll generate a first
//invisible major tick but the ValueAxis seems to filter it out.
double firstTick = Math.floor(rangeVal.low / rangeVal.tickSpacing) * rangeVal.tickSpacing;
//Generate one more tick than we expect, for "overlap" to get minor ticks on both sides of the
//first and last major tick.
int numTicks = (int) (rangeVal.getDelta() / rangeVal.tickSpacing) + 1;
List<T> ret = new ArrayList<>(numTicks + 1);
minorTicks = new ArrayList<>((numTicks + 2) * NUM_MINOR_TICKS);
double minorTickSpacing = rangeVal.tickSpacing / (NUM_MINOR_TICKS + 1);
for (int i = 0; i <= numTicks; ++i) {
double majorTick = firstTick + rangeVal.tickSpacing * i;
ret.add((T) Double.valueOf(majorTick));
for (int j = 1; j <= NUM_MINOR_TICKS; ++j) {
minorTicks.add((T) Double.valueOf(majorTick + minorTickSpacing * j));
}
}
return ret;
}
@Override
protected String getTickMarkLabel(T number) {
return axisTickFormatter.format(number);
}
private double getLength() {
if (getSide().isHorizontal()) {
return getWidth();
} else {
return getHeight();
}
}
private double getLabelSize() {
Dimension2D dim = measureTickMarkLabelSize("-888.88E-88", getTickLabelRotation());
if (getSide().isHorizontal()) {
return dim.getWidth();
} else {
return dim.getHeight();
}
}
/**
* Returns the tick spacing value.
*
* @return the tick spacing value.
*/
public double getTickSpacing() {
return tickSpacing.get();
}
/**
* The tickSpacing property
*
* @return the tickSpacing property
*/
public SimpleDoubleProperty tickSpacingProperty() {
return tickSpacing;
}
/**
* Sets the value of the space space in between ticks.
*
* @param tickSpacing the value of the space space in between ticks.
*/
public void setTickSpacing(double tickSpacing) {
this.tickSpacing.set(tickSpacing);
}
public boolean isSelected() {
return selected.get();
}
public void setSelected(boolean value) {
selected.setValue(value);
}
public BooleanProperty selectedProperty() {
return selected;
}
public boolean getSelectionMarkerVisible() {
return selectionMarkerVisible.get();
}
public BooleanProperty selectionMarkerVisibleProperty() {
return selectionMarkerVisible;
}
public void setSelectionMarkerVisible(boolean selectionMarkerVisible) {
this.selectionMarkerVisible.set(selectionMarkerVisible);
}
public SelectableRegion getSelectionMarker() {
return selectionMarker;
}
public String getDisplayUnit() {
return displayUnit.get();
}
public StringProperty displayUnitProperty() {
return displayUnit;
}
public void setDisplayUnit(String displayUnit) {
this.displayUnit.set(displayUnit);
}
private static class Range {
public final double low;
public final double high;
public final double tickSpacing;
public final double scale;
private Range(double low, double high, double tickSpacing, double scale) {
this.low = low;
this.high = high;
this.tickSpacing = tickSpacing;
this.scale = scale;
}
public double getDelta() {
return high - low;
}
@Override
public String toString() {
return "Range{" +
"low=" + low +
", high=" + high +
", tickSpacing=" + tickSpacing +
", scale=" + scale +
'}';
}
}
}
@@ -0,0 +1,408 @@
/*
* Copyright 2016-2021 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.javafx.charts;
import eu.binjr.common.javafx.bindings.BindingManager;
import eu.binjr.common.logging.Logger;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.Property;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.geometry.Point2D;
import javafx.geometry.Rectangle2D;
import javafx.scene.Node;
import javafx.scene.chart.XYChart;
import javafx.scene.control.Label;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.Pane;
import javafx.scene.layout.Region;
import javafx.scene.paint.Color;
import javafx.scene.shape.Line;
import javafx.scene.shape.Rectangle;
import javafx.scene.shape.Shape;
import javafx.scene.shape.StrokeType;
import org.gillius.jfxutils.chart.XYChartInfo;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.function.Consumer;
import java.util.function.Function;
import static org.gillius.jfxutils.JFXUtil.getXShift;
import static org.gillius.jfxutils.JFXUtil.getYShift;
/**
* Draws a crosshair on top of an {@link XYChart} and handles selection of a portion of the chart view.
*
* @author Frederic Thevenet
*/
public class XYChartCrosshair<X, Y> {
private static final Logger logger = Logger.create(XYChartCrosshair.class);
private static final double SELECTION_OPACITY = 0.5;
private final Line horizontalMarker = new Line();
private final Line verticalMarker = new Line();
private final Label xAxisLabel;
private final Label yAxisLabel;
private final LinkedHashMap<XYChart<X, Y>, Function<Y, String>> charts;
private final Function<X, String> xValuesFormatter;
private final XYChartInfo chartInfo;
private final BooleanProperty isSelecting = new SimpleBooleanProperty(false);
private final Pane parent;
private final Rectangle selection = new Rectangle(0, 0, 0, 0);
private final BooleanProperty verticalMarkerVisible = new SimpleBooleanProperty();
private final BooleanProperty horizontalMarkerVisible = new SimpleBooleanProperty();
private final Map<XYChart<X, Y>, Property<Y>> currentYValues = new HashMap<>();
private final Property<X> currentXValue = new SimpleObjectProperty<>();
private final XYChart<X, Y> masterChart;
private final BooleanProperty mouseOverChart = new SimpleBooleanProperty(false);
private final BindingManager bindingManager = new BindingManager();
private final BooleanProperty displayFullHeightMarker = new SimpleBooleanProperty(false);
private Point2D selectionStart = new Point2D(-1, -1);
private Point2D mousePosition = new Point2D(-1, -1);
private Consumer<Map<XYChart<X, Y>, XYChartSelection<X, Y>>> selectionDoneEvent;
/**
* Initializes a new instance of the {@link XYChartCrosshair} class.
*
* @param charts a map of the {@link XYChart} to attach and their formatting function of the Y values.
* @param parent the parent node of the chart
* @param xValuesFormatter a function used to format the display of X values as strings
*/
public XYChartCrosshair(LinkedHashMap<XYChart<X, Y>, Function<Y, String>> charts, Pane parent, Function<X, String> xValuesFormatter) {
this.charts = charts;
applyStyle(this.verticalMarker);
applyStyle(this.horizontalMarker);
applyStyle(this.selection);
this.xAxisLabel = newAxisLabel();
this.yAxisLabel = newAxisLabel();
this.parent = parent;
parent.getChildren().addAll(xAxisLabel, yAxisLabel, verticalMarker, horizontalMarker, selection);
this.xValuesFormatter = xValuesFormatter;
masterChart = charts.keySet().stream().reduce((p, n) -> n).orElseThrow(() -> new IllegalStateException("Could not identify last element in chart linked hash map."));
this.chartInfo = new XYChartInfo(masterChart, parent);
masterChart.addEventHandler(MouseEvent.MOUSE_MOVED, bindingManager.registerHandler(this::handleMouseMoved));
masterChart.addEventHandler(MouseEvent.MOUSE_DRAGGED, bindingManager.registerHandler(this::handleMouseMoved));
masterChart.setOnMouseReleased(bindingManager.registerHandler(e -> {
if (isSelecting.get()) {
fireSelectionDoneEvent();
drawVerticalMarker();
drawHorizontalMarker();
}
isSelecting.set(false);
}));
isSelecting.addListener((observable, oldValue, newValue) -> {
logger.debug(() -> "observable=" + observable + " oldValue=" + oldValue + " newValue=" + newValue);
if (!oldValue && newValue) {
selectionStart = new Point2D(verticalMarker.getStartX(), horizontalMarker.getStartY());
}
drawSelection();
selection.setVisible(newValue);
});
horizontalMarkerVisible.addListener((observable, oldValue, newValue) -> {
drawHorizontalMarker();
if (!newValue && !verticalMarkerVisible.get()) {
isSelecting.set(false);
currentYValues.forEach((key, value) -> value.setValue(null));
}
});
verticalMarkerVisible.addListener((observable, oldValue, newValue) -> {
drawVerticalMarker();
if (!newValue && !horizontalMarkerVisible.get()) {
isSelecting.set(false);
currentXValue.setValue(null);
}
});
masterChart.setOnMouseExited(bindingManager.registerHandler(event -> mouseOverChart.set(false)));
masterChart.setOnMouseEntered(bindingManager.registerHandler(event -> mouseOverChart.set(true)));
bindingManager.bind(horizontalMarker.visibleProperty(), horizontalMarkerVisible.and(mouseOverChart));
bindingManager.bind(yAxisLabel.visibleProperty(), horizontalMarkerVisible.and(mouseOverChart));
bindingManager.bind(verticalMarker.visibleProperty(), verticalMarkerVisible.and(mouseOverChart));
bindingManager.bind(xAxisLabel.visibleProperty(), verticalMarkerVisible.and(mouseOverChart));
}
/**
* Gets the boolean property that tracks the visibility of the vertical marker of the crosshair
*
* @return the boolean property that tracks the visibility of the vertical marker of the crosshair
*/
public BooleanProperty verticalMarkerVisibleProperty() {
return verticalMarkerVisible;
}
/**
* Gets the boolean property that tracks the visibility of the horizontal marker of the crosshair
*
* @return the boolean property that tracks the visibility of the horizontal marker of the crosshair
*/
public BooleanProperty horizontalMarkerVisibleProperty() {
return horizontalMarkerVisible;
}
/**
* Returns true if the vertical marker is visible, false otherwise
*
* @return true if the vertical marker is visible, false otherwise
*/
public boolean isVerticalMarkerVisible() {
return verticalMarkerVisible.get();
}
/**
* Sets the visibility of the vertical marker
*
* @param verticalMarkerVisible the visibility of the vertical marker
*/
public void setVerticalMarkerVisible(boolean verticalMarkerVisible) {
this.verticalMarkerVisible.set(verticalMarkerVisible);
}
/**
* Returns true if the horizontal marker is visible, false otherwise
*
* @return true if the horizontal marker is visible, false otherwise
*/
public boolean isHorizontalMarkerVisible() {
return horizontalMarkerVisible.get();
}
/**
* Sets the visibility of the horizontal marker
*
* @param horizontalMarkerVisible the visibility of the horizontal marker
*/
public void setHorizontalMarkerVisible(boolean horizontalMarkerVisible) {
this.horizontalMarkerVisible.set(horizontalMarkerVisible);
}
/**
* Sets the action to be triggered when selection is complete
*
* @param action the action to be triggered when selection is complete
*/
public void onSelectionDone(Consumer<Map<XYChart<X, Y>, XYChartSelection<X, Y>>> action) {
selectionDoneEvent = action;
}
/**
* Returns the Y value for currently selected set of coordinates for the provided chart.
*
* @param chart The chart to retrieve the current Y value from.
* @return the Y value for currently selected set of coordinates for the provided chart.
*/
public Y getCurrentYValue(XYChart<X, Y> chart) {
return currentYValues.get(chart).getValue();
}
/**
* Retusn the X value for currently selected set of coordinates.
*
* @return the X value for currently selected set of coordinates.
*/
public X getCurrentXValue() {
return currentXValue.getValue();
}
/**
* The currentXValue property.
*
* @return the currentXValue property.
*/
public Property<X> currentXValueProperty() {
return currentXValue;
}
public boolean isMouseOverChart() {
return mouseOverChart.get();
}
public BooleanProperty mouseOverChartProperty() {
return mouseOverChart;
}
public boolean isDisplayFullHeightMarker() {
return displayFullHeightMarker.get();
}
public void setDisplayFullHeightMarker(boolean displayFullHeightMarker) {
this.displayFullHeightMarker.set(displayFullHeightMarker);
}
public BooleanProperty displayFullHeightMarkerProperty() {
return displayFullHeightMarker;
}
public void dispose() {
logger.debug(() -> "Disposing XYChartCrossHair " + this);
selectionDoneEvent = null;
bindingManager.close();
}
private void fireSelectionDoneEvent() {
if (selectionDoneEvent != null && (selection.getWidth() > 0 && selection.getHeight() > 0)) {
var s = new HashMap<XYChart<X, Y>, XYChartSelection<X, Y>>();
var plotArea = chartInfo.getPlotArea();
charts.forEach((c, f) -> {
s.put(c, new XYChartSelection<>(
getValueFromXcoord(selection.getX()),
getValueFromXcoord(selection.getX() + selection.getWidth()),
getValueFromYcoord(c, Math.min(plotArea.getMaxY(), (selection.getY() + selection.getHeight()))),
getValueFromYcoord(c, Math.max(plotArea.getMinY(), (selection.getY()))),
selection.getHeight() != plotArea.getHeight()));
});
selectionDoneEvent.accept(s);
}
}
private void drawHorizontalMarker() {
if (mousePosition.getY() < 0) {
return;
}
var plotArea = chartInfo.getPlotArea();
horizontalMarker.setStartX(plotArea.getMinX());
horizontalMarker.setEndX(plotArea.getMaxX());
horizontalMarker.setStartY(mousePosition.getY());
horizontalMarker.setEndY(horizontalMarker.getStartY());
yAxisLabel.setLayoutX(Math.min(parent.getWidth() - yAxisLabel.getWidth(), plotArea.getMaxX() + 5));
yAxisLabel.setLayoutY(Math.min(mousePosition.getY() + 5, plotArea.getMaxY() - yAxisLabel.getHeight()));
StringBuilder yAxisText = new StringBuilder();
charts.forEach((c, f) -> {
currentYValues.computeIfAbsent(c, (k) -> new SimpleObjectProperty<Y>()).setValue(getValueFromYcoord(c, mousePosition.getY()));
yAxisText.append(c.getYAxis().getLabel())
.append(": ")
.append(f.apply(currentYValues.get(c).getValue()))
.append("\n");
});
yAxisLabel.setText(yAxisText.toString());
}
private Y getValueFromYcoord(XYChart<X, Y> chart, double yPosition) {
double yStart = chart.getYAxis().getLocalToParentTransform().getTy();
double axisYRelativePosition = yPosition - getYShift(masterChart, parent) - (yStart * 1.5);
return chart.getYAxis().getValueForDisplay(axisYRelativePosition);
}
private X getValueFromXcoord(double xPosition) {
double xStart = masterChart.getXAxis().getLocalToParentTransform().getTx();
double axisXRelativeMousePosition = xPosition - getXShift(masterChart, parent) - xStart;
return masterChart.getXAxis().getValueForDisplay(axisXRelativeMousePosition - 5);
}
private void drawVerticalMarker() {
if (mousePosition.getX() < 0) {
return;
}
var plotArea = chartInfo.getPlotArea();
verticalMarker.setStartX(mousePosition.getX());
verticalMarker.setEndX(verticalMarker.getStartX());
if (displayFullHeightMarker.getValue()) {
verticalMarker.setStartY(2);
verticalMarker.setEndY(parent.getHeight() - 2);
} else {
verticalMarker.setStartY(plotArea.getMinY());
verticalMarker.setEndY(plotArea.getMaxY());
}
xAxisLabel.setLayoutY(plotArea.getMaxY() + 4);
xAxisLabel.setLayoutX(Math.min(mousePosition.getX() + 4, plotArea.getMaxX() - xAxisLabel.getWidth()));
currentXValue.setValue(getValueFromXcoord(mousePosition.getX()));
xAxisLabel.setText(xValuesFormatter.apply(currentXValue.getValue()));
}
public static Point2D getShift(Node descendant, Region ancestor) {
double retX = 0.0;
double retY = 0.0;
Node curr = descendant;
while (curr != ancestor) {
var t = curr.getLocalToParentTransform();
retX += ancestor.snapSpaceX(t.getTx());
retY += ancestor.snapSpaceX(t.getTy());
curr = curr.getParent();
if (curr == null)
throw new IllegalArgumentException("'descendant' Node is not a descendant of 'ancestor");
}
return new Point2D(retX, retY);
}
private void handleMouseMoved(MouseEvent event) {
Rectangle2D area = chartInfo.getPlotArea();
var shift = getShift(masterChart, parent);
double xPos = parent.snapSpaceX(event.getX()) + shift.getX();
double yPos = parent.snapSpaceX(event.getY()) + shift.getY();
mousePosition = new Point2D(Math.max(area.getMinX(), Math.min(area.getMaxX(), xPos)), Math.max(area.getMinY(), Math.min(area.getMaxY(), yPos)));
if (horizontalMarkerVisible.get()) {
drawHorizontalMarker();
}
if (verticalMarkerVisible.get()) {
drawVerticalMarker();
}
if (event.isPrimaryButtonDown() && (verticalMarkerVisible.get() || horizontalMarkerVisible.get())) {
isSelecting.set(true);
drawSelection();
}
}
private void drawSelection() {
if (selectionStart.getX() < 0 || selectionStart.getY() < 0) {
return;
}
if (horizontalMarkerVisible.get()) {
double height = horizontalMarker.getStartY() - selectionStart.getY();
selection.setY(height < 0 ? horizontalMarker.getStartY() : selectionStart.getY());
selection.setHeight(Math.abs(height));
} else {
selection.setY(verticalMarker.getStartY());
selection.setHeight(verticalMarker.getEndY() - verticalMarker.getStartY());
}
if (verticalMarkerVisible.get()) {
double width = verticalMarker.getStartX() - selectionStart.getX();
selection.setX(width < 0 ? verticalMarker.getStartX() : selectionStart.getX());
selection.setWidth(Math.abs(width));
} else {
selection.setX(horizontalMarker.getStartX());
selection.setWidth(horizontalMarker.getEndX() - horizontalMarker.getStartX());
}
}
private Label newAxisLabel() {
Label label = new Label("");
label.getStyleClass().add("crosshair-axis-label");
label.setMinSize(Label.USE_PREF_SIZE, Label.USE_PREF_SIZE);
label.setMouseTransparent(true);
label.setVisible(false);
return label;
}
private void applyStyle(Shape shape) {
shape.setMouseTransparent(true);
shape.setSmooth(false);
shape.setStrokeWidth(1.0);
shape.setVisible(false);
shape.setStrokeType(StrokeType.CENTERED);
shape.setStroke(Color.STEELBLUE);
Color fillColor = Color.LIGHTSTEELBLUE;
shape.setFill(new Color(
fillColor.getRed(),
fillColor.getGreen(),
fillColor.getBlue(),
SELECTION_OPACITY));
}
}
@@ -0,0 +1,152 @@
/*
* Copyright 2016-2019 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.javafx.charts;
/**
* An immutable representation of the selection of the portion of a chart.
*
* @author Frederic Thevenet
*/
public class XYChartSelection<X, Y> {
private static final int PRIME = 31;
private final X startX;
private final X endX;
private final Y startY;
private final Y endY;
private final boolean autoRangeY;
/**
* Returns the lower bound on the X axis of the selection
*
* @return the lower bound on the X axis of the selection
*/
public X getStartX() {
return startX;
}
/**
* Returns the upper bound on the X axis of the selection
*
* @return the upper bound on the X axis of the selection
*/
public X getEndX() {
return endX;
}
/**
* Returns the lower bound on the Y axis of the selection
*
* @return the lower bound on the Y axis of the selection
*/
public Y getStartY() {
return startY;
}
/**
* Returns the upper bound on the Y axis of the selection
*
* @return the upper bound on the Y axis of the selection
*/
public Y getEndY() {
return endY;
}
/**
* Return true is auto range is enabled on the Y axis, false otherwise.
*
* @return true is auto range is enabled on the Y axis, false otherwise.
*/
public boolean isAutoRangeY() {
return autoRangeY;
}
/**
* Copy constructor for the {@link XYChartSelection} class.
*
* @param selection the {@link XYChartSelection} to clone.
*/
public XYChartSelection(XYChartSelection<X, Y> selection) {
this.startX = selection.getStartX();
this.endX = selection.getEndX();
this.startY = selection.getStartY();
this.endY = selection.getEndY();
this.autoRangeY = selection.isAutoRangeY();
}
/**
* Initializes a new instance of the {@link XYChartSelection} class
*
* @param startX the lower bound on the X axis of the selection
* @param endX the upper bound on the X axis of the selection
* @param startY the lower bound on the Y axis of the selection
* @param endY the upper bound on the Y axis of the selection
* @param autoRangeY set to true to adapt the range on the Y axis automatically
*/
public XYChartSelection(X startX, X endX, Y startY, Y endY, boolean autoRangeY) {
this.startX = startX;
this.endX = endX;
this.startY = startY;
this.endY = endY;
this.autoRangeY = autoRangeY;
}
@Override
public int hashCode() {
int result = 1;
result = PRIME * result + ((this.getStartX() == null) ? 0 : this.getStartX().hashCode());
result = PRIME * result + ((this.getEndX() == null) ? 0 : this.getEndX().hashCode());
result = PRIME * result + ((this.getStartY() == null) ? 0 : this.getStartY().hashCode());
result = PRIME * result + ((this.getEndY() == null) ? 0 : this.getEndY().hashCode());
return result;
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj == null) {
return false;
}
if (getClass() != obj.getClass()) {
return false;
}
XYChartSelection other = (XYChartSelection) obj;
if (!evaluatesEquality(this.getEndX(), other.getEndX())) {
return false;
}
if (!evaluatesEquality(this.getStartX(), other.getStartX())) {
return false;
}
if (!evaluatesEquality(this.getEndY(), other.getEndY())) {
return false;
}
return evaluatesEquality(this.getStartY(), other.getStartY());
}
private boolean evaluatesEquality(Object o1, Object o2) {
if (o1 == null) {
return o2 == null;
} else return o1.equals(o2);
}
@Override
public String toString() {
return String.format("Selection{startX=%s, endX=%s, startY=%s, endY=%s}", getStartX(), getEndX(), getStartY(), getEndY());
}
}
@@ -0,0 +1,607 @@
/*
* The MIT License (MIT)
*
* Copyright (c) 2013, Christian Schudt
* Copyright (c) 2016-2020, Frederic Thevenet
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
package eu.binjr.common.javafx.charts;
import javafx.beans.property.*;
import javafx.scene.chart.Axis;
import javafx.util.StringConverter;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.FormatStyle;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/**
* An axis that displays date and time values.
* Tick labels are usually automatically set and calculated depending on the range unless you explicitly {@linkplain #setTickLabelFormatter(StringConverter) set an formatter}.
* You also have the chance to specify fix lower and upper bounds, otherwise they are calculated by your data.
* This code is a straight forward adaptation of the original DateTimeAxis by Christian Schudt and Diego Cirujano
* to use JAVA 8 {@link java.time.ZonedDateTime} instead of {@link java.util.Date}
*
* @author Christian Schudt
* @author Diego Cirujano
* @author Frederic Thevenet
*/
public final class ZonedDateTimeAxis extends Axis<ZonedDateTime> {
/**
* These property are used for animation.
*/
private final LongProperty currentLowerBound = new SimpleLongProperty(this, "currentLowerBound");
private final LongProperty currentUpperBound = new SimpleLongProperty(this, "currentUpperBound");
private final ObjectProperty<StringConverter<ZonedDateTime>> tickLabelFormatter = new ObjectPropertyBase<StringConverter<ZonedDateTime>>() {
@Override
protected void invalidated() {
if (!isAutoRanging()) {
invalidateRange();
requestAxisLayout();
}
}
@Override
public Object getBean() {
return ZonedDateTimeAxis.this;
}
@Override
public String getName() {
return "tickLabelFormatter";
}
};
private final Property<ZoneId> zoneId;
/**
* Stores the min and max date of the list of dates which is used.
* If {@link #Axis:autoRanging} is true, these values are used as lower and upper bounds.
*/
private ZonedDateTime minDate, maxDate;
private ObjectProperty<ZonedDateTime> lowerBound = new ObjectPropertyBase<ZonedDateTime>() {
@Override
protected void invalidated() {
if (!isAutoRanging()) {
invalidateRange();
requestAxisLayout();
}
}
@Override
public Object getBean() {
return ZonedDateTimeAxis.this;
}
@Override
public String getName() {
return "lowerBound";
}
};
private ObjectProperty<ZonedDateTime> upperBound = new ObjectPropertyBase<ZonedDateTime>() {
@Override
protected void invalidated() {
if (!isAutoRanging()) {
invalidateRange();
requestAxisLayout();
}
}
@Override
public Object getBean() {
return ZonedDateTimeAxis.this;
}
@Override
public String getName() {
return "upperBound";
}
};
private Object currentAnimationID;
private Interval actualInterval = Interval.DECADE;
private ZoneOffset zoneOffset = ZoneOffset.UTC;
/**
* Default constructor. By default the lower and upper bound are calculated by the data.
*/
public ZonedDateTimeAxis() {
this.zoneId = new SimpleObjectProperty<>(ZoneId.systemDefault());
}
public ZonedDateTimeAxis(ZoneId zoneId) {
this.zoneId = new SimpleObjectProperty<>(zoneId);
}
/**
* Constructs a date axis with fix lower and upper bounds.
*
* @param lowerBound The lower bound.
* @param upperBound The upper bound.
*/
public ZonedDateTimeAxis(ZonedDateTime lowerBound, ZonedDateTime upperBound) {
this(lowerBound.getZone());
setAutoRanging(false);
setLowerBound(lowerBound);
setUpperBound(upperBound);
}
/**
* Constructs a date axis with a label and fix lower and upper bounds.
*
* @param axisLabel The label for the axis.
* @param lowerBound The lower bound.
* @param upperBound The upper bound.
*/
public ZonedDateTimeAxis(String axisLabel, ZonedDateTime lowerBound, ZonedDateTime upperBound) {
this(lowerBound, upperBound);
setLabel(axisLabel);
}
@Override
public void invalidateRange(List<ZonedDateTime> list) {
super.invalidateRange(list);
Collections.sort(list);
if (list.isEmpty()) {
minDate = maxDate = ZonedDateTime.now(zoneId.getValue());
} else if (list.size() == 1) {
minDate = maxDate = list.get(0);
} else if (list.size() > 1) {
minDate = list.get(0);
maxDate = list.get(list.size() - 1);
}
}
@Override
protected Object autoRange(double length) {
if (isAutoRanging()) {
return new Object[]{minDate, maxDate};
} else {
if (getLowerBound() == null || getUpperBound() == null) {
throw new IllegalArgumentException("If autoRanging is false, a lower and upper bound must be set.");
}
return getRange();
}
}
@Override
protected void setRange(Object range, boolean animating) {
Object[] r = (Object[]) range;
ZonedDateTime oldLowerBound = getLowerBound();
ZonedDateTime oldUpperBound = getUpperBound();
ZonedDateTime lower = (ZonedDateTime) r[0];
ZonedDateTime upper = (ZonedDateTime) r[1];
setLowerBound(lower);
setUpperBound(upper);
currentLowerBound.set(getLowerBound().toInstant().toEpochMilli());
currentUpperBound.set(getUpperBound().toInstant().toEpochMilli());
}
@Override
protected Object getRange() {
return new Object[]{getLowerBound(), getUpperBound()};
}
@Override
public double getZeroPosition() {
return 0;
}
@Override
public double getDisplayPosition(ZonedDateTime date) {
final double length = getSide().isHorizontal() ? getWidth() : getHeight();
// Get the difference between the max and min date.
double diff = currentUpperBound.get() - currentLowerBound.get();
// Get the actual range of the visible area.
// The minimal date should start at the zero position, that's why we subtract it.
double range = length - getZeroPosition();
// Then get the difference from the actual date to the min date and divide it by the total difference.
// We get a value between 0 and 1, if the date is within the min and max date.
double d = (date.toInstant().toEpochMilli() - currentLowerBound.get()) / diff;
// Multiply this percent value with the range and add the zero offset.
if (getSide().isVertical()) {
return getHeight() - d * range + getZeroPosition();
} else {
return d * range + getZeroPosition();
}
}
@Override
public ZonedDateTime getValueForDisplay(double displayPosition) {
final double length = getSide().isHorizontal() ? getWidth() : getHeight();
// Get the difference between the max and min date.
double diff = currentUpperBound.get() - currentLowerBound.get();
// Get the actual range of the visible area.
// The minimal date should start at the zero position, that's why we subtract it.
double range = length - getZeroPosition();
if (getSide().isVertical()) {
long v = Math.round((displayPosition - getZeroPosition() - getHeight()) / -range * diff + currentLowerBound.get());
return ZonedDateTime.ofInstant(Instant.ofEpochMilli(v), zoneId.getValue());
} else {
long v = Math.round((displayPosition - getZeroPosition()) / range * diff + currentLowerBound.get());
return ZonedDateTime.ofInstant(Instant.ofEpochMilli(v), zoneId.getValue());
}
}
@Override
public boolean isValueOnAxis(ZonedDateTime date) {
return date.toInstant().toEpochMilli() > currentLowerBound.get() && date.toInstant().toEpochMilli() < currentUpperBound.get();
}
@Override
public double toNumericValue(ZonedDateTime date) {
return date.toInstant().toEpochMilli();
}
@Override
public ZonedDateTime toRealValue(double v) {
return ZonedDateTime.ofInstant(Instant.ofEpochMilli(Math.round(v)), zoneId.getValue());
}
@Override
protected List<ZonedDateTime> calculateTickValues(double v, Object range) {
Object[] r = (Object[]) range;
ZonedDateTime lower = (ZonedDateTime) r[0];
ZonedDateTime upper = (ZonedDateTime) r[1];
List<ZonedDateTime> dateList = new ArrayList<ZonedDateTime>();
// The preferred gap which should be between two tick marks.
double averageTickGap = 100;
double averageTicks = v / averageTickGap;
// Starting with the greatest unit, add one of each calendar unit.
int i = 0;
while (i < Interval.values().length && dateList.size() <= averageTicks) {
Interval interval = Interval.values()[i];
ZonedDateTime currentLower = ZonedDateTime.from(lower);
ZonedDateTime currentUpper = ZonedDateTime.from(upper);
dateList.clear();
actualInterval = interval;
// Loop as long we exceeded the upper bound.
while (currentLower.toInstant().toEpochMilli() <= currentUpper.toInstant().toEpochMilli()) {
dateList.add(currentLower);
currentLower = currentLower.plus(interval.amount, interval.unit);
}
i++;
}
dateList.add(upper);
List<ZonedDateTime> evenDateList = makeDatesEven(dateList);
// If there are at least three dates, check if the gap between the lower date and the second date is at least half the gap of the second and third date.
// Do the same for the upper bound.
// If gaps between dates are to small, remove one of them.
// This can occur, e.g. if the lower bound is 25.12.2013 and years are shown. Then the next year shown would be 2014 (01.01.2014) which would be too narrow to 25.12.2013.
if (evenDateList.size() > 2) {
ZonedDateTime secondDate = evenDateList.get(1);
ZonedDateTime thirdDate = evenDateList.get(2);
ZonedDateTime lastDate = evenDateList.get(dateList.size() - 2);
ZonedDateTime previousLastDate = evenDateList.get(dateList.size() - 3);
// If the second date is too near by the lower bound, remove it.
// if (secondDate.toInstant().toEpochMilli() - lower.toInstant().toEpochMilli() < (thirdDate.toInstant().toEpochMilli() - secondDate.toInstant().toEpochMilli()) / 2) {
// evenDateList.remove(secondDate);
// }
// If difference from the upper bound to the last date is less than the half of the difference of the previous two dates,
// we better remove the last date, as it comes to close to the upper bound.
if (upper.toInstant().toEpochMilli() - lastDate.toInstant().toEpochMilli() <
(lastDate.toInstant().toEpochMilli() - previousLastDate.toInstant().toEpochMilli()) / 2) {
evenDateList.remove(lastDate);
}
}
return evenDateList;
}
@Override
protected void layoutChildren() {
if (!isAutoRanging()) {
currentLowerBound.set(getLowerBound().toInstant().toEpochMilli());
currentUpperBound.set(getUpperBound().toInstant().toEpochMilli());
}
super.layoutChildren();
}
@Override
protected String getTickMarkLabel(ZonedDateTime date) {
StringConverter<ZonedDateTime> converter = getTickLabelFormatter();
if (converter != null) {
return converter.toString(date);
}
DateTimeFormatter formatter;
if (actualInterval.unit == ChronoUnit.YEARS && date.getMonthValue() == 1 && date.getDayOfMonth() == 1) {
formatter = DateTimeFormatter.ofPattern("yyyy");
} else if (actualInterval.unit == ChronoUnit.MONTHS && date.getDayOfMonth() == 1) {
formatter = DateTimeFormatter.ofPattern("MMM yy");
} else {
switch (actualInterval.unit) {
case DAYS:
case WEEKS:
default:
formatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT);
break;
case HOURS:
case MINUTES:
formatter = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT);
break;
case SECONDS:
formatter = DateTimeFormatter.ofLocalizedTime(FormatStyle.MEDIUM);
break;
case MILLIS:
formatter = DateTimeFormatter.ofPattern("HH:mm:ss.SSS");
break;
}
}
return formatter.withZone(zoneId.getValue()).format(date);
}
/**
* Makes dates even, in the sense of that years always begin in January, months always begin on the 1st and days always at midnight.
*
* @param dates The list of dates.
* @return The new list of dates.
*/
private List<ZonedDateTime> makeDatesEven(List<ZonedDateTime> dates) {
// If the dates contain more dates than just the lower and upper bounds, make the dates in between even.
if (dates.size() > 2) {
List<ZonedDateTime> evenDates = new ArrayList<ZonedDateTime>();
// For each unit, modify the date slightly by a few millis, to make sure they are different days.
// This is because Axis stores each value and won't update the tick labels, if the value is already known.
// This happens if you display days and then add a date many years in the future the tick label will still be displayed as day.
for (int i = 0; i < dates.size(); i++) {
ZonedDateTime date = dates.get(i);
ZonedDateTime normalizedDate = date;
boolean isFirstOrLast = i != 0 && i != dates.size() - 1;
switch (actualInterval.unit) {
case YEARS:
normalizedDate = ZonedDateTime.of(
date.getYear(),
isFirstOrLast ? 1 : date.getMonthValue(),
isFirstOrLast ? 1 : date.getDayOfMonth(),
0, 0, 0, 6, dates.get(i).getZone());
break;
case MONTHS:
normalizedDate = ZonedDateTime.of(
date.getYear(),
date.getMonthValue(),
isFirstOrLast ? 1 : date.getDayOfMonth(),
0, 0, 0, 5, dates.get(i).getZone());
break;
case WEEKS:
normalizedDate = ZonedDateTime.of(
date.getYear(),
date.getMonthValue(),
date.getDayOfMonth(),
0, 0, 0, 4, dates.get(i).getZone());
break;
case DAYS:
normalizedDate = ZonedDateTime.of(
date.getYear(),
date.getMonthValue(),
date.getDayOfMonth(),
0, 0, 0, 3, dates.get(i).getZone());
break;
case HOURS:
normalizedDate = ZonedDateTime.of(
date.getYear(),
date.getMonthValue(),
date.getDayOfMonth(),
date.getHour(),
isFirstOrLast ? 0 : date.getMinute(),
isFirstOrLast ? 0 : date.getSecond(),
2, dates.get(i).getZone());
break;
case MINUTES:
normalizedDate = ZonedDateTime.of(
date.getYear(),
date.getMonthValue(),
date.getDayOfMonth(),
date.getHour(),
date.getMinute(),
isFirstOrLast ? 0 : date.getSecond(),
1, dates.get(i).getZone());
break;
case SECONDS:
normalizedDate = ZonedDateTime.of(
date.getYear(),
date.getMonthValue(),
date.getDayOfMonth(),
date.getHour(),
date.getMinute(),
date.getSecond(),
1,
dates.get(i).getZone());
break;
case MILLIS:
normalizedDate = ZonedDateTime.of(
date.getYear(),
date.getMonthValue(),
date.getDayOfMonth(),
date.getHour(),
date.getMinute(),
date.getSecond(),
(date.getNano() / 1000) * 1000,
dates.get(i).getZone());
break;
}
evenDates.add(normalizedDate);
}
return evenDates;
} else {
return dates;
}
}
/**
* Gets the lower bound of the axis.
*
* @return The property.
* @see #getLowerBound()
*/
public final ObjectProperty<ZonedDateTime> lowerBoundProperty() {
return lowerBound;
}
/**
* Gets the lower bound of the axis.
*
* @return The lower bound.
* @see #lowerBoundProperty()
*/
public final ZonedDateTime getLowerBound() {
return lowerBound.get();
}
/**
* Sets the lower bound of the axis.
*
* @param date The lower bound date.
* @see #lowerBoundProperty()
*/
public final void setLowerBound(ZonedDateTime date) {
lowerBound.set(date);
}
/**
* Gets the upper bound of the axis.
*
* @return The property.
* @see #getUpperBound() ()
*/
public final ObjectProperty<ZonedDateTime> upperBoundProperty() {
return upperBound;
}
/**
* Gets the upper bound of the axis.
*
* @return The upper bound.
* @see #upperBoundProperty()
*/
public final ZonedDateTime getUpperBound() {
return upperBound.get();
}
/**
* Sets the upper bound of the axis.
*
* @param date The upper bound date.
* @see #upperBoundProperty() ()
*/
public final void setUpperBound(ZonedDateTime date) {
upperBound.set(date);
}
/**
* Gets the tick label formatter for the ticks.
*
* @return The converter.
*/
public final StringConverter<ZonedDateTime> getTickLabelFormatter() {
return tickLabelFormatter.getValue();
}
/**
* Sets the tick label formatter for the ticks.
*
* @param value The converter.
*/
public final void setTickLabelFormatter(StringConverter<ZonedDateTime> value) {
tickLabelFormatter.setValue(value);
}
/**
* Gets the tick label formatter for the ticks.
*
* @return The property.
*/
public final ObjectProperty<StringConverter<ZonedDateTime>> tickLabelFormatterProperty() {
return tickLabelFormatter;
}
public ZoneId getZoneId() {
return zoneId.getValue();
}
public Property<ZoneId> zoneIdProperty() {
return zoneId;
}
/**
* The intervals, which are used for the tick labels. Beginning with the largest unit, the axis tries to calculate the tick values for this unit.
* If a smaller unit is better suited for, that one is taken.
*/
private enum Interval {
DECADE(ChronoUnit.YEARS, 10),
YEAR(ChronoUnit.YEARS, 1),
MONTH_6(ChronoUnit.MONTHS, 6),
MONTH_3(ChronoUnit.MONTHS, 3),
MONTH_1(ChronoUnit.MONTHS, 1),
WEEK(ChronoUnit.WEEKS, 1),
DAY(ChronoUnit.DAYS, 1),
HOUR_12(ChronoUnit.HOURS, 12),
HOUR_6(ChronoUnit.HOURS, 6),
HOUR_3(ChronoUnit.HOURS, 3),
HOUR_1(ChronoUnit.HOURS, 1),
MINUTE_30(ChronoUnit.MINUTES, 30),
MINUTE_10(ChronoUnit.MINUTES, 10),
MINUTE_5(ChronoUnit.MINUTES, 5),
MINUTE_1(ChronoUnit.MINUTES, 1),
SECOND_15(ChronoUnit.SECONDS, 15),
SECOND_5(ChronoUnit.SECONDS, 5),
SECOND_1(ChronoUnit.SECONDS, 1),
MILLISECOND_500(ChronoUnit.MILLIS, 500),
MILLISECOND_100(ChronoUnit.MILLIS, 100),
MILLISECOND_10(ChronoUnit.MILLIS, 10),
MILLISECOND_1(ChronoUnit.MILLIS, 1);
private final int amount;
private final transient ChronoUnit unit;
Interval(ChronoUnit interval, int amount) {
this.unit = interval;
this.amount = amount;
}
}
}
@@ -0,0 +1,94 @@
/*
* Copyright 2017-2021 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.javafx.controls;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.TableCell;
import javafx.scene.control.TableColumn;
import javafx.scene.text.TextAlignment;
import javafx.util.Callback;
/**
* A table cell factory aiming to format cells containing decimal numbers.
*
* @param <S> The type of the TableView generic type
* @param <T> The type of the item contained within the Cell
* @author Frederic Thevenet
*/
public class AlignedTableCellFactory<S, T> implements Callback<TableColumn<S, T>, TableCell<S, T>> {
private TextAlignment alignment;
/**
* Gets the alignment of text in the cell
*
* @return the alignment of text in the cell
*/
public TextAlignment getAlignment() {
return alignment;
}
/**
* Sets the alignment of text in the cell
*
* @param alignment the alignment of text in the cell
*/
public void setAlignment(TextAlignment alignment) {
this.alignment = alignment;
}
@Override
@SuppressWarnings("unchecked")
public TableCell<S, T> call(TableColumn<S, T> p) {
TableCell<S, T> cell = new TableCell<S, T>() {
@Override
public void updateItem(Object item, boolean empty) {
if (item == getItem()) {
return;
}
super.updateItem((T) item, empty);
if (item == null) {
super.setText(null);
super.setGraphic(null);
} else if (item instanceof Node node) {
super.setText(null);
super.setGraphic(node);
} else {
super.setText(item.toString());
super.setGraphic(null);
}
}
};
cell.setTextAlignment(alignment);
switch (alignment) {
case CENTER:
cell.setAlignment(Pos.CENTER);
break;
case RIGHT:
cell.setAlignment(Pos.CENTER_RIGHT);
break;
default:
cell.setAlignment(Pos.CENTER_LEFT);
break;
}
return cell;
}
}
@@ -0,0 +1,197 @@
/*
* Copyright 2022 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.javafx.controls;
import eu.binjr.core.dialogs.Dialogs;
import javafx.animation.PauseTransition;
import javafx.beans.property.*;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.css.PseudoClass;
import javafx.geometry.Pos;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.StackPane;
import javafx.util.Duration;
import java.util.Objects;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
public class BinjrLoadingPane extends StackPane {
private static final PseudoClass LOADING_PSEUDO_CLASS = PseudoClass.getPseudoClass("loading");
private static final int NB_FRAMES = 5;
private final AtomicBoolean rendering = new AtomicBoolean(false);
private final ObjectProperty<AnimationSize> animationSize = new SimpleObjectProperty<>(AnimationSize.LARGE);
private final BooleanProperty loading = new BooleanPropertyBase(false) {
public void invalidated() {
pseudoClassStateChanged(LOADING_PSEUDO_CLASS, get());
BinjrLoadingPane.this.imageView.setVisible(get());
}
@Override
public Object getBean() {
return BinjrLoadingPane.this;
}
@Override
public String getName() {
return "loading";
}
};
private final DoubleProperty targetFps;
private final IntegerProperty initialDelayMs;
private final ScheduledExecutorService scheduler;
private final ImageView imageView = new ImageView();
private final Image[] frames = new Image[NB_FRAMES];
private int frameIndex;
public BinjrLoadingPane() {
this(2, 500);
}
public BinjrLoadingPane(double targetFps, int initialDelayMs) {
super();
this.targetFps = new SimpleDoubleProperty(targetFps);
this.initialDelayMs = new SimpleIntegerProperty(initialDelayMs);
scheduler = Executors.newScheduledThreadPool(1, r -> {
Thread thread = new Thread(r);
thread.setDaemon(true);
return thread;
});
animationSize.addListener((observable) -> {
imageView.setFitWidth(animationSize.getValue().getSize());
imageView.setFitHeight(animationSize.getValue().getSize());
});
// Setup animation
imageView.getStyleClass().add("binjr-logo-view");
imageView.setFitWidth(animationSize.getValue().getSize());
imageView.setFitHeight(animationSize.getValue().getSize());
// imageView.setOpacity(.6);
this.getChildren().add(imageView);
this.frameIndex = 0;
for (int i = 0; i < NB_FRAMES; i++) {
this.frames[i] = new Image(Objects.requireNonNull(getClass().getResourceAsStream("/eu/binjr/images/loading_" + i + ".png")));
}
this.setAlignment(Pos.CENTER);
this.visibleProperty().addListener((observable, oldValue, newValue) -> {
if (newValue) {
var delay = new PauseTransition(Duration.millis(this.initialDelayMs.get()));
delay.setOnFinished((event) -> {
// Only start animation if masker is still visible after delay
if (this.isVisible()) {
startAnimation();
}
});
delay.playFromStart();
}
});
startAnimation();
this.getStyleClass().setAll("binjr-loading-pane");
}
public double getTargetFps() {
return this.targetFps.get();
}
public DoubleProperty targetFpsProperty() {
return this.targetFps;
}
public void setTargetFps(double targetFps) {
this.targetFps.set(targetFps);
}
public int getInitialDelayMs() {
return initialDelayMs.get();
}
public IntegerProperty initialDelayMsProperty() {
return initialDelayMs;
}
public void setInitialDelayMs(int initialDelayMs) {
this.initialDelayMs.set(initialDelayMs);
}
private void render() {
if (rendering.compareAndSet(false, true)) {
try {
Dialogs.runOnFXThread(() -> {
imageView.setImage(frames[frameIndex]);
if (++frameIndex >= NB_FRAMES) {
frameIndex = 0;
}
});
} finally {
rendering.set(false);
}
}
}
private void startAnimation() {
this.loading.set(true);
var task = scheduler.scheduleAtFixedRate(this::render, 0, Math.round(1000.0 / targetFps.get()), TimeUnit.MILLISECONDS);
ChangeListener<Boolean> stopWhenNotVisible = new ChangeListener<>() {
@Override
public void changed(ObservableValue<? extends Boolean> observable, Boolean oldValue, Boolean newValue) {
if (!newValue) {
BinjrLoadingPane.this.visibleProperty().removeListener(this);
BinjrLoadingPane.this.loading.set(false);
task.cancel(true);
frameIndex = 0;
}
}
};
this.visibleProperty().addListener(stopWhenNotVisible);
}
public void setAnimationSize(AnimationSize value){
this.animationSize.setValue(value);
}
public AnimationSize getAnimationSize() {
return animationSize.getValue();
}
public Property<AnimationSize> animationSizeProperty() {
return animationSize;
}
public enum AnimationSize {
SMALL(32),
MEDIUM(64.0),
LARGE(128.0),
XL(256.0);
private final double size;
AnimationSize(double size) {
this.size = size;
}
public double getSize() {
return size;
}
}
}
@@ -0,0 +1,64 @@
/*
* Copyright 2017-2018 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.javafx.controls;
import eu.binjr.common.javafx.bindings.BindingManager;
import javafx.scene.control.*;
import javafx.scene.paint.Color;
/**
* A {@link TableCell} implementation that shows a {@link ColorPicker}
*
* @param <T> The type of the TableView generic type
* @author Frederic Thevenet
*/
public class ColorTableCell<T> extends TableCell<T, Color> {
private final ColorPicker colorPicker;
public ColorTableCell(TableColumn<T, Color> column, BindingManager bindingManager) {
colorPicker = new ColorPicker();
colorPicker.getStyleClass().add("button");
colorPicker.getStyleClass().add("borderless-color-picker");
bindingManager.bind(colorPicker.editableProperty(), column.editableProperty());
bindingManager.bind(colorPicker.disableProperty(), column.editableProperty().not());
colorPicker.setOnShowing(bindingManager.registerHandler(event -> {
TableView<T> tableView = getTableView();
tableView.getSelectionModel().clearSelection();
tableView.getSelectionModel().select(getTableRow().getIndex());
tableView.edit(tableView.getSelectionModel().getSelectedIndex(), column);
}));
colorPicker.setOnAction(bindingManager.registerHandler(event -> {
if (isEditing()) {
commitEdit(colorPicker.getValue());
}
}));
setContentDisplay(ContentDisplay.GRAPHIC_ONLY);
}
@Override
protected void updateItem(Color item, boolean empty) {
super.updateItem(item, empty);
setText(null);
if (empty) {
setGraphic(null);
} else {
colorPicker.setValue(item);
setGraphic(this.colorPicker);
}
}
}
@@ -0,0 +1,56 @@
/*
* Copyright 2017-2018 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.javafx.controls;
import javafx.scene.control.ContextMenu;
import javafx.scene.control.TableCell;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TreeCell;
import javafx.util.Callback;
/**
* An implementation of {@link TreeCell} with a context menu attached
*
* @author Frederic Thevenet
*/
public class ContextMenuTableViewCell<S, T> extends TableCell<S, T> {
public static <S, T> Callback<TableColumn<S, T>, TableCell<S, T>> forTableColumn(ContextMenu contextMenu) {
return forTableColumn(contextMenu, null);
}
public static <S, T> Callback<TableColumn<S, T>, TableCell<S, T>> forTableColumn(final ContextMenu contextMenu, final Callback<TableColumn<S, T>, TableCell<S, T>> cellFactory) {
return column -> {
TableCell<S, T> cell;
if (cellFactory == null) {
cell = new TableCell<S, T>();
cell.itemProperty().addListener((observable, oldValue, newValue) -> {
cell.setText(newValue == null ? null : newValue.toString());
});
} else {
cell = cellFactory.call(column);
}
cell.setContextMenu(contextMenu);
return cell;
};
}
public ContextMenuTableViewCell(ContextMenu contextMenu) {
setContextMenu(contextMenu);
}
}
@@ -0,0 +1,52 @@
/*
* Copyright 2017-2018 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.javafx.controls;
import javafx.scene.control.ContextMenu;
import javafx.scene.control.TreeCell;
import javafx.scene.control.TreeView;
import javafx.util.Callback;
/**
* An implementation of {@link TreeCell} with a context menu attached
*/
public class ContextMenuTreeViewCell<T> extends TreeCell<T> {
public static <T> Callback<TreeView<T>, TreeCell<T>> forTreeView(ContextMenu contextMenu) {
return forTreeView(contextMenu, null);
}
public static <T> Callback<TreeView<T>, TreeCell<T>> forTreeView(final ContextMenu contextMenu, final Callback<TreeView<T>, TreeCell<T>> cellFactory) {
return treeView -> {
TreeCell<T> cell;
if (cellFactory == null) {
cell = new TreeCell<T>();
cell.itemProperty().addListener((observable, oldValue, newValue) -> cell.setText(newValue == null ? null : newValue.toString()));
} else {
cell = cellFactory.call(treeView);
}
cell.setContextMenu(contextMenu);
return cell;
};
}
public ContextMenuTreeViewCell(ContextMenu contextMenu) {
setContextMenu(contextMenu);
}
}
@@ -0,0 +1,52 @@
/*
* Copyright 2017-2021 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.javafx.controls;
import eu.binjr.core.data.async.AsyncTaskManager;
import javafx.concurrent.Task;
import javafx.util.Duration;
/**
* Schedule a tasks to start after the specified delay
*
* @author Frederic Thevenet
*/
public class DelayedAction {
private final Task<Object> delayedTask;
public DelayedAction(Runnable action, Duration delay) {
delayedTask = new Task<>() {
@Override
protected Object call() throws Exception {
Thread.sleep(((long) delay.toMillis()));
return null;
}
};
delayedTask.setOnSucceeded(event -> action.run());
}
public void submit() {
AsyncTaskManager.getInstance().submit(delayedTask);
}
public static void run(Runnable action, Duration delay) {
new DelayedAction(action, delay).submit();
}
}
@@ -0,0 +1,293 @@
/*
* Copyright 2017-2021 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.javafx.controls;
import javafx.animation.*;
import javafx.beans.property.*;
import javafx.css.PseudoClass;
import javafx.geometry.Side;
import javafx.scene.Node;
import javafx.scene.layout.AnchorPane;
import javafx.util.Duration;
/**
* An {@link AnchorPane} that can slide in or out from a side of its attached window.
*
* @author Frederic Thevenet
*/
public class DrawerPane extends AnchorPane {
private static PseudoClass EXPANDED_PSEUDO_CLASS = PseudoClass.getPseudoClass("expanded");
private DoubleProperty collapsedWidth = new SimpleDoubleProperty(48);
private DoubleProperty expandedWidth = new SimpleDoubleProperty(200);
private IntegerProperty animationDuration = new SimpleIntegerProperty(50);
private Property<PaneAnimation> animation = new SimpleObjectProperty<>(PaneAnimation.NONE);
private Timeline showTimeline;
private Timeline hideTimeline;
private DoubleProperty commandBarWidth = new SimpleDoubleProperty(0.2);
private Property<Side> side = new SimpleObjectProperty<>(Side.LEFT);
private Property<Node> sibling = new SimpleObjectProperty<>();
public enum PaneAnimation {
NONE,
GROW,
SLIDE
}
private BooleanProperty expanded = new BooleanPropertyBase(false) {
public void invalidated() {
pseudoClassStateChanged(EXPANDED_PSEUDO_CLASS, get());
}
@Override
public Object getBean() {
return DrawerPane.this;
}
@Override
public String getName() {
return "expanded";
}
};
public DrawerPane() {
expanded.addListener((observable, oldValue, newValue) -> {
switch (animation.getValue()) {
case NONE:
movePane(newValue);
break;
case GROW:
if (newValue)
growPane();
else
shrinkPane();
break;
case SLIDE:
slidePane(newValue);
break;
}
});
commandBarWidth.addListener((observable, oldValue, newValue) -> {
doCommandBarResize(newValue.doubleValue());
});
}
private void movePane(boolean show) {
Double value = show ? expandedWidth.getValue() : collapsedWidth.getValue();
if (side.getValue().isVertical()) {
this.setTranslateX(value);
} else {
this.setTranslateY(value);
}
switch (side.getValue()) {
case LEFT:
this.setTranslateX(value);
break;
case RIGHT:
this.setTranslateX(-1 * value);
break;
case TOP:
this.setTranslateY(value);
break;
case BOTTOM:
this.setTranslateY(-1 * value);
break;
}
anchorNode(sibling.getValue(), value);
}
private void growPane() {
if (hideTimeline != null) {
hideTimeline.stop();
}
if (showTimeline != null && showTimeline.getStatus() == Animation.Status.RUNNING) {
return;
}
Duration duration = Duration.millis(animationDuration.getValue());
KeyFrame keyFrame = new KeyFrame(duration, new KeyValue(commandBarWidth, expandedWidth.getValue()));
showTimeline = new Timeline(keyFrame);
if (sibling.getValue() != null) {
showTimeline.setOnFinished(event -> new DelayedAction(
() -> anchorNode(sibling.getValue(), expandedWidth.getValue()),
Duration.millis(50)).submit());
}
showTimeline.play();
this.expanded.setValue(true);
}
private void shrinkPane() {
if (showTimeline != null) {
showTimeline.stop();
}
if (hideTimeline != null && hideTimeline.getStatus() == Animation.Status.RUNNING) {
return;
}
if (commandBarWidth.get() <= collapsedWidth.getValue()) {
return;
}
Duration duration = Duration.millis(animationDuration.getValue());
hideTimeline = new Timeline(new KeyFrame(duration, new KeyValue(commandBarWidth, collapsedWidth.getValue())));
anchorNode(sibling.getValue(), collapsedWidth.getValue());
hideTimeline.play();
this.expanded.setValue(false);
}
private void doCommandBarResize(double v) {
if (side.getValue().isVertical()) {
this.setMinWidth(v);
} else {
this.setMinHeight(v);
}
}
private void slidePane(boolean show) {
Double value = show ? expandedWidth.getValue() : collapsedWidth.getValue();
TranslateTransition transition = new TranslateTransition(new Duration(animationDuration.get()), this);
switch (side.getValue()) {
case LEFT:
transition.setToX(value);
break;
case RIGHT:
transition.setToX(-1 * value);
break;
case TOP:
transition.setToY(value);
break;
case BOTTOM:
transition.setToY(-1 * value);
break;
}
transition.play();
if (show) {
transition.setOnFinished(event -> new DelayedAction(() -> anchorNode(sibling.getValue(), value), Duration.millis(50)).submit());
} else {
anchorNode(sibling.getValue(), value);
}
}
private void anchorNode(Node node, double distance) {
if (node != null) {
switch (getSide()) {
case LEFT:
AnchorPane.setLeftAnchor(node, distance);
break;
case BOTTOM:
AnchorPane.setBottomAnchor(node, distance);
break;
case RIGHT:
AnchorPane.setRightAnchor(node, distance);
break;
case TOP:
AnchorPane.setTopAnchor(node, distance);
break;
}
}
}
public Side getSide() {
return side.getValue();
}
public Property<Side> sideProperty() {
return side;
}
public void setSide(Side side) {
this.side.setValue(side);
}
public double getCollapsedWidth() {
return collapsedWidth.get();
}
public DoubleProperty collapsedWidthProperty() {
return collapsedWidth;
}
public void setCollapsedWidth(double collapsedWidth) {
this.collapsedWidth.set(collapsedWidth);
}
public double getExpandedWidth() {
return expandedWidth.get();
}
public DoubleProperty expandedWidthProperty() {
return expandedWidth;
}
public void setExpandedWidth(double expandedWidth) {
this.expandedWidth.set(expandedWidth);
}
public int getAnimationDuration() {
return animationDuration.get();
}
public IntegerProperty animationDurationProperty() {
return animationDuration;
}
public void setAnimationDuration(int animationDuration) {
this.animationDuration.set(animationDuration);
}
public PaneAnimation getAnimation() {
return animation.getValue();
}
public Property<PaneAnimation> animationProperty() {
return animation;
}
public void setAnimation(PaneAnimation animation) {
this.animation.setValue(animation);
}
public Node getSibling() {
return sibling.getValue();
}
public Property<Node> siblingProperty() {
return sibling;
}
public void setSibling(Node sibling) {
this.sibling.setValue(sibling);
}
public boolean isExpanded() {
return expanded.get();
}
public ReadOnlyBooleanProperty expandedProperty() {
return ReadOnlyBooleanProperty.readOnlyBooleanProperty(expanded);
}
public void expand() {
this.expanded.set(true);
}
public void collapse() {
this.expanded.set(false);
}
public void toggle() {
this.expanded.set(!expanded.getValue());
}
}
@@ -0,0 +1,154 @@
/*
* Copyright 2017-2023 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.javafx.controls;
import com.sun.istack.Nullable;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.Property;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.control.TextField;
import javafx.scene.layout.HBox;
/**
* A Tab control that can be renamed on double-click
*
* @author Frederic Thevenet
*/
public class EditableTab extends Tab {
private final Label titleLabel;
private final BooleanProperty editable = new SimpleBooleanProperty(false);
public String getName() {
return titleLabel.textProperty().getValue();
}
private final TextField textField = new TextField();
public Property<String> nameProperty() {
return titleLabel.textProperty();
}
public void setName(String tabName) {
titleLabel.textProperty().setValue(tabName);
}
public EditableTab(String text) {
this(text, (ButtonBase) null);
}
/**
* Initializes a new instance of the {@link EditableTab} instance.
*
* @param text the title for the tab.
* @param buttons A custom {@link Button} instance used to close the tab
*/
public EditableTab(String text, @Nullable ButtonBase... buttons) {
this(null, text, buttons);
}
/**
* Initializes a new instance of the {@link EditableTab} instance.
*
* @param text the title for the tab.
* @param buttons A custom {@link Button} instance used to close the tab
* @param graphic A node used as an icon oon the tab
*/
public EditableTab(Node graphic, String text, @Nullable ButtonBase... buttons) {
super();
titleLabel = new Label(text);
titleLabel.setPadding(new javafx.geometry.Insets(0, 4, 0, 4));
var toolbar = new HBox();
toolbar.setAlignment(Pos.CENTER_LEFT);
if (graphic != null) {
toolbar.getChildren().add(graphic);
}
toolbar.getChildren().add(titleLabel);
if (buttons != null) {
setClosable(false);
toolbar.getChildren().addAll(buttons);
}
// We need to wrap the hbox used for layout into a Label
// otherwise the tabs names in the overflow menu is empty.
var wrappingLabel = new Label();
wrappingLabel.setContentDisplay(ContentDisplay.GRAPHIC_ONLY);
wrappingLabel.textProperty().bind(titleLabel.textProperty());
wrappingLabel.setGraphic(toolbar);
setGraphic(wrappingLabel);
editable.addListener((observable, oldValue, newValue) -> {
if (newValue) {
textField.setText(titleLabel.getText());
setGraphic(textField);
textField.selectAll();
textField.requestFocus();
} else {
if (!textField.getText().isEmpty()) {
titleLabel.setText(textField.getText());
}
setGraphic(wrappingLabel);
}
});
titleLabel.setOnMouseClicked(event -> {
if (event.getClickCount() == 2) {
editable.setValue(true);
}
});
textField.setOnAction(event -> {
editable.setValue(false);
});
textField.focusedProperty().addListener((observable, oldValue, newValue) -> {
if (!newValue) {
editable.setValue(false);
}
});
}
/**
* Renames the tab
*
* @param text the new name for the tab.
*/
public void rename(String text) {
titleLabel.setText(text);
}
public boolean isEditable() {
return editable.get();
}
public BooleanProperty editableProperty() {
return editable;
}
public void setEditable(boolean editable) {
if (editable) {
this.textField.requestFocus();
}
this.editable.set(editable);
}
}
@@ -0,0 +1,147 @@
/*
* Copyright 2021 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.javafx.controls;
import eu.binjr.common.javafx.bindings.BindingManager;
import javafx.collections.ListChangeListener;
import javafx.geometry.Bounds;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.control.skin.PaginationSkin;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
public class EnhancedPagination extends Pagination {
@Override
protected Skin<Pagination> createDefaultSkin() {
return new EnhancedPaginationSkin(this);
}
public static class EnhancedPaginationSkin extends PaginationSkin {
private final HBox controlBox;
private final Button previousPageButton;
private final Button nextPageButton;
private final Button firstPageButton;
private final Button lastPageButton;
private final Label pageInformation;
private final Button jumpToPageButton;
private final BindingManager bindingManager = new BindingManager();
public EnhancedPaginationSkin(Pagination pagination) {
super(pagination);
controlBox = (HBox) lookup(pagination, ".control-box");
previousPageButton = (Button) lookup(pagination, ".left-arrow-button");
nextPageButton = (Button) lookup(pagination, ".right-arrow-button");
pageInformation = (Label) lookup(pagination, ".page-information");
pageInformation.setVisible(false);
pageInformation.setManaged(false);
firstPageButton = new ToolButtonBuilder<Button>(bindingManager)
.setText("First")
.setTooltip("Jump to first page")
.setHeight(previousPageButton.getHeight())
.setStyleClass("dialog-button")
.setIconStyleClass("rewind-icon")
.bind(Node::disableProperty, previousPageButton.disableProperty())
.setAction(event -> pagination.setCurrentPageIndex(0))
.build(Button::new);
lastPageButton = new ToolButtonBuilder<Button>(bindingManager)
.setText("Last")
.setTooltip("Jump to last page")
.setHeight(nextPageButton.getHeight())
.setStyleClass("dialog-button")
.setIconStyleClass("fast-forward-icon")
.bind(Node::disableProperty, nextPageButton.disableProperty())
.setAction(event -> pagination.setCurrentPageIndex(pagination.getPageCount()))
.build(Button::new);
var input = new TextField();
HBox.setHgrow(input, Priority.NEVER);
var label = new Label("Jump to:");
label.setMinWidth(55);
HBox.setHgrow(label, Priority.ALWAYS);
var hbox = new HBox();
hbox.setSpacing(10);
hbox.getChildren().addAll(label, input);
hbox.setAlignment(Pos.CENTER_LEFT);
hbox.getStyleClass().addAll("pagination-popup");
hbox.setPadding(new Insets(5.0, 5.0, 5.0, 5.0));
hbox.setPrefSize(135, 40);
var popup = new PopupControl();
popup.setAutoHide(true);
popup.getScene().setRoot(hbox);
input.setOnAction(bindingManager.registerHandler(event -> {
try {
int targetPageIndex = Math.max(Math.min(Integer.parseInt(input.getText()) - 1, pagination.getPageCount()), 0);
pagination.setCurrentPageIndex(targetPageIndex);
} catch (NumberFormatException e) {
// Ignore badly formatted numerical input
} finally {
popup.hide();
}
}));
jumpToPageButton = new ToolButtonBuilder<Button>(bindingManager)
.setText("Jump")
.setTooltip("Jump to page")
.setHeight(nextPageButton.getHeight())
.setStyleClass("dialog-button")
.setIconStyleClass("share-icon")
.setAction(actionEvent -> {
Node owner = (Node) actionEvent.getSource();
Bounds bounds = owner.localToScreen(owner.getBoundsInLocal());
popup.show(owner.getScene().getWindow(), bounds.getMinX(), bounds.getMinY() - 45);
input.setText(Integer.toString(pagination.getCurrentPageIndex() + 1));
input.selectAll();
}).build(Button::new);
bindingManager.attachListener(this.controlBox.getChildren(), (ListChangeListener<Node>) c -> {
while (c.next()) {
if (c.wasAdded() && !c.wasRemoved() && c.getAddedSize() == 1 && c.getAddedSubList().get(0) == nextPageButton) {
addCustomNodes();
}
}
});
addCustomNodes();
}
private Node lookup(Control parent, String key) {
var node = parent.lookup(key);
if (node == null) {
throw new IllegalStateException("Failed to find a child node for lookup " + key);
}
return node;
}
protected void addCustomNodes() {
if (firstPageButton.getParent() != controlBox) {
controlBox.getChildren().add(0, firstPageButton);
controlBox.getChildren().addAll(lastPageButton, jumpToPageButton);
}
}
@Override
public void dispose() {
super.dispose();
bindingManager.close();
}
}
}
@@ -0,0 +1,275 @@
/*
* Copyright 2019-2023 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.javafx.controls;
import com.google.gson.Gson;
import eu.binjr.common.logging.Logger;
import eu.binjr.common.preferences.ObfuscatedString;
import javafx.scene.Node;
import javafx.scene.control.Button;
import javafx.scene.control.PasswordField;
import javafx.scene.control.TextField;
import javafx.scene.control.TextFormatter;
import javafx.scene.layout.AnchorPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.stage.DirectoryChooser;
import javafx.util.Duration;
import javafx.util.StringConverter;
import org.controlsfx.control.PropertySheet;
import org.controlsfx.property.editor.DefaultPropertyEditorFactory;
import org.controlsfx.property.editor.PropertyEditor;
import java.io.File;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Optional;
import java.util.function.BiFunction;
import java.util.function.Function;
public class ExtendedPropertyEditorFactory extends DefaultPropertyEditorFactory {
private static final Logger logger = Logger.create(ExtendedPropertyEditorFactory.class);
private static final Gson gson = new Gson();
private final ObfuscatedString.Obfuscator obfuscator;
public ExtendedPropertyEditorFactory() {
this.obfuscator = null;
}
public ExtendedPropertyEditorFactory(ObfuscatedString.Obfuscator obfuscator) {
this.obfuscator = obfuscator;
}
@Override
public PropertyEditor<?> call(PropertySheet.Item item) {
var editor = super.call(item);
if (editor != null) {
return editor;
}
if (Path.class.isAssignableFrom(item.getType())) {
return new FormattedPropertyEditor<Path>(item, new TextFormatter<>(new StringConverter<>() {
@Override
public String toString(Path object) {
if (object == null) {
return "";
}
return object.toString();
}
@Override
public Path fromString(String string) {
return Path.of(string);
}
}), (node, path) -> {
DirectoryChooser fileChooser = new DirectoryChooser();
fileChooser.setTitle("Select Folder");
try {
Path pluginPath = path.toRealPath();
if (Files.isDirectory(pluginPath)) {
fileChooser.setInitialDirectory(pluginPath.toFile());
}
} catch (Exception e) {
logger.debug("Could not initialize working dir for DirectoryChooser", e);
}
File folder = fileChooser.showDialog(NodeUtils.getStage(node));
if (folder != null) {
return Optional.of(folder.toPath());
}
return Optional.empty();
});
}
if (String[].class.isAssignableFrom(item.getType())) {
return new FormattedPropertyEditor<String[]>(item, new TextFormatter<>(new StringConverter<>() {
@Override
public String toString(String[] object) {
if (object == null) {
object = new String[0];
}
return gson.toJson(object);
}
@Override
public String[] fromString(String string) {
var res = gson.fromJson(string, String[].class);
return res != null ? res : new String[0];
}
}));
}
if (this.obfuscator != null && ObfuscatedString.class.isAssignableFrom(item.getType())) {
return new PasswordPropertyEditor<ObfuscatedString>(item, new TextFormatter<>(new StringConverter<>() {
@Override
public String toString(ObfuscatedString object) {
if (object == null) {
return "";
}
return object.toPlainText();
}
@Override
public ObfuscatedString fromString(String string) {
return obfuscator.fromPlainText(string);
}
}));
}
if (Duration.class.isAssignableFrom(item.getType())) {
return new FormattedPropertyEditor<Duration>(item, new TextFormatter<>(new StringConverter<>() {
@Override
public String toString(Duration object) {
if (object == null) {
return "";
}
return String.format("%d", (long) object.toMillis());
}
@Override
public Duration fromString(String string) {
return Duration.millis(Long.parseLong(string));
}
}));
}
if (URI.class.isAssignableFrom(item.getType())) {
return new FormattedPropertyEditor<URI>(item, new TextFormatter<>(new StringConverter<>() {
@Override
public String toString(URI object) {
if (object == null) {
return "";
}
return object.toString();
}
@Override
public URI fromString(String string) {
return URI.create(string);
}
}), s -> {
try {
new URL(s);
return new TextFieldValidator.ValidationStatus(true, "");
} catch (MalformedURLException e) {
return new TextFieldValidator.ValidationStatus(false, e.getMessage());
}
});
}
return null;
}
public static class PasswordPropertyEditor<T> implements PropertyEditor<T> {
private final PasswordField textField;
private final TextFormatter<T> textFormatter;
@SuppressWarnings("unchecked")
public PasswordPropertyEditor(PropertySheet.Item item, TextFormatter<T> textFormatter) {
textField = new PasswordField();
this.textFormatter = textFormatter;
textField.setTextFormatter(textFormatter);
item.getObservableValue().ifPresent(itemValue -> {
itemValue.addListener((observable, oldValue, newValue) -> textFormatter.setValue((T) newValue));
});
textFormatter.setValue((T) item.getValue());
textFormatter.valueProperty().addListener((observable, oldValue, newValue) -> item.setValue(newValue));
}
@Override
public Node getEditor() {
return textField;
}
@Override
public T getValue() {
return textFormatter.getValueConverter().fromString(textField.getText());
}
@Override
public void setValue(T value) {
textField.setText(textFormatter.getValueConverter().toString(value));
}
}
public static class FormattedPropertyEditor<T> implements PropertyEditor<T> {
private final HBox editor;
private final TextField textField;
private final TextFormatter<T> textFormatter;
FormattedPropertyEditor(PropertySheet.Item item, TextFormatter<T> textFormatter) {
this(item, textFormatter, null, null);
}
FormattedPropertyEditor(PropertySheet.Item item, TextFormatter<T> textFormatter, Function<String, TextFieldValidator.ValidationStatus> validator) {
this(item, textFormatter, null, validator);
}
FormattedPropertyEditor(PropertySheet.Item item, TextFormatter<T> textFormatter, BiFunction<Node, T, Optional<T>> editAction) {
this(item, textFormatter, editAction, null);
}
@SuppressWarnings("unchecked")
FormattedPropertyEditor(PropertySheet.Item item,
TextFormatter<T> textFormatter,
BiFunction<Node, T, Optional<T>> editAction,
Function<String, TextFieldValidator.ValidationStatus> validator) {
this.textFormatter = textFormatter;
editor = new HBox();
editor.setSpacing(5);
AnchorPane.setLeftAnchor(editor, 0.0);
AnchorPane.setRightAnchor(editor, 0.0);
textField = new TextField();
textField.setTextFormatter(textFormatter);
item.getObservableValue().ifPresent(itemValue -> {
itemValue.addListener((observable, oldValue, newValue) -> textFormatter.setValue((T) newValue));
});
textFormatter.setValue((T) item.getValue());
textFormatter.valueProperty().addListener((observable, oldValue, newValue) -> item.setValue(newValue));
HBox.setHgrow(textField, Priority.ALWAYS);
editor.getChildren().add(textField);
if (editAction != null) {
var editButton = new Button("...");
editButton.setOnAction(event -> {
editAction.apply(getEditor(), textFormatter.getValue()).ifPresent(textFormatter::setValue);
});
editor.getChildren().add(editButton);
}
if (validator != null) {
textField.setOnAction(event -> TextFieldValidator.validate(textField, true, validator));
}
}
@Override
public Node getEditor() {
return editor;
}
@Override
public T getValue() {
return textFormatter.getValueConverter().fromString(textField.getText());
}
@Override
public void setValue(T value) {
textField.setText(textFormatter.getValueConverter().toString(value));
}
}
}
@@ -0,0 +1,51 @@
/*
* Copyright 2017-2018 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.javafx.controls;
import eu.binjr.common.javafx.bindings.BindingManager;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Region;
import javafx.scene.paint.Color;
/**
* A {@link TableCell} implementation that shows an icon if the bound property is true
*
* @param <T> The type of the TableView generic type
* @author Frederic Thevenet
*/
public class IconTableCell<T> extends TableCell<T, Boolean> {
private final Node icon;
public IconTableCell(Node icon) {
this.icon = icon;
setContentDisplay(ContentDisplay.GRAPHIC_ONLY);
}
@Override
protected void updateItem(Boolean item, boolean empty) {
super.updateItem(item, empty);
setText(null);
if (empty || !item) {
setGraphic(null);
} else {
setGraphic(icon);
}
}
}
@@ -0,0 +1,114 @@
/*
* Copyright 2023 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.javafx.controls;
import eu.binjr.core.preferences.UserPreferences;
import javafx.animation.PauseTransition;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.geometry.Bounds;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Pane;
import javafx.scene.layout.Priority;
import javafx.util.Duration;
public class LabelWithInlineHelp extends HBox {
private final Label label;
private final StringProperty inlineHelp = new SimpleStringProperty();
public LabelWithInlineHelp() {
this(null, null);
}
public LabelWithInlineHelp(String label) {
this(label, null);
}
public LabelWithInlineHelp(String labelText, String inlineHelpText) {
var helpPopup = new Tooltip();
helpPopup.textProperty().bind(inlineHelpProperty());
helpPopup.setAutoHide(true);
helpPopup.setHideOnEscape(true);
var tooltip = new Tooltip();
tooltip.textProperty().bind(inlineHelpProperty());
ButtonBase helpButton = new ToolButtonBuilder<>()
.setStyleClass("dialog-button")
.setHeight(20.0)
.setWidth(20.0)
.setIconStyleClass("help-small-icon")
.build(ToggleButton::new);
helpButton.setTooltip(tooltip);
((ToggleButton) helpButton).selectedProperty().addListener((observable, oldValue, newValue) -> {
if (newValue) {
Bounds bounds = helpButton.localToScreen(helpButton.getBoundsInLocal());
helpPopup.show(helpButton, bounds.getMaxX(), bounds.getMinY());
helpButton.setTooltip(null);
} else {
helpPopup.hide();
helpButton.setTooltip(tooltip);
}
});
helpPopup.showingProperty().addListener((observable, oldValue, newValue) -> {
((ToggleButton) helpButton).setSelected(newValue);
});
helpButton.setAlignment(Pos.TOP_RIGHT);
helpButton.visibleProperty().bind(UserPreferences.getInstance().showInlineHelpButtons.property());
helpButton.managedProperty().bind(UserPreferences.getInstance().showInlineHelpButtons.property());
label = new Label();
label.setAlignment(Pos.TOP_LEFT);
label.setWrapText(true);
var spacer = new Pane();
HBox.setHgrow(spacer, Priority.SOMETIMES);
this.getChildren().addAll(label, spacer, helpButton);
if (labelText != null) {
label.setText(labelText);
}
if (inlineHelpText != null) {
inlineHelp.set(inlineHelpText);
}
}
public String getInlineHelp() {
return inlineHelp.get();
}
public StringProperty inlineHelpProperty() {
return inlineHelp;
}
public void setInlineHelp(String inlineHelp) {
this.inlineHelp.set(inlineHelp);
}
public String getText() {
return label.getText();
}
public StringProperty textProperty() {
return label.textProperty();
}
public void setText(String text) {
this.label.setText(text);
}
}
@@ -0,0 +1,154 @@
/*
* Copyright 2022 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.javafx.controls;
import eu.binjr.core.dialogs.Dialogs;
import javafx.beans.InvalidationListener;
import javafx.beans.property.DoubleProperty;
import javafx.beans.property.SimpleDoubleProperty;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.geometry.Pos;
import javafx.scene.canvas.Canvas;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.image.Image;
import javafx.scene.layout.StackPane;
import javafx.scene.transform.Rotate;
import java.util.Objects;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicBoolean;
public class LoadingPane extends StackPane {
private static final double HEIGHT = 64.0;
private static final double WIDTH = 64.0;
private final Canvas canvas;
private final AtomicBoolean rendering = new AtomicBoolean(false);
private final DoubleProperty targetFps;
private final DoubleProperty rotationSpeed;
private final ScheduledExecutorService scheduler;
private final GraphicsContext context2D;
private double currentAngle;
private final Rotate transform;
private final Image image;
private double angularStep;
public LoadingPane() {
this(30, 90);
}
public LoadingPane(double targetFps, double rotationSpeed) {
super();
this.targetFps = new SimpleDoubleProperty(targetFps);
this.rotationSpeed = new SimpleDoubleProperty(rotationSpeed);
scheduler = Executors.newScheduledThreadPool(1, r -> {
Thread thread = new Thread(r);
thread.setDaemon(true);
return thread;
});
InvalidationListener computeAngularStep = (observable) -> this.angularStep = this.rotationSpeed.get() / this.targetFps.get();
// Setup animation
this.canvas = new Canvas(WIDTH, HEIGHT);
this.context2D = canvas.getGraphicsContext2D();
this.currentAngle = 0;
this.transform = new Rotate(0, WIDTH / 2, HEIGHT / 2);
this.image = new Image(Objects.requireNonNull(getClass().getResourceAsStream("/eu/binjr/images/loading_64.png")));
this.targetFps.addListener(computeAngularStep);
this.rotationSpeed.addListener(computeAngularStep);
computeAngularStep.invalidated(null);
this.setAlignment(Pos.CENTER);
this.visibleProperty().addListener((observable, oldValue, newValue) -> {
if (newValue) {
startSpinning();
}
});
this.getChildren().add(canvas);
startSpinning();
this.getStyleClass().setAll("canvas-masker-pane");
}
public double getTargetFps() {
return this.targetFps.get();
}
public DoubleProperty targetFpsProperty() {
return this.targetFps;
}
public void setTargetFps(double targetFps) {
this.targetFps.set(targetFps);
}
public double getRotationSpeed() {
return rotationSpeed.get();
}
public DoubleProperty rotationSpeedProperty() {
return rotationSpeed;
}
public void setRotationSpeed(double rotationSpeed) {
this.rotationSpeed.set(rotationSpeed);
}
private void render() {
if (rendering.compareAndSet(false, true)) {
try {
Dialogs.runOnFXThread(() -> {
context2D.clearRect(0, 0, WIDTH, HEIGHT);
transform.setAngle(currentAngle);
context2D.setTransform(
transform.getMxx(),
transform.getMyx(),
transform.getMxy(),
transform.getMyy(),
transform.getTx(),
transform.getTy());
context2D.drawImage(image, 0, 0);
currentAngle += angularStep;
});
} finally {
rendering.set(false);
}
}
}
private void startSpinning() {
var task = scheduler.scheduleAtFixedRate(this::render, 0, Math.round(1000.0 / targetFps.get()), TimeUnit.MILLISECONDS);
ChangeListener<Boolean> stopWhenNotVisible = new ChangeListener<>() {
@Override
public void changed(ObservableValue<? extends Boolean> observable, Boolean oldValue, Boolean newValue) {
if (!newValue) {
LoadingPane.this.visibleProperty().removeListener(this);
task.cancel(true);
}
}
};
this.visibleProperty().addListener(stopWhenNotVisible);
}
}
@@ -0,0 +1,104 @@
/*
* Copyright 2019 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.javafx.controls;
import javafx.scene.Node;
import javafx.scene.SnapshotParameters;
import javafx.scene.image.WritableImage;
import javafx.scene.paint.Color;
import javafx.scene.paint.Paint;
import javafx.scene.transform.Transform;
import javafx.stage.Screen;
import javafx.stage.Stage;
/**
* Defines a collection of helper methods that work with @{@link Node}
*/
public final class NodeUtils {
/**
* Takes a snapshot of the provided node and render an image using the same output scale as it containing stage.
*
* @param node the node to take a snapshot of.
* @return the rendered image.
*/
public static WritableImage scaledSnapshot(Node node) {
return scaledSnapshot(node, Color.TRANSPARENT, getOutputScaleX(node), getOutputScaleY(node));
}
/**
* Takes a snapshot of the provided node and render an image using the specified output scale.
*
* @param node the node to take a snapshot of.
* @param fillColor the fill color.
* @return the rendered image.
*/
public static WritableImage scaledSnapshot(Node node, Paint fillColor) {
return scaledSnapshot(node, fillColor, getOutputScaleX(node), getOutputScaleY(node));
}
/**
* Takes a snapshot of the provided node and render an image using the specified output scale.
*
* @param node the node to take a snapshot of.
* @param fillColor the fill color.
* @param scaleX the X output scale to use.
* @param scaleY the Y output scale to use.
* @return the rendered image.
*/
public static WritableImage scaledSnapshot(Node node, Paint fillColor, double scaleX, double scaleY) {
SnapshotParameters spa = new SnapshotParameters();
spa.setFill(fillColor);
spa.setTransform(Transform.scale(scaleX, scaleY));
return node.snapshot(spa, null);
}
/**
* Returns the vertical output scale of the {@link Stage} that contains the provided {@link Node}
*
* @param node the node contained by the stage to get the output scale for.
* @return the vertical output scale
*/
public static double getOutputScaleX(Node node) {
var stage = getStage(node);
return stage == null ? Screen.getPrimary().getOutputScaleX() : stage.getOutputScaleX();
}
/**
* Returns the horizontal output scale of the {@link Stage} that contains the provided {@link Node}
*
* @param node the node contained by the stage to get the output scale for.
* @return the horizontal output scale.
*/
public static double getOutputScaleY(Node node) {
var stage = getStage(node);
return stage == null ? Screen.getPrimary().getOutputScaleY() : stage.getOutputScaleY();
}
/**
* Returns the {@link Stage} instance to which the provided {@link Node} is attached
*
* @param node the node to get the stage for.
* @return the {@link Stage} instance to which the provided {@link Node} is attached
*/
public static Stage getStage(Node node) {
if (node != null && node.getScene() != null) {
return (Stage) node.getScene().getWindow();
}
return null;
}
}
@@ -0,0 +1,77 @@
/*
* Copyright 2017-2022 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.javafx.controls;
import eu.binjr.common.javafx.bindings.BindingManager;
import eu.binjr.core.data.indexes.parser.profile.BuiltInParsingProfile;
import eu.binjr.core.data.indexes.parser.profile.ParsingProfile;
import eu.binjr.core.dialogs.LogParsingProfileDialog;
import javafx.scene.control.*;
/**
* A {@link TableCell} implementation that shows a {@link Button}
*
* @param <T> The type of the TableView generic type
* @author Frederic Thevenet
*/
public class ParsingProfileCell<T> extends TableCell<T, ParsingProfile> {
private final ButtonBase button;
private ParsingProfile current;
public ParsingProfileCell(TableColumn<T, ParsingProfile> column,
BindingManager bindingManager,
Runnable onEdit) {
button = new ToolButtonBuilder<>(bindingManager)
.setText("")
.setTooltip("Edit")
.setStyleClass("dialog-button")
.setIconStyleClass("settings-icon", "small-icon")
.setFocusTraversable(false)
.setAction(event -> {
// getTableView().getSelectionModel().select(getTableRow().getIndex());
var dlg = new LogParsingProfileDialog(NodeUtils.getStage(getTableView()), current);
dlg.showAndWait().ifPresent(selection -> {
getTableView().edit(getTableRow().getIndex(), column);
commitEdit(selection);
onEdit.run();
});
})
.build(Button::new);
setContentDisplay(ContentDisplay.LEFT);
}
@Override
protected void updateItem(ParsingProfile item, boolean empty) {
super.updateItem(item, empty);
if (empty) {
setGraphic(null);
setText("");
} else {
if (item != null) {
setText(item.toString());
current = item;
if (item != BuiltInParsingProfile.NONE) {
setGraphic(button);
} else {
setGraphic(null);
}
}
}
}
}
@@ -0,0 +1,51 @@
/*
* Copyright 2017-2018 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.javafx.controls;
import eu.binjr.core.data.indexes.IndexingStatus;
import javafx.scene.Node;
import javafx.scene.control.ContentDisplay;
import javafx.scene.control.TableCell;
import java.util.Map;
/**
* A {@link TableCell} implementation that shows an icon if the bound property is true
*
* @param <T> The type of the TableView generic type
* @author Frederic Thevenet
*/
public class StatusIconTableCell<T> extends TableCell<T, IndexingStatus> {
private final Map<IndexingStatus, Node> iconMap;
public StatusIconTableCell(Map<IndexingStatus, Node> icons) {
this.iconMap = icons;
setContentDisplay(ContentDisplay.GRAPHIC_ONLY);
}
@Override
protected void updateItem(IndexingStatus item, boolean empty) {
super.updateItem(item, empty);
setText(null);
if (empty || item == null) {
setGraphic(null);
} else {
setText(item.name());
setGraphic(iconMap.getOrDefault(item, null));
}
}
}
@@ -0,0 +1,77 @@
/*
* Copyright 2021 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.javafx.controls;
import javafx.css.PseudoClass;
import javafx.scene.control.TreeCell;
import javafx.scene.control.TreeView;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
public class StylableTreeItem {
private final String label;
private final List<PseudoClass> pseudoClasses;
public StylableTreeItem(String label, String... pseudoClasses) {
this.label = label;
this.pseudoClasses = Stream.of(pseudoClasses).map(PseudoClass::getPseudoClass).collect(Collectors.toList());
}
public String getLabel() {
return label;
}
@Override
public String toString() {
return label;
}
public List<PseudoClass> getPseudoClasses() {
return pseudoClasses;
}
public static <T extends StylableTreeItem> void setCellFactory(TreeView<T> treeView){
treeView.setCellFactory(tv -> new TreeCell<T>() {
private final Set<PseudoClass> pseudoClassesSet = new HashSet<>();
@Override
protected void updateItem(T item, boolean empty) {
super.updateItem(item, empty);
textProperty().unbind();
pseudoClassesSet.forEach(pc -> pseudoClassStateChanged(pc, false));
if (empty) {
setText("");
setGraphic(null);
} else {
setText(item.getLabel());
setGraphic(getTreeItem().getGraphic());
List<PseudoClass> itemPC = item.getPseudoClasses();
if (itemPC != null) {
itemPC.forEach(pc->{
pseudoClassStateChanged(pc, true);
pseudoClassesSet.add(pc);
});
}
}
}
});
}
}
@@ -0,0 +1,53 @@
/*
* Copyright 2019 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.javafx.controls;
import javafx.beans.binding.Bindings;
import javafx.beans.property.ReadOnlyDoubleProperty;
import javafx.scene.control.TableView;
public final class TableViewUtils {
//FIXME try and retrieve the actual scrollbar width at runtime
private static final double SCROLL_BAR_WIDTH = 20.0;
public static void autoFillTableWidthWithLastColumn(TableView<?> table) {
autoFillTableWidthWithColumn(table, table.getColumns().size() - 1);
}
public static void autoFillTableWidthWithColumn(TableView<?> table, int columnIdx) {
if (columnIdx > 0) {
ReadOnlyDoubleProperty[] widthProperties = new ReadOnlyDoubleProperty[table.getColumns().size()];
for (int i = 0; i < table.getColumns().size(); i++) {
if (i != columnIdx) {
widthProperties[i] = table.getColumns().get(i).widthProperty();
}
}
widthProperties[columnIdx] = table.widthProperty();
table.getColumns().get(columnIdx).prefWidthProperty().bind(Bindings.createDoubleBinding(() -> {
double width = widthProperties[columnIdx].getValue() - SCROLL_BAR_WIDTH;
for (int i = 0; i < table.getColumns().size(); i++) {
if (i != columnIdx) {
width -= widthProperties[i].getValue();
}
}
return Math.max(width, 100.0);
}, widthProperties));
}
}
}
@@ -0,0 +1,676 @@
/*
* Copyright 2017-2023 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.javafx.controls;
import eu.binjr.common.javafx.bindings.BindingManager;
import eu.binjr.common.logging.Logger;
import javafx.application.Platform;
import javafx.beans.InvalidationListener;
import javafx.beans.binding.Bindings;
import javafx.beans.property.*;
import javafx.beans.value.ChangeListener;
import javafx.collections.FXCollections;
import javafx.collections.ListChangeListener;
import javafx.collections.ObservableList;
import javafx.collections.ObservableMap;
import javafx.css.PseudoClass;
import javafx.css.Styleable;
import javafx.event.ActionEvent;
import javafx.event.EventHandler;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.control.ContextMenu;
import javafx.scene.control.Tab;
import javafx.scene.control.TabPane;
import javafx.scene.input.*;
import javafx.scene.layout.AnchorPane;
import javafx.scene.layout.Pane;
import javafx.scene.layout.Region;
import javafx.scene.layout.StackPane;
import javafx.scene.shape.SVGPath;
import javafx.stage.Stage;
import javafx.stage.StageStyle;
import javafx.stage.WindowEvent;
import java.awt.*;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Function;
/**
* A TabPane container with a button to add a new tab that also supports tearing away tabs into a separate window.
* <p>It relies on the {@link TabPaneManager} class to keep track of all tabs spread over many windows and {@link TabPane} instances</p>
*
* @author Frederic Thevenet
*/
public class TearableTabPane extends TabPane implements AutoCloseable {
private static final PseudoClass HOVER_PSEUDO_CLASS = PseudoClass.getPseudoClass("hover");
private static final Logger logger = Logger.create(TearableTabPane.class);
private boolean tearable;
private boolean reorderable;
private Function<ActionEvent, Optional<Tab>> newTabFactory = (e) -> Optional.of(new Tab());
private final Map<Tab, TabState> tearableTabMap = new HashMap<>();
private final TabPaneManager manager;
private EventHandler<ActionEvent> onAddNewTab;
private EventHandler<WindowEvent> onOpenNewWindow;
private EventHandler<WindowEvent> onClosingWindow;
private final BindingManager bindingManager = new BindingManager();
private final Property<StageStyle> detachedStageStyle;
private final ReadOnlyBooleanWrapper empty = new ReadOnlyBooleanWrapper(true);
private ContextMenu newTabContextMenu;
/**
* Initializes a new instance of the {@link TearableTabPane} class.
*/
public TearableTabPane() {
this(new TabPaneManager(), false, false, StageStyle.DECORATED, (Tab[]) null);
}
/**
* Initializes a new instance of the {@link TearableTabPane} class.
*
* @param manager the {@link TabPaneManager} manager instance
* @param reorderable true if tabs can be reordered, false otherwise.
* @param tearable true if tabs are teared away from the pane, false otherwise.
* @param style the {@link StageStyle} for the detached tab window.
* @param tabs tabs to attached to the TabPane.
*/
public TearableTabPane(TabPaneManager manager, boolean reorderable, boolean tearable, StageStyle style, Tab... tabs) {
super(tabs);
this.manager = manager;
this.tearable = tearable;
this.reorderable = reorderable;
this.detachedStageStyle = new SimpleObjectProperty<>(style);
bindingManager.attachListener(this.getSelectionModel().selectedItemProperty(), (ChangeListener<Tab>)
(observable, oldValue, newValue) -> this.manager.setSelectedTab(newValue)
);
bindingManager.attachListener(this.getTabs(), (ListChangeListener<Tab>) c -> {
while (c.next()) {
if (c.wasAdded()) {
for (Tab newTab : c.getAddedSubList()) {
this.tearableTabMap.put(newTab, new TabState(true));
this.manager.addTab(newTab, this);
Node target = newTab.getGraphic();
if (target.getOnDragEntered() == null) {
target.setOnDragEntered(bindingManager.registerHandler(event -> {
if (event.getDragboard().hasContent(manager.getDragAndDropFormat())) {
findTabHeader(newTab).ifPresent(node -> node.pseudoClassStateChanged(HOVER_PSEUDO_CLASS, true));
}
}));
}
if (target.getOnDragExited() == null) {
target.setOnDragExited(bindingManager.registerHandler(event -> {
if (event.getDragboard().hasContent(manager.getDragAndDropFormat())) {
findTabHeader(newTab).ifPresent(node -> node.pseudoClassStateChanged(HOVER_PSEUDO_CLASS, false));
}
}));
}
if (target.getOnDragDropped() == null) {
target.setOnDragDropped(bindingManager.registerHandler(event -> {
Dragboard db = event.getDragboard();
if (db.hasContent(manager.getDragAndDropFormat())) {
logger.trace(() -> "setOnDragDropped fired");
if (manager.completeDragAndDrop()) {
String id = (String) db.getContent(manager.getDragAndDropFormat());
Tab draggedTab = manager.getTab(id);
if (draggedTab != null) {
TearableTabPane draggedTabPane = (TearableTabPane) manager.getTabPane(draggedTab);
if (draggedTabPane.isReorderable()) {
manager.setMovingTab(true);
try {
var draggedIndex = draggedTabPane.getTabs().indexOf(draggedTab);
var droppedTabPane = manager.getTabPane(newTab);
var dropIndex = droppedTabPane.getTabs().indexOf(newTab);
if (!newTab.equals(draggedTab)) {
draggedTabPane.getTabs().remove(draggedIndex);
droppedTabPane.getTabs().add(dropIndex, draggedTab);
droppedTabPane.getSelectionModel().clearAndSelect(dropIndex);
}
} finally {
manager.setMovingTab(false);
}
} else {
logger.debug(() -> "Tabs on this pane cannot be reordered");
}
} else {
logger.debug(() -> "Failed to retrieve targetTab with id " + (id != null ? id : "null"));
}
}
event.consume();
}
}));
}
}
}
if (c.wasRemoved()) {
for (Tab t : c.getRemoved()) {
this.tearableTabMap.remove(t);
this.manager.removeTab(t);
}
}
}
logger.trace(() -> "Tearable tabs in tab pane: " +
tearableTabMap.keySet().stream()
.map(tab -> tab.getText() == null ? tab.toString() : tab.getText())
.reduce((s, s2) -> s + " " + s2).orElse("null"));
});
this.setOnDragDetected(bindingManager.registerHandler((MouseEvent event) -> {
if (!this.tearable) {
return;
}
if (event.getSource() instanceof TabPane) {
Tab currentTab = this.getSelectionModel().getSelectedItem();
if (currentTab != null) {
Dragboard db = this.startDragAndDrop(TransferMode.MOVE);
ClipboardContent clipboardContent = new ClipboardContent();
clipboardContent.put(manager.getDragAndDropFormat(), manager.getId(currentTab));
db.setDragView(NodeUtils.scaledSnapshot(currentTab.getContent()));
db.setContent(clipboardContent);
manager.startDragAndDrop();
}
}
event.consume();
}
));
this.setOnDragOver(bindingManager.registerHandler(event -> {
if (!this.tearable) {
return;
}
Dragboard db = event.getDragboard();
if (db.hasContent(manager.getDragAndDropFormat())) {
String id = (String) db.getContent(manager.getDragAndDropFormat());
Tab t = manager.getTab(id);
event.acceptTransferModes(TransferMode.MOVE);
event.consume();
}
}));
this.setOnDragDone(bindingManager.registerHandler(
(DragEvent event) -> {
if (!this.tearable || event.isDropCompleted()) {
return;
}
Dragboard db = event.getDragboard();
if (manager.completeDragAndDrop() && db.hasContent(manager.getDragAndDropFormat())) {
String id = (String) db.getContent(manager.getDragAndDropFormat());
logger.trace(() -> "setOnDragDone fired");
Tab t = manager.getTab(id);
manager.setMovingTab(true);
try {
tearOffTab(t);
} finally {
manager.setMovingTab(false);
}
}
event.consume();
}
));
this.setOnDragDropped(bindingManager.registerHandler(event -> {
Dragboard db = event.getDragboard();
if (db.hasContent(manager.getDragAndDropFormat())) {
logger.trace(() -> "setOnDragDropped fired");
if (manager.completeDragAndDrop()) {
String id = (String) db.getContent(manager.getDragAndDropFormat());
Tab t = manager.getTab(id);
if (t != null) {
TabPane p = manager.getTabPane(t);
if (!this.equals(p)) {
manager.setMovingTab(true);
try {
p.getTabs().remove(t);
this.getTabs().add(t);
this.getSelectionModel().select(t);
bringStageToFront();
} finally {
manager.setMovingTab(false);
}
}
} else {
logger.debug(() -> "Failed to retrieve tab with id " + (id != null ? id : "null"));
}
}
event.consume();
}
}));
Platform.runLater(() -> {
positionNewTabButton();
Stage stage = (Stage) this.getScene().getWindow();
bindingManager.attachListener(stage.focusedProperty(),
(ChangeListener<Boolean>) (observable, oldValue, newValue) -> {
if (newValue) {
manager.setSelectedTab(this.getSelectionModel().getSelectedItem());
}
});
});
// Prepare to change the button on screen position if the tearableTabMap side changes
bindingManager.attachListener(sideProperty(),
(observable, oldValue, newValue) -> {
if (newValue != null) {
positionNewTabButton();
}
});
bindingManager.attachListener(this.getTabs(), ((InvalidationListener) observable -> {
empty.setValue(this.getTabs().size() == 0);
}));
}
public void detachTab(Tab t) {
Objects.requireNonNull(t, "Tab to detach cannot be null");
logger.trace(() -> "Detaching tab " + t.getId() + " " + t.getText());
manager.setMovingTab(true);
try {
tearOffTab(t);
} finally {
manager.setMovingTab(false);
}
}
/**
* Returns the factory for creating new tabs
*
* @return the factory for creating new tabs
*/
public Function<ActionEvent, Optional<Tab>> getNewTabFactory() {
return newTabFactory;
}
/**
* Sets the factory for creating new tabs
*
* @param newTabFactory the factory for creating new tabs
*/
public void setNewTabFactory(Function<ActionEvent, Optional<Tab>> newTabFactory) {
this.newTabFactory = newTabFactory;
}
public void setNewTabContextMenu(ContextMenu menu) {
this.newTabContextMenu = menu;
}
/**
* Sets the action that should be fired on the addition of a tab to the pane.
*
* @param onAddNewTab the actions that should be fired on the addition of a tab to the pane.
*/
public void setOnAddNewTab(EventHandler<ActionEvent> onAddNewTab) {
this.onAddNewTab = onAddNewTab;
}
/**
* Returns true if tabs can be teared away from the pane, false otherwise.
*
* @return true true if tabs can be teared away from the pane, false otherwise.
*/
public boolean isTearable() {
return tearable;
}
/**
* Set to true if tabs can be teared away from the pane, false otherwise.
*
* @param tearable true if tabs can be teared away from the pane, false otherwise.
*/
public void setTearable(boolean tearable) {
this.tearable = tearable;
}
/**
* Returns true if tabs can be reordered, false otherwise.
*
* @return true if tabs can be reordered, false otherwise.
*/
public boolean isReorderable() {
return reorderable;
}
/**
* Set to true if tabs can be reordered, false otherwise.
*
* @param reorderable true if tabs can be reordered, false otherwise.
*/
public void setReorderable(boolean reorderable) {
this.reorderable = reorderable;
}
/**
* Returns the tab currently selected.
*
* @return the tab currently selected.
*/
public Tab getSelectedTab() {
return manager.getSelectedTab();
}
/**
* Returns the pane containing the tab currently selected.
*
* @return the pane containing the tab currently selected.
*/
public TabPane getSelectedTabPane() {
if (manager.getSelectedTab() == null || manager.tabToPaneMap.get(manager.getSelectedTab()) == null) {
return this;
}
return manager.tabToPaneMap.get(getSelectedTab());
}
/**
* Returns a list of all tabs, across of panes sharing the same {@link TabPaneManager}
*
* @return a list of all tabs, across of panes sharing the same {@link TabPaneManager}
*/
public ObservableList<Tab> getGlobalTabs() {
return manager.getGlobalTabList();
}
/**
* Clears the list of tabs.
*/
public void clearAllTabs() {
manager.clearAllTabs();
}
/**
* Sets the action to be fired on opening a new window to host tabs.
*
* @param action the action to be fired on opening a new window to host tabs.
*/
public void setOnOpenNewWindow(EventHandler<WindowEvent> action) {
this.onOpenNewWindow = action;
}
/**
* Sets the action to be fired on closing a window hosting tabs.
*
* @param action the action to be fired on closing a window hosting tabs.
*/
public void setOnClosingWindow(EventHandler<WindowEvent> action) {
this.onClosingWindow = action;
}
/**
* Returns the generated {@link DataFormat} used to identify drag and drop operations across panes sharing the same {@link TabPaneManager}
*
* @return the generated {@link DataFormat} used to identify drag and drop operations across panes sharing the same {@link TabPaneManager}
*/
public DataFormat getDataFormat() {
return manager.dragAndDropFormat;
}
public boolean isEmpty() {
return empty.get();
}
public ReadOnlyBooleanProperty emptyProperty() {
return empty.getReadOnlyProperty();
}
private void positionNewTabButton() {
Pane tabHeaderBg = (Pane) this.lookup(".tab-header-background");
if (tabHeaderBg == null) {
// TabPane is not ready
return;
}
Pane tabHeaderArea = (Pane) this.lookup(".tab-header-area");
logger.debug("tabHeaderArea.getHeight() = " + tabHeaderArea.getHeight());
Button newTabButton = (Button) tabHeaderBg.lookup("#newTabButton");
// Remove the button if it was already present
if (newTabButton != null) {
tabHeaderBg.getChildren().remove(newTabButton);
}
newTabButton = new Button();
newTabButton.visibleProperty().bind(manager.newTabButtonVisible);
newTabButton.setId("newTabButton");
newTabButton.setFocusTraversable(false);
Pane headersRegion = (Pane) this.lookup(".headers-region");
Region headerArea = (Region) this.lookup(".tab-header-area");
logger.debug("headersRegion.getHeight() = " + headersRegion.getHeight());
logger.debug("headersRegion.getPrefHeight = " + headersRegion.getPrefHeight());
newTabButton.getStyleClass().add("add-tab-button");
SVGPath icon = new SVGPath();
icon.setContent("m 31.25,54.09375 0,2.4375 -2.46875,0 0,0.375 2.46875,0 0,2.46875 0.375,0 0,-2.46875 2.46875,0 0,-0.375 -2.46875,0 0,-2.4375 -0.375,0 z");
icon.getStyleClass().add("add-tab-button-icon");
newTabButton.setGraphic(icon);
newTabButton.setAlignment(Pos.CENTER);
if (newTabContextMenu != null) {
newTabButton.setContextMenu(newTabContextMenu);
}
if (onAddNewTab != null) {
newTabButton.setOnAction(bindingManager.registerHandler(onAddNewTab));
} else {
newTabButton.setOnAction(bindingManager.registerHandler(event -> {
newTabFactory.apply(event).ifPresent(newTab -> {
getTabs().add(newTab);
this.getSelectionModel().select(newTab);
});
}));
}
tabHeaderBg.getChildren().add(newTabButton);
StackPane.setAlignment(newTabButton, Pos.CENTER_LEFT);
switch (getSide()) {
case TOP, BOTTOM -> newTabButton.translateXProperty().bind(
headersRegion.widthProperty()
.add(Bindings.createDoubleBinding(() -> headerArea.getInsets().getLeft(), headerArea.insetsProperty()))
);
case LEFT, RIGHT -> newTabButton.translateXProperty().bind(
tabHeaderBg.widthProperty()
.subtract(headersRegion.widthProperty())
.subtract(newTabButton.widthProperty())
.subtract(Bindings.createDoubleBinding(() -> headerArea.getInsets().getTop(), headerArea.insetsProperty()))
);
default -> throw new IllegalStateException("Invalid value for side enum");
}
}
private void bringStageToFront() {
if (this.getScene() != null) {
Stage stage = (Stage) this.getScene().getWindow();
if (stage != null) {
stage.toFront();
}
}
}
private Optional<Node> findTabHeader(Tab t) {
Styleable styleable = t.getGraphic();
while (styleable != null && !styleable.getStyleClass().contains("tab")) {
styleable = styleable.getStyleableParent();
}
if (styleable instanceof Node node) {
return Optional.of(node);
} else {
return Optional.empty();
}
}
private void tearOffTab(Tab tab) {
TearableTabPane detachedTabPane = new TearableTabPane(this.manager, isReorderable(), true, this.getDetachedStageStyle());
detachedTabPane.setId("tearableTabPane");
detachedTabPane.setOnOpenNewWindow(this.onOpenNewWindow);
detachedTabPane.setNewTabFactory(this.getNewTabFactory());
this.getTabs().remove(tab);
detachedTabPane.getTabs().add(tab);
Pane root = new AnchorPane(detachedTabPane);
AnchorPane.setBottomAnchor(detachedTabPane, 0.0);
AnchorPane.setLeftAnchor(detachedTabPane, 0.0);
AnchorPane.setRightAnchor(detachedTabPane, 0.0);
AnchorPane.setTopAnchor(detachedTabPane, 0.0);
final Scene scene = new Scene(root, root.getPrefWidth(), root.getPrefHeight());
Stage stage = new Stage();
stage.setScene(scene);
Point p = MouseInfo.getPointerInfo().getLocation();
stage.setX(p.getX());
stage.setY(p.getY());
detachedTabPane.getTabs().addListener((ListChangeListener<Tab>) c -> {
if (c.getList().size() == 0) {
if (onClosingWindow != null) {
onClosingWindow.handle(new WindowEvent(stage, WindowEvent.WINDOW_CLOSE_REQUEST));
}
stage.close();
detachedTabPane.close();
}
});
if (onOpenNewWindow != null) {
onOpenNewWindow.handle(new WindowEvent(stage, WindowEvent.WINDOW_SHOWING));
}
stage.initStyle(this.getDetachedStageStyle());
stage.show();
detachedTabPane.getSelectionModel().select(tab);
stage.setOnCloseRequest(bindingManager.registerHandler(event -> {
detachedTabPane.getTabs().removeAll(detachedTabPane.getTabs());
}));
}
@Override
public void close() {
getTabs().forEach(tab -> tab.setContextMenu(null));
logger.trace(() -> "Closing down TearableTabPane instance");
bindingManager.close();
}
public StageStyle getDetachedStageStyle() {
return detachedStageStyle.getValue();
}
public Property<StageStyle> detachedStageStyleProperty() {
return detachedStageStyle;
}
public void setDetachedStageStyle(StageStyle detachedStageStyle) {
this.detachedStageStyle.setValue(detachedStageStyle);
}
public boolean isNewTabButtonVisible() {
return manager.newTabButtonVisible.get();
}
public BooleanProperty newTabButtonVisibleProperty() {
return manager.newTabButtonVisible;
}
public void setNewTabButtonVisible(boolean newTabButtonVisible) {
manager.newTabButtonVisible.set(newTabButtonVisible);
}
/**
* Represents the state of a single tab
*/
private record TabState(boolean attached) {
}
/**
* A class that represents the state of the tabs across all TabPane windows
*/
protected static class TabPaneManager {
private final ObservableMap<Tab, TabPane> tabToPaneMap;
private final Map<String, Tab> idToTabMap;
private final ObservableList<Tab> globalTabList;
private final DataFormat dragAndDropFormat;
private final AtomicBoolean dndComplete;
private Tab selectedTab;
private boolean movingTab;
private final BooleanProperty newTabButtonVisible = new SimpleBooleanProperty(true);
public TabPaneManager() {
tabToPaneMap = FXCollections.observableMap(new HashMap<>());
idToTabMap = new HashMap<>();
globalTabList = FXCollections.observableList(new ArrayList<>());
dragAndDropFormat = new DataFormat(UUID.randomUUID().toString());
dndComplete = new AtomicBoolean(true);
}
public void startDragAndDrop() {
dndComplete.set(false);
}
public boolean completeDragAndDrop() {
return dndComplete.compareAndSet(false, true);
}
public void addTab(Tab tab, TabPane pane) {
idToTabMap.put(getId(tab), tab);
tabToPaneMap.put(tab, pane);
if (!movingTab) {
globalTabList.add(tab);
}
}
public void removeTab(Tab tab) {
logger.trace(() -> "Removing tab " + tab.getText() + " (movingTab=" + movingTab + ")");
idToTabMap.remove(getId(tab));
tabToPaneMap.remove(tab);
if (!movingTab) {
globalTabList.remove(tab);
}
}
public TabPane getTabPane(Tab tab) {
return tabToPaneMap.get(tab);
}
public String getId(Tab tab) {
return Integer.toString(tab.hashCode());
}
public Tab getTab(String id) {
return idToTabMap.get(id);
}
public DataFormat getDragAndDropFormat() {
return dragAndDropFormat;
}
private ObservableList<Tab> getGlobalTabList() {
return globalTabList;
}
public Tab getSelectedTab() {
return selectedTab;
}
public void setSelectedTab(Tab selectedTab) {
this.selectedTab = selectedTab;
logger.trace(() -> "Selected Tab: " + ((selectedTab == null) ? "null" : selectedTab + " " + getId(selectedTab) + " " + tabToPaneMap.get(selectedTab)));
}
public void setMovingTab(boolean movingTab) {
this.movingTab = movingTab;
}
public void clearAllTabs() {
tabToPaneMap.values()
.stream()
.distinct()
.toList()
.forEach(p -> p.getTabs().clear());
}
}
}
@@ -0,0 +1,97 @@
/*
* Copyright 2021-2022 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.javafx.controls;
import eu.binjr.common.colors.ColorUtils;
import eu.binjr.common.javafx.bindings.BindingManager;
import eu.binjr.core.dialogs.Dialogs;
import eu.binjr.core.preferences.UserPreferences;
import javafx.beans.InvalidationListener;
import javafx.beans.value.ObservableValue;
import javafx.geometry.Pos;
import javafx.scene.control.TextField;
import java.util.function.Function;
import java.util.function.Predicate;
public class TextFieldValidator {
public static void succeed(TextField textField) {
textField.setStyle("");
}
public static void fail(TextField textField,
boolean autoReset,
ObservableValue<?>... resetProperties) {
fail(textField, null, autoReset, resetProperties);
}
public static void fail(TextField textField, String reason,
boolean autoReset,
ObservableValue<?>... resetProperties) {
textField.setStyle(String.format("-fx-background-color: %s;",
ColorUtils.toHex(UserPreferences.getInstance().invalidInputColor.get())));
if (reason != null) {
Dialogs.notifyError("Invalid input", reason, Pos.BOTTOM_RIGHT, textField);
}
if (autoReset) {
BindingManager manager = new BindingManager();
InvalidationListener autoResetListener = obs -> {
succeed(textField);
manager.close();
};
manager.attachListener(textField.textProperty(), autoResetListener);
if (resetProperties != null) {
for (ObservableValue<?> p : resetProperties) {
manager.attachListener(p, autoResetListener);
}
}
}
}
public static boolean validate(TextField textField,
boolean autoReset,
Predicate<String> validator,
ObservableValue<?>... resetProperties) {
if (validator.test(textField.getText())) {
succeed(textField);
return true;
} else {
fail(textField, autoReset, resetProperties);
return false;
}
}
public static boolean validate(TextField textField,
boolean autoReset,
Function<String, ValidationStatus> validator,
ObservableValue<?>... resetProperties) {
var status = validator.apply(textField.getText());
if (status.isValid()) {
succeed(textField);
return true;
} else {
fail(textField, status.reason, autoReset, resetProperties);
return false;
}
}
public record ValidationStatus(boolean isValid, String reason) {
}
}
@@ -0,0 +1,137 @@
/*
* Copyright 2019 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.javafx.controls;
import com.google.gson.TypeAdapter;
import com.google.gson.annotations.JsonAdapter;
import com.google.gson.stream.JsonReader;
import com.google.gson.stream.JsonWriter;
import jakarta.xml.bind.annotation.XmlAccessType;
import jakarta.xml.bind.annotation.XmlAccessorType;
import javafx.scene.input.DataFormat;
import java.io.IOException;
import java.time.Duration;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Objects;
@JsonAdapter(TimeRange.Adapter.class)
@XmlAccessorType(XmlAccessType.PROPERTY)
public class TimeRange {
public static final DataFormat TIME_RANGE_DATA_FORMAT = new DataFormat(TimeRange.class.getCanonicalName());
private static final String DELIMITER = " ";
private final ZonedDateTime beginning;
private final ZonedDateTime end;
private final ZoneId zoneId;
private TimeRange(ZonedDateTime beginning, ZonedDateTime end) {
Objects.requireNonNull(beginning, "Parameter 'beginning' must not be null");
Objects.requireNonNull(end, "Parameter 'end' must not be null");
this.zoneId = beginning.getZone();
this.beginning = beginning;
this.end = end.withZoneSameInstant(zoneId);
}
public static TimeRange of(TimeRange range) {
return new TimeRange(range.getBeginning(), range.getEnd());
}
public static TimeRange of(ZonedDateTime beginning, ZonedDateTime end) {
return new TimeRange(beginning, end);
}
public static TimeRange last24Hours() {
var end = ZonedDateTime.now();
return new TimeRange(end.minusHours(24), end);
}
public static TimeRange deSerialize(String valueStr) {
String[] s = valueStr.split(DELIMITER);
if (s.length != 2) {
throw new IllegalArgumentException("Could not parse provided string as a TimeRange");
}
return TimeRange.of(DateTimeFormatter.ISO_ZONED_DATE_TIME.parse(s[0], ZonedDateTime::from), DateTimeFormatter.ISO_ZONED_DATE_TIME.parse(s[1], ZonedDateTime::from));
}
public ZonedDateTime getBeginning() {
return beginning;
}
public ZonedDateTime getEnd() {
return end;
}
public Duration getDuration() {
return Duration.between(beginning, end);
}
public ZoneId getZoneId() {
return zoneId;
}
public boolean isNegative() {
return getDuration().isNegative();
}
public String serialize() {
return DateTimeFormatter.ISO_ZONED_DATE_TIME.format(beginning) + DELIMITER + DateTimeFormatter.ISO_ZONED_DATE_TIME.format(end);
}
@Override
public int hashCode() {
return beginning.hashCode() + end.hashCode() + zoneId.hashCode();
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj == null || getClass() != obj.getClass()) {
return false;
}
var other = (TimeRange) obj;
return beginning.equals(other.beginning) &&
end.equals(other.end) &&
zoneId.equals(other.zoneId);
}
@Override
public String toString() {
final StringBuffer sb = new StringBuffer("TimeRange{");
sb.append("beginning=").append(beginning);
sb.append(", end=").append(end);
sb.append(", zoneId=").append(zoneId);
sb.append('}');
return sb.toString();
}
public static class Adapter extends TypeAdapter<TimeRange> {
@Override
public void write(final JsonWriter jsonWriter, final TimeRange value) throws IOException {
jsonWriter.value(value.serialize());
}
@Override
public TimeRange read(final JsonReader jsonReader) throws IOException {
String value = jsonReader.nextString();
return TimeRange.deSerialize(value);
}
}
}
@@ -0,0 +1,537 @@
/*
* Copyright 2017-2020 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.javafx.controls;
import eu.binjr.common.javafx.bindings.BindingManager;
import eu.binjr.common.logging.Logger;
import eu.binjr.core.dialogs.Dialogs;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.Property;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.property.StringProperty;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.collections.FXCollections;
import javafx.fxml.FXML;
import javafx.fxml.FXMLLoader;
import javafx.geometry.Bounds;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.input.Clipboard;
import javafx.scene.input.ClipboardContent;
import javafx.scene.layout.AnchorPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Pane;
import javafx.util.StringConverter;
import org.controlsfx.control.textfield.AutoCompletionBinding;
import org.controlsfx.control.textfield.TextFields;
import java.io.IOException;
import java.net.URL;
import java.time.*;
import java.time.temporal.ChronoUnit;
import java.util.ResourceBundle;
import java.util.function.BiConsumer;
import java.util.function.BiFunction;
import java.util.function.Supplier;
import java.util.stream.Collectors;
public class TimeRangePicker extends HBox {
private static final Logger logger = Logger.create(TimeRangePicker.class);
private final ToggleButton timeRangeLabel;
private final ButtonBase previousIntervalBtn;
private final ButtonBase nextIntervalBtn;
private final ButtonBase resetIntervalBtn;
private TimeRangePickerController timeRangePickerController;
private final PopupControl popup;
private final Property<ZoneId> zoneId;
private final Property<TimeRange> timeRange = new SimpleObjectProperty<>(TimeRange.of(ZonedDateTime.now(), ZonedDateTime.now()));
private final ObjectProperty<TimeRange> selectedRange = new SimpleObjectProperty<>(TimeRange.of(ZonedDateTime.now().minusHours(1), ZonedDateTime.now()));
private final BindingManager bindingManager = new BindingManager();
private final ObjectProperty<Supplier<TimeRange>> onResetInterval = new SimpleObjectProperty<>();
public void setReferenceEndDateSupplier(Supplier<ZonedDateTime> referenceEndDateSupplier) {
this.referenceEndDateSupplier = referenceEndDateSupplier;
}
private Supplier<ZonedDateTime> referenceEndDateSupplier;
public TimeRangePicker() throws IOException {
super();
this.previousIntervalBtn = new ToolButtonBuilder<>()
.setStyleClass("dialog-button")
.setHeight(USE_COMPUTED_SIZE)
.setWidth(20.0)
.setIconStyleClass("left-arrow-icon")
.setAction(event -> {
timeRangePickerController.stepBy(
Duration.between(timeRangePickerController.endDate.getDateTimeValue(),
timeRangePickerController.startDate.getDateTimeValue()));
event.consume();
})
.setTooltip("Step Back")
.build(Button::new);
previousIntervalBtn.setMaxHeight(Double.MAX_VALUE);
this.nextIntervalBtn = new ToolButtonBuilder<>()
.setHeight(USE_COMPUTED_SIZE)
.setWidth(20)
.setStyleClass("dialog-button")
.setIconStyleClass("right-arrow-icon")
.setAction(event -> {
timeRangePickerController.stepBy(
Duration.between(timeRangePickerController.startDate.getDateTimeValue(),
timeRangePickerController.endDate.getDateTimeValue()));
event.consume();
})
.setTooltip("Step Forward")
.build(Button::new);
nextIntervalBtn.setMaxHeight(Double.MAX_VALUE);
this.resetIntervalBtn = new ToolButtonBuilder<>()
.setHeight(USE_COMPUTED_SIZE)
.setWidth(40.0)
.setStyleClass("dialog-button")
.setAction(event -> {
if (getOnResetInterval() != null) {
var range = getOnResetInterval().get();
timeRangePickerController.applyNewTimeRange.accept(range.getBeginning(), range.getEnd());
}
event.consume();
})
.setIconStyleClass("reset-clock-icon", "medium-icon")
.setTooltip("Reset Time Range")
.build(Button::new);
resetIntervalBtn.setMaxHeight(Double.MAX_VALUE);
timeRangeLabel = new ToggleButton();
var tooltip = new Tooltip("Edit the time range");
tooltip.setShowDelay(javafx.util.Duration.millis(500));
timeRangeLabel.setTooltip(tooltip);
timeRangeLabel.setMaxHeight(Double.MAX_VALUE);
this.setAlignment(Pos.CENTER);
this.getChildren().addAll(
previousIntervalBtn,
timeRangeLabel,
resetIntervalBtn,
nextIntervalBtn);
timeRangeLabel.setGraphic(ToolButtonBuilder.makeIconNode(Pos.CENTER, "combo-box-arrow-icon", "small-icon"));
timeRangeLabel.setContentDisplay(ContentDisplay.RIGHT);
timeRangeLabel.setGraphicTextGap(8);
timeRangeLabel.setPadding(new Insets(0, 10, 0, 10));
FXMLLoader loader = new FXMLLoader(getClass().getResource("/eu/binjr/views/TimeRangePickerView.fxml"));
this.timeRangePickerController = new TimeRangePickerController();
loader.setController(timeRangePickerController);
Pane timeRangePickerPane = loader.load();
popup = new PopupControl();
popup.setAutoHide(true);
popup.getScene().setRoot(timeRangePickerPane);
popup.showingProperty().addListener((observable, oldValue, newValue) -> {
timeRangeLabel.setSelected(newValue);
});
this.timeRangeLabel.setOnAction(actionEvent -> {
Node owner = (Node) actionEvent.getSource();
Bounds bounds = owner.localToScreen(owner.getBoundsInLocal());
this.popup.show(owner.getScene().getWindow(), bounds.getMinX(), bounds.getMaxY());
});
timeRangePickerController.applyNewTimeRange = (beginning, end) -> {
this.selectedRange.setValue(TimeRange.of(beginning, end));
};
timeRangePickerController.startDate.setDateTimeValue(selectedRange.getValue().getBeginning());
timeRangePickerController.endDate.setDateTimeValue(selectedRange.getValue().getEnd());
zoneId = timeRangePickerController.endDate.zoneIdProperty();
bindingManager.bind(timeRangePickerController.startDate.zoneIdProperty(), zoneId);
bindingManager.bindBidirectional(timeRangePickerController.zoneIdProperty(), zoneId);
bindingManager.attachListener(zoneId, (observable, oldValue, newValue) -> updateText());
bindingManager.attachListener(selectedRange, (ChangeListener<TimeRange>) (observable, oldValue, newValue) -> {
if (newValue != null) {
bindingManager.suspend();
try {
zoneId.setValue(newValue.getZoneId());
timeRangePickerController.startDate.setDateTimeValue(newValue.getBeginning());
timeRangePickerController.endDate.setDateTimeValue(newValue.getEnd());
} finally {
bindingManager.resume();
updateText();
}
}
});
bindingManager.attachListener(timeRangePickerController.startDate.dateTimeValueProperty(),
(ChangeListener<ZonedDateTime>) (observable, oldValue, newValue) -> {
if (newValue != null && timeRangePickerController.endDate.getDateTimeValue() != null) {
TimeRange newRange = TimeRange.of(newValue, timeRangePickerController.endDate.getDateTimeValue());
if (newRange.isNegative()) {
TimeRange oldRange = TimeRange.of(oldValue, timeRangePickerController.endDate.getDateTimeValue());
this.selectedRange.setValue(TimeRange.of(newValue, newValue.plus(oldRange.getDuration())));
} else {
this.selectedRange.setValue(newRange);
}
}
});
bindingManager.attachListener(timeRangePickerController.endDate.dateTimeValueProperty(),
(ChangeListener<ZonedDateTime>) (observable, oldValue, newValue) -> {
if (newValue != null && timeRangePickerController.startDate.getDateTimeValue() != null) {
TimeRange newRange = TimeRange.of(timeRangePickerController.startDate.getDateTimeValue(), newValue);
if (newRange.isNegative()) {
TimeRange oldRange = TimeRange.of(timeRangePickerController.startDate.getDateTimeValue(), oldValue);
this.selectedRange.setValue(TimeRange.of(newValue.minus(oldRange.getDuration()), newValue));
} else {
this.selectedRange.setValue(newRange);
}
}
});
referenceEndDateSupplier = () -> ZonedDateTime.now(zoneId.getValue());
}
public String getText() {
return timeRangeLabel.getText();
}
public StringProperty textProperty() {
return timeRangeLabel.textProperty();
}
public void setText(String text) {
timeRangeLabel.setText(text);
}
public ZoneId getZoneId() {
return zoneId.getValue();
}
public Property<ZoneId> zoneIdProperty() {
return zoneId;
}
public void dispose() {
bindingManager.close();
}
private void updateText() {
Dialogs.runOnFXThread(() -> {
this.timeRange.setValue(TimeRange.of(timeRangePickerController.startDate.getDateTimeValue(), timeRangePickerController.endDate.getDateTimeValue()));
timeRangeLabel.setText(String.format("From %s to %s (%s)",
timeRangePickerController.startDate.getDateTimeValue().format(timeRangePickerController.startDate.getFormatter()),
timeRangePickerController.endDate.getDateTimeValue().format(timeRangePickerController.endDate.getFormatter()),
getZoneId().toString()));
});
}
public Supplier<TimeRange> getOnResetInterval() {
return onResetInterval.get();
}
public ObjectProperty<Supplier<TimeRange>> onResetIntervalProperty() {
return onResetInterval;
}
public void setOnResetInterval(Supplier<TimeRange> onResetInterval) {
this.onResetInterval.set(onResetInterval);
}
public TimeRange getSelectedRange() {
return selectedRange.getValue();
}
public Property<TimeRange> selectedRangeProperty() {
return selectedRange;
}
public void initSelectedRange(TimeRange selectedRange) {
this.selectedRange.setValue(selectedRange);
}
public void updateSelectedRange(TimeRange newValue) {
bindingManager.suspend();
try {
logger.trace(() -> "updateRangeBeginning -> " + newValue.toString());
timeRangePickerController.startDate.setDateTimeValue(newValue.getBeginning());
timeRangePickerController.endDate.setDateTimeValue(newValue.getEnd());
this.selectedRange.setValue(TimeRange.of(newValue.getBeginning(), newValue.getEnd()));
zoneId.setValue(newValue.getZoneId());
updateText();
} finally {
bindingManager.resume();
}
}
public TimeRange getTimeRange() {
return timeRange.getValue();
}
public Property<TimeRange> timeRangeProperty() {
return timeRange;
}
private void onZoneIdChanged(ObservableValue<? extends ZoneId> observable, ZoneId oldValue, ZoneId newValue) {
updateText();
}
public void setOnSelectedRangeChanged(ChangeListener<TimeRange> timeRangeChangeListener) {
bindingManager.attachListener(selectedRange, timeRangeChangeListener);
}
public Boolean isTimeRangeLinked() {
return timeRangePickerController.linkTimeRangeButton.isSelected();
}
public Property<Boolean> timeRangeLinkedProperty() {
return timeRangePickerController.linkTimeRangeButton.selectedProperty();
}
public void setTimeRangeLinked(Boolean timeRangeLinked) {
timeRangePickerController.linkTimeRangeButton.setSelected(timeRangeLinked);
}
private class TimeRangePickerController {
@FXML
private ResourceBundle resources;
@FXML
private URL location;
@FXML
private AnchorPane root;
@FXML
private Button previousIntervalBtn;
@FXML
private ZonedDateTimePicker startDate;
@FXML
private ZonedDateTimePicker endDate;
@FXML
private Button nextIntervalBtn;
@FXML
private ComboBox<String> timezoneField;
@FXML
private Button last6Hours;
@FXML
private Button last3Hours;
@FXML
private Button last24Hours;
@FXML
private Button last7Days;
@FXML
private Button last15Days;
@FXML
private Button last30Days;
@FXML
private Button last90Days;
@FXML
private Button last15Minutes;
@FXML
private Button last30Minutes;
@FXML
private Button last60Minutes;
@FXML
private Button last12Hours;
@FXML
private Button last90Minutes;
@FXML
private Button today;
@FXML
private Button yesterday;
@FXML
private Button thisWeek;
@FXML
private Button lastWeek;
@FXML
private Button minus1Hour;
@FXML
private Button plus1Hour;
@FXML
private Button minus24Hours;
@FXML
private Button plus24Hours;
@FXML
private Button pasteTimeRangeButton;
@FXML
private Button copyTimeRangeButton;
@FXML
private ToggleButton linkTimeRangeButton;
private TextFormatter<ZoneId> formatter;
private AutoCompletionBinding<String> autoCompletionBinding;
private BiConsumer<ZonedDateTime, ZonedDateTime> applyNewTimeRange = (start, end) -> {
startDate.dateTimeValueProperty().setValue(start);
endDate.dateTimeValueProperty().setValue(end);
};
private void stepBy(Duration intervalDuration) {
applyNewTimeRange.accept(startDate.getDateTimeValue().plus(intervalDuration), endDate.getDateTimeValue().plus(intervalDuration));
}
private void last(Duration duration) {
ZonedDateTime end = referenceEndDateSupplier.get();
applyNewTimeRange.accept(end.minus(duration), end);
// hide popup
popup.hide();
}
private void forward(Duration duration) {
shift(duration, ZonedDateTime::plus);
}
private void backward(Duration duration) {
shift(duration, ZonedDateTime::minus);
}
private void shift(Duration duration, BiFunction<ZonedDateTime, Duration, ZonedDateTime> move) {
var ref = TimeRange.of(selectedRange.get());
applyNewTimeRange.accept(
move.apply(ref.getBeginning(), duration),
move.apply(ref.getEnd(), duration));
// hide popup
popup.hide();
}
private void updateAutoCompletionBinding() {
if (autoCompletionBinding != null) {
autoCompletionBinding.dispose();
}
autoCompletionBinding = TextFields.bindAutoCompletion(timezoneField.getEditor(),
ZoneId.getAvailableZoneIds().stream().sorted().collect(Collectors.toList()));
autoCompletionBinding.setPrefWidth(200);
}
@FXML
void initialize() {
formatter = new TextFormatter<ZoneId>(new StringConverter<ZoneId>() {
@Override
public String toString(ZoneId object) {
if (object == null) {
return "null";
}
return object.toString();
}
@Override
public ZoneId fromString(String string) {
return ZoneId.of(string);
}
});
updateAutoCompletionBinding();
timezoneField.getEditor().setTextFormatter(formatter);
timezoneField.setItems(FXCollections.observableArrayList(ZoneId.getAvailableZoneIds().stream().sorted().collect(Collectors.toList())));
timezoneField.showingProperty().addListener((observable, oldValue, newValue) -> {
if (newValue) {
autoCompletionBinding.dispose();
} else {
updateAutoCompletionBinding();
}
});
nextIntervalBtn.setOnAction(event -> {
stepBy(Duration.between(startDate.getDateTimeValue(), endDate.getDateTimeValue()));
});
previousIntervalBtn.setOnAction(event -> {
stepBy(Duration.between(endDate.getDateTimeValue(), startDate.getDateTimeValue()));
});
last3Hours.setOnAction(event -> last(Duration.of(3, ChronoUnit.HOURS)));
last6Hours.setOnAction(event -> last(Duration.of(6, ChronoUnit.HOURS)));
last12Hours.setOnAction(event -> last(Duration.of(12, ChronoUnit.HOURS)));
last24Hours.setOnAction(event -> last(Duration.of(24, ChronoUnit.HOURS)));
last7Days.setOnAction(event -> last(Duration.of(7, ChronoUnit.DAYS)));
last15Days.setOnAction(event -> last(Duration.of(15, ChronoUnit.DAYS)));
last30Days.setOnAction(event -> last(Duration.of(30, ChronoUnit.DAYS)));
last90Days.setOnAction(event -> last(Duration.of(90, ChronoUnit.DAYS)));
last15Minutes.setOnAction(event -> last(Duration.of(15, ChronoUnit.MINUTES)));
last30Minutes.setOnAction(event -> last(Duration.of(30, ChronoUnit.MINUTES)));
last60Minutes.setOnAction(event -> last(Duration.of(60, ChronoUnit.MINUTES)));
last90Minutes.setOnAction(event -> last(Duration.of(90, ChronoUnit.MINUTES)));
today.setOnAction(event -> {
LocalDate today = LocalDate.now(zoneId.getValue());
applyNewTimeRange.accept(
ZonedDateTime.of(today, LocalTime.MIDNIGHT, zoneId.getValue()),
ZonedDateTime.of(today.plusDays(1), LocalTime.MIDNIGHT, zoneId.getValue()));
// hide popup
popup.hide();
});
yesterday.setOnAction(event -> {
LocalDate today = LocalDate.now(zoneId.getValue());
applyNewTimeRange.accept(
ZonedDateTime.of(today.minusDays(1), LocalTime.MIDNIGHT, zoneId.getValue()),
ZonedDateTime.of(today, LocalTime.MIDNIGHT, zoneId.getValue()));
// hide popup
popup.hide();
});
thisWeek.setOnAction(event -> {
LocalDate refDay = LocalDate.now(zoneId.getValue());
int n = refDay.getDayOfWeek().getValue();
applyNewTimeRange.accept(
ZonedDateTime.of(refDay.minusDays(n - 1), LocalTime.MIDNIGHT, zoneId.getValue()),
ZonedDateTime.of(refDay.plusDays(8 - n), LocalTime.MIDNIGHT, zoneId.getValue()));
// hide popup
popup.hide();
});
lastWeek.setOnAction(event -> {
LocalDate refDay = LocalDate.now(zoneId.getValue()).minusWeeks(1);
int n = refDay.getDayOfWeek().getValue();
applyNewTimeRange.accept(
ZonedDateTime.of(refDay.minusDays(n - 1), LocalTime.MIDNIGHT, zoneId.getValue()),
ZonedDateTime.of(refDay.plusDays(8 - n), LocalTime.MIDNIGHT, zoneId.getValue()));
// hide popup
popup.hide();
});
minus1Hour.setOnAction(event -> backward(Duration.ofHours(1)));
plus1Hour.setOnAction(event -> forward(Duration.ofHours(1)));
minus24Hours.setOnAction(event -> backward(Duration.ofHours(24)));
plus24Hours.setOnAction(event -> forward(Duration.ofHours(24)));
copyTimeRangeButton.setOnAction(event -> {
try {
final ClipboardContent content = new ClipboardContent();
content.put(TimeRange.TIME_RANGE_DATA_FORMAT, timeRange.getValue().serialize());
Clipboard.getSystemClipboard().setContent(content);
} catch (Exception e) {
logger.error("Failed to copy time range to clipboard", e);
}
});
pasteTimeRangeButton.setOnAction(event -> {
try {
if (Clipboard.getSystemClipboard().hasContent(TimeRange.TIME_RANGE_DATA_FORMAT)) {
String content = (String) Clipboard.getSystemClipboard().getContent(TimeRange.TIME_RANGE_DATA_FORMAT);
TimeRange range = TimeRange.deSerialize(content);
zoneId.setValue(range.getBeginning().getZone());
selectedRange.setValue(range);
}
} catch (Exception e) {
logger.error("Failed to paste range output from clipboard", e);
}
});
}
Property<ZoneId> zoneIdProperty() {
return formatter.valueProperty();
}
}
}
@@ -0,0 +1,199 @@
/*
* Copyright 2019-2023 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.javafx.controls;
import eu.binjr.common.colors.ColorUtils;
import eu.binjr.common.javafx.bindings.BindingManager;
import eu.binjr.core.preferences.UserPreferences;
import javafx.beans.binding.Bindings;
import javafx.beans.property.Property;
import javafx.beans.value.ObservableValue;
import javafx.event.ActionEvent;
import javafx.event.EventHandler;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.ButtonBase;
import javafx.scene.control.ContentDisplay;
import javafx.scene.control.Tooltip;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Region;
import javafx.scene.paint.Color;
import javafx.util.Duration;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Supplier;
public class ToolButtonBuilder<T extends ButtonBase> {
private final BindingManager bindingManager;
private double height = 20.0;
private double width = 20.0;
private String text = "";
private String tooltip = null;
private List<String> styleClass = new ArrayList<>();
private List<String> iconStyleClass = new ArrayList<>();
private EventHandler<ActionEvent> action = null;
private final List<Consumer<T>> bindings = new ArrayList<>();
private boolean focusTraversable = true;
private ContentDisplay contentDisplay = ContentDisplay.GRAPHIC_ONLY;
private String iconColorString = null;
public ToolButtonBuilder() {
this(null);
}
public ToolButtonBuilder(BindingManager bindingManager) {
this.bindingManager = bindingManager;
}
private T buildButton(T btn) {
btn.setText(text);
if (tooltip != null) {
btn.setTooltip(makeTooltip(tooltip));
}
btn.setPrefHeight(height);
btn.setMaxHeight(height);
btn.setMinHeight(height);
btn.setPrefWidth(width);
btn.setMaxWidth(width);
btn.setMinWidth(width);
btn.setFocusTraversable(focusTraversable);
if (styleClass != null) {
btn.getStyleClass().addAll(styleClass);
}
if (iconStyleClass != null && !iconStyleClass.isEmpty()) {
Region icon = new Region();
icon.getStyleClass().addAll(iconStyleClass);
if (iconColorString != null) {
icon.setStyle("-fx-background-color:" + iconColorString + ";");
}
btn.setGraphic(icon);
btn.setAlignment(Pos.CENTER);
btn.setContentDisplay(contentDisplay);
} else {
btn.setContentDisplay(ContentDisplay.TEXT_ONLY);
}
if (action != null) {
btn.setOnAction(bindingManager != null ? bindingManager.registerHandler(action) : action);
}
bindings.forEach(buttonBaseConsumer -> buttonBaseConsumer.accept(btn));
return btn;
}
public ToolButtonBuilder<T> setHeight(double height) {
this.height = height;
return this;
}
public ToolButtonBuilder<T> setWidth(double width) {
this.width = width;
return this;
}
public ToolButtonBuilder<T> setFocusTraversable(boolean focusTraversable) {
this.focusTraversable = focusTraversable;
return this;
}
public ToolButtonBuilder<T> setText(String text) {
this.text = text;
return this;
}
public ToolButtonBuilder<T> setTooltip(String tooltip) {
this.tooltip = tooltip;
return this;
}
public ToolButtonBuilder<T> setStyleClass(String... styleClass) {
this.styleClass = new ArrayList<>(Arrays.asList(styleClass));
return this;
}
public ToolButtonBuilder<T> setIconStyleClass(String... iconStyleClass) {
this.iconStyleClass = new ArrayList<>(Arrays.asList(iconStyleClass));
return this;
}
public ToolButtonBuilder<T> setIconColor(Color color) {
this.iconColorString = ColorUtils.toHex(color);
return this;
}
public ToolButtonBuilder<T> setContentDisplay(ContentDisplay contentDisplay) {
this.contentDisplay = contentDisplay;
return this;
}
public ToolButtonBuilder<T> setAction(EventHandler<ActionEvent> action) {
this.action = action;
return this;
}
public <R> ToolButtonBuilder<T> bindBidirectionnal(Function<T, Property<R>> mapper, Property<R> property) {
bindings.add(t -> {
if (bindingManager != null) {
bindingManager.bindBidirectional(mapper.apply(t), property);
} else {
mapper.apply(t).bindBidirectional(property);
}
});
return this;
}
public <R, U extends R> ToolButtonBuilder<T> bind(Function<T, Property<R>> mapper, ObservableValue<U> observableValue) {
bindings.add(buttonBase -> {
if (bindingManager != null) {
bindingManager.bind(mapper.apply(buttonBase), observableValue);
} else {
mapper.apply(buttonBase).bind(observableValue);
}
});
return this;
}
public T build(Supplier<T> generator) {
return buildButton(generator.get());
}
public static Node makeIconNode(Pos position, String... iconStyles) {
return makeIconNode(position, 0, 0, iconStyles);
}
public static Node makeIconNode(Pos position, double width, double height, String... iconStyles) {
Region r = new Region();
r.setMinSize(width, height);
r.getStyleClass().addAll(iconStyles);
HBox box = new HBox(r);
box.setAlignment(position);
box.getStyleClass().add("icon-container");
return box;
}
private Tooltip makeTooltip(String text) {
var tooltip = new Tooltip(text);
var delayProp = UserPreferences.getInstance().tooltipShowDelayMs.property();
tooltip.showDelayProperty().bind(Bindings.createObjectBinding(() -> Duration.millis(delayProp.getValue().doubleValue()), delayProp));
return tooltip;
}
}
@@ -0,0 +1,192 @@
/*
* Copyright 2017-2018 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.javafx.controls;
import javafx.collections.FXCollections;
import javafx.scene.control.TreeItem;
import java.util.*;
import java.util.function.Predicate;
/**
* Helper methods to walk JavaFX TreeViews
*
* @author Frederic Thevenet
*/
public class TreeViewUtils {
public enum ExpandDirection {
UP,
DOWN,
BOTH
}
/**
* Finds the first tree item that matches the provided predicate.
*
* @param currentTreeItem the node in the tree where to start searching.
* @param predicate the predicate onto witch the search is based.
* @param <T> the type for the tree item
* @return an {@link Optional} encapsulating the {@link TreeItem} instance matching the predicate.
*/
public static <T> Optional<TreeItem<T>> findFirstInTree(TreeItem<T> currentTreeItem, Predicate<TreeItem<T>> predicate) {
if (predicate.test(currentTreeItem)) {
return Optional.of(currentTreeItem);
}
if (!currentTreeItem.isLeaf()) {
for (TreeItem<T> item : currentTreeItem.getChildren()) {
Optional<TreeItem<T>> res = findFirstInTree(item, predicate);
if (res.isPresent()) {
return res;
}
}
}
return Optional.empty();
}
/**
* Finds all tree items that match the provided predicate.
*
* @param currentTreeItem the node in the tree where to start searching.
* @param predicate the predicate onto witch the search is based.
* @param <T> the type for the tree item
* @return a list of {@link Optional} encapsulating the {@link TreeItem} instances matching the predicate.
*/
public static <T> List<TreeItem<T>> findAllInTree(TreeItem<T> currentTreeItem, Predicate<TreeItem<T>> predicate) {
return findAllInTree(currentTreeItem, predicate, new ArrayList<>());
}
private static <T> List<TreeItem<T>> findAllInTree(TreeItem<T> currentTreeItem, Predicate<TreeItem<T>> predicate, List<TreeItem<T>> found) {
if (predicate.test(currentTreeItem)) {
found.add(currentTreeItem);
}
if (!currentTreeItem.isLeaf()) {
for (TreeItem<T> item : currentTreeItem.getChildren()) {
findAllInTree(item, predicate, found);
}
}
return found;
}
public static <T> List<T> flattenLeaves(TreeItem<T> branch) {
return flattenLeaves(branch, false);
}
public static <T> List<T> flattenLeaves(TreeItem<T> branch, boolean expand) {
List<T> leaves = new ArrayList<>();
flattenLeaves(branch, leaves, expand);
return leaves;
}
private static <T> void flattenLeaves(TreeItem<T> branch, List<T> leaves, boolean expand) {
if (expand) {
branch.setExpanded(true);
}
if (!branch.isLeaf()) {
for (TreeItem<T> t : branch.getChildren()) {
flattenLeaves(t, leaves, expand);
}
} else {
leaves.add(branch.getValue());
}
}
public static <T> List<TreeItem<T>> splitAboveLeaves(TreeItem<T> branch) {
return splitAboveLeaves(branch, false);
}
public static <T> List<TreeItem<T>> splitAboveLeaves(TreeItem<T> branch, boolean expand) {
List<TreeItem<T>> level1Items = new ArrayList<>();
if (branch.isLeaf()) {
level1Items.add(branch);
} else {
splitAboveLeaves(branch, level1Items, expand);
}
return level1Items;
}
//FIXME: The following implementation assumes that a leaf node cannot have a sibling which isn't also a leaf.
private static <T> void splitAboveLeaves(TreeItem<T> branch, List<TreeItem<T>> level1Items, boolean expand) {
if (expand) {
branch.setExpanded(true);
}
if (!branch.isLeaf() && branch.getChildren().get(0).isLeaf()) {
level1Items.add(branch);
} else {
branch.setExpanded(true);
for (TreeItem<T> t : branch.getChildren()) {
splitAboveLeaves(t, level1Items, expand);
}
}
}
public static <T> void expandBranch(TreeItem<T> branch, ExpandDirection direction) {
if (EnumSet.of(ExpandDirection.BOTH, ExpandDirection.UP).contains(direction)) {
climbUpFromBranch(branch, true);
}
if (EnumSet.of(ExpandDirection.BOTH, ExpandDirection.DOWN).contains(direction)) {
climbDownFromBranch(branch, true);
}
}
public static <T> void collapseBranch(TreeItem<T> branch, ExpandDirection direction) {
if (EnumSet.of(ExpandDirection.BOTH, ExpandDirection.UP).contains(direction)) {
climbUpFromBranch(branch, false);
}
if (EnumSet.of(ExpandDirection.BOTH, ExpandDirection.DOWN).contains(direction)) {
climbDownFromBranch(branch, false);
}
}
public static <T> void sortFromBranch(TreeItem<T> branch) {
if (!branch.isLeaf()){
branch.getChildren().forEach(TreeViewUtils::sortFromBranch);
}
FXCollections.sort(branch.getChildren(),
(o1, o2) -> {
Objects.requireNonNull(o1);
Objects.requireNonNull(o2);
if (o1.isLeaf() && !o2.isLeaf()) {
return 1;
}
if (!o1.isLeaf() && o2.isLeaf()) {
return -1;
}
return o1.getValue().toString().compareToIgnoreCase(o2.getValue().toString());
});
}
private static <T> void climbUpFromBranch(TreeItem<T> branch, boolean expanded) {
if (branch == null) {
return;
}
branch.setExpanded(expanded);
climbUpFromBranch(branch.getParent(), expanded);
}
private static <T> void climbDownFromBranch(TreeItem<T> branch, boolean expanded) {
if (branch == null) {
return;
}
branch.setExpanded(expanded);
if (branch.getChildren() != null) {
for (TreeItem<?> item : branch.getChildren()) {
climbDownFromBranch(item, expanded);
}
}
}
}
@@ -0,0 +1,187 @@
/*
* Copyright 2016-2021 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.javafx.controls;
import eu.binjr.common.logging.Logger;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.Property;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.value.ChangeListener;
import javafx.scene.control.DatePicker;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.util.StringConverter;
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.FormatStyle;
/**
* A date and time picker control that works with {@link java.time.ZonedDateTime}
*
* @author Frederic Thevenet
*/
public class ZonedDateTimePicker extends DatePicker {
private static final Logger logger = Logger.create(ZonedDateTimePicker.class);
private final Property<ZoneId> zoneId;
private final ObjectProperty<ZonedDateTime> dateTimeValue;//
/**
* Initializes a new instance of the {@link ZonedDateTimePicker} class with the system's default timezone
*/
public ZonedDateTimePicker() {
this(ZoneId.systemDefault());
}
public ZonedDateTimePicker(ZoneId zoneId) {
this(new SimpleObjectProperty<>(zoneId));
}
/**
* Initializes a new instance of the {@link ZonedDateTimePicker} class with the provided timezone
*
* @param zoneIdProperty the timezone id to use in the control
*/
public ZonedDateTimePicker(SimpleObjectProperty<ZoneId> zoneIdProperty) {
this.zoneId = zoneIdProperty;
dateTimeValue = new SimpleObjectProperty<>(ZonedDateTime.now(zoneIdProperty.get()));
getStyleClass().add("datetime-picker");
setConverter(new StringConverter<LocalDate>() {
public String toString(LocalDate object) {
ZonedDateTime value = getDateTimeValue();
return (value != null) ? value.format(getFormatter()) : "";
}
public LocalDate fromString(String stringValue) {
if (stringValue == null || stringValue.isEmpty()) {
dateTimeValue.set(null);
return null;
}
try {
dateTimeValue.set(ZonedDateTime.parse(stringValue, getFormatter()));
logger.trace(() -> "zonedId for dateTimeValue=" + dateTimeValue.get().getZone());
} catch (Exception ex) {
logger.debug("Error parsing date", ex);
throw ex;
}
return dateTimeValue.get().toLocalDate();
}
});
ChangeListener<LocalDate> localDateChangeListener = (observable, oldValue, newValue) -> {
if (newValue != null) {
if (dateTimeValue.get() == null) {
dateTimeValue.set(ZonedDateTime.of(newValue, LocalTime.now(), getZoneId()));
} else {
LocalTime time = dateTimeValue.get().toLocalTime();
dateTimeValue.set(ZonedDateTime.of(newValue, time, getZoneId()));
}
}
};
valueProperty().addListener(localDateChangeListener);
ChangeListener<ZoneId> zoneIdChangeListener = (observable, oldValue, newValue) -> {
if (newValue != null) {
dateTimeValue.setValue(dateTimeValue.get().withZoneSameInstant(newValue));
}
};
dateTimeValue.addListener((observable, oldValue, newValue) -> {
valueProperty().removeListener(localDateChangeListener);
setValue(null);
setValue(newValue == null ? null : newValue.toLocalDate());
valueProperty().addListener(localDateChangeListener);
});
getEditor().focusedProperty().addListener((observable, oldValue, newValue) -> {
if (!newValue) {
KeyEvent ke = new KeyEvent(KeyEvent.KEY_RELEASED,
KeyCode.ENTER.toString(), KeyCode.ENTER.toString(),
KeyCode.ENTER, false, false, false, false);
getEditor().getParent().fireEvent(ke);
}
});
this.zoneId.addListener(zoneIdChangeListener);
}
DateTimeFormatter getFormatter() {
return DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT, FormatStyle.MEDIUM).withZone(getZoneId());
}
//region [Properties]
/**
* Gets the selected date/time value of the control.
*
* @return the selected date/time value of the control.
*/
public ZonedDateTime getDateTimeValue() {
return dateTimeValue.get();
}
/**
* Sets the selected date/time value of the control.
*
* @param dateTimeValue the selected date/time value of the control.
*/
public void setDateTimeValue(ZonedDateTime dateTimeValue) {
this.dateTimeValue.set(dateTimeValue);
}
/**
* Returns the value property for the selected date/time
*
* @return the value property for the selected date/time
*/
public ObjectProperty<ZonedDateTime> dateTimeValueProperty() {
return dateTimeValue;
}
/**
* Gets the {@link ZoneId} of the date picker
*
* @return the {@link ZoneId} of the date picker
*/
public ZoneId getZoneId() {
return zoneId.getValue();
}
/**
* The zoneId property
*
* @return the zoneId property
*/
public Property<ZoneId> zoneIdProperty() {
return zoneId;
}
/**
* Sets the {@link ZoneId} of the date picker
*
* @param zoneId the {@link ZoneId} of the date picker
*/
public void setZoneId(ZoneId zoneId) {
this.zoneId.setValue(zoneId);
}
//endregion
}
@@ -0,0 +1,175 @@
/*
* Copyright 2020-2021 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.javafx.richtext;
import eu.binjr.common.logging.Logger;
import org.fxmisc.richtext.model.StyleSpans;
import org.fxmisc.richtext.model.StyleSpansBuilder;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.regex.PatternSyntaxException;
public class CodeAreaHighlighter {
private static final Logger logger = Logger.create(CodeAreaHighlighter.class);
private static final Pattern XML_TAG = Pattern.compile(
"(?<ELEMENT>(</?\\??\\h*)([\\w:\\-_]+)([^<>]*)(\\h*/?\\??>))" +
"|(?<COMMENT><!--[^<>]+-->)");
private static final Pattern LOGS_SEVERITY = Pattern.compile(
"(?i)\\[TRACE|DEBUG|PERF|NOTE|INFO|WARN|ERROR|FATAL\\s?\\]");
private static final Pattern ATTRIBUTES = Pattern.compile("([\\w:]+\\h*)(=)(\\h*\"[^\"]*\")");
private static final int GROUP_OPEN_BRACKET = 2;
private static final int GROUP_ELEMENT_NAME = 3;
private static final int GROUP_ATTRIBUTES_SECTION = 4;
private static final int GROUP_CLOSE_BRACKET = 5;
private static final int GROUP_ATTRIBUTE_NAME = 1;
private static final int GROUP_EQUAL_SYMBOL = 2;
private static final int GROUP_ATTRIBUTE_VALUE = 3;
public static final String SEARCH_RESULT_HIGHLIGHT = "search-result-highlight";
public static class SearchHitRange {
private final int start;
private final int end;
private SearchHitRange(int start, int end) {
this.start = start;
this.end = end;
}
public int getStart() {
return start;
}
public int getEnd() {
return end;
}
}
public static class SearchHighlightResults {
private final List<SearchHitRange> searchHitRanges;
private final StyleSpans<Collection<String>> styleSpans;
private SearchHighlightResults(List<SearchHitRange> searchHitRanges, StyleSpans<Collection<String>> styleSpans) {
this.searchHitRanges = searchHitRanges;
this.styleSpans = styleSpans;
}
public List<SearchHitRange> getSearchHitRanges() {
return searchHitRanges;
}
public StyleSpans<Collection<String>> getStyleSpans() {
return styleSpans;
}
}
public static SearchHighlightResults computeSearchHitsHighlighting(String text,
String searchText,
boolean matchCase,
boolean regEx) throws HighlightPatternException {
StringBuilder searchExpression = new StringBuilder();
if (!regEx) {
searchText.codePoints().forEachOrdered(value -> {
var c = Character.toString(value);
String regExSpecialChars = "\\^$.|?*+()[]{}";
if (regExSpecialChars.contains(c)) {
searchExpression.append("\\");
}
searchExpression.append(c);
});
} else {
searchExpression.append(searchText);
}
if (!matchCase) {
searchExpression.insert(0, "(?i)");
}
List<SearchHitRange> hits = new ArrayList<>();
int lastKwEnd = 0;
StyleSpansBuilder<Collection<String>> spansBuilder = new StyleSpansBuilder<>();
try {
var searchPattern = Pattern.compile(searchExpression.toString());
if (searchText != null && !searchText.isEmpty()) {
Matcher searchMatcher = searchPattern.matcher(text);
while (searchMatcher.find()) {
spansBuilder.add(Collections.emptyList(), searchMatcher.start() - lastKwEnd);
spansBuilder.add(Collections.singleton(SEARCH_RESULT_HIGHLIGHT), searchMatcher.end() - searchMatcher.start());
hits.add(new SearchHitRange(searchMatcher.start(), searchMatcher.end()));
lastKwEnd = searchMatcher.end();
}
}
} catch (PatternSyntaxException e) {
throw new HighlightPatternException(e);
}
spansBuilder.add(Collections.emptyList(), text.length() - lastKwEnd);
return new SearchHighlightResults(hits, spansBuilder.create());
}
public static StyleSpans<Collection<String>> computeLogsSyntaxHighlighting(String text) {
Matcher matcher = LOGS_SEVERITY.matcher(text);
int lastKwEnd = 0;
StyleSpansBuilder<Collection<String>> spansBuilder = new StyleSpansBuilder<>();
while (matcher.find()) {
spansBuilder.add(Collections.emptyList(), matcher.start() - lastKwEnd);
spansBuilder.add(Collections.singleton("comment"), matcher.end() - matcher.start());
lastKwEnd = matcher.end();
}
spansBuilder.add(Collections.emptyList(), text.length() - lastKwEnd);
return spansBuilder.create();
}
public static StyleSpans<Collection<String>> computeXmlSyntaxHighlighting(String text) {
Matcher matcher = XML_TAG.matcher(text);
int lastKwEnd = 0;
StyleSpansBuilder<Collection<String>> spansBuilder = new StyleSpansBuilder<>();
while (matcher.find()) {
spansBuilder.add(Collections.emptyList(), matcher.start() - lastKwEnd);
if (matcher.group("COMMENT") != null) {
spansBuilder.add(Collections.singleton("comment"), matcher.end() - matcher.start());
} else {
if (matcher.group("ELEMENT") != null) {
String attributesText = matcher.group(GROUP_ATTRIBUTES_SECTION);
spansBuilder.add(Collections.singleton("tagmark"), matcher.end(GROUP_OPEN_BRACKET) - matcher.start(GROUP_OPEN_BRACKET));
spansBuilder.add(Collections.singleton("anytag"), matcher.end(GROUP_ELEMENT_NAME) - matcher.end(GROUP_OPEN_BRACKET));
if (!attributesText.isEmpty()) {
lastKwEnd = 0;
Matcher amatcher = ATTRIBUTES.matcher(attributesText);
while (amatcher.find()) {
spansBuilder.add(Collections.emptyList(), amatcher.start() - lastKwEnd);
spansBuilder.add(Collections.singleton("attribute"), amatcher.end(GROUP_ATTRIBUTE_NAME) - amatcher.start(GROUP_ATTRIBUTE_NAME));
spansBuilder.add(Collections.singleton("tagmark"), amatcher.end(GROUP_EQUAL_SYMBOL) - amatcher.end(GROUP_ATTRIBUTE_NAME));
spansBuilder.add(Collections.singleton("avalue"), amatcher.end(GROUP_ATTRIBUTE_VALUE) - amatcher.end(GROUP_EQUAL_SYMBOL));
lastKwEnd = amatcher.end();
}
if (attributesText.length() > lastKwEnd)
spansBuilder.add(Collections.emptyList(), attributesText.length() - lastKwEnd);
}
lastKwEnd = matcher.end(GROUP_ATTRIBUTES_SECTION);
spansBuilder.add(Collections.singleton("tagmark"), matcher.end(GROUP_CLOSE_BRACKET) - lastKwEnd);
}
}
lastKwEnd = matcher.end();
}
spansBuilder.add(Collections.emptyList(), text.length() - lastKwEnd);
return spansBuilder.create();
}
}
@@ -0,0 +1,35 @@
/*
* Copyright 2021 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.javafx.richtext;
public class HighlightPatternException extends Exception {
public HighlightPatternException() {
super("Invalid Pattern for highlight expression");
}
public HighlightPatternException(String message) {
super(message);
}
public HighlightPatternException(String message, Throwable cause) {
super(message, cause);
}
public HighlightPatternException(Throwable cause) {
super("Invalid Pattern for highlight expression", cause);
}
}
@@ -0,0 +1,36 @@
/*
* Copyright 2022 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.json.adapters;
import com.google.gson.TypeAdapter;
import com.google.gson.stream.JsonReader;
import com.google.gson.stream.JsonWriter;
import java.io.IOException;
import java.util.Locale;
public class LocaleJsonAdapter extends TypeAdapter<Locale> {
@Override
public void write(JsonWriter jsonWriter, Locale locale) throws IOException {
jsonWriter.value(locale.toLanguageTag());
}
@Override
public Locale read(JsonReader jsonReader) throws IOException {
return Locale.forLanguageTag(jsonReader.nextString());
}
}
@@ -0,0 +1,38 @@
/*
* Copyright 2022 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.json.adapters;
import com.google.gson.TypeAdapter;
import com.google.gson.stream.JsonReader;
import com.google.gson.stream.JsonWriter;
import java.io.IOException;
import java.util.regex.Pattern;
public class PatternJsonAdapter extends TypeAdapter<Pattern> {
@Override
public void write(JsonWriter out, Pattern value) throws IOException {
out.value(value.pattern());
}
@Override
public Pattern read(final JsonReader jsonReader) throws IOException {
var pattern = jsonReader.nextString();
return Pattern.compile(pattern);
}
}
@@ -0,0 +1,62 @@
/*
* Copyright 2019 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.logging;
import org.apache.logging.log4j.Level;
import java.util.Arrays;
public enum Log4j2Level {
OFF(Level.OFF),
FATAL(Level.FATAL),
ERROR(Level.ERROR),
WARN(Level.WARN),
INFO(Level.INFO),
DEBUG(Level.DEBUG),
TRACE(Level.TRACE),
ALL(Level.ALL);
private final Level level;
Log4j2Level(Level level) {
this.level = level;
}
public Level getLevel() {
return level;
}
public static Log4j2Level valueOf(String value, Log4j2Level defaultValue) {
if (value != null &&
Arrays.stream(Log4j2Level.values()).map(Enum::toString).anyMatch(s -> s.equalsIgnoreCase(value))) {
return Log4j2Level.valueOf(value);
}
return defaultValue;
}
public static Log4j2Level valueOf(Level value){
return valueOf(value, Log4j2Level.INFO);
}
public static Log4j2Level valueOf(Level value, Log4j2Level defaultValue) {
return Arrays.stream(Log4j2Level.values())
.filter(l -> l.getLevel().equals(value))
.findFirst()
.orElse(defaultValue);
}
}
@@ -0,0 +1,811 @@
package eu.binjr.common.logging;
import org.apache.logging.log4j.Level;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Marker;
import org.apache.logging.log4j.message.Message;
import org.apache.logging.log4j.message.MessageFactory;
import org.apache.logging.log4j.spi.AbstractLogger;
import org.apache.logging.log4j.spi.ExtendedLoggerWrapper;
import org.apache.logging.log4j.util.MessageSupplier;
import org.apache.logging.log4j.util.Supplier;
/**
* Extended Logger interface with convenience methods for
* the PROFILE custom log level.
* <p>Compatible with Log4j 2.6 or higher.</p>
*/
public final class Logger extends ExtendedLoggerWrapper {
private static final long serialVersionUID = 1888347051960200L;
private final ExtendedLoggerWrapper logger;
private static final String FQCN = Logger.class.getName();
public static final Level PERF = Level.forName("PERF", 450);
private Logger(final org.apache.logging.log4j.Logger logger) {
super((AbstractLogger) logger, logger.getName(), logger.getMessageFactory());
this.logger = this;
}
/**
* Returns a custom Logger with the name of the calling class.
*
* @return The custom Logger for the calling class.
*/
public static Logger create() {
final org.apache.logging.log4j.Logger wrapped = LogManager.getLogger();
return new Logger(wrapped);
}
/**
* Returns a custom Logger using the fully qualified name of the Class as
* the Logger name.
*
* @param loggerName The Class whose name should be used as the Logger name.
* If null it will default to the calling class.
* @return The custom Logger.
*/
public static Logger create(final Class<?> loggerName) {
final org.apache.logging.log4j.Logger wrapped = LogManager.getLogger(loggerName);
return new Logger(wrapped);
}
/**
* Returns a custom Logger using the fully qualified name of the Class as
* the Logger name.
*
* @param loggerName The Class whose name should be used as the Logger name.
* If null it will default to the calling class.
* @param messageFactory The message factory is used only when creating a
* logger, subsequent use does not change the logger but will log
* a warning if mismatched.
* @return The custom Logger.
*/
public static Logger create(final Class<?> loggerName, final MessageFactory messageFactory) {
final org.apache.logging.log4j.Logger wrapped = LogManager.getLogger(loggerName, messageFactory);
return new Logger(wrapped);
}
/**
* Returns a custom Logger using the fully qualified class name of the value
* as the Logger name.
*
* @param value The value whose class name should be used as the Logger
* name. If null the name of the calling class will be used as
* the logger name.
* @return The custom Logger.
*/
public static Logger create(final Object value) {
final org.apache.logging.log4j.Logger wrapped = LogManager.getLogger(value);
return new Logger(wrapped);
}
/**
* Returns a custom Logger using the fully qualified class name of the value
* as the Logger name.
*
* @param value The value whose class name should be used as the Logger
* name. If null the name of the calling class will be used as
* the logger name.
* @param messageFactory The message factory is used only when creating a
* logger, subsequent use does not change the logger but will log
* a warning if mismatched.
* @return The custom Logger.
*/
public static Logger create(final Object value, final MessageFactory messageFactory) {
final org.apache.logging.log4j.Logger wrapped = LogManager.getLogger(value, messageFactory);
return new Logger(wrapped);
}
/**
* Returns a custom Logger with the specified name.
*
* @param name The logger name. If null the name of the calling class will
* be used.
* @return The custom Logger.
*/
public static Logger create(final String name) {
final org.apache.logging.log4j.Logger wrapped = LogManager.getLogger(name);
return new Logger(wrapped);
}
/**
* Returns a custom Logger with the specified name.
*
* @param name The logger name. If null the name of the calling class will
* be used.
* @param messageFactory The message factory is used only when creating a
* logger, subsequent use does not change the logger but will log
* a warning if mismatched.
* @return The custom Logger.
*/
public static Logger create(final String name, final MessageFactory messageFactory) {
final org.apache.logging.log4j.Logger wrapped = LogManager.getLogger(name, messageFactory);
return new Logger(wrapped);
}
/**
* Logs a message with the specific Marker at the {@code PROFILE} level.
*
* @param marker the marker data specific to this log statement
* @param msg the message string to be logged
*/
public void perf(final Marker marker, final Message msg) {
logger.logIfEnabled(FQCN, PERF, marker, msg, (Throwable) null);
}
/**
* Logs a message with the specific Marker at the {@code PROFILE} level.
*
* @param marker the marker data specific to this log statement
* @param msg the message string to be logged
* @param t A Throwable or null.
*/
public void perf(final Marker marker, final Message msg, final Throwable t) {
logger.logIfEnabled(FQCN, PERF, marker, msg, t);
}
/**
* Logs a message object with the {@code PROFILE} level.
*
* @param marker the marker data specific to this log statement
* @param message the message object to log.
*/
public void perf(final Marker marker, final Object message) {
logger.logIfEnabled(FQCN, PERF, marker, message, (Throwable) null);
}
/**
* Logs a message CharSequence with the {@code PROFILE} level.
*
* @param marker the marker data specific to this log statement
* @param message the message CharSequence to log.
* @since Log4j-2.6
*/
public void perf(final Marker marker, final CharSequence message) {
logger.logIfEnabled(FQCN, PERF, marker, message, (Throwable) null);
}
/**
* Logs a message at the {@code PROFILE} level including the stack trace of
* the {@link Throwable} {@code t} passed as parameter.
*
* @param marker the marker data specific to this log statement
* @param message the message to log.
* @param t the exception to log, including its stack trace.
*/
public void perf(final Marker marker, final Object message, final Throwable t) {
logger.logIfEnabled(FQCN, PERF, marker, message, t);
}
/**
* Logs a message at the {@code PROFILE} level including the stack trace of
* the {@link Throwable} {@code t} passed as parameter.
*
* @param marker the marker data specific to this log statement
* @param message the CharSequence to log.
* @param t the exception to log, including its stack trace.
* @since Log4j-2.6
*/
public void perf(final Marker marker, final CharSequence message, final Throwable t) {
logger.logIfEnabled(FQCN, PERF, marker, message, t);
}
/**
* Logs a message object with the {@code PROFILE} level.
*
* @param marker the marker data specific to this log statement
* @param message the message object to log.
*/
public void perf(final Marker marker, final String message) {
logger.logIfEnabled(FQCN, PERF, marker, message, (Throwable) null);
}
/**
* Logs a message with parameters at the {@code PROFILE} level.
*
* @param marker the marker data specific to this log statement
* @param message the message to log; the format depends on the message factory.
* @param params parameters to the message.
* @see #getMessageFactory()
*/
public void perf(final Marker marker, final String message, final Object... params) {
logger.logIfEnabled(FQCN, PERF, marker, message, params);
}
/**
* Logs a message with parameters at the {@code PROFILE} level.
*
* @param marker the marker data specific to this log statement
* @param message the message to log; the format depends on the message factory.
* @param p0 parameter to the message.
* @see #getMessageFactory()
* @since Log4j-2.6
*/
public void perf(final Marker marker, final String message, final Object p0) {
logger.logIfEnabled(FQCN, PERF, marker, message, p0);
}
/**
* Logs a message with parameters at the {@code PROFILE} level.
*
* @param marker the marker data specific to this log statement
* @param message the message to log; the format depends on the message factory.
* @param p0 parameter to the message.
* @param p1 parameter to the message.
* @see #getMessageFactory()
* @since Log4j-2.6
*/
public void perf(final Marker marker, final String message, final Object p0, final Object p1) {
logger.logIfEnabled(FQCN, PERF, marker, message, p0, p1);
}
/**
* Logs a message with parameters at the {@code PROFILE} level.
*
* @param marker the marker data specific to this log statement
* @param message the message to log; the format depends on the message factory.
* @param p0 parameter to the message.
* @param p1 parameter to the message.
* @param p2 parameter to the message.
* @see #getMessageFactory()
* @since Log4j-2.6
*/
public void perf(final Marker marker, final String message, final Object p0, final Object p1, final Object p2) {
logger.logIfEnabled(FQCN, PERF, marker, message, p0, p1, p2);
}
/**
* Logs a message with parameters at the {@code PROFILE} level.
*
* @param marker the marker data specific to this log statement
* @param message the message to log; the format depends on the message factory.
* @param p0 parameter to the message.
* @param p1 parameter to the message.
* @param p2 parameter to the message.
* @param p3 parameter to the message.
* @see #getMessageFactory()
* @since Log4j-2.6
*/
public void perf(final Marker marker, final String message, final Object p0, final Object p1, final Object p2,
final Object p3) {
logger.logIfEnabled(FQCN, PERF, marker, message, p0, p1, p2, p3);
}
/**
* Logs a message with parameters at the {@code PROFILE} level.
*
* @param marker the marker data specific to this log statement
* @param message the message to log; the format depends on the message factory.
* @param p0 parameter to the message.
* @param p1 parameter to the message.
* @param p2 parameter to the message.
* @param p3 parameter to the message.
* @param p4 parameter to the message.
* @see #getMessageFactory()
* @since Log4j-2.6
*/
public void perf(final Marker marker, final String message, final Object p0, final Object p1, final Object p2,
final Object p3, final Object p4) {
logger.logIfEnabled(FQCN, PERF, marker, message, p0, p1, p2, p3, p4);
}
/**
* Logs a message with parameters at the {@code PROFILE} level.
*
* @param marker the marker data specific to this log statement
* @param message the message to log; the format depends on the message factory.
* @param p0 parameter to the message.
* @param p1 parameter to the message.
* @param p2 parameter to the message.
* @param p3 parameter to the message.
* @param p4 parameter to the message.
* @param p5 parameter to the message.
* @see #getMessageFactory()
* @since Log4j-2.6
*/
public void perf(final Marker marker, final String message, final Object p0, final Object p1, final Object p2,
final Object p3, final Object p4, final Object p5) {
logger.logIfEnabled(FQCN, PERF, marker, message, p0, p1, p2, p3, p4, p5);
}
/**
* Logs a message with parameters at the {@code PROFILE} level.
*
* @param marker the marker data specific to this log statement
* @param message the message to log; the format depends on the message factory.
* @param p0 parameter to the message.
* @param p1 parameter to the message.
* @param p2 parameter to the message.
* @param p3 parameter to the message.
* @param p4 parameter to the message.
* @param p5 parameter to the message.
* @param p6 parameter to the message.
* @see #getMessageFactory()
* @since Log4j-2.6
*/
public void perf(final Marker marker, final String message, final Object p0, final Object p1, final Object p2,
final Object p3, final Object p4, final Object p5, final Object p6) {
logger.logIfEnabled(FQCN, PERF, marker, message, p0, p1, p2, p3, p4, p5, p6);
}
/**
* Logs a message with parameters at the {@code PROFILE} level.
*
* @param marker the marker data specific to this log statement
* @param message the message to log; the format depends on the message factory.
* @param p0 parameter to the message.
* @param p1 parameter to the message.
* @param p2 parameter to the message.
* @param p3 parameter to the message.
* @param p4 parameter to the message.
* @param p5 parameter to the message.
* @param p6 parameter to the message.
* @param p7 parameter to the message.
* @see #getMessageFactory()
* @since Log4j-2.6
*/
public void perf(final Marker marker, final String message, final Object p0, final Object p1, final Object p2,
final Object p3, final Object p4, final Object p5, final Object p6,
final Object p7) {
logger.logIfEnabled(FQCN, PERF, marker, message, p0, p1, p2, p3, p4, p5, p6, p7);
}
/**
* Logs a message with parameters at the {@code PROFILE} level.
*
* @param marker the marker data specific to this log statement
* @param message the message to log; the format depends on the message factory.
* @param p0 parameter to the message.
* @param p1 parameter to the message.
* @param p2 parameter to the message.
* @param p3 parameter to the message.
* @param p4 parameter to the message.
* @param p5 parameter to the message.
* @param p6 parameter to the message.
* @param p7 parameter to the message.
* @param p8 parameter to the message.
* @see #getMessageFactory()
* @since Log4j-2.6
*/
public void perf(final Marker marker, final String message, final Object p0, final Object p1, final Object p2,
final Object p3, final Object p4, final Object p5, final Object p6,
final Object p7, final Object p8) {
logger.logIfEnabled(FQCN, PERF, marker, message, p0, p1, p2, p3, p4, p5, p6, p7, p8);
}
/**
* Logs a message with parameters at the {@code PROFILE} level.
*
* @param marker the marker data specific to this log statement
* @param message the message to log; the format depends on the message factory.
* @param p0 parameter to the message.
* @param p1 parameter to the message.
* @param p2 parameter to the message.
* @param p3 parameter to the message.
* @param p4 parameter to the message.
* @param p5 parameter to the message.
* @param p6 parameter to the message.
* @param p7 parameter to the message.
* @param p8 parameter to the message.
* @param p9 parameter to the message.
* @see #getMessageFactory()
* @since Log4j-2.6
*/
public void perf(final Marker marker, final String message, final Object p0, final Object p1, final Object p2,
final Object p3, final Object p4, final Object p5, final Object p6,
final Object p7, final Object p8, final Object p9) {
logger.logIfEnabled(FQCN, PERF, marker, message, p0, p1, p2, p3, p4, p5, p6, p7, p8, p9);
}
/**
* Logs a message at the {@code PROFILE} level including the stack trace of
* the {@link Throwable} {@code t} passed as parameter.
*
* @param marker the marker data specific to this log statement
* @param message the message to log.
* @param t the exception to log, including its stack trace.
*/
public void perf(final Marker marker, final String message, final Throwable t) {
logger.logIfEnabled(FQCN, PERF, marker, message, t);
}
/**
* Logs the specified Message at the {@code PROFILE} level.
*
* @param msg the message string to be logged
*/
public void perf(final Message msg) {
logger.logIfEnabled(FQCN, PERF, null, msg, (Throwable) null);
}
/**
* Logs the specified Message at the {@code PROFILE} level.
*
* @param msg the message string to be logged
* @param t A Throwable or null.
*/
public void perf(final Message msg, final Throwable t) {
logger.logIfEnabled(FQCN, PERF, null, msg, t);
}
/**
* Logs a message object with the {@code PROFILE} level.
*
* @param message the message object to log.
*/
public void perf(final Object message) {
logger.logIfEnabled(FQCN, PERF, null, message, (Throwable) null);
}
/**
* Logs a message at the {@code PROFILE} level including the stack trace of
* the {@link Throwable} {@code t} passed as parameter.
*
* @param message the message to log.
* @param t the exception to log, including its stack trace.
*/
public void perf(final Object message, final Throwable t) {
logger.logIfEnabled(FQCN, PERF, null, message, t);
}
/**
* Logs a message CharSequence with the {@code PROFILE} level.
*
* @param message the message CharSequence to log.
* @since Log4j-2.6
*/
public void perf(final CharSequence message) {
logger.logIfEnabled(FQCN, PERF, null, message, (Throwable) null);
}
/**
* Logs a CharSequence at the {@code PROFILE} level including the stack trace of
* the {@link Throwable} {@code t} passed as parameter.
*
* @param message the CharSequence to log.
* @param t the exception to log, including its stack trace.
* @since Log4j-2.6
*/
public void perf(final CharSequence message, final Throwable t) {
logger.logIfEnabled(FQCN, PERF, null, message, t);
}
/**
* Logs a message object with the {@code PROFILE} level.
*
* @param message the message object to log.
*/
public void perf(final String message) {
logger.logIfEnabled(FQCN, PERF, null, message, (Throwable) null);
}
/**
* Logs a message with parameters at the {@code PROFILE} level.
*
* @param message the message to log; the format depends on the message factory.
* @param params parameters to the message.
* @see #getMessageFactory()
*/
public void perf(final String message, final Object... params) {
logger.logIfEnabled(FQCN, PERF, null, message, params);
}
/**
* Logs a message with parameters at the {@code PROFILE} level.
*
* @param message the message to log; the format depends on the message factory.
* @param p0 parameter to the message.
* @see #getMessageFactory()
* @since Log4j-2.6
*/
public void perf(final String message, final Object p0) {
logger.logIfEnabled(FQCN, PERF, null, message, p0);
}
/**
* Logs a message with parameters at the {@code PROFILE} level.
*
* @param message the message to log; the format depends on the message factory.
* @param p0 parameter to the message.
* @param p1 parameter to the message.
* @see #getMessageFactory()
* @since Log4j-2.6
*/
public void perf(final String message, final Object p0, final Object p1) {
logger.logIfEnabled(FQCN, PERF, null, message, p0, p1);
}
/**
* Logs a message with parameters at the {@code PROFILE} level.
*
* @param message the message to log; the format depends on the message factory.
* @param p0 parameter to the message.
* @param p1 parameter to the message.
* @param p2 parameter to the message.
* @see #getMessageFactory()
* @since Log4j-2.6
*/
public void perf(final String message, final Object p0, final Object p1, final Object p2) {
logger.logIfEnabled(FQCN, PERF, null, message, p0, p1, p2);
}
/**
* Logs a message with parameters at the {@code PROFILE} level.
*
* @param message the message to log; the format depends on the message factory.
* @param p0 parameter to the message.
* @param p1 parameter to the message.
* @param p2 parameter to the message.
* @param p3 parameter to the message.
* @see #getMessageFactory()
* @since Log4j-2.6
*/
public void perf(final String message, final Object p0, final Object p1, final Object p2,
final Object p3) {
logger.logIfEnabled(FQCN, PERF, null, message, p0, p1, p2, p3);
}
/**
* Logs a message with parameters at the {@code PROFILE} level.
*
* @param message the message to log; the format depends on the message factory.
* @param p0 parameter to the message.
* @param p1 parameter to the message.
* @param p2 parameter to the message.
* @param p3 parameter to the message.
* @param p4 parameter to the message.
* @see #getMessageFactory()
* @since Log4j-2.6
*/
public void perf(final String message, final Object p0, final Object p1, final Object p2,
final Object p3, final Object p4) {
logger.logIfEnabled(FQCN, PERF, null, message, p0, p1, p2, p3, p4);
}
/**
* Logs a message with parameters at the {@code PROFILE} level.
*
* @param message the message to log; the format depends on the message factory.
* @param p0 parameter to the message.
* @param p1 parameter to the message.
* @param p2 parameter to the message.
* @param p3 parameter to the message.
* @param p4 parameter to the message.
* @param p5 parameter to the message.
* @see #getMessageFactory()
* @since Log4j-2.6
*/
public void perf(final String message, final Object p0, final Object p1, final Object p2,
final Object p3, final Object p4, final Object p5) {
logger.logIfEnabled(FQCN, PERF, null, message, p0, p1, p2, p3, p4, p5);
}
/**
* Logs a message with parameters at the {@code PROFILE} level.
*
* @param message the message to log; the format depends on the message factory.
* @param p0 parameter to the message.
* @param p1 parameter to the message.
* @param p2 parameter to the message.
* @param p3 parameter to the message.
* @param p4 parameter to the message.
* @param p5 parameter to the message.
* @param p6 parameter to the message.
* @see #getMessageFactory()
* @since Log4j-2.6
*/
public void perf(final String message, final Object p0, final Object p1, final Object p2,
final Object p3, final Object p4, final Object p5, final Object p6) {
logger.logIfEnabled(FQCN, PERF, null, message, p0, p1, p2, p3, p4, p5, p6);
}
/**
* Logs a message with parameters at the {@code PROFILE} level.
*
* @param message the message to log; the format depends on the message factory.
* @param p0 parameter to the message.
* @param p1 parameter to the message.
* @param p2 parameter to the message.
* @param p3 parameter to the message.
* @param p4 parameter to the message.
* @param p5 parameter to the message.
* @param p6 parameter to the message.
* @param p7 parameter to the message.
* @see #getMessageFactory()
* @since Log4j-2.6
*/
public void perf(final String message, final Object p0, final Object p1, final Object p2,
final Object p3, final Object p4, final Object p5, final Object p6,
final Object p7) {
logger.logIfEnabled(FQCN, PERF, null, message, p0, p1, p2, p3, p4, p5, p6, p7);
}
/**
* Logs a message with parameters at the {@code PROFILE} level.
*
* @param message the message to log; the format depends on the message factory.
* @param p0 parameter to the message.
* @param p1 parameter to the message.
* @param p2 parameter to the message.
* @param p3 parameter to the message.
* @param p4 parameter to the message.
* @param p5 parameter to the message.
* @param p6 parameter to the message.
* @param p7 parameter to the message.
* @param p8 parameter to the message.
* @see #getMessageFactory()
* @since Log4j-2.6
*/
public void perf(final String message, final Object p0, final Object p1, final Object p2,
final Object p3, final Object p4, final Object p5, final Object p6,
final Object p7, final Object p8) {
logger.logIfEnabled(FQCN, PERF, null, message, p0, p1, p2, p3, p4, p5, p6, p7, p8);
}
/**
* Logs a message with parameters at the {@code PROFILE} level.
*
* @param message the message to log; the format depends on the message factory.
* @param p0 parameter to the message.
* @param p1 parameter to the message.
* @param p2 parameter to the message.
* @param p3 parameter to the message.
* @param p4 parameter to the message.
* @param p5 parameter to the message.
* @param p6 parameter to the message.
* @param p7 parameter to the message.
* @param p8 parameter to the message.
* @param p9 parameter to the message.
* @see #getMessageFactory()
* @since Log4j-2.6
*/
public void perf(final String message, final Object p0, final Object p1, final Object p2,
final Object p3, final Object p4, final Object p5, final Object p6,
final Object p7, final Object p8, final Object p9) {
logger.logIfEnabled(FQCN, PERF, null, message, p0, p1, p2, p3, p4, p5, p6, p7, p8, p9);
}
/**
* Logs a message at the {@code PROFILE} level including the stack trace of
* the {@link Throwable} {@code t} passed as parameter.
*
* @param message the message to log.
* @param t the exception to log, including its stack trace.
*/
public void perf(final String message, final Throwable t) {
logger.logIfEnabled(FQCN, PERF, null, message, t);
}
/**
* Logs a message which is only to be constructed if the logging level is the {@code PROFILE}level.
*
* @param msgSupplier A function, which when called, produces the desired log message;
* the format depends on the message factory.
* @since Log4j-2.4
*/
public void perf(final Supplier<?> msgSupplier) {
logger.logIfEnabled(FQCN, PERF, null, msgSupplier, (Throwable) null);
}
/**
* Logs a message (only to be constructed if the logging level is the {@code PROFILE}
* level) including the stack trace of the {@link Throwable} <code>t</code> passed as parameter.
*
* @param msgSupplier A function, which when called, produces the desired log message;
* the format depends on the message factory.
* @param t the exception to log, including its stack trace.
* @since Log4j-2.4
*/
public void perf(final Supplier<?> msgSupplier, final Throwable t) {
logger.logIfEnabled(FQCN, PERF, null, msgSupplier, t);
}
/**
* Logs a message which is only to be constructed if the logging level is the
* {@code PROFILE} level with the specified Marker.
*
* @param marker the marker data specific to this log statement
* @param msgSupplier A function, which when called, produces the desired log message;
* the format depends on the message factory.
* @since Log4j-2.4
*/
public void perf(final Marker marker, final Supplier<?> msgSupplier) {
logger.logIfEnabled(FQCN, PERF, marker, msgSupplier, (Throwable) null);
}
/**
* Logs a message with parameters which are only to be constructed if the logging level is the
* {@code PROFILE} level.
*
* @param marker the marker data specific to this log statement
* @param message the message to log; the format depends on the message factory.
* @param paramSuppliers An array of functions, which when called, produce the desired log message parameters.
* @since Log4j-2.4
*/
public void perf(final Marker marker, final String message, final Supplier<?>... paramSuppliers) {
logger.logIfEnabled(FQCN, PERF, marker, message, paramSuppliers);
}
/**
* Logs a message (only to be constructed if the logging level is the {@code PROFILE}
* level) with the specified Marker and including the stack trace of the {@link Throwable}
* <code>t</code> passed as parameter.
*
* @param marker the marker data specific to this log statement
* @param msgSupplier A function, which when called, produces the desired log message;
* the format depends on the message factory.
* @param t A Throwable or null.
* @since Log4j-2.4
*/
public void perf(final Marker marker, final Supplier<?> msgSupplier, final Throwable t) {
logger.logIfEnabled(FQCN, PERF, marker, msgSupplier, t);
}
/**
* Logs a message with parameters which are only to be constructed if the logging level is
* the {@code PROFILE} level.
*
* @param message the message to log; the format depends on the message factory.
* @param paramSuppliers An array of functions, which when called, produce the desired log message parameters.
* @since Log4j-2.4
*/
public void perf(final String message, final Supplier<?>... paramSuppliers) {
logger.logIfEnabled(FQCN, PERF, null, message, paramSuppliers);
}
/**
* Logs a message which is only to be constructed if the logging level is the
* {@code PROFILE} level with the specified Marker. The {@code MessageSupplier} may or may
* not use the {@link MessageFactory} to construct the {@code Message}.
*
* @param marker the marker data specific to this log statement
* @param msgSupplier A function, which when called, produces the desired log message.
* @since Log4j-2.4
*/
public void perf(final Marker marker, final MessageSupplier msgSupplier) {
logger.logIfEnabled(FQCN, PERF, marker, msgSupplier, (Throwable) null);
}
/**
* Logs a message (only to be constructed if the logging level is the {@code PROFILE}
* level) with the specified Marker and including the stack trace of the {@link Throwable}
* <code>t</code> passed as parameter. The {@code MessageSupplier} may or may not use the
* {@link MessageFactory} to construct the {@code Message}.
*
* @param marker the marker data specific to this log statement
* @param msgSupplier A function, which when called, produces the desired log message.
* @param t A Throwable or null.
* @since Log4j-2.4
*/
public void perf(final Marker marker, final MessageSupplier msgSupplier, final Throwable t) {
logger.logIfEnabled(FQCN, PERF, marker, msgSupplier, t);
}
/**
* Logs a message which is only to be constructed if the logging level is the
* {@code PROFILE} level. The {@code MessageSupplier} may or may not use the
* {@link MessageFactory} to construct the {@code Message}.
*
* @param msgSupplier A function, which when called, produces the desired log message.
* @since Log4j-2.4
*/
public void perf(final MessageSupplier msgSupplier) {
logger.logIfEnabled(FQCN, PERF, null, msgSupplier, (Throwable) null);
}
/**
* Logs a message (only to be constructed if the logging level is the {@code PROFILE}
* level) including the stack trace of the {@link Throwable} <code>t</code> passed as parameter.
* The {@code MessageSupplier} may or may not use the {@link MessageFactory} to construct the
* {@code Message}.
*
* @param msgSupplier A function, which when called, produces the desired log message.
* @param t the exception to log, including its stack trace.
* @since Log4j-2.4
*/
public void perf(final MessageSupplier msgSupplier, final Throwable t) {
logger.logIfEnabled(FQCN, PERF, null, msgSupplier, t);
}
}
@@ -0,0 +1,136 @@
/*
* Copyright 2019-2020 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.logging;
import org.apache.logging.log4j.Level;
import java.io.IOException;
import java.io.OutputStream;
public class LoggingOutputStream extends OutputStream {
/**
* Default number of bytes in the buffer.
*/
private static final int DEFAULT_BUFFER_LENGTH = 2048;
/**
* Indicates stream state.
*/
private boolean hasBeenClosed = false;
/**
* Internal buffer where data is stored.
*/
private byte[] buf;
/**
* The number of valid bytes in the buffer.
*/
private int count;
/**
* Remembers the size of the buffer.
*/
private int curBufLength;
/**
* The logger to write to.
*/
private Logger log;
/**
* The log level.
*/
private Level level;
/**
* Creates the Logging instance to flush to the given logger.
*
* @param log the Logger to write to
* @param level the log level
* @throws IllegalArgumentException in case if one of arguments is null.
*/
public LoggingOutputStream(final Logger log,
final Level level)
throws IllegalArgumentException {
if (log == null || level == null) {
throw new IllegalArgumentException(
"Logger or log level must be not null");
}
this.log = log;
this.level = level;
curBufLength = DEFAULT_BUFFER_LENGTH;
buf = new byte[curBufLength];
count = 0;
}
/**
* Writes the specified byte to this output stream.
*
* @param b the byte to write
* @throws IOException if an I/O error occurs.
*/
public void write(final int b) throws IOException {
if (hasBeenClosed) {
throw new IOException("The stream has been closed.");
}
// don't log nulls
if (b == 0) {
return;
}
// would this be writing past the buffer?
if (count == curBufLength) {
// grow the buffer
final int newBufLength = curBufLength +
DEFAULT_BUFFER_LENGTH;
final byte[] newBuf = new byte[newBufLength];
System.arraycopy(buf, 0, newBuf, 0, curBufLength);
buf = newBuf;
curBufLength = newBufLength;
}
buf[count] = (byte) b;
count++;
}
/**
* Flushes this output stream and forces any buffered output
* bytes to be written out.
*/
public void flush() {
if (count == 0) {
return;
}
final byte[] bytes = new byte[count];
System.arraycopy(buf, 0, bytes, 0, count);
String str = new String(bytes);
if (!str.isBlank()) {
log.log(level, str);
}
count = 0;
}
/**
* Closes this output stream and releases any system resources
* associated with this stream.
*/
public void close() {
flush();
hasBeenClosed = true;
}
}
@@ -0,0 +1,367 @@
/*
* Copyright 2016-2018 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.logging;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Supplier;
/**
* A utility class that measures and reports the execution time of a portion of
* code.
* <p>
* A time interval is measured from the creation of the object to its closing.
* It implements {@code AutoCloseable} and can be used in conjunction with a
* try-with-resource statement to measure the amount of time totalTime from
* entering to exiting the try block.
*
* @author Frederic Thevenet
*/
public final class Profiler implements AutoCloseable {
private final Elapsed elapsed;
private final OutputDelegate writeCallback;
private final AtomicBoolean closed = new AtomicBoolean(false);
private final long startTime;
private final long thresholdMs;
private Profiler(Elapsed elapsed, OutputDelegate writeCallback, long thresholdMs) {
this.elapsed = elapsed;
this.writeCallback = writeCallback;
this.thresholdMs = thresholdMs;
this.startTime = System.nanoTime();
}
/**
* Returns a new instance of the {@link Profiler} class.
*
* @param message The message associated to the perf totalTime.
* @param writeCallback The callback that will be invoked to log the results of the
* totalTime.
* @return The new instance of the Profiler class
*/
public static Profiler start(String message, OutputDelegate writeCallback) {
return new Profiler(new Elapsed(message), writeCallback, -1);
}
/**
* Returns a new instance of the {@link Profiler} class.
*
* @param messageSupplier The lambda responsible for suppling the message associated to the perf totalTime..
* @param writeCallback The callback that will be invoked to log the results of totalTime.
* @return The new instance of the Profiler class
*/
public static Profiler start(Supplier<String> messageSupplier, OutputDelegate writeCallback) {
return new Profiler(new Elapsed(messageSupplier), writeCallback, -1);
}
/**
* Returns a new instance of the {@link Profiler} class.
*
* @param message The message associated to the perf totalTime.
* @param writeCallback The callback that will be invoked to log the results of the
* totalTime.
* @param threshold If the elapsed time is greater or equal to that threshold
* value (in ms) the message is logged otherwise it isn't.
* @return The new instance of the Profiler class
*/
public static Profiler start(String message, OutputDelegate writeCallback, long threshold) {
return new Profiler(new Elapsed(message), writeCallback, threshold);
}
/**
* Returns a new instance of the {@link Profiler} class.
*
* @param messageSupplier The lambda responsible for supplying the message associated to the perf totalTime.
* @param writeCallback The callback that will be invoked to log the results of totalTime.
* @param threshold If the elapsed time is greater or equal to that threshold
* value (in ms) the message is logged otherwise it isn't.
* @return The new instance of the Profiler class
*/
public static Profiler start(Supplier<String> messageSupplier, OutputDelegate writeCallback, long threshold) {
return new Profiler(new Elapsed(messageSupplier), writeCallback, threshold);
}
/**
* Returns a new instance of the {@link Profiler} class.
*
* @param message The message associated to the perf totalTime.
* @return The newly created Profiler instance.
*/
public static Profiler start(String message) {
return new Profiler(new Elapsed(message), null, -1);
}
/**
* Returns a new instance of the {@link Profiler} class.
*
* @param messageSupplier The lambda responsible for supplying the message associated to the perf totalTime.
* @return The newly created Profiler instance.
*/
public static Profiler start(Supplier<String> messageSupplier) {
return new Profiler(new Elapsed(messageSupplier), null, -1);
}
/**
* Returns a new instance of the {@link Profiler} class.
*
* @param message The message associated to the perf totalTime.
* @param threshold If the elapsed time is greater or equal to that threshold
* value (in ms) the message is logged otherwise it isn't.
* @return The newly created Profiler instance.
*/
public static Profiler start(String message, long threshold) {
return new Profiler(new Elapsed(message), null, threshold);
}
/**
* Returns a new instance of the {@link Profiler} class.
*
* @param messageSupplier The lambda responsible for supplying the message associated to the perf totalTime.
* @param threshold If the elapsed time is greater or equal to that threshold
* value (in ms) the message is logged otherwise it isn't.
* @return The newly created Profiler instance.
*/
public static Profiler start(Supplier<String> messageSupplier, long threshold) {
return new Profiler(new Elapsed(messageSupplier), null, threshold);
}
/**
* Returns a new instance of the {@link Profiler} class.
*
* @param writeCallback The callback that will be invoked to log the results of the
* totalTime.
* @return The new instance of the Profiler class.
*/
public static Profiler start(OutputDelegate writeCallback) {
return new Profiler(new Elapsed(""), writeCallback, -1);
}
/**
* Returns a new instance of the {@link Profiler} class.
*
* @param writeCallback The callback that will be invoked to log the results of the
* totalTime.
* @param threshold If the elapsed time is greater or equal to that threshold
* value (in ms) the message is logged otherwise it isn't.
* @return The new instance of the Profiler class.
*/
public static Profiler start(OutputDelegate writeCallback, long threshold) {
return new Profiler(new Elapsed(""), writeCallback, threshold);
}
/**
* Returns a new instance of the {@link Profiler} class.
*
* @param elapsed A Elapsed object that will be used to store the results of the
* totalTime.
* @return The newly created Profiler instance.
*/
public static Profiler start(Elapsed elapsed) {
return new Profiler(elapsed, null, -1);
}
/**
* Returns a new instance of the {@link Profiler} class.
*
* @param elapsed A Elapsed object that will be used to store the results of the
* totalTime.
* @param threshold If the elapsed time is greater or equal to that threshold
* value (in ms) the message is logged otherwise it isn't.
* @return The newly created Profiler instance.
*/
public static Profiler start(Elapsed elapsed, long threshold) {
return new Profiler(elapsed, null, threshold);
}
@Override
public void close() {
if (closed.compareAndSet(false, true)) {
long stopTime = System.nanoTime();
this.elapsed.nanoSec += stopTime - this.startTime;
if (writeCallback != null) {
if (this.elapsed.getMillis() >= thresholdMs) {
writeCallback.invoke(this.elapsed);
}
}
}
}
/**
* A functional interface that represents the action to output the measured
* interval (for compatibility with &lt; 1.8)
*/
public interface OutputDelegate {
/**
* Invoke
*
* @param e Elapsed
*/
void invoke(Elapsed e);
}
/**
* A class that encapsulate the interval measured by a {@link Profiler}
* object.
*/
public static class Elapsed {
private Supplier<String> messageSupplier;
private long nanoSec;
/**
* Initializes a new instance of the {@link Elapsed} class.
*/
public Elapsed() {
this("", 0);
}
/**
* Initializes a new instance of the {@link Elapsed} class with the
* specified message.
*
* @param message The specified message
*/
public Elapsed(String message) {
this(message, 0);
}
public Elapsed(Supplier<String> messageSupplier) {
this(messageSupplier, 0);
}
/**
* Initializes a new instance of the {@link Elapsed} class with the
* specified message and initial value for the totalTime time.
*
* @param intialValue the initial value for the totalTime time.
* @param message The specified message
*/
public Elapsed(String message, long intialValue) {
this(() -> message, intialValue);
}
public Elapsed(Supplier<String> messageSupplier, long intialValue) {
this.nanoSec = intialValue;
this.setMessageSupplier(messageSupplier);
}
/**
* Gets the {@link Elapsed}'s message.
*
* @return the perf totalTime's message.
*/
public String getMessage() {
if (messageSupplier != null) {
return messageSupplier.get();
}
return null;
}
/**
* Sets the {@link Elapsed}'s message.
*
* @param messageSupplier the perf totalTime's message.
*/
public void setMessageSupplier(Supplier<String> messageSupplier) {
this.messageSupplier = messageSupplier;
}
/**
* Gets the {@link Elapsed} time in ns.
*
* @return the totalTime time in ns.
*/
public long getNanos() {
return nanoSec;
}
/**
* Gets the {@link Elapsed} time in μs.
*
* @return the totalTime time in μs.
*/
public long getMicros() {
return Math.round(nanoSec * Math.pow(10, -3));
}
/**
* Gets the {@link Elapsed} time in ms.
*
* @return the totalTime time in ms.
*/
public long getMillis() {
return Math.round(nanoSec * Math.pow(10, -6));
}
/**
* Gets the {@link Elapsed} time in s.
*
* @return the totalTime time in s.
*/
public long getSeconds() {
return Math.round(nanoSec * Math.pow(10, -9));
}
@Override
public String toString() {
return this.getMessage() + ": " + getMillis() + " ms";
}
/**
* Returns a string representation of the profiler, composed ot the
* message and the time interval in ns.
*
* @return a string representation of the profiler, composed ot the
* message and the time interval in ns.
*/
public String toNanoString() {
return this.getMessage() + ": " + getNanos() + " ns";
}
/**
* Returns a string representation of the profiler, composed ot the
* message and the time interval in μs.
*
* @return a string representation of the profiler, composed ot the
* message and the time interval in μs.
*/
public String toMicroString() {
return this.getMessage() + ": " + getMicros() + " μs";
}
/**
* Returns a string representation of the profiler, composed ot the
* message and the time interval in ms.
*
* @return a string representation of the profiler, composed ot the
* message and the time interval in ms.
*/
public String toMilliString() {
return this.getMessage() + ": " + getMillis() + " ms";
}
/**
* Returns a string representation of the profiler, composed ot the
* message and the time interval in s.
*
* @return a string representation of the profiler, composed ot the
* message and the time interval in s.
*/
public String toSecondString() {
return this.getMessage() + ": " + getSeconds() + " s";
}
}
}
@@ -0,0 +1,199 @@
/*
* Copyright 2017-2022 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.logging;
import eu.binjr.core.dialogs.Dialogs;
import eu.binjr.core.preferences.AppEnvironment;
import eu.binjr.core.preferences.UserPreferences;
import org.apache.logging.log4j.Level;
import org.apache.logging.log4j.core.Filter;
import org.apache.logging.log4j.core.Layout;
import org.apache.logging.log4j.core.LogEvent;
import org.apache.logging.log4j.core.appender.AbstractAppender;
import org.apache.logging.log4j.core.config.Property;
import org.apache.logging.log4j.core.config.plugins.Plugin;
import org.apache.logging.log4j.core.config.plugins.PluginAttribute;
import org.apache.logging.log4j.core.config.plugins.PluginElement;
import org.apache.logging.log4j.core.config.plugins.PluginFactory;
import org.apache.logging.log4j.core.layout.PatternLayout;
import java.io.Serializable;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.Consumer;
/**
* TextFlowAppender for Log4j 2
*
* @author Frederic Thevenet
*/
@Plugin(
name = "TextFlowAppender",
category = "Core",
elementType = "appender",
printObject = true)
public class TextFlowAppender extends AbstractAppender {
private final Lock renderTextLock = new ReentrantLock();
private final Map<Level, String> logColors = new HashMap<>();
private final String defaultColor = "log-info";
private final LogBuffer logBuffer = new LogBuffer();
private Consumer<Collection<Log>> renderTextDelegate;
protected TextFlowAppender(String name, Filter filter,
Layout<? extends Serializable> layout,
final boolean ignoreExceptions) {
super(name, filter, layout, ignoreExceptions, Property.EMPTY_ARRAY);
logColors.put(Level.TRACE, "trace");
logColors.put(Level.DEBUG, "debug");
logColors.put(Logger.PERF, "perf");
logColors.put(Level.INFO, "info");
logColors.put(Level.WARN, "warn");
logColors.put(Level.ERROR, "error");
logColors.put(Level.FATAL, "fatal");
Timer timer = new Timer(true);
timer.schedule(new TimerTask() {
@Override
public void run() {
if (AppEnvironment.getInstance().isDebugMode()) {
refreshTextFlow();
}
}
}, 500, 500);
}
/**
* Factory method. Log4j will parse the configuration and call this factory
* method to construct the appender with
* the configured attributes.
*
* @param name Name of appender
* @param layout Log layout of appender
* @param filter Filter for appender
* @return The TextFlowAppender
*/
@PluginFactory
public static TextFlowAppender createAppender(
@PluginAttribute("name") String name,
@PluginElement("Layout") Layout<? extends Serializable> layout,
@PluginElement("Filter") final Filter filter) {
if (name == null) {
LOGGER.error("No name provided for TextFlowAppender");
return null;
}
if (layout == null) {
layout = PatternLayout.createDefaultLayout();
}
return new TextFlowAppender(name, filter, layout, true);
}
public void setRenderTextDelegate(Consumer<Collection<Log>> delegate) {
this.renderTextDelegate = delegate;
}
/**
* Clear the circular buffer used by the appender
*/
public void clearBuffer() {
renderTextLock.lock();
try {
logBuffer.clear();
} finally {
renderTextLock.unlock();
}
}
/**
* This method is where the appender does the work.
*
* @param event Log event with log data
*/
@Override
public void append(LogEvent event) {
renderTextLock.lock();
try {
new String(getLayout().toByteArray(event), StandardCharsets.UTF_8).lines().forEach(
message -> {
Log log = new Log(message, logColors.getOrDefault(event.getLevel(), defaultColor));
logBuffer.push(log);
});
} finally {
renderTextLock.unlock();
}
}
private void refreshTextFlow() {
Dialogs.runOnFXThread(() -> {
if (renderTextLock.tryLock()) {
try {
if (renderTextDelegate != null && logBuffer.isDirty()) {
logBuffer.clean();
renderTextDelegate.accept(logBuffer.getLogs());
}
} finally {
renderTextLock.unlock();
}
}
});
}
public record Log(String message, String styleClass) {
public String getMessage() {
return message;
}
public String getStyleClass() {
return styleClass;
}
}
private static class LogBuffer {
private volatile boolean dirty;
private long lineCounter = 0;
private final LinkedHashMap<Long, Log> buffer = new LinkedHashMap<>() {
@Override
protected boolean removeEldestEntry(Map.Entry<Long, Log> eldest) {
return size() > UserPreferences.getInstance().consoleMaxLineCapacity.get().intValue();
}
};
public void clear() {
dirty = true;
buffer.clear();
lineCounter = 0;
}
public void clean() {
this.dirty = false;
}
public Log push(Log log) {
dirty = true;
return buffer.put(lineCounter++, log);
}
public boolean isDirty() {
return dirty;
}
public Collection<Log> getLogs() {
return buffer.values();
}
}
}
@@ -0,0 +1,155 @@
/*
* Copyright 2020 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.navigation;
import eu.binjr.common.logging.Logger;
import javafx.beans.property.Property;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleObjectProperty;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Wraps a stack to record user navigation steps.
*/
public class NavigationHistory<T> {
private static final Logger logger = Logger.create(NavigationHistory.class);
private final HistoryQueue<T> forward = new HistoryQueue<>();
private final HistoryQueue<T> backward = new HistoryQueue<>();
public T getHead() {
return head.getValue();
}
public Property<T> headProperty() {
return head;
}
private final Property<T> head = new SimpleObjectProperty<>();
private T previousItem;
public HistoryQueue<T> forward() {
return forward;
}
public HistoryQueue<T> backward() {
return backward;
}
public synchronized void clear() {
forward.clear();
backward.clear();
}
public synchronized void setHead(T item, boolean save) {
if (save) {
logger.debug((() -> "Saving to history: item=" + item.toString() + " previous=" + previousItem.toString()));
this.backward.push(previousItem);
this.forward.clear();
}
previousItem = item;
head.setValue(item);
}
public Optional<T> getPrevious() {
return restoreSelectionFromHistory(backward, forward);
}
public Optional<T> getNext() {
return restoreSelectionFromHistory(forward, backward);
}
public synchronized String dump() {
return "Backward navigation history:\n" + backward.dump() +
"Forward navigation history:\n" + forward.dump();
}
private synchronized Optional<T> restoreSelectionFromHistory(HistoryQueue<T> history, HistoryQueue<T> toHistory) {
if (!history.isEmpty()) {
toHistory.push(getHead());
return Optional.of(history.pop());
} else {
logger.debug(() -> "NavigationHistory is empty: nothing to go back to.");
return Optional.empty();
}
}
public class HistoryQueue<T> {
private final Deque<T> stack = new ArrayDeque<>();
private final SimpleBooleanProperty empty = new SimpleBooleanProperty(true);
private void push(T state) {
if (state == null) {
logger.warn(() -> "Trying to push null state into backwardHistory");
return;
}
empty.set(false);
this.stack.push(state);
}
/**
* Clears the history
*/
private void clear() {
this.stack.clear();
empty.set(true);
}
private T pop() {
T r = this.stack.pop();
empty.set(stack.isEmpty());
return r;
}
public SimpleBooleanProperty emptyProperty() {
return empty;
}
/**
* Returns true if the underlying stack is empty, false otherwise.
*
* @return true if the underlying stack is empty, false otherwise.
*/
public boolean isEmpty() {
return empty.get();
}
/**
* Dumps the content of the stack as a string
*
* @return the content of the stack as a string
*/
private String dump() {
final StringBuilder sb = new StringBuilder("");
AtomicInteger pos = new AtomicInteger(0);
if (this.isEmpty()) {
sb.append(" { empty }");
} else {
stack.forEach(h -> sb.append(pos.incrementAndGet()).append(" ->").append(h.toString()).append("\n"));
}
return sb.toString();
}
}
}
@@ -0,0 +1,72 @@
/*
* Copyright 2020 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.navigation;
import java.util.Iterator;
import java.util.List;
public class RingIterator<E> implements Iterator<E> {
private final List<E> wrappedList;
private final int lastIdx;
private int idx = -1;
private RingIterator(List<E> wrappedList) {
this.wrappedList = wrappedList;
lastIdx = wrappedList.size() - 1;
}
public static <T> RingIterator<T> of(List<T> list) {
return new RingIterator<>(list);
}
@Override
public boolean hasNext() {
return lastIdx > -1;
}
@Override
public E next() {
return wrappedList.get(nextIndex());
}
public boolean hasPrevious() {
return lastIdx > -1;
}
public E previous() {
return wrappedList.get(previousIndex());
}
public int nextIndex() {
idx = (idx == lastIdx) ? 0 : idx + 1;
return idx;
}
public int previousIndex() {
idx = (idx <= 0) ? lastIdx : idx - 1;
return idx;
}
public int peekCurrentIndex() {
return idx;
}
public int peekLastIndex() {
return lastIdx;
}
}
@@ -0,0 +1,122 @@
/*
* Copyright 2019-2021 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.plugins;
import eu.binjr.common.logging.Logger;
import java.io.IOException;
import java.net.URL;
import java.net.URLClassLoader;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.*;
/**
* A collection of helper methods to work with {@link ServiceLoader}
*
* @author Frederic Thevenet
*/
public final class ServiceLoaderHelper {
private static final Logger logger = Logger.create(ServiceLoaderHelper.class);
/**
* A helper method to load and return service implementations from the classpath and external jars
*
* @param clazz the type of service to load and return
* @param loadedServices a set of services to witch the loaded services should be added.
* @param <T> the type of service to load and return
*/
public static <T> void loadFromClasspath(Class<T> clazz, Set<T> loadedServices) {
// Load plugins from classpath
loadFromServiceLoader(ServiceLoader.load(clazz), loadedServices);
}
/**
* A helper method to load and return service implementations from the classpath and/or external jars
*
* @param clazz the type of service to load and return
* @param loadedServices a set of services to witch the loaded services should be added.
* @param externalLocations file system paths specifying where to look for external jar to load services from
* @param <T> the type of service to load and return
* @return the class loader used to load jars from the external paths
*/
public static <T> ClassLoader loadFromPaths(Class<T> clazz, Set<T> loadedServices, Collection<Path> externalLocations) {
return loadFromPaths(clazz, loadedServices, externalLocations.toArray(Path[]::new));
}
/**
* A helper method to load and return service implementations from the classpath and/or external jars
*
* @param clazz the type of service to load and return
* @param loadedServices a set of services to witch the loaded services should be added.
* @param externalLocations file system paths specifying where to look for external jar to load services from
* @param <T> the type of service to load and return
* @return the class loader used to load jars from the external paths
*/
public static <T> ClassLoader loadFromPaths(Class<T> clazz, Set<T> loadedServices, Path... externalLocations) {
Objects.requireNonNull(externalLocations);
//Load plugin from external folder
List<URL> urls = new ArrayList<>();
for (var externalLocation : externalLocations) {
if (externalLocation != null && Files.exists(externalLocation)) {
logger.info(() -> "Looking for services of type " + clazz.getName() + " in " + externalLocation);
PathMatcher jarMatcher = FileSystems.getDefault().getPathMatcher("glob:**.jar");
try {
Files.walkFileTree(externalLocation,
EnumSet.noneOf(FileVisitOption.class),
1,
new SimpleFileVisitor<Path>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
if (jarMatcher.matches(file)) {
logger.debug(() -> "Inspecting " + file.getFileName() +
" for " + clazz.getName() + " service implementations");
urls.add(file.toUri().toURL());
}
return FileVisitResult.CONTINUE;
}
});
} catch (IOException e) {
logger.error("Error while scanning for services: " + e.getMessage());
if (logger.isDebugEnabled()) {
logger.debug("Stack trace", e);
}
}
} else {
logger.warn("External location " + externalLocation + " does not exist.");
}
}
var ucl = new URLClassLoader(urls.toArray(URL[]::new));
loadFromServiceLoader(ServiceLoader.load(clazz, ucl), loadedServices);
return ucl;
}
private static <T> void loadFromServiceLoader(ServiceLoader<T> sl, Set<T> loadedServices) {
for (Iterator<T> iterator = sl.iterator(); iterator.hasNext(); ) {
try {
T res = iterator.next();
loadedServices.add(res);
logger.debug(() -> "Successfully registered resource " + res.toString() + " from external JAR.");
} catch (ServiceConfigurationError sce) {
logger.error("Failed to load resource", sce);
} catch (Exception e) {
logger.error("Unexpected error while loading resource", e);
}
}
}
}
@@ -0,0 +1,84 @@
/*
* Copyright 2023 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.preferences;
import eu.binjr.common.io.AesHelper;
import eu.binjr.common.logging.Logger;
import javafx.beans.property.Property;
import javafx.beans.property.SimpleObjectProperty;
import javax.crypto.SecretKey;
import java.security.NoSuchAlgorithmException;
public class AesKeyring extends ObservablePreferenceFactory {
private static final Logger logger = Logger.create(AesKeyring.class);
private static final String UNINITIALIZED = "uninitialized";
protected AesKeyring(String storeKey) {
super(storeKey);
}
private SecretKey createDefaultKey() {
try {
return AesHelper.generateKey(256);
} catch (NoSuchAlgorithmException e) {
logger.fatal("Failed to generated key: " + e.getMessage());
logger.debug("Stack Trace", e);
throw new RuntimeException(e);
}
}
public ObservablePreference<SecretKey> secretKeyPreference(String storePath) {
var p = new ObservablePreference<>(SecretKey.class, storePath, null, backingStore) {
@Override
protected Property<SecretKey> makeProperty(SecretKey value) {
return new SimpleObjectProperty<>(value);
}
@Override
protected SecretKey loadFromBackend() {
var encoded = getBackingStore().get(getKey(), UNINITIALIZED);
SecretKey decoded;
if (!UNINITIALIZED.equals(encoded)) {
try {
decoded = AesHelper.getKeyFromEncoded(encoded);
return decoded;
} catch (Exception e){
logger.debug("Stack trace", e);
logger.warn("Failed to decode key for " + storePath + ": a new key will be initialized");
}
} else {
logger.debug("Initializing new key instance " + storePath);
}
// Key is uninitialized or invalid: generating a new one.
decoded = createDefaultKey();
getBackingStore().put(getKey(), AesHelper.encodeSecretKey(decoded));
return decoded;
}
@Override
protected void saveToBackend(SecretKey value) {
getBackingStore().put(getKey(), AesHelper.encodeSecretKey(value));
}
};
storedItems.put(p.getKey(), p);
return p;
}
}
@@ -0,0 +1,58 @@
/*
* Copyright 2023 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.preferences;
import eu.binjr.common.io.AesHelper;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import javax.crypto.SecretKey;
public class AesStringObfuscator extends ObfuscatedString.Obfuscator {
private static final Logger logger = LogManager.getLogger(AesStringObfuscator.class);
private final SecretKey secretKey;
public AesStringObfuscator(SecretKey secretKey) {
this.secretKey = secretKey;
}
protected String deObfuscateString(String obfuscated) {
try {
if (obfuscated.isEmpty()) {
return obfuscated;
}
return AesHelper.decrypt(obfuscated, this.secretKey);
} catch (Exception e) {
logger.error("Error while attempting to de-obfuscate string: " + e.getMessage());
logger.debug(() -> "Stack trace", e);
return "";
}
}
protected String obfuscateString(String clearText) {
try {
if (clearText.isEmpty()) {
return clearText;
}
return AesHelper.encrypt(clearText, this.secretKey);
} catch (Exception e) {
logger.error("Error while attempting to obfuscate string: " + e.getMessage());
logger.debug(() -> "Stack trace", e);
return "";
}
}
}
@@ -0,0 +1,186 @@
/*
* Licence:
* CC0 1.0 Universal (CC0 1.0)
* Public Domain Dedication
*
* The person who associated a work with this deed has dedicated
* the work to the public domain by waiving all of his or her rights
* to the work worldwide under copyright law, including all related
* and neighboring rights, to the extent allowed by law.
*
*/
package eu.binjr.common.preferences;
import eu.binjr.common.logging.Logger;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.*;
import java.util.prefs.AbstractPreferences;
import java.util.prefs.BackingStoreException;
/**
* Preferences implementation that stores to a user-defined file.
*
* @author David Croft (<a href="http://www.davidc.net">www.davidc.net</a>)
*/
public class FilePreferences extends AbstractPreferences {
private static final Logger logger = Logger.create(FilePreferences.class);
private final File backingFile;
private Map<String, String> root;
private Map<String, FilePreferences> children;
private boolean isRemoved = false;
public FilePreferences(File backingFile, AbstractPreferences parent, String name) {
super(parent, name);
this.backingFile = backingFile;
logger.trace("Instantiating node " + name);
root = new TreeMap<>();
children = new TreeMap<>();
try {
sync();
} catch (BackingStoreException e) {
logger.error("Unable to sync on creation of node " + name, e);
}
}
protected void putSpi(String key, String value) {
root.put(key, value);
try {
flush();
} catch (BackingStoreException e) {
logger.error("Unable to flush after putting " + key, e);
}
}
protected String getSpi(String key) {
return root.get(key);
}
protected void removeSpi(String key) {
root.remove(key);
try {
flush();
} catch (BackingStoreException e) {
logger.error("Unable to flush after removing " + key, e);
}
}
protected void removeNodeSpi() throws BackingStoreException {
isRemoved = true;
flush();
}
protected String[] keysSpi() throws BackingStoreException {
return root.keySet().toArray(new String[root.keySet().size()]);
}
protected String[] childrenNamesSpi() throws BackingStoreException {
return children.keySet().toArray(new String[children.keySet().size()]);
}
protected FilePreferences childSpi(String name) {
FilePreferences child = children.get(name);
if (child == null || child.isRemoved()) {
child = new FilePreferences(this.backingFile, this, name);
children.put(name, child);
}
return child;
}
protected void syncSpi() throws BackingStoreException {
if (isRemoved()){
return;
}
if (!backingFile.exists()) {
return;
}
synchronized (backingFile) {
Properties p = new Properties();
try {
p.load(new FileInputStream(backingFile));
StringBuilder sb = new StringBuilder();
getPath(sb);
String path = sb.toString();
final Enumeration<?> pnen = p.propertyNames();
while (pnen.hasMoreElements()) {
String propKey = (String) pnen.nextElement();
if (propKey.startsWith(path)) {
String subKey = propKey.substring(path.length());
// Only load immediate descendants
if (subKey.indexOf('.') == -1) {
root.put(subKey, p.getProperty(propKey));
}
}
}
} catch (IOException e) {
throw new BackingStoreException(e);
}
}
}
private void getPath(StringBuilder sb) {
final FilePreferences parent = (FilePreferences) parent();
if (parent == null) return;
parent.getPath(sb);
sb.append(name()).append('.');
}
protected void flushSpi() throws BackingStoreException {
synchronized (backingFile) {
Properties p = new Properties();
try {
StringBuilder sb = new StringBuilder();
getPath(sb);
String path = sb.toString();
if (backingFile.exists()) {
p.load(new FileInputStream(backingFile));
List<String> toRemove = new ArrayList<String>();
// Make a list of all direct children of this node to be removed
final Enumeration<?> pnen = p.propertyNames();
while (pnen.hasMoreElements()) {
String propKey = (String) pnen.nextElement();
if (propKey.startsWith(path)) {
String subKey = propKey.substring(path.length());
// Only do immediate descendants
if (subKey.indexOf('.') == -1) {
toRemove.add(propKey);
}
}
}
// Remove them now that the enumeration is done with
for (String propKey : toRemove) {
p.remove(propKey);
}
}
// If this node hasn't been removed, add back in any values
if (!isRemoved) {
for (String s : root.keySet()) {
p.setProperty(path + s, root.get(s));
}
}
p.store(new FileOutputStream(backingFile), "FilePreferences");
} catch (IOException e) {
throw new BackingStoreException(e);
}
}
}
}
@@ -0,0 +1,173 @@
/*
* Copyright 2019-2023 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.preferences;
import eu.binjr.common.concurrent.ReadWriteLockHelper;
import eu.binjr.common.logging.Logger;
import java.util.*;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.function.Consumer;
import java.util.prefs.Preferences;
/**
* Manages access to a persistent, Last In First Out, auto de-duplicating collection, whose main purpose is to
* handle "most recently used" item lists in UI applications.
*
* @param <T> The type of item to collect
* @author Frederic Thevenet
*/
public abstract class MostRecentlyUsedList<T> implements ReloadableItemStore.Reloadable {
private static final Logger logger = Logger.create(MostRecentlyUsedList.class);
private final int capacity;
private final String key;
private final Preferences backingStore;
ReadWriteLockHelper monitor = new ReadWriteLockHelper(new ReentrantReadWriteLock());
private final Deque<T> mruList;
private Consumer<T> onItemEvicted;
public void setOnItemEvicted(Consumer<T> onItemEvicted) {
this.onItemEvicted = onItemEvicted;
}
protected Preferences getBackingStore() {
return backingStore;
}
protected String getKey() {
return key;
}
protected MostRecentlyUsedList(String key, int capacity, Preferences backingStore) {
this.key = key;
this.backingStore = backingStore;
this.capacity = capacity;
mruList = failSafeLoad();
}
protected abstract boolean validate(T value);
/**
* Push an item to the top of the stack.
* <p>
* If the value is already present in the inner stack, it is removed from its current location and replaced on top.
* <p>
* If the defined capacity has been reached, the tail of the stack is culled.
*
* @param value the item to push on the stack.
*/
public void push(T value) {
if (validate(value)) {
monitor.write().lock(() -> {
mruList.remove(value);
mruList.push(value);
if (mruList.size() > capacity) {
var evicted = mruList.pollLast();
if (onItemEvicted != null && evicted != null) {
onItemEvicted.accept(evicted);
}
}
failSafeSave();
});
} else {
logger.debug("Invalid value to push in " + key);
}
}
/**
* Returns true is the list contains the specified value, false otherwise.
*
* @param value the value whose presence is to be tested
* @return true is the list contains the specified value, false otherwise
*/
public boolean contains(T value) {
return monitor.read().lock(() -> mruList.contains(value));
}
/**
* Remove a value from the most recently used list.
*
* @param value the value to remove
*/
public void remove(T value) {
monitor.write().lock(() -> {
mruList.remove(value);
failSafeSave();
});
}
/**
* Get the head of the stack, i.e. the last pushed item.
*
* @return the head of the stack
*/
public Optional<T> peek() {
var mru = monitor.read().lock(mruList::peek);
return mru != null ? Optional.of(mru) : Optional.empty();
}
@Override
public void reload() {
monitor.write().lock(() -> {
mruList.clear();
mruList.addAll(failSafeLoad());
});
}
/**
* Return all the current items on the stack.
*
* @return all the current items on the stack.
*/
public Collection<T> getAll() {
return monitor.read().lock(() -> List.copyOf(mruList));
}
private void failSafeSave() {
try {
getBackingStore().node(getKey()).clear();
int i = 0;
for (T t : getAll()) {
saveToBackend(i++, t);
}
} catch (Throwable t) {
logger.error("Failed to save preference " + key + " to backend: " + t.getMessage(), t);
}
}
protected abstract void saveToBackend(int index, T value);
private Deque<T> failSafeLoad() {
var mru = new LinkedList<T>();
try {
for (int i = 0; i < capacity; i++) {
var o = loadFromBackend(i);
if (o.isPresent()) {
mru.add(o.get());
} else {
break;
}
}
} catch (Throwable t) {
logger.error("Failed to load preference " + key + " from backend: " + t.getMessage(), t);
}
return mru;
}
protected abstract Optional<T> loadFromBackend(int index);
}
@@ -0,0 +1,129 @@
/*
* Copyright 2019-2020 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.preferences;
import eu.binjr.common.logging.Logger;
import javafx.beans.property.Property;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.util.Optional;
import java.util.prefs.Preferences;
/**
* A factory to create and manage preferences exposed as {@link Property} and backed by {@link Preferences}
*
* @author Frederic Thevenet
*/
public class MruFactory extends ReloadableItemStore<MostRecentlyUsedList<?>> {
private static final Logger logger = Logger.create(MruFactory.class);
public MruFactory(String backingStoreKey) {
super(backingStoreKey);
}
public MostRecentlyUsedList<String> stringMostRecentlyUsedList(String key, int capacity) {
var mru = new MostRecentlyUsedList<String>(key, capacity, backingStore) {
@Override
protected boolean validate(String value) {
return (value != null);
}
@Override
protected void saveToBackend(int index, String value) {
getBackingStore().node(getKey()).put("value_" + index, value);
}
@Override
protected Optional<String> loadFromBackend(int index) {
var p = getBackingStore().node(getKey()).get("value_" + index, null);
if (p != null) {
return Optional.of(p);
}
return Optional.empty();
}
};
storedItems.put(key, mru);
return mru;
}
public MostRecentlyUsedList<URI> uriMostRecentlyUsedList(String key, int capacity) {
var mru = new MostRecentlyUsedList<URI>(key, capacity, backingStore) {
@Override
protected boolean validate(URI value) {
return true;
}
@Override
protected void saveToBackend(int index, URI value) {
getBackingStore().node(getKey()).put("value_" + index, value.toString());
}
@Override
protected Optional<URI> loadFromBackend(int index) {
var p = getBackingStore().node(getKey()).get("value_" + index, "");
if (!p.isEmpty()) {
try {
return Optional.of(new URI(p));
} catch (URISyntaxException e) {
logger.debug(() -> "Error reloading URI: " + e.getMessage(), e);
}
}
return Optional.empty();
}
};
storedItems.put(key, mru);
return mru;
}
public MostRecentlyUsedList<Path> pathMostRecentlyUsedList(String key, int capacity, boolean mustBeDirectory) {
var mru = new MostRecentlyUsedList<Path>(key, capacity, backingStore) {
@Override
protected boolean validate(Path value) {
try {
if (value != null && value.toRealPath(LinkOption.NOFOLLOW_LINKS) != null
&& (!mustBeDirectory || value.toFile().isDirectory())) {
return true;
}
} catch (IOException e) {
logger.debug(() -> "Cannot insert into most recently used list: " + e.getMessage(), e);
}
return false;
}
@Override
protected void saveToBackend(int index, Path value) {
getBackingStore().node(getKey()).put("value_" + index, value.toString());
}
@Override
protected Optional<Path> loadFromBackend(int index) {
var p = getBackingStore().node(getKey()).get("value_" + index, "");
if (!p.isEmpty()) {
return Optional.of(Path.of(p));
}
return Optional.empty();
}
};
storedItems.put(key, mru);
return mru;
}
}
@@ -0,0 +1,93 @@
/*
* Copyright 2022-2023 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.preferences;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import java.util.Objects;
/**
* Encapsulates a String and obfuscates its content.
* <p>
* <b>Remark:</b> if an empty string is used to initialize the {@link ObfuscatedString}, then it will not be obfuscated (i.e.
* its obfuscated value will also be an empty string).
*
* @author Frederic Thevenet
*/
public class ObfuscatedString {
private static final Logger logger = LogManager.getLogger(ObfuscatedString.class);
private final String obfuscated;
private final Obfuscator obfuscator;
private ObfuscatedString(String obfuscated, Obfuscator obfuscator) {
Objects.requireNonNull(obfuscated, "Cannot create an ObfuscatedString instance from a null value");
Objects.requireNonNull(obfuscator, "factory cannot be null");
this.obfuscator = obfuscator;
this.obfuscated = obfuscated;
}
@Override
public String toString() {
return "************";
}
/**
* Returns the plain text representation of the encapsulated string
*
* @return the plain text representation of the encapsulated string
*/
public String toPlainText() {
return obfuscator.deObfuscateString(obfuscated);
}
/**
* Returns the obfuscated representation of the encapsulated string
*
* @return the obfuscated representation of the encapsulated string
*/
public String getObfuscated() {
return obfuscated;
}
public abstract static class Obfuscator {
/**
* Returns an {@link ObfuscatedString} instance that stores the value of the provided String.
* <p>
* <b>Remark:</b> if an empty string is used to initialize the {@link ObfuscatedString}, then it will not be obfuscated (i.e.
* its obfuscated value will also be an empty string).
*
* @param plainText The value stored by the {@link ObfuscatedString} instance.
* @return an {@link ObfuscatedString} instance that stores the value of the provided String.
*/
public ObfuscatedString fromPlainText(String plainText) {
return fromObfuscatedText(obfuscateString(plainText));
}
public ObfuscatedString fromObfuscatedText(String obfuscated) {
return new ObfuscatedString(obfuscated, this);
}
protected abstract String deObfuscateString(String obfuscated);
protected abstract String obfuscateString(String clearText);
}
}
@@ -0,0 +1,159 @@
/*
* Copyright 2019-2020 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.preferences;
import eu.binjr.common.logging.Logger;
import javafx.beans.property.Property;
import javafx.beans.value.ObservableValue;
import org.controlsfx.control.PropertySheet;
import java.util.Optional;
import java.util.prefs.Preferences;
/**
* An abstract class that defines a preference that can be accessed via a {@link Property} and is persisted as
* user {@link Preferences}
*
* @param <T> The type of the inner value of the preference
* @author Frederic Thevenet
*/
public abstract class ObservablePreference<T> implements ReloadableItemStore.Reloadable {
private static final Logger logger = Logger.create(ObservablePreference.class);
private final String key;
private final T defaultValue;
private final Preferences backingStore;
private final Property<T> backingProperty;
private final Class<T> innerType;
public ObservablePreference(Class<T> innerType, String key, T defaultValue, Preferences backingStore) {
this.backingStore = backingStore;
this.key = key;
this.defaultValue = defaultValue;
this.innerType = innerType;
backingProperty = makeProperty(failSafeLoad());
backingProperty.addListener((observable, oldValue, newValue) -> failSafeSave(newValue));
}
@Override
public void reload() {
backingProperty.setValue(failSafeLoad());
}
public T get() {
return backingProperty.getValue();
}
public Property<T> property() {
return backingProperty;
}
public void set(T property) {
this.backingProperty.setValue(property);
}
protected abstract Property<T> makeProperty(T value);
protected abstract T loadFromBackend();
protected abstract void saveToBackend(T value);
protected Preferences getBackingStore() {
return backingStore;
}
protected String getKey() {
return key;
}
protected T getDefaultValue() {
return defaultValue;
}
protected Class<T> getInnerType() {
return innerType;
}
private void failSafeSave(T value) {
try {
saveToBackend(value);
} catch (Throwable t) {
logger.error("Failed to save preference " + key + " to backend: " + t.getMessage(), t);
}
}
private T failSafeLoad() {
try {
return loadFromBackend();
} catch (Throwable t) {
logger.error("Failed to load preference " + key + " from backend: " + t.getMessage());
logger.debug("Call Stack:", t);
return defaultValue;
}
}
@Override
public String toString() {
final StringBuffer sb = new StringBuffer("ObservablePreference{");
sb.append("key='").append(key).append('\'');
sb.append(", defaultValue=").append(defaultValue);
sb.append(", value=").append(backingProperty.getValue());
sb.append(", innerType=").append(innerType);
sb.append('}');
return sb.toString();
}
public PropertySheet.Item asPropertyItem() {
return new PropertySheet.Item() {
@Override
public Class<?> getType() {
return get().getClass();
}
@Override
public String getCategory() {
return backingStore.name();
}
@Override
public String getName() {
return key;
}
@Override
public String getDescription() {
return "";
}
@Override
public T getValue() {
return get();
}
@Override
public void setValue(Object value) {
set((T) value);
}
@Override
public Optional<ObservableValue<? extends Object>> getObservableValue() {
return Optional.of(property());
}
};
}
}
@@ -0,0 +1,343 @@
/*
* Copyright 2019-2023 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.preferences;
import eu.binjr.common.logging.Logger;
import javafx.beans.property.*;
import java.net.URI;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Arrays;
import java.util.Optional;
import java.util.function.Function;
import java.util.prefs.Preferences;
import java.util.stream.Collectors;
/**
* A factory to create and manage preferences exposed as {@link Property} and backed by {@link Preferences}
*
* @author Frederic Thevenet
*/
public class ObservablePreferenceFactory extends ReloadableItemStore<ObservablePreference<?>> {
private static final Logger logger = Logger.create(ObservablePreferenceFactory.class);
public ObservablePreferenceFactory(String backingStoreKey) {
super(backingStoreKey);
}
public ObservablePreference<Boolean> booleanPreference(String key, Boolean defaultValue) {
var p = new ObservablePreference<>(Boolean.class, key, defaultValue, backingStore) {
@Override
protected Property<Boolean> makeProperty(Boolean value) {
return new SimpleBooleanProperty(value);
}
@Override
protected Boolean loadFromBackend() {
return getBackingStore().getBoolean(getKey(), getDefaultValue());
}
@Override
protected void saveToBackend(Boolean value) {
getBackingStore().putBoolean(getKey(), value);
}
};
storedItems.put(p.getKey(), p);
return p;
}
public ObservablePreference<ObfuscatedString> obfuscatedStringPreference(String key, String defaultValue, ObfuscatedString.Obfuscator obfuscator) {
var p = new ObservablePreference<>(ObfuscatedString.class, key, obfuscator.fromPlainText(defaultValue), backingStore) {
@Override
protected Property<ObfuscatedString> makeProperty(ObfuscatedString value) {
return new SimpleObjectProperty<>(value);
}
@Override
protected ObfuscatedString loadFromBackend() {
return obfuscator.fromObfuscatedText(getBackingStore().get(getKey(), getDefaultValue().getObfuscated()));
}
@Override
protected void saveToBackend(ObfuscatedString value) {
getBackingStore().put(getKey(), value.getObfuscated());
}
};
storedItems.put(p.getKey(), p);
return p;
}
public ObservablePreference<String> stringPreference(String key, String defaultValue) {
var p = new ObservablePreference<>(String.class, key, defaultValue, backingStore) {
@Override
protected Property<String> makeProperty(String value) {
return new SimpleStringProperty(value);
}
@Override
protected String loadFromBackend() {
return getBackingStore().get(getKey(), getDefaultValue());
}
@Override
protected void saveToBackend(String value) {
getBackingStore().put(getKey(), value);
}
};
storedItems.put(p.getKey(), p);
return p;
}
public ObservablePreference<Number> integerPreference(String key, Integer defaultValue) {
var p = new ObservablePreference<>(Number.class, key, defaultValue, backingStore) {
@Override
protected Property<Number> makeProperty(Number value) {
return new SimpleIntegerProperty(value.intValue());
}
@Override
protected Integer loadFromBackend() {
return getBackingStore().getInt(getKey(), getDefaultValue().intValue());
}
@Override
protected void saveToBackend(Number value) {
getBackingStore().putInt(getKey(), value.intValue());
}
};
storedItems.put(p.getKey(), p);
return p;
}
public ObservablePreference<ZonedDateTime> zoneDateTimePreference(String key, ZonedDateTime defaultValue) {
var p = new ObservablePreference<>(ZonedDateTime.class, key, defaultValue, backingStore) {
@Override
protected Property<ZonedDateTime> makeProperty(ZonedDateTime value) {
return new SimpleObjectProperty<>(value);
}
@Override
protected ZonedDateTime loadFromBackend() {
return ZonedDateTime.parse(getBackingStore().get(getKey(),
getDefaultValue().format(DateTimeFormatter.ISO_ZONED_DATE_TIME)),
DateTimeFormatter.ISO_ZONED_DATE_TIME);
}
@Override
protected void saveToBackend(ZonedDateTime value) {
getBackingStore().put(getKey(), value.format(DateTimeFormatter.ISO_ZONED_DATE_TIME));
}
};
storedItems.put(p.getKey(), p);
return p;
}
public ObservablePreference<LocalDateTime> localDateTimePreference(String key, LocalDateTime defaultValue) {
var p = new ObservablePreference<>(LocalDateTime.class, key, defaultValue, backingStore) {
@Override
protected Property<LocalDateTime> makeProperty(LocalDateTime value) {
return new SimpleObjectProperty<>(value);
}
@Override
protected LocalDateTime loadFromBackend() {
return LocalDateTime.parse(getBackingStore().get(getKey(),
getDefaultValue().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)),
DateTimeFormatter.ISO_LOCAL_DATE_TIME);
}
@Override
protected void saveToBackend(LocalDateTime value) {
getBackingStore().put(getKey(), value.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME));
}
};
storedItems.put(p.getKey(), p);
return p;
}
public ObservablePreference<Number> longPreference(String key, Long defaultValue) {
var p = new ObservablePreference<>(Number.class, key, defaultValue, backingStore) {
@Override
protected Property<Number> makeProperty(Number value) {
return new SimpleLongProperty(value.longValue());
}
@Override
protected Long loadFromBackend() {
return getBackingStore().getLong(getKey(), getDefaultValue().longValue());
}
@Override
protected void saveToBackend(Number value) {
getBackingStore().putLong(getKey(), value.longValue());
}
};
storedItems.put(p.getKey(), p);
return p;
}
public ObservablePreference<Number> doublePreference(String key, Double defaultValue) {
var p = new ObservablePreference<>(Number.class, key, defaultValue, backingStore) {
@Override
protected Property<Number> makeProperty(Number value) {
return new SimpleDoubleProperty(value.doubleValue());
}
@Override
protected Double loadFromBackend() {
return getBackingStore().getDouble(getKey(), getDefaultValue().doubleValue());
}
@Override
protected void saveToBackend(Number value) {
getBackingStore().putDouble(getKey(), value.doubleValue());
}
};
storedItems.put(p.getKey(), p);
return p;
}
public ObservablePreference<Path> pathPreference(String key, Path defaultValue) {
var p = new ObservablePreference<>(Path.class, key, defaultValue, backingStore) {
@Override
protected Property<Path> makeProperty(Path value) {
return new SimpleObjectProperty<>(value);
}
@Override
protected Path loadFromBackend() {
return Path.of(getBackingStore().get(getKey(), getDefaultValue().toString()));
}
@Override
protected void saveToBackend(Path value) {
getBackingStore().put(getKey(), value.toString());
}
};
storedItems.put(p.getKey(), p);
return p;
}
public ObservablePreference<URI> uriPreference(String key, URI defaultValue) {
var p = new ObservablePreference<>(URI.class, key, defaultValue, backingStore) {
@Override
protected Property<URI> makeProperty(URI value) {
return new SimpleObjectProperty<>(value);
}
@Override
protected URI loadFromBackend() {
return URI.create(getBackingStore().get(getKey(), getDefaultValue().toString()));
}
@Override
protected void saveToBackend(URI value) {
getBackingStore().put(getKey(), value.toString());
}
};
storedItems.put(p.getKey(), p);
return p;
}
@SafeVarargs
public final <E extends Enum<E>> ObservablePreference<E> enumPreference(Class<E> type, String key, E defaultValue, E... forbiddenValues) {
var p = new ObservablePreference<>(type, key, defaultValue, backingStore) {
@Override
protected Property<E> makeProperty(E value) {
return new SimpleObjectProperty<>(value);
}
@Override
protected E loadFromBackend() {
return Enum.valueOf(getInnerType(), getBackingStore().get(getKey(), getDefaultValue().name()));
}
@Override
protected void saveToBackend(E value) {
if (forbiddenValues != null) {
for (var val : forbiddenValues) {
if (value.equals(val)) {
getBackingStore().put(getKey(), getDefaultValue().name());
return;
}
}
}
getBackingStore().put(getKey(), value.name());
}
@Override
public void set(E value) {
if (forbiddenValues != null) {
for (var val : forbiddenValues) {
if (value.equals(val)) {
super.set(getDefaultValue());
return;
}
}
}
super.set(value);
}
};
storedItems.put(p.getKey(), p);
return p;
}
public <T> ObservablePreference<T> objectPreference(Class<T> type,
String key,
T defaultValue,
Function<T, String> convertToString,
Function<String, T> parseFromString) {
var p = new ObservablePreference<>(type, key, defaultValue, backingStore) {
@Override
protected Property<T> makeProperty(T value) {
return new SimpleObjectProperty<>(value);
}
@Override
protected T loadFromBackend() {
return parseFromString.apply(getBackingStore().get(getKey(), convertToString.apply(getDefaultValue())));
}
@Override
protected void saveToBackend(T value) {
getBackingStore().put(getKey(), convertToString.apply(value));
}
};
storedItems.put(p.getKey(), p);
return p;
}
@SuppressWarnings("unchecked")
public <U> Optional<ObservablePreference<U>> getByName(String name, Class<U> type) {
var p = storedItems.get(name);
if (p == null || !type.isAssignableFrom(p.getInnerType())) {
return Optional.empty();
}
return Optional.of((ObservablePreference<U>) p);
}
@Override
public String toString() {
return storedItems.values().stream().map(p -> p.getKey() + "=" + p.get()).collect(Collectors.joining("\n"));
}
}
@@ -0,0 +1,169 @@
/*
* Copyright 2019-2021 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.preferences;
import eu.binjr.common.logging.Logger;
import eu.binjr.core.Binjr;
import eu.binjr.core.preferences.AppEnvironment;
import javafx.collections.FXCollections;
import javafx.collections.MapChangeListener;
import javafx.collections.ObservableMap;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.prefs.BackingStoreException;
import java.util.prefs.InvalidPreferencesFormatException;
import java.util.prefs.Preferences;
/**
* Provides methods to access and store, import and export items that are persisted into a {@link Preferences} sub tree.
*
* @param <T> The type of items managed by the store
* @author Frederic Thevenet
*/
public abstract class ReloadableItemStore<T extends ReloadableItemStore.Reloadable> {
private static final Logger logger = Logger.create(ObservablePreferenceFactory.class);
protected final Preferences backingStore;
protected final ObservableMap<String, T> storedItems = FXCollections.observableMap(new ConcurrentHashMap<>());
private ObservableMap<String, T> readOnlyStoreItems = FXCollections.unmodifiableObservableMap(storedItems);
ReloadableItemStore(String backingStoreKey) {
this.backingStore = getBackingPreference(backingStoreKey);
storedItems.addListener((MapChangeListener<String, T>) c -> {
if (c.wasAdded()) {
logger.trace(() -> "Preference added to store: " + c.getValueAdded().toString());
}
if (c.wasRemoved()) {
logger.trace(() -> "Preference removed from store: " + c.getValueAdded().toString());
}
});
}
/**
* Resets all managed items to their default values.
*
* @throws BackingStoreException if an error occurs while reloading items from the backing store
*/
public void reset() throws BackingStoreException {
clearSubTree(backingStore);
storedItems.values().forEach(T::reload);
}
private static Preferences getBackingPreference(String name) {
if (Boolean.parseBoolean(System.getProperty(AppEnvironment.PORTABLE_PROPERTY))) {
try {
var jarLocation = Paths.get(Binjr.class.getProtectionDomain().getCodeSource().getLocation().toURI());
var configDir = jarLocation.getParent().getParent().resolve("settings");
if (!Files.isDirectory(configDir)) {
Files.createDirectory(configDir);
}
var preferencesFile = configDir.resolve("user").toFile();
if (!preferencesFile.exists()) {
preferencesFile.createNewFile();
}
return new FilePreferences(preferencesFile, null, "").node(name);
} catch (Exception e) {
logger.error("Failed to create file to store preferences" + e.getMessage());
logger.debug(() -> "Stack Trace", e);
logger.warn("Non portable preference store will be used instead");
}
}
return Preferences.userRoot().node(name);
}
private void clearSubTree(Preferences node) throws BackingStoreException {
if (node.nodeExists("")) {
node.clear();
for (var n : node.childrenNames()) {
clearSubTree(node.node(n));
}
}
}
/**
* Reloads all managed items from the backing store.
*/
public void reload() {
storedItems.values().forEach(T::reload);
}
/**
* Export all managed items to a file.
*
* @param savePath the path to export the data to.
* @throws IOException if an IO error occrurs.
* @throws BackingStoreException if an error occurs while accessing the backing store.
*/
public void exportToFile(Path savePath) throws IOException, BackingStoreException {
try (var os = Files.newOutputStream(savePath, StandardOpenOption.WRITE, StandardOpenOption.CREATE_NEW)) {
backingStore.flush();
backingStore.exportSubtree(os);
}
}
/**
* Imports items into the store from a file.
*
* @param savePath the path to import data from.
* @throws IOException if an IO error occrurs.
* @throws InvalidPreferencesFormatException if an error occurs while accessing the backing store.
*/
public void importFromFile(Path savePath) throws IOException, InvalidPreferencesFormatException {
try (var is = Files.newInputStream(savePath, StandardOpenOption.READ)) {
Preferences.importPreferences(is);
storedItems.values().forEach(T::reload);
}
}
/**
* Returns all managed items.
*
* @return all managed items.
*/
public ObservableMap<String, T> getAll() {
return readOnlyStoreItems;
}
/**
* Request a specific item by its name.
*
* @param name the name of the item to retrieve/
* @return an optional of the requested item, Optional.empty if no such item exists.
*/
public Optional<T> getByName(String name) {
var p = storedItems.get(name);
if (p == null) {
return Optional.empty();
}
return Optional.of(p);
}
/**
* Defines an item whose value can be stored into a {@link ReloadableItemStore} instance.
*/
public interface Reloadable {
/**
* Reloads the value of the item from the backing store.
*/
void reload();
}
}
@@ -0,0 +1,58 @@
/*
* Copyright 2017-2019 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.text;
import java.util.TreeMap;
/**
* An implementation of {@link PrefixFormatter} for binary prefixes
*
* @author Frederic Thevenet
*/
public class BinaryPrefixFormatter extends PrefixFormatter {
public static final int BASE = 1024;
public static final TreeMap<Double, String> SUFFIX_MAP = new TreeMap<>() {{
put(Math.pow(BASE, -4.0), "*2⁻¹⁰⁰⁰⁰⁰⁰⁰⁰⁰");
put(Math.pow(BASE, -3.0), "*2⁻¹⁰⁰⁰⁰⁰⁰");
put(Math.pow(BASE, -2.0), "*2⁻¹⁰⁰⁰");
put(Math.pow(BASE, -1.0), "*2⁻¹⁰");
put(Math.pow(BASE, 0.0), "");
put(Math.pow(BASE, 1.0), "ki");
put(Math.pow(BASE, 2.0), "Mi");
put(Math.pow(BASE, 3.0), "Gi");
put(Math.pow(BASE, 4.0), "Ti");
put(Math.pow(BASE, 5.0), "Pi");
put(Math.pow(BASE, 6.0), "Ei");
}};
/**
* Initializes a new instance of the {@link BinaryPrefixFormatter} class
*/
public BinaryPrefixFormatter() {
super(SUFFIX_MAP);
}
/**
* Initializes a new instance of the {@link BinaryPrefixFormatter} class with the specified format pattern.
*
* @param pattern a non-localized pattern string
*/
public BinaryPrefixFormatter(String pattern) {
super(SUFFIX_MAP, pattern);
}
}
@@ -0,0 +1,61 @@
/*
* Copyright 2017-2019 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.text;
import java.util.NavigableMap;
import java.util.TreeMap;
/**
* An implementation of {@link PrefixFormatter} for metric prefixes
*
* @author Frederic Thevenet
*/
public class MetricPrefixFormatter extends PrefixFormatter {
private static final NavigableMap<Double, String> SUFFIX_MAP = new TreeMap<>() {{
put(Math.pow(BASE, -6.0), "a");
put(Math.pow(BASE, -5.0), "f");
put(Math.pow(BASE, -4.0), "p");
put(Math.pow(BASE, -3.0), "n");
put(Math.pow(BASE, -2.0), "µ");
put(Math.pow(BASE, -1.0), "m");
put(Math.pow(BASE, 0.0), "");
put(Math.pow(BASE, 1.0), "k");
put(Math.pow(BASE, 2.0), "M");
put(Math.pow(BASE, 3.0), "G");
put(Math.pow(BASE, 4.0), "T");
put(Math.pow(BASE, 5.0), "P");
put(Math.pow(BASE, 6.0), "E");
}};
public static final int BASE = 1000;
/**
* Initializes a new instance of the {@link MetricPrefixFormatter} class
*/
public MetricPrefixFormatter() {
super(SUFFIX_MAP);
}
/**
* Initializes a new instance of the {@link MetricPrefixFormatter} class with the specified format pattern.
*
* @param pattern a non-localized pattern string
*/
public MetricPrefixFormatter(String pattern) {
super(SUFFIX_MAP, pattern);
}
}
@@ -0,0 +1,45 @@
/*
* Copyright 2021 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.text;
import java.util.NavigableMap;
import java.util.TreeMap;
/**
* An implementation of {@link PrefixFormatter} for metric prefixes
*
* @author Frederic Thevenet
*/
public class NoopPrefixFormatter extends PrefixFormatter {
private static final NavigableMap<Double, String> SUFFIX_MAP = new TreeMap<>() ;
/**
* Initializes a new instance of the {@link NoopPrefixFormatter} class
*/
public NoopPrefixFormatter() {
super(SUFFIX_MAP);
}
/**
* Initializes a new instance of the {@link NoopPrefixFormatter} class with the specified format pattern.
*
* @param pattern a non-localized pattern string
*/
public NoopPrefixFormatter(String pattern) {
super(SUFFIX_MAP, pattern);
}
}
@@ -0,0 +1,38 @@
/*
* Copyright 2023 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.text;
import eu.binjr.common.text.PrefixFormatter;
import java.util.NavigableMap;
import java.util.TreeMap;
public class PercentagePrefixFormatter extends PrefixFormatter {
public static final int BASE = 10;
private static final NavigableMap<Double, String> SUFFIX_MAP = new TreeMap<>() {{
put(0.01, "");
}};
public PercentagePrefixFormatter() {
super(SUFFIX_MAP);
}
public PercentagePrefixFormatter(String pattern) {
super(SUFFIX_MAP, pattern);
}
}
@@ -0,0 +1,82 @@
/*
* Copyright 2017-2019 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.text;
import java.text.DecimalFormat;
import java.util.NavigableMap;
/**
* This class provides the interface and base implementation for formatting double or long into string
* with a unit prefix.
*
* @author Frederic Thevenet
*/
public abstract class PrefixFormatter {
private static final String DEFAULT_PATTERN = "###,###.##";
private final NavigableMap<Double, String> suffixMap;
private final DecimalFormat formatter;
/**
* Initializes a new instance of {@link PrefixFormatter} with the arithmetical base and a
* representation of the prefixes.
*
* @param suffixMap a map of the suffix labels and the associated divider as the key.
*/
protected PrefixFormatter(NavigableMap<Double, String> suffixMap) {
this(suffixMap, DEFAULT_PATTERN);
}
/**
* Initializes a new instance of {@link PrefixFormatter} with the arithmetical base and a
* representation of the prefixes.
*
* @param suffixMap a map of the suffix labels and the associated divider as the key.
* @param pattern a non-localized pattern string.
*/
protected PrefixFormatter(NavigableMap<Double, String> suffixMap, String pattern) {
this.suffixMap = suffixMap;
this.formatter = new DecimalFormat(pattern);
}
/**
* Formats a {@code double} as a string with a unit prefix
*
* @param value the value to format
* @return the formatted string
*/
public String format(double value) {
if (Double.isNaN(value)) {
return "NaN";
}
if (Double.isInfinite(value)) {
return "Infinite";
}
if (value == Double.MIN_VALUE) {
return format(Double.MIN_VALUE + 1);
}
if (value < 0) {
return "-" + format(-value);
}
var e = suffixMap.floorEntry(value);
if (e == null) {
return formatter.format(value);
}
Double divideBy = e.getKey();
String suffix = e.getValue();
return formatter.format(value / divideBy) + suffix;
}
}
@@ -0,0 +1,131 @@
/*
* Copyright 2019 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.text;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
public class StringUtils {
private final static String NON_THIN = "[^iIl1\\.,']";
private static final Logger logger = LogManager.getLogger(StringUtils.class);
private static int textWidth(String str) {
return (int) (str.length() - str.replaceAll(NON_THIN, "").length() / 2);
}
public static String ellipsize(String text, int max) {
if (textWidth(text) <= max)
return text;
// Start by chopping off at the word before max
// This is an over-approximation due to thin-characters...
int end = text.lastIndexOf(' ', max - 3);
// Just one long word. Chop it off.
if (end == -1)
return text.substring(0, max - 3) + "...";
// Step forward as long as textWidth allows.
int newEnd = end;
do {
end = newEnd;
newEnd = text.indexOf(' ', end + 1);
// No more spaces.
if (newEnd == -1)
newEnd = text.length();
} while (textWidth(text.substring(0, newEnd) + "...") < max);
return text.substring(0, end) + "...";
}
public static boolean contains(String str, String searchStr, boolean caseSensitive) {
if (str == null || searchStr == null) return false;
if (searchStr.isEmpty()) return true;
if (caseSensitive) {
return str.contains(searchStr);
}
final int length = searchStr.length();
for (int i = str.length() - length; i >= 0; i--) {
if (str.regionMatches(true, i, searchStr, 0, length))
return true;
}
return false;
}
public static String integerToOrdinal(int i) {
if (i < 0) {
throw new UnsupportedOperationException("Only positive integer are supported.");
}
int mod100 = i % 100;
int mod10 = i % 10;
if (mod10 == 1 && mod100 != 11) {
return i + "st";
} else if (mod10 == 2 && mod100 != 12) {
return i + "nd";
} else if (mod10 == 3 && mod100 != 13) {
return i + "rd";
} else {
return i + "th";
}
}
public static String stringToEscapeSequence(String val) {
if (val == null){
return null;
}
switch (val.trim()) {
case "\\t" -> {
return "\t";
}
case "\'" -> {
return "'";
}
case "\\" -> {
return "\"";
}
case "\\n" -> {
return "\n";
}
case "\\r" -> {
return "\r";
}
case "\\f" -> {
return "\f";
}
case "\\b" -> {
return "\b";
}
case "\\\\" -> {
return "\\";
}
case "\\r\\n" -> {
return "\r\n";
}
case "\\n\\r" -> {
return "\n\r";
}
default -> {
return val;
}
}
}
}
@@ -0,0 +1,339 @@
/**
* Copyright (c) OSGi Alliance (2004, 2007). All Rights Reserved.
* <br>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <br>
* http://www.apache.org/licenses/LICENSE-2.0
* <br>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.version;
import java.util.NoSuchElementException;
import java.util.StringTokenizer;
public class Version implements Comparable<Version> {
public static final String SNAPSHOT = "-SNAPSHOT";
private final int major;
private final int minor;
private final int micro;
private final String qualifier;
private final boolean isSnapshot;
private static final String SEPARATOR = ".";
/**
* The empty version "0.0.0". Equivalent to calling
* <code>new Version(0,0,0)</code>.
*/
public static final Version emptyVersion = new Version(0, 0, 0);
/**
* Creates a version identifier from the specified numerical components.
* <br>
* <br>
* The qualifier is set to the empty string.
*
* @param major Major component of the version identifier.
* @param minor Minor component of the version identifier.
* @param micro Micro component of the version identifier.
* @throws IllegalArgumentException If the numerical components are
* negative.
*/
public Version(int major, int minor, int micro) {
this(major, minor, micro, null);
}
public Version(int major, int minor, int micro, String qualifier) {
this(major, minor, micro, qualifier, false);
}
/**
* Creates a version identifier from the specifed components.
*
* @param major Major component of the version identifier.
* @param minor Minor component of the version identifier.
* @param micro Micro component of the version identifier.
* @param qualifier Qualifier component of the version identifier. If
* <code>null</code> is specified, then the qualifier will be set
* to the empty string.
* @param snapshot true if the version is a snapshot.
* @throws IllegalArgumentException If the numerical components are negative
* or the qualifier string is invalid.
*/
public Version(int major, int minor, int micro, String qualifier, boolean snapshot) {
if (qualifier == null) {
qualifier = ""; //$NON-NLS-1$
}
this.major = major;
this.minor = minor;
this.micro = micro;
this.qualifier = qualifier;
this.isSnapshot = snapshot;
validate();
}
/**
* Created a version identifier from the specified string.
* <br>
* <br>
* Here is the grammar for version strings.
* <br>
* <pre>
* version ::= major('.'minor('.'micro('.'qualifier)?)?)?
* major ::= digit+
* minor ::= digit+
* micro ::= digit+
* qualifier ::= (alpha|digit|'_'|'-')+
* digit ::= [0..9]
* alpha ::= [a..zA..Z]
* </pre>
* <br>
* There must be no whitespace in version.
*
* @param version String representation of the version identifier.
* @throws IllegalArgumentException If <code>version</code> is improperly
* formatted.
*/
public Version(String version) {
if (version == null) {
throw new IllegalArgumentException("Provided version string is null");
}
int major = 0;
int minor = 0;
int micro = 0;
String qualifier = ""; //$NON-NLS-1$
if (version.endsWith(SNAPSHOT)) {
this.isSnapshot = true;
version = version.substring(0, version.length() - 9);
} else {
isSnapshot = false;
}
try {
StringTokenizer st = new StringTokenizer(version, SEPARATOR+"-", true);
major = Integer.parseInt(st.nextToken());
if (st.hasMoreTokens()) {
st.nextToken(); // consume delimiter
minor = Integer.parseInt(st.nextToken());
if (st.hasMoreTokens()) {
st.nextToken(); // consume delimiter
micro = Integer.parseInt(st.nextToken());
if (st.hasMoreTokens()) {
st.nextToken(); // consume delimiter
qualifier = st.nextToken();
if (st.hasMoreTokens()) {
throw new IllegalArgumentException("invalid format"); //$NON-NLS-1$
}
}
}
}
} catch (NoSuchElementException e) {
throw new IllegalArgumentException("invalid format"); //$NON-NLS-1$
}
this.major = major;
this.minor = minor;
this.micro = micro;
this.qualifier = qualifier;
validate();
}
/**
* Called by the Version constructors to validate the version components.
*
* @throws IllegalArgumentException If the numerical components are negative
* or the qualifier string is invalid.
*/
private void validate() {
if (major < 0) {
throw new IllegalArgumentException("negative major"); //$NON-NLS-1$
}
if (minor < 0) {
throw new IllegalArgumentException("negative minor"); //$NON-NLS-1$
}
if (micro < 0) {
throw new IllegalArgumentException("negative micro"); //$NON-NLS-1$
}
int length = qualifier.length();
for (int i = 0; i < length; i++) {
if ("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-".indexOf(qualifier.charAt(i)) == -1) { //$NON-NLS-1$
throw new IllegalArgumentException("invalid qualifier"); //$NON-NLS-1$
}
}
}
/**
* Parses a version identifier from the specified string.
* <br>
* <br>
* See <code>Version(String)</code> for the format of the version string.
*
* @param version String representation of the version identifier. Leading
* and trailing whitespace will be ignored.
* @return A <code>Version</code> object representing the version
* identifier. If <code>version</code> is <code>null</code> or
* the empty string then <code>emptyVersion</code> will be
* returned.
* @throws IllegalArgumentException If <code>version</code> is improperly
* formatted.
*/
public static Version parseVersion(String version) {
if (version == null) {
return emptyVersion;
}
version = version.trim();
if (version.length() == 0) {
return emptyVersion;
}
return new Version(version);
}
/**
* Returns the major component of this version identifier.
*
* @return The major component.
*/
public int getMajor() {
return major;
}
/**
* Returns the minor component of this version identifier.
*
* @return The minor component.
*/
public int getMinor() {
return minor;
}
/**
* Returns the micro component of this version identifier.
*
* @return The micro component.
*/
public int getMicro() {
return micro;
}
/**
* Returns the qualifier component of this version identifier.
*
* @return The qualifier component.
*/
public String getQualifier() {
return qualifier;
}
public boolean isSnapshot() {
return isSnapshot;
}
/**
* Returns the string representation of this version identifier.
* <br>
* <br>
* The format of the version string will be <code>major.minor.micro</code>
* if qualifier is the empty string or
* <code>major.minor.micro-qualifier</code> otherwise.
*
* @return The string representation of this version identifier.
*/
@Override
public String toString() {
String base = major + SEPARATOR + minor + SEPARATOR + micro;
if (qualifier.length() == 0) { //$NON-NLS-1$
return isSnapshot ? base + SNAPSHOT : base;
} else {
return isSnapshot ? base + "-" + qualifier + SNAPSHOT : base + "-" + qualifier;
}
}
/**
* Returns a hash code value for the object.
*
* @return An integer which is a hash code value for this object.
*/
public int hashCode() {
return ((major << 24) + (minor << 16) + (micro << 8) + qualifier.hashCode()) * (isSnapshot ? -1 : 1);
}
/**
* Compares this <code>Version</code> object to another object.
* <br>
* <br>
* A version is considered to be <b>equal to </b> another version if the
* major, minor and micro components are equal and the qualifier component
* is equal (using <code>String.equals</code>).
*
* @param object The <code>Version</code> object to be compared.
* @return <code>true</code> if <code>object</code> is a
* <code>Version</code> and is equal to this object;
* <code>false</code> otherwise.
*/
public boolean equals(Object object) {
if (object == this) { // quicktest
return true;
}
if (!(object instanceof Version)) {
return false;
}
Version other = (Version) object;
return (major == other.major) && (minor == other.minor)
&& (micro == other.micro) && qualifier.equals(other.qualifier);
}
/**
* Compares this <code>Version</code> object to another object.
* <br>
* <br>
* A version is considered to be <b>less than </b> another version if its
* major component is less than the other version's major component, or the
* major components are equal and its minor component is less than the other
* version's minor component, or the major and minor components are equal
* and its micro component is less than the other version's micro component,
* or the major, minor and micro components are equal and it's qualifier
* component is less than the other version's qualifier component (using
* <code>String.compareTo</code>).
* <br>
* <br>
* A version is considered to be <b>equal to</b> another version if the
* major, minor and micro components are equal and the qualifier component
* is equal (using <code>String.compareTo</code>).
*
* @param other The <code>Version</code> object to be compared.
* @return A negative integer, zero, or a positive integer if this object is
* less than, equal to, or greater than the specified
* <code>Version</code> object.
* @throws ClassCastException If the specified object is not a
* <code>Version</code>.
*/
public int compareTo(Version other) {
if (other == this) { // quicktest
return 0;
}
int result = major - other.major;
if (result != 0) {
return result;
}
result = minor - other.minor;
if (result != 0) {
return result;
}
result = micro - other.micro;
if (result != 0) {
return result;
}
result = qualifier.compareTo(other.qualifier);
if (result != 0) {
return result;
}
return (this.isSnapshot ? 0 : 1) - (other.isSnapshot ? 0 : 1);
}
}
@@ -0,0 +1,173 @@
/*
* Copyright 2017-2021 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.xml;
import jakarta.xml.bind.JAXBContext;
import jakarta.xml.bind.JAXBException;
import jakarta.xml.bind.Marshaller;
import jakarta.xml.bind.Unmarshaller;
import org.xml.sax.InputSource;
import org.xml.sax.SAXException;
import org.xml.sax.XMLReader;
import javax.xml.parsers.ParserConfigurationException;
import javax.xml.parsers.SAXParserFactory;
import javax.xml.stream.XMLInputFactory;
import javax.xml.stream.XMLStreamException;
import javax.xml.stream.XMLStreamReader;
import javax.xml.transform.*;
import javax.xml.transform.sax.SAXSource;
import javax.xml.transform.stream.StreamResult;
import javax.xml.transform.stream.StreamSource;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.Objects;
/**
* A collections of convenience methods to help with serialization and deserialization of XML to and from Java objects.
*
* @author Frederic Thevenet
*/
public class XmlUtils {
public static String getFirstAttributeValue(File file, String attribute) throws IOException, XMLStreamException {
XMLStreamReader xmlr = XMLInputFactoryHolder.instance.createXMLStreamReader(new FileInputStream(file));
while (xmlr.hasNext()) {
if (xmlr.getEventType() == XMLStreamReader.START_ELEMENT) {
for (int i = 0; i < xmlr.getAttributeCount(); i++) {
String localName = xmlr.getAttributeName(i).getLocalPart();
if (localName.equals(attribute)) {
return xmlr.getAttributeValue(i);
}
}
return null;
}
xmlr.next();
}
return null;
}
public static String processXslt(String xslString, String xmlString) throws TransformerException, IOException {
Objects.requireNonNull(xslString, "xslString cannot be null");
Objects.requireNonNull(xmlString, "xmlString cannot be null");
return applyTransform(new StreamSource(new StringReader(xslString)), new StreamSource(new StringReader(xmlString)));
}
public static String processXslt(InputStream inXsl, InputStream inXml) throws TransformerException, IOException {
Objects.requireNonNull(inXsl, "inXsl cannot be null");
Objects.requireNonNull(inXml, "inXml cannot be null");
return applyTransform(new StreamSource(inXsl), new StreamSource(inXml));
}
private static String applyTransform(StreamSource xslt, StreamSource xml) throws TransformerException, IOException {
TransformerFactory factory = TransformerFactory.newInstance();
Transformer transformer = factory.newTransformer(xslt);
try (Writer outputWriter = new StringWriter()) {
Result outputResult = new StreamResult(outputWriter);
transformer.transform(xml, outputResult);
return outputWriter.toString();
}
}
public static <T> T deSerialize(File file, Class<?>... classes) throws JAXBException, IOException {
try (FileInputStream fin = new FileInputStream(file)) {
return deSerialize(fin, classes);
}
}
/**
* Deserialize the XML content of a stream into a Java object of the specified type.
*
* @param classes The classes of the object to unmarshall the XML as
* @param inputStream An input stream containing the XML to deserialize
* @param <T> The type of object to unmarshall the XML as
* @return The deserialized object
* @throws JAXBException if an error occurs during deserialization
*/
public static <T> T deSerialize(InputStream inputStream, Class<?>... classes) throws JAXBException {
return deSerialize(new StreamSource(inputStream), classes);
}
/**
* Deserialize the XML content of a string into a Java object of the specified type.
*
* @param classes The classes of the object to unmarshall the XML as
* @param xmlString The XML to deserialize, as a string
* @param <T> The type of object to unmarshall the XML as
* @return The deserialized object
* @throws JAXBException if an error occurs during deserialization
*/
public static <T> T deSerialize(String xmlString, Class<?>... classes) throws JAXBException {
return deSerialize(new StreamSource(new StringReader(xmlString)), classes);
}
@SuppressWarnings("unchecked")
private static <T> T deSerialize(StreamSource source, Class<?>... classes) throws JAXBException {
JAXBContext jc = JAXBContext.newInstance(classes);
Unmarshaller unmarshaller = jc.createUnmarshaller();
return (T) unmarshaller.unmarshal(source);
}
/**
* Returns a {@link SAXSource} for the provided {@link InputStream} that explicitly forfeit external DTD validation
*
* @param in the {@link InputStream} for the {@link SAXSource}
* @return a {@link SAXSource} for the provided {@link InputStream} that explicitly forfeit external DTD validation
* @throws SAXException if a SAX error occurs
* @throws ParserConfigurationException if a configuration error occurs
*/
public static Source toNonValidatingSAXSource(InputStream in) throws SAXException, ParserConfigurationException {
SAXParserFactory spf = SAXParserFactory.newInstance();
spf.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
XMLReader xmlReader = spf.newSAXParser().getXMLReader();
InputSource inputSource = new InputSource(in);
return new SAXSource(xmlReader, inputSource);
}
public static <T> void serialize(T object, Path path, Class<?>... classes) throws JAXBException, IOException {
serialize(object, path.toFile(), classes);
}
public static <T> void serialize(T object, File file, Class<?>... classes) throws JAXBException, IOException {
try (FileOutputStream fout = new FileOutputStream(file)) {
serialize(object, fout, classes);
}
}
public static <T> String serialize(T object, Class<?>... classes) throws IOException, JAXBException {
try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
serialize(object, out, classes);
return out.toString(StandardCharsets.UTF_8);
}
}
public static <T> void serialize(T object, OutputStream out, Class<?>... classes) throws JAXBException {
if (classes == null || classes.length == 0) {
classes = new Class<?>[]{object.getClass()};
}
JAXBContext jaxbContext = JAXBContext.newInstance(classes);
Marshaller jaxbMarshaller = jaxbContext.createMarshaller();
jaxbMarshaller.setProperty(Marshaller.JAXB_FORMATTED_OUTPUT, true);
jaxbMarshaller.marshal(object, out);
}
private static class XMLInputFactoryHolder {
private final static XMLInputFactory instance = XMLInputFactory.newInstance();
}
}
@@ -0,0 +1,44 @@
/*
* Copyright 2017-2018 Frederic Thevenet
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.xml.adapters;
import jakarta.xml.bind.annotation.adapters.XmlAdapter;
import javafx.scene.paint.Color;
/**
* An {@link XmlAdapter} for {@link Color} objects
*
* @author Frederic Thevenet
*/
public class ColorXmlAdapter extends XmlAdapter<String, Color> {
/**
* Initializes a new instance of the {@link ColorXmlAdapter} class
*/
public ColorXmlAdapter() {
}
@Override
public Color unmarshal(String stringValue) {
return stringValue != null ? Color.valueOf(stringValue) : null;
}
@Override
public String marshal(Color value) {
return value != null ? value.toString() : null;
}
}
@@ -0,0 +1,46 @@
/*
* Copyright (c) 2015 Mikhail Sokolov
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.xml.adapters;
import jakarta.xml.bind.annotation.adapters.XmlAdapter;
import java.time.Duration;
/**
* {@code XmlAdapter} mapping JSR-310 {@code Duration} to ISO-8601 string
* <p>
* String format details:
* <ul>
* <li>{@link Duration#parse(CharSequence)}</li>
* <li>{@link Duration#toString()}</li>
* </ul>
*
* @see XmlAdapter
* @see Duration
*/
public class DurationXmlAdapter extends XmlAdapter<String, Duration> {
@Override
public Duration unmarshal(String stringValue) {
return stringValue != null ? Duration.parse(stringValue) : null;
}
@Override
public String marshal(Duration value) {
return value != null ? value.toString() : null;
}
}
@@ -0,0 +1,33 @@
/*
* Copyright (c) 2015 Mikhail Sokolov
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.xml.adapters;
import java.time.Instant;
import java.time.format.DateTimeFormatter;
/**
* {@code XmlAdapter} mapping JSR-310 {@code Instant} to ISO-8601 string
* <p>
* String format details: {@link DateTimeFormatter#ISO_INSTANT}
*
* @see Instant
*/
public class InstantXmlAdapter extends TemporalAccessorXmlAdapter<Instant> {
public InstantXmlAdapter() {
super(DateTimeFormatter.ISO_INSTANT, Instant::from);
}
}
@@ -0,0 +1,34 @@
/*
* Copyright (c) 2015 Mikhail Sokolov
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.xml.adapters;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
/**
* {@code XmlAdapter} mapping JSR-310 {@code LocalDateTime} to ISO-8601 string
* <p>
* String format details: {@link DateTimeFormatter#ISO_LOCAL_DATE_TIME}
*
* @see LocalDateTime
*/
public class LocalDateTimeXmlAdapter extends TemporalAccessorXmlAdapter<LocalDateTime> {
public LocalDateTimeXmlAdapter() {
super(DateTimeFormatter.ISO_LOCAL_DATE_TIME, LocalDateTime::from);
}
}
@@ -0,0 +1,34 @@
/*
* Copyright (c) 2015 Mikhail Sokolov
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package eu.binjr.common.xml.adapters;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
/**
* {@code XmlAdapter} mapping JSR-310 {@code LocalDate} to ISO-8601 string
* <p>
* It uses {@link DateTimeFormatter#ISO_DATE} for parsing and serializing,
* time-zone information ignored.
*
* @see LocalDate
*/
public class LocalDateXmlAdapter extends TemporalAccessorXmlAdapter<LocalDate> {
public LocalDateXmlAdapter() {
super(DateTimeFormatter.ISO_DATE, LocalDate::from);
}
}

Some files were not shown because too many files have changed in this diff Show More