Compare commits

..

No commits in common. "old" and "master" have entirely different histories.
old ... master

65 changed files with 7345 additions and 239 deletions

8
.containerignore Normal file
View File

@ -0,0 +1,8 @@
*
!Cargo.*
!.sqlx/
!js/
!migrations/
!src/
!sql/
!templates/

26
.editorconfig Normal file
View File

@ -0,0 +1,26 @@
root = true
[*]
end_of_line = lf
insert_final_newline =true
charset = utf-8
[*.{html,css,tera}]
indent_style = space
indent_size = 2
[*.rs]
indent_style = space
indent_size = 4
[*.{js,ts}]
indent_style = space
indent_size = 4
[*.json]
indent_style = space
indent_size = 2
[*.sql]
indent_style = space
indent_size = 4

3
.gitignore vendored
View File

@ -1 +1,4 @@
/Rocket.toml
/firefly.token
/target
/.postgresql

10
.prettierrc Normal file
View File

@ -0,0 +1,10 @@
{
"overrides": [
{
"files": "*.html.tera",
"options": {
"parser": "html"
}
}
]
}

View File

@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM receipts WHERE id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int4"
]
},
"nullable": []
},
"hash": "1b1fccb2e49acf1402b1c473d4eaebd46034214df37c1d5b954a69027c2c037f"
}

View File

@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n count(id) AS \"count!\"\nFROM\n receipts\n",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count!",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
null
]
},
"hash": "34f56cde503100c09bbb378ce656af95abd81949be0c369a5d7225272e6c9c58"
}

View File

@ -0,0 +1,57 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO receipts (\n vendor, date, amount, notes, filename, image\n) VALUES (\n $1, $2, $3, $4, $5, $6\n)\nRETURNING\n id, vendor, date, amount, notes, filename\n",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int4"
},
{
"ordinal": 1,
"name": "vendor",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "date",
"type_info": "Timestamptz"
},
{
"ordinal": 3,
"name": "amount",
"type_info": "Numeric"
},
{
"ordinal": 4,
"name": "notes",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "filename",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Varchar",
"Timestamptz",
"Numeric",
"Text",
"Varchar",
"Bytea"
]
},
"nullable": [
false,
false,
false,
false,
true,
false
]
},
"hash": "a3fb24d192843a656565cebcc78a49f5cd14598e029d6bacfeb2326daf142119"
}

View File

@ -0,0 +1,28 @@
{
"db_name": "PostgreSQL",
"query": "SELECT filename, image FROM receipts WHERE id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "filename",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "image",
"type_info": "Bytea"
}
],
"parameters": {
"Left": [
"Int4"
]
},
"nullable": [
false,
false
]
},
"hash": "da76776380d89783df7270592d03afbd07c627342af3d03dc83932b152402941"
}

View File

@ -0,0 +1,52 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n id, vendor, date, amount, notes, filename\nFROM\n receipts\nWHERE\n id = $1\n",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int4"
},
{
"ordinal": 1,
"name": "vendor",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "date",
"type_info": "Timestamptz"
},
{
"ordinal": 3,
"name": "amount",
"type_info": "Numeric"
},
{
"ordinal": 4,
"name": "notes",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "filename",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Int4"
]
},
"nullable": [
false,
false,
false,
false,
true,
false
]
},
"hash": "e173b8a98dcdf9e4b259ae59e0ddcceef07851bbe9b429fd62b9d02bf01f52e1"
}

View File

@ -0,0 +1,53 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n id, vendor, date, amount, notes, filename\nFROM\n receipts\nORDER BY\n date DESC,\n id DESC\nLIMIT $1\nOFFSET $2\n",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int4"
},
{
"ordinal": 1,
"name": "vendor",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "date",
"type_info": "Timestamptz"
},
{
"ordinal": 3,
"name": "amount",
"type_info": "Numeric"
},
{
"ordinal": 4,
"name": "notes",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "filename",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Int8",
"Int8"
]
},
"nullable": [
false,
false,
false,
false,
true,
false
]
},
"hash": "ed7bf495d2eefe7b479a79cc2fc77de3b5a3db4415cd55ecbd21c28c108274a6"
}

3657
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,11 +1,24 @@
[package]
name = "receipts"
version = "0.1.0"
authors = ["Dustin C. Hatch <dustin@hatch.name>"]
edition = "2021"
rust-version = "1.80"
description = "Receipts management tool for Firefly-III"
homepage = "https://receipts.pyrocufflink.blue/"
license = "MIT OR Apache-2.0"
keywords = ["personal-finance", "receipts"]
[dependencies]
axum = { version = "0.8.1", default-features = false, features = ["form", "http1", "multipart", "tokio", "tracing"] }
tokio = { version = "1.43.0", default-features = false, features = ["rt", "macros", "net", "signal", "tracing"] }
tower-http = { version = "0.6.2", features = ["limit", "trace"] }
chrono = { version = "0.4.40", default-features = false, features = ["serde"] }
graphicsmagick = { version = "0.6.1", features = ["v1_3_38"] }
reqwest = { version = "0.12.12", features = ["json"] }
rocket = { version = "0.5.1", default-features = false, features = ["json"] }
rocket_db_pools = { version = "0.2.0", features = ["sqlx_macros", "sqlx_postgres"] }
rocket_dyn_templates = { version = "0.2.0", features = ["tera"] }
rust_decimal = { version = "1.36.0", features = ["serde-with-str"] }
serde = { version = "1.0.218", default-features = false, features = ["derive"] }
sqlx = { version = "~0.7.4", default-features = false, features = ["chrono", "macros", "migrate", "postgres", "rust_decimal", "time"] }
thiserror = "2.0.12"
tracing = "0.1.41"
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }

62
Containerfile Normal file
View File

@ -0,0 +1,62 @@
FROM git.pyrocufflink.net/containerimages/dch-base AS build
RUN --mount=type=cache,target=/var/cache \
microdnf install -y \
--setopt persistdir=/var/cache/dnf \
--setopt install_weak_deps=0 \
GraphicsMagick-devel \
cargo \
clang-devel \
openssl-devel \
&& :
WORKDIR /build
COPY Cargo.* .
COPY src src
COPY .sqlx .sqlx
COPY migrations migrations
COPY sql sql
RUN --mount=type=cache,target=/root/.cargo \
cargo build --release --locked
FROM git.pyrocufflink.net/containerimages/dch-base AS esbuild
RUN --mount=type=cache,target=/var/cache \
microdnf install -y \
--setopt persistdir=/var/cache/dnf \
--setopt install_weak_deps=0 \
npm \
&& :
WORKDIR /build
COPY js .
RUN --mount=type=cache,target=/root/.cargo \
npm ci && npm run build
FROM git.pyrocufflink.net/containerimages/dch-base
RUN --mount=type=cache,target=/var/cache \
microdnf install -y \
--setopt persistdir=/var/cache/dnf \
--setopt install_weak_deps=0 \
GraphicsMagick \
clang-libs \
ghostscript \
&& :
COPY --from=build /build/target/release/receipts /usr/local/bin
COPY --from=esbuild /build/dist /usr/local/share/receipts/static
COPY templates /usr/local/share/receipts/templates
WORKDIR /usr/local/share/receipts
ENTRYPOINT ["/usr/local/bin/receipts"]
ENV ROCKET_CONFIG=/etc/receipts/config.toml

201
LICENSE-Apache-2.0.txt Normal file
View File

@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

21
LICENSE-MIT.txt Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) [year] [fullname]
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

10
ci/Jenkinsfile vendored Normal file
View File

@ -0,0 +1,10 @@
@Library('containerimages')
@Library('dch')
_
buildContainerImage2(project: 'packages', defaultBranch: 'master')
stage('Deploy') {
when(env.BRANCH_NAME == "master") {
kubeRestartDeployment()
}
}

22
createdb.sh Normal file
View File

@ -0,0 +1,22 @@
#!/bin/sh
set -e
C() {
podman exec -u postgres postgresql "$@"
}
until C pg_isready; do sleep 1; done
if
! C psql -At -c 'SELECT 1 FROM pg_user WHERE usename = '\'receipts\' \
| grep -q .
then
C createuser -DERS receipts
fi
if
! C psql -At -c 'SELECT 1 FROM pg_database WHERE datname = '\'receipts\' \
| grep -q .
then
C createdb -O receipts receipts
fi

3
firefly.env Normal file
View File

@ -0,0 +1,3 @@
DB_CONNECTION=sqlite
APP_KEY=FVo8gylkwKlgtXbn4hjcdCuekDEbGyl2
MAIL_MAILER=log

View File

@ -1,72 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Upload Receipts</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css" />
<style>
#previews img {
padding: 0.25em;
}
</style>
</head>
<body>
<div class="container">
<h1>Upload Receipts</h1>
<form method="post" enctype="multipart/form-data" action="">
<div>
<input
id="image"
type="file"
name="image[]"
accept="image/*,*.png,*.jpg,*.jpeg,*.jpx"
capture="environment"
multiple
/>
</div>
<div class="grid" id="previews">
</div>
<div><input type="reset"><input type="submit" /></div>
</div>
</form>
<script>
(() => {
const image = document.getElementById("image");
const previews = document.getElementById("previews");
const clearPreviews = () => {
while (previews.children.length > 0) {
previews.removeChild(previews.children[0]);
}
}
const setPreviews = () => {
for (const i of image.files) {
const el = document.createElement("div");
const img = document.createElement("img");
img.src = URL.createObjectURL(i);
el.appendChild(img);
const txt = document.createElement("input");
txt.name = "notes[]";
txt.placeholder = "Notes ...";
el.appendChild(txt);
previews.appendChild(el);
}
}
image.addEventListener("change", (e) => {
clearPreviews();
setPreviews();
});
document.forms[0].addEventListener("reset", () => {
clearPreviews();
});
if (image.files.length > 0) {
setPreviews();
}
})();
</script>
</body>
</html>

2
js/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
dist/
node_modules/

16
js/ajaxUtil.ts Normal file
View File

@ -0,0 +1,16 @@
export async function getResponseError(r: Response): Promise<string> {
let ct = r.headers.get("Content-Type");
if (ct && ct.indexOf("json") > -1) {
const json = await r.json();
if (json.error) {
return json.error;
}
}
const html = await r.text();
if (html) {
const doc = new DOMParser().parseFromString(html, "text/html");
return doc.documentElement.textContent ?? "";
} else {
return r.statusText;
}
}

34
js/alert.ts Normal file
View File

@ -0,0 +1,34 @@
import SlAlert from "@shoelace-style/shoelace/dist/components/alert/alert.js";
import SlIcon from "@shoelace-style/shoelace/dist/components/icon/icon.js";
type AlertVariant = "primary" | "success" | "neutral" | "warning" | "danger";
export function notify(
message: string,
variant: AlertVariant = "primary",
iconName = "info-circle",
duration: number | null = 3000,
) {
const alert = new SlAlert();
const icon = new SlIcon();
icon.slot = "icon";
icon.name = iconName;
alert.variant = variant;
alert.open = true;
alert.style.position = "relative";
alert.closable = true;
if (duration) {
alert.duration = duration;
}
alert.appendChild(icon);
alert.appendChild(document.createTextNode(message));
document.body.append(alert);
alert.toast();
}
export function notifyError(
message: string,
duration: number | null = null,
) {
notify(message, "danger", "exclamation-octagon", duration);
}

58
js/build.js Normal file
View File

@ -0,0 +1,58 @@
import * as esbuild from "esbuild";
import * as path from "path";
import * as fs from "fs";
import * as url from "url";
const __dirname = url.fileURLToPath(new URL(".", import.meta.url));
const context = await esbuild.context({
entryPoints: [
"*.ts",
"icons/**/*",
],
outdir: "./dist",
bundle: true,
sourcemap: true,
platform: "node",
target: "esnext",
format: "esm",
loader: {
".png": "copy",
".ico": "copy",
},
plugins: [
{
name: "copy-shoelace-assets",
setup(build) {
let hasCopied = false;
build.onStart(() => {
if (!hasCopied) {
// We only copy one time, these files shouldn't be changing often.
const shoelaceAssets = path.resolve(
__dirname,
"node_modules/@shoelace-style/shoelace/dist/assets",
);
const outputDir = path.resolve(
__dirname,
"dist/shoelace/assets",
);
fs.rmSync(outputDir, { force: true, recursive: true });
fs.cpSync(shoelaceAssets, outputDir, {
recursive: true,
});
hasCopied = true;
}
});
},
},
],
});
await context.rebuild();
if (process.argv.includes("--watch")) {
await context.watch();
console.log("watching for file changes...");
} else {
await context.dispose();
}

177
js/camera.ts Normal file
View File

@ -0,0 +1,177 @@
import "@shoelace-style/shoelace/dist/components/icon-button/icon-button.js";
import "@shoelace-style/shoelace/dist/components/tooltip/tooltip.js";
import SlIconButton from "@shoelace-style/shoelace/dist/components/icon-button/icon-button.js";
import { notifyError } from "./alert.js";
const STYLESHEET = `
.camera-input {
text-align: center;
}
.camera-input video,
.camera-input canvas {
width: 100%;
height: auto;
object-fit: contain;
}
.camera-buttons sl-icon-button {
font-size: 3em;
margin: 0 0.5em;
}
.hidden {
display: none;
}
`;
export default class CameraInput extends HTMLElement {
elmVideo: HTMLVideoElement;
btnShutter: SlIconButton;
btnClear: SlIconButton;
constructor() {
super();
}
connectedCallback() {
this.innerHTML = "";
const shadow = this.attachShadow({ mode: "open" });
shadow.append(
Object.assign(document.createElement("style"), {
textContent: STYLESHEET,
}),
);
const wrapper = document.createElement("div");
wrapper.className = "camera-input";
shadow.appendChild(wrapper);
this.elmVideo = Object.assign(document.createElement("video"), {
className: "hidden",
});
this.elmVideo.addEventListener("canplay", () => {
this.btnShutter.disabled = false;
});
wrapper.appendChild(this.elmVideo);
const buttons = document.createElement("div");
buttons.classList.add("camera-buttons");
wrapper.appendChild(buttons);
this.btnShutter = Object.assign(
document.createElement("sl-icon-button"),
{
name: "camera",
label: "Take Photo",
disabled: true,
},
);
this.btnShutter.addEventListener("click", () => {
this.takePhoto();
});
const ttShutter = Object.assign(document.createElement("sl-tooltip"), {
content: "Take Photo",
});
ttShutter.appendChild(this.btnShutter);
buttons.appendChild(ttShutter);
this.btnClear = Object.assign(
document.createElement("sl-icon-button"),
{
name: "trash",
label: "Start Over",
disabled: true,
className: "hidden",
},
);
this.btnClear.addEventListener("click", () => {
this.clearCamera();
this.startCamera();
});
const ttClear = Object.assign(document.createElement("sl-tooltip"), {
content: "Start Over",
});
ttClear.appendChild(this.btnClear);
buttons.appendChild(ttClear);
}
public async getBlob(): Promise<Blob | null> {
const canvas = this.shadowRoot!.querySelector("canvas");
return await new Promise((resolve) => {
if (canvas) {
canvas.toBlob((blob) => {
resolve(blob);
}, "image/jpeg");
} else {
resolve(null);
}
});
}
async clearCamera() {
this.elmVideo.pause();
this.elmVideo.srcObject = null;
this.elmVideo.classList.add("hidden");
this.elmVideo.parentNode
?.querySelectorAll("canvas")
.forEach((e) => e.remove());
this.btnShutter.disabled = true;
this.btnShutter.classList.add("hidden");
this.btnClear.disabled = true;
this.btnClear.classList.add("hidden");
this.sendReady(false);
}
sendReady(hasPhoto: boolean) {
this.dispatchEvent(new CustomEvent("ready", { detail: { hasPhoto } }));
}
async startCamera() {
let stream: MediaStream;
try {
stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: {
ideal: "environment",
},
width: { ideal: 1280 },
height: { ideal: 720 },
},
audio: false,
});
} catch (ex) {
console.error(ex);
notifyError(`${ex}`);
return;
}
this.btnShutter.classList.remove("hidden");
this.elmVideo.classList.remove("hidden");
this.elmVideo.srcObject = stream;
this.elmVideo.play();
}
takePhoto() {
this.btnShutter.disabled = true;
this.btnShutter.classList.add("hidden");
this.elmVideo.pause();
const canvas = document.createElement("canvas");
const context = canvas.getContext("2d");
if (!context) {
notifyError("Failed to get canvas 2D rendering context");
return;
}
const width = this.elmVideo.videoWidth;
const height = this.elmVideo.videoHeight;
canvas.width = width;
canvas.height = height;
context.drawImage(this.elmVideo, 0, 0, width, height);
this.elmVideo.srcObject = null;
this.elmVideo.classList.add("hidden");
this.elmVideo.after(canvas);
this.btnClear.disabled = false;
this.btnClear.classList.remove("hidden");
this.sendReady(true);
}
}
customElements.define("camera-input", CameraInput);

90
js/common.css Normal file
View File

@ -0,0 +1,90 @@
:host,
body {
font-family: var(--sl-font-sans);
color: var(--sl-color-neutral-900);
}
body:has(div#page-loading) main {
opacity: 0;
}
main {
transition: 1s opacity;
}
#page-loading {
animation: 2s linear infinite pulse;
align-items: center;
bottom: 0;
display: flex;
font-size: 24pt;
justify-content: center;
left: 0;
position: absolute;
right: 0;
top: 0;
}
@keyframes pulse {
0% {
opacity: 0;
}
50% {
opacity: 1;
}
100% {
opacity: 0;
}
}
@media screen and (min-width: 900px) {
body {
margin: auto;
max-width: 1000px;
}
}
form footer {
text-align: right;
}
form footer sl-button {
margin: 0.5em 0.25em;
}
table {
width: 100%;
border-collapse: collapse;
}
tr {
border-bottom: 1px solid var(--sl-color-neutral-200);
}
table td,
table th {
padding: 1rem;
}
col.shrink {
text-align: center;
width: 1px;
white-space: nowrap;
}
.table-actions {
text-align: right;
}
sl-input[data-user-invalid]::part(base),
sl-select[data-user-invalid]::part(combobox),
sl-checkbox[data-user-invalid]::part(control) {
border-color: var(--sl-color-danger-600);
}
sl-input:focus-within[data-user-invalid]::part(base),
sl-select:focus-within[data-user-invalid]::part(combobox),
sl-checkbox:focus-within[data-user-invalid]::part(control) {
border-color: var(--sl-color-danger-600);
box-shadow: 0 0 0 var(--sl-focus-ring-width) var(--sl-color-danger-300);
}

20
js/common.ts Normal file
View File

