3 Commits
Author SHA1 Message Date
nils 1859078cf4 backend: generate thumbnails and previews for all pages
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 15m27s
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 15m30s
2025-10-20 16:54:56 +02:00
nils c44ba91ec7 backend: split assets into objects 2025-10-20 16:28:57 +02:00
nils 96a6d0ee5d backend: reduce assets detail in DocumentVersionResponse 2025-10-20 14:26:11 +02:00
10 changed files with 421 additions and 216 deletions
+137 -64
View File
@@ -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()
.storage .timestamp_millis()
.presign_get_object(&s3_key, Duration::from_secs(PRESIGNED_URL_EXPIRY_SECONDS)) .checked_add((PRESIGNED_URL_EXPIRY_SECONDS as i64) * 1000)
.await .ok_or_else(|| AppError::internal("failed to compute expiry timestamp"))?;
.map_err(|err| AppError::internal(format!("failed to generate asset URL: {err}")))?;
Ok(Json(to_asset_response( let mut object_responses = Vec::with_capacity(objects.len());
asset, for object in objects {
Some(object_metadata), let url = state
Some(presigned_url), .storage
))) .presign_get_object(
&object.s3_key,
Duration::from_secs(PRESIGNED_URL_EXPIRY_SECONDS),
)
.await
.map_err(|err| AppError::internal(format!("failed to generate asset URL: {err}")))?;
object_responses.push(to_asset_object_response(
object,
Some(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())
} }
+3 -1
View File
@@ -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()
+241 -136
View File
@@ -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
); );
if let Err(err) = state let thumbnail_asset_id = Uuid::new_v4();
.storage let thumbnail_base = format!(
.put_object( "documents/{}/v{}/assets/{}/{}",
&preview_s3_key, initial.document.id,
generation.preview.image_bytes.clone(), initial.version.version_number,
Some("image/png".into()), THUMBNAIL_ASSET_TYPE,
None, thumbnail_asset_id
) );
.await
{ let mut preview_objects: Vec<AssetObjectPersistence> =
warn!(job_id = %job.id, error = %err, "failed to upload preview; retrying"); Vec::with_capacity(generation.preview.objects.len());
return JobExecution::Retry { for (index, image) in generation.preview.objects.iter().enumerate() {
delay: Duration::from_secs(30), if index + 1 > i32::MAX as usize {
error: err.to_string(), 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
.storage
.put_object(
&s3_key,
image.image_bytes.clone(),
Some("image/png".into()),
None,
)
.await
{
warn!(job_id = %job.id, error = %err, ordinal, "failed to upload preview; retrying");
return JobExecution::Retry {
delay: Duration::from_secs(30),
error: err.to_string(),
};
}
preview_objects.push(AssetObjectPersistence {
ordinal,
s3_key,
width: image.width,
height: image.height,
});
} }
if let Err(err) = state let mut thumbnail_objects: Vec<AssetObjectPersistence> =
.storage Vec::with_capacity(generation.thumbnail.objects.len());
.put_object( for (index, image) in generation.thumbnail.objects.iter().enumerate() {
&thumbnail_s3_key, if index + 1 > i32::MAX as usize {
generation.thumbnail.image_bytes.clone(), return JobExecution::Failed {
Some("image/png".into()), error: "too many thumbnail objects".to_string(),
None, };
) }
.await let ordinal = (index + 1) as i32;
{ let s3_key = format!("{thumbnail_base}/{ordinal}");
warn!(job_id = %job.id, error = %err, "failed to upload thumbnail; retrying");
return JobExecution::Retry { if let Err(err) = state
delay: Duration::from_secs(30), .storage
error: err.to_string(), .put_object(
}; &s3_key,
image.image_bytes.clone(),
Some("image/png".into()),
None,
)
.await
{
warn!(job_id = %job.id, error = %err, ordinal, "failed to upload thumbnail; retrying");
return JobExecution::Retry {
delay: Duration::from_secs(30),
error: err.to_string(),
};
}
thumbnail_objects.push(AssetObjectPersistence {
ordinal,
s3_key,
width: image.width,
height: image.height,
});
} }
let asset_persistences = vec![
AssetPersistence {
asset_type: PREVIEW_ASSET_TYPE,
asset_id: preview_asset_id,
objects: preview_objects,
},
AssetPersistence {
asset_type: THUMBNAIL_ASSET_TYPE,
asset_id: thumbnail_asset_id,
objects: thumbnail_objects,
},
];
let state_clone = state.clone(); let state_clone = state.clone();
match task::spawn_blocking(move || { match task::spawn_blocking(move || {
persist_assets_metadata( persist_assets_metadata(state_clone, &initial, &asset_persistences)
state_clone,
&initial,
&[
AssetPersistence {
asset_type: PREVIEW_ASSET_TYPE,
asset_id: preview_asset_id,
s3_key: &preview_s3_key,
generated: &generation.preview,
},
AssetPersistence {
asset_type: THUMBNAIL_ASSET_TYPE,
asset_id: thumbnail_asset_id,
s3_key: &thumbnail_s3_key,
generated: &generation.thumbnail,
},
],
)
}) })
.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 bitmap = page let mut preview_objects: Vec<GeneratedImage> = Vec::with_capacity(total_pages);
.render_with_config(&render_config) let mut thumbnail_objects: Vec<GeneratedImage> = Vec::with_capacity(total_pages);
.map_err(|err| format!("render pdf page: {err}"))?;
let preview_buffer = bitmap.as_image().to_rgb8(); for page_index in 0..total_pages {
let preview_image = image::DynamicImage::ImageRgb8(preview_buffer); 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 thumbnail_image = let bitmap = page
if preview_image.width() > THUMBNAIL_WIDTH || preview_image.height() > THUMBNAIL_HEIGHT { .render_with_config(&render_config)
.map_err(|err| format!("render pdf page {page_index}: {err}"))?;
let preview_buffer = bitmap.as_image().to_rgb8();
let preview_image = image::DynamicImage::ImageRgb8(preview_buffer);
let thumbnail_image = if preview_image.width() > THUMBNAIL_WIDTH
|| 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,47 +625,37 @@ 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();
if let Some(width) = object.width {
metadata_map.insert("width".to_string(), Value::from(width));
}
if let Some(height) = object.height {
metadata_map.insert("height".to_string(), Value::from(height));
}
let mut metadata_map = Map::new(); let object_metadata = Value::Object(metadata_map);
if let Some(width) = asset.generated.width {
metadata_map.insert("width".to_string(), Value::from(width)); let new_object = NewDocumentAssetObject {
id: Uuid::new_v4(),
asset_id: asset.asset_id,
ordinal: object.ordinal,
s3_key: object.s3_key.clone(),
metadata: object_metadata,
};
diesel::insert_into(document_asset_objects::table)
.values(&new_object)
.execute(&mut conn)
.map_err(|err| format!("{err:?}"))?;
} }
if let Some(height) = asset.generated.height {
metadata_map.insert("height".to_string(), Value::from(height));
}
let object_metadata = Value::Object(metadata_map);
let new_object = NewDocumentAssetObject {
id: object_id,
asset_id: asset.asset_id,
ordinal: 1,
s3_key: asset.s3_key.to_string(),
metadata: object_metadata,
};
diesel::insert_into(document_asset_objects::table)
.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)
.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
View File
@@ -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
--------- ---------
+22 -4
View File
@@ -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;
+4 -2
View File
@@ -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,
+4 -2
View File
@@ -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) {
+4 -2
View File
@@ -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
+4 -2
View File
@@ -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 };
} }
+1 -2
View File
@@ -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 {