From c0fcad5952d3dcd50aa49bd896b7f0f84cfb2504 Mon Sep 17 00:00:00 2001 From: Per Stark Date: Mon, 29 Sep 2025 20:28:06 +0200 Subject: [PATCH] fix: deletion of items, shared files etc --- CHANGELOG.md | 4 + common/src/storage/types/file_info.rs | 20 +---- common/src/storage/types/text_content.rs | 80 +++++++++++++++++++ html-router/src/routes/content/handlers.rs | 9 ++- html-router/src/routes/index/handlers.rs | 41 +++++----- .../templates/dashboard/recent_content.html | 4 +- .../dashboard/recent_content_list.html | 65 +++++++++++++++ 7 files changed, 184 insertions(+), 39 deletions(-) create mode 100644 html-router/templates/dashboard/recent_content_list.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b878c4..4054450 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,8 @@ # Changelog +## Staging +- Support for ingestion of PDF files +- Improved ingestion speed +- Fix deletion of items work as expected ## Version 0.2.1 (2025-09-24) - Fixed API JSON responses so iOS Shortcuts integrations keep working. diff --git a/common/src/storage/types/file_info.rs b/common/src/storage/types/file_info.rs index 4558827..5bd5af5 100644 --- a/common/src/storage/types/file_info.rs +++ b/common/src/storage/types/file_info.rs @@ -230,14 +230,8 @@ impl FileInfo { config: &AppConfig, ) -> Result<(), AppError> { // Get the FileInfo from the database - let file_info = match db_client.get_item::(id).await? { - Some(info) => info, - None => { - return Err(AppError::from(FileError::FileNotFound(format!( - "File with id {} was not found", - id - )))) - } + let Some(file_info) = db_client.get_item::(id).await? else { + return Ok(()); }; // Remove the object's parent prefix in the object store @@ -733,14 +727,8 @@ mod tests { ) .await; - // Should fail with FileNotFound error - assert!(result.is_err()); - match result { - Err(AppError::File(_)) => { - // Expected error - } - _ => panic!("Expected FileNotFound error"), - } + // Should succeed even if the file record does not exist + assert!(result.is_ok()); } #[tokio::test] async fn test_get_by_id() { diff --git a/common/src/storage/types/text_content.rs b/common/src/storage/types/text_content.rs index e8702b3..3b7ff2f 100644 --- a/common/src/storage/types/text_content.rs +++ b/common/src/storage/types/text_content.rs @@ -110,6 +110,26 @@ impl TextContent { Ok(()) } + pub async fn has_other_with_file( + file_id: &str, + exclude_id: &str, + db: &SurrealDbClient, + ) -> Result { + let mut response = db + .client + .query( + "SELECT VALUE id FROM type::table($table_name) WHERE file_info.id = $file_id AND id != type::thing($table_name, $exclude_id) LIMIT 1", + ) + .bind(("table_name", TextContent::table_name())) + .bind(("file_id", file_id.to_owned())) + .bind(("exclude_id", exclude_id.to_owned())) + .await?; + + let existing: Option = response.take(0)?; + + Ok(existing.is_some()) + } + pub async fn search( db: &SurrealDbClient, search_terms: &str, @@ -276,4 +296,64 @@ mod tests { assert_eq!(updated_content.text, new_text); assert!(updated_content.updated_at > text_content.updated_at); } + + #[tokio::test] + async fn test_has_other_with_file_detects_shared_usage() { + let namespace = "test_ns"; + let database = &Uuid::new_v4().to_string(); + let db = SurrealDbClient::memory(namespace, database) + .await + .expect("Failed to start in-memory surrealdb"); + + let user_id = "user123".to_string(); + let file_info = FileInfo { + id: "file-1".to_string(), + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + sha256: "sha-test".to_string(), + path: "user123/file-1/test.txt".to_string(), + file_name: "test.txt".to_string(), + mime_type: "text/plain".to_string(), + user_id: user_id.clone(), + }; + + let content_a = TextContent::new( + "First".to_string(), + Some("ctx-a".to_string()), + "category".to_string(), + Some(file_info.clone()), + None, + user_id.clone(), + ); + let content_b = TextContent::new( + "Second".to_string(), + Some("ctx-b".to_string()), + "category".to_string(), + Some(file_info.clone()), + None, + user_id.clone(), + ); + + db.store_item(content_a.clone()) + .await + .expect("Failed to store first content"); + db.store_item(content_b.clone()) + .await + .expect("Failed to store second content"); + + let has_other = TextContent::has_other_with_file(&file_info.id, &content_a.id, &db) + .await + .expect("Failed to check for shared file usage"); + assert!(has_other); + + let _removed: Option = db + .delete_item(&content_b.id) + .await + .expect("Failed to delete second content"); + + let has_other_after = TextContent::has_other_with_file(&file_info.id, &content_a.id, &db) + .await + .expect("Failed to check shared usage after delete"); + assert!(!has_other_after); + } } diff --git a/html-router/src/routes/content/handlers.rs b/html-router/src/routes/content/handlers.rs index f556e1e..2679766 100644 --- a/html-router/src/routes/content/handlers.rs +++ b/html-router/src/routes/content/handlers.rs @@ -185,8 +185,13 @@ pub async fn delete_text_content( let text_content = User::get_and_validate_text_content(&id, &user.id, &state.db).await?; // If it has file info, delete that too - if let Some(file_info) = &text_content.file_info { - FileInfo::delete_by_id(&file_info.id, &state.db, &state.config).await?; + if let Some(file_info) = text_content.file_info.as_ref() { + let file_in_use = + TextContent::has_other_with_file(&file_info.id, &text_content.id, &state.db).await?; + + if !file_in_use { + FileInfo::delete_by_id(&file_info.id, &state.db, &state.config).await?; + } } // Delete related knowledge entities and text chunks diff --git a/html-router/src/routes/index/handlers.rs b/html-router/src/routes/index/handlers.rs index 4afeb46..7a48dae 100644 --- a/html-router/src/routes/index/handlers.rs +++ b/html-router/src/routes/index/handlers.rs @@ -6,7 +6,6 @@ use axum::{ }; use futures::try_join; use serde::Serialize; -use tokio::join; use crate::{ html_state::HtmlState, @@ -68,7 +67,7 @@ pub async fn index_handler( #[derive(Serialize)] pub struct LatestTextContentData { - latest_text_contents: Vec, + text_contents: Vec, user: User, } @@ -80,31 +79,35 @@ pub async fn delete_text_content( // Get and validate TextContent let text_content = get_and_validate_text_content(&state, &id, &user).await?; - // Perform concurrent deletions - let (_res1, _res2, _res3, _res4, _res5) = join!( - async { - if let Some(file_info) = text_content.file_info { - FileInfo::delete_by_id(&file_info.id, &state.db, &state.config).await - } else { - Ok(()) - } - }, - state.db.delete_item::(&text_content.id), - TextChunk::delete_by_source_id(&text_content.id, &state.db), - KnowledgeEntity::delete_by_source_id(&text_content.id, &state.db), - KnowledgeRelationship::delete_relationships_by_source_id(&text_content.id, &state.db) - ); + // Remove stored assets before deleting the text content record + if let Some(file_info) = text_content.file_info.as_ref() { + let file_in_use = + TextContent::has_other_with_file(&file_info.id, &text_content.id, &state.db).await?; + + if !file_in_use { + FileInfo::delete_by_id(&file_info.id, &state.db, &state.config).await?; + } + } + + // Delete the text content and any related data + TextChunk::delete_by_source_id(&text_content.id, &state.db).await?; + KnowledgeEntity::delete_by_source_id(&text_content.id, &state.db).await?; + KnowledgeRelationship::delete_relationships_by_source_id(&text_content.id, &state.db).await?; + state + .db + .delete_item::(&text_content.id) + .await?; // Render updated content - let latest_text_contents = + let text_contents = truncate_text_contents(User::get_latest_text_contents(&user.id, &state.db).await?); Ok(TemplateResponse::new_partial( - "index/signed_in/recent_content.html", + "dashboard/recent_content.html", "latest_content_section", LatestTextContentData { user: user.to_owned(), - latest_text_contents, + text_contents, }, )) } diff --git a/html-router/templates/dashboard/recent_content.html b/html-router/templates/dashboard/recent_content.html index aeb50e7..8d2d39f 100644 --- a/html-router/templates/dashboard/recent_content.html +++ b/html-router/templates/dashboard/recent_content.html @@ -1,6 +1,6 @@ {% block latest_content_section %}

