Initial commit.

This commit is contained in:
Steve Schafer 2025-07-13 09:38:54 -06:00
parent 8385efb9b0
commit 31f9272409
31 changed files with 1795 additions and 0 deletions

42
.classpath Normal file
View file

@ -0,0 +1,42 @@
<?xml version="1.0" encoding="UTF-8"?>
<classpath>
<classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-17">
<attributes>
<attribute name="module" value="true"/>
<attribute name="maven.pomderived" value="true"/>
</attributes>
</classpathentry>
<classpathentry kind="src" output="target/classes" path="src/main/java">
<attributes>
<attribute name="optional" value="true"/>
<attribute name="maven.pomderived" value="true"/>
</attributes>
</classpathentry>
<classpathentry excluding="**" kind="src" output="target/classes" path="src/main/resources">
<attributes>
<attribute name="maven.pomderived" value="true"/>
<attribute name="optional" value="true"/>
</attributes>
</classpathentry>
<classpathentry kind="src" output="target/test-classes" path="src/test/java">
<attributes>
<attribute name="test" value="true"/>
<attribute name="optional" value="true"/>
<attribute name="maven.pomderived" value="true"/>
</attributes>
</classpathentry>
<classpathentry excluding="**" kind="src" output="target/test-classes" path="src/test/resources">
<attributes>
<attribute name="test" value="true"/>
<attribute name="maven.pomderived" value="true"/>
<attribute name="optional" value="true"/>
</attributes>
</classpathentry>
<classpathentry kind="con" path="org.eclipse.m2e.MAVEN2_CLASSPATH_CONTAINER">
<attributes>
<attribute name="maven.pomderived" value="true"/>
<attribute name="org.eclipse.jst.component.dependency" value="/WEB-INF/lib"/>
</attributes>
</classpathentry>
<classpathentry kind="output" path="target/classes"/>
</classpath>

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
*.log
/target/

37
.project Normal file
View file

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8"?>
<projectDescription>
<name>com.stephenschafer.budget.web</name>
<comment></comment>
<projects>
</projects>
<buildSpec>
<buildCommand>
<name>org.eclipse.jdt.core.javabuilder</name>
<arguments>
</arguments>
</buildCommand>
<buildCommand>
<name>org.eclipse.wst.common.project.facet.core.builder</name>
<arguments>
</arguments>
</buildCommand>
<buildCommand>
<name>org.eclipse.wst.validation.validationbuilder</name>
<arguments>
</arguments>
</buildCommand>
<buildCommand>
<name>org.eclipse.m2e.core.maven2Builder</name>
<arguments>
</arguments>
</buildCommand>
</buildSpec>
<natures>
<nature>org.eclipse.m2e.core.maven2Nature</nature>
<nature>org.eclipse.jem.workbench.JavaEMFNature</nature>
<nature>org.eclipse.wst.common.modulecore.ModuleCoreNature</nature>
<nature>org.eclipse.wst.common.project.facet.core.nature</nature>
<nature>org.eclipse.jdt.core.javanature</nature>
<nature>org.eclipse.wst.jsdt.core.jsNature</nature>
</natures>
</projectDescription>

12
.settings/.jsdtscope Normal file
View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<classpath>
<classpathentry excluding="**/node_modules/*|**/*.min.js|**/bower_components/*" kind="src" path="src/main/webapp"/>
<classpathentry kind="con" path="org.eclipse.wst.jsdt.launching.JRE_CONTAINER"/>
<classpathentry kind="con" path="org.eclipse.wst.jsdt.launching.WebProject">
<attributes>
<attribute name="hide" value="true"/>
</attributes>
</classpathentry>
<classpathentry kind="con" path="org.eclipse.wst.jsdt.launching.baseBrowserLibrary"/>
<classpathentry kind="output" path=""/>
</classpath>

View file

@ -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

View file

@ -0,0 +1,4 @@
activeProfiles=
eclipse.preferences.version=1
resolveWorkspaceProjects=true
version=1

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?><project-modules id="moduleCoreId" project-version="1.5.0">
<wb-module deploy-name="budget.web-0.0.1-SNAPSHOT">
<wb-resource deploy-path="/" source-path="/target/m2e-wtp/web-resources"/>
<wb-resource deploy-path="/" source-path="/src/main/webapp"/>
<wb-resource deploy-path="/WEB-INF/classes" source-path="/src/main/java"/>
<wb-resource deploy-path="/" source-path="/WebContent" tag="defaultRootSource"/>
<wb-resource deploy-path="/WEB-INF/classes" source-path="/src/main/resources"/>
<wb-resource deploy-path="/WEB-INF/classes" source-path="/src/test/java"/>
<wb-resource deploy-path="/WEB-INF/classes" source-path="/src/test/resources"/>
<property name="java-output-path" value="/com.stephenschafer.budget.web/build/classes"/>
<property name="context-root" value="budget.web"/>
</wb-module>
</project-modules>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<faceted-project>
<fixed facet="jst.web"/>
<fixed facet="java"/>
<fixed facet="wst.jsdt.web"/>
<installed facet="wst.jsdt.web" version="1.0"/>
<installed facet="jst.web" version="4.0"/>
<installed facet="java" version="17"/>
</faceted-project>

View file

@ -0,0 +1 @@
org.eclipse.wst.jsdt.launching.baseBrowserLibrary

View file

@ -0,0 +1 @@
Window

View file

@ -0,0 +1,2 @@
disabled=06target
eclipse.preferences.version=1

View file

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="http://java.sun.com/xml/ns/j2ee" version="2.4"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://java.sun.com/xml/ns/j2ee http://java.sun.com/xml/ns/j2ee/web-app_2_4.xsd http://xmlns.jcp.org/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_2_5.xsd"
xmlns:web="http://xmlns.jcp.org/xml/ns/javaee">
<session-config>
<session-timeout>1440</session-timeout>
</session-config>
<servlet>
<servlet-name>CategoriesPage</servlet-name>
<servlet-class>com.stephenschafer.budget.web.CategoriesPage</servlet-class>
<load-on-startup>1</load-on-startup>
</servlet>
<servlet-mapping>
<servlet-name>CategoriesPage</servlet-name>
<url-pattern>/categories</url-pattern>
</servlet-mapping>
</web-app>

8
build-war Executable file
View file

@ -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

48
pom.xml Normal file
View file

@ -0,0 +1,48 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.stephenschafer</groupId>
<artifactId>budget.web</artifactId>
<version>0.0.1-SNAPSHOT</version>
<packaging>war</packaging>
<build>
<plugins>
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration>
<source>17</source>
<target>17</target>
</configuration>
</plugin>
<plugin>
<artifactId>maven-war-plugin</artifactId>
<version>3.4.0</version>
<configuration>
<warSourceDirectory>WebContent</warSourceDirectory>
</configuration>
</plugin>
</plugins>
</build>
<dependencies>
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>javax.servlet-api</artifactId>
<version>4.0.1</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>5.1.38</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-core</artifactId>
<version>2.18.1</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.18.1</version>
</dependency>
</dependencies>
</project>

View file

@ -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("<html>");
out.println("<head>");
final var js = Util.getResourceAsString("categories.js");
out.println("<script>");
out.println(js);
out.println("</script>");
final var css = Util.getResourceAsString("categories.css");
out.println("<style>");
out.println(css);
out.println("</style>");
out.println("</head>");
out.println("<body>");
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("<div class=\"tableFixHead\">");
out.println("<h1>" + year + " Categories</h1>");
out.println("<table>");
out.println("<thead>");
final var grandHead = Util.getResourceAsString("grandHead.html");
out.println(grandHead);
out.println("</thead>");
out.println("<tbody>");
generateCategoryTable(out, rootCategory, 0);
out.println("</tbody>");
out.println("</table>");
out.println("</div>");
}
out.println("</body>");
out.println("</html>");
}
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("</table>");
out.println("</td>");
out.println("</tr>");
final var categories = parent.getChildCategories();
for (final Category category : categories) {
generateCategoryTable(out, category, level + 1);
}
}
private String getFlagsString(final int flags) {
final List<String> 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<Integer, Category> 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<Integer, Category> 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;
}
}

View file

@ -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<Detail> details = new ArrayList<>();
private final Map<Integer, Category> 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<Integer, Category> 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<Detail> getDetails() {
return details;
}
public void setDetails(final List<Detail> details) {
this.details = details;
}
public void addDetail(final Detail detail) {
details.add(detail);
}
public Map<Integer, Category> 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<Category> getChildCategories() {
final List<Category> 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;
}
}

View file

@ -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;
}
}

View file

@ -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<PooledConnection> connections;
private long openConnectionIndex;
private final Map<Long, OpenConnectionInfo> 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<Long, Long> getOpenConnections() {
final Map<Long, Long> 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<StackTraceElement> stackTrace;
public OpenConnectionInfo() {
timestamp = System.currentTimeMillis();
final StackTraceElement[] steArray = Thread.currentThread().getStackTrace();
final List<StackTraceElement> 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<String, Class<?>> 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<String, Class<?>> 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> T unwrap(final Class<T> 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();
}
}
}

View file

@ -0,0 +1,73 @@
package com.stephenschafer.budget.web;
import java.math.BigDecimal;
import java.util.Date;
class Detail implements Comparable<Detail> {
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();
}
}

View file

@ -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;
}
}

View file

@ -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();
}
}

View file

@ -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; }

View file

@ -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";
}
}

View file

@ -0,0 +1,10 @@
<tr>
<td>${source}</td>
<td style="padding-left: 20px;">${date}</td>
<td style="padding-left: 20px;">${description}</td>
<td style="padding-left: 20px;">${extraDescription}</td>
<td style="padding-left: 20px; text-align: right;">${amount}</td>
<td style="padding-left: 20px;">${regex}</td>
<td style="padding-left: 20px;">${flags}</td>
<td style="padding-left: 20px;">${requiredSource}</td>
</tr>

View file

@ -0,0 +1,10 @@
<tr>
<th>Source</th>
<th>Date</th>
<th>Description</th>
<th>Extra Description</th>
<th style="text-align: right">Amount</th>
<th>Regex</th>
<th>Flags</th>
<th>Required Source</th>
</tr>

View file

@ -0,0 +1,3 @@
<tr id="cat${id}" style="display: none">
<td style="padding-left: ${indent}; " colspan="15">
<table>

View file

@ -0,0 +1,47 @@
<tr>
<td style="padding-left: ${indent}; ">
<h2 style="cursor: pointer; font-weight: normal;" onclick="showDetail(this, ${id})">${name}</h2>
</td>
<td style="padding-left: 15px; text-align: right; ">
<h2 style="font-weight: normal; ">${amount}</h2>
</td>
<td style="padding-left: 15px; text-align: right; ">
<h2 style="font-weight: normal; ">${total}</h2>
</td>
<td style="padding-left: 15px; text-align: right; ">
<h2 style="font-weight: normal; ${color-0}">${total-0}</h2>
</td>
<td style="padding-left: 15px; text-align: right; ">
<h2 style="font-weight: normal; ${color-1}">${total-1}</h2>
</td>
<td style="padding-left: 15px; text-align: right; ">
<h2 style="font-weight: normal; ${color-2}">${total-2}</h2>
</td>
<td style="padding-left: 15px; text-align: right; ">
<h2 style="font-weight: normal; ${color-3}">${total-3}</h2>
</td>
<td style="padding-left: 15px; text-align: right; ">
<h2 style="font-weight: normal; ${color-4}">${total-4}</h2>
</td>
<td style="padding-left: 15px; text-align: right; ">
<h2 style="font-weight: normal; ${color-5}">${total-5}</h2>
</td>
<td style="padding-left: 15px; text-align: right; ">
<h2 style="font-weight: normal; ${color-6}">${total-6}</h2>
</td>
<td style="padding-left: 15px; text-align: right; ">
<h2 style="font-weight: normal; ${color-7}">${total-7}</h2>
</td>
<td style="padding-left: 15px; text-align: right; ">
<h2 style="font-weight: normal; ${color-8}">${total-8}</h2>
</td>
<td style="padding-left: 15px; text-align: right; ">
<h2 style="font-weight: normal; ${color-9}">${total-9}</h2>
</td>
<td style="padding-left: 15px; text-align: right; ">
<h2 style="font-weight: normal; ${color-10}">${total-10}</h2>
</td>
<td style="padding-left: 15px; text-align: right; ">
<h2 style="font-weight: normal; ${color-11}">${total-11}</h2>
</td>
</tr>

View file

@ -0,0 +1,2 @@
select id, parent_category_id, name
from ${databaseName}.category

View file

@ -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

View file

@ -0,0 +1,17 @@
<tr>
<th>Category</th>
<th>Amount</th>
<th>Total</th>
<th>Jan</th>
<th>Feb</th>
<th>Mar</th>
<th>Apr</th>
<th>May</th>
<th>Jun</th>
<th>Jul</th>
<th>Aug</th>
<th>Sep</th>
<th>Oct</th>
<th>Nov</th>
<th>Dec</th>
</tr>

View file

@ -0,0 +1,3 @@
Manifest-Version: 1.0
Class-Path: