Compare commits

...

5 Commits

Author SHA1 Message Date
Dustin 79d8d899bb receipt/view: Fix image sizing on moble
dustin/receipts/pipeline/head There was a failure building this commit Details
I wasn't paying attention and added a bunch of CSS rules that only apply
to destkop screen sizes that should apply to all clients.
2025-03-14 20:57:12 -05:00
Dustin 83a4ca0ad5 receipts/view: Display PDFs in iframe
PDF attachments can now be shown on the receipt view page in an
`iframe`.
2025-03-14 20:57:12 -05:00
Dustin da3d3e4c8e receipts/list: Generate thumbnails for receipts
Instead of sending the whole image file for every receipt shown on the
list page, we now generate thumbnails for them on the fly.  This
dramatically reduces the amount of bytes sent for each image, especially
very large, high-quality photographs.  It also improves support for
non-image attachments like PDFs, by rendering image previews in the
grid view instead of a broken image placeholder.

We use GraphicsMagic to do the conversion.  Its `MagickWand` API is
pretty straightforward and convenient, and it supports a plethora of
image and image-like formats.
2025-03-14 20:57:12 -05:00
Dustin e158a095d3 receipts/add: Do not query Firefly for empty ID
The `transaction` field is usually included in the form submission, even
if it is empty.  To avoid querying Firefly for an invalid ID and logging
a useless error about it, we only query if the field is non-empty.
2025-03-14 20:27:20 -05:00
Dustin f3d31a7256 receipts/add: Skip updating notes if unchanged
If the value of the `notes` form field is the same as the current value
for the same field of an existing Firefly transaction, we do not need to
update it.
2025-03-14 20:27:18 -05:00
10 changed files with 270 additions and 13 deletions

119
Cargo.lock generated
View File

