diff --git a/libs/app-error/src/lib.rs b/libs/app-error/src/lib.rs index 506cbc96..69f1f397 100644 --- a/libs/app-error/src/lib.rs +++ b/libs/app-error/src/lib.rs @@ -139,6 +139,9 @@ pub enum AppError { #[error("{0}")] InvalidPublishedOutline(String), + + #[error("{0}")] + InvalidFolderView(String), } impl AppError { @@ -204,6 +207,7 @@ impl AppError { AppError::StringLengthLimitReached(_) => ErrorCode::StringLengthLimitReached, AppError::InvalidContentType(_) => ErrorCode::InvalidContentType, AppError::InvalidPublishedOutline(_) => ErrorCode::InvalidPublishedOutline, + AppError::InvalidFolderView(_) => ErrorCode::InvalidFolderView, } } } @@ -323,6 +327,7 @@ pub enum ErrorCode { SingleUploadLimitExceeded = 1037, AppleRevokeTokenError = 1038, InvalidPublishedOutline = 1039, + InvalidFolderView = 1040, } impl ErrorCode { diff --git a/libs/client-api/src/http.rs b/libs/client-api/src/http.rs index e3823c3b..5a18a30f 100644 --- a/libs/client-api/src/http.rs +++ b/libs/client-api/src/http.rs @@ -687,12 +687,16 @@ impl Client { &self, workspace_id: &str, depth: Option, + root_view_id: Option, ) -> Result { let url = format!("{}/api/workspace/{}/folder", self.base_url, workspace_id); let resp = self .http_client_with_auth(Method::GET, &url) .await? - .query(&QueryWorkspaceFolder { depth }) + .query(&QueryWorkspaceFolder { + depth, + root_view_id, + }) .send() .await?; log_request_id(&resp); diff --git a/libs/shared-entity/src/dto/workspace_dto.rs b/libs/shared-entity/src/dto/workspace_dto.rs index b860ae10..bb2a013f 100644 --- a/libs/shared-entity/src/dto/workspace_dto.rs +++ b/libs/shared-entity/src/dto/workspace_dto.rs @@ -135,6 +135,10 @@ pub struct FolderView { pub icon: Option, pub is_space: bool, pub is_private: bool, + pub is_published: bool, + pub layout: ViewLayout, + pub created_at: DateTime, + pub last_edited_time: DateTime, /// contains fields like `is_space`, and font information pub extra: Option, pub children: Vec, @@ -178,6 +182,7 @@ pub struct QueryWorkspaceParam { #[derive(Default, Debug, Deserialize, Serialize)] pub struct QueryWorkspaceFolder { pub depth: Option, + pub root_view_id: Option, } #[derive(Default, Debug, Clone, Serialize, Deserialize)] diff --git a/src/api/workspace.rs b/src/api/workspace.rs index 2c201a61..08655e1d 100644 --- a/src/api/workspace.rs +++ b/src/api/workspace.rs @@ -1298,17 +1298,25 @@ async fn get_workspace_usage_handler( async fn get_workspace_folder_handler( user_uuid: UserUuid, - workspace_id: web::Path, + workspace_id: web::Path, state: Data, query: web::Query, ) -> Result>> { let depth = query.depth.unwrap_or(1); let uid = state.user_cache.get_user_uid(&user_uuid).await?; + let workspace_id = workspace_id.into_inner(); + let root_view_id = if let Some(root_view_id) = query.root_view_id.as_ref() { + root_view_id.to_string() + } else { + workspace_id.to_string() + }; let folder_view = biz::collab::ops::get_user_workspace_structure( state.collab_access_control_storage.clone(), + &state.pg_pool, uid, - workspace_id.into_inner(), + workspace_id, depth, + &root_view_id, ) .await?; Ok(Json(AppResponse::Ok().with_data(folder_view))) diff --git a/src/biz/collab/folder_view.rs b/src/biz/collab/folder_view.rs index a4c01a27..547fc0bc 100644 --- a/src/biz/collab/folder_view.rs +++ b/src/biz/collab/folder_view.rs @@ -1,10 +1,17 @@ use std::collections::HashSet; +use app_error::AppError; +use chrono::DateTime; use collab_folder::{Folder, ViewLayout as CollabFolderViewLayout}; use shared_entity::dto::workspace_dto::{FolderView, ViewLayout}; -use uuid::Uuid; -pub fn collab_folder_to_folder_view(folder: &Folder, depth: u32) -> FolderView { +/// Return all folders belonging to a workspace, excluding private sections which the user does not have access to. +pub fn collab_folder_to_folder_view( + root_view_id: &str, + folder: &Folder, + max_depth: u32, + pubished_view_ids: &HashSet, +) -> Result { let mut unviewable = HashSet::new(); for private_section in folder.get_all_private_sections() { unviewable.insert(private_section.id); @@ -13,117 +20,99 @@ pub fn collab_folder_to_folder_view(folder: &Folder, depth: u32) -> FolderView { unviewable.insert(trash_view.id); } - let mut private_views = HashSet::new(); + let mut private_view_ids = HashSet::new(); for private_section in folder.get_my_private_sections() { unviewable.remove(&private_section.id); - private_views.insert(private_section.id); + private_view_ids.insert(private_section.id); } - let workspace_id = folder.get_workspace_id().unwrap_or_else(|| { - tracing::error!("failed to get workspace_id"); - Uuid::nil().to_string() - }); - let root = match folder.get_view(&workspace_id) { - Some(root) => root, + to_folder_view( + "", + root_view_id, + folder, + &unviewable, + &private_view_ids, + pubished_view_ids, + false, + 0, + max_depth, + ) + .ok_or(AppError::InvalidFolderView(format!( + "There is no valid folder view belonging to the root view id: {}", + root_view_id + ))) +} + +#[allow(clippy::too_many_arguments)] +fn to_folder_view( + parent_view_id: &str, + view_id: &str, + folder: &Folder, + unviewable: &HashSet, + private_view_ids: &HashSet, + published_view_ids: &HashSet, + parent_is_private: bool, + depth: u32, + max_depth: u32, +) -> Option { + if depth > max_depth || unviewable.contains(view_id) { + return None; + } + + let view = match folder.get_view(view_id) { + Some(view) => view, None => { - tracing::error!("failed to get root view, workspace_id: {}", workspace_id); - return FolderView::default(); + return None; }, }; - let extra = root.extra.as_deref().map(|extra| { + // There is currently a bug, in which the parent_view_id is not always set correctly + if !(parent_view_id.is_empty() || view.parent_view_id == parent_view_id) { + return None; + } + + let is_private = + parent_is_private || (view_is_space(&view) && private_view_ids.contains(view_id)); + let extra = view.extra.as_deref().map(|extra| { serde_json::from_str::(extra).unwrap_or_else(|e| { - tracing::error!("failed to parse extra field({}): {}", extra, e); + tracing::warn!("failed to parse extra field({}): {}", extra, e); serde_json::Value::Null }) }); - - FolderView { - view_id: root.id.clone(), - name: root.name.clone(), - icon: root + let children: Vec = view + .children + .iter() + .filter_map(|child_view_id| { + to_folder_view( + view_id, + &child_view_id.id, + folder, + unviewable, + private_view_ids, + published_view_ids, + is_private, + depth + 1, + max_depth, + ) + }) + .collect(); + Some(FolderView { + view_id: view_id.to_string(), + name: view.name.clone(), + icon: view .icon .as_ref() .map(|icon| to_dto_view_icon(icon.clone())), - is_space: false, - is_private: false, + is_space: view_is_space(&view), + is_private, + is_published: published_view_ids.contains(view_id), + layout: to_view_layout(&view.layout), + created_at: DateTime::from_timestamp(view.created_at, 0).unwrap_or(DateTime::default()), + last_edited_time: DateTime::from_timestamp(view.last_edited_time, 0) + .unwrap_or(DateTime::default()), extra, - children: if depth == 0 { - vec![] - } else { - root - .children - .iter() - .filter(|v| !unviewable.contains(&v.id)) - .map(|v| { - let intermediate = FolderViewIntermediate { - folder, - view_id: &v.id, - unviewable: &unviewable, - private_views: &private_views, - depth, - }; - FolderView::from(intermediate) - }) - .collect() - }, - } -} - -struct FolderViewIntermediate<'a> { - folder: &'a Folder, - view_id: &'a str, - unviewable: &'a HashSet, - private_views: &'a HashSet, - depth: u32, -} - -impl<'a> From> for FolderView { - fn from(fv: FolderViewIntermediate) -> Self { - let view = match fv.folder.get_view(fv.view_id) { - Some(view) => view, - None => { - tracing::error!("failed to get view, view_id: {}", fv.view_id); - return Self::default(); - }, - }; - let extra = view.extra.as_deref().map(|extra| { - serde_json::from_str::(extra).unwrap_or_else(|e| { - tracing::error!("failed to parse extra field({}): {}", extra, e); - serde_json::Value::Null - }) - }); - - Self { - view_id: view.id.clone(), - name: view.name.clone(), - icon: view - .icon - .as_ref() - .map(|icon| to_dto_view_icon(icon.clone())), - is_space: view_is_space(&view), - is_private: fv.private_views.contains(&view.id), - extra, - children: if fv.depth == 1 { - vec![] - } else { - view - .children - .iter() - .filter(|v| !fv.unviewable.contains(&v.id)) - .map(|v| { - FolderView::from(FolderViewIntermediate { - folder: fv.folder, - view_id: &v.id, - unviewable: fv.unviewable, - private_views: fv.private_views, - depth: fv.depth - 1, - }) - }) - .collect() - }, - } - } + children, + }) } pub fn view_is_space(view: &collab_folder::View) -> bool { diff --git a/src/biz/collab/ops.rs b/src/biz/collab/ops.rs index 266110ad..7e055373 100644 --- a/src/biz/collab/ops.rs +++ b/src/biz/collab/ops.rs @@ -158,9 +158,11 @@ pub async fn get_collab_member_list( pub async fn get_user_workspace_structure( collab_storage: Arc, + pg_pool: &PgPool, uid: i64, - workspace_id: String, + workspace_id: Uuid, depth: u32, + root_view_id: &str, ) -> Result { let depth_limit = 10; if depth > depth_limit { @@ -169,10 +171,18 @@ pub async fn get_user_workspace_structure( depth, depth_limit ))); } - let folder = - get_latest_collab_folder(collab_storage, GetCollabOrigin::User { uid }, &workspace_id).await?; - let folder_view: FolderView = collab_folder_to_folder_view(&folder, depth); - Ok(folder_view) + let folder = get_latest_collab_folder( + collab_storage, + GetCollabOrigin::User { uid }, + &workspace_id.to_string(), + ) + .await?; + let publish_view_ids = select_published_view_ids_for_workspace(pg_pool, workspace_id).await?; + let publish_view_ids: HashSet = publish_view_ids + .into_iter() + .map(|id| id.to_string()) + .collect(); + collab_folder_to_folder_view(root_view_id, &folder, depth, &publish_view_ids) } pub async fn get_latest_collab_folder( @@ -245,6 +255,6 @@ pub async fn get_published_view( .map(|id| id.to_string()) .collect(); let published_view: PublishedView = - collab_folder_to_published_outline(&folder, &publish_view_ids)?; + collab_folder_to_published_outline(&workspace_id.to_string(), &folder, &publish_view_ids)?; Ok(published_view) } diff --git a/src/biz/collab/publish_outline.rs b/src/biz/collab/publish_outline.rs index 10b5b75f..6245cdf3 100644 --- a/src/biz/collab/publish_outline.rs +++ b/src/biz/collab/publish_outline.rs @@ -6,7 +6,10 @@ use shared_entity::dto::workspace_dto::PublishedView; use super::folder_view::{to_dto_view_icon, to_view_layout}; +/// Returns only folders that are published, or one of the nested subfolders is published. +/// Exclude folders that are in the trash. pub fn collab_folder_to_published_outline( + root_view_id: &str, folder: &Folder, publish_view_ids: &HashSet, ) -> Result { @@ -15,55 +18,20 @@ pub fn collab_folder_to_published_outline( unviewable.insert(trash_view.id); } - let workspace_id = folder - .get_workspace_id() - .ok_or_else(|| AppError::InvalidPublishedOutline("failed to get workspace_id".to_string()))?; - let root = match folder.get_view(&workspace_id) { - Some(root) => root, - None => { - return Err(AppError::InvalidPublishedOutline( - "failed to get root view".to_string(), - )); - }, - }; - - let extra = root.extra.as_deref().map(|extra| { - serde_json::from_str::(extra).unwrap_or_else(|e| { - tracing::warn!("failed to parse extra field({}): {}", extra, e); - serde_json::Value::Null - }) - }); - - // Set a reasonable max depth to prevent execessive recursion let max_depth = 10; - let published_view = PublishedView { - view_id: root.id.clone(), - name: root.name.clone(), - icon: root - .icon - .as_ref() - .map(|icon| to_dto_view_icon(icon.clone())), - layout: to_view_layout(&root.layout), - is_published: false, - extra, - children: root - .children - .iter() - .filter(|v| !unviewable.contains(&v.id)) - .filter_map(|v| { - to_publish_view( - &root.id, - &v.id, - folder, - &unviewable, - publish_view_ids, - 0, - max_depth, - ) - }) - .collect(), - }; - Ok(published_view) + to_publish_view( + "", + root_view_id, + folder, + &unviewable, + publish_view_ids, + 0, + max_depth, + ) + .ok_or(AppError::InvalidPublishedOutline(format!( + "failed to get published outline for root view id: {}", + root_view_id + ))) } fn to_publish_view( @@ -75,7 +43,7 @@ fn to_publish_view( depth: u32, max_depth: u32, ) -> Option { - if depth > max_depth { + if depth > max_depth || unviewable.contains(view_id) { return None; } @@ -87,7 +55,7 @@ fn to_publish_view( }; // There is currently a bug, in which the parent_view_id is not always set correctly - if view.parent_view_id != parent_view_id { + if !(parent_view_id.is_empty() || view.parent_view_id == parent_view_id) { return None; } @@ -100,7 +68,6 @@ fn to_publish_view( let pruned_view: Vec = view .children .iter() - .filter(|v| !unviewable.contains(&v.id)) .filter_map(|child_view_id| { to_publish_view( view_id, @@ -114,7 +81,7 @@ fn to_publish_view( }) .collect(); let is_published = publish_view_ids.contains(view_id); - if is_published || !pruned_view.is_empty() { + if parent_view_id.is_empty() || is_published || !pruned_view.is_empty() { Some(PublishedView { view_id: view.id.clone(), name: view.name.clone(), diff --git a/tests/workspace/publish.rs b/tests/workspace/publish.rs index e75e768b..254dd1ae 100644 --- a/tests/workspace/publish.rs +++ b/tests/workspace/publish.rs @@ -740,7 +740,7 @@ async fn duplicate_to_workspace_references() { let workspace_id_2 = client_2.workspace_id().await; let fv = client_2 .api_client - .get_workspace_folder(&workspace_id_2, Some(5)) + .get_workspace_folder(&workspace_id_2, Some(5), None) .await .unwrap(); @@ -765,7 +765,7 @@ async fn duplicate_to_workspace_references() { let fv = client_2 .api_client - .get_workspace_folder(&workspace_id_2, Some(5)) + .get_workspace_folder(&workspace_id_2, Some(5), None) .await .unwrap(); @@ -847,7 +847,7 @@ async fn duplicate_to_workspace_doc_inline_database() { let fv = client_2 .api_client - .get_workspace_folder(&workspace_id_2, Some(5)) + .get_workspace_folder(&workspace_id_2, Some(5), None) .await .unwrap(); @@ -873,7 +873,7 @@ async fn duplicate_to_workspace_doc_inline_database() { { let fv = client_2 .api_client - .get_workspace_folder(&workspace_id_2, Some(5)) + .get_workspace_folder(&workspace_id_2, Some(5), None) .await .unwrap(); let doc_3_fv = fv @@ -913,7 +913,8 @@ async fn duplicate_to_workspace_doc_inline_database() { ) .unwrap(); - let folder_view = collab_folder_to_folder_view(&folder, 5); + let folder_view = + collab_folder_to_folder_view(&workspace_id_2, &folder, 5, &HashSet::default()).unwrap(); let doc_3_fv = folder_view .children .into_iter() @@ -1040,7 +1041,7 @@ async fn duplicate_to_workspace_db_embedded_in_doc() { let fv = client_2 .api_client - .get_workspace_folder(&workspace_id_2, Some(5)) + .get_workspace_folder(&workspace_id_2, Some(5), None) .await .unwrap(); @@ -1064,7 +1065,7 @@ async fn duplicate_to_workspace_db_embedded_in_doc() { { let fv = client_2 .api_client - .get_workspace_folder(&workspace_id_2, Some(5)) + .get_workspace_folder(&workspace_id_2, Some(5), None) .await .unwrap(); let doc_with_embedded_db = fv @@ -1151,7 +1152,7 @@ async fn duplicate_to_workspace_db_with_relation() { let fv = client_2 .api_client - .get_workspace_folder(&workspace_id_2, Some(5)) + .get_workspace_folder(&workspace_id_2, Some(5), None) .await .unwrap(); @@ -1178,7 +1179,7 @@ async fn duplicate_to_workspace_db_with_relation() { { let fv = client_2 .api_client - .get_workspace_folder(&workspace_id_2, Some(5)) + .get_workspace_folder(&workspace_id_2, Some(5), None) .await .unwrap(); let db_with_rel_col = fv diff --git a/tests/workspace/workspace_folder.rs b/tests/workspace/workspace_folder.rs index c0931bbd..8b3738c1 100644 --- a/tests/workspace/workspace_folder.rs +++ b/tests/workspace/workspace_folder.rs @@ -7,8 +7,27 @@ async fn get_workpace_folder() { assert_eq!(workspaces.len(), 1); let workspace_id = workspaces[0].workspace_id.to_string(); - let folder_view = c.get_workspace_folder(&workspace_id, None).await.unwrap(); + let folder_view = c + .get_workspace_folder(&workspace_id, None, None) + .await + .unwrap(); assert_eq!(folder_view.name, "Workspace"); assert_eq!(folder_view.children[0].name, "General"); assert_eq!(folder_view.children[0].children.len(), 0); + let folder_view = c + .get_workspace_folder(&workspace_id, Some(2), None) + .await + .unwrap(); + assert_eq!(folder_view.name, "Workspace"); + assert_eq!(folder_view.children[0].name, "General"); + assert_eq!(folder_view.children[0].children.len(), 2); + let folder_view = c + .get_workspace_folder( + &workspace_id, + Some(1), + Some(folder_view.children[0].view_id.clone()), + ) + .await + .unwrap(); + assert_eq!(folder_view.children.len(), 2); }