Merge pull request #811 from AppFlowy-IO/add-is-published-to-folder-struct
feat: add additional fields to FolderView and support different root id
This commit is contained in:
commit
57a30817fe
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -687,12 +687,16 @@ impl Client {
|
|||
&self,
|
||||
workspace_id: &str,
|
||||
depth: Option<u32>,
|
||||
root_view_id: Option<String>,
|
||||
) -> Result<FolderView, AppResponseError> {
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -135,6 +135,10 @@ pub struct FolderView {
|
|||
pub icon: Option<ViewIcon>,
|
||||
pub is_space: bool,
|
||||
pub is_private: bool,
|
||||
pub is_published: bool,
|
||||
pub layout: ViewLayout,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub last_edited_time: DateTime<Utc>,
|
||||
/// contains fields like `is_space`, and font information
|
||||
pub extra: Option<serde_json::Value>,
|
||||
pub children: Vec<FolderView>,
|
||||
|
|
@ -178,6 +182,7 @@ pub struct QueryWorkspaceParam {
|
|||
#[derive(Default, Debug, Deserialize, Serialize)]
|
||||
pub struct QueryWorkspaceFolder {
|
||||
pub depth: Option<u32>,
|
||||
pub root_view_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, Serialize, Deserialize)]
|
||||
|
|
|
|||
|
|
@ -1298,17 +1298,25 @@ async fn get_workspace_usage_handler(
|
|||
|
||||
async fn get_workspace_folder_handler(
|
||||
user_uuid: UserUuid,
|
||||
workspace_id: web::Path<String>,
|
||||
workspace_id: web::Path<Uuid>,
|
||||
state: Data<AppState>,
|
||||
query: web::Query<QueryWorkspaceFolder>,
|
||||
) -> Result<Json<AppResponse<FolderView>>> {
|
||||
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)))
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
) -> Result<FolderView, AppError> {
|
||||
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<String>,
|
||||
private_view_ids: &HashSet<String>,
|
||||
published_view_ids: &HashSet<String>,
|
||||
parent_is_private: bool,
|
||||
depth: u32,
|
||||
max_depth: u32,
|
||||
) -> Option<FolderView> {
|
||||
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::<serde_json::Value>(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<FolderView> = 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<String>,
|
||||
private_views: &'a HashSet<String>,
|
||||
depth: u32,
|
||||
}
|
||||
|
||||
impl<'a> From<FolderViewIntermediate<'a>> 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::<serde_json::Value>(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 {
|
||||
|
|
|
|||
|
|
@ -158,9 +158,11 @@ pub async fn get_collab_member_list(
|
|||
|
||||
pub async fn get_user_workspace_structure(
|
||||
collab_storage: Arc<CollabAccessControlStorage>,
|
||||
pg_pool: &PgPool,
|
||||
uid: i64,
|
||||
workspace_id: String,
|
||||
workspace_id: Uuid,
|
||||
depth: u32,
|
||||
root_view_id: &str,
|
||||
) -> Result<FolderView, AppError> {
|
||||
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<String> = 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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
) -> Result<PublishedView, AppError> {
|
||||
|
|
@ -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::<serde_json::Value>(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<PublishedView> {
|
||||
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<PublishedView> = 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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue