Add Transaction Properties #15
			
				
			
		
		
		
	| 
						 | 
				
			
			@ -3,7 +3,9 @@ package com.andrewlalis.perfin;
 | 
			
		|||
import com.andrewlalis.javafx_scene_router.AnchorPaneRouterView;
 | 
			
		||||
import com.andrewlalis.javafx_scene_router.SceneRouter;
 | 
			
		||||
import com.andrewlalis.perfin.data.ProfileLoadException;
 | 
			
		||||
import com.andrewlalis.perfin.data.impl.JdbcDataSourceFactory;
 | 
			
		||||
import com.andrewlalis.perfin.model.Profile;
 | 
			
		||||
import com.andrewlalis.perfin.model.ProfileLoader;
 | 
			
		||||
import com.andrewlalis.perfin.view.ImageCache;
 | 
			
		||||
import com.andrewlalis.perfin.view.SceneUtil;
 | 
			
		||||
import com.andrewlalis.perfin.view.StartupSplashScreen;
 | 
			
		||||
| 
						 | 
				
			
			@ -29,6 +31,7 @@ public class PerfinApp extends Application {
 | 
			
		|||
    private static final Logger log = LoggerFactory.getLogger(PerfinApp.class);
 | 
			
		||||
    public static final Path APP_DIR = Path.of(System.getProperty("user.home", "."), ".perfin");
 | 
			
		||||
    public static PerfinApp instance;
 | 
			
		||||
    public static ProfileLoader profileLoader;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The router that's used for navigating between different "pages" in the application.
 | 
			
		||||
| 
						 | 
				
			
			@ -48,6 +51,7 @@ public class PerfinApp extends Application {
 | 
			
		|||
    @Override
 | 
			
		||||
    public void start(Stage stage) {
 | 
			
		||||
        instance = this;
 | 
			
		||||
        profileLoader = new ProfileLoader(stage, new JdbcDataSourceFactory());
 | 
			
		||||
        loadFonts();
 | 
			
		||||
        var splashScreen = new StartupSplashScreen(List.of(
 | 
			
		||||
                PerfinApp::defineRoutes,
 | 
			
		||||
| 
						 | 
				
			
			@ -112,9 +116,10 @@ public class PerfinApp extends Application {
 | 
			
		|||
    }
 | 
			
		||||
 | 
			
		||||
    private static void loadLastUsedProfile(Consumer<String> msgConsumer) throws Exception {
 | 
			
		||||
        msgConsumer.accept("Loading the most recent profile.");
 | 
			
		||||
        String lastProfile = ProfileLoader.getLastProfile();
 | 
			
		||||
        msgConsumer.accept("Loading the most recent profile: \"" + lastProfile + "\".");
 | 
			
		||||
        try {
 | 
			
		||||
            Profile.loadLast();
 | 
			
		||||
            Profile.setCurrent(profileLoader.load(lastProfile));
 | 
			
		||||
        } catch (ProfileLoadException e) {
 | 
			
		||||
            msgConsumer.accept("Failed to load the profile: " + e.getMessage());
 | 
			
		||||
            throw e;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -64,7 +64,7 @@ public class AccountViewController implements RouteSelectionListener {
 | 
			
		|||
        accountNumberLabel.setText(account.getAccountNumber());
 | 
			
		||||
        accountCurrencyLabel.setText(account.getCurrency().getDisplayName());
 | 
			
		||||
        accountCreatedAtLabel.setText(DateUtil.formatUTCAsLocalWithZone(account.getCreatedAt()));
 | 
			
		||||
        Profile.getCurrent().getDataSource().getAccountBalanceText(account)
 | 
			
		||||
        Profile.getCurrent().dataSource().getAccountBalanceText(account)
 | 
			
		||||
                .thenAccept(accountBalanceLabel::setText);
 | 
			
		||||
 | 
			
		||||
        reloadHistory();
 | 
			
		||||
| 
						 | 
				
			
			@ -96,7 +96,7 @@ public class AccountViewController implements RouteSelectionListener {
 | 
			
		|||
                        "later if you need to."
 | 
			
		||||
        );
 | 
			
		||||
        if (confirmResult) {
 | 
			
		||||
            Profile.getCurrent().getDataSource().useRepo(AccountRepository.class, repo -> repo.archive(account.id));
 | 
			
		||||
            Profile.getCurrent().dataSource().useRepo(AccountRepository.class, repo -> repo.archive(account.id));
 | 
			
		||||
            router.replace("accounts");
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
| 
						 | 
				
			
			@ -107,7 +107,7 @@ public class AccountViewController implements RouteSelectionListener {
 | 
			
		|||
                        "status?"
 | 
			
		||||
        );
 | 
			
		||||
        if (confirm) {
 | 
			
		||||
            Profile.getCurrent().getDataSource().useRepo(AccountRepository.class, repo -> repo.unarchive(account.id));
 | 
			
		||||
            Profile.getCurrent().dataSource().useRepo(AccountRepository.class, repo -> repo.unarchive(account.id));
 | 
			
		||||
            router.replace("accounts");
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
| 
						 | 
				
			
			@ -122,13 +122,13 @@ public class AccountViewController implements RouteSelectionListener {
 | 
			
		|||
                        "want to hide it."
 | 
			
		||||
        );
 | 
			
		||||
        if (confirm) {
 | 
			
		||||
            Profile.getCurrent().getDataSource().useRepo(AccountRepository.class, repo -> repo.delete(account));
 | 
			
		||||
            Profile.getCurrent().dataSource().useRepo(AccountRepository.class, repo -> repo.delete(account));
 | 
			
		||||
            router.replace("accounts");
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @FXML public void loadMoreHistory() {
 | 
			
		||||
        Profile.getCurrent().getDataSource().useRepoAsync(AccountHistoryItemRepository.class, repo -> {
 | 
			
		||||
        Profile.getCurrent().dataSource().useRepoAsync(AccountHistoryItemRepository.class, repo -> {
 | 
			
		||||
            List<AccountHistoryItem> historyItems = repo.findMostRecentForAccount(
 | 
			
		||||
                    account.id,
 | 
			
		||||
                    loadHistoryFrom,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -49,7 +49,7 @@ public class AccountsViewController implements RouteSelectionListener {
 | 
			
		|||
 | 
			
		||||
    public void refreshAccounts() {
 | 
			
		||||
        Profile.whenLoaded(profile -> {
 | 
			
		||||
            profile.getDataSource().useRepoAsync(AccountRepository.class, repo -> {
 | 
			
		||||
            profile.dataSource().useRepoAsync(AccountRepository.class, repo -> {
 | 
			
		||||
                List<Account> accounts = repo.findAllOrderedByRecentHistory();
 | 
			
		||||
                Platform.runLater(() -> accountsPane.getChildren()
 | 
			
		||||
                        .setAll(accounts.stream()
 | 
			
		||||
| 
						 | 
				
			
			@ -59,7 +59,7 @@ public class AccountsViewController implements RouteSelectionListener {
 | 
			
		|||
            });
 | 
			
		||||
            // Compute grand totals!
 | 
			
		||||
            Thread.ofVirtual().start(() -> {
 | 
			
		||||
                var totals = profile.getDataSource().getCombinedAccountBalances();
 | 
			
		||||
                var totals = profile.dataSource().getCombinedAccountBalances();
 | 
			
		||||
                StringBuilder sb = new StringBuilder("Totals: ");
 | 
			
		||||
                for (var entry : totals.entrySet()) {
 | 
			
		||||
                    sb.append(CurrencyUtil.formatMoneyWithCurrencyPrefix(new MoneyValue(entry.getValue(), entry.getKey())));
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -41,7 +41,7 @@ public class BalanceRecordViewController implements RouteSelectionListener {
 | 
			
		|||
        timestampLabel.setText(DateUtil.formatUTCAsLocalWithZone(balanceRecord.getTimestamp()));
 | 
			
		||||
        balanceLabel.setText(CurrencyUtil.formatMoney(balanceRecord.getMoneyAmount()));
 | 
			
		||||
        currencyLabel.setText(balanceRecord.getCurrency().getDisplayName());
 | 
			
		||||
        Profile.getCurrent().getDataSource().useRepoAsync(BalanceRecordRepository.class, repo -> {
 | 
			
		||||
        Profile.getCurrent().dataSource().useRepoAsync(BalanceRecordRepository.class, repo -> {
 | 
			
		||||
            List<Attachment> attachments = repo.findAttachments(balanceRecord.id);
 | 
			
		||||
            Platform.runLater(() -> attachmentsViewPane.setAttachments(attachments));
 | 
			
		||||
        });
 | 
			
		||||
| 
						 | 
				
			
			@ -50,7 +50,7 @@ public class BalanceRecordViewController implements RouteSelectionListener {
 | 
			
		|||
    @FXML public void delete() {
 | 
			
		||||
        boolean confirm = Popups.confirm("Are you sure you want to delete this balance record? This may have an effect on the derived balance of your account, as shown in Perfin.");
 | 
			
		||||
        if (confirm) {
 | 
			
		||||
            Profile.getCurrent().getDataSource().useRepo(BalanceRecordRepository.class, repo -> repo.deleteById(balanceRecord.id));
 | 
			
		||||
            Profile.getCurrent().dataSource().useRepo(BalanceRecordRepository.class, repo -> repo.deleteById(balanceRecord.id));
 | 
			
		||||
            router.navigateBackAndClear();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -60,7 +60,7 @@ public class CreateBalanceRecordController implements RouteSelectionListener {
 | 
			
		|||
                return;
 | 
			
		||||
            }
 | 
			
		||||
            BigDecimal reportedBalance = new BigDecimal(newValue);
 | 
			
		||||
            Profile.getCurrent().getDataSource().useRepoAsync(AccountRepository.class, repo -> {
 | 
			
		||||
            Profile.getCurrent().dataSource().useRepoAsync(AccountRepository.class, repo -> {
 | 
			
		||||
                BigDecimal derivedBalance = repo.deriveCurrentBalance(account.id);
 | 
			
		||||
                Platform.runLater(() -> balanceWarningLabel.visibleProperty().set(
 | 
			
		||||
                        !reportedBalance.setScale(derivedBalance.scale(), RoundingMode.HALF_UP).equals(derivedBalance)
 | 
			
		||||
| 
						 | 
				
			
			@ -76,7 +76,7 @@ public class CreateBalanceRecordController implements RouteSelectionListener {
 | 
			
		|||
    public void onRouteSelected(Object context) {
 | 
			
		||||
        this.account = (Account) context;
 | 
			
		||||
        timestampField.setText(LocalDateTime.now().format(DateUtil.DEFAULT_DATETIME_FORMAT));
 | 
			
		||||
        Profile.getCurrent().getDataSource().useRepoAsync(AccountRepository.class, repo -> {
 | 
			
		||||
        Profile.getCurrent().dataSource().useRepoAsync(AccountRepository.class, repo -> {
 | 
			
		||||
            BigDecimal value = repo.deriveCurrentBalance(account.id);
 | 
			
		||||
            Platform.runLater(() -> balanceField.setText(
 | 
			
		||||
                    CurrencyUtil.formatMoneyAsBasicNumber(new MoneyValue(value, account.getCurrency()))
 | 
			
		||||
| 
						 | 
				
			
			@ -95,7 +95,7 @@ public class CreateBalanceRecordController implements RouteSelectionListener {
 | 
			
		|||
                localTimestamp.atZone(ZoneId.systemDefault()).format(DateUtil.DEFAULT_DATETIME_FORMAT_WITH_ZONE)
 | 
			
		||||
        ));
 | 
			
		||||
        if (confirm && confirmIfInconsistentBalance(reportedBalance)) {
 | 
			
		||||
            Profile.getCurrent().getDataSource().useRepo(BalanceRecordRepository.class, repo -> {
 | 
			
		||||
            Profile.getCurrent().dataSource().useRepo(BalanceRecordRepository.class, repo -> {
 | 
			
		||||
                repo.insert(
 | 
			
		||||
                        DateUtil.localToUTC(localTimestamp),
 | 
			
		||||
                        account.id,
 | 
			
		||||
| 
						 | 
				
			
			@ -113,7 +113,7 @@ public class CreateBalanceRecordController implements RouteSelectionListener {
 | 
			
		|||
    }
 | 
			
		||||
 | 
			
		||||
    private boolean confirmIfInconsistentBalance(BigDecimal reportedBalance) {
 | 
			
		||||
        BigDecimal currentDerivedBalance = Profile.getCurrent().getDataSource().mapRepo(
 | 
			
		||||
        BigDecimal currentDerivedBalance = Profile.getCurrent().dataSource().mapRepo(
 | 
			
		||||
                AccountRepository.class,
 | 
			
		||||
                repo -> repo.deriveCurrentBalance(account.id)
 | 
			
		||||
        );
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -106,8 +106,8 @@ public class EditAccountController implements RouteSelectionListener {
 | 
			
		|||
    @FXML
 | 
			
		||||
    public void save() {
 | 
			
		||||
        try (
 | 
			
		||||
                var accountRepo = Profile.getCurrent().getDataSource().getAccountRepository();
 | 
			
		||||
                var balanceRepo = Profile.getCurrent().getDataSource().getBalanceRecordRepository()
 | 
			
		||||
                var accountRepo = Profile.getCurrent().dataSource().getAccountRepository();
 | 
			
		||||
                var balanceRepo = Profile.getCurrent().dataSource().getBalanceRecordRepository()
 | 
			
		||||
        ) {
 | 
			
		||||
            if (creatingNewAccount.get()) {
 | 
			
		||||
                String name = accountNameField.getText().strip();
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -111,7 +111,7 @@ public class EditTransactionController implements RouteSelectionListener {
 | 
			
		|||
        List<Attachment> existingAttachments = attachmentsSelectionArea.getSelectedAttachments();
 | 
			
		||||
        final long idToNavigate;
 | 
			
		||||
        if (transaction == null) {
 | 
			
		||||
            idToNavigate = Profile.getCurrent().getDataSource().mapRepo(
 | 
			
		||||
            idToNavigate = Profile.getCurrent().dataSource().mapRepo(
 | 
			
		||||
                TransactionRepository.class,
 | 
			
		||||
                repo -> repo.insert(
 | 
			
		||||
                    utcTimestamp,
 | 
			
		||||
| 
						 | 
				
			
			@ -123,7 +123,7 @@ public class EditTransactionController implements RouteSelectionListener {
 | 
			
		|||
                )
 | 
			
		||||
            );
 | 
			
		||||
        } else {
 | 
			
		||||
            Profile.getCurrent().getDataSource().useRepo(
 | 
			
		||||
            Profile.getCurrent().dataSource().useRepo(
 | 
			
		||||
                TransactionRepository.class,
 | 
			
		||||
                repo -> repo.update(
 | 
			
		||||
                        transaction.id,
 | 
			
		||||
| 
						 | 
				
			
			@ -165,8 +165,8 @@ public class EditTransactionController implements RouteSelectionListener {
 | 
			
		|||
        container.setDisable(true);
 | 
			
		||||
        Thread.ofVirtual().start(() -> {
 | 
			
		||||
            try (
 | 
			
		||||
                    var accountRepo = Profile.getCurrent().getDataSource().getAccountRepository();
 | 
			
		||||
                    var transactionRepo = Profile.getCurrent().getDataSource().getTransactionRepository()
 | 
			
		||||
                    var accountRepo = Profile.getCurrent().dataSource().getAccountRepository();
 | 
			
		||||
                    var transactionRepo = Profile.getCurrent().dataSource().getTransactionRepository()
 | 
			
		||||
            ) {
 | 
			
		||||
                // First fetch all the data.
 | 
			
		||||
                List<Currency> currencies = accountRepo.findAllUsedCurrencies().stream()
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -4,6 +4,7 @@ import com.andrewlalis.perfin.PerfinApp;
 | 
			
		|||
import com.andrewlalis.perfin.data.ProfileLoadException;
 | 
			
		||||
import com.andrewlalis.perfin.data.util.FileUtil;
 | 
			
		||||
import com.andrewlalis.perfin.model.Profile;
 | 
			
		||||
import com.andrewlalis.perfin.model.ProfileLoader;
 | 
			
		||||
import com.andrewlalis.perfin.view.ProfilesStage;
 | 
			
		||||
import com.andrewlalis.perfin.view.component.validation.ValidationApplier;
 | 
			
		||||
import com.andrewlalis.perfin.view.component.validation.validators.PredicateValidator;
 | 
			
		||||
| 
						 | 
				
			
			@ -44,7 +45,7 @@ public class ProfilesViewController {
 | 
			
		|||
    @FXML public void addProfile() {
 | 
			
		||||
        String name = newProfileNameField.getText();
 | 
			
		||||
        boolean valid = Profile.validateName(name);
 | 
			
		||||
        if (valid && !Profile.getAvailableProfiles().contains(name)) {
 | 
			
		||||
        if (valid && !ProfileLoader.getAvailableProfiles().contains(name)) {
 | 
			
		||||
            boolean confirm = Popups.confirm("Are you sure you want to add a new profile named \"" + name + "\"?");
 | 
			
		||||
            if (confirm) {
 | 
			
		||||
                if (openProfile(name, false)) {
 | 
			
		||||
| 
						 | 
				
			
			@ -56,8 +57,8 @@ public class ProfilesViewController {
 | 
			
		|||
    }
 | 
			
		||||
 | 
			
		||||
    private void refreshAvailableProfiles() {
 | 
			
		||||
        List<String> profileNames = Profile.getAvailableProfiles();
 | 
			
		||||
        String currentProfile = Profile.getCurrent() == null ? null : Profile.getCurrent().getName();
 | 
			
		||||
        List<String> profileNames = ProfileLoader.getAvailableProfiles();
 | 
			
		||||
        String currentProfile = Profile.getCurrent() == null ? null : Profile.getCurrent().name();
 | 
			
		||||
        List<Node> nodes = new ArrayList<>(profileNames.size());
 | 
			
		||||
        for (String profileName : profileNames) {
 | 
			
		||||
            boolean isCurrent = profileName.equals(currentProfile);
 | 
			
		||||
| 
						 | 
				
			
			@ -104,7 +105,7 @@ public class ProfilesViewController {
 | 
			
		|||
    private boolean openProfile(String name, boolean showPopup) {
 | 
			
		||||
        log.info("Opening profile \"{}\".", name);
 | 
			
		||||
        try {
 | 
			
		||||
            Profile.load(name);
 | 
			
		||||
            PerfinApp.profileLoader.load(name);
 | 
			
		||||
            ProfilesStage.closeView();
 | 
			
		||||
            router.replace("accounts");
 | 
			
		||||
            if (showPopup) Popups.message("The profile \"" + name + "\" has been loaded.");
 | 
			
		||||
| 
						 | 
				
			
			@ -123,11 +124,11 @@ public class ProfilesViewController {
 | 
			
		|||
                try {
 | 
			
		||||
                    FileUtil.deleteDirRecursive(Profile.getDir(name));
 | 
			
		||||
                    // Reset the app's "last profile" to the default if it was the deleted profile.
 | 
			
		||||
                    if (Profile.getLastProfile().equals(name)) {
 | 
			
		||||
                        Profile.saveLastProfile("default");
 | 
			
		||||
                    if (ProfileLoader.getLastProfile().equals(name)) {
 | 
			
		||||
                        ProfileLoader.saveLastProfile("default");
 | 
			
		||||
                    }
 | 
			
		||||
                    // If the current profile was deleted, switch to the default.
 | 
			
		||||
                    if (Profile.getCurrent() != null && Profile.getCurrent().getName().equals(name)) {
 | 
			
		||||
                    if (Profile.getCurrent() != null && Profile.getCurrent().name().equals(name)) {
 | 
			
		||||
                        openProfile("default", true);
 | 
			
		||||
                    }
 | 
			
		||||
                    refreshAvailableProfiles();
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -45,7 +45,7 @@ public class TransactionViewController {
 | 
			
		|||
        amountLabel.setText(CurrencyUtil.formatMoney(transaction.getMoneyAmount()));
 | 
			
		||||
        timestampLabel.setText(DateUtil.formatUTCAsLocalWithZone(transaction.getTimestamp()));
 | 
			
		||||
        descriptionLabel.setText(transaction.getDescription());
 | 
			
		||||
        Profile.getCurrent().getDataSource().useRepoAsync(TransactionRepository.class, repo -> {
 | 
			
		||||
        Profile.getCurrent().dataSource().useRepoAsync(TransactionRepository.class, repo -> {
 | 
			
		||||
            CreditAndDebitAccounts accounts = repo.findLinkedAccounts(transaction.id);
 | 
			
		||||
            List<Attachment> attachments = repo.findAttachments(transaction.id);
 | 
			
		||||
            Platform.runLater(() -> {
 | 
			
		||||
| 
						 | 
				
			
			@ -81,7 +81,7 @@ public class TransactionViewController {
 | 
			
		|||
            "it's derived from the most recent balance-record, and transactions."
 | 
			
		||||
        );
 | 
			
		||||
        if (confirm) {
 | 
			
		||||
            Profile.getCurrent().getDataSource().useRepo(TransactionRepository.class, repo -> repo.delete(transaction.id));
 | 
			
		||||
            Profile.getCurrent().dataSource().useRepo(TransactionRepository.class, repo -> repo.delete(transaction.id));
 | 
			
		||||
            router.replace("transactions");
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -66,7 +66,7 @@ public class TransactionsViewController implements RouteSelectionListener {
 | 
			
		|||
                    @Override
 | 
			
		||||
                    public Page<? extends Node> fetchPage(PageRequest pagination) throws Exception {
 | 
			
		||||
                        Account accountFilter = filterByAccountComboBox.getValue();
 | 
			
		||||
                        try (var repo = Profile.getCurrent().getDataSource().getTransactionRepository()) {
 | 
			
		||||
                        try (var repo = Profile.getCurrent().dataSource().getTransactionRepository()) {
 | 
			
		||||
                            Page<Transaction> result;
 | 
			
		||||
                            if (accountFilter == null) {
 | 
			
		||||
                                result = repo.findAll(pagination);
 | 
			
		||||
| 
						 | 
				
			
			@ -80,7 +80,7 @@ public class TransactionsViewController implements RouteSelectionListener {
 | 
			
		|||
                    @Override
 | 
			
		||||
                    public int getTotalCount() throws Exception {
 | 
			
		||||
                        Account accountFilter = filterByAccountComboBox.getValue();
 | 
			
		||||
                        try (var repo = Profile.getCurrent().getDataSource().getTransactionRepository()) {
 | 
			
		||||
                        try (var repo = Profile.getCurrent().dataSource().getTransactionRepository()) {
 | 
			
		||||
                            if (accountFilter == null) {
 | 
			
		||||
                                return (int) repo.countAll();
 | 
			
		||||
                            } else {
 | 
			
		||||
| 
						 | 
				
			
			@ -124,7 +124,7 @@ public class TransactionsViewController implements RouteSelectionListener {
 | 
			
		|||
        transactionsVBox.getChildren().clear(); // Clear the transactions before reload initially.
 | 
			
		||||
 | 
			
		||||
        // Refresh account filter options.
 | 
			
		||||
        Profile.getCurrent().getDataSource().useRepoAsync(AccountRepository.class, repo -> {
 | 
			
		||||
        Profile.getCurrent().dataSource().useRepoAsync(AccountRepository.class, repo -> {
 | 
			
		||||
            List<Account> accounts = repo.findAll(PageRequest.unpaged(Sort.asc("name"))).items();
 | 
			
		||||
            Platform.runLater(() -> {
 | 
			
		||||
                filterByAccountComboBox.setAccounts(accounts);
 | 
			
		||||
| 
						 | 
				
			
			@ -135,7 +135,7 @@ public class TransactionsViewController implements RouteSelectionListener {
 | 
			
		|||
 | 
			
		||||
        // If a transaction id is given in the route context, navigate to the page it's on and select it.
 | 
			
		||||
        if (context instanceof RouteContext ctx && ctx.selectedTransactionId != null) {
 | 
			
		||||
            Profile.getCurrent().getDataSource().useRepoAsync(TransactionRepository.class, repo -> {
 | 
			
		||||
            Profile.getCurrent().dataSource().useRepoAsync(TransactionRepository.class, repo -> {
 | 
			
		||||
                repo.findById(ctx.selectedTransactionId).ifPresent(tx -> {
 | 
			
		||||
                    long offset = repo.countAllAfter(tx.id);
 | 
			
		||||
                    int pageNumber = (int) (offset / paginationControls.getItemsPerPage()) + 1;
 | 
			
		||||
| 
						 | 
				
			
			@ -161,7 +161,7 @@ public class TransactionsViewController implements RouteSelectionListener {
 | 
			
		|||
        File file = fileChooser.showSaveDialog(detailPanel.getScene().getWindow());
 | 
			
		||||
        if (file != null) {
 | 
			
		||||
            try (
 | 
			
		||||
                    var repo = Profile.getCurrent().getDataSource().getTransactionRepository();
 | 
			
		||||
                    var repo = Profile.getCurrent().dataSource().getTransactionRepository();
 | 
			
		||||
                    var out = new PrintWriter(file, StandardCharsets.UTF_8)
 | 
			
		||||
            ) {
 | 
			
		||||
                out.println("id,utc-timestamp,amount,currency,description");
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,19 @@
 | 
			
		|||
package com.andrewlalis.perfin.data;
 | 
			
		||||
 | 
			
		||||
import java.io.IOException;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Interface that defines the data source factory, a component responsible for
 | 
			
		||||
 * obtaining a data source, and performing some introspection around that data
 | 
			
		||||
 * source before one is obtained.
 | 
			
		||||
 */
 | 
			
		||||
public interface DataSourceFactory {
 | 
			
		||||
    DataSource getDataSource(String profileName) throws ProfileLoadException;
 | 
			
		||||
 | 
			
		||||
    enum SchemaStatus {
 | 
			
		||||
        UP_TO_DATE,
 | 
			
		||||
        NEEDS_MIGRATION,
 | 
			
		||||
        INCOMPATIBLE
 | 
			
		||||
    }
 | 
			
		||||
    SchemaStatus getSchemaStatus(String profileName) throws IOException;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,6 +1,7 @@
 | 
			
		|||
package com.andrewlalis.perfin.data.impl;
 | 
			
		||||
 | 
			
		||||
import com.andrewlalis.perfin.data.DataSource;
 | 
			
		||||
import com.andrewlalis.perfin.data.DataSourceFactory;
 | 
			
		||||
import com.andrewlalis.perfin.data.ProfileLoadException;
 | 
			
		||||
import com.andrewlalis.perfin.data.impl.migration.Migration;
 | 
			
		||||
import com.andrewlalis.perfin.data.impl.migration.Migrations;
 | 
			
		||||
| 
						 | 
				
			
			@ -23,7 +24,7 @@ import java.util.List;
 | 
			
		|||
/**
 | 
			
		||||
 * Component that's responsible for obtaining a JDBC data source for a profile.
 | 
			
		||||
 */
 | 
			
		||||
public class JdbcDataSourceFactory {
 | 
			
		||||
public class JdbcDataSourceFactory implements DataSourceFactory {
 | 
			
		||||
    private static final Logger log = LoggerFactory.getLogger(JdbcDataSourceFactory.class);
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
| 
						 | 
				
			
			@ -59,6 +60,13 @@ public class JdbcDataSourceFactory {
 | 
			
		|||
        return new JdbcDataSource(getJdbcUrl(profileName), Profile.getContentDir(profileName));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public SchemaStatus getSchemaStatus(String profileName) throws IOException {
 | 
			
		||||
        int existingSchemaVersion = getSchemaVersion(profileName);
 | 
			
		||||
        if (existingSchemaVersion == SCHEMA_VERSION) return SchemaStatus.UP_TO_DATE;
 | 
			
		||||
        if (existingSchemaVersion < SCHEMA_VERSION) return SchemaStatus.NEEDS_MIGRATION;
 | 
			
		||||
        return SchemaStatus.INCOMPATIBLE;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private void createNewDatabase(String profileName) throws ProfileLoadException {
 | 
			
		||||
        log.info("Creating new database for profile {}.", profileName);
 | 
			
		||||
        JdbcDataSource dataSource = new JdbcDataSource(getJdbcUrl(profileName), Profile.getContentDir(profileName));
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -2,23 +2,16 @@ package com.andrewlalis.perfin.model;
 | 
			
		|||
 | 
			
		||||
import com.andrewlalis.perfin.PerfinApp;
 | 
			
		||||
import com.andrewlalis.perfin.data.DataSource;
 | 
			
		||||
import com.andrewlalis.perfin.data.ProfileLoadException;
 | 
			
		||||
import com.andrewlalis.perfin.data.impl.JdbcDataSourceFactory;
 | 
			
		||||
import com.andrewlalis.perfin.data.util.FileUtil;
 | 
			
		||||
import org.slf4j.Logger;
 | 
			
		||||
import org.slf4j.LoggerFactory;
 | 
			
		||||
 | 
			
		||||
import java.io.IOException;
 | 
			
		||||
import java.nio.file.Files;
 | 
			
		||||
import java.lang.ref.WeakReference;
 | 
			
		||||
import java.nio.file.Path;
 | 
			
		||||
import java.util.ArrayList;
 | 
			
		||||
import java.util.Collections;
 | 
			
		||||
import java.util.List;
 | 
			
		||||
import java.util.HashSet;
 | 
			
		||||
import java.util.Properties;
 | 
			
		||||
import java.util.Set;
 | 
			
		||||
import java.util.function.Consumer;
 | 
			
		||||
 | 
			
		||||
import static com.andrewlalis.perfin.data.util.FileUtil.copyResourceFile;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * A profile is essentially a complete set of data that the application can
 | 
			
		||||
 * operate on, sort of like a save file or user account. The profile contains
 | 
			
		||||
| 
						 | 
				
			
			@ -36,34 +29,17 @@ import static com.andrewlalis.perfin.data.util.FileUtil.copyResourceFile;
 | 
			
		|||
 *     unloaded.
 | 
			
		||||
 * </p>
 | 
			
		||||
 */
 | 
			
		||||
public class Profile {
 | 
			
		||||
public record Profile(String name, Properties settings, DataSource dataSource) {
 | 
			
		||||
    private static final Logger log = LoggerFactory.getLogger(Profile.class);
 | 
			
		||||
 | 
			
		||||
    private static Profile current;
 | 
			
		||||
    private static final List<Consumer<Profile>> profileLoadListeners = new ArrayList<>();
 | 
			
		||||
    private static final Set<WeakReference<Consumer<Profile>>> currentProfileListeners = new HashSet<>();
 | 
			
		||||
 | 
			
		||||
    private final String name;
 | 
			
		||||
    private final Properties settings;
 | 
			
		||||
    private final DataSource dataSource;
 | 
			
		||||
 | 
			
		||||
    private Profile(String name, Properties settings, DataSource dataSource) {
 | 
			
		||||
        this.name = name;
 | 
			
		||||
        this.settings = settings;
 | 
			
		||||
        this.dataSource = dataSource;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public String getName() {
 | 
			
		||||
    @Override
 | 
			
		||||
    public String toString() {
 | 
			
		||||
        return name;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public Properties getSettings() {
 | 
			
		||||
        return settings;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public DataSource getDataSource() {
 | 
			
		||||
        return dataSource;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static Path getDir(String name) {
 | 
			
		||||
        return PerfinApp.APP_DIR.resolve(name);
 | 
			
		||||
    }
 | 
			
		||||
| 
						 | 
				
			
			@ -80,79 +56,22 @@ public class Profile {
 | 
			
		|||
        return current;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static void setCurrent(Profile profile) {
 | 
			
		||||
        current = profile;
 | 
			
		||||
        for (var ref : currentProfileListeners) {
 | 
			
		||||
            Consumer<Profile> consumer = ref.get();
 | 
			
		||||
            if (consumer != null) {
 | 
			
		||||
                consumer.accept(profile);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        currentProfileListeners.removeIf(ref -> ref.get() == null);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static void whenLoaded(Consumer<Profile> consumer) {
 | 
			
		||||
        if (current != null) {
 | 
			
		||||
            consumer.accept(current);
 | 
			
		||||
        } else {
 | 
			
		||||
            profileLoadListeners.add(consumer);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static List<String> getAvailableProfiles() {
 | 
			
		||||
        try (var files = Files.list(PerfinApp.APP_DIR)) {
 | 
			
		||||
            return files.filter(Files::isDirectory)
 | 
			
		||||
                    .map(path -> path.getFileName().toString())
 | 
			
		||||
                    .sorted().toList();
 | 
			
		||||
        } catch (IOException e) {
 | 
			
		||||
            log.error("Failed to get a list of available profiles.", e);
 | 
			
		||||
            return Collections.emptyList();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static String getLastProfile() {
 | 
			
		||||
        Path lastProfileFile = PerfinApp.APP_DIR.resolve("last-profile.txt");
 | 
			
		||||
        if (Files.exists(lastProfileFile)) {
 | 
			
		||||
            try {
 | 
			
		||||
                String s = Files.readString(lastProfileFile).strip().toLowerCase();
 | 
			
		||||
                if (!s.isBlank()) return s;
 | 
			
		||||
            } catch (IOException e) {
 | 
			
		||||
                log.error("Failed to read " + lastProfileFile, e);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        return "default";
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static void saveLastProfile(String name) {
 | 
			
		||||
        Path lastProfileFile = PerfinApp.APP_DIR.resolve("last-profile.txt");
 | 
			
		||||
        try {
 | 
			
		||||
            Files.writeString(lastProfileFile, name);
 | 
			
		||||
        } catch (IOException e) {
 | 
			
		||||
            log.error("Failed to write " + lastProfileFile, e);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static void loadLast() throws ProfileLoadException {
 | 
			
		||||
        load(getLastProfile());
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static void load(String name) throws ProfileLoadException {
 | 
			
		||||
        if (Files.notExists(getDir(name))) {
 | 
			
		||||
            try {
 | 
			
		||||
                initProfileDir(name);
 | 
			
		||||
            } catch (IOException e) {
 | 
			
		||||
                FileUtil.deleteIfPossible(getDir(name));
 | 
			
		||||
                throw new ProfileLoadException("Failed to initialize new profile directory.", e);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        Properties settings = new Properties();
 | 
			
		||||
        try (var in = Files.newInputStream(getSettingsFile(name))) {
 | 
			
		||||
            settings.load(in);
 | 
			
		||||
        } catch (IOException e) {
 | 
			
		||||
            throw new ProfileLoadException("Failed to load profile settings.", e);
 | 
			
		||||
        }
 | 
			
		||||
        current = new Profile(name, settings, new JdbcDataSourceFactory().getDataSource(name));
 | 
			
		||||
        saveLastProfile(current.getName());
 | 
			
		||||
        for (var c : profileLoadListeners) {
 | 
			
		||||
            c.accept(current);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static void initProfileDir(String name) throws IOException {
 | 
			
		||||
        Files.createDirectory(getDir(name));
 | 
			
		||||
        copyResourceFile("/text/profileDirReadme.txt", getDir(name).resolve("README.txt"));
 | 
			
		||||
        copyResourceFile("/text/defaultProfileSettings.properties", getSettingsFile(name));
 | 
			
		||||
        Files.createDirectory(getContentDir(name));
 | 
			
		||||
        copyResourceFile("/text/contentDirReadme.txt", getContentDir(name).resolve("README.txt"));
 | 
			
		||||
        currentProfileListeners.add(new WeakReference<>(consumer));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static boolean validateName(String name) {
 | 
			
		||||
| 
						 | 
				
			
			@ -160,9 +79,4 @@ public class Profile {
 | 
			
		|||
                name.matches("\\w+") &&
 | 
			
		||||
                name.toLowerCase().equals(name);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public String toString() {
 | 
			
		||||
        return name;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,90 @@
 | 
			
		|||
package com.andrewlalis.perfin.model;
 | 
			
		||||
 | 
			
		||||
import com.andrewlalis.perfin.PerfinApp;
 | 
			
		||||
import com.andrewlalis.perfin.data.DataSourceFactory;
 | 
			
		||||
import com.andrewlalis.perfin.data.ProfileLoadException;
 | 
			
		||||
import com.andrewlalis.perfin.data.util.FileUtil;
 | 
			
		||||
import javafx.stage.Window;
 | 
			
		||||
import org.slf4j.Logger;
 | 
			
		||||
import org.slf4j.LoggerFactory;
 | 
			
		||||
 | 
			
		||||
import java.io.IOException;
 | 
			
		||||
import java.nio.file.Files;
 | 
			
		||||
import java.nio.file.Path;
 | 
			
		||||
import java.util.Collections;
 | 
			
		||||
import java.util.List;
 | 
			
		||||
import java.util.Properties;
 | 
			
		||||
 | 
			
		||||
import static com.andrewlalis.perfin.data.util.FileUtil.copyResourceFile;
 | 
			
		||||
 | 
			
		||||
public class ProfileLoader {
 | 
			
		||||
    private static final Logger log = LoggerFactory.getLogger(ProfileLoader.class);
 | 
			
		||||
 | 
			
		||||
    private final Window window;
 | 
			
		||||
    private final DataSourceFactory dataSourceFactory;
 | 
			
		||||
 | 
			
		||||
    public ProfileLoader(Window window, DataSourceFactory dataSourceFactory) {
 | 
			
		||||
        this.window = window;
 | 
			
		||||
        this.dataSourceFactory = dataSourceFactory;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public Profile load(String name) throws ProfileLoadException {
 | 
			
		||||
        if (Files.notExists(Profile.getDir(name))) {
 | 
			
		||||
            try {
 | 
			
		||||
                initProfileDir(name);
 | 
			
		||||
            } catch (IOException e) {
 | 
			
		||||
                FileUtil.deleteIfPossible(Profile.getDir(name));
 | 
			
		||||
                throw new ProfileLoadException("Failed to initialize new profile directory.", e);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        Properties settings = new Properties();
 | 
			
		||||
        try (var in = Files.newInputStream(Profile.getSettingsFile(name))) {
 | 
			
		||||
            settings.load(in);
 | 
			
		||||
        } catch (IOException e) {
 | 
			
		||||
            throw new ProfileLoadException("Failed to load profile settings.", e);
 | 
			
		||||
        }
 | 
			
		||||
        return new Profile(name, settings, dataSourceFactory.getDataSource(name));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static List<String> getAvailableProfiles() {
 | 
			
		||||
        try (var files = Files.list(PerfinApp.APP_DIR)) {
 | 
			
		||||
            return files.filter(Files::isDirectory)
 | 
			
		||||
                    .map(path -> path.getFileName().toString())
 | 
			
		||||
                    .sorted().toList();
 | 
			
		||||
        } catch (IOException e) {
 | 
			
		||||
            log.error("Failed to get a list of available profiles.", e);
 | 
			
		||||
            return Collections.emptyList();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static String getLastProfile() {
 | 
			
		||||
        Path lastProfileFile = PerfinApp.APP_DIR.resolve("last-profile.txt");
 | 
			
		||||
        if (Files.exists(lastProfileFile)) {
 | 
			
		||||
            try {
 | 
			
		||||
                String s = Files.readString(lastProfileFile).strip().toLowerCase();
 | 
			
		||||
                if (!s.isBlank()) return s;
 | 
			
		||||
            } catch (IOException e) {
 | 
			
		||||
                log.error("Failed to read " + lastProfileFile, e);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        return "default";
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static void saveLastProfile(String name) {
 | 
			
		||||
        Path lastProfileFile = PerfinApp.APP_DIR.resolve("last-profile.txt");
 | 
			
		||||
        try {
 | 
			
		||||
            Files.writeString(lastProfileFile, name);
 | 
			
		||||
        } catch (IOException e) {
 | 
			
		||||
            log.error("Failed to write " + lastProfileFile, e);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Deprecated
 | 
			
		||||
    private static void initProfileDir(String name) throws IOException {
 | 
			
		||||
        Files.createDirectory(Profile.getDir(name));
 | 
			
		||||
        copyResourceFile("/text/profileDirReadme.txt", Profile.getDir(name).resolve("README.txt"));
 | 
			
		||||
        copyResourceFile("/text/defaultProfileSettings.properties", Profile.getSettingsFile(name));
 | 
			
		||||
        Files.createDirectory(Profile.getContentDir(name));
 | 
			
		||||
        copyResourceFile("/text/contentDirReadme.txt", Profile.getContentDir(name).resolve("README.txt"));
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -110,7 +110,7 @@ public class AccountSelectionBox extends ComboBox<Account> {
 | 
			
		|||
 | 
			
		||||
            nameLabel.setText(item.getName() + " (" + item.getAccountNumberSuffix() + ")");
 | 
			
		||||
            if (showBalanceProp.get()) {
 | 
			
		||||
                Profile.getCurrent().getDataSource().useRepoAsync(AccountRepository.class, repo -> {
 | 
			
		||||
                Profile.getCurrent().dataSource().useRepoAsync(AccountRepository.class, repo -> {
 | 
			
		||||
                    BigDecimal balance = repo.deriveCurrentBalance(item.id);
 | 
			
		||||
                    Platform.runLater(() -> {
 | 
			
		||||
                        balanceLabel.setText(CurrencyUtil.formatMoney(new MoneyValue(balance, item.getCurrency())));
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -81,7 +81,7 @@ public class AccountTile extends BorderPane {
 | 
			
		|||
        Label balanceLabel = new Label("Computing balance...");
 | 
			
		||||
        balanceLabel.getStyleClass().addAll("mono-font");
 | 
			
		||||
        balanceLabel.setDisable(true);
 | 
			
		||||
        Profile.getCurrent().getDataSource().useRepoAsync(AccountRepository.class, repo -> {
 | 
			
		||||
        Profile.getCurrent().dataSource().useRepoAsync(AccountRepository.class, repo -> {
 | 
			
		||||
            BigDecimal balance = repo.deriveCurrentBalance(account.id);
 | 
			
		||||
            String text = CurrencyUtil.formatMoney(new MoneyValue(balance, account.getCurrency()));
 | 
			
		||||
            Platform.runLater(() -> {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -46,7 +46,7 @@ public class AttachmentPreview extends BorderPane {
 | 
			
		|||
        boolean showDocIcon = true;
 | 
			
		||||
        Set<String> imageTypes = Set.of("image/png", "image/jpeg", "image/gif", "image/bmp");
 | 
			
		||||
        if (imageTypes.contains(attachment.getContentType())) {
 | 
			
		||||
            try (var in = Files.newInputStream(attachment.getPath(Profile.getContentDir(Profile.getCurrent().getName())))) {
 | 
			
		||||
            try (var in = Files.newInputStream(attachment.getPath(Profile.getContentDir(Profile.getCurrent().name())))) {
 | 
			
		||||
                Image img = new Image(in, IMAGE_SIZE, IMAGE_SIZE, true, true);
 | 
			
		||||
                contentContainer.setCenter(new ImageView(img));
 | 
			
		||||
                showDocIcon = false;
 | 
			
		||||
| 
						 | 
				
			
			@ -69,7 +69,7 @@ public class AttachmentPreview extends BorderPane {
 | 
			
		|||
        this.setCenter(stackPane);
 | 
			
		||||
        this.setOnMouseClicked(event -> {
 | 
			
		||||
            if (this.isHover()) {
 | 
			
		||||
                Path filePath = attachment.getPath(Profile.getContentDir(Profile.getCurrent().getName()));
 | 
			
		||||
                Path filePath = attachment.getPath(Profile.getContentDir(Profile.getCurrent().name()));
 | 
			
		||||
                PerfinApp.instance.getHostServices().showDocument(filePath.toAbsolutePath().toUri().toString());
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -100,7 +100,7 @@ public class TransactionTile extends BorderPane {
 | 
			
		|||
    }
 | 
			
		||||
 | 
			
		||||
    private CompletableFuture<CreditAndDebitAccounts> getCreditAndDebitAccounts(Transaction transaction) {
 | 
			
		||||
        return Profile.getCurrent().getDataSource().mapRepoAsync(
 | 
			
		||||
        return Profile.getCurrent().dataSource().mapRepoAsync(
 | 
			
		||||
                TransactionRepository.class,
 | 
			
		||||
                repo -> repo.findLinkedAccounts(transaction.id)
 | 
			
		||||
        );
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
		Reference in New Issue