mirror of
https://github.com/perstarkse/minne.git
synced 2026-01-11 20:50:24 +01:00
fix: deletion of items, shared files etc
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -230,14 +230,8 @@ impl FileInfo {
|
||||
config: &AppConfig,
|
||||
) -> Result<(), AppError> {
|
||||
// Get the FileInfo from the database
|
||||
let file_info = match db_client.get_item::<FileInfo>(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::<FileInfo>(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() {
|
||||
|
||||
@@ -110,6 +110,26 @@ impl TextContent {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn has_other_with_file(
|
||||
file_id: &str,
|
||||
exclude_id: &str,
|
||||
db: &SurrealDbClient,
|
||||
) -> Result<bool, AppError> {
|
||||
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<surrealdb::sql::Thing> = 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<TextContent> = 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,9 +185,14 @@ 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 {
|
||||
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
|
||||
KnowledgeEntity::delete_by_source_id(&id, &state.db).await?;
|
||||
|
||||
@@ -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<TextContent>,
|
||||
text_contents: Vec<TextContent>,
|
||||
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(())
|
||||
// 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?;
|
||||
}
|
||||
},
|
||||
state.db.delete_item::<TextContent>(&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)
|
||||
);
|
||||
}
|
||||
|
||||
// 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::<TextContent>(&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,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% block latest_content_section %}
|
||||
<div id="latest_content_section" class="list">
|
||||
<h2 class="text-2xl mb-2 font-extrabold">Recent content</h2>
|
||||
{% include "content/content_list.html" %}
|
||||
{% include "dashboard/recent_content_list.html" %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
65
html-router/templates/dashboard/recent_content_list.html
Normal file
65
html-router/templates/dashboard/recent_content_list.html
Normal file
@@ -0,0 +1,65 @@
|
||||
<div id="latest_text_content_cards" class="space-y-6">
|
||||
{% if text_contents|length > 0 %}
|
||||
<div class="nb-masonry w-full">
|
||||
{% for text_content in text_contents %}
|
||||
<article class="nb-card cursor-pointer mx-auto mb-4 w-full max-w-[92vw] space-y-3 sm:max-w-none"
|
||||
hx-get="/content/{{ text_content.id }}/read" hx-target="#modal" hx-swap="innerHTML">
|
||||
{% if text_content.url_info %}
|
||||
<figure class="-mx-4 -mt-4 border-b-2 border-neutral bg-base-200">
|
||||
<img class="w-full h-auto" src="/file/{{ text_content.url_info.image_id }}" alt="website screenshot" />
|
||||
</figure>
|
||||
{% endif %}
|
||||
{% if text_content.file_info and (text_content.file_info.mime_type == "image/png" or text_content.file_info.mime_type == "image/jpeg") %}
|
||||
<figure class="-mx-4 -mt-4 border-b-2 border-neutral bg-base-200">
|
||||
<img class="w-full h-auto" src="/file/{{ text_content.file_info.id }}" alt="{{ text_content.file_info.file_name }}" />
|
||||
</figure>
|
||||
{% endif %}
|
||||
<div class="space-y-3 break-words">
|
||||
<h2 class="text-lg font-extrabold tracking-tight truncate">
|
||||
{% 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 %}
|
||||
</h2>
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<p class="text-xs opacity-60 shrink-0">
|
||||
{{ text_content.created_at | datetimeformat(format="short", tz=user.timezone) }}
|
||||
</p>
|
||||
<span class="nb-badge">{{ text_content.category }}</span>
|
||||
<div class="flex gap-2" hx-on:click="event.stopPropagation()">
|
||||
{% if text_content.url_info %}
|
||||
<a href="{{ text_content.url_info.url }}" target="_blank" rel="noopener noreferrer"
|
||||
class="nb-btn btn-square btn-sm" aria-label="Open source link">
|
||||
{% include "icons/link_icon.html" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
<button hx-get="/content/{{ text_content.id }}/read" hx-target="#modal" hx-swap="innerHTML"
|
||||
class="nb-btn btn-square btn-sm" aria-label="Read content">
|
||||
{% include "icons/read_icon.html" %}
|
||||
</button>
|
||||
<button hx-get="/content/{{ text_content.id }}" hx-target="#modal" hx-swap="innerHTML"
|
||||
class="nb-btn btn-square btn-sm" aria-label="Edit content">
|
||||
{% include "icons/edit_icon.html" %}
|
||||
</button>
|
||||
<button hx-delete="/text-content/{{ text_content.id }}" hx-target="#latest_content_section"
|
||||
hx-swap="outerHTML" class="nb-btn btn-square btn-sm" aria-label="Delete content">
|
||||
{% include "icons/delete_icon.html" %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm leading-relaxed">
|
||||
{{ text_content.instructions }}
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="nb-card p-8 text-center text-sm opacity-70">
|
||||
No content found.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
Reference in New Issue
Block a user