diff --git a/backend/src/routes/documents.rs b/backend/src/routes/documents.rs index 986f6a4..e084178 100644 --- a/backend/src/routes/documents.rs +++ b/backend/src/routes/documents.rs @@ -118,12 +118,33 @@ pub struct DocumentAssetResponse { pub id: Uuid, pub asset_type: String, pub mime_type: String, + pub metadata: Value, #[serde(skip_serializing_if = "Option::is_none")] - pub metadata: Option, + pub cardinality: Option, +} + +#[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, #[serde(skip_serializing_if = "Option::is_none")] - pub created_at: Option, + pub expires_at: Option, +} + +#[derive(Serialize)] +pub struct DocumentAssetDetailResponse { + pub id: Uuid, + pub asset_type: String, + pub mime_type: String, + pub metadata: Value, + pub created_at: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub cardinality: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub objects: Vec, } #[derive(Serialize, Clone)] @@ -339,6 +360,14 @@ pub struct AssignTagsRequest { pub tag_ids: Vec, } +#[derive(Deserialize, Default)] +pub struct AssetObjectsQuery { + #[serde(default)] + pub start: Option, + #[serde(default)] + pub limit: Option, +} + pub async fn list_documents( State(state): State, Query(params): Query, @@ -852,46 +881,70 @@ pub async fn list_document_assets( pub async fn get_document_asset( State(state): State, - Path((document_id, asset_id)): Path<(Uuid, Uuid)>, -) -> AppResult> { + Path(asset_id): Path, + Query(query): Query, +) -> AppResult> { let mut conn = state.db()?; - let document: Document = documents::table.find(document_id).first(&mut conn)?; - if document.deleted_at.is_some() { - return Err(AppError::not_found()); + + let asset: DocumentAsset = match document_assets::table + .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 - .inner_join( - document_asset_objects::table.on(document_asset_objects::asset_id - .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)?; + let end = start + .checked_add(limit - 1) + .ok_or_else(|| AppError::bad_request("requested range is too large"))?; - if version.document_id != document_id { - return Err(AppError::not_found()); - } + let objects: Vec = document_asset_objects::table + .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); - let presigned_url = state - .storage - .presign_get_object(&s3_key, Duration::from_secs(PRESIGNED_URL_EXPIRY_SECONDS)) - .await - .map_err(|err| AppError::internal(format!("failed to generate asset URL: {err}")))?; + 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"))?; - Ok(Json(to_asset_response( - asset, - Some(object_metadata), - Some(presigned_url), - AssetResponseScope::Detailed, - ))) + let mut object_responses = Vec::with_capacity(objects.len()); + for object in objects { + let url = state + .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( @@ -1819,14 +1872,9 @@ pub(crate) async fn load_primary_assets( .load(&mut conn)?; let mut assets_by_version: HashMap> = HashMap::new(); - for (asset, object) in assets { + for (asset, _object) in assets { let version_id = asset.document_version_id; - let response = to_asset_response( - asset, - object.map(|o| o.metadata), - None, - AssetResponseScope::Summary, - ); + let response = to_asset_summary(asset); assets_by_version .entry(version_id) .or_default() @@ -1916,52 +1964,42 @@ fn to_version_response( } } -fn merge_metadata(base: &Value, overlay: Option<&Value>) -> Value { - 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_summary(asset: DocumentAsset) -> DocumentAssetResponse { + DocumentAssetResponse { + id: asset.id, + asset_type: asset.asset_type, + mime_type: asset.mime_type, + metadata: asset.metadata, + cardinality: asset.cardinality, } } -#[derive(Clone, Copy)] -enum AssetResponseScope { - Summary, - Detailed, +fn to_asset_detail_response( + asset: DocumentAsset, + objects: Vec, +) -> 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), + cardinality: asset.cardinality, + objects, + } } -fn to_asset_response( - asset: DocumentAsset, - object_metadata: Option, +fn to_asset_object_response( + object: DocumentAssetObject, url: Option, - scope: AssetResponseScope, -) -> DocumentAssetResponse { - match scope { - AssetResponseScope::Summary => DocumentAssetResponse { - id: asset.id, - asset_type: asset.asset_type, - mime_type: asset.mime_type, - metadata: None, - url: None, - created_at: None, - }, - AssetResponseScope::Detailed => { - let metadata = merge_metadata(&asset.metadata, object_metadata.as_ref()); - DocumentAssetResponse { - id: asset.id, - asset_type: asset.asset_type, - mime_type: asset.mime_type, - metadata: Some(metadata), - url, - created_at: Some(to_iso(asset.created_at)), - } - } + expires_at: Option, +) -> DocumentAssetObjectResponse { + DocumentAssetObjectResponse { + id: object.id, + ordinal: object.ordinal, + metadata: object.metadata, + url, + expires_at, } } @@ -2023,14 +2061,7 @@ async fn load_asset_responses( Ok(assets .into_iter() - .map(|(asset, object)| { - to_asset_response( - asset, - object.map(|o| o.metadata), - None, - AssetResponseScope::Detailed, - ) - }) + .map(|(asset, _object)| to_asset_summary(asset)) .collect()) } diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index bde1038..d1bcf57 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -75,7 +75,6 @@ pub fn create_router(state: AppState) -> Router<()> { .patch(documents::update_document), ) .route("/:id/download", get(documents::download_document)) - .route("/:id/assets/:asset_id", get(documents::get_document_asset)) .route( "/:id/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 assets_routes = Router::new().route("/:asset_id", get(documents::get_document_asset)); + let protected_routes = Router::new() .nest("/api/documents", documents_routes) .nest("/api/folders", folders_routes) .nest("/api/tags", tags_routes) .nest("/api/correspondents", correspondents_routes) + .nest("/api/assets", assets_routes) .layer(middleware::from_extractor_with_state::(protected_state)); Router::new() diff --git a/docs/api.txt b/docs/api.txt index 0e8a690..563e2cf 100644 --- a/docs/api.txt +++ b/docs/api.txt @@ -37,7 +37,7 @@ Document Assets --------------- - 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. -- 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 --------- diff --git a/frontend/src/asset_manager.js b/frontend/src/asset_manager.js index eeb7f33..89bc4bb 100644 --- a/frontend/src/asset_manager.js +++ b/frontend/src/asset_manager.js @@ -172,7 +172,7 @@ class AssetManager { return next; } - ensureAsset(documentId, asset, { force = false } = {}) { + ensureAsset(documentId, asset, { force = false, start = null, limit = null } = {}) { if (!documentId || !asset?.id) { return Promise.resolve(asset || null); } @@ -189,7 +189,7 @@ class AssetManager { 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)) { return this.assetInflight.get(inflightKey); } @@ -198,13 +198,31 @@ class AssetManager { 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 - .get(`/documents/${documentId}/assets/${asset.id}`) + .get(`/assets/${asset.id}`, requestConfig) .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 = { ...asset, ...data, - expiresAt: Date.now() + this.assetPresignTtlMs, + objects, + url: primaryObject?.url || null, + expiresAt, }; this.rememberAsset(entry); return entry; diff --git a/frontend/src/detail/DetailPanel.jsx b/frontend/src/detail/DetailPanel.jsx index 5c54398..4698c38 100644 --- a/frontend/src/detail/DetailPanel.jsx +++ b/frontend/src/detail/DetailPanel.jsx @@ -446,8 +446,10 @@ const DetailPanel = ({ return null; } const asset = getDocumentAsset(doc, 'preview'); - const width = Number(asset?.metadata?.width) || 0; - const height = Number(asset?.metadata?.height) || 0; + const primaryObject = asset?.objects?.[0] || null; + 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'; return { id: doc.id, diff --git a/frontend/src/documents/DocumentsTable.jsx b/frontend/src/documents/DocumentsTable.jsx index 10e8f3b..629a03f 100644 --- a/frontend/src/documents/DocumentsTable.jsx +++ b/frontend/src/documents/DocumentsTable.jsx @@ -22,8 +22,10 @@ const DocumentThumbnailImage = ({ }) => { const resolvedMaxSize = Math.max(1, Math.round(maxSize || 1)); const thumbnailAsset = useMemo(() => getAssetFromVersion(document?.current_version, 'thumbnail'), [document?.current_version]); - const assetWidth = Number(thumbnailAsset?.metadata?.width); - const assetHeight = Number(thumbnailAsset?.metadata?.height); + const primaryObject = thumbnailAsset?.objects?.[0] || null; + const primaryMetadata = primaryObject?.metadata || thumbnailAsset?.metadata || {}; + const assetWidth = Number(primaryMetadata?.width); + const assetHeight = Number(primaryMetadata?.height); const dimensions = useMemo(() => { if (!Number.isFinite(assetWidth) || assetWidth <= 0 || !Number.isFinite(assetHeight) || assetHeight <= 0) { diff --git a/frontend/src/index.jsx b/frontend/src/index.jsx index e90fbce..3917037 100644 --- a/frontend/src/index.jsx +++ b/frontend/src/index.jsx @@ -413,11 +413,13 @@ const AppLayout = () => { const isAssetEquivalent = (lhs, rhs) => { if (!lhs || !rhs) return false; + const lhsPrimaryMetadata = lhs?.objects?.[0]?.metadata || lhs?.metadata; + const rhsPrimaryMetadata = rhs?.objects?.[0]?.metadata || rhs?.metadata; return ( lhs.id === rhs.id && lhs.url === rhs.url && - lhs?.metadata?.width === rhs?.metadata?.width && - lhs?.metadata?.height === rhs?.metadata?.height && + lhsPrimaryMetadata?.width === rhsPrimaryMetadata?.width && + lhsPrimaryMetadata?.height === rhsPrimaryMetadata?.height && lhs.mime_type === rhs.mime_type && lhs.asset_type === rhs.asset_type && lhs.created_at === rhs.created_at diff --git a/frontend/src/skeuomorphic_ws.jsx b/frontend/src/skeuomorphic_ws.jsx index bba8790..2a91643 100644 --- a/frontend/src/skeuomorphic_ws.jsx +++ b/frontend/src/skeuomorphic_ws.jsx @@ -502,8 +502,10 @@ const SkeuomorphicWorkspace = ({ (doc) => { if (!doc) return null; const asset = resolvePreviewAsset(doc); - const width = asset?.metadata?.width; - const height = asset?.metadata?.height; + const primaryObject = asset?.objects?.[0] || null; + const primaryMetadata = primaryObject?.metadata || asset?.metadata || {}; + const width = primaryMetadata?.width; + const height = primaryMetadata?.height; if (typeof width === 'number' && typeof height === 'number') { return { width, height }; } diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 1b731cb..eb1d8f1 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -751,7 +751,6 @@ button.icon-button.ghost:hover:not([disabled]) { } .column + .column { - border-left: none; padding-left: 1.25rem; } @@ -847,7 +846,6 @@ button.icon-button.ghost:hover:not([disabled]) { color: inherit; transition: background 0.12s ease, color 0.12s ease; position: relative; - display: inline-block; } .folder-row span.name { @@ -1511,6 +1509,7 @@ button.icon-button.ghost:hover:not([disabled]) { position: relative; padding: 1.25rem; overflow-y: auto; + border-left: 1px solid var(--border); } .detail-panel .column-body {