Repository: ccavanaugh/jgnash Branch: master Commit: e812bb777244 Files: 921 Total size: 5.4 MB Directory structure: gitextract_1exs9n_q/ ├── .github/ │ ├── ISSUE_TEMPLATE.md │ └── workflows/ │ ├── ci-linux.yml │ ├── ci-macOS.yml │ ├── ci-windows.yml │ └── gradle-wrapper-validation.yml ├── .gitignore ├── .travis.yml ├── COPYING ├── README.adoc ├── README.html ├── README.md ├── build.gradle.kts ├── changelog.adoc ├── deployfx/ │ └── gnome-money.icns ├── gradle/ │ └── wrapper/ │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradle.properties ├── gradlew ├── gradlew.bat ├── jGnash ├── jgnash-bayes/ │ ├── build.gradle.kts │ └── src/ │ └── main/ │ └── java/ │ └── jgnash/ │ └── bayes/ │ └── BayesClassifier.java ├── jgnash-convert/ │ ├── build.gradle.kts │ └── src/ │ └── main/ │ ├── java/ │ │ └── jgnash/ │ │ └── convert/ │ │ ├── common/ │ │ │ └── OfxTags.java │ │ ├── exportantur/ │ │ │ ├── csv/ │ │ │ │ └── CsvExport.java │ │ │ └── ofx/ │ │ │ └── OfxExport.java │ │ └── importat/ │ │ ├── BayesImportClassifier.java │ │ ├── DateFormat.java │ │ ├── GenericImport.java │ │ ├── ImportBank.java │ │ ├── ImportFilter.java │ │ ├── ImportSecurity.java │ │ ├── ImportState.java │ │ ├── ImportTransaction.java │ │ ├── ImportUtils.java │ │ ├── ofx/ │ │ │ ├── OfxBank.java │ │ │ ├── OfxImport.java │ │ │ ├── OfxV1ToV2.java │ │ │ ├── OfxV2Parser.java │ │ │ └── Sanitize.java │ │ └── qif/ │ │ ├── QifAccount.java │ │ ├── QifCategory.java │ │ ├── QifImport.java │ │ ├── QifParser.java │ │ ├── QifReader.java │ │ ├── QifSplitTransaction.java │ │ ├── QifTransaction.java │ │ └── QifUtils.java │ └── resources/ │ └── jgnash/ │ └── convert/ │ └── scripts/ │ └── tidy.js ├── jgnash-core/ │ ├── build.gradle.kts │ └── src/ │ └── main/ │ ├── java/ │ │ └── jgnash/ │ │ ├── engine/ │ │ │ ├── AbstractInvestmentTransactionEntry.java │ │ │ ├── Account.java │ │ │ ├── AccountGroup.java │ │ │ ├── AccountProxy.java │ │ │ ├── AccountTreeXMLFactory.java │ │ │ ├── AccountType.java │ │ │ ├── AccountUtils.java │ │ │ ├── AmortizeObject.java │ │ │ ├── AttachmentUtils.java │ │ │ ├── CashFlow.java │ │ │ ├── CommodityNode.java │ │ │ ├── Comparators.java │ │ │ ├── Config.java │ │ │ ├── CurrencyNode.java │ │ │ ├── DataStore.java │ │ │ ├── DataStoreType.java │ │ │ ├── DefaultCurrencies.java │ │ │ ├── Engine.java │ │ │ ├── EngineException.java │ │ │ ├── EngineFactory.java │ │ │ ├── ExchangeRate.java │ │ │ ├── ExchangeRateDAO.java │ │ │ ├── ExchangeRateHistoryNode.java │ │ │ ├── InvestmentAccountProxy.java │ │ │ ├── InvestmentPerformanceSummary.java │ │ │ ├── InvestmentTransaction.java │ │ │ ├── MathConstants.java │ │ │ ├── QuoteSource.java │ │ │ ├── RecTransaction.java │ │ │ ├── ReconcileManager.java │ │ │ ├── ReconciledState.java │ │ │ ├── RootAccount.java │ │ │ ├── SecurityHistoryEvent.java │ │ │ ├── SecurityHistoryEventType.java │ │ │ ├── SecurityHistoryNode.java │ │ │ ├── SecurityNode.java │ │ │ ├── StoredObject.java │ │ │ ├── StoredObjectComparator.java │ │ │ ├── Tag.java │ │ │ ├── Transaction.java │ │ │ ├── TransactionEntry.java │ │ │ ├── TransactionEntryAbstractIncrease.java │ │ │ ├── TransactionEntryAddX.java │ │ │ ├── TransactionEntryBuyX.java │ │ │ ├── TransactionEntryDividendX.java │ │ │ ├── TransactionEntryMergeX.java │ │ │ ├── TransactionEntryReinvestDivX.java │ │ │ ├── TransactionEntryRemoveX.java │ │ │ ├── TransactionEntryRocX.java │ │ │ ├── TransactionEntrySellX.java │ │ │ ├── TransactionEntrySplitX.java │ │ │ ├── TransactionFactory.java │ │ │ ├── TransactionTag.java │ │ │ ├── TransactionType.java │ │ │ ├── TrashObject.java │ │ │ ├── attachment/ │ │ │ │ ├── AttachmentManager.java │ │ │ │ ├── AttachmentTransferClient.java │ │ │ │ ├── AttachmentTransferServer.java │ │ │ │ ├── DistributedAttachmentManager.java │ │ │ │ ├── LocalAttachmentManager.java │ │ │ │ └── NettyTransferHandler.java │ │ │ ├── budget/ │ │ │ │ ├── Budget.java │ │ │ │ ├── BudgetFactory.java │ │ │ │ ├── BudgetGoal.java │ │ │ │ ├── BudgetPeriodDescriptor.java │ │ │ │ ├── BudgetPeriodDescriptorFactory.java │ │ │ │ ├── BudgetPeriodResults.java │ │ │ │ ├── BudgetResultsModel.java │ │ │ │ └── Pattern.java │ │ │ ├── concurrent/ │ │ │ │ ├── DistributedLockManager.java │ │ │ │ ├── DistributedLockServer.java │ │ │ │ ├── LocalLockManager.java │ │ │ │ ├── LockManager.java │ │ │ │ ├── Priority.java │ │ │ │ └── PriorityThreadPoolExecutor.java │ │ │ ├── dao/ │ │ │ │ ├── AbstractDAO.java │ │ │ │ ├── AccountDAO.java │ │ │ │ ├── BudgetDAO.java │ │ │ │ ├── CommodityDAO.java │ │ │ │ ├── ConfigDAO.java │ │ │ │ ├── DAO.java │ │ │ │ ├── EngineDAO.java │ │ │ │ ├── RecurringDAO.java │ │ │ │ ├── TagDAO.java │ │ │ │ ├── TransactionDAO.java │ │ │ │ └── TrashDAO.java │ │ │ ├── jpa/ │ │ │ │ ├── AbstractJpaDAO.java │ │ │ │ ├── AbstractJpaDataStore.java │ │ │ │ ├── JpaAccountDAO.java │ │ │ │ ├── JpaBudgetDAO.java │ │ │ │ ├── JpaCommodityDAO.java │ │ │ │ ├── JpaConfigDAO.java │ │ │ │ ├── JpaConfiguration.java │ │ │ │ ├── JpaEngineDAO.java │ │ │ │ ├── JpaH2DataStore.java │ │ │ │ ├── JpaH2MvDataStore.java │ │ │ │ ├── JpaHsqlDataStore.java │ │ │ │ ├── JpaNetworkServer.java │ │ │ │ ├── JpaRecurringDAO.java │ │ │ │ ├── JpaTagDAO.java │ │ │ │ ├── JpaTransactionDAO.java │ │ │ │ ├── JpaTrashDAO.java │ │ │ │ ├── JpaTrashEntity.java │ │ │ │ └── SqlUtils.java │ │ │ ├── message/ │ │ │ │ ├── ChannelEvent.java │ │ │ │ ├── LocalServerListener.java │ │ │ │ ├── Message.java │ │ │ │ ├── MessageBus.java │ │ │ │ ├── MessageBusClient.java │ │ │ │ ├── MessageBusServer.java │ │ │ │ ├── MessageChannel.java │ │ │ │ ├── MessageListener.java │ │ │ │ ├── MessageProperty.java │ │ │ │ ├── MessageProxy.java │ │ │ │ └── XStreamFactory.java │ │ │ ├── recurring/ │ │ │ │ ├── DailyReminder.java │ │ │ │ ├── MonthlyReminder.java │ │ │ │ ├── OneTimeReminder.java │ │ │ │ ├── PendingReminder.java │ │ │ │ ├── RecurringIterator.java │ │ │ │ ├── Reminder.java │ │ │ │ ├── ReminderType.java │ │ │ │ ├── WeeklyReminder.java │ │ │ │ └── YearlyReminder.java │ │ │ └── xstream/ │ │ │ ├── AbstractXStreamContainer.java │ │ │ ├── AbstractXStreamDAO.java │ │ │ ├── BinaryContainer.java │ │ │ ├── BinaryXStreamDataStore.java │ │ │ ├── StoredObjectReflectionProvider.java │ │ │ ├── XMLContainer.java │ │ │ ├── XMLDataStore.java │ │ │ ├── XStreamAccountDAO.java │ │ │ ├── XStreamBudgetDAO.java │ │ │ ├── XStreamCommodityDAO.java │ │ │ ├── XStreamConfigDAO.java │ │ │ ├── XStreamEngineDAO.java │ │ │ ├── XStreamJVM9.java │ │ │ ├── XStreamRecurringDAO.java │ │ │ ├── XStreamTagDAO.java │ │ │ ├── XStreamTransactionDAO.java │ │ │ └── XStreamTrashDAO.java │ │ ├── net/ │ │ │ ├── AbstractAuthenticator.java │ │ │ ├── ConnectionFactory.java │ │ │ ├── YahooCrumbManager.java │ │ │ ├── currency/ │ │ │ │ ├── CurrencyConverterParser.java │ │ │ │ ├── CurrencyParser.java │ │ │ │ └── CurrencyUpdateFactory.java │ │ │ └── security/ │ │ │ ├── NullParser.java │ │ │ ├── SecurityParser.java │ │ │ ├── UpdateFactory.java │ │ │ ├── YahooEventParser.java │ │ │ └── iex/ │ │ │ └── IEXParser.java │ │ ├── report/ │ │ │ ├── BalanceByMonthCSVReport.java │ │ │ ├── ProfitLossTextReport.java │ │ │ ├── ReportPeriod.java │ │ │ └── ReportPeriodUtils.java │ │ ├── text/ │ │ │ └── NumericFormats.java │ │ ├── time/ │ │ │ ├── DateUtils.java │ │ │ └── Period.java │ │ └── util/ │ │ ├── CollectionUtils.java │ │ ├── DefaultDaemonThreadFactory.java │ │ ├── EncodeDecode.java │ │ ├── EncryptionManager.java │ │ ├── FileLocker.java │ │ ├── FileMagic.java │ │ ├── FileUtils.java │ │ ├── LocaleObject.java │ │ ├── LockedCommodityNode.java │ │ ├── LogUtil.java │ │ ├── MathEval.java │ │ ├── MultiHashMap.java │ │ ├── NewFileUtility.java │ │ ├── NotNull.java │ │ ├── Nullable.java │ │ ├── SearchUtils.java │ │ ├── function/ │ │ │ ├── MemoPredicate.java │ │ │ ├── ParentAccountPredicate.java │ │ │ ├── PayeePredicate.java │ │ │ ├── ReconciledPredicate.java │ │ │ ├── TagPredicate.java │ │ │ └── TransactionAgePredicate.java │ │ └── prefs/ │ │ ├── MapBasedPreferences.java │ │ ├── MapPreferencesFactory.java │ │ └── PortablePreferences.java │ └── resources/ │ ├── META-INF/ │ │ └── persistence.xml │ └── logging.properties ├── jgnash-fx/ │ ├── build.gradle.kts │ ├── scripts/ │ │ ├── clean-security-history.js │ │ └── helloworld.js │ └── src/ │ ├── main/ │ │ ├── java/ │ │ │ └── jgnash/ │ │ │ ├── app/ │ │ │ │ ├── jGnash.java │ │ │ │ └── jGnashFx.java │ │ │ ├── bootloader/ │ │ │ │ ├── BootLoader.java │ │ │ │ └── BootLoaderDialog.java │ │ │ └── uifx/ │ │ │ ├── Options.java │ │ │ ├── StaticUIMethods.java │ │ │ ├── about/ │ │ │ │ └── AboutDialogController.java │ │ │ ├── actions/ │ │ │ │ ├── DatabasePathAction.java │ │ │ │ ├── DefaultCurrencyAction.java │ │ │ │ ├── DefaultLocaleAction.java │ │ │ │ ├── ExecuteJavaScriptAction.java │ │ │ │ ├── ExportAccountsAction.java │ │ │ │ ├── ImportAccountsAction.java │ │ │ │ ├── ImportOfxAction.java │ │ │ │ └── ImportQifAction.java │ │ │ ├── control/ │ │ │ │ ├── AbstractAccountTreeController.java │ │ │ │ ├── AccountComboBox.java │ │ │ │ ├── Alert.java │ │ │ │ ├── AutoCompleteTextField.java │ │ │ │ ├── BigDecimalTableCell.java │ │ │ │ ├── BusyPane.java │ │ │ │ ├── CheckComboBox.java │ │ │ │ ├── CheckListView.java │ │ │ │ ├── ChoiceDialog.java │ │ │ │ ├── CurrencyComboBox.java │ │ │ │ ├── DataStoreTypeComboBox.java │ │ │ │ ├── DatePickerEx.java │ │ │ │ ├── DateRangeDialogController.java │ │ │ │ ├── DecimalTextField.java │ │ │ │ ├── DetailedDecimalTextField.java │ │ │ │ ├── DoughnutChart.java │ │ │ │ ├── ExceptionDialog.java │ │ │ │ ├── ImageDialog.java │ │ │ │ ├── IntegerTextField.java │ │ │ │ ├── IntegerTreeTableCell.java │ │ │ │ ├── LockedCommodityListCell.java │ │ │ │ ├── NullTableViewSelectionModel.java │ │ │ │ ├── PopOverButton.java │ │ │ │ ├── QuoteSourceComboBox.java │ │ │ │ ├── SecurityComboBox.java │ │ │ │ ├── SecurityHistoryEventTypeComboBox.java │ │ │ │ ├── SecurityNodeAreaChart.java │ │ │ │ ├── ShortDateTableCell.java │ │ │ │ ├── StatusBar.java │ │ │ │ ├── TabViewPane.java │ │ │ │ ├── TableViewEx.java │ │ │ │ ├── TextFieldEx.java │ │ │ │ ├── TextInputDialog.java │ │ │ │ ├── TimePeriodComboBox.java │ │ │ │ ├── TransactionNumberComboBox.java │ │ │ │ ├── autocomplete/ │ │ │ │ │ ├── AutoCompleteFactory.java │ │ │ │ │ ├── AutoCompleteModel.java │ │ │ │ │ └── DefaultAutoCompleteModel.java │ │ │ │ └── wizard/ │ │ │ │ ├── AbstractWizardPaneController.java │ │ │ │ ├── WizardDescriptor.java │ │ │ │ ├── WizardDialogController.java │ │ │ │ └── WizardPaneController.java │ │ │ ├── dialog/ │ │ │ │ ├── ChangeDatabasePasswordDialogController.java │ │ │ │ ├── ImportScriptsDialogController.java │ │ │ │ ├── PackDatabaseDialogController.java │ │ │ │ ├── RemoteConnectionDialogController.java │ │ │ │ ├── TagManagerDialogController.java │ │ │ │ ├── currency/ │ │ │ │ │ ├── AddRemoveCurrencyController.java │ │ │ │ │ ├── EditExchangeRatesController.java │ │ │ │ │ └── ModifyCurrencyController.java │ │ │ │ ├── options/ │ │ │ │ │ ├── AccountTabController.java │ │ │ │ │ ├── DataProviderTabController.java │ │ │ │ │ ├── FormatsTabController.java │ │ │ │ │ ├── GeneralTabController.java │ │ │ │ │ ├── NetworkTabController.java │ │ │ │ │ ├── OptionDialogController.java │ │ │ │ │ ├── RegisterTabController.java │ │ │ │ │ ├── RemindersTabController.java │ │ │ │ │ ├── ReportTabController.java │ │ │ │ │ ├── StartupShutdownTabController.java │ │ │ │ │ └── TransactionNumberDialogController.java │ │ │ │ └── security/ │ │ │ │ ├── CreateModifySecuritiesController.java │ │ │ │ ├── HistoricalImportController.java │ │ │ │ └── SecurityHistoryController.java │ │ │ ├── net/ │ │ │ │ └── NetworkAuthenticator.java │ │ │ ├── report/ │ │ │ │ ├── AbstractSumByTypeReport.java │ │ │ │ ├── AccountBalanceChartController.java │ │ │ │ ├── AccountRegisterReport.java │ │ │ │ ├── AccountRegisterReportController.java │ │ │ │ ├── BalanceByMonthOptionsDialogController.java │ │ │ │ ├── BalanceSheetReport.java │ │ │ │ ├── BalanceSheetReportController.java │ │ │ │ ├── ChartUtilities.java │ │ │ │ ├── IncomeExpenseBarChartDialogController.java │ │ │ │ ├── IncomeExpensePayeePieChartDialogController.java │ │ │ │ ├── IncomeExpensePieChartDialogController.java │ │ │ │ ├── ListOfAccountsReport.java │ │ │ │ ├── ListOfAccountsReportController.java │ │ │ │ ├── NetWorthReport.java │ │ │ │ ├── NetWorthReportController.java │ │ │ │ ├── PortfolioReport.java │ │ │ │ ├── PortfolioReportController.java │ │ │ │ ├── ProfitLossReport.java │ │ │ │ ├── ProfitLossReportController.java │ │ │ │ ├── ReportActions.java │ │ │ │ ├── TransactionTagPieChartDialogController.java │ │ │ │ └── pdf/ │ │ │ │ ├── PageFormatDialogController.java │ │ │ │ ├── ReportController.java │ │ │ │ └── ReportViewerDialogController.java │ │ │ ├── resource/ │ │ │ │ ├── cursor/ │ │ │ │ │ └── CustomCursor.java │ │ │ │ └── font/ │ │ │ │ └── MaterialDesignLabel.java │ │ │ ├── skin/ │ │ │ │ ├── BaseColorDialogController.java │ │ │ │ ├── FontSizeDialogController.java │ │ │ │ ├── StyleClass.java │ │ │ │ └── ThemeManager.java │ │ │ ├── tasks/ │ │ │ │ ├── BootEngineTask.java │ │ │ │ ├── CloseFileTask.java │ │ │ │ ├── PackDatabaseTask.java │ │ │ │ └── SaveAsTask.java │ │ │ ├── util/ │ │ │ │ ├── AccountTypeFilter.java │ │ │ │ ├── FXMLUtils.java │ │ │ │ ├── FileChooserFactory.java │ │ │ │ ├── InjectFXML.java │ │ │ │ ├── JavaFXUtils.java │ │ │ │ ├── StageUtils.java │ │ │ │ ├── TableViewManager.java │ │ │ │ └── TreeSearch.java │ │ │ ├── views/ │ │ │ │ ├── AccountBalanceDisplayManager.java │ │ │ │ ├── AccountBalanceDisplayMode.java │ │ │ │ ├── accounts/ │ │ │ │ │ ├── AccountCommodityFormatTreeTableCell.java │ │ │ │ │ ├── AccountPropertiesController.java │ │ │ │ │ ├── AccountTypeFilterFormController.java │ │ │ │ │ ├── AccountsViewController.java │ │ │ │ │ ├── SelectAccountController.java │ │ │ │ │ ├── SelectAccountSecuritiesDialog.java │ │ │ │ │ └── StaticAccountsMethods.java │ │ │ │ ├── budget/ │ │ │ │ │ ├── BudgetGoalsDialogController.java │ │ │ │ │ ├── BudgetManagerDialogController.java │ │ │ │ │ ├── BudgetPropertiesDialogController.java │ │ │ │ │ ├── BudgetSparkLine.java │ │ │ │ │ ├── BudgetTableController.java │ │ │ │ │ ├── BudgetViewController.java │ │ │ │ │ └── HistoricalBudgetDialogController.java │ │ │ │ ├── main/ │ │ │ │ │ ├── ConsoleDialogController.java │ │ │ │ │ ├── MainToolBarController.java │ │ │ │ │ ├── MainView.java │ │ │ │ │ ├── MenuBarController.java │ │ │ │ │ └── OpenDatabaseController.java │ │ │ │ ├── recurring/ │ │ │ │ │ ├── AbstractTabController.java │ │ │ │ │ ├── DayTabController.java │ │ │ │ │ ├── MonthTabController.java │ │ │ │ │ ├── NoneTabController.java │ │ │ │ │ ├── NotificationDialog.java │ │ │ │ │ ├── RecurringDialogController.java │ │ │ │ │ ├── RecurringEntryDialog.java │ │ │ │ │ ├── RecurringPropertiesController.java │ │ │ │ │ ├── RecurringTabController.java │ │ │ │ │ ├── RecurringViewController.java │ │ │ │ │ ├── WeekTabController.java │ │ │ │ │ └── YearTabController.java │ │ │ │ └── register/ │ │ │ │ ├── AbstractInvIncomeSlipController.java │ │ │ │ ├── AbstractInvSlipController.java │ │ │ │ ├── AbstractPriceQtyInvSlipController.java │ │ │ │ ├── AbstractSlipController.java │ │ │ │ ├── AbstractTransactionEntryDialog.java │ │ │ │ ├── AbstractTransactionEntrySlipController.java │ │ │ │ ├── AbstractTransactionTableCell.java │ │ │ │ ├── AccountExchangePane.java │ │ │ │ ├── AccountPropertyWrapper.java │ │ │ │ ├── AdjustSharesSlipController.java │ │ │ │ ├── AdjustmentSlipController.java │ │ │ │ ├── AmortizeSetupDialogController.java │ │ │ │ ├── AttachmentPane.java │ │ │ │ ├── BankRegisterPaneController.java │ │ │ │ ├── BaseSlip.java │ │ │ │ ├── BasicRegisterTableController.java │ │ │ │ ├── BuyShareSlipController.java │ │ │ │ ├── DateTransNumberDialogController.java │ │ │ │ ├── DecreaseAmountProperty.java │ │ │ │ ├── DividendSlipController.java │ │ │ │ ├── FeeDialog.java │ │ │ │ ├── FeePane.java │ │ │ │ ├── FeeTransactionEntrySlipController.java │ │ │ │ ├── GainLossDialog.java │ │ │ │ ├── GainLossPane.java │ │ │ │ ├── GainLossTransactionEntrySlipController.java │ │ │ │ ├── IncreaseAmountProperty.java │ │ │ │ ├── InvestmentRegisterPaneController.java │ │ │ │ ├── InvestmentRegisterTableController.java │ │ │ │ ├── InvestmentSlipManager.java │ │ │ │ ├── InvestmentTransactionDialog.java │ │ │ │ ├── InvestmentTransactionQuantityTableCell.java │ │ │ │ ├── LiabilityRegisterPaneController.java │ │ │ │ ├── LockedBasicRegisterPaneController.java │ │ │ │ ├── LockedInvestmentRegisterPaneController.java │ │ │ │ ├── RegisterActions.java │ │ │ │ ├── RegisterFactory.java │ │ │ │ ├── RegisterPaneController.java │ │ │ │ ├── RegisterStage.java │ │ │ │ ├── RegisterTableController.java │ │ │ │ ├── RegisterViewController.java │ │ │ │ ├── ReinvestDividendSlipController.java │ │ │ │ ├── ReturnOfCapitalSlipController.java │ │ │ │ ├── SellShareSlipController.java │ │ │ │ ├── Slip.java │ │ │ │ ├── SlipController.java │ │ │ │ ├── SlipControllerContainer.java │ │ │ │ ├── SlipType.java │ │ │ │ ├── SplitMergeSharesSlipController.java │ │ │ │ ├── SplitTransactionDialog.java │ │ │ │ ├── SplitTransactionSlipController.java │ │ │ │ ├── TransactionCommodityFormatTableCell.java │ │ │ │ ├── TransactionDateTableCell.java │ │ │ │ ├── TransactionDateTimeTableCell.java │ │ │ │ ├── TransactionDialog.java │ │ │ │ ├── TransactionEntryCommodityFormatTableCell.java │ │ │ │ ├── TransactionStringTableCell.java │ │ │ │ ├── TransactionTagDialogController.java │ │ │ │ ├── TransactionTagPane.java │ │ │ │ ├── TransferSlipController.java │ │ │ │ └── reconcile/ │ │ │ │ ├── ReconcileDialogController.java │ │ │ │ └── ReconcileSettingsDialogController.java │ │ │ └── wizard/ │ │ │ ├── file/ │ │ │ │ ├── NewFileFourController.java │ │ │ │ ├── NewFileOneController.java │ │ │ │ ├── NewFileSummaryController.java │ │ │ │ ├── NewFileThreeController.java │ │ │ │ ├── NewFileTwoController.java │ │ │ │ └── NewFileWizard.java │ │ │ └── imports/ │ │ │ ├── ImportPageOneController.java │ │ │ ├── ImportPageThreeController.java │ │ │ ├── ImportPageTwoController.java │ │ │ └── ImportWizard.java │ │ └── resources/ │ │ ├── META-INF/ │ │ │ └── MANIFEST.MF │ │ └── jgnash/ │ │ ├── skin/ │ │ │ ├── default.css │ │ │ ├── tableHideHorizontalScrollBar.css │ │ │ └── tableHideVerticalScrollBar.css │ │ └── uifx/ │ │ ├── about/ │ │ │ └── AboutDialog.fxml │ │ ├── control/ │ │ │ ├── AlertDialog.fxml │ │ │ ├── ChoiceDialog.fxml │ │ │ ├── DateRangeDialog.fxml │ │ │ ├── DetailedDecimalTextField.fxml │ │ │ ├── ExceptionDialog.fxml │ │ │ ├── TabViewPane.fxml │ │ │ ├── TextInputDialog.fxml │ │ │ └── wizard/ │ │ │ └── WizardDialog.fxml │ │ ├── dialog/ │ │ │ ├── ChangePasswordDialog.fxml │ │ │ ├── ImportScriptsDialog.fxml │ │ │ ├── PackDatabaseDialog.fxml │ │ │ ├── RemoteConnectionDialog.fxml │ │ │ ├── TagManagerDialog.fxml │ │ │ ├── currency/ │ │ │ │ ├── AddRemoveCurrency.fxml │ │ │ │ ├── EditExchangeRates.fxml │ │ │ │ └── ModifyCurrency.fxml │ │ │ ├── options/ │ │ │ │ ├── AccountTab.fxml │ │ │ │ ├── DataProviderTab.fxml │ │ │ │ ├── FormatsTab.fxml │ │ │ │ ├── GeneralTab.fxml │ │ │ │ ├── NetworkTab.fxml │ │ │ │ ├── OptionDialog.fxml │ │ │ │ ├── RegisterTab.fxml │ │ │ │ ├── RemindersTab.fxml │ │ │ │ ├── ReportTab.fxml │ │ │ │ ├── StartupShutdownTab.fxml │ │ │ │ └── TransactionNumberDialog.fxml │ │ │ └── security/ │ │ │ ├── CreateModifySecurities.fxml │ │ │ ├── HistoricalImport.fxml │ │ │ └── SecurityHistory.fxml │ │ ├── report/ │ │ │ ├── AccountBalanceChart.fxml │ │ │ ├── AccountRegisterReport.fxml │ │ │ ├── BalanceByMonthOptionsDialog.fxml │ │ │ ├── BalanceSheetReport.fxml │ │ │ ├── IncomeExpenseBarChartDialog.fxml │ │ │ ├── IncomeExpensePayeePieChartDialog.fxml │ │ │ ├── IncomeExpensePieChartDialog.fxml │ │ │ ├── ListOfAccountsReport.fxml │ │ │ ├── NetWorthReport.fxml │ │ │ ├── PortfolioReport.fxml │ │ │ ├── ProfitLossReport.fxml │ │ │ ├── TransactionTagPieChartDialog.fxml │ │ │ └── pdf/ │ │ │ ├── PageFormatDialog.fxml │ │ │ └── ReportViewerDialog.fxml │ │ ├── skin/ │ │ │ ├── BaseColorDialog.fxml │ │ │ └── FontSizeDialog.fxml │ │ ├── views/ │ │ │ ├── accounts/ │ │ │ │ ├── AccountProperties.fxml │ │ │ │ ├── AccountTypeFilterForm.fxml │ │ │ │ ├── AccountsView.fxml │ │ │ │ └── SelectAccountForm.fxml │ │ │ ├── budget/ │ │ │ │ ├── BudgetGoalsDialog.fxml │ │ │ │ ├── BudgetManagerDialog.fxml │ │ │ │ ├── BudgetPropertiesDialog.fxml │ │ │ │ ├── BudgetTable.fxml │ │ │ │ ├── BudgetView.fxml │ │ │ │ └── HistoricalBudgetDialog.fxml │ │ │ ├── main/ │ │ │ │ ├── ConsoleDialog.fxml │ │ │ │ ├── MainMenuBar.fxml │ │ │ │ ├── MainToolBar.fxml │ │ │ │ └── OpenDatabaseForm.fxml │ │ │ ├── recurring/ │ │ │ │ ├── DayTab.fxml │ │ │ │ ├── MonthTab.fxml │ │ │ │ ├── NoneTab.fxml │ │ │ │ ├── NotificationDialog.fxml │ │ │ │ ├── RecurringDialog.fxml │ │ │ │ ├── RecurringProperties.fxml │ │ │ │ ├── RecurringView.fxml │ │ │ │ ├── WeekTab.fxml │ │ │ │ └── YearTab.fxml │ │ │ └── register/ │ │ │ ├── AccountExchangePane.fxml │ │ │ ├── AdjustSharesSlip.fxml │ │ │ ├── AdjustmentSlip.fxml │ │ │ ├── AmortizeSetupDialog.fxml │ │ │ ├── AttachmentPane.fxml │ │ │ ├── BankSlip.fxml │ │ │ ├── BasicRegisterPane.fxml │ │ │ ├── BasicRegisterTable.fxml │ │ │ ├── BuyShareSlip.fxml │ │ │ ├── DateTransNumberDialog.fxml │ │ │ ├── DividendSlip.fxml │ │ │ ├── FeeDialog.fxml │ │ │ ├── FeeTransactionEntrySlip.fxml │ │ │ ├── GainLossDialog.fxml │ │ │ ├── GainLossTransactionEntrySlip.fxml │ │ │ ├── InvestmentRegisterPane.fxml │ │ │ ├── InvestmentRegisterTable.fxml │ │ │ ├── InvestmentTransactionDialog.fxml │ │ │ ├── InvestmentTransactionPane.fxml │ │ │ ├── LiabilityRegisterPane.fxml │ │ │ ├── LockedBasicRegisterPane.fxml │ │ │ ├── LockedInvestmentRegisterPane.fxml │ │ │ ├── RegisterView.fxml │ │ │ ├── ReinvestDividendSlip.fxml │ │ │ ├── ReturnOfCapitalSlip.fxml │ │ │ ├── SellShareSlip.fxml │ │ │ ├── SplitMergeSharesSlip.fxml │ │ │ ├── SplitTransactionDialog.fxml │ │ │ ├── SplitTransactionSlip.fxml │ │ │ ├── TransactionDialog.fxml │ │ │ ├── TransactionTagDialog.fxml │ │ │ ├── TransactionTagPane.fxml │ │ │ ├── TransferSlip.fxml │ │ │ └── reconcile/ │ │ │ ├── ReconcileDialog.fxml │ │ │ └── ReconcileSettingsDialog.fxml │ │ └── wizard/ │ │ ├── file/ │ │ │ ├── NewFileFour.fxml │ │ │ ├── NewFileOne.fxml │ │ │ ├── NewFileSummary.fxml │ │ │ ├── NewFileThree.fxml │ │ │ └── NewFileTwo.fxml │ │ └── imports/ │ │ ├── ImportPageOne.fxml │ │ ├── ImportPageThree.fxml │ │ └── ImportPageTwo.fxml │ └── test/ │ └── java/ │ └── jgnash/ │ └── uifx/ │ ├── ControlsTest.java │ └── control/ │ └── autocomplete/ │ └── AutoCompleteModelTest.java ├── jgnash-fx-test-plugin/ │ ├── build.gradle.kts │ └── src/ │ └── main/ │ └── java/ │ └── jgnash/ │ └── uifx/ │ └── plugin/ │ └── TestFxPlugin.java ├── jgnash-manual/ │ ├── readme.md │ ├── sample.bxds │ └── src/ │ ├── Manual.tex │ ├── fdl-1.3.tex │ └── gpl-3.0.tex ├── jgnash-plugin/ │ ├── build.gradle.kts │ └── src/ │ └── main/ │ └── java/ │ └── jgnash/ │ └── plugin/ │ ├── FxPlugin.java │ ├── Plugin.java │ └── PluginFactory.java ├── jgnash-report-core/ │ ├── build.gradle.kts │ └── src/ │ └── main/ │ └── java/ │ └── jgnash/ │ └── report/ │ ├── pdf/ │ │ ├── Constants.java │ │ ├── FontRegistry.java │ │ ├── PageSize.java │ │ ├── Report.java │ │ └── ReportFactory.java │ ├── poi/ │ │ ├── BudgetResultsExport.java │ │ ├── StyleFactory.java │ │ └── Workbook.java │ ├── table/ │ │ ├── AbstractReportTableModel.java │ │ ├── ColumnStyle.java │ │ ├── GroupInfo.java │ │ ├── Row.java │ │ └── SortOrder.java │ └── ui/ │ └── ReportPrintFactory.java ├── jgnash-resources/ │ ├── build.gradle.kts │ └── src/ │ └── main/ │ ├── java/ │ │ └── jgnash/ │ │ └── resource/ │ │ └── util/ │ │ ├── ClassPathUtils.java │ │ ├── HTMLResource.java │ │ ├── MonthName.java │ │ ├── OS.java │ │ ├── ResourceUtils.java │ │ ├── TextResource.java │ │ └── Version.java │ └── resources/ │ └── jgnash/ │ └── resource/ │ ├── account/ │ │ ├── cs/ │ │ │ ├── common.xml │ │ │ ├── set.txt │ │ │ └── spouse.xml │ │ ├── de/ │ │ │ ├── set.txt │ │ │ ├── spouse.xml │ │ │ └── standard.xml │ │ ├── en/ │ │ │ ├── common.xml │ │ │ ├── set.txt │ │ │ └── spouse.xml │ │ ├── pt/ │ │ │ ├── common.xml │ │ │ ├── set.txt │ │ │ └── spouse.xml │ │ └── zh/ │ │ ├── common.xml │ │ ├── set.txt │ │ └── spouse.xml │ ├── constants.properties │ ├── html/ │ │ └── en/ │ │ ├── apache-license.html │ │ ├── credits.html │ │ ├── gpl-license.html │ │ ├── jgnash-license.html │ │ ├── lgpl.html │ │ ├── notice.html │ │ └── xstream-license.html │ ├── resource.properties │ ├── resource_cs.properties │ ├── resource_de.properties │ ├── resource_en.properties │ ├── resource_en_GB.properties │ ├── resource_es.properties │ ├── resource_fr.properties │ ├── resource_it.properties │ ├── resource_iw.properties │ ├── resource_lt.properties │ ├── resource_nl.properties │ ├── resource_pl.properties │ ├── resource_pt.properties │ ├── resource_ru.properties │ ├── resource_uk.properties │ ├── resource_zh.properties │ ├── resource_zh_TW.properties │ └── text/ │ ├── cs/ │ │ ├── ArchiveDate.txt │ │ ├── ArchiveEquity.txt │ │ ├── ArchiveFile.txt │ │ ├── CreateNewFile.txt │ │ ├── DupeTransImport.txt │ │ ├── FileNotSaved.txt │ │ ├── ImportFileZero.txt │ │ ├── ImportOne.txt │ │ ├── ImportTwo.txt │ │ ├── NewFileFour.txt │ │ ├── NewFileOne.txt │ │ ├── NewFileThree.txt │ │ ├── NewFileTwo.txt │ │ ├── QifOne.txt │ │ └── QifTwo.txt │ ├── de/ │ │ ├── ArchiveDate.txt │ │ ├── ArchiveEquity.txt │ │ ├── ArchiveFile.txt │ │ ├── CreateNewFile.txt │ │ ├── DupeTransImport.txt │ │ ├── FileNotSaved.txt │ │ ├── ImportFileZero.txt │ │ ├── ImportOne.txt │ │ ├── ImportTwo.txt │ │ ├── NewBudgetOne.txt │ │ ├── NewFileFour.txt │ │ ├── NewFileOne.txt │ │ ├── NewFileThree.txt │ │ ├── NewFileTwo.txt │ │ ├── QifOne.txt │ │ └── QifTwo.txt │ ├── en/ │ │ ├── ArchiveDate.txt │ │ ├── ArchiveEquity.txt │ │ ├── ArchiveFile.txt │ │ ├── CreateNewFile.txt │ │ ├── DupeTransImport.txt │ │ ├── FileNotSaved.txt │ │ ├── ImportFileZero.txt │ │ ├── ImportOne.txt │ │ ├── ImportTwo.txt │ │ ├── NewBudgetOne.txt │ │ ├── NewFileFour.txt │ │ ├── NewFileOne.txt │ │ ├── NewFileThree.txt │ │ ├── NewFileTwo.txt │ │ ├── QifOne.txt │ │ └── QifTwo.txt │ ├── es/ │ │ ├── ArchiveDate.txt │ │ ├── ArchiveEquity.txt │ │ ├── ArchiveFile.txt │ │ ├── CreateNewFile.txt │ │ ├── DupeTransImport.txt │ │ ├── FileNotSaved.txt │ │ ├── NewFileFour.txt │ │ ├── NewFileThree.txt │ │ ├── NewFileTwo.txt │ │ ├── QifOne.txt │ │ └── QifTwo.txt │ ├── fr/ │ │ ├── ArchiveDate.txt │ │ ├── ArchiveEquity.txt │ │ ├── ArchiveFile.txt │ │ ├── CreateNewFile.txt │ │ ├── DupeTransImport.txt │ │ ├── FileNotSaved.txt │ │ ├── NewFileFour.txt │ │ ├── NewFileThree.txt │ │ ├── NewFileTwo.txt │ │ ├── QifOne.txt │ │ └── QifTwo.txt │ ├── it/ │ │ ├── CreateNewFile.txt │ │ ├── FileNotSaved.txt │ │ ├── NewFileFour.txt │ │ ├── NewFileThree.txt │ │ ├── NewFileTwo.txt │ │ ├── QifOne.txt │ │ └── QifTwo.txt │ ├── lt/ │ │ ├── ArchiveDate.txt │ │ ├── ArchiveEquity.txt │ │ ├── ArchiveFile.txt │ │ ├── CreateNewFile.txt │ │ ├── FileNotSaved.txt │ │ ├── NewFileFour.txt │ │ ├── NewFileThree.txt │ │ ├── NewFileTwo.txt │ │ ├── QifOne.txt │ │ └── QifTwo.txt │ ├── nl/ │ │ ├── ArchiveDate.txt │ │ ├── ArchiveEquity.txt │ │ ├── ArchiveFile.txt │ │ ├── CreateNewFile.txt │ │ ├── DupeTransImport.txt │ │ ├── FileNotSaved.txt │ │ ├── NewFileFour.txt │ │ ├── NewFileThree.txt │ │ ├── NewFileTwo.txt │ │ ├── QifOne.txt │ │ └── QifTwo.txt │ ├── pl/ │ │ ├── ArchiveDate.txt │ │ ├── ArchiveEquity.txt │ │ ├── ArchiveFile.txt │ │ ├── CreateNewFile.txt │ │ ├── DupeTransImport.txt │ │ ├── FileNotSaved.txt │ │ ├── ImportFileZero.txt │ │ ├── ImportOne.txt │ │ ├── ImportTwo.txt │ │ ├── NewBudgetOne.txt │ │ ├── NewFileFour.txt │ │ ├── NewFileOne.txt │ │ ├── NewFileThree.txt │ │ ├── NewFileTwo.txt │ │ ├── QifOne.txt │ │ └── QifTwo.txt │ ├── pt/ │ │ ├── ArchiveDate.txt │ │ ├── ArchiveEquity.txt │ │ ├── ArchiveFile.txt │ │ ├── CreateNewFile.txt │ │ ├── DupeTransImport.txt │ │ ├── FileNotSaved.txt │ │ ├── ImportFileZero.txt │ │ ├── ImportOne.txt │ │ ├── ImportTwo.txt │ │ ├── NewFileFour.txt │ │ ├── NewFileOne.txt │ │ ├── NewFileThree.txt │ │ ├── NewFileTwo.txt │ │ ├── QifOne.txt │ │ └── QifTwo.txt │ ├── ru/ │ │ ├── ArchiveDate.txt │ │ ├── ArchiveEquity.txt │ │ ├── ArchiveFile.txt │ │ ├── CreateNewFile.txt │ │ ├── DupeTransImport.txt │ │ ├── FileNotSaved.txt │ │ ├── NewFileFour.txt │ │ ├── NewFileThree.txt │ │ ├── NewFileTwo.txt │ │ ├── QifOne.txt │ │ └── QifTwo.txt │ ├── uk/ │ │ ├── ArchiveDate.txt │ │ ├── ArchiveEquity.txt │ │ ├── ArchiveFile.txt │ │ ├── CreateNewFile.txt │ │ ├── DupeTransImport.txt │ │ ├── FileNotSaved.txt │ │ ├── ImportFileZero.txt │ │ ├── ImportOne.txt │ │ ├── ImportTwo.txt │ │ ├── NewFileFour.txt │ │ ├── NewFileOne.txt │ │ ├── NewFileThree.txt │ │ ├── NewFileTwo.txt │ │ ├── QifOne.txt │ │ └── QifTwo.txt │ └── zh/ │ ├── ArchiveDate.txt │ ├── ArchiveEquity.txt │ ├── ArchiveFile.txt │ ├── CreateNewFile.txt │ ├── DupeTransImport.txt │ ├── FileNotSaved.txt │ ├── ImportFileZero.txt │ ├── ImportOne.txt │ ├── ImportTwo.txt │ ├── NewBudgetOne.txt │ ├── NewFileFour.txt │ ├── NewFileOne.txt │ ├── NewFileThree.txt │ ├── NewFileTwo.txt │ ├── QifOne.txt │ └── QifTwo.txt ├── jgnash-tests/ │ ├── build.gradle.kts │ └── src/ │ └── test/ │ ├── java/ │ │ ├── ApiTest.java │ │ └── jgnash/ │ │ ├── ClassPathTest.java │ │ ├── JavascriptTest.js │ │ ├── PDFBoxTableTest.java │ │ ├── VersionTest.java │ │ ├── bayes/ │ │ │ └── BayesClassifierTest.java │ │ ├── convert/ │ │ │ └── importat/ │ │ │ ├── FilterTest.java │ │ │ ├── ofx/ │ │ │ │ ├── Ofx2Test.java │ │ │ │ ├── OfxConvertTest.java │ │ │ │ └── OfxExportText.java │ │ │ └── qif/ │ │ │ └── QifUtilsTest.java │ │ ├── engine/ │ │ │ ├── AbstractEngineTest.java │ │ │ ├── AccountTreeDepthTest.java │ │ │ ├── BinaryXStreamEngineTest.java │ │ │ ├── CashFlowTest.java │ │ │ ├── ConfigTest.java │ │ │ ├── DataStoreTest.java │ │ │ ├── DistributedLockTest.java │ │ │ ├── EncryptedDistributedLockTest.java │ │ │ ├── EngineTest.java │ │ │ ├── FileTransferTest.java │ │ │ ├── InvestmentHistoryExchangeTest.java │ │ │ ├── InvestmentPerformanceTest.java │ │ │ ├── InvestmentTransactionTest.java │ │ │ ├── JpaH2EngineTest.java │ │ │ ├── JpaH2MvEngineTest.java │ │ │ ├── JpaHsqlEngineTest.java │ │ │ ├── PriorityThreadTest.java │ │ │ ├── TransactionTest.java │ │ │ ├── XMLEngineTest.java │ │ │ ├── net/ │ │ │ │ └── security/ │ │ │ │ ├── IEXParserTest.java │ │ │ │ └── YahooEventParserTest.java │ │ │ └── recurring/ │ │ │ ├── DailyReminderTest.java │ │ │ ├── MonthlyReminderTest.java │ │ │ ├── WeeklyReminderTest.java │ │ │ └── YearlyReminderTest.java │ │ ├── report/ │ │ │ └── poi/ │ │ │ └── BudgetResultsExportTest.java │ │ ├── text/ │ │ │ └── NumericFormatsTests.java │ │ └── util/ │ │ ├── BinaryXStreamTest.java │ │ ├── DateFormatTest.java │ │ ├── DateTest.java │ │ ├── EncodeDecodeTest.java │ │ ├── EncryptionManagerTest.java │ │ ├── FileMagicTest.java │ │ └── FileUtilsTest.java │ └── resources/ │ ├── 401k-header.xml │ ├── 401k.xml │ ├── File_with_Accents.ofx │ ├── IEX-IBM-1y.csv │ ├── IEX-IBM-1y.json │ ├── Sample.ofx │ ├── activity.ofx │ ├── bank1-commas.ofx │ ├── bank1-indent.ofx │ ├── bank1.ofx │ ├── bank1.qif │ ├── bank2.ofx │ ├── budgetTest.xml │ ├── checking1.ofx │ ├── chequing.ofx │ ├── comptes.ofx │ ├── demobank.ofx │ ├── invest.xml │ ├── invest2.xml │ ├── ofx_spec160_stmtrs_example.sgml │ ├── ofx_spec201_stmtrs_example.xml │ ├── test_fails.ofx │ └── uglyFormat.ofx ├── mt940/ │ ├── build.gradle.kts │ └── src/ │ ├── main/ │ │ └── java/ │ │ └── net/ │ │ └── bzzt/ │ │ └── swift/ │ │ └── mt940/ │ │ ├── ImportMt940FxAction.java │ │ ├── Mt940Entry.java │ │ ├── Mt940File.java │ │ ├── Mt940Plugin.java │ │ ├── Mt940Record.java │ │ ├── exporter/ │ │ │ └── Mt940Exporter.java │ │ └── parser/ │ │ └── Mt940Parser.java │ └── test/ │ ├── java/ │ │ └── net/ │ │ └── bzzt/ │ │ └── swift/ │ │ └── mt940/ │ │ └── Mt940Test.java │ └── resources/ │ ├── bank1.STA │ ├── multiaccounts.sta │ └── rabobank.swi ├── rhino-scripts/ │ ├── README.adoc │ ├── clear-budget-goal.js │ ├── create-random-transaction.js │ └── load-budget-goal.js ├── rust-launcher/ │ ├── Cargo.toml │ ├── README.adoc │ ├── build.rs │ └── src/ │ └── main.rs ├── settings.gradle.kts ├── spelling.dic └── windows 10 Java reg fix.reg ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/ISSUE_TEMPLATE.md ================================================ ## Expected Behavior ## Actual Behavior ## Steps to Reproduce the Problem 1. 2. 3. ## Specifications - jGnash Version: - Operating System: - Java Version ================================================ FILE: .github/workflows/ci-linux.yml ================================================ name: 'CI Test Linux' on: [push] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@master - uses: actions/cache@master with: path: ~/.gradle/caches key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }} restore-keys: | ${{ runner.os }}-gradle- - uses: actions/setup-java@master with: java-version: '11' architecture: 'x64' - run: ./gradlew test ================================================ FILE: .github/workflows/ci-macOS.yml ================================================ name: 'CI Test macOS' on: [push] jobs: test: runs-on: macOS-latest steps: - uses: actions/checkout@master - uses: actions/setup-java@master with: java-version: '11' architecture: 'x64' - run: ./gradlew test ================================================ FILE: .github/workflows/ci-windows.yml ================================================ name: 'CI Test Windows' on: [push] jobs: test: runs-on: windows-latest steps: - uses: actions/checkout@master - uses: actions/setup-java@master with: java-version: '11' architecture: 'x64' - run: ./gradlew.bat test ================================================ FILE: .github/workflows/gradle-wrapper-validation.yml ================================================ name: "Validate Gradle Wrapper" on: [push, pull_request] jobs: validation: name: "Validation" runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - uses: gradle/wrapper-validation-action@v1 ================================================ FILE: .gitignore ================================================ **/target/ **/build/ **/out/ **/bin/ .svn/ *.directory .settings/ .project .classpath .idea/ *.iml *.log nb-configuration.xml *.backup /pref.xml /gradle-app.setting /*.zip .gradle/ lib/ *.aux *.toc *.gz *.fls *.fdb* *.out # keep the exe file for easy dist build on non Windows platfroms !rust-launcher/target/release/jGnash.exe ================================================ FILE: .travis.yml ================================================ language: java sudo: required before_install: - sudo apt-get update -q - sudo apt-get install lib32z1 lib32ncurses5 -y before_script: - if [ "${TRAVIS_OS_NAME}" == "linux" ]; then export DISPLAY=:99.0 && /sbin/start-stop-daemon --start --quiet --pidfile /tmp/custom_xvfb_99.pid --make-pidfile --background --exec /usr/bin/Xvfb -- :99 -ac -screen 0 1280x1024x16; fi jdk: - openjdk11 #- oraclejdk11 before_cache: - rm -f $HOME/.gradle/caches/modules-2/modules-2.lock - rm -fr $HOME/.gradle/caches/*/plugin-resolution/ - rm -f $HOME/.gradle/caches/*/fileHashes/fileHashes.bin - rm -f $HOME/.gradle/caches/*/fileHashes/fileHashes.lock cache: directories: - $HOME/.gradle/caches/ - $HOME/.gradle/wrapper/ - $HOME/.m2 ================================================ FILE: COPYING ================================================ jGnash, a personal finance application Copyright (C) 2001-2020 Craig Cavanaugh This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . ================================================ FILE: README.adoc ================================================ image:https://jgnash.github.io/img/jgnash-logo.png[jGnash Logo] == jGnash README https://sourceforge.net/projects/jgnash/[jGnash] is a free (no strings attached!) personal finance manager with many of the same features as commercially-available software. It was created in order to make tracking personal finances easy, but also provides the functionality needed by advanced users. jGnash is cross-platform and will run on any operating system that has a current Java Runtime Environment (e.g., Linux, Mac OS X, and Microsoft Windows). * jGnash requires *Java 11* or newer and is compatible with the open source OpenJDK Platform, and the Oracle JVM as well. See the <> section below for more details. === Contents: * <> - <> * <> * <> * <> - <> - <> * <> * <> * <> * <> [[About]] == About jGnash [[Features]] === jGnash Features - Operates on any operating system with Java 11 or newer installed - Double Entry Accounting with reconciliation tools - OFX, QFX, mt940, and QIF import capabilities - Investment Accounts and automatic import of Stocks, Bond, and Funds price history - Nestable accounts with automatic rollup of totals and intelligent handling of mixed currencies - Reminders with automatic transaction entry - Intelligent handling of multiple currencies and exchange rates with automatic online exchange rate updates - Printable reports with PDF and spreadsheet export capability - XML, Binary, and multiple relational database file formats - Supports concurrent multiple users over a network To learn more about the features of jGnash, visit the https://sourceforge.net/projects/jgnash/[jGnash Website]. The jGnash download includes a user manual to help get you started with the basics if you are new to tracking finances. It also covers some of the more subtle features, command line options, and shortcuts that are not immediately obvious. The latest version of jGnash uses *OpenJFX* for the user interface. This replaces the old version that used Java Swing for the user interface. Experienced jGnash users will notice interface improvements. For example, try using the vertical and horizontal scroll wheels in a date picker, and the collapsible transaction forms. [[Donations]] == Donations Donations are always welcome and appreciated. This helps to defer the cost of computer hardware and internet access. https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=TYN4QECUL5C44[image:https://img.shields.io/badge/Donate-PayPal-green.svg[PayPal]] [[Support]] == Support The *https://groups.google.com/forum/#!forum/jgnash-user[jGnash Help Group]* is always a good source if you need help and *is the prefered method of contact.* Your first post to the group will be moderated to filter spam. Please use the search tool to check for similar questions. The preferred method of reporting bugs is to use the https://github.com/ccavanaugh/jgnash/issues[Github Issue tracker]. [[Requirements]] == Requirements [[Reqs-Java]] === 1. Java Java 11 or newer is required to run jGnash. Unless you have a specific need for a newer version, Java 11 is currently recommended. Use of a prebuilt installer is recommended. - https://www.azul.com/downloads/zulu/[Azul OpenJDK 11] is a branded release that will be easiest to install for most users and is free to use. - https://adoptopenjdk.net/index.html?variant=openjdk11&jvmVariant=hotspot[AdoptOpenJDK] will require manual installation but allows more flexibility and is free to use. - https://jdk.java.net/11/[https://jdk.java.net/11 (OpenJDK)/] will require manual installation and is free to use. - https://www.oracle.com/technetwork/java/javase/downloads/index.html[Oracle Java SE 11] will require manual installation and licensing is required. [NOTE] When performing a manual installation of Java, The *JAVA_HOME* Environment Variable must be set. Also, the Java bin directory must be added to the execution path. [NOTE] If you have multiple versions of Java installed on your system, The *JAVA_HOME* Environment Variable must be set to Java 11 or newer and the related Java bin directory must be the only version in the execution path. Mixing JVM and JDK versions will confuse the bootloader. *_Use of an OpenJDK package is recommended over use of Oracle JDK due to licensing requirements_* === 2. OpenJFX jGnash uses OpenJFX for the user interface, but will automatically download and place the needed components within the lib directly of the jGnash installation. Portions of the OpenJFX components are OS specific and cannot be shared between different operating systems. [[Reqs-OS]] === 3. Supported Operating Systems: Windows, Linux, or Mac OS X. ==== Microsoft Windows * any Windows release that can run the required version of Java ==== Linux * any Linux distribution that can run the required version of Java [NOTE] jGnash is _not compatible_ with GCJ pre-installed on older Linux distributions. You will need to install *OpenJDK 11* for jGnash to operate correctly. ==== Mac OS X * Mac OS X 10.8.3 or later * can run the required version of Java _Be sure to read <> to create the startup script._ [[Download]] == Download jGnash You can download jGnash from the https://sourceforge.net/projects/jgnash/files/Active%20Stable%202.x/[jGnash Download Page]. image:https://img.shields.io/sourceforge/dt/jgnash.svg["Download button", link="https://sourceforge.net/projects/jgnash/files/latest/download"] [[Install]] == To Install jGnash . Install the latest version of *Java 11* if you don't already have it installed. _jGnash has been tested and is know to work on Java 12 through 14._ ** Developers will want the complete Java Development Kit (see build instructions below.) . Unzip all files into a directory of your choice leaving the directory structure unchanged. [[Install-Windows]] === Windows Installation: Some Windows users with restricted rights may experience write access issues *(Access is denied exception)* with jGnash downloading the JavaFX dependencies. Unzipping and placing jGnash into `%AppData%\jGnash` will ensure the users has proper write access. [[Install-MacOSX]] === Mac OS X Installation: . Copy the jGnash folder to `/Applications` and remove the version extension so that the final path looks like `/Applications/jGnash`. . Create an AppleScript that will run the application: .. Open the AppleScript Editor. .. Create the following script: try do shell script "/Applications/jGnash/jGnash" end try .. Save it as an Application called `jGnash.app` in `/Applications/jGnash` . Instead of step 2, you can set the `/Applications/jGnash/jGnash` file to _Open with..._ `Terminal.app` (the Terminal application). [[Running]] == To Run: Executable files are provided for Windows and UN*X users at the root of the installation directory. (These are `.exe` and `bash shell` files, respectively). Mac OS X users will have created application launch files per the <> [NOTE] jGnash will need to be restarted after the first launch of a new version. Operating System specific files are download and a restart is required for correct operation. * Windows: Simply double-click on the jGnash.exe file. * UN*X / MacOS: Start jGnash with the provided *jGnash* Bash script. If jGnash fails to launch, check your file permissions and make sure they are set to be executable or use an unzip tool that preserves file permissions. An example for UN*X users is shown below assuming you have changed to the installation directory: [source] ---- ./jGnash ---- *Mac OS X:* Run the application file you created per the <> [[Development]] == Building and Development Travis-CI Build Status image:https://travis-ci.org/ccavanaugh/jgnash.svg?branch=master["Build Status", link="https://travis-ci.org/ccavanaugh/jgnash"] === Development List The https://groups.google.com/forum/#!forum/jgnash-devel[Google Groups jGnash Developer list] is the best place to start if you have questions or ideas. Initial posts will are moderated to prevent spam. === Development Tools The IDE used for the development of jGnash is IntelliJ IDEA, but any IDE that supports a Gradle build environment should work. image:https://github.com/jGnash/jgnash.github.io/blob/master/img/logo_IntelliJIDEA.png["IntelliJIDEA Logo", height=90, link="https://www.jetbrains.com/idea/"] === Building jGnash: *Gradle* is used as the primary build system for jGnash. The Gradle Wrapper is included (`gradlew` shell and .bat files) so that you do not need to install Gradle. The Wrapper will automatically download the necessary dependencies. [NOTE] Depending on your OS (almost always Windows and OSX) the JCE Unlimited Strength Jurisdiction Policy Files for Java are needed for the unit tests to complete correctly. If you do not want to install these files or are restricted by your locale, modify the test build or disable tests. jGnash uses encryption for client / server communication and unit tests are performed to prevent regressions. To build jGnash you'll need the following software installed and correctly configured on your system: OpenJDK 11 or later. _If you are building with a recent 64bit Linux system, you may need to enable Multilib/32 Bit support capabilities. Otherwise, the Gradle build may fail when building the windows executables._ To create the distribution zip file, start at the main directory and run the gradle task to clean and create the distribution: *Building on Windows:* [source] ---- gradlew clean distZip ---- *Building on UN*X or Mac OS X:* [source] ---- ./gradlew clean distZip ---- This will run the Gradle tasks necessary to execute core tests and create the distribution file. The distributable zip file will be produced at the root of the build directory called jGnash-_version_-bin.zip. ================================================ FILE: README.html ================================================ jGnash README

jGnash Logo

jGnash README

jGnash is a free (no strings attached!) personal finance manager with many of the same features as commercially-available software. It was created in order to make tracking personal finances easy, but also provides the functionality needed by advanced users. jGnash is cross-platform and will run on any operating system that has a current Java Runtime Environment (e.g., Linux, Mac OS X, and Microsoft Windows).

  • jGnash requires Java 11 or newer and is compatible with the open source OpenJDK Platform, and the Oracle JVM as well.

See the Requirements section below for more details.

About jGnash

jGnash Features

  • Operates on any operating system with Java 11 or newer installed

  • Double Entry Accounting with reconciliation tools

  • OFX, QFX, mt940, and QIF import capabilities

  • Investment Accounts and automatic import of Stocks, Bond, and Funds price history

  • Nestable accounts with automatic rollup of totals and intelligent handling of mixed currencies

  • Reminders with automatic transaction entry

  • Intelligent handling of multiple currencies and exchange rates with automatic online exchange rate updates

  • Printable reports with PDF and spreadsheet export capability

  • XML, Binary, and multiple relational database file formats

  • Supports concurrent multiple users over a network

To learn more about the features of jGnash, visit the jGnash Website.

The jGnash download includes a user manual to help get you started with the basics if you are new to tracking finances. It also covers some of the more subtle features, command line options, and shortcuts that are not immediately obvious.

The latest version of jGnash uses OpenJFX for the user interface. This replaces the old version that used Java Swing for the user interface. Experienced jGnash users will notice interface improvements. For example, try using the vertical and horizontal scroll wheels in a date picker, and the collapsible transaction forms.

Donations

Donations are always welcome and appreciated. This helps to defer the cost of computer hardware and internet access.

PayPal

Support

The jGnash Help Group is always a good source if you need help and is the prefered method of contact. Your first post to the group will be moderated to filter spam.

Please use the search tool to check for similar questions.

The preferred method of reporting bugs is to use the Github Issue tracker.

Requirements

1. Java

Java 11 or newer is required to run jGnash. Unless you have a specific need for a newer version, Java 11 is currently recommended.

Use of a prebuilt installer is recommended.

Note
When performing a manual installation of Java, The JAVA_HOME Environment Variable must be set. Also, the Java bin directory must be added to the execution path.
Note
If you have multiple versions of Java installed on your system, The JAVA_HOME Environment Variable must be set to Java 11 or newer and the related Java bin directory must be the only version in the execution path. Mixing JVM and JDK versions will confuse the bootloader.

Use of an OpenJDK package is recommended over use of Oracle JDK due to licensing requirements

2. OpenJFX

jGnash uses OpenJFX for the user interface, but will automatically download and place the needed components within the lib directly of the jGnash installation. Portions of the OpenJFX components are OS specific and cannot be shared between different operating systems.

3. Supported Operating Systems: Windows, Linux, or Mac OS X.

Microsoft Windows

  • any Windows release that can run the required version of Java

Linux

  • any Linux distribution that can run the required version of Java

Note
jGnash is not compatible with GCJ pre-installed on older Linux distributions. You will need to install OpenJDK 11 for jGnash to operate correctly.

Mac OS X

  • Mac OS X 10.8.3 or later

  • can run the required version of Java

Be sure to read the section about installing on Mac OS X to create the startup script.

Download jGnash

You can download jGnash from the jGnash Download Page. Download button

To Install jGnash

  1. Install the latest version of Java 11 if you don’t already have it installed. jGnash has been tested and is know to work on Java 12 through 14.

    • Developers will want the complete Java Development Kit (see build instructions below.)

  2. Unzip all files into a directory of your choice leaving the directory structure unchanged.

Windows Installation:

Some Windows users with restricted rights may experience write access issues (Access is denied exception) with jGnash downloading the JavaFX dependencies.

Unzipping and placing jGnash into %AppData%\jGnash will ensure the users has proper write access.

Mac OS X Installation:

  1. Copy the jGnash folder to /Applications and remove the version extension so that the final path looks like /Applications/jGnash.

  2. Create an AppleScript that will run the application:

    1. Open the AppleScript Editor.

    2. Create the following script:

      try
          do shell script "/Applications/jGnash/jGnash"
      end try
    3. Save it as an Application called jGnash.app in /Applications/jGnash

  3. Instead of step 2, you can set the /Applications/jGnash/jGnash file to Open with…​ Terminal.app (the Terminal application).

To Run:

Executable files are provided for Windows and UN*X users at the root of the installation directory. (These are .exe and bash shell files, respectively). Mac OS X users will have created application launch files per the Mac installation instructions.

Note
jGnash will need to be restarted after the first launch of a new version. Operating System specific files are download and a restart is required for correct operation.
  • Windows: Simply double-click on the jGnash.exe file.

  • UN*X / MacOS: Start jGnash with the provided jGnash Bash script. If jGnash fails to launch, check your file permissions and make sure they are set to be executable or use an unzip tool that preserves file permissions.

An example for UN*X users is shown below assuming you have changed to the installation directory:

./jGnash

Mac OS X: Run the application file you created per the Mac installation instructions.

Building and Development

Travis-CI Build Status Build Status

Development List

The Google Groups jGnash Developer list is the best place to start if you have questions or ideas. Initial posts will are moderated to prevent spam.

Development Tools

The IDE used for the development of jGnash is IntelliJ IDEA, but any IDE that supports a Gradle build environment should work.

IntelliJIDEA Logo

Building jGnash:

Gradle is used as the primary build system for jGnash. The Gradle Wrapper is included (gradlew shell and .bat files) so that you do not need to install Gradle. The Wrapper will automatically download the necessary dependencies.

Note
Depending on your OS (almost always Windows and OSX) the JCE Unlimited Strength Jurisdiction Policy Files for Java are needed for the unit tests to complete correctly. If you do not want to install these files or are restricted by your locale, modify the test build or disable tests. jGnash uses encryption for client / server communication and unit tests are performed to prevent regressions.

To build jGnash you’ll need the following software installed and correctly configured on your system:

OpenJDK 11 or later.

If you are building with a recent 64bit Linux system, you may need to enable Multilib/32 Bit support capabilities. Otherwise, the Gradle build may fail when building the windows executables.

To create the distribution zip file, start at the main directory and run the gradle task to clean and create the distribution:

Building on Windows:

gradlew clean distZip

Building on UN*X or Mac OS X:

./gradlew clean distZip

This will run the Gradle tasks necessary to execute core tests and create the distribution file. The distributable zip file will be produced at the root of the build directory called jGnash-version-bin.zip.

================================================ FILE: README.md ================================================ ![jGnash Logo](https://jgnash.github.io/img/jgnash-logo.png) ## jGnash README [jGnash](https://sourceforge.net/projects/jgnash/) is a free (no strings attached!) personal finance manager with many of the same features as commercially-available software. It was created in order to make tracking personal finances easy, but also provides the functionality needed by advanced users. jGnash is cross-platform and will run on any operating system that has a current Java Runtime Environment (e.g., Linux, Mac OS X, and Microsoft Windows). - jGnash requires **Java 11** or newer and is compatible with the open source OpenJDK Platform, and the Oracle JVM as well. See the [Requirements](#Requirements) section below for more details. ### Contents: - [About jGnash](#About) - [jGnash Features](#Features) - [Donations](#Donations) - [Support](#Support) - [Requirements](#Requirements) - [Java](#Reqs-Java) - [Supported Operating System versions](#Reqs-OS) - [Download jGnash](#Download) - [Installation](#Install) - [Running jGnash](#Running) - [Building and Development](#Development) ## About jGnash ### jGnash Features - Operates on any operating system with Java 11 or newer installed - Double Entry Accounting with reconciliation tools - OFX, QFX, mt940, and QIF import capabilities - Investment Accounts and automatic import of Stocks, Bond, and Funds price history - Nestable accounts with automatic rollup of totals and intelligent handling of mixed currencies - Reminders with automatic transaction entry - Intelligent handling of multiple currencies and exchange rates with automatic online exchange rate updates - Printable reports with PDF and spreadsheet export capability - XML, Binary, and multiple relational database file formats - Supports concurrent multiple users over a network To learn more about the features of jGnash, visit the [jGnash Website](https://sourceforge.net/projects/jgnash/). The jGnash download includes a user manual to help get you started with the basics if you are new to tracking finances. It also covers some of the more subtle features, command line options, and shortcuts that are not immediately obvious. The latest version of jGnash uses **OpenJFX** for the user interface. This replaces the old version that used Java Swing for the user interface. Experienced jGnash users will notice interface improvements. For example, try using the vertical and horizontal scroll wheels in a date picker, and the collapsible transaction forms. ## Donations Donations are always welcome and appreciated. This helps to defer the cost of computer hardware and internet access. [![PayPal](https://img.shields.io/badge/Donate-PayPal-green.svg)](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=TYN4QECUL5C44) ## Support The **[jGnash Help Group](https://groups.google.com/forum/#!forum/jgnash-user)** is always a good source if you need help and **is the prefered method of contact.** Your first post to the group will be moderated to filter spam. Please use the search tool to check for similar questions. The preferred method of reporting bugs is to use the [Github Issue tracker](https://github.com/ccavanaugh/jgnash/issues). ## Requirements ### 1. Java Java 11 or newer is required to run jGnash. Unless you have a specific need for a newer version, Java 11 is currently recommended. Use of a prebuilt installer is recommended. - [Azul OpenJDK 11](https://www.azul.com/downloads/zulu/) is a branded release that will be easiest to install for most users and is free to use. - [AdoptOpenJDK](https://adoptopenjdk.net/index.html?variant=openjdk11&jvmVariant=hotspot) will require manual installation but allows more flexibility and is free to use. - [https://jdk.java.net/11 (OpenJDK)/](https://jdk.java.net/11/) will require manual installation and is free to use. - [Oracle Java SE 11](https://www.oracle.com/technetwork/java/javase/downloads/index.html) will require manual installation and licensing is required.
Note
When performing a manual installation of Java, The JAVA_HOME Environment Variable must be set. Also, the Java bin directory must be added to the execution path.
Note
If you have multiple versions of Java installed on your system, The JAVA_HOME Environment Variable must be set to Java 11 or newer and the related Java bin directory must be the only version in the execution path. Mixing JVM and JDK versions will confuse the bootloader.
***Use of an OpenJDK package is recommended over use of Oracle JDK due to licensing requirements*** ### 2. OpenJFX jGnash uses OpenJFX for the user interface, but will automatically download and place the needed components within the lib directly of the jGnash installation. Portions of the OpenJFX components are OS specific and cannot be shared between different operating systems. ### 3. Supported Operating Systems: Windows, Linux, or Mac OS X. #### Microsoft Windows - any Windows release that can run the required version of Java #### Linux - any Linux distribution that can run the required version of Java
Note
jGnash is not compatible with GCJ pre-installed on older Linux distributions. You will need to install OpenJDK 11 for jGnash to operate correctly.
#### Mac OS X - Mac OS X 10.8.3 or later - can run the required version of Java *Be sure to read [the section about installing on Mac OS X](#Install-MacOSX) to create the startup script.* ## Download jGnash You can download jGnash from the [jGnash Download Page](https://sourceforge.net/projects/jgnash/files/Active%20Stable%202.x/). Download button ## To Install jGnash 1. Install the latest version of **Java 11** if you don’t already have it installed. *jGnash has been tested and is know to work on Java 12 through 14.* - Developers will want the complete Java Development Kit (see build instructions below.) 2. Unzip all files into a directory of your choice leaving the directory structure unchanged. ### Windows Installation: Some Windows users with restricted rights may experience write access issues **(Access is denied exception)** with jGnash downloading the JavaFX dependencies. Unzipping and placing jGnash into `%AppData%\jGnash` will ensure the users has proper write access. ### Mac OS X Installation: 1. Copy the jGnash folder to `/Applications` and remove the version extension so that the final path looks like `/Applications/jGnash`. 2. Create an AppleScript that will run the application: 1. Open the AppleScript Editor. 2. Create the following script: try do shell script "/Applications/jGnash/jGnash" end try 3. Save it as an Application called `jGnash.app` in `/Applications/jGnash` 3. Instead of step 2, you can set the `/Applications/jGnash/jGnash` file to *Open with…​* `Terminal.app` (the Terminal application). ## To Run: Executable files are provided for Windows and UN\*X users at the root of the installation directory. (These are `.exe` and `bash shell` files, respectively). Mac OS X users will have created application launch files per the [Mac installation instructions.](#Install-MacOSX)
Note
jGnash will need to be restarted after the first launch of a new version. Operating System specific files are download and a restart is required for correct operation.
- Windows: Simply double-click on the jGnash.exe file. - UN\*X / MacOS: Start jGnash with the provided **jGnash** Bash script. If jGnash fails to launch, check your file permissions and make sure they are set to be executable or use an unzip tool that preserves file permissions. An example for UN\*X users is shown below assuming you have changed to the installation directory: ``` CodeRay ./jGnash ``` **Mac OS X:** Run the application file you created per the [Mac installation instructions.](#Install-MacOSX) ## Building and Development Travis-CI Build Status Build Status ### Development List The [Google Groups jGnash Developer list](https://groups.google.com/forum/#!forum/jgnash-devel) is the best place to start if you have questions or ideas. Initial posts will are moderated to prevent spam. ### Development Tools The IDE used for the development of jGnash is IntelliJ IDEA, but any IDE that supports a Gradle build environment should work. IntelliJIDEA Logo ### Building jGnash: **Gradle** is used as the primary build system for jGnash. The Gradle Wrapper is included (`gradlew` shell and .bat files) so that you do not need to install Gradle. The Wrapper will automatically download the necessary dependencies.
Note
Depending on your OS (almost always Windows and OSX) the JCE Unlimited Strength Jurisdiction Policy Files for Java are needed for the unit tests to complete correctly. If you do not want to install these files or are restricted by your locale, modify the test build or disable tests. jGnash uses encryption for client / server communication and unit tests are performed to prevent regressions.
To build jGnash you’ll need the following software installed and correctly configured on your system: OpenJDK 11 or later. *If you are building with a recent 64bit Linux system, you may need to enable Multilib/32 Bit support capabilities. Otherwise, the Gradle build may fail when building the windows executables.* To create the distribution zip file, start at the main directory and run the gradle task to clean and create the distribution: **Building on Windows:** ``` CodeRay gradlew clean distZip ``` **Building on UN\*X or Mac OS X:** ``` CodeRay ./gradlew clean distZip ``` This will run the Gradle tasks necessary to execute core tests and create the distribution file. The distributable zip file will be produced at the root of the build directory called jGnash-*version*-bin.zip. Last updated 2020-03-24 06:00:42 -0400 ================================================ FILE: build.gradle.kts ================================================ plugins { id("com.github.ben-manes.versions") } allprojects { repositories { mavenCentral() jcenter() mavenLocal() } apply(plugin = "java") } subprojects { group = "jgnash" version = "3.6.0" } ================================================ FILE: changelog.adoc ================================================ == WANTS * Support for Derby database * CSV Import * OFX export * VAT/GST UI * Command Line interface/Class for loading accounts and transactions * Mass selection of destination accounts for imported transactions. * Info icon in a sell investment panel that calculate the estimated gains/loss for selling a transaction * FIFO, and Lot performance calculations for securities. (http://groups.google.com/group/jgnash-user/browse_thread/thread/fe124ecf806857f3?hl=en) * Moving option for a Transaction from one Register to other Register. * Hyperlink option from the results in the Reports and Graphs to the concerned Transactions of the Registers. * Drill down of split entries in Split Transactions by right clicking on ledger view. ** Flag budget exceptions? * Direct Transaction entry option in the Ledger to avoid Transaction form. * Formal Trial Balance report * Budgeting ** annotation, similar to transaction annotation, so the user can reference the reason for an exception in any given period. * Reinstate archive capability * Open zipped backups instead of manually unzipping. == Release 3.6.0 __(File format change)__ * 02/21/2021 Fixed an issue with the JVM returning a negative scale for some locales (GitHub Pull Request #95) _[t-pa]_ * 02/21/2021 Updated to the latest dependencies. * 04/06/2020 Corrected a minor jdbc and Hibernate warning. * 03/21/2020 Yahoo financial historical event download format changed. * 03/15/2020 Updated to the JavaFX 14 release. * 03/07/2020 Waiting for lock files to be removed is now more efficient. * 03/01/2020 The numeric format for register balance summaries was not updating with format changes automatically. * 03/01/2020 Protect against a divide by zero error for the Tag report. * 03/01/2020 Internal cleanup and optimization of application thread management. * 02/29/2020 Don't hide the resize columns button when automatic sizing is enabled. * 02/29/2020 A race condition would cause Jump actions to fail. * 02/29/2020 Zoomed and Jumped register windows were not using the jGnash icon. * 02/26/2020 The Tag Manger now filters out application specific tags to make selection less cluttered. * 02/25/2020 Expanded Tags to support a much larger icon set. * 02/25/2020 Switched icon set from FontAwesome to Material Design. * 02/23/2020 Corrected broken Print Icon. == Release 3.5.1 * 02/20/2020 Corrected a bug with jGnash.exe causing a failure to launch on Windows Platforms. * 02/20/2020 Fixed a bug that was causing icons to no longer work on Windows Platforms. == Release 3.5.0 __(File format change)__ * 02/03/2020 Updated to the latest Hibernate, Poi, and Commons CSV dependencies. * 02/11/2020 jGnash will now restart automatically for Linux and OSX users after downloading the JavaFX dependencies. * 02/10/2020 Restructured main classes to a package to make future development with jpackage cleaner (GitHub Pull Request #88) _[msgilligan]_ * 02/08/2020 Build system cleanup and consolidated the bootloader. * 02/08/2020 Fix RuntimeException when the currency of a locale is unknown. (GitHub Pull Request #92) _[t-pa]_ * 02/08/2020 Updated to JavaFx 14-ea+8. * 02/05/2020 The location for JavaFX dependency downloads has changed. HTTPS is now used. * 02/03/2020 Updated to the latest Picocli, Hikari, PDFBox and Netty dependencies. * 01/19/2020 Correctly handle a change to the Yahoo finance cookie expiration date. * 01/01/2020 Transactions may now be tagged and filtered with user managed Tags. * 12/28/2019 Fixed poor transaction form behavior caused by opening a splits dialog and closing before adding entries. * 12/27/2019 Reduced overhead for icon management. * 12/26/2019 FontAwesome icons were updated resulting in some icon changes. * 12/15/2019 Reworked the Engine API for advanced transaction tags. * 12/14/2019 The Windows exe wrapper will now look in the registry if JAVA_HOME has not been set. == Release 3.4.0 * 12/14/2019 jGnash now uses a Rust based exe launcher for Windows users that replaces Launch4j. * 12/14/2019 Updated to the latest Picocli and Hibernate dependencies. * 12/01/2019 Switching the recommended Windows JVM to AdoptOpenJDK. * 12/01/2019 Removed old jGnash 2.35.1 workaround for bad UUID values. * 12/01/2019 Added a List of Accounts report. * 11/30/2019 Added an Export function for the account tree. * 11/30/2019 The headers in the CSV export of an account are now localized. * 11/30/2019 Changed the internal delimiter for transaction tags to allow use of commas (currently not user accessible). * 11/29/2019 Added a Securities chapter to the Manual. * 11/29/2019 Prevent a NPE when changing the Symbol of a Security shortly after viewing it's chart. * 11/27/2019 Added support for IEXCloud as a data provider for Securities history. * 11/16/2019 Switched to a public API introduced in JavaFX 9 to prevent table column reordering in the Budget View. * 11/04/2019 Errors and warnings from a failed QIF import were not being displayed. * 11/03/2019 Mt940 import now correctly handles files not encoded using ISO_8859_1. * 11/03/2019 QIF import now correctly handles files not encoded using UTF8. * 10/27/2019 Improved the behavior of the Report dialog when manually changing the scale. The auto fit size toggles will be cleared automatically. * 10/24/2019 Internal cleanup to improve handling of Client/Server network failures. * 10/23/2019 Fixed a regression that prevented caching of hash codes. * 10/19/2019 Updated to the latest H2 dependency. * 10/01/2019 The majority of the build system was migrated to the Kotlin DSL. == Release 3.3.0 * 09/21/2019 Updated to the latest Hibernate, HikariCP and PDFBox dependencies. * 09/18/2019 The displayed columns for the Portfolio report can now be changed. * 09/16/2019 Don't display an exception if tabular report generation is interrupted. * 09/15/2019 The Date range of a Portfolio report can now be changed. * 09/14/2019 Prevent a rare NPE that would occur when deleting an account shortly after closure of a tabular report. * 09/14/2019 Updated to the latest Picocli and Commons Text dependencies. * 09/12/2019 Updated to JavaFX 13 dependency. * 09/08/2019 Custom style sheets can now be applied to theme the UI. * 09/07/2019 Made changes to prevent a very rare ConcurrentModificationException when using a relational database. * 09/01/2019 Fixed a source code encoding issue that caused issues when compiling and testing under Windows. * 09/01/2019 Updated to the latest Netty dependency. * 08/26/2019 The selected file type was ignored when exporting a register and the extension was not specified within the filename. * 08/25/2019 Properly restore the configured page size of a report if it's not a custom size. * 08/25/2019 Expanded the auto-completion API to improve unit testing robustness. * 08/25/2019 Fixed a regression that was causing a failure to load .h2.db files. * 08/25/2019 Added a new option to save/restore prior report dates and added a Reset button. The default start date is now the 1st day of the month. * 08/12/2019 Backup files are no longer created unless the data file/database has been changed during a session. * 08/11/2019 Consolidated CSS to make customization easier. * 08/11/2019 Prevent a transaction form null pointer exception at shutdown when working with a multi-currency files. == Release 3.2.1 * 08/10/2019 Updated to JavaFX 13-ea+11 dependency. * 08/04/2019 Corrected the column sizing behavior of tabular reports with long values. * 08/03/2019 Changed H2 relation databases to use Asynchronous access instead of NIO for safer file access. * 08/03/2019 Fixed a bug that was causing File > Save As to force files to a .bxds file if a period existed within the file name's path other than the file extension. * 08/03/2019 Internal deduplication and cleanup of relational database code. * 08/03/2019 Update to the latest Hibernate dependency. * 07/30/2019 Reduced console messages about ignored use of font Layout tables when creating tabular reports. * 07/27/2019 Updated to the latest Commons Lang, Commons Text, Commons Collections, Commons CSV, Netty and Picocli dependencies. * 07/23/2019 The Create / Modify Security form was not validating the reported Currency had been set. * 07/21/2019 Fixed a very old UI bug that would prevent table column width restoration for locales that use a comma for a decimal separator. It would also trigger unit test failures for the same locales. * 07/09/2019 Fixed an issue that was preventing the jGnash.exe file from detecting Java on some systems. * 07/09/2019 Improved bootloader behavior when determining "lib" location. (GitHub Issue #84) _[Raven Kopelman]_ * 07/07/2019 Selected tabular rows may now be copied to the clipboard using CTRL-C. == Release 3.2.0 * 07/05/2019 Fixed a bug that was preventing entry of investment transaction gains or losses without using the detailed entry form and specifying a gains and loss account. * 07/05/2019 Fixed a bug that was causing backup files and database conversions to save in the wrong location or fail completely if a period existed within the file name's path other than the file extension. * 07/05/2019 The style of selected odd rows of TreeTables were not consistent with even rows. * 06/30/2019 Updated to the latest Netty and PDFBox dependencies. * 06/30/2019 The manual was missing from the distribution. * 06/30/2019 Converted the manual to Latex and moved to manual process to improve build performance. * 06/15/2019 Improved the speed of detecting a non converging IRR calculation. * 06/15/2019 Protect against Reminders without descriptions. * 06/14/2019 Tabular reports may now be exported to a spreadsheet. * 06/01/2019 Updated to the latest Hibernate dependency. * 05/26/2019 The current budget period is now highlighted for easier identification. * 05/26/2019 Selected table rows were not using the user configured focus color. * 05/22/2019 Added a context menu to copy selected transactions to the clipboard. == Release 3.1.0 __(File format change)__ * 05/19/2019 Added a Today button to the Budget toolbar for easy refocus of the current period. * 05/18/2019 The starting month for Budgets is now configurable. * 05/04/2019 Correction for reports with running totals between periods incorrectly hiding accounts when the 'hide zero balance accounts' box is selected. * 05/03/2019 Improved the size behavior for Alert dialogs on 4K displays (GitHub Issue #82) * 05/02/2019 Updated to the latest Netty dependency. * 04/28/2019 Cleaned up selection focus visual issues in the Budget view caused by poor JavaFX behavior. * 04/28/2019 Protect against unwanted Budget column reordering. * 04/28/2019 The account column width may now be changed in the Budget view. * 04/28/2019 Updated to JavaFX 12.0.1 dependency * 04/27/2019 The rounding mode and scale for Budgets is now configurable. * 04/24/2019 A JavaFX exception was being thrown during underlying changes to a budget. == Release 3.0.4 * 04/15/2019 The jGnash launch script now works correctly when double clicked in MacOS. (GitHub Pull Request #80) _[Pranay Kumar]_ * 04/14/2019 Disable the Portfolio report if there are not any investment accounts. * 04/14/2019 A NPE could occur if the last investment account was deleted after showing the Portfolio report. * 04/13/2019 Eliminated zero width spaces from the export of a register to a xls/xlsx file. * 04/13/2019 Updated to latest POI and PDFBox dependencies. * 04/07/2019 A Reminders chapter was added to the manual. * 04/09/2019 Made the Enabled check box of the New Reminder dialog enabled by default. * 04/07/2019 Improved shutdown speed if background events are occurring. * 04/05/2019 Made internal changes to prevent race conditions during a shutdown. * 04/05/2019 Calculate opening balance if user changes reconcile date. (GitHub Pull Request #79) _[Pranay Kumar]_ * 04/04/2019 Prevent an NPE caused by a race condition between recurring transactions being processed and an application shutdown in process. (GitHub Issue #78) * 04/04/2019 Prevent accumulation of stale internal listeners that would result in wasted system memory and a slowdown during a long running session. == Release 3.0.3 * 04/01/2019 Enhanced error handling for relational database to make identification of errors easier. * 04/01/2019 Correct validation of numeric input when using a comma as a decimal separator. (GitHub Issue #77) == Release 3.0.2 * 03/31/2019 Closing a register window with CTRL-F4 was not working. * 03/31/2019 Added a command line option to bypass the bootloader. * 03/31/2019 The wrong version information was being reported on the console when requested. * 03/30/2019 Automatic column widths will now update correctly if numeric or date formats change. * 03/30/2019 Use a full commodity format for the Total column in the investment register. * 03/30/2019 Changes to preferred date and numeric formats will now trigger an immediate update of the active register. * 03/29/2019 Improved detection and handling of invalid decimal input. * 03/29/2019 Removed direct print support from the report dialog. The user can use save the PDF and print it. * 03/28/2019 Reimplemented the page format dialog for reports to address OSX issues and to allow custom paper sizes. * 03/28/2019 Prevent an NPE from a race condition between a background security price update and an application shutdown. * 03/27/2019 Prevent an NPE from occurring when closing after a report has been shown and GC is slow. * 03/26/2019 Improved the update behavior when performing a Save As and when packing databases. * 03/26/2019 The pack database action was not listing .mv.db files. * 03/24/2019 Cleanup of language files to make translation and updates easier. == Release 3.0.1 * 03/22/2019 Corrected a very rare concurrency exception when retrieving investment accounts using a relational database. * 03/23/2019 Updated to the latest Hibernate dependency. * 03/22/2019 Updated Russian translation. _[pchurzin]_ * 03/22/2019 Reporting would fail if Java encountered a font file it did not like. * 03/21/2019 Fixed wrong currency symbol in Debit/Credit Columns if using a full format. (GitHub Issue #75) * 03/19/2019 Do a better job of reporting bootloader network errors. * 03/19/2019 Disabled Ctl-C shortcut for closing a file (Conflicts with a paste command). * 03/18/2019 Prevent an exception from occurring if a default directory does not exist. * 03/16/2019 Changed the download link in the Windows launcher to use a correct JDK. == Release 3.0.0 * 03/14/2019 Updated to the latest H2 and Netty dependencies. * 03/13/2019 Control of report resolution was added to the Balance Sheet and Net Worth reports. * 03/12/2019 Corrected localization issues with the Default Currency and Locale selection dialogs. * 03/12/2019 Updated to JavaFX 12 (Java 11 Compatible). * 03/10/2019 Made the Options dialog accessible without a file loaded. * 03/10/2019 Number formats can now be chosen using the Options dialog and are more consistent. This allows full control of the display of register values and provides a work around for a known JDK 11 bug. * 03/10/2019 Date format selection was moved to the Formats Tab in the Options dialog for UI consistency. * 03/08/2019 Disabled generation of the faulty -fx.bat file in the distribution. * 03/07/2019 Decimal fields now use an internal math interpreter instead of the Javascript interpreter. * 03/05/2019 Updated to latest h2, Apache Poi, and sl4j dependencies. * 03/05/2019 Minor internal changes to take advantage of Java 11 APIs. == Release 3.0.0-b1 * 02/27/2019 jGnash is designed to operate with Java 11 and newer. * 02/27/2019 Removed support for old Swing UI. * 02/27/2019 Jasper is no longer used for report generation. jGnash now uses it's own internal reporting API. == Release 2.36.2 * 02/17/2019 Fixed an issue preventing the old Swing UI from running with Java 11 (Swing). * 02/10/2019 Prevent an exception when importing odd OFX files using an XML declaration. (GitHub Issue #72) * 02/10/2019 Update to the latest Hibernate, Netty, and HikariCP dependencies. This improves compatibility with Java 9+. * 01/14/2019 jGnash would not start on a early access version of Java 8 (Swing, Fx, GitHub Issue #71) * 01/11/2019 Corrected an exception when the date picker was cleared and focus was lost (Fx, GitHub PR #70) _[pchurzin]_ * 12/24/2018 Updated Polish translation (Swing, Fx) _[Sławomir Szarkowicz]_ * 12/24/2018 Fixed several localization issues reported by Sławomir Szarkowicz. * 12/24/2018 Corrected a Runtime exception when trying to create a new file for locales without a country specified (JavaFx, Bug #65) _[valnaumov]_ == Release 2.36.1 * 11/06/2018 Updated to the latest Commons CSV dependency. * 11/05/2018 Potential fix for a ConcurrentModificationException when changing budget properties (Swing, Bug #64) * 11/04/2018 Updated to the latest Hibernate, Netty, XStream, and JUnit dependencies. * 11/01/2018 Adjust width of the date column to match entry format and font scale. (Fx, GitHub Issue #63) * 10/07/2018 Improved handling of OFXv2 files with incorrectly escaped XML characters. (Swing, Fx, GitHub Issue #61) * 10/01/2018 Currency exchange rate is working again. Yahoo continues to lock down their API. (Swing, Fx) _[Pranay Kumar]_ * 10/01/2018 Updated German translation. (Swing, Fx) _[Alex Werz]_ * 09/30/2018 Fixed an NPE when an ISIN was not specified for a security. (Swing, Fx) _[Pranay Kumar]_ * 09/16/2018 The new file wizard would not behave correctly if the task list was used instead of stepping sequentially using the Next button. This also impacted the Import Wizard. (Fx) == Release 2.36.0 * 09/13/2018 Enhanced the MT940 parser to allow for an optional currency designator in decimal values. (Swing, Fx) _[Alex Werz]_ * 09/13/2018 Reinstated check and correct for data files with multiple root accounts and config objects. (Swing, Fx) * 09/13/2018 The Fx interface now uses picocli for command line processing. (Fx) * 09/13/2018 The old Swing interface no longer supports command line processing. (Swing) * 09/10/2018 Fixed a bug that was preventing initialization of a new user specified portable preference file. (Fx) * 09/09/2018 Fixed a random stability issue with client / server operation discovered during unit testing. (Swing, Fx) * 09/09/2018 Updated to the latest Apache POI dependency. * 09/09/2018 Updated manual with proper use of escape characters on the command line for file names. * 09/06/2018 Dropped use of log4j as it is no longer a needed dependency. == Release 2.35.1 * 08/26/2018 Updated to the latest Netty dependency. * 08/25/2018 Fixed a bug when loading files using a very old UUID format. (Swing, Fx) * 08/24/2018 Fixed several large memory leaks in the jGnashFx user interface. (Fx) * 08/18/2018 Tightened up API for adding and removing securities to accounts to prevent corruption. * 08/17/2018 Updated to the latest Hibernate dependency. == Release 2.35.0 __(File format change)__ === Notes: Relational databases will need to be saved to a .xml or .bxds file format in the prior release of jGnash. They may be saved back to a relational database format afterwards. * 08/12/2018 Fixed a layout bug that was preventing the Investment Transaction dialog from showing the full form. (Fx) * 08/12/2018 Improved the layout behavior of the Transaction dialog. (Fx) * 08/12/2018 Fixed a bug that was causing decimal artifacts to occur in empty rows of the reminders table when using Java 10. (Fx) * 08/12/2018 Prevent an "illegal reflective access operation" from occurring on Java 9 and newer. * 08/10/2018 Reimplemented the detailed gains and loss control to support use in Java 10. (Fx) * 07/29/2018 Changes were made to make migration to Java 10+ easier. * 07/29/2018 Use Stax instead of kxml to make migration to Java 10+ easier. * 07/28/2018 Migrated test system to JUnit 5. * 07/28/2018 Updated to the latest Netty dependency. * 07/06/2018 Reduced memory usage and improved performance for relational database users. * 07/01/2018 Removed support for handling old XML file formats from 2017 and detection of 1.x files. * 07/01/2018 Removed Dump Heap button from Console Dialog because API use is restricted in Java 9 and newer. (Swing) * 06/24/2018 Replaced c3p0 with HikariCP for reduced application size and improved performance. * 06/24/2018 Updated to the latest Hibernate dependency. This breaks schema compatibility with older relational database files. * 06/24/2018 Dropped support for the old jgnash Hibernate persistence unit / schema. == Release 2.34.1 * 06/07/18 Updated Russian translation. (Swing, Fx) _[pchurzin]_ * 06/07/18 Updated to the latest Hibernate, Netty, Hsqldb, and DynamicJasper dependencies. * 06/06/18 Remove stale relational database lock files if a crash had occurred. * 03/28/18 Updated to the latest H2 dependency. * 02/06/18 Improved snooze behavior for reminders. (Fx) _[leeboardtools]_ * 02/05/18 Corrected a race condition in the transaction register that would cause a rare sorting issue and IndexOutOfBoundsExceptions. (Fx) * 02/03/18 Updated to the latest Netty dependency. * 02/02/18 Corrected an IllegalStateException when manually reconciling transactions. (Fx) * 01/12/18 Nested Investment accounts were summing with small fractional errors depending on Market price. (Swing, Fx) == Release 2.34.0 * 01/06/18 Significant update to the Polish translation _[Sławomir Szarkowicz]_ * 01/06/18 Updated to latest Netty dependency. * 12/10/17 Another significant update for the zh-ch locale translation. (Swing, Fx) _[kevinzhwl]_ * 12/02/17 The Portfolio report now calculates Internal Rate of Return. (Swing, Fx) _[t-pa]_ * 11/28/17 Improve MT940 import to handle Kontobezeichung (Swing, Fx) _[laeubi and sschuberth]_ * 11/22/17 Prevent a deadlock due to a poor or corrupt printer configuration at the OS level. (Swing, Fx) * 11/22/17 Fixed a Platform thread exception on exit when the application was closed soon after closing a Reminder dialog. (Fx) * 11/19/17 Expanded the import filter script interface to allow advanced manipulation of ImportTransactions. (Fx) * 11/18/17 Switched from Opencsv to Apache Commons CSV for exports to reduce distribution size and dependencies. (Swing, Fx) == Release 2.33.2 * 11/12/17 The opening Reconcile dialog now has a button to calculate ending balance based on the closing date. (Fx) _[Pranay Kumar]_ * 11/11/17 The Reminders dialog would not close properly if dismissed with a button. (Fx) * 11/11/17 The Reminders dialog was not correctly restoring the last used snooze period. (Fx) * 11/11/17 Corrected a Hibernate configure error for Account objects that may have been causing subtle bugs. (Swing, Fx) * 11/08/17 New workaround for Yahoo discontinuing a portion of their Securites history API. (Swing, Fx) * 11/06/17 Correct handling of special characters when importing OFX files. (Swing, Fx, GitHub Issue #35) * 11/06/17 Ignore cash transfers for dividends in realized gains calculations. (Swing, Fx) _[t-pa]_ * 11/06/17 Significant update for the zh-ch locale translation. (Swing, Fx) _[kevinzhwl]_ * 10/22/17 Updated to latest Hibernate and Netty dependencies. * 08/19/17 Switched from a MD5 to SHA-256 hash function for encrypted client / server operation. (Swing, Fx) * 08/19/17 Protect against the import of an OFX file with malicious content. (Swing, Fx) == Release 2.33.1 * 08/18/17 Corrected an issue with optimal Date storage in xml and bxds files caused by a regression introduced in 2.33.0 * 08/16/17 The OFX export will now generate required information for transfer between accounts. (Swing, Fx) * 08/15/17 Simple use of portable preferences was failing with use of -p or --portable. (Swing, Fx) * 08/15/17 Display a console message with the successful uninstallation of application preferences. (Swing, Fx) * 08/15/17 The command line help system was not being displayed on the console correctly. (Swing, Fx) * 08/14/17 OFX import now supports transfers between accounts. (Fx) * 08/13/17 The open dialog was incorrectly allowing selection of a file when a remote connection was selected. (Fx) * 08/13/17 Internal were changes made to allow operation with Java 9 with the addition of the command line option `--add-modules java.xml.bind`. (Swing, Fx) == Release 2.33.0 * 08/11/17 Updated to the latest Netty dependency. * 08/09/17 Implemented amortized payments for the Fx interface. (Fx) * 07/31/17 Build system has been converted to Gradle. Unix executable shell scripts are now provided. * 07/31/17 Updated to the latest jopt-simple dependency. * 07/09/17 Updated to the latest DynamicJasper and JasperReports dependencies. * 07/09/17 Updated to the latest Apache POI dependency. * 07/02/17 Further improvements to handling Yahoo Fiance API errors. * 07/02/17 Potential fix for a Budget Exception occurring when OSX users are using a relational database. * 06/19/17 Added a command button to execute a Reminder on demand. (Fx) * 06/16/17 Updated to the latest Netty dependency. * 06/16/17 Updated to the latest XStream dependency. * 06/16/17 Updated to the latest H2 database dependency. == Release 2.32.0 * 06/13/17 Updated to the new Yahoo Finance API for retrieving historical stock price information. * 06/12/17 The security history chart would incorrectly show a prior chart if no data existed. (Fx) * 06/11/17 Updated to the new Yahoo Finance API for retrieving dividend and stock split information. * 06/03/17 Expanded the manual content for importing transactions. * 06/03/17 Fixed a regression that was preventing the selection of the transaction's account when importing. (Fx) * 05/30/17 Added the ability to pre-process imported transaction memos and payees using user supplied JavaScript. (Fx) * 05/28/17 Minor improvements to the button behavior when editing the transaction number list. (Fx) * 05/22/17 Updated to the latest Netty dependency. * 05/14/17 Minor internal changes to remove the dependency on ControlsFX. (Fx) * 05/14/17 The Enter button should be disabled if the form is not valid for investment transactions and split entries. (Fx) * 05/13/17 Reworked exchange rate popup because display quality was inconsistent when first shown. (Fx) * 05/11/17 Fixed missing icons for the currency exchange rate dialog. (Fx) == Release 2.31.0 * 05/10/17 Added a General configuration option to allow full manual control of table column widths. (Fx) * 05/10/17 The Options dialog now remembers the last tab that was used. (Fx) * 05/08/17 Corrected handling of OFX files written with a windows-1252 character set. (Swing, Fx) * 05/08/17 Prevent ghosting horizontal scrollbars when resizing the main window. (Fx) * 05/07/17 Table Column sizes (register & reconcile) are now correctly remembered, restored, and scaled. (Fx) * 05/06/17 Updated to the latest Netty dependency. * 05/06/17 The reminder dialog now closes itself automatically if it was shown in the background while a file close was started concurrently. (Fx) * 04/28/17 Dependency on FontAwesomeFx is no longer needed. (Fx) * 04/24/17 Updated to the latest H2 database dependency. * 04/17/17 Yahoo Security Download now requires use of HTTPS for downloads. (Swing, Fx) * 04/17/17 Improved sizing of the open dialog for the Fx interface (Fx, GitHub Issue #25) _[Pranay Kumar]_ * 04/17/17 Cleaned up build system. JGoodies dependencies now come from Maven Central * 04/15/17 Updated to the latest Hibernate and HSQLDB dependencies. * 04/10/17 Corrected an IndexOutOfBoundsException occurring during Transaction import (OFX, QIF) of a quantity not large enough to fill the table. (Fx) * 04/09/17 Entry of date separators is now more flexible and allows use of ',' '.' '/' and '\' characters for all locales. (Fx) * 04/09/17 Relaxed date entry requirements. Single digit months may be now be typed in. (Swing, Fx) * 04/09/17 The Account Register report was not reporting split entries correctly and consistent with the UI. (Fx) == Release 2.30.0 * 04/09/17 Fixed a bug that was causing Buy and Sell transactions not using the cash balance of the investment account to generate an incorrect cash account amount. (Fx) * 04/06/17 Fixed an issue with importing OFX 1.x files with ugly white space formatting. (Swing, Fx) * 03/30/17 Added support for the H2 MVStore database file format. * 03/30/17 Updated to the latest H2 database dependency * 03/26/17 Updated to the latest Hibernate dependency. * 03/25/17 The payee for Reinvested Dividends was not being generated correctly. (Swing, Fx) * 03/24/17 OFX import of investment transactions is supported for Buys, Sells, Dividends, and Reinvested Dividends. * 03/22/17 Corrected a random IllegalStateException occurring during transaction edits. (Fx) * 03/11/17 Updated to the latest Netty dependency. == Release 2.29.0 * 02/25/17 Improved UI performance when performing large batch updates of transactions. (Fx, GitHub Issue #23) * 02/24/17 Updated to the latest Hibernate dependency. * 02/22/17 Backup files were not being preserved correctly in some instances depending on the pattern of the file names in the same directory and if they contained a '-' character. (Swing, Fx) * 02/13/17 jGnashFx Users are required to use Java 8 Update 71 or newer due to critical Java bugs. (Fx) * 02/11/17 Clicking on an Income or Expense bar within the Income Expense Bar Chart will show the details for the period within a pie chart. (Fx) _[Pranay Kumar]_ * 02/07/17 Improved UI behavior when performing a large batch delete of transactions. (Fx) * 02/06/17 An OFX import now prevents initial assignment to the wrong account type. (Fx) * 02/03/17 Updated to the latest Netty and JOpt Simple dependencies. * 02/01/17 Fixed a StringIndexOutOfBoundsException that was occurring when escaping out of a text field on MacOS. (Fx) * 01/30/17 Fixed a NPE that was occurring when importing transactions. (Fx) * 01/30/17 Corrected an OFX import regression that reduced effectiveness of detecting a duplicate import. (Swing, Fx) * 01/29/17 Entity trash was being checked too frequently. (Swing, Fx, GitHub Issue #21) == Release 2.28.4 * 01/26/17 Fixed an OFX import bug. File header was in an unanticipated format that prevented correct identification. == Release 2.28.3 * 01/23/17 Manual was expanded with specifics of transaction entry * 01/20/17 Updated to the latest Hibernate and HSQLDB dependencies. * 01/18/17 Corrected a performance regression loading and saving bxds and zip files introduced in 2.28.0. * 01/17/17 Updates and corrections to translations. Parts of text for some languages were corrupt due to an editor bug. * 01/15/17 More stability improvements when under heavy background loads and using a relational database. == Release 2.28.2 * 01/14/17 Corrected a bug with file locking on Windows OS. * 01/14/17 Added the Account and Amount columns to the Reminders table. (Fx) * 01/14/17 Corrected errors with the Polish translation. (Sławomir Szarkowicz) * 01/14/17 Fixed a regression that removed the Ticker/Investment column from the Investment account register. (Fx) == Release 2.28.1 * 01/14/17 Corrected issues with inconsistent behavior of the reported memos of split transactions. (Swing, Fx) * 01/14/17 Updated to the latest Netty dependency. * 01/08/17 The Investment Register was not sizing the Quantity column correctly. (Fx) * 01/08/17 Fixed an IllegalArgumentException that was occurring if the option "Next time jGnash starts" was used when dismissing the Reminders dialog. (Fx) * 01/08/17 jGnash now uses a priority based model for the handling of internal tasks to prevent deadlocks from background operations. This corrects some reported bugs with random freezing and hanging of the UI. (Swing, Fx) * 01/07/17 Updated to the latest Hibernate dependency. * 12/01/16 Corrected an NPE that was occurring during import of a OFX/QFX files. (Fx) * 11/30/16 Improved the behavior of background removal of securities history. == Release 2.28.0 * 11/27/16 Added the transaction timestamp to the CSV export. (Swing, Fx) * 11/27/16 The xls and xlsx Account exports were broken by the addition of timestamps in Release 2.27.0. (Swing, Fx) * 11/27/16 Updated to the latest Hibernate, DynamicJasper, JasperReports and OpenCSV dependencies. * 11/26/16 The Account Register report was broken by the addition of timestamps in Release 2.27.0. (Fx) * 11/26/16 An exception was occurring if Budgeting was being used and the window was too small to display data. (Fx) * 11/26/16 Corrected sizing issues in the Budget interface if the screen was very wide and the budget was configured to use a small number of periods. (Fx) * 11/26/16 Column widths are preserved so automatic column width resizing is less notable. (Fx) * 11/26/16 Fixed a NPE triggered by a file load while a file is already being loaded. (Fx) * 11/24/16 Columns were not resize correctly when adding or removing transactions. (Fx) * 11/24/16 Delay the upgrade notification a bit more for a cleaner startup for some users. (Fx) * 11/23/16 Added capability to cull down unneeded securities history as a background operation. This can reduce file size and improve overall performance. (Fx) * 11/23/16 Improved performance when using a relational database and updating securities history. (Swing, Fx) * 11/20/16 jGnash would stall and appear to be hung if a large group of transactions or security history was being removed when using a relational database. (Swing, Fx) * 11/14/16 Corrected a rare ConcurrentModificationException on systems under heavy loads. (Swing, Fx) * 11/08/16 Account ComboBox selection can be made using the first letter of the account. (Fx) _[Pranay Kumar]_ * 11/08/16 Up and Down arrows can be used for selection within the Transaction number box * 11/05/16 Improved visual feedback for placeholder accounts and sums in the Budget interface. (Fx) (Feature Request 116) == Release 2.27.0 * 11/05/16 Improved window positioning behavior on multi-monitor systems. (Fx) * 11/05/16 The Budget Goals dialog had the wrong title and layout behavior was poor. (Fx) * 11/03/16 Budgets results may now be display as running totals instead of per period values. (Fx) * 10/26/16 Improved the density and layout stability of the budget view. (Fx) * 10/24/16 Added selection buttons to the Reminders notification dialog to reduce required effort. (Fx) * 10/24/16 The Periodic Account Balance chart was improved with more selectable periods and better button names. (Fx) * 10/24/16 The Income and Expense bar chart was freezing due to an infinite loop. (Fx) _[Pranay Kumar]_ * 10/24/16 Improved stability of test and build for slow or virtualized systems. * 10/23/16 The focus and accent colors for the Fx UI can now be changed. (Fx) * 10/22/16 Corrected some font scaling issues within the UI. (Fx) * 10/22/16 Improved column layout behavior when changing the default font scale. (Fx) * 10/18/16 Transaction timestamps are now strictly controlled. Alternation of a transaction including reconciliation will alter the timestamp. * 10/18/16 A transaction entry timestamp column has been added to the register. It is hidden by default. (Fx) * 10/17/16 Prevent an IllegalArgumentException from occurring if a default or prior directory is missing when attempting to select a file. (Fx) * 10/16/16 Added an option to control how Bayesian model matches transactions to accounts when importing. (Fx) * 10/16/16 Make the last selected transaction account sticky for the OFX/QIF/MT940 Import wizard. (Fx) * 10/14/16 The OFX/QIF/MT940 Import wizard was not displaying consistent precision for transaction amounts. (Fx) == Release 2.26.1 * 10/13/16 Binary and XML files were not loading in the Fx interface. (Fx) == Release 2.26.0 __(File format change)__ === Notes: H2 databases will be upgraded automatically. HyperSql (*.script) databases will need to be saved to another file format in the prior release of jGnash. They may be saved back to a HyperSql database afterwards. * 10/12/16 Added an option to invert the sign of Credit, Liability, Equity, and Income accounts for the Periodic Account Balance chart. * 10/10/16 Fixed another transaction and security history concurrency bug for relational databases. (Swing, Fx) * 10/09/16 The initial check for recurring transactions / reminders was taking too long. (Fx) * 10/04/16 Add utility method to pack and upgrade older databases. (Fx) * 09/07/16 Added work around for JavaFx bug JDK-8132897. Using a ComboBox was causing an application crash when running on the Windows platform. (Fx) * 09/03/16 Improved predictability of the sort order of transactions with the same date without an assigned transaction number. (Swing, Fx) * 08/29/16 Internal cleanup, removal of duplicated code. * 08/28/16 The Open dialog was not behaving correctly if a remote connection was used for the prior session. (Fx) * 08/21/16 Updated to the latest Hibernate dependency. HyperSql (*.script) users will need to save to a different file format before updating to this release. You may revert back to HyperSql after the upgrade. * 08/21/16 Removed support for corrections to older file formats (Prior to release 2.22.0). Existing files must have been loaded with jGnash release 2.22 - 2.25 prior to using with this release. == Release 2.25.0 * 08/20/16 JavaFx interface is now considered stable for daily use. Remove -ea suffix off executables. (Fx) * 08/16/16 Fixed a rare transaction and security history concurrency bug for relational databases. (Swing, Fx) * 08/11/16 Running totals for Spit transaction forms were not updating correctly. (Fx) * 07/10/16 Command line options have changed, they now use '--' instead of '-'. See the manual for details. (Swing) * 07/05/16 Integrated help has been removed from the Swing interface. Help is provided with the supplied Manual.pdf. The old help system was limiting the type and quality of documentation that could be generated. (Swing) * 07/05/16 The Check Designer Dialog button texts were not displayed correctly. (Swing) * 07/04/16 The mt940 import now works with the Fx interface. (Fx) * 07/04/16 Plugin API has been changed to allow a plugin to support the Swing and Fx interface. (Swing, Fx) * 07/02/16 Plugin API implemented for the Fx interface. (Fx) * 06/30/16 Plugins may now be placed in $HOME/.jgnash/plugins for *nix based OS's or C:\Users\user\AppData\Local\jgnash\plugins for Windows users. This makes upgrades easier for custom plugins. * 06/26/16 Plugins were not being loaded from the correct location. This prevented the mt940 plugin from loading. (Swing) * 06/26/16 Added print capability and a status bar to the transaction attachment viewer. (Fx) == Release 2.24.0 * 06/23/16 Improved performance for loading large files, working with large accounts and reports. (Swing, Fx) * 06/22/16 Fixed some bugs related to editing of split transactions. (Fx) * 06/22/16 Enable use of concatenated memos within the Swing interface. (Swing) * 06/19/16 Improve handling of JDBC connection errors. (Swing, Fx) * 06/19/16 The Fx UI would not completely shut down in some rare instances. (Fx) * 06/18/16 Shutdown of server was not working correctly. (Fx) * 06/17/16 Implemented use of command line options for the Fx interface. (Fx) * 06/09/16 Implemented the Payee Pie chart for the Fx interface. (Fx) * 06/02/16 The Income and Expense Pie chart now uses a DoughnutChart variation. (Fx) * 05/27/16 Update to the latest Netty and include only the needed dependencies. (Swing, Fx) * 05/26/16 Improved the name of the application in the OSX and Gnome global menu. (Fx) == Release 2.23.0 * 05/14/16 Added the Periodic Account Balance report. (Fx) * 05/04/16 Implemented the Reminders dialog for the FX interface. (Fx) * 05/03/16 Implemented the "Shutdown Server" command for the FX interface. (Fx) * 05/03/16 Added access to the Budget Manager from the Tools Menu. (Fx) * 05/02/16 Implemented "File | Save As" capability for the FX interface. (Fx) * 05/02/16 Implemented Password Change capability for relational databases. (Fx) * 05/01/16 Modified transactions were not refreshing consistently in the register table. (Fx) * 04/26/16 Fixed import of an account tree when using a relational database. (Swing, Fx) * 04/25/16 Added Account structure import and export capability. (Fx) * 04/24/16 Display a wait indicator when a generating a large report. (Fx) * 04/24/16 Added the Account Register Report. (Fx) * 04/24/16 Added a Memo specific column to the investment register table and separated the Investment column (Fx) * 04/17/16 Added an option to the split entry dialog to automatically concatenate the memos of split transactions. This will reduce file size if used and reduces split transaction entry effort. (Fx) * 04/15/16 Display a message at startup when a newer version is available for download. (Swing, Fx) * 04/10/16 Language files now use UTF-8 file encoding. (Swing, Fx) == Release 2.22.1 * 04/03/16 Fix for Fx UI font scaling issues for locales that use a comma for the decimal separator. (t-pa) * 03/29/16 Preserve the tree structure in budget exports. (t-pa) * 03/28/16 Fixed random deadlocks when loading budgets in the Swing interface. (t-pa) * 03/28/16 Corrected budget calculations for mixed child account types. (t-pa) * 03/28/16 Added Polish translation (Sławomir Szarkowicz) * 03/22/16 Fixed broken OFX export. * 03/09/16 Correct issues with table column widths sizing themselves incorrectly. (Fx) * 03/07/16 Budgets were not calculating net period amounts correctly for income and expense accounts. (Bug #216) (Swing, Fx) * 02/28/16 Enable automatic load of the last file for the Fx interface. (Fx) * 02/28/16 Force the Fx interface on Windows to use 95% font scaling for work around potential Hi-DPI display bugs. (Fx) * 02/28/16 NPE was occurring when editing transactions with an empty payee or memo's. (Fx) * 02/28/16 OFX/QFX files with a capitalised file extension were not visible for import. (Fx) == Release 2.22.0 __(File format change)__ * 02/20/16 Fixed behavior of manual date input. It would sometimes reposition the caret and ignore input. (Fx) * 02/18/16 Fixed a bug that was preventing removal of stale data from the relational database file formats. * 02/14/16 Changed storage format for Budgets * 02/09/16 Enable Menu mnemonics for platforms that support it (Fx, Windows). Changed mnemonics design so it is easier for translation and works for both Swing and Fx interfaces. * 02/08/16 Added the Net Worth Report. (Fx) * 02/06/16 Added the Balance Sheet Report. (Fx) * 02/05/16 Added the Profit Loss Report. (Fx) * 01/29/16 Added the Portfolio Report. (Fx) * 01/29/16 The running balance in the register was not updating correctly with transaction changes. (Fx) * 01/25/16 The transaction number ComboBox would not always capture a manually entered value. (Fx) * 01/18/16 Incorrect accounts were available for selection in the account ComboBox. (Fx) * 01/10/16 Added the Monthly Account Balance CSV export report to the jGnashFx UI. (Fx) == Release 2.21.0 * 01/09/16 Fixed a bug that was causing Investment Accounts to loose Securities resulting in exceptions. It was triggered when a Security was added to more than one Investment Account and only users of a relational database would be impacted. Files will be repaired automatically (Swing, Fx) * 01/06/16 Fixed a regression that was preventing creation of new Reminders. (Fx) * 01/06/16 The Reminder table was not updating correctly after a new recurring event had occurred. (Fx) * 01/04/16 The Account type was being corrupted for top level accounts when editing properties. (Fx) * 01/04/16 The default account type was not the same as the parent when creating a new account. (Fx) * 12/27/15 Set an explicit and stable sort order for budget account groups. (Swing, Fx) * 12/14/15 Added an Income / Expense Bar Chart report to the jGnashFx UI. (Fx) * 12/13/15 Month labels for tabular reports were off by one. (Swing) * 12/13/15 Added the Profit and Loss text report to the jGnashFx UI. (Fx) * 12/12/15 Added an Income / Expense Pie Chart report to the jGnashFx UI. (Fx) * 12/12/15 The Profit and Loss text report was failing to execute. (Swing) * 12/08/15 An option to remember the last used transaction tab has been added. (Fx) * 12/07/15 An option to change button order has been added if you do not like the OS default. (Fx) * 11/27/15 The budget view will automatically focus the current period when first displayed. (Fx) * 11/27/15 Fixed an IndexOutOfBounds exception when reducing the displayed period count for a budget. (Fx) * 11/27/15 Improved column sizing for the account summary table within the budget view. (Fx) * 11/26/15 Improved general dialog sizing and positioning behavior. (Fx) * 11/24/15 The transaction register may now be filtered/searched by date, reconciled state, memo, and payee. (Fx only) == Release 2.20.0 * 11/22/15 Fixed several potential locale specific bugs. * 11/17/15 Added a context menu to the account tree table. (Fx) * 11/16/15 Right aligned decimal values in the account tree table. (Fx) * 11/16/15 Fixed the account code editing behavior from within the account tree table. (Fx) * 11/16/15 Completed implementation of Budgeting for the jGnashFx UI. * 11/15/15 Reduced distribution size by excluding compile time dependencies. * 11/11/15 Budgeting now uses the ISO 8601 standard for handling weekly periods for consistency. (Swing) * 11/11/15 Fixed several budgeting bugs related to 53 week years. (Swing) * 11/10/15 Fixed an error that would occur when creating a new file and the given filename extension did not match the selected file type. (Fx) * 11/10/15 Duplicate tabs were being created and an exception thrown when creating a new file. (Fx) * 11/05/15 Added the ability to change the default date format to something other than the locale default. (Swing, Fx) * 10/31/15 Fixed a file version check bug that was causing asset and liability accounts to lose their budget visibility. * 10/31/15 Internal test framework should not leave files lying around anymore. * 10/28/15 Fixed a bug with account combos not retaining their initial value. (Fx) * 10/27/15 Improved font appearance by forcing smoothing type to gray. (Fx) * 10/25/15 The last used tab of the primary interface is now restored at startup. (Fx) * 10/24/15 Windows were not saving their size and location because of a race condition. (Fx) * 10/24/15 Transaction number combo box was not working correctly. (Java Bug JDK-8136838 Fx) * 10/18/15 Improved the column packing speed of the transaction register. (Fx) * 10/17/15 The base font size will need to be reset after some code cleanup. (Fx) * 10/17/15 Avoid extraneous automatic securities price updates during the weekend if at least one has occurred. * 10/14/15 Fixed a race condition in the account ComboBox resulting in NPE when creating a new account. (Bug #212) (Fx) * 10/14/15 Increase the darkness of the alternating tabular data row color from 2% to 6%. (Fx) * 10/11/15 Added keyboard accelerators. (Fx) == Release 2.19.0 * 10/10/15 Cleaned up Transaction Import API. External import plugins will need to be updated. * 10/09/15 Updated to latest Netty release * 10/08/15 Improved layout behavior for investment transaction forms. (Fx) * 10/07/15 Fixed a transaction entry bug when selecting the next available check number. (Fx) * 10/05/15 Improved register layout. (Fx) * 10/05/15 The reconcile button in the accounts list was not working. (Fx) * 10/04/15 Fixed an NPE that could occur when creating a new account. (Fx) * 10/04/15 Icons behave better when the base color and font size is changed. (Fx) * 10/04/15 Implemented QIF import for the jGnashFx UI. * 09/25/15 The QIF import utility has been improved to make a better determination of the date format automatically. * 09/25/15 Some QIF imports would fail because of a date parsing regression in 2.17.0. * 09/20/15 Fixed an OFX and QIF bug that was preventing matches of previous and manually entered transactions. (Swing, Fx) * 09/20/15 Fixed an exception if an attempt was made to import an OFX or QIF file with a previously imported transaction. (Swing, Fx) * 09/20/15 Implemented OFX import for the jGnashFx UI. * 09/15/15 Fixed an exception when opening the Transaction Number configuration Dialog (Fx) * 09/15/15 Added XLS, and XLSX files to the existing export capability of the transaction register (Swing) * 09/15/15 Added CSV, OFX, XLS, and XLSX file export capability to the transaction register (Fx) * 09/14/15 Fixed ellipse mark that made it into the Open toolbar button (Swing) == Release 2.18.0 * 09/13/15 jGnashFx Early Access is now included with the distribution. * 09/08/15 The NetWorth and BalanceSheet reports were not including Simple Investment account types. * 09/06/15 Fixed QIF date parsing import bug introduced by 2.17.0 * 09/01/15 (FX) DatePicker now increments and decrements with use of vertical and horizontal scroll input * 08/29/15 Remove support for importing jGnash 1.x files == Release 2.17.1 * 09/01/15 Fix for a one day shift when converting Dates to LocalDates for XML and BXDS file formats. == Release 2.17.0 __(File format change)__ * 08/28/15 Automatic backup preferences are now stored within the data file. This is better for users working off of portable storage and multiple computers. You will need to update your preferences with this release. * 08/24/15 Securities historical charts now factor in stock splits and reverse splits. * 08/23/15 The JavaFx UI for Securities history allows manual edits of split and dividend history. * 08/16/15 File formats have changed and will not be backwards compatible with prior releases. * 08/16/15 Added framework for handling historical information for stock splits and dividends. (File format change) * 08/15/15 Migrated to use of the new Java 8 LocalDate classes. This improves the overall application performance. (File format change) * 08/12/15 Added RTF, and DOCX export capability for tabular reports. * 08/12/15 Updated to the latest DynamicJasper. * 08/11/15 Removed unused dependencies from the distribution files and build system. * 08/02/15 Dependencies updates. * 08/02/15 Temporally disable SSL jdbc connections until some bugs are sorted out. * 08/02/15 Encrypt client/server communications if a password is specified without requiring explicit enabling of encryption. * 07/31/15 Fixed a bug that would cause transfers of attachments in client/server mode to fail under Windows OS * 07/26/15 The exchange rate dialog was not showing the close button and the clear button was in the wrong location. * 07/22/15 Fixed a rare concurrency issue when updating securities history * 07/20/15 Fixed an issue with historical investment downloads timing out when using a relational database. * 07/11/15 Fixed issues when exporting an account structure when using a relational database. * 07/10/15 Removed the days past due field in recurring form. It's not needed because we have a Due date column now. * 07/09/15 Add Last Posted date and Due date columns to the reminder table for easy reference * 07/09/15 Add new capability to create a new recurring transaction from an existing transaction (context menu in the register) == Release 2.16.0 __(Java 8)__ * 07/03/15 Dependencies updates, fixes for some Hsqldb database issues and minor performance improvements. * 06/26/15 General cleanup and internal changes to support the new FX user interface in development. * 05/31/15 An exception would occur if a Security was removed immediately after it was created and loaded with history (Bug #208) * 05/31/15 Changed the reconcile checkbox to support three states for not-reconciled, cleared, and reconciled. * 05/25/15 Return of Capital transactions were not being shown in the register table correctly. * 05/14/15 Autocomplete now makes better choices for debit and credit transactions. * 03/14/15 Ensure directory has been created first before trying to write a file. * 03/14/15 Historical import dialog for securities did not correctly preset the prior month as intended. * 03/14/15 Java 8 is now required for 2.16.0 and newer == Release 2.15.2 * 02/12/15 Add tooltip to the investment gains and fees details buttons * 02/09/15 SecurityHistoryNodes are now immutable to prevent database corruption * 02/08/15 Insure resource cleanup if an SQL error is thrown * 01/31/15 Fix for potential resource leak when exporting budgets to a spreadsheet * 01/24/15 Fix sorting issues with securities and currency history dialogs * 01/23/15 Initial sort order for security history was incorrect for XML and BXDS file formats * 01/22/15 Dependencies updates, fix for some H2 database and Hibernate warnings == Release 2.15.1 * 12/24/14 Fixed import of an exported account tree * 12/24/14 Fixed security price import from Yahoo UK == Release 2.15.0 __(File format change)__ * 12/07/14 Display a warning dialog if loan amortization is not configured instead of logging to the status bar. * 12/03/14 Correct database at load if a transaction was incorrectly marked as orphaned and removable. * 11/26/14 The simple investment account type was moved to it's own group to improve program logic * 11/23/14 Active Account Securities were not marked to prevent removal in the Account Properties dialog. * 11/22/14 Updated Spanish translation (Marcelo Abeldaño) * 11/21/14 Reminder transactions were being incorrectly identified as orphaned. * 11/12/14 Improved sort capability. Accounts are now sorted by an account code and then by name. * 11/11/14 Added a Code property to Accounts. Codes can be change to suit users needs. (Changes file format) * 11/09/14 Improved reconciliation behavior. Reconcile Settings are remembered from prior sessions and are intelligently updated. * 11/09/14 Add sorting capability to the reconcile dialog tables. * 11/08/14 A dialog will now be displayed when the file has been automatically upgraded and a backup of the old version made. * 11/08/14 Relational database files will be altered automatically to address Hibernate Bug #HHH_9389 * 11/06/14 Settings for background updates of exchange rates and securities were moved into the data file (Changes file format) * 11/06/14 Reconcile settings were moved into the data file for consistent behavior when the file is shared on multiple systems (Changes file format) == Release 2.14.1 * 10/31/14 Fixed a bug that was preventing Securities history from being deleted if added within the same jGnash session for relational databases. * 10/31/14 Updated to latest Hibernate 4.3.7 release * 10/28/14 Minor translation improvements * 10/26/14 After exporting a budget to XLS, you can left align a numeric column to see indents. * 10/26/14 Fixed a bug with an empty account being changed into a placeholder account and retaining invalid budget goal information. Placeholder accounts should only roll-up child account goals. * 10/26/14 Bug fix for placeholder accounts not recalculating balances correctly if their currency is changed. * 10/24/14 Improve shutdown behavior when interrupting background updates. * 10/22/14 Fixed another race condition that could freeze the UI at startup * 10/21/14 Fix for incorrect totals for register reports with split details shown. The sum of the split was being calculated twice. Correct behavior is to not show the sum of the splits. * 10/21/14 Updated to the latest dependencies for report generation and XLS file exports. == Release 2.14.0 * 10/19/14 PDF version of the integrated help is now packaged with the zip distribution. * 10/14/14 When using the reconcile Wizard, Finish Later will now mark the transaction as Cleared and not Reconciled. * 10/12/14 Redesigned the reconcile behavior to use the statement end date. Public and internal API's have changed for reconciliation and may break plugins. * 10/12/14 Committing reconcile changes can take a long time when working remotely or using a relational database. Improve the UI behavior by showing a wait message instead of freezing the display. * 10/12/14 Changing the reconciled state of a transaction using the context menu was not following the rules of the selected register option. * 10/05/14 Bug fix for potential return of an incorrect closest by date market price for a security * 10/05/14 Bug fix for potential erroneous removal of the prior days security history during a market price update * 10/04/14 Bug fix for difficult to trigger Concurrent Modification error when updating stock prices * 09/26/14 Bug fix for false positives identifying duplicate transactions when importing QIF files. * 09/25/14 Handle non-standard OFX files that use commas as a decimal separator for amounts == Release 2.13.6 * 09/21/14 Updated to latest Insubstantial release. This fixes the Substance look and feel compatibility with Java 8 * 09/20/14 Updated to the latest JGoodies dependencies. This should improve font appearance on Windows systems in some instances * 09/20/14 Fixed the build process for the mt940 plugin so it always stays current * 09/07/14 Internal cleanup, improve relational database load behaviors * 07/29/14 Fixed a race condition that would cause a random NPE when loading security histories from a relational database * 04/28/14 Force eager load of budget goals to prevent a random NPE at file load when using a relational database * 04/28/14 Updated to the latest Netty * 04/17/14 Updated to latest H2 database release * 04/17/14 Updated to latest Hibernate 4.2.x release == Release 2.13.5 * 02/23/14 Fixed an NPE when cleaning out orphaned transactions from a prior jGnash bug * 02/22/14 Update to the latest HSQLDB database release * 02/22/14 Update to the latest H2 database release * 02/22/14 Update to latest XStream, security vulnerability CVE-2013-7285, an arbitrary execution of commands when unmarshalling * 02/09/14 Minor API changes to allow operation using Java 8 * 01/25/14 Fixed a rare ConcurrentModificationException that would occur when updating stock prices. * 01/12/14 Fixed another race condition that could freeze the UI at startup if loading a very large file. * 01/12/14 Any newly added or modified transactions will be highlighted in the register table for easy identification. * 01/11/14 Fixed a race condition that was preventing newly duplicated transactions from gaining focus in the register. * 01/11/14 When an account register was open in it's own window, window focus could be lost when deleting and duplicating transactions. == Release 2.13.4 * 01/01/14 Accounts appearing in the budget model now respect the budget visibility of the ancestor accounts. * 01/01/14 Fixed a bug with encrypted file attachment transfers * 12/31/13 Fixed a race condition that could hang the UI at startup when loading a large file. * 12/31/13 In some cases, a file would not reopen if a relational database was not closed cleanly. * 12/27/13 Update to the latest XStream * 12/26/13 Improve font appearance when running under KDE * 12/21/13 Correct Budget UI exceptions that were occurring when performing SaveAs operations. * 12/15/13 Show full currency formatting in the Budget display, otherwise, currency of the account is not obvious. * 12/15/13 A Java bug was preventing new files from being created if a default currency was not determinable. * 12/12/13 Changed the name of the Budget Column from "Change" to "Actual" to clarify intent. == Release 2.13.3 * 12/05/13 Client/Server communications are now fully encrypted if enabled from command line for supported locales. * 11/29/13 Allow loading of a file with duplicate Config objects. The file will be corrected at load time. == Release 2.13.2 * 11/17/13 A caching bug was causing the first transaction added to an account after restart of jGnash to show up twice. After restart the duplicate transaction would go away. == Release 2.13.1 * 11/12/13 Update to latest Netty, H2, and Hibernate dependencies. Users using H2 database may notice more consistent shutdown times. * 10/29/13 Fix a race condition that was causing an ArrayIndexOutOfBoundsException in the GUI when adding a new transaction. == Release 2.13.0 * 10/15/13 Correctly report and handle an attempt to open a wrong file type. (Bug #206) * 10/15/13 Correctly report an attempt to open a directory instead of a file. (Bug #205) * 10/02/13 Excess UI updates could occur when updating a budget goal and create performance issues. * 10/02/13 Fixed an exception that would occur when filling in a bi-weekly budget. * 08/13/13 Improved UI performance of the busy indicator on slower machines (Klemen Zagar) * 08/11/13 When saving a compressed backup on exit, use the OS's temporary directory to play nice with cloud services (Patch #55, Klemen Zagar) * 07/10/13 Update to the latest JFreeChart * 06/29/13 Added a new feature; Transactions may now have image attachments. * 06/20/13 New client server architecture based on Hibernate/JPA2 with H2 or HSQLDB SQL database. db4o support has been purged from the code base. * 06/10/13 Improve dialog positioning when using multiple monitors and when using fewer monitors than the last run. * 04/23/13 Use Netty instead of Mina for performance and for improved protocol support * 04/21/13 Added a Money Market account type. * 04/20/13 New Engine and account api for setting and accessing custom text based account properties. * 04/20/13 File schema changes to support external links to files and custom tags for transaction entries. * 04/04/13 Discover and remove orphaned transactions left behind when Reminders were removed. * 04/03/13 Improve the shutdown experience if a file is not open. * 03/30/13 Added a new command line option to enable the xrender pipeline for X11 based systems. * 03/28/13 Prevent background updates from running during a shutdown if performed right after startup. * 03/25/13 Create a versioned backup of the old file automatically if the file format has been changed. * 03/21/13 Changed binary and xml file structure for amortization objects. == Release 2.12.0 * 03/03/13 When importing transactions, display a tooltip for payee and memo fields to make transaction determination easier. (Feature Request #107) * 03/02/13 Automatically update the exchange rate tables when a multi-currency transaction is entered for a given date if one has not been set. * 03/01/13 Updated to DynamicJasper 4.0.3. * 02/24/13 Corrections made to the Portuguese translation. (Fernando Ribeiro da Silva) * 02/14/13 Updated the jGoodies libraries. * 02/12/13 Updated to XStream 1.4.4. * 02/12/13 Updated to SwingX 1.6.5. * 02/12/13 Updated to Apache POI 3.9. * 02/10/13 Changed the exit process so that the final file write and closure is complete before the UI disappears instead of afterwards. * 01/30/13 Improved the natural sort order of investment transaction for improved consistency (Date, Type, Memo, Security, Modification Date, Internal Id) * 01/20/13 Fix for IllegalArgumentException caused by reordering table columns * 12/09/12 Added register option to restore the last used transaction tab * 12/09/12 When modifying an existing account, the current account currency would not be set in the dialog correctly. * 12/02/12 Update to latest Substance L&F * 11/25/12 Make confirmation on transaction deletion the default. * 11/24/12 Added basic OFX export of accounts (Investment accounts are still a work in progress) * 11/23/12 Warn if you are using a db4o (jdb) and recommend that you save as another format * 11/10/12 Added CSV export direct from the transaction register. * 11/10/12 Backup files were not being created in the same directory as the data file. * 11/04/12 Update to Mina 2.0.7 == Release 2.11.0 * 10/24/12 Tabular style reports will start with a better default page size the first time the report is run. * 10/23/12 Reminder dates were not correct if it was modified after being executed. * 10/21/12 Display a message if an error occurs during a budget export (Read only file, etc) * 10/20/12 Reworked the Balance Sheet report. Results are displayed by period instead of a running balance and retained income / expense is calculated. * 10/14/12 Added a new Simple Investment account type. This can be used for Annuities or Guaranteed Retirement accounts that you cannot actively manage. * 10/14/12 Added a utility script that can be run to remove weekend security history. * 10/14/12 Update to the latest Insubstantial/Substance L&F release. * 10/13/12 Improve security price import from Yahoo. Dates returned from Yahoo are now used. This prevents history entries on weekends and financial holidays. * 10/13/12 Corrections to the reporting in the Income and Expense by Payee pie chart report as well as GUI behavior improvements. It now has a chart for debit and credits. (Pranay Kumar) * 10/13/12 Updated DynamicJasper to the latest release. * 10/11/12 Updated XStream and Mina dependencies to the latest releases. * 10/08/12 Add new controls to the historical security import dialog to make selection of securities faster and easier. * 10/07/12 The Income and Expense pie chart now displays the default currency in addition to the account currency when multiple currencies are being used. (Pranay Kumar) * 10/06/12 Added an option for matching to the last similar entry when entering transactions. (Pranay Kumar) * 10/03/12 Updated the Spanish Translation. (Marcelo Abeldaño) * 09/03/12 A exception would occur when trying to generate a loan payment with a zero percent interest rate. == Release 2.10.0 * 09/02/12 The Jump button would not work from a register in a separate window (Bug #3563951) * 09/02/12 Do not preload report fonts to reduce startup time and reduce memory usage if reports are not being generated. * 08/28/12 Changed busy indicator for significant memory usage reduction. * 07/17/12 Fix for printing checks on Windows printers. * 07/12/12 Dividends were not showing a correct value in the register total column (Bug #3526172) * 07/12/12 Code migrated to fully utilize Java 7 try-with-resources. * 07/07/12 Fixed a memory leak that was occurring when loading plugins * 05/27/12 Added workarounds for JVM bugs when using Gnome 3 and Cinnamon. Mouse behavior was not correct when the jGnash window was maximized. * 05/18/12 Imported transactions are automatically assigned an account using a Naive Bayes classifier. * 05/17/12 Improved imported transaction match against manually entered transactions == Release 2.9.0 * 05/03/12 Check for Java 7 or newer before executing * 05/02/12 Added an alternating pattern fill option to the budget goal entry dialog * 04/26/12 Strip extra white space when importing OFX files * 04/24/12 Warn if an attempt is made to modify a transaction with a locked account * 04/24/12 Correctly handle the modification of a transaction against a hidden account * 04/24/12 Mark newly imported QIF transactions so they can be considered for account matching (no change to file format) * 04/24/12 The account tree would not display correctly after a new file was created until open and closed. * 04/24/12 Make the new binary format the default for new files. * 04/23/12 A new file would not be created if the specified directory did not exist. jGnash will now create the directory tree automatically. * 04/09/12 Added new fast and compact binary file format * 04/07/12 Added a Smart fill panel to the budget goal entry dialog for historical entry and fill all * 03/18/12 Fixed the import of Citibank QFX and OFX credit card exports. * 03/17/12 Modularized jGnash into several Maven modules and separated the UI code from the core engine code * 03/17/12 jGnash was causing Java 7 JRE to seg-fault on close. == Release 2.8.0 * 03/10/12 Help build system no longer requires OS level installed dependencies * 03/05/12 Update to Insubstantial 7.1 and the latest JGoodies dependencies * 03/05/12 Fixed an NPE that would randomly occur at startup * 03/05/12 Corrected budget UI controls state when adding a budget for the first time and deleting the last budget * 03/04/12 Mavenized the help build system * 02/15/12 Printable reports can now be saved as xls files * 02/15/12 Improved mt940 import (Patch #3487030, Arnout Engelen) * 02/14/12 Fixed issue with large budget values being clipped in the budget UI * 02/13/12 Working xls and xlsx export of budget results * 02/12/12 Improve handling of multiple currencies in the budget UI == Release 2.7.0 * 02/08/12 Added functionality to sort the Profit and Loss report by Account balance and percentiles (Patch #3154343, Klemen Zagar) * 02/06/12 Removed duplicate code in budget results UI * 02/05/12 Fixed formatting of the creation date on printed and pdf reports * 02/05/12 Updated to latest DynamicJasper and associated dependencies * 02/05/12 Reduced complexity of the budget results UI code and eliminated redundant listeners * 02/04/12 Rewrote the budget results calculation code * 02/02/12 Updated Dutch translation (Patch #3482860, hellemans) * 02/01/12 Transactions may now be modified through arrow key selection inside the register (Patch #3481312, hellemans) * 01/29/12 Reworked the summary information for the budget view including the addition of a row footer and options to display the summary information == Release 2.6.2 * 01/21/12 Set the jGnash file filter as the default when choosing a file * 01/21/12 Improve budget UI performance when transaction event and budget changes occur * 01/19/12 Budget results would randomly show 0 if the CPU was heavily loaded * 01/15/12 Fix generation of weekly and bi-weekly budget dates for non-US locales; Do not assume Sunday is the first day of the week. * 01/11/12 Budget totals were calculated incorrectly after a budget's properties/period were modified == Release 2.6.1 * 01/08/12 A default user and password is now set if not specified when using client / server functionality * 01/08/12 Add a Yearly period option for Budgets * 01/08/12 Add a command line option to help detect UI code that hangs the EDT * 01/07/12 Corrected some UI update and threading and performance issues with the Budget interface * 01/03/12 Reinvested dividends were not showing a correct value in the register total column (Bug #3467513) * 01/02/12 Close any open windows first when closing a file * 01/01/12 Switched build system over to Maven and Ant hybrid * 12/28/11 Expand budgeting help for budget properties * 12/28/11 Add functionality to control account types for a budget (income, expense, asset, liability) * 12/28/11 Selected budget year was not be used when editing goals and switching between budgets * 12/26/11 Update to the latest, JGoodies, XStream, Mina, JFreeChart external dependencies * 12/25/11 Update to the latest args4j external dependencies == Release 2.6.0 * 12/24/11 Add help content for the new budget feature * 12/14/11 Additional fixes for hierarchical display of the budget * 12/13/11 Improved performance when working files with large account structure and many transactions. * 12/11/11 The Budget account structure was not consistently updating when accounts were added, remove, or changed. * 12/04/11 The total remaining for budgets periods was not calculated correctly (Chris Bunney) * 12/03/11 Add ability to break budgets and goals down to daily entry if desired * 12/01/11 Improve editing and focus behavior when changing budget goals * 12/01/11 Fix for NPE occurring with Metal look and feels * 11/26/11 Minor internal cleanup * 11/22/11 Use the meta key instead of the control key on OSX systems * 11/20/11 Completed fully functional hierarchical display for budgets * 11/12/11 Minor improvements for behavior and appearance when running on OSX * 10/25/11 Sum of transactions shown in the tooltip was not correct if the register was sorted. * 10/17/11 Investment transaction total values were not displayed correctly in the register Total column (Bug #3408123) * 10/15/11 Yahoo UK historical download address changed (Bug #3423566) * 10/15/11 Improved behavior of auto completion. Added an option to control the case sensitivity of the match. Don't replace the memo or amount and account selection if entered before the payee field is matched. (Bug #3407399, #3407400) * 10/07/11 Balance reversal selection was not being restored correctly in the option dialog (Bug #3417960) * 10/04/11 Fixed OpenJDK specific bugs * 09/15/11 Second period of the displayed budget was missing * 09/14/11 Fix bug with exceptions occurring in the budget interface when the account structure changed * 09/07/11 Improved overall UI layout for the new budget interface * 09/06/11 Internal code cleanup, PMD, etc. * 09/05/11 Menu items for Substance look and feels were not being selected when active (Bug #3404037) * 09/04/11 Fix for enabled symbol when a substance look and feel is used (Bug #3403710) * 09/04/11 Improve the behavior of the help dialog (Feature Request #3174487) * 09/04/11 Add a double click listener for modifying reminders (Feature Request #3403673) * 09/04/11 Add a delete key listener for reminders (Feature Request #3403736) * 09/03/11 Add Sparklines to the budget display * 09/01/11 Update default Portuguese accounts (Pietro A R CERCHIARI) * 08/29/11 Update Italian translation (Davide) * 08/26/11 Added a property to accounts to exclude them from budgets * 08/26/11 Added a field to the account properties dialog for a long hidden bank id property * 08/17/11 Fix a bug with UI actions not working when running from a jar file * 08/14/11 Add a summary footer to the budget view * 08/07/11 Remove locale specific information from CurrencyNode. db4o cannot persist Java 7 Locale correctly and the Locale specific information has not adding value. * 08/05/11 Fix Comparator so it plays nice with Java 7 (Exception: Comparison method violates its general contract!) * 08/03/11 Make the current period visible by default in the budget view * 08/02/11 Do not show hidden or locked accounts in the budget view * 08/02/11 Do not show hidden accounts in the account selection combo boxes (Feature Request #3384937) * 08/02/11 Show a tooltip in the budget views account header with the full account path * 07/31/11 Added function to create a new budget based on historical data. * 07/21/11 Fundamentals of a budgeting system are working. * 07/21/11 Fixed a bug with the mt940 import plugin that was causing an exception if a file was not open instead of disabling the plugin until a file is loaded. * 07/14/11 Fix for OFX import when preceding spaces are in the transaction amount * 06/19/11 Update to latest JGoodies libraries * 02/16/11 Check for multiple root accounts and correct if needed at startup. * 02/15/11 Fixed a bug where an account would show twice in reports in very rare circumstances. * 02/13/11 Minor selection and expansion performance improvement for the account view. * 01/22/11 Corrected layout issues in the investment transaction entry forms * 01/18/11 Mnemonics for menu items were not being shown * 01/16/11 Base API for Budgets added to the engine * 01/16/11 Use Annotations to reduce amount of managed code for UI actions == Release 2.5.1 * 01/02/11 Added new option to change the font size of the Nimbus Look and Feel * 01/02/11 Reorganized the Options Dialog to reduce the required space for small displays * 01/02/11 Added option to control network connection timeouts * 12/31/10 Add new variation of the Monthly Account Balance report (Patch #3087286, Pranay Kumar) * 12/31/10 Dumped the jGnash.app OSX launcher... sometimes it works, and sometimes it does not depending on the age of the system. Will now leave it up to the end user to sort it out. (Bug #3148438, Peter B. West) * 12/31/10 Improve behavior of split entry dialog (Bug #3132102, Chris B) * 12/31/10 jGnash 1.x import fixes and performance improvements (Bug #3147017, Klemen Zagar) * 12/30/10 Code cleanup efforts * 12/30/10 Protect against a null locale when importing jGnash 1.x file (Bug #3147015, Klemen Zagar) * 12/30/10 Protect against an invalid file entry (Bug #3147013, Klemen Zagar) * 12/30/10 Protect against NPE (Bug #3147012, Klemen Zagar) * 12/05/10 Improve the performance of the Accounts list for large account structures and play nice with db4o 7+ * 12/05/10 Ensure XML background write thread is complete before another write can occur or jGnash can close (Bug #3071371) * 11/28/10 Don't freeze the UI when duplicating a transaction on slow systems. * 11/28/10 Update to SwingX 1.6.2 * 11/16/10 Fix poor button layout for wizard dialogs * 10/18/10 Fix handling of the exchange rates for the pie chart report (Patch #3089661) * 10/17/10 Protect against incomplete XML file writes * 09/26/10 Enable selection of an account in the accounts tree by pressing the first letter of the account name == Release 2.5.0 * 09/19/10 Added additional integrated help content. * 09/18/10 Improved error handling when the selected font size for a report is too large. * 09/12/10 Added new options to reverse the display of account balances (Patch #2935203, Peter Vida) * 09/12/10 When opening an income account, select the income tab by default (Feature Request #2889091) * 09/08/10 Cleaned up a console warning when displaying reports. * 09/06/10 Reinvested dividend transaction fees were not being handled correctly. (Bug #2924555) * 09/02/10 The exchanged amount in a multi-currency transaction would not be correct if a change in field focus had not occurred (Bug #3045847) * 09/01/10 A Stack overflow was occurring when adding a new loan payment (Bug #3053384) * 09/01/10 Accounts were not always visible when choosing from a dialog * 08/31/10 UI components would not display correctly on OSX after integration of the Substance Look and Feel * 08/29/10 Mt940 import converted to a jGnash Plugin * 08/29/10 Finalized new Plugin API * 08/27/10 Pieces of the Portuguese translation were missing * 08/21/10 Update to Substance 6.1 * 08/21/10 Reports would not show if a default font was not available (Bug #3050057) * 08/11/10 The color for reconciled balance in the account list view was not always correct (Bug #3040309) == Release 2.4.1 * 07/21/10 Added CTRL-F4 shortcut to close the active register window (Feature Request #2889093) * 07/21/10 Added an option to disable the Substance Look and Feel animations * 07/21/10 The report print button would not work when using the Substance Look and Feel * 07/21/10 Updated to the latest DynamicJasper and JasperReports == Release 2.4.0 * 07/18/10 Add functionality to adjust the global font size when using the Substance look and feel * 07/18/10 Add Startup option to control automatic load of the last open file (Feature Request #2933793) * 07/18/10 Improve duplicate transaction functionality (Feature Request #1683578) * 07/15/10 Fix for a random NPE occurring at startup (Bug #3020688) * 07/12/10 Update to SwingX 1.6.1 * 07/12/10 Reworked the validation framework to use JXLayer * 07/11/10 Integrate JXLayer into the UI to improve effects and behavior * 07/09/10 A Portfolio report column name was not being displayed correctly * 07/07/10 The expansion state of the account list view is now restored on start * 07/02/10 Reimplement the account list view so the appearance is correct for certain look and feels * 06/27/10 Add Substance Look and Feel to the main distribution == Release 2.3.5 * 05/20/10 Removed percent gains and unrealized gains from portfolio report because they cannot be accurately calculated * 05/08/10 Added Czech localization (Patch #2981896 & 2991446, Luboš Hilgert) * 05/08/10 Update Portuguese localization (Patch #2996097, Marco A L Barbosa) * 04/04/10 Do not allow the portfolio report to run if there are not any investment accounts present. * 04/03/10 Fix typos (Patch #2981190, Nathan McCrina) * 03/27/10 Prevent duplicate transaction dialog from resizing too small * 03/17/10 Fix typos (Patch #2971980, Adrian A) * 03/14/10 Portfolio cost basis was not being calculated correctly * 03/14/10 The market value of investment accounts was not reported consistently (Bug #2822512) * 03/13/10 Add a simple chart to the Security History dialog * 03/10/10 Security price Table was sorting alphabetically instead of numerically (Bug #2940278) * 03/09/10 Report unrealized gains correctly in the portfolio report. * 03/06/10 Cleaned up internal exchange rate API. * 02/24/10 Add context sensitive help capability. * 02/24/10 Income tab names were reversed when using accounting terms. == Release 2.3.4 * 02/21/10 Expanded help content * 02/03/10 Add Ukrainian translation (Vitaliy Aksyonov) * 01/20/10 Update to latest JGoodies Forms and Looks to improve layout on OSX and L&F issues on Windows 7 * 01/19/10 Improve report name consistency for Report/Exports (Patch #2935268, Peter Vida) * 01/19/10 Reorganize the Profit Loss Text report into the Report/Exports menu (Patch #2935208, Peter Vida) * 01/19/10 Use the scale value specified for Securities in the transaction register table (Peter Vida) * 01/18/10 Add cost basis columns to the portfolio report * 01/18/10 Add options to the Running and End-of-Month account chart reports to filter placeholder and locked accounts (Patch #2931574, Peter Vida) * 01/17/10 XML file corruption could occur for fast parallel jGnash starts (Bug #2929425) * 01/17/10 Improved detection of correct OFX encoding when importing (Bug #2929581) * 01/16/10 Date selection field was no always displayed correctly (Bug #2931561, Peter Vida) * 01/15/10 Fix distribution build so it works on all platforms (Bug #2929859) * 01/10/10 Add filtering capability to the account register report (Pranay Kumar) * 01/10/10 Allow double clicking a date in the dialog to automatically select and close (Patch #2929289, Peter Vida) * 01/10/10 Exchange rates not saved to XML files. (Bug# 2928985, Peter Vida) * 01/01/10 Improper amount of cash is transferred from e.g. a bank account to an investment account when more than one fee is assigned to the sell share transaction. (Bug #2924554, Peter Vida) * 12/26/09 Fixed a formatting problem affecting the Portfolio Report * 12/26/09 Style the report footer text * 12/26/09 Update to DynamicJasper 3.0.14 == Release 2.3.3 * 12/25/09 Reconcile columns were not labeled correctly in the dialog (Bug #2902064) * 12/24/09 The latest memorized transaction would not always be recalled * 12/17/09 The remote sever now performs periodic XML backups for long running periods if changes have been made * 12/09/09 The Profit and Loss Text report was not including the start date as part of the reported balance (Bug #2909000) * 12/07/09 Changes made to support operation as a webstart application (Patch #2908944) * 11/09/09 Improve formatting of Quantities in the portfolio report (Bug #2892985) * 11/08/09 Disable multiple selection of Reminders (Bug #2894147) * 11/07/09 Exchange rate of modified transactions was being set to the current rate instead of the prior rate (Pranay Kumar) * 11/06/09 Improve UI layout for small screens (netbooks) * 11/03/09 Correctly show modifications to currencies without a restart * 11/03/09 File import actions should be enabled only if a file is open (Bugs #2890420, #2890422, #2890426) * 11/03/09 Update to SwingX 1.6 == Release 2.3.2 11/02/09 Reports with totals were broken in the 2.3.1 release (Bug #2890310) == Release 2.3.1 * 10/30/09 Reports would hang if certain characters were in currency prefix or suffixes (Bug #2884085) * 10/23/09 Transaction tab names were reversed when using accounting terms for credit and liability accounts (Bug #2770638) * 10/19/09 Reminders with no last date would default to current date when using the XML file format (Bug #2860259) * 10/18/09 Update to latest JGoodies look and feel * 10/18/09 Use a temporary swap file when generating large reports * 10/18/09 Add a group label to the reports to help improve readability * 10/15/09 Update to latest DynamicJasper and JasperReports dependencies * 10/15/09 Updated German translation (Adrian Gygax) * 09/23/09 Fix for Bug #2863303, Improve UI behavior for duplicate transaction behavior (L2K) * 07/31/09 Add Yahoo Australia as Quote Source (Rob Hills) * 07/09/09 Lazily create the help broker and fail gracefully if an exception occurs instead of preventing the application from starting. * 07/07/09 Show the sum of the selected transactions in the register using a tooltip * 07/07/09 Liability register was missing the Jump button == Release 2.3.0 * 06/26/09 Detect and correct accounts with self parenting * 06/20/09 Prevent a user from assigning an account's parent as itself. * 06/18/09 Begin migration to MigLayout to replace Forms Layout * 06/07/09 Use JXColorSelectionButton to select register colors. * 06/05/09 Add network activity indicator when updating security prices and exchange rates in the background. * 06/04/09 Update to JasperReports 3.1.4 * 06/04/09 Add ellipsis symbol to truncated text in reports * 06/04/09 Update to DynamicJasper 3.0.6 * 06/03/09 Correctly handle file encoding of OFX V1 files. * 06/01/09 Add a new option to automatically select text when a field receives focus * 05/31/09 New report to show income and expense by payee (Pranay Kumar) * 05/29/09 Updated Portuguese translation (Pietro Augusto) * 05/25/09 Improved handling of validation errors * 05/04/09 Integrate the SwingX libraries for improved usability * 04/22/09 Fix for Bug #2500229, Display a warning if a Security is not selected when creating an investment transaction. * 04/22/09 Correctly handle an attempt to open a zero length file. * 04/20/09 Fix for Bug #2734778, Default currency was not accessible immediately after creating a new XML file. * 04/10/09 Add an escape key listener to most all dialogs and add additional bounds listening to dialogs that did not already have it. == Release 2.2.0 * 03/31/09 Correct identification of OFX 2.0 files that are now starting to show up in the wild. * 03/26/09 Fixed report of multiple currencies for the Monthly and End-of-Month account balance charts. * 03/26/09 Switched to DocBook for creating content for the JavaHelp system. * 03/26/09 Add menu commands to perform background updates on security prices and exchange rates. * 03/25/09 Fix for bug #2690988, poor form layout behavior for recurring entry creation in OSX. * 03/25/09 Various updates to the Spanish translation (Marcelo Abeldaño). * 03/25/09 Transaction reconcile was not occurring per the selected options. * 03/25/09 Reconciled state of the opposite side of a transaction was not preserved when modifying. * 03/24/09 Fix for bug #2691568 (Andrey Bondarenko). * 03/07/09 Much improved account tree UI behavior when security prices change. * 03/05/09 Remove unused fields from the Create/Modify Security Dialog. * 03/05/09 Improve amortization UI behavior. * 03/04/09 Reporting has been reworked. Report preferences are persistent; Font size is configurable; CSV export has been improved; Consistent appearance for all reports; Now uses Jasper and DynamicJasper report APIs. * 02/15/09 Render investment quantities with a fixed decimal to improve appearance. == Release 2.1.0 * 02/01/09 Fixed issues with multiple network clients not communicating with each other. * 01/14/09 Fixed a problem with duplicate default currencies when creating a new default account set. * 01/12/09 Investment account balance was not calculated correctly if the last transaction was a dividend and a security price for same date or after was not established. * 01/04/09 Added an integrated help system. * 12/30/08 Added -portable command line options to save jGnash preferences to an external location for users who want to run jGnash from a USB drive. == Release 2.0.3 * 12/30/08 Checks would print with test border. * 12/30/08 Feature Request #2474667, If an invalid file extension is provided during File | Save As, default to the db4o file type and extension. * 12/30/08 Fix for Bug #2474820, Performing File | Save As over the current file would result in an empty file and loss of data. * 12/30/08 Update to XStream 1.3.1. Update should improve XML performance. * 12/30/08 Fix new file account structure and import regression. * 12/30/08 Patch #2477090, MT940 import fix from Miroslav Holubec. * 12/14/08 Add a shutdown option to automatically control the number of backup files. * 12/05/08 The automatic Security price download would not work correctly if more than two Securities were configured with no download source. * 12/04/08 jGnash can now import Ofx version 1 and 2 credit card account files. * 12/03/08 jGnash can now import Ofx version 1 and 2 bank account files. * 12/03/08 Fix problem with null account numbers == Release 2.0.2 * 11/28/08 Set the default selected account for buy and sell transactions to the base investment account. * 11/28/08 Fixed an incorrect warning to the console when modifying and reinvested dividend transaction. * 11/28/08 Improved the appearance of the investment transaction entry panels when using the Nimbus look and feel. * 11/26/08 Investment account balances were not always reflecting the latest security price. * 11/23/08 Disable db4o defragment. The defragment function is not stable and could cause corruption. * 11/23/08 Fix for Bug #2334048, Available Securities dialog was pushing the parent frame to the back. * 11/23/08 Fix for Bug #2332586, Modifying an investment transaction from a bank account register was not working. * 11/23/08 Fix for Bug #2332540, Loss of focus on an empty numeric field in OSX was throwing an exception. (Fix from Petey) * 11/23/08 Internal code cleanup * 11/18/08 Dropped Beanshell support because it is no longer supported and does not work well with OSX * 11/17/08 Converted the MonthBalanceCSV text report from a Beanshell script to a compiled report. * 11/16/08 Converted the ProfitLoss text report from a Beanshell script to a compiled report. == Release 2.0.1 * 11/16/08 Update to the latest Pentaho reporting jars. * 11/15/08 Prevent the removal of a currency assigned to a security node. * 11/10/08 Currency exchange rate was not factored in for investment transaction reconciliation. * 11/10/08 Extend default security / exchange download to 30 seconds. It was 10 seconds. * 11/09/08 Fix for Bug #2246569, Date dialog was pushing the parent dialog to the back * 11/09/08 Fix for Bug #2222143, Multiple RootAccounts were being created and making import look like it failed. == Release 2.0.0 * 11/02/08 The reconciled market balance was not factoring in the exchange rate of currencies * 11/02/08 Update to latest JGoodies Looks * 10/29/08 Improve appearance of the date selector for modern look and feels (Nimbus and JGoodies) * 10/27/08 Fix problem with Reminder modification resulting in a duplicate when using the XML file format * 10/17/08 Reconciliation from transaction forms was not working correctly * 10/17/08 Automatic reconciliation of income and expense accounts was not working correctly. * 10/17/08 Transfer panel was missing the reconcile button * 10/16/08 Recurring transaction reminders were not working unless a file was reloaded without UI restart * 10/12/08 Fix Portfolio report summary row value * 10/10/08 Updated Spanish translation (Marcelo Abeldaño) == Release 2.0.0-RC4 * 10/05/08 Typing a 'T' or 't' inside a date field changes it to the current date. * 10/05/08 Prevent an exception from occurring if the overall length of a date field is shortened when a shortcut key is used. * 10/05/08 Update to the latest JGoodies Forms and Looks jars. * 10/05/08 Fix problem with lost views when UI is restarted because of look and feel update * 10/04/08 The enabled state of the recurring transaction panel was not correct * 10/04/08 Fix the UI layout for the Account Register and Portfolio Reports * 10/04/08 Remove unused jar dependency == Release 2.0.0-RC3 * 10/01/08 Yahoo UK has reverted to the security symbol instead of the ISIN number for downloading data * 10/01/08 Fix for Bug #1991337. The portfolio report should use the account currency instead of the default currency, and it was not factoring in the exchange rate for securities with different reported currencies. * 10/01/08 Change how UI elements are handled when a file is loaded and unload. This circumvents Java Bug #6472844 which was causing a memory leak. * 09/25/08 Yahoo security download info occasionally contains extra white space. Protect against a NumberFormatException when parsing * 09/23/08 Prevent incorrect moving of an account * 09/23/08 Update to latest JFreeChart jar * 09/23/08 Update to latest db40 6.4 jar * 09/22/08 Fix for Bug #2080742. The direction of the currency conversion was not correct * 09/21/08 Correctly set the enabled state of the Reports menu when a file is not loaded * 09/20/08 Prevent the import of a MT940 file if a jGnash file is not loaded * 09/20/08 Fix for Bug #2098347. Prevent the import of an OFX file if a jGnash file is not loaded * 09/19/08 Fix the enabled state of the reminder panel buttons and prevent an NPE if a file is not loaded. * 09/17/08 Fix the investment account reconciliation process * 09/09/08 Fix the reported reconciled amount for investment accounts * 08/27/08 Fix for Bug #2068074. Reminder modifications were not handled correctly * 08/20/08 Localization fixes * 08/18/08 Update to latest Pentaho reporting jar == Release 2.0.0-RC2 * 08/18/08 Add sort capability to currency exchange table * 08/17/08 Add Copy to Clipboard button to Console and Exception dialogs * 08/17/08 HTTP connections were left open when downloading security history * 08/17/08 Change sort order of the accounts for reports * 08/17/08 Restart the UI when the L&F is changed to prevent Exceptions * 08/15/08 Spanish translation fixes (Marcelo Abeldaño) * 08/14/08 Correctly handle a filename passed by Windows if associated with jGnash * 08/14/08 Fix NPE in recurring transactions * 08/13/08 Fixed 1.x import and behavior of BuyX and SellX transactions * 07/31/08 Change EDT check to used a command line option * 07/30/08 The reconciled balance was not always rendered in the correct color * 07/29/08 Use the default sort icons for the table header in the transaction register * 07/28/08 Fix the appearance of the table header in the transaction register for newer look and feels * 07/28/08 The duplicate function for transactions was not working for split transactions * 07/27/08 Allow sorting of the security history table * 07/27/08 Yahoo UK parser was not using the ISIN number * 07/27/08 Fix more EDT issues == Release 2.0.0-RC1 * 07/27/08 The lookup mechanism for default account sets when creating a new file did not work when jGnash was run from a jar or exe. * 07/26/08 Currency Exchange history dialog was not always showing the correct conversion direction * 07/23/08 Fix some initial display issues with SecurityHighLowChart * 07/22/08 The XML storage container would not remove objects as expected * 07/22/08 Add UI option to export timestamped and compressed file on exit * 07/21/08 Update to JFreeChart 1.0.10. Fixes some quirks with the income/expense pie chart * 07/21/08 Create all UI elements on the EDT * 07/19/08 Fix a NPE if the RootAccount AccountGroup is requested * 07/19/08 Fix a potential problem with stray account properties being left in the object database upon account removal * 07/18/08 Fix Profit and Loss text report and Monthly Balance export scripts * 07/16/08 Save a time-stamped and compressed file on exit if enabled * 07/14/08 Implement full Save As functionality. It is now possible to switch between file formats. == Release 2.0.0 - Beta 3 * 07/12/08 Lock XML file at OS level to prevent overwrite from multiple instances of jGnash * 07/07/08 New icons to update UI appearance * 07/06/08 Add a reconciled balance column to the accounts overview * 07/06/08 XML Datastore is now working * 06/30/08 Reinstate the 1.x status bar * 06/29/08 Fixed a validation problem that prevented 0 scale currencies from being added to the database * 06/28/08 Enable full support of client / server connection from the command line * 06/21/08 AmortizeObject does not have to extend StoredObject * 06/21/08 Enable option to load a file from the command line * 06/20/08 TransactionEntry does not have to extend StoredObject * 06/18/08 Balance Sheet report was not pulling all account types correctly * 06/17/08 Fix bad validation code for jGnash 1.x import. Depends on update release of Java 6. == Release 2.0.0 - Beta 2 * 06/16/08 Preselect default transaction form tab based on account type * 06/15/08 Dump GnuCash import support * 06/15/08 SecurityNode and TransactionEntry db schema change. db4o does not handle changes to enums well * 06/14/08 Add "Checking" account type * 06/14/08 Account db schema change. db4o does not handle changes to enums well * 06/13/08 If a transaction is dated for the future, italicize the font in the register table * 06/13/08 Soft null check Workaround for a weird JVM bug for null assert checks on non-null Strings with international characters. * 06/12/08 Autocomplete was occurring when text was being set vs typed causing mysterious changes to fields. * 06/11/08 Add missing top level memo for transactions * 06/11/08 Fix enabled state of the account combo for split transaction entry * 06/09/08 Fix the display of split details for the account register report == Release 2.0.0 - Beta 1 * 06/08/08 Reduce XML export file size by 45% * 06/06/08 Dumped some unused legacy methods from TransactionEntry and subclasses * 06/05/08 Fix transaction generation for basic double entry panel * 06/05/08 Do not allow the currency of an account to be changed to it already contains transactions. * 06/04/08 Overhauled the register tree panel code to fix column resize behavior and fix some bugs * 06/03/08 Fix last known data corruption bug (Was not cloning TransactionEntries in the FeesPanel) * 05/31/08 Reworked UI and API for reinvested transactions * 05/26/08 Use new exchange rate UI for bank and transfer transactions * 05/23/08 Remove duplicate code in TransactionDAO * 05/22/08 New API and UI for handling capital gains and loss * 05/22/08 Use java collections for storage instead of manually controlled arrays * 05/08/08 Disable web update in Security History Dialog if a download source has not been selected for the security * 04/12/08 Save and restore the last active view * 04/06/08 Open streams were not being closed * 04/06/08 Fixed formatting error in balance sheet and networth reports * 03/25/08 Fixed import of jGnash 1.x Dividend transactions * 03/20/08 Begin separation of BuyX and SellX transaction forms * 03/10/08 Improve fees handling for BuyX transactions * 03/05/08 Applied patch #1907963 for improved OFX parsing (Nicolas Bouillon) * 03/03/08 Improved TransactionDialog * 03/03/08 Fix divide by zero bug #1906150 * 03/01/08 Fix localization bug #1903842 * 02/29/08 Place nice with upcoming Nimbus look and feel * 02/29/08 Update to jGoodies 1.2.0 * 02/27/08 Improve Next # action for transaction numbers Bug #1902455 * 02/21/08 Support for multiple security quote sources (Yahoo! and Yahoo! UK) * 02/21/08 Improved OFX header parsing * 01/31/08 Merge mt940 import support * 01/30/08 Use of accounting terms were not correct in all cases. * 01/22/08 Fixed handling for split and merge transactions in the portfolio report. * 01/01/08 Reworked Dividend transactions and UI to support true double entry. * 01/01/08 Use TimingFramework instead of jGoodies animations. * 12/27/07 A button was added to the investment register to allow selection of available securities. * 12/26/07 Improve generated payee of investment transactions. == Release 2.0.0 - Alpha 3 * 12/26/07 Added Working OFX import for savings and checking accounts. * 12/17/07 Improved new account wizard so user can add default account structures * 12/10/07 Added import and export of the account tree ================================================ FILE: gradle/wrapper/gradle-wrapper.properties ================================================ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-6.8.2-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists ================================================ FILE: gradle.properties ================================================ # suppress inspection "UnusedProperty" for whole file # https://github.com/openjfx/javafx-gradle-plugin/releases javafxPluginVersion=0.0.9 # https://github.com/ben-manes/gradle-versions-plugin/releases versionsPluginVersion=0.36.0 # https://mvnrepository.com/artifact/org.openjfx/javafx javaFXVersion=15.0.1 # https://github.com/TestFX/TestFX/releases testFxVersion=4.0.16-alpha # https://github.com/TestFX/Monocle/releases monocleVersion=jdk-12.0.1+2 # https://plugins.gradle.org/plugin/edu.sc.seis.macAppBundle macAppBundleVersion=2.3.0 # https://junit.org/junit5/ junitVersion=5.7.1 # https://github.com/glytching/junit-extensions junitExtensionsVersion=2.4.0 # https://github.com/awaitility/awaitility awaitilityVersion=4.0.3 # https://commons.apache.org/proper/commons-lang/ commonsLangVersion=3.11 # https://commons.apache.org/proper/commons-math/ commonsMathVersion=3.6.1 # https://commons.apache.org/proper/commons-csv/ commonsCsvVersion=1.8 #https://commons.apache.org/proper/commons-collections/ commonsCollectionsVersion=4.4 # https://commons.apache.org/proper/commons-text/ commonsTextVersion=1.9 # https://github.com/remkop/picocli picocliVersion=4.6.1 # https://poi.apache.org/ apachePoiVersion=5.0.0 # http://www.h2database.com/html/main.html h2Version=1.4.200 # http://hsqldb.org/ hsqldbVersion=2.5.1 # https://github.com/x-stream/xstream xstreamVersion=1.4.15 # http://hibernate.org/orm/ hibernateVersion=5.4.28.Final # https://github.com/brettwooldridge/HikariCP hikariVersion=4.0.2 # https://pdfbox.apache.org/ pdfBoxVersion=2.0.22 # https://netty.io/index.html nettyVersion=4.1.59.Final # https://www.slf4j.org/news.html slf4jVersion = 1.8.0-beta4 ================================================ FILE: gradlew ================================================ #!/usr/bin/env sh # # Copyright 2015 the original author or authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # https://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # ############################################################################## ## ## Gradle start up script for UN*X ## ############################################################################## # Attempt to set APP_HOME # Resolve links: $0 may be a link PRG="$0" # Need this for relative symlinks. while [ -h "$PRG" ] ; do ls=`ls -ld "$PRG"` link=`expr "$ls" : '.*-> \(.*\)$'` if expr "$link" : '/.*' > /dev/null; then PRG="$link" else PRG=`dirname "$PRG"`"/$link" fi done SAVED="`pwd`" cd "`dirname \"$PRG\"`/" >/dev/null APP_HOME="`pwd -P`" cd "$SAVED" >/dev/null APP_NAME="Gradle" APP_BASE_NAME=`basename "$0"` # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD="maximum" warn () { echo "$*" } die () { echo echo "$*" echo exit 1 } # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false case "`uname`" in CYGWIN* ) cygwin=true ;; Darwin* ) darwin=true ;; MINGW* ) msys=true ;; NONSTOP* ) nonstop=true ;; esac CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables JAVACMD="$JAVA_HOME/jre/sh/java" else JAVACMD="$JAVA_HOME/bin/java" fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else JAVACMD="java" which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi # Increase the maximum file descriptors if we can. if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then MAX_FD_LIMIT=`ulimit -H -n` if [ $? -eq 0 ] ; then if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then MAX_FD="$MAX_FD_LIMIT" fi ulimit -n $MAX_FD if [ $? -ne 0 ] ; then warn "Could not set maximum file descriptor limit: $MAX_FD" fi else warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" fi fi # For Darwin, add options to specify how the application appears in the dock if $darwin; then GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" fi # For Cygwin or MSYS, switch paths to Windows format before running java if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then APP_HOME=`cygpath --path --mixed "$APP_HOME"` CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` JAVACMD=`cygpath --unix "$JAVACMD"` # We build the pattern for arguments to be converted via cygpath ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` SEP="" for dir in $ROOTDIRSRAW ; do ROOTDIRS="$ROOTDIRS$SEP$dir" SEP="|" done OURCYGPATTERN="(^($ROOTDIRS))" # Add a user-defined pattern to the cygpath arguments if [ "$GRADLE_CYGPATTERN" != "" ] ; then OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" fi # Now convert the arguments - kludge to limit ourselves to /bin/sh i=0 for arg in "$@" ; do CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` else eval `echo args$i`="\"$arg\"" fi i=`expr $i + 1` done case $i in 0) set -- ;; 1) set -- "$args0" ;; 2) set -- "$args0" "$args1" ;; 3) set -- "$args0" "$args1" "$args2" ;; 4) set -- "$args0" "$args1" "$args2" "$args3" ;; 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; esac fi # Escape application args save () { for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done echo " " } APP_ARGS=`save "$@"` # Collect all arguments for the java command, following the shell quoting and substitution rules eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" exec "$JAVACMD" "$@" ================================================ FILE: gradlew.bat ================================================ @rem @rem Copyright 2015 the original author or authors. @rem @rem Licensed under the Apache License, Version 2.0 (the "License"); @rem you may not use this file except in compliance with the License. @rem You may obtain a copy of the License at @rem @rem https://www.apache.org/licenses/LICENSE-2.0 @rem @rem Unless required by applicable law or agreed to in writing, software @rem distributed under the License is distributed on an "AS IS" BASIS, @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem @if "%DEBUG%" == "" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @rem @rem ########################################################################## @rem Set local scope for the variables with windows NT shell if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 if "%DIRNAME%" == "" set DIRNAME=. set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @rem Resolve any "." and ".." in APP_HOME to make it shorter. for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" @rem Find java.exe if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if "%ERRORLEVEL%" == "0" goto execute echo. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. echo. echo Please set the JAVA_HOME variable in your environment to match the echo location of your Java installation. goto fail :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute echo. echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% echo. echo Please set the JAVA_HOME variable in your environment to match the echo location of your Java installation. goto fail :execute @rem Setup the command line set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar @rem Execute Gradle "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* :end @rem End local scope for the variables with windows NT shell if "%ERRORLEVEL%"=="0" goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 exit /b 1 :mainEnd if "%OS%"=="Windows_NT" endlocal :omega ================================================ FILE: jGnash ================================================ #!/usr/bin/env sh cwd=$(dirname "$0") "$cwd"/bin/bootloader "$@" if [ $? -eq 100 ]; then #relaunch automatically if return value is 100 "$cwd"/bin/bootloader "$@" fi exit 0 ================================================ FILE: jgnash-bayes/build.gradle.kts ================================================ description = "jGnash Bayes" val moduleName = "jgnash.bayes" tasks.jar { manifest.attributes["Automatic-Module-Name"] = moduleName } ================================================ FILE: jgnash-bayes/src/main/java/jgnash/bayes/BayesClassifier.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.bayes; import java.util.Arrays; import java.util.Collection; import java.util.HashMap; import java.util.Locale; import java.util.Map; import java.util.Map.Entry; import java.util.regex.Pattern; /** * Naive Bayes BayesClassifier. * Modeled after classifier presented in "Programming Collective Intelligence" by Toby Segaran * * @param the type of mapped value * * @author Craig Cavanaugh */ public class BayesClassifier { /** * Default class if not determinate */ private final E defaultClass; private static final double ASSUMED_PROBABILITY = 0.5; private static final double THRESHOLD = 1.0; private static final double WEIGHT = 1.0; private static final String WHITE_SPACE_REGEX = "[,\\s]+"; // private final static String NUMBERS_REGEX = "(?>-?\\d+(?:[\\./]\\d+)?)"; private final Map> featureCounter = new HashMap<>(); private final Map classCounter = new HashMap<>(); private final Pattern whiteSpacePattern; /** * Constructor * * @param defaultClass the mapped type */ public BayesClassifier(final E defaultClass) { this.defaultClass = defaultClass; whiteSpacePattern = Pattern.compile(WHITE_SPACE_REGEX); } private void incrementFeature(final String feature, final E classification) { Map featureMap = featureCounter.get(feature); if (featureMap == null) { featureMap = new HashMap<>(); featureMap.put(classification, 1); featureCounter.put(feature, featureMap); return; } Integer count = featureMap.get(classification); count = (count == null) ? 1 : count + 1; featureMap.put(classification, count); } /** * Support method to increment a classification * * @param classification classification to increment */ private void incrementClass(final E classification) { Integer count = classCounter.get(classification); count = (count == null) ? 1 : count + 1; classCounter.put(classification, count); } private int getClassCount(final E classification) { Integer count = classCounter.get(classification); return (count != null) ? count : 0; } /** * Gets the number of times the feature as occurred in the classification * * @param feature feature to count * @param classification class * @return occurrence count */ private int getFeatureCount(final String feature, final E classification) { Map featureMap = featureCounter.get(feature); if (featureMap == null) { return 0; } Integer count = featureMap.get(classification); return (count != null) ? count : 0; } private int getFeatureCount(final String feature) { Map featureMap = featureCounter.get(feature); int count = 0; if (featureMap == null) { return count; } for (Entry entry : featureMap.entrySet()) { count += entry.getValue(); } return count; } private double getFeatureProbability(final String feature, final E classification) { int count = getClassCount(classification); return (count == 0) ? 0.0 : (double) getFeatureCount(feature, classification) / count; } private double getWeightedProbability(final String feature, final E classification) { double probability = getFeatureProbability(feature, classification); int totals = getFeatureCount(feature); return (WEIGHT * ASSUMED_PROBABILITY + totals * probability) / (WEIGHT + totals); } private double getClassProbability(final String item, final E classification) { double probability = 1; for (final String feature : whiteSpacePattern.split(item)) { probability *= getWeightedProbability(feature, classification); } return (double) classCounter.get(classification) / classCounter.size() * probability; } private void train(final Collection features, final E classification) { for (String feature : features) { incrementFeature(feature, classification); } incrementClass(classification); } /** * Trains the classifier * * @param item training string * @param classification object being classified */ public void train(final String item, final E classification) { train(Arrays.asList(whiteSpacePattern.split(item.toLowerCase(Locale.getDefault()))), classification); } /** * Returns the best probabilistic match * * @param item String data to match * @return best possible match */ public E classify(final String item) { E bestClass = defaultClass; double classProb; double bestProbability; double max = 0; Map probabilities = new HashMap<>(); // find the category with the highest probability for (final E classification : classCounter.keySet()) { classProb = getClassProbability(item.toLowerCase(Locale.getDefault()), classification); probabilities.put(classification, classProb); if (classProb > max) { max = classProb; bestClass = classification; } } // make sure the probability exceeds for (final Entry entry : probabilities.entrySet()) { if (!entry.getKey().equals(bestClass)) { classProb = entry.getValue(); bestProbability = probabilities.get(bestClass); if (classProb * THRESHOLD >= bestProbability) { return defaultClass; } } } return bestClass; } } ================================================ FILE: jgnash-convert/build.gradle.kts ================================================ description = "jGnash Convert" val moduleName = "jgnash.convert" val commonsCsvVersion: String by project plugins { `java-library` } dependencies { implementation(project(":jgnash-resources")) implementation(project(":jgnash-core")) implementation(project(":jgnash-bayes")) implementation("org.apache.commons:commons-csv:$commonsCsvVersion") } tasks.jar { manifest.attributes["Automatic-Module-Name"] = moduleName } ================================================ FILE: jgnash-convert/src/main/java/jgnash/convert/common/OfxTags.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.convert.common; /** * Known OFX tags * * @author Craig Cavanaugh * @author Nicolas Bouillon */ public interface OfxTags { /** * Account ID */ String ACCTID = "ACCTID"; /** * Account Type */ String ACCTTYPE = "ACCTTYPE"; /** * Available balance */ String AVAILBAL = "AVAILBAL"; /** * Balance Amount */ String BALAMT = "BALAMT"; /** * Bank Account info */ String BANKACCTFROM = "BANKACCTFROM"; /** * Bank Account info for a transfer */ String BANKACCTTO = "BANKACCTTO"; /** * CASH Tag */ String CASH = "CASH"; /** * Credit Card info */ String CCACCTFROM = "CCACCTFROM"; /** * Credit Account info for a transfer */ String CCACCTTO = "CCACCTTO"; /** * Investment Account info */ String INVACCTFROM = "INVACCTFROM"; /** * Investment Account info for a transfer */ String INVACCTTO = "INVACCTTO"; /** * Bank ID */ String BANKID = "BANKID"; /** * Stock purchase */ String BUYSTOCK = "BUYSTOCK"; /** * Purchase type, normally "BUY" */ String BUYTYPE = "BUYTYPE"; /** * Buy other security type */ String BUYOTHER = "BUYOTHER"; /** * Buy mutual fund */ String BUYMF = "BUYMF"; /** * Sub-account that security or cash is being transferred to: CASH, MARGIN, SHORT, OTHER */ String SUBACCTTO = "SUBACCTTO"; /** * Sub-account that security or cash is being transferred from: CASH, MARGIN, SHORT, OTHER */ String SUBACCTFROM = "SUBACCTFROM"; /** * Investment account position list */ String INVPOSLIST = "INVPOSLIST"; /** * Investment account balance information */ String INVBAL = "INVBAL"; /** * Open investment transaction orders list */ String INVOOLIST = "INVOOLIST"; /** * Branch identifier. May be required for some non-US banks */ String BRANCHID = "BRANCHID"; /** * Bank transaction list */ String BANKTRANLIST = "BANKTRANLIST"; String INVTRANLIST = "INVTRANLIST"; String INVTRAN = "INVTRAN"; String INVBUY = "INVBUY"; String INVSELL = "INVSELL"; String BROKERID = "BROKERID"; /** * Check number */ String CHECKNUM = "CHECKNUM"; String CODE = "CODE"; String CURDEF = "CURDEF"; String CURRENCY = "CURRENCY"; /** * Checking account type * @see #ACCTTYPE */ String CHECKING = "CHECKING"; /** * Credit line account type * @see #ACCTTYPE */ String CREDITLINE = "CREDITLINE"; /** * Money market account type * @see #ACCTTYPE */ String MONEYMRKT = "MONEYMRKT"; /** * Savings account type * @see #ACCTTYPE */ String SAVINGS = "SAVINGS"; /** * Credit transaction * @see #TRNTYPE */ String CREDIT = "CREDIT"; /** * Debit transaction * @see #TRNTYPE */ String DEBIT = "DEBIT"; /** * Date of balance * @see #LEDGERBAL * @see #AVAILBAL */ String DTASOF = "DTASOF"; /** * End date of transaction list */ String DTEND = "DTEND"; /** * Date posted */ String DTPOSTED = "DTPOSTED"; /** * Date user initiated transaction */ String DTUSER = "DTUSER"; String DTSERVER = "DTSERVER"; /** * Start date of transaction list */ String DTSTART = "DTSTART"; String DTTRADE = "DTTRADE"; String DTSETTLE = "DTSETTLE"; /** * Fees applied to trade, amount */ String FEES = "FEES"; String FI = "FI"; String FID = "FID"; /** * Financial Institution transaction id */ String FITID = "FITID"; String LANGUAGE = "LANGUAGE"; /** * Account balance */ String LEDGERBAL = "LEDGERBAL"; /** * Transaction memo */ String MEMO = "MEMO"; /** * Chase bank mucking up the OFX standard */ String CATEGORY = "CATEGORY"; String MESSAGE = "MESSAGE"; /** * Name of payee or transaction description, may be used exclusive of {@code PAYEE} * @see #PAYEE */ String NAME = "NAME"; String OFX = "OFX"; String ORG = "ORG"; String ORIGCURRENCY = "ORIGCURRENCY"; /** * Name of payee, may be used exclusive of {@code NAME} * @see #NAME */ String PAYEE = "PAYEE"; String PAYEEID = "PAYEEID"; /** * Indicates an amount withheld due to a penalty. Amount */ String PENALTY = "PENALTY"; String REFNUM = "REFNUM"; String SEVERITY = "SEVERITY"; String SECID = "SECID"; /** * Accounting SIC code */ String SIC = "SIC"; /** * Sign-on Message Set Aggregate */ String SIGNONMSGSRSV1 = "SIGNONMSGSRSV1"; String SONRS = "SONRS"; String STATUS = "STATUS"; /** * Bank statement response aggregate */ String STMTRS = "STMTRS"; /** * Investment account bank transaction */ String INVBANKTRAN = "INVBANKTRAN"; /** * Credit Card statement response aggregate */ String CCSTMTRS = "CCSTMTRS"; /** * Investment statement response aggregate */ String INVSTMTRS = "INVSTMTRS"; /** * Bank Transaction */ String STMTTRN = "STMTTRN"; String STMTTRNRS = "STMTTRNRS"; String SUBACCTSEC = "SUBACCTSEC"; /** * Where did the money for the transaction come from or go to? CASH, MARGIN, SHORT, OTHER */ String SUBACCTFUND = "SUBACCTFUND"; /** * Sell a mutual fund */ String SELLMF = "SELLMF"; /** * Sell other type of security */ String SELLOTHER = "SELLOTHER"; /** * Sell a stock */ String SELLSTOCK = "SELLSTOCK"; String SELLTYPE = "SELLTYPE"; String CCSTMTTRNRS = "CCSTMTTRNRS"; String INVSTMTTRNRS = "INVSTMTTRNRS"; String REINVEST = "REINVEST"; String INCOME = "INCOME"; String INCOMETYPE = "INCOMETYPE"; /** * 401k loan id */ String LOANID = "LOANID"; /** * 401k loan principal */ String LOANPRINCIPAL = "LOANPRINCIPAL"; /** * 401k loan interest */ String LOANINTEREST = "LOANINTEREST"; /** * Must be one of the following: PRETAX, AFTERTAX, MATCH, PROFITSHARING, ROLLOVER, OTHERVEST, OTHERNONVEST */ String INV401KSOURCE = "INV401KSOURCE"; /** * For 401(k)accounts, date the funds for this transaction was obtained via payroll deduction, datetime */ String DTPAYROLL = "DTPAYROLL"; /** * For 401(k) accounts, indicates that this Buy was made with a prior year contribution. Boolean */ String PRIORYEARCONTRIB = "PRIORYEARCONTRIB"; /** * For 401(k) accounts, account balance aggregate */ String INV401KBAL = "INV401KBAL"; /** * For 401(k) accounts, account information aggregate */ String INV401K = "INV401K"; /** * Tax exempt status of an investment transactions */ String TAXEXEMPT = "TAXEXEMPT"; /** * Transaction amount */ String TRNAMT = "TRNAMT"; /** * Transaction type */ String TRNTYPE = "TRNTYPE"; /** * Client Assigned Globally Unique Transaction ID */ String TRNUID = "TRNUID"; /** * Total of the investment transaction (unit * unit price + commission) */ String TOTAL = "TOTAL"; //String USERKEY = "USERKEY"; String UNIQUEID = "UNIQUEID"; String UNIQUEIDTYPE = "UNIQUEIDTYPE"; String UNITS = "UNITS"; String UNITPRICE = "UNITPRICE"; String COMMISSION = "COMMISSION"; /** * Bank Message Set Aggregate */ String BANKMSGSRSV1 = "BANKMSGSRSV1"; String CREDITCARDMSGSRSV1 = "CREDITCARDMSGSRSV1"; String INVSTMTMSGSRSV1 = "INVSTMTMSGSRSV1"; String SECLISTMSGSRSV1 = "SECLISTMSGSRSV1"; /** * Security Info */ String STOCKINFO = "STOCKINFO"; /** * Mutual fund information */ String MFINFO = "MFINFO"; /** * Security information */ String SECINFO = "SECINFO"; /** * Information about an Option */ String OPTINFO = "OPTINFO"; /** * Option type */ String OPTTYPE = "OPTTYPE"; /** * Strike price */ String STRIKEPRICE = "STRIKEPRICE"; /** * ISO-4217 3-letter currency identifier */ String CURSYM = "CURSYM"; /** * Ratio of currency to currency, in decimal notation, rate */ String CURRATE = "CURRATE"; /** * Security name, maximum of 120 characters */ String SECNAME = "SECNAME"; String TICKER = "TICKER"; String SECLIST = "SECLIST"; /** * Expiration date for an Option */ String DTEXPIRE = "DTEXPIRE"; /** * Number of shares per contract */ String SHPERCTRCT = "SHPERCTRCT"; /** * Asset class of the security */ String ASSETCLASS = "ASSETCLASS"; /** * Yield of the security */ String YIELD = "YIELD"; /** * Internal security identifier for the financial institution */ String FIID = "FIID"; /** * Security rating, maximum of 10 characters */ String RATING = "RATING"; /** * Intuit mucking up the OFX standard, Bank Id, In signon message */ String INTUBID = "INTU.BID"; /** * Intuit mucking up the OFX standard, User Id, In signon message */ String INTUUSERID = "INTU.USERID"; } ================================================ FILE: jgnash-convert/src/main/java/jgnash/convert/exportantur/csv/CsvExport.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.convert.exportantur.csv; import java.io.BufferedWriter; import java.io.IOException; import java.io.OutputStreamWriter; import java.math.BigDecimal; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.time.LocalDate; import java.time.format.DateTimeFormatter; import java.util.List; import java.util.Objects; import java.util.logging.Level; import java.util.logging.Logger; import jgnash.engine.Account; import jgnash.engine.Comparators; import jgnash.engine.CurrencyNode; import jgnash.engine.Engine; import jgnash.engine.ReconciledState; import jgnash.engine.Transaction; import jgnash.resource.util.ResourceUtils; import jgnash.time.DateUtils; import jgnash.util.FileUtils; import jgnash.util.NotNull; import org.apache.commons.csv.CSVFormat; import org.apache.commons.csv.CSVPrinter; import org.apache.commons.csv.QuoteMode; /** * Primary class for CSV export * * @author Craig Cavanaugh */ public class CsvExport { private static final char BYTE_ORDER_MARK = '\ufeff'; private static final String SPACE = " "; private static final int INDENT = 4; private CsvExport() { } public static void exportAccountTree(@NotNull final Engine engine, @NotNull final Path path) { Objects.requireNonNull(engine); Objects.requireNonNull(path); // force a correct file extension final String fileName = FileUtils.stripFileExtension(path.toString()) + ".csv"; final CSVFormat csvFormat = CSVFormat.EXCEL.withQuoteMode(QuoteMode.ALL); try (final OutputStreamWriter outputStreamWriter = new OutputStreamWriter(Files.newOutputStream(Paths.get(fileName)), StandardCharsets.UTF_8); final CSVPrinter writer = new CSVPrinter(new BufferedWriter(outputStreamWriter), csvFormat)) { outputStreamWriter.write(BYTE_ORDER_MARK); // write UTF-8 byte order mark to the file for easier imports writer.printRecord(ResourceUtils.getString("Column.Account"), ResourceUtils.getString("Column.Code"), ResourceUtils.getString("Column.Entries"), ResourceUtils.getString("Column.Balance"), ResourceUtils.getString("Column.ReconciledBalance"), ResourceUtils.getString("Column.Currency"), ResourceUtils.getString("Column.Type")); // Create a list sorted by depth and account code and then name if code is not specified final List accountList = engine.getAccountList(); accountList.sort(Comparators.getAccountByTreePosition(Comparators.getAccountByCode())); final CurrencyNode currencyNode = engine.getDefaultCurrency(); final LocalDate today = LocalDate.now(); for (final Account account : accountList) { final String indentedName = SPACE.repeat((account.getDepth() - 1) * INDENT) + account.getName(); final String balance = account.getTreeBalance(today, currencyNode).toPlainString(); final String reconcileBalance = account.getReconciledTreeBalance().toPlainString(); writer.printRecord(indentedName, String.valueOf(account.getAccountCode()), String.valueOf(account.getTransactionCount()), balance, reconcileBalance, account.getCurrencyNode().getSymbol(), account.getAccountType().toString()); } } catch (final IOException e) { Logger.getLogger(CsvExport.class.getName()).log(Level.SEVERE, e.getLocalizedMessage(), e); } } public static void exportAccount(@NotNull final Account account, @NotNull final LocalDate startDate, @NotNull final LocalDate endDate, @NotNull final Path path) { Objects.requireNonNull(account); Objects.requireNonNull(startDate); Objects.requireNonNull(endDate); Objects.requireNonNull(path); // force a correct file extension final String fileName = FileUtils.stripFileExtension(path.toString()) + ".csv"; final CSVFormat csvFormat = CSVFormat.EXCEL.withQuoteMode(QuoteMode.ALL); try (final OutputStreamWriter outputStreamWriter = new OutputStreamWriter(Files.newOutputStream(Paths.get(fileName)), StandardCharsets.UTF_8); final CSVPrinter writer = new CSVPrinter(new BufferedWriter(outputStreamWriter), csvFormat)) { outputStreamWriter.write(BYTE_ORDER_MARK); // write UTF-8 byte order mark to the file for easier imports writer.printRecord(ResourceUtils.getString("Column.Account"), ResourceUtils.getString("Column.Num"), ResourceUtils.getString("Column.Debit"), ResourceUtils.getString("Column.Credit"), ResourceUtils.getString("Column.Balance"), ResourceUtils.getString("Column.Date"), ResourceUtils.getString("Column.Timestamp"), ResourceUtils.getString("Column.Memo"), ResourceUtils.getString("Column.Payee"), ResourceUtils.getString("Column.Clr")); // write the transactions final List transactions = account.getTransactions(startDate, endDate); final DateTimeFormatter dateTimeFormatter = DateUtils.getExcelDateFormatter(); final DateTimeFormatter timestampFormatter = DateUtils.getExcelTimestampFormatter(); for (final Transaction transaction : transactions) { final String date = dateTimeFormatter.format(transaction.getLocalDate()); final String timeStamp = timestampFormatter.format(transaction.getTimestamp()); final String credit = transaction.getAmount(account).compareTo(BigDecimal.ZERO) < 0 ? "" : transaction.getAmount(account).abs().toPlainString(); final String debit = transaction.getAmount(account).compareTo(BigDecimal.ZERO) > 0 ? "" : transaction.getAmount(account).abs().toPlainString(); final String balance = account.getBalanceAt(transaction).toPlainString(); final String reconciled = transaction.getReconciled(account) == ReconciledState.NOT_RECONCILED ? Boolean.FALSE.toString() : Boolean.TRUE.toString(); writer.printRecord(account.getName(), transaction.getNumber(), debit, credit, balance, date, timeStamp, transaction.getMemo(), transaction.getPayee(), reconciled); } } catch (final IOException e) { Logger.getLogger(CsvExport.class.getName()).log(Level.SEVERE, e.getLocalizedMessage(), e); } } } ================================================ FILE: jgnash-convert/src/main/java/jgnash/convert/exportantur/ofx/OfxExport.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.convert.exportantur.ofx; import java.io.File; import java.io.IOException; import java.io.PrintWriter; import java.io.Writer; import java.math.BigDecimal; import java.nio.charset.Charset; import java.nio.file.Files; import java.nio.file.Paths; import java.time.LocalDate; import java.time.format.DateTimeFormatter; import java.util.Objects; import java.util.logging.Level; import java.util.logging.Logger; import jgnash.convert.common.OfxTags; import jgnash.engine.Account; import jgnash.engine.AccountType; import jgnash.engine.InvestmentTransaction; import jgnash.engine.SecurityNode; import jgnash.engine.Transaction; import jgnash.engine.TransactionType; import jgnash.util.FileUtils; import jgnash.util.NotNull; /** * Primary class for OFX export. The SGML format is used instead of the newer * XML to offer the best compatibility with older importers * * @author Craig Cavanaugh */ public class OfxExport implements OfxTags { private static final String[] OFXHEADER = new String[]{"OFXHEADER:100", "DATA:OFXSGML", "VERSION:102", "SECURITY:NONE", "ENCODING:USASCII", "CHARSET:1252", "COMPRESSION:NONE", "OLDFILEUID:NONE", "NEWFILEUID:NONE"}; private final DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("yyyyMMdd"); private final Account account; private final LocalDate startDate; private final LocalDate endDate; private final File file; private IndentedPrintWriter indentedWriter; private int indentLevel = 0; public OfxExport(final Account account, final LocalDate startDate, final LocalDate endDate, final File file) { this.account = account; this.startDate = startDate; this.endDate = endDate; this.file = file; } public void exportAccount() { Objects.requireNonNull(account); Objects.requireNonNull(startDate); Objects.requireNonNull(endDate); Objects.requireNonNull(file); final LocalDate exportDate = LocalDate.now(); // force a correct file extension final String fileName = FileUtils.stripFileExtension(file.getAbsolutePath()) + ".ofx"; try (final IndentedPrintWriter writer = new IndentedPrintWriter(Files.newBufferedWriter(Paths.get(fileName), Charset.forName("windows-1252")))) { indentedWriter = writer; // write the required header for (String line : OFXHEADER) { writer.println(line, indentLevel); } writer.println(); // start of data writer.println(wrapOpen(OFX), indentLevel++); // write sign-on response writer.println(wrapOpen(SIGNONMSGSRSV1), indentLevel++); writer.println(wrapOpen(SONRS), indentLevel++); writer.println(wrapOpen(STATUS), indentLevel++); writer.println(wrapOpen(CODE) + "0", indentLevel); writer.println(wrapOpen(SEVERITY) + "INFO", indentLevel); writer.println(wrapClose(STATUS), --indentLevel); writer.println(wrapOpen(DTSERVER) + encodeDate(exportDate), indentLevel); writer.println(wrapOpen(LANGUAGE) + "ENG", indentLevel); writer.println(wrapClose(SONRS), --indentLevel); writer.println(wrapClose(SIGNONMSGSRSV1), --indentLevel); writer.println(wrapOpen(getBankingMessageSetAggregate(account)), indentLevel++); writer.println(wrapOpen(getResponse(account)), indentLevel++); writer.println(wrapOpen(TRNUID) + "1", indentLevel); writer.println(wrapOpen(STATUS), indentLevel++); writer.println(wrapOpen(CODE) + "0", indentLevel); writer.println(wrapOpen(SEVERITY) + "INFO", indentLevel); writer.println(wrapClose(STATUS), --indentLevel); // begin start of statement response writer.println(wrapOpen(getStatementResponse(account)), indentLevel++); writer.println(wrapOpen(CURDEF) + account.getCurrencyNode().getSymbol(), indentLevel); // write account identification writer.println(wrapOpen(getAccountFromAggregate(account)), indentLevel++); switch (account.getAccountType()) { case INVEST: case MUTUAL: writer.println(wrapOpen(BROKERID), indentLevel); // required for investment accounts, but jGnash does not manage a broker ID, normally a web URL break; default: writer.println(wrapOpen(BANKID) + account.getBankId(), indentLevel); // savings and checking only break; } writer.println(wrapOpen(ACCTID) + account.getAccountNumber(), indentLevel); // write the required account type switch (account.getAccountType()) { case CHECKING: writer.println(wrapOpen(ACCTTYPE) + CHECKING, indentLevel); break; case ASSET: case BANK: case CASH: writer.println(wrapOpen(ACCTTYPE) + SAVINGS, indentLevel); break; case CREDIT: case LIABILITY: writer.println(wrapOpen(ACCTTYPE) + CREDITLINE, indentLevel); break; case SIMPLEINVEST: case MONEYMKRT: writer.println(wrapOpen(ACCTTYPE) + MONEYMRKT, indentLevel); break; default: break; } writer.println(wrapClose(getAccountFromAggregate(account)), --indentLevel); // begin start of transaction list writer.println(wrapOpen(getTransactionList(account)), indentLevel++); writer.println(wrapOpen(DTSTART) + encodeDate(startDate), indentLevel); writer.println(wrapOpen(DTEND) + encodeDate(endDate), indentLevel); // write the transaction list if (account.getAccountType() == AccountType.INVEST || account.getAccountType() == AccountType.MUTUAL) { writeInvestmentTransactions(); } else { writeBankTransactions(); } // end of transaction list writer.println(wrapClose(getTransactionList(account)), --indentLevel); // write ledger balance writer.println(wrapOpen(LEDGERBAL), indentLevel++); writer.println(wrapOpen(BALAMT) + account.getBalance(endDate).toPlainString(), indentLevel); writer.println(wrapOpen(DTASOF) + encodeDate(exportDate), indentLevel); writer.println(wrapClose(LEDGERBAL), --indentLevel); // end of statement response writer.println(wrapClose(getStatementResponse(account)), --indentLevel); writer.println(wrapClose(getResponse(account)), --indentLevel); writer.println(wrapClose(getBankingMessageSetAggregate(account)), --indentLevel); // finished writer.println(wrapClose(OFX), --indentLevel); } catch (IOException e) { Logger.getLogger(OfxExport.class.getName()).log(Level.SEVERE, e.getLocalizedMessage(), e); } } /** * Writes all bank account transactions within the date range */ private void writeBankTransactions() { account.getTransactions(startDate, endDate).forEach(this::writeBankTransaction); } /** * Writes all investment account transactions within the date range */ private void writeInvestmentTransactions() { for (final Transaction transaction : account.getTransactions(startDate, endDate)) { if (transaction instanceof InvestmentTransaction) { final InvestmentTransaction invTransaction = (InvestmentTransaction) transaction; switch (invTransaction.getTransactionType()) { case ADDSHARE: case BUYSHARE: writeBuyStockTransaction(invTransaction); break; case REMOVESHARE: case SELLSHARE: writeSellStockTransaction(invTransaction); break; case DIVIDEND: writeDividendTransaction(invTransaction); break; case REINVESTDIV: writeReinvestStockTransaction(invTransaction); break; default: break; } } else { // bank transaction, write it indentedWriter.println(wrapOpen(INVBANKTRAN), indentLevel++); writeBankTransaction(transaction); indentedWriter.println(wrapClose(INVBANKTRAN), --indentLevel); } } } /** * Writes one bank transaction * * @param transaction {@code Transaction} to write */ private void writeBankTransaction(final Transaction transaction) { indentedWriter.println(wrapOpen(STMTTRN), indentLevel++); indentedWriter.println(wrapOpen(TRNTYPE) + (transaction.getAmount(account).compareTo(BigDecimal.ZERO) >= 1 ? CREDIT : DEBIT), indentLevel); indentedWriter.println(wrapOpen(DTPOSTED) + encodeDate(transaction.getLocalDate()), indentLevel); indentedWriter.println(wrapOpen(TRNAMT) + transaction.getAmount(account).toPlainString(), indentLevel); indentedWriter.println(wrapOpen(REFNUM) + transaction.getUuid(), indentLevel); indentedWriter.println(wrapOpen(NAME) + transaction.getPayee(), indentLevel); indentedWriter.println(wrapOpen(MEMO) + transaction.getMemo(), indentLevel); // write the check number if applicable if (account.getAccountType() == AccountType.CHECKING && !transaction.getNumber().isEmpty()) { indentedWriter.println(wrapOpen(CHECKNUM) + transaction.getNumber(), indentLevel); } // write out the banks transaction id if previously imported writeFitID(transaction); // write out the account to if (transaction.getTransactionType() == TransactionType.DOUBLEENTRY) { final Account other = transaction.getTransactionEntries().get(0).getCreditAccount() != account ? transaction.getTransactionEntries().get(0).getCreditAccount() : transaction.getTransactionEntries().get(0).getDebitAccount(); if (other != null && other.getAccountNumber() != null && other.getAccountNumber().length() > 0) { if (other.getAccountType() != AccountType.EXPENSE && other.getAccountType() != AccountType.INCOME) { writeAccountTo(other); } } } indentedWriter.println(wrapClose(STMTTRN), --indentLevel); } private void writeFitID(@NotNull final Transaction transaction) { // write out the banks transaction id if previously imported if (transaction.getFitid() != null && !transaction.getFitid().isEmpty()) { indentedWriter.println(wrap(FITID, transaction.getFitid()), indentLevel); } else { indentedWriter.println(wrap(FITID, transaction.getUuid().toString()), indentLevel); } } private void writeSecID(final SecurityNode node) { // write security information indentedWriter.println(wrapOpen(SECID), indentLevel++); if (!node.getISIN().isEmpty()) { indentedWriter.println(wrap(UNIQUEID, node.getISIN()), indentLevel); } else { indentedWriter.println(wrap(UNIQUEID, node.getSymbol()), indentLevel); } indentedWriter.println(wrap(UNIQUEIDTYPE, "CUSIP"), indentLevel); indentedWriter.println(wrapClose(SECID), --indentLevel); } private void writeBuyStockTransaction(final InvestmentTransaction transaction) { indentedWriter.println(wrapOpen(BUYSTOCK), indentLevel++); indentedWriter.println(wrapOpen(INVBUY), indentLevel++); indentedWriter.println(wrapOpen(INVTRAN), indentLevel++); // write the FITID writeFitID(transaction); indentedWriter.println(wrap(DTTRADE, encodeDate(transaction.getLocalDate())), indentLevel); indentedWriter.println(wrap(DTSETTLE, encodeDate(transaction.getLocalDate())), indentLevel); indentedWriter.println(wrapClose(INVTRAN), --indentLevel); // write security information writeSecID(transaction.getSecurityNode()); indentedWriter.println(wrap(UNITS, transaction.getQuantity().toPlainString()), indentLevel); indentedWriter.println(wrap(UNITPRICE, transaction.getPrice().toPlainString()), indentLevel); indentedWriter.println(wrap(COMMISSION, transaction.getFees().toPlainString()), indentLevel); indentedWriter.println(wrap(TOTAL, transaction.getTotal(account).toPlainString()), indentLevel); indentedWriter.println(wrap(SUBACCTSEC, "CASH"), indentLevel); indentedWriter.println(wrap(SUBACCTFUND, "CASH"), indentLevel); indentedWriter.println(wrapClose(INVBUY), --indentLevel); indentedWriter.println(wrap(BUYTYPE, "BUY"), indentLevel); indentedWriter.println(wrapClose(BUYSTOCK), --indentLevel); } private void writeSellStockTransaction(final InvestmentTransaction transaction) { indentedWriter.println(wrapOpen(SELLSTOCK), indentLevel++); indentedWriter.println(wrapOpen(INVSELL), indentLevel++); indentedWriter.println(wrapOpen(INVTRAN), indentLevel++); // write the FITID writeFitID(transaction); indentedWriter.println(wrap(DTTRADE, encodeDate(transaction.getLocalDate())), indentLevel); indentedWriter.println(wrap(DTSETTLE, encodeDate(transaction.getLocalDate())), indentLevel); indentedWriter.println(wrapClose(INVTRAN), --indentLevel); // write security information writeSecID(transaction.getSecurityNode()); indentedWriter.println(wrap(UNITS, transaction.getQuantity().toPlainString()), indentLevel); indentedWriter.println(wrap(UNITPRICE, transaction.getPrice().toPlainString()), indentLevel); indentedWriter.println(wrap(COMMISSION, transaction.getFees().toPlainString()), indentLevel); indentedWriter.println(wrap(TOTAL, transaction.getTotal(account).toPlainString()), indentLevel); indentedWriter.println(wrap(SUBACCTSEC, "CASH"), indentLevel); indentedWriter.println(wrap(SUBACCTFUND, "CASH"), indentLevel); indentedWriter.println(wrapClose(INVSELL), --indentLevel); indentedWriter.println(wrap(SELLTYPE, "SELL"), indentLevel); indentedWriter.println(wrapClose(SELLSTOCK), --indentLevel); } /** * Reinvested transaction is a two part process. * Need to show Income into cash and then the reinvestment from cash * * @param transaction transaction to write */ private void writeReinvestStockTransaction(final InvestmentTransaction transaction) { // Part one, show dividend income to cash writeDividendTransaction(transaction); // Part two, show reinvest from cash indentedWriter.println(wrapOpen(REINVEST), indentLevel++); indentedWriter.println(wrapOpen(INVTRAN), indentLevel++); // write the FITID writeFitID(transaction); indentedWriter.println(wrap(DTTRADE, encodeDate(transaction.getLocalDate())), indentLevel); indentedWriter.println(wrap(DTSETTLE, encodeDate(transaction.getLocalDate())), indentLevel); indentedWriter.println(wrap(MEMO, "Distribution reinvestment: " + transaction.getSecurityNode().getSymbol()), indentLevel); indentedWriter.println(wrapClose(INVTRAN), --indentLevel); // write security information writeSecID(transaction.getSecurityNode()); indentedWriter.println(wrap(INCOMETYPE, "DIV"), indentLevel); indentedWriter.println(wrap(TOTAL, transaction.getTotal(account).abs().negate().toPlainString()), indentLevel); indentedWriter.println(wrap(SUBACCTSEC, "CASH"), indentLevel); indentedWriter.println(wrap(UNITS, transaction.getQuantity().toPlainString()), indentLevel); indentedWriter.println(wrap(UNITPRICE, transaction.getPrice().toPlainString()), indentLevel); indentedWriter.println(wrap(COMMISSION, transaction.getFees().toPlainString()), indentLevel); indentedWriter.println(wrapClose(REINVEST), --indentLevel); } private void writeDividendTransaction(final InvestmentTransaction transaction) { indentedWriter.println(wrapOpen(INCOME), indentLevel++); indentedWriter.println(wrapOpen(INVTRAN), indentLevel++); writeFitID(transaction); // write the FITID indentedWriter.println(wrap(DTTRADE, encodeDate(transaction.getLocalDate())), indentLevel); indentedWriter.println(wrap(DTSETTLE, encodeDate(transaction.getLocalDate())), indentLevel); indentedWriter.println(wrap(MEMO, "Dividend: " + transaction.getSecurityNode().getSymbol()), indentLevel); indentedWriter.println(wrapClose(INVTRAN), --indentLevel); // write security information writeSecID(transaction.getSecurityNode()); indentedWriter.println(wrap(INCOMETYPE, "DIV"), indentLevel); indentedWriter.println(wrap(TOTAL, transaction.getTotal(account).abs().toPlainString()), indentLevel); indentedWriter.println(wrap(SUBACCTSEC, "CASH"), indentLevel); indentedWriter.println(wrap(SUBACCTFUND, "CASH"), indentLevel); indentedWriter.println(wrapClose(INCOME), --indentLevel); } private String encodeDate(final LocalDate date) { return dateTimeFormatter.format(date) + "000000"; } private static String wrapOpen(final String element) { return "<" + element + ">"; } private static String wrapClose(final String element) { return ""; } private static String wrap(final String element, final String text) { return wrapOpen(element) + text + wrapClose(element); } private void writeAccountTo(Account account) { // write account identification indentedWriter.println(wrapOpen(getAccountToAggregate(account)), indentLevel++); switch (account.getAccountType()) { case INVEST: case MUTUAL: indentedWriter.println(wrapOpen(BROKERID), indentLevel); // required for investment accounts, but jGnash does not manage a broker ID, normally a web URL break; default: indentedWriter.println(wrapOpen(BANKID) + account.getBankId(), indentLevel); // savings and checking only break; } indentedWriter.println(wrapOpen(ACCTID) + account.getAccountNumber(), indentLevel); // write the required account type switch (account.getAccountType()) { case CHECKING: indentedWriter.println(wrapOpen(ACCTTYPE) + CHECKING, indentLevel); break; case ASSET: case BANK: case CASH: indentedWriter.println(wrapOpen(ACCTTYPE) + SAVINGS, indentLevel); break; case CREDIT: case LIABILITY: indentedWriter.println(wrapOpen(ACCTTYPE) + CREDITLINE, indentLevel); break; case SIMPLEINVEST: case MONEYMKRT: indentedWriter.println(wrapOpen(ACCTTYPE) + MONEYMRKT, indentLevel); break; default: break; } indentedWriter.println(wrapClose(getAccountToAggregate(account)), --indentLevel); } private static String getBankingMessageSetAggregate(final Account account) { switch (account.getAccountType()) { case ASSET: case BANK: case CASH: case CHECKING: case SIMPLEINVEST: return BANKMSGSRSV1; case CREDIT: case LIABILITY: return CREDITCARDMSGSRSV1; case INVEST: case MUTUAL: return INVSTMTMSGSRSV1; default: return ""; } } private static String getResponse(final Account account) { switch (account.getAccountType()) { case ASSET: case BANK: case CASH: case CHECKING: case SIMPLEINVEST: return STMTTRNRS; case CREDIT: case LIABILITY: return CCSTMTTRNRS; case INVEST: case MUTUAL: return INVSTMTTRNRS; default: return ""; } } private static String getStatementResponse(final Account account) { switch (account.getAccountType()) { case ASSET: case BANK: case CASH: case CHECKING: case SIMPLEINVEST: return STMTRS; case CREDIT: case LIABILITY: return CCSTMTRS; case INVEST: case MUTUAL: return INVSTMTRS; default: return ""; } } private static String getAccountFromAggregate(final Account account) { switch (account.getAccountType()) { case ASSET: case BANK: case CASH: case CHECKING: case SIMPLEINVEST: return BANKACCTFROM; case CREDIT: case LIABILITY: return CCACCTFROM; case INVEST: case MUTUAL: return INVACCTFROM; default: return ""; } } private static String getAccountToAggregate(final Account account) { switch (account.getAccountType()) { case ASSET: case BANK: case CASH: case CHECKING: case SIMPLEINVEST: return BANKACCTTO; case CREDIT: case LIABILITY: return CCACCTTO; case INVEST: case MUTUAL: return INVACCTTO; default: return ""; } } private static String getTransactionList(final Account account) { switch (account.getAccountType()) { case INVEST: case MUTUAL: return INVTRANLIST; default: return BANKTRANLIST; } } /** * Support class to make writing indented SGML easier */ private static class IndentedPrintWriter extends PrintWriter { private static final String INDENT = " "; IndentedPrintWriter(final Writer out) { super(out); } void println(final String x, final int indentLevel) { for (int i = 0; i < indentLevel; i++) { write(INDENT); } println(x); } } } ================================================ FILE: jgnash-convert/src/main/java/jgnash/convert/importat/BayesImportClassifier.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.convert.importat; import java.util.List; import java.util.Set; import jgnash.bayes.BayesClassifier; import jgnash.engine.Account; import jgnash.engine.Transaction; import jgnash.engine.TransactionType; /** * Bayes classifier import utility methods * * @author Craig Cavanaugh */ public class BayesImportClassifier { /** * Utility class, private constructor */ private BayesImportClassifier() { } public static void classifyTransactions(final List list, final List transactions, final Account baseAccount) { final BayesClassifier classifier = generateClassifier(transactions, baseAccount); for (final ImportTransaction transaction : list) { final StringBuilder builder = new StringBuilder(); builder.append(transaction.getPayee()).append(" "); if (transaction.getMemo() != null) { builder.append(transaction.getMemo()); } // reinvested dividends do not have a cash account if (transaction.getTransactionType() != TransactionType.REINVESTDIV) { transaction.setAccount(classifier.classify(builder.toString())); } } } private static BayesClassifier generateClassifier(List transactions, final Account baseAccount) { final BayesClassifier classifier = new BayesClassifier<>(baseAccount); for (final Transaction t : transactions) { final Set accountSet = t.getAccounts(); accountSet.remove(baseAccount); for (final Account account : accountSet) { if (!t.getPayee().isEmpty()) { classifier.train(t.getPayee(), account); } if (!t.getMemo().isEmpty()) { classifier.train(t.getMemo(), account); } } } return classifier; } } ================================================ FILE: jgnash-convert/src/main/java/jgnash/convert/importat/DateFormat.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.convert.importat; /** * @author Craig Cavanaugh */ public enum DateFormat { US("mm/dd/yyyy"), EU("dd/mm/yyyy"); private final String format; DateFormat(String format) { this.format = format; } @Override public String toString() { return format; } } ================================================ FILE: jgnash-convert/src/main/java/jgnash/convert/importat/GenericImport.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.convert.importat; import java.time.LocalDate; import java.util.List; import java.util.Objects; import java.util.logging.Level; import java.util.logging.Logger; import jgnash.engine.Account; import jgnash.engine.AccountGroup; import jgnash.engine.CurrencyNode; import jgnash.engine.Engine; import jgnash.engine.EngineFactory; import jgnash.engine.SecurityHistoryNode; import jgnash.engine.SecurityNode; import jgnash.engine.Transaction; import jgnash.engine.TransactionFactory; import jgnash.time.DateUtils; import jgnash.util.NotNull; /** * Generic import utility methods * * @author Craig Cavanaugh * @author Arnout Engelen */ public class GenericImport { private GenericImport() { } public static void importTransactions(@NotNull final List transactions, @NotNull final Account baseAccount) { Objects.requireNonNull(transactions); Objects.requireNonNull(baseAccount); final Engine engine = EngineFactory.getEngine(EngineFactory.DEFAULT); Objects.requireNonNull(engine); for (final ImportTransaction tran : transactions) { Objects.requireNonNull(tran.getAccount()); if (tran.getState() == ImportState.NEW || tran.getState() == ImportState.NOT_EQUAL) { // do not import matched transactions Transaction transaction; if (tran.isInvestmentTransaction()) { if (baseAccount.getAccountType().getAccountGroup() == AccountGroup.INVEST) { System.out.println("Should be creating an investment transaction"); } else { // Signal an error System.out.println("The base account was not an investment account type"); } } if (baseAccount.equals(tran.getAccount())) { // single entry oTran transaction = TransactionFactory.generateSingleEntryTransaction(baseAccount, tran.getAmount(), tran.getDatePosted(), tran.getMemo(), tran.getPayee(), tran.getCheckNumber()); } else { // double entry if (tran.getAmount().signum() >= 0) { transaction = TransactionFactory.generateDoubleEntryTransaction(baseAccount, tran.getAccount(), tran.getAmount().abs(), tran.getDatePosted(), tran.getMemo(), tran.getPayee(), tran.getCheckNumber()); } else { transaction = TransactionFactory.generateDoubleEntryTransaction(tran.getAccount(), baseAccount, tran.getAmount().abs(), tran.getDatePosted(), tran.getMemo(), tran.getPayee(), tran.getCheckNumber()); } } transaction.setFitid(tran.getFITID()); engine.addTransaction(transaction); } } } /** * Sets the match state of a list of imported transactions * * @param list list of imported transactions * @param baseAccount account to perform match against */ public static void matchTransactions(final List list, @NotNull final Account baseAccount) { Objects.requireNonNull(baseAccount); for (final ImportTransaction importTransaction : list) { // amount must always match for (final Transaction tran : baseAccount.getSortedTransactionList()) { // amounts must be comparably the same, do not use an equality check if (tran.getAmount(baseAccount).compareTo(importTransaction.getAmount()) == 0) { // check for date match final LocalDate startDate; final LocalDate endDate; // we have a user initiated date, use a smaller window if ((importTransaction.getDateUser() != null)) { startDate = importTransaction.getDateUser().minusDays(1); endDate = importTransaction.getDateUser().plusDays(1); } else { // use the posted date with a larger window startDate = importTransaction.getDatePosted().minusDays(3); endDate = importTransaction.getDatePosted().plusDays(3); } if (DateUtils.after(tran.getLocalDate(), startDate) && DateUtils.before(tran.getLocalDate(), endDate)) { importTransaction.setState(ImportState.EQUAL); break; } // check for matching check number final String checkNumber = importTransaction.getCheckNumber(); if (checkNumber != null && !checkNumber.isEmpty()) { if (tran.getNumber().equals(checkNumber)) { importTransaction.setState(ImportState.EQUAL); break; } } // check for matching fitid number final String id = importTransaction.getFITID(); if (id != null && !id.isEmpty()) { if (tran.getFitid() != null && tran.getFitid().equals(id)) { importTransaction.setState(ImportState.EQUAL); break; } } } } } } public static Account findFirstAvailableAccount() { final Engine engine = EngineFactory.getEngine(EngineFactory.DEFAULT); Objects.requireNonNull(engine); for (final Account account : engine.getAccountList()) { if (!account.isPlaceHolder() && !account.isLocked()) { return account; } } return null; } public static void importSecurities(final List importSecurities, final CurrencyNode currencyNode) { final Engine engine = EngineFactory.getEngine(EngineFactory.DEFAULT); Objects.requireNonNull(engine); for (final ImportSecurity importSecurity : importSecurities) { if (ImportUtils.matchSecurity(importSecurity).isEmpty()) { // Import only if a match is not found final SecurityNode securityNode = ImportUtils.createSecurityNode(importSecurity, currencyNode); // link the security node importSecurity.setSecurityNode(securityNode); engine.addSecurity(securityNode); // if the ImportSecurity has pricing information, import it as well importSecurity.getLocalDate().ifPresent(localDate -> importSecurity.getUnitPrice().ifPresent(price -> { SecurityHistoryNode securityHistoryNode = new SecurityHistoryNode(localDate, price, 0, price, price); engine.addSecurityHistory(securityNode, securityHistoryNode); })); } else { // check to see if the cuspid needs to be updated // link the security node ImportUtils.matchSecurity(importSecurity).ifPresent(importSecurity::setSecurityNode); ImportUtils.matchSecurity(importSecurity) .ifPresent(securityNode -> importSecurity.getId().ifPresent(securityId -> { if (securityNode.getISIN() == null || securityNode.getISIN().isEmpty()) { try { final SecurityNode clone = (SecurityNode) securityNode.clone(); clone.setISIN(securityId); engine.updateCommodity(securityNode, clone); Logger.getLogger(GenericImport.class.getName()).info("Assigning CUSPID"); } catch (final CloneNotSupportedException e) { Logger.getLogger(GenericImport.class.getName()).log(Level.SEVERE, e.getLocalizedMessage(), e); } } })); } } } } ================================================ FILE: jgnash-convert/src/main/java/jgnash/convert/importat/ImportBank.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.convert.importat; import java.util.ArrayList; import java.util.List; /** * Common superclass for OFX and MT940 in * * @author Craig Cavanaugh * @author Arnout Engelen */ public class ImportBank { private List transactions = new ArrayList<>(); final protected List securityList = new ArrayList<>(); public void addSecurity(final ImportSecurity importSecurity) { securityList.add(importSecurity); } public void setTransactions(List transactions) { this.transactions = transactions; } /** * Returns a mutable list of transactions. * * @return mutable List */ public List getTransactions() { return transactions; } public void addTransaction(E transaction) { transactions.add(transaction); } public List getSecurityList() { return securityList; } public boolean isInvestmentAccount() { boolean result = false; for (final E transaction : getTransactions()) { if (transaction.isInvestmentTransaction()) { result = true; break; } } return result; } } ================================================ FILE: jgnash-convert/src/main/java/jgnash/convert/importat/ImportFilter.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2021 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.convert.importat; import jgnash.engine.Engine; import jgnash.engine.EngineFactory; import jgnash.util.EncodeDecode; import jgnash.util.FileUtils; import jgnash.util.NotNull; import jgnash.resource.util.OS; import javax.script.Invocable; import javax.script.ScriptEngine; import javax.script.ScriptEngineManager; import javax.script.ScriptException; import java.io.IOException; import java.io.InputStreamReader; import java.io.Reader; import java.io.UnsupportedEncodingException; import java.net.URLDecoder; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; import java.util.List; import java.util.Locale; import java.util.Objects; import java.util.logging.Level; import java.util.logging.Logger; import java.util.stream.Collectors; import static jgnash.util.FileUtils.SEPARATOR; /** * Transaction Import filter. *

* This class is responsible for calling the supplied javascript file. * * @author Craig Cavanaugh */ public class ImportFilter { private final static String ENABLED_FILTERS = "enabledFilters"; private final static String IMPORT_SCRIPT_DIRECTORY_NAME = "importScripts"; private static final String JS_REGEX_PATTERN = ".*.js"; private static final Logger logger = Logger.getLogger(ImportFilter.class.getName()); private static final String[] KNOWN_SCRIPTS = {"/jgnash/convert/scripts/tidy.js"}; private final ScriptEngine scriptEngine; private final String script; ImportFilter(final String script) { scriptEngine = new ScriptEngineManager().getEngineByName("nashorn"); this.script = script; evalScript(); } public static List getImportFilters() { final List importFilterList = new ArrayList<>(); // known filters first for (String knownScript : KNOWN_SCRIPTS) { importFilterList.add(new ImportFilter(knownScript)); } for (final Path path : FileUtils.getDirectoryListing(getUserImportScriptDirectory(), JS_REGEX_PATTERN)) { importFilterList.add(new ImportFilter(path.toString())); } final String activeDatabase = EngineFactory.getActiveDatabase(); if (activeDatabase != null && !activeDatabase.startsWith(EngineFactory.REMOTE_PREFIX)) { for (final Path path : FileUtils.getDirectoryListing(getBaseFileImportScriptDirectory(Paths.get(activeDatabase)), JS_REGEX_PATTERN)) { importFilterList.add(new ImportFilter(path.toString())); } } return importFilterList; } public static List getEnabledImportFilters() { List filterList = new ArrayList<>(); final Engine engine = EngineFactory.getEngine(EngineFactory.DEFAULT); Objects.requireNonNull(engine); for (final String string : EncodeDecode.decodeStringCollection(engine.getPreference(ENABLED_FILTERS))) { filterList.add(new ImportFilter(string)); } return filterList; } public static void saveEnabledImportFilters(final List filters) { final Engine engine = EngineFactory.getEngine(EngineFactory.DEFAULT); Objects.requireNonNull(engine); if (filters != null && filters.size() > 0) { final List scripts = filters.stream().map(ImportFilter::getScript).collect(Collectors.toList()); engine.setPreference(ENABLED_FILTERS, EncodeDecode.encodeStringCollection(scripts)); } else { engine.setPreference(ENABLED_FILTERS, null); } } private static Path getUserImportScriptDirectory() { String scriptDirectory = System.getProperty("user.home"); // decode to correctly handle spaces, etc. in the returned path try { scriptDirectory = URLDecoder.decode(scriptDirectory, StandardCharsets.UTF_8.name()); } catch (final UnsupportedEncodingException ex) { logger.log(Level.SEVERE, ex.getLocalizedMessage(), ex); } if (OS.isSystemWindows()) { scriptDirectory += SEPARATOR + "AppData" + SEPARATOR + "Local" + SEPARATOR + "jgnash" + SEPARATOR + IMPORT_SCRIPT_DIRECTORY_NAME; } else { // unix, osx scriptDirectory += SEPARATOR + ".jgnash" + SEPARATOR + IMPORT_SCRIPT_DIRECTORY_NAME; } logger.log(Level.INFO, "Import Script path: {0}", scriptDirectory); return Paths.get(scriptDirectory); } private static Path getBaseFileImportScriptDirectory(@NotNull final Path baseFile) { if (baseFile.getParent() != null) { return Paths.get(baseFile.getParent() + SEPARATOR + IMPORT_SCRIPT_DIRECTORY_NAME); } return null; } public String getScript() { return script; } private void evalScript() { try (final Reader reader = getReader()) { scriptEngine.eval(reader); } catch (final ScriptException | IOException e) { logger.log(Level.SEVERE, e.getLocalizedMessage(), e); } } public String processMemo(final String memo) { try { final Invocable invocable = (Invocable) scriptEngine; final Object result = invocable.invokeFunction("processMemo", memo); return result.toString(); } catch (final ScriptException | NoSuchMethodException e) { logger.log(Level.SEVERE, e.getLocalizedMessage(), e); } return memo; } public String processPayee(final String payee) { try { final Invocable invocable = (Invocable) scriptEngine; final Object result = invocable.invokeFunction("processPayee", payee); return result.toString(); } catch (final ScriptException | NoSuchMethodException e) { logger.log(Level.SEVERE, e.getLocalizedMessage(), e); } return payee; } public String getDescription() { try { final Invocable invocable = (Invocable) scriptEngine; final Object result = invocable.invokeFunction("getDescription", Locale.getDefault()); return result.toString(); } catch (final ScriptException | NoSuchMethodException e) { logger.log(Level.SEVERE, e.getLocalizedMessage(), e); } return ""; } public void acceptTransaction(final ImportTransaction importTransaction) { try { final Invocable invocable = (Invocable) scriptEngine; invocable.invokeFunction("acceptTransaction", importTransaction); } catch (final ScriptException | NoSuchMethodException e) { logger.log(Level.SEVERE, e.getLocalizedMessage(), e); } } private Reader getReader() throws IOException { if (Files.exists(Paths.get(script))) { return Files.newBufferedReader(Paths.get(script)); } return new InputStreamReader( Objects.requireNonNull(ImportFilter.class.getResourceAsStream(script)), StandardCharsets.UTF_8); } } ================================================ FILE: jgnash-convert/src/main/java/jgnash/convert/importat/ImportSecurity.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.convert.importat; import java.math.BigDecimal; import java.time.LocalDate; import java.util.Optional; import jgnash.engine.SecurityNode; /** * Security Import Object * * @author Craig Cavanaugh */ public class ImportSecurity { private String ticker; private String securityName; private BigDecimal unitPrice; private LocalDate localDate; private String id; public String idType; private String currency; private BigDecimal currencyRate; private SecurityNode securityNode; @Override public String toString() { StringBuilder b = new StringBuilder(); b.append("ticker: ").append(getTicker()).append('\n'); b.append("securityName: ").append(securityName).append('\n'); b.append("unitPrice: ").append(unitPrice).append('\n'); b.append("localDate: ").append(localDate).append('\n'); if (id != null) { b.append("id: ").append(id).append('\n'); } if (idType != null) { b.append("idType: ").append(idType).append('\n'); } getCurrency().ifPresent(currency -> b.append("currency: ").append(currency).append('\n')); getCurrencyRate().ifPresent(rate -> b.append("currencyRate: ").append(rate).append('\n')); return b.toString(); } public Optional getId() { return Optional.ofNullable(id); } public void setId(String id) { this.id = id; } Optional getSecurityName() { return Optional.ofNullable(securityName); } public void setSecurityName(final String securityName) { this.securityName = securityName; } Optional getUnitPrice() { return Optional.ofNullable(unitPrice); } public void setUnitPrice(final BigDecimal unitPrice) { this.unitPrice = unitPrice; } public Optional getLocalDate() { return Optional.ofNullable(localDate); } public void setLocalDate(final LocalDate localDate) { this.localDate = localDate; } public void setCurrencyRate(final BigDecimal unitPrice) { this.currencyRate = unitPrice; } public Optional getCurrencyRate() { return Optional.ofNullable(currencyRate); } public void setCurrency(final String currency) { this.currency = currency; } public Optional getCurrency() { return Optional.ofNullable(currency); } /** * Reference to the security node linked to this imported security node */ public SecurityNode getSecurityNode() { return securityNode; } public void setSecurityNode(SecurityNode securityNode) { this.securityNode = securityNode; } public String getTicker() { return ticker; } public void setTicker(final String ticker) { this.ticker = ticker; } } ================================================ FILE: jgnash-convert/src/main/java/jgnash/convert/importat/ImportState.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.convert.importat; /** * @author Craig Cavanaugh */ public enum ImportState { NEW, EQUAL, IGNORE, NOT_EQUAL } ================================================ FILE: jgnash-convert/src/main/java/jgnash/convert/importat/ImportTransaction.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.convert.importat; import java.math.BigDecimal; import java.time.LocalDate; import java.util.Objects; import java.util.UUID; import jgnash.engine.Account; import jgnash.engine.TransactionType; import jgnash.util.NotNull; import jgnash.util.Nullable; /** * Common interface for importing transactions from OFX, QIF, and mt940 * * @author Craig Cavanaugh * @author Arnout Engelen * @author Nicolas Bouillon */ public class ImportTransaction implements Comparable { private final String uuid = UUID.randomUUID().toString(); /** * The destination account */ private Account account; /** * Account for dividends and gains/losses from an investment transaction */ private Account gainsAccount; /** * Account for investment expenses */ private Account feesAccount; private BigDecimal amount = BigDecimal.ZERO; private String checkNumber = ""; // check number (?) @NotNull private LocalDate datePosted = LocalDate.now(); @Nullable private LocalDate dateUser = null; private String memo = ""; // memo @NotNull private String payee = ""; // OFX private String payeeId; private ImportState state = ImportState.NEW; // OFX, Financial Institution transaction ID private String FITID; // OFX private String securityId; // OFX private String securityType; private BigDecimal units = BigDecimal.ZERO; private BigDecimal unitPrice = BigDecimal.ZERO; private BigDecimal commission = BigDecimal.ZERO; private BigDecimal fees = BigDecimal.ZERO; // OFX, Type of income for investment transaction private String incomeType; private boolean taxExempt = false; // OFX private TransactionType transactionType = TransactionType.SINGLENTRY; // single entry by default // OFX private String transactionTypeDescription; // OFX private String SIC; // OFX private String refNum; // OFX private String subAccount; private String currency; // OFX, transfer account id private String accountTo; /** * @return returns the destination account */ public Account getAccount() { return account; } public void setAccount(final Account account) { this.account = account; } /** * Depending on the implementation a unique ID may be provided that can be used to detect * duplication of prior imported transactions. * * @return transaction id */ public String getFITID() { return FITID; } public void setFITID(String FITID) { this.FITID = FITID; } /** * Deposits get positive 'amounts', withdrawals negative * * @return transaction amount */ public BigDecimal getAmount() { return amount; } public void setAmount(BigDecimal amount) { this.amount = amount; } @NotNull public LocalDate getDatePosted() { return datePosted; } public void setDatePosted(@NotNull LocalDate datePosted) { this.datePosted = datePosted; } /** * Date user initiated the transaction, optional, may be null * * @return date transaction was initiated */ @Nullable public LocalDate getDateUser() { return dateUser; } public void setDateUser(@Nullable LocalDate dateUser) { this.dateUser = dateUser; } public String getCheckNumber() { return checkNumber; } public void setCheckNumber(final String checkNumber) { this.checkNumber = checkNumber; } public String getMemo() { return memo; } public void setMemo(String memo) { this.memo = memo; } public ImportState getState() { return state; } public void setState(ImportState state) { this.state = state; } @NotNull public String getPayee() { return payee; } public void setPayee(@NotNull String payee) { Objects.requireNonNull(payee); this.payee = payee; } public String getSecurityId() { return securityId; } public void setSecurityId(String securityId) { this.securityId = securityId; } @Override public int compareTo(@NotNull final ImportTransaction importTransaction) { if (importTransaction == this) { return 0; } int result = getDatePosted().compareTo(importTransaction.getDatePosted()); if (result != 0) { return result; } result = payee.compareTo(importTransaction.payee); if (result != 0) { return result; } return Integer.compare(hashCode(), importTransaction.hashCode()); } @Override public boolean equals(final Object that) { if (this == that) { return true; } if (that == null || getClass() != that.getClass()) { return false; } return Objects.equals(uuid, ((ImportTransaction) that).uuid); } @Override public int hashCode() { return Objects.hash(uuid); } /** * Investment transaction units */ public BigDecimal getUnits() { return units; } public void setUnits(final BigDecimal units) { this.units = units; } /** * Investment transaction unit price */ public BigDecimal getUnitPrice() { return unitPrice; } public void setUnitPrice(final BigDecimal unitPrice) { this.unitPrice = unitPrice; } /** * Investment transaction commission */ public BigDecimal getCommission() { return commission; } public void setCommission(final BigDecimal commission) { this.commission = commission; } public boolean isTaxExempt() { return taxExempt; } public void setTaxExempt(boolean taxExempt) { this.taxExempt = taxExempt; } public String getSecurityType() { return securityType; } public void setSecurityType(final String securityType) { this.securityType = securityType; } public boolean isInvestmentTransaction() { return getSecurityId() != null; } public String getIncomeType() { return incomeType; } public void setIncomeType(String incomeType) { this.incomeType = incomeType; } @NotNull public BigDecimal getFees() { return fees; } public void setFees(@NotNull final BigDecimal fees) { this.fees = fees; } /** * The parser may establish a transaction type when imported. * * @return {@code TransactionType} */ @NotNull public TransactionType getTransactionType() { return transactionType; } public void setTransactionType(@NotNull final TransactionType transactionType) { this.transactionType = transactionType; } /** * OFX defines descriptive transaction types */ public String getTransactionTypeDescription() { return transactionTypeDescription; } public void setTransactionTypeDescription(final String transactionTypeDescription) { this.transactionTypeDescription = transactionTypeDescription; } /** * Standard Industry Code *

* Could be use used for automatic expense and income assignment. Typically a 4 digit numeric, but OFX allows 6 */ public String getSIC() { return SIC; } public void setSIC(String SIC) { this.SIC = SIC; } /** * Reference number that uniquely identifies the transaction. May be used in * addition to or instead of a {@link #getCheckNumber()} */ public String getRefNum() { return refNum; } public void setRefNum(String refNum) { this.refNum = refNum; } /** * Some OFX based systems will assign an ID to a Payee. *

* The ID would correspond to a Payee List identified by (not implemented) */ public String getPayeeId() { return payeeId; } public void setPayeeId(String payeeId) { this.payeeId = payeeId; } /** * The sub-account for cash transfer, typically CASH, but could be MARGIN, SHORT, or OTHER *

* , , , */ public String getSubAccount() { return subAccount; } public void setSubAccount(String subAccount) { this.subAccount = subAccount; } public String getCurrency() { return currency; } public void setCurrency(String currency) { this.currency = currency; } /** * Account for gains or losses from an investment transaction */ public Account getGainsAccount() { return gainsAccount; } public void setGainsAccount(final Account gainsAccount) { this.gainsAccount = gainsAccount; } /** * Account for investment expenses */ public Account getFeesAccount() { return feesAccount; } public void setFeesAccount(Account feesAccount) { this.feesAccount = feesAccount; } @NotNull public String getToolTip() { if (isInvestmentTransaction()) { return units.toString() + " @ " + unitPrice.toString(); } return ""; } @Override public String toString() { return getTransactionTypeDescription() + ", " + getTransactionType() + ", " + getDatePosted() + ", " + getAmount() + ", " + getFITID() + ", " + getSIC() + ", " + getPayee() + ", " + getMemo() + ", " + getCheckNumber() + ", " + getRefNum() + ", " + getPayeeId() + ", " + getCurrency(); } public String getAccountTo() { return accountTo; } public void setAccountTo(String accountTo) { this.accountTo = accountTo; } } ================================================ FILE: jgnash-convert/src/main/java/jgnash/convert/importat/ImportUtils.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.convert.importat; import java.util.Objects; import java.util.Optional; import jgnash.engine.Account; import jgnash.engine.AccountType; import jgnash.engine.CurrencyNode; import jgnash.engine.Engine; import jgnash.engine.EngineFactory; import jgnash.engine.SecurityNode; /** * Various utility methods used when importing transactions * * @author Craig Cavanaugh */ public class ImportUtils { private ImportUtils() { } public static Account getRootExpenseAccount() { final Engine engine = EngineFactory.getEngine(EngineFactory.DEFAULT); Objects.requireNonNull(engine); return searchForRootType(engine.getRootAccount(), AccountType.EXPENSE); } public static Account getRootIncomeAccount() { final Engine engine = EngineFactory.getEngine(EngineFactory.DEFAULT); Objects.requireNonNull(engine); return searchForRootType(engine.getRootAccount(), AccountType.INCOME); } /** * Matches an ImportTransaction to an Account based on an AccountTo tag if it exists * @param importTransaction Import transaction to test * @return Account if found, null otherwise */ public static Account matchAccount(final ImportTransaction importTransaction) { final Engine engine = EngineFactory.getEngine(EngineFactory.DEFAULT); Objects.requireNonNull(engine); final String number = importTransaction.getAccountTo(); Account account = null; if (number != null) { for (final Account a : engine.getAccountList()) { if (a.getAccountNumber().equals(number)) { account = a; break; } } } return account; } private static Account searchForRootType(final Account account, final AccountType accountType) { Account result = null; // search immediate top level accounts for (Account a : account.getChildren()) { if (a.getAccountType().equals(accountType)) { return a; } } // recursive search for (Account a : account.getChildren()) { result = searchForRootType(a, accountType); if (result != null) { break; } } return result; } static Optional matchSecurity(final ImportSecurity security) { final Engine engine = EngineFactory.getEngine(EngineFactory.DEFAULT); Objects.requireNonNull(engine); for (final SecurityNode securityNode : engine.getSecurities()) { if (securityNode.getSymbol().equals(security.getTicker())) { return Optional.of(securityNode); } } return Optional.empty(); } static SecurityNode createSecurityNode(final ImportSecurity security, final CurrencyNode currencyNode) { final SecurityNode securityNode = new SecurityNode(currencyNode); securityNode.setSymbol(security.getTicker()); securityNode.setScale(currencyNode.getScale()); security.getSecurityName().ifPresent(securityNode::setDescription); security.getId().ifPresent(securityNode::setISIN); return securityNode; } } ================================================ FILE: jgnash-convert/src/main/java/jgnash/convert/importat/ofx/OfxBank.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.convert.importat.ofx; import java.math.BigDecimal; import java.time.LocalDate; import jgnash.convert.importat.ImportBank; import jgnash.convert.importat.ImportSecurity; import jgnash.convert.importat.ImportTransaction; import jgnash.util.Nullable; /** * OFX Bank Object * * @author Craig Cavanaugh * @author Nicolas Bouillon */ public class OfxBank extends ImportBank { public String currency; String bankId; /** * Branch identifier. May be required for some non-US banks */ String branchId; public String accountId; String accountType; LocalDate dateStart; LocalDate dateEnd; BigDecimal ledgerBalance; LocalDate ledgerBalanceDate; BigDecimal availBalance; LocalDate availBalanceDate; int statusCode; String statusSeverity; @Nullable String statusMessage; @Override public String toString() { StringBuilder b = new StringBuilder(); b.append("statusCode: ").append(statusCode).append('\n'); b.append("statusSeverity: ").append(statusSeverity).append('\n'); b.append("statusMessage: ").append(statusMessage).append('\n'); b.append("currency: ").append(currency).append('\n'); b.append("bankId: ").append(bankId).append('\n'); b.append("branchId: ").append(branchId).append('\n'); b.append("accountId: ").append(accountId).append('\n'); b.append("accountType: ").append(accountType).append('\n'); b.append("dateStart: ").append(dateStart).append('\n'); b.append("dateEnd: ").append(dateEnd).append('\n'); b.append("ledgerBalance: ").append(ledgerBalance).append('\n'); b.append("ledgerBalanceDate: ").append(ledgerBalanceDate).append('\n'); if (availBalance != null) { b.append("availBalance: ").append(availBalance).append('\n'); } if (availBalanceDate != null) { b.append("availBalanceDate: ").append(availBalanceDate).append('\n'); } for (final ImportTransaction t : getTransactions()) { b.append(t).append('\n'); } for (final ImportSecurity importSecurity : securityList) { b.append(importSecurity.toString()).append('\n'); } return b.toString(); } /* USD 074914229 10076164 CHECKING */ } ================================================ FILE: jgnash-convert/src/main/java/jgnash/convert/importat/ofx/OfxImport.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.convert.importat.ofx; import java.math.BigDecimal; import java.time.LocalDate; import java.util.ArrayList; import java.util.List; import java.util.Objects; import jgnash.convert.common.OfxTags; import jgnash.convert.importat.ImportSecurity; import jgnash.convert.importat.ImportState; import jgnash.convert.importat.ImportTransaction; import jgnash.engine.Account; import jgnash.engine.AccountGroup; import jgnash.engine.CurrencyNode; import jgnash.engine.Engine; import jgnash.engine.EngineFactory; import jgnash.engine.InvestmentTransaction; import jgnash.engine.SecurityNode; import jgnash.engine.Transaction; import jgnash.engine.TransactionEntry; import jgnash.engine.TransactionFactory; import jgnash.engine.TransactionTag; /** * OfxImport utility methods * * @author Craig Cavanaugh */ public class OfxImport { /** * Private constructor, utility class */ private OfxImport() { } public static void importTransactions(final OfxBank ofxBank, final Account baseAccount) { Objects.requireNonNull(ofxBank.getTransactions()); Objects.requireNonNull(baseAccount); final Engine engine = EngineFactory.getEngine(EngineFactory.DEFAULT); Objects.requireNonNull(engine); for (final ImportTransaction tran : ofxBank.getTransactions()) { // do not import matched transactions if (tran.getState() == ImportState.NEW || tran.getState() == ImportState.NOT_EQUAL) { Transaction transaction = null; if (tran.isInvestmentTransaction()) { if (baseAccount.getAccountType().getAccountGroup() == AccountGroup.INVEST) { transaction = importInvestmentTransaction(ofxBank, tran, baseAccount); if (transaction != null) { // check and add the security node to the account if not present if (!baseAccount.containsSecurity(((InvestmentTransaction) transaction).getSecurityNode())) { engine.addAccountSecurity(((InvestmentTransaction) transaction).getInvestmentAccount(), ((InvestmentTransaction) transaction).getSecurityNode()); } } } else { // Signal an error System.out.println("Base account was not an investment account type"); } } else { if (baseAccount.equals(tran.getAccount())) { // single entry oTran transaction = TransactionFactory.generateSingleEntryTransaction(baseAccount, tran.getAmount(), tran.getDatePosted(), tran.getMemo(), tran.getPayee(), tran.getCheckNumber()); } else { // double entry if (tran.getAmount().signum() >= 0) { transaction = TransactionFactory.generateDoubleEntryTransaction(baseAccount, tran.getAccount(), tran.getAmount().abs(), tran.getDatePosted(), tran.getMemo(), tran.getPayee(), tran.getCheckNumber()); } else { transaction = TransactionFactory.generateDoubleEntryTransaction(tran.getAccount(), baseAccount, tran.getAmount().abs(), tran.getDatePosted(), tran.getMemo(), tran.getPayee(), tran.getCheckNumber()); } } } // add the new transaction if (transaction != null) { transaction.setFitid(tran.getFITID()); engine.addTransaction(transaction); } } } } private static InvestmentTransaction importInvestmentTransaction(final OfxBank ofxBank, final ImportTransaction ofxTransaction, final Account investmentAccount) { final SecurityNode securityNode = matchSecurity(ofxBank, ofxTransaction.getSecurityId()); final String memo = ofxTransaction.getMemo(); final LocalDate datePosted = ofxTransaction.getDatePosted(); final BigDecimal units = ofxTransaction.getUnits(); final BigDecimal unitPrice = ofxTransaction.getUnitPrice(); final Account incomeAccount = ofxTransaction.getGainsAccount(); final Account fessAccount = ofxTransaction.getFeesAccount(); Account cashAccount = ofxTransaction.getAccount(); // force use of cash balance if (OfxTags.CASH.equals(ofxTransaction.getSubAccount())) { cashAccount = investmentAccount; } final List fees = new ArrayList<>(); final List gains = new ArrayList<>(); if (ofxTransaction.getCommission().compareTo(BigDecimal.ZERO) != 0) { final TransactionEntry transactionEntry = new TransactionEntry(fessAccount, ofxTransaction.getCommission().negate()); transactionEntry.setTransactionTag(TransactionTag.INVESTMENT_FEE); fees.add(transactionEntry); } if (ofxTransaction.getFees().compareTo(BigDecimal.ZERO) != 0) { final TransactionEntry transactionEntry = new TransactionEntry(fessAccount, ofxTransaction.getFees().negate()); transactionEntry.setTransactionTag(TransactionTag.INVESTMENT_FEE); fees.add(transactionEntry); } InvestmentTransaction transaction = null; if (securityNode != null) { switch (ofxTransaction.getTransactionType()) { case DIVIDEND: final BigDecimal dividend = ofxTransaction.getAmount(); transaction = TransactionFactory.generateDividendXTransaction(incomeAccount, investmentAccount, cashAccount, securityNode, dividend, dividend, dividend, datePosted, memo); break; case REINVESTDIV: // Create a gains entry of an account other than the investment account has been selected if (incomeAccount != investmentAccount) { final TransactionEntry gainsEntry = TransactionFactory.createTransactionEntry(incomeAccount, investmentAccount, ofxTransaction.getAmount().negate(), memo, TransactionTag.GAIN_LOSS); gains.add(gainsEntry); } transaction = TransactionFactory.generateReinvestDividendXTransaction(investmentAccount, securityNode, unitPrice, units, datePosted, memo, fees, gains); break; case BUYSHARE: transaction = TransactionFactory.generateBuyXTransaction(cashAccount, investmentAccount, securityNode, unitPrice, units, BigDecimal.ONE, datePosted, memo, fees); break; case SELLSHARE: transaction = TransactionFactory.generateSellXTransaction(cashAccount, investmentAccount, securityNode, unitPrice, units, BigDecimal.ONE, datePosted, memo, fees, gains); break; default: } } return transaction; } private static SecurityNode matchSecurity(final OfxBank ofxBank, final String securityId) { SecurityNode securityNode = null; for (final ImportSecurity importSecurity : ofxBank.getSecurityList()) { if (importSecurity.getId().isPresent()) { if (importSecurity.getId().get().equals(securityId)) { securityNode = importSecurity.getSecurityNode(); break; } } } return securityNode; } public static Account matchAccount(final OfxBank bank) { final Engine engine = EngineFactory.getEngine(EngineFactory.DEFAULT); Objects.requireNonNull(engine); final String number = bank.accountId; final CurrencyNode node = engine.getCurrency(bank.currency); Account account = null; if (node != null) { for (Account a : engine.getAccountList()) { if (a.getAccountNumber() != null && a.getAccountNumber().equals(number) && a.getCurrencyNode().equals(node)) { account = a; break; } } } else if (number != null) { for (Account a : engine.getAccountList()) { if (a.getAccountNumber().equals(number)) { account = a; break; } } } return account; } } ================================================ FILE: jgnash-convert/src/main/java/jgnash/convert/importat/ofx/OfxV1ToV2.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.convert.importat.ofx; import java.io.BufferedInputStream; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.Collection; import java.util.List; import java.util.logging.Level; import java.util.logging.Logger; import jgnash.util.FileMagic; import static jgnash.util.LogUtil.logSevere; import static jgnash.convert.importat.ofx.Sanitize.sanitize; /** * Utility class to convert OFX version 1 (SGML) to OFX version 2 (XML) * * @author Craig Cavanaugh */ class OfxV1ToV2 { private static final int READ_AHEAD_LIMIT = 2048; /* public static void main(final String[] args) { if (args.length == 2) { Path input = Paths.get(args[0]); Path output = Paths.get(args[1]); if (Files.exists(input)) { try { Files.write(output, convertToXML(input).getBytes()); } catch (final IOException e) { e.printStackTrace(); } } } }*/ static String convertToXML(final Path path) { String encoding = FileMagic.getOfxV1Encoding(path); Logger.getLogger(OfxV1ToV2.class.getName()).log(Level.INFO, "OFX Version 1 file encoding was {0}", encoding); return convertSgmlToXML(readFile(path, encoding)); } static String convertToXML(final InputStream stream) { return convertSgmlToXML(readFile(stream, System.getProperty("file.encoding"))); } private static String convertSgmlToXML(final String sgml) { StringBuilder xml = new StringBuilder(sgml); int readPos = 0; int tagEnd = 0; while (readPos < xml.length() && readPos != -1) { String tag; readPos = xml.indexOf("<", tagEnd); if (readPos != -1) { tagEnd = xml.indexOf(">", readPos); if (tagEnd != -1) { tag = xml.substring(readPos + 1, tagEnd); if (!tag.startsWith("/")) { if (xml.indexOf("", tagEnd) == -1) { readPos = xml.indexOf("<", tagEnd); xml.insert(readPos, ""); } } } else { readPos = -1; } } } return sanitize(xml.toString()); } private static String concat(final Collection strings) { StringBuilder b = new StringBuilder(); for (String s : strings) { b.append(s.trim()); } return b.toString(); } /** * Munch through the header one character at a time. Do not assume clean * formatting or EOL characters. * * @param reader {@code BufferedReader} * @throws IOException thrown if IO error occurs */ private static void consumeHeader(final BufferedReader reader) throws IOException { Logger logger = Logger.getLogger(OfxV1ToV2.class.getName()); while (true) { reader.mark(READ_AHEAD_LIMIT); int character = reader.read(); if (character >= 0) { if (character == '<') { reader.reset(); logger.info("readHeader() Complete"); break; } } else { break; } } } /** * Reads a file, strips the header and reads the SGML content into one large * string * * @param stream input stream * @param characterSet assumed character set for the file being converted * @return a String with the SGML content and header removed */ private static String readFile(final InputStream stream, final String characterSet) { if (stream == null) { logSevere(OfxV1ToV2.class, "InputStream was null"); return null; } List strings = new ArrayList<>(); try (BufferedReader reader = new BufferedReader(new InputStreamReader(stream, characterSet))) { // consume the Ofx1 header consumeHeader(reader); String line = reader.readLine(); while (line != null) { line = line.trim(); if (!line.isEmpty()) { strings.add(line); } line = reader.readLine(); } } catch (final IOException e) { logSevere(OfxV1ToV2.class, e); } return concat(strings); } private static String readFile(final Path path, final String characterSet) { try (final InputStream stream = new BufferedInputStream(Files.newInputStream(path))) { return readFile(stream, characterSet); } catch (final IOException e) { logSevere(OfxV1ToV2.class, e); return ""; } } private OfxV1ToV2() { } } ================================================ FILE: jgnash-convert/src/main/java/jgnash/convert/importat/ofx/OfxV2Parser.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.convert.importat.ofx; import java.io.BufferedInputStream; import java.io.BufferedReader; import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.io.UnsupportedEncodingException; import java.math.BigDecimal; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.text.DecimalFormat; import java.text.NumberFormat; import java.text.ParseException; import java.time.LocalDate; import java.util.Iterator; import java.util.List; import java.util.Locale; import java.util.logging.FileHandler; import java.util.logging.Handler; import java.util.logging.Level; import java.util.logging.Logger; import java.util.logging.SimpleFormatter; import java.util.regex.Pattern; import java.util.stream.Collectors; import javax.xml.namespace.QName; import javax.xml.stream.XMLInputFactory; import javax.xml.stream.XMLStreamConstants; import javax.xml.stream.XMLStreamException; import javax.xml.stream.XMLStreamReader; import jgnash.convert.common.OfxTags; import jgnash.convert.importat.ImportSecurity; import jgnash.convert.importat.ImportTransaction; import jgnash.engine.TransactionType; import jgnash.resource.util.ResourceUtils; import jgnash.util.FileMagic; import jgnash.util.NotNull; import static jgnash.convert.importat.ofx.Sanitize.sanitize; /** * StAX based parser for 2.x OFX (XML) files. *

* This parser will intentionally absorb higher level elements and drop through to simplify and reduce code. * * @author Craig Cavanaugh */ public class OfxV2Parser implements OfxTags { private static final Logger logger = Logger.getLogger("OfxV2Parser"); private static final String EXTRA_SPACE_REGEX = "\\s+"; private static final String ENCODING = StandardCharsets.UTF_8.name(); private OfxBank bank; /** * Default language is assumed to be English unless the import file defines it */ private String language = "ENG"; private int statusCode; private String statusSeverity; /** * Status message from sign-on process */ private String statusMessage; private final Pattern extraSpaceRegex = Pattern.compile(EXTRA_SPACE_REGEX); /** * Support class */ private static class AccountInfo { String bankId; String accountId; String accountType; String branchId; } static void enableDetailedLogFile() { try { final Handler fh = new FileHandler("%t/jgnash-ofx.log", false); fh.setFormatter(new SimpleFormatter()); logger.addHandler(fh); logger.setLevel(Level.ALL); } catch (final IOException ioe) { logger.severe(ResourceUtils.getString("Message.Error.LogFileHandler")); } } public static OfxBank parse(@NotNull final Path file) throws Exception { final OfxV2Parser parser = new OfxV2Parser(); if (FileMagic.isOfxV1(file)) { logger.info("Parsing OFX Version 1 file"); parser.parse(OfxV1ToV2.convertToXML(file), FileMagic.getOfxV1Encoding(file)); } else if (FileMagic.isOfxV2(file)) { logger.info("Parsing OFX Version 2 file"); parser.parseFile(file); } else { logger.info("Unknown OFX Version"); } if (parser.getBank() == null) { throw new Exception("Bank import failed"); } return postProcess(parser.getBank()); } /** * Post processes the OFX transactions for import. Income transactions with a reinvestment transaction will be * stripped out. jGnash has a reinvested dividend transaction that reduces overall transaction count. * * @param ofxBank OfxBank to process * @return OfxBank with post processed transactions */ private static OfxBank postProcess(final OfxBank ofxBank) { // Clone the original list final List importTransactions = ofxBank.getTransactions(); // Create a list of Reinvested dividends final List reinvestedDividends = importTransactions.stream() .filter(importTransaction -> importTransaction.getTransactionType() == TransactionType.REINVESTDIV) .collect(Collectors.toList()); // Search through the list and remove matching income transactions for (final ImportTransaction reinvestDividend : reinvestedDividends) { final Iterator iterator = importTransactions.iterator(); while (iterator.hasNext()) { final ImportTransaction otherTran = iterator.next(); // if this was OFX income and the securities match and the amount match, remove the transaction if (reinvestDividend != otherTran && OfxTags.INCOME.equals(otherTran.getTransactionTypeDescription())) { if (otherTran.getAmount().compareTo(reinvestDividend.getAmount().abs()) == 0) { if (otherTran.getSecurityId().equals(reinvestDividend.getSecurityId())) { iterator.remove(); // remove it // reverse sign reinvestDividend.setAmount(reinvestDividend.getAmount().abs()); } } } } } return ofxBank; } /** * Parse a date. Time zone and seconds are ignored *

* YYYYMMDDHHMMSS.XXX [gmt offset:tz name] * * @param date String form of the date * @return parsed date */ private static LocalDate parseDate(final String date) { int year = Integer.parseInt(date.substring(0, 4)); // year int month = Integer.parseInt(date.substring(4, 6)); // month int day = Integer.parseInt(date.substring(6, 8)); // day return LocalDate.of(year, month, day); } private static BigDecimal parseAmount(final String amount) { /* Must trim the amount for a clean parse * Some banks leave extra spaces before the value */ try { return new BigDecimal(amount.trim()); } catch (final NumberFormatException e) { if (amount.contains(",")) { // OFX file in not valid and uses commas for decimal separators // Use the French locale as it uses commas for decimal separators DecimalFormat df = (DecimalFormat) NumberFormat.getInstance(Locale.FRENCH); df.setParseBigDecimal(true); // force return value of BigDecimal try { return (BigDecimal) df.parseObject(amount.trim()); } catch (final ParseException pe) { logger.log(Level.INFO, "Parse amount was: {0}", amount); logger.log(Level.SEVERE, e.getLocalizedMessage(), pe); } } return BigDecimal.ZERO; // give up at this point } } private static boolean parseBoolean(final String bool) { return !bool.isEmpty() && bool.startsWith("T"); } /** * Parses an InputStream and assumes UTF-8 encoding *

* Illegal characters are corrected automatically if found * * @param stream InputStream to parse */ public void parse(final InputStream stream) { final StringBuilder stringBuilder = new StringBuilder(); try (final BufferedReader reader = new BufferedReader(new InputStreamReader(stream, ENCODING))) { String line; while ((line = reader.readLine()) != null) { stringBuilder.append(line); } parse(sanitize(stringBuilder.toString()), ENCODING); } catch (final IOException e) { logger.log(Level.SEVERE, e.toString(), e); } } /** * Parses an InputStream using a specified encoding * * @param stream InputStream to parse * @param encoding encoding to use */ private void parse(final InputStream stream, final String encoding) { logger.entering(OfxV2Parser.class.getName(), "parse"); bank = new OfxBank(); final XMLInputFactory inputFactory = XMLInputFactory.newInstance(); inputFactory.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false); inputFactory.setProperty(XMLInputFactory.SUPPORT_DTD, false); try (final InputStream input = new BufferedInputStream(stream)) { XMLStreamReader reader = inputFactory.createXMLStreamReader(input, encoding); readOfx(reader); } catch (IOException | XMLStreamException e) { logger.log(Level.SEVERE, e.toString(), e); } logger.exiting(OfxV2Parser.class.getName(), "parse"); } private void parseFile(final Path path) { try (final InputStream stream = new BufferedInputStream(Files.newInputStream(path))) { parse(stream); } catch (final IOException e) { logger.log(Level.SEVERE, e.toString(), e); } } public void parse(final String string, final String encoding) throws UnsupportedEncodingException { parse(new ByteArrayInputStream(string.getBytes(encoding)), encoding); } private void readOfx(final XMLStreamReader reader) throws XMLStreamException { logger.entering(OfxV2Parser.class.getName(), "readOfx"); while (reader.hasNext()) { final int event = reader.next(); if (event == XMLStreamConstants.START_ELEMENT) { switch (reader.getLocalName()) { case OFX: // consume the OFX header here break; case SIGNONMSGSRSV1: parseSignOnMessageSet(reader); break; case BANKMSGSRSV1: parseBankMessageSet(reader); break; case CREDITCARDMSGSRSV1: parseCreditCardMessageSet(reader); break; case INVSTMTMSGSRSV1: parseInvestmentAccountMessageSet(reader); break; case SECLISTMSGSRSV1: parseSecuritesMessageSet(reader); break; default: logger.log(Level.WARNING, "Unknown message set {0}", reader.getLocalName()); break; } } } logger.exiting(OfxV2Parser.class.getName(), "readOfx"); } private void parseInvestmentAccountMessageSet(final XMLStreamReader reader) throws XMLStreamException { logger.entering(OfxV2Parser.class.getName(), "parseInvestmentAccountMessageSet"); final QName parsingElement = reader.getName(); parse: while (reader.hasNext()) { final int event = reader.next(); switch (event) { case XMLStreamConstants.START_ELEMENT: switch (reader.getLocalName()) { case STATUS: parseStatementStatus(reader); break; case CURDEF: bank.currency = reader.getElementText(); break; case INVACCTFROM: parseAccountInfo(bank, parseAccountInfo(reader)); break; case INVTRANLIST: parseInvestmentTransactionList(reader); break; case INVSTMTRS: // consume the statement download break; case INVPOSLIST: // consume the securities position list; TODO: Use for reconcile case INV401KBAL: // consume 401k balance aggregate case INV401K: // consume 401k account info aggregate case INVBAL: // consume the investment account balance; TODO: Use for reconcile case INVOOLIST: // consume open orders consumeElement(reader); break; case INVSTMTTRNRS: case TRNUID: case DTASOF: // statement date break; default: logger.log(Level.WARNING, "Unknown INVSTMTMSGSRSV1 element: {0}", reader.getLocalName()); break; } break; case XMLStreamConstants.END_ELEMENT: if (reader.getName().equals(parsingElement)) { logger.fine("Found the end of the investment account message set aggregate"); break parse; } default: } } logger.exiting(OfxV2Parser.class.getName(), "parseInvestmentAccountMessageSet"); } /** * Parses a BANKMSGSRSV1 element * * @param reader shared XMLStreamReader * @throws XMLStreamException XML parsing error has occurred */ private void parseBankMessageSet(final XMLStreamReader reader) throws XMLStreamException { logger.entering(OfxV2Parser.class.getName(), "parseBankMessageSet"); final QName parsingElement = reader.getName(); while (reader.hasNext()) { final int event = reader.next(); switch (event) { case XMLStreamConstants.START_ELEMENT: switch (reader.getLocalName()) { case STATUS: parseStatementStatus(reader); break; case CURDEF: bank.currency = reader.getElementText(); break; case LEDGERBAL: parseLedgerBalance(reader); break; case AVAILBAL: parseAvailableBalance(reader); break; case BANKACCTFROM: parseAccountInfo(bank, parseAccountInfo(reader)); break; case BANKTRANLIST: parseBankTransactionList(reader); break; case STMTTRNRS: // consume it case TRNUID: case STMTRS: break; default: logger.log(Level.WARNING, "Unknown BANKMSGSRSV1 element: {0}", reader.getLocalName()); break; } break; case XMLStreamConstants.END_ELEMENT: if (reader.getName().equals(parsingElement)) { logger.fine("Found the end of the bank message set aggregate"); break; } default: } } logger.exiting(OfxV2Parser.class.getName(), "parseBankMessageSet"); } /** * Parses a CREDITCARDMSGSRSV1 element * * @param reader shared XMLStreamReader * @throws XMLStreamException XML parsing error has occurred */ private void parseCreditCardMessageSet(final XMLStreamReader reader) throws XMLStreamException { logger.entering(OfxV2Parser.class.getName(), "parseCreditCardMessageSet"); final QName parsingElement = reader.getName(); while (reader.hasNext()) { final int event = reader.next(); switch (event) { case XMLStreamConstants.START_ELEMENT: switch (reader.getLocalName()) { case STATUS: parseStatementStatus(reader); break; case CURDEF: bank.currency = reader.getElementText(); break; case LEDGERBAL: parseLedgerBalance(reader); break; case AVAILBAL: parseAvailableBalance(reader); break; case CCACCTFROM: parseAccountInfo(bank, parseAccountInfo(reader)); break; case BANKTRANLIST: parseBankTransactionList(reader); break; default: logger.log(Level.WARNING, "Unknown CREDITCARDMSGSRSV1 element: {0}", reader.getLocalName()); break; } break; case XMLStreamConstants.END_ELEMENT: if (reader.getName().equals(parsingElement)) { logger.fine("Found the end of the credit card message set aggregate"); break; } default: } } logger.exiting(OfxV2Parser.class.getName(), "parseCreditCardMessageSet"); } /** * Parses a SECLISTMSGSRSV1 element * * @param reader shared XMLStreamReader * @throws XMLStreamException XML parsing error has occurred */ private void parseSecuritesMessageSet(final XMLStreamReader reader) throws XMLStreamException { logger.entering(OfxV2Parser.class.getName(), "parseSecuritesMessageSet"); final QName parsingElement = reader.getName(); while (reader.hasNext()) { final int event = reader.next(); switch (event) { case XMLStreamConstants.START_ELEMENT: if (SECLIST.equals(reader.getLocalName())) { parseSecuritiesList(reader); } else { logger.log(Level.WARNING, "Unknown SECLISTMSGSRSV1 element: {0}", reader.getLocalName()); } break; case XMLStreamConstants.END_ELEMENT: if (reader.getName().equals(parsingElement)) { logger.fine("Found the end of the sercurites set"); break; } default: } } logger.exiting(OfxV2Parser.class.getName(), "parseSecuritesMessageSet"); } private void parseSecuritiesList(final XMLStreamReader reader) throws XMLStreamException { logger.entering(OfxV2Parser.class.getName(), "parseSecuritiesList"); final QName parsingElement = reader.getName(); parse: while (reader.hasNext()) { final int event = reader.next(); switch (event) { case XMLStreamConstants.START_ELEMENT: switch (reader.getLocalName()) { case ASSETCLASS: case OPTTYPE: case STRIKEPRICE: case DTEXPIRE: case SHPERCTRCT: case YIELD: break; // just consume it, not used case SECID: // underlying stock for an Option. Not used, so consume it here case UNIQUEID: case UNIQUEIDTYPE: break; case SECINFO: parseSecurity(reader); break; case MFINFO: // just consume it case OPTINFO: case STOCKINFO: break; default: logger.log(Level.WARNING, "Unknown SECLIST element: {0}", reader.getLocalName()); break; } break; case XMLStreamConstants.END_ELEMENT: if (reader.getName().equals(parsingElement)) { logger.fine("Found the end of the securities list"); break parse; } default: } } logger.exiting(OfxV2Parser.class.getName(), "parseSecuritiesList"); } private void parseSecurity(final XMLStreamReader reader) throws XMLStreamException { logger.entering(OfxV2Parser.class.getName(), "parseSecurity"); final QName parsingElement = reader.getName(); final ImportSecurity importSecurity = new ImportSecurity(); parse: while (reader.hasNext()) { final int event = reader.next(); switch (event) { case XMLStreamConstants.START_ELEMENT: switch (reader.getLocalName()) { case FIID: case SECID: // consume it: break; case UNIQUEIDTYPE: importSecurity.idType = reader.getElementText().trim(); break; case UNIQUEID: importSecurity.setId(reader.getElementText().trim()); break; case SECNAME: importSecurity.setSecurityName(reader.getElementText().trim()); break; case TICKER: importSecurity.setTicker(reader.getElementText().trim()); break; case UNITPRICE: importSecurity.setUnitPrice(parseAmount(reader.getElementText())); break; case DTASOF: importSecurity.setLocalDate(parseDate(reader.getElementText())); break; case CURRENCY: // consume the currency aggregate for unit price and handle here case ORIGCURRENCY: break; case RATING: // consume, not used break; case CURSYM: importSecurity.setCurrency(reader.getElementText().trim()); break; case CURRATE: importSecurity.setCurrencyRate(parseAmount(reader.getElementText())); break; default: logger.log(Level.WARNING, "Unknown SECINFO element: {0}", reader.getLocalName()); break; } break; case XMLStreamConstants.END_ELEMENT: if (reader.getName().equals(parsingElement)) { logger.fine("Found the end of the security info"); break parse; } default: } } bank.addSecurity(importSecurity); logger.exiting(OfxV2Parser.class.getName(), "parseSecurity"); } /** * Parses a BANKTRANLIST element * * @param reader shared XMLStreamReader * @throws XMLStreamException XML parsing error has occurred */ private void parseBankTransactionList(final XMLStreamReader reader) throws XMLStreamException { logger.entering(OfxV2Parser.class.getName(), "parseBankTransactionList"); final QName parsingElement = reader.getName(); parse: while (reader.hasNext()) { final int event = reader.next(); switch (event) { case XMLStreamConstants.START_ELEMENT: switch (reader.getLocalName()) { case DTSTART: bank.dateStart = parseDate(reader.getElementText()); break; case DTEND: bank.dateEnd = parseDate(reader.getElementText()); break; case STMTTRN: parseBankTransaction(reader); break; default: logger.log(Level.WARNING, "Unknown BANKTRANLIST element: {0}", reader.getLocalName()); break; } break; case XMLStreamConstants.END_ELEMENT: if (reader.getName().equals(parsingElement)) { logger.fine("Found the end of the bank transaction list"); break parse; } default: } } logger.exiting(OfxV2Parser.class.getName(), "parseBankTransactionList"); } private void parseInvestmentTransactionList(final XMLStreamReader reader) throws XMLStreamException { logger.entering(OfxV2Parser.class.getName(), "parseInvestmentTransactionList"); final QName parsingElement = reader.getName(); parse: while (reader.hasNext()) { final int event = reader.next(); switch (event) { case XMLStreamConstants.START_ELEMENT: switch (reader.getLocalName()) { case DTSTART: bank.dateStart = parseDate(reader.getElementText()); break; case DTEND: bank.dateEnd = parseDate(reader.getElementText()); break; case BUYSTOCK: case BUYMF: case BUYOTHER: case INCOME: case REINVEST: case SELLSTOCK: parseInvestmentTransaction(reader); break; case INVBANKTRAN: parseBankTransaction(reader); break; default: logger.log(Level.WARNING, "Unknown INVTRANLIST element: {0}", reader.getLocalName()); break; } break; case XMLStreamConstants.END_ELEMENT: if (reader.getName().equals(parsingElement)) { logger.fine("Found the end of the investment transaction list"); break parse; } default: } } logger.exiting(OfxV2Parser.class.getName(), "parseInvestmentTransactionList"); } private void parseInvestmentTransaction(final XMLStreamReader reader) throws XMLStreamException { logger.entering(OfxV2Parser.class.getName(), "parseInvestmentTransaction"); final QName parsingElement = reader.getName(); final ImportTransaction tran = new ImportTransaction(); // set the descriptive transaction type text as well tran.setTransactionTypeDescription(parsingElement.toString()); // extract the investment transaction type from the element name switch (parsingElement.toString()) { case BUYMF: case BUYOTHER: case BUYSTOCK: tran.setTransactionType(TransactionType.BUYSHARE); break; case SELLMF: case SELLOTHER: case SELLSTOCK: tran.setTransactionType(TransactionType.SELLSHARE); break; case INCOME: // dividend tran.setTransactionType(TransactionType.DIVIDEND); break; case REINVEST: tran.setTransactionType(TransactionType.REINVESTDIV); break; default: logger.log(Level.WARNING, "Unknown investment transaction type: {0}", parsingElement.toString()); break; } parse: while (reader.hasNext()) { final int event = reader.next(); switch (event) { case XMLStreamConstants.START_ELEMENT: switch (reader.getLocalName()) { case DTSETTLE: tran.setDatePosted(parseDate(reader.getElementText())); break; case DTTRADE: tran.setDateUser(parseDate(reader.getElementText())); break; case TOTAL: // total of the investment transaction tran.setAmount(parseAmount(reader.getElementText())); break; case FITID: tran.setFITID(reader.getElementText()); break; case UNIQUEID: // the security for the transaction tran.setSecurityId(reader.getElementText()); break; case UNIQUEIDTYPE: // the type of security for the transaction tran.setSecurityType(reader.getElementText()); break; case UNITS: tran.setUnits(parseAmount(reader.getElementText())); break; case UNITPRICE: tran.setUnitPrice(parseAmount(reader.getElementText())); break; case FEES: // investment fees tran.setFees(parseAmount(reader.getElementText())); break; case COMMISSION: // investment commission tran.setCommission(parseAmount(reader.getElementText())); break; case INCOMETYPE: tran.setIncomeType(reader.getElementText()); break; case SUBACCTSEC: case SUBACCTFROM: case SUBACCTTO: case SUBACCTFUND: tran.setSubAccount(reader.getElementText()); break; case CHECKNUM: tran.setCheckNumber(reader.getElementText()); break; case NAME: case PAYEE: // either PAYEE or NAME will be used tran.setPayee(removeExtraWhiteSpace(reader.getElementText())); break; case MEMO: tran.setMemo(removeExtraWhiteSpace(reader.getElementText())); break; case CATEGORY: // Chase bank mucking up the OFX standard break; case SIC: tran.setSIC(reader.getElementText()); break; case REFNUM: tran.setRefNum(reader.getElementText()); break; case PAYEEID: tran.setPayeeId(removeExtraWhiteSpace(reader.getElementText())); break; case CURRENCY: // currency used for the transaction //tran.currency = reader.getElementText(); consumeElement(reader); break; case ORIGCURRENCY: tran.setCurrency(reader.getElementText()); break; case TAXEXEMPT: tran.setTaxExempt(parseBoolean(reader.getElementText())); break; case BUYTYPE: case INVBUY: // consume case INVTRAN: case SECID: break; case LOANID: // consume 401k loan information case LOANPRINCIPAL: case LOANINTEREST: break; case INV401KSOURCE: // consume 401k information case DTPAYROLL: case PRIORYEARCONTRIB: break; default: logger.log(Level.WARNING, "Unknown investment transaction element: {0}", reader.getLocalName()); break; } break; case XMLStreamConstants.END_ELEMENT: if (reader.getName().equals(parsingElement)) { //logger.fine("Found the end of the investment transaction"); break parse; } default: } } bank.addTransaction(tran); logger.exiting(OfxV2Parser.class.getName(), "parseInvestmentTransaction"); } private String removeExtraWhiteSpace(final String string) { return extraSpaceRegex.matcher(string).replaceAll(" ").trim(); } /** * Parses a STMTTRN element * * @param reader shared XMLStreamReader * @throws XMLStreamException XML parsing error has occurred */ private void parseBankTransaction(final XMLStreamReader reader) throws XMLStreamException { logger.entering(OfxV2Parser.class.getName(), "parseBankTransaction"); final QName parsingElement = reader.getName(); final ImportTransaction tran = new ImportTransaction(); parse: while (reader.hasNext()) { final int event = reader.next(); switch (event) { case XMLStreamConstants.START_ELEMENT: switch (reader.getLocalName()) { case TRNTYPE: tran.setTransactionTypeDescription(reader.getElementText()); break; case DTPOSTED: tran.setDatePosted(parseDate(reader.getElementText())); break; case DTUSER: tran.setDateUser(parseDate(reader.getElementText())); break; case TRNAMT: tran.setAmount(parseAmount(reader.getElementText())); break; case FITID: tran.setFITID(reader.getElementText()); break; case CHECKNUM: tran.setCheckNumber(reader.getElementText()); break; case NAME: case PAYEE: // either PAYEE or NAME will be used tran.setPayee(removeExtraWhiteSpace(reader.getElementText())); break; case MEMO: tran.setMemo(removeExtraWhiteSpace(reader.getElementText())); break; case CATEGORY: // Chase bank mucking up the OFX standard break; case SIC: tran.setSIC(reader.getElementText()); break; case REFNUM: tran.setRefNum(reader.getElementText()); break; case PAYEEID: tran.setPayeeId(removeExtraWhiteSpace(reader.getElementText())); break; case CURRENCY: case ORIGCURRENCY: tran.setCurrency(reader.getElementText()); break; case SUBACCTFUND: // transfer into / out off an investment account tran.setSubAccount(reader.getElementText()); break; case STMTTRN: // consume, occurs with an investment account transfer break; case BANKACCTTO: case CCACCTTO: case INVACCTTO: parseAccountInfo(tran, parseAccountInfo(reader)); break; default: logger.log(Level.WARNING, "Unknown STMTTRN element: {0}", reader.getLocalName()); break; } break; case XMLStreamConstants.END_ELEMENT: if (reader.getName().equals(parsingElement)) { logger.fine("Found the end of the bank transaction"); break parse; } default: } } bank.addTransaction(tran); logger.exiting(OfxV2Parser.class.getName(), "parseBankTransaction"); } private static void parseAccountInfo(final ImportTransaction importTransaction, final AccountInfo accountInfo) { importTransaction.setAccountTo(accountInfo.accountId); } /** * Parses a BANKACCTFROM element * * @param reader shared XMLStreamReader * @throws XMLStreamException XML parsing error has occurred */ private static AccountInfo parseAccountInfo(final XMLStreamReader reader) throws XMLStreamException { logger.entering(OfxV2Parser.class.getName(), "parseAccountInfo"); final QName parsingElement = reader.getName(); final AccountInfo accountInfo = new AccountInfo(); parse: while (reader.hasNext()) { final int event = reader.next(); switch (event) { case XMLStreamConstants.START_ELEMENT: switch (reader.getLocalName()) { case BANKID: case BROKERID: // normally a URL per the OFX specification accountInfo.bankId = reader.getElementText(); break; case ACCTID: accountInfo.accountId = reader.getElementText(); break; case ACCTTYPE: accountInfo.accountType = reader.getElementText(); break; case BRANCHID: accountInfo.branchId = reader.getElementText(); break; default: logger.log(Level.WARNING, "Unknown BANKACCTFROM element: {0}", reader.getLocalName()); break; } break; case XMLStreamConstants.END_ELEMENT: if (reader.getName().equals(parsingElement)) { logger.fine("Found the end of the bank and account info aggregate"); break parse; } default: } } logger.exiting(OfxV2Parser.class.getName(), "parseAccountInfo"); return accountInfo; } private static void parseAccountInfo(final OfxBank ofxBank, final AccountInfo accountInfo) { ofxBank.bankId = accountInfo.bankId; ofxBank.branchId = accountInfo.branchId; ofxBank.accountId = accountInfo.accountId; ofxBank.accountType = accountInfo.accountType; } /** * Parses a LEDGERBAL element * * @param reader shared XMLStreamReader * @throws XMLStreamException XML parsing error has occurred */ private void parseLedgerBalance(final XMLStreamReader reader) throws XMLStreamException { logger.entering(OfxV2Parser.class.getName(), "parseLedgerBalance"); final QName parsingElement = reader.getName(); parse: while (reader.hasNext()) { final int event = reader.next(); switch (event) { case XMLStreamConstants.START_ELEMENT: switch (reader.getLocalName()) { case BALAMT: bank.ledgerBalance = parseAmount(reader.getElementText()); break; case DTASOF: bank.ledgerBalanceDate = parseDate(reader.getElementText()); break; default: logger.log(Level.WARNING, "Unknown ledger balance information {0}", reader.getLocalName()); break; } break; case XMLStreamConstants.END_ELEMENT: if (reader.getName().equals(parsingElement)) { logger.fine("Found the end of the ledger balance aggregate"); break parse; } default: } } logger.exiting(OfxV2Parser.class.getName(), "parseLedgerBalance"); } /** * Parses a AVAILBAL element * * @param reader shared XMLStreamReader * @throws XMLStreamException XML parsing error has occurred */ private void parseAvailableBalance(final XMLStreamReader reader) throws XMLStreamException { logger.entering(OfxV2Parser.class.getName(), "parseAvailableBalance"); final QName parsingElement = reader.getName(); parse: while (reader.hasNext()) { final int event = reader.next(); switch (event) { case XMLStreamConstants.START_ELEMENT: switch (reader.getLocalName()) { case BALAMT: bank.availBalance = parseAmount(reader.getElementText()); break; case DTASOF: bank.availBalanceDate = parseDate(reader.getElementText()); break; default: logger.log(Level.WARNING, "Unknown AVAILBAL element {0}", reader.getLocalName()); break; } break; case XMLStreamConstants.END_ELEMENT: if (reader.getName().equals(parsingElement)) { logger.fine("Found the end of the available balance aggregate"); break parse; } default: } } logger.exiting(OfxV2Parser.class.getName(), "parseAvailableBalance"); } /** * Parses a SIGNONMSGSRSV1 element * * @param reader shared XMLStreamReader * @throws XMLStreamException XML parsing error has occurred */ private void parseSignOnMessageSet(final XMLStreamReader reader) throws XMLStreamException { logger.entering(OfxV2Parser.class.getName(), "parseSignOnMessageSet"); final QName parsingElement = reader.getName(); parse: while (reader.hasNext()) { final int event = reader.next(); switch (event) { case XMLStreamConstants.START_ELEMENT: switch (reader.getLocalName()) { case LANGUAGE: language = reader.getElementText(); break; case STATUS: parseSignOnStatus(reader); break; case FI: case FID: case ORG: case INTUBID: case INTUUSERID: default: break; } case XMLStreamConstants.END_ELEMENT: if (reader.getName().equals(parsingElement)) { logger.fine("Found the end of the sign-on message set aggregate"); break parse; } default: } } logger.exiting(OfxV2Parser.class.getName(), "parseSignOnMessageSet"); } /** * Parses a STATUS element from the SignOn element * * @param reader shared XMLStreamReader * @throws XMLStreamException XML parsing error has occurred */ private void parseSignOnStatus(final XMLStreamReader reader) throws XMLStreamException { logger.entering(OfxV2Parser.class.getName(), "parseSignOnStatus"); final QName parsingElement = reader.getName(); parse: while (reader.hasNext()) { final int event = reader.next(); switch (event) { case XMLStreamConstants.START_ELEMENT: switch (reader.getLocalName()) { case CODE: try { statusCode = Integer.parseInt(reader.getElementText()); } catch (final NumberFormatException ex) { logger.log(Level.SEVERE, ex.getLocalizedMessage(), ex); } break; case SEVERITY: statusSeverity = reader.getElementText(); break; case MESSAGE: // consume it, not used statusMessage = reader.getElementText(); break; default: logger.log(Level.WARNING, "Unknown STATUS element {0}", reader.getLocalName()); break; } break; case XMLStreamConstants.END_ELEMENT: if (reader.getName().equals(parsingElement)) { logger.fine("Found the end of the statusCode response"); break parse; } default: } } logger.exiting(OfxV2Parser.class.getName(), "parseSignOnStatus"); } /** * Parses a STATUS element from the statement element * * @param reader shared XMLStreamReader * @throws XMLStreamException XML parsing error has occurred */ private void parseStatementStatus(final XMLStreamReader reader) throws XMLStreamException { logger.entering(OfxV2Parser.class.getName(), "parseSignOnStatus"); final QName parsingElement = reader.getName(); parse: while (reader.hasNext()) { final int event = reader.next(); switch (event) { case XMLStreamConstants.START_ELEMENT: switch (reader.getLocalName()) { case CODE: try { bank.statusCode = Integer.parseInt(reader.getElementText()); } catch (final NumberFormatException ex) { logger.log(Level.SEVERE, ex.getLocalizedMessage(), ex); } break; case SEVERITY: bank.statusSeverity = reader.getElementText(); break; case MESSAGE: bank.statusMessage = reader.getElementText(); break; default: logger.log(Level.WARNING, "Unknown STATUS element {0}", reader.getLocalName()); break; } break; case XMLStreamConstants.END_ELEMENT: if (reader.getName().equals(parsingElement)) { logger.fine("Found the end of the statement status response"); break parse; } default: } } logger.exiting(OfxV2Parser.class.getName(), "parseStatementStatus"); } /** * Consumes an element that will not be used * * @param reader shared XMLStreamReader * @throws XMLStreamException XML parsing error has occurred */ private static void consumeElement(final XMLStreamReader reader) throws XMLStreamException { logger.entering(OfxV2Parser.class.getName(), "consumeElement"); final QName parsingElement = reader.getName(); parse: while (reader.hasNext()) { final int event = reader.next(); switch (event) { case XMLStreamConstants.START_ELEMENT: reader.getLocalName(); break; case XMLStreamConstants.END_ELEMENT: if (reader.getName().equals(parsingElement)) { logger.log(Level.FINEST, "Found the end of consumed element {0}", reader.getName()); break parse; } default: } } logger.exiting(OfxV2Parser.class.getName(), "consumeElement"); } public OfxBank getBank() { logger.log(Level.INFO, "OFX Status was: {0}", statusCode); logger.log(Level.INFO, "Status Level was: {0}", statusSeverity); logger.log(Level.INFO, "File language was: {0}", language); return bank; } int getStatusCode() { return statusCode; } String getStatusSeverity() { return statusSeverity; } public String getLanguage() { return language; } String getStatusMessage() { return statusMessage; } } ================================================ FILE: jgnash-convert/src/main/java/jgnash/convert/importat/ofx/Sanitize.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.convert.importat.ofx; /** * Utility class for handling OFX files with invalid characters * * @author Craig Cavanaugh */ class Sanitize { private Sanitize() { // utility class } /** * Replaces illegal XML characters with escaped characters * * @param xml String to process * @return valid string */ static String sanitize(final String xml) { String ugly = xml; // remove all XML declarations as they are not needed ugly = ugly.replaceAll("<\\?xml.*\\?>", ""); ugly = ugly.replaceAll("&(?!(?:amp);)", "&"); ugly = ugly.replaceAll("\"", """); ugly = ugly.replaceAll("'", "'"); return ugly; } } ================================================ FILE: jgnash-convert/src/main/java/jgnash/convert/importat/qif/QifAccount.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.convert.importat.qif; import java.util.List; import java.util.Objects; import jgnash.convert.importat.DateFormat; import jgnash.convert.importat.ImportBank; /** * @author Craig Cavanaugh */ public class QifAccount extends ImportBank { public String name; public String type; public String description = ""; private DateFormat dateFormat = null; @Override public List getTransactions() { if (dateFormat == null) { setDateFormat(QifTransaction.determineDateFormat(super.getTransactions())); } reparseDates(getDateFormat()); // reparse the dates before returning return super.getTransactions(); } public QifTransaction get(final int index) { return super.getTransactions().get(index); } @Override public String toString() { return "Name: " + name + '\n' + "Type: " + type + '\n' + "Description: " + description + '\n'; } public void reparseDates(final DateFormat dateFormat) { Objects.requireNonNull(dateFormat); setDateFormat(dateFormat); for (final QifTransaction transaction: super.getTransactions()) { transaction.setDatePosted(QifTransaction.parseDate(transaction.oDate, dateFormat)); } } public DateFormat getDateFormat() { if (dateFormat == null) { return DateFormat.US; } return dateFormat; } public void setDateFormat(final DateFormat dateFormat) { Objects.requireNonNull(dateFormat); this.dateFormat = dateFormat; } } ================================================ FILE: jgnash-convert/src/main/java/jgnash/convert/importat/qif/QifCategory.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.convert.importat.qif; /** * @author Craig Cavanaugh */ class QifCategory { public String name; public String description; public String type; public QifCategory() { type = "E"; } } ================================================ FILE: jgnash-convert/src/main/java/jgnash/convert/importat/qif/QifImport.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.convert.importat.qif; import java.io.File; import java.io.IOException; import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.logging.Level; import java.util.logging.Logger; import java.util.stream.Collectors; import jgnash.convert.importat.DateFormat; import jgnash.convert.importat.ImportUtils; import jgnash.engine.Account; import jgnash.engine.AccountType; import jgnash.engine.CurrencyNode; import jgnash.engine.Engine; import jgnash.engine.EngineFactory; import jgnash.engine.ReconcileManager; import jgnash.engine.ReconciledState; import jgnash.engine.Transaction; import jgnash.engine.TransactionEntry; import jgnash.engine.TransactionFactory; /** * QifImport takes a couple of simple steps to prevent importing a duplicate account. Other than that, duplicate * transactions will be imported. At this time, it would require a lot of extra and normally unused data to be stored * with each transaction and account to prevent duplication. This import utility is ideal for importing an existing data * set, but not for importing monthly bank statements. A more specialized import utility may be useful/required for more * advanced in * * @author Craig Cavanaugh */ public class QifImport { /** * Default for a QIF import */ private static final String FITID = "qif"; private QifParser parser; private final Engine engine; private final HashMap expenseMap = new HashMap<>(); private final HashMap incomeMap = new HashMap<>(); private final HashMap accountMap = new HashMap<>(); private boolean partialImport = false; private static final Logger logger = Logger.getLogger("qifimport"); public QifImport() { engine = EngineFactory.getEngine(EngineFactory.DEFAULT); } public QifParser getParser() { return parser; } public void doFullParse(final File file, final DateFormat dateFormat) throws IOException { if (file != null) { parser = new QifParser(dateFormat); parser.parseFullFile(file); logger.info("*** Parsing Complete ***"); } } public void doFullImport() { if (parser != null) { importCategories(); importAccounts(); logger.info("*** Importing Complete ***"); } } public boolean doPartialParse(final File file) { if (file != null) { partialImport = true; parser = new QifParser(DateFormat.US); return parser.parsePartialFile(file); } return false; } public void dumpStats() { if (parser != null) { parser.dumpStats(); } } private void importCategories() { logger.info("*** Importing Categories ***"); loadCategoryMap(engine.getExpenseAccountList(), expenseMap); loadCategoryMap(engine.getIncomeAccountList(), incomeMap); reduceCategories(); addCategories(); } private void importAccounts() { loadAccountMap(); addAccounts(); } private static void loadCategoryMap(final List list, final Map map) { if (list != null) { // protect against a failed load on a new account for (Account aList : list) { loadCategoryMap(aList, map); } } } private static void loadCategoryMap(final Account acc, final Map map) { String pathName = acc.getPathName(); int index = pathName.indexOf(':'); if (index != -1) { map.put(pathName.substring(index + 1), acc); } } /** * Returns a list of all accounts excluding the rootAccount and IncomeAccounts and ExpenseAccounts * * @return List of bank accounts */ private List getBankAccountList() { final List list = engine.getAccountList(); return list.stream().filter(a -> a.getAccountType() == AccountType.BANK || a.getAccountType() == AccountType.CASH).collect(Collectors.toList()); } private void loadAccountMap() { List list = getBankAccountList(); list.forEach(this::loadAccountMap); } private void loadAccountMap(final Account acc) { String pathName = acc.getPathName(); int index = pathName.indexOf(':'); if (index != -1) { accountMap.put(pathName.substring(index + 1), acc); } } /** * All of the accounts must be added before the transactions are added because the category (account) must exist * first */ private void addAccounts() { logger.info("*** Importing Accounts ***"); List list = parser.accountList; // add all of the accounts first for (QifAccount qAcc : list) { Account acc; if (!accountMap.containsKey(qAcc.name)) { // add the account if it does not exist acc = generateAccount(qAcc); if (acc != null) { engine.addAccount(engine.getRootAccount(), acc); loadAccountMap(acc); } } } logger.info("*** Importing Transactions ***"); // go back and add the transactions; for (QifAccount qAcc : list) { Account acc = accountMap.get(qAcc.name); // try and match the closest if (acc == null) { acc = engine.getAccountByName(qAcc.name); } // TODO Correct import of investment transactions if (acc != null && acc.getAccountType() != AccountType.INVEST) { addTransactions(qAcc, acc); } else { if (acc != null) { logger.severe("Investment transactions not fully supported"); } else { logger.log(Level.SEVERE, "Lost the account: {0}", qAcc.name); } } } } private void addTransactions(final QifAccount qAcc, final Account acc) { if (qAcc.getTransactions().isEmpty()) { return; } List list = qAcc.getTransactions(); for (QifTransaction aList : list) { Transaction tran; /*if (acc.getAccountType().equals(AccountType.INVEST)) { //tran = generateInvestmentTransaction(aList, acc); tran = generateTransaction(aList, acc); } else { tran = generateTransaction(aList, acc); }*/ tran = generateTransaction(aList, acc); if (tran != null) { if (partialImport) { tran.setFitid(FITID); // importing a bank statement, flag as imported } engine.addTransaction(tran); } else { logger.warning("Null Transaction!"); } } } private void addCategories() { List list = parser.categories; Map map; for (QifCategory cat : list) { Account acc = generateAccount(cat); if (acc.getAccountType() == AccountType.EXPENSE) { map = expenseMap; } else { map = incomeMap; } Account parent = findBestParent(cat, map); engine.addAccount(parent, acc); loadCategoryMap(acc, map); } } /** * Returns the Account of the best possible parent account for the supplied QifCategory * * @param cat imported QifCategory to match * @param map cached account map * @return best Account match */ private Account findBestParent(final QifCategory cat, final Map map) { int i = cat.name.lastIndexOf(':'); if (i != -1) { String pathName = cat.name.substring(0, i); while (true) { if (map.containsKey(pathName)) { return map.get(pathName); } int j = pathName.lastIndexOf(':'); if (j != -1) { pathName = pathName.substring(0, j); } else { break; } } } Account parent; if (cat.type.equals("E")) { parent = ImportUtils.getRootExpenseAccount(); } else { parent = ImportUtils.getRootIncomeAccount(); } if (parent != null) { return parent; } return engine.getRootAccount(); } /** * Returns the best matching account * * @param category QIF category * @return Best matching account */ private Account findBestAccount(final String category) { Account acc = null; // nulls can happen and don't search on an empty category if (category != null && !category.isEmpty()) { String name = category; if (isAccount(name)) { // account name = category.substring(1, category.length() - 1); logger.log(Level.FINEST, "Looking for bank account: {0}", name); acc = accountMap.get(name); } if (acc == null) { // income or expense account // strip any category tags name = QifUtils.stripCategoryTags(name); acc = expenseMap.get(name); if (acc == null) { acc = incomeMap.get(name); } } if (acc == null) { logger.log(Level.WARNING, "No account match for: {0}", name); } } return acc; } /** * Determines if the supplied String represents a QIF account * * @param category category string to validate * @return true if this is suppose to be an account */ private static boolean isAccount(final String category) { return category.startsWith("[") && category.endsWith("]"); } /* * Removes duplicate categories from a supplied list */ private void reduceCategories() { QifCategory cat; String path; List list = parser.categories; Iterator i = list.iterator(); while (i.hasNext()) { cat = i.next(); path = cat.name; if (cat.type.equals("E") && expenseMap.containsKey(path)) { i.remove(); } else if (cat.type.equals("I") && incomeMap.containsKey(path)) { i.remove(); } } } /* * Creates and returns an Account of the correct type given a QifCategory */ private Account generateAccount(final QifCategory cat) { Account account; CurrencyNode defaultCurrency = engine.getDefaultCurrency(); if (cat.type.equals("E")) { account = new Account(AccountType.EXPENSE, defaultCurrency); // account.setTaxRelated(cat.taxRelated); // account.setTaxSchedule(cat.taxSchedule); } else { account = new Account(AccountType.INCOME, defaultCurrency); // account.setTaxRelated(cat.taxRelated); // account.setTaxSchedule(cat.taxSchedule); } // trim off the leading parent account int index = cat.name.lastIndexOf(':'); if (index != -1) { account.setName(cat.name.substring(index + 1)); } else { account.setName(cat.name); } account.setDescription(cat.description); return account; } private Account generateAccount(final QifAccount acc) { Account account; CurrencyNode defaultCurrency = engine.getDefaultCurrency(); switch (acc.type) { case "Bank": account = new Account(AccountType.BANK, defaultCurrency); break; case "CCard": account = new Account(AccountType.CREDIT, defaultCurrency); break; case "Cash": account = new Account(AccountType.CASH, defaultCurrency); break; case "Invst": case "Port": account = new Account(AccountType.INVEST, defaultCurrency); break; case "Oth A": account = new Account(AccountType.ASSET, defaultCurrency); break; case "Oth L": account = new Account(AccountType.LIABILITY, defaultCurrency); break; default: logger.log(Level.SEVERE, "Could not generate an account for:\n{0}", acc.toString()); return null; } account.setName(acc.name); account.setDescription(acc.description); return account; } /** * Generates a transaction *

* Notes: If a QifTransaction does not specify an account, then assume it is a single * entry transaction for the supplied Account. The transaction most likely came from a online banking source. * * @param qTran Qif transaction to generate Transaction for * @param acc base Account * @return new Transaction */ private Transaction generateTransaction(final QifTransaction qTran, final Account acc) { Objects.requireNonNull(acc); boolean reconciled = "x".equalsIgnoreCase(qTran.status); Transaction tran; Account cAcc; if (qTran.getAccount() != null) { cAcc = qTran.getAccount(); } else { cAcc = findBestAccount(qTran.category); } if (qTran.hasSplits()) { tran = new Transaction(); // create a double entry transaction with splits List splits = qTran.splits; for (QifSplitTransaction splitTransaction : splits) { TransactionEntry split = generateSplitTransaction(splitTransaction, acc); Objects.requireNonNull(split); // should not be null, throw an exception tran.addTransactionEntry(split); } ReconcileManager.reconcileTransaction(acc, tran, reconciled ? ReconciledState.RECONCILED : ReconciledState.NOT_RECONCILED); } else if (acc == cAcc && !qTran.hasSplits() || cAcc == null) { // create single entry transaction without splits tran = TransactionFactory.generateSingleEntryTransaction(acc, qTran.getAmount(), qTran.getDatePosted(), qTran.getMemo(), qTran.getPayee(), qTran.getCheckNumber()); ReconcileManager.reconcileTransaction(acc, tran, reconciled ? ReconciledState.RECONCILED : ReconciledState.NOT_RECONCILED); } else if (!qTran.hasSplits()) { // && cAcc != null // create a double entry transaction without splits if (qTran.getAmount().signum() == -1) { tran = TransactionFactory.generateDoubleEntryTransaction(cAcc, acc, qTran.getAmount(), qTran.getDatePosted(), qTran.getMemo(), qTran.getPayee(), qTran.getCheckNumber()); } else { tran = TransactionFactory.generateDoubleEntryTransaction(acc, cAcc, qTran.getAmount(), qTran.getDatePosted(), qTran.getMemo(), qTran.getPayee(), qTran.getCheckNumber()); } ReconcileManager.reconcileTransaction(cAcc, tran, reconciled ? ReconciledState.RECONCILED : ReconciledState.NOT_RECONCILED); ReconcileManager.reconcileTransaction(acc, tran, reconciled ? ReconciledState.RECONCILED : ReconciledState.NOT_RECONCILED); if (isAccount(qTran.category)) { removeMirrorTransaction(qTran, acc); // remove the mirror transaction } } else { // could not find the account this transaction belongs to logger.log(Level.WARNING, "Could not create following transaction:" + "\n{0}", qTran.toString()); return null; } tran.setDate(qTran.getDatePosted()); tran.setPayee(qTran.getPayee()); tran.setNumber(qTran.getCheckNumber()); return tran; } private Account unassignedExpense = null; private Account unassignedIncome = null; /** * Generates a Transaction given a QifSplitTransaction * * @param qTran split qif transaction to convert * @param acc base Account * @return generated TransactionEntry */ private TransactionEntry generateSplitTransaction(final QifSplitTransaction qTran, final Account acc) { TransactionEntry tran = new TransactionEntry(); Account account = findBestAccount(qTran.category); /* Verify that the splits category is not assigned to the parent account. This is * allowed within Quicken, but violates double entry and is not allowed in jGnash. * Wipe the resulting account and default to the unassigned accounts to maintain * integrity. */ if (account == acc) { logger.warning("Detected an invalid split transactions entry, correcting problem"); account = null; } /* If a valid account is found at this point, then it should have a duplicate * entry in another account that needs to be removed */ if (account != null && isAccount(qTran.category)) { removeMirrorSplitTransaction(qTran); } if (account == null) { // unassigned split transaction.... fix it with a default if (qTran.amount.signum() == -1) { // need an expense account if (unassignedExpense == null) { unassignedExpense = new Account(AccountType.EXPENSE, engine.getDefaultCurrency()); unassignedExpense.setName("** QIF Import - Unassigned Expense Account"); unassignedExpense.setDescription("Fix transactions and delete this account"); engine.addAccount(engine.getRootAccount(), unassignedExpense); logger.info("Created an account for unassigned expense account"); } account = unassignedExpense; } else { if (unassignedIncome == null) { unassignedIncome = new Account(AccountType.INCOME, engine.getDefaultCurrency()); unassignedIncome.setName("** QIF Import - Unassigned Income Account"); unassignedIncome.setDescription("Fix transactions and delete this account"); engine.addAccount(engine.getRootAccount(), unassignedIncome); logger.info("Created an account for unassigned income account"); } account = unassignedIncome; } } if (qTran.amount.signum() == -1) { tran.setDebitAccount(acc); tran.setCreditAccount(account); } else { tran.setDebitAccount(account); tran.setCreditAccount(acc); } tran.setAmount(qTran.amount.abs()); tran.setMemo(qTran.memo); return tran; } /* Cannot check against check number and payee because Quicken allows for the * different payees at each side of the transaction and does not include the * check number on both sides. * * The date, amount, and account/category is checked to determine if the match is valid */ private void removeMirrorTransaction(final QifTransaction qTran, final Account acc) { String name = qTran.category.substring(1, qTran.category.length() - 1); List list = parser.accountList; for (QifAccount qAcc : list) { if (qAcc.name.equals(name)) { List items = qAcc.getTransactions(); Iterator i = items.iterator(); QifTransaction tran; while (i.hasNext()) { tran = i.next(); if (tran.getAmount().compareTo(qTran.getAmount().negate()) == 0 && tran.getDatePosted().equals(qTran.getDatePosted()) && tran.category.contains(acc.getName())) { i.remove(); logger.finest("Removed mirror transaction"); return; } } } } } private void removeMirrorSplitTransaction(final QifSplitTransaction qTran) { String name = qTran.category.substring(1, qTran.category.length() - 1); logger.log(Level.FINE, "Category name is: {0}", name); List list = parser.accountList; for (QifAccount qAcc : list) { if (qAcc.name.equals(name)) { List items = qAcc.getTransactions(); Iterator i = items.iterator(); QifTransaction tran; while (i.hasNext()) { tran = i.next(); if (tran != null) { if (tran.getAmount().compareTo(qTran.amount.negate()) == 0 && tran.getMemo().equals(qTran.memo)) { i.remove(); logger.finest("Removed mirror split transaction"); return; } } else { // should not occur anymore logger.log(Level.SEVERE, "There was a null QifTransaction in QifAccount: \n{0}", qAcc.toString()); } } } } // could be a split into a bank account... look a level higher // TODO add check against the base account... should point at each other.. // qTran's category same as tran category? for (QifAccount qAcc : list) { if (qAcc.name.equals(name)) { List items = qAcc.getTransactions(); Iterator i = items.iterator(); QifTransaction tran; while (i.hasNext()) { tran = i.next(); // is the match an account and the opposite value and does not have any splits? // is this a valid method? if (tran.getAmount().compareTo(qTran.amount.negate()) == 0 && isAccount(tran.category) && !tran.hasSplits()) { logger.log(Level.FINE, "Found a match:\n{0}", tran.toString()); i.remove(); return; } } } } logger.log(Level.WARNING, "Did not find matching mirror:" + "\n{0}", qTran.toString()); } } ================================================ FILE: jgnash-convert/src/main/java/jgnash/convert/importat/qif/QifParser.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.convert.importat.qif; import java.io.File; import java.io.FileNotFoundException; import java.io.IOException; import java.nio.charset.Charset; import java.nio.file.Files; import java.nio.file.Paths; import java.util.ArrayList; import java.util.Objects; import java.util.logging.Level; import java.util.logging.Logger; import jgnash.convert.importat.DateFormat; import jgnash.util.FileMagic; import jgnash.util.NotNull; /** * The QIF format seems to be very broken. Various applications and services * export it differently, some have even extended an already broken format to * add even more confusion. To make matters worse, the format has changed over * time with no indication of what "version" the QIF file is. *

* This parses through the QIF file using brute force, no fancy parser or * tricks. The QIF file is broken enough that it's easier to find problems with * the parser when it's easy to step through the code. *

* The !Option:AutoSwitch and !Clear:AutoSwitch headers do not appear to be used * correctly, even by the "creator" of the QIF file format. There seems to be * confusion as to it's purpose. The best thing to do is ignore AutoSwitch * completely and make an educated guess about the data. *

* I'm not very happy with this code, but I'm not sure there is a clean solution * to parsing QIF files * * @author Craig Cavanaugh */ public final class QifParser { private DateFormat dateFormat = DateFormat.US; final ArrayList categories = new ArrayList<>(); private final ArrayList classes = new ArrayList<>(); public final ArrayList accountList = new ArrayList<>(); private final ArrayList securities = new ArrayList<>(); private static final Logger logger = Logger.getLogger(QifParser.class.getName()); QifParser(final DateFormat dateFormat) { setDateFormat(dateFormat); } public QifAccount getBank() { return accountList.get(0); } /** * Tests if the source string starts with the prefix string. Case is * ignored. * * @param source the source String. * @param prefix the prefix String. * @return true, if the source starts with the prefix string. */ private static boolean startsWith(final String source, final String prefix) { return prefix.length() <= source.length() && source.regionMatches(true, 0, prefix, 0, prefix.length()); } private void setDateFormat(@NotNull final DateFormat dateFormat) { Objects.requireNonNull(dateFormat); this.dateFormat = dateFormat; } void parseFullFile(final File file) throws IOException { parseFullFile(file.getAbsolutePath()); } boolean parsePartialFile(final File file) { return parsePartialFile(file.getAbsolutePath()); } private void parseFullFile(final String fileName) throws IOException { boolean accountFound = true; final Charset charset = FileMagic.detectCharset(fileName); try (final QifReader in = new QifReader(Files.newBufferedReader(Paths.get(fileName), charset))) { String line = in.readLine(); while (line != null) { if (startsWith(line, "!Type:Class")) { parseClassList(in); } else if (startsWith(line, "!Type:Cat")) { parseCategoryList(in); } else if (startsWith(line, "!Account")) { parseAccount(in); } else if (startsWith(line, "!Type:Memorized")) { parseMemorizedTransactions(in); } else if (startsWith(line, "!Type:Security")) { parseSecurity(in); } else if (startsWith(line, "!Type:Prices")) { parsePrice(in); } else if (startsWith(line, "!Type:Bank")) { // QIF from an online bank statement... assumes the account is known accountFound = false; break; } else if (startsWith(line, "!Type:CCard")) { // QIF from an online credit card statement accountFound = false; break; } else if (startsWith(line, "!Type:Oth")) { // QIF from an online credit card statement accountFound = false; break; } else if (startsWith(line, "!Type:Cash")) { // Partial QIF export accountFound = false; break; } else if (startsWith(line, "!Option:AutoSwitch")) { logger.info("Consuming !Option:AutoSwitch"); } else if (startsWith(line, "!Clear:AutoSwitch")) { logger.info("Consuming !Clear:AutoSwitch"); } else { System.out.println("Error: " + line); } line = in.readLine(); } } catch (final FileNotFoundException e) { logger.log(Level.WARNING, "Could not find file: {0}", fileName); } catch (final IOException e) { logger.log(Level.SEVERE, null, e); } if (!accountFound) { throw new IOException("The account was not found"); } // re-parse the dates for (final QifAccount account : accountList) { account.reparseDates(QifTransaction.determineDateFormat(account.getTransactions())); } } private boolean parsePartialFile(final String fileName) { final Charset charset = FileMagic.detectCharset(fileName); try (QifReader in = new QifReader(Files.newBufferedReader(Paths.get(fileName), charset))) { String peek = in.peekLine(); if (startsWith(peek, "!Type:")) { final QifAccount acc = new QifAccount(); // "unknown" holding account if (parseAccountTransactions(in, acc)) { accountList.add(acc); logger.finest("*** Added account ***"); // re-parse the dates acc.reparseDates(QifTransaction.determineDateFormat(acc.getTransactions())); return true; // only look for transactions for one account } System.err.println("parseAccountTransactions: error"); } } catch (final FileNotFoundException fne) { logger.log(Level.WARNING, "Could not find file: {0}", fileName); } catch (final IOException ioe) { logger.log(Level.SEVERE, null, ioe); } return false; } private void parseAccount(final QifReader in) throws IOException { logger.entering(this.getClass().getName(), "parseAccount", in); String line; QifAccount acc = new QifAccount(); boolean result = false; line = in.readLine(); while (line != null) { if (line.startsWith("N")) { acc.name = line.substring(1); } else if (line.startsWith("T")) { acc.type = line.substring(1); } else if (line.startsWith("D")) { acc.description = line.substring(1); } else if (line.startsWith("L")) { logger.finest("Ignoring credit limit"); } else if (line.startsWith("/")) { logger.finest("statement balance date"); } else if (line.startsWith("$")) { logger.finest("Ignoring statement balance"); } else if (line.startsWith("X")) { // must be GnuCashToQIF... not sure what it is??? ignore it. logger.warning("Ignoring 'X' attribute"); } else if (line.startsWith("^")) { String peek = in.peekLine(); if (peek == null) { // end of the file in empty account list accountList.add(acc); result = true; break; } if (startsWith(peek, "!Account")) { // must be in an account list, no transaction data here accountList.add(acc); acc = new QifAccount(); in.readLine(); // eat the line since we only peeked at it } else if (startsWith(peek, "!Type:Memor")) { accountList.add(acc); result = true; break; } else if (startsWith(peek, "!Type:Invst")) { // investment transactions follow logger.fine("Found investment transactions"); /* Search for a duplicate account generated by the account list and * use it if possible. Qif out will output a list of empty accounts * and then follow up with the same account and it's transactions */ QifAccount dup = searchForDuplicate(acc); if (dup != null) { acc = dup; // trade for the duplicate already existing in the list } if (parseInvestmentAccountTransactions(in, acc)) { if (dup == null) { accountList.add(acc); // only add if not a duplicate } logger.finest("Added Qif Account"); result = true; break; // exit here, the outer loop will catch the next account if it exists } acc = new QifAccount(); } else if (startsWith(peek, "!Type:Prices")) { // security prices, jump out logger.fine("Found commodity price; jump out"); result = true; break; } else if (startsWith(peek, "!Type:")) { // must be transactions that follow logger.fine("Found bank transactions"); /* Search for a duplicate account generated by the account list and * use it if possible. Qif out will output a list of empty accounts * and then follow up with the same account and it's transactions */ QifAccount dup = searchForDuplicate(acc); if (dup != null) { acc = dup; // trade for the duplicate already existing in the list } if (parseAccountTransactions(in, acc)) { if (dup == null) { accountList.add(acc); // only add if not a duplicate } logger.finest("Added Qif Account"); result = true; break; // exit here, the outer loop will catch the next account if it exists } acc = new QifAccount(); } else if (startsWith(peek, "!Clear:Auto")) { in.readLine(); // the broken AutoSwitch.... eat the line accountList.add(acc); result = true; break; } else if (startsWith(peek, "!")) { // something weird, assume in empty account list accountList.add(acc); result = true; break; } else { // must be in an account list using AutoSwitch accountList.add(acc); acc = new QifAccount(); } } else { break; } line = in.readLine(); } logger.exiting(this.getClass().getName(), "parseAccount", result); } private QifAccount searchForDuplicate(final QifAccount acc) { String name = acc.name; String type = acc.type; String description = acc.description; // assume non-null description Objects.requireNonNull(description); if (name == null || type == null) { logger.log(Level.SEVERE, "Invalid account: \n{0}", acc.toString()); return null; } /* Investment account types are not consistent in Quicken export.... buggy software. * A Type of "Invst" is used as a generic when listing the transactions in the * investment account. * TODO "Port" is only one type.... need to discover other types */ if (type.equals("Invst") || type.equals("Port")) { for (QifAccount a : accountList) { if (a.name.equals(name) && a.description.equals(description)) { logger.fine("Matched a duplicate account"); return a; } } } else { for (QifAccount a : accountList) { if (a.name.equals(name) && a.type.equals(type) && a.description.equals(description)) { logger.fine("Matched a duplicate account"); return a; } } } return null; } // TODO strip out investment account transaction checks private static boolean parseAccountTransactions(final QifReader in, final QifAccount acc) { String line; QifTransaction tran = new QifTransaction(); try { line = in.readLine(); while (line != null) { if (startsWith(line, "!Type:")) { if (startsWith(line, "!Type:Invst")) { tran.setTransactionTypeDescription(line.substring(1)); tran.setTransactionTypeDescription(line.substring(1)); } else if (startsWith(line, "!Type:Memor")) { in.reset(); return true; } else { tran.setTransactionTypeDescription(line.substring(1)); } } else if (line.startsWith("D")) { /* Preserve the original unparsed date so that it may be * reevaluated at a later time. */ tran.oDate = line.substring(1); //tran.datePosted = QifTransaction.parseDate(tran.oDate, dateFormat); } else if (line.startsWith("U")) { logger.finest("Ignoring U"); } else if (line.startsWith("T")) { tran.setAmount(QifUtils.parseMoney(line.substring(1))); } else if (line.startsWith("C")) { tran.status = line.substring(1); } else if (line.startsWith("P")) { tran.setPayee(line.substring(1)); } else if (line.startsWith("L")) { tran.category = line.substring(1); } else if (line.startsWith("N")) { tran.setCheckNumber(line.substring(1)); } else if (line.startsWith("M")) { tran.setMemo(line.substring(1)); } else if (line.startsWith("A")) { logger.log(Level.INFO, "Ignored address line: {0}", line.substring(1)); } else if (line.startsWith("I")) { tran.price = line.substring(1); } else if (line.startsWith("^")) { acc.addTransaction(tran); logger.finest("*** Added a Transaction ***"); tran = new QifTransaction(); } else if (startsWith(line, "!Account")) { in.reset(); return true; } else if (startsWith(line, "!Type:Prices")) { // fund prices... jump out in.reset(); return true; } else if (line.charAt(0) == 'S' || line.charAt(0) == 'E' || line.charAt(0) == '$' || line.charAt(0) == '%') { // doing a split transaction in.reset(); QifSplitTransaction split = parseSplitTransaction(in); if (split != null) { tran.addSplit(split); logger.finest("*** Added a Split Transaction ***"); } } else { logger.log(Level.SEVERE, "Unknown field: {0}", line); } in.mark(); line = in.readLine(); } } catch (IOException e) { return false; } return true; } private boolean parseInvestmentAccountTransactions(final QifReader in, final QifAccount acc) { boolean result = true; logger.entering(this.getClass().getName(), "parseInvestmentAccountTransactions", acc); String line; QifTransaction tran = new QifTransaction(); try { line = in.readLine(); while (line != null) { if (startsWith(line, "!Type:Invst")) { // TODO Bogus check? tran.setTransactionTypeDescription(line.substring(1)); } else if (startsWith(line, "!Type:Memor")) { in.reset(); result = true; break; } else if (startsWith(line, "!Type:Prices")) { // fund prices... jump out logger.fine("Found commodity prices; jumping out"); in.reset(); result = true; break; } else if (line.startsWith("D")) { /* Preserve the original unparsed date so that it may be * reevaluated at a later time. */ tran.oDate = line.substring(1); tran.setDatePosted(QifTransaction.parseDate(tran.oDate, dateFormat)); } else if (line.startsWith("U")) { //tran.U = line.substring(1); logger.finest("Ignoring U"); } else if (line.startsWith("T")) { tran.setAmount(QifUtils.parseMoney(line.substring(1))); } else if (line.startsWith("C")) { tran.status = line.substring(1); } else if (line.startsWith("P")) { tran.setPayee(line.substring(1)); } else if (line.startsWith("L")) { tran.category = line.substring(1); } else if (line.startsWith("N")) { // trans type for inv accounts tran.setCheckNumber(line.substring(1)); } else if (line.startsWith("M")) { tran.setMemo(line.substring(1)); } else if (line.startsWith("A")) { logger.log(Level.INFO, "Ignored address line: {0}", line.substring(1)); } else if (line.startsWith("Y")) { tran.security = line.substring(1); } else if (line.startsWith("I")) { tran.price = line.substring(1); } else if (line.startsWith("Q")) { tran.quantity = line.substring(1); } else if (line.charAt(0) == '$') { // must check before split trans checks... Does Quicken allow for split investment transactions? tran.amountTrans = line.substring(1); } else if (line.startsWith("^")) { acc.addTransaction(tran); logger.finest("*** Added an investment transaction ***"); tran = new QifTransaction(); } else if (startsWith(line, "!Account")) { in.reset(); result = true; break; } else if (line.charAt(0) == 'S' || line.charAt(0) == 'E' || line.charAt(0) == '$' || line.charAt(0) == '%') { // doing a split transaction in.reset(); QifSplitTransaction split = parseSplitTransaction(in); if (split != null) { tran.addSplit(split); logger.fine("*** Added a Split Transaction ***"); } } else { logger.log(Level.SEVERE, "Unknown field: {0}", line); } in.mark(); line = in.readLine(); } } catch (IOException e) { result = false; } logger.exiting(this.getClass().getName(), "parseInvestmentAccountTransactions", result); return result; } private static QifSplitTransaction parseSplitTransaction(final QifReader in) { boolean category = false; boolean memo = false; boolean amount = false; boolean percentage = false; String line; QifSplitTransaction split = new QifSplitTransaction(); try { line = in.readLine(); while (line != null) { if (line.startsWith("S")) { if (category) { in.reset(); return split; } category = true; split.category = line.substring(1); } else if (line.startsWith("E")) { if (memo) { in.reset(); return split; } memo = true; split.memo = line.substring(1); } else if (line.startsWith("$")) { if (amount) { in.reset(); return split; } amount = true; split.amount = QifUtils.parseMoney(line.substring(1)); } else if (line.startsWith("%")) { if (percentage) { in.reset(); return split; } percentage = true; // split.percentage = line.substring(1); } else if (line.startsWith("^")) { in.reset(); return split; } else { in.reset(); return null; } in.mark(); line = in.readLine(); } return null; } catch (IOException e) { return null; } } /** * Just eats the memorized transaction data. Will not try to convert to jGnash entities * * @param in {@code QifReader} */ private static void parseMemorizedTransactions(final QifReader in) throws IOException { logger.finest("*** Start: parseMemorizedTransactions ***"); String line = in.readLine(); while (line != null) { if (line.startsWith("K")) { // munch until all of them are gone line = in.readLine(); if (line != null && line.charAt(0) == '^') { String peek = in.peekLine(); if (peek == null) { return; } else if (!peek.startsWith("K")) { break; } } } else { in.reset(); return; } in.mark(); line = in.readLine(); } } private void parseCategoryList(final QifReader in) throws IOException { boolean result = false; QifCategory cat = new QifCategory(); String line = in.readLine(); while (line != null) { if (line.startsWith("N")) { cat.name = line.substring(1); } else if (line.startsWith("D")) { cat.description = line.substring(1); } else if (line.startsWith("T")) { logger.finest("Ignoring tax related flag"); } else if (line.startsWith("I")) { cat.type = "I"; } else if (line.startsWith("E")) { cat.type = "E"; } else if (line.startsWith("B")) { logger.finest("Ignoring budget amount"); } else if (line.startsWith("R")) { logger.finest("Ignoring tax schedule"); } else if (line.startsWith("^")) { // a complete category item categories.add(cat); // add it to the list cat = new QifCategory(); // start a new one in.mark(); // next line might be end of list } else if (line.startsWith("!")) { // done with category list in.reset(); // give the line back result = true; // a good return break; } else { System.out.println("Error: " + line); result = false; } line = in.readLine(); } logger.exiting(this.getClass().getName(), "parseCategoryList", result); } private void parseClassList(final QifReader in) throws IOException { QifClassItem classItem = new QifClassItem(); String line = in.readLine(); while (line != null) { if (line.startsWith("N")) { classItem.name = line.substring(1); } else if (line.startsWith("D")) { classItem.description = line.substring(1); } else if (line.startsWith("^")) { // end of a class item classes.add(classItem); // add it to the list classItem = new QifClassItem(); // start a new one in.mark(); // next line might be end of the list } else if (line.startsWith("!")) { // done with the class list in.reset(); // give the line back return; } else { throw new IOException("Error: " + line); } line = in.readLine(); } } /** * So far, I haven't see a security as part of a list, but it is supported * just in case there is another "variation" of the format * * @param in {@code QifReader} */ private void parseSecurity(final QifReader in) throws IOException { boolean result = false; QifSecurity sec = new QifSecurity(); String line = in.readLine(); while (line != null) { if (line.startsWith("N")) { sec.name = line.substring(1); } else if (line.startsWith("D")) { sec.description = line.substring(1); } else if (line.startsWith("T")) { sec.type = line.substring(1); } else if (line.startsWith("S")) { sec.symbol = line.substring(1); } else if (line.startsWith("^")) { securities.add(sec); sec = new QifSecurity(); in.mark(); } else if (line.startsWith("!")) { // end of securities in.reset(); result = true; break; } else { System.out.println("Error: " + line); break; } line = in.readLine(); } logger.exiting(this.getClass().getName(), "parseSecurity", result); } /** * Price data in QIF file is not very informative.... ignore it for now * * @param in {@code QifReader} */ private void parsePrice(final QifReader in) throws IOException { logger.entering(this.getClass().getName(), "parsePrice"); boolean result = false; String line = in.readLine(); while (line != null) { if (line.startsWith("^")) { result = true; break; } line = in.readLine(); } logger.exiting(this.getClass().getName(), "parsePrice", result); } void dumpStats() { System.out.println("Num Classes :" + classes.size()); System.out.println("Num Categories :" + categories.size()); System.out.println("Num Securities :" + securities.size()); System.out.println("Num Accounts :" + accountList.size()); int count = accountList.size(); for (int i = 0; i < count; i++) { QifAccount acc = accountList.get(i); System.out.println("Account " + (i + 1) + " " + acc.name); int size = acc.getTransactions().size(); System.out.println(" Num Transactions :" + size); for (int j = 0; j < size; j++) { QifTransaction tran = acc.getTransactions().get(j); System.out.println(" Transaction " + (j + 1) + " " + tran.getPayee()); System.out.println(" Num Splits :" + tran.splits.size()); for (int k = 0; k < tran.splits.size(); k++) { System.out.println(" Split " + (k + 1) + " " + tran.splits.get(k).memo); } } } } static class QifSecurity { String name; String description; String symbol; String type; } static class QifClassItem { String name; String description; } } ================================================ FILE: jgnash-convert/src/main/java/jgnash/convert/importat/qif/QifReader.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.convert.importat.qif; import java.io.IOException; import java.io.LineNumberReader; import java.io.Reader; /** * An extended LineNumberReader to help ease the pain of parsing * a QIF file * * @author Craig Cavanaugh */ class QifReader extends LineNumberReader { private static final boolean debug = false; QifReader(Reader in) { super(in, 8192); } void mark() throws IOException { super.mark(256); } @Override public void reset() throws IOException { super.reset(); if (debug) { System.out.println("Reset"); } } /** * Takes a peek at the next line and eats and empty line if found * * @return next readable line, null if at the end of the file * @throws IOException IO exception */ String peekLine() throws IOException { String peek; while (true) { mark(); peek = readLine(); if (peek != null) { peek = peek.trim(); reset(); if (peek.isEmpty()) { readLine(); // eat the empty line if (debug) { System.out.println("*EMPTY LINE*"); } } else { return peek.trim(); } } else { return null; } } } @Override public String readLine() throws IOException { while (true) { try { String line = super.readLine().trim(); if (debug) { System.out.println("Line " + getLineNumber() + ": " + line); } if (!line.isEmpty()) { return line; } if (debug) { System.out.println("*EMPTY LINE*"); } } catch (NullPointerException e) { return null; } } } } ================================================ FILE: jgnash-convert/src/main/java/jgnash/convert/importat/qif/QifSplitTransaction.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.convert.importat.qif; import java.math.BigDecimal; /** * Class for QIF split transaction import * * @author Craig Cavanaugh */ class QifSplitTransaction { public String category; public String memo = ""; public BigDecimal amount; //public String percentage; public QifSplitTransaction() { amount = BigDecimal.ZERO; } @Override public String toString() { StringBuilder buf = new StringBuilder(); buf.append("Memo: ").append(memo).append('\n'); buf.append("Category: ").append(category).append('\n'); if (amount != null) { buf.append("Amount:").append(amount).append('\n'); } return buf.toString(); } } ================================================ FILE: jgnash-convert/src/main/java/jgnash/convert/importat/qif/QifTransaction.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.convert.importat.qif; import java.time.LocalDate; import java.util.ArrayList; import java.util.Collection; import java.util.Objects; import java.util.logging.Logger; import java.util.regex.Pattern; import jgnash.convert.importat.DateFormat; import jgnash.convert.importat.ImportTransaction; /** * Transaction object for a QIF transaction * * @author Craig Cavanaugh */ public class QifTransaction extends ImportTransaction { private static final Pattern DATE_DELIMITER_PATTERN = Pattern.compile("[/'.-]"); /** * Original date before conversion */ String oDate; String status = null; public String category = null; String security; String price; String quantity; String amountTrans; public final ArrayList splits = new ArrayList<>(); void addSplit(QifSplitTransaction split) { splits.add(split); } boolean hasSplits() { return !splits.isEmpty(); } @Override public String toString() { StringBuilder buf = new StringBuilder(); buf.append("Payee: ").append(getPayee()).append('\n'); buf.append("Memo: ").append(getMemo()).append('\n'); buf.append("Category: ").append(category).append('\n'); if (getAmount() != null) { buf.append("Amount:").append(getAmount()).append('\n'); } buf.append("Date: ").append(getDatePosted()).append('\n'); return buf.toString(); } static DateFormat determineDateFormat(final Collection transactions) { Objects.requireNonNull(transactions); DateFormat dateFormat = DateFormat.US; // US date is assumed for (final QifTransaction transaction : transactions) { // protect against a transaction missing a date Github issue #30 if (transaction.oDate != null) { int zero; int one; //int two; final String[] chunks = QifTransaction.DATE_DELIMITER_PATTERN.split(transaction.oDate); zero = Integer.parseInt(chunks[0].trim()); one = Integer.parseInt(chunks[1].trim()); //two = Integer.parseInt(chunks[2].trim()); if (zero > 12 && one <= 12) { // must have a EU date format dateFormat = DateFormat.EU; break; } } } return dateFormat; } /** * Converts a string into a data object *

* {@code * "6/21' 1" -> 6/21/2001 * "6/21'01" -> 6/21/2001 * "9/18'2001 -> 9/18/2001 * "06/21/2001" -> "06/21/01" * "3.26.03" -> German version of quicken format "03-26-2003" * MSMoney format "1.1.2005" * kmymoney2 20.1.94 * European dd/mm/yyyy * 21/2/07 -> 02/21/2007 UK * Quicken 2007 D15/2/07 * }`` * * @param sDate String QIF date to parse * @param format String identifier of format to parse * @return Returns parsed date and current date if an error occurs */ static LocalDate parseDate(final String sDate, final DateFormat format) throws java.time.DateTimeException { int month = 0; int day = 0; int year = 0; final String[] chunks = DATE_DELIMITER_PATTERN.split(sDate); try { switch (format) { case US: month = Integer.parseInt(chunks[0].trim()); day = Integer.parseInt(chunks[1].trim()); year = Integer.parseInt(chunks[2].trim()); break; case EU: day = Integer.parseInt(chunks[0].trim()); month = Integer.parseInt(chunks[1].trim()); year = Integer.parseInt(chunks[2].trim()); break; } } catch (final NumberFormatException e) { Logger.getLogger(QifUtils.class.getName()).severe(e.toString()); } if (year < 100) { if (year < 29) { year += 2000; } else { year += 1900; } } try { return LocalDate.of(year, month, day); } catch (Exception e) { Logger.getLogger(QifUtils.class.getName()).severe("Invalid date format specified"); return LocalDate.now(); } } } ================================================ FILE: jgnash-convert/src/main/java/jgnash/convert/importat/qif/QifUtils.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.convert.importat.qif; import java.io.File; import java.io.FileNotFoundException; import java.io.IOException; import java.math.BigDecimal; import java.nio.charset.Charset; import java.nio.file.Files; import java.text.NumberFormat; import java.text.ParseException; import java.util.logging.Level; import java.util.logging.Logger; import java.util.regex.Pattern; import jgnash.engine.MathConstants; import jgnash.util.FileMagic; /** * Various helper methods for importing QIF files * * @author Craig Cavanaugh * @author Navneet Karnani */ public class QifUtils { private static final Pattern MONEY_PREFIX_PATTERN = Pattern.compile("\\D"); private static final Pattern CATEGORY_DELIMITER_PATTERN = Pattern.compile("/"); private QifUtils() { } static BigDecimal parseMoney(final String money) { String sMoney = money; if (sMoney != null) { sMoney = sMoney.trim(); // to be safe try { return new BigDecimal(sMoney); } catch (NumberFormatException e) { /* there must be commas, etc in the number. Need to look for them * and remove them first, and then try BigDecimal again. If that * fails, then give up and use NumberFormat and scale it down * */ String[] split = MONEY_PREFIX_PATTERN.split(sMoney); if (split.length > 2) { StringBuilder buf = new StringBuilder(); if (sMoney.startsWith("-")) { buf.append('-'); } for (int i = 0; i < split.length - 1; i++) { buf.append(split[i]); } buf.append('.'); buf.append(split[split.length - 1]); try { return new BigDecimal(buf.toString()); } catch (final NumberFormatException e2) { Logger l = Logger.getLogger(QifUtils.class.getName()); l.info("second parse attempt failed"); l.info(buf.toString()); l.info("falling back to rounding"); } } NumberFormat formatter = NumberFormat.getNumberInstance(); try { Number num = formatter.parse(sMoney); BigDecimal bd = BigDecimal.valueOf(num.floatValue()); if (bd.scale() > 6) { Logger l = Logger.getLogger(QifUtils.class.getName()); l.warning("-Warning-"); l.warning("Large scale detected in QifUtils.parseMoney"); l.warning("Truncating scale to 2 places"); l.warning(bd.toString()); bd = bd.setScale(2, MathConstants.roundingMode); l.warning(bd.toString()); } return bd; } catch (ParseException ignored) { Logger.getLogger(QifUtils.class.getName()) .log(Level.SEVERE, "poorly formatted number: {0}", sMoney); } } } return BigDecimal.ZERO; } public static boolean isFullFile(final File file) { boolean result = false; final Charset charset = FileMagic.detectCharset(file.getPath()); try (final QifReader in = new QifReader(Files.newBufferedReader(file.toPath(), charset))) { String line = in.readLine(); while (line != null) { if (startsWith(line, "!Type:Class")) { result = true; break; } else if (startsWith(line, "!Type:Cat")) { result = true; break; } else if (startsWith(line, "!Account")) { result = true; break; } else if (startsWith(line, "!Type:Memorized")) { result = true; break; } else if (startsWith(line, "!Type:Security")) { result = true; break; } else if (startsWith(line, "!Type:Prices")) { result = true; break; } else if (startsWith(line, "!Type:Bank")) { // QIF from an online bank statement... assumes the account is known break; } else if (startsWith(line, "!Type:CCard")) { // QIF from an online credit card statement break; } else if (startsWith(line, "!Type:Oth")) { // QIF from an online credit card statement break; } else if (startsWith(line, "!Type:Cash")) { // Partial QIF export break; } else if (startsWith(line, "!Option:AutoSwitch")) { Logger.getLogger(QifUtils.class.getName()).fine("!Option:AutoSwitch"); } else if (startsWith(line, "!Clear:AutoSwitch")) { Logger.getLogger(QifUtils.class.getName()).fine("!Clear:AutoSwitch"); } else { System.out.println("Error: " + line); break; } line = in.readLine(); } in.close(); return result; } catch (FileNotFoundException e) { Logger.getLogger(QifUtils.class.getName()).log(Level.SEVERE, "Could not find file: {0}", file.getAbsolutePath()); return false; } catch (IOException e) { Logger.getLogger(QifUtils.class.getName()).log(Level.SEVERE, null, e); return false; } } /** * Tests if the source string starts with the prefix string. Case is * ignored. * * @param source the source String. * @param prefix the prefix String. * @return true, if the source starts with the prefix string. */ private static boolean startsWith(final String source, final String prefix) { return prefix.length() <= source.length() && source.regionMatches(true, 0, prefix, 0, prefix.length()); } /** * Strip any category tags from the category name... found when parsing * transactions * * @param category string to strip * @return the stripped string */ static String stripCategoryTags(final String category) { // Auto:Gas/matrix:Vacation > Auto:Gas if (category != null && category.contains("/")) { return CATEGORY_DELIMITER_PATTERN.split(category)[0]; } return category; } } ================================================ FILE: jgnash-convert/src/main/resources/jgnash/convert/scripts/tidy.js ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ /* This scripts normalizes the case of the payee and memo fields */ var importTransaction; // place holder for the passed ImportTransaction /* This will be called first and passed the ImportTransaction */ function acceptTransaction(transaction) { importTransaction = transaction; // do nothing with it in this script } function processMemo(memo) { return capitalizeFirstLetter(memo.toLocaleLowerCase()); } function processPayee(payee) { return titleCase(payee.toLocaleLowerCase()); } function getDescription(locale) { var Locale = Packages.java.util.Locale; switch (locale) { case Locale.ENGLISH: return "Tidy Memo and Payee fields"; default: return "Tidy Memo and Payee fields"; } } function capitalizeFirstLetter(str) { return str.charAt(0).toUpperCase() + str.slice(1); } function titleCase(str) { return str.replace(/(^|\s)[a-z]/g,function(f){return f.toUpperCase();}); } ================================================ FILE: jgnash-core/build.gradle.kts ================================================ description = "jGnash Core" var moduleName = "jgnash.core" val commonsCollectionsVersion: String by project val commonsCsvVersion: String by project val commonsLangVersion: String by project val commonsMathVersion: String by project val slf4jVersion: String by project val hibernateVersion: String by project val hikariVersion: String by project val h2Version: String by project val hsqldbVersion: String by project val xstreamVersion: String by project val nettyVersion: String by project plugins { `java-library` } dependencies { implementation(project(":jgnash-resources")) // required for HikariCP, override with modular version implementation("org.slf4j:slf4j-api:$slf4jVersion") implementation("org.slf4j:slf4j-jdk14:$slf4jVersion") api("org.hibernate:hibernate-entitymanager:$hibernateVersion") implementation("org.hibernate:hibernate-hikaricp:$hibernateVersion") implementation("com.zaxxer:HikariCP:$hikariVersion") implementation("com.h2database:h2:$h2Version") implementation("org.hsqldb:hsqldb:$hsqldbVersion") implementation("com.thoughtworks.xstream:xstream:$xstreamVersion") { exclude(module = "xmlpull") exclude(module = "xpp3_min") } implementation("com.thoughtworks.xstream:xstream-hibernate:$xstreamVersion") { exclude(module = "xmlpull") exclude(module = "xpp3_min") } implementation("io.netty:netty-codec:$nettyVersion") implementation("org.apache.commons:commons-collections4:$commonsCollectionsVersion") implementation("org.apache.commons:commons-csv:$commonsCsvVersion") implementation("org.apache.commons:commons-lang3:$commonsLangVersion") implementation("org.apache.commons:commons-math3:$commonsMathVersion") } tasks.jar { manifest.attributes["Automatic-Module-Name"] = moduleName } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/AbstractInvestmentTransactionEntry.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine; import java.math.BigDecimal; import javax.persistence.Column; import javax.persistence.Entity; import javax.persistence.ManyToOne; import jgnash.util.NotNull; /** * Investment Transaction Entry. * * @author Craig Cavanaugh */ @Entity public abstract class AbstractInvestmentTransactionEntry extends TransactionEntry { /** * Security for this entry. */ @ManyToOne private SecurityNode securityNode; /** * share price. */ @Column(precision = 26, scale = 8) private BigDecimal price; /** * number of shares. */ @Column(precision = 26, scale = 8) private BigDecimal quantity; /** * Creates a new instance of InvestmentTransactionEntry. */ protected AbstractInvestmentTransactionEntry() { setTransactionTag(TransactionTag.INVESTMENT); } /** * Calculates the total of the value of the shares, gains, fees, etc. as it * pertains to an account. *

* Not intended for use to calculate account balances * * @return total resulting total for this entry * @see InvestmentTransaction#getTotal(jgnash.engine.Account) */ public BigDecimal getTotal() { return getQuantity().multiply(getPrice()); } public SecurityNode getSecurityNode() { return securityNode; } void setSecurityNode(final SecurityNode securityNode) { this.securityNode = securityNode; } public BigDecimal getPrice() { return price; } void setPrice(final BigDecimal price) { this.price = price; } /** * Assigns the number of shares for this transaction. The value should be * always be a positive value. * * @param quantity the quantity of securities to assign to this account * @see #getSignedQuantity() */ void setQuantity(final BigDecimal quantity) { this.quantity = quantity; } /** * Returns the number of shares assigned to this transaction. * * @return the quantity of securities for this transaction * @see #getSignedQuantity() */ public BigDecimal getQuantity() { return quantity; } /** * Returns the number of shares as it would impact the sum of the investment * accounts shares. Useful for summing share quantities * * @return the quantity of securities for this transaction */ public abstract BigDecimal getSignedQuantity(); /** * Returns the type of this transaction entry. * * @return the transaction type */ @NotNull public abstract TransactionType getTransactionType(); @Override public String toString() { return super.toString() + "Security: " + getSecurityNode().getSymbol() + System.lineSeparator() + "Quantity: " + getQuantity().toPlainString() + System.lineSeparator() + "Price: " + getPrice().toPlainString() + System.lineSeparator(); } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/Account.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received account copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine; import java.math.BigDecimal; import java.time.LocalDate; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; import java.util.TreeSet; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReadWriteLock; import java.util.concurrent.locks.ReentrantReadWriteLock; import java.util.logging.Level; import java.util.logging.Logger; import java.util.regex.Pattern; import java.util.stream.Collectors; import javax.persistence.CascadeType; import javax.persistence.Column; import javax.persistence.ElementCollection; import javax.persistence.Entity; import javax.persistence.EnumType; import javax.persistence.Enumerated; import javax.persistence.FetchType; import javax.persistence.JoinColumn; import javax.persistence.JoinTable; import javax.persistence.ManyToMany; import javax.persistence.ManyToOne; import javax.persistence.OneToMany; import javax.persistence.OneToOne; import javax.persistence.OrderBy; import javax.persistence.PostLoad; import javax.persistence.Transient; import jgnash.time.DateUtils; import jgnash.util.NotNull; import jgnash.util.Nullable; /** * Account object. The {@code Account} object is mutable. Changes should be made using the {@code Engine} to * ensure correct state and persistence. * * @author Craig Cavanaugh * @author Jeff Prickett prickett@users.sourceforge.net */ @Entity public class Account extends StoredObject implements Comparable { static final int MAX_ATTRIBUTE_LENGTH = 8192; /** * Attribute key for the last attempted reconciliation date. */ public static final String RECONCILE_LAST_ATTEMPT_DATE = "Reconcile.LastAttemptDate"; /** * Attribute key for the last successful reconciliation date. */ public static final String RECONCILE_LAST_SUCCESS_DATE = "Reconcile.LastSuccessDate"; /** * Attribute key for the last reconciliation statement date. */ public static final String RECONCILE_LAST_STATEMENT_DATE = "Reconcile.LastStatementDate"; /** * Attribute key for the last reconciliation opening balance. */ public static final String RECONCILE_LAST_OPENING_BALANCE = "Reconcile.LastOpeningBalance"; /** * Attribute key for the last reconciliation closing balance. */ public static final String RECONCILE_LAST_CLOSING_BALANCE = "Reconcile.LastClosingBalance"; private static final Pattern numberPattern = Pattern.compile("\\d+"); private static final Logger logger = Logger.getLogger(Account.class.getName()); /** * String delimiter for reported account structure. */ private static String accountSeparator = ":"; @ManyToOne Account parentAccount; /** * List of transactions for this account. */ @JoinTable @OrderBy("date, number, timestamp") @ManyToMany(cascade = {CascadeType.ALL}, fetch = FetchType.EAGER) final Set transactions = new HashSet<>(); /** * List of securities if this is an investment account. */ @JoinColumn() @OrderBy("symbol") @ManyToMany(cascade = {CascadeType.REFRESH, CascadeType.PERSIST, CascadeType.MERGE}, fetch = FetchType.EAGER) private final Set securities = new HashSet<>(); @Enumerated(EnumType.STRING) private AccountType accountType; private boolean placeHolder = false; private boolean locked = false; private boolean visible = true; private boolean excludedFromBudget = false; private String name = ""; private String description = ""; @Column(columnDefinition = "VARCHAR(8192)") private String notes = ""; /** * CurrencyNode for this account. */ @ManyToOne private CurrencyNode currencyNode; /** * Sorted list of child accounts. */ @OrderBy("name") @OneToMany(cascade = {CascadeType.ALL}, fetch = FetchType.EAGER) private final Set children = new HashSet<>(); /** * Cached list of sorted transactions that is not persisted. This prevents concurrency issues when using a JPA backend */ @Transient private transient List cachedSortedTransactionList; /** * Cached list of sorted accounts this is not persisted. This prevents concurrency issues when using a JPA backend */ @Transient private transient List cachedSortedChildren; /** * Balance of the account. * * Cached balances cannot be persisted to do nature of JPA */ @Transient private transient BigDecimal accountBalance; /** * Reconciled balance of the account. * * Cached balances cannot be persisted to do nature of JPA */ @Transient private transient BigDecimal reconciledBalance; /** * User definable account number. */ private String accountNumber = ""; /** * User definable bank id. Useful for OFX import */ private String bankId; /** * User definable account code. This will control sort order */ @Column(nullable = false, columnDefinition = "int default 0") private int accountCode; @OneToOne(orphanRemoval = true, cascade = {CascadeType.ALL}) private AmortizeObject amortizeObject; /** * User definable attributes. */ @ElementCollection @Column(columnDefinition = "varchar(8192)") private final Map attributes = new HashMap<>(); // maps from attribute name to value private transient ReadWriteLock transactionLock; private transient ReadWriteLock childLock; private transient ReadWriteLock securitiesLock; private transient ReadWriteLock attributesLock; private transient AccountProxy proxy; /** * No argument public constructor for reflection purposes. * * Do not use to create account new instance */ public Account() { transactionLock = new ReentrantReadWriteLock(true); childLock = new ReentrantReadWriteLock(true); securitiesLock = new ReentrantReadWriteLock(true); attributesLock = new ReentrantReadWriteLock(true); // CopyOnWrite is used as an alternative to defensive copies cachedSortedChildren = new ArrayList<>(); } public Account(@NotNull final AccountType type, @NotNull final CurrencyNode node) { this(); Objects.requireNonNull(type); Objects.requireNonNull(node); setAccountType(type); setCurrencyNode(node); } private static String getAccountSeparator() { return accountSeparator; } static void setAccountSeparator(final String separator) { accountSeparator = separator; } ReadWriteLock getTransactionLock() { return transactionLock; } private AccountProxy getProxy() { if (proxy == null) { proxy = getAccountType().getProxy(this); } return proxy; } /** * Clear cached account balances so they will be recalculated. */ void clearCachedBalances() { accountBalance = null; reconciledBalance = null; } /** * Adds account transaction in chronological order. * * @param tran the {@code Transaction} to be added * @return true the transaction was added successful false the transaction was already attached * to this account */ boolean addTransaction(final Transaction tran) { if (placeHolder) { logger.severe("Tried to add transaction to a place holder account"); return false; } transactionLock.writeLock().lock(); try { boolean result = false; if (!contains(tran)) { transactions.add(tran); /* The cached list may already contain the transaction if it has not been initialized yet */ if (!getCachedSortedTransactionList().contains(tran)) { getCachedSortedTransactionList().add(tran); Collections.sort(getCachedSortedTransactionList()); } clearCachedBalances(); result = true; } else { logger.log(Level.SEVERE, "Account: {0}({1}){2}Already have transaction ID: {3}", new Object[]{getName(), hashCode(), System.lineSeparator(), tran.hashCode()}); } return result; } finally { transactionLock.writeLock().unlock(); } } /** * Removes the specified transaction from this account. * * @param tran the {@code Transaction} to be removed * @return {@code true} the transaction removal was successful {@code false} the transaction could not be found * within this account */ boolean removeTransaction(final Transaction tran) { transactionLock.writeLock().lock(); try { boolean result = false; if (contains(tran)) { transactions.remove(tran); getCachedSortedTransactionList().remove(tran); clearCachedBalances(); result = true; } else { Logger.getLogger(Account.class.toString()).log(Level.SEVERE, "Account: {0}({1}){2}Did not contain transaction ID: {3}", new Object[]{getName(), getUuid(), System.lineSeparator(), tran.getUuid()}); } return result; } finally { transactionLock.writeLock().unlock(); } } /** * Determines if the specified transaction is attach to this account. * * @param tran the {@code Transaction} to look for * @return {@code true} the transaction is attached to this account {@code false} the transaction is not attached * to this account */ public boolean contains(final Transaction tran) { transactionLock.readLock().lock(); try { return transactions.contains(tran); } finally { transactionLock.readLock().unlock(); } } /** * Determine if the supplied account is a child of this account. * * @param account to check * @return true if the supplied account is a child of this account */ public boolean contains(final Account account) { childLock.readLock().lock(); try { return cachedSortedChildren.contains(account); } finally { childLock.readLock().unlock(); } } /** * Returns a sorted list of transactions for this account that is unmodifiable. * * @return List of transactions */ @NotNull public List getSortedTransactionList() { transactionLock.readLock().lock(); try { return Collections.unmodifiableList(getCachedSortedTransactionList()); } finally { transactionLock.readLock().unlock(); } } /** * Returns the transaction at the specified index. * * @param index the index of the transaction to return. * @return the transaction at the specified index. * @throws IndexOutOfBoundsException if the index is out of bounds */ @NotNull public Transaction getTransactionAt(final int index) { transactionLock.readLock().lock(); try { return getCachedSortedTransactionList().get(index); } finally { transactionLock.readLock().unlock(); } } /** * Returns the number of transactions attached to this account. * * @return the number of transactions attached to this account. */ public int getTransactionCount() { transactionLock.readLock().lock(); try { return transactions.size(); } finally { transactionLock.readLock().unlock(); } } /** * Searches through the transactions and determines the next largest * transaction number. * * @return The next check number; and empty String if numbers are not found */ @NotNull public String getNextTransactionNumber() { transactionLock.readLock().lock(); try { int number = 0; for (final Transaction tran : transactions) { if (numberPattern.matcher(tran.getNumber()).matches()) { try { number = Math.max(number, Integer.parseInt(tran.getNumber())); } catch (NumberFormatException e) { logger.log(Level.INFO, "Number regex failed", e); } } } if (number == 0) { return ""; } return Integer.toString(number + 1); } finally { transactionLock.readLock().unlock(); } } /** * Add account child account given it's reference. * * @param child The child account to add to this account. * @return {@code true} if the account was added successfully, {@code false} otherwise. */ boolean addChild(final Account child) { childLock.writeLock().lock(); try { boolean result = false; if (!children.contains(child) && child != this) { if (child.setParent(this)) { children.add(child); result = true; cachedSortedChildren.add(child); Collections.sort(cachedSortedChildren); } } return result; } finally { childLock.writeLock().unlock(); } } /** * Removes account child account. The reference to the parent(this) is left so that the parent can be discovered. * * @param child The child account to remove. * @return {@code true} if the specific account was account child of this account, {@code false} otherwise. */ boolean removeChild(final Account child) { childLock.writeLock().lock(); try { boolean result = false; if (children.remove(child)) { result = true; cachedSortedChildren.remove(child); } return result; } finally { childLock.writeLock().unlock(); } } /** * Returns a sorted list of the children. A protective copy is returned to protect against concurrency issues. * * @return List of children */ public List getChildren() { childLock.readLock().lock(); try { return new ArrayList<>(cachedSortedChildren); } finally { childLock.readLock().unlock(); } } /** * Returns a sorted list of the children. A protective copy is returned to protect against concurrency issues. * * @param comparator {@code Comparator} to use * @return List of children */ public List getChildren(final Comparator comparator) { List accountChildren = getChildren(); accountChildren.sort(comparator); return accountChildren; } /** * Returns the index of the specified {@code Transaction} within this {@code Account}. * * @param tran the {@code Transaction} to look for * @return The index of the {@code Transaction}, -1 if this * {@code Account} does not contain the {@code Transaction}. */ public int indexOf(final Transaction tran) { transactionLock.readLock().lock(); try { return getCachedSortedTransactionList().indexOf(tran); } finally { transactionLock.readLock().unlock(); } } /** * Returns the number of children this account has. * * @return the number of children this account has. */ public int getChildCount() { childLock.readLock().lock(); try { return cachedSortedChildren.size(); } finally { childLock.readLock().unlock(); } } /** * Returns the parent account. * * @return the parent of this account, null is this account is not account child */ public Account getParent() { childLock.readLock().lock(); try { return parentAccount; } finally { childLock.readLock().unlock(); } } /** * Sets the parent of this {@code Account}. * * @param account The new parent {@code Account} * @return {@code true} is successful */ public boolean setParent(final Account account) { childLock.writeLock().lock(); try { boolean result = false; if (account != this) { parentAccount = account; result = true; } return result; } finally { childLock.writeLock().unlock(); } } /** * Determines is this {@code Account} has any child{@code Account}. * * @return {@code true} is this {@code Account} has children, {@code false} otherwise. */ public boolean isParent() { childLock.readLock().lock(); try { return !cachedSortedChildren.isEmpty(); } finally { childLock.readLock().unlock(); } } /** * The account balance is cached to improve performance and reduce thrashing * of the GC system. The accountBalance is reset when transactions are added * and removed and lazily recalculated. * * @return the balance of this account */ public BigDecimal getBalance() { transactionLock.readLock().lock(); try { if (accountBalance != null) { return accountBalance; } return accountBalance = getProxy().getBalance(); } finally { transactionLock.readLock().unlock(); } } /** * The account balance is cached to improve performance and reduce thrashing * of the GC system. The accountBalance is rest when transactions are added * and removed and lazily recalculated. * * @param node CurrencyNode to get balance against * @return the balance of this account */ private BigDecimal getBalance(final CurrencyNode node) { transactionLock.readLock().lock(); try { return adjustForExchangeRate(getBalance(), node); } finally { transactionLock.readLock().unlock(); } } /** * Get the account balance up to the specified index using the natural * transaction sort order. * * @param index the balance of this account at the specified index. * @return the balance of this account at the specified index. */ private BigDecimal getBalanceAt(final int index) { transactionLock.readLock().lock(); try { return getProxy().getBalanceAt(index); } finally { transactionLock.readLock().unlock(); } } /** * Get the account balance up to the specified transaction using the natural * transaction sort order. * * @param transaction reference transaction for running balance. Must be contained within the account * @return the balance of this account at the specified transaction */ public BigDecimal getBalanceAt(final Transaction transaction) { transactionLock.readLock().lock(); try { final int index = indexOf(transaction); if (index >= 0) { return getBalanceAt(index); } return BigDecimal.ZERO; } finally { transactionLock.readLock().unlock(); } } /** * The reconciled balance is cached to improve performance and reduce * thrashing of the GC system. The reconciledBalance is reset when * transactions are added and removed and lazily recalculated. * * @return the reconciled balance of this account */ public BigDecimal getReconciledBalance() { transactionLock.readLock().lock(); try { if (reconciledBalance != null) { return reconciledBalance; } return reconciledBalance = getProxy().getReconciledBalance(); } finally { transactionLock.readLock().unlock(); } } private BigDecimal getReconciledBalance(final CurrencyNode node) { return adjustForExchangeRate(getReconciledBalance(), node); } private BigDecimal adjustForExchangeRate(final BigDecimal amount, final CurrencyNode node) { if (node.equals(getCurrencyNode())) { // child has the same commodity type return amount; } // the account has a different currency, use the last known exchange rate return amount.multiply(getCurrencyNode().getExchangeRate(node)); } /** * Returns the date of the first unreconciled transaction. * * @return Date of first unreconciled transaction */ public LocalDate getFirstUnreconciledTransactionDate() { transactionLock.readLock().lock(); try { LocalDate date = null; for (final Transaction transaction : getSortedTransactionList()) { if (transaction.getReconciled(this) != ReconciledState.RECONCILED) { date = transaction.getLocalDate(); break; } } if (date == null) { date = getCachedSortedTransactionList().get(getTransactionCount() - 1).getLocalDate(); } return date; } finally { transactionLock.readLock().unlock(); } } /** * Get the default opening balance for reconciling the account. * * @return Opening balance for reconciling the account * @see AccountProxy#getOpeningBalanceForReconcile() */ public BigDecimal getOpeningBalanceForReconcile() { return getProxy().getOpeningBalanceForReconcile(); } /** * Returns the balance of the account plus any child accounts. * * @return the balance of this account including the balance of any child * accounts. */ public BigDecimal getTreeBalance() { transactionLock.readLock().lock(); childLock.readLock().lock(); try { BigDecimal balance = getBalance(); for (final Account child : cachedSortedChildren) { balance = balance.add(child.getTreeBalance(getCurrencyNode())); } return balance; } finally { transactionLock.readLock().unlock(); childLock.readLock().unlock(); } } /** * Returns the balance of the account plus any child accounts. * * @param endDate The inclusive end date * @param node The commodity to convert balance to * * @return the balance of this account including the balance of any child * accounts. */ public BigDecimal getTreeBalance(final LocalDate endDate, final CurrencyNode node) { transactionLock.readLock().lock(); childLock.readLock().lock(); try { BigDecimal balance = getBalance(endDate, node); for (final Account child : cachedSortedChildren) { balance = balance.add(child.getTreeBalance(endDate, node)); } return balance; } finally { transactionLock.readLock().unlock(); childLock.readLock().unlock(); } } /** * Returns the balance of the account plus any child accounts. The balance * is adjusted to the current exchange rate of the supplied commodity if * needed. * * @param node The commodity to convert balance to * @return the balance of this account including the balance of any child * accounts. */ private BigDecimal getTreeBalance(final CurrencyNode node) { transactionLock.readLock().lock(); childLock.readLock().lock(); try { BigDecimal balance = getBalance(node); for (final Account child : cachedSortedChildren) { balance = balance.add(child.getTreeBalance(node)); } return balance; } finally { transactionLock.readLock().unlock(); childLock.readLock().unlock(); } } /** * Returns the reconciled balance of the account plus any child accounts. * The balance is adjusted to the current exchange rate of the supplied * commodity if needed. * * @param node The commodity to convert balance to * @return the balance of this account including the balance of any child * accounts. */ private BigDecimal getReconciledTreeBalance(final CurrencyNode node) { transactionLock.readLock().lock(); childLock.readLock().lock(); try { BigDecimal balance = getReconciledBalance(node); for (final Account child : cachedSortedChildren) { balance = balance.add(child.getReconciledTreeBalance(node)); } return balance; } finally { transactionLock.readLock().unlock(); childLock.readLock().unlock(); } } /** * Returns the reconciled balance of the account plus any child accounts. * * @return the balance of this account including the balance of any child * accounts. */ public BigDecimal getReconciledTreeBalance() { transactionLock.readLock().lock(); childLock.readLock().lock(); try { BigDecimal balance = getReconciledBalance(); for (final Account child : cachedSortedChildren) { balance = balance.add(child.getReconciledTreeBalance(getCurrencyNode())); } return balance; } finally { transactionLock.readLock().unlock(); childLock.readLock().unlock(); } } /** * Returns the balance of the transactions inclusive of the start and end * dates. * * @param start The inclusive start date * @param end The inclusive end date * @return The ending balance */ public BigDecimal getBalance(final LocalDate start, final LocalDate end) { Objects.requireNonNull(start); Objects.requireNonNull(end); transactionLock.readLock().lock(); try { return getProxy().getBalance(start, end); } finally { transactionLock.readLock().unlock(); } } /** * Returns the account balance up to and inclusive of the supplied date. The * returned balance is converted to the specified commodity. * * @param startDate start date * @param endDate end date * @param node The commodity to convert balance to * @return the account balance */ public BigDecimal getBalance(final LocalDate startDate, final LocalDate endDate, final CurrencyNode node) { transactionLock.readLock().lock(); try { return adjustForExchangeRate(getBalance(startDate, endDate), node); } finally { transactionLock.readLock().unlock(); } } /** * Returns the full inclusive ancestry of this * {@code Account}. * * @return {@code List} of accounts */ public List getAncestors() { List list = new ArrayList<>(); list.add(this); Account parent = getParent(); while (parent != null) { list.add(parent); parent = parent.getParent(); } return list; } /** * Returns the the balance of the account plus any child accounts inclusive * of the start and end dates. * * @param start start date * @param end end date * @param node CurrencyNode to use for balance * @return account balance */ public BigDecimal getTreeBalance(final LocalDate start, final LocalDate end, final CurrencyNode node) { Objects.requireNonNull(start); Objects.requireNonNull(end); transactionLock.readLock().lock(); childLock.readLock().lock(); try { BigDecimal returnValue = getBalance(start, end, node); for (final Account child : cachedSortedChildren) { returnValue = returnValue.add(child.getTreeBalance(start, end, node)); } return returnValue; } finally { transactionLock.readLock().unlock(); childLock.readLock().unlock(); } } /** * Returns the account balance up to and inclusive of the supplied localDate. * * @param localDate The inclusive ending localDate * @return The ending balance */ public BigDecimal getBalance(final LocalDate localDate) { transactionLock.readLock().lock(); try { return getProxy().getBalance(localDate); } finally { transactionLock.readLock().unlock(); } } /** * Returns the account balance up to and inclusive of the supplied date. The * returned balance is converted to the specified commodity. * * @param node The commodity to convert balance to * @param date The inclusive ending date * @return The ending balance */ public BigDecimal getBalance(final LocalDate date, final CurrencyNode node) { transactionLock.readLock().lock(); try { return adjustForExchangeRate(getBalance(date), node); } finally { transactionLock.readLock().unlock(); } } /** * Returns a {@code List} of {@code Transaction} that occur during the specified period. * The specified dates are inclusive. * * @param startDate starting date * @param endDate ending date * @return a {@code List} of transactions that occurred within the specified dates */ public List getTransactions(final LocalDate startDate, final LocalDate endDate) { transactionLock.readLock().lock(); try { return transactions.parallelStream().filter(transaction -> DateUtils.after(transaction.getLocalDate(), startDate) && DateUtils.before(transaction.getLocalDate(), endDate)).sorted().collect(Collectors.toList()); } finally { transactionLock.readLock().unlock(); } } /** * Returns the commodity node for this account * * Note: method may not be final for Hibernate. * * @return the commodity node for this account. */ public CurrencyNode getCurrencyNode() { return currencyNode; } /** * Sets the commodity node for this account. * * Note: method may not be final for Hibernate * * @param node The new commodity node for this account. */ void setCurrencyNode(@NotNull final CurrencyNode node) { Objects.requireNonNull(node); if (!node.equals(currencyNode)) { currencyNode = node; clearCachedBalances(); // cached balances will need to be recalculated } } public boolean isLocked() { return locked; } public void setLocked(final boolean locked) { this.locked = locked; } public boolean isPlaceHolder() { return placeHolder; } public void setPlaceHolder(final boolean placeHolder) { this.placeHolder = placeHolder; } public String getDescription() { return description; } public void setDescription(final String desc) { description = desc; } public synchronized String getName() { return name; } public synchronized void setName(final String newName) { if (!newName.equals(name)) { name = newName; } } public synchronized String getPathName() { final Account parent = getParent(); if (parent != null && parent.getAccountType() != AccountType.ROOT) { return parent.getPathName() + getAccountSeparator() + getName(); } return getName(); // this account is at the root level } public AccountType getAccountType() { return accountType; } /** * Sets the account type * * Note: method may not be final for Hibernate * * @param type new account type */ void setAccountType(final AccountType type) { Objects.requireNonNull(type); if (accountType != null && !accountType.isMutable()) { throw new EngineException("Immutable account type"); } accountType = type; proxy = null; // proxy will need to change } /** * Returns the visibility of the account. * * @return boolean is this account is visible, false otherwise */ public boolean isVisible() { return visible; } /** * Changes the visibility of the account. * * @param visible the new account visibility */ public void setVisible(final boolean visible) { this.visible = visible; } /** * Returns the notes for this account. * * @return the notes for this account */ public String getNotes() { return notes; } /** * Sets the notes for this account. * * @param notes the notes for this account */ public void setNotes(final String notes) { this.notes = notes; } /** * Compares two Account for ordering. Returned sort order is consistent with JPA order. * The account name, and then account UUID is used1 * * @param acc the {@code Account} to be compared. * @return the value {@code 0} if the argument Account is equal to this Account; account * value less than {@code 0} if this Account is before the Account argument; and * account value greater than {@code 0} if this Account is after the Account argument. */ @Override public int compareTo(@NotNull final Account acc) { // Sort by name int result = getName().compareToIgnoreCase(acc.getName()); if (result != 0) { return result; } // Sort of uuid after everything else fails. return getUuid().compareTo(acc.getUuid()); } @Override public String toString() { return name; } @Override public boolean equals(final Object other) { return this == other || other instanceof Account && getUuid().equals(((Account) other).getUuid()); } /** * User definable account code. This can be used to manage sort order * * @return the user defined account code */ public int getAccountCode() { return accountCode; } public void setAccountCode(final int accountCode) { this.accountCode = accountCode; } /** * Returns the account number. A non-null value is guaranteed * * @return the account number */ public String getAccountNumber() { return accountNumber; } public void setAccountNumber(final String account) { accountNumber = account; } /** * Adds account commodity to the list and ensures duplicates are not added. * The list is sorted according to numeric code * * @param node SecurityNode to add * @return true if successful */ boolean addSecurity(final SecurityNode node) { boolean result = false; if (node != null && memberOf(AccountGroup.INVEST) && !containsSecurity(node)) { securities.add(node); result = true; } return result; } /** * Removes a {@code SecurityNode} from the account. If the {@code SecurityNode} is in use by transactions, * removal will be prohibited. * * @param node {@code SecurityNode} to remove * @return {@code true} if successful, {@code false} if used by a transaction or not an active {@code SecurityNode} */ boolean removeSecurity(final SecurityNode node) { securitiesLock.writeLock().lock(); try { boolean result = false; if (!getUsedSecurities().contains(node) && containsSecurity(node)) { securities.remove(node); result = true; } return result; } finally { securitiesLock.writeLock().unlock(); } } public boolean containsSecurity(final SecurityNode node) { securitiesLock.readLock().lock(); try { return securities.contains(node); } finally { securitiesLock.readLock().unlock(); } } /** * Returns the market value of this account. * * @return market value of the account */ public BigDecimal getMarketValue() { return getProxy().getMarketValue(); } /** * Returns a defensive copy of the security set. * * @return a sorted set */ public Set getSecurities() { securitiesLock.readLock().lock(); try { return new TreeSet<>(securities); } finally { securitiesLock.readLock().unlock(); } } /** * Returns a set of used SecurityNodes. * * @return a set of used SecurityNodes */ public Set getUsedSecurities() { transactionLock.readLock().lock(); securitiesLock.readLock().lock(); try { return transactions.parallelStream().filter(t -> t instanceof InvestmentTransaction).map(t -> ((InvestmentTransaction) t).getSecurityNode()).collect(Collectors.toCollection(TreeSet::new)); } finally { securitiesLock.readLock().unlock(); transactionLock.readLock().unlock(); } } /** * Returns the cash balance of this account. * * @return Cash balance of the account */ public BigDecimal getCashBalance() { Lock l = transactionLock.readLock(); l.lock(); try { return getProxy().getCashBalance(); } finally { l.unlock(); } } /** * Returns the depth of the account relative to the {@code RootAccount}. * * @return depth relative to the root */ public int getDepth() { int depth = 0; Account parent = getParent(); while (parent != null) { depth++; parent = parent.getParent(); } return depth; } /** * Shortcut method to check account type. * * @param type AccountType to compare against * @return true if supplied AccountType match */ public final boolean instanceOf(final AccountType type) { return getAccountType() == type; } /** * Shortcut method to check account group membership. * * @param group AccountGroup to compare against * @return true if this account belongs to the supplied group */ public final boolean memberOf(final AccountGroup group) { return getAccountType().getAccountGroup() == group; } public String getBankId() { return bankId; } public void setBankId(final String bankId) { this.bankId = bankId; } public boolean isExcludedFromBudget() { return excludedFromBudget; } public void setExcludedFromBudget(boolean excludeFromBudget) { this.excludedFromBudget = excludeFromBudget; } /** * Amortization object for loan payments. * * @return {@code AmortizeObject} if not null */ @Nullable public AmortizeObject getAmortizeObject() { return amortizeObject; } void setAmortizeObject(final AmortizeObject amortizeObject) { this.amortizeObject = amortizeObject; } /** * Sets an attribute for the {@code Account}. * * @param key the attribute key * @param value the value. If null, the attribute will be removed */ void setAttribute(@NotNull final String key, @Nullable final String value) { attributesLock.writeLock().lock(); try { if (key.isEmpty()) { throw new EngineException("Attribute key may not be empty or null"); } if (value == null) { attributes.remove(key); } else { attributes.put(key, value); } } finally { attributesLock.writeLock().unlock(); } } /** * Returns an {@code Account} attribute. * * @param key the attribute key * @return the attribute if found * @see Engine#setAccountAttribute */ @Nullable String getAttribute(@NotNull final String key) { attributesLock.readLock().lock(); try { if (key.isEmpty()) { throw new EngineException("Attribute key may not be empty or null"); } return attributes.get(key); } finally { attributesLock.readLock().unlock(); } } /** * Provides access to a cached and sorted list of transactions. Direct access to the list * is for internal use only. * * @return List of sorted transactions * @see #getSortedTransactionList */ private List getCachedSortedTransactionList() { // Lazy initialization if (cachedSortedTransactionList == null) { cachedSortedTransactionList = new ArrayList<>(transactions); Collections.sort(cachedSortedTransactionList); } return cachedSortedTransactionList; } /** * Required by XStream for proper initialization. * * @return Properly initialized Account */ protected Object readResolve() { postLoad(); return this; } @PostLoad private void postLoad() { transactionLock = new ReentrantReadWriteLock(true); childLock = new ReentrantReadWriteLock(true); securitiesLock = new ReentrantReadWriteLock(true); attributesLock = new ReentrantReadWriteLock(true); cachedSortedChildren = new ArrayList<>(children); Collections.sort(cachedSortedChildren); // JPA will be naturally sorted, but XML files will not } /** * Accounts should not be cloned. * * @return will result in a CloneNotSupportedException * @throws java.lang.CloneNotSupportedException will always occur */ @Override public Object clone() throws CloneNotSupportedException { super.clone(); throw new CloneNotSupportedException("Accounts may not be cloned"); } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/AccountGroup.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine; import jgnash.resource.util.ResourceUtils; /** * Account Group class. Helps to categorize account types to make reporting easier and consistent. * * @author Craig Cavanaugh */ public enum AccountGroup { ASSET(ResourceUtils.getString("AccountType.Asset")), EQUITY(ResourceUtils.getString("AccountType.Equity")), EXPENSE(ResourceUtils.getString("AccountType.Expense")), INCOME(ResourceUtils.getString("AccountType.Income")), INVEST(ResourceUtils.getString("AccountType.Investment")), LIABILITY(ResourceUtils.getString("AccountType.Liability")), ROOT(ResourceUtils.getString("AccountType.Root")), SIMPLEINVEST(ResourceUtils.getString("AccountType.SimpleInvestment")); // CD's, Treasuries, Etc. private final transient String description; AccountGroup(final String description) { this.description = description; } @Override public String toString() { return description; } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/AccountProxy.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine; import java.math.BigDecimal; import java.time.LocalDate; import java.util.List; import java.util.concurrent.locks.Lock; import jgnash.time.DateUtils; /** * Proxy class to locate account balance behaviors. Depending on account type, summation of transaction types are * handled differently. * * @author Craig Cavanaugh */ class AccountProxy { final Account account; AccountProxy(final Account account) { this.account = account; } /** * Get the balance of all account transactions. * * @return the balance of this account */ public BigDecimal getBalance() { final Lock l = account.getTransactionLock().readLock(); l.lock(); try { BigDecimal balance = BigDecimal.ZERO; for (Transaction transaction : account.getSortedTransactionList()) { balance = balance.add(transaction.getAmount(account)); } return balance; } finally { l.unlock(); } } /** * Get the account balance up to a specified index. * * @param index the balance of this account at the specified index. * @return the balance of this account at the specified index. */ public BigDecimal getBalanceAt(final int index) { final Lock l = account.getTransactionLock().readLock(); l.lock(); try { BigDecimal balance = BigDecimal.ZERO; List transactions = account.getSortedTransactionList(); for (int i = 0; i <= index; i++) { balance = balance.add(transactions.get(i).getAmount(account)); } return balance; } finally { l.unlock(); } } /** * Returns the balance of the transactions inclusive of the start and end dates. * * @param start The inclusive start date * @param end The inclusive end date * @return The ending balance */ public BigDecimal getBalance(final LocalDate start, final LocalDate end) { final Lock l = account.getTransactionLock().readLock(); l.lock(); try { BigDecimal balance = BigDecimal.ZERO; for (final Transaction t : account.getSortedTransactionList()) { final LocalDate d = t.getLocalDate(); if (DateUtils.after(d, start) && DateUtils.before(d, end)) { balance = balance.add(t.getAmount(account)); } } return balance; } finally { l.unlock(); } } /** * Returns the account balance up to and inclusive of the supplied date. * * @param date The inclusive ending date * @return The ending balance */ public BigDecimal getBalance(final LocalDate date) { final Lock l = account.getTransactionLock().readLock(); l.lock(); try { BigDecimal balance = BigDecimal.ZERO; if (!account.transactions.isEmpty()) { balance = getBalance(account.getSortedTransactionList().get(0).getLocalDate(), date); } return balance; } finally { l.unlock(); } } /** * Returns the cash balance of this account. * * @return exception thrown */ public BigDecimal getCashBalance() { throw new UnsupportedOperationException(); } /** * Returns the market value of this account. * * @return exception thrown */ public BigDecimal getMarketValue() { throw new UnsupportedOperationException(); } /** * Calculates the reconciled balance of the account. * * @return the reconciled balance of this account */ public BigDecimal getReconciledBalance() { final Lock l = account.getTransactionLock().readLock(); l.lock(); try { BigDecimal balance = BigDecimal.ZERO; // Use the cached list to avoid ConcurrentModificationException with JPA for (final Transaction t : account.getSortedTransactionList()) { if (t.getReconciled(account) == ReconciledState.RECONCILED) { balance = balance.add(t.getAmount(account)); } } return balance; } finally { l.unlock(); } } /** * Get the default opening balance for reconciling the account. * * @return Opening balance for reconciling the account */ public BigDecimal getOpeningBalanceForReconcile() { final Lock l = account.getTransactionLock().readLock(); l.lock(); try { final LocalDate date = account.getFirstUnreconciledTransactionDate(); final List transactions = account.getSortedTransactionList(); BigDecimal balance = BigDecimal.ZERO; for (int i = 0; i < transactions.size(); i++) { if (transactions.get(i).getLocalDate().equals(date)) { if (i > 0) { balance = getBalanceAt(i - 1); } break; } } return balance; } finally { l.unlock(); } } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/AccountTreeXMLFactory.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine; import com.thoughtworks.xstream.XStream; import com.thoughtworks.xstream.converters.reflection.PureJavaReflectionProvider; import com.thoughtworks.xstream.hibernate.converter.HibernatePersistentCollectionConverter; import com.thoughtworks.xstream.hibernate.converter.HibernatePersistentMapConverter; import com.thoughtworks.xstream.hibernate.converter.HibernateProxyConverter; import com.thoughtworks.xstream.hibernate.mapper.HibernateMapper; import com.thoughtworks.xstream.io.xml.PrettyPrintWriter; import com.thoughtworks.xstream.io.xml.StaxDriver; import com.thoughtworks.xstream.mapper.MapperWrapper; import com.thoughtworks.xstream.security.ArrayTypePermission; import com.thoughtworks.xstream.security.NoTypePermission; import com.thoughtworks.xstream.security.PrimitiveTypePermission; import com.thoughtworks.xstream.security.WildcardTypePermission; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.io.ObjectInputStream; import java.io.ObjectOutputStream; import java.io.Reader; import java.io.Writer; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.time.LocalDate; import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.ResourceBundle; import java.util.Set; import java.util.logging.Level; import java.util.logging.Logger; import jgnash.engine.xstream.XStreamJVM9; import jgnash.resource.util.ClassPathUtils; import jgnash.resource.util.ResourceUtils; /** * Import and export a tree of accounts using XML files. * * @author Craig Cavanaugh */ public class AccountTreeXMLFactory { private static final Charset ENCODING = StandardCharsets.UTF_8; private static final String RESOURCE_ROOT_PATH = "/jgnash/resource/account"; private AccountTreeXMLFactory() { } private static XStream getStream() { final XStreamJVM9 xstream = new XStreamJVM9(new PureJavaReflectionProvider(), new StaxDriver()) { @Override protected MapperWrapper wrapMapper(final MapperWrapper next) { return new HibernateMapper(next); } }; // configure XStream security xstream.addPermission(NoTypePermission.NONE); xstream.addPermission(PrimitiveTypePermission.PRIMITIVES); xstream.addPermission(ArrayTypePermission.ARRAYS); xstream.addPermission(new WildcardTypePermission(new String[] {"java.**", "jgnash.engine.**"})); xstream.ignoreUnknownElements(); // gracefully ignore fields in the file that do not have object members xstream.setMode(XStream.ID_REFERENCES); xstream.alias("date", LocalDate.class); // use date instead of local-date by default xstream.alias("Account", Account.class); xstream.alias("RootAccount", RootAccount.class); xstream.alias("CurrencyNode", CurrencyNode.class); xstream.alias("SecurityNode", SecurityNode.class); xstream.useAttributeFor(Account.class, "placeHolder"); xstream.useAttributeFor(Account.class, "locked"); xstream.useAttributeFor(Account.class, "visible"); xstream.useAttributeFor(Account.class, "name"); xstream.useAttributeFor(Account.class, "description"); xstream.useAttributeFor(CommodityNode.class, "symbol"); xstream.useAttributeFor(CommodityNode.class, "scale"); xstream.useAttributeFor(CommodityNode.class, "prefix"); xstream.useAttributeFor(CommodityNode.class, "suffix"); xstream.useAttributeFor(CommodityNode.class, "description"); xstream.omitField(StoredObject.class, "uuid"); xstream.omitField(StoredObject.class, "markedForRemoval"); // Ignore fields required for JPA xstream.omitField(StoredObject.class, "version"); xstream.omitField(Account.class, "transactions"); xstream.omitField(Account.class, "accountBalance"); xstream.omitField(Account.class, "reconciledBalance"); xstream.omitField(Account.class, "attributes"); xstream.omitField(Account.class, "propertyMap"); xstream.omitField(Account.class, "amortizeObject"); xstream.omitField(SecurityNode.class, "historyNodes"); xstream.omitField(SecurityNode.class, "securityHistoryEvents"); // Filters out the hibernate xstream.registerConverter(new HibernateProxyConverter()); xstream.registerConverter(new HibernatePersistentCollectionConverter(xstream.getMapper())); xstream.registerConverter(new HibernatePersistentMapConverter(xstream.getMapper())); //xstream.registerConverter(new HibernatePersistentSortedMapConverter(xstream.getMapper())); //xstream.registerConverter(new HibernatePersistentSortedSetConverter(xstream.getMapper())); return xstream; } public static void exportAccountTree(final Engine engine, final Path file) { RootAccount account = engine.getRootAccount(); XStream xstream = getStream(); try (final Writer writer = Files.newBufferedWriter(file, ENCODING); final ObjectOutputStream out = xstream.createObjectOutputStream(new PrettyPrintWriter(writer))) { out.writeObject(account); } catch (IOException e) { Logger.getLogger(AccountTreeXMLFactory.class.getName()).log(Level.SEVERE, e.getLocalizedMessage(), e); } } /** * Load an account tree given a reader. * * @param reader Reader to use * @return RootAccount if reader is valid */ private static RootAccount loadAccountTree(final Reader reader) { RootAccount account = null; XStream xstream = getStream(); try (final ObjectInputStream in = xstream.createObjectInputStream(reader)) { final Object o = in.readObject(); if (o instanceof RootAccount) { account = (RootAccount) o; } } catch (IOException | ClassNotFoundException ex) { Logger.getLogger(AccountTreeXMLFactory.class.getName()).log(Level.SEVERE, null, ex); } return account; } /** * Load an account tree given a reader. * * @param file file name to use * @return RootAccount if file name is valid */ public static RootAccount loadAccountTree(final Path file) { RootAccount account = null; try (final Reader reader = Files.newBufferedReader(file, ENCODING)) { account = loadAccountTree(reader); } catch (IOException ex) { Logger.getLogger(AccountTreeXMLFactory.class.getName()).log(Level.SEVERE, null, ex); } return account; } /** * Load an account tree given an InputStream. * * @param stream InputStream to use * @return RootAccount if stream is valid */ private static RootAccount loadAccountTree(final InputStream stream) { try (Reader reader = new InputStreamReader(stream, ENCODING)) { return loadAccountTree(reader); } catch (IOException ex) { Logger.getLogger(AccountTreeXMLFactory.class.getName()).log(Level.SEVERE, null, ex); } return null; } /** * Imports an account tree into the existing account tree. Account * currencies are forced to the engine's default * * @param engine current engine to merge into * @param root root of account structure to merge */ public static void importAccountTree(final Engine engine, final RootAccount root) { AccountImport accountImport = new AccountImport(); accountImport.importAccountTree(engine, root); } /** * Merges an account tree into the existing account tree. Duplicate * currencies are prevented * * @param engine current engine to merge into * @param root root of account structure to merge */ public static void mergeAccountTree(final Engine engine, final RootAccount root) { AccountImport accountImport = new AccountImport(); accountImport.mergeAccountTree(engine, root); } public static Collection getLocalizedAccountSet() { final List files = new ArrayList<>(); for (final String string : getAccountSetList()) { try (final InputStream stream = AccountTreeXMLFactory.class.getResourceAsStream(string)) { final RootAccount account = AccountTreeXMLFactory.loadAccountTree(stream); files.add(account); } catch (final IOException e) { Logger.getLogger(AccountTreeXMLFactory.class.getName()).log(Level.SEVERE, null, e); } } return files; } private static List getAccountSetList() { final String path = ClassPathUtils.getLocalizedPath(RESOURCE_ROOT_PATH); final List set = new ArrayList<>(); if (path != null) { try (final InputStream stream = AccountTreeXMLFactory.class.getResourceAsStream(path + "/set.txt"); final BufferedReader r = new BufferedReader(new InputStreamReader(stream, ENCODING))) { String line = r.readLine(); while (line != null) { set.add(path + "/" + line); line = r.readLine(); } } catch (final IOException ex) { Logger.getLogger(AccountTreeXMLFactory.class.getName()).log(Level.SEVERE, null, ex); } } return set; } private static class AccountImport { // merge map for accounts private final Map mergeMap = new HashMap<>(); private void importAccountTree(final Engine engine, final RootAccount root) { forceCurrency(engine, root); for (Account child : root.getChildren()) { importChildren(engine, child); } } private void mergeAccountTree(final Engine engine, final RootAccount root) { fixCurrencies(engine, root); for (final Account child : root.getChildren()) { importChildren(engine, child); } } /** * Ensures that duplicate currencies are not created when the accounts are merged. * * @param engine Engine with existing currencies * @param account account to correct */ private static void fixCurrencies(final Engine engine, final Account account) { // If an existing currency matches, assign it to the account engine.getCurrencies().stream().filter(currencyNode -> account.getCurrencyNode() .matches(currencyNode)).forEach(account::setCurrencyNode); // Need to persist the currency before the account if it does not exist within the database if (!engine.getCurrencies().contains(account.getCurrencyNode())) { engine.addCurrency(account.getCurrencyNode()); } // match SecurityNodes to prevent duplicates if (account.memberOf(AccountGroup.INVEST)) { final Set nodes = account.getSecurities(); for (final SecurityNode node : nodes) { SecurityNode sNode = engine.getSecurity(node.getSymbol()); if (sNode == null) { // no match found try { sNode = (SecurityNode) node.clone(); for (final CurrencyNode currencyNode : engine.getCurrencies()) { if (sNode.getReportedCurrencyNode().matches(currencyNode)) { sNode.setReportedCurrencyNode(currencyNode); } } if (!engine.addSecurity(sNode)) { final ResourceBundle rb = ResourceUtils.getBundle(); Logger.getLogger(AccountImport.class.getName()).log(Level.SEVERE, rb.getString("Message.Error.SecurityAdd"), sNode.getSymbol()); } } catch (final CloneNotSupportedException e) { Logger.getLogger(AccountImport.class.getName()).log(Level.SEVERE, e.getLocalizedMessage(), e); } } account.removeSecurity(node); account.addSecurity(sNode); } } for (Account child : account.getChildren()) { fixCurrencies(engine, child); } } /** * Ensures that duplicate currencies are not created when the accounts are merged. * * @param engine Engine with existing currencies * @param account account to correct */ private static void forceCurrency(final Engine engine, final Account account) { account.setCurrencyNode(engine.getDefaultCurrency()); // match SecurityNodes to prevent duplicates if (account.memberOf(AccountGroup.INVEST)) { final Set nodes = account.getSecurities(); for (final SecurityNode node : nodes) { SecurityNode sNode = engine.getSecurity(node.getSymbol()); if (sNode == null) { // no match found try { sNode = (SecurityNode) node.clone(); sNode.setReportedCurrencyNode(engine.getDefaultCurrency()); if (!engine.addSecurity(sNode)) { final ResourceBundle rb = ResourceUtils.getBundle(); Logger.getLogger(AccountImport.class.getName()).log(Level.SEVERE, rb.getString("Message.Error.SecurityAdd"), sNode.getSymbol()); } } catch (final CloneNotSupportedException e) { Logger.getLogger(AccountImport.class.getName()).log(Level.SEVERE, e.toString(), e); } } account.removeSecurity(node); account.addSecurity(sNode); } } for (Account child : account.getChildren()) { forceCurrency(engine, child); } } private void importChildren(final Engine engine, final Account account) { // fix the exchange rate DAO if needed engine.attachCurrencyNode(account.getCurrencyNode()); // match RootAccount special case if (account.getParent() instanceof RootAccount) { mergeMap.put(account.getParent(), engine.getRootAccount()); } // search for a pre-existing match Account match = AccountUtils.searchTree(engine.getRootAccount(), account.getName(), account.getAccountType(), account.getDepth()); if (match != null && match.getParent().equals(mergeMap.get(account.getParent()))) { // found a match mergeMap.put(account, match); } else { // the account is unique // place in the merge map mergeMap.put(account, account); Account parent = mergeMap.get(account.getParent()); engine.addAccount(parent, account); } for (final Account child : account.getChildren()) { importChildren(engine, child); } } } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/AccountType.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine; import java.lang.reflect.Constructor; import java.lang.reflect.InvocationTargetException; import java.util.EnumSet; import java.util.Set; import java.util.logging.Level; import java.util.logging.Logger; import jgnash.resource.util.ResourceUtils; /** * Account type enumeration. * * @author Craig Cavanaugh */ public enum AccountType { ASSET(ResourceUtils.getString("AccountType.Asset"), AccountGroup.ASSET, AccountProxy.class, true), BANK(ResourceUtils.getString("AccountType.Bank"), AccountGroup.ASSET, AccountProxy.class, true), CASH(ResourceUtils.getString("AccountType.Cash"), AccountGroup.ASSET, AccountProxy.class, true), CHECKING(ResourceUtils.getString("AccountType.Checking"), AccountGroup.ASSET, AccountProxy.class, true), CREDIT(ResourceUtils.getString("AccountType.Credit"), AccountGroup.LIABILITY, AccountProxy.class, true), EQUITY(ResourceUtils.getString("AccountType.Equity"), AccountGroup.EQUITY, AccountProxy.class, true), EXPENSE(ResourceUtils.getString("AccountType.Expense"), AccountGroup.EXPENSE, AccountProxy.class, true), INCOME(ResourceUtils.getString("AccountType.Income"), AccountGroup.INCOME, AccountProxy.class, true), INVEST(ResourceUtils.getString("AccountType.Investment"), AccountGroup.INVEST, InvestmentAccountProxy.class, false), SIMPLEINVEST(ResourceUtils.getString("AccountType.SimpleInvestment"), AccountGroup.SIMPLEINVEST, AccountProxy.class, true), LIABILITY(ResourceUtils.getString("AccountType.Liability"), AccountGroup.LIABILITY, AccountProxy.class, true), MONEYMKRT(ResourceUtils.getString("AccountType.MoneyMarket"), AccountGroup.ASSET, AccountProxy.class, true), MUTUAL(ResourceUtils.getString("AccountType.Mutual"), AccountGroup.INVEST, InvestmentAccountProxy.class, false), ROOT(ResourceUtils.getString("AccountType.Root"), AccountGroup.ROOT, AccountProxy.class, true); private final transient String description; private final transient AccountGroup accountGroup; private final transient Class accountProxy; private final transient boolean mutable; AccountType(final String description, final AccountGroup accountGroup, final Class accountProxy, final boolean mutable) { this.description = description; this.accountGroup = accountGroup; this.accountProxy = accountProxy; this.mutable = mutable; } /** * Returns all AccountTypes that fit the supplied AccountGroup. * * @param group AccountGroup to match * @return array of AccountTypes that fit the supplied group */ public static Set getAccountTypes(final AccountGroup group) { final Set list = getAccountTypeSet(); list.removeIf(accountType -> accountType.getAccountGroup() != group); return list; } @Override public String toString() { return description; } public AccountGroup getAccountGroup() { return accountGroup; } public boolean isMutable() { return mutable; } private static Set getAccountTypeSet() { Set set = EnumSet.allOf(AccountType.class); set.remove(AccountType.ROOT); return set; } AccountProxy getProxy(final Account account) { try { Class[] constParams = new Class[] { Account.class }; Constructor accConst = accountProxy.getDeclaredConstructor(constParams); Object[] params = new Object[] { account }; return (AccountProxy) accConst.newInstance(params); } catch (final InstantiationException | IllegalAccessException | IllegalArgumentException | InvocationTargetException | NoSuchMethodException | SecurityException ex) { Logger.getLogger(AccountType.class.getName()).log(Level.SEVERE, null, ex); } return null; // unable to create object } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/AccountUtils.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received account copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine; /** * Static account utilities. * * @author Craig Cavanaugh */ class AccountUtils { /** * Searches an account tree given the supplied parameters. * * @param root Base account * @param name Account name * @param type Account type * @param depth Account depth * @return matched account if it exists */ static Account searchTree(final Account root, final String name, final AccountType type, final int depth) { Account match = null; for (final Account a : root.getChildren()) { if (a.getName().equals(name) && a.getAccountType() == type && a.getDepth() == depth) { match = a; } else if (a.getChildCount() > 0) { match = searchTree(a, name, type, depth); } if (match != null) { break; } } return match; } private AccountUtils() { } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/AmortizeObject.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine; import java.io.Serializable; import java.math.BigDecimal; import java.time.LocalDate; import java.util.Objects; import javax.persistence.Entity; import javax.persistence.GeneratedValue; import javax.persistence.GenerationType; import javax.persistence.Id; import javax.persistence.ManyToOne; import javax.persistence.SequenceGenerator; import jgnash.resource.util.ResourceUtils; import jgnash.util.NotNull; import jgnash.util.Nullable; /** * This class is used to calculate loan payments. *

* Because BigDecimal is lacking methods of exponents, calculations are * performed using StrictMath to maintain portability. Results are returned as * doubles. Results will need to be scaled and rounded. * * @author Craig Cavanaugh */ @Entity @SequenceGenerator(name = "sequence", allocationSize = 10) public class AmortizeObject implements Serializable { @SuppressWarnings("unused") @Id @GeneratedValue(generator = "sequence", strategy = GenerationType.SEQUENCE) private long id; @ManyToOne private Account interestAccount; // account for interest payment @ManyToOne private Account bankAccount; // account for principal account // (normally the liability account) @ManyToOne private Account feesAccount; // account to place non interest fees /** * Controls the type of transaction automatically generated */ @SuppressWarnings("unused") private Integer transactionType; /** * the number of payments per year. */ private int numPayments; /** * length of loan in months. */ private int length; /** * the number of compounding periods per year. */ private int numCompPeriods; /** * annual interest rate, APR (ex 6.75). */ private BigDecimal interestRate; /** * original balance of the loan. */ private BigDecimal originalBalance; /** * PMI, escrow, etc. */ private BigDecimal fees = BigDecimal.ZERO; /** * the payee to use. */ private String payee; /** * the memo to use. */ private String memo; // private String checkNumber; // check number to use /** * origination date. */ private LocalDate date = LocalDate.now(); /** * calculate interest based on daily periodic rate. */ private boolean useDailyRate; /** * the number of days per year for daily periodic rate. */ private BigDecimal daysPerYear; /** * Empty constructor to keep reflection happy. */ public AmortizeObject() { } public void setDate(final LocalDate localDate) { date = localDate; } public LocalDate getDate() { return date; } public void setPaymentPeriods(final int periods) { numPayments = periods; } public int getPaymentPeriods() { return numPayments; } /** * Sets the length of the loan (in months). * * @param months length of loan */ public void setLength(final int months) { this.length = months; } /** * Gets the length of the loan in months. * * @return length of loan in months */ public int getLength() { return length; } /** * Determines if interest will be calculate based on a daily periodic rate, * or if it is assumed that the interest is paid exactly on the due date. * * @param daily true if interest should be calculated using a daily rate */ public void setUseDailyRate(final boolean daily) { useDailyRate = daily; } /** * Returns how interest will be calculated. * * @return true if interest is calculated using the daily periodic rate */ public boolean getUseDailyRate() { return useDailyRate; } /** * Sets the number of days per year used to calculate the daily periodic * interest rate. The value can be a decimal. * * @param days The number of days in a year */ public void setDaysPerYear(final BigDecimal days) { daysPerYear = days; } /** * Returns the number of days per year used to calculate a daily periodic * interest rate. * * @return The number of days per year */ public BigDecimal getDaysPerYear() { return daysPerYear; } public void setRate(final BigDecimal rate) { interestRate = rate; } public BigDecimal getRate() { return interestRate; } public void setPrincipal(final BigDecimal principal) { originalBalance = principal; } public BigDecimal getPrincipal() { return originalBalance; } public void setInterestPeriods(final int periods) { numCompPeriods = periods; } public int getInterestPeriods() { return numCompPeriods; } public void setFees(final BigDecimal fees) { this.fees = Objects.requireNonNullElse(fees, BigDecimal.ZERO); } public BigDecimal getFees() { return fees; } /** * Set the id of the interest account. * * @param id the id of the interest account */ public void setInterestAccount(final Account id) { interestAccount = id; } /** * Returns the id of the interest account. * * @return the id of the interest account */ public Account getInterestAccount() { return interestAccount; } /** * Set the id of the principal account. * * @param id the id of the principal account */ public void setBankAccount(final Account id) { bankAccount = id; } /** * Returns the id of the principal account. * * @return the id of the principal account */ public Account getBankAccount() { return bankAccount; } /** * Set the id of the fees account. * * @param id the id of the fees account */ public void setFeesAccount(final Account id) { feesAccount = id; } /** * Returns the id of the fees account. * * @return the id of the fees account */ public Account getFeesAccount() { return feesAccount; } public void setPayee(final String payee) { this.payee = payee; } public String getPayee() { return payee; } public void setMemo(String memo) { this.memo = memo; } public String getMemo() { return memo; } /** * Calculates the effective interest rate.
* Ie = (1 + i/m)^(m/n) - 1
* n = payments per period m = number of times compounded per period * * @return effective interest rate */ private double getEffectiveInterestRate() { if (interestRate != null && numPayments > 0 && numCompPeriods > 0) { double i = interestRate.doubleValue() / 100.0; return StrictMath.pow(1.0 + i / numCompPeriods, (double) numCompPeriods / (double) numPayments) - 1.0; } return 0.0; } /** * Calculates the daily interest rate.
* This works for US, can't find any information on Canada * * @return periodic interest rate */ private double getDailyPeriodicInterestRate() { if (interestRate != null && numPayments > 0 && numCompPeriods > 0 && daysPerYear != null) { double rate = getEffectiveInterestRate(); rate = rate * numPayments; return rate / daysPerYear.doubleValue(); } return 0.0; } // /** // * Calculates the sum of compounded interest and principal // * S = P(1+Ie)^n*term // * // * @return sum with interest // */ // double getSumWithInterest() { // return getPIPayment() * length; // } // public double getTotalInterestPaid() { // return getSumWithInterest() - originalBalance.doubleValue(); // } /** * Calculates the principal and interest payment of an equal payment series * M = P * ( Ie / (1 - (1 + Ie) ^ -N)) N = total number of periods the loan * is amortized over. * * @return P and I */ private double getPIPayment() { // zero interest loan if ((interestRate == null || interestRate.compareTo(BigDecimal.ZERO) == 0) && length > 0 && numPayments > 0 && originalBalance != null) { return originalBalance.doubleValue() / ((length / 12.0) * numPayments); } if (length > 0 && numPayments > 0 && numCompPeriods > 0 && originalBalance != null) { double i = getEffectiveInterestRate(); double p = originalBalance.doubleValue(); return p * (i / (1.0 - StrictMath.pow(1.0 + i, length * -1.0))); } return 0.0; } /** * Calculates the principal and interest plus finance charges. * * @return the payment */ private double getPayment() { return getPIPayment() + fees.doubleValue(); } /** * Calculates the interest portion of the next loan payment given the * remaining loan balance. * * @param balance remaining balance * @return interest */ private double getIPayment(final BigDecimal balance) { if (balance != null) { double i = getEffectiveInterestRate(); return i * balance.doubleValue(); } return 0.0; } /** * Calculates the interest portion of the next loan payment given the * remaining loan balance and the dates between payments. * * @param balance balance * @param start start date * @param end end date * @return interest */ private double getIPayment(final BigDecimal balance, final LocalDate start, final LocalDate end) { if (balance != null) { int dayEnd = end.getDayOfYear(); int dayStart = start.getDayOfYear(); int days = Math.abs(dayEnd - dayStart); double i = getDailyPeriodicInterestRate(); return i * days * balance.doubleValue(); } return 0.0; } // /** // * Calculates the principal portion of the next loan payment given the // * remaining loan balance // * // * @param balance balance // * @return principal // */ // public double getPPayment(BigDecimal balance) { // return getPIPayment() - getIPayment(balance); // } @Nullable public Transaction generateTransaction(@NotNull final Account account, @NotNull final LocalDate date, final String number) { BigDecimal balance = account.getBalance().abs(); double payment = getPayment(); double interest; if (getUseDailyRate()) { LocalDate last; if (account.getTransactionCount() > 0) { last = account.getTransactionAt(account.getTransactionCount() - 1).getLocalDate(); } else { last = date; } interest = getIPayment(balance, last, date); // get the interest portion } else { interest = getIPayment(balance); // get the interest portion } // get debit account final Account bank = getBankAccount(); if (bank != null) { CommodityNode n = bank.getCurrencyNode(); Transaction transaction = new Transaction(); transaction.setDate(date); transaction.setNumber(number); transaction.setPayee(getPayee()); // transaction is made relative to the debit/checking account TransactionEntry entry = new TransactionEntry(); // this entry is the principal payment entry.setCreditAccount(account); entry.setDebitAccount(bank); entry.setAmount(n.round(payment - interest)); entry.setMemo(getMemo()); transaction.addTransactionEntry(entry); // handle interest portion of the payment Account i = getInterestAccount(); if (i != null && interest != 0.0) { entry = new TransactionEntry(); entry.setCreditAccount(i); entry.setDebitAccount(bank); entry.setAmount(n.round(interest)); entry.setMemo(ResourceUtils.getString("Word.Interest")); transaction.addTransactionEntry(entry); } // a fee has been assigned if (getFees().compareTo(BigDecimal.ZERO) != 0) { Account f = getFeesAccount(); if (f != null) { entry = new TransactionEntry(); entry.setCreditAccount(f); entry.setDebitAccount(bank); entry.setAmount(getFees()); entry.setMemo(ResourceUtils.getString("Word.Fees")); transaction.addTransactionEntry(entry); } } return transaction; } return null; } //Creates a payment transaction relative to the liability account /* private void paymentActionLiability() { AmortizeObject ao = ((LiabilityAccount)account).getAmortizeObject(); Transaction tran = null; if (ao != null) { DateChkNumberDialog d = new DateChkNumberDialog(null, engine.getAccount(ao.getInterestAccount())); d.show(); if (!d.getResult()) { return; } BigDecimal balance = account.getBalance().abs(); BigDecimal fees = ao.getFees(); double payment = ao.getPayment(); double interest; if (ao.getUseDailyRate()) { Date today = d.getDate(); Date last = account.getTransactionAt(account.getTransactionCount() - 1).getDate(); interest = ao.getIPayment(balance, last, today); // get the interest portion } else { interest = ao.getIPayment(balance); // get the interest portion } Account b = engine.getAccount(ao.getBankAccount()); if (b != null) { CommodityNode n = b.getCommodityNode(); SplitEntryTransaction e; SplitTransaction t = new SplitTransaction(b.getCommodityNode()); t.setAccount(b); t.setMemo(ao.getMemo()); t.setPayee(ao.getPayee()); t.setNumber(d.getNumber()); t.setDate(d.getDate()); // this entry is the complete payment e = new SplitEntryTransaction(n); e.setCreditAccount(account); e.setDebitAccount(b); e.setAmount(n.round(payment)); e.setMemo(ao.getMemo()); t.addSplit(e); try { // maintain transaction order (stretch time) Thread.sleep(2); } catch (Exception ie) {} // handle interest portion of the payment Account i = engine.getAccount(ao.getInterestAccount()); if (i != null) { e = new SplitEntryTransaction(n); e.setCreditAccount(i); e.setDebitAccount(account); e.setAmount(n.round(interest)); e.setMemo(rb.getString("Word.Interest")); t.addSplit(e); } try { // maintain transaction order (stretch time) Thread.sleep(2); } catch (Exception ie) {} // a fee has been assigned if (ao.getFees().compareTo(new BigDecimal("0")) != 0) { Account f = engine.getAccount(ao.getFeesAccount()); if (f != null) { e = new SplitEntryTransaction(n); e.setCreditAccount(f); e.setDebitAccount(account); e.setAmount(ao.getFees()); e.setMemo(rb.getString("Word.Fees")); t.addSplit(e); } } // the total should be the debit to the checking account tran = t; } } if (tran != null) {// display the transaction in the register newTransaction(tran); } else { // could not generate the transaction if (ao == null) { Logger.getLogger("jgnashEngine").warning("Please configure amortization"); } else { Logger.getLogger("jgnashEngine").warning("Not enough information"); } } }*/ } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/AttachmentUtils.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine; import jgnash.util.FileUtils; import jgnash.util.NotNull; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.Objects; import java.util.logging.Level; import java.util.logging.Logger; /** * Support methods for handling attachments. * * @author Craig Cavanaugh */ public class AttachmentUtils { private static final String ATTACHMENT_BASE = "attachments"; /** * Utility class. */ private AttachmentUtils() { } /** * Creates the attachment directory for the active database. * * @param baseFile base directory for file attachments * @return {@code true} if and only if the directory was created or if * it already exists; {@code false} otherwise */ public static boolean createAttachmentDirectory(final Path baseFile) { boolean result = false; final Path attachmentPath = getAttachmentDirectory(baseFile); if (attachmentPath != null && Files.notExists(attachmentPath)) { try { Files.createDirectories(attachmentPath); result = true; } catch (IOException e) { Logger.getLogger(AttachmentUtils.class.getName()).log(Level.SEVERE, e.getLocalizedMessage(), e); } } else { result = true; } return result; } /** * Returns the default attachment directory for the given base file. * * @param baseFile base file for attachment directory * @return directory for all attachments */ public static Path getAttachmentDirectory(@NotNull final Path baseFile) { Objects.requireNonNull(baseFile); if (baseFile.getParent() != null) { return Paths.get(baseFile.getParent() + FileUtils.SEPARATOR + ATTACHMENT_BASE); } return null; } public static Path getAttachmentPath() { return getAttachmentDirectory(Paths.get(EngineFactory.getActiveDatabase())); } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/CashFlow.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine; import java.math.BigDecimal; import java.time.LocalDate; import java.util.ArrayList; import java.util.List; import java.util.logging.Level; import java.util.logging.Logger; import static java.lang.Math.abs; import static java.time.temporal.ChronoUnit.DAYS; /** * Stores a history of cash flow items and calculates their internal rate of * return. It assumes 365 days per year (Actual/365 Fixed day count convention) * and uses a simple iterative solver. * * @author t-pa * @author Craig Cavanaugh */ public class CashFlow { private static final double DAYS_PER_YEAR = 365; private static final int MAX_ITERATIONS = 1000; private static final double CONVERGENCE = 1.e-5; private static final Logger logger = Logger.getLogger(CashFlow.class.getName()); private static class CashFlowItem { final LocalDate date; final BigDecimal amount; CashFlowItem(final LocalDate date, final BigDecimal amount) { this.date = date; this.amount = amount; } @Override public String toString() { return String.format("[%s, %f]", date.toString(), amount); } } private final List cashFlows = new ArrayList<>(); /** * Add an item to the history of cash flows. * * @param date the date of the cash flow * @param amount the amount; negative for an investment, positive for a payout */ public void add(final LocalDate date, final BigDecimal amount) { cashFlows.add(new CashFlowItem(date, amount)); } /** * Calculate the internal rate of return of the cash flow. If the iterative * solution does not converge, NaN is returned. * * @return an approximation of the (annualized) internal rate of return */ public double internalRateOfReturn() { if (cashFlows.isEmpty()) { return 0.0; } // the reference date is arbitrary, but for better numerical accuracy, // use one of the actual dates in the cash flow history LocalDate referenceDate = cashFlows.get(0).date; double lastRate = 0.0; double lastNPV = netPresentValue(referenceDate, lastRate); double rate = (lastNPV > 0) ? 0.05 : -0.05; // iteratively calculate the IRR with the secant method int i = 0; boolean hasConverged = false; do { double npv = netPresentValue(referenceDate, rate); double newRate = rate - npv * (rate - lastRate) / (npv - lastNPV); lastRate = rate; lastNPV = npv; rate = newRate; i++; if (Double.isNaN(rate)) { // check for failure to converge break; } else if (rate != 0 || lastRate != 0) { hasConverged = abs(rate - lastRate) / (abs(rate) + abs(lastRate)) < CONVERGENCE; } else { hasConverged = true; } } while (!hasConverged && i < MAX_ITERATIONS); if (!hasConverged) { rate = Double.NaN; logger.log(Level.INFO, "IRR calculation did not converge. Data: {0}", cashFlows); } return rate; } /** * Calculate the net present value of the cash flow. * * @param referenceDate the NPV is relative to this date * @param rate the discount rate * @return the net present value */ private double netPresentValue(final LocalDate referenceDate, final double rate) { double npv = 0; for (final CashFlowItem item : cashFlows) { double timeDifference = referenceDate.until(item.date, DAYS) / DAYS_PER_YEAR; npv += item.amount.doubleValue() / StrictMath.pow(1 + rate, timeDifference); } return npv; } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/CommodityNode.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine; import javax.persistence.Entity; import java.math.BigDecimal; import jgnash.util.NotNull; /** * Abstract class for representing a commodity. * * @author Craig Cavanaugh */ @Entity public abstract class CommodityNode extends StoredObject implements Comparable { private String symbol; private byte scale = 2; private String prefix = ""; private String suffix = ""; private String description; public void setScale(final byte scale) { this.scale = scale; } public byte getScale() { return scale; } public void setSymbol(final String symbol) { this.symbol = symbol; } public String getSymbol() { return symbol; } public void setDescription(final String description) { this.description = description; } public String getDescription() { return description; } public void setPrefix(final String prefix) { this.prefix = prefix; } public String getPrefix() { return prefix; } public void setSuffix(final String suffix) { this.suffix = suffix; } public String getSuffix() { return suffix; } @Override public String toString() { if (description != null) { return symbol + " (" + description + ')'; } return symbol; } @Override public int compareTo(@NotNull final CommodityNode node) { return symbol.compareTo(node.symbol); } @Override public boolean equals(final Object other) { return this == other || other instanceof CommodityNode && getUuid().equals(((CommodityNode) other).getUuid()); } /** * Determines if given node matches this node. *

* The UUID is not used for comparison if equals fails. * * @param other CurrencyNode to compare against * @return true if objects match */ public boolean matches(final CommodityNode other) { boolean result = equals(other); if (!result) { result = getSymbol().equals(other.getSymbol()); } return result; } /** * Rounds a supplied double to the correct scale and returns a BigDecimal. * * @param value double to round * @return properly scaled BigDecimal */ public BigDecimal round(final double value) { return new BigDecimal(value).setScale(scale, MathConstants.roundingMode); } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/Comparators.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine; import java.io.Serializable; import java.time.LocalDate; import java.util.*; /** * Utility class consisting of {@code Comparators} useful for sorting lists of {@code StoredObject} * with the same and mixed inheritance. * * @author Craig Cavanaugh */ public class Comparators { public static Comparator getAccountByCode() { return new AccountByCode(); } public static Comparator getAccountByName() { return new AccountByName(); } public static Comparator getAccountByPathName() { return new AccountByPathName(); } public static Comparator getAccountByBalance(LocalDate startDate, LocalDate endDate, CurrencyNode currency, boolean ascending) { return new AccountByBalance(startDate, endDate, currency, ascending); } /** * Sort {@code Account}s according to their position in the account tree. Parent accounts are * sorted before their children. * * @param subComparator defines the sort order of accounts which have the same parent account * @return the {@code Comparator} */ public static Comparator getAccountByTreePosition(Comparator subComparator) { return new AccountByTreePosition(subComparator); } private static class AccountByCode implements Comparator, Serializable { @Override public int compare(final Account a1, final Account a2) { // Sort by account code first int result = Integer.compare(a1.getAccountCode(), a2.getAccountCode()); if (result != 0) { return result; } return a1.getName().compareTo(a2.getName()); } } private static class AccountByName implements Comparator, Serializable { @Override public int compare(final Account a1, final Account a2) { return a1.getName().compareTo(a2.getName()); } } private static class AccountByPathName implements Comparator, Serializable { @Override public int compare(Account a1, Account a2) { return a1.getPathName().compareTo(a2.getPathName()); } } private static class AccountByBalance implements Comparator, Serializable { private final LocalDate startDate; private final LocalDate endDate; private final boolean ascending; private final CurrencyNode currency; AccountByBalance(final LocalDate startDate, final LocalDate endDate, CurrencyNode currency, boolean ascending) { this.startDate = startDate; this.endDate = endDate; this.currency = currency; this.ascending = ascending; } @Override public int compare(Account a1, Account a2) { int result = a1.getBalance(startDate, endDate, currency) .compareTo(a2.getBalance(startDate, endDate, currency)); if (!ascending) { result *= -1; } return result; } } private static class AccountByTreePosition implements Comparator, Serializable { private final Comparator subComparator; AccountByTreePosition(final Comparator subComparator) { this.subComparator = subComparator; } private static Deque accountPath(Account acc) { final Deque path = new LinkedList<>(); while (acc != null) { path.addFirst(acc); acc = acc.getParent(); } return path; } @Override public int compare(final Account a1, final Account a2) { final Deque path1 = accountPath(a1); final Deque path2 = accountPath(a2); // find the first non-common ancestors Account pa1, pa2; do { pa1 = path1.pollFirst(); pa2 = path2.pollFirst(); } while (pa1 != null && pa1.equals(pa2)); if (pa1 == null && pa2 == null) { // this can only happen if a1 equals a2 return 0; } // if one of the paths ended, this is an ancestor of the other one, so sort it first; // otherwise, let the subComparator decide on the same-level ancestors return (pa1 == null) ? -1 : (pa2 == null) ? 1 : subComparator.compare(pa1, pa2); } } /** * Explicit order Comparator. * * @param object type that is being sorted */ public static class ExplicitComparator implements Comparator, Serializable { final List order = new ArrayList<>(); @SafeVarargs public ExplicitComparator(final T... objects) { Collections.addAll(order, objects); } @Override public int compare(final T t1, final T t2) { return Integer.compare(order.indexOf(t1), order.indexOf(t2)); } } private Comparators() { } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/Config.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.ResourceBundle; import java.util.concurrent.locks.ReadWriteLock; import java.util.concurrent.locks.ReentrantReadWriteLock; import javax.persistence.CascadeType; import javax.persistence.Column; import javax.persistence.ElementCollection; import javax.persistence.Entity; import javax.persistence.ManyToOne; import javax.persistence.PostLoad; import jgnash.resource.util.ResourceUtils; import jgnash.util.NotNull; import jgnash.util.Nullable; /** * A general configuration class so that global configuration information may be stored inside the database. * * @author Craig Cavanaugh */ @Entity public class Config extends StoredObject { private static final String CREATE_BACKUPS = "CreateBackups"; private static final String MAX_BACKUPS = "MaxBackups"; private static final String REMOVE_BACKUPS = "RemoveBackups"; private static final String LAST_SECURITIES_UPDATE_TIMESTAMP = "LastSecuritiesUpdateTimestamp"; private static final int MAX_BACKUPS_DEFAULT = 5; @ManyToOne(cascade = CascadeType.PERSIST) private CurrencyNode defaultCurrency; private String accountSeparator = ":"; /** * Current file format */ private String fileFormat = Engine.CURRENT_MAJOR_VERSION + "." + Engine.CURRENT_MINOR_VERSION; private transient ReadWriteLock preferencesLock; /** * Contains a list a items to display in the transaction number combo. */ @ElementCollection private final List transactionNumberItems = new ArrayList<>(); /** * {@code Map} for file based operation preferences. *

* GUI locations, last used accounts, etc * should not be stored here as they will be dependent on the user. * Only values required for operation consistency should be stored here. */ @ElementCollection @Column(columnDefinition = "varchar(8192)") private final Map preferences = new HashMap<>(); public Config() { preferencesLock = new ReentrantReadWriteLock(true); } void initialize() { Account.setAccountSeparator(getAccountSeparator()); } public String getFileFormat() { return fileFormat; } int getMajorFileFormatVersion() { return Integer.parseInt(getFileFormat().split("\\.")[0]); } int getMinorFileFormatVersion() { return Integer.parseInt(getFileFormat().split("\\.")[1]); } void updateFileVersion() { this.fileFormat = Engine.CURRENT_MAJOR_VERSION + "." + Engine.CURRENT_MINOR_VERSION; } void setDefaultCurrency(final CurrencyNode defaultCurrency) { this.defaultCurrency = defaultCurrency; } CurrencyNode getDefaultCurrency() { return defaultCurrency; } void setAccountSeparator(final String accountSeparator) { this.accountSeparator = accountSeparator; Account.setAccountSeparator(this.accountSeparator); } String getAccountSeparator() { return accountSeparator; } void setTransactionNumberList(final List transactionNumberItems) { if (transactionNumberItems != null) { this.transactionNumberItems.clear(); this.transactionNumberItems.addAll(transactionNumberItems); } } List getTransactionNumberList() { if (transactionNumberItems.isEmpty()) { final ResourceBundle rb = ResourceUtils.getBundle(); transactionNumberItems.add(rb.getString("Item.EFT")); transactionNumberItems.add(rb.getString("Item.Trans")); } return new ArrayList<>(transactionNumberItems); } void setPreference(@NotNull final String key, @Nullable final String value) { preferencesLock.writeLock().lock(); try { if (key.isEmpty()) { throw new RuntimeException(ResourceUtils.getString("Message.Error.EmptyKey")); } if (value == null) { // find and remove preferences.remove(key); } else { preferences.put(key, value); } } finally { preferencesLock.writeLock().unlock(); } } @Nullable String getPreference(@NotNull final String key) { preferencesLock.readLock().lock(); try { if (key.isEmpty()) { throw new RuntimeException(ResourceUtils.getString("Message.Error.EmptyKey")); } return preferences.get(key); } finally { preferencesLock.readLock().unlock(); } } boolean createBackups() { final String result = getPreference(CREATE_BACKUPS); return result == null || Boolean.parseBoolean(result); } void setCreateBackups(final boolean createBackups) { setPreference(CREATE_BACKUPS, Boolean.toString(createBackups)); } int getRetainedBackupLimit() { final String result = getPreference(MAX_BACKUPS); if (result != null) { return Integer.parseInt(result); } return MAX_BACKUPS_DEFAULT; } void setRetainedBackupLimit(final int retainedBackupLimit) { setPreference(MAX_BACKUPS, Integer.toString(retainedBackupLimit)); } boolean removeOldBackups() { final String result = getPreference(REMOVE_BACKUPS); return result == null || Boolean.parseBoolean(result); } void setRemoveOldBackups(final boolean removeOldBackups) { setPreference(REMOVE_BACKUPS, Boolean.toString(removeOldBackups)); } void setLastSecuritiesUpdateTimestamp(@NotNull final LocalDateTime localDateTime) { setPreference(LAST_SECURITIES_UPDATE_TIMESTAMP, localDateTime.toString()); } @NotNull LocalDateTime getLastSecuritiesUpdateTimestamp() { final String result = getPreference(LAST_SECURITIES_UPDATE_TIMESTAMP); if (result != null) { return LocalDateTime.parse(result); } return LocalDateTime.MIN; } /** * Required by XStream for proper initialization. * * @return Properly initialized Config object */ protected Object readResolve() { postLoad(); return this; } @PostLoad private void postLoad() { preferencesLock = new ReentrantReadWriteLock(true); } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/CurrencyNode.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine; import java.math.BigDecimal; import java.util.logging.Logger; import javax.persistence.Entity; /** * Class for representing currency nodes. * * @author Craig Cavanaugh */ @Entity public class CurrencyNode extends CommodityNode { private transient ExchangeRateDAO exchangeRateDAO; public CurrencyNode() { } /** * Returns the {@code ExchangeRateDAO}. * * @return the exchangeRateStore */ synchronized private ExchangeRateDAO getExchangeRateDAO() { return exchangeRateDAO; } /** * Sets the {@code ExchangeRateDAO}. * * @param exchangeRateStore the exchangeRateStore to set */ synchronized void setExchangeRateDAO(final ExchangeRateDAO exchangeRateStore) { this.exchangeRateDAO = exchangeRateStore; } /** * Returns an exchange rate given a currency to convert to. * * @param exchangeCurrency currency to convert to * @return exchange rate */ synchronized public BigDecimal getExchangeRate(final CurrencyNode exchangeCurrency) { if (exchangeCurrency == null) { Logger.getLogger(CurrencyNode.class.getName()).severe("exchangeCurrency was null"); return BigDecimal.ONE; } if (exchangeCurrency.equals(this)) { return BigDecimal.ONE; } BigDecimal rate = getExchangeRateDAO().getExchangeRateNode(this, exchangeCurrency).getRate(); if (getSymbol().compareToIgnoreCase(exchangeCurrency.getSymbol()) < 0) { rate = BigDecimal.ONE.divide(rate, MathConstants.mathContext); } return rate; } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/DataStore.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.Collection; import java.util.function.DoubleConsumer; import jgnash.util.NotNull; /** * Interface for data storage backends. * * @author Craig Cavanaugh */ public interface DataStore { /** * Close the engine instance if open. */ void closeEngine(); /** * Create an engine instance connected to a remote server. * * @param host host name or IP address * @param port connection port * @param password user password * @param engineName unique name to give the engine instance * @return Engine instance if a successful connection is made */ Engine getClientEngine(final String host, final int port, final char[] password, final String engineName); /** * Create an engine instance that uses a file. * * @param fileName full path to the file * @param engineName unique name to give the engine instance * @param password user password * @return Engine instance. A new file will be created if it does not exist */ Engine getLocalEngine(final String fileName, final String engineName, final char[] password); /** * Returns the default file extension for this DataStore. * * @return file extension */ @NotNull String getFileExt(); /** * Returns the full path to the file the DataStore is using. * * @return full path to the file, null if this is a remotely connected DataStore */ String getFileName(); /** * Returns this DataStores type. * * @return type of data store */ DataStoreType getType(); /** * Local / Remote connection indicator. * * @return false if connected to a remote server */ boolean isLocal(); /** * Saves a Collection of StoredObjects to a file other than what is currently open. *

* The currently open file will not be closed. * @param path full path to the file to save the database to * @param objects Collection of StoredObjects to save * @param percentComplete callback to report the percent complete */ void saveAs(Path path, Collection objects, DoubleConsumer percentComplete); /** * Renames a datastore. * * @param fileName name of the datastore to rename * @param newFileName the new filename * @throws java.io.IOException if an I/O error occurs */ default void rename(final String fileName, final String newFileName) throws IOException { final Path path = Paths.get(fileName); if (Files.exists(path)) { Files.move(path, Paths.get(newFileName)); } } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/DataStoreType.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine; import jgnash.engine.jpa.JpaH2DataStore; import jgnash.engine.jpa.JpaH2MvDataStore; import jgnash.engine.jpa.JpaHsqlDataStore; import jgnash.engine.xstream.BinaryXStreamDataStore; import jgnash.engine.xstream.XMLDataStore; import jgnash.resource.util.ResourceUtils; import java.lang.reflect.InvocationTargetException; import java.util.logging.Level; import java.util.logging.Logger; /** * Storage type enumeration. * * @author Craig Cavanaugh */ public enum DataStoreType { BINARY_XSTREAM( ResourceUtils.getString("DataStoreType.Bxds"), false, BinaryXStreamDataStore.class), H2_DATABASE ( ResourceUtils.getString("DataStoreType.H2") + " (1.3)", true, JpaH2DataStore.class), H2MV_DATABASE ( ResourceUtils.getString("DataStoreType.H2") + " (1.4)", true, JpaH2MvDataStore.class), HSQL_DATABASE ( ResourceUtils.getString("DataStoreType.HSQL"), true, JpaHsqlDataStore.class), XML( ResourceUtils.getString("DataStoreType.XML"), false, XMLDataStore.class); /* If true, then this DataStoreType can support remote connections */ public final transient boolean supportsRemote; private final transient String description; private final transient Class dataStore; DataStoreType(final String description, final boolean supportsRemote, final Class dataStore) { this.description = description; this.supportsRemote = supportsRemote; this.dataStore = dataStore; } public DataStore getDataStore() { try { return dataStore.getDeclaredConstructor().newInstance(); } catch (InstantiationException | IllegalAccessException | IllegalArgumentException | InvocationTargetException | NoSuchMethodException | SecurityException ex) { Logger.getLogger(DataStoreType.class.getName()).log(Level.SEVERE, null, ex); throw new RuntimeException(ex); } } @Override public String toString() { return description; } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/DefaultCurrencies.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2021 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine; import java.text.DecimalFormatSymbols; import java.text.NumberFormat; import java.util.Currency; import java.util.Locale; import java.util.Set; import java.util.TreeSet; import java.util.logging.Level; import java.util.logging.Logger; /** * Static methods for currency generation and discovery. *

* These are known to not show up because Java 1.4.2 and older does not have * a default NumberFormat defined for the currency: *

* {@code * "SGD" * "MYR" * } * * @author Craig Cavanaugh */ public class DefaultCurrencies { /** * Private Constructor, use static methods only. */ private DefaultCurrencies() { } /** * Generates an array of default currency nodes that Java knows about. * * @return An array of default CurrencyNodes */ public static Set generateCurrencies() { TreeSet set = new TreeSet<>(); for (Locale locale : NumberFormat.getAvailableLocales()) { // only try if a valid county length is returned if (locale.getCountry().length() == 2) { try { if (Currency.getInstance(locale) != null) { set.add(buildNode(locale)); } } catch (final IllegalArgumentException ignored) { // ignored, locale is not a supported ISO 3166 country code } catch (final Exception ex) { Logger.getLogger(DefaultCurrencies.class.getName()).log(Level.SEVERE, null, ex); } } } return set; } /** * Creates a custom CurrencyNode given an ISO code. If the ISO code is * not valid, then a node will be generated using the supplied code. The * locale is assumed to be the default locale. * * @param ISOCode The custom currency to generate * @return The custom CurrencyNode */ public static CurrencyNode buildCustomNode(final String ISOCode) { final CurrencyNode node = new CurrencyNode(); Currency c; try { c = Currency.getInstance(ISOCode); node.setSymbol(c.getCurrencyCode()); } catch (Exception e) { node.setSymbol(ISOCode); Logger.getLogger(DefaultCurrencies.class.getName()).log(Level.FINE, null, e); } finally { node.setDescription(Locale.getDefault().toString()); } return node; } /** * Creates a valid CurrencyNode given a locale. * * @param locale Locale to create a CurrencyNode for * @return The new CurrencyNode */ public static CurrencyNode buildNode(final Locale locale) { DecimalFormatSymbols symbols = new DecimalFormatSymbols(locale); Currency c = symbols.getCurrency(); CurrencyNode node = new CurrencyNode(); node.setSymbol(c.getCurrencyCode()); node.setPrefix(symbols.getCurrencySymbol()); byte scale = (byte) c.getDefaultFractionDigits(); if (scale == -1) { // The JVM may return a negative value for some Locales scale = 0; // scale may be -1, but this is not allowed for CurrencyNodes } node.setScale(scale); return node; } /** * Generates the default CurrencyNode for the current locale. * * @return The new CurrencyNode */ public static CurrencyNode getDefault() { try { return buildNode(Locale.getDefault()); } catch (final Exception e) { return buildNode(Locale.US); } } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/Engine.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine; import java.io.IOException; import java.math.BigDecimal; import java.nio.file.Path; import java.time.DayOfWeek; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Objects; import java.util.Optional; import java.util.ResourceBundle; import java.util.Set; import java.util.UUID; import java.util.concurrent.Callable; import java.util.concurrent.CompletionService; import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorCompletionService; import java.util.concurrent.ExecutorService; import java.util.concurrent.Future; import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ScheduledThreadPoolExecutor; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.locks.ReentrantReadWriteLock; import java.util.function.Function; import java.util.logging.Handler; import java.util.logging.Level; import java.util.logging.Logger; import java.util.stream.Collectors; import java.util.stream.Stream; import jgnash.engine.attachment.AttachmentManager; import jgnash.engine.budget.Budget; import jgnash.engine.budget.BudgetGoal; import jgnash.engine.concurrent.LockManager; import jgnash.engine.dao.AccountDAO; import jgnash.engine.dao.BudgetDAO; import jgnash.engine.dao.CommodityDAO; import jgnash.engine.dao.ConfigDAO; import jgnash.engine.dao.EngineDAO; import jgnash.engine.dao.RecurringDAO; import jgnash.engine.dao.TransactionDAO; import jgnash.engine.dao.TrashDAO; import jgnash.engine.message.ChannelEvent; import jgnash.engine.message.Message; import jgnash.engine.message.MessageBus; import jgnash.engine.message.MessageChannel; import jgnash.engine.message.MessageProperty; import jgnash.engine.recurring.MonthlyReminder; import jgnash.engine.recurring.PendingReminder; import jgnash.engine.recurring.RecurringIterator; import jgnash.engine.recurring.Reminder; import jgnash.net.currency.CurrencyUpdateFactory; import jgnash.net.security.UpdateFactory; import jgnash.resource.util.ResourceUtils; import jgnash.time.DateUtils; import jgnash.util.DefaultDaemonThreadFactory; import jgnash.util.NotNull; import jgnash.util.Nullable; import org.apache.commons.collections4.ListUtils; /** * Engine class *

* When objects are removed, they are wrapped in a TrashObject so they may still be referenced for messaging and cleanup * operations. After a predefined period of time, they are permanently removed. * * @author Craig Cavanaugh */ public class Engine { /** * Current version for the file format. */ public static final int CURRENT_MAJOR_VERSION = 3; public static final int CURRENT_MINOR_VERSION = 6; // Lock name private static final String BIG_LOCK = "bigLock"; private static final Logger logger = Logger.getLogger(Engine.class.getName()); private static final long MAXIMUM_TRASH_AGE = 2L * 60L * 1000L; // 2 minutes /** * The maximum number of network errors before scheduled tasks are stopped. */ private static final short MAX_ERRORS = 2; /** * Time in seconds to delay start of background updates. */ private static final int SCHEDULED_DELAY = 30; /** * Time is seconds for a forced shutdown of background services */ private static final int FORCED_SHUTDOWN_TIMEOUT = 15; private static final String MESSAGE_ACCOUNT_MODIFY = "Message.AccountModify"; private static final String COMMODITY = "Commodity "; static { logger.setLevel(Level.ALL); } private final ResourceBundle rb = ResourceUtils.getBundle(); /** * Primary lock for any operation that alters or reads data */ private final ReentrantReadWriteLock dataLock; private final AtomicInteger backGroundCounter = new AtomicInteger(); /** * Named identifier for this engine instance. */ private final String name; /** * Unique identifier for this engine instance. * Used by this distributed lock manager to keep track of who has a lock */ private final String uuid = UUID.randomUUID().toString(); private final EngineDAO eDAO; private final AttachmentManager attachmentManager; /** * Background executor service for trash management and currency / security updates */ private final ScheduledThreadPoolExecutor backgroundExecutorService; /** * All engine instances will share the same message bus. */ private final MessageBus messageBus; /** * Cached for performance. */ private Config config; /** * Cached for performance. */ private RootAccount rootAccount; private ExchangeRateDAO exchangeRateDAO; /** * Cached for performance. */ private String accountSeparator = null; public Engine(final EngineDAO eDAO, final LockManager lockManager, final AttachmentManager attachmentManager, final String name) { Objects.requireNonNull(name, "The engine name may not be null"); Objects.requireNonNull(eDAO, "The engineDAO may not be null"); logger.log(Level.INFO, "Release {0}.{1}", new Object[]{CURRENT_MAJOR_VERSION, CURRENT_MINOR_VERSION}); this.attachmentManager = attachmentManager; this.eDAO = eDAO; this.name = name; // Generate lock dataLock = lockManager.getLock(BIG_LOCK); messageBus = MessageBus.getInstance(name); initialize(); checkAndCorrect(); backgroundExecutorService = new ScheduledThreadPoolExecutor(1, new DefaultDaemonThreadFactory("Engine Background Executor")); backgroundExecutorService.setRemoveOnCancelPolicy(true); backgroundExecutorService.setExecuteExistingDelayedTasksAfterShutdownPolicy(false); // run trash cleanup every 5 minutes 45 seconds after startup backgroundExecutorService.scheduleWithFixedDelay(() -> { if (!Thread.currentThread().isInterrupted()) { emptyTrash(); } }, 45, 5L * 60L, TimeUnit.SECONDS); backgroundExecutorService.schedule(() -> { if (UpdateFactory.getUpdateOnStartup()) { // don't update on weekends unless needed if (UpdateFactory.shouldAutomaticUpdateOccur(getConfig().getLastSecuritiesUpdateTimestamp())) { startSecuritiesUpdate(SCHEDULED_DELAY); } } }, 30, TimeUnit.SECONDS); backgroundExecutorService.schedule(() -> { if (CurrencyUpdateFactory.getUpdateOnStartup()) { startExchangeRateUpdate(SCHEDULED_DELAY); } }, 30, TimeUnit.SECONDS); } boolean isFileDirty() { return eDAO.isDirty() || getAccountDAO().isDirty() || getBudgetDAO().isDirty() || getCommodityDAO().isDirty() || getConfigDAO().isDirty() || getReminderDAO().isDirty() || getTransactionDAO().isDirty() || getTrashDAO().isDirty(); } /** * Registers a {@code Handler} with the class logger. * This also ensures the static logger is initialized. * * @param handler {@code Handler} to register */ public static void addLogHandler(final Handler handler) { logger.addHandler(handler); } /** * Returns the most current known market price for a requested date. The {@code SecurityNode} history will be * searched for an exact match first. If an exact match is not found, investment transactions will be searched * for the closest requested date. {@code SecurityHistoryNode} history values will take precedent over * a transaction with the same closest or matching date. * * @param transactions Collection of transactions utilizing the requested investment * @param node {@code SecurityNode} we want a price for * @param baseCurrency {@code CurrencyNode} reporting currency * @param localDate {@code LocalDate} we want a market price for * @return The best market price or a value of 0 if no history or transactions exist */ public static BigDecimal getMarketPrice(final Collection transactions, final SecurityNode node, final CurrencyNode baseCurrency, final LocalDate localDate) { // Search for the exact history node record Optional optional = node.getHistoryNode(localDate); // not null, must be an exact match, return the value because it has precedence if (optional.isPresent()) { return node.getMarketPrice(localDate, baseCurrency); } // Nothing found yet, continue searching for something better LocalDate priceDate = LocalDate.ofEpochDay(0); BigDecimal price = BigDecimal.ZERO; optional = node.getClosestHistoryNode(localDate); if (optional.isPresent()) { // Closest option so far price = optional.get().getPrice(); priceDate = optional.get().getLocalDate(); } // Compare against transactions for (final Transaction t : transactions) { if (t instanceof InvestmentTransaction && ((InvestmentTransaction) t).getSecurityNode() == node) { // The transaction date must be closer than the history node, but not newer than the request date if ((t.getLocalDate().isAfter(priceDate) && t.getLocalDate().isBefore(localDate)) || t.getLocalDate().equals(localDate)) { // Check for a dividend, etc that may have returned a price of zero final BigDecimal p = ((InvestmentTransaction) t).getPrice(); if (p != null && p.compareTo(BigDecimal.ZERO) > 0) { price = p; priceDate = t.getLocalDate(); } } } } // Get the current exchange rate for the security node final BigDecimal rate = node.getReportedCurrencyNode().getExchangeRate(baseCurrency); // return the price and factor in the exchange rate return price.multiply(rate); } static String buildExchangeRateId(final CurrencyNode baseCurrency, final CurrencyNode exchangeCurrency) { String rateId; if (baseCurrency.getSymbol().compareToIgnoreCase(exchangeCurrency.getSymbol()) > 0) { rateId = baseCurrency.getSymbol() + exchangeCurrency.getSymbol(); } else { rateId = exchangeCurrency.getSymbol() + baseCurrency.getSymbol(); } return rateId; } /** * Returns the engine logger. * * @return the engine logger */ public static Logger getLogger() { return logger; } /** * Log a informational message. * * @param message message to display */ private static void logInfo(final String message) { logger.log(Level.INFO, message); } /** * Log a warning message. * * @param message message to display */ private static void logWarning(final String message) { logger.warning(message); } /** * Log a severe message. * * @param message message to display */ private static void logSevere(final String message) { logger.severe(message); } private static void shutDownAndWait(final ExecutorService executorService) { executorService.shutdownNow(); try { if (!executorService.awaitTermination(FORCED_SHUTDOWN_TIMEOUT, TimeUnit.SECONDS)) { if (!executorService.awaitTermination(FORCED_SHUTDOWN_TIMEOUT, TimeUnit.SECONDS)) { logSevere("Unable to shutdown background service"); } } } catch (final InterruptedException e) { executorService.shutdownNow(); Thread.currentThread().interrupt(); logger.log(Level.FINEST, e.getLocalizedMessage(), e); } } /** * Initiates a background exchange rate update with a given start delay. * * @param delay delay in seconds */ public void startExchangeRateUpdate(final int delay) { backgroundExecutorService.schedule(new BackgroundCallable(new CurrencyUpdateFactory.UpdateExchangeRatesCallable()), delay, TimeUnit.SECONDS); } /** * Initiates a background securities history update with a given start delay. * * @param delay delay in seconds */ public void startSecuritiesUpdate(final int delay) { final List callables = new ArrayList<>(); getSecurities().stream().filter(securityNode -> securityNode.getQuoteSource() != QuoteSource.NONE).forEach(securityNode -> { // failure will occur if source is not defined callables.add(new BackgroundCallable(new UpdateFactory.UpdateSecurityNodeCallable(securityNode))); callables.add(new BackgroundCallable(new UpdateFactory.UpdateSecurityNodeEventsCallable(securityNode))); }); // Cleanup thread that monitors for excess network connection failures new SecuritiesUpdateRunnable(callables, delay).start(); // Save the last update config.setLastSecuritiesUpdateTimestamp(LocalDateTime.now()); } /** * Creates a RootAccount and default currency only if necessary. */ private void initialize() { dataLock.writeLock().lock(); try { // ask the Config object to perform any needed configuration getConfig().initialize(); // build the exchange rate storage object exchangeRateDAO = new ExchangeRateDAO(getCommodityDAO()); // assign the exchange rate store to the currencies for (final CurrencyNode node : getCurrencies()) { node.setExchangeRateDAO(exchangeRateDAO); } // obtain or establish the root account RootAccount root = getRootAccount(); if (root == null) { CurrencyNode node = getDefaultCurrency(); if (node == null) { node = DefaultCurrencies.getDefault(); node.setExchangeRateDAO(exchangeRateDAO); addCurrency(node); // force the node to persisted } root = new RootAccount(node); root.setName(rb.getString("Name.Root")); root.setDescription(rb.getString("Name.Root")); logInfo("Creating RootAccount"); if (!getAccountDAO().addRootAccount(root)) { logSevere("Was not able to add the root account"); throw new EngineException("Was not able to add the root account"); } if (getDefaultCurrency() == null) { setDefaultCurrency(node); } } } finally { dataLock.writeLock().unlock(); } logInfo("Engine initialization is complete"); } /** * Corrects minor issues with a database that may occur because of prior bugs or file format upgrades. */ private void checkAndCorrect() { dataLock.writeLock().lock(); try { // check and correct multiple root accounts from old files... there are still a few. List accountList = getAccountList().stream().filter(account -> account.getAccountType() .equals(AccountType.ROOT)).collect(Collectors.toList()); if (accountList.size() > 1) { for (Account account : accountList) { if (account.getChildCount() == 0) { removeAccount(account); logWarning("Removed an extra / empty root account"); } } } final List list = eDAO.getStoredObjects(Config.class); if (list.size() > 1) { // Delete all but the first found config object for (int i = 1; i < list.size(); i++) { logWarning("Removed an extra Config object"); moveObjectToTrash(list.get(i)); } } // Transaction timestamps were updated for release 2.25 if (getConfig().getMinorFileFormatVersion() < 25 && getConfig().getMajorFileFormatVersion() < 3) { // Update transactions in chunks of 200 ListUtils.partition(getTransactions(), 200).forEach(eDAO::bulkUpdate); } // update the file version if it is not current if (getConfig().getMajorFileFormatVersion() != CURRENT_MAJOR_VERSION || getConfig().getMinorFileFormatVersion() != CURRENT_MINOR_VERSION) { final Config localConfig = getConfig(); localConfig.updateFileVersion(); getConfigDAO().update(localConfig); } } finally { dataLock.writeLock().unlock(); } } private void clearObsoleteExchangeRates() { getCommodityDAO().getExchangeRates().stream() .filter(rate -> getBaseCurrencies(rate.getRateId()).length == 0) .forEach(this::removeExchangeRate); } private void removeExchangeRate(final ExchangeRate rate) { dataLock.writeLock().lock(); try { for (final ExchangeRateHistoryNode node : rate.getHistory()) { removeExchangeRateHistory(rate, node); } moveObjectToTrash(rate); } finally { dataLock.writeLock().unlock(); } } void stopBackgroundServices() { logInfo("Controlled engine shutdown initiated"); shutDownAndWait(backgroundExecutorService); logInfo("Background services have been stopped"); } void shutdown() { eDAO.shutdown(); } public String getName() { return name; } private AccountDAO getAccountDAO() { return eDAO.getAccountDAO(); } private BudgetDAO getBudgetDAO() { return eDAO.getBudgetDAO(); } private CommodityDAO getCommodityDAO() { return eDAO.getCommodityDAO(); } private ConfigDAO getConfigDAO() { return eDAO.getConfigDAO(); } private RecurringDAO getReminderDAO() { return eDAO.getRecurringDAO(); } private TransactionDAO getTransactionDAO() { return eDAO.getTransactionDAO(); } private TrashDAO getTrashDAO() { return eDAO.getTrashDAO(); } private boolean moveObjectToTrash(final Object object) { boolean result = false; dataLock.writeLock().lock(); try { if (object instanceof StoredObject) { getTrashDAO().add(new TrashObject((StoredObject) object)); } else { // simple object with an annotated JPA entity id of type long is assumed getTrashDAO().addEntityTrash(object); } result = true; } catch (final Exception ex) { logger.log(Level.SEVERE, ex.getLocalizedMessage(), ex); } finally { dataLock.writeLock().unlock(); } return result; } /** * Empty the trash if any objects are older than the defined time. */ private void emptyTrash() { if (backGroundCounter.incrementAndGet() == 1) { messageBus.fireEvent(new Message(MessageChannel.SYSTEM, ChannelEvent.BACKGROUND_PROCESS_STARTED, Engine.this)); } dataLock.writeLock().lock(); try { logger.info("Checking for trash"); final List trash = getTrashDAO().getTrashObjects(); /* always sort by the timestamp of the trash object to prevent * foreign key removal exceptions when multiple related accounts * or objects are removed */ Collections.sort(trash); if (trash.isEmpty()) { logger.info("No trash was found"); } trash.stream().filter(o -> ChronoUnit.MILLIS.between(o.getDate(), LocalDateTime.now()) >= MAXIMUM_TRASH_AGE) .forEach(o -> getTrashDAO().remove(o)); } finally { dataLock.writeLock().unlock(); if (backGroundCounter.decrementAndGet() == 0) { messageBus.fireEvent(new Message(MessageChannel.SYSTEM, ChannelEvent.BACKGROUND_PROCESS_STOPPED, Engine.this)); } } } /** * Creates a default reminder given a transaction and the primary account. The Reminder will need to persisted. * * @param transaction Transaction for the reminder. The transaction will be cloned * @param account primary account * @return new default {@code MonthlyReminder} */ public static Reminder createDefaultReminder(final Transaction transaction, final Account account) { final Reminder reminder = new MonthlyReminder(); try { reminder.setAccount(account); reminder.setStartDate(transaction.getLocalDate().plusMonths(1)); reminder.setTransaction((Transaction) transaction.clone()); reminder.setDescription(transaction.getPayee()); reminder.setNotes(transaction.getMemo()); } catch (final CloneNotSupportedException e) { logSevere(e.getLocalizedMessage()); } return reminder; } public boolean addReminder(final Reminder reminder) { Objects.requireNonNull(reminder.getUuid()); boolean result = false; // make sure the description has been set if (reminder.getDescription() != null && !reminder.getDescription().isBlank()) { result = getReminderDAO().addReminder(reminder); } Message message; if (result) { message = new Message(MessageChannel.REMINDER, ChannelEvent.REMINDER_ADD, this); } else { message = new Message(MessageChannel.REMINDER, ChannelEvent.REMINDER_ADD_FAILED, this); } message.setObject(MessageProperty.REMINDER, reminder); messageBus.fireEvent(message); return result; } public boolean removeReminder(final Reminder reminder) { boolean result = false; if (moveObjectToTrash(reminder)) { if (reminder.getTransaction() != null) { moveObjectToTrash(reminder.getTransaction()); reminder.setTransaction(null); } Message message = new Message(MessageChannel.REMINDER, ChannelEvent.REMINDER_REMOVE, this); message.setObject(MessageProperty.REMINDER, reminder); messageBus.fireEvent(message); result = true; } return result; } /** * Returns a list of reminders. * * @return List of reminders */ public List getReminders() { return getReminderDAO().getReminderList(); } public Reminder getReminderByUuid(final UUID uuid) { return getReminderDAO().getReminderByUuid(uuid); } public List getPendingReminders() { final ArrayList pendingList = new ArrayList<>(); final List list = getReminders(); final LocalDate now = LocalDate.now(); // today's date for (final Reminder r : list) { if (r.isEnabled()) { final RecurringIterator ri = r.getIterator(); LocalDate next = ri.next(); while (next != null) { LocalDate date = next; if (r.isAutoCreate()) { date = date.minusDays(r.getDaysAdvance()); } if (DateUtils.before(date, now)) { // need to fire this reminder pendingList.add(new PendingReminder(r, next)); next = ri.next(); } else { next = null; } } } } return pendingList; } public static PendingReminder getPendingReminder(@NotNull Reminder reminder) { final RecurringIterator ri = reminder.getIterator(); LocalDate next = ri.next(); if (next != null) { return new PendingReminder(reminder, next); } return null; } public void processPendingReminders(final Collection pendingReminders) { pendingReminders.stream().filter(PendingReminder::isApproved).forEach(pending -> { final Reminder reminder = pending.getReminder(); if (reminder.getTransaction() != null) { // add the transaction final Transaction t = reminder.getTransaction(); // Update to the commit date (commit date can be modified) t.setDate(pending.getCommitDate()); addTransaction(t); } // update the last fired date... date returned from the iterator reminder.setLastDate(); // mark as complete if (!updateReminder(reminder)) { logSevere(rb.getString("Message.Error.ReminderUpdate")); } }); } public T getStoredObjectByUuid(final Class tClass, final UUID uuid) { return eDAO.getObjectByUuid(tClass, uuid); } /** * Returns a {@code Collection} of all {@code StoredObjects} in a consistent order. * {@code StoredObjects} marked for removal and {@code TrashObjects} are filtered from the collection. * * @return {@code Collection} of {@code StoredObjects} * @see Collection * @see StoredObjectComparator */ public Collection getStoredObjects() { dataLock.readLock().lock(); try { List objects = eDAO.getStoredObjects(); // Filter out objects to be removed objects.removeIf(TrashObject.class::isInstance); objects.sort(new StoredObjectComparator()); return objects; } finally { dataLock.readLock().unlock(); } } /** * Validate a CommodityNode for correctness. * * @param node CommodityNode to validate * @return true if valid */ private boolean isCommodityNodeValid(final CommodityNode node) { boolean result = true; if (node.getUuid() == null) { result = false; logSevere("Commodity uuid was not valid"); } if (node.getSymbol() == null || node.getSymbol().isEmpty()) { result = false; logSevere("Commodity symbol was not valid"); } if (node.getScale() < 0) { result = false; logSevere(COMMODITY + node + " had a scale less than zero"); } if (node instanceof SecurityNode && ((SecurityNode) node).getReportedCurrencyNode() == null) { result = false; logSevere(COMMODITY + node + " was not assigned a currency"); } // ensure the UUID being used is unique if (eDAO.getObjectByUuid(CommodityNode.class, node.getUuid()) != null) { result = false; logSevere(COMMODITY + node + " was not unique"); } return result; } /** * Adds a new CurrencyNode to the data set. *

* Checks and prevents the addition of a duplicate Currencies. * * @param node new CurrencyNode to add * @return {@code true} if the add it successful */ public boolean addCurrency(final CurrencyNode node) { dataLock.writeLock().lock(); try { boolean status = isCommodityNodeValid(node); if (status) { node.setExchangeRateDAO(exchangeRateDAO); if (getCurrency(node.getSymbol()) != null) { logger.log(Level.INFO, "Prevented addition of a duplicate CurrencyNode: {0}", node.getSymbol()); status = false; } } if (status) { status = getCommodityDAO().addCommodity(node); logger.log(Level.FINE, "Adding: {0}", node); } Message message; if (status) { message = new Message(MessageChannel.COMMODITY, ChannelEvent.CURRENCY_ADD, this); } else { message = new Message(MessageChannel.COMMODITY, ChannelEvent.CURRENCY_ADD_FAILED, this); } message.setObject(MessageProperty.COMMODITY, node); messageBus.fireEvent(message); return status; } finally { dataLock.writeLock().unlock(); } } /** * Links the {@code CurrencyNode} to the exchange DAO. * Support method to be used during import operations * * @param currencyNode {@code CurrencyNode} to link */ void attachCurrencyNode(final CurrencyNode currencyNode) { currencyNode.setExchangeRateDAO(exchangeRateDAO); } /** * Adds a new SecurityNode to the data set. *

* Checks and prevents the addition of a duplicate SecurityNode. * * @param node new SecurityNode to add * @return {@code true} if the add it successful */ public boolean addSecurity(final SecurityNode node) { dataLock.writeLock().lock(); try { boolean status = isCommodityNodeValid(node); if (status) { if (getSecurity(node.getSymbol()) != null) { logger.log(Level.INFO, "Prevented addition of a duplicate SecurityNode: {0}", node.getSymbol()); status = false; } } if (status) { status = getCommodityDAO().addCommodity(node); logger.log(Level.FINE, "Adding: {0}", node); } Message message; if (status) { message = new Message(MessageChannel.COMMODITY, ChannelEvent.SECURITY_ADD, this); } else { message = new Message(MessageChannel.COMMODITY, ChannelEvent.SECURITY_ADD_FAILED, this); } message.setObject(MessageProperty.COMMODITY, node); messageBus.fireEvent(message); return status; } finally { dataLock.writeLock().unlock(); } } /** * Add a SecurityHistoryNode node to a SecurityNode. If the SecurityNode already contains * an equivalent SecurityHistoryNode, the old SecurityHistoryNode is removed first. * * @param node SecurityNode to add to * @param hNode SecurityHistoryNode to add * @return true if successful */ public boolean addSecurityHistory(@NotNull final SecurityNode node, @NotNull final SecurityHistoryNode hNode) { dataLock.writeLock().lock(); try { // Remove old history of the same date if it exists if (node.contains(hNode.getLocalDate())) { if (!removeSecurityHistory(node, hNode.getLocalDate())) { logSevere(ResourceUtils.getString("Message.Error.HistRemoval", hNode.getLocalDate(), node.getSymbol())); return false; } } boolean status = node.addHistoryNode(hNode); if (status) { status = getCommodityDAO().addSecurityHistory(node, hNode); } Message message; if (status) { clearCachedAccountBalance(node); message = new Message(MessageChannel.COMMODITY, ChannelEvent.SECURITY_HISTORY_ADD, this); } else { message = new Message(MessageChannel.COMMODITY, ChannelEvent.SECURITY_HISTORY_ADD_FAILED, this); } message.setObject(MessageProperty.COMMODITY, node); messageBus.fireEvent(message); return status; } finally { dataLock.writeLock().unlock(); } } /** * Add a SecurityHistoryNode node to a SecurityNode. If the SecurityNode already contains * an equivalent SecurityHistoryNode, the old SecurityHistoryNode is removed first. * * @param node SecurityNode to add to * @param historyEvent SecurityHistoryNode to add * @return true if successful */ public boolean addSecurityHistoryEvent(@NotNull final SecurityNode node, @NotNull final SecurityHistoryEvent historyEvent) { dataLock.writeLock().lock(); try { // Remove old history event if it exists, equality is used to work around hibernate optimizations // A defensive copy of the old events is used to prevent concurrent modification errors new HashSet<>(node.getHistoryEvents()).stream().filter(event -> event.equals(historyEvent)) .forEach(event -> removeSecurityHistoryEvent(node, historyEvent)); boolean status = node.addSecurityHistoryEvent(historyEvent); if (status) { status = getCommodityDAO().addSecurityHistoryEvent(node, historyEvent); } Message message; if (status) { clearCachedAccountBalance(node); message = new Message(MessageChannel.COMMODITY, ChannelEvent.SECURITY_HISTORY_EVENT_ADD, this); } else { message = new Message(MessageChannel.COMMODITY, ChannelEvent.SECURITY_HISTORY_EVENT_ADD_FAILED, this); } message.setObject(MessageProperty.COMMODITY, node); messageBus.fireEvent(message); return status; } finally { dataLock.writeLock().unlock(); } } /** * Returns a list of investment accounts that use the given security node. * * @param node security node * @return list of investment accounts */ private Set getInvestmentAccountList(final SecurityNode node) { return getInvestmentAccountList().parallelStream() .filter(account -> account.containsSecurity(node)).collect(Collectors.toSet()); } /** * Forces all investment accounts containing the security to clear the cached account balance and reconciled account * balance and recalculate when queried. * * @param node SecurityNode that was changed */ private void clearCachedAccountBalance(final SecurityNode node) { getInvestmentAccountList(node).forEach(this::clearCachedAccountBalance); } /** * Clears an {@code Accounts} cached balance and recursively works up the tree to the root. * * @param account {@code Account} to clear */ private void clearCachedAccountBalance(final Account account) { dataLock.writeLock().lock(); try { account.clearCachedBalances(); // force a persistence update if working as a client / server if (eDAO.isRemote()) { getAccountDAO().updateAccount(account); } } finally { dataLock.writeLock().unlock(); } if (account.getParent() != null && account.getParent().getAccountType() != AccountType.ROOT) { clearCachedAccountBalance(account.getParent()); } } private CurrencyNode[] getBaseCurrencies(final String exchangeRateId) { dataLock.readLock().lock(); try { final List currencies = getCurrencies(); Collections.sort(currencies); Collections.reverse(currencies); for (final CurrencyNode node1 : currencies) { for (final CurrencyNode node2 : currencies) { if (node1 != node2 && buildExchangeRateId(node1, node2).equals(exchangeRateId)) { return new CurrencyNode[]{node1, node2}; } } } return new CurrencyNode[0]; } finally { dataLock.readLock().unlock(); } } /** * Returns an array of currencies being used in accounts. * * @return Set of CurrencyNodes */ public Set getActiveCurrencies() { dataLock.readLock().lock(); try { return getCommodityDAO().getActiveCurrencies(); } finally { dataLock.readLock().unlock(); } } /** * Returns a CurrencyNode given the symbol. This will not generate a new CurrencyNode. It must be explicitly created * and added. * * @param symbol Currency symbol * @return null if the CurrencyNode as not been defined */ public CurrencyNode getCurrency(final String symbol) { dataLock.readLock().lock(); try { CurrencyNode rNode = null; for (CurrencyNode node : getCurrencies()) { if (node.getSymbol().equals(symbol)) { rNode = node; break; } } return rNode; } finally { dataLock.readLock().unlock(); } } public List getCurrencies() { dataLock.readLock().lock(); try { return getCommodityDAO().getCurrencies(); } finally { dataLock.readLock().unlock(); } } public CurrencyNode getCurrencyNodeByUuid(final UUID uuid) { return getCommodityDAO().getCurrencyByUuid(uuid); } public ExchangeRate getExchangeRate(final CurrencyNode baseCurrency, final CurrencyNode exchangeCurrency) { dataLock.readLock().lock(); try { return exchangeRateDAO.getExchangeRateNode(baseCurrency, exchangeCurrency); } finally { dataLock.readLock().unlock(); } } public ExchangeRate getExchangeRateByUuid(final UUID uuid) { return getCommodityDAO().getExchangeRateByUuid(uuid); } @NotNull public List getSecurities() { dataLock.readLock().lock(); try { return getCommodityDAO().getSecurities(); } finally { dataLock.readLock().unlock(); } } /** * Find a SecurityNode given it's symbol. * * @param symbol symbol of security to find * @return null if not found */ public SecurityNode getSecurity(final String symbol) { dataLock.readLock().lock(); try { List list = getSecurities(); SecurityNode sNode = null; for (SecurityNode node : list) { if (node.getSymbol().equals(symbol)) { sNode = node; break; } } return sNode; } finally { dataLock.readLock().unlock(); } } public SecurityNode getSecurityNodeByUuid(final UUID uuid) { return getCommodityDAO().getSecurityByUuid(uuid); } private boolean isCommodityNodeUsed(final CommodityNode node) { dataLock.readLock().lock(); try { List list = getAccountList(); for (Account a : list) { if (a.getCurrencyNode().equals(node)) { return true; } if (a.getAccountType() == AccountType.INVEST || a.getAccountType() == AccountType.MUTUAL) { for (SecurityNode j : a.getSecurities()) { if (j.equals(node) || j.getReportedCurrencyNode().equals(node)) { return true; } } } } List sList = getSecurities(); for (SecurityNode sNode : sList) { if (sNode.getReportedCurrencyNode().equals(node)) { return true; } } } finally { dataLock.readLock().unlock(); } return false; } public boolean removeCommodity(final CurrencyNode node) { boolean status = true; dataLock.writeLock().lock(); try { if (isCommodityNodeUsed(node)) { status = false; } else { clearObsoleteExchangeRates(); moveObjectToTrash(node); } Message message; if (status) { message = new Message(MessageChannel.COMMODITY, ChannelEvent.CURRENCY_REMOVE, this); } else { message = new Message(MessageChannel.COMMODITY, ChannelEvent.CURRENCY_REMOVE_FAILED, this); } message.setObject(MessageProperty.COMMODITY, node); messageBus.fireEvent(message); return status; } finally { dataLock.writeLock().unlock(); } } public boolean removeSecurity(final SecurityNode node) { boolean status = true; dataLock.writeLock().lock(); try { if (isCommodityNodeUsed(node)) { status = false; } else { // Remove all history nodes first so they are not left behind // A copy is made to prevent a concurrent modification error to the underlying list, Bug #208 final List hNodes = new ArrayList<>(node.getHistoryNodes()); hNodes.stream() .filter(hNode -> !removeSecurityHistory(node, hNode.getLocalDate())) .forEach(hNode -> logSevere(ResourceUtils.getString("Message.Error.HistRemoval", hNode.getLocalDate(), node.getSymbol()))); moveObjectToTrash(node); } Message message; if (status) { message = new Message(MessageChannel.COMMODITY, ChannelEvent.SECURITY_REMOVE, this); } else { message = new Message(MessageChannel.COMMODITY, ChannelEvent.SECURITY_REMOVE_FAILED, this); } message.setObject(MessageProperty.COMMODITY, node); messageBus.fireEvent(message); return status; } finally { dataLock.writeLock().unlock(); } } /** * Remove a {@code SecurityHistoryNode} given a {@code Date}. * * @param node {@code SecurityNode} to remove history from * @param date the search {@code Date} * @return {@code true} if a {@code SecurityHistoryNode} was found and removed */ public boolean removeSecurityHistory(@NotNull final SecurityNode node, @NotNull final LocalDate date) { dataLock.writeLock().lock(); boolean status = false; try { final Optional optional = node.getHistoryNode(date); if (optional.isPresent()) { status = node.removeHistoryNode(date); if (status) { // removal was a success, make sure we cleanup properly moveObjectToTrash(optional.get()); status = getCommodityDAO().removeSecurityHistory(node, optional.get()); logInfo(ResourceUtils.getString("Message.RemovingSecurityHistory", date, node.getSymbol())); } } Message message; if (status) { clearCachedAccountBalance(node); message = new Message(MessageChannel.COMMODITY, ChannelEvent.SECURITY_HISTORY_REMOVE, this); } else { message = new Message(MessageChannel.COMMODITY, ChannelEvent.SECURITY_HISTORY_REMOVE_FAILED, this); } message.setObject(MessageProperty.COMMODITY, node); messageBus.fireEvent(message); return status; } finally { dataLock.writeLock().unlock(); } } /** * Remove a {@code SecurityHistoryEvent} from a {@code SecurityNode}. * * @param node {@code SecurityNode} to remove history from * @param historyEvent the {@code SecurityHistoryEvent} to remove * @return {@code true} if the {@code SecurityHistoryEvent} was found and removed */ public boolean removeSecurityHistoryEvent(@NotNull final SecurityNode node, @NotNull final SecurityHistoryEvent historyEvent) { dataLock.writeLock().lock(); boolean status; try { status = node.removeSecurityHistoryEvent(historyEvent); if (status) { // removal was a success, make sure we cleanup properly moveObjectToTrash(historyEvent); status = getCommodityDAO().removeSecurityHistoryEvent(node, historyEvent); } Message message; if (status) { clearCachedAccountBalance(node); message = new Message(MessageChannel.COMMODITY, ChannelEvent.SECURITY_HISTORY_EVENT_REMOVE, this); } else { message = new Message(MessageChannel.COMMODITY, ChannelEvent.SECURITY_HISTORY_EVENT_REMOVE_FAILED, this); } message.setObject(MessageProperty.COMMODITY, node); messageBus.fireEvent(message); return status; } finally { dataLock.writeLock().unlock(); } } private Config getConfig() { dataLock.readLock().lock(); try { if (config == null) { config = getConfigDAO().getDefaultConfig(); } return config; } finally { dataLock.readLock().unlock(); } } public CurrencyNode getDefaultCurrency() { dataLock.readLock().lock(); try { CurrencyNode node = getConfig().getDefaultCurrency(); if (node == null) { logger.warning("No default currency assigned"); } return node; } finally { dataLock.readLock().unlock(); } } public void setDefaultCurrency(final CurrencyNode defaultCurrency) { // make sure the new default is persisted if it has not been if (!isStored(defaultCurrency)) { addCurrency(defaultCurrency); } dataLock.writeLock().lock(); try { final Config currencyConfig = getConfig(); currencyConfig.setDefaultCurrency(defaultCurrency); getConfigDAO().update(currencyConfig); logInfo("Setting default currency: " + defaultCurrency); Message message = new Message(MessageChannel.CONFIG, ChannelEvent.CONFIG_MODIFY, this); message.setObject(MessageProperty.CONFIG, currencyConfig); messageBus.fireEvent(message); Account root = getRootAccount(); // The root account holds a reference to the default currency root.setCurrencyNode(defaultCurrency); getAccountDAO().updateAccount(root); message = new Message(MessageChannel.ACCOUNT, ChannelEvent.ACCOUNT_MODIFY, this); message.setObject(MessageProperty.ACCOUNT, root); messageBus.fireEvent(message); } finally { dataLock.writeLock().unlock(); } } public void setExchangeRate(final CurrencyNode baseCurrency, final CurrencyNode exchangeCurrency, final BigDecimal rate) { setExchangeRate(baseCurrency, exchangeCurrency, rate, LocalDate.now()); } public void setExchangeRate(final CurrencyNode baseCurrency, final CurrencyNode exchangeCurrency, final BigDecimal rate, final LocalDate localDate) { Objects.requireNonNull(rate); if (rate.compareTo(BigDecimal.ZERO) < 1) { throw new EngineException("Rate must be greater than zero"); } if (baseCurrency.equals(exchangeCurrency)) { return; } // find the correct ExchangeRate and create if needed ExchangeRate exchangeRate = getExchangeRate(baseCurrency, exchangeCurrency); if (exchangeRate == null) { exchangeRate = new ExchangeRate(buildExchangeRateId(baseCurrency, exchangeCurrency)); getCommodityDAO().addExchangeRate(exchangeRate); } // Remove old history of the same date if it exists if (exchangeRate.contains(localDate)) { removeExchangeRateHistory(exchangeRate, exchangeRate.getHistory(localDate)); } dataLock.writeLock().lock(); try { // create the new history node ExchangeRateHistoryNode historyNode; if (baseCurrency.getSymbol().compareToIgnoreCase(exchangeCurrency.getSymbol()) > 0) { historyNode = new ExchangeRateHistoryNode(localDate, rate); } else { historyNode = new ExchangeRateHistoryNode(localDate, BigDecimal.ONE.divide(rate, MathConstants.mathContext)); } final Message message; boolean result = false; if (exchangeRate.addHistoryNode(historyNode)) { result = getCommodityDAO().addExchangeRateHistory(exchangeRate); } if (result) { message = new Message(MessageChannel.COMMODITY, ChannelEvent.EXCHANGE_RATE_ADD, this); } else { message = new Message(MessageChannel.COMMODITY, ChannelEvent.EXCHANGE_RATE_ADD_FAILED, this); } message.setObject(MessageProperty.EXCHANGE_RATE, exchangeRate); messageBus.fireEvent(message); } finally { dataLock.writeLock().unlock(); } } public void removeExchangeRateHistory(final ExchangeRate exchangeRate, final ExchangeRateHistoryNode history) { dataLock.writeLock().lock(); try { final Message message; boolean result = false; if (exchangeRate.contains(history)) { if (exchangeRate.removeHistoryNode(history)) { moveObjectToTrash(history); result = getCommodityDAO().removeExchangeRateHistory(exchangeRate); } } if (result) { message = new Message(MessageChannel.COMMODITY, ChannelEvent.EXCHANGE_RATE_REMOVE, this); } else { message = new Message(MessageChannel.COMMODITY, ChannelEvent.EXCHANGE_RATE_REMOVE_FAILED, this); } message.setObject(MessageProperty.EXCHANGE_RATE, exchangeRate); messageBus.fireEvent(message); } finally { dataLock.writeLock().unlock(); } } /** * Modifies an existing currency node in place. The supplied node should not be a reference to the original * * @param oldNode old CommodityNode * @param templateNode template CommodityNode * @return true if successful */ public boolean updateCommodity(final CommodityNode oldNode, final CommodityNode templateNode) { Objects.requireNonNull(oldNode); Objects.requireNonNull(templateNode); if (oldNode == templateNode) { throw new EngineException("node were the same"); } dataLock.writeLock().lock(); try { boolean status; if (oldNode.getClass().equals(templateNode.getClass())) { oldNode.setDescription(templateNode.getDescription()); oldNode.setPrefix(templateNode.getPrefix()); oldNode.setScale(templateNode.getScale()); oldNode.setSuffix(templateNode.getSuffix()); if (templateNode instanceof SecurityNode) { oldNode.setSymbol(templateNode.getSymbol()); // allow symbol to change ((SecurityNode) oldNode).setReportedCurrencyNode(((SecurityNode) templateNode).getReportedCurrencyNode()); ((SecurityNode) oldNode).setQuoteSource(((SecurityNode) templateNode).getQuoteSource()); ((SecurityNode) oldNode).setISIN(((SecurityNode) templateNode).getISIN()); } status = getCommodityDAO().updateCommodityNode(oldNode); } else { status = false; logger.warning("Template object class did not match old object class"); } final Message message; if (templateNode instanceof SecurityNode) { if (status) { message = new Message(MessageChannel.COMMODITY, ChannelEvent.SECURITY_MODIFY, this); message.setObject(MessageProperty.COMMODITY, oldNode); } else { message = new Message(MessageChannel.COMMODITY, ChannelEvent.SECURITY_MODIFY_FAILED, this); message.setObject(MessageProperty.COMMODITY, templateNode); } } else { if (status) { message = new Message(MessageChannel.COMMODITY, ChannelEvent.CURRENCY_MODIFY, this); message.setObject(MessageProperty.COMMODITY, oldNode); } else { message = new Message(MessageChannel.COMMODITY, ChannelEvent.CURRENCY_MODIFY_FAILED, this); message.setObject(MessageProperty.COMMODITY, templateNode); } } messageBus.fireEvent(message); return status; } finally { dataLock.writeLock().unlock(); } } private boolean updateReminder(final Reminder reminder) { final boolean result = getReminderDAO().updateReminder(reminder); final Message message; if (result) { message = new Message(MessageChannel.REMINDER, ChannelEvent.REMINDER_UPDATE, this); } else { message = new Message(MessageChannel.REMINDER, ChannelEvent.REMINDER_UPDATE_FAILED, this); } message.setObject(MessageProperty.REMINDER, reminder); messageBus.fireEvent(message); return result; } public String getAccountSeparator() { dataLock.readLock().lock(); try { if (accountSeparator == null) { accountSeparator = getConfig().getAccountSeparator(); } return accountSeparator; } finally { dataLock.readLock().unlock(); } } public void setAccountSeparator(final String separator) { dataLock.writeLock().lock(); try { accountSeparator = separator; Config localConfig = getConfig(); localConfig.setAccountSeparator(separator); getConfigDAO().update(localConfig); Message message = new Message(MessageChannel.CONFIG, ChannelEvent.CONFIG_MODIFY, this); message.setObject(MessageProperty.CONFIG, localConfig); messageBus.fireEvent(message); } finally { dataLock.writeLock().unlock(); } } /** * Returns a list of all Accounts excluding the rootAccount. * * @return List of accounts */ public List getAccountList() { final List accounts = getAccountDAO().getAccountList(); accounts.remove(getRootAccount()); return accounts; } public Account getAccountByUuid(final UUID id) { return getAccountDAO().getAccountByUuid(id); } /** * Search for an account with a matching account name. * * @param accountName Account name to search for. Must not be null * @return The matching account. {@code null} if not found. */ public Account getAccountByName(@NotNull final String accountName) { Objects.requireNonNull(accountName); final List list = getAccountList(); // sort for consistent search order Collections.sort(list); for (final Account account : list) { if (accountName.equals(account.getName())) { return account; } } return null; } /** * Returns a list of IncomeAccounts excluding the rootIncomeAccount. * * @return List of income accounts */ @NotNull public List getIncomeAccountList() { return getAccountDAO().getIncomeAccountList(); } /** * Returns a list of ExpenseAccounts excluding the rootExpenseAccount. * * @return List if expense accounts */ @NotNull public List getExpenseAccountList() { return getAccountDAO().getExpenseAccountList(); } /** * Returns a list of all accounts excluding the rootAccount and IncomeAccounts and ExpenseAccounts. * * @return List of investment accounts */ public List getInvestmentAccountList() { return getAccountDAO().getInvestmentAccountList(); } public void refresh(final StoredObject object) { eDAO.refresh(object); } /** * Adds a new account. * * @param parent The parent account * @param child A new Account object * @return true if successful */ public boolean addAccount(final Account parent, final Account child) { Objects.requireNonNull(child); Objects.requireNonNull(child.getUuid()); if (child.getAccountType() == AccountType.ROOT) { throw new IllegalArgumentException("Invalid Account"); } dataLock.writeLock().lock(); try { Message message; boolean result; result = parent.addChild(child); if (result) { result = getAccountDAO().addAccount(parent, child); } if (result) { message = new Message(MessageChannel.ACCOUNT, ChannelEvent.ACCOUNT_ADD, this); message.setObject(MessageProperty.ACCOUNT, child); messageBus.fireEvent(message); logInfo(rb.getString("Message.AccountAdd")); result = true; } else { message = new Message(MessageChannel.ACCOUNT, ChannelEvent.ACCOUNT_ADD_FAILED, this); message.setObject(MessageProperty.ACCOUNT, child); messageBus.fireEvent(message); result = false; } return result; } finally { dataLock.writeLock().unlock(); } } /** * Return the root account. * * @return RootAccount */ public RootAccount getRootAccount() { dataLock.readLock().lock(); try { if (rootAccount == null) { rootAccount = getAccountDAO().getRootAccount(); } return rootAccount; } finally { dataLock.readLock().unlock(); } } /** * Move an account to a new parent account.. * * @param account account to move * @param newParent the new parent account * @return true if successful */ public boolean moveAccount(final Account account, final Account newParent) { Objects.requireNonNull(account); Objects.requireNonNull(newParent); dataLock.writeLock().lock(); try { // cannot invert the child/parent relationship of an account if (account.contains(newParent)) { Message message = new Message(MessageChannel.ACCOUNT, ChannelEvent.ACCOUNT_MODIFY_FAILED, this); message.setObject(MessageProperty.ACCOUNT, account); messageBus.fireEvent(message); logInfo(rb.getString("Message.AccountMoveFailed")); return false; } Account oldParent = account.getParent(); if (oldParent != null) { // check for detached account oldParent.removeChild(account); getAccountDAO().updateAccount(account); getAccountDAO().updateAccount(oldParent); Message message = new Message(MessageChannel.ACCOUNT, ChannelEvent.ACCOUNT_MODIFY, this); message.setObject(MessageProperty.ACCOUNT, oldParent); messageBus.fireEvent(message); } newParent.addChild(account); getAccountDAO().updateAccount(account); getAccountDAO().updateAccount(newParent); Message message = new Message(MessageChannel.ACCOUNT, ChannelEvent.ACCOUNT_MODIFY, this); message.setObject(MessageProperty.ACCOUNT, newParent); messageBus.fireEvent(message); logInfo(rb.getString(MESSAGE_ACCOUNT_MODIFY)); return true; } finally { dataLock.writeLock().unlock(); } } /** * Changes an Account's code. * * @param account Account to change * @param code new code * @return true is successful */ public boolean setAccountCode(final Account account, final int code) { account.setAccountCode(code); boolean result = getAccountDAO().updateAccount(account); if (result) { final Message message = new Message(MessageChannel.ACCOUNT, ChannelEvent.ACCOUNT_MODIFY, this); message.setObject(MessageProperty.ACCOUNT, account); messageBus.fireEvent(message); logInfo(rb.getString(MESSAGE_ACCOUNT_MODIFY)); } else { final Message message = new Message(MessageChannel.ACCOUNT, ChannelEvent.ACCOUNT_MODIFY_FAILED, this); message.setObject(MessageProperty.ACCOUNT, account); messageBus.fireEvent(message); } return result; } /** * Modifies an existing account given an account as a template. The type of the account cannot be changed. * * @param template The Account object to use as a template * @param account The existing account * @return true if successful */ public boolean modifyAccount(final Account template, final Account account) { boolean result; Message message; dataLock.writeLock().lock(); try { account.setName(template.getName()); account.setDescription(template.getDescription()); account.setNotes(template.getNotes()); account.setLocked(template.isLocked()); account.setPlaceHolder(template.isPlaceHolder()); account.setVisible(template.isVisible()); account.setExcludedFromBudget(template.isExcludedFromBudget()); account.setAccountNumber(template.getAccountNumber()); account.setBankId(template.getBankId()); account.setAccountCode(template.getAccountCode()); if (account.getAccountType().isMutable()) { account.setAccountType(template.getAccountType()); } // allow allow a change if the account does not contain transactions if (account.getTransactionCount() == 0) { account.setCurrencyNode(template.getCurrencyNode()); } result = getAccountDAO().updateAccount(account); if (result) { message = new Message(MessageChannel.ACCOUNT, ChannelEvent.ACCOUNT_MODIFY, this); message.setObject(MessageProperty.ACCOUNT, account); messageBus.fireEvent(message); logInfo(rb.getString(MESSAGE_ACCOUNT_MODIFY)); } else { message = new Message(MessageChannel.ACCOUNT, ChannelEvent.ACCOUNT_MODIFY_FAILED, this); message.setObject(MessageProperty.ACCOUNT, account); messageBus.fireEvent(message); } /* Check to see if the account needs to be moved */ if (account.parentAccount != template.parentAccount && template.parentAccount != null && result) { if (!moveAccount(account, template.parentAccount)) { logWarning(rb.getString("Message.Error.MoveAccount")); result = false; } } // Force clearing of any budget goals if an empty account has been changed to become a place holder if (account.isPlaceHolder()) { purgeBudgetGoal(account); } return result; } finally { dataLock.writeLock().unlock(); } } /** * Purges any {@code BudgetGoal} associated with an account. * * @param account {@code Account} to remove all associated budget goal history */ private void purgeBudgetGoal(@NotNull final Account account) { // clear budget history for (final Budget budget : getBudgetList()) { budget.removeBudgetGoal(account); if (!updateBudget(budget)) { logWarning("Unable to remove account goals from the budget"); } } } /** * Sets the account number of an account. * * @param account account to change * @param number new account number */ public void setAccountNumber(final Account account, final String number) { dataLock.writeLock().lock(); try { account.setAccountNumber(number); getAccountDAO().updateAccount(account); Message message = new Message(MessageChannel.ACCOUNT, ChannelEvent.ACCOUNT_MODIFY, this); message.setObject(MessageProperty.ACCOUNT, account); messageBus.fireEvent(message); logInfo(rb.getString(MESSAGE_ACCOUNT_MODIFY)); } finally { dataLock.writeLock().unlock(); } } /** * Sets an attribute for an {@code Account}. The key and values are string based * * @param account {@code Account} to add or update an attribute * @param key the key for the attribute * @param value the value of the attribute */ public void setAccountAttribute(final Account account, @NotNull final String key, @Nullable final String value) { // Throw an error if the value exceeds the maximum length if (value != null && value.length() > Account.MAX_ATTRIBUTE_LENGTH) { Message message = new Message(MessageChannel.ACCOUNT, ChannelEvent.ACCOUNT_MODIFY_FAILED, this); message.setObject(MessageProperty.ACCOUNT, account); messageBus.fireEvent(message); logInfo("The maximum length of the attribute was exceeded"); return; } dataLock.writeLock().lock(); try { account.setAttribute(key, value); getAccountDAO().updateAccount(account); Message message = new Message(MessageChannel.ACCOUNT, ChannelEvent.ACCOUNT_ATTRIBUTE_MODIFY, this); message.setObject(MessageProperty.ACCOUNT, account); messageBus.fireEvent(message); logInfo(rb.getString(MESSAGE_ACCOUNT_MODIFY)); } finally { dataLock.writeLock().unlock(); } } /** * Returns an {@code Account} attribute. * * @param account account to extract attribute from * @param key the attribute key * @return the attribute if found * @see #setAccountAttribute */ public static String getAccountAttribute(@NotNull final Account account, @NotNull final String key) { return account.getAttribute(key); } /** * Removes an existing account given it's ID. * * @param account The account to remove * @return true if successful */ public boolean removeAccount(final Account account) { dataLock.writeLock().lock(); try { boolean result = false; if (account.getTransactionCount() == 0 && account.getChildCount() == 0) { Account parent = account.getParent(); if (parent != null) { result = parent.removeChild(account); if (result) { getAccountDAO().updateAccount(parent); // clear budget history purgeBudgetGoal(account); } } moveObjectToTrash(account); } Message message; if (result) { message = new Message(MessageChannel.ACCOUNT, ChannelEvent.ACCOUNT_REMOVE, this); message.setObject(MessageProperty.ACCOUNT, account); messageBus.fireEvent(message); logInfo(rb.getString("Message.AccountRemove")); } else { message = new Message(MessageChannel.ACCOUNT, ChannelEvent.ACCOUNT_REMOVE_FAILED, this); message.setObject(MessageProperty.ACCOUNT, account); messageBus.fireEvent(message); } return result; } finally { dataLock.writeLock().unlock(); } } public Future getAttachment(final String attachment) { return attachmentManager.getAttachment(attachment); } public boolean addAttachment(final Path path, final boolean copy) { boolean result = false; try { result = attachmentManager.addAttachment(path, copy); } catch (IOException e) { logSevere(e.getLocalizedMessage()); } return result; } public boolean removeAttachment(final String attachment) { return attachmentManager.removeAttachment(attachment); } /** * Sets the amortize object of an account. * * @param account The Liability account to change * @param amortizeObject the new AmortizeObject * @return true if successful */ public boolean setAmortizeObject(final Account account, final AmortizeObject amortizeObject) { dataLock.writeLock().lock(); try { if (account != null && amortizeObject != null && account.getAccountType() == AccountType.LIABILITY) { account.setAmortizeObject(amortizeObject); if (!getAccountDAO().updateAccount(account)) { logSevere("Was not able to save the amortize object"); } return true; } return false; } finally { dataLock.writeLock().unlock(); } } /** * Toggles the visibility of an account given its ID. * * @param account The account to toggle visibility */ public void toggleAccountVisibility(final Account account) { dataLock.writeLock().lock(); try { Message message; account.setVisible(!account.isVisible()); if (getAccountDAO().toggleAccountVisibility(account)) { message = new Message(MessageChannel.ACCOUNT, ChannelEvent.ACCOUNT_VISIBILITY_CHANGE, this); } else { message = new Message(MessageChannel.ACCOUNT, ChannelEvent.ACCOUNT_VISIBILITY_CHANGE_FAILED, this); } message.setObject(MessageProperty.ACCOUNT, account); messageBus.fireEvent(message); } finally { dataLock.writeLock().unlock(); } } /** * Adds a SecurityNode from a InvestmentAccount. * * @param account destination account * @param node SecurityNode to add * @return true if add was successful */ public boolean addAccountSecurity(final Account account, final SecurityNode node) { dataLock.writeLock().lock(); try { Message message; boolean result = account.addSecurity(node); if (result) { result = getAccountDAO().addAccountSecurity(account, node); } if (result) { message = new Message(MessageChannel.ACCOUNT, ChannelEvent.ACCOUNT_SECURITY_ADD, this); } else { message = new Message(MessageChannel.ACCOUNT, ChannelEvent.ACCOUNT_SECURITY_ADD_FAILED, this); } message.setObject(MessageProperty.ACCOUNT, account); message.setObject(MessageProperty.COMMODITY, node); messageBus.fireEvent(message); return result; } finally { dataLock.writeLock().unlock(); } } /** * Removes a SecurityNode from an InvestmentAccount. * * @param account Account to remove SecurityNode from * @param node SecurityNode to remove * @return true if successful */ private boolean removeAccountSecurity(final Account account, final SecurityNode node) { Objects.requireNonNull(node); dataLock.writeLock().lock(); try { Message message; boolean result = account.removeSecurity(node); if (result) { getAccountDAO().updateAccount(account); } if (result) { message = new Message(MessageChannel.ACCOUNT, ChannelEvent.ACCOUNT_SECURITY_REMOVE, this); } else { message = new Message(MessageChannel.ACCOUNT, ChannelEvent.ACCOUNT_SECURITY_REMOVE_FAILED, this); } message.setObject(MessageProperty.ACCOUNT, account); message.setObject(MessageProperty.COMMODITY, node); messageBus.fireEvent(message); return result; } finally { dataLock.writeLock().unlock(); } } /** * Update an account's securities list. This compares the old list of securities and the supplied list and adds or * removes securities to make sure the lists are the same. * * @param acc Destination account * @param list Collection of SecurityNodes * @return true if successful */ public boolean updateAccountSecurities(final Account acc, final Collection list) { boolean result = true; if (acc.memberOf(AccountGroup.INVEST)) { dataLock.writeLock().lock(); try { final Collection oldList = acc.getSecurities(); for (SecurityNode node : oldList) { if (!list.contains(node)) { if (!removeAccountSecurity(acc, node)) { logWarning(ResourceUtils.getString("Message.Error.SecurityAccountRemove", node.toString(), acc.getName())); result = false; } } } for (SecurityNode node : list) { if (!oldList.contains(node)) { if (!addAccountSecurity(acc, node)) { logWarning(ResourceUtils.getString("Message.Error.SecurityAccountRemove", node.toString(), acc.getName())); result = false; } } } } finally { dataLock.writeLock().unlock(); } } return result; } public boolean addBudget(final Budget budget) { boolean result; dataLock.writeLock().lock(); try { Message message; result = getBudgetDAO().add(budget); if (result) { message = new Message(MessageChannel.BUDGET, ChannelEvent.BUDGET_ADD, this); } else { message = new Message(MessageChannel.BUDGET, ChannelEvent.BUDGET_ADD_FAILED, this); } message.setObject(MessageProperty.BUDGET, budget); messageBus.fireEvent(message); return result; } finally { dataLock.writeLock().unlock(); } } public boolean removeBudget(final Budget budget) { boolean result = false; dataLock.writeLock().lock(); try { moveObjectToTrash(budget); Message message = new Message(MessageChannel.BUDGET, ChannelEvent.BUDGET_REMOVE, this); message.setObject(MessageProperty.BUDGET, budget); messageBus.fireEvent(message); result = true; } catch (final Exception ex) { logger.log(Level.SEVERE, ex.getLocalizedMessage(), ex); } finally { dataLock.writeLock().unlock(); } return result; } public void updateBudgetGoals(final Budget budget, final Account account, final BudgetGoal newGoals) { dataLock.writeLock().lock(); try { BudgetGoal oldGoals = budget.getBudgetGoal(account); budget.setBudgetGoal(account, newGoals); moveObjectToTrash(oldGoals); // need to keep the old goal around, will be cleaned up later, orphan removal causes refresh issues updateBudgetGoals(budget, account); } finally { dataLock.writeLock().unlock(); } } private void updateBudgetGoals(final Budget budget, final Account account) { dataLock.writeLock().lock(); try { Message message; boolean result = getBudgetDAO().update(budget); if (result) { message = new Message(MessageChannel.BUDGET, ChannelEvent.BUDGET_GOAL_UPDATE, this); } else { message = new Message(MessageChannel.BUDGET, ChannelEvent.BUDGET_GOAL_UPDATE_FAILED, this); } message.setObject(MessageProperty.BUDGET, budget); message.setObject(MessageProperty.ACCOUNT, account); messageBus.fireEvent(message); logger.log(Level.FINE, "Budget goal updated for {0}", account.getPathName()); } finally { dataLock.writeLock().unlock(); } } public boolean updateBudget(final Budget budget) { boolean result; dataLock.writeLock().lock(); try { Message message; result = getBudgetDAO().update(budget); if (result) { message = new Message(MessageChannel.BUDGET, ChannelEvent.BUDGET_UPDATE, this); } else { message = new Message(MessageChannel.BUDGET, ChannelEvent.BUDGET_UPDATE_FAILED, this); } message.setObject(MessageProperty.BUDGET, budget); messageBus.fireEvent(message); logger.log(Level.FINE, "Budget updated"); return result; } finally { dataLock.writeLock().unlock(); } } public List getBudgetList() { dataLock.readLock().lock(); try { return getBudgetDAO().getBudgets(); } finally { dataLock.readLock().unlock(); } } public Budget getBudgetByUuid(final UUID uuid) { return getBudgetDAO().getBudgetByUuid(uuid); } public boolean isTransactionValid(final Transaction transaction) { for (final Account a : transaction.getAccounts()) { if (a.isLocked()) { logWarning(rb.getString("Message.TransactionAccountLocked")); return false; } } if (transaction.isMarkedForRemoval()) { logger.log(Level.SEVERE, "Transaction already marked for removal", new Throwable()); return false; } if (eDAO.getObjectByUuid(Transaction.class, transaction.getUuid()) != null) { logger.log(Level.WARNING, "Transaction UUID was not unique"); return false; } if (transaction.size() < 1) { logger.log(Level.WARNING, "Invalid Transaction"); return false; } for (final TransactionEntry e : transaction.getTransactionEntries()) { if (e == null) { logger.log(Level.WARNING, "Null TransactionEntry"); return false; } } for (final TransactionEntry e : transaction.getTransactionEntries()) { if (e.getTransactionTag() == null) { logger.log(Level.WARNING, "Null TransactionTag"); return false; } } for (final TransactionEntry e : transaction.getTransactionEntries()) { if (e.getCreditAccount() == null) { logger.log(Level.WARNING, "Null Credit Account"); return false; } if (e.getDebitAccount() == null) { logger.log(Level.WARNING, "Null Debit Account"); return false; } if (e.getCreditAmount() == null) { logger.log(Level.WARNING, "Null Credit Amount"); return false; } if (e.getDebitAmount() == null) { logger.log(Level.WARNING, "Null Debit Amount"); return false; } } if (transaction.getTransactionType() == TransactionType.SPLITENTRY && transaction.getCommonAccount() == null) { logger.log(Level.WARNING, "Entries do not share a common account"); return false; } if (transaction instanceof InvestmentTransaction) { final InvestmentTransaction investmentTransaction = (InvestmentTransaction) transaction; if (!investmentTransaction.getInvestmentAccount().containsSecurity(investmentTransaction.getSecurityNode())) { logger.log(Level.WARNING, "Investment Account is missing the security"); return false; } } return transaction.getTransactionType() != TransactionType.INVALID; } /** * Determine if a StoredObject is persisted in the database. * * @param object StoredObject to check * @return true if persisted */ public boolean isStored(final StoredObject object) { return eDAO.getObjectByUuid(StoredObject.class, object.getUuid()) != null; } public boolean addTransaction(final Transaction transaction) { dataLock.writeLock().lock(); try { boolean result = isTransactionValid(transaction); if (result) { /* Add the transaction to each account */ transaction.getAccounts().stream() .filter(account -> !account.addTransaction(transaction)) .forEach(account -> logSevere("Failed to add the Transaction")); result = getTransactionDAO().addTransaction(transaction); logInfo(rb.getString("Message.TransactionAdd")); /* If successful, extract and enter a default exchange rate for the transaction date if a rate has not been set */ if (result) { // no rate for the date has been set transaction.getTransactionEntries().stream() .filter(TransactionEntry::isMultiCurrency) .forEach(entry -> { final ExchangeRate rate = getExchangeRate(entry.getDebitAccount().getCurrencyNode(), entry.getCreditAccount().getCurrencyNode()); if (rate.getRate(transaction.getLocalDate()).compareTo(BigDecimal.ZERO) == 0) { // no rate for the date has been set final BigDecimal exchangeRate = entry.getDebitAmount().abs() .divide(entry.getCreditAmount().abs(), MathConstants.mathContext); setExchangeRate(entry.getCreditAccount().getCurrencyNode(), entry.getDebitAccount().getCurrencyNode(), exchangeRate, transaction.getLocalDate()); } }); } } postTransactionAdd(transaction, result); return result; } finally { dataLock.writeLock().unlock(); } } public boolean removeTransaction(final Transaction transaction) { dataLock.writeLock().lock(); try { for (final Account account : transaction.getAccounts()) { if (account.isLocked()) { logWarning(rb.getString("Message.TransactionRemoveLocked")); return false; } } /* Remove the transaction from each account */ transaction.getAccounts().stream() .filter(account -> !account.removeTransaction(transaction)) .forEach(account -> logSevere("Failed to remove the Transaction")); logInfo(rb.getString("Message.TransactionRemove")); boolean result = getTransactionDAO().removeTransaction(transaction); // move transactions into the trash if (result) { moveObjectToTrash(transaction); } postTransactionRemove(transaction, result); return result; } finally { dataLock.writeLock().unlock(); } } /** * Changes the reconciled state of a transaction. * * @param transaction transaction to change * @param account account to change state for * @param state new reconciled state */ public void setTransactionReconciled(final Transaction transaction, final Account account, final ReconciledState state) { dataLock.writeLock().lock(); // hold a write lock to ensure nothing slips in between the remove and add try { final Transaction newTransaction = (Transaction) transaction.clone(); ReconcileManager.reconcileTransaction(account, newTransaction, state); if (removeTransaction(transaction)) { addTransaction(newTransaction); } } catch (final CloneNotSupportedException e) { logger.log(Level.SEVERE, "Failed to reconcile the Transaction", e); } finally { dataLock.writeLock().unlock(); } } public List getTransactionNumberList() { dataLock.readLock().lock(); try { return getConfig().getTransactionNumberList(); } finally { dataLock.readLock().unlock(); } } public void setTransactionNumberList(final List list) { dataLock.writeLock().lock(); try { final Config transactionConfig = getConfig(); transactionConfig.setTransactionNumberList(list); getConfigDAO().update(transactionConfig); Message message = new Message(MessageChannel.CONFIG, ChannelEvent.CONFIG_MODIFY, this); message.setObject(MessageProperty.CONFIG, transactionConfig); messageBus.fireEvent(message); } finally { dataLock.writeLock().unlock(); } } /** * Get all transactions. * * @return List of transactions that may be altered without concern of side effects */ public List getTransactions() { return getTransactionDAO().getTransactions(); } /** * Returns a list of transactions with external links. * * @return List of transactions that may be altered without concern of side effects */ public List getTransactionsWithAttachments() { return getTransactionDAO().getTransactionsWithAttachments(); } public Transaction getTransactionByUuid(final UUID uuid) { return getTransactionDAO().getTransactionByUuid(uuid); } private void postTransactionAdd(final Transaction transaction, final boolean result) { for (Account a : transaction.getAccounts()) { Message message; if (result) { message = new Message(MessageChannel.TRANSACTION, ChannelEvent.TRANSACTION_ADD, this); } else { message = new Message(MessageChannel.TRANSACTION, ChannelEvent.TRANSACTION_ADD_FAILED, this); } message.setObject(MessageProperty.ACCOUNT, a); message.setObject(MessageProperty.TRANSACTION, transaction); messageBus.fireEvent(message); } } private void postTransactionRemove(final Transaction transaction, final boolean result) { for (Account a : transaction.getAccounts()) { Message message; if (result) { message = new Message(MessageChannel.TRANSACTION, ChannelEvent.TRANSACTION_REMOVE, this); } else { message = new Message(MessageChannel.TRANSACTION, ChannelEvent.TRANSACTION_REMOVE_FAILED, this); } message.setObject(MessageProperty.ACCOUNT, a); message.setObject(MessageProperty.TRANSACTION, transaction); messageBus.fireEvent(message); } } /** * Adds a new Tag * * @param tag Tag to add * @return true is successful */ public boolean addTag(@NotNull Tag tag) { Objects.requireNonNull(tag); dataLock.writeLock().lock(); try { boolean result = eDAO.getTagDAO().add(tag); final Message message = new Message(MessageChannel.TAG, result ? ChannelEvent.TAG_ADD : ChannelEvent.TAG_ADD_FAILED, this); message.setObject(MessageProperty.TAG, tag); messageBus.fireEvent(message); return result; } finally { dataLock.writeLock().unlock(); } } /** * Updates an existing Tag * * @param tag Tag to update * @return true is successful */ public boolean updateTag(@NotNull Tag tag) { Objects.requireNonNull(tag); dataLock.writeLock().lock(); try { boolean result = eDAO.getTagDAO().update(tag); final Message message = new Message(MessageChannel.TAG, result ? ChannelEvent.TAG_MODIFY : ChannelEvent.TAG_MODIFY_FAILED, this); message.setObject(MessageProperty.TAG, tag); messageBus.fireEvent(message); return result; } finally { dataLock.writeLock().unlock(); } } /** * Retrieves all Tags * * @return a Set of Tags. */ @NotNull public Set getTags() { dataLock.readLock().lock(); try { return eDAO.getTagDAO().getTags(); } finally { dataLock.readLock().unlock(); } } /** * Retrieves the Tags that are in use * * @return a Set of Tags. */ @NotNull public Set getTagsInUse() { dataLock.readLock().lock(); try { return getTransactions() .parallelStream() .flatMap((Function>) transaction -> transaction.getTags().stream()) .collect(Collectors.toSet()); } finally { dataLock.readLock().unlock(); } } /** * Removes a Tag from the database. *

* The removal will fail if the Tag is in use. * * @param tag Tag to remove * @return true if successful */ public boolean removeTag(@NotNull Tag tag) { Objects.requireNonNull(tag); dataLock.writeLock().lock(); try { boolean result = !getTagsInUse().contains(tag); // make sure the tag is not used if (result) { result = moveObjectToTrash(tag); } final Message message = new Message(MessageChannel.TAG, result ? ChannelEvent.TAG_REMOVE : ChannelEvent.TAG_REMOVE_FAILED, this); message.setObject(MessageProperty.TAG, tag); messageBus.fireEvent(message); return result; } finally { dataLock.writeLock().unlock(); } } /** * Returns the unique identifier for this engine instance. * * @return uuid */ public String getUuid() { return uuid; } public void setPreference(@NotNull final String key, @Nullable final String value) { dataLock.writeLock().lock(); try { getConfig().setPreference(key, value); getConfigDAO().update(getConfig()); config = null; // clear stale cached reference Message message = new Message(MessageChannel.CONFIG, ChannelEvent.CONFIG_MODIFY, this); message.setObject(MessageProperty.CONFIG, getConfig()); messageBus.fireEvent(message); } finally { dataLock.writeLock().unlock(); } } @Nullable public String getPreference(@NotNull final String key) { dataLock.readLock().lock(); try { return getConfig().getPreference(key); } finally { dataLock.readLock().unlock(); } } public boolean getBoolean(@NotNull final String key, final boolean defaultValue) { boolean value = defaultValue; final String stringResult = getPreference(key); if (stringResult != null && !stringResult.isEmpty()) { value = Boolean.parseBoolean(stringResult); } return value; } public void putBoolean(@NotNull final String key, final boolean value) { setPreference(key, Boolean.toString(value)); } public boolean createBackups() { return getConfig().createBackups(); } public void setCreateBackups(final boolean createBackups) { dataLock.writeLock().lock(); try { final Config backupConfig = getConfig(); backupConfig.setCreateBackups(createBackups); getConfigDAO().update(backupConfig); config = null; // clear stale cached reference Message message = new Message(MessageChannel.CONFIG, ChannelEvent.CONFIG_MODIFY, this); message.setObject(MessageProperty.CONFIG, backupConfig); messageBus.fireEvent(message); } finally { dataLock.writeLock().unlock(); } } public int getRetainedBackupLimit() { return getConfig().getRetainedBackupLimit(); } public void setRetainedBackupLimit(final int retainedBackupLimit) { dataLock.writeLock().lock(); try { final Config backupConfig = getConfig(); backupConfig.setRetainedBackupLimit(retainedBackupLimit); getConfigDAO().update(backupConfig); config = null; // clear stale cached reference Message message = new Message(MessageChannel.CONFIG, ChannelEvent.CONFIG_MODIFY, this); message.setObject(MessageProperty.CONFIG, backupConfig); messageBus.fireEvent(message); } finally { dataLock.writeLock().unlock(); } } public boolean removeOldBackups() { return getConfig().removeOldBackups(); } public void setRemoveOldBackups(final boolean removeOldBackups) { dataLock.writeLock().lock(); try { final Config backupConfig = getConfig(); backupConfig.setRemoveOldBackups(removeOldBackups); getConfigDAO().update(backupConfig); config = null; // clear stale cached reference Message message = new Message(MessageChannel.CONFIG, ChannelEvent.CONFIG_MODIFY, this); message.setObject(MessageProperty.CONFIG, backupConfig); messageBus.fireEvent(message); } finally { dataLock.writeLock().unlock(); } } /** * Handles Background removal of {@code SecurityNode} history. This can an expensive operation that block normal * operations, so the removal is partitioned into small events to prevent stalling. * * @param securityNode SecurityNode being processed * @param daysOfWeek Collection of {code DayOfWeek} to remove */ public void removeSecurityHistoryByDayOfWeek(final SecurityNode securityNode, final Collection daysOfWeek) { final Thread thread = new Thread(() -> { long delay = 0; for (final SecurityHistoryNode historyNode : new ArrayList<>(securityNode.getHistoryNodes())) { for (final DayOfWeek dayOfWeek : daysOfWeek) { if (historyNode.getLocalDate().getDayOfWeek() == dayOfWeek) { backgroundExecutorService.schedule(new BackgroundCallable(() -> { removeSecurityHistory(securityNode, historyNode.getLocalDate()); return true; }), delay, TimeUnit.MILLISECONDS); delay += 750; } } } }); thread.setDaemon(true); thread.setPriority(Thread.MIN_PRIORITY); thread.start(); } /** * Thread to monitor background update of securities and terminate is network errors are occurring */ private class SecuritiesUpdateRunnable extends Thread { private final List backgroundCallables; private final int delay; SecuritiesUpdateRunnable(final List callables, final int delay) { this.backgroundCallables = callables; this.delay = delay; } @Override public void run() { try { TimeUnit.SECONDS.sleep(delay); // for controlled delay at startup } catch (final InterruptedException ignored) { return; } int errors = 0; final CompletionService completionService = new ExecutorCompletionService<>(backgroundExecutorService); // submit the callables for (final BackgroundCallable backgroundCallable : backgroundCallables) { try { completionService.submit(backgroundCallable); } catch (final RejectedExecutionException ignored) { // ignore, race to shut down the executor was won } } // poll until complete or there have been too many errors while (errors < MAX_ERRORS && !Thread.currentThread().isInterrupted()) { try { final Future future = completionService.poll(1, TimeUnit.MINUTES); if (future == null) { // all done, no issues break; } errors += future.get() ? 0 : 1; } catch (final InterruptedException | ExecutionException e) { errors = Integer.MAX_VALUE; logger.log(Level.SEVERE, e.getLocalizedMessage(), e); } } // if there are too many errors, force cancellation if (errors > MAX_ERRORS || Thread.currentThread().isInterrupted()) { for (final BackgroundCallable backgroundCallable : backgroundCallables) { backgroundCallable.cancel = true; // stop all other callables } } } } /** * Decorates a Callable to indicate background engine activity is occurring. */ private class BackgroundCallable implements Callable { private final Callable callable; volatile boolean cancel = false; // may be set to true to interrupt operation BackgroundCallable(@NotNull final Callable callable) { this.callable = callable; } @Override public Boolean call() throws Exception { if (!cancel) { if (backGroundCounter.incrementAndGet() == 1) { messageBus.fireEvent(new Message(MessageChannel.SYSTEM, ChannelEvent.BACKGROUND_PROCESS_STARTED, Engine.this)); } try { return callable.call(); } finally { if (backGroundCounter.decrementAndGet() == 0) { messageBus.fireEvent(new Message(MessageChannel.SYSTEM, ChannelEvent.BACKGROUND_PROCESS_STOPPED, Engine.this)); } } } return false; } } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/EngineException.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine; /** * Unchecked Engine Exception */ public class EngineException extends RuntimeException { public EngineException(final String message) { super(message); } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/EngineFactory.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.time.Instant; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.time.temporal.ChronoUnit; import java.util.Collection; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.UUID; import java.util.function.DoubleConsumer; import java.util.logging.Handler; import java.util.logging.Level; import java.util.logging.Logger; import java.util.prefs.Preferences; import jgnash.engine.jpa.JpaNetworkServer; import jgnash.engine.jpa.SqlUtils; import jgnash.engine.message.ChannelEvent; import jgnash.engine.message.Message; import jgnash.engine.message.MessageBus; import jgnash.engine.message.MessageChannel; import jgnash.engine.xstream.BinaryXStreamDataStore; import jgnash.engine.xstream.XMLDataStore; import jgnash.resource.util.OS; import jgnash.resource.util.ResourceUtils; import jgnash.util.FileMagic; import jgnash.util.FileMagic.FileType; import jgnash.util.FileUtils; import jgnash.util.Nullable; /** * Factory class for obtaining an engine instance. *

* The filename of the database or remote server must be explicitly set before an Engine instance will be returned * * @author Craig Cavanaugh */ public class EngineFactory { public static final char[] EMPTY_PASSWORD = new char[]{}; public static final String LOCALHOST = "localhost"; public static final String DEFAULT = "default"; public static final String REMOTE_PREFIX = "@"; private static final String LAST_DATABASE = "LastDatabase"; private static final String LAST_HOST = "LastHost"; private static final String LAST_PORT = "LastPort"; private static final String USED_PASSWORD = "LastUsedPassword"; private static final String LAST_REMOTE = "LastRemote"; /** * Default directory for jGnash data. To be located in the default user * directory */ private static final String DEFAULT_DIR = "jGnash"; private static final Logger logger = Logger.getLogger(EngineFactory.class.getName()); private static final Map engineMap = new HashMap<>(); private static final Map dataStoreMap = new HashMap<>(); private EngineFactory() { } /** * Registers a {@code Handler} with the class logger. * This also ensures the static logger is initialized. * * @param handler {@code Handler} to register */ public static void addLogHandler(final Handler handler) { logger.addHandler(handler); } public static boolean doesDatabaseExist(final String database, final DataStoreType type) { if (FileUtils.fileHasExtension(database)) { return Files.isReadable(Paths.get(database)); } return Files.isReadable(Paths.get(database + type.getDataStore().getFileExt())); } public static boolean deleteDatabase(final String database) { try { return Files.deleteIfExists(Paths.get(database)); } catch (final IOException e) { logger.warning(e.getLocalizedMessage()); return false; } } /** * Returns the engine with the given name. * * @param name engine name to look for * @return returns {@code null} if it does not exist */ @Nullable public static synchronized Engine getEngine(final String name) { return engineMap.get(name); } private static void exportCompressedXML(final String engineName) { final Engine oldEngine = engineMap.get(engineName); final DataStore oldDataStore = dataStoreMap.get(engineName); exportCompressedXML(oldDataStore.getFileName(), oldEngine.getStoredObjects()); } public static void exportCompressedXML(final String fileName, final Collection objects) { final DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("yyyyMMdd-HHmm"); final DataStore xmlDataStore = new XMLDataStore(); Path xmlFile = Paths.get(FileUtils.stripFileExtension(fileName) + "-" + dateTimeFormatter.format(LocalDateTime.now()) + xmlDataStore.getFileExt()); // push the intermediary file to the temporary directory xmlFile = Paths.get(System.getProperty("java.io.tmpdir") + xmlFile.getFileSystem().getSeparator() + xmlFile.getFileName().toString()); xmlDataStore.saveAs(xmlFile, objects, ignored -> { }); Path zipFile = Paths.get(FileUtils.stripFileExtension(fileName) + "-" + dateTimeFormatter.format(LocalDateTime.now()) + ".zip"); FileUtils.compressFile(xmlFile, zipFile); try { Files.delete(xmlFile); } catch (final IOException e) { logger.log(Level.SEVERE, e.getLocalizedMessage(), e); logger.log(Level.WARNING, "Was not able to delete the temporary file: {0}", xmlFile); } } public static void removeOldCompressedXML(final String fileName, final int limit) { final Path path = Paths.get(fileName); String baseFile = FileUtils.stripFileExtension(path.toString()); // '\' on Windows platform must be replaced with '\\' to prevent an exception if (OS.isSystemWindows()) { baseFile = baseFile.replace("\\","\\\\"); } // old files use the base file name plus a '-' and a 8 digit date plus a '-' and a 4 digit time stamp final List fileList = FileUtils.getDirectoryListing(path.getParent(), baseFile + "-\\d{8}-\\d{4}.zip"); if (fileList.size() > limit) { for (int i = 0; i < fileList.size() - limit; i++) { try { Files.delete(fileList.get(i)); } catch (final IOException e) { logger.log(Level.WARNING, "Unable to delete the file: {0}", fileList.get(i)); } } } } public static synchronized void closeEngine(final String engineName) { Engine oldEngine = engineMap.get(engineName); DataStore oldDataStore = dataStoreMap.get(engineName); if (oldEngine != null) { // stop and wait for all working background services to complete oldEngine.stopBackgroundServices(); // Post a message so the GUI knows what is going on final Message message = new Message(MessageChannel.SYSTEM, ChannelEvent.FILE_CLOSING, oldEngine); MessageBus.getInstance(engineName).fireBlockingEvent(message); // block until event has been completely processed if (oldEngine.isFileDirty()) { // should a backup file be created? // Dump an XML backup if (oldEngine.createBackups() && oldDataStore.isLocal()) { exportCompressedXML(engineName); } // Purge old backups if (oldEngine.removeOldBackups() && oldDataStore.isLocal()) { removeOldCompressedXML(oldDataStore.getFileName(), oldEngine.getRetainedBackupLimit()); } } else { logger.info("File was not dirty"); } // Initiate a complete shutdown oldEngine.shutdown(); MessageBus.getInstance(engineName).setLocal(); oldDataStore.closeEngine(); engineMap.remove(engineName); dataStoreMap.remove(engineName); } } /** * Boots a local Engine for a preexisting file. The API determines the * correct file type and uses the correct DataStoreType for engine * initialization. If successful, a new * {@code Engine} instance will be returned. * * @param fileName filename to load * @param engineName engine identifier * @param password connection password * @return new {@code Engine} instance if successful, null otherwise * @see Engine */ public static synchronized Engine bootLocalEngine(final String fileName, final String engineName, final char[] password) { final DataStoreType type = getDataStoreByType(fileName); Engine engine = null; if (type != null) { engine = bootLocalEngine(fileName, engineName, password, type); } return engine; } /** * Boots a local Engine for a file. If the file does not exist, it will be created. Otherwise it will be loaded. * If successful, a new {@code Engine} instance will be returned. * * @param fileName filename to load or create * @param engineName engine identifier * @param password password for the file * @param type {@code DataStoreType} type to use for storage * @return new {@code Engine} instance if successful * @see Engine * @see DataStoreType */ public static synchronized Engine bootLocalEngine(final String fileName, final String engineName, final char[] password, final DataStoreType type) { Instant start = Instant.now(); MessageBus.getInstance(engineName).setLocal(); final DataStore dataStore = type.getDataStore(); final Engine engine = dataStore.getLocalEngine(fileName, engineName, password); if (engine != null) { logger.info(ResourceUtils.getString("Message.EngineStart")); engineMap.put(engineName, engine); dataStoreMap.put(engineName, dataStore); Message message = new Message(MessageChannel.SYSTEM, ChannelEvent.FILE_LOAD_SUCCESS, engine); MessageBus.getInstance(engineName).fireEvent(message); if (engineName.equals(EngineFactory.DEFAULT)) { Preferences pref = Preferences.userNodeForPackage(EngineFactory.class); pref.putBoolean(USED_PASSWORD, password.length > 0); pref.put(LAST_DATABASE, fileName); pref.putBoolean(LAST_REMOTE, false); } logger.log(Level.INFO, "Boot time was {0} milliseconds", ChronoUnit.MILLIS.between(start, Instant.now())); } return engine; } public static synchronized Engine bootClientEngine(final String host, final int port, final char[] password, final String engineName) { if (engineMap.get(engineName) != null) { throw new EngineException("A stale engine was found in the map"); } final Preferences pref = Preferences.userNodeForPackage(EngineFactory.class); Engine engine = null; // start the client message bus if (MessageBus.getInstance(engineName).setRemote(host, port + JpaNetworkServer.MESSAGE_SERVER_INCREMENT, password)) { pref.putInt(LAST_PORT, port); pref.put(LAST_HOST, host); pref.putBoolean(LAST_REMOTE, true); final MessageBus messageBus = MessageBus.getInstance(engineName); // after starting the remote message bus, it should receive the path on the server final String remoteDataBasePath = messageBus.getRemoteDataBasePath(); final DataStoreType dataStoreType = messageBus.getRemoteDataStoreType(); if (remoteDataBasePath == null || remoteDataBasePath.isEmpty() || dataStoreType == null) { throw new EngineException("Invalid connection wih the message bus"); } logger.log(Level.INFO, "Remote path was {0}", remoteDataBasePath); logger.log(Level.INFO, "Remote data store was {0}", dataStoreType.name()); logger.log(Level.INFO, "Engine name was {0}", engineName); DataStore dataStore = dataStoreType.getDataStore(); // connect to the remote server engine = dataStore.getClientEngine(host, port, password, remoteDataBasePath); if (engine != null) { logger.info(ResourceUtils.getString("Message.EngineStart")); engineMap.put(engineName, engine); dataStoreMap.put(engineName, dataStore); // remember if the user used a password for the last session pref.putBoolean(USED_PASSWORD, password.length > 0); final Message message = new Message(MessageChannel.SYSTEM, ChannelEvent.FILE_LOAD_SUCCESS, engine); MessageBus.getInstance(engineName).fireEvent(message); } } return engine; } private static DataStoreType getDataStoreByType(final Path file) { final FileType type = FileMagic.magic(file); switch (type) { case jGnash2XML: return DataStoreType.XML; case BinaryXStream: return DataStoreType.BINARY_XSTREAM; case h2: return DataStoreType.H2_DATABASE; case h2mv: return DataStoreType.H2MV_DATABASE; case hsql: return DataStoreType.HSQL_DATABASE; default: break; } return null; } public static DataStoreType getDataStoreByType(final String fileName) { return getDataStoreByType(Paths.get(fileName)); } public static float getFileVersion(final Path file, final char[] password) { float version = 0; final FileType type = FileMagic.magic(file); switch (type) { case jGnash2XML: version = XMLDataStore.getFileVersion(file); break; case BinaryXStream: version = BinaryXStreamDataStore.getFileVersion(file); break; case h2: case h2mv: case hsql: try { version = SqlUtils.getFileVersion(file.toString(), password); } catch (final Exception e) { version = 0; } break; default: break; } return version; } /** * Returns the default path to the database without a file extension. * * @return Default path to the database */ public static synchronized String getDefaultDatabase() { final String base = System.getProperty("user.home"); final String userName = System.getProperty("user.name"); return base + FileUtils.SEPARATOR + DEFAULT_DIR + FileUtils.SEPARATOR + userName; } /** * Returns the last open database. If a database has not been opened, then * the default database will be returned. * * @return Last open or default database */ public static synchronized String getLastDatabase() { final Preferences pref = Preferences.userNodeForPackage(EngineFactory.class); return pref.get(LAST_DATABASE, getDefaultDatabase()); } public static synchronized String getActiveDatabase() { if (getLastRemote()) { return REMOTE_PREFIX + getLastHost(); } return getLastDatabase(); } /** * Returns the host of the last remote database connection. If a remote * database has not been opened, then the default host will be returned. * * @return Last remote database host or default */ public static synchronized String getLastHost() { final Preferences pref = Preferences.userNodeForPackage(EngineFactory.class); return pref.get(LAST_HOST, EngineFactory.LOCALHOST); } /** * Returns the port of the last remote database connection. If a remote * database has not been opened, then the default port will be returned. * * @return Last remote database port or default */ public static synchronized int getLastPort() { final Preferences pref = Preferences.userNodeForPackage(EngineFactory.class); return pref.getInt(LAST_PORT, JpaNetworkServer.DEFAULT_PORT); } /** * Returns true if the last connection was made to a remote host. * * @return true if the last connection was made to a remote host */ public static synchronized boolean getLastRemote() { final Preferences pref = Preferences.userNodeForPackage(EngineFactory.class); return pref.getBoolean(LAST_REMOTE, false); } public static synchronized boolean usedPassword() { final Preferences pref = Preferences.userNodeForPackage(EngineFactory.class); return pref.getBoolean(USED_PASSWORD, false); } /** * Saves the active database as a new file/format * * @param destination new file * @param percentCompleteConsumer progress consumer * @throws IOException IO error */ public static void saveAs(final String destination, final DoubleConsumer percentCompleteConsumer) throws IOException { final String fileExtension = "." + FileUtils.getFileExtension(destination); DataStoreType newFileType = DataStoreType.BINARY_XSTREAM; // default for a new file if (fileExtension.length() > 1) { // should have more than just the period in it for (final DataStoreType type : DataStoreType.values()) { if (type.getDataStore().getFileExt().equalsIgnoreCase(fileExtension)) { newFileType = type; break; } } } final Path newFile = Paths.get(FileUtils.stripFileExtension(destination) + newFileType.getDataStore().getFileExt()); final Path current = Paths.get(EngineFactory.getActiveDatabase()); // don't perform the save if the destination is going to overwrite the current database if (!current.equals(newFile)) { final DataStoreType currentType = dataStoreMap.get(EngineFactory.DEFAULT).getType(); // Need to create an interim copy when converting a relational database if (currentType.supportsRemote && newFileType.supportsRemote) { final Path tempFile = Files.createTempFile("jgnash-tmp", BinaryXStreamDataStore.FILE_EXT); Engine engine = EngineFactory.getEngine(EngineFactory.DEFAULT); if (engine != null) { // Get collection of object to persist Collection objects = engine.getStoredObjects(); // Write everything to a temporary file DataStoreType.BINARY_XSTREAM.getDataStore().saveAs(tempFile, objects, value -> { percentCompleteConsumer.accept(value * 0.5); // doing it twice }); // Close the current file EngineFactory.closeEngine(EngineFactory.DEFAULT); // Boot the engine using the temporary file EngineFactory.bootLocalEngine(tempFile.toString(), EngineFactory.DEFAULT, EngineFactory.EMPTY_PASSWORD); engine = EngineFactory.getEngine(EngineFactory.DEFAULT); if (engine != null) { // Get collection of object to persist objects = engine.getStoredObjects(); // Write everything to the new file and close newFileType.getDataStore().saveAs(newFile, objects, value -> percentCompleteConsumer.accept(0.5 + value * 0.5)); EngineFactory.closeEngine(EngineFactory.DEFAULT); percentCompleteConsumer.accept(1); // Boot the engine with the new file EngineFactory.bootLocalEngine(newFile.toString(), EngineFactory.DEFAULT, EngineFactory.EMPTY_PASSWORD); } try { Files.delete(tempFile); } catch (final IOException ioe) { Logger.getLogger(EngineFactory.class.getName()) .info(ResourceUtils.getString("Message.Error.RemoveTempFile")); } } } else { // Simple Engine engine = EngineFactory.getEngine(EngineFactory.DEFAULT); if (engine != null) { final Collection objects = engine.getStoredObjects(); newFileType.getDataStore().saveAs(newFile, objects, percentCompleteConsumer); EngineFactory.closeEngine(EngineFactory.DEFAULT); EngineFactory.bootLocalEngine(newFile.toString(), EngineFactory.DEFAULT, EngineFactory.EMPTY_PASSWORD); } } } } /** * Saves a closed database as a new file/format * * @param fileName file to save a copy of * @param newFileName new file * @param password password * @param percentCompleteConsumer progress consumer * * @throws IOException IO error */ public static void saveAs(final String fileName, final String newFileName, final char[] password, final DoubleConsumer percentCompleteConsumer) throws IOException { Objects.requireNonNull(fileName); Objects.requireNonNull(newFileName); Objects.requireNonNull(password); final String ENGINE = UUID.randomUUID().toString(); // create a temporary engine ID for utility use only final String fileExtension = "." + FileUtils.getFileExtension(newFileName); DataStoreType newFileType = DataStoreType.BINARY_XSTREAM; // default for a new file // Determine the data store type given the file extension if (fileExtension.length() > 1) { // should have more than just the period in it for (final DataStoreType type : DataStoreType.values()) { if (type.getDataStore().getFileExt().equalsIgnoreCase(fileExtension)) { newFileType = type; break; } } } final Path newFile = Paths.get(FileUtils.stripFileExtension(newFileName) + newFileType.getDataStore().getFileExt()); final Path current = Paths.get(fileName); // don't perform the save if the destination is going to overwrite the current database if (!current.equals(newFile)) { // Need to know the data store type for correct behavior final DataStoreType currentType = EngineFactory.getDataStoreByType(fileName); Objects.requireNonNull(currentType); // fail if type is null // Create a utility engine instead of using the default Engine engine = EngineFactory.bootLocalEngine(fileName, ENGINE, password); if (currentType.supportsRemote && newFileType.supportsRemote) { // Relational database final Path tempFile = Files.createTempFile("jgnash", BinaryXStreamDataStore.FILE_EXT); if (engine != null) { // Get collection of object to persist Collection objects = engine.getStoredObjects(); // Write everything to a temporary file DataStoreType.BINARY_XSTREAM.getDataStore().saveAs(tempFile, objects, value -> { percentCompleteConsumer.accept(value * 0.5); // doing it twice }); EngineFactory.closeEngine(ENGINE); // Boot the engine with the temporary file engine = EngineFactory.bootLocalEngine(tempFile.toString(), ENGINE, EngineFactory.EMPTY_PASSWORD); if (engine != null) { // Get collection of object to persist objects = engine.getStoredObjects(); // Write everything to the new file newFileType.getDataStore().saveAs(newFile, objects, value -> percentCompleteConsumer.accept(0.5 + value * 0.5)); EngineFactory.closeEngine(ENGINE); // reset the password SqlUtils.changePassword(newFileName, EngineFactory.EMPTY_PASSWORD, password); percentCompleteConsumer.accept(1); } try { Files.delete(tempFile); } catch (final IOException ioe) { Logger.getLogger(EngineFactory.class.getName()) .info(ResourceUtils.getString("Message.Error.RemoveTempFile")); } } } else { // Simple if (engine != null) { final Collection objects = engine.getStoredObjects(); newFileType.getDataStore().saveAs(newFile, objects, percentCompleteConsumer); EngineFactory.closeEngine(ENGINE); } } } } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/ExchangeRate.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine; import java.math.BigDecimal; import java.time.LocalDate; import java.util.ArrayList; import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Set; import java.util.concurrent.locks.ReadWriteLock; import java.util.concurrent.locks.ReentrantReadWriteLock; import java.util.logging.Level; import java.util.logging.Logger; import javax.persistence.CascadeType; import javax.persistence.Entity; import javax.persistence.JoinTable; import javax.persistence.OneToMany; import javax.persistence.OrderBy; import javax.persistence.PostLoad; import jgnash.util.Nullable; /** * Exchange rate object. * * @author Craig Cavanaugh */ @Entity public class ExchangeRate extends StoredObject { @JoinTable @OrderBy("date") //applying a sort order prevents refresh issues @OneToMany(cascade = {CascadeType.ALL}) private final Set historyNodes = new HashSet<>(); /** * Cache the last exchange rate. */ transient private BigDecimal lastRate; /** * Identifier for the ExchangeRate object. */ private String rateId; /** * ReadWrite lock. */ private transient ReadWriteLock lock = new ReentrantReadWriteLock(); /** * No argument constructor for reflection purposes. *

* Do not use to create a new instance */ @SuppressWarnings("unused") public ExchangeRate() { } ExchangeRate(final String rateId) { this.rateId = rateId; } public boolean contains(final ExchangeRateHistoryNode node) { lock.readLock().lock(); try { return historyNodes.contains(node); } finally { lock.readLock().unlock(); } } public boolean contains(final LocalDate localDate) { lock.readLock().lock(); boolean result = false; try { for (final ExchangeRateHistoryNode node : historyNodes) { if (localDate.compareTo(node.getLocalDate()) == 0) { result = true; break; } } } finally { lock.readLock().unlock(); } return result; } public List getHistory() { // return a defensive copy List nodes = new ArrayList<>(historyNodes); Collections.sort(nodes); return nodes; } boolean addHistoryNode(final ExchangeRateHistoryNode node) { boolean result = false; lock.writeLock().lock(); try { historyNodes.add(node); lastRate = null; // force an update result = true; } catch (final Exception ex) { Logger.getLogger(ExchangeRate.class.getName()).log(Level.SEVERE, ex.getLocalizedMessage(), ex); } finally { lock.writeLock().unlock(); } return result; } @Nullable ExchangeRateHistoryNode getHistory(final LocalDate localDate) { ExchangeRateHistoryNode node = null; lock.readLock().lock(); try { for (final ExchangeRateHistoryNode historyNode : historyNodes) { if (localDate.compareTo(historyNode.getLocalDate()) == 0) { node = historyNode; break; } } } finally { lock.readLock().unlock(); } return node; } boolean removeHistoryNode(final ExchangeRateHistoryNode hNode) { lock.writeLock().lock(); try { final boolean result = historyNodes.remove(hNode); if (result) { lastRate = null; // force an update } return result; } finally { lock.writeLock().unlock(); } } public String getRateId() { return rateId; } public BigDecimal getRate() { lock.readLock().lock(); try { if (lastRate == null) { if (!historyNodes.isEmpty()) { List nodes = getHistory(); lastRate = nodes.get(nodes.size() - 1).getRate(); } else { lastRate = BigDecimal.ONE; } } } finally { lock.readLock().unlock(); } return lastRate; } /** * Returns the exchange rate for a given {@code LocalDate}. *

* If a rate has not be set, {@code BigDecimal.ZERO} is returned * * @param localDate {@code LocalDate} for exchange * @return the exchange rate if known, otherwise {@code BigDecimal.ZERO} */ BigDecimal getRate(final LocalDate localDate) { lock.readLock().lock(); BigDecimal rate = BigDecimal.ZERO; try { for (ExchangeRateHistoryNode historyNode : historyNodes) { if (localDate.equals(historyNode.getLocalDate())) { rate = historyNode.getRate(); break; } } } finally { lock.readLock().unlock(); } return rate; } @Override public boolean equals(final Object other) { return this == other || other instanceof ExchangeRate && rateId.equals(((ExchangeRate) other).rateId); } @Override public int hashCode() { return super.hashCode() * 67 + rateId.hashCode(); } /** * Required by XStream for proper initialization. * * @return Properly initialized ExchangeRate */ protected Object readResolve() { postLoad(); return this; } @PostLoad private void postLoad() { lock = new ReentrantReadWriteLock(true); } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/ExchangeRateDAO.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine; import jgnash.engine.dao.CommodityDAO; /** * DAO for exchange rate access. * * @author Craig Cavanaugh * */ class ExchangeRateDAO { private final CommodityDAO commodityDAO; ExchangeRateDAO(final CommodityDAO commodityDAO) { this.commodityDAO = commodityDAO; } ExchangeRate getExchangeRateNode(final CurrencyNode baseCurrency, final CurrencyNode exchangeCurrency) { if (baseCurrency.equals(exchangeCurrency)) { return null; } final String rateId = Engine.buildExchangeRateId(baseCurrency, exchangeCurrency); ExchangeRate node = commodityDAO.getExchangeNode(rateId); if (node == null) { node = new ExchangeRate(Engine.buildExchangeRateId(baseCurrency, exchangeCurrency)); commodityDAO.addExchangeRate(node); } return node; } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/ExchangeRateHistoryNode.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine; import jgnash.util.NotNull; import javax.persistence.Column; import javax.persistence.Entity; import javax.persistence.GeneratedValue; import javax.persistence.GenerationType; import javax.persistence.Id; import javax.persistence.SequenceGenerator; import java.io.Serializable; import java.math.BigDecimal; import java.time.LocalDate; import java.util.Objects; /** * Exchange rate history node for a {@code ExchangeRate}. * {@code ExchangeRateHistoryNode} objects are immutable. * * @author Craig Cavanaugh */ @Entity @SequenceGenerator(name = "sequence", allocationSize = 10) public class ExchangeRateHistoryNode implements Comparable, Serializable { @SuppressWarnings("UnusedDeclaration") @Id @GeneratedValue(generator = "sequence", strategy = GenerationType.SEQUENCE) public long id; @Column(precision = 20, scale = 8) private BigDecimal rate = BigDecimal.ZERO; private LocalDate date = LocalDate.now(); /** * No argument constructor for reflection purposes. * Do not use to create a new instance */ @SuppressWarnings("unused") protected ExchangeRateHistoryNode() { } /** * Constructor. * * @param localDate date for this history node. The date will be trimmed * @param rate exchange rate for the given date */ ExchangeRateHistoryNode(final LocalDate localDate, final BigDecimal rate) { Objects.requireNonNull(date); Objects.requireNonNull(rate); this.date = localDate; this.rate = rate; } public LocalDate getLocalDate() { return date; } @Override public int compareTo(@NotNull final ExchangeRateHistoryNode node) { return getLocalDate().compareTo(node.getLocalDate()); } @Override public boolean equals(final Object o) { return this == o || o instanceof ExchangeRateHistoryNode && date.equals(((ExchangeRateHistoryNode) o).date); } @Override public int hashCode() { return date.hashCode(); } public BigDecimal getRate() { return rate; } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/InvestmentAccountProxy.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine; import java.math.BigDecimal; import java.time.LocalDate; import java.util.HashMap; import java.util.List; import java.util.concurrent.locks.Lock; /** * Investment Account Proxy class. * * @author Craig Cavanaugh */ class InvestmentAccountProxy extends AccountProxy { public InvestmentAccountProxy(final Account account) { super(account); } @Override public BigDecimal getBalance(final LocalDate start, final LocalDate end) { return getCashBalance(start, end).add(getMarketValue(start, end)); } /** * Returns the cash balance plus the market value of the shares. * * @return cash balance */ @Override public BigDecimal getBalance() { return getCashBalance().add(getMarketValue()); } @Override public BigDecimal getBalance(final LocalDate date) { return getCashBalance(date).add(getMarketValue(date)); } /** * Returns the cash balance of this account. Cash balance may be referred to as the "sweep" account where * the money market fund (cash) does not have it's own account number and the user see's it as a cash balance * in their account statements. * * @return cash balance of the account */ @Override public BigDecimal getCashBalance() { return super.getBalance(); } /** * Get the account's cash balance up to a specified index. * * @param index the balance of the account at the specified index. * @return the balance of the account at the specified index. */ private BigDecimal getCashBalanceAt(final int index) { return super.getBalanceAt(index); } /** * Returns the balance of the transactions inclusive of the start and end dates. *

* The balance includes the cash transactions and is based on the current market value. * * @param start The inclusive start date * @param end The inclusive end date * @return The ending balance */ private BigDecimal getCashBalance(final LocalDate start, final LocalDate end) { return super.getBalance(start, end); } /** * Returns the cash account balance up to and inclusive of the supplied date. * * @param end The inclusive ending date * @return The ending cash balance */ private BigDecimal getCashBalance(final LocalDate end) { final Lock l = account.getTransactionLock().readLock(); l.lock(); try { return !account.transactions.isEmpty() ? getCashBalance(account.getSortedTransactionList().get(0).getLocalDate(), end) : BigDecimal.ZERO; } finally { l.unlock(); } } /** * Returns a market price for the supplied {@code SecurityNode} that is closest to the supplied date without * exceeding it. The history of the {@code SecurityNode} is searched as well as the account's transaction * history to find the closest market price without exceeding the supplied date. * * @param node security to search against * @param date date to search against * @return market price */ private BigDecimal getMarketPrice(final SecurityNode node, final LocalDate date) { account.getTransactionLock().readLock().lock(); try { return Engine.getMarketPrice(account.getSortedTransactionList(), node, account.getCurrencyNode(), date); } finally { account.getTransactionLock().readLock().unlock(); } } /** * Returns the market value of this account. * * @return the market value of the account */ @Override public BigDecimal getMarketValue() { final Lock l = account.getTransactionLock().readLock(); l.lock(); try { BigDecimal marketValue = BigDecimal.ZERO; int count = account.getTransactionCount(); if (count > 0) { LocalDate lastDate = account.getSortedTransactionList().get(count - 1).getLocalDate(); /* * If the user was to enter a date value greater than the current date, then * "new Date()" is not sufficient to pick up the last transaction. If the * current date is greater, than it is used to force use of the latest * security price. */ final LocalDate startDate = account.getSortedTransactionList().get(0).getLocalDate(); if (lastDate.compareTo(LocalDate.now()) >= 0) { marketValue = getMarketValue(startDate, lastDate); } else { marketValue = getMarketValue(startDate, LocalDate.now()); } } return marketValue; } finally { l.unlock(); } } /** * Returns the market value of the account at a specified date. The closest market price is used and only investment * transactions earlier and inclusive of the specified date are considered. * * @param date the end date to calculate the market value * @return the ending balance */ private BigDecimal getMarketValue(final LocalDate date) { final Lock l = account.getTransactionLock().readLock(); l.lock(); try { BigDecimal marketValue = BigDecimal.ZERO; if (account.getTransactionCount() > 0) { marketValue = getMarketValue(account.getSortedTransactionList().get(0).getLocalDate(), date); } return marketValue; } finally { l.unlock(); } } /** * Returns the market value for an account. * * @param start inclusive start date * @param end inclusive end date * @return market value */ private BigDecimal getMarketValue(final LocalDate start, final LocalDate end) { final Lock l = account.getTransactionLock().readLock(); l.lock(); try { final HashMap priceMap = new HashMap<>(); // build lookup map for market prices for (final SecurityNode node : account.getSecurities()) { priceMap.put(node, getMarketPrice(node, end)); } BigDecimal balance = BigDecimal.ZERO; // Get a defensive copy, JPA lazy updates can have side effects List transactions = account.getSortedTransactionList(); for (final Transaction t : transactions) { if (t.getLocalDate().compareTo(start) >= 0 && t.getLocalDate().compareTo(end) <= 0) { if (t instanceof InvestmentTransaction) { balance = balance.add(((InvestmentTransaction) t).getMarketValue(priceMap.get(((InvestmentTransaction) t).getSecurityNode()))); } } } return round(balance); } finally { l.unlock(); } } /** * Calculates the accounts market value based on the latest security price. * * @param index index to calculate the balance to * @return market value */ private BigDecimal getMarketValueAt(final int index) { final Lock l = account.getTransactionLock().readLock(); l.lock(); try { final HashMap priceMap = new HashMap<>(); LocalDate today = LocalDate.now(); // build lookup map for market prices for (final SecurityNode node : account.getSecurities()) { priceMap.put(node, getMarketPrice(node, today)); } BigDecimal balance = BigDecimal.ZERO; for (int i = 0; i <= index; i++) { Transaction t = account.getTransactionAt(i); if (t instanceof InvestmentTransaction) { balance = balance.add(((InvestmentTransaction) t).getMarketValue(priceMap.get(((InvestmentTransaction) t).getSecurityNode()))); } } return round(balance); } finally { l.unlock(); } } private BigDecimal getReconciledMarketValue() { final Lock l = account.getTransactionLock().readLock(); l.lock(); try { final HashMap priceMap = new HashMap<>(); // build lookup map for market prices for (final SecurityNode node :account.getSecurities()) { priceMap.put(node, getMarketPrice(node, LocalDate.now())); } BigDecimal balance = BigDecimal.ZERO; // Get a defensive copy, JPA lazy updates can have side effects List transactions = account.getSortedTransactionList(); for (final Transaction t : transactions) { if (t instanceof InvestmentTransaction && t.getReconciled(account) == ReconciledState.RECONCILED) { balance = balance.add(((InvestmentTransaction) t).getMarketValue(priceMap.get(((InvestmentTransaction) t).getSecurityNode()))); } } return round(balance); } finally { l.unlock(); } } /** * Calculates the reconciled balance of the account. * * @return the reconciled balance of this account */ @Override public BigDecimal getReconciledBalance() { return super.getReconciledBalance().add(getReconciledMarketValue()); } /** * Get the default opening balance for reconciling the account. * * @return Opening balance for reconciling the account */ @Override public BigDecimal getOpeningBalanceForReconcile() { final Lock l = account.getTransactionLock().readLock(); l.lock(); try { final LocalDate date = account.getFirstUnreconciledTransactionDate(); BigDecimal balance = BigDecimal.ZERO; final List transactions = account.getSortedTransactionList(); for (int i = 0; i < transactions.size(); i++) { if (transactions.get(i).getLocalDate().equals(date)) { if (i > 0) { balance = getCashBalanceAt(i - 1).add(getMarketValueAt(i - 1)); } break; } } return round(balance); } finally { l.unlock(); } } /** * Scales / Rounds a given value to the scale of the accounts currency. Calculating Market value will result * in minor discrepancies. Use this before returning values for consistent calculations. * * @param value value to round * @return rounded value */ private BigDecimal round(final BigDecimal value) { return value.setScale(account.getCurrencyNode().getScale(), MathConstants.roundingMode); } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/InvestmentPerformanceSummary.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine; import java.math.BigDecimal; import java.text.NumberFormat; import java.time.LocalDate; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; import java.util.TreeMap; import org.apache.commons.lang3.tuple.ImmutablePair; import org.apache.commons.lang3.tuple.Pair; /** * Investment Performance Summary Class. * * @author Craig Cavanaugh */ public class InvestmentPerformanceSummary { private final Account account; private LocalDate startDate; private LocalDate endDate; private final Map performanceData = new TreeMap<>(); private final List transactions; private final CurrencyNode baseCurrency; /** * If true, recurse into sub accounts */ private final boolean recursive; public InvestmentPerformanceSummary(final Account account, final LocalDate startDate, final LocalDate endDate, final boolean recursive) { Objects.requireNonNull(account, "Account may not be null"); this.recursive = recursive; if (!account.memberOf(AccountGroup.INVEST)) { throw new IllegalArgumentException("The account is not a valid type"); } this.baseCurrency = account.getCurrencyNode(); this.account = account; if (startDate == null || endDate == null) { final Pair datePair = getTransactionDateRange(account, recursive); setStartDate(datePair.getLeft()); setEndDate(datePair.getRight()); } else { setStartDate(startDate); setEndDate(endDate); } transactions = account.getTransactions(getStartDate(), getEndDate()); if (recursive && account.getChildCount() > 0) { collectSubAccountTransactions(account, transactions); } Collections.sort(transactions); } public static Pair getTransactionDateRange(final Account account, final boolean recursive) { List transactions = new ArrayList<>(account.getSortedTransactionList()); if (recursive && account.getChildCount() > 0) { _collectSubAccountTransactions(account, transactions); } transactions.sort(null); return new ImmutablePair<>(transactions.get(0).getLocalDate(), transactions.get(transactions.size() - 1).getLocalDate()); } private static void _collectSubAccountTransactions(final Account account, final List transactions) { for (final Account child : account.getChildren(Comparators.getAccountByCode())) { transactions.addAll(child.getSortedTransactionList()); if (child.getChildCount() > 0) { _collectSubAccountTransactions(child, transactions); } } } private void collectSubAccountTransactions(final Account account, final List transactions) { for (final Account child : account.getChildren(Comparators.getAccountByCode())) { transactions.addAll(child.getTransactions(getStartDate(), getEndDate())); if (child.getChildCount() > 0) { collectSubAccountTransactions(child, transactions); } } } private void collectSubAccountSecurities(final Account account, final Set securities) { for (final Account child : account.getChildren(Comparators.getAccountByCode())) { securities.addAll(child.getSecurities()); if (child.getChildCount() > 0) { collectSubAccountSecurities(child, securities); } } } public SecurityPerformanceData getPerformanceData(final SecurityNode node) { return performanceData.get(node); } public List getSecurities() { return new ArrayList<>(performanceData.keySet()); } /** * Calculates the cost basis of a given security which is the average cost including fees. * * @param data SecurityPerformanceData object to save the result in * @param transactions transactions to calculate cost basis against */ private void calculateCostBasis(final SecurityPerformanceData data, final List transactions) { SecurityNode node = data.getNode(); BigDecimal totalShares = BigDecimal.ZERO; BigDecimal totalCost = BigDecimal.ZERO; for (Transaction transaction : transactions) { if (transaction instanceof InvestmentTransaction) { InvestmentTransaction t = (InvestmentTransaction) transaction; if (t.getSecurityNode().equals(node)) { BigDecimal rate = baseCurrency.getExchangeRate(t.getInvestmentAccount().getCurrencyNode()); BigDecimal fees = t.getFees().multiply(rate); BigDecimal quantity = t.getQuantity(); BigDecimal price = t.getPrice().multiply(rate); switch (t.getTransactionType()) { case BUYSHARE: case REINVESTDIV: totalShares = totalShares.add(quantity); totalCost = totalCost.add(price.multiply(quantity).add(fees)); break; case SPLITSHARE: totalShares = totalShares.add(quantity); break; case MERGESHARE: totalShares = totalShares.subtract(quantity); break; case DIVIDEND: // do nothing, no fees, no shares added. break; default: break; } } } } if (totalShares.compareTo(BigDecimal.ZERO) != 0) { data.setCostBasisShares(totalShares); data.setCostBasisPerShare(totalCost.divide(totalShares, MathConstants.mathContext)); } } /** * Calculates the realized gains of a given Security. * * @param data SecurityPerformanceData object to save the result in * @param transactions transactions to calculate the realized gains */ private void calculateRealizedGains(final SecurityPerformanceData data, final List transactions) { SecurityNode node = data.getNode(); BigDecimal totalSharesSold = BigDecimal.ZERO; BigDecimal totalSales = BigDecimal.ZERO; for (Transaction transaction : transactions) { if (transaction instanceof InvestmentTransaction) { InvestmentTransaction t = (InvestmentTransaction) transaction; if (t.getSecurityNode().equals(node)) { BigDecimal rate = baseCurrency.getExchangeRate(t.getInvestmentAccount().getCurrencyNode()); BigDecimal fees = t.getFees().multiply(rate); BigDecimal quantity = t.getQuantity(); BigDecimal price = t.getPrice().multiply(rate); switch (t.getTransactionType()) { case SELLSHARE: totalSharesSold = totalSharesSold.add(quantity); totalSales = totalSales.add(price.multiply(quantity).subtract(fees)); break; case DIVIDEND: totalSales = totalSales.add(t.getTotalWithoutCashTransfer(t.getInvestmentAccount()).multiply(rate)); break; case REINVESTDIV: totalSales = totalSales.add(t.getTotalWithoutCashTransfer(t.getInvestmentAccount()).multiply(rate)).subtract(fees); break; default: break; } } } } if (totalSharesSold.compareTo(BigDecimal.ZERO) != 0) { data.setAvgSalePrice(totalSales.divide(totalSharesSold, MathConstants.mathContext)); data.setRealizedGains(data.getAvgSalePrice().subtract(data.getCostBasisPerShare()).multiply(totalSharesSold)); } else if (totalSales.compareTo(BigDecimal.ZERO) != 0) { // pure dividends and no share purchased or sold data.setRealizedGains(totalSales); } } private static void calculateUnrealizedGains(final SecurityPerformanceData data) { if (data.getSharesHeld().compareTo(BigDecimal.ZERO) != 0) { data.setUnrealizedGains(data.getPrice().subtract(data.getCostBasisPerShare()).multiply(data.getSharesHeld())); } } private static void calculateTotalGains(final SecurityPerformanceData data) { data.setTotalGains(data.getUnrealizedGains().add(data.getRealizedGains())); if (data.getTotalCostBasis().compareTo(BigDecimal.ZERO) != 0) { data.setTotalGainsPercentage(data.getTotalGains().divide(data.getTotalCostBasis(), MathConstants.mathContext)); } } /** * Sums the number of given security shares. * * @param data SecurityPerformanceData object to save the result in * @param transactions transactions to count shares against */ private static void calculateShares(final SecurityPerformanceData data, final List transactions) { SecurityNode node = data.getNode(); BigDecimal shares = BigDecimal.ZERO; for (Transaction transaction : transactions) { if (transaction instanceof InvestmentTransaction) { InvestmentTransaction t = (InvestmentTransaction) transaction; if (t.getSecurityNode().equals(node)) { switch (t.getTransactionType()) { case ADDSHARE: case BUYSHARE: case REINVESTDIV: case SPLITSHARE: shares = shares.add(t.getQuantity()); break; case REMOVESHARE: case SELLSHARE: case MERGESHARE: shares = shares.subtract(t.getQuantity()); break; default: break; } } } } data.setSharesHeld(data.getSharesHeld().add(shares).setScale(MathConstants.SECURITY_QUANTITY_ACCURACY, MathConstants.roundingMode)); } private void calculatePercentPortfolio() { BigDecimal marketValue = BigDecimal.ZERO; for (SecurityPerformanceData data : performanceData.values()) { marketValue = marketValue.add(data.getMarketValue(account.getCurrencyNode())); } for (SecurityPerformanceData data : performanceData.values()) { if (data.getMarketValue().compareTo(BigDecimal.ZERO) != 0) { BigDecimal percentage = data.getMarketValue(account.getCurrencyNode()).divide(marketValue, MathConstants.mathContext); data.setPercentPortfolio(percentage); } } } /** * Calculates the internal rate of return of a given security. * * @param data SecurityPerformanceData object to save the result in * @param transactions transactions to obtain the cash flow */ private void calculateInternalRateOfReturn(final SecurityPerformanceData data, final List transactions) { SecurityNode node = data.getNode(); CashFlow cashFlow = new CashFlow(); BigDecimal totalShares = BigDecimal.ZERO; for (Transaction transaction : transactions) { if (transaction instanceof InvestmentTransaction) { InvestmentTransaction t = (InvestmentTransaction) transaction; if (t.getSecurityNode().equals(node)) { BigDecimal rate = baseCurrency.getExchangeRate(t.getInvestmentAccount().getCurrencyNode()); BigDecimal fees = t.getFees().multiply(rate); BigDecimal quantity = t.getQuantity(); BigDecimal price = t.getPrice().multiply(rate); switch (t.getTransactionType()) { case BUYSHARE: totalShares = totalShares.add(quantity); cashFlow.add(t.getLocalDate(), price.multiply(quantity).add(fees).negate()); break; case SELLSHARE: totalShares = totalShares.subtract(quantity); cashFlow.add(t.getLocalDate(), price.multiply(quantity).subtract(fees)); break; case SPLITSHARE: case ADDSHARE: totalShares = totalShares.add(quantity); break; case MERGESHARE: case REMOVESHARE: totalShares = totalShares.subtract(quantity); break; case DIVIDEND: case RETURNOFCAPITAL: cashFlow.add(t.getLocalDate(), t.getTotalWithoutCashTransfer(t.getInvestmentAccount()).multiply(rate)); break; case REINVESTDIV: totalShares = totalShares.add(quantity); cashFlow.add(t.getLocalDate(), t.getTotalWithoutCashTransfer(t.getInvestmentAccount()).multiply(rate)); break; default: break; } } } } // unrealized gains cashFlow.add(getEndDate(), totalShares.multiply(getMarketPrice(node, getEndDate()))); data.setInternalRateOfReturn(cashFlow.internalRateOfReturn()); } public void runCalculations() { Set nodes = account.getSecurities(); if (recursive) { collectSubAccountSecurities(account, nodes); } for (final SecurityNode node : nodes) { SecurityPerformanceData data = new SecurityPerformanceData(node); data.setPrice(getMarketPrice(node, getEndDate())); performanceData.put(node, data); calculateShares(data, transactions); calculateCostBasis(data, transactions); calculateRealizedGains(data, transactions); calculateUnrealizedGains(data); calculateTotalGains(data); calculateInternalRateOfReturn(data, transactions); } calculatePercentPortfolio(); } private BigDecimal getMarketPrice(final SecurityNode node, final LocalDate date) { return Engine.getMarketPrice(transactions, node, baseCurrency, date); } @Override public String toString() { final StringBuilder b = new StringBuilder(); final NumberFormat percentageFormat = NumberFormat.getPercentInstance(); percentageFormat.setMinimumFractionDigits(2); final String lineSep = System.lineSeparator(); for (SecurityPerformanceData data : performanceData.values()) { b.append(data.getNode().getSymbol()).append(lineSep); b.append("sharesHeld: ").append(data.getSharesHeld().toPlainString()).append(lineSep); b.append("price: ").append(data.getPrice().toPlainString()).append(lineSep); b.append("costBasisPerShare: ").append(data.getCostBasisPerShare().toPlainString()).append(lineSep); b.append("costBasisShares: ").append(data.getCostBasisShares().toPlainString()).append(lineSep); b.append("totalCostBasis: ").append(data.getTotalCostBasis().toPlainString()).append(lineSep); b.append("heldCostBasis: ").append(data.getHeldCostBasis().toPlainString()).append(lineSep); b.append("marketValue: ").append(data.getMarketValue().toPlainString()).append(lineSep); b.append("unrealizedGains: ").append(data.getUnrealizedGains().toPlainString()).append(lineSep); b.append("realizedGains: ").append(data.getRealizedGains().toPlainString()).append(lineSep); b.append("totalGains: ").append(data.getTotalGains().toPlainString()).append(lineSep); b.append("totalGainsPercentage: ").append(percentageFormat.format(data.getTotalGainsPercentage())).append(lineSep); b.append("sharesSold: ").append(data.getSharesSold().toPlainString()).append(lineSep); b.append("avgSalePrice: ").append(data.getAvgSalePrice().toPlainString()).append(lineSep); b.append("percentPortfolio: ").append(percentageFormat.format(data.getPercentPortfolio())).append(lineSep); b.append("internalRateOfReturn: ").append(percentageFormat.format(data.getInternalRateOfReturn())).append(lineSep); b.append(lineSep); } return b.toString(); } private void setStartDate(LocalDate startDate) { this.startDate = startDate; } private LocalDate getStartDate() { return startDate; } private void setEndDate(LocalDate endDate) { this.endDate = endDate; } private LocalDate getEndDate() { return endDate; } public class SecurityPerformanceData { private SecurityNode node; private BigDecimal sharesHeld = BigDecimal.ZERO; private BigDecimal costBasisPerShare = BigDecimal.ZERO; private BigDecimal costBasisShares = BigDecimal.ZERO; private BigDecimal avgSalePrice = BigDecimal.ZERO; private BigDecimal price = BigDecimal.ZERO; private BigDecimal realizedGains = BigDecimal.ZERO; private BigDecimal unrealizedGains = BigDecimal.ZERO; private BigDecimal totalGains = BigDecimal.ZERO; private BigDecimal totalGainsPercentage = BigDecimal.ZERO; private BigDecimal percentPortfolio = BigDecimal.ZERO; private double internalRateOfReturn = 0.; SecurityPerformanceData(final SecurityNode node) { setNode(node); } public BigDecimal getCostBasisPerShare() { return costBasisPerShare; } public BigDecimal getCostBasisShares() { return costBasisShares; } public BigDecimal getMarketValue() { return getPrice().multiply(getSharesHeld(), MathConstants.mathContext); } public BigDecimal getMarketValue(final CurrencyNode currencyNode) { return getPrice(currencyNode).multiply(getSharesHeld(), MathConstants.mathContext); } public SecurityNode getNode() { return node; } public BigDecimal getSharesHeld() { return sharesHeld; } public BigDecimal getPrice(final CurrencyNode currencyNode) { return price.multiply(account.getCurrencyNode().getExchangeRate(currencyNode)); } public BigDecimal getPrice() { return price; } public BigDecimal getRealizedGains() { return realizedGains; } public BigDecimal getTotalCostBasis() { return getCostBasisPerShare().multiply(getCostBasisShares(), MathConstants.mathContext); } public BigDecimal getHeldCostBasis() { return getCostBasisPerShare().multiply(getSharesHeld(), MathConstants.mathContext); } public BigDecimal getTotalGains() { return totalGains; } public BigDecimal getTotalGainsPercentage() { return totalGainsPercentage; } public BigDecimal getUnrealizedGains() { return unrealizedGains; } public double getInternalRateOfReturn() { return internalRateOfReturn; } void setCostBasisPerShare(final BigDecimal costBasis) { this.costBasisPerShare = costBasis; } void setCostBasisShares(final BigDecimal costBasisShares) { this.costBasisShares = costBasisShares; } private void setNode(final SecurityNode node) { this.node = node; } void setSharesHeld(final BigDecimal sharesHeld) { this.sharesHeld = sharesHeld; } void setPrice(final BigDecimal price) { this.price = price; } void setAvgSalePrice(final BigDecimal avgSalePrice) { this.avgSalePrice = avgSalePrice; } void setUnrealizedGains(final BigDecimal unrealizedGains) { this.unrealizedGains = unrealizedGains; } void setRealizedGains(final BigDecimal realizedGains) { this.realizedGains = realizedGains; } void setTotalGains(final BigDecimal totalGains) { this.totalGains = totalGains; } void setTotalGainsPercentage(final BigDecimal totalGainsPercentage) { this.totalGainsPercentage = totalGainsPercentage; } public BigDecimal getAvgSalePrice() { return avgSalePrice; } public BigDecimal getSharesSold() { return getCostBasisShares().subtract(getSharesHeld()); } public BigDecimal getPercentPortfolio() { return percentPortfolio; } public void setPercentPortfolio(final BigDecimal percentPortfolio) { this.percentPortfolio = percentPortfolio; } public void setInternalRateOfReturn(final double internalRateOfReturn) { this.internalRateOfReturn = internalRateOfReturn; } } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/InvestmentTransaction.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine; import java.math.BigDecimal; import java.time.LocalDate; import java.util.List; import javax.persistence.Entity; import jgnash.util.NotNull; /** * Class for investment transactions. *

* All TransactionEntry(s) must be of the same security. * * @author Craig Cavanaugh */ @Entity public class InvestmentTransaction extends Transaction { public InvestmentTransaction() { super(); } /** * Returns the transaction type. If a valid investment entry has not been added then * {@code TransactionType.INVALID} will be returned. * * @return transaction type */ @NotNull @Override public TransactionType getTransactionType() { TransactionType type = TransactionType.INVALID; for (TransactionEntry e : transactionEntries) { if (e instanceof AbstractInvestmentTransactionEntry) { type = ((AbstractInvestmentTransactionEntry) e).getTransactionType(); break; } } return type; } public Account getInvestmentAccount() { Account account = null; for (final TransactionEntry e : transactionEntries) { if (e.getCreditAccount().getAccountType().getAccountGroup() == AccountGroup.INVEST) { account = e.getCreditAccount(); } else if (e.getDebitAccount().getAccountType().getAccountGroup() == AccountGroup.INVEST) { account = e.getDebitAccount(); } } return account; } @Override public void addTransactionEntry(@NotNull final TransactionEntry entry) { if (entry instanceof AbstractInvestmentTransactionEntry) { // assert that the types are a match if established if (getTransactionType() != TransactionType.INVALID && ((AbstractInvestmentTransactionEntry) entry).getTransactionType() != getTransactionType()) { throw new IllegalArgumentException("TransactionEntry type did not match the transaction type"); } // assert that the securities are a match if established if (getSecurityNode() != null && !((AbstractInvestmentTransactionEntry) entry).getSecurityNode().equals(getSecurityNode())) { throw new IllegalArgumentException("TransactionEntry security did not match the transaction security"); } } super.addTransactionEntry(entry); } /** * Returns the price per share. * * @return the price per share */ public BigDecimal getPrice() { BigDecimal price = BigDecimal.ZERO; for (final TransactionEntry e : transactionEntries) { if (e instanceof AbstractInvestmentTransactionEntry) { price = ((AbstractInvestmentTransactionEntry) e).getPrice(); break; } } return price; } /** * Returns the number of shares assigned to this transaction. * * @return the quantity of securities for this transaction * @see #getSignedQuantity() */ public BigDecimal getQuantity() { BigDecimal quantity = BigDecimal.ZERO; for (final TransactionEntry e : transactionEntries) { if (e instanceof AbstractInvestmentTransactionEntry) { quantity = quantity.add(((AbstractInvestmentTransactionEntry) e).getQuantity()); } } return quantity; } /** * Returns the number of shares assigned to this transaction. * * @return the quantity of securities for this transaction * @see #getSignedQuantity() */ private BigDecimal getSignedQuantity() { BigDecimal quantity = BigDecimal.ZERO; for (final TransactionEntry e : transactionEntries) { if (e instanceof AbstractInvestmentTransactionEntry) { quantity = quantity.add(((AbstractInvestmentTransactionEntry) e).getSignedQuantity()); } } return quantity; } public SecurityNode getSecurityNode() { SecurityNode node = null; for (final TransactionEntry e : transactionEntries) { if (e instanceof AbstractInvestmentTransactionEntry) { node = ((AbstractInvestmentTransactionEntry) e).getSecurityNode(); } } return node; } /** * Sum transaction fees. * * @return transaction fees */ public BigDecimal getFees() { return getFees(getInvestmentAccount()); } /** * Sum transaction fees for a given {@code Account}. * * @param account account to calculate fees against * @return transaction fees */ private BigDecimal getFees(final Account account) { BigDecimal fees = BigDecimal.ZERO; for (final TransactionEntry e : transactionEntries) { if (e.getTransactionTag() == TransactionTag.INVESTMENT_FEE) { fees = fees.add(e.getAmount(account)); } } return fees.negate(); } /** * Get a list of transaction entries tagged as investment fees. * * @return list of investment fees * @see TransactionTag#INVESTMENT_FEE */ public List getInvestmentFeeEntries() { return getTransactionEntriesByTag(TransactionTag.INVESTMENT_FEE); } /** * Get a list of transaction entries tagged as gains and loss. * * @return list of gains and loss entries * @see TransactionTag#GAIN_LOSS */ public List getInvestmentGainLossEntries() { return getTransactionEntriesByTag(TransactionTag.GAIN_LOSS); } /** * Return the market value of the transaction based on the supplied share price. * * @param sharePrice share price * @return the value of this transaction */ public BigDecimal getMarketValue(final BigDecimal sharePrice) { return getSignedQuantity().multiply(sharePrice); } /** * Return the market value of the transaction based on the latest share price. * * @param date Date to base market value against * @return the value of this transaction */ public BigDecimal getMarketValue(final LocalDate date) { return getSignedQuantity().multiply( getSecurityNode().getMarketPrice(date, getInvestmentAccount().getCurrencyNode())); } /** * Calculates the total of the value of the shares, gains, fees, etc. as it * pertains to an account. *

* Not intended for use to calculate account balances * * @param account The {@code Account} to calculate the total against * @return total resulting total for this transaction * @see AbstractInvestmentTransactionEntry#getTotal() */ public BigDecimal getTotal(final Account account) { BigDecimal total = BigDecimal.ZERO; for (final TransactionEntry e : transactionEntries) { if (e instanceof AbstractInvestmentTransactionEntry) { total = total.add(((AbstractInvestmentTransactionEntry) e).getTotal()); } else { total = total.add(e.getAmount(account)); } } return total; } /** * Calculates the total of the value of the shares, gains, fees, etc. as it * pertains to an account, but leaves out any cash transfer. *

* @param account The {@code Account} to calculate the total against * @return total resulting total for this transaction * @see #getTotal(Account) */ BigDecimal getTotalWithoutCashTransfer(final Account account) { BigDecimal total = BigDecimal.ZERO; for (final TransactionEntry e : transactionEntries) { if (e.getTransactionTag() != TransactionTag.INVESTMENT_CASH_TRANSFER) { if (e instanceof AbstractInvestmentTransactionEntry) { total = total.add(((AbstractInvestmentTransactionEntry) e).getTotal()); } else { total = total.add(e.getAmount(account)); } } } return total; } /** * Calculates the total cash value of the transaction. *

* Not intended for use to calculate account balances * * @return the total cash value of the transaction */ public BigDecimal getNetCashValue() { BigDecimal total = getQuantity().multiply(getPrice()); switch (getTransactionType()) { case DIVIDEND: case RETURNOFCAPITAL: return getAmount(getInvestmentAccount()); case REINVESTDIV: case SELLSHARE: total = total.subtract(getFees()); break; case BUYSHARE: total = total.add(getFees()); break; default: break; } return total; } /** * Compares two Transactions for ordering. Equality is checked for at the * reference level. If a comparison cannot be determined, the hashCode is * used * * @param tran the {@code Transaction} to be compared. * @return the value {@code 0} if the argument Transaction is equal to * this Transaction; a value less than {@code 0} if this Transaction is * before the Transaction argument; and a value greater than {@code 0} * if this Transaction is after the Transaction argument. */ @Override public int compareTo(@NotNull final Transaction tran) { if (tran == this) { return 0; } int result = date.compareTo(tran.date); if (result != 0) { return result; } result = getTransactionType().name().compareTo(tran.getTransactionType().name()); if (result != 0) { return result; } result = getMemo().compareTo(tran.getMemo()); if (result != 0) { return result; } if (tran instanceof InvestmentTransaction) { result = getSecurityNode().compareTo(((InvestmentTransaction) tran).getSecurityNode()); if (result != 0) { return result; } } result = Long.compareUnsigned(timestamp, tran.timestamp); if (result != 0) { return result; } return getUuid().compareTo(tran.getUuid()); } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/MathConstants.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine; import java.math.MathContext; import java.math.RoundingMode; /** * Math constants utility class. * * @author Craig Cavanaugh */ public final class MathConstants { /** * Default rounding mode. */ public static final RoundingMode roundingMode = RoundingMode.HALF_UP; /** * Default math context. */ public static final MathContext mathContext = new MathContext(16, roundingMode); /** * Default math context for budget values. Reduces precision to decrease file size. */ public static final MathContext budgetMathContext = new MathContext(8, roundingMode); /** * Number of significant digits to the right of the decimal SEPARATOR. */ public static final int EXCHANGE_RATE_ACCURACY = 6; /** * Number of significant digits to the right of the decimal SEPARATOR. */ public static final int SECURITY_PRICE_ACCURACY = 6; /** * Number of significant digits to the right of the decimal SEPARATOR. */ public static final int SECURITY_QUANTITY_ACCURACY = 6; public static final int DEFAULT_COMMODITY_PRECISION = 4; private MathConstants() { // restrict instantiation } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/QuoteSource.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine; import java.lang.reflect.InvocationTargetException; import jgnash.net.security.NullParser; import jgnash.net.security.SecurityParser; import jgnash.net.security.YahooEventParser; import jgnash.net.security.iex.IEXParser; import jgnash.resource.util.ResourceUtils; import jgnash.util.LogUtil; import jgnash.util.Nullable; /** * Enumeration for quote download source. * * Not actually used at this time * * @author Craig Cavanaugh */ public enum QuoteSource { NONE(ResourceUtils.getString("QuoteSource.None"), NullParser.class), YAHOO(ResourceUtils.getString("QuoteSource.Yahoo"), YahooEventParser.class), YAHOO_UK(ResourceUtils.getString("QuoteSource.YahooUK"), YahooEventParser.class), YAHOO_AUS(ResourceUtils.getString("QuoteSource.YahooAus"), YahooEventParser.class), IEX_CLOUD("IEX Cloud", IEXParser.class); private final transient String description; private final transient Class parser; /** * Cache the SecurityParser instance to improve performance */ private transient SecurityParser securityParser; QuoteSource(String description, final Class parser) { this.description = description; this.parser = parser; } @Override public String toString() { return description; } /** * Return a new SecurityParser instance appropriate for the QuoteSource. * * @return a new SecurityParser instance. Null if not able to create it */ @Nullable public SecurityParser getParser() { if (securityParser == null) { try { securityParser = parser.getDeclaredConstructor().newInstance(); } catch (NoSuchMethodException | SecurityException | InstantiationException | IllegalAccessException | IllegalArgumentException | InvocationTargetException e) { LogUtil.logSevere(QuoteSource.class, e); return null; // unable to create object } } return securityParser; } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/RecTransaction.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine; import jgnash.util.NotNull; import java.math.BigDecimal; import java.time.LocalDate; import java.util.Objects; /** * Utility Decorator around a Transaction to maintain the original reconciledState state. This class is not intended * to be serialized. * * @author Craig Cavanaugh */ public class RecTransaction implements Comparable { private ReconciledState reconciledState; private final Transaction transaction; public RecTransaction(@NotNull final Transaction transaction, @NotNull final ReconciledState reconciledState) { Objects.requireNonNull(transaction); Objects.requireNonNull(reconciledState); this.transaction = transaction; this.reconciledState = reconciledState; } public LocalDate getDate() { return getTransaction().getLocalDate(); } public String getNumber() { return getTransaction().getNumber(); } public String getPayee() { return getTransaction().getPayee(); } public ReconciledState getReconciledState() { return reconciledState; } public void setReconciledState(final ReconciledState reconciledState) { this.reconciledState = reconciledState; } public BigDecimal getAmount(final Account a) { if (getTransaction() instanceof InvestmentTransaction && a.memberOf(AccountGroup.INVEST)) { return ((InvestmentTransaction) getTransaction()).getMarketValue(getTransaction().getLocalDate()) .add(getTransaction().getAmount(a)); } return getTransaction().getAmount(a); } @Override public int hashCode() { return getTransaction().hashCode(); } @Override public boolean equals(final Object obj) { boolean result = false; if (obj instanceof RecTransaction) { final RecTransaction other = (RecTransaction) obj; if (getTransaction().equals(other.getTransaction()) && reconciledState == other.reconciledState) { result = true; } } return result; } @Override public int compareTo(@NotNull final RecTransaction t) { return getTransaction().compareTo(t.getTransaction()); } public Transaction getTransaction() { return transaction; } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/ReconcileManager.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine; import java.math.BigDecimal; import java.time.LocalDate; import java.util.ArrayList; import java.util.List; import java.util.Objects; import java.util.Optional; import jgnash.time.DateUtils; import jgnash.util.NotNull; /** * Manages the reconciliation options and provides utility functions for preserving account reconciliation attributes. *

* Both reconcileIncomeExpense and reconcileBothSides can be false, but only one can be true. * * @author Craig Cavanaugh */ public class ReconcileManager { private static final String RECONCILE_INCOME_EXPENSE = "reconcileIncomeExpense"; private static final String RECONCILE_BOTH_SIDES = "reconcileBothSides"; private static boolean reconcileIncomeExpense; private static boolean reconcileBothSides; private ReconcileManager() { } static { final Engine engine = EngineFactory.getEngine(EngineFactory.DEFAULT); if (engine != null) { reconcileIncomeExpense = engine.getBoolean(RECONCILE_INCOME_EXPENSE, false); reconcileBothSides = engine.getBoolean(RECONCILE_BOTH_SIDES, true); } } /** * Set so the income or expense side of a transaction is * automatically reconciled. * * @param reconcile true if income and expense accounts should be * automatically reconciled */ public static void setAutoReconcileIncomeExpense(final boolean reconcile) { reconcileIncomeExpense = reconcile; final Engine engine = EngineFactory.getEngine(EngineFactory.DEFAULT); if (engine != null) { engine.putBoolean(RECONCILE_INCOME_EXPENSE, reconcileIncomeExpense); } if (reconcile) { setAutoReconcileBothSides(false); } } /** * Determines if the income or expense side of a transaction should * be automatically reconciled. * * @return true if income and expense accounts should automatically be * reconciled. */ public static boolean getAutoReconcileIncomeExpense() { return reconcileIncomeExpense; } /** * Set so both sides of a double entry transaction are automatically * reconciled. * * @param reconcile true if income and expense accounts should be * automatically reconciled */ public static void setAutoReconcileBothSides(final boolean reconcile) { reconcileBothSides = reconcile; final Engine engine = EngineFactory.getEngine(EngineFactory.DEFAULT); if (engine != null) { engine.putBoolean(RECONCILE_BOTH_SIDES, reconcileBothSides); } if (reconcile) { setAutoReconcileIncomeExpense(false); } } /** * Determines if both sides of a transaction should be automatically reconciled. * * @return true if income and expense accounts should automatically be * reconciled. */ public static boolean getAutoReconcileBothSides() { return reconcileBothSides; } public static boolean isAutoReconcileDisabled() { return !reconcileBothSides && !reconcileIncomeExpense; } /** * Disables auto reconciliation. */ public static void setDoNotAutoReconcile() { setAutoReconcileBothSides(false); setAutoReconcileIncomeExpense(false); } /** * Sets the reconciled state of the transaction using the rules set * by the user. * * @param account Base account * @param t Transaction to reconcile * @param reconciled Reconciled state */ public static void reconcileTransaction(final Account account, final Transaction t, final ReconciledState reconciled) { // mark transaction reconciled for the primary account t.setReconciled(account, reconciled); if (getAutoReconcileBothSides()) { t.setReconciled(reconciled); } else if (getAutoReconcileIncomeExpense()) { List entries = t.getTransactionEntries(); for (TransactionEntry entry : entries) { Account c = entry.getCreditAccount(); if (c.instanceOf(AccountType.INCOME) || c.instanceOf(AccountType.EXPENSE)) { entry.setCreditReconciled(ReconciledState.RECONCILED); } Account d = entry.getDebitAccount(); if (d.instanceOf(AccountType.INCOME) || d.instanceOf(AccountType.EXPENSE)) { entry.setDebitReconciled(ReconciledState.RECONCILED); } } } } public static void reconcileTransactions(final Account account, final List list, final ReconciledState reconciledState) { final Engine engine = EngineFactory.getEngine(EngineFactory.DEFAULT); Objects.requireNonNull(engine); // create a copy of the list to prevent concurrent modification errors final List transactions = new ArrayList<>(list); // Set to the requested reconcile state, ignore if no change is detected transactions.parallelStream().filter(transaction -> transaction.getReconciledState() != transaction.getTransaction().getReconciled(account)).forEach(recTransaction -> { // Set to the requested reconcile state if (recTransaction.getReconciledState() != ReconciledState.NOT_RECONCILED) { engine.setTransactionReconciled(recTransaction.getTransaction(), account, reconciledState); } else { // must have been reconciled or cleared engine.setTransactionReconciled(recTransaction.getTransaction(), account, recTransaction.getReconciledState()); } }); } /** * Sets a date attribute for an account. * * @param account account that is being updated * @param attribute key for the date attribute * @param date date value */ public static void setAccountDateAttribute(@NotNull final Account account, @NotNull final String attribute, @NotNull final LocalDate date) { final Engine engine = EngineFactory.getEngine(EngineFactory.DEFAULT); Objects.requireNonNull(engine); engine.setAccountAttribute(account, attribute, Long.toString(DateUtils.asEpochMilli(date))); } /** * Returns a date attribute for an account. * * @param account {@code Account} that is being queried * @param attribute key for the date attribute * @return an {@code Optional} containing the date if previously set */ public static Optional getAccountDateAttribute(@NotNull final Account account, @NotNull final String attribute) { final String value = account.getAttribute(attribute); if (value != null) { return Optional.ofNullable(DateUtils.asLocalDate(Long.parseLong(value))); } return Optional.empty(); } /** * Sets a {@code BigDecimal} attribute for an account. * * @param account account that is being updated * @param attribute key for the {@code BigDecimal} attribute * @param decimal decimal value */ public static void setAccountBigDecimalAttribute(@NotNull final Account account, @NotNull final String attribute, @NotNull final BigDecimal decimal) { final Engine engine = EngineFactory.getEngine(EngineFactory.DEFAULT); Objects.requireNonNull(engine); engine.setAccountAttribute(account, attribute, decimal.toString()); } /** * Returns a {@code BigDecimal} attribute for an account. * * @param account {@code Account} that is being queried * @param attribute key for the {@code BigDecimal} attribute * @return an {@code Optional} containing the {@code BigDecimal} if previously set */ public static Optional getAccountBigDecimalAttribute(@NotNull final Account account, @NotNull final String attribute) { final String value = account.getAttribute(attribute); if (value != null) { return Optional.of(new BigDecimal(value)); } return Optional.empty(); } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/ReconciledState.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine; import jgnash.resource.util.ResourceUtils; /** * Enumeration for the reconciled state of a transaction. * * @author Craig Cavanaugh */ public enum ReconciledState { CLEARED(ResourceUtils.getString("State.Cleared")), NOT_RECONCILED(ResourceUtils.getString("State.NotReconciled")), RECONCILED(ResourceUtils.getString("State.Reconciled")); private final transient String symbol; ReconciledState(final String symbol) { this.symbol = symbol; } @Override public String toString() { return symbol; } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/RootAccount.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine; import javax.persistence.Entity; /** * The RootAccount class. * * @author Craig Cavanaugh */ @Entity public class RootAccount extends Account { /** * No argument constructor for reflection purposes. * Do not use to create a new instance */ public RootAccount() { super(); } /** * Public constructor. * * @param node The base commodity for this data set. */ protected RootAccount(final CurrencyNode node) { super(AccountType.ROOT, node); } @Override public AccountType getAccountType() { return AccountType.ROOT; } /** * Override the super. This account does not have a valid or usable path. * * @return returns an empty string. */ @Override public synchronized String getPathName() { return ""; } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/SecurityHistoryEvent.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine; import jgnash.util.NotNull; import javax.persistence.Column; import javax.persistence.Entity; import javax.persistence.EnumType; import javax.persistence.Enumerated; import javax.persistence.GeneratedValue; import javax.persistence.GenerationType; import javax.persistence.Id; import javax.persistence.SequenceGenerator; import java.io.Serializable; import java.math.BigDecimal; import java.time.LocalDate; import java.util.Objects; /** * Represents security history events such as splits and dividends. *

* Equality is assumed if the date and type match. * * @author Craig Cavanaugh */ @Entity @SequenceGenerator(name = "sequence", allocationSize = 10) public class SecurityHistoryEvent implements Comparable, Serializable { @SuppressWarnings("unused") @Id @GeneratedValue(generator = "sequence", strategy = GenerationType.SEQUENCE) public long id; @Enumerated(EnumType.STRING) private SecurityHistoryEventType type = SecurityHistoryEventType.DIVIDEND; @Column(precision = 19, scale = 4) private BigDecimal value = BigDecimal.ZERO; private LocalDate date = LocalDate.now(); /** * Cached hash code */ private transient int hash = 0; /** * public no-argument constructor for reflection. */ @SuppressWarnings("unused") public SecurityHistoryEvent() { } public SecurityHistoryEvent(@NotNull final SecurityHistoryEventType type, @NotNull final LocalDate date, @NotNull final BigDecimal value) { this.type = type; this.date = date; this.value = value; } public SecurityHistoryEventType getType() { return type; } public BigDecimal getValue() { return value; } public LocalDate getDate() { return date; } @Override public boolean equals(final Object o) { if (this == o) { return true; } if (o == null || getClass() != o.getClass()) { return false; } SecurityHistoryEvent event = (SecurityHistoryEvent) o; return Objects.equals(type, event.type) && date.compareTo(((SecurityHistoryEvent) o).getDate()) == 0; } @Override public int hashCode() { int h = hash; if (h == 0) { hash = h = Objects.hash(type, date); } return h; } @Override public int compareTo(@NotNull final SecurityHistoryEvent historyEvent) { if (historyEvent == this) { return 0; } int result = date.compareTo(historyEvent.getDate()); if (result != 0) { return result; } return type.compareTo(historyEvent.getType()); } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/SecurityHistoryEventType.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine; import jgnash.resource.util.ResourceUtils; /** * Historical event descriptor for securities. * * @author Craig Cavanaugh */ public enum SecurityHistoryEventType { SPLIT(ResourceUtils.getString("SecurityEvent.Split")), DIVIDEND(ResourceUtils.getString("SecurityEvent.Dividend")), PRICE("SecurityEvent.Price"); private final transient String description; SecurityHistoryEventType(String description) { this.description = description; } @Override public String toString() { return description; } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/SecurityHistoryNode.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine; import jgnash.util.NotNull; import jgnash.util.Nullable; import javax.persistence.Column; import javax.persistence.Entity; import javax.persistence.GeneratedValue; import javax.persistence.GenerationType; import javax.persistence.Id; import javax.persistence.SequenceGenerator; import java.io.Serializable; import java.math.BigDecimal; import java.time.LocalDate; import java.util.Objects; /** * Historical data for a {@code SecurityNode}. * * @author Craig Cavanaugh */ @Entity @SequenceGenerator(name = "sequence", allocationSize = 10) public class SecurityHistoryNode implements Comparable, Serializable { @SuppressWarnings("unused") @Id @GeneratedValue(generator = "sequence", strategy = GenerationType.SEQUENCE) public long id; private LocalDate date = LocalDate.now(); @Column(precision = 19, scale = 4) private BigDecimal price = BigDecimal.ZERO; @Column(precision = 19, scale = 4) private BigDecimal high = BigDecimal.ZERO; @Column(precision = 19, scale = 4) private BigDecimal low = BigDecimal.ZERO; private long volume = 0; private transient BigDecimal adjustedPrice = null; /** * public no-argument constructor for reflection. */ public SecurityHistoryNode() { } /** * Public constructor for creating a history node. * * @param date date * @param price closing price * @param volume closing volume * @param high high price for the day * @param low low price for the day */ public SecurityHistoryNode(@NotNull final LocalDate date, @Nullable final BigDecimal price, final long volume, @Nullable final BigDecimal high, @Nullable final BigDecimal low) { setDate(date); setPrice(price); setVolume(volume); setHigh(high); setLow(low); } private void setHigh(final BigDecimal high) { if (high != null) { this.high = high; } } private void setLow(final BigDecimal low) { if (low != null) { this.low = low; } } private void setVolume(final long volume) { this.volume = volume; } public BigDecimal getHigh() { return high; } public BigDecimal getLow() { return low; } public long getVolume() { return volume; } void setDate(final @NotNull LocalDate localDate) { Objects.requireNonNull(localDate); this.date = localDate; } public LocalDate getLocalDate() { return date; } void setPrice(final BigDecimal price) { if (price != null) { this.price = price; } } public BigDecimal getPrice() { return price; } /** * The price adjusted for any splits or reverse splits. * * @return the adjusted price */ public @NotNull BigDecimal getAdjustedPrice() { if (adjustedPrice != null) { return adjustedPrice; } return price; } /** * Adjusts the historical values given a multiplier. To be used for handling * security splits and reverse splits. * * @param multiplier multiplier to be used for adjusting prices */ void setAdjustmentMultiplier(@NotNull BigDecimal multiplier) { if (price != null) { adjustedPrice = price.multiply(multiplier, MathConstants.mathContext); } } /** * Compare using only the {@code LocalDate} * * @param node node to compare */ @Override public int compareTo(@NotNull final SecurityHistoryNode node) { return getLocalDate().compareTo(node.getLocalDate()); } /** * Equality is based on the {@code LocalDate} of the SecurityHistoryNode. * * @param obj the reference SecurityHistoryNode with which to compare. * @return {@code true} if this object is the same as the obj argument; {@code false} otherwise. */ @Override public boolean equals(final Object obj) { return this == obj || obj instanceof SecurityHistoryNode && date .compareTo(((SecurityHistoryNode) obj).date) == 0; } @Override public int hashCode() { return date.hashCode(); } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/SecurityNode.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine; import java.math.BigDecimal; import java.time.LocalDate; import java.util.ArrayList; import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.ListIterator; import java.util.Optional; import java.util.Set; import java.util.concurrent.locks.ReadWriteLock; import java.util.concurrent.locks.ReentrantReadWriteLock; import java.util.stream.Collectors; import javax.persistence.CascadeType; import javax.persistence.Entity; import javax.persistence.EnumType; import javax.persistence.Enumerated; import javax.persistence.FetchType; import javax.persistence.JoinTable; import javax.persistence.ManyToOne; import javax.persistence.OneToMany; import javax.persistence.PostLoad; import jgnash.time.DateUtils; import jgnash.util.NotNull; /** * Security Node. * * @author Craig Cavanaugh */ @Entity public class SecurityNode extends CommodityNode { @ManyToOne private CurrencyNode reportedCurrency; /** * The currency that security values are reported in. */ @Enumerated(EnumType.STRING) private QuoteSource quoteSource = QuoteSource.NONE; /** * ISIN or CUSIP, Used for OFX and quote downloads. */ private String isin; @JoinTable @OneToMany(cascade = {CascadeType.ALL}, fetch = FetchType.EAGER) private Set historyNodes = new HashSet<>(); @JoinTable @OneToMany(cascade = {CascadeType.ALL}, fetch = FetchType.EAGER) private final Set securityHistoryEvents = new HashSet<>(); private transient ReadWriteLock lock; private transient List sortedHistoryNodeCache = new ArrayList<>(); public SecurityNode() { lock = new ReentrantReadWriteLock(true); } public SecurityNode(final CurrencyNode node) { this(); setReportedCurrencyNode(node); } /** * Prefix is deferred to the reported currency. * * @return prefix of the reported currency */ @Override public String getPrefix() { return reportedCurrency.getPrefix(); } @Override public void setPrefix(final String ignored) { // prefix is controlled by the reported currency } /** * Suffix is deferred to the reported currency. * * @return suffix of the reported currency */ @Override public String getSuffix() { return reportedCurrency.getSuffix(); } @Override public void setSuffix(final String ignored) { // suffix is controlled by the reported currency } /** * Returns the quote download source. * * @return quote download source */ public QuoteSource getQuoteSource() { return quoteSource; } /** * Sets the quote download source. * * @param source QuoteSource to use */ public void setQuoteSource(final QuoteSource source) { quoteSource = source; } @NotNull public String getISIN() { return (isin == null) ? "" : isin; } public void setISIN(final String isin) { this.isin = isin; } /** * Set the CurrencyNode that security histories are reported in. * * @param node reported CurrencyNode */ public void setReportedCurrencyNode(final CurrencyNode node) { reportedCurrency = node; } /** * Returns the CurrencyNode that security histories are reported in. * * @return reported CurrencyNode */ public CurrencyNode getReportedCurrencyNode() { return reportedCurrency; } boolean addHistoryNode(final SecurityHistoryNode node) { lock.writeLock().lock(); try { sortedHistoryNodeCache.add(node); Collections.sort(sortedHistoryNodeCache); return historyNodes.add(node); } finally { lock.writeLock().unlock(); } } boolean removeHistoryNode(final LocalDate date) { lock.writeLock().lock(); try { final boolean result = historyNodes.removeIf(node -> node.getLocalDate().compareTo(date) == 0); if (result) { sortedHistoryNodeCache.removeIf(node -> node.getLocalDate().compareTo(date) == 0); } return result; } finally { lock.writeLock().unlock(); } } boolean addSecurityHistoryEvent(final SecurityHistoryEvent securityHistoryEvent) { lock.writeLock().lock(); try { return securityHistoryEvents.add(securityHistoryEvent); } finally { lock.writeLock().unlock(); } } boolean removeSecurityHistoryEvent(final SecurityHistoryEvent securityHistoryEvent) { boolean result = false; lock.writeLock().lock(); try { // Use equality check for remove for (final SecurityHistoryEvent historyEvent : securityHistoryEvents) { if (historyEvent.equals(securityHistoryEvent)) { result = securityHistoryEvents.remove(historyEvent); break; // break to prevent concurrent modification error } } } finally { lock.writeLock().unlock(); } return result; } /** * Returns true if this SecurityNode contains the specified element. * * @param date LocalDate whose presence in this SecurityNode is to be tested * @return true if this SecurityNode contains a SecurityHistoryNode with the specified date */ public boolean contains(final LocalDate date) { boolean result = false; lock.readLock().lock(); try { for (final SecurityHistoryNode node : historyNodes) { if (node.getLocalDate().compareTo(date) == 0) { result = true; break; } } } finally { lock.readLock().unlock(); } return result; } /** * Gets the SecurityHistoryNodes for this security. At time of retrieval the adjusted price of the * SecurityHistoryNodes will be updated to reflect any spits or reverse splits * * @return Returns a shallow copy of the history nodes to protect against modification * @see SecurityHistoryNode#getAdjustedPrice() */ public List getHistoryNodes() { lock.readLock().lock(); try { final List splits = getSplitEvents(); if (!splits.isEmpty()) { BigDecimal scalar = BigDecimal.ONE; final ListIterator historyEventIterator = splits.listIterator(splits.size()); LocalDate eventDate = historyEventIterator.previous().getDate(); historyEventIterator.next(); // reset back to the tail // work backwards for (int i = sortedHistoryNodeCache.size() - 1; i >= 0; i--) { if (DateUtils.after(eventDate, sortedHistoryNodeCache.get(i).getLocalDate()) && historyEventIterator.hasPrevious()) { final SecurityHistoryEvent historyEvent = historyEventIterator.previous(); eventDate = historyEvent.getDate(); scalar = scalar.divide(historyEvent.getValue(), MathConstants.mathContext); } sortedHistoryNodeCache.get(i).setAdjustmentMultiplier(scalar); } } return Collections.unmodifiableList(sortedHistoryNodeCache); } finally { lock.readLock().unlock(); } } /** * Convenience function to return the upper and lower date bounds. * * @return an array of LocalDate with {@code [0]} being the lower bound and {@code [1]} being the upper bound */ public Optional getLocalDateBounds() { lock.readLock().lock(); try { if (sortedHistoryNodeCache.size() > 1) { return Optional.of(new LocalDate[]{ sortedHistoryNodeCache.get(0).getLocalDate(), sortedHistoryNodeCache.get(sortedHistoryNodeCache.size() - 1).getLocalDate() }); } return Optional.empty(); } finally { lock.readLock().unlock(); } } /** * Returns the history node events split into groups by historical splits or reverse splits. * * @return a List of Lists of SecurityHistoryNodes */ public List> getHistoryNodeGroupsBySplits() { lock.readLock().lock(); try { final List> groups = new ArrayList<>(); final List splitEvents = getSplitEvents(); if (splitEvents.isEmpty()) { groups.add(getHistoryNodes()); } else { // count should be split events + 1 when complete // Create a defensive copy that has the adjustment multiplier set final List securityHistoryNodes = getHistoryNodes(); final ListIterator historyEventIterator = splitEvents.listIterator(); LocalDate eventDate = historyEventIterator.next().getDate(); List group = new ArrayList<>(); for (int i = 0; i < securityHistoryNodes.size(); i++) { if (eventDate == null || securityHistoryNodes.get(i).getLocalDate().isBefore(eventDate)) { group.add(securityHistoryNodes.get(i)); } else { groups.add(group); // save the current group group = new ArrayList<>(); // start a new group group.add(securityHistoryNodes.get(i - 1)); // create continuity with the previous group group.add(securityHistoryNodes.get(i)); // add the current node if (historyEventIterator.hasNext()) { eventDate = historyEventIterator.next().getDate(); } else { eventDate = null; } } } groups.add(group); // add last group } return groups; } finally { lock.readLock().unlock(); } } /** * Get an unmodifiable copy of the SecurityHistoryEvents for this security. * * @return returns a shallow copy of the SecurityHistoryEvents to protect against modification */ public Set getHistoryEvents() { return Collections.unmodifiableSet(securityHistoryEvents); } private List getSplitEvents() { lock.readLock().lock(); try { return securityHistoryEvents.stream().filter(historyEvent -> historyEvent.getType() == SecurityHistoryEventType.SPLIT).sorted().collect(Collectors.toList()); } finally { lock.readLock().unlock(); } } /** * Returns the {@code SecurityHistoryNode} with the matching date. * * @param date Date to match * @return {@code Optional} contain a matching node */ public Optional getHistoryNode(final LocalDate date) { lock.readLock().lock(); try { SecurityHistoryNode hNode = null; // Work backwards through the list as the newest date is requested the most for (int i = sortedHistoryNodeCache.size() - 1; i >= 0; i--) { final SecurityHistoryNode node = sortedHistoryNodeCache.get(i); if (date.compareTo(node.getLocalDate()) == 0) { hNode = node; break; } } return Optional.ofNullable(hNode); } finally { lock.readLock().unlock(); } } /** * Returns the {@code SecurityHistoryNode} with the closet matching date without exceeding the request date. * * @param date {@code Date} to match * @return {@code Optional} containing a {@code SecurityHistoryNode} if a match is found */ public Optional getClosestHistoryNode(final LocalDate date) { final long epochDay = date.toEpochDay(); lock.readLock().lock(); try { SecurityHistoryNode hNode = null; // Work backwards through the list as the newest date is requested the most for (int i = sortedHistoryNodeCache.size() - 1; i >= 0; i--) { final SecurityHistoryNode node = sortedHistoryNodeCache.get(i); if (node.getLocalDate().toEpochDay() <= epochDay) { hNode = node; break; } } return Optional.ofNullable(hNode); } finally { lock.readLock().unlock(); } } private BigDecimal getMarketPrice(final LocalDate date) { BigDecimal marketPrice = BigDecimal.ZERO; final Optional optional = getClosestHistoryNode(date); if (optional.isPresent()) { marketPrice = optional.get().getPrice(); } return marketPrice; } /** * Returns the latest market price exchanged to the specified currency. * * @param date date to find closest matching rate without exceeding * @param node currency to exchange to * @return latest market price */ public BigDecimal getMarketPrice(final LocalDate date, final CurrencyNode node) { return getMarketPrice(date).multiply(getReportedCurrencyNode().getExchangeRate(node)); } /** * Return a clone of this security node. Security history is not cloned * * @return clone of this SecurityNode with history nodes */ @Override public Object clone() throws CloneNotSupportedException { lock.readLock().lock(); try { SecurityNode node = (SecurityNode) super.clone(); node.historyNodes = new HashSet<>(); node.postLoad(); return node; } finally { lock.readLock().unlock(); } } /** * . * Required by XStream for proper initialization * * @return Properly initialized SecurityNode */ protected Object readResolve() { postLoad(); return this; } @PostLoad private void postLoad() { lock = new ReentrantReadWriteLock(true); // load the cache list sortedHistoryNodeCache = new ArrayList<>(historyNodes); Collections.sort(sortedHistoryNodeCache); // JPA will be naturally sorted, but XML files will not } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/StoredObject.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine; import java.io.Serializable; import java.util.UUID; import javax.persistence.Basic; import javax.persistence.Column; import javax.persistence.Entity; import javax.persistence.Id; import javax.persistence.Inheritance; import javax.persistence.InheritanceType; import javax.persistence.Version; import static jgnash.util.LogUtil.logSevere; /** * Abstract class for anything stored in the database that requires a unique id. * * @author Craig Cavanaugh */ @Entity @Inheritance(strategy = InheritanceType.JOINED) public abstract class StoredObject implements Cloneable, Serializable { /** * Version field for persistence purposes. */ @Version @SuppressWarnings("unused") private int version; /** * Indicates object is marked for removal. */ @Basic private boolean markedForRemoval = false; /** * String based Unique ID for every object. */ @Id @Column(name = "uuid", nullable = false, updatable = false) private UUID uuid = UUID.randomUUID(); /** Cache the hash code for the StoredObject */ private transient int hash = 0; /** * Getter for the uuid. * * Note: method should not be final (HHH000305) * * @return uuid of the object */ public UUID getUuid() { return uuid; } /** * Setter for the uuid. Used for reflection purposes only * * @param uuid uuid to assign the object */ private void setUuid(final UUID uuid) { this.uuid = uuid; } void setMarkedForRemoval() { this.markedForRemoval = true; } public boolean isMarkedForRemoval() { return markedForRemoval; } /** * Override hashCode to use UUID hash code. * * The hash is cached since UUID does not cache * * @see java.util.UUID#hashCode() */ @Override public int hashCode() { int h = hash; if (h == 0) { hash = h = getUuid().hashCode(); } return h; } /** * Default equals override. * * @param o object to compare * @see java.lang.Object#equals(java.lang.Object) */ @Override public boolean equals(final Object o) { return this == o || o instanceof StoredObject && getUuid().equals(((StoredObject) o).getUuid()); } @Override public Object clone() throws CloneNotSupportedException { StoredObject o = null; try { o = (StoredObject) super.clone(); o.setUuid(UUID.randomUUID()); // force a new UUID o.hash = 0; o.markedForRemoval = false; } catch (final CloneNotSupportedException e) { logSevere(StoredObject.class, e); } return o; } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/StoredObjectComparator.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine; import java.io.Serializable; import java.util.Comparator; /** * High level StoredObject comparator. When serializing to an XML file, this helps with readability of references * * @author Craig Cavanaugh */ public class StoredObjectComparator implements Comparator, Serializable { @Override public int compare(final StoredObject o1, final StoredObject o2) { if ((o1 instanceof CurrencyNode || o2 instanceof CurrencyNode) && !(o1 instanceof CurrencyNode && o2 instanceof CurrencyNode)) { if (o1 instanceof CurrencyNode) { return -1; } return 1; } if ((o1 instanceof SecurityNode || o2 instanceof SecurityNode) && !(o1 instanceof SecurityNode && o2 instanceof SecurityNode)) { if (o1 instanceof SecurityNode) { return -1; } return 1; } if (o1 instanceof Config && o2 instanceof RootAccount) { return -1; } if (o1 instanceof RootAccount && o2 instanceof Config) { return 1; } // two config objects should never occur if ((o1 instanceof Config || o2 instanceof Config) && !(o1 instanceof Config && o2 instanceof Config)) { if (o1 instanceof Config) { return -1; } return 1; } // two root accounts should never occur if ((o1 instanceof RootAccount || o2 instanceof RootAccount) && !(o1 instanceof RootAccount && o2 instanceof RootAccount)) { if (o1 instanceof RootAccount) { return -1; } return 1; } if (!o1.getClass().equals(o2.getClass())) { return o1.getClass().getName().compareTo(o2.getClass().getName()); } return o1.getUuid().compareTo(o2.getUuid()); } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/Tag.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine; import javax.persistence.Column; import javax.persistence.Entity; /** * Class for user created tags to mark transactions. * * @author Craig Cavanaugh */ @Entity public class Tag extends StoredObject implements Comparable { /** * Tag name */ private String name = ""; /** * Tag description */ @Column(columnDefinition = "VARCHAR(2048)") private String description = ""; /** * Tag color *

* Default value is black */ private long color = 255; /** * Icon unicode value */ private Integer unicode = 0xf764; // circle public Tag() { // zero arg constructor required for persistence } public void setDescription(final String description) { if (description != null && description.length() <= 2048) { this.description = description; } } public String getDescription() { return description; } public void setName(final String name) { if (name != null && !name.isBlank()) { this.name = name; } } public String getName() { return name; } /** * Sets the Tag color * * @see jgnash.util.EncodeDecode#longToColorString(long) * @param color integer equivalent of a web based color string */ public void setColor(final long color) { this.color = color; } /** * Returns the Tag color * * @see jgnash.util.EncodeDecode#colorStringToLong(String) * @return integer value encoded from a web based color string */ public long getColor() { return color; } /** * Returns the Tag shape * * @return char value of the font character */ public int getShape() { if (unicode == null) { unicode = 0xf0e4; // bug symbol.. from conversion of an older file } return unicode; } /** * Sets the Tag shape * * @param unicode value of the font character */ public void setShape(final int unicode) { this.unicode = unicode; } @Override public java.lang.String toString() { return getName(); } /** * Compares this object with the specified object for order. Returns a * negative integer, zero, or a positive integer as this object is less * than, equal to, or greater than the specified object. * * @param o the object to be compared. * @return a negative integer, zero, or a positive integer as this object * is less than, equal to, or greater than the specified object. * @throws NullPointerException if the specified object is null * @throws ClassCastException if the specified object's type prevents it * from being compared to this object. */ @Override public int compareTo(final Tag o) { if (o == this) { return 0; } int result = name.compareTo(o.name); if (result != 0) { return result; } result = Long.compare(color, o.color); if (result != 0) { return result; } result = Integer.compare(getShape(), o.getShape()); if (result != 0) { return result; } result = description.compareTo(o.description); if (result != 0) { return result; } return getUuid().compareTo(o.getUuid()); } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/Transaction.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine; import java.math.BigDecimal; import java.time.Instant; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.ZoneId; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Objects; import java.util.Set; import java.util.TreeSet; import java.util.stream.Collectors; import javax.persistence.Basic; import javax.persistence.CascadeType; import javax.persistence.Column; import javax.persistence.Entity; import javax.persistence.FetchType; import javax.persistence.JoinTable; import javax.persistence.OneToMany; import javax.persistence.Table; import jgnash.util.NotNull; import jgnash.util.Nullable; /** * Base class for transactions. Transaction should be treated as immutable as in not modified if they have * been persisted within the database. * * @author Craig Cavanaugh */ @SuppressWarnings("JpaDataSourceORMInspection") @Entity @Table(name = "TRANSACT") // cannot use "Transaction" as the table name or it causes an SQL error!!!! public class Transaction extends StoredObject implements Comparable { private static final transient String EMPTY = ""; /** * If the memo consists of only the summation symbol, memos from the TransactionEntries are concatenated. */ public static final transient String CONCATENATE = "Ʃ"; /** * Date of entry from form entry, used for sort order. */ LocalDate date = LocalDate.now(); /** * Timestamp for transaction creation. */ @Column(name = "timestamp", nullable = false, columnDefinition = "BIGINT default 0") long timestamp = System.currentTimeMillis(); /** * Transaction number. */ private String number; /** * Transaction payee. */ private String payee; /** * Financial Institute Transaction ID. Typically used for OFX import FITID. If this field is not null * then it is an indicator of an imported transaction */ @Basic private String fitid; /** * File name for the attachment, should not contain any preceding paths. */ @Column(columnDefinition = "VARCHAR(256)") private String attachment; /** * Transaction memo. */ @Column(columnDefinition = "VARCHAR(1024)") private String memo; /** * Cache the concatenated memo */ private transient String concatMemo; /** * Cache the generated LocalDateTime */ private transient LocalDateTime timeStampDate; /** * Transaction entries. */ @JoinTable @OneToMany(cascade = {CascadeType.ALL}, fetch = FetchType.EAGER) Set transactionEntries = new HashSet<>(); /** * Public constructor. */ public Transaction() { // zero arg constructor required for persistence } /** * Returns a set of accounts this transaction effects. * The returned set may be altered without creating side effects * * @return set of accounts * @see Account */ @NotNull public Set getAccounts() { final Set accounts = new TreeSet<>(); for (final TransactionEntry e : transactionEntries) { accounts.add(e.getCreditAccount()); accounts.add(e.getDebitAccount()); } return accounts; } /** * Determines if any of the transaction's accounts are hidden. * * @return true if any of the accounts are hidden */ public boolean areAccountsHidden() { boolean accountsHidden = false; for (final Account account : getAccounts()) { if (!account.isVisible()) { accountsHidden = true; break; } } return accountsHidden; } /** * Determines if any of the transaction's accounts are locked against editing (cloning and then changing accounts). * * @return true if any of the accounts are locked */ public boolean areAccountsLocked() { boolean accountsLocked = false; for (final Account account : getAccounts()) { if (account.isLocked()) { accountsLocked = true; break; } } return accountsLocked; } /** * Search for a common account for all entries. * * @return the common Account * @see Account */ public Account getCommonAccount() { Account account = null; if (size() >= 2) { for (final Account a : getAccounts()) { boolean success = true; for (final TransactionEntry e : transactionEntries) { if (!e.getCreditAccount().equals(a) && !e.getDebitAccount().equals(a)) { success = false; break; } } if (success) { account = a; break; } } } else { // double entry transaction, return the credit account by default account = transactionEntries.iterator().next().getCreditAccount(); } return account; } /** * Returns the balance of the transaction amount with respect to the supplied account. Value will be positive or * negative depending if the transaction debits or credits the account. * * @param entry new TransactionEntry to add * @see TransactionEntry */ public void addTransactionEntry(@NotNull final TransactionEntry entry) { Objects.requireNonNull(entry); if (transactionEntries.contains(entry)) { throw new IllegalArgumentException("Duplicate entry is not allowed"); } transactionEntries.add(entry); } public void removeTransactionEntry(@NotNull final TransactionEntry entry) { Objects.requireNonNull(entry); transactionEntries.remove(entry); } /** * Returns the number of {@code TransactionEntry(s)} this transaction contains. A read lock is obtained before * determining the size. * * @return the number of {@code TransactionEntry(s)} * @see TransactionEntry */ public int size() { return transactionEntries.size(); } public void setDate(@NotNull final LocalDate localDate) { this.date = localDate; } public LocalDate getLocalDate() { return date; } /** * Sets the payee for this transaction. * * @param payee the transaction payee */ public void setPayee(@Nullable final String payee) { this.payee = payee; } /** * Return the payee for this transaction. * * @return the transaction payee. Guaranteed to not return null */ @NotNull public String getPayee() { String result = EMPTY; if (payee != null) { result = payee; } return result; } /** * Sets the number for this transaction. * * @param number the transaction number */ public void setNumber(@Nullable final String number) { this.number = number; } /** * Return the number for this transaction. * * @return the transaction number. Guaranteed to not return null */ @NotNull public String getNumber() { String result = EMPTY; if (number != null) { result = number; } return result; } /** * Calculates the amount of the transaction relative to the supplied account. *

* This method is synchronized to protect against concurrency issues * * @param account reference account * @return Amount of this transaction relative to the supplied account */ public synchronized BigDecimal getAmount(final Account account) { return transactionEntries.stream().map(transactionEntry -> transactionEntry.getAmount(account)).reduce(BigDecimal.ZERO, BigDecimal::add); } /** * Compares two Transactions for ordering. Equality is checked for at the reference level. If a comparison cannot be * determined, the hashCode is used * * @param tran the {@code Transaction} to be compared. * @return the value {@code 0} if the argument Transaction is equal to this Transaction; a value less than * {@code 0} if this Transaction is before the Transaction argument; and a value greater than * {@code 0} if this Transaction is after the Transaction argument. */ @Override public int compareTo(final @NotNull Transaction tran) { if (tran == this) { return 0; } int result = date.compareTo(tran.date); if (result != 0) { return result; } result = getNumber().compareTo(tran.getNumber()); if (result != 0) { return result; } result = Long.compareUnsigned(timestamp, tran.timestamp); if (result != 0) { return result; } result = getAmount(getCommonAccount()).compareTo(tran.getAmount(tran.getCommonAccount())); if (result != 0) { return result; } return getUuid().compareTo(tran.getUuid()); } /** * Returns a sorted defensive copy of the transaction entries. * * @return list of transaction entries */ public List getTransactionEntries() { // protect against write through by creating a new ArrayList final List list = new ArrayList<>(transactionEntries); Collections.sort(list); return list; } private List getTransactionEntries(final Account account) { return transactionEntries.stream() .filter(transactionEntry -> transactionEntry.getCreditAccount().equals(account) || transactionEntry.getDebitAccount().equals(account)).collect(Collectors.toList()); } /** * Return a list of transaction entries with the given tag. * * @param tag TransactionTag to filter for * @return {@code List} of entries with the given tag. An empty list will be * returned if none are found */ List getTransactionEntriesByTag(final TransactionTag tag) { return transactionEntries.stream().filter(e -> e.getTransactionTag() == tag).collect(Collectors.toList()); } /** * Adds a collection of transaction entries. * * @param entries collection of TransactionEntry(s) */ public void addTransactionEntries(final Collection entries) { entries.forEach(this::addTransactionEntry); } /** * Clears all transaction entries. */ public void clearTransactionEntries() { transactionEntries.clear(); } public LocalDateTime getTimestamp() { if (timeStampDate == null) { timeStampDate = LocalDateTime.ofInstant(Instant.ofEpochMilli(timestamp), ZoneId.systemDefault()); } return timeStampDate; } @NotNull public TransactionType getTransactionType() { if (size() == 1) { final TransactionEntry entry = transactionEntries.iterator().next(); if (entry.isSingleEntry()) { return TransactionType.SINGLENTRY; } return TransactionType.DOUBLEENTRY; } if (size() > 1) { return TransactionType.SPLITENTRY; } return TransactionType.INVALID; } /** * Returns the memo for the {@code Transaction}. If memo was set to be equal to {@value #CONCATENATE}, then a * concatenated version of the {@code TransactionEntry} memos will be returned. If the {@code Transaction} level * memo is null, that the memo for the first {@code TransactionEntry} is returned * * @return resultant memo */ @NotNull public synchronized String getMemo() { if (memo != null) { if (isMemoConcatenated()) { if (concatMemo == null) { concatMemo = getMemo(getTransactionEntries()); } return concatMemo; } return memo; } return getTransactionEntries().get(0).getMemo(); } /** * Returns the {@code Transaction} level memo * * @return the Transaction memo */ @Nullable public String getTransactionMemo() { return memo; } /** * Returns the concatenated memo given an Account. * * @param account base account to generate a memo for * @return Concatenated string of split entry memos */ @NotNull public synchronized String getMemo(@NotNull final Account account) { return getMemo(getTransactionEntries(account)); } /** * Builds a concatenated memo given a list of TransactionEntries. * * @param transEntries List of {@code TransactionEntry} * @return concatenated memo */ public static String getMemo(final List transEntries) { final List memoList = new ArrayList<>(); // Create an ordered list of unique memos that are not empty transEntries.stream().filter(transactionEntry -> !transactionEntry.getMemo().isEmpty() && !memoList.contains(transactionEntry.getMemo())) .forEachOrdered(transactionEntry -> memoList.add(transactionEntry.getMemo())); return String.join(", ", memoList); } public boolean isMemoConcatenated() { return CONCATENATE.equals(memo); } /** * Set the memo for the {@code Transaction}. If set to be equal to {@value #CONCATENATE}, then a concatenated * version will be reported. If set to null or an empty string, the memo of the first {@code TransactionEntry} * will be reported. Otherwise, the supplied String will be reported. * * @param memo sets the {@code Transaction} level memo */ public synchronized void setMemo(final String memo) { // force to null if empty to conserve memory. if (memo != null && memo.isEmpty()) { this.memo = null; } else { this.memo = memo; } } /** * Returns a set of all tags associated with the transaction * * @return Set of all Tags */ public Set getTags() { final Set tags = new HashSet<>(); for (final TransactionEntry entry : transactionEntries) { tags.addAll(entry.getTags()); } return tags; } /** * Returns a set of all tags associated with the specified Account in common. * * @param account common Account * @return Set of all Tags */ public Set getTags(final Account account) { final Set tags = new HashSet<>(); for (final TransactionEntry entry : getTransactionEntries(account)) { tags.addAll(entry.getTags()); } return tags; } /** * Assigns the specified Tag(s) to all entries * * @param tags Set of tags to assign * @see TransactionEntry#setTags(Collection) */ public void setTags(final Collection tags) { for (final TransactionEntry entry : transactionEntries) { entry.setTags(tags); } } /** * Assigns the specified Tag(s) to all entries of the specified class * @param clazz class to filter by * @param tags Tags to assign * @see TransactionEntry#setTags(Collection) */ public void setTags(@NotNull final Class clazz, @NotNull final Collection tags) { transactionEntries.stream().filter(clazz::isInstance).forEach(e-> e.setTags(tags)); } /** * Returns all tags associated with with entries of the specified class * @param clazz class to filter by * @return Set of Tags */ public Set getTags(@NotNull final Class clazz) { final Set tags = new HashSet<>(); transactionEntries.stream().filter(clazz::isInstance) .forEach(transactionEntry -> tags.addAll(transactionEntry.getTags())); return tags; } @Nullable public String getFitid() { return fitid; } public void setFitid(final String fitid) { this.fitid = fitid; } @SuppressWarnings("WeakerAccess") public void setReconciled(@NotNull final Account account, @NotNull final ReconciledState state) { Objects.requireNonNull(account); Objects.requireNonNull(state); for (final TransactionEntry e : transactionEntries) { e.setReconciled(account, state); } } public void setReconciled(@NotNull final ReconciledState state) { Objects.requireNonNull(state); for (final TransactionEntry e : transactionEntries) { e.setCreditReconciled(state); e.setDebitReconciled(state); } } @NotNull public ReconciledState getReconciled(final Account account) { ReconciledState state = ReconciledState.NOT_RECONCILED; // default is not reconciled for (final TransactionEntry e : transactionEntries) { if (e.getCreditAccount().equals(account)) { state = e.getCreditReconciled(); break; } if (e.getDebitAccount().equals(account)) { state = e.getDebitReconciled(); break; } } return state; } /** * Returns an external link to a file. * * @return external path, null if not set */ @Nullable public String getAttachment() { return attachment; } /** * Sets an external link to a file, the path should be relative to the data file for portability. * May be set to null. * * @param attachment attachment path */ public void setAttachment(@Nullable final String attachment) { this.attachment = attachment; } @Override public Object clone() throws CloneNotSupportedException { final Transaction tran = (Transaction) super.clone(); tran.concatMemo = null; // force a reset of the concatenated memo, the entries of the clone may change tran.timestamp = System.currentTimeMillis(); // force the clone to have a new timestamp tran.timeStampDate = null; // clear the cached value // deep clone tran.transactionEntries = new HashSet<>(); // deep clone for (final TransactionEntry entry : transactionEntries) { tran.addTransactionEntry((TransactionEntry) entry.clone()); } return tran; } @Override public String toString() { final StringBuilder b = new StringBuilder(); final String lineSep = System.getProperty("line.SEPARATOR"); b.append("Transaction UUID: ").append(getUuid()).append(lineSep); b.append("Number: ").append(getNumber()).append(lineSep); b.append("Payee: ").append(getPayee()).append(lineSep); b.append("Memo: ").append(getMemo()).append(lineSep); b.append(lineSep); for (final TransactionEntry entry : getTransactionEntries()) { b.append(entry).append(lineSep); } return b.toString(); } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/TransactionEntry.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine; import java.io.Serializable; import java.math.BigDecimal; import java.util.Collection; import java.util.Collections; import java.util.HashSet; import java.util.Objects; import java.util.Set; import javax.persistence.CascadeType; import javax.persistence.Column; import javax.persistence.Entity; import javax.persistence.EnumType; import javax.persistence.Enumerated; import javax.persistence.FetchType; import javax.persistence.GeneratedValue; import javax.persistence.GenerationType; import javax.persistence.Id; import javax.persistence.JoinTable; import javax.persistence.ManyToMany; import javax.persistence.ManyToOne; import javax.persistence.OrderBy; import javax.persistence.SequenceGenerator; import jgnash.util.NotNull; /** * Transaction Entry *

* Each Transaction entry has an amount for the credit and debit side of the transaction. When the debit and credit * account has the same currency, one amount will be the negated value of the other. If the credit and debit accounts do * not have the same currency then the amounts will be different to represent the exchanged value at the time of the * transaction. *

* If the entry is to be used as an "single entry / adjustment" transaction, then the credit and debit account must be * set to the same account and the credit and debit amounts must be set to the same value. * * @author Craig Cavanaugh */ @Entity @SequenceGenerator(name = "sequence", allocationSize = 10) public class TransactionEntry implements Comparable, Cloneable, Serializable { /** * Cache the hash code for the TransactionEntry */ private transient int hash = 0; @SuppressWarnings("unused") @Id @GeneratedValue(generator = "sequence", strategy = GenerationType.SEQUENCE) private long id; @Enumerated(EnumType.STRING) private TransactionTag transactionTag = TransactionTag.BANK; /** * Account with balance being decreased. */ @ManyToOne private Account debitAccount; /** * Account with balance being increased. */ @ManyToOne private Account creditAccount; @Column(precision = 22, scale = 4) private BigDecimal creditAmount = BigDecimal.ZERO; @Column(precision = 22, scale = 4) private BigDecimal debitAmount = BigDecimal.ZERO; /** * Reconciled state of the transaction. */ @Enumerated(EnumType.STRING) private ReconciledState creditReconciled = ReconciledState.NOT_RECONCILED; /** * Reconciled state of the debit side of the transaction. */ @Enumerated(EnumType.STRING) private ReconciledState debitReconciled = ReconciledState.NOT_RECONCILED; /** * Memo for this entry. */ @Column(columnDefinition = "VARCHAR(1024)") private String memo = ""; @JoinTable @OrderBy("name") @ManyToMany(cascade = {CascadeType.REFRESH, CascadeType.PERSIST, CascadeType.MERGE}, fetch = FetchType.EAGER) private Set tags = new HashSet<>(); /** * Public constructor. */ public TransactionEntry() { } /** * Simple constructor for a single entry transaction. * * @param account account for the transaction * @param amount amount for the transaction */ public TransactionEntry(final Account account, final BigDecimal amount) { Objects.requireNonNull(account); Objects.requireNonNull(amount); creditAccount = account; debitAccount = account; creditAmount = amount; debitAmount = amount; } /** * Simple constructor for a double entry transaction. * * @param creditAccount credit account for the transaction * @param debitAccount debit account for the transaction * @param amount amount for the transaction */ TransactionEntry(final Account creditAccount, final Account debitAccount, final BigDecimal amount) { Objects.requireNonNull(creditAccount); Objects.requireNonNull(debitAccount); Objects.requireNonNull(amount); this.creditAccount = creditAccount; this.debitAccount = debitAccount; setAmount(amount.abs()); } /** * Simple constructor for a double entry transaction with exchange rate. * * @param creditAccount credit account for the transaction * @param debitAccount debit account for the transaction * @param creditAmount amount for the transaction * @param debitAmount amount for the transaction */ TransactionEntry(@NotNull final Account creditAccount, @NotNull final Account debitAccount, @NotNull final BigDecimal creditAmount, @NotNull final BigDecimal debitAmount) { Objects.requireNonNull(creditAccount); Objects.requireNonNull(debitAccount); Objects.requireNonNull(creditAmount); Objects.requireNonNull(debitAmount); assert creditAmount.signum() == 1 && debitAmount.signum() == -1; this.creditAccount = creditAccount; this.debitAccount = debitAccount; this.creditAmount = creditAmount; this.debitAmount = debitAmount; } public BigDecimal getCreditAmount() { return creditAmount; } public BigDecimal getAmount(@NotNull final Account account) { Objects.requireNonNull(account); if (account.equals(creditAccount)) { return creditAmount; } else if (account.equals(debitAccount)) { return debitAmount; } else { return BigDecimal.ZERO; } } /** * Shortcut method to set credit and debit amounts. * * @param amount credit amount of the transaction */ public final void setAmount(@NotNull final BigDecimal amount) { Objects.requireNonNull(amount); if (amount.signum() < 0) { throw new IllegalArgumentException("Amount must not be negative"); } creditAmount = amount; debitAmount = amount.negate(); } public Account getCreditAccount() { return creditAccount; } ReconciledState getCreditReconciled() { return creditReconciled; } public Account getDebitAccount() { return debitAccount; } ReconciledState getDebitReconciled() { return debitReconciled; } @NotNull public String getMemo() { return memo; } public ReconciledState getReconciled(final Account account) { if (account == getDebitAccount()) { return debitReconciled; } else if (account == getCreditAccount()) { return creditReconciled; } return ReconciledState.NOT_RECONCILED; } public void setCreditAmount(final BigDecimal creditAmount) { Objects.requireNonNull(creditAmount); this.creditAmount = creditAmount; } public void setCreditAccount(final Account creditAccount) { this.creditAccount = creditAccount; } void setCreditReconciled(@NotNull final ReconciledState creditReconciled) { Objects.requireNonNull(creditReconciled); this.creditReconciled = creditReconciled; } public void setDebitAccount(final Account debitAccount) { this.debitAccount = debitAccount; } void setDebitReconciled(@NotNull final ReconciledState debitReconciled) { Objects.requireNonNull(debitReconciled); this.debitReconciled = debitReconciled; } /** * Sets the memo for the entry. * * @param memo new memo */ public void setMemo(final String memo) { if (memo != null) { this.memo = memo; } } public void setReconciled(final Account account, final ReconciledState reconciled) { if (account.equals(getCreditAccount())) { setCreditReconciled(reconciled); } else if (account.equals(getDebitAccount())) { setDebitReconciled(reconciled); } } public BigDecimal getDebitAmount() { return debitAmount; } public void setDebitAmount(@NotNull final BigDecimal debitAmount) { Objects.requireNonNull(debitAmount); this.debitAmount = debitAmount; } public void setTags(final Collection tags) { this.tags.clear(); this.tags.addAll(tags); } public Set getTags() { if (tags != null) { return Collections.unmodifiableSet(tags); } return Collections.emptySet(); } @Override public int compareTo(@NotNull final TransactionEntry entry) { if (this == entry) { return 0; } int result = memo.compareTo(entry.getMemo()); if (result != 0) { return result; } result = transactionTag.compareTo(entry.transactionTag); if (result != 0) { return result; } if (hashCode() > entry.hashCode()) { return 1; } return -1; } /** * Check to determine is this is a single entry transaction. * * @return {@code true} if this is a single entry TransactionEntry */ boolean isSingleEntry() { return creditAccount.equals(debitAccount) && creditAmount.compareTo(debitAmount) == 0; } /** * Returns true if multiple currencies are being used for this entry. *

* If the credit and debit accounts have differing currencies, then unless * the currencies are equal in value, the credit and debit amounts should be different. * * @return {@code true} if this is a multi-currency transaction entry */ public boolean isMultiCurrency() { return !creditAccount.getCurrencyNode().equals(debitAccount.getCurrencyNode()); } public void setTransactionTag(final TransactionTag transactionTag) { Objects.requireNonNull(transactionTag); this.transactionTag = transactionTag; } public TransactionTag getTransactionTag() { return transactionTag; } @Override public Object clone() throws CloneNotSupportedException { final TransactionEntry e = (TransactionEntry) super.clone(); // a deep clone for tags is needed for JPA persistence e.tags = new HashSet<>(); e.tags.addAll(tags); // clones id must be reset e.id = 0; return e; } @SuppressWarnings("BigDecimalEquals") @Override public boolean equals(Object o) { if (this == o) { return true; } if (o == null || getClass() != o.getClass()) { return false; } TransactionEntry that = (TransactionEntry) o; return Objects.equals(transactionTag, that.transactionTag) && Objects.equals(debitAccount, that.debitAccount) && Objects.equals(creditAccount, that.creditAccount) && Objects.equals(creditAmount, that.creditAmount) && Objects.equals(debitAmount, that.debitAmount) && Objects.equals(creditReconciled, that.creditReconciled) && Objects.equals(debitReconciled, that.debitReconciled) && Objects.equals(memo, that.memo) && Objects.equals(tags, that.tags); } @Override public int hashCode() { int h = hash; if (h == 0) { h = Objects.hash(transactionTag, debitAccount, creditAccount, creditAmount, debitAmount, creditReconciled, debitReconciled, memo, tags); hash = h; } return h; } @Override public String toString() { StringBuilder b = new StringBuilder(); final String lineSep = System.lineSeparator(); b.append("TransactionEntry hashCode: ").append(hashCode()).append(lineSep); b.append("Tag: ").append(getTransactionTag().name()).append(lineSep); b.append("Memo: ").append(getMemo()).append(lineSep); b.append("Debit Account: ").append(getDebitAccount().getName()).append(lineSep); b.append("Credit Account: ").append(getCreditAccount().getName()).append(lineSep); b.append("Debit Amount: ").append(getDebitAmount().toPlainString()).append(lineSep); b.append("Credit Amount: ").append(getCreditAmount().toPlainString()).append(lineSep); return b.toString(); } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/TransactionEntryAbstractIncrease.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine; import java.math.BigDecimal; import javax.persistence.Entity; /** * Abstract Add transaction. * * @author Craig Cavanaugh */ @Entity public abstract class TransactionEntryAbstractIncrease extends AbstractInvestmentTransactionEntry { protected TransactionEntryAbstractIncrease() { } /** * Returns the number of shares as it would impact * the sum of the investment accounts shares. Useful * for summing share quantities * * @return the quantity of securities for this transaction */ @Override public BigDecimal getSignedQuantity() { return getQuantity(); } @Override public void setCreditAccount(Account creditAccount) { super.setCreditAccount(creditAccount); super.setDebitAccount(creditAccount); } @Override public void setDebitAccount(Account debitAccount) { super.setDebitAccount(debitAccount); super.setCreditAccount(debitAccount); } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/TransactionEntryAddX.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine; import java.math.BigDecimal; import javax.persistence.Entity; import jgnash.util.NotNull; /** * Add shares without impacting the cash balance. This is a single * entry transaction * * @author Craig Cavanaugh */ @Entity public class TransactionEntryAddX extends TransactionEntryAbstractIncrease { /** * No argument constructor for reflection purposes. * Do not use to create a new instance */ @SuppressWarnings("unused") public TransactionEntryAddX() { } public TransactionEntryAddX(final Account account, final SecurityNode securityNode, final BigDecimal price, final BigDecimal quantity) { setCreditAccount(account); setDebitAccount(account); setPrice(price); setQuantity(quantity); setSecurityNode(securityNode); } @Override @NotNull public TransactionType getTransactionType() { return TransactionType.ADDSHARE; } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/TransactionEntryBuyX.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine; import java.math.BigDecimal; import javax.persistence.Entity; import jgnash.util.NotNull; /** * Buy shares and reduce the (cash) balance of an account. *

* The investment account is always assigned to the debit account. *

* If an account other than the investment account is assigned to the credit * account, then the balance of the credit account is reduced because the cost * of the buy is made against it. * * @author Craig Cavanaugh */ @Entity public class TransactionEntryBuyX extends AbstractInvestmentTransactionEntry { /** * No argument constructor for reflection purposes. * Do not use to create a new instance */ @SuppressWarnings("unused") public TransactionEntryBuyX() { } /** * Constructor. * * @param account Credit account * @param investmentAccount Debit / Investment account * @param securityNode Security for the transaction * @param price Price of shares * @param quantity Number of shares * @param exchangeRate Exchange rate for the credit account (May be ONE, but may not be null and must be greater than ZERO) */ TransactionEntryBuyX(final Account account, final Account investmentAccount, final SecurityNode securityNode, final BigDecimal price, final BigDecimal quantity, final BigDecimal exchangeRate) { assert investmentAccount.memberOf(AccountGroup.INVEST); assert exchangeRate != null && exchangeRate.signum() == 1; setSecurityNode(securityNode); setPrice(price); setQuantity(quantity); setDebitAccount(investmentAccount); setCreditAccount(account); if (investmentAccount.equals(account)) { // transaction against the cash balance BigDecimal amount = price.multiply(quantity).setScale(investmentAccount.getCurrencyNode().getScale(), MathConstants.roundingMode).negate(); setCreditAmount(amount); setDebitAmount(amount); } else { // transaction against a different account setDebitAmount(BigDecimal.ZERO); byte scale = getCreditAccount().getCurrencyNode().getScale(); if (account.getCurrencyNode().equals(investmentAccount.getCurrencyNode())) { setCreditAmount(price.multiply(quantity.negate()).setScale(scale, MathConstants.roundingMode)); } else { // currency exchange setCreditAmount(price.multiply(quantity.negate()).multiply(exchangeRate).setScale(scale, MathConstants.roundingMode)); } } } /** * Returns the number of shares as it would impact * the sum of the investment accounts shares. Useful * for summing share quantities * * @return the quantity of securities for this transaction */ @Override public BigDecimal getSignedQuantity() { return getQuantity(); } @Override @NotNull public TransactionType getTransactionType() { return TransactionType.BUYSHARE; } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/TransactionEntryDividendX.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine; import java.math.BigDecimal; import java.util.Objects; import javax.persistence.Entity; import jgnash.util.NotNull; /** * Investment dividend. *

* The creditAccount and creditAmount fields are used for the investment account. * The debitAccount and debitAmount fields are used for the capital gains (income) account. * creditAccount is assumed to be the investment account. * * @author Craig Cavanaugh */ @Entity public class TransactionEntryDividendX extends AbstractInvestmentTransactionEntry { /** * No argument constructor for reflection purposes only. * Do not use to create a new instance */ @SuppressWarnings("unused") public TransactionEntryDividendX() { } /** * Constructor. * * @param incomeAccount Debit account * @param investmentAccount Credit/Investment account * @param securityNode Security for the transaction * @param dividend Dividend received * @param incomeExchangedAmount Exchanged amount for the debit account */ TransactionEntryDividendX(final Account incomeAccount, final Account investmentAccount, final SecurityNode securityNode, final BigDecimal dividend, final BigDecimal incomeExchangedAmount) { Objects.requireNonNull(incomeAccount); Objects.requireNonNull(investmentAccount); assert investmentAccount.memberOf(AccountGroup.INVEST); assert dividend.signum() >= 0 && incomeExchangedAmount.signum() <= 0; setSecurityNode(securityNode); /* Dividends do not involve exchange of shares */ setPrice(BigDecimal.ZERO); setQuantity(BigDecimal.ZERO); setCreditAccount(investmentAccount); setDebitAccount(incomeAccount); /* Transaction can be treated as single entry, but double entry is being forced by UI * Single entry support is required for jGnash 1.x in. */ if (investmentAccount.equals(incomeAccount)) { // transaction against the cash balance setCreditAmount(dividend); // treat as a single entry transaction setDebitAmount(dividend); } else { // double entry dividend setCreditAmount(dividend); // account balance of investment account not impacted setDebitAmount(incomeExchangedAmount); } } @Override public BigDecimal getTotal() { return getCreditAmount(); } /** * Returns the number of shares as it would impact * the sum of the investment accounts shares. Useful * for summing share quantities * * @return the quantity of securities for this transaction */ @Override public BigDecimal getSignedQuantity() { return BigDecimal.ZERO; } @Override @NotNull public TransactionType getTransactionType() { return TransactionType.DIVIDEND; } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/TransactionEntryMergeX.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine; import java.math.BigDecimal; import javax.persistence.Entity; import jgnash.util.NotNull; import jgnash.resource.util.ResourceUtils; /** * Remove shares without impacting the cash balance. This is a single entry transaction * * @author Craig Cavanaugh */ @Entity public class TransactionEntryMergeX extends AbstractInvestmentTransactionEntry { /** * No argument constructor for reflection purposes. *

* Do not use to create a new instance */ @SuppressWarnings("unused") public TransactionEntryMergeX() { } public TransactionEntryMergeX(final Account investmentAccount, final SecurityNode securityNode, final BigDecimal price, final BigDecimal quantity) { if (investmentAccount.getAccountType().getAccountGroup() != AccountGroup.INVEST) { throw new RuntimeException(ResourceUtils.getString("Message.Error.InvalidAccountGroup")); } setCreditAccount(investmentAccount); setDebitAccount(investmentAccount); setPrice(price); setQuantity(quantity); setSecurityNode(securityNode); } /** * Returns the number of shares as it would impact the sum of the investment accounts shares. Useful for summing * share quantities * * @return the quantity of securities for this transaction */ @Override public BigDecimal getSignedQuantity() { return getQuantity().negate(); } @Override @NotNull public TransactionType getTransactionType() { return TransactionType.MERGESHARE; } @Override public void setCreditAccount(final Account creditAccount) { super.setCreditAccount(creditAccount); super.setDebitAccount(creditAccount); } @Override public void setDebitAccount(final Account debitAccount) { super.setDebitAccount(debitAccount); super.setCreditAccount(debitAccount); } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/TransactionEntryReinvestDivX.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine; import java.math.BigDecimal; import javax.persistence.Entity; import jgnash.util.NotNull; import jgnash.resource.util.ResourceUtils; /** * Reinvest dividend transaction. * * @author Craig Cavanaugh */ @Entity public class TransactionEntryReinvestDivX extends TransactionEntryAbstractIncrease { /** * No argument constructor for reflection purposes. *

* Do not use to create a new instance */ @SuppressWarnings("unused") public TransactionEntryReinvestDivX() { } TransactionEntryReinvestDivX(final Account investmentAccount, final SecurityNode securityNode, final BigDecimal price, final BigDecimal quantity) { if (investmentAccount.getAccountType().getAccountGroup() != AccountGroup.INVEST) { throw new RuntimeException(ResourceUtils.getString("Message.Error.InvalidAccountGroup")); } setSecurityNode(securityNode); setPrice(price); setQuantity(quantity); setCreditAccount(investmentAccount); setDebitAmount(BigDecimal.ZERO); setCreditAmount(BigDecimal.ZERO); } /** * Returns {@code TransactionType.REINVESTDIV} for this type of transaction * * @see TransactionType#REINVESTDIV * @return returns {@code TransactionType.REINVESTDIV} */ @Override @NotNull public TransactionType getTransactionType() { return TransactionType.REINVESTDIV; } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/TransactionEntryRemoveX.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine; import java.math.BigDecimal; import javax.persistence.Entity; import jgnash.util.NotNull; /** * Remove shares without impacting the cash balance. This is a single * entry transaction * * @author Craig Cavanaugh */ @Entity public class TransactionEntryRemoveX extends AbstractInvestmentTransactionEntry { /** * No argument constructor for reflection purposes. * Do not use to create a new instance */ @SuppressWarnings("unused") public TransactionEntryRemoveX() { } public TransactionEntryRemoveX(final Account account, final SecurityNode securityNode, final BigDecimal price, final BigDecimal quantity) { setCreditAccount(account); setDebitAccount(account); setPrice(price); setQuantity(quantity); setSecurityNode(securityNode); } /** * Returns the number of shares as it would impact * the sum of the investment accounts shares. Useful * for summing share quantities * * @return the quantity of securities for this transaction */ @Override public BigDecimal getSignedQuantity() { return getQuantity().negate(); } @Override @NotNull public TransactionType getTransactionType() { return TransactionType.REMOVESHARE; } @Override public void setCreditAccount(final Account creditAccount) { super.setCreditAccount(creditAccount); super.setDebitAccount(creditAccount); } @Override public void setDebitAccount(final Account debitAccount) { super.setDebitAccount(debitAccount); super.setCreditAccount(debitAccount); } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/TransactionEntryRocX.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine; import java.math.BigDecimal; import java.util.Objects; import javax.persistence.Entity; import jgnash.util.NotNull; /** * Return of capital investment transaction. *

* The creditAccount and creditAmount fields are used for the investment account. * The debitAccount and debitAmount fields are used for the capital gains (income) account. * * @author Craig Cavanaugh */ @Entity public class TransactionEntryRocX extends AbstractInvestmentTransactionEntry { /** * No argument constructor for reflection purposes only. * Do not use to create a new instance */ @SuppressWarnings("unused") public TransactionEntryRocX() { } /** * Constructor. * * @param incomeAccount Debit account * @param investmentAccount Credit/Investment account * @param securityNode Security for the transaction * @param dividend Dividend received * @param incomeExchangedAmount Exchanged amount for the debit account */ TransactionEntryRocX(final Account incomeAccount, final Account investmentAccount, final SecurityNode securityNode, final BigDecimal dividend, final BigDecimal incomeExchangedAmount) { Objects.requireNonNull(incomeAccount); Objects.requireNonNull(investmentAccount); assert investmentAccount.memberOf(AccountGroup.INVEST); assert dividend.signum() >= 0 && incomeExchangedAmount.signum() <= 0; setSecurityNode(securityNode); /* Dividends do not involve exchange of shares */ setPrice(BigDecimal.ZERO); setQuantity(BigDecimal.ZERO); setCreditAccount(investmentAccount); setDebitAccount(incomeAccount); /* Transaction can be treated as single entry, but double entry is being forced by UI * Single entry support is required for jGnash 1.x in. */ if (investmentAccount.equals(incomeAccount)) { // transaction against the cash balance setCreditAmount(dividend); // treat as a single entry transaction setDebitAmount(dividend); } else { // double entry dividend setCreditAmount(dividend); // account balance of investment account not impacted setDebitAmount(incomeExchangedAmount); } } @Override public BigDecimal getTotal() { return getCreditAmount(); } /** * Returns the number of shares as it would impact * the sum of the investment accounts shares. Useful * for summing share quantities * * @return the quantity of securities for this transaction */ @Override public BigDecimal getSignedQuantity() { return BigDecimal.ZERO; } @Override @NotNull public TransactionType getTransactionType() { return TransactionType.RETURNOFCAPITAL; } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/TransactionEntrySellX.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine; import java.math.BigDecimal; import javax.persistence.Entity; import jgnash.util.NotNull; /** * Sell shares and increase the (cash) balance of an account. *

* The investment account is always assigned to the credit account. *

* If an account other than the investment account is assigned to the debit * account, then the balance of the debit account is increased because the * gains of the sell are added to it. * * @author Craig Cavanaugh */ @Entity public class TransactionEntrySellX extends AbstractInvestmentTransactionEntry { /** * No argument constructor for reflection purposes. * Do not use to create a new instance */ @SuppressWarnings("unused") public TransactionEntrySellX() { } /** * Constructor. * * @param account Debit account * @param investmentAccount Credit / Investment account * @param securityNode Security for the transaction * @param price Price of shares * @param quantity Number of shares * @param exchangeRate Exchange rate for the debit account (May be ONE, but may not be null and must be greater than ZERO) */ TransactionEntrySellX(final Account account, final Account investmentAccount, final SecurityNode securityNode, final BigDecimal price, final BigDecimal quantity, final BigDecimal exchangeRate) { assert investmentAccount.memberOf(AccountGroup.INVEST); assert exchangeRate != null && exchangeRate.signum() == 1; setSecurityNode(securityNode); setPrice(price); setQuantity(quantity); setCreditAccount(investmentAccount); setDebitAccount(account); if (investmentAccount.equals(account)) { // transaction against the cash balance BigDecimal amount = price.multiply(quantity).setScale(investmentAccount.getCurrencyNode().getScale(), MathConstants.roundingMode); setCreditAmount(amount); setDebitAmount(amount); } else { // transaction against a different account setCreditAmount(BigDecimal.ZERO); byte scale = getCreditAccount().getCurrencyNode().getScale(); if (account.getCurrencyNode().equals(investmentAccount.getCurrencyNode())) { setDebitAmount(price.multiply(quantity).setScale(scale, MathConstants.roundingMode)); } else { // currency exchange setDebitAmount(price.multiply(quantity).multiply(exchangeRate).setScale(scale, MathConstants.roundingMode)); } } } /** * Returns the number of shares as it would impact * the sum of the investment accounts shares. Useful * for summing share quantities * * @return the quantity of securities for this transaction */ @Override public BigDecimal getSignedQuantity() { return getQuantity().negate(); } @Override @NotNull public TransactionType getTransactionType() { return TransactionType.SELLSHARE; } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/TransactionEntrySplitX.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine; import java.math.BigDecimal; import javax.persistence.Entity; import jgnash.util.NotNull; import jgnash.resource.util.ResourceUtils; /** * Add shares without impacting the cash balance. This is a single entry transaction * * @author Craig Cavanaugh */ @Entity public class TransactionEntrySplitX extends TransactionEntryAbstractIncrease { /** * No argument constructor for reflection purposes. *

* Do not use to create a new instance */ @SuppressWarnings("unused") public TransactionEntrySplitX() { } public TransactionEntrySplitX(final Account investmentAccount, final SecurityNode securityNode, final BigDecimal price, final BigDecimal quantity) { if (investmentAccount.getAccountType().getAccountGroup() != AccountGroup.INVEST) { throw new RuntimeException(ResourceUtils.getString("Message.Error.InvalidAccountGroup")); } setCreditAccount(investmentAccount); setDebitAccount(investmentAccount); setPrice(price); setQuantity(quantity); setSecurityNode(securityNode); } @Override @NotNull public TransactionType getTransactionType() { return TransactionType.SPLITSHARE; } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/TransactionFactory.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine; import java.math.BigDecimal; import java.text.NumberFormat; import java.time.LocalDate; import java.util.Collection; import java.util.Objects; import java.util.ResourceBundle; import jgnash.text.NumericFormats; import jgnash.resource.util.ResourceUtils; /** * Transaction Factory. * * @author Craig Cavanaugh */ public class TransactionFactory { private static final String MESSAGE_ERROR_INVALID_TRANSACTION_TAG = "Message.Error.InvalidTransactionTag"; /** * Create an AddX investment transaction. * * @param investmentAccount Investment account * @param node Security to add * @param price Price of each share * @param quantity Number of shares * @param date Transaction date * @param memo Transaction memo * @return new Investment Transaction */ public static InvestmentTransaction generateAddXTransaction(final Account investmentAccount, final SecurityNode node, final BigDecimal price, final BigDecimal quantity, final LocalDate date, final String memo) { Objects.requireNonNull(investmentAccount); Objects.requireNonNull(node); Objects.requireNonNull(price); Objects.requireNonNull(quantity); Objects.requireNonNull(date); Objects.requireNonNull(memo); final InvestmentTransaction transaction = new InvestmentTransaction(); transaction.setDate(date); transaction.setMemo(memo); final TransactionEntryAddX entry = new TransactionEntryAddX(investmentAccount, node, price, quantity); entry.setMemo(memo); transaction.setPayee(buildPayee("Word.Add", node, price, quantity)); transaction.addTransactionEntry(entry); return transaction; } /** * Create a buy security transaction. * * @param account Account to buy against (can be the same investment account) * @param investmentAccount Investment account * @param node Security to buy * @param price Price of each share * @param quantity Number of shares * @param exchangeRate Exchange rate (Can be BigDecimal.ONE, cannot be null) * @param date Transaction date * @param memo Transaction memo * @param fees List of transaction fees * @return new Transaction */ public static InvestmentTransaction generateBuyXTransaction(final Account account, final Account investmentAccount, final SecurityNode node, final BigDecimal price, final BigDecimal quantity, final BigDecimal exchangeRate, final LocalDate date, final String memo, final Collection fees) { Objects.requireNonNull(account); Objects.requireNonNull(investmentAccount); Objects.requireNonNull(node); Objects.requireNonNull(price); Objects.requireNonNull(quantity); Objects.requireNonNull(date); Objects.requireNonNull(memo); Objects.requireNonNull(exchangeRate); Objects.requireNonNull(fees); // verify fees are tagged correctly for (TransactionEntry fee : fees) { if (fee.getTransactionTag() != TransactionTag.INVESTMENT_FEE) { throw new EngineException(ResourceUtils.getString(MESSAGE_ERROR_INVALID_TRANSACTION_TAG)); } } final InvestmentTransaction transaction = new InvestmentTransaction(); transaction.setDate(date); transaction.setMemo(memo); final TransactionEntryBuyX entry = new TransactionEntryBuyX(account, investmentAccount, node, price, quantity, exchangeRate); entry.setMemo(memo); transaction.setPayee(buildPayee("Word.Buy", node, price, quantity)); transaction.addTransactionEntry(entry); // process transaction fees processFees(transaction, account, investmentAccount, memo, fees, exchangeRate); // Logger.getLogger(TransactionFactory.class.getName()).info(transaction.toString()); return transaction; } /** * Create a Dividend transaction. * * @param incomeAccount Income source account for the cash dividend * @param investmentAccount Investment account * @param cashAccount The account receiving the cash dividend. May be the same investment account. * @param node Security for dividend * @param dividend Cash dividend * @param incomeExchangedAmount Income account exchanged amount (Can be the same as dividend, cannot be null) * @param cashExchangedAmount The exchanged amount for the cash account (Can be the same as dividend, cannot be null) * @param date Transaction date * @param memo Transaction memo * @return new InvestmentTransaction */ public static InvestmentTransaction generateDividendXTransaction(final Account incomeAccount, final Account investmentAccount, final Account cashAccount, final SecurityNode node, final BigDecimal dividend, final BigDecimal incomeExchangedAmount, final BigDecimal cashExchangedAmount, final LocalDate date, final String memo) { Objects.requireNonNull(incomeAccount); Objects.requireNonNull(cashAccount); Objects.requireNonNull(investmentAccount); Objects.requireNonNull(node); Objects.requireNonNull(dividend); Objects.requireNonNull(incomeExchangedAmount); Objects.requireNonNull(date); Objects.requireNonNull(memo); if (incomeExchangedAmount.signum() > 0) { throw new EngineException("Income exchange amount must be less than or equal to zero"); } final InvestmentTransaction transaction = new InvestmentTransaction(); transaction.setDate(date); transaction.setMemo(memo); final TransactionEntryDividendX entry = new TransactionEntryDividendX(incomeAccount, investmentAccount, node, dividend, incomeExchangedAmount); entry.setMemo(memo); final NumberFormat format = NumericFormats.getFullCommodityFormat(incomeAccount.getCurrencyNode()); transaction.setPayee(ResourceUtils.getString("Word.Dividend") + " : " + node.getSymbol() + " @ " + format.format(dividend)); transaction.addTransactionEntry(entry); // Process a cash transfer processCashTransfer(transaction, cashAccount, investmentAccount, memo, dividend, cashExchangedAmount); return transaction; } /** * Create a Return of Capital transaction. * * @param incomeAccount Income source account for the cash dividend * @param investmentAccount Investment account * @param cashAccount The account receiving the cash dividend. May be the same investment account. * @param node Security for dividend * @param dividend Cash dividend * @param incomeExchangedAmount Income account exchanged amount (Can be the same as dividend, cannot be null) * @param cashExchangedAmount The exchanged amount for the cash account (Can be the same as dividend, cannot be * null) * @param date Transaction date * @param memo Transaction memo * @return new InvestmentTransaction */ public static InvestmentTransaction generateRocXTransaction(final Account incomeAccount, final Account investmentAccount, final Account cashAccount, final SecurityNode node, final BigDecimal dividend, final BigDecimal incomeExchangedAmount, final BigDecimal cashExchangedAmount, final LocalDate date, final String memo) { Objects.requireNonNull(incomeAccount); Objects.requireNonNull(cashAccount); Objects.requireNonNull(investmentAccount); Objects.requireNonNull(node); Objects.requireNonNull(dividend); Objects.requireNonNull(incomeExchangedAmount); Objects.requireNonNull(date); Objects.requireNonNull(memo); if (incomeExchangedAmount.signum() > 0) { throw new EngineException("Income exchange amount must be less than or equal to zero"); } final InvestmentTransaction transaction = new InvestmentTransaction(); transaction.setDate(date); transaction.setMemo(memo); final TransactionEntryRocX entry = new TransactionEntryRocX(incomeAccount, investmentAccount, node, dividend, incomeExchangedAmount); entry.setMemo(memo); final ResourceBundle rb = ResourceUtils.getBundle(); final NumberFormat format = NumericFormats.getFullCommodityFormat(incomeAccount.getCurrencyNode()); transaction.setPayee(rb.getString("Word.ReturnOfCapital") + " : " + node.getSymbol() + " @ " + format.format(dividend)); transaction.addTransactionEntry(entry); // Process a cash transfer processCashTransfer(transaction, cashAccount, investmentAccount, memo, dividend, cashExchangedAmount); return transaction; } /** * Generate a double entry transaction with exchange rate. * * @param creditAccount Credit account * @param debitAccount Debit account * @param creditAmount Transaction credit amount * @param debitAmount Transaction credit amount * @param date Transaction date * @param memo Transaction memo * @param payee Transaction payee * @param number Transaction number * @return new Transaction */ public static Transaction generateDoubleEntryTransaction(final Account creditAccount, final Account debitAccount, final BigDecimal creditAmount, final BigDecimal debitAmount, final LocalDate date, final String memo, final String payee, final String number) { Objects.requireNonNull(creditAccount); Objects.requireNonNull(debitAccount); if (creditAccount == debitAccount) { throw new EngineException(ResourceUtils.getString("Message.Error.CreditDebit.Equal")); } final Transaction transaction = new Transaction(); transaction.setDate(date); transaction.setNumber(number); transaction.setPayee(payee); transaction.setMemo(memo); final TransactionEntry entry = new TransactionEntry(creditAccount, debitAccount, creditAmount, debitAmount); entry.setMemo(memo); transaction.addTransactionEntry(entry); return transaction; } /** * Generate a double entry transaction. * * @param creditAccount Credit account * @param debitAccount Debit account * @param amount Transaction amount * @param date Transaction date * @param memo Transaction memo * @param payee Transaction payee * @param number Transaction number * @return new Transaction */ public static Transaction generateDoubleEntryTransaction(final Account creditAccount, final Account debitAccount, final BigDecimal amount, final LocalDate date, final String memo, final String payee, final String number) { Objects.requireNonNull(creditAccount); Objects.requireNonNull(debitAccount); if (creditAccount == debitAccount) { throw new EngineException(ResourceUtils.getString("Message.Error.CreditDebit.Equal")); } final Transaction transaction = new Transaction(); transaction.setDate(date); transaction.setNumber(number); transaction.setPayee(payee); transaction.setMemo(memo); final TransactionEntry entry = new TransactionEntry(creditAccount, debitAccount, amount); entry.setMemo(memo); transaction.addTransactionEntry(entry); return transaction; } /** * Create a Split investment transaction. * * @param investmentAccount Investment account * @param node Security that merged * @param price Price of each share * @param quantity Number of shares * @param date Transaction date * @param memo Transaction memo * @return new Investment Transaction */ public static InvestmentTransaction generateMergeXTransaction(final Account investmentAccount, final SecurityNode node, final BigDecimal price, final BigDecimal quantity, final LocalDate date, final String memo) { Objects.requireNonNull(investmentAccount); Objects.requireNonNull(node); Objects.requireNonNull(price); Objects.requireNonNull(quantity); Objects.requireNonNull(date); Objects.requireNonNull(memo); final InvestmentTransaction transaction = new InvestmentTransaction(); transaction.setDate(date); transaction.setMemo(memo); final TransactionEntryMergeX entry = new TransactionEntryMergeX(investmentAccount, node, price, quantity); entry.setMemo(memo); transaction.setPayee(buildPayee("Word.Merge", node, price, quantity)); transaction.addTransactionEntry(entry); return transaction; } /** * Create a Reinvested Dividend transaction. * * @param investmentAccount Investment account * @param node Security for dividend * @param price Share price * @param quantity Quantity of shares reinvested * @param date Date of transaction * @param memo Transaction memo * @param fees Fee entry(s) * @param gains Gain/Loss entry(s) * @return new InvestmentTransaction */ public static InvestmentTransaction generateReinvestDividendXTransaction(final Account investmentAccount, final SecurityNode node, final BigDecimal price, final BigDecimal quantity, final LocalDate date, final String memo, final Collection fees, final Collection gains) { Objects.requireNonNull(investmentAccount); Objects.requireNonNull(node); Objects.requireNonNull(price); Objects.requireNonNull(quantity); Objects.requireNonNull(date); Objects.requireNonNull(memo); Objects.requireNonNull(fees); Objects.requireNonNull(gains); for (final TransactionEntry fee : fees) { if (fee.getTransactionTag() != TransactionTag.INVESTMENT_FEE) { throw new EngineException(ResourceUtils.getString(MESSAGE_ERROR_INVALID_TRANSACTION_TAG)); } } for (final TransactionEntry gain : gains) { if (gain.getTransactionTag() != TransactionTag.GAIN_LOSS) { throw new EngineException(ResourceUtils.getString(MESSAGE_ERROR_INVALID_TRANSACTION_TAG)); } } final InvestmentTransaction transaction = new InvestmentTransaction(); transaction.setDate(date); transaction.setMemo(memo); final TransactionEntryReinvestDivX entry = new TransactionEntryReinvestDivX(investmentAccount, node, price, quantity); entry.setMemo(memo); transaction.setPayee(buildPayee("Word.ReInvDiv", node, price, quantity)); transaction.addTransactionEntry(entry); if (!fees.isEmpty()) { BigDecimal totalFees = BigDecimal.ZERO; // loop through and add investment fees for (final TransactionEntry fee : fees) { transaction.addTransactionEntry(fee); totalFees = totalFees.add(fee.getAmount(investmentAccount)); } // create a single entry transaction that offsets any resulting fees final TransactionEntry feesOffsetEntry = new TransactionEntry(investmentAccount, totalFees.negate()); feesOffsetEntry.setMemo(memo); feesOffsetEntry.setTransactionTag(TransactionTag.FEES_OFFSET); assert feesOffsetEntry.isSingleEntry(); // check transaction.addTransactionEntry(feesOffsetEntry); } // process gains processGains(transaction, investmentAccount, memo, gains); return transaction; } /** * Create a RemoveX investment transaction. * * @param investmentAccount Investment account * @param node Security to remove * @param price Price of each share * @param quantity Number of shares * @param date Transaction date * @param memo Transaction memo * @return new Investment Transaction */ public static InvestmentTransaction generateRemoveXTransaction(final Account investmentAccount, final SecurityNode node, final BigDecimal price, final BigDecimal quantity, final LocalDate date, final String memo) { Objects.requireNonNull(investmentAccount); Objects.requireNonNull(node); Objects.requireNonNull(price); Objects.requireNonNull(quantity); Objects.requireNonNull(date); Objects.requireNonNull(memo); final InvestmentTransaction transaction = new InvestmentTransaction(); transaction.setDate(date); transaction.setMemo(memo); final TransactionEntryRemoveX entry = new TransactionEntryRemoveX(investmentAccount, node, price, quantity); entry.setMemo(memo); transaction.setPayee(buildPayee("Word.Remove", node, price, quantity)); transaction.addTransactionEntry(entry); return transaction; } /** * Create a sell security transaction. * * @param account Account receive sale profits or loss. May be the same as the investment account (Cash balance) * @param investmentAccount Investment account * @param node Security to sell * @param price Price of each share * @param quantity Number of shares * @param exchangeRate Exchanged amount (cannot be null) * @param date Transaction date * @param memo Transaction memo * @param fees Purchase fee * @param gains Gains/Loss entries * @return new Transaction */ public static InvestmentTransaction generateSellXTransaction(final Account account, final Account investmentAccount, final SecurityNode node, final BigDecimal price, final BigDecimal quantity, final BigDecimal exchangeRate, final LocalDate date, final String memo, final Collection fees, final Collection gains) { Objects.requireNonNull(account); Objects.requireNonNull(investmentAccount); Objects.requireNonNull(node); Objects.requireNonNull(price); Objects.requireNonNull(quantity); Objects.requireNonNull(exchangeRate); Objects.requireNonNull(gains); Objects.requireNonNull(fees); Objects.requireNonNull(date); Objects.requireNonNull(memo); // verify fees are tagged correctly for (final TransactionEntry fee : fees) { if (fee.getTransactionTag() != TransactionTag.INVESTMENT_FEE) { throw new EngineException(ResourceUtils.getString(MESSAGE_ERROR_INVALID_TRANSACTION_TAG)); } } // verify gains are tagged correctly for (final TransactionEntry gain : gains) { if (gain.getTransactionTag() != TransactionTag.GAIN_LOSS) { throw new EngineException(ResourceUtils.getString(MESSAGE_ERROR_INVALID_TRANSACTION_TAG)); } } final InvestmentTransaction transaction = new InvestmentTransaction(); transaction.setDate(date); transaction.setMemo(memo); final TransactionEntrySellX entry = new TransactionEntrySellX(account, investmentAccount, node, price, quantity, exchangeRate); entry.setMemo(memo); transaction.setPayee(buildPayee("Word.Sell", node, price, quantity)); transaction.addTransactionEntry(entry); // process transaction fees processFees(transaction, account, investmentAccount, memo, fees, exchangeRate); // process gains processGains(transaction, investmentAccount, memo, gains); // Logger.getLogger(TransactionFactory.class.getName()).info(transaction.toString()); return transaction; } /** * Create a single entry transaction. * * @param account Destination account * @param amount Transaction amount * @param date Transaction date * @param memo Transaction memo * @param payee Transaction payee * @param number Transaction number * @return new Transaction */ public static Transaction generateSingleEntryTransaction(final Account account, final BigDecimal amount, final LocalDate date, final String memo, final String payee, final String number) { Objects.requireNonNull(account); Objects.requireNonNull(amount); Objects.requireNonNull(date); final Transaction transaction = new Transaction(); transaction.setDate(date); transaction.setNumber(number); transaction.setPayee(payee); transaction.setMemo(memo); final TransactionEntry entry = new TransactionEntry(account, amount); entry.setMemo(memo); assert entry.isSingleEntry(); // check transaction.addTransactionEntry(entry); return transaction; } /** * Create a Split investment transaction. * * @param investmentAccount Investment account * @param node Security that split * @param price Price of each share * @param quantity Number of shares * @param date Transaction date * @param memo Transaction memo * @return new Investment Transaction */ public static InvestmentTransaction generateSplitXTransaction(final Account investmentAccount, final SecurityNode node, final BigDecimal price, final BigDecimal quantity, final LocalDate date, final String memo) { Objects.requireNonNull(investmentAccount); Objects.requireNonNull(node); Objects.requireNonNull(price); Objects.requireNonNull(quantity); Objects.requireNonNull(date); Objects.requireNonNull(memo); final InvestmentTransaction transaction = new InvestmentTransaction(); transaction.setDate(date); transaction.setMemo(memo); final TransactionEntrySplitX entry = new TransactionEntrySplitX(investmentAccount, node, price, quantity); entry.setMemo(memo); transaction.setPayee(buildPayee("Word.Split", node, price, quantity)); transaction.addTransactionEntry(entry); return transaction; } private static String buildPayee(final String wordProperty, final SecurityNode node, final BigDecimal price, final BigDecimal quantity) { final NumberFormat format = NumericFormats.getFullCommodityFormat(node); return ResourceUtils.getString(wordProperty) + " : " + node.getSymbol() + ' ' + quantity.toString() + " @ " + format.format(price); } private static void processCashTransfer(final Transaction transaction, final Account cashAccount, final Account investmentAccount, final String memo, final BigDecimal dividend, final BigDecimal cashExchangedAmount) { if (!cashAccount.equals(investmentAccount)) { final TransactionEntry tran = new TransactionEntry(cashAccount, investmentAccount, cashExchangedAmount, dividend.negate()); tran.setMemo(memo); tran.setTransactionTag(TransactionTag.INVESTMENT_CASH_TRANSFER); transaction.addTransactionEntry(tran); } } private static void processFees(final Transaction transaction, final Account account, final Account investmentAccount, final String memo, final Collection fees, final BigDecimal exchangeRate) { // process transaction fees if (!fees.isEmpty()) { // total of the fees charged against the investment account BigDecimal nonCashBalanceFees = BigDecimal.ZERO; // loop through and add investment fees to the transaction for (final TransactionEntry fee : fees) { transaction.addTransactionEntry(fee); nonCashBalanceFees = nonCashBalanceFees.add(fee.getAmount(investmentAccount).abs()); } // transfer cash from the account to the cash balance of the investment account to cover fees if (!account.equals(investmentAccount)) { byte scale = account.getCurrencyNode().getScale(); BigDecimal exchangedAmount = nonCashBalanceFees.abs().multiply(exchangeRate).setScale(scale, MathConstants.roundingMode); final TransactionEntry tran = new TransactionEntry(investmentAccount, account, nonCashBalanceFees, exchangedAmount.negate()); tran.setMemo(memo); tran.setTransactionTag(TransactionTag.INVESTMENT_CASH_TRANSFER); transaction.addTransactionEntry(tran); } } } private static void processGains(final Transaction transaction, final Account investmentAccount, final String memo, final Collection gains) { if (!gains.isEmpty()) { // capital gains entered BigDecimal totalGains = BigDecimal.ZERO; // loop through and add gains/loss entries for (final TransactionEntry gain : gains) { transaction.addTransactionEntry(gain); totalGains = totalGains.add(gain.getAmount(investmentAccount)); } // create a single entry transaction that offsets investment gains or loss final TransactionEntry gainsOffsetEntry = new TransactionEntry(investmentAccount, totalGains.negate()); gainsOffsetEntry.setMemo(memo); gainsOffsetEntry.setTransactionTag(TransactionTag.GAINS_OFFSET); assert gainsOffsetEntry.isSingleEntry(); // check transaction.addTransactionEntry(gainsOffsetEntry); } } public static TransactionEntry createTransactionEntry(final Account debitAccount, final Account creditAccount, final BigDecimal amount, final String memo, final TransactionTag transactionTag) { final TransactionEntry entry = new TransactionEntry(); entry.setMemo(memo); entry.setDebitAccount(debitAccount); entry.setCreditAccount(creditAccount); entry.setDebitAmount(amount.abs().negate()); entry.setCreditAmount(amount.abs()); entry.setTransactionTag(transactionTag); return entry; } private TransactionFactory() { } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/TransactionTag.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine; import jgnash.resource.util.ResourceUtils; /** * Tagging enumeration for special transaction types. * * @author Craig Cavanaugh */ @SuppressWarnings("UnusedDeclaration") public enum TransactionTag { BANK(ResourceUtils.getString("Tag.Bank")), DIVIDEND(ResourceUtils.getString("Tag.Dividend")), FEES_OFFSET(ResourceUtils.getString("Tag.FeesOffset")), GAIN_LOSS(ResourceUtils.getString("Tag.GainLoss")), GAINS_OFFSET(ResourceUtils.getString("Tag.GainsOffset")), INVESTMENT(ResourceUtils.getString("Tag.Investment")), INVESTMENT_FEE(ResourceUtils.getString("Tag.InvestmentFee")), INVESTMENT_CASH_TRANSFER(ResourceUtils.getString("Tag.InvestmentCashTransfer")), VAT(ResourceUtils.getString("Tag.Vat")); private final transient String description; TransactionTag(String description) { this.description = description; } @Override public String toString() { return description; } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/TransactionType.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine; import jgnash.resource.util.ResourceUtils; /** * Transaction type class. * * @author Craig Cavanaugh * */ public enum TransactionType { // bank transactions DOUBLEENTRY(ResourceUtils.getString("Transaction.DoubleEntry")), SPLITENTRY(ResourceUtils.getString("Transaction.SplitEntry")), SINGLENTRY(ResourceUtils.getString("Transaction.SingleEntry")), // investment transactions ADDSHARE(ResourceUtils.getString("Transaction.AddShare")), BUYSHARE(ResourceUtils.getString("Transaction.BuyShare")), DIVIDEND(ResourceUtils.getString("Transaction.Dividend")), REINVESTDIV(ResourceUtils.getString("Transaction.ReinvestDiv")), REMOVESHARE(ResourceUtils.getString("Transaction.RemoveShare")), RETURNOFCAPITAL(ResourceUtils.getString("Transaction.ReturnOfCapital")), SELLSHARE(ResourceUtils.getString("Transaction.SellShare")), SPLITSHARE(ResourceUtils.getString("Transaction.SplitShare")), MERGESHARE(ResourceUtils.getString("Transaction.MergeShare")), INVALID(ResourceUtils.getString("Word.Invalid")); private final transient String transactionTypeName; TransactionType(String name) { transactionTypeName = name; } @Override public String toString() { return transactionTypeName; } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/TrashObject.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine; import java.time.LocalDateTime; import javax.persistence.CascadeType; import javax.persistence.Entity; import javax.persistence.OneToOne; import jgnash.util.NotNull; /** * Wraps objects that have been removed from active use in the engine. * * @author Craig Cavanaugh */ @Entity public class TrashObject extends StoredObject implements Comparable { /** * Date object was added. */ private final LocalDateTime date = LocalDateTime.now(); /** * The stored object. */ @OneToOne(orphanRemoval = true, cascade = {CascadeType.PERSIST, CascadeType.REMOVE}) private StoredObject object; /** * Public no-argument constructor for reflection purposes. */ @SuppressWarnings("unused") public TrashObject() { } TrashObject(final StoredObject object) { this.object = object; object.setMarkedForRemoval(); setMarkedForRemoval(); } public StoredObject getObject() { return object; } public LocalDateTime getDate() { return date; } @Override public int compareTo(@NotNull final TrashObject o) { return date.compareTo(o.date); } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/attachment/AttachmentManager.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.attachment; import java.io.IOException; import java.nio.file.Path; import java.util.concurrent.Future; /** * Interface for handling attachments. * * @author Craig Cavanaugh */ public interface AttachmentManager { boolean addAttachment(Path path, boolean copy) throws IOException; boolean removeAttachment(String attachment); Future getAttachment(String attachment); } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/attachment/AttachmentTransferClient.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.attachment; import io.netty.bootstrap.Bootstrap; import io.netty.channel.Channel; import io.netty.channel.ChannelInitializer; import io.netty.channel.ChannelOption; import io.netty.channel.nio.NioEventLoopGroup; import io.netty.channel.socket.SocketChannel; import io.netty.channel.socket.nio.NioSocketChannel; import io.netty.handler.codec.DelimiterBasedFrameDecoder; import io.netty.handler.codec.Delimiters; import io.netty.handler.codec.base64.Base64Decoder; import io.netty.handler.codec.base64.Base64Encoder; import io.netty.handler.codec.string.StringDecoder; import io.netty.handler.codec.string.StringEncoder; import io.netty.util.CharsetUtil; import java.nio.file.Path; import java.nio.file.Paths; import java.util.concurrent.Future; import java.util.logging.Level; import java.util.logging.Logger; import jgnash.net.ConnectionFactory; import jgnash.util.EncryptionManager; import static jgnash.engine.attachment.NettyTransferHandler.*; /** * Client for sending and receiving files. * * @author Craig Cavanaugh */ class AttachmentTransferClient { private static final Logger logger = Logger.getLogger(AttachmentTransferClient.class.getName()); private final Path tempDirectory; private NioEventLoopGroup eventLoopGroup; private Channel channel; private NettyTransferHandler transferHandler; private EncryptionManager encryptionManager = null; AttachmentTransferClient(final Path tempPath) { tempDirectory = tempPath; } /** * Starts the connection with the lock server. * * @param host remote host * @param port connection port * @param password connection password * @return {@code true} if successful */ boolean connectToServer(final String host, final int port, final char[] password) { boolean result = false; // If a password has been specified, create an EncryptionManager if (password != null && password.length > 0) { encryptionManager = new EncryptionManager(password); } final Bootstrap bootstrap = new Bootstrap(); eventLoopGroup = new NioEventLoopGroup(); transferHandler = new NettyTransferHandler(tempDirectory, encryptionManager); bootstrap.group(eventLoopGroup) .channel(NioSocketChannel.class) .handler(new Initializer()) .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, ConnectionFactory.getConnectionTimeout() * 1000) .option(ChannelOption.SO_KEEPALIVE, true); try { // Start the connection attempt. channel = bootstrap.connect(host, port).sync().channel(); result = true; logger.info("Connection made with File Transfer Server"); } catch (final InterruptedException e) { logger.log(Level.SEVERE, "Failed to connect to the File Transfer Server", e); disconnectFromServer(); Thread.currentThread().interrupt(); } return result; } private String encrypt(final String message) { if (encryptionManager != null) { return encryptionManager.encrypt(message); } return message; } void requestFile(final Path file) { try { channel.writeAndFlush(encrypt(FILE_REQUEST + file) + EOL_DELIMITER).sync(); } catch (final InterruptedException e) { logger.log(Level.SEVERE, e.getLocalizedMessage(), e); Thread.currentThread().interrupt(); } } void deleteFile(final String attachment) { try { channel.writeAndFlush(encrypt(DELETE + Paths.get(attachment).getFileName()) + EOL_DELIMITER).sync(); } catch (final InterruptedException e) { logger.log(Level.SEVERE, e.getLocalizedMessage(), e); Thread.currentThread().interrupt(); } } Future sendFile(final Path file) { if (transferHandler != null) { return transferHandler.sendFile(channel, file.toString()); } return null; } /** * Disconnects from the lock server. */ void disconnectFromServer() { try { channel.close().sync(); } catch (InterruptedException e) { logger.log(Level.SEVERE, e.getLocalizedMessage(), e); Thread.currentThread().interrupt(); } eventLoopGroup.shutdownGracefully(); eventLoopGroup = null; channel = null; logger.info("Disconnected from the File Transfer Server"); } private class Initializer extends ChannelInitializer { @Override public void initChannel(final SocketChannel ch) { ch.pipeline().addLast( new DelimiterBasedFrameDecoder(((TRANSFER_BUFFER_SIZE + 2) / 3) * 4 + PATH_MAX, true, Delimiters.lineDelimiter()), new StringEncoder(CharsetUtil.UTF_8), new StringDecoder(CharsetUtil.UTF_8), new Base64Encoder(), new Base64Decoder(), transferHandler); } } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/attachment/AttachmentTransferServer.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.attachment; import io.netty.bootstrap.ServerBootstrap; import io.netty.channel.ChannelFuture; import io.netty.channel.ChannelHandlerContext; import io.netty.channel.ChannelInitializer; import io.netty.channel.ChannelOption; import io.netty.channel.EventLoopGroup; import io.netty.channel.group.ChannelGroup; import io.netty.channel.group.DefaultChannelGroup; import io.netty.channel.nio.NioEventLoopGroup; import io.netty.channel.socket.SocketChannel; import io.netty.channel.socket.nio.NioServerSocketChannel; import io.netty.handler.codec.DelimiterBasedFrameDecoder; import io.netty.handler.codec.Delimiters; import io.netty.handler.codec.base64.Base64Decoder; import io.netty.handler.codec.base64.Base64Encoder; import io.netty.handler.codec.string.StringDecoder; import io.netty.handler.codec.string.StringEncoder; import io.netty.util.CharsetUtil; import io.netty.util.concurrent.GlobalEventExecutor; import java.nio.file.Path; import java.util.logging.Level; import java.util.logging.Logger; import jgnash.util.EncryptionManager; import static jgnash.engine.attachment.NettyTransferHandler.PATH_MAX; import static jgnash.engine.attachment.NettyTransferHandler.TRANSFER_BUFFER_SIZE; /** * File server for attachments. * * @author Craig Cavanaugh */ public class AttachmentTransferServer { private static final Logger logger = Logger.getLogger(AttachmentTransferServer.class.getName()); private final int port; private final EventLoopGroup eventLoopGroup = new NioEventLoopGroup(); private final ChannelGroup channelGroup = new DefaultChannelGroup("file-server", GlobalEventExecutor.INSTANCE); private final Path attachmentPath; private EncryptionManager encryptionManager = null; public AttachmentTransferServer(final int port, final Path attachmentPath) { this.port = port; this.attachmentPath = attachmentPath; } public boolean startServer(final char[] password) { boolean result = false; // If a password has been specified, create an EncryptionManager if (password != null && password.length > 0) { encryptionManager = new EncryptionManager(password); } try { ServerBootstrap b = new ServerBootstrap(); b.group(eventLoopGroup) .channel(NioServerSocketChannel.class) .option(ChannelOption.SO_KEEPALIVE, true) //.handler(new LoggingHandler(LogLevel.INFO)) // for debugging purposes .childHandler(new ChannelInitializer() { @Override public void initChannel(final SocketChannel ch) { ch.pipeline().addLast( new DelimiterBasedFrameDecoder(((TRANSFER_BUFFER_SIZE + 2) / 3) * 4 + PATH_MAX, true, Delimiters.lineDelimiter()), new StringEncoder(CharsetUtil.UTF_8), new StringDecoder(CharsetUtil.UTF_8), new Base64Encoder(), new Base64Decoder(), new ServerTransferHandler()); } }); // Start the server. final ChannelFuture future = b.bind(port).sync(); if (future.isDone() && future.isSuccess()) { result = true; logger.info("File Transfer Server started successfully"); } else { logger.info("Failed to start the File Transfer Server"); } } catch (final InterruptedException e) { logger.log(Level.SEVERE, e.getLocalizedMessage(), e); stopServer(); } return result; } public void stopServer() { try { channelGroup.close().sync(); eventLoopGroup.shutdownGracefully(); logger.info("File Transfer Server stopped"); } catch (final InterruptedException e) { logger.log(Level.SEVERE, e.getLocalizedMessage(), e); } } private final class ServerTransferHandler extends NettyTransferHandler { ServerTransferHandler() { super(attachmentPath, encryptionManager); } @Override public void channelActive(final ChannelHandlerContext ctx) { channelGroup.add(ctx.channel()); // maintain channels logger.log(Level.INFO, "Remote connection from: {0}", ctx.channel().remoteAddress()); } } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/attachment/DistributedAttachmentManager.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.attachment; import java.io.IOException; import java.nio.file.DirectoryStream; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.StandardCopyOption; import java.nio.file.attribute.FileAttribute; import java.nio.file.attribute.PosixFilePermission; import java.nio.file.attribute.PosixFilePermissions; import java.time.Duration; import java.time.LocalDateTime; import java.util.EnumSet; import java.util.Set; import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.logging.Level; import java.util.logging.Logger; import jgnash.util.FileUtils; import jgnash.resource.util.OS; /** * Attachment handler for a remote database. * * @author Craig Cavanaugh */ public class DistributedAttachmentManager implements AttachmentManager { private static final String TEMP_ATTACHMENT_PATH = "jGnashTemp-"; private static final int TRANSFER_TIMEOUT = 5000; private final ExecutorService executorService = Executors.newCachedThreadPool(); private final String host; private final int port; /** * Path to temporary attachment cache location. */ private Path tempAttachmentPath; private AttachmentTransferClient fileClient; public DistributedAttachmentManager(final String host, final int port) { this.host = host; this.port = port; try { if (!OS.isSystemWindows()) { final EnumSet permissions = EnumSet.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE, PosixFilePermission.OWNER_EXECUTE, PosixFilePermission.GROUP_READ, PosixFilePermission.GROUP_WRITE); final FileAttribute> attr = PosixFilePermissions.asFileAttribute(permissions); tempAttachmentPath = Files.createTempDirectory(TEMP_ATTACHMENT_PATH, attr); } else { // windows cannot handle posix permissions tempAttachmentPath = Files.createTempDirectory(TEMP_ATTACHMENT_PATH); } fileClient = new AttachmentTransferClient(tempAttachmentPath); } catch (final IOException e) { Logger.getLogger(DistributedAttachmentManager.class.getName()).log(Level.SEVERE, e.getLocalizedMessage(), e); } } /** * Add a file attachment. * When moving a file, it must be copied and then deleted. Moves can not be done atomically across file systems * which is a high probability. * * @param path Path to the attachment to add * @param copy true if only copying the file * @return true if successful * @throws IOException thrown if a network error occurs */ @Override public boolean addAttachment(final Path path, final boolean copy) throws IOException { boolean result = false; // Transfer the file to the remote location final Future future = fileClient.sendFile(path); // Determine the cache location the file needs to go to so it does not have to be requested final Path newPath = Paths.get(tempAttachmentPath + FileUtils.SEPARATOR + path.getFileName()); if (future != null) { // if null, path was not valid try { future.get(); // wait for the transfer to complete } catch (final InterruptedException | ExecutionException e) { Logger.getLogger(DistributedAttachmentManager.class.getName()).log(Level.SEVERE, e.getLocalizedMessage(), e); } // Copy or move the file if (copy) { Files.copy(path, newPath, StandardCopyOption.REPLACE_EXISTING); } else { Files.copy(path, newPath, StandardCopyOption.REPLACE_EXISTING); Files.delete(path); } result = true; } return result; } @Override public boolean removeAttachment(final String attachment) { fileClient.deleteFile(attachment); return true; } @Override public Future getAttachment(final String attachment) { return executorService.submit(() -> { Path path = Paths.get(tempAttachmentPath + FileUtils.SEPARATOR + Paths.get(attachment).getFileName()); if (Files.notExists(path)) { fileClient.requestFile(Paths.get(attachment)); // Request the file and place in a a temp location final LocalDateTime start = LocalDateTime.now(); while (Duration.between(start, LocalDateTime.now()).toMillis() < TRANSFER_TIMEOUT) { if (Files.exists(path)) { break; } } } if (Files.notExists(path)) { path = null; } return path; }); } public boolean connectToServer(final char[] password) { return fileClient.connectToServer(host, port, password); } public void disconnectFromServer() { fileClient.disconnectFromServer(); // Cleanup before exit try (final DirectoryStream ds = Files.newDirectoryStream(tempAttachmentPath)) { for (final Path p : ds) { Files.delete(p); } Files.delete(tempAttachmentPath); } catch (final IOException e) { Logger.getLogger(DistributedAttachmentManager.class.getName()).log(Level.SEVERE, e.getLocalizedMessage(), e); } } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/attachment/LocalAttachmentManager.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.attachment; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; import jgnash.engine.AttachmentUtils; import jgnash.engine.EngineFactory; import jgnash.util.FileUtils; import static jgnash.util.LogUtil.logSevere; /** * Attachment handler for a local database. * * @author Craig Cavanaugh */ public class LocalAttachmentManager implements AttachmentManager { private final ExecutorService executorService = Executors.newCachedThreadPool(); /** * Add a file attachment. * When moving a file, it must be copied and then deleted. Moves can not be done atomically across file systems * which is a high probability. * * @param path Path to the attachment to add * @param copy true if only copying the file * @return true if successful * @throws IOException thrown if a filesystem error occurs */ @Override public boolean addAttachment(final Path path, final boolean copy) throws IOException { boolean result = false; Path baseFile = Paths.get(EngineFactory.getActiveDatabase()); if (AttachmentUtils.createAttachmentDirectory(baseFile)) { // create if needed Path newPath = Paths.get(AttachmentUtils.getAttachmentPath() + FileUtils.SEPARATOR + path.getFileName()); try { if (copy) { Files.copy(path, newPath); } else { Files.copy(path, newPath); Files.delete(path); } result = true; } catch (final IOException e) { logSevere(LocalAttachmentManager.class, e); throw new IOException(e); } } return result; } @Override public boolean removeAttachment(final String attachment) { boolean result = false; Path path = Paths.get(AttachmentUtils.getAttachmentPath() + FileUtils.SEPARATOR + attachment); try { Files.delete(path); result = true; } catch (final IOException e) { logSevere(LocalAttachmentManager.class, e); } return result; } @Override public Future getAttachment(final String attachment) { return executorService.submit(() -> Paths.get(AttachmentUtils.getAttachmentPath() + FileUtils.SEPARATOR + attachment)); } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/attachment/NettyTransferHandler.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.attachment; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.Arrays; import java.util.Base64; import java.util.Map; import java.util.Objects; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Future; import java.util.logging.Level; import java.util.logging.Logger; import jgnash.engine.AttachmentUtils; import jgnash.util.EncryptionManager; import jgnash.util.FileUtils; import jgnash.util.Nullable; import io.netty.channel.Channel; import io.netty.channel.ChannelHandler; import io.netty.channel.ChannelHandlerContext; import io.netty.channel.SimpleChannelInboundHandler; /** * Handles the details of bi-directional transfer of files between a client and server. * * @author Craig Cavanaugh */ @ChannelHandler.Sharable class NettyTransferHandler extends SimpleChannelInboundHandler { static final String FILE_REQUEST = ""; static final String DELETE = ""; static final String EOL_DELIMITER = "\r\n"; private static final String FILE_STARTS = ""; private static final String FILE_ENDS = ""; private static final String FILE_CHUNK = ""; private static final String ERROR = ""; private static final Logger logger = Logger.getLogger(NettyTransferHandler.class.getName()); static final int TRANSFER_BUFFER_SIZE = 1024; // too large can break netty... bug? static final int PATH_MAX = 4096; private final Map fileMap = new ConcurrentHashMap<>(); private final Path attachmentPath; private final EncryptionManager encryptionManager; /** * Netty Handler. The specified path may be a temporary location for clients or a persistent location for servers. * * @param attachmentPath Path for attachments. * @param encryptionManager encryption manager instance */ NettyTransferHandler(final Path attachmentPath, @Nullable final EncryptionManager encryptionManager) { Objects.requireNonNull(attachmentPath); this.attachmentPath = attachmentPath; this.encryptionManager = encryptionManager; } @Override public void channelRead0(final ChannelHandlerContext ctx, final String msg) { final String plainMessage; if (encryptionManager != null) { plainMessage = encryptionManager.decrypt(msg); } else { plainMessage = msg; } if (plainMessage.startsWith(FILE_REQUEST)) { sendFile(ctx, attachmentPath + FileUtils.SEPARATOR + plainMessage.substring(FILE_REQUEST.length())); } else if (plainMessage.startsWith(FILE_STARTS)) { openOutputStream(plainMessage.substring(FILE_STARTS.length())); } else if (plainMessage.startsWith(FILE_CHUNK)) { writeOutputStream(plainMessage.substring(FILE_CHUNK.length())); } else if (plainMessage.startsWith(FILE_ENDS)) { closeOutputStream(plainMessage.substring(FILE_ENDS.length())); } else if (plainMessage.startsWith(DELETE)) { deleteFile(plainMessage.substring(FILE_ENDS.length())); } } private void deleteFile(final String fileName) { Path path = Paths.get(attachmentPath + FileUtils.SEPARATOR + fileName); if (Files.exists(path)) { try { Files.delete(path); } catch (final IOException e) { logger.log(Level.SEVERE, e.getLocalizedMessage(), e); } } } @Override public void channelInactive(final ChannelHandlerContext ctx) { for (Attachment object : fileMap.values()) { try { object.outputStream.close(); } catch (IOException e) { logger.log(Level.SEVERE, e.getLocalizedMessage(), e); } } ctx.fireChannelInactive(); // forward to the next handler in the pipeline } @Override public void exceptionCaught(final ChannelHandlerContext ctx, final Throwable cause) throws Exception { super.exceptionCaught(ctx, cause); logger.log(Level.WARNING, "Unexpected exception from downstream.", cause); ctx.close(); } private String encrypt(final String message) { if (encryptionManager != null) { return encryptionManager.encrypt(message); } return message; } /** * Sends a file across the channel. The Fu * @param channel Channel to send file through * @param fileName the file name * @return the future of the potentially asynchronous send is returned. A null value is returned if fileName is a path. */ Future sendFile(final Channel channel, final String fileName) { Future future = null; Path path = Paths.get(fileName); if (Files.exists(path)) { if (Files.isDirectory(path)) { channel.writeAndFlush(encrypt(ERROR + "Not a file: " + path) + EOL_DELIMITER); return null; } try (final InputStream inputStream = Files.newInputStream(path)) { channel.writeAndFlush(encrypt(FILE_STARTS + path.getFileName() + ":" + Files.size(path)) + EOL_DELIMITER); byte[] bytes = new byte[TRANSFER_BUFFER_SIZE]; // leave room for base 64 expansion int bytesRead; while ((bytesRead = inputStream.read(bytes)) != -1) { if (bytesRead > 0) { channel.write(encrypt(FILE_CHUNK + path.getFileName() + ':' + Base64.getEncoder().encodeToString(Arrays.copyOfRange(bytes, 0, bytesRead))) + EOL_DELIMITER); } } future = channel.writeAndFlush(encrypt(FILE_ENDS + path.getFileName()) + EOL_DELIMITER).sync(); } catch (IOException | InterruptedException e) { logger.log(Level.SEVERE, e.getLocalizedMessage(), e); Thread.currentThread().interrupt(); } } else { try { future = channel.writeAndFlush(encrypt(ERROR + "File not found: " + path) + EOL_DELIMITER).sync(); } catch (final InterruptedException e) { logger.log(Level.SEVERE, e.getLocalizedMessage(), e); Thread.currentThread().interrupt(); } logger.log(Level.WARNING, "File not found: {0}", path); } return future; } private void sendFile(final ChannelHandlerContext ctx, final String msg) { sendFile(ctx.channel(), msg); } private void closeOutputStream(final String msg) { Attachment attachment = fileMap.get(msg); try { attachment.outputStream.close(); fileMap.remove(msg); } catch (final IOException e) { logger.log(Level.SEVERE, e.getLocalizedMessage(), e); } if (attachment.path.toFile().length() != attachment.fileSize) { logger.severe("Invalid file length"); } } private void writeOutputStream(final String msg) { String[] msgParts = msg.split(":"); Attachment attachment = fileMap.get(msgParts[0]); if (attachment != null) { try { attachment.outputStream.write(Base64.getDecoder().decode(msgParts[1])); } catch (final IOException e) { logger.log(Level.SEVERE, e.getLocalizedMessage(), e); } } } private void openOutputStream(final String msg) { String[] msgParts = msg.split(":"); final String fileName = msgParts[0]; final long fileLength = Long.parseLong(msgParts[1]); final Path filePath = Paths.get(attachmentPath + FileUtils.SEPARATOR + fileName); // Lazy creation of the attachment path if needed if (!AttachmentUtils.createAttachmentDirectory(attachmentPath)) { logger.severe("Unable to find or create the attachment directory"); return; } try { fileMap.put(fileName, new Attachment(filePath, fileLength)); } catch (IOException e) { logger.log(Level.SEVERE, e.getLocalizedMessage(), e); } } private static class Attachment { final Path path; final OutputStream outputStream; final long fileSize; private Attachment(final Path path, long fileSize) throws IOException { this.path = path; outputStream = Files.newOutputStream(path); this.fileSize = fileSize; } } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/budget/Budget.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received account copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.budget; import java.math.RoundingMode; import java.time.Month; import java.util.HashMap; import java.util.Map; import java.util.Objects; import javax.persistence.CascadeType; import javax.persistence.Column; import javax.persistence.Entity; import javax.persistence.EnumType; import javax.persistence.Enumerated; import javax.persistence.FetchType; import javax.persistence.JoinTable; import javax.persistence.OneToMany; import jgnash.engine.Account; import jgnash.engine.StoredObject; import jgnash.resource.util.ResourceUtils; import jgnash.time.Period; import jgnash.util.NotNull; /** * Budget Object. * * @author Craig Cavanaugh */ @SuppressWarnings("JpaDataSourceORMInspection") @Entity public class Budget extends StoredObject implements Comparable, Cloneable { /** * Budget name. */ private String name = "Default"; /** * Budget description. */ private String description = ""; /** * Period to report the budget in. */ @Enumerated(EnumType.STRING) @Column(name = "BUDGETPERIOD") private Period budgetPeriod = Period.MONTHLY; /** * Rounding mode for display of budget values. * @since 3.1 */ @Enumerated(EnumType.STRING) @Column(name = "ROUNDINGMODE", length = 12, nullable = false, columnDefinition = "varchar(12) default 'FLOOR'") private RoundingMode roundingMode = RoundingMode.FLOOR; /** * Controls the number of decimals displayed for budgeting * @since 3.1 */ @Column(name = "ROUNDINGSCALE", nullable = false, columnDefinition = "tinyint default 2") private byte roundingScale = 2; @Enumerated(EnumType.STRING) @Column(name = "STARTMONTH", length = 10, nullable = false, columnDefinition = "varchar(10) default 'JANUARY'") private Month startMonth = Month.JANUARY; /** * Account goals are stored internally by the account UUID. */ @JoinTable @OneToMany(cascade = {CascadeType.ALL}, fetch = FetchType.EAGER) private Map accountGoals = new HashMap<>(); private boolean assetAccountsIncluded = false; private boolean incomeAccountsIncluded = true; private boolean expenseAccountsIncluded = true; private boolean liabilityAccountsIncluded = false; public String getName() { return name; } public void setName(final String name) { Objects.requireNonNull(name); if (name.isEmpty()) { throw new IllegalArgumentException("name may not be zero length"); } this.name = name; } public String getDescription() { return description; } public void setDescription(final String description) { this.description = Objects.requireNonNull(description); } /** * Sets the goals for an {@code Account}. * * @param account Account * @param budgetGoal budget goals */ public void setBudgetGoal(final Account account, final BudgetGoal budgetGoal) { Objects.requireNonNull(account); Objects.requireNonNull(budgetGoal); accountGoals.put(account.getUuid().toString(), budgetGoal); } public void removeBudgetGoal(final Account account) { Objects.requireNonNull(account); accountGoals.remove(account.getUuid().toString()); } /** * Returns an accounts goals. If goals have not yet been specified, then an empty set is automatically assigned and * returned * * @param account {@code Account} to retrieve the goals for * @return the goals */ public BudgetGoal getBudgetGoal(final Account account) { Objects.requireNonNull(account); BudgetGoal goal = accountGoals.get(account.getUuid().toString()); if (goal == null) { goal = new BudgetGoal(); goal.setBudgetPeriod(getBudgetPeriod()); accountGoals.put(account.getUuid().toString(), goal); } return goal; } @Override public int compareTo(@NotNull final Budget budget) { int result = getName().compareTo(budget.getName()); if (result == 0) { result = getDescription().compareTo(budget.getDescription()); } if (result == 0) { return getUuid().compareTo(budget.getUuid()); } return result; } @Override public boolean equals(final Object other) { return this == other || other instanceof Budget && getUuid().equals(((Budget) other).getUuid()); } /** * Returns the global display period for this budget. * * @return The period for this budget */ public Period getBudgetPeriod() { return budgetPeriod; } /** * Sets the global display period for this budget. * * @param budgetPeriod The budget period */ public void setBudgetPeriod(final Period budgetPeriod) { this.budgetPeriod = Objects.requireNonNull(budgetPeriod); } /** * Returns a clone of this {@code Budget}. * * @return clone * @throws java.lang.CloneNotSupportedException thrown if cloning error occurs */ @Override public Object clone() throws CloneNotSupportedException { Budget budget = (Budget) super.clone(); budget.setDescription(getDescription()); budget.setName(getName() + "(" + ResourceUtils.getString("Word.Copy") + ")"); budget.accountGoals = new HashMap<>(); for (final Map.Entry entry : accountGoals.entrySet()) { budget.accountGoals.put(entry.getKey(), (BudgetGoal) entry.getValue().clone()); } budget.setBudgetPeriod(getBudgetPeriod()); return budget; } /** * Returns true if asset accounts are included in the budget. * * @return the assetAccountsIncluded */ public boolean areAssetAccountsIncluded() { return assetAccountsIncluded; } /** * Controls inclusion of asset accounts in the budget. * * @param assetAccountsIncluded the assetAccountsIncluded to set */ public void setAssetAccountsIncluded(final boolean assetAccountsIncluded) { this.assetAccountsIncluded = assetAccountsIncluded; } /** * Returns true if income accounts are included in the budget. * * @return the incomeAccountsIncluded */ public boolean areIncomeAccountsIncluded() { return incomeAccountsIncluded; } /** * Controls inclusion of income accounts in the budget. * * @param incomeAccountsIncluded the incomeAccountsIncluded to set */ public void setIncomeAccountsIncluded(final boolean incomeAccountsIncluded) { this.incomeAccountsIncluded = incomeAccountsIncluded; } /** * Returns true if expense accounts are included in the budget. * * @return the expenseAccountsIncluded */ public boolean areExpenseAccountsIncluded() { return expenseAccountsIncluded; } /** * Controls inclusion of expense accounts in the budget. * * @param expenseAccountsIncluded the expenseAccountsIncluded to set */ public void setExpenseAccountsIncluded(final boolean expenseAccountsIncluded) { this.expenseAccountsIncluded = expenseAccountsIncluded; } /** * Returns true if liability accounts are included in the budget. * * @return the liabilityAccountsIncluded */ public boolean areLiabilityAccountsIncluded() { return liabilityAccountsIncluded; } /** * Controls inclusion of liability accounts in the budget. * * @param liabilityAccountsIncluded the liabilityAccountsIncluded to set */ public void setLiabilityAccountsIncluded(final boolean liabilityAccountsIncluded) { this.liabilityAccountsIncluded = liabilityAccountsIncluded; } /** * Overridden to return the name of the budget for convenience. * * @return name of the budget */ @Override public String toString() { return getName(); } /** * Scale at which reported values are rounded to * * @return reporting scale for budget values */ public byte getRoundingScale() { return roundingScale; } /** * Sets the reporting scale of budget values * * @param scale new scale value */ public void setRoundingScale(final byte scale) { this.roundingScale = scale; } /** * Configured rounding mode for the budget * * @return returns the rounding mode */ @NotNull public RoundingMode getRoundingMode() { return roundingMode; } /** * Sets the rounding mode * * @param roundingMethod new rounding mode */ public void setRoundingMode(@NotNull final RoundingMode roundingMethod) { Objects.requireNonNull(roundingMethod); this.roundingMode = roundingMethod; } /** * Returns the starting month of the budget * @since 3.1 */ public Month getStartMonth() { return startMonth; } /** * Sets the starting month of the budget * @since 3.1 */ public void setStartMonth(Month startMonth) { this.startMonth = startMonth; } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/budget/BudgetFactory.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received account copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.budget; import java.math.BigDecimal; import java.math.RoundingMode; import java.time.LocalDate; import java.util.ArrayList; import java.util.List; import java.util.Objects; import java.util.logging.Level; import java.util.logging.Logger; import jgnash.engine.Account; import jgnash.engine.AccountType; import jgnash.engine.Engine; import jgnash.engine.EngineFactory; import jgnash.time.Period; /** * Budget Factory for automatic generation of Budgets. * * @author Craig Cavanaugh */ public class BudgetFactory { /** * Utility class, no public constructor. */ private BudgetFactory() { } public static Budget buildAverageBudget(final Period budgetPeriod, final String name, final boolean round) { final Engine engine = EngineFactory.getEngine(EngineFactory.DEFAULT); Objects.requireNonNull(engine); final Budget budget = new Budget(); budget.setName(name); budget.setBudgetPeriod(budgetPeriod); int year = LocalDate.now().getYear() - 1; final List descriptors = BudgetPeriodDescriptorFactory.getDescriptors(year, budget.getStartMonth(), budgetPeriod); final List accounts = new ArrayList<>(); accounts.addAll(engine.getIncomeAccountList()); accounts.addAll(engine.getExpenseAccountList()); for (final Account account : accounts) { budget.setBudgetGoal(account, buildAverageBudgetGoal(account, descriptors, round)); } return budget; } public static BudgetGoal buildAverageBudgetGoal(final Account account, final List descriptors, final boolean round) { BudgetGoal goal = new BudgetGoal(); goal.setBudgetPeriod(descriptors.get(0).getBudgetPeriod()); for (final BudgetPeriodDescriptor descriptor : descriptors) { BigDecimal amount = account.getBalance(descriptor.getStartDate(), descriptor.getEndDate()); if (account.getAccountType() == AccountType.INCOME) { amount = amount.negate(); } if (round) { if (account.getAccountType() == AccountType.INCOME) { amount = amount.setScale(0, RoundingMode.DOWN); } else { amount = amount.setScale(0, RoundingMode.UP); } } goal.setGoal(descriptor.getStartPeriod(), descriptor.getEndPeriod(), amount, descriptor.getStartDate().isLeapYear()); } return goal; } /** * Creates a {@code BudgetGoal} with an alternating pattern. * * @param baseBudgetGoal {@code BudgetGoal} to clone * @param descriptors descriptors to use * @param pattern Pattern to use * @param startRow starting row, 0 based index is assumed * @param endRow ending row, 0 based index is assumed * @param amount amount to use * @return new {@code BudgetGoal} */ public static BudgetGoal buildBudgetGoal(final BudgetGoal baseBudgetGoal, final List descriptors, final Pattern pattern, final int startRow, final int endRow, final BigDecimal amount) { BudgetGoal goal; try { goal = (BudgetGoal)baseBudgetGoal.clone(); } catch (CloneNotSupportedException e) { Logger.getLogger(BudgetFactory.class.getName()).log(Level.SEVERE, e.getLocalizedMessage(), e); goal = new BudgetGoal(); } goal.setBudgetPeriod(descriptors.get(0).getBudgetPeriod()); //System.out.println("Pattern: " + pattern.getIncrement()); for (int i = startRow; i <= endRow; i += pattern.getIncrement()) { //System.out.println(i); final BudgetPeriodDescriptor descriptor = descriptors.get(i); goal.setBudgetPeriod(descriptor.getBudgetPeriod()); goal.setGoal(descriptor.getStartPeriod(), descriptor.getEndPeriod(), amount, descriptor.getStartDate().isLeapYear()); } return goal; } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/budget/BudgetGoal.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received account copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.budget; import jgnash.engine.MathConstants; import jgnash.time.Period; import javax.persistence.Column; import javax.persistence.ElementCollection; import javax.persistence.Entity; import javax.persistence.EnumType; import javax.persistence.Enumerated; import javax.persistence.FetchType; import javax.persistence.GeneratedValue; import javax.persistence.GenerationType; import javax.persistence.Id; import javax.persistence.OrderColumn; import javax.persistence.SequenceGenerator; import java.io.Serializable; import java.math.BigDecimal; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Objects; /** * Budget Goal Object *

* 366 days per year are assumed and static for goals. The 366th day will not be used if not a leap year * * @author Craig Cavanaugh */ @Entity @SequenceGenerator(name = "sequence", allocationSize = 10) public class BudgetGoal implements Cloneable, Serializable { @SuppressWarnings("unused") @Id @GeneratedValue(generator = "sequence", strategy = GenerationType.SEQUENCE) public long id; /** * 366 days per year. */ public static final int PERIODS = 366; // cache the hash code private transient int hash; @SuppressWarnings("UnusedAssignment") @ElementCollection(fetch = FetchType.EAGER) @OrderColumn(name = "INDEX") private List budgetGoals = new ArrayList<>(); @Enumerated(EnumType.STRING) @Column(name = "BUDGETPERIOD") private Period budgetPeriod = Period.MONTHLY; public BudgetGoal() { budgetGoals = new ArrayList<>(Collections.nCopies(PERIODS, BigDecimal.ZERO)); } public final BigDecimal[] getGoals() { return budgetGoals.toArray(BigDecimal[]::new); } public final void setGoals(final BigDecimal[] goals) { Objects.requireNonNull(goals); if (goals.length != PERIODS) { throw new IllegalArgumentException("goals must be " + PERIODS + " in length"); } for (int i = 0; i < goals.length; i++) { if (goals[i] == null) { throw new IllegalArgumentException("goals [" + i + "] may not be null"); } } for (int i = 0; i < goals.length; i++) { budgetGoals.set(i, goals[i]); } } /** * Returns the entry / display period for this budget goal. * * @return the BudgetPeriod */ public Period getBudgetPeriod() { return budgetPeriod; } /** * Sets the global entry / display period for this budget goal. * * @param budgetPeriod The budget period */ public void setBudgetPeriod(final Period budgetPeriod) { this.budgetPeriod = Objects.requireNonNull(budgetPeriod); } public void setGoal(final int startPeriod, final int endPeriod, final BigDecimal amount, final boolean leapYear) { if (startPeriod <= endPeriod) { final BigDecimal divisor = new BigDecimal(endPeriod - startPeriod + 1); final BigDecimal portion = amount.divide(divisor, MathConstants.budgetMathContext); for (int i = startPeriod; i <= endPeriod && i < BudgetGoal.PERIODS; i++) { budgetGoals.set(i, portion); } } else { // wrap around the array, need to handle a leap year final BigDecimal divisor = new BigDecimal(BudgetGoal.PERIODS - startPeriod + endPeriod - (leapYear ? 1 : 0)); final BigDecimal portion = amount.divide(divisor, MathConstants.budgetMathContext); //System.out.println("divisor: " + divisor+ ", start: " + startPeriod + ", end: " + endPeriod + ", leapYear: " + leapYear); for (int i = startPeriod; i < BudgetGoal.PERIODS - (leapYear ? 0 : 1); i++) { budgetGoals.set(i, portion); } for (int i = 0; i <= endPeriod && i < BudgetGoal.PERIODS; i++) { budgetGoals.set(i, portion); } } } public BigDecimal getGoal(final int startPeriod, final int endPeriod, final boolean leapYear) { BigDecimal amount = BigDecimal.ZERO; if (startPeriod <= endPeriod) { // clip to the max number of periods... some locale calendars behave differently for (int i = startPeriod; i <= endPeriod && i < BudgetGoal.PERIODS; i++) { amount = amount.add(budgetGoals.get(i)); } } else { // wrap around the array, need to handle a leap year //int days = 0; for (int i = startPeriod; i < BudgetGoal.PERIODS - (leapYear ? 0 : 1); i++) { //days++; amount = amount.add(budgetGoals.get(i)); } for (int i = 0; i <= endPeriod && i < BudgetGoal.PERIODS; i++) { //days++; amount = amount.add(budgetGoals.get(i)); } //System.out.println("days: " + days+ ", start: " + startPeriod + ", end: " + endPeriod + ", leapYear: " + leapYear); } return amount; } /** * {@inheritDoc} *

* A deep copy of the goals is performed */ @Override public Object clone() throws CloneNotSupportedException { final BudgetGoal goal = (BudgetGoal) super.clone(); goal.id = 0; // clones id must be reset for JPA // deep copy goal.budgetGoals = new ArrayList<>(Collections.nCopies(PERIODS, BigDecimal.ZERO)); for (int i = 0; i < PERIODS; i++) { goal.budgetGoals.set(i, budgetGoals.get(i)); } return goal; } @Override public int hashCode() { int h = hash; if (h == 0) { final int prime = 31; h = 1; h = prime * h + budgetPeriod.hashCode(); h = prime * h + budgetGoals.hashCode(); hash = h; } return h; } @Override public boolean equals(final Object obj) { if (this == obj) { return true; } if (obj == null) { return false; } if (!(obj instanceof BudgetGoal)) { return false; } final BudgetGoal other = (BudgetGoal) obj; return budgetPeriod == other.budgetPeriod && budgetGoals.equals(other.budgetGoals); } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/budget/BudgetPeriodDescriptor.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2021 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.budget; import java.time.LocalDate; import java.util.Objects; import jgnash.resource.util.ResourceUtils; import jgnash.time.DateUtils; import jgnash.time.Period; import jgnash.util.NotNull; /** * Immutable descriptor for a budget period. * * @author Craig Cavanaugh */ public class BudgetPeriodDescriptor implements Comparable { private int hash = 0; /** * The starting period (Day of the year). */ private int startPeriod; private final int endPeriod; private final LocalDate startDate; private final LocalDate endDate; private final String periodDescription; private final Period budgetPeriod; /** * * @param periodStartDate the user specified start of the budget (fiscal year) * @param periodEndDate the starting date for the period * @param budgetPeriod the period for the descriptor */ BudgetPeriodDescriptor(final LocalDate periodStartDate, final LocalDate periodEndDate, final Period budgetPeriod) { Objects.requireNonNull(budgetPeriod); Objects.requireNonNull(periodStartDate); Objects.requireNonNull(periodEndDate); this.budgetPeriod = budgetPeriod; this.startDate = periodStartDate; this.endDate = periodEndDate; startPeriod = this.startDate.getDayOfYear() - 1; endPeriod = this.endDate.getDayOfYear() - 1; // correctly handle the roll over of the index if (periodEndDate.getYear() > periodStartDate.getYear()) { int startYearDays = periodStartDate.lengthOfYear(); int endYearDays = periodEndDate.lengthOfYear(); startPeriod += startYearDays - endYearDays + 1; // shift for a leap year/day } switch (budgetPeriod) { case DAILY: periodDescription = ResourceUtils.getString("Pattern.NumericDate", DateUtils.asDate(startDate)); break; case WEEKLY: periodDescription = ResourceUtils.getString("Pattern.WeekOfYear", DateUtils.getWeekOfTheYear(startDate), startDate.getYear()); break; case BI_WEEKLY: periodDescription = ResourceUtils.getString("Pattern.DateRangeShort", DateUtils.asDate(startDate), DateUtils.asDate(endDate)); break; case MONTHLY: periodDescription = ResourceUtils.getString("Pattern.MonthOfYear", DateUtils.asDate(startDate)); break; case QUARTERLY: periodDescription = ResourceUtils.getString("Pattern.QuarterOfYear", DateUtils.getQuarterNumber(startDate), startDate.getYear()); break; case YEARLY: if (periodEndDate.getYear() == periodStartDate.getYear()) { periodDescription = Integer.toString(periodStartDate.getYear()); } else { periodDescription = ResourceUtils.getString("Pattern.DateRangeShort", DateUtils.asDate(startDate), DateUtils.asDate(endDate)); } break; default: periodDescription = ""; } } public int getStartPeriod() { return startPeriod; } public int getEndPeriod() { return endPeriod; } public LocalDate getStartDate() { return startDate; } public LocalDate getEndDate() { return endDate; } public String getPeriodDescription() { return periodDescription; } Period getBudgetPeriod() { return budgetPeriod; } /** * Determines if the specified date lies within or inclusive of this descriptor period. * * @param date date to check * @return true if between or inclusive */ public boolean isBetween(final LocalDate date) { return DateUtils.after(date, startDate) && DateUtils.before(date, endDate); } @Override public String toString() { return String.format("BudgetPeriodDescriptor [startDate=%tD, endDate=%tD, periodDescription=%s, budgetPeriod=%s, startPeriod=%3d, endPeriod=%3d]", DateUtils.asDate(startDate), DateUtils.asDate(endDate), periodDescription, budgetPeriod, startPeriod, endPeriod); } @Override public int hashCode() { int h = hash; if (h == 0) { final int prime = 31; h = 1; h = prime * h + budgetPeriod.hashCode(); h = prime * h + startPeriod; h = prime * h + endPeriod; hash = h; } return h; } @Override public boolean equals(final Object obj) { if (this == obj) { return true; } if (obj == null) { return false; } if (!(obj instanceof BudgetPeriodDescriptor)) { return false; } BudgetPeriodDescriptor other = (BudgetPeriodDescriptor) obj; return budgetPeriod == other.budgetPeriod && startPeriod == other.startPeriod; } /** * {@inheritDoc} *

* Compares by the start date */ @Override public int compareTo(@NotNull final BudgetPeriodDescriptor that) { return this.startDate.compareTo(that.getStartDate()); } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/budget/BudgetPeriodDescriptorFactory.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.budget; import java.time.LocalDate; import java.time.Month; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.concurrent.locks.ReadWriteLock; import java.util.concurrent.locks.ReentrantReadWriteLock; import jgnash.time.DateUtils; import jgnash.time.Period; import org.apache.commons.collections4.map.ReferenceMap; /** * Factory class for generating descriptors. * * @author Craig Cavanaugh */ public final class BudgetPeriodDescriptorFactory { private static final Map> cache = new ReferenceMap<>(); private static final ReadWriteLock rwl = new ReentrantReadWriteLock(false); private BudgetPeriodDescriptorFactory() { } public static List getDescriptors(final int budgetYear, final Month startingMonth, final Period budgetPeriod) { final String cacheKey = budgetYear + budgetPeriod.name() + startingMonth.ordinal(); List descriptors; rwl.readLock().lock(); try { descriptors = cache.get(cacheKey); } finally { rwl.readLock().unlock(); } // build the descriptor List if not cached if (descriptors == null) { LocalDate[] dates = new LocalDate[0]; switch (budgetPeriod) { case DAILY: dates = DateUtils.getAllDays(startingMonth, budgetYear, DateUtils.getDaysInYear(budgetYear) + 1); break; case WEEKLY: dates = DateUtils.getFirstDayWeekly(startingMonth, budgetYear, 52 + 1); break; case BI_WEEKLY: dates = DateUtils.getFirstDayBiWeekly(startingMonth, budgetYear, 26 + 1); break; case MONTHLY: dates = DateUtils.getFirstDayMonthly(startingMonth, budgetYear, 12 + 1); break; case QUARTERLY: dates = DateUtils.getFirstDayQuarterly(startingMonth, budgetYear, 4 + 1); break; case YEARLY: dates = DateUtils.getFirstDayWeekly(startingMonth, budgetYear, 2, 52); break; } descriptors = new ArrayList<>(dates.length); for (int i = 0; i < dates.length - 1; i++) { descriptors.add(new BudgetPeriodDescriptor(dates[i], dates[i + 1].minusDays(1), budgetPeriod)); } // Debugging info /*System.out.println("Descriptor list length: " + descriptors.size()); for (final BudgetPeriodDescriptor descriptor : descriptors) { System.out.println(descriptor.toString()); }*/ rwl.writeLock().lock(); try { cache.put(cacheKey, descriptors); } finally { rwl.writeLock().unlock(); } } return descriptors; } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/budget/BudgetPeriodResults.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.budget; import java.math.BigDecimal; /** * A simple wrapper for calculated budget results. * * @author Craig Cavanaugh * */ public class BudgetPeriodResults { private BigDecimal budgeted = BigDecimal.ZERO; private BigDecimal change = BigDecimal.ZERO; private BigDecimal remaining = BigDecimal.ZERO; public BigDecimal getBudgeted() { return budgeted; } public void setBudgeted(final BigDecimal budgeted) { this.budgeted = budgeted; } public BigDecimal getChange() { return change; } public void setChange(final BigDecimal change) { this.change = change; } public BigDecimal getRemaining() { return remaining; } public void setRemaining(final BigDecimal balance) { this.remaining = balance; } @Override public String toString() { return String.format("Budgeted: %f Change: %f Remaining: %f", budgeted, change, remaining); } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/budget/BudgetResultsModel.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.budget; import java.math.BigDecimal; import java.util.ArrayList; import java.util.EnumMap; import java.util.EnumSet; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; import java.util.concurrent.locks.ReentrantLock; import java.util.concurrent.locks.ReentrantReadWriteLock; import java.util.stream.Collectors; import jgnash.engine.Account; import jgnash.engine.AccountGroup; import jgnash.engine.AccountType; import jgnash.engine.Comparators; import jgnash.engine.CurrencyNode; import jgnash.engine.Engine; import jgnash.engine.EngineFactory; import jgnash.engine.RootAccount; import jgnash.engine.Transaction; import jgnash.engine.message.Message; import jgnash.engine.message.MessageBus; import jgnash.engine.message.MessageChannel; import jgnash.engine.message.MessageListener; import jgnash.engine.message.MessageProperty; import jgnash.engine.message.MessageProxy; /** * Model for budget results. * * @author Craig Cavanaugh */ public class BudgetResultsModel implements MessageListener { private Set accounts = new HashSet<>(); private final Budget budget; private final CurrencyNode baseCurrency; private List accountGroupList; private final List descriptorList; private final ReentrantReadWriteLock accountLock = new ReentrantReadWriteLock(); private final ReentrantLock cacheLock = new ReentrantLock(); private final Map accountResultsCache; private final Map accountGroupResultsCache; private final Map> descriptorAccountResultsCache; private final Map> descriptorAccountGroupResultsCache; private final boolean useRunningTotals; /** * Message proxy. */ private final MessageProxy proxy = new MessageProxy(); public BudgetResultsModel(final Budget budget, final int year, final CurrencyNode baseCurrency, final boolean useRunningTotals) { this.budget = budget; this.descriptorList = BudgetPeriodDescriptorFactory.getDescriptors(year, budget.getStartMonth(), budget.getBudgetPeriod()); this.baseCurrency = baseCurrency; this.useRunningTotals = useRunningTotals; accountResultsCache = new HashMap<>(); accountGroupResultsCache = new EnumMap<>(AccountGroup.class); descriptorAccountResultsCache = new HashMap<>(); descriptorAccountGroupResultsCache = new HashMap<>(); loadAccounts(); loadAccountGroups(); registerListeners(); } public Budget getBudget() { return budget; } public CurrencyNode getBaseCurrency() { return baseCurrency; } /** * Returns the depth of the account relative to topmost account in the * budget hierarchy. * * @param account Account to get depth for * @return depth depth relative to accounts to be shown in the budget * @see Account#getDepth() */ public int getDepth(final Account account) { int depth = 0; Account parent = account.getParent(); while (parent != null) { if (accounts.contains(parent)) { depth++; } parent = parent.getParent(); } return depth; } private boolean areParentsIncluded(final Account account) { boolean result = true; Account parent = account.getParent(); if (parent != null && !(parent instanceof RootAccount)) { if (!includeAccount(parent)) { result = false; } else { result = areParentsIncluded(parent); } } return result; } public List getDescriptorList() { return descriptorList; } private void registerListeners() { MessageBus.getInstance().registerListener(this, MessageChannel.ACCOUNT, MessageChannel.BUDGET, MessageChannel.SYSTEM, MessageChannel.TRANSACTION); } private void unregisterListeners() { MessageBus.getInstance().unregisterListener(this, MessageChannel.ACCOUNT, MessageChannel.BUDGET, MessageChannel.SYSTEM, MessageChannel.TRANSACTION); } public synchronized void addMessageListener(final MessageListener messageListener) { proxy.addMessageListener(messageListener); } public synchronized void removeMessageListener(final MessageListener messageListener) { proxy.removeMessageListener(messageListener); } /** * Determines if the account will be added to the model. * * @param account Account to test * @return true if it should be added */ public boolean includeAccount(final Account account) { boolean result = false; // account must be visible and not marked for exclusion from a budget if (account.isVisible() && !account.isExcludedFromBudget()) { if (account.memberOf(AccountGroup.INCOME) && budget.areIncomeAccountsIncluded()) { result = true; } else if (account.memberOf(AccountGroup.EXPENSE) && budget.areExpenseAccountsIncluded()) { result = true; } else if (account.memberOf(AccountGroup.ASSET) && budget.areAssetAccountsIncluded()) { result = true; } else if (account.memberOf(AccountGroup.LIABILITY) && budget.areLiabilityAccountsIncluded()) { result = true; } } if (result) { result = areParentsIncluded(account); } return result; } private void loadAccounts() { final Engine engine = EngineFactory.getEngine(EngineFactory.DEFAULT); Objects.requireNonNull(engine); Set accountSet = engine.getAccountList().stream() .filter(this::includeAccount).collect(Collectors.toSet()); accountLock.writeLock().lock(); try { accounts = accountSet; } finally { accountLock.writeLock().unlock(); } } private void loadAccountGroups() { accountLock.writeLock().lock(); try { final EnumSet accountSet = EnumSet.noneOf(AccountGroup.class); accountSet.addAll(accounts.stream() .map(account -> account.getAccountType().getAccountGroup()).collect(Collectors.toList())); // create a list and sort List groups = new ArrayList<>(accountSet); // Set an explicit sort order groups.sort(new Comparators.ExplicitComparator<>(AccountGroup.INCOME, AccountGroup.EXPENSE, AccountGroup.ASSET, AccountGroup.LIABILITY)); accountGroupList = groups; } finally { accountLock.writeLock().unlock(); } } public List getAccountGroupList() { accountLock.readLock().lock(); try { return accountGroupList; } finally { accountLock.readLock().unlock(); } } public final Set getAccounts() { accountLock.readLock().lock(); try { // return a defensive copy return new HashSet<>(accounts); } finally { accountLock.readLock().unlock(); } } private Set getAccounts(final AccountGroup group) { accountLock.readLock().lock(); try { return accounts.stream().filter(account -> account.memberOf(group)).collect(Collectors.toSet()); } finally { accountLock.readLock().unlock(); } } private void clear(final BudgetPeriodDescriptor descriptor, final Account account) { cacheLock.lock(); try { final Map resultsMap = descriptorAccountResultsCache.get(descriptor); if (resultsMap != null) { resultsMap.remove(account); } } finally { cacheLock.unlock(); } } private void clear(final BudgetPeriodDescriptor descriptor, final AccountGroup group) { cacheLock.lock(); try { final Map resultsMap = descriptorAccountGroupResultsCache.get(descriptor); if (resultsMap != null) { resultsMap.remove(group); } } finally { cacheLock.unlock(); } } private void clear(final Account account) { cacheLock.lock(); try { accountResultsCache.remove(account); } finally { cacheLock.unlock(); } } private void clear(final AccountGroup accountGroup) { cacheLock.lock(); try { final BudgetPeriodResults results = accountGroupResultsCache.get(accountGroup); if (results != null) { accountGroupResultsCache.remove(accountGroup); } } finally { cacheLock.unlock(); } } private void clearCached() { cacheLock.lock(); try { accountResultsCache.clear(); accountGroupResultsCache.clear(); descriptorAccountResultsCache.clear(); descriptorAccountGroupResultsCache.clear(); } finally { cacheLock.unlock(); } } /** * Gets results by descriptor and account (per account results). * * @param descriptor BudgetPeriodDescriptor descriptor * @param account Account * @return cached or newly created BudgetPeriodResults */ public BudgetPeriodResults getResults(final BudgetPeriodDescriptor descriptor, final Account account) { cacheLock.lock(); try { final Map resultsMap = descriptorAccountResultsCache.computeIfAbsent(descriptor, k -> new HashMap<>()); return resultsMap.computeIfAbsent(account, k -> buildAccountResults(descriptor, account, true)); } finally { cacheLock.unlock(); } } /** * Gets summary result by descriptor and account group (column summary by * AccountGroup). * * @param descriptor BudgetPeriodDescriptor for summary * @param group AccountGroup for summary * @return summary results */ public BudgetPeriodResults getResults(final BudgetPeriodDescriptor descriptor, final AccountGroup group) { cacheLock.lock(); try { final Map resultsMap = descriptorAccountGroupResultsCache .computeIfAbsent(descriptor, k -> new EnumMap<>(AccountGroup.class)); return resultsMap.computeIfAbsent(group, k -> buildResults(descriptor, group)); } finally { cacheLock.unlock(); } } /** * Gets summary result by account (row summary). * * @param account Account for summary * @return summary results */ public BudgetPeriodResults getResults(final Account account) { cacheLock.lock(); try { return accountResultsCache.computeIfAbsent(account, k -> buildResults(account)); } finally { cacheLock.unlock(); } } /** * Gets summary result by account group (corner summary). * * @param accountGroup AccountGroup for summary * @return summary results */ public BudgetPeriodResults getResults(final AccountGroup accountGroup) { cacheLock.lock(); try { return accountGroupResultsCache.computeIfAbsent(accountGroup, k -> buildResults(accountGroup)); } finally { cacheLock.unlock(); } } private BudgetPeriodResults buildAccountResults(final BudgetPeriodDescriptor descriptor, final Account account, final boolean includeBaseAccountResults) { final BudgetPeriodResults results = new BudgetPeriodResults(); accountLock.readLock().lock(); try { // calculate this account's results if (accounts.contains(account)) { final BudgetGoal goal = budget.getBudgetGoal(account); results.setBudgeted(goal.getGoal(descriptor.getStartPeriod(), descriptor.getEndPeriod(), descriptor.getStartDate().isLeapYear())); // calculate the change and remaining amount for the budget if (account.getAccountType() == AccountType.INCOME) { results.setChange(account.getBalance(descriptor.getStartDate(), descriptor.getEndDate()).negate()); results.setRemaining(results.getChange().subtract(results.getBudgeted())); } else { results.setChange(account.getBalance(descriptor.getStartDate(), descriptor.getEndDate())); results.setRemaining(results.getBudgeted().subtract(results.getChange())); } final int index = descriptorList.indexOf(descriptor); // per account running total if (useRunningTotals && index > 0 && includeBaseAccountResults) { final BudgetPeriodResults priorResults = getResults(descriptorList.get(index - 1), account); results.setBudgeted(results.getBudgeted().add(priorResults.getBudgeted())); results.setChange(results.getChange().add(priorResults.getChange())); results.setRemaining(results.getRemaining().add(priorResults.getRemaining())); } } // recursive decent to add child account results and handle exchange rates for (final Account child : account.getChildren(Comparators.getAccountByCode())) { final BudgetPeriodResults childResults = buildAccountResults(descriptor, child, false); final BigDecimal exchangeRate = child.getCurrencyNode().getExchangeRate(account.getCurrencyNode()); // reverse sign if the parent account is an income account but the child is not, or vice versa final BigDecimal sign = ((account.getAccountType() == AccountType.INCOME) != (child.getAccountType() == AccountType.INCOME)) ? BigDecimal.ONE.negate() : BigDecimal.ONE; results.setChange(results.getChange().add(childResults.getChange().multiply(exchangeRate).multiply(sign))); results.setBudgeted(results.getBudgeted().add(childResults.getBudgeted().multiply(exchangeRate).multiply(sign))); results.setRemaining(results.getRemaining().add(childResults.getRemaining().multiply(exchangeRate))); } } finally { accountLock.readLock().unlock(); } // rescale the results results.setChange(results.getChange().setScale(budget.getRoundingScale(), budget.getRoundingMode())); results.setBudgeted(results.getBudgeted().setScale(budget.getRoundingScale(), budget.getRoundingMode())); results.setRemaining(results.getRemaining().setScale(budget.getRoundingScale(), budget.getRoundingMode())); return results; } private BudgetPeriodResults buildResults(final BudgetPeriodDescriptor descriptor, final AccountGroup group) { BigDecimal remainingTotal = BigDecimal.ZERO; BigDecimal totalChange = BigDecimal.ZERO; BigDecimal totalBudgeted = BigDecimal.ZERO; accountLock.readLock().lock(); try { for (final Account account : getAccounts(group)) { // only sum for the top level accounts within the budget model // top level account if the parent is not included in the budget model if (!accounts.contains(account.getParent())) { final BudgetPeriodResults periodResults = getResults(descriptor, account); final BigDecimal remaining = periodResults.getRemaining(); remainingTotal = remainingTotal.add(remaining.multiply(baseCurrency.getExchangeRate(account.getCurrencyNode()))); final BigDecimal change = periodResults.getChange(); totalChange = totalChange.add(change.multiply(baseCurrency.getExchangeRate(account.getCurrencyNode()))); final BigDecimal budgeted = periodResults.getBudgeted(); totalBudgeted = totalBudgeted.add(budgeted.multiply(baseCurrency.getExchangeRate(account.getCurrencyNode()))); } } } finally { accountLock.readLock().unlock(); } final BudgetPeriodResults results = new BudgetPeriodResults(); if (useRunningTotals) { final int index = descriptorList.indexOf(descriptor); if (index > 0) { final BudgetPeriodResults priorResults = getResults(descriptorList.get(index - 1), group); results.setBudgeted(results.getBudgeted().add(priorResults.getBudgeted())); results.setChange(results.getChange().add(priorResults.getChange())); results.setRemaining(results.getRemaining().add(priorResults.getRemaining())); } } // rescale the results results.setBudgeted(totalBudgeted.setScale(budget.getRoundingScale(), budget.getRoundingMode())); results.setRemaining(remainingTotal.setScale(budget.getRoundingScale(), budget.getRoundingMode())); results.setChange(totalChange.setScale(budget.getRoundingScale(), budget.getRoundingMode())); return results; } private BudgetPeriodResults buildResults(final Account account) { BigDecimal change = BigDecimal.ZERO; BigDecimal budgeted = BigDecimal.ZERO; BigDecimal remaining = BigDecimal.ZERO; accountLock.readLock().lock(); try { if (useRunningTotals) { // just use the last descriptor final BudgetPeriodResults periodResults = getResults(descriptorList.get(descriptorList.size() - 1), account); change = periodResults.getChange(); budgeted = periodResults.getBudgeted(); remaining = periodResults.getRemaining(); } else { for (final BudgetPeriodDescriptor descriptor : descriptorList) { final BudgetPeriodResults periodResults = getResults(descriptor, account); change = change.add(periodResults.getChange()); budgeted = budgeted.add(periodResults.getBudgeted()); remaining = remaining.add(periodResults.getRemaining()); } } } finally { accountLock.readLock().unlock(); } final BudgetPeriodResults results = new BudgetPeriodResults(); results.setChange(change); results.setBudgeted(budgeted); results.setRemaining(remaining); return results; } private BudgetPeriodResults buildResults(final AccountGroup group) { BigDecimal totalChange = BigDecimal.ZERO; BigDecimal totalBudgeted = BigDecimal.ZERO; BigDecimal totalRemaining = BigDecimal.ZERO; accountLock.readLock().lock(); try { if (useRunningTotals) { final BudgetPeriodResults periodResults = getResults(descriptorList.get(descriptorList.size() - 1), group); totalChange = periodResults.getChange(); totalBudgeted = periodResults.getBudgeted(); totalRemaining = periodResults.getRemaining(); } else { for (final BudgetPeriodDescriptor descriptor : descriptorList) { final BudgetPeriodResults periodResults = getResults(descriptor, group); totalChange = totalChange.add(periodResults.getChange()); totalBudgeted = totalBudgeted.add(periodResults.getBudgeted()); totalRemaining = totalRemaining.add(periodResults.getRemaining()); } } } finally { accountLock.readLock().unlock(); } final BudgetPeriodResults results = new BudgetPeriodResults(); results.setChange(totalChange); results.setBudgeted(totalBudgeted); results.setRemaining(totalRemaining); return results; } private void clearCached(final Account account) { accountLock.readLock().lock(); try { cacheLock.lock(); try { // clear cached results // could be mixed group tree account.getAncestors().stream().filter(accounts::contains).forEach(ancestor -> { for (BudgetPeriodDescriptor descriptor : descriptorList) { clear(ancestor); clear(descriptor, ancestor); clear(ancestor.getAccountType().getAccountGroup()); // could be mixed group tree clear(descriptor, ancestor.getAccountType().getAccountGroup()); } }); } finally { cacheLock.unlock(); } } finally { accountLock.readLock().unlock(); } } private void processAccountEvent(final Message message) { Account account = message.getObject(MessageProperty.ACCOUNT); switch (message.getEvent()) { case ACCOUNT_ADD: accounts.add(account); clearCached(account); break; case ACCOUNT_REMOVE: accounts.remove(account); clearCached(account); break; case ACCOUNT_MODIFY: loadAccounts(); // force a reload of accounts, structure is indeterminate clearCached(); // indeterminate structure, so dump all cached results break; default: break; } loadAccountGroups(); // force reload of account groups after accounts have changed } private void processBudgetEvent(final Message message) { Budget messageBudget = message.getObject(MessageProperty.BUDGET); if (budget.equals(messageBudget)) { switch (message.getEvent()) { case BUDGET_UPDATE: clearCached(); break; case BUDGET_GOAL_UPDATE: Account account = message.getObject(MessageProperty.ACCOUNT); clearCached(account); break; case BUDGET_REMOVE: unregisterListeners(); clearCached(); break; default: } } } private void processTransactionEvent(final Message message) { final Transaction transaction = message.getObject(MessageProperty.TRANSACTION); descriptorList.stream().filter(descriptor -> descriptor.isBetween(transaction.getLocalDate())) .forEach(descriptor -> { final Set accountSet = new HashSet<>(); for (Account account : transaction.getAccounts()) { accountSet.addAll(account.getAncestors()); } accountSet.forEach(this::clearCached); }); } @Override public void messagePosted(final Message message) { switch (message.getEvent()) { case ACCOUNT_ADD: case ACCOUNT_MODIFY: case ACCOUNT_MODIFY_FAILED: processAccountEvent(message); break; case BUDGET_UPDATE: case BUDGET_GOAL_UPDATE: case BUDGET_REMOVE: processBudgetEvent(message); break; case TRANSACTION_ADD: case TRANSACTION_REMOVE: processTransactionEvent(message); break; case FILE_CLOSING: unregisterListeners(); clearCached(); break; default: } proxy.forwardMessage(message); } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/budget/Pattern.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received account copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.budget; import jgnash.resource.util.ResourceUtils; /** * Pattern Enum. * * @author Craig Cavanaugh */ @SuppressWarnings("unused") public enum Pattern { EveryRow(ResourceUtils.getString("Sequence.EveryRow")), EveryOtherRow(ResourceUtils.getString("Sequence.EveryOtherRow")), EverySecondRow(ResourceUtils.getString("Sequence.EverySecondRow")), EveryThirdRow(ResourceUtils.getString("Sequence.EveryThirdRow")), EveryForthRow(ResourceUtils.getString("Sequence.EveryForthRow")), EveryFifthRow(ResourceUtils.getString("Sequence.EveryFifthRow")); private final transient String description; Pattern(final String description) { this.description = description; } @Override public String toString() { return description; } public int getIncrement() { return this.ordinal() + 1; } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/concurrent/DistributedLockManager.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.concurrent; import io.netty.bootstrap.Bootstrap; import io.netty.channel.Channel; import io.netty.channel.ChannelHandler; import io.netty.channel.ChannelHandlerContext; import io.netty.channel.ChannelInboundHandlerAdapter; import io.netty.channel.ChannelInitializer; import io.netty.channel.ChannelOption; import io.netty.channel.ChannelPipeline; import io.netty.channel.nio.NioEventLoopGroup; import io.netty.channel.socket.SocketChannel; import io.netty.channel.socket.nio.NioSocketChannel; import io.netty.handler.codec.DelimiterBasedFrameDecoder; import io.netty.handler.codec.Delimiters; import io.netty.handler.codec.string.StringDecoder; import io.netty.handler.codec.string.StringEncoder; import io.netty.util.CharsetUtil; import io.netty.util.ReferenceCountUtil; import java.text.MessageFormat; import java.util.HashMap; import java.util.Map; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.ThreadFactory; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; import java.util.concurrent.locks.ReentrantReadWriteLock; import java.util.logging.Level; import java.util.logging.Logger; import jgnash.net.ConnectionFactory; import jgnash.util.EncryptionManager; import jgnash.util.NotNull; /** * Lock manager for distributed engine instances. * * @author Craig Cavanaugh */ public class DistributedLockManager implements LockManager { private static final Logger logger = Logger.getLogger(DistributedLockManager.class.getName()); private final Map lockMap = new ConcurrentHashMap<>(); private final Map latchMap = new HashMap<>(); private final Lock latchLock = new ReentrantLock(); /** * lock_action, lock_id, thread_id, lock_type. */ private static final String PATTERN = "{0},{1},{2},{3}"; static final String UUID_PREFIX = "UUID:"; private NioEventLoopGroup eventLoopGroup; private final int port; private final String host; private Channel channel; private static final String EOL_DELIMITER = "\r\n"; private final ExecutorService executorService = Executors.newCachedThreadPool(new LockManagerThreadFactory()); private EncryptionManager encryptionManager = null; /** * Unique id to differentiate remote threads. */ private static final String uuid = UUID.randomUUID().toString(); static { logger.setLevel(Level.INFO); } public DistributedLockManager(final String host, final int port) { this.host = host; this.port = port; } private String encrypt(final String message) { if (encryptionManager != null) { return encryptionManager.encrypt(message); } return message; } /** * Starts the connection with the lock server. * * @param password connection password * @return {@code true} if successful */ public boolean connectToServer(final char[] password) { boolean result = false; // If a password has been specified, create an EncryptionManager if (password != null && password.length > 0) { encryptionManager = new EncryptionManager(password); } final Bootstrap bootstrap = new Bootstrap(); eventLoopGroup = new NioEventLoopGroup(); bootstrap.group(eventLoopGroup) .channel(NioSocketChannel.class) .handler(new Initializer()) .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, ConnectionFactory.getConnectionTimeout() * 1000) .option(ChannelOption.SO_KEEPALIVE, true); try { // Start the connection attempt. channel = bootstrap.connect(host, port).sync().channel(); channel.writeAndFlush(encrypt(UUID_PREFIX + uuid) + EOL_DELIMITER).sync(); // send this channels uuid result = true; logger.info("Connection made with Distributed Lock Server"); } catch (final InterruptedException e) { logger.log(Level.SEVERE, "Failed to connect to Distributed Lock Server", e); disconnectFromServer(); } return result; } /** * Disconnects from the lock server. */ public void disconnectFromServer() { try { channel.close().sync(); } catch (InterruptedException e) { logger.log(Level.SEVERE, e.getLocalizedMessage(), e); } executorService.shutdown(); eventLoopGroup.shutdownGracefully(); eventLoopGroup = null; channel = null; logger.info("Disconnected from the Distributed Lock Server"); } @Override public synchronized ReentrantReadWriteLock getLock(final String lockId) { return lockMap.computeIfAbsent(lockId, k -> new DistributedReadWriteLock(lockId)); } private CountDownLatch getLatch(final String lockMessage) { latchLock.lock(); try { return latchMap.computeIfAbsent(lockMessage, k -> new CountDownLatch(1)); } finally { latchLock.unlock(); } } private void lock(final String lockId, final String type) { changeLockState(lockId, type, DistributedLockServer.LOCK); } private void unlock(final String lockId, final String type) { changeLockState(lockId, type, DistributedLockServer.UNLOCK); } private void changeLockState(final String lockId, final String type, final String lockState) { final String threadId = uuid + '-' + Thread.currentThread().getId(); final String lockMessage = MessageFormat.format(PATTERN, lockState, lockId, threadId, type); final CountDownLatch responseLatch = getLatch(lockMessage); //noinspection SynchronizationOnLocalVariableOrMethodParameter synchronized (responseLatch) { // synchronize on the lock to prevent concurrency errors boolean result = false; try { // send the message to the server and wait until it if flushed channel.writeAndFlush(encrypt(lockMessage) + EOL_DELIMITER).sync(); for (int i = 0; i < 2; i++) { result = responseLatch.await(45L, TimeUnit.SECONDS); if (!result) { logger.log(Level.WARNING, "Excessive wait for release of the lock latch for: {0}", lockId); } else { break; } } if (!result) { // check for a failed release or deadlock logger.log(Level.SEVERE, "Failed to release the lock latch for: {0}", lockId); latchLock.lock(); try { responseLatch.countDown(); // force a countdown to occur latchMap.remove(lockId); // force removal } finally { latchLock.unlock(); } } } catch (InterruptedException e) { logger.log(Level.SEVERE, e.getLocalizedMessage(), e); } } } private void processMessage(final String lockMessage) { final String plainMessage; if (encryptionManager != null) { plainMessage = encryptionManager.decrypt(lockMessage); } else { plainMessage = lockMessage; } //logger.info(plainMessage); /* lock_action, lock_id, thread_id, lock_type */ // unlock,account,3456384756384563,read // lock,account,3456384756384563,write latchLock.lock(); try { final CountDownLatch responseLatch = getLatch(plainMessage); responseLatch.countDown(); // this should release the responseLatch allowing a blocked thread to continue latchMap.remove(plainMessage); // remove the used up latch } finally { latchLock.unlock(); } } private static class LockManagerThreadFactory implements ThreadFactory { private final AtomicLong counter = new AtomicLong(); @Override public Thread newThread(final Runnable r) { return new Thread(r, "jGnash Distributed Lock Manager " + counter.incrementAndGet()); } } private class Initializer extends ChannelInitializer { @Override public void initChannel(final SocketChannel ch) { ChannelPipeline pipeline = ch.pipeline(); // Add the text line codec combination first, pipeline.addLast("framer", new DelimiterBasedFrameDecoder(8192, true, Delimiters.lineDelimiter())); pipeline.addLast("decoder", new StringDecoder(CharsetUtil.UTF_8)); pipeline.addLast("encoder", new StringEncoder(CharsetUtil.UTF_8)); // and then business logic. pipeline.addLast("handler", new ClientHandler()); } } /** * Handles a client-side channel. */ @ChannelHandler.Sharable private class ClientHandler extends ChannelInboundHandlerAdapter { @Override public void channelRead(final ChannelHandlerContext ctx, final Object msg) { executorService.submit(() -> { processMessage(msg.toString()); ReferenceCountUtil.release(msg); }); } @Override public void exceptionCaught(final ChannelHandlerContext ctx, final Throwable cause) throws Exception { super.exceptionCaught(ctx, cause); logger.log(Level.WARNING, "Unexpected exception from downstream.", cause); ctx.close(); } } private class DistributedReadWriteLock extends ReentrantReadWriteLock { private final String lockId; private final DistributedReadWriteLock.ReadLock readLock; private final DistributedReadWriteLock.WriteLock writeLock; DistributedReadWriteLock(final String lockId) { super(); this.lockId = lockId; readLock = new DistributedReadWriteLock.ReadLock(this); writeLock = new DistributedReadWriteLock.WriteLock(this); } @Override @NotNull public ReentrantReadWriteLock.ReadLock readLock() { return readLock; } @Override @NotNull public ReentrantReadWriteLock.WriteLock writeLock() { return writeLock; } class ReadLock extends ReentrantReadWriteLock.ReadLock { ReadLock(final ReentrantReadWriteLock lock) { super(lock); } @Override public void lock() { DistributedLockManager.this.lock(lockId, DistributedLockServer.LOCK_TYPE_READ); super.lock(); } @Override public void unlock() { DistributedLockManager.this.unlock(lockId, DistributedLockServer.LOCK_TYPE_READ); super.unlock(); } } class WriteLock extends ReentrantReadWriteLock.WriteLock { WriteLock(final ReentrantReadWriteLock lock) { super(lock); } @Override public void lock() { DistributedLockManager.this.lock(lockId, DistributedLockServer.LOCK_TYPE_WRITE); super.lock(); } @Override public void unlock() { DistributedLockManager.this.unlock(lockId, DistributedLockServer.LOCK_TYPE_WRITE); super.unlock(); } } } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/concurrent/DistributedLockServer.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.concurrent; import java.util.HashMap; import java.util.Map; import java.util.Objects; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.ThreadFactory; import java.util.concurrent.atomic.AtomicLong; import java.util.logging.Level; import java.util.logging.Logger; import jgnash.net.ConnectionFactory; import jgnash.util.EncodeDecode; import jgnash.util.EncryptionManager; import io.netty.bootstrap.ServerBootstrap; import io.netty.channel.ChannelFuture; import io.netty.channel.ChannelHandler; import io.netty.channel.ChannelHandlerContext; import io.netty.channel.ChannelInboundHandlerAdapter; import io.netty.channel.ChannelInitializer; import io.netty.channel.ChannelOption; import io.netty.channel.ChannelPipeline; import io.netty.channel.group.ChannelGroup; import io.netty.channel.group.DefaultChannelGroup; import io.netty.channel.nio.NioEventLoopGroup; import io.netty.channel.socket.SocketChannel; import io.netty.channel.socket.nio.NioServerSocketChannel; import io.netty.handler.codec.DelimiterBasedFrameDecoder; import io.netty.handler.codec.Delimiters; import io.netty.handler.codec.string.StringDecoder; import io.netty.handler.codec.string.StringEncoder; import io.netty.util.CharsetUtil; import io.netty.util.ReferenceCountUtil; import io.netty.util.concurrent.GlobalEventExecutor; import static jgnash.net.ConnectionFactory.MILLIS_PER_SECOND; /** * Distributed Lock Server. * * @author Craig Cavanaugh */ public class DistributedLockServer { private static final Logger logger = Logger.getLogger(DistributedLockServer.class.getName()); // there needs to be to 2 threads to ensure order of operations and allow for unlocking without blocking private final ExecutorService executorService = Executors.newFixedThreadPool(2, new LockServerThreadFactory()); private final ChannelGroup channelGroup = new DefaultChannelGroup("lock-server", GlobalEventExecutor.INSTANCE); private NioEventLoopGroup eventLoopGroup; private final int port; private final Map lockMap = new HashMap<>(); private final Map handlerContextMap = new HashMap<>(); static final String LOCK = "lock"; static final String UNLOCK = "unlock"; static final String LOCK_TYPE_READ = "READ"; static final String LOCK_TYPE_WRITE = "WRITE"; private static final String EOL_DELIMITER = "\r\n"; private EncryptionManager encryptionManager = null; public DistributedLockServer(final int port) { this.port = port; } private String encrypt(final String message) { if (encryptionManager != null) { return encryptionManager.encrypt(message); } return message; } private void processMessage(final ChannelHandlerContext ctx, final String msg) { final String message; if (encryptionManager != null) { message = encryptionManager.decrypt(msg); } else { message = msg; } // Look for a uuid announcement for a channel if (message.startsWith(DistributedLockManager.UUID_PREFIX)) { handlerContextMap.put(ctx, message.substring(DistributedLockManager.UUID_PREFIX.length())); return; } /* lock_action, lock_id, thread_id, lock_type */ // unlock,account,1194917570,read // lock,account,1194917570,write // decode the message into it's parts final String[] strings = EncodeDecode.decodeStringCollection(message).toArray(new String[4]); final String action = strings[0]; final String lockId = strings[1]; final String remoteThread = strings[2]; final String lockType = strings[3]; final ReadWriteLock lock = getLock(lockId); try { // request a lock or unlock. This may block switch (action) { case LOCK: switch (lockType) { case LOCK_TYPE_READ: lock.lockForRead(remoteThread); break; case LOCK_TYPE_WRITE: lock.lockForWrite(remoteThread); break; default: break; } break; case UNLOCK: switch (lockType) { case LOCK_TYPE_READ: lock.unlockRead(remoteThread); break; case LOCK_TYPE_WRITE: lock.unlockWrite(remoteThread); break; default: break; } break; } // return the message as an acknowledgment lock state has changed if (ctx.channel().isOpen()) { ctx.writeAndFlush(encrypt(message) + EOL_DELIMITER).sync(); } } catch (final Exception e) { logger.log(Level.SEVERE, e.getLocalizedMessage(), e); } } private ReadWriteLock getLock(final String lockId) { return lockMap.computeIfAbsent(lockId, k -> new ReadWriteLock(lockId)); } public boolean startServer(final char[] password) { boolean result = false; // If a password has been specified, create an EncryptionManager if (password != null && password.length > 0) { encryptionManager = new EncryptionManager(password); } eventLoopGroup = new NioEventLoopGroup(); final ServerBootstrap bootstrap = new ServerBootstrap(); try { bootstrap.group(eventLoopGroup) .channel(NioServerSocketChannel.class) .childHandler(new Initializer()) .childOption(ChannelOption.SO_KEEPALIVE, true); final ChannelFuture future = bootstrap.bind(port); future.sync(); if (future.isDone() && future.isSuccess()) { logger.info("Distributed Lock Server started successfully"); result = true; } else { logger.info("Failed to start the Distributed Lock Server"); } } catch (final InterruptedException e) { logger.log(Level.SEVERE, e.getLocalizedMessage(), e); stopServer(); } return result; } public void stopServer() { try { channelGroup.close().sync(); executorService.shutdown(); eventLoopGroup.shutdownGracefully(); eventLoopGroup = null; logger.info("Distributed Lock Server Stopped"); } catch (final InterruptedException e) { logger.log(Level.SEVERE, e.getLocalizedMessage(), e); } } private static class LockServerThreadFactory implements ThreadFactory { private final AtomicLong counter = new AtomicLong(); @Override public Thread newThread(final Runnable r) { return new Thread(r, "jGnash Distributed Lock Server " + counter.incrementAndGet()); } } @ChannelHandler.Sharable private class ServerHandler extends ChannelInboundHandlerAdapter { @Override public void channelActive(final ChannelHandlerContext ctx) { channelGroup.add(ctx.channel()); // maintain channels logger.log(Level.INFO, "Remote connection from: {0}", ctx.channel().remoteAddress().toString()); } @Override public void channelInactive(final ChannelHandlerContext ctx) throws Exception { logger.log(Level.INFO, "Remote connection {0} closed", ctx.channel().remoteAddress().toString()); final String uuid = handlerContextMap.get(ctx); // Search through the lock map and remove any stale locks if (uuid != null) { for (ReadWriteLock readWriteLock : lockMap.values()) { // look at every lock // if the remoteThread starts with the uuid, request a cleanup // cleanup a stale lock readWriteLock.readingThreads.keySet().stream().filter(remoteThread -> remoteThread.startsWith(uuid)).forEach(readWriteLock::cleanupStaleThread); if (readWriteLock.hasWriteThread(uuid)) { readWriteLock.cleanupStaleWriteThread(); } } } handlerContextMap.remove(ctx); channelGroup.remove(ctx.channel()); super.channelInactive(ctx); } @Override public void channelRead(final ChannelHandlerContext ctx, final Object msg) { executorService.submit(() -> { processMessage(ctx, msg.toString()); ReferenceCountUtil.release(msg); }); } @Override public void exceptionCaught(final ChannelHandlerContext ctx, final Throwable cause) { logger.log(Level.WARNING, "Unexpected exception from downstream.", cause); ctx.close(); } } private class Initializer extends ChannelInitializer { @Override public void initChannel(final SocketChannel ch) { ChannelPipeline pipeline = ch.pipeline(); // Add the text line codec combination first, pipeline.addLast("framer", new DelimiterBasedFrameDecoder(8192, true, Delimiters.lineDelimiter())); // the encoder and decoder are static as these are sharable pipeline.addLast("decoder", new StringDecoder(CharsetUtil.UTF_8)); pipeline.addLast("encoder", new StringEncoder(CharsetUtil.UTF_8)); // and then business logic. pipeline.addLast("handler", new ServerHandler()); } } /** * Reentrant Read Write lock. *

* A unique integer must be supplied to identify the thread instead of the current thread. */ private static class ReadWriteLock { private final String id; /** * The key is the uuid of the manager plus the remote thread id. *

* uuid-integer */ private final Map readingThreads = new ConcurrentHashMap<>(); private int writeAccesses = 0; private int writeRequests = 0; private String writingThread = null; private ReadWriteLock(final String id) { this.id = id; } synchronized boolean hasWriteThread(final String id) { boolean result = false; if (writingThread != null) { result = writingThread.startsWith(id); } return result; } synchronized void cleanupStaleThread(final String remoteThread) { if (readingThreads.containsKey(remoteThread)) { unlockRead(remoteThread); logger.log(Level.WARNING, "Removed a stale read lock for: {0}", id); } if (writingThread != null && writingThread.equals(remoteThread)) { unlockWrite(remoteThread); logger.log(Level.WARNING, "Removed a stale write lock for: {0}", id); } } synchronized void cleanupStaleWriteThread() { if (readingThreads.containsKey(writingThread)) { unlockRead( writingThread); logger.log(Level.WARNING, "Removed a stale read lock for: {0}", id); } if (writingThread != null) { unlockWrite( writingThread); logger.log(Level.WARNING, "Removed a stale write lock for: {0}", id); } } synchronized void lockForRead(final String remoteThread) throws InterruptedException { while (!canGrantReadAccess(remoteThread)) { // wait for a maximum of 2X the network timout wait((long) ConnectionFactory.getConnectionTimeout() * MILLIS_PER_SECOND * 2); } readingThreads.put(remoteThread, (getReadHoldCount(remoteThread) + 1)); } synchronized void lockForWrite(final String remoteThread) throws InterruptedException { writeRequests++; while (!canGrantWriteAccess(remoteThread)) { // wait for a maximum of 2X the network timout wait((long) ConnectionFactory.getConnectionTimeout() * MILLIS_PER_SECOND * 2); } writeRequests--; writeAccesses++; // bump, if greater than 1, then the lock is reentrant writingThread = remoteThread; } synchronized void unlockRead(final String remoteThread) { if (!isReadLockedByCurrentThread(remoteThread)) { throw new IllegalMonitorStateException("Remote Thread: " + remoteThread + " does not hold a read lock for: " + id); } int holdCount = getReadHoldCount(remoteThread); if (holdCount == 1) { readingThreads.remove(remoteThread); } else { readingThreads.put(remoteThread, (holdCount - 1)); } notifyAll(); } synchronized void unlockWrite(final String remoteThread) { if (!isWriteLockedByCurrentThread(remoteThread)) { throw new IllegalMonitorStateException("Remote Thread: " + remoteThread + " does not hold the write lock for: " + id); } writeAccesses--; if (writeAccesses == 0) { writingThread = null; } notifyAll(); } private synchronized boolean canGrantReadAccess(final String remoteThread) { if (isWriteLockedByCurrentThread(remoteThread)) { // lock down grade is allowed return true; } if (writingThread != null) { return false; } if (isReadLockedByCurrentThread(remoteThread)) { return true; } return writeRequests <= 0; } private synchronized boolean canGrantWriteAccess(final String remoteThread) { if (!readingThreads.isEmpty()) { return false; } if (writingThread == null) { return true; } return isWriteLockedByCurrentThread(remoteThread); // reentrant write } private synchronized int getReadHoldCount(final String remoteThread) { final Integer accessCount = readingThreads.get(remoteThread); return Objects.requireNonNullElse(accessCount, 0); } private synchronized boolean isReadLockedByCurrentThread(final String remoteThread) { return readingThreads.get(remoteThread) != null; } private synchronized boolean isWriteLockedByCurrentThread(final String remoteThread) { if (writingThread != null) { return writingThread.equals(remoteThread); } return false; } } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/concurrent/LocalLockManager.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.concurrent; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.locks.ReentrantReadWriteLock; /** * Lock manager for local engine instances. * * @author Craig Cavanaugh */ public class LocalLockManager implements LockManager { private final Map lockMap = new ConcurrentHashMap<>(); @Override public ReentrantReadWriteLock getLock(final String lockId) { return lockMap.computeIfAbsent(lockId, k -> new ReentrantReadWriteLock()); } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/concurrent/LockManager.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.concurrent; import java.util.concurrent.locks.ReentrantReadWriteLock; /** * Lock manger for all engine operations. * * Locks may be local or distributed depending on connection type * * @author Craig Cavanaugh */ public interface LockManager { /** * Returns a named {@link ReentrantReadWriteLock}. * Locks are cached and reused * * @param lockId id of the lock * * @return a new or cached ReentrantReadWriteLock */ ReentrantReadWriteLock getLock(final String lockId); } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/concurrent/Priority.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.concurrent; import java.util.concurrent.atomic.AtomicLong; /** * Immutable priority object that ensures first-in-first-out if tasks have the same priority. * * Smaller values will have more priority. * * @author Craig Cavanaugh */ public class Priority implements Comparable { public static final int SYSTEM = 1; public static final int BACKGROUND = 100; private static final AtomicLong atomicLongSequence = new AtomicLong(0); private final long sequence; private final int priority; Priority(final int priority) { sequence = atomicLongSequence.getAndIncrement(); this.priority = priority; } int getPriority() { return priority; } @Override public int compareTo(final Priority other) { int result = Integer.compare(priority, other.priority); if (result == 0) { // ensure fifo if the priority is the same result = (sequence < other.sequence ? -1 : 1); } return result; } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/concurrent/PriorityThreadPoolExecutor.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.concurrent; import java.util.List; import java.util.concurrent.Callable; import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.FutureTask; import java.util.concurrent.PriorityBlockingQueue; import java.util.concurrent.RunnableFuture; import java.util.concurrent.ThreadFactory; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; /** * Decorator around a {@code ThreadPoolExecutor} that provides execution priority. * * Callables with the same priority level are FIFO'd. * * @author Craig Cavanaugh */ public class PriorityThreadPoolExecutor { private final ThreadPoolExecutor threadPoolExecutor; private final PriorityBlockingQueue queue = new PriorityBlockingQueue<>(); public PriorityThreadPoolExecutor(ThreadFactory threadFactory) { threadPoolExecutor = new ThreadPoolExecutor(1, 1, Long.MAX_VALUE, TimeUnit.DAYS, queue, threadFactory) { // Wraps a Callable with a FutureTaskWrapper that respects the Priority @Override protected RunnableFuture newTaskFor(final Callable c) { return new FutureTaskWrapper<>((PriorityCallable) c); } }; threadPoolExecutor.allowCoreThreadTimeOut(false); } public PriorityThreadPoolExecutor() { this(Executors.defaultThreadFactory()); } private Future submit(final Callable callable, final Priority priority) { return threadPoolExecutor.submit(new PriorityCallable<>() { @Override public Priority getPriority() { return priority; } @Override public T call() throws Exception { return callable.call(); } }); } public Future submit(final Callable callable, final int priority) { return submit(callable, new Priority(priority)); } public Future submit(final Callable callable) { return submit(callable, Priority.SYSTEM); } public void shutdown() { /* Remove any non-critical system tasks from the executor first */ for (final Runnable runnable : queue.toArray(new Runnable[0])) { if (((FutureTaskWrapper)runnable).getPriorityCallable().getPriority().getPriority() != Priority.SYSTEM) { threadPoolExecutor.remove(runnable); } } threadPoolExecutor.shutdown(); } @SuppressWarnings("UnusedReturnValue") public List shutdownNow() { return threadPoolExecutor.shutdownNow(); } @SuppressWarnings("UnusedReturnValue") public boolean awaitTermination(final long timeout, final TimeUnit unit) throws InterruptedException { return threadPoolExecutor.awaitTermination(timeout, unit); } //A future task that wraps around the priority task to be used in the queue static class FutureTaskWrapper extends FutureTask implements Comparable> { private final PriorityCallable priorityCallable; FutureTaskWrapper(final PriorityCallable priorityCallable) { super(priorityCallable); this.priorityCallable = priorityCallable; } PriorityCallable getPriorityCallable() { return priorityCallable; } @Override public int compareTo(final FutureTaskWrapper other) { return priorityCallable.getPriority().compareTo(other.priorityCallable.getPriority()); } } private interface PriorityCallable extends Callable { Priority getPriority (); } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/dao/AbstractDAO.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.dao; import java.util.List; import java.util.concurrent.atomic.AtomicBoolean; import jgnash.engine.StoredObject; /** * Basic DAO. * * @author Craig Cavanaugh */ public abstract class AbstractDAO implements DAO { protected final AtomicBoolean dirtyFlag = new AtomicBoolean(false); protected static List stripMarkedForRemoval(final List list) { list.removeIf(StoredObject::isMarkedForRemoval); return list; } @Override public boolean isDirty() { return dirtyFlag.get(); } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/dao/AccountDAO.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.dao; import java.util.List; import java.util.UUID; import jgnash.engine.Account; import jgnash.engine.RootAccount; import jgnash.engine.SecurityNode; /** * Account DAO Interface. * * @author Craig Cavanaugh */ public interface AccountDAO extends DAO { RootAccount getRootAccount(); List getAccountList(); boolean addAccount(Account parent, final Account child); boolean addRootAccount(RootAccount account); /** * Adds a SecurityNode from a InvestmentAccount. * * @param account account to add security to * @param node security to add * @return true if success */ boolean addAccountSecurity(final Account account, final SecurityNode node); /** * Returns a list of IncomeAccounts. * * @return list of income accounts */ List getIncomeAccountList(); /** * Returns a list of ExpenseAccounts. * * @return list of expense accounts */ List getExpenseAccountList(); /** * Returns a list of InvestmentAccounts. * * @return list of investment accounts */ List getInvestmentAccountList(); Account getAccountByUuid(final UUID uuid); boolean updateAccount(Account account); /** * Toggles the visibility of an account given its ID. * * @param account The account to toggle visibility * @return true if the supplied account ID was found * false if the supplied account ID was not found */ boolean toggleAccountVisibility(final Account account); } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/dao/BudgetDAO.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.dao; import java.util.List; import java.util.UUID; import jgnash.engine.budget.Budget; /** * Budget DAO. * * @author Craig Cavanaugh */ public interface BudgetDAO extends DAO { boolean add(Budget budget); boolean update(Budget budget); List getBudgets(); Budget getBudgetByUuid(final UUID uuid); } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/dao/CommodityDAO.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.dao; import java.util.List; import java.util.Set; import java.util.UUID; import jgnash.engine.CommodityNode; import jgnash.engine.CurrencyNode; import jgnash.engine.ExchangeRate; import jgnash.engine.SecurityHistoryEvent; import jgnash.engine.SecurityHistoryNode; import jgnash.engine.SecurityNode; import jgnash.util.NotNull; /** * Commodity DAO Interface. * * @author Craig Cavanaugh */ public interface CommodityDAO extends DAO { boolean addCommodity(CommodityNode node); /** * Call after a {@code ExchangeRateHistoryNode} has been added. This pushes the update * to the underlying database * @param rate ExchangeRate to update * * @return true if successful */ boolean addExchangeRateHistory(final ExchangeRate rate); /** * Call after a {@code SecurityHistoryNode} has been added. This pushes the update * to the underlying database * @param node {@code SecurityNode} to update * @param historyNode {@code SecurityHistoryNode to add} * * @return true if successful */ boolean addSecurityHistory(final SecurityNode node, final SecurityHistoryNode historyNode); /** * Call after a {@code SecurityHistoryEvent} has been added. This pushes the update * to the underlying database * @param node {@code SecurityNode} to update * @param historyEvent {@code SecurityHistoryEvent to add} * * @return true if successful */ boolean addSecurityHistoryEvent(final SecurityNode node, final SecurityHistoryEvent historyEvent); /** * Returns the active currencies. * * @return set of active currencies */ Set getActiveCurrencies(); List getCurrencies(); CurrencyNode getCurrencyByUuid(final UUID uuid); SecurityNode getSecurityByUuid(final UUID uuid); ExchangeRate getExchangeNode(final String rateId); ExchangeRate getExchangeRateByUuid(final UUID uuid); @NotNull List getSecurities(); /** * Call after a {@code ExchangeRateHistoryNode} has been removed. This pushes the update * to the underlying database * @param rate ExchangeRate to update * * @return true if successful */ boolean removeExchangeRateHistory(final ExchangeRate rate); /** * Call after a {@code SecurityHistoryNode} has been removed. This pushes the update * to the underlying database * @param node {@code SecurityNode} to update * @param historyNode {@code SecurityHistoryNode} to remove * * @return true if successful */ boolean removeSecurityHistory(final SecurityNode node, final SecurityHistoryNode historyNode); /** * Call after a {@code SecurityHistoryEvent} has been removed. This pushes the update * to the underlying database * @param node {@code SecurityNode} to update * @param historyEvent {@code SecurityHistoryEvent} to remove * * @return true if successful */ boolean removeSecurityHistoryEvent(final SecurityNode node, final SecurityHistoryEvent historyEvent); void addExchangeRate(ExchangeRate eRate); boolean updateCommodityNode(final CommodityNode node); List getExchangeRates(); } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/dao/ConfigDAO.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.dao; import jgnash.engine.Config; /** * Configuration DAO Interface. * * @author Craig Cavanaugh */ public interface ConfigDAO extends DAO { Config getDefaultConfig(); void update(Config config); } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/dao/DAO.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.dao; import java.util.UUID; /** * Base DAO Interface. * * @author Craig Cavanaugh */ public interface DAO { T getObjectByUuid(final Class tClass, final UUID uuid); boolean isDirty(); } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/dao/EngineDAO.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.dao; import java.util.List; import jgnash.engine.StoredObject; /** * Engine DAO Interface. * * @author Craig Cavanaugh */ public interface EngineDAO extends DAO { AccountDAO getAccountDAO(); BudgetDAO getBudgetDAO(); CommodityDAO getCommodityDAO(); ConfigDAO getConfigDAO(); RecurringDAO getRecurringDAO(); TagDAO getTagDAO(); TransactionDAO getTransactionDAO(); TrashDAO getTrashDAO(); List getStoredObjects(); List getStoredObjects(Class tClass); /** * Force the object to be reloaded from the underlying database. *

* Intended for client / server use. * * @param object object to refresh */ void refresh(StoredObject object); /** * Allows for a bulk update of StoredObjects *

* This is intended for in place data updates and use should be minimal * * @param objectList list of {@code StoredObject} to update */ void bulkUpdate(List objectList); void shutdown(); default boolean isRemote() { return false; } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/dao/RecurringDAO.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.dao; import java.util.List; import java.util.UUID; import jgnash.engine.recurring.Reminder; /** * Reminder DAO Interface. * * @author Craig Cavanaugh */ public interface RecurringDAO extends DAO { List getReminderList(); boolean addReminder(Reminder reminder); Reminder getReminderByUuid(final UUID uuid); boolean updateReminder(Reminder reminder); } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/dao/TagDAO.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.dao; import java.util.Set; import jgnash.engine.Tag; /** * Tag DAO. * * @author Craig Cavanaugh */ public interface TagDAO extends DAO { boolean add(Tag tag); boolean update(Tag tag); Set getTags(); } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/dao/TransactionDAO.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.dao; import java.util.List; import java.util.UUID; import jgnash.engine.Transaction; /** * Transaction DAO Interface. * * @author Craig Cavanaugh */ public interface TransactionDAO extends DAO { /** * Returns a list of transactions. * * @return List of transactions */ List getTransactions(); boolean addTransaction(Transaction transaction); Transaction getTransactionByUuid(final UUID uuid); boolean removeTransaction(Transaction transaction); /** * Returns a list of transactions with external links. * * @return List of transactions */ List getTransactionsWithAttachments(); } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/dao/TrashDAO.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.dao; import java.util.List; import jgnash.engine.TrashObject; /** * Trash DAO Interface. * * @author Craig Cavanaugh */ public interface TrashDAO extends DAO { List getTrashObjects(); void add(TrashObject trashObject); void remove(TrashObject trashObject); void addEntityTrash(Object entity); } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/jpa/AbstractJpaDAO.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.jpa; import java.util.ConcurrentModificationException; import java.util.List; import java.util.Objects; import java.util.UUID; import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.ReentrantLock; import java.util.logging.Level; import java.util.logging.Logger; import java.util.stream.Collectors; import javax.persistence.EntityManager; import javax.persistence.NoResultException; import javax.persistence.PersistenceException; import javax.persistence.TypedQuery; import javax.persistence.criteria.CriteriaBuilder; import javax.persistence.criteria.CriteriaQuery; import jgnash.engine.StoredObject; import jgnash.engine.concurrent.PriorityThreadPoolExecutor; import jgnash.engine.dao.AbstractDAO; import jgnash.engine.dao.DAO; import jgnash.util.DefaultDaemonThreadFactory; import jgnash.util.NotNull; import static jgnash.util.LogUtil.logSevere; /** * Abstract JPA DAO. Provides basic framework to work with the {@link EntityManager} in a thread safe manner. * * @author Craig Cavanaugh */ abstract class AbstractJpaDAO extends AbstractDAO implements DAO { /** * The {@link EntityManager} is not thread safe. All interaction should be wrapped with this lock */ static final ReentrantLock emLock = new ReentrantLock(); /** * This ExecutorService is to be used whenever the entity manager is * accessed because the EntityManager is not thread safe, but we want to return from some methods without blocking */ static PriorityThreadPoolExecutor executorService = new PriorityThreadPoolExecutor(new DefaultDaemonThreadFactory("JPA Priority Executor")); /** * Entity manager reference. */ final EntityManager em; /** * Remote connection if {@code true}. */ final boolean isRemote; AbstractJpaDAO(final EntityManager entityManager, final boolean isRemote) { Objects.requireNonNull(entityManager); this.isRemote = isRemote; em = entityManager; } static void shutDownExecutor() { // Stop the shared executor server, wait for all tasks to complete emLock.lock(); try { executorService.shutdown(); executorService.awaitTermination(Long.MAX_VALUE, TimeUnit.MILLISECONDS); // Regenerate the executor service executorService = new PriorityThreadPoolExecutor(); } catch (final InterruptedException e) { logSevere(AbstractJpaDAO.class, e); Thread.currentThread().interrupt(); } finally { emLock.unlock(); } } /** * Returns a list of objects that are assignable from from the specified Class. *

* Objects marked for removal are not included * * @param clazz the Class to query for * @param the type of class to query * @return A list of type T containing objects of type clazz */ @NotNull public List query(final Class clazz) { try { final Future> future = executorService.submit(() -> { emLock.lock(); try { final CriteriaBuilder cb = em.getCriteriaBuilder(); final CriteriaQuery cq = cb.createQuery(clazz); cq.from(clazz); final TypedQuery query = em.createQuery(cq); // filtering though the stream is not has fast as performing the filter within the query, but it // ensures a ConcurrentModificationException is not thrown in rare circumstances by iterating. return query.getResultStream().filter(t -> !t.isMarkedForRemoval()).collect(Collectors.toList()); } catch (final ConcurrentModificationException | PersistenceException | IllegalStateException e1) { logSevere(AbstractJpaDAO.class, e1); return null; } finally { emLock.unlock(); } }); return future.get(); // block and return } catch (final InterruptedException | ExecutionException e) { logSevere(AbstractJpaDAO.class, e); Thread.currentThread().interrupt(); return null; } } /** * Merge / Update the object in place. * * @param object {@link StoredObject} to merge * @param the type of the value being merged * @return the merged object or null if an error occurred */ T merge(final T object) { try { final Future future = executorService.submit(() -> { emLock.lock(); try { em.getTransaction().begin(); T mergedObject = em.merge(object); em.getTransaction().commit(); dirtyFlag.set(true); return mergedObject; } catch (final PersistenceException | IllegalStateException e1) { logSevere(AbstractJpaDAO.class, e1); return null; } finally { emLock.unlock(); } }); return future.get(); // block and return } catch (final InterruptedException | ExecutionException e) { logSevere(AbstractJpaDAO.class, e); Thread.currentThread().interrupt(); return null; } } /** * Persists an object. * * @param objects {@link Object} to persist * @return {@code true} if successful, {@code false} otherwise */ boolean persist(final Object... objects) { boolean result = false; try { final Future future = executorService.submit(() -> { emLock.lock(); try { em.getTransaction().begin(); for (final Object object : objects) { em.persist(object); } em.getTransaction().commit(); dirtyFlag.set(true); return true; } catch (final PersistenceException | IllegalStateException e1) { logSevere(AbstractJpaDAO.class, e1); return false; } finally { emLock.unlock(); } }); try { result = future.get(); // block and return } catch (final InterruptedException ie) { Logger.getLogger(AbstractJpaDAO.class.getName()).log(Level.INFO, "Interrupted while waiting for result"); result = false; } } catch (final ExecutionException e2) { logSevere(AbstractJpaDAO.class, e2); Thread.currentThread().interrupt(); } return result; } @Override public T getObjectByUuid(final Class tClass, final UUID uuid) { T object = null; try { final Future future = executorService.submit(() -> { emLock.lock(); try { return em.find(tClass, uuid); } finally { emLock.unlock(); } }); object = future.get(); // block and return } catch (final NoResultException e) { Logger.getLogger(AbstractJpaDAO.class.getName()).log(Level.INFO, "Did not find {0} for uuid: {1}", new Object[]{tClass.getName(), uuid}); } catch (ExecutionException | InterruptedException e) { logSevere(AbstractJpaDAO.class, e); Thread.currentThread().interrupt(); } return object; } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/jpa/AbstractJpaDataStore.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.jpa; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.sql.Connection; import java.sql.DriverManager; import java.sql.PreparedStatement; import java.sql.SQLException; import java.util.ArrayList; import java.util.Collection; import java.util.List; import java.util.Properties; import java.util.function.DoubleConsumer; import java.util.logging.Level; import java.util.logging.Logger; import javax.persistence.EntityManager; import javax.persistence.EntityManagerFactory; import javax.persistence.Persistence; import jgnash.engine.DataStore; import jgnash.engine.Engine; import jgnash.engine.EngineFactory; import jgnash.engine.StoredObject; import jgnash.engine.attachment.DistributedAttachmentManager; import jgnash.engine.attachment.LocalAttachmentManager; import jgnash.engine.concurrent.DistributedLockManager; import jgnash.engine.concurrent.LocalLockManager; import jgnash.util.FileUtils; import org.apache.commons.collections4.ListUtils; import org.apache.commons.math3.util.Precision; /** * Abstract JPA DataStore. * * @author Craig Cavanaugh */ abstract class AbstractJpaDataStore implements DataStore { private static final String SHUTDOWN = "SHUTDOWN"; private static final int PARTITION_SIZE = 200; private EntityManager em; private EntityManagerFactory factory; private DistributedLockManager distributedLockManager; private DistributedAttachmentManager distributedAttachmentManager; private boolean local = true; private String fileName; private static final boolean DEBUG = false; private char[] password; static final Logger logger = Logger.getLogger(AbstractJpaDataStore.class.getName()); private void waitForLockFileRelease(final String fileName, final char[] password) { // Explicitly force the database closed, Required for hsqldb and h2 SqlUtils.waitForLockFileRelease(getType(), fileName, getLockFileExtension(), password); } @Override public void closeEngine() { logger.info("Closing"); if (em != null && factory != null) { em.close(); factory.close(); } else { logger.severe("The EntityManger was already null!"); } if (local) { waitForLockFileRelease(fileName, password); } else { distributedLockManager.disconnectFromServer(); distributedAttachmentManager.disconnectFromServer(); } } @Override public Engine getClientEngine(final String host, final int port, final char[] password, final String dataBasePath) { final Properties properties = JpaConfiguration.getClientProperties(getType(), dataBasePath, host, port, password); Engine engine = null; try { if (SqlUtils.isConnectionValid(properties.getProperty(JpaConfiguration.JAVAX_PERSISTENCE_JDBC_URL))) { factory = Persistence.createEntityManagerFactory(JpaConfiguration.UNIT_NAME, properties); em = factory.createEntityManager(); if (em != null) { distributedLockManager = new DistributedLockManager(host, port + JpaNetworkServer.LOCK_SERVER_INCREMENT); boolean lockManagerResult = distributedLockManager.connectToServer(password); distributedAttachmentManager = new DistributedAttachmentManager(host, port + JpaNetworkServer.TRANSFER_SERVER_INCREMENT); boolean attachmentManagerResult = distributedAttachmentManager.connectToServer(password); if (attachmentManagerResult && lockManagerResult) { engine = new Engine(new JpaEngineDAO(em, true), distributedLockManager, distributedAttachmentManager, EngineFactory.DEFAULT); logger.info("Created local JPA container and engine"); fileName = null; local = false; } else { distributedLockManager.disconnectFromServer(); distributedAttachmentManager.disconnectFromServer(); em.close(); factory.close(); em = null; factory = null; } } } } catch (final Exception e) { logger.log(Level.SEVERE, e.toString(), e); } return engine; } @Override public Engine getLocalEngine(final String fileName, final String engineName, final char[] password) { Properties properties = JpaConfiguration.getLocalProperties(getType(), fileName, password, false); Engine engine = null; if (DEBUG) { System.out.println(FileUtils.stripFileExtension(fileName)); } if (!exists(fileName) && !initEmptyDatabase(fileName)) { return null; } try { if (!FileUtils.isFileLocked(fileName)) { try { float fileVersion = SqlUtils.getFileVersion(fileName, password); if (Precision.equals(fileVersion, 3.5f)) { SqlUtils.dropColumn(fileName, password, "TAG", "SHAPE", "ICONSET"); logger.info("Dropped old TAG columns"); } /* specifies the unit name and properties. Unit name can be used to specify a different persistence unit defined in persistence.xml */ factory = Persistence.createEntityManagerFactory(JpaConfiguration.UNIT_NAME, properties); em = factory.createEntityManager(); logger.info("Created local JPA container and engine"); engine = new Engine(new JpaEngineDAO(em, false), new LocalLockManager(), new LocalAttachmentManager(), engineName); this.fileName = fileName; this.password = password.clone(); // clone to protect against side effects local = true; } catch (final Exception e) { logger.log(Level.SEVERE, e.getMessage(), e); } } } catch (final IOException e) { logger.info(e.getLocalizedMessage()); } return engine; } @Override public String getFileName() { return fileName; } @Override public boolean isLocal() { return local; } @Override public void saveAs(final Path path, final Collection objects, final DoubleConsumer percentComplete) { final int collectionSize = objects.size(); // Remove the existing files so we don't mix entities and cause corruption if (Files.exists(path)) { deleteDatabase(path.toString()); } if (initEmptyDatabase(path.toString())) { final Properties properties = JpaConfiguration.getLocalProperties(getType(), path.toString(), new char[]{}, false); EntityManagerFactory emFactory = null; EntityManager entityManager = null; try { emFactory = Persistence.createEntityManagerFactory(JpaConfiguration.UNIT_NAME, properties); entityManager = emFactory.createEntityManager(); final List> partitions = ListUtils.partition(new ArrayList<>(objects), PARTITION_SIZE); int writeCount = 0; for (final List partition : partitions) { entityManager.getTransaction().begin(); for (final StoredObject o : partition) { entityManager.persist(o); writeCount++; percentComplete.accept((double) writeCount / (double) collectionSize); } entityManager.getTransaction().commit(); } } catch (final Exception e) { logger.log(Level.SEVERE, e.getMessage(), e); } finally { if (entityManager != null) { entityManager.close(); } if (emFactory != null) { emFactory.close(); } } waitForLockFileRelease(path.toString(), new char[]{}); } } /** * Returns the string representation of this {@code DataStore}. * * @return string representation of this {@code DataStore}. */ @Override public String toString() { return getType().toString(); } private boolean exists(final String fileName) { return Files.exists(Paths.get(FileUtils.stripFileExtension(fileName) + getFileExt())); } /** * Opens and closes the database in order to create a new file. * * @param fileName database file * @return {@code true} if successful */ private boolean initEmptyDatabase(final String fileName) { boolean result = false; final Properties properties = JpaConfiguration.getLocalProperties(getType(), fileName, EngineFactory.EMPTY_PASSWORD, false); final String url = properties.getProperty(JpaConfiguration.JAVAX_PERSISTENCE_JDBC_URL); try (final Connection connection = DriverManager.getConnection(url)) { // absolutely required for a correct shutdown try (final PreparedStatement statement = connection.prepareStatement(SHUTDOWN)) { statement.execute(); } result = true; } catch (final SQLException e) { logger.log(Level.SEVERE, e.getMessage(), e); } waitForLockFileRelease(fileName, EngineFactory.EMPTY_PASSWORD); logger.log(Level.INFO, "Initialized an empty database for {0}", fileName); return result; } /** * Deletes a database and associated files and directories. * * @param fileName one of the primary database files */ protected abstract void deleteDatabase(final String fileName); /** * Return the extension used by the lock file with the preceding period. * * @return lock file extension */ protected abstract String getLockFileExtension(); } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/jpa/JpaAccountDAO.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.jpa; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.UUID; import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; import java.util.logging.Level; import java.util.logging.Logger; import java.util.stream.Collectors; import javax.persistence.EntityManager; import javax.persistence.TypedQuery; import jgnash.engine.Account; import jgnash.engine.AccountGroup; import jgnash.engine.AccountType; import jgnash.engine.RootAccount; import jgnash.engine.SecurityNode; import jgnash.engine.dao.AccountDAO; import jgnash.util.LogUtil; /** * Account Jpa DAO. * * @author Craig Cavanaugh */ class JpaAccountDAO extends AbstractJpaDAO implements AccountDAO { private static final Logger logger = Logger.getLogger(JpaAccountDAO.class.getName()); JpaAccountDAO(final EntityManager entityManager, final boolean isRemote) { super(entityManager, isRemote); } /* * @see jgnash.engine.AccountDAOInterface#getRootAccount() */ @Override public RootAccount getRootAccount() { RootAccount root = null; try { final Future future = executorService.submit(() -> { emLock.lock(); try { final TypedQuery q = em.createQuery("select a from RootAccount a", RootAccount.class); final List list = q.getResultList(); if (list.size() == 1) { return list.get(0); } else if (list.size() > 1) { LogUtil.logSevere(JpaAccountDAO.class, new Exception("More than one RootAccount was found: " + list.size())); for (final RootAccount rootAccount : list) { if (rootAccount.getChildCount() > 0) { return rootAccount; } } } return null; } finally { emLock.unlock(); } }); root = future.get(); // block and return } catch (final ExecutionException | InterruptedException e) { logger.log(Level.SEVERE, e.getLocalizedMessage(), e); } return root; } /* * @see jgnash.engine.AccountDAOInterface#getAccountList() */ @Override public List getAccountList() { return query(Account.class); } /* * @see jgnash.engine.AccountDAOInterface#addAccount(jgnash.engine.Account, jgnash.engine.Account) */ @Override public boolean addAccount(final Account parent, final Account child) { boolean result = false; try { final Future future = executorService.submit(() -> { emLock.lock(); try { em.getTransaction().begin(); em.persist(child); em.merge(parent); em.getTransaction().commit(); dirtyFlag.set(true); return true; } finally { emLock.unlock(); } }); result = future.get(); } catch (final ExecutionException | InterruptedException e) { logger.log(Level.SEVERE, e.getLocalizedMessage(), e); } return result; } /* * @see jgnash.engine.AccountDAOInterface#addRootAccount(jgnash.engine.RootAccount) */ @Override public boolean addRootAccount(final RootAccount account) { return persist(account); } /* * @see jgnash.engine.AccountDAOInterface#addAccountSecurity(jgnash.engine.Account, jgnash.engine.SecurityNode) */ @Override public boolean addAccountSecurity(final Account account, final SecurityNode node) { return merge(account) != null; } private List getAccountList(final AccountType type) { List accountList = Collections.emptyList(); try { final Future> future = executorService.submit(() -> { emLock.lock(); try { final String queryString = "SELECT a FROM Account a WHERE a.accountType = :type AND a.markedForRemoval = false"; final TypedQuery query = em.createQuery(queryString, Account.class); query.setParameter("type", type); return new ArrayList<>(query.getResultList()); } finally { emLock.unlock(); } }); accountList = future.get(); } catch (final InterruptedException | ExecutionException e) { logger.log(Level.SEVERE, e.getLocalizedMessage(), e); } return accountList; } /* * @see jgnash.engine.AccountDAOInterface#getIncomeAccountList() */ @Override public List getIncomeAccountList() { return getAccountList(AccountType.INCOME); } /* * @see jgnash.engine.AccountDAOInterface#getExpenseAccountList() */ @Override public List getExpenseAccountList() { return getAccountList(AccountType.EXPENSE); } /* * @see jgnash.engine.AccountDAOInterface#getInvestmentAccountList() */ @Override public List getInvestmentAccountList() { // do not use a parallel stream, result list is not thread safe return query(Account.class).parallelStream().filter(a -> a.memberOf(AccountGroup.INVEST)) .collect(Collectors.toList()); } /* * @see jgnash.engine.AccountDAOInterface#getAccountByUuid(java.util.UUID) */ @Override public Account getAccountByUuid(final UUID uuid) { return getObjectByUuid(Account.class, uuid); } /* * @see jgnash.engine.AccountDAOInterface#updateAccount(jgnash.engine.Account) */ @Override public boolean updateAccount(final Account account) { return merge(account) != null; } /* * @see jgnash.engine.dao.AccountDAO#toggleAccountVisibility(jgnash.engine.Account) */ @Override public boolean toggleAccountVisibility(final Account account) { return merge(account) != null; } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/jpa/JpaBudgetDAO.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.jpa; import java.util.List; import java.util.UUID; import javax.persistence.EntityManager; import jgnash.engine.budget.Budget; import jgnash.engine.dao.BudgetDAO; /** * Budget DAO. * * @author Craig Cavanaugh */ class JpaBudgetDAO extends AbstractJpaDAO implements BudgetDAO { JpaBudgetDAO(final EntityManager entityManager, final boolean isRemote) { super(entityManager, isRemote); } @Override public boolean add(final Budget budget) { return persist(budget); } @Override public boolean update(final Budget budget) { return merge(budget) != null; } @Override public List getBudgets() { return query(Budget.class); } @Override public Budget getBudgetByUuid(final UUID uuid) { return getObjectByUuid(Budget.class, uuid); } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/jpa/JpaCommodityDAO.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.jpa; import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Set; import java.util.UUID; import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; import java.util.logging.Level; import java.util.logging.Logger; import java.util.stream.Collectors; import javax.persistence.EntityManager; import javax.persistence.TypedQuery; import jgnash.engine.Account; import jgnash.engine.CommodityNode; import jgnash.engine.CurrencyNode; import jgnash.engine.ExchangeRate; import jgnash.engine.SecurityHistoryEvent; import jgnash.engine.SecurityHistoryNode; import jgnash.engine.SecurityNode; import jgnash.engine.dao.CommodityDAO; import jgnash.util.NotNull; /** * Commodity DAO. * * @author Craig Cavanaugh */ class JpaCommodityDAO extends AbstractJpaDAO implements CommodityDAO { private static final Logger logger = Logger.getLogger(JpaCommodityDAO.class.getName()); JpaCommodityDAO(final EntityManager entityManager, final boolean isRemote) { super(entityManager, isRemote); } /* * @see jgnash.engine.CommodityDAOInterface#addCurrency(jgnash.engine.CommodityNode) */ @Override public boolean addCommodity(final CommodityNode node) { return persist(node); } @Override public boolean addSecurityHistory(final SecurityNode node, final SecurityHistoryNode historyNode) { return persist(historyNode, node); } @Override public boolean addSecurityHistoryEvent(final SecurityNode node, final SecurityHistoryEvent historyEvent) { return persist(historyEvent, node); } @Override public boolean removeSecurityHistory(final SecurityNode node, final SecurityHistoryNode historyNode) { return persist(node, historyNode); } @Override public boolean removeSecurityHistoryEvent(final SecurityNode node, final SecurityHistoryEvent historyEvent) { return persist(node, historyEvent); } @Override public boolean addExchangeRateHistory(final ExchangeRate rate) { return merge(rate) != null; } @Override public boolean removeExchangeRateHistory(final ExchangeRate rate) { return merge(rate) != null; } /* * @see jgnash.engine.CommodityDAOInterface#getCurrencies() */ @Override public List getCurrencies() { return query(CurrencyNode.class); } @Override public CurrencyNode getCurrencyByUuid(final UUID uuid) { return getObjectByUuid(CurrencyNode.class, uuid); } /* * @see jgnash.engine.CommodityDAOInterface#getExchangeNode(java.lang.String) */ @Override public ExchangeRate getExchangeNode(final String rateId) { ExchangeRate exchangeRate = null; for (final ExchangeRate rate : query(ExchangeRate.class)) { if (rate.getRateId().equals(rateId)) { exchangeRate = rate; break; } } return exchangeRate; } @Override public ExchangeRate getExchangeRateByUuid(final UUID uuid) { return getObjectByUuid(ExchangeRate.class, uuid); } @Override public SecurityNode getSecurityByUuid(final UUID uuid) { return getObjectByUuid(SecurityNode.class, uuid); } /* * @see jgnash.engine.dao.CommodityDAO#getSecurities() */ @Override @NotNull public List getSecurities() { return query(SecurityNode.class); } @Override public List getExchangeRates() { return query(ExchangeRate.class); } /* * @see jgnash.engine.CommodityDAOInterface#setExchangeRate(jgnash.engine.ExchangeRate) */ @Override public void addExchangeRate(final ExchangeRate eRate) { persist(eRate); } /* * @see jgnash.engine.CommodityDAOInterface#updateCommodityNode(jgnash.engine.CommodityNode) */ @Override public boolean updateCommodityNode(final CommodityNode node) { return merge(node) != null; } /* * @see jgnash.engine.CommodityDAOInterface#getActiveAccountCommodities() */ @Override public Set getActiveCurrencies() { Set currencyNodeSet = Collections.emptySet(); try { Future> future = executorService.submit(() -> { emLock.lock(); try { final TypedQuery q = em.createQuery("SELECT a FROM Account a WHERE a.markedForRemoval = false", Account.class); final List accountList = q.getResultList(); final Set currencies = new HashSet<>(); for (final Account account : accountList) { currencies.add(account.getCurrencyNode()); currencies.addAll(account.getSecurities().parallelStream() .map(SecurityNode::getReportedCurrencyNode).collect(Collectors.toList())); } return currencies; } finally { emLock.unlock(); } }); currencyNodeSet = future.get(); } catch (final InterruptedException | ExecutionException e) { logger.log(Level.SEVERE, e.getLocalizedMessage(), e); } return currencyNodeSet; } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/jpa/JpaConfigDAO.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.jpa; import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; import java.util.logging.Level; import java.util.logging.Logger; import javax.persistence.EntityManager; import javax.persistence.TypedQuery; import javax.persistence.criteria.CriteriaBuilder; import javax.persistence.criteria.CriteriaQuery; import javax.persistence.criteria.Root; import jgnash.engine.Config; import jgnash.engine.dao.ConfigDAO; /**. * Config DAO * * @author Craig Cavanaugh */ class JpaConfigDAO extends AbstractJpaDAO implements ConfigDAO { private static final Logger logger = Logger.getLogger(JpaConfigDAO.class.getName()); JpaConfigDAO(final EntityManager entityManager, final boolean isRemote) { super(entityManager, isRemote); } /* * @see jgnash.engine.ConfigDAOInterface#getDefaultConfig() */ @Override public synchronized Config getDefaultConfig() { Config defaultConfig = null; try { Future future = executorService.submit(() -> { emLock.lock(); try { Config newConfig; try { final CriteriaBuilder cb = em.getCriteriaBuilder(); final CriteriaQuery cq = cb.createQuery(Config.class); final Root root = cq.from(Config.class); cq.select(root); final TypedQuery q = em.createQuery(cq); newConfig = q.getSingleResult(); } catch (final Exception e) { newConfig = new Config(); em.getTransaction().begin(); em.persist(newConfig); em.getTransaction().commit(); logger.info("Generating new default config"); } return newConfig; } finally { emLock.unlock(); } }); defaultConfig = future.get(); } catch (final InterruptedException | ExecutionException e) { logger.log(Level.SEVERE, e.getLocalizedMessage(), e); } return defaultConfig; } @Override public void update(final Config config) { persist(config); } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/jpa/JpaConfiguration.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.jpa; import java.util.Objects; import java.util.Properties; import java.util.logging.Logger; import jgnash.engine.DataStoreType; import jgnash.util.FileUtils; /** * Utility class to help with JPA configuration. * * @author Craig Cavanaugh */ class JpaConfiguration { private JpaConfiguration() { // utility class } static final String UNIT_NAME = "jgnash"; static final String DEFAULT_USER = "JGNASH"; static final String JAVAX_PERSISTENCE_JDBC_URL = "javax.persistence.jdbc.url"; private static final String JAVAX_PERSISTENCE_JDBC_DRIVER = "javax.persistence.jdbc.driver"; private static final String JAVAX_PERSISTENCE_JDBC_USER = "javax.persistence.jdbc.user"; private static final String JAVAX_PERSISTENCE_JDBC_PASSWORD = "javax.persistence.jdbc.password"; private static final String HIBERNATE_DIALECT = "hibernate.dialect"; private static final String HIBERNATE_HBM2DDL_AUTO = "hibernate.hbm2ddl.auto"; private static final String UNKNOWN_DATABASE_TYPE = "Unknown database type"; private static Properties getBaseProperties(final DataStoreType database) { Properties properties = System.getProperties(); properties.setProperty(HIBERNATE_HBM2DDL_AUTO, "update"); switch (database) { case H2_DATABASE: case H2MV_DATABASE: properties.setProperty(JAVAX_PERSISTENCE_JDBC_DRIVER, "org.h2.Driver"); properties.setProperty(HIBERNATE_DIALECT, "org.hibernate.dialect.H2Dialect"); break; case HSQL_DATABASE: properties.setProperty(JAVAX_PERSISTENCE_JDBC_DRIVER, "org.hsqldb.jdbc.JDBCDriver"); properties.setProperty(HIBERNATE_DIALECT, "org.hibernate.dialect.HSQLDialect"); break; default: throw new RuntimeException(UNKNOWN_DATABASE_TYPE); } return properties; } static Properties getLocalProperties(final DataStoreType dataStoreType, final String fileName, final char[] password, final boolean readOnly) { Objects.requireNonNull(password); Objects.requireNonNull(dataStoreType); final StringBuilder urlBuilder = new StringBuilder(); switch (dataStoreType) { case H2_DATABASE: case H2MV_DATABASE: // //urlBuilder.append("jdbc:h2:nio:"); urlBuilder.append("jdbc:h2:async:"); urlBuilder.append(FileUtils.stripFileExtension(fileName)); urlBuilder.append(";USER=").append(DEFAULT_USER); if (password.length > 0) { urlBuilder.append(";PASSWORD=").append(password); } // use the old 1.3 page storage format instead of the MVStore based on file extension. This allows // for correct handling of old files without forcing an upgrade if (fileName.endsWith(JpaH2DataStore.H2_FILE_EXT)) { urlBuilder.append(";MV_STORE=FALSE"); } else { urlBuilder.append(";COMPRESS=TRUE;FILE_LOCK=FILE"); // do not use FS locking for } if (readOnly) { urlBuilder.append(";ACCESS_MODE_DATA=r"); } urlBuilder.append(";TRACE_LEVEL_SYSTEM_OUT=1"); // make sure errors are logged to the console break; case HSQL_DATABASE: urlBuilder.append("jdbc:hsqldb:file:"); urlBuilder.append(FileUtils.stripFileExtension(fileName)); urlBuilder.append(";user=").append(DEFAULT_USER); if (password.length > 0) { urlBuilder.append(";password=").append(password); } if (readOnly) { urlBuilder.append(";readonly=true"); } break; default: throw new RuntimeException(UNKNOWN_DATABASE_TYPE); } final Properties properties = getBaseProperties(dataStoreType); properties.setProperty(JAVAX_PERSISTENCE_JDBC_URL, urlBuilder.toString()); properties.setProperty(JAVAX_PERSISTENCE_JDBC_USER, DEFAULT_USER); properties.setProperty(JAVAX_PERSISTENCE_JDBC_PASSWORD, new String(password)); return properties; } /** * Generates and a JPA properties to connect to a remote database. * * @param dataStoreType DataStoreType type * @param fileName remote file to connect to, ignored for HSQL_DATABASE connections * @param host remote host * @param port remote port * @param password database password * @return JPA properties */ static Properties getClientProperties(final DataStoreType dataStoreType, final String fileName, final String host, final int port, final char[] password) { Objects.requireNonNull(password); Objects.requireNonNull(dataStoreType); final StringBuilder urlBuilder = new StringBuilder(); final Properties properties = getBaseProperties(dataStoreType); switch (dataStoreType) { case H2_DATABASE: case H2MV_DATABASE: urlBuilder.append("jdbc:h2"); /*boolean useSSL = Boolean.parseBoolean(properties.getProperty(EncryptionManager.ENCRYPTION_FLAG)); if (useSSL) { urlBuilder.append(":ssl://"); } else { urlBuilder.append(":tcp://"); }*/ urlBuilder.append(":tcp://"); urlBuilder.append(host).append(":").append(port).append("/"); urlBuilder.append(FileUtils.stripFileExtension(fileName)); urlBuilder.append(";USER=").append(DEFAULT_USER); urlBuilder.append(";PASSWORD=").append(password); break; case HSQL_DATABASE: urlBuilder.append("jdbc:hsqldb:hsql://"); urlBuilder.append(host).append(":").append(port).append("/jgnash"); // needs a public alias urlBuilder.append(";user=").append(DEFAULT_USER); if (password.length > 0) { urlBuilder.append(";password=").append(password); } Logger.getLogger(JpaConfiguration.class.getName()).info(urlBuilder.toString()); break; default: throw new RuntimeException(UNKNOWN_DATABASE_TYPE); } properties.setProperty(JAVAX_PERSISTENCE_JDBC_USER, DEFAULT_USER); properties.setProperty(JAVAX_PERSISTENCE_JDBC_PASSWORD, new String(password)); properties.setProperty(JAVAX_PERSISTENCE_JDBC_URL, urlBuilder.toString()); return properties; } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/jpa/JpaEngineDAO.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.jpa; import java.util.List; import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; import javax.persistence.EntityManager; import jgnash.engine.StoredObject; import jgnash.engine.dao.AccountDAO; import jgnash.engine.dao.BudgetDAO; import jgnash.engine.dao.CommodityDAO; import jgnash.engine.dao.ConfigDAO; import jgnash.engine.dao.EngineDAO; import jgnash.engine.dao.RecurringDAO; import jgnash.engine.dao.TagDAO; import jgnash.engine.dao.TransactionDAO; import jgnash.engine.dao.TrashDAO; import static jgnash.util.LogUtil.logSevere; /** * Engine DAO. * * @author Craig Cavanaugh */ class JpaEngineDAO extends AbstractJpaDAO implements EngineDAO { private AccountDAO accountDAO; private BudgetDAO budgetDAO; private CommodityDAO commodityDAO; private ConfigDAO configDAO; private RecurringDAO recurringDAO; private TagDAO tagDAO; private TransactionDAO transactionDAO; private TrashDAO trashDAO; JpaEngineDAO(final EntityManager entityManager, final boolean isRemote) { super(entityManager, isRemote); } @Override public synchronized void shutdown() { emLock.lock(); try { // Stop the trash executor service ((JpaTrashDAO) getTrashDAO()).stopTrashExecutor(); // Stop the shared executor service, wait for all tasks to complete and reset shutDownExecutor(); } finally { emLock.unlock(); } } @Override public synchronized AccountDAO getAccountDAO() { if (accountDAO == null) { accountDAO = new JpaAccountDAO(em, isRemote); } return accountDAO; } @Override public BudgetDAO getBudgetDAO() { if (budgetDAO == null) { budgetDAO = new JpaBudgetDAO(em, isRemote); } return budgetDAO; } @Override public synchronized CommodityDAO getCommodityDAO() { if (commodityDAO == null) { commodityDAO = new JpaCommodityDAO(em, isRemote); } return commodityDAO; } @Override public synchronized ConfigDAO getConfigDAO() { if (configDAO == null) { configDAO = new JpaConfigDAO(em, isRemote); } return configDAO; } @Override public synchronized RecurringDAO getRecurringDAO() { if (recurringDAO == null) { recurringDAO = new JpaRecurringDAO(em, isRemote); } return recurringDAO; } @Override public TagDAO getTagDAO() { if (tagDAO == null) { tagDAO = new JpaTagDAO(em, isRemote); } return tagDAO; } @Override public synchronized TransactionDAO getTransactionDAO() { if (transactionDAO == null) { transactionDAO = new JpaTransactionDAO(em, isRemote); } return transactionDAO; } @Override public synchronized TrashDAO getTrashDAO() { if (trashDAO == null) { trashDAO = new JpaTrashDAO(em, isRemote); } return trashDAO; } @Override public List getStoredObjects() { return query(StoredObject.class); } @Override public List getStoredObjects(final Class tClass) { return query(tClass); } /** * Refresh a managed object. * * @param object object to re */ @Override public void refresh(final StoredObject object) { try { Future future = executorService.submit(() -> { emLock.lock(); try { em.refresh(object); return null; } finally { emLock.unlock(); } }); future.get(); // block } catch (ExecutionException | InterruptedException e) { logSevere(JpaEngineDAO.class, e); } } @Override public void bulkUpdate(final List objectList) { try { final Future future = executorService.submit(() -> { emLock.lock(); try { em.getTransaction().begin(); objectList.forEach(em::persist); em.getTransaction().commit(); return null; } finally { emLock.unlock(); } }); future.get(); // block } catch (final InterruptedException | ExecutionException e) { logSevere(JpaEngineDAO.class, e); } } @Override public boolean isRemote() { return isRemote; } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/jpa/JpaH2DataStore.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.jpa; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Paths; import java.util.logging.Level; import jgnash.engine.DataStoreType; import jgnash.util.FileUtils; import jgnash.util.NotNull; /** * JPA specific code for data storage and creating an engine. * * @author Craig Cavanaugh */ public class JpaH2DataStore extends AbstractJpaDataStore { public static final String H2_FILE_EXT = ".h2.db"; public static final String LOCK_EXT = ".lock.db"; @NotNull @Override public String getFileExt() { return H2_FILE_EXT; } @Override public DataStoreType getType() { return DataStoreType.H2_DATABASE; } @Override protected String getLockFileExtension() { return LOCK_EXT; } @Override public void deleteDatabase(final String fileName) { final String[] extensions = new String[]{getFileExt(), getLockFileExtension()}; final String base = FileUtils.stripFileExtension(fileName); for (final String extension : extensions) { try { logger.log(Level.INFO, "Deleting {0}{1}", new Object[]{base, extension}); Files.deleteIfExists(Paths.get(base + extension)); } catch (final IOException e) { logger.log(Level.SEVERE, e.getMessage(), e); } } } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/jpa/JpaH2MvDataStore.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.jpa; import jgnash.engine.DataStoreType; import jgnash.util.NotNull; /** * JPA specific code for data storage and creating an engine. * * @author Craig Cavanaugh */ public class JpaH2MvDataStore extends JpaH2DataStore { public static final String MV_FILE_EXT = ".mv.db"; @NotNull @Override public String getFileExt() { return MV_FILE_EXT; } @Override public DataStoreType getType() { return DataStoreType.H2MV_DATABASE; } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/jpa/JpaHsqlDataStore.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.jpa; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.logging.Level; import jgnash.engine.DataStoreType; import jgnash.util.FileUtils; import jgnash.util.NotNull; /** * JPA specific code for HSQLDB data storage and creating an engine. * * @author Craig Cavanaugh */ public class JpaHsqlDataStore extends AbstractJpaDataStore { public static final String FILE_EXT = ".script"; public static final String LOCK_EXT = ".lck"; private static final String[] extensions = new String[]{".log", ".properties", FILE_EXT, ".data", ".backup", ".tmp", ".lobs", LOCK_EXT}; @NotNull @Override public String getFileExt() { return FILE_EXT; } @Override public DataStoreType getType() { return DataStoreType.HSQL_DATABASE; } @Override public void rename(final String fileName, final String newFileName) throws IOException { for (final String extension : extensions) { final Path path = Paths.get(FileUtils.stripFileExtension(fileName) + extension); if (Files.exists(path)) { Files.move(path, Paths.get(FileUtils.stripFileExtension(newFileName) + extension)); } } } @Override public String getLockFileExtension() { return LOCK_EXT; } @Override public void deleteDatabase(final String fileName) { final String base = FileUtils.stripFileExtension(fileName); for (final String extension : extensions) { try { Files.deleteIfExists(Paths.get(base + extension)); } catch (final IOException e) { logger.log(Level.SEVERE, e.getMessage(), e); } } } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/jpa/JpaNetworkServer.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2021 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.jpa; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.sql.SQLException; import java.util.ArrayList; import java.util.List; import java.util.Properties; import java.util.concurrent.CountDownLatch; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.logging.Level; import java.util.logging.Logger; import javax.persistence.EntityManager; import javax.persistence.EntityManagerFactory; import javax.persistence.Persistence; import jgnash.engine.AttachmentUtils; import jgnash.engine.DataStoreType; import jgnash.engine.Engine; import jgnash.engine.EngineException; import jgnash.engine.EngineFactory; import jgnash.engine.StoredObject; import jgnash.engine.attachment.AttachmentTransferServer; import jgnash.engine.attachment.DistributedAttachmentManager; import jgnash.engine.concurrent.DistributedLockManager; import jgnash.engine.concurrent.DistributedLockServer; import jgnash.engine.message.LocalServerListener; import jgnash.engine.message.MessageBusServer; import jgnash.util.DefaultDaemonThreadFactory; import jgnash.util.FileMagic; import jgnash.util.FileUtils; import jgnash.util.LogUtil; /** * JPA network server. * * @author Craig Cavanaugh */ public class JpaNetworkServer { public static final String STOP_SERVER_MESSAGE = ""; public static final int MESSAGE_SERVER_INCREMENT = 1; static final int LOCK_SERVER_INCREMENT = 2; static final int TRANSFER_SERVER_INCREMENT = 3; private final CountDownLatch stopLatch = new CountDownLatch(1); private static final int BACKUP_PERIOD = 2; private volatile boolean dirty = false; private EntityManager em; private EntityManagerFactory factory; private DistributedLockManager distributedLockManager; private DistributedAttachmentManager distributedAttachmentManager; public static final int DEFAULT_PORT = 5300; private static final String SERVER_ENGINE = "server"; private static final Logger logger = Logger.getLogger(JpaNetworkServer.class.getName()); private Runnable runningCallback = null; public synchronized void startServer(final String fileName, final int port, final char[] password) throws EngineException { final Path file = Paths.get(fileName); // create the base directory if needed if (!Files.exists(file)) { final Path parent = file.getParent(); if (parent != null && !Files.exists(parent)) { try { Files.createDirectories(parent); } catch (IOException e) { throw new EngineException("Could not create directory for file: " + parent); } } } final FileMagic.FileType type = FileMagic.magic(Paths.get(fileName)); switch (type) { case h2: case h2mv: runH2Server(fileName, port, password); break; case hsql: runHsqldbServer(fileName, port, password); break; default: logger.severe("Not a valid file type for server usage"); } System.exit(0); // force exit } public synchronized void startServer(final String fileName, final int port, final char[] password, final Runnable callback) throws EngineException { this.runningCallback = callback; this.startServer(fileName, port, password); } /** * Starts the server and blocks until it is stopped * * @param dataStoreType datastore type * @param fileName database file name * @param port port * @param password password* * @throws EngineException thrown if engine or buss cannot be created */ private synchronized void run(final DataStoreType dataStoreType, final String fileName, final int port, final char[] password) throws EngineException { final DistributedLockServer distributedLockServer = new DistributedLockServer(port + LOCK_SERVER_INCREMENT); final boolean lockServerStarted = distributedLockServer.startServer(password); final AttachmentTransferServer attachmentTransferServer = new AttachmentTransferServer(port + TRANSFER_SERVER_INCREMENT, AttachmentUtils.getAttachmentDirectory(Paths.get(fileName))); final boolean attachmentServerStarted = attachmentTransferServer.startServer(password); if (attachmentServerStarted && lockServerStarted) { final Engine engine = createEngine(dataStoreType, fileName, port, password); if (engine != null) { // Start the message bus and pass the file name so it can be reported to the client final MessageBusServer messageBusServer = new MessageBusServer(port + MESSAGE_SERVER_INCREMENT); // don't continue if the server is not started successfully if (messageBusServer.startServer(dataStoreType, fileName, password)) { // Start the backup thread that ensures an XML backup is created at set intervals final ScheduledExecutorService backupExecutor = Executors.newSingleThreadScheduledExecutor( new DefaultDaemonThreadFactory("JPA Network Server Executor")); // run commit every backup period after startup backupExecutor.scheduleWithFixedDelay(() -> { if (dirty) { exportXML(engine, fileName); EngineFactory.removeOldCompressedXML(fileName, engine.getRetainedBackupLimit()); dirty = false; } }, BACKUP_PERIOD, BACKUP_PERIOD, TimeUnit.HOURS); final LocalServerListener listener = event -> { // look for a remote request to stop the server if (event.startsWith(STOP_SERVER_MESSAGE)) { logger.info("Remote shutdown request was received"); stopServer(); } dirty = true; }; messageBusServer.addLocalListener(listener); // if a callback has been registered, call it if (runningCallback != null) { runningCallback.run(); } // wait here forever try { while (stopLatch.getCount() != 0) { // check for condition, handle a spurious wake up stopLatch.await(); // wait forever from stopServer() } } catch (final InterruptedException ex) { logger.log(Level.SEVERE, ex.getLocalizedMessage(), ex); Thread.currentThread().interrupt(); } messageBusServer.removeLocalListener(listener); backupExecutor.shutdown(); exportXML(engine, fileName); messageBusServer.stopServer(); EngineFactory.closeEngine(SERVER_ENGINE); EngineFactory.removeOldCompressedXML(fileName, engine.getRetainedBackupLimit()); distributedLockManager.disconnectFromServer(); distributedAttachmentManager.disconnectFromServer(); distributedLockServer.stopServer(); attachmentTransferServer.stopServer(); em.close(); factory.close(); } else { throw new EngineException("Failed to start the Message Bus"); } } else { throw new EngineException("Failed to create the engine"); } } else { if (lockServerStarted) { distributedLockServer.stopServer(); } if (attachmentServerStarted) { attachmentTransferServer.stopServer(); } } } private void runH2Server(final String fileName, final int port, final char[] password) { org.h2.tools.Server server = null; try { final List serverArgs = new ArrayList<>(); serverArgs.add("-tcpPort"); serverArgs.add(String.valueOf(port)); serverArgs.add("-tcpAllowOthers"); /*boolean useSSL = Boolean.parseBoolean(System.getProperties().getProperty(EncryptionManager.ENCRYPTION_FLAG)); if (useSSL) { serverArgs.add("-tcpSSL"); }*/ server = org.h2.tools.Server.createTcpServer(serverArgs.toArray(new String[0])); server.start(); } catch (final SQLException e) { logger.log(Level.SEVERE, e.getMessage(), e); } // Start the message server and engine, this should block until closed try { run(DataStoreType.H2_DATABASE, fileName, port, password); } catch (final EngineException e) { LogUtil.logSevere(JpaNetworkServer.class, e); } if (server != null) { server.stop(); } } private void runHsqldbServer(final String fileName, final int port, final char[] password) { org.hsqldb.server.Server hsqlServer = new org.hsqldb.server.Server(); hsqlServer.setPort(port); hsqlServer.setDatabaseName(0, JpaConfiguration.UNIT_NAME); // the alias hsqlServer.setDatabasePath(0, "file:" + FileUtils.stripFileExtension(fileName)); hsqlServer.start(); // Start the message server and engine, this should block until closed try { run(DataStoreType.HSQL_DATABASE, fileName, port, password); } catch (final EngineException e) { LogUtil.logSevere(JpaNetworkServer.class, e); } hsqlServer.stop(); } /** * stops this server. */ private synchronized void stopServer() { stopLatch.countDown(); // will result in zero which will trigger a stop } private Engine createEngine(final DataStoreType dataStoreType, final String fileName, final int port, final char[] password) { final Properties properties = JpaConfiguration.getClientProperties(dataStoreType, fileName, EngineFactory.LOCALHOST, port, password); logger.log(Level.INFO, "Local connection url is: {0}", properties.getProperty(JpaConfiguration.JAVAX_PERSISTENCE_JDBC_URL)); Engine engine = null; try { // An exception will be thrown if the password is not correct, or the database did not have a password if (SqlUtils.isConnectionValid(properties.getProperty(JpaConfiguration.JAVAX_PERSISTENCE_JDBC_URL))) { /* specifies the unit name and properties. Unit name can be used to specify a different persistence unit defined in persistence.xml */ factory = Persistence.createEntityManagerFactory(JpaConfiguration.UNIT_NAME, properties); em = factory.createEntityManager(); distributedLockManager = new DistributedLockManager(EngineFactory.LOCALHOST, port + LOCK_SERVER_INCREMENT); distributedLockManager.connectToServer(password); distributedAttachmentManager = new DistributedAttachmentManager(EngineFactory.LOCALHOST, port + TRANSFER_SERVER_INCREMENT); distributedAttachmentManager.connectToServer(password); logger.info("Created local JPA container and engine"); engine = new Engine(new JpaEngineDAO(em, true), distributedLockManager, distributedAttachmentManager, SERVER_ENGINE); // treat as a remote engine } } catch (final Exception e) { logger.log(Level.SEVERE, e.toString(), e); } return engine; } private static void exportXML(final Engine engine, final String fileName) { ArrayList list = new ArrayList<>(engine.getStoredObjects()); EngineFactory.exportCompressedXML(fileName, list); } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/jpa/JpaRecurringDAO.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.jpa; import java.util.List; import java.util.UUID; import javax.persistence.EntityManager; import jgnash.engine.dao.RecurringDAO; import jgnash.engine.recurring.Reminder; /** * JPA Reminder DAO. * * @author Craig Cavanaugh */ class JpaRecurringDAO extends AbstractJpaDAO implements RecurringDAO { JpaRecurringDAO(final EntityManager entityManager, final boolean isRemote) { super(entityManager, isRemote); } /* * @see jgnash.engine.ReminderDAOInterface#getReminderList() */ @Override public List getReminderList() { return query(Reminder.class); } /* * @see jgnash.engine.ReminderDAOInterface#addReminder(jgnash.engine.recurring.Reminder) */ @Override public boolean addReminder(final Reminder reminder) { return persist(reminder); } @Override public Reminder getReminderByUuid(final UUID uuid) { return getObjectByUuid(Reminder.class, uuid); } @Override public boolean updateReminder(final Reminder reminder) { return addReminder(reminder); // call add, same code } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/jpa/JpaTagDAO.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.jpa; import java.util.HashSet; import java.util.Set; import javax.persistence.EntityManager; import jgnash.engine.Tag; import jgnash.engine.dao.TagDAO; /** * Tag DAO. * * @author Craig Cavanaugh */ class JpaTagDAO extends AbstractJpaDAO implements TagDAO { JpaTagDAO(final EntityManager entityManager, final boolean isRemote) { super(entityManager, isRemote); } @Override public boolean add(final Tag tag) { return persist(tag); } @Override public boolean update(final Tag tag) { return merge(tag) != null; } @Override public Set getTags() { return new HashSet<>(query(Tag.class)); } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/jpa/JpaTransactionDAO.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.jpa; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.UUID; import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; import java.util.logging.Level; import java.util.logging.Logger; import javax.persistence.EntityManager; import javax.persistence.TypedQuery; import jgnash.engine.Transaction; import jgnash.engine.dao.TransactionDAO; /** * Transaction DAO. * * @author Craig Cavanaugh */ class JpaTransactionDAO extends AbstractJpaDAO implements TransactionDAO { private static final Logger logger = Logger.getLogger(JpaTransactionDAO.class.getName()); JpaTransactionDAO(final EntityManager entityManager, final boolean isRemote) { super(entityManager, isRemote); logger.setLevel(Level.ALL); } /* * @see jgnash.engine.dao.TransactionDAO#getTransactions() */ @Override public List getTransactions() { return query(Transaction.class); } /* * @see jgnash.engine.TransactionDAO#addTransaction(jgnash.engine.Transaction) */ @Override public synchronized boolean addTransaction(final Transaction transaction) { boolean result = false; try { final Future future = executorService.submit(() -> { emLock.lock(); try { em.getTransaction().begin(); em.persist(transaction); transaction.getAccounts().forEach(em::persist); em.getTransaction().commit(); dirtyFlag.set(true); return true; } finally { emLock.unlock(); } }); result = future.get(); // block and return } catch (final InterruptedException | ExecutionException e) { logger.log(Level.SEVERE, e.getLocalizedMessage(), e); } return result; } @Override public Transaction getTransactionByUuid(final UUID uuid) { return getObjectByUuid(Transaction.class, uuid); } /* * @see jgnash.engine.TransactionDAO#removeTransaction(jgnash.engine.Transaction) */ @Override public synchronized boolean removeTransaction(final Transaction transaction) { boolean result = false; try { final Future future = executorService.submit(() -> { emLock.lock(); try { em.getTransaction().begin(); // look at accounts this transaction impacted and update the accounts transaction.getAccounts().forEach(em::persist); em.persist(transaction); // saved, removed with the trash em.getTransaction().commit(); dirtyFlag.set(true); return true; } finally { emLock.unlock(); } }); result = future.get(); // block and return } catch (final InterruptedException | ExecutionException e) { logger.log(Level.SEVERE, e.getLocalizedMessage(), e); } return result; } @Override public List getTransactionsWithAttachments() { List transactionList = Collections.emptyList(); try { final Future> future = executorService.submit(() -> { emLock.lock(); try { final TypedQuery q = em .createQuery("SELECT t FROM Transaction t WHERE t.markedForRemoval = false AND t.attachment is not null", Transaction.class); return new ArrayList<>(q.getResultList()); } finally { emLock.unlock(); } }); transactionList = future.get(); // block and return } catch (final InterruptedException | ExecutionException e) { logger.log(Level.SEVERE, e.getLocalizedMessage(), e); } return transactionList; } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/jpa/JpaTrashDAO.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.jpa; import java.time.LocalDateTime; import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.concurrent.ExecutionException; import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.logging.Level; import java.util.logging.Logger; import javax.persistence.EntityManager; import javax.persistence.TypedQuery; import javax.persistence.criteria.CriteriaBuilder; import javax.persistence.criteria.CriteriaQuery; import javax.persistence.criteria.Root; import jgnash.engine.StoredObject; import jgnash.engine.TrashObject; import jgnash.engine.concurrent.Priority; import jgnash.engine.dao.TrashDAO; import jgnash.util.DefaultDaemonThreadFactory; import org.apache.commons.collections4.ListUtils; /** * JPA Trash DAO. * * @author Craig Cavanaugh */ class JpaTrashDAO extends AbstractJpaDAO implements TrashDAO { private static final Logger logger = Logger.getLogger(JpaTrashDAO.class.getName()); private static final long MAXIMUM_ENTITY_TRASH_AGE = 2000; private static final int INITIAL_DELAY = 60; // Delay start 60 seconds private static final int PERIOD = 35; // Execute every 35 seconds private static final int MAX_ENTITY_LUMP = 5; private ScheduledExecutorService entityTrashExecutor; JpaTrashDAO(final EntityManager entityManager, final boolean isRemote) { super(entityManager, isRemote); entityTrashExecutor = Executors.newSingleThreadScheduledExecutor( new DefaultDaemonThreadFactory("JPA Trash Executor")); // run trash cleanup every 35 seconds 1 minute after startup entityTrashExecutor.scheduleWithFixedDelay(JpaTrashDAO.this::cleanupEntityTrash, INITIAL_DELAY, PERIOD, TimeUnit.SECONDS); } void stopTrashExecutor() { entityTrashExecutor.shutdown(); try { entityTrashExecutor.awaitTermination(Long.MAX_VALUE, TimeUnit.MILLISECONDS); entityTrashExecutor = null; } catch (InterruptedException e) { logger.log(Level.SEVERE, e.getLocalizedMessage(), e); } } @Override public List getTrashObjects() { List trashObjectList = Collections.emptyList(); try { final Future> future = executorService.submit(() -> { emLock.lock(); try { final CriteriaBuilder cb = em.getCriteriaBuilder(); final CriteriaQuery cq = cb.createQuery(TrashObject.class); final Root root = cq.from(TrashObject.class); cq.select(root); final TypedQuery q = em.createQuery(cq); return new ArrayList<>(q.getResultList()); } finally { emLock.unlock(); } }); trashObjectList = future.get(); // block } catch (final InterruptedException | ExecutionException e) { logger.log(Level.SEVERE, e.getLocalizedMessage(), e); } return trashObjectList; } @Override public void add(final TrashObject trashObject) { try { final Future future = executorService.submit(() -> { emLock.lock(); try { em.getTransaction().begin(); em.persist(trashObject.getObject()); em.persist(trashObject); em.getTransaction().commit(); dirtyFlag.set(true); return null; } finally { emLock.unlock(); } }); future.get(); // block } catch (final InterruptedException | ExecutionException e) { logger.log(Level.SEVERE, e.getLocalizedMessage(), e); } } @Override public void remove(final TrashObject trashObject) { try { final Future future = executorService.submit(() -> { emLock.lock(); try { em.getTransaction().begin(); final StoredObject object = trashObject.getObject(); em.remove(object); em.remove(trashObject); em.getTransaction().commit(); dirtyFlag.set(true); logger.info("Removed TrashObject"); return null; } finally { emLock.unlock(); } }, Priority.BACKGROUND); future.get(); // block } catch (final InterruptedException | ExecutionException e) { logger.log(Level.SEVERE, e.getLocalizedMessage(), e); } } @Override public void addEntityTrash(final Object entity) { try { final Future future = executorService.submit(() -> { emLock.lock(); try { em.getTransaction().begin(); em.persist(entity); em.persist(new JpaTrashEntity(entity)); em.getTransaction().commit(); dirtyFlag.set(true); return null; } finally { emLock.unlock(); } }); future.get(); // block } catch (final InterruptedException | ExecutionException e) { logger.log(Level.SEVERE, e.getLocalizedMessage(), e); } } private void cleanupEntityTrash() { try { final Future future = executorService.submit(() -> { emLock.lock(); try { final CriteriaBuilder cb = em.getCriteriaBuilder(); final CriteriaQuery cq = cb.createQuery(JpaTrashEntity.class); final Root root = cq.from(JpaTrashEntity.class); cq.select(root); final TypedQuery q = em.createQuery(cq); /* Partition the results into small chunks so other higher priority work can be performed without stalling the application */ final List> listList = ListUtils.partition(q.getResultList(), MAX_ENTITY_LUMP); for (final List entityList : listList) { executorService.submit(() -> { emLock.lock(); try { em.getTransaction().begin(); for (final JpaTrashEntity trashEntity : entityList) { if (!trashEntity.isPending() && ChronoUnit.MILLIS.between(trashEntity.getDate(), LocalDateTime.now()) >= MAXIMUM_ENTITY_TRASH_AGE) { trashEntity.setPending(); final Class clazz = Class.forName(trashEntity.getClassName()); final Object entity = em.find(clazz, trashEntity.getEntityId()); if (entity != null) { em.remove(entity); logger.log(Level.INFO, "Removed entity trash: {0}@{1}", new Object[]{trashEntity.getClassName(), trashEntity.getEntityId()}); } em.remove(trashEntity); } } em.getTransaction().commit(); } finally { emLock.unlock(); } return null; }, Priority.BACKGROUND); } return null; } finally { emLock.unlock(); } }, Priority.BACKGROUND); future.get(); // block } catch (final InterruptedException | ExecutionException e) { logger.log(Level.SEVERE, e.getLocalizedMessage(), e); } } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/jpa/JpaTrashEntity.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.jpa; import javax.persistence.Entity; import javax.persistence.GeneratedValue; import javax.persistence.GenerationType; import javax.persistence.Id; import javax.persistence.SequenceGenerator; import java.lang.reflect.Field; import java.time.LocalDateTime; import static jgnash.util.LogUtil.logSevere; /** * A Trash entity for generic cleanup of typed entities that need to be cleanup up * at a later date because of how JPA operates, but the object is not a StoredObject. * * @author Craig Cavanaugh */ @Entity @SequenceGenerator(name = "sequence", allocationSize = 10) public class JpaTrashEntity { @SuppressWarnings("unused") @Id @GeneratedValue(generator = "sequence", strategy = GenerationType.SEQUENCE) private long id; private long entityId; /** * Date object was added. */ private final LocalDateTime date = LocalDateTime.now(); private String className; /** * Will be true if it has been scheduled for removal. State is not persisted should the engine be close prior * to complete removal. */ private transient boolean pending = false; /** * No argument constructor for reflection purposes. * Do not use to create a new instance */ @SuppressWarnings("unused") protected JpaTrashEntity() { } JpaTrashEntity(final Object entity) { className = entity.getClass().getName(); entityId = getBasicEntityId(entity); } public LocalDateTime getDate() { return date; } String getClassName() { return className; } long getEntityId() { return entityId; } private static long getBasicEntityId(final Object object) { for (final Field field : object.getClass().getDeclaredFields()) { if (field.isAnnotationPresent(Id.class)) { try { return field.getLong(object); } catch (final IllegalAccessException e) { logSevere(JpaTrashEntity.class, e); } } } return -1; } void setPending() { pending = true; } boolean isPending() { return pending; } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/jpa/SqlUtils.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.jpa; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Paths; import java.sql.Connection; import java.sql.DatabaseMetaData; import java.sql.DriverManager; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Statement; import java.util.Locale; import java.util.Objects; import java.util.Properties; import java.util.Set; import java.util.TreeSet; import java.util.logging.Level; import java.util.logging.Logger; import jgnash.engine.DataStoreType; import jgnash.engine.EngineFactory; import jgnash.util.FileUtils; /** * Utility class to perform low level SQL related stuff with the database. * * @author Craig Cavanaugh */ public class SqlUtils { private static final Logger logger = Logger.getLogger(SqlUtils.class.getName()); /** * Maximum amount of time to wait for the lock file to release after closure. Typical time should be about 2 * seconds, but unit tests or large databases can sometimes take longer */ private static final long MAX_LOCK_RELEASE_TIME = 30L * 1000L; private static final int TABLE_NAME = 3; private static final int COLUMN_NAME = 4; private static final String SHUTDOWN = "SHUTDOWN"; private SqlUtils() { } public static boolean changePassword(final String fileName, final char[] password, final char[] newPassword) { boolean result = false; try { if (!FileUtils.isFileLocked(fileName)) { final DataStoreType dataStoreType = EngineFactory.getDataStoreByType(fileName); Objects.requireNonNull(dataStoreType); final Properties properties = JpaConfiguration.getLocalProperties(dataStoreType, fileName, password, false); final String url = properties.getProperty(JpaConfiguration.JAVAX_PERSISTENCE_JDBC_URL); try (final Connection connection = DriverManager.getConnection(url)) { try (final PreparedStatement statement = connection.prepareStatement("SET PASSWORD ?")) { statement.setString(1, new String(newPassword)); statement.execute(); result = true; } } catch (final SQLException e) { logger.log(Level.SEVERE, e.getMessage(), e); } } } catch (final IOException e) { logger.log(Level.SEVERE, e.getMessage(), e); } return result; } /** * Opens the database in readonly mode and reads the version of the file format. * * @param fileName name of file to open * @param password connection password * @return file version. Zero is returned if not found */ public static float getFileVersion(final String fileName, final char[] password) { float fileVersion = 0f; try { if (!FileUtils.isFileLocked(fileName)) { final DataStoreType dataStoreType = EngineFactory.getDataStoreByType(fileName); final Properties properties = JpaConfiguration.getLocalProperties(dataStoreType, fileName, password, false); final String url = properties.getProperty(JpaConfiguration.JAVAX_PERSISTENCE_JDBC_URL); try (final Connection connection = DriverManager.getConnection(url)) { try (final Statement statement = connection.createStatement()) { boolean configTableExists = false; // check for the existence of the table first final DatabaseMetaData metaData = connection.getMetaData(); try (final ResultSet resultSet = metaData.getTables(null, null, "CONFIG", null)) { while (resultSet.next()) { if (resultSet.getString(TABLE_NAME).toUpperCase(Locale.ROOT).equals("CONFIG")) { configTableExists = true; } } } // check for the value if the table exists if (configTableExists) { try (final ResultSet resultSet = statement.executeQuery("SELECT FILEFORMAT FROM CONFIG")) { resultSet.next(); fileVersion = Float.parseFloat(resultSet.getString("fileformat")); } } } // must issue a shutdown for correct file closure try (final Statement statement = connection.createStatement()) { statement.execute(SHUTDOWN); } } catch (final SQLException e) { logger.log(Level.SEVERE, e.getMessage(), e); } } else { logger.severe("File was locked"); } } catch (final IOException e) { logger.log(Level.SEVERE, e.getMessage(), e); } return fileVersion; } /** * Diagnostic utility method to dump a list of table names and columns to the console. * Assumes the file is not password protected. * * @param fileName name of file to open * @return a {@code Set} of strings with the table names and columns, comma separated */ public static Set getTableAndColumnNames(final String fileName) { final Set tableNames = new TreeSet<>(); try { if (!FileUtils.isFileLocked(fileName)) { final DataStoreType dataStoreType = EngineFactory.getDataStoreByType(fileName); final Properties properties = JpaConfiguration.getLocalProperties(dataStoreType, fileName, EngineFactory.EMPTY_PASSWORD, true); final String url = properties.getProperty(JpaConfiguration.JAVAX_PERSISTENCE_JDBC_URL); try (final Connection connection = DriverManager.getConnection(url)) { final DatabaseMetaData metaData = connection.getMetaData(); try (final ResultSet resultSet = metaData.getColumns(null, null, "%", "%")) { while (resultSet.next()) { tableNames.add(resultSet.getString(TABLE_NAME).toUpperCase(Locale.ROOT) + "," + resultSet.getString(COLUMN_NAME).toUpperCase(Locale.ROOT)); } } // must issue a shutdown for correct file closure try (final Statement statement = connection.createStatement()) { statement.execute(SHUTDOWN); } } catch (final SQLException e) { logger.log(Level.SEVERE, e.getMessage(), e); } } else { logger.severe("File was locked"); } } catch (final IOException e) { logger.log(Level.SEVERE, e.getMessage(), e); } return tableNames; } @SuppressWarnings("SameParameterValue") static void dropColumn(final String fileName, final char[] password, final String table, final String... columns) { try { if (!FileUtils.isFileLocked(fileName)) { final DataStoreType dataStoreType = EngineFactory.getDataStoreByType(fileName); final Properties properties = JpaConfiguration.getLocalProperties(dataStoreType, fileName, password, false); final String url = properties.getProperty(JpaConfiguration.JAVAX_PERSISTENCE_JDBC_URL); try (final Connection connection = DriverManager.getConnection(url)) { final DatabaseMetaData metaData = connection.getMetaData(); final ResultSet resultSet = metaData.getTables(null, null, table, new String[] {"TABLE"}); while (resultSet.next()) { final String tableName = resultSet.getString("TABLE_NAME"); if (tableName !=null && tableName.equalsIgnoreCase(table)) { for (final String column : columns) { try (final Statement statement = connection.createStatement()) { statement.execute("ALTER TABLE " + table + " DROP " + column); } } } } // must issue a shutdown for correct file closure try (final Statement statement = connection.createStatement()) { statement.execute(SHUTDOWN); } } catch (final SQLException e) { logger.log(Level.SEVERE, e.getMessage(), e); } } else { logger.severe("File was locked"); } } catch (final IOException e) { logger.log(Level.SEVERE, e.getMessage(), e); } } /** * Returns true if the url is valid, throws an exception otherwise. * * @param url url to validate * @return {@code true} if valid */ static boolean isConnectionValid(final String url) { boolean result = false; try (final Connection ignored = DriverManager.getConnection(url)) { logger.fine("Connection is valid"); result = true; } catch (final SQLException e) { if (e.toString().contains("Unknown database")) { logger.severe("Unknown database"); } } catch (final Exception e) { logger.log(Level.SEVERE, e.toString(), e); } return result; } /** * Forces a database closed and waits for the lock file to disappear indicating the database server is closed. * * @param dataStoreType DataStoreType to connect to * @param fileName path of the file to close * @param lockFileExtension lock file extension * @param password password for the database */ static void waitForLockFileRelease(final DataStoreType dataStoreType, final String fileName, final String lockFileExtension, final char[] password) { final String lockFile = FileUtils.stripFileExtension(fileName) + lockFileExtension; logger.log(Level.INFO, "Searching for lock file: {0}", lockFile); // Don't try if the lock file does not exist if (Files.exists(Paths.get(lockFile))) { final Properties properties = JpaConfiguration.getLocalProperties(dataStoreType, fileName, password, false); final String url = properties.getProperty(JpaConfiguration.JAVAX_PERSISTENCE_JDBC_URL); // Send shutdown to close the database try (final Connection connection = DriverManager.getConnection(url, JpaConfiguration.DEFAULT_USER, new String(password))) { // must issue a shutdown for correct file closure try (final Statement statement = connection.createStatement()) { statement.execute(SHUTDOWN); } } catch (final SQLException e) { logger.log(Level.SEVERE, e.getMessage(), e); } logger.info("SQL SHUTDOWN was issued"); if (!FileUtils.waitForFileRemoval(lockFile, MAX_LOCK_RELEASE_TIME)) { logger.warning("Exceeded the maximum wait time for the file lock release"); } } } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/message/ChannelEvent.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.message; /** * Standard message channel events. * * @author Craig Cavanaugh */ public enum ChannelEvent { ACCOUNT_ADD, ACCOUNT_ADD_FAILED, ACCOUNT_ATTRIBUTE_MODIFY, ACCOUNT_MODIFY, ACCOUNT_MODIFY_FAILED, ACCOUNT_REMOVE, ACCOUNT_REMOVE_FAILED, ACCOUNT_SECURITY_ADD, ACCOUNT_SECURITY_ADD_FAILED, ACCOUNT_SECURITY_REMOVE, ACCOUNT_SECURITY_REMOVE_FAILED, ACCOUNT_VISIBILITY_CHANGE, ACCOUNT_VISIBILITY_CHANGE_FAILED, BACKGROUND_PROCESS_STARTED, BACKGROUND_PROCESS_STOPPED, BUDGET_ADD, BUDGET_ADD_FAILED, BUDGET_GOAL_UPDATE, BUDGET_GOAL_UPDATE_FAILED, BUDGET_UPDATE, BUDGET_UPDATE_FAILED, BUDGET_REMOVE, CONFIG_MODIFY, // CONFIG_MODIFY_FAILED, CURRENCY_ADD, CURRENCY_ADD_FAILED, CURRENCY_MODIFY, CURRENCY_MODIFY_FAILED, CURRENCY_REMOVE, CURRENCY_REMOVE_FAILED, EXCHANGE_RATE_ADD, EXCHANGE_RATE_ADD_FAILED, EXCHANGE_RATE_REMOVE, EXCHANGE_RATE_REMOVE_FAILED, REMINDER_ADD, REMINDER_ADD_FAILED, REMINDER_REMOVE, REMINDER_UPDATE, REMINDER_UPDATE_FAILED, SECURITY_ADD, SECURITY_ADD_FAILED, SECURITY_MODIFY, SECURITY_MODIFY_FAILED, SECURITY_REMOVE, SECURITY_REMOVE_FAILED, SECURITY_HISTORY_ADD, SECURITY_HISTORY_ADD_FAILED, SECURITY_HISTORY_REMOVE, SECURITY_HISTORY_REMOVE_FAILED, SECURITY_HISTORY_EVENT_ADD, SECURITY_HISTORY_EVENT_ADD_FAILED, SECURITY_HISTORY_EVENT_REMOVE, SECURITY_HISTORY_EVENT_REMOVE_FAILED, TRANSACTION_ADD, TRANSACTION_ADD_FAILED, TRANSACTION_REMOVE, TRANSACTION_REMOVE_FAILED, TAG_ADD, TAG_ADD_FAILED, TAG_MODIFY, TAG_MODIFY_FAILED, TAG_REMOVE, TAG_REMOVE_FAILED, FILE_CLOSING, FILE_NOT_FOUND, FILE_IO_ERROR, FILE_LOAD_FAILED, FILE_LOAD_SUCCESS, } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/message/LocalServerListener.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.message; /** * Classes must implement this interface to register and listen to message events. * * @author Craig Cavanaugh */ public interface LocalServerListener { void messagePosted(String event); } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/message/Message.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.message; import jgnash.engine.Engine; import jgnash.engine.EngineFactory; import jgnash.engine.StoredObject; import jgnash.util.NotNull; import java.io.IOException; import java.io.ObjectInputStream; import java.io.ObjectOutputStream; import java.io.Serializable; import java.util.EnumMap; import java.util.Objects; import java.util.UUID; /** * Message object. * * @author Craig Cavanaugh */ public class Message implements Serializable, Cloneable { private ChannelEvent event; private MessageChannel channel; private String source; private transient EnumMap properties = new EnumMap<>(MessageProperty.class); /** * Used to flag message sent remotely. */ private transient boolean remote; /** * No argument constructor for reflection purposes.
* Do not use to create new instances * * @deprecated */ @Deprecated public Message() { } public Message(final MessageChannel channel, final ChannelEvent event, final Engine source) { this(channel, event, source.getUuid()); } private Message(final MessageChannel channel, final ChannelEvent event, final String source) { this.source = Objects.requireNonNull(source); this.event = Objects.requireNonNull(event); this.channel = Objects.requireNonNull(channel); } public MessageChannel getChannel() { return channel; } public ChannelEvent getEvent() { return event; } /** * Sets a message property. The value must be reachable by the engine or and exception will be thrown. * * @param key property key * @param value message value */ public void setObject(@NotNull final MessageProperty key, @NotNull final StoredObject value) { properties.put(Objects.requireNonNull(key), Objects.requireNonNull(value)); } /** * Returns a {@code StoredObject} given a property key. * * @param key {@code MessageProperty} to search for * @param instance of {@code StoredObject} * @return object if found, {@code null} otherwise */ @SuppressWarnings("unchecked") public T getObject(final MessageProperty key) { return (T) properties.get(key); } public String getSource() { return source; } void setRemote() { remote = true; } public boolean isRemote() { return remote; } /** * Write message out to ObjectOutputStream. * * @param s stream * @throws IOException io exception * @serialData Write serializable fields, if any exist. Write out the integer count of properties. Write out key and * value of each property */ @SuppressWarnings("unused") private void writeObject(final ObjectOutputStream s) throws IOException { s.defaultWriteObject(); // write the property count s.writeInt(properties.size()); StoredObject[] values = properties.values().toArray(new StoredObject[0]); MessageProperty[] keys = properties.keySet().toArray(new MessageProperty[0]); for (int i = 0; i < properties.size(); i++) { s.writeObject(keys[i]); s.writeUTF(values[i].getClass().getName()); s.writeUTF(values[i].getUuid().toString()); } } /** * Read a Message from an ObjectInputStream. * * @param s input stream * @throws java.io.IOException io exception * @throws ClassNotFoundException thrown is class is not found * @serialData Read serializable fields, if any exist. Read the integer count of properties. Read the key and value * of each property */ @SuppressWarnings({"unchecked", "unused"}) private void readObject(final ObjectInputStream s) throws IOException, ClassNotFoundException { s.defaultReadObject(); properties = new EnumMap<>(MessageProperty.class); final int size = s.readInt(); final Engine engine = EngineFactory.getEngine(EngineFactory.DEFAULT); Objects.requireNonNull(engine); for (int i = 0; i < size; i++) { MessageProperty key = (MessageProperty) s.readObject(); Class clazz = (Class) Class.forName(s.readUTF()); StoredObject value = engine.getStoredObjectByUuid(clazz, UUID.fromString(s.readUTF())); properties.put(key, value); } } @Override public Message clone() throws CloneNotSupportedException { final Message m = (Message) super.clone(); m.properties = properties.clone(); return m; } /** * Returns the event and channel for this message. * * @return event and channel information * @see java.lang.Object#toString() */ @Override public String toString() { return String.format("Message [event=%s, channel=%s, source=%s]", event, channel, source); } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/message/MessageBus.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.message; import java.lang.ref.WeakReference; import java.time.LocalDateTime; import java.time.temporal.ChronoUnit; import java.util.HashMap; import java.util.HashSet; import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.CopyOnWriteArraySet; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.logging.Level; import java.util.logging.Logger; import jgnash.engine.DataStoreType; import jgnash.util.DefaultDaemonThreadFactory; /** * Thread safe Message Bus. * * Listeners are stored in a CopyOnWriteArraySet to prevent duplicate listeners * and to ease the burden of synchronizing against multiple threads. The iterator * must be used for access, but removal of weak references must be done through the * set, not the iterator. * * @author Craig Cavanaugh */ public class MessageBus { /** * Maximum wait to get a valid response from the remote message bus before giving up. */ private static final long MAX_LATENCY = 5L * 1000L; private static final Logger logger = Logger.getLogger(MessageBus.class.getName()); private final ConcurrentMap>> map = new ConcurrentHashMap<>(); private final ExecutorService pool = Executors.newSingleThreadExecutor(new DefaultDaemonThreadFactory("Message Bus Executor")); private MessageBusClient messageBusClient = null; private static final Map busMap = new HashMap<>(); private static final String DEFAULT = "default"; /** Name given to this message bus instance. */ private final String busName; private MessageBus(final String busName) { this.busName = busName; } /** * Set message bus to post remote messages. * * @param host message server name or IP address * @param port message server port * @param password connection password * @return {@code true} if connection to the remote server was successful */ public synchronized boolean setRemote(final String host, final int port, final char[] password) { disconnectFromServer(); return connectToServer(host, port, password); } /** * Set message bus for local operation. */ public synchronized void setLocal() { disconnectFromServer(); } /** * Returns the message bus instance intended for UI use only. * * @return an instance of a MessageBus */ public static synchronized MessageBus getInstance() { return getInstance(DEFAULT); } public static synchronized MessageBus getInstance(final String name) { return busMap.computeIfAbsent(name, k -> new MessageBus(name)); } public String getRemoteDataBasePath() { if (messageBusClient != null) { return messageBusClient.getDataBasePath(); } return null; } public DataStoreType getRemoteDataStoreType() { if (messageBusClient != null) { return messageBusClient.getDataStoreType(); } return null; } private void disconnectFromServer() { if (messageBusClient != null) { messageBusClient.disconnectFromServer(); messageBusClient = null; } } /** * Issues a shutdown request to a remote server. * * @param remoteHost message server name or IP address * @param remotePort message server port * @param password connection password */ public void shutDownRemoteServer(final String remoteHost, final int remotePort, final char[] password) { if (remoteHost == null || remotePort <= 0) { throw new IllegalArgumentException(); } MessageBusClient client = new MessageBusClient(remoteHost, remotePort, busName); if (client.connectToServer(password)) { client.sendRemoteShutdownRequest(); } } private boolean connectToServer(final String remoteHost, final int remotePort, final char[] password) { if (remoteHost == null || remotePort <= 0) { throw new IllegalArgumentException(); } messageBusClient = new MessageBusClient(remoteHost, remotePort, busName); boolean result = messageBusClient.connectToServer(password); if (result) { final LocalDateTime start = LocalDateTime.now(); // wait for the server response to the remote database path for a max delay before timing out // this is the handshake that a good connection was made while (getRemoteDataBasePath() == null || getRemoteDataStoreType() == null) { if (ChronoUnit.MILLIS.between(start, LocalDateTime.now()) > MAX_LATENCY) { disconnectFromServer(); logger.warning("Did not receive a valid response from the server"); result = false; break; } } } else { messageBusClient = null; //make sure bad client connections are dumped } return result; } public void registerListener(final MessageListener listener, final MessageChannel... channels) { for (final MessageChannel channel : channels) { final Set> set = map.computeIfAbsent(channel, k -> new CopyOnWriteArraySet<>()); if (containsListener(listener, channel)) { logger.severe("An attempt was made to install a duplicate listener"); logStackTrace(); } else { set.add(new WeakReference<>(listener)); } } } private static void logStackTrace() { final StringBuilder trace = new StringBuilder("Stack Trace" + System.lineSeparator()); for (final StackTraceElement element : Thread.currentThread().getStackTrace()) { trace.append("\tat ").append(element).append(System.lineSeparator()); } logger.log(Level.SEVERE, trace.toString()); } public void unregisterListener(final MessageListener listener, final MessageChannel... channels) { for (MessageChannel channel : channels) { Set> set = map.get(channel); if (set != null) { for (WeakReference ref : set) { MessageListener l = ref.get(); if (l == null || l == listener) { set.remove(ref); } } } } } private boolean containsListener(final MessageListener listener, final MessageChannel channel) { Set> set = map.get(channel); if (set != null) { for (WeakReference ref : set) { MessageListener l = ref.get(); if (l == listener) { return true; } } } return false; } /** * Fires an event and blocks until all listeners have processed it. * * @param message {@code Message} to send */ public void fireBlockingEvent(final Message message) { final Future future = fireEvent(message); // spin until everyone has consumed the event while(!future.isDone()) { Thread.onSpinWait(); } } /** * Fires and event to all listeners and return immediately with a {@code Future} * @param message {@code Message} to send * * @return {@code Future} indicating when all listeners have processed the event */ public Future fireEvent(final Message message) { return pool.submit(() -> { final Set> staleListener = new HashSet<>(); // Look for and post to local listeners final Set> set = map.get(message.getChannel()); if (set != null) { for (final WeakReference ref : set) { MessageListener l = ref.get(); if (l != null) { l.messagePosted(message); } else { staleListener.add(ref); } } } // purge stale references to prevent a slowdown and wasted memory during a long application session for (final WeakReference staleReference : staleListener) { set.remove(staleReference); } /* Post a remote message if configured to do so and filter system events. * * Do not re-post a remote message otherwise it will just loop through the * remote message system * */ if (!message.isRemote() && messageBusClient != null && message.getChannel() != MessageChannel.SYSTEM) { messageBusClient.sendRemoteMessage(message); } return null; }); } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/message/MessageBusClient.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.message; import io.netty.bootstrap.Bootstrap; import io.netty.channel.Channel; import io.netty.channel.ChannelHandler; import io.netty.channel.ChannelHandlerContext; import io.netty.channel.ChannelInboundHandlerAdapter; import io.netty.channel.ChannelInitializer; import io.netty.channel.ChannelOption; import io.netty.channel.ChannelPipeline; import io.netty.channel.nio.NioEventLoopGroup; import io.netty.channel.socket.SocketChannel; import io.netty.channel.socket.nio.NioSocketChannel; import io.netty.handler.codec.DelimiterBasedFrameDecoder; import io.netty.handler.codec.Delimiters; import io.netty.handler.codec.string.StringDecoder; import io.netty.handler.codec.string.StringEncoder; import io.netty.util.CharsetUtil; import io.netty.util.ReferenceCountUtil; import java.io.CharArrayWriter; import java.util.Objects; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.locks.ReentrantLock; import java.util.logging.Level; import java.util.logging.Logger; import jgnash.engine.Account; import jgnash.engine.CommodityNode; import jgnash.engine.Config; import jgnash.engine.DataStoreType; import jgnash.engine.Engine; import jgnash.engine.EngineFactory; import jgnash.engine.ExchangeRate; import jgnash.engine.Transaction; import jgnash.engine.budget.Budget; import jgnash.engine.jpa.JpaNetworkServer; import jgnash.engine.recurring.Reminder; import jgnash.net.ConnectionFactory; import jgnash.util.EncryptionManager; import com.thoughtworks.xstream.XStream; import com.thoughtworks.xstream.io.xml.CompactWriter; import jgnash.util.LogUtil; /** * Message bus client for remote connections. * * @author Craig Cavanaugh */ class MessageBusClient { private final String host; private final int port; private static final Logger logger = Logger.getLogger(MessageBusClient.class.getName()); private final XStream xstream; private String dataBasePath; private DataStoreType dataBaseType; private EncryptionManager encryptionManager = null; private NioEventLoopGroup eventLoopGroup; private Channel channel; private final String name; private final ReentrantLock channelLock = new ReentrantLock(); static { logger.setLevel(Level.INFO); } MessageBusClient(final String host, final int port, final String name) { this.host = host; this.port = port; this.name = name; xstream = XStreamFactory.getInstance(); } String getDataBasePath() { return dataBasePath; } DataStoreType getDataStoreType() { return dataBaseType; } private static int getConnectionTimeout() { return ConnectionFactory.getConnectionTimeout(); } boolean connectToServer(final char[] password) { boolean result = false; // If a password has been specified, create an EncryptionManager if (password != null && password.length > 0) { encryptionManager = new EncryptionManager(password); } eventLoopGroup = new NioEventLoopGroup(); final Bootstrap bootstrap = new Bootstrap(); bootstrap.group(eventLoopGroup) .channel(NioSocketChannel.class) .handler(new MessageBusClientInitializer()) .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, getConnectionTimeout() * 1000) .option(ChannelOption.SO_KEEPALIVE, true); channelLock.lock(); try { // Start the connection attempt. channel = bootstrap.connect(host, port).sync().channel(); result = true; logger.info("Connected to remote message server"); } catch (final InterruptedException e) { logger.log(Level.SEVERE, "Failed to connect to remote message bus", e); disconnectFromServer(); } finally { channelLock.unlock(); } return result; } private class MessageBusClientInitializer extends ChannelInitializer { @Override public void initChannel(final SocketChannel ch) { ChannelPipeline pipeline = ch.pipeline(); // Add the text line codec combination first, pipeline.addLast("framer", new DelimiterBasedFrameDecoder(8192, true, Delimiters.lineDelimiter())); pipeline.addLast("decoder", new StringDecoder(CharsetUtil.UTF_8)); pipeline.addLast("encoder", new StringEncoder(CharsetUtil.UTF_8)); // and then business logic. pipeline.addLast("handler", new MessageBusClientHandler()); } } /** * Handles a client-side channel. */ @ChannelHandler.Sharable private class MessageBusClientHandler extends ChannelInboundHandlerAdapter { private final ExecutorService executorService = Executors.newSingleThreadExecutor(); private String decrypt(final Object object) { String plainMessage; if (encryptionManager != null) { plainMessage = encryptionManager.decrypt(object.toString()); } else { plainMessage = object.toString(); } return plainMessage; } @Override public void channelRead(final ChannelHandlerContext ctx, final Object msg) { try { final String plainMessage = decrypt(msg); logger.log(Level.FINE, "messageReceived: {0}", plainMessage); if (plainMessage.startsWith(" { final Message message = (Message) xstream.fromXML(plainMessage); final Engine engine = EngineFactory.getEngine(name); Objects.requireNonNull(engine); // ignore our own messages if (!engine.getUuid().equals(message.getSource())) { processRemoteMessage(message); } }); } else if (plainMessage.startsWith(MessageBusServer.PATH_PREFIX)) { dataBasePath = plainMessage.substring(MessageBusServer.PATH_PREFIX.length()); logger.log(Level.FINE, "Remote data path is: {0}", dataBasePath); } else if (plainMessage.startsWith(MessageBusServer.DATA_STORE_TYPE_PREFIX)) { dataBaseType = DataStoreType.valueOf(plainMessage.substring(MessageBusServer.DATA_STORE_TYPE_PREFIX.length())); logger.log(Level.FINE, "Remote dataBaseType type is: {0}", dataBaseType.name()); } else if (plainMessage.startsWith(EncryptionManager.DECRYPTION_ERROR_TAG)) { // decryption has failed, shut down the engine logger.log(Level.SEVERE, "Unable to decrypt the remote message"); } else if (plainMessage.startsWith(JpaNetworkServer.STOP_SERVER_MESSAGE)) { logger.info("Server is shutting down"); EngineFactory.closeEngine(name); } else { logger.log(Level.SEVERE, "Unknown message: {0}", plainMessage); } } finally { ReferenceCountUtil.release(msg); } } @Override public void exceptionCaught(final ChannelHandlerContext ctx, final Throwable cause) throws Exception { super.exceptionCaught(ctx, cause); logger.log(Level.WARNING, "Unexpected exception from downstream.", cause); ctx.close(); } } void disconnectFromServer() { channelLock.lock(); try { if (channel != null) { // null from a prior failed connection channel.close().sync(); } } catch (final InterruptedException e) { LogUtil.logSevere(MessageBusClient.class, e); } finally { channelLock.unlock(); } eventLoopGroup.shutdownGracefully(); channel = null; eventLoopGroup = null; } synchronized void sendRemoteMessage(final Message message) { CharArrayWriter writer = new CharArrayWriter(); xstream.marshal(message, new CompactWriter(writer)); sendRemoteMessage(writer.toString()); logger.log(Level.FINE, "sent: {0}", writer); } void sendRemoteShutdownRequest() { sendRemoteMessage(JpaNetworkServer.STOP_SERVER_MESSAGE); } private void sendRemoteMessage(final String message) { channelLock.lock(); try { if (encryptionManager != null) { channel.writeAndFlush(encryptionManager.encrypt(message) + MessageBusServer.EOL_DELIMITER).sync(); } else { channel.writeAndFlush(message + MessageBusServer.EOL_DELIMITER).sync(); } } catch (final InterruptedException e) { logger.log(Level.SEVERE, e.getLocalizedMessage(), e); } catch (final NullPointerException e) { if (channel == null) { logger.info("Channel was null"); } logger.log(Level.INFO, "Tried to send message: {0} through a null channel", message); } finally { channelLock.unlock(); } } /** * Takes a remote message and forces remote updates before sending the message to the MessageBus to notify UI * components of changes. * * @param message Message to process and send */ private void processRemoteMessage(final Message message) { logger.fine("processing a remote message"); final Engine engine = EngineFactory.getEngine(name); Objects.requireNonNull(engine); if (message.getChannel() == MessageChannel.ACCOUNT) { final Account account = message.getObject(MessageProperty.ACCOUNT); switch (message.getEvent()) { case ACCOUNT_ADD: case ACCOUNT_REMOVE: engine.refresh(account); message.setObject(MessageProperty.ACCOUNT, engine.getAccountByUuid(account.getUuid())); engine.refresh(account.getParent()); break; case ACCOUNT_MODIFY: case ACCOUNT_SECURITY_ADD: case ACCOUNT_SECURITY_REMOVE: case ACCOUNT_VISIBILITY_CHANGE: engine.refresh(account); message.setObject(MessageProperty.ACCOUNT, engine.getAccountByUuid(account.getUuid())); break; default: break; } } if (message.getChannel() == MessageChannel.BUDGET) { final Budget budget = message.getObject(MessageProperty.BUDGET); switch (message.getEvent()) { case BUDGET_ADD: case BUDGET_UPDATE: case BUDGET_REMOVE: case BUDGET_GOAL_UPDATE: engine.refresh(budget); message.setObject(MessageProperty.BUDGET, engine.getBudgetByUuid(budget.getUuid())); break; default: break; } } if (message.getChannel() == MessageChannel.COMMODITY) { switch (message.getEvent()) { case CURRENCY_ADD: case CURRENCY_MODIFY: final CommodityNode currency = message.getObject(MessageProperty.COMMODITY); engine.refresh(currency); message.setObject(MessageProperty.COMMODITY, engine.getCurrencyNodeByUuid(currency.getUuid())); break; case SECURITY_ADD: case SECURITY_MODIFY: case SECURITY_HISTORY_ADD: case SECURITY_HISTORY_REMOVE: final CommodityNode node = message.getObject(MessageProperty.COMMODITY); engine.refresh(node); message.setObject(MessageProperty.COMMODITY, engine.getSecurityNodeByUuid(node.getUuid())); break; case EXCHANGE_RATE_ADD: case EXCHANGE_RATE_REMOVE: final ExchangeRate rate = message.getObject(MessageProperty.EXCHANGE_RATE); engine.refresh(rate); message.setObject(MessageProperty.EXCHANGE_RATE, engine.getExchangeRateByUuid(rate.getUuid())); break; default: break; } } if (message.getChannel() == MessageChannel.CONFIG && message.getEvent() == ChannelEvent.CONFIG_MODIFY) { final Config config = message.getObject(MessageProperty.CONFIG); engine.refresh(config); message.setObject(MessageProperty.CONFIG, engine.getStoredObjectByUuid(Config.class, config.getUuid())); } if (message.getChannel() == MessageChannel.REMINDER) { switch (message.getEvent()) { case REMINDER_ADD: case REMINDER_REMOVE: final Reminder reminder = message.getObject(MessageProperty.REMINDER); engine.refresh(reminder); message.setObject(MessageProperty.REMINDER, engine.getReminderByUuid(reminder.getUuid())); break; default: break; } } if (message.getChannel() == MessageChannel.TRANSACTION) { switch (message.getEvent()) { case TRANSACTION_ADD: case TRANSACTION_REMOVE: final Transaction transaction = message.getObject(MessageProperty.TRANSACTION); engine.refresh(transaction); message.setObject(MessageProperty.TRANSACTION, engine.getTransactionByUuid(transaction.getUuid())); final Account account = message.getObject(MessageProperty.ACCOUNT); engine.refresh(account); message.setObject(MessageProperty.ACCOUNT, engine.getAccountByUuid(account.getUuid())); break; default: break; } } /* Flag the message as remote */ message.setRemote(); logger.fine("fire remote message"); MessageBus.getInstance(name).fireEvent(message); } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/message/MessageBusServer.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.message; import io.netty.bootstrap.ServerBootstrap; import io.netty.channel.ChannelFuture; import io.netty.channel.ChannelHandler; import io.netty.channel.ChannelHandlerContext; import io.netty.channel.ChannelInboundHandlerAdapter; import io.netty.channel.ChannelInitializer; import io.netty.channel.ChannelOption; import io.netty.channel.ChannelPipeline; import io.netty.channel.group.ChannelGroup; import io.netty.channel.group.DefaultChannelGroup; import io.netty.channel.nio.NioEventLoopGroup; import io.netty.channel.socket.SocketChannel; import io.netty.channel.socket.nio.NioServerSocketChannel; import io.netty.handler.codec.DelimiterBasedFrameDecoder; import io.netty.handler.codec.Delimiters; import io.netty.handler.codec.string.StringDecoder; import io.netty.handler.codec.string.StringEncoder; import io.netty.util.CharsetUtil; import io.netty.util.ReferenceCountUtil; import io.netty.util.concurrent.GlobalEventExecutor; import java.util.HashSet; import java.util.Set; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.locks.ReadWriteLock; import java.util.concurrent.locks.ReentrantReadWriteLock; import java.util.logging.Level; import java.util.logging.Logger; import jgnash.engine.DataStoreType; import jgnash.util.EncryptionManager; /** * Message bus server for remote connections. * * @author Craig Cavanaugh */ public class MessageBusServer { private static final Logger logger = Logger.getLogger(MessageBusServer.class.getName()); static final String PATH_PREFIX = ""; static final String DATA_STORE_TYPE_PREFIX = ""; static final String EOL_DELIMITER = "\r\n"; private final int port; private String dataBasePath = ""; private String dataStoreType = ""; private NioEventLoopGroup eventLoopGroup; private final ReadWriteLock rwl = new ReentrantReadWriteLock(true); private final Set listeners = new HashSet<>(); private final ChannelGroup channelGroup = new DefaultChannelGroup("all-connected", GlobalEventExecutor.INSTANCE); private EncryptionManager encryptionManager; private final ExecutorService executorService = Executors.newSingleThreadExecutor(); static { logger.setLevel(Level.INFO); } public MessageBusServer(final int port) { this.port = port; } public boolean startServer(final DataStoreType dataStoreType, final String dataBasePath, final char[] password) { boolean result = false; logger.info("Starting message bus server on port: " + port); this.dataBasePath = dataBasePath; this.dataStoreType = dataStoreType.name(); // If a password has been specified, create an EncryptionManager if (password != null && password.length > 0) { encryptionManager = new EncryptionManager(password); } eventLoopGroup = new NioEventLoopGroup(); final ServerBootstrap bootstrap = new ServerBootstrap(); try { bootstrap.group(eventLoopGroup) .channel(NioServerSocketChannel.class) .childHandler(new MessageBusRemoteInitializer()) .childOption(ChannelOption.SO_KEEPALIVE, true); final ChannelFuture future = bootstrap.bind(port); future.sync(); if (future.isDone() && future.isSuccess()) { logger.info("Message Bus Server started successfully"); result = true; } else { logger.info("Failed to start the Message Bus Server"); } } catch (final InterruptedException e) { logger.log(Level.SEVERE, e.getLocalizedMessage(), e); stopServer(); } return result; } public void stopServer() { rwl.writeLock().lock(); try { channelGroup.close().sync(); executorService.shutdown(); eventLoopGroup.shutdownGracefully(); eventLoopGroup = null; listeners.clear(); logger.info("MessageBusServer Stopped"); } catch (final InterruptedException e) { logger.log(Level.SEVERE, e.getLocalizedMessage(), e); } finally { rwl.writeLock().unlock(); } } public void addLocalListener(final LocalServerListener listener) { rwl.writeLock().lock(); try { listeners.add(listener); } finally { rwl.writeLock().unlock(); } } public void removeLocalListener(final LocalServerListener listener) { rwl.writeLock().lock(); try { listeners.remove(listener); } finally { rwl.writeLock().unlock(); } } /** * Utility method to encrypt a message. * * @param message message to encrypt * @return encrypted message */ private String encrypt(final String message) { if (encryptionManager != null) { return encryptionManager.encrypt(message); } return message; } private String decrypt(final String message) { String plainMessage; if (encryptionManager != null) { plainMessage = encryptionManager.decrypt(message); } else { plainMessage = message; } return plainMessage; } private class MessageBusRemoteInitializer extends ChannelInitializer { @Override public void initChannel(final SocketChannel ch) { ChannelPipeline pipeline = ch.pipeline(); // Add the text line codec combination first, pipeline.addLast("framer", new DelimiterBasedFrameDecoder(8192, true, Delimiters.lineDelimiter())); // the encoder and decoder are static as these are sharable pipeline.addLast("decoder", new StringDecoder(CharsetUtil.UTF_8)); pipeline.addLast("encoder", new StringEncoder(CharsetUtil.UTF_8)); // and then business logic. pipeline.addLast("handler", new MessageBusServerHandler()); } } @ChannelHandler.Sharable private class MessageBusServerHandler extends ChannelInboundHandlerAdapter { @Override public void channelActive(final ChannelHandlerContext ctx) { channelGroup.add(ctx.channel()); // maintain channels logger.log(Level.INFO, "Remote connection from: {0}", ctx.channel().remoteAddress().toString()); // Inform the client what they are talking with so they can establish a correct database url ctx.writeAndFlush(encrypt(PATH_PREFIX + dataBasePath) + EOL_DELIMITER); ctx.writeAndFlush(encrypt(DATA_STORE_TYPE_PREFIX + dataStoreType) + EOL_DELIMITER); } @Override public void channelInactive(final ChannelHandlerContext ctx) throws Exception { channelGroup.remove(ctx.channel()); super.channelInactive(ctx); } @Override public void channelRead(final ChannelHandlerContext ctx, final Object msg) { executorService.submit(() -> { processMessage(msg.toString()); ReferenceCountUtil.release(msg); }); } private void processMessage(final String message) { final String plainMessage = decrypt(message); rwl.readLock().lock(); try { channelGroup.writeAndFlush(encrypt(plainMessage) + EOL_DELIMITER).sync(); // Local listeners do not receive encrypted messages for (LocalServerListener listener : listeners) { listener.messagePosted(plainMessage); } logger.log(Level.FINE, "Broadcast: {0}", plainMessage); } catch (InterruptedException e) { logger.log(Level.SEVERE, e.getLocalizedMessage(), e); } finally { rwl.readLock().unlock(); } } @Override public void exceptionCaught(final ChannelHandlerContext ctx, final Throwable cause) { logger.log(Level.WARNING, "Unexpected exception from downstream.", cause); ctx.close(); } } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/message/MessageChannel.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.message; /** * Default message channels used by the engine. * * @author Craig Cavanaugh */ public enum MessageChannel { ACCOUNT, BUDGET, COMMODITY, CONFIG, REMINDER, SYSTEM, TAG, TRANSACTION } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/message/MessageListener.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.message; /** * Classes must implement this interface to register and lister the message events. * * @author Craig Cavanaugh */ public interface MessageListener { void messagePosted(Message message); } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/message/MessageProperty.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.message; /** * Message property Enums. * * @author Craig Cavanaugh */ public enum MessageProperty { ACCOUNT, BUDGET, COMMODITY, CONFIG, EXCHANGE_RATE, REMINDER, TAG, TRANSACTION } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/message/MessageProxy.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.message; import java.lang.ref.WeakReference; import java.util.ArrayList; import java.util.Iterator; import java.util.List; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.locks.ReentrantLock; /** * A Utility class that forwards messages to registered listeners. *

* This is useful for classes that must process a message first and then forward to other listeners for secondary * processing. * * @author Craig Cavanaugh */ public class MessageProxy { /** * Message Listeners. */ private final List> messageListeners = new ArrayList<>(); /** * Concurrency lock. */ private final ReentrantLock lock = new ReentrantLock(); /** * Shared thread pool for all instances of MessageProxy. */ private static final ExecutorService THREAD_POOL = Executors.newCachedThreadPool(); /** * Register a listener. * * @param messageListener listener to register with this proxy */ public final void addMessageListener(final MessageListener messageListener) { lock.lock(); try { messageListeners.add(new WeakReference<>(messageListener)); } finally { lock.unlock(); } } /** * Remove a listener. * * @param messageListener listener to register with this proxy */ public final void removeMessageListener(final MessageListener messageListener) { lock.lock(); try { Iterator> iterator = messageListeners.iterator(); while (iterator.hasNext()) { WeakReference reference = iterator.next(); MessageListener actionListener = reference.get(); if (actionListener == null || actionListener == messageListener) { iterator.remove(); } } } finally { lock.unlock(); } } /** * Forwards a message to an listeners. * * @param message message to forward */ public final void forwardMessage(final Message message) { lock.lock(); try { THREAD_POOL.submit(() -> { Iterator> iterator = messageListeners.iterator(); while (iterator.hasNext()) { WeakReference reference = iterator.next(); final MessageListener actionListener = reference.get(); if (actionListener != null) { THREAD_POOL.submit(() -> actionListener.messagePosted(message)); } else { iterator.remove(); } } }); } finally { lock.unlock(); } } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/message/XStreamFactory.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.message; import com.thoughtworks.xstream.XStream; import com.thoughtworks.xstream.converters.reflection.PureJavaReflectionProvider; import com.thoughtworks.xstream.io.xml.StaxDriver; import jgnash.engine.xstream.XStreamJVM9; /** * Utility class to generate an XStream instance. * * @author Craig Cavanaugh */ class XStreamFactory { private XStreamFactory() {} static XStream getInstance() { final XStream xstream = new XStreamJVM9(new PureJavaReflectionProvider(), new StaxDriver()); xstream.alias("Message", Message.class); xstream.alias("MessageProperty", MessageProperty.class); return xstream; } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/recurring/DailyReminder.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.recurring; import java.time.LocalDate; import javax.persistence.Entity; import jgnash.time.DateUtils; /** * A daily reminder. * * @author Craig Cavanaugh */ @Entity public class DailyReminder extends Reminder { public DailyReminder() { } @Override public RecurringIterator getIterator() { return new DailyIterator(); } @Override public ReminderType getReminderType() { return ReminderType.DAILY; } private class DailyIterator implements RecurringIterator { private LocalDate base; DailyIterator() { if (getLastDate() != null) { base = getLastDate(); } else { base = getStartDate().minusDays(getIncrement()); } } @Override public LocalDate next() { if (isEnabled()) { base = base.plusDays(getIncrement()); if (getEndDate() == null || DateUtils.before(base, getEndDate())) { return base; } } return null; } } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/recurring/MonthlyReminder.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.recurring; import java.time.DayOfWeek; import java.time.LocalDate; import java.time.temporal.TemporalField; import java.time.temporal.WeekFields; import java.util.Locale; import javax.persistence.Entity; import jgnash.time.DateUtils; /** * A monthly reminder / iterator. Dates get a little weird when iterating by DAY * and the day is early or late in the month. Months may be skipped or multiple * days in the same month. Iterating by DATE will be much more consistent. * * @author Craig Cavanaugh */ @Entity public class MonthlyReminder extends Reminder { /** * Defines if increment is by day of the week or day of the month. */ private int type = DATE; static final int DATE = 0; static final int DAY = 1; public MonthlyReminder() { } @Override public RecurringIterator getIterator() { return new MonthlyIterator(); } /** * Returns the Monthly Reminder type. * * @return Returns the type. */ public int getType() { return type; } @Override public ReminderType getReminderType() { return ReminderType.MONTHLY; } /** * Sets the Monthly Reminder type. * * @param type The type to set. */ public void setType(int type) { if (type == DATE || type == DAY) { this.type = type; } } private class MonthlyIterator implements RecurringIterator { private LocalDate base; MonthlyIterator() { if (getLastDate() != null) { base = getLastDate(); final TemporalField weekOfMonth = WeekFields.of(Locale.getDefault()).weekOfMonth(); final int week = base.get(weekOfMonth); // extract the current week final DayOfWeek day = base.getDayOfWeek(); // extract the current day of the week base = base.with(weekOfMonth, week); // force the week of the month base = base.with(day); // force the day of the week } else { if (type == DATE) { base = getStartDate().minusMonths(getIncrement()); } else if (type == DAY) { base = getStartDate(); final TemporalField weekOfMonth = WeekFields.of(Locale.getDefault()).weekOfMonth(); final int week = base.get(weekOfMonth); // extract the current week final DayOfWeek day = base.getDayOfWeek(); // extract the current day of the week base = getStartDate().minusMonths(getIncrement()); // decrement the month base = base.with(weekOfMonth, week); // force the week of the month base = base.with(day); // force the day of the week } } } @Override public LocalDate next() { if (isEnabled()) { if (type == DATE) { base = base.plusMonths(getIncrement()); } else { final TemporalField weekOfMonth = WeekFields.of(Locale.getDefault()).weekOfMonth(); final int week = base.get(weekOfMonth); // extract the current week final DayOfWeek day = base.getDayOfWeek(); // extract the current day of the week base = base.plusMonths(getIncrement()); // increment the month base = base.with(weekOfMonth, week); // force the week of the month base = base.with(day); // force the day of the week // if plusMonths resulted in an invalid date and adjusted to the prior week, bump it a week if (base.get(weekOfMonth) > week) { base = base.plusWeeks(1); base = base.with(weekOfMonth, week); // force the week of the month base = base.with(day); // force the day of the week } } if (getEndDate() == null || DateUtils.before(base, getEndDate())) { return base; } } return null; } } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/recurring/OneTimeReminder.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.recurring; import java.time.LocalDate; import javax.persistence.Entity; /** * A one time only reminder. * * @author Craig Cavanaugh */ @Entity public class OneTimeReminder extends Reminder { public OneTimeReminder() { } @Override public RecurringIterator getIterator() { return new OneTimeIterator(); } @Override public ReminderType getReminderType() { return ReminderType.ONETIME; } private class OneTimeIterator implements RecurringIterator { private boolean end = false; // one time only trigger @Override public LocalDate next() { if (isEnabled()) { if (getLastDate() == null && !end) { end = true; return getStartDate(); } } return null; } } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/recurring/PendingReminder.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.recurring; import java.time.LocalDate; import jgnash.util.NotNull; /** * Used to wrap a reminder and it's event date to aid sorting and display. * * @author Craig Cavanaugh */ public class PendingReminder implements Comparable { private final Reminder reminder; /** * The date for the register if a transaction is generated. */ final private LocalDate commitDate; /** * Approved state of the reminder. */ private boolean approved; public PendingReminder(final @NotNull Reminder reminder, final @NotNull LocalDate date) { this.reminder = reminder; commitDate = date; } @Override public int compareTo(final @NotNull PendingReminder o) { if (o.reminder == reminder && o.commitDate.equals(commitDate)) { return 0; } if (o.reminder == reminder) { return commitDate.compareTo(o.commitDate); } return reminder.compareTo(o.reminder); } @Override public boolean equals(final Object o) { if (this == o) { return true; } return o instanceof PendingReminder && ((PendingReminder) o).reminder == reminder && ((PendingReminder) o).commitDate.equals(commitDate); } @Override public int hashCode() { int hash = 5; hash = 79 * hash + (reminder != null ? reminder.hashCode() : 0); return 79 * hash + (commitDate != null ? commitDate.hashCode() : 0); } /** * Returns true if approved for execution of the {@code Reminder}. * * @return Returns the approved. */ public synchronized final boolean isApproved() { return approved; } /** * Controls approval state of the {@code Reminder}. * * @param approved The approved to set. */ public synchronized final void setApproved(final boolean approved) { this.approved = approved; } /** * Gets the commit date for the associated {@code Reminder}. * * @return Returns the commitDate. */ public synchronized final LocalDate getCommitDate() { return commitDate; } /** * Gets the {@code Reminder}. * * @return Returns the reminder. */ public synchronized final Reminder getReminder() { return reminder; } @Override public String toString() { return reminder.getDescription() + " " + commitDate; } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/recurring/RecurringIterator.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.recurring; import java.time.LocalDate; /** * Interface for recurring iterators. * * @author Craig Cavanaugh */ public interface RecurringIterator { /** * Returns the next date the event should occur. * * @return The next date in the sequence or null if the sequence is no longer valid. */ LocalDate next(); } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/recurring/Reminder.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.recurring; import java.time.LocalDate; import java.util.Objects; import java.util.logging.Level; import java.util.logging.Logger; import javax.persistence.CascadeType; import javax.persistence.Entity; import javax.persistence.ManyToOne; import javax.persistence.OneToOne; import jgnash.engine.Account; import jgnash.engine.StoredObject; import jgnash.engine.Transaction; import jgnash.util.NotNull; import jgnash.util.Nullable; /** * This is an abstract class for scheduled reminders. * * @author Craig Cavanaugh */ @Entity public abstract class Reminder extends StoredObject implements Comparable { /** * Number of days to notify in advance. */ private int daysAdvance; /** * Create the transaction automatically. */ private boolean autoCreate; /** * Description for this reminder. */ private String description; /** * Enabled state. */ private boolean enabled = true; /** * The last date the reminder will stop executing, may be null (Bug #2860259). */ private LocalDate endDate = null; /** * Number of periods to increment between events. */ private int increment = 1; /** * The last date the reminder was executed. *

* It should remain an increment of the iterator for correct operation. */ private LocalDate lastDate = null; /** * Notes for this reminder. */ private String notes = null; /** * The start date of this reminder. */ private LocalDate startDate = LocalDate.now(); @ManyToOne private Account account; /** * Reference to the transaction for this reminder. */ @OneToOne(orphanRemoval = true, cascade = {CascadeType.PERSIST, CascadeType.REMOVE}) private Transaction transaction; @Override public int compareTo(@NotNull final Reminder reminder) { int result = description.compareTo(reminder.description); if (result != 0) { return result; } result = getReminderType().compareTo(reminder.getReminderType()); if (result != 0) { return result; } return getUuid().compareTo(reminder.getUuid()); } /** * Gets the number of days in advance the reminder should execute. * * @return Returns the advanceRemindDays. */ public int getDaysAdvance() { return daysAdvance; } /** * Gets the description for the reminder. * * @return Returns the description. */ public String getDescription() { return description; } /** * Gets the ending date for the reminder. * * @return Returns the last date the reminder should execute. */ public @Nullable LocalDate getEndDate() { return endDate; } /** * Gets the {@code Iterator} for this {@code Reminder}. * * @return Returns the iterator. */ public abstract RecurringIterator getIterator(); /** * Gets the last recorded date for this {@code Reminder}. * @return Returns the last recorded date */ public @Nullable LocalDate getLastDate() { return lastDate; } /** * Gets the start date for this {@code Reminder}. * * @return Returns the start date. */ public @NotNull LocalDate getStartDate() { return startDate; } /** * Returns a clone of the transaction. A clone is returned to prevent * accidental insertion of the original into the engine. * * @return Returns the transaction. */ public Transaction getTransaction() { if (transaction != null) { try { return (Transaction) transaction.clone(); } catch (CloneNotSupportedException ex) { Logger.getLogger(Reminder.class.getName()).log(Level.SEVERE, null, ex); } } return null; } public abstract ReminderType getReminderType(); @Override public boolean equals(final Object other) { return this == other || other != null && getClass() == other.getClass() && this.getUuid().equals(((Reminder) other).getUuid()); } /** * Returns true is this {@code Reminder} is executed without user interaction. * * @return Returns the autoCreate. */ public boolean isAutoCreate() { return autoCreate; } /** * Getter for property enabled. * * @return Value of property enabled. */ public boolean isEnabled() { return enabled; } /** * Sets the number of days in advance this {@code Reminder} should execute. * * @param advanceRemindDays The advanceRemindDays to set. */ public void setDaysAdvance(final int advanceRemindDays) { this.daysAdvance = advanceRemindDays; } /** * Controls auto entry of the {@code Reminder}. * @param autoCreate The autoCreate to set. */ public void setAutoCreate(final boolean autoCreate) { this.autoCreate = autoCreate; } /** * Sets the description for this {@code Reminder}. * * @param description The description to set. */ public void setDescription(@NotNull final String description) { Objects.requireNonNull(description); this.description = description; } /** * Setter for property enabled. * * @param enabled New value of property enabled. */ public void setEnabled(final boolean enabled) { this.enabled = enabled; } /** * Sets the last date for the iterator in the series. * * @param endDate The last date the reminder should execute. */ public void setEndDate(final @Nullable LocalDate endDate) { this.endDate = endDate; } /** * Sets the last date fired to the next iterator date in the series. * * @param lastDate The lastDate to set. */ private void setLastDate(final @NotNull LocalDate lastDate) { this.lastDate = lastDate; } /** * Increment the last date fired to the next iterator date in the series. */ public void setLastDate() { setLastDate(getIterator().next()); } /** * Set the starting date for the reminder. * * @param startDate The startDate to set. */ public void setStartDate(final @NotNull LocalDate startDate) { this.startDate = startDate; } /** * Assign the transaction to be entered upon acceptable of the reminder. * * @param transaction The transaction to set. */ public void setTransaction(final Transaction transaction) { this.transaction = transaction; } /** * Get the Reminders's increment. * * @return Returns the increment. */ public int getIncrement() { return increment; } /** * Set the Reminders's increment. * * @param increment The increment to set. */ public void setIncrement(final int increment) { this.increment = increment; } @Override public String toString() { return description; } /** * Sets the Reminder's notes. * * @param notes The notes to set. */ public void setNotes(final String notes) { this.notes = notes; } /** * Get the Reminder's notes. * * @return Returns the notes. */ public String getNotes() { return notes; } /** * Sets the base Account associated with the Reminder. * * @param account base account for this Reminder */ public void setAccount(final Account account) { this.account = account; } /** * Returns the base Account associated with the Reminder. * * @return Returns the account */ public Account getAccount() { return account; } @Override public Object clone() throws CloneNotSupportedException { Reminder r = (Reminder) super.clone(); // create a deep clone r.setEndDate(getEndDate()); r.setStartDate(getStartDate()); if (getLastDate() != null) { r.setLastDate(getLastDate()); } if (getTransaction() != null) { r.setTransaction((Transaction) getTransaction().clone()); } return r; } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/recurring/ReminderType.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.recurring; import jgnash.resource.util.ResourceUtils; /** * Reminder type class. * * @author Craig Cavanaugh * */ public enum ReminderType { ONETIME(ResourceUtils.getString("Period.OnlyOnce")), DAILY(ResourceUtils.getString("Period.Daily")), WEEKLY(ResourceUtils.getString("Period.Weekly")), MONTHLY(ResourceUtils.getString("Period.Monthly")), YEARLY(ResourceUtils.getString("Period.Yearly")); private final transient String typeName; ReminderType(String name) { typeName = name; } /** * Prints the ReminderType name in the toString method. */ @Override public final String toString() { return typeName; } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/recurring/WeeklyReminder.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.recurring; import java.time.DayOfWeek; import java.time.LocalDate; import javax.persistence.Entity; import jgnash.time.DateUtils; /** * A weekly reminder. * * @author Craig Cavanaugh */ @Entity public class WeeklyReminder extends Reminder { public WeeklyReminder() { } @Override public ReminderType getReminderType() { return ReminderType.WEEKLY; } @Override public RecurringIterator getIterator() { return new WeeklyIterator(); } private class WeeklyIterator implements RecurringIterator { private LocalDate base; WeeklyIterator() { if (getLastDate() != null) { final DayOfWeek dayOfWeek = DayOfWeek.from(getStartDate()); base = getLastDate().plusDays(1); // plus one day to force next iteration // adjust for actual target date, it could have been modified since the last date base = (LocalDate) dayOfWeek.adjustInto(base); } else { base = getStartDate().minusWeeks(getIncrement()); } } @Override public LocalDate next() { if (isEnabled()) { final DayOfWeek dayOfWeek = DayOfWeek.from(getStartDate()); base = base.plusWeeks(getIncrement()); base = (LocalDate) dayOfWeek.adjustInto(base); if (getEndDate() == null || DateUtils.before(base, getEndDate())) { return base; } } return null; } } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/recurring/YearlyReminder.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.recurring; import java.time.LocalDate; import javax.persistence.Entity; import jgnash.time.DateUtils; /** * A yearly reminder. * * @author Craig Cavanaugh */ @Entity public class YearlyReminder extends Reminder { public YearlyReminder() { } @Override public RecurringIterator getIterator() { return new YearlyIterator(); } @Override public ReminderType getReminderType() { return ReminderType.YEARLY; } private class YearlyIterator implements RecurringIterator { private LocalDate base; YearlyIterator() { if (getLastDate() != null) { base = getLastDate(); if (getStartDate().lengthOfYear() == getStartDate().getDayOfYear()) { base = base.withDayOfYear(base.lengthOfYear()); } else { // adjust for actual target date, it could have been modified since the last date base = base.withDayOfYear(getStartDate().getDayOfYear()); } } else { base = getStartDate().minusYears(getIncrement()); } } @Override public LocalDate next() { if (isEnabled()) { base = base.plusYears(getIncrement()); if (getStartDate().lengthOfYear() == getStartDate().getDayOfYear()) { base = base.withDayOfYear(base.lengthOfYear()); } else { // adjust for actual target date, it could have been modified since the last date base = base.withDayOfYear(getStartDate().getDayOfYear()); } if (getEndDate() == null || DateUtils.before(base, getEndDate())) { return base; } } return null; } } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/xstream/AbstractXStreamContainer.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2021 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.xstream; import java.io.IOException; import java.math.BigDecimal; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.time.LocalDate; import java.util.ArrayList; import java.util.Collection; import java.util.List; import java.util.UUID; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReadWriteLock; import java.util.concurrent.locks.ReentrantReadWriteLock; import java.util.logging.Level; import java.util.logging.Logger; import java.util.stream.Collectors; import jgnash.engine.Account; import jgnash.engine.AmortizeObject; import jgnash.engine.CommodityNode; import jgnash.engine.Config; import jgnash.engine.CurrencyNode; import jgnash.engine.ExchangeRate; import jgnash.engine.ExchangeRateHistoryNode; import jgnash.engine.InvestmentTransaction; import jgnash.engine.RootAccount; import jgnash.engine.SecurityHistoryEvent; import jgnash.engine.SecurityHistoryNode; import jgnash.engine.SecurityNode; import jgnash.engine.StoredObject; import jgnash.engine.Tag; import jgnash.engine.Transaction; import jgnash.engine.TransactionEntry; import jgnash.engine.TransactionEntryAddX; import jgnash.engine.TransactionEntryBuyX; import jgnash.engine.TransactionEntryDividendX; import jgnash.engine.TransactionEntryMergeX; import jgnash.engine.TransactionEntryReinvestDivX; import jgnash.engine.TransactionEntryRemoveX; import jgnash.engine.TransactionEntrySellX; import jgnash.engine.TransactionEntrySplitX; import jgnash.engine.budget.Budget; import jgnash.engine.budget.BudgetGoal; import jgnash.time.Period; import jgnash.util.FileLocker; import jgnash.util.FileUtils; import jgnash.util.NotNull; import com.thoughtworks.xstream.XStream; import com.thoughtworks.xstream.converters.reflection.ReflectionProvider; import com.thoughtworks.xstream.hibernate.converter.HibernatePersistentCollectionConverter; import com.thoughtworks.xstream.hibernate.converter.HibernatePersistentMapConverter; import com.thoughtworks.xstream.hibernate.converter.HibernateProxyConverter; import com.thoughtworks.xstream.hibernate.mapper.HibernateMapper; import com.thoughtworks.xstream.io.HierarchicalStreamDriver; import com.thoughtworks.xstream.mapper.MapperWrapper; import com.thoughtworks.xstream.security.ArrayTypePermission; import com.thoughtworks.xstream.security.NoTypePermission; import com.thoughtworks.xstream.security.PrimitiveTypePermission; import com.thoughtworks.xstream.security.WildcardTypePermission; /** * Abstract XStream container. * * @author Craig Cavanaugh */ abstract class AbstractXStreamContainer { private static final String DESCRIPTION = "description"; final List objects = new ArrayList<>(); final ReadWriteLock readWriteLock = new ReentrantReadWriteLock(true); final Path path; private final FileLocker fileLocker = new FileLocker(); AbstractXStreamContainer(final Path path) { this.path = path; } /** * Creates a backup file if it exists. * * @param origFile file to check and backup */ static void createBackup(final Path origFile) { if (Files.exists(origFile)) { final Path backup = Paths.get(origFile + ".backup"); if (Files.exists(backup)) { try { Files.delete(backup); } catch (IOException e) { Logger.getLogger(AbstractXStreamContainer.class.getName()) .log(Level.WARNING, "Was not able to delete the old backup file: {0}", backup); } } FileUtils.copyFile(origFile, backup); } } /** * Returns a list of objects that are assignable from from the specified Class. *

* The returned list may be modified without causing side effects * * @param the type of class to query * @param values Collection of objects to query * @param clazz the Class to query for * @return A list of type T containing objects of type clazz */ @SuppressWarnings("unchecked") @NotNull static List query(final Collection values, final Class clazz) { return values.parallelStream().filter(o -> clazz.isAssignableFrom(o.getClass())) .map(o -> (T) o).collect(Collectors.toList()); } static XStream configureXStream(final XStreamJVM9 xstream) { // configure XStream security xstream.addPermission(NoTypePermission.NONE); xstream.addPermission(PrimitiveTypePermission.PRIMITIVES); xstream.addPermission(ArrayTypePermission.ARRAYS); xstream.addPermission(new WildcardTypePermission(new String[] {"java.**", "jgnash.engine.**"})); xstream.ignoreUnknownElements(); // gracefully ignore fields in the file that do not have object members xstream.setMode(XStream.ID_REFERENCES); xstream.alias("date", LocalDate.class); // use date instead of local-date by default xstream.alias("Decimal", BigDecimal.class); xstream.alias("Account", Account.class); xstream.alias("RootAccount", RootAccount.class); xstream.alias("Budget", Budget.class); xstream.alias("BudgetGoal", BudgetGoal.class); xstream.alias("Config", Config.class); xstream.alias("CurrencyNode", CurrencyNode.class); xstream.alias("ExchangeRate", ExchangeRate.class); xstream.alias("ExchangeRateHistoryNode", ExchangeRateHistoryNode.class); xstream.alias("InvestmentTransaction", InvestmentTransaction.class); xstream.alias("BudgetPeriod", Period.class); xstream.alias("SecurityNode", SecurityNode.class); xstream.alias("SecurityHistoryNode", SecurityHistoryNode.class); xstream.alias("SecurityHistoryEvent", SecurityHistoryEvent.class); xstream.alias("Tag", Tag.class); xstream.alias("Transaction", Transaction.class); xstream.alias("TransactionEntry", TransactionEntry.class); xstream.alias("TransactionEntryAddX", TransactionEntryAddX.class); xstream.alias("TransactionEntryBuyX", TransactionEntryBuyX.class); xstream.alias("TransactionEntryDividendX", TransactionEntryDividendX.class); xstream.alias("TransactionEntryMergeX", TransactionEntryMergeX.class); xstream.alias("TransactionEntryReinvestDivX", TransactionEntryReinvestDivX.class); xstream.alias("TransactionEntryRemoveX", TransactionEntryRemoveX.class); xstream.alias("TransactionEntrySellX", TransactionEntrySellX.class); xstream.alias("TransactionEntrySplitX", TransactionEntrySplitX.class); xstream.useAttributeFor(Account.class, "placeHolder"); xstream.useAttributeFor(Account.class, "locked"); xstream.useAttributeFor(Account.class, "visible"); xstream.useAttributeFor(Account.class, "name"); xstream.useAttributeFor(Account.class, DESCRIPTION); xstream.useAttributeFor(Budget.class, DESCRIPTION); xstream.useAttributeFor(Budget.class, "name"); xstream.useAttributeFor(Budget.class, "roundingScale"); xstream.useAttributeFor(Budget.class, "roundingMode"); xstream.useAttributeFor(Budget.class, "startMonth"); xstream.useAttributeFor(CommodityNode.class, "symbol"); xstream.useAttributeFor(CommodityNode.class, "scale"); xstream.useAttributeFor(CommodityNode.class, "prefix"); xstream.useAttributeFor(CommodityNode.class, "suffix"); xstream.useAttributeFor(CommodityNode.class, DESCRIPTION); xstream.useAttributeFor(SecurityHistoryNode.class, "date"); xstream.useAttributeFor(SecurityHistoryNode.class, "price"); xstream.useAttributeFor(SecurityHistoryNode.class, "high"); xstream.useAttributeFor(SecurityHistoryNode.class, "low"); xstream.useAttributeFor(SecurityHistoryNode.class, "volume"); xstream.useAttributeFor(SecurityHistoryEvent.class, "date"); xstream.useAttributeFor(SecurityHistoryEvent.class, "type"); xstream.useAttributeFor(SecurityHistoryEvent.class, "value"); xstream.useAttributeFor(StoredObject.class, "uuid"); xstream.useAttributeFor(Tag.class, "name"); xstream.useAttributeFor(Tag.class, "color"); xstream.useAttributeFor(Tag.class, "unicode"); xstream.omitField(StoredObject.class, "markedForRemoval"); // Ignore fields required for JPA xstream.omitField(StoredObject.class, "version"); xstream.omitField(AmortizeObject.class, "id"); xstream.omitField(BudgetGoal.class, "id"); xstream.omitField(TransactionEntry.class, "id"); xstream.omitField(ExchangeRateHistoryNode.class, "id"); xstream.omitField(SecurityHistoryNode.class, "id"); xstream.omitField(SecurityHistoryEvent.class, "id"); // Filters out the hibernate xstream.registerConverter(new HibernateProxyConverter()); xstream.registerConverter(new HibernatePersistentCollectionConverter(xstream.getMapper())); xstream.registerConverter(new HibernatePersistentMapConverter(xstream.getMapper())); // These will trigger an illegal reflective access operation //xstream.registerConverter(new HibernatePersistentSortedMapConverter(xstream.getMapper())); //xstream.registerConverter(new HibernatePersistentSortedSetConverter(xstream.getMapper())); return xstream; } boolean acquireFileLock() { return fileLocker.acquireLock(path); } void releaseFileLock() { fileLocker.release(); } abstract void commit(); boolean set(final StoredObject object) { boolean result = false; readWriteLock.writeLock().lock(); try { if (get(object.getUuid()) == null) { // make sure the UUID is unique before adding objects.add(object); } result = true; } catch (final Exception ex) { Logger.getLogger(AbstractXStreamContainer.class.getName()).log(Level.SEVERE, null, ex); } finally { readWriteLock.writeLock().unlock(); } return result; } void delete(final StoredObject object) { readWriteLock.writeLock().lock(); try { objects.remove(object); } finally { readWriteLock.writeLock().unlock(); } } StoredObject get(final UUID uuid) { StoredObject result = null; Lock l = readWriteLock.readLock(); l.lock(); try { for (StoredObject o : objects) { if (o.getUuid().equals(uuid)) { result = o; } } } finally { l.unlock(); } return result; } List query(final Class clazz) { readWriteLock.readLock().lock(); try { return query(objects, clazz); } finally { readWriteLock.readLock().unlock(); } } void close() { releaseFileLock(); } String getFileName() { if (path != null) { return path.toString(); } return null; } /** * Returns of list of all {@code StoredObjects} held within this container. The returned list is a defensive * copy. * * @return A list of all {@code StoredObjects} * @see jgnash.engine.StoredObject */ List asList() { readWriteLock.readLock().lock(); try { return new ArrayList<>(objects); } finally { readWriteLock.readLock().unlock(); } } static class XStreamOut extends XStreamJVM9 { XStreamOut(final ReflectionProvider reflectionProvider, final HierarchicalStreamDriver hierarchicalStreamDriver) { super(reflectionProvider, hierarchicalStreamDriver); } @Override protected MapperWrapper wrapMapper(final MapperWrapper next) { return new HibernateMapper(next); } } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/xstream/AbstractXStreamDAO.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.xstream; import java.util.Objects; import java.util.UUID; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.locks.ReentrantLock; import jgnash.engine.StoredObject; import jgnash.engine.dao.AbstractDAO; import jgnash.engine.dao.DAO; import jgnash.util.NotNull; /** * Simple object container for StoredObjects that reads and writes and xml file. * * @author Craig Cavanaugh */ abstract class AbstractXStreamDAO extends AbstractDAO implements DAO { /** * Maximum time in seconds before a commit will occur. */ static final int MAX_COMMIT_TIME = 30; // seconds static final AtomicInteger commitCount = new AtomicInteger(0); final AbstractXStreamContainer container; private static final ReentrantLock commitLock = new ReentrantLock(); private static final int MAX_COMMIT_COUNT = 250; AbstractXStreamDAO(@NotNull final AbstractXStreamContainer container) { Objects.requireNonNull(container); this.container = container; } private StoredObject getObjectByUuid(final UUID uuid) { return container.get(uuid); } @Override @SuppressWarnings("unchecked") public T getObjectByUuid(final Class clazz, final UUID uuid) { Object o = getObjectByUuid(uuid); if (o != null && clazz.isAssignableFrom(o.getClass())) { return (T) o; } return null; } final void commit() { dirtyFlag.set(true); if (commitCount.getAndIncrement() >= MAX_COMMIT_COUNT) { commitAndReset(); } } final void commitAndReset() { commitLock.lock(); try { commitCount.set(0); container.commit(); } finally { commitLock.unlock(); } } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/xstream/BinaryContainer.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.xstream; import java.io.BufferedInputStream; import java.io.BufferedOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.ObjectInputStream; import java.io.ObjectOutputStream; import java.io.OutputStream; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardOpenOption; import java.util.ArrayList; import java.util.Collection; import java.util.List; import java.util.function.DoubleConsumer; import java.util.logging.Level; import java.util.logging.Logger; import jgnash.engine.CommodityNode; import jgnash.engine.Config; import jgnash.engine.ExchangeRate; import jgnash.engine.RootAccount; import jgnash.engine.StoredObject; import jgnash.engine.StoredObjectComparator; import jgnash.engine.Tag; import jgnash.engine.budget.Budget; import jgnash.engine.recurring.Reminder; import jgnash.util.NotNull; import com.thoughtworks.xstream.XStream; import com.thoughtworks.xstream.converters.reflection.PureJavaReflectionProvider; import com.thoughtworks.xstream.io.binary.BinaryStreamDriver; /** * Simple object container for StoredObjects that reads and writes a binary file * using XStream. * * @author Craig Cavanaugh */ class BinaryContainer extends AbstractXStreamContainer { BinaryContainer(final Path path) { super(path); } @Override void commit() { writeBinary(); } private synchronized void writeBinary() { readWriteLock.readLock().lock(); try { releaseFileLock(); writeBinary(objects, path, ignored -> { }); } finally { if (!acquireFileLock()) { // lock the file on open Logger.getLogger(BinaryContainer.class.getName()).severe("Could not acquire the file lock"); } readWriteLock.readLock().unlock(); } } /** * Writes an XML file given a collection of StoredObjects. TrashObjects and * objects marked for removal are not written. If the file already exists, * it will be overwritten. * * @param objects Collection of StoredObjects to write * @param path file to write */ static synchronized void writeBinary(@NotNull final Collection objects, @NotNull final Path path, @NotNull final DoubleConsumer percentCompleteConsumer) { final Logger logger = Logger.getLogger(BinaryContainer.class.getName()); if (!Files.exists(path.getParent())) { try { Files.createDirectories(path.getParent()); logger.info("Created missing directories"); } catch (final IOException e) { logger.log(Level.SEVERE, e.getLocalizedMessage(), e); } } percentCompleteConsumer.accept(0); createBackup(path); List list = new ArrayList<>(); list.addAll(query(objects, Budget.class)); list.addAll(query(objects, Config.class)); list.addAll(query(objects, CommodityNode.class)); list.addAll(query(objects, ExchangeRate.class)); list.addAll(query(objects, RootAccount.class)); list.addAll(query(objects, Reminder.class)); list.addAll(query(objects, Tag.class)); percentCompleteConsumer.accept(0.25); // remove any objects marked for removal list.removeIf(StoredObject::isMarkedForRemoval); // sort the list list.sort(new StoredObjectComparator()); percentCompleteConsumer.accept(0.5); logger.info("Writing Binary file"); try (final OutputStream os = new BufferedOutputStream(Files.newOutputStream(path))) { final XStream xstream = configureXStream(new XStreamOut(new PureJavaReflectionProvider(), new BinaryStreamDriver())); try (final ObjectOutputStream out = xstream.createObjectOutputStream(os)) { out.writeObject(list); out.flush(); } os.flush(); // forcibly flush before letting go of the resources to help older windows systems write correctly } catch (IOException e) { logger.log(Level.SEVERE, e.getLocalizedMessage(), e); } logger.info("Writing Binary file complete"); percentCompleteConsumer.accept(1); } void readBinary() { // A file lock will be held on Windows OS when reading try (final InputStream fis = new BufferedInputStream(Files.newInputStream(path, StandardOpenOption.READ))) { readWriteLock.writeLock().lock(); final XStream xstream = configureXStream(new XStreamJVM9(new StoredObjectReflectionProvider(objects), new BinaryStreamDriver())); try (final ObjectInputStream in = xstream.createObjectInputStream(fis)) { in.readObject(); } } catch (final IOException | ClassNotFoundException e) { Logger.getLogger(BinaryContainer.class.getName()).log(Level.SEVERE, null, e); } finally { if (!acquireFileLock()) { // lock the file on open Logger.getLogger(BinaryContainer.class.getName()).severe("Could not acquire the file lock"); } readWriteLock.writeLock().unlock(); } } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/xstream/BinaryXStreamDataStore.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.xstream; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.Collection; import java.util.List; import java.util.function.DoubleConsumer; import java.util.logging.Logger; import jgnash.engine.Config; import jgnash.engine.DataStore; import jgnash.engine.DataStoreType; import jgnash.engine.Engine; import jgnash.engine.StoredObject; import jgnash.engine.attachment.LocalAttachmentManager; import jgnash.engine.concurrent.LocalLockManager; import jgnash.util.NotNull; import jgnash.resource.util.ResourceUtils; /** * XML specific code for data storage and creating an engine. * * @author Craig Cavanaugh */ public class BinaryXStreamDataStore implements DataStore { private static final Logger logger = Logger.getLogger(BinaryXStreamDataStore.class.getName()); public static final String FILE_EXT = ".bxds"; private BinaryContainer container; /** * Close the open {@code Engine}. * * @see jgnash.engine.DataStore#closeEngine() */ @Override public void closeEngine() { container.commit(); // force a commit container.close(); container = null; } /** * Create an engine instance that uses a local XML file. * * @see jgnash.engine.DataStore#getLocalEngine(String, String, char[]) */ @Override public Engine getLocalEngine(final String fileName, final String engineName, final char[] password) { Path path = Paths.get(fileName); container = new BinaryContainer(path); if (Files.exists(path)) { container.readBinary(); } Engine engine = new Engine(new XStreamEngineDAO(container), new LocalLockManager(), new LocalAttachmentManager(), engineName); logger.info("Created local Binary container and engine"); return engine; } /** * {@code XMLDataStore} will always return false. * * @see jgnash.engine.DataStore#isLocal() */ @Override public boolean isLocal() { return true; } /** * Returns the default file extension for this {@code DataStore}. * * @see jgnash.engine.DataStore#getFileExt() * @see BinaryXStreamDataStore#FILE_EXT */ @Override @NotNull public final String getFileExt() { return FILE_EXT; } /** * Returns the full path to the file the DataStore is using. * * @see jgnash.engine.DataStore#getFileName() */ @Override public final String getFileName() { return container.getFileName(); } @Override public DataStoreType getType() { return DataStoreType.BINARY_XSTREAM; } /** * XMLDataStore will throw an exception if called. * * @see jgnash.engine.DataStore#getClientEngine(String, int, char[], String) * @throws UnsupportedOperationException thrown if an attempt is made to use as a remote data store */ @Override public Engine getClientEngine(final String host, final int port, final char[] password, final String engineName) { throw new UnsupportedOperationException("Client / Server operation not supported for this type."); } /** * Returns the string representation of this {@code DataStore}. * * @return string representation of this {@code DataStore}. */ @Override public String toString() { return ResourceUtils.getString("DataStoreType.Bxds"); } /* * @see jgnash.engine.DataStore#saveAs(java.util.Collection) */ @Override public void saveAs(final Path path, final Collection objects, final DoubleConsumer percentComplete) { BinaryContainer.writeBinary(objects, path, percentComplete); } /** * Opens the file in readonly mode and reads the version of the file format. * * @param file * {@code Path} to open * @return file version */ public static float getFileVersion(final Path file) { float fileVersion = 0; if (Files.exists(file)) { final BinaryContainer container = new BinaryContainer(file); try { container.readBinary(); List list = container.query(Config.class); if (list.size() == 1) { fileVersion = Float.parseFloat(list.get(0).getFileFormat()); } else { fileVersion = Float.parseFloat(list.get(0).getFileFormat()); logger.severe("A duplicate config object was found"); } } finally { container.close(); } } return fileVersion; } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/xstream/StoredObjectReflectionProvider.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.xstream; import com.thoughtworks.xstream.converters.reflection.PureJavaReflectionProvider; import java.util.List; import jgnash.engine.StoredObject; /** * Expanded XStream reflection provider. * * This will load all objects that extend {@code StoredObject} into a supplied list as they are created * * @author Craig Cavanaugh */ final class StoredObjectReflectionProvider extends PureJavaReflectionProvider { /** * Reference to the supplied list to load objects into. */ private final List objects; StoredObjectReflectionProvider(final List objects) { this.objects = objects; } @Override public Object newInstance(final Class type) { Object o = super.newInstance(type); if (o instanceof StoredObject) { objects.add((StoredObject) o); } return o; } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/xstream/XMLContainer.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.xstream; import java.io.IOException; import java.io.ObjectInputStream; import java.io.ObjectOutputStream; import java.io.Reader; import java.io.Writer; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.Collection; import java.util.List; import java.util.function.DoubleConsumer; import java.util.logging.Level; import java.util.logging.Logger; import jgnash.engine.CommodityNode; import jgnash.engine.Config; import jgnash.engine.Engine; import jgnash.engine.ExchangeRate; import jgnash.engine.RootAccount; import jgnash.engine.StoredObject; import jgnash.engine.StoredObjectComparator; import jgnash.engine.Tag; import jgnash.engine.budget.Budget; import jgnash.engine.recurring.Reminder; import com.thoughtworks.xstream.XStream; import com.thoughtworks.xstream.io.xml.StaxDriver; import com.thoughtworks.xstream.converters.reflection.PureJavaReflectionProvider; import com.thoughtworks.xstream.io.xml.PrettyPrintWriter; import jgnash.util.NotNull; /** * Simple object container for StoredObjects that reads and writes an XML file. * * @author Craig Cavanaugh */ class XMLContainer extends AbstractXStreamContainer { XMLContainer(final Path path) { super(path); } /** * Writes an XML file given a collection of StoredObjects. TrashObjects and * objects marked for removal are not written. If the file already exists, * it will be overwritten. * * @param objects Collection of StoredObjects to write * @param path file to write */ static synchronized void writeXML(@NotNull final Collection objects, @NotNull final Path path, @NotNull final DoubleConsumer percentCompleteConsumer) { Logger logger = Logger.getLogger(XMLContainer.class.getName()); if (!Files.exists(path.getParent())) { try { Files.createDirectories(path.getParent()); logger.info("Created missing directories"); } catch (final IOException e) { logger.log(Level.SEVERE, e.getLocalizedMessage(), e); } } percentCompleteConsumer.accept(0); createBackup(path); List list = new ArrayList<>(); list.addAll(query(objects, Budget.class)); list.addAll(query(objects, Config.class)); list.addAll(query(objects, CommodityNode.class)); list.addAll(query(objects, ExchangeRate.class)); list.addAll(query(objects, RootAccount.class)); list.addAll(query(objects, Reminder.class)); list.addAll(query(objects, Tag.class)); percentCompleteConsumer.accept(0.25); // remove any objects marked for removal list.removeIf(StoredObject::isMarkedForRemoval); // sort the list list.sort(new StoredObjectComparator()); percentCompleteConsumer.accept(0.5); logger.info("Writing XML file"); try (final Writer writer = Files.newBufferedWriter(path, StandardCharsets.UTF_8)) { writer.write("\n"); writer.write("\n"); final XStream xstream = configureXStream(new XStreamOut(new PureJavaReflectionProvider(), new StaxDriver())); try (final ObjectOutputStream out = xstream.createObjectOutputStream(new PrettyPrintWriter(writer))) { out.writeObject(list); out.flush(); // forcibly flush before letting go of the resources to help older windows systems write correctly } catch (final Exception e) { logger.log(Level.SEVERE, e.getLocalizedMessage(), e); } } catch (final IOException e) { logger.log(Level.SEVERE, e.getLocalizedMessage(), e); } logger.info("Writing XML file complete"); percentCompleteConsumer.accept(1); } @Override void commit() { writeXML(); } private synchronized void writeXML() { readWriteLock.readLock().lock(); try { releaseFileLock(); writeXML(objects, path, ignored -> { }); } finally { if (!acquireFileLock()) { // lock the file on open Logger.getLogger(XMLContainer.class.getName()).severe("Could not acquire the file lock"); } readWriteLock.readLock().unlock(); } } void readXML() { // A file lock will be held on Windows OS when reading try (final Reader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) { readWriteLock.writeLock().lock(); final XStream xstream = configureXStream(new XStreamJVM9(new StoredObjectReflectionProvider(objects), new StaxDriver())); try (final ObjectInputStream in = xstream.createObjectInputStream(reader)) { in.readObject(); } } catch (final IOException | ClassNotFoundException e) { Logger.getLogger(XMLContainer.class.getName()).log(Level.SEVERE, null, e); } finally { if (!acquireFileLock()) { // lock the file on open Logger.getLogger(XMLContainer.class.getName()).severe("Could not acquire the file lock"); } readWriteLock.writeLock().unlock(); } } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/xstream/XMLDataStore.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.xstream; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.Collection; import java.util.List; import java.util.function.DoubleConsumer; import java.util.logging.Logger; import jgnash.engine.Config; import jgnash.engine.DataStore; import jgnash.engine.DataStoreType; import jgnash.engine.Engine; import jgnash.engine.StoredObject; import jgnash.engine.attachment.LocalAttachmentManager; import jgnash.engine.concurrent.LocalLockManager; import jgnash.util.NotNull; import jgnash.resource.util.ResourceUtils; /** * XML specific code for data storage and creating an engine. * * @author Craig Cavanaugh */ public class XMLDataStore implements DataStore { private static final Logger logger = Logger.getLogger(XMLDataStore.class.getName()); public static final String FILE_EXT = ".xml"; private XMLContainer container; /** * Close the open {@code Engine}. * * @see DataStore#closeEngine() */ @Override public void closeEngine() { container.commit(); // force a commit container.close(); container = null; } /** * Create an engine instance that uses a local XML file. * * @see DataStore#getLocalEngine(java.lang.String, java.lang.String, char[]) */ @Override public Engine getLocalEngine(final String fileName, final String engineName, final char[] password) { final Path path = Paths.get(fileName); container = new XMLContainer(path); if (Files.exists(path)) { container.readXML(); } Engine engine = new Engine(new XStreamEngineDAO(container), new LocalLockManager(), new LocalAttachmentManager(), engineName); logger.info("Created local XML container and engine"); return engine; } /** * {@code XMLDataStore} will always return false. * * @see DataStore#isLocal() */ @Override public boolean isLocal() { return true; } /** * Returns the default file extension for this {@code DataStore}. * * @see DataStore#getFileExt() * @see XMLDataStore#FILE_EXT */ @NotNull @Override public final String getFileExt() { return FILE_EXT; } /** * Returns the full path to the file the DataStore is using. * * @see DataStore#getFileName() */ @Override public final String getFileName() { return container.getFileName(); } @Override public DataStoreType getType() { return DataStoreType.XML; } /** * XMLDataStore will throw an exception if called. * * @see DataStore#getClientEngine(java.lang.String, int, char[], java.lang.String) * @throws UnsupportedOperationException thrown if an attempt is made to use as a remote data store */ @Override public Engine getClientEngine(final String host, final int port, final char[] password, final String engineName) { throw new UnsupportedOperationException("Client / Server operation not supported for this type."); } /** * Returns the string representation of this {@code DataStore}. * * @return string representation of this * {@code DataStore}. */ @Override public String toString() { return ResourceUtils.getString("DataStoreType.XML"); } @Override public void saveAs(final Path path, final Collection objects, final DoubleConsumer percentComplete) { XMLContainer.writeXML(objects, path, percentComplete); } /** * Opens the file in readonly mode and reads the version of the file format. * * @param file * {@code File} to open * @return file version */ public static float getFileVersion(final Path file) { float fileVersion = 0; if (Files.exists(file)) { final XMLContainer container = new XMLContainer(file); try { container.readXML(); List list = container.query(Config.class); if (list.size() == 1) { fileVersion = Float.parseFloat(list.get(0).getFileFormat()); } else { fileVersion = Float.parseFloat(list.get(0).getFileFormat()); Logger.getLogger(XMLDataStore.class.getName()).severe("A duplicate config object was found"); } } finally { container.close(); } } return fileVersion; } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/xstream/XStreamAccountDAO.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.xstream; import java.util.List; import java.util.UUID; import java.util.logging.Logger; import java.util.stream.Collectors; import jgnash.engine.Account; import jgnash.engine.AccountType; import jgnash.engine.RootAccount; import jgnash.engine.SecurityNode; import jgnash.engine.dao.AccountDAO; /** * XML Account DAO. * * @author Craig Cavanaugh */ class XStreamAccountDAO extends AbstractXStreamDAO implements AccountDAO { private static final Logger logger = Logger.getLogger(XStreamAccountDAO.class.getName()); XStreamAccountDAO(final AbstractXStreamContainer container) { super(container); } @Override public RootAccount getRootAccount() { RootAccount root = null; List list = container.query(RootAccount.class); if (list.size() == 1) { root = list.get(0); } if (list.size() > 1) { // old bug logger.severe("More than one RootAccount found"); for (final RootAccount rootAccount : list) { if (rootAccount.getChildCount() > 0) { root = rootAccount; } } } return root; } @Override public List getAccountList() { return stripMarkedForRemoval(container.query(Account.class)); } @Override public boolean addAccount(final Account parent, final Account child) { container.set(child); commit(); return true; } @Override public boolean addRootAccount(final RootAccount account) { container.set(account); commit(); return true; } @Override public boolean addAccountSecurity(final Account account, final SecurityNode node) { container.set(node); commit(); return true; } @Override public List getIncomeAccountList() { return getAccountByType(AccountType.INCOME); } @Override public List getExpenseAccountList() { return getAccountByType(AccountType.EXPENSE); } @Override public List getInvestmentAccountList() { return getAccountByType(AccountType.INVEST); } @Override public Account getAccountByUuid(final UUID uuid) { return getObjectByUuid(Account.class, uuid); } @Override public boolean updateAccount(final Account account) { commit(); return true; } @Override public boolean toggleAccountVisibility(final Account account) { commit(); return true; } private List getAccountByType(final AccountType type) { return getAccountList().parallelStream().filter(a -> a.getAccountType() == type).collect(Collectors.toList()); } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/xstream/XStreamBudgetDAO.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.xstream; import jgnash.engine.budget.Budget; import jgnash.engine.dao.BudgetDAO; import java.util.List; import java.util.UUID; /** * XML Budget DAO. * * @author Craig Cavanaugh */ class XStreamBudgetDAO extends AbstractXStreamDAO implements BudgetDAO { XStreamBudgetDAO(final AbstractXStreamContainer container) { super(container); } @Override public boolean add(final Budget budget) { container.set(budget); commit(); return true; } @Override public boolean update(final Budget budget) { container.set(budget); commit(); return true; } @Override public List getBudgets() { return stripMarkedForRemoval(container.query(Budget.class)); } @Override public Budget getBudgetByUuid(final UUID uuid) { return getObjectByUuid(Budget.class, uuid); } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/xstream/XStreamCommodityDAO.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.xstream; import java.util.List; import java.util.Set; import java.util.UUID; import java.util.stream.Collectors; import jgnash.engine.Account; import jgnash.engine.CommodityNode; import jgnash.engine.CurrencyNode; import jgnash.engine.ExchangeRate; import jgnash.engine.SecurityHistoryEvent; import jgnash.engine.SecurityHistoryNode; import jgnash.engine.SecurityNode; import jgnash.engine.StoredObject; import jgnash.engine.dao.CommodityDAO; import jgnash.util.NotNull; /** * Hides all the db4o commodity code. * * @author Craig Cavanaugh */ class XStreamCommodityDAO extends AbstractXStreamDAO implements CommodityDAO { XStreamCommodityDAO(final AbstractXStreamContainer container) { super(container); } @Override public boolean addCommodity(final CommodityNode node) { boolean result = container.set(node); commit(); return result; } @Override public boolean addExchangeRateHistory(final ExchangeRate rate) { commit(); return true; } @Override public boolean addSecurityHistory(final SecurityNode node, final SecurityHistoryNode historyNode) { commit(); return true; } @Override public boolean addSecurityHistoryEvent(final SecurityNode node, final SecurityHistoryEvent historyEvent) { commit(); return true; } @Override public Set getActiveCurrencies() { Set set = stripMarkedForRemoval(container.query(Account.class)) .parallelStream().map(Account::getCurrencyNode).collect(Collectors.toSet()); set.addAll(stripMarkedForRemoval(container.query(SecurityNode.class)) .parallelStream().map(SecurityNode::getReportedCurrencyNode).collect(Collectors.toList())); return set; } @Override public List getCurrencies() { return stripMarkedForRemoval(container.query(CurrencyNode.class)); } @Override public CurrencyNode getCurrencyByUuid(final UUID uuid) { return getObjectByUuid(CurrencyNode.class, uuid); } @Override public SecurityNode getSecurityByUuid(final UUID uuid) { return getObjectByUuid(SecurityNode.class, uuid); } @Override public ExchangeRate getExchangeNode(final String rateId) { ExchangeRate rate = null; for (ExchangeRate r : stripMarkedForRemoval(container.query(ExchangeRate.class))) { if (r.getRateId().equals(rateId)) { rate = r; break; } } return rate; } @Override public ExchangeRate getExchangeRateByUuid(final UUID uuid) { ExchangeRate exchangeRate = null; StoredObject o = container.get(uuid); if (o instanceof ExchangeRate) { exchangeRate = (ExchangeRate) o; } return exchangeRate; } @Override @NotNull public List getSecurities() { return stripMarkedForRemoval(container.query(SecurityNode.class)); } @Override public List getExchangeRates() { return stripMarkedForRemoval(container.query(ExchangeRate.class)); } @Override public boolean removeExchangeRateHistory(final ExchangeRate rate) { commit(); return true; } @Override public boolean removeSecurityHistory(final SecurityNode node, final SecurityHistoryNode historyNode) { commit(); return true; } @Override public boolean removeSecurityHistoryEvent(final SecurityNode node, final SecurityHistoryEvent historyEvent) { commit(); return true; } @Override public void addExchangeRate(final ExchangeRate eRate) { container.set(eRate); commit(); } @Override public boolean updateCommodityNode(final CommodityNode node) { commit(); return true; } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/xstream/XStreamConfigDAO.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.xstream; import java.util.List; import java.util.logging.Logger; import jgnash.engine.Config; import jgnash.engine.dao.ConfigDAO; /** * Config object DAO. * * @author Craig Cavanaugh */ class XStreamConfigDAO extends AbstractXStreamDAO implements ConfigDAO { private static final Logger logger = Logger.getLogger(XStreamConfigDAO.class.getName()); XStreamConfigDAO(final AbstractXStreamContainer container) { super(container); } @Override public Config getDefaultConfig() { Config defaultConfig = null; List list = container.query(Config.class); if (!list.isEmpty()) { defaultConfig = list.get(0); } if (defaultConfig == null) { defaultConfig = new Config(); container.set(defaultConfig); commit(); logger.info("Generating new default config"); } return defaultConfig; } @Override public void update(final Config config) { container.set(config); commit(); } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/xstream/XStreamEngineDAO.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.xstream; import java.util.List; import java.util.Timer; import java.util.TimerTask; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; import java.util.logging.Logger; import jgnash.engine.StoredObject; import jgnash.engine.dao.AccountDAO; import jgnash.engine.dao.BudgetDAO; import jgnash.engine.dao.CommodityDAO; import jgnash.engine.dao.ConfigDAO; import jgnash.engine.dao.EngineDAO; import jgnash.engine.dao.RecurringDAO; import jgnash.engine.dao.TagDAO; import jgnash.engine.dao.TransactionDAO; import jgnash.engine.dao.TrashDAO; /** * XML Engine DAO Interface. * * @author Craig Cavanaugh */ class XStreamEngineDAO extends AbstractXStreamDAO implements EngineDAO { private AccountDAO accountDAO; private BudgetDAO budgetDAO; private CommodityDAO commodityDAO; private ConfigDAO configDAO; private RecurringDAO recurringDAO; private TagDAO tagDAO; private TransactionDAO transactionDAO; private TrashDAO trashDAO; private ScheduledExecutorService commitExecutor; private ScheduledFuture future; private final Timer commitTimer; XStreamEngineDAO(final AbstractXStreamContainer container) { super(container); commitTimer = new Timer(); // scheduled thread to check and verify a commit occurs every 30 seconds at the minimum if needed. commitExecutor = Executors.newSingleThreadScheduledExecutor(); // run commit every 30 seconds, 30 seconds after startup commitTimer.scheduleAtFixedRate(new TimerTask() { @Override public void run() { if (future != null && !future.isDone() || commitExecutor == null) { return; } future = commitExecutor.schedule(() -> { if (commitCount.get() > 0) { Logger.getLogger(XStreamEngineDAO.class.getName()).info("Committing file"); commitAndReset(); } }, 0, TimeUnit.SECONDS); } }, MAX_COMMIT_TIME * 1000, MAX_COMMIT_TIME * 1000); } @Override public synchronized void shutdown() { commitTimer.cancel(); commitExecutor.shutdown(); commitExecutor = null; } @Override public synchronized AccountDAO getAccountDAO() { if (accountDAO == null) { accountDAO = new XStreamAccountDAO(container); } return accountDAO; } @Override public BudgetDAO getBudgetDAO() { if (budgetDAO == null) { budgetDAO = new XStreamBudgetDAO(container); } return budgetDAO; } @Override public synchronized CommodityDAO getCommodityDAO() { if (commodityDAO == null) { commodityDAO = new XStreamCommodityDAO(container); } return commodityDAO; } @Override public synchronized ConfigDAO getConfigDAO() { if (configDAO == null) { configDAO = new XStreamConfigDAO(container); } return configDAO; } @Override public synchronized RecurringDAO getRecurringDAO() { if (recurringDAO == null) { recurringDAO = new XStreamRecurringDAO(container); } return recurringDAO; } @Override public synchronized TransactionDAO getTransactionDAO() { if (transactionDAO == null) { transactionDAO = new XStreamTransactionDAO(container); } return transactionDAO; } @Override public TagDAO getTagDAO() { if (tagDAO == null) { tagDAO = new XStreamTagDAO(container); } return tagDAO; } @Override public synchronized TrashDAO getTrashDAO() { if (trashDAO == null) { trashDAO = new XStreamTrashDAO(container); } return trashDAO; } @Override public List getStoredObjects() { return container.asList(); } @Override public List getStoredObjects(Class tClass) { return stripMarkedForRemoval(container.query(tClass)); } @Override public void refresh(final StoredObject object) { // do nothing for XStream } @Override public void bulkUpdate(List objectList) { commit(); } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/xstream/XStreamJVM9.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.xstream; import com.thoughtworks.xstream.XStream; import com.thoughtworks.xstream.converters.Converter; import com.thoughtworks.xstream.converters.basic.BigDecimalConverter; import com.thoughtworks.xstream.converters.basic.BigIntegerConverter; import com.thoughtworks.xstream.converters.basic.BooleanConverter; import com.thoughtworks.xstream.converters.basic.ByteConverter; import com.thoughtworks.xstream.converters.basic.CharConverter; import com.thoughtworks.xstream.converters.basic.DateConverter; import com.thoughtworks.xstream.converters.basic.DoubleConverter; import com.thoughtworks.xstream.converters.basic.FloatConverter; import com.thoughtworks.xstream.converters.basic.IntConverter; import com.thoughtworks.xstream.converters.basic.LongConverter; import com.thoughtworks.xstream.converters.basic.NullConverter; import com.thoughtworks.xstream.converters.basic.ShortConverter; import com.thoughtworks.xstream.converters.basic.StringConverter; import com.thoughtworks.xstream.converters.collections.ArrayConverter; import com.thoughtworks.xstream.converters.collections.CharArrayConverter; import com.thoughtworks.xstream.converters.collections.CollectionConverter; import com.thoughtworks.xstream.converters.collections.MapConverter; import com.thoughtworks.xstream.converters.enums.EnumConverter; import com.thoughtworks.xstream.converters.extended.EncodedByteArrayConverter; import com.thoughtworks.xstream.converters.reflection.ExternalizableConverter; import com.thoughtworks.xstream.converters.reflection.ReflectionConverter; import com.thoughtworks.xstream.converters.reflection.ReflectionProvider; import com.thoughtworks.xstream.converters.reflection.SerializableConverter; import com.thoughtworks.xstream.converters.time.InstantConverter; import com.thoughtworks.xstream.converters.time.LocalDateConverter; import com.thoughtworks.xstream.converters.time.LocalDateTimeConverter; import com.thoughtworks.xstream.converters.time.LocalTimeConverter; import com.thoughtworks.xstream.core.util.SelfStreamingInstanceChecker; import com.thoughtworks.xstream.io.HierarchicalStreamDriver; import com.thoughtworks.xstream.converters.basic.UUIDConverter; /** * This Replaces the default XStream constructor to prevent the loading of converters that trigger an illegal reflective * access operation when operating with JVM 9 or newer. * * @author Craig Cavanaugh */ public class XStreamJVM9 extends XStream { public XStreamJVM9(final ReflectionProvider reflectionProvider, final HierarchicalStreamDriver hierarchicalStreamDriver) { super(reflectionProvider, hierarchicalStreamDriver); } @Override protected void setupConverters() { registerConverter(new ReflectionConverter(getMapper(), getReflectionProvider()), PRIORITY_VERY_LOW); registerConverter(new SerializableConverter(getMapper(), getReflectionProvider(), getClassLoaderReference()), PRIORITY_LOW); registerConverter(new ExternalizableConverter(getMapper(), getClassLoaderReference()), PRIORITY_LOW); registerConverter(new NullConverter(), PRIORITY_VERY_HIGH); registerConverter(new IntConverter(), PRIORITY_NORMAL); registerConverter(new FloatConverter(), PRIORITY_NORMAL); registerConverter(new DoubleConverter(), PRIORITY_NORMAL); registerConverter(new LongConverter(), PRIORITY_NORMAL); registerConverter(new ShortConverter(), PRIORITY_NORMAL); registerConverter((Converter) new CharConverter(), PRIORITY_NORMAL); registerConverter(new BooleanConverter(), PRIORITY_NORMAL); registerConverter(new ByteConverter(), PRIORITY_NORMAL); registerConverter(new StringConverter(), PRIORITY_NORMAL); registerConverter(new DateConverter(), PRIORITY_NORMAL); registerConverter(new BigIntegerConverter(), PRIORITY_NORMAL); registerConverter(new BigDecimalConverter(), PRIORITY_NORMAL); registerConverter(new ArrayConverter(getMapper()), PRIORITY_NORMAL); registerConverter(new CharArrayConverter(), PRIORITY_NORMAL); registerConverter(new CollectionConverter(getMapper()), PRIORITY_NORMAL); registerConverter(new MapConverter(getMapper()), PRIORITY_NORMAL); registerConverter((Converter) new EncodedByteArrayConverter(), PRIORITY_NORMAL); registerConverter(new EnumConverter(), PRIORITY_NORMAL); registerConverter(new InstantConverter(), PRIORITY_NORMAL); registerConverter(new LocalDateConverter(), PRIORITY_NORMAL); registerConverter(new LocalDateTimeConverter(), PRIORITY_NORMAL); registerConverter(new LocalTimeConverter(), PRIORITY_NORMAL); registerConverter(new UUIDConverter(), PRIORITY_NORMAL); registerConverter(new SelfStreamingInstanceChecker(getConverterLookup(), this), PRIORITY_NORMAL); } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/xstream/XStreamRecurringDAO.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.xstream; import jgnash.engine.dao.RecurringDAO; import jgnash.engine.recurring.Reminder; import java.util.List; import java.util.UUID; /** * Recurring XML DAO. * * @author Craig Cavanaugh */ class XStreamRecurringDAO extends AbstractXStreamDAO implements RecurringDAO { XStreamRecurringDAO(final AbstractXStreamContainer container) { super(container); } @Override public List getReminderList() { return stripMarkedForRemoval(container.query(Reminder.class)); } @Override public boolean addReminder(final Reminder reminder) { container.set(reminder); commit(); return true; } @Override public Reminder getReminderByUuid(final UUID uuid) { return getObjectByUuid(Reminder.class, uuid); } @Override public boolean updateReminder(final Reminder reminder) { commit(); return true; } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/xstream/XStreamTagDAO.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.xstream; import java.util.HashSet; import java.util.Set; import jgnash.engine.Tag; import jgnash.engine.dao.TagDAO; /** * XML Tag DAO. * * @author Craig Cavanaugh */ class XStreamTagDAO extends AbstractXStreamDAO implements TagDAO { XStreamTagDAO(final AbstractXStreamContainer container) { super(container); } @Override public boolean add(final Tag tag) { container.set(tag); commit(); return true; } @Override public boolean update(final Tag tag) { container.set(tag); commit(); return true; } @Override public Set getTags() { return new HashSet<>(stripMarkedForRemoval(container.query(Tag.class))); } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/xstream/XStreamTransactionDAO.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.xstream; import java.util.List; import java.util.UUID; import java.util.stream.Collectors; import jgnash.engine.Transaction; import jgnash.engine.dao.TransactionDAO; /** * Transaction XML DAO. * * @author Craig Cavanaugh */ class XStreamTransactionDAO extends AbstractXStreamDAO implements TransactionDAO { XStreamTransactionDAO(final AbstractXStreamContainer container) { super(container); } @Override public List getTransactions() { return stripMarkedForRemoval(container.query(Transaction.class)); } @Override public boolean addTransaction(final Transaction transaction) { container.set(transaction); commit(); return true; } @Override public Transaction getTransactionByUuid(final UUID uuid) { return getObjectByUuid(Transaction.class, uuid); } @Override public boolean removeTransaction(final Transaction transaction) { commit(); return true; } @Override public List getTransactionsWithAttachments() { return container.query(Transaction.class).parallelStream() .filter(transaction -> !transaction.isMarkedForRemoval() && transaction.getAttachment() != null) .collect(Collectors.toList()); } } ================================================ FILE: jgnash-core/src/main/java/jgnash/engine/xstream/XStreamTrashDAO.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.engine.xstream; import jgnash.engine.TrashObject; import jgnash.engine.dao.TrashDAO; import java.util.List; import java.util.logging.Logger; /** * XML trash DAO. * * @author Craig Cavanaugh */ class XStreamTrashDAO extends AbstractXStreamDAO implements TrashDAO { private static final Logger logger = Logger.getLogger(XStreamTrashDAO.class.getName()); XStreamTrashDAO(final AbstractXStreamContainer container) { super(container); } @Override public List getTrashObjects() { return container.query(TrashObject.class); } @Override public void add(final TrashObject trashObject) { container.set(trashObject); commit(); } @Override public void remove(final TrashObject trashObject) { container.delete(trashObject.getObject()); container.delete(trashObject); commit(); logger.info("Removed TrashObject"); } @Override public void addEntityTrash(Object entity) { // XStream does not need to do anything with entity trash } } ================================================ FILE: jgnash-core/src/main/java/jgnash/net/AbstractAuthenticator.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.net; import java.net.Authenticator; import java.util.prefs.Preferences; /** * An Authenticator that will pop up a dialog and ask for http authentication * info if it has not assigned. This does not make authentication information * permanent. That must be done using the options configuration for http connect * * @author Craig Cavanaugh */ public class AbstractAuthenticator extends Authenticator { public static final String NODEHTTP = "/jgnash/http"; protected static final String HTTPUSER = "httpuser"; protected static final String HTTPPASS = "httppass"; public static final String USEPROXY = "useproxy"; private static final String USEAUTH = "useauth"; public static final String PROXYHOST = "proxyhost"; public static final String PROXYPORT = "proxyport"; public static void setName(String name) { final Preferences pref = Preferences.userRoot().node(NODEHTTP); pref.put(HTTPUSER, name); } public static String getName() { final Preferences pref = Preferences.userRoot().node(NODEHTTP); return pref.get(HTTPUSER, "user"); } public static void setHost(String host) { final Preferences pref = Preferences.userRoot().node(NODEHTTP); pref.put(PROXYHOST, host); } public static String getHost() { final Preferences pref = Preferences.userRoot().node(NODEHTTP); return pref.get(PROXYHOST, "localhost"); } public static void setPort(int port) { final Preferences pref = Preferences.userRoot().node(NODEHTTP); pref.putInt(PROXYPORT, port); } public static int getPort() { final Preferences pref = Preferences.userRoot().node(NODEHTTP); return pref.getInt(PROXYPORT, 8080); } public static void setPassword(String password) { final Preferences pref = Preferences.userRoot().node(NODEHTTP); pref.put(HTTPPASS, password); } public static String getPassword() { final Preferences pref = Preferences.userRoot().node(NODEHTTP); return pref.get(HTTPPASS, ""); } public static void setUseAuthentication(boolean use) { final Preferences pref = Preferences.userRoot().node(NODEHTTP); pref.putBoolean(USEAUTH, use); } public static void setUseProxy(boolean use) { final Preferences pref = Preferences.userRoot().node(NODEHTTP); pref.putBoolean(USEPROXY, use); } public static boolean isProxyUsed() { final Preferences pref = Preferences.userRoot().node(NODEHTTP); return pref.getBoolean(USEPROXY, false); } public static boolean isAuthenticationUsed() { final Preferences pref = Preferences.userRoot().node(NODEHTTP); return pref.getBoolean(USEAUTH, false); } } ================================================ FILE: jgnash-core/src/main/java/jgnash/net/ConnectionFactory.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.net; import java.io.IOException; import java.net.MalformedURLException; import java.net.URL; import java.net.URLConnection; import java.util.prefs.Preferences; import jgnash.util.Nullable; import static jgnash.util.LogUtil.logSevere; /** * Factory methods for setting network connection timeout and creating a URLConnection with timeout set correctly. * * @author Craig Cavanaugh */ public class ConnectionFactory { private static final String TIMEOUT = "timeout"; private static final int DEFAULT_TIMEOUT = 30; public static final int MAX_TIMEOUT = 30; public static final int MIN_TIMEOUT = 1; public static final int MILLIS_PER_SECOND = 1000; private ConnectionFactory() { } /** * Sets the network timeout in seconds. * * @param seconds time in seconds before a timeout occurs */ public synchronized static void setConnectionTimeout(final int seconds) { if (seconds < MIN_TIMEOUT || seconds > MAX_TIMEOUT) { throw new IllegalArgumentException("Invalid timeout connection"); } Preferences pref = Preferences.userNodeForPackage(ConnectionFactory.class); pref.putInt(TIMEOUT, seconds); } /** * Return the network connection timeout in seconds. * * @return timeout in seconds */ public synchronized static int getConnectionTimeout() { Preferences pref = Preferences.userNodeForPackage(ConnectionFactory.class); return pref.getInt(TIMEOUT, DEFAULT_TIMEOUT); } @Nullable public synchronized static URLConnection openConnection(final String url) { try { return openConnection(new URL(url)); } catch (final MalformedURLException ex) { logSevere(ConnectionFactory.class, ex); } return null; } @Nullable private synchronized static URLConnection openConnection(final URL url) { URLConnection connection = null; try { connection = url.openConnection(); } catch (final IOException ex) { logSevere(ConnectionFactory.class, ex); } /* Set the connection timeout */ if (connection != null) { connection.setConnectTimeout(getConnectionTimeout() * 1000); connection.setReadTimeout(getConnectionTimeout() * 1000); } return connection; } } ================================================ FILE: jgnash-core/src/main/java/jgnash/net/YahooCrumbManager.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.net; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; import java.net.URLConnection; import java.nio.charset.StandardCharsets; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; import java.time.temporal.TemporalAccessor; import java.util.List; import java.util.Map; import java.util.prefs.Preferences; import java.util.regex.Matcher; import java.util.regex.Pattern; import jgnash.util.LogUtil; /** * Manages the Cookie and Crumb required to connect to the yahoo finance APIs. *

* The cookie and crumb is cached and reused * * @author Craig Cavanaugh */ public class YahooCrumbManager { private static final String SET_COOKIE_KEY = "Set-Cookie"; private static final String CRUMB_REGEX = ".*\"CrumbStore\":\\{\"crumb\":\"(?.+?)\"}.*"; private static final String EXPIRES = "expires"; private static final String CRUMB = "crumb"; private static final String COOKIE = "cookie"; private static final String DATE_FORMAT = "dd-MMM-yyyy HH:mm:ss z"; private static String cookie; private static String crumb; private YahooCrumbManager() { // utility class } /** * This scrapes the cookie and crumb from a Yahoo web page. *

* This must be call prior to requesting a cookie or crumb. * * @param symbol Stock to use to scrape value */ public static synchronized boolean authorize(final String symbol) { boolean result = false; final Preferences preferences = Preferences.userNodeForPackage(YahooCrumbManager.class); if (preferences.get(EXPIRES, null) != null) { final ZonedDateTime expires = ZonedDateTime.parse(preferences.get(EXPIRES, null)); if (ZonedDateTime.now().compareTo(expires) < 0) { crumb = preferences.get(CRUMB, null); cookie = preferences.get(COOKIE, null); if (cookie != null && crumb != null) { return true; } } else { clearAuthorization(); } } final String url = "https://finance.yahoo.com/quote/" + symbol + "?p=" + symbol; final URLConnection connection = ConnectionFactory.openConnection(url); if (connection != null) { final Map> headerFields = connection.getHeaderFields(); for (final Map.Entry> entry : headerFields.entrySet()) { if (SET_COOKIE_KEY.equalsIgnoreCase(entry.getKey())) { final List cookieValue = entry.getValue(); if (cookieValue != null) { final String[] values = cookieValue.get(0).split(";"); cookie = values[0]; preferences.put(COOKIE, cookie); final String expires = values[1].trim(); if (expires.startsWith("expires")) { // old, left should Yahoo switch back final DateTimeFormatter formatter = DateTimeFormatter.ofPattern(DATE_FORMAT); final TemporalAccessor accessor = formatter.parse(expires.split(",")[1].trim()); preferences.put(EXPIRES, ZonedDateTime.from(accessor).toString()); } else if (expires.startsWith("Max-Age")) { final long seconds = Long.parseLong(expires.split("=")[1].trim()); ZonedDateTime expireDate = ZonedDateTime.now().plusSeconds(seconds); preferences.put(EXPIRES, expireDate.toString()); } else { preferences.put(EXPIRES, null); } final Pattern pattern = Pattern.compile(CRUMB_REGEX); try (final BufferedReader reader = new BufferedReader(new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8))) { String line; while ((line = reader.readLine()) != null) { final Matcher matcher = pattern.matcher(line); if (matcher.matches()) { crumb = matcher.group(1); preferences.put(CRUMB, crumb); result = true; break; } } } catch (final IOException e) { LogUtil.logSevere(YahooCrumbManager.class, e); } } } } } return result; } public static synchronized void clearAuthorization() { final Preferences preferences = Preferences.userNodeForPackage(YahooCrumbManager.class); preferences.remove(CRUMB); preferences.remove(COOKIE); } public static synchronized String getCookie() { return cookie; } public static synchronized String getCrumb() { return crumb; } } ================================================ FILE: jgnash-core/src/main/java/jgnash/net/currency/CurrencyConverterParser.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.net.currency; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; import java.math.BigDecimal; import java.net.SocketTimeoutException; import java.net.URLConnection; import java.net.UnknownHostException; import java.nio.charset.StandardCharsets; import java.util.logging.Logger; import jgnash.net.ConnectionFactory; import jgnash.util.LogUtil; /** * A CurrencyParser for the CurrencyConverterAPI site. * * Use www.currencyconverterapi.com * * @author Pranay Kumar */ public class CurrencyConverterParser implements CurrencyParser { private BigDecimal result = null; @Override public synchronized boolean parse(final String source, final String target) { String label = source + "_" + target; /* Build the URL: https://free.currencyconverterapi.com/api/v6/convert?q=HKD_INR&compact=ultra */ StringBuilder url = new StringBuilder("https://free.currencyconverterapi.com/api/v6/convert?q="); url.append(label); url.append("&compact=ultra"); BufferedReader in = null; try { URLConnection connection = ConnectionFactory.openConnection(url.toString()); if (connection != null) { in = new BufferedReader(new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8)); /* Result: {"HKD_INR":9.263368} */ StringBuilder resp = new StringBuilder(); String l; while ( (l = in.readLine()) != null) { resp.append(l.trim()); } resp = new StringBuilder(resp.toString().replace("{", "")); resp = new StringBuilder(resp.toString().replace("}", "")); String[] tokens = resp.toString().split(":"); if ( ("\""+label+"\"").equals(tokens[0]) ) { result = new BigDecimal(tokens[1]); } } } catch (final SocketTimeoutException | UnknownHostException e) { result = null; Logger.getLogger(CurrencyConverterParser.class.getName()).warning(e.getLocalizedMessage()); return false; } catch (final Exception e) { LogUtil.logSevere(CurrencyConverterParser.class, e); return false; } finally { try { if (in != null) { in.close(); } } catch (final IOException ex) { LogUtil.logSevere(CurrencyConverterParser.class, ex); } } return true; } @Override public synchronized BigDecimal getConversion() { return result; } } ================================================ FILE: jgnash-core/src/main/java/jgnash/net/currency/CurrencyParser.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.net.currency; import java.math.BigDecimal; /** * Currency parser interface. * * @author Craig Cavanaugh */ interface CurrencyParser { BigDecimal getConversion(); boolean parse(String source, String target); } ================================================ FILE: jgnash-core/src/main/java/jgnash/net/currency/CurrencyUpdateFactory.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.net.currency; import java.math.BigDecimal; import java.util.List; import java.util.Optional; import java.util.concurrent.Callable; import jgnash.engine.CurrencyNode; import jgnash.engine.Engine; import jgnash.engine.EngineFactory; /** * Fetches latest exchange rates in the background. * * @author Craig Cavanaugh */ public class CurrencyUpdateFactory { private static final String UPDATE_ON_STARTUP = "updateCurrenciesOnStartup"; private CurrencyUpdateFactory() { } public static void setUpdateOnStartup(final boolean update) { final Engine engine = EngineFactory.getEngine(EngineFactory.DEFAULT); if (engine != null) { engine.putBoolean(UPDATE_ON_STARTUP, update); } } public static boolean getUpdateOnStartup() { boolean result = false; final Engine engine = EngineFactory.getEngine(EngineFactory.DEFAULT); if (engine != null) { result = engine.getBoolean(UPDATE_ON_STARTUP, false); } return result; } public static Optional getExchangeRate(final CurrencyNode source, final CurrencyNode target) { Optional optional = Optional.empty(); final CurrencyParser parser = new CurrencyConverterParser(); if (parser.parse(source.getSymbol(), target.getSymbol())) { final BigDecimal exchangeRate = parser.getConversion(); if (exchangeRate != null && exchangeRate.compareTo(BigDecimal.ZERO) != 0) { optional = Optional.of(exchangeRate); } } return optional; } public static class UpdateExchangeRatesCallable implements Callable { @Override public Boolean call() { boolean result = false; final Engine engine = EngineFactory.getEngine(EngineFactory.DEFAULT); if (engine != null) { final List list = engine.getCurrencies(); for (final CurrencyNode source : list) { list.stream().filter(target -> !source.equals(target) && source.getSymbol().compareToIgnoreCase(target.getSymbol()) > 0).forEach(target -> { final Optional rate = CurrencyUpdateFactory.getExchangeRate(source, target); rate.ifPresent(value -> engine.setExchangeRate(source, target, value)); }); } result = true; } return result; } } } ================================================ FILE: jgnash-core/src/main/java/jgnash/net/security/NullParser.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.net.security; import java.time.LocalDate; import java.util.Collections; import java.util.List; import java.util.Set; import java.util.function.Supplier; import jgnash.engine.SecurityHistoryEvent; import jgnash.engine.SecurityHistoryNode; import jgnash.engine.SecurityNode; import jgnash.util.NotNull; /** * Null security history parser. * * Returns empty results * * @author Craig Cavanaugh */ public class NullParser implements SecurityParser { /** * Sets a {@code Supplier} that can provide an API token when requested. * * @param supplier token {@code Supplier} */ @Override public void setTokenSupplier(Supplier supplier) { // do nothing... } @Override public List retrieveHistoricalPrice(final SecurityNode securityNode, final LocalDate startDate, final LocalDate endDate) { return Collections.emptyList(); } @Override public Set retrieveHistoricalEvents(@NotNull final SecurityNode securityNode, final LocalDate endDate) { return Collections.emptySet(); } } ================================================ FILE: jgnash-core/src/main/java/jgnash/net/security/SecurityParser.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.net.security; import java.io.IOException; import java.time.LocalDate; import java.util.List; import java.util.Set; import java.util.function.Supplier; import jgnash.engine.SecurityHistoryEvent; import jgnash.engine.SecurityHistoryNode; import jgnash.engine.SecurityNode; import jgnash.util.NotNull; /** * Interface for security parser. * * @author Craig Cavanaugh */ public interface SecurityParser { /** * Sets a {@code Supplier} that can provide an API token when requested. * * @param supplier token {@code Supplier} */ void setTokenSupplier(@NotNull final Supplier supplier); /** * Retrieves historical pricing * * @param securityNode SecurityNode to retrieve events for * @param startDate start date * @param endDate end date * @return List of SecurityHistoryNode * @throws IOException indicates if IO / Network error has occurred */ @NotNull List retrieveHistoricalPrice(@NotNull final SecurityNode securityNode, final LocalDate startDate, final LocalDate endDate) throws IOException; /** * Retrieves historical events * * @param securityNode SecurityNode to retrieve events for * @param endDate end date * @return Set of SecurityHistoryEvent * @throws IOException indicates if IO / Network error has occurred */ @NotNull Set retrieveHistoricalEvents(@NotNull final SecurityNode securityNode, final LocalDate endDate) throws IOException; } ================================================ FILE: jgnash-core/src/main/java/jgnash/net/security/UpdateFactory.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.net.security; import java.io.IOException; import java.time.DayOfWeek; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.ZoneId; import java.time.ZonedDateTime; import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.Objects; import java.util.Set; import java.util.concurrent.Callable; import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import java.util.function.Supplier; import java.util.logging.Handler; import java.util.logging.Level; import java.util.logging.Logger; import jgnash.engine.Engine; import jgnash.engine.EngineFactory; import jgnash.engine.QuoteSource; import jgnash.engine.SecurityHistoryEvent; import jgnash.engine.SecurityHistoryNode; import jgnash.engine.SecurityNode; import jgnash.net.security.iex.IEXParser; import jgnash.resource.util.ResourceUtils; import jgnash.util.LogUtil; import jgnash.util.NotNull; /** * Fetches latest stock prices in the background. * * @author Craig Cavanaugh */ public class UpdateFactory { private static final String UPDATE_ON_STARTUP = "updateSecuritiesOnStartup"; // static reference is kept so LogManager cannot garbage collect the logger private static final Logger logger = Logger.getLogger(UpdateFactory.class.getName()); private static final int TIMEOUT = 1; // default timeout in minutes /** * Registers a {@code Handler} with the class logger. * * @param handler {@code Handler} to register */ public static void addLogHandler(final Handler handler) { logger.addHandler(handler); } public static void setUpdateOnStartup(final boolean update) { final Engine engine = EngineFactory.getEngine(EngineFactory.DEFAULT); if (engine != null) { engine.putBoolean(UPDATE_ON_STARTUP, update); } } /** * Determines if an automatic update is recommended. *

* The current approach is to avoid multiple updates on Saturday or Sunday if one has already occurred. * This could be expanded to understand locale rules. * * @param lastUpdate the last known timestamp for an update to have occurred * @return true if an update is recommended */ public static boolean shouldAutomaticUpdateOccur(final LocalDateTime lastUpdate) { boolean result = true; final LocalDate lastDate = LocalDate.from(lastUpdate); final DayOfWeek lastDayOfWeek = lastDate.getDayOfWeek(); if (lastDayOfWeek == DayOfWeek.SATURDAY || lastDayOfWeek == DayOfWeek.SUNDAY) { if (LocalDate.now().equals(lastDate) || (LocalDate.now().minusDays(1).equals(lastDate)) && lastDayOfWeek == DayOfWeek.SATURDAY) { result = false; } } if (result && LocalDate.now().equals(lastDate)) { // check for an after hours update switch (Locale.getDefault().getCountry()) { case "AU": case "CA": case "HK": case "US": final ZonedDateTime zdtUS = lastUpdate.atZone(ZoneId.of("UTC").normalized()); if (zdtUS.getHour() >= 21 && zdtUS.getMinute() > 25) { // 4:25 EST for delayed online sources result = false; } break; case "GB": // UK final ZonedDateTime zdtUK = lastUpdate.atZone(ZoneId.of("UTC").normalized()); if (zdtUK.getHour() >= 21 && zdtUK.getMinute() > 55) { // 4:55 EST for delayed online sources result = false; } break; case "IN": // India final ZonedDateTime zdtIN = lastUpdate.atZone(ZoneId.of("UTC").normalized()); if (zdtIN.getHour() >= 20 && zdtIN.getMinute() > 55) { // 3:55 EST for delayed online sources result = false; } break; default: break; } } return result; } public static boolean getUpdateOnStartup() { boolean result = false; final Engine engine = EngineFactory.getEngine(EngineFactory.DEFAULT); if (engine != null) { result = engine.getBoolean(UPDATE_ON_STARTUP, false); } return result; } public static void updateSecurityEvents(final SecurityNode node) { waitForCallable(new UpdateSecurityNodeEventsCallable(node)); } public static boolean updateOne(final SecurityNode node) { return waitForCallable(new UpdateSecurityNodeCallable(node)); } private static boolean waitForCallable(final Callable callable) { boolean result = false; final ExecutorService service = Executors.newSingleThreadExecutor(); final Future future = service.submit(callable); try { result = future.get(TIMEOUT, TimeUnit.MINUTES); service.shutdown(); } catch (final InterruptedException | ExecutionException e) { // intentionally interrupted logger.log(Level.FINEST, e.getLocalizedMessage(), e); } catch (final TimeoutException e) { logger.log(Level.SEVERE, e.getLocalizedMessage(), e); } return result; } public static List downloadHistory(final SecurityNode securityNode, final LocalDate startDate, final LocalDate endDate) throws IllegalArgumentException { List newSecurityNodes = Collections.emptyList(); QuoteSource quoteSource = securityNode.getQuoteSource(); Objects.requireNonNull(quoteSource); SecurityParser securityParser = quoteSource.getParser(); Objects.requireNonNull(securityParser); securityParser.setTokenSupplier(getTokenSupplier(securityParser)); try { newSecurityNodes = securityParser.retrieveHistoricalPrice(securityNode, startDate, endDate); if (!newSecurityNodes.isEmpty()) { logger.info(ResourceUtils.getString("Message.UpdatedPrice", securityNode.getSymbol())); } } catch (final IOException ex) { LogUtil.logSevere(UpdateFactory.class, ex); } catch (final IllegalArgumentException iae) { LogUtil.logSevere(UpdateFactory.class, iae); throw new IllegalArgumentException(iae); } return newSecurityNodes; } /** * Updates historical information for one day */ public static class UpdateSecurityNodeCallable implements Callable { private final SecurityNode securityNode; public UpdateSecurityNodeCallable(@NotNull final SecurityNode securityNode) { this.securityNode = securityNode; } @Override public Boolean call() { boolean result = true; final Engine e = EngineFactory.getEngine(EngineFactory.DEFAULT); if (e != null) { // protect against a call in process during a shutdown // check for thread interruption final QuoteSource quoteSource = securityNode.getQuoteSource(); if (quoteSource != QuoteSource.NONE && !Thread.currentThread().isInterrupted()) { try { final SecurityParser securityParser = quoteSource.getParser(); Objects.requireNonNull(securityParser); securityParser.setTokenSupplier(getTokenSupplier(securityParser)); final List nodes = securityParser.retrieveHistoricalPrice(securityNode, LocalDate.now().minusDays(1), LocalDate.now()); for (final SecurityHistoryNode node : nodes) { if (!Thread.currentThread().isInterrupted()) { // check for thread interruption if (e.addSecurityHistory(securityNode, node)) { logger.info(ResourceUtils.getString("Message.UpdatedPrice", securityNode.getSymbol())); } } } } catch (final IOException | IllegalArgumentException ex) { result = false; LogUtil.logSevere(UpdateFactory.class, ex); } } } return result; } } public static class UpdateSecurityNodeEventsCallable implements Callable { private final SecurityNode securityNode; public UpdateSecurityNodeEventsCallable(@NotNull final SecurityNode securityNode) { this.securityNode = securityNode; } @Override public Boolean call() { boolean result = true; final Engine e = EngineFactory.getEngine(EngineFactory.DEFAULT); final LocalDate oldest = securityNode.getHistoryNodes().get(0).getLocalDate(); final QuoteSource quoteSource = securityNode.getQuoteSource(); Objects.requireNonNull(quoteSource); if (e != null && quoteSource != QuoteSource.NONE) { try { final Set oldHistoryEvents = new HashSet<>(securityNode.getHistoryEvents()); final SecurityParser securityParser = quoteSource.getParser(); Objects.requireNonNull(securityParser); securityParser.setTokenSupplier(getTokenSupplier(securityParser)); for (final SecurityHistoryEvent securityHistoryEvent : securityParser.retrieveHistoricalEvents(securityNode, LocalDate.now())) { if (!Thread.currentThread().isInterrupted()) { // check for thread interruption if (securityHistoryEvent.getDate().isAfter(oldest) || securityHistoryEvent.getDate().isEqual(oldest)) { if (!oldHistoryEvents.contains(securityHistoryEvent)) { result = e.addSecurityHistoryEvent(securityNode, securityHistoryEvent); if (result) { logger.info(ResourceUtils.getString("Message.UpdatedSecurityEvent", securityNode.getSymbol())); } } } } } } catch (final IOException | IllegalArgumentException ex) { result = false; LogUtil.logSevere(UpdateFactory.class, ex); } } return result; } } /** * Returns a token Supplier * @param parser parser we need a token supplier for * @return Supplier */ private static Supplier getTokenSupplier(final SecurityParser parser) { if (parser instanceof IEXParser) { final Engine engine = EngineFactory.getEngine(EngineFactory.DEFAULT); if (engine != null) { return () -> engine.getPreference(IEXParser.IEX_SECRET_KEY); } } return () -> ""; } private UpdateFactory() { // Utility class } } ================================================ FILE: jgnash-core/src/main/java/jgnash/net/security/YahooEventParser.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.net.security; import java.io.BufferedReader; import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStreamReader; import java.math.BigDecimal; import java.net.HttpURLConnection; import java.net.URLConnection; import java.nio.charset.StandardCharsets; import java.time.DateTimeException; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; import java.time.Month; import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Set; import java.util.function.Function; import java.util.function.Supplier; import java.util.logging.Level; import java.util.logging.Logger; import jgnash.engine.MathConstants; import jgnash.engine.SecurityHistoryEvent; import jgnash.engine.SecurityHistoryEventType; import jgnash.engine.SecurityHistoryNode; import jgnash.engine.SecurityNode; import jgnash.net.ConnectionFactory; import jgnash.net.YahooCrumbManager; import jgnash.util.LogUtil; import jgnash.util.NotNull; import static jgnash.util.EncodeDecode.COMMA_DELIMITER_PATTERN; /** * Retrieves historical stock dividend and split information from Yahoo. * * @author Craig Cavanaugh */ public class YahooEventParser implements SecurityParser { private static final String DIV_RESPONSE_HEADER = "Date,Dividends"; private static final String SPLIT_RESPONSE_HEADER = "Date,Stock Splits"; private static final String HISTORY_RESPONSE_HEADER = "Date,Open,High,Low,Close,Adj Close,Volume"; public Set retrieveHistoricalEvents(@NotNull final SecurityNode securityNode, final LocalDate endDate) throws IOException { final Set events = new HashSet<>(); // Ensure we have a valid cookie and crumb if (!YahooCrumbManager.authorize(securityNode.getSymbol())) { return events; } LocalDate startDate = LocalDate.now().minusDays(1); final List historyNodeList = securityNode.getHistoryNodes(); if (!historyNodeList.isEmpty()) { startDate = historyNodeList.get(0).getLocalDate(); } events.addAll(retrieveNewDividends(securityNode, startDate, endDate)); events.addAll(retrieveNewSplits(securityNode, startDate, endDate)); return events; } private static List retrieveNewDividends(@NotNull final SecurityNode securityNode, final LocalDate startDate, final LocalDate endDate) throws IOException { /* Date,Dividends 1989-02-02,0.275 1989-08-03,0.3025 1964-02-04,0.00167 1964-08-04,0.00208 */ return parseStream(securityNode, startDate, endDate, SecurityHistoryEventType.DIVIDEND, DIV_RESPONSE_HEADER::equals, line -> { final String[] fields = COMMA_DELIMITER_PATTERN.split(line); if (fields.length == 2) { // if fields are != 2, then it's not valid data try { return new SecurityHistoryEvent(SecurityHistoryEventType.DIVIDEND, parseYahooDate(fields[0]), new BigDecimal(fields[1])); } catch (final DateTimeException | NumberFormatException ex) { Logger.getLogger(YahooEventParser.class.getName()).log(Level.INFO, line); LogUtil.logSevere(YahooEventParser.class, ex); } } return null; }); } /** * Sets a {@code Supplier} that can provide an API token when requested. * * @param supplier token {@code Supplier} */ @Override public void setTokenSupplier(final Supplier supplier) { // no nothing, API not required for access to Yahoo } @Override public List retrieveHistoricalPrice(@NotNull final SecurityNode securityNode, final LocalDate startDate, final LocalDate endDate) throws IOException { /* Date,Open,High,Low,Close,Adj Close,Volume 2016-01-04,128.344070,128.694244,127.056824,135.949997,128.675323,5229400 2016-01-05,129.441956,129.565018,127.634193,135.850006,128.580673,3924800 2016-01-06,127.189331,128.325134,126.469986,135.169998,127.937050,4310900 */ final List events = parseStream(securityNode, startDate, endDate, SecurityHistoryEventType.PRICE, HISTORY_RESPONSE_HEADER::equals, line -> { final String[] fields = COMMA_DELIMITER_PATTERN.split(line); if (fields.length == 7) { // if fields are != 7, then it's not valid data try { // Date,Open,High,Low,Close,Adj Close,Volume final LocalDate date = parseYahooDate(fields[0]); final BigDecimal high = new BigDecimal(fields[2]); final BigDecimal low = new BigDecimal(fields[3]); final BigDecimal close = new BigDecimal(fields[4]); final long volume = Long.parseLong(fields[6]); return new SecurityHistoryNode(date, close, volume, high, low); } catch (final DateTimeException | NumberFormatException ex) { Logger.getLogger(YahooEventParser.class.getName()).log(Level.INFO, line); LogUtil.logSevere(YahooEventParser.class, ex); } } return null; }); Collections.reverse(events); // reverse for better chronological order return events; } private static List retrieveNewSplits(@NotNull final SecurityNode securityNode, final LocalDate startDate, final LocalDate endDate) throws IOException { /* Date,Stock Splits 1973-05-29,5/4 1964-05-18,5/4 1997-05-28,2/1 */ return parseStream(securityNode, startDate, endDate, SecurityHistoryEventType.SPLIT, SPLIT_RESPONSE_HEADER::equals, line -> { final String[] fields = COMMA_DELIMITER_PATTERN.split(line); if (fields.length == 2) { // if fields are != 2, then it's not valid data try { // Yahoo uses : or / final String delimiter = fields[1].contains(":") ? ":" : "/"; final String[] fraction = fields[1].split(delimiter); final BigDecimal value = new BigDecimal(fraction[0]) .divide(new BigDecimal(fraction[1]), MathConstants.mathContext); return new SecurityHistoryEvent(SecurityHistoryEventType.SPLIT, parseYahooDate(fields[0]), value); } catch (final DateTimeException | NumberFormatException ex) { Logger.getLogger(YahooEventParser.class.getName()).log(Level.INFO, line); LogUtil.logSevere(YahooEventParser.class, ex); } } return null; }); } private static List parseStream(@NotNull final SecurityNode securityNode, final LocalDate startDate, final LocalDate endDate, final SecurityHistoryEventType type, final Function acceptHeaderFunction, final Function processLineFunction) throws IOException, NullPointerException { final List events = new ArrayList<>(); // Ensure we have a valid cookie and crumb if (!YahooCrumbManager.authorize(securityNode.getSymbol())) { return events; } final String url = buildYahooQuery(securityNode, startDate, endDate, type); URLConnection connection = null; try { connection = ConnectionFactory.openConnection(url); if (connection != null) { // required by Yahoo connection.setRequestProperty("Cookie", YahooCrumbManager.getCookie()); int responseCode = ((HttpURLConnection) connection).getResponseCode(); if (responseCode == 401) { YahooCrumbManager.clearAuthorization(); } else { try (final BufferedReader in = new BufferedReader(new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8))) { String line = in.readLine(); if (acceptHeaderFunction.apply(line)) { line = in.readLine(); // prime the first read while (line != null) { if (Thread.currentThread().isInterrupted()) { Thread.currentThread().interrupt(); } events.add(processLineFunction.apply(line)); line = in.readLine(); } } } catch (final FileNotFoundException ignored) { // silently ignored, history may not exist } } } } catch (final NullPointerException | IOException ex) { LogUtil.logSevere(YahooEventParser.class, ex); YahooCrumbManager.clearAuthorization(); throw new IOException(ex); } finally { if (connection instanceof HttpURLConnection) { ((HttpURLConnection) connection).disconnect(); } } return events; } private static LocalDate parseYahooDate(final String string) { return LocalDate.of(Integer.parseInt(string.substring(0, 4)), Integer.parseInt(string.substring(5, 7)), Integer.parseInt(string.substring(8, 10))); } private static String buildYahooQuery(final SecurityNode securityNode, final LocalDate startDate, final LocalDate endDate, final SecurityHistoryEventType event) { // dividend 1/1/1962 to 8/22/2015 // https://query1.finance.yahoo.com/v7/finance/download/IBM?period1=-252442800&period2=1440216000&interval=1d&events=div&crumb=oTulTvLJSBg // splits // https://query1.finance.yahoo.com/v7/finance/download/IBM?period1=-252442800&period2=1440216000&interval=1d&events=split&crumb=oTulTvLJSBg // History // https://query1.finance.yahoo.com/v7/finance/download/IBM?period1=-252442800&period2=1440216000&interval=1d&events=history&crumb=oTulTvLJSBg final LocalDateTime epoch = LocalDateTime.of(1970, Month.JANUARY, 1, 0, 0); long period1 = ChronoUnit.SECONDS.between(epoch, LocalDateTime.of(startDate, LocalTime.MIN)); long period2 = ChronoUnit.SECONDS.between(epoch, LocalDateTime.of(endDate, LocalTime.MAX)); final StringBuilder builder = new StringBuilder("https://query1.finance.yahoo.com/v7/finance/download/"); builder.append(securityNode.getSymbol()) .append("?period1=").append(period1) .append("&period2=").append(period2) .append("&interval=1d&events="); switch (event) { case DIVIDEND: builder.append("div"); break; case PRICE: builder.append("history"); break; case SPLIT: builder.append("split"); break; } builder.append("&crumb=").append(YahooCrumbManager.getCrumb()); return builder.toString(); } } ================================================ FILE: jgnash-core/src/main/java/jgnash/net/security/iex/IEXParser.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.net.security.iex; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; import java.math.BigDecimal; import java.net.HttpURLConnection; import java.net.URL; import java.nio.charset.StandardCharsets; import java.time.LocalDate; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Objects; import java.util.Set; import java.util.function.Supplier; import jgnash.engine.MathConstants; import jgnash.engine.SecurityHistoryEvent; import jgnash.engine.SecurityHistoryEventType; import jgnash.engine.SecurityHistoryNode; import jgnash.engine.SecurityNode; import jgnash.net.ConnectionFactory; import jgnash.net.security.SecurityParser; import jgnash.resource.util.ResourceUtils; import jgnash.time.DateUtils; import jgnash.util.LogUtil; import org.apache.commons.csv.CSVFormat; import org.apache.commons.csv.CSVParser; import org.apache.commons.csv.CSVRecord; import org.apache.commons.lang3.StringUtils; import static java.time.temporal.ChronoUnit.DAYS; /** * SecurityParser for querying iexcloud.io for stock price history and events * * @author Craig Cavanaugh */ public class IEXParser implements SecurityParser { public static final String IEX_SECRET_KEY = "IEXSecretKey"; private static final String BASE_URL = "https://cloud.iexapis.com/v1"; private static final String SANDBOX_URL = "https://sandbox.iexapis.com/stable"; private static final int READ_AHEAD_LIMIT = 512; private enum IEXChartPeriod { FiveDay("5d", 5), OneMonth("1m", 30), ThreeMonth("3m", 30 * 3), SixMonth("6m", 30 * 6), OneYear("1y", 365), TwoYear("2y", 365 * 2), FiveYear("5y", 365 * 5), max("max", Integer.MAX_VALUE); final private int days; final private String path; IEXChartPeriod(final String api, final int days) { this.days = days; this.path = api; } public static String getPath(final int days) { for (final IEXChartPeriod period : IEXChartPeriod.values()) { if (period.days > days) { return period.path; } } return IEXChartPeriod.max.path; } } /** * Required IEX Token */ private Supplier tokenSupplier = () -> ""; private String baseURL = BASE_URL; public void setUseSandbox() { baseURL = SANDBOX_URL; } /** * Sets a {@code Supplier} that can provide an API token when requested. * * @param supplier token {@code Supplier} */ @Override public void setTokenSupplier(final Supplier supplier) { Objects.requireNonNull(supplier); tokenSupplier = supplier; } /** * Retrieves historical pricing. *

* The IEX API allows query of a period of time starting with the current date. The startDate is ignored for REST * but is used for filter of the returned data * * @param securityNode SecurityNode to retrieve events for * @param startDate start date * @param endDate end date * @return List of SecurityHistoryNode * @throws IOException indicates if IO / Network error has occurred */ @Override public List retrieveHistoricalPrice(final SecurityNode securityNode, final LocalDate startDate, final LocalDate endDate) throws IOException, IllegalArgumentException { final String token = tokenSupplier.get(); if (StringUtils.isBlank(token)) { throw new IllegalArgumentException(ResourceUtils.getString("Message.Error.DataSupplierToken", securityNode.getSymbol())); } final String range = IEXChartPeriod.getPath((int) DAYS.between(startDate, LocalDate.now())); final String restURL = baseURL + "/stock/" + securityNode.getSymbol() + "/chart/" + range + "?token=" + token + "&format=csv"; final List historyNodes = new ArrayList<>(); final URL url = new URL(restURL); final HttpURLConnection connection = (HttpURLConnection) url.openConnection(); connection.setConnectTimeout(ConnectionFactory.getConnectionTimeout() * 1000); // return an empty list if the response is bad if (connection.getResponseCode() != HttpURLConnection.HTTP_OK) { connection.disconnect(); return historyNodes; } try (final BufferedReader reader = new BufferedReader(new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8))) { reader.mark(READ_AHEAD_LIMIT); if (isDataOkay(reader.readLine())) { reader.reset(); try (final CSVParser csvParser = new CSVParser(reader, CSVFormat.DEFAULT.withHeader())) { for (final CSVRecord record : csvParser) { final LocalDate date = LocalDate.parse(record.get("date"), DateTimeFormatter.ISO_DATE); if (DateUtils.between(date, startDate, endDate)) { final BigDecimal price = new BigDecimal(record.get("uClose")); final long volume = Long.parseLong(record.get("uVolume")); final BigDecimal high = new BigDecimal(record.get("uHigh")); final BigDecimal low = new BigDecimal(record.get("uLow")); final SecurityHistoryNode historyNode = new SecurityHistoryNode(date, price, volume, high, low); historyNodes.add(historyNode); } } } catch (final IllegalArgumentException iae) { LogUtil.logSevere(IEXParser.class, iae); } } } return historyNodes; } /** * Retrieves historical events * * @param securityNode SecurityNode to retrieve events for * @param endDate end date * @return Set of SecurityHistoryEvent * @throws IOException indicates if IO / Network error has occurred */ @Override public Set retrieveHistoricalEvents(final SecurityNode securityNode, final LocalDate endDate) throws IOException, IllegalArgumentException { final String token = tokenSupplier.get(); if (StringUtils.isBlank(token)) { throw new IllegalArgumentException(ResourceUtils.getString("Message.Error.DataSupplierToken", securityNode.getSymbol())); } final Set historyEvents = new HashSet<>(); historyEvents.addAll(retrieveSplitEvents(securityNode, endDate)); historyEvents.addAll(retrieveDividendEvents(securityNode, endDate)); return historyEvents; } private Set retrieveSplitEvents(final SecurityNode securityNode, final LocalDate endDate) throws IOException { final String range = IEXChartPeriod.getPath((int) DAYS.between(endDate, LocalDate.now())); final String restURL = baseURL + "/stock/" + securityNode.getSymbol() + "/splits/" + range + "?token=" + tokenSupplier.get() + "&format=csv"; final Set historyEvents = new HashSet<>(); final URL url = new URL(restURL); final HttpURLConnection connection = (HttpURLConnection) url.openConnection(); connection.setConnectTimeout(ConnectionFactory.getConnectionTimeout() * 1000); // return an empty list if the response is bad if (connection.getResponseCode() != HttpURLConnection.HTTP_OK) { connection.disconnect(); return historyEvents; } try (final BufferedReader reader = new BufferedReader(new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8))) { reader.mark(READ_AHEAD_LIMIT); if (isDataOkay(reader.readLine())) { reader.reset(); try (final CSVParser csvParser = new CSVParser(reader, CSVFormat.DEFAULT.withHeader())) { LocalDate startDate = LocalDate.now().minusDays(1); for (final CSVRecord record : csvParser) { final LocalDate date = LocalDate.parse(record.get("exDate"), DateTimeFormatter.ISO_DATE); if (DateUtils.between(date, endDate, startDate)) { final BigDecimal toFactor = new BigDecimal(record.get("toFactor")); final BigDecimal fromFactor = new BigDecimal(record.get("fromFactor")); final BigDecimal ratio = fromFactor.divide(toFactor, MathConstants.mathContext); final SecurityHistoryEvent event = new SecurityHistoryEvent(SecurityHistoryEventType.SPLIT, date, ratio); historyEvents.add(event); } } } catch (final IllegalArgumentException iae) { LogUtil.logSevere(IEXParser.class, iae); } } } return historyEvents; } private Set retrieveDividendEvents(final SecurityNode securityNode, final LocalDate endDate) throws IOException { final String range = IEXChartPeriod.getPath((int) DAYS.between(endDate, LocalDate.now())); final String restURL = baseURL + "/stock/" + securityNode.getSymbol() + "/dividends/" + range + "?token=" + tokenSupplier.get() + "&format=csv"; //System.out.println(restURL); final Set historyEvents = new HashSet<>(); final URL url = new URL(restURL); final HttpURLConnection connection = (HttpURLConnection) url.openConnection(); connection.setConnectTimeout(ConnectionFactory.getConnectionTimeout() * 1000); // return an empty list if the response is bad if (connection.getResponseCode() != HttpURLConnection.HTTP_OK) { connection.disconnect(); return historyEvents; } try (final BufferedReader reader = new BufferedReader(new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8))) { reader.mark(READ_AHEAD_LIMIT); if (isDataOkay(reader.readLine())) { reader.reset(); try (final CSVParser csvParser = new CSVParser(reader, CSVFormat.DEFAULT.withHeader())) { LocalDate startDate = LocalDate.now().minusDays(1); for (final CSVRecord record : csvParser) { final LocalDate date = LocalDate.parse(record.get("paymentDate"), DateTimeFormatter.ISO_DATE); if (DateUtils.between(date, endDate, startDate)) { final BigDecimal amount = new BigDecimal(record.get("amount")); final SecurityHistoryEvent event = new SecurityHistoryEvent(SecurityHistoryEventType.DIVIDEND, date, amount); historyEvents.add(event); } } } catch (final IllegalArgumentException iae) { LogUtil.logSevere(IEXParser.class, iae); } } } return historyEvents; } /** * Determines if data appears to be okay.... no immediate errors. * * @param line 1st line returned * @return true if data appears to be okay */ private static boolean isDataOkay(final String line) { if (line != null && !line.isEmpty()) { return !(StringUtils.startsWithIgnoreCase(line, "Not Found") || StringUtils.startsWithIgnoreCase(line, "Unknown symbol") || StringUtils.startsWithIgnoreCase(line, "An API key is required") || StringUtils.startsWithIgnoreCase(line, "Test tokens may only be used")); } return false; } } ================================================ FILE: jgnash-core/src/main/java/jgnash/report/BalanceByMonthCSVReport.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.report; import java.io.BufferedWriter; import java.io.IOException; import java.math.BigDecimal; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Paths; import java.time.LocalDate; import java.time.format.TextStyle; import java.time.temporal.TemporalAdjusters; import java.util.ArrayList; import java.util.List; import java.util.Locale; import java.util.Objects; import java.util.function.BiFunction; import java.util.logging.Level; import java.util.logging.Logger; import jgnash.engine.Account; import jgnash.engine.AccountType; import jgnash.engine.Comparators; import jgnash.engine.CurrencyNode; import jgnash.engine.Engine; import jgnash.engine.EngineFactory; import jgnash.time.DateUtils; import jgnash.util.NotNull; import jgnash.util.Nullable; /** * Export monthly balance information as a CSV (comma-separated variable) file. * * @author Craig Cavanaugh * @author Tom Edelson */ public class BalanceByMonthCSVReport { private final List accountList = new ArrayList<>(); private final List balanceList = new ArrayList<>(); private final boolean vertical; private final BiFunction balanceConverter; private final LocalDate startDate; private final LocalDate endDate; private final String fileName; private final CurrencyNode baseCommodity; /** * Report constructor. * * @param fileName file name to save to * @param startDate start date for the report * @param endDate end date for the report * @param currencyNode CurrencyNode account balances are reported in * @param vertical {@code true} if the export is oriented vertically instead of horizontally * @param balanceConverter function to adjust account balance sign */ public BalanceByMonthCSVReport(@NotNull final String fileName, @NotNull final LocalDate startDate, @NotNull final LocalDate endDate, @Nullable final CurrencyNode currencyNode, boolean vertical, @NotNull final BiFunction balanceConverter) { Objects.requireNonNull(fileName); this.fileName = fileName; this.baseCommodity = currencyNode; this.startDate = startDate; this.endDate = endDate; this.vertical = vertical; this.balanceConverter = balanceConverter; } public void run() { final Logger logger = Logger.getLogger(BalanceByMonthCSVReport.class.getName()); final Engine engine = EngineFactory.getEngine(EngineFactory.DEFAULT); Objects.requireNonNull(engine); final LocalDate[] dates = getLastDays(startDate, endDate); buildLists(engine.getRootAccount(), dates); try { logger.info("Writing file"); if (vertical) { writeVerticalCSVFileFormat(fileName, dates); } else { writeHorizontalFormatCSVFile(fileName, dates); } } catch (IOException e) { logger.log(Level.SEVERE, null, e); } } private static LocalDate[] getLastDays(final LocalDate startDate, final LocalDate stopDate) { final ArrayList list = new ArrayList<>(); LocalDate t = DateUtils.getLastDayOfTheMonth(startDate); // add a month at a time to the previous date until all of the months // have been captured while (DateUtils.before(t, stopDate)) { list.add(t); t = t.plusMonths(1).with(TemporalAdjusters.lastDayOfMonth()); } return list.toArray(new LocalDate[0]); } private void buildLists(final Account account, final LocalDate[] dates) { for (final Account child : account.getChildren(Comparators.getAccountByCode())) { if (child.getTransactionCount() > 0) { accountList.add(child); // add the account final BigDecimal[] bigDecimals = new BigDecimal[dates.length]; for (int i = 0; i < dates.length; i++) { if (baseCommodity != null) { bigDecimals[i] = balanceConverter.apply(child.getAccountType(), child.getBalance(dates[i], baseCommodity)); } else { bigDecimals[i] = balanceConverter.apply(child.getAccountType(), child.getBalance(dates[i])); } } balanceList.add(bigDecimals); } if (child.isParent()) { buildLists(child, dates); } } } /* * ,A1,A2,A3 Jan,455,30,80 Feb,566,70,90 March,678,200,300 */ private void writeHorizontalFormatCSVFile(final String fileName, final LocalDate[] dates) throws IOException { if (fileName == null || dates == null) { return; } try (final BufferedWriter writer = Files.newBufferedWriter(Paths.get(fileName), StandardCharsets.UTF_8)) { // write out the account names with full path final int length = accountList.size(); for (final Account a : accountList) { writer.write(","); writer.write(a.getPathName()); } writer.newLine(); // write out the month, and then balance for that month for (int i = 0; i < dates.length; i++) { writer.write(dates[i].getMonth().getDisplayName(TextStyle.FULL, Locale.getDefault())); for (int j = 0; j < length; j++) { BigDecimal[] bigDecimals = balanceList.get(j); writer.write(","); writer.write(bigDecimals[i].toString()); } writer.newLine(); } } } /* * ,Jan,Feb,Mar A1,30,80,100 A2,70,90,120 A3,200,300,400 */ private void writeVerticalCSVFileFormat(final String fileName, final LocalDate[] dates) throws IOException { if (fileName == null || dates == null) { return; } try (final BufferedWriter writer = Files.newBufferedWriter(Paths.get(fileName), StandardCharsets.UTF_8)) { // write out the month header, the first column is empty for (final LocalDate date : dates) { writer.write(","); writer.write(date.getMonth().getDisplayName(TextStyle.FULL, Locale.getDefault())); } writer.newLine(); // write out the account balance info for (int i = 0; i < accountList.size(); i++) { writer.write(accountList.get(i).getPathName()); for (final BigDecimal bigDecimal : balanceList.get(i)) { writer.write(","); writer.write(bigDecimal.toString()); } writer.newLine(); } } } } ================================================ FILE: jgnash-core/src/main/java/jgnash/report/ProfitLossTextReport.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.report; import java.io.BufferedWriter; import java.io.IOException; import java.math.BigDecimal; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Paths; import java.text.NumberFormat; import java.time.LocalDate; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.List; import java.util.Objects; import java.util.ResourceBundle; import java.util.function.BiFunction; import java.util.logging.Level; import java.util.logging.Logger; import jgnash.engine.Account; import jgnash.engine.AccountType; import jgnash.engine.Comparators; import jgnash.engine.CurrencyNode; import jgnash.engine.Engine; import jgnash.engine.EngineFactory; import jgnash.text.NumericFormats; import jgnash.resource.util.ResourceUtils; /** * Dumps a simple Profit/Loss report to a text file. * * @author Michael Mueller * @author Craig Cavanaugh * @author David Robertson */ public class ProfitLossTextReport { private static final int MAX_LENGTH = 15; //(-000,000,000.00) private static final int MAX_NAME_LEN = 30; private static final int HEADER_LENGTH = 54; private static final String REPORT_FOOTER = new String(new char[HEADER_LENGTH]).replace("\0", "="); private static final String ROW_SEPARATOR = new String(new char[HEADER_LENGTH]).replace("\0", "-"); private NumberFormat numberFormat; private final List reportText = new ArrayList<>(); private final CurrencyNode baseCommodity; private final ResourceBundle rb = ResourceUtils.getBundle(); private final BiFunction balanceConverter; private final LocalDate startDate; private final LocalDate endDate; private final String fileName; /** * Report constructor. * * @param fileName file name to save to * @param startDate start date for the report * @param endDate end date for the report * @param currencyNode CurrencyNode account balances are reported in * @param balanceConverter function to adjust account balance sign */ public ProfitLossTextReport(final String fileName, final LocalDate startDate, final LocalDate endDate, final CurrencyNode currencyNode, final BiFunction balanceConverter) { Objects.requireNonNull(fileName); this.fileName = fileName; this.baseCommodity = currencyNode; this.startDate = startDate; this.endDate = endDate; this.balanceConverter = balanceConverter; } public void run() { generateReport(); writeFile(); } private void generateReport() { final Engine engine = EngineFactory.getEngine(EngineFactory.DEFAULT); Objects.requireNonNull(engine); final Account root = engine.getRootAccount(); numberFormat = NumericFormats.getFullCommodityFormat(baseCommodity); final DateTimeFormatter df = DateTimeFormatter.ISO_DATE; // title and dates reportText.add(rb.getString("Title.ProfitLoss")); reportText.add(""); reportText.add("From " + df.format(startDate) + " To " + df.format(endDate)); reportText.add(""); reportText.add(""); //Income reportText.add(AccountType.INCOME.toString()); reportText.add(ROW_SEPARATOR); //Add up the Gross Income. BigDecimal incomeTotal = BigDecimal.ZERO; for (final BigDecimal balance : getBalances(root, AccountType.INCOME)) { incomeTotal = incomeTotal.add(balance); } reportText.add(ROW_SEPARATOR); reportText.add(formatAccountName(rb.getString("Word.GrossIncome")) + " " + formatDecimal(incomeTotal)); reportText.add(ROW_SEPARATOR); reportText.add(""); reportText.add(""); //Expense reportText.add(AccountType.EXPENSE.toString()); reportText.add(ROW_SEPARATOR); //Add up the Gross Expenses BigDecimal expenseTotal = BigDecimal.ZERO; for (final BigDecimal balance : getBalances(root, AccountType.EXPENSE)) { expenseTotal = expenseTotal.add(balance); } reportText.add(ROW_SEPARATOR); reportText.add(formatAccountName(rb.getString("Word.GrossExpense")) + " " + formatDecimal(expenseTotal)); reportText.add(ROW_SEPARATOR); reportText.add(""); reportText.add(""); //Net Total reportText.add(ROW_SEPARATOR); reportText.add(formatAccountName(rb.getString("Word.NetIncome")) + " " + formatDecimal(incomeTotal.add(expenseTotal))); reportText.add(REPORT_FOOTER); } private void writeFile() { try (final BufferedWriter writer = Files.newBufferedWriter(Paths.get(fileName), StandardCharsets.UTF_8)) { for (final String text : reportText) { //write the array list pl to the file writer.write(text); writer.newLine(); } writer.newLine(); } catch (IOException e) { Logger.getLogger(ProfitLossTextReport.class.getName()).log(Level.SEVERE, e.getLocalizedMessage(), e); } } private List getBalances(final Account a, final AccountType type) { final List balances = new ArrayList<>(); for (final Account child : a.getChildren(Comparators.getAccountByCode())) { if ((child.getTransactionCount() > 0) && type == child.getAccountType()) { final BigDecimal acctBal = balanceConverter.apply(child.getAccountType(), child.getBalance(startDate, endDate, baseCommodity)); // output account name and balance reportText.add(formatAccountName(child.getName()) + " " + formatDecimal(acctBal)); balances.add(acctBal); } if (child.isParent()) { balances.addAll(getBalances(child, type)); } } return balances; } /** * Format decimal amount. * * @param balance the BigDecimal value to format * @return formatted string */ private String formatDecimal(final BigDecimal balance) { final StringBuilder stringBuilder = new StringBuilder(); final String formattedBalance = numberFormat.format(balance); // right align amount to pre-defined maximum length if (formattedBalance.length() < MAX_LENGTH) { stringBuilder.append(" ".repeat(MAX_LENGTH - formattedBalance.length())); } stringBuilder.append(formattedBalance); return stringBuilder.toString(); } /** * Format account name. * * @param name the account name to format * @return the formatted account name */ private static String formatAccountName(final String name) { final StringBuilder stringBuilder = new StringBuilder(MAX_NAME_LEN); stringBuilder.append(name); // set name to pre-defined maximum length stringBuilder.append(" ".repeat(Math.max(0, MAX_NAME_LEN - name.length()))); stringBuilder.setLength(MAX_NAME_LEN); return stringBuilder.toString(); } } ================================================ FILE: jgnash-core/src/main/java/jgnash/report/ReportPeriod.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received account copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.report; import jgnash.resource.util.ResourceUtils; /** * Report period Enum. * * @author Craig Cavanaugh */ public enum ReportPeriod { MONTHLY(ResourceUtils.getString("Period.Monthly")), QUARTERLY(ResourceUtils.getString("Period.Quarterly")), YEARLY(ResourceUtils.getString("Period.Yearly")); private final transient String description; ReportPeriod(final String description) { this.description = description; } @Override public String toString() { return description; } } ================================================ FILE: jgnash-core/src/main/java/jgnash/report/ReportPeriodUtils.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received account copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.report; import java.time.LocalDate; import java.time.temporal.TemporalAdjusters; import java.util.ArrayList; import java.util.List; import jgnash.time.DateUtils; import jgnash.util.NotNull; /** * Utility Methods for reporting. * * @author Craig Cavanaugh */ public class ReportPeriodUtils { private ReportPeriodUtils() { // utility class } public static class Descriptor { private final LocalDate startDate; private final LocalDate endDate; private final String label; private Descriptor(@NotNull final LocalDate startDate, @NotNull final LocalDate endDate, @NotNull final String label) { this.startDate = startDate; this.endDate = endDate; this.label = label; } public LocalDate getStartDate() { return startDate; } public LocalDate getEndDate() { return endDate; } public String getLabel() { return label; } } public static List getDescriptors(@NotNull ReportPeriod reportPeriod, @NotNull LocalDate startDate, @NotNull LocalDate endDate) { final List descriptors = new ArrayList<>(); LocalDate start = startDate; LocalDate end = startDate; switch (reportPeriod) { case YEARLY: while (end.isBefore(endDate)) { end = start.with(TemporalAdjusters.lastDayOfYear()); descriptors.add(new Descriptor(start, end, " " + start.getYear())); start = end.plusDays(1); } break; case QUARTERLY: int i = DateUtils.getQuarterNumber(start) - 1; while (end.isBefore(endDate)) { end = DateUtils.getLastDayOfTheQuarter(start); descriptors.add(new Descriptor(start, end, " " + start.getYear() + "-Q" + (1 + i++ % 4))); start = end.plusDays(1); } break; case MONTHLY: while (end.isBefore(endDate)) { end = DateUtils.getLastDayOfTheMonth(start); final int month = start.getMonthValue(); descriptors.add(new Descriptor(start, end, " " + start.getYear() + (month < 10 ? "/0" + month : "/" + month))); start = end.plusDays(1); } break; default: } return descriptors; } } ================================================ FILE: jgnash-core/src/main/java/jgnash/text/NumericFormats.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.text; import java.text.DecimalFormat; import java.text.DecimalFormatSymbols; import java.text.NumberFormat; import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.Set; import java.util.TreeSet; import java.util.WeakHashMap; import java.util.prefs.Preferences; import java.util.regex.Pattern; import jgnash.engine.CommodityNode; import jgnash.engine.message.Message; import jgnash.engine.message.MessageBus; import jgnash.engine.message.MessageChannel; import jgnash.engine.message.MessageListener; import jgnash.util.NotNull; /** * Utility class to provide Numeric formats * * @author Craig Cavanaugh */ public final class NumericFormats { private static final String FULL_FORMAT = "fullFormat"; private static final String SHORT_FORMAT = "shortFormat"; private static final CommodityListener listener; private static final Map> fullInstanceMap = new WeakHashMap<>(); private static final Map> simpleInstanceMap = new WeakHashMap<>(); private static final String CURRENCY_SYMBOL = "\u00A4"; // ¤, must be defined using unicode or some platforms fail test static { /* * Need to clear any references to CommodityNodes to prevent memory leaks when * files are loaded and unload. */ listener = new CommodityListener(); MessageBus.getInstance().registerListener(listener, MessageChannel.COMMODITY, MessageChannel.SYSTEM); } private NumericFormats() { // factory class } public static Set getKnownFullPatterns() { Set patternSet = new TreeSet<>(); for (final Locale locale : Locale.getAvailableLocales()) { DecimalFormat df = (DecimalFormat)NumberFormat.getCurrencyInstance(locale); patternSet.add(df.toPattern()); } // TODO: add missing US locale format, JDK 11 Bug patternSet.add("\u00A4#,##0.00;(\u00A4#,##0.00)"); patternSet.add("\u00A4 #,##0.00;(\u00A4 #,##0.00)"); patternSet.add(getFullFormatPattern()); // add the users own format return patternSet; } public static Set getKnownShortPatterns() { final Pattern currencyReplacementPattern = Pattern.compile(CURRENCY_SYMBOL); final Set patternSet = new TreeSet<>(); for (final Locale locale : Locale.getAvailableLocales()) { final DecimalFormat df = (DecimalFormat)NumberFormat.getCurrencyInstance(locale); final String pattern = df.toPattern(); patternSet.add(currencyReplacementPattern.matcher(pattern).replaceAll("").stripLeading()); } patternSet.add("#,##0.00;(#,##0.00)"); patternSet.add(getShortFormatPattern()); // add the users own format return patternSet; } public static String getFullFormatPattern() { return getFormatPattern(FULL_FORMAT, ((DecimalFormat)NumberFormat.getCurrencyInstance()).toPattern()); } public static String getShortFormatPattern() { DecimalFormat df = (DecimalFormat)NumberFormat.getCurrencyInstance(); // create the default short format final DecimalFormatSymbols dfs = df.getDecimalFormatSymbols(); dfs.setCurrencySymbol(""); df.setDecimalFormatSymbols(dfs); return getFormatPattern(SHORT_FORMAT, df.toPattern()); } private static String getFormatPattern(@NotNull final String key, final String defaultPattern) { Objects.requireNonNull(key); final Preferences preferences = Preferences.userNodeForPackage(NumericFormats.class); return preferences.get(key, defaultPattern); } public static void setFullFormatPattern(@NotNull final String pattern) { if (!getFullFormatPattern().equals(pattern)) { setFormatPattern(FULL_FORMAT, pattern); fullInstanceMap.clear(); // flush the cached instance map } } public static void setShortFormatPattern(@NotNull final String pattern) { if (!getShortFormatPattern().equals(pattern)) { setFormatPattern(SHORT_FORMAT, pattern); simpleInstanceMap.clear(); // flush the cached instance map } } private static void setFormatPattern(@NotNull final String key, @NotNull final String pattern) { Objects.requireNonNull(pattern); if (!pattern.isBlank()) { final Preferences preferences = Preferences.userNodeForPackage(NumericFormats.class); preferences.put(key, pattern); } } /** * Returns a thread safe simplified {@code NumberFormat} for a given {@code CommodityNode}. * * @param node CommodityNode to format to * @return thread safe {@code NumberFormat} */ public static NumberFormat getShortCommodityFormat(@NotNull final CommodityNode node) { final ThreadLocal o = simpleInstanceMap.get(node); if (o != null) { return o.get(); } final ThreadLocal threadLocal = ThreadLocal.withInitial(() -> { final String pattern = getShortFormatPattern(); // generate a full currency format if (pattern.contains(CURRENCY_SYMBOL)) { return generateFullFormat(node, pattern); } final DecimalFormat df = new DecimalFormat(getShortFormatPattern()); // required for some locales df.setMaximumFractionDigits(node.getScale()); df.setMinimumFractionDigits(df.getMaximumFractionDigits()); // for positive suffix padding for fraction alignment int negSufLen = df.getNegativeSuffix().length(); if (negSufLen > 0) { char[] pad = new char[negSufLen]; for (int i = 0; i < negSufLen; i++) { pad[i] = ' '; } df.setPositiveSuffix(new String(pad)); } return df; }); simpleInstanceMap.put(node, threadLocal); return threadLocal.get(); } /** * Returns a thread safe {@code NumberFormat} for a given {@code CommodityNode}. * * @param node CommodityNode to format to * @return thread safe {@code NumberFormat} */ public static NumberFormat getFullCommodityFormat(@NotNull final CommodityNode node) { final ThreadLocal o = fullInstanceMap.get(node); if (o != null) { return o.get(); } final ThreadLocal threadLocal = ThreadLocal.withInitial(() -> generateFullFormat(node, getFullFormatPattern())); fullInstanceMap.put(node, threadLocal); return threadLocal.get(); } private static DecimalFormat generateFullFormat(final CommodityNode node, final String pattern) { final DecimalFormat df = new DecimalFormat(pattern); final DecimalFormatSymbols dfs = df.getDecimalFormatSymbols(); dfs.setCurrencySymbol(node.getPrefix()); df.setDecimalFormatSymbols(dfs); df.setMaximumFractionDigits(node.getScale()); // required for some locale df.setMinimumFractionDigits(df.getMaximumFractionDigits()); if (node.getSuffix() != null && !node.getSuffix().isEmpty()) { df.setPositiveSuffix(node.getSuffix() + df.getPositiveSuffix()); df.setNegativeSuffix(node.getSuffix() + df.getNegativeSuffix()); } // for positive suffix padding for fraction alignment final int negSufLen = df.getNegativeSuffix().length(); final int posSufLen = df.getPositiveSuffix().length(); // pad the prefix and suffix as necessary so that they are the same length if (negSufLen > posSufLen) { df.setPositiveSuffix(df.getPositiveSuffix() + " ".repeat(Math.max(0, negSufLen - (negSufLen - posSufLen) + 1))); } else if (posSufLen > negSufLen) { df.setNegativeSuffix(df.getNegativeSuffix() + " ".repeat(Math.max(0, posSufLen - (posSufLen - negSufLen) + 1))); } return df; } private static String getConversion(final String cur1, final String cur2) { return cur1 + " > " + cur2; } public static String getConversion(final CommodityNode cur1, final CommodityNode cur2) { return getConversion(cur1.getSymbol(), cur2.getSymbol()); } public static NumberFormat getFixedPrecisionFormat(final int scale) { final NumberFormat nf = NumberFormat.getNumberInstance(); nf.setMaximumFractionDigits(scale); nf.setMinimumFractionDigits(scale); return nf; } public static NumberFormat getPercentageFormat() { final NumberFormat nf = NumberFormat.getPercentInstance(); nf.setMaximumFractionDigits(2); nf.setMinimumFractionDigits(2); return nf; } private static class CommodityListener implements MessageListener { @Override public void messagePosted(final Message event) { switch (event.getEvent()) { case FILE_CLOSING: case CURRENCY_MODIFY: simpleInstanceMap.clear(); fullInstanceMap.clear(); break; default: break; } } } } ================================================ FILE: jgnash-core/src/main/java/jgnash/time/DateUtils.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.time; import java.text.DateFormat; import java.text.SimpleDateFormat; import java.time.DayOfWeek; import java.time.Instant; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.Month; import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatterBuilder; import java.time.format.ResolverStyle; import java.time.temporal.TemporalAdjusters; import java.time.temporal.WeekFields; import java.util.ArrayList; import java.util.Date; import java.util.List; import java.util.Locale; import java.util.Objects; import java.util.Set; import java.util.TreeSet; import java.util.prefs.Preferences; import java.util.regex.Pattern; import jgnash.util.NotNull; import static java.time.temporal.ChronoField.DAY_OF_MONTH; import static java.time.temporal.ChronoField.HOUR_OF_DAY; import static java.time.temporal.ChronoField.MINUTE_OF_HOUR; import static java.time.temporal.ChronoField.MONTH_OF_YEAR; import static java.time.temporal.ChronoField.SECOND_OF_MINUTE; import static java.time.temporal.ChronoField.YEAR; /** * Static methods to make working with dates a bit easier. * * @author Craig Cavanaugh * @author Vincent Frison */ public class DateUtils { private static final int MONTHS_PER_YEAR = 12; private static final int DAYS_PER_WEEK = 7; private static final String DATE_FORMAT = "dateFormat"; private static final int MILLISECONDS_PER_SECOND = 1000; private static final Pattern MONTH_PATTERN = Pattern.compile("M{1,2}"); private static final Pattern DAY_PATTERN = Pattern.compile("d{1,2}"); private static final Pattern HOUR_PATTERN = Pattern.compile("h{1,2}"); private static DateTimeFormatter shortDateFormatter; private static final DateTimeFormatter shortDateTimeFormatter; private static DateTimeFormatter shortDateManualEntryFormatter; static { shortDateFormatter = DateTimeFormatter.ofPattern(getShortDatePattern()).withResolverStyle(ResolverStyle.SMART); shortDateTimeFormatter = DateTimeFormatter.ofPattern(getShortDateTimePattern()) .withResolverStyle(ResolverStyle.SMART); shortDateManualEntryFormatter = DateTimeFormatter.ofPattern(getShortDateManualEntryPattern()) .withResolverStyle(ResolverStyle.SMART); } private DateUtils() { } public static void setShortDateFormatPattern(@NotNull final String pattern) throws IllegalArgumentException { Objects.requireNonNull(pattern); final Preferences preferences = Preferences.userNodeForPackage(DateUtils.class); preferences.put(DATE_FORMAT, pattern); shortDateFormatter = DateTimeFormatter.ofPattern(pattern).withResolverStyle(ResolverStyle.SMART); shortDateManualEntryFormatter = DateTimeFormatter.ofPattern(getShortDateManualEntryPattern()) .withResolverStyle(ResolverStyle.SMART); } public static Set getAvailableShortDateFormats() { final Set dateFormats = new TreeSet<>(); for (final Locale locale : Locale.getAvailableLocales()) { final DateFormat df = DateFormat.getDateInstance(DateFormat.SHORT, locale); if (df instanceof SimpleDateFormat) { dateFormats.add(((SimpleDateFormat) df).toPattern()); } } return dateFormats; } public static String getShortDatePattern() { final Preferences preferences = Preferences.userNodeForPackage(DateUtils.class); String pattern = preferences.get(DATE_FORMAT, ""); if (pattern.isEmpty()) { // create a default for the current locale final DateFormat df = DateFormat.getDateInstance(DateFormat.SHORT); if (df instanceof SimpleDateFormat) { pattern = ((SimpleDateFormat) df).toPattern(); pattern = DAY_PATTERN.matcher(MONTH_PATTERN.matcher(pattern).replaceAll("MM")).replaceAll("dd"); } else { throw new RuntimeException("Unexpected class"); } } return pattern; } private static String getShortDateTimePattern() { final DateFormat df = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM); String pattern = ((SimpleDateFormat) df).toPattern(); pattern = DAY_PATTERN.matcher(MONTH_PATTERN.matcher(pattern).replaceAll("MM")).replaceAll("dd"); pattern = HOUR_PATTERN.matcher(pattern).replaceAll("hh"); return pattern; } /** * Returns a variant of the default format with required days reduced to one to make manual entry easier. * * @return date format */ private static String getShortDateManualEntryPattern() { String pattern = getShortDatePattern(); // Relax date entry if (pattern.contains("dd")) { pattern = pattern.replace("dd", "d"); } // Relax month entry if (pattern.contains("MM")) { pattern = pattern.replace("MM", "M"); } return pattern; } /** * Returns a {@code LocalDate} compatible {@code DateTimeFormatter} that Excel understands * @return Excel compatible {@code DateTimeFormatter} */ public static DateTimeFormatter getExcelDateFormatter() { return new DateTimeFormatterBuilder().appendValue(YEAR, 4).appendValue(MONTH_OF_YEAR, 2) .appendValue(DAY_OF_MONTH, 2) .toFormatter(); } /** * Returns a {@code LocalDateTime} compatible {@code DateTimeFormatter} that Excel understands * @return Excel compatible {@code DateTimeFormatter} */ public static DateTimeFormatter getExcelTimestampFormatter() { return new DateTimeFormatterBuilder() .appendValue(YEAR, 4).appendLiteral('-').appendValue(MONTH_OF_YEAR, 2) .appendLiteral('-').appendValue(DAY_OF_MONTH, 2).appendLiteral(' ') .appendValue(HOUR_OF_DAY, 2).appendLiteral(':').appendValue(MINUTE_OF_HOUR, 2) .appendLiteral(':').appendValue(SECOND_OF_MINUTE, 2) .toFormatter(); } /** * Converts a {@code LocalDate} into a {@code Date} using the default timezone. * * @param localDate {@code LocalDate} to convert * @return an equivalent {@code Date} */ public static Date asDate(final LocalDate localDate) { return Date.from(localDate.atStartOfDay().atZone(ZoneId.systemDefault()).toInstant()); } /** * Converts a {@code LocalDate} into a {@code Date} using the default timezone. * * @param localDate {@code LocalDate} to convert * @return an equivalent {@code Date} */ public static Date asDate(final LocalDateTime localDate) { return Date.from(localDate.atZone(ZoneId.systemDefault()).toInstant()); } /** * Converts a {@code Date} into a {@code LocalDate} using the default timezone. * * @param date {@code Date} to convert * @return an equivalent {@code LocalDate} or {@code null} if the supplied date was {@code null} */ public static LocalDate asLocalDate(final Date date) { if (date != null) { return asLocalDate(date.getTime()); } return null; } /** * Converts milliseconds from the epoch of 1970-01-01T00:00:00Z into a {@code LocalDate} using the default timezone. * * @param milli milliseconds from the epoch of 1970-01-01T00:00:00Z. * @return an equivalent {@code LocalDate} or {@code null} if the supplied date was {@code null} */ public static LocalDate asLocalDate(final long milli) { return Instant.ofEpochMilli(milli).atZone(ZoneId.systemDefault()).toLocalDate(); } /** * Converts a LocaleDate into milliseconds from the epoch of 1970-01-01T00:00:00Z. * * @param localDate {@code LocalDate} to convert * @return and equivalent milliseconds from the epoch of 1970-01-01T00:00:00Z */ public static long asEpochMilli(final LocalDate localDate) { return localDate.atStartOfDay(ZoneId.systemDefault()).toEpochSecond() * MILLISECONDS_PER_SECOND; } /** * Determines is {@code LocalDate} d1 occurs after {@code LocalDate} d2. The specified dates are * inclusive. * * @param d1 date 1 * @param d2 date 2 * @return true if d1 is after d2 */ public static boolean after(final LocalDate d1, final LocalDate d2) { return before(d2, d1, true); } /** * Determines if {@code LocalDate} d1 occurs before {@code LocalDate} d2. The specified dates are * inclusive * * @param d1 date 1 * @param d2 date 2 * @return true if d1 is before d2 or the same date */ public static boolean before(final LocalDate d1, final LocalDate d2) { return before(d1, d2, true); } /** * Determines if {@code LocalDate} d1 occurs before {@code LocalDate} d2. * * @param d1 {@code LocalDate} 1 * @param d2 {@code LocalDate} 2 * @param inclusive {@code true} is comparison is inclusive * @return {@code true} if d1 occurs before d2 */ public static boolean before(final LocalDate d1, final LocalDate d2, final boolean inclusive) { if (inclusive) { return d1.isEqual(d2) || d1.isBefore(d2); } return d1.isBefore(d2); } /** * Determines if {@code LocalDate} d1 occurs after {@code LocalDate} d2 and before {@code LocalDate} d3. * The specified dates are inclusive. * * @param d1 {@code LocalDate} 1 * @param d2 {@code LocalDate} 2 * @param d3 {@code LocalDate} 3 * @return {@code true} if d1 occurs between d2 and d3 */ public static boolean between(final LocalDate d1, final LocalDate d2, final LocalDate d3) { return after(d1, d2) && before(d1, d3); } /** * Returns the number of days in the year. * * @param year calendar year * @return the number of days in the year */ public static int getDaysInYear(final int year) { return LocalDate.ofYearDay(year, 1).lengthOfYear(); } /** * Returns an array of the first days bi-weekly for a given year. * * @param year The year to generate the array for * @return The array of dates */ public static LocalDate[] getFirstDayBiWeekly(final int year) { return getFirstDayWeekly(Month.JANUARY, year, (int) Math.ceil((float) DateUtils.getNumberOfWeeksInYear(year) / 2.0), 2); } /** * Returns an array of the first days bi-weekly given a stating month, year, and the number of weeks * * @param statingMonth The Month to begin with * @param year The year to generate the array for * @param weeks The number of dates to return * @see #getFirstDayWeekly(Month, int, int) */ public static LocalDate[] getFirstDayBiWeekly(final Month statingMonth, final int year, final int weeks) { return getFirstDayWeekly(statingMonth, year, weeks, 2); } /** * Generates an array of dates starting on the first day of every month in * the specified year. * * @param year The year to generate the array for * @return The array of dates */ public static LocalDate[] getFirstDayMonthly(final int year) { return getFirstDayMonthly(Month.JANUARY, year, MONTHS_PER_YEAR); } /** * Generates an array of dates starting on the first day of every month in * the specified year. * * @param month That month to start with * @param year The year to generate the array for * @param months The number of months * @return The array of dates */ public static LocalDate[] getFirstDayMonthly(final Month month, final int year, final int months) { return getFirstDayMonthly(month, year, months, 1); } /** * Generates an array of dates starting on the first day of every month in * the specified year. * * @param month That month to start with * @param year The year to generate the array for * @param months The number of months * @param step increment * @return The array of dates */ private static LocalDate[] getFirstDayMonthly(final Month month, final int year, final int months, final int step) { LocalDate[] list = new LocalDate[months]; int index = 0; int _month = month.getValue(); int _year = year; while (index < months) { list[index] = getFirstDayOfTheMonth(_month, _year); _month += step; if (_month > MONTHS_PER_YEAR) { _year++; _month = _month - MONTHS_PER_YEAR; } index++; } return list; } /** * Returns a leveled date representing the first day of the month based on a * specified date. * * @param date the base date to work from * @return The last day of the month and year specified */ public static LocalDate getFirstDayOfTheMonth(final LocalDate date) { return date.with(TemporalAdjusters.firstDayOfMonth()); } /** * Returns a date representing the first day of the month. * * @param month The month (index starts at 1) * @param year The year (index starts at 1) * @return The last day of the month and year specified */ private static LocalDate getFirstDayOfTheMonth(final int month, final int year) { return LocalDate.of(year, month, 1); } /** * Returns an array of the starting date of each quarter in a year. * * @param year The year to generate the array for * @return The array of quarter bound dates */ public static LocalDate[] getFirstDayQuarterly(final int year) { return getFirstDayMonthly(Month.JANUARY, year, 4, 3); } /** * Returns an array of the starting date of each quarter in a year. * * @param year The year to generate the array for * @return The array of quarter bound dates */ public static LocalDate[] getFirstDayQuarterly(final Month statingMonth, final int year, final int weeks) { return getFirstDayMonthly(statingMonth, year, weeks, 3); } /** * Returns an array of Dates starting with the first day of each week of the * year per ISO 8601. * * @param year The year to generate the array for * @return The array of dates * @see ISO_8601 */ public static LocalDate[] getFirstDayWeekly(final int year) { return getFirstDayWeekly(Month.JANUARY, year, getNumberOfWeeksInYear(year), 1); } /** * Returns an array of weekly Dates per ISO 8601. * * @param statingMonth The Month to begin with * @param year The year to generate the array for * @param weeks The number of dates to return * @return The array of dates * @see ISO_8601 */ public static LocalDate[] getFirstDayWeekly(final Month statingMonth, final int year, final int weeks) { return getFirstDayWeekly(statingMonth, year, weeks, 1); } /** * Returns an array of weekly Dates per ISO 8601. * * @param statingMonth The Month to begin with * @param year The year to generate the array for * @param weeks The number of dates to return * @param step The week increment * @return The array of dates * @see ISO_8601 */ public static LocalDate[] getFirstDayWeekly(final Month statingMonth, final int year, final int weeks, final int step) { LocalDate date = LocalDate.of(year, statingMonth, 1); // Weeks begin on Monday per ISO_8601 date = date.with(TemporalAdjusters.firstInMonth(DayOfWeek.MONDAY)); // shift the stating date per ISO_8601 rules switch (LocalDate.of(year, Month.JANUARY, 1).getDayOfWeek()) { case FRIDAY: case SATURDAY: case SUNDAY: break; default: date = date.minusDays(DAYS_PER_WEEK); break; } final List dates = new ArrayList<>(); dates.add(date); for (int i = 1; i < weeks; i++) { date = date.plusDays((long) DAYS_PER_WEEK * step); dates.add(date); } return dates.toArray(new LocalDate[0]); } /** * Returns the number of weeks in a year per ISO 8601. * * @param year year * @return number of weeks * @see ISO_8601 */ public static int getNumberOfWeeksInYear(final int year) { LocalDate midYear = LocalDate.of(year, Month.JUNE, 1); return (int) midYear.range(WeekFields.ISO.weekOfWeekBasedYear()).getMaximum(); } /** * Returns an array of every day in a given year. * * @param year The year to generate the array for * @return The array of dates */ public static LocalDate[] getAllDays(final int year) { return getAllDays(Month.JANUARY, year, getDaysInYear(year)); } public static LocalDate[] getAllDays(final Month statingMonth, final int year, final int days) { final List dates = new ArrayList<>(); LocalDate start = LocalDate.of(year, statingMonth, 1); dates.add(start); for (int i = 1; i < days; i++) { start = start.plusDays(1); dates.add(start); } return dates.toArray(new LocalDate[0]); } /** * Returns date representing the last day of the month given a specified date. * * @param date the base date to work from * @return The last day of the month of the supplied date */ public static LocalDate getLastDayOfTheMonth(@NotNull final LocalDate date) { Objects.requireNonNull(date); return date.with(TemporalAdjusters.lastDayOfMonth()); } /** * Generates an array of dates ending on the last day of every month between * the start and stop dates. * * @param startDate The date to start at * @param endDate The data to stop at * @return The array of dates */ public static List getLastDayOfTheMonths(final LocalDate startDate, final LocalDate endDate) { final ArrayList list = new ArrayList<>(); final LocalDate end = DateUtils.getLastDayOfTheMonth(endDate); LocalDate t = DateUtils.getLastDayOfTheMonth(startDate); /* * add a month at a time to the previous date until all of the months * have been captured */ while (before(t, end)) { list.add(t); t = t.plusMonths(1); t = t.with(TemporalAdjusters.lastDayOfMonth()); } return list; } /** * Generates an array of dates starting on the first day of every month * between the start and stop dates. * * @param startDate The date to start at * @param endDate The data to stop at * @return The array of dates */ public static List getFirstDayOfTheMonths(final LocalDate startDate, final LocalDate endDate) { final ArrayList list = new ArrayList<>(); final LocalDate end = DateUtils.getFirstDayOfTheMonth(endDate); LocalDate t = DateUtils.getFirstDayOfTheMonth(startDate); /* * add a month at a time to the previous date until all of the months * have been captured */ while (before(t, end)) { list.add(t); t = t.with(TemporalAdjusters.firstDayOfNextMonth()); } return list; } /** * Returns a {@code LocalDate} date representing the last day of the quarter based on * a specified date. * * @param date the base date to work from * @return The last day of the quarter specified */ public static LocalDate getLastDayOfTheQuarter(final LocalDate date) { Objects.requireNonNull(date); LocalDate result; LocalDate[] bounds = getQuarterBounds(date); if (date.compareTo(bounds[2]) < 0) { result = bounds[1]; } else if (date.compareTo(bounds[4]) < 0) { result = bounds[3]; } else if (date.compareTo(bounds[6]) < 0) { result = bounds[5]; } else { result = bounds[DAYS_PER_WEEK]; } return result; } /** * Returns a {@code LocalDate} representing the last day of the year based on a * specified date. * * @param date the base date to work from * @return The last day of the year specified */ public static LocalDate getLastDayOfTheYear(@NotNull final LocalDate date) { Objects.requireNonNull(date); return date.with(TemporalAdjusters.lastDayOfYear()); } /** * Returns an array of quarter bound dates of the year based on a specified * date. The order is q1s, q1e, q2s, q2e, q3s, q3e, q4s, q4e. * * @param date the base date to work from * @return The array of quarter bound dates */ private static LocalDate[] getQuarterBounds(final LocalDate date) { Objects.requireNonNull(date); final LocalDate[] bounds = new LocalDate[8]; bounds[0] = date.with(TemporalAdjusters.firstDayOfYear()); bounds[1] = date.withMonth(Month.MARCH.getValue()).with(TemporalAdjusters.lastDayOfMonth()); bounds[2] = date.withMonth(Month.APRIL.getValue()).with(TemporalAdjusters.firstDayOfMonth()); bounds[3] = date.withMonth(Month.JUNE.getValue()).with(TemporalAdjusters.lastDayOfMonth()); bounds[4] = date.withMonth(Month.JULY.getValue()).with(TemporalAdjusters.firstDayOfMonth()); bounds[5] = date.withMonth(Month.SEPTEMBER.getValue()).with(TemporalAdjusters.lastDayOfMonth()); bounds[6] = date.withMonth(Month.OCTOBER.getValue()).with(TemporalAdjusters.firstDayOfMonth()); bounds[DAYS_PER_WEEK] = date.with(TemporalAdjusters.lastDayOfYear()); return bounds; } /** * Returns the number of the quarter (i.e. 1, 2, 3 or 4) based on a * specified date. * * @param date the base date to work from * @return The number of the quarter specified */ public static int getQuarterNumber(final LocalDate date) { Objects.requireNonNull(date); int result; LocalDate[] bounds = getQuarterBounds(date); if (date.compareTo(bounds[2]) < 0) { result = 1; } else if (date.compareTo(bounds[4]) < 0) { result = 2; } else if (date.compareTo(bounds[6]) < 0) { result = 3; } else { result = 4; } return result; } public static DateTimeFormatter getShortDateFormatter() { return shortDateFormatter; } public static DateTimeFormatter getShortDateTimeFormatter() { return shortDateTimeFormatter; } public static DateTimeFormatter getShortDateManualEntryFormatter() { return shortDateManualEntryFormatter; } /** * Returns the numerical week of the year given a date per the ISO 8601 standard. * * @param dateOfYear the base date to work from * @return the week of the year */ public static int getWeekOfTheYear(final LocalDate dateOfYear) { return dateOfYear.get(WeekFields.ISO.weekOfWeekBasedYear()); } } ================================================ FILE: jgnash-core/src/main/java/jgnash/time/Period.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received account copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.time; import jgnash.resource.util.ResourceUtils; /** * Period Enum. * * @author Craig Cavanaugh */ public enum Period { DAILY(ResourceUtils.getString("Period.Daily"), 1/30f), // approximation WEEKLY(ResourceUtils.getString("Period.Weekly"), .25f), // approximation BI_WEEKLY(ResourceUtils.getString("Period.BiWeekly"), .5f), // approximation MONTHLY(ResourceUtils.getString("Period.Monthly"), 1), QUARTERLY(ResourceUtils.getString("Period.Quarterly"), 3), YEARLY(ResourceUtils.getString("Period.Yearly" ), 12); private final transient String description; /** * The number of months in a period. The value may be an approximation */ private final transient float months; Period(final String description, final float months) { this.description = description; this.months = months; } @Override public String toString() { return description; } public float getMonths() { return months; } } ================================================ FILE: jgnash-core/src/main/java/jgnash/util/CollectionUtils.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.util; import java.util.LinkedHashMap; import java.util.LinkedList; import java.util.List; import java.util.Map; /** * Utility class for working with Collections. * * @author Craig Cavanaugh */ public class CollectionUtils { private CollectionUtils() {} /** * Sorts a map by it's value. * * @param map Map to sort * @param key * @param value * @return sorted Map */ public static > Map sortMapByValue(final Map map) { final List> list = new LinkedList<>(map.entrySet()); list.sort(Map.Entry.comparingByValue()); final Map sortedMap = new LinkedHashMap<>(); for (final Map.Entry entry : list) { sortedMap.put(entry.getKey(), entry.getValue()); } return sortedMap; } } ================================================ FILE: jgnash-core/src/main/java/jgnash/util/DefaultDaemonThreadFactory.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.util; import java.util.concurrent.ThreadFactory; import java.util.concurrent.atomic.AtomicInteger; /** * The default thread factory to be used for {@code ExecutorService} that * forces threads to be daemons *

* Example usage: * {@code * ExecutorService pool = Executors.newSingleThreadExecutor(new DefaultDaemonThreadFactory()); * } * * @author Craig Cavanaugh * * @see java.util.concurrent.ExecutorService */ public class DefaultDaemonThreadFactory implements ThreadFactory { private static final AtomicInteger poolNumber = new AtomicInteger(1); private final AtomicInteger threadNumber = new AtomicInteger(1); private final String namePrefix; public DefaultDaemonThreadFactory(final String description) { namePrefix = description + "(" + poolNumber.incrementAndGet() + "), Thread "; } @Override public Thread newThread(final @NotNull Runnable r) { Thread t = new Thread(r, namePrefix + threadNumber.incrementAndGet()); t.setDaemon(true); t.setPriority(Thread.NORM_PRIORITY); return t; } } ================================================ FILE: jgnash-core/src/main/java/jgnash/util/EncodeDecode.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.util; import java.text.DecimalFormat; import java.text.NumberFormat; import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.Locale; import java.util.regex.Pattern; import static java.util.stream.Collectors.joining; /** * EncodeDecode is used to encode/decode various objects using a String. * * @author Craig Cavanaugh */ public class EncodeDecode { public static final Pattern COMMA_DELIMITER_PATTERN = Pattern.compile(","); private static final char COMMA_DELIMITER = ','; private EncodeDecode() { } /** * Encodes a double array as a comma separated {@code String}. Values will be rounded to 2 decimal places. *

* The format is forced to use '.' as the decimal separator because some locales will use a comma. * * @param doubleArray array of doubles to encode * @return resultant {@code String} */ public static String encodeDoubleArray(final double[] doubleArray) { final NumberFormat numberFormat = NumberFormat.getNumberInstance(Locale.US); final DecimalFormat df = (DecimalFormat) numberFormat; df.applyPattern("#.##"); return Arrays.stream(doubleArray).mapToObj(df::format).collect(joining(String.valueOf(COMMA_DELIMITER))); } /** * Decodes a comma separated list of {@code double} into a primitive {@code double} array. * * @param string Comma separated string * @return primitive {@code double} array */ public static double[] decodeDoubleArray(@NotNull final String string) { return Arrays.stream(COMMA_DELIMITER_PATTERN.split(string)).mapToDouble(Double::parseDouble).toArray(); } /** * Encodes a boolean array as a string of 1's and 0's. * * @param array a boolean array to encode as a String * @return A string of 1's and 0's representing the boolean array */ public static String encodeBooleanArray(final boolean[] array) { StringBuilder buf = new StringBuilder(); if (array != null) { for (boolean anArray : array) { if (anArray) { buf.append('1'); } else { buf.append('0'); } } return buf.toString(); } return null; } /** * Turns a string of "10101" into a boolean array. * * @param array array to decode * @return the boolean array, zero length if string is null or zero length */ public static boolean[] decodeBooleanArray(final String array) { if (array != null) { int len = array.length(); if (len > 0) { boolean[] b = new boolean[len]; for (int i = 0; i < len; i++) { if (array.charAt(i) == '1') { b[i] = true; } } return b; } } return new boolean[0]; } public static String encodeStringCollection(final Collection list) { return encodeStringCollection(list, COMMA_DELIMITER); } public static String encodeStringCollection(final Collection list, final char delimiter) { // precondition check for the delimiter existence for (final String string : list) { if (string.indexOf(delimiter) > -1) { throw new RuntimeException("The list of strings may not contain a " + delimiter); } } return list.stream().collect(joining(String.valueOf(delimiter))); } public static Collection decodeStringCollection(final String string) { return decodeStringCollection(string, COMMA_DELIMITER); } public static Collection decodeStringCollection(final String string, final char delimiter) { if (string == null || string.isEmpty()) { return Collections.emptyList(); } Pattern pattern = Pattern.compile(String.valueOf(delimiter)); return java.util.Arrays.asList(pattern.split(string)); } /** * Converts a hex based color into an integer for compact storage * @param color # based hex color * @return int value */ public static long colorStringToLong(final String color) { return Long.parseUnsignedLong(color.replaceAll("0x",""), 16); } /** * Converts a int based color into a hex format used by UI classes * @param color int value * @return hex based color string */ public static String longToColorString(final long color) { return String.format("0x%08X", color); } } ================================================ FILE: jgnash-core/src/main/java/jgnash/util/EncryptionManager.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.util; import java.nio.charset.StandardCharsets; import java.security.InvalidKeyException; import java.security.Key; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.Base64; import java.util.logging.Level; import java.util.logging.Logger; import javax.crypto.BadPaddingException; import javax.crypto.Cipher; import javax.crypto.IllegalBlockSizeException; import javax.crypto.NoSuchPaddingException; import javax.crypto.spec.SecretKeySpec; /** * A Simple encryption class based on a supplied user and password. * * @author Craig Cavanaugh */ public class EncryptionManager { private final Key key; //public static final String ENCRYPTION_FLAG = "encrypt"; private static final String ENCRYPTION_ALGORITHM = "AES"; public static final String DECRYPTION_ERROR_TAG = ""; private static final Logger logger = Logger.getLogger(EncryptionManager.class.getName()); public EncryptionManager(final char[] password) { byte[] encryptionKey = "fake".getBytes(StandardCharsets.UTF_8); try { final MessageDigest md = MessageDigest.getInstance("SHA-256"); final StringBuilder builder = new StringBuilder(new String(password)); // generate the encryption key encryptionKey = md.digest(builder.toString().getBytes(StandardCharsets.UTF_8)); builder.delete(0, builder.length() - 1); } catch (final NoSuchAlgorithmException e) { LogUtil.logSevere(EncryptionManager.class, e); } key = new SecretKeySpec(encryptionKey, ENCRYPTION_ALGORITHM); } /** * Encrypts the supplied string. * * @param plain String to encrypt * @return the encrypted string */ public String encrypt(final String plain) { try { final Cipher cipher = Cipher.getInstance(ENCRYPTION_ALGORITHM); cipher.init(Cipher.ENCRYPT_MODE, key); return Base64.getEncoder().encodeToString(cipher.doFinal(plain.getBytes(StandardCharsets.UTF_8))); } catch (final InvalidKeyException | NoSuchAlgorithmException | NoSuchPaddingException | BadPaddingException | IllegalBlockSizeException e) { LogUtil.logSevere(EncryptionManager.class, e); } return null; } /** * Decrypts the supplied string. * * @param encrypted String to decrypt * @return The decrypted string of {@code DECRYPTION_ERROR_TAG} if decryption fails * @see #DECRYPTION_ERROR_TAG */ public String decrypt(final String encrypted) { try { final Cipher cipher = Cipher.getInstance(ENCRYPTION_ALGORITHM); cipher.init(Cipher.DECRYPT_MODE, key); return new String(cipher.doFinal(Base64.getDecoder().decode(encrypted)), StandardCharsets.UTF_8); } catch (final InvalidKeyException | NoSuchAlgorithmException | NoSuchPaddingException | BadPaddingException | IllegalBlockSizeException e) { logger.log(Level.SEVERE, "Invalid password"); return DECRYPTION_ERROR_TAG; } } } ================================================ FILE: jgnash-core/src/main/java/jgnash/util/FileLocker.java ================================================ package jgnash.util; import java.io.IOException; import java.nio.channels.FileChannel; import java.nio.channels.FileLock; import java.nio.channels.OverlappingFileLockException; import java.nio.file.Path; import java.nio.file.StandardOpenOption; import java.util.logging.Level; import java.util.logging.Logger; /** * Class to encapsulate file locking * * @author Craig Cavanaugh */ public class FileLocker { private FileLock fileLock = null; private FileChannel lockChannel = null; public boolean acquireLock(final Path path) { boolean result = false; try { lockChannel = FileChannel.open(path, StandardOpenOption.READ, StandardOpenOption.WRITE); fileLock = lockChannel.tryLock(); result = fileLock.isValid(); } catch (final IOException | OverlappingFileLockException ex) { Logger.getLogger(FileLock .class.getName()).log(Level.SEVERE, ex.getLocalizedMessage(), ex); } return result; } @SuppressWarnings("UnusedReturnValue") public boolean release() { boolean result = false; try { if (fileLock != null) { fileLock.release(); fileLock = null; } if (lockChannel != null) { lockChannel.close(); lockChannel = null; } result = true; } catch (final IOException ex) { Logger.getLogger(FileLock .class.getName()).log(Level.SEVERE, ex.getLocalizedMessage(), ex); } return result; } } ================================================ FILE: jgnash-core/src/main/java/jgnash/util/FileMagic.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.util; import java.io.BufferedInputStream; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStream; import java.nio.ByteBuffer; import java.nio.channels.SeekableByteChannel; import java.nio.charset.CharacterCodingException; import java.nio.charset.Charset; import java.nio.charset.CharsetDecoder; import java.nio.charset.MalformedInputException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.Arrays; import java.util.EnumSet; import java.util.Objects; import java.util.logging.Level; import java.util.logging.Logger; import java.util.regex.Pattern; import javax.xml.stream.XMLInputFactory; import javax.xml.stream.XMLStreamConstants; import javax.xml.stream.XMLStreamException; import javax.xml.stream.XMLStreamReader; import jgnash.engine.Engine; import static java.nio.charset.StandardCharsets.ISO_8859_1; import static java.nio.charset.StandardCharsets.US_ASCII; import static java.nio.charset.StandardCharsets.UTF_16; import static java.nio.charset.StandardCharsets.UTF_16BE; import static java.nio.charset.StandardCharsets.UTF_16LE; import static java.nio.charset.StandardCharsets.UTF_8; import static java.nio.file.StandardOpenOption.READ; /** * Class to identify file types. * * @author Craig Cavanaugh */ public class FileMagic { private static final Pattern COLON_DELIMITER_PATTERN = Pattern.compile(":"); private static final byte[] BINARY_XSTREAM_HEADER = new byte[]{10, -127, 0, 13, 111, 98, 106, 101, 99, 116, 45, 115, 116, 114, 101, 97, 109, 11, -127, 10}; private static final byte[] H2_HEADER = new byte[]{0x2D, 0x2D, 0x20, 0x48, 0x32, 0x20, 0x30, 0x2E, 0x35, 0x2F, 0x42, 0x20, 0x2D, 0x2D}; private static final byte[] H2MV_HEADER = new byte[]{72, 58, 50}; private static final byte[] HSQL_HEADER = "SET DATABASE UNIQUE NAME HSQLDB".getBytes(StandardCharsets.UTF_8); private static final byte[] XML_HEADER = "")) { //must be ofx result = true; } break; } line = reader.readLine(); } } catch (final MalformedInputException mie) { result = false; // caused by binary file Logger.getLogger(FileMagic.class.getName()).info("Tried to read a binary file"); } catch (IOException e) { Logger.getLogger(FileMagic.class.getName()).log(Level.SEVERE, e.toString(), e); } } return result; } static boolean isBinaryXStreamFile(final Path path) { return isFile(path, BINARY_XSTREAM_HEADER); } private static boolean isH2File(final Path path) { return isFile(path, H2_HEADER); } private static boolean isH2MvFile(final Path path) { return isFile(path, H2MV_HEADER); } private static boolean isHsqlFile(final Path path) { return isFile(path, HSQL_HEADER); } private static boolean isFile(final Path path, final byte[] header) { boolean result = false; if (Files.exists(path)) { try (final SeekableByteChannel channel = Files.newByteChannel(path, EnumSet.of(READ))) { if (channel.size() > 0) { // must not be a zero length file final ByteBuffer buff = ByteBuffer.allocate(header.length); channel.read(buff); result = Arrays.equals(buff.array(), header); buff.clear(); } } catch (final IOException ex) { Logger.getLogger(FileMagic.class.getName()).log(Level.SEVERE, null, ex); } } return result; } /** * Determines if this is a valid jGnash XML file. *

* This will fail if the file was created by a future version of the file with a greater major version. * * @param path path to verify* * @return {@code true} if valid. */ private static boolean isValidVersion2File(final Path path) { if (!Files.exists(path) || !Files.isReadable(path) || !Files.isRegularFile(path)) { return false; } boolean result = false; if (isFile(path, XML_HEADER)) { try { result = (int) Math.floor(Float.parseFloat(getXMLVersion(path))) <= Engine.CURRENT_MAJOR_VERSION; } catch (final NumberFormatException nfe) { Logger.getLogger(FileMagic.class.getName()).info("Invalid version string"); } } return result; } /** * Determines the version of the jGnash file. * * @param path {@code file to check} * @return file version as a String */ private static String getXMLVersion(final Path path) { String version = ""; try (final InputStream input = Files.newInputStream(path)) { XMLInputFactory inputFactory = XMLInputFactory.newInstance(); // Protect against external entity attacks inputFactory.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false); inputFactory.setProperty(XMLInputFactory.SUPPORT_DTD, false); XMLStreamReader reader = inputFactory.createXMLStreamReader(input, StandardCharsets.UTF_8.name()); while (reader.hasNext()) { int event = reader.next(); if (event == XMLStreamConstants.PROCESSING_INSTRUCTION) { String name = reader.getPITarget(); String data = reader.getPIData(); if (name.equals("fileFormat")) { version = data; break; } } } reader.close(); } catch (final IOException e) { LogUtil.logSevere(FileMagic.class, e); } catch (final XMLStreamException e) { Logger.getLogger(FileMagic.class.getName()).log(Level.INFO, "{0} was not a valid jGnash XML_HEADER file", path.toString()); } return version; } private static boolean detectCharset(final InputStream inputStream, final Charset charset) { boolean identified; try (final BufferedInputStream input = new BufferedInputStream(inputStream)) { final CharsetDecoder decoder = charset.newDecoder(); final byte[] buffer = new byte[BUFFER_SIZE]; identified = false; while ((input.read(buffer) != -1) && (!identified)) { try { decoder.decode(ByteBuffer.wrap(buffer)); identified = true; } catch (final CharacterCodingException e) { return false; } } } catch (final IOException e) { return false; } return identified; } private static boolean detectCharset(final Path path, final Charset charset) { try { return detectCharset(Files.newInputStream(path), charset); } catch (final IOException e) { return false; } } private static Charset detectCharset(final Path path) { for (final Charset charset : CHARSETS) { if (detectCharset(path, charset)) { return charset; } } return UTF_8; // default } /** * Determines the Charset of an input stream. * * The stream will be closed after detection * @param inputStream stream to check * @return detected Charset */ public static Charset detectCharset(final InputStream inputStream) { for (final Charset charset : CHARSETS) { if (detectCharset(inputStream, charset)) { return charset; } } return UTF_8; // default } /** * Determines the Charset of an input file * The stream will be closed after detection * @param fileName file to check * @return detected Charset */ public static Charset detectCharset(final String fileName) { Charset charset = StandardCharsets.UTF_8; final Path path = Paths.get(fileName); if (Files.exists(path)) { charset = FileMagic.detectCharset(path); Logger.getLogger(FileMagic.class.getName()).info(fileName + " was encoded as " + charset); } return charset; } public enum FileType { BinaryXStream, OfxV1, OfxV2, jGnash2XML, h2, h2mv, hsql, unknown } } ================================================ FILE: jgnash-core/src/main/java/jgnash/util/FileUtils.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.util; import java.io.BufferedInputStream; import java.io.BufferedOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.nio.channels.FileChannel; import java.nio.channels.FileLock; import java.nio.channels.OverlappingFileLockException; import java.nio.file.FileSystems; import java.nio.file.FileVisitResult; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.SimpleFileVisitor; import java.nio.file.StandardCopyOption; import java.nio.file.StandardOpenOption; import java.nio.file.StandardWatchEventKinds; import java.nio.file.WatchEvent; import java.nio.file.WatchKey; import java.nio.file.WatchService; import java.nio.file.attribute.BasicFileAttributes; import java.time.Duration; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Objects; import java.util.concurrent.TimeUnit; import java.util.logging.Logger; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; import java.util.stream.Stream; import java.util.zip.Deflater; import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; import jgnash.engine.jpa.JpaH2DataStore; import jgnash.engine.jpa.JpaH2MvDataStore; import jgnash.engine.jpa.JpaHsqlDataStore; import jgnash.engine.xstream.XMLDataStore; import static jgnash.util.LogUtil.logSevere; /** * File utilities. * * @author Craig Cavanaugh */ public final class FileUtils { /** * Regular expression for returning the file extension. */ private static final String FILE_EXT_REGEX = "(?<=\\.).*$"; private static final String[] FILE_LOCK_EXTENSIONS = new String[]{JpaHsqlDataStore.LOCK_EXT, JpaH2DataStore.LOCK_EXT, JpaH2MvDataStore.LOCK_EXT, ".lock"}; private static final String[] FILE_EXTENSIONS = new String[]{JpaH2DataStore.H2_FILE_EXT, JpaH2MvDataStore.MV_FILE_EXT, JpaHsqlDataStore.FILE_EXT, XMLDataStore.FILE_EXT}; public static final String SEPARATOR = System.getProperty("file.separator"); private static final int DEFAULT_BUFFER_SIZE = 8192; // HSQLDB ~10.10 seconds // H2 database ~2.0 seconds private static final long LOCK_FILE_PERIOD = 11000; private static final int LOCK_WAIT_PERIOD = 20; private FileUtils() { } /** * Deletes a path and it's contents. * * @param path {@code Path} to delete * @throws IOException thrown if an IO error occurs */ public static void deletePathAndContents(final Path path) throws IOException { if (Files.exists(path)) { // only try if it exists Files.walkFileTree(path, new SimpleFileVisitor<>() { @Override public FileVisitResult visitFile(final Path file, final BasicFileAttributes attributes) throws IOException { Files.delete(file); return FileVisitResult.CONTINUE; } @Override public FileVisitResult postVisitDirectory(final Path dir, final IOException ex) throws IOException { Files.delete(dir); return FileVisitResult.CONTINUE; } }); } } /** * Determines if a file has been locked for use. A lock file check is performed * at the filesystem level and the actual file is checked for a locked state at the OS level. * * @param fileName file name to check for locked state * @return true if a lock file is found or the file is locked at the OS level. * @throws java.io.IOException thrown if file does not exist or it is a directory */ public static boolean isFileLocked(final String fileName) throws IOException { boolean result = false; if (Files.exists(Paths.get(fileName))) { // false if the base file is not found for (final String extension : FILE_LOCK_EXTENSIONS) { if (Files.exists(Paths.get(FileUtils.stripFileExtension(fileName) + extension))) { result = true; break; } } if (!result) { try (final FileChannel channel = FileChannel.open(Paths.get(fileName), StandardOpenOption.READ, StandardOpenOption.WRITE)) { try (final FileLock lock = channel.tryLock()) { if (lock == null) { result = true; } } catch (final OverlappingFileLockException ex) { // indicates file is already locked result = true; } } catch (final IOException e) { logSevere(FileUtils.class, e); throw e; } } } return result; } /** * Determines if a lock file is stale / leftover from a crash * * @param fileName database file/lockfile to test * @return true if the lock file is determined to be stale * @throws IOException thrown if file does not exist or it is a directory */ public static boolean isLockFileStale(final String fileName) throws IOException { boolean result = false; final long maxIterations = LOCK_FILE_PERIOD / LOCK_WAIT_PERIOD; search: for (final String extension : FILE_LOCK_EXTENSIONS) { final Path path = Paths.get(FileUtils.stripFileExtension(fileName) + extension); if (Files.exists(path)) { for (int i = 0; i < maxIterations; i++) { long timeStamp = lastModified(path); if (System.currentTimeMillis() - timeStamp > LOCK_FILE_PERIOD) { result = true; break search; } try { Thread.sleep(LOCK_WAIT_PERIOD); } catch (final InterruptedException e) { logSevere(FileUtils.class, e); Thread.currentThread().interrupt(); } } } } return result; } public static boolean deleteLockFile(final String fileName) throws IOException { boolean result = false; for (final String extension : FILE_LOCK_EXTENSIONS) { final Path path = Paths.get(FileUtils.stripFileExtension(fileName) + extension); if (Files.exists(path)) { Files.delete(path); result = true; } } return result; } /** * Strips the file extensions off the supplied filename including the period. *

* Known @code(DataStore} extensions are checked first prior to assuming a simple file extension. * If the supplied filename does not contain an extension ending with a period, the original is returned. * * @param fileName filename to strip the extension off * @return filename with extension removed */ public static String stripFileExtension(@NotNull final String fileName) { // check for known types first for (final String extension : FILE_EXTENSIONS) { int index = fileName.toLowerCase().lastIndexOf(extension.toLowerCase()); if (index >= 0) { return fileName.substring(0, index); } } int index = fileName.lastIndexOf('.'); if (index >= 0) { return fileName.substring(0, index); } return fileName; } /** * Returns the modification time of a file * * @param path file to check * @return last modification timestamp in millis * @throws IOException thrown if file is not found */ private static long lastModified(final Path path) throws IOException { return Files.getLastModifiedTime(path).toMillis(); } /** * Determine if the supplied file name has an extension. * * @param fileName filename to check * @return true if supplied file has an extension */ public static boolean fileHasExtension(final String fileName) { return !stripFileExtension(fileName).equals(fileName); } /** * Returns the file extension if it has one * jGnash specific file extensions are check first. * * @param fileName file name to extract extension from * @return file extension or an empty string if not found. */ public static String getFileExtension(final String fileName) { Objects.requireNonNull(fileName); for (final String extension : FILE_EXTENSIONS) { if (fileName.toLowerCase().endsWith(extension.toLowerCase())) { return extension.substring(1); // skip the leading period } } String result = ""; final Pattern pattern = Pattern.compile(FILE_EXT_REGEX); final Matcher matcher = pattern.matcher(fileName); if (matcher.find()) { result = matcher.group(); } return result; } /** * Make a copy of a file given a source and destination. * * @param src Source file * @param dst Destination file * @return true if the copy was successful */ public static boolean copyFile(final Path src, final Path dst) { boolean result = false; if (src != null && dst != null && !src.equals(dst)) { try { Files.copy(src, dst, StandardCopyOption.REPLACE_EXISTING); result = true; } catch (final IOException e) { logSevere(FileUtils.class, e); } } return result; } public static void compressFile(@NotNull final Path source, @NotNull final Path destination) { // Create the destination directory if needed if (!Files.isDirectory(destination.getParent())) { try { Files.createDirectories(destination.getParent()); Logger.getLogger(FileUtils.class.getName()).info("Created directories"); } catch (final IOException e) { logSevere(FileUtils.class, e); } } // Try to open the zip file for output try (final OutputStream fos = new BufferedOutputStream(Files.newOutputStream(destination)); final ZipOutputStream zipOut = new ZipOutputStream(fos)) { // Try to open the input stream try (final InputStream in = new BufferedInputStream(Files.newInputStream(source, StandardOpenOption.READ))) { zipOut.setLevel(Deflater.BEST_COMPRESSION); // strip the path when creating the zip entry zipOut.putNextEntry(new ZipEntry(source.getFileName().toString())); // Transfer bytes from the file to the ZIP file int length; byte[] ioBuffer = new byte[DEFAULT_BUFFER_SIZE]; while ((length = in.read(ioBuffer)) > 0) { zipOut.write(ioBuffer, 0, length); } // finish the zip entry, but let the try-with-resources handle the close zipOut.finish(); } } catch (final IOException e) { logSevere(FileUtils.class, e); } } /** * Returns a sorted list of files in a specified directory that match a regex search pattern. * * @param directory base directory for the search * @param regexPattern regex search pattern * @return a List of matching Files. The list will be empty if no matches * are found or if the directory is not valid. */ public static List getDirectoryListing(final Path directory, final String regexPattern) { final List fileList = new ArrayList<>(); if (directory != null && Files.isDirectory(directory)) { final Pattern p = Pattern.compile(regexPattern); try (final Stream stream = Files.list(directory)) { fileList.addAll(stream.filter(path -> p.matcher(path.toString()).matches()) .collect(Collectors.toList())); } catch (IOException e) { logSevere(FileUtils.class, e); } Collections.sort(fileList); // sort in natural order } return fileList; } /** * Blocks for a specified period of time for removal of a file * * @param fileName file to monitor * @param timeout timeout in milliseconds * @return true if the file existed and was removed */ public static boolean waitForFileRemoval(final String fileName, final long timeout) { boolean result = false; final LocalDateTime start = LocalDateTime.now(); if (fileName != null && Files.exists(Paths.get(fileName))) { final Path filePath = Paths.get(fileName); try (final WatchService watchService = FileSystems.getDefault().newWatchService()) { filePath.getParent().register(watchService, StandardWatchEventKinds.ENTRY_DELETE); while (Duration.between(start, LocalDateTime.now()).toMillis() < timeout) { final WatchKey watchKey = watchService.poll(timeout, TimeUnit.MILLISECONDS); if (watchKey != null) { for (final WatchEvent ignored : watchKey.pollEvents()) { if (!Files.exists(filePath)) { Logger.getLogger(FileUtils.class.getName()).info(fileName + " was removed"); result = true; break; } } } } } catch (final IOException e) { logSevere(FileUtils.class, e); } catch (final InterruptedException e) { logSevere(FileUtils.class, e); Thread.currentThread().interrupt(); } } else { result = true; // lock file was already gone } // Last check for file existence. File removal could have occurred before watch service started if (!result && Files.exists(Paths.get(fileName))) { Logger.getLogger(FileUtils.class.getName()).info("Timed out waiting for removal of: " + fileName); } return result; } } ================================================ FILE: jgnash-core/src/main/java/jgnash/util/LocaleObject.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.util; import java.util.Arrays; import java.util.Collection; import java.util.Locale; import java.util.Objects; import java.util.stream.Collectors; /** * Utility Class for display and ordering of {@code Locale} objects in a nice readable and sorted order. * * @author Craig Cavanaugh */ public class LocaleObject implements Comparable { private final Locale locale; private final String display; public LocaleObject(final Locale locale) { this.locale = Objects.requireNonNull(locale); display = locale.getDisplayName() + " - " + locale + " [" + locale.getDisplayName(locale) + "]"; } public Locale getLocale() { return locale; } @Override public final String toString() { return display; } @Override public int compareTo(@NotNull final LocaleObject o) { return toString().compareTo(o.toString()); } @Override public boolean equals(final Object obj) { if (obj instanceof LocaleObject) { return equals((LocaleObject) obj); } return false; } @Override public int hashCode() { return Objects.hash(locale, display); } private boolean equals(final LocaleObject obj) { return obj.locale.equals(locale); } public static Collection getLocaleObjects() { return Arrays.stream(Locale.getAvailableLocales()) .filter(l -> l.getDisplayName() != null && !l.getDisplayName().isEmpty() && !l.getCountry().isEmpty()) .map(LocaleObject::new) .sorted().collect(Collectors.toList()); } } ================================================ FILE: jgnash-core/src/main/java/jgnash/util/LockedCommodityNode.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received account copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.util; import jgnash.engine.CommodityNode; /** * Decorator around a CommodityNode to indicate it is in a locked/unlocked state. * * @author Craig Cavanaugh */ public class LockedCommodityNode implements Comparable> { private final boolean locked; private final T t; public LockedCommodityNode(@NotNull final T t, final boolean locked) { this.t = t; this.locked = locked; } @Override public String toString() { return t.toString(); } public boolean isLocked() { return locked; } @Override public int compareTo(@NotNull final LockedCommodityNode other) { return t.compareTo(other.t); } @SuppressWarnings("rawtypes") @Override public boolean equals(final Object o) { return this == o || o instanceof LockedCommodityNode && t.equals(((LockedCommodityNode) o).t); } public T getNode() { return t; } @Override public int hashCode() { int hash = t.hashCode(); return 13 * hash + (locked ? 1 : 0); } } ================================================ FILE: jgnash-core/src/main/java/jgnash/util/LogUtil.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.util; import java.io.IOException; import java.io.InputStream; import java.util.logging.Level; import java.util.logging.LogManager; import java.util.logging.Logger; /** * Utility class to reduce logging code *

* A fair amount of byte code is generated to log a throwable. *

* This class provides a static method which produces much less byte code when used. * * @author Craig Cavanaugh */ public class LogUtil { static { try (final InputStream stream = LogUtil.class.getClassLoader() .getResourceAsStream("logging.properties")) { LogManager.getLogManager().readConfiguration(stream); } catch (IOException e) { e.printStackTrace(); } } private LogUtil() { // utility class } public static void configureLogging() { Logger.getLogger(LogUtil.class.getName()).info("Logging configured"); } /** * Logs a throwable at Level.SEVERE. * * @param clazz calling class * @param throwable Throwable to log */ public static void logSevere(final Class clazz, final Throwable throwable) { Logger.getLogger(clazz.getName()).log(Level.SEVERE, throwable.getLocalizedMessage(), throwable); } /** * Logs a throwable at Level.SEVERE. * * @param clazz calling class * @param message error message */ public static void logSevere(final Class clazz, final String message) { Logger.getLogger(clazz.getName()).log(Level.SEVERE, message); } } ================================================ FILE: jgnash-core/src/main/java/jgnash/util/MathEval.java ================================================ package jgnash.util; /** * A simple recursive decent math parser taken from public domain code: *

* https://stackoverflow.com/questions/3422673/how-to-evaluate-a-math-expression-given-in-string-form/26227947#26227947 */ public class MathEval { private final String str; private int pos = -1; private int ch; private MathEval(final String str) { this.str = str; } public static double eval(final String str) throws ArithmeticException { return new MathEval(str).parse(); } private void nextChar() { ch = (++pos < str.length()) ? str.charAt(pos) : -1; } private boolean eat(final int charToEat) { while (ch == ' ') { nextChar(); } if (ch == charToEat) { nextChar(); return true; } return false; } private double parse() throws ArithmeticException { nextChar(); double x = parseExpression(); if (pos < str.length()) { throw new ArithmeticException("Unexpected: " + (char) ch); } return x; } // Grammar: // expression = term | expression `+` term | expression `-` term // term = factor | term `*` factor | term `/` factor // factor = `+` factor | `-` factor | `(` expression `)` // | number | functionName factor | factor `^` factor private double parseExpression() { double x = parseTerm(); for (; ; ) { if (eat('+')) { x += parseTerm(); // addition } else if (eat('-')) { x -= parseTerm(); // subtraction } else { return x; } } } private double parseTerm() { double x = parseFactor(); for (; ; ) { if (eat('*')) { x *= parseFactor(); // multiplication } else if (eat('/')) { x /= parseFactor(); // division } else { return x; } } } private double parseFactor() { if (eat('+')) { // unary plus return parseFactor(); } if (eat('-')) { // unary minus return -parseFactor(); } double x; int startPos = this.pos; if (eat('(')) { // parentheses x = parseExpression(); eat(')'); } else if ((ch >= '0' && ch <= '9') || ch == '.') { // numbers while ((ch >= '0' && ch <= '9') || ch == '.') { nextChar(); } try { x = Double.parseDouble(str.substring(startPos, this.pos)); } catch (final NumberFormatException nfe) { return Double.NaN; // return NaN if the parser failed } } else { throw new ArithmeticException("Unexpected: " + (char) ch); } return x; } } ================================================ FILE: jgnash-core/src/main/java/jgnash/util/MultiHashMap.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.util; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.concurrent.locks.ReentrantReadWriteLock; /** * A HashMap that allows multiple values for the same key. If multiple * values exist for a given key, the values are treated as a FILO buffer. *

* This class is thread-safe * * @param Key class * @param Object class * @author Craig Cavanaugh */ public class MultiHashMap extends HashMap { private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock(); @SuppressWarnings("unchecked") @Override public Object put(final K key, final Object value) { if (value instanceof ArrayList) { throw new IllegalArgumentException("ArrayLists are not allowed"); } lock.writeLock().lock(); try { if (containsKey(key)) { Object val = super.get(key); if (val instanceof ArrayList) { ArrayList l = (ArrayList) val; l.add(value); return l.get(l.size() - 2); } ArrayList l = new ArrayList<>(); l.add(val); l.add(value); super.put(key, l); return val; } return super.put(key, value); } finally { lock.writeLock().unlock(); } } /** * By default the last value in, is the first value out. */ @SuppressWarnings("rawtypes") @Override public Object get(final Object key) { lock.readLock().lock(); try { Object v = super.get(key); if (v instanceof ArrayList) { ArrayList l = (ArrayList) v; return l.get(l.size() - 1); } return v; } finally { lock.readLock().unlock(); } } /** * Returns all of the objects associated with a given key. This is guaranteed to * return a valid list. * * @param key The key to use * @return The list of values associated with the key */ @SuppressWarnings("unchecked") public List getAll(final Object key) { lock.readLock().lock(); try { Object v = super.get(key); if (v instanceof ArrayList) { return Collections.unmodifiableList((ArrayList) v); } else if (v == null) { return Collections.emptyList(); } else { return Collections.singletonList((V) v); } } finally { lock.readLock().unlock(); } } @SuppressWarnings({"unchecked", "rawtypes", "element-type-mismatch"}) @Override public Object remove(final Object key) { lock.writeLock().lock(); try { Object v = super.get(key); if (v instanceof ArrayList) { ArrayList l = (ArrayList) v; if (l.size() == 2) { v = l.get(1); super.put((K) key, l.get(0)); // remove the ArrayList return v; } return l.remove(l.size() - 1); } return super.remove(key); } finally { lock.writeLock().unlock(); } } /** * Removes a specific value. * * @param key The key of the value to remove * @param value The specific value to remove * @return {@code true} if the map changed */ @SuppressWarnings("rawtypes") public boolean removeValue(final K key, final V value) { boolean result = false; lock.writeLock().lock(); try { Object v = super.get(key); if (v instanceof ArrayList) { ArrayList l = (ArrayList) v; if (l.remove(value)) { if (l.size() == 1) { // remove the ArrayList super.put(key, value); } result = true; } } else if (v != null && v.equals(value)) { result = super.remove(key) != null; } } finally { lock.writeLock().unlock(); } return result; } } ================================================ FILE: jgnash-core/src/main/java/jgnash/util/NewFileUtility.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.util; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Paths; import java.util.Collection; import java.util.ResourceBundle; import jgnash.engine.Account; import jgnash.engine.AccountTreeXMLFactory; import jgnash.engine.AccountType; import jgnash.engine.CurrencyNode; import jgnash.engine.DataStoreType; import jgnash.engine.Engine; import jgnash.engine.EngineFactory; import jgnash.engine.RootAccount; import jgnash.resource.util.ResourceUtils; /** * Utility class for generating new files. * * @author Craig Cavanaugh */ public class NewFileUtility { public static void buildNewFile(final String fileName, final DataStoreType dataStoreType, final char[] password, final CurrencyNode currencyNode, final Collection currencyNodes, final Collection rootAccountCollection) throws IOException { final ResourceBundle resources = ResourceUtils.getBundle(); // have to close the engine first EngineFactory.closeEngine(EngineFactory.DEFAULT); // try to delete any existing database if (Files.exists(Paths.get(fileName))) { if (!EngineFactory.deleteDatabase(fileName)) { throw new IOException(ResourceUtils.getString("Message.Error.DeleteExistingFile", fileName)); } } // create the directory if needed Files.createDirectories(Paths.get(fileName).getParent()); final Engine e = EngineFactory.bootLocalEngine(fileName, EngineFactory.DEFAULT, password, dataStoreType); CurrencyNode defaultCurrency = currencyNode; // match the existing default to the new default. If they match, replace to prevent // creation of a duplicate currency if (e.getDefaultCurrency().matches(defaultCurrency)) { defaultCurrency = e.getDefaultCurrency(); } // make sure a duplicate default is not added for (final CurrencyNode node : currencyNodes) { if (!node.matches(defaultCurrency)) { e.addCurrency(node); } } if (!defaultCurrency.equals(e.getDefaultCurrency())) { e.setDefaultCurrency(defaultCurrency); } if (!rootAccountCollection.isEmpty()) { // import account sets for (final RootAccount root : rootAccountCollection) { AccountTreeXMLFactory.importAccountTree(e, root); } } else { // none selected, create a very basic account set final RootAccount root = e.getRootAccount(); final Account bank = new Account(AccountType.BANK, defaultCurrency); bank.setDescription(resources.getString("Name.BankAccounts")); bank.setName(resources.getString("Name.BankAccounts")); e.addAccount(root, bank); final Account income = new Account(AccountType.INCOME, defaultCurrency); income.setDescription(resources.getString("Name.IncomeAccounts")); income.setName(resources.getString("Name.IncomeAccounts")); e.addAccount(root, income); final Account expense = new Account(AccountType.EXPENSE, defaultCurrency); expense.setDescription(resources.getString("Name.ExpenseAccounts")); expense.setName(resources.getString("Name.ExpenseAccounts")); e.addAccount(root, expense); } } private NewFileUtility() { // Utility class } } ================================================ FILE: jgnash-core/src/main/java/jgnash/util/NotNull.java ================================================ package jgnash.util; import java.lang.annotation.Documented; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; @Documented @Retention(RetentionPolicy.CLASS) @Target({ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER, ElementType.LOCAL_VARIABLE}) public @interface NotNull { } ================================================ FILE: jgnash-core/src/main/java/jgnash/util/Nullable.java ================================================ package jgnash.util; import java.lang.annotation.Documented; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; @Documented @Retention(RetentionPolicy.CLASS) @Target({ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER, ElementType.LOCAL_VARIABLE}) public @interface Nullable { } ================================================ FILE: jgnash-core/src/main/java/jgnash/util/SearchUtils.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.util; import java.util.regex.Pattern; /** * Search Utility class. * * @author Craig Cavanaugh */ public class SearchUtils { private SearchUtils() { } /** * Creates a Pattern given a DOS style wildcard search pattern. * * @param pattern search pattern * @param caseSensitive true if the Pattern should be case sensitive * @return Pattern according to specified parameters */ public static Pattern createSearchPattern(final String pattern, final boolean caseSensitive) { Pattern p; if (caseSensitive) { p = Pattern.compile(wildcardSearchToRegex(pattern)); } else { p = Pattern.compile(wildcardSearchToRegex(pattern), Pattern.CASE_INSENSITIVE | Pattern.UNICODE_CASE); } return p; } private static String wildcardSearchToRegex(final String wildcard) { StringBuilder buffer = new StringBuilder(wildcard.length() + 2); buffer.append('^'); for (char c : wildcard.toCharArray()) { if (c == '*') { buffer.append(".*"); } else if (c == '?') { buffer.append('.'); } else if ("+()^$.{}[]|\\".indexOf(c) != -1) { buffer.append('\\').append(c); // escape special regexp-characters } else { buffer.append(c); } } buffer.append('$'); return buffer.toString(); } } ================================================ FILE: jgnash-core/src/main/java/jgnash/util/function/MemoPredicate.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.util.function; import java.util.Locale; import java.util.Objects; import java.util.function.Predicate; import java.util.regex.Matcher; import java.util.regex.Pattern; import jgnash.engine.Transaction; import jgnash.util.SearchUtils; /** * Filter for Transaction memos. * * @author Craig Cavanaugh */ public class MemoPredicate implements Predicate { private final String filter; private final Pattern pattern; public MemoPredicate(final String filter, final boolean useRegex) { if (useRegex && !filter.isEmpty()) { pattern = SearchUtils.createSearchPattern(Objects.requireNonNull(filter), false); this.filter = null; } else { pattern = null; this.filter = filter.toLowerCase(Locale.getDefault()); } } @Override public boolean test(final Transaction transaction) { if (pattern != null) { final Matcher matcher = pattern.matcher(transaction.getMemo()); return matcher.matches(); } else if (filter != null && !filter.isEmpty()) { return transaction.getMemo().toLowerCase(Locale.getDefault()).contains(filter); } return true; } } ================================================ FILE: jgnash-core/src/main/java/jgnash/util/function/ParentAccountPredicate.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.util.function; import java.util.function.Predicate; import jgnash.engine.Account; /** * Predicate that only allows Accounts with children. * * @author Craig Cavanaugh */ public class ParentAccountPredicate implements Predicate { @Override public boolean test(final Account account) { return account.isParent(); } } ================================================ FILE: jgnash-core/src/main/java/jgnash/util/function/PayeePredicate.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.util.function; import java.util.Locale; import java.util.Objects; import java.util.function.Predicate; import java.util.regex.Matcher; import java.util.regex.Pattern; import jgnash.engine.Transaction; import jgnash.util.NotNull; import jgnash.util.SearchUtils; /** * Filter for Transaction payees. * * @author Craig Cavanaugh */ public class PayeePredicate implements Predicate { private final String filter; private final Pattern pattern; public PayeePredicate(@NotNull final String filter, final boolean useRegex) { if (useRegex && !filter.isEmpty()) { pattern = SearchUtils.createSearchPattern(Objects.requireNonNull(filter), false); this.filter = null; } else { pattern = null; this.filter = filter.toLowerCase(Locale.getDefault()); } } @Override public boolean test(final Transaction transaction) { if (pattern != null) { final Matcher matcher = pattern.matcher(transaction.getPayee()); return matcher.matches(); } else if (filter != null && !filter.isEmpty()) { return transaction.getPayee().toLowerCase(Locale.getDefault()).contains(filter); } return true; } } ================================================ FILE: jgnash-core/src/main/java/jgnash/util/function/ReconciledPredicate.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.util.function; import java.util.function.Predicate; import jgnash.engine.Account; import jgnash.engine.ReconciledState; import jgnash.engine.Transaction; import jgnash.util.NotNull; import jgnash.util.Nullable; /** * Predicate for the reconciled state of a transaction. * * @author Craig Cavanaugh */ public class ReconciledPredicate implements Predicate { private final Account account; private final ReconciledState reconciledState; public ReconciledPredicate(@NotNull Account account, @Nullable final ReconciledState reconciledState) { this.account = account; this.reconciledState = reconciledState; } @Override public boolean test(final Transaction transaction) { if (reconciledState == null) { return true; } return reconciledState == transaction.getReconciled(account); } } ================================================ FILE: jgnash-core/src/main/java/jgnash/util/function/TagPredicate.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.util.function; import java.util.HashSet; import java.util.Set; import java.util.function.Predicate; import jgnash.engine.Tag; import jgnash.engine.Transaction; import jgnash.util.NotNull; /** * Predicate for the Tags assigned to a transaction. * * @see jgnash.engine.Tag * * @author Craig Cavanaugh */ public class TagPredicate implements Predicate { private final Set tags = new HashSet<>(); public TagPredicate(@NotNull final Set tags) { this.tags.addAll(tags); } @Override public boolean test(final Transaction transaction) { if (tags.isEmpty()) { return true; } for (final Tag tag : tags) { if (transaction.getTags().contains(tag)) { return true; } } return false; } } ================================================ FILE: jgnash-core/src/main/java/jgnash/util/function/TransactionAgePredicate.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.util.function; import java.time.LocalDate; import java.time.temporal.ChronoUnit; import java.util.function.Predicate; import jgnash.engine.Transaction; /** * Predicate for age of a {@code Transaction}. * * @author Craig Cavanaugh */ public class TransactionAgePredicate implements Predicate { private final int age; private final ChronoUnit chronoUnit; public TransactionAgePredicate(final ChronoUnit chronoUnit, final int age) { this.chronoUnit = chronoUnit; this.age = age; } @Override public boolean test(final Transaction transaction) { if (chronoUnit == ChronoUnit.FOREVER) { return true; } return chronoUnit.between(transaction.getLocalDate(), LocalDate.now()) <= age; } } ================================================ FILE: jgnash-core/src/main/java/jgnash/util/prefs/MapBasedPreferences.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.util.prefs; import java.util.HashMap; import java.util.Map; import java.util.prefs.AbstractPreferences; /** * Map based Preferences implementation. Preferences must be persisted using the * {@code exportSubtree(OutputStream os)} and {@code Preferences.importPreferences(InputStream)} * methods. * * @author Craig Cavanaugh */ @SuppressWarnings("unused") class MapBasedPreferences extends AbstractPreferences { private final boolean isUserNode; private final Map map = new HashMap<>(); MapBasedPreferences(final MapBasedPreferences parent, final String name, final boolean isUserNode) { super(parent, name); this.isUserNode = isUserNode; newNode = true; } @Override public boolean isUserNode() { return isUserNode; } @Override protected void putSpi(final String key, final String value) { map.put(key, value); } @Override protected String getSpi(final String key) { return map.get(key); } @Override protected void removeSpi(final String key) { map.remove(key); } @Override protected void removeNodeSpi() { map.clear(); } @Override protected String[] keysSpi() { return map.keySet().toArray(new String[0]); } @Override protected String[] childrenNamesSpi() { return new String[0]; } @Override protected AbstractPreferences childSpi(final String name) { return new MapBasedPreferences(this, name, isUserNode); } @Override protected void syncSpi() { // implementation not needed, memory based } @Override protected void flushSpi() { // implementation not needed, memory based } } ================================================ FILE: jgnash-core/src/main/java/jgnash/util/prefs/MapPreferencesFactory.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.util.prefs; import java.util.prefs.Preferences; import java.util.prefs.PreferencesFactory; /** * Map based Preferences implementation. * * @author Craig Cavanaugh* */ @SuppressWarnings("unused") public class MapPreferencesFactory implements PreferencesFactory { private static final Preferences user = new MapBasedPreferences(null, "", true); private static final Preferences system = new MapBasedPreferences(null, "", false); /** * Returns the system root preference node. (Multiple calls on this * method will return the same object reference.) */ @Override public Preferences systemRoot() { return system; } /** * Returns the user root preference node corresponding to the calling * user. In a server, the returned value will typically depend on * some implicit client-context. */ @Override public Preferences userRoot() { return user; } } ================================================ FILE: jgnash-core/src/main/java/jgnash/util/prefs/PortablePreferences.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2021 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.util.prefs; import jgnash.resource.util.ResourceUtils; import jgnash.util.FileUtils; import jgnash.util.LogUtil; import jgnash.util.NotNull; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.logging.Level; import java.util.logging.Logger; import java.util.prefs.BackingStoreException; import java.util.prefs.InvalidPreferencesFormatException; import java.util.prefs.Preferences; /** * Portable preferences utility class. * * @author Craig Cavanaugh */ public class PortablePreferences { private PortablePreferences() { // Utility class } public static void initPortablePreferences(final String portableFile) { System.setProperty("java.util.prefs.PreferencesFactory", "jgnash.util.prefs.MapPreferencesFactory"); importPreferences(portableFile); // add a shutdown hook to export user preferences Runtime.getRuntime().addShutdownHook(new ExportPreferencesThread(portableFile)); } private static void importPreferences(final String file) { final Path importFile = getPreferenceFile(file); if (Files.isReadable(importFile)) { Logger.getLogger(PortablePreferences.class.getName()).info("Importing preferences"); try (final InputStream is = Files.newInputStream(importFile)) { Preferences.importPreferences(is); } catch (final InvalidPreferencesFormatException | IOException e) { System.err.println("Preferences file " + importFile + " could not be read"); LogUtil.logSevere(PortablePreferences.class, e); } } else { System.err.println("Preferences file " + importFile + " was not found"); } } public static void deleteUserPreferences() { try { final Preferences p = Preferences.userRoot(); if (p.nodeExists("/jgnash")) { Preferences jgnash = p.node("/jgnash"); jgnash.removeNode(); p.flush(); System.out.println(ResourceUtils.getString("Message.UninstallGood")); } else { System.err.println(ResourceUtils.getString("Message.PrefFail")); } } catch (final BackingStoreException bse) { LogUtil.logSevere(PortablePreferences.class, bse); System.err.println(ResourceUtils.getString("Message.UninstallBad")); } } private static Path getPreferenceFile(final String portableFile) { final Path exportFile; if (portableFile != null && !portableFile.isEmpty()) { exportFile = Paths.get(portableFile); } else { exportFile = Paths.get(System.getProperty("user.dir") + FileUtils.SEPARATOR + "pref.xml"); } Logger.getLogger(PortablePreferences.class.getName()).log(Level.INFO, "Preference file: {0}", exportFile); return exportFile; } private static class ExportPreferencesThread extends Thread { final String file; ExportPreferencesThread(@NotNull final String file) { this.file = file; } @Override public void run() { Logger.getLogger(ExportPreferencesThread.class.getName()).info("Exporting preferences"); final Path exportFile = getPreferenceFile(file); final Preferences preferences = Preferences.userRoot(); try (final OutputStream os = Files.newOutputStream(exportFile)) { try { if (preferences.nodeExists("/jgnash")) { final Preferences p = preferences.node("/jgnash"); p.exportSubtree(os); } deleteUserPreferences(); } catch (final BackingStoreException e) { LogUtil.logSevere(PortablePreferences.class, e); } } catch (final IOException e) { LogUtil.logSevere(ExportPreferencesThread.class, e); } } } } ================================================ FILE: jgnash-core/src/main/resources/META-INF/persistence.xml ================================================ org.hibernate.jpa.HibernatePersistenceProvider jgnash.engine.AbstractInvestmentTransactionEntry jgnash.engine.Account jgnash.engine.AmortizeObject jgnash.engine.budget.Budget jgnash.engine.budget.BudgetGoal jgnash.engine.CommodityNode jgnash.engine.Config jgnash.engine.CurrencyNode jgnash.engine.ExchangeRate jgnash.engine.ExchangeRateHistoryNode jgnash.engine.InvestmentTransaction jgnash.engine.RootAccount jgnash.engine.SecurityHistoryEvent jgnash.engine.SecurityHistoryNode jgnash.engine.SecurityNode jgnash.engine.Tag jgnash.engine.Transaction jgnash.engine.TransactionEntry jgnash.engine.TransactionEntryAddX jgnash.engine.TransactionEntryAbstractIncrease jgnash.engine.TransactionEntryBuyX jgnash.engine.TransactionEntryDividendX jgnash.engine.TransactionEntryMergeX jgnash.engine.TransactionEntryReinvestDivX jgnash.engine.TransactionEntryRemoveX jgnash.engine.TransactionEntryRocX jgnash.engine.TransactionEntrySellX jgnash.engine.TransactionEntrySplitX jgnash.engine.recurring.DailyReminder jgnash.engine.recurring.MonthlyReminder jgnash.engine.recurring.OneTimeReminder jgnash.engine.recurring.Reminder jgnash.engine.recurring.WeeklyReminder jgnash.engine.recurring.YearlyReminder jgnash.engine.StoredObject jgnash.engine.TrashObject jgnash.engine.jpa.JpaTrashEntity ================================================ FILE: jgnash-core/src/main/resources/logging.properties ================================================ handlers= java.util.logging.ConsoleHandler .level= INFO java.util.logging.ConsoleHandler.level = INFO java.util.logging.ConsoleHandler.formatter = java.util.logging.SimpleFormatter java.util.logging.SimpleFormatter.format=[%1$tF %1$tT] [%4$-7s] {%2$s} %5$s%6$s%n ================================================ FILE: jgnash-fx/build.gradle.kts ================================================ description = "jGnash" val javaFXVersion: String by project // extract JavaFX version from gradle.properties val picocliVersion: String by project val testFxVersion: String by project val monocleVersion: String by project val commonsLangVersion: String by project val commonsMathVersion: String by project val junitVersion: String by project val junitExtensionsVersion: String by project val awaitilityVersion: String by project plugins { application // creates a task to run the full application `java-library` id("org.openjfx.javafxplugin") id("edu.sc.seis.macAppBundle") } val jGnashVersion : String = version.toString() application { mainClassName = "jgnash.app.jGnash" } dependencies { testImplementation("org.junit.jupiter:junit-jupiter-api:$junitVersion") testImplementation("org.junit.jupiter:junit-jupiter-params:$junitVersion") testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:$junitVersion") testImplementation("io.github.glytching:junit-extensions:$junitExtensionsVersion") testImplementation("org.awaitility:awaitility:$awaitilityVersion") implementation(project(":jgnash-resources")) implementation(project(":jgnash-core")) implementation(project(":jgnash-convert")) implementation(project(":jgnash-report-core")) implementation(project(":jgnash-plugin")) implementation("info.picocli:picocli:$picocliVersion") implementation("org.apache.commons:commons-lang3:$commonsLangVersion") implementation("org.apache.commons:commons-math3:$commonsMathVersion") // Hack to include all javafx platforms in the classpath // The platform specific libraries are excluded when the distribution is assembled implementation("org.openjfx:javafx-base:$javaFXVersion") implementation("org.openjfx:javafx-fxml:$javaFXVersion") implementation("org.openjfx:javafx-controls:$javaFXVersion") implementation("org.openjfx:javafx-graphics:$javaFXVersion") implementation("org.openjfx:javafx-media:$javaFXVersion") implementation("org.openjfx:javafx-swing:$javaFXVersion") implementation("org.openjfx:javafx-web:$javaFXVersion") runtimeOnly("org.openjfx:javafx-base:$javaFXVersion:linux") runtimeOnly("org.openjfx:javafx-fxml:$javaFXVersion:linux") runtimeOnly("org.openjfx:javafx-controls:$javaFXVersion:linux") runtimeOnly("org.openjfx:javafx-graphics:$javaFXVersion:linux") runtimeOnly("org.openjfx:javafx-media:$javaFXVersion:linux") runtimeOnly("org.openjfx:javafx-swing:$javaFXVersion:linux") runtimeOnly("org.openjfx:javafx-web:$javaFXVersion:linux") runtimeOnly("org.openjfx:javafx-base:$javaFXVersion:win") runtimeOnly("org.openjfx:javafx-fxml:$javaFXVersion:win") runtimeOnly("org.openjfx:javafx-controls:$javaFXVersion:win") runtimeOnly("org.openjfx:javafx-graphics:$javaFXVersion:win") runtimeOnly("org.openjfx:javafx-media:$javaFXVersion:win") runtimeOnly("org.openjfx:javafx-swing:$javaFXVersion:win") runtimeOnly("org.openjfx:javafx-web:$javaFXVersion:win") runtimeOnly("org.openjfx:javafx-base:$javaFXVersion:mac") runtimeOnly("org.openjfx:javafx-fxml:$javaFXVersion:mac") runtimeOnly("org.openjfx:javafx-controls:$javaFXVersion:mac") runtimeOnly("org.openjfx:javafx-graphics:$javaFXVersion:mac") runtimeOnly("org.openjfx:javafx-media:$javaFXVersion:mac") runtimeOnly("org.openjfx:javafx-swing:$javaFXVersion:mac") runtimeOnly("org.openjfx:javafx-web:$javaFXVersion:mac") // end hack // required of Unit testing JavaFX testImplementation("org.testfx:testfx-junit5:$testFxVersion") testImplementation("org.testfx:openjfx-monocle:$monocleVersion") } javafx { version = javaFXVersion modules("javafx.base", "javafx.controls", "javafx.fxml", "javafx.web", "javafx.swing", "javafx.graphics", "javafx.media") } tasks.test { useJUnitPlatform() // we want display the following test events testLogging { events("PASSED", "STARTED", "FAILED", "SKIPPED") showStandardStreams = true } } tasks.startScripts { applicationName = "bootloader" } tasks.distZip { destinationDirectory.set(file(rootDir)) // this "should" work according to Gradle Doc but mangles the content of the zip file //archiveFileName.set("jgnash-${archiveVersion.get()}-bin.${archiveExtension.get()}") // build the mt940 plugin prior to creating the zip file without creating a circular loop dependsOn(":mt940:jar") // add the mt940 plugin into("jGnash-${archiveVersion.get()}") { from("../mt940/build/libs") include("*") into("jGnash-${archiveVersion.get()}/plugins") } into("jGnash-${archiveVersion.get()}") { from(".") include("scripts/*") } doLast { // delete the old renamed build file("${destinationDirectory.get()}/jgnash-${archiveVersion.get()}-bin.${archiveExtension.get()}").delete() file("${destinationDirectory.get()}/${archiveFileName.get()}").renameTo(file("${destinationDirectory.get()}/jgnash-${archiveVersion.get()}-bin.${archiveExtension.get()}")) } } distributions { main { distributionBaseName.set("jGnash") contents { from("../jgnash-manual/src/Manual.pdf") from("../changelog.adoc") from("../rust-launcher/target/release/jGnash.exe") from("../README.html") from("../README.adoc") from("../jGnash") exclude("**/*-linux*") // excludes linux specific JavaFx modules from cross platform zip exclude("**/*-win*") // excludes windows specific JavaFx modules from cross platform zip exclude("**/*-mac*") // excludes mac specific JavaFx modules from cross platform zip } } } macAppBundle { appStyle = "universalJavaApplicationStub" appName = "jGnash-$jGnashVersion" mainClassName = "jgnash.app.jGnash" icon = "../deployfx/gnome-money.icns" javaProperties["apple.laf.useScreenMenuBar"] = "true" } /** * Returns a proper Class-Path entry for the manifest file * @return classpath relative to the installation root point to the jars in the lib directory */ fun generateManifestClassPath(): String { val path = StringBuilder() configurations.runtimeClasspath.get().files.forEach { path.append("lib/") path.append(it.name) path.append(" ") } return path.toString() } tasks.jar { // Keep jar clean: exclude("META-INF/*.SF", "META-INF/*.DSA", "META-INF/*.RSA", "META-INF/*.MF") manifest { attributes(mapOf("Main-Class" to "jGnash", "Class-Path" to generateManifestClassPath())) } } tasks.register("macDist") { description = "Creates a Mac compatible .app distribution directory" dependsOn("createApp", "distZip") doLast { configurations.runtimeClasspath.get().files.forEach { // copy all files in the class path, but ignore windows and linux specific files if (!it.name.contains("linux.jar") && !it.name.contains("win.jar")) { it.copyTo(file("$buildDir/macApp/jGnash-$jGnashVersion.app/Contents/Java/" + it.name), true) } } } } tasks.register("macDistZip") { description = "Creates a Mac compatible archive of the .app distribution directory" dependsOn("clean", "macDist") archiveFileName.set("jGnash-$jGnashVersion.App.zip") destinationDirectory.set(rootDir) from("$buildDir/macApp") from("../jgnash-manual/src/Manual.pdf") { into("jGnash-$jGnashVersion.app/Contents/SharedSupport") } from("../changelog.adoc") { into("jGnash-$jGnashVersion.app/Contents/SharedSupport") } from("../README.adoc") { into("jGnash-$jGnashVersion.app/Contents/SharedSupport") } from("../README.html") { into("jGnash-$jGnashVersion.app/Contents/SharedSupport") } } ================================================ FILE: jgnash-fx/scripts/clean-security-history.js ================================================ // clears security history nodes that fall on weekends var LocalDate = Packages.java.time.LocalDate; var DayOfWeek = Packages.java.time.DayOfWeek; var EngineFactory = Packages.jgnash.engine.EngineFactory; function debug(message) { // helper function to print messages to the console Java.type("java.lang.System").out["println(String)"](message); } var Console = Java.type("jgnash.uifx.views.main.ConsoleDialogController"); //show the console dialog to see the debug information Console.show(); // this is how to get the default Engine instance var engine = EngineFactory.getEngine(EngineFactory.DEFAULT); if (engine !== null) { // List var securities = engine.getSecurities(); for (var i = 0; i < securities.size(); i++) { var security = securities.get(i); debug(security.getSymbol()); var securityHistory = securities.get(i).getHistoryNodes(); for (var j = 0; j < securityHistory.size(); j++) { var historyNode = securityHistory.get(j); var dayOfWeek = historyNode.getLocalDate().getDayOfWeek(); if (dayOfWeek === DayOfWeek.SATURDAY || dayOfWeek === DayOfWeek.SUNDAY) { debug("removing one"); engine.removeSecurityHistory(security, historyNode.getLocalDate()); } } } } debug("finished"); ================================================ FILE: jgnash-fx/scripts/helloworld.js ================================================ function debug(message) { // helper function to print messages to the console Java.type("java.lang.System").out["println(String)"](message); } var Console = Java.type("jgnash.uifx.views.main.ConsoleDialogController"); //show the console dialog to see the debug information Console.show(); debug("Hello World!"); ================================================ FILE: jgnash-fx/src/main/java/jgnash/app/jGnash.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.app; import jgnash.bootloader.BootLoader; import jgnash.bootloader.BootLoaderDialog; import javax.swing.JOptionPane; import java.awt.EventQueue; import picocli.CommandLine; import static jgnash.util.LogUtil.logSevere; /** * Bootstraps a modular jGnashFx by downloading platform specific OpenJFX libraries and then launching the application * * @author Craig Cavanaugh */ public class jGnash { public static void main(final String[] args) { boolean bypassBootLoader = false; final CommandLine commandLine = new CommandLine(new jGnashFx.CommandLineOptions()); commandLine.setToggleBooleanFlags(false); commandLine.setUsageHelpWidth(80); try { final CommandLine.ParseResult pr = commandLine.parseArgs(args); final jGnashFx.CommandLineOptions options = commandLine.getCommand(); if (CommandLine.printHelpIfRequested(pr)) { System.exit(0); } bypassBootLoader = options.bypassBootloader; } catch (final CommandLine.UnmatchedArgumentException uae) { commandLine.usage(System.err, CommandLine.Help.Ansi.AUTO); System.exit(1); } catch (final Exception e) { logSevere(jGnash.class, e); System.exit(1); } if (BootLoader.getOS() != null) { if (bypassBootLoader || BootLoader.doFilesExist()) { launch(args); } else { EventQueue.invokeLater(() -> { final BootLoaderDialog d = new BootLoaderDialog(); d.setVisible(true); final Thread thread = new Thread(() -> { if (!BootLoader.downloadFiles(d.getActiveFileConsumer(), d.getPercentCompleteConsumer())) { System.exit(-1); } }); thread.start(); }); } } else { JOptionPane.showMessageDialog(null, "Unsupported operating system", "Error", JOptionPane.ERROR_MESSAGE); System.exit(BootLoader.FAILED_EXIT); } } private static void launch(final String[] args) { jGnashFx.main(args); } } ================================================ FILE: jgnash-fx/src/main/java/jgnash/app/jGnashFx.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.app; import java.io.File; import java.io.FileNotFoundException; import java.net.Authenticator; import java.util.Arrays; import java.util.logging.Handler; import java.util.logging.Level; import java.util.logging.Logger; import java.util.prefs.Preferences; import javax.swing.JOptionPane; import javafx.application.Application; import javafx.stage.Stage; import jgnash.engine.Engine; import jgnash.engine.EngineFactory; import jgnash.engine.jpa.JpaNetworkServer; import jgnash.engine.message.MessageBus; import jgnash.resource.util.OS; import jgnash.resource.util.ResourceUtils; import jgnash.resource.util.Version; import jgnash.uifx.StaticUIMethods; import jgnash.uifx.net.NetworkAuthenticator; import jgnash.uifx.views.main.MainView; import jgnash.util.FileUtils; import jgnash.util.LogUtil; import jgnash.util.prefs.PortablePreferences; import picocli.CommandLine; import static picocli.CommandLine.Command; import static picocli.CommandLine.Help; import static picocli.CommandLine.IVersionProvider; import static picocli.CommandLine.Option; import static picocli.CommandLine.ParseResult; import static jgnash.util.LogUtil.logSevere; /** * Main entry point for the application. *

* This bootstraps the JavaFX application and lives in the default class as a workaround for * Gnome and OSX menu naming issues. * * @author Craig Cavanaugh */ public class jGnashFx extends Application { private static File dataFile = null; private static File serverFile = null; private static char[] password = EngineFactory.EMPTY_PASSWORD; private static int port = JpaNetworkServer.DEFAULT_PORT; private static String hostName = null; public static void main(final String[] args) { if (OS.getJavaVersion() < 11f) { System.err.println(ResourceUtils.getString("Message.JVM11")); System.err.println(ResourceUtils.getString("Message.Version") + " " + System.getProperty("java.version") + "\n"); // show a swing based dialog JOptionPane.showMessageDialog(null, ResourceUtils.getString("Message.JVM11"), ResourceUtils.getString("Title.Error"), JOptionPane.ERROR_MESSAGE); return; } // Register the default exception handler Thread.setDefaultUncaughtExceptionHandler(new StaticUIMethods.ExceptionHandler()); final CommandLine commandLine = new CommandLine(new CommandLineOptions()); commandLine.setToggleBooleanFlags(false); commandLine.setUsageHelpWidth(80); try { final ParseResult pr = commandLine.parseArgs(args); final CommandLineOptions options = commandLine.getCommand(); if (CommandLine.printHelpIfRequested(pr)) { System.exit(0); } // check for bad server file and hostName combination... can't do both if (serverFile != null && options.hostName != null) { commandLine.usage(System.err, Help.Ansi.AUTO); System.exit(1); } configureLogging(); if (options.uninstall) { PortablePreferences.deleteUserPreferences(); System.exit(0); } //System.getProperties().put(EncryptionManager.ENCRYPTION_FLAG, Boolean.toString(options.ssl)); //System.getProperties().put("ssl", Boolean.toString(options.ssl)); port = options.port; hostName = options.hostName; password = options.password; if (options.shutdown) { if (hostName == null) { hostName = EngineFactory.LOCALHOST; } MessageBus.getInstance().shutDownRemoteServer(hostName, port + 1, password); System.exit(0); } if (options.verbose) { System.setProperty("javafx.verbose", "true"); } if (options.portableFile != null) { PortablePreferences.initPortablePreferences(options.portableFile.getAbsolutePath()); } else if (options.portable) { PortablePreferences.initPortablePreferences(null); } if (options.zeroArgFile != null && options.zeroArgFile.exists()) { jGnashFx.dataFile = options.zeroArgFile; } if (options.dataFile != null && options.dataFile.exists()) { jGnashFx.dataFile = options.dataFile; } if (options.serverFile != null && options.serverFile.exists()) { jGnashFx.serverFile = options.serverFile; startServer(); } else { setupNetworking(); launch(args); } } catch (final Exception e) { logSevere(jGnashFx.class, e); commandLine.usage(System.err, Help.Ansi.AUTO); System.exit(1); } } @Override public void start(final Stage primaryStage) throws Exception { final MainView mainView = new MainView(); mainView.start(primaryStage, dataFile, password, hostName, port); if (password != null) { Arrays.fill(password, (char) 0); // clear the password to protect against malicious code } } @Override public void stop() { System.exit(0); // Platform.exit() is not always enough for a complete shutdown, force closure } private static void startServer() { try { if (!FileUtils.isFileLocked(serverFile.getAbsolutePath())) { JpaNetworkServer networkServer = new JpaNetworkServer(); networkServer.startServer(serverFile.getAbsolutePath(), port, password); Arrays.fill(password, (char) 0); // clear the password to protect against malicious code } else { System.err.println(ResourceUtils.getString("Message.FileIsLocked")); } } catch (final FileNotFoundException e) { logSevere(jGnashFx.class, e); System.err.println("File " + serverFile.getAbsolutePath() + " was not found"); } catch (final Exception e) { logSevere(jGnashFx.class, e); } } private static void setupNetworking() { final Preferences auth = Preferences.userRoot().node(NetworkAuthenticator.NODEHTTP); if (auth.getBoolean(NetworkAuthenticator.USEPROXY, false)) { final String proxyHost = auth.get(NetworkAuthenticator.PROXYHOST, ""); final String proxyPort = auth.get(NetworkAuthenticator.PROXYPORT, ""); System.getProperties().put("http.proxyHost", proxyHost); System.getProperties().put("http.proxyPort", proxyPort); // this will deal with any authentication requests properly Authenticator.setDefault(new NetworkAuthenticator()); System.out.println(ResourceUtils.getString("Message.Proxy") + proxyHost + ":" + proxyPort); } } private static void configureLogging() { LogUtil.configureLogging(); final Handler[] handlers = Logger.getLogger("").getHandlers(); for (final Handler handler : handlers) { handler.setLevel(Level.ALL); } Engine.getLogger().setLevel(Level.ALL); } @SuppressWarnings({"CanBeFinal", "FieldMayBeFinal"}) @Command(mixinStandardHelpOptions = true, versionProvider = CommandLineOptions.VersionProvider.class, name = "jGnash", separator = " ", sortOptions = false) public static class CommandLineOptions { private static final String FILE_OPTION_SHORT = "-f"; private static final String FILE_OPTION_LONG = "--file"; private static final String VERBOSE_OPTION_SHORT = "-v"; private static final String VERBOSE_OPTION_LONG = "--verbose"; private static final String PORTABLE_FILE_OPTION = "--portableFile"; private static final String PORTABLE_OPTION_SHORT = "-p"; private static final String PORTABLE_OPTION_LONG = "--portable"; private static final String UNINSTALL_OPTION_SHORT = "-u"; private static final String UNINSTALL_OPTION_LONG = "--uninstall"; private static final String PORT_OPTION = "--port"; private static final String HOST_OPTION = "--hostName"; private static final String PASSWORD_OPTION = "--password"; private static final String SERVER_OPTION = "--server"; private static final String SHUTDOWN_OPTION = "--shutdown"; private static final String BYPASS_BOOTLOADER = "--bypassBootloader"; //private static final String SSL_OPTION = "--ssl"; @CommandLine.Parameters(index = "0", arity = "0") private File zeroArgFile = null; @Option(names = {FILE_OPTION_SHORT, FILE_OPTION_LONG}, paramLabel = "", description = "File to load at start") private File dataFile = null; @Option(names = {PASSWORD_OPTION}, description = "Password for a local File, server or client") private char[] password = EngineFactory.EMPTY_PASSWORD; // must not be null @Option(names = {PORTABLE_OPTION_SHORT, PORTABLE_OPTION_LONG}, description = "Enable portable preferences") private boolean portable = false; @Option(names = {PORTABLE_FILE_OPTION}, paramLabel = "", description = "Enable portable preferences and specify the file") private File portableFile = null; @Option(names = {HOST_OPTION}, description = "Server host name or address") private String hostName = null; @Option(names = {PORT_OPTION}, description = "Network port server is running on (default: " + JpaNetworkServer.DEFAULT_PORT + ")") private int port = JpaNetworkServer.DEFAULT_PORT; //@Option(names = {SSL_OPTION}, description = "Enable SSL for network communication") //private boolean ssl = false; @Option(names = {SERVER_OPTION}, paramLabel = "", description = "Runs as a server using the specified file") private File serverFile = null; @Option(names = {SHUTDOWN_OPTION}, description = "Issues a shutdown request to a server") private boolean shutdown = false; @Option(names = {UNINSTALL_OPTION_SHORT, UNINSTALL_OPTION_LONG}, description = "Remove registry settings (uninstall)") private boolean uninstall = false; @Option(names = {VERBOSE_OPTION_SHORT, VERBOSE_OPTION_LONG}, description = "Enable verbose application messages") private boolean verbose = false; @CommandLine.Option(names = {BYPASS_BOOTLOADER}, description = "Bypasses the bootloader and requires manual installation of OS specific files") public boolean bypassBootloader = false; static class VersionProvider implements IVersionProvider { @Override public String[] getVersion() { return new String[] {Version.getAppName() + " " + Version.getAppVersion()}; } } } } ================================================ FILE: jgnash-fx/src/main/java/jgnash/bootloader/BootLoader.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.bootloader; import jgnash.util.NotNull; import javax.swing.JOptionPane; import javax.xml.bind.DatatypeConverter; import java.io.BufferedInputStream; import java.io.BufferedOutputStream; import java.io.BufferedReader; import java.io.File; import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; import java.net.HttpURLConnection; import java.net.MalformedURLException; import java.net.URISyntaxException; import java.net.URL; import java.nio.channels.Channels; import java.nio.channels.ReadableByteChannel; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.security.MessageDigest; import java.text.MessageFormat; import java.util.Arrays; import java.util.function.Consumer; import java.util.function.IntConsumer; import java.util.function.LongConsumer; import java.util.logging.Logger; /** * Boot loader used to download platform specific OpenJFX files and place them in the LIB directory. * * @author Craig Cavanaugh */ public class BootLoader { private static final String JFX_VERSION = "15.0.1"; private static final String MAVEN_REPO = "https://repo1.maven.org/maven2/org/openjfx/"; private static final String FILE_PATTERN = "javafx-{0}-{1}-{2}.jar"; private static final String SEPARATOR = System.getProperty("file.separator"); private static final String OS = getOS(); public static final int FAILED_EXIT = -1; static final int REBOOT_EXIT = 100; private static final int BUFFER_SIZE = 4096; // download them all private static final String[] JARS = new String[]{"base", "controls", "fxml", "graphics", "media", "swing", "web"}; public static boolean doFilesExist() { String libPath = getLibPath(); boolean result = true; final String pathSpec = libPath + SEPARATOR + FILE_PATTERN; for (final String fxJar : JARS) { Path path = Paths.get(MessageFormat.format(pathSpec, fxJar, JFX_VERSION, OS)); if (!Files.exists(path)) { result = false; break; } } return result; } private static String getLibPath() { // Current class lives in a .jar that lives in the lib folder we want to populate. Let's // use that to find the lib path rather than depend on jGnash being invoked from the correct // folder. // https://stackoverflow.com/questions/320542/how-to-get-the-path-of-a-running-jar-file?noredirect=1&lq=1 try { return new File(BootLoader.class.getProtectionDomain().getCodeSource().getLocation().toURI()) .getParentFile().getPath(); } catch (final URISyntaxException e) { throw new RuntimeException("Unable to determine lib path fpr bootloader"); } } public static String getOS() { final String os = System.getProperty("os.name"); if (os.startsWith("Linux")) { return "linux"; } else if (os.startsWith("Windows")) { return "win"; } else if (os.startsWith("Darwin") || os.startsWith("Mac")) { return "mac"; } return null; } public static boolean downloadFiles(final Consumer fileNameConsumer, final IntConsumer percentCompleteConsumer) { percentCompleteConsumer.accept(0); boolean result = true; final Path lib = Paths.get(BootLoader.getLibPath()); try { final long completeDownloadSize = getTotalDownloadSize(); Files.createDirectories(lib); // create the directory if needed final String spec = MAVEN_REPO + "javafx-{0}/{1}/" + FILE_PATTERN; final String pathSpec = lib + SEPARATOR + FILE_PATTERN; final LongConsumer countConsumer = new LongConsumer() { long totalCounts; @Override public void accept(final long value) { totalCounts += value; percentCompleteConsumer.accept((int) (((double) totalCounts / (double) completeDownloadSize) * 100)); } }; for (final String fxJar : JARS) { URL url = new URL(MessageFormat.format(spec, fxJar, JFX_VERSION, OS)); Path path = Paths.get(MessageFormat.format(pathSpec, fxJar, JFX_VERSION, OS)); if (!Files.exists(path)) { fileNameConsumer.accept(url.toExternalForm()); try { boolean downloadResult = downloadFile(url, path, countConsumer); if (!downloadResult) { result = false; break; } } catch (final IOException e) { e.printStackTrace(); showException(e); } } } } catch (final IOException e) { e.printStackTrace(); result = false; } return result; } private static long getTotalDownloadSize() throws MalformedURLException { long size = 0; for (String fxJar : JARS) { final String spec = MAVEN_REPO + "javafx-{0}/{1}/" + FILE_PATTERN; URL url = new URL(MessageFormat.format(spec, fxJar, JFX_VERSION, OS)); size += getFileDownloadSize(url); } return size; } private static long getFileDownloadSize(final URL source) { long length = 0; try { final HttpURLConnection httpConnection = (HttpURLConnection) (source.openConnection()); length = httpConnection.getContentLength(); httpConnection.disconnect(); } catch (final IOException e) { e.printStackTrace(); } return length; } private static void showException(final Exception exception) { final String message = exception.getMessage() + "\nStackTrace: " + Arrays.toString(exception.getStackTrace()); final String title = exception.getClass().getName(); JOptionPane.showMessageDialog(null, message, title, JOptionPane.ERROR_MESSAGE); } private static boolean downloadFile(final URL source, final Path dest, final LongConsumer countConsumer) throws IOException { final Logger logger = Logger.getLogger(BootLoader.class.getName()); boolean result = true; logger.info("Downloading " + source.toExternalForm() + " to " + dest.toString()); String md5 = ""; HttpURLConnection httpConnection = (HttpURLConnection) (source.openConnection()); try (final BufferedInputStream in = new BufferedInputStream(httpConnection.getInputStream()); final BufferedOutputStream bout = new BufferedOutputStream(new CountingFileOutputStream(dest.toString(), countConsumer), BUFFER_SIZE)) { byte[] data = new byte[BUFFER_SIZE]; int x; while ((x = in.read(data, 0, BUFFER_SIZE)) >= 0) { bout.write(data, 0, x); } bout.close(); // hash the file final MessageDigest md = MessageDigest.getInstance("MD5"); md.update(Files.readAllBytes(dest)); md5 = DatatypeConverter.printHexBinary(md.digest()).toLowerCase(); } catch (final Exception e) { e.printStackTrace(); showException(e); result = false; } final URL md5Source = new URL(source.toExternalForm() + ".md5"); final Path md5Dest = Files.createTempFile("", ".md5"); try (final ReadableByteChannel readableByteChannel = Channels.newChannel(md5Source.openStream()); final FileOutputStream fileOutputStream = new FileOutputStream(md5Dest.toString())) { fileOutputStream.getChannel().transferFrom(readableByteChannel, 0, Long.MAX_VALUE); } catch (final Exception e) { e.printStackTrace(); showException(e); } try (final BufferedReader reader = Files.newBufferedReader(md5Dest)) { final String hash = reader.readLine(); if (hash.compareTo(md5) != 0) { Files.delete(dest); logger.severe("Download of " + dest + " was corrupt; removing the file"); result = false; } Files.delete(md5Dest); } catch (final Exception e) { e.printStackTrace(); showException(e); } return result; } /** * Updates a supplied consumer with the number of bytes written */ private static class CountingFileOutputStream extends FileOutputStream { final LongConsumer countConsumer; CountingFileOutputStream(final String name, @NotNull final LongConsumer consumer) throws FileNotFoundException { super(name); this.countConsumer = consumer; } @Override public void write(final int idx) throws IOException { super.write(idx); countConsumer.accept(1); } @Override public void write(final byte[] b) throws IOException { super.write(b); countConsumer.accept(b.length); } @Override public void write(final byte[] b, final int off, final int len) throws IOException { super.write(b, off, len); countConsumer.accept(len); } } } ================================================ FILE: jgnash-fx/src/main/java/jgnash/bootloader/BootLoaderDialog.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.bootloader; import java.awt.EventQueue; import java.awt.GridBagConstraints; import java.awt.GridBagLayout; import java.awt.Insets; import java.util.Objects; import java.util.function.Consumer; import java.util.function.IntConsumer; import javax.swing.ImageIcon; import javax.swing.JButton; import javax.swing.JFrame; import javax.swing.JLabel; import javax.swing.JProgressBar; import javax.swing.JScrollPane; import javax.swing.JTextArea; import jgnash.resource.util.ResourceUtils; /** * UI for displaying the boot loader download status. * * @author Craig Cavanaugh */ public class BootLoaderDialog extends JFrame { private JLabel activeDownloadLabel; private JProgressBar progressBar; private final Consumer activeFileConsumer; private final IntConsumer percentCompleteConsumer; public BootLoaderDialog() { ImageIcon icon = new ImageIcon(Objects.requireNonNull( BootLoaderDialog.class.getResource("/jgnash/resource/gnome-money.png"))); setIconImage(icon.getImage()); this.activeFileConsumer = s -> EventQueue.invokeLater(() -> activeDownloadLabel.setText(s)); this.percentCompleteConsumer = value -> EventQueue.invokeLater(() -> { progressBar.setValue(value); if (value >= 100) { final Thread thread = new Thread(() -> { try { Thread.sleep(3000); System.exit(BootLoader.REBOOT_EXIT); } catch (InterruptedException e) { e.printStackTrace(); } }); thread.start(); } }); initComponents(); } public Consumer getActiveFileConsumer() { return activeFileConsumer; } public IntConsumer getPercentCompleteConsumer() { return percentCompleteConsumer; } private void initComponents() { GridBagConstraints gridBagConstraints; JScrollPane jScrollPane = new JScrollPane(); JTextArea messageArea = new JTextArea(); progressBar = new JProgressBar(); progressBar.setMinimum(0); progressBar.setMaximum(100); progressBar.setStringPainted(true); JButton cancelButton = new JButton(ResourceUtils.getString("Button.Cancel")); cancelButton.addActionListener(evt -> cancelButtonActionPerformed()); activeDownloadLabel = new JLabel(); activeDownloadLabel.setFocusable(false); setDefaultCloseOperation(javax.swing.WindowConstants.EXIT_ON_CLOSE); setTitle("Please Wait"); setAlwaysOnTop(true); setMinimumSize(new java.awt.Dimension(430, 220)); getContentPane().setLayout(new GridBagLayout()); jScrollPane.setBorder(null); messageArea.setEditable(false); messageArea.setColumns(20); messageArea.setRows(3); messageArea.setText(ResourceUtils.getString("Message.OpenJfxDownload")); messageArea.setWrapStyleWord(true); messageArea.setFocusable(false); messageArea.setOpaque(false); messageArea.setRequestFocusEnabled(false); jScrollPane.setViewportView(messageArea); gridBagConstraints = new GridBagConstraints(); gridBagConstraints.gridx = 0; gridBagConstraints.gridy = 0; gridBagConstraints.fill = GridBagConstraints.BOTH; gridBagConstraints.weightx = 1.0; gridBagConstraints.weighty = 1.0; gridBagConstraints.insets = new Insets(12, 12, 12, 12); getContentPane().add(jScrollPane, gridBagConstraints); gridBagConstraints = new GridBagConstraints(); gridBagConstraints.gridx = 0; gridBagConstraints.gridy = 2; gridBagConstraints.fill = GridBagConstraints.HORIZONTAL; gridBagConstraints.ipadx = 677; gridBagConstraints.insets = new Insets(6, 12, 12, 12); getContentPane().add(progressBar, gridBagConstraints); gridBagConstraints = new GridBagConstraints(); gridBagConstraints.gridx = 0; gridBagConstraints.gridy = 3; gridBagConstraints.anchor = GridBagConstraints.EAST; gridBagConstraints.insets = new java.awt.Insets(12, 12, 12, 12); getContentPane().add(cancelButton, gridBagConstraints); gridBagConstraints = new GridBagConstraints(); gridBagConstraints.gridx = 0; gridBagConstraints.gridy = 1; gridBagConstraints.anchor = GridBagConstraints.NORTHWEST; gridBagConstraints.insets = new Insets(12, 12, 6, 12); getContentPane().add(activeDownloadLabel, gridBagConstraints); pack(); setLocationRelativeTo(null); } private void cancelButtonActionPerformed() { System.exit(progressBar.getValue() == 100 ? BootLoader.REBOOT_EXIT : -1); } } ================================================ FILE: jgnash-fx/src/main/java/jgnash/uifx/Options.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.uifx; import java.util.function.Consumer; import java.util.prefs.Preferences; import javafx.beans.property.BooleanProperty; import javafx.beans.property.IntegerProperty; import javafx.beans.property.SimpleBooleanProperty; import javafx.beans.property.SimpleIntegerProperty; import javafx.beans.property.SimpleStringProperty; import javafx.beans.property.StringProperty; import javafx.beans.value.ChangeListener; import javafx.scene.control.ButtonBar; import jgnash.text.NumericFormats; import jgnash.time.DateUtils; import jgnash.uifx.control.TimePeriodComboBox; /** * Manages application preferences. * * @author Craig Cavanaugh */ @SuppressWarnings("SameParameterValue") public class Options { private static final Preferences p = Preferences.userNodeForPackage(Options.class); private static final String AUTO_PACK_TABLE = "autoPackTables"; private static final String CONFIRM_DELETE_REMINDER = "confirmDeleteReminder"; private static final String CONFIRM_DELETE_TRANSACTION = "confirmDeleteTransaction"; private static final String ACCOUNTING_TERMS = "useAccountingTerms"; private static final String REMEMBER_DATE = "rememberDate"; private static final String AUTO_COMPLETE = "autoCompleteEnabled"; private static final String CASE_SENSITIVE = "autoCompleteIsCaseEnabled"; private static final String CHECK_UPDATES = "checkForUpdates"; private static final String CONCATENATE_MEMOS = "concatenateMemos"; private static final String FUZZY_MATCH = "autoCompleteFuzzyMatchEnabled"; private static final String REMINDER_SNOOZE = "reminderSnoozePeriod"; private static final String OPEN_LAST = "openLastEnabled"; private static final String SELECT_ON_FOCUS = "selectOnFocus"; private static final String BUTTON_ORDER = "buttonOrder"; private static final String ANIMATIONS_ENABLED = "animationsEnabled"; private static final String RESTORE_LAST_TAB = "restoreLastTab"; private static final String REGEX_FOR_FILTERS = "regexForFilters"; private static final String GLOBAL_BAYES_ENABLED = "globalBayesEnabled"; private static final String LAST_FORMAT_CHANGE = "lastFormatChange"; private static final String RESTORE_REPORT_DATES = "restoreReportDates"; private static final int DEFAULT_SNOOZE = TimePeriodComboBox.getPeriods()[0]; private static final SimpleBooleanProperty useAccountingTerms; private static final SimpleBooleanProperty checkForUpdates; private static final SimpleBooleanProperty confirmDeleteTransaction; private static final SimpleBooleanProperty confirmDeleteReminder; private static final SimpleBooleanProperty rememberDate; private static final SimpleBooleanProperty autoPackTablesEnabled; private static final SimpleBooleanProperty autoCompleteEnabled; private static final SimpleBooleanProperty autoCompleteCaseSensitiveEnabled; private static final SimpleBooleanProperty autoCompleteFuzzyMatchEnabled; private static final SimpleBooleanProperty selectOnFocusEnabled; private static final SimpleBooleanProperty openLastEnabled; private static final SimpleBooleanProperty animationsEnabled; private static final SimpleBooleanProperty restoreLastRegisterTab; private static final SimpleBooleanProperty concatenateMemos; private static final SimpleBooleanProperty regexForFilters; private static final SimpleBooleanProperty globalBayesEnabled; private static final SimpleBooleanProperty restoreReportDates; private static final SimpleIntegerProperty reminderSnoozePeriod; private static final SimpleStringProperty buttonOrder; private static final SimpleStringProperty fullNumericFormat; private static final SimpleStringProperty shortNumericFormat; private static final SimpleStringProperty shortDateFormat; private static final ChangeListener booleanChangeListener; private static final ChangeListener integerChangeListener; static { booleanChangeListener = (observable, oldValue, newValue) -> p.putBoolean(((SimpleBooleanProperty)observable).getName(), newValue); integerChangeListener = (observable, oldValue, newValue) -> p.putInt(((SimpleIntegerProperty) observable).getName(), (Integer) newValue); useAccountingTerms = createBooleanProperty(ACCOUNTING_TERMS, false); checkForUpdates = createBooleanProperty(CHECK_UPDATES, true); confirmDeleteTransaction = createBooleanProperty(CONFIRM_DELETE_TRANSACTION, true); confirmDeleteReminder = createBooleanProperty(CONFIRM_DELETE_REMINDER, true); rememberDate = createBooleanProperty(REMEMBER_DATE, true); autoCompleteEnabled = createBooleanProperty(AUTO_COMPLETE, true); autoCompleteCaseSensitiveEnabled = createBooleanProperty(CASE_SENSITIVE, false); autoCompleteFuzzyMatchEnabled = createBooleanProperty(FUZZY_MATCH, false); autoPackTablesEnabled = createBooleanProperty(AUTO_PACK_TABLE, true); openLastEnabled = createBooleanProperty(OPEN_LAST, false); selectOnFocusEnabled = createBooleanProperty(SELECT_ON_FOCUS, false); concatenateMemos = createBooleanProperty(CONCATENATE_MEMOS, false); animationsEnabled = createBooleanProperty(ANIMATIONS_ENABLED, true); restoreLastRegisterTab = createBooleanProperty(RESTORE_LAST_TAB, true); regexForFilters = createBooleanProperty(REGEX_FOR_FILTERS, false); globalBayesEnabled = createBooleanProperty(GLOBAL_BAYES_ENABLED, false); restoreReportDates = createBooleanProperty(RESTORE_REPORT_DATES, false); reminderSnoozePeriod = createIntegerProperty(REMINDER_SNOOZE, DEFAULT_SNOOZE); /* Zero value caused by a prior bug */ if (Options.reminderSnoozePeriodProperty().get() <= 0) { Options.reminderSnoozePeriodProperty().setValue(DEFAULT_SNOOZE); } shortNumericFormat = createStringProperty(NumericFormats.getShortFormatPattern(), pattern -> { NumericFormats.setShortFormatPattern(pattern); updateLastFormatChange(); }); fullNumericFormat = createStringProperty(NumericFormats.getFullFormatPattern(), pattern -> { NumericFormats.setFullFormatPattern(pattern); updateLastFormatChange(); }); shortDateFormat = createStringProperty(DateUtils.getShortDatePattern(), pattern -> { DateUtils.setShortDateFormatPattern(pattern); updateLastFormatChange(); }); buttonOrder = createStringProperty(new ButtonBar().getButtonOrder(), s -> p.put(BUTTON_ORDER, s)); // Initialize with the current value if it's not been set before if (getLastFormatChange() == 0) { updateLastFormatChange(); } } private Options() { // Utility class } private static SimpleBooleanProperty createBooleanProperty(final String name, final boolean defaultValue) { final SimpleBooleanProperty property = new SimpleBooleanProperty(null, name, p.getBoolean(name, defaultValue)); property.addListener(booleanChangeListener); return property; } private static SimpleIntegerProperty createIntegerProperty(final String name, final int defaultValue) { final SimpleIntegerProperty property = new SimpleIntegerProperty(null, name, p.getInt(name, defaultValue)); property.addListener(integerChangeListener); return property; } private static SimpleStringProperty createStringProperty(final String defaultValue, final Consumer stringConsumer) { final SimpleStringProperty property = new SimpleStringProperty(defaultValue); property.addListener((observable, oldValue, newValue) -> stringConsumer.accept(newValue)); return property; } public static StringProperty fullNumericFormatProperty() { return fullNumericFormat; } public static StringProperty shortNumericFormatProperty() { return shortNumericFormat; } public static StringProperty shortDateFormatProperty() { return shortDateFormat; } public static long getLastFormatChange() { return p.getLong(LAST_FORMAT_CHANGE, 0); // default should trigger an update } private static void updateLastFormatChange() { p.putLong(LAST_FORMAT_CHANGE, System.currentTimeMillis()); } /** * Returns transaction deletion confirmation. * * @return true if confirm on transaction delete is enabled, false otherwise */ public static BooleanProperty confirmOnTransactionDeleteProperty() { return confirmDeleteTransaction; } public static BooleanProperty checkForUpdatesProperty() { return checkForUpdates; } /** * Returns reminder deletion confirmation. * * @return true if confirm on reminder delete is enabled, false otherwise */ public static BooleanProperty confirmOnDeleteReminderProperty() { return confirmDeleteReminder; } public static BooleanProperty concatenateMemosProperty() { return concatenateMemos; } public static BooleanProperty useAccountingTermsProperty() { return useAccountingTerms; } public static BooleanProperty globalBayesProperty() { return globalBayesEnabled; } /** * Determines if the last date used for a transaction is reset * to the current date or remembered. * * @return true if the last date should be reused */ public static BooleanProperty rememberLastDateProperty() { return rememberDate; } /** * Determines if the last date used for a report is restored or ignored. * * @return true if the last date should be used */ public static BooleanProperty restoreReportDateProperty() { return restoreReportDates; } /** * Provides access to the enabled state of auto completion. * * @return {@code BooleanProperty} controlling enabled state */ public static BooleanProperty useAutoCompleteProperty() { return autoCompleteEnabled; } /** * Provides access to the case sensitivity of auto completion. * * @return {@code BooleanProperty} controlling case sensitivity */ public static BooleanProperty autoCompleteIsCaseSensitiveProperty() { return autoCompleteCaseSensitiveEnabled; } /** * Provides access to the case sensitivity of auto completion. * * @return {@code BooleanProperty} controlling case sensitivity */ public static BooleanProperty autoPackTablesProperty() { return autoPackTablesEnabled; } /** Provides access to the property controlling if fuzzy match is used for auto completion. * * @return {@code BooleanProperty} controlling fuzzy match */ public static BooleanProperty useFuzzyMatchForAutoCompleteProperty() { return autoCompleteFuzzyMatchEnabled; } /** * Provides access to the property controlling the snooze time between reminder events. * * @return {@code IntegerProperty} controlling snooze time. Period is in milliseconds */ public static IntegerProperty reminderSnoozePeriodProperty() { return reminderSnoozePeriod; } /** Provides access to the property controlling if a text field automatically selects all text when * it receives the focus. * * @return {@code BooleanProperty} controlling fuzzy match */ public static BooleanProperty selectOnFocusProperty() { return selectOnFocusEnabled; } /** * Provides access to the open last file at startup property. * * @return {@code BooleanProperty} controlling open last file */ public static BooleanProperty openLastProperty() { return openLastEnabled; } /** * Provides access to the enabled state for animations. * * @return {@code BooleanProperty} controlling open last file */ public static BooleanProperty animationsEnabledProperty() { return animationsEnabled; } /** * Provides access to the restore last used tab property. * * @return {@code BooleanProperty} controlling open last file */ public static BooleanProperty restoreLastTabProperty() { return restoreLastRegisterTab; } /** * Provides access to the property controlling use of regular expressions when applying filters. * * @return {@code BooleanProperty} controlling use of regex for filters/{@code Predicate} */ public static BooleanProperty regexForFiltersProperty() { return regexForFilters; } public static StringProperty buttonOrderProperty() { return buttonOrder; } } ================================================ FILE: jgnash-fx/src/main/java/jgnash/uifx/StaticUIMethods.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2021 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.uifx; import java.util.Objects; import java.util.Optional; import java.util.logging.Level; import java.util.logging.Logger; import javafx.concurrent.Task; import javafx.scene.control.ButtonType; import javafx.scene.image.Image; import jgnash.uifx.control.Alert; import jgnash.uifx.control.ExceptionDialog; import jgnash.uifx.util.FXMLUtils; import jgnash.uifx.util.JavaFXUtils; import jgnash.uifx.util.StageUtils; import jgnash.uifx.views.main.MainView; import jgnash.uifx.views.main.OpenDatabaseController; import jgnash.util.Nullable; import jgnash.resource.util.ResourceUtils; /** * Various static UI support methods. * * @author Craig Cavanaugh */ public class StaticUIMethods { private static final String APP_ICON = "/jgnash/resource/gnome-money.png"; private static Image applicationImage; private StaticUIMethods() { // Utility class } public static void showOpenDialog() { final FXMLUtils.Pair pair = FXMLUtils.load(OpenDatabaseController.class.getResource("OpenDatabaseForm.fxml"), ResourceUtils.getString("Title.Open")); pair.getStage().setResizable(true); pair.getStage().show(); pair.getStage().setMaxHeight(pair.getStage().getHeight()); StageUtils.addBoundsListener(pair.getStage(), OpenDatabaseController.class, MainView.getPrimaryStage()); } public static void displayError(final String message) { final Alert alert = new Alert(Alert.AlertType.ERROR, message); alert.setTitle(ResourceUtils.getString("Title.Error")); alert.initOwner(MainView.getPrimaryStage()); JavaFXUtils.runLater(alert::showAndWait); } public static void displayMessage(final String message) { final Alert alert = new Alert(Alert.AlertType.INFORMATION, message); alert.setTitle(ResourceUtils.getString("Title.Information")); alert.initOwner(MainView.getPrimaryStage()); JavaFXUtils.runLater(alert::showAndWait); } public static void displayWarning(final String message) { final Alert alert = new Alert(Alert.AlertType.WARNING, message); alert.setTitle(ResourceUtils.getString("Title.Warning")); alert.initOwner(MainView.getPrimaryStage()); JavaFXUtils.runLater(alert::showAndWait); } /** * Displays a Yes and No dialog requesting confirmation. * * @param title Dialog title * @param message Dialog message * @return {@code ButtonBar.ButtonData.YES} or {@code ButtonBar.ButtonData.NO} */ public static ButtonType showConfirmationDialog(final String title, final String message) { final Alert alert = new Alert(Alert.AlertType.YES_NO, message); alert.setTitle(title); alert.initOwner(MainView.getPrimaryStage()); final Optional buttonType = alert.showAndWait(); return buttonType.orElse(ButtonType.NO); } public static void displayException(final Throwable exception) { JavaFXUtils.runLater(() -> { ExceptionDialog exceptionDialog = new ExceptionDialog(exception); exceptionDialog.showAndWait(); }); } public static void displayTaskProgress(final Task task) { MainView.getInstance().setBusy(task); } /** * Returns the primary application icon. * * @return {@code} Image or {@code null} if not found */ @Nullable public static synchronized Image getApplicationIcon() { if (applicationImage == null) { try { applicationImage = new Image( Objects.requireNonNull(StaticUIMethods.class.getResourceAsStream(APP_ICON))); } catch (final Exception ex) { Logger.getLogger(StaticUIMethods.class.getName()).log(Level.WARNING, ex.getLocalizedMessage(), ex); applicationImage = null; } } return applicationImage; } /** * Handler for displaying uncaught exceptions. */ public static class ExceptionHandler implements Thread.UncaughtExceptionHandler { @Override public void uncaughtException(final Thread t, final Throwable throwable) { Logger.getLogger(ExceptionHandler.class.getName()) .log(Level.SEVERE, throwable.getLocalizedMessage(), throwable); displayException(throwable); } } } ================================================ FILE: jgnash-fx/src/main/java/jgnash/uifx/about/AboutDialogController.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.uifx.about; import java.util.Objects; import java.util.Properties; import java.util.ResourceBundle; import java.util.stream.Collectors; import javafx.beans.property.SimpleStringProperty; import javafx.beans.property.StringProperty; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import javafx.fxml.FXML; import javafx.scene.control.ContextMenu; import javafx.scene.control.MenuItem; import javafx.scene.control.SelectionMode; import javafx.scene.control.Tab; import javafx.scene.control.TabPane; import javafx.scene.control.TableColumn; import javafx.scene.control.TableView; import javafx.scene.input.Clipboard; import javafx.scene.input.ClipboardContent; import javafx.scene.web.WebView; import javafx.stage.Stage; import jgnash.resource.util.HTMLResource; import jgnash.resource.util.ResourceUtils; import jgnash.uifx.util.FXMLUtils; import jgnash.uifx.util.FXMLUtils.Pair; import jgnash.uifx.util.JavaFXUtils; import jgnash.uifx.util.TableViewManager; import jgnash.util.NotNull; /** * About Dialog. * * @author Craig Cavanaugh */ public class AboutDialogController { private static final double FONT_SCALE = 0.8333; private static final int MAX_HEIGHT = 490; private static final String PREF_NODE = "/jgnash/uifx/about/AboutDialogController"; private static final double[] PREF_COLUMN_WEIGHTS = {0, 100}; private static final String DEFAULT = "default"; @FXML private ResourceBundle resources; @FXML private TabPane tabbedPane; @SuppressWarnings("FieldCanBeLocal") private TableViewManager tableViewManager; @FXML void initialize() { tabbedPane.getTabs().addAll( addHTMLTab(resources.getString("Tab.About"), "notice.html"), addHTMLTab(resources.getString("Tab.Credits"), "credits.html"), addHTMLTab(resources.getString("Tab.AppLicense"), "jgnash-license.html"), addHTMLTab(resources.getString("Tab.GPLLicense"), "gpl-license.html"), addHTMLTab(resources.getString("Tab.LGPLLicense"), "lgpl.html"), addHTMLTab("Apache License", "apache-license.html"), addHTMLTab("XStream License", "xstream-license.html"), getSystemPropertiesTab()); } private static Tab addHTMLTab(final String name, final String resource) { final WebView webView = new WebView(); webView.setFontScale(FONT_SCALE); webView.setMaxHeight(MAX_HEIGHT); // be paranoid, protect against external scripts webView.getEngine().setJavaScriptEnabled(false); webView.getEngine().load(HTMLResource.getURL(resource).toExternalForm()); return new Tab(name, webView); } private Tab getSystemPropertiesTab() { final ObservableList propertiesList = FXCollections.observableArrayList(); final Properties properties = System.getProperties(); propertiesList.addAll(properties.stringPropertyNames().stream().map(prop -> new SystemProperty(prop, properties.getProperty(prop))).collect(Collectors.toList())); FXCollections.sort(propertiesList); final TableColumn keyCol = new TableColumn<>(resources.getString("Column.PropName")); keyCol.setCellValueFactory(param -> param.getValue().keyProperty()); final TableColumn valueCol = new TableColumn<>(resources.getString("Column.PropVal")); valueCol.setCellValueFactory(param -> param.getValue().valueProperty()); final TableView tableView = new TableView<>(propertiesList); tableView.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY); tableView.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE); final ObservableList>tableViewColumns = tableView.getColumns(); tableViewColumns.add(keyCol); tableViewColumns.add(valueCol); final ContextMenu menu = new ContextMenu(); final MenuItem copyMenuItem = new MenuItem(resources.getString("Menu.Copy.Name")); copyMenuItem.setOnAction(event -> dumpPropertiesToClipboard(tableView)); menu.getItems().add(copyMenuItem); tableView.setContextMenu(menu); tableViewManager = new TableViewManager<>(tableView, PREF_NODE); tableViewManager.setColumnWeightFactory(column -> PREF_COLUMN_WEIGHTS[column]); tableViewManager.setPreferenceKeyFactory(() -> DEFAULT); JavaFXUtils.runLater(tableViewManager::packTable); return new Tab(ResourceUtils.getString("Tab.SysInfo"), tableView); } private static void dumpPropertiesToClipboard(final TableView tableView) { final StringBuilder buffer = new StringBuilder(); tableView.getSelectionModel().getSelectedItems().stream().filter(systemProperty -> systemProperty.keyProperty().get() != null).forEach(systemProperty -> { buffer.append(systemProperty.keyProperty().get()); buffer.append("\t"); if (systemProperty.valueProperty().get() != null) { buffer.append(systemProperty.valueProperty().get()); } buffer.append("\n"); }); final ClipboardContent content = new ClipboardContent(); content.putString(buffer.toString()); Clipboard.getSystemClipboard().setContent(content); } public static void showAndWait() { JavaFXUtils.runLater(() -> { // push to EDT to avoid race when loading the html files final Pair pair = FXMLUtils.load(AboutDialogController.class.getResource("AboutDialog.fxml"), ResourceUtils.getString("Title.About")); pair.getStage().setResizable(false); pair.getStage().show(); }); } @FXML private void handleCloseAction() { ((Stage) tabbedPane.getScene().getWindow()).close(); } private static class SystemProperty implements Comparable { private final StringProperty key; private final StringProperty value; private SystemProperty(final String name, final String value) { this.key = new SimpleStringProperty(name); this.value = new SimpleStringProperty(value); } StringProperty keyProperty() { return key; } StringProperty valueProperty() { return value; } @Override public int compareTo(@NotNull final SystemProperty o) { return keyProperty().get().compareTo(o.keyProperty().get()); } @Override public boolean equals(final Object obj) { if (this == obj) { return true; } if (obj == null || getClass() != obj.getClass()) { return false; } final SystemProperty that = (SystemProperty) obj; return Objects.equals(key, that.key) && Objects.equals(value, that.value); } @Override public int hashCode() { return Objects.hash(key, value); } } } ================================================ FILE: jgnash-fx/src/main/java/jgnash/uifx/actions/DatabasePathAction.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.uifx.actions; import java.io.File; import java.util.ArrayList; import java.util.List; import java.util.ResourceBundle; import java.util.prefs.Preferences; import javafx.stage.FileChooser; import jgnash.engine.DataStoreType; import jgnash.uifx.views.main.MainView; import jgnash.resource.util.ResourceUtils; /** * Utility class request database path from the user. * * @author Craig Cavanaugh */ public class DatabasePathAction { private static final String LAST_DIR = "LastDir"; private DatabasePathAction() { // utility class } public static File getFileToSave() { final ResourceBundle resources = ResourceUtils.getBundle(); final FileChooser fileChooser = configureFileChooser(); fileChooser.setTitle(resources.getString("Title.NewFile")); final File file = fileChooser.showSaveDialog(MainView.getPrimaryStage()); if (file != null) { Preferences pref = Preferences.userNodeForPackage(DatabasePathAction.class); pref.put(LAST_DIR, file.getParentFile().getAbsolutePath()); } return file; } public static File getFileToOpen() { final ResourceBundle resources = ResourceUtils.getBundle(); final FileChooser fileChooser = configureFileChooser(); fileChooser.setTitle(resources.getString("Title.Open")); final File file = fileChooser.showOpenDialog(MainView.getPrimaryStage()); if (file != null) { Preferences pref = Preferences.userNodeForPackage(DatabasePathAction.class); pref.put(LAST_DIR, file.getParentFile().getAbsolutePath()); } return file; } private static FileChooser configureFileChooser() { final ResourceBundle resources = ResourceUtils.getBundle(); final Preferences pref = Preferences.userNodeForPackage(DatabasePathAction.class); final FileChooser fileChooser = new FileChooser(); final File initialDirectory = new File(pref.get(LAST_DIR, System.getProperty("user.home"))); // Protect against an IllegalArgumentException if (initialDirectory.isDirectory()) { fileChooser.setInitialDirectory(initialDirectory); } final List types = new ArrayList<>(); for (final DataStoreType dataStoreType : DataStoreType.values()) { types.add("*" + dataStoreType.getDataStore().getFileExt()); } fileChooser.getExtensionFilters().addAll( new FileChooser.ExtensionFilter(resources.getString("Label.jGnashFiles"), types) ); for (final DataStoreType dataStoreType : DataStoreType.values()) { fileChooser.getExtensionFilters().addAll( new FileChooser.ExtensionFilter(dataStoreType.toString(), "*" + dataStoreType.getDataStore().getFileExt()) ); } fileChooser.getExtensionFilters().addAll( new FileChooser.ExtensionFilter("All Files", "*.*") ); return fileChooser; } } ================================================ FILE: jgnash-fx/src/main/java/jgnash/uifx/actions/DefaultCurrencyAction.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.uifx.actions; import java.util.List; import java.util.Objects; import java.util.Optional; import java.util.ResourceBundle; import javafx.concurrent.Task; import jgnash.engine.CurrencyNode; import jgnash.engine.Engine; import jgnash.engine.EngineFactory; import jgnash.resource.util.ResourceUtils; import jgnash.uifx.StaticUIMethods; import jgnash.uifx.control.ChoiceDialog; import jgnash.uifx.util.JavaFXUtils; /** * UI Action to change the default currency. * * @author Craig Cavanaugh */ public class DefaultCurrencyAction { public static void showAndWait() { final Task> task = new Task<>() { final ResourceBundle resources = ResourceUtils.getBundle(); private List currencyNodeList; @Override protected List call() { final Engine engine = EngineFactory.getEngine(EngineFactory.DEFAULT); Objects.requireNonNull(engine); currencyNodeList = engine.getCurrencies(); return currencyNodeList; } @Override protected void succeeded() { super.succeeded(); JavaFXUtils.runLater(() -> { final Engine engine = EngineFactory.getEngine(EngineFactory.DEFAULT); Objects.requireNonNull(engine); final ChoiceDialog dialog = new ChoiceDialog<>(engine.getDefaultCurrency(), currencyNodeList); dialog.setTitle(resources.getString("Title.SelDefCurr")); dialog.setContentText(resources.getString("Title.SelDefCurr")); final Optional optional = dialog.showAndWait(); optional.ifPresent(currencyNode -> { engine.setDefaultCurrency(currencyNode); JavaFXUtils.runLater(() -> StaticUIMethods.displayMessage(resources.getString("Message.CurrChange") + " " + engine.getDefaultCurrency().getSymbol())); }); }); } }; new Thread(task).start(); } } ================================================ FILE: jgnash-fx/src/main/java/jgnash/uifx/actions/DefaultLocaleAction.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.uifx.actions; import java.util.ArrayList; import java.util.Collection; import java.util.Locale; import java.util.Optional; import java.util.ResourceBundle; import javafx.concurrent.Task; import jgnash.resource.util.ResourceUtils; import jgnash.uifx.StaticUIMethods; import jgnash.uifx.control.ChoiceDialog; import jgnash.uifx.util.JavaFXUtils; import jgnash.util.LocaleObject; /** * UI Action to change the default locale. * * @author Craig Cavanaugh */ public class DefaultLocaleAction { public static void showAndWait() { final Task> task = new Task<>() { final ResourceBundle resources = ResourceUtils.getBundle(); private Collection localeObjects; @Override protected Collection call() { localeObjects = new ArrayList<>(LocaleObject.getLocaleObjects()); return localeObjects; } @Override protected void succeeded() { super.succeeded(); JavaFXUtils.runLater(() -> { final ChoiceDialog dialog = new ChoiceDialog<>(new LocaleObject(Locale.getDefault()), localeObjects); dialog.setTitle(resources.getString("Title.SelDefLocale")); dialog.setContentText(resources.getString("Title.SelDefLocale")); final Optional optional = dialog.showAndWait(); optional.ifPresent(localeObject -> { ResourceUtils.setLocale(localeObject.getLocale()); JavaFXUtils.runLater(() -> StaticUIMethods.displayMessage(localeObject + "\n" + resources.getString("Message.RestartLocale"))); }); }); } }; new Thread(task).start(); } } ================================================ FILE: jgnash-fx/src/main/java/jgnash/uifx/actions/ExecuteJavaScriptAction.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.uifx.actions; import java.io.File; import java.io.IOException; import java.io.Reader; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.util.ResourceBundle; import java.util.logging.Level; import java.util.logging.Logger; import java.util.prefs.Preferences; import javafx.stage.FileChooser; import javax.script.ScriptEngineManager; import javax.script.ScriptException; import jgnash.resource.util.ResourceUtils; import jgnash.uifx.util.JavaFXUtils; import jgnash.uifx.views.main.MainView; /** * Utility class to run a javascript file. * * @author Craig Cavanaugh */ public class ExecuteJavaScriptAction { private static final String LAST_DIR = "javaScriptDir"; private ExecuteJavaScriptAction() { // Utility class } public static void showAndWait() { final ResourceBundle resources = ResourceUtils.getBundle(); final FileChooser fileChooser = configureFileChooser(); fileChooser.setTitle(resources.getString("Title.SelFile")); final File file = fileChooser.showOpenDialog(MainView.getPrimaryStage()); if (file != null) { Preferences pref = Preferences.userNodeForPackage(ExecuteJavaScriptAction.class); pref.put(LAST_DIR, file.getParentFile().getAbsolutePath()); JavaFXUtils.runLater(() -> { try (final Reader reader = Files.newBufferedReader(file.toPath(), StandardCharsets.UTF_8)) { new ScriptEngineManager().getEngineByName("nashorn").eval(reader); } catch (IOException | ScriptException ex) { Logger.getLogger(ExecuteJavaScriptAction.class.getName()).log(Level.SEVERE, ex.toString(), ex); } }); } } private static FileChooser configureFileChooser() { final Preferences pref = Preferences.userNodeForPackage(ExecuteJavaScriptAction.class); final FileChooser fileChooser = new FileChooser(); final File initialDirectory = new File(pref.get(LAST_DIR, System.getProperty("user.home"))); // Protect against an IllegalArgumentException if (initialDirectory.isDirectory()) { fileChooser.setInitialDirectory(initialDirectory); } fileChooser.getExtensionFilters().addAll( new FileChooser.ExtensionFilter("JavaScript Files", "*.js") ); fileChooser.getExtensionFilters().addAll( new FileChooser.ExtensionFilter("All Files", "*.*") ); return fileChooser; } } ================================================ FILE: jgnash-fx/src/main/java/jgnash/uifx/actions/ExportAccountsAction.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.uifx.actions; import java.io.File; import java.nio.file.Path; import java.nio.file.Paths; import java.util.ResourceBundle; import java.util.prefs.Preferences; import javafx.concurrent.Task; import javafx.stage.FileChooser; import jgnash.engine.AccountTreeXMLFactory; import jgnash.engine.EngineFactory; import jgnash.uifx.views.main.MainView; import jgnash.util.FileUtils; import jgnash.resource.util.ResourceUtils; /** * UI Action to export the current account tree. * * @author Craig Cavanaugh */ public class ExportAccountsAction { private static final String LAST_DIR = "exportDir"; private ExportAccountsAction() { // Utility class } public static void showAndWait() { final ResourceBundle resources = ResourceUtils.getBundle(); final FileChooser fileChooser = configureFileChooser(); fileChooser.setTitle(resources.getString("Title.SelFile")); final File file = fileChooser.showSaveDialog(MainView.getPrimaryStage()); if (file != null) { Preferences pref = Preferences.userNodeForPackage(ExportAccountsAction.class); pref.put(LAST_DIR, file.getParentFile().getAbsolutePath()); final ExportTask exportTask = new ExportTask(Paths.get(FileUtils.stripFileExtension(file.getAbsolutePath()) + ".xml")); new Thread(exportTask).start(); MainView.getInstance().setBusy(exportTask); } } private static FileChooser configureFileChooser() { final Preferences pref = Preferences.userNodeForPackage(ExportAccountsAction.class); final FileChooser fileChooser = new FileChooser(); final File initialDirectory = new File(pref.get(LAST_DIR, System.getProperty("user.home"))); // Protect against an IllegalArgumentException if (initialDirectory.isDirectory()) { fileChooser.setInitialDirectory(initialDirectory); } fileChooser.getExtensionFilters().addAll( new FileChooser.ExtensionFilter( ResourceUtils.getString("Label.XMLFiles") + " (*.xml)", "*.xml", "*.XML") ); return fileChooser; } private static class ExportTask extends Task { private final Path file; ExportTask(final Path file) { this.file = file; } @Override protected Void call() { updateMessage(ResourceUtils.getString("Message.PleaseWait")); updateProgress(-1, Long.MAX_VALUE); AccountTreeXMLFactory.exportAccountTree(EngineFactory.getEngine(EngineFactory.DEFAULT), file); return null; } } } ================================================ FILE: jgnash-fx/src/main/java/jgnash/uifx/actions/ImportAccountsAction.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.uifx.actions; import java.io.File; import java.nio.file.Path; import java.nio.file.Paths; import java.util.ResourceBundle; import java.util.prefs.Preferences; import javafx.concurrent.Task; import javafx.stage.FileChooser; import jgnash.engine.AccountTreeXMLFactory; import jgnash.engine.EngineFactory; import jgnash.engine.RootAccount; import jgnash.uifx.views.main.MainView; import jgnash.util.FileUtils; import jgnash.resource.util.ResourceUtils; /** * UI Action to import a tree of accounts. * * @author Craig Cavanaugh */ public class ImportAccountsAction { private static final String LAST_DIR = "importDir"; private ImportAccountsAction() { // Utility class } public static void showAndWait() { final ResourceBundle resources = ResourceUtils.getBundle(); final FileChooser fileChooser = configureFileChooser(); fileChooser.setTitle(resources.getString("Title.SelFile")); final File file = fileChooser.showOpenDialog(MainView.getPrimaryStage()); if (file != null) { Preferences pref = Preferences.userNodeForPackage(ImportAccountsAction.class); pref.put(LAST_DIR, file.getParentFile().getAbsolutePath()); final ImportTask importTask = new ImportTask(Paths.get(FileUtils.stripFileExtension(file.getAbsolutePath()) + ".xml")); new Thread(importTask).start(); MainView.getInstance().setBusy(importTask); } } private static FileChooser configureFileChooser() { final Preferences pref = Preferences.userNodeForPackage(ImportAccountsAction.class); final FileChooser fileChooser = new FileChooser(); final File initialDirectory = new File(pref.get(LAST_DIR, System.getProperty("user.home"))); // Protect against an IllegalArgumentException if (initialDirectory.isDirectory()) { fileChooser.setInitialDirectory(initialDirectory); } fileChooser.getExtensionFilters().addAll( new FileChooser.ExtensionFilter( ResourceUtils.getString("Label.XMLFiles") + " (*.xml)", "*.xml", "*.XML") ); return fileChooser; } private static class ImportTask extends Task { private final Path file; ImportTask(final Path file) { this.file = file; } @Override protected Void call() { updateMessage(ResourceUtils.getString("Message.ImportWait")); updateProgress(-1, Long.MAX_VALUE); final RootAccount root = AccountTreeXMLFactory.loadAccountTree(file); if (root != null) { AccountTreeXMLFactory.mergeAccountTree(EngineFactory.getEngine(EngineFactory.DEFAULT), root); } return null; } } } ================================================ FILE: jgnash-fx/src/main/java/jgnash/uifx/actions/ImportOfxAction.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.uifx.actions; import java.io.File; import java.util.Objects; import java.util.ResourceBundle; import java.util.logging.Level; import java.util.logging.Logger; import java.util.prefs.Preferences; import javafx.concurrent.Task; import javafx.stage.FileChooser; import jgnash.convert.importat.GenericImport; import jgnash.convert.importat.ofx.OfxBank; import jgnash.convert.importat.ofx.OfxImport; import jgnash.convert.importat.ofx.OfxV2Parser; import jgnash.engine.Account; import jgnash.engine.Engine; import jgnash.engine.EngineFactory; import jgnash.uifx.StaticUIMethods; import jgnash.uifx.control.wizard.WizardDialogController; import jgnash.uifx.views.main.MainView; import jgnash.uifx.wizard.imports.ImportWizard; import jgnash.resource.util.ResourceUtils; /** * Utility class to import an OFX file. * * @author Craig Cavanaugh */ public class ImportOfxAction { private static final String LAST_DIR = "importDir"; private ImportOfxAction() { // Utility class } public static void showAndWait() { final ResourceBundle resources = ResourceUtils.getBundle(); final FileChooser fileChooser = configureFileChooser(); fileChooser.setTitle(resources.getString("Title.SelFile")); final File file = fileChooser.showOpenDialog(MainView.getPrimaryStage()); if (file != null) { Preferences pref = Preferences.userNodeForPackage(ImportOfxAction.class); pref.put(LAST_DIR, file.getParentFile().getAbsolutePath()); new Thread(new ImportTask(file)).start(); } } private static FileChooser configureFileChooser() { final Preferences pref = Preferences.userNodeForPackage(ImportOfxAction.class); final FileChooser fileChooser = new FileChooser(); final File lastDirectory = new File(pref.get(LAST_DIR, System.getProperty("user.home"))); if (lastDirectory.isDirectory()) { fileChooser.setInitialDirectory(lastDirectory); } fileChooser.getExtensionFilters().addAll( new FileChooser.ExtensionFilter("OFX Files (*.ofx,*.qfx)", "*.ofx", "*.qfx", "*.OFX", "*.QFX"), new FileChooser.ExtensionFilter("All Files (*.*)", "*.*") ); return fileChooser; } private static class ImportTask extends Task { private final File file; private Account match = null; ImportTask(final File file) { this.file = file; setOnSucceeded(event -> onSuccess()); } @Override protected OfxBank call() throws Exception { final OfxBank ofxBank = OfxV2Parser.parse(file.toPath()); /* Preset the best match for the downloaded account */ final String accountNumber = ofxBank.accountId; if (accountNumber != null && !accountNumber.isEmpty()) { match = OfxImport.matchAccount(ofxBank); } return ofxBank; } private void onSuccess() { final OfxBank ofxBank = getValue(); final ImportWizard importWizard = new ImportWizard(); WizardDialogController wizardDialogController = importWizard.wizardControllerProperty().get(); // Set the bank match first for a better work flow if (match != null) { wizardDialogController.setSetting(ImportWizard.Settings.ACCOUNT, match); } wizardDialogController.setSetting(ImportWizard.Settings.BANK, ofxBank); importWizard.showAndWait(); if (wizardDialogController.validProperty().get()) { final Account account = (Account) wizardDialogController.getSetting(ImportWizard.Settings.ACCOUNT); // import threads in the background final ImportTransactionsTask importTransactionsTask = new ImportTransactionsTask(ofxBank, account); new Thread(importTransactionsTask).start(); StaticUIMethods.displayTaskProgress(importTransactionsTask); } } } private static class ImportTransactionsTask extends Task { private final OfxBank bank; private final Account account; ImportTransactionsTask(final OfxBank bank, final Account account) { this.bank = bank; this.account = account; } @Override public Void call() { updateMessage(ResourceUtils.getString("Message.PleaseWait")); updateProgress(-1, Long.MAX_VALUE); String accountNumber = bank.accountId; /* set the account number if not a match */ if (accountNumber != null && !accountNumber.equals(account.getAccountNumber())) { final Engine engine = EngineFactory.getEngine(EngineFactory.DEFAULT); Objects.requireNonNull(engine); engine.setAccountNumber(account, accountNumber); } // Import or update securities that were found if (bank.getSecurityList().size() > 0) { GenericImport.importSecurities(bank.getSecurityList(), account.getCurrencyNode()); } // Import the transactions try { OfxImport.importTransactions(bank, account); } catch (final Exception e) { Logger.getLogger(ImportOfxAction.class.getName()).log(Level.SEVERE, e.getLocalizedMessage(), e); } return null; } } } ================================================ FILE: jgnash-fx/src/main/java/jgnash/uifx/actions/ImportQifAction.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.uifx.actions; import java.io.File; import java.util.List; import java.util.ResourceBundle; import java.util.prefs.Preferences; import javafx.concurrent.Task; import javafx.stage.FileChooser; import jgnash.convert.importat.GenericImport; import jgnash.convert.importat.ImportTransaction; import jgnash.convert.importat.qif.QifAccount; import jgnash.convert.importat.qif.QifImport; import jgnash.convert.importat.qif.QifParser; import jgnash.convert.importat.qif.QifUtils; import jgnash.engine.Account; import jgnash.resource.util.ResourceUtils; import jgnash.uifx.StaticUIMethods; import jgnash.uifx.control.wizard.WizardDialogController; import jgnash.uifx.util.JavaFXUtils; import jgnash.uifx.views.main.MainView; import jgnash.uifx.wizard.imports.ImportWizard; /** * Utility class to import an OFX file. * * @author Craig Cavanaugh */ public class ImportQifAction { private static final String LAST_DIR = "importDir"; private ImportQifAction() { // Utility class } public static void showAndWait() { final ResourceBundle resources = ResourceUtils.getBundle(); final FileChooser fileChooser = configureFileChooser(); fileChooser.setTitle(resources.getString("Title.SelFile")); final File file = fileChooser.showOpenDialog(MainView.getPrimaryStage()); if (file != null) { Preferences pref = Preferences.userNodeForPackage(ImportQifAction.class); pref.put(LAST_DIR, file.getParentFile().getAbsolutePath()); new Thread(new ImportTask(file)).start(); } } private static FileChooser configureFileChooser() { final Preferences pref = Preferences.userNodeForPackage(ImportQifAction.class); final FileChooser fileChooser = new FileChooser(); final File initialDirectory = new File(pref.get(LAST_DIR, System.getProperty("user.home"))); // Protect against an IllegalArgumentException if (initialDirectory.isDirectory()) { fileChooser.setInitialDirectory(initialDirectory); } fileChooser.getExtensionFilters().addAll( new FileChooser.ExtensionFilter("Qif Files (*.qif)", "*.qif") ); return fileChooser; } private static class ImportTask extends Task { private final File file; ImportTask(final File file) { this.file = file; setOnSucceeded(event -> onSuccess()); } @Override protected QifImport call() { if (QifUtils.isFullFile(file)) { JavaFXUtils.runLater(() -> StaticUIMethods.displayError("Only bank statement based QIF file are " + "supported at this time")); cancel(); return null; } final QifImport qifImport = new QifImport(); if (!qifImport.doPartialParse(file)) { JavaFXUtils.runLater(() -> StaticUIMethods.displayError( ResourceUtils.getString("Message.Error.ParseTransactions"))); cancel(); return null; } qifImport.dumpStats(); if (qifImport.getParser().accountList.isEmpty()) { JavaFXUtils.runLater(() -> StaticUIMethods.displayError( ResourceUtils.getString("Message.Error.ParseTransactions"))); cancel(); return null; } return qifImport; } private void onSuccess() { final QifImport qifImport = getValue(); final QifParser parser = qifImport.getParser(); final ImportWizard importWizard = new ImportWizard(); final WizardDialogController wizardDialogController = importWizard.wizardControllerProperty().get(); importWizard.dateFormatSelectionEnabled().set(true); final QifAccount qAccount = parser.getBank(); wizardDialogController.setSetting(ImportWizard.Settings.BANK, qAccount); importWizard.showAndWait(); if (wizardDialogController.validProperty().get()) { final Account account = (Account) wizardDialogController.getSetting(ImportWizard.Settings.ACCOUNT); @SuppressWarnings("unchecked") final List transactions = (List) wizardDialogController.getSetting(ImportWizard.Settings.TRANSACTIONS); // import threads in the background ImportTransactionsTask importTransactionsTask = new ImportTransactionsTask(account, transactions); new Thread(importTransactionsTask).start(); StaticUIMethods.displayTaskProgress(importTransactionsTask); } } } private static class ImportTransactionsTask extends Task { private final Account account; private final List transactions; ImportTransactionsTask(final Account account, final List transactions) { this.account = account; this.transactions = transactions; } @Override public Void call() { updateMessage(ResourceUtils.getString("Message.PleaseWait")); updateProgress(-1, Long.MAX_VALUE); /* Import the transactions */ GenericImport.importTransactions(transactions, account); return null; } } } ================================================ FILE: jgnash-fx/src/main/java/jgnash/uifx/control/AbstractAccountTreeController.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.uifx.control; import java.util.Objects; import java.util.TreeSet; import javafx.beans.property.ReadOnlyObjectProperty; import javafx.beans.property.ReadOnlyObjectWrapper; import javafx.collections.FXCollections; import javafx.collections.ObservableSet; import javafx.collections.SetChangeListener; import javafx.scene.control.TreeItem; import javafx.scene.control.TreeView; import jgnash.engine.Account; import jgnash.engine.Comparators; import jgnash.engine.Engine; import jgnash.engine.EngineFactory; import jgnash.engine.message.Message; import jgnash.engine.message.MessageBus; import jgnash.engine.message.MessageChannel; import jgnash.engine.message.MessageListener; import jgnash.uifx.util.JavaFXUtils; import jgnash.uifx.util.TreeSearch; import jgnash.util.Nullable; /** * Abstract Controller handling a {@code TreeView} of {@code Account}s. * * @author Craig Cavanaugh */ public abstract class AbstractAccountTreeController implements MessageListener { private final ReadOnlyObjectWrapper selectedAccount = new ReadOnlyObjectWrapper<>(); protected abstract TreeView getTreeView(); /** * Determines account visibility. * * @param account {@code Account} to determine visibility based on filter state * @return {@code true} if the {@code Account} should be visible */ protected abstract boolean isAccountVisible(Account account); protected abstract boolean isAccountSelectable(Account account); private final ObservableSet filteredAccounts = FXCollections.observableSet(new TreeSet<>()); /** * Adds accounts to be excluded from the list of selectable accounts. * * @param filteredAccountsList collection of {@code Account} that should be excluded regardless * of {@code isAccountVisible()} */ public void addExcludeAccounts(@Nullable final Account... filteredAccountsList) { Objects.requireNonNull(filteredAccounts); for (final Account account: filteredAccountsList != null ? filteredAccountsList : new Account[0]) { if (account != null) { filteredAccounts.add(account); } } } public void initialize() { getTreeView().setShowRoot(false); loadAccountTree(); MessageBus.getInstance().registerListener(this, MessageChannel.SYSTEM, MessageChannel.ACCOUNT); getTreeView().getSelectionModel().selectedItemProperty() .addListener((observable, oldValue, newValue) -> { if (newValue != null) { if (isAccountSelectable(newValue.getValue())) { selectedAccount.set(newValue.getValue()); } } }); filteredAccounts.addListener((SetChangeListener) change -> reload()); } public ReadOnlyObjectProperty getSelectedAccountProperty() { return selectedAccount.getReadOnlyProperty(); } public void setSelectedAccount(final Account account) { final TreeItem treeItem = TreeSearch.findTreeItem(getTreeView().getRoot(), account); if (treeItem != null) { JavaFXUtils.runLater(() -> getTreeView().getSelectionModel().select(treeItem)); } } public void reload() { JavaFXUtils.runLater(this::loadAccountTree); } private void loadAccountTree() { final Engine engine = EngineFactory.getEngine(EngineFactory.DEFAULT); if (engine != null) { final TreeItem root = new TreeItem<>(engine.getRootAccount()); root.setExpanded(true); getTreeView().setRoot(root); loadChildren(root); } else { getTreeView().setRoot(null); } } private synchronized void loadChildren(final TreeItem parentItem) { final Account parent = parentItem.getValue(); parent.getChildren(Comparators.getAccountByCode()).stream().filter(child -> !filteredAccounts.contains(child) && isAccountVisible(child)).forEach(child -> { final TreeItem childItem = new TreeItem<>(child); childItem.setExpanded(true); parentItem.getChildren().add(childItem); if (child.getChildCount() > 0) { loadChildren(childItem); } }); } @Override public void messagePosted(final Message event) { switch (event.getEvent()) { case ACCOUNT_ADD: case ACCOUNT_MODIFY: case ACCOUNT_REMOVE: reload(); break; case FILE_CLOSING: JavaFXUtils.runLater(() -> getTreeView().setRoot(null)); // dump account references immediately MessageBus.getInstance().unregisterListener(this, MessageChannel.SYSTEM, MessageChannel.ACCOUNT); break; default: break; } } } ================================================ FILE: jgnash-fx/src/main/java/jgnash/uifx/control/AccountComboBox.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.uifx.control; import java.util.List; import java.util.Objects; import java.util.function.Predicate; import javafx.beans.binding.Bindings; import javafx.beans.property.SimpleListProperty; import javafx.collections.ObservableList; import javafx.collections.transformation.FilteredList; import javafx.event.EventHandler; import javafx.scene.control.ComboBox; import javafx.scene.control.ListView; import javafx.scene.control.cell.ComboBoxListCell; import javafx.scene.input.KeyEvent; import javafx.util.StringConverter; import jgnash.engine.Account; import jgnash.engine.Comparators; import jgnash.engine.Engine; import jgnash.engine.EngineFactory; import jgnash.engine.message.ChannelEvent; import jgnash.engine.message.Message; import jgnash.engine.message.MessageBus; import jgnash.engine.message.MessageChannel; import jgnash.engine.message.MessageListener; import jgnash.engine.message.MessageProperty; import jgnash.uifx.util.JavaFXUtils; import jgnash.util.NotNull; /** * ComboBox of available accounts. A Predicate for allowed accounts may be specified. *

* The combo will disable itself if it is empty after filtering. * * Bug JDK-8129123 * * @author Craig Cavanaugh */ public class AccountComboBox extends ComboBox implements MessageListener { private final FilteredList filteredList; @SuppressWarnings("FieldCanBeLocal") private final SimpleListProperty listProperty; private final ObservableList items; private ListView listView; public AccountComboBox() { items = getItems(); // By default only visible accounts that are not locked or placeholders are shown filteredList = new FilteredList<>(items, getDefaultPredicate()); listProperty = new SimpleListProperty<>(filteredList); setItems(filteredList); loadAccounts(); MessageBus.getInstance().registerListener(this, MessageChannel.ACCOUNT, MessageChannel.SYSTEM); setOnMouseClicked(event -> forceScrollSelectionBugWorkAround()); setOnKeyTyped(new KeyHandler()); final StringConverter accountStringConverter = new StringConverter<>() { @Override public String toString(final Account account) { return account == null ? null : account.getPathName(); } @Override public Account fromString(final String string) { for (final Account account : filteredList) { if (account.getPathName().equals(string)) { return account; } } return null; } }; // cell factory for capturing the ListView needed for addressing JDK Bug JDK-8129123 setCellFactory(param -> { final ComboBoxListCell comboBoxListCell = new ComboBoxListCell<>(); comboBoxListCell.listViewProperty().addListener((observable, oldValue, newValue) -> { if (newValue != null) { listView = newValue; } }); comboBoxListCell.setConverter(accountStringConverter); return comboBoxListCell; }); // display the full account path instead of the name setConverter(accountStringConverter); // disable if empty disableProperty().bind(Bindings.equal(listProperty.sizeProperty(), 0)); } /** * Returns the default Predicate used to determine which Accounts are displayed. *

* The default is only visible accounts that are not locked or placeholders are shown. * * @return default Account Predicate */ public static Predicate getDefaultPredicate() { return account -> account.isVisible() && !account.isLocked() && !account.isPlaceHolder(); } /** * Returns a Predicate used to display all accounts. * * @return Account Predicate */ public static Predicate getShowAllPredicate() { return account -> true; } // TODO: JDK Bug JDK-8129123 https://bugs.openjdk.java.net/browse/JDK-8129123 private void forceScrollSelectionBugWorkAround() { if (listView != null) { listView.scrollTo(getSelectionModel().getSelectedIndex()); } } public ObservableList getUnfilteredItems() { return items; } public void setPredicate(final Predicate predicate) { filteredList.setPredicate(predicate); // force an update if the filtered list does not contain the current value if (!filteredList.contains(getValue())) { selectDefaultAccount(); } } private void selectDefaultAccount() { // Set a default account, must use the filtered list because that is what is visible if (filteredList.size() > 0) { JavaFXUtils.runLater(() -> setValue(filteredList.get(0))); } } /** * A decorator around {@link ComboBox#setValue(Object)} that ensures only Accounts available within this ComboBox * are set. * * @param value Account to set */ public final void setAccountValue(final Account value) { if (value == null || filteredList.contains(value)) { setValue(value); } } private void loadAccounts(@NotNull final List accounts) { accounts.forEach(account -> { getUnfilteredItems().add(account); if (account.getChildCount() > 0) { loadAccounts(account.getChildren(Comparators.getAccountByCode())); } }); } private void loadAccounts() { getUnfilteredItems().clear(); final Engine engine = EngineFactory.getEngine(EngineFactory.DEFAULT); Objects.requireNonNull(engine); loadAccounts(engine.getRootAccount().getChildren(Comparators.getAccountByCode())); selectDefaultAccount(); } /** * Deterministic removal of an account * * @param account Account to remove */ private void removeAccount(final Account account) { final int oldIndex = items.indexOf(account); items.removeAll(account); if (oldIndex >= 0) { setAccountValue(items.get(oldIndex)); } else if (items.size() > 0) { setAccountValue(items.get(0)); } } @Override public void messagePosted(final Message event) { // unregister immediately if (event.getEvent() == ChannelEvent.FILE_CLOSING) { MessageBus.getInstance().unregisterListener(this, MessageChannel.ACCOUNT, MessageChannel.SYSTEM); } JavaFXUtils.runLater(() -> { switch (event.getEvent()) { case FILE_CLOSING: items.clear(); break; case ACCOUNT_REMOVE: removeAccount(event.getObject(MessageProperty.ACCOUNT)); break; case ACCOUNT_ADD: case ACCOUNT_MODIFY: loadAccounts(); break; default: break; } }); } private class KeyHandler implements EventHandler { @Override public void handle(final KeyEvent event) { int current = getSelectionModel().getSelectedIndex(); if (current < 0 || current > filteredList.size()) { current = 0; } int loop = current; do { loop++; //move to next item if (loop > filteredList.size() - 1) { loop = 0; } final Account item = filteredList.get(loop); if (item.toString().toUpperCase().startsWith(event.getCharacter().toUpperCase())) { getSelectionModel().select(item); break; } } while (loop != current); forceScrollSelectionBugWorkAround(); } } } ================================================ FILE: jgnash-fx/src/main/java/jgnash/uifx/control/Alert.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.uifx.control; import java.util.Optional; import java.util.ResourceBundle; import javafx.beans.property.ObjectProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.fxml.FXML; import javafx.scene.Node; import javafx.scene.Scene; import javafx.scene.control.Button; import javafx.scene.control.ButtonBar; import javafx.scene.control.ButtonType; import javafx.scene.control.Label; import javafx.scene.paint.Color; import javafx.stage.Stage; import javafx.stage.Window; import jgnash.resource.util.ResourceUtils; import jgnash.uifx.resource.font.MaterialDesignLabel; import jgnash.uifx.skin.ThemeManager; import jgnash.uifx.util.FXMLUtils; import jgnash.uifx.util.InjectFXML; import jgnash.util.NotNull; /** * A Better behaved Alert class. * * @author Craig Cavanaugh */ public class Alert { static final int HEIGHT_MULTIPLIER = 3; @InjectFXML private final ObjectProperty parent = new SimpleObjectProperty<>(); @FXML private Label message; @FXML private ButtonBar buttonBar; private ButtonType buttonType = ButtonType.CANCEL; // default is cancelled public enum AlertType { ERROR, WARNING, INFORMATION, YES_NO } private final Stage dialog; public Alert(@NotNull final AlertType alertType, final String contentText) { final ResourceBundle resources = ResourceUtils.getBundle(); dialog = FXMLUtils.loadFXML(this, "AlertDialog.fxml", resources); setContentText(contentText); switch (alertType) { case ERROR: setGraphic(new MaterialDesignLabel(MaterialDesignLabel.MDIcon.EXCLAMATION_TRIANGLE, ThemeManager.getBaseTextHeight() * HEIGHT_MULTIPLIER, Color.DARKRED)); setButtons(new ButtonType(resources.getString("Button.Close"), ButtonBar.ButtonData.CANCEL_CLOSE)); break; case WARNING: setGraphic(new MaterialDesignLabel(MaterialDesignLabel.MDIcon.EXCLAMATION_CIRCLE, ThemeManager.getBaseTextHeight() * HEIGHT_MULTIPLIER, Color.DARKGOLDENROD)); setButtons(new ButtonType(resources.getString("Button.Close"), ButtonBar.ButtonData.CANCEL_CLOSE)); break; case INFORMATION: setGraphic(new MaterialDesignLabel(MaterialDesignLabel.MDIcon.INFO_CIRCLE, ThemeManager.getBaseTextHeight() * HEIGHT_MULTIPLIER, Color.DARKGOLDENROD)); setButtons(new ButtonType(resources.getString("Button.Close"), ButtonBar.ButtonData.CANCEL_CLOSE)); break; case YES_NO: setGraphic(new MaterialDesignLabel(MaterialDesignLabel.MDIcon.QUESTION_CIRCLE, ThemeManager.getBaseTextHeight() * HEIGHT_MULTIPLIER)); ButtonType buttonTypeYes = new ButtonType(resources.getString("Button.Yes"), ButtonBar.ButtonData.YES); ButtonType buttonTypeNo = new ButtonType(resources.getString("Button.No"), ButtonBar.ButtonData.NO); setButtons(buttonTypeYes, buttonTypeNo); break; default: } } public void setTitle(final String title) { dialog.setTitle(title); } public void initOwner(final Window window) { dialog.initOwner(window); } private void setContentText(final String contentText) { message.setText(contentText); } private void setGraphic(final Node node) { message.setGraphic(node); } private void setButtons(final ButtonType... buttons) { for (final ButtonType buttonType : buttons) { final Button button = new Button(buttonType.getText()); ButtonBar.setButtonData(button, buttonType.getButtonData()); button.setOnAction(event -> { Alert.this.buttonType = buttonType; ((Stage) parent.get().getWindow()).close(); }); buttonBar.getButtons().add(button); } } private Optional getButtonType() { return Optional.ofNullable(buttonType); } public Optional showAndWait() { dialog.sizeToScene(); dialog.setResizable(false); dialog.showAndWait(); return getButtonType(); } } ================================================ FILE: jgnash-fx/src/main/java/jgnash/uifx/control/AutoCompleteTextField.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.uifx.control; import javafx.beans.property.ObjectProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.scene.input.KeyEvent; import jgnash.uifx.control.autocomplete.AutoCompleteModel; import jgnash.uifx.util.JavaFXUtils; /** * Text field for auto completion of values. * * @author Craig Cavanaugh */ public class AutoCompleteTextField extends TextFieldEx { private final ObjectProperty> autoCompleteModel = new SimpleObjectProperty<>(); public AutoCompleteTextField() { // If the enter key is pressed to accept the auto complete, // simulate a tab key press to focus the next field addEventFilter(KeyEvent.KEY_PRESSED, event -> { if (JavaFXUtils.ENTER_KEY.match(event)) { JavaFXUtils.runLater(() -> JavaFXUtils.focusNext(AutoCompleteTextField.this)); } }); } @Override public void replaceText(final int start, final int end, final String text) { super.replaceText(start, end, text); if (autoCompleteModel.get() != null) { final String currText = getText(); // get the full string final String newText = autoCompleteModel.get().doLookAhead(currText); // look for a match if (newText != null && !currText.isEmpty()) { // found a match and the field is not empty clear(); // clear existing text if (start + 1 > currText.length()) { // delete action has occurred setText(currText.substring(0, start)); positionCaret(start); } else { // replace with the new text string super.replaceText(0, 0, currText.substring(0, start + 1) + newText.substring(start + 1)); // highlight the remainder of the auto-completed text positionCaret(start + 1); selectEnd(); } } } } @Override public void deleteText(final int start, final int end) { super.replaceText(start, end, ""); // force call to super super.replaceText to prevent bounds errors } public ObjectProperty> autoCompleteModelObjectProperty() { return autoCompleteModel; } } ================================================ FILE: jgnash-fx/src/main/java/jgnash/uifx/control/BigDecimalTableCell.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received account copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.uifx.control; import java.math.BigDecimal; import java.text.NumberFormat; import javafx.beans.property.ObjectProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.beans.value.ChangeListener; import javafx.beans.value.WeakChangeListener; import javafx.scene.control.TableCell; import javafx.scene.input.KeyCode; /** * A class containing a {@link TableCell} implementation that draws a {@link DecimalTextField} node inside the cell. *

* By default, the BigDecimalTableCell is rendered as a {@link javafx.scene.control.Label} when not being edited, and * as a DecimalTextField when in editing mode. * * @author Craig Cavanaugh */ public class BigDecimalTableCell extends TableCell { private final SimpleObjectProperty numberFormat = new SimpleObjectProperty<>(); private DecimalTextField decimalTextField = null; /** * Reference is needed to prevent premature garbage collection. */ @SuppressWarnings("FieldCanBeLocal") private ChangeListener focusChangeListener; public BigDecimalTableCell(final ObjectProperty numberFormatProperty) { setStyle("-fx-alignment: center-right;"); // Right align numberFormatProperty().bind(numberFormatProperty); } public BigDecimalTableCell(final NumberFormat numberFormat) { setStyle("-fx-alignment: center-right;"); // Right align numberFormatProperty().set(numberFormat); } @Override protected void updateItem(final BigDecimal amount, final boolean empty) { super.updateItem(amount, empty); // required if (empty) { setText(null); setGraphic(null); } else { if (isEditing()) { setText(null); getDecimalTextField().setDecimal(getItem()); setGraphic(getDecimalTextField()); } else if (amount != null) { setText(numberFormat.get().format(amount)); setGraphic(null); } else { setText(null); setGraphic(null); } } } private SimpleObjectProperty numberFormatProperty() { return numberFormat; } @Override public void startEdit() { if (!isEditable() || !getTableView().isEditable() || !getTableColumn().isEditable()) { return; } final DecimalTextField decimalTextField = getDecimalTextField(); decimalTextField.setDecimal(getItem()); super.startEdit(); setText(null); setGraphic(decimalTextField); decimalTextField.requestFocus(); } @Override public void cancelEdit() { super.cancelEdit(); setText(numberFormat.get().format(getItem())); setGraphic(null); } private DecimalTextField getDecimalTextField() { if (decimalTextField == null) { decimalTextField = new DecimalTextField(); decimalTextField.scaleProperty().set(numberFormat.get().getMaximumFractionDigits()); decimalTextField.setOnKeyPressed(event -> { if (isEditing() && event.getCode() == KeyCode.ENTER) { commitEdit(decimalTextField.getDecimal()); } }); focusChangeListener = (observable, oldValue, newValue) -> { if (isEditing() && !newValue) { commitEdit(decimalTextField.getDecimal()); } }; decimalTextField.focusedProperty().addListener(new WeakChangeListener<>(focusChangeListener)); } return decimalTextField; } } ================================================ FILE: jgnash-fx/src/main/java/jgnash/uifx/control/BusyPane.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.uifx.control; import javafx.beans.value.ChangeListener; import javafx.beans.value.WeakChangeListener; import javafx.concurrent.Task; import javafx.concurrent.WorkerStateEvent; import javafx.geometry.Pos; import javafx.scene.control.Label; import javafx.scene.control.ProgressIndicator; import javafx.scene.effect.BoxBlur; import javafx.scene.image.ImageView; import javafx.scene.layout.GridPane; import javafx.scene.layout.StackPane; import javafx.scene.text.Font; import javafx.scene.text.FontWeight; import jgnash.uifx.skin.ThemeManager; import jgnash.util.Nullable; /** * A busy pane. Intended to overlay the main ui and blur the display when a long running operation is occurring * * @author Craig Cavanaugh */ public class BusyPane extends StackPane { private ImageView imageView; private final Label messageLabel; private final ProgressIndicator progressIndicator; /** * Listens for changes to the font scale */ @SuppressWarnings("FieldCanBeLocal") private final ChangeListener fontScaleListener; public BusyPane() { progressIndicator = new ProgressIndicator(); messageLabel = new Label(); final GridPane gridPane = new GridPane(); gridPane.setAlignment(Pos.CENTER); gridPane.setHgap(15); gridPane.add(progressIndicator, 0, 0); gridPane.add(messageLabel, 1, 0); updateFont(); fontScaleListener = (observable, oldValue, newValue) -> updateFont(); ThemeManager.fontScaleProperty().addListener(new WeakChangeListener<>(fontScaleListener)); getChildren().addAll(gridPane); setVisible(false); } private ImageView getImageView() { ImageView imageView = new ImageView(); imageView.setFocusTraversable(false); imageView.setEffect(new BoxBlur(4, 4, 2)); imageView.setImage(getScene().getRoot().snapshot(null, null)); return imageView; } private void updateFont() { messageLabel.fontProperty().set(Font.font(null, FontWeight.BOLD, null, ThemeManager.getBaseTextHeight() * 1.2)); } public void setTask(@Nullable final Task task) { if (task == null) { setVisible(false); progressIndicator.progressProperty().unbind(); messageLabel.textProperty().unbind(); getChildren().remove(imageView); if (imageView != null) { // protect against a race condition imageView.setImage(null); // don't retain the image, conserve memory imageView = null; } } else { // get the snapshot imageView = getImageView(); messageLabel.textProperty().bind(task.messageProperty()); progressIndicator.progressProperty().bind(task.progressProperty()); // Add event handlers to automatically hide the busy pane. // These handler will not override the setOnXxx handlers task.addEventHandler(WorkerStateEvent.WORKER_STATE_SUCCEEDED, event -> setTask(null)); task.addEventHandler(WorkerStateEvent.WORKER_STATE_CANCELLED, event -> setTask(null)); task.addEventHandler(WorkerStateEvent.WORKER_STATE_FAILED, event -> setTask(null)); setVisible(true); } } @Override protected void layoutChildren() { super.layoutChildren(); if (getParent() != null && isVisible()) { setVisible(false); getChildren().remove(imageView); getChildren().add(imageView); imageView.toBack(); setVisible(true); } } } ================================================ FILE: jgnash-fx/src/main/java/jgnash/uifx/control/CheckComboBox.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.uifx.control; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import javafx.beans.property.BooleanProperty; import javafx.beans.property.SimpleBooleanProperty; import javafx.beans.value.ChangeListener; import javafx.collections.ListChangeListener; import javafx.scene.control.ComboBox; import javafx.scene.control.ListCell; import javafx.scene.control.Tooltip; import javafx.scene.control.cell.CheckBoxListCell; import javafx.scene.input.MouseEvent; import jgnash.uifx.util.JavaFXUtils; /** * ComboBox of checked items * * @author Craig Cavanaugh */ public class CheckComboBox extends ComboBox { private final Map itemBooleanPropertyMap = new HashMap<>(); private final List> checkChangedListeners = new ArrayList<>(); @SuppressWarnings("FieldCanBeLocal") private final ListCell buttonCell; private final ChangeListener checkedChangeListener = (observable, oldValue, newValue) -> { for (final ChangeListener listener : checkChangedListeners) { listener.changed(observable, oldValue, newValue); } }; public CheckComboBox() { setCellFactory(param -> { final ListCell cell = new CheckBoxListCell<>(CheckComboBox.this::getItemBooleanProperty); // toggle the value cell.addEventFilter(MouseEvent.MOUSE_RELEASED, event -> { itemBooleanPropertyMap.get(cell.getItem()).setValue(!itemBooleanPropertyMap.get(cell.getItem()).get()); JavaFXUtils.runLater(CheckComboBox.this::updatePromptText); }); return cell; }); // need to override the button cell, otherwise prompt text is replaced by the last selection buttonCell = new ListCell<>() { @Override protected void updateItem(final T item, final boolean empty) { setText(getPromptText()); } }; setButtonCell(buttonCell); getItems().addListener((ListChangeListener) c -> JavaFXUtils.runLater(CheckComboBox.this::updatePromptText)); } /** * Adds a ChangeListener which will be notified whenever a check changes. * * @param listener The listener to register */ public void addListener(final ChangeListener listener) { checkChangedListeners.add(listener); } public void add(T item, boolean check) { getItems().add(item); getItemBooleanProperty(item).setValue(check); } public List getCheckedItems() { final List checkedItems = new ArrayList<>(); for (final T item : getItems()) { if (getItemBooleanProperty(item).get()) { checkedItems.add(item); } } return checkedItems; } public void setChecked(T item, boolean check) { getItemBooleanProperty(item).setValue(check); } public void setAllChecked() { for (final T item : getItems()) { getItemBooleanProperty(item).set(true); } } private void updatePromptText() { final StringBuilder sb = new StringBuilder(); getItems().filtered(t -> getItemBooleanProperty(t).get()) .forEach(t -> sb.append(", ").append(t.toString())); // strip the leading space and comma final String string = sb.substring(Integer.min(2, sb.length())); setPromptText(string); setPromptText(string); setTooltip(new Tooltip(string)); } private BooleanProperty getItemBooleanProperty(final T item) { if (itemBooleanPropertyMap.get(item) == null) { SimpleBooleanProperty booleanProperty = new SimpleBooleanProperty(); itemBooleanPropertyMap.put(item, booleanProperty); booleanProperty.addListener(checkedChangeListener); } return itemBooleanPropertyMap.get(item); } } ================================================ FILE: jgnash-fx/src/main/java/jgnash/uifx/control/CheckListView.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.uifx.control; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import javafx.beans.property.BooleanProperty; import javafx.beans.property.SimpleBooleanProperty; import javafx.collections.FXCollections; import javafx.scene.control.ListView; import javafx.scene.control.cell.CheckBoxListCell; /** * CheckListView control * * @author Craig Cavanaugh */ public class CheckListView extends ListView { private final Map itemMap = new HashMap<>(); public CheckListView() { super(FXCollections.observableArrayList()); setCellFactory(listView -> new CheckBoxListCell<>(this::getItemBooleanProperty)); } private BooleanProperty getItemBooleanProperty(final T item) { itemMap.putIfAbsent(item, new SimpleBooleanProperty()); return itemMap.get(item); } public List getCheckedItems() { final List checkedItems = new ArrayList<>(); for (final T item : getItems()) { if (getItemBooleanProperty(item).get()) { checkedItems.add(item); } } return checkedItems; } public void clearChecks() { for (final T item : getItems()) { getItemBooleanProperty(item).set(false); } } public void checkAll() { for (final T item : getItems()) { getItemBooleanProperty(item).set(true); } } public void toggleAll() { for (final T item : getItems()) { getItemBooleanProperty(item).set(!getItemBooleanProperty(item).get()); } } } ================================================ FILE: jgnash-fx/src/main/java/jgnash/uifx/control/ChoiceDialog.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.uifx.control; import java.util.Collection; import java.util.Optional; import java.util.ResourceBundle; import javafx.beans.property.ObjectProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.fxml.FXML; import javafx.scene.Node; import javafx.scene.Scene; import javafx.scene.control.Button; import javafx.scene.control.ButtonBar; import javafx.scene.control.ComboBox; import javafx.scene.control.Label; import javafx.stage.Stage; import jgnash.resource.util.ResourceUtils; import jgnash.uifx.Options; import jgnash.uifx.resource.font.MaterialDesignLabel; import jgnash.uifx.skin.ThemeManager; import jgnash.uifx.util.FXMLUtils; import jgnash.uifx.util.InjectFXML; import jgnash.uifx.util.JavaFXUtils; /** * A ChoiceDialog with a consistent application appearance * * @param The type of the items to show to the user, and the type that is returned * via {@link #showAndWait()} when the dialog is dismissed. * * @author Craig Cavanaugh */ public class ChoiceDialog { @InjectFXML private final ObjectProperty parent = new SimpleObjectProperty<>(); @FXML private ButtonBar buttonBar; @FXML private Button okButton; @FXML private Button cancelButton; @FXML private Label message; @FXML private ComboBox comboBox; private final Stage dialog; /** * Creates a new ChoiceDialog instance with the first argument specifying the * default choice that should be shown to the user, and the second argument * specifying a collection of all available choices for the user. It is * expected that the defaultChoice be one of the elements in the choices * collection. If this is not true, then defaultChoice will be set to null and the * dialog will show with the initial choice set to the first item in the list * of choices. * * @param defaultChoice The item to display as the pre-selected choice in the dialog. * This item must be contained within the choices varargs array. * @param choices All possible choices to present to the user. */ public ChoiceDialog(final T defaultChoice, final Collection choices) { final ResourceBundle resources = ResourceUtils.getBundle(); dialog = FXMLUtils.loadFXML(this, "ChoiceDialog.fxml", resources); setGraphic(new MaterialDesignLabel(MaterialDesignLabel.MDIcon.QUESTION_CIRCLE, ThemeManager.getBaseTextHeight() * Alert.HEIGHT_MULTIPLIER)); // block until the combo box is completed loaded to prevent a race condition JavaFXUtils.runAndWait(() -> { comboBox.getItems().setAll(choices); setSelectedItem(defaultChoice); }); } @FXML private void initialize() { buttonBar.buttonOrderProperty().bind(Options.buttonOrderProperty()); okButton.setOnAction(event -> handleOkayAction()); cancelButton.setOnAction(event -> handleCancelAction()); okButton.disableProperty().bind(comboBox.valueProperty().isNull()); } public void setTitle(final String title) { dialog.setTitle(title); } public void setContentText(final String contentText) { message.setText(contentText); dialog.sizeToScene(); } private void setGraphic(final Node node) { message.setGraphic(node); } /** * Returns the currently selected item in the dialog. * @return the currently selected item */ private T getSelectedItem() { return comboBox.getSelectionModel().getSelectedItem(); } /** * Sets the currently selected item in the dialog. * @param item The item to select in the dialog. */ private void setSelectedItem(T item) { comboBox.getSelectionModel().select(item); } public Optional showAndWait() { dialog.sizeToScene(); dialog.setResizable(false); dialog.showAndWait(); return Optional.ofNullable(getSelectedItem()); } private void handleOkayAction() { ((Stage)parent.get().getWindow()).close(); } private void handleCancelAction() { comboBox.setValue(null); ((Stage)parent.get().getWindow()).close(); } } ================================================ FILE: jgnash-fx/src/main/java/jgnash/uifx/control/CurrencyComboBox.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.uifx.control; import java.util.List; import java.util.Objects; import javafx.collections.ObservableList; import javafx.collections.transformation.SortedList; import javafx.scene.control.ComboBox; import jgnash.engine.CurrencyNode; import jgnash.engine.Engine; import jgnash.engine.EngineFactory; import jgnash.engine.message.Message; import jgnash.engine.message.MessageBus; import jgnash.engine.message.MessageChannel; import jgnash.engine.message.MessageListener; import jgnash.engine.message.MessageProperty; import jgnash.uifx.util.JavaFXUtils; /** * ComboBox that allows selection of a CurrencyNode and manages it's own model. * * @author Craig Cavanaugh */ public class CurrencyComboBox extends ComboBox implements MessageListener{ /** Model for the ComboBox. */ private ObservableList items; public CurrencyComboBox() { JavaFXUtils.runLater(this::loadModel); // lazy load to let the ui build happen faster } private void loadModel() { final Engine engine = EngineFactory.getEngine(EngineFactory.DEFAULT); Objects.requireNonNull(engine); final List nodeList = engine.getCurrencies(); // extract and reuse the default model items = getItems(); // warp in a sorted list setItems(new SortedList<>(items, null)); items.addAll(nodeList); final CurrencyNode defaultCurrency = engine.getDefaultCurrency(); setValue(defaultCurrency); MessageBus.getInstance().registerListener(this, MessageChannel.COMMODITY, MessageChannel.SYSTEM); } @Override public void messagePosted(final Message event) { if (event.getObject(MessageProperty.COMMODITY) instanceof CurrencyNode) { final CurrencyNode node = event.getObject(MessageProperty.COMMODITY); JavaFXUtils.runLater(() -> { switch (event.getEvent()) { case CURRENCY_REMOVE: items.removeAll(node); break; case CURRENCY_ADD: items.add(node); break; case CURRENCY_MODIFY: items.removeAll(node); items.add(node); break; case FILE_CLOSING: items.clear(); default: break; } }); } } } ================================================ FILE: jgnash-fx/src/main/java/jgnash/uifx/control/DataStoreTypeComboBox.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.uifx.control; import javafx.collections.ObservableList; import javafx.collections.transformation.SortedList; import javafx.scene.control.ComboBox; import jgnash.engine.DataStoreType; import jgnash.uifx.util.JavaFXUtils; /** * ComboBox that allows selection of a CurrencyNode and manages it's own model. * * @author Craig Cavanaugh */ public class DataStoreTypeComboBox extends ComboBox { public DataStoreTypeComboBox() { setEditable(false); JavaFXUtils.runLater(this::loadModel); // lazy load to let the ui build happen faster } private void loadModel() { // extract and reuse the default model ObservableList items = getItems(); // warp in a sorted list setItems(new SortedList<>(items, null)); items.addAll(DataStoreType.values()); setValue(DataStoreType.H2_DATABASE); } } ================================================ FILE: jgnash-fx/src/main/java/jgnash/uifx/control/DatePickerEx.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.uifx.control; import java.time.LocalDate; import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; import java.util.concurrent.atomic.AtomicBoolean; import java.util.prefs.Preferences; import javafx.beans.property.BooleanProperty; import javafx.beans.property.ObjectProperty; import javafx.beans.property.SimpleBooleanProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.beans.property.SimpleStringProperty; import javafx.beans.property.StringProperty; import javafx.beans.value.ChangeListener; import javafx.beans.value.WeakChangeListener; import javafx.scene.control.DatePicker; import javafx.scene.input.KeyEvent; import javafx.util.converter.LocalDateStringConverter; import jgnash.time.DateUtils; import jgnash.uifx.util.JavaFXUtils; /** * Enhanced DatePicker. Adds short cuts for date entry with better input character filters * * @author Craig Cavanuugh */ public class DatePickerEx extends DatePicker { private final String allowedDateCharacters; private final DateTimeFormatter dateFormatter; private char dateFormatSeparator = '/'; /** * Strong Reference is needed to prevent premature garbage collection. */ @SuppressWarnings("FieldCanBeLocal") private final ChangeListener focusChangeListener; /** * Strong Reference is needed to prevent premature garbage collection. */ @SuppressWarnings("FieldCanBeLocal") private final ChangeListener valueListener; private final BooleanProperty preserveDate = new SimpleBooleanProperty(false); private final StringProperty preferenceKey = new SimpleStringProperty(); private final ObjectProperty preferences = new SimpleObjectProperty<>(); /** * Composite to make handling of bindings simpler */ private final BooleanProperty saveRestoreDate = new SimpleBooleanProperty(false); /** * One shot restoration of the data value if enabled */ private final AtomicBoolean oldValueRestored = new AtomicBoolean(false); public DatePickerEx() { super(LocalDate.now()); // initialize with a valid date saveRestoreDate.bind(preferenceKey.isNotEmpty().and(preferences.isNotNull()).and(preserveDate)); // restore the date when the property goes from false to true saveRestoreDate.addListener((observable, oldValue, newValue) -> { if (newValue != null && newValue && !oldValueRestored.get()) { oldValueRestored.set(true); Preferences preferences = preferencesProperty().get(); final long value = preferences.getLong(preferenceKeyProperty().get(), -1); if (value > 0) { final LocalDate restoreDate = DateUtils.asLocalDate(value); JavaFXUtils.runLater(() -> setValue(restoreDate)); } } }); final StringBuilder buf = new StringBuilder("0123456789"); dateFormatter = DateUtils.getShortDateManualEntryFormatter(); final char[] chars = dateFormatter.format(LocalDate.now()).toCharArray(); for (final char aChar : chars) { if (!Character.isDigit(aChar)) { if (buf.indexOf(Character.toString(aChar)) == -1) { buf.append(aChar); dateFormatSeparator = aChar; } } } allowedDateCharacters = buf.toString(); setConverter(new LocalDateStringConverter(dateFormatter, dateFormatter)); // Handle horizontal and vertical scroll wheel events getEditor().setOnScroll(event -> { final int caretPosition = getEditor().getCaretPosition(); final LocalDate date = _getValue(); if (event.getDeltaY() > 0) { JavaFXUtils.runLater(() -> { setValue(date.plusDays(1)); getEditor().positionCaret(caretPosition); }); } else if (event.getDeltaY() < 0) { JavaFXUtils.runLater(() -> { setValue(date.minusDays(1)); getEditor().positionCaret(caretPosition); }); } if (event.getDeltaX() > 0) { JavaFXUtils.runLater(() -> { setValue(date.plusMonths(1)); getEditor().positionCaret(caretPosition); }); } else if (event.getDeltaX() < 0) { JavaFXUtils.runLater(() -> { setValue(date.minusMonths(1)); getEditor().positionCaret(caretPosition); }); } }); getEditor().addEventFilter(KeyEvent.KEY_TYPED, event -> { // An empty event character is possible... must protect against it if (!event.getCharacter().isEmpty() && allowedDateCharacters.indexOf(event.getCharacter().charAt(0)) < 0) { event.consume(); } }); focusChangeListener = (observable, oldValue, newValue) -> JavaFXUtils.runLater(() -> { final int caretPosition = getEditor().getCaretPosition(); getEditor().setText(dateFormatter.format(_getValue())); getEditor().positionCaret(caretPosition); }); // Ensure the last parsable value is displayed after focus is lost. // Wrap in a weak change listener to protect against memory leaks. getEditor().focusedProperty().addListener(new WeakChangeListener<>(focusChangeListener)); getEditor().addEventHandler(KeyEvent.KEY_PRESSED, event -> { final int caretPosition = getEditor().getCaretPosition(); // preserve caret position for restoration final LocalDate date = _getValue(); // force an update to the current value switch (event.getCode()) { case PERIOD: // substitute common separators with the current locale's SEPARATOR case SLASH: // while preventing entry of consecutive separators case COMMA: case BACK_SLASH: JavaFXUtils.runLater(() -> { final StringBuilder text = new StringBuilder(getEditor().getText()); if (text.length() > caretPosition) { if (text.charAt(caretPosition) != dateFormatSeparator && (caretPosition > 0 && text.charAt(caretPosition - 1) != dateFormatSeparator)) { text.insert(caretPosition, dateFormatSeparator); } } else { text.append(dateFormatSeparator); } getEditor().setText(text.toString()); getEditor().positionCaret(caretPosition + 1); }); break; case ADD: case UP: case KP_UP: JavaFXUtils.runLater(() -> { setValue(date.plusDays(1)); getEditor().positionCaret(caretPosition); }); break; case SUBTRACT: case DOWN: case KP_DOWN: JavaFXUtils.runLater(() -> { setValue(date.minusDays(1)); getEditor().positionCaret(caretPosition); }); break; case T: JavaFXUtils.runLater(() -> { setValue(LocalDate.now()); getEditor().positionCaret(caretPosition); }); break; case PAGE_UP: JavaFXUtils.runLater(() -> { setValue(date.plusMonths(1)); getEditor().positionCaret(caretPosition); }); break; case PAGE_DOWN: JavaFXUtils.runLater(() -> { setValue(date.minusMonths(1)); getEditor().positionCaret(caretPosition); }); break; default: } }); valueListener = (observable, oldValue, newValue) -> { if (newValue == null) { setValue(oldValue); } else if (saveRestoreDate.get()) { // save the date preferencesProperty().get().putLong(preferenceKeyProperty().get(), DateUtils.asEpochMilli(newValue)); } }; // Wrap in a weak change listener to protect against memory leaks. valueProperty().addListener(new WeakChangeListener<>(valueListener)); } private LocalDate _getValue() { try { final LocalDate date = LocalDate.parse(getEditor().getText(), dateFormatter); setValue(date); return date; } catch (final DateTimeParseException ignored) { return getValue(); // return the current value } } public BooleanProperty preserveDateProperty() { return preserveDate; } public StringProperty preferenceKeyProperty() { return preferenceKey; } public ObjectProperty preferencesProperty() { return preferences; } } ================================================ FILE: jgnash-fx/src/main/java/jgnash/uifx/control/DateRangeDialogController.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.uifx.control; import java.time.LocalDate; import java.util.Optional; import javafx.beans.property.ObjectProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.fxml.FXML; import javafx.scene.Scene; import javafx.scene.control.ButtonBar; import javafx.stage.Stage; import jgnash.uifx.Options; import jgnash.uifx.util.InjectFXML; /** * Simple date range input controller. * * @author Craig Cavanaugh */ public class DateRangeDialogController { @InjectFXML private final ObjectProperty parent = new SimpleObjectProperty<>(); @FXML private ButtonBar buttonBar; @FXML private DatePickerEx startDatePicker; @FXML private DatePickerEx endDatePicker; private LocalDate[] dates = null; @FXML private void initialize() { buttonBar.buttonOrderProperty().bind(Options.buttonOrderProperty()); startDatePicker.setValue(endDatePicker.getValue().minusYears(1)); } public Optional getDates() { return Optional.ofNullable(dates); } @FXML private void handleCloseAction() { ((Stage) parent.get().getWindow()).close(); } @FXML private void handleOkAction() { dates = new LocalDate[] {startDatePicker.getValue(), endDatePicker.getValue()}; ((Stage) parent.get().getWindow()).close(); } } ================================================ FILE: jgnash-fx/src/main/java/jgnash/uifx/control/DecimalTextField.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.uifx.control; import java.math.BigDecimal; import java.text.DecimalFormat; import java.text.NumberFormat; import java.util.Objects; import javafx.beans.property.BooleanProperty; import javafx.beans.property.IntegerProperty; import javafx.beans.property.ObjectProperty; import javafx.beans.property.ReadOnlyBooleanProperty; import javafx.beans.property.ReadOnlyDoubleProperty; import javafx.beans.property.SimpleBooleanProperty; import javafx.beans.property.SimpleDoubleProperty; import javafx.beans.property.SimpleIntegerProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.beans.value.ChangeListener; import javafx.beans.value.WeakChangeListener; import javafx.scene.input.KeyCode; import javafx.scene.input.KeyEvent; import jgnash.engine.MathConstants; import jgnash.uifx.util.JavaFXUtils; import jgnash.util.MathEval; import jgnash.util.NotNull; /** * Text field for entering decimal values. * * @author Craig Cavanaugh */ public class DecimalTextField extends TextFieldEx { private static final int DEFAULT_SCALE = 2; /** * Allowable character in input. */ private static final String FLOAT; /** * Allowable math operators in input. */ private static final String MATH_OPERATORS = "()+*/"; private static char group = ','; private static char fraction = '.'; /** * Used for output of parsed input. */ private final NumberFormat format; /** * Used to track state of fractional SEPARATOR input on numeric pad. */ private volatile boolean forceFraction = false; // the property value may be null private final ObjectProperty decimal = new SimpleObjectProperty<>(); private final SimpleDoubleProperty doubleValue = new SimpleDoubleProperty(); /** * Controls the maximum number of displayed decimal places. */ private final SimpleIntegerProperty scale = new SimpleIntegerProperty(); /** * Controls the minimum number of displayed decimal places. */ private final SimpleIntegerProperty minScale = new SimpleIntegerProperty(); /** * Displays an empty field if {@code decimalProperty} is zero. */ private final BooleanProperty emptyWhenZero = new SimpleBooleanProperty(true); private final BooleanProperty isValid = new SimpleBooleanProperty(false); /** * Reference is needed to prevent premature garbage collection. */ @SuppressWarnings("FieldCanBeLocal") private final ChangeListener focusChangeListener; @SuppressWarnings("FieldCanBeLocal") private final ChangeListener validValueListener; static { FLOAT = getAllowedChars(); } public DecimalTextField() { format = NumberFormat.getInstance(); /* Disable grouping for output formatting on all locales. * This solves issues with parsing out group separators. * This does not prevent parsing grouping input. */ format.setGroupingUsed(false); // Force evaluation on loss of focus focusChangeListener = (observable, oldValue, newValue) -> { if (!newValue) { evaluateAndSet(); } }; // Listen to any text changes to determine if it validValueListener = (observable, oldValue, newValue) -> { try { // Replace any commas with decimals before trying to parse the string Double.parseDouble(newValue.replace(',', '.')); isValid.setValue(true); } catch (Exception e) { isValid.setValue(false); } }; focusedProperty().addListener(new WeakChangeListener<>(focusChangeListener)); textProperty().addListener(new WeakChangeListener<>(validValueListener)); addEventFilter(KeyEvent.KEY_PRESSED, event -> { //Raise a flag the Decimal key has been pressed so it can be // forced to a comma or period based on locale if (event.getCode() == KeyCode.DECIMAL) { forceFraction = true; } // For evaluation if the enter key is pressed if (event.getCode() == KeyCode.ENTER) { JavaFXUtils.runLater(() -> { evaluateAndSet(); positionCaret(getText().length()); }); } }); decimalProperty().addListener((observable, oldValue, newValue) -> { if (newValue == null || (emptyWhenZeroProperty().get() && newValue.compareTo(BigDecimal.ZERO) == 0)) { setText(""); } else { setText(format.format(newValue.doubleValue())); } }); // Change the max and minimum scale allowed for entry scale.addListener((observable, oldValue, newValue) -> { if (format instanceof DecimalFormat) { format.setMaximumFractionDigits(scale.get()); } evaluateAndSet(); }); minScale.addListener((observable, oldValue, newValue) -> { if (format instanceof DecimalFormat) { format.setMinimumFractionDigits(minScale.get()); } evaluateAndSet(); }); scale.set(DEFAULT_SCALE); // trigger update to the format minScale.set(DEFAULT_SCALE); } public ObjectProperty decimalProperty() { return decimal; } /** * {@code ReadOnlyDoubleProperty} representation of the {@code BigDecimal} property to make bindings and error checking easier * * @return ReadOnlyDoubleProperty */ public ReadOnlyDoubleProperty doubleProperty() { return ReadOnlyDoubleProperty.readOnlyDoubleProperty(doubleValue); } /** * Property indicating if the field contains a valid numeric value * * @return ReadOnlyBooleanProperty */ public ReadOnlyBooleanProperty validDecimalProperty() { return ReadOnlyBooleanProperty.readOnlyBooleanProperty(isValid); } public IntegerProperty scaleProperty() { return scale; } public IntegerProperty minScaleProperty() { return minScale; } private void evaluateAndSet() { final String t = evaluateInput(); if (!t.isEmpty()) { // round the value to scale setDecimal(new BigDecimal(t).setScale(scale.get(), MathConstants.roundingMode)); } else { setDecimal(BigDecimal.ZERO); } } /** * Sets the decimal value for the field. * * @param decimal {@code BigDecimal} */ public void setDecimal(@NotNull final BigDecimal decimal) { Objects.requireNonNull(decimal); this.decimal.set(decimal.setScale(scale.get(), MathConstants.roundingMode)); doubleValue.setValue(this.decimal.getValue().doubleValue()); // set the double property } public @NotNull BigDecimal getDecimal() { if (!isEmpty()) { try { return new BigDecimal(evaluateInput()); } catch (final NumberFormatException ignored) { // ignore and drop out } } return BigDecimal.ZERO; } /** * Determines if the field is empty. * * @return {@code false} if empty */ private boolean isEmpty() { boolean result = true; if (getText() != null) { result = getText().isEmpty(); } return result; } @Override public void deleteText(int start, int end) { super.replaceText(start, end, ""); } @Override public void replaceText(final int start, final int end, final String text) { Objects.requireNonNull(text); final String newText = getText().substring(0, start) + text + getText().substring(end); /* fraction input is handled as a special case */ if (forceFraction) { super.replaceText(start, end, Character.toString(fraction)); forceFraction = false; return; } for (int i = 0; i < newText.length(); i++) { if (!FLOAT.contains(String.valueOf(newText.charAt(i)))) { return; } } super.replaceText(start, end, text); } @Override public void replaceSelection(final String text) { final int start = getSelection().getStart(); final int end = getSelection().getEnd(); final String newText = getText().substring(0, start) + text + getText().substring(end); for (int j = 0; j < newText.length(); j++) { if (!FLOAT.contains(String.valueOf(newText.charAt(j)))) { return; } } super.replaceSelection(text); positionCaret(end); } /** * By default, and numeric values, basic math operators, and '.' and ',' are * allowed. * * @return A string with the characters that are allowed in math expressions */ private static String getAllowedChars() { // get grouping and fractional separators final NumberFormat format = NumberFormat.getInstance(); if (format instanceof DecimalFormat) { group = ((DecimalFormat) format).getDecimalFormatSymbols().getGroupingSeparator(); fraction = ((DecimalFormat) format).getDecimalFormatSymbols().getDecimalSeparator(); } // doctor up some locales so numeric pad works if (group != '.' && fraction == ',') { // grouping symbol is odd group = '.'; } return "-0123456789" + group + fraction + MATH_OPERATORS; } /** * BigDecimal and the interpreter cannot parse ',' in string * representations of decimals. This method will replace any ',' with '.' * and then try parsing with BigDecimal. If this fails, then it is assumed * that the user has used mathematical operators and then evaluates the * string as a mathematical expression. * * @return A string representation of the resulting decimal */ @NotNull private String evaluateInput() { String text = getText(); if (text == null || text.isEmpty()) { return ""; } // strip out any group separators (This could be '.' for certain locales) final StringBuilder temp = new StringBuilder(); for (int i = 0; i < text.length(); i++) { char c = text.charAt(i); if (c != group) { temp.append(c); } } text = temp.toString(); // replace any ',' with periods so that it can be parsed correctly if (fraction == ',') { text = text.replace(',', '.'); } try { doubleValue.setValue(new BigDecimal(text).doubleValue()); // try to set the double property return new BigDecimal(text).toString(); } catch (final NumberFormatException nfe) { try { final double val = MathEval.eval(text); if (!Double.isNaN(val)) { final BigDecimal value = new BigDecimal(val); setDecimal(value); return value.toString(); } doubleValue.set(Double.NaN); return ""; } catch (final ArithmeticException ex) { return ""; } } } public BooleanProperty emptyWhenZeroProperty() { return emptyWhenZero; } } ================================================ FILE: jgnash-fx/src/main/java/jgnash/uifx/control/DetailedDecimalTextField.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.uifx.control; import java.math.BigDecimal; import java.util.Objects; import javafx.beans.property.BooleanProperty; import javafx.beans.property.ObjectProperty; import javafx.beans.property.ReadOnlyObjectProperty; import javafx.beans.property.ReadOnlyObjectWrapper; import javafx.beans.property.SimpleBooleanProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.css.PseudoClass; import javafx.scene.control.Button; import javafx.scene.layout.ColumnConstraints; import javafx.scene.layout.GridPane; import javafx.scene.layout.Priority; import javafx.scene.layout.Region; import jgnash.uifx.resource.font.MaterialDesignLabel; import jgnash.util.NotNull; /** * A {@code DecimalTextField} composite that supports use of a popup or dialog by overriding {@code show()}. * * @author Craig Cavanaugh */ public class DetailedDecimalTextField extends GridPane { /** * The editor for the ComboBox. It is used for both editable text field and non-editable text field. */ private ReadOnlyObjectWrapper editor; public ObjectProperty valueProperty() { return value; } private final ObjectProperty value = new SimpleObjectProperty<>(this, "value"); /** * Specifies whether the numeric field allows for user input. * * @return the editable property */ protected final BooleanProperty editableProperty() { return editable; } private final BooleanProperty editable = new SimpleBooleanProperty(this, "editable", true) { @Override protected void invalidated() { pseudoClassStateChanged(PSEUDO_CLASS_EDITABLE, get()); } }; public DetailedDecimalTextField() { // apply the choice box and date picker styles to the pane getStyleClass().setAll("choice-box", "date-picker"); final Button arrowButton = new Button("", new MaterialDesignLabel(MaterialDesignLabel.MDIcon.PENCIL)); arrowButton.getStyleClass().setAll("button", "arrow-button"); arrowButton.setFocusTraversable(false); arrowButton.setOnAction(event -> show()); final ColumnConstraints col = new ColumnConstraints(); col.setHgrow(Priority.ALWAYS); getColumnConstraints().addAll(col); // add the controls add(getEditor(), 0, 0); add(arrowButton, 1, 0); // we want the GridPane to wrap right around the controls setMaxSize(Region.USE_PREF_SIZE, Region.USE_PREF_SIZE); // use the focused state of the text field and apply to the pane getEditor().focusedProperty().addListener((observable, oldValue, newValue) -> pseudoClassStateChanged(FOCUSED_PSEUDO_CLASS, newValue)); } private DecimalTextField getEditor() { return editorProperty().get(); } private ReadOnlyObjectProperty editorProperty() { if (editor == null) { editor = new ReadOnlyObjectWrapper<>(this, "editor"); //NON-NLS DecimalTextField field = new DecimalTextField(); field.decimalProperty().bindBidirectional(valueProperty()); field.editableProperty().bindBidirectional(editableProperty()); editor.set(field); } return editor.getReadOnlyProperty(); } /** * Decimal property. * * @return BigDecimal object property * @see DecimalTextField#decimalProperty() */ public ObjectProperty decimalProperty() { return getEditor().decimalProperty(); } /** * Gets the decimal value. * * @return BigDecimal value * @see DecimalTextField#getDecimal() */ public @NotNull BigDecimal getDecimal() { return getEditor().getDecimal(); } /** * Sets the value for the field. * * @param decimal BigDecimal value to display. May not be null * @see DecimalTextField#setDecimal(BigDecimal) */ protected void setDecimal(@NotNull final BigDecimal decimal) { Objects.requireNonNull(decimal); getEditor().setDecimal(decimal); } /** * Called when the button is clicked. */ public void show() { // does nothing by default } private static final PseudoClass FOCUSED_PSEUDO_CLASS = PseudoClass.getPseudoClass("focused"); private static final PseudoClass PSEUDO_CLASS_EDITABLE = PseudoClass.getPseudoClass("editable"); } ================================================ FILE: jgnash-fx/src/main/java/jgnash/uifx/control/DoughnutChart.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.uifx.control; import javafx.beans.property.SimpleStringProperty; import javafx.beans.property.StringProperty; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import javafx.scene.Node; import javafx.scene.chart.PieChart; import javafx.scene.layout.Pane; import javafx.scene.shape.Circle; import javafx.scene.text.Text; import javafx.scene.text.TextAlignment; /** * Doughnut Chart implementation. * * @author Craig Cavanaugh */ public class DoughnutChart extends PieChart { private static final int RING_WIDTH = 3; private final Circle hole; private final Text titleText; private final Text subTitleText; private final StringProperty centerTitle = new SimpleStringProperty(); private final StringProperty centerSubTitle = new SimpleStringProperty(); @SuppressWarnings("unused") public DoughnutChart() { this(FXCollections.observableArrayList()); } private DoughnutChart(final ObservableList data) { super(data); hole = new Circle(); hole.setStyle("-fx-fill: -fx-background"); titleText = new Text(); titleText.setStyle("-fx-font-size: 1.4em"); titleText.setTextAlignment(TextAlignment.JUSTIFY); titleText.textProperty().bind(centerTitle); subTitleText = new Text(); subTitleText.setStyle("-fx-font-size: 1.0em"); subTitleText.setTextAlignment(TextAlignment.JUSTIFY); subTitleText.textProperty().bind(centerSubTitle); } public StringProperty centerTitleProperty() { return centerTitle; } public StringProperty centerSubTitleProperty() { return centerSubTitle; } @Override protected void layoutChartChildren(final double top, final double left, final double contentWidth, final double contentHeight) { super.layoutChartChildren(top, left, contentWidth, contentHeight); installContent(); updateLayout(); } private void installContent() { if (!getData().isEmpty()) { final Node node = getData().get(0).getNode(); if (node.getParent() instanceof Pane) { final Pane parent = (Pane) node.getParent(); // The content needs to be reordered after data has changed if (parent.getChildren().contains(hole)) { parent.getChildren().remove(hole); parent.getChildren().remove(titleText); parent.getChildren().remove(subTitleText); } if (!parent.getChildren().contains(hole)) { parent.getChildren().add(hole); parent.getChildren().add(titleText); parent.getChildren().add(subTitleText); } } } } private void updateLayout() { // Determine maximums and minimums, make use of available processors final double minX = getData().parallelStream().mapToDouble(value -> value.getNode().getBoundsInParent() .getMinX()).min().orElse(0); final double minY = getData().parallelStream().mapToDouble(value -> value.getNode().getBoundsInParent() .getMinY()).min().orElse(0); final double maxX = getData().parallelStream().mapToDouble(value -> value.getNode().getBoundsInParent() .getMaxX()).max().orElse(Double.MAX_VALUE); final double maxY = getData().parallelStream().mapToDouble(value -> value.getNode().getBoundsInParent() .getMaxY()).max().orElse(Double.MAX_VALUE); // center the hole and set radius hole.setCenterX(minX + (maxX - minX) / 2); hole.setCenterY(minY + (maxY - minY) / 2); hole.setRadius((maxX - minX) / RING_WIDTH); // center the title and subtitle titleText.setX((minX + (maxX - minX) / 2) - titleText.getLayoutBounds().getWidth() / 2); titleText.setY(minY + (maxY - minY) / 2); subTitleText.setX((minX + (maxX - minX) / 2) - subTitleText.getLayoutBounds().getWidth() / 2); subTitleText.setY((minY + (maxY - minY) / 2) + subTitleText.getLayoutBounds().getHeight()); } } ================================================ FILE: jgnash-fx/src/main/java/jgnash/uifx/control/ExceptionDialog.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.uifx.control; import java.io.PrintWriter; import java.io.StringWriter; import java.util.HashMap; import java.util.Map; import java.util.ResourceBundle; import javafx.beans.property.ObjectProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.fxml.FXML; import javafx.scene.Scene; import javafx.scene.control.Button; import javafx.scene.control.Label; import javafx.scene.control.TextArea; import javafx.scene.input.Clipboard; import javafx.scene.input.DataFormat; import javafx.scene.paint.Color; import javafx.stage.Stage; import jgnash.resource.util.ResourceUtils; import jgnash.uifx.resource.font.MaterialDesignLabel; import jgnash.uifx.skin.ThemeManager; import jgnash.uifx.util.FXMLUtils; import jgnash.uifx.util.InjectFXML; /** * Exception dialog. * * @author Craig Cavanaugh */ public class ExceptionDialog { @InjectFXML private final ObjectProperty parent = new SimpleObjectProperty<>(); @FXML private Button clipboardButton; @FXML private TextArea textArea; @FXML private Button closeButton; @FXML private Label message; private final Stage dialog; private String stackTrace; private final Throwable throwable; public ExceptionDialog(final Throwable throwable) { final ResourceBundle resources = ResourceUtils.getBundle(); this.throwable = throwable; dialog = FXMLUtils.loadFXML(this, "ExceptionDialog.fxml", resources); dialog.setTitle(resources.getString("Title.UncaughtException")); } @FXML private void initialize() { message.setGraphic(new MaterialDesignLabel(MaterialDesignLabel.MDIcon.EXCLAMATION_TRIANGLE, ThemeManager.getBaseTextHeight() * Alert.HEIGHT_MULTIPLIER, Color.DARKRED)); closeButton.setOnAction(event -> ((Stage) parent.get().getWindow()).close()); clipboardButton.setOnAction(event -> { Clipboard clipboard = Clipboard.getSystemClipboard(); Map map = new HashMap<>(); map.put(DataFormat.PLAIN_TEXT, stackTrace); clipboard.setContent(map); }); } public void showAndWait() { message.setText(throwable.getLocalizedMessage()); final StringWriter sw = new StringWriter(); throwable.printStackTrace(new PrintWriter(sw)); stackTrace = sw.toString(); textArea.setText(stackTrace); dialog.setResizable(false); dialog.showAndWait(); } } ================================================ FILE: jgnash-fx/src/main/java/jgnash/uifx/control/ImageDialog.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.uifx.control; import java.net.MalformedURLException; import java.nio.file.Files; import java.nio.file.Path; import java.util.ResourceBundle; import java.util.logging.Level; import java.util.logging.Logger; import javafx.scene.Scene; import javafx.scene.control.Button; import javafx.scene.control.ToolBar; import javafx.scene.image.Image; import javafx.scene.image.ImageView; import javafx.scene.layout.BorderPane; import javafx.scene.layout.StackPane; import javafx.stage.Modality; import javafx.stage.Stage; import javafx.stage.StageStyle; import jgnash.resource.util.ResourceUtils; import jgnash.uifx.StaticUIMethods; import jgnash.uifx.skin.ThemeManager; import jgnash.uifx.util.JavaFXUtils; import jgnash.uifx.util.StageUtils; import jgnash.uifx.views.main.MainView; /** * A dialog for displaying and printing an image. * * @author Craig Cavanaugh */ public class ImageDialog { private static final int MARGIN = 20; private static final int MIN_SIZE = 100; private final Stage dialog; private final ImageView imageView = new ImageView(); private final StatusBar statusBar = new StatusBar(); public static void showImage(final Path path) { final ImageDialog imageDialog = new ImageDialog(); imageDialog.setImage(path); imageDialog.dialog.show(); } private ImageDialog() { final ResourceBundle resources = ResourceUtils.getBundle(); dialog = new Stage(StageStyle.DECORATED); dialog.initModality(Modality.APPLICATION_MODAL); dialog.initOwner(MainView.getPrimaryStage()); dialog.setTitle(resources.getString("Title.ViewImage")); // Set a sane default size dialog.setWidth(450); dialog.setHeight(350); final BorderPane borderPane = new BorderPane(); final ToolBar toolBar = new ToolBar(); final Button printButton = new Button(resources.getString("Button.Print")); printButton.setOnAction(event -> JavaFXUtils.printImageView(imageView)); toolBar.getItems().add(printButton); final StackPane stackPane = new StackPane(); stackPane.getChildren().add(imageView); stackPane.setMinSize(MIN_SIZE, MIN_SIZE); imageView.fitWidthProperty().bind(stackPane.widthProperty().subtract(MARGIN)); imageView.fitHeightProperty().bind(stackPane.heightProperty().subtract(MARGIN)); borderPane.setTop(toolBar); borderPane.setCenter(stackPane); borderPane.setBottom(statusBar); borderPane.styleProperty().bind(ThemeManager.styleProperty()); dialog.setScene(new Scene(borderPane)); ThemeManager.applyStyleSheets(dialog.getScene()); // Remember dialog size and location StageUtils.addBoundsListener(dialog, ImageDialog.class, MainView.getPrimaryStage()); } private void setImage(final Path path) { if (Files.exists(path)) { try { final Image image = new Image(path.toUri().toURL().toString(), true); imageView.setPreserveRatio(true); imageView.setImage(image); statusBar.textProperty().set(path.toString()); } catch (final MalformedURLException e) { Logger.getLogger(getClass().getName()).log(Level.SEVERE, e.getMessage(), e); } } else { StaticUIMethods.displayError(ResourceUtils.getString("Message.Error.MissingAttachment", path.toString())); } } } ================================================ FILE: jgnash-fx/src/main/java/jgnash/uifx/control/IntegerTextField.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.uifx.control; import java.util.regex.Pattern; /** * Text field for entering integer values. * * @author Craig Cavanaugh */ public class IntegerTextField extends TextFieldEx { private static final String DIGITS_ONLY_REGEX = "\\b\\d+\\b"; private final Pattern digitOnlyPattern = Pattern.compile(DIGITS_ONLY_REGEX); public IntegerTextField() { } /** * Sets the {@code Integer} value of the field. * * @param value {@code Integer} value, if null, the field will be cleared */ public void setInteger(final Integer value) { if (value != null) { setText(value.toString()); } else { setText(""); } } /** * Sets the {@code Long} value of the field. * * @param value {@code Long} value, if null, the field will be cleared */ public void setLong(final Long value) { if (value != null) { setText(value.toString()); } else { setText(""); } } /** * Returns the {@code Integer} value of the field. * * @return the {@code Integer}, zero if the field is empty */ public Integer getInteger() { if (getText() != null && getText().length() > 0) { return Integer.parseInt(getText()); } return 0; } /** * Returns the {@code Long} value of the field. * * @return the {@code Long}, zero if the field is empty */ public Long getLong() { if (getText() != null && getText().length() > 0) { return Long.parseLong(getText()); } return 0L; } @Override public void deleteText(final int start, final int end) { super.replaceText(start, end, ""); } @Override public void replaceText(final int start, final int end, final String text) { // If the replaced text would end up being invalid, then simply // ignore this call! if (digitOnlyPattern.matcher(text).matches()) { super.replaceText(start, end, text); } } @Override public void replaceSelection(final String text) { if (digitOnlyPattern.matcher(text).matches()) { super.replaceSelection(text); } } } ================================================ FILE: jgnash-fx/src/main/java/jgnash/uifx/control/IntegerTreeTableCell.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received account copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.uifx.control; import javafx.beans.value.ChangeListener; import javafx.beans.value.WeakChangeListener; import javafx.scene.control.TreeTableCell; import javafx.scene.input.KeyCode; /** * A class containing a {@link TreeTableCell} implementation that draws a {@link IntegerTextField} node inside the cell. *

* By default, the IntegerTreeTableCell is rendered as a {@link javafx.scene.control.Label} when not being edited, and * as a IntegerTextField when in editing mode. * * @author Craig Cavanaugh */ public class IntegerTreeTableCell extends TreeTableCell { private IntegerTextField integerTextField = null; /** * Reference is needed to prevent premature garbage collection. */ @SuppressWarnings("FieldCanBeLocal") private ChangeListener focusChangeListener; @Override protected void updateItem(final Integer amount, final boolean empty) { super.updateItem(amount, empty); // required if (empty) { setText(null); setGraphic(null); } else { if (isEditing()) { setText(null); getIntegerTextField().setInteger(getItem()); setGraphic(getIntegerTextField()); } else if (amount != null) { setText(amount.toString()); setGraphic(null); } else { setText(null); setGraphic(null); } } } @Override public void startEdit() { if (!isEditable() || !getTreeTableView().isEditable() || !getTableColumn().isEditable()) { return; } final IntegerTextField decimalTextField = getIntegerTextField(); decimalTextField.setInteger(getItem()); super.startEdit(); setText(null); setGraphic(decimalTextField); decimalTextField.requestFocus(); } @Override public void cancelEdit() { super.cancelEdit(); setText(getItem().toString()); setGraphic(null); } private IntegerTextField getIntegerTextField() { if (integerTextField == null) { integerTextField = new IntegerTextField(); integerTextField.setOnKeyPressed(event -> { if (isEditing() && event.getCode() == KeyCode.ENTER) { commitEdit(integerTextField.getInteger()); } }); focusChangeListener = (observable, oldValue, newValue) -> { if (isEditing() && !newValue) { commitEdit(integerTextField.getInteger()); } }; integerTextField.focusedProperty().addListener(new WeakChangeListener<>(focusChangeListener)); } return integerTextField; } } ================================================ FILE: jgnash-fx/src/main/java/jgnash/uifx/control/LockedCommodityListCell.java ================================================ package jgnash.uifx.control; import javafx.scene.control.ListCell; import jgnash.engine.CommodityNode; import jgnash.uifx.skin.StyleClass; import jgnash.util.LockedCommodityNode; /** * Provides visual feedback that items are locked and may not be moved. * * @author Craig Cavanaugh */ public class LockedCommodityListCell extends ListCell> { @Override public void updateItem(final LockedCommodityNode item, final boolean empty) { super.updateItem(item, empty); // required if (!empty) { if (item.isLocked()) { setId(StyleClass.DISABLED_CELL_ID); setDisable(true); } else { setId(StyleClass.ENABLED_CELL_ID); setDisable(false); } setText(item.toString()); } else { setText(""); } } } ================================================ FILE: jgnash-fx/src/main/java/jgnash/uifx/control/NullTableViewSelectionModel.java ================================================ package jgnash.uifx.control; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import javafx.scene.control.TableColumn; import javafx.scene.control.TablePosition; import javafx.scene.control.TableView; /** * Disables selection. * * @author Craig Cavanaugh */ public class NullTableViewSelectionModel extends TableView.TableViewSelectionModel { public NullTableViewSelectionModel(TableView tableView) { super(tableView); } @SuppressWarnings("rawtypes") @Override public ObservableList getSelectedCells() { return FXCollections.emptyObservableList(); } @Override public void clearSelection(int row, TableColumn column) { } @Override public void clearAndSelect(int row, TableColumn column) { } @Override public void select(int row, TableColumn column) { } @Override public boolean isSelected(int row, TableColumn column) { return false; } @Override public void selectLeftCell() { } @Override public void selectRightCell() { } @Override public void selectAboveCell() { } @Override public void selectBelowCell() { } public void selectIndices(int row, int... rows) { } @Override public void selectAll() { } @Override public void clearSelection(int index) { } @Override public void clearSelection() { } } ================================================ FILE: jgnash-fx/src/main/java/jgnash/uifx/control/PopOverButton.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.uifx.control; import javafx.beans.property.ObjectProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.geometry.Pos; import javafx.scene.Node; import javafx.scene.control.MenuButton; import javafx.scene.control.MenuItem; import javafx.scene.layout.VBox; import jgnash.uifx.skin.StyleClass; /** * Pop Over Button control * * @author Craig Cavanaugh */ public class PopOverButton extends MenuButton { @SuppressWarnings("unused") public PopOverButton() { this(null); } public PopOverButton(final Node graphic) { super(null, graphic, (MenuItem[])null); getStyleClass().add(StyleClass.POP_OVER_BUTTON); } private final ObjectProperty contentNode = new SimpleObjectProperty<>(this, "contentNode"); /** * Returns the value of the content property * * @return the content node */ @SuppressWarnings("unused") public final Node getContentNode() { return contentNode.get(); } /** * Sets the value of the content property. * * @param content the new content node value */ public final void setContentNode(final Node content) { contentNode.set(content); final VBox vBox = new VBox(5); vBox.setAlignment(Pos.CENTER); vBox.getChildren().setAll(content); final MenuItem menuItem = new MenuItem(); menuItem.setGraphic(vBox); getItems().setAll(menuItem); } } ================================================ FILE: jgnash-fx/src/main/java/jgnash/uifx/control/QuoteSourceComboBox.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.uifx.control; import javafx.collections.ObservableList; import javafx.collections.transformation.SortedList; import javafx.scene.control.ComboBox; import jgnash.engine.QuoteSource; import jgnash.uifx.util.JavaFXUtils; /** * ComboBox that allows selection of a {@code QuoteSource}. * * @author Craig Cavanaugh */ public class QuoteSourceComboBox extends ComboBox { public QuoteSourceComboBox() { setEditable(false); JavaFXUtils.runLater(this::loadModel); // lazy load to let the ui build happen faster } private void loadModel() { // extract and reuse the default model final ObservableList items = getItems(); // warp in a sorted list setItems(new SortedList<>(items, null)); items.addAll(QuoteSource.values()); setValue(QuoteSource.YAHOO); } } ================================================ FILE: jgnash-fx/src/main/java/jgnash/uifx/control/SecurityComboBox.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.uifx.control; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.Objects; import javafx.beans.property.ObjectProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.collections.ObservableList; import javafx.collections.transformation.SortedList; import javafx.scene.control.ComboBox; import jgnash.engine.Account; import jgnash.engine.Engine; import jgnash.engine.EngineFactory; import jgnash.engine.SecurityNode; import jgnash.engine.message.Message; import jgnash.engine.message.MessageBus; import jgnash.engine.message.MessageChannel; import jgnash.engine.message.MessageListener; import jgnash.engine.message.MessageProperty; import jgnash.uifx.util.JavaFXUtils; /** * ComboBox that allows selection of a SecurityNode and manages it's own model. *

* The default operation is to load all known {@code SecurityNodes}. If the * {@code accountProperty} is set, then only the account's {@code SecurityNodes} * will be available for selection. * * @author Craig Cavanaugh */ public class SecurityComboBox extends ComboBox implements MessageListener { /** * Model for the ComboBox. */ final private ObservableList items; final private ObjectProperty account = new SimpleObjectProperty<>(); public SecurityComboBox() { // extract and reuse the default model items = getItems(); // warp in a sorted list setItems(new SortedList<>(items, null)); JavaFXUtils.runLater(this::loadModel); // lazy load to let the ui build happen faster account.addListener((observable, oldValue, newValue) -> loadModel()); MessageBus.getInstance().registerListener(this, MessageChannel.ACCOUNT, MessageChannel.COMMODITY, MessageChannel.SYSTEM); } public void setSecurityNode(final SecurityNode securityNode) { JavaFXUtils.runLater(() -> setValue(securityNode)); } private void loadModel() { final Collection securityNodes; if (account.get() != null) { items.clear(); securityNodes = account.get().getSecurities(); } else { final Engine engine = EngineFactory.getEngine(EngineFactory.DEFAULT); Objects.requireNonNull(engine); securityNodes = engine.getSecurities(); } if (!securityNodes.isEmpty()) { final List sortedNodeList = new ArrayList<>(securityNodes); Collections.sort(sortedNodeList); items.addAll(sortedNodeList); getSelectionModel().select(0); } } @Override public void messagePosted(final Message event) { if (event.getObject(MessageProperty.COMMODITY) instanceof SecurityNode) { final SecurityNode node = event.getObject(MessageProperty.COMMODITY); final Account account = event.getObject(MessageProperty.ACCOUNT); JavaFXUtils.runLater(() -> { switch (event.getEvent()) { case ACCOUNT_SECURITY_ADD: if (account != null && account.equals(this.account.get())) { final int index = Collections.binarySearch(items, node); if (index < 0) { items.add(-index -1, node); } } break; case ACCOUNT_SECURITY_REMOVE: if (account != null && account.equals(this.account.get())) { items.removeAll(node); } break; case SECURITY_REMOVE: items.removeAll(node); break; case SECURITY_ADD: final int index = Collections.binarySearch(items, node); if (index < 0) { items.add(-index -1, node); } break; case SECURITY_MODIFY: items.removeAll(node); final int i = Collections.binarySearch(items, node); if (i < 0) { items.add(-i - 1, node); } break; case FILE_CLOSING: items.clear(); default: break; } }); } } public ObjectProperty accountProperty() { return account; } } ================================================ FILE: jgnash-fx/src/main/java/jgnash/uifx/control/SecurityHistoryEventTypeComboBox.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.uifx.control; import javafx.collections.ObservableList; import javafx.collections.transformation.SortedList; import javafx.scene.control.ComboBox; import jgnash.engine.SecurityHistoryEventType; import jgnash.uifx.util.JavaFXUtils; /** * ComboBox that allows selection of a {@code SecurityHistoryEventType}. * * @author Craig Cavanaugh */ public class SecurityHistoryEventTypeComboBox extends ComboBox { public SecurityHistoryEventTypeComboBox() { setEditable(false); JavaFXUtils.runLater(this::loadModel); // lazy load to let the ui build happen faster } private void loadModel() { // extract and reuse the default model ObservableList items = getItems(); // warp in a sorted list setItems(new SortedList<>(items, null)); items.addAll(SecurityHistoryEventType.DIVIDEND, SecurityHistoryEventType.SPLIT); setValue(SecurityHistoryEventType.DIVIDEND); } } ================================================ FILE: jgnash-fx/src/main/java/jgnash/uifx/control/SecurityNodeAreaChart.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.uifx.control; import java.time.LocalDate; import java.time.format.DateTimeFormatter; import java.util.List; import java.util.Optional; import javafx.beans.property.SimpleObjectProperty; import javafx.scene.chart.AreaChart; import javafx.scene.chart.NumberAxis; import javafx.util.StringConverter; import jgnash.engine.SecurityHistoryNode; import jgnash.engine.SecurityNode; import jgnash.time.DateUtils; import jgnash.uifx.Options; import jgnash.uifx.util.JavaFXUtils; /** * Extends and AreaChart to encapsulate generation of a SecurityNode chart. * * @author Craig Cavanaugh */ public class SecurityNodeAreaChart extends AreaChart { private static final int TICK_MARKS = 14; private final SimpleObjectProperty securityNodeProperty = new SimpleObjectProperty<>(); private final NumberAxis xAxis; public SecurityNodeAreaChart() { super(new NumberAxis(), new NumberAxis()); setCreateSymbols(false); setLegendVisible(false); animatedProperty().bind(Options.animationsEnabledProperty()); xAxis = (NumberAxis) getXAxis(); xAxis.setTickLabelFormatter(new NumberDateStringConverter()); xAxis.tickLabelRotationProperty().set(-60); xAxis.setAutoRanging(false); securityNodeProperty().addListener((observable, oldValue, newValue) -> update()); } public SimpleObjectProperty securityNodeProperty() { return securityNodeProperty; } public void update() { JavaFXUtils.runLater(getData()::clear); // clear old data new Thread(() -> { final SecurityNode securityNode = securityNodeProperty().get(); if (securityNode != null) { // protect against an NPE caused by security rename final Optional optional = securityNode.getLocalDateBounds(); optional.ifPresent(localDates -> { final List> groups = securityNode.getHistoryNodeGroupsBySplits(); for (int i = 0; i < groups.size(); i++) { final Series series = new Series<>(); series.setName(securityNode.getSymbol() + i); for (final SecurityHistoryNode node : groups.get(i)) { series.getData().add(new Data<>(node.getLocalDate().toEpochDay(), node.getAdjustedPrice())); } JavaFXUtils.runLater(() -> getData().add(series)); } xAxis.setLowerBound(localDates[0].toEpochDay()); xAxis.setUpperBound(localDates[1].toEpochDay()); final long range = localDates[1].toEpochDay() - localDates[0].toEpochDay(); if (range > TICK_MARKS) { xAxis.setTickUnit((int)((localDates[1].toEpochDay() - localDates[0].toEpochDay()) / TICK_MARKS)); } else { xAxis.setTickUnit(range - 1); } }); } }).start(); } private static class NumberDateStringConverter extends StringConverter { final DateTimeFormatter formatter = DateUtils.getShortDateFormatter(); @Override public String toString(final Number value) { return formatter.format(LocalDate.ofEpochDay(value.longValue())); } @Override public Number fromString(final String string) { return LocalDate.parse(string, formatter).toEpochDay(); } } } ================================================ FILE: jgnash-fx/src/main/java/jgnash/uifx/control/ShortDateTableCell.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received account copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.uifx.control; import java.time.LocalDate; import java.time.format.DateTimeFormatter; import javafx.scene.control.TableCell; import jgnash.time.DateUtils; /** * {@code TableCell} for {@code Dates} formatted in a simplified manner. * * @author Craig Cavanaugh */ public class ShortDateTableCell extends TableCell { private final DateTimeFormatter dateTimeFormatter = DateUtils.getShortDateFormatter(); @Override protected void updateItem(final LocalDate date, final boolean empty) { super.updateItem(date, empty); // required if (!empty && date != null) { setText(dateTimeFormatter.format(date)); } else { setText(null); } } } ================================================ FILE: jgnash-fx/src/main/java/jgnash/uifx/control/StatusBar.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.uifx.control; import javafx.beans.binding.Bindings; import javafx.beans.property.DoubleProperty; import javafx.beans.property.ObjectProperty; import javafx.beans.property.SimpleDoubleProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.beans.property.SimpleStringProperty; import javafx.beans.property.StringProperty; import javafx.scene.Node; import javafx.scene.control.Label; import javafx.scene.control.ProgressIndicator; import javafx.scene.layout.BorderPane; import javafx.scene.layout.StackPane; import jgnash.uifx.skin.ThemeManager; import jgnash.resource.util.ResourceUtils; /** * Status bar. * * @author Craig Cavanaugh */ public class StatusBar extends StackPane { private final StringProperty text = new SimpleStringProperty(this, "text"); private final ObjectProperty graphic = new SimpleObjectProperty<>(this, "graphic"); private final DoubleProperty progress = new SimpleDoubleProperty(this, "progress"); public StatusBar() { getStyleClass().add("status-bar"); final Label label = new Label(); label.setMaxSize(Double.MAX_VALUE, Double.MAX_VALUE); label.textProperty().bind(textProperty()); label.graphicProperty().bind(graphicProperty()); label.getStyleClass().add("status-label"); label.textFillProperty().bind(ThemeManager.controlTextFillProperty()); textProperty().set(ResourceUtils.getString("Button.Ok")); final ProgressIndicator progressBar = new ProgressIndicator(); progressBar.progressProperty().bind(progressProperty()); progressBar.visibleProperty().bind(Bindings.notEqual(0, progressProperty())); progressBar.maxHeightProperty().bind(heightProperty()); final BorderPane borderPane = new BorderPane(); borderPane.setCenter(label); borderPane.setRight(progressBar); getChildren().add(borderPane); } /** * The property used for storing the text message shown by the status bar. * * @return the text message property */ public final StringProperty textProperty() { return text; } /** * The property used to store a graphic node that can be displayed by the * status label inside the status bar control. * * @return the property used for storing a graphic node */ public final ObjectProperty graphicProperty() { return graphic; } /** * The property used to store the progress, a value between 0 and 1. A negative * value causes the progress indicator to show an indeterminate state. * * @return the property used to store the progress of a task */ public final DoubleProperty progressProperty() { return progress; } } ================================================ FILE: jgnash-fx/src/main/java/jgnash/uifx/control/TabViewPane.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.uifx.control; import java.io.IOException; import javafx.fxml.FXMLLoader; import javafx.geometry.Side; import javafx.scene.Node; import javafx.scene.control.Tab; import javafx.scene.control.TabPane; import javafx.scene.control.TitledPane; import javafx.scene.layout.BorderPane; /** * Extended TabPane for consistent view creation. * * @author Craig Cavanaugh */ public class TabViewPane extends TabPane { private static final String VIEW_TITLE = "view-title"; public TabViewPane() { FXMLLoader loader = new FXMLLoader(getClass().getResource("TabViewPane.fxml")); loader.setRoot(this); loader.setController(this); try { loader.load(); } catch (final IOException exception) { throw new RuntimeException(exception); } setSide(Side.LEFT); setTabClosingPolicy(TabClosingPolicy.UNAVAILABLE); } public void addTab(final Node node, final String description) { BorderPane borderPane = new BorderPane(); TitledPane titledPane = new TitledPane(description, null); titledPane.setCollapsible(false); titledPane.setExpanded(false); titledPane.setFocusTraversable(false); titledPane.getStyleClass().add(VIEW_TITLE); borderPane.setTop(titledPane); borderPane.setCenter(node); Tab tab = new Tab(); tab.setText(description); tab.setContent(borderPane); getTabs().add(tab); } } ================================================ FILE: jgnash-fx/src/main/java/jgnash/uifx/control/TableViewEx.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.uifx.control; import java.math.BigDecimal; import java.time.LocalDate; import java.util.List; import java.util.function.Function; import javafx.scene.control.TableColumn; import javafx.scene.control.TableView; import javafx.scene.input.Clipboard; import javafx.scene.input.ClipboardContent; import javafx.scene.input.KeyCode; import jgnash.time.DateUtils; /** * Expanded TableView with a default Copy to clipboard handler * * @param The type of the objects contained within the TableView items list. * * @author Craig Cavanaugh */ public class TableViewEx extends TableView { private Function clipBoardStringFunction = null; public TableViewEx() { super(); // register a keyboard copy command for transactions setOnKeyPressed(event -> { if (event.isShortcutDown() && event.getCode() == KeyCode.C) { if (clipBoardStringFunction != null) { handleCopyToClipboard(); } else { handleGenericCopyToClipboard(); } event.consume(); } }); } public void handleCopyToClipboard() { final List items = getSelectionModel().getSelectedItems(); if (items.size() > 0 && getClipBoardStringFunction() != null) { final Clipboard clipboard = Clipboard.getSystemClipboard(); final ClipboardContent content = new ClipboardContent(); final StringBuilder builder = new StringBuilder(); for (final S item : items) { builder.append(getClipBoardStringFunction().apply(item)); builder.append('\n'); } content.putString(builder.toString()); clipboard.setContent(content); } } @SuppressWarnings("WeakerAccess") public Function getClipBoardStringFunction() { return clipBoardStringFunction; } public void setClipBoardStringFunction(final Function clipBoardStringFunction) { this.clipBoardStringFunction = clipBoardStringFunction; } private void handleGenericCopyToClipboard() { final List integerList = getSelectionModel().getSelectedIndices(); if (integerList.size() > 0) { final Clipboard clipboard = Clipboard.getSystemClipboard(); final ClipboardContent content = new ClipboardContent(); final StringBuilder builder = new StringBuilder(); for (final Integer row : integerList) { final List> columns = getColumns(); for (int i = 0; i < columns.size(); i++) { final TableColumn column = columns.get(i); if (column.getCellObservableValue(row) != null) { final Object value = column.getCellObservableValue(row).getValue(); if (value != null) { if (value instanceof BigDecimal) { builder.append(((BigDecimal) value).toPlainString()); } else if (value instanceof LocalDate) { builder.append(DateUtils.getExcelDateFormatter().format((LocalDate) value)); } else { builder.append(value); } } } if (i < columns.size() - i) { builder.append('\t'); } } builder.append('\n'); } content.putString(builder.toString()); clipboard.setContent(content); } } } ================================================ FILE: jgnash-fx/src/main/java/jgnash/uifx/control/TextFieldEx.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.uifx.control; import javafx.beans.property.BooleanProperty; import javafx.beans.property.SimpleBooleanProperty; import javafx.beans.value.ChangeListener; import javafx.beans.value.WeakChangeListener; import javafx.scene.control.TextField; import jgnash.uifx.Options; import jgnash.uifx.util.JavaFXUtils; /** * TextField with expanded capabilities. * * @author Craig Cavanaugh */ public class TextFieldEx extends TextField { private static final BooleanProperty selectOnFocus; /** * Reference is needed to prevent premature garbage collection. */ @SuppressWarnings("FieldCanBeLocal") private final ChangeListener focusChangeListener; static { selectOnFocus = new SimpleBooleanProperty(); selectOnFocus.bind(Options.selectOnFocusProperty()); } public TextFieldEx() { // If the select on focus property is enabled, select the text when focus is received focusChangeListener = (observable, oldValue, newValue) -> { if (selectOnFocus.get()) { JavaFXUtils.runLater(() -> { if (getText() != null && isFocused() && isEditable() && !getText().isEmpty()) { selectAll(); } }); } }; focusedProperty().addListener(new WeakChangeListener<>(focusChangeListener)); } } ================================================ FILE: jgnash-fx/src/main/java/jgnash/uifx/control/TextInputDialog.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.uifx.control; import java.util.Optional; import java.util.ResourceBundle; import javafx.beans.NamedArg; import javafx.beans.property.ObjectProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.fxml.FXML; import javafx.scene.Node; import javafx.scene.Scene; import javafx.scene.control.Button; import javafx.scene.control.ButtonBar; import javafx.scene.control.Label; import javafx.stage.Stage; import jgnash.resource.util.ResourceUtils; import jgnash.uifx.Options; import jgnash.uifx.resource.font.MaterialDesignLabel; import jgnash.uifx.skin.ThemeManager; import jgnash.uifx.util.FXMLUtils; import jgnash.uifx.util.InjectFXML; import jgnash.uifx.util.JavaFXUtils; /** * A better behaved TextInputDialog. * * @author Craig Cavanaugh */ public class TextInputDialog { @InjectFXML private final ObjectProperty parent = new SimpleObjectProperty<>(); @FXML private ButtonBar buttonBar; @FXML private Button okButton; @FXML private Button cancelButton; @FXML private TextFieldEx textField; @FXML private Label message; private final Stage dialog; public TextInputDialog(@NamedArg("defaultValue") final String defaultValue) { final ResourceBundle resources = ResourceUtils.getBundle(); dialog = FXMLUtils.loadFXML(this, "TextInputDialog.fxml", resources); JavaFXUtils.runLater(() -> textField.setText(defaultValue)); setGraphic(new MaterialDesignLabel(MaterialDesignLabel.MDIcon.QUESTION_CIRCLE, ThemeManager.getBaseTextHeight() * Alert.HEIGHT_MULTIPLIER)); } @FXML private void initialize() { buttonBar.buttonOrderProperty().bind(Options.buttonOrderProperty()); okButton.setOnAction(event -> handleOkayAction()); cancelButton.setOnAction(event -> handleCancelAction()); okButton.disableProperty().bind(textField.textProperty().isEmpty()); } public void setTitle(final String title) { dialog.setTitle(title); } public void setContentText(final String contentText) { message.setText(contentText); } private void setGraphic(final Node node) { message.setGraphic(node); } public Optional showAndWait() { dialog.sizeToScene(); dialog.setResizable(false); dialog.showAndWait(); return Optional.ofNullable(textField.getText()); } private void handleOkayAction() { ((Stage)parent.get().getWindow()).close(); } private void handleCancelAction() { textField.setText(null); ((Stage)parent.get().getWindow()).close(); } } ================================================ FILE: jgnash-fx/src/main/java/jgnash/uifx/control/TimePeriodComboBox.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.uifx.control; import javafx.beans.property.ReadOnlyIntegerProperty; import javafx.beans.property.ReadOnlyIntegerWrapper; import javafx.scene.control.ComboBox; import jgnash.resource.util.ResourceUtils; import java.util.ResourceBundle; /** * ComboBox that allows for selection of predetermined periods of times. *

* The period returned is in milliseconds. getPeriods and getDescriptions * can be overridden to change the available periods * * @author Craig Cavanaugh */ public class TimePeriodComboBox extends ComboBox { private int[] periods = new int[0]; private final ReadOnlyIntegerWrapper period = new ReadOnlyIntegerWrapper(); public TimePeriodComboBox() { loadModel(); // Update the period property automatically valueProperty().addListener((observable, oldValue, newValue) -> period.setValue(periods[getSelectionModel().getSelectedIndex()])); } public ReadOnlyIntegerProperty periodProperty() { return period.getReadOnlyProperty(); } private void loadModel() { periods = getPeriods(); String[] descriptions = getDescriptions(); assert periods.length == descriptions.length; getItems().addAll(getDescriptions()); } /** * Sets the selected period. Period must be a valid period * or no change will occur. * * @param period period in milliseconds to select */ public void setSelectedPeriod(final int period) { for (int i = 0; i < periods.length; i++) { if (period == periods[i]) { getSelectionModel().select(i); break; } } } public static int[] getPeriods() { // only non-zero values are allowed return new int[]{300000, 600000, 900000, 1800000, 3600000, 7200000, 28800000, 86400000, 1}; } private static String[] getDescriptions() { final ResourceBundle rb = ResourceUtils.getBundle(); return new String[]{rb.getString("Period.5Min"), rb.getString("Period.10Min"), rb.getString("Period.15Min"), rb.getString("Period.30Min"), rb.getString("Period.1Hr"), rb.getString("Period.2Hr"), rb.getString("Period.8Hr"), rb.getString("Period.1Day"), rb.getString("Period.NextStart")}; } } ================================================ FILE: jgnash-fx/src/main/java/jgnash/uifx/control/TransactionNumberComboBox.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.uifx.control; import java.util.Objects; import java.util.ResourceBundle; import javafx.beans.property.ObjectProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.scene.control.ComboBox; import javafx.scene.input.KeyCode; import javafx.scene.input.KeyEvent; import jgnash.engine.Account; import jgnash.engine.Engine; import jgnash.engine.EngineFactory; import jgnash.resource.util.ResourceUtils; import jgnash.uifx.util.JavaFXUtils; /** * Enhanced ComboBox; provides a UI to the user for entering transaction * numbers in a consistent manner. * * @author Craig Cavanaugh */ public class TransactionNumberComboBox extends ComboBox { private final String nextNumberItem; final private ObjectProperty accountProperty = new SimpleObjectProperty<>(); public TransactionNumberComboBox() { super(); ResourceBundle rb = ResourceUtils.getBundle(); nextNumberItem = rb.getString("Item.NextNum"); String[] defaultItems = new String[]{"", nextNumberItem}; getItems().addAll(defaultItems); final Engine engine = EngineFactory.getEngine(EngineFactory.DEFAULT); Objects.requireNonNull(engine); getItems().addAll(engine.getTransactionNumberList()); setEditable(true); valueProperty().addListener((observable, oldValue, newValue) -> new Thread(() -> { if (nextNumberItem.equals(newValue)) { final Account account = accountProperty().getValue(); if (account != null) { JavaFXUtils.runLater(() -> setValue(account.getNextTransactionNumber())); } } }).start()); setOnKeyReleased((final KeyEvent event) -> { if (event.getCode() == KeyCode.DOWN) { getSelectionModel().selectNext(); } else if (event.getCode() == KeyCode.UP) { getSelectionModel().selectPrevious(); } }); } public ObjectProperty accountProperty() { return accountProperty; } } ================================================ FILE: jgnash-fx/src/main/java/jgnash/uifx/control/autocomplete/AutoCompleteFactory.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.uifx.control.autocomplete; import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.Locale; import java.util.Objects; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.logging.Level; import java.util.logging.Logger; import jgnash.engine.Account; import jgnash.engine.Engine; import jgnash.engine.EngineFactory; import jgnash.engine.Transaction; import jgnash.engine.TransactionType; import jgnash.engine.message.ChannelEvent; import jgnash.engine.message.Message; import jgnash.engine.message.MessageBus; import jgnash.engine.message.MessageChannel; import jgnash.engine.message.MessageListener; import jgnash.engine.message.MessageProperty; import jgnash.uifx.Options; import jgnash.uifx.control.AutoCompleteTextField; import jgnash.uifx.util.JavaFXUtils; import jgnash.util.DefaultDaemonThreadFactory; import jgnash.util.MultiHashMap; import jgnash.resource.util.ResourceUtils; /** * This factory class generates AutoCompleteTextFields that share a common model * to reduce the amount of overhead. * * @author Craig Cavanaugh * @author Don Brown */ public class AutoCompleteFactory { private static MemoModel memoModel; private static final Object synchronizationObject = new Object(); /** * Use an ExecutorService to manage the number of running threads. */ private static final ExecutorService pool = Executors.newSingleThreadExecutor( new DefaultDaemonThreadFactory("Auto Complete Factory Executor")); private AutoCompleteFactory() { // Factory class } /** * Sets the {@code AutoCompleteModel} for {@code Transaction} memos to * an {@code AutoCompleteTextField}. * * @param autoCompleteTextField text field to bind to */ public static void setMemoModel(final AutoCompleteTextField autoCompleteTextField) { if (Options.useAutoCompleteProperty().get()) { synchronized (synchronizationObject) { if (memoModel == null) { memoModel = new MemoModel(); } } autoCompleteTextField.autoCompleteModelObjectProperty().set(memoModel); } } /** * Sets the {@code AutoCompleteModel} for {@code Transaction} payees to * an {@code AutoCompleteTextField}. * * @param account base Account for payee model * @param autoCompleteTextField text Field to bind to */ public static void setPayeeModel(final AutoCompleteTextField autoCompleteTextField, final Account account) { if (Options.useAutoCompleteProperty().get()) { autoCompleteTextField.autoCompleteModelObjectProperty().set(new PayeeAccountModel(account)); } } private static abstract class TransactionModel extends DefaultAutoCompleteModel implements MessageListener { volatile boolean load = false; TransactionModel() { init(); } final void init() { MessageBus.getInstance().registerListener(this, MessageChannel.TRANSACTION, MessageChannel.SYSTEM); load(); } @Override public void messagePosted(final Message event) { switch (event.getEvent()) { case TRANSACTION_ADD: Transaction t = event.getObject(MessageProperty.TRANSACTION); load(t); return; case FILE_LOAD_SUCCESS: reload(); return; case FILE_CLOSING: load = false; // prevent loading purge(); return; default: } } void load() { load = true; pool.execute(() -> { try { final Engine engine = EngineFactory.getEngine(EngineFactory.DEFAULT); Objects.requireNonNull(engine); final List transactions = engine.getTransactions(); // sort the transactions for consistent order Collections.sort(transactions); for (final Transaction t : transactions) { if (load) { load(t); } else { // loading has been stopped return; } } super.load(); } catch (Exception e) { Logger.getLogger(TransactionModel.class.getName()).log(Level.INFO, e.getLocalizedMessage(), e); } }); } final void reload() { purge(); // purge the old load(); // load the new } abstract void load(Transaction tran); } private static final class MemoModel extends PayeeModel { @Override void load(final Transaction tran) { if (tran != null) { // Add both memo versions addString(tran.getMemo()); addString(tran.getTransactionMemo()); } } } /** * This model stores the transaction with the payee field value. A new * instance is created for each account and is account specific. */ private static final class PayeeAccountModel extends PayeeModel { private final Account account; PayeeAccountModel(final Account account) { super(); this.account = account; } @Override void load() { // Push the load to the end of the application thread for a lazy init JavaFXUtils.runLater(() -> pool.execute(() -> { if (account != null) { account.getSortedTransactionList().forEach(this::load); } // Signal complete at the very end of the application thread JavaFXUtils.runLater(() -> pool.execute(() -> loadComplete.set(true))); })); } @Override public void messagePosted(final Message event) { Account a = event.getObject(MessageProperty.ACCOUNT); Transaction t = event.getObject(MessageProperty.TRANSACTION); switch (event.getEvent()) { case TRANSACTION_ADD: if (a.equals(account)) { load(t); } return; case FILE_LOAD_SUCCESS: reload(); return; case TRANSACTION_REMOVE: if (a.equals(account)) { removeExtraInfo(t); } return; default: } } } /** * This model stores the transaction with the payee field value. *

* Split entries are excluded because duplicating one in a form would * only impact the parent split transaction. */ private static class PayeeModel extends TransactionModel { final MultiHashMap transactions = new MultiHashMap<>(); @Override void load(final Transaction tran) { if (tran != null && tran.getTransactionType() != TransactionType.SPLITENTRY) { addString(tran.getPayee()); if (ignoreCaseEnabled.get()) { transactions.put(tran.getPayee().toLowerCase(Locale.getDefault()), tran); } else { transactions.put(tran.getPayee(), tran); } } } @Override public Collection getAllExtraInfo(final String key) { if (ignoreCaseEnabled.get()) { return transactions.getAll(key.toLowerCase(Locale.getDefault())); } return transactions.getAll(key); } /** * Removes the transaction associated with the payee. This is done so * that deleted transactions can be garbage collected. * * @param t transaction to remove */ void removeExtraInfo(final Transaction t) { pool.execute(() -> { if (ignoreCaseEnabled.get()) { if (!transactions.removeValue(t.getPayee().toLowerCase(Locale.getDefault()), t)) { Logger.getLogger(AutoCompleteFactory.class.getName()) .finest(ResourceUtils.getString("Message.Warn.FailedTransInfoRemoval")); } } else { if (!transactions.removeValue(t.getPayee(), t)) { Logger.getLogger(AutoCompleteFactory.class.getName()) .finest(ResourceUtils.getString("Message.Warn.FailedTransInfoRemoval")); } } }); } @Override public void messagePosted(final Message event) { super.messagePosted(event); if (event.getEvent() == ChannelEvent.TRANSACTION_REMOVE) { removeExtraInfo(event.getObject(MessageProperty.TRANSACTION)); } } } } ================================================ FILE: jgnash-fx/src/main/java/jgnash/uifx/control/autocomplete/AutoCompleteModel.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.uifx.control.autocomplete; import java.util.Collection; import java.util.concurrent.atomic.AtomicBoolean; /** * Auto complete model interface. * * @author Craig Cavanaugh * @author Don Brown */ public interface AutoCompleteModel { /** * Performs a search to find the best matching * String to the supplied String. Returns null if nothing * is found * * @param content content to search for * @return string that was found if any */ String doLookAhead(String content); /** * Returns extra information that might be stored with a * found string returned by doLookAhead(). This information * can be used to populate other fields based on matching the * string key. * * @param key The string key most likely returned from doLookAhead() * @return A collection of objects that would give extra information about the key */ Collection getAllExtraInfo(String key); /** * Atomic indicating if the initial load of data is complete to support * a full search * * @return AtomicBoolean indicating completion */ AtomicBoolean isLoadComplete(); } ================================================ FILE: jgnash-fx/src/main/java/jgnash/uifx/control/autocomplete/DefaultAutoCompleteModel.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.uifx.control.autocomplete; import java.util.Collection; import java.util.Collections; import java.util.LinkedList; import java.util.List; import java.util.concurrent.atomic.AtomicBoolean; import javafx.beans.property.SimpleBooleanProperty; import jgnash.uifx.Options; import jgnash.util.Nullable; /** * Default model for auto complete search. * * @author Craig Cavanaugh * @author Don Brown * @author Pranay Kumar */ abstract class DefaultAutoCompleteModel implements AutoCompleteModel { private final List list = new LinkedList<>(); private final SimpleBooleanProperty autoCompleteEnabled = new SimpleBooleanProperty(true); final SimpleBooleanProperty ignoreCaseEnabled = new SimpleBooleanProperty(false); private final SimpleBooleanProperty fuzzyMatchEnabled = new SimpleBooleanProperty(false); /** * Must be set to true when the initial load has been completed */ final AtomicBoolean loadComplete = new AtomicBoolean(false); DefaultAutoCompleteModel() { // bind preferences to the global options autoCompleteEnabled.bind(Options.useAutoCompleteProperty()); ignoreCaseEnabled.bind(Options.autoCompleteIsCaseSensitiveProperty().not()); fuzzyMatchEnabled.bind(Options.useFuzzyMatchForAutoCompleteProperty()); } @Override public String doLookAhead(final String content) { if (autoCompleteEnabled.get()) { return doLookAhead(content, ignoreCaseEnabled.get()); } return null; } /** * Perform a brute force linear search top down for the best match. * * @param content content to search for * @param ignoreCase true is search is case insensitive * @return best match if found, null otherwise */ private @Nullable String doLookAhead(final String content, final boolean ignoreCase) { if (!content.isEmpty()) { synchronized (list) { for (final String s : list) { if (ignoreCase) { if (s.equalsIgnoreCase(content)) { break; } } else { if (s.equals(content)) { break; } } if (startsWith(s, content, ignoreCase)) { return s; } } } } return null; } void addString(final String content) { if (content != null && !content.isEmpty()) { synchronized (list) { if (fuzzyMatchEnabled.get()) { list.remove(content); // remove old instance list.add(0, content); // push it to the top of the search list } else { int index = Collections.binarySearch(list, content); if (index < 0) { list.add(-index - 1, content); } } } } } /** * Removes all of the strings that have been remembered. */ void purge() { synchronized (list) { list.clear(); } } void load() { loadComplete.set(true); } public final AtomicBoolean isLoadComplete() { return loadComplete; } /** * Returns extra information that might be stored with a * found string returned by doLookAhead(). This information * can be used to populate other fields based on matching the * string key. * * @param key The string key most likely returned from doLookAhead() * @return A list of objects that would give extra information about the key */ @Override public Collection getAllExtraInfo(final String key) { return Collections.emptyList(); } /** * Tests if the source string starts with the prefix string. Case is * ignored. * * @param source the source String. * @param prefix the prefix String. * @param ignoreCase true if case should be ignored * @return true, if the source starts with the prefix string. */ private static boolean startsWith(final String source, final String prefix, final boolean ignoreCase) { return prefix.length() <= source.length() && source.regionMatches(ignoreCase, 0, prefix, 0, prefix.length()); } } ================================================ FILE: jgnash-fx/src/main/java/jgnash/uifx/control/wizard/AbstractWizardPaneController.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.uifx.control.wizard; import javafx.beans.property.ObjectProperty; import javafx.beans.property.SimpleObjectProperty; /** * Support class to make implementation of {@code WizardPaneController} less repetitive. Subclasses are required * to overwrite {@code toString()} and return a meaningful description. {@code toString()} will be called during * class initialization. * * @author Craig Cavanaugh */ public abstract class AbstractWizardPaneController> implements WizardPaneController { private final SimpleObjectProperty descriptorProperty = new SimpleObjectProperty<>(new WizardDescriptor(toString())); @Override public ObjectProperty descriptorProperty() { return descriptorProperty; } /** * Should be called when a change to validity occurs. */ protected void updateDescriptor() { descriptorProperty().get().setIsValid(isPaneValid()); } } ================================================ FILE: jgnash-fx/src/main/java/jgnash/uifx/control/wizard/WizardDescriptor.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.uifx.control.wizard; import java.util.Objects; import jgnash.util.NotNull; /** * Wizard Task Descriptor and status object. * * @author Craig Cavanaugh */ class WizardDescriptor { private boolean valid; private String description = ""; WizardDescriptor(@NotNull final String description) { Objects.requireNonNull(description); setDescription(description); } boolean isValid() { return valid; } void setIsValid(boolean valid) { this.valid = valid; } @NotNull public String getDescription() { return description; } private void setDescription(@NotNull final String description) { Objects.requireNonNull(description); this.description = description; } @Override public boolean equals(final Object o) { if (this == o) { return true; } if (o == null || getClass() != o.getClass()) { return false; } WizardDescriptor that = (WizardDescriptor) o; return Objects.equals(getDescription(), that.getDescription()); } @Override public int hashCode() { return Objects.hash(getDescription()); } } ================================================ FILE: jgnash-fx/src/main/java/jgnash/uifx/control/wizard/WizardDialogController.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.uifx.control.wizard; import java.util.HashMap; import java.util.Map; import java.util.Objects; import java.util.ResourceBundle; import javafx.beans.property.BooleanProperty; import javafx.beans.property.DoubleProperty; import javafx.beans.property.IntegerProperty; import javafx.beans.property.ObjectProperty; import javafx.beans.property.ReadOnlyBooleanProperty; import javafx.beans.property.ReadOnlyBooleanWrapper; import javafx.beans.property.SimpleBooleanProperty; import javafx.beans.property.SimpleDoubleProperty; import javafx.beans.property.SimpleIntegerProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.fxml.FXML; import javafx.scene.Scene; import javafx.scene.control.Button; import javafx.scene.control.ListCell; import javafx.scene.control.ListView; import javafx.scene.control.TitledPane; import javafx.scene.layout.Pane; import javafx.scene.layout.StackPane; import javafx.stage.Stage; import jgnash.uifx.skin.StyleClass; import jgnash.uifx.util.InjectFXML; import jgnash.uifx.util.JavaFXUtils; /** * Controller for the wizard dialog. * * @author Craig Cavanaugh */ public class WizardDialogController> { @InjectFXML private final ObjectProperty parent = new SimpleObjectProperty<>(); @FXML private TitledPane taskTitlePane; @FXML protected ResourceBundle resources; @FXML private ListView taskList; @FXML private StackPane taskPane; @FXML private Button backButton; @FXML private Button nextButton; @FXML private Button finishButton; @FXML private Button cancelButton; private final Map settings = new HashMap<>(); private final BooleanProperty valid = new SimpleBooleanProperty(false); private final IntegerProperty selectedIndex = new SimpleIntegerProperty(); private final Map, Pane> paneMap = new HashMap<>(); private final DoubleProperty taskListWidth = new SimpleDoubleProperty(); @FXML private void initialize() { cancelButton.setCancelButton(true); nextButton.setDefaultButton(true); taskList.setEditable(false); // updates the task title taskList.getSelectionModel().selectedItemProperty().addListener((observable, oldValue, newValue) -> handleTaskChange(oldValue, newValue)); taskList.setCellFactory(param -> new ControllerListCell()); // bind the min and max width to a value we control. ListView does a terrible job of controlling it's width taskList.minWidthProperty().bind(taskListWidth); taskList.maxWidthProperty().bind(taskListWidth); selectedIndex.bind(taskList.getSelectionModel().selectedIndexProperty()); } public ReadOnlyBooleanProperty validProperty() { return new ReadOnlyBooleanWrapper(valid.get()); } public void addTaskPane(final WizardPaneController wizardPaneController, final Pane pane) { Objects.requireNonNull(wizardPaneController); Objects.requireNonNull(pane); wizardPaneController.getSettings(settings); wizardPaneController.putSettings(settings); // Listen for changes to the controller descriptor wizardPaneController.descriptorProperty().addListener(observable -> taskList.refresh()); taskList.getItems().add(wizardPaneController.descriptorProperty().get()); paneMap.put(wizardPaneController, pane); // force selection if this is the first pane if (taskList.getItems().size() == 1) { taskList.getSelectionModel().select(0); taskPane.minWidthProperty().bind(pane.minWidthProperty()); } // update the preferred task list width taskListWidth.set(Math.max(getControllerDescriptionWidth(wizardPaneController), taskListWidth.get())); updateButtonState(); } public Object getSetting(final K key) { return settings.get(key); } public void setSetting(final K key, final Object value) { settings.put(key, value); /* New setting. Tell each page to read */ for (WizardPaneController wizardPaneController : paneMap.keySet()) { wizardPaneController.getSettings(settings); } updateButtonState(); } // handle a task change via the list private void handleTaskChange(final WizardDescriptor oldDescriptor, final WizardDescriptor newDescriptor) { // store the settings of the prior controller... the user may be skipping around if (oldDescriptor != null) { getController(oldDescriptor).putSettings(settings); } getController(newDescriptor).getSettings(settings); taskTitlePane.textProperty().set(newDescriptor.getDescription()); updateButtonState(); paneMap.keySet().stream().filter(controller -> controller.descriptorProperty().get().equals(newDescriptor)) .forEach(controller -> { taskPane.getChildren().clear(); taskPane.getChildren().addAll(paneMap.get(controller)); }); } private WizardPaneController getController(final WizardDescriptor descriptor) { for (final WizardPaneController wizardPaneController : paneMap.keySet()) { if (wizardPaneController.descriptorProperty().get().equals(descriptor)) { return wizardPaneController; } } throw new IllegalArgumentException("Did not find a controller match for the descriptor"); } private void updateButtonState() { nextButton.setDisable(selectedIndex.get() < 0 || selectedIndex.get() >= taskList.getItems().size() - 1); if (selectedIndex.get() == taskList.getItems().size() - 1) { boolean isValid = true; for (WizardPaneController wizardPaneController : paneMap.keySet()) { if (!wizardPaneController.isPaneValid()) { isValid = false; } } finishButton.setDisable(!isValid); } else { finishButton.setDisable(true); } backButton.setDisable(selectedIndex.get() == 0); } @FXML private void handleCancelAction() { valid.set(false); ((Stage) parent.get().getWindow()).close(); } @FXML private void handleNextAction() { if (selectedIndex.get() < taskList.getItems().size() - 1) { // store an setting on the active page. May be necessary for the next page getController(taskList.getSelectionModel().getSelectedItem()).putSettings(settings); // select the next page taskList.getSelectionModel().select(selectedIndex.get() + 1); JavaFXUtils.runLater(() -> { // tell the active page to update getController(taskList.getSelectionModel().getSelectedItem()).getSettings(settings); }); } } @FXML private void handleBackAction() { if (selectedIndex.get() > 0) { // select the previous page taskList.getSelectionModel().select(selectedIndex.get() - 1); } } @FXML private void handleFinishAction() { valid.set(true); for (WizardPaneController wizardPaneController : paneMap.keySet()) { if (!wizardPaneController.isPaneValid()) { valid.set(false); break; } wizardPaneController.putSettings(settings); } if (valid.get()) { ((Stage) parent.get().getWindow()).close(); } } /** * Determines the preferred width of the {@code ListCell} for a better visual width of the task list. * * @param item controller we need the optimal width off * @return preferred width */ private double getControllerDescriptionWidth(final WizardPaneController item) { final ControllerListCell cell = new ControllerListCell(); cell.updateItem(item.descriptorProperty().get(), false); return cell.prefWidth(-1); } /** * Custom list cell. Marks any bad pages with a font change */ private class ControllerListCell extends ListCell { ControllerListCell() { super(); updateListView(taskList); setSkin(createDefaultSkin()); } @Override protected void updateItem(final WizardDescriptor item, final boolean empty) { super.updateItem(item, empty); if (!empty) { setText(item.getDescription()); if (!item.isValid()) { setId(StyleClass.NORMAL_NEGATIVE_CELL_ID); } else { setId(StyleClass.NORMAL_CELL_ID); } } } } } ================================================ FILE: jgnash-fx/src/main/java/jgnash/uifx/control/wizard/WizardPaneController.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.uifx.control.wizard; import java.util.Map; import javafx.beans.property.ObjectProperty; /** * Interface for a task pane in a {@code WizardPane}. * * @author Craig Cavanaugh */ public interface WizardPaneController> { default boolean isPaneValid() { return true; } /** * Called after a page has been made active. The page * can load predefined settings/preferences when called. * * @param map preferences are accessible here */ default void getSettings(final Map map) { } /** * Called on the active prior to switching to the next page. The page * can save settings/preferences to be used later * * @param map place to put default preferences */ default void putSettings(final Map map) { } ObjectProperty descriptorProperty(); } ================================================ FILE: jgnash-fx/src/main/java/jgnash/uifx/dialog/ChangeDatabasePasswordDialogController.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.uifx.dialog; import java.io.File; import java.util.ResourceBundle; import javafx.beans.binding.Bindings; import javafx.beans.property.ObjectProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.fxml.FXML; import javafx.scene.Scene; import javafx.scene.control.Button; import javafx.scene.control.PasswordField; import javafx.scene.control.TextField; import javafx.stage.FileChooser; import javafx.stage.Stage; import jgnash.engine.DataStoreType; import jgnash.engine.jpa.SqlUtils; import jgnash.uifx.StaticUIMethods; import jgnash.uifx.util.FileChooserFactory; import jgnash.uifx.util.InjectFXML; import jgnash.uifx.util.JavaFXUtils; import jgnash.uifx.views.main.MainView; /** * Utility Dialog/Controller for changing the password of a relational database. * * @author Craig Cavanaugh */ public class ChangeDatabasePasswordDialogController { @InjectFXML private final ObjectProperty parent = new SimpleObjectProperty<>(); @FXML private Button okayButton; @FXML private TextField databaseTextField; @FXML private PasswordField passwordField; @FXML private PasswordField newPasswordField; @FXML private PasswordField verifyPasswordField; @FXML private ResourceBundle resources; @FXML void initialize() { okayButton.disableProperty().bind(Bindings.notEqual(newPasswordField.textProperty(), verifyPasswordField.textProperty()).or(Bindings.isEmpty(databaseTextField.textProperty()))); } @FXML private void handleOkAction() { ((Stage) parent.get().getWindow()).close(); final boolean result = SqlUtils.changePassword(databaseTextField.getText(), passwordField.getText().toCharArray(), newPasswordField.getText().toCharArray()); JavaFXUtils.runLater(() -> { if (result) { StaticUIMethods.displayMessage(resources.getString("Message.CredentialChange")); } else { StaticUIMethods.displayError(resources.getString("Message.Error.CredentialChange")); } }); } @FXML private void handleCloseAction() { ((Stage) parent.get().getWindow()).close(); } @FXML private void handleDatabaseButtonAction() { final FileChooser fileChooser = FileChooserFactory.getDataStoreChooser(DataStoreType.H2_DATABASE, DataStoreType.HSQL_DATABASE); fileChooser.setTitle(resources.getString("Title.SelFile")); final File file = fileChooser.showOpenDialog(MainView.getPrimaryStage()); if (file != null && file.exists()) { databaseTextField.setText(file.getAbsolutePath()); } } } ================================================ FILE: jgnash-fx/src/main/java/jgnash/uifx/dialog/ImportScriptsDialogController.java ================================================ /* * jGnash, a personal finance application * Copyright (C) 2001-2020 Craig Cavanaugh * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package jgnash.uifx.dialog; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.ResourceBundle; import java.util.function.Consumer; import javafx.beans.binding.Bindings; import javafx.beans.property.BooleanProperty; import javafx.beans.property.IntegerProperty; import javafx.beans.property.ObjectProperty; import javafx.beans.property.SimpleBooleanProperty; import javafx.beans.property.SimpleIntegerProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.beans.property.SimpleStringProperty; import javafx.beans.property.StringProperty; import javafx.fxml.FXML; import javafx.scene.Scene; import javafx.scene.control.Button; import javafx.scene.control.ButtonBar; import javafx.scene.control.TableColumn; import javafx.scene.control.TableView; import javafx.scene.control.cell.CheckBoxTableCell; import javafx.stage.Stage; import jgnash.convert.importat.ImportFilter; import jgnash.uifx.Options; import jgnash.uifx.skin.StyleClass; import jgnash.uifx.util.InjectFXML; import jgnash.uifx.util.JavaFXUtils; import jgnash.uifx.util.TableViewManager; /** * Controller for managing import scripts * * @author Craig Cavanaugh */ public class ImportScriptsDialogController { private static final String PREF_NODE = "/jgnash/uifx/dialog/ImportScriptsDialogController"; private static final double[] PREF_COLUMN_WEIGHTS = {0, 50, 50}; private static final String DEFAULT = "default"; @InjectFXML private final ObjectProperty parent = new SimpleObjectProperty<>(); @FXML private ButtonBar buttonBar; @FXML private Button upButton; @FXML private Button downButton; @FXML private ResourceBundle resources; @FXML private TableView