folder tree

This commit is contained in:
2025-11-06 00:51:38 +01:00
parent 911051e75d
commit 4027ac66cb
5 changed files with 300 additions and 37 deletions
+60 -34
View File
@@ -4,7 +4,7 @@ use axum::extract::{Json, Multipart, Path, Query, State};
use axum::http::StatusCode;
use axum::response::IntoResponse;
use chrono::{DateTime, NaiveDateTime, Utc};
use diesel::dsl::{exists, sql};
use diesel::dsl::{exists, not, sql};
use diesel::{prelude::*, result::DatabaseErrorKind, select, sql_types::Text, OptionalExtension};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
@@ -467,44 +467,70 @@ pub async fn list_documents(
}
if let Some(tags_param) = tags_param.as_ref() {
let tag_ids: Result<Vec<Uuid>, _> = tags_param
.split(',')
.map(|s| Uuid::parse_str(s.trim()))
.collect();
if tags_param.trim().eq_ignore_ascii_case("none") {
let docs_without_tags: Vec<Uuid> = documents::table
.filter(documents::tenant_id.eq(tenant_id))
.filter(documents::deleted_at.is_null())
.filter(not(exists(
document_tags::table.filter(document_tags::document_id.eq(documents::id)),
)))
.select(documents::id)
.load(&mut conn)?;
if let Ok(ids) = tag_ids {
if !ids.is_empty() {
let mut doc_id_set: Option<HashSet<Uuid>> = None;
for tag_id in &ids {
let docs_for_tag: Vec<Uuid> = document_tags::table
.filter(document_tags::tag_id.eq(*tag_id))
.select(document_tags::document_id)
.load(&mut conn)?;
let docs_set: HashSet<Uuid> = docs_for_tag.into_iter().collect();
doc_id_set = Some(match doc_id_set {
Some(existing) => existing.intersection(&docs_set).cloned().collect(),
None => docs_set,
});
let docs_set: HashSet<Uuid> = docs_without_tags.into_iter().collect();
if let Some(ref set) = doc_id_set {
if set.is_empty() {
break;
if docs_set.is_empty() {
return Ok(Json(vec![]));
}
let new_filter = match &filter_ids {
Some(existing) => existing.intersection(&docs_set).copied().collect(),
None => docs_set,
};
filter_ids = Some(new_filter);
} else {
let tag_ids: Result<Vec<Uuid>, _> = tags_param
.split(',')
.map(|s| Uuid::parse_str(s.trim()))
.collect();
if let Ok(ids) = tag_ids {
if !ids.is_empty() {
let mut doc_id_set: Option<HashSet<Uuid>> = None;
for tag_id in &ids {
let docs_for_tag: Vec<Uuid> = document_tags::table
.filter(document_tags::tag_id.eq(*tag_id))
.select(document_tags::document_id)
.load(&mut conn)?;
let docs_set: HashSet<Uuid> = docs_for_tag.into_iter().collect();
doc_id_set = Some(match doc_id_set {
Some(existing) => existing.intersection(&docs_set).cloned().collect(),
None => docs_set,
});
if let Some(ref set) = doc_id_set {
if set.is_empty() {
break;
}
}
}
let matching_doc_ids: HashSet<Uuid> = doc_id_set.unwrap_or_default();
if matching_doc_ids.is_empty() {
return Ok(Json(vec![]));
}
let new_filter = match &filter_ids {
Some(existing) => {
existing.intersection(&matching_doc_ids).copied().collect()
}
None => matching_doc_ids.clone(),
};
filter_ids = Some(new_filter);
}
let matching_doc_ids: HashSet<Uuid> = doc_id_set.unwrap_or_default();
if matching_doc_ids.is_empty() {
return Ok(Json(vec![]));
}
let new_filter = match &filter_ids {
Some(existing) => existing.intersection(&matching_doc_ids).copied().collect(),
None => matching_doc_ids.clone(),
};
filter_ids = Some(new_filter);
}
}
}
+89 -3
View File
@@ -2,7 +2,12 @@ use axum::{
extract::{Json, Path, Query, State},
http::StatusCode,
};
use diesel::{dsl::exists, prelude::*, PgConnection};
use diesel::{
dsl::{exists, sql},
prelude::*,
sql_types::Text,
PgConnection,
};
use serde::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use uuid::Uuid;
@@ -18,8 +23,7 @@ use crate::{
use super::documents::{hydrate_documents, DocumentResponse};
use crate::documents::ordering::{ordering_clauses, DocumentSortField, SortDirection};
use crate::utils::{json::deserialize_patch_field, time::to_iso};
use diesel::dsl::sql;
use diesel::sql_types::Text;
use std::collections::HashMap;
#[derive(Deserialize, ToSchema)]
pub struct CreateFolderRequest {
@@ -48,6 +52,18 @@ pub struct FolderContentsResponse {
pub documents: Vec<DocumentResponse>,
}
#[derive(Clone, Serialize, ToSchema)]
pub struct FolderTreeNode {
pub id: Uuid,
pub name: String,
#[schema(nullable)]
pub parent_id: Option<Uuid>,
pub created_at: String,
pub updated_at: String,
#[serde(default)]
pub children: Vec<FolderTreeNode>,
}
#[derive(Deserialize, IntoParams, ToSchema)]
#[into_params(parameter_in = Query)]
pub struct FolderContentsQuery {
@@ -418,6 +434,74 @@ pub async fn list_folder_contents(
}))
}
#[utoipa::path(
get,
path = "/api/folders/tree",
responses((status = 200, description = "Folder hierarchy", body = [FolderTreeNode])),
tag = "Folders"
)]
pub async fn list_folder_tree(
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
) -> AppResult<Json<Vec<FolderTreeNode>>> {
let folders: Vec<Folder> = folders::table
.filter(folders::tenant_id.eq(tenant_id))
.order(sql::<Text>("name COLLATE \"unicode_ci\" ASC"))
.load(&mut conn)?;
let mut node_map: HashMap<Uuid, FolderTreeNode> = HashMap::with_capacity(folders.len());
let mut children_map: HashMap<Uuid, Vec<Uuid>> = HashMap::new();
let mut roots: Vec<Uuid> = Vec::new();
for folder in folders {
let id = folder.id;
let parent_id = folder.parent_id;
let node = FolderTreeNode {
id,
name: folder.name,
parent_id,
created_at: to_iso(folder.created_at),
updated_at: to_iso(folder.updated_at),
children: Vec::new(),
};
if let Some(parent) = parent_id {
children_map.entry(parent).or_default().push(id);
} else {
roots.push(id);
}
node_map.insert(id, node);
}
fn build_node(
id: Uuid,
nodes: &HashMap<Uuid, FolderTreeNode>,
child_map: &HashMap<Uuid, Vec<Uuid>>,
) -> FolderTreeNode {
let mut node = nodes.get(&id).cloned().expect("folder node must exist");
if let Some(children) = child_map.get(&id) {
node.children = children
.iter()
.map(|child_id| build_node(*child_id, nodes, child_map))
.collect();
}
node
}
let tree = roots
.iter()
.map(|root_id| build_node(*root_id, &node_map, &children_map))
.collect();
Ok(Json(tree))
}
#[utoipa::path(
delete,
path = "/api/folders/{id}",
@@ -639,6 +723,7 @@ pub(super) fn gather_descendant_folder_ids(
crate::routes::folders::ensure_folder_path,
crate::routes::folders::get_folder,
crate::routes::folders::list_folder_contents,
crate::routes::folders::list_folder_tree,
crate::routes::folders::delete_folder,
crate::routes::folders::update_folder
),
@@ -649,6 +734,7 @@ pub(super) fn gather_descendant_folder_ids(
crate::routes::folders::FolderInfo,
crate::routes::folders::FolderContentsQuery,
crate::routes::folders::FolderContentsResponse,
crate::routes::folders::FolderTreeNode,
crate::routes::folders::UpdateFolderRequest
))
)]
+5
View File
@@ -223,6 +223,11 @@ pub fn create_router(state: AppState) -> Router<()> {
post(folders::ensure_folder_path)
.layer(RequireCapabilitiesLayer::all([ApiCapability::FoldersWrite])),
)
.route(
"/tree",
get(folders::list_folder_tree)
.layer(RequireCapabilitiesLayer::all([ApiCapability::FoldersRead])),
)
.route(
"/:id",
get(folders::get_folder)
+75
View File
@@ -605,6 +605,81 @@ async fn upload_skips_existing_when_requested() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn filter_documents_without_tags() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "tagfilter";
app.insert_user("tagfilter", password, "admin").await?;
let token = app.login_token("tagfilter", password).await?;
// Create a tag and upload a document that uses it.
let tag_payload = CreateTagPayload {
label: "with-tag",
color: None,
};
let tag_resp = app
.post_json("/api/tags", &tag_payload, Some(&token))
.await?;
assert!(tag_resp.status().is_success());
let tag_body = body_to_vec(tag_resp.into_body()).await?;
let tag: TagResponse = serde_json::from_slice(&tag_body)?;
let tag_json = format!("[\"{}\"]", tag.id);
let tagged_upload = app
.upload_document_with_extras(
"/api/documents",
"with-tag.txt",
"text/plain",
b"tagged",
None,
UploadExtras {
title: Some("With Tag"),
metadata_json: None,
tag_ids_json: Some(tag_json.as_str()),
correspondents_json: None,
issued_at: None,
skip_existing: None,
},
&token,
)
.await?;
assert!(tagged_upload.status().is_success());
let untagged_upload = app
.upload_document(
"/api/documents",
"without-tag.txt",
"text/plain",
b"untagged",
None,
&token,
)
.await?;
assert!(untagged_upload.status().is_success());
let untagged_body = body_to_vec(untagged_upload.into_body()).await?;
let untagged_detail: DocumentDetail = serde_json::from_slice(&untagged_body)?;
// Sanity: both documents appear in the default listing.
let all_resp = app.get("/api/documents", Some(&token)).await?;
assert_eq!(all_resp.status(), StatusCode::OK);
let all_body = body_to_vec(all_resp.into_body()).await?;
let all_docs: Vec<DocumentListItem> = serde_json::from_slice(&all_body)?;
assert_eq!(all_docs.len(), 2);
// Filter for documents without tags.
let none_resp = app.get("/api/documents?tags=none", Some(&token)).await?;
assert_eq!(none_resp.status(), StatusCode::OK);
let none_body = body_to_vec(none_resp.into_body()).await?;
let none_docs: Vec<DocumentListItem> = serde_json::from_slice(&none_body)?;
assert_eq!(none_docs.len(), 1);
assert_eq!(none_docs[0].id, untagged_detail.document.id);
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn bulk_move_documents_to_folder() -> Result<()> {
let _lock = acquire_db_lock().await;
+71
View File
@@ -32,6 +32,12 @@ struct DocSummary {
id: Uuid,
}
#[derive(Deserialize)]
struct FolderTreeNodeResponse {
name: String,
children: Vec<FolderTreeNodeResponse>,
}
#[derive(Serialize)]
struct CreateFolder<'a> {
name: &'a str,
@@ -144,6 +150,71 @@ async fn folder_move_and_delete_flow() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn folder_tree_lists_hierarchy() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "folderpass";
app.insert_user("folder-tree", password, "admin").await?;
let token = app.login_token("folder-tree", password).await?;
let alpha_resp = app
.post_json(
"/api/folders",
&CreateFolder {
name: "Alpha",
parent_id: None,
},
Some(&token),
)
.await?;
assert_eq!(alpha_resp.status(), StatusCode::CREATED);
let alpha_body = body_to_vec(alpha_resp.into_body()).await?;
let alpha: FolderResponse = serde_json::from_slice(&alpha_body)?;
let archive_resp = app
.post_json(
"/api/folders",
&CreateFolder {
name: "Archive",
parent_id: Some(alpha.folder.id),
},
Some(&token),
)
.await?;
assert_eq!(archive_resp.status(), StatusCode::CREATED);
let beta_resp = app
.post_json(
"/api/folders",
&CreateFolder {
name: "Beta",
parent_id: None,
},
Some(&token),
)
.await?;
assert_eq!(beta_resp.status(), StatusCode::CREATED);
let tree_resp = app.get("/api/folders/tree", Some(&token)).await?;
assert_eq!(tree_resp.status(), StatusCode::OK);
let tree_body = body_to_vec(tree_resp.into_body()).await?;
let tree: Vec<FolderTreeNodeResponse> = serde_json::from_slice(&tree_body)?;
assert_eq!(tree.len(), 2);
assert_eq!(tree[0].name, "Alpha");
assert_eq!(tree[0].children.len(), 1);
assert_eq!(tree[0].children[0].name, "Archive");
assert!(tree[0].children[0].children.is_empty());
assert_eq!(tree[1].name, "Beta");
assert!(tree[1].children.is_empty());
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn update_folder_parent_to_root() -> Result<()> {
let _lock = acquire_db_lock().await;