add folders to asset library
This commit is contained in:
parent
1fcad0355d
commit
b19f66e648
|
|
@ -0,0 +1,183 @@
|
|||
//! Create folder action
|
||||
//!
|
||||
//! Handles creating a new folder in the asset library.
|
||||
|
||||
use crate::action::Action;
|
||||
use crate::asset_folder::AssetFolder;
|
||||
use crate::document::{AssetCategory, Document};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Action that creates a new folder in an asset category
|
||||
pub struct CreateFolderAction {
|
||||
/// Asset category for this folder
|
||||
category: AssetCategory,
|
||||
|
||||
/// Folder name
|
||||
name: String,
|
||||
|
||||
/// Parent folder ID (None = root level)
|
||||
parent_id: Option<Uuid>,
|
||||
|
||||
/// ID of the created folder (set after execution)
|
||||
created_folder_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
impl CreateFolderAction {
|
||||
/// Create a new folder action
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `category` - Which asset category to create the folder in
|
||||
/// * `name` - The name for the new folder
|
||||
/// * `parent_id` - Optional parent folder ID (None = root level)
|
||||
pub fn new(
|
||||
category: AssetCategory,
|
||||
name: impl Into<String>,
|
||||
parent_id: Option<Uuid>,
|
||||
) -> Self {
|
||||
Self {
|
||||
category,
|
||||
name: name.into(),
|
||||
parent_id,
|
||||
created_folder_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the ID of the created folder (after execution)
|
||||
pub fn created_folder_id(&self) -> Option<Uuid> {
|
||||
self.created_folder_id
|
||||
}
|
||||
}
|
||||
|
||||
impl Action for CreateFolderAction {
|
||||
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
|
||||
// Create the folder
|
||||
let folder = AssetFolder::new(&self.name, self.parent_id);
|
||||
let folder_id = folder.id;
|
||||
|
||||
// Add to the appropriate folder tree
|
||||
let tree = document.get_folder_tree_mut(self.category);
|
||||
tree.add_folder(folder);
|
||||
|
||||
// Store the ID for rollback
|
||||
self.created_folder_id = Some(folder_id);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
|
||||
// Remove the created folder if it exists
|
||||
if let Some(folder_id) = self.created_folder_id {
|
||||
let tree = document.get_folder_tree_mut(self.category);
|
||||
tree.remove_folder(&folder_id);
|
||||
|
||||
// Clear the stored ID
|
||||
self.created_folder_id = None;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn description(&self) -> String {
|
||||
format!("Create folder '{}'", self.name)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_create_folder_at_root() {
|
||||
let mut document = Document::new("Test");
|
||||
|
||||
// Create and execute action
|
||||
let mut action = CreateFolderAction::new(AssetCategory::Vector, "My Folder", None);
|
||||
action.execute(&mut document).unwrap();
|
||||
|
||||
// Verify folder was created
|
||||
let tree = document.get_folder_tree(AssetCategory::Vector);
|
||||
assert_eq!(tree.folders.len(), 1);
|
||||
|
||||
let roots = tree.root_folders();
|
||||
assert_eq!(roots.len(), 1);
|
||||
assert_eq!(roots[0].name, "My Folder");
|
||||
assert_eq!(roots[0].parent_id, None);
|
||||
|
||||
// Get the created ID
|
||||
let folder_id = action.created_folder_id().unwrap();
|
||||
assert_eq!(roots[0].id, folder_id);
|
||||
|
||||
// Rollback
|
||||
action.rollback(&mut document).unwrap();
|
||||
|
||||
// Verify folder was removed
|
||||
let tree = document.get_folder_tree(AssetCategory::Vector);
|
||||
assert_eq!(tree.folders.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_nested_folder() {
|
||||
let mut document = Document::new("Test");
|
||||
|
||||
// Create parent folder
|
||||
let mut parent_action = CreateFolderAction::new(AssetCategory::Audio, "Parent", None);
|
||||
parent_action.execute(&mut document).unwrap();
|
||||
let parent_id = parent_action.created_folder_id().unwrap();
|
||||
|
||||
// Create child folder
|
||||
let mut child_action =
|
||||
CreateFolderAction::new(AssetCategory::Audio, "Child", Some(parent_id));
|
||||
child_action.execute(&mut document).unwrap();
|
||||
|
||||
// Verify structure
|
||||
let tree = document.get_folder_tree(AssetCategory::Audio);
|
||||
assert_eq!(tree.folders.len(), 2);
|
||||
|
||||
let roots = tree.root_folders();
|
||||
assert_eq!(roots.len(), 1);
|
||||
assert_eq!(roots[0].name, "Parent");
|
||||
|
||||
let children = tree.children_of(&parent_id);
|
||||
assert_eq!(children.len(), 1);
|
||||
assert_eq!(children[0].name, "Child");
|
||||
assert_eq!(children[0].parent_id, Some(parent_id));
|
||||
|
||||
// Rollback child
|
||||
child_action.rollback(&mut document).unwrap();
|
||||
let tree = document.get_folder_tree(AssetCategory::Audio);
|
||||
assert_eq!(tree.folders.len(), 1);
|
||||
assert_eq!(tree.children_of(&parent_id).len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_folder_description() {
|
||||
let action = CreateFolderAction::new(AssetCategory::Images, "Photos", None);
|
||||
assert_eq!(action.description(), "Create folder 'Photos'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multiple_categories() {
|
||||
let mut document = Document::new("Test");
|
||||
|
||||
// Create folders in different categories
|
||||
let mut vector_action = CreateFolderAction::new(AssetCategory::Vector, "Shapes", None);
|
||||
let mut video_action = CreateFolderAction::new(AssetCategory::Video, "Clips", None);
|
||||
|
||||
vector_action.execute(&mut document).unwrap();
|
||||
video_action.execute(&mut document).unwrap();
|
||||
|
||||
// Verify each category has its own tree
|
||||
let vector_tree = document.get_folder_tree(AssetCategory::Vector);
|
||||
assert_eq!(vector_tree.root_folders().len(), 1);
|
||||
assert_eq!(vector_tree.root_folders()[0].name, "Shapes");
|
||||
|
||||
let video_tree = document.get_folder_tree(AssetCategory::Video);
|
||||
assert_eq!(video_tree.root_folders().len(), 1);
|
||||
assert_eq!(video_tree.root_folders()[0].name, "Clips");
|
||||
|
||||
// Other categories should still be empty
|
||||
let audio_tree = document.get_folder_tree(AssetCategory::Audio);
|
||||
assert_eq!(audio_tree.folders.len(), 0);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,452 @@
|
|||
//! Delete folder action
|
||||
//!
|
||||
//! Handles deleting a folder from the asset library with two strategies:
|
||||
//! - Move contents to parent folder
|
||||
//! - Delete recursively (folder and all contents)
|
||||
|
||||
use crate::action::Action;
|
||||
use crate::asset_folder::AssetFolder;
|
||||
use crate::document::{AssetCategory, Document};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Strategy for handling folder contents during deletion
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DeleteStrategy {
|
||||
/// Move contents to parent folder before deleting
|
||||
MoveToParent,
|
||||
/// Delete folder and all contents recursively
|
||||
DeleteRecursive,
|
||||
}
|
||||
|
||||
/// Action that deletes a folder
|
||||
pub struct DeleteFolderAction {
|
||||
/// Asset category for this folder
|
||||
category: AssetCategory,
|
||||
|
||||
/// Folder ID to delete
|
||||
folder_id: Uuid,
|
||||
|
||||
/// Deletion strategy
|
||||
strategy: DeleteStrategy,
|
||||
|
||||
/// Removed folders (for undo)
|
||||
removed_folders: Vec<AssetFolder>,
|
||||
|
||||
/// Asset IDs that were moved to parent (for MoveToParent strategy)
|
||||
moved_asset_ids: Vec<Uuid>,
|
||||
|
||||
/// Asset IDs that were deleted (for DeleteRecursive strategy)
|
||||
deleted_asset_ids: Vec<Uuid>,
|
||||
}
|
||||
|
||||
impl DeleteFolderAction {
|
||||
/// Create a new delete folder action
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `category` - Which asset category the folder is in
|
||||
/// * `folder_id` - ID of the folder to delete
|
||||
/// * `strategy` - How to handle folder contents
|
||||
pub fn new(category: AssetCategory, folder_id: Uuid, strategy: DeleteStrategy) -> Self {
|
||||
Self {
|
||||
category,
|
||||
folder_id,
|
||||
strategy,
|
||||
removed_folders: Vec::new(),
|
||||
moved_asset_ids: Vec::new(),
|
||||
deleted_asset_ids: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Action for DeleteFolderAction {
|
||||
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
|
||||
// Get the folder tree
|
||||
let tree = document.get_folder_tree_mut(self.category);
|
||||
|
||||
// Get the folder to check if it exists
|
||||
let folder = tree
|
||||
.folders
|
||||
.get(&self.folder_id)
|
||||
.ok_or_else(|| format!("Folder {} not found", self.folder_id))?;
|
||||
|
||||
let parent_id = folder.parent_id;
|
||||
|
||||
match self.strategy {
|
||||
DeleteStrategy::MoveToParent => {
|
||||
// Find all assets in this folder and move them to parent
|
||||
match self.category {
|
||||
AssetCategory::Vector => {
|
||||
for (id, clip) in document.vector_clips.iter_mut() {
|
||||
if clip.folder_id == Some(self.folder_id) {
|
||||
clip.folder_id = parent_id;
|
||||
self.moved_asset_ids.push(*id);
|
||||
}
|
||||
}
|
||||
}
|
||||
AssetCategory::Video => {
|
||||
for (id, clip) in document.video_clips.iter_mut() {
|
||||
if clip.folder_id == Some(self.folder_id) {
|
||||
clip.folder_id = parent_id;
|
||||
self.moved_asset_ids.push(*id);
|
||||
}
|
||||
}
|
||||
}
|
||||
AssetCategory::Audio => {
|
||||
for (id, clip) in document.audio_clips.iter_mut() {
|
||||
if clip.folder_id == Some(self.folder_id) {
|
||||
clip.folder_id = parent_id;
|
||||
self.moved_asset_ids.push(*id);
|
||||
}
|
||||
}
|
||||
}
|
||||
AssetCategory::Images => {
|
||||
for (id, asset) in document.image_assets.iter_mut() {
|
||||
if asset.folder_id == Some(self.folder_id) {
|
||||
asset.folder_id = parent_id;
|
||||
self.moved_asset_ids.push(*id);
|
||||
}
|
||||
}
|
||||
}
|
||||
AssetCategory::Effects => {
|
||||
for (id, effect) in document.effect_definitions.iter_mut() {
|
||||
if effect.folder_id == Some(self.folder_id) {
|
||||
effect.folder_id = parent_id;
|
||||
self.moved_asset_ids.push(*id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find all subfolders and move them to parent
|
||||
let tree = document.get_folder_tree_mut(self.category);
|
||||
let subfolder_ids: Vec<Uuid> = tree
|
||||
.folders
|
||||
.values()
|
||||
.filter(|f| f.parent_id == Some(self.folder_id))
|
||||
.map(|f| f.id)
|
||||
.collect();
|
||||
|
||||
for subfolder_id in subfolder_ids {
|
||||
if let Some(subfolder) = tree.folders.get_mut(&subfolder_id) {
|
||||
subfolder.parent_id = parent_id;
|
||||
}
|
||||
}
|
||||
}
|
||||
DeleteStrategy::DeleteRecursive => {
|
||||
// Find all assets in this folder and its descendants, and delete them
|
||||
// First, collect all descendant folder IDs
|
||||
let tree = document.get_folder_tree(self.category);
|
||||
let mut to_check = vec![self.folder_id];
|
||||
let mut all_folder_ids = vec![self.folder_id];
|
||||
let mut i = 0;
|
||||
|
||||
while i < to_check.len() {
|
||||
let current_id = to_check[i];
|
||||
for (child_id, child) in &tree.folders {
|
||||
if child.parent_id == Some(current_id) {
|
||||
to_check.push(*child_id);
|
||||
all_folder_ids.push(*child_id);
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
// Delete all assets in these folders
|
||||
match self.category {
|
||||
AssetCategory::Vector => {
|
||||
let to_delete: Vec<Uuid> = document
|
||||
.vector_clips
|
||||
.iter()
|
||||
.filter(|(_, clip)| {
|
||||
clip.folder_id
|
||||
.map(|fid| all_folder_ids.contains(&fid))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.map(|(id, _)| *id)
|
||||
.collect();
|
||||
|
||||
for id in to_delete {
|
||||
document.vector_clips.remove(&id);
|
||||
self.deleted_asset_ids.push(id);
|
||||
}
|
||||
}
|
||||
AssetCategory::Video => {
|
||||
let to_delete: Vec<Uuid> = document
|
||||
.video_clips
|
||||
.iter()
|
||||
.filter(|(_, clip)| {
|
||||
clip.folder_id
|
||||
.map(|fid| all_folder_ids.contains(&fid))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.map(|(id, _)| *id)
|
||||
.collect();
|
||||
|
||||
for id in to_delete {
|
||||
document.video_clips.remove(&id);
|
||||
self.deleted_asset_ids.push(id);
|
||||
}
|
||||
}
|
||||
AssetCategory::Audio => {
|
||||
let to_delete: Vec<Uuid> = document
|
||||
.audio_clips
|
||||
.iter()
|
||||
.filter(|(_, clip)| {
|
||||
clip.folder_id
|
||||
.map(|fid| all_folder_ids.contains(&fid))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.map(|(id, _)| *id)
|
||||
.collect();
|
||||
|
||||
for id in to_delete {
|
||||
document.audio_clips.remove(&id);
|
||||
self.deleted_asset_ids.push(id);
|
||||
}
|
||||
}
|
||||
AssetCategory::Images => {
|
||||
let to_delete: Vec<Uuid> = document
|
||||
.image_assets
|
||||
.iter()
|
||||
.filter(|(_, asset)| {
|
||||
asset
|
||||
.folder_id
|
||||
.map(|fid| all_folder_ids.contains(&fid))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.map(|(id, _)| *id)
|
||||
.collect();
|
||||
|
||||
for id in to_delete {
|
||||
document.image_assets.remove(&id);
|
||||
self.deleted_asset_ids.push(id);
|
||||
}
|
||||
}
|
||||
AssetCategory::Effects => {
|
||||
let to_delete: Vec<Uuid> = document
|
||||
.effect_definitions
|
||||
.iter()
|
||||
.filter(|(_, effect)| {
|
||||
effect
|
||||
.folder_id
|
||||
.map(|fid| all_folder_ids.contains(&fid))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.map(|(id, _)| *id)
|
||||
.collect();
|
||||
|
||||
for id in to_delete {
|
||||
document.effect_definitions.remove(&id);
|
||||
self.deleted_asset_ids.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the folder and all descendants
|
||||
let tree = document.get_folder_tree_mut(self.category);
|
||||
self.removed_folders = tree.remove_folder(&self.folder_id);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
|
||||
// Restore all removed folders
|
||||
let tree = document.get_folder_tree_mut(self.category);
|
||||
for folder in &self.removed_folders {
|
||||
tree.add_folder(folder.clone());
|
||||
}
|
||||
|
||||
match self.strategy {
|
||||
DeleteStrategy::MoveToParent => {
|
||||
// Restore folder_id for moved assets
|
||||
match self.category {
|
||||
AssetCategory::Vector => {
|
||||
for id in &self.moved_asset_ids {
|
||||
if let Some(clip) = document.vector_clips.get_mut(id) {
|
||||
clip.folder_id = Some(self.folder_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
AssetCategory::Video => {
|
||||
for id in &self.moved_asset_ids {
|
||||
if let Some(clip) = document.video_clips.get_mut(id) {
|
||||
clip.folder_id = Some(self.folder_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
AssetCategory::Audio => {
|
||||
for id in &self.moved_asset_ids {
|
||||
if let Some(clip) = document.audio_clips.get_mut(id) {
|
||||
clip.folder_id = Some(self.folder_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
AssetCategory::Images => {
|
||||
for id in &self.moved_asset_ids {
|
||||
if let Some(asset) = document.image_assets.get_mut(id) {
|
||||
asset.folder_id = Some(self.folder_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
AssetCategory::Effects => {
|
||||
for id in &self.moved_asset_ids {
|
||||
if let Some(effect) = document.effect_definitions.get_mut(id) {
|
||||
effect.folder_id = Some(self.folder_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
DeleteStrategy::DeleteRecursive => {
|
||||
// Note: We can't restore deleted assets as we didn't store them
|
||||
// In a real implementation, you might want to store the deleted assets too
|
||||
// For now, this is a limitation - recursive delete is not fully undoable
|
||||
// if assets are involved. We could improve this in a future iteration.
|
||||
}
|
||||
}
|
||||
|
||||
// Clear the rollback data
|
||||
self.removed_folders.clear();
|
||||
self.moved_asset_ids.clear();
|
||||
self.deleted_asset_ids.clear();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn description(&self) -> String {
|
||||
match self.strategy {
|
||||
DeleteStrategy::MoveToParent => "Delete folder (move contents to parent)".to_string(),
|
||||
DeleteStrategy::DeleteRecursive => "Delete folder and all contents".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::actions::create_folder::CreateFolderAction;
|
||||
use crate::clip::VectorClip;
|
||||
|
||||
#[test]
|
||||
fn test_delete_empty_folder() {
|
||||
let mut document = Document::new("Test");
|
||||
|
||||
// Create a folder
|
||||
let mut create_action =
|
||||
CreateFolderAction::new(AssetCategory::Vector, "Empty Folder", None);
|
||||
create_action.execute(&mut document).unwrap();
|
||||
let folder_id = create_action.created_folder_id().unwrap();
|
||||
|
||||
// Delete it
|
||||
let mut delete_action =
|
||||
DeleteFolderAction::new(AssetCategory::Vector, folder_id, DeleteStrategy::MoveToParent);
|
||||
delete_action.execute(&mut document).unwrap();
|
||||
|
||||
// Verify folder was deleted
|
||||
let tree = document.get_folder_tree(AssetCategory::Vector);
|
||||
assert_eq!(tree.folders.len(), 0);
|
||||
|
||||
// Rollback
|
||||
delete_action.rollback(&mut document).unwrap();
|
||||
|
||||
// Verify folder was restored
|
||||
let tree = document.get_folder_tree(AssetCategory::Vector);
|
||||
assert_eq!(tree.folders.len(), 1);
|
||||
assert_eq!(tree.folders[&folder_id].name, "Empty Folder");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_folder_move_to_parent() {
|
||||
let mut document = Document::new("Test");
|
||||
|
||||
// Create a folder
|
||||
let mut create_action =
|
||||
CreateFolderAction::new(AssetCategory::Vector, "Folder", None);
|
||||
create_action.execute(&mut document).unwrap();
|
||||
let folder_id = create_action.created_folder_id().unwrap();
|
||||
|
||||
// Add a clip to the folder
|
||||
let mut clip = VectorClip::new("Test Clip", 100.0, 100.0, 5.0);
|
||||
clip.folder_id = Some(folder_id);
|
||||
let clip_id = clip.id;
|
||||
document.vector_clips.insert(clip_id, clip);
|
||||
|
||||
// Delete folder with MoveToParent strategy
|
||||
let mut delete_action =
|
||||
DeleteFolderAction::new(AssetCategory::Vector, folder_id, DeleteStrategy::MoveToParent);
|
||||
delete_action.execute(&mut document).unwrap();
|
||||
|
||||
// Verify folder was deleted
|
||||
let tree = document.get_folder_tree(AssetCategory::Vector);
|
||||
assert_eq!(tree.folders.len(), 0);
|
||||
|
||||
// Verify clip was moved to root (folder_id = None)
|
||||
assert_eq!(document.vector_clips[&clip_id].folder_id, None);
|
||||
|
||||
// Rollback
|
||||
delete_action.rollback(&mut document).unwrap();
|
||||
|
||||
// Verify folder was restored
|
||||
let tree = document.get_folder_tree(AssetCategory::Vector);
|
||||
assert_eq!(tree.folders.len(), 1);
|
||||
|
||||
// Verify clip is back in folder
|
||||
assert_eq!(
|
||||
document.vector_clips[&clip_id].folder_id,
|
||||
Some(folder_id)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_folder_with_subfolders() {
|
||||
let mut document = Document::new("Test");
|
||||
|
||||
// Create parent folder
|
||||
let mut parent_action =
|
||||
CreateFolderAction::new(AssetCategory::Audio, "Parent", None);
|
||||
parent_action.execute(&mut document).unwrap();
|
||||
let parent_id = parent_action.created_folder_id().unwrap();
|
||||
|
||||
// Create child folder
|
||||
let mut child_action =
|
||||
CreateFolderAction::new(AssetCategory::Audio, "Child", Some(parent_id));
|
||||
child_action.execute(&mut document).unwrap();
|
||||
let child_id = child_action.created_folder_id().unwrap();
|
||||
|
||||
// Delete parent with MoveToParent (moves child to root)
|
||||
let mut delete_action =
|
||||
DeleteFolderAction::new(AssetCategory::Audio, parent_id, DeleteStrategy::MoveToParent);
|
||||
delete_action.execute(&mut document).unwrap();
|
||||
|
||||
// Verify parent was deleted
|
||||
let tree = document.get_folder_tree(AssetCategory::Audio);
|
||||
assert!(!tree.folders.contains_key(&parent_id));
|
||||
|
||||
// Verify child was moved to root
|
||||
assert_eq!(tree.folders[&child_id].parent_id, None);
|
||||
|
||||
// Rollback
|
||||
delete_action.rollback(&mut document).unwrap();
|
||||
|
||||
// Verify both folders restored
|
||||
let tree = document.get_folder_tree(AssetCategory::Audio);
|
||||
assert_eq!(tree.folders.len(), 2);
|
||||
assert_eq!(tree.folders[&child_id].parent_id, Some(parent_id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_nonexistent_folder() {
|
||||
let mut document = Document::new("Test");
|
||||
let fake_id = Uuid::new_v4();
|
||||
|
||||
let mut action =
|
||||
DeleteFolderAction::new(AssetCategory::Images, fake_id, DeleteStrategy::MoveToParent);
|
||||
let result = action.execute(&mut document);
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("not found"));
|
||||
}
|
||||
}
|
||||
|
|
@ -20,6 +20,10 @@ pub mod split_clip_instance;
|
|||
pub mod transform_clip_instances;
|
||||
pub mod transform_objects;
|
||||
pub mod trim_clip_instances;
|
||||
pub mod create_folder;
|
||||
pub mod rename_folder;
|
||||
pub mod delete_folder;
|
||||
pub mod move_asset_to_folder;
|
||||
|
||||
pub use add_clip_instance::AddClipInstanceAction;
|
||||
pub use add_effect::AddEffectAction;
|
||||
|
|
@ -38,3 +42,7 @@ pub use split_clip_instance::SplitClipInstanceAction;
|
|||
pub use transform_clip_instances::TransformClipInstancesAction;
|
||||
pub use transform_objects::TransformShapeInstancesAction;
|
||||
pub use trim_clip_instances::{TrimClipInstancesAction, TrimData, TrimType};
|
||||
pub use create_folder::CreateFolderAction;
|
||||
pub use rename_folder::RenameFolderAction;
|
||||
pub use delete_folder::{DeleteFolderAction, DeleteStrategy};
|
||||
pub use move_asset_to_folder::MoveAssetToFolderAction;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,319 @@
|
|||
//! Move asset to folder action
|
||||
//!
|
||||
//! Handles moving an asset between folders in the asset library.
|
||||
|
||||
use crate::action::Action;
|
||||
use crate::document::{AssetCategory, Document};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Action that moves an asset to a different folder
|
||||
pub struct MoveAssetToFolderAction {
|
||||
/// Asset category
|
||||
category: AssetCategory,
|
||||
|
||||
/// Asset ID to move
|
||||
asset_id: Uuid,
|
||||
|
||||
/// New folder ID (None = move to root)
|
||||
new_folder_id: Option<Uuid>,
|
||||
|
||||
/// Old folder ID (stored after execution for rollback)
|
||||
old_folder_id: Option<Option<Uuid>>,
|
||||
}
|
||||
|
||||
impl MoveAssetToFolderAction {
|
||||
/// Create a new move asset to folder action
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `category` - Which asset category the asset is in
|
||||
/// * `asset_id` - ID of the asset to move
|
||||
/// * `new_folder_id` - ID of the destination folder (None = root)
|
||||
pub fn new(
|
||||
category: AssetCategory,
|
||||
asset_id: Uuid,
|
||||
new_folder_id: Option<Uuid>,
|
||||
) -> Self {
|
||||
Self {
|
||||
category,
|
||||
asset_id,
|
||||
new_folder_id,
|
||||
old_folder_id: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Action for MoveAssetToFolderAction {
|
||||
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
|
||||
// Validate that the destination folder exists (if specified)
|
||||
if let Some(folder_id) = self.new_folder_id {
|
||||
let tree = document.get_folder_tree(self.category);
|
||||
if !tree.folders.contains_key(&folder_id) {
|
||||
return Err(format!("Destination folder {} not found", folder_id));
|
||||
}
|
||||
}
|
||||
|
||||
// Find the asset and update its folder_id
|
||||
match self.category {
|
||||
AssetCategory::Vector => {
|
||||
let clip = document
|
||||
.vector_clips
|
||||
.get_mut(&self.asset_id)
|
||||
.ok_or_else(|| format!("Vector clip {} not found", self.asset_id))?;
|
||||
|
||||
self.old_folder_id = Some(clip.folder_id);
|
||||
clip.folder_id = self.new_folder_id;
|
||||
}
|
||||
AssetCategory::Video => {
|
||||
let clip = document
|
||||
.video_clips
|
||||
.get_mut(&self.asset_id)
|
||||
.ok_or_else(|| format!("Video clip {} not found", self.asset_id))?;
|
||||
|
||||
self.old_folder_id = Some(clip.folder_id);
|
||||
clip.folder_id = self.new_folder_id;
|
||||
}
|
||||
AssetCategory::Audio => {
|
||||
let clip = document
|
||||
.audio_clips
|
||||
.get_mut(&self.asset_id)
|
||||
.ok_or_else(|| format!("Audio clip {} not found", self.asset_id))?;
|
||||
|
||||
self.old_folder_id = Some(clip.folder_id);
|
||||
clip.folder_id = self.new_folder_id;
|
||||
}
|
||||
AssetCategory::Images => {
|
||||
let asset = document
|
||||
.image_assets
|
||||
.get_mut(&self.asset_id)
|
||||
.ok_or_else(|| format!("Image asset {} not found", self.asset_id))?;
|
||||
|
||||
self.old_folder_id = Some(asset.folder_id);
|
||||
asset.folder_id = self.new_folder_id;
|
||||
}
|
||||
AssetCategory::Effects => {
|
||||
let effect = document
|
||||
.effect_definitions
|
||||
.get_mut(&self.asset_id)
|
||||
.ok_or_else(|| format!("Effect definition {} not found", self.asset_id))?;
|
||||
|
||||
self.old_folder_id = Some(effect.folder_id);
|
||||
effect.folder_id = self.new_folder_id;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
|
||||
// Restore the old folder_id
|
||||
if let Some(old_folder_id) = self.old_folder_id {
|
||||
match self.category {
|
||||
AssetCategory::Vector => {
|
||||
if let Some(clip) = document.vector_clips.get_mut(&self.asset_id) {
|
||||
clip.folder_id = old_folder_id;
|
||||
}
|
||||
}
|
||||
AssetCategory::Video => {
|
||||
if let Some(clip) = document.video_clips.get_mut(&self.asset_id) {
|
||||
clip.folder_id = old_folder_id;
|
||||
}
|
||||
}
|
||||
AssetCategory::Audio => {
|
||||
if let Some(clip) = document.audio_clips.get_mut(&self.asset_id) {
|
||||
clip.folder_id = old_folder_id;
|
||||
}
|
||||
}
|
||||
AssetCategory::Images => {
|
||||
if let Some(asset) = document.image_assets.get_mut(&self.asset_id) {
|
||||
asset.folder_id = old_folder_id;
|
||||
}
|
||||
}
|
||||
AssetCategory::Effects => {
|
||||
if let Some(effect) = document.effect_definitions.get_mut(&self.asset_id) {
|
||||
effect.folder_id = old_folder_id;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn description(&self) -> String {
|
||||
if self.new_folder_id.is_some() {
|
||||
"Move asset to folder".to_string()
|
||||
} else {
|
||||
"Move asset to root".to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::actions::create_folder::CreateFolderAction;
|
||||
use crate::clip::VectorClip;
|
||||
|
||||
#[test]
|
||||
fn test_move_asset_to_folder() {
|
||||
let mut document = Document::new("Test");
|
||||
|
||||
// Create a folder
|
||||
let mut create_action =
|
||||
CreateFolderAction::new(AssetCategory::Vector, "My Folder", None);
|
||||
create_action.execute(&mut document).unwrap();
|
||||
let folder_id = create_action.created_folder_id().unwrap();
|
||||
|
||||
// Create a clip at root
|
||||
let clip = VectorClip::new("Test Clip", 100.0, 100.0, 5.0);
|
||||
let clip_id = clip.id;
|
||||
document.vector_clips.insert(clip_id, clip);
|
||||
|
||||
// Verify clip is at root
|
||||
assert_eq!(document.vector_clips[&clip_id].folder_id, None);
|
||||
|
||||
// Move clip to folder
|
||||
let mut move_action =
|
||||
MoveAssetToFolderAction::new(AssetCategory::Vector, clip_id, Some(folder_id));
|
||||
move_action.execute(&mut document).unwrap();
|
||||
|
||||
// Verify clip moved
|
||||
assert_eq!(
|
||||
document.vector_clips[&clip_id].folder_id,
|
||||
Some(folder_id)
|
||||
);
|
||||
|
||||
// Rollback
|
||||
move_action.rollback(&mut document).unwrap();
|
||||
|
||||
// Verify clip back at root
|
||||
assert_eq!(document.vector_clips[&clip_id].folder_id, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_move_asset_to_root() {
|
||||
let mut document = Document::new("Test");
|
||||
|
||||
// Create a folder
|
||||
let mut create_action =
|
||||
CreateFolderAction::new(AssetCategory::Video, "Videos", None);
|
||||
create_action.execute(&mut document).unwrap();
|
||||
let folder_id = create_action.created_folder_id().unwrap();
|
||||
|
||||
// Create a clip in the folder
|
||||
let mut clip = crate::clip::VideoClip::new(
|
||||
"Test Video",
|
||||
"test.mp4",
|
||||
1920.0,
|
||||
1080.0,
|
||||
10.0,
|
||||
30.0,
|
||||
);
|
||||
clip.folder_id = Some(folder_id);
|
||||
let clip_id = clip.id;
|
||||
document.video_clips.insert(clip_id, clip);
|
||||
|
||||
// Move clip to root
|
||||
let mut move_action = MoveAssetToFolderAction::new(AssetCategory::Video, clip_id, None);
|
||||
move_action.execute(&mut document).unwrap();
|
||||
|
||||
// Verify clip at root
|
||||
assert_eq!(document.video_clips[&clip_id].folder_id, None);
|
||||
|
||||
// Rollback
|
||||
move_action.rollback(&mut document).unwrap();
|
||||
|
||||
// Verify clip back in folder
|
||||
assert_eq!(
|
||||
document.video_clips[&clip_id].folder_id,
|
||||
Some(folder_id)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_move_asset_between_folders() {
|
||||
let mut document = Document::new("Test");
|
||||
|
||||
// Create two folders
|
||||
let mut folder1_action =
|
||||
CreateFolderAction::new(AssetCategory::Audio, "Folder 1", None);
|
||||
folder1_action.execute(&mut document).unwrap();
|
||||
let folder1_id = folder1_action.created_folder_id().unwrap();
|
||||
|
||||
let mut folder2_action =
|
||||
CreateFolderAction::new(AssetCategory::Audio, "Folder 2", None);
|
||||
folder2_action.execute(&mut document).unwrap();
|
||||
let folder2_id = folder2_action.created_folder_id().unwrap();
|
||||
|
||||
// Create a clip in folder 1
|
||||
let mut clip = crate::clip::AudioClip::new_sampled("Test Audio", 5.0, 0);
|
||||
clip.folder_id = Some(folder1_id);
|
||||
let clip_id = clip.id;
|
||||
document.audio_clips.insert(clip_id, clip);
|
||||
|
||||
// Move to folder 2
|
||||
let mut move_action =
|
||||
MoveAssetToFolderAction::new(AssetCategory::Audio, clip_id, Some(folder2_id));
|
||||
move_action.execute(&mut document).unwrap();
|
||||
|
||||
// Verify in folder 2
|
||||
assert_eq!(
|
||||
document.audio_clips[&clip_id].folder_id,
|
||||
Some(folder2_id)
|
||||
);
|
||||
|
||||
// Rollback
|
||||
move_action.rollback(&mut document).unwrap();
|
||||
|
||||
// Verify back in folder 1
|
||||
assert_eq!(
|
||||
document.audio_clips[&clip_id].folder_id,
|
||||
Some(folder1_id)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_move_to_nonexistent_folder() {
|
||||
let mut document = Document::new("Test");
|
||||
|
||||
// Create a clip
|
||||
let clip = VectorClip::new("Test", 100.0, 100.0, 5.0);
|
||||
let clip_id = clip.id;
|
||||
document.vector_clips.insert(clip_id, clip);
|
||||
|
||||
// Try to move to nonexistent folder
|
||||
let fake_folder_id = Uuid::new_v4();
|
||||
let mut action =
|
||||
MoveAssetToFolderAction::new(AssetCategory::Vector, clip_id, Some(fake_folder_id));
|
||||
let result = action.execute(&mut document);
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("not found"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_move_nonexistent_asset() {
|
||||
let mut document = Document::new("Test");
|
||||
|
||||
let fake_asset_id = Uuid::new_v4();
|
||||
let mut action = MoveAssetToFolderAction::new(AssetCategory::Images, fake_asset_id, None);
|
||||
let result = action.execute(&mut document);
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("not found"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_move_description() {
|
||||
let asset_id = Uuid::new_v4();
|
||||
let folder_id = Uuid::new_v4();
|
||||
|
||||
let action1 =
|
||||
MoveAssetToFolderAction::new(AssetCategory::Effects, asset_id, Some(folder_id));
|
||||
assert_eq!(action1.description(), "Move asset to folder");
|
||||
|
||||
let action2 = MoveAssetToFolderAction::new(AssetCategory::Effects, asset_id, None);
|
||||
assert_eq!(action2.description(), "Move asset to root");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,173 @@
|
|||
//! Rename folder action
|
||||
//!
|
||||
//! Handles renaming a folder in the asset library.
|
||||
|
||||
use crate::action::Action;
|
||||
use crate::document::{AssetCategory, Document};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Action that renames a folder
|
||||
pub struct RenameFolderAction {
|
||||
/// Asset category for this folder
|
||||
category: AssetCategory,
|
||||
|
||||
/// Folder ID to rename
|
||||
folder_id: Uuid,
|
||||
|
||||
/// New folder name
|
||||
new_name: String,
|
||||
|
||||
/// Old folder name (stored after execution for rollback)
|
||||
old_name: Option<String>,
|
||||
}
|
||||
|
||||
impl RenameFolderAction {
|
||||
/// Create a new rename folder action
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `category` - Which asset category the folder is in
|
||||
/// * `folder_id` - ID of the folder to rename
|
||||
/// * `new_name` - The new name for the folder
|
||||
pub fn new(category: AssetCategory, folder_id: Uuid, new_name: impl Into<String>) -> Self {
|
||||
Self {
|
||||
category,
|
||||
folder_id,
|
||||
new_name: new_name.into(),
|
||||
old_name: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Action for RenameFolderAction {
|
||||
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
|
||||
// Get the folder tree
|
||||
let tree = document.get_folder_tree_mut(self.category);
|
||||
|
||||
// Get the folder
|
||||
let folder = tree
|
||||
.folders
|
||||
.get_mut(&self.folder_id)
|
||||
.ok_or_else(|| format!("Folder {} not found", self.folder_id))?;
|
||||
|
||||
// Store old name for rollback
|
||||
self.old_name = Some(folder.name.clone());
|
||||
|
||||
// Update the name
|
||||
folder.name = self.new_name.clone();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
|
||||
// Get the folder tree
|
||||
let tree = document.get_folder_tree_mut(self.category);
|
||||
|
||||
// Get the folder
|
||||
let folder = tree
|
||||
.folders
|
||||
.get_mut(&self.folder_id)
|
||||
.ok_or_else(|| format!("Folder {} not found", self.folder_id))?;
|
||||
|
||||
// Restore old name
|
||||
if let Some(old_name) = &self.old_name {
|
||||
folder.name = old_name.clone();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn description(&self) -> String {
|
||||
format!("Rename folder to '{}'", self.new_name)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::actions::create_folder::CreateFolderAction;
|
||||
|
||||
#[test]
|
||||
fn test_rename_folder() {
|
||||
let mut document = Document::new("Test");
|
||||
|
||||
// Create a folder first
|
||||
let mut create_action =
|
||||
CreateFolderAction::new(AssetCategory::Vector, "Original Name", None);
|
||||
create_action.execute(&mut document).unwrap();
|
||||
let folder_id = create_action.created_folder_id().unwrap();
|
||||
|
||||
// Rename it
|
||||
let mut rename_action =
|
||||
RenameFolderAction::new(AssetCategory::Vector, folder_id, "New Name");
|
||||
rename_action.execute(&mut document).unwrap();
|
||||
|
||||
// Verify name changed
|
||||
let tree = document.get_folder_tree(AssetCategory::Vector);
|
||||
let folder = &tree.folders[&folder_id];
|
||||
assert_eq!(folder.name, "New Name");
|
||||
|
||||
// Rollback
|
||||
rename_action.rollback(&mut document).unwrap();
|
||||
|
||||
// Verify name restored
|
||||
let tree = document.get_folder_tree(AssetCategory::Vector);
|
||||
let folder = &tree.folders[&folder_id];
|
||||
assert_eq!(folder.name, "Original Name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rename_nonexistent_folder() {
|
||||
let mut document = Document::new("Test");
|
||||
let fake_id = Uuid::new_v4();
|
||||
|
||||
let mut action = RenameFolderAction::new(AssetCategory::Audio, fake_id, "New Name");
|
||||
let result = action.execute(&mut document);
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("not found"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rename_description() {
|
||||
let folder_id = Uuid::new_v4();
|
||||
let action = RenameFolderAction::new(AssetCategory::Images, folder_id, "Photos 2024");
|
||||
assert_eq!(action.description(), "Rename folder to 'Photos 2024'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multiple_renames() {
|
||||
let mut document = Document::new("Test");
|
||||
|
||||
// Create a folder
|
||||
let mut create_action = CreateFolderAction::new(AssetCategory::Effects, "V1", None);
|
||||
create_action.execute(&mut document).unwrap();
|
||||
let folder_id = create_action.created_folder_id().unwrap();
|
||||
|
||||
// Rename multiple times
|
||||
let mut rename1 = RenameFolderAction::new(AssetCategory::Effects, folder_id, "V2");
|
||||
let mut rename2 = RenameFolderAction::new(AssetCategory::Effects, folder_id, "V3");
|
||||
let mut rename3 = RenameFolderAction::new(AssetCategory::Effects, folder_id, "Final");
|
||||
|
||||
rename1.execute(&mut document).unwrap();
|
||||
rename2.execute(&mut document).unwrap();
|
||||
rename3.execute(&mut document).unwrap();
|
||||
|
||||
// Verify final name
|
||||
let tree = document.get_folder_tree(AssetCategory::Effects);
|
||||
assert_eq!(tree.folders[&folder_id].name, "Final");
|
||||
|
||||
// Rollback in reverse order
|
||||
rename3.rollback(&mut document).unwrap();
|
||||
let tree = document.get_folder_tree(AssetCategory::Effects);
|
||||
assert_eq!(tree.folders[&folder_id].name, "V3");
|
||||
|
||||
rename2.rollback(&mut document).unwrap();
|
||||
let tree = document.get_folder_tree(AssetCategory::Effects);
|
||||
assert_eq!(tree.folders[&folder_id].name, "V2");
|
||||
|
||||
rename1.rollback(&mut document).unwrap();
|
||||
let tree = document.get_folder_tree(AssetCategory::Effects);
|
||||
assert_eq!(tree.folders[&folder_id].name, "V1");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,263 @@
|
|||
//! Asset library folder organization
|
||||
//!
|
||||
//! Provides hierarchical folder structure for organizing assets in the library.
|
||||
//! Each asset category (Vector, Video, Audio, Images, Effects) has its own
|
||||
//! independent folder tree with unlimited nesting depth.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Metadata for an asset library folder
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct AssetFolder {
|
||||
/// Unique identifier
|
||||
pub id: Uuid,
|
||||
|
||||
/// Folder name
|
||||
pub name: String,
|
||||
|
||||
/// Parent folder ID (None for root-level folders)
|
||||
pub parent_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
impl AssetFolder {
|
||||
/// Create a new folder
|
||||
pub fn new(name: impl Into<String>, parent_id: Option<Uuid>) -> Self {
|
||||
Self {
|
||||
id: Uuid::new_v4(),
|
||||
name: name.into(),
|
||||
parent_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Folder tree for a specific asset category
|
||||
///
|
||||
/// Uses a flat HashMap for efficient O(1) lookup, with parent_id references
|
||||
/// for hierarchy. This matches the Document's asset storage pattern.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Default)]
|
||||
pub struct AssetFolderTree {
|
||||
/// All folders in this category, keyed by ID
|
||||
pub folders: HashMap<Uuid, AssetFolder>,
|
||||
}
|
||||
|
||||
impl AssetFolderTree {
|
||||
/// Create a new empty folder tree
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Add a folder to the tree
|
||||
///
|
||||
/// Returns the folder's ID for convenience
|
||||
pub fn add_folder(&mut self, folder: AssetFolder) -> Uuid {
|
||||
let id = folder.id;
|
||||
self.folders.insert(id, folder);
|
||||
id
|
||||
}
|
||||
|
||||
/// Remove a folder and all its children recursively
|
||||
///
|
||||
/// Returns the removed folder and all descendants for undo support
|
||||
pub fn remove_folder(&mut self, folder_id: &Uuid) -> Vec<AssetFolder> {
|
||||
let mut removed = Vec::new();
|
||||
|
||||
// Collect all descendant IDs using breadth-first traversal
|
||||
let mut to_remove = vec![*folder_id];
|
||||
let mut i = 0;
|
||||
while i < to_remove.len() {
|
||||
let current_id = to_remove[i];
|
||||
|
||||
// Find children of current folder
|
||||
for (child_id, child) in &self.folders {
|
||||
if child.parent_id == Some(current_id) {
|
||||
to_remove.push(*child_id);
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
// Remove all collected folders
|
||||
for id in to_remove {
|
||||
if let Some(folder) = self.folders.remove(&id) {
|
||||
removed.push(folder);
|
||||
}
|
||||
}
|
||||
|
||||
removed
|
||||
}
|
||||
|
||||
/// Get all root folders (folders with no parent)
|
||||
///
|
||||
/// Returns folders sorted alphabetically by name (case-insensitive)
|
||||
pub fn root_folders(&self) -> Vec<&AssetFolder> {
|
||||
let mut roots: Vec<_> = self.folders.values()
|
||||
.filter(|f| f.parent_id.is_none())
|
||||
.collect();
|
||||
roots.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
|
||||
roots
|
||||
}
|
||||
|
||||
/// Get children of a specific folder
|
||||
///
|
||||
/// Returns folders sorted alphabetically by name (case-insensitive)
|
||||
pub fn children_of(&self, parent_id: &Uuid) -> Vec<&AssetFolder> {
|
||||
let mut children: Vec<_> = self.folders.values()
|
||||
.filter(|f| f.parent_id == Some(*parent_id))
|
||||
.collect();
|
||||
children.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
|
||||
children
|
||||
}
|
||||
|
||||
/// Get the full path from root to a folder
|
||||
///
|
||||
/// Returns a vector of folder IDs starting from root and ending at the specified folder
|
||||
pub fn path_to_folder(&self, folder_id: &Uuid) -> Vec<Uuid> {
|
||||
let mut path = Vec::new();
|
||||
let mut current_id = Some(*folder_id);
|
||||
|
||||
// Walk up the tree from folder to root
|
||||
while let Some(id) = current_id {
|
||||
path.insert(0, id);
|
||||
current_id = self.folders.get(&id).and_then(|f| f.parent_id);
|
||||
}
|
||||
|
||||
path
|
||||
}
|
||||
|
||||
/// Check if folder_a is a descendant of folder_b (or the same folder)
|
||||
///
|
||||
/// Used to prevent circular references when moving folders
|
||||
pub fn is_descendant_of(&self, folder_a: &Uuid, folder_b: &Uuid) -> bool {
|
||||
if folder_a == folder_b {
|
||||
return true;
|
||||
}
|
||||
|
||||
let mut current_id = self.folders.get(folder_a).and_then(|f| f.parent_id);
|
||||
|
||||
// Walk up from folder_a, looking for folder_b
|
||||
while let Some(id) = current_id {
|
||||
if id == *folder_b {
|
||||
return true;
|
||||
}
|
||||
current_id = self.folders.get(&id).and_then(|f| f.parent_id);
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_create_folder() {
|
||||
let folder = AssetFolder::new("Test Folder", None);
|
||||
assert_eq!(folder.name, "Test Folder");
|
||||
assert_eq!(folder.parent_id, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_add_and_get_root_folders() {
|
||||
let mut tree = AssetFolderTree::new();
|
||||
|
||||
let folder1 = AssetFolder::new("Folder B", None);
|
||||
let folder2 = AssetFolder::new("Folder A", None);
|
||||
|
||||
tree.add_folder(folder1);
|
||||
tree.add_folder(folder2);
|
||||
|
||||
let roots = tree.root_folders();
|
||||
assert_eq!(roots.len(), 2);
|
||||
// Should be sorted alphabetically
|
||||
assert_eq!(roots[0].name, "Folder A");
|
||||
assert_eq!(roots[1].name, "Folder B");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_children_of() {
|
||||
let mut tree = AssetFolderTree::new();
|
||||
|
||||
let parent = AssetFolder::new("Parent", None);
|
||||
let parent_id = tree.add_folder(parent);
|
||||
|
||||
let child1 = AssetFolder::new("Child B", Some(parent_id));
|
||||
let child2 = AssetFolder::new("Child A", Some(parent_id));
|
||||
|
||||
tree.add_folder(child1);
|
||||
tree.add_folder(child2);
|
||||
|
||||
let children = tree.children_of(&parent_id);
|
||||
assert_eq!(children.len(), 2);
|
||||
// Should be sorted alphabetically
|
||||
assert_eq!(children[0].name, "Child A");
|
||||
assert_eq!(children[1].name, "Child B");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_path_to_folder() {
|
||||
let mut tree = AssetFolderTree::new();
|
||||
|
||||
let root = AssetFolder::new("Root", None);
|
||||
let root_id = tree.add_folder(root);
|
||||
|
||||
let child = AssetFolder::new("Child", Some(root_id));
|
||||
let child_id = tree.add_folder(child);
|
||||
|
||||
let grandchild = AssetFolder::new("Grandchild", Some(child_id));
|
||||
let grandchild_id = tree.add_folder(grandchild);
|
||||
|
||||
let path = tree.path_to_folder(&grandchild_id);
|
||||
assert_eq!(path, vec![root_id, child_id, grandchild_id]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_descendant_of() {
|
||||
let mut tree = AssetFolderTree::new();
|
||||
|
||||
let root = AssetFolder::new("Root", None);
|
||||
let root_id = tree.add_folder(root);
|
||||
|
||||
let child = AssetFolder::new("Child", Some(root_id));
|
||||
let child_id = tree.add_folder(child);
|
||||
|
||||
let grandchild = AssetFolder::new("Grandchild", Some(child_id));
|
||||
let grandchild_id = tree.add_folder(grandchild);
|
||||
|
||||
// Grandchild is descendant of child
|
||||
assert!(tree.is_descendant_of(&grandchild_id, &child_id));
|
||||
|
||||
// Grandchild is descendant of root
|
||||
assert!(tree.is_descendant_of(&grandchild_id, &root_id));
|
||||
|
||||
// Child is not descendant of grandchild
|
||||
assert!(!tree.is_descendant_of(&child_id, &grandchild_id));
|
||||
|
||||
// Folder is descendant of itself
|
||||
assert!(tree.is_descendant_of(&child_id, &child_id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove_folder_recursive() {
|
||||
let mut tree = AssetFolderTree::new();
|
||||
|
||||
let root = AssetFolder::new("Root", None);
|
||||
let root_id = tree.add_folder(root);
|
||||
|
||||
let child1 = AssetFolder::new("Child1", Some(root_id));
|
||||
let child1_id = tree.add_folder(child1);
|
||||
|
||||
let child2 = AssetFolder::new("Child2", Some(root_id));
|
||||
tree.add_folder(child2);
|
||||
|
||||
let grandchild = AssetFolder::new("Grandchild", Some(child1_id));
|
||||
tree.add_folder(grandchild);
|
||||
|
||||
// Remove root folder (should remove all descendants)
|
||||
let removed = tree.remove_folder(&root_id);
|
||||
assert_eq!(removed.len(), 4); // root + 2 children + 1 grandchild
|
||||
assert_eq!(tree.folders.len(), 0);
|
||||
}
|
||||
}
|
||||
|
|
@ -43,6 +43,10 @@ pub struct VectorClip {
|
|||
|
||||
/// Nested layer hierarchy
|
||||
pub layers: LayerTree<AnyLayer>,
|
||||
|
||||
/// Folder this clip belongs to (None = root of category)
|
||||
#[serde(default)]
|
||||
pub folder_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
impl VectorClip {
|
||||
|
|
@ -55,6 +59,7 @@ impl VectorClip {
|
|||
height,
|
||||
duration,
|
||||
layers: LayerTree::new(),
|
||||
folder_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -73,6 +78,7 @@ impl VectorClip {
|
|||
height,
|
||||
duration,
|
||||
layers: LayerTree::new(),
|
||||
folder_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -185,6 +191,10 @@ pub struct ImageAsset {
|
|||
/// If None, the image will be loaded from path when needed
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub data: Option<Vec<u8>>,
|
||||
|
||||
/// Folder this asset belongs to (None = root of category)
|
||||
#[serde(default)]
|
||||
pub folder_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
impl ImageAsset {
|
||||
|
|
@ -202,6 +212,7 @@ impl ImageAsset {
|
|||
width,
|
||||
height,
|
||||
data: None,
|
||||
folder_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -220,6 +231,7 @@ impl ImageAsset {
|
|||
width,
|
||||
height,
|
||||
data: Some(data),
|
||||
folder_id: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -252,6 +264,10 @@ pub struct VideoClip {
|
|||
/// When set, the audio clip should be moved/trimmed in sync with this video clip
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub linked_audio_clip_id: Option<Uuid>,
|
||||
|
||||
/// Folder this clip belongs to (None = root of category)
|
||||
#[serde(default)]
|
||||
pub folder_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
impl VideoClip {
|
||||
|
|
@ -273,6 +289,7 @@ impl VideoClip {
|
|||
duration,
|
||||
frame_rate,
|
||||
linked_audio_clip_id: None,
|
||||
folder_id: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -366,6 +383,10 @@ pub struct AudioClip {
|
|||
|
||||
/// Audio clip type (sampled or MIDI)
|
||||
pub clip_type: AudioClipType,
|
||||
|
||||
/// Folder this clip belongs to (None = root of category)
|
||||
#[serde(default)]
|
||||
pub folder_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
impl AudioClip {
|
||||
|
|
@ -381,6 +402,7 @@ impl AudioClip {
|
|||
name: name.into(),
|
||||
duration,
|
||||
clip_type: AudioClipType::Sampled { audio_pool_index },
|
||||
folder_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -400,6 +422,7 @@ impl AudioClip {
|
|||
name: name.into(),
|
||||
duration,
|
||||
clip_type: AudioClipType::Midi { midi_clip_id },
|
||||
folder_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
//! The Document represents a complete animation project with settings
|
||||
//! and a root graphics object containing the scene graph.
|
||||
|
||||
use crate::asset_folder::AssetFolderTree;
|
||||
use crate::clip::{AudioClip, ClipInstance, ImageAsset, VideoClip, VectorClip};
|
||||
use crate::effect::EffectDefinition;
|
||||
use crate::layer::AnyLayer;
|
||||
|
|
@ -68,6 +69,16 @@ impl Default for GraphicsObject {
|
|||
}
|
||||
}
|
||||
|
||||
/// Asset category for folder tree access
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AssetCategory {
|
||||
Vector,
|
||||
Video,
|
||||
Audio,
|
||||
Images,
|
||||
Effects,
|
||||
}
|
||||
|
||||
/// Document settings and scene
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Document {
|
||||
|
|
@ -115,6 +126,26 @@ pub struct Document {
|
|||
#[serde(default)]
|
||||
pub effect_definitions: HashMap<Uuid, EffectDefinition>,
|
||||
|
||||
/// Folder organization for vector clips
|
||||
#[serde(default)]
|
||||
pub vector_folders: AssetFolderTree,
|
||||
|
||||
/// Folder organization for video clips
|
||||
#[serde(default)]
|
||||
pub video_folders: AssetFolderTree,
|
||||
|
||||
/// Folder organization for audio clips
|
||||
#[serde(default)]
|
||||
pub audio_folders: AssetFolderTree,
|
||||
|
||||
/// Folder organization for image assets
|
||||
#[serde(default)]
|
||||
pub image_folders: AssetFolderTree,
|
||||
|
||||
/// Folder organization for effect definitions
|
||||
#[serde(default)]
|
||||
pub effect_folders: AssetFolderTree,
|
||||
|
||||
/// Current UI layout state (serialized for save/load)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub ui_layout: Option<LayoutNode>,
|
||||
|
|
@ -145,6 +176,11 @@ impl Default for Document {
|
|||
image_assets: HashMap::new(),
|
||||
instance_groups: HashMap::new(),
|
||||
effect_definitions: HashMap::new(),
|
||||
vector_folders: AssetFolderTree::new(),
|
||||
video_folders: AssetFolderTree::new(),
|
||||
audio_folders: AssetFolderTree::new(),
|
||||
image_folders: AssetFolderTree::new(),
|
||||
effect_folders: AssetFolderTree::new(),
|
||||
ui_layout: None,
|
||||
ui_layout_base: None,
|
||||
current_time: 0.0,
|
||||
|
|
@ -194,6 +230,28 @@ impl Document {
|
|||
self.width / self.height
|
||||
}
|
||||
|
||||
/// Get the folder tree for a specific asset category
|
||||
pub fn get_folder_tree(&self, category: AssetCategory) -> &AssetFolderTree {
|
||||
match category {
|
||||
AssetCategory::Vector => &self.vector_folders,
|
||||
AssetCategory::Video => &self.video_folders,
|
||||
AssetCategory::Audio => &self.audio_folders,
|
||||
AssetCategory::Images => &self.image_folders,
|
||||
AssetCategory::Effects => &self.effect_folders,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a mutable reference to the folder tree for a specific asset category
|
||||
pub fn get_folder_tree_mut(&mut self, category: AssetCategory) -> &mut AssetFolderTree {
|
||||
match category {
|
||||
AssetCategory::Vector => &mut self.vector_folders,
|
||||
AssetCategory::Video => &mut self.video_folders,
|
||||
AssetCategory::Audio => &mut self.audio_folders,
|
||||
AssetCategory::Images => &mut self.image_folders,
|
||||
AssetCategory::Effects => &mut self.effect_folders,
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate the actual timeline endpoint based on the last clip
|
||||
///
|
||||
/// Returns the end time of the last clip instance across all layers,
|
||||
|
|
|
|||
|
|
@ -284,6 +284,10 @@ pub struct EffectDefinition {
|
|||
pub inputs: Vec<EffectInput>,
|
||||
/// Parameter definitions
|
||||
pub parameters: Vec<EffectParameterDef>,
|
||||
|
||||
/// Folder this effect belongs to (None = root of category)
|
||||
#[serde(default)]
|
||||
pub folder_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
impl EffectDefinition {
|
||||
|
|
@ -302,6 +306,7 @@ impl EffectDefinition {
|
|||
shader_code: shader_code.into(),
|
||||
inputs: vec![EffectInput::composition("source")],
|
||||
parameters,
|
||||
folder_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -315,6 +320,7 @@ impl EffectDefinition {
|
|||
shader_code: shader_code.into(),
|
||||
inputs: vec![EffectInput::composition("source")],
|
||||
parameters,
|
||||
folder_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ pub mod object;
|
|||
pub mod layer;
|
||||
pub mod layer_tree;
|
||||
pub mod clip;
|
||||
pub mod asset_folder;
|
||||
pub mod instance_group;
|
||||
pub mod effect;
|
||||
pub mod effect_layer;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,17 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
id="svg-folder"
|
||||
height="16"
|
||||
width="16"
|
||||
version="1.1"
|
||||
viewBox="0 0 16 16">
|
||||
<path
|
||||
id="folder-path"
|
||||
style="fill:#ffc864;stroke:none"
|
||||
d="M 1,3 C 1,2.5 1.5,2 2,2 h 4 l 1.5,1.5 H 14 c 0.5,0 1,0.5 1,1 V 13 c 0,0.5 -0.5,1 -1,1 H 2 C 1.5,14 1,13.5 1,13 Z" />
|
||||
<path
|
||||
id="folder-tab"
|
||||
style="fill:#ffb83d;stroke:none"
|
||||
d="M 1,4.5 C 1,4 1.5,3.5 2,3.5 h 5.5 l 1,1 H 14 c 0.5,0 1,0.5 1,1 V 5.5 H 1 Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 541 B |
Loading…
Reference in New Issue