Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c17a9484ca | ||
|
|
9060905e7a | ||
|
|
159577dff9 | ||
|
|
86c75cea8e | ||
|
|
3f0c607383 |
@@ -0,0 +1,13 @@
|
|||||||
|
-- Restore width/height columns and repopulate from metadata where available.
|
||||||
|
|
||||||
|
ALTER TABLE document_assets
|
||||||
|
ADD COLUMN width INTEGER,
|
||||||
|
ADD COLUMN height INTEGER;
|
||||||
|
|
||||||
|
UPDATE document_assets
|
||||||
|
SET width = (metadata->>'width')::INTEGER
|
||||||
|
WHERE metadata ? 'width';
|
||||||
|
|
||||||
|
UPDATE document_assets
|
||||||
|
SET height = (metadata->>'height')::INTEGER
|
||||||
|
WHERE metadata ? 'height';
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- Backfill existing width/height values into metadata then drop the columns.
|
||||||
|
|
||||||
|
UPDATE document_assets
|
||||||
|
SET metadata = metadata || jsonb_build_object('width', width)
|
||||||
|
WHERE width IS NOT NULL
|
||||||
|
AND NOT (metadata ? 'width');
|
||||||
|
|
||||||
|
UPDATE document_assets
|
||||||
|
SET metadata = metadata || jsonb_build_object('height', height)
|
||||||
|
WHERE height IS NOT NULL
|
||||||
|
AND NOT (metadata ? 'height');
|
||||||
|
|
||||||
|
ALTER TABLE document_assets
|
||||||
|
DROP COLUMN width,
|
||||||
|
DROP COLUMN height;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
ALTER TABLE documents ADD COLUMN current_version INT4;
|
||||||
|
|
||||||
|
UPDATE documents AS d
|
||||||
|
SET current_version = dv.version_number
|
||||||
|
FROM document_versions AS dv
|
||||||
|
WHERE dv.id = d.current_version_id;
|
||||||
|
|
||||||
|
ALTER TABLE documents
|
||||||
|
ALTER COLUMN current_version SET NOT NULL;
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS idx_documents_current_version_id;
|
||||||
|
|
||||||
|
ALTER TABLE documents
|
||||||
|
DROP CONSTRAINT IF EXISTS documents_current_version_fk;
|
||||||
|
|
||||||
|
ALTER TABLE documents
|
||||||
|
DROP COLUMN current_version_id;
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
ALTER TABLE documents ADD COLUMN current_version_id UUID;
|
||||||
|
|
||||||
|
UPDATE documents AS d
|
||||||
|
SET current_version_id = dv.id
|
||||||
|
FROM document_versions AS dv
|
||||||
|
WHERE dv.document_id = d.id
|
||||||
|
AND dv.version_number = d.current_version;
|
||||||
|
|
||||||
|
ALTER TABLE documents
|
||||||
|
ALTER COLUMN current_version_id SET NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE documents
|
||||||
|
ADD CONSTRAINT documents_current_version_fk
|
||||||
|
FOREIGN KEY (current_version_id)
|
||||||
|
REFERENCES document_versions(id)
|
||||||
|
DEFERRABLE INITIALLY DEFERRED;
|
||||||
|
|
||||||
|
CREATE INDEX idx_documents_current_version_id
|
||||||
|
ON documents(current_version_id);
|
||||||
|
|
||||||
|
ALTER TABLE documents DROP COLUMN current_version;
|
||||||
@@ -53,7 +53,7 @@ pub struct Document {
|
|||||||
pub original_name: String,
|
pub original_name: String,
|
||||||
pub content_type: Option<String>,
|
pub content_type: Option<String>,
|
||||||
pub folder_id: Option<Uuid>,
|
pub folder_id: Option<Uuid>,
|
||||||
pub current_version: i32,
|
pub current_version_id: Uuid,
|
||||||
pub uploaded_at: NaiveDateTime,
|
pub uploaded_at: NaiveDateTime,
|
||||||
pub updated_at: NaiveDateTime,
|
pub updated_at: NaiveDateTime,
|
||||||
pub deleted_at: Option<NaiveDateTime>,
|
pub deleted_at: Option<NaiveDateTime>,
|
||||||
@@ -70,7 +70,7 @@ pub struct NewDocument {
|
|||||||
pub original_name: String,
|
pub original_name: String,
|
||||||
pub content_type: Option<String>,
|
pub content_type: Option<String>,
|
||||||
pub folder_id: Option<Uuid>,
|
pub folder_id: Option<Uuid>,
|
||||||
pub current_version: i32,
|
pub current_version_id: Uuid,
|
||||||
pub metadata: serde_json::Value,
|
pub metadata: serde_json::Value,
|
||||||
pub issued_at: Option<NaiveDateTime>,
|
pub issued_at: Option<NaiveDateTime>,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
@@ -111,8 +111,6 @@ pub struct DocumentAsset {
|
|||||||
pub asset_type: String,
|
pub asset_type: String,
|
||||||
pub s3_key: String,
|
pub s3_key: String,
|
||||||
pub mime_type: String,
|
pub mime_type: String,
|
||||||
pub width: Option<i32>,
|
|
||||||
pub height: Option<i32>,
|
|
||||||
pub metadata: serde_json::Value,
|
pub metadata: serde_json::Value,
|
||||||
pub created_at: NaiveDateTime,
|
pub created_at: NaiveDateTime,
|
||||||
}
|
}
|
||||||
@@ -125,8 +123,6 @@ pub struct NewDocumentAsset {
|
|||||||
pub asset_type: String,
|
pub asset_type: String,
|
||||||
pub s3_key: String,
|
pub s3_key: String,
|
||||||
pub mime_type: String,
|
pub mime_type: String,
|
||||||
pub width: Option<i32>,
|
|
||||||
pub height: Option<i32>,
|
|
||||||
pub metadata: serde_json::Value,
|
pub metadata: serde_json::Value,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+143
-143
@@ -77,25 +77,6 @@ impl From<Tag> for TagResponse {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
pub struct DocumentResponse {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub filename: String,
|
|
||||||
pub title: String,
|
|
||||||
pub original_name: String,
|
|
||||||
pub content_type: Option<String>,
|
|
||||||
pub folder_id: Option<Uuid>,
|
|
||||||
pub current_version: i32,
|
|
||||||
pub uploaded_at: String,
|
|
||||||
pub updated_at: String,
|
|
||||||
pub deleted_at: Option<String>,
|
|
||||||
pub issued_at: Option<String>,
|
|
||||||
pub metadata: Value,
|
|
||||||
pub tags: Vec<TagResponse>,
|
|
||||||
pub thumbnail: Option<DocumentAssetResponse>,
|
|
||||||
pub download_path: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Clone)]
|
#[derive(Serialize, Clone)]
|
||||||
pub struct DocumentVersionResponse {
|
pub struct DocumentVersionResponse {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
@@ -112,17 +93,41 @@ pub struct DocumentAssetResponse {
|
|||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub asset_type: String,
|
pub asset_type: String,
|
||||||
pub mime_type: String,
|
pub mime_type: String,
|
||||||
pub width: Option<i32>,
|
pub metadata: Value,
|
||||||
pub height: Option<i32>,
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub url: String,
|
pub url: Option<String>,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Clone)]
|
||||||
|
pub struct DocumentCurrentVersionResponse {
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub version: DocumentVersionResponse,
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub assets: Vec<DocumentAssetResponse>,
|
||||||
|
pub download_path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct DocumentResponse {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub filename: String,
|
||||||
|
pub title: String,
|
||||||
|
pub original_name: String,
|
||||||
|
pub content_type: Option<String>,
|
||||||
|
pub folder_id: Option<Uuid>,
|
||||||
|
pub uploaded_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
pub deleted_at: Option<String>,
|
||||||
|
pub issued_at: Option<String>,
|
||||||
|
pub metadata: Value,
|
||||||
|
pub tags: Vec<TagResponse>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub current_version: Option<DocumentCurrentVersionResponse>,
|
||||||
|
}
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct DocumentDetailResponse {
|
pub struct DocumentDetailResponse {
|
||||||
pub document: DocumentResponse,
|
pub document: DocumentResponse,
|
||||||
pub current_version: DocumentVersionResponse,
|
|
||||||
pub assets: Vec<DocumentAssetResponse>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@@ -239,18 +244,18 @@ pub async fn list_documents(
|
|||||||
let tags_map = load_tags_for_documents(&mut conn, &doc_ids)?;
|
let tags_map = load_tags_for_documents(&mut conn, &doc_ids)?;
|
||||||
drop(conn);
|
drop(conn);
|
||||||
|
|
||||||
let thumbnails = load_primary_thumbnails(&state, &docs).await?;
|
let primary_versions = load_primary_assets(&state, &docs).await?;
|
||||||
|
|
||||||
let mut response = Vec::with_capacity(doc_ids.len());
|
let mut response = Vec::with_capacity(doc_ids.len());
|
||||||
for doc in docs {
|
for doc in docs {
|
||||||
let tags = tags_map.get(&doc.id).cloned();
|
let tags = tags_map.get(&doc.id).cloned();
|
||||||
let thumbnail = thumbnails.get(&doc.id).cloned();
|
let current_version = primary_versions.get(&doc.id).cloned();
|
||||||
response.push(to_document_response(
|
response.push(to_document_response(
|
||||||
&state,
|
&state,
|
||||||
user.user_id,
|
user.user_id,
|
||||||
doc,
|
doc,
|
||||||
tags,
|
tags,
|
||||||
thumbnail,
|
current_version,
|
||||||
)?);
|
)?);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,8 +275,7 @@ pub async fn get_document(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let current_version: DocumentVersion = document_versions::table
|
let current_version: DocumentVersion = document_versions::table
|
||||||
.filter(document_versions::document_id.eq(document_id))
|
.find(doc.current_version_id)
|
||||||
.filter(document_versions::version_number.eq(doc.current_version))
|
|
||||||
.first(&mut conn)?;
|
.first(&mut conn)?;
|
||||||
|
|
||||||
let tags_map = load_tags_for_documents(&mut conn, &[document_id])?;
|
let tags_map = load_tags_for_documents(&mut conn, &[document_id])?;
|
||||||
@@ -279,10 +283,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 thumbnail = assets
|
let version_response = to_version_response(current_version);
|
||||||
.iter()
|
|
||||||
.find(|asset| asset.asset_type == "thumbnail")
|
|
||||||
.cloned();
|
|
||||||
|
|
||||||
Ok(Json(DocumentDetailResponse {
|
Ok(Json(DocumentDetailResponse {
|
||||||
document: to_document_response(
|
document: to_document_response(
|
||||||
@@ -290,10 +291,8 @@ pub async fn get_document(
|
|||||||
user.user_id,
|
user.user_id,
|
||||||
doc,
|
doc,
|
||||||
tags_map.get(&document_id).cloned(),
|
tags_map.get(&document_id).cloned(),
|
||||||
thumbnail,
|
Some((version_response, assets)),
|
||||||
)?,
|
)?,
|
||||||
current_version: to_version_response(current_version),
|
|
||||||
assets,
|
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,17 +412,12 @@ pub async fn request_document_assets(
|
|||||||
return Err(AppError::not_found());
|
return Err(AppError::not_found());
|
||||||
}
|
}
|
||||||
|
|
||||||
let version: DocumentVersion = document_versions::table
|
|
||||||
.filter(document_versions::document_id.eq(document_id))
|
|
||||||
.filter(document_versions::version_number.eq(document.current_version))
|
|
||||||
.first(&mut conn)?;
|
|
||||||
|
|
||||||
enqueue_job(
|
enqueue_job(
|
||||||
&mut conn,
|
&mut conn,
|
||||||
JOB_ANALYZE_DOCUMENT,
|
JOB_ANALYZE_DOCUMENT,
|
||||||
json!({
|
json!({
|
||||||
"document_id": document_id,
|
"document_id": document_id,
|
||||||
"document_version_id": version.id,
|
"document_version_id": document.current_version_id,
|
||||||
"force": query.force,
|
"force": query.force,
|
||||||
}),
|
}),
|
||||||
None,
|
None,
|
||||||
@@ -439,11 +433,9 @@ pub async fn reanalyze_all_documents(
|
|||||||
) -> AppResult<(StatusCode, Json<BulkReanalyzeResponse>)> {
|
) -> AppResult<(StatusCode, Json<BulkReanalyzeResponse>)> {
|
||||||
let mut conn = state.db()?;
|
let mut conn = state.db()?;
|
||||||
|
|
||||||
let targets: Vec<(Uuid, Uuid)> = document_versions::table
|
let targets: Vec<(Uuid, Uuid)> = documents::table
|
||||||
.inner_join(documents::table.on(document_versions::document_id.eq(documents::id)))
|
|
||||||
.filter(documents::deleted_at.is_null())
|
.filter(documents::deleted_at.is_null())
|
||||||
.filter(document_versions::version_number.eq(documents::current_version))
|
.select((documents::id, documents::current_version_id))
|
||||||
.select((documents::id, document_versions::id))
|
|
||||||
.load(&mut conn)?;
|
.load(&mut conn)?;
|
||||||
|
|
||||||
let mut queued = 0usize;
|
let mut queued = 0usize;
|
||||||
@@ -483,12 +475,10 @@ pub async fn reanalyze_selected_documents(
|
|||||||
|
|
||||||
let mut conn = state.db()?;
|
let mut conn = state.db()?;
|
||||||
|
|
||||||
let targets: Vec<(Uuid, Uuid)> = document_versions::table
|
let targets: Vec<(Uuid, Uuid)> = documents::table
|
||||||
.inner_join(documents::table.on(document_versions::document_id.eq(documents::id)))
|
|
||||||
.filter(documents::id.eq_any(&document_ids))
|
.filter(documents::id.eq_any(&document_ids))
|
||||||
.filter(documents::deleted_at.is_null())
|
.filter(documents::deleted_at.is_null())
|
||||||
.filter(document_versions::version_number.eq(documents::current_version))
|
.select((documents::id, documents::current_version_id))
|
||||||
.select((documents::id, document_versions::id))
|
|
||||||
.load(&mut conn)?;
|
.load(&mut conn)?;
|
||||||
|
|
||||||
if targets.len() != document_ids.len() {
|
if targets.len() != document_ids.len() {
|
||||||
@@ -526,16 +516,44 @@ pub async fn list_document_assets(
|
|||||||
return Err(AppError::not_found());
|
return Err(AppError::not_found());
|
||||||
}
|
}
|
||||||
|
|
||||||
let version: DocumentVersion = document_versions::table
|
let version_id = document.current_version_id;
|
||||||
.filter(document_versions::document_id.eq(document_id))
|
|
||||||
.filter(document_versions::version_number.eq(document.current_version))
|
|
||||||
.first(&mut conn)?;
|
|
||||||
drop(conn);
|
drop(conn);
|
||||||
|
|
||||||
let assets = load_asset_responses(&state, version.id).await?;
|
let assets = load_asset_responses(&state, version_id).await?;
|
||||||
Ok(Json(assets))
|
Ok(Json(assets))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_document_asset(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path((document_id, asset_id)): Path<(Uuid, Uuid)>,
|
||||||
|
) -> AppResult<Json<DocumentAssetResponse>> {
|
||||||
|
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 = document_assets::table.find(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 {
|
||||||
|
return Err(AppError::not_found());
|
||||||
|
}
|
||||||
|
|
||||||
|
let s3_key = asset.s3_key.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}")))?;
|
||||||
|
|
||||||
|
Ok(Json(to_asset_response(asset, Some(presigned_url))))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn download_document(
|
pub async fn download_document(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(document_id): Path<Uuid>,
|
Path(document_id): Path<Uuid>,
|
||||||
@@ -547,8 +565,7 @@ pub async fn download_document(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let version: DocumentVersion = document_versions::table
|
let version: DocumentVersion = document_versions::table
|
||||||
.filter(document_versions::document_id.eq(document_id))
|
.find(doc.current_version_id)
|
||||||
.filter(document_versions::version_number.eq(doc.current_version))
|
|
||||||
.first(&mut conn)?;
|
.first(&mut conn)?;
|
||||||
|
|
||||||
let presigned_url = state
|
let presigned_url = state
|
||||||
@@ -586,8 +603,7 @@ pub async fn download_with_token(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let version: DocumentVersion = document_versions::table
|
let version: DocumentVersion = document_versions::table
|
||||||
.filter(document_versions::document_id.eq(claims.doc_id))
|
.find(doc.current_version_id)
|
||||||
.filter(document_versions::version_number.eq(doc.current_version))
|
|
||||||
.first(&mut conn)?;
|
.first(&mut conn)?;
|
||||||
|
|
||||||
let now = Utc::now().naive_utc();
|
let now = Utc::now().naive_utc();
|
||||||
@@ -669,8 +685,7 @@ pub async fn update_document(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let current_version: DocumentVersion = document_versions::table
|
let current_version: DocumentVersion = document_versions::table
|
||||||
.filter(document_versions::document_id.eq(document_id))
|
.find(document.current_version_id)
|
||||||
.filter(document_versions::version_number.eq(document.current_version))
|
|
||||||
.first(&mut conn)?;
|
.first(&mut conn)?;
|
||||||
|
|
||||||
let tags_map = load_tags_for_documents(&mut conn, &[document_id])?;
|
let tags_map = load_tags_for_documents(&mut conn, &[document_id])?;
|
||||||
@@ -678,10 +693,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 thumbnail = assets
|
let version_response = to_version_response(current_version);
|
||||||
.iter()
|
|
||||||
.find(|asset| asset.asset_type == "thumbnail")
|
|
||||||
.cloned();
|
|
||||||
|
|
||||||
Ok(Json(DocumentDetailResponse {
|
Ok(Json(DocumentDetailResponse {
|
||||||
document: to_document_response(
|
document: to_document_response(
|
||||||
@@ -689,10 +701,8 @@ pub async fn update_document(
|
|||||||
user.user_id,
|
user.user_id,
|
||||||
document,
|
document,
|
||||||
tags_map.get(&document_id).cloned(),
|
tags_map.get(&document_id).cloned(),
|
||||||
thumbnail,
|
Some((version_response, assets)),
|
||||||
)?,
|
)?,
|
||||||
current_version: to_version_response(current_version),
|
|
||||||
assets,
|
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -943,9 +953,8 @@ async fn process_upload(
|
|||||||
|
|
||||||
let existing = documents::table
|
let existing = documents::table
|
||||||
.inner_join(
|
.inner_join(
|
||||||
document_versions::table.on(document_versions::document_id
|
document_versions::table
|
||||||
.eq(documents::id)
|
.on(document_versions::id.eq(documents::current_version_id)),
|
||||||
.and(document_versions::version_number.eq(documents::current_version))),
|
|
||||||
)
|
)
|
||||||
.filter(document_versions::checksum.eq(&checksum_hex))
|
.filter(document_versions::checksum.eq(&checksum_hex))
|
||||||
.select((documents::all_columns, document_versions::all_columns))
|
.select((documents::all_columns, document_versions::all_columns))
|
||||||
@@ -969,10 +978,7 @@ async fn process_upload(
|
|||||||
let tags = tags_map.get(&document.id).cloned();
|
let tags = tags_map.get(&document.id).cloned();
|
||||||
drop(conn);
|
drop(conn);
|
||||||
let assets = load_asset_responses(state, version.id).await?;
|
let assets = load_asset_responses(state, version.id).await?;
|
||||||
let thumbnail = assets
|
let version_response = to_version_response(version.clone());
|
||||||
.iter()
|
|
||||||
.find(|asset| asset.asset_type == "thumbnail")
|
|
||||||
.cloned();
|
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
document_id = %document.id,
|
document_id = %document.id,
|
||||||
@@ -982,9 +988,13 @@ async fn process_upload(
|
|||||||
|
|
||||||
return Ok(UploadOutcome {
|
return Ok(UploadOutcome {
|
||||||
detail: DocumentDetailResponse {
|
detail: DocumentDetailResponse {
|
||||||
document: to_document_response(state, user_id, document, tags, thumbnail)?,
|
document: to_document_response(
|
||||||
current_version: to_version_response(version),
|
state,
|
||||||
assets,
|
user_id,
|
||||||
|
document,
|
||||||
|
tags,
|
||||||
|
Some((version_response, assets)),
|
||||||
|
)?,
|
||||||
},
|
},
|
||||||
created: false,
|
created: false,
|
||||||
});
|
});
|
||||||
@@ -1022,7 +1032,7 @@ async fn process_upload(
|
|||||||
original_name: original_name.clone(),
|
original_name: original_name.clone(),
|
||||||
content_type: content_type.clone(),
|
content_type: content_type.clone(),
|
||||||
folder_id,
|
folder_id,
|
||||||
current_version: version_number,
|
current_version_id: version_id,
|
||||||
issued_at: None,
|
issued_at: None,
|
||||||
title: derive_document_title(&original_name),
|
title: derive_document_title(&original_name),
|
||||||
metadata: metadata_value.clone(),
|
metadata: metadata_value.clone(),
|
||||||
@@ -1053,9 +1063,13 @@ async fn process_upload(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let detail = DocumentDetailResponse {
|
let detail = DocumentDetailResponse {
|
||||||
document: to_document_response(state, user_id, document, None, None)?,
|
document: to_document_response(
|
||||||
current_version: to_version_response(version.clone()),
|
state,
|
||||||
assets: Vec::new(),
|
user_id,
|
||||||
|
document,
|
||||||
|
None,
|
||||||
|
Some((to_version_response(version.clone()), Vec::new())),
|
||||||
|
)?,
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Ok(mut conn) = state.db() {
|
if let Ok(mut conn) = state.db() {
|
||||||
@@ -1112,75 +1126,64 @@ pub(crate) fn load_tags_for_documents(
|
|||||||
Ok(map)
|
Ok(map)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn load_primary_thumbnails(
|
pub(crate) async fn load_primary_assets(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
documents: &[Document],
|
documents: &[Document],
|
||||||
) -> AppResult<HashMap<Uuid, DocumentAssetResponse>> {
|
) -> AppResult<HashMap<Uuid, (DocumentVersionResponse, Vec<DocumentAssetResponse>)>> {
|
||||||
if documents.is_empty() {
|
if documents.is_empty() {
|
||||||
return Ok(HashMap::new());
|
return Ok(HashMap::new());
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut current_versions: HashMap<Uuid, i32> = HashMap::new();
|
let mut doc_to_version: HashMap<Uuid, Uuid> = HashMap::with_capacity(documents.len());
|
||||||
let mut doc_ids = Vec::with_capacity(documents.len());
|
let mut version_ids: Vec<Uuid> = Vec::with_capacity(documents.len());
|
||||||
for doc in documents {
|
for doc in documents {
|
||||||
current_versions.insert(doc.id, doc.current_version);
|
doc_to_version.insert(doc.id, doc.current_version_id);
|
||||||
doc_ids.push(doc.id);
|
version_ids.push(doc.current_version_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
version_ids.sort();
|
||||||
|
version_ids.dedup();
|
||||||
|
|
||||||
let mut conn = state.db()?;
|
let mut conn = state.db()?;
|
||||||
let versions: Vec<DocumentVersion> = document_versions::table
|
let versions: Vec<DocumentVersion> = document_versions::table
|
||||||
.filter(document_versions::document_id.eq_any(&doc_ids))
|
.filter(document_versions::id.eq_any(&version_ids))
|
||||||
.load(&mut conn)?;
|
.load(&mut conn)?;
|
||||||
|
|
||||||
let mut version_id_by_doc: HashMap<Uuid, Uuid> = HashMap::new();
|
let mut version_map: HashMap<Uuid, DocumentVersion> = HashMap::new();
|
||||||
let mut doc_id_by_version: HashMap<Uuid, Uuid> = HashMap::new();
|
|
||||||
for version in versions {
|
for version in versions {
|
||||||
if let Some(current_number) = current_versions.get(&version.document_id) {
|
version_map.insert(version.id, version);
|
||||||
if *current_number == version.version_number {
|
|
||||||
version_id_by_doc.insert(version.document_id, version.id);
|
|
||||||
doc_id_by_version.insert(version.id, version.document_id);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if version_id_by_doc.is_empty() {
|
|
||||||
return Ok(HashMap::new());
|
|
||||||
}
|
|
||||||
|
|
||||||
let version_ids: Vec<Uuid> = version_id_by_doc.values().copied().collect();
|
|
||||||
|
|
||||||
let assets: Vec<DocumentAsset> = document_assets::table
|
let assets: Vec<DocumentAsset> = document_assets::table
|
||||||
.filter(document_assets::document_version_id.eq_any(&version_ids))
|
.filter(document_assets::document_version_id.eq_any(&version_ids))
|
||||||
.filter(document_assets::asset_type.eq("thumbnail"))
|
|
||||||
.order((
|
.order((
|
||||||
document_assets::document_version_id.asc(),
|
document_assets::document_version_id.asc(),
|
||||||
document_assets::created_at.asc(),
|
document_assets::created_at.asc(),
|
||||||
))
|
))
|
||||||
.load(&mut conn)?;
|
.load(&mut conn)?;
|
||||||
|
|
||||||
|
let mut assets_by_version: HashMap<Uuid, Vec<DocumentAssetResponse>> = HashMap::new();
|
||||||
|
for asset in assets {
|
||||||
|
let version_id = asset.document_version_id;
|
||||||
|
let response = to_asset_response(asset, None);
|
||||||
|
assets_by_version
|
||||||
|
.entry(version_id)
|
||||||
|
.or_default()
|
||||||
|
.push(response);
|
||||||
|
}
|
||||||
|
|
||||||
drop(conn);
|
drop(conn);
|
||||||
|
|
||||||
let mut first_assets: HashMap<Uuid, DocumentAsset> = HashMap::new();
|
let mut result: HashMap<Uuid, (DocumentVersionResponse, Vec<DocumentAssetResponse>)> =
|
||||||
for asset in assets {
|
HashMap::with_capacity(doc_to_version.len());
|
||||||
if let Some(doc_id) = doc_id_by_version.get(&asset.document_version_id) {
|
for (doc_id, version_id) in doc_to_version {
|
||||||
first_assets.entry(*doc_id).or_insert(asset);
|
if let Some(version) = version_map.remove(&version_id) {
|
||||||
|
let assets = assets_by_version.remove(&version_id).unwrap_or_default();
|
||||||
|
result.insert(doc_id, (to_version_response(version), assets));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut responses = HashMap::with_capacity(first_assets.len());
|
Ok(result)
|
||||||
for (doc_id, asset) in first_assets {
|
|
||||||
let url = state
|
|
||||||
.storage
|
|
||||||
.presign_get_object(
|
|
||||||
&asset.s3_key,
|
|
||||||
Duration::from_secs(PRESIGNED_URL_EXPIRY_SECONDS),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|err| AppError::internal(format!("failed to sign asset URL: {err}")))?;
|
|
||||||
responses.insert(doc_id, to_asset_response(asset, url));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(responses)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn to_document_response(
|
pub(crate) fn to_document_response(
|
||||||
@@ -1188,9 +1191,18 @@ pub(crate) fn to_document_response(
|
|||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
doc: Document,
|
doc: Document,
|
||||||
tags: Option<Vec<Tag>>,
|
tags: Option<Vec<Tag>>,
|
||||||
thumbnail: Option<DocumentAssetResponse>,
|
current_version: Option<(DocumentVersionResponse, Vec<DocumentAssetResponse>)>,
|
||||||
) -> AppResult<DocumentResponse> {
|
) -> AppResult<DocumentResponse> {
|
||||||
|
let current_version = if let Some((version, assets)) = current_version {
|
||||||
let download_path = build_download_path(state, doc.id, user_id)?;
|
let download_path = build_download_path(state, doc.id, user_id)?;
|
||||||
|
Some(DocumentCurrentVersionResponse {
|
||||||
|
version,
|
||||||
|
assets,
|
||||||
|
download_path,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
Ok(DocumentResponse {
|
Ok(DocumentResponse {
|
||||||
id: doc.id,
|
id: doc.id,
|
||||||
@@ -1199,7 +1211,6 @@ pub(crate) fn to_document_response(
|
|||||||
original_name: doc.original_name,
|
original_name: doc.original_name,
|
||||||
content_type: doc.content_type,
|
content_type: doc.content_type,
|
||||||
folder_id: doc.folder_id,
|
folder_id: doc.folder_id,
|
||||||
current_version: doc.current_version,
|
|
||||||
uploaded_at: to_iso(doc.uploaded_at),
|
uploaded_at: to_iso(doc.uploaded_at),
|
||||||
updated_at: to_iso(doc.updated_at),
|
updated_at: to_iso(doc.updated_at),
|
||||||
deleted_at: doc.deleted_at.map(to_iso),
|
deleted_at: doc.deleted_at.map(to_iso),
|
||||||
@@ -1210,8 +1221,7 @@ pub(crate) fn to_document_response(
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.map(TagResponse::from)
|
.map(TagResponse::from)
|
||||||
.collect(),
|
.collect(),
|
||||||
thumbnail,
|
current_version,
|
||||||
download_path,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1235,13 +1245,13 @@ fn to_version_response(version: DocumentVersion) -> DocumentVersionResponse {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn to_asset_response(asset: DocumentAsset, url: String) -> DocumentAssetResponse {
|
fn to_asset_response(asset: DocumentAsset, url: Option<String>) -> DocumentAssetResponse {
|
||||||
|
let metadata = asset.metadata.clone();
|
||||||
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,
|
||||||
width: asset.width,
|
metadata,
|
||||||
height: asset.height,
|
|
||||||
url,
|
url,
|
||||||
created_at: to_iso(asset.created_at),
|
created_at: to_iso(asset.created_at),
|
||||||
}
|
}
|
||||||
@@ -1274,20 +1284,10 @@ async fn load_asset_responses(
|
|||||||
.load(&mut conn)?;
|
.load(&mut conn)?;
|
||||||
drop(conn);
|
drop(conn);
|
||||||
|
|
||||||
let mut responses = Vec::with_capacity(assets.len());
|
Ok(assets
|
||||||
for asset in assets {
|
.into_iter()
|
||||||
let url = state
|
.map(|asset| to_asset_response(asset, None))
|
||||||
.storage
|
.collect())
|
||||||
.presign_get_object(
|
|
||||||
&asset.s3_key,
|
|
||||||
Duration::from_secs(PRESIGNED_URL_EXPIRY_SECONDS),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|err| AppError::internal(format!("failed to sign asset URL: {err}")))?;
|
|
||||||
responses.push(to_asset_response(asset, url));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(responses)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn to_iso(dt: NaiveDateTime) -> String {
|
pub(crate) fn to_iso(dt: NaiveDateTime) -> String {
|
||||||
|
|||||||
@@ -19,8 +19,7 @@ use crate::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use super::documents::{
|
use super::documents::{
|
||||||
load_primary_thumbnails, load_tags_for_documents, to_document_response, to_iso,
|
load_primary_assets, load_tags_for_documents, to_document_response, to_iso, DocumentResponse,
|
||||||
DocumentResponse,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const QUICKWIT_MAX_HITS: usize = 200;
|
const QUICKWIT_MAX_HITS: usize = 200;
|
||||||
@@ -216,18 +215,18 @@ pub async fn list_folder_contents(
|
|||||||
let tags_map = load_tags_for_documents(&mut conn, &doc_ids)?;
|
let tags_map = load_tags_for_documents(&mut conn, &doc_ids)?;
|
||||||
drop(conn);
|
drop(conn);
|
||||||
|
|
||||||
let thumbnails = load_primary_thumbnails(&state, &docs).await?;
|
let primary_versions = load_primary_assets(&state, &docs).await?;
|
||||||
|
|
||||||
let mut documents = Vec::with_capacity(doc_ids.len());
|
let mut documents = Vec::with_capacity(doc_ids.len());
|
||||||
for doc in docs {
|
for doc in docs {
|
||||||
let tags = tags_map.get(&doc.id).cloned();
|
let tags = tags_map.get(&doc.id).cloned();
|
||||||
let thumbnail = thumbnails.get(&doc.id).cloned();
|
let current_version = primary_versions.get(&doc.id).cloned();
|
||||||
documents.push(to_document_response(
|
documents.push(to_document_response(
|
||||||
&state,
|
&state,
|
||||||
user.user_id,
|
user.user_id,
|
||||||
doc,
|
doc,
|
||||||
tags,
|
tags,
|
||||||
thumbnail,
|
current_version,
|
||||||
)?);
|
)?);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -400,17 +399,17 @@ pub async fn search_documents(
|
|||||||
let tags_map = load_tags_for_documents(&mut conn, &doc_ids)?;
|
let tags_map = load_tags_for_documents(&mut conn, &doc_ids)?;
|
||||||
drop(conn);
|
drop(conn);
|
||||||
|
|
||||||
let thumbnails = load_primary_thumbnails(&state, &docs).await?;
|
let primary_versions = load_primary_assets(&state, &docs).await?;
|
||||||
let mut response = Vec::with_capacity(doc_ids.len());
|
let mut response = Vec::with_capacity(doc_ids.len());
|
||||||
for doc in docs {
|
for doc in docs {
|
||||||
let tags = tags_map.get(&doc.id).cloned();
|
let tags = tags_map.get(&doc.id).cloned();
|
||||||
let thumbnail = thumbnails.get(&doc.id).cloned();
|
let current_version = primary_versions.get(&doc.id).cloned();
|
||||||
response.push(to_document_response(
|
response.push(to_document_response(
|
||||||
&state,
|
&state,
|
||||||
user.user_id,
|
user.user_id,
|
||||||
doc,
|
doc,
|
||||||
tags,
|
tags,
|
||||||
thumbnail,
|
current_version,
|
||||||
)?);
|
)?);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ 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),
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ diesel::table! {
|
|||||||
asset_type -> Text,
|
asset_type -> Text,
|
||||||
s3_key -> Text,
|
s3_key -> Text,
|
||||||
mime_type -> Text,
|
mime_type -> Text,
|
||||||
width -> Nullable<Int4>,
|
|
||||||
height -> Nullable<Int4>,
|
|
||||||
metadata -> Jsonb,
|
metadata -> Jsonb,
|
||||||
created_at -> Timestamptz,
|
created_at -> Timestamptz,
|
||||||
}
|
}
|
||||||
@@ -48,7 +46,6 @@ diesel::table! {
|
|||||||
#[max_length = 100]
|
#[max_length = 100]
|
||||||
content_type -> Nullable<Varchar>,
|
content_type -> Nullable<Varchar>,
|
||||||
folder_id -> Nullable<Uuid>,
|
folder_id -> Nullable<Uuid>,
|
||||||
current_version -> Int4,
|
|
||||||
uploaded_at -> Timestamptz,
|
uploaded_at -> Timestamptz,
|
||||||
updated_at -> Timestamptz,
|
updated_at -> Timestamptz,
|
||||||
deleted_at -> Nullable<Timestamptz>,
|
deleted_at -> Nullable<Timestamptz>,
|
||||||
@@ -56,6 +53,7 @@ diesel::table! {
|
|||||||
issued_at -> Nullable<Timestamptz>,
|
issued_at -> Nullable<Timestamptz>,
|
||||||
#[max_length = 255]
|
#[max_length = 255]
|
||||||
title -> Varchar,
|
title -> Varchar,
|
||||||
|
current_version_id -> Uuid,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,7 +126,6 @@ diesel::joinable!(document_assets -> document_versions (document_version_id));
|
|||||||
diesel::joinable!(document_tags -> documents (document_id));
|
diesel::joinable!(document_tags -> documents (document_id));
|
||||||
diesel::joinable!(document_tags -> tags (tag_id));
|
diesel::joinable!(document_tags -> tags (tag_id));
|
||||||
diesel::joinable!(document_tags -> users (assigned_by));
|
diesel::joinable!(document_tags -> users (assigned_by));
|
||||||
diesel::joinable!(document_versions -> documents (document_id));
|
|
||||||
diesel::joinable!(documents -> folders (folder_id));
|
diesel::joinable!(documents -> folders (folder_id));
|
||||||
diesel::joinable!(refresh_tokens -> users (user_id));
|
diesel::joinable!(refresh_tokens -> users (user_id));
|
||||||
|
|
||||||
|
|||||||
@@ -377,8 +377,6 @@ fn persist_ocr_metadata(
|
|||||||
asset_type: OCR_TEXT_ASSET_TYPE.to_string(),
|
asset_type: OCR_TEXT_ASSET_TYPE.to_string(),
|
||||||
s3_key: s3_key.to_string(),
|
s3_key: s3_key.to_string(),
|
||||||
mime_type: "text/plain".to_string(),
|
mime_type: "text/plain".to_string(),
|
||||||
width: None,
|
|
||||||
height: None,
|
|
||||||
metadata: json!({
|
metadata: json!({
|
||||||
"generated_at": Utc::now().to_rfc3339(),
|
"generated_at": Utc::now().to_rfc3339(),
|
||||||
"source": source,
|
"source": source,
|
||||||
|
|||||||
@@ -3,9 +3,7 @@ use std::{io::Cursor, panic, sync::Arc, time::Duration};
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use diesel::{pg::upsert::excluded, prelude::*};
|
use diesel::{pg::upsert::excluded, prelude::*};
|
||||||
use image::{
|
use image::{GenericImageView, ImageFormat, ImageReader};
|
||||||
codecs::png::PngEncoder, ColorType, GenericImageView, ImageEncoder, ImageFormat, ImageReader,
|
|
||||||
};
|
|
||||||
use pdfium_render::prelude::*;
|
use pdfium_render::prelude::*;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
@@ -24,7 +22,10 @@ use super::{analyze::determine_thumbnail_support, JobExecution, JobHandler};
|
|||||||
|
|
||||||
const THUMBNAIL_WIDTH: u32 = 512;
|
const THUMBNAIL_WIDTH: u32 = 512;
|
||||||
const THUMBNAIL_HEIGHT: u32 = 512;
|
const THUMBNAIL_HEIGHT: u32 = 512;
|
||||||
|
const PREVIEW_WIDTH: u32 = THUMBNAIL_WIDTH * 4;
|
||||||
|
const PREVIEW_HEIGHT: u32 = THUMBNAIL_HEIGHT * 4;
|
||||||
const THUMBNAIL_ASSET_TYPE: &str = "thumbnail";
|
const THUMBNAIL_ASSET_TYPE: &str = "thumbnail";
|
||||||
|
const PREVIEW_ASSET_TYPE: &str = "preview";
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct ThumbnailPayload {
|
struct ThumbnailPayload {
|
||||||
@@ -95,28 +96,61 @@ impl JobHandler for GenerateThumbnailsJob {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let generation = match generate_thumbnail(&initial.document, &bytes) {
|
let generation = match generate_preview_and_thumbnail(&initial.document, &bytes) {
|
||||||
Ok(result) => result,
|
Ok(result) => result,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
return JobExecution::Failed { error: err };
|
return JobExecution::Failed { error: err };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let asset_id = initial
|
let thumbnail_asset_id = initial
|
||||||
.existing_asset
|
.existing_thumbnail
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|asset| asset.id)
|
.map(|asset| asset.id)
|
||||||
.unwrap_or_else(Uuid::new_v4);
|
.unwrap_or_else(Uuid::new_v4);
|
||||||
let s3_key = format!(
|
let thumbnail_s3_key = format!(
|
||||||
"documents/{}/v{}/assets/{}/{}",
|
"documents/{}/v{}/assets/{}/{}",
|
||||||
initial.document.id, initial.version.version_number, THUMBNAIL_ASSET_TYPE, asset_id
|
initial.document.id,
|
||||||
|
initial.version.version_number,
|
||||||
|
THUMBNAIL_ASSET_TYPE,
|
||||||
|
thumbnail_asset_id
|
||||||
|
);
|
||||||
|
|
||||||
|
let preview_asset_id = initial
|
||||||
|
.existing_preview
|
||||||
|
.as_ref()
|
||||||
|
.map(|asset| asset.id)
|
||||||
|
.unwrap_or_else(Uuid::new_v4);
|
||||||
|
let preview_s3_key = format!(
|
||||||
|
"documents/{}/v{}/assets/{}/{}",
|
||||||
|
initial.document.id,
|
||||||
|
initial.version.version_number,
|
||||||
|
PREVIEW_ASSET_TYPE,
|
||||||
|
preview_asset_id
|
||||||
);
|
);
|
||||||
|
|
||||||
if let Err(err) = state
|
if let Err(err) = state
|
||||||
.storage
|
.storage
|
||||||
.put_object(
|
.put_object(
|
||||||
&s3_key,
|
&preview_s3_key,
|
||||||
generation.image_bytes.clone(),
|
generation.preview.image_bytes.clone(),
|
||||||
|
Some("image/png".into()),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
warn!(job_id = %job.id, error = %err, "failed to upload preview; retrying");
|
||||||
|
return JobExecution::Retry {
|
||||||
|
delay: Duration::from_secs(30),
|
||||||
|
error: err.to_string(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(err) = state
|
||||||
|
.storage
|
||||||
|
.put_object(
|
||||||
|
&thumbnail_s3_key,
|
||||||
|
generation.thumbnail.image_bytes.clone(),
|
||||||
Some("image/png".into()),
|
Some("image/png".into()),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
@@ -131,7 +165,24 @@ impl JobHandler for GenerateThumbnailsJob {
|
|||||||
|
|
||||||
let state_clone = state.clone();
|
let state_clone = state.clone();
|
||||||
match task::spawn_blocking(move || {
|
match task::spawn_blocking(move || {
|
||||||
persist_thumbnail_metadata(state_clone, &initial, &generation, asset_id, &s3_key)
|
persist_assets_metadata(
|
||||||
|
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
|
||||||
{
|
{
|
||||||
@@ -159,16 +210,29 @@ impl JobHandler for GenerateThumbnailsJob {
|
|||||||
struct ThumbnailContext {
|
struct ThumbnailContext {
|
||||||
document: Document,
|
document: Document,
|
||||||
version: DocumentVersion,
|
version: DocumentVersion,
|
||||||
existing_asset: Option<DocumentAsset>,
|
existing_thumbnail: Option<DocumentAsset>,
|
||||||
|
existing_preview: Option<DocumentAsset>,
|
||||||
skip: bool,
|
skip: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct GeneratedThumbnail {
|
struct GeneratedImage {
|
||||||
image_bytes: Vec<u8>,
|
image_bytes: Vec<u8>,
|
||||||
width: Option<i32>,
|
width: Option<i32>,
|
||||||
height: Option<i32>,
|
height: Option<i32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct GeneratedAssets {
|
||||||
|
thumbnail: GeneratedImage,
|
||||||
|
preview: GeneratedImage,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AssetPersistence<'a> {
|
||||||
|
asset_type: &'static str,
|
||||||
|
asset_id: Uuid,
|
||||||
|
s3_key: &'a str,
|
||||||
|
generated: &'a GeneratedImage,
|
||||||
|
}
|
||||||
|
|
||||||
fn load_thumbnail_context(
|
fn load_thumbnail_context(
|
||||||
state: Arc<AppState>,
|
state: Arc<AppState>,
|
||||||
payload: &ThumbnailPayload,
|
payload: &ThumbnailPayload,
|
||||||
@@ -189,29 +253,45 @@ fn load_thumbnail_context(
|
|||||||
.first(&mut conn)
|
.first(&mut conn)
|
||||||
.map_err(|err| format!("{err:?}"))?;
|
.map_err(|err| format!("{err:?}"))?;
|
||||||
|
|
||||||
let existing: Option<DocumentAsset> = document_assets::table
|
let existing_assets: Vec<DocumentAsset> = document_assets::table
|
||||||
.filter(document_assets::document_version_id.eq(payload.document_version_id))
|
.filter(document_assets::document_version_id.eq(payload.document_version_id))
|
||||||
.filter(document_assets::asset_type.eq(THUMBNAIL_ASSET_TYPE))
|
.filter(document_assets::asset_type.eq_any(vec![
|
||||||
.first(&mut conn)
|
THUMBNAIL_ASSET_TYPE.to_string(),
|
||||||
.optional()
|
PREVIEW_ASSET_TYPE.to_string(),
|
||||||
|
]))
|
||||||
|
.load(&mut conn)
|
||||||
.map_err(|err| format!("{err:?}"))?;
|
.map_err(|err| format!("{err:?}"))?;
|
||||||
|
|
||||||
|
let mut existing_thumbnail = None;
|
||||||
|
let mut existing_preview = None;
|
||||||
|
for asset in existing_assets {
|
||||||
|
match asset.asset_type.as_str() {
|
||||||
|
THUMBNAIL_ASSET_TYPE => existing_thumbnail = Some(asset),
|
||||||
|
PREVIEW_ASSET_TYPE => existing_preview = Some(asset),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let (supported, _) = determine_thumbnail_support(&document);
|
let (supported, _) = determine_thumbnail_support(&document);
|
||||||
if !supported {
|
if !supported {
|
||||||
return Err("thumbnail generation not supported for this document".into());
|
return Err("thumbnail generation not supported for this document".into());
|
||||||
}
|
}
|
||||||
|
|
||||||
let skip = existing.is_some() && !payload.force;
|
let skip = existing_thumbnail.is_some() && existing_preview.is_some() && !payload.force;
|
||||||
|
|
||||||
Ok(ThumbnailContext {
|
Ok(ThumbnailContext {
|
||||||
document,
|
document,
|
||||||
version,
|
version,
|
||||||
existing_asset: existing,
|
existing_thumbnail,
|
||||||
|
existing_preview,
|
||||||
skip,
|
skip,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn generate_thumbnail(document: &Document, bytes: &[u8]) -> Result<GeneratedThumbnail, String> {
|
fn generate_preview_and_thumbnail(
|
||||||
|
document: &Document,
|
||||||
|
bytes: &[u8],
|
||||||
|
) -> Result<GeneratedAssets, String> {
|
||||||
let is_pdf = document
|
let is_pdf = document
|
||||||
.content_type
|
.content_type
|
||||||
.as_deref()
|
.as_deref()
|
||||||
@@ -225,38 +305,41 @@ fn generate_thumbnail(document: &Document, bytes: &[u8]) -> Result<GeneratedThum
|
|||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
});
|
});
|
||||||
|
|
||||||
let (png_bytes, width, height) = if is_pdf {
|
let (preview, thumbnail) = if is_pdf {
|
||||||
generate_pdf_thumbnail(bytes)?
|
generate_pdf_assets(bytes)?
|
||||||
} else {
|
} else {
|
||||||
generate_image_thumbnail(bytes)?
|
generate_image_assets(bytes)?
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(GeneratedThumbnail {
|
Ok(GeneratedAssets { preview, thumbnail })
|
||||||
image_bytes: png_bytes,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn generate_image_thumbnail(bytes: &[u8]) -> Result<(Vec<u8>, Option<i32>, Option<i32>), String> {
|
fn generate_image_assets(bytes: &[u8]) -> Result<(GeneratedImage, GeneratedImage), 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())?;
|
||||||
let mut image = reader.decode().map_err(|err| err.to_string())?;
|
let image = reader.decode().map_err(|err| err.to_string())?;
|
||||||
if image.width() > THUMBNAIL_WIDTH || image.height() > THUMBNAIL_HEIGHT {
|
|
||||||
image = image.thumbnail(THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT);
|
let preview_image = if image.width() > PREVIEW_WIDTH || image.height() > PREVIEW_HEIGHT {
|
||||||
|
image.thumbnail(PREVIEW_WIDTH, PREVIEW_HEIGHT)
|
||||||
|
} else {
|
||||||
|
image.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
let thumbnail_image =
|
||||||
|
if preview_image.width() > THUMBNAIL_WIDTH || preview_image.height() > THUMBNAIL_HEIGHT {
|
||||||
|
preview_image.thumbnail(THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT)
|
||||||
|
} else {
|
||||||
|
preview_image.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
let preview = encode_dynamic_image(preview_image)?;
|
||||||
|
let thumbnail = encode_dynamic_image(thumbnail_image)?;
|
||||||
|
|
||||||
|
Ok((preview, thumbnail))
|
||||||
}
|
}
|
||||||
|
|
||||||
let (width, height) = image.dimensions();
|
fn generate_pdf_assets(bytes: &[u8]) -> Result<(GeneratedImage, GeneratedImage), String> {
|
||||||
let mut cursor = Cursor::new(Vec::new());
|
|
||||||
image
|
|
||||||
.write_to(&mut cursor, ImageFormat::Png)
|
|
||||||
.map_err(|err| err.to_string())?;
|
|
||||||
let buffer = cursor.into_inner();
|
|
||||||
Ok((buffer, Some(width as i32), Some(height as i32)))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn generate_pdf_thumbnail(bytes: &[u8]) -> Result<(Vec<u8>, Option<i32>, Option<i32>), String> {
|
|
||||||
let pdfium = panic::catch_unwind(|| Pdfium::default())
|
let pdfium = panic::catch_unwind(|| Pdfium::default())
|
||||||
.map_err(|_| "failed to initialize PDFium".to_string())?;
|
.map_err(|_| "failed to initialize PDFium".to_string())?;
|
||||||
|
|
||||||
@@ -270,8 +353,8 @@ fn generate_pdf_thumbnail(bytes: &[u8]) -> Result<(Vec<u8>, Option<i32>, Option<
|
|||||||
.map_err(|err| format!("load first page: {err}"))?;
|
.map_err(|err| format!("load first page: {err}"))?;
|
||||||
|
|
||||||
let render_config = PdfRenderConfig::new()
|
let render_config = PdfRenderConfig::new()
|
||||||
.set_target_width(THUMBNAIL_WIDTH as i32)
|
.set_target_width(PREVIEW_WIDTH as i32)
|
||||||
.set_maximum_height(THUMBNAIL_HEIGHT as i32)
|
.set_maximum_height(PREVIEW_HEIGHT as i32)
|
||||||
.render_form_data(true)
|
.render_form_data(true)
|
||||||
.rotate_if_landscape(PdfPageRenderRotation::None, true);
|
.rotate_if_landscape(PdfPageRenderRotation::None, true);
|
||||||
|
|
||||||
@@ -279,35 +362,53 @@ fn generate_pdf_thumbnail(bytes: &[u8]) -> Result<(Vec<u8>, Option<i32>, Option<
|
|||||||
.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: {err}"))?;
|
||||||
|
|
||||||
let image = bitmap.as_image().to_rgb8();
|
let preview_buffer = bitmap.as_image().to_rgb8();
|
||||||
let (width, height) = image.dimensions();
|
let preview_image = image::DynamicImage::ImageRgb8(preview_buffer);
|
||||||
let mut cursor = Cursor::new(Vec::new());
|
|
||||||
PngEncoder::new(&mut cursor)
|
|
||||||
.write_image(image.as_raw(), width, height, ColorType::Rgb8.into())
|
|
||||||
.map_err(|err| format!("encode pdf thumbnail: {err}"))?;
|
|
||||||
|
|
||||||
Ok((cursor.into_inner(), Some(width as i32), Some(height as i32)))
|
let thumbnail_image =
|
||||||
|
if preview_image.width() > THUMBNAIL_WIDTH || preview_image.height() > THUMBNAIL_HEIGHT {
|
||||||
|
preview_image.thumbnail(THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT)
|
||||||
|
} else {
|
||||||
|
preview_image.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
let preview = encode_dynamic_image(preview_image)?;
|
||||||
|
let thumbnail = encode_dynamic_image(thumbnail_image)?;
|
||||||
|
|
||||||
|
Ok((preview, thumbnail))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn persist_thumbnail_metadata(
|
fn encode_dynamic_image(image: image::DynamicImage) -> Result<GeneratedImage, String> {
|
||||||
|
let (width, height) = image.dimensions();
|
||||||
|
let mut cursor = Cursor::new(Vec::new());
|
||||||
|
image
|
||||||
|
.write_to(&mut cursor, ImageFormat::Png)
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
Ok(GeneratedImage {
|
||||||
|
image_bytes: cursor.into_inner(),
|
||||||
|
width: Some(width as i32),
|
||||||
|
height: Some(height as i32),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn persist_assets_metadata(
|
||||||
state: Arc<AppState>,
|
state: Arc<AppState>,
|
||||||
context: &ThumbnailContext,
|
context: &ThumbnailContext,
|
||||||
generated: &GeneratedThumbnail,
|
assets: &[AssetPersistence<'_>],
|
||||||
asset_id: Uuid,
|
|
||||||
s3_key: &str,
|
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let mut conn = state.db().map_err(|err| format!("{err:?}"))?;
|
let mut conn = state.db().map_err(|err| format!("{err:?}"))?;
|
||||||
|
|
||||||
|
for asset in assets {
|
||||||
let new_asset = NewDocumentAsset {
|
let new_asset = NewDocumentAsset {
|
||||||
id: asset_id,
|
id: asset.asset_id,
|
||||||
document_version_id: context.version.id,
|
document_version_id: context.version.id,
|
||||||
asset_type: THUMBNAIL_ASSET_TYPE.to_string(),
|
asset_type: asset.asset_type.to_string(),
|
||||||
s3_key: s3_key.to_string(),
|
s3_key: asset.s3_key.to_string(),
|
||||||
mime_type: "image/png".to_string(),
|
mime_type: "image/png".to_string(),
|
||||||
width: generated.width,
|
|
||||||
height: generated.height,
|
|
||||||
metadata: json!({
|
metadata: json!({
|
||||||
"generated_at": Utc::now().to_rfc3339(),
|
"generated_at": Utc::now().to_rfc3339(),
|
||||||
|
"width": asset.generated.width,
|
||||||
|
"height": asset.generated.height,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -321,12 +422,11 @@ fn persist_thumbnail_metadata(
|
|||||||
.set((
|
.set((
|
||||||
document_assets::s3_key.eq(excluded(document_assets::s3_key)),
|
document_assets::s3_key.eq(excluded(document_assets::s3_key)),
|
||||||
document_assets::mime_type.eq(excluded(document_assets::mime_type)),
|
document_assets::mime_type.eq(excluded(document_assets::mime_type)),
|
||||||
document_assets::width.eq(excluded(document_assets::width)),
|
|
||||||
document_assets::height.eq(excluded(document_assets::height)),
|
|
||||||
document_assets::metadata.eq(excluded(document_assets::metadata)),
|
document_assets::metadata.eq(excluded(document_assets::metadata)),
|
||||||
))
|
))
|
||||||
.execute(&mut conn)
|
.execute(&mut conn)
|
||||||
.map_err(|err| format!("{err:?}"))?;
|
.map_err(|err| format!("{err:?}"))?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ use uuid::Uuid;
|
|||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct DocumentDetail {
|
struct DocumentDetail {
|
||||||
document: DocumentInfo,
|
document: DocumentInfo,
|
||||||
current_version: DocumentVersion,
|
|
||||||
assets: Vec<DocumentAssetInfo>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -18,11 +16,11 @@ struct DocumentInfo {
|
|||||||
id: Uuid,
|
id: Uuid,
|
||||||
title: String,
|
title: String,
|
||||||
original_name: String,
|
original_name: String,
|
||||||
current_version: i32,
|
|
||||||
deleted_at: Option<String>,
|
deleted_at: Option<String>,
|
||||||
issued_at: Option<String>,
|
issued_at: Option<String>,
|
||||||
tags: Vec<TagSummary>,
|
tags: Vec<TagSummary>,
|
||||||
download_path: String,
|
#[serde(default)]
|
||||||
|
current_version: Option<DocumentVersion>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -30,6 +28,10 @@ struct DocumentVersion {
|
|||||||
id: Uuid,
|
id: Uuid,
|
||||||
s3_key: String,
|
s3_key: String,
|
||||||
size_bytes: i64,
|
size_bytes: i64,
|
||||||
|
version_number: i32,
|
||||||
|
download_path: String,
|
||||||
|
#[serde(default)]
|
||||||
|
assets: Vec<DocumentAssetInfo>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
@@ -42,8 +44,8 @@ struct DocumentAssetInfo {
|
|||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct DocumentListItem {
|
struct DocumentListItem {
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
current_version: i32,
|
#[serde(default)]
|
||||||
download_path: String,
|
current_version: Option<DocumentVersion>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -152,17 +154,22 @@ async fn upload_and_list_document() -> Result<()> {
|
|||||||
|
|
||||||
assert_eq!(detail.document.original_name, "doc.txt");
|
assert_eq!(detail.document.original_name, "doc.txt");
|
||||||
assert_eq!(detail.document.title, "doc");
|
assert_eq!(detail.document.title, "doc");
|
||||||
assert_eq!(detail.document.current_version, 1);
|
|
||||||
assert_eq!(detail.document.deleted_at, None);
|
assert_eq!(detail.document.deleted_at, None);
|
||||||
assert!(detail.document.issued_at.is_none());
|
assert!(detail.document.issued_at.is_none());
|
||||||
assert!(detail.document.tags.is_empty());
|
assert!(detail.document.tags.is_empty());
|
||||||
assert!(detail.document.download_path.starts_with("/download/"));
|
assert!(current_version.download_path.starts_with("/download/"));
|
||||||
assert_eq!(detail.current_version.size_bytes, file_bytes.len() as i64);
|
let current_version = detail
|
||||||
assert!(detail.assets.is_empty());
|
.document
|
||||||
|
.current_version
|
||||||
|
.as_ref()
|
||||||
|
.expect("current version detail");
|
||||||
|
assert_eq!(current_version.version_number, 1);
|
||||||
|
assert_eq!(current_version.size_bytes, file_bytes.len() as i64);
|
||||||
|
assert!(current_version.assets.is_empty());
|
||||||
|
|
||||||
let stored = app
|
let stored = app
|
||||||
.storage()
|
.storage()
|
||||||
.get(&detail.current_version.s3_key)
|
.get(¤t_version.s3_key)
|
||||||
.await
|
.await
|
||||||
.expect("object stored");
|
.expect("object stored");
|
||||||
assert_eq!(stored.bytes, file_bytes);
|
assert_eq!(stored.bytes, file_bytes);
|
||||||
@@ -175,8 +182,18 @@ async fn upload_and_list_document() -> Result<()> {
|
|||||||
assert_eq!(list.len(), 1);
|
assert_eq!(list.len(), 1);
|
||||||
let item = list.pop().unwrap();
|
let item = list.pop().unwrap();
|
||||||
assert_eq!(item.id, detail.document.id);
|
assert_eq!(item.id, detail.document.id);
|
||||||
assert_eq!(item.current_version, 1);
|
assert_eq!(
|
||||||
assert!(item.download_path.starts_with("/download/"));
|
item.current_version
|
||||||
|
.as_ref()
|
||||||
|
.map(|version| version.version_number),
|
||||||
|
Some(1)
|
||||||
|
);
|
||||||
|
assert!(item
|
||||||
|
.current_version
|
||||||
|
.as_ref()
|
||||||
|
.expect("list current version")
|
||||||
|
.download_path
|
||||||
|
.starts_with("/download/"));
|
||||||
|
|
||||||
let download = app
|
let download = app
|
||||||
.get(
|
.get(
|
||||||
@@ -187,17 +204,17 @@ async fn upload_and_list_document() -> Result<()> {
|
|||||||
assert_eq!(download.status(), StatusCode::OK);
|
assert_eq!(download.status(), StatusCode::OK);
|
||||||
let body = body_to_vec(download.into_body()).await?;
|
let body = body_to_vec(download.into_body()).await?;
|
||||||
let download_info: DocumentDownload = serde_json::from_slice(&body)?;
|
let download_info: DocumentDownload = serde_json::from_slice(&body)?;
|
||||||
assert!(download_info.url.contains(&detail.current_version.s3_key));
|
assert!(download_info.url.contains(¤t_version.s3_key));
|
||||||
assert_eq!(download_info.filename, "doc.txt");
|
assert_eq!(download_info.filename, "doc.txt");
|
||||||
|
|
||||||
let redirect = app.get(&detail.document.download_path, None).await?;
|
let redirect = app.get(¤t_version.download_path, None).await?;
|
||||||
assert_eq!(redirect.status(), StatusCode::TEMPORARY_REDIRECT);
|
assert_eq!(redirect.status(), StatusCode::TEMPORARY_REDIRECT);
|
||||||
let location = redirect
|
let location = redirect
|
||||||
.headers()
|
.headers()
|
||||||
.get("location")
|
.get("location")
|
||||||
.expect("redirect location header");
|
.expect("redirect location header");
|
||||||
let location = location.to_str().expect("location header utf8");
|
let location = location.to_str().expect("location header utf8");
|
||||||
assert!(location.contains(&detail.current_version.s3_key));
|
assert!(location.contains(¤t_version.s3_key));
|
||||||
|
|
||||||
app.cleanup().await?;
|
app.cleanup().await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -243,7 +260,13 @@ async fn duplicate_and_restore_document() -> Result<()> {
|
|||||||
|
|
||||||
assert_eq!(first_detail.document.id, second_detail.document.id);
|
assert_eq!(first_detail.document.id, second_detail.document.id);
|
||||||
assert_eq!(second_detail.document.deleted_at, None);
|
assert_eq!(second_detail.document.deleted_at, None);
|
||||||
assert!(second_detail.assets.is_empty());
|
assert!(second_detail
|
||||||
|
.document
|
||||||
|
.current_version
|
||||||
|
.as_ref()
|
||||||
|
.expect("second current version")
|
||||||
|
.assets
|
||||||
|
.is_empty());
|
||||||
assert_eq!(app.storage().object_count().await, 1);
|
assert_eq!(app.storage().object_count().await, 1);
|
||||||
|
|
||||||
let delete = app
|
let delete = app
|
||||||
@@ -341,8 +364,24 @@ async fn bulk_reanalyze_documents() -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut expected = vec![
|
let mut expected = vec![
|
||||||
(first_detail.document.id, first_detail.current_version.id),
|
(
|
||||||
(second_detail.document.id, second_detail.current_version.id),
|
first_detail.document.id,
|
||||||
|
first_detail
|
||||||
|
.document
|
||||||
|
.current_version
|
||||||
|
.as_ref()
|
||||||
|
.expect("first current version")
|
||||||
|
.id,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
second_detail.document.id,
|
||||||
|
second_detail
|
||||||
|
.document
|
||||||
|
.current_version
|
||||||
|
.as_ref()
|
||||||
|
.expect("second current version")
|
||||||
|
.id,
|
||||||
|
),
|
||||||
];
|
];
|
||||||
payload_docs.sort();
|
payload_docs.sort();
|
||||||
expected.sort();
|
expected.sort();
|
||||||
@@ -661,8 +700,24 @@ async fn bulk_reanalyze_selected_documents() -> Result<()> {
|
|||||||
.all(|(doc_id, _)| *doc_id != second_detail.document.id));
|
.all(|(doc_id, _)| *doc_id != second_detail.document.id));
|
||||||
|
|
||||||
let mut expected = vec![
|
let mut expected = vec![
|
||||||
(first_detail.document.id, first_detail.current_version.id),
|
(
|
||||||
(third_detail.document.id, third_detail.current_version.id),
|
first_detail.document.id,
|
||||||
|
first_detail
|
||||||
|
.document
|
||||||
|
.current_version
|
||||||
|
.as_ref()
|
||||||
|
.expect("first current version")
|
||||||
|
.id,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
third_detail.document.id,
|
||||||
|
third_detail
|
||||||
|
.document
|
||||||
|
.current_version
|
||||||
|
.as_ref()
|
||||||
|
.expect("third current version")
|
||||||
|
.id,
|
||||||
|
),
|
||||||
];
|
];
|
||||||
payload_docs.sort();
|
payload_docs.sort();
|
||||||
expected.sort();
|
expected.sort();
|
||||||
|
|||||||
@@ -0,0 +1,257 @@
|
|||||||
|
export const getAssetFromGroup = (assets, assetType) => {
|
||||||
|
if (!assetType || !assets) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(assets)) {
|
||||||
|
return assets.find((entry) => entry?.asset_type === assetType) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return assets?.[assetType] || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAssetFromVersion = (currentVersion, assetType) => {
|
||||||
|
if (!currentVersion) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return getAssetFromGroup(currentVersion.assets, assetType);
|
||||||
|
};
|
||||||
|
|
||||||
|
class AssetManager {
|
||||||
|
constructor({ api, assetPresignTtlMs }) {
|
||||||
|
this.api = api;
|
||||||
|
this.assetPresignTtlMs = assetPresignTtlMs;
|
||||||
|
this.assetCache = new Map();
|
||||||
|
this.assetInflight = new Map();
|
||||||
|
this.previewCache = new Map();
|
||||||
|
this.previewInflight = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
setApi(api) {
|
||||||
|
this.api = api;
|
||||||
|
}
|
||||||
|
|
||||||
|
rememberAsset(entry) {
|
||||||
|
if (entry?.id) {
|
||||||
|
this.assetCache.set(entry.id, entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hydrateAsset(asset) {
|
||||||
|
if (!asset || !asset.id) {
|
||||||
|
return asset;
|
||||||
|
}
|
||||||
|
const cached = this.assetCache.get(asset.id);
|
||||||
|
if (!cached) {
|
||||||
|
return asset;
|
||||||
|
}
|
||||||
|
const merged = { ...cached, ...asset };
|
||||||
|
if (cached.url && !asset.url) {
|
||||||
|
merged.url = cached.url;
|
||||||
|
}
|
||||||
|
if (cached.expiresAt) {
|
||||||
|
const cachedExpires = Number(cached.expiresAt) || null;
|
||||||
|
const assetExpires = Number(asset.expiresAt) || null;
|
||||||
|
if (!assetExpires || (cachedExpires && cachedExpires > assetExpires)) {
|
||||||
|
merged.expiresAt = cachedExpires;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
hydrateDocument(document) {
|
||||||
|
if (!document) {
|
||||||
|
return document;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentVersion = document.current_version || null;
|
||||||
|
if (!currentVersion) {
|
||||||
|
return document;
|
||||||
|
}
|
||||||
|
|
||||||
|
let changed = false;
|
||||||
|
let nextAssets = currentVersion.assets;
|
||||||
|
|
||||||
|
if (nextAssets && !Array.isArray(nextAssets)) {
|
||||||
|
const hydrated = {};
|
||||||
|
Object.keys(nextAssets).forEach((key) => {
|
||||||
|
hydrated[key] = this.hydrateAsset(nextAssets[key]);
|
||||||
|
if (hydrated[key] !== nextAssets[key]) {
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (changed) {
|
||||||
|
nextAssets = { ...nextAssets, ...hydrated };
|
||||||
|
}
|
||||||
|
} else if (Array.isArray(nextAssets)) {
|
||||||
|
const hydratedList = nextAssets.map((item) => this.hydrateAsset(item));
|
||||||
|
if (
|
||||||
|
hydratedList.length !== nextAssets.length ||
|
||||||
|
hydratedList.some((item, index) => item !== nextAssets[index])
|
||||||
|
) {
|
||||||
|
changed = true;
|
||||||
|
nextAssets = hydratedList;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!changed) {
|
||||||
|
return document;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextCurrentVersion = { ...currentVersion, assets: nextAssets };
|
||||||
|
return { ...document, current_version: nextCurrentVersion };
|
||||||
|
}
|
||||||
|
|
||||||
|
hydrateDocuments(documents) {
|
||||||
|
if (!Array.isArray(documents)) {
|
||||||
|
return documents;
|
||||||
|
}
|
||||||
|
return documents.map((doc) => this.hydrateDocument(doc));
|
||||||
|
}
|
||||||
|
|
||||||
|
hydrateDetail(detail) {
|
||||||
|
if (!detail) {
|
||||||
|
return detail;
|
||||||
|
}
|
||||||
|
let changed = false;
|
||||||
|
const next = { ...detail };
|
||||||
|
|
||||||
|
if (detail.document) {
|
||||||
|
const hydratedDocument = this.hydrateDocument(detail.document);
|
||||||
|
if (hydratedDocument !== detail.document) {
|
||||||
|
next.document = hydratedDocument;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(detail.assets)) {
|
||||||
|
const hydratedAssets = detail.assets.map((item) => this.hydrateAsset(item));
|
||||||
|
if (
|
||||||
|
hydratedAssets.length !== detail.assets.length ||
|
||||||
|
hydratedAssets.some((item, index) => item !== detail.assets[index])
|
||||||
|
) {
|
||||||
|
next.assets = hydratedAssets;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return changed ? next : detail;
|
||||||
|
}
|
||||||
|
|
||||||
|
hydrateFolderContents(contents) {
|
||||||
|
if (!contents) {
|
||||||
|
return contents;
|
||||||
|
}
|
||||||
|
const next = { ...contents };
|
||||||
|
if (Array.isArray(contents.documents)) {
|
||||||
|
next.documents = this.hydrateDocuments(contents.documents);
|
||||||
|
}
|
||||||
|
if (contents.document) {
|
||||||
|
next.document = this.hydrateDocument(contents.document);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureAsset(documentId, asset, { force = false } = {}) {
|
||||||
|
if (!documentId || !asset?.id) {
|
||||||
|
return Promise.resolve(asset || null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const assetExpiresAt = typeof asset.expiresAt === 'number' ? asset.expiresAt : null;
|
||||||
|
if (!force && asset?.url && (!assetExpiresAt || assetExpiresAt > Date.now())) {
|
||||||
|
this.rememberAsset(asset);
|
||||||
|
return Promise.resolve(asset);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cached = this.assetCache.get(asset.id);
|
||||||
|
const now = Date.now();
|
||||||
|
if (!force && cached && cached.expiresAt && cached.expiresAt > now && cached.url) {
|
||||||
|
return Promise.resolve({ ...asset, ...cached });
|
||||||
|
}
|
||||||
|
|
||||||
|
const inflightKey = `${documentId}:${asset.id}`;
|
||||||
|
if (!force && this.assetInflight.has(inflightKey)) {
|
||||||
|
return this.assetInflight.get(inflightKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.api) {
|
||||||
|
return Promise.reject(new Error('AssetManager API client is not configured.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = this.api
|
||||||
|
.get(`/documents/${documentId}/assets/${asset.id}`)
|
||||||
|
.then(({ data }) => {
|
||||||
|
const entry = {
|
||||||
|
...asset,
|
||||||
|
...data,
|
||||||
|
expiresAt: Date.now() + this.assetPresignTtlMs,
|
||||||
|
};
|
||||||
|
this.rememberAsset(entry);
|
||||||
|
return entry;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.assetInflight.delete(inflightKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.assetInflight.set(inflightKey, request);
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
ensurePreview(documentId, { force = false } = {}) {
|
||||||
|
if (!documentId) {
|
||||||
|
return Promise.resolve(null);
|
||||||
|
}
|
||||||
|
const cached = this.previewCache.get(documentId);
|
||||||
|
const now = Date.now();
|
||||||
|
if (!force && cached && (!cached.expiresAt || cached.expiresAt > now)) {
|
||||||
|
return Promise.resolve(cached);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!force && this.previewInflight.has(documentId)) {
|
||||||
|
return this.previewInflight.get(documentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.api) {
|
||||||
|
return Promise.reject(new Error('AssetManager API client is not configured.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = this.api
|
||||||
|
.get(`/documents/${documentId}/download`)
|
||||||
|
.then(({ data }) => {
|
||||||
|
const ttl = data.expires_in ? Math.max(data.expires_in - 60, 30) * 1000 : 5 * 60 * 1000;
|
||||||
|
const entry = {
|
||||||
|
url: data.url,
|
||||||
|
contentType: data.content_type || null,
|
||||||
|
filename: data.filename,
|
||||||
|
expiresAt: Date.now() + ttl,
|
||||||
|
};
|
||||||
|
this.previewCache.set(documentId, entry);
|
||||||
|
return entry;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.previewInflight.delete(documentId);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.previewInflight.set(documentId, request);
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
getPreview(documentId) {
|
||||||
|
return this.previewCache.get(documentId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteDocument(documentId) {
|
||||||
|
if (!documentId) return;
|
||||||
|
this.previewCache.delete(documentId);
|
||||||
|
this.previewInflight.delete(documentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
this.assetCache.clear();
|
||||||
|
this.assetInflight.clear();
|
||||||
|
this.previewCache.clear();
|
||||||
|
this.previewInflight.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AssetManager;
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
|
const noop = () => {};
|
||||||
|
|
||||||
|
const normalizeMessage = (error) => {
|
||||||
|
if (!error) return 'Something went wrong.';
|
||||||
|
if (typeof error === 'string') {
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
const { response, message } = error;
|
||||||
|
if (response?.data?.error) return response.data.error;
|
||||||
|
if (response?.data?.message) return response.data.message;
|
||||||
|
return message || 'Something went wrong.';
|
||||||
|
};
|
||||||
|
|
||||||
|
const useApiError = ({
|
||||||
|
logger = console,
|
||||||
|
onReport = noop,
|
||||||
|
} = {}) => {
|
||||||
|
return useCallback(
|
||||||
|
(error, { message, variant = 'error', retry = null } = {}) => {
|
||||||
|
const normalizedMessage = message || normalizeMessage(error);
|
||||||
|
if (logger && typeof logger.error === 'function') {
|
||||||
|
logger.error('[API]', normalizedMessage, error);
|
||||||
|
}
|
||||||
|
onReport({ message: normalizedMessage, variant, retry, error });
|
||||||
|
return normalizedMessage;
|
||||||
|
},
|
||||||
|
[logger, onReport],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useApiError;
|
||||||
+943
-156
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,317 @@
|
|||||||
|
/* Skeuomorphic workspace styles */
|
||||||
|
.skeuo-main {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeuo-shell {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
--desk-1: #f0e2c6;
|
||||||
|
--desk-2: #e7d2ad;
|
||||||
|
--desk-3: #d8b98a;
|
||||||
|
--desk-grain-dark: rgba(110, 86, 48, 0.08);
|
||||||
|
--desk-grain-fine: rgba(86, 66, 33, 0.04);
|
||||||
|
--desk-vignette: rgba(60, 40, 18, 0.15);
|
||||||
|
background:
|
||||||
|
radial-gradient(120% 85% at 50% -10%, var(--desk-vignette), #0000 48%),
|
||||||
|
linear-gradient(7deg, #0000 0 32%, var(--desk-grain-dark) 60%, #0000 85%),
|
||||||
|
repeating-linear-gradient(
|
||||||
|
7deg,
|
||||||
|
var(--desk-grain-fine) 0 2px,
|
||||||
|
#0000 2px 9px
|
||||||
|
),
|
||||||
|
linear-gradient(90deg, var(--desk-1), var(--desk-2) 48%, var(--desk-3) 100%);
|
||||||
|
background-size: 100% 100%, 100% 100%, 100% 100%, 100% 100%;
|
||||||
|
background-blend-mode: multiply, overlay, normal, normal;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeuo-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem 1.5rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeuo-header__meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeuo-header__meta h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeuo-breadcrumbs {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeuo-crumb {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeuo-crumb.is-current {
|
||||||
|
color: var(--fg);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeuo-crumb-separator {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeuo-header__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeuo-canvas {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
margin: 0 1.5rem 1.5rem;
|
||||||
|
border-radius: 18px;
|
||||||
|
background-image: radial-gradient(rgba(255, 255, 255, 0.5) 9%, transparent 9%),
|
||||||
|
linear-gradient(180deg, rgba(0, 0, 0, 0.05), transparent 32%);
|
||||||
|
background-size: 46px 46px, 100% 100%;
|
||||||
|
box-shadow: inset 0 16px 28px rgba(0, 0, 0, 0.07), inset 0 -6px 20px rgba(0, 0, 0, 0.05);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeuo-empty {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 3rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeuo-item {
|
||||||
|
position: absolute;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
width: auto;
|
||||||
|
cursor: grab;
|
||||||
|
touch-action: none;
|
||||||
|
transform-origin: center center;
|
||||||
|
transition: transform 0.28s ease, box-shadow 0.16s ease;
|
||||||
|
outline: none;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeuo-item__body {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
transition: width 0.28s ease, height 0.28s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeuo-item:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeuo-item.is-dragging {
|
||||||
|
cursor: grabbing;
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeuo-item.is-zoomed {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeuo-item.is-zoomed .skeuo-item__card {
|
||||||
|
box-shadow: 0 18px 42px rgba(0, 0, 0, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeuo-item.is-tag-target .skeuo-item__card {
|
||||||
|
outline: 1em dashed var(--accent);
|
||||||
|
outline-offset: 1.41em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeuo-item.is-tag-pending .skeuo-item__card {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeuo-item.is-filtered-out {
|
||||||
|
opacity: 0.12;
|
||||||
|
pointer-events: none;
|
||||||
|
filter: blur(15px) grayscale(100%);
|
||||||
|
transition: opacity 0.6s ease, filter 0.28s ease;
|
||||||
|
z-index: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeuo-item__tags {
|
||||||
|
--tag-scale: 1;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
align-items: flex-end;
|
||||||
|
transform-origin: top right;
|
||||||
|
transform: scale(var(--tag-scale)) translate(-0.5em, 0.5em);
|
||||||
|
transition: transform 0.28s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeuo-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding: 0.28rem 0.7rem;
|
||||||
|
border-radius: 1px;
|
||||||
|
background: linear-gradient(180deg, rgba(0, 0, 0, 0.04), rgba(0, 0, 0, 0) 70%), var(--surface);
|
||||||
|
color: var(--fg);
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: 2px 2px 3px rgba(0, 0, 0, 0.18);
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeuo-item__tags .skeuo-tag {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.18rem 0.55rem;
|
||||||
|
pointer-events: auto;
|
||||||
|
cursor: grab;
|
||||||
|
transition: transform 0.16s ease, opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeuo-tag.is-drag-hidden {
|
||||||
|
opacity: 0.4;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeuo-tag span {
|
||||||
|
display: block;
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeuo-item__tags .skeuo-tag.is-tear-pending {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeuo-tag-shelf {
|
||||||
|
position: absolute;
|
||||||
|
top: 2.25rem;
|
||||||
|
right: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.6rem;
|
||||||
|
align-items: flex-end;
|
||||||
|
max-height: calc(100% - 4rem);
|
||||||
|
overflow: auto;
|
||||||
|
padding: 0;
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeuo-tag-shelf .skeuo-tag {
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeuo-tag-shelf .skeuo-tag:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeuo-tag-shelf .skeuo-tag.is-inactive {
|
||||||
|
opacity: 0.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeuo-tag-shelf .skeuo-tag.is-active {
|
||||||
|
opacity: 1;
|
||||||
|
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeuo-tag-add {
|
||||||
|
width: 2.4rem;
|
||||||
|
height: 2.4rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.15);
|
||||||
|
background: rgba(255, 255, 255, 0.8);
|
||||||
|
color: var(--fg);
|
||||||
|
font-size: 1.4rem;
|
||||||
|
line-height: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
transition: background 0.2s ease, transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeuo-tag-add:hover {
|
||||||
|
background: rgba(255, 255, 255, 1);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeuo-tag-add:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeuo-tag-add:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.skeuo-cursor-remove,
|
||||||
|
body.skeuo-cursor-remove * {
|
||||||
|
cursor: not-allowed !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeuo-item__shadow {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeuo-item__card {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 0;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.24);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeuo-item__card img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeuo-item__placeholder {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: normal;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -691,7 +691,7 @@ button.icon-button.ghost:hover:not([disabled]) {
|
|||||||
.thumb-placeholder {
|
.thumb-placeholder {
|
||||||
width: 36px;
|
width: 36px;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
border-radius: 0;
|
border-radius: 1px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -738,6 +738,11 @@ button.icon-button.ghost:hover:not([disabled]) {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.documents-panel tbody tr.folder.focused {
|
||||||
|
box-shadow: inset 2px 0 0 rgba(43, 92, 255, 0.45);
|
||||||
|
background: rgba(43, 92, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
.documents-panel tbody tr.document {
|
.documents-panel tbody tr.document {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,7 @@ const env = dotenv.config({ path: path.resolve(__dirname, '.env.local') }).parse
|
|||||||
|
|
||||||
const DEFAULT_DEV_API = 'http://127.0.0.1:3000';
|
const DEFAULT_DEV_API = 'http://127.0.0.1:3000';
|
||||||
|
|
||||||
const API_BASE_URL = env.API_BASE_URL || process.env.API_BASE_URL || '';
|
const API_BASE_URL = env.API_BASE_URL || process.env.API_BASE_URL || DEFAULT_DEV_API;
|
||||||
const devServerApiTarget = API_BASE_URL || DEFAULT_DEV_API;
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
entry: './src/index.jsx',
|
entry: './src/index.jsx',
|
||||||
@@ -49,13 +48,6 @@ module.exports = {
|
|||||||
port: 5173,
|
port: 5173,
|
||||||
historyApiFallback: true,
|
historyApiFallback: true,
|
||||||
open: true,
|
open: true,
|
||||||
proxy: [
|
|
||||||
{
|
|
||||||
context: ['/api'],
|
|
||||||
target: devServerApiTarget,
|
|
||||||
changeOrigin: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
devtool: 'source-map',
|
devtool: 'source-map',
|
||||||
resolve: {
|
resolve: {
|
||||||
|
|||||||
Reference in New Issue
Block a user