@ -0,0 +1,20 @@
import "@shoelace-style/shoelace/dist/themes/light.css";
import "@shoelace-style/shoelace/dist/themes/dark.css";
import "./common.css";
const mql = window.matchMedia("(prefers-color-scheme: dark");
const setDarkMode = () => {
if (mql.matches) {
document.documentElement.classList.add("sl-theme-dark");
} else {
document.documentElement.classList.remove("sl-theme-dark");
}
};
mql.addEventListener("change", setDarkMode);
setDarkMode();
document.addEventListener("DOMContentLoaded", () => {
const elm = document.getElementById("page-loading");
if (elm) elm.remove();
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

BIN
js/icons/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

BIN
js/icons/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

BIN
js/icons/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

648
js/package-lock.json generated Normal file
View File

@ -0,0 +1,648 @@
{
"name": "receipts",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "receipts",
"dependencies": {
"@shoelace-style/shoelace": "^2.20.0"
},
"devDependencies": {
"esbuild": "^0.25.0"
}
},
"node_modules/@ctrl/tinycolor": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-4.1.0.tgz",
"integrity": "sha512-WyOx8cJQ+FQus4Mm4uPIZA64gbk3Wxh0so5Lcii0aJifqwoVOlfFtorjLE0Hen4OYyHZMXDWqMmaQemBhgxFRQ==",
"license": "MIT",
"engines": {
"node": ">=14"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.0.tgz",
"integrity": "sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.0.tgz",
"integrity": "sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.0.tgz",
"integrity": "sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.0.tgz",
"integrity": "sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.0.tgz",
"integrity": "sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.0.tgz",
"integrity": "sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.0.tgz",
"integrity": "sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.0.tgz",
"integrity": "sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.0.tgz",
"integrity": "sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.0.tgz",
"integrity": "sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.0.tgz",
"integrity": "sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.0.tgz",
"integrity": "sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.0.tgz",
"integrity": "sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.0.tgz",
"integrity": "sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.0.tgz",
"integrity": "sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.0.tgz",
"integrity": "sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.0.tgz",
"integrity": "sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.0.tgz",
"integrity": "sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.0.tgz",
"integrity": "sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.0.tgz",
"integrity": "sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.0.tgz",
"integrity": "sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.0.tgz",
"integrity": "sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.0.tgz",
"integrity": "sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.0.tgz",
"integrity": "sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.0.tgz",
"integrity": "sha512-ZENoHJBxA20C2zFzh6AI4fT6RraMzjYw4xKWemRTRmRVtN9c5DcH9r/f2ihEkMjOW5eGgrwCslG/+Y/3bL+DHQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@floating-ui/core": {
"version": "1.6.9",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.9.tgz",
"integrity": "sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.9"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.6.13",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.13.tgz",
"integrity": "sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.6.0",
"@floating-ui/utils": "^0.2.9"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz",
"integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==",
"license": "MIT"
},
"node_modules/@lit-labs/ssr-dom-shim": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.3.0.tgz",
"integrity": "sha512-nQIWonJ6eFAvUUrSlwyHDm/aE8PBDu5kRpL0vHMg6K8fK3Diq1xdPjTnsJSwxABhaZ+5eBi1btQB5ShUTKo4nQ==",
"license": "BSD-3-Clause"
},
"node_modules/@lit/react": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/@lit/react/-/react-1.0.7.tgz",
"integrity": "sha512-cencnwwLXQKiKxjfFzSgZRngcWJzUDZi/04E0fSaF86wZgchMdvTyu+lE36DrUfvuus3bH8+xLPrhM1cTjwpzw==",
"license": "BSD-3-Clause",
"peerDependencies": {
"@types/react": "17 || 18 || 19"
}
},
"node_modules/@lit/reactive-element": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-2.0.4.tgz",
"integrity": "sha512-GFn91inaUa2oHLak8awSIigYz0cU0Payr1rcFsrkf5OJ5eSPxElyZfKh0f2p9FsTiZWXQdWGJeXZICEfXXYSXQ==",
"license": "BSD-3-Clause",
"dependencies": {
"@lit-labs/ssr-dom-shim": "^1.2.0"
}
},
"node_modules/@shoelace-style/animations": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@shoelace-style/animations/-/animations-1.2.0.tgz",
"integrity": "sha512-avvo1xxkLbv2dgtabdewBbqcJfV0e0zCwFqkPMnHFGbJbBHorRFfMAHh1NG9ymmXn0jW95ibUVH03E1NYXD6Gw==",
"license": "MIT",
"funding": {
"type": "individual",
"url": "https://github.com/sponsors/claviska"
}
},
"node_modules/@shoelace-style/localize": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/@shoelace-style/localize/-/localize-3.2.1.tgz",
"integrity": "sha512-r4C9C/5kSfMBIr0D9imvpRdCNXtUNgyYThc4YlS6K5Hchv1UyxNQ9mxwj+BTRH2i1Neits260sR3OjKMnplsFA==",
"license": "MIT"
},
"node_modules/@shoelace-style/shoelace": {
"version": "2.20.0",
"resolved": "https://registry.npmjs.org/@shoelace-style/shoelace/-/shoelace-2.20.0.tgz",
"integrity": "sha512-Qq/kPtWC//HVyHX6EZ/i5y9zTMORjqV4lrxU2sbHLh+qdc9DlroYVSSqa2eqkmSzeLO+gHPZrjYmxDTja85iAA==",
"license": "MIT",
"dependencies": {
"@ctrl/tinycolor": "^4.1.0",
"@floating-ui/dom": "^1.6.12",
"@lit/react": "^1.0.6",
"@shoelace-style/animations": "^1.2.0",
"@shoelace-style/localize": "^3.2.1",
"composed-offset-position": "^0.0.6",
"lit": "^3.2.1",
"qr-creator": "^1.0.0"
},
"engines": {
"node": ">=14.17.0"
},
"funding": {
"type": "individual",
"url": "https://github.com/sponsors/claviska"
}
},
"node_modules/@types/react": {
"version": "19.0.10",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.10.tgz",
"integrity": "sha512-JuRQ9KXLEjaUNjTWpzuR231Z2WpIwczOkBEIvbHNCzQefFIT0L8IqE6NV6ULLyC1SI/i234JnDoMkfg+RjQj2g==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT"
},
"node_modules/composed-offset-position": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/composed-offset-position/-/composed-offset-position-0.0.6.tgz",
"integrity": "sha512-Q7dLompI6lUwd7LWyIcP66r4WcS9u7AL2h8HaeipiRfCRPLMWqRx8fYsjb4OHi6UQFifO7XtNC2IlEJ1ozIFxw==",
"license": "MIT",
"peerDependencies": {
"@floating-ui/utils": "^0.2.5"
}
},
"node_modules/csstype": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"license": "MIT",
"peer": true
},
"node_modules/esbuild": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.0.tgz",
"integrity": "sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.25.0",
"@esbuild/android-arm": "0.25.0",
"@esbuild/android-arm64": "0.25.0",
"@esbuild/android-x64": "0.25.0",
"@esbuild/darwin-arm64": "0.25.0",
"@esbuild/darwin-x64": "0.25.0",
"@esbuild/freebsd-arm64": "0.25.0",
"@esbuild/freebsd-x64": "0.25.0",
"@esbuild/linux-arm": "0.25.0",
"@esbuild/linux-arm64": "0.25.0",
"@esbuild/linux-ia32": "0.25.0",
"@esbuild/linux-loong64": "0.25.0",
"@esbuild/linux-mips64el": "0.25.0",
"@esbuild/linux-ppc64": "0.25.0",
"@esbuild/linux-riscv64": "0.25.0",
"@esbuild/linux-s390x": "0.25.0",
"@esbuild/linux-x64": "0.25.0",
"@esbuild/netbsd-arm64": "0.25.0",
"@esbuild/netbsd-x64": "0.25.0",
"@esbuild/openbsd-arm64": "0.25.0",
"@esbuild/openbsd-x64": "0.25.0",
"@esbuild/sunos-x64": "0.25.0",
"@esbuild/win32-arm64": "0.25.0",
"@esbuild/win32-ia32": "0.25.0",
"@esbuild/win32-x64": "0.25.0"
}
},
"node_modules/lit": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/lit/-/lit-3.2.1.tgz",
"integrity": "sha512-1BBa1E/z0O9ye5fZprPtdqnc0BFzxIxTTOO/tQFmyC/hj1O3jL4TfmLBw0WEwjAokdLwpclkvGgDJwTIh0/22w==",
"license": "BSD-3-Clause",
"dependencies": {
"@lit/reactive-element": "^2.0.4",
"lit-element": "^4.1.0",
"lit-html": "^3.2.0"
}
},
"node_modules/lit-element": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lit-element/-/lit-element-4.1.1.tgz",
"integrity": "sha512-HO9Tkkh34QkTeUmEdNYhMT8hzLid7YlMlATSi1q4q17HE5d9mrrEHJ/o8O2D0cMi182zK1F3v7x0PWFjrhXFew==",
"license": "BSD-3-Clause",
"dependencies": {
"@lit-labs/ssr-dom-shim": "^1.2.0",
"@lit/reactive-element": "^2.0.4",
"lit-html": "^3.2.0"
}
},
"node_modules/lit-html": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/lit-html/-/lit-html-3.2.1.tgz",
"integrity": "sha512-qI/3lziaPMSKsrwlxH/xMgikhQ0EGOX2ICU73Bi/YHFvz2j/yMCIrw4+puF2IpQ4+upd3EWbvnHM9+PnJn48YA==",
"license": "BSD-3-Clause",
"dependencies": {
"@types/trusted-types": "^2.0.2"
}
},
"node_modules/qr-creator": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/qr-creator/-/qr-creator-1.0.0.tgz",
"integrity": "sha512-C0cqfbS1P5hfqN4NhsYsUXePlk9BO+a45bAQ3xLYjBL3bOIFzoVEjs79Fado9u9BPBD3buHi3+vY+C8tHh4qMQ==",
"license": "MIT"
}
}
}

13
js/package.json Normal file
View File

@ -0,0 +1,13 @@
{
"name": "receipts",
"type": "module",
"dependencies": {
"@shoelace-style/shoelace": "^2.20.0"
},
"devDependencies": {
"esbuild": "^0.25.0"
},
"scripts": {
"build": "node build.js"
}
}

176
js/receipt-form.ts Normal file
View File

@ -0,0 +1,176 @@
import "@shoelace-style/shoelace/dist/components/button/button.js";
import "@shoelace-style/shoelace/dist/components/details/details.js";
import "@shoelace-style/shoelace/dist/components/icon/icon.js";
import "@shoelace-style/shoelace/dist/components/input/input.js";
import "@shoelace-style/shoelace/dist/components/option/option.js";
import "@shoelace-style/shoelace/dist/components/spinner/spinner.js";
import "@shoelace-style/shoelace/dist/components/select/select.js";
import "@shoelace-style/shoelace/dist/components/switch/switch.js";
import "@shoelace-style/shoelace/dist/components/textarea/textarea.js";
import "./shoelace.js";
import "./camera.ts";
import CameraInput from "./camera.ts";
import SlButton from "@shoelace-style/shoelace/dist/components/button/button.js";
import SlSelect from "@shoelace-style/shoelace/dist/components/select/select.js";
import SlSwitch from "@shoelace-style/shoelace/dist/components/switch/switch.js";
import { notify, notifyError } from "./alert";
import { getResponseError } from "./ajaxUtil.js";
const form = document.forms[0];
const cameraInput = form.querySelector("camera-input") as CameraInput;
const btnSubmit = form.querySelector("sl-button[type='submit']") as SlButton;
const btnUpload = form.querySelector(
"sl-button[class='choose-file']",
) as SlButton;
const inpImage = form.photo as HTMLInputElement;
const imgPreview = document.getElementById(
"upload-preview",
) as HTMLImageElement;
const xactselect = document.getElementById("transactions") as SlSelect;
let dirty = false;
window.addEventListener("beforeunload", function (evt) {
if (dirty) {
evt.preventDefault();
}
});
form.querySelectorAll("sl-input, sl-textarea, input").forEach((inp) => {
let eventName = inp.tagName == "input" ? "cange" : "sl-change";
inp.addEventListener(eventName, (evt) => {
if ((evt.target as HTMLInputElement).value) {
dirty = true;
}
});
});
form.addEventListener("submit", async (evt) => {
evt.preventDefault();
btnSubmit.loading = true;
const data = new FormData(form);
if (!inpImage.files?.length) {
data.delete("photo");
const blob = await cameraInput.getBlob();
if (blob) {
data.append("photo", blob, "photo.jpg");
}
}
let r: Response;
try {
r = await fetch("", {
method: "POST",
body: data,
});
} catch (e) {
notifyError(`Failed to submit form: ${e}`);
return;
} finally {
btnSubmit.loading = false;
}
if (r.ok) {
notify("Successfully uploaded receipt", undefined, undefined, null);
dirty = false;
window.location.href = "/receipts";
} else {
const err = await getResponseError(r);
notifyError(err);
}
});
form.addEventListener("reset", () => {
dirty = false;
});
cameraInput.addEventListener("ready", ((evt: CustomEvent) => {
btnSubmit.disabled = !evt.detail.hasPhoto;
btnUpload.disabled = !!evt.detail.hasPhoto;
if (!!evt.detail.hasPhoto) {
inpImage.value = "";
imgPreview.src = "";
}
}) as EventListener);
const cameraDetails = document.querySelector(
"sl-details[summary='Take Photo']",
)!;
let cameraInitialized = false;
cameraDetails.addEventListener("sl-show", () => {
if (!cameraInitialized) {
cameraInitialized = true;
cameraInput.startCamera();
}
});
btnUpload.addEventListener("click", (evt) => {
evt.preventDefault();
form.photo.showPicker();
});
inpImage.addEventListener("change", () => {
if (inpImage.files) {
const file = inpImage.files[0];
if (file) {
btnSubmit.disabled = false;
imgPreview.src = URL.createObjectURL(file);
}
}
});
async function fetchTransactions() {
let r: Response;
try {
r = await fetch("/transactions");
} catch (e) {
notifyError(e.toString());
return;
} finally {
xactselect.placeholder = "";
xactselect.querySelectorAll("sl-spinner").forEach((e) => e.remove());
}
if (!r.ok) {
notifyError(await getResponseError(r), 10000);
return;
}
xactselect.placeholder = "Select existing transaction";
let prev = xactselect.firstChild;
for (const xact of await r.json()) {
const option = document.createElement("sl-option");
option.value = xact.id;
option.textContent = `${xact.description}${xact.date}$${xact.amount}`;
option.dataset.amount = xact.amount;
option.dataset.date = xact.date.split("T")[0];
option.dataset.vendor = xact.description;
option.dataset.is_restaurant = xact.is_restaurant;
xactselect.insertBefore(option, prev);
}
}
xactselect.addEventListener("sl-change", () => {
const value = xactselect.value;
if (!value) {
return;
}
const option: HTMLOptionElement | null = xactselect.querySelector(
`[value="${value}"]`,
);
if (!option) {
return;
}
form.querySelectorAll("sl-input").forEach((elm) => {
if (elm.name && elm.value !== undefined) {
const value = option.dataset[elm.name];
if (value) {
elm.value = value;
}
}
});
(form.querySelector("[name='is_restaurant']") as SlSwitch).checked =
option.dataset.is_restaurant == "true";
});
fetchTransactions();

89
js/receipt-list.ts Normal file
View File

@ -0,0 +1,89 @@
import "@shoelace-style/shoelace/dist/components/alert/alert.js";
import "@shoelace-style/shoelace/dist/components/button/button.js";
import "@shoelace-style/shoelace/dist/components/card/card.js";
import "@shoelace-style/shoelace/dist/components/dialog/dialog.js";
import "@shoelace-style/shoelace/dist/components/icon/icon.js";
import "@shoelace-style/shoelace/dist/components/icon-button/icon-button.js";
import "./shoelace.js";
import SlDialog from "@shoelace-style/shoelace/dist/components/dialog/dialog.js";
import { notify, notifyError } from "./alert.js";
import { getResponseError } from "./ajaxUtil.js";
const dlgDelete = document.getElementById("confirm-delete") as SlDialog;
const confirmDelete = (
id: string,
amount: string,
date: string,
vendor: string,
) => {
dlgDelete.querySelector(".amount")!.textContent = amount;
dlgDelete.querySelector(".date")!.textContent = date;
dlgDelete.querySelector(".receipt-id")!.textContent = id;
dlgDelete.querySelector(".vendor")!.textContent = vendor;
dlgDelete.show();
};
const deleteReceipt = async (receiptId: string): Promise<boolean> => {
let r: Response;
try {
r = await fetch(`/receipts/${receiptId}`, { method: "DELETE" });
} catch (e) {
notifyError(e.toString());
return false;
}
if (r.ok) {
notify(`Deleted receipt ${receiptId}`);
return true;
} else {
const err = await getResponseError(r);
notifyError(err);
return false;
}
};
document.querySelectorAll(".receipt-card").forEach((card) => {
const amount = card.querySelector(".amount")!.textContent!;
const date = (card.querySelector(".date") as HTMLElement).dataset.date!;
const vendor = card.querySelector(".vendor")!.textContent!;
const btn = card.querySelector("sl-icon-button[name='trash']");
if (btn) {
btn.addEventListener("click", (evt) => {
evt.preventDefault();
confirmDelete(
(card as HTMLElement).dataset.receiptId!,
amount,
date,
vendor,
);
});
}
});
dlgDelete
.querySelector("sl-button[aria-label='No']")!
.addEventListener("click", () => {
dlgDelete.hide();
});
dlgDelete
.querySelector("sl-button[aria-label='Yes']")!
.addEventListener("click", async () => {
dlgDelete.hide();
const receiptId = dlgDelete.querySelector(".receipt-id")?.textContent;
if (receiptId) {
const success = await deleteReceipt(receiptId);
if (success) {
const card = document.querySelector(
`sl-card[data-receipt-id="${receiptId}"]`,
) as HTMLElement;
card.style.opacity = "0";
setTimeout(() => {
card.remove();
}, 1000);
}
}
});

6
js/receipt.ts Normal file
View File

@ -0,0 +1,6 @@
import "@shoelace-style/shoelace/dist/components/button/button.js";
import "@shoelace-style/shoelace/dist/components/details/details.js";
import "@shoelace-style/shoelace/dist/components/input/input.js";
import "@shoelace-style/shoelace/dist/components/textarea/textarea.js";
import "./shoelace.js";

2
js/shoelace.ts Normal file
View File

@ -0,0 +1,2 @@
import { setBasePath } from "@shoelace-style/shoelace/dist/utilities/base-path.js";
setBasePath("/static/shoelace/");

19
js/transaction.css Normal file
View File

@ -0,0 +1,19 @@
.invisible {
display: none !important;
}
#photo-view {
text-align: center;
}
#photo-view video,
#photo-view canvas {
width: 100%;
height: auto;
object-fit: contain;
}
#photo-view sl-icon-button {
font-size: 3em;
margin: 0 0.5em;
}

183
js/transaction.ts Normal file
View File

@ -0,0 +1,183 @@
import "./transaction.css";
import "@shoelace-style/shoelace/dist/components/button/button.js";
import "@shoelace-style/shoelace/dist/components/breadcrumb/breadcrumb.js";
import "@shoelace-style/shoelace/dist/components/breadcrumb-item/breadcrumb-item.js";
import "@shoelace-style/shoelace/dist/components/details/details.js";
import "@shoelace-style/shoelace/dist/components/icon-button/icon-button.js";
import "@shoelace-style/shoelace/dist/components/input/input.js";
import "@shoelace-style/shoelace/dist/components/textarea/textarea.js";
import "@shoelace-style/shoelace/dist/components/tooltip/tooltip.js";
import SlButton from "@shoelace-style/shoelace/dist/components/button/button.js";
import SlDetails from "@shoelace-style/shoelace/dist/components/details/details.js";
import SlIconButton from "@shoelace-style/shoelace/dist/components/icon-button/icon-button.js";
import { setBasePath } from "@shoelace-style/shoelace/dist/utilities/base-path.js";
import { notify } from "./alert";
setBasePath("/static/shoelace/");
const form = document.forms[0];
const photobox = document.getElementById("photo-box")!;
const photoview = document.getElementById("photo-view")!;
const video = photoview.querySelector("video")!;
const btnshutter = photoview.querySelector(
" sl-icon-button[label='Take Photo']",
) as SlIconButton;
const btnreset = photoview.querySelector(
" sl-icon-button[label='Start Over']",
) as SlIconButton;
let initialized = false;
async function clearCamera() {
video.pause();
video.srcObject = null;
video.classList.add("invisible");
video.parentNode?.querySelectorAll("canvas").forEach((e) => e.remove());
btnshutter.disabled = true;
btnshutter.classList.add("invisible");
btnreset.disabled = true;
btnreset.classList.add("invisible");
photoview.classList.remove("invisible");
}
async function startCamera() {
let stream: MediaStream;
try {
stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: {
ideal: "environment",
},
width: { ideal: 1280 },
height: { ideal: 720 },
},
audio: false,
});
} catch (ex) {
console.error(ex);
notify(`${ex}`, "danger", "exclamation-octagon", null);
return;
}
photobox.querySelectorAll(".fallback").forEach((e) => e.remove());
btnshutter.classList.remove("invisible");
video.classList.remove("invisible");
video.srcObject = stream;
video.play();
}
async function submitForm(data: FormData) {
const btn = form.querySelector("sl-button[type='submit']") as SlButton;
btn.loading = true;
let r: Response | null = null;
try {
r = await fetch("", {
method: "POST",
body: data,
});
} catch (e) {
notify(
`Failed to submit form: ${e}`,
"danger",
"exclamation-octagon",
null,
);
}
btn.loading = false;
if (r) {
if (r.ok) {
notify(
"Successfully updated transaction",
undefined,
undefined,
null,
);
window.location.href = "/transactions";
} else {
const html = await r.text();
if (html) {
const doc = new DOMParser().parseFromString(html, "text/html");
notify(
doc.body.textContent ?? "",
"danger",
"exclamation-octagon",
null,
);
} else {
notify(r.statusText, "danger", "exclamation-octagon", null);
}
}
}
}
function takePhoto() {
btnshutter.disabled = true;
btnshutter.classList.add("invisible");
video.pause();
const canvas = document.createElement("canvas");
canvas.setAttribute("id", "camera-photo");
const context = canvas.getContext("2d");
if (!context) {
notify(
"Failed to get canvas 2D rendering context",
"danger",
"exclamation-octagon",
null,
);
return;
}
const width = video.videoWidth;
const height = video.videoHeight;
canvas.width = width;
canvas.height = height;
context.drawImage(video, 0, 0, width, height);
video.srcObject = null;
video.classList.add("invisible");
video.parentNode!.appendChild(canvas);
btnreset.disabled = false;
btnreset.classList.remove("invisible");
}
photobox.addEventListener("sl-show", () => {
if (!initialized) {
initialized = true;
clearCamera();
startCamera();
}
});
video.addEventListener("canplay", () => {
btnshutter.disabled = false;
});
btnshutter.addEventListener("click", async () => {
takePhoto();
});
btnreset.addEventListener("click", () => {
clearCamera();
startCamera();
});
form.addEventListener("submit", async (evt) => {
evt.preventDefault();
let data = new FormData(form);
const canvas = document.getElementById("camera-photo") as HTMLCanvasElement;
if (canvas) {
canvas.toBlob((blob: Blob | null) => {
if (blob) {
data.append("photo", blob, "photo.jpg");
}
submitForm(data);
}, "image/jpeg");
} else {
submitForm(data);
}
});
form.addEventListener("reset", () => {
document.querySelectorAll("sl-details").forEach((e: SlDetails) => e.hide());
clearCamera();
initialized = false;
});

View File

@ -0,0 +1,9 @@
CREATE TABLE receipts (
id serial PRIMARY KEY,
date timestamp with time zone NOT NULL,
vendor varchar(99) NOT NULL,
amount decimal NOT NULL,
notes text,
filename varchar(199) NOT NULL,
image bytea NOT NULL
);

View File

@ -1 +1,2 @@
match_block_trailing_comma = true
max_width = 79

View File

@ -0,0 +1,4 @@
SELECT
count(id) AS "count!"
FROM
receipts

View File

@ -0,0 +1,6 @@
SELECT
id, vendor, date, amount, notes, filename
FROM
receipts
WHERE
id = $1