@ -71,6 +71,12 @@ dependencies = [
"libc",
]
[[package]]
name = "anyhow"
version = "1.0.97"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dcfed56ad506cb2c684a14971b8861fdc3baaaae314b9e5f9bb532cbe3ba7a4f"
[[package]]
name = "arrayvec"
version = "0.7.6"
@ -185,6 +191,24 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "383d29d513d8764dcdc42ea295d979eb99c3c9f00607b3692cf68a431f7dca72"
[[package]]
name = "bindgen"
version = "0.71.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3"
dependencies = [
"bitflags 2.9.0",
"cexpr",
"clang-sys",
"itertools",
"proc-macro2",
"quote",
"regex",
"rustc-hash",
"shlex",
"syn 2.0.99",
]
[[package]]
name = "bitflags"
version = "1.3.2"
@ -309,6 +333,15 @@ dependencies = [
"shlex",
]
[[package]]
name = "cexpr"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
dependencies = [
"nom",
]
[[package]]
name = "cfg-if"
version = "1.0.0"
@ -356,6 +389,17 @@ dependencies = [
"phf_codegen",
]
[[package]]
name = "clang-sys"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4"
dependencies = [
"glob",
"libc",
"libloading",
]
[[package]]
name = "const-oid"
version = "0.9.6"
@ -862,6 +906,28 @@ dependencies = [
"walkdir",
]
[[package]]
name = "graphicsmagick"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "58c4ba437067d42af001dcda9d7188f368539b0731c2f5f4a6ba3765f3b64518"
dependencies = [
"graphicsmagick-sys",
"null-terminated-str",
"num_enum",
"thiserror 2.0.12",
]
[[package]]
name = "graphicsmagick-sys"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9004437800b450090fdd1045edc43647209601b68320a4be450de541d0be16f6"
dependencies = [
"anyhow",
"bindgen",
]
[[package]]
name = "h2"
version = "0.4.8"
@ -1373,6 +1439,15 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "itertools"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.15"
@ -1424,6 +1499,16 @@ version = "0.2.170"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "875b3680cb2f8f71bdcf9a30f38d48282f5d3c95cbf9b3fa57269bb5d5c06828"
[[package]]
name = "libloading"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc2f4eb4bc735547cfed7c0a4922cbd04a4655978c09b54f1f7b228750664c34"
dependencies = [
"cfg-if",
"windows-targets 0.52.6",
]
[[package]]
name = "libm"
version = "0.2.11"
@ -1648,6 +1733,12 @@ dependencies = [
"winapi",
]
[[package]]
name = "null-terminated-str"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "594ec098b589ef9bcf24ff9d2a1ed9295851ecb4009ce1df58557c239cf250bd"
[[package]]
name = "num-bigint-dig"
version = "0.8.4"
@ -1711,6 +1802,27 @@ dependencies = [
"libc",
]
[[package]]
name = "num_enum"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e613fc340b2220f734a8595782c551f1250e969d87d3be1ae0579e8d4065179"
dependencies = [
"num_enum_derive",
]
[[package]]
name = "num_enum_derive"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af1844ef2428cc3e1cb900be36181049ef3d3193c63e43026cfe202983b27a56"
dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
"syn 2.0.99",
]
[[package]]
name = "object"
version = "0.36.7"
@ -2090,6 +2202,7 @@ name = "receipts"
version = "0.1.0"
dependencies = [
"chrono",
"graphicsmagick",
"reqwest",
"rocket",
"rocket_db_pools",
@ -2430,6 +2543,12 @@ version = "0.1.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f"
[[package]]
name = "rustc-hash"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
[[package]]
name = "rustix"
version = "1.0.1"

View File

@ -11,6 +11,7 @@ keywords = ["personal-finance", "receipts"]
[dependencies]
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"] }

View File

@ -4,7 +4,9 @@ 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 \
&& :
@ -38,6 +40,15 @@ RUN --mount=type=cache,target=/root/.cargo \
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

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,5 +1,6 @@
mod config;
mod firefly;
mod imaging;
mod receipts;
mod routes;
mod transactions;
@ -89,6 +90,8 @@ async fn rocket() -> _ {
.with_writer(std::io::stderr)
.init();
graphicsmagick::initialize();
let rocket = rocket::build();
let figment = rocket.figment();

View File

@ -6,6 +6,7 @@ use serde::Serialize;
use sqlx::types::Decimal;
use tracing::error;
use crate::imaging;
use crate::Database;
#[derive(Debug, Serialize)]
@ -56,6 +57,8 @@ pub enum ReceiptPostFormError {
Amount(#[from] rust_decimal::Error),
#[error("Error reading photo: {0}")]
Photo(#[from] std::io::Error),
#[error("Unsupported image type")]
UnsupportedImageFormat,
}
#[derive(Serialize)]
@ -84,16 +87,31 @@ impl ReceiptPostData {
use rust_decimal::prelude::FromStr;
let amount = Decimal::from_str(&form.amount)?;
let notes = form.notes.clone();
let filename = form
.photo
.raw_name()
.map(|n| n.dangerous_unsafe_unsanitized_raw().as_str())
.unwrap_or("photo.jpg")
.into();
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,
@ -108,6 +126,7 @@ impl ReceiptPostData {
pub struct ReceiptsRepository {
conn: Connection<Database>,
}
impl ReceiptsRepository {
pub fn new(conn: Connection<Database>) -> Self {
Self { conn }

View File

@ -8,6 +8,7 @@ use rust_decimal::prelude::ToPrimitive;
use tracing::{debug, error, info};
use crate::firefly::{TransactionStore, TransactionUpdate};
use crate::imaging;
use crate::receipts::*;
use crate::{Context, Database};
@ -113,7 +114,11 @@ pub async fn add_receipt(
},
}
},
Some(ref id) => match ctx.firefly.get_transaction(id).await {
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());
@ -128,8 +133,12 @@ pub async fn add_receipt(
needs_update = true;
}
if !data.notes.is_empty() {
split.notes = Some(data.notes.clone());
needs_update = true;
if let Some(notes) = split.notes.as_deref() {
if notes != data.notes.as_str() {
split.notes = Some(data.notes.clone());
needs_update = true;
}
}
}
} else {
debug!("Transaction {} has no splits", id);
@ -162,6 +171,7 @@ pub async fn add_receipt(
None
},
},
Some(_) => None,
None => None,
};
if let Some(xact) = xact {
@ -218,6 +228,31 @@ impl PhotoResponse {
}
}
#[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,
@ -264,6 +299,7 @@ pub fn routes() -> Vec<Route> {
get_receipt,
list_receipts,
receipt_form,
view_receipt_thumbnail,
view_receipt_photo,
]
}

View File

@ -54,7 +54,12 @@
<img id="upload-preview" style="max-height: 400px" />
</p>
<p>
<input name="photo" type="file" style="display: none" />
<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

View File

@ -94,7 +94,7 @@
<sl-card class="receipt-card" data-receipt-id="{{ receipt.id }}">
<a slot="image" href="/receipts/{{ receipt.id }}">
<img
src="/receipts/{{ receipt.id }}/view/{{ receipt.filename }}"
src="/receipts/{{ receipt.id }}/thumbnail/{{ receipt.filename }}"
alt="Receipt {{ receipt.id }}"
/>
</a>

View File

@ -1,6 +1,19 @@
{% 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;
@ -11,8 +24,8 @@
margin-right: 1em;
}
.photo img {
max-width: 100%;
.photo {
flex-grow: 1;
}
}
</style>
@ -52,7 +65,11 @@
</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>