implement Spaces

This commit is contained in:
Jamie Pine
2025-11-11 07:35:58 -08:00
parent 8d61dc1139
commit d9f452f852
51 changed files with 1419 additions and 27 deletions

View File

@@ -17,9 +17,6 @@ pub struct Space {
/// Unique identifier
pub id: Uuid,
/// Library this space belongs to
pub library_id: Uuid,
/// Human-friendly name (e.g., "All Devices", "Work Files")
pub name: String,
@@ -39,11 +36,10 @@ pub struct Space {
impl Space {
/// Create a new space
pub fn new(library_id: Uuid, name: String, icon: String, color: String) -> Self {
pub fn new(name: String, icon: String, color: String) -> Self {
let now = Utc::now();
Self {
id: Uuid::new_v4(),
library_id,
name,
icon,
color,
@@ -59,9 +55,8 @@ impl Space {
}
/// Create a default "All Devices" space
pub fn create_default(library_id: Uuid) -> Self {
pub fn create_default() -> Self {
Self::new(
library_id,
"All Devices".to_string(),
"Planet".to_string(),
"#3B82F6".to_string(),
@@ -283,15 +278,12 @@ mod tests {
#[test]
fn test_space_creation() {
let library_id = Uuid::new_v4();
let space = Space::new(
library_id,
"Test Space".to_string(),
"Folder".to_string(),
"#FF0000".to_string(),
);
assert_eq!(space.library_id, library_id);
assert_eq!(space.name, "Test Space");
assert_eq!(space.icon, "Folder");
assert_eq!(space.color, "#FF0000");
@@ -300,8 +292,7 @@ mod tests {
#[test]
fn test_default_space() {
let library_id = Uuid::new_v4();
let space = Space::create_default(library_id);
let space = Space::create_default();
assert_eq!(space.name, "All Devices");
assert_eq!(space.icon, "Planet");

View File

@@ -10,7 +10,6 @@ pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub uuid: Uuid,
pub library_id: i32,
pub name: String,
pub icon: String,
pub color: String,
@@ -50,7 +49,7 @@ impl Syncable for Model {
}
fn exclude_fields() -> Option<&'static [&'static str]> {
Some(&["id", "library_id"])
Some(&["id"])
}
fn sync_depends_on() -> &'static [&'static str] {

View File

@@ -30,7 +30,6 @@ impl MigrationTrait for Migration {
.not_null()
.unique_key(),
)
.col(ColumnDef::new(Spaces::LibraryId).integer().not_null())
.col(ColumnDef::new(Spaces::Name).string().not_null())
.col(ColumnDef::new(Spaces::Icon).string().not_null())
.col(ColumnDef::new(Spaces::Color).string().not_null())
@@ -159,17 +158,7 @@ impl MigrationTrait for Migration {
)
.await?;
// Create indexes for better query performance
manager
.create_index(
Index::create()
.name("idx_spaces_library_id")
.table(Spaces::Table)
.col(Spaces::LibraryId)
.to_owned(),
)
.await?;
// Create index for better query performance
manager
.create_index(
Index::create()
@@ -246,7 +235,6 @@ enum Spaces {
Table,
Id,
Uuid,
LibraryId,
Name,
Icon,
Color,

View File

@@ -293,6 +293,9 @@ impl LibraryManager {
// Now open the library (which will call ensure_device_registered for current device)
let library = self.open_library(&library_path, context).await?;
// Create default space with Quick Access group
self.create_default_space(&library).await?;
// Emit event
self.event_bus.emit(Event::LibraryCreated {
id: library.id(),
@@ -354,6 +357,9 @@ impl LibraryManager {
// Open the newly created library
let library = self.open_library(&library_path, context.clone()).await?;
// Create default space with Quick Access group
self.create_default_space(&library).await?;
// Emit event
self.event_bus.emit(Event::LibraryCreated {
id: library.id(),
@@ -940,6 +946,89 @@ impl LibraryManager {
Ok(())
}
/// Create default space with Quick Access group for new libraries
async fn create_default_space(&self, library: &Arc<Library>) -> Result<()> {
use crate::domain::{GroupType, ItemType, Space, SpaceGroup, SpaceItem};
use chrono::Utc;
use sea_orm::{ActiveModelTrait, Set};
let db = library.db().conn();
// Create default space
let space_id = uuid::Uuid::new_v4();
let now = Utc::now();
let space_model = crate::infra::db::entities::space::ActiveModel {
id: Set(0),
uuid: Set(space_id),
name: Set("All Devices".to_string()),
icon: Set("Planet".to_string()),
color: Set("#3B82F6".to_string()),
order: Set(0),
created_at: Set(now.into()),
updated_at: Set(now.into()),
};
let space_result = space_model
.insert(db)
.await
.map_err(LibraryError::DatabaseError)?;
info!("Created default space for library {}", library.id());
// Create Quick Access group
let group_id = uuid::Uuid::new_v4();
let group_type_json = serde_json::to_string(&GroupType::QuickAccess)
.map_err(|e| LibraryError::Other(format!("Failed to serialize group_type: {}", e)))?;
let group_model = crate::infra::db::entities::space_group::ActiveModel {
id: Set(0),
uuid: Set(group_id),
space_id: Set(space_result.id),
name: Set("Quick Access".to_string()),
group_type: Set(group_type_json),
is_collapsed: Set(false),
order: Set(0),
created_at: Set(now.into()),
};
let group_result = group_model
.insert(db)
.await
.map_err(LibraryError::DatabaseError)?;
// Create Quick Access items (Overview, Recents, Favorites)
let items = vec![
(ItemType::Overview, 0),
(ItemType::Recents, 1),
(ItemType::Favorites, 2),
];
for (item_type, order) in items {
let item_type_json = serde_json::to_string(&item_type)
.map_err(|e| LibraryError::Other(format!("Failed to serialize item_type: {}", e)))?;
let item_model = crate::infra::db::entities::space_item::ActiveModel {
id: Set(0),
uuid: Set(uuid::Uuid::new_v4()),
group_id: Set(group_result.id),
item_type: Set(item_type_json),
order: Set(order),
created_at: Set(now.into()),
};
item_model
.insert(db)
.await
.map_err(LibraryError::DatabaseError)?;
}
info!("Created default Quick Access group for library {}", library.id());
Ok(())
}
/// Check if this device created the library (is the only device)
async fn is_library_creator(&self, library: &Arc<Library>) -> Result<bool> {
let db = library.db();

View File

@@ -23,6 +23,7 @@ pub mod metadata;
pub mod network;
pub mod search;
pub mod sidecar;
pub mod spaces;
pub mod sync;
pub mod tags;
pub mod volumes;

View File

@@ -0,0 +1,102 @@
use super::{input::AddGroupInput, output::AddGroupOutput};
use crate::{
context::CoreContext,
domain::SpaceGroup,
infra::action::{
error::{ActionError, ActionResult},
LibraryAction,
},
};
use chrono::Utc;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AddGroupAction {
input: AddGroupInput,
}
impl LibraryAction for AddGroupAction {
type Input = AddGroupInput;
type Output = AddGroupOutput;
fn from_input(input: AddGroupInput) -> Result<Self, String> {
if input.name.trim().is_empty() {
return Err("Group name cannot be empty".to_string());
}
Ok(Self { input })
}
async fn execute(
self,
library: std::sync::Arc<crate::library::Library>,
_context: std::sync::Arc<CoreContext>,
) -> Result<Self::Output, ActionError> {
let db = library.db().conn();
// Verify space exists
let space_model = crate::infra::db::entities::space::Entity::find()
.filter(crate::infra::db::entities::space::Column::Uuid.eq(self.input.space_id))
.one(db)
.await
.map_err(ActionError::SeaOrm)?
.ok_or_else(|| ActionError::Internal(format!("Space {} not found", self.input.space_id)))?;
// Get max order for this space
let max_order = crate::infra::db::entities::space_group::Entity::find()
.filter(crate::infra::db::entities::space_group::Column::SpaceId.eq(space_model.id))
.order_by_desc(crate::infra::db::entities::space_group::Column::Order)
.one(db)
.await
.map_err(ActionError::SeaOrm)?
.map(|g| g.order)
.unwrap_or(-1);
let group_id = uuid::Uuid::new_v4();
let now = Utc::now();
// Serialize group_type to JSON
let group_type_json = serde_json::to_string(&self.input.group_type)
.map_err(|e| ActionError::Internal(format!("Failed to serialize group_type: {}", e)))?;
let active_model = crate::infra::db::entities::space_group::ActiveModel {
id: Set(0),
uuid: Set(group_id),
space_id: Set(space_model.id),
name: Set(self.input.name.clone()),
group_type: Set(group_type_json),
is_collapsed: Set(false),
order: Set(max_order + 1),
created_at: Set(now.into()),
};
let result = active_model.insert(db).await.map_err(ActionError::SeaOrm)?;
let group = SpaceGroup {
id: result.uuid,
space_id: self.input.space_id,
name: result.name,
group_type: self.input.group_type,
is_collapsed: result.is_collapsed,
order: result.order,
created_at: result.created_at.into(),
};
Ok(AddGroupOutput { group })
}
fn action_kind(&self) -> &'static str {
"spaces.add_group"
}
async fn validate(
&self,
_library: std::sync::Arc<crate::library::Library>,
_context: std::sync::Arc<CoreContext>,
) -> ActionResult<()> {
Ok(())
}
}
crate::register_library_action!(AddGroupAction, "spaces.add_group");

View File

@@ -0,0 +1,11 @@
use crate::domain::GroupType;
use serde::{Deserialize, Serialize};
use specta::Type;
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
pub struct AddGroupInput {
pub space_id: Uuid,
pub name: String,
pub group_type: GroupType,
}

View File

@@ -0,0 +1,7 @@
pub mod action;
pub mod input;
pub mod output;
pub use action::*;
pub use input::*;
pub use output::*;

View File

@@ -0,0 +1,8 @@
use crate::domain::SpaceGroup;
use serde::{Deserialize, Serialize};
use specta::Type;
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
pub struct AddGroupOutput {
pub group: SpaceGroup,
}

View File

@@ -0,0 +1,94 @@
use super::{input::AddItemInput, output::AddItemOutput};
use crate::{
context::CoreContext,
domain::SpaceItem,
infra::action::{
error::{ActionError, ActionResult},
LibraryAction,
},
};
use chrono::Utc;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AddItemAction {
input: AddItemInput,
}
impl LibraryAction for AddItemAction {
type Input = AddItemInput;
type Output = AddItemOutput;
fn from_input(input: AddItemInput) -> Result<Self, String> {
Ok(Self { input })
}
async fn execute(
self,
library: std::sync::Arc<crate::library::Library>,
_context: std::sync::Arc<CoreContext>,
) -> Result<Self::Output, ActionError> {
let db = library.db().conn();
// Verify group exists
let group_model = crate::infra::db::entities::space_group::Entity::find()
.filter(crate::infra::db::entities::space_group::Column::Uuid.eq(self.input.group_id))
.one(db)
.await
.map_err(ActionError::SeaOrm)?
.ok_or_else(|| ActionError::Internal(format!("Group {} not found", self.input.group_id)))?;
// Get max order for this group
let max_order = crate::infra::db::entities::space_item::Entity::find()
.filter(crate::infra::db::entities::space_item::Column::GroupId.eq(group_model.id))
.order_by_desc(crate::infra::db::entities::space_item::Column::Order)
.one(db)
.await
.map_err(ActionError::SeaOrm)?
.map(|i| i.order)
.unwrap_or(-1);
let item_id = uuid::Uuid::new_v4();
let now = Utc::now();
// Serialize item_type to JSON
let item_type_json = serde_json::to_string(&self.input.item_type)
.map_err(|e| ActionError::Internal(format!("Failed to serialize item_type: {}", e)))?;
let active_model = crate::infra::db::entities::space_item::ActiveModel {
id: Set(0),
uuid: Set(item_id),
group_id: Set(group_model.id),
item_type: Set(item_type_json),
order: Set(max_order + 1),
created_at: Set(now.into()),
};
let result = active_model.insert(db).await.map_err(ActionError::SeaOrm)?;
let item = SpaceItem {
id: result.uuid,
group_id: self.input.group_id,
item_type: self.input.item_type,
order: result.order,
created_at: result.created_at.into(),
};
Ok(AddItemOutput { item })
}
fn action_kind(&self) -> &'static str {
"spaces.add_item"
}
async fn validate(
&self,
_library: std::sync::Arc<crate::library::Library>,
_context: std::sync::Arc<CoreContext>,
) -> ActionResult<()> {
Ok(())
}
}
crate::register_library_action!(AddItemAction, "spaces.add_item");

View File

@@ -0,0 +1,10 @@
use crate::domain::ItemType;
use serde::{Deserialize, Serialize};
use specta::Type;
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
pub struct AddItemInput {
pub group_id: Uuid,
pub item_type: ItemType,
}

View File

@@ -0,0 +1,7 @@
pub mod action;
pub mod input;
pub mod output;
pub use action::*;
pub use input::*;
pub use output::*;

View File

@@ -0,0 +1,8 @@
use crate::domain::SpaceItem;
use serde::{Deserialize, Serialize};
use specta::Type;
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
pub struct AddItemOutput {
pub item: SpaceItem,
}

View File

@@ -0,0 +1,101 @@
use super::{input::SpaceCreateInput, output::SpaceCreateOutput};
use crate::{
context::CoreContext,
domain::Space,
infra::action::{
error::{ActionError, ActionResult},
LibraryAction,
},
};
use chrono::Utc;
use sea_orm::{ActiveModelTrait, EntityTrait, QueryOrder, Set};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpaceCreateAction {
input: SpaceCreateInput,
}
impl SpaceCreateAction {
pub fn new(input: SpaceCreateInput) -> Self {
Self { input }
}
}
impl LibraryAction for SpaceCreateAction {
type Input = SpaceCreateInput;
type Output = SpaceCreateOutput;
fn from_input(input: SpaceCreateInput) -> Result<Self, String> {
// Validate input
if input.name.trim().is_empty() {
return Err("Space name cannot be empty".to_string());
}
if !Space::validate_color(&input.color) {
return Err("Invalid color format. Must be #RRGGBB".to_string());
}
Ok(SpaceCreateAction::new(input))
}
async fn execute(
self,
library: std::sync::Arc<crate::library::Library>,
_context: std::sync::Arc<CoreContext>,
) -> Result<Self::Output, ActionError> {
let db = library.db().conn();
// Get the current max order
let max_order = crate::infra::db::entities::space::Entity::find()
.order_by_desc(crate::infra::db::entities::space::Column::Order)
.one(db)
.await
.map_err(ActionError::SeaOrm)?
.map(|s| s.order)
.unwrap_or(-1);
let space_id = uuid::Uuid::new_v4();
let now = Utc::now();
// Create space entity
let active_model = crate::infra::db::entities::space::ActiveModel {
id: Set(0), // Auto-increment
uuid: Set(space_id),
name: Set(self.input.name.clone()),
icon: Set(self.input.icon.clone()),
color: Set(self.input.color.clone()),
order: Set(max_order + 1),
created_at: Set(now.into()),
updated_at: Set(now.into()),
};
let result = active_model.insert(db).await.map_err(ActionError::SeaOrm)?;
let space = Space {
id: result.uuid,
name: result.name,
icon: result.icon,
color: result.color,
order: result.order,
created_at: result.created_at.into(),
updated_at: result.updated_at.into(),
};
Ok(SpaceCreateOutput { space })
}
fn action_kind(&self) -> &'static str {
"spaces.create"
}
async fn validate(
&self,
_library: std::sync::Arc<crate::library::Library>,
_context: std::sync::Arc<CoreContext>,
) -> ActionResult<()> {
Ok(())
}
}
crate::register_library_action!(SpaceCreateAction, "spaces.create");

View File

@@ -0,0 +1,9 @@
use serde::{Deserialize, Serialize};
use specta::Type;
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
pub struct SpaceCreateInput {
pub name: String,
pub icon: String,
pub color: String,
}

View File

@@ -0,0 +1,7 @@
pub mod action;
pub mod input;
pub mod output;
pub use action::*;
pub use input::*;
pub use output::*;

View File

@@ -0,0 +1,8 @@
use crate::domain::Space;
use serde::{Deserialize, Serialize};
use specta::Type;
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
pub struct SpaceCreateOutput {
pub space: Space,
}

View File

@@ -0,0 +1,58 @@
use super::{input::SpaceDeleteInput, output::SpaceDeleteOutput};
use crate::{
context::CoreContext,
infra::action::{
error::{ActionError, ActionResult},
LibraryAction,
},
};
use sea_orm::{ColumnTrait, EntityTrait, ModelTrait, QueryFilter};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpaceDeleteAction {
input: SpaceDeleteInput,
}
impl LibraryAction for SpaceDeleteAction {
type Input = SpaceDeleteInput;
type Output = SpaceDeleteOutput;
fn from_input(input: SpaceDeleteInput) -> Result<Self, String> {
Ok(Self { input })
}
async fn execute(
self,
library: std::sync::Arc<crate::library::Library>,
_context: std::sync::Arc<CoreContext>,
) -> Result<Self::Output, ActionError> {
let db = library.db().conn();
let space_model = crate::infra::db::entities::space::Entity::find()
.filter(crate::infra::db::entities::space::Column::Uuid.eq(self.input.space_id))
.one(db)
.await
.map_err(ActionError::SeaOrm)?
.ok_or_else(|| ActionError::Internal(format!("Space {} not found", self.input.space_id)))?;
// Delete will cascade to groups and items due to foreign key constraints
space_model.delete(db).await.map_err(ActionError::SeaOrm)?;
Ok(SpaceDeleteOutput { success: true })
}
fn action_kind(&self) -> &'static str {
"spaces.delete"
}
async fn validate(
&self,
_library: std::sync::Arc<crate::library::Library>,
_context: std::sync::Arc<CoreContext>,
) -> ActionResult<()> {
Ok(())
}
}
crate::register_library_action!(SpaceDeleteAction, "spaces.delete");

View File

@@ -0,0 +1,8 @@
use serde::{Deserialize, Serialize};
use specta::Type;
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
pub struct SpaceDeleteInput {
pub space_id: Uuid,
}

View File

@@ -0,0 +1,7 @@
pub mod action;
pub mod input;
pub mod output;
pub use action::*;
pub use input::*;
pub use output::*;

View File

@@ -0,0 +1,7 @@
use serde::{Deserialize, Serialize};
use specta::Type;
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
pub struct SpaceDeleteOutput {
pub success: bool,
}

View File

@@ -0,0 +1,58 @@
use super::{input::DeleteGroupInput, output::DeleteGroupOutput};
use crate::{
context::CoreContext,
infra::action::{
error::{ActionError, ActionResult},
LibraryAction,
},
};
use sea_orm::{ColumnTrait, EntityTrait, ModelTrait, QueryFilter};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeleteGroupAction {
input: DeleteGroupInput,
}
impl LibraryAction for DeleteGroupAction {
type Input = DeleteGroupInput;
type Output = DeleteGroupOutput;
fn from_input(input: DeleteGroupInput) -> Result<Self, String> {
Ok(Self { input })
}
async fn execute(
self,
library: std::sync::Arc<crate::library::Library>,
_context: std::sync::Arc<CoreContext>,
) -> Result<Self::Output, ActionError> {
let db = library.db().conn();
let group_model = crate::infra::db::entities::space_group::Entity::find()
.filter(crate::infra::db::entities::space_group::Column::Uuid.eq(self.input.group_id))
.one(db)
.await
.map_err(ActionError::SeaOrm)?
.ok_or_else(|| ActionError::Internal(format!("Group {} not found", self.input.group_id)))?;
// Delete will cascade to items due to foreign key constraints
group_model.delete(db).await.map_err(ActionError::SeaOrm)?;
Ok(DeleteGroupOutput { success: true })
}
fn action_kind(&self) -> &'static str {
"spaces.delete_group"
}
async fn validate(
&self,
_library: std::sync::Arc<crate::library::Library>,
_context: std::sync::Arc<CoreContext>,
) -> ActionResult<()> {
Ok(())
}
}
crate::register_library_action!(DeleteGroupAction, "spaces.delete_group");

View File

@@ -0,0 +1,8 @@
use serde::{Deserialize, Serialize};
use specta::Type;
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
pub struct DeleteGroupInput {
pub group_id: Uuid,
}

View File

@@ -0,0 +1,7 @@
pub mod action;
pub mod input;
pub mod output;
pub use action::*;
pub use input::*;
pub use output::*;

View File

@@ -0,0 +1,7 @@
use serde::{Deserialize, Serialize};
use specta::Type;
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
pub struct DeleteGroupOutput {
pub success: bool,
}

View File

@@ -0,0 +1,57 @@
use super::{input::DeleteItemInput, output::DeleteItemOutput};
use crate::{
context::CoreContext,
infra::action::{
error::{ActionError, ActionResult},
LibraryAction,
},
};
use sea_orm::{ColumnTrait, EntityTrait, ModelTrait, QueryFilter};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeleteItemAction {
input: DeleteItemInput,
}
impl LibraryAction for DeleteItemAction {
type Input = DeleteItemInput;
type Output = DeleteItemOutput;
fn from_input(input: DeleteItemInput) -> Result<Self, String> {
Ok(Self { input })
}
async fn execute(
self,
library: std::sync::Arc<crate::library::Library>,
_context: std::sync::Arc<CoreContext>,
) -> Result<Self::Output, ActionError> {
let db = library.db().conn();
let item_model = crate::infra::db::entities::space_item::Entity::find()
.filter(crate::infra::db::entities::space_item::Column::Uuid.eq(self.input.item_id))
.one(db)
.await
.map_err(ActionError::SeaOrm)?
.ok_or_else(|| ActionError::Internal(format!("Item {} not found", self.input.item_id)))?;
item_model.delete(db).await.map_err(ActionError::SeaOrm)?;
Ok(DeleteItemOutput { success: true })
}
fn action_kind(&self) -> &'static str {
"spaces.delete_item"
}
async fn validate(
&self,
_library: std::sync::Arc<crate::library::Library>,
_context: std::sync::Arc<CoreContext>,
) -> ActionResult<()> {
Ok(())
}
}
crate::register_library_action!(DeleteItemAction, "spaces.delete_item");

View File

@@ -0,0 +1,8 @@
use serde::{Deserialize, Serialize};
use specta::Type;
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
pub struct DeleteItemInput {
pub item_id: Uuid,
}

View File

@@ -0,0 +1,7 @@
pub mod action;
pub mod input;
pub mod output;
pub use action::*;
pub use input::*;
pub use output::*;

View File

@@ -0,0 +1,7 @@
use serde::{Deserialize, Serialize};
use specta::Type;
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
pub struct DeleteItemOutput {
pub success: bool,
}

View File

@@ -0,0 +1,5 @@
pub mod output;
pub mod query;
pub use output::*;
pub use query::*;

View File

@@ -0,0 +1,8 @@
use crate::domain::Space;
use serde::{Deserialize, Serialize};
use specta::Type;
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
pub struct SpaceGetOutput {
pub space: Space,
}

View File

@@ -0,0 +1,69 @@
use super::output::SpaceGetOutput;
use crate::domain::Space;
use crate::infra::query::{QueryError, QueryResult};
use crate::{context::CoreContext, infra::query::LibraryQuery};
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::sync::Arc;
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
pub struct SpaceGetQueryInput {
pub space_id: Uuid,
}
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
pub struct SpaceGetQuery {
space_id: Uuid,
}
impl LibraryQuery for SpaceGetQuery {
type Input = SpaceGetQueryInput;
type Output = SpaceGetOutput;
fn from_input(input: Self::Input) -> QueryResult<Self> {
Ok(Self {
space_id: input.space_id,
})
}
async fn execute(
self,
context: Arc<CoreContext>,
session: crate::infra::api::SessionContext,
) -> QueryResult<Self::Output> {
let library_id = session
.current_library_id
.ok_or_else(|| QueryError::Internal("No library selected".to_string()))?;
let library = context
.libraries()
.await
.get_library(library_id)
.await
.ok_or_else(|| QueryError::Internal("Library not found".to_string()))?;
let db = library.db().conn();
let space_model = crate::infra::db::entities::space::Entity::find()
.filter(crate::infra::db::entities::space::Column::Uuid.eq(self.space_id))
.one(db)
.await?
.ok_or_else(|| QueryError::Internal(format!("Space {} not found", self.space_id)))?;
let space = Space {
id: space_model.uuid,
name: space_model.name,
icon: space_model.icon,
color: space_model.color,
order: space_model.order,
created_at: space_model.created_at.into(),
updated_at: space_model.updated_at.into(),
};
Ok(SpaceGetOutput { space })
}
}
crate::register_library_query!(SpaceGetQuery, "spaces.get");

View File

@@ -0,0 +1,5 @@
pub mod output;
pub mod query;
pub use output::*;
pub use query::*;

View File

@@ -0,0 +1,8 @@
use crate::domain::{SpaceLayout};
use serde::{Deserialize, Serialize};
use specta::Type;
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
pub struct SpaceLayoutOutput {
pub layout: SpaceLayout,
}

View File

@@ -0,0 +1,122 @@
use super::output::SpaceLayoutOutput;
use crate::domain::{GroupType, ItemType, Space, SpaceGroup, SpaceGroupWithItems, SpaceItem, SpaceLayout};
use crate::infra::query::{QueryError, QueryResult};
use crate::{context::CoreContext, infra::query::LibraryQuery};
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::sync::Arc;
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
pub struct SpaceLayoutQueryInput {
pub space_id: Uuid,
}
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
pub struct SpaceLayoutQuery {
space_id: Uuid,
}
impl LibraryQuery for SpaceLayoutQuery {
type Input = SpaceLayoutQueryInput;
type Output = SpaceLayoutOutput;
fn from_input(input: Self::Input) -> QueryResult<Self> {
Ok(Self {
space_id: input.space_id,
})
}
async fn execute(
self,
context: Arc<CoreContext>,
session: crate::infra::api::SessionContext,
) -> QueryResult<Self::Output> {
let library_id = session
.current_library_id
.ok_or_else(|| QueryError::Internal("No library selected".to_string()))?;
let library = context
.libraries()
.await
.get_library(library_id)
.await
.ok_or_else(|| QueryError::Internal("Library not found".to_string()))?;
let db = library.db().conn();
// Get space
let space_model = crate::infra::db::entities::space::Entity::find()
.filter(crate::infra::db::entities::space::Column::Uuid.eq(self.space_id))
.one(db)
.await?
.ok_or_else(|| QueryError::Internal(format!("Space {} not found", self.space_id)))?;
let space = Space {
id: space_model.uuid,
name: space_model.name,
icon: space_model.icon,
color: space_model.color,
order: space_model.order,
created_at: space_model.created_at.into(),
updated_at: space_model.updated_at.into(),
};
// Get groups for this space
let group_models = crate::infra::db::entities::space_group::Entity::find()
.filter(crate::infra::db::entities::space_group::Column::SpaceId.eq(space_model.id))
.order_by_asc(crate::infra::db::entities::space_group::Column::Order)
.all(db)
.await?;
let mut groups = Vec::new();
for group_model in group_models {
// Parse group_type JSON
let group_type: GroupType = serde_json::from_str(&group_model.group_type)
.map_err(|e| QueryError::Internal(format!("Failed to parse group_type: {}", e)))?;
let group = SpaceGroup {
id: group_model.uuid,
space_id: self.space_id,
name: group_model.name,
group_type,
is_collapsed: group_model.is_collapsed,
order: group_model.order,
created_at: group_model.created_at.into(),
};
// Get items for this group
let item_models = crate::infra::db::entities::space_item::Entity::find()
.filter(crate::infra::db::entities::space_item::Column::GroupId.eq(group_model.id))
.order_by_asc(crate::infra::db::entities::space_item::Column::Order)
.all(db)
.await?;
let mut items = Vec::new();
for item_model in item_models {
// Parse item_type JSON
let item_type: ItemType = serde_json::from_str(&item_model.item_type)
.map_err(|e| QueryError::Internal(format!("Failed to parse item_type: {}", e)))?;
items.push(SpaceItem {
id: item_model.uuid,
group_id: group_model.uuid,
item_type,
order: item_model.order,
created_at: item_model.created_at.into(),
});
}
groups.push(SpaceGroupWithItems { group, items });
}
let layout = SpaceLayout { space, groups };
Ok(SpaceLayoutOutput { layout })
}
}
crate::register_library_query!(SpaceLayoutQuery, "spaces.get_layout");

View File

@@ -0,0 +1,5 @@
pub mod output;
pub mod query;
pub use output::*;
pub use query::*;

View File

@@ -0,0 +1,8 @@
use crate::domain::{Space};
use serde::{Deserialize, Serialize};
use specta::Type;
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
pub struct SpacesListOutput {
pub spaces: Vec<Space>,
}

View File

@@ -0,0 +1,64 @@
use super::output::SpacesListOutput;
use crate::domain::Space;
use crate::infra::query::{QueryError, QueryResult};
use crate::{context::CoreContext, infra::query::LibraryQuery};
use sea_orm::{EntityTrait, QueryOrder};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::sync::Arc;
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
pub struct SpacesListQueryInput;
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
pub struct SpacesListQuery;
impl LibraryQuery for SpacesListQuery {
type Input = SpacesListQueryInput;
type Output = SpacesListOutput;
fn from_input(_input: Self::Input) -> QueryResult<Self> {
Ok(Self {})
}
async fn execute(
self,
context: Arc<CoreContext>,
session: crate::infra::api::SessionContext,
) -> QueryResult<Self::Output> {
let library_id = session
.current_library_id
.ok_or_else(|| QueryError::Internal("No library selected".to_string()))?;
let library = context
.libraries()
.await
.get_library(library_id)
.await
.ok_or_else(|| QueryError::Internal("Library not found".to_string()))?;
let db = library.db().conn();
let space_models = crate::infra::db::entities::space::Entity::find()
.order_by_asc(crate::infra::db::entities::space::Column::Order)
.all(db)
.await?;
let spaces = space_models
.into_iter()
.map(|model| Space {
id: model.uuid,
name: model.name,
icon: model.icon,
color: model.color,
order: model.order,
created_at: model.created_at.into(),
updated_at: model.updated_at.into(),
})
.collect();
Ok(SpacesListOutput { spaces })
}
}
crate::register_library_query!(SpacesListQuery, "spaces.list");

View File

@@ -0,0 +1,29 @@
//! Space operations
//!
//! Queries and actions for managing Spaces, SpaceGroups, and SpaceItems
pub mod add_group;
pub mod add_item;
pub mod create;
pub mod delete;
pub mod delete_group;
pub mod delete_item;
pub mod get;
pub mod get_layout;
pub mod list;
pub mod reorder;
pub mod update;
pub mod update_group;
pub use add_group::*;
pub use add_item::*;
pub use create::*;
pub use delete::*;
pub use delete_group::*;
pub use delete_item::*;
pub use get::*;
pub use get_layout::*;
pub use list::*;
pub use reorder::*;
pub use update::*;
pub use update_group::*;

View File

@@ -0,0 +1,118 @@
use super::{
input::{ReorderGroupsInput, ReorderItemsInput},
output::ReorderOutput,
};
use crate::{
context::CoreContext,
infra::action::{
error::{ActionError, ActionResult},
LibraryAction,
},
};
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReorderGroupsAction {
input: ReorderGroupsInput,
}
impl LibraryAction for ReorderGroupsAction {
type Input = ReorderGroupsInput;
type Output = ReorderOutput;
fn from_input(input: ReorderGroupsInput) -> Result<Self, String> {
Ok(Self { input })
}
async fn execute(
self,
library: std::sync::Arc<crate::library::Library>,
_context: std::sync::Arc<CoreContext>,
) -> Result<Self::Output, ActionError> {
let db = library.db().conn();
// Update order for each group
for (index, group_id) in self.input.group_ids.iter().enumerate() {
let group_model = crate::infra::db::entities::space_group::Entity::find()
.filter(crate::infra::db::entities::space_group::Column::Uuid.eq(*group_id))
.one(db)
.await
.map_err(ActionError::SeaOrm)?
.ok_or_else(|| ActionError::Internal(format!("Group {} not found", group_id)))?;
let mut active_model: crate::infra::db::entities::space_group::ActiveModel =
group_model.into();
active_model.order = Set(index as i32);
active_model.update(db).await.map_err(ActionError::SeaOrm)?;
}
Ok(ReorderOutput { success: true })
}
fn action_kind(&self) -> &'static str {
"spaces.reorder_groups"
}
async fn validate(
&self,
_library: std::sync::Arc<crate::library::Library>,
_context: std::sync::Arc<CoreContext>,
) -> ActionResult<()> {
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReorderItemsAction {
input: ReorderItemsInput,
}
impl LibraryAction for ReorderItemsAction {
type Input = ReorderItemsInput;
type Output = ReorderOutput;
fn from_input(input: ReorderItemsInput) -> Result<Self, String> {
Ok(Self { input })
}
async fn execute(
self,
library: std::sync::Arc<crate::library::Library>,
_context: std::sync::Arc<CoreContext>,
) -> Result<Self::Output, ActionError> {
let db = library.db().conn();
// Update order for each item
for (index, item_id) in self.input.item_ids.iter().enumerate() {
let item_model = crate::infra::db::entities::space_item::Entity::find()
.filter(crate::infra::db::entities::space_item::Column::Uuid.eq(*item_id))
.one(db)
.await
.map_err(ActionError::SeaOrm)?
.ok_or_else(|| ActionError::Internal(format!("Item {} not found", item_id)))?;
let mut active_model: crate::infra::db::entities::space_item::ActiveModel =
item_model.into();
active_model.order = Set(index as i32);
active_model.update(db).await.map_err(ActionError::SeaOrm)?;
}
Ok(ReorderOutput { success: true })
}
fn action_kind(&self) -> &'static str {
"spaces.reorder_items"
}
async fn validate(
&self,
_library: std::sync::Arc<crate::library::Library>,
_context: std::sync::Arc<CoreContext>,
) -> ActionResult<()> {
Ok(())
}
}
crate::register_library_action!(ReorderGroupsAction, "spaces.reorder_groups");
crate::register_library_action!(ReorderItemsAction, "spaces.reorder_items");

View File

@@ -0,0 +1,15 @@
use serde::{Deserialize, Serialize};
use specta::Type;
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
pub struct ReorderGroupsInput {
pub space_id: Uuid,
pub group_ids: Vec<Uuid>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
pub struct ReorderItemsInput {
pub group_id: Uuid,
pub item_ids: Vec<Uuid>,
}

View File

@@ -0,0 +1,7 @@
pub mod action;
pub mod input;
pub mod output;
pub use action::*;
pub use input::*;
pub use output::*;

View File

@@ -0,0 +1,7 @@
use serde::{Deserialize, Serialize};
use specta::Type;
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
pub struct ReorderOutput {
pub success: bool,
}

View File

@@ -0,0 +1,97 @@
use super::{input::SpaceUpdateInput, output::SpaceUpdateOutput};
use crate::{
context::CoreContext,
domain::Space,
infra::action::{
error::{ActionError, ActionResult},
LibraryAction,
},
};
use chrono::Utc;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpaceUpdateAction {
input: SpaceUpdateInput,
}
impl LibraryAction for SpaceUpdateAction {
type Input = SpaceUpdateInput;
type Output = SpaceUpdateOutput;
fn from_input(input: SpaceUpdateInput) -> Result<Self, String> {
if let Some(ref name) = input.name {
if name.trim().is_empty() {
return Err("Space name cannot be empty".to_string());
}
}
if let Some(ref color) = input.color {
if !Space::validate_color(color) {
return Err("Invalid color format. Must be #RRGGBB".to_string());
}
}
Ok(Self { input })
}
async fn execute(
self,
library: std::sync::Arc<crate::library::Library>,
_context: std::sync::Arc<CoreContext>,
) -> Result<Self::Output, ActionError> {
let db = library.db().conn();
let space_model = crate::infra::db::entities::space::Entity::find()
.filter(crate::infra::db::entities::space::Column::Uuid.eq(self.input.space_id))
.one(db)
.await
.map_err(ActionError::SeaOrm)?
.ok_or_else(|| ActionError::Internal(format!("Space {} not found", self.input.space_id)))?;
let mut active_model: crate::infra::db::entities::space::ActiveModel = space_model.into();
if let Some(name) = self.input.name {
active_model.name = Set(name);
}
if let Some(icon) = self.input.icon {
active_model.icon = Set(icon);
}
if let Some(color) = self.input.color {
active_model.color = Set(color);
}
active_model.updated_at = Set(Utc::now().into());
let result = active_model.update(db).await.map_err(ActionError::SeaOrm)?;
let space = Space {
id: result.uuid,
name: result.name,
icon: result.icon,
color: result.color,
order: result.order,
created_at: result.created_at.into(),
updated_at: result.updated_at.into(),
};
Ok(SpaceUpdateOutput { space })
}
fn action_kind(&self) -> &'static str {
"spaces.update"
}
async fn validate(
&self,
_library: std::sync::Arc<crate::library::Library>,
_context: std::sync::Arc<CoreContext>,
) -> ActionResult<()> {
Ok(())
}
}
crate::register_library_action!(SpaceUpdateAction, "spaces.update");

View File

@@ -0,0 +1,11 @@
use serde::{Deserialize, Serialize};
use specta::Type;
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
pub struct SpaceUpdateInput {
pub space_id: Uuid,
pub name: Option<String>,
pub icon: Option<String>,
pub color: Option<String>,
}

View File

@@ -0,0 +1,7 @@
pub mod action;
pub mod input;
pub mod output;
pub use action::*;
pub use input::*;
pub use output::*;

View File

@@ -0,0 +1,8 @@
use crate::domain::Space;
use serde::{Deserialize, Serialize};
use specta::Type;
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
pub struct SpaceUpdateOutput {
pub space: Space,
}

View File

@@ -0,0 +1,95 @@
use super::{input::UpdateGroupInput, output::UpdateGroupOutput};
use crate::{
context::CoreContext,
domain::{GroupType, SpaceGroup},
infra::action::{
error::{ActionError, ActionResult},
LibraryAction,
},
};
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateGroupAction {
input: UpdateGroupInput,
}
impl LibraryAction for UpdateGroupAction {
type Input = UpdateGroupInput;
type Output = UpdateGroupOutput;
fn from_input(input: UpdateGroupInput) -> Result<Self, String> {
if let Some(ref name) = input.name {
if name.trim().is_empty() {
return Err("Group name cannot be empty".to_string());
}
}
Ok(Self { input })
}
async fn execute(
self,
library: std::sync::Arc<crate::library::Library>,
_context: std::sync::Arc<CoreContext>,
) -> Result<Self::Output, ActionError> {
let db = library.db().conn();
let group_model = crate::infra::db::entities::space_group::Entity::find()
.filter(crate::infra::db::entities::space_group::Column::Uuid.eq(self.input.group_id))
.one(db)
.await
.map_err(ActionError::SeaOrm)?
.ok_or_else(|| ActionError::Internal(format!("Group {} not found", self.input.group_id)))?;
let mut active_model: crate::infra::db::entities::space_group::ActiveModel = group_model.clone().into();
if let Some(name) = self.input.name {
active_model.name = Set(name);
}
if let Some(is_collapsed) = self.input.is_collapsed {
active_model.is_collapsed = Set(is_collapsed);
}
let result = active_model.update(db).await.map_err(ActionError::SeaOrm)?;
// Parse group_type from JSON
let group_type: GroupType = serde_json::from_str(&result.group_type)
.map_err(|e| ActionError::Internal(format!("Failed to parse group_type: {}", e)))?;
// Get space UUID
let space_model = crate::infra::db::entities::space::Entity::find_by_id(result.space_id)
.one(db)
.await
.map_err(ActionError::SeaOrm)?
.ok_or_else(|| ActionError::Internal("Space not found for group".to_string()))?;
let group = SpaceGroup {
id: result.uuid,
space_id: space_model.uuid,
name: result.name,
group_type,
is_collapsed: result.is_collapsed,
order: result.order,
created_at: result.created_at.into(),
};
Ok(UpdateGroupOutput { group })
}
fn action_kind(&self) -> &'static str {
"spaces.update_group"
}
async fn validate(
&self,
_library: std::sync::Arc<crate::library::Library>,
_context: std::sync::Arc<CoreContext>,
) -> ActionResult<()> {
Ok(())
}
}
crate::register_library_action!(UpdateGroupAction, "spaces.update_group");

View File

@@ -0,0 +1,10 @@
use serde::{Deserialize, Serialize};
use specta::Type;
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
pub struct UpdateGroupInput {
pub group_id: Uuid,
pub name: Option<String>,
pub is_collapsed: Option<bool>,
}

View File

@@ -0,0 +1,7 @@
pub mod action;
pub mod input;
pub mod output;
pub use action::*;
pub use input::*;
pub use output::*;

View File

@@ -0,0 +1,8 @@
use crate::domain::SpaceGroup;
use serde::{Deserialize, Serialize};
use specta::Type;
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
pub struct UpdateGroupOutput {
pub group: SpaceGroup,
}