feat: api for publish page to web (#1108)

This commit is contained in:
Khor Shu Heng 2025-01-03 21:28:48 +08:00 committed by GitHub
parent a61a5c58cc
commit ab0fa6e7fc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 481 additions and 23 deletions

View File

@ -1,5 +1,5 @@
use bytes::Bytes;
use client_api_entity::workspace_dto::PublishInfoView;
use client_api_entity::workspace_dto::{PublishInfoView, PublishedView};
use client_api_entity::{workspace_dto::PublishedDuplicate, PublishInfo, UpdatePublishNamespace};
use client_api_entity::{
CreateGlobalCommentParams, CreateReactionParams, DeleteGlobalCommentParams, DeleteReactionParams,
@ -301,6 +301,29 @@ impl Client {
.into_data()
}
#[instrument(level = "debug", skip_all)]
pub async fn get_published_outline(
&self,
publish_namespace: &str,
) -> Result<PublishedView, AppResponseError> {
let url = format!(
"{}/api/workspace/published-outline/{}",
self.base_url, publish_namespace,
);
let resp = self
.cloud_client
.get(&url)
.send()
.await?
.error_for_status()?;
log_request_id(&resp);
AppResponse::<PublishedView>::from_response(resp)
.await?
.into_data()
}
#[instrument(level = "debug", skip_all)]
pub async fn get_default_published_collab<T>(
&self,

View File

@ -1,6 +1,6 @@
use client_api_entity::workspace_dto::{
CreatePageParams, CreateSpaceParams, MovePageParams, Page, PageCollab, Space, UpdatePageParams,
UpdateSpaceParams,
CreatePageParams, CreateSpaceParams, MovePageParams, Page, PageCollab, PublishPageParams, Space,
UpdatePageParams, UpdateSpaceParams,
};
use reqwest::Method;
use serde_json::json;
@ -169,6 +169,25 @@ impl Client {
.into_data()
}
pub async fn publish_page(
&self,
workspace_id: Uuid,
view_id: &str,
params: &PublishPageParams,
) -> Result<(), AppResponseError> {
let url = format!(
"{}/api/workspace/{}/page-view/{}/publish",
self.base_url, workspace_id, view_id
);
let resp = self
.http_client_with_auth(Method::POST, &url)
.await?
.json(params)
.send()
.await?;
AppResponse::<()>::from_response(resp).await?.into_error()
}
pub async fn create_space(
&self,
workspace_id: Uuid,

View File

@ -281,6 +281,12 @@ pub struct PublishInfoView {
pub info: PublishInfo,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct PublishPageParams {
pub publish_name: Option<String>,
pub visible_database_view_ids: Option<Vec<String>>,
}
#[derive(Eq, PartialEq, Debug, Hash, Clone, Serialize_repr, Deserialize_repr)]
#[repr(u8)]
pub enum IconType {

View File

@ -13,8 +13,8 @@ use crate::biz::workspace::ops::{
};
use crate::biz::workspace::page_view::{
create_page, create_space, delete_all_pages_from_trash, delete_trash, get_page_view_collab,
move_page, move_page_to_trash, restore_all_pages_from_trash, restore_page_from_trash,
update_page, update_page_collab_data, update_space,
move_page, move_page_to_trash, publish_page, restore_all_pages_from_trash,
restore_page_from_trash, update_page, update_page_collab_data, update_space,
};
use crate::biz::workspace::publish::get_workspace_default_publish_view_info_meta;
use crate::biz::workspace::quick_note::{
@ -189,6 +189,10 @@ pub fn workspace_scope() -> Scope {
web::resource("/{workspace_id}/delete-all-pages-from-trash")
.route(web::post().to(delete_all_pages_from_trash_handler)),
)
.service(
web::resource("/{workspace_id}/page-view/{view_id}/publish")
.route(web::post().to(publish_page_handler)),
)
.service(
web::resource("/{workspace_id}/batch/collab")
.route(web::post().to(batch_create_collab_handler)),
@ -1272,6 +1276,37 @@ async fn delete_all_pages_from_trash_handler(
Ok(Json(AppResponse::Ok()))
}
async fn publish_page_handler(
user_uuid: UserUuid,
path: web::Path<(Uuid, String)>,
payload: Json<PublishPageParams>,
state: Data<AppState>,
) -> Result<Json<AppResponse<()>>> {
let (workspace_id, view_id) = path.into_inner();
let uid = state
.user_cache
.get_user_uid(&user_uuid)
.await
.map_err(AppResponseError::from)?;
let PublishPageParams {
publish_name,
visible_database_view_ids,
} = payload.into_inner();
publish_page(
&state.pg_pool,
&state.collab_access_control_storage,
state.published_collab_store.as_ref(),
uid,
*user_uuid,
workspace_id,
&view_id,
visible_database_view_ids,
publish_name,
)
.await?;
Ok(Json(AppResponse::Ok()))
}
async fn update_page_view_handler(
user_uuid: UserUuid,
path: web::Path<(Uuid, String)>,

View File

@ -76,14 +76,12 @@ use super::folder_view::section_items_to_recent_folder_view;
use super::folder_view::section_items_to_trash_folder_view;
use super::folder_view::to_dto_folder_view_miminal;
use super::publish_outline::collab_folder_to_published_outline;
use super::utils::collab_from_doc_state;
use super::utils::collab_to_bin;
use super::utils::create_row_document;
use super::utils::field_by_id_name_uniq;
use super::utils::get_latest_collab;
use super::utils::get_latest_collab_database_body;
use super::utils::get_latest_collab_database_row_body;
use super::utils::get_latest_collab_encoded;
use super::utils::get_latest_collab_folder;
use super::utils::get_row_details_serde;
use super::utils::type_option_reader_by_id;
@ -434,17 +432,14 @@ pub async fn get_latest_workspace_database(
workspace_id: Uuid,
) -> Result<(String, WorkspaceDatabase), AppError> {
let workspace_database_oid = select_workspace_database_oid(pg_pool, &workspace_id).await?;
let workspace_database_collab = {
let encoded_collab = get_latest_collab_encoded(
collab_storage,
collab_origin,
&workspace_id.to_string(),
&workspace_database_oid,
CollabType::WorkspaceDatabase,
)
.await?;
collab_from_doc_state(encoded_collab.doc_state.to_vec(), &workspace_database_oid)?
};
let workspace_database_collab = get_latest_collab(
collab_storage,
collab_origin,
&workspace_id.to_string(),
&workspace_database_oid,
CollabType::WorkspaceDatabase,
)
.await?;
let workspace_database = WorkspaceDatabase::open(workspace_database_collab)
.map_err(|err| AppError::Unhandled(format!("failed to open workspace database: {}", err)))?;

View File

@ -19,19 +19,26 @@ use collab_database::rows::RowId;
use collab_database::rows::RowMetaKey;
use collab_database::template::timestamp_parse::TimestampCellData;
use collab_database::workspace_database::NoPersistenceDatabaseCollabService;
use collab_database::workspace_database::WorkspaceDatabaseBody;
use collab_document::document::Document;
use collab_document::importer::md_importer::MDImporter;
use collab_entity::CollabType;
use collab_entity::EncodedCollab;
use collab_folder::CollabOrigin;
use collab_folder::Folder;
use database::collab::select_workspace_database_oid;
use database::collab::CollabStorage;
use database::collab::GetCollabOrigin;
use database_entity::dto::QueryCollab;
use database_entity::dto::QueryCollabParams;
use database_entity::dto::QueryCollabResult;
use rayon::iter::IntoParallelIterator;
use rayon::iter::ParallelIterator;
use sqlx::PgPool;
use std::collections::HashMap;
use std::collections::HashSet;
use std::sync::Arc;
use uuid::Uuid;
use yrs::Map;
pub const DEFAULT_SPACE_ICON: &str = "interface_essential/home-3";
@ -262,6 +269,53 @@ pub async fn get_latest_collab_encoded(
.await
}
pub async fn batch_get_latest_collab_encoded(
collab_storage: &CollabAccessControlStorage,
collab_origin: GetCollabOrigin,
workspace_id: &str,
oid_list: &[String],
collab_type: CollabType,
) -> Result<HashMap<String, EncodedCollab>, AppError> {
let uid = match collab_origin {
GetCollabOrigin::User { uid } => uid,
_ => 0,
};
let queries: Vec<QueryCollab> = oid_list
.iter()
.map(|row_id| QueryCollab {
object_id: row_id.to_string(),
collab_type: collab_type.clone(),
})
.collect();
let query_collab_results = collab_storage
.batch_get_collab(&uid, workspace_id, queries, true)
.await;
let encoded_collabs = tokio::task::spawn_blocking(move || {
let collabs: HashMap<String, EncodedCollab> = query_collab_results
.into_par_iter()
.filter_map(|(oid, query_collab_result)| match query_collab_result {
QueryCollabResult::Success { encode_collab_v1 } => {
let decoded_result = EncodedCollab::decode_from_bytes(&encode_collab_v1);
match decoded_result {
Ok(decoded) => Some((oid, decoded)),
Err(err) => {
tracing::error!("Failed to decode collab for row {}: {}", oid, err);
None
},
}
},
QueryCollabResult::Failed { error } => {
tracing::error!("Failed to get collab: {:?}", error);
None
},
})
.collect();
collabs
})
.await?;
Ok(encoded_collabs)
}
pub async fn get_latest_collab(
storage: &CollabAccessControlStorage,
origin: GetCollabOrigin,
@ -280,6 +334,31 @@ pub async fn get_latest_collab(
Ok(collab)
}
pub async fn get_latest_collab_workspace_database_body(
pg_pool: &PgPool,
storage: &CollabAccessControlStorage,
origin: GetCollabOrigin,
workspace_id: &str,
) -> Result<WorkspaceDatabaseBody, AppError> {
let workspace_uuid = Uuid::parse_str(workspace_id)?;
let ws_db_oid = select_workspace_database_oid(pg_pool, &workspace_uuid).await?;
let mut collab = get_latest_collab(
storage,
origin,
workspace_id,
&ws_db_oid,
CollabType::WorkspaceDatabase,
)
.await?;
let ws_db = WorkspaceDatabaseBody::open(&mut collab).map_err(|err| {
AppError::Internal(anyhow::anyhow!(
"Failed to open workspace database body: {}",
err
))
})?;
Ok(ws_db)
}
pub async fn get_latest_collab_folder(
collab_storage: &CollabAccessControlStorage,
collab_origin: GetCollabOrigin,
@ -344,6 +423,21 @@ pub async fn collab_to_bin(collab: Collab, collab_type: CollabType) -> Result<Ve
.await?
}
pub async fn collab_to_doc_state(
collab: Collab,
collab_type: CollabType,
) -> Result<Vec<u8>, AppError> {
tokio::task::spawn_blocking(move || {
let bin = collab
.encode_collab_v1(|collab| collab_type.validate_require_data(collab))
.map_err(|e| AppError::Unhandled(e.to_string()))?
.doc_state
.to_vec();
Ok(bin)
})
.await?
}
pub fn collab_from_doc_state(doc_state: Vec<u8>, object_id: &str) -> Result<Collab, AppError> {
let collab = Collab::new_with_source(
CollabOrigin::Server,

View File

@ -6,7 +6,8 @@ use crate::biz::collab::folder_view::{
};
use crate::biz::collab::ops::get_latest_workspace_database;
use crate::biz::collab::utils::{
collab_from_doc_state, get_latest_collab_encoded, get_latest_collab_folder,
batch_get_latest_collab_encoded, collab_from_doc_state, collab_to_doc_state,
get_latest_collab_database_body, get_latest_collab_encoded, get_latest_collab_folder,
};
use actix_web::web::Data;
use anyhow::anyhow;
@ -41,21 +42,27 @@ use collab_rt_entity::user::RealtimeUser;
use database::collab::{select_workspace_database_oid, CollabStorage, GetCollabOrigin};
use database::publish::select_published_view_ids_for_workspace;
use database::user::select_web_user_from_uid;
use database_entity::dto::{CollabParams, QueryCollab, QueryCollabResult};
use database_entity::dto::{
CollabParams, PublishCollabItem, PublishCollabMetadata, QueryCollab, QueryCollabResult,
};
use fancy_regex::Regex;
use itertools::Itertools;
use rayon::iter::{IntoParallelIterator, ParallelIterator};
use serde_json::json;
use shared_entity::dto::publish_dto::{PublishDatabaseData, PublishViewInfo, PublishViewMetaData};
use shared_entity::dto::workspace_dto::{
FolderView, Page, PageCollab, PageCollabData, Space, SpacePermission, ViewIcon, ViewLayout,
};
use sqlx::PgPool;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::sync::{Arc, LazyLock};
use std::time::{Duration, Instant};
use tokio::time::timeout_at;
use tracing::instrument;
use uuid::Uuid;
use super::publish::PublishedCollabStore;
#[allow(clippy::too_many_arguments)]
pub async fn update_space(
appflowy_web_metrics: &AppFlowyWebMetrics,
@ -1036,6 +1043,217 @@ pub async fn update_page(
Ok(())
}
static INVALID_URL_CHARS: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[^\w-]").unwrap());
fn replace_invalid_url_chars(input: &str) -> String {
INVALID_URL_CHARS.replace_all(input, "-").to_string()
}
fn generate_publish_name(view_id: &str, name: &str) -> String {
let id_len = view_id.len();
let name = replace_invalid_url_chars(name);
let name_len = name.len();
// The backend limits the publish name to a maximum of 50 characters.
// If the combined length of the ID and the name exceeds 50 characters,
// we will truncate the name to ensure the final result is within the limit.
// The name should only contain alphanumeric characters and hyphens.
let result = format!(
"{}-{}",
&name[..std::cmp::min(49 - id_len, name_len)],
view_id
);
result
}
#[allow(clippy::too_many_arguments)]
pub async fn publish_page(
pg_pool: &PgPool,
collab_access_control_storage: &CollabAccessControlStorage,
publish_collab_store: &dyn PublishedCollabStore,
uid: i64,
user_uuid: Uuid,
workspace_id: Uuid,
view_id: &str,
visible_database_view_ids: Option<Vec<String>>,
publish_name: Option<impl ToString>,
) -> Result<(), AppError> {
let folder = get_latest_collab_folder(
collab_access_control_storage,
GetCollabOrigin::User { uid },
&workspace_id.to_string(),
)
.await?;
let view = folder
.get_view(view_id)
.ok_or(AppError::InvalidFolderView(format!(
"View {} not found",
view_id
)))?;
let icon = view
.icon
.as_ref()
.map(|icon| to_dto_view_icon(icon.clone()));
let metadata = PublishViewMetaData {
view: PublishViewInfo {
view_id: view_id.to_string(),
name: view.name.clone(),
icon,
layout: to_dto_view_layout(&view.layout),
extra: view.extra.clone(),
created_by: view.created_by,
last_edited_by: view.last_edited_by,
last_edited_time: view.last_edited_time,
created_at: view.created_at,
child_views: None,
},
// Note: The use of child views and ancestor views are going to be deprecated in
// appflowy web as there is now endpoint to obtain published outline.
child_views: vec![],
ancestor_views: vec![],
};
let publish_data = match view.layout {
collab_folder::ViewLayout::Document => {
generate_publish_data_for_document(collab_access_control_storage, uid, workspace_id, view_id)
.await
},
collab_folder::ViewLayout::Grid
| collab_folder::ViewLayout::Board
| collab_folder::ViewLayout::Calendar => {
generate_publish_data_for_database(
pg_pool,
collab_access_control_storage,
uid,
workspace_id,
view_id,
visible_database_view_ids,
)
.await
},
collab_folder::ViewLayout::Chat => Err(AppError::InvalidRequest(
"AI Chat cannot be published".to_string(),
)),
}?;
publish_collab_store
.publish_collabs(
vec![PublishCollabItem {
meta: PublishCollabMetadata {
view_id: Uuid::parse_str(view_id).unwrap(),
publish_name: publish_name
.map(|name| name.to_string())
.unwrap_or_else(|| generate_publish_name(view_id, &view.name)),
metadata: serde_json::value::to_value(metadata).unwrap(),
},
data: publish_data,
}],
&workspace_id,
&user_uuid,
)
.await?;
Ok(())
}
async fn generate_publish_data_for_document(
collab_access_control_storage: &CollabAccessControlStorage,
uid: i64,
workspace_id: Uuid,
view_id: &str,
) -> Result<Vec<u8>, AppError> {
let collab = get_latest_collab_encoded(
collab_access_control_storage,
GetCollabOrigin::User { uid },
&workspace_id.to_string(),
view_id,
CollabType::Document,
)
.await?;
Ok(collab.doc_state.to_vec())
}
async fn generate_publish_data_for_database(
pg_pool: &PgPool,
collab_storage: &CollabAccessControlStorage,
uid: i64,
workspace_id: Uuid,
view_id: &str,
visible_database_view_ids: Option<Vec<String>>,
) -> Result<Vec<u8>, AppError> {
let (_, ws_db) = get_latest_workspace_database(
collab_storage,
pg_pool,
GetCollabOrigin::User { uid },
workspace_id,
)
.await?;
let db_oid = {
ws_db
.get_database_meta_with_view_id(view_id)
.ok_or(AppError::NoRequiredData(format!(
"Database view {} not found",
view_id
)))?
.database_id
};
let (db_collab, db_body) =
get_latest_collab_database_body(collab_storage, &workspace_id.to_string(), &db_oid).await?;
let inline_view_id = {
let txn = db_collab.transact();
db_body.get_inline_view_id(&txn)
};
let row_ids: Vec<String> = {
let txn = db_collab.transact();
db_body
.views
.get_row_orders(&txn, &inline_view_id)
.iter()
.map(|ro| ro.id.to_string())
.collect()
};
let encoded_rows = batch_get_latest_collab_encoded(
collab_storage,
GetCollabOrigin::User { uid },
&workspace_id.to_string(),
&row_ids,
CollabType::DatabaseRow,
)
.await?;
let row_data: HashMap<String, Vec<u8>> = encoded_rows
.into_iter()
.map(|(oid, encoded_collab)| (oid, encoded_collab.doc_state.to_vec()))
.collect();
let row_document_ids = row_ids
.iter()
.filter_map(|row_id| {
db_body
.block
.get_row_document_id(&RowId::from(row_id.to_owned()))
.map(|doc_id| doc_id.to_string())
})
.collect_vec();
let encoded_row_documents = batch_get_latest_collab_encoded(
collab_storage,
GetCollabOrigin::User { uid },
&workspace_id.to_string(),
&row_document_ids,
CollabType::Document,
)
.await?;
let row_document_data: HashMap<String, Vec<u8>> = encoded_row_documents
.into_iter()
.map(|(oid, encoded_collab)| (oid, encoded_collab.doc_state.to_vec()))
.collect();
let data = PublishDatabaseData {
database_collab: collab_to_doc_state(db_collab, CollabType::Database).await?,
database_row_collabs: row_data,
database_row_document_collabs: row_document_data,
visible_database_view_ids: visible_database_view_ids.unwrap_or(vec![view_id.to_string()]),
database_relations: HashMap::from([(db_oid, view_id.to_string())]),
};
Ok(serde_json::ser::to_vec(&data)?)
}
pub async fn get_page_view_collab(
pg_pool: &PgPool,
collab_access_control_storage: &CollabAccessControlStorage,

View File

@ -9,8 +9,8 @@ use collab_entity::CollabType;
use collab_folder::{CollabOrigin, Folder};
use serde_json::{json, Value};
use shared_entity::dto::workspace_dto::{
CreatePageParams, CreateSpaceParams, IconType, MovePageParams, SpacePermission, UpdatePageParams,
UpdateSpaceParams, ViewIcon, ViewLayout,
CreatePageParams, CreateSpaceParams, IconType, MovePageParams, PublishPageParams,
SpacePermission, UpdatePageParams, UpdateSpaceParams, ViewIcon, ViewLayout,
};
use tokio::time::sleep;
use uuid::Uuid;
@ -727,3 +727,71 @@ async fn create_space() {
assert_eq!(space_info["space_icon"].as_str().unwrap(), "space_icon_3");
assert_eq!(space_info["space_icon_color"].as_str().unwrap(), "#000000");
}
#[tokio::test]
async fn publish_page() {
let registered_user = generate_unique_registered_user().await;
let web_client = TestClient::user_with_new_device(registered_user.clone()).await;
let workspace_id = web_client.workspace_id().await;
let folder_view = web_client
.api_client
.get_workspace_folder(&workspace_id, Some(2), None)
.await
.unwrap();
let general_space = &folder_view
.children
.into_iter()
.find(|v| v.name == "General")
.unwrap();
let database_page_id = general_space
.children
.iter()
.find(|v| v.name == "To-dos")
.unwrap()
.view_id
.clone();
let document_page_id = general_space
.children
.iter()
.find(|v| v.name == "Getting started")
.unwrap()
.view_id
.clone();
let page_to_be_published = vec![database_page_id, document_page_id];
for view_id in &page_to_be_published {
web_client
.api_client
.publish_page(
Uuid::parse_str(&workspace_id).unwrap(),
view_id,
&PublishPageParams {
publish_name: None,
visible_database_view_ids: None,
},
)
.await
.unwrap();
}
let publish_namespace = web_client
.api_client
.get_workspace_publish_namespace(&workspace_id)
.await
.unwrap();
let published_view = web_client
.api_client
.get_published_outline(&publish_namespace)
.await
.unwrap();
let published_view_ids: HashSet<String> = published_view
.children
.iter()
.find(|v| v.name == "General")
.unwrap()
.children
.iter()
.map(|v| v.view_id.clone())
.collect();
for view_id in &page_to_be_published {
assert!(published_view_ids.contains(view_id));
}
}