diff --git a/.classpath b/.classpath new file mode 100644 index 0000000..1af8d6d --- /dev/null +++ b/.classpath @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..609b950 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +*.log +/target/ \ No newline at end of file diff --git a/.project b/.project new file mode 100644 index 0000000..080944e --- /dev/null +++ b/.project @@ -0,0 +1,37 @@ + + + com.stephenschafer.budget.web + + + + + + org.eclipse.jdt.core.javabuilder + + + + + org.eclipse.wst.common.project.facet.core.builder + + + + + org.eclipse.wst.validation.validationbuilder + + + + + org.eclipse.m2e.core.maven2Builder + + + + + + org.eclipse.m2e.core.maven2Nature + org.eclipse.jem.workbench.JavaEMFNature + org.eclipse.wst.common.modulecore.ModuleCoreNature + org.eclipse.wst.common.project.facet.core.nature + org.eclipse.jdt.core.javanature + org.eclipse.wst.jsdt.core.jsNature + + diff --git a/.settings/.jsdtscope b/.settings/.jsdtscope new file mode 100644 index 0000000..76c2d63 --- /dev/null +++ b/.settings/.jsdtscope @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/.settings/org.eclipse.jdt.core.prefs b/.settings/org.eclipse.jdt.core.prefs new file mode 100644 index 0000000..d089a9b --- /dev/null +++ b/.settings/org.eclipse.jdt.core.prefs @@ -0,0 +1,11 @@ +eclipse.preferences.version=1 +org.eclipse.jdt.core.compiler.codegen.inlineJsrBytecode=enabled +org.eclipse.jdt.core.compiler.codegen.targetPlatform=17 +org.eclipse.jdt.core.compiler.compliance=17 +org.eclipse.jdt.core.compiler.problem.assertIdentifier=error +org.eclipse.jdt.core.compiler.problem.enablePreviewFeatures=disabled +org.eclipse.jdt.core.compiler.problem.enumIdentifier=error +org.eclipse.jdt.core.compiler.problem.forbiddenReference=warning +org.eclipse.jdt.core.compiler.problem.reportPreviewFeatures=warning +org.eclipse.jdt.core.compiler.release=enabled +org.eclipse.jdt.core.compiler.source=17 diff --git a/.settings/org.eclipse.m2e.core.prefs b/.settings/org.eclipse.m2e.core.prefs new file mode 100644 index 0000000..f897a7f --- /dev/null +++ b/.settings/org.eclipse.m2e.core.prefs @@ -0,0 +1,4 @@ +activeProfiles= +eclipse.preferences.version=1 +resolveWorkspaceProjects=true +version=1 diff --git a/.settings/org.eclipse.wst.common.component b/.settings/org.eclipse.wst.common.component new file mode 100644 index 0000000..d8b7229 --- /dev/null +++ b/.settings/org.eclipse.wst.common.component @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/.settings/org.eclipse.wst.common.project.facet.core.xml b/.settings/org.eclipse.wst.common.project.facet.core.xml new file mode 100644 index 0000000..5509be0 --- /dev/null +++ b/.settings/org.eclipse.wst.common.project.facet.core.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/.settings/org.eclipse.wst.jsdt.ui.superType.container b/.settings/org.eclipse.wst.jsdt.ui.superType.container new file mode 100644 index 0000000..3bd5d0a --- /dev/null +++ b/.settings/org.eclipse.wst.jsdt.ui.superType.container @@ -0,0 +1 @@ +org.eclipse.wst.jsdt.launching.baseBrowserLibrary \ No newline at end of file diff --git a/.settings/org.eclipse.wst.jsdt.ui.superType.name b/.settings/org.eclipse.wst.jsdt.ui.superType.name new file mode 100644 index 0000000..05bd71b --- /dev/null +++ b/.settings/org.eclipse.wst.jsdt.ui.superType.name @@ -0,0 +1 @@ +Window \ No newline at end of file diff --git a/.settings/org.eclipse.wst.validation.prefs b/.settings/org.eclipse.wst.validation.prefs new file mode 100644 index 0000000..04cad8c --- /dev/null +++ b/.settings/org.eclipse.wst.validation.prefs @@ -0,0 +1,2 @@ +disabled=06target +eclipse.preferences.version=1 diff --git a/WebContent/WEB-INF/web.xml b/WebContent/WEB-INF/web.xml new file mode 100644 index 0000000..8f40f64 --- /dev/null +++ b/WebContent/WEB-INF/web.xml @@ -0,0 +1,23 @@ + + + + + + 1440 + + + + CategoriesPage + com.stephenschafer.budget.web.CategoriesPage + 1 + + + + CategoriesPage + /categories + + + diff --git a/build-war b/build-war new file mode 100755 index 0000000..2b2ae15 --- /dev/null +++ b/build-war @@ -0,0 +1,8 @@ +#!/bin/sh +if mvn -f pom.xml clean install > build-jar.log 2> build-jar.err.log; then + echo "success" + cp target/*.war /tmp +else + echo "failure" + exit 1 +fi \ No newline at end of file diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..9fa3fa3 --- /dev/null +++ b/pom.xml @@ -0,0 +1,48 @@ + + 4.0.0 + com.stephenschafer + budget.web + 0.0.1-SNAPSHOT + war + + + + maven-compiler-plugin + 3.13.0 + + 17 + 17 + + + + maven-war-plugin + 3.4.0 + + WebContent + + + + + + + javax.servlet + javax.servlet-api + 4.0.1 + + + mysql + mysql-connector-java + 5.1.38 + + + com.fasterxml.jackson.core + jackson-core + 2.18.1 + + + com.fasterxml.jackson.core + jackson-databind + 2.18.1 + + + \ No newline at end of file diff --git a/src/main/java/com/stephenschafer/budget/web/CategoriesPage.java b/src/main/java/com/stephenschafer/budget/web/CategoriesPage.java new file mode 100644 index 0000000..abfc330 --- /dev/null +++ b/src/main/java/com/stephenschafer/budget/web/CategoriesPage.java @@ -0,0 +1,320 @@ +package com.stephenschafer.budget.web; + +import java.io.IOException; +import java.io.PrintWriter; +import java.sql.Connection; +import java.sql.SQLException; +import java.text.DateFormat; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Collections; +import java.util.Date; +import java.util.GregorianCalendar; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; + +import javax.naming.NamingException; +import javax.servlet.ServletConfig; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +public class CategoriesPage extends HttpServlet { + private static final long serialVersionUID = 1L; + + @Override + public void init(final ServletConfig config) throws ServletException { + try { + Configuration.INSTANCE.load(); + } + catch (final Exception e) { + e.printStackTrace(); + } + super.init(config); + } + + @Override + protected void doGet(final HttpServletRequest request, final HttpServletResponse response) + throws ServletException, IOException { + var paramYear = request.getParameterValues("year"); + if (paramYear == null || paramYear.length == 0) { + final Calendar cal = new GregorianCalendar(); + cal.setTime(new Date()); + final var year = cal.get(Calendar.YEAR); + paramYear = new String[] { String.valueOf(year) }; + } + var tmpIncludeExpenses = true; + var tmpIncludeIncome = true; + var tmpIncludePayments = false; + var tmpIncludeInvestments = false; + final var includes = request.getParameterValues("include"); + if (includes != null && includes.length > 0) { + tmpIncludeExpenses = false; + tmpIncludeIncome = false; + tmpIncludePayments = false; + tmpIncludeInvestments = false; + for (final String include : includes) { + if ("expenses".equalsIgnoreCase(include)) { + tmpIncludeExpenses = true; + } + else if ("income".equalsIgnoreCase(include)) { + tmpIncludeIncome = true; + } + else if ("payments".equalsIgnoreCase(include)) { + tmpIncludePayments = true; + } + else if ("investments".equalsIgnoreCase(include)) { + tmpIncludeInvestments = true; + } + } + } + final var includeExpenses = tmpIncludeExpenses; + final var includeIncome = tmpIncludeIncome; + final var includePayments = tmpIncludePayments; + final var includeInvestments = tmpIncludeInvestments; + final var startDates = request.getParameterValues("startDate"); + final var endDates = request.getParameterValues("endDate"); + final var startDateString = startDates == null ? null + : startDates.length == 0 ? null : startDates[0]; + final var endDateString = endDates == null ? null + : endDates.length == 0 ? null : endDates[0]; + final DateFormat df = new SimpleDateFormat("yyyy-MM-dd"); + final Date startDate; + final Date endDate; + try { + startDate = startDateString == null ? null : df.parse(startDateString); + endDate = endDateString == null ? null : df.parse(endDateString); + } + catch (final ParseException e) { + response.sendError(400, "Invalid date"); + return; + } + final var out = response.getWriter(); + response.setHeader("Content-Type", "text/html"); + try (var connection = Configuration.INSTANCE.getConnection()) { + out.println(""); + out.println(""); + final var js = Util.getResourceAsString("categories.js"); + out.println(""); + final var css = Util.getResourceAsString("categories.css"); + out.println(""); + out.println(""); + out.println(""); + for (final String year : paramYear) { + final var categoryMap = loadCategories(connection, year, includeIncome, + includePayments, includeInvestments, includeExpenses); + final var sql = Util.getResourceAsString("getTransactionDetail.sql") // + .replace("${databaseName}", "budget_" + year) // + .replace("${regexDatabaseName}", "budget"); + try (var stmt = connection.prepareStatement(sql)) { + try (var rs = stmt.executeQuery()) { + while (rs.next()) { + final var detail = new Detail(); + var i = 0; + detail.transactionId = rs.getInt(++i); + detail.date = rs.getDate(++i); + detail.source = rs.getString(++i); + detail.description = rs.getString(++i); + detail.amount = rs.getBigDecimal(++i); + final var categoryId = rs.getInt(++i); + detail.regex = rs.getString(++i); + detail.flags = rs.getInt(++i); + detail.requiredSource = rs.getString(++i); + detail.extraDescription = rs.getString(++i); + final var category = categoryMap.get(categoryId); + if (category != null && // + detail.isAfter(startDate) && // + detail.isBefore(endDate) && // + category.isIncluded()) { + category.addDetail(detail); + } + } + } + } + final var rootCategory = new Category(-1, null, "root"); + for (final Integer categoryId : categoryMap.keySet()) { + final var category = categoryMap.get(categoryId); + if ((category != null) && (category.getParent() == null)) { + rootCategory.addChild(category); + } + } + out.println("
"); + out.println("

