Compare commits
3
Commits
5619bb27a1
...
1859078cf4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1859078cf4 | ||
|
|
c44ba91ec7 | ||
|
|
96a6d0ee5d |
+134
-61
@@ -109,7 +109,8 @@ pub struct DocumentVersionResponse {
|
|||||||
pub checksum: String,
|
pub checksum: String,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub metadata: Value,
|
pub metadata: Value,
|
||||||
pub operations_summary: Value,
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub operations_summary: Option<Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Clone)]
|
#[derive(Serialize, Clone)]
|
||||||
@@ -119,8 +120,31 @@ pub struct DocumentAssetResponse {
|
|||||||
pub mime_type: String,
|
pub mime_type: String,
|
||||||
pub metadata: Value,
|
pub metadata: Value,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub cardinality: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Clone)]
|
||||||
|
pub struct DocumentAssetObjectResponse {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub ordinal: i32,
|
||||||
|
pub metadata: Value,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub url: Option<String>,
|
pub url: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub expires_at: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct DocumentAssetDetailResponse {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub asset_type: String,
|
||||||
|
pub mime_type: String,
|
||||||
|
pub metadata: Value,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub cardinality: Option<i32>,
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub objects: Vec<DocumentAssetObjectResponse>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Clone)]
|
#[derive(Serialize, Clone)]
|
||||||
@@ -336,6 +360,14 @@ pub struct AssignTagsRequest {
|
|||||||
pub tag_ids: Vec<Uuid>,
|
pub tag_ids: Vec<Uuid>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Default)]
|
||||||
|
pub struct AssetObjectsQuery {
|
||||||
|
#[serde(default)]
|
||||||
|
pub start: Option<i32>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub limit: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn list_documents(
|
pub async fn list_documents(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Query(params): Query<DocumentListQuery>,
|
Query(params): Query<DocumentListQuery>,
|
||||||
@@ -606,7 +638,7 @@ pub async fn get_document(
|
|||||||
drop(conn);
|
drop(conn);
|
||||||
|
|
||||||
let assets = load_asset_responses(&state, version_id).await?;
|
let assets = load_asset_responses(&state, version_id).await?;
|
||||||
let version_response = to_version_response(current_version);
|
let version_response = to_version_response(current_version, true);
|
||||||
|
|
||||||
Ok(Json(DocumentDetailResponse {
|
Ok(Json(DocumentDetailResponse {
|
||||||
document: to_document_response(
|
document: to_document_response(
|
||||||
@@ -849,45 +881,70 @@ pub async fn list_document_assets(
|
|||||||
|
|
||||||
pub async fn get_document_asset(
|
pub async fn get_document_asset(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path((document_id, asset_id)): Path<(Uuid, Uuid)>,
|
Path(asset_id): Path<Uuid>,
|
||||||
) -> AppResult<Json<DocumentAssetResponse>> {
|
Query(query): Query<AssetObjectsQuery>,
|
||||||
|
) -> AppResult<Json<DocumentAssetDetailResponse>> {
|
||||||
let mut conn = state.db()?;
|
let mut conn = state.db()?;
|
||||||
let document: Document = documents::table.find(document_id).first(&mut conn)?;
|
|
||||||
if document.deleted_at.is_some() {
|
let asset: DocumentAsset = match document_assets::table
|
||||||
return Err(AppError::not_found());
|
.find(asset_id)
|
||||||
|
.first(&mut conn)
|
||||||
|
.optional()?
|
||||||
|
{
|
||||||
|
Some(asset) => asset,
|
||||||
|
None => return Err(AppError::not_found()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let start = query.start.unwrap_or(1);
|
||||||
|
let limit = query.limit.unwrap_or(1);
|
||||||
|
if start < 1 {
|
||||||
|
return Err(AppError::bad_request("start must be at least 1"));
|
||||||
|
}
|
||||||
|
if limit < 1 {
|
||||||
|
return Err(AppError::bad_request("limit must be at least 1"));
|
||||||
}
|
}
|
||||||
|
|
||||||
let (asset, object): (DocumentAsset, DocumentAssetObject) = document_assets::table
|
let end = start
|
||||||
.inner_join(
|
.checked_add(limit - 1)
|
||||||
document_asset_objects::table.on(document_asset_objects::asset_id
|
.ok_or_else(|| AppError::bad_request("requested range is too large"))?;
|
||||||
.eq(document_assets::id)
|
|
||||||
.and(document_asset_objects::ordinal.eq(1))),
|
|
||||||
)
|
|
||||||
.filter(document_assets::id.eq(asset_id))
|
|
||||||
.first(&mut conn)?;
|
|
||||||
let version: DocumentVersion = document_versions::table
|
|
||||||
.find(asset.document_version_id)
|
|
||||||
.first(&mut conn)?;
|
|
||||||
|
|
||||||
if version.document_id != document_id {
|
let objects: Vec<DocumentAssetObject> = document_asset_objects::table
|
||||||
return Err(AppError::not_found());
|
.filter(document_asset_objects::asset_id.eq(asset_id))
|
||||||
}
|
.filter(document_asset_objects::ordinal.ge(start))
|
||||||
|
.filter(document_asset_objects::ordinal.le(end))
|
||||||
|
.order(document_asset_objects::ordinal.asc())
|
||||||
|
.load(&mut conn)?;
|
||||||
|
|
||||||
let s3_key = object.s3_key.clone();
|
|
||||||
let object_metadata = object.metadata.clone();
|
|
||||||
drop(conn);
|
drop(conn);
|
||||||
|
|
||||||
let presigned_url = state
|
let expires_at = Utc::now()
|
||||||
|
.timestamp_millis()
|
||||||
|
.checked_add((PRESIGNED_URL_EXPIRY_SECONDS as i64) * 1000)
|
||||||
|
.ok_or_else(|| AppError::internal("failed to compute expiry timestamp"))?;
|
||||||
|
|
||||||
|
let mut object_responses = Vec::with_capacity(objects.len());
|
||||||
|
for object in objects {
|
||||||
|
let url = state
|
||||||
.storage
|
.storage
|
||||||
.presign_get_object(&s3_key, Duration::from_secs(PRESIGNED_URL_EXPIRY_SECONDS))
|
.presign_get_object(
|
||||||
|
&object.s3_key,
|
||||||
|
Duration::from_secs(PRESIGNED_URL_EXPIRY_SECONDS),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|err| AppError::internal(format!("failed to generate asset URL: {err}")))?;
|
.map_err(|err| AppError::internal(format!("failed to generate asset URL: {err}")))?;
|
||||||
|
|
||||||
Ok(Json(to_asset_response(
|
object_responses.push(to_asset_object_response(
|
||||||
asset,
|
object,
|
||||||
Some(object_metadata),
|
Some(url),
|
||||||
Some(presigned_url),
|
Some(expires_at),
|
||||||
)))
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if object_responses.is_empty() {
|
||||||
|
return Err(AppError::not_found());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Json(to_asset_detail_response(asset, object_responses)))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn download_document(
|
pub async fn download_document(
|
||||||
@@ -1045,7 +1102,7 @@ pub async fn update_document(
|
|||||||
drop(conn);
|
drop(conn);
|
||||||
|
|
||||||
let assets = load_asset_responses(&state, version_id).await?;
|
let assets = load_asset_responses(&state, version_id).await?;
|
||||||
let version_response = to_version_response(current_version);
|
let version_response = to_version_response(current_version, true);
|
||||||
|
|
||||||
Ok(Json(DocumentDetailResponse {
|
Ok(Json(DocumentDetailResponse {
|
||||||
document: to_document_response(
|
document: to_document_response(
|
||||||
@@ -1584,7 +1641,7 @@ async fn process_upload(
|
|||||||
let correspondents = correspondents_map.remove(&document.id).unwrap_or_default();
|
let correspondents = correspondents_map.remove(&document.id).unwrap_or_default();
|
||||||
drop(conn);
|
drop(conn);
|
||||||
let assets = load_asset_responses(state, version.id).await?;
|
let assets = load_asset_responses(state, version.id).await?;
|
||||||
let version_response = to_version_response(version.clone());
|
let version_response = to_version_response(version.clone(), true);
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
document_id = %document.id,
|
document_id = %document.id,
|
||||||
@@ -1677,7 +1734,7 @@ async fn process_upload(
|
|||||||
document,
|
document,
|
||||||
None,
|
None,
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
Some((to_version_response(version.clone()), Vec::new())),
|
Some((to_version_response(version.clone(), true), Vec::new())),
|
||||||
)?,
|
)?,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1815,9 +1872,9 @@ pub(crate) async fn load_primary_assets(
|
|||||||
.load(&mut conn)?;
|
.load(&mut conn)?;
|
||||||
|
|
||||||
let mut assets_by_version: HashMap<Uuid, Vec<DocumentAssetResponse>> = HashMap::new();
|
let mut assets_by_version: HashMap<Uuid, Vec<DocumentAssetResponse>> = HashMap::new();
|
||||||
for (asset, object) in assets {
|
for (asset, _object) in assets {
|
||||||
let version_id = asset.document_version_id;
|
let version_id = asset.document_version_id;
|
||||||
let response = to_asset_response(asset, object.map(|o| o.metadata), None);
|
let response = to_asset_summary(asset);
|
||||||
assets_by_version
|
assets_by_version
|
||||||
.entry(version_id)
|
.entry(version_id)
|
||||||
.or_default()
|
.or_default()
|
||||||
@@ -1831,7 +1888,7 @@ pub(crate) async fn load_primary_assets(
|
|||||||
for (doc_id, version_id) in doc_to_version {
|
for (doc_id, version_id) in doc_to_version {
|
||||||
if let Some(version) = version_map.remove(&version_id) {
|
if let Some(version) = version_map.remove(&version_id) {
|
||||||
let assets = assets_by_version.remove(&version_id).unwrap_or_default();
|
let assets = assets_by_version.remove(&version_id).unwrap_or_default();
|
||||||
result.insert(doc_id, (to_version_response(version), assets));
|
result.insert(doc_id, (to_version_response(version, false), assets));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1887,7 +1944,10 @@ fn build_download_path(state: &AppState, document_id: Uuid, user_id: Uuid) -> Ap
|
|||||||
.map_err(|err| AppError::internal(format!("failed to generate download token: {err}")))
|
.map_err(|err| AppError::internal(format!("failed to generate download token: {err}")))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn to_version_response(version: DocumentVersion) -> DocumentVersionResponse {
|
fn to_version_response(
|
||||||
|
version: DocumentVersion,
|
||||||
|
include_operations_summary: bool,
|
||||||
|
) -> DocumentVersionResponse {
|
||||||
DocumentVersionResponse {
|
DocumentVersionResponse {
|
||||||
id: version.id,
|
id: version.id,
|
||||||
version_number: version.version_number,
|
version_number: version.version_number,
|
||||||
@@ -1896,37 +1956,50 @@ fn to_version_response(version: DocumentVersion) -> DocumentVersionResponse {
|
|||||||
checksum: version.checksum,
|
checksum: version.checksum,
|
||||||
created_at: to_iso(version.created_at),
|
created_at: to_iso(version.created_at),
|
||||||
metadata: version.metadata,
|
metadata: version.metadata,
|
||||||
operations_summary: version.operations_summary,
|
operations_summary: if include_operations_summary {
|
||||||
|
Some(version.operations_summary)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn merge_metadata(base: &Value, overlay: Option<&Value>) -> Value {
|
fn to_asset_summary(asset: DocumentAsset) -> DocumentAssetResponse {
|
||||||
match (base, overlay) {
|
|
||||||
(Value::Object(base_obj), Some(Value::Object(overlay_obj))) => {
|
|
||||||
let mut merged = base_obj.clone();
|
|
||||||
for (key, value) in overlay_obj {
|
|
||||||
merged.insert(key.clone(), value.clone());
|
|
||||||
}
|
|
||||||
Value::Object(merged)
|
|
||||||
}
|
|
||||||
(_, Some(value)) => value.clone(),
|
|
||||||
(value, None) => value.clone(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn to_asset_response(
|
|
||||||
asset: DocumentAsset,
|
|
||||||
object_metadata: Option<Value>,
|
|
||||||
url: Option<String>,
|
|
||||||
) -> DocumentAssetResponse {
|
|
||||||
let metadata = merge_metadata(&asset.metadata, object_metadata.as_ref());
|
|
||||||
DocumentAssetResponse {
|
DocumentAssetResponse {
|
||||||
id: asset.id,
|
id: asset.id,
|
||||||
asset_type: asset.asset_type,
|
asset_type: asset.asset_type,
|
||||||
mime_type: asset.mime_type,
|
mime_type: asset.mime_type,
|
||||||
metadata,
|
metadata: asset.metadata,
|
||||||
url,
|
cardinality: asset.cardinality,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_asset_detail_response(
|
||||||
|
asset: DocumentAsset,
|
||||||
|
objects: Vec<DocumentAssetObjectResponse>,
|
||||||
|
) -> DocumentAssetDetailResponse {
|
||||||
|
DocumentAssetDetailResponse {
|
||||||
|
id: asset.id,
|
||||||
|
asset_type: asset.asset_type,
|
||||||
|
mime_type: asset.mime_type,
|
||||||
|
metadata: asset.metadata,
|
||||||
created_at: to_iso(asset.created_at),
|
created_at: to_iso(asset.created_at),
|
||||||
|
cardinality: asset.cardinality,
|
||||||
|
objects,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_asset_object_response(
|
||||||
|
object: DocumentAssetObject,
|
||||||
|
url: Option<String>,
|
||||||
|
expires_at: Option<i64>,
|
||||||
|
) -> DocumentAssetObjectResponse {
|
||||||
|
DocumentAssetObjectResponse {
|
||||||
|
id: object.id,
|
||||||
|
ordinal: object.ordinal,
|
||||||
|
metadata: object.metadata,
|
||||||
|
url,
|
||||||
|
expires_at,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1988,7 +2061,7 @@ async fn load_asset_responses(
|
|||||||
|
|
||||||
Ok(assets
|
Ok(assets
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(asset, object)| to_asset_response(asset, object.map(|o| o.metadata), None))
|
.map(|(asset, _object)| to_asset_summary(asset))
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -75,7 +75,6 @@ pub fn create_router(state: AppState) -> Router<()> {
|
|||||||
.patch(documents::update_document),
|
.patch(documents::update_document),
|
||||||
)
|
)
|
||||||
.route("/:id/download", get(documents::download_document))
|
.route("/:id/download", get(documents::download_document))
|
||||||
.route("/:id/assets/:asset_id", get(documents::get_document_asset))
|
|
||||||
.route(
|
.route(
|
||||||
"/:id/assets",
|
"/:id/assets",
|
||||||
get(documents::list_document_assets).post(documents::request_document_assets),
|
get(documents::list_document_assets).post(documents::request_document_assets),
|
||||||
@@ -120,11 +119,14 @@ pub fn create_router(state: AppState) -> Router<()> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let protected_state = state.clone();
|
let protected_state = state.clone();
|
||||||
|
let assets_routes = Router::new().route("/:asset_id", get(documents::get_document_asset));
|
||||||
|
|
||||||
let protected_routes = Router::new()
|
let protected_routes = Router::new()
|
||||||
.nest("/api/documents", documents_routes)
|
.nest("/api/documents", documents_routes)
|
||||||
.nest("/api/folders", folders_routes)
|
.nest("/api/folders", folders_routes)
|
||||||
.nest("/api/tags", tags_routes)
|
.nest("/api/tags", tags_routes)
|
||||||
.nest("/api/correspondents", correspondents_routes)
|
.nest("/api/correspondents", correspondents_routes)
|
||||||
|
.nest("/api/assets", assets_routes)
|
||||||
.layer(middleware::from_extractor_with_state::<AuthenticatedUser, _>(protected_state));
|
.layer(middleware::from_extractor_with_state::<AuthenticatedUser, _>(protected_state));
|
||||||
|
|
||||||
Router::new()
|
Router::new()
|
||||||
|
|||||||
@@ -171,17 +171,8 @@ impl JobHandler for GenerateThumbnailsJob {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let thumbnail_asset_id = Uuid::new_v4();
|
|
||||||
let thumbnail_s3_key = format!(
|
|
||||||
"documents/{}/v{}/assets/{}/{}",
|
|
||||||
initial.document.id,
|
|
||||||
initial.version.version_number,
|
|
||||||
THUMBNAIL_ASSET_TYPE,
|
|
||||||
thumbnail_asset_id
|
|
||||||
);
|
|
||||||
|
|
||||||
let preview_asset_id = Uuid::new_v4();
|
let preview_asset_id = Uuid::new_v4();
|
||||||
let preview_s3_key = format!(
|
let preview_base = format!(
|
||||||
"documents/{}/v{}/assets/{}/{}",
|
"documents/{}/v{}/assets/{}/{}",
|
||||||
initial.document.id,
|
initial.document.id,
|
||||||
initial.version.version_number,
|
initial.version.version_number,
|
||||||
@@ -189,60 +180,103 @@ impl JobHandler for GenerateThumbnailsJob {
|
|||||||
preview_asset_id
|
preview_asset_id
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let thumbnail_asset_id = Uuid::new_v4();
|
||||||
|
let thumbnail_base = format!(
|
||||||
|
"documents/{}/v{}/assets/{}/{}",
|
||||||
|
initial.document.id,
|
||||||
|
initial.version.version_number,
|
||||||
|
THUMBNAIL_ASSET_TYPE,
|
||||||
|
thumbnail_asset_id
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut preview_objects: Vec<AssetObjectPersistence> =
|
||||||
|
Vec::with_capacity(generation.preview.objects.len());
|
||||||
|
for (index, image) in generation.preview.objects.iter().enumerate() {
|
||||||
|
if index + 1 > i32::MAX as usize {
|
||||||
|
return JobExecution::Failed {
|
||||||
|
error: "too many preview objects".to_string(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let ordinal = (index + 1) as i32;
|
||||||
|
let s3_key = format!("{preview_base}/{ordinal}");
|
||||||
|
|
||||||
if let Err(err) = state
|
if let Err(err) = state
|
||||||
.storage
|
.storage
|
||||||
.put_object(
|
.put_object(
|
||||||
&preview_s3_key,
|
&s3_key,
|
||||||
generation.preview.image_bytes.clone(),
|
image.image_bytes.clone(),
|
||||||
Some("image/png".into()),
|
Some("image/png".into()),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
warn!(job_id = %job.id, error = %err, "failed to upload preview; retrying");
|
warn!(job_id = %job.id, error = %err, ordinal, "failed to upload preview; retrying");
|
||||||
return JobExecution::Retry {
|
return JobExecution::Retry {
|
||||||
delay: Duration::from_secs(30),
|
delay: Duration::from_secs(30),
|
||||||
error: err.to_string(),
|
error: err.to_string(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
preview_objects.push(AssetObjectPersistence {
|
||||||
|
ordinal,
|
||||||
|
s3_key,
|
||||||
|
width: image.width,
|
||||||
|
height: image.height,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut thumbnail_objects: Vec<AssetObjectPersistence> =
|
||||||
|
Vec::with_capacity(generation.thumbnail.objects.len());
|
||||||
|
for (index, image) in generation.thumbnail.objects.iter().enumerate() {
|
||||||
|
if index + 1 > i32::MAX as usize {
|
||||||
|
return JobExecution::Failed {
|
||||||
|
error: "too many thumbnail objects".to_string(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let ordinal = (index + 1) as i32;
|
||||||
|
let s3_key = format!("{thumbnail_base}/{ordinal}");
|
||||||
|
|
||||||
if let Err(err) = state
|
if let Err(err) = state
|
||||||
.storage
|
.storage
|
||||||
.put_object(
|
.put_object(
|
||||||
&thumbnail_s3_key,
|
&s3_key,
|
||||||
generation.thumbnail.image_bytes.clone(),
|
image.image_bytes.clone(),
|
||||||
Some("image/png".into()),
|
Some("image/png".into()),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
warn!(job_id = %job.id, error = %err, "failed to upload thumbnail; retrying");
|
warn!(job_id = %job.id, error = %err, ordinal, "failed to upload thumbnail; retrying");
|
||||||
return JobExecution::Retry {
|
return JobExecution::Retry {
|
||||||
delay: Duration::from_secs(30),
|
delay: Duration::from_secs(30),
|
||||||
error: err.to_string(),
|
error: err.to_string(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let state_clone = state.clone();
|
thumbnail_objects.push(AssetObjectPersistence {
|
||||||
match task::spawn_blocking(move || {
|
ordinal,
|
||||||
persist_assets_metadata(
|
s3_key,
|
||||||
state_clone,
|
width: image.width,
|
||||||
&initial,
|
height: image.height,
|
||||||
&[
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let asset_persistences = vec![
|
||||||
AssetPersistence {
|
AssetPersistence {
|
||||||
asset_type: PREVIEW_ASSET_TYPE,
|
asset_type: PREVIEW_ASSET_TYPE,
|
||||||
asset_id: preview_asset_id,
|
asset_id: preview_asset_id,
|
||||||
s3_key: &preview_s3_key,
|
objects: preview_objects,
|
||||||
generated: &generation.preview,
|
|
||||||
},
|
},
|
||||||
AssetPersistence {
|
AssetPersistence {
|
||||||
asset_type: THUMBNAIL_ASSET_TYPE,
|
asset_type: THUMBNAIL_ASSET_TYPE,
|
||||||
asset_id: thumbnail_asset_id,
|
asset_id: thumbnail_asset_id,
|
||||||
s3_key: &thumbnail_s3_key,
|
objects: thumbnail_objects,
|
||||||
generated: &generation.thumbnail,
|
|
||||||
},
|
},
|
||||||
],
|
];
|
||||||
)
|
|
||||||
|
let state_clone = state.clone();
|
||||||
|
match task::spawn_blocking(move || {
|
||||||
|
persist_assets_metadata(state_clone, &initial, &asset_persistences)
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -283,17 +317,27 @@ struct GeneratedImage {
|
|||||||
height: Option<i32>,
|
height: Option<i32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct GeneratedAsset {
|
||||||
|
objects: Vec<GeneratedImage>,
|
||||||
|
}
|
||||||
|
|
||||||
struct GeneratedAssets {
|
struct GeneratedAssets {
|
||||||
thumbnail: GeneratedImage,
|
thumbnail: GeneratedAsset,
|
||||||
preview: GeneratedImage,
|
preview: GeneratedAsset,
|
||||||
page_count: Option<u32>,
|
page_count: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct AssetPersistence<'a> {
|
struct AssetObjectPersistence {
|
||||||
|
ordinal: i32,
|
||||||
|
s3_key: String,
|
||||||
|
width: Option<i32>,
|
||||||
|
height: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AssetPersistence {
|
||||||
asset_type: &'static str,
|
asset_type: &'static str,
|
||||||
asset_id: Uuid,
|
asset_id: Uuid,
|
||||||
s3_key: &'a str,
|
objects: Vec<AssetObjectPersistence>,
|
||||||
generated: &'a GeneratedImage,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load_thumbnail_context(
|
fn load_thumbnail_context(
|
||||||
@@ -356,7 +400,25 @@ fn load_thumbnail_context(
|
|||||||
return Err("thumbnail generation not supported for this document".into());
|
return Err("thumbnail generation not supported for this document".into());
|
||||||
}
|
}
|
||||||
|
|
||||||
let skip = existing_thumbnail.is_some() && existing_preview.is_some() && !payload.force;
|
let expected_cardinality = expected_asset_cardinality(&document, &version);
|
||||||
|
let preview_cardinality = existing_preview
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|asset| asset.cardinality)
|
||||||
|
.unwrap_or_else(|| existing_preview_objects.len() as i32);
|
||||||
|
let thumbnail_cardinality = existing_thumbnail
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|asset| asset.cardinality)
|
||||||
|
.unwrap_or_else(|| existing_thumbnail_objects.len() as i32);
|
||||||
|
|
||||||
|
let needs_regeneration = preview_cardinality < expected_cardinality
|
||||||
|
|| thumbnail_cardinality < expected_cardinality
|
||||||
|
|| (existing_preview_objects.len() as i32) < expected_cardinality
|
||||||
|
|| (existing_thumbnail_objects.len() as i32) < expected_cardinality;
|
||||||
|
|
||||||
|
let skip = existing_thumbnail.is_some()
|
||||||
|
&& existing_preview.is_some()
|
||||||
|
&& !payload.force
|
||||||
|
&& !needs_regeneration;
|
||||||
|
|
||||||
Ok(ThumbnailContext {
|
Ok(ThumbnailContext {
|
||||||
document,
|
document,
|
||||||
@@ -373,18 +435,7 @@ fn generate_preview_and_thumbnail(
|
|||||||
document: &Document,
|
document: &Document,
|
||||||
bytes: &[u8],
|
bytes: &[u8],
|
||||||
) -> Result<GeneratedAssets, String> {
|
) -> Result<GeneratedAssets, String> {
|
||||||
let is_pdf = document
|
let is_pdf = document_is_pdf(document);
|
||||||
.content_type
|
|
||||||
.as_deref()
|
|
||||||
.map(|mime| mime == "application/pdf")
|
|
||||||
.unwrap_or_else(|| {
|
|
||||||
document
|
|
||||||
.original_name
|
|
||||||
.rsplit('.')
|
|
||||||
.next()
|
|
||||||
.map(|ext| ext.eq_ignore_ascii_case("pdf"))
|
|
||||||
.unwrap_or(false)
|
|
||||||
});
|
|
||||||
|
|
||||||
if is_pdf {
|
if is_pdf {
|
||||||
let pdf_assets = generate_pdf_assets(bytes)?;
|
let pdf_assets = generate_pdf_assets(bytes)?;
|
||||||
@@ -403,7 +454,7 @@ fn generate_preview_and_thumbnail(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn generate_image_assets(bytes: &[u8]) -> Result<(GeneratedImage, GeneratedImage), String> {
|
fn generate_image_assets(bytes: &[u8]) -> Result<(GeneratedAsset, GeneratedAsset), String> {
|
||||||
let reader = ImageReader::new(Cursor::new(bytes))
|
let reader = ImageReader::new(Cursor::new(bytes))
|
||||||
.with_guessed_format()
|
.with_guessed_format()
|
||||||
.map_err(|err| err.to_string())?;
|
.map_err(|err| err.to_string())?;
|
||||||
@@ -425,12 +476,19 @@ fn generate_image_assets(bytes: &[u8]) -> Result<(GeneratedImage, GeneratedImage
|
|||||||
let preview = encode_dynamic_image(preview_image)?;
|
let preview = encode_dynamic_image(preview_image)?;
|
||||||
let thumbnail = encode_dynamic_image(thumbnail_image)?;
|
let thumbnail = encode_dynamic_image(thumbnail_image)?;
|
||||||
|
|
||||||
Ok((preview, thumbnail))
|
Ok((
|
||||||
|
GeneratedAsset {
|
||||||
|
objects: vec![preview],
|
||||||
|
},
|
||||||
|
GeneratedAsset {
|
||||||
|
objects: vec![thumbnail],
|
||||||
|
},
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
struct PdfGeneratedAssets {
|
struct PdfGeneratedAssets {
|
||||||
preview: GeneratedImage,
|
preview: GeneratedAsset,
|
||||||
thumbnail: GeneratedImage,
|
thumbnail: GeneratedAsset,
|
||||||
page_count: u32,
|
page_count: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -443,11 +501,7 @@ fn generate_pdf_assets(bytes: &[u8]) -> Result<PdfGeneratedAssets, String> {
|
|||||||
.map_err(|err| format!("load pdf: {err}"))?;
|
.map_err(|err| format!("load pdf: {err}"))?;
|
||||||
|
|
||||||
let pages = document.pages();
|
let pages = document.pages();
|
||||||
let total_pages = pages.len();
|
let total_pages = pages.len() as usize;
|
||||||
|
|
||||||
let page = pages
|
|
||||||
.get(0)
|
|
||||||
.map_err(|err| format!("load first page: {err}"))?;
|
|
||||||
|
|
||||||
let render_config = PdfRenderConfig::new()
|
let render_config = PdfRenderConfig::new()
|
||||||
.set_target_width(PREVIEW_WIDTH as i32)
|
.set_target_width(PREVIEW_WIDTH as i32)
|
||||||
@@ -455,30 +509,44 @@ fn generate_pdf_assets(bytes: &[u8]) -> Result<PdfGeneratedAssets, String> {
|
|||||||
.render_form_data(true)
|
.render_form_data(true)
|
||||||
.rotate_if_landscape(PdfPageRenderRotation::None, true);
|
.rotate_if_landscape(PdfPageRenderRotation::None, true);
|
||||||
|
|
||||||
|
let mut preview_objects: Vec<GeneratedImage> = Vec::with_capacity(total_pages);
|
||||||
|
let mut thumbnail_objects: Vec<GeneratedImage> = Vec::with_capacity(total_pages);
|
||||||
|
|
||||||
|
for page_index in 0..total_pages {
|
||||||
|
let page = pages
|
||||||
|
.get(u16::try_from(page_index).map_err(|_| "page index overflow".to_string())?)
|
||||||
|
.map_err(|err| format!("load page {page_index}: {err}"))?;
|
||||||
|
|
||||||
let bitmap = page
|
let bitmap = page
|
||||||
.render_with_config(&render_config)
|
.render_with_config(&render_config)
|
||||||
.map_err(|err| format!("render pdf page: {err}"))?;
|
.map_err(|err| format!("render pdf page {page_index}: {err}"))?;
|
||||||
|
|
||||||
let preview_buffer = bitmap.as_image().to_rgb8();
|
let preview_buffer = bitmap.as_image().to_rgb8();
|
||||||
let preview_image = image::DynamicImage::ImageRgb8(preview_buffer);
|
let preview_image = image::DynamicImage::ImageRgb8(preview_buffer);
|
||||||
|
|
||||||
let thumbnail_image =
|
let thumbnail_image = if preview_image.width() > THUMBNAIL_WIDTH
|
||||||
if preview_image.width() > THUMBNAIL_WIDTH || preview_image.height() > THUMBNAIL_HEIGHT {
|
|| preview_image.height() > THUMBNAIL_HEIGHT
|
||||||
|
{
|
||||||
preview_image.thumbnail(THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT)
|
preview_image.thumbnail(THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT)
|
||||||
} else {
|
} else {
|
||||||
preview_image.clone()
|
preview_image.clone()
|
||||||
};
|
};
|
||||||
|
|
||||||
let preview = encode_dynamic_image(preview_image)?;
|
preview_objects.push(encode_dynamic_image(preview_image)?);
|
||||||
let thumbnail = encode_dynamic_image(thumbnail_image)?;
|
thumbnail_objects.push(encode_dynamic_image(thumbnail_image)?);
|
||||||
|
}
|
||||||
|
|
||||||
let page_count: u32 = total_pages
|
let page_count: u32 = total_pages
|
||||||
.try_into()
|
.try_into()
|
||||||
.map_err(|_| "page count exceeds supported range".to_string())?;
|
.map_err(|_| "page count exceeds supported range".to_string())?;
|
||||||
|
|
||||||
Ok(PdfGeneratedAssets {
|
Ok(PdfGeneratedAssets {
|
||||||
preview,
|
preview: GeneratedAsset {
|
||||||
thumbnail,
|
objects: preview_objects,
|
||||||
|
},
|
||||||
|
thumbnail: GeneratedAsset {
|
||||||
|
objects: thumbnail_objects,
|
||||||
|
},
|
||||||
page_count,
|
page_count,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -499,7 +567,7 @@ fn encode_dynamic_image(image: image::DynamicImage) -> Result<GeneratedImage, St
|
|||||||
fn persist_assets_metadata(
|
fn persist_assets_metadata(
|
||||||
state: Arc<AppState>,
|
state: Arc<AppState>,
|
||||||
context: &ThumbnailContext,
|
context: &ThumbnailContext,
|
||||||
assets: &[AssetPersistence<'_>],
|
assets: &[AssetPersistence],
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let mut conn = state.db().map_err(|err| format!("{err:?}"))?;
|
let mut conn = state.db().map_err(|err| format!("{err:?}"))?;
|
||||||
|
|
||||||
@@ -518,6 +586,19 @@ fn persist_assets_metadata(
|
|||||||
}
|
}
|
||||||
|
|
||||||
for asset in assets {
|
for asset in assets {
|
||||||
|
if asset.objects.is_empty() {
|
||||||
|
return Err(format!(
|
||||||
|
"asset {} has no generated objects",
|
||||||
|
asset.asset_type
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let object_count: i32 = asset
|
||||||
|
.objects
|
||||||
|
.len()
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| "asset contains too many objects".to_string())?;
|
||||||
|
|
||||||
let new_asset = NewDocumentAsset {
|
let new_asset = NewDocumentAsset {
|
||||||
id: asset.asset_id,
|
id: asset.asset_id,
|
||||||
document_version_id: context.version.id,
|
document_version_id: context.version.id,
|
||||||
@@ -526,7 +607,7 @@ fn persist_assets_metadata(
|
|||||||
metadata: json!({
|
metadata: json!({
|
||||||
"generated_at": Utc::now().to_rfc3339(),
|
"generated_at": Utc::now().to_rfc3339(),
|
||||||
}),
|
}),
|
||||||
cardinality: Some(1),
|
cardinality: Some(object_count),
|
||||||
};
|
};
|
||||||
|
|
||||||
diesel::insert_into(document_assets::table)
|
diesel::insert_into(document_assets::table)
|
||||||
@@ -544,48 +625,38 @@ fn persist_assets_metadata(
|
|||||||
.execute(&mut conn)
|
.execute(&mut conn)
|
||||||
.map_err(|err| format!("{err:?}"))?;
|
.map_err(|err| format!("{err:?}"))?;
|
||||||
|
|
||||||
let existing_object_id: Option<Uuid> = document_asset_objects::table
|
diesel::delete(
|
||||||
.filter(document_asset_objects::asset_id.eq(asset.asset_id))
|
document_asset_objects::table
|
||||||
.filter(document_asset_objects::ordinal.eq(1))
|
.filter(document_asset_objects::asset_id.eq(asset.asset_id)),
|
||||||
.select(document_asset_objects::id)
|
)
|
||||||
.first(&mut conn)
|
.execute(&mut conn)
|
||||||
.optional()
|
|
||||||
.map_err(|err| format!("{err:?}"))?;
|
.map_err(|err| format!("{err:?}"))?;
|
||||||
|
|
||||||
let object_id = existing_object_id.unwrap_or_else(Uuid::new_v4);
|
for object in &asset.objects {
|
||||||
|
|
||||||
let mut metadata_map = Map::new();
|
let mut metadata_map = Map::new();
|
||||||
if let Some(width) = asset.generated.width {
|
if let Some(width) = object.width {
|
||||||
metadata_map.insert("width".to_string(), Value::from(width));
|
metadata_map.insert("width".to_string(), Value::from(width));
|
||||||
}
|
}
|
||||||
if let Some(height) = asset.generated.height {
|
if let Some(height) = object.height {
|
||||||
metadata_map.insert("height".to_string(), Value::from(height));
|
metadata_map.insert("height".to_string(), Value::from(height));
|
||||||
}
|
}
|
||||||
|
|
||||||
let object_metadata = Value::Object(metadata_map);
|
let object_metadata = Value::Object(metadata_map);
|
||||||
|
|
||||||
let new_object = NewDocumentAssetObject {
|
let new_object = NewDocumentAssetObject {
|
||||||
id: object_id,
|
id: Uuid::new_v4(),
|
||||||
asset_id: asset.asset_id,
|
asset_id: asset.asset_id,
|
||||||
ordinal: 1,
|
ordinal: object.ordinal,
|
||||||
s3_key: asset.s3_key.to_string(),
|
s3_key: object.s3_key.clone(),
|
||||||
metadata: object_metadata,
|
metadata: object_metadata,
|
||||||
};
|
};
|
||||||
|
|
||||||
diesel::insert_into(document_asset_objects::table)
|
diesel::insert_into(document_asset_objects::table)
|
||||||
.values(&new_object)
|
.values(&new_object)
|
||||||
.on_conflict((
|
|
||||||
document_asset_objects::asset_id,
|
|
||||||
document_asset_objects::ordinal,
|
|
||||||
))
|
|
||||||
.do_update()
|
|
||||||
.set((
|
|
||||||
document_asset_objects::s3_key.eq(excluded(document_asset_objects::s3_key)),
|
|
||||||
document_asset_objects::metadata.eq(excluded(document_asset_objects::metadata)),
|
|
||||||
))
|
|
||||||
.execute(&mut conn)
|
.execute(&mut conn)
|
||||||
.map_err(|err| format!("{err:?}"))?;
|
.map_err(|err| format!("{err:?}"))?;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -628,3 +699,37 @@ fn persist_document_page_count(
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn document_is_pdf(document: &Document) -> bool {
|
||||||
|
document
|
||||||
|
.content_type
|
||||||
|
.as_deref()
|
||||||
|
.map(|mime| mime.eq_ignore_ascii_case("application/pdf"))
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
document
|
||||||
|
.original_name
|
||||||
|
.rsplit('.')
|
||||||
|
.next()
|
||||||
|
.map(|ext| ext.eq_ignore_ascii_case("pdf"))
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn expected_asset_cardinality(document: &Document, version: &DocumentVersion) -> i32 {
|
||||||
|
if let Value::Object(map) = &version.metadata {
|
||||||
|
if let Some(count) = map.get("page_count").and_then(|v| v.as_i64()) {
|
||||||
|
if count > 0 {
|
||||||
|
return count
|
||||||
|
.min(i64::from(i32::MAX))
|
||||||
|
.try_into()
|
||||||
|
.unwrap_or(i32::MAX);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if document_is_pdf(document) {
|
||||||
|
1
|
||||||
|
} else {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+1
-1
@@ -37,7 +37,7 @@ Document Assets
|
|||||||
---------------
|
---------------
|
||||||
- GET /api/documents/:id/assets - List generated assets for the current version.
|
- GET /api/documents/:id/assets - List generated assets for the current version.
|
||||||
- POST /api/documents/:id/assets - Request (re)generation of document assets; accepts optional `force` query flag.
|
- POST /api/documents/:id/assets - Request (re)generation of document assets; accepts optional `force` query flag.
|
||||||
- GET /api/documents/:id/assets/:asset_id - Fetch metadata and a pre-signed URL for a specific asset.
|
- GET /api/assets/:asset_id - Fetch asset metadata plus a presigned URL for a range of objects (query params: `start` and `limit`, defaulting to the first object).
|
||||||
|
|
||||||
Downloads
|
Downloads
|
||||||
---------
|
---------
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ class AssetManager {
|
|||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
ensureAsset(documentId, asset, { force = false } = {}) {
|
ensureAsset(documentId, asset, { force = false, start = null, limit = null } = {}) {
|
||||||
if (!documentId || !asset?.id) {
|
if (!documentId || !asset?.id) {
|
||||||
return Promise.resolve(asset || null);
|
return Promise.resolve(asset || null);
|
||||||
}
|
}
|
||||||
@@ -189,7 +189,7 @@ class AssetManager {
|
|||||||
return Promise.resolve({ ...asset, ...cached });
|
return Promise.resolve({ ...asset, ...cached });
|
||||||
}
|
}
|
||||||
|
|
||||||
const inflightKey = `${documentId}:${asset.id}`;
|
const inflightKey = `${documentId}:${asset.id}:${start ?? 'd'}:${limit ?? 'd'}`;
|
||||||
if (!force && this.assetInflight.has(inflightKey)) {
|
if (!force && this.assetInflight.has(inflightKey)) {
|
||||||
return this.assetInflight.get(inflightKey);
|
return this.assetInflight.get(inflightKey);
|
||||||
}
|
}
|
||||||
@@ -198,13 +198,31 @@ class AssetManager {
|
|||||||
return Promise.reject(new Error('AssetManager API client is not configured.'));
|
return Promise.reject(new Error('AssetManager API client is not configured.'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const params = {};
|
||||||
|
if (Number.isInteger(start) && start > 0) {
|
||||||
|
params.start = start;
|
||||||
|
}
|
||||||
|
if (Number.isInteger(limit) && limit > 0) {
|
||||||
|
params.limit = limit;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestConfig = Object.keys(params).length ? { params } : undefined;
|
||||||
|
|
||||||
const request = this.api
|
const request = this.api
|
||||||
.get(`/documents/${documentId}/assets/${asset.id}`)
|
.get(`/assets/${asset.id}`, requestConfig)
|
||||||
.then(({ data }) => {
|
.then(({ data }) => {
|
||||||
|
const objects = Array.isArray(data.objects) ? data.objects : [];
|
||||||
|
const primaryObject = objects[0] || null;
|
||||||
|
const expiresAt = typeof primaryObject?.expires_at === 'number'
|
||||||
|
? primaryObject.expires_at
|
||||||
|
: Date.now() + this.assetPresignTtlMs;
|
||||||
|
|
||||||
const entry = {
|
const entry = {
|
||||||
...asset,
|
...asset,
|
||||||
...data,
|
...data,
|
||||||
expiresAt: Date.now() + this.assetPresignTtlMs,
|
objects,
|
||||||
|
url: primaryObject?.url || null,
|
||||||
|
expiresAt,
|
||||||
};
|
};
|
||||||
this.rememberAsset(entry);
|
this.rememberAsset(entry);
|
||||||
return entry;
|
return entry;
|
||||||
|
|||||||
@@ -446,8 +446,10 @@ const DetailPanel = ({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const asset = getDocumentAsset(doc, 'preview');
|
const asset = getDocumentAsset(doc, 'preview');
|
||||||
const width = Number(asset?.metadata?.width) || 0;
|
const primaryObject = asset?.objects?.[0] || null;
|
||||||
const height = Number(asset?.metadata?.height) || 0;
|
const primaryMetadata = primaryObject?.metadata || asset?.metadata || {};
|
||||||
|
const width = Number(primaryMetadata?.width) || 0;
|
||||||
|
const height = Number(primaryMetadata?.height) || 0;
|
||||||
const orientation = width > 0 && height > 0 ? (width >= height ? 'landscape' : 'portrait') : 'landscape';
|
const orientation = width > 0 && height > 0 ? (width >= height ? 'landscape' : 'portrait') : 'landscape';
|
||||||
return {
|
return {
|
||||||
id: doc.id,
|
id: doc.id,
|
||||||
|
|||||||
@@ -22,8 +22,10 @@ const DocumentThumbnailImage = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const resolvedMaxSize = Math.max(1, Math.round(maxSize || 1));
|
const resolvedMaxSize = Math.max(1, Math.round(maxSize || 1));
|
||||||
const thumbnailAsset = useMemo(() => getAssetFromVersion(document?.current_version, 'thumbnail'), [document?.current_version]);
|
const thumbnailAsset = useMemo(() => getAssetFromVersion(document?.current_version, 'thumbnail'), [document?.current_version]);
|
||||||
const assetWidth = Number(thumbnailAsset?.metadata?.width);
|
const primaryObject = thumbnailAsset?.objects?.[0] || null;
|
||||||
const assetHeight = Number(thumbnailAsset?.metadata?.height);
|
const primaryMetadata = primaryObject?.metadata || thumbnailAsset?.metadata || {};
|
||||||
|
const assetWidth = Number(primaryMetadata?.width);
|
||||||
|
const assetHeight = Number(primaryMetadata?.height);
|
||||||
|
|
||||||
const dimensions = useMemo(() => {
|
const dimensions = useMemo(() => {
|
||||||
if (!Number.isFinite(assetWidth) || assetWidth <= 0 || !Number.isFinite(assetHeight) || assetHeight <= 0) {
|
if (!Number.isFinite(assetWidth) || assetWidth <= 0 || !Number.isFinite(assetHeight) || assetHeight <= 0) {
|
||||||
|
|||||||
@@ -413,11 +413,13 @@ const AppLayout = () => {
|
|||||||
|
|
||||||
const isAssetEquivalent = (lhs, rhs) => {
|
const isAssetEquivalent = (lhs, rhs) => {
|
||||||
if (!lhs || !rhs) return false;
|
if (!lhs || !rhs) return false;
|
||||||
|
const lhsPrimaryMetadata = lhs?.objects?.[0]?.metadata || lhs?.metadata;
|
||||||
|
const rhsPrimaryMetadata = rhs?.objects?.[0]?.metadata || rhs?.metadata;
|
||||||
return (
|
return (
|
||||||
lhs.id === rhs.id &&
|
lhs.id === rhs.id &&
|
||||||
lhs.url === rhs.url &&
|
lhs.url === rhs.url &&
|
||||||
lhs?.metadata?.width === rhs?.metadata?.width &&
|
lhsPrimaryMetadata?.width === rhsPrimaryMetadata?.width &&
|
||||||
lhs?.metadata?.height === rhs?.metadata?.height &&
|
lhsPrimaryMetadata?.height === rhsPrimaryMetadata?.height &&
|
||||||
lhs.mime_type === rhs.mime_type &&
|
lhs.mime_type === rhs.mime_type &&
|
||||||
lhs.asset_type === rhs.asset_type &&
|
lhs.asset_type === rhs.asset_type &&
|
||||||
lhs.created_at === rhs.created_at
|
lhs.created_at === rhs.created_at
|
||||||
|
|||||||
@@ -502,8 +502,10 @@ const SkeuomorphicWorkspace = ({
|
|||||||
(doc) => {
|
(doc) => {
|
||||||
if (!doc) return null;
|
if (!doc) return null;
|
||||||
const asset = resolvePreviewAsset(doc);
|
const asset = resolvePreviewAsset(doc);
|
||||||
const width = asset?.metadata?.width;
|
const primaryObject = asset?.objects?.[0] || null;
|
||||||
const height = asset?.metadata?.height;
|
const primaryMetadata = primaryObject?.metadata || asset?.metadata || {};
|
||||||
|
const width = primaryMetadata?.width;
|
||||||
|
const height = primaryMetadata?.height;
|
||||||
if (typeof width === 'number' && typeof height === 'number') {
|
if (typeof width === 'number' && typeof height === 'number') {
|
||||||
return { width, height };
|
return { width, height };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -751,7 +751,6 @@ button.icon-button.ghost:hover:not([disabled]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.column + .column {
|
.column + .column {
|
||||||
border-left: none;
|
|
||||||
padding-left: 1.25rem;
|
padding-left: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -847,7 +846,6 @@ button.icon-button.ghost:hover:not([disabled]) {
|
|||||||
color: inherit;
|
color: inherit;
|
||||||
transition: background 0.12s ease, color 0.12s ease;
|
transition: background 0.12s ease, color 0.12s ease;
|
||||||
position: relative;
|
position: relative;
|
||||||
display: inline-block;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.folder-row span.name {
|
.folder-row span.name {
|
||||||
@@ -1511,6 +1509,7 @@ button.icon-button.ghost:hover:not([disabled]) {
|
|||||||
position: relative;
|
position: relative;
|
||||||
padding: 1.25rem;
|
padding: 1.25rem;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-panel .column-body {
|
.detail-panel .column-body {
|
||||||
|
|||||||
Reference in New Issue
Block a user