805 lines
24 KiB
Rust
805 lines
24 KiB
Rust
use anyhow::anyhow;
|
|
use axum::{
|
|
extract::{Json, Path, Query, State},
|
|
http::StatusCode,
|
|
};
|
|
use diesel::{dsl::exists, prelude::*, PgConnection};
|
|
use reqwest::Client;
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::{json, Value};
|
|
use std::collections::{HashMap, HashSet};
|
|
use uuid::Uuid;
|
|
|
|
use crate::models::{Document, Folder, NewFolder};
|
|
use crate::schema::{document_correspondents, document_tags, documents, folders};
|
|
use crate::state::AppState;
|
|
use crate::{
|
|
auth::AuthenticatedUser,
|
|
error::{AppError, AppResult},
|
|
};
|
|
|
|
use super::documents::{
|
|
load_correspondents_for_documents, load_primary_assets, load_tags_for_documents,
|
|
to_document_response, to_iso, DocumentResponse,
|
|
};
|
|
|
|
const QUICKWIT_MAX_HITS: usize = 200;
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct CreateFolderRequest {
|
|
pub name: String,
|
|
pub parent_id: Option<Uuid>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct EnsureFolderPathRequest {
|
|
pub parent_id: Option<Uuid>,
|
|
pub segments: Vec<String>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct UpdateFolderRequest {
|
|
#[serde(default)]
|
|
pub parent_id: Option<Option<Uuid>>,
|
|
pub name: Option<String>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct FolderResponse {
|
|
pub folder: FolderInfo,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct FolderContentsResponse {
|
|
pub folder: Option<FolderInfo>,
|
|
pub subfolders: Vec<FolderInfo>,
|
|
pub documents: Vec<DocumentResponse>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct DocumentSearchQuery {
|
|
pub query: Option<String>,
|
|
pub tags: Option<String>,
|
|
pub correspondents: Option<String>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct FolderInfo {
|
|
pub id: Uuid,
|
|
pub name: String,
|
|
pub parent_id: Option<Uuid>,
|
|
pub path_cache: Option<String>,
|
|
pub created_at: String,
|
|
pub updated_at: String,
|
|
}
|
|
|
|
pub async fn ensure_folder_path(
|
|
State(state): State<AppState>,
|
|
Json(payload): Json<EnsureFolderPathRequest>,
|
|
) -> AppResult<Json<FolderResponse>> {
|
|
if payload.segments.is_empty() {
|
|
return Err(AppError::bad_request("segments must not be empty"));
|
|
}
|
|
|
|
let mut conn = state.db()?;
|
|
|
|
let target_folder = conn.transaction::<Folder, AppError, _>(|conn| {
|
|
let mut current_parent = payload.parent_id;
|
|
let mut last_folder: Option<Folder> = None;
|
|
|
|
for raw_name in &payload.segments {
|
|
let name = raw_name.trim();
|
|
if name.is_empty() {
|
|
return Err(AppError::bad_request("folder names must not be empty"));
|
|
}
|
|
|
|
let existing: Option<Folder> = if let Some(parent_id) = current_parent {
|
|
folders::table
|
|
.filter(folders::parent_id.eq(Some(parent_id)))
|
|
.filter(folders::name.eq(name))
|
|
.first(conn)
|
|
.optional()?
|
|
} else {
|
|
folders::table
|
|
.filter(folders::parent_id.is_null())
|
|
.filter(folders::name.eq(name))
|
|
.first(conn)
|
|
.optional()?
|
|
};
|
|
|
|
let folder = if let Some(folder) = existing {
|
|
folder
|
|
} else {
|
|
let path_cache = build_path_cache(conn, current_parent, name)?;
|
|
let new_folder = NewFolder {
|
|
id: Uuid::new_v4(),
|
|
name: name.to_string(),
|
|
parent_id: current_parent,
|
|
path_cache,
|
|
};
|
|
|
|
diesel::insert_into(folders::table)
|
|
.values(&new_folder)
|
|
.execute(conn)?;
|
|
|
|
folders::table.find(new_folder.id).first(conn)?
|
|
};
|
|
|
|
current_parent = Some(folder.id);
|
|
last_folder = Some(folder);
|
|
}
|
|
|
|
last_folder.ok_or_else(|| AppError::internal("failed to resolve folder path".to_string()))
|
|
})?;
|
|
|
|
Ok(Json(FolderResponse {
|
|
folder: folder_to_info(target_folder),
|
|
}))
|
|
}
|
|
|
|
pub async fn create_folder(
|
|
State(state): State<AppState>,
|
|
Json(payload): Json<CreateFolderRequest>,
|
|
) -> AppResult<Json<FolderResponse>> {
|
|
if payload.name.trim().is_empty() {
|
|
return Err(AppError::bad_request("name must not be empty"));
|
|
}
|
|
|
|
let mut conn = state.db()?;
|
|
let path_cache = build_path_cache(&mut conn, payload.parent_id, &payload.name)?;
|
|
|
|
let new_folder = NewFolder {
|
|
id: Uuid::new_v4(),
|
|
name: payload.name.trim().to_string(),
|
|
parent_id: payload.parent_id,
|
|
path_cache,
|
|
};
|
|
|
|
diesel::insert_into(folders::table)
|
|
.values(&new_folder)
|
|
.execute(&mut conn)?;
|
|
|
|
let folder: Folder = folders::table.find(new_folder.id).first(&mut conn)?;
|
|
Ok(Json(FolderResponse {
|
|
folder: folder_to_info(folder),
|
|
}))
|
|
}
|
|
|
|
pub async fn list_folder_contents(
|
|
State(state): State<AppState>,
|
|
Path(folder_identifier): Path<String>,
|
|
user: AuthenticatedUser,
|
|
) -> AppResult<Json<FolderContentsResponse>> {
|
|
let mut conn = state.db()?;
|
|
|
|
let folder_id = if folder_identifier.eq_ignore_ascii_case("root") {
|
|
None
|
|
} else {
|
|
Some(
|
|
Uuid::parse_str(&folder_identifier)
|
|
.map_err(|_| AppError::bad_request("folder identifier must be 'root' or a UUID"))?,
|
|
)
|
|
};
|
|
|
|
let folder = match folder_id {
|
|
Some(id) => Some(folder_to_info(
|
|
folders::table.find(id).first::<Folder>(&mut conn)?,
|
|
)),
|
|
None => None,
|
|
};
|
|
|
|
let child_folders: Vec<Folder> = if let Some(parent_id) = folder_id {
|
|
folders::table
|
|
.filter(folders::parent_id.eq(parent_id))
|
|
.order(folders::name.asc())
|
|
.load(&mut conn)?
|
|
} else {
|
|
folders::table
|
|
.filter(folders::parent_id.is_null())
|
|
.order(folders::name.asc())
|
|
.load(&mut conn)?
|
|
};
|
|
let subfolders = child_folders.into_iter().map(folder_to_info).collect();
|
|
|
|
let docs_query = documents::table
|
|
.filter(documents::deleted_at.is_null())
|
|
.order(documents::uploaded_at.desc());
|
|
|
|
let docs: Vec<Document> = if let Some(current_folder) = folder_id {
|
|
docs_query
|
|
.filter(documents::folder_id.eq(current_folder))
|
|
.load(&mut conn)?
|
|
} else {
|
|
docs_query
|
|
.filter(documents::folder_id.is_null())
|
|
.load(&mut conn)?
|
|
};
|
|
|
|
let doc_ids: Vec<Uuid> = docs.iter().map(|doc| doc.id).collect();
|
|
let tags_map = load_tags_for_documents(&mut conn, &doc_ids)?;
|
|
let mut correspondents_map = load_correspondents_for_documents(&mut conn, &doc_ids)?;
|
|
drop(conn);
|
|
|
|
let primary_versions = load_primary_assets(&state, &docs).await?;
|
|
|
|
let mut documents = Vec::with_capacity(doc_ids.len());
|
|
for doc in docs {
|
|
let tags = tags_map.get(&doc.id).cloned();
|
|
let correspondents = correspondents_map.remove(&doc.id).unwrap_or_default();
|
|
let current_version = primary_versions.get(&doc.id).cloned();
|
|
documents.push(to_document_response(
|
|
&state,
|
|
user.user_id,
|
|
doc,
|
|
tags,
|
|
correspondents,
|
|
current_version,
|
|
)?);
|
|
}
|
|
|
|
Ok(Json(FolderContentsResponse {
|
|
folder,
|
|
subfolders,
|
|
documents,
|
|
}))
|
|
}
|
|
|
|
pub async fn search_documents(
|
|
State(state): State<AppState>,
|
|
Path(folder_identifier): Path<String>,
|
|
Query(params): Query<DocumentSearchQuery>,
|
|
user: AuthenticatedUser,
|
|
) -> AppResult<Json<Vec<DocumentResponse>>> {
|
|
let mut conn = state.db()?;
|
|
|
|
let folder_id = if folder_identifier.eq_ignore_ascii_case("root") {
|
|
None
|
|
} else {
|
|
Some(
|
|
Uuid::parse_str(&folder_identifier)
|
|
.map_err(|_| AppError::bad_request("folder identifier must be 'root' or a UUID"))?,
|
|
)
|
|
};
|
|
|
|
let mut docs_query = documents::table
|
|
.filter(documents::deleted_at.is_null())
|
|
.into_boxed();
|
|
|
|
if let Some(folder_id) = folder_id {
|
|
let descendant_ids = gather_descendant_folder_ids(&mut conn, folder_id)?;
|
|
docs_query = docs_query.filter(documents::folder_id.eq_any(descendant_ids));
|
|
}
|
|
|
|
let mut filter_ids: Option<HashSet<Uuid>> = None;
|
|
let mut quickwit_order: Option<Vec<Uuid>> = None;
|
|
|
|
if let Some(query) = params
|
|
.query
|
|
.as_ref()
|
|
.map(|s| s.trim())
|
|
.filter(|s| !s.is_empty())
|
|
{
|
|
let endpoint = state
|
|
.config
|
|
.quickwit_endpoint
|
|
.as_ref()
|
|
.ok_or_else(|| AppError::internal("quickwit endpoint not configured"))?;
|
|
let index = state
|
|
.config
|
|
.quickwit_index
|
|
.as_ref()
|
|
.ok_or_else(|| AppError::internal("quickwit index not configured"))?;
|
|
|
|
let ids = quickwit_search(endpoint, index, query)
|
|
.await
|
|
.map_err(|err| AppError::internal(format!("quickwit search failed: {err}")))?;
|
|
|
|
if ids.is_empty() {
|
|
return Ok(Json(vec![]));
|
|
}
|
|
|
|
quickwit_order = Some(ids.clone());
|
|
let set: HashSet<Uuid> = ids.into_iter().collect();
|
|
filter_ids = Some(match &filter_ids {
|
|
Some(existing) => existing.intersection(&set).copied().collect(),
|
|
None => set,
|
|
});
|
|
}
|
|
|
|
if let Some(tags_param) = params
|
|
.tags
|
|
.as_ref()
|
|
.map(|s| s.trim())
|
|
.filter(|s| !s.is_empty())
|
|
{
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Some(correspondents_param) = params
|
|
.correspondents
|
|
.as_ref()
|
|
.map(|s| s.trim())
|
|
.filter(|s| !s.is_empty())
|
|
{
|
|
let correspondent_ids: Result<Vec<Uuid>, _> = correspondents_param
|
|
.split(',')
|
|
.map(|s| Uuid::parse_str(s.trim()))
|
|
.collect();
|
|
|
|
if let Ok(ids) = correspondent_ids {
|
|
if !ids.is_empty() {
|
|
let mut doc_id_set: Option<HashSet<Uuid>> = None;
|
|
for correspondent_id in &ids {
|
|
let docs_for_correspondent: Vec<Uuid> = document_correspondents::table
|
|
.filter(document_correspondents::correspondent_id.eq(*correspondent_id))
|
|
.select(document_correspondents::document_id)
|
|
.load(&mut conn)?;
|
|
|
|
let docs_set: HashSet<Uuid> = docs_for_correspondent.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);
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Some(ref set) = filter_ids {
|
|
if set.is_empty() {
|
|
return Ok(Json(vec![]));
|
|
}
|
|
|
|
let ids_vec: Vec<Uuid> = set.iter().copied().collect();
|
|
docs_query = docs_query.filter(documents::id.eq_any(ids_vec));
|
|
}
|
|
|
|
let docs: Vec<Document> = if let Some(order_ids) = quickwit_order.as_ref() {
|
|
let relevant_ids: Vec<Uuid> = if let Some(filter_set) = filter_ids.as_ref() {
|
|
order_ids
|
|
.iter()
|
|
.copied()
|
|
.filter(|id| filter_set.contains(id))
|
|
.collect()
|
|
} else {
|
|
order_ids.clone()
|
|
};
|
|
|
|
if relevant_ids.is_empty() {
|
|
return Ok(Json(vec![]));
|
|
}
|
|
|
|
let fetched: Vec<Document> = docs_query.load(&mut conn)?;
|
|
let mut by_id: HashMap<Uuid, Document> =
|
|
fetched.into_iter().map(|doc| (doc.id, doc)).collect();
|
|
|
|
let mut ordered = Vec::with_capacity(by_id.len());
|
|
for id in relevant_ids {
|
|
if let Some(doc) = by_id.remove(&id) {
|
|
ordered.push(doc);
|
|
}
|
|
}
|
|
|
|
if !by_id.is_empty() {
|
|
let mut remaining: Vec<Document> = by_id.into_values().collect();
|
|
remaining.sort_by(|a, b| b.uploaded_at.cmp(&a.uploaded_at));
|
|
ordered.extend(remaining);
|
|
}
|
|
|
|
ordered
|
|
} else {
|
|
docs_query
|
|
.order(documents::uploaded_at.desc())
|
|
.load(&mut conn)?
|
|
};
|
|
|
|
let doc_ids: Vec<Uuid> = docs.iter().map(|doc| doc.id).collect();
|
|
let tags_map = load_tags_for_documents(&mut conn, &doc_ids)?;
|
|
let mut correspondents_map = load_correspondents_for_documents(&mut conn, &doc_ids)?;
|
|
drop(conn);
|
|
|
|
let primary_versions = load_primary_assets(&state, &docs).await?;
|
|
let mut response = Vec::with_capacity(doc_ids.len());
|
|
for doc in docs {
|
|
let tags = tags_map.get(&doc.id).cloned();
|
|
let correspondents = correspondents_map.remove(&doc.id).unwrap_or_default();
|
|
let current_version = primary_versions.get(&doc.id).cloned();
|
|
response.push(to_document_response(
|
|
&state,
|
|
user.user_id,
|
|
doc,
|
|
tags,
|
|
correspondents,
|
|
current_version,
|
|
)?);
|
|
}
|
|
|
|
Ok(Json(response))
|
|
}
|
|
|
|
async fn quickwit_search(endpoint: &str, index: &str, query: &str) -> anyhow::Result<Vec<Uuid>> {
|
|
let quickwit_query = match build_quickwit_query(query) {
|
|
Some(q) => q,
|
|
None => return Ok(vec![]),
|
|
};
|
|
|
|
let client = Client::new();
|
|
let url = format!("{}/api/v1/{}/search", endpoint.trim_end_matches('/'), index);
|
|
|
|
let payload = json!({
|
|
"query": quickwit_query,
|
|
"max_hits": QUICKWIT_MAX_HITS,
|
|
});
|
|
|
|
let response = client.post(url).json(&payload).send().await?;
|
|
if !response.status().is_success() {
|
|
let status = response.status();
|
|
let body = response.text().await.unwrap_or_default();
|
|
return Err(anyhow!(
|
|
"quickwit search failed with status {status}: {body}"
|
|
));
|
|
}
|
|
|
|
let data: QuickwitSearchResponse = response.json().await?;
|
|
let mut seen = HashSet::new();
|
|
let mut doc_ids = Vec::new();
|
|
|
|
for hit in data.hits {
|
|
if let Some(doc_id) = extract_document_id(&hit) {
|
|
if seen.insert(doc_id) {
|
|
doc_ids.push(doc_id);
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(doc_ids)
|
|
}
|
|
|
|
fn build_quickwit_query(input: &str) -> Option<String> {
|
|
let tokens: Vec<String> = input
|
|
.split_whitespace()
|
|
.filter(|token| !token.is_empty())
|
|
.map(|token| {
|
|
let normalized = token.to_lowercase();
|
|
escape_quickwit_token(&normalized)
|
|
})
|
|
.collect();
|
|
|
|
if tokens.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
let parts: Vec<String> = tokens
|
|
.into_iter()
|
|
.map(|token| format!("(title:{token} OR text:{token})"))
|
|
.collect();
|
|
|
|
Some(parts.join(" AND "))
|
|
}
|
|
|
|
fn escape_quickwit_token(token: &str) -> String {
|
|
let mut escaped = String::with_capacity(token.len());
|
|
for ch in token.chars() {
|
|
match ch {
|
|
'+' | '-' | '&' | '|' | '!' | '(' | ')' | '{' | '}' | '[' | ']' | '^' | '"' | '~'
|
|
| '*' | '?' | ':' | '\\' | '/' => {
|
|
escaped.push('\\');
|
|
escaped.push(ch);
|
|
}
|
|
_ => escaped.push(ch),
|
|
}
|
|
}
|
|
escaped
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct QuickwitSearchResponse {
|
|
#[serde(default)]
|
|
hits: Vec<Value>,
|
|
}
|
|
|
|
fn extract_document_id(hit: &Value) -> Option<Uuid> {
|
|
for key in ["_source", "source", "fields", "stored_fields"] {
|
|
if let Some(value) = hit.get(key) {
|
|
if let Some(uuid) = extract_uuid_from_value(value) {
|
|
return Some(uuid);
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Some(value) = hit.get("document_id") {
|
|
if let Some(uuid) = extract_uuid_from_value(value) {
|
|
return Some(uuid);
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
fn extract_uuid_from_value(value: &Value) -> Option<Uuid> {
|
|
if let Some(obj) = value.as_object() {
|
|
if let Some(inner) = obj.get("document_id") {
|
|
return parse_uuid_value(inner);
|
|
}
|
|
}
|
|
|
|
if let Some(arr) = value.as_array() {
|
|
for item in arr {
|
|
if let Some(uuid) = extract_uuid_from_value(item) {
|
|
return Some(uuid);
|
|
}
|
|
}
|
|
}
|
|
|
|
parse_uuid_value(value)
|
|
}
|
|
|
|
fn parse_uuid_value(value: &Value) -> Option<Uuid> {
|
|
if let Some(s) = value.as_str() {
|
|
return Uuid::parse_str(s).ok();
|
|
}
|
|
|
|
if let Some(arr) = value.as_array() {
|
|
for item in arr {
|
|
if let Some(uuid) = parse_uuid_value(item) {
|
|
return Some(uuid);
|
|
}
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
pub async fn delete_folder(
|
|
State(state): State<AppState>,
|
|
Path(folder_id): Path<Uuid>,
|
|
) -> AppResult<StatusCode> {
|
|
let mut conn = state.db()?;
|
|
|
|
conn.transaction::<_, AppError, _>(|conn| {
|
|
folders::table.find(folder_id).first::<Folder>(conn)?;
|
|
|
|
let has_child_folders: bool = diesel::select(exists(
|
|
folders::table.filter(folders::parent_id.eq(Some(folder_id))),
|
|
))
|
|
.get_result(conn)?;
|
|
|
|
if has_child_folders {
|
|
return Err(AppError::bad_request(
|
|
"folder must be empty before deletion",
|
|
));
|
|
}
|
|
|
|
let has_documents: bool = diesel::select(exists(
|
|
documents::table
|
|
.filter(documents::folder_id.eq(Some(folder_id)))
|
|
.filter(documents::deleted_at.is_null()),
|
|
))
|
|
.get_result(conn)?;
|
|
|
|
if has_documents {
|
|
return Err(AppError::bad_request(
|
|
"folder must be empty before deletion",
|
|
));
|
|
}
|
|
|
|
diesel::delete(folders::table.find(folder_id)).execute(conn)?;
|
|
|
|
Ok(())
|
|
})?;
|
|
|
|
Ok(StatusCode::NO_CONTENT)
|
|
}
|
|
|
|
pub async fn update_folder(
|
|
State(state): State<AppState>,
|
|
Path(folder_id): Path<Uuid>,
|
|
Json(payload): Json<UpdateFolderRequest>,
|
|
) -> AppResult<StatusCode> {
|
|
let mut conn = state.db()?;
|
|
|
|
conn.transaction::<(), AppError, _>(|conn| {
|
|
let folder: Folder = folders::table.find(folder_id).first(conn)?;
|
|
|
|
let mut next_parent = folder.parent_id;
|
|
let mut parent_changed = false;
|
|
|
|
if let Some(parent_request) = payload.parent_id {
|
|
if parent_request == Some(folder_id) {
|
|
return Err(AppError::bad_request("folder cannot be its own parent"));
|
|
}
|
|
|
|
if let Some(parent_id) = parent_request {
|
|
let _parent: Folder = folders::table.find(parent_id).first(conn)?;
|
|
|
|
let descendant_ids = gather_descendant_folder_ids(conn, folder_id)?;
|
|
if descendant_ids.contains(&parent_id) {
|
|
return Err(AppError::bad_request(
|
|
"cannot move folder into itself or a descendant",
|
|
));
|
|
}
|
|
}
|
|
|
|
parent_changed = parent_request != folder.parent_id;
|
|
next_parent = parent_request;
|
|
}
|
|
|
|
let mut new_name = folder.name.clone();
|
|
let mut name_changed = false;
|
|
|
|
if let Some(name) = payload.name {
|
|
let trimmed = name.trim();
|
|
if trimmed.is_empty() {
|
|
return Err(AppError::bad_request("name must not be empty"));
|
|
}
|
|
|
|
if trimmed != folder.name {
|
|
new_name = trimmed.to_string();
|
|
name_changed = true;
|
|
}
|
|
}
|
|
|
|
if !parent_changed && !name_changed {
|
|
return Ok(());
|
|
}
|
|
|
|
let conflict = if let Some(parent_id) = next_parent {
|
|
folders::table
|
|
.filter(folders::parent_id.eq(Some(parent_id)))
|
|
.filter(folders::name.eq(&new_name))
|
|
.filter(folders::id.ne(folder_id))
|
|
.first::<Folder>(conn)
|
|
.optional()?
|
|
} else {
|
|
folders::table
|
|
.filter(folders::parent_id.is_null())
|
|
.filter(folders::name.eq(&new_name))
|
|
.filter(folders::id.ne(folder_id))
|
|
.first::<Folder>(conn)
|
|
.optional()?
|
|
};
|
|
|
|
if conflict.is_some() {
|
|
return Err(AppError::bad_request(
|
|
"a folder with the same name already exists in the target",
|
|
));
|
|
}
|
|
|
|
let new_path = build_path_cache(conn, next_parent, &new_name)?;
|
|
|
|
diesel::update(folders::table.find(folder_id))
|
|
.set((
|
|
folders::parent_id.eq(next_parent),
|
|
folders::name.eq(&new_name),
|
|
folders::path_cache.eq(new_path),
|
|
))
|
|
.execute(conn)?;
|
|
|
|
refresh_descendant_paths(conn, folder_id)?;
|
|
|
|
Ok(())
|
|
})?;
|
|
|
|
Ok(StatusCode::NO_CONTENT)
|
|
}
|
|
|
|
fn build_path_cache(
|
|
conn: &mut PgConnection,
|
|
parent_id: Option<Uuid>,
|
|
name: &str,
|
|
) -> AppResult<Option<String>> {
|
|
let path = if let Some(parent_id) = parent_id {
|
|
let parent: Folder = folders::table.find(parent_id).first(conn)?;
|
|
let base = parent
|
|
.path_cache
|
|
.unwrap_or_else(|| "/".to_string())
|
|
.trim_end_matches('/')
|
|
.to_string();
|
|
format!("{}/{}", base, name)
|
|
} else {
|
|
format!("/{}", name)
|
|
};
|
|
Ok(Some(path))
|
|
}
|
|
|
|
fn folder_to_info(folder: Folder) -> FolderInfo {
|
|
FolderInfo {
|
|
id: folder.id,
|
|
name: folder.name,
|
|
parent_id: folder.parent_id,
|
|
path_cache: folder.path_cache,
|
|
created_at: to_iso(folder.created_at),
|
|
updated_at: to_iso(folder.updated_at),
|
|
}
|
|
}
|
|
|
|
fn gather_descendant_folder_ids(conn: &mut PgConnection, folder_id: Uuid) -> AppResult<Vec<Uuid>> {
|
|
let mut ids = vec![folder_id];
|
|
let mut queue = vec![folder_id];
|
|
|
|
while let Some(current) = queue.pop() {
|
|
let child_ids: Vec<Uuid> = folders::table
|
|
.filter(folders::parent_id.eq(Some(current)))
|
|
.select(folders::id)
|
|
.load(conn)?;
|
|
queue.extend(child_ids.iter().copied());
|
|
ids.extend(child_ids);
|
|
}
|
|
|
|
Ok(ids)
|
|
}
|
|
|
|
fn refresh_descendant_paths(conn: &mut PgConnection, parent_id: Uuid) -> AppResult<()> {
|
|
let children: Vec<Folder> = folders::table
|
|
.filter(folders::parent_id.eq(Some(parent_id)))
|
|
.load(conn)?;
|
|
|
|
for child in children {
|
|
let path_cache = build_path_cache(conn, Some(parent_id), &child.name)?;
|
|
diesel::update(folders::table.find(child.id))
|
|
.set(folders::path_cache.eq(path_cache))
|
|
.execute(conn)?;
|
|
refresh_descendant_paths(conn, child.id)?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|