" + year + " Categories

"); + out.println(""); + out.println(""); + final var grandHead = Util.getResourceAsString("grandHead.html"); + out.println(grandHead); + out.println(""); + out.println(""); + generateCategoryTable(out, rootCategory, 0); + out.println(""); + out.println("
"); + out.println("
"); + } + out.println(""); + out.println(""); + } + catch (final SQLException | NamingException e) { + throw new ServletException(e); + } + } + + private void generateCategoryTable(final PrintWriter out, final Category parent, + final int level) throws IOException { + final var details = parent.getDetails(); + final var categoryAmount = parent.getDetailTotal(); + final var categoryGrandAmount = parent.getGrandTotal(); + var categoryHeadHtml = Util.getResourceAsString("categoryHead.html") // + .replace("${name}", parent.getName()) // + .replace("${id}", parent.getId().toString()) // + .replace("${amount}", categoryAmount.toString()) // + .replace("${total}", categoryGrandAmount.toString()) // + .replace("${indent}", String.valueOf(level * 20) + "px"); + final int largestMonth = parent.getLargestMonth(); + for (int month = 0; month < 12; month++) { + categoryHeadHtml = categoryHeadHtml // + .replace("${total-" + month + "}", parent.getMonthGrandTotal(month).toString()) // + .replace("${color-" + month + "}", month == largestMonth ? "color: red; " : ""); + } + out.print(categoryHeadHtml); + final var categoryDetailIntroHtml = Util.getResourceAsString("categoryDetailIntro.html") // + .replace("${id}", parent.getId().toString()) // + .replace("${indent}", String.valueOf(level * 10) + "px"); + out.print(categoryDetailIntroHtml); + if (!details.isEmpty()) { + final var categoryDetailHeadHtml = Util.getResourceAsString("categoryDetailHead.html"); + out.print(categoryDetailHeadHtml); + } + Collections.sort(details); + for (final Detail detail : details) { + final var categoryDetailHtml = Util.getResourceAsString("categoryDetail.html") // + .replace("${source}", detail.source) // + .replace("${description}", detail.description) // + .replace("${date}", detail.date.toString()) // + .replace("${amount}", detail.amount.toString()) // + .replace("${extraDescription}", + detail.extraDescription == null ? "" : detail.extraDescription) // + .replace("${regex}", detail.regex) // + .replace("${flags}", getFlagsString(detail.flags)) // + .replace("${requiredSource}", + detail.requiredSource == null ? "" : detail.requiredSource)// + ; + out.print(categoryDetailHtml); + } + out.println(""); + out.println(""); + out.println(""); + final var categories = parent.getChildCategories(); + for (final Category category : categories) { + generateCategoryTable(out, category, level + 1); + } + } + + private String getFlagsString(final int flags) { + final List flagStrings = new ArrayList<>(); + if ((flags & Pattern.CASE_INSENSITIVE) != 0) { + flagStrings.add("Case Insensitive"); + } + if ((flags & Pattern.MULTILINE) != 0) { + flagStrings.add("Multiline"); + } + if ((flags & Pattern.DOTALL) != 0) { + flagStrings.add("Dotall"); + } + if ((flags & Pattern.UNICODE_CASE) != 0) { + flagStrings.add("Unicode Case"); + } + if ((flags & Pattern.CANON_EQ) != 0) { + flagStrings.add("Canon EQ"); + } + if ((flags & Pattern.UNIX_LINES) != 0) { + flagStrings.add("Unix Linex"); + } + if ((flags & Pattern.LITERAL) != 0) { + flagStrings.add("Literal"); + } + if ((flags & Pattern.UNICODE_CHARACTER_CLASS) != 0) { + flagStrings.add("Unicode Character Class"); + } + if ((flags & Pattern.COMMENTS) != 0) { + flagStrings.add("Comments"); + } + return flagStrings.toString(); + } + + interface CategoryFilter { + boolean include(Category category); + } + + private Map loadCategories(final Connection connection, final String year, + final boolean includeIncome, final boolean includePayments, + final boolean includeInvestments, final boolean includeExpenses) + throws SQLException, IOException { + Category unknownCategory = null; + final Map categories = new HashMap<>(); + final var sql = Util.getResourceAsString("getCategories.sql").replace("${databaseName}", + "budget"); + try (var stmt = connection.prepareStatement(sql)) { + try (var rs = stmt.executeQuery()) { + while (rs.next()) { + final Integer id = rs.getInt(1); + Integer parentId = rs.getInt(2); + if (rs.wasNull()) { + parentId = null; + } + final var name = rs.getString(3); + final var category = new Category(id, parentId, name); + categories.put(id, category); + if ("unknown".equals(name) && parentId == null) { + unknownCategory = category; + } + } + } + } + for (final Integer categoryId : categories.keySet()) { + final var category = categories.get(categoryId); + category.updateParent(categories); + } + if (unknownCategory == null) { + unknownCategory = new Category(-1, null, "unknown"); + categories.put(unknownCategory.getId(), unknownCategory); + } + for (final Integer categoryId : categories.keySet()) { + final var category = categories.get(categoryId); + final CategoryFilter filter = category1 -> { + final String categoryName = category1.getName(); + if ("income".equals(categoryName) || "refunds".equals(categoryName)) { + if (!includeIncome) { + return false; + } + } + else if ("payment".equals(categoryName)) { + if (!includePayments) { + return false; + } + } + else if ("investment".equals(categoryName)) { + if (!includeInvestments) { + return false; + } + } + else if (!includeExpenses) { + return false; + } + return true; + }; + final boolean included = filter.include(category); + category.setIncluded(included); + } + return categories; + } +} diff --git a/src/main/java/com/stephenschafer/budget/web/Category.java b/src/main/java/com/stephenschafer/budget/web/Category.java new file mode 100644 index 0000000..91cbe43 --- /dev/null +++ b/src/main/java/com/stephenschafer/budget/web/Category.java @@ -0,0 +1,169 @@ +package com.stephenschafer.budget.web; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Calendar; +import java.util.GregorianCalendar; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class Category { + private final Integer id; + private final Integer parentId; + private final String name; + private Category parent; + private boolean included; + private List details = new ArrayList<>(); + private final Map children = new HashMap<>(); + + public Category(final Integer id, final Integer parentId, final String name) { + this.id = id; + this.parentId = parentId; + this.name = name; + } + + public void updateParent(final Map categories) { + if (parentId == null) { + parent = null; + } + else { + parent = categories.get(parentId); + parent.addChild(this); + } + } + + public void addChild(final Category category) { + children.put(category.id, category); + } + + public Category getParent() { + return parent; + } + + public void setParent(final Category parent) { + this.parent = parent; + } + + public Integer getId() { + return id; + } + + public Integer getParentId() { + return parentId; + } + + public String getName() { + return name; + } + + public String getQualifiedName() { + final StringBuilder sb = new StringBuilder(); + if (parent != null) { + sb.append(parent.getQualifiedName()); + sb.append("."); + } + sb.append(name); + return sb.toString(); + } + + public List getDetails() { + return details; + } + + public void setDetails(final List details) { + this.details = details; + } + + public void addDetail(final Detail detail) { + details.add(detail); + } + + public Map getChildren() { + return children; + } + + public BigDecimal getDetailTotal() { + var amount = new BigDecimal(0); + for (final Detail detail : details) { + amount = amount.add(detail.amount); + } + return amount; + } + + public BigDecimal getMonthTotal(final int month) { + var amount = new BigDecimal(0); + for (final Detail detail : details) { + final Calendar cal = new GregorianCalendar(); + cal.setTime(detail.date); + if (month == cal.get(Calendar.MONTH)) { + amount = amount.add(detail.amount); + } + } + return amount; + } + + public BigDecimal getGrandTotal() { + var total = getDetailTotal(); + for (final Integer categoryId : children.keySet()) { + final var category = children.get(categoryId); + total = total.add(category.getGrandTotal()); + } + return total; + } + + public BigDecimal getMonthGrandTotal(final int month) { + var total = getMonthTotal(month); + for (final Integer categoryId : children.keySet()) { + final var category = children.get(categoryId); + total = total.add(category.getMonthGrandTotal(month)); + } + return total; + } + + public int getLargestMonth() { + BigDecimal max = new BigDecimal(0); + int maxMonth = -1; + int maxCount = 0; + for (int month = 0; month < 12; month++) { + final BigDecimal monthAmount = getMonthGrandTotal(month); + if (monthAmount.compareTo(max) == 0) { + maxCount++; + } + else if (monthAmount.compareTo(max) > 0) { + max = monthAmount; + maxMonth = month; + maxCount = 1; + } + } + return maxCount > 1 ? -1 : maxMonth; + } + + public List getChildCategories() { + final List categories = new ArrayList<>(); + for (final Integer categoryId : children.keySet()) { + final var category = children.get(categoryId); + categories.add(category); + } + categories.sort((arg0, arg1) -> { + final var name0 = arg0.getName(); + final var name1 = arg1.getName(); + final var comparison = name0.compareTo(name1); + if (comparison != 0) { + return comparison; + } + final var id0 = arg0.getId(); + final var id1 = arg1.getId(); + return id0.compareTo(id1); + }); + return categories; + } + + public boolean isIncluded() { + return included; + } + + public void setIncluded(final boolean include) { + this.included = include; + } +} diff --git a/src/main/java/com/stephenschafer/budget/web/Configuration.java b/src/main/java/com/stephenschafer/budget/web/Configuration.java new file mode 100644 index 0000000..2e5443f --- /dev/null +++ b/src/main/java/com/stephenschafer/budget/web/Configuration.java @@ -0,0 +1,120 @@ +package com.stephenschafer.budget.web; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.sql.Connection; +import java.sql.SQLException; +import java.util.Properties; + +import javax.naming.InitialContext; +import javax.naming.NamingException; +import javax.sql.DataSource; + +public class Configuration { + public static final Configuration INSTANCE; + private String jndiName; + private DbConnectionPool pool; + private String privilegedHost; + private String defaultDomain; + private boolean loaded; + + private Configuration() { + jndiName = null; + pool = null; + loaded = false; + } + + public void load() throws IOException, ClassNotFoundException, SQLException { + if (loaded) { + return; + } + final var systemProps = System.getProperties(); + final var catalinaHome = systemProps.getProperty("catalina.home"); + var propertiesFileName = System.getenv("BUDGET_PROPERTIES"); + if (propertiesFileName == null) { + propertiesFileName = catalinaHome + "/conf/Catalina/localhost/budget.properties"; + } + final var propertiesFile = new File(propertiesFileName); + final var properties = new Properties(); + try (var fis = new FileInputStream(propertiesFile)) { + properties.load(fis); + } + Logger.logFilename = properties.getProperty("log.filename"); + Logger.log("********************************* Starting"); + jndiName = properties.getProperty("db.jndi"); + final var url = properties.getProperty("db.url"); + final var username = properties.getProperty("db.username"); + final var password = properties.getProperty("db.password"); + if (jndiName == null) { + pool = new DbConnectionPool("com.mysql.jdbc.Driver", url, username, password); + } + privilegedHost = properties.getProperty("priv-host"); + final var defaultDomain = properties.getProperty("default-domain"); + this.defaultDomain = defaultDomain == null ? "schafer.cc" : defaultDomain; + Logger.log("initialized, jndiName = " + jndiName); + loaded = true; + } + + public Connection getConnection() throws SQLException, NamingException { + final var jndiName = getJndiName(); + if (jndiName != null) { + final var initialContext = new InitialContext(); + final var datasource = (DataSource) initialContext.lookup(jndiName); + SQLException lastException = null; + var i = 0; + while (i < 4) { + try { + return datasource.getConnection(); + } + catch (final SQLException e) { + Logger.log("Failed to get connection, " + (3 - i) + " retrys left", e); + lastException = e; + try { + Thread.sleep(5000L); + } + catch (final InterruptedException ex) { + } + ++i; + continue; + } + } + if (lastException != null) { + throw lastException; + } + } + return getPool().getConnection(8, false, true); + } + + public String getJndiName() { + return jndiName; + } + + public void setJndiName(final String jndiName) { + this.jndiName = jndiName; + } + + public DbConnectionPool getPool() { + return pool; + } + + public void setPool(final DbConnectionPool pool) { + this.pool = pool; + } + + static { + INSTANCE = new Configuration(); + } + + public String getPrivilegedHost() { + return privilegedHost; + } + + public String getDefaultDomain() { + return defaultDomain; + } + + public void setDefaultDomain(final String defaultDomain) { + this.defaultDomain = defaultDomain; + } +} diff --git a/src/main/java/com/stephenschafer/budget/web/DbConnectionPool.java b/src/main/java/com/stephenschafer/budget/web/DbConnectionPool.java new file mode 100644 index 0000000..443d4e3 --- /dev/null +++ b/src/main/java/com/stephenschafer/budget/web/DbConnectionPool.java @@ -0,0 +1,690 @@ +package com.stephenschafer.budget.web; + +import java.io.Serializable; +import java.net.SocketException; +import java.sql.Array; +import java.sql.Blob; +import java.sql.CallableStatement; +import java.sql.Clob; +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.DriverManager; +import java.sql.NClob; +import java.sql.PreparedStatement; +import java.sql.SQLClientInfoException; +import java.sql.SQLException; +import java.sql.SQLWarning; +import java.sql.SQLXML; +import java.sql.Savepoint; +import java.sql.Statement; +import java.sql.Struct; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.Queue; +import java.util.concurrent.Executor; +import java.util.logging.Level; +import java.util.logging.Logger; + +public class DbConnectionPool implements Serializable { + private static final long serialVersionUID = 1L; + private static final Logger LOGGER; + private final String uRL; + private final String username; + private final String password; + private final String driver; + private final Queue connections; + private long openConnectionIndex; + private final Map openConnections; + private long timeout; + static { + LOGGER = Logger.getLogger(DbConnectionPool.class.getName()); + } + + public DbConnectionPool(final String dbDriver, final String dbName, final String dbUsername, + final String dbPassword) throws ClassNotFoundException, SQLException { + connections = new LinkedList<>(); + openConnectionIndex = 0L; + openConnections = new HashMap<>(); + timeout = 900000L; + driver = dbDriver; + uRL = dbName; + username = dbUsername; + password = dbPassword; + validateConnection(); + } + + public final void validateConnection() throws ClassNotFoundException, SQLException { + DbConnectionPool.LOGGER.log(Level.FINEST, "Testing connection pool"); + DbConnectionPool.LOGGER.log(Level.FINEST, "Instantiating " + driver + "\n"); + Class.forName(driver); + DbConnectionPool.LOGGER.log(Level.FINEST, "Connecting"); + final Connection connection = DriverManager.getConnection(uRL, username, password); + try { + final DatabaseMetaData dbmd = connection.getMetaData(); + DbConnectionPool.LOGGER.log(Level.FINEST, + "Connection to " + dbmd.getDatabaseProductName() + " " + + dbmd.getDatabaseProductVersion() + " successful."); + } + finally { + connection.close(); + } + connection.close(); + } + + public final void clear() throws SQLException { + synchronized (this) { + while (!connections.isEmpty()) { + final PooledConnection connection = connections.remove(); + connection.reallyClose(); + } + } + } + + public final PooledConnection getConnection(final int transactionIsolation, + final boolean readOnly, final boolean autoCommit) throws SQLException { + while (true) { + PooledConnection oldConnection; + final long timeout; + synchronized (this) { + if (connections.isEmpty()) { + oldConnection = null; + } + else { + oldConnection = connections.remove(); + } + timeout = this.timeout; + } + if (oldConnection == null) { + DbConnectionPool.LOGGER.log(Level.FINEST, "Connecting"); + SQLException exception = null; + int retryCount = 0; + while (retryCount < 10) { + Connection newConnection; + try { + newConnection = DriverManager.getConnection(uRL, username, password); + } + catch (final SQLException e) { + if (!(e.getCause() instanceof SocketException)) { + throw e; + } + exception = e; + DbConnectionPool.LOGGER.log(Level.FINEST, "Retrying", e); + try { + Thread.sleep(1000L); + } + catch (final InterruptedException ex) { + } + ++retryCount; + newConnection = null; + } + if (newConnection != null) { + newConnection.setAutoCommit(true); + newConnection.setTransactionIsolation(transactionIsolation); + newConnection.setReadOnly(readOnly); + newConnection.setAutoCommit(autoCommit); + final long index = incrementOpenConnectionCount(); + return new PooledConnection(newConnection, index); + } + } + if (exception != null) { + throw exception; + } + } + if (oldConnection != null) { + if (oldConnection.isClosed()) { + DbConnectionPool.LOGGER.log(Level.WARNING, + "Pooled connection was already closed"); + } + else { + Label_0430: { + Label_0418: { + if ((timeout != 0L) && (System.currentTimeMillis() + - oldConnection.getLastAccess() >= timeout)) { + break Label_0418; + } + try { + oldConnection.setAutoCommit(true); + oldConnection.setTransactionIsolation(transactionIsolation); + oldConnection.setReadOnly(readOnly); + oldConnection.setAutoCommit(autoCommit); + synchronized (this) { + final Long key = Long.valueOf(oldConnection.getIndex()); + final OpenConnectionInfo info = openConnections.get(key); + if (info != null) { + DbConnectionPool.LOGGER.log(Level.WARNING, + "Overwriting open connection info: " + key + " " + + info); + } + openConnections.put(key, new OpenConnectionInfo()); + } + return oldConnection; + } + catch (final Exception e2) { + DbConnectionPool.LOGGER.log(Level.SEVERE, + "Unable to reuse DB connection", e2); + break Label_0430; + } + } + DbConnectionPool.LOGGER.log(Level.FINEST, "DB connection timed out"); + try { + oldConnection.reallyClose(); + } + catch (final Exception e2) { + DbConnectionPool.LOGGER.log(Level.SEVERE, + "Unable to really close DB connection", e2); + } + } + } + } + } + } + + private long incrementOpenConnectionCount() { + synchronized (this) { + final long index = openConnectionIndex++; + final Long key = Long.valueOf(index); + final OpenConnectionInfo info = openConnections.get(key); + if (info != null) { + DbConnectionPool.LOGGER.log(Level.WARNING, + "Overwriting open connection info: " + key + " " + info); + } + openConnections.put(key, new OpenConnectionInfo()); + return index; + } + } + + protected final void add(final PooledConnection connection) { + synchronized (this) { + final OpenConnectionInfo info = openConnections.remove( + Long.valueOf(connection.getIndex())); + if (info == null) { + DbConnectionPool.LOGGER.log(Level.WARNING, + "adding orphaned connection: " + connection.getIndex()); + } + else { + connections.offer(connection); + } + } + } + + public final long getOpenConnectionIndex() { + synchronized (this) { + return openConnectionIndex; + } + } + + public final OpenConnectionInfo getOpenConnectionInfo(final long index) { + synchronized (this) { + return openConnections.get(Long.valueOf(index)); + } + } + + public final int getOpenConnectionCount() { + synchronized (this) { + return openConnections.size(); + } + } + + public final int getPooledConnectionCount() { + synchronized (this) { + return connections.size(); + } + } + + @Override + public final String toString() { + final StringBuilder buf = new StringBuilder(); + buf.append("DB "); + buf.append(uRL); + buf.append(" "); + synchronized (this) { + buf.append("open: "); + buf.append(openConnections.size()); + buf.append(", pooled: "); + buf.append(connections.size()); + buf.append(", next: "); + buf.append(openConnectionIndex); + } + return buf.toString(); + } + + public final long getTimeout() { + synchronized (this) { + return timeout; + } + } + + public final void setTimeout(final long timeout) { + synchronized (this) { + this.timeout = timeout; + } + } + + public final String getDriver() { + return driver; + } + + public final String getURL() { + return uRL; + } + + public final String getPassword() { + return password; + } + + public final String getUsername() { + return username; + } + + public synchronized Map getOpenConnections() { + final Map map = new HashMap<>(); + for (final Long index : openConnections.keySet()) { + final OpenConnectionInfo info = openConnections.get(index); + map.put(index, Long.valueOf(info.timestamp)); + } + return map; + } + + public static final class OpenConnectionInfo { + public final long timestamp; + private final List stackTrace; + + public OpenConnectionInfo() { + timestamp = System.currentTimeMillis(); + final StackTraceElement[] steArray = Thread.currentThread().getStackTrace(); + final List stackTrace = new ArrayList<>( + steArray.length); + StackTraceElement[] array; + for (int length = (array = steArray).length, i = 0; i < length; ++i) { + final StackTraceElement element = array[i]; + stackTrace.add(element); + } + this.stackTrace = Collections.unmodifiableList(stackTrace); + } + + @Override + public String toString() { + final StringBuilder sb = new StringBuilder(); + sb.append("Timestamp: "); + sb.append(new Date(timestamp)); + sb.append("\n"); + for (final StackTraceElement element : stackTrace) { + sb.append(element); + sb.append("\n"); + } + return sb.toString(); + } + } + + public class PooledConnection implements Connection { + private final Connection connection; + private long lastAccess; + private boolean autoCommit; + private boolean readOnly; + private final long index; + + public PooledConnection(final Connection connection, final long index) { + lastAccess = System.currentTimeMillis(); + autoCommit = false; + readOnly = false; + this.index = index; + this.connection = connection; + try { + autoCommit = connection.getAutoCommit(); + } + catch (final SQLException e) { + DbConnectionPool.LOGGER.log(Level.WARNING, "Unable to get auto commit", e); + } + try { + readOnly = connection.isReadOnly(); + } + catch (final SQLException e) { + DbConnectionPool.LOGGER.log(Level.WARNING, "Unable to get read only", e); + } + } + + public void clearPool() throws SQLException { + clear(); + } + + @Override + public String nativeSQL(final String sql) throws SQLException { + return connection.nativeSQL(sql); + } + + @Override + public int hashCode() { + return connection.hashCode(); + } + + @Override + public Map> getTypeMap() throws SQLException { + return connection.getTypeMap(); + } + + @Override + public PreparedStatement prepareStatement(final String sql) throws SQLException { + final PreparedStatement stmt = connection.prepareStatement(sql); + if (connection.getAutoCommit() != autoCommit || connection.isReadOnly() != readOnly) { + final StringBuilder sb = new StringBuilder(); + String sep = ""; + if (connection.getAutoCommit() != autoCommit) { + sb.append(sep); + sep = " and "; + sb.append("autoCommit has changed from " + autoCommit); + } + if (connection.isReadOnly() != readOnly) { + sb.append(sep); + sep = " and "; + sb.append("readOnly has changed from " + readOnly); + } + sb.append(" in "); + sb.append(stmt); + DbConnectionPool.LOGGER.log(Level.WARNING, sb.toString()); + } + return stmt; + } + + @Override + public void setTransactionIsolation(final int level) throws SQLException { + connection.setTransactionIsolation(level); + } + + @Override + public String getCatalog() throws SQLException { + return connection.getCatalog(); + } + + @Override + public int getTransactionIsolation() throws SQLException { + return connection.getTransactionIsolation(); + } + + @Override + public void releaseSavepoint(final Savepoint savepoint) throws SQLException { + connection.releaseSavepoint(savepoint); + } + + @Override + public int getHoldability() throws SQLException { + return connection.getHoldability(); + } + + @Override + public CallableStatement prepareCall(final String sql, final int resultSetType, + final int resultSetConcurrency, final int resultSetHoldability) + throws SQLException { + return connection.prepareCall(sql, resultSetType, resultSetConcurrency, + resultSetHoldability); + } + + @Override + public boolean getAutoCommit() throws SQLException { + return connection.getAutoCommit(); + } + + @Override + public Statement createStatement() throws SQLException { + return connection.createStatement(); + } + + @Override + public CallableStatement prepareCall(final String sql) throws SQLException { + return connection.prepareCall(sql); + } + + @Override + public void setAutoCommit(final boolean autoCommit) throws SQLException { + this.autoCommit = autoCommit; + connection.setAutoCommit(autoCommit); + } + + @Override + public PreparedStatement prepareStatement(final String sql, final int autoGeneratedKeys) + throws SQLException { + return connection.prepareStatement(sql, autoGeneratedKeys); + } + + @Override + public void setReadOnly(final boolean readOnly) throws SQLException { + this.readOnly = readOnly; + connection.setReadOnly(readOnly); + } + + @Override + public CallableStatement prepareCall(final String sql, final int resultSetType, + final int resultSetConcurrency) throws SQLException { + return connection.prepareCall(sql, resultSetType, resultSetConcurrency); + } + + @Override + public SQLWarning getWarnings() throws SQLException { + return connection.getWarnings(); + } + + @Override + public PreparedStatement prepareStatement(final String sql, final int resultSetType, + final int resultSetConcurrency) throws SQLException { + return connection.prepareStatement(sql, resultSetType, resultSetConcurrency); + } + + @Override + public boolean equals(final Object obj) { + return connection.equals(obj); + } + + @Override + public PreparedStatement prepareStatement(final String sql, final int[] columnIndexes) + throws SQLException { + return connection.prepareStatement(sql, columnIndexes); + } + + @Override + public boolean isClosed() throws SQLException { + return connection.isClosed(); + } + + @Override + public PreparedStatement prepareStatement(final String sql, final int resultSetType, + final int resultSetConcurrency, final int resultSetHoldability) + throws SQLException { + return connection.prepareStatement(sql, resultSetType, resultSetConcurrency, + resultSetHoldability); + } + + @Override + public void commit() throws SQLException { + connection.commit(); + } + + @Override + public void clearWarnings() throws SQLException { + connection.clearWarnings(); + } + + @Override + public void setCatalog(final String catalog) throws SQLException { + connection.setCatalog(catalog); + } + + @Override + public void close() { + add(this); + lastAccess = System.currentTimeMillis(); + } + + public void reallyClose() throws SQLException { + connection.close(); + } + + @Override + public String toString() { + return connection.toString(); + } + + @Override + public DatabaseMetaData getMetaData() throws SQLException { + return connection.getMetaData(); + } + + @Override + public void rollback() throws SQLException { + connection.rollback(); + } + + @Override + public Savepoint setSavepoint(final String name) throws SQLException { + return connection.setSavepoint(name); + } + + @Override + public boolean isReadOnly() throws SQLException { + return connection.isReadOnly(); + } + + @Override + public Statement createStatement(final int resultSetType, final int resultSetConcurrency) + throws SQLException { + return connection.createStatement(resultSetType, resultSetConcurrency); + } + + @Override + public void rollback(final Savepoint savepoint) throws SQLException { + connection.rollback(savepoint); + } + + @Override + public PreparedStatement prepareStatement(final String sql, final String[] columnNames) + throws SQLException { + return connection.prepareStatement(sql, columnNames); + } + + @Override + public Savepoint setSavepoint() throws SQLException { + return connection.setSavepoint(); + } + + @Override + public Statement createStatement(final int resultSetType, final int resultSetConcurrency, + final int resultSetHoldability) throws SQLException { + return connection.createStatement(resultSetType, resultSetConcurrency, + resultSetHoldability); + } + + @Override + public void setTypeMap(final Map> map) throws SQLException { + connection.setTypeMap(map); + } + + @Override + public void setHoldability(final int holdability) throws SQLException { + connection.setHoldability(holdability); + } + + public long getLastAccess() { + return lastAccess; + } + + @Override + public Array createArrayOf(final String arg0, final Object[] arg1) throws SQLException { + return connection.createArrayOf(arg0, arg1); + } + + @Override + public Blob createBlob() throws SQLException { + return connection.createBlob(); + } + + @Override + public Clob createClob() throws SQLException { + return connection.createClob(); + } + + @Override + public NClob createNClob() throws SQLException { + return connection.createNClob(); + } + + @Override + public SQLXML createSQLXML() throws SQLException { + return connection.createSQLXML(); + } + + @Override + public Struct createStruct(final String arg0, final Object[] arg1) throws SQLException { + return connection.createStruct(arg0, arg1); + } + + @Override + public Properties getClientInfo() throws SQLException { + return connection.getClientInfo(); + } + + @Override + public String getClientInfo(final String arg0) throws SQLException { + return connection.getClientInfo(arg0); + } + + @Override + public boolean isValid(final int arg0) throws SQLException { + return connection.isValid(arg0); + } + + @Override + public void setClientInfo(final Properties arg0) throws SQLClientInfoException { + connection.setClientInfo(arg0); + } + + @Override + public void setClientInfo(final String arg0, final String arg1) + throws SQLClientInfoException { + connection.setClientInfo(arg0, arg1); + } + + @Override + public boolean isWrapperFor(final Class arg0) throws SQLException { + return connection.isWrapperFor(arg0); + } + + @Override + public T unwrap(final Class arg0) throws SQLException { + return connection.unwrap(arg0); + } + + public long getIndex() { + return index; + } + + @Override + public void setSchema(final String schema) throws SQLException { + connection.setSchema(schema); + } + + @Override + public String getSchema() throws SQLException { + return connection.getSchema(); + } + + @Override + public void abort(final Executor executor) throws SQLException { + connection.abort(executor); + } + + @Override + public void setNetworkTimeout(final Executor executor, final int milliseconds) + throws SQLException { + connection.setNetworkTimeout(executor, milliseconds); + } + + @Override + public int getNetworkTimeout() throws SQLException { + return connection.getNetworkTimeout(); + } + } +} diff --git a/src/main/java/com/stephenschafer/budget/web/Detail.java b/src/main/java/com/stephenschafer/budget/web/Detail.java new file mode 100644 index 0000000..32bce40 --- /dev/null +++ b/src/main/java/com/stephenschafer/budget/web/Detail.java @@ -0,0 +1,73 @@ +package com.stephenschafer.budget.web; + +import java.math.BigDecimal; +import java.util.Date; + +class Detail implements Comparable { + int transactionId; + String source; + String description; + Date date; + BigDecimal amount; + String regex; + int flags; + String requiredSource; + String extraDescription; + + @Override + public int compareTo(final Detail arg1) { + final var arg0 = this; + int comparison; + if (arg0.date == null) { + if (arg1.date != null) { + return -1; + } + } + else if (arg1.date == null) { + return 1; + } + else { + comparison = arg0.date.compareTo(arg1.date); + if (comparison != 0) { + return comparison; + } + } + if (arg0.source == null) { + if (arg1.source != null) { + return -1; + } + } + else if (arg1.source == null) { + return 1; + } + else { + comparison = arg0.source.compareTo(arg1.source); + if (comparison != 0) { + return comparison; + } + } + if (arg0.description == null) { + if (arg1.description != null) { + return -1; + } + } + else if (arg1.description == null) { + return 1; + } + else { + comparison = arg0.description.compareTo(arg1.description); + if (comparison != 0) { + return comparison; + } + } + return 0; + } + + public boolean isAfter(final Date startDate) { + return startDate == null || startDate.getTime() <= this.date.getTime(); + } + + public boolean isBefore(final Date endDate) { + return endDate == null || endDate.getTime() > this.date.getTime(); + } +} \ No newline at end of file diff --git a/src/main/java/com/stephenschafer/budget/web/Logger.java b/src/main/java/com/stephenschafer/budget/web/Logger.java new file mode 100644 index 0000000..88eb316 --- /dev/null +++ b/src/main/java/com/stephenschafer/budget/web/Logger.java @@ -0,0 +1,76 @@ +package com.stephenschafer.budget.web; + +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; +import java.io.PrintStream; +import java.io.PrintWriter; +import java.text.DateFormat; +import java.text.SimpleDateFormat; +import java.util.Date; + +public class Logger { + public static String logFilename; + public static boolean debugLogging; + + public static void debugLog(final Object message) { + if (Logger.debugLogging) { + log(message); + } + } + + public static void debugLog(final Object message, final Throwable t) { + if (Logger.debugLogging) { + log(message, t); + } + } + + public static void log(final Object message) { + log(message, null); + } + + public static void log(final Object message, final Throwable t) { + final DateFormat df = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS "); + try { + final String filename = Logger.logFilename; + if (filename == null) { + final PrintStream out = System.out; + out.print(df.format(new Date())); + out.print(" "); + out.println(message); + if (t != null) { + t.printStackTrace(out); + } + } + else { + final File file = new File(filename); + final FileWriter fw = new FileWriter(file, true); + final PrintWriter pw = new PrintWriter(fw); + try { + pw.print(df.format(new Date())); + pw.print(" "); + pw.println(message); + if (t != null) { + t.printStackTrace(pw); + } + } + finally { + pw.close(); + } + } + } + catch (final IOException e) { + System.out.println("Logger: Unable to log " + message); + if (t != null) { + t.printStackTrace(); + } + log("Log failure caused by:"); + e.printStackTrace(); + } + } + + static { + Logger.logFilename = null; + Logger.debugLogging = false; + } +} diff --git a/src/main/java/com/stephenschafer/budget/web/Util.java b/src/main/java/com/stephenschafer/budget/web/Util.java new file mode 100644 index 0000000..8ddefe0 --- /dev/null +++ b/src/main/java/com/stephenschafer/budget/web/Util.java @@ -0,0 +1,22 @@ +package com.stephenschafer.budget.web; + +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.Reader; + +public class Util { + public static String getResourceAsString(final String resourceName) throws IOException { + final ClassLoader classLoader = Thread.currentThread().getContextClassLoader(); + final StringBuilder sb = new StringBuilder(); + try (final Reader reader = new InputStreamReader( + classLoader.getResourceAsStream(resourceName))) { + final char[] buffer = new char[0x1000]; + int charsRead = reader.read(buffer); + while (charsRead >= 0) { + sb.append(buffer, 0, charsRead); + charsRead = reader.read(buffer); + } + } + return sb.toString(); + } +} diff --git a/src/main/resources/categories.css b/src/main/resources/categories.css new file mode 100644 index 0000000..2bcf5a3 --- /dev/null +++ b/src/main/resources/categories.css @@ -0,0 +1,6 @@ +.tableFixHead { overflow: auto; height: 100vh; } +.tableFixHead thead th { position: sticky; top: 0; z-index: 1; } + +table { border-collapse: collapse; width: 100%; } +th, td { padding: 8px 16px; } +th { background: #eee; } \ No newline at end of file diff --git a/src/main/resources/categories.js b/src/main/resources/categories.js new file mode 100644 index 0000000..4bee54e --- /dev/null +++ b/src/main/resources/categories.js @@ -0,0 +1,11 @@ +function showDetail(h2, categoryId) { + var tr = document.body.querySelector("#cat" + categoryId); + if(tr.style.display == "none") { + tr.style.display = ""; + h2.style.fontWeight = "bold"; + } + else { + tr.style.display = "none"; + h2.style.fontWeight = "normal"; + } +} diff --git a/src/main/resources/categoryDetail.html b/src/main/resources/categoryDetail.html new file mode 100644 index 0000000..20b4bda --- /dev/null +++ b/src/main/resources/categoryDetail.html @@ -0,0 +1,10 @@ + +${source} +${date} +${description} +${extraDescription} +${amount} +${regex} +${flags} +${requiredSource} + diff --git a/src/main/resources/categoryDetailHead.html b/src/main/resources/categoryDetailHead.html new file mode 100644 index 0000000..8cf9629 --- /dev/null +++ b/src/main/resources/categoryDetailHead.html @@ -0,0 +1,10 @@ + +Source +Date +Description +Extra Description +Amount +Regex +Flags +Required Source + diff --git a/src/main/resources/categoryDetailIntro.html b/src/main/resources/categoryDetailIntro.html new file mode 100644 index 0000000..692caf4 --- /dev/null +++ b/src/main/resources/categoryDetailIntro.html @@ -0,0 +1,3 @@ + + + diff --git a/src/main/resources/categoryHead.html b/src/main/resources/categoryHead.html new file mode 100644 index 0000000..982e5c5 --- /dev/null +++ b/src/main/resources/categoryHead.html @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/getCategories.sql b/src/main/resources/getCategories.sql new file mode 100644 index 0000000..0bd55ab --- /dev/null +++ b/src/main/resources/getCategories.sql @@ -0,0 +1,2 @@ +select id, parent_category_id, name +from ${databaseName}.category \ No newline at end of file diff --git a/src/main/resources/getTransactionDetail.sql b/src/main/resources/getTransactionDetail.sql new file mode 100644 index 0000000..c5e1d7b --- /dev/null +++ b/src/main/resources/getTransactionDetail.sql @@ -0,0 +1,3 @@ +select t.id, t.date, t.source, t.description, t.amount, r.category_id, r.regex, r.flags, r.source, r.description +from ${databaseName}.transaction t +inner join ${regexDatabaseName}.regex r on r.id = t.regex_id \ No newline at end of file diff --git a/src/main/resources/grandHead.html b/src/main/resources/grandHead.html new file mode 100644 index 0000000..de6f771 --- /dev/null +++ b/src/main/resources/grandHead.html @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/src/main/webapp/META-INF/MANIFEST.MF b/src/main/webapp/META-INF/MANIFEST.MF new file mode 100644 index 0000000..5e94951 --- /dev/null +++ b/src/main/webapp/META-INF/MANIFEST.MF @@ -0,0 +1,3 @@ +Manifest-Version: 1.0 +Class-Path: +
+

${name}

+
+

${amount}

+
+

${total}

+
+

${total-0}

+
+

${total-1}

+
+

${total-2}

+
+

${total-3}

+
+

${total-4}

+
+

${total-5}

+
+

${total-6}

+
+

${total-7}

+
+

${total-8}

+
+

${total-9}

+
+

${total-10}

+
+

${total-11}

+
CategoryAmountTotalJanFebMarAprMayJunJulAugSepOctNovDec