View File

@ -0,0 +1,7 @@
INSERT INTO receipts (
vendor, date, amount, notes, filename, image
) VALUES (
$1, $2, $3, $4, $5, $6
)
RETURNING
id, vendor, date, amount, notes, filename

View File

@ -0,0 +1,9 @@
SELECT
id, vendor, date, amount, notes, filename
FROM
receipts
ORDER BY
date DESC,
id DESC
LIMIT $1
OFFSET $2

22
src/config.rs Normal file
View File

@ -0,0 +1,22 @@
use std::path::PathBuf;
use serde::Deserialize;
#[derive(Debug, Deserialize)]
pub struct FireflyConfig {
pub url: String,
pub token: PathBuf,
pub search_query: String,
pub default_account: String,
#[serde(default = "default_restaurant_tag")]
pub restaurant_tag: String
}
#[derive(Debug, Deserialize)]
pub struct Config {
pub firefly: FireflyConfig,
}
fn default_restaurant_tag() -> String {
"Food & Drink".into()
}

322
src/firefly.rs Normal file
View File

@ -0,0 +1,322 @@
use chrono::{DateTime, FixedOffset};
use reqwest::header::{HeaderMap, HeaderValue};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use tracing::{error, info};
pub struct Firefly {
url: String,
client: reqwest::Client,
}
#[derive(Clone, Serialize, Deserialize)]
pub struct TransactionSplit {
pub transaction_journal_id: String,
pub date: chrono::DateTime<chrono::FixedOffset>,
pub amount: String,
pub description: String,
pub notes: Option<String>,
pub tags: Option<Vec<String>>,
}
#[derive(Clone, Deserialize)]
pub struct TransactionAttributes {
pub group_title: Option<String>,
pub transactions: Vec<TransactionSplit>,
}
#[derive(Clone, Deserialize)]
pub struct TransactionRead {
pub id: String,
pub attributes: TransactionAttributes,
}
#[derive(Deserialize)]
pub struct TransactionArray {
pub data: Vec<TransactionRead>,
}
#[derive(Clone, Deserialize)]
pub struct TransactionSingle {
pub data: TransactionRead,
}
#[derive(Debug, Serialize)]
pub struct TransactionSplitUpdate {
pub transaction_journal_id: String,
pub amount: String,
pub description: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tags: Option<Vec<String>>,
}
#[derive(Debug, Serialize)]
pub struct TransactionUpdate {
pub transactions: Vec<TransactionSplitUpdate>,
}
#[derive(Serialize)]
#[serde(rename_all = "lowercase")]
pub enum TransactionTypeProperty {
Withdrawal,
Deposit,
}
#[derive(Serialize)]
pub struct TransactionSplitStore {
#[serde(rename = "type")]
pub type_: TransactionTypeProperty,
pub date: DateTime<FixedOffset>,
pub amount: String,
pub description: String,
pub notes: Option<String>,
pub source_name: Option<String>,
pub destination_name: Option<String>,
pub tags: Option<Vec<String>>,
}
#[derive(Serialize)]
pub struct TransactionStore {
pub error_if_duplicate_hash: bool,
pub apply_rules: bool,
pub fire_webhooks: bool,
pub group_title: String,
pub transactions: Vec<TransactionSplitStore>,
}
#[derive(Serialize)]
#[non_exhaustive]
enum AttachableType {
TransactionJournal,
}
#[derive(Serialize)]
struct AttachmentStore {
filename: String,
attachable_type: AttachableType,
attachable_id: String,
}
#[derive(Deserialize)]
struct AttachmentRead {
id: String,
}
#[derive(Deserialize)]
struct AttachmentSingle {
data: AttachmentRead,
}
impl TransactionRead {
pub fn amount(&self) -> f64 {
self.attributes
.transactions
.iter()
.filter_map(|t| match t.amount.parse::<f64>() {
Ok(v) => Some(v),
Err(e) => {
error!("Invalid amount: {}", e);
None
},
})
.sum()
}
}
impl From<TransactionRead> for TransactionUpdate {
fn from(t: TransactionRead) -> Self {
let transactions: Vec<_> = t
.attributes
.transactions
.into_iter()
.map(|s| TransactionSplitUpdate {
transaction_journal_id: s.transaction_journal_id,
amount: s.amount,
description: s.description,
notes: s.notes,
tags: s.tags,
})
.collect();
TransactionUpdate { transactions }
}
}
impl TransactionStore {
pub fn new_deposit<A, D, S, T, N, G>(
date: DateTime<FixedOffset>,
amount: A,
description: D,
source_account: S,
destination_account: T,
notes: N,
tags: G,
) -> Self
where
A: Into<String>,
D: Into<String>,
S: Into<Option<String>>,
T: Into<Option<String>>,
N: Into<Option<String>>,
G: Into<Option<Vec<String>>>,
{
Self {
error_if_duplicate_hash: true,
apply_rules: true,
fire_webhooks: true,
group_title: Default::default(),
transactions: vec![TransactionSplitStore {
type_: TransactionTypeProperty::Deposit,
date,
amount: amount.into(),
description: description.into(),
source_name: source_account.into(),
destination_name: destination_account.into(),
notes: notes.into(),
tags: tags.into(),
}],
}
}
pub fn new_withdrawal<A, D, S, T, N, G>(
date: DateTime<FixedOffset>,
amount: A,
description: D,
source_account: S,
destination_account: T,
notes: N,
tags: G,
) -> Self
where
A: Into<String>,
D: Into<String>,
S: Into<Option<String>>,
T: Into<Option<String>>,
N: Into<Option<String>>,
G: Into<Option<Vec<String>>>,
{
Self {
error_if_duplicate_hash: true,
apply_rules: true,
fire_webhooks: true,
group_title: Default::default(),
transactions: vec![TransactionSplitStore {
type_: TransactionTypeProperty::Withdrawal,
date,
amount: amount.into(),
description: description.into(),
source_name: source_account.into(),
destination_name: destination_account.into(),
notes: notes.into(),
tags: tags.into(),
}],
}
}
}
impl Firefly {
pub fn new(url: impl Into<String>, token: &str) -> Self {
let mut headers = HeaderMap::new();
headers.insert(
"Accept",
HeaderValue::from_static("application/vnd.api+json"),
);
headers.insert(
"Authorization",
HeaderValue::from_str(&format!("Bearer {}", &token)).unwrap(),
);
let client =
Client::builder().default_headers(headers).build().unwrap();
Self {
url: url.into().trim_end_matches("/").into(),
client,
}
}
pub fn url(&self) -> &str {
&self.url
}
pub async fn search_transactions(
&self,
query: &str,
) -> Result<TransactionArray, reqwest::Error> {
let url = format!("{}/api/v1/search/transactions", self.url);
let res = self
.client
.get(url)
.query(&[("query", query)])
.send()
.await?;
res.error_for_status_ref()?;
res.json().await
}
pub async fn get_transaction(
&self,
id: &str,
) -> Result<TransactionSingle, reqwest::Error> {
let url = format!("{}/api/v1/transactions/{}", self.url, id);
let res = self.client.get(url).send().await?;
res.error_for_status_ref()?;
res.json().await
}
pub async fn update_transaction(
&self,
id: &str,
txn: &TransactionUpdate,
) -> Result<TransactionSingle, reqwest::Error> {
let url = format!("{}/api/v1/transactions/{}", self.url, id);
let res = self.client.put(url).json(txn).send().await?;
res.error_for_status_ref()?;
info!("Successfully updated transaction {}", id);
res.json().await
}
pub async fn attach_receipt(
&self,
id: &str,
content: impl Into<reqwest::Body>,
filename: impl Into<String>,
) -> Result<(), reqwest::Error> {
let filename = filename.into();
info!("Attaching receipt {} to transaction {}", filename, id);
let url = format!("{}/api/v1/attachments", self.url);
let data = AttachmentStore {
filename,
attachable_type: AttachableType::TransactionJournal,
attachable_id: id.into(),
};
let res = self.client.post(url).json(&data).send().await?;
res.error_for_status_ref()?;
let attachment: AttachmentSingle = res.json().await?;
info!("Created attachment {}", attachment.data.id);
let url = format!(
"{}/api/v1/attachments/{}/upload",
self.url, attachment.data.id
);
let res = self
.client
.post(url)
.header("Content-Type", "application/octet-stream")
.body(content)
.send()
.await?;
res.error_for_status_ref()?;
Ok(())
}
pub async fn create_transaction(
&self,
post: TransactionStore,
) -> Result<TransactionSingle, reqwest::Error> {
let url = format!("{}/api/v1/transactions", self.url);
let res = self.client.post(url).json(&post).send().await?;
if let Err(e) = res.error_for_status_ref() {
error!("Failed to create transaction: {:?}", res.text().await);
return Err(e);
}
res.json().await
}
}

46
src/imaging.rs Normal file
View File

@ -0,0 +1,46 @@
use graphicsmagick::types::FilterTypes;
use graphicsmagick::wand::MagickWand;
pub fn thumbnail(
image: &[u8],
) -> Result<Option<Vec<u8>>, graphicsmagick::Error> {
let mut wand = MagickWand::new();
wand.read_image_blob(image)?;
// Multi-page documents like PDFs become multiple images in the
// MagickWand. We want a thumbnail of the first page, so we have
// to reset the iterator back to the first image.
wand.reset_iterator();
let orig_height = wand.get_image_height() as f64;
let orig_width = wand.get_image_width() as f64;
let min_width = 300.0;
let min_height = 450.0;
let scale_w = min_width / orig_width;
let scale_h = min_height / orig_height;
let scale = scale_w.max(scale_h);
let new_width = (orig_width * scale).round() as u64;
let new_height = (orig_height * scale).round() as u64;
wand.resize_image(
new_width,
new_height,
FilterTypes::UndefinedFilter,
1.0,
)?;
Ok(wand
.set_image_format("WEBP")?
.write_image_blob()
.map(|a| a.to_vec()))
}
pub fn get_type(image: &[u8]) -> Option<String> {
Some(
MagickWand::new()
.read_image_blob(image)
.ok()?
.get_image_format()
.to_str_lossy()
.to_string(),
)
}

View File

@ -1,51 +1,118 @@
use axum::extract::Multipart;
use axum::response::Html;
use axum::routing::get;
use axum::Router;
use tokio::net::TcpListener;
use tower_http::limit::RequestBodyLimitLayer;
use tower_http::trace::{self, TraceLayer};
use tracing::error;
use tracing::Level;
mod config;
mod firefly;
mod imaging;
mod receipts;
mod routes;
mod transactions;
#[tokio::main(flavor = "current_thread")]
async fn main() {
use rocket::fairing::{self, AdHoc};
use rocket::fs::FileServer;
use rocket::response::{Redirect, Responder};
use rocket::serde::json::Json;
use rocket::Rocket;
use rocket_db_pools::Database as RocketDatabase;
use rocket_dyn_templates::Template;
use serde::Serialize;
use tracing::{debug, error, info};
use config::Config;
use firefly::Firefly;
struct Context {
#[allow(dead_code)]
config: Config,
firefly: Firefly,
}
#[derive(Debug, thiserror::Error)]
enum InitError {
#[error("Invalid config: {0}")]
Config(std::io::Error),
}
impl Context {
pub fn init(config: Config) -> Result<Self, InitError> {
let token = std::fs::read_to_string(&config.firefly.token)
.map_err(InitError::Config)?;
let firefly = Firefly::new(&config.firefly.url, &token);
Ok(Self { config, firefly })
}
}
#[derive(RocketDatabase)]
#[database("receipts")]
struct Database(rocket_db_pools::sqlx::PgPool);
#[derive(Serialize)]
struct ErrorBody {
error: String,
}
#[derive(Responder)]
#[response(status = 500)]
struct ErrorResponse {
error: Json<ErrorBody>,
}
impl ErrorResponse {
pub fn new<S: Into<String>>(error: S) -> Self {
Self {
error: Json(ErrorBody {
error: error.into(),
}),
}
}
}
#[rocket::get("/")]
async fn index() -> Redirect {
Redirect::to("/receipts")
}
async fn run_migrations(rocket: Rocket<rocket::Build>) -> fairing::Result {
if let Some(db) = Database::fetch(&rocket) {
info!("Applying database migrations");
if let Err(e) = sqlx::migrate!("./migrations").run(&db.0).await {
error!("Database migration failed: {}", e);
Err(rocket)
} else {
Ok(rocket)
}
} else {
Err(rocket)
}
}
#[rocket::launch]
async fn rocket() -> _ {
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.with_writer(std::io::stderr)
.init();
let app = Router::new()
.route("/", get(get_receipts).post(post_receipts))
.layer(RequestBodyLimitLayer::new(20971520))
.layer(
TraceLayer::new_for_http()
.make_span_with(
trace::DefaultMakeSpan::new().level(Level::INFO),
)
.on_response(
trace::DefaultOnResponse::new().level(Level::INFO),
),
);
graphicsmagick::initialize();
let listen_address = match std::env::var("LISTEN_ADDRESS") {
Ok(v) => v,
Err(std::env::VarError::NotPresent) => "0.0.0.0:3000".into(),
Err(std::env::VarError::NotUnicode(_)) => {
error!("Invalid UTF-8 value for LISTEN_ADDRESS");
let rocket = rocket::build();
let figment = rocket.figment();
let config: Config = figment.extract().unwrap();
let ctx = match Context::init(config) {
Ok(c) => c,
Err(e) => {
error!("Failed to initialize application context: {}", e);
std::process::exit(1);
}
},
};
debug!("Using Firefly III URL {}", &ctx.firefly.url());
let listener = TcpListener::bind(&listen_address).await.unwrap();
axum::serve(listener, app).await.unwrap();
}
async fn get_receipts() -> Html<&'static str> {
Html(include_str!("../index.html"))
}
async fn post_receipts(mut multipart: Multipart) -> Html<&'static str> {
while let Ok(Some(_)) = multipart.next_field().await {}
Html("Ok")
rocket
.manage(ctx)
.mount("/", rocket::routes![index])
.mount("/transactions", routes::transactions::routes())
.mount("/receipts", routes::receipts::routes())
.mount("/static", FileServer::from("static"))
.attach(Template::fairing())
.attach(Database::init())
.attach(AdHoc::try_on_ignite("Migrate Database", run_migrations))
}

205
src/receipts.rs Normal file
View File

@ -0,0 +1,205 @@
use chrono::{DateTime, FixedOffset};
use rocket::fs::TempFile;
use rocket::tokio::io::{AsyncReadExt, BufReader};
use rocket_db_pools::Connection;
use serde::Serialize;
use sqlx::types::Decimal;
use tracing::error;
use crate::imaging;
use crate::Database;
#[derive(Debug, Serialize)]
pub struct Receipt {
pub id: i32,
pub date: DateTime<FixedOffset>,
pub vendor: String,
pub amount: Decimal,
pub notes: Option<String>,
pub filename: String,
pub image: Vec<u8>,
}
#[derive(Debug, Serialize)]
pub struct ReceiptJson {
pub id: i32,
pub date: DateTime<FixedOffset>,
pub vendor: String,
pub amount: Decimal,
pub notes: Option<String>,
pub filename: String,
}
#[derive(rocket::FromForm)]
pub struct ReceiptPostForm<'r> {
pub transaction: Option<String>,
pub date: String,
pub vendor: String,
pub amount: String,
pub is_restaurant: Option<bool>,
pub notes: String,
pub photo: TempFile<'r>,
}
pub struct ReceiptPostData {
pub date: DateTime<FixedOffset>,
pub vendor: String,
pub amount: Decimal,
pub is_restaurant: bool,
pub notes: String,
pub filename: String,
pub photo: Vec<u8>,
}
#[derive(Debug, thiserror::Error)]
pub enum ReceiptPostFormError {
#[error("Invalid date: {0}")]
Date(#[from] chrono::format::ParseError),
#[error("Invalid amount: {0}")]
Amount(#[from] rust_decimal::Error),
#[error("Error reading photo: {0}")]
Photo(#[from] std::io::Error),
#[error("Unsupported image type")]
UnsupportedImageFormat,
}
#[derive(Serialize)]
#[serde(rename_all = "lowercase")]
pub enum AddReceiptResponse {
Success(ReceiptJson),
Error(String),
}
#[derive(Serialize)]
#[serde(rename_all = "lowercase")]
pub enum DeleteReceiptResponse {
Success,
Error(String),
}
impl ReceiptPostData {
pub async fn from_form(
form: &ReceiptPostForm<'_>,
) -> Result<Self, ReceiptPostFormError> {
let date = DateTime::parse_from_str(
&format!("{} 00:00:00 +0000", form.date),
"%Y-%m-%d %H:%M:%S %z",
)?;
let vendor = form.vendor.clone();
use rust_decimal::prelude::FromStr;
let amount = Decimal::from_str(&form.amount)?;
let is_restaurant = form.is_restaurant.unwrap_or_default();
let notes = form.notes.clone();
let stream = form.photo.open().await?;
let mut reader = BufReader::new(stream);
let mut photo = Vec::new();
reader.read_to_end(&mut photo).await?;
let extension = match imaging::get_type(&photo).as_deref() {
Some("BMP") => "bmp",
Some("JPEG") => "jpg",
Some("PDF") => "pdf",
Some("PNG") => "png",
Some("PPM") => "ppm",
Some("TIFF") => "tiff",
Some("WEBP") => "webp",
Some(f) => {
error!("Unsupported image format: {}", f);
return Err(ReceiptPostFormError::UnsupportedImageFormat);
},
None => {
return Err(ReceiptPostFormError::UnsupportedImageFormat);
},
};
let filename = form
.photo
.name()
.map(|n| format!("{}.{}", n, extension))
.unwrap_or("photo.jpg".into());
Ok(Self {
date,
vendor,
amount,
is_restaurant,
notes,
filename,
photo,
})
}
}
pub struct ReceiptsRepository {
conn: Connection<Database>,
}
impl ReceiptsRepository {
pub fn new(conn: Connection<Database>) -> Self {
Self { conn }
}
pub async fn list_receipts(
&mut self,
limit: i64,
offset: i64,
) -> Result<Vec<ReceiptJson>, sqlx::Error> {
sqlx::query_file_as!(ReceiptJson, "sql/receipts/list-receipts.sql", limit, offset)
.fetch_all(&mut **self.conn)
.await
}
pub async fn count_receipts(&mut self) -> Result<i64, sqlx::Error> {
Ok(sqlx::query_file!("sql/receipts/count-receipts.sql")
.fetch_one(&mut **self.conn)
.await?
.count)
}
pub async fn add_receipt(
&mut self,
data: &ReceiptPostData,
) -> Result<ReceiptJson, sqlx::Error> {
let result = sqlx::query_file_as!(
ReceiptJson,
"sql/receipts/insert-receipt.sql",
data.vendor,
data.date,
data.amount,
data.notes,
data.filename,
data.photo,
)
.fetch_one(&mut **self.conn)
.await?;
Ok(result)
}
pub async fn get_receipt(
&mut self,
id: i32,
) -> Result<ReceiptJson, sqlx::Error> {
sqlx::query_file_as!(ReceiptJson, "sql/receipts/get-receipt.sql", id,)
.fetch_one(&mut **self.conn)
.await
}
pub async fn get_receipt_photo(
&mut self,
id: i32,
) -> Result<(String, Vec<u8>), sqlx::Error> {
let result = sqlx::query!(
"SELECT filename, image FROM receipts WHERE id = $1",
id,
)
.fetch_one(&mut **self.conn)
.await?;
Ok((result.filename, result.image))
}
pub async fn delete_receipt(
&mut self,
id: i32,
) -> Result<(), sqlx::Error> {
sqlx::query_as!(ReceiptJson, "DELETE FROM receipts WHERE id = $1", id)
.execute(&mut **self.conn)
.await?;
Ok(())
}
}

2
src/routes/mod.rs Normal file
View File

@ -0,0 +1,2 @@
pub mod receipts;
pub mod transactions;

382
src/routes/receipts.rs Normal file
View File

@ -0,0 +1,382 @@
use std::ops::RangeInclusive;
use rocket::form::Form;
use rocket::http::{ContentType, Header, MediaType, Status};
use rocket::serde::json::Json;
use rocket::{Route, State};
use rocket_db_pools::Connection as DatabaseConnection;
use rocket_dyn_templates::{context, Template};
use rust_decimal::prelude::ToPrimitive;
use tracing::{debug, error, info, trace};
use crate::firefly::{TransactionStore, TransactionUpdate};
use crate::imaging;
use crate::receipts::*;
use crate::{Context, Database};
fn paginate(total: i64, count: i64, current: i64) -> Vec<String> {
let start = 1;
let end = (total / count).max(1);
let pages = RangeInclusive::new(start, end);
if end < 10 {
pages.map(|p| format!("{}", p)).collect()
} else {
pages
.filter_map(|p| {
if p == start
|| (current - 2 <= p && p <= current + 2)
|| p == end
{
Some(format!("{}", p))
} else if p == current - 3 || p == current + 3 {
Some("...".into())
} else {
None
}
})
.collect()
}
}
#[rocket::get("/?<page>&<count>")]
pub async fn list_receipts(
db: DatabaseConnection<Database>,
page: Option<i64>,
count: Option<i64>,
) -> (Status, Template) {
let mut repo = ReceiptsRepository::new(db);
let count = count.unwrap_or(25);
let page = page.unwrap_or(1);
let total = match repo.count_receipts().await {
Ok(r) => r,
Err(e) => {
return (
Status::InternalServerError,
Template::render(
"error",
context! {
error: e.to_string(),
},
),
)
},
};
match repo.list_receipts(count, (page - 1) * count).await {
Ok(r) => (
Status::Ok,
Template::render(
"receipt-list",
context! {
receipts: r,
pages: paginate(total, count, page),
count: count,
},
),
),
Err(e) => (
Status::InternalServerError,
Template::render(
"error",
context! {
error: e.to_string(),
},
),
),
}
}
#[rocket::get("/add")]
pub async fn receipt_form() -> Template {
Template::render("receipt-form", context! {})
}
#[rocket::post("/add", data = "<form>")]
pub async fn add_receipt(
form: Form<ReceiptPostForm<'_>>,
db: DatabaseConnection<Database>,
ctx: &State<Context>,
) -> (Status, Json<AddReceiptResponse>) {
let data = match ReceiptPostData::from_form(&form).await {
Ok(d) => d,
Err(e) => {
return (
Status::BadRequest,
Json(AddReceiptResponse::Error(e.to_string())),
);
},
};
let mut repo = ReceiptsRepository::new(db);
let receipt = match repo.add_receipt(&data).await {
Ok(r) => {
info!("Created new receipt {}", r.id);
r
},
Err(e) => {
error!("Failed to insert new receipt record: {}", e);
return (
Status::InternalServerError,
Json(AddReceiptResponse::Error(e.to_string())),
);
},
};
let xact = match form.transaction {
Some(ref s) if s == "new" => {
let tags = if data.is_restaurant {
Some(vec![ctx.config.firefly.restaurant_tag.clone()])
} else {
None
};
let data = TransactionStore::new_withdrawal(
data.date,
data.amount.to_string(),
data.vendor,
ctx.config.firefly.default_account.clone(),
Some("(no name)".into()),
data.notes,
tags,
);
match ctx.firefly.create_transaction(data).await {
Ok(t) => {
info!("Successfully created transaction ID {}", t.data.id);
Some(t)
},
Err(e) => {
error!("Failed to create Firefly transaction: {}", e);
None
},
}
},
Some(ref s) if s == "deposit" => {
let data = TransactionStore::new_deposit(
data.date,
data.amount.to_string(),
data.vendor,
Some("(no name)".into()),
ctx.config.firefly.default_account.clone(),
data.notes,
None,
);
match ctx.firefly.create_transaction(data).await {
Ok(t) => {
info!("Successfully created transaction ID {}", t.data.id);
Some(t)
},
Err(e) => {
error!("Failed to create Firefly transaction: {}", e);
None
},
}
},
Some(ref id) if !id.is_empty() => match ctx
.firefly
.get_transaction(id)
.await
{
Ok(t) => {
let mut needs_update = false;
let mut update = TransactionUpdate::from(t.data.clone());
let amount = t.data.amount();
if let Some(split) = update.transactions.last_mut() {
if data.amount.to_f64() != Some(amount) {
split.amount = data.amount.to_string();
needs_update = true;
}
if data.vendor != split.description {
split.description = data.vendor;
needs_update = true;
}
if !data.notes.is_empty() {
if let Some(notes) = split.notes.as_deref() {
if notes != data.notes.as_str() {
split.notes = Some(data.notes.clone());
needs_update = true;
}
} else {
split.notes = data.notes.into();
needs_update = true;
}
}
if data.is_restaurant {
if let Some(tags) = &mut split.tags {
if !tags
.contains(&ctx.config.firefly.restaurant_tag)
{
tags.push(
ctx.config.firefly.restaurant_tag.clone(),
);
needs_update = true;
}
} else {
split.tags.replace(vec![ctx
.config
.firefly
.restaurant_tag
.clone()]);
needs_update = true;
}
}
trace!("Original transaction: {:?}", split);
trace!("Updated transaction: {:?}", update);
} else {
debug!("Transaction {} has no splits", id);
}
if needs_update {
let res = ctx
.firefly
.update_transaction(&t.data.id, &update)
.await;
match res {
Ok(t) => {
info!("Successfully updated transaction {}", id);
Some(t)
},
Err(e) => {
error!(
"Failed to update trancation {}: {}",
id, e
);
Some(t)
},
}
} else {
debug!("Transaction {} does not need updated", id);
Some(t)
}
},
Err(e) => {
error!("Could not load Firefly transaction {}: {}", id, e);
None
},
},
Some(_) => None,
None => None,
};
if let Some(xact) = xact {
if let Some(t) = xact.data.attributes.transactions.first() {
if let Err(e) = ctx
.firefly
.attach_receipt(
&t.transaction_journal_id,
data.photo,
data.filename,
)
.await
{
error!(
"Failed to attach receipt to Firefly transaction: {}",
e
);
}
}
}
(Status::Ok, Json(AddReceiptResponse::Success(receipt)))
}
#[rocket::get("/<id>")]
pub async fn get_receipt(
id: i32,
db: DatabaseConnection<Database>,
) -> Option<Template> {
let mut repo = ReceiptsRepository::new(db);
match repo.get_receipt(id).await {
Ok(r) => Some(Template::render("receipt", r)),
Err(e) => {
error!("Error fetching receipt image: {}", e);
None
},
}
}
#[derive(rocket::response::Responder)]
pub struct PhotoResponse {
content: Vec<u8>,
content_type: ContentType,
cache_control: Header<'static>,
}
impl PhotoResponse {
fn new(content: Vec<u8>, content_type: ContentType) -> Self {
let cache_control = Header::new("Cache-Control", "max-age=604800");
Self {
content,
content_type,
cache_control,
}
}
}
#[rocket::get("/<id>/thumbnail/<_>")]
pub async fn view_receipt_thumbnail(
id: i32,
db: DatabaseConnection<Database>,
) -> Option<PhotoResponse> {
let mut repo = ReceiptsRepository::new(db);
match repo.get_receipt_photo(id).await {
Ok((_, image)) => {
let thumbnail = match imaging::thumbnail(&image) {
Ok(Some(t)) => t,
Ok(None) => return None,
Err(e) => {
error!("Failed to create receipt photo thumbnail: {}", e);
return None;
},
};
Some(PhotoResponse::new(thumbnail, ContentType(MediaType::WEBP)))
},
Err(e) => {
error!("Error fetching receipt image: {}", e);
None
},
}
}
#[rocket::get("/<id>/view/<_>")]
pub async fn view_receipt_photo(
id: i32,
db: DatabaseConnection<Database>,
) -> Option<PhotoResponse> {
let mut repo = ReceiptsRepository::new(db);
match repo.get_receipt_photo(id).await {
Ok((filename, image)) => {
let mt = filename
.rsplit_once('.')
.and_then(|(_, ext)| MediaType::from_extension(ext))
.unwrap_or(MediaType::Binary);
Some(PhotoResponse::new(image, ContentType(mt)))
},
Err(e) => {
error!("Error fetching receipt image: {}", e);
None
},
}
}
#[rocket::delete("/<id>")]
pub async fn delete_receipt(
id: i32,
db: DatabaseConnection<Database>,
) -> (Status, Json<DeleteReceiptResponse>) {
let mut repo = ReceiptsRepository::new(db);
match repo.delete_receipt(id).await {
Ok(_) => (Status::Ok, Json(DeleteReceiptResponse::Success)),
Err(e) => {
error!("Error fetching receipt image: {}", e);
(
Status::InternalServerError,
Json(DeleteReceiptResponse::Error(e.to_string())),
)
},
}
}
pub fn routes() -> Vec<Route> {
rocket::routes![
add_receipt,
delete_receipt,
get_receipt,
list_receipts,
receipt_form,
view_receipt_thumbnail,
view_receipt_photo,
]
}

View File

@ -0,0 +1,45 @@
use rocket::serde::json::Json;
use rocket::Route;
use rocket::State;
use tracing::error;
use crate::transactions::*;
use crate::{Context, ErrorResponse};
#[rocket::get("/", format = "json")]
async fn transaction_list(
ctx: &State<Context>,
) -> Result<Json<Vec<Transaction>>, ErrorResponse> {
let result = ctx
.firefly
.search_transactions(&ctx.config.firefly.search_query)
.await
.map_err(|e| {
error!("Error fetching transaction list: {}", e);
ErrorResponse::new(
"Failed to fetch transaction list from Firefly III",
)
})?;
let restaurant_tag = Some(&ctx.config.firefly.restaurant_tag);
Ok(Json(
result
.data
.into_iter()
.filter_map(|t| {
match Transaction::from_firefly(t, restaurant_tag) {
Ok(t) => Some(t),
Err(e) => {
error!("Error parsing transaction details: {}", e);
None
},
}
})
.collect(),
))
}
pub fn routes() -> Vec<Route> {
rocket::routes![transaction_list]
}

52
src/transactions.rs Normal file
View File

@ -0,0 +1,52 @@
use chrono::{DateTime, FixedOffset};
use serde::Serialize;
use tracing::error;
use crate::firefly::TransactionRead;
#[derive(Serialize)]
pub struct Transaction {
pub id: String,
pub amount: f64,
pub description: String,
pub date: DateTime<FixedOffset>,
pub is_restaurant: bool,
}
impl Transaction {
pub fn from_firefly<T: AsRef<str>>(
t: TransactionRead,
restaurant_tag: Option<T>,
) -> Result<Self, &'static str> {
let first_split = match t.attributes.transactions.first() {
Some(t) => t,
None => {
error!("Invalid transaction {}: no splits", t.id);
return Err("Transaction has no splits");
},
};
let date = first_split.date;
let amount = t.amount();
let description = if let Some(title) = &t.attributes.group_title {
title.into()
} else {
first_split.description.clone()
};
let is_restaurant = if let Some(tag) = restaurant_tag {
if let Some(tags) = &first_split.tags {
tags.iter().any(|a| a == tag.as_ref())
} else {
false
}
} else {
false
};
Ok(Self {
id: t.id,
amount,
description,
date,
is_restaurant,
})
}
}

9
start-firefly.sh Normal file
View File

@ -0,0 +1,9 @@
#!/bin/sh
podman run \
--rm \
-d \
--name firefly-iii \
--env-file firefly.env \
-v firefly-iii:/var/www/html/storage \
-p 8080:8080 \
docker.io/fireflyiii/core

10
start-postgresql.sh Normal file
View File

@ -0,0 +1,10 @@
#!/bin/sh
mkdir -p .postgresql
podman run \
--rm \
-d \
--name postgresql \
-e POSTGRES_PASSWORD=$(tr -cd a-zA-Z0-9 < /dev/urandom | head -c 32) \
-v postgresql:/var/lib/postgresql/data \
-v ./.postgresql:/var/run/postgresql:rw,z \
docker.io/library/postgres

1
static Symbolic link
View File

@ -0,0 +1 @@
js/dist

24
templates/base.html.tera Normal file
View File

@ -0,0 +1,24 @@
<!doctype html>
<html lang="en">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta charset="utf-8" />
<link
rel="icon"
href="/static/icons/icon-192.png"
sizes="192x192"
type="image/png"
/>
<link rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png" />
<link rel="stylesheet" href="/static/common.css" />
<script src="/static/common.js"></script>
{% block head %}{% endblock -%}
</head>
<body>
<div id="page-loading">
<div>Loading ...</div>
</div>
<main class="container">{% block main %}{% endblock %}</main>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@ -0,0 +1,6 @@
{% extends "base" %} {% block head %}
<title>Error</title>
{% endblock %} {% block main %}
<h1>Error</h1>
<p style="color: var(--sl-color-red-600)">{{ error }}</p>
{% endblock %}

View File

@ -0,0 +1,79 @@
{% extends "base" %} {% block head %}
<title>Add Receipt</title>
<style></style>
{% endblock %} {% block main %}
<h1>Add Receipt</h1>
<nav>
<sl-button href="/receipts">
<sl-icon slot="prefix" name="chevron-left"></sl-icon>Receipts
</sl-button>
</nav>
<article>
<form>
<p>
<sl-select
id="transactions"
name="transaction"
placeholder="Loading transactions …"
help-text="Select an existing transaction to auto fill fields"
clearable
>
<sl-spinner slot="suffix"></sl-spinner>
<sl-option value="new">New Gas Station Transaction …</sl-option>
<sl-option value="deposit">New Refund/Deposit Transaction …</sl-option>
</sl-select>
</p>
<p>
<sl-input
name="vendor"
label="Vendor"
placeholder="Vendor"
list="vendors"
required
></sl-input>
</p>
<p><sl-switch name="is_restaurant">Restaurant</sl-switch>
<p>
<sl-input type="date" name="date" label="Date" required></sl-input>
</p>
<p>
<sl-input
type="number"
min="0.01"
step="0.01"
label="Amount"
name="amount"
required
></sl-input>
</p>
<p><sl-textarea label="Notes" name="notes"></sl-textarea></p>
<sl-details summary="Take Photo" id="photo-box">
<camera-input>Your browser does not support taking photos.</camera-input>
</sl-details>
<sl-details summary="Upload Photo" style="text-align: center">
<p>
<img id="upload-preview" style="max-height: 400px" />
</p>
<p>
<input
name="photo"
type="file"
style="display: none"
accept="image/*,application/pdf,.avif"
/>
<sl-button size="large" variant="primary" class="choose-file">
<sl-icon slot="prefix" name="image" label="Choose File"></sl-icon>
Choose File
</sl-button>
</p>
</sl-details>
<footer class="table-actions">
<sl-button type="reset">Reset</sl-button>
<sl-button type="submit" variant="primary" disabled>Submit</sl-button>
</footer>
</form>
</article>
{% endblock %}
{% block scripts %}
<script src="/static/receipt-form.js"></script>
{% endblock %}

View File

@ -0,0 +1,155 @@
{% extends "base" %} {% block head %}
<title>Receipts</title>
<style>
article {
display: flex;
flex-wrap: wrap;
justify-content: center;
align-content: stretch;
}
.receipt-card {
max-width: 250px;
margin: 1em;
transition: opacity 1s ease;
}
.receipt-card > a {
display: flex;
align-items: center;
justify-content: space-between;
}
.receipt-card .vendor {
font-weight: bold;
font-size: 115%;
}
.receipt-card .date {
font-size: 75%;
color: var(--sl-color-neutral-500);
vertical-align: bottom;
}
.receipt-card :link,
.receipt-card :visited {
color: var(--sl-color-neutral-900);
text-decoration: none;
}
sl-card.receipt-card::part(base) {
height: 100%;
}
sl-card.receipt-card::part(image) {
height: 100%;
}
sl-card.receipt-card [slot="image"] img {
width: 100%;
height: 100%;
object-fit: cover;
}
sl-card.receipt-card::part(body) {
padding-bottom: calc(var(--padding) / 2);
}
sl-card.receipt-card::part(footer) {
padding: 0 calc(var(--padding) / 2);
}
sl-card.receipt-card footer {
display: flex;
align-items: center;
justify-content: space-between;
}
#confirm-delete dl {
display: grid;
grid-template-columns: max-content max-content;
gap: var(--sl-spacing-x-small) var(--sl-spacing-medium);
padding: 1rem;
margin: 0 0 1.5rem 0;
width: min-content;
}
#confirm-delete dl dt {
text-align: right;
font-weight: bold;
}
#confirm-delete dl dd {
margin-left: 0;
}
ul.pagination {
margin: 0;
padding: 0;
list-style-type: none;
text-align: center;
}
ul.pagination li {
display: inline-block;
margin: 1em;
}
</style>
{% endblock %} {% block main %}
<h1>Receipts</h1>
<p class="table-actions">
<sl-button variant="primary" size="large" href="/receipts/add"
><sl-icon slot="prefix" name="file-earmark-plus"></sl-icon>Add
Receipt</sl-button
>
</p>
<article>
{% for receipt in receipts -%}
<sl-card class="receipt-card" data-receipt-id="{{ receipt.id }}">
<a slot="image" href="/receipts/{{ receipt.id }}">
<img
src="/receipts/{{ receipt.id }}/thumbnail/{{ receipt.filename }}"
alt="Receipt {{ receipt.id }}"
/>
</a>
<a href="/receipts/{{ receipt.id }}">
<div class="vendor">{{ receipt.vendor }}</div>
<div class="amount">$ {{ receipt.amount }}</div>
</a>
<footer slot="footer">
<div
class="date"
data-date="{{ receipt.date | date(format='%A %_d %B %Y') }}"
>
{{ receipt.date | date(format="%a %_d %b %Y") }}
</div>
<sl-icon-button name="trash" label="Delete"></sl-icon-button>
</footer>
</sl-card>
{% endfor %}
</article>
<ul class="pagination">
{%- for page in pages %}
<li>{% if page == "..." %}...{% else
%}<sl-button href="?page={{ page }}&count={{ count }}">{{ page }}</sl-button>{%
endif %}</li>
{%- endfor %}
</ul>
<sl-dialog id="confirm-delete" label="Delete Receipt">
<p>
Are you sure you want to delete receipt <span class="receipt-id"></span>?
</p>
<dl>
<dt>Vendor</dt>
<dd class="vendor"></dd>
<dt>Amount</dt>
<dd class="amount"></dd>
<dt>Date</dt>
<dd class="date"></dd>
</dl>
<footer slot="footer" class="table-actions">
<sl-button aria-label="No">No</sl-button>
<sl-button variant="danger" aria-label="Yes">Yes</sl-button>
</footer>
</sl-dialog>
{% endblock %} {% block scripts %}
<script src="/static/receipt-list.js"></script>
{% endblock %}

View File

@ -0,0 +1,83 @@
{% extends "base" %} {% block head %}
<title>Receipt: ${{ amount }} at {{ vendor }}</title>
<style>
.photo {
max-width: 100%;
}
.photo img {
max-width: 100%;
}
.photo iframe {
width: 100%;
aspect-ratio: 8.5 / 11;
}
@media screen and (min-width: 900px) {
article {
display: flex;
justify-content: space-between;
}
.attributes {
margin-right: 1em;
}
.photo {
flex-grow: 1;
}
}
</style>
{% endblock %} {% block main %}
<h1>Receipt</h1>
<nav>
<sl-button href="/receipts">
<sl-icon slot="prefix" name="chevron-left"></sl-icon>Receipts
</sl-button>
</nav>
<article>
<div class="attributes">
<p>
<sl-input
label="Date"
value='{{ date | date(format="%A %_d %B %Y") }}'
readonly
></sl-input>
</p>
<p>
<sl-input label="Vendor" value="{{ vendor }}" readonly></sl-input>
</p>
<p>
<sl-input
type="number"
min="0.01"
step="0.01"
label="Amount"
name="amount"
value="{{ amount }}"
readonly
></sl-input>
</p>
<p>
<sl-textarea
label="Notes"
name="notes"
value="{{ notes }}"
readonly
></sl-textarea>
</p>
</div>
<div class="photo">
<p>
{% if filename is ending_with(".pdf") %}
<iframe src="/receipts/{{ id }}/view/{{ filename }}"></iframe>
{% else %}
<img src="/receipts/{{ id }}/view/{{ filename }}" />
{% endif %}
</p>
</div>
</article>
{% endblock %} {% block scripts %}
<script src="/static/receipt.js"></script>
{% endblock %}

View File

@ -0,0 +1,28 @@
{% extends "base" %}
{% block head %}
<title>Transactions</title>
{% endblock %}
{% block main %}
<h1>Transactions</h1>
<p>
These transactions have not been reviewed and do not have attached receipts.
</p>
<table>
<tbody>
<tr>
<th scope="col">Description</th>
<th scope="col">Date</th>
<th scope="col">Amount</th>
</tr>
{% for txn in transactions -%}
<tr>
<td>
<a href="/transactions/{{ txn.id }}">{{ txn.description }}</a>
</td>
<td>{{ txn.date }}</td>
<td>${{ txn.amount }}</td>
</tr>
{% endfor -%}
</tbody>
</table>
{% endblock %}

View File

@ -0,0 +1,59 @@
{% extends "base" %}
{% block head %}
<link rel="stylesheet" href="/static/transaction.css" />
<title>Update Transaction: {{ description }}</title>
{% endblock %}
{% block main %}
<h1>Update Transaction</h1>
<nav>
<sl-breadcrumb>
<sl-breadcrumb-item href="/transactions">Transactions</sl-breadcrumb-item>
<sl-breadcrumb-item>{{ description }}</sl-breadcrumb-item>
</sl-breadcrumb>
</nav>
<form name="transaction">
<p>
<sl-input label="Date" value="{{ date }}" readonly></sl-input>
</p>
<p>
<sl-input label="Description" value="{{ description }}" readonly></sl-input>
</p>
<p>
<sl-input
type="number"
min="0.01"
step="0.01"
label="Amount"
name="amount"
value="{{ amount }}"
></sl-input>
</p>
<p><sl-textarea label="Notes" name="notes"></sl-textarea></p>
<sl-details summary="Take Photo" id="photo-box">
<p class="fallback">Your browser does not support taking photos.</p>
<div id="photo-view" class="invisible">
<div class="workspace">
<video class="invisible"></video>
</div>
<div class="buttons">
<sl-tooltip content="Take Photo">
<sl-icon-button name="camera" label="Take Photo"></sl-icon-button>
</sl-tooltip>
<sl-tooltip content="Start Over">
<sl-icon-button
name="trash"
label="Start Over"
></sl-icon-button>
</sl-tooltip>
</div>
</div>
</sl-details>
<footer>
<sl-button type="reset" variant="secondary">Reset</sl-button
><sl-button type="submit" variant="primary">Submit</sl-button>
</footer>
</form>
{% endblock %}
{% block scripts %}
<script src="/static/transaction.js"></script>
{% endblock %}