Recent content

- {% include "content/content_list.html" %} + {% include "dashboard/recent_content_list.html" %}
-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/html-router/templates/dashboard/recent_content_list.html b/html-router/templates/dashboard/recent_content_list.html new file mode 100644 index 0000000..0fb3297 --- /dev/null +++ b/html-router/templates/dashboard/recent_content_list.html @@ -0,0 +1,65 @@ +
+ {% if text_contents|length > 0 %} +
+ {% for text_content in text_contents %} +
+ {% if text_content.url_info %} +
+ website screenshot +
+ {% endif %} + {% if text_content.file_info and (text_content.file_info.mime_type == "image/png" or text_content.file_info.mime_type == "image/jpeg") %} +
+ {{ text_content.file_info.file_name }} +
+ {% endif %} +
+

+ {% if text_content.url_info %} + {{ text_content.url_info.title }} + {% elif text_content.file_info %} + {{ text_content.file_info.file_name }} + {% else %} + {{ text_content.text }} + {% endif %} +

+
+

+ {{ text_content.created_at | datetimeformat(format="short", tz=user.timezone) }} +

+ {{ text_content.category }} +
+ {% if text_content.url_info %} + + {% include "icons/link_icon.html" %} + + {% endif %} + + + +
+
+

+ {{ text_content.instructions }} +

+
+
+ {% endfor %} +
+ {% else %} +
+ No content found. +
+ {% endif %} +