diff --git a/copperc/src/lib/api/openapi.ts b/copperc/src/lib/api/openapi.ts index ed06eb04..c9f24312 100644 --- a/copperc/src/lib/api/openapi.ts +++ b/copperc/src/lib/api/openapi.ts @@ -66,7 +66,7 @@ export interface paths { path?: never; cookie?: never; }; - /** Get class info */ + /** List items in this class */ get: operations["list_items"]; put?: never; post?: never; @@ -625,6 +625,7 @@ export interface components { class: number; /** Format: int64 */ item: number; + primary_attr: components["schemas"]["PrimaryAttrData"]; /** @enum {string} */ type: "Reference"; }; @@ -762,6 +763,57 @@ export interface components { [key: string]: components["schemas"]["NodeJson"]; }; }; + PrimaryAttrData: + | { + /** @enum {string} */ + type: "NotAvailable"; + } + | { + /** Format: int64 */ + attr: number; + /** @enum {string} */ + type: "Text"; + value: string; + } + | { + /** Format: int64 */ + attr: number; + /** @enum {string} */ + type: "Integer"; + /** Format: int64 */ + value: number; + } + | { + /** Format: int64 */ + attr: number; + /** @enum {string} */ + type: "Float"; + /** Format: double */ + value: number; + } + | { + /** Format: int64 */ + attr: number; + /** @enum {string} */ + type: "Boolean"; + value: boolean; + } + | { + /** Format: int64 */ + attr: number; + /** @enum {string} */ + type: "Hash"; + value: string; + } + | { + /** Format: int64 */ + attr: number; + mime: string; + /** Format: int64 */ + size?: number | null; + /** @enum {string} */ + type: "Blob"; + }; QueuedJobCounts: { /** Format: int64 */ build_errors: number; @@ -819,11 +871,6 @@ export interface components { /** @enum {string} */ state: "FailedRunning"; } - | { - message: string; - /** @enum {string} */ - state: "FailedTransaction"; - } | { /** @enum {string} */ state: "Success"; diff --git a/copperc/src/lib/attributes/impls/blob.tsx b/copperc/src/lib/attributes/impls/blob.tsx index b95f7b25..3251cd73 100644 --- a/copperc/src/lib/attributes/impls/blob.tsx +++ b/copperc/src/lib/attributes/impls/blob.tsx @@ -45,59 +45,68 @@ export const _blobAttrType: attrTypeInfo<"Blob"> = { type: "panel", panel_body: (params) => { - const data_url = `/api/item/${params.item_id}/attr/${params.attr_id}`; + return ; + }, + }, +}; - let inner: ReactNode | null = ( - <_PanelBodyUnknown - src={data_url} - icon={} - attr_value={params.value} - /> - ); +export function BlobPanel(params: { + item_id: number; + attr_id: number; + value: { + mime: string; + size?: number | null; + type: "Blob"; + }; + inner?: boolean; +}) { + const data_url = `/api/item/${params.item_id}/attr/${params.attr_id}`; - if (params.value.mime != null && params.value.mime.startsWith("image/")) { - inner = <_PanelBodyImage src={data_url} attr_value={params.value} />; - } else if ( - params.value.mime != null && - params.value.mime.startsWith("audio/") - ) { - inner = <_PanelBodyAudio src={data_url} attr_value={params.value} />; - } + let inner: ReactNode | null = ( + <_PanelBodyUnknown src={data_url} icon={} attr_value={params.value} /> + ); - return ( -
; + } else if ( + params.value.mime != null && + params.value.mime.startsWith("audio/") + ) { + inner = <_PanelBodyAudio src={data_url} attr_value={params.value} />; + } + + return ( +
+
+ - - {params.inner !== true ? ( - <_PanelBottom attr_value={params.value} /> - ) : null} -
- ); - }, - }, -}; + {inner} + +
+ {params.inner !== true ? ( + <_PanelBottom attr_value={params.value} /> + ) : null} +
+ ); +} // Same as basicform, but with the "unique" switch hidden. // It has no effect on blobs. diff --git a/copperc/src/lib/attributes/impls/reference.tsx b/copperc/src/lib/attributes/impls/reference.tsx index e4b11bbe..05034490 100644 --- a/copperc/src/lib/attributes/impls/reference.tsx +++ b/copperc/src/lib/attributes/impls/reference.tsx @@ -11,6 +11,7 @@ import { useMutation, useQuery } from "@tanstack/react-query"; import { ReactElement } from "react"; import { Select } from "@mantine/core"; import { useForm, UseFormReturnType } from "@mantine/form"; +import { BlobPanel } from "./blob"; export const _referenceAttrType: attrTypeInfo<"Reference"> = { pretty_name: "Reference", @@ -43,8 +44,83 @@ export const _referenceAttrType: attrTypeInfo<"Reference"> = { editor: { type: "panel", - panel_body: () => { - return "todo-ref"; + panel_body: (params) => { + const pa = params.value.primary_attr; + + if (pa.type === "NotAvailable") { + return ( +
+ No attribute available +
+ ); + } + + if (pa.type === "Blob") { + return ( + + ); + } + + let v =
UNSET!
; + if (pa.type === "Boolean") { + v = pa.value ? ( + true + ) : ( + false + ); + } else if (pa.type === "Float" || pa.type === "Integer") { + v = {pa.value}; + } else if (pa.type === "Hash") { + v = {pa.value}; + } else if (pa.type === "Text") { + v = {pa.value}; + } + + return ( +
+
{pa.type}
+ {v} +
+ ); }, }, }; diff --git a/copperd/bin/edged/src/api/class/items.rs b/copperd/bin/edged/src/api/class/items.rs index be52fd18..575a84d0 100644 --- a/copperd/bin/edged/src/api/class/items.rs +++ b/copperd/bin/edged/src/api/class/items.rs @@ -11,18 +11,22 @@ use axum::{ use axum_extra::extract::CookieJar; use copper_itemdb::{ client::errors::{ - class::GetClassError, + class::{ClassPrimaryAttributeError, GetClassError}, dataset::GetDatasetError, - item::{CountItemsError, ListItemsError}, + item::{CountItemsError, GetItemError, ListItemsError}, }, AttrData, AttributeId, ClassId, ItemId, }; use itertools::Itertools; use serde::{Deserialize, Serialize}; -use sqlx::Acquire; +use sqlx::{Acquire, Transaction}; use tracing::error; use utoipa::{IntoParams, ToSchema}; +// +// MARK: itemattrdata +// + /// Attribute data returned to the user #[derive(Debug, Clone, Serialize, ToSchema)] #[serde(tag = "type")] @@ -58,6 +62,8 @@ pub(super) enum ItemAttrData { #[schema(value_type = i64)] item: ItemId, + + primary_attr: PrimaryAttrData, }, } @@ -65,8 +71,27 @@ impl ItemAttrData { async fn from_attr_data( state: &RouterState, value: AttrData, + trans: &mut Transaction<'_, sqlx::Postgres>, ) -> Result { Ok(match value { + // + // Easy + // + AttrData::Integer { value, .. } => Self::Integer { value }, + AttrData::Float { value, .. } => Self::Float { value }, + AttrData::Boolean { value } => Self::Boolean { value }, + + AttrData::Text { value } => Self::Text { + value: value.into(), + }, + + AttrData::Hash { data, .. } => Self::Hash { + value: data.into_iter().map(|x| format!("{x:02X?}")).join(""), + }, + + // + // MARK: blob + // AttrData::Blob { bucket, key } => { let meta = match state .s3_client @@ -89,18 +114,176 @@ impl ItemAttrData { size: meta.size, } } - AttrData::Integer { value, .. } => ItemAttrData::Integer { value }, - AttrData::Float { value, .. } => ItemAttrData::Float { value }, - AttrData::Boolean { value } => ItemAttrData::Boolean { value }, - AttrData::Reference { class, item } => ItemAttrData::Reference { class, item }, - AttrData::Text { value } => ItemAttrData::Text { + // + // MARK: reference + // + AttrData::Reference { class, item } => Self::Reference { + class, + item, + primary_attr: match state.itemdb_client.class_primary_attr(trans, class).await { + Ok(primary_attr) => { + if let Some(primary_attr) = primary_attr { + let value = state.itemdb_client.get_item(trans, item).await; + + match value { + Ok(value) => { + PrimaryAttrData::from_attr_data( + state, + primary_attr.id, + value + .attribute_values + .get(&primary_attr.id) + .unwrap() + .clone(), + ) + .await? + } + + Err(GetItemError::NotFound) => { + return Err(StatusCode::NOT_FOUND.into_response()) + } + + Err(GetItemError::DbError(error)) => { + error!(message = "Error in itemdb client", ?error); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json("Internal server error"), + ) + .into_response()); + } + } + } else { + PrimaryAttrData::NotAvailable + } + } + + Err(ClassPrimaryAttributeError::NotFound) => { + return Err(StatusCode::NOT_FOUND.into_response()); + } + + Err(ClassPrimaryAttributeError::DbError(error)) => { + error!(message = "Error in itemdb client", ?error); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json("Internal server error"), + ) + .into_response()); + } + }, + }, + }) + } +} + +// +// MARK: primaryattrdata +// + +// Almost identical to [`ItemAttrData`], but excluding references. +// Used inside the reference variant of [`ItemAttrData`]. +#[derive(Debug, Clone, Serialize, ToSchema)] +#[serde(tag = "type")] +pub(super) enum PrimaryAttrData { + NotAvailable, + Text { + #[schema(value_type = i64)] + attr: AttributeId, + value: String, + }, + + Integer { + #[schema(value_type = i64)] + attr: AttributeId, + value: i64, + }, + + Float { + #[schema(value_type = i64)] + attr: AttributeId, + value: f64, + }, + + Boolean { + #[schema(value_type = i64)] + attr: AttributeId, + value: bool, + }, + + Hash { + #[schema(value_type = i64)] + attr: AttributeId, + value: String, + }, + + Blob { + #[schema(value_type = i64)] + attr: AttributeId, + + mime: String, + size: Option, + }, +} + +impl PrimaryAttrData { + async fn from_attr_data( + state: &RouterState, + attr: AttributeId, + value: AttrData, + ) -> Result { + Ok(match value { + // + // Easy + // + AttrData::Integer { value, .. } => Self::Integer { value, attr }, + AttrData::Float { value, .. } => Self::Float { value, attr }, + AttrData::Boolean { value } => Self::Boolean { value, attr }, + + AttrData::Text { value } => Self::Text { value: value.into(), + attr, }, - AttrData::Hash { data, .. } => ItemAttrData::Hash { + AttrData::Hash { data, .. } => Self::Hash { value: data.into_iter().map(|x| format!("{x:02X?}")).join(""), + attr, }, + + // + // MARK: blob + // + AttrData::Blob { bucket, key } => { + let meta = match state + .s3_client + .get_object_metadata(bucket.as_str(), key.as_str()) + .await + { + Ok(x) => x, + Err(error) => { + error!(message = "Error in itemdb client", ?error); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json("Internal server error"), + ) + .into_response()); + } + }; + + Self::Blob { + mime: meta.mime.to_string(), + size: meta.size, + attr, + } + } + + AttrData::Reference { .. } => { + error!(message = "Tried to put a reference in a reference"); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json("Internal server error"), + ) + .into_response()); + } }) } } @@ -133,6 +316,10 @@ pub(super) struct PaginateParams { count: usize, } +// +// MARK: route +// + /// List items in this class #[utoipa::path( get, @@ -258,7 +445,7 @@ pub(super) async fn list_items( for i in x { let mut attribute_values = BTreeMap::new(); for (attr_id, data) in i.attribute_values { - match ItemAttrData::from_attr_data(&state, data).await { + match ItemAttrData::from_attr_data(&state, data, &mut trans).await { Ok(x) => { attribute_values.insert(attr_id, x); } diff --git a/copperd/bin/edged/src/api/class/mod.rs b/copperd/bin/edged/src/api/class/mod.rs index 9c62deeb..efd69691 100644 --- a/copperd/bin/edged/src/api/class/mod.rs +++ b/copperd/bin/edged/src/api/class/mod.rs @@ -27,6 +27,7 @@ use rename::*; NewAttributeRequest, ItemlistItemInfo, ItemAttrData, + PrimaryAttrData, ItemListResponse )) )] diff --git a/copperd/lib/itemdb/src/client/client/class.rs b/copperd/lib/itemdb/src/client/client/class.rs index 6bb28dba..57ba5fa7 100644 --- a/copperd/lib/itemdb/src/client/client/class.rs +++ b/copperd/lib/itemdb/src/client/client/class.rs @@ -4,13 +4,20 @@ use copper_util::names::check_name; use sqlx::Row; use crate::{ - client::errors::class::{AddClassError, DeleteClassError, GetClassError, RenameClassError}, - AttributeInfo, AttributeOptions, ClassId, ClassInfo, DatasetId, + client::errors::class::{ + AddClassError, ClassPrimaryAttributeError, DeleteClassError, GetClassError, + RenameClassError, + }, + AttrDataStub, AttributeInfo, AttributeOptions, ClassId, ClassInfo, DatasetId, }; use super::ItemdbClient; impl ItemdbClient { + // + // MARK: crud + // + pub async fn add_class( &self, t: &mut sqlx::Transaction<'_, sqlx::Postgres>, @@ -142,4 +149,42 @@ impl ItemdbClient { return Ok(()); } + + // + // MARK: misc + // + + /// Get the attribute we should use to represent this class. + /// This is used as an item preview in the `Reference` ui panel. + /// + /// If this returns `None`, the class has no valid attributes + pub async fn class_primary_attr( + &self, + t: &mut sqlx::Transaction<'_, sqlx::Postgres>, + class: ClassId, + ) -> Result, ClassPrimaryAttributeError> { + let class = self.get_class(t, class).await.map_err(|e| match e { + GetClassError::NotFound => ClassPrimaryAttributeError::NotFound, + GetClassError::DbError(err) => ClassPrimaryAttributeError::DbError(err), + })?; + + // If we have blobs, return the first one + for attr in &class.attributes { + match attr.data_type { + AttrDataStub::Blob => return Ok(Some(attr.clone())), + _ => continue, + } + } + + // Otherwise, return the first non-reference attribute + // This prevents infinite loops that might occur we have a ref cycle. + for attr in &class.attributes { + match attr.data_type { + AttrDataStub::Reference { .. } => continue, + _ => return Ok(Some(attr.clone())), + } + } + + return Ok(None); + } } diff --git a/copperd/lib/itemdb/src/client/client/item.rs b/copperd/lib/itemdb/src/client/client/item.rs index 26241e4d..51ca4fbf 100644 --- a/copperd/lib/itemdb/src/client/client/item.rs +++ b/copperd/lib/itemdb/src/client/client/item.rs @@ -56,6 +56,10 @@ pub enum AddItemError { } impl ItemdbClient { + // + // MARK: crud + // + pub async fn get_item( &self, t: &mut sqlx::Transaction<'_, sqlx::Postgres>, @@ -155,23 +159,6 @@ impl ItemdbClient { }; } - pub async fn count_items( - &self, - t: &mut sqlx::Transaction<'_, sqlx::Postgres>, - class: ClassId, - ) -> Result { - let res = sqlx::query("SELECT COUNT(*) FROM item WHERE class_id=$1;") - .bind(i64::from(class)) - .fetch_one(&mut **t) - .await; - - return match res { - Err(sqlx::Error::RowNotFound) => Err(CountItemsError::ClassNotFound), - Err(e) => Err(e.into()), - Ok(res) => Ok(res.get("count")), - }; - } - pub async fn add_item( &self, t: &mut sqlx::Transaction<'_, sqlx::Postgres>, @@ -318,4 +305,25 @@ impl ItemdbClient { return Ok(new_item); } + + // + // MARK: misc + // + + pub async fn count_items( + &self, + t: &mut sqlx::Transaction<'_, sqlx::Postgres>, + class: ClassId, + ) -> Result { + let res = sqlx::query("SELECT COUNT(*) FROM item WHERE class_id=$1;") + .bind(i64::from(class)) + .fetch_one(&mut **t) + .await; + + return match res { + Err(sqlx::Error::RowNotFound) => Err(CountItemsError::ClassNotFound), + Err(e) => Err(e.into()), + Ok(res) => Ok(res.get("count")), + }; + } } diff --git a/copperd/lib/itemdb/src/client/errors/class.rs b/copperd/lib/itemdb/src/client/errors/class.rs index f962e6c5..9def3026 100644 --- a/copperd/lib/itemdb/src/client/errors/class.rs +++ b/copperd/lib/itemdb/src/client/errors/class.rs @@ -58,3 +58,13 @@ pub enum DeleteClassError { #[error("database backend error")] DbError(#[from] sqlx::Error), } + +/// An error we can encounter when getting a class' primary attribute +#[derive(Debug, Error)] +pub enum ClassPrimaryAttributeError { + #[error("database backend error")] + DbError(#[from] sqlx::Error), + + #[error("class not found")] + NotFound, +}