Added database utils, cleaned up data logic, added rest of models.
This commit is contained in:
parent
40e5533208
commit
974fbe0b6e
|
@ -10,3 +10,6 @@ interface and interoperable file formats for maximum compatibility.
|
||||||
Perfin is a desktop app built with Java 21 and JavaFX, using the SQLite3
|
Perfin is a desktop app built with Java 21 and JavaFX, using the SQLite3
|
||||||
database for most data storage. It's intended to be used by individuals to
|
database for most data storage. It's intended to be used by individuals to
|
||||||
track their finances across multiple accounts (savings, checking, credit, etc.).
|
track their finances across multiple accounts (savings, checking, credit, etc.).
|
||||||
|
|
||||||
|
Because the app lives and works entirely on your local computer, you can rest
|
||||||
|
assured that your data remains completely private.
|
||||||
|
|
|
@ -23,12 +23,13 @@ public class PerfinApp extends Application {
|
||||||
initMainScreen(stage);
|
initMainScreen(stage);
|
||||||
splashStage.stateProperty().addListener((v, oldState, state) -> {
|
splashStage.stateProperty().addListener((v, oldState, state) -> {
|
||||||
if (state == SplashScreenStage.State.DONE) stage.show();
|
if (state == SplashScreenStage.State.DONE) stage.show();
|
||||||
|
if (state == SplashScreenStage.State.ERROR) System.out.println("ERROR!");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private void initMainScreen(Stage stage) {
|
private void initMainScreen(Stage stage) {
|
||||||
stage.hide();
|
stage.hide();
|
||||||
stage.setScene(SceneUtil.load("/main.fxml"));
|
stage.setScene(SceneUtil.load("/accounts-view.fxml"));
|
||||||
stage.setTitle("Perfin");
|
stage.setTitle("Perfin");
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -33,7 +33,7 @@ public class AccountTileController {
|
||||||
this.account = account;
|
this.account = account;
|
||||||
Platform.runLater(() -> {
|
Platform.runLater(() -> {
|
||||||
accountNumberLabel.setText(account.getAccountNumber());
|
accountNumberLabel.setText(account.getAccountNumber());
|
||||||
accountBalanceLabel.setText(account.getCurrency().getSymbol() + " " + account.getCurrentBalance().toPlainString());
|
accountBalanceLabel.setText(account.getCurrency().getSymbol());
|
||||||
accountNameLabel.setText(account.getName());
|
accountNameLabel.setText(account.getName());
|
||||||
container.addEventHandler(MouseEvent.MOUSE_CLICKED, event -> {
|
container.addEventHandler(MouseEvent.MOUSE_CLICKED, event -> {
|
||||||
System.out.println("Clicked on " + account.getAccountNumber());
|
System.out.println("Clicked on " + account.getAccountNumber());
|
||||||
|
|
|
@ -0,0 +1,44 @@
|
||||||
|
package com.andrewlalis.perfin.control;
|
||||||
|
|
||||||
|
import com.andrewlalis.perfin.SceneUtil;
|
||||||
|
import com.andrewlalis.perfin.model.Account;
|
||||||
|
import com.andrewlalis.perfin.model.Profile;
|
||||||
|
import com.andrewlalis.perfin.view.BindingUtil;
|
||||||
|
import javafx.collections.FXCollections;
|
||||||
|
import javafx.collections.ObservableList;
|
||||||
|
import javafx.fxml.FXML;
|
||||||
|
import javafx.scene.layout.BorderPane;
|
||||||
|
import javafx.scene.layout.FlowPane;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
|
public class AccountsViewController {
|
||||||
|
@FXML
|
||||||
|
public BorderPane mainContainer;
|
||||||
|
@FXML
|
||||||
|
public FlowPane accountsPane;
|
||||||
|
|
||||||
|
private final ObservableList<Account> accountsList = FXCollections.observableArrayList();
|
||||||
|
|
||||||
|
@FXML
|
||||||
|
public void initialize() {
|
||||||
|
accountsPane.minWidthProperty().bind(mainContainer.widthProperty());
|
||||||
|
accountsPane.prefWidthProperty().bind(mainContainer.widthProperty());
|
||||||
|
accountsPane.prefWrapLengthProperty().bind(mainContainer.widthProperty());
|
||||||
|
accountsPane.maxWidthProperty().bind(mainContainer.widthProperty());
|
||||||
|
|
||||||
|
BindingUtil.mapContent(accountsPane.getChildren(), accountsList, account -> SceneUtil.loadNode(
|
||||||
|
"/account-tile.fxml",
|
||||||
|
(Consumer<AccountTileController>) c -> c.setAccount(account)
|
||||||
|
));
|
||||||
|
Profile.whenLoaded(profile -> {
|
||||||
|
populateAccounts(profile.getDataSource().getAccountRepository().findAll());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void populateAccounts(List<Account> accounts) {
|
||||||
|
this.accountsList.clear();
|
||||||
|
this.accountsList.addAll(accounts);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,47 +0,0 @@
|
||||||
package com.andrewlalis.perfin.control;
|
|
||||||
|
|
||||||
import com.andrewlalis.perfin.SceneUtil;
|
|
||||||
import com.andrewlalis.perfin.model.Account;
|
|
||||||
import com.andrewlalis.perfin.model.AccountType;
|
|
||||||
import javafx.fxml.FXML;
|
|
||||||
import javafx.scene.Parent;
|
|
||||||
import javafx.scene.layout.BorderPane;
|
|
||||||
import javafx.scene.layout.FlowPane;
|
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.util.Currency;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.function.Consumer;
|
|
||||||
|
|
||||||
public class MainController {
|
|
||||||
@FXML
|
|
||||||
public BorderPane mainContainer;
|
|
||||||
@FXML
|
|
||||||
public FlowPane accountsPane;
|
|
||||||
|
|
||||||
@FXML
|
|
||||||
public void initialize() {
|
|
||||||
accountsPane.minWidthProperty().bind(mainContainer.widthProperty());
|
|
||||||
accountsPane.prefWidthProperty().bind(mainContainer.widthProperty());
|
|
||||||
accountsPane.prefWrapLengthProperty().bind(mainContainer.widthProperty());
|
|
||||||
accountsPane.maxWidthProperty().bind(mainContainer.widthProperty());
|
|
||||||
List<Account> tempAccounts = List.of(
|
|
||||||
new Account(AccountType.CHECKING, "1234-4324-4321-4143", BigDecimal.valueOf(3745.01), "Main Checking", Currency.getInstance("USD")),
|
|
||||||
new Account(AccountType.CHECKING, "1234-4324-4321-4143", BigDecimal.valueOf(3745.01), "Main Checking", Currency.getInstance("USD")),
|
|
||||||
new Account(AccountType.CHECKING, "1234-4324-4321-4143", BigDecimal.valueOf(3745.01), "Main Checking", Currency.getInstance("USD")),
|
|
||||||
new Account(AccountType.CHECKING, "1234-4324-4321-4143", BigDecimal.valueOf(3745.01), "Main Checking", Currency.getInstance("USD"))
|
|
||||||
);
|
|
||||||
populateAccounts(tempAccounts);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void populateAccounts(List<Account> accounts) {
|
|
||||||
accountsPane.getChildren().clear();
|
|
||||||
for (var account : accounts) {
|
|
||||||
Parent node = SceneUtil.loadNode(
|
|
||||||
"/account-tile.fxml",
|
|
||||||
(Consumer<AccountTileController>) c -> c.setAccount(account)
|
|
||||||
);
|
|
||||||
accountsPane.getChildren().add(node);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,5 +1,6 @@
|
||||||
package com.andrewlalis.perfin.control;
|
package com.andrewlalis.perfin.control;
|
||||||
|
|
||||||
|
import com.andrewlalis.perfin.model.Profile;
|
||||||
import com.andrewlalis.perfin.view.SplashScreenStage;
|
import com.andrewlalis.perfin.view.SplashScreenStage;
|
||||||
import javafx.application.Platform;
|
import javafx.application.Platform;
|
||||||
import javafx.fxml.FXML;
|
import javafx.fxml.FXML;
|
||||||
|
@ -29,10 +30,22 @@ public class StartupSplashScreenController {
|
||||||
try {
|
try {
|
||||||
printlnLater("Initializing application files...");
|
printlnLater("Initializing application files...");
|
||||||
if (!initAppDir()) {
|
if (!initAppDir()) {
|
||||||
|
Thread.sleep(3000);
|
||||||
Platform.runLater(() -> getSplashStage().setError());
|
Platform.runLater(() -> getSplashStage().setError());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
printlnLater("Loading the last profile...");
|
||||||
|
try {
|
||||||
|
Profile.loadLast();
|
||||||
|
} catch (Exception e) {
|
||||||
|
printlnLater("Failed to load profile: " + e.getMessage());
|
||||||
|
Thread.sleep(3000);
|
||||||
|
Platform.runLater(() -> getSplashStage().setError());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
printlnLater("Perfin initialized. Starting the app now.");
|
printlnLater("Perfin initialized. Starting the app now.");
|
||||||
Thread.sleep(500);
|
Thread.sleep(500);
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,12 @@
|
||||||
|
package com.andrewlalis.perfin.data;
|
||||||
|
|
||||||
|
import com.andrewlalis.perfin.model.Account;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public interface AccountRepository {
|
||||||
|
long insert(Account account);
|
||||||
|
List<Account> findAll();
|
||||||
|
Optional<Account> findById(long id);
|
||||||
|
}
|
|
@ -0,0 +1,5 @@
|
||||||
|
package com.andrewlalis.perfin.data;
|
||||||
|
|
||||||
|
public interface DataSource {
|
||||||
|
AccountRepository getAccountRepository();
|
||||||
|
}
|
|
@ -0,0 +1,93 @@
|
||||||
|
package com.andrewlalis.perfin.data;
|
||||||
|
|
||||||
|
import java.sql.*;
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public final class DbUtil {
|
||||||
|
private DbUtil() {}
|
||||||
|
|
||||||
|
public static void setArgs(PreparedStatement stmt, List<Object> args) {
|
||||||
|
for (int i = 0; i < args.size(); i++) {
|
||||||
|
try {
|
||||||
|
stmt.setObject(i + 1, args.get(i));
|
||||||
|
} catch (SQLException e) {
|
||||||
|
throw new UncheckedSqlException("Failed to set parameter " + (i + 1) + " to " + args.get(i), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static <T> List<T> findAll(Connection conn, String query, List<Object> args, ResultSetMapper<T> mapper) {
|
||||||
|
try (var stmt = conn.prepareStatement(query)) {
|
||||||
|
setArgs(stmt, args);
|
||||||
|
var rs = stmt.executeQuery();
|
||||||
|
List<T> results = new ArrayList<>();
|
||||||
|
while (rs.next()) {
|
||||||
|
results.add(mapper.map(rs));
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
} catch (SQLException e) {
|
||||||
|
throw new UncheckedSqlException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static <T> List<T> findAll(Connection conn, String query, ResultSetMapper<T> mapper) {
|
||||||
|
return findAll(conn, query, Collections.emptyList(), mapper);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static <T> Optional<T> findOne(Connection conn, String query, List<Object> args, ResultSetMapper<T> mapper) {
|
||||||
|
try (var stmt = conn.prepareStatement(query)) {
|
||||||
|
setArgs(stmt, args);
|
||||||
|
var rs = stmt.executeQuery();
|
||||||
|
if (!rs.next()) return Optional.empty();
|
||||||
|
return Optional.of(mapper.map(rs));
|
||||||
|
} catch (SQLException e) {
|
||||||
|
throw new UncheckedSqlException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static <T> Optional<T> findById(Connection conn, String query, long id, ResultSetMapper<T> mapper) {
|
||||||
|
return findOne(conn, query, List.of(id), mapper);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void updateOne(Connection conn, String query, List<Object> args) {
|
||||||
|
try (var stmt = conn.prepareStatement(query)) {
|
||||||
|
setArgs(stmt, args);
|
||||||
|
int updateCount = stmt.executeUpdate();
|
||||||
|
if (updateCount != 1) throw new UncheckedSqlException("Update count is " + updateCount + "; expected 1.");
|
||||||
|
} catch (SQLException e) {
|
||||||
|
throw new UncheckedSqlException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static long insertOne(Connection conn, String query, List<Object> args) {
|
||||||
|
try (var stmt = conn.prepareStatement(query, Statement.RETURN_GENERATED_KEYS)) {
|
||||||
|
setArgs(stmt, args);
|
||||||
|
int result = stmt.executeUpdate();
|
||||||
|
if (result != 1) throw new UncheckedSqlException("Insert query did not update 1 row.");
|
||||||
|
var rs = stmt.getGeneratedKeys();
|
||||||
|
rs.next();
|
||||||
|
return rs.getLong(1);
|
||||||
|
} catch (SQLException e) {
|
||||||
|
throw new UncheckedSqlException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Timestamp timestampFromUtcLDT(LocalDateTime utc) {
|
||||||
|
return Timestamp.from(utc.toInstant(ZoneOffset.UTC));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Timestamp timestampFromUtcNow() {
|
||||||
|
return Timestamp.from(Instant.now(Clock.systemUTC()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static LocalDateTime utcLDTFromTimestamp(Timestamp ts) {
|
||||||
|
return ts.toInstant().atOffset(ZoneOffset.UTC).toLocalDateTime();
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
package com.andrewlalis.perfin.data;
|
||||||
|
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface ResultSetMapper<T> {
|
||||||
|
T map(ResultSet rs) throws SQLException;
|
||||||
|
}
|
|
@ -0,0 +1,17 @@
|
||||||
|
package com.andrewlalis.perfin.data;
|
||||||
|
|
||||||
|
import java.sql.SQLException;
|
||||||
|
|
||||||
|
public class UncheckedSqlException extends RuntimeException {
|
||||||
|
public UncheckedSqlException(SQLException cause) {
|
||||||
|
super(cause);
|
||||||
|
}
|
||||||
|
|
||||||
|
public UncheckedSqlException(String message, SQLException cause) {
|
||||||
|
super(message, cause);
|
||||||
|
}
|
||||||
|
|
||||||
|
public UncheckedSqlException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,51 @@
|
||||||
|
package com.andrewlalis.perfin.data.impl;
|
||||||
|
|
||||||
|
import com.andrewlalis.perfin.data.AccountRepository;
|
||||||
|
import com.andrewlalis.perfin.data.DbUtil;
|
||||||
|
import com.andrewlalis.perfin.model.Account;
|
||||||
|
import com.andrewlalis.perfin.model.AccountType;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Currency;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public record JdbcAccountRepository(Connection conn) implements AccountRepository {
|
||||||
|
@Override
|
||||||
|
public long insert(Account account) {
|
||||||
|
return DbUtil.insertOne(
|
||||||
|
conn,
|
||||||
|
"INSERT INTO account (created_at, account_type, account_number, name, currency) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
List.of(
|
||||||
|
DbUtil.timestampFromUtcNow(),
|
||||||
|
account.getType().name(),
|
||||||
|
account.getAccountNumber(),
|
||||||
|
account.getName(),
|
||||||
|
account.getCurrency().getCurrencyCode()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Account> findAll() {
|
||||||
|
return DbUtil.findAll(conn, "SELECT * FROM account ORDER BY created_at", JdbcAccountRepository::parseAccount);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<Account> findById(long id) {
|
||||||
|
return DbUtil.findById(conn, "SELECT * FROM account WHERE id = ?", id, JdbcAccountRepository::parseAccount);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Account parseAccount(ResultSet rs) throws SQLException {
|
||||||
|
long id = rs.getLong("id");
|
||||||
|
LocalDateTime createdAt = DbUtil.utcLDTFromTimestamp(rs.getTimestamp("created_at"));
|
||||||
|
AccountType type = AccountType.valueOf(rs.getString("account_type").toUpperCase());
|
||||||
|
String accountNumber = rs.getString("account_number");
|
||||||
|
String name = rs.getString("name");
|
||||||
|
Currency currency = Currency.getInstance(rs.getString("currency"));
|
||||||
|
return new Account(id, createdAt, type, accountNumber, name, currency);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,34 @@
|
||||||
|
package com.andrewlalis.perfin.data.impl;
|
||||||
|
|
||||||
|
import com.andrewlalis.perfin.data.AccountRepository;
|
||||||
|
import com.andrewlalis.perfin.data.DataSource;
|
||||||
|
import com.andrewlalis.perfin.data.UncheckedSqlException;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.DriverManager;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A basic data source implementation that gets a new SQL connection using a
|
||||||
|
* pre-defined JDBC connection URL.
|
||||||
|
*/
|
||||||
|
public class JdbcDataSource implements DataSource {
|
||||||
|
private final String jdbcUrl;
|
||||||
|
|
||||||
|
public JdbcDataSource(String jdbcUrl) {
|
||||||
|
this.jdbcUrl = jdbcUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Connection getConnection() {
|
||||||
|
try {
|
||||||
|
return DriverManager.getConnection(jdbcUrl);
|
||||||
|
} catch (SQLException e) {
|
||||||
|
throw new UncheckedSqlException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AccountRepository getAccountRepository() {
|
||||||
|
return new JdbcAccountRepository(getConnection());
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,6 +1,5 @@
|
||||||
package com.andrewlalis.perfin.model;
|
package com.andrewlalis.perfin.model;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.Currency;
|
import java.util.Currency;
|
||||||
|
|
||||||
|
@ -14,14 +13,21 @@ public class Account {
|
||||||
|
|
||||||
private AccountType type;
|
private AccountType type;
|
||||||
private String accountNumber;
|
private String accountNumber;
|
||||||
private BigDecimal currentBalance;
|
|
||||||
private String name;
|
private String name;
|
||||||
private Currency currency;
|
private Currency currency;
|
||||||
|
|
||||||
public Account(AccountType type, String accountNumber, BigDecimal currentBalance, String name, Currency currency) {
|
public Account(long id, LocalDateTime createdAt, AccountType type, String accountNumber, String name, Currency currency) {
|
||||||
|
this.id = id;
|
||||||
|
this.createdAt = createdAt;
|
||||||
|
this.type = type;
|
||||||
|
this.accountNumber = accountNumber;
|
||||||
|
this.name = name;
|
||||||
|
this.currency = currency;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Account(AccountType type, String accountNumber, String name, Currency currency) {
|
||||||
this.type = type;
|
this.type = type;
|
||||||
this.accountNumber = accountNumber;
|
this.accountNumber = accountNumber;
|
||||||
this.currentBalance = currentBalance;
|
|
||||||
this.name = name;
|
this.name = name;
|
||||||
this.currency = currency;
|
this.currency = currency;
|
||||||
}
|
}
|
||||||
|
@ -34,10 +40,6 @@ public class Account {
|
||||||
return accountNumber;
|
return accountNumber;
|
||||||
}
|
}
|
||||||
|
|
||||||
public BigDecimal getCurrentBalance() {
|
|
||||||
return currentBalance;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getName() {
|
public String getName() {
|
||||||
return name;
|
return name;
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,7 +5,8 @@ import java.time.LocalDateTime;
|
||||||
import java.util.Currency;
|
import java.util.Currency;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A single entry depicting a credit or debit to an account.
|
* A single entry depicting a credit or debit to an account, according to the
|
||||||
|
* rules of single-entry accounting.
|
||||||
* <p>
|
* <p>
|
||||||
* The following rules apply in determining the type of an entry:
|
* The following rules apply in determining the type of an entry:
|
||||||
* </p>
|
* </p>
|
||||||
|
@ -13,6 +14,21 @@ import java.util.Currency;
|
||||||
* <li>A <em>debit</em> indicates an increase in assets or decrease in liability.</li>
|
* <li>A <em>debit</em> indicates an increase in assets or decrease in liability.</li>
|
||||||
* <li>A <em>credit</em> indicates a decrease in assets or increase in liability.</li>
|
* <li>A <em>credit</em> indicates a decrease in assets or increase in liability.</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
|
* <p>
|
||||||
|
* For example, for a checking account, a debit entry is added when money
|
||||||
|
* is transferred to the account, and credit when money is taken away.
|
||||||
|
* </p>
|
||||||
|
* <p>
|
||||||
|
* Each entry corresponds to exactly one transaction. For pretty much
|
||||||
|
* everything but personal transfers, one transaction maps to one entry,
|
||||||
|
* but for transferring from one account to another, you'll have one
|
||||||
|
* transaction and an entry for each account.
|
||||||
|
* </p>
|
||||||
|
* <p>
|
||||||
|
* We don't use double-entry accounting since we're just tracking personal
|
||||||
|
* accounts, so we don't need the granularity of business accounting, and
|
||||||
|
* all those extra accounts would be a burden to casual users.
|
||||||
|
* </p>
|
||||||
*/
|
*/
|
||||||
public class AccountEntry {
|
public class AccountEntry {
|
||||||
public enum Type {
|
public enum Type {
|
||||||
|
@ -23,21 +39,16 @@ public class AccountEntry {
|
||||||
private long id;
|
private long id;
|
||||||
private LocalDateTime timestamp;
|
private LocalDateTime timestamp;
|
||||||
private long accountId;
|
private long accountId;
|
||||||
|
private long transactionId;
|
||||||
private BigDecimal amount;
|
private BigDecimal amount;
|
||||||
private Type type;
|
private Type type;
|
||||||
private Currency currency;
|
private Currency currency;
|
||||||
|
|
||||||
public AccountEntry(long id, LocalDateTime timestamp, long accountId, BigDecimal amount, Type type, Currency currency) {
|
public AccountEntry(long id, LocalDateTime timestamp, long accountId, long transactionId, BigDecimal amount, Type type, Currency currency) {
|
||||||
this.id = id;
|
this.id = id;
|
||||||
this.timestamp = timestamp;
|
this.timestamp = timestamp;
|
||||||
this.accountId = accountId;
|
this.accountId = accountId;
|
||||||
this.amount = amount;
|
this.transactionId = transactionId;
|
||||||
this.type = type;
|
|
||||||
this.currency = currency;
|
|
||||||
}
|
|
||||||
|
|
||||||
public AccountEntry(long accountId, BigDecimal amount, Type type, Currency currency) {
|
|
||||||
this.accountId = accountId;
|
|
||||||
this.amount = amount;
|
this.amount = amount;
|
||||||
this.type = type;
|
this.type = type;
|
||||||
this.currency = currency;
|
this.currency = currency;
|
||||||
|
|
|
@ -0,0 +1,27 @@
|
||||||
|
package com.andrewlalis.perfin.model;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Currency;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A recording of an account's real reported balance at a given point in time,
|
||||||
|
* used as a sanity check for ensuring that an account's entries add up to the
|
||||||
|
* correct balance.
|
||||||
|
*/
|
||||||
|
public class BalanceRecord {
|
||||||
|
private long id;
|
||||||
|
private LocalDateTime timestamp;
|
||||||
|
|
||||||
|
private long accountId;
|
||||||
|
private BigDecimal balance;
|
||||||
|
private Currency currency;
|
||||||
|
|
||||||
|
public BalanceRecord(long id, LocalDateTime timestamp, long accountId, BigDecimal balance, Currency currency) {
|
||||||
|
this.id = id;
|
||||||
|
this.timestamp = timestamp;
|
||||||
|
this.accountId = accountId;
|
||||||
|
this.balance = balance;
|
||||||
|
this.currency = currency;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,5 +1,20 @@
|
||||||
package com.andrewlalis.perfin.model;
|
package com.andrewlalis.perfin.model;
|
||||||
|
|
||||||
|
import com.andrewlalis.perfin.PerfinApp;
|
||||||
|
import com.andrewlalis.perfin.data.DataSource;
|
||||||
|
import com.andrewlalis.perfin.data.impl.JdbcDataSource;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Properties;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A profile is essentially a complete set of data that the application can
|
* A profile is essentially a complete set of data that the application can
|
||||||
* operate on, sort of like a save file or user account. The profile contains
|
* operate on, sort of like a save file or user account. The profile contains
|
||||||
|
@ -7,10 +22,165 @@ package com.andrewlalis.perfin.model;
|
||||||
* and more. A profile can be imported or exported easily from the application,
|
* and more. A profile can be imported or exported easily from the application,
|
||||||
* and can be encrypted for additional security. Each profile also has its own
|
* and can be encrypted for additional security. Each profile also has its own
|
||||||
* settings.
|
* settings.
|
||||||
* Practically, each profile is stored as its own isolated database file, with
|
* <p>
|
||||||
* a name corresponding to the profile's name.
|
* Practically, each profile is a directory containing a database file,
|
||||||
|
* settings, files, and other information.
|
||||||
|
* </p>
|
||||||
|
* <p>
|
||||||
|
* Because only one profile may be loaded in the app at once, the Profile
|
||||||
|
* class maintains a static <em>current</em> profile that can be loaded and
|
||||||
|
* unloaded.
|
||||||
|
* </p>
|
||||||
*/
|
*/
|
||||||
public class Profile {
|
public class Profile {
|
||||||
private String name;
|
private static Profile current;
|
||||||
|
private static final List<Consumer<Profile>> profileLoadListeners = new ArrayList<>();
|
||||||
|
|
||||||
|
private final String name;
|
||||||
|
private final Properties settings;
|
||||||
|
private final DataSource dataSource;
|
||||||
|
|
||||||
|
private Profile(String name, Properties settings, DataSource dataSource) {
|
||||||
|
this.name = name;
|
||||||
|
this.settings = settings;
|
||||||
|
this.dataSource = dataSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getName() {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Properties getSettings() {
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
public DataSource getDataSource() {
|
||||||
|
return dataSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Path getDir(String name) {
|
||||||
|
return PerfinApp.APP_DIR.resolve(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Path getContentDir(String name) {
|
||||||
|
return getDir(name).resolve("content");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Path getSettingsFile(String name) {
|
||||||
|
return getDir(name).resolve("settings.properties");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Path getDatabaseFile(String name) {
|
||||||
|
return getDir(name).resolve("database.mv.db");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Profile getCurrent() {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void whenLoaded(Consumer<Profile> consumer) {
|
||||||
|
if (current != null) {
|
||||||
|
consumer.accept(current);
|
||||||
|
} else {
|
||||||
|
profileLoadListeners.add(consumer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String getLastProfile() {
|
||||||
|
Path lastProfileFile = PerfinApp.APP_DIR.resolve("last-profile.txt");
|
||||||
|
if (Files.exists(lastProfileFile)) {
|
||||||
|
try {
|
||||||
|
String s = Files.readString(lastProfileFile).strip().toLowerCase();
|
||||||
|
if (!s.isBlank()) return s;
|
||||||
|
} catch (IOException e) {
|
||||||
|
System.err.println("Failed to read " + lastProfileFile);
|
||||||
|
e.printStackTrace(System.err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "default";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void saveLastProfile(String name) {
|
||||||
|
Path lastProfileFile = PerfinApp.APP_DIR.resolve("last-profile.txt");
|
||||||
|
try {
|
||||||
|
Files.writeString(lastProfileFile, name);
|
||||||
|
} catch (IOException e) {
|
||||||
|
System.err.println("Failed to write " + lastProfileFile);
|
||||||
|
e.printStackTrace(System.err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void loadLast() throws Exception {
|
||||||
|
load(getLastProfile());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void load(String name) throws IOException {
|
||||||
|
if (Files.notExists(getDir(name))) {
|
||||||
|
initProfileDir(name);
|
||||||
|
}
|
||||||
|
Properties settings = new Properties();
|
||||||
|
try (var in = Files.newInputStream(getSettingsFile(name))) {
|
||||||
|
settings.load(in);
|
||||||
|
}
|
||||||
|
current = new Profile(name, settings, initJdbcDataSource(name));
|
||||||
|
saveLastProfile(current.getName());
|
||||||
|
for (var c : profileLoadListeners) {
|
||||||
|
c.accept(current);
|
||||||
|
}
|
||||||
|
profileLoadListeners.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void initProfileDir(String name) throws IOException {
|
||||||
|
Files.createDirectory(getDir(name));
|
||||||
|
copyResourceFile("/text/profileDirReadme.txt", getDir(name).resolve("README.txt"));
|
||||||
|
copyResourceFile("/text/defaultProfileSettings.properties", getSettingsFile(name));
|
||||||
|
Files.createDirectory(getContentDir(name));
|
||||||
|
copyResourceFile("/text/contentDirReadme.txt", getContentDir(name).resolve("README.txt"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DataSource initJdbcDataSource(String name) throws IOException {
|
||||||
|
String databaseFilename = getDatabaseFile(name).toAbsolutePath().toString();
|
||||||
|
String jdbcUrl = "jdbc:h2:" + databaseFilename.substring(0, databaseFilename.length() - 6);
|
||||||
|
boolean exists = Files.exists(getDatabaseFile(name));
|
||||||
|
JdbcDataSource dataSource = new JdbcDataSource(jdbcUrl);
|
||||||
|
if (!exists) {// Initialize the datasource using schema.sql.
|
||||||
|
try (var in = Profile.class.getResourceAsStream("/sql/schema.sql"); var conn = dataSource.getConnection()) {
|
||||||
|
if (in == null) throw new IOException("Could not load /sql/schema.sql");
|
||||||
|
String schemaStr = new String(in.readAllBytes(), StandardCharsets.UTF_8);
|
||||||
|
List<String> statements = Arrays.stream(schemaStr.split(";"))
|
||||||
|
.map(String::strip).filter(s -> !s.isBlank()).toList();
|
||||||
|
for (var statementStr : statements) {
|
||||||
|
try (var stmt = conn.createStatement()) {
|
||||||
|
stmt.executeUpdate(statementStr);
|
||||||
|
System.out.println("Executed update:\n" + statementStr + "\n-----");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (SQLException e) {
|
||||||
|
Files.deleteIfExists(getDatabaseFile(name));
|
||||||
|
throw new IOException("Failed to initialize database.", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Test the datasource before returning it.
|
||||||
|
try (var conn = dataSource.getConnection(); var s = conn.createStatement()) {
|
||||||
|
boolean success = s.execute("SELECT 1;");
|
||||||
|
if (!success) throw new IOException("Failed to execute DB test statement.");
|
||||||
|
} catch (SQLException e) {
|
||||||
|
throw new IOException(e);
|
||||||
|
}
|
||||||
|
return dataSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void copyResourceFile(String resource, Path dest) throws IOException {
|
||||||
|
try (
|
||||||
|
var in = Profile.class.getResourceAsStream(resource);
|
||||||
|
var out = Files.newOutputStream(dest)
|
||||||
|
) {
|
||||||
|
if (in == null) throw new IOException("Could not load resource " + resource);
|
||||||
|
in.transferTo(out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean validateName(String name) {
|
||||||
|
return name.matches("\\w+");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,26 @@
|
||||||
|
package com.andrewlalis.perfin.model;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Currency;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A transaction is a permanent record of a transfer of funds between two
|
||||||
|
* accounts.
|
||||||
|
*/
|
||||||
|
public class Transaction {
|
||||||
|
private long id;
|
||||||
|
private LocalDateTime timestamp;
|
||||||
|
|
||||||
|
private BigDecimal amount;
|
||||||
|
private Currency currency;
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
public Transaction(long id, LocalDateTime timestamp, BigDecimal amount, Currency currency, String description) {
|
||||||
|
this.id = id;
|
||||||
|
this.timestamp = timestamp;
|
||||||
|
this.amount = amount;
|
||||||
|
this.currency = currency;
|
||||||
|
this.description = description;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,89 @@
|
||||||
|
package com.andrewlalis.perfin.view;
|
||||||
|
|
||||||
|
import javafx.beans.WeakListener;
|
||||||
|
import javafx.collections.ListChangeListener;
|
||||||
|
import javafx.collections.ObservableList;
|
||||||
|
|
||||||
|
import java.lang.ref.WeakReference;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.function.Function;
|
||||||
|
|
||||||
|
public class BindingUtil {
|
||||||
|
|
||||||
|
public static <E, F> void mapContent(ObservableList<F> mapped, ObservableList<? extends E> source,
|
||||||
|
Function<? super E, ? extends F> mapper) {
|
||||||
|
map(mapped, source, mapper);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static <E, F> Object map(ObservableList<F> mapped, ObservableList<? extends E> source,
|
||||||
|
Function<? super E, ? extends F> mapper) {
|
||||||
|
final ListContentMapping<E, F> contentMapping = new ListContentMapping<>(mapped, mapper);
|
||||||
|
mapped.setAll(source.stream().map(mapper).toList());
|
||||||
|
source.removeListener(contentMapping);
|
||||||
|
source.addListener(contentMapping);
|
||||||
|
return contentMapping;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class ListContentMapping<E, F> implements ListChangeListener<E>, WeakListener {
|
||||||
|
private final WeakReference<List<F>> mappedRef;
|
||||||
|
private final Function<? super E, ? extends F> mapper;
|
||||||
|
|
||||||
|
public ListContentMapping(List<F> mapped, Function<? super E, ? extends F> mapper) {
|
||||||
|
this.mappedRef = new WeakReference<>(mapped);
|
||||||
|
this.mapper = mapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onChanged(Change<? extends E> change) {
|
||||||
|
final List<F> mapped = mappedRef.get();
|
||||||
|
if (mapped == null) {
|
||||||
|
change.getList().removeListener(this);
|
||||||
|
} else {
|
||||||
|
while (change.next()) {
|
||||||
|
if (change.wasPermutated()) {
|
||||||
|
mapped.subList(change.getFrom(), change.getTo()).clear();
|
||||||
|
mapped.addAll(change.getFrom(), change.getList().subList(change.getFrom(), change.getTo())
|
||||||
|
.stream().map(mapper).toList());
|
||||||
|
} else {
|
||||||
|
if (change.wasRemoved()) {
|
||||||
|
mapped.subList(change.getFrom(), change.getFrom() + change.getRemovedSize()).clear();
|
||||||
|
}
|
||||||
|
if (change.wasAdded()) {
|
||||||
|
mapped.addAll(change.getFrom(), change.getAddedSubList()
|
||||||
|
.stream().map(mapper).toList());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean wasGarbageCollected() {
|
||||||
|
return mappedRef.get() == null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int hashCode() {
|
||||||
|
final List<F> list = mappedRef.get();
|
||||||
|
return (list == null) ? 0 : list.hashCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean equals(Object obj) {
|
||||||
|
if (this == obj) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
final List<F> mapped1 = mappedRef.get();
|
||||||
|
if (mapped1 == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (obj instanceof ListContentMapping<?, ?> other) {
|
||||||
|
final List<?> mapped2 = other.mappedRef.get();
|
||||||
|
return mapped1 == mapped2;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -6,6 +6,8 @@ module com.andrewlalis.perfin {
|
||||||
|
|
||||||
requires com.fasterxml.jackson.databind;
|
requires com.fasterxml.jackson.databind;
|
||||||
|
|
||||||
|
requires java.sql;
|
||||||
|
|
||||||
exports com.andrewlalis.perfin to javafx.graphics;
|
exports com.andrewlalis.perfin to javafx.graphics;
|
||||||
opens com.andrewlalis.perfin.control to javafx.fxml;
|
opens com.andrewlalis.perfin.control to javafx.fxml;
|
||||||
}
|
}
|
|
@ -6,15 +6,13 @@
|
||||||
<VBox
|
<VBox
|
||||||
styleClass="account-tile-container"
|
styleClass="account-tile-container"
|
||||||
prefHeight="100.0"
|
prefHeight="100.0"
|
||||||
prefWidth="200.0"
|
prefWidth="300.0"
|
||||||
stylesheets="@style/account-tile.css"
|
stylesheets="@style/account-tile.css"
|
||||||
xmlns="http://javafx.com/javafx/17.0.2-ea"
|
xmlns="http://javafx.com/javafx/17.0.2-ea"
|
||||||
xmlns:fx="http://javafx.com/fxml/1"
|
xmlns:fx="http://javafx.com/fxml/1"
|
||||||
fx:controller="com.andrewlalis.perfin.control.AccountTileController"
|
fx:controller="com.andrewlalis.perfin.control.AccountTileController"
|
||||||
fx:id="container"
|
fx:id="container"
|
||||||
>
|
>
|
||||||
<Label styleClass="main-label" text="Account Info" />
|
|
||||||
<Separator prefWidth="200.0" />
|
|
||||||
<Label text="Account Number" styleClass="property-label"/>
|
<Label text="Account Number" styleClass="property-label"/>
|
||||||
<Label fx:id="accountNumberLabel" styleClass="property-value" text="Account Number placeholder" />
|
<Label fx:id="accountNumberLabel" styleClass="property-value" text="Account Number placeholder" />
|
||||||
<Label text="Account Balance" styleClass="property-label"/>
|
<Label text="Account Balance" styleClass="property-label"/>
|
||||||
|
|
|
@ -8,9 +8,9 @@
|
||||||
minWidth="600.0"
|
minWidth="600.0"
|
||||||
xmlns="http://javafx.com/javafx/17.0.2-ea"
|
xmlns="http://javafx.com/javafx/17.0.2-ea"
|
||||||
xmlns:fx="http://javafx.com/fxml/1"
|
xmlns:fx="http://javafx.com/fxml/1"
|
||||||
fx:controller="com.andrewlalis.perfin.control.MainController"
|
fx:controller="com.andrewlalis.perfin.control.AccountsViewController"
|
||||||
fx:id="mainContainer"
|
fx:id="mainContainer"
|
||||||
stylesheets="@style/main.css"
|
stylesheets="@style/accounts-view.css"
|
||||||
>
|
>
|
||||||
<top>
|
<top>
|
||||||
<MenuBar BorderPane.alignment="CENTER">
|
<MenuBar BorderPane.alignment="CENTER">
|
|
@ -0,0 +1,43 @@
|
||||||
|
CREATE TABLE account (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
created_at TIMESTAMP NOT NULL,
|
||||||
|
account_type VARCHAR(31) NOT NULL,
|
||||||
|
account_number VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
name VARCHAR(63) NOT NULL,
|
||||||
|
currency VARCHAR(3) NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE transaction (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
timestamp TIMESTAMP NOT NULL,
|
||||||
|
amount NUMERIC(12, 4) NOT NULL,
|
||||||
|
currency VARCHAR(3) NOT NULL,
|
||||||
|
description VARCHAR(255) NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE account_entry (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
timestamp TIMESTAMP NOT NULL,
|
||||||
|
account_id BIGINT NOT NULL,
|
||||||
|
transaction_id BIGINT NOT NULL,
|
||||||
|
amount NUMERIC(12, 4) NOT NULL,
|
||||||
|
type ENUM('CREDIT', 'DEBIT') NOT NULL,
|
||||||
|
currency VARCHAR(3) NOT NULL,
|
||||||
|
CONSTRAINT fk_account_entry_account
|
||||||
|
FOREIGN KEY (account_id) REFERENCES account(id)
|
||||||
|
ON UPDATE CASCADE ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_account_entry_transaction
|
||||||
|
FOREIGN KEY (transaction_id) REFERENCES transaction(id)
|
||||||
|
ON UPDATE CASCADE ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE balance_record (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
timestamp TIMESTAMP NOT NULL,
|
||||||
|
account_id BIGINT NOT NULL,
|
||||||
|
balance NUMERIC(12, 4) NOT NULL,
|
||||||
|
currency VARCHAR(3) NOT NULL,
|
||||||
|
CONSTRAINT fk_balance_record_account
|
||||||
|
FOREIGN KEY (account_id) REFERENCES account(id)
|
||||||
|
ON UPDATE CASCADE ON DELETE CASCADE
|
||||||
|
);
|
|
@ -2,6 +2,7 @@
|
||||||
-fx-border-color: lightgray;
|
-fx-border-color: lightgray;
|
||||||
-fx-border-width: 1px;
|
-fx-border-width: 1px;
|
||||||
-fx-border-style: solid;
|
-fx-border-style: solid;
|
||||||
|
-fx-border-radius: 5px;
|
||||||
-fx-padding: 5px;
|
-fx-padding: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,7 @@
|
||||||
|
This is your Perfin profile's content directory.
|
||||||
|
|
||||||
|
It contains all the files and other large content that you've added to your
|
||||||
|
profile, including but not limited to transaction attachments (receipts,
|
||||||
|
invoices, etc.), bank statements, or portfolio exports. These files are usually
|
||||||
|
managed by the Perfin app through in-app actions, but you're also welcome to
|
||||||
|
browse them directly, or even delete files you no longer want stored.
|
|
@ -0,0 +1 @@
|
||||||
|
my-setting=true
|
|
@ -0,0 +1,4 @@
|
||||||
|
This is your Perfin profile's main directory.
|
||||||
|
|
||||||
|
All the data contained in your Perfin profile is stored in this directory,
|
||||||
|
organized into a few separate locations based on the data's usage and purpose.
|
Loading…
Reference in New Issue