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 @@
+
+
+${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}
+ |
+
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 @@
+
+ | Category |
+ Amount |
+ Total |
+ Jan |
+ Feb |
+ Mar |
+ Apr |
+ May |
+ Jun |
+ Jul |
+ Aug |
+ Sep |
+ Oct |
+ Nov |
+ Dec |
+
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:
+
|