Skip to content
Merged
1 change: 1 addition & 0 deletions scripts/check-discovery-metadata.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const validPanels = new Set([
"connections",
"agents",
"personas",
"gallery",
"settings",
"bot-browser",
"discover",
Expand Down
40 changes: 39 additions & 1 deletion src-tauri/src/commands/storage/admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,14 @@ pub(crate) fn admin_clear_all(state: &AppState, body: Value) -> AppResult<Value>
if body.get("confirm").and_then(Value::as_bool) != Some(true) {
return Err(AppError::invalid_input("confirm must be true"));
}
// Snapshot gallery file refs, clear all rows first, then delete the files so a
// failed clear can't leave live rows pointing at deleted assets.
let mut gallery_files = Vec::new();
for collection in GALLERY_FILE_COLLECTIONS {
Comment thread
Xelvanis marked this conversation as resolved.
gallery_files.extend(gallery_file_rows_snapshot(state, collection)?);
}
state.storage.clear_all()?;
remove_gallery_files_from_snapshot(state, &gallery_files);
clear_runtime_media(state)?;
Ok(json!({ "success": true, "cleared": "all" }))
}
Expand Down Expand Up @@ -51,7 +58,7 @@ pub(crate) fn admin_expunge(state: &AppState, body: Value) -> AppResult<Value> {
}
"personas" => clear_collections(
state,
&["personas", "persona-groups"],
&["personas", "persona-groups", "persona-gallery"],
&mut cleared_collections,
)?,
"lorebooks" => clear_collections(
Expand Down Expand Up @@ -92,6 +99,9 @@ pub(crate) fn admin_expunge(state: &AppState, body: Value) -> AppResult<Value> {
&[
"gallery",
"character-gallery",
"persona-gallery",
"global-gallery",
"gallery-folders",
"background-metadata",
"sprites",
"knowledge-sources",
Expand All @@ -112,13 +122,41 @@ pub(crate) fn admin_expunge(state: &AppState, body: Value) -> AppResult<Value> {
Ok(json!({ "success": true, "clearedCollections": cleared_collections }))
}

/// Gallery collections whose rows reference managed image files in the shared
/// `gallery` asset folder. Their files must be removed when the rows are cleared,
/// or expunge/clear-all leaves orphaned files behind. Per-row removal (rather
/// than nuking the whole folder) is what lets a per-scope expunge — e.g.
/// "personas" — drop only its own gallery's files without touching the others.
const GALLERY_FILE_COLLECTIONS: &[&str] =
&["gallery", "character-gallery", "persona-gallery", "global-gallery"];

/// Snapshot a gallery collection's rows so their files can be deleted AFTER the
/// rows are cleared. Returns empty for non-gallery collections.
fn gallery_file_rows_snapshot(state: &AppState, collection: &str) -> AppResult<Vec<Value>> {
if !GALLERY_FILE_COLLECTIONS.contains(&collection) {
return Ok(Vec::new());
}
state.storage.list(collection)
}

fn remove_gallery_files_from_snapshot(state: &AppState, rows: &[Value]) {
for row in rows {
media_uploads::remove_managed_record_file(state, "gallery", row, "filePath", "filename");
}
}

fn clear_collections(
state: &AppState,
collections: &[&str],
cleared: &mut Vec<String>,
) -> AppResult<()> {
for collection in collections {
// Snapshot file refs, clear the ROWS first, then delete the files. If the
// row clear fails, the rows still point at intact files (no broken refs);
// a later file-removal hiccup only orphans files, the lesser evil.
let files = gallery_file_rows_snapshot(state, collection)?;
state.storage.replace_all(collection, Vec::new())?;
remove_gallery_files_from_snapshot(state, &files);
cleared.push((*collection).to_string());
}
Ok(())
Expand Down
179 changes: 176 additions & 3 deletions src-tauri/src/commands/storage/commands/entities.rs
Original file line number Diff line number Diff line change
Expand Up @@ -689,6 +689,7 @@ pub(crate) fn storage_create_inner(
validate_chat_folder_for_create(state, &entity, &value)?;
validate_connection_folder_for_create(state, &entity, &value)?;
validate_lorebook_folder_for_create(state, &entity, &value)?;
validate_gallery_folder_for_create(state, &entity, &value)?;
if entity == "messages" {
return Ok(shared::project_timeline_message(
message_swipes::create_message(
Expand Down Expand Up @@ -769,6 +770,7 @@ pub(crate) fn storage_update_inner(
validate_chat_folder_for_patch(state, &entity, &id, &patch)?;
validate_connection_folder_for_patch(state, &entity, &patch)?;
validate_lorebook_folder_for_patch(state, &entity, &id, &patch)?;
validate_gallery_folder_for_patch(state, &entity, &patch)?;
let mut normalized_patch =
normalize_chat_for_update(&entity, shared::normalize_update_patch(&entity, patch)?)?;
if entity == "chats" {
Expand Down Expand Up @@ -806,7 +808,9 @@ pub(crate) fn prepare_entity_for_create(
"chats" => normalize_chat_for_create(value),
"chat-folders" => chat_folder_defaults_for_create(value),
"connection-folders" => connection_folder_defaults_for_create(state, value),
"gallery" | "character-gallery" => gallery_defaults_for_create(state, value),
"gallery" | "character-gallery" | "persona-gallery" | "global-gallery" => {
gallery_defaults_for_create(state, value)
}
_ => Ok(value),
}
}
Expand Down Expand Up @@ -1050,7 +1054,10 @@ fn gallery_defaults_for_create(state: &AppState, value: Value) -> Result<Value,
}

fn gallery_create_persists_inline_image(entity: &str, value: &Value) -> bool {
matches!(entity, "gallery" | "character-gallery")
matches!(
entity,
"gallery" | "character-gallery" | "persona-gallery" | "global-gallery"
)
&& value
.get("url")
.and_then(Value::as_str)
Expand Down Expand Up @@ -1434,6 +1441,55 @@ fn validate_lorebook_folder_parent(
Ok(())
}

/// Reject a `global-gallery` row whose `folderId` points at a `gallery-folders`
/// row that does not exist. The dedicated upload command coerces a missing
/// folder to root, but the generic create/update path (used by the lightbox
/// move and any remote caller) must guard the reference itself so a stale UI
/// race or remote write can't strand an image under a ghost folder.
pub(crate) fn validate_gallery_folder_for_create(
state: &AppState,
entity: &str,
value: &Value,
) -> Result<(), AppError> {
if entity != "global-gallery" {
return Ok(());
}
validate_gallery_folder_assignment(state, parse_chat_folder_id(value.get("folderId"))?)
}

pub(crate) fn validate_gallery_folder_for_patch(
state: &AppState,
entity: &str,
patch: &Value,
) -> Result<(), AppError> {
if entity != "global-gallery" {
return Ok(());
}
let Some(object) = patch.as_object() else {
return Err(AppError::invalid_input("Patch must be an object"));
};
if !object.contains_key("folderId") {
return Ok(());
}
validate_gallery_folder_assignment(state, parse_chat_folder_id(patch.get("folderId"))?)
}

fn validate_gallery_folder_assignment(
state: &AppState,
folder_id: Option<String>,
) -> Result<(), AppError> {
let Some(folder_id) = folder_id else {
return Ok(());
};
if state.storage.get("gallery-folders", &folder_id)?.is_some() {
Ok(())
} else {
Err(AppError::invalid_input(format!(
"gallery-folders/{folder_id} was not found"
)))
}
}

fn parse_chat_folder_id(folder_id: Option<&Value>) -> Result<Option<String>, AppError> {
let Some(folder_id) = folder_id else {
return Ok(None);
Expand Down Expand Up @@ -1678,12 +1734,18 @@ fn apply_delete_cleanup(
contracts::DeleteCleanup::ClearConnectionFolder => {
unfile_connections_in_folder(state, id)?
}
contracts::DeleteCleanup::ClearGalleryFolder => {
unfile_records_in_folder(state, "global-gallery", id)?
}
contracts::DeleteCleanup::ClearLorebookReferences => {
clear_deleted_lorebook_references(state, id)?;
}
contracts::DeleteCleanup::DeleteCharacterGallery => {
delete_character_gallery(state, id)?
}
contracts::DeleteCleanup::DeletePersonaGallery => {
delete_persona_gallery(state, id)?
}
contracts::DeleteCleanup::DeleteLorebookChildren => {
delete_lorebook_children(state, id)?
}
Expand Down Expand Up @@ -2108,6 +2170,20 @@ fn delete_character_gallery(state: &AppState, character_id: &str) -> Result<(),
Ok(())
}

fn delete_persona_gallery(state: &AppState, persona_id: &str) -> Result<(), AppError> {
let mut filters = Map::new();
filters.insert(
"personaId".to_string(),
Value::String(persona_id.to_string()),
);
let rows = state.storage.list_where("persona-gallery", &filters)?;
for row in &rows {
remove_gallery_file(state, row);
}
state.storage.delete_where("persona-gallery", &filters)?;
Ok(())
}

fn delete_lorebook_children(state: &AppState, lorebook_id: &str) -> Result<(), AppError> {
let mut filters = Map::new();
filters.insert(
Expand Down Expand Up @@ -2821,7 +2897,9 @@ fn remove_owned_media(state: &AppState, entity: &str, record: &Value) {
}
}
"lorebooks" => lorebook_images::remove_lorebook_image_file(state, record),
"gallery" | "character-gallery" => remove_gallery_file(state, record),
"gallery" | "character-gallery" | "persona-gallery" | "global-gallery" => {
remove_gallery_file(state, record)
}
_ => {}
}
}
Expand Down Expand Up @@ -4854,6 +4932,101 @@ mod tests {
);
}

#[test]
fn deleting_persona_removes_persona_gallery_records_and_managed_files() {
let state = test_state("persona-gallery-delete");
state
.storage
.create(
"personas",
json!({
"id": "persona-1",
"data": { "name": "Gallery Persona" }
}),
)
.expect("persona should be created");
let gallery_dir = state.data_dir.join("gallery");
std::fs::create_dir_all(&gallery_dir).expect("gallery dir should be created");
let image_path = gallery_dir.join("persona.png");
std::fs::write(&image_path, b"managed").expect("managed image should be written");
state
.storage
.create(
"persona-gallery",
json!({
"id": "persona-image-1",
"personaId": "persona-1",
"filePath": "persona.png",
"filename": "persona.png",
"url": "data:image/png;base64,bWFuYWdlZA=="
}),
)
.expect("persona gallery row should be created");

delete_entity(&state, "personas", "persona-1", false)
.expect("persona delete should succeed");

let mut filters = Map::new();
filters.insert(
"personaId".to_string(),
Value::String("persona-1".to_string()),
);
assert!(
state
.storage
.list_where("persona-gallery", &filters)
.expect("persona gallery should be readable")
.is_empty(),
"persona gallery rows should be removed"
);
assert!(
!image_path.exists(),
"managed gallery file should be removed"
);
}

#[test]
fn deleting_gallery_folder_unfiles_its_images() {
let state = test_state("gallery-folder-unfile");
state
.storage
.create("gallery-folders", json!({ "id": "folder-1", "name": "Reactions" }))
.expect("gallery folder should be created");
for id in ["image-1", "image-2"] {
state
.storage
.create(
"global-gallery",
json!({ "id": id, "folderId": "folder-1", "filePath": "x.png", "filename": "x.png" }),
)
.expect("global gallery row should be created");
}

delete_entity(&state, "gallery-folders", "folder-1", false)
.expect("gallery folder delete should succeed");

let mut folder_filters = Map::new();
folder_filters.insert("id".to_string(), Value::String("folder-1".to_string()));
assert!(
state
.storage
.list_where("gallery-folders", &folder_filters)
.expect("gallery folders should be readable")
.is_empty(),
"deleted folder row should be gone"
);

let images = state.storage.list("global-gallery").expect("images should be readable");
assert_eq!(images.len(), 2, "images must survive folder deletion");
for image in &images {
assert_eq!(
image.get("folderId"),
Some(&Value::Null),
"image should be re-filed to the root level"
);
}
}

#[test]
fn deleting_chat_reports_cascade_deleted_chat_ids() {
let state = test_state("chat-delete-ids");
Expand Down
18 changes: 18 additions & 0 deletions src-tauri/src/commands/storage/commands/media.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,24 @@ pub fn character_gallery_upload(
)
}

#[tauri::command]
pub fn persona_gallery_upload(
state: State<'_, AppState>,
persona_id: String,
body: Value,
) -> Result<Value, AppError> {
shared::upload_gallery_image(&state, "persona-gallery", "personaId", &persona_id, body)
}

#[tauri::command]
pub fn global_gallery_upload(
state: State<'_, AppState>,
folder_id: Option<String>,
body: Value,
) -> Result<Value, AppError> {
shared::upload_global_gallery_image(&state, folder_id.as_deref(), body)
}

#[tauri::command]
pub fn chat_gallery_upload(
state: State<'_, AppState>,
Expand Down
Loading
Loading