diff --git a/Cargo.lock b/Cargo.lock index cea7297..fb4d7e1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -747,6 +747,26 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "enum-iterator" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c280b9e6b3ae19e152d8e31cf47f18389781e119d4013a2a2bb0180e5facc635" +dependencies = [ + "enum-iterator-derive", +] + +[[package]] +name = "enum-iterator-derive" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1ab991c1362ac86c61ab6f556cff143daa22e5a15e4e189df818b2fd19fe65b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.68", +] + [[package]] name = "env_filter" version = "0.1.0" @@ -1172,6 +1192,7 @@ dependencies = [ "barcoders", "base64 0.22.1", "datamatrix", + "enum-iterator", "env_logger", "futures-util", "log", @@ -1179,6 +1200,7 @@ dependencies = [ "mime", "printpdf", "serde", + "serde_variant", "sqlx", "thiserror", "time", @@ -1813,6 +1835,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_variant" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a0068df419f9d9b6488fdded3f1c818522cdea328e02ce9d9f147380265a432" +dependencies = [ + "serde", +] + [[package]] name = "sha1" version = "0.10.6" diff --git a/Cargo.toml b/Cargo.toml index 82336c2..94f280b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ actix-web = { version = "4.8.0", features = ["cookies"] } barcoders = { version = "2.0.0", default-features = false, features = ["std"] } base64 = "0.22.1" datamatrix = "0.3.1" +enum-iterator = "2.1.0" env_logger = "0.11.3" futures-util = "0.3.30" log = "0.4.21" @@ -24,6 +25,7 @@ maud = { version = "0.26.0", features = ["actix-web"] } mime = "0.3.17" printpdf = "0.7.0" serde = { version = "1.0.203", features = ["serde_derive"] } +serde_variant = "0.1.3" sqlx = { version = "0.7.4", features = ["runtime-tokio", "postgres", "uuid", "time"] } thiserror = "1.0.61" time = { version = "0.3.36", features = ["serde"] } diff --git a/deny.toml b/deny.toml index 7a29a32..9290bef 100644 --- a/deny.toml +++ b/deny.toml @@ -5,6 +5,7 @@ [licenses] # Only allow AGPL-3.0-or-later compatible licenses! allow = [ + "0BSD", "AGPL-3.0", "Apache-2.0", "BSD-3-Clause", diff --git a/src/frontend/labels.rs b/src/frontend/labels.rs new file mode 100644 index 0000000..308e89e --- /dev/null +++ b/src/frontend/labels.rs @@ -0,0 +1,137 @@ +// SPDX-FileCopyrightText: 2024 Simon Bruder +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use actix_identity::Identity; +use actix_web::{error, get, post, web, Responder}; +use maud::html; +use serde::Deserialize; +use serde_variant::to_variant_name; +use sqlx::PgPool; +use uuid::Uuid; + +use super::templates::{self, datalist, helpers::ItemName, TemplateConfig}; +use crate::label::{Label, LabelPreset}; +use crate::manage; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.service(generate_post) + .service(generate_get) + .service(form); +} + +#[derive(Debug, Deserialize)] +struct GenerateParams { + ids: String, + preset: LabelPreset, +} + +async fn generate(pool: &PgPool, params: GenerateParams) -> actix_web::Result { + let ids = params + .ids + .split(',') + .skip_while(|s| s.is_empty()) // to make the empty string parse as an empty iterator + .map(Uuid::try_parse) + .collect::, uuid::Error>>() + .map_err(error::ErrorInternalServerError)?; + + let items = manage::item::get_multiple(pool, &ids) + .await + .map_err(error::ErrorInternalServerError)?; + + Ok(Label::for_items(&items, params.preset.clone().into())) +} + +#[post("/labels/generate")] +async fn generate_post( + pool: web::Data, + _user: Identity, + params: web::Form, +) -> impl Responder { + generate(&pool, params.into_inner()).await +} + +#[get("/labels/generate")] +async fn generate_get( + pool: web::Data, + _user: Identity, + params: web::Query, +) -> impl Responder { + generate(&pool, params.into_inner()).await +} + +#[get("/labels")] +async fn form(pool: web::Data, user: Identity) -> actix_web::Result { + let items = manage::item::get_all(&pool) + .await + .map_err(error::ErrorInternalServerError)?; + + let item_classes = manage::item_class::get_all_as_map(&pool) + .await + .map_err(error::ErrorInternalServerError)?; + + let datalist_items = datalist::items(&pool) + .await + .map_err(error::ErrorInternalServerError)?; + + Ok(templates::base( + TemplateConfig { + path: "/labels", + title: Some("Generate Labels"), + page_title: Some(Box::new("Generate Labels")), + datalists: vec![&datalist_items], + user: Some(user), + ..Default::default() + }, + html! { + form #label-form method="POST" action="/labels/generate" { + div .mb-3 { + label .form-label for="preset" { "Preset" } + select #preset name="preset" .form-select { + @for preset in enum_iterator::all::() { + option selected[preset == LabelPreset::default()] value={ (to_variant_name(&preset).unwrap()) } { (preset) } + } + } + } + + ul .nav.nav-tabs.mb-3 { + li .nav-item { + button .nav-link.active data-bs-toggle="tab" data-bs-target="#simple-pane" type="button" { "Simple" } + } + li .nav-item { + button .nav-link data-bs-toggle="tab" data-bs-target="#multiselect-pane" type="button" { "Multiselect" } + } + } + + div .tab-content { + div .tab-pane.show.active #simple-pane { + div .mb-3 { + label .form-label { "Items" } + ul #simple-items { } + } + + div .input-group.mb-3 { + input .form-control #simple-add list="items-datalist" placeholder="Search for Item"; + button .btn.btn-success #simple-add-button type="button" { "Add" } + } + } + div .tab-pane #multiselect-pane { + div .mb-3 { + label .form-label for="ids-multiselect" { "Items" } + select #ids-multiselect .form-select multiple size="15" { + @for item in items { + @let item_name = ItemName::new(&item, item_classes.get(&item.class).unwrap()); + option value={ (item.id) } { (item_name.to_string()) " (" (item.id) ")" } + } + } + } + } + } + + input #ids type="hidden" name="ids"; + + button .btn.btn-primary type="submit" { "Generate" } + } + }, + )) +} diff --git a/src/frontend/mod.rs b/src/frontend/mod.rs index bfec59e..301ca37 100644 --- a/src/frontend/mod.rs +++ b/src/frontend/mod.rs @@ -5,6 +5,7 @@ mod auth; mod item; mod item_class; +mod labels; mod templates; use actix_identity::Identity; @@ -22,7 +23,8 @@ pub fn config(cfg: &mut web::ServiceConfig) { .service(jump) .configure(auth::config) .configure(item::config) - .configure(item_class::config); + .configure(item_class::config) + .configure(labels::config); } #[get("/")] diff --git a/src/label/preset.rs b/src/label/preset.rs index 8db47b6..033d8f8 100644 --- a/src/label/preset.rs +++ b/src/label/preset.rs @@ -2,12 +2,15 @@ // // SPDX-License-Identifier: AGPL-3.0-or-later +use std::fmt; + +use enum_iterator::Sequence; use printpdf::Mm; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use super::{Code128Config, DataMatrixConfig, LabelConfig, TextConfig}; -#[derive(Clone, Debug, Deserialize)] +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, Sequence)] #[serde(rename_all = "kebab-case")] pub enum LabelPreset { SeikoSlpMrlDataMatrix, @@ -15,6 +18,26 @@ pub enum LabelPreset { SeikoSlpMrl, } +impl fmt::Display for LabelPreset { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{}", + match self { + Self::SeikoSlpMrlDataMatrix => "Seiko SLP MRL (Data Matrix only)", + Self::SeikoSlpMrlCode128 => "Seiko SLP MRL (Code128 only)", + Self::SeikoSlpMrl => "Seiko SLP MRL", + } + ) + } +} + +impl Default for LabelPreset { + fn default() -> Self { + Self::SeikoSlpMrl + } +} + #[allow(clippy::from_over_into)] impl Into for LabelPreset { fn into(self) -> LabelConfig { diff --git a/static/app.js b/static/app.js index 422b04f..492ac1a 100644 --- a/static/app.js +++ b/static/app.js @@ -27,4 +27,54 @@ datalistHint(input, hint) input.addEventListener("input", _ => datalistHint(input, hint)) }) + + // there probably is a better way + if (document.location.pathname === "/labels") { + const multiselect = document.getElementById("ids-multiselect") + const updateSelectedItemList = _ => { + const list = document.getElementById("simple-items") + list.innerHTML = "" + Array.from(multiselect.selectedOptions) + .map(option => { + let li = document.createElement("li") + let a = document.createElement("a") + a.innerHTML = document.getElementById("items-datalist").querySelector(`option[value="${option.value}"]`).innerHTML + a.href = `/item/${option.value}` + li.appendChild(a) + let remove = document.createElement("a") + remove.classList = "btn btn-sm btn-danger ms-2 my-1 simple-items-remove" + remove.dataset.id = option.value + remove.innerText = "Remove" + remove.addEventListener("click", e => { + multiselect.querySelector(`option[value="${option.value}"]`).selected = false + updateSelectedItemList() + }) + li.appendChild(remove) + return li + }) + .forEach(li => list.appendChild(li)) + } + updateSelectedItemList() + addEventListener("input", _ => updateSelectedItemList()) + + const addFromSimple = _ => { + const inputEl = document.getElementById("simple-add") + document.getElementById("ids-multiselect").querySelector(`option[value="${inputEl.value}"]`).selected = true + inputEl.value = "" + updateSelectedItemList() + } + document.getElementById("simple-add-button").addEventListener("click", addFromSimple) + document.getElementById("simple-add").addEventListener("keydown", e => { + if (e.key === "Enter") { + e.preventDefault() // do not submit form + addFromSimple() + } + }) + + document.getElementById("label-form").addEventListener("submit", e => { + document.getElementById("ids").value = Array.from(multiselect.selectedOptions) + .map(option => option.value) + .join(",") + }) + } })()