diff --git a/libs/client-api/src/http.rs b/libs/client-api/src/http.rs index 5a18a30f..2d65565c 100644 --- a/libs/client-api/src/http.rs +++ b/libs/client-api/src/http.rs @@ -4,6 +4,7 @@ use client_api_entity::auth_dto::DeleteUserQuery; use client_api_entity::workspace_dto::FolderView; use client_api_entity::workspace_dto::QueryWorkspaceFolder; use client_api_entity::workspace_dto::QueryWorkspaceParam; +use client_api_entity::workspace_dto::SectionItems; use client_api_entity::AuthProvider; use client_api_entity::CollabType; use gotrue::grant::PasswordGrant; @@ -719,6 +720,57 @@ impl Client { .into_data() } + #[instrument(level = "info", skip_all, err)] + pub async fn get_workspace_favorite( + &self, + workspace_id: &str, + ) -> Result { + let url = format!("{}/api/workspace/{}/favorite", self.base_url, workspace_id); + let resp = self + .http_client_with_auth(Method::GET, &url) + .await? + .send() + .await?; + log_request_id(&resp); + AppResponse::::from_response(resp) + .await? + .into_data() + } + + #[instrument(level = "info", skip_all, err)] + pub async fn get_workspace_recent( + &self, + workspace_id: &str, + ) -> Result { + let url = format!("{}/api/workspace/{}/recent", self.base_url, workspace_id); + let resp = self + .http_client_with_auth(Method::GET, &url) + .await? + .send() + .await?; + log_request_id(&resp); + AppResponse::::from_response(resp) + .await? + .into_data() + } + + #[instrument(level = "info", skip_all, err)] + pub async fn get_workspace_trash( + &self, + workspace_id: &str, + ) -> Result { + let url = format!("{}/api/workspace/{}/trash", self.base_url, workspace_id); + let resp = self + .http_client_with_auth(Method::GET, &url) + .await? + .send() + .await?; + log_request_id(&resp); + AppResponse::::from_response(resp) + .await? + .into_data() + } + #[instrument(skip_all, err)] pub async fn sign_in_password( &self, diff --git a/libs/shared-entity/src/dto/workspace_dto.rs b/libs/shared-entity/src/dto/workspace_dto.rs index bb2a013f..31d774cb 100644 --- a/libs/shared-entity/src/dto/workspace_dto.rs +++ b/libs/shared-entity/src/dto/workspace_dto.rs @@ -144,6 +144,11 @@ pub struct FolderView { pub children: Vec, } +#[derive(Default, Debug, Clone, Serialize, Deserialize)] +pub struct SectionItems { + pub views: Vec, +} + #[derive(Eq, PartialEq, Debug, Hash, Clone, Serialize_repr, Deserialize_repr)] #[repr(u8)] pub enum IconType { diff --git a/src/api/workspace.rs b/src/api/workspace.rs index 08655e1d..2570dc53 100644 --- a/src/api/workspace.rs +++ b/src/api/workspace.rs @@ -38,6 +38,9 @@ use crate::api::util::PayloadReader; use crate::api::util::{compress_type_from_header_value, device_id_from_headers, CollabValidator}; use crate::api::ws::RealtimeServerAddr; use crate::biz; +use crate::biz::collab::ops::{ + get_user_favorite_folder_views, get_user_recent_folder_views, get_user_trash_folder_views, +}; use crate::biz::workspace; use crate::biz::workspace::ops::{ create_comment_on_published_view, create_reaction_on_comment, get_comments_on_published_view, @@ -174,6 +177,11 @@ pub fn workspace_scope() -> Scope { .service( web::resource("/{workspace_id}/folder").route(web::get().to(get_workspace_folder_handler)), ) + .service(web::resource("/{workspace_id}/recent").route(web::get().to(get_recent_views_handler))) + .service( + web::resource("/{workspace_id}/favorite").route(web::get().to(get_favorite_views_handler)), + ) + .service(web::resource("/{workspace_id}/trash").route(web::get().to(get_trash_views_handler))) .service( web::resource("/published-outline/{publish_namespace}") .route(web::get().to(get_workspace_publish_outline_handler)), @@ -1322,6 +1330,65 @@ async fn get_workspace_folder_handler( Ok(Json(AppResponse::Ok().with_data(folder_view))) } +async fn get_recent_views_handler( + user_uuid: UserUuid, + workspace_id: web::Path, + state: Data, +) -> Result>> { + let uid = state.user_cache.get_user_uid(&user_uuid).await?; + let workspace_id = workspace_id.into_inner(); + let folder_views = get_user_recent_folder_views( + state.collab_access_control_storage.clone(), + &state.pg_pool, + uid, + workspace_id, + ) + .await?; + let section_items = SectionItems { + views: folder_views, + }; + Ok(Json(AppResponse::Ok().with_data(section_items))) +} + +async fn get_favorite_views_handler( + user_uuid: UserUuid, + workspace_id: web::Path, + state: Data, +) -> Result>> { + let uid = state.user_cache.get_user_uid(&user_uuid).await?; + let workspace_id = workspace_id.into_inner(); + let folder_views = get_user_favorite_folder_views( + state.collab_access_control_storage.clone(), + &state.pg_pool, + uid, + workspace_id, + ) + .await?; + let section_items = SectionItems { + views: folder_views, + }; + Ok(Json(AppResponse::Ok().with_data(section_items))) +} + +async fn get_trash_views_handler( + user_uuid: UserUuid, + workspace_id: web::Path, + state: Data, +) -> Result>> { + let uid = state.user_cache.get_user_uid(&user_uuid).await?; + let workspace_id = workspace_id.into_inner(); + let folder_views = get_user_trash_folder_views( + state.collab_access_control_storage.clone(), + uid, + workspace_id, + ) + .await?; + let section_items = SectionItems { + views: folder_views, + }; + Ok(Json(AppResponse::Ok().with_data(section_items))) +} + async fn get_workspace_publish_outline_handler( publish_namespace: web::Path, state: Data, diff --git a/src/biz/collab/folder_view.rs b/src/biz/collab/folder_view.rs index 547fc0bc..f2ffd6d4 100644 --- a/src/biz/collab/folder_view.rs +++ b/src/biz/collab/folder_view.rs @@ -2,7 +2,7 @@ use std::collections::HashSet; use app_error::AppError; use chrono::DateTime; -use collab_folder::{Folder, ViewLayout as CollabFolderViewLayout}; +use collab_folder::{Folder, SectionItem, ViewLayout as CollabFolderViewLayout}; use shared_entity::dto::workspace_dto::{FolderView, ViewLayout}; /// Return all folders belonging to a workspace, excluding private sections which the user does not have access to. @@ -107,14 +107,39 @@ fn to_folder_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()), + created_at: DateTime::from_timestamp(view.created_at, 0).unwrap_or_default(), + last_edited_time: DateTime::from_timestamp(view.last_edited_time, 0).unwrap_or_default(), extra, children, }) } +pub fn section_items_to_folder_view( + section_items: &[SectionItem], + folder: &Folder, + published_view_ids: &HashSet, +) -> Vec { + section_items + .iter() + .filter_map(|section_item| { + let view = folder.get_view(§ion_item.id); + view.map(|v| FolderView { + view_id: v.id.clone(), + name: v.name.clone(), + icon: v.icon.as_ref().map(|icon| to_dto_view_icon(icon.clone())), + is_space: false, + is_private: false, + is_published: published_view_ids.contains(&v.id), + created_at: DateTime::from_timestamp(v.created_at, 0).unwrap_or_default(), + last_edited_time: DateTime::from_timestamp(v.last_edited_time, 0).unwrap_or_default(), + layout: to_view_layout(&v.layout), + extra: v.extra.as_ref().map(|e| parse_extra_field_as_json(e)), + children: vec![], + }) + }) + .collect() +} + pub fn view_is_space(view: &collab_folder::View) -> bool { let extra = match view.extra.as_ref() { Some(extra) => extra, @@ -133,6 +158,13 @@ pub fn view_is_space(view: &collab_folder::View) -> bool { } } +pub fn parse_extra_field_as_json(extra: &str) -> serde_json::Value { + serde_json::from_str::(extra).unwrap_or_else(|e| { + tracing::warn!("failed to parse extra field({}): {}", extra, e); + serde_json::Value::Null + }) +} + pub fn to_dto_view_icon( icon: collab_folder::ViewIcon, ) -> shared_entity::dto::workspace_dto::ViewIcon { diff --git a/src/biz/collab/ops.rs b/src/biz/collab/ops.rs index 7e055373..f81dea24 100644 --- a/src/biz/collab/ops.rs +++ b/src/biz/collab/ops.rs @@ -4,6 +4,7 @@ use app_error::AppError; use appflowy_collaborate::collab::storage::CollabAccessControlStorage; use collab_entity::CollabType; use collab_entity::EncodedCollab; +use collab_folder::SectionItem; use collab_folder::{CollabOrigin, Folder}; use database::collab::{CollabStorage, GetCollabOrigin}; use database::publish::select_published_view_ids_for_workspace; @@ -27,6 +28,7 @@ use database_entity::dto::{ }; use super::folder_view::collab_folder_to_folder_view; +use super::folder_view::section_items_to_folder_view; use super::publish_outline::collab_folder_to_published_outline; /// Create a new collab member @@ -156,6 +158,93 @@ pub async fn get_collab_member_list( Ok(collab_member) } +pub async fn get_user_favorite_folder_views( + collab_storage: Arc, + pg_pool: &PgPool, + uid: i64, + workspace_id: Uuid, +) -> Result, AppError> { + 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(); + let deleted_section_item_ids: Vec = folder + .get_my_trash_sections() + .iter() + .map(|s| s.id.clone()) + .collect(); + let favorite_section_items: Vec = folder + .get_my_favorite_sections() + .into_iter() + .filter(|s| !deleted_section_item_ids.contains(&s.id)) + .collect(); + Ok(section_items_to_folder_view( + &favorite_section_items, + &folder, + &publish_view_ids, + )) +} + +pub async fn get_user_recent_folder_views( + collab_storage: Arc, + pg_pool: &PgPool, + uid: i64, + workspace_id: Uuid, +) -> Result, AppError> { + let folder = get_latest_collab_folder( + collab_storage, + GetCollabOrigin::User { uid }, + &workspace_id.to_string(), + ) + .await?; + let deleted_section_item_ids: Vec = folder + .get_my_trash_sections() + .iter() + .map(|s| s.id.clone()) + .collect(); + let recent_section_items: Vec = folder + .get_my_recent_sections() + .into_iter() + .filter(|s| !deleted_section_item_ids.contains(&s.id)) + .collect(); + 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(); + Ok(section_items_to_folder_view( + &recent_section_items, + &folder, + &publish_view_ids, + )) +} + +pub async fn get_user_trash_folder_views( + collab_storage: Arc, + uid: i64, + workspace_id: Uuid, +) -> Result, AppError> { + let folder = get_latest_collab_folder( + collab_storage, + GetCollabOrigin::User { uid }, + &workspace_id.to_string(), + ) + .await?; + let section_items = folder.get_my_trash_sections(); + Ok(section_items_to_folder_view( + §ion_items, + &folder, + &HashSet::default(), + )) +} + pub async fn get_user_workspace_structure( collab_storage: Arc, pg_pool: &PgPool, diff --git a/tests/workspace/workspace_folder.rs b/tests/workspace/workspace_folder.rs index 8b3738c1..1c4303a8 100644 --- a/tests/workspace/workspace_folder.rs +++ b/tests/workspace/workspace_folder.rs @@ -1,4 +1,7 @@ +use client_api::entity::{CreateCollabParams, QueryCollabParams}; use client_api_test::generate_unique_registered_user_client; +use collab::core::origin::CollabClient; +use collab_folder::{CollabOrigin, Folder}; #[tokio::test] async fn get_workpace_folder() { @@ -31,3 +34,65 @@ async fn get_workpace_folder() { .unwrap(); assert_eq!(folder_view.children.len(), 2); } + +#[tokio::test] +async fn get_section_items() { + let (c, _user) = generate_unique_registered_user_client().await; + let user_workspace_info = c.get_user_workspace_info().await.unwrap(); + let workspaces = c.get_workspaces().await.unwrap(); + assert_eq!(workspaces.len(), 1); + let workspace_id = workspaces[0].workspace_id.to_string(); + let folder_collab = c + .get_collab(QueryCollabParams::new( + workspace_id.clone(), + collab_entity::CollabType::Folder, + workspace_id.clone(), + )) + .await + .unwrap() + .encode_collab; + let uid = user_workspace_info.user_profile.uid; + let mut folder = Folder::from_collab_doc_state( + uid, + CollabOrigin::Client(CollabClient::new(uid, c.device_id.clone())), + folder_collab.into(), + &workspace_id, + vec![], + ) + .unwrap(); + let views = folder.get_views_belong_to(&workspace_id); + let new_favorite_id = views[0].children[0].id.clone(); + let to_be_deleted_favorite_id = views[0].children[1].id.clone(); + folder.add_favorite_view_ids(vec![ + new_favorite_id.clone(), + to_be_deleted_favorite_id.clone(), + ]); + folder.add_trash_view_ids(vec![to_be_deleted_favorite_id.clone()]); + let recent_id = folder.get_views_belong_to(&new_favorite_id)[0].id.clone(); + folder.add_recent_view_ids(vec![recent_id.clone()]); + let collab_type = collab_entity::CollabType::Folder; + c.update_collab(CreateCollabParams { + workspace_id: workspace_id.clone(), + collab_type: collab_type.clone(), + object_id: workspace_id.clone(), + encoded_collab_v1: folder + .encode_collab_v1(|collab| collab_type.validate_require_data(collab)) + .unwrap() + .encode_to_bytes() + .unwrap(), + }) + .await + .unwrap(); + let favorite_section_items = c.get_workspace_favorite(&workspace_id).await.unwrap(); + assert_eq!(favorite_section_items.views.len(), 1); + assert_eq!(favorite_section_items.views[0].view_id, new_favorite_id); + let trash_section_items = c.get_workspace_trash(&workspace_id).await.unwrap(); + assert_eq!(trash_section_items.views.len(), 1); + assert_eq!( + trash_section_items.views[0].view_id, + to_be_deleted_favorite_id + ); + let recent_section_items = c.get_workspace_recent(&workspace_id).await.unwrap(); + assert_eq!(recent_section_items.views.len(), 1); + assert_eq!(recent_section_items.views[0].view_id, recent_id); +}