diff --git a/README.md b/README.md index 94c8a50..f3c6b63 100644 --- a/README.md +++ b/README.md @@ -37,3 +37,30 @@ to set the version everywhere that it needs to be. Once that's done, the workflow will start, and you should see a release appear in the next few minutes. + +## Migration Procedure + +Because this application relies on a structured relational database schema, +changes to the schema must be handled with care to avoid destroying users' data. +Specifically, when changes are made to the schema, a *migration* must be defined +which provides instructions for Perfin to safely apply changes to an old schema. + +The database schema is versioned using whole-number versions (1, 2, 3, ...), and +a migration is defined for each transition from version to version, such that +any older version can be incrementally upgraded, step by step, to the latest +schema version. + +Perfin only supports the latest schema version, as defined by `JdbcDataSourceFactory.SCHEMA_VERSION`. +When the app loads a profile, it'll check that profile's schema version by +reading a `.jdbc-schema-version.txt` file in the profile's main directory. If +the profile's schema version is **less than** the current, Perfin will +ask the user if they want to upgrade. If the profile's schema version is +**greater than** the current, Perfin will tell the user that it can't load a +schema from a newer version, and will prompt the user to upgrade. + +### Writing a Migration + +1. Write your migration. This can be plain SQL (placed in `resources/sql/migration`), or Java code. +2. Add your migration to `com.andrewlalis.perfin.data.impl.migration.Migrations#getMigrations()`. +3. Increment the schema version defined in `JdbcDataSourceFactory`. +4. Test the migration yourself on a profile with data. diff --git a/pom.xml b/pom.xml index f7af29b..025ecdb 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.andrewlalis perfin - 1.4.0 + 1.5.0 21 diff --git a/scripts/package-linux-deb.sh b/scripts/package-linux-deb.sh index 61ec33e..df98726 100755 --- a/scripts/package-linux-deb.sh +++ b/scripts/package-linux-deb.sh @@ -24,7 +24,7 @@ module_path="$module_path:target/modules/h2-2.2.224.jar" jpackage \ --name "Perfin" \ - --app-version "1.4.0" \ + --app-version "1.5.0" \ --description "Desktop application for personal finance. Add your accounts, track transactions, and store receipts, invoices, and more." \ --icon design/perfin-logo_256.png \ --vendor "Andrew Lalis" \ diff --git a/scripts/package-windows-msi.ps1 b/scripts/package-windows-msi.ps1 index a51d31e..8a30060 100644 --- a/scripts/package-windows-msi.ps1 +++ b/scripts/package-windows-msi.ps1 @@ -12,7 +12,7 @@ $modulePath = "$modulePath;target\modules\h2-2.2.224.jar" jpackage ` --name "Perfin" ` - --app-version "1.4.0" ` + --app-version "1.5.0" ` --description "Desktop application for personal finance. Add your accounts, track transactions, and store receipts, invoices, and more." ` --icon design\perfin-logo_256.ico ` --vendor "Andrew Lalis" ` diff --git a/src/main/java/com/andrewlalis/perfin/PerfinApp.java b/src/main/java/com/andrewlalis/perfin/PerfinApp.java index 22b0fa6..0ec8083 100644 --- a/src/main/java/com/andrewlalis/perfin/PerfinApp.java +++ b/src/main/java/com/andrewlalis/perfin/PerfinApp.java @@ -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,13 +51,14 @@ 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, PerfinApp::initAppDir, c -> initMainScreen(stage, c), PerfinApp::loadLastUsedProfile - )); + ), false); splashScreen.showAndWait(); if (splashScreen.isStartupSuccessful()) { stage.show(); @@ -87,6 +91,11 @@ public class PerfinApp extends Application { router.map("edit-transaction", PerfinApp.class.getResource("/edit-transaction.fxml")); router.map("create-balance-record", PerfinApp.class.getResource("/create-balance-record.fxml")); router.map("balance-record", PerfinApp.class.getResource("/balance-record-view.fxml")); + router.map("vendors", PerfinApp.class.getResource("/vendors-view.fxml")); + router.map("edit-vendor", PerfinApp.class.getResource("/edit-vendor.fxml")); + router.map("categories", PerfinApp.class.getResource("/categories-view.fxml")); + router.map("edit-category", PerfinApp.class.getResource("/edit-category.fxml")); + router.map("tags", PerfinApp.class.getResource("/tags-view.fxml")); // Help pages. helpRouter.map("home", PerfinApp.class.getResource("/help-pages/home.fxml")); @@ -112,9 +121,10 @@ public class PerfinApp extends Application { } private static void loadLastUsedProfile(Consumer 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; diff --git a/src/main/java/com/andrewlalis/perfin/control/AccountViewController.java b/src/main/java/com/andrewlalis/perfin/control/AccountViewController.java index 2694104..5241538 100644 --- a/src/main/java/com/andrewlalis/perfin/control/AccountViewController.java +++ b/src/main/java/com/andrewlalis/perfin/control/AccountViewController.java @@ -1,12 +1,12 @@ package com.andrewlalis.perfin.control; import com.andrewlalis.javafx_scene_router.RouteSelectionListener; -import com.andrewlalis.perfin.data.AccountHistoryItemRepository; import com.andrewlalis.perfin.data.AccountRepository; +import com.andrewlalis.perfin.data.HistoryRepository; import com.andrewlalis.perfin.data.util.DateUtil; import com.andrewlalis.perfin.model.Account; import com.andrewlalis.perfin.model.Profile; -import com.andrewlalis.perfin.model.history.AccountHistoryItem; +import com.andrewlalis.perfin.model.history.HistoryItem; import com.andrewlalis.perfin.view.component.AccountHistoryItemTile; import javafx.application.Platform; import javafx.beans.binding.BooleanExpression; @@ -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(); @@ -89,6 +89,7 @@ public class AccountViewController implements RouteSelectionListener { @FXML public void archiveAccount() { boolean confirmResult = Popups.confirm( + titleLabel, "Are you sure you want to archive this account? It will no " + "longer show up in the app normally, and you won't be " + "able to add new transactions to it. You'll still be " + @@ -96,18 +97,19 @@ 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"); } } @FXML public void unarchiveAccount() { boolean confirm = Popups.confirm( + titleLabel, "Are you sure you want to restore this account from its archived " + "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"); } } @@ -115,6 +117,7 @@ public class AccountViewController implements RouteSelectionListener { @FXML public void deleteAccount() { boolean confirm = Popups.confirm( + titleLabel, "Are you sure you want to permanently delete this account and " + "all data directly associated with it? This cannot be " + "undone; deleted accounts are not recoverable at all. " + @@ -122,26 +125,21 @@ 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 -> { - List historyItems = repo.findMostRecentForAccount( - account.id, - loadHistoryFrom, - historyLoadSize - ); - if (historyItems.size() < historyLoadSize) { + Profile.getCurrent().dataSource().useRepoAsync(HistoryRepository.class, repo -> { + long historyId = repo.getOrCreateHistoryForAccount(account.id); + List items = repo.getNItemsBefore(historyId, historyLoadSize, loadHistoryFrom); + if (items.size() < historyLoadSize) { Platform.runLater(() -> loadMoreHistoryButton.setDisable(true)); } else { - loadHistoryFrom = historyItems.getLast().getTimestamp(); + loadHistoryFrom = items.getLast().getTimestamp(); } - List nodes = historyItems.stream() - .map(item -> AccountHistoryItemTile.forItem(item, repo, this)) - .toList(); + List nodes = items.stream().map(AccountHistoryItemTile::forItem).toList(); Platform.runLater(() -> historyItemsVBox.getChildren().addAll(nodes)); }); } diff --git a/src/main/java/com/andrewlalis/perfin/control/AccountsViewController.java b/src/main/java/com/andrewlalis/perfin/control/AccountsViewController.java index 7234eb6..5071b96 100644 --- a/src/main/java/com/andrewlalis/perfin/control/AccountsViewController.java +++ b/src/main/java/com/andrewlalis/perfin/control/AccountsViewController.java @@ -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 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()))); diff --git a/src/main/java/com/andrewlalis/perfin/control/BalanceRecordViewController.java b/src/main/java/com/andrewlalis/perfin/control/BalanceRecordViewController.java index 642294a..9e2c4d4 100644 --- a/src/main/java/com/andrewlalis/perfin/control/BalanceRecordViewController.java +++ b/src/main/java/com/andrewlalis/perfin/control/BalanceRecordViewController.java @@ -41,16 +41,19 @@ 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 attachments = repo.findAttachments(balanceRecord.id); Platform.runLater(() -> attachmentsViewPane.setAttachments(attachments)); }); } @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."); + boolean confirm = Popups.confirm( + titleLabel, + "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(); } } diff --git a/src/main/java/com/andrewlalis/perfin/control/CategoriesViewController.java b/src/main/java/com/andrewlalis/perfin/control/CategoriesViewController.java new file mode 100644 index 0000000..a006b25 --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/control/CategoriesViewController.java @@ -0,0 +1,63 @@ +package com.andrewlalis.perfin.control; + +import com.andrewlalis.javafx_scene_router.RouteSelectionListener; +import com.andrewlalis.perfin.data.TransactionCategoryRepository; +import com.andrewlalis.perfin.data.impl.JdbcDataSource; +import com.andrewlalis.perfin.data.impl.JdbcDataSourceFactory; +import com.andrewlalis.perfin.data.util.DbUtil; +import com.andrewlalis.perfin.model.Profile; +import com.andrewlalis.perfin.view.BindingUtil; +import com.andrewlalis.perfin.view.component.CategoryTile; +import javafx.application.Platform; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.fxml.FXML; +import javafx.scene.layout.VBox; + +import java.io.IOException; +import java.io.UncheckedIOException; + +import static com.andrewlalis.perfin.PerfinApp.router; + +public class CategoriesViewController implements RouteSelectionListener { + @FXML public VBox categoriesVBox; + private final ObservableList categoryTreeNodes = FXCollections.observableArrayList(); + + @FXML public void initialize() { + BindingUtil.mapContent(categoriesVBox.getChildren(), categoryTreeNodes, node -> new CategoryTile(node, this::refreshCategories)); + } + + @Override + public void onRouteSelected(Object context) { + refreshCategories(); + } + + @FXML public void addCategory() { + router.navigate("edit-category"); + } + + private void refreshCategories() { + Profile.getCurrent().dataSource().mapRepoAsync( + TransactionCategoryRepository.class, + TransactionCategoryRepository::findTree + ).thenAccept(nodes -> Platform.runLater(() -> categoryTreeNodes.setAll(nodes))); + } + + @FXML public void addDefaultCategories() { + boolean confirm = Popups.confirm(categoriesVBox, "Are you sure you want to add all of Perfin's default categories to your profile? This might interfere with existing categories of the same name."); + if (!confirm) return; + JdbcDataSource ds = (JdbcDataSource) Profile.getCurrent().dataSource(); + try (var conn = ds.getConnection()) { + DbUtil.doTransaction(conn, () -> { + try { + new JdbcDataSourceFactory().insertDefaultCategories(conn); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }); + refreshCategories(); + } catch (Exception e) { + Popups.error(categoriesVBox, e); + } + } +} diff --git a/src/main/java/com/andrewlalis/perfin/control/CreateBalanceRecordController.java b/src/main/java/com/andrewlalis/perfin/control/CreateBalanceRecordController.java index f70b78a..583aff0 100644 --- a/src/main/java/com/andrewlalis/perfin/control/CreateBalanceRecordController.java +++ b/src/main/java/com/andrewlalis/perfin/control/CreateBalanceRecordController.java @@ -11,6 +11,7 @@ import com.andrewlalis.perfin.model.Profile; import com.andrewlalis.perfin.view.component.FileSelectionArea; import com.andrewlalis.perfin.view.component.PropertiesPane; import com.andrewlalis.perfin.view.component.validation.ValidationApplier; +import com.andrewlalis.perfin.view.component.validation.ValidationFunction; import com.andrewlalis.perfin.view.component.validation.ValidationResult; import com.andrewlalis.perfin.view.component.validation.validators.CurrencyAmountValidator; import javafx.application.Platform; @@ -39,7 +40,7 @@ public class CreateBalanceRecordController implements RouteSelectionListener { private Account account; @FXML public void initialize() { - var timestampValid = new ValidationApplier(input -> { + var timestampValid = new ValidationApplier<>((ValidationFunction) input -> { try { DateUtil.DEFAULT_DATETIME_FORMAT.parse(input); return ValidationResult.valid(); @@ -60,7 +61,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 +77,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())) @@ -89,13 +90,13 @@ public class CreateBalanceRecordController implements RouteSelectionListener { LocalDateTime localTimestamp = LocalDateTime.parse(timestampField.getText(), DateUtil.DEFAULT_DATETIME_FORMAT); BigDecimal reportedBalance = new BigDecimal(balanceField.getText()); - boolean confirm = Popups.confirm("Are you sure that you want to record the balance of account\n%s\nas %s,\nas of %s?".formatted( + boolean confirm = Popups.confirm(timestampField, "Are you sure that you want to record the balance of account\n%s\nas %s,\nas of %s?".formatted( account.getShortName(), CurrencyUtil.formatMoneyWithCurrencyPrefix(new MoneyValue(reportedBalance, account.getCurrency())), 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 +114,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) ); @@ -122,7 +123,7 @@ public class CreateBalanceRecordController implements RouteSelectionListener { CurrencyUtil.formatMoney(new MoneyValue(reportedBalance, account.getCurrency())), CurrencyUtil.formatMoney(new MoneyValue(currentDerivedBalance, account.getCurrency())) ); - return Popups.confirm(msg); + return Popups.confirm(timestampField, msg); } return true; } diff --git a/src/main/java/com/andrewlalis/perfin/control/EditAccountController.java b/src/main/java/com/andrewlalis/perfin/control/EditAccountController.java index 3074aa9..2d97e97 100644 --- a/src/main/java/com/andrewlalis/perfin/control/EditAccountController.java +++ b/src/main/java/com/andrewlalis/perfin/control/EditAccountController.java @@ -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(); @@ -117,7 +117,7 @@ public class EditAccountController implements RouteSelectionListener { BigDecimal initialBalance = new BigDecimal(initialBalanceField.getText().strip()); List attachments = Collections.emptyList(); - boolean success = Popups.confirm("Are you sure you want to create this account?"); + boolean success = Popups.confirm(accountNameField, "Are you sure you want to create this account?"); if (success) { long id = accountRepo.insert(type, number, name, currency); balanceRepo.insert(LocalDateTime.now(ZoneOffset.UTC), id, initialBalance, currency, attachments); @@ -138,7 +138,7 @@ public class EditAccountController implements RouteSelectionListener { } } catch (Exception e) { log.error("Failed to save (or update) account " + account.id, e); - Popups.error("Failed to save the account: " + e.getMessage()); + Popups.error(accountNameField, "Failed to save the account: " + e.getMessage()); } } diff --git a/src/main/java/com/andrewlalis/perfin/control/EditCategoryController.java b/src/main/java/com/andrewlalis/perfin/control/EditCategoryController.java new file mode 100644 index 0000000..792990e --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/control/EditCategoryController.java @@ -0,0 +1,108 @@ +package com.andrewlalis.perfin.control; + +import com.andrewlalis.javafx_scene_router.RouteSelectionListener; +import com.andrewlalis.perfin.data.TransactionCategoryRepository; +import com.andrewlalis.perfin.model.Profile; +import com.andrewlalis.perfin.model.TransactionCategory; +import com.andrewlalis.perfin.view.component.validation.ValidationApplier; +import com.andrewlalis.perfin.view.component.validation.validators.PredicateValidator; +import javafx.fxml.FXML; +import javafx.scene.control.Button; +import javafx.scene.control.ColorPicker; +import javafx.scene.control.TextField; +import javafx.scene.paint.Color; + +import java.util.concurrent.CompletableFuture; + +import static com.andrewlalis.perfin.PerfinApp.router; + +public class EditCategoryController implements RouteSelectionListener { + public record CategoryRouteContext(TransactionCategory category) implements RouteContext {} + public record AddSubcategoryRouteContext(TransactionCategory parent) implements RouteContext {} + private sealed interface RouteContext permits AddSubcategoryRouteContext, CategoryRouteContext {} + + private TransactionCategory category; + private TransactionCategory parent; + + @FXML public TextField nameField; + @FXML public ColorPicker colorPicker; + + @FXML public Button saveButton; + + @FXML public void initialize() { + var nameValid = new ValidationApplier<>(new PredicateValidator() + .addTerminalPredicate(s -> s != null && !s.isBlank(), "Name is required.") + .addPredicate(s -> s.strip().length() <= TransactionCategory.NAME_MAX_LENGTH, "Name is too long.") + .addAsyncPredicate( + s -> { + if (Profile.getCurrent() == null) return CompletableFuture.completedFuture(false); + return Profile.getCurrent().dataSource().mapRepoAsync( + TransactionCategoryRepository.class, + repo -> { + var categoryByName = repo.findByName(s).orElse(null); + if (this.category != null) { + return this.category.equals(categoryByName) || categoryByName == null; + } + return categoryByName == null; + } + ); + }, + "Category with this name already exists." + ) + ).validatedInitially().attachToTextField(nameField); + + saveButton.disableProperty().bind(nameValid.not()); + } + + @Override + public void onRouteSelected(Object context) { + this.category = null; + this.parent = null; + if (context instanceof RouteContext ctx) { + switch (ctx) { + case CategoryRouteContext(var cat): + this.category = cat; + nameField.setText(cat.getName()); + colorPicker.setValue(cat.getColor()); + break; + case AddSubcategoryRouteContext(var par): + this.parent = par; + nameField.setText(null); + colorPicker.setValue(parent.getColor()); + break; + } + } else { + nameField.setText(null); + colorPicker.setValue(Color.WHITE); + } + } + + @FXML public void save() { + final String name = nameField.getText().strip(); + final Color color = colorPicker.getValue(); + if (this.category == null && this.parent == null) { + // New top-level category. + Profile.getCurrent().dataSource().useRepo( + TransactionCategoryRepository.class, + repo -> repo.insert(name, color) + ); + } else if (this.category == null) { + // New subcategory. + Profile.getCurrent().dataSource().useRepo( + TransactionCategoryRepository.class, + repo -> repo.insert(parent.id, name, color) + ); + } else if (this.parent == null) { + // Save edits to an existing category. + Profile.getCurrent().dataSource().useRepo( + TransactionCategoryRepository.class, + repo -> repo.update(category.id, name, color) + ); + } + router.replace("categories"); + } + + @FXML public void cancel() { + router.navigateBackAndClear(); + } +} diff --git a/src/main/java/com/andrewlalis/perfin/control/EditTransactionController.java b/src/main/java/com/andrewlalis/perfin/control/EditTransactionController.java index c708fe0..0a7c119 100644 --- a/src/main/java/com/andrewlalis/perfin/control/EditTransactionController.java +++ b/src/main/java/com/andrewlalis/perfin/control/EditTransactionController.java @@ -1,24 +1,36 @@ package com.andrewlalis.perfin.control; import com.andrewlalis.javafx_scene_router.RouteSelectionListener; +import com.andrewlalis.perfin.data.DataSource; import com.andrewlalis.perfin.data.TransactionRepository; 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.data.util.DateUtil; import com.andrewlalis.perfin.model.*; +import com.andrewlalis.perfin.view.BindingUtil; import com.andrewlalis.perfin.view.component.AccountSelectionBox; +import com.andrewlalis.perfin.view.component.CategorySelectionBox; import com.andrewlalis.perfin.view.component.FileSelectionArea; import com.andrewlalis.perfin.view.component.validation.ValidationApplier; import com.andrewlalis.perfin.view.component.validation.validators.CurrencyAmountValidator; import com.andrewlalis.perfin.view.component.validation.validators.PredicateValidator; import javafx.application.Platform; +import javafx.beans.binding.BooleanExpression; +import javafx.beans.property.BooleanProperty; import javafx.beans.property.Property; +import javafx.beans.property.SimpleBooleanProperty; import javafx.beans.property.SimpleObjectProperty; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; import javafx.fxml.FXML; +import javafx.geometry.Pos; +import javafx.scene.Node; import javafx.scene.control.*; +import javafx.scene.input.KeyCode; import javafx.scene.layout.BorderPane; import javafx.scene.layout.HBox; +import javafx.scene.layout.VBox; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -27,13 +39,14 @@ import java.nio.file.Path; import java.time.DateTimeException; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; -import java.util.Collections; -import java.util.Comparator; -import java.util.Currency; -import java.util.List; +import java.util.*; import static com.andrewlalis.perfin.PerfinApp.router; +/** + * Controller for the "edit-transaction" view, which is where the user can + * create or edit transactions. + */ public class EditTransactionController implements RouteSelectionListener { private static final Logger log = LoggerFactory.getLogger(EditTransactionController.class); @@ -49,6 +62,25 @@ public class EditTransactionController implements RouteSelectionListener { @FXML public AccountSelectionBox debitAccountSelector; @FXML public AccountSelectionBox creditAccountSelector; + @FXML public ComboBox vendorComboBox; + @FXML public Hyperlink vendorsHyperlink; + @FXML public CategorySelectionBox categoryComboBox; + @FXML public Hyperlink categoriesHyperlink; + @FXML public ComboBox tagsComboBox; + @FXML public Hyperlink tagsHyperlink; + @FXML public Button addTagButton; + @FXML public VBox tagsVBox; + private final ObservableList selectedTags = FXCollections.observableArrayList(); + + @FXML public Spinner lineItemQuantitySpinner; + @FXML public TextField lineItemValueField; + @FXML public TextField lineItemDescriptionField; + @FXML public Button addLineItemButton; + @FXML public VBox addLineItemForm; + @FXML public Button addLineItemAddButton; + @FXML public Button addLineItemCancelButton; + @FXML public final BooleanProperty addingLineItemProperty = new SimpleBooleanProperty(false); + @FXML public FileSelectionArea attachmentsSelectionArea; @FXML public Button saveButton; @@ -70,32 +102,32 @@ public class EditTransactionController implements RouteSelectionListener { var descriptionValid = new ValidationApplier<>(new PredicateValidator() .addTerminalPredicate(s -> s == null || s.length() <= 255, "Description is too long.") ).validatedInitially().attach(descriptionField, descriptionField.textProperty()); + var linkedAccountsValid = initializeLinkedAccountsValidationUi(); + initializeTagSelectionUi(); - // Linked accounts will use a property derived from both the debit and credit selections. - Property linkedAccountsProperty = new SimpleObjectProperty<>(getSelectedAccounts()); - debitAccountSelector.valueProperty().addListener((observable, oldValue, newValue) -> linkedAccountsProperty.setValue(getSelectedAccounts())); - creditAccountSelector.valueProperty().addListener((observable, oldValue, newValue) -> linkedAccountsProperty.setValue(getSelectedAccounts())); - var linkedAccountsValid = new ValidationApplier<>(new PredicateValidator() - .addPredicate(accounts -> accounts.hasCredit() || accounts.hasDebit(), "At least one account must be linked.") - .addPredicate( - accounts -> (!accounts.hasCredit() || !accounts.hasDebit()) || !accounts.creditAccount().equals(accounts.debitAccount()), - "The credit and debit accounts cannot be the same." - ) - .addPredicate( - accounts -> ( - (!accounts.hasCredit() || accounts.creditAccount().getCurrency().equals(currencyChoiceBox.getValue())) && - (!accounts.hasDebit() || accounts.debitAccount().getCurrency().equals(currencyChoiceBox.getValue())) - ), - "Linked accounts must use the same currency." - ) - .addPredicate( - accounts -> ( - (!accounts.hasCredit() || !accounts.creditAccount().isArchived()) && - (!accounts.hasDebit() || !accounts.debitAccount().isArchived()) - ), - "Linked accounts must not be archived." - ) - ).validatedInitially().attach(linkedAccountsContainer, linkedAccountsProperty); + vendorsHyperlink.setOnAction(event -> router.navigate("vendors")); + categoriesHyperlink.setOnAction(event -> router.navigate("categories")); + tagsHyperlink.setOnAction(event -> router.navigate("tags")); + + // Initialize line item stuff. + addLineItemButton.setOnAction(event -> addingLineItemProperty.set(true)); + addLineItemCancelButton.setOnAction(event -> { + lineItemQuantitySpinner.getValueFactory().setValue(1); + lineItemValueField.setText(null); + lineItemDescriptionField.setText(null); + addingLineItemProperty.set(false); + }); + BindingUtil.bindManagedAndVisible(addLineItemButton, addingLineItemProperty.not()); + BindingUtil.bindManagedAndVisible(addLineItemForm, addingLineItemProperty); + lineItemQuantitySpinner.setValueFactory(new SpinnerValueFactory.IntegerSpinnerValueFactory(1, Integer.MAX_VALUE, 1, 1)); + var lineItemValueValid = new ValidationApplier<>( + new CurrencyAmountValidator(() -> currencyChoiceBox.getValue(), false, false) + ).attachToTextField(lineItemValueField, currencyChoiceBox.valueProperty()); + var lineItemDescriptionValid = new ValidationApplier<>(new PredicateValidator() + .addTerminalPredicate(s -> s != null && !s.isBlank(), "A description is required.") + ).attachToTextField(lineItemDescriptionField); + var lineItemFormValid = lineItemValueValid.and(lineItemDescriptionValid); + addLineItemAddButton.disableProperty().bind(lineItemFormValid.not()); var formValid = timestampValid.and(amountValid).and(descriptionValid).and(linkedAccountsValid); saveButton.disableProperty().bind(formValid.not()); @@ -107,11 +139,14 @@ public class EditTransactionController implements RouteSelectionListener { Currency currency = currencyChoiceBox.getValue(); String description = getSanitizedDescription(); CreditAndDebitAccounts linkedAccounts = getSelectedAccounts(); + String vendor = vendorComboBox.getValue(); + String category = categoryComboBox.getValue() == null ? null : categoryComboBox.getValue().getName(); + Set tags = new HashSet<>(selectedTags); List newAttachmentPaths = attachmentsSelectionArea.getSelectedPaths(); List 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, @@ -119,11 +154,14 @@ public class EditTransactionController implements RouteSelectionListener { currency, description, linkedAccounts, + vendor, + category, + tags, newAttachmentPaths ) ); } else { - Profile.getCurrent().getDataSource().useRepo( + Profile.getCurrent().dataSource().useRepo( TransactionRepository.class, repo -> repo.update( transaction.id, @@ -132,6 +170,9 @@ public class EditTransactionController implements RouteSelectionListener { currency, description, linkedAccounts, + vendor, + category, + tags, existingAttachments, newAttachmentPaths ) @@ -149,6 +190,11 @@ public class EditTransactionController implements RouteSelectionListener { public void onRouteSelected(Object context) { transaction = (Transaction) context; + // Clear some initial fields immediately: + tagsComboBox.setValue(null); + vendorComboBox.setValue(null); + categoryComboBox.select(null); + if (transaction == null) { titleLabel.setText("Create New Transaction"); timestampField.setText(LocalDateTime.now().format(DateUtil.DEFAULT_DATETIME_FORMAT)); @@ -163,10 +209,13 @@ public class EditTransactionController implements RouteSelectionListener { // Fetch some account-specific data. container.setDisable(true); + DataSource ds = Profile.getCurrent().dataSource(); Thread.ofVirtual().start(() -> { try ( - var accountRepo = Profile.getCurrent().getDataSource().getAccountRepository(); - var transactionRepo = Profile.getCurrent().getDataSource().getTransactionRepository() + var accountRepo = ds.getAccountRepository(); + var transactionRepo = ds.getTransactionRepository(); + var vendorRepo = ds.getTransactionVendorRepository(); + var categoryRepo = ds.getTransactionCategoryRepository() ) { // First fetch all the data. List currencies = accountRepo.findAllUsedCurrencies().stream() @@ -174,23 +223,50 @@ public class EditTransactionController implements RouteSelectionListener { .toList(); List accounts = accountRepo.findAll(PageRequest.unpaged(Sort.asc("name"))).items(); final List attachments; + final var categoryTreeNodes = categoryRepo.findTree(); + final List availableTags = transactionRepo.findAllTags(); + final List tags; final CreditAndDebitAccounts linkedAccounts; + final String vendorName; + final TransactionCategory category; if (transaction == null) { attachments = Collections.emptyList(); + tags = Collections.emptyList(); linkedAccounts = new CreditAndDebitAccounts(null, null); + vendorName = null; + category = null; } else { attachments = transactionRepo.findAttachments(transaction.id); + tags = transactionRepo.findTags(transaction.id); linkedAccounts = transactionRepo.findLinkedAccounts(transaction.id); + if (transaction.getVendorId() != null) { + vendorName = vendorRepo.findById(transaction.getVendorId()) + .map(TransactionVendor::getName).orElse(null); + } else { + vendorName = null; + } + if (transaction.getCategoryId() != null) { + category = categoryRepo.findById(transaction.getCategoryId()).orElse(null); + } else { + category = null; + } } + final List availableVendors = vendorRepo.findAll(); // Then make updates to the view. Platform.runLater(() -> { + currencyChoiceBox.getItems().setAll(currencies); creditAccountSelector.setAccounts(accounts); debitAccountSelector.setAccounts(accounts); - currencyChoiceBox.getItems().setAll(currencies); + vendorComboBox.getItems().setAll(availableVendors.stream().map(TransactionVendor::getName).toList()); + vendorComboBox.setValue(vendorName); + categoryComboBox.loadCategories(categoryTreeNodes); + categoryComboBox.select(category); + tagsComboBox.getItems().setAll(availableTags); attachmentsSelectionArea.clear(); attachmentsSelectionArea.addAttachments(attachments); + selectedTags.clear(); + selectedTags.addAll(tags); if (transaction == null) { - // TODO: Allow user to select a default currency. currencyChoiceBox.getSelectionModel().selectFirst(); creditAccountSelector.select(null); debitAccountSelector.select(null); @@ -203,11 +279,53 @@ public class EditTransactionController implements RouteSelectionListener { }); } catch (Exception e) { log.error("Failed to get repositories.", e); - Popups.error("Failed to fetch account-specific data: " + e.getMessage()); + Platform.runLater(() -> Popups.error(container, "Failed to fetch account-specific data: " + e.getMessage())); + router.navigateBackAndClear(); } }); } + private BooleanExpression initializeLinkedAccountsValidationUi() { + Property linkedAccountsProperty = new SimpleObjectProperty<>(getSelectedAccounts()); + debitAccountSelector.valueProperty().addListener((observable, oldValue, newValue) -> linkedAccountsProperty.setValue(getSelectedAccounts())); + creditAccountSelector.valueProperty().addListener((observable, oldValue, newValue) -> linkedAccountsProperty.setValue(getSelectedAccounts())); + return new ValidationApplier<>(getLinkedAccountsValidator()) + .validatedInitially() + .attach(linkedAccountsContainer, linkedAccountsProperty); + } + + private void initializeTagSelectionUi() { + addTagButton.disableProperty().bind(tagsComboBox.valueProperty().map(s -> s == null || s.isBlank())); + addTagButton.setOnAction(event -> { + if (tagsComboBox.getValue() == null) return; + String tag = tagsComboBox.getValue().strip(); + if (!selectedTags.contains(tag)) { + selectedTags.add(tag); + selectedTags.sort(String::compareToIgnoreCase); + } + tagsComboBox.setValue(null); + }); + tagsComboBox.setOnKeyPressed(event -> { + if (event.getCode() == KeyCode.ENTER) { + addTagButton.fire(); + } + }); + BindingUtil.mapContent(tagsVBox.getChildren(), selectedTags, this::createTagListTile); + } + + private Node createTagListTile(String tag) { + Label label = new Label(tag); + label.setMaxWidth(Double.POSITIVE_INFINITY); + label.getStyleClass().addAll("bold-text"); + Button removeButton = new Button("Remove"); + removeButton.setOnAction(event -> selectedTags.remove(tag)); + BorderPane tile = new BorderPane(label); + tile.setRight(removeButton); + tile.getStyleClass().addAll("std-spacing"); + BorderPane.setAlignment(label, Pos.CENTER_LEFT); + return tile; + } + private CreditAndDebitAccounts getSelectedAccounts() { return new CreditAndDebitAccounts( creditAccountSelector.getValue(), @@ -215,6 +333,29 @@ public class EditTransactionController implements RouteSelectionListener { ); } + private PredicateValidator getLinkedAccountsValidator() { + return new PredicateValidator() + .addPredicate(accounts -> accounts.hasCredit() || accounts.hasDebit(), "At least one account must be linked.") + .addPredicate( + accounts -> (!accounts.hasCredit() || !accounts.hasDebit()) || !accounts.creditAccount().equals(accounts.debitAccount()), + "The credit and debit accounts cannot be the same." + ) + .addPredicate( + accounts -> ( + (!accounts.hasCredit() || accounts.creditAccount().getCurrency().equals(currencyChoiceBox.getValue())) && + (!accounts.hasDebit() || accounts.debitAccount().getCurrency().equals(currencyChoiceBox.getValue())) + ), + "Linked accounts must use the same currency." + ) + .addPredicate( + accounts -> ( + (!accounts.hasCredit() || !accounts.creditAccount().isArchived()) && + (!accounts.hasDebit() || !accounts.debitAccount().isArchived()) + ), + "Linked accounts must not be archived." + ); + } + private LocalDateTime parseTimestamp() { List formatters = List.of( DateTimeFormatter.ISO_LOCAL_DATE_TIME, diff --git a/src/main/java/com/andrewlalis/perfin/control/EditVendorController.java b/src/main/java/com/andrewlalis/perfin/control/EditVendorController.java new file mode 100644 index 0000000..3c18cb4 --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/control/EditVendorController.java @@ -0,0 +1,93 @@ +package com.andrewlalis.perfin.control; + +import com.andrewlalis.javafx_scene_router.RouteSelectionListener; +import com.andrewlalis.perfin.data.DataSource; +import com.andrewlalis.perfin.data.TransactionVendorRepository; +import com.andrewlalis.perfin.model.Profile; +import com.andrewlalis.perfin.model.TransactionVendor; +import com.andrewlalis.perfin.view.component.validation.ValidationApplier; +import com.andrewlalis.perfin.view.component.validation.validators.PredicateValidator; +import javafx.fxml.FXML; +import javafx.scene.control.Button; +import javafx.scene.control.TextArea; +import javafx.scene.control.TextField; + +import java.util.Objects; +import java.util.concurrent.CompletableFuture; + +import static com.andrewlalis.perfin.PerfinApp.router; + +public class EditVendorController implements RouteSelectionListener { + private TransactionVendor vendor; + + @FXML public TextField nameField; + @FXML public TextArea descriptionField; + @FXML public Button saveButton; + + @FXML public void initialize() { + var nameValid = new ValidationApplier<>(new PredicateValidator() + .addTerminalPredicate(s -> s != null && !s.isBlank(), "Name is required.") + .addPredicate(s -> s.strip().length() <= TransactionVendor.NAME_MAX_LENGTH, "Name is too long.") + // A predicate that prevents duplicate names. + .addAsyncPredicate( + s -> { + if (Profile.getCurrent() == null) return CompletableFuture.completedFuture(false); + return Profile.getCurrent().dataSource().mapRepoAsync( + TransactionVendorRepository.class, + repo -> { + var vendorByName = repo.findByName(s).orElse(null); + if (this.vendor != null) { + return this.vendor.equals(vendorByName) || vendorByName == null; + } + return vendorByName == null; + } + ); + }, + "Vendor with this name already exists." + ) + ).validatedInitially().attachToTextField(nameField); + var descriptionValid = new ValidationApplier<>(new PredicateValidator() + .addPredicate( + s -> s == null || s.strip().length() <= TransactionVendor.DESCRIPTION_MAX_LENGTH, + "Description is too long." + ) + ).validatedInitially().attach(descriptionField, descriptionField.textProperty()); + + var formValid = nameValid.and(descriptionValid); + saveButton.disableProperty().bind(formValid.not()); + } + + @Override + public void onRouteSelected(Object context) { + if (context instanceof TransactionVendor tv) { + this.vendor = tv; + nameField.setText(vendor.getName()); + descriptionField.setText(vendor.getDescription()); + } else { + nameField.setText(null); + descriptionField.setText(null); + } + } + + @FXML public void save() { + String name = nameField.getText().strip(); + String description = descriptionField.getText() == null ? null : descriptionField.getText().strip(); + DataSource ds = Profile.getCurrent().dataSource(); + if (vendor != null) { + ds.useRepo(TransactionVendorRepository.class, repo -> repo.update(vendor.id, name, description)); + } else { + ds.useRepo(TransactionVendorRepository.class, repo -> { + if (description == null || description.isEmpty()) { + repo.insert(name); + } else { + repo.insert(name, description); + } + }); + } + router.replace("vendors"); + } + + @FXML public void cancel() { + router.navigateBackAndClear(); + } +} diff --git a/src/main/java/com/andrewlalis/perfin/control/Popups.java b/src/main/java/com/andrewlalis/perfin/control/Popups.java index eb2c5d4..b0477bb 100644 --- a/src/main/java/com/andrewlalis/perfin/control/Popups.java +++ b/src/main/java/com/andrewlalis/perfin/control/Popups.java @@ -1,30 +1,70 @@ package com.andrewlalis.perfin.control; +import javafx.application.Platform; +import javafx.scene.Node; +import javafx.scene.Scene; import javafx.scene.control.Alert; import javafx.scene.control.ButtonType; import javafx.stage.Modality; +import javafx.stage.Window; /** * Helper class for standardized popups and confirmation dialogs for the app. */ public class Popups { - public static boolean confirm(String text) { + public static boolean confirm(Window owner, String text) { Alert alert = new Alert(Alert.AlertType.CONFIRMATION, text); + alert.initOwner(owner); alert.initModality(Modality.APPLICATION_MODAL); var result = alert.showAndWait(); return result.isPresent() && result.get() == ButtonType.OK; } - public static void message(String text) { + public static boolean confirm(Node node, String text) { + return confirm(getWindowFromNode(node), text); + } + + public static void message(Window owner, String text) { Alert alert = new Alert(Alert.AlertType.NONE, text); + alert.initOwner(owner); alert.initModality(Modality.APPLICATION_MODAL); alert.getButtonTypes().setAll(ButtonType.OK); alert.showAndWait(); } - public static void error(String text) { + public static void message(Node node, String text) { + message(getWindowFromNode(node), text); + } + + public static void error(Window owner, String text) { Alert alert = new Alert(Alert.AlertType.WARNING, text); + alert.initOwner(owner); alert.initModality(Modality.APPLICATION_MODAL); alert.showAndWait(); } + + public static void error(Node node, String text) { + error(getWindowFromNode(node), text); + } + + public static void error(Window owner, Exception e) { + error(owner, "An " + e.getClass().getSimpleName() + " occurred: " + e.getMessage()); + } + + public static void error(Node node, Exception e) { + error(getWindowFromNode(node), e); + } + + public static void errorLater(Node node, Exception e) { + Platform.runLater(() -> error(node, e)); + } + + private static Window getWindowFromNode(Node n) { + Window owner = null; + Scene scene = n.getScene(); + if (scene != null) { + owner = scene.getWindow(); + } + return owner; + } } diff --git a/src/main/java/com/andrewlalis/perfin/control/ProfilesViewController.java b/src/main/java/com/andrewlalis/perfin/control/ProfilesViewController.java index 89d7aca..0304a94 100644 --- a/src/main/java/com/andrewlalis/perfin/control/ProfilesViewController.java +++ b/src/main/java/com/andrewlalis/perfin/control/ProfilesViewController.java @@ -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,11 +45,11 @@ public class ProfilesViewController { @FXML public void addProfile() { String name = newProfileNameField.getText(); boolean valid = Profile.validateName(name); - if (valid && !Profile.getAvailableProfiles().contains(name)) { - boolean confirm = Popups.confirm("Are you sure you want to add a new profile named \"" + name + "\"?"); + if (valid && !ProfileLoader.getAvailableProfiles().contains(name)) { + boolean confirm = Popups.confirm(profilesVBox, "Are you sure you want to add a new profile named \"" + name + "\"?"); if (confirm) { if (openProfile(name, false)) { - Popups.message("Created new profile \"" + name + "\" and loaded it."); + Popups.message(profilesVBox, "Created new profile \"" + name + "\" and loaded it."); } newProfileNameField.clear(); } @@ -56,8 +57,8 @@ public class ProfilesViewController { } private void refreshAvailableProfiles() { - List profileNames = Profile.getAvailableProfiles(); - String currentProfile = Profile.getCurrent() == null ? null : Profile.getCurrent().getName(); + List profileNames = ProfileLoader.getAvailableProfiles(); + String currentProfile = Profile.getCurrent() == null ? null : Profile.getCurrent().name(); List nodes = new ArrayList<>(profileNames.size()); for (String profileName : profileNames) { boolean isCurrent = profileName.equals(currentProfile); @@ -104,30 +105,31 @@ public class ProfilesViewController { private boolean openProfile(String name, boolean showPopup) { log.info("Opening profile \"{}\".", name); try { - Profile.load(name); + Profile.setCurrent(PerfinApp.profileLoader.load(name)); + ProfileLoader.saveLastProfile(name); ProfilesStage.closeView(); router.replace("accounts"); - if (showPopup) Popups.message("The profile \"" + name + "\" has been loaded."); + if (showPopup) Popups.message(profilesVBox, "The profile \"" + name + "\" has been loaded."); return true; } catch (ProfileLoadException e) { - Popups.error("Failed to load the profile: " + e.getMessage()); + Popups.error(profilesVBox, "Failed to load the profile: " + e.getMessage()); return false; } } private void deleteProfile(String name) { - boolean confirmA = Popups.confirm("Are you sure you want to delete the profile \"" + name + "\"? This will permanently delete ALL accounts, transactions, files, and other data for this profile, and it cannot be recovered."); + boolean confirmA = Popups.confirm(profilesVBox, "Are you sure you want to delete the profile \"" + name + "\"? This will permanently delete ALL accounts, transactions, files, and other data for this profile, and it cannot be recovered."); if (confirmA) { - boolean confirmB = Popups.confirm("Press \"OK\" to confirm that you really want to delete the profile \"" + name + "\". There's no going back."); + boolean confirmB = Popups.confirm(profilesVBox, "Press \"OK\" to confirm that you really want to delete the profile \"" + name + "\". There's no going back."); if (confirmB) { 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(); diff --git a/src/main/java/com/andrewlalis/perfin/control/TagsViewController.java b/src/main/java/com/andrewlalis/perfin/control/TagsViewController.java new file mode 100644 index 0000000..2f0c569 --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/control/TagsViewController.java @@ -0,0 +1,64 @@ +package com.andrewlalis.perfin.control; + +import com.andrewlalis.javafx_scene_router.RouteSelectionListener; +import com.andrewlalis.perfin.data.TransactionRepository; +import com.andrewlalis.perfin.model.Profile; +import com.andrewlalis.perfin.view.BindingUtil; +import javafx.application.Platform; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.fxml.FXML; +import javafx.scene.Node; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.VBox; + +public class TagsViewController implements RouteSelectionListener { + @FXML public VBox tagsVBox; + private final ObservableList tags = FXCollections.observableArrayList(); + + @FXML public void initialize() { + BindingUtil.mapContent(tagsVBox.getChildren(), tags, this::buildTagTile); + } + + @Override + public void onRouteSelected(Object context) { + refreshTags(); + } + + private void refreshTags() { + Profile.getCurrent().dataSource().mapRepoAsync( + TransactionRepository.class, + TransactionRepository::findAllTags + ).thenAccept(strings -> Platform.runLater(() -> tags.setAll(strings))); + } + + private Node buildTagTile(String name) { + BorderPane tile = new BorderPane(); + tile.getStyleClass().addAll("tile"); + Label nameLabel = new Label(name); + nameLabel.getStyleClass().addAll("bold-text"); + Label usagesLabel = new Label(); + usagesLabel.getStyleClass().addAll("small-font", "secondary-color-text-fill"); + Profile.getCurrent().dataSource().mapRepoAsync( + TransactionRepository.class, + repo -> repo.countTagUsages(name) + ).thenAccept(count -> Platform.runLater(() -> usagesLabel.setText("Tagged transactions: " + count))); + VBox contentBox = new VBox(nameLabel, usagesLabel); + tile.setLeft(contentBox); + Button removeButton = new Button("Remove"); + removeButton.setOnAction(event -> { + boolean confirm = Popups.confirm(removeButton, "Are you sure you want to remove this tag? It will be removed from any transactions. This cannot be undone."); + if (confirm) { + Profile.getCurrent().dataSource().useRepo( + TransactionRepository.class, + repo -> repo.deleteTag(name) + ); + refreshTags(); + } + }); + tile.setRight(removeButton); + return tile; + } +} diff --git a/src/main/java/com/andrewlalis/perfin/control/TransactionViewController.java b/src/main/java/com/andrewlalis/perfin/control/TransactionViewController.java index ca2181e..02cf37d 100644 --- a/src/main/java/com/andrewlalis/perfin/control/TransactionViewController.java +++ b/src/main/java/com/andrewlalis/perfin/control/TransactionViewController.java @@ -3,23 +3,37 @@ package com.andrewlalis.perfin.control; import com.andrewlalis.perfin.data.TransactionRepository; import com.andrewlalis.perfin.data.util.CurrencyUtil; import com.andrewlalis.perfin.data.util.DateUtil; -import com.andrewlalis.perfin.model.Attachment; -import com.andrewlalis.perfin.model.CreditAndDebitAccounts; -import com.andrewlalis.perfin.model.Profile; -import com.andrewlalis.perfin.model.Transaction; +import com.andrewlalis.perfin.model.*; +import com.andrewlalis.perfin.view.BindingUtil; import com.andrewlalis.perfin.view.component.AttachmentsViewPane; +import com.andrewlalis.perfin.view.component.PropertiesPane; import javafx.application.Platform; +import javafx.beans.property.ListProperty; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleListProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; import javafx.fxml.FXML; import javafx.scene.control.Hyperlink; import javafx.scene.control.Label; +import javafx.scene.shape.Circle; import javafx.scene.text.TextFlow; - -import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import static com.andrewlalis.perfin.PerfinApp.router; public class TransactionViewController { - private Transaction transaction; + private static final Logger log = LoggerFactory.getLogger(TransactionViewController.class); + + private final ObjectProperty transactionProperty = new SimpleObjectProperty<>(null); + private final ObjectProperty linkedAccountsProperty = new SimpleObjectProperty<>(null); + private final ObjectProperty vendorProperty = new SimpleObjectProperty<>(null); + private final ObjectProperty categoryProperty = new SimpleObjectProperty<>(null); + private final ObservableList tagsList = FXCollections.observableArrayList(); + private final ListProperty tagsListProperty = new SimpleListProperty<>(tagsList); + private final ObservableList attachmentsList = FXCollections.observableArrayList(); @FXML public Label titleLabel; @@ -27,51 +41,108 @@ public class TransactionViewController { @FXML public Label timestampLabel; @FXML public Label descriptionLabel; + @FXML public Label vendorLabel; + @FXML public Circle categoryColorIndicator; + @FXML public Label categoryLabel; + @FXML public Label tagsLabel; + @FXML public Hyperlink debitAccountLink; @FXML public Hyperlink creditAccountLink; @FXML public AttachmentsViewPane attachmentsViewPane; @FXML public void initialize() { - configureAccountLinkBindings(debitAccountLink); - configureAccountLinkBindings(creditAccountLink); + titleLabel.textProperty().bind(transactionProperty.map(t -> "Transaction #" + t.id)); + amountLabel.textProperty().bind(transactionProperty.map(t -> CurrencyUtil.formatMoney(t.getMoneyAmount()))); + timestampLabel.textProperty().bind(transactionProperty.map(t -> DateUtil.formatUTCAsLocalWithZone(t.getTimestamp()))); + descriptionLabel.textProperty().bind(transactionProperty.map(Transaction::getDescription)); + + PropertiesPane vendorPane = (PropertiesPane) vendorLabel.getParent(); + BindingUtil.bindManagedAndVisible(vendorPane, vendorProperty.isNotNull()); + vendorLabel.textProperty().bind(vendorProperty.map(TransactionVendor::getName)); + + PropertiesPane categoryPane = (PropertiesPane) categoryLabel.getParent().getParent(); + BindingUtil.bindManagedAndVisible(categoryPane, categoryProperty.isNotNull()); + categoryLabel.textProperty().bind(categoryProperty.map(TransactionCategory::getName)); + categoryColorIndicator.fillProperty().bind(categoryProperty.map(TransactionCategory::getColor)); + + PropertiesPane tagsPane = (PropertiesPane) tagsLabel.getParent(); + BindingUtil.bindManagedAndVisible(tagsPane, tagsListProperty.emptyProperty().not()); + tagsLabel.textProperty().bind(tagsListProperty.map(tags -> String.join(", ", tags))); + + TextFlow debitText = (TextFlow) debitAccountLink.getParent(); + BindingUtil.bindManagedAndVisible(debitText, linkedAccountsProperty.map(CreditAndDebitAccounts::hasDebit)); + debitAccountLink.textProperty().bind(linkedAccountsProperty.map(la -> la.hasDebit() ? la.debitAccount().getShortName() : null)); + debitAccountLink.onActionProperty().bind(linkedAccountsProperty.map(la -> { + if (la.hasDebit()) { + return event -> router.navigate("account", la.debitAccount()); + } + return event -> {}; + })); + TextFlow creditText = (TextFlow) creditAccountLink.getParent(); + BindingUtil.bindManagedAndVisible(creditText, linkedAccountsProperty.map(CreditAndDebitAccounts::hasCredit)); + creditAccountLink.textProperty().bind(linkedAccountsProperty.map(la -> la.hasCredit() ? la.creditAccount().getShortName() : null)); + creditAccountLink.onActionProperty().bind(linkedAccountsProperty.map(la -> { + if (la.hasCredit()) { + return event -> router.navigate("account", la.creditAccount()); + } + return event -> {}; + })); + attachmentsViewPane.hideIfEmpty(); + attachmentsViewPane.listProperty().bindContent(attachmentsList); + + transactionProperty.addListener((observable, oldValue, newValue) -> { + if (newValue == null) { + linkedAccountsProperty.set(null); + vendorProperty.set(null); + categoryProperty.set(null); + tagsList.clear(); + attachmentsList.clear(); + } else { + updateLinkedData(newValue); + } + }); } public void setTransaction(Transaction transaction) { - this.transaction = transaction; - if (transaction == null) return; - titleLabel.setText("Transaction #" + transaction.id); - amountLabel.setText(CurrencyUtil.formatMoney(transaction.getMoneyAmount())); - timestampLabel.setText(DateUtil.formatUTCAsLocalWithZone(transaction.getTimestamp())); - descriptionLabel.setText(transaction.getDescription()); - Profile.getCurrent().getDataSource().useRepoAsync(TransactionRepository.class, repo -> { - CreditAndDebitAccounts accounts = repo.findLinkedAccounts(transaction.id); - List attachments = repo.findAttachments(transaction.id); - Platform.runLater(() -> { - if (accounts.hasDebit()) { - debitAccountLink.setText(accounts.debitAccount().getShortName()); - debitAccountLink.setOnAction(event -> router.navigate("account", accounts.debitAccount())); - } else { - debitAccountLink.setText(null); - } - if (accounts.hasCredit()) { - creditAccountLink.setText(accounts.creditAccount().getShortName()); - creditAccountLink.setOnAction(event -> router.navigate("account", accounts.creditAccount())); - } else { - creditAccountLink.setText(null); - } - attachmentsViewPane.setAttachments(attachments); - }); + this.transactionProperty.set(transaction); + } + + private void updateLinkedData(Transaction tx) { + var ds = Profile.getCurrent().dataSource(); + Thread.ofVirtual().start(() -> { + try ( + var transactionRepo = ds.getTransactionRepository(); + var vendorRepo = ds.getTransactionVendorRepository(); + var categoryRepo = ds.getTransactionCategoryRepository() + ) { + final var linkedAccounts = transactionRepo.findLinkedAccounts(tx.id); + final var vendor = tx.getVendorId() == null ? null : vendorRepo.findById(tx.getVendorId()).orElse(null); + final var category = tx.getCategoryId() == null ? null : categoryRepo.findById(tx.getCategoryId()).orElse(null); + final var attachments = transactionRepo.findAttachments(tx.id); + final var tags = transactionRepo.findTags(tx.id); + Platform.runLater(() -> { + linkedAccountsProperty.set(linkedAccounts); + vendorProperty.set(vendor); + categoryProperty.set(category); + attachmentsList.setAll(attachments); + tagsList.setAll(tags); + }); + } catch (Exception e) { + log.error("Failed to fetch additional transaction data.", e); + Popups.errorLater(titleLabel, e); + } }); } @FXML public void editTransaction() { - router.navigate("edit-transaction", this.transaction); + router.navigate("edit-transaction", this.transactionProperty.get()); } @FXML public void deleteTransaction() { boolean confirm = Popups.confirm( + titleLabel, "Are you sure you want to delete this transaction? This will " + "permanently remove the transaction and its effects on any linked " + "accounts, as well as remove any attachments from storage within " + @@ -81,15 +152,8 @@ 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(this.transactionProperty.get().id)); router.replace("transactions"); } } - - private void configureAccountLinkBindings(Hyperlink link) { - TextFlow parent = (TextFlow) link.getParent(); - parent.managedProperty().bind(parent.visibleProperty()); - parent.visibleProperty().bind(link.textProperty().isNotEmpty()); - link.setText(null); - } } diff --git a/src/main/java/com/andrewlalis/perfin/control/TransactionsViewController.java b/src/main/java/com/andrewlalis/perfin/control/TransactionsViewController.java index bb00272..751ba51 100644 --- a/src/main/java/com/andrewlalis/perfin/control/TransactionsViewController.java +++ b/src/main/java/com/andrewlalis/perfin/control/TransactionsViewController.java @@ -3,14 +3,18 @@ package com.andrewlalis.perfin.control; import com.andrewlalis.javafx_scene_router.RouteSelectionListener; import com.andrewlalis.perfin.data.AccountRepository; import com.andrewlalis.perfin.data.TransactionRepository; +import com.andrewlalis.perfin.data.impl.JdbcDataSource; import com.andrewlalis.perfin.data.pagination.Page; import com.andrewlalis.perfin.data.pagination.PageRequest; import com.andrewlalis.perfin.data.pagination.Sort; +import com.andrewlalis.perfin.data.search.JdbcTransactionSearcher; +import com.andrewlalis.perfin.data.search.SearchFilter; import com.andrewlalis.perfin.data.util.DateUtil; import com.andrewlalis.perfin.data.util.Pair; import com.andrewlalis.perfin.model.Account; import com.andrewlalis.perfin.model.Profile; import com.andrewlalis.perfin.model.Transaction; +import com.andrewlalis.perfin.view.BindingUtil; import com.andrewlalis.perfin.view.SceneUtil; import com.andrewlalis.perfin.view.component.AccountSelectionBox; import com.andrewlalis.perfin.view.component.DataSourcePaginationControls; @@ -21,6 +25,7 @@ import javafx.beans.property.SimpleObjectProperty; import javafx.beans.value.ObservableValue; import javafx.fxml.FXML; import javafx.scene.Node; +import javafx.scene.control.TextField; import javafx.scene.layout.BorderPane; import javafx.scene.layout.HBox; import javafx.scene.layout.VBox; @@ -29,8 +34,9 @@ import javafx.stage.FileChooser; import java.io.File; import java.io.PrintWriter; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; import java.util.List; -import java.util.Set; import static com.andrewlalis.perfin.PerfinApp.router; @@ -45,6 +51,7 @@ public class TransactionsViewController implements RouteSelectionListener { public record RouteContext(Long selectedTransactionId) {} @FXML public BorderPane transactionsListBorderPane; + @FXML public TextField searchField; @FXML public AccountSelectionBox filterByAccountComboBox; @FXML public VBox transactionsVBox; private DataSourcePaginationControls paginationControls; @@ -59,33 +66,30 @@ public class TransactionsViewController implements RouteSelectionListener { paginationControls.setPage(1); selectedTransaction.set(null); }); + searchField.textProperty().addListener((observable, oldValue, newValue) -> { + paginationControls.setPage(1); + selectedTransaction.set(null); + }); this.paginationControls = new DataSourcePaginationControls( transactionsVBox.getChildren(), new DataSourcePaginationControls.PageFetcherFunction() { @Override public Page fetchPage(PageRequest pagination) throws Exception { - Account accountFilter = filterByAccountComboBox.getValue(); - try (var repo = Profile.getCurrent().getDataSource().getTransactionRepository()) { - Page result; - if (accountFilter == null) { - result = repo.findAll(pagination); - } else { - result = repo.findAllByAccounts(Set.of(accountFilter.id), pagination); - } - return result.map(TransactionsViewController.this::makeTile); + JdbcDataSource ds = (JdbcDataSource) Profile.getCurrent().dataSource(); + try (var conn = ds.getConnection()) { + JdbcTransactionSearcher searcher = new JdbcTransactionSearcher(conn); + return searcher.search(pagination, getCurrentSearchFilters()) + .map(TransactionsViewController.this::makeTile); } } @Override public int getTotalCount() throws Exception { - Account accountFilter = filterByAccountComboBox.getValue(); - try (var repo = Profile.getCurrent().getDataSource().getTransactionRepository()) { - if (accountFilter == null) { - return (int) repo.countAll(); - } else { - return (int) repo.countAllByAccounts(Set.of(accountFilter.id)); - } + JdbcDataSource ds = (JdbcDataSource) Profile.getCurrent().dataSource(); + try (var conn = ds.getConnection()) { + JdbcTransactionSearcher searcher = new JdbcTransactionSearcher(conn); + return (int) searcher.resultCount(getCurrentSearchFilters()); } } } @@ -98,18 +102,13 @@ public class TransactionsViewController implements RouteSelectionListener { detailPanel.minWidthProperty().bind(halfWidthProp); detailPanel.maxWidthProperty().bind(halfWidthProp); detailPanel.prefWidthProperty().bind(halfWidthProp); - detailPanel.managedProperty().bind(detailPanel.visibleProperty()); - detailPanel.visibleProperty().bind(selectedTransaction.isNotNull()); + BindingUtil.bindManagedAndVisible(detailPanel, selectedTransaction.isNotNull()); Pair detailComponents = SceneUtil.loadNodeAndController("/transaction-view.fxml"); TransactionViewController transactionViewController = detailComponents.second(); BorderPane transactionDetailView = detailComponents.first(); - transactionDetailView.managedProperty().bind(transactionDetailView.visibleProperty()); - transactionDetailView.visibleProperty().bind(selectedTransaction.isNotNull()); detailPanel.getChildren().add(transactionDetailView); - selectedTransaction.addListener((observable, oldValue, newValue) -> { - transactionViewController.setTransaction(newValue); - }); + selectedTransaction.addListener((observable, oldValue, newValue) -> transactionViewController.setTransaction(newValue)); // Clear the transactions when a new profile is loaded. Profile.whenLoaded(profile -> { @@ -121,10 +120,10 @@ public class TransactionsViewController implements RouteSelectionListener { @Override public void onRouteSelected(Object context) { paginationControls.sorts.setAll(DEFAULT_SORTS); - transactionsVBox.getChildren().clear(); // Clear the transactions before reload initially. + selectedTransaction.set(null); // Initially set the selected transaction as null. // Refresh account filter options. - Profile.getCurrent().getDataSource().useRepoAsync(AccountRepository.class, repo -> { + Profile.getCurrent().dataSource().useRepoAsync(AccountRepository.class, repo -> { List accounts = repo.findAll(PageRequest.unpaged(Sort.asc("name"))).items(); Platform.runLater(() -> { filterByAccountComboBox.setAccounts(accounts); @@ -135,18 +134,19 @@ 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 -> { - repo.findById(ctx.selectedTransactionId).ifPresent(tx -> { - long offset = repo.countAllAfter(tx.id); - int pageNumber = (int) (offset / paginationControls.getItemsPerPage()) + 1; - Platform.runLater(() -> { - paginationControls.setPage(pageNumber).thenRun(() -> selectedTransaction.set(tx)); - }); - }); - }); + 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; + Platform.runLater(() -> { + paginationControls.setPage(pageNumber); + selectedTransaction.set(tx); + }); + }) + ); } else { paginationControls.setPage(1); - selectedTransaction.set(null); } } @@ -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"); @@ -177,11 +177,42 @@ public class TransactionsViewController implements RouteSelectionListener { )); } } catch (Exception e) { - Popups.error("An error occurred: " + e.getMessage()); + Popups.error(transactionsListBorderPane, e); } } } + private List getCurrentSearchFilters() { + List filters = new ArrayList<>(); + if (searchField.getText() != null && !searchField.getText().isBlank()) { + var likeTerms = Arrays.stream(searchField.getText().strip().toLowerCase().split("\\s+")) + .map(t -> '%'+t+'%') + .toList(); + var builder = new SearchFilter.Builder(); + List orClauses = new ArrayList<>(likeTerms.size()); + for (var term : likeTerms) { + orClauses.add("LOWER(transaction.description) LIKE ? OR LOWER(sfv.name) LIKE ? OR LOWER(sfc.name) LIKE ?"); + builder.withArg(term); + builder.withArg(term); + builder.withArg(term); + } + builder.where(String.join(" OR ", orClauses)); + builder.withJoin("LEFT JOIN transaction_vendor sfv ON sfv.id = transaction.vendor_id"); + builder.withJoin("LEFT JOIN transaction_category sfc ON sfc.id = transaction.category_id"); + filters.add(builder.build()); + } + if (filterByAccountComboBox.getValue() != null) { + Account filteredAccount = filterByAccountComboBox.getValue(); + var filter = new SearchFilter.Builder() + .where("fae.account_id = ?") + .withArg(filteredAccount.id) + .withJoin("LEFT JOIN account_entry fae ON fae.transaction_id = transaction.id") + .build(); + filters.add(filter); + } + return filters; + } + private TransactionTile makeTile(Transaction transaction) { var tile = new TransactionTile(transaction); tile.setOnMouseClicked(event -> { diff --git a/src/main/java/com/andrewlalis/perfin/control/VendorsViewController.java b/src/main/java/com/andrewlalis/perfin/control/VendorsViewController.java new file mode 100644 index 0000000..fb7598d --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/control/VendorsViewController.java @@ -0,0 +1,45 @@ +package com.andrewlalis.perfin.control; + +import com.andrewlalis.javafx_scene_router.RouteSelectionListener; +import com.andrewlalis.perfin.data.TransactionVendorRepository; +import com.andrewlalis.perfin.model.Profile; +import com.andrewlalis.perfin.model.TransactionVendor; +import com.andrewlalis.perfin.view.BindingUtil; +import com.andrewlalis.perfin.view.component.VendorTile; +import javafx.application.Platform; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.fxml.FXML; +import javafx.scene.layout.VBox; + +import java.util.List; + +import static com.andrewlalis.perfin.PerfinApp.router; + +public class VendorsViewController implements RouteSelectionListener { + @FXML public VBox vendorsVBox; + private final ObservableList vendors = FXCollections.observableArrayList(); + + @FXML public void initialize() { + BindingUtil.mapContent(vendorsVBox.getChildren(), vendors, vendor -> new VendorTile(vendor, this::refreshVendors)); + } + + @Override + public void onRouteSelected(Object context) { + refreshVendors(); + } + + @FXML public void addVendor() { + router.navigate("edit-vendor"); + } + + private void refreshVendors() { + Profile.getCurrent().dataSource().useRepoAsync(TransactionVendorRepository.class, repo -> { + final List vendors = repo.findAll(); + Platform.runLater(() -> { + this.vendors.clear(); + this.vendors.addAll(vendors); + }); + }); + } +} diff --git a/src/main/java/com/andrewlalis/perfin/data/AccountHistoryItemRepository.java b/src/main/java/com/andrewlalis/perfin/data/AccountHistoryItemRepository.java deleted file mode 100644 index a669d03..0000000 --- a/src/main/java/com/andrewlalis/perfin/data/AccountHistoryItemRepository.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.andrewlalis.perfin.data; - -import com.andrewlalis.perfin.data.util.DateUtil; -import com.andrewlalis.perfin.model.AccountEntry; -import com.andrewlalis.perfin.model.BalanceRecord; -import com.andrewlalis.perfin.model.history.AccountHistoryItem; - -import java.time.LocalDateTime; -import java.util.List; -import java.util.Optional; - -public interface AccountHistoryItemRepository extends Repository, AutoCloseable { - void recordAccountEntry(LocalDateTime timestamp, long accountId, long entryId); - void recordBalanceRecord(LocalDateTime timestamp, long accountId, long recordId); - void recordText(LocalDateTime timestamp, long accountId, String text); - List findMostRecentForAccount(long accountId, LocalDateTime utcTimestamp, int count); - default Optional getMostRecentForAccount(long accountId) { - var items = findMostRecentForAccount(accountId, DateUtil.nowAsUTC(), 1); - if (items.isEmpty()) return Optional.empty(); - return Optional.of(items.getFirst()); - } - String getTextItem(long itemId); - AccountEntry getAccountEntryItem(long itemId); - BalanceRecord getBalanceRecordItem(long itemId); -} diff --git a/src/main/java/com/andrewlalis/perfin/data/DataSource.java b/src/main/java/com/andrewlalis/perfin/data/DataSource.java index ca008de..bd2244f 100644 --- a/src/main/java/com/andrewlalis/perfin/data/DataSource.java +++ b/src/main/java/com/andrewlalis/perfin/data/DataSource.java @@ -30,8 +30,10 @@ public interface DataSource { AccountRepository getAccountRepository(); BalanceRecordRepository getBalanceRecordRepository(); TransactionRepository getTransactionRepository(); + TransactionVendorRepository getTransactionVendorRepository(); + TransactionCategoryRepository getTransactionCategoryRepository(); AttachmentRepository getAttachmentRepository(); - AccountHistoryItemRepository getAccountHistoryItemRepository(); + HistoryRepository getHistoryRepository(); // Repository helper methods: @@ -81,8 +83,10 @@ public interface DataSource { AccountRepository.class, this::getAccountRepository, BalanceRecordRepository.class, this::getBalanceRecordRepository, TransactionRepository.class, this::getTransactionRepository, + TransactionVendorRepository.class, this::getTransactionVendorRepository, + TransactionCategoryRepository.class, this::getTransactionCategoryRepository, AttachmentRepository.class, this::getAttachmentRepository, - AccountHistoryItemRepository.class, this::getAccountHistoryItemRepository + HistoryRepository.class, this::getHistoryRepository ); return (Supplier) repoSuppliers.get(type); } diff --git a/src/main/java/com/andrewlalis/perfin/data/DataSourceFactory.java b/src/main/java/com/andrewlalis/perfin/data/DataSourceFactory.java new file mode 100644 index 0000000..5fbc7d8 --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/data/DataSourceFactory.java @@ -0,0 +1,21 @@ +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; + + int getSchemaVersion(String profileName) throws IOException; +} diff --git a/src/main/java/com/andrewlalis/perfin/data/HistoryRepository.java b/src/main/java/com/andrewlalis/perfin/data/HistoryRepository.java new file mode 100644 index 0000000..815f67d --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/data/HistoryRepository.java @@ -0,0 +1,28 @@ +package com.andrewlalis.perfin.data; + +import com.andrewlalis.perfin.data.pagination.Page; +import com.andrewlalis.perfin.data.pagination.PageRequest; +import com.andrewlalis.perfin.data.util.DateUtil; +import com.andrewlalis.perfin.model.history.HistoryItem; +import com.andrewlalis.perfin.model.history.HistoryTextItem; + +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.List; + +public interface HistoryRepository extends Repository, AutoCloseable { + long getOrCreateHistoryForAccount(long accountId); + long getOrCreateHistoryForTransaction(long transactionId); + void deleteHistoryForAccount(long accountId); + void deleteHistoryForTransaction(long transactionId); + + HistoryTextItem addTextItem(long historyId, LocalDateTime utcTimestamp, String description); + default HistoryTextItem addTextItem(long historyId, String description) { + return addTextItem(historyId, DateUtil.nowAsUTC(), description); + } + Page getItems(long historyId, PageRequest pagination); + List getNItemsBefore(long historyId, int n, LocalDateTime timestamp); + default List getNItemsBeforeNow(long historyId, int n) { + return getNItemsBefore(historyId, n, LocalDateTime.now(ZoneOffset.UTC)); + } +} diff --git a/src/main/java/com/andrewlalis/perfin/data/TransactionCategoryRepository.java b/src/main/java/com/andrewlalis/perfin/data/TransactionCategoryRepository.java new file mode 100644 index 0000000..a996349 --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/data/TransactionCategoryRepository.java @@ -0,0 +1,21 @@ +package com.andrewlalis.perfin.data; + +import com.andrewlalis.perfin.model.TransactionCategory; +import javafx.scene.paint.Color; + +import java.util.List; +import java.util.Optional; + +public interface TransactionCategoryRepository extends Repository, AutoCloseable { + Optional findById(long id); + Optional findByName(String name); + List findAllBaseCategories(); + List findAll(); + long insert(long parentId, String name, Color color); + long insert(String name, Color color); + void update(long id, String name, Color color); + void deleteById(long id); + + record CategoryTreeNode(TransactionCategory category, List children){} + List findTree(); +} diff --git a/src/main/java/com/andrewlalis/perfin/data/TransactionRepository.java b/src/main/java/com/andrewlalis/perfin/data/TransactionRepository.java index 08003cd..e5d845c 100644 --- a/src/main/java/com/andrewlalis/perfin/data/TransactionRepository.java +++ b/src/main/java/com/andrewlalis/perfin/data/TransactionRepository.java @@ -21,6 +21,9 @@ public interface TransactionRepository extends Repository, AutoCloseable { Currency currency, String description, CreditAndDebitAccounts linkedAccounts, + String vendor, + String category, + Set tags, List attachments ); Optional findById(long id); @@ -31,6 +34,10 @@ public interface TransactionRepository extends Repository, AutoCloseable { Page findAllByAccounts(Set accountIds, PageRequest pagination); CreditAndDebitAccounts findLinkedAccounts(long transactionId); List findAttachments(long transactionId); + List findTags(long transactionId); + List findAllTags(); + void deleteTag(String name); + long countTagUsages(String name); void delete(long transactionId); void update( long id, @@ -39,6 +46,9 @@ public interface TransactionRepository extends Repository, AutoCloseable { Currency currency, String description, CreditAndDebitAccounts linkedAccounts, + String vendor, + String category, + Set tags, List existingAttachments, List newAttachmentPaths ); diff --git a/src/main/java/com/andrewlalis/perfin/data/TransactionVendorRepository.java b/src/main/java/com/andrewlalis/perfin/data/TransactionVendorRepository.java new file mode 100644 index 0000000..93dd5cd --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/data/TransactionVendorRepository.java @@ -0,0 +1,16 @@ +package com.andrewlalis.perfin.data; + +import com.andrewlalis.perfin.model.TransactionVendor; + +import java.util.List; +import java.util.Optional; + +public interface TransactionVendorRepository extends Repository, AutoCloseable { + Optional findById(long id); + Optional findByName(String name); + List findAll(); + long insert(String name, String description); + long insert(String name); + void update(long id, String name, String description); + void deleteById(long id); +} diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountEntryRepository.java b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountEntryRepository.java index 18fdd6f..0ec481b 100644 --- a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountEntryRepository.java +++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountEntryRepository.java @@ -1,7 +1,7 @@ package com.andrewlalis.perfin.data.impl; import com.andrewlalis.perfin.data.AccountEntryRepository; -import com.andrewlalis.perfin.data.AccountHistoryItemRepository; +import com.andrewlalis.perfin.data.HistoryRepository; import com.andrewlalis.perfin.data.util.DbUtil; import com.andrewlalis.perfin.model.AccountEntry; @@ -31,8 +31,9 @@ public record JdbcAccountEntryRepository(Connection conn) implements AccountEntr ) ); // Insert an entry into the account's history. - AccountHistoryItemRepository historyRepo = new JdbcAccountHistoryItemRepository(conn); - historyRepo.recordAccountEntry(timestamp, accountId, entryId); + HistoryRepository historyRepo = new JdbcHistoryRepository(conn); + long historyId = historyRepo.getOrCreateHistoryForAccount(accountId); + historyRepo.addTextItem(historyId, timestamp, "Entry #" + entryId + " added as a " + type.name() + " from Transaction #" + transactionId + "."); return entryId; } diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountHistoryItemRepository.java b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountHistoryItemRepository.java deleted file mode 100644 index eda1a7c..0000000 --- a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountHistoryItemRepository.java +++ /dev/null @@ -1,120 +0,0 @@ -package com.andrewlalis.perfin.data.impl; - -import com.andrewlalis.perfin.data.AccountHistoryItemRepository; -import com.andrewlalis.perfin.data.util.DbUtil; -import com.andrewlalis.perfin.model.AccountEntry; -import com.andrewlalis.perfin.model.BalanceRecord; -import com.andrewlalis.perfin.model.history.AccountHistoryItem; -import com.andrewlalis.perfin.model.history.AccountHistoryItemType; - -import java.sql.Connection; -import java.sql.ResultSet; -import java.sql.SQLException; -import java.time.LocalDateTime; -import java.util.List; - -public record JdbcAccountHistoryItemRepository(Connection conn) implements AccountHistoryItemRepository { - @Override - public void recordAccountEntry(LocalDateTime timestamp, long accountId, long entryId) { - long itemId = insertHistoryItem(timestamp, accountId, AccountHistoryItemType.ACCOUNT_ENTRY); - DbUtil.insertOne( - conn, - "INSERT INTO account_history_item_account_entry (item_id, entry_id) VALUES (?, ?)", - List.of(itemId, entryId) - ); - } - - @Override - public void recordBalanceRecord(LocalDateTime timestamp, long accountId, long recordId) { - long itemId = insertHistoryItem(timestamp, accountId, AccountHistoryItemType.BALANCE_RECORD); - DbUtil.insertOne( - conn, - "INSERT INTO account_history_item_balance_record (item_id, record_id) VALUES (?, ?)", - List.of(itemId, recordId) - ); - } - - @Override - public void recordText(LocalDateTime timestamp, long accountId, String text) { - long itemId = insertHistoryItem(timestamp, accountId, AccountHistoryItemType.TEXT); - DbUtil.insertOne( - conn, - "INSERT INTO account_history_item_text (item_id, description) VALUES (?, ?)", - List.of(itemId, text) - ); - } - - @Override - public List findMostRecentForAccount(long accountId, LocalDateTime utcTimestamp, int count) { - return DbUtil.findAll( - conn, - "SELECT * FROM account_history_item WHERE account_id = ? AND timestamp < ? ORDER BY timestamp DESC LIMIT " + count, - List.of(accountId, DbUtil.timestampFromUtcLDT(utcTimestamp)), - JdbcAccountHistoryItemRepository::parseHistoryItem - ); - } - - @Override - public String getTextItem(long itemId) { - return DbUtil.findOne( - conn, - "SELECT description FROM account_history_item_text WHERE item_id = ?", - List.of(itemId), - rs -> rs.getString(1) - ).orElse(null); - } - - @Override - public AccountEntry getAccountEntryItem(long itemId) { - return DbUtil.findOne( - conn, - """ - SELECT * - FROM account_entry - LEFT JOIN account_history_item_account_entry h ON h.entry_id = account_entry.id - WHERE h.item_id = ?""", - List.of(itemId), - JdbcAccountEntryRepository::parse - ).orElse(null); - } - - @Override - public BalanceRecord getBalanceRecordItem(long itemId) { - return DbUtil.findOne( - conn, - """ - SELECT * - FROM balance_record - LEFT JOIN account_history_item_balance_record h ON h.record_id = balance_record.id - WHERE h.item_id = ?""", - List.of(itemId), - JdbcBalanceRecordRepository::parse - ).orElse(null); - } - - @Override - public void close() throws Exception { - conn.close(); - } - - public static AccountHistoryItem parseHistoryItem(ResultSet rs) throws SQLException { - return new AccountHistoryItem( - rs.getLong("id"), - DbUtil.utcLDTFromTimestamp(rs.getTimestamp("timestamp")), - rs.getLong("account_id"), - AccountHistoryItemType.valueOf(rs.getString("type")) - ); - } - - private long insertHistoryItem(LocalDateTime timestamp, long accountId, AccountHistoryItemType type) { - return DbUtil.insertOne( - conn, - "INSERT INTO account_history_item (timestamp, account_id, type) VALUES (?, ?, ?)", - List.of( - DbUtil.timestampFromUtcLDT(timestamp), - accountId, - type.name() - ) - ); - } -} diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountRepository.java b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountRepository.java index 8a36051..73faf1d 100644 --- a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountRepository.java +++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcAccountRepository.java @@ -1,12 +1,8 @@ package com.andrewlalis.perfin.data.impl; -import com.andrewlalis.perfin.data.AccountEntryRepository; -import com.andrewlalis.perfin.data.AccountRepository; -import com.andrewlalis.perfin.data.BalanceRecordRepository; -import com.andrewlalis.perfin.data.EntityNotFoundException; +import com.andrewlalis.perfin.data.*; import com.andrewlalis.perfin.data.pagination.Page; import com.andrewlalis.perfin.data.pagination.PageRequest; -import com.andrewlalis.perfin.data.util.DateUtil; import com.andrewlalis.perfin.data.util.DbUtil; import com.andrewlalis.perfin.model.Account; import com.andrewlalis.perfin.model.AccountEntry; @@ -43,8 +39,9 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements ) ); // Insert a history item indicating the creation of the account. - var historyRepo = new JdbcAccountHistoryItemRepository(conn); - historyRepo.recordText(DateUtil.nowAsUTC(), accountId, "Account added to your Perfin profile."); + HistoryRepository historyRepo = new JdbcHistoryRepository(conn); + long historyId = historyRepo.getOrCreateHistoryForAccount(accountId); + historyRepo.addTextItem(historyId, "Account added to your Perfin profile."); return accountId; }); } @@ -59,11 +56,12 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements return DbUtil.findAll( conn, """ - SELECT DISTINCT ON (account.id) account.*, ahi.timestamp AS _ + SELECT DISTINCT ON (account.id) account.*, hi.timestamp AS _ FROM account - LEFT OUTER JOIN account_history_item ahi ON ahi.account_id = account.id + LEFT OUTER JOIN history_account ha ON ha.account_id = account.id + LEFT OUTER JOIN history_item hi ON hi.history_id = ha.history_id WHERE NOT account.archived - ORDER BY ahi.timestamp DESC, account.created_at DESC""", + ORDER BY hi.timestamp DESC, account.created_at DESC""", JdbcAccountRepository::parseAccount ); } @@ -160,7 +158,9 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements public void archive(long accountId) { DbUtil.doTransaction(conn, () -> { DbUtil.updateOne(conn, "UPDATE account SET archived = TRUE WHERE id = ?", List.of(accountId)); - new JdbcAccountHistoryItemRepository(conn).recordText(DateUtil.nowAsUTC(), accountId, "Account has been archived."); + HistoryRepository historyRepo = new JdbcHistoryRepository(conn); + long historyId = historyRepo.getOrCreateHistoryForAccount(accountId); + historyRepo.addTextItem(historyId, "Account has been archived."); }); } @@ -168,7 +168,9 @@ public record JdbcAccountRepository(Connection conn, Path contentDir) implements public void unarchive(long accountId) { DbUtil.doTransaction(conn, () -> { DbUtil.updateOne(conn, "UPDATE account SET archived = FALSE WHERE id = ?", List.of(accountId)); - new JdbcAccountHistoryItemRepository(conn).recordText(DateUtil.nowAsUTC(), accountId, "Account has been unarchived."); + HistoryRepository historyRepo = new JdbcHistoryRepository(conn); + long historyId = historyRepo.getOrCreateHistoryForAccount(accountId); + historyRepo.addTextItem(historyId, "Account has been unarchived."); }); } diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcBalanceRecordRepository.java b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcBalanceRecordRepository.java index 44f42d2..da34639 100644 --- a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcBalanceRecordRepository.java +++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcBalanceRecordRepository.java @@ -1,11 +1,13 @@ package com.andrewlalis.perfin.data.impl; -import com.andrewlalis.perfin.data.AccountHistoryItemRepository; import com.andrewlalis.perfin.data.AttachmentRepository; import com.andrewlalis.perfin.data.BalanceRecordRepository; +import com.andrewlalis.perfin.data.HistoryRepository; +import com.andrewlalis.perfin.data.util.CurrencyUtil; import com.andrewlalis.perfin.data.util.DbUtil; import com.andrewlalis.perfin.model.Attachment; import com.andrewlalis.perfin.model.BalanceRecord; +import com.andrewlalis.perfin.model.MoneyValue; import java.math.BigDecimal; import java.nio.file.Path; @@ -36,8 +38,9 @@ public record JdbcBalanceRecordRepository(Connection conn, Path contentDir) impl } } // Add a history item entry. - AccountHistoryItemRepository historyRepo = new JdbcAccountHistoryItemRepository(conn); - historyRepo.recordBalanceRecord(utcTimestamp, accountId, recordId); + HistoryRepository historyRepo = new JdbcHistoryRepository(conn); + long historyId = historyRepo.getOrCreateHistoryForAccount(accountId); + historyRepo.addTextItem(historyId, utcTimestamp, "Balance Record #" + recordId + " added with a value of " + CurrencyUtil.formatMoneyWithCurrencyPrefix(new MoneyValue(balance, currency))); return recordId; }); } diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSource.java b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSource.java index 5296a2a..9f7b172 100644 --- a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSource.java +++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSource.java @@ -49,13 +49,23 @@ public class JdbcDataSource implements DataSource { return new JdbcTransactionRepository(getConnection(), contentDir); } + @Override + public TransactionVendorRepository getTransactionVendorRepository() { + return new JdbcTransactionVendorRepository(getConnection()); + } + + @Override + public TransactionCategoryRepository getTransactionCategoryRepository() { + return new JdbcTransactionCategoryRepository(getConnection()); + } + @Override public AttachmentRepository getAttachmentRepository() { return new JdbcAttachmentRepository(getConnection(), contentDir); } @Override - public AccountHistoryItemRepository getAccountHistoryItemRepository() { - return new JdbcAccountHistoryItemRepository(getConnection()); + public HistoryRepository getHistoryRepository() { + return new JdbcHistoryRepository(getConnection()); } } diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSourceFactory.java b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSourceFactory.java index bf18f06..bdad590 100644 --- a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSourceFactory.java +++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSourceFactory.java @@ -1,11 +1,16 @@ 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; +import com.andrewlalis.perfin.data.util.DbUtil; import com.andrewlalis.perfin.data.util.FileUtil; import com.andrewlalis.perfin.model.Profile; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -14,16 +19,14 @@ 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.sql.*; import java.util.Arrays; 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); /** @@ -32,7 +35,7 @@ public class JdbcDataSourceFactory { * 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 static final int SCHEMA_VERSION = 3; public DataSource getDataSource(String profileName) throws ProfileLoadException { final boolean dbExists = Files.exists(getDatabaseFile(profileName)); @@ -59,6 +62,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)); @@ -69,6 +79,7 @@ public class JdbcDataSourceFactory { if (in == null) throw new IOException("Could not load database schema SQL file."); String schemaStr = new String(in.readAllBytes(), StandardCharsets.UTF_8); executeSqlScript(schemaStr, conn); + insertDefaultData(conn); try { writeCurrentSchemaVersion(profileName); } catch (IOException e) { @@ -89,6 +100,53 @@ public class JdbcDataSourceFactory { } } + /** + * Inserts all default data into the database, using static content found in + * various locations on the classpath. + * @param conn The connection to use to insert data. + * @throws IOException If resources couldn't be read. + * @throws SQLException If SQL fails. + */ + public void insertDefaultData(Connection conn) throws IOException, SQLException { + insertDefaultCategories(conn); + } + + public void insertDefaultCategories(Connection conn) throws IOException, SQLException { + try ( + var categoriesIn = JdbcDataSourceFactory.class.getResourceAsStream("/sql/data/default-categories.json"); + var stmt = conn.prepareStatement( + "INSERT INTO transaction_category (parent_id, name, color) VALUES (?, ?, ?)", + Statement.RETURN_GENERATED_KEYS + ) + ) { + if (categoriesIn == null) throw new IOException("Couldn't load default categories file."); + ObjectMapper mapper = new ObjectMapper(); + ArrayNode categoriesArray = mapper.readValue(categoriesIn, ArrayNode.class); + insertCategoriesRecursive(stmt, categoriesArray, null, "#FFFFFF"); + } + } + + private void insertCategoriesRecursive(PreparedStatement stmt, ArrayNode categoriesArray, Long parentId, String parentColorHex) throws SQLException { + for (JsonNode obj : categoriesArray) { + String name = obj.get("name").asText(); + String colorHex = parentColorHex; + if (obj.hasNonNull("color")) colorHex = obj.get("color").asText(parentColorHex); + if (parentId == null) { + stmt.setNull(1, Types.BIGINT); + } else { + stmt.setLong(1, parentId); + } + stmt.setString(2, name); + stmt.setString(3, colorHex.substring(1)); + int result = stmt.executeUpdate(); + if (result != 1) throw new SQLException("Failed to insert category."); + long id = DbUtil.getGeneratedId(stmt); + if (obj.hasNonNull("children") && obj.get("children").isArray()) { + insertCategoriesRecursive(stmt, obj.withArray("children"), id, colorHex); + } + } + } + private boolean testConnection(JdbcDataSource dataSource) { try (var conn = dataSource.getConnection(); var stmt = conn.createStatement()) { return stmt.execute("SELECT 1;"); @@ -168,7 +226,7 @@ public class JdbcDataSourceFactory { return Profile.getDir(profileName).resolve(".jdbc-schema-version.txt"); } - private static int getSchemaVersion(String profileName) throws IOException { + public int getSchemaVersion(String profileName) throws IOException { if (Files.exists(getSchemaVersionFile(profileName))) { try { return Integer.parseInt(Files.readString(getSchemaVersionFile(profileName)).strip()); diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcHistoryRepository.java b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcHistoryRepository.java new file mode 100644 index 0000000..5626d38 --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcHistoryRepository.java @@ -0,0 +1,125 @@ +package com.andrewlalis.perfin.data.impl; + +import com.andrewlalis.perfin.data.HistoryRepository; +import com.andrewlalis.perfin.data.pagination.Page; +import com.andrewlalis.perfin.data.pagination.PageRequest; +import com.andrewlalis.perfin.data.util.DateUtil; +import com.andrewlalis.perfin.data.util.DbUtil; +import com.andrewlalis.perfin.model.history.HistoryItem; +import com.andrewlalis.perfin.model.history.HistoryTextItem; + +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.LocalDateTime; +import java.util.List; + +public record JdbcHistoryRepository(Connection conn) implements HistoryRepository { + @Override + public long getOrCreateHistoryForAccount(long accountId) { + return getOrCreateHistoryForEntity(accountId, "history_account", "account_id"); + } + + @Override + public long getOrCreateHistoryForTransaction(long transactionId) { + return getOrCreateHistoryForEntity(transactionId, "history_transaction", "transaction_id"); + } + + private long getOrCreateHistoryForEntity(long entityId, String joinTableName, String joinColumn) { + String selectQuery = "SELECT history_id FROM " + joinTableName + " WHERE " + joinColumn + " = ?"; + var optionalHistoryId = DbUtil.findById(conn, selectQuery, entityId, rs -> rs.getLong(1)); + if (optionalHistoryId.isPresent()) return optionalHistoryId.get(); + long historyId = DbUtil.insertOne(conn, "INSERT INTO history () VALUES ()"); + String insertQuery = "INSERT INTO " + joinTableName + " (" + joinColumn + ", history_id) VALUES (?, ?)"; + DbUtil.updateOne(conn, insertQuery, entityId, historyId); + return historyId; + } + + @Override + public void deleteHistoryForAccount(long accountId) { + deleteHistoryForEntity(accountId, "history_account", "account_id"); + } + + @Override + public void deleteHistoryForTransaction(long transactionId) { + deleteHistoryForEntity(transactionId, "history_transaction", "transaction_id"); + } + + private void deleteHistoryForEntity(long entityId, String joinTableName, String joinColumn) { + String selectQuery = "SELECT history_id FROM " + joinTableName + " WHERE " + joinColumn + " = ?"; + var optionalHistoryId = DbUtil.findById(conn, selectQuery, entityId, rs -> rs.getLong(1)); + if (optionalHistoryId.isPresent()) { + long historyId = optionalHistoryId.get(); + DbUtil.updateOne(conn, "DELETE FROM history WHERE id = ?", historyId); + } + } + + @Override + public HistoryTextItem addTextItem(long historyId, LocalDateTime utcTimestamp, String description) { + long itemId = insertHistoryItem(historyId, utcTimestamp, HistoryItem.TYPE_TEXT); + DbUtil.updateOne( + conn, + "INSERT INTO history_item_text (id, description) VALUES (?, ?)", + itemId, + description + ); + return new HistoryTextItem(itemId, historyId, utcTimestamp, description); + } + + private long insertHistoryItem(long historyId, LocalDateTime timestamp, String type) { + return DbUtil.insertOne( + conn, + "INSERT INTO history_item (history_id, timestamp, type) VALUES (?, ?, ?)", + historyId, + DbUtil.timestampFromUtcLDT(timestamp), + type + ); + } + + @Override + public Page getItems(long historyId, PageRequest pagination) { + return DbUtil.findAll( + conn, + "SELECT * FROM history_item WHERE history_id = ?", + pagination, + List.of(historyId), + JdbcHistoryRepository::parseItem + ); + } + + @Override + public List getNItemsBefore(long historyId, int n, LocalDateTime timestamp) { + return DbUtil.findAll( + conn, + """ + SELECT * + FROM history_item + WHERE history_id = ? AND timestamp <= ? + ORDER BY timestamp DESC""", + List.of(historyId, DbUtil.timestampFromUtcLDT(timestamp)), + JdbcHistoryRepository::parseItem + ); + } + + @Override + public void close() throws Exception { + conn.close(); + } + + public static HistoryItem parseItem(ResultSet rs) throws SQLException { + long id = rs.getLong(1); + long historyId = rs.getLong(2); + LocalDateTime timestamp = DbUtil.utcLDTFromTimestamp(rs.getTimestamp(3)); + String type = rs.getString(4); + if (type.equalsIgnoreCase(HistoryItem.TYPE_TEXT)) { + String description = DbUtil.findOne( + rs.getStatement().getConnection(), + "SELECT description FROM history_item_text WHERE id = ?", + List.of(id), + r -> r.getString(1) + ).orElseThrow(); + return new HistoryTextItem(id, historyId, timestamp, description); + } + throw new SQLException("Unknown history item type: " + type); + } +} diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionCategoryRepository.java b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionCategoryRepository.java new file mode 100644 index 0000000..7dcd072 --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionCategoryRepository.java @@ -0,0 +1,142 @@ +package com.andrewlalis.perfin.data.impl; + +import com.andrewlalis.perfin.data.TransactionCategoryRepository; +import com.andrewlalis.perfin.data.util.ColorUtil; +import com.andrewlalis.perfin.data.util.DbUtil; +import com.andrewlalis.perfin.model.TransactionCategory; +import javafx.scene.paint.Color; + +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +public record JdbcTransactionCategoryRepository(Connection conn) implements TransactionCategoryRepository { + @Override + public Optional findById(long id) { + return DbUtil.findById( + conn, + "SELECT * FROM transaction_category WHERE id = ?", + id, + JdbcTransactionCategoryRepository::parseCategory + ); + } + + @Override + public Optional findByName(String name) { + return DbUtil.findOne( + conn, + "SELECT * FROM transaction_category WHERE name = ?", + List.of(name), + JdbcTransactionCategoryRepository::parseCategory + ); + } + + @Override + public List findAllBaseCategories() { + return DbUtil.findAll( + conn, + "SELECT * FROM transaction_category WHERE parent_id IS NULL ORDER BY name ASC", + JdbcTransactionCategoryRepository::parseCategory + ); + } + + @Override + public List findAll() { + return DbUtil.findAll( + conn, + "SELECT * FROM transaction_category ORDER BY parent_id ASC, name ASC", + JdbcTransactionCategoryRepository::parseCategory + ); + } + + @Override + public long insert(long parentId, String name, Color color) { + return DbUtil.insertOne( + conn, + "INSERT INTO transaction_category (parent_id, name, color) VALUES (?, ?, ?)", + List.of(parentId, name, ColorUtil.toHex(color)) + ); + } + + @Override + public long insert(String name, Color color) { + return DbUtil.insertOne( + conn, + "INSERT INTO transaction_category (name, color) VALUES (?, ?)", + List.of(name, ColorUtil.toHex(color)) + ); + } + + @Override + public void update(long id, String name, Color color) { + DbUtil.doTransaction(conn, () -> { + TransactionCategory category = findById(id).orElseThrow(); + if (!category.getName().equals(name)) { + DbUtil.updateOne( + conn, + "UPDATE transaction_category SET name = ? WHERE id = ?", + name, + id + ); + } + if (!category.getColor().equals(color)) { + DbUtil.updateOne( + conn, + "UPDATE transaction_category SET color = ? WHERE id = ?", + ColorUtil.toHex(color), + id + ); + } + }); + } + + @Override + public void deleteById(long id) { + DbUtil.updateOne(conn, "DELETE FROM transaction_category WHERE id = ?", id); + } + + @Override + public List findTree() { + List rootCategories = DbUtil.findAll( + conn, + "SELECT * FROM transaction_category WHERE parent_id IS NULL ORDER BY name ASC", + JdbcTransactionCategoryRepository::parseCategory + ); + List rootNodes = new ArrayList<>(rootCategories.size()); + for (var category : rootCategories) { + rootNodes.add(findTreeRecursive(category)); + } + return rootNodes; + } + + private CategoryTreeNode findTreeRecursive(TransactionCategory root) { + CategoryTreeNode node = new CategoryTreeNode(root, new ArrayList<>()); + List childCategories = DbUtil.findAll( + conn, + "SELECT * FROM transaction_category WHERE parent_id = ? ORDER BY name ASC", + List.of(root.id), + JdbcTransactionCategoryRepository::parseCategory + ); + for (var childCategory : childCategories) { + node.children().add(findTreeRecursive(childCategory)); + } + return node; + } + + @Override + public void close() throws Exception { + conn.close(); + } + + public static TransactionCategory parseCategory(ResultSet rs) throws SQLException { + return new TransactionCategory( + rs.getLong("id"), + rs.getObject("parent_id", Long.class), + rs.getString("name"), + Color.valueOf("#" + rs.getString("color")) + ); + } +} diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionRepository.java b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionRepository.java index 5eb9a8d..edd8bb1 100644 --- a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionRepository.java +++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionRepository.java @@ -2,20 +2,21 @@ package com.andrewlalis.perfin.data.impl; import com.andrewlalis.perfin.data.AccountEntryRepository; import com.andrewlalis.perfin.data.AttachmentRepository; +import com.andrewlalis.perfin.data.HistoryRepository; import com.andrewlalis.perfin.data.TransactionRepository; import com.andrewlalis.perfin.data.pagination.Page; import com.andrewlalis.perfin.data.pagination.PageRequest; import com.andrewlalis.perfin.data.util.CurrencyUtil; import com.andrewlalis.perfin.data.util.DateUtil; import com.andrewlalis.perfin.data.util.DbUtil; +import com.andrewlalis.perfin.data.util.UncheckedSqlException; import com.andrewlalis.perfin.model.*; +import javafx.scene.paint.Color; import java.math.BigDecimal; import java.math.RoundingMode; import java.nio.file.Path; -import java.sql.Connection; -import java.sql.ResultSet; -import java.sql.SQLException; +import java.sql.*; import java.time.LocalDateTime; import java.util.*; import java.util.stream.Collectors; @@ -28,29 +29,104 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem Currency currency, String description, CreditAndDebitAccounts linkedAccounts, + String vendor, + String category, + Set tags, List attachments ) { return DbUtil.doTransaction(conn, () -> { - // 1. Insert the transaction. - long txId = DbUtil.insertOne( - conn, - "INSERT INTO transaction (timestamp, amount, currency, description) VALUES (?, ?, ?, ?)", - List.of(DbUtil.timestampFromUtcLDT(utcTimestamp), amount, currency.getCurrencyCode(), description) - ); - // 2. Insert linked account entries. + Long vendorId = null; + if (vendor != null && !vendor.isBlank()) { + vendorId = getOrCreateVendorId(vendor.strip()); + } + Long categoryId = null; + if (category != null && !category.isBlank()) { + categoryId = getOrCreateCategoryId(category.strip()); + } + // Insert the transaction, using a custom JDBC statement to deal with nullables. + long txId; + try (var stmt = conn.prepareStatement( + "INSERT INTO transaction (timestamp, amount, currency, description, vendor_id, category_id) VALUES (?, ?, ?, ?, ?, ?)", + Statement.RETURN_GENERATED_KEYS + )) { + stmt.setTimestamp(1, DbUtil.timestampFromUtcLDT(utcTimestamp)); + stmt.setBigDecimal(2, amount); + stmt.setString(3, currency.getCurrencyCode()); + if (description != null && !description.isBlank()) { + stmt.setString(4, description.strip()); + } else { + stmt.setNull(4, Types.VARCHAR); + } + if (vendorId != null) { + stmt.setLong(5, vendorId); + } else { + stmt.setNull(5, Types.BIGINT); + } + if (categoryId != null) { + stmt.setLong(6, categoryId); + } else { + stmt.setNull(6, Types.BIGINT); + } + int result = stmt.executeUpdate(); + if (result != 1) throw new UncheckedSqlException("Transaction insert returned " + result); + var rs = stmt.getGeneratedKeys(); + if (!rs.next()) throw new UncheckedSqlException("Transaction insert didn't generate any keys."); + txId = rs.getLong(1); + } + // Insert linked account entries. AccountEntryRepository accountEntryRepository = new JdbcAccountEntryRepository(conn); linkedAccounts.ifDebit(acc -> accountEntryRepository.insert(utcTimestamp, acc.id, txId, amount, AccountEntry.Type.DEBIT, currency)); linkedAccounts.ifCredit(acc -> accountEntryRepository.insert(utcTimestamp, acc.id, txId, amount, AccountEntry.Type.CREDIT, currency)); - // 3. Add attachments. + // Add attachments. AttachmentRepository attachmentRepo = new JdbcAttachmentRepository(conn, contentDir); for (Path attachmentPath : attachments) { Attachment attachment = attachmentRepo.insert(attachmentPath); insertAttachmentLink(txId, attachment.id); } + // Add tags. + for (String tag : tags) { + try (var stmt = conn.prepareStatement("INSERT INTO transaction_tag_join (transaction_id, tag_id) VALUES (?, ?)")) { + long tagId = getOrCreateTagId(tag.toLowerCase().strip()); + stmt.setLong(1, txId); + stmt.setLong(2, tagId); + stmt.executeUpdate(); + } + + } return txId; }); } + private long getOrCreateVendorId(String name) { + var repo = new JdbcTransactionVendorRepository(conn); + TransactionVendor vendor = repo.findByName(name).orElse(null); + if (vendor != null) { + return vendor.id; + } + return repo.insert(name); + } + + private long getOrCreateCategoryId(String name) { + var repo = new JdbcTransactionCategoryRepository(conn); + TransactionCategory category = repo.findByName(name).orElse(null); + if (category != null) { + return category.id; + } + return repo.insert(name, Color.WHITE); + } + + private long getOrCreateTagId(String name) { + Optional optionalId = DbUtil.findOne( + conn, + "SELECT id FROM transaction_tag WHERE name = ?", + List.of(name), + rs -> rs.getLong(1) + ); + return optionalId.orElseGet(() -> + DbUtil.insertOne(conn, "INSERT INTO transaction_tag (name) VALUES (?)", List.of(name)) + ); + } + @Override public Optional findById(long id) { return DbUtil.findById(conn, "SELECT * FROM transaction WHERE id = ?", id, JdbcTransactionRepository::parseTransaction); @@ -147,6 +223,51 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem ); } + @Override + public List findTags(long transactionId) { + return DbUtil.findAll( + conn, + """ + SELECT tt.name + FROM transaction_tag tt + LEFT JOIN transaction_tag_join ttj ON ttj.tag_id = tt.id + WHERE ttj.transaction_id = ? + ORDER BY tt.name ASC""", + List.of(transactionId), + rs -> rs.getString(1) + ); + } + + @Override + public List findAllTags() { + return DbUtil.findAll( + conn, + "SELECT name FROM transaction_tag ORDER BY name ASC", + rs -> rs.getString(1) + ); + } + + @Override + public void deleteTag(String name) { + DbUtil.update( + conn, + "DELETE FROM transaction_tag WHERE name = ?", + name + ); + } + + @Override + public long countTagUsages(String name) { + return DbUtil.count( + conn, + """ + SELECT COUNT(transaction_id) + FROM transaction_tag_join + WHERE tag_id = (SELECT id FROM transaction_tag WHERE name = ?)""", + name + ); + } + @Override public void delete(long transactionId) { DbUtil.doTransaction(conn, () -> { @@ -164,44 +285,93 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem Currency currency, String description, CreditAndDebitAccounts linkedAccounts, + String vendor, + String category, + Set tags, List existingAttachments, List newAttachmentPaths ) { DbUtil.doTransaction(conn, () -> { - Transaction tx = findById(id).orElseThrow(); - CreditAndDebitAccounts currentLinkedAccounts = findLinkedAccounts(id); - List currentAttachments = findAttachments(id); var entryRepo = new JdbcAccountEntryRepository(conn); var attachmentRepo = new JdbcAttachmentRepository(conn, contentDir); + var vendorRepo = new JdbcTransactionVendorRepository(conn); + var categoryRepo = new JdbcTransactionCategoryRepository(conn); + + Transaction tx = findById(id).orElseThrow(); + CreditAndDebitAccounts currentLinkedAccounts = findLinkedAccounts(id); + TransactionVendor currentVendor = tx.getVendorId() == null ? null : vendorRepo.findById(tx.getVendorId()).orElseThrow(); + String currentVendorName = currentVendor == null ? null : currentVendor.getName(); + TransactionCategory currentCategory = tx.getCategoryId() == null ? null : categoryRepo.findById(tx.getCategoryId()).orElseThrow(); + String currentCategoryName = currentCategory == null ? null : currentCategory.getName(); + Set currentTags = new HashSet<>(findTags(id)); + List currentAttachments = findAttachments(id); + List updateMessages = new ArrayList<>(); if (!tx.getTimestamp().equals(utcTimestamp)) { - DbUtil.updateOne(conn, "UPDATE transaction SET timestamp = ? WHERE id = ?", List.of(DbUtil.timestampFromUtcLDT(utcTimestamp), id)); + DbUtil.updateOne(conn, "UPDATE transaction SET timestamp = ? WHERE id = ?", DbUtil.timestampFromUtcLDT(utcTimestamp), id); updateMessages.add("Updated timestamp to UTC " + DateUtil.DEFAULT_DATETIME_FORMAT.format(utcTimestamp) + "."); } BigDecimal scaledAmount = amount.setScale(4, RoundingMode.HALF_UP); if (!tx.getAmount().equals(scaledAmount)) { - DbUtil.updateOne(conn, "UPDATE transaction SET amount = ? WHERE id = ?", List.of(scaledAmount, id)); + DbUtil.updateOne(conn, "UPDATE transaction SET amount = ? WHERE id = ?", scaledAmount, id); updateMessages.add("Updated amount to " + CurrencyUtil.formatMoney(new MoneyValue(scaledAmount, currency)) + "."); } if (!tx.getCurrency().equals(currency)) { - DbUtil.updateOne(conn, "UPDATE transaction SET currency = ? WHERE id = ?", List.of(currency.getCurrencyCode(), id)); + DbUtil.updateOne(conn, "UPDATE transaction SET currency = ? WHERE id = ?", currency.getCurrencyCode(), id); updateMessages.add("Updated currency to " + currency.getCurrencyCode() + "."); } if (!Objects.equals(tx.getDescription(), description)) { - DbUtil.updateOne(conn, "UPDATE transaction SET description = ? WHERE id = ?", List.of(description, id)); + DbUtil.updateOne(conn, "UPDATE transaction SET description = ? WHERE id = ?", description, id); updateMessages.add("Updated description."); } - boolean updateAccountEntries = !tx.getAmount().equals(scaledAmount) || + boolean shouldUpdateAccountEntries = !tx.getAmount().equals(scaledAmount) || !tx.getCurrency().equals(currency) || !tx.getTimestamp().equals(utcTimestamp) || !currentLinkedAccounts.equals(linkedAccounts); - if (updateAccountEntries) { - // Delete all entries and re-write them correctly? - DbUtil.update(conn, "DELETE FROM account_entry WHERE transaction_id = ?", List.of(id)); + if (shouldUpdateAccountEntries) { + // Delete all entries and re-write them correctly. + DbUtil.update(conn, "DELETE FROM account_entry WHERE transaction_id = ?", id); linkedAccounts.ifCredit(acc -> entryRepo.insert(utcTimestamp, acc.id, id, scaledAmount, AccountEntry.Type.CREDIT, currency)); linkedAccounts.ifDebit(acc -> entryRepo.insert(utcTimestamp, acc.id, id, scaledAmount, AccountEntry.Type.DEBIT, currency)); updateMessages.add("Updated linked accounts."); } + // Manage vendor change. + if (!Objects.equals(vendor, currentVendorName)) { + if (vendor == null || vendor.isBlank()) { + DbUtil.updateOne(conn, "UPDATE transaction SET vendor_id = NULL WHERE id = ?", id); + } else { + long newVendorId = getOrCreateVendorId(vendor); + DbUtil.updateOne(conn, "UPDATE transaction SET vendor_id = ? WHERE id = ?", newVendorId, id); + } + updateMessages.add("Updated vendor name to \"" + vendor + "\"."); + } + // Manage category change. + if (!Objects.equals(category, currentCategoryName)) { + if (category == null || category.isBlank()) { + DbUtil.updateOne(conn, "UPDATE transaction SET category_id = NULL WHERE id = ?", id); + } else { + long newCategoryId = getOrCreateCategoryId(category); + DbUtil.updateOne(conn, "UPDATE transaction SET category_id = ? WHERE id = ?", newCategoryId, id); + } + updateMessages.add("Updated category name to \"" + category + "\"."); + } + // Manage tags changes. + if (!currentTags.equals(tags)) { + Set tagsAdded = new HashSet<>(tags); + tagsAdded.removeAll(currentTags); + Set tagsRemoved = new HashSet<>(currentTags); + tagsRemoved.removeAll(tags); + + for (var t : tagsRemoved) removeTag(id, t); + for (var t : tagsAdded) addTag(id, t); + + if (!tagsAdded.isEmpty()) { + updateMessages.add("Added tag(s): " + String.join(", ", tagsAdded)); + } + if (!tagsRemoved.isEmpty()) { + updateMessages.add("Removed tag(s): " + String.join(", ", tagsRemoved)); + } + } // Manage attachments changes. List removedAttachments = new ArrayList<>(currentAttachments); removedAttachments.removeAll(existingAttachments); @@ -214,10 +384,12 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem insertAttachmentLink(tx.id, attachment.id); updateMessages.add("Added attachment \"" + attachment.getFilename() + "\"."); } + + // Add a text history item to any linked accounts detailing the changes. String updateMessageStr = "Transaction #" + tx.id + " was updated:\n" + String.join("\n", updateMessages); - var historyRepo = new JdbcAccountHistoryItemRepository(conn); - linkedAccounts.ifCredit(acc -> historyRepo.recordText(DateUtil.nowAsUTC(), acc.id, updateMessageStr)); - linkedAccounts.ifDebit(acc -> historyRepo.recordText(DateUtil.nowAsUTC(), acc.id, updateMessageStr)); + HistoryRepository historyRepo = new JdbcHistoryRepository(conn); + long historyId = historyRepo.getOrCreateHistoryForTransaction(id); + historyRepo.addTextItem(historyId, updateMessageStr); }); } @@ -226,16 +398,6 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem conn.close(); } - public static Transaction parseTransaction(ResultSet rs) throws SQLException { - return new Transaction( - rs.getLong("id"), - DbUtil.utcLDTFromTimestamp(rs.getTimestamp("timestamp")), - rs.getBigDecimal("amount"), - Currency.getInstance(rs.getString("currency")), - rs.getString("description") - ); - } - private void insertAttachmentLink(long transactionId, long attachmentId) { DbUtil.insertOne( conn, @@ -243,4 +405,50 @@ public record JdbcTransactionRepository(Connection conn, Path contentDir) implem List.of(transactionId, attachmentId) ); } + + private long getTagId(String name) { + return DbUtil.findOne( + conn, + "SELECT id FROM transaction_tag WHERE name = ?", + List.of(name), + rs -> rs.getLong(1) + ).orElse(-1L); + } + + private void removeTag(long transactionId, String tag) { + long id = getTagId(tag); + if (id != -1) { + DbUtil.update(conn, "DELETE FROM transaction_tag_join WHERE transaction_id = ? AND tag_id = ?", transactionId, id); + } + } + + private void addTag(long transactionId, String tag) { + long id = getOrCreateTagId(tag); + boolean exists = DbUtil.count( + conn, + "SELECT COUNT(tag_id) FROM transaction_tag_join WHERE transaction_id = ? AND tag_id = ?", + transactionId, + id + ) > 0; + if (!exists) { + DbUtil.insertOne( + conn, + "INSERT INTO transaction_tag_join (transaction_id, tag_id) VALUES (?, ?)", + transactionId, + id + ); + } + } + + public static Transaction parseTransaction(ResultSet rs) throws SQLException { + return new Transaction( + rs.getLong("id"), + DbUtil.utcLDTFromTimestamp(rs.getTimestamp("timestamp")), + rs.getBigDecimal("amount"), + Currency.getInstance(rs.getString("currency")), + rs.getString("description"), + rs.getObject("vendor_id", Long.class), + rs.getObject("category_id", Long.class) + ); + } } diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionVendorRepository.java b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionVendorRepository.java new file mode 100644 index 0000000..4b9c388 --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionVendorRepository.java @@ -0,0 +1,102 @@ +package com.andrewlalis.perfin.data.impl; + +import com.andrewlalis.perfin.data.TransactionVendorRepository; +import com.andrewlalis.perfin.data.util.DbUtil; +import com.andrewlalis.perfin.model.TransactionVendor; + +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +public record JdbcTransactionVendorRepository(Connection conn) implements TransactionVendorRepository { + @Override + public Optional findById(long id) { + return DbUtil.findById( + conn, + "SELECT * FROM transaction_vendor WHERE id = ?", + id, + JdbcTransactionVendorRepository::parseVendor + ); + } + + @Override + public Optional findByName(String name) { + return DbUtil.findOne( + conn, + "SELECT * FROM transaction_vendor WHERE name = ?", + List.of(name), + JdbcTransactionVendorRepository::parseVendor + ); + } + + @Override + public List findAll() { + return DbUtil.findAll( + conn, + "SELECT * FROM transaction_vendor ORDER BY name ASC", + JdbcTransactionVendorRepository::parseVendor + ); + } + + @Override + public long insert(String name, String description) { + return DbUtil.insertOne( + conn, + "INSERT INTO transaction_vendor (name, description) VALUES (?, ?)", + List.of(name, description) + ); + } + + @Override + public long insert(String name) { + return DbUtil.insertOne( + conn, + "INSERT INTO transaction_vendor (name) VALUES (?)", + List.of(name) + ); + } + + @Override + public void update(long id, String name, String description) { + DbUtil.doTransaction(conn, () -> { + TransactionVendor vendor = findById(id).orElseThrow(); + if (!vendor.getName().equals(name)) { + DbUtil.updateOne( + conn, + "UPDATE transaction_vendor SET name = ? WHERE id = ?", + name, + id + ); + } + if (!Objects.equals(vendor.getDescription(), description)) { + DbUtil.updateOne( + conn, + "UPDATE transaction_vendor SET description = ? WHERE id = ?", + description, + id + ); + } + }); + } + + @Override + public void deleteById(long id) { + DbUtil.update(conn, "DELETE FROM transaction_vendor WHERE id = ?", List.of(id)); + } + + @Override + public void close() throws Exception { + conn.close(); + } + + public static TransactionVendor parseVendor(ResultSet rs) throws SQLException { + return new TransactionVendor( + rs.getLong("id"), + rs.getString("name"), + rs.getString("description") + ); + } +} diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/migration/Migrations.java b/src/main/java/com/andrewlalis/perfin/data/impl/migration/Migrations.java index 79a7d6c..3893fae 100644 --- a/src/main/java/com/andrewlalis/perfin/data/impl/migration/Migrations.java +++ b/src/main/java/com/andrewlalis/perfin/data/impl/migration/Migrations.java @@ -4,10 +4,20 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +/** + * Utility class for defining and using all known migrations. + */ public class Migrations { + /** + * Gets a list of migrations, as a map with the key being the version to + * migrate from. For example, a migration that takes us from version 42 to + * 43 would exist in the map with key 42. + * @return The map of all migrations. + */ public static Map getMigrations() { final Map migrations = new HashMap<>(); - migrations.put(1, new PlainSQLMigration("/sql/migration/M1_AddBalanceRecordDeleted.sql")); + migrations.put(1, new PlainSQLMigration("/sql/migration/M001_AddTransactionProperties.sql")); + migrations.put(2, new PlainSQLMigration("/sql/migration/M002_RefactorHistories.sql")); return migrations; } @@ -25,4 +35,14 @@ public class Migrations { } return selectedMigration; } + + public static Map getSchemaVersionCompatibility() { + final Map compatibilities = new HashMap<>(); + compatibilities.put(1, "1.4.0"); + return compatibilities; + } + + public static String getLatestCompatibleVersion(int schemaVersion) { + return getSchemaVersionCompatibility().get(schemaVersion); + } } diff --git a/src/main/java/com/andrewlalis/perfin/data/search/EntitySearcher.java b/src/main/java/com/andrewlalis/perfin/data/search/EntitySearcher.java new file mode 100644 index 0000000..ebc61c3 --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/data/search/EntitySearcher.java @@ -0,0 +1,28 @@ +package com.andrewlalis.perfin.data.search; + +import com.andrewlalis.perfin.data.pagination.Page; +import com.andrewlalis.perfin.data.pagination.PageRequest; + +import java.util.List; + +/** + * An entity searcher will search for entities matching a list of filters. + * @param The entity type to search over. + */ +public interface EntitySearcher { + /** + * Gets a page of results that match the given filters. + * @param pageRequest The page request. + * @param filters The filters to apply. + * @return A page of results. + */ + Page search(PageRequest pageRequest, List filters); + + /** + * Gets the number of results that would be returned for a given set of + * filters. + * @param filters The filters to apply. + * @return The number of entities that match. + */ + long resultCount(List filters); +} diff --git a/src/main/java/com/andrewlalis/perfin/data/search/JdbcEntitySearcher.java b/src/main/java/com/andrewlalis/perfin/data/search/JdbcEntitySearcher.java new file mode 100644 index 0000000..7e2d46f --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/data/search/JdbcEntitySearcher.java @@ -0,0 +1,118 @@ +package com.andrewlalis.perfin.data.search; + +import com.andrewlalis.perfin.data.pagination.Page; +import com.andrewlalis.perfin.data.pagination.PageRequest; +import com.andrewlalis.perfin.data.util.Pair; +import com.andrewlalis.perfin.data.util.ResultSetMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +public class JdbcEntitySearcher implements EntitySearcher { + private static final Logger logger = LoggerFactory.getLogger(JdbcEntitySearcher.class); + + private final Connection conn; + private final String countExpression; + private final String selectExpression; + private final ResultSetMapper resultSetMapper; + + public JdbcEntitySearcher(Connection conn, String countExpression, String selectExpression, ResultSetMapper resultSetMapper) { + this.conn = conn; + this.countExpression = countExpression; + this.selectExpression = selectExpression; + this.resultSetMapper = resultSetMapper; + } + + private Pair>> buildSearchQuery(List filters) { + if (filters.isEmpty()) return new Pair<>("", Collections.emptyList()); + StringBuilder sb = new StringBuilder(); + List> args = new ArrayList<>(); + for (var filter : filters) { + args.addAll(filter.args()); + for (var joinClause : filter.joinClauses()) { + sb.append(joinClause).append('\n'); + } + } + sb.append("WHERE\n"); + for (int i = 0; i < filters.size(); i++) { + sb.append(filters.get(i).whereClause()); + if (i < filters.size() - 1) { + sb.append(" AND"); + } + sb.append('\n'); + } + return new Pair<>(sb.toString(), args); + } + + private void applyArgs(PreparedStatement stmt, List> args) throws SQLException { + for (int i = 1; i <= args.size(); i++) { + Pair arg = args.get(i - 1); + if (arg.second() == null) { + stmt.setNull(i, arg.first()); + } else { + stmt.setObject(i, arg.second(), arg.first()); + } + } + } + + @Override + public Page search(PageRequest pageRequest, List filters) { + var baseQueryAndArgs = buildSearchQuery(filters); + StringBuilder sqlBuilder = new StringBuilder(selectExpression); + if (baseQueryAndArgs.first() != null && !baseQueryAndArgs.first().isBlank()) { + sqlBuilder.append('\n').append(baseQueryAndArgs.first()); + } + String pagingSql = pageRequest.toSQL(); + if (pagingSql != null && !pagingSql.isBlank()) { + sqlBuilder.append('\n').append(pagingSql); + } + String sql = sqlBuilder.toString(); + logger.debug( + "Searching with query:\n{}\nWith arguments: {}", + sql, + baseQueryAndArgs.second().stream() + .map(Pair::second) + .map(Object::toString) + .collect(Collectors.joining(", ")) + ); + try (var stmt = conn.prepareStatement(sql)) { + applyArgs(stmt, baseQueryAndArgs.second()); + ResultSet rs = stmt.executeQuery(); + List results = new ArrayList<>(pageRequest.size()); + while (rs.next() && results.size() < pageRequest.size()) { + results.add(resultSetMapper.map(rs)); + } + return new Page<>(results, pageRequest); + } catch (SQLException e) { + logger.error("Search failed.", e); + return new Page<>(Collections.emptyList(), pageRequest); + } + } + + @Override + public long resultCount(List filters) { + var baseQueryAndArgs = buildSearchQuery(filters); + String sql = countExpression + "\n" + baseQueryAndArgs.first(); + try (var stmt = conn.prepareStatement(sql)) { + applyArgs(stmt, baseQueryAndArgs.second()); + ResultSet rs = stmt.executeQuery(); + if (!rs.next()) throw new SQLException("No count result."); + return rs.getLong(1); + } catch (SQLException e) { + logger.error("Failed to get search result count.", e); + return 0L; + } + } + + public static class Builder { + + } +} diff --git a/src/main/java/com/andrewlalis/perfin/data/search/JdbcTransactionSearcher.java b/src/main/java/com/andrewlalis/perfin/data/search/JdbcTransactionSearcher.java new file mode 100644 index 0000000..8f5c111 --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/data/search/JdbcTransactionSearcher.java @@ -0,0 +1,35 @@ +package com.andrewlalis.perfin.data.search; + +import com.andrewlalis.perfin.data.util.DbUtil; +import com.andrewlalis.perfin.model.Transaction; + +import java.math.BigDecimal; +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.LocalDateTime; +import java.util.Currency; + +public class JdbcTransactionSearcher extends JdbcEntitySearcher { + public JdbcTransactionSearcher(Connection conn) { + super( + conn, + "SELECT COUNT(transaction.id) FROM transaction", + "SELECT transaction.* FROM transaction", + JdbcTransactionSearcher::parseResultSet + ); + } + + private static Transaction parseResultSet(ResultSet rs) throws SQLException { + long id = rs.getLong(1); + LocalDateTime timestamp = DbUtil.utcLDTFromTimestamp(rs.getTimestamp(2)); + BigDecimal amount = rs.getBigDecimal(3); + Currency currency = Currency.getInstance(rs.getString(4)); + String description = rs.getString(5); + Long vendorId = rs.getLong(6); + if (rs.wasNull()) vendorId = null; + Long categoryId = rs.getLong(7); + if (rs.wasNull()) categoryId = null; + return new Transaction(id, timestamp, amount, currency, description, vendorId, categoryId); + } +} diff --git a/src/main/java/com/andrewlalis/perfin/data/search/SearchFilter.java b/src/main/java/com/andrewlalis/perfin/data/search/SearchFilter.java new file mode 100644 index 0000000..f69437a --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/data/search/SearchFilter.java @@ -0,0 +1,61 @@ +package com.andrewlalis.perfin.data.search; + +import com.andrewlalis.perfin.data.util.DbUtil; +import com.andrewlalis.perfin.data.util.Pair; + +import java.sql.Types; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public interface SearchFilter { + String whereClause(); + List> args(); + default List joinClauses() { + return Collections.emptyList(); + } + + record Impl(String whereClause, List> args, List joinClauses) implements SearchFilter {} + + class Builder { + private String whereClause; + private List> args = new ArrayList<>(); + private List joinClauses = new ArrayList<>(); + + public Builder where(String clause) { + this.whereClause = clause; + return this; + } + + public Builder withArg(int sqlType, Object value) { + args.add(new Pair<>(sqlType, value)); + return this; + } + + public Builder withArg(int value) { + return withArg(Types.INTEGER, value); + } + + public Builder withArg(long value) { + return withArg(Types.BIGINT, value); + } + + public Builder withArg(String value) { + return withArg(Types.VARCHAR, value); + } + + public Builder withArg(LocalDateTime utcTimestamp) { + return withArg(Types.TIMESTAMP, DbUtil.timestampFromUtcLDT(utcTimestamp)); + } + + public Builder withJoin(String joinClause) { + joinClauses.add(joinClause); + return this; + } + + public SearchFilter build() { + return new Impl(whereClause, args, joinClauses); + } + } +} diff --git a/src/main/java/com/andrewlalis/perfin/data/util/ColorUtil.java b/src/main/java/com/andrewlalis/perfin/data/util/ColorUtil.java new file mode 100644 index 0000000..98b9325 --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/data/util/ColorUtil.java @@ -0,0 +1,14 @@ +package com.andrewlalis.perfin.data.util; + +import javafx.scene.paint.Color; + +public class ColorUtil { + public static String toHex(Color color) { + return formatColorDouble(color.getRed()) + formatColorDouble(color.getGreen()) + formatColorDouble(color.getBlue()); + } + + private static String formatColorDouble(double val) { + String in = Integer.toHexString((int) Math.round(val * 255)); + return in.length() == 1 ? "0" + in : in; + } +} diff --git a/src/main/java/com/andrewlalis/perfin/data/util/DbUtil.java b/src/main/java/com/andrewlalis/perfin/data/util/DbUtil.java index 11d2cdb..29ef062 100644 --- a/src/main/java/com/andrewlalis/perfin/data/util/DbUtil.java +++ b/src/main/java/com/andrewlalis/perfin/data/util/DbUtil.java @@ -31,6 +31,15 @@ public final class DbUtil { setArgs(stmt, List.of(args)); } + public static long getGeneratedId(PreparedStatement stmt) { + try (ResultSet rs = stmt.getGeneratedKeys()) { + if (!rs.next()) throw new SQLException("No generated keys available."); + return rs.getLong(1); + } catch (SQLException e) { + throw new UncheckedSqlException(e); + } + } + public static List findAll(Connection conn, String query, List args, ResultSetMapper mapper) { try (var stmt = conn.prepareStatement(query)) { setArgs(stmt, args); @@ -58,6 +67,17 @@ public final class DbUtil { return findAll(conn, query, pagination, Collections.emptyList(), mapper); } + public static long count(Connection conn, String query, Object... args) { + try (var stmt = conn.prepareStatement(query)) { + setArgs(stmt, args); + var rs = stmt.executeQuery(); + if (!rs.next()) throw new UncheckedSqlException("No count result available."); + return rs.getLong(1); + } catch (SQLException e) { + throw new UncheckedSqlException(e); + } + } + public static Optional findOne(Connection conn, String query, List args, ResultSetMapper mapper) { try (var stmt = conn.prepareStatement(query)) { setArgs(stmt, args); @@ -82,6 +102,10 @@ public final class DbUtil { } } + public static int update(Connection conn, String query, Object... args) { + return update(conn, query, List.of(args)); + } + public static void updateOne(Connection conn, String query, List args) { try (var stmt = conn.prepareStatement(query)) { setArgs(stmt, args); @@ -92,19 +116,25 @@ public final class DbUtil { } } + public static void updateOne(Connection conn, String query, Object... args) { + updateOne(conn, query, List.of(args)); + } + public static long insertOne(Connection conn, String query, List args) { try (var stmt = conn.prepareStatement(query, Statement.RETURN_GENERATED_KEYS)) { setArgs(stmt, args); int result = stmt.executeUpdate(); if (result != 1) throw new UncheckedSqlException("Insert query did not update 1 row."); - var rs = stmt.getGeneratedKeys(); - rs.next(); - return rs.getLong(1); + return getGeneratedId(stmt); } catch (SQLException e) { throw new UncheckedSqlException(e); } } + public static long insertOne(Connection conn, String query, Object... args) { + return insertOne(conn, query, List.of(args)); + } + public static Timestamp timestampFromUtcLDT(LocalDateTime utc) { return Timestamp.from(utc.toInstant(ZoneOffset.UTC)); } @@ -132,7 +162,9 @@ public final class DbUtil { public static T doTransaction(Connection conn, SQLSupplier supplier) { try { conn.setAutoCommit(false); - return supplier.offer(); + T result = supplier.offer(); + conn.commit(); + return result; } catch (Exception e) { try { conn.rollback(); diff --git a/src/main/java/com/andrewlalis/perfin/data/util/FileUtil.java b/src/main/java/com/andrewlalis/perfin/data/util/FileUtil.java index 0edab5c..09bb389 100644 --- a/src/main/java/com/andrewlalis/perfin/data/util/FileUtil.java +++ b/src/main/java/com/andrewlalis/perfin/data/util/FileUtil.java @@ -1,5 +1,6 @@ package com.andrewlalis.perfin.data.util; +import com.andrewlalis.perfin.model.Profile; import javafx.stage.FileChooser; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -103,4 +104,14 @@ public class FileUtil { throw new RuntimeException(e); } } + + public static void copyResourceFile(String resource, Path dest) throws IOException { + try ( + var in = Profile.class.getResourceAsStream(resource); + var out = Files.newOutputStream(dest) + ) { + if (in == null) throw new IOException("Could not load resource " + resource); + in.transferTo(out); + } + } } diff --git a/src/main/java/com/andrewlalis/perfin/model/Profile.java b/src/main/java/com/andrewlalis/perfin/model/Profile.java index 7dc8ee9..de4647b 100644 --- a/src/main/java/com/andrewlalis/perfin/model/Profile.java +++ b/src/main/java/com/andrewlalis/perfin/model/Profile.java @@ -2,19 +2,14 @@ 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; /** @@ -33,35 +28,22 @@ import java.util.function.Consumer; * class maintains a static current profile that can be loaded and * unloaded. *

