Added SLF4J, logging, and more orderly profile initialization.
This commit is contained in:
		
							parent
							
								
									7682c569eb
								
							
						
					
					
						commit
						96cbe22c3d
					
				
							
								
								
									
										14
									
								
								pom.xml
								
								
								
								
							
							
						
						
									
										14
									
								
								pom.xml
								
								
								
								
							| 
						 | 
				
			
			@ -50,6 +50,7 @@
 | 
			
		|||
            <version>2.2.224</version>
 | 
			
		||||
        </dependency>
 | 
			
		||||
 | 
			
		||||
        <!-- Testing -->
 | 
			
		||||
        <dependency>
 | 
			
		||||
            <groupId>org.junit.jupiter</groupId>
 | 
			
		||||
            <artifactId>junit-jupiter-api</artifactId>
 | 
			
		||||
| 
						 | 
				
			
			@ -62,6 +63,19 @@
 | 
			
		|||
            <version>5.10.0</version>
 | 
			
		||||
            <scope>test</scope>
 | 
			
		||||
        </dependency>
 | 
			
		||||
 | 
			
		||||
        <!-- Logging -->
 | 
			
		||||
        <dependency>
 | 
			
		||||
            <groupId>org.slf4j</groupId>
 | 
			
		||||
            <artifactId>slf4j-api</artifactId>
 | 
			
		||||
            <version>2.0.10</version>
 | 
			
		||||
        </dependency>
 | 
			
		||||
        <dependency>
 | 
			
		||||
            <groupId>ch.qos.logback</groupId>
 | 
			
		||||
            <artifactId>logback-classic</artifactId>
 | 
			
		||||
            <version>1.4.12</version>
 | 
			
		||||
            <scope>runtime</scope>
 | 
			
		||||
        </dependency>
 | 
			
		||||
    </dependencies>
 | 
			
		||||
 | 
			
		||||
    <build>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -2,6 +2,8 @@ package com.andrewlalis.perfin;
 | 
			
		|||
 | 
			
		||||
import com.andrewlalis.javafx_scene_router.AnchorPaneRouterView;
 | 
			
		||||
import com.andrewlalis.javafx_scene_router.SceneRouter;
 | 
			
		||||
import com.andrewlalis.perfin.control.Popups;
 | 
			
		||||
import com.andrewlalis.perfin.data.DataSourceInitializationException;
 | 
			
		||||
import com.andrewlalis.perfin.model.Profile;
 | 
			
		||||
import com.andrewlalis.perfin.view.ImageCache;
 | 
			
		||||
import com.andrewlalis.perfin.view.SceneUtil;
 | 
			
		||||
| 
						 | 
				
			
			@ -89,6 +91,11 @@ public class PerfinApp extends Application {
 | 
			
		|||
 | 
			
		||||
    private static void loadLastUsedProfile(Consumer<String> msgConsumer) throws Exception {
 | 
			
		||||
        msgConsumer.accept("Loading the most recent profile.");
 | 
			
		||||
        Profile.loadLast();
 | 
			
		||||
        try {
 | 
			
		||||
            Profile.loadLast();
 | 
			
		||||
        } catch (DataSourceInitializationException e) {
 | 
			
		||||
            Popups.error(e.getMessage());
 | 
			
		||||
            throw e;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,11 +1,12 @@
 | 
			
		|||
package com.andrewlalis.perfin.control;
 | 
			
		||||
 | 
			
		||||
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
 | 
			
		||||
import com.andrewlalis.perfin.view.component.AccountTile;
 | 
			
		||||
import com.andrewlalis.perfin.data.util.CurrencyUtil;
 | 
			
		||||
import com.andrewlalis.perfin.data.pagination.PageRequest;
 | 
			
		||||
import com.andrewlalis.perfin.data.pagination.Sort;
 | 
			
		||||
import com.andrewlalis.perfin.data.util.CurrencyUtil;
 | 
			
		||||
import com.andrewlalis.perfin.model.MoneyValue;
 | 
			
		||||
import com.andrewlalis.perfin.model.Profile;
 | 
			
		||||
import com.andrewlalis.perfin.view.component.AccountTile;
 | 
			
		||||
import javafx.application.Platform;
 | 
			
		||||
import javafx.beans.property.BooleanProperty;
 | 
			
		||||
import javafx.beans.property.SimpleBooleanProperty;
 | 
			
		||||
| 
						 | 
				
			
			@ -13,10 +14,6 @@ import javafx.fxml.FXML;
 | 
			
		|||
import javafx.scene.control.Label;
 | 
			
		||||
import javafx.scene.layout.FlowPane;
 | 
			
		||||
 | 
			
		||||
import java.math.BigDecimal;
 | 
			
		||||
import java.math.RoundingMode;
 | 
			
		||||
import java.util.Currency;
 | 
			
		||||
 | 
			
		||||
import static com.andrewlalis.perfin.PerfinApp.router;
 | 
			
		||||
 | 
			
		||||
public class AccountsViewController implements RouteSelectionListener {
 | 
			
		||||
| 
						 | 
				
			
			@ -63,9 +60,7 @@ public class AccountsViewController implements RouteSelectionListener {
 | 
			
		|||
                var totals = profile.getDataSource().getCombinedAccountBalances();
 | 
			
		||||
                StringBuilder sb = new StringBuilder("Totals: ");
 | 
			
		||||
                for (var entry : totals.entrySet()) {
 | 
			
		||||
                    Currency cur = entry.getKey();
 | 
			
		||||
                    BigDecimal value = entry.getValue().setScale(cur.getDefaultFractionDigits(), RoundingMode.HALF_UP);
 | 
			
		||||
                    sb.append(cur.getCurrencyCode()).append(' ').append(CurrencyUtil.formatMoney(value, cur)).append(' ');
 | 
			
		||||
                    sb.append(CurrencyUtil.formatMoneyWithCurrencyPrefix(new MoneyValue(entry.getValue(), entry.getKey())));
 | 
			
		||||
                }
 | 
			
		||||
                Platform.runLater(() -> totalLabel.setText(sb.toString().strip()));
 | 
			
		||||
            });
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -5,6 +5,7 @@ import com.andrewlalis.perfin.data.util.CurrencyUtil;
 | 
			
		|||
import com.andrewlalis.perfin.data.util.DateUtil;
 | 
			
		||||
import com.andrewlalis.perfin.data.util.FileUtil;
 | 
			
		||||
import com.andrewlalis.perfin.model.Account;
 | 
			
		||||
import com.andrewlalis.perfin.model.MoneyValue;
 | 
			
		||||
import com.andrewlalis.perfin.model.Profile;
 | 
			
		||||
import com.andrewlalis.perfin.view.component.FileSelectionArea;
 | 
			
		||||
import javafx.application.Platform;
 | 
			
		||||
| 
						 | 
				
			
			@ -39,7 +40,7 @@ public class CreateBalanceRecordController implements RouteSelectionListener {
 | 
			
		|||
            Profile.getCurrent().getDataSource().useAccountRepository(repo -> {
 | 
			
		||||
                BigDecimal value = repo.deriveCurrentBalance(account.getId());
 | 
			
		||||
                Platform.runLater(() -> balanceField.setText(
 | 
			
		||||
                        CurrencyUtil.formatMoneyAsBasicNumber(value, account.getCurrency())
 | 
			
		||||
                        CurrencyUtil.formatMoneyAsBasicNumber(new MoneyValue(value, account.getCurrency()))
 | 
			
		||||
                ));
 | 
			
		||||
            });
 | 
			
		||||
        });
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,6 +1,7 @@
 | 
			
		|||
package com.andrewlalis.perfin.control;
 | 
			
		||||
 | 
			
		||||
import com.andrewlalis.perfin.PerfinApp;
 | 
			
		||||
import com.andrewlalis.perfin.data.DataSourceInitializationException;
 | 
			
		||||
import com.andrewlalis.perfin.data.util.FileUtil;
 | 
			
		||||
import com.andrewlalis.perfin.model.Profile;
 | 
			
		||||
import com.andrewlalis.perfin.view.ProfilesStage;
 | 
			
		||||
| 
						 | 
				
			
			@ -124,6 +125,9 @@ public class ProfilesViewController {
 | 
			
		|||
            e.printStackTrace(System.err);
 | 
			
		||||
            Popups.error("Failed to load profile: " + e.getMessage());
 | 
			
		||||
            return false;
 | 
			
		||||
        } catch (DataSourceInitializationException e) {
 | 
			
		||||
            Popups.error("Failed to initialize the profile's data: " + e.getMessage());
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -44,7 +44,7 @@ public class TransactionViewController {
 | 
			
		|||
        this.transaction = transaction;
 | 
			
		||||
        if (transaction == null) return;
 | 
			
		||||
        titleLabel.setText("Transaction #" + transaction.getId());
 | 
			
		||||
        amountLabel.setText(CurrencyUtil.formatMoney(transaction.getAmount(), transaction.getCurrency()));
 | 
			
		||||
        amountLabel.setText(CurrencyUtil.formatMoney(transaction.getMoneyAmount()));
 | 
			
		||||
        timestampLabel.setText(DateUtil.formatUTCAsLocalWithZone(transaction.getTimestamp()));
 | 
			
		||||
        descriptionLabel.setText(transaction.getDescription());
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -5,6 +5,7 @@ import com.andrewlalis.perfin.data.util.CurrencyUtil;
 | 
			
		|||
import com.andrewlalis.perfin.data.util.DbUtil;
 | 
			
		||||
import com.andrewlalis.perfin.data.util.ThrowableConsumer;
 | 
			
		||||
import com.andrewlalis.perfin.model.Account;
 | 
			
		||||
import com.andrewlalis.perfin.model.MoneyValue;
 | 
			
		||||
import javafx.application.Platform;
 | 
			
		||||
 | 
			
		||||
import java.math.BigDecimal;
 | 
			
		||||
| 
						 | 
				
			
			@ -52,12 +53,11 @@ public interface DataSource {
 | 
			
		|||
    // Utility methods:
 | 
			
		||||
 | 
			
		||||
    default void getAccountBalanceText(Account account, Consumer<String> balanceConsumer) {
 | 
			
		||||
        Thread.ofVirtual().start(() -> {
 | 
			
		||||
            useAccountRepository(repo -> {
 | 
			
		||||
                BigDecimal balance = repo.deriveCurrentBalance(account.getId());
 | 
			
		||||
                Platform.runLater(() -> balanceConsumer.accept(CurrencyUtil.formatMoney(balance, account.getCurrency())));
 | 
			
		||||
            });
 | 
			
		||||
        });
 | 
			
		||||
        Thread.ofVirtual().start(() -> useAccountRepository(repo -> {
 | 
			
		||||
            BigDecimal balance = repo.deriveCurrentBalance(account.getId());
 | 
			
		||||
            MoneyValue money = new MoneyValue(balance, account.getCurrency());
 | 
			
		||||
            Platform.runLater(() -> balanceConsumer.accept(CurrencyUtil.formatMoney(money)));
 | 
			
		||||
        }));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    default Map<Currency, BigDecimal> getCombinedAccountBalances() {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,11 @@
 | 
			
		|||
package com.andrewlalis.perfin.data;
 | 
			
		||||
 | 
			
		||||
public class DataSourceInitializationException extends Exception {
 | 
			
		||||
    public DataSourceInitializationException(String message) {
 | 
			
		||||
        super(message);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public DataSourceInitializationException(String message, Throwable cause) {
 | 
			
		||||
        super(message, cause);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,130 @@
 | 
			
		|||
package com.andrewlalis.perfin.data.impl;
 | 
			
		||||
 | 
			
		||||
import com.andrewlalis.perfin.data.DataSource;
 | 
			
		||||
import com.andrewlalis.perfin.data.DataSourceInitializationException;
 | 
			
		||||
import com.andrewlalis.perfin.data.util.FileUtil;
 | 
			
		||||
import com.andrewlalis.perfin.model.Profile;
 | 
			
		||||
import org.slf4j.Logger;
 | 
			
		||||
import org.slf4j.LoggerFactory;
 | 
			
		||||
 | 
			
		||||
import java.io.IOException;
 | 
			
		||||
import java.io.InputStream;
 | 
			
		||||
import java.nio.charset.StandardCharsets;
 | 
			
		||||
import java.nio.file.Files;
 | 
			
		||||
import java.nio.file.Path;
 | 
			
		||||
import java.sql.Connection;
 | 
			
		||||
import java.sql.SQLException;
 | 
			
		||||
import java.sql.Statement;
 | 
			
		||||
import java.util.Arrays;
 | 
			
		||||
import java.util.List;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Component that's responsible for obtaining a JDBC data source for a profile.
 | 
			
		||||
 */
 | 
			
		||||
public class JdbcDataSourceFactory {
 | 
			
		||||
    private static final Logger log = LoggerFactory.getLogger(JdbcDataSourceFactory.class);
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The version of schema that this app is compatible with. If a profile is
 | 
			
		||||
     * loaded with an old schema version, then we'll migrate to the latest. If
 | 
			
		||||
     * the profile has a newer schema version, we'll exit and prompt the user
 | 
			
		||||
     * to update their app.
 | 
			
		||||
     */
 | 
			
		||||
    public static final int SCHEMA_VERSION = 1;
 | 
			
		||||
 | 
			
		||||
    public DataSource getDataSource(String profileName) throws DataSourceInitializationException {
 | 
			
		||||
        final boolean dbExists = Files.exists(getDatabaseFile(profileName));
 | 
			
		||||
        if (!dbExists) {
 | 
			
		||||
            log.info("Creating new database for profile {}.", profileName);
 | 
			
		||||
            createNewDatabase(profileName);
 | 
			
		||||
        } else {
 | 
			
		||||
            int loadedSchemaVersion;
 | 
			
		||||
            try {
 | 
			
		||||
                loadedSchemaVersion = getSchemaVersion(profileName);
 | 
			
		||||
            } catch (IOException e) {
 | 
			
		||||
                log.error("Failed to load schema version.", e);
 | 
			
		||||
                throw new DataSourceInitializationException("Failed to determine database schema version.", e);
 | 
			
		||||
            }
 | 
			
		||||
            log.debug("Database loaded for profile {} has schema version {}.", profileName, loadedSchemaVersion);
 | 
			
		||||
            if (loadedSchemaVersion < SCHEMA_VERSION) {
 | 
			
		||||
                log.debug("Schema version {} is lower than the app's version {}. Performing migration.", loadedSchemaVersion, SCHEMA_VERSION);
 | 
			
		||||
                // TODO: Do migration
 | 
			
		||||
            } else if (loadedSchemaVersion > SCHEMA_VERSION) {
 | 
			
		||||
                log.debug("Schema version {} is higher than the app's version {}. Cannot continue.", loadedSchemaVersion, SCHEMA_VERSION);
 | 
			
		||||
                throw new DataSourceInitializationException("Profile " + profileName + " has a database with an unsupported schema version.");
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        return new JdbcDataSource(getJdbcUrl(profileName), Profile.getContentDir(profileName));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private void createNewDatabase(String profileName) throws DataSourceInitializationException {
 | 
			
		||||
        log.info("Creating new database for profile {}.", profileName);
 | 
			
		||||
        JdbcDataSource dataSource = new JdbcDataSource(getJdbcUrl(profileName), Profile.getContentDir(profileName));
 | 
			
		||||
        try (
 | 
			
		||||
                InputStream in = JdbcDataSourceFactory.class.getResourceAsStream("/sql/schema.sql");
 | 
			
		||||
                Connection conn = dataSource.getConnection()
 | 
			
		||||
        ) {
 | 
			
		||||
            if (in == null) throw new IOException("Could not load database schema SQL file.");
 | 
			
		||||
            String schemaStr = new String(in.readAllBytes(), StandardCharsets.UTF_8);
 | 
			
		||||
            List<String> statements = Arrays.stream(schemaStr.split(";"))
 | 
			
		||||
                    .map(String::strip).filter(s -> !s.isBlank()).toList();
 | 
			
		||||
            for (String statementText : statements) {
 | 
			
		||||
                try (Statement stmt = conn.createStatement()) {
 | 
			
		||||
                    stmt.executeUpdate(statementText);
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            try {
 | 
			
		||||
                writeCurrentSchemaVersion(profileName);
 | 
			
		||||
            } catch (IOException e) {
 | 
			
		||||
                log.warn("Failed to write current schema version to file.", e);
 | 
			
		||||
            }
 | 
			
		||||
        } catch (IOException e) {
 | 
			
		||||
            log.error("IO Exception when trying to create database.", e);
 | 
			
		||||
            FileUtil.deleteIfPossible(getDatabaseFile(profileName));
 | 
			
		||||
            throw new DataSourceInitializationException("Failed to read SQL data to create database schema.", e);
 | 
			
		||||
        } catch (SQLException e) {
 | 
			
		||||
            log.error("SQL Exception when trying to create database.", e);
 | 
			
		||||
            FileUtil.deleteIfPossible(getDatabaseFile(profileName));
 | 
			
		||||
            throw new DataSourceInitializationException("Failed to create the database due to an SQL error.", e);
 | 
			
		||||
        }
 | 
			
		||||
        if (!testConnection(dataSource)) {
 | 
			
		||||
            FileUtil.deleteIfPossible(getDatabaseFile(profileName));
 | 
			
		||||
            throw new DataSourceInitializationException("Testing the database connection failed.");
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private boolean testConnection(JdbcDataSource dataSource) {
 | 
			
		||||
        try (var conn = dataSource.getConnection(); var stmt = conn.createStatement()) {
 | 
			
		||||
            return stmt.execute("SELECT 1;");
 | 
			
		||||
        } catch (SQLException e) {
 | 
			
		||||
            log.error("JDBC database connection failed.", e);
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static Path getDatabaseFile(String profileName) {
 | 
			
		||||
        return Profile.getDir(profileName).resolve("database.mv.db");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static String getJdbcUrl(String profileName) {
 | 
			
		||||
        String dbPathAbs = getDatabaseFile(profileName).toAbsolutePath().toString();
 | 
			
		||||
        return "jdbc:h2:" + dbPathAbs.substring(0, dbPathAbs.length() - 6);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static Path getSchemaVersionFile(String profileName) {
 | 
			
		||||
        return Profile.getDir(profileName).resolve(".jdbc-schema-version.txt");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static int getSchemaVersion(String profileName) throws IOException {
 | 
			
		||||
        if (Files.exists(getSchemaVersionFile(profileName))) {
 | 
			
		||||
            return Integer.parseInt(Files.readString(getSchemaVersionFile(profileName)));
 | 
			
		||||
        } else {
 | 
			
		||||
            writeCurrentSchemaVersion(profileName);
 | 
			
		||||
            return SCHEMA_VERSION;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static void writeCurrentSchemaVersion(String profileName) throws IOException {
 | 
			
		||||
        Files.writeString(getSchemaVersionFile(profileName), Integer.toString(SCHEMA_VERSION));
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,22 +1,27 @@
 | 
			
		|||
package com.andrewlalis.perfin.data.util;
 | 
			
		||||
 | 
			
		||||
import com.andrewlalis.perfin.model.MoneyValue;
 | 
			
		||||
 | 
			
		||||
import java.math.BigDecimal;
 | 
			
		||||
import java.math.RoundingMode;
 | 
			
		||||
import java.text.NumberFormat;
 | 
			
		||||
import java.util.Currency;
 | 
			
		||||
 | 
			
		||||
public class CurrencyUtil {
 | 
			
		||||
    public static String formatMoney(BigDecimal amount, Currency currency) {
 | 
			
		||||
    public static String formatMoney(MoneyValue money) {
 | 
			
		||||
        NumberFormat nf = NumberFormat.getCurrencyInstance();
 | 
			
		||||
        nf.setCurrency(currency);
 | 
			
		||||
        nf.setMaximumFractionDigits(currency.getDefaultFractionDigits());
 | 
			
		||||
        nf.setMinimumFractionDigits(currency.getDefaultFractionDigits());
 | 
			
		||||
        BigDecimal displayValue = amount.setScale(currency.getDefaultFractionDigits(), RoundingMode.HALF_UP);
 | 
			
		||||
        nf.setCurrency(money.currency());
 | 
			
		||||
        nf.setMaximumFractionDigits(money.currency().getDefaultFractionDigits());
 | 
			
		||||
        nf.setMinimumFractionDigits(money.currency().getDefaultFractionDigits());
 | 
			
		||||
        BigDecimal displayValue = money.amount().setScale(money.currency().getDefaultFractionDigits(), RoundingMode.HALF_UP);
 | 
			
		||||
        return nf.format(displayValue);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static String formatMoneyAsBasicNumber(BigDecimal amount, Currency currency) {
 | 
			
		||||
        BigDecimal displayValue = amount.setScale(currency.getDefaultFractionDigits(), RoundingMode.HALF_UP);
 | 
			
		||||
    public static String formatMoneyWithCurrencyPrefix(MoneyValue money) {
 | 
			
		||||
        return money.currency().getCurrencyCode() + ' ' + formatMoney(money);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static String formatMoneyAsBasicNumber(MoneyValue money) {
 | 
			
		||||
        BigDecimal displayValue = money.amount().setScale(money.currency().getDefaultFractionDigits(), RoundingMode.HALF_UP);
 | 
			
		||||
        return displayValue.toString();
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,6 +1,8 @@
 | 
			
		|||
package com.andrewlalis.perfin.data.util;
 | 
			
		||||
 | 
			
		||||
import javafx.stage.FileChooser;
 | 
			
		||||
import org.slf4j.Logger;
 | 
			
		||||
import org.slf4j.LoggerFactory;
 | 
			
		||||
 | 
			
		||||
import java.io.IOException;
 | 
			
		||||
import java.nio.file.FileVisitResult;
 | 
			
		||||
| 
						 | 
				
			
			@ -12,6 +14,8 @@ import java.util.HashMap;
 | 
			
		|||
import java.util.Map;
 | 
			
		||||
 | 
			
		||||
public class FileUtil {
 | 
			
		||||
    private static final Logger log = LoggerFactory.getLogger(FileUtil.class);
 | 
			
		||||
 | 
			
		||||
    public static Map<String, String> MIMETYPES = new HashMap<>();
 | 
			
		||||
    static {
 | 
			
		||||
        MIMETYPES.put(".pdf", "application/pdf");
 | 
			
		||||
| 
						 | 
				
			
			@ -31,6 +35,14 @@ public class FileUtil {
 | 
			
		|||
        MIMETYPES.put(".tiff", "image/tiff");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static void deleteIfPossible(Path file) {
 | 
			
		||||
        try {
 | 
			
		||||
            Files.deleteIfExists(file);
 | 
			
		||||
        } catch (IOException e) {
 | 
			
		||||
            log.error("Failed to delete file " + file, e);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static void deleteDirRecursive(Path startDir) throws IOException {
 | 
			
		||||
        Files.walkFileTree(startDir, new SimpleFileVisitor<>() {
 | 
			
		||||
            @Override
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -85,4 +85,8 @@ public class AccountEntry {
 | 
			
		|||
    public BigDecimal getSignedAmount() {
 | 
			
		||||
        return type == Type.DEBIT ? amount : amount.negate();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public MoneyValue getMoneyValue() {
 | 
			
		||||
        return new MoneyValue(amount, currency);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -44,4 +44,8 @@ public class BalanceRecord {
 | 
			
		|||
    public Currency getCurrency() {
 | 
			
		||||
        return currency;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public MoneyValue getMoneyAmount() {
 | 
			
		||||
        return new MoneyValue(balance, currency);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,11 @@
 | 
			
		|||
package com.andrewlalis.perfin.model;
 | 
			
		||||
 | 
			
		||||
import java.math.BigDecimal;
 | 
			
		||||
import java.util.Currency;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * An amount of money of a certain currency.
 | 
			
		||||
 * @param amount The amount of money.
 | 
			
		||||
 * @param currency The currency of the money.
 | 
			
		||||
 */
 | 
			
		||||
public record MoneyValue(BigDecimal amount, Currency currency) {}
 | 
			
		||||
| 
						 | 
				
			
			@ -2,14 +2,18 @@ package com.andrewlalis.perfin.model;
 | 
			
		|||
 | 
			
		||||
import com.andrewlalis.perfin.PerfinApp;
 | 
			
		||||
import com.andrewlalis.perfin.data.DataSource;
 | 
			
		||||
import com.andrewlalis.perfin.data.impl.JdbcDataSource;
 | 
			
		||||
import com.andrewlalis.perfin.data.DataSourceInitializationException;
 | 
			
		||||
import com.andrewlalis.perfin.data.impl.JdbcDataSourceFactory;
 | 
			
		||||
import org.slf4j.Logger;
 | 
			
		||||
import org.slf4j.LoggerFactory;
 | 
			
		||||
 | 
			
		||||
import java.io.IOException;
 | 
			
		||||
import java.nio.charset.StandardCharsets;
 | 
			
		||||
import java.nio.file.Files;
 | 
			
		||||
import java.nio.file.Path;
 | 
			
		||||
import java.sql.SQLException;
 | 
			
		||||
import java.util.*;
 | 
			
		||||
import java.util.ArrayList;
 | 
			
		||||
import java.util.Collections;
 | 
			
		||||
import java.util.List;
 | 
			
		||||
import java.util.Properties;
 | 
			
		||||
import java.util.function.Consumer;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
| 
						 | 
				
			
			@ -30,6 +34,8 @@ import java.util.function.Consumer;
 | 
			
		|||
 * </p>
 | 
			
		||||
 */
 | 
			
		||||
public class Profile {
 | 
			
		||||
    private static final Logger log = LoggerFactory.getLogger(Profile.class);
 | 
			
		||||
 | 
			
		||||
    private static Profile current;
 | 
			
		||||
    private static final List<Consumer<Profile>> profileLoadListeners = new ArrayList<>();
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -67,10 +73,6 @@ public class Profile {
 | 
			
		|||
        return getDir(name).resolve("settings.properties");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static Path getDatabaseFile(String name) {
 | 
			
		||||
        return getDir(name).resolve("database.mv.db");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static Profile getCurrent() {
 | 
			
		||||
        return current;
 | 
			
		||||
    }
 | 
			
		||||
| 
						 | 
				
			
			@ -89,6 +91,7 @@ public class Profile {
 | 
			
		|||
                    .map(path -> path.getFileName().toString())
 | 
			
		||||
                    .sorted().toList();
 | 
			
		||||
        } catch (IOException e) {
 | 
			
		||||
            log.error("Failed to get a list of available profiles.", e);
 | 
			
		||||
            return Collections.emptyList();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
| 
						 | 
				
			
			@ -100,8 +103,7 @@ public class Profile {
 | 
			
		|||
                String s = Files.readString(lastProfileFile).strip().toLowerCase();
 | 
			
		||||
                if (!s.isBlank()) return s;
 | 
			
		||||
            } catch (IOException e) {
 | 
			
		||||
                System.err.println("Failed to read " + lastProfileFile);
 | 
			
		||||
                e.printStackTrace(System.err);
 | 
			
		||||
                log.error("Failed to read " + lastProfileFile, e);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        return "default";
 | 
			
		||||
| 
						 | 
				
			
			@ -112,16 +114,15 @@ public class Profile {
 | 
			
		|||
        try {
 | 
			
		||||
            Files.writeString(lastProfileFile, name);
 | 
			
		||||
        } catch (IOException e) {
 | 
			
		||||
            System.err.println("Failed to write " + lastProfileFile);
 | 
			
		||||
            e.printStackTrace(System.err);
 | 
			
		||||
            log.error("Failed to write " + lastProfileFile, e);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static void loadLast() throws Exception {
 | 
			
		||||
    public static void loadLast() throws IOException, DataSourceInitializationException {
 | 
			
		||||
        load(getLastProfile());
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static void load(String name) throws IOException {
 | 
			
		||||
    public static void load(String name) throws IOException, DataSourceInitializationException {
 | 
			
		||||
        if (Files.notExists(getDir(name))) {
 | 
			
		||||
            initProfileDir(name);
 | 
			
		||||
        }
 | 
			
		||||
| 
						 | 
				
			
			@ -129,7 +130,7 @@ public class Profile {
 | 
			
		|||
        try (var in = Files.newInputStream(getSettingsFile(name))) {
 | 
			
		||||
            settings.load(in);
 | 
			
		||||
        }
 | 
			
		||||
        current = new Profile(name, settings, initJdbcDataSource(name));
 | 
			
		||||
        current = new Profile(name, settings, new JdbcDataSourceFactory().getDataSource(name));
 | 
			
		||||
        saveLastProfile(current.getName());
 | 
			
		||||
        for (var c : profileLoadListeners) {
 | 
			
		||||
            c.accept(current);
 | 
			
		||||
| 
						 | 
				
			
			@ -144,38 +145,6 @@ public class Profile {
 | 
			
		|||
        copyResourceFile("/text/contentDirReadme.txt", getContentDir(name).resolve("README.txt"));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static DataSource initJdbcDataSource(String name) throws IOException {
 | 
			
		||||
        String databaseFilename = getDatabaseFile(name).toAbsolutePath().toString();
 | 
			
		||||
        String jdbcUrl = "jdbc:h2:" + databaseFilename.substring(0, databaseFilename.length() - 6);
 | 
			
		||||
        boolean exists = Files.exists(getDatabaseFile(name));
 | 
			
		||||
        JdbcDataSource dataSource = new JdbcDataSource(jdbcUrl, getContentDir(name));
 | 
			
		||||
        if (!exists) {// Initialize the datasource using schema.sql.
 | 
			
		||||
            try (var in = Profile.class.getResourceAsStream("/sql/schema.sql"); var conn = dataSource.getConnection()) {
 | 
			
		||||
                if (in == null) throw new IOException("Could not load /sql/schema.sql");
 | 
			
		||||
                String schemaStr = new String(in.readAllBytes(), StandardCharsets.UTF_8);
 | 
			
		||||
                List<String> statements = Arrays.stream(schemaStr.split(";"))
 | 
			
		||||
                        .map(String::strip).filter(s -> !s.isBlank()).toList();
 | 
			
		||||
                for (var statementStr : statements) {
 | 
			
		||||
                    try (var stmt = conn.createStatement()) {
 | 
			
		||||
                        stmt.executeUpdate(statementStr);
 | 
			
		||||
                        System.out.println("Executed update:\n" + statementStr + "\n-----");
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            } catch (SQLException e) {
 | 
			
		||||
                Files.deleteIfExists(getDatabaseFile(name));
 | 
			
		||||
                throw new IOException("Failed to initialize database.", e);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        // Test the datasource before returning it.
 | 
			
		||||
        try (var conn = dataSource.getConnection(); var s = conn.createStatement()) {
 | 
			
		||||
            boolean success = s.execute("SELECT 1;");
 | 
			
		||||
            if (!success) throw new IOException("Failed to execute DB test statement.");
 | 
			
		||||
        } catch (SQLException e) {
 | 
			
		||||
            throw new IOException(e);
 | 
			
		||||
        }
 | 
			
		||||
        return dataSource;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static void copyResourceFile(String resource, Path dest) throws IOException {
 | 
			
		||||
        try (
 | 
			
		||||
                var in = Profile.class.getResourceAsStream(resource);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -46,6 +46,10 @@ public class Transaction {
 | 
			
		|||
        return description;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public MoneyValue getMoneyAmount() {
 | 
			
		||||
        return new MoneyValue(amount, currency);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public boolean equals(Object other) {
 | 
			
		||||
        return other instanceof Transaction tx && id == tx.id;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -42,7 +42,7 @@ public class AccountHistoryItemTile extends BorderPane {
 | 
			
		|||
    }
 | 
			
		||||
 | 
			
		||||
    private Node buildAccountEntryItem(AccountEntry entry) {
 | 
			
		||||
        Text amountText = new Text(CurrencyUtil.formatMoney(entry.getSignedAmount(), entry.getCurrency()));
 | 
			
		||||
        Text amountText = new Text(CurrencyUtil.formatMoneyWithCurrencyPrefix(entry.getMoneyValue()));
 | 
			
		||||
        Hyperlink transactionLink = new Hyperlink("Transaction #" + entry.getTransactionId());
 | 
			
		||||
        transactionLink.setOnAction(event -> router.navigate(
 | 
			
		||||
                "transactions",
 | 
			
		||||
| 
						 | 
				
			
			@ -56,7 +56,7 @@ public class AccountHistoryItemTile extends BorderPane {
 | 
			
		|||
    }
 | 
			
		||||
 | 
			
		||||
    private Node buildBalanceRecordItem(BalanceRecord balanceRecord) {
 | 
			
		||||
        Text amountText = new Text(CurrencyUtil.formatMoney(balanceRecord.getBalance(), balanceRecord.getCurrency()));
 | 
			
		||||
        return new TextFlow(new Text("Balance record added with value of "), amountText);
 | 
			
		||||
        Text amountText = new Text(CurrencyUtil.formatMoneyWithCurrencyPrefix(balanceRecord.getMoneyAmount()));
 | 
			
		||||
        return new TextFlow(new Text("Balance record #" + balanceRecord.getId() + " added with value of "), amountText);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -57,7 +57,6 @@ public class DataSourcePaginationControls extends BorderPane {
 | 
			
		|||
        pageText.setTextAlignment(TextAlignment.CENTER);
 | 
			
		||||
        BorderPane pageTextContainer = new BorderPane(pageText);
 | 
			
		||||
        BorderPane.setAlignment(pageText, Pos.CENTER);
 | 
			
		||||
        pageTextContainer.setStyle("-fx-border-color: blue;");
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
        Button previousPageButton = new Button("Previous Page");
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -55,7 +55,7 @@ public class TransactionTile extends BorderPane {
 | 
			
		|||
    }
 | 
			
		||||
 | 
			
		||||
    private Node getHeader(Transaction transaction) {
 | 
			
		||||
        Label currencyLabel = new Label(CurrencyUtil.formatMoney(transaction.getAmount(), transaction.getCurrency()));
 | 
			
		||||
        Label currencyLabel = new Label(CurrencyUtil.formatMoney(transaction.getMoneyAmount()));
 | 
			
		||||
        currencyLabel.setStyle("-fx-font-family: monospace;");
 | 
			
		||||
        HBox headerHBox = new HBox(
 | 
			
		||||
                currencyLabel
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -9,6 +9,8 @@ module com.andrewlalis.perfin {
 | 
			
		|||
 | 
			
		||||
    requires java.sql;
 | 
			
		||||
 | 
			
		||||
    requires org.slf4j;
 | 
			
		||||
 | 
			
		||||
    exports com.andrewlalis.perfin to javafx.graphics;
 | 
			
		||||
    exports com.andrewlalis.perfin.view to javafx.graphics;
 | 
			
		||||
    exports com.andrewlalis.perfin.model to javafx.graphics;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
		Reference in New Issue