+ * + * @param name The name of the profile. + * @param settings The profile's settings. + * @param dataSource The profile's data source. */ -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> profileLoadListeners = new ArrayList<>(); + private static final Set>> 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); } @@ -78,89 +60,23 @@ public class Profile { return current; } + public static void setCurrent(Profile profile) { + current = profile; + for (var ref : currentProfileListeners) { + Consumer consumer = ref.get(); + if (consumer != null) { + consumer.accept(profile); + } + } + currentProfileListeners.removeIf(ref -> ref.get() == null); + log.debug("Current profile set to {}.", current.name()); + } + public static void whenLoaded(Consumer consumer) { if (current != null) { consumer.accept(current); - } else { - profileLoadListeners.add(consumer); - } - } - - public static List 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")); - } - - private static void copyResourceFile(String resource, Path dest) throws IOException { - try ( - var in = Profile.class.getResourceAsStream(resource); - var out = Files.newOutputStream(dest) - ) { - if (in == null) throw new IOException("Could not load resource " + resource); - in.transferTo(out); } + currentProfileListeners.add(new WeakReference<>(consumer)); } public static boolean validateName(String name) { @@ -168,9 +84,4 @@ public class Profile { name.matches("\\w+") && name.toLowerCase().equals(name); } - - @Override - public String toString() { - return name; - } } diff --git a/src/main/java/com/andrewlalis/perfin/model/ProfileLoader.java b/src/main/java/com/andrewlalis/perfin/model/ProfileLoader.java new file mode 100644 index 0000000..b1f6a4e --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/model/ProfileLoader.java @@ -0,0 +1,116 @@ +package com.andrewlalis.perfin.model; + +import com.andrewlalis.perfin.PerfinApp; +import com.andrewlalis.perfin.control.Popups; +import com.andrewlalis.perfin.data.DataSourceFactory; +import com.andrewlalis.perfin.data.ProfileLoadException; +import com.andrewlalis.perfin.data.impl.migration.Migrations; +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; + +/** + * Component responsible for loading a profile from storage, as well as some + * other basic tasks concerning the set of stored profiles. + */ +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); + } + try { + DataSourceFactory.SchemaStatus status = dataSourceFactory.getSchemaStatus(name); + if (status == DataSourceFactory.SchemaStatus.NEEDS_MIGRATION) { + boolean confirm = Popups.confirm(window, "The profile \"" + name + "\" has an outdated data schema and needs to be migrated to the latest version. Is this okay?"); + if (!confirm) { + int existingSchemaVersion = dataSourceFactory.getSchemaVersion(name); + String compatibleVersion = Migrations.getLatestCompatibleVersion(existingSchemaVersion); + Popups.message( + window, + "The profile \"" + name + "\" is using schema version " + existingSchemaVersion + ", which is compatible with Perfin version " + compatibleVersion + ". Consider downgrading Perfin to access this profile safely." + ); + throw new ProfileLoadException("User rejected the migration."); + } + } else if (status == DataSourceFactory.SchemaStatus.INCOMPATIBLE) { + Popups.error(window, "The profile \"" + name + "\" has a data schema that's incompatible with this app. Update Perfin to access this profile safely."); + throw new ProfileLoadException("Incompatible schema version."); + } + } catch (IOException e) { + throw new ProfileLoadException("Failed to get profile's schema status.", e); + } + return new Profile(name, settings, dataSourceFactory.getDataSource(name)); + } + + public static List 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")); + } +} diff --git a/src/main/java/com/andrewlalis/perfin/model/Transaction.java b/src/main/java/com/andrewlalis/perfin/model/Transaction.java index 8f179ca..92588d8 100644 --- a/src/main/java/com/andrewlalis/perfin/model/Transaction.java +++ b/src/main/java/com/andrewlalis/perfin/model/Transaction.java @@ -17,13 +17,17 @@ public class Transaction extends IdEntity { private final BigDecimal amount; private final Currency currency; private final String description; + private final Long vendorId; + private final Long categoryId; - public Transaction(long id, LocalDateTime timestamp, BigDecimal amount, Currency currency, String description) { + public Transaction(long id, LocalDateTime timestamp, BigDecimal amount, Currency currency, String description, Long vendorId, Long categoryId) { super(id); this.timestamp = timestamp; this.amount = amount; this.currency = currency; this.description = description; + this.vendorId = vendorId; + this.categoryId = categoryId; } public LocalDateTime getTimestamp() { @@ -42,6 +46,14 @@ public class Transaction extends IdEntity { return description; } + public Long getVendorId() { + return vendorId; + } + + public Long getCategoryId() { + return categoryId; + } + public MoneyValue getMoneyAmount() { return new MoneyValue(amount, currency); } diff --git a/src/main/java/com/andrewlalis/perfin/model/TransactionCategory.java b/src/main/java/com/andrewlalis/perfin/model/TransactionCategory.java new file mode 100644 index 0000000..e18d86f --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/model/TransactionCategory.java @@ -0,0 +1,35 @@ +package com.andrewlalis.perfin.model; + +import javafx.scene.paint.Color; + +public class TransactionCategory extends IdEntity { + public static final int NAME_MAX_LENGTH = 63; + + private final Long parentId; + private final String name; + private final Color color; + + public TransactionCategory(long id, Long parentId, String name, Color color) { + super(id); + this.parentId = parentId; + this.name = name; + this.color = color; + } + + public Long getParentId() { + return parentId; + } + + public String getName() { + return name; + } + + public Color getColor() { + return color; + } + + @Override + public String toString() { + return name; + } +} diff --git a/src/main/java/com/andrewlalis/perfin/model/TransactionLineItem.java b/src/main/java/com/andrewlalis/perfin/model/TransactionLineItem.java new file mode 100644 index 0000000..fa58745 --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/model/TransactionLineItem.java @@ -0,0 +1,65 @@ +package com.andrewlalis.perfin.model; + +import java.math.BigDecimal; + +/** + * A line item that comprises part of a transaction. Its total value (value per + * item * quantity) is part of the transaction's total value. It can be used to + * record some transactions, like purchases and invoices, in more granular + * detail. + */ +public class TransactionLineItem extends IdEntity { + public static final int DESCRIPTION_MAX_LENGTH = 255; + + private final long transactionId; + private final BigDecimal valuePerItem; + private final int quantity; + private final int idx; + private final String description; + + public TransactionLineItem(long id, long transactionId, BigDecimal valuePerItem, int quantity, int idx, String description) { + super(id); + this.transactionId = transactionId; + this.valuePerItem = valuePerItem; + this.quantity = quantity; + this.idx = idx; + this.description = description; + } + + public long getTransactionId() { + return transactionId; + } + + public BigDecimal getValuePerItem() { + return valuePerItem; + } + + public int getQuantity() { + return quantity; + } + + public int getIdx() { + return idx; + } + + public String getDescription() { + return description; + } + + public BigDecimal getTotalValue() { + return valuePerItem.multiply(new BigDecimal(quantity)); + } + + @Override + public String toString() { + return String.format( + "TransactionLineItem(id=%d, transactionId=%d, valuePerItem=%s, quantity=%d, idx=%d, description=\"%s\")", + id, + transactionId, + valuePerItem.toPlainString(), + quantity, + idx, + description + ); + } +} diff --git a/src/main/java/com/andrewlalis/perfin/model/TransactionTag.java b/src/main/java/com/andrewlalis/perfin/model/TransactionTag.java new file mode 100644 index 0000000..2dfd29f --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/model/TransactionTag.java @@ -0,0 +1,19 @@ +package com.andrewlalis.perfin.model; + +/** + * A tag that can be applied to a transaction to add some user-defined semantic + * meaning to it. + */ +public class TransactionTag extends IdEntity { + public static final int NAME_MAX_LENGTH = 63; + private final String name; + + public TransactionTag(long id, String name) { + super(id); + this.name = name; + } + + public String getName() { + return name; + } +} diff --git a/src/main/java/com/andrewlalis/perfin/model/TransactionVendor.java b/src/main/java/com/andrewlalis/perfin/model/TransactionVendor.java new file mode 100644 index 0000000..af4130b --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/model/TransactionVendor.java @@ -0,0 +1,32 @@ +package com.andrewlalis.perfin.model; + +/** + * A vendor is a business establishment that can be linked to a transaction, to + * denote the business that the transaction took place with. + */ +public class TransactionVendor extends IdEntity { + public static final int NAME_MAX_LENGTH = 255; + public static final int DESCRIPTION_MAX_LENGTH = 255; + + private final String name; + private final String description; + + public TransactionVendor(long id, String name, String description) { + super(id); + this.name = name; + this.description = description; + } + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } + + @Override + public String toString() { + return name; + } +} diff --git a/src/main/java/com/andrewlalis/perfin/model/history/AccountHistoryItem.java b/src/main/java/com/andrewlalis/perfin/model/history/AccountHistoryItem.java deleted file mode 100644 index 565452e..0000000 --- a/src/main/java/com/andrewlalis/perfin/model/history/AccountHistoryItem.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.andrewlalis.perfin.model.history; - -import com.andrewlalis.perfin.model.IdEntity; - -import java.time.LocalDateTime; - -/** - * The base class representing account history items, a read-only record of an - * account's data and changes over time. The type of history item determines - * what exactly it means, and could be something like an account entry, balance - * record, or modifications to the account's properties. - */ -public class AccountHistoryItem extends IdEntity { - private final LocalDateTime timestamp; - private final long accountId; - private final AccountHistoryItemType type; - - public AccountHistoryItem(long id, LocalDateTime timestamp, long accountId, AccountHistoryItemType type) { - super(id); - this.timestamp = timestamp; - this.accountId = accountId; - this.type = type; - } - - public LocalDateTime getTimestamp() { - return timestamp; - } - - public long getAccountId() { - return accountId; - } - - public AccountHistoryItemType getType() { - return type; - } -} diff --git a/src/main/java/com/andrewlalis/perfin/model/history/AccountHistoryItemType.java b/src/main/java/com/andrewlalis/perfin/model/history/AccountHistoryItemType.java deleted file mode 100644 index eeeac1d..0000000 --- a/src/main/java/com/andrewlalis/perfin/model/history/AccountHistoryItemType.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.andrewlalis.perfin.model.history; - -public enum AccountHistoryItemType { - TEXT, - ACCOUNT_ENTRY, - BALANCE_RECORD -} diff --git a/src/main/java/com/andrewlalis/perfin/model/history/HistoryItem.java b/src/main/java/com/andrewlalis/perfin/model/history/HistoryItem.java new file mode 100644 index 0000000..7e0d7b7 --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/model/history/HistoryItem.java @@ -0,0 +1,36 @@ +package com.andrewlalis.perfin.model.history; + +import com.andrewlalis.perfin.model.IdEntity; + +import java.time.LocalDateTime; + +/** + * Represents a single polymorphic history item. The item's "type" attribute + * tells where to find additional type-specific data. + */ +public abstract class HistoryItem extends IdEntity { + public static final String TYPE_TEXT = "TEXT"; + + private final long historyId; + private final LocalDateTime timestamp; + private final String type; + + public HistoryItem(long id, long historyId, LocalDateTime timestamp, String type) { + super(id); + this.historyId = historyId; + this.timestamp = timestamp; + this.type = type; + } + + public long getHistoryId() { + return historyId; + } + + public LocalDateTime getTimestamp() { + return timestamp; + } + + public String getType() { + return type; + } +} diff --git a/src/main/java/com/andrewlalis/perfin/model/history/HistoryTextItem.java b/src/main/java/com/andrewlalis/perfin/model/history/HistoryTextItem.java new file mode 100644 index 0000000..d325286 --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/model/history/HistoryTextItem.java @@ -0,0 +1,16 @@ +package com.andrewlalis.perfin.model.history; + +import java.time.LocalDateTime; + +public class HistoryTextItem extends HistoryItem { + private final String description; + + public HistoryTextItem(long id, long historyId, LocalDateTime timestamp, String description) { + super(id, historyId, timestamp, HistoryItem.TYPE_TEXT); + this.description = description; + } + + public String getDescription() { + return description; + } +} diff --git a/src/main/java/com/andrewlalis/perfin/view/BindingUtil.java b/src/main/java/com/andrewlalis/perfin/view/BindingUtil.java index 6a233c5..f3d6389 100644 --- a/src/main/java/com/andrewlalis/perfin/view/BindingUtil.java +++ b/src/main/java/com/andrewlalis/perfin/view/BindingUtil.java @@ -1,8 +1,10 @@ package com.andrewlalis.perfin.view; import javafx.beans.WeakListener; +import javafx.beans.value.ObservableValue; import javafx.collections.ListChangeListener; import javafx.collections.ObservableList; +import javafx.scene.Node; import java.lang.ref.WeakReference; import java.util.List; @@ -86,4 +88,9 @@ public class BindingUtil { return false; } } + + public static void bindManagedAndVisible(Node node, ObservableValue value) { + node.managedProperty().bind(node.visibleProperty()); + node.visibleProperty().bind(value); + } } diff --git a/src/main/java/com/andrewlalis/perfin/view/StartupSplashScreen.java b/src/main/java/com/andrewlalis/perfin/view/StartupSplashScreen.java index 526951d..e78fb82 100644 --- a/src/main/java/com/andrewlalis/perfin/view/StartupSplashScreen.java +++ b/src/main/java/com/andrewlalis/perfin/view/StartupSplashScreen.java @@ -9,6 +9,7 @@ import javafx.stage.Stage; import javafx.stage.StageStyle; import java.util.List; +import java.util.concurrent.CompletableFuture; import java.util.function.Consumer; /** @@ -17,12 +18,14 @@ import java.util.function.Consumer; */ public class StartupSplashScreen extends Stage implements Consumer { private final List>> tasks; + private final boolean delayTasks; private boolean startupSuccessful = false; private final TextArea textArea = new TextArea(); - public StartupSplashScreen(List>> tasks) { + public StartupSplashScreen(List>> tasks, boolean delayTasks) { this.tasks = tasks; + this.delayTasks = delayTasks; setTitle("Starting Perfin..."); setResizable(false); initStyle(StageStyle.UNDECORATED); @@ -60,37 +63,50 @@ public class StartupSplashScreen extends Stage implements Consumer { return scene; } + /** + * Runs all tasks sequentially, invoking each one on the JavaFX main thread, + * and quitting if there's any exception thrown. + */ private void runTasks() { Thread.ofVirtual().start(() -> { - try { - Thread.sleep(1000); - } catch (InterruptedException e) { - throw new RuntimeException(e); - } + if (delayTasks) sleepOrThrowRE(1000); for (var task : tasks) { try { - task.accept(this); - Thread.sleep(500); + CompletableFuture future = new CompletableFuture<>(); + Platform.runLater(() -> { + try { + task.accept(this); + future.complete(null); + } catch (Exception e) { + future.completeExceptionally(e); + } + }); + future.join(); + if (delayTasks) sleepOrThrowRE(500); } catch (Exception e) { accept("Startup failed: " + e.getMessage()); e.printStackTrace(System.err); - try { - Thread.sleep(5000); - } catch (InterruptedException ex) { - throw new RuntimeException(ex); - } + sleepOrThrowRE(5000); Platform.runLater(this::close); return; } } accept("Startup successful!"); - try { - Thread.sleep(1000); - } catch (InterruptedException e) { - throw new RuntimeException(e); - } + if (delayTasks) sleepOrThrowRE(1000); startupSuccessful = true; Platform.runLater(this::close); }); } + + /** + * Helper method to sleep the current thread or throw a runtime exception. + * @param ms The number of milliseconds to sleep for. + */ + private static void sleepOrThrowRE(long ms) { + try { + Thread.sleep(ms); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } } diff --git a/src/main/java/com/andrewlalis/perfin/view/component/AccountHistoryAccountEntryTile.java b/src/main/java/com/andrewlalis/perfin/view/component/AccountHistoryAccountEntryTile.java deleted file mode 100644 index d78d42c..0000000 --- a/src/main/java/com/andrewlalis/perfin/view/component/AccountHistoryAccountEntryTile.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.andrewlalis.perfin.view.component; - -import com.andrewlalis.perfin.control.TransactionsViewController; -import com.andrewlalis.perfin.data.AccountHistoryItemRepository; -import com.andrewlalis.perfin.data.util.CurrencyUtil; -import com.andrewlalis.perfin.model.AccountEntry; -import com.andrewlalis.perfin.model.history.AccountHistoryItem; -import javafx.scene.control.Hyperlink; -import javafx.scene.text.Text; -import javafx.scene.text.TextFlow; - -import static com.andrewlalis.perfin.PerfinApp.router; - -public class AccountHistoryAccountEntryTile extends AccountHistoryItemTile { - public AccountHistoryAccountEntryTile(AccountHistoryItem item, AccountHistoryItemRepository repo) { - super(item); - AccountEntry entry = repo.getAccountEntryItem(item.id); - if (entry == null) { - setCenter(new TextFlow(new Text("Deleted account entry because of deleted transaction."))); - return; - } - - Text amountText = new Text(CurrencyUtil.formatMoneyWithCurrencyPrefix(entry.getMoneyValue())); - Hyperlink transactionLink = new Hyperlink("Transaction #" + entry.getTransactionId()); - transactionLink.setOnAction(event -> router.navigate( - "transactions", - new TransactionsViewController.RouteContext(entry.getTransactionId()) - )); - var text = new TextFlow( - transactionLink, - new Text("posted as a " + entry.getType().name().toLowerCase() + " to this account, with a value of "), - amountText - ); - setCenter(text); - } -} diff --git a/src/main/java/com/andrewlalis/perfin/view/component/AccountHistoryBalanceRecordTile.java b/src/main/java/com/andrewlalis/perfin/view/component/AccountHistoryBalanceRecordTile.java deleted file mode 100644 index b263498..0000000 --- a/src/main/java/com/andrewlalis/perfin/view/component/AccountHistoryBalanceRecordTile.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.andrewlalis.perfin.view.component; - -import com.andrewlalis.perfin.control.AccountViewController; -import com.andrewlalis.perfin.data.AccountHistoryItemRepository; -import com.andrewlalis.perfin.data.util.CurrencyUtil; -import com.andrewlalis.perfin.model.BalanceRecord; -import com.andrewlalis.perfin.model.history.AccountHistoryItem; -import javafx.scene.control.Hyperlink; -import javafx.scene.text.Text; -import javafx.scene.text.TextFlow; - -import static com.andrewlalis.perfin.PerfinApp.router; - -public class AccountHistoryBalanceRecordTile extends AccountHistoryItemTile { - public AccountHistoryBalanceRecordTile(AccountHistoryItem item, AccountHistoryItemRepository repo, AccountViewController controller) { - super(item); - BalanceRecord balanceRecord = repo.getBalanceRecordItem(item.id); - if (balanceRecord == null) { - setCenter(new TextFlow(new Text("Deleted balance record was added."))); - return; - } - - Text amountText = new Text(CurrencyUtil.formatMoneyWithCurrencyPrefix(balanceRecord.getMoneyAmount())); - var text = new TextFlow(new Text("Balance record #" + balanceRecord.id + " added with value of "), amountText); - setCenter(text); - - Hyperlink viewLink = new Hyperlink("View this balance record"); - viewLink.setOnAction(event -> router.navigate("balance-record", balanceRecord)); - setBottom(viewLink); - } -} diff --git a/src/main/java/com/andrewlalis/perfin/view/component/AccountHistoryItemTile.java b/src/main/java/com/andrewlalis/perfin/view/component/AccountHistoryItemTile.java index 9704d04..853bb7b 100644 --- a/src/main/java/com/andrewlalis/perfin/view/component/AccountHistoryItemTile.java +++ b/src/main/java/com/andrewlalis/perfin/view/component/AccountHistoryItemTile.java @@ -1,9 +1,8 @@ package com.andrewlalis.perfin.view.component; -import com.andrewlalis.perfin.control.AccountViewController; -import com.andrewlalis.perfin.data.AccountHistoryItemRepository; import com.andrewlalis.perfin.data.util.DateUtil; -import com.andrewlalis.perfin.model.history.AccountHistoryItem; +import com.andrewlalis.perfin.model.history.HistoryItem; +import com.andrewlalis.perfin.model.history.HistoryTextItem; import javafx.scene.control.Label; import javafx.scene.layout.BorderPane; @@ -11,7 +10,7 @@ import javafx.scene.layout.BorderPane; * A tile that shows a brief bit of information about an account history item. */ public abstract class AccountHistoryItemTile extends BorderPane { - public AccountHistoryItemTile(AccountHistoryItem item) { + public AccountHistoryItemTile(HistoryItem item) { getStyleClass().add("tile"); Label timestampLabel = new Label(DateUtil.formatUTCAsLocalWithZone(item.getTimestamp())); @@ -20,14 +19,11 @@ public abstract class AccountHistoryItemTile extends BorderPane { } public static AccountHistoryItemTile forItem( - AccountHistoryItem item, - AccountHistoryItemRepository repo, - AccountViewController controller + HistoryItem item ) { - return switch (item.getType()) { - case TEXT -> new AccountHistoryTextTile(item, repo); - case ACCOUNT_ENTRY -> new AccountHistoryAccountEntryTile(item, repo); - case BALANCE_RECORD -> new AccountHistoryBalanceRecordTile(item, repo, controller); - }; + if (item instanceof HistoryTextItem t) { + return new AccountHistoryTextTile(t); + } + throw new RuntimeException("Unsupported history item type: " + item.getType()); } } diff --git a/src/main/java/com/andrewlalis/perfin/view/component/AccountHistoryTextTile.java b/src/main/java/com/andrewlalis/perfin/view/component/AccountHistoryTextTile.java index 22f6c7d..2c7b8a2 100644 --- a/src/main/java/com/andrewlalis/perfin/view/component/AccountHistoryTextTile.java +++ b/src/main/java/com/andrewlalis/perfin/view/component/AccountHistoryTextTile.java @@ -1,14 +1,12 @@ package com.andrewlalis.perfin.view.component; -import com.andrewlalis.perfin.data.AccountHistoryItemRepository; -import com.andrewlalis.perfin.model.history.AccountHistoryItem; +import com.andrewlalis.perfin.model.history.HistoryTextItem; import javafx.scene.text.Text; import javafx.scene.text.TextFlow; public class AccountHistoryTextTile extends AccountHistoryItemTile { - public AccountHistoryTextTile(AccountHistoryItem item, AccountHistoryItemRepository repo) { + public AccountHistoryTextTile(HistoryTextItem item) { super(item); - String text = repo.getTextItem(item.id); - setCenter(new TextFlow(new Text(text))); + setCenter(new TextFlow(new Text(item.getDescription()))); } } diff --git a/src/main/java/com/andrewlalis/perfin/view/component/AccountSelectionBox.java b/src/main/java/com/andrewlalis/perfin/view/component/AccountSelectionBox.java index 2b38968..eacddea 100644 --- a/src/main/java/com/andrewlalis/perfin/view/component/AccountSelectionBox.java +++ b/src/main/java/com/andrewlalis/perfin/view/component/AccountSelectionBox.java @@ -72,19 +72,21 @@ public class AccountSelectionBox extends ComboBox { showBalanceProperty.set(value); } - private static class CellFactory implements Callback, ListCell> { - private final BooleanProperty showBalanceProp; - - private CellFactory(BooleanProperty showBalanceProp) { - this.showBalanceProp = showBalanceProp; - } - + /** + * A simple cell factory that just returns instances of {@link AccountListCell}. + * @param showBalanceProp Whether to show the account's balance. + */ + private record CellFactory(BooleanProperty showBalanceProp) implements Callback, ListCell> { @Override public ListCell call(ListView param) { return new AccountListCell(showBalanceProp); } } + /** + * A list cell implementation which shows an account's name, and optionally, + * its current derived balance underneath. + */ private static class AccountListCell extends ListCell { private final BooleanProperty showBalanceProp; private final Label nameLabel = new Label(); @@ -110,7 +112,7 @@ public class AccountSelectionBox extends ComboBox { 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()))); diff --git a/src/main/java/com/andrewlalis/perfin/view/component/AccountTile.java b/src/main/java/com/andrewlalis/perfin/view/component/AccountTile.java index ee4a415..1787078 100644 --- a/src/main/java/com/andrewlalis/perfin/view/component/AccountTile.java +++ b/src/main/java/com/andrewlalis/perfin/view/component/AccountTile.java @@ -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(() -> { diff --git a/src/main/java/com/andrewlalis/perfin/view/component/AttachmentPreview.java b/src/main/java/com/andrewlalis/perfin/view/component/AttachmentPreview.java index 2e986d0..094caeb 100644 --- a/src/main/java/com/andrewlalis/perfin/view/component/AttachmentPreview.java +++ b/src/main/java/com/andrewlalis/perfin/view/component/AttachmentPreview.java @@ -46,7 +46,7 @@ public class AttachmentPreview extends BorderPane { boolean showDocIcon = true; Set 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()); } }); diff --git a/src/main/java/com/andrewlalis/perfin/view/component/CategorySelectionBox.java b/src/main/java/com/andrewlalis/perfin/view/component/CategorySelectionBox.java new file mode 100644 index 0000000..3a60753 --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/view/component/CategorySelectionBox.java @@ -0,0 +1,81 @@ +package com.andrewlalis.perfin.view.component; + +import com.andrewlalis.perfin.data.TransactionCategoryRepository; +import com.andrewlalis.perfin.model.TransactionCategory; +import javafx.geometry.Insets; +import javafx.scene.control.ComboBox; +import javafx.scene.control.Label; +import javafx.scene.control.ListCell; +import javafx.scene.layout.HBox; +import javafx.scene.shape.Circle; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class CategorySelectionBox extends ComboBox { + private final Map categoryIndentationLevels = new HashMap<>(); + + public CategorySelectionBox() { + setCellFactory(view -> new CategoryListCell(categoryIndentationLevels)); + setButtonCell(new CategoryListCell(null)); + } + + public void loadCategories(List treeNodes) { + categoryIndentationLevels.clear(); + getItems().clear(); + populateCategories(treeNodes, 0); + getItems().add(null); + } + + private void populateCategories( + List treeNodes, + int depth + ) { + for (var node : treeNodes) { + getItems().add(node.category()); + categoryIndentationLevels.put(node.category(), depth); + populateCategories(node.children(), depth + 1); + } + } + + public void select(TransactionCategory category) { + setButtonCell(new CategoryListCell(null)); + getSelectionModel().select(category); + } + + private static class CategoryListCell extends ListCell { + private final Label nameLabel = new Label(); + private final Circle colorIndicator = new Circle(8); + private final Map categoryIndentationLevels; + + public CategoryListCell(Map categoryIndentationLevels) { + this.categoryIndentationLevels = categoryIndentationLevels; + nameLabel.getStyleClass().add("normal-color-text-fill"); + colorIndicator.managedProperty().bind(colorIndicator.visibleProperty()); + HBox container = new HBox(colorIndicator, nameLabel); + container.getStyleClass().add("std-spacing"); + setGraphic(container); + } + + @Override + protected void updateItem(TransactionCategory item, boolean empty) { + super.updateItem(item, empty); + if (item == null || empty) { + nameLabel.setText("None"); + colorIndicator.setVisible(false); + return; + } + + nameLabel.setText(item.getName()); + if (categoryIndentationLevels != null) { + HBox.setMargin( + colorIndicator, + new Insets(0, 0, 0, 10 * categoryIndentationLevels.getOrDefault(item, 0)) + ); + } + colorIndicator.setVisible(true); + colorIndicator.setFill(item.getColor()); + } + } +} diff --git a/src/main/java/com/andrewlalis/perfin/view/component/CategoryTile.java b/src/main/java/com/andrewlalis/perfin/view/component/CategoryTile.java new file mode 100644 index 0000000..73f4f2f --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/view/component/CategoryTile.java @@ -0,0 +1,65 @@ +package com.andrewlalis.perfin.view.component; + +import com.andrewlalis.perfin.control.EditCategoryController; +import com.andrewlalis.perfin.control.Popups; +import com.andrewlalis.perfin.data.TransactionCategoryRepository; +import com.andrewlalis.perfin.model.Profile; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.VBox; +import javafx.scene.shape.Circle; + +import static com.andrewlalis.perfin.PerfinApp.router; + +public class CategoryTile extends VBox { + public CategoryTile( + TransactionCategoryRepository.CategoryTreeNode treeNode, + Runnable categoriesRefresh + ) { + this.getStyleClass().addAll("tile", "spacing-extra", "hand-cursor"); + this.setStyle("-fx-border-width: 1px; -fx-border-color: grey;"); + this.setOnMouseClicked(event -> { + event.consume(); + router.navigate( + "edit-category", + new EditCategoryController.CategoryRouteContext(treeNode.category()) + ); + }); + + BorderPane borderPane = new BorderPane(); + borderPane.getStyleClass().addAll("std-padding"); + Label nameLabel = new Label(treeNode.category().getName()); + nameLabel.getStyleClass().addAll("bold-text"); + Circle colorCircle = new Circle(10, treeNode.category().getColor()); + HBox contentBox = new HBox(colorCircle, nameLabel); + contentBox.getStyleClass().addAll("std-spacing"); + borderPane.setLeft(contentBox); + + Button addChildButton = new Button("Add Subcategory"); + addChildButton.setOnAction(event -> router.navigate( + "edit-category", + new EditCategoryController.AddSubcategoryRouteContext(treeNode.category()) + )); + Button removeButton = new Button("Remove"); + removeButton.setOnAction(event -> { + boolean confirm = Popups.confirm(removeButton, "Are you sure you want to remove this category? It will permanently remove the category from all linked transactions, and all subcategories will also be removed. This cannot be undone."); + if (confirm) { + Profile.getCurrent().dataSource().useRepo( + TransactionCategoryRepository.class, + repo -> repo.deleteById(treeNode.category().id) + ); + categoriesRefresh.run(); + } + }); + HBox buttonsBox = new HBox(addChildButton, removeButton); + buttonsBox.getStyleClass().addAll("std-spacing"); + borderPane.setRight(buttonsBox); + + this.getChildren().add(borderPane); + for (var child : treeNode.children()) { + this.getChildren().add(new CategoryTile(child, categoriesRefresh)); + } + } +} diff --git a/src/main/java/com/andrewlalis/perfin/view/component/TransactionTile.java b/src/main/java/com/andrewlalis/perfin/view/component/TransactionTile.java index fd0ff44..993fd4b 100644 --- a/src/main/java/com/andrewlalis/perfin/view/component/TransactionTile.java +++ b/src/main/java/com/andrewlalis/perfin/view/component/TransactionTile.java @@ -100,7 +100,7 @@ public class TransactionTile extends BorderPane { } private CompletableFuture getCreditAndDebitAccounts(Transaction transaction) { - return Profile.getCurrent().getDataSource().mapRepoAsync( + return Profile.getCurrent().dataSource().mapRepoAsync( TransactionRepository.class, repo -> repo.findLinkedAccounts(transaction.id) ); diff --git a/src/main/java/com/andrewlalis/perfin/view/component/VendorTile.java b/src/main/java/com/andrewlalis/perfin/view/component/VendorTile.java new file mode 100644 index 0000000..9def0ce --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/view/component/VendorTile.java @@ -0,0 +1,46 @@ +package com.andrewlalis.perfin.view.component; + +import com.andrewlalis.perfin.control.Popups; +import com.andrewlalis.perfin.data.TransactionVendorRepository; +import com.andrewlalis.perfin.model.Profile; +import com.andrewlalis.perfin.model.TransactionVendor; +import javafx.geometry.Pos; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.VBox; + +import static com.andrewlalis.perfin.PerfinApp.router; + +public class VendorTile extends BorderPane { + public VendorTile(TransactionVendor vendor, Runnable vendorRefresh) { + this.getStyleClass().addAll("tile", "std-spacing", "hand-cursor"); + this.setOnMouseClicked(event -> router.navigate("edit-vendor", vendor)); + + Label nameLabel = new Label(vendor.getName()); + nameLabel.getStyleClass().addAll("bold-text"); + Label descriptionLabel = new Label(vendor.getDescription()); + descriptionLabel.setWrapText(true); + VBox contentVBox = new VBox(nameLabel, descriptionLabel); + contentVBox.getStyleClass().addAll("std-spacing"); + this.setCenter(contentVBox); + BorderPane.setAlignment(contentVBox, Pos.TOP_LEFT); + + this.setRight(getRemoveButton(vendor, vendorRefresh)); + } + + private Button getRemoveButton(TransactionVendor transactionVendor, Runnable vendorRefresh) { + Button removeButton = new Button("Remove"); + removeButton.setOnAction(event -> { + boolean confirm = Popups.confirm(removeButton, "Are you sure you want to remove this vendor? Any transactions assigned to this vendor will have their vendor field cleared. This cannot be undone."); + if (confirm) { + Profile.getCurrent().dataSource().useRepo( + TransactionVendorRepository.class, + repo -> repo.deleteById(transactionVendor.id) + ); + vendorRefresh.run(); + } + }); + return removeButton; + } +} diff --git a/src/main/java/com/andrewlalis/perfin/view/component/validation/AsyncValidationFunction.java b/src/main/java/com/andrewlalis/perfin/view/component/validation/AsyncValidationFunction.java new file mode 100644 index 0000000..e618f41 --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/view/component/validation/AsyncValidationFunction.java @@ -0,0 +1,7 @@ +package com.andrewlalis.perfin.view.component.validation; + +import java.util.concurrent.CompletableFuture; + +public interface AsyncValidationFunction { + CompletableFuture validate(T input); +} diff --git a/src/main/java/com/andrewlalis/perfin/view/component/validation/ValidationApplier.java b/src/main/java/com/andrewlalis/perfin/view/component/validation/ValidationApplier.java index 663affe..2ad5e78 100644 --- a/src/main/java/com/andrewlalis/perfin/view/component/validation/ValidationApplier.java +++ b/src/main/java/com/andrewlalis/perfin/view/component/validation/ValidationApplier.java @@ -1,24 +1,40 @@ package com.andrewlalis.perfin.view.component.validation; import com.andrewlalis.perfin.view.component.validation.decorators.FieldSubtextDecorator; +import javafx.application.Platform; import javafx.beans.binding.BooleanExpression; import javafx.beans.property.Property; +import javafx.beans.property.SimpleBooleanProperty; import javafx.scene.Node; import javafx.scene.control.TextField; +import java.util.concurrent.CompletableFuture; + /** * Fluent interface for applying a validator to one or more controls. * @param The value type. */ public class ValidationApplier { - private final ValidationFunction validator; + private final AsyncValidationFunction validator; private ValidationDecorator decorator = new FieldSubtextDecorator(); private boolean validateInitially = false; public ValidationApplier(ValidationFunction validator) { + this.validator = input -> CompletableFuture.completedFuture(validator.validate(input)); + } + + public ValidationApplier(AsyncValidationFunction validator) { this.validator = validator; } + public static ValidationApplier of(ValidationFunction validator) { + return new ValidationApplier<>(validator); + } + + public static ValidationApplier ofAsync(AsyncValidationFunction validator) { + return new ValidationApplier<>(validator); + } + public ValidationApplier decoratedWith(ValidationDecorator decorator) { this.decorator = decorator; return this; @@ -29,24 +45,47 @@ public class ValidationApplier { return this; } + /** + * Attaches the configured validator and decorator to a node, so that when + * the node's specified valueProperty changes, the validator will be called + * and if the new value is invalid, the decorator will update the UI to + * show the message(s) to the user. + * @param node The node to attach to. + * @param valueProperty The property to listen for changes and validate on. + * @param triggerProperties Additional properties that, when changed, can + * trigger validation. + * @return A boolean expression that tells whether the given valueProperty + * is valid at any given time. + */ public BooleanExpression attach(Node node, Property valueProperty, Property... triggerProperties) { - BooleanExpression validProperty = BooleanExpression.booleanExpression( - valueProperty.map(value -> validator.validate(value).isValid()) - ); + final SimpleBooleanProperty validProperty = new SimpleBooleanProperty(); valueProperty.addListener((observable, oldValue, newValue) -> { - ValidationResult result = validator.validate(newValue); - decorator.decorate(node, result); + validProperty.set(false); // Always set valid to false before we start validation. + validator.validate(newValue) + .thenAccept(result -> Platform.runLater(() -> { + validProperty.set(result.isValid()); + decorator.decorate(node, result); + })); }); for (Property influencingProperty : triggerProperties) { influencingProperty.addListener((observable, oldValue, newValue) -> { - ValidationResult result = validator.validate(valueProperty.getValue()); - decorator.decorate(node, result); + validProperty.set(false); // Always set valid to false before we start validation. + validator.validate(valueProperty.getValue()) + .thenAccept(result -> Platform.runLater(() -> { + validProperty.set(result.isValid()); + decorator.decorate(node, result); + })); }); } if (validateInitially) { // Call the decorator once to perform validation right away. - decorator.decorate(node, validator.validate(valueProperty.getValue())); + validProperty.set(false); // Always set valid to false before we start validation. + validator.validate(valueProperty.getValue()) + .thenAccept(result -> Platform.runLater(() -> { + validProperty.set(result.isValid()); + decorator.decorate(node, result); + })); } return validProperty; } diff --git a/src/main/java/com/andrewlalis/perfin/view/component/validation/decorators/FieldSubtextDecorator.java b/src/main/java/com/andrewlalis/perfin/view/component/validation/decorators/FieldSubtextDecorator.java index 14f41ef..d386211 100644 --- a/src/main/java/com/andrewlalis/perfin/view/component/validation/decorators/FieldSubtextDecorator.java +++ b/src/main/java/com/andrewlalis/perfin/view/component/validation/decorators/FieldSubtextDecorator.java @@ -4,6 +4,7 @@ import com.andrewlalis.perfin.view.component.validation.ValidationDecorator; import com.andrewlalis.perfin.view.component.validation.ValidationResult; import javafx.scene.Node; import javafx.scene.control.Label; +import javafx.scene.layout.HBox; import javafx.scene.layout.Pane; import javafx.scene.layout.VBox; import org.slf4j.Logger; @@ -55,6 +56,9 @@ public class FieldSubtextDecorator implements ValidationDecorator { errorLabel.getStyleClass().addAll("small-font", "negative-color-text-fill"); errorLabel.setWrapText(true); VBox validationContainer = new VBox(node, errorLabel); + if (trueParent instanceof HBox) { + HBox.setHgrow(validationContainer, HBox.getHgrow(node)); + } validationContainer.setUserData(WRAP_KEY); trueParent.getChildren().add(idx, validationContainer); return errorLabel; diff --git a/src/main/java/com/andrewlalis/perfin/view/component/validation/validators/PredicateValidator.java b/src/main/java/com/andrewlalis/perfin/view/component/validation/validators/PredicateValidator.java index 51c73a5..6ac94d0 100644 --- a/src/main/java/com/andrewlalis/perfin/view/component/validation/validators/PredicateValidator.java +++ b/src/main/java/com/andrewlalis/perfin/view/component/validation/validators/PredicateValidator.java @@ -1,10 +1,14 @@ package com.andrewlalis.perfin.view.component.validation.validators; -import com.andrewlalis.perfin.view.component.validation.ValidationFunction; +import com.andrewlalis.perfin.view.component.validation.AsyncValidationFunction; import com.andrewlalis.perfin.view.component.validation.ValidationResult; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.util.ArrayList; import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; import java.util.function.Function; /** @@ -12,32 +16,73 @@ import java.util.function.Function; * determine if it's valid. If invalid, a message is added. * @param The value type. */ -public class PredicateValidator implements ValidationFunction { - private record ValidationStep(Function predicate, String message, boolean terminal) {} +public class PredicateValidator implements AsyncValidationFunction { + private static final Logger logger = LoggerFactory.getLogger(PredicateValidator.class); + + private record ValidationStep(Function> predicate, String message, boolean terminal) {} private final List> steps = new ArrayList<>(); - public PredicateValidator addPredicate(Function predicate, String errorMessage) { - steps.add(new ValidationStep<>(predicate, errorMessage, false)); + private PredicateValidator addPredicate(Function predicate, String errorMessage, boolean terminal) { + steps.add(new ValidationStep<>( + v -> CompletableFuture.completedFuture(predicate.apply(v)), + errorMessage, + terminal + )); return this; } - public PredicateValidator addTerminalPredicate(Function predicate, String errorMessage) { - steps.add(new ValidationStep<>(predicate, errorMessage, true)); + private PredicateValidator addAsyncPredicate(Function> asyncPredicate, String errorMessage, boolean terminal) { + steps.add(new ValidationStep<>(asyncPredicate, errorMessage, terminal)); return this; } + public PredicateValidator addPredicate(Function predicate, String errorMessage) { + return addPredicate(predicate, errorMessage, false); + } + + public PredicateValidator addAsyncPredicate(Function> asyncPredicate, String errorMessage) { + return addAsyncPredicate(asyncPredicate, errorMessage, false); + } + + /** + * Adds a terminal predicate, that is, if the given boolean function + * evaluates to false, then no further predicates are evaluated. + * @param predicate The predicate function. + * @param errorMessage The error message to display if the predicate + * evaluates to false for a given value. + * @return A reference to the validator, for method chaining. + */ + public PredicateValidator addTerminalPredicate(Function predicate, String errorMessage) { + return addPredicate(predicate, errorMessage, true); + } + + public PredicateValidator addTerminalAsyncPredicate(Function> asyncPredicate, String errorMessage) { + return addAsyncPredicate(asyncPredicate, errorMessage); + } + @Override - public ValidationResult validate(T input) { - List messages = new ArrayList<>(); - for (var step : steps) { - if (!step.predicate().apply(input)) { - messages.add(step.message()); - if (step.terminal()) { - return new ValidationResult(messages); + public CompletableFuture validate(T input) { + CompletableFuture cf = new CompletableFuture<>(); + Thread.ofVirtual().start(() -> { + List messages = new ArrayList<>(); + for (var step : steps) { + try { + boolean success = step.predicate().apply(input).get(); + if (!success) { + messages.add(step.message()); + if (step.terminal()) { + cf.complete(new ValidationResult(messages)); + return; // Exit if this is a terminal step and it failed. + } + } + } catch (InterruptedException | ExecutionException e) { + logger.error("Applying a predicate to input failed.", e); + cf.completeExceptionally(e); } } - } - return new ValidationResult(messages); + cf.complete(new ValidationResult(messages)); + }); + return cf; } } diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index 006df3f..766abce 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -19,4 +19,5 @@ module com.andrewlalis.perfin { opens com.andrewlalis.perfin.view to javafx.fxml; opens com.andrewlalis.perfin.view.component to javafx.fxml; opens com.andrewlalis.perfin.view.component.validation to javafx.fxml; + exports com.andrewlalis.perfin.model.history to javafx.graphics; } \ No newline at end of file diff --git a/src/main/resources/categories-view.fxml b/src/main/resources/categories-view.fxml new file mode 100644 index 0000000..c34b38f --- /dev/null +++ b/src/main/resources/categories-view.fxml @@ -0,0 +1,34 @@ + + + + + + + + + + + + +
+ + + Categories are used to group your transactions based on their + purpose. It's helpful to categorize transactions in order to get + a better view of your spending habits, and it makes it easier to + lookup transactions later. + + +
+
diff --git a/src/main/resources/create-balance-record.fxml b/src/main/resources/create-balance-record.fxml index 98683f4..e3544fd 100644 --- a/src/main/resources/create-balance-record.fxml +++ b/src/main/resources/create-balance-record.fxml @@ -55,7 +55,7 